diff --git a/.editorconfig b/.editorconfig index 9778c7012d1..f706f5dee15 100644 --- a/.editorconfig +++ b/.editorconfig @@ -6,3 +6,4 @@ ktlint_standard_no-wildcard-imports = enabled ktlint_standard_trailing-comma-on-call-site = enabled ktlint_standard_trailing-comma-on-declaration-site = enabled ktlint_standard_import-ordering = enabled +ktlint_experimental_no-unused-imports = enabled diff --git a/.github/dependabot.yml b/.github/dependabot.yml index b75843dd40d..e72b95fcd40 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -19,8 +19,12 @@ updates: update-types: ["version-update:semver-major", "version-update:semver-minor"] - dependency-name: "com.squareup.okhttp3:*" - dependency-name: "org.hisp.dhis.mobile:designsystem" - - dependency-name: "org.hisp.dhis:android-core" + - dependency-name: "org.hisp.dhis.rules:rule-engine-jvm" + - dependency-name: "org.hisp.dhis.lib.expression:expression-parser-jvm" groups: + dhis2-android-core: + patterns: + - "org.hisp.dhis:android-core" gradle-updates: patterns: - "*" # Group all Gradle updates into one PR diff --git a/.github/workflows/build-release-candidate.yml b/.github/workflows/build-release-candidate.yml index 17dd450f794..2255793e356 100644 --- a/.github/workflows/build-release-candidate.yml +++ b/.github/workflows/build-release-candidate.yml @@ -45,7 +45,7 @@ jobs: encodedString: ${{ secrets.KEYSTORE }} - name: Build Release APKs - run: ./gradlew app:assembleDhis2Release app:assembleDhis2PlayServicesRelease + run: ./gradlew --dependency-verification lenient app:assembleDhis2Release app:assembleDhis2PlayServicesRelease env: SENTRY_DSN: ${{ secrets.SENTRY_DSN }} SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} @@ -69,7 +69,7 @@ jobs: TRAINING_KEY_PASSWORD: ${{ secrets.TRAINING_KEY_PASSWORD }} TRAINING_STORE_PASSWORD: ${{ secrets.TRAINING_STORE_PASSWORD }} TRAINING_STORE_FILE: ${{ steps.decode_training_keystore.outputs.filePath }} - run: ./gradlew app:assembleDhis2TrainingRelease + run: ./gradlew --dependency-verification lenient app:assembleDhis2TrainingRelease - name: Read version name from file working-directory: ./gradle @@ -78,21 +78,21 @@ jobs: # Upload DhisRelease APK - name: Upload DhisRelease APK - uses: actions/upload-artifact@v6.0.0 + uses: actions/upload-artifact@v7.0.0 with: name: ${{ env.repository_name }} - DhisRelease APK path: ${{ env.main_project_module }}/build/outputs/apk/dhis2/release/dhis2-v${{ steps.read-version.outputs.vName }}.apk # Upload DhisPlayServicesRelease APK - name: Upload DhisPlayServicesRelease APK - uses: actions/upload-artifact@v6.0.0 + uses: actions/upload-artifact@v7.0.0 with: name: ${{ env.repository_name }} - DhisPlayServicesRelease APK path: ${{ env.main_project_module }}/build/outputs/apk/dhis2PlayServices/release/dhis2-v${{ steps.read-version.outputs.vName }}-googlePlay.apk # Upload Training Release APK - name: Upload Training Release APK - uses: actions/upload-artifact@v6.0.0 + uses: actions/upload-artifact@v7.0.0 with: name: ${{ env.repository_name }} - Training Release APK path: ${{ env.main_project_module }}/build/outputs/apk/dhis2Training/release/dhis2-v${{ steps.read-version.outputs.vName }}-training.apk \ No newline at end of file diff --git a/.github/workflows/continuous-delivery.yml b/.github/workflows/continuous-delivery.yml index e156128ed9e..0915084b71a 100644 --- a/.github/workflows/continuous-delivery.yml +++ b/.github/workflows/continuous-delivery.yml @@ -42,20 +42,34 @@ jobs: - name: Change wrapper permissions run: chmod +x ./gradlew + - name: Decode Keystore + id: decode_keystore + uses: timheuer/base64-to-file@784a1a4a994315802b7d8e2084e116e783d157be + with: + fileName: 'debug.keystore' + encodedString: ${{ secrets.DEBUG_KEYSTORE_BASE64 }} + # Create APK Debug - name: Build apk debug project (APK) - ${{ env.main_project_module }} module - run: ./gradlew assembleDhis2Debug + run: ./gradlew --dependency-verification lenient assembleDhis2Debug env: SENTRY_DSN: ${{ secrets.SENTRY_DSN }} - - - name: Read version name from file - working-directory: ./gradle - id: read-version - run: echo "vName=$(grep 'vName' libs.versions.toml | awk -F' = ' '{print $2}' | tr -d '"')" >> "$GITHUB_OUTPUT" + DEBUG_KEYSTORE_ALIAS: ${{ secrets.DEBUG_KEYSTORE_ALIAS }} + DEBUG_KEY_PASS: ${{ secrets.DEBUG_KEY_PASS }} + DEBUG_KEYSTORE_PASSWORD: ${{ secrets.DEBUG_KEYSTORE_PASSWORD }} + DEBUG_KEYSTORE_PATH: ${{ steps.decode_keystore.outputs.filePath }} + + - name: Locate built APK + id: apk-path + run: | + echo "Searching for APKs under ${{ env.main_project_module }}/build/outputs/apk" + find "${{ env.main_project_module }}/build/outputs/apk" -type f -name "*.apk" + APK_PATH=$(find "${{ env.main_project_module }}/build/outputs/apk" -type f -name "*.apk" -print -quit) + echo "apk_path=$APK_PATH" >> "$GITHUB_OUTPUT" # Upload Artifact Build - name: Upload Android artifacts - uses: actions/upload-artifact@v6.0.0 + uses: actions/upload-artifact@v7.0.0 with: name: ${{ env.repository_name }} - Android APK - ${{ steps.date.outputs.date }} - path: ${{ env.main_project_module }}/build/outputs/apk/dhis2/debug/dhis2-v${{ steps.read-version.outputs.vName }}.apk + path: ${{ steps.apk-path.outputs.apk_path }} diff --git a/.github/workflows/deploy-release.yml b/.github/workflows/deploy-release.yml index fbd6b94d124..a34c3897cb4 100644 --- a/.github/workflows/deploy-release.yml +++ b/.github/workflows/deploy-release.yml @@ -49,7 +49,7 @@ jobs: encodedString: ${{ secrets.KEYSTORE }} - name: Build Release APKs - run: ./gradlew app:assembleDhis2Release app:assembleDhis2PlayServicesRelease + run: ./gradlew --dependency-verification lenient app:assembleDhis2Release app:assembleDhis2PlayServicesRelease env: SENTRY_DSN: ${{ secrets.SENTRY_DSN }} SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} @@ -73,7 +73,7 @@ jobs: TRAINING_KEY_PASSWORD: ${{ secrets.TRAINING_KEY_PASSWORD }} TRAINING_STORE_PASSWORD: ${{ secrets.TRAINING_STORE_PASSWORD }} TRAINING_STORE_FILE: ${{ steps.decode_training_keystore.outputs.filePath }} - run: ./gradlew app:assembleDhis2TrainingRelease + run: ./gradlew --dependency-verification lenient app:assembleDhis2TrainingRelease - name: Read version name from file working-directory: ./gradle diff --git a/.tx/config b/.tx/config index af74d17517a..dbefa2b2d9d 100644 --- a/.tx/config +++ b/.tx/config @@ -50,14 +50,6 @@ type = ANDROID minimum_perc = 0 resource_name = Main App -[o:hisp-uio:p:dhis2-android-capture-app:r:ui-strings-xml] -file_filter = ui-components/src/main/res/values-/strings.xml -source_file = ui-components/src/main/res/values/strings.xml -source_lang = en -type = ANDROID -minimum_perc = 0 -resource_name = UI Components - [o:hisp-uio:p:dhis2-android-capture-app:r:table-strings-xml] file_filter = compose-table/src/main/res/values-/strings.xml source_file = compose-table/src/main/res/values/strings.xml @@ -105,4 +97,3 @@ source_lang = en type = ANDROID minimum_perc = 0 resource_name = Sync - diff --git a/Jenkinsfile b/Jenkinsfile index de22090ec9f..10854742886 100644 --- a/Jenkinsfile +++ b/Jenkinsfile @@ -66,13 +66,18 @@ pipeline { } } stage('Unit tests') { + when { + expression { + return !isSkipUnitTest() + } + } environment { ANDROID_HOME = '/opt/android-sdk' } steps { script { echo 'Running unit tests' - sh './gradlew testDebugUnitTest testDhis2DebugUnitTest --stacktrace --no-daemon' + sh './gradlew --dependency-verification lenient testDebugUnitTest testDhis2DebugUnitTest testAndroidHostTest --stacktrace --no-daemon' } } } @@ -80,7 +85,7 @@ pipeline { steps { script { echo 'Building UI APKs' - sh './gradlew :app:assembleDhis2Debug :app:assembleDhis2DebugAndroidTest :form:assembleAndroidTest' + sh './gradlew --dependency-verification lenient :app:assembleDhis2Debug :app:assembleDhis2DebugAndroidTest :form:assembleAndroidTest' } } } @@ -202,3 +207,9 @@ def isSkipSizeCheck() { def prDescription = env.CHANGE_DESCRIPTION ?: "" return (prTitle.contains("[skip size]") || prDescription.contains("[skip size]")) } + +def isSkipUnitTest() { + def prTitle = env.CHANGE_TITLE ?: "" + def prDescription = env.CHANGE_DESCRIPTION ?: "" + return (prTitle.contains("[skip unitTest]") || prDescription.contains("[skip unitTest]")) +} diff --git a/README.md b/README.md index 1bc9fccc045..49d33cccdaa 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,31 @@ -# README # +# DHIS2 Android Capture App [![Lines of Code](https://sonarcloud.io/api/project_badges/measure?project=dhis2_dhis2-android-capture-app&metric=ncloc)](https://sonarcloud.io/summary/new_code?id=dhis2_dhis2-android-capture-app) [![Reliability Rating](https://sonarcloud.io/api/project_badges/measure?project=dhis2_dhis2-android-capture-app&metric=reliability_rating)](https://sonarcloud.io/summary/new_code?id=dhis2_dhis2-android-capture-app) -Check the [Wiki](https://github.com/dhis2/dhis2-android-capture-app/wiki) for information about how to build the project and its architecture **(WIP)** +## Introduction -### What is this repository for? ### +DHIS2 Android Capture App is a mobile application that allows offline data collection for DHIS2. It enables health workers and data collectors to access and update DHIS2 data from their mobile devices without requiring constant internet connectivity. -DHIS2 Android application. +Main features: + +- **Offline-first data capture**. Enter data for events, tracked entities, and data sets without an internet connection. Data is stored locally and synchronized when connectivity is available. +- **Complete DHIS2 workflows**. Support for Tracker programs, Event programs, and Data Sets with validation rules, program rules, and indicators. +- **Native mobile experience**. Built with modern Android technologies to provide a fast, intuitive, and reliable user experience. +- **Synchronized with DHIS2**. Seamlessly integrates with DHIS2 server instances, ensuring data consistency and compatibility across DHIS2 versions. + +## Documentation + +Implementation guidance and user documentation can be found in the [Android Implementation Guide](https://docs.dhis2.org/en/implement/android-implementation/executive-summary.html) in DHIS2 documentation. + +Developer-oriented documentation and architecture details can be found in [AGENTS.md](AGENTS.md). + +## Getting Started + +This app is built using the [DHIS2 Android SDK](https://github.com/dhis2/dhis2-android-sdk) and the [DHIS2 Mobile UI library](https://developers.dhis2.org/docs/mobile/mobile-ui/overview). To get started with development, check the [AGENTS.md](AGENTS.md) file for comprehensive development guidelines, architecture patterns, and best practices. + +## Community + +Community support can be found in the [DHIS2 Community portal](https://community.dhis2.org/c/implementation/mobile). Any feedback on the app will be highly appreciated. + +To report bugs or request new features, visit the [DHIS2 Jira project](https://jira.dhis2.org/projects/ANDROAPP/issues). diff --git a/RELEASE.md b/RELEASE.md index ed20a609629..b566936cbc9 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -1,75 +1,58 @@ -# Release notes - Android App for DHIS2 - 3.3.1 +# Release notes - Android App for DHIS2 - 3.4.0 -### Bug +### NEW FUNCTIONALITY AND WEB PARITY -[ANDROAPP-6870](https://dhis2.atlassian.net/browse/ANDROAPP-6870) Let the rule-engine apply the logic for useCodeForOptionSet in RuleVariable +#### Tracked Entity Search Performance Configuration: +This feature enables the configuration of the search operators for the different Tracked Entity Attributes to improve search performance. -[ANDROAPP-6975](https://dhis2.atlassian.net/browse/ANDROAPP-6975) Crash when rotating the device with the save dialog open +Tracked entity attributes can now define a preferred default search operator. This configuration is set in the Maintenance app and interpreted by the Capture Web and Android applications, third party clients can also use the recommended operator when performing searches. Specific operators can also be restricted to protect system performance. -[ANDROAPP-7211](https://dhis2.atlassian.net/browse/ANDROAPP-7211) NoSuchElementException: Collection contains no element matching the predicate. +Sensible defaults are automatically applied to guide efficient queries. The LIKE operator—commonly associated with slow performance—is no longer selected by default; instead, EQUALS or other more efficient operators are pre-selected -[ANDROAPP-7235](https://dhis2.atlassian.net/browse/ANDROAPP-7235) Program rules not triggered when moving between fields manually +To further optimize tracked entity instance (TEI) searches, this feature adds support for specifying a minimum number of characters required for searching and for enabling trigram indexing. -[ANDROAPP-7260](https://dhis2.atlassian.net/browse/ANDROAPP-7260) Incorrect behavior when creating a new event in timeline view +[Jira](https://dhis2.atlassian.net/browse/ROADMAP-128) | [Feature card](https://s3.eu-west-1.amazonaws.com/content.dhis2.org/releases/screenshots/43/feature-cards/43-search-performance-operators-combo.png) -[ANDROAPP-7261](https://dhis2.atlassian.net/browse/ANDROAPP-7261) Keyboard blocks the last field when entering data \(screen doesn’t scroll\) -[ANDROAPP-7269](https://dhis2.atlassian.net/browse/ANDROAPP-7269) Crash on search +#### DHIS2 Custom Theme: +DHIS2 version 43 now supports changing the theme color of your DHIS2 instance. This is done via the theme color setting under the Appearance tab in the Settings app. The color picker has a curated set of preset colors to choose from, or the user can enter a specific RGB value. The selected color is applied consistently across the headerbar on the entire instance, as well as on the Android app. A contrast algorithm automatically adjusts the text and icon color to maintain legibility against the selected background. Removing the color reverts the instance to the default blue color. The android style setting is restricted to v42 and below. -[ANDROAPP-7293](https://dhis2.atlassian.net/browse/ANDROAPP-7293) Bottom sheet landscape behavior +[Jira](https://dhis2.atlassian.net/browse/ROADMAP-622) | [Feature card](https://s3.eu-west-1.amazonaws.com/content.dhis2.org/releases/screenshots/43/feature-cards/43-custom-color-combo-new.png) -[ANDROAPP-7345](https://dhis2.atlassian.net/browse/ANDROAPP-7345) Changes to enrollment date not respected by program rules \(version 3.2.1.2\) +#### Markdown and legend support in Android Feedback Widget: +Feedback messages generated through display text and key-value pair actions can now include formatted text using Markdown, enabling the display of structured content such as titles, lists, and emphasized text. -[ANDROAPP-7368](https://dhis2.atlassian.net/browse/ANDROAPP-7368) crash: when trying to update fields in Tracker +In addition, support for legend-based styling has been introduced, allowing feedback values to be visually highlighted based on predefined legend sets. This enables dynamic color-coding of key values, helping users quickly interpret results and identify important conditions. -[ANDROAPP-7394](https://dhis2.atlassian.net/browse/ANDROAPP-7394) Login blocked after logout “The user is already logged in” error +[Jira]() | [Feature card]() -[ANDROAPP-7400](https://dhis2.atlassian.net/browse/ANDROAPP-7400) Crash - changing org unit and dates +#### Program rule priority for Actions: +Each program rule action can define an optional priority value. During rule evaluation, actions are first grouped based on their parent program rule, and then ordered by the611ir assigned priority. Actions with lower priority values are processed first, while actions without a defined priority are placed at the end. -[ANDROAPP-7402](https://dhis2.atlassian.net/browse/ANDROAPP-7402) Bottom sheet org unit not displaying buttons +This allows multiple related actions to be managed within a single program rule while still maintaining a clear and predictable execution order. -[ANDROAPP-7403](https://dhis2.atlassian.net/browse/ANDROAPP-7403) Home cards lose proper layout +[Jira](https://dhis2.atlassian.net/browse/ROADMAP-625) | [Feature card](https://s3.eu-west-1.amazonaws.com/content.dhis2.org/releases/screenshots/43/feature-cards/43-action-priority.png) -[ANDROAPP-7411](https://dhis2.atlassian.net/browse/ANDROAPP-7411) Android sync by working lists: After updating predefined list views settings, changes aren't reflected in android app after syncing -[ANDROAPP-7415](https://dhis2.atlassian.net/browse/ANDROAPP-7415) Android: WORKING LIST incorrect result in app +### USER EXPERIENCE -[ANDROAPP-7419](https://dhis2.atlassian.net/browse/ANDROAPP-7419) Server selection title is missing +#### Android Log In process improvements: +The new PIN design provides a more modern and consistent user experience during setup. This change is part of the broader work to improve authentication related screens and prepare the foundation for future enhancements -[ANDROAPP-7421](https://dhis2.atlassian.net/browse/ANDROAPP-7421) Data set table not opening after clicking next for default category combo +[Jira](https://dhis2.atlassian.net/browse/ROADMAP-618) | [Feature card](https://s3.eu-west-1.amazonaws.com/content.dhis2.org/releases/screenshots/43/feature-cards/Android-3-4-PIN-redesign-new.png) -[ANDROAPP-7425](https://dhis2.atlassian.net/browse/ANDROAPP-7425) NullPointerException: ProgramFragment +### PERFORMANCE & MAINTENANCE -[ANDROAPP-7428](https://dhis2.atlassian.net/browse/ANDROAPP-7428) LMIS program is using completed enrollment +#### Android Metadata Sync Improvements +Metadata synchronization has been enhanced with more frequency options. In addition to existing intervals, automatic metadata sync can now run every 6 or 12 hours, allowing for more timely updates and better alignment with data sync behavior. -[ANDROAPP-7442](https://dhis2.atlassian.net/browse/ANDROAPP-7442) Program rules not triggering for completed enrollments +As part of the improvements, the app also performs a daily background check to detect any changes in the configuration when the sync is set to "Manual". If a change is detected from manual to an automatic sync frequency, an immediate metadata sync is triggered and the corresponding schedule is applied. -### Task +[Jira](https://dhis2.atlassian.net/browse/ROADMAP-617) | [Feature card](https://s3.eu-west-1.amazonaws.com/content.dhis2.org/releases/screenshots/43/feature-cards/Android-3-4-new-sync-periods-new.png) -[ANDROAPP-7286](https://dhis2.atlassian.net/browse/ANDROAPP-7286) Replace deprecated categoryComboUid usages with categoryCombo in Program and DataElement +#### Improved Event ordering for consistent sync and calculations: +With this update, event ordering has been aligned across Web, Android, and API sources, allowing events to be processed in the correct sequence during synchronization and improving overall data consistency and reliability. -[ANDROAPP-7288](https://dhis2.atlassian.net/browse/ANDROAPP-7288) Implement UseCase interface +[Jira](https://dhis2.atlassian.net/browse/ROADMAP-619) | [Feature card](https://s3.eu-west-1.amazonaws.com/content.dhis2.org/releases/screenshots/43/feature-cards/43-stock-management-lmis.png) -[ANDROAPP-7318](https://dhis2.atlassian.net/browse/ANDROAPP-7318) Create AGENTS.md file -[ANDROAPP-7349](https://dhis2.atlassian.net/browse/ANDROAPP-7349) Sonarcloud - Use full commit SHA hash for this dependency. - -[ANDROAPP-7373](https://dhis2.atlassian.net/browse/ANDROAPP-7373) LogoutUser use case improvements - -[ANDROAPP-7384](https://dhis2.atlassian.net/browse/ANDROAPP-7384) Update transifex tracker configuration - -[ANDROAPP-7386](https://dhis2.atlassian.net/browse/ANDROAPP-7386) QA: Remove duplicated UI modules - -[ANDROAPP-7388](https://dhis2.atlassian.net/browse/ANDROAPP-7388) Create sync module - -[ANDROAPP-7395](https://dhis2.atlassian.net/browse/ANDROAPP-7395) Review settings repository data loading for log out request - -[ANDROAPP-7396](https://dhis2.atlassian.net/browse/ANDROAPP-7396) Remove and update usage to design systems' deprecated methods - -[ANDROAPP-7424](https://dhis2.atlassian.net/browse/ANDROAPP-7424) Gradle warnings: Remove RX binding dependency and zxing dependency - -[ANDROAPP-7426](https://dhis2.atlassian.net/browse/ANDROAPP-7426) Upload proguard mapping on Sentry - -[ANDROAPP-7440](https://dhis2.atlassian.net/browse/ANDROAPP-7440) Update Expression parser to 1.2.2 - -[ANDROAPP-7441](https://dhis2.atlassian.net/browse/ANDROAPP-7441) Remove username from Sentry reports \ No newline at end of file diff --git a/aggregates/build.gradle.kts b/aggregates/build.gradle.kts index 9eedb8161a5..b7e394b50bf 100644 --- a/aggregates/build.gradle.kts +++ b/aggregates/build.gradle.kts @@ -1,16 +1,22 @@ import kotlin.text.set plugins { - kotlin("multiplatform") + alias(libs.plugins.kotlin.multiplatform) alias(libs.plugins.compose) - id("com.android.library") + alias(libs.plugins.android.kotlin.multiplatform.library) alias(libs.plugins.kotlin.compose.compiler) } kotlin { - androidTarget { - compilerOptions { - jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17) + androidLibrary { + namespace = "org.dhis2.mobile.aggregates" + compileSdk = libs.versions.sdk.get().toInt() + minSdk = libs.versions.minSdk.get().toInt() + compilerOptions { jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17) } + androidResources { enable = true } + withHostTestBuilder {}.configure {} + withDeviceTestBuilder {}.configure { + instrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" } } @@ -65,9 +71,12 @@ kotlin { implementation(libs.koin.androidx.compose) implementation(project(":commons")) implementation(project(":dhis2_android_maps")) + compileOnly(libs.androidx.compose.uitooling) } - androidUnitTest.dependencies { } + getByName("androidHostTest") { + dependencies { implementation(libs.junit.jupiter) } + } val desktopMain by getting { dependencies { @@ -83,30 +92,6 @@ compose.resources { generateResClass = always } -android { - namespace = "org.dhis2.mobile.aggregates" - compileSdk = libs.versions.sdk.get().toInt() - - sourceSets["main"].manifest.srcFile("src/androidMain/AndroidManifest.xml") - sourceSets["main"].res.srcDirs("src/androidMain/res") - - defaultConfig { - minSdk = libs.versions.minSdk.get().toInt() - } - compileOptions { - isCoreLibraryDesugaringEnabled = true - - sourceCompatibility = JavaVersion.VERSION_17 - targetCompatibility = JavaVersion.VERSION_17 - } - - dependencies { - coreLibraryDesugaring(libs.desugar) - } -} - dependencies { - testImplementation(libs.junit.jupiter) - debugImplementation(libs.androidx.compose.preview) - debugImplementation(libs.androidx.compose.uitooling) -} \ No newline at end of file + coreLibraryDesugaring(libs.desugar) +} diff --git a/aggregates/src/androidUnitTest/kotlin/org/dhis2/mobile/aggregates/data/DataSetInstanceRepositoryImplTest.kt b/aggregates/src/androidHostTest/kotlin/org/dhis2/mobile/aggregates/data/DataSetInstanceRepositoryImplTest.kt similarity index 100% rename from aggregates/src/androidUnitTest/kotlin/org/dhis2/mobile/aggregates/data/DataSetInstanceRepositoryImplTest.kt rename to aggregates/src/androidHostTest/kotlin/org/dhis2/mobile/aggregates/data/DataSetInstanceRepositoryImplTest.kt diff --git a/aggregates/src/androidMain/kotlin/org/dhis2/mobile/aggregates/data/DataSetInstanceRepositoryImpl.kt b/aggregates/src/androidMain/kotlin/org/dhis2/mobile/aggregates/data/DataSetInstanceRepositoryImpl.kt index 66e74b898e7..eb0086a8113 100644 --- a/aggregates/src/androidMain/kotlin/org/dhis2/mobile/aggregates/data/DataSetInstanceRepositoryImpl.kt +++ b/aggregates/src/androidMain/kotlin/org/dhis2/mobile/aggregates/data/DataSetInstanceRepositoryImpl.kt @@ -179,6 +179,9 @@ internal class DataSetInstanceRepositoryImpl( DataSetNonEditableReason.EXPIRED -> NonEditableReason.Expired + + DataSetNonEditableReason.PERIOD_NOT_IN_DATA_INPUT_PERIODS -> + NonEditableReason.PeriodNotInDataInputPeriods } } ?: NonEditableReason.None, ) @@ -336,7 +339,10 @@ internal class DataSetInstanceRepositoryImpl( .blockingGet() ?.let { DataSetRenderingConfig( - useVerticalTabs = it.displayOptions()?.tabsDirection() == TabsDirection.VERTICAL, + useVerticalTabs = + it + .displayOptions() + ?.tabsDirection() == TabsDirection.VERTICAL, ) } ?: DataSetRenderingConfig( useVerticalTabs = true, @@ -415,11 +421,12 @@ internal class DataSetInstanceRepositoryImpl( .dataValueModule() .dataValues() .value( - periodId, - orgUnitUid, - dataElementUid, - categoryOptionComboUid, - attrOptionComboUid, + period = periodId, + organisationUnit = orgUnitUid, + dataElement = dataElementUid, + categoryOptionCombo = categoryOptionComboUid, + attributeOptionCombo = attrOptionComboUid, + sourceDataSet = dataSetUid, ).blockingGet() ?.syncState() @@ -817,6 +824,7 @@ internal class DataSetInstanceRepositoryImpl( orgUnitUid: String, categoryOptionComboUid: String, attrOptionComboUid: String, + sourceDataSetUid: String, ): Pair? { val dataElement = d2 @@ -847,6 +855,7 @@ internal class DataSetInstanceRepositoryImpl( dataElement = dataElementUid, categoryOptionCombo = categoryOptionComboUid, attributeOptionCombo = attrOptionComboUid, + sourceDataSet = sourceDataSetUid, ).blockingGet() ?.value() ?.toDoubleOrNull() @@ -982,6 +991,7 @@ internal class DataSetInstanceRepositoryImpl( attrOptionComboUid: String, dataElementUid: String, categoryOptionComboUid: String, + sourceDataSetUid: String, ) = d2 .dataValueModule() .dataValues() @@ -993,6 +1003,7 @@ internal class DataSetInstanceRepositoryImpl( dataElementUid, categoryOptionComboUid, attrOptionComboUid, + sourceDataSetUid, ).blockingGet() ?.value() @@ -1003,6 +1014,7 @@ internal class DataSetInstanceRepositoryImpl( dataElementUid: String, categoryOptionComboUid: String, value: String?, + sourceDataSetUid: String, ): Result { val valueRepository = d2 @@ -1014,6 +1026,7 @@ internal class DataSetInstanceRepositoryImpl( dataElement = dataElementUid, categoryOptionCombo = categoryOptionComboUid, attributeOptionCombo = attrOptionComboUid, + sourceDataSet = sourceDataSetUid, ) val dataElement = @@ -1257,6 +1270,7 @@ internal class DataSetInstanceRepositoryImpl( ValidationResultStatus.valueOf(result.status().name), mapViolations( violations = result.violations(), + dataSetUid = dataSetUid, periodId = periodId, orgUnitUid = orgUnitUid, attrOptionComboUid = attrOptionComboUid, @@ -1291,6 +1305,7 @@ internal class DataSetInstanceRepositoryImpl( private fun mapViolations( violations: List, + dataSetUid: String, periodId: String, orgUnitUid: String, attrOptionComboUid: String, @@ -1301,6 +1316,7 @@ internal class DataSetInstanceRepositoryImpl( it.validationRule().instruction(), mapDataElements( dataElementUids = it.dataElementUids(), + dataSetUid = dataSetUid, periodId = periodId, orgUnitUid = orgUnitUid, attrOptionComboUid = attrOptionComboUid, @@ -1310,6 +1326,7 @@ internal class DataSetInstanceRepositoryImpl( private fun mapDataElements( dataElementUids: Set, + dataSetUid: String, periodId: String, orgUnitUid: String, attrOptionComboUid: String, @@ -1348,21 +1365,23 @@ internal class DataSetInstanceRepositoryImpl( .dataValueModule() .dataValues() .value( - periodId, - orgUnitUid, - de.uid(), - catOptCombo.uid(), - attrOptionComboUid, + period = periodId, + organisationUnit = orgUnitUid, + dataElement = de.uid(), + categoryOptionCombo = catOptCombo.uid(), + attributeOptionCombo = attrOptionComboUid, + sourceDataSet = dataSetUid, ).blockingExists() && d2 .dataValueModule() .dataValues() .value( - periodId, - orgUnitUid, - de.uid(), - catOptCombo.uid(), - attrOptionComboUid, + period = periodId, + organisationUnit = orgUnitUid, + dataElement = de.uid(), + categoryOptionCombo = catOptCombo.uid(), + attributeOptionCombo = attrOptionComboUid, + sourceDataSet = dataSetUid, ).blockingGet() ?.deleted() != true ) { @@ -1370,11 +1389,12 @@ internal class DataSetInstanceRepositoryImpl( .dataValueModule() .dataValues() .value( - periodId, - orgUnitUid, - de.uid(), - catOptCombo.uid(), - attrOptionComboUid, + period = periodId, + organisationUnit = orgUnitUid, + dataElement = de.uid(), + categoryOptionCombo = catOptCombo.uid(), + attributeOptionCombo = attrOptionComboUid, + sourceDataSet = dataSetUid, ).blockingGet() ?.value() ?: "-" } else { diff --git a/aggregates/src/commonMain/composeResources/values-zh/strings.xml b/aggregates/src/commonMain/composeResources/values-zh/strings.xml index 9cf949ce08b..bb8bae0fbfa 100644 --- a/aggregates/src/commonMain/composeResources/values-zh/strings.xml +++ b/aggregates/src/commonMain/composeResources/values-zh/strings.xml @@ -72,6 +72,7 @@ %2$s is the attribute option combo name --> 您不能编辑 %2$s中的时段%1$s的数据。 + 该数据 不可编辑,因为所选期间不在数据 输入周期内 此数据不可编辑,因为已标记为关闭状态 该数据不可编辑,因为其编辑时间已过 diff --git a/aggregates/src/commonMain/composeResources/values/strings.xml b/aggregates/src/commonMain/composeResources/values/strings.xml index bc1831ca71d..7a7b077647f 100644 --- a/aggregates/src/commonMain/composeResources/values/strings.xml +++ b/aggregates/src/commonMain/composeResources/values/strings.xml @@ -72,6 +72,7 @@ %2$s is the attribute option combo name --> You cannot edit data for the period %1$s in %2$s + This data is not editable because the selected period is outside the data input period This data is not editable because it is marked as closed This data is not editable because its edition time has expired diff --git a/aggregates/src/commonMain/kotlin/org/dhis2/mobile/aggregates/data/DataSetInstanceRepository.kt b/aggregates/src/commonMain/kotlin/org/dhis2/mobile/aggregates/data/DataSetInstanceRepository.kt index b05c6f6d4db..9ef94b10437 100644 --- a/aggregates/src/commonMain/kotlin/org/dhis2/mobile/aggregates/data/DataSetInstanceRepository.kt +++ b/aggregates/src/commonMain/kotlin/org/dhis2/mobile/aggregates/data/DataSetInstanceRepository.kt @@ -87,6 +87,7 @@ internal interface DataSetInstanceRepository { attrOptionComboUid: String, dataElementUid: String, categoryOptionComboUid: String, + sourceDataSetUid: String, ): String? suspend fun updateValue( @@ -96,6 +97,7 @@ internal interface DataSetInstanceRepository { dataElementUid: String, categoryOptionComboUid: String, value: String?, + sourceDataSetUid: String, ): Result suspend fun categoryOptionComboFromCategoryOptions( @@ -165,6 +167,7 @@ internal interface DataSetInstanceRepository { orgUnitUid: String, categoryOptionComboUid: String, attrOptionComboUid: String, + sourceDataSetUid: String, ): Pair? suspend fun uploadFile( diff --git a/aggregates/src/commonMain/kotlin/org/dhis2/mobile/aggregates/domain/GetDataValueData.kt b/aggregates/src/commonMain/kotlin/org/dhis2/mobile/aggregates/domain/GetDataValueData.kt index 806d71cba6c..db2a48536d8 100644 --- a/aggregates/src/commonMain/kotlin/org/dhis2/mobile/aggregates/domain/GetDataValueData.kt +++ b/aggregates/src/commonMain/kotlin/org/dhis2/mobile/aggregates/domain/GetDataValueData.kt @@ -36,6 +36,7 @@ internal class GetDataValueData( orgUnitUid = orgUnitUid, attrOptionComboUid = attrOptionComboUid, categoryOptionComboUid = key.second, + sourceDataSetUid = datasetUid, )?.first, ) } diff --git a/aggregates/src/commonMain/kotlin/org/dhis2/mobile/aggregates/domain/GetDataValueInput.kt b/aggregates/src/commonMain/kotlin/org/dhis2/mobile/aggregates/domain/GetDataValueInput.kt index 10e579b42b6..0ae33c4e922 100644 --- a/aggregates/src/commonMain/kotlin/org/dhis2/mobile/aggregates/domain/GetDataValueInput.kt +++ b/aggregates/src/commonMain/kotlin/org/dhis2/mobile/aggregates/domain/GetDataValueInput.kt @@ -45,6 +45,7 @@ internal class GetDataValueInput( attrOptionComboUid = attrOptionComboUid, dataElementUid = dataElementUid, categoryOptionComboUid = categoryOptionComboUid, + sourceDataSetUid = dataSetUid, ) val conflicts = repository.conflicts( @@ -63,6 +64,7 @@ internal class GetDataValueInput( orgUnitUid = orgUnitUid, categoryOptionComboUid = categoryOptionComboUid, attrOptionComboUid = attrOptionComboUid, + sourceDataSetUid = dataSetUid, ) CellInfo( diff --git a/aggregates/src/commonMain/kotlin/org/dhis2/mobile/aggregates/domain/SetDataValue.kt b/aggregates/src/commonMain/kotlin/org/dhis2/mobile/aggregates/domain/SetDataValue.kt index 5ae00192639..d92058d2c90 100644 --- a/aggregates/src/commonMain/kotlin/org/dhis2/mobile/aggregates/domain/SetDataValue.kt +++ b/aggregates/src/commonMain/kotlin/org/dhis2/mobile/aggregates/domain/SetDataValue.kt @@ -25,6 +25,7 @@ internal class SetDataValue( dataElementUid = dataElementUid, categoryOptionComboUid = categoryOptionComboUid, value = value, + sourceDataSetUid = dataSetUid, ) } } diff --git a/aggregates/src/commonMain/kotlin/org/dhis2/mobile/aggregates/model/DataSetDetails.kt b/aggregates/src/commonMain/kotlin/org/dhis2/mobile/aggregates/model/DataSetDetails.kt index 6845e8b4baa..cdd01bb9875 100644 --- a/aggregates/src/commonMain/kotlin/org/dhis2/mobile/aggregates/model/DataSetDetails.kt +++ b/aggregates/src/commonMain/kotlin/org/dhis2/mobile/aggregates/model/DataSetDetails.kt @@ -53,6 +53,8 @@ sealed class NonEditableReason { data object Closed : NonEditableReason() data object Expired : NonEditableReason() + + data object PeriodNotInDataInputPeriods : NonEditableReason() } enum class TextAlignment { diff --git a/aggregates/src/commonMain/kotlin/org/dhis2/mobile/aggregates/ui/DataSetTableScreen.kt b/aggregates/src/commonMain/kotlin/org/dhis2/mobile/aggregates/ui/DataSetTableScreen.kt index 6be8ccf6780..95e836b5cd5 100644 --- a/aggregates/src/commonMain/kotlin/org/dhis2/mobile/aggregates/ui/DataSetTableScreen.kt +++ b/aggregates/src/commonMain/kotlin/org/dhis2/mobile/aggregates/ui/DataSetTableScreen.kt @@ -70,6 +70,7 @@ import org.dhis2.mobile.aggregates.resources.empty_section_message import org.dhis2.mobile.aggregates.resources.no_data_write_access import org.dhis2.mobile.aggregates.resources.org_unit_not_in_capture_scope import org.dhis2.mobile.aggregates.resources.period_not_in_attribute_option_combo_range +import org.dhis2.mobile.aggregates.resources.period_not_in_data_input_periods import org.dhis2.mobile.aggregates.resources.period_not_in_org_unit_range import org.dhis2.mobile.aggregates.ui.component.HtmlContentBox import org.dhis2.mobile.aggregates.ui.component.ValidationBar @@ -575,6 +576,9 @@ private fun nonEditableReasonLabel(edition: DataSetEdition) = edition.nonEditableReason.periodLabel, edition.nonEditableReason.orgUnitLabel, ) + + is NonEditableReason.PeriodNotInDataInputPeriods -> + stringResource(Res.string.period_not_in_data_input_periods) } /** diff --git a/aggregates/src/commonMain/kotlin/org/dhis2/mobile/aggregates/ui/inputs/InputProvider.kt b/aggregates/src/commonMain/kotlin/org/dhis2/mobile/aggregates/ui/inputs/InputProvider.kt index 18190b8143c..8c2fa2a7234 100644 --- a/aggregates/src/commonMain/kotlin/org/dhis2/mobile/aggregates/ui/inputs/InputProvider.kt +++ b/aggregates/src/commonMain/kotlin/org/dhis2/mobile/aggregates/ui/inputs/InputProvider.kt @@ -223,7 +223,7 @@ internal fun InputProvider( onAction(UiAction.OnValueChanged(inputData.id, value)) } }, - onNextClicked = { onAction.invoke(UiAction.OnNextClick(inputData.id)) }, + onImeActionClick = { onAction.invoke(UiAction.OnNextClick(inputData.id)) }, modifier = modifierWithFocus, ) } @@ -329,7 +329,7 @@ internal fun InputProvider( dateTextValue = it ?: TextFieldValue() onAction(UiAction.OnValueChanged(inputData.id, dateTextValue.text)) }, - onNextClicked = { onAction(UiAction.OnNextClick(inputData.id)) }, + onImeActionClick = { onAction(UiAction.OnNextClick(inputData.id)) }, modifier = modifierWithFocus, ) } @@ -342,7 +342,7 @@ internal fun InputProvider( legendData = inputData.legendData, inputTextFieldValue = textValue, isRequiredField = inputData.isRequired, - onNextClicked = { onAction.invoke(UiAction.OnNextClick(inputData.id)) }, + onImeActionClick = { onAction.invoke(UiAction.OnNextClick(inputData.id)) }, onValueChanged = { textValue = it ?: TextFieldValue() onAction(UiAction.OnValueChanged(inputData.id, textValue.text)) @@ -457,7 +457,7 @@ internal fun InputProvider( legendData = inputData.legendData, inputTextFieldValue = textValue, isRequiredField = inputData.isRequired, - onNextClicked = { onAction.invoke(UiAction.OnNextClick(inputData.id)) }, + onImeActionClick = { onAction.invoke(UiAction.OnNextClick(inputData.id)) }, onValueChanged = { textValue = it ?: TextFieldValue() onAction(UiAction.OnValueChanged(inputData.id, textValue.text)) @@ -477,7 +477,7 @@ internal fun InputProvider( legendData = inputData.legendData, inputTextFieldValue = textValue, isRequiredField = inputData.isRequired, - onNextClicked = { onAction.invoke(UiAction.OnNextClick(inputData.id)) }, + onImeActionClick = { onAction.invoke(UiAction.OnNextClick(inputData.id)) }, onValueChanged = { textValue = it ?: TextFieldValue() onAction(UiAction.OnValueChanged(inputData.id, textValue.text)) @@ -497,7 +497,7 @@ internal fun InputProvider( legendData = inputData.legendData, inputTextFieldValue = textValue, isRequiredField = inputData.isRequired, - onNextClicked = { onAction.invoke(UiAction.OnNextClick(inputData.id)) }, + onImeActionClick = { onAction.invoke(UiAction.OnNextClick(inputData.id)) }, onValueChanged = { textValue = it ?: TextFieldValue() onAction(UiAction.OnValueChanged(inputData.id, textValue.text)) @@ -517,7 +517,7 @@ internal fun InputProvider( legendData = inputData.legendData, inputTextFieldValue = textValue, isRequiredField = inputData.isRequired, - onNextClicked = { onAction.invoke(UiAction.OnNextClick(inputData.id)) }, + onImeActionClick = { onAction.invoke(UiAction.OnNextClick(inputData.id)) }, onValueChanged = { textValue = it ?: TextFieldValue() onAction(UiAction.OnValueChanged(inputData.id, textValue.text)) @@ -537,7 +537,7 @@ internal fun InputProvider( legendData = inputData.legendData, inputTextFieldValue = textValue, isRequiredField = inputData.isRequired, - onNextClicked = { onAction.invoke(UiAction.OnNextClick(inputData.id)) }, + onImeActionClick = { onAction.invoke(UiAction.OnNextClick(inputData.id)) }, onValueChanged = { if (textValue.text != it?.text) { textValue = it ?: TextFieldValue() @@ -559,7 +559,7 @@ internal fun InputProvider( legendData = inputData.legendData, inputTextFieldValue = textValue, isRequiredField = inputData.isRequired, - onNextClicked = { onAction.invoke(UiAction.OnNextClick(inputData.id)) }, + onImeActionClick = { onAction.invoke(UiAction.OnNextClick(inputData.id)) }, onValueChanged = { textValue = it ?: TextFieldValue() onAction(UiAction.OnValueChanged(inputData.id, textValue.text)) @@ -783,7 +783,7 @@ internal fun InputProvider( legendData = inputData.legendData, inputTextFieldValue = textValue, isRequiredField = inputData.isRequired, - onNextClicked = { onAction.invoke(UiAction.OnNextClick(inputData.id)) }, + onImeActionClick = { onAction.invoke(UiAction.OnNextClick(inputData.id)) }, onValueChanged = { textValue = it ?: TextFieldValue() onAction(UiAction.OnValueChanged(inputData.id, textValue.text)) @@ -824,7 +824,7 @@ internal fun InputProvider( legendData = inputData.legendData, inputTextFieldValue = textValue, isRequiredField = inputData.isRequired, - onNextClicked = { onAction.invoke(UiAction.OnNextClick(inputData.id)) }, + onImeActionClick = { onAction.invoke(UiAction.OnNextClick(inputData.id)) }, onValueChanged = { textValue = it ?: TextFieldValue() onAction(UiAction.OnValueChanged(inputData.id, textValue.text)) @@ -847,7 +847,7 @@ internal fun InputProvider( inputTextFieldValue = textValue, isRequiredField = inputData.isRequired, onCallActionClicked = { onAction(UiAction.OnCall(inputData.id, textValue.text)) }, - onNextClicked = { onAction.invoke(UiAction.OnNextClick(inputData.id)) }, + onImeActionClick = { onAction.invoke(UiAction.OnNextClick(inputData.id)) }, onValueChanged = { textValue = it ?: TextFieldValue() onAction(UiAction.OnValueChanged(inputData.id, textValue.text)) @@ -867,7 +867,7 @@ internal fun InputProvider( legendData = inputData.legendData, inputTextFieldValue = textValue, isRequiredField = inputData.isRequired, - onNextClicked = { onAction.invoke(UiAction.OnNextClick(inputData.id)) }, + onImeActionClick = { onAction.invoke(UiAction.OnNextClick(inputData.id)) }, onValueChanged = { textValue = it ?: TextFieldValue() onAction(UiAction.OnValueChanged(inputData.id, textValue.text)) @@ -909,7 +909,7 @@ internal fun InputProvider( legendData = inputData.legendData, inputTextFieldValue = textValue, isRequiredField = inputData.isRequired, - onNextClicked = { onAction.invoke(UiAction.OnNextClick(inputData.id)) }, + onImeActionClick = { onAction.invoke(UiAction.OnNextClick(inputData.id)) }, onValueChanged = { textValue = it ?: TextFieldValue() onAction(UiAction.OnValueChanged(inputData.id, textValue.text)) @@ -928,7 +928,7 @@ internal fun InputProvider( legendData = inputData.legendData, inputTextFieldValue = textValue, isRequiredField = inputData.isRequired, - onNextClicked = { onAction.invoke(UiAction.OnNextClick(inputData.id)) }, + onImeActionClick = { onAction.invoke(UiAction.OnNextClick(inputData.id)) }, onValueChanged = { textValue = it ?: TextFieldValue() onAction(UiAction.OnValueChanged(inputData.id, textValue.text)) diff --git a/aggregates/src/commonMain/kotlin/org/dhis2/mobile/aggregates/ui/states/InputDataUiState.kt b/aggregates/src/commonMain/kotlin/org/dhis2/mobile/aggregates/ui/states/InputDataUiState.kt index 0cdcbd9608c..89593684995 100644 --- a/aggregates/src/commonMain/kotlin/org/dhis2/mobile/aggregates/ui/states/InputDataUiState.kt +++ b/aggregates/src/commonMain/kotlin/org/dhis2/mobile/aggregates/ui/states/InputDataUiState.kt @@ -28,7 +28,7 @@ internal sealed class CellSelectionState( val inputType: InputType, private val inputExtra: InputExtra, val inputShellState: InputShellState, - val inputStyle: InputStyle = InputStyle.DataInputStyle(), + val inputStyle: InputStyle = InputStyle.DarkInputStyle(), val supportingText: List?, val legendData: LegendData?, val isRequired: Boolean, diff --git a/aggregates/src/commonMain/kotlin/org/dhis2/mobile/aggregates/ui/viewModel/DataSetTableViewModel.kt b/aggregates/src/commonMain/kotlin/org/dhis2/mobile/aggregates/ui/viewModel/DataSetTableViewModel.kt index 9906c538bb9..3c3511eba23 100644 --- a/aggregates/src/commonMain/kotlin/org/dhis2/mobile/aggregates/ui/viewModel/DataSetTableViewModel.kt +++ b/aggregates/src/commonMain/kotlin/org/dhis2/mobile/aggregates/ui/viewModel/DataSetTableViewModel.kt @@ -58,6 +58,7 @@ import org.dhis2.mobile.aggregates.ui.states.OverwrittenDimension import org.dhis2.mobile.aggregates.ui.states.ValidationBarUiState import org.dhis2.mobile.aggregates.ui.states.mapper.InputDataUiStateMapper import org.dhis2.mobile.commons.coroutine.CoroutineTracker +import org.dhis2.mobile.commons.extensions.launchUseCase import org.dhis2.mobile.commons.input.CallbackStatus import org.dhis2.mobile.commons.input.InputType import org.dhis2.mobile.commons.input.UiAction @@ -104,7 +105,7 @@ internal class DataSetTableViewModel( ) fun loadDataSet() { - viewModelScope.launch(dispatcher.io()) { + launchUseCase(dispatcher.io()) { val dataSetInstanceData = getDataSetInstanceData(this) val initialSection = dataSetInstanceData.initialSectionToLoad @@ -120,65 +121,51 @@ internal class DataSetTableViewModel( } else { dataSetInstanceData.dataSetSections.firstOrNull()?.uid ?: NO_SECTION_UID } - _dataSetScreenState.value = - DataSetScreenState.Loaded( - dataSetDetails = - dataSetInstanceData.dataSetDetails.copy( - customTitle = - dataSetInstanceData.dataSetDetails.customTitle.copy( - header = dataSetSectionTitle, - ), - ), - dataSetSections = dataSetInstanceData.dataSetSections, - renderingConfig = dataSetInstanceData.dataSetRenderingConfig, - dataSetSectionTable = - DataSetSectionTable( - sectionToLoad, - emptyList(), - overridingDimensions = overwrittenWidths(sectionToLoad), - loading = true, - ), - initialSection = dataSetInstanceData.initialSectionToLoad, - selectedCellInfo = CellSelectionState.Default(TableSelection.Unselected()), - ) + val initialDimensions = overwrittenWidths(sectionToLoad) + withContext(dispatcher.main()) { + _dataSetScreenState.value = + DataSetScreenState.Loaded( + dataSetDetails = + dataSetInstanceData.dataSetDetails.copy( + customTitle = + dataSetInstanceData.dataSetDetails.customTitle.copy( + header = dataSetSectionTitle, + ), + ), + dataSetSections = dataSetInstanceData.dataSetSections, + renderingConfig = dataSetInstanceData.dataSetRenderingConfig, + dataSetSectionTable = + DataSetSectionTable( + sectionToLoad, + emptyList(), + overridingDimensions = initialDimensions, + loading = true, + ), + initialSection = dataSetInstanceData.initialSectionToLoad, + selectedCellInfo = CellSelectionState.Default(TableSelection.Unselected()), + ) + } - val sectionTable = async { sectionData(sectionToLoad) } + val tableModels = sectionData(sectionToLoad) - _dataSetScreenState.update { - when (it) { - is DataSetScreenState.Loaded -> - it.copy( - dataSetDetails = - dataSetInstanceData.dataSetDetails.copy( - customTitle = - dataSetInstanceData.dataSetDetails.customTitle.copy( - header = dataSetSectionTitle, - ), - ), - dataSetSectionTable = - DataSetSectionTable( - sectionToLoad, - sectionTable.await(), - overridingDimensions = overwrittenWidths(sectionToLoad), - loading = false, - ), - ) - - DataSetScreenState.Loading -> - DataSetScreenState.Loaded( - dataSetDetails = dataSetInstanceData.dataSetDetails, - dataSetSections = dataSetInstanceData.dataSetSections, - renderingConfig = dataSetInstanceData.dataSetRenderingConfig, - dataSetSectionTable = - DataSetSectionTable( - sectionToLoad, - sectionTable.await(), - overridingDimensions = overwrittenWidths(sectionToLoad), - loading = true, - ), - initialSection = initialSection, - selectedCellInfo = CellSelectionState.Default(TableSelection.Unselected()), - ) + withContext(dispatcher.main()) { + _dataSetScreenState.update { + (it as? DataSetScreenState.Loaded)?.copy( + dataSetDetails = + dataSetInstanceData.dataSetDetails.copy( + customTitle = + dataSetInstanceData.dataSetDetails.customTitle.copy( + header = dataSetSectionTitle, + ), + ), + dataSetSectionTable = + DataSetSectionTable( + sectionToLoad, + tableModels, + overridingDimensions = it.dataSetSectionTable.overridingDimensions, + loading = false, + ), + ) ?: it } } } @@ -188,65 +175,69 @@ internal class DataSetTableViewModel( if (_dataSetScreenState.value.currentSection() == sectionUid) return sectionChangeJob?.takeIf { it.isActive }?.cancel() sectionChangeJob = - viewModelScope.launch(dispatcher.io()) { + launchUseCase(dispatcher.io()) { val selectedSectionIndex = (dataSetScreenState.value as? DataSetScreenState.Loaded) ?.dataSetSections ?.indexOfFirst { it.uid == sectionUid } - CoroutineTracker.increment() - _dataSetScreenState.update { - if (it is DataSetScreenState.Loaded) { - val dataSetSectionTitle = - if (it.dataSetDetails.customTitle.isConfiguredTitle) { - it.dataSetDetails.customTitle.header - } else { - it.dataSetSections.firstOrNull { section -> section.uid == sectionUid }?.title - } - it.copy( - dataSetDetails = - it.dataSetDetails.copy( - customTitle = - DataSetCustomTitle( - header = dataSetSectionTitle, - subHeader = it.dataSetDetails.customTitle.subHeader, - textAlignment = it.dataSetDetails.customTitle.textAlignment, - isConfiguredTitle = it.dataSetDetails.customTitle.isConfiguredTitle, - ), - ), - dataSetSectionTable = - it.dataSetSectionTable.copy( - id = sectionUid, - tableModels = emptyList(), - overridingDimensions = overwrittenWidths(sectionUid), - loading = true, - ), - selectedCellInfo = CellSelectionState.Default(TableSelection.Unselected()), - initialSection = selectedSectionIndex ?: 0, - ) - } else { - it + val initialDimensions = overwrittenWidths(sectionUid) + withContext(dispatcher.main()) { + _dataSetScreenState.update { + if (it is DataSetScreenState.Loaded) { + val dataSetSectionTitle = + if (it.dataSetDetails.customTitle.isConfiguredTitle) { + it.dataSetDetails.customTitle.header + } else { + it.dataSetSections.firstOrNull { section -> section.uid == sectionUid }?.title + } + it.copy( + dataSetDetails = + it.dataSetDetails.copy( + customTitle = + DataSetCustomTitle( + header = dataSetSectionTitle, + subHeader = it.dataSetDetails.customTitle.subHeader, + textAlignment = it.dataSetDetails.customTitle.textAlignment, + isConfiguredTitle = it.dataSetDetails.customTitle.isConfiguredTitle, + ), + ), + dataSetSectionTable = + it.dataSetSectionTable.copy( + id = sectionUid, + tableModels = emptyList(), + overridingDimensions = initialDimensions, + loading = true, + ), + selectedCellInfo = CellSelectionState.Default(TableSelection.Unselected()), + initialSection = selectedSectionIndex ?: 0, + ) + } else { + it + } } } - val sectionData = async { sectionData(sectionUid) } - _dataSetScreenState.update { - if (it is DataSetScreenState.Loaded) { - it.copy( - dataSetSectionTable = - it.dataSetSectionTable.copy( - id = sectionUid, - tableModels = sectionData.await(), - overridingDimensions = overwrittenWidths(sectionUid), - loading = false, - ), - initialSection = selectedSectionIndex ?: 0, - ) - } else { - it + val tableModels = sectionData(sectionUid) + withContext(dispatcher.main()) { + _dataSetScreenState.update { + if (it is DataSetScreenState.Loaded) { + it.copy( + dataSetSectionTable = + it.dataSetSectionTable.copy( + id = sectionUid, + tableModels = tableModels, + // Preserve any resize the user applied while loading + overridingDimensions = it.dataSetSectionTable.overridingDimensions, + loading = false, + ), + initialSection = selectedSectionIndex ?: 0, + ) + } else { + it + } } } - CoroutineTracker.decrement() } } @@ -379,7 +370,14 @@ internal class DataSetTableViewModel( } else { it.dataSetSectionTable }, - selectedCellInfo = if (showInputDialog) inputData else CellSelectionState.Default(TableSelection.Unselected()), + selectedCellInfo = + if (showInputDialog) { + inputData + } else { + CellSelectionState.Default( + TableSelection.Unselected(), + ) + }, ) ?: it } CoroutineTracker.decrement() @@ -432,11 +430,16 @@ internal class DataSetTableViewModel( selectedCellInfo is CellSelectionState.InputDataUiState && selectedCellInfo.inputType is InputType.MultiText -> !selectedCellInfo.multiTextExtras().optionsFetched + else -> false } - updateSelectedCell(uiAction.id, fetchOptions, showInputDialog = uiAction.showInputDialog) + updateSelectedCell( + uiAction.id, + fetchOptions, + showInputDialog = uiAction.showInputDialog, + ) }, onFailure = { updateSelectedCell( @@ -572,22 +575,20 @@ internal class DataSetTableViewModel( } private fun updateFileLoadingState(state: UploadFileState) { - viewModelScope.launch(dispatcher.io()) { - _dataSetScreenState.update { - (it as? DataSetScreenState.Loaded)?.copy( - selectedCellInfo = - if (it.selectedCellInfo is CellSelectionState.InputDataUiState) { - it.selectedCellInfo.copy( - inputExtra = - it.selectedCellInfo.fileExtras().copy( - fileState = state, - ), - ) - } else { - it.selectedCellInfo - }, - ) ?: it - } + _dataSetScreenState.update { + (it as? DataSetScreenState.Loaded)?.copy( + selectedCellInfo = + if (it.selectedCellInfo is CellSelectionState.InputDataUiState) { + it.selectedCellInfo.copy( + inputExtra = + it.selectedCellInfo.fileExtras().copy( + fileState = state, + ), + ) + } else { + it.selectedCellInfo + }, + ) ?: it } } @@ -613,13 +614,15 @@ internal class DataSetTableViewModel( ?.dataSetSectionTable ?.id ?: return@launch val updatedDimensions = overwrittenWidths(currentSectionId) - _dataSetScreenState.update { state -> - (state as? DataSetScreenState.Loaded)?.copy( - dataSetSectionTable = - state.dataSetSectionTable.copy( - overridingDimensions = updatedDimensions, - ), - ) ?: state + withContext(dispatcher.main()) { + _dataSetScreenState.update { state -> + (state as? DataSetScreenState.Loaded)?.copy( + dataSetSectionTable = + state.dataSetSectionTable.copy( + overridingDimensions = updatedDimensions, + ), + ) ?: state + } } } } @@ -737,14 +740,11 @@ internal class DataSetTableViewModel( it } } - CoroutineTracker.decrement() } } private fun checkValidationRules() { - viewModelScope.launch { - CoroutineTracker.increment() - + launchUseCase { val rules = withContext(dispatcher.io()) { runValidationRules() @@ -793,7 +793,6 @@ internal class DataSetTableViewModel( } } } - CoroutineTracker.decrement() } } @@ -823,8 +822,7 @@ internal class DataSetTableViewModel( } private fun attemptToFinish() { - viewModelScope.launch { - CoroutineTracker.increment() + launchUseCase { val onSavedMessage = resourceManager.provideSaved() val result = @@ -851,13 +849,11 @@ internal class DataSetTableViewModel( } } } - CoroutineTracker.decrement() } } private fun attemptToComplete() { - viewModelScope.launch { - CoroutineTracker.increment() + launchUseCase { val result = withContext(dispatcher.io()) { completeDataSet() @@ -907,7 +903,6 @@ internal class DataSetTableViewModel( showSnackbar(resourceManager.provideErrorOnCompleteDataset()) } } - CoroutineTracker.decrement() } } diff --git a/aggregates/src/commonTest/kotlin/org/dhis2/mobile/aggregates/domain/GetDataValueInputTest.kt b/aggregates/src/commonTest/kotlin/org/dhis2/mobile/aggregates/domain/GetDataValueInputTest.kt index b6c8a3d380f..574902e8bdc 100644 --- a/aggregates/src/commonTest/kotlin/org/dhis2/mobile/aggregates/domain/GetDataValueInputTest.kt +++ b/aggregates/src/commonTest/kotlin/org/dhis2/mobile/aggregates/domain/GetDataValueInputTest.kt @@ -46,7 +46,7 @@ internal class GetDataValueInputTest : KoinTest { private val repository = mock { onRunBlocking { dataElementInfo(any(), any(), any()) } doReturn mockedDataElementInfo - onRunBlocking { value(any(), any(), any(), any(), any()) } doReturn "Current value" + onRunBlocking { value(any(), any(), any(), any(), any(), any()) } doReturn "Current value" onRunBlocking { conflicts(any(), any(), any(), any(), any(), any()) } doReturn Pair( emptyList(), diff --git a/aggregates/src/commonTest/kotlin/org/dhis2/mobile/aggregates/ui/viewModel/DataSetTableViewModelTest.kt b/aggregates/src/commonTest/kotlin/org/dhis2/mobile/aggregates/ui/viewModel/DataSetTableViewModelTest.kt index 781babd905d..d1a838d8d67 100644 --- a/aggregates/src/commonTest/kotlin/org/dhis2/mobile/aggregates/ui/viewModel/DataSetTableViewModelTest.kt +++ b/aggregates/src/commonTest/kotlin/org/dhis2/mobile/aggregates/ui/viewModel/DataSetTableViewModelTest.kt @@ -182,8 +182,13 @@ internal class DataSetTableViewModelTest : KoinTest { dataSetSections = listOf( DataSetSection( - uid = "sectionUid", - title = "sectionTitle", + uid = "section_uid1", + title = "sectionTitle1", + misconfiguredRows = emptyList(), + ), + DataSetSection( + uid = "section_uid2", + title = "sectionTitle2", misconfiguredRows = emptyList(), ), ), @@ -437,8 +442,8 @@ internal class DataSetTableViewModelTest : KoinTest { if (this is DataSetScreenState.Loaded) { assertTrue(this.selectedCellInfo is InputDataUiState) require(this.selectedCellInfo is InputDataUiState) - assertEquals("Legend label 2", this.selectedCellInfo?.legendData?.title) - assertEquals("#CD5C5C".toColor(), this.selectedCellInfo?.legendData?.color) + assertEquals("Legend label 2", this.selectedCellInfo.legendData?.title) + assertEquals("#CD5C5C".toColor(), this.selectedCellInfo.legendData?.color) } else { assertTrue(false) } @@ -965,16 +970,15 @@ internal class DataSetTableViewModelTest : KoinTest { runTest { viewModel.dataSetScreenState.test { awaitInitialization() - viewModel.onSectionSelected("section_uid1") + viewModel.onSectionSelected("section_uid2") with(awaitItem()) { assertTrue(this is DataSetScreenState.Loaded) assertTrue((this as DataSetScreenState.Loaded).dataSetSectionTable.loading) - assertTrue(this.currentSection() == "section_uid1") + assertTrue(this.currentSection() == "section_uid2") } - viewModel.onSectionSelected("section_uid2") with(awaitItem()) { assertTrue(this is DataSetScreenState.Loaded) - assertTrue((this as DataSetScreenState.Loaded).dataSetSectionTable.loading) + assertTrue(!(this as DataSetScreenState.Loaded).dataSetSectionTable.loading) assertTrue(this.currentSection() == "section_uid2") } viewModel.onSectionSelected("section_uid1") diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 479b5a6139f..2c6219b2902 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -1,5 +1,3 @@ -@file:Suppress("UnstableApiUsage") - import com.android.build.api.variant.impl.VariantOutputImpl import org.jetbrains.kotlin.gradle.dsl.JvmTarget import java.text.SimpleDateFormat @@ -7,8 +5,7 @@ import java.util.Date plugins { id("com.android.application") - kotlin("android") - kotlin("kapt") + alias(libs.plugins.legacy.kapt) id("com.google.devtools.ksp") id("kotlin-parcelize") alias(libs.plugins.kotlin.serialization) @@ -17,27 +14,52 @@ plugins { } apply(from = "${project.rootDir}/jacoco/jacoco.gradle.kts") -android { +val getBuildDate by extra { + fun(): String { + return SimpleDateFormat("yyyy-MM-dd HH:mm").format(Date()) + } +} - val getBuildDate by extra { - fun(): String { - return SimpleDateFormat("yyyy-MM-dd HH:mm").format(Date()) +val getCommitHash by extra { + fun(): String { + return try { + val process = ProcessBuilder("git", "rev-parse", "--short", "HEAD") + .redirectOutput(ProcessBuilder.Redirect.PIPE) + .redirectError(ProcessBuilder.Redirect.PIPE) + .start() + process.inputStream.bufferedReader().readText().trim() + } catch (e: Exception) { + "unknown" } } +} + +val getBranchName by extra { + fun(): String { + val envBranchName = System.getenv("GITHUB_HEAD_REF") + ?: System.getenv("GITHUB_REF_NAME") - val getCommitHash by extra { - fun(): String { - return try { - val process = ProcessBuilder("git", "rev-parse", "--short", "HEAD") - .redirectOutput(ProcessBuilder.Redirect.PIPE) - .redirectError(ProcessBuilder.Redirect.PIPE) - .start() - process.inputStream.bufferedReader().readText().trim() - } catch (e: Exception) { - "unknown" + return try { + if (!envBranchName.isNullOrBlank()) { + return envBranchName.replace(Regex("[/\\\\:*?\"<>|]"), "-") } + val process = ProcessBuilder("git", "rev-parse", "--abbrev-ref", "HEAD") + .redirectOutput(ProcessBuilder.Redirect.PIPE) + .redirectError(ProcessBuilder.Redirect.PIPE) + .start() + val branchName = process.inputStream.bufferedReader().readText().trim() + branchName.replace(Regex("[/\\\\:*?\"<>|]"), "-") + } catch (e: Exception) { + "unknown" } } +} + +base { + archivesName.set("dhis2-v" + libs.versions.vName.get()) +} + +android { signingConfigs { create("release") { @@ -56,6 +78,15 @@ android { } storePassword = System.getenv("TRAINING_STORE_PASSWORD") } + val customKeystorePath = System.getenv("DEBUG_KEYSTORE_PATH") + if (customKeystorePath != null ) { + getByName("debug") { + keyAlias = System.getenv("DEBUG_KEYSTORE_ALIAS") + keyPassword = System.getenv("DEBUG_KEY_PASS") + storeFile = file(customKeystorePath) + storePassword = System.getenv("DEBUG_KEYSTORE_PASSWORD") + } + } } testOptions { @@ -74,23 +105,18 @@ android { } } + compileSdk = libs.versions.sdk.get().toInt() namespace = "org.dhis2" testNamespace = "org.dhis2.test" - base { - archivesName.set("dhis2-v" + libs.versions.vName.get()) - } - defaultConfig { applicationId = "com.dhis2" - compileSdk = libs.versions.sdk.get().toInt() targetSdk = libs.versions.sdk.get().toInt() minSdk = libs.versions.minSdk.get().toInt() versionCode = libs.versions.vCode.get().toInt() versionName = libs.versions.vName.get() testInstrumentationRunner = "org.dhis2.Dhis2Runner" vectorDrawables.useSupportLibrary = true - multiDexEnabled = true val bitriseSentryDSN = System.getenv("SENTRY_DSN") ?: "" @@ -123,6 +149,9 @@ android { "META-INF/gradle/incremental.annotation.processors" ) ) + // Compose Multiplatform string resources from KMP modules can duplicate + // when multiple modules package the same locale strings.xml as Java resources. + pickFirsts.addAll(listOf("values*/**")) } } @@ -199,41 +228,42 @@ android { abortOnError = false checkReleaseBuilds = false } +} - androidComponents { - onVariants { variant -> - val buildType = variant.buildType - val flavorName = variant.flavorName +androidComponents { + onVariants { variant -> + val buildType = variant.buildType + val flavorName = variant.flavorName - // Apply suffix only for training flavor in release buildType - if (buildType == "release" && flavorName == "dhis2Training") { - variant.applicationId.set("${variant.applicationId.get()}.training") - } - - variant.outputs.forEach { output -> - if (output is VariantOutputImpl) { - val suffix = when { - buildType == "release" && flavorName == "dhis2Training" -> "-training" - buildType == "release" && flavorName == "dhis2PlayServices" -> "-googlePlay" - else -> "" - } + // Apply suffix only for training flavor in release buildType + if (buildType == "release" && flavorName == "dhis2Training") { + variant.applicationId.set("${variant.applicationId.get()}.training") + } - output.outputFileName = "dhis2-v${libs.versions.vName.get()}$suffix.apk" + variant.outputs.forEach { output -> + if (output is VariantOutputImpl) { + val suffix = when { + buildType == "release" && flavorName == "dhis2Training" -> "-training" + buildType == "release" && flavorName == "dhis2PlayServices" -> "-googlePlay" + buildType == "debug" -> "-${getBranchName()}" + else -> "" } - } + output.outputFileName = "dhis2-v${libs.versions.vName.get()}$suffix.apk" + } } - } - ksp { - arg("room.schemaLocation", "$projectDir/schemas") - arg("room.incremental", "true") - arg("room.expandProjection", "true") - // Enable debug logs - arg("ksp.logging.level", "DEBUG") } } +ksp { + arg("room.schemaLocation", "$projectDir/schemas") + arg("room.incremental", "true") + arg("room.expandProjection", "true") + // Enable debug logs + arg("ksp.logging.level", "DEBUG") +} + kotlin { compilerOptions { jvmTarget.set(JvmTarget.JVM_17) @@ -242,6 +272,10 @@ kotlin { } } +kapt { + correctErrorTypes = true +} + dependencies { implementation(fileTree(mapOf("dir" to "libs", "include" to listOf("*.jar")))) implementation(project(":dhis_android_analytics")) @@ -255,6 +289,7 @@ dependencies { implementation(project(":aggregates")) implementation(project(":commonskmm")) implementation(project(":login")) + implementation(project(":sync")) implementation(libs.security.conscrypt) implementation(libs.security.rootbeer) @@ -264,7 +299,6 @@ dependencies { implementation(libs.androidx.annotation) implementation(libs.androidx.cardview) implementation(libs.androidx.legacy.support.v4) - implementation(libs.androidx.multidex) implementation(libs.androidx.constraintlayout) implementation(libs.androidx.work) implementation(libs.androidx.workrx) @@ -272,7 +306,6 @@ dependencies { implementation(libs.androidx.biometric) implementation(libs.androidx.material3) implementation(libs.google.guava) - implementation(libs.github.pinlock) implementation(libs.github.fancyshowcase) implementation(libs.lottie) implementation(libs.network.okhttp) @@ -316,6 +349,9 @@ dependencies { androidTestImplementation(libs.test.rx2.idler) androidTestImplementation(libs.test.compose.ui.test) androidTestImplementation(libs.test.hamcrest) + androidTestImplementation(libs.koin.test) + androidTestImplementation(libs.koin.test.junit4) + debugImplementation(libs.test.ui.test.manifest) } sentry { diff --git a/app/src/androidTest/assets/databases/dhis_test.db b/app/src/androidTest/assets/databases/dhis_test.db index 3e6c7f617d0..401d0461abf 100644 Binary files a/app/src/androidTest/assets/databases/dhis_test.db and b/app/src/androidTest/assets/databases/dhis_test.db differ diff --git a/app/src/androidTest/assets/mocks/settingswebapp/generalsettings.json b/app/src/androidTest/assets/mocks/settingswebapp/generalsettings.json new file mode 100644 index 00000000000..8b14c43405f --- /dev/null +++ b/app/src/androidTest/assets/mocks/settingswebapp/generalsettings.json @@ -0,0 +1,9 @@ +{ + "encryptDB": false, + "mobileConfiguration": { + "disableAllSettings": false + }, + "reservedValues": 100, + "smsGateway": "", + "smsResultSender": "" +} diff --git a/app/src/androidTest/java/org/dhis2/AppTest.kt b/app/src/androidTest/java/org/dhis2/AppTest.kt index f594dd84e89..aac5b3b125c 100644 --- a/app/src/androidTest/java/org/dhis2/AppTest.kt +++ b/app/src/androidTest/java/org/dhis2/AppTest.kt @@ -1,7 +1,5 @@ package org.dhis2 -import androidx.lifecycle.MutableLiveData -import androidx.work.WorkInfo import org.dhis2.common.di.TestingInjector import org.dhis2.common.keystore.KeyStoreRobot import org.dhis2.common.preferences.PreferencesTestingModule @@ -10,8 +8,6 @@ import org.dhis2.commons.schedulers.SchedulersProviderImpl import org.dhis2.data.server.ServerModule import org.dhis2.data.user.UserModule import org.dhis2.usescases.BaseTest.Companion.MOCK_SERVER_URL -import org.dhis2.usescases.sync.MockedWorkManagerController -import org.dhis2.usescases.sync.MockedWorkManagerModule import org.dhis2.utils.analytics.AnalyticsModule import org.hisp.dhis.android.core.D2Manager import org.hisp.dhis.android.core.D2Manager.blockingInstantiateD2 @@ -20,8 +16,6 @@ import org.hisp.dhis.android.core.D2Manager.setTestingSecureStore class AppTest : App() { - val mutableWorkInfoStatuses = MutableLiveData>() - fun restoreDB() { } @@ -68,13 +62,6 @@ class AppTest : App() { .schedulerModule(SchedulerModule(SchedulersProviderImpl())) .analyticsModule(AnalyticsModule()) .preferenceModule(PreferencesTestingModule()) - .workManagerController( - MockedWorkManagerModule( - MockedWorkManagerController( - mutableWorkInfoStatuses - ) - ) - ) } companion object { diff --git a/app/src/androidTest/java/org/dhis2/DBTestLoader.kt b/app/src/androidTest/java/org/dhis2/DBTestLoader.kt index cff717391f2..e294aa278cf 100644 --- a/app/src/androidTest/java/org/dhis2/DBTestLoader.kt +++ b/app/src/androidTest/java/org/dhis2/DBTestLoader.kt @@ -23,7 +23,10 @@ class DBTestLoader(private val context: Context) { Timber.tag("RUNNER_LOG").d("Copying database") val input = InstrumentationRegistry.getInstrumentation() .context.assets.open("databases/$DB_NAME_TEST") - val output = FileOutputStream("$databasePath/$DB_NAME") + + file.parentFile?.mkdirs() + + val output = FileOutputStream(file) writeExtractedFileToDisk(input, output) Timber.d("Database copy done") } catch (e: IOException) { @@ -51,4 +54,4 @@ class DBTestLoader(private val context: Context) { const val DB_NAME_TEST = "dhis_test.db" const val DB_NAME = "127-0-0-1-8080_android_unencrypted.db" } -} \ No newline at end of file +} diff --git a/app/src/androidTest/java/org/dhis2/common/BaseRobot.kt b/app/src/androidTest/java/org/dhis2/common/BaseRobot.kt index bbc2ad5e85c..57f243124e9 100644 --- a/app/src/androidTest/java/org/dhis2/common/BaseRobot.kt +++ b/app/src/androidTest/java/org/dhis2/common/BaseRobot.kt @@ -10,10 +10,12 @@ import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.junit4.ComposeContentTestRule import androidx.compose.ui.test.onNodeWithText import androidx.test.espresso.Espresso.onView +import androidx.test.espresso.FailureHandler import androidx.test.espresso.NoMatchingViewException import androidx.test.espresso.UiController import androidx.test.espresso.ViewAction import androidx.test.espresso.ViewInteraction +import androidx.test.espresso.base.DefaultFailureHandler import androidx.test.espresso.matcher.ViewMatchers.isRoot import androidx.test.espresso.util.TreeIterables import androidx.test.platform.app.InstrumentationRegistry.getInstrumentation @@ -105,6 +107,13 @@ open class BaseRobot { val maxTries = waitMillis / waitMillisPerTry.toInt() var tries = 0 + val defaultFailureHandler = DefaultFailureHandler(getInstrumentation().targetContext) + val failureHandler = FailureHandler { error, matcher -> + if (error is NoMatchingViewException) { + throw error + } + defaultFailureHandler.handle(error, matcher) + } for (i in 0..maxTries) try { @@ -112,7 +121,9 @@ open class BaseRobot { tries++ // Search the root for the view - onView(isRoot()).perform(searchFor(viewMatcher)) + onView(isRoot()) + .withFailureHandler(failureHandler) + .perform(searchFor(viewMatcher)) // If we're here, we found our view. Now return it return onView(viewMatcher) diff --git a/app/src/androidTest/java/org/dhis2/common/db/TestingDatabase.kt b/app/src/androidTest/java/org/dhis2/common/db/TestingDatabase.kt deleted file mode 100644 index 6bf505b016c..00000000000 --- a/app/src/androidTest/java/org/dhis2/common/db/TestingDatabase.kt +++ /dev/null @@ -1,61 +0,0 @@ -package org.dhis2.common.db - -import android.os.Environment -import androidx.test.core.app.ApplicationProvider -import androidx.test.ext.junit.runners.AndroidJUnit4 -import org.dhis2.AppTest -import org.dhis2.data.server.ServerModule -import org.dhis2.usescases.BaseTest -import org.hisp.dhis.android.core.D2Manager -import org.junit.Ignore -import org.junit.Test -import org.junit.runner.RunWith -import timber.log.Timber -import java.io.File -import java.io.FileInputStream -import java.io.FileOutputStream - -@RunWith(AndroidJUnit4::class) -class TestingDatabase : BaseTest() { - - companion object { - const val url = "https://play.dhis2.org/android-current/" - const val username = "android" - const val password = "Android123" - } - - @Ignore("This tests exports the Database to SD card, uncomment to use it locally") - @Test - fun copyDatabase() { - - /* Download db */ - val d2 = - D2Manager.blockingInstantiateD2(ServerModule.getD2Configuration(ApplicationProvider.getApplicationContext())) - d2?.userModule() - ?.logIn(username, password, url) - ?.blockingGet() - d2?.metadataModule()?.blockingDownload() - - /* Export Db to sdcard */ - try { - val sd = Environment.getExternalStorageDirectory() - - if (sd.canWrite()) { - val currentDB = - context.getDatabasePath("play-dhis2-org-android-current_android_unencrypted.db") - val backupDBPath = "dhis_test.db" - val backupDB = File(sd, backupDBPath) - - if (currentDB.exists()) { - val src = FileInputStream(currentDB).channel - val dst = FileOutputStream(backupDB).channel - dst.transferFrom(src, 0, src.size()) - src.close() - dst.close() - } - } - } catch (e: Exception) { - Timber.e(e) - } - } -} \ No newline at end of file diff --git a/app/src/androidTest/java/org/dhis2/usescases/UseCaseTestsSuite.kt b/app/src/androidTest/java/org/dhis2/usescases/UseCaseTestsSuite.kt index 76b6b8edc7c..fa9ddbf28ff 100644 --- a/app/src/androidTest/java/org/dhis2/usescases/UseCaseTestsSuite.kt +++ b/app/src/androidTest/java/org/dhis2/usescases/UseCaseTestsSuite.kt @@ -6,7 +6,6 @@ import org.dhis2.usescases.event.EventTest import org.dhis2.usescases.eventsWithoutRegistration.eventDetails.EventInitialTest import org.dhis2.usescases.login.LoginTest import org.dhis2.usescases.main.MainTest -import org.dhis2.usescases.pin.PinTest import org.dhis2.usescases.programevent.ProgramEventTest import org.dhis2.usescases.searchte.SearchTETest import org.dhis2.usescases.settings.SettingsTest @@ -23,12 +22,12 @@ import org.junit.runners.Suite EventInitialTest::class, EventTest::class, MainTest::class, - PinTest::class, ProgramEventTest::class, SearchTETest::class, SettingsTest::class, SyncActivityTest::class, TeiDashboardTest::class, SchedulingDialogUiTest::class, + LoginTest::class, ) class UseCaseTestsSuite diff --git a/app/src/androidTest/java/org/dhis2/usescases/about/AboutTest.kt b/app/src/androidTest/java/org/dhis2/usescases/about/AboutTest.kt index 2e3261c54e1..d2b1c598757 100644 --- a/app/src/androidTest/java/org/dhis2/usescases/about/AboutTest.kt +++ b/app/src/androidTest/java/org/dhis2/usescases/about/AboutTest.kt @@ -1,5 +1,6 @@ package org.dhis2.usescases.about +import androidx.compose.ui.test.junit4.createComposeRule import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.rule.ActivityTestRule import org.dhis2.bindings.buildInfo @@ -18,13 +19,16 @@ class AboutTest : BaseTest() { @get:Rule val rule = ActivityTestRule(MainActivity::class.java, false, false) + @get:Rule + val composeTestRule = createComposeRule() + @Test fun shouldCheckVersionsWhenOpenAboutScreen() { startActivity() val appVersion = getAppVersionName() val sdkVersion = getSDKVersionName() - homeRobot { + homeRobot(composeTestRule) { clickOnNavigationDrawerMenu() clickAbout() } diff --git a/app/src/androidTest/java/org/dhis2/usescases/datasets/DataSetDetailRobot.kt b/app/src/androidTest/java/org/dhis2/usescases/datasets/DataSetDetailRobot.kt index 0216ff9c92a..bb7caf6f126 100644 --- a/app/src/androidTest/java/org/dhis2/usescases/datasets/DataSetDetailRobot.kt +++ b/app/src/androidTest/java/org/dhis2/usescases/datasets/DataSetDetailRobot.kt @@ -84,7 +84,7 @@ internal class DataSetDetailRobot( } fun checkDataSetIsNotCompletedAndModified(catCombo: String, orgUnit: String ) { - onView(withId(R.id.recycler)) + waitForView(withId(R.id.recycler)) .check( matches( hasItem( diff --git a/app/src/androidTest/java/org/dhis2/usescases/datasets/DataSetInitialRobot.kt b/app/src/androidTest/java/org/dhis2/usescases/datasets/DataSetInitialRobot.kt index 6f8df0645b7..85a98e6a34b 100644 --- a/app/src/androidTest/java/org/dhis2/usescases/datasets/DataSetInitialRobot.kt +++ b/app/src/androidTest/java/org/dhis2/usescases/datasets/DataSetInitialRobot.kt @@ -7,7 +7,6 @@ import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick import androidx.compose.ui.test.performTextReplacement -import androidx.test.espresso.Espresso.onView import androidx.test.espresso.action.ViewActions.click import androidx.test.espresso.assertion.ViewAssertions.matches import androidx.test.espresso.matcher.ViewMatchers.isDisplayed @@ -30,23 +29,23 @@ internal class DataSetInitialRobot( ) : BaseRobot() { fun clickOnInputOrgUnit() { - onView(withId(R.id.dataSetOrgUnitInputLayout)).perform(click()) + waitForView(withId(R.id.dataSetOrgUnitInputLayout)).perform(click()) } fun clickOnInputPeriod() { - onView(withId(R.id.dataSetPeriodInputLayout)).perform(click()) + waitForView(withId(R.id.dataSetPeriodInputLayout)).perform(click()) } fun clickOnActionButton() { - onView(withId(R.id.actionButton)).perform(click()) + waitForView(withId(R.id.actionButton)).perform(click()) } fun clickOnInputCatCombo() { - onView(withId(R.id.input_layout)).perform(click()) + waitForView(withId(R.id.input_layout)).perform(click()) } fun selectCatCombo(catCombo: String) { - onView(withText(catCombo)).perform(click()) + waitForView(withText(catCombo)).perform(click()) } fun chooseDate(date: String) { @@ -59,6 +58,7 @@ internal class DataSetInitialRobot( ).performClick() composeTestRule.onNodeWithContentDescription("Date", substring = true).performTextReplacement(date) composeTestRule.onNodeWithText("Accept", true).performClick() + composeTestRule.waitForIdle() } fun checkActionInputIsNotDisplayed() { diff --git a/app/src/androidTest/java/org/dhis2/usescases/datasets/DataSetTableRobot.kt b/app/src/androidTest/java/org/dhis2/usescases/datasets/DataSetTableRobot.kt index ae070a9fa4b..1805133dbdf 100644 --- a/app/src/androidTest/java/org/dhis2/usescases/datasets/DataSetTableRobot.kt +++ b/app/src/androidTest/java/org/dhis2/usescases/datasets/DataSetTableRobot.kt @@ -2,7 +2,6 @@ package org.dhis2.usescases.datasets -import androidx.compose.ui.graphics.Color import androidx.compose.ui.semantics.SemanticsProperties.TestTag import androidx.compose.ui.test.ExperimentalTestApi import androidx.compose.ui.test.SemanticsMatcher @@ -31,7 +30,6 @@ import androidx.compose.ui.test.performTextReplacement import androidx.compose.ui.test.performTouchInput import androidx.compose.ui.test.printToLog import androidx.compose.ui.test.swipeRight -import androidx.core.graphics.toColorInt import org.dhis2.common.BaseRobot import org.dhis2.composetable.ui.semantics.CELL_TEST_TAG import org.dhis2.composetable.ui.semantics.MANDATORY_ICON_TEST_TAG @@ -115,8 +113,19 @@ internal class DataSetTableRobot( composeTestRule.onNodeWithTag(SYNC_BUTTON_TAG) .assertIsDisplayed() .performClick() + + // Wait for the dialog to appear before interacting with it + composeTestRule.waitUntilExactlyOneExists( + hasText("Refresh"), + TIMEOUT, + ) composeTestRule.onNodeWithText("Refresh") .assertIsDisplayed() + + composeTestRule.waitUntilExactlyOneExists( + hasText("Not now"), + TIMEOUT, + ) composeTestRule.onNodeWithText("Not now") .assertIsDisplayed() .performClick() @@ -244,7 +253,7 @@ internal class DataSetTableRobot( fun assertTableIsDisplayed() { composeTestRule.waitUntilExactlyOneExists( hasTestTag("TABLE_SCROLLABLE_COLUMN"), - timeoutMillis = 3000 + timeoutMillis = TIMEOUT ) } @@ -252,7 +261,7 @@ internal class DataSetTableRobot( fun assertImmunizationTableIsDisplayed() { composeTestRule.waitUntilAtLeastOneExists( hasText("Fixed"), - timeoutMillis = 5000 + timeoutMillis = TIMEOUT ) } @@ -400,6 +409,10 @@ internal class DataSetTableRobot( fun checkItemWithTextIsDisplayed(text: String) { assertTableIsDisplayed() + composeTestRule.waitUntilAtLeastOneExists( + hasText(text, substring = true), + timeoutMillis = 10000 + ) composeTestRule.onNodeWithText(text, substring = true, useUnmergedTree = true) .assertIsDisplayed() } diff --git a/app/src/androidTest/java/org/dhis2/usescases/datasets/DataSetTest.kt b/app/src/androidTest/java/org/dhis2/usescases/datasets/DataSetTest.kt index 17f23469945..f9f5e33e751 100644 --- a/app/src/androidTest/java/org/dhis2/usescases/datasets/DataSetTest.kt +++ b/app/src/androidTest/java/org/dhis2/usescases/datasets/DataSetTest.kt @@ -307,6 +307,7 @@ class DataSetTest : BaseTest() { val cellId = "PGRlPlhOcmpYcVpySEQ4Ojxjb2M+SGxsdlg1MGNYQzA=" val threeDaysFromNowStr = threeDaysFromNow.format(formatter) val fiveDaysAgoStr = fiveDaysAgo.format(formatter) + enterDataSetStep( uid = dataSetUid, name = dataSetName, @@ -318,20 +319,12 @@ class DataSetTest : BaseTest() { ) checkTableIsNotEditable() - dataSetTableRobot(composeTestRule) { - tapOnSaveButton() - } - composeTestRule.waitForIdle() + createDailyPeriodDataSetInstanceStep( date = threeDaysFromNowStr, orgUnit = orgUnit, catCombo = catCombo ) - // Wait for table to be ready after creating the second dataset instance - composeTestRule.waitUntilExactlyOneExists( - hasTestTag("TABLE_SCROLLABLE_COLUMN"), - timeoutMillis = 10000 - ) tableIsVisible() enterDataStep( tableId = tableId, @@ -374,24 +367,25 @@ class DataSetTest : BaseTest() { } } - private suspend fun waitForTableToBeVisible() { - composeTestRule.awaitIdle() + private fun waitForTableToBeVisible() { + composeTestRule.waitForIdle() dataSetRobot { clickOnDataSetAtPosition(0) } tableIsVisible() } - private suspend fun checkTableIsNotEditable() { + private fun checkTableIsNotEditable() { tableIsVisible() composeTestRule.onNodeWithTag("TABLE_SCROLLABLE_COLUMN").printToLog("TABLE_LOG") dataSetTableRobot(composeTestRule) { checkItemWithTextIsDisplayed("This data is not editable") + tapOnSaveButton() + composeTestRule.waitForIdle() } - composeTestRule.waitForIdle() } - private suspend fun checkContentBoxesAreDisplayed() { + private fun checkContentBoxesAreDisplayed() { tableIsVisible() // Check top and bottom content is displayed in initial section dataSetDetailRobot(composeTestRule) { @@ -449,7 +443,7 @@ class DataSetTest : BaseTest() { } } - private suspend fun checkCategoryIsMovedToRow() { + private fun checkCategoryIsMovedToRow() { val cellIdSection8 = "PGRlPlAzakpINVR1NVZDLCA8Y28+RmJMWlMzdWVXYlE6PGNvPg==" val cellId2Section8 = "PGRlPkZRMm84VUJsY3JTLCA8Y28+RmJMWlMzdWVXYlE6PGNvPg==" val cellIdSection16 = "PGRlPkFyUzdWeXVMOTVmLCA8Y28+RmJMWlMzdWVXYlE6PGNvPg==" @@ -560,7 +554,7 @@ class DataSetTest : BaseTest() { } } - private suspend fun checkAutomaticGroupingDisabled() { + private fun checkAutomaticGroupingDisabled() { val table19 = "t3aNCvHsoSn_0" val table219 = "aN8uN5b15YG_1" val table20 = "ck7mRNwGDjP_1" @@ -637,7 +631,7 @@ class DataSetTest : BaseTest() { } } - private suspend fun checkPivotOptions() { + private fun checkPivotOptions() { val table5 = "aN8uN5b15YG" val table23 = "aN8uN5b15YG_1" val cellIdSection5 = "PGNvYz5ET0M3ZW1MenlSaTo8ZGU+TFNKNW1LcHlFdjE=" @@ -706,29 +700,29 @@ class DataSetTest : BaseTest() { } } - private suspend fun tableIsVisible() { - composeTestRule.awaitIdle() + private fun tableIsVisible() { + composeTestRule.waitForIdle() dataSetTableRobot(composeTestRule) { assertTableIsDisplayed() } } - private suspend fun checkImmunizationTableIsDisplayed() { - composeTestRule.awaitIdle() + private fun checkImmunizationTableIsDisplayed() { + composeTestRule.waitForIdle() dataSetTableRobot(composeTestRule) { assertImmunizationTableIsDisplayed() } } - private suspend fun syncButtonIsAvailableStep() { - composeTestRule.awaitIdle() + private fun syncButtonIsAvailableStep() { + composeTestRule.waitForIdle() dataSetTableRobot(composeTestRule) { syncIsAvailable() } } - private suspend fun checkTotals() { - composeTestRule.awaitIdle() + private fun checkTotals() { + composeTestRule.waitForIdle() dataSetTableRobot(composeTestRule) { totalsAreDisplayed( tableId = "dzjKKQq0cSO", @@ -793,14 +787,14 @@ class DataSetTest : BaseTest() { } } - private suspend fun checkIndicatorsStep() { - composeTestRule.awaitIdle() + private fun checkIndicatorsStep() { + composeTestRule.waitForIdle() dataSetTableRobot(composeTestRule) { indicatorTableIsDisplayed() } } - private suspend fun reenterDataSetToCheckValueSavedStep() { + private fun reenterDataSetToCheckValueSavedStep() { val cell00Id = "PGRlPnM0Nm01TVMwaHh1Ojxjb2M+UHJsdDBDMVJGMHM=" dataSetTableRobot(composeTestRule) { diff --git a/app/src/androidTest/java/org/dhis2/usescases/event/EventRegistrationRobot.kt b/app/src/androidTest/java/org/dhis2/usescases/event/EventRegistrationRobot.kt index d818ea3e31e..13f9abb3a6b 100644 --- a/app/src/androidTest/java/org/dhis2/usescases/event/EventRegistrationRobot.kt +++ b/app/src/androidTest/java/org/dhis2/usescases/event/EventRegistrationRobot.kt @@ -3,13 +3,10 @@ package org.dhis2.usescases.event import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.junit4.ComposeTestRule import androidx.compose.ui.test.onNodeWithText -import androidx.compose.ui.test.performClick import androidx.compose.ui.test.performScrollTo import androidx.test.espresso.Espresso.onView -import androidx.test.espresso.action.ViewActions.click import androidx.test.espresso.assertion.ViewAssertions.matches import androidx.test.espresso.matcher.ViewMatchers.withId -import androidx.test.platform.app.InstrumentationRegistry import org.dhis2.R import org.dhis2.common.BaseRobot import org.dhis2.common.matchers.hasCompletedPercentage @@ -25,45 +22,8 @@ fun eventRegistrationRobot( class EventRegistrationRobot(val composeTestRule: ComposeTestRule) : BaseRobot() { - fun openMenuMoreOptions() { - onView(withId(R.id.moreOptions)).perform(click()) - } - - fun clickOnDelete() { - with(InstrumentationRegistry.getInstrumentation().targetContext) { - composeTestRule.onNodeWithText(getString(R.string.delete)).performClick() - } - } - fun checkEventDataEntryIsOpened(completion: Int, orgUnit: String) { onView(withId(R.id.completion)).check(matches(hasCompletedPercentage(completion))) composeTestRule.onNodeWithText(orgUnit).performScrollTo().assertIsDisplayed() } - - fun clickOnShare() { - with(InstrumentationRegistry.getInstrumentation().targetContext) { - composeTestRule.onNodeWithText(getString(R.string.share)).performClick() - } - } - - private fun clickOnNextQR() { - waitForView(withId(R.id.next)).perform(click()) - } - - fun clickOnAllQR(listQR: Int) { - var qrLength = 1 - - while (qrLength < listQR) { - clickOnNextQR() - qrLength++ - } - } - - fun clickOnDeleteDialog() { - onView(withId(R.id.possitive)).perform(click()) - } - - fun clickNextButton() { - waitForView(withId(R.id.action_button)).perform(click()) - } } diff --git a/app/src/androidTest/java/org/dhis2/usescases/flow/teiFlow/TeiFlowRobot.kt b/app/src/androidTest/java/org/dhis2/usescases/flow/teiFlow/TeiFlowRobot.kt index 59904781ca9..a8d0f167239 100644 --- a/app/src/androidTest/java/org/dhis2/usescases/flow/teiFlow/TeiFlowRobot.kt +++ b/app/src/androidTest/java/org/dhis2/usescases/flow/teiFlow/TeiFlowRobot.kt @@ -116,8 +116,10 @@ class TeiFlowRobot(val composeTestRule: ComposeTestRule) : BaseRobot() { teiDashboardRobot(composeTestRule) { clickOnMenuMoreOptions() clickOnTimelineEvents() + composeTestRule.waitForIdle() clickOnMenuMoreOptions() clickOnMenuComplete() + composeTestRule.waitForIdle() checkCanNotAddEvent() checkAllEventsAreClosed() } diff --git a/app/src/androidTest/java/org/dhis2/usescases/login/LoginRobot.kt b/app/src/androidTest/java/org/dhis2/usescases/login/LoginRobot.kt index fce28c37788..25863c4453c 100644 --- a/app/src/androidTest/java/org/dhis2/usescases/login/LoginRobot.kt +++ b/app/src/androidTest/java/org/dhis2/usescases/login/LoginRobot.kt @@ -1,34 +1,24 @@ package org.dhis2.usescases.login import androidx.compose.ui.test.ExperimentalTestApi -import androidx.compose.ui.test.assert import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertIsEnabled +import androidx.compose.ui.test.assertIsNotEnabled import androidx.compose.ui.test.hasTestTag import androidx.compose.ui.test.hasText import androidx.compose.ui.test.junit4.ComposeTestRule import androidx.compose.ui.test.onNodeWithTag -import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick import androidx.compose.ui.test.performTextReplacement -import androidx.test.espresso.Espresso.onView -import androidx.test.espresso.action.ViewActions.click -import androidx.test.espresso.assertion.ViewAssertions.matches -import androidx.test.espresso.intent.Intents -import androidx.test.espresso.intent.matcher.IntentMatchers.hasComponent -import androidx.test.espresso.intent.matcher.IntentMatchers.hasExtra -import androidx.test.espresso.matcher.ViewMatchers.isDisplayed -import androidx.test.espresso.matcher.ViewMatchers.withId -import androidx.test.espresso.matcher.ViewMatchers.withText import androidx.test.platform.app.InstrumentationRegistry -import org.dhis2.R import org.dhis2.common.BaseRobot -import org.dhis2.commons.dialogs.bottomsheet.CLICKABLE_TEXT_TAG +import org.dhis2.mobile.login.accounts.ui.screen.ACCOUNT_ITEM_TAG +import org.dhis2.mobile.login.main.ui.screen.CREDENTIALS_ERROR_INFO_BAR_TAG +import org.dhis2.mobile.login.main.ui.screen.CREDENTIALS_LOGIN_BUTTON_TAG +import org.dhis2.mobile.login.main.ui.screen.CREDENTIALS_MANAGE_ACCOUNTS_BUTTON_TAG +import org.dhis2.mobile.login.main.ui.screen.CREDENTIALS_PASSWORD_INPUT_TAG +import org.dhis2.mobile.login.main.ui.screen.CREDENTIALS_USERNAME_INPUT_TAG import org.dhis2.mobile.login.main.ui.screen.SERVER_VALIDATION_CONTENT_BUTTON_TAG -import org.dhis2.usescases.BaseTest.Companion.MOCK_SERVER_URL -import org.dhis2.usescases.about.PolicyView -import org.dhis2.usescases.qrScanner.ScanActivity -import org.dhis2.utils.WebViewActivity -import org.hamcrest.CoreMatchers fun loginRobot( @@ -42,7 +32,6 @@ fun loginRobot( class LoginRobot(val composeTestRule: ComposeTestRule) : BaseRobot() { - val context = InstrumentationRegistry.getInstrumentation().targetContext @OptIn(ExperimentalTestApi::class) @@ -54,75 +43,144 @@ class LoginRobot(val composeTestRule: ComposeTestRule) : BaseRobot() { composeTestRule.onNodeWithTag(SERVER_VALIDATION_CONTENT_BUTTON_TAG).performClick() } + @OptIn(ExperimentalTestApi::class) fun typeServerToValidate(server: String) { + composeTestRule.waitUntilExactlyOneExists( + hasTestTag("INPUT_QR_CODE_FIELD"), + TIMEOUT + ) composeTestRule.onNodeWithTag("INPUT_QR_CODE_FIELD").performTextReplacement(server) } - fun clickQRButton() { - composeTestRule.onNodeWithTag("INPUT_QR_CODE_BUTTON").performClick() + @OptIn(ExperimentalTestApi::class) + fun checkServerInputIsDisplayed() { + composeTestRule.waitUntilExactlyOneExists( + hasTestTag("INPUT_QR_CODE_FIELD"), + TIMEOUT, + ) + composeTestRule.onNodeWithTag("INPUT_QR_CODE_FIELD").assertIsDisplayed() } - - fun checkAuthErrorAlertIsVisible() { - waitForView(withText(LOGIN_ERROR_TITLE)).check(matches(isDisplayed())) + @OptIn(ExperimentalTestApi::class) + fun typeUsername(username: String) { + // Wait for credentials screen to appear + composeTestRule.waitUntilExactlyOneExists( + hasTestTag(CREDENTIALS_USERNAME_INPUT_TAG), + TIMEOUT, + ) + // Click on the username input to focus it + composeTestRule.onNodeWithTag(CREDENTIALS_USERNAME_INPUT_TAG).performClick() + // Now find the inner text field and type the username + composeTestRule.onNodeWithTag("INPUT_USER_FIELD").performTextReplacement(username) } - fun clickOKAuthErrorAlert() { - onView(withText(OK)).perform(click()) + @OptIn(ExperimentalTestApi::class) + fun typePassword(password: String) { + // Wait for password field to appear + composeTestRule.waitUntilExactlyOneExists( + hasTestTag(CREDENTIALS_PASSWORD_INPUT_TAG), + TIMEOUT, + ) + // Click on the password input to focus it + composeTestRule.onNodeWithTag(CREDENTIALS_PASSWORD_INPUT_TAG).performClick() + // Now find the inner text field and type the password + composeTestRule.onNodeWithTag("INPUT_PASSWORD_TEXT_FIELD").performTextReplacement(password) } - fun checkUnblockSessionViewIsVisible() { - onView(withId(R.id.cardview_pin)).check(matches(isDisplayed())) + @OptIn(ExperimentalTestApi::class) + fun checkLoginButtonIsEnabled() { + closeKeyboard() + composeTestRule.waitUntilExactlyOneExists( + hasTestTag(CREDENTIALS_LOGIN_BUTTON_TAG), + TIMEOUT, + ) + composeTestRule.onNodeWithTag(CREDENTIALS_LOGIN_BUTTON_TAG) + .assertIsEnabled() } - fun checkURL(url: String) { - composeTestRule.onNodeWithTag("INPUT_QR_CODE_FIELD").assert(hasText(url)) + @OptIn(ExperimentalTestApi::class) + fun checkLoginButtonIsDisabled() { + composeTestRule.waitUntilExactlyOneExists( + hasTestTag(CREDENTIALS_LOGIN_BUTTON_TAG), + TIMEOUT, + ) + composeTestRule.onNodeWithTag(CREDENTIALS_LOGIN_BUTTON_TAG) + .assertIsNotEnabled() } - fun checkWebviewWithRecoveryAccountIsOpened() { - Intents.intended( - CoreMatchers.allOf( - hasExtra( - WebViewActivity.WEB_VIEW_URL, - "${MOCK_SERVER_URL}/dhis-web-commons/security/recovery.action" - ), - hasComponent(WebViewActivity::class.java.name) - ) + @OptIn(ExperimentalTestApi::class) + fun clickLoginButton() { + composeTestRule.waitUntilExactlyOneExists( + hasTestTag(CREDENTIALS_LOGIN_BUTTON_TAG), + TIMEOUT, ) + composeTestRule.onNodeWithTag(CREDENTIALS_LOGIN_BUTTON_TAG).performClick() } - fun checkQRScanIsOpened() { - Intents.intended(CoreMatchers.allOf(hasComponent(ScanActivity::class.java.name))) + @OptIn(ExperimentalTestApi::class) + fun checkServerValidationErrorIsDisplayed() { + // Wait for error to appear in the supporting text of the server input + composeTestRule.waitUntilAtLeastOneExists( + hasTestTag("INPUT_QR_CODE_FIELD"), + TIMEOUT, + ) + // The error is displayed as supporting text, we just need to verify the field is in error state } - fun checkShareDataDialogIsDisplayed() { - val title = InstrumentationRegistry.getInstrumentation() - .targetContext.getString(R.string.improve_app_msg_title) - composeTestRule.onNodeWithText(title) + @OptIn(ExperimentalTestApi::class) + fun checkCredentialsErrorIsDisplayed() { + // Wait for the InfoBar with error message to appear + composeTestRule.waitUntilAtLeastOneExists( + hasTestTag(CREDENTIALS_ERROR_INFO_BAR_TAG), + TIMEOUT, + ) + composeTestRule.onNodeWithTag(CREDENTIALS_ERROR_INFO_BAR_TAG).assertIsDisplayed() } - fun clickOnPrivacyPolicy() { - composeTestRule.onNodeWithTag(CLICKABLE_TEXT_TAG).performClick() + @OptIn(ExperimentalTestApi::class) + fun clickOnManageAccountsButton() { + composeTestRule.waitUntilExactlyOneExists( + hasTestTag(CREDENTIALS_MANAGE_ACCOUNTS_BUTTON_TAG), + TIMEOUT, + ) + composeTestRule.onNodeWithTag(CREDENTIALS_MANAGE_ACCOUNTS_BUTTON_TAG).performClick() } - fun acceptTrackerDialog() { - val title = InstrumentationRegistry - .getInstrumentation() - .targetContext.getString(R.string.improve_app_msg_title) - composeTestRule.onNodeWithText(title).assertIsDisplayed() + @OptIn(ExperimentalTestApi::class) + fun checkAccountIsListed(serverUrl: String, username: String) { + val accountTag = "${ACCOUNT_ITEM_TAG}_${serverUrl}_${username}" + composeTestRule.waitUntilExactlyOneExists( + hasTestTag(accountTag), + TIMEOUT, + ) + composeTestRule.onNodeWithTag(accountTag).assertIsDisplayed() } - fun clickYesOnAcceptTrackerDialog() { - composeTestRule.onNodeWithText(context.getString(R.string.yes)) - .performClick() + @OptIn(ExperimentalTestApi::class) + fun clickOnAccount(serverUrl: String, username: String) { + val accountTag = "${ACCOUNT_ITEM_TAG}_${serverUrl}_${username}" + composeTestRule.waitUntilExactlyOneExists( + hasTestTag(accountTag), + TIMEOUT, + ) + composeTestRule.onNodeWithTag(accountTag).performClick() } - fun checkPrivacyViewIsOpened() { - Intents.intended(CoreMatchers.allOf(hasComponent(PolicyView::class.java.name))) + @OptIn(ExperimentalTestApi::class) + fun checkTrackingPermissionDialogIsDisplayed() { + composeTestRule.waitUntilExactlyOneExists( + hasText("Do you want to help us improve this app?"), + TIMEOUT, + ) } - companion object { - const val LOGIN_ERROR_TITLE = "Login error" - const val OK = "OK" + @OptIn(ExperimentalTestApi::class) + fun acceptTrackingPermission() { + checkTrackingPermissionDialogIsDisplayed() + composeTestRule.waitUntilExactlyOneExists( + hasText("Yes"), + TIMEOUT, + ) + composeTestRule.onNode(hasText("Yes")).performClick() } } diff --git a/app/src/androidTest/java/org/dhis2/usescases/login/LoginTest.kt b/app/src/androidTest/java/org/dhis2/usescases/login/LoginTest.kt index a2cf81e3e12..708a2590a71 100644 --- a/app/src/androidTest/java/org/dhis2/usescases/login/LoginTest.kt +++ b/app/src/androidTest/java/org/dhis2/usescases/login/LoginTest.kt @@ -1,31 +1,20 @@ package org.dhis2.usescases.login -import android.app.Activity -import android.app.Instrumentation import android.content.Intent import androidx.compose.ui.test.junit4.createComposeRule import androidx.test.core.app.ApplicationProvider -import androidx.test.espresso.intent.Intents.intending -import androidx.test.espresso.intent.matcher.IntentMatchers import org.dhis2.common.keystore.KeyStoreRobot -import org.dhis2.commons.Constants.EXTRA_DATA -import org.dhis2.commons.prefs.Preference.Companion.PIN -import org.dhis2.commons.prefs.Preference.Companion.SESSION_LOCKED import org.dhis2.lazyActivityScenarioRule import org.dhis2.usescases.BaseTest -import org.dhis2.usescases.qrScanner.ScanActivity -import org.hamcrest.CoreMatchers.allOf import org.hisp.dhis.android.core.D2Manager import org.hisp.dhis.android.core.mockwebserver.ResponseController.Companion.API_ME_PATH import org.hisp.dhis.android.core.mockwebserver.ResponseController.Companion.API_SYSTEM_INFO_PATH import org.hisp.dhis.android.core.mockwebserver.ResponseController.Companion.GET import org.junit.FixMethodOrder -import org.junit.Ignore import org.junit.Rule import org.junit.Test import org.junit.runners.MethodSorters -@Ignore("implement in login module") @FixMethodOrder(MethodSorters.NAME_ASCENDING) class LoginTest : BaseTest() { @@ -39,6 +28,7 @@ class LoginTest : BaseTest() { restoreDataBaseOnBeforeAction = false super.setUp() setupMockServer() + clearAnalyticsPermission() D2Manager.removeCredentials() } @@ -46,110 +36,60 @@ class LoginTest : BaseTest() { override fun teardown() { restoreDataBaseOnBeforeAction = true super.teardown() + // Restore original credentials D2Manager.setCredentials(KeyStoreRobot.KEYSTORE_USERNAME, KeyStoreRobot.PASSWORD) } - @Ignore("implement in login module") @Test - fun loginFlow() { - mockWebServerRobot.addResponse(GET, API_LOGIN_CONFIG, API_LOGIN_CONFIG_RESPONSE, 200) - mockWebServerRobot.addResponse(GET, API_ME_PATH, API_ME_RESPONSE_OK) - mockWebServerRobot.addResponse(GET, API_SYSTEM_INFO_PATH, API_SYSTEM_INFO_RESPONSE_OK) - mockWebServerRobot.addResponse( - GET, - PATH_WEBAPP_GENERAL_SETTINGS, - API_METADATA_SETTINGS_RESPONSE_ERROR, - 404 - ) - mockWebServerRobot.addResponse(GET, PATH_WEBAPP_INFO, API_METADATA_SETTINGS_INFO_ERROR, 404) + fun shouldLoginWithoutOauth() { + enableIntents() startLoginActivity() loginRobot(composeTestRule) { - typeServerToValidate(MOCK_SERVER_URL) + // Step: Enter incorrect server URL and validate - should show error + typeServerToValidate("https://invalid-server.com") clickOnValidateServerButton() - // Test case - [ANDROAPP-4122](https://dhis2.atlassian.net/browse/ANDROAPP-4122) -// typeUsername(USERNAME) -// typePassword(PASSWORD) -// clearUsernameField() -// clearPasswordField() -// checkUsernameFieldIsClear() -// checkPasswordFieldIsClear() - - //Test case - [ANDROAPP-4123](https://dhis2.atlassian.net/browse/ANDROAPP-4123) -// checkLoginButtonIsHidden() - - // Test case - [ANDROAPP-4126](https://dhis2.atlassian.net/browse/ANDROAPP-4126) - enableIntents() -// clickAccountRecovery() - checkWebviewWithRecoveryAccountIsOpened() - pressBack() - - // Test case - [ANDROAPP-4121](https://dhis2.atlassian.net/browse/ANDROAPP-4121) - mockWebServerRobot.addResponse(GET, API_ME_PATH, API_ME_UNAUTHORIZE, HTTP_UNAUTHORIZE) -// selectUsernameField() -// typeUsername(USERNAME) -// typePassword(PASSWORD) - waitToDebounce(5000) -// clickLoginButton() - checkAuthErrorAlertIsVisible() - clickOKAuthErrorAlert() - - // Test case - [ANDROAPP-4121](https://dhis2.atlassian.net/browse/ANDROAPP-4121) - mockWebServerRobot.addResponse(GET, API_ME_PATH, API_ME_RESPONSE_OK) -// clearPasswordField() -// typePassword(PASSWORD) - waitToDebounce(5000) -// clickLoginButton() - - //Test case - [ANDROAPP-5184](https://dhis2.atlassian.net/browse/ANDROAPP-5184) - checkShareDataDialogIsDisplayed() - clickOnPrivacyPolicy() - checkPrivacyViewIsOpened() - pressBack() - acceptTrackerDialog() - clickYesOnAcceptTrackerDialog() - } - cleanDatabase() - } + checkServerValidationErrorIsDisplayed() - @Ignore("implement in login module") - @Test - fun goToPinScreenWhenPinWasSet() { - preferencesRobot.saveValue(SESSION_LOCKED, true) - preferencesRobot.saveValue(PIN, PIN_PASSWORD) - - startLoginActivity() - - loginRobot(composeTestRule) { + // Step: Enter correct server URL and validate - should proceed to credentials + mockWebServerRobot.addResponse(GET, API_LOGIN_CONFIG, API_LOGIN_CONFIG_RESPONSE, 200) + typeServerToValidate(MOCK_SERVER_URL) clickOnValidateServerButton() - checkUnblockSessionViewIsVisible() - } - } - @Ignore("implement in login module") - @Test - fun generateLoginThroughQR() { - preferencesRobot.cleanPreferences() - enableIntents() - startLoginActivity() + // Step: Credentials screen should be displayed + // Enter incorrect credentials - should show error + mockWebServerRobot.addResponse(GET, API_ME_PATH, API_ME_UNAUTHORIZE, HTTP_UNAUTHORIZE) + checkLoginButtonIsDisabled() + typeUsername("wronguser") + typePassword("wrongpassword") + checkLoginButtonIsEnabled() + clickLoginButton() + checkCredentialsErrorIsDisplayed() + + // Step: Enter correct credentials - should proceed to tracker dialog + mockWebServerRobot.addResponse(GET, API_ME_PATH, API_ME_RESPONSE_OK) + mockWebServerRobot.addResponse(GET, API_SYSTEM_INFO_PATH, API_SYSTEM_INFO_RESPONSE_OK) + mockWebServerRobot.addResponse( + GET, + PATH_WEBAPP_GENERAL_SETTINGS, + API_METADATA_SETTINGS_RESPONSE_OK, + 200 + ) + mockWebServerRobot.addResponse( + GET, + PATH_WEBAPP_INFO, + API_METADATA_SETTINGS_INFO_ERROR, + 404 + ) - loginRobot(composeTestRule) { - mockOnActivityForResult() - clickQRButton() - checkQRScanIsOpened() - checkURL(MOCK_SERVER_URL) - } - } + typeUsername(USERNAME) + typePassword(PASSWORD) + checkLoginButtonIsEnabled() + clickLoginButton() - private fun mockOnActivityForResult() { - val intent = Intent().apply { - putExtra(EXTRA_DATA, MOCK_SERVER_URL - ) + // Step: Handle tracking permission dialog + acceptTrackingPermission() } - val result = Instrumentation.ActivityResult(Activity.RESULT_OK, intent) - intending(allOf(IntentMatchers.hasComponent(ScanActivity::class.java.name))).respondWith( - result - ) } private fun startLoginActivity() { @@ -161,27 +101,32 @@ class LoginTest : BaseTest() { } - private fun cleanDatabase() { - context.deleteDatabase(DB_GENERATED_BY_LOGIN) + private fun clearAnalyticsPermission() { + D2Manager.getD2() + .dataStoreModule() + .localDataStore() + .value(ANALYTICS_PERMISSION_KEY) + .blockingDeleteIfExist() } companion object { const val HTTP_UNAUTHORIZE = 401 const val API_LOGIN_CONFIG = "/api/loginConfig" - const val API_LOGIN_CONFIG_RESPONSE ="mocks/loginconfig/legacy_flow_config.json" + const val API_LOGIN_CONFIG_RESPONSE = "mocks/loginconfig/legacy_flow_config.json" const val API_ME_RESPONSE_OK = "mocks/user/user.json" const val API_ME_UNAUTHORIZE = "mocks/user/unauthorize.json" const val API_SYSTEM_INFO_RESPONSE_OK = "mocks/systeminfo/systeminfo.json" - const val API_METADATA_SETTINGS_RESPONSE_ERROR = - "mocks/settingswebapp/generalsettings_404.json" + const val API_METADATA_SETTINGS_RESPONSE_OK = + "mocks/settingswebapp/generalsettings.json" const val API_METADATA_SETTINGS_INFO_ERROR = "mocks/settingswebapp/infosettings_404.json" const val PATH_WEBAPP_GENERAL_SETTINGS = "/api/dataStore/ANDROID_SETTING_APP/general_settings?.*" const val PATH_WEBAPP_INFO = "/api/dataStore/ANDROID_SETTINGS_APP/info?.*" - const val DB_GENERATED_BY_LOGIN = "127-0-0-1-8080_test_unencrypted.db" - const val PIN_PASSWORD = 1234 - const val USERNAME = "test" - const val PASSWORD = "Android123" + const val USERNAME = "android" // Existing test database username + const val PASSWORD = "Android123" // Existing test database password + + private const val ANALYTICS_PERMISSION_KEY = "analytics_permission" + } } diff --git a/app/src/androidTest/java/org/dhis2/usescases/main/MainRobot.kt b/app/src/androidTest/java/org/dhis2/usescases/main/MainRobot.kt index 07ad8529ebf..daa49524a31 100644 --- a/app/src/androidTest/java/org/dhis2/usescases/main/MainRobot.kt +++ b/app/src/androidTest/java/org/dhis2/usescases/main/MainRobot.kt @@ -17,13 +17,16 @@ import org.dhis2.common.BaseRobot import org.dhis2.usescases.main.program.HOME_ITEMS import org.dhis2.usescases.main.program.hasPrograms -fun homeRobot(robotBody: MainRobot.() -> Unit) { - MainRobot().apply { +fun homeRobot( + composeTestRule: ComposeTestRule, + robotBody: MainRobot.() -> Unit +) { + MainRobot(composeTestRule).apply { robotBody() } } -class MainRobot : BaseRobot() { +class MainRobot(val composeTestRule: ComposeTestRule) : BaseRobot() { fun clickOnNavigationDrawerMenu() = apply { waitForView(withId(R.id.menu)).perform(click()) @@ -34,19 +37,11 @@ class MainRobot : BaseRobot() { waitToDebounce(FRAGMENT_TRANSITION) } - fun clickOnPin() = apply { - onView(withId(R.id.nav_view)).perform(NavigationViewActions.navigateTo(R.id.block_button)) - } - fun clickAbout() = apply { onView(withId(R.id.nav_view)).perform(NavigationViewActions.navigateTo(R.id.menu_about)) waitToDebounce(FRAGMENT_TRANSITION) } - fun clickDeleteAccount() = apply { - onView(withId(R.id.nav_view)).perform(NavigationViewActions.navigateTo(R.id.delete_account)) - } - fun checkViewIsNotEmpty(composeTestRule: ComposeTestRule) { composeTestRule.waitUntil() { composeTestRule.onNodeWithTag(HOME_ITEMS) @@ -65,6 +60,5 @@ class MainRobot : BaseRobot() { companion object { const val FRAGMENT_TRANSITION = 1500L - const val LOGOUT_TRANSITION = 2000L } } diff --git a/app/src/androidTest/java/org/dhis2/usescases/main/MainTest.kt b/app/src/androidTest/java/org/dhis2/usescases/main/MainTest.kt index 1df078c6a6a..5e90b83bcdf 100644 --- a/app/src/androidTest/java/org/dhis2/usescases/main/MainTest.kt +++ b/app/src/androidTest/java/org/dhis2/usescases/main/MainTest.kt @@ -32,7 +32,7 @@ class MainTest : BaseTest() { @Test fun checkHomeScreenRecyclerviewHasElements() { - homeRobot { + homeRobot(composeTestRule) { composeTestRule.waitForIdle() checkViewIsNotEmpty(composeTestRule) } @@ -40,7 +40,7 @@ class MainTest : BaseTest() { @Test fun shouldNavigateToHomeWhenBackPressed() { - homeRobot { + homeRobot(composeTestRule) { clickOnNavigationDrawerMenu() clickOnSettings() pressBack() diff --git a/app/src/androidTest/java/org/dhis2/usescases/pin/PinRobot.kt b/app/src/androidTest/java/org/dhis2/usescases/pin/PinRobot.kt deleted file mode 100644 index a77476e6560..00000000000 --- a/app/src/androidTest/java/org/dhis2/usescases/pin/PinRobot.kt +++ /dev/null @@ -1,40 +0,0 @@ -package org.dhis2.usescases.pin - -import androidx.test.espresso.Espresso.onView -import androidx.test.espresso.action.ViewActions.click -import androidx.test.espresso.assertion.ViewAssertions.matches -import androidx.test.espresso.intent.Intents -import androidx.test.espresso.intent.matcher.IntentMatchers.hasComponent -import androidx.test.espresso.matcher.ViewMatchers.isDisplayed -import androidx.test.espresso.matcher.ViewMatchers.withId -import androidx.test.espresso.matcher.ViewMatchers.withText -import org.dhis2.R -import org.dhis2.common.BaseRobot -import org.dhis2.common.matchers.isToast -import org.dhis2.usescases.main.MainActivity -import org.hamcrest.CoreMatchers.allOf - -fun pinRobot(pinBody: PinRobot.() -> Unit) { - PinRobot().apply { - pinBody() - } -} - -class PinRobot : BaseRobot() { - - fun clickForgotCode() { - onView(withId(R.id.forgotCode)).perform(click()) - } - - fun clickPinButton(button: String) { - onView(withText(button)).perform(click()) - } - - fun checkRedirectToHome() { - Intents.intended(allOf(hasComponent(MainActivity::class.java.name))) - } - - fun checkToastDisplayed(toastText: String) { - onView(withText(toastText)).inRoot(isToast()).check(matches(isDisplayed())) - } -} diff --git a/app/src/androidTest/java/org/dhis2/usescases/pin/PinTest.kt b/app/src/androidTest/java/org/dhis2/usescases/pin/PinTest.kt deleted file mode 100644 index 75664686787..00000000000 --- a/app/src/androidTest/java/org/dhis2/usescases/pin/PinTest.kt +++ /dev/null @@ -1,31 +0,0 @@ -package org.dhis2.usescases.pin - -import androidx.test.ext.junit.runners.AndroidJUnit4 -import androidx.test.rule.ActivityTestRule -import org.dhis2.usescases.BaseTest -import org.dhis2.usescases.main.MainActivity -import org.dhis2.usescases.main.homeRobot -import org.junit.Rule -import org.junit.Test -import org.junit.runner.RunWith - -@RunWith(AndroidJUnit4::class) -class PinTest : BaseTest() { - - @get:Rule - val rule = ActivityTestRule(MainActivity::class.java, false, false) - - @Test - fun openPin() { - startActivity() - - homeRobot { - clickOnNavigationDrawerMenu() - clickOnPin() - } - } - - private fun startActivity() { - rule.launchActivity(null) - } -} diff --git a/app/src/androidTest/java/org/dhis2/usescases/programevent/ProgramEventTest.kt b/app/src/androidTest/java/org/dhis2/usescases/programevent/ProgramEventTest.kt index 99bde9ffc87..094cbace362 100644 --- a/app/src/androidTest/java/org/dhis2/usescases/programevent/ProgramEventTest.kt +++ b/app/src/androidTest/java/org/dhis2/usescases/programevent/ProgramEventTest.kt @@ -80,13 +80,11 @@ class ProgramEventTest : BaseTest() { programEventsRobot(composeTestRule) { clickOnEvent(eventDate) - } - eventRobot(composeTestRule) { - openMenuMoreOptions() - clickOnDelete() - clickOnDeleteDialog() - } - programEventsRobot(composeTestRule) { + eventRobot(composeTestRule) { + openMenuMoreOptions() + clickOnDelete() + clickOnDeleteDialog() + } checkEventWasDeleted(eventDate) } } diff --git a/app/src/androidTest/java/org/dhis2/usescases/programevent/robot/ProgramEventsRobot.kt b/app/src/androidTest/java/org/dhis2/usescases/programevent/robot/ProgramEventsRobot.kt index e8d749e430e..ad20e27a443 100644 --- a/app/src/androidTest/java/org/dhis2/usescases/programevent/robot/ProgramEventsRobot.kt +++ b/app/src/androidTest/java/org/dhis2/usescases/programevent/robot/ProgramEventsRobot.kt @@ -9,12 +9,6 @@ import androidx.compose.ui.test.junit4.ComposeContentTestRule import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick -import androidx.test.espresso.Espresso.onView -import androidx.test.espresso.action.ViewActions.click -import androidx.test.espresso.assertion.ViewAssertions.matches -import androidx.test.espresso.matcher.ViewMatchers.isDisplayed -import androidx.test.espresso.matcher.ViewMatchers.withId -import org.dhis2.R import org.dhis2.common.BaseRobot fun programEventsRobot( @@ -68,6 +62,7 @@ class ProgramEventsRobot(val composeTestRule: ComposeContentTestRule) : BaseRobo } fun checkEventWasDeleted(eventDate: String) { + composeTestRule.waitForIdle() composeTestRule.onNodeWithText(eventDate).assertDoesNotExist() } diff --git a/app/src/androidTest/java/org/dhis2/usescases/settings/SettingsTest.kt b/app/src/androidTest/java/org/dhis2/usescases/settings/SettingsTest.kt index ce1b9bcb812..bd1ffb25c07 100644 --- a/app/src/androidTest/java/org/dhis2/usescases/settings/SettingsTest.kt +++ b/app/src/androidTest/java/org/dhis2/usescases/settings/SettingsTest.kt @@ -34,7 +34,7 @@ class SettingsTest : BaseTest() { fun shouldFindEditPeriodDisabledWhenClickOnSyncData() { startActivity() - homeRobot { + homeRobot(composeTestRule) { clickOnNavigationDrawerMenu() clickOnSettings() } @@ -49,7 +49,7 @@ class SettingsTest : BaseTest() { fun shouldFindEditDisabledWhenClickOnSyncConfiguration() { startActivity() - homeRobot { + homeRobot(composeTestRule) { clickOnNavigationDrawerMenu() clickOnSettings() } @@ -64,7 +64,7 @@ class SettingsTest : BaseTest() { fun shouldFindEditDisableWhenClickOnSyncParameters() { startActivity() - homeRobot { + homeRobot(composeTestRule) { clickOnNavigationDrawerMenu() clickOnSettings() } @@ -79,7 +79,7 @@ class SettingsTest : BaseTest() { fun shouldRefillValuesWhenClickOnReservedValues() { startActivity() - homeRobot { + homeRobot(composeTestRule) { clickOnNavigationDrawerMenu() clickOnSettings() } @@ -94,7 +94,7 @@ class SettingsTest : BaseTest() { fun shouldSuccessfullyOpenLogs() { startActivity() - homeRobot { + homeRobot(composeTestRule) { clickOnNavigationDrawerMenu() clickOnSettings() } @@ -112,7 +112,7 @@ class SettingsTest : BaseTest() { startActivity() - homeRobot { + homeRobot(composeTestRule) { clickOnNavigationDrawerMenu() clickOnSettings() } @@ -129,7 +129,7 @@ class SettingsTest : BaseTest() { startActivity() - homeRobot { + homeRobot(composeTestRule) { clickOnNavigationDrawerMenu() clickOnSettings() } diff --git a/app/src/androidTest/java/org/dhis2/usescases/sync/MockedWorkManagerController.kt b/app/src/androidTest/java/org/dhis2/usescases/sync/MockedWorkManagerController.kt deleted file mode 100644 index aa258d6b737..00000000000 --- a/app/src/androidTest/java/org/dhis2/usescases/sync/MockedWorkManagerController.kt +++ /dev/null @@ -1,53 +0,0 @@ -package org.dhis2.usescases.sync - -import androidx.lifecycle.LiveData -import androidx.work.WorkInfo -import org.dhis2.data.service.workManager.WorkManagerController -import org.dhis2.data.service.workManager.WorkerItem - -class MockedWorkManagerController(private val workInfoStatuses: LiveData>) : - WorkManagerController { - - override fun syncDataForWorker(workerItem: WorkerItem) { - } - - override fun syncDataForWorker(dataWorkerTag: String, workName: String) { - - } - - override fun syncMetaDataForWorker(metadataWorkerTag: String, workName: String) { - } - - override fun beginUniqueWork(workerItem: WorkerItem) { - } - - override fun enqueuePeriodicWork(workerItem: WorkerItem) { - } - - override fun getWorkInfosForUniqueWorkLiveData(workerName: String): LiveData> { - return workInfoStatuses - } - - override fun getWorkInfosByTagLiveData(tag: String): LiveData> { - return workInfoStatuses - } - - override fun getWorkInfosForTags(vararg tags: String): LiveData> { - return workInfoStatuses - } - - override fun cancelAllWork() { - } - - override suspend fun cancelAllWorkAndWait() { - } - - override fun cancelAllWorkByTag(tag: String) { - } - - override fun cancelUniqueWork(workName: String) { - } - - override fun pruneWork() { - } -} \ No newline at end of file diff --git a/app/src/androidTest/java/org/dhis2/usescases/sync/MockedWorkManagerModule.kt b/app/src/androidTest/java/org/dhis2/usescases/sync/MockedWorkManagerModule.kt deleted file mode 100644 index 6d312feb985..00000000000 --- a/app/src/androidTest/java/org/dhis2/usescases/sync/MockedWorkManagerModule.kt +++ /dev/null @@ -1,13 +0,0 @@ -package org.dhis2.usescases.sync - -import androidx.work.WorkManager -import org.dhis2.data.service.workManager.WorkManagerController -import org.dhis2.data.service.workManager.WorkManagerModule - -class MockedWorkManagerModule(private val mockedController: WorkManagerController) : - WorkManagerModule() { - - override fun providesWorkManagerController(workManager: WorkManager): WorkManagerController { - return mockedController - } -} \ No newline at end of file diff --git a/app/src/androidTest/java/org/dhis2/usescases/sync/SyncActivityTest.kt b/app/src/androidTest/java/org/dhis2/usescases/sync/SyncActivityTest.kt index 006b9ad8be9..8b9b969d5e3 100644 --- a/app/src/androidTest/java/org/dhis2/usescases/sync/SyncActivityTest.kt +++ b/app/src/androidTest/java/org/dhis2/usescases/sync/SyncActivityTest.kt @@ -1,52 +1,60 @@ package org.dhis2.usescases.sync -import androidx.lifecycle.MutableLiveData -import androidx.test.core.app.ApplicationProvider -import androidx.test.ext.junit.rules.activityScenarioRule +import androidx.test.core.app.ActivityScenario import androidx.test.ext.junit.runners.AndroidJUnit4 -import androidx.work.Data -import androidx.work.WorkInfo -import org.dhis2.AppTest -import org.dhis2.commons.Constants +import kotlinx.coroutines.flow.MutableStateFlow +import org.dhis2.mobile.sync.data.SyncBackgroundJobAction +import org.dhis2.mobile.sync.model.SyncJobStatus +import org.dhis2.mobile.sync.model.SyncStatus import org.dhis2.usescases.BaseTest -import org.junit.Before +import org.junit.After import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith -import java.util.UUID -import org.junit.After +import org.koin.test.KoinTest +import org.koin.test.mock.MockProviderRule +import org.koin.test.mock.declareMock +import org.mockito.Mockito +import org.mockito.kotlin.any +import org.mockito.kotlin.doAnswer +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.whenever + @RunWith(AndroidJUnit4::class) -class SyncActivityTest : BaseTest() { - private lateinit var workInfoStatusLiveData: MutableLiveData> +class SyncActivityTest : BaseTest(), KoinTest { + private val metadataSyncJobStatuses = MutableStateFlow>(emptyList()) @get:Rule - val rule = activityScenarioRule() - - @Before - override fun setUp() { - super.setUp() - workInfoStatusLiveData = - ApplicationProvider.getApplicationContext().mutableWorkInfoStatuses - } + val mockProvider = + MockProviderRule.create { clazz -> + Mockito.mock(clazz.java) + } @After override fun teardown() { super.teardown() - workInfoStatusLiveData.postValue(emptyList()) + metadataSyncJobStatuses.tryEmit(emptyList()) } @Test fun shouldShowMetadataErrorDialog() { - syncRobot { - waitUntilActivityVisible() - waitToDebounce(3000) - workInfoStatusLiveData.postValue(arrayListOf(mockedMetaWorkInfo(WorkInfo.State.RUNNING))) - waitToDebounce(3000) - workInfoStatusLiveData.postValue(arrayListOf(mockedMetaWorkInfo(WorkInfo.State.FAILED))) - waitToDebounce(3000) - checkMetadataErrorDialogIsDisplayed() + declareMock { + whenever(launchMetadataSync(any())) doAnswer {} + whenever(observeMetadataJob()) doReturn metadataSyncJobStatuses + } + + ActivityScenario.launch(SyncActivity::class.java).use { + syncRobot { + waitUntilActivityVisible() + waitToDebounce(3000) + metadataSyncJobStatuses.tryEmit(listOf(mockedMetadataJobStatus(SyncStatus.Running))) + waitToDebounce(3000) + metadataSyncJobStatuses.tryEmit(listOf(mockedMetadataJobStatus(SyncStatus.Failed))) + waitToDebounce(3000) + checkMetadataErrorDialogIsDisplayed() + } } } @@ -54,28 +62,28 @@ class SyncActivityTest : BaseTest() { fun shouldCompleteSyncProcess() { enableIntents() - syncRobot { - waitUntilActivityVisible() - workInfoStatusLiveData.postValue(arrayListOf(mockedMetaWorkInfo(WorkInfo.State.RUNNING))) - waitToDebounce(3000) - checkMetadataIsSyncing() - checkDataIsWaiting() - workInfoStatusLiveData.postValue(arrayListOf(mockedMetaWorkInfo(WorkInfo.State.SUCCEEDED))) - waitToDebounce(3000) - checkMainActivityIsLaunched() + declareMock { + whenever(launchMetadataSync(any())) doAnswer {} + whenever(observeMetadataJob()) doReturn metadataSyncJobStatuses } - } - private fun mockedMetaWorkInfo(state: WorkInfo.State): WorkInfo { - return WorkInfo( - id = UUID.randomUUID(), - state = state, - tags = setOf(Constants.META_NOW), - outputData = Data.EMPTY, - progress = Data.EMPTY, - runAttemptCount = 0, - generation = 0 - ) + ActivityScenario.launch(SyncActivity::class.java).use { + syncRobot { + waitUntilActivityVisible() + metadataSyncJobStatuses.tryEmit(listOf(mockedMetadataJobStatus(SyncStatus.Running))) + waitToDebounce(3000) + checkMetadataIsSyncing() + checkDataIsWaiting() + metadataSyncJobStatuses.tryEmit(listOf(mockedMetadataJobStatus(SyncStatus.Succeed))) + waitToDebounce(3000) + checkMainActivityIsLaunched() + } + } } -} + private fun mockedMetadataJobStatus(status: SyncStatus) = SyncJobStatus( + tags = listOf("METADATA_SYNC", "METADATA_SYNC_NOW"), + status = status, + message = null + ) +} diff --git a/app/src/androidTest/java/org/dhis2/usescases/teidashboard/TeiDashboardTest.kt b/app/src/androidTest/java/org/dhis2/usescases/teidashboard/TeiDashboardTest.kt index 93c3c49e54f..1e67d65ca19 100644 --- a/app/src/androidTest/java/org/dhis2/usescases/teidashboard/TeiDashboardTest.kt +++ b/app/src/androidTest/java/org/dhis2/usescases/teidashboard/TeiDashboardTest.kt @@ -339,23 +339,21 @@ class TeiDashboardTest : BaseTest() { clickOnTimelineEvents() clickOnMenuMoreOptions() clickOnMenuProgramEnrollments() - } - enrollmentRobot(composeTestRule) { - clickOnAProgramForEnrollment(composeTestRule, womanProgram) - clickOnAcceptInDatePicker() + enrollmentRobot(composeTestRule) { + clickOnAProgramForEnrollment(composeTestRule, womanProgram) + clickOnAcceptInDatePicker() - orgUnitSelectorRobot(composeTestRule) { - selectTreeOrgUnit(orgUnit) - } + orgUnitSelectorRobot(composeTestRule) { + selectTreeOrgUnit(orgUnit) + } - waitUntilActivityVisible() - openFormSection(personAttribute) - typeOnInputDateField("01012000", "Date of birth") - clickOnSaveEnrollment() - } + waitUntilActivityVisible() + openFormSection(personAttribute) + typeOnInputDateField("01012000", "Date of birth") + clickOnSaveEnrollment() + } - teiDashboardRobot(composeTestRule) { waitToDebounce(1000) clickOnMenuMoreOptions() clickOnTimelineEvents() diff --git a/app/src/androidTest/java/org/dhis2/usescases/teidashboard/dialogs/scheduling/SchedulingDialogUiTest.kt b/app/src/androidTest/java/org/dhis2/usescases/teidashboard/dialogs/scheduling/SchedulingDialogUiTest.kt index 439cd182a0d..7406d43880b 100644 --- a/app/src/androidTest/java/org/dhis2/usescases/teidashboard/dialogs/scheduling/SchedulingDialogUiTest.kt +++ b/app/src/androidTest/java/org/dhis2/usescases/teidashboard/dialogs/scheduling/SchedulingDialogUiTest.kt @@ -1,10 +1,13 @@ package org.dhis2.usescases.teidashboard.dialogs.scheduling import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.hasAnyAncestor import androidx.compose.ui.test.hasTestTag +import androidx.compose.ui.test.isSelectable import androidx.compose.ui.test.junit4.createAndroidComposeRule import androidx.compose.ui.test.onAllNodesWithTag import androidx.compose.ui.test.onFirst +import androidx.compose.ui.test.onLast import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick @@ -34,7 +37,11 @@ class SchedulingDialogUiTest : BaseTest() { val composeTestRule = createAndroidComposeRule() private val viewModel: SchedulingViewModel = mock() - private val enrollment = Enrollment.builder().uid("enrollmentUid").build() + private val enrollment = Enrollment + .builder() + .uid("enrollmentUid") + .attributeOptionCombo("attributeOptionComboUid") + .build() private val overdueSubtitle = "Overdue subtitle" @@ -145,7 +152,14 @@ class SchedulingDialogUiTest : BaseTest() { ) { } } - composeTestRule.onNodeWithText("No").performClick() + composeTestRule.waitForIdle() + composeTestRule + .onAllNodes( + matcher = hasAnyAncestor(hasTestTag("YES_NO_OPTIONS")) and isSelectable(), + useUnmergedTree = true, + ) + .onLast() + .performClick() composeTestRule.onNodeWithText("Program stage").assertDoesNotExist() composeTestRule.onNodeWithText("Date").assertDoesNotExist() composeTestRule.onNodeWithText("CatCombo *").assertDoesNotExist() diff --git a/app/src/androidTest/java/org/dhis2/usescases/teidashboard/robot/EnrollmentRobot.kt b/app/src/androidTest/java/org/dhis2/usescases/teidashboard/robot/EnrollmentRobot.kt index 1941f23e3db..1a95d437aa5 100644 --- a/app/src/androidTest/java/org/dhis2/usescases/teidashboard/robot/EnrollmentRobot.kt +++ b/app/src/androidTest/java/org/dhis2/usescases/teidashboard/robot/EnrollmentRobot.kt @@ -40,9 +40,15 @@ fun enrollmentRobot( class EnrollmentRobot(val composeTestRule: ComposeTestRule) : BaseRobot() { + @OptIn(ExperimentalTestApi::class) fun clickOnAProgramForEnrollment(composeTestRule: ComposeTestRule, program: String) { + val testTag = PROGRAM_TO_ENROLL.format(program) composeTestRule.waitForIdle() - composeTestRule.onNodeWithTag(PROGRAM_TO_ENROLL.format(program), useUnmergedTree = true) + composeTestRule.waitUntilAtLeastOneExists( + hasTestTag(testTag), + TIMEOUT + ) + composeTestRule.onNodeWithTag(testTag, useUnmergedTree = true) .performClick() composeTestRule.waitForIdle() } diff --git a/app/src/androidTest/java/org/dhis2/usescases/teidashboard/robot/EventRobot.kt b/app/src/androidTest/java/org/dhis2/usescases/teidashboard/robot/EventRobot.kt index b42e272c0ad..cb6475cdba3 100644 --- a/app/src/androidTest/java/org/dhis2/usescases/teidashboard/robot/EventRobot.kt +++ b/app/src/androidTest/java/org/dhis2/usescases/teidashboard/robot/EventRobot.kt @@ -3,17 +3,11 @@ package org.dhis2.usescases.teidashboard.robot import androidx.compose.ui.test.ExperimentalTestApi import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.assertIsNotDisplayed -import androidx.compose.ui.test.hasAnyAncestor -import androidx.compose.ui.test.hasAnySibling import androidx.compose.ui.test.hasTestTag -import androidx.compose.ui.test.hasText -import androidx.compose.ui.test.isDialog import androidx.compose.ui.test.junit4.ComposeTestRule -import androidx.compose.ui.test.onNodeWithContentDescription import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick -import androidx.compose.ui.test.performTextReplacement import androidx.test.espresso.Espresso.onView import androidx.test.espresso.action.ViewActions.click import androidx.test.espresso.assertion.ViewAssertions.matches @@ -52,63 +46,26 @@ class EventRobot(val composeTestRule: ComposeTestRule) : BaseRobot() { composeTestRule.onNodeWithTag(MAIN_BUTTON_TAG).performClick() } - fun checkSecondaryButtonNotVisible() { - composeTestRule.onNodeWithTag(SECONDARY_BUTTON_TAG).assertDoesNotExist() - } - @OptIn(ExperimentalTestApi::class) fun clickOnReopen() { composeTestRule.waitUntilAtLeastOneExists(hasTestTag("REOPEN_BUTTON")) composeTestRule.onNodeWithTag("REOPEN_BUTTON", useUnmergedTree = true).performClick() } - fun acceptUpdateEventDate() { - composeTestRule.onNodeWithText("OK", true).performClick() - } - fun openMenuMoreOptions() { - onView(withId(R.id.moreOptions)).perform(click()) + waitForView(withId(R.id.moreOptions)).perform(click()) } fun clickOnDelete() { with(InstrumentationRegistry.getInstrumentation().targetContext) { val deleteLabel = getString(R.string.delete) composeTestRule.onNodeWithText(deleteLabel).performClick() + composeTestRule.waitForIdle() } } fun clickOnDeleteDialog() { - onView(withId(R.id.possitive)).perform(click()) - } - - fun clickOnEventDueDate() { - composeTestRule.onNode( - hasTestTag("INPUT_DATE_TIME_ACTION_BUTTON") and hasAnySibling( - hasText("Due date") - ) - ).assertIsDisplayed().performClick() - - } - - fun selectSpecificDate(currentDate: String, date: String) { - composeTestRule.onNodeWithTag("DATE_PICKER").assertIsDisplayed() - composeTestRule.onNodeWithContentDescription( - label = "text", - substring = true, - useUnmergedTree = true, - ).performClick() - composeTestRule.onNode( - hasText(currentDate) and hasAnyAncestor(isDialog()) - ).performTextReplacement(date) - } - - @OptIn(ExperimentalTestApi::class) - fun typeOnDateParameter(dateValue: String) { - composeTestRule.waitUntilAtLeastOneExists(hasTestTag("INPUT_DATE_TIME_TEXT_FIELD"),2000) - composeTestRule.apply { - onNodeWithTag("INPUT_DATE_TIME_TEXT_FIELD").performClick() - onNodeWithTag("INPUT_DATE_TIME_TEXT_FIELD").performTextReplacement(dateValue) - } + waitForView(withId(R.id.possitive)).perform(click()) } fun checkEventDetails(eventDate: String, eventOrgUnit: String) { @@ -123,10 +80,6 @@ class EventRobot(val composeTestRule: ComposeTestRule) : BaseRobot() { Intents.intended(allOf(IntentMatchers.hasComponent(EventCaptureActivity::class.java.name))) } - fun openEventDetailsSection() { - composeTestRule.onNodeWithText("Event details").performClick() - } - fun checkEventIsOpen() { composeTestRule.onNodeWithTag("REOPEN_BUTTON").assertIsNotDisplayed() } diff --git a/app/src/androidTest/java/org/dhis2/usescases/teidashboard/robot/NoteRobot.kt b/app/src/androidTest/java/org/dhis2/usescases/teidashboard/robot/NoteRobot.kt index 53774a20689..ed7f3e92a96 100644 --- a/app/src/androidTest/java/org/dhis2/usescases/teidashboard/robot/NoteRobot.kt +++ b/app/src/androidTest/java/org/dhis2/usescases/teidashboard/robot/NoteRobot.kt @@ -1,6 +1,5 @@ package org.dhis2.usescases.teidashboard.robot -import androidx.test.espresso.Espresso.onView import androidx.test.espresso.action.TypeTextAction import androidx.test.espresso.action.ViewActions.click import androidx.test.espresso.assertion.ViewAssertions.matches @@ -30,7 +29,7 @@ fun noteRobot(noteRobot: NoteRobot.() -> Unit) { class NoteRobot : BaseRobot() { fun clickOnFabAddNewNote() { - onView(withId(R.id.addNoteButton)).check(matches(isDisplayed())).perform(click()) + waitForView(withId(R.id.addNoteButton)).check(matches(isDisplayed())).perform(click()) } fun verifyNoteDetailActivityIsLaunched() { @@ -38,24 +37,24 @@ class NoteRobot : BaseRobot() { } fun typeNote(text: String) { - onView(withId(R.id.noteText)).perform(TypeTextAction(text)) + waitForView(withId(R.id.noteText)).perform(TypeTextAction(text)) closeKeyboard() } fun clickOnSaveButton() { - waitForView(withText(R.string.save)) + waitForView(allOf(withId(R.id.saveButton), withText(R.string.save))) .check(matches(allOf(isDisplayed(), isEnabled()))) .perform(click()) } fun clickYesOnAlertDialog() { - waitForView(withText(R.string.yes)) + waitForView(withId(android.R.id.button1), waitMillis = DIALOG_WAIT_TIMEOUT_MS) .check(matches(isDisplayed())) .perform(click()) } fun checkNoteWasNotCreated(text: String) { - onView(withId(R.id.notes_recycler)).check( + waitForView(withId(R.id.notes_recycler), waitMillis = NOTES_WAIT_TIMEOUT_MS).check( matches( not( atPosition( @@ -68,14 +67,14 @@ class NoteRobot : BaseRobot() { } fun checkNewNoteWasCreated(text: String) { - waitForView(withId(R.id.notes_recycler)).check( - matches( - allOf( - isDisplayed(), - isNotEmpty(), - atPosition(0, hasDescendant(withText(text))) - ) - ) + waitForView( + allOf( + withId(R.id.notes_recycler), + isDisplayed(), + isNotEmpty(), + atPosition(0, hasDescendant(withText(text))) + ), + waitMillis = NOTES_WAIT_TIMEOUT_MS ) } @@ -86,10 +85,28 @@ class NoteRobot : BaseRobot() { } fun checkNoteDetails(user: String, noteText: String) { - waitForView(withId(R.id.notes_recycler)).check(matches(isDisplayed())) - waitForView(allOf(withId(R.id.storeBy), withEffectiveVisibility(Visibility.VISIBLE), withText(user))) + waitForView(withId(R.id.notes_recycler), waitMillis = NOTES_WAIT_TIMEOUT_MS) + .check(matches(isDisplayed())) + waitForView( + allOf( + withId(R.id.storeBy), + withEffectiveVisibility(Visibility.VISIBLE), + withText(user) + ) + ) .check(matches(withEffectiveVisibility(Visibility.VISIBLE))) - waitForView(allOf(withId(R.id.note_text), withEffectiveVisibility(Visibility.VISIBLE), withText(noteText))) + waitForView( + allOf( + withId(R.id.note_text), + withEffectiveVisibility(Visibility.VISIBLE), + withText(noteText) + ) + ) .check(matches(withEffectiveVisibility(Visibility.VISIBLE))) } + + companion object { + private const val DIALOG_WAIT_TIMEOUT_MS = 10000 + private const val NOTES_WAIT_TIMEOUT_MS = 15000 + } } diff --git a/app/src/androidTest/java/org/dhis2/usescases/teidashboard/robot/TeiDashboardRobot.kt b/app/src/androidTest/java/org/dhis2/usescases/teidashboard/robot/TeiDashboardRobot.kt index 5c1ae787770..d43cf6a216f 100644 --- a/app/src/androidTest/java/org/dhis2/usescases/teidashboard/robot/TeiDashboardRobot.kt +++ b/app/src/androidTest/java/org/dhis2/usescases/teidashboard/robot/TeiDashboardRobot.kt @@ -7,8 +7,6 @@ import androidx.compose.ui.test.hasAnySibling import androidx.compose.ui.test.hasTestTag import androidx.compose.ui.test.hasText import androidx.compose.ui.test.junit4.ComposeTestRule -import androidx.compose.ui.test.onAllNodesWithText -import androidx.compose.ui.test.onFirst import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick @@ -17,7 +15,6 @@ import androidx.test.espresso.Espresso.onView import androidx.test.espresso.NoMatchingViewException import androidx.test.espresso.action.ViewActions.click import androidx.test.espresso.assertion.ViewAssertions.matches -import androidx.test.espresso.contrib.RecyclerViewActions.actionOnItem import androidx.test.espresso.contrib.RecyclerViewActions.actionOnItemAtPosition import androidx.test.espresso.intent.Intents import androidx.test.espresso.intent.matcher.IntentMatchers @@ -31,20 +28,14 @@ import androidx.test.platform.app.InstrumentationRegistry import org.dhis2.R import org.dhis2.common.BaseRobot import org.dhis2.common.matchers.RecyclerviewMatchers.Companion.atPosition -import org.dhis2.common.matchers.RecyclerviewMatchers.Companion.hasItem import org.dhis2.common.matchers.RecyclerviewMatchers.Companion.isNotEmpty -import org.dhis2.usescases.event.entity.EventStatusUIModel -import org.dhis2.usescases.event.entity.TEIProgramStagesUIModel -import org.dhis2.usescases.flow.teiFlow.entity.DateRegistrationUIModel import org.dhis2.usescases.programStageSelection.ProgramStageSelectionActivity import org.dhis2.usescases.programStageSelection.ProgramStageSelectionViewHolder -import org.dhis2.usescases.teiDashboard.dashboardfragments.teidata.teievents.EventViewHolder import org.dhis2.usescases.teiDashboard.ui.INFO_BAR_TEST_TAG import org.dhis2.usescases.teiDashboard.ui.TEST_ADD_EVENT_BUTTON import org.dhis2.usescases.teiDashboard.ui.TEST_ADD_EVENT_BUTTON_IN_TIMELINE import org.dhis2.usescases.teiDashboard.ui.model.InfoBarType import org.dhis2.usescases.teidashboard.entity.EnrollmentUIModel -import org.dhis2.usescases.teidashboard.entity.UpperEnrollmentUIModel import org.hamcrest.CoreMatchers.allOf import org.hamcrest.CoreMatchers.anyOf import org.hamcrest.CoreMatchers.equalTo @@ -58,6 +49,7 @@ fun teiDashboardRobot( } } +@OptIn(ExperimentalTestApi::class) class TeiDashboardRobot(val composeTestRule: ComposeTestRule) : BaseRobot() { fun goToNotes() { @@ -84,7 +76,7 @@ class TeiDashboardRobot(val composeTestRule: ComposeTestRule) : BaseRobot() { R.string.navigation_analytics ) composeTestRule.waitUntilExactlyOneExists( - hasText(analyticsText,true), + hasText(analyticsText, true), TIMEOUT ) composeTestRule.onNodeWithText(analyticsText, useUnmergedTree = true).performClick() @@ -113,11 +105,6 @@ class TeiDashboardRobot(val composeTestRule: ComposeTestRule) : BaseRobot() { composeTestRule.onNodeWithText(title).performClick() } - fun clickOnEventWith(searchParam: String) { - composeTestRule.onAllNodesWithText(searchParam, useUnmergedTree = true).onFirst() - .performClick() - } - fun clickOnFab() { composeTestRule.onNodeWithTag(TEST_ADD_EVENT_BUTTON_IN_TIMELINE, useUnmergedTree = true) .performClick() @@ -144,11 +131,6 @@ class TeiDashboardRobot(val composeTestRule: ComposeTestRule) : BaseRobot() { Intents.intended(allOf(IntentMatchers.hasComponent(ProgramStageSelectionActivity::class.java.name))) } - - fun clickOnReferralOption(oneTime: String) { - composeTestRule.onNodeWithText(oneTime).performClick() - } - fun clickOnReferralNextButton() { waitForView(withId(R.id.action_button)).perform(click()) } @@ -184,6 +166,10 @@ class TeiDashboardRobot(val composeTestRule: ComposeTestRule) : BaseRobot() { fun clickOnMenuComplete() { with(InstrumentationRegistry.getInstrumentation().targetContext) { + composeTestRule.waitUntilExactlyOneExists( + hasText(getString(R.string.complete)), + TIMEOUT + ) composeTestRule.onNodeWithText(getString(R.string.complete)).performClick() } } @@ -199,22 +185,6 @@ class TeiDashboardRobot(val composeTestRule: ComposeTestRule) : BaseRobot() { .assertDoesNotExist() } - fun clickOnShareButton() { - with(InstrumentationRegistry.getInstrumentation().targetContext) { - composeTestRule.onNodeWithText(getString(R.string.share)).performClick() - } - } - - fun clickOnNextQR() { - var qrLenght = 1 - - while (qrLenght < 8) { - waitForView(withId(R.id.next)) - onView(withId(R.id.next)).perform(click()) - qrLenght++ - } - } - fun clickOnMenuDeleteTEI() { with(InstrumentationRegistry.getInstrumentation().targetContext) { composeTestRule.onNodeWithText(getString(R.string.dashboard_menu_delete_person)) @@ -222,11 +192,6 @@ class TeiDashboardRobot(val composeTestRule: ComposeTestRule) : BaseRobot() { } } - fun checkUpperInfo(upperInformation: UpperEnrollmentUIModel) { - onView(withId(R.id.org_unit)) - .check(matches(withText(upperInformation.orgUnit))) - } - fun clickOnSeeDetails() { waitForView(withId(R.id.editButton)) .perform(click()) @@ -270,17 +235,14 @@ class TeiDashboardRobot(val composeTestRule: ComposeTestRule) : BaseRobot() { fun clickOnMenuProgramEnrollments() { with(InstrumentationRegistry.getInstrumentation().targetContext) { val programSelectorLabel = getString(R.string.more_enrollments) + composeTestRule.waitUntilExactlyOneExists( + hasText(programSelectorLabel), + TIMEOUT + ) composeTestRule.onNodeWithText(programSelectorLabel).performClick() } } - fun checkEventWasCreatedAndClosed(eventName: String) { - composeTestRule.onNodeWithText(eventName).assertIsDisplayed() - val targetContext: Context = InstrumentationRegistry.getInstrumentation().targetContext - val viewOnlyText = targetContext.resources.getString(R.string.view_only) - composeTestRule.onNodeWithText(viewOnlyText).assertDoesNotExist() - } - fun clickOnMenuDeleteEnrollment() { with(InstrumentationRegistry.getInstrumentation().targetContext) { val deleteEnrollmentLabel = getString(R.string.remove_from) @@ -292,6 +254,10 @@ class TeiDashboardRobot(val composeTestRule: ComposeTestRule) : BaseRobot() { with(InstrumentationRegistry.getInstrumentation().targetContext) { val timelineLabel = getString(R.string.view_timeline) try { + composeTestRule.waitUntilExactlyOneExists( + hasText(timelineLabel), + TIMEOUT + ) composeTestRule.onNodeWithText(timelineLabel).performClick() } catch (e: NoMatchingViewException) { checkIfGroupedEventsIsVisible() @@ -299,19 +265,6 @@ class TeiDashboardRobot(val composeTestRule: ComposeTestRule) : BaseRobot() { } } - fun clickOnReopen() { - with(InstrumentationRegistry.getInstrumentation().targetContext) { - val timelineLabel = getString(R.string.enrollment_reopen) - val eventLabel = resources.getQuantityString(R.plurals.event_label, 2) - val itemLabel = timelineLabel.format(eventLabel) - try { - onView(withText(itemLabel)).perform(click()) - } catch (e: NoMatchingViewException) { - checkIfGroupedEventsIsVisible() - } - } - } - private fun checkIfGroupedEventsIsVisible() { with(InstrumentationRegistry.getInstrumentation().targetContext) { val groupLabel = getString(R.string.group_by_stage) @@ -383,85 +336,6 @@ class TeiDashboardRobot(val composeTestRule: ComposeTestRule) : BaseRobot() { composeTestRule.onAllNodes(hasText(viewOnlyText), useUnmergedTree = false) } - fun clickOnStageGroup(programStageName: String) { - composeTestRule.onNodeWithText(programStageName).performClick() - } - - fun clickOnEventGroupByStage(eventDate: String) { - onView(withId(R.id.tei_recycler)) - .perform( - actionOnItem( - hasDescendant( - allOf( - withText(eventDate), - withId(R.id.event_date), - ), - ), - click(), - ), - ) - } - - fun checkEventWasDeletedStageGroup(teiProgramStages: TEIProgramStagesUIModel) { - val firstProgramStage = teiProgramStages.programStage_first - val secondProgramStage = teiProgramStages.programStage_second - val thirdProgramStage = teiProgramStages.programStage_third - - onView(withId(R.id.tei_recycler)) - .check( - matches( - allOf( - hasItem( - allOf( - hasDescendant(withText(firstProgramStage.name)), - hasDescendant(withText(firstProgramStage.events)), - ), - ), - hasItem( - allOf( - hasDescendant(withText(secondProgramStage.name)), - hasDescendant(withText(secondProgramStage.events)), - ), - ), - hasItem( - allOf( - hasDescendant(withText(thirdProgramStage.name)), - hasDescendant(withText(thirdProgramStage.events)), - ), - ), - ), - ), - ) - } - - fun checkEventStateStageGroup(eventDetails: EventStatusUIModel) { - var status = R.drawable.ic_event_status_open - when (eventDetails.status) { - "Open" -> status = R.drawable.ic_event_status_open - "Overdue" -> status = R.drawable.ic_event_status_overdue - "Event Completed" -> status = R.drawable.ic_event_status_complete - "Skip" -> status = R.drawable.ic_event_status_skipped - "Schedule" -> status = R.drawable.ic_event_status_schedule - } - - onView(withId(R.id.tei_recycler)) - .check( - matches( - hasItem( - allOf( - hasDescendant(withText(eventDetails.date)), - hasDescendant(withText(eventDetails.orgUnit)), - hasDescendant(withTagValue(equalTo(status))), - ), - ), - ), - ) - } - - fun clickOnEventGroupByStageUsingDate(dueDate: String) { - composeTestRule.onNodeWithText(dueDate).performClick() - } - fun clickOnConfirmDeleteTEI() { composeTestRule.onNodeWithText("Delete").performClick() } @@ -470,16 +344,6 @@ class TeiDashboardRobot(val composeTestRule: ComposeTestRule) : BaseRobot() { composeTestRule.onNodeWithText("Remove").performClick() } - fun checkEnrollmentDate(enrollmentDate: DateRegistrationUIModel) { - composeTestRule.onNode( - hasText( - "Date of enrollment: 0${enrollmentDate.day}/0${enrollmentDate.month}/${enrollmentDate.year}", - true - ), - useUnmergedTree = true - ).assertIsDisplayed() - } - fun typeOnInputDateField(dateValue: String, title: String) { composeTestRule.apply { onNode( diff --git a/app/src/dhis2/java/org/dhis2/usescases/main/domain/DownloadNewVersion.kt b/app/src/dhis2/java/org/dhis2/usescases/main/domain/DownloadNewVersion.kt new file mode 100644 index 00000000000..746bee80950 --- /dev/null +++ b/app/src/dhis2/java/org/dhis2/usescases/main/domain/DownloadNewVersion.kt @@ -0,0 +1,33 @@ +package org.dhis2.usescases.main.domain + +import android.content.Context +import kotlinx.coroutines.suspendCancellableCoroutine +import org.dhis2.data.service.VersionRepository +import org.dhis2.mobile.commons.domain.UseCase +import org.dhis2.mobile.commons.error.DomainError +import org.dhis2.usescases.main.domain.model.DownloadMethod +import kotlin.coroutines.resume + +class DownloadNewVersion( + private val versionRepository: VersionRepository, +) : UseCase { + override suspend fun invoke(input: Context): Result = + try { + suspendCancellableCoroutine { continuation -> + versionRepository.download( + context = input, + onDownloadCompleted = { + continuation.resume(Result.success(DownloadMethod.File(it))) + }, + onDownloading = { + // no-op + }, + ) + continuation.invokeOnCancellation { + // If needed perform action on cancellation + } + } + } catch (e: DomainError) { + Result.failure(e) + } +} diff --git a/app/src/dhis2PlayServices/java/org/dhis2/usescases/main/domain/DownloadNewVersion.kt b/app/src/dhis2PlayServices/java/org/dhis2/usescases/main/domain/DownloadNewVersion.kt new file mode 100644 index 00000000000..df2258b4d61 --- /dev/null +++ b/app/src/dhis2PlayServices/java/org/dhis2/usescases/main/domain/DownloadNewVersion.kt @@ -0,0 +1,21 @@ +package org.dhis2.usescases.main.domain + +import android.content.Context +import org.dhis2.data.service.VersionRepository +import org.dhis2.mobile.commons.domain.UseCase +import org.dhis2.mobile.commons.error.DomainError +import org.dhis2.usescases.main.domain.model.DownloadMethod + +class DownloadNewVersion( + private val versionRepository: VersionRepository, +) : UseCase { + override suspend fun invoke(input: Context): Result = + try { + val url = versionRepository.getUrl() + url?.let { + Result.success(DownloadMethod.Url(it)) + } ?: Result.failure(DomainError.UnexpectedError("No url provided")) + } catch (domainError: DomainError) { + Result.failure(domainError) + } +} diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index dd346b3e6c1..40613e869a7 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -17,6 +17,7 @@ android:name="android.permission.READ_EXTERNAL_STORAGE" android:maxSdkVersion="32" /> + + + + + + + @@ -85,7 +94,6 @@ - @@ -145,8 +153,7 @@ + android:foregroundServiceType="dataSync" /> { options.setDsn(BuildConfig.SENTRY_DSN); options.setAnrReportInDebug(true); @@ -154,12 +151,6 @@ private void initCustomCrashActivity() { .apply(); } - @Override - protected void attachBaseContext(Context base) { - super.attachBaseContext(base); - MultiDex.install(this); - } - private void setUpAppComponent() { appComponent = prepareAppComponent().build(); appComponent.inject(this); @@ -278,15 +269,6 @@ public void releaseDashboardComponent() { } } - @NotNull - public SessionComponent createSessionComponent(PinModule pinModule) { - return (sessionComponent = userComponent.plus(pinModule)); - } - - public void releaseSessionComponent() { - sessionComponent = null; - } - @OnLifecycleEvent(Lifecycle.Event.ON_STOP) public void onAppBackgrounded() { Timber.tag("BG").d("App in background"); diff --git a/app/src/main/java/org/dhis2/AppModule.kt b/app/src/main/java/org/dhis2/AppModule.kt index 0373cade8fa..32f408bfa99 100644 --- a/app/src/main/java/org/dhis2/AppModule.kt +++ b/app/src/main/java/org/dhis2/AppModule.kt @@ -4,6 +4,9 @@ import android.content.Context import dagger.Module import dagger.Provides import org.dhis2.commons.resources.ColorUtils +import org.dhis2.data.service.SyncGranularWorker +import org.koin.androidx.workmanager.dsl.workerOf +import org.koin.dsl.module import javax.inject.Singleton @Module @@ -18,3 +21,8 @@ class AppModule( @Singleton fun colorUtils(): ColorUtils = ColorUtils() } + +val appModule = + module { + workerOf(::SyncGranularWorker) + } diff --git a/app/src/main/java/org/dhis2/bindings/Bindings.java b/app/src/main/java/org/dhis2/bindings/Bindings.java index 0354b034f25..60d0a67c159 100644 --- a/app/src/main/java/org/dhis2/bindings/Bindings.java +++ b/app/src/main/java/org/dhis2/bindings/Bindings.java @@ -5,11 +5,9 @@ import android.graphics.drawable.AnimatedVectorDrawable; import android.graphics.drawable.Drawable; import android.graphics.drawable.GradientDrawable; -import android.os.Build; import android.util.TypedValue; import android.view.View; import android.widget.ImageView; -import android.widget.LinearLayout; import android.widget.TextView; import androidx.appcompat.content.res.AppCompatResources; @@ -168,13 +166,6 @@ public static void setImageBackground(ImageView imageView, Drawable drawable) { } - @BindingAdapter("versionVisibility") - public static void setVisibility(LinearLayout linearLayout, boolean check) { - if (check && Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) { - linearLayout.setVisibility(View.GONE); - } - } - @BindingAdapter("settingIcon") public static void setSettingIcon(ImageView view, int drawableReference) { Drawable drawable = AppCompatResources.getDrawable(view.getContext(), drawableReference); diff --git a/app/src/main/java/org/dhis2/bindings/SettingExtensions.kt b/app/src/main/java/org/dhis2/bindings/SettingExtensions.kt index bafbb9a0aba..66102244615 100644 --- a/app/src/main/java/org/dhis2/bindings/SettingExtensions.kt +++ b/app/src/main/java/org/dhis2/bindings/SettingExtensions.kt @@ -15,6 +15,7 @@ const val MANUAL = 0 fun MetadataSyncPeriod.toSeconds(): Int = when (this) { MetadataSyncPeriod.EVERY_HOUR -> EVERY_HOUR + MetadataSyncPeriod.EVERY_6_HOURS -> EVERY_6_HOUR MetadataSyncPeriod.EVERY_12_HOURS -> EVERY_12_HOUR MetadataSyncPeriod.EVERY_24_HOURS -> EVERY_24_HOUR MetadataSyncPeriod.EVERY_7_DAYS -> EVERY_7_DAYS diff --git a/app/src/main/java/org/dhis2/data/biometric/BiometricUtils.kt b/app/src/main/java/org/dhis2/data/biometric/BiometricUtils.kt index 47627d21950..781e1485952 100644 --- a/app/src/main/java/org/dhis2/data/biometric/BiometricUtils.kt +++ b/app/src/main/java/org/dhis2/data/biometric/BiometricUtils.kt @@ -4,7 +4,6 @@ import android.content.Context import android.os.Build import android.security.keystore.KeyGenParameterSpec import android.security.keystore.KeyProperties -import androidx.annotation.RequiresApi import androidx.biometric.BiometricManager import androidx.biometric.BiometricPrompt import androidx.core.content.ContextCompat @@ -93,27 +92,19 @@ class CryptographyManager : CryptographicActions { load(null) } - override fun getInitializedCipherForEncryption(): Cipher? = - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { - val cipher = getCipher() - val secretKey = - getOrCreateSecretKey() - - cipher.init(Cipher.ENCRYPT_MODE, secretKey) - cipher - } else { - null - } + override fun getInitializedCipherForEncryption(): Cipher { + val cipher = getCipher() + val secretKey = getOrCreateSecretKey() + cipher.init(Cipher.ENCRYPT_MODE, secretKey) + return cipher + } - override fun getInitializedCipherForDecryption(initializationVector: ByteArray): Cipher? = - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { - val cipher = getCipher() - val secretKey = getOrCreateSecretKey() - cipher.init(Cipher.DECRYPT_MODE, secretKey, GCMParameterSpec(128, initializationVector)) - cipher - } else { - null - } + override fun getInitializedCipherForDecryption(initializationVector: ByteArray): Cipher { + val cipher = getCipher() + val secretKey = getOrCreateSecretKey() + cipher.init(Cipher.DECRYPT_MODE, secretKey, GCMParameterSpec(128, initializationVector)) + return cipher + } override fun encryptData( plaintext: String, @@ -136,7 +127,6 @@ class CryptographyManager : CryptographicActions { return Cipher.getInstance(transformation) } - @RequiresApi(Build.VERSION_CODES.M) private fun getOrCreateSecretKey(): SecretKey { // If Secretkey was previously created for that keyName, then grab and return it. keyStore.getKey(KEY_NAME, null)?.let { return it as SecretKey } diff --git a/app/src/main/java/org/dhis2/data/forms/dataentry/ValueStore.kt b/app/src/main/java/org/dhis2/data/forms/dataentry/ValueStore.kt index 337621dc51a..60faa114c38 100644 --- a/app/src/main/java/org/dhis2/data/forms/dataentry/ValueStore.kt +++ b/app/src/main/java/org/dhis2/data/forms/dataentry/ValueStore.kt @@ -15,15 +15,6 @@ interface ValueStore { value: String?, ): Flowable - suspend fun save( - orgUnitUid: String, - periodId: String, - attributeOptionComboUid: String, - dataElementUid: String, - categoryOptionComboUid: String, - value: String?, - ): Flowable - fun deleteOptionValues(optionCodeValuesToDelete: List) fun deleteOptionValueIfSelected( diff --git a/app/src/main/java/org/dhis2/data/forms/dataentry/ValueStoreImpl.kt b/app/src/main/java/org/dhis2/data/forms/dataentry/ValueStoreImpl.kt index 523c7b362cc..a6203eadaf0 100644 --- a/app/src/main/java/org/dhis2/data/forms/dataentry/ValueStoreImpl.kt +++ b/app/src/main/java/org/dhis2/data/forms/dataentry/ValueStoreImpl.kt @@ -1,39 +1,52 @@ package org.dhis2.data.forms.dataentry import io.reactivex.Flowable +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.stateIn import org.dhis2.bindings.blockingSetCheck import org.dhis2.bindings.withValueTypeCheck import org.dhis2.commons.data.EntryMode -import org.dhis2.commons.network.NetworkUtils import org.dhis2.commons.resources.ResourceManager +import org.dhis2.commons.viewmodel.DispatcherProvider import org.dhis2.data.dhislogic.DhisEnrollmentUtils import org.dhis2.form.R import org.dhis2.form.model.StoreResult import org.dhis2.form.model.ValueStoreResult -import org.dhis2.mobile.commons.providers.FieldErrorMessageProvider +import org.dhis2.mobile.commons.network.NetworkStatusProvider import org.dhis2.mobile.commons.reporting.CrashReportController import org.dhis2.utils.DhisTextUtils import org.hisp.dhis.android.core.D2 import org.hisp.dhis.android.core.arch.helpers.FileResizerHelper import org.hisp.dhis.android.core.arch.helpers.Result import org.hisp.dhis.android.core.common.ValueType +import org.hisp.dhis.android.core.datavalue.LegacyDataValueApi import org.hisp.dhis.android.core.enrollment.EnrollmentObjectRepository import java.io.File +@OptIn(LegacyDataValueApi::class) class ValueStoreImpl( private val d2: D2, private val recordUid: String, private val entryMode: EntryMode, private val dhisEnrollmentUtils: DhisEnrollmentUtils, private val crashReportController: CrashReportController, - private val networkUtils: NetworkUtils, private val searchTEIRepository: SearchTEIRepository, - private val fieldErrorMessageProvider: FieldErrorMessageProvider, private val resourceManager: ResourceManager, + networkStatusProvider: NetworkStatusProvider, + dispatcherProvider: DispatcherProvider, ) : ValueStore { var enrollmentRepository: EnrollmentObjectRepository? = null var overrideProgramUid: String? = null + private val isNetworkOnline = + networkStatusProvider.connectionStatus + .stateIn( + CoroutineScope(dispatcherProvider.io()), + SharingStarted.Eagerly, + false, + ) + override fun overrideProgram(programUid: String?) { overrideProgramUid = programUid } @@ -65,76 +78,6 @@ class ValueStoreImpl( ) } - override suspend fun save( - orgUnitUid: String, - periodId: String, - attributeOptionComboUid: String, - dataElementUid: String, - categoryOptionComboUid: String, - value: String?, - ): Flowable { - val dataValueObject = - d2.dataValueModule().dataValues().value( - periodId, - orgUnitUid, - dataElementUid, - categoryOptionComboUid, - attributeOptionComboUid, - ) - - val validator = - d2 - .dataElementModule() - .dataElements() - .uid(dataElementUid) - .blockingGet() - ?.valueType() - ?.validator - - return if (!value.isNullOrEmpty()) { - if (dataValueObject.blockingExists() && - dataValueObject.blockingGet()?.value() == value - ) { - Flowable.just(StoreResult("", ValueStoreResult.VALUE_HAS_NOT_CHANGED)) - } else { - when (val validation = validator?.validate(value)) { - is Result.Failure -> - Flowable.just( - StoreResult( - uid = "", - valueStoreResult = ValueStoreResult.ERROR_UPDATING_VALUE, - valueStoreResultMessage = - fieldErrorMessageProvider - .getFriendlyErrorMessage(validation.failure), - ), - ) - is Result.Success -> - dataValueObject - .set(value) - .andThen(Flowable.just(StoreResult("", ValueStoreResult.VALUE_CHANGED))) - else -> - Flowable.just( - StoreResult( - uid = "", - valueStoreResult = ValueStoreResult.ERROR_UPDATING_VALUE, - valueStoreResultMessage = - fieldErrorMessageProvider - .defaultValidationErrorMessage(), - ), - ) - } - } - } else { - if (dataValueObject.blockingExists()) { - dataValueObject - .deleteIfExist() - .andThen(Flowable.just(StoreResult("", ValueStoreResult.VALUE_CHANGED))) - } else { - Flowable.just(StoreResult("", ValueStoreResult.VALUE_HAS_NOT_CHANGED)) - } - } - } - override fun saveWithTypeCheck( uid: String, value: String?, @@ -146,12 +89,14 @@ class ValueStoreImpl( .uid(uid) .blockingExists() -> saveDataElement(uid, value) + d2 .trackedEntityModule() .trackedEntityAttributes() .uid(uid) .blockingExists() -> saveAttribute(uid, value) + else -> Flowable.just(StoreResult(uid, ValueStoreResult.UID_IS_NOT_DE_OR_ATTR)) } @@ -176,6 +121,7 @@ class ValueStoreImpl( .blockingGet() enrollment?.trackedEntityInstance() } + EntryMode.ATTR -> recordUid EntryMode.DV -> null } @@ -297,7 +243,7 @@ class ValueStoreImpl( value: String?, teiUid: String, ): Boolean = - if (!networkUtils.isOnline()) { + if (!isNetworkOnline.value) { dhisEnrollmentUtils.isTrackedEntityAttributeValueUnique(uid, value, teiUid) } else { val programUid = overrideProgramUid ?: enrollmentRepository?.blockingGet()?.program() @@ -381,6 +327,7 @@ class ValueStoreImpl( deleteOptionValuesForEnrollment( optionCodeValuesToDelete, ) + EntryMode.DV, -> throw IllegalArgumentException( "DataValues can't be saved using these arguments. Use the other one.", diff --git a/app/src/main/java/org/dhis2/data/server/ServerModule.kt b/app/src/main/java/org/dhis2/data/server/ServerModule.kt index 1021d00743e..d1ae9f3e71f 100644 --- a/app/src/main/java/org/dhis2/data/server/ServerModule.kt +++ b/app/src/main/java/org/dhis2/data/server/ServerModule.kt @@ -21,8 +21,6 @@ import org.dhis2.commons.resources.EventResourcesProvider import org.dhis2.commons.resources.MetadataIconProvider import org.dhis2.commons.resources.ResourceManager import org.dhis2.commons.schedulers.SchedulerProvider -import org.dhis2.commons.viewmodel.DispatcherProvider -import org.dhis2.data.service.SyncStatusController import org.dhis2.data.service.VersionRepository import org.dhis2.form.data.OptionsRepository import org.dhis2.form.data.RulesUtilsProvider @@ -104,7 +102,10 @@ class ServerModule { @Provides @PerServer - fun providesRepository(d2: D2): ServerSettingsRepository = ServerSettingsRepository(d2) + fun providesRepository( + d2: D2, + colorUtils: ColorUtils, + ): ServerSettingsRepository = ServerSettingsRepository(d2, colorUtils) @Provides @PerServer @@ -123,11 +124,6 @@ class ServerModule { colorUtils, ) - @Provides - @PerServer - fun providesSyncStatusController(dispatcherProvider: DispatcherProvider): SyncStatusController = - SyncStatusController(dispatcherProvider) - @Provides @PerServer fun providesVersionStatusController(d2: D2): VersionRepository = VersionRepository(d2) diff --git a/app/src/main/java/org/dhis2/data/server/ServerSettingsRepository.kt b/app/src/main/java/org/dhis2/data/server/ServerSettingsRepository.kt index 735da2d5033..92bfcd7b3b5 100644 --- a/app/src/main/java/org/dhis2/data/server/ServerSettingsRepository.kt +++ b/app/src/main/java/org/dhis2/data/server/ServerSettingsRepository.kt @@ -2,11 +2,18 @@ package org.dhis2.data.server import io.reactivex.Single import org.dhis2.BuildConfig +import org.dhis2.R +import org.dhis2.commons.resources.ColorUtils +import org.dhis2.commons.resources.paletteThemes +import org.dhis2.mobile.commons.color.ColorMatcher +import org.dhis2.mobile.commons.color.PaletteColor import org.hisp.dhis.android.core.D2 import org.hisp.dhis.android.core.settings.SystemSetting +import timber.log.Timber class ServerSettingsRepository( private val d2: D2, + private val colorUtils: ColorUtils, ) { fun getTheme(): Single> = d2 @@ -14,19 +21,49 @@ class ServerSettingsRepository( .systemSetting() .get() .map { systemSettings -> - val style = + val customColor = systemSettings .firstOrNull { - it.key() == SystemSetting.SystemSettingKey.STYLE + it.key() == SystemSetting.SystemSettingKey.CUSTOM_COLOR }?.value() val flag = systemSettings .firstOrNull { it.key() == SystemSetting.SystemSettingKey.FLAG }?.value() - Pair(flag, SystemStyleMapper(style)) + if (customColor.isNullOrEmpty()) { + Pair(flag, R.style.AppTheme) + } else { + try { + val customColorPalette = PaletteColor.fromHex(customColor) + val closestColor = + ColorMatcher.findClosest( + selectedR = customColorPalette.r, + selectedG = customColorPalette.g, + selectedB = customColorPalette.b, + palette = + paletteThemes.map { (color, _) -> + PaletteColor.fromHex(color) + }, + ) + Pair(flag, getThemeFromClosestColor(closestColor)) + } catch (e: Exception) { + Timber.e(e, "Error parsing custom color, using default theme") + Pair(flag, R.style.AppTheme) + } + } } + private fun getThemeFromClosestColor(color: PaletteColor?): Int = + color?.let { + val serverTheme = colorUtils.getThemeFromColor(it.hex) + if (serverTheme != -1) { + serverTheme + } else { + R.style.AppTheme + } + } ?: R.style.AppTheme + fun allowScreenShare(): Boolean = BuildConfig.DEBUG || d2 diff --git a/app/src/main/java/org/dhis2/data/server/SystemStyleMapper.kt b/app/src/main/java/org/dhis2/data/server/SystemStyleMapper.kt deleted file mode 100644 index a00ceaede9b..00000000000 --- a/app/src/main/java/org/dhis2/data/server/SystemStyleMapper.kt +++ /dev/null @@ -1,17 +0,0 @@ -package org.dhis2.data.server - -import org.dhis2.R - -object SystemStyleMapper { - private const val SERVER_GREEN_THEME = "green" - private const val SERVER_INDIA_THEME = "india" - private const val SERVER_MYANMAR_THEME = "myanmar" - - operator fun invoke(serverStyle: String?): Int = - when { - serverStyle?.contains(SERVER_GREEN_THEME) == true -> R.style.GreenTheme - serverStyle?.contains(SERVER_INDIA_THEME) == true -> R.style.OrangeTheme - serverStyle?.contains(SERVER_MYANMAR_THEME) == true -> R.style.RedTheme - else -> R.style.AppTheme - } -} diff --git a/app/src/main/java/org/dhis2/data/service/SyncDataWorker.java b/app/src/main/java/org/dhis2/data/service/SyncDataWorker.java deleted file mode 100644 index ebb30c9b4e5..00000000000 --- a/app/src/main/java/org/dhis2/data/service/SyncDataWorker.java +++ /dev/null @@ -1,206 +0,0 @@ -package org.dhis2.data.service; - -import static org.dhis2.utils.analytics.AnalyticsConstants.DATA_TIME; - -import android.app.NotificationChannel; -import android.app.NotificationManager; -import android.content.Context; -import android.content.pm.ServiceInfo; -import android.os.Build; - -import androidx.annotation.NonNull; -import androidx.core.app.NotificationCompat; -import androidx.core.app.NotificationManagerCompat; -import androidx.work.Data; -import androidx.work.ForegroundInfo; -import androidx.work.Worker; -import androidx.work.WorkerParameters; - -import org.dhis2.App; -import org.dhis2.R; -import org.dhis2.commons.Constants; -import org.dhis2.commons.date.DateUtils; -import org.dhis2.commons.network.NetworkUtils; -import org.dhis2.commons.prefs.PreferenceProvider; - -import java.util.Calendar; -import java.util.Objects; - -import javax.inject.Inject; - -import timber.log.Timber; - -public class SyncDataWorker extends Worker { - - private static final String DATA_CHANNEL = "sync_data_notification"; - private static final int SYNC_DATA_ID = 8071986; - - @Inject - SyncPresenter presenter; - - @Inject - PreferenceProvider prefs; - - public SyncDataWorker( - @NonNull Context context, - @NonNull WorkerParameters workerParams) { - super(context, workerParams); - } - - @NonNull - @Override - public Result doWork() { - Objects.requireNonNull(((App) getApplicationContext()).userComponent()) - .plus(new SyncDataWorkerModule()) - .inject(this); - - presenter.initSyncControllerMap(); - - triggerNotification( - getApplicationContext().getString(R.string.app_name), - getApplicationContext().getString(R.string.syncing_data), - 0); - - boolean isEventOk = true; - boolean isTeiOk = true; - boolean isDataValue = true; - - long init = System.currentTimeMillis(); - - triggerNotification( - getApplicationContext().getString(R.string.app_name), - getApplicationContext().getString(R.string.syncing_events), - 20); - - try { - presenter.syncAndDownloadEvents(); - } catch (Exception e) { - if (!new NetworkUtils(getApplicationContext()).isOnline()) { - presenter.setNetworkUnavailable(); - } - Timber.e(e); - isEventOk = false; - } - - triggerNotification( - getApplicationContext().getString(R.string.app_name), - getApplicationContext().getString(R.string.syncing_teis), - 40); - - try { - presenter.syncAndDownloadTeis(); - } catch (Exception e) { - if (!new NetworkUtils(getApplicationContext()).isOnline()) { - presenter.setNetworkUnavailable(); - } - Timber.e(e); - isTeiOk = false; - } - - triggerNotification( - getApplicationContext().getString(R.string.app_name), - getApplicationContext().getString(R.string.syncing_data_sets), - 60); - - try { - presenter.syncAndDownloadDataValues(); - } catch (Exception e) { - if (!new NetworkUtils(getApplicationContext()).isOnline()) { - presenter.setNetworkUnavailable(); - } - Timber.e(e); - isDataValue = false; - } - - triggerNotification( - getApplicationContext().getString(R.string.app_name), - getApplicationContext().getString(R.string.syncing_resources), - 80); - - try { - presenter.downloadResources(); - } catch (Exception e) { - Timber.e(e); - } - - triggerNotification( - getApplicationContext().getString(R.string.app_name), - "syncing reserved values", - 95 - - ); - - try { - presenter.syncReservedValues(); - } catch (Exception e) { - Timber.e(e); - } - - triggerNotification( - getApplicationContext().getString(R.string.app_name), - getApplicationContext().getString(R.string.syncing_done), - 100); - - presenter.logTimeToFinish(System.currentTimeMillis() - init, DATA_TIME); - - String lastDataSyncDate = DateUtils.dateTimeFormat().format(Calendar.getInstance().getTime()); - SyncResult syncResult = presenter.checkSyncStatus(); - - prefs.setValue(Constants.LAST_DATA_SYNC, lastDataSyncDate); - prefs.setValue(Constants.LAST_DATA_SYNC_STATUS, isEventOk && isTeiOk && isDataValue && syncResult == SyncResult.SYNC); - prefs.setValue(Constants.SYNC_RESULT, syncResult.name()); - - cancelNotification(); - - presenter.startPeriodicDataWork(); - - presenter.finishSync(); - - return Result.success(createOutputData(true)); - } - - @Override - public void onStopped() { - cancelNotification(); - super.onStopped(); - } - - private Data createOutputData(boolean state) { - return new Data.Builder() - .putBoolean("DATA_STATE", state) - .build(); - } - - private void triggerNotification(String title, String content, int progress) { - NotificationManager notificationManager = (NotificationManager) getApplicationContext().getSystemService(Context.NOTIFICATION_SERVICE); - - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - NotificationChannel mChannel = new NotificationChannel(DATA_CHANNEL, "DataSync", NotificationManager.IMPORTANCE_HIGH); - notificationManager.createNotificationChannel(mChannel); - } - - NotificationCompat.Builder notificationBuilder = - new NotificationCompat.Builder(getApplicationContext(), DATA_CHANNEL) - .setSmallIcon(R.drawable.ic_sync) - .setContentTitle(title) - .setContentText(content) - .setOngoing(true) - .setOnlyAlertOnce(true) - .setAutoCancel(false) - .setProgress(100, progress, false) - .setPriority(NotificationCompat.PRIORITY_DEFAULT); - - setForegroundAsync(new ForegroundInfo( - SyncDataWorker.SYNC_DATA_ID, - notificationBuilder.build(), - Build.VERSION.SDK_INT >= Build.VERSION_CODES.R ? ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC : 0 - )); - - } - - private void cancelNotification() { - NotificationManagerCompat notificationManager = - NotificationManagerCompat.from(getApplicationContext()); - notificationManager.cancel(SYNC_DATA_ID); - } -} diff --git a/app/src/main/java/org/dhis2/data/service/SyncDataWorkerComponent.java b/app/src/main/java/org/dhis2/data/service/SyncDataWorkerComponent.java deleted file mode 100644 index 3938520a51d..00000000000 --- a/app/src/main/java/org/dhis2/data/service/SyncDataWorkerComponent.java +++ /dev/null @@ -1,13 +0,0 @@ -package org.dhis2.data.service; - -import androidx.annotation.NonNull; - -import org.dhis2.commons.di.dagger.PerService; - -import dagger.Subcomponent; - -@PerService -@Subcomponent(modules = SyncDataWorkerModule.class) -public interface SyncDataWorkerComponent { - void inject(@NonNull SyncDataWorker syncDataWorker); -} diff --git a/app/src/main/java/org/dhis2/data/service/SyncDataWorkerModule.kt b/app/src/main/java/org/dhis2/data/service/SyncDataWorkerModule.kt deleted file mode 100644 index 66758840961..00000000000 --- a/app/src/main/java/org/dhis2/data/service/SyncDataWorkerModule.kt +++ /dev/null @@ -1,35 +0,0 @@ -package org.dhis2.data.service - -import dagger.Module -import dagger.Provides -import org.dhis2.commons.di.dagger.PerService -import org.dhis2.commons.prefs.PreferenceProvider -import org.dhis2.data.service.workManager.WorkManagerController -import org.dhis2.utils.analytics.AnalyticsHelper -import org.hisp.dhis.android.core.D2 - -@Module -class SyncDataWorkerModule { - @Provides - @PerService - fun syncRepository(d2: D2): SyncRepository = SyncRepositoryImpl(d2) - - @Provides - @PerService - internal fun syncPresenter( - d2: D2, - preferences: PreferenceProvider, - workManagerController: WorkManagerController, - analyticsHelper: AnalyticsHelper, - syncStatusController: SyncStatusController, - syncRepository: SyncRepository, - ): SyncPresenter = - SyncPresenterImpl( - d2, - preferences, - workManagerController, - analyticsHelper, - syncStatusController, - syncRepository, - ) -} diff --git a/app/src/main/java/org/dhis2/data/service/SyncGranularRxModule.kt b/app/src/main/java/org/dhis2/data/service/SyncGranularRxModule.kt index 64dc7834207..3875821b4c7 100644 --- a/app/src/main/java/org/dhis2/data/service/SyncGranularRxModule.kt +++ b/app/src/main/java/org/dhis2/data/service/SyncGranularRxModule.kt @@ -4,12 +4,13 @@ import dagger.Module import dagger.Provides import org.dhis2.commons.di.dagger.PerService import org.dhis2.commons.prefs.PreferenceProvider -import org.dhis2.data.service.workManager.WorkManagerController -import org.dhis2.utils.analytics.AnalyticsHelper +import org.dhis2.mobile.sync.domain.SyncStatusController import org.hisp.dhis.android.core.D2 @Module -class SyncGranularRxModule { +class SyncGranularRxModule( + private val syncStatusController: SyncStatusController, +) { @Provides @PerService fun syncRepository(d2: D2): SyncRepository = SyncRepositoryImpl(d2) @@ -19,17 +20,12 @@ class SyncGranularRxModule { internal fun syncPresenter( d2: D2, preferences: PreferenceProvider, - workManagerController: WorkManagerController, - analyticsHelper: AnalyticsHelper, - syncStatusController: SyncStatusController, syncRepository: SyncRepository, ): SyncPresenter = SyncPresenterImpl( d2, preferences, - workManagerController, - analyticsHelper, - syncStatusController, syncRepository, + syncStatusController, ) } diff --git a/app/src/main/java/org/dhis2/data/service/SyncGranularWorker.kt b/app/src/main/java/org/dhis2/data/service/SyncGranularWorker.kt index e15ffa40ae0..6ce7392a572 100644 --- a/app/src/main/java/org/dhis2/data/service/SyncGranularWorker.kt +++ b/app/src/main/java/org/dhis2/data/service/SyncGranularWorker.kt @@ -44,6 +44,7 @@ import org.dhis2.commons.Constants.ORG_UNIT import org.dhis2.commons.Constants.PERIOD_ID import org.dhis2.commons.Constants.UID import org.dhis2.commons.sync.ConflictType +import org.dhis2.mobile.sync.domain.SyncStatusController import javax.inject.Inject private const val GRANULAR_CHANNEL = "sync_granular_notification" @@ -52,6 +53,7 @@ private const val SYNC_GRANULAR_ID = 8071988 class SyncGranularWorker( context: Context, workerParams: WorkerParameters, + private val syncStatusController: SyncStatusController, ) : Worker(context, workerParams) { @Inject internal lateinit var presenter: SyncPresenter @@ -59,7 +61,7 @@ class SyncGranularWorker( override fun doWork(): Result { (applicationContext as App) .userComponent() - ?.plus(SyncGranularRxModule()) + ?.plus(SyncGranularRxModule(syncStatusController)) ?.inject(this) val uid = inputData.getString(UID) ?: return Result.failure() diff --git a/app/src/main/java/org/dhis2/data/service/SyncInitWorker.java b/app/src/main/java/org/dhis2/data/service/SyncInitWorker.java deleted file mode 100644 index f8ac513724b..00000000000 --- a/app/src/main/java/org/dhis2/data/service/SyncInitWorker.java +++ /dev/null @@ -1,46 +0,0 @@ -package org.dhis2.data.service; - -import android.content.Context; - -import androidx.annotation.NonNull; -import androidx.work.Worker; -import androidx.work.WorkerParameters; - -import org.dhis2.App; - -import javax.inject.Inject; - -public class SyncInitWorker extends Worker { - - public static final String INIT_META = "INIT_META"; - public static final String INIT_DATA = "INIT_DATA"; - - @Inject - SyncPresenter presenter; - - public SyncInitWorker( - @NonNull Context context, - @NonNull WorkerParameters workerParams) { - super(context, workerParams); - } - - @NonNull - @Override - public Result doWork() { - - if (((App) getApplicationContext()).userComponent() != null) { - - ((App) getApplicationContext()).userComponent().plus(new SyncInitWorkerModule()).inject(this); - - if (getInputData().getBoolean(INIT_META, false)) - presenter.startPeriodicMetaWork(); - if (getInputData().getBoolean(INIT_DATA, false)) - presenter.startPeriodicDataWork(); - - return Result.success(); - } else { - return Result.failure(); - } - } - -} diff --git a/app/src/main/java/org/dhis2/data/service/SyncInitWorkerComponent.java b/app/src/main/java/org/dhis2/data/service/SyncInitWorkerComponent.java deleted file mode 100644 index ea8097b8f1f..00000000000 --- a/app/src/main/java/org/dhis2/data/service/SyncInitWorkerComponent.java +++ /dev/null @@ -1,13 +0,0 @@ -package org.dhis2.data.service; - -import androidx.annotation.NonNull; - -import org.dhis2.commons.di.dagger.PerService; - -import dagger.Subcomponent; - -@PerService -@Subcomponent(modules = SyncInitWorkerModule.class) -public interface SyncInitWorkerComponent { - void inject(@NonNull SyncInitWorker syncInitWorker); -} diff --git a/app/src/main/java/org/dhis2/data/service/SyncInitWorkerModule.kt b/app/src/main/java/org/dhis2/data/service/SyncInitWorkerModule.kt deleted file mode 100644 index 68fbd402275..00000000000 --- a/app/src/main/java/org/dhis2/data/service/SyncInitWorkerModule.kt +++ /dev/null @@ -1,35 +0,0 @@ -package org.dhis2.data.service - -import dagger.Module -import dagger.Provides -import org.dhis2.commons.di.dagger.PerService -import org.dhis2.commons.prefs.PreferenceProvider -import org.dhis2.data.service.workManager.WorkManagerController -import org.dhis2.utils.analytics.AnalyticsHelper -import org.hisp.dhis.android.core.D2 - -@Module -class SyncInitWorkerModule { - @Provides - @PerService - fun syncRepository(d2: D2): SyncRepository = SyncRepositoryImpl(d2) - - @Provides - @PerService - internal fun syncPresenter( - d2: D2, - preferences: PreferenceProvider, - workManagerController: WorkManagerController, - analyticsHelper: AnalyticsHelper, - syncStatusController: SyncStatusController, - syncRepository: SyncRepository, - ): SyncPresenter = - SyncPresenterImpl( - d2, - preferences, - workManagerController, - analyticsHelper, - syncStatusController, - syncRepository, - ) -} diff --git a/app/src/main/java/org/dhis2/data/service/SyncMetadataWorker.java b/app/src/main/java/org/dhis2/data/service/SyncMetadataWorker.java deleted file mode 100644 index 2757c5bea89..00000000000 --- a/app/src/main/java/org/dhis2/data/service/SyncMetadataWorker.java +++ /dev/null @@ -1,212 +0,0 @@ -package org.dhis2.data.service; - -import static org.dhis2.data.service.SyncOutputKt.METADATA_MESSAGE; -import static org.dhis2.data.service.SyncOutputKt.METADATA_STATE; -import static org.dhis2.utils.analytics.AnalyticsConstants.METADATA_TIME; - -import android.app.NotificationChannel; -import android.app.NotificationManager; -import android.content.Context; -import android.content.pm.ServiceInfo; -import android.os.Build; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.core.app.NotificationCompat; -import androidx.core.app.NotificationManagerCompat; -import androidx.work.Data; -import androidx.work.ForegroundInfo; -import androidx.work.Worker; -import androidx.work.WorkerParameters; - -import org.dhis2.App; -import org.dhis2.R; -import org.dhis2.commons.date.DateUtils; -import org.dhis2.commons.prefs.PreferenceProvider; -import org.dhis2.commons.resources.ResourceManager; -import org.dhis2.commons.Constants; -import org.dhis2.utils.NetworkUtils; -import org.hisp.dhis.android.core.maintenance.D2Error; - -import java.io.PrintWriter; -import java.io.StringWriter; -import java.io.Writer; -import java.util.Calendar; - -import javax.inject.Inject; - -import timber.log.Timber; - -public class SyncMetadataWorker extends Worker { - - private static final String METADATA_CHANNEL = "sync_metadata_notification"; - private static final int SYNC_METADATA_ID = 26061987; - - @Inject - SyncPresenter presenter; - - @Inject - PreferenceProvider prefs; - - @Inject - ResourceManager resourceManager; - - public SyncMetadataWorker( - @NonNull Context context, - @NonNull WorkerParameters workerParams) { - super(context, workerParams); - } - - @NonNull - @Override - public Result doWork() { - if (((App) getApplicationContext()).userComponent() != null) { - - ((App) getApplicationContext()).userComponent().plus(new SyncMetadataWorkerModule()).inject(this); - - triggerNotification( - getApplicationContext().getString(R.string.app_name), - getApplicationContext().getString(R.string.syncing_configuration), - 0); - - boolean isMetaOk = true; - boolean noNetwork = false; - StringBuilder message = new StringBuilder(""); - - long init = System.currentTimeMillis(); - try { - presenter.syncMetadata(progress -> triggerNotification( - getApplicationContext().getString(R.string.app_name), - getApplicationContext().getString(R.string.syncing_configuration), - progress)); - } catch (Exception e) { - Timber.e(e); - isMetaOk = false; - if (!NetworkUtils.isOnline(getApplicationContext())) - noNetwork = true; - if (e instanceof D2Error) { - D2Error error = (D2Error) e; - message.append(composeErrorMessageInfo(error)); - } else if (e.getCause() instanceof D2Error) { - D2Error error = (D2Error) e.getCause(); - message.append(composeErrorMessageInfo(error)); - } else { - message.append(e.toString().split("\n\t")[0]); - } - } finally { - presenter.logTimeToFinish(System.currentTimeMillis() - init, METADATA_TIME); - } - - String lastDataSyncDate = DateUtils.dateTimeFormat().format(Calendar.getInstance().getTime()); - - prefs.setValue(Constants.LAST_META_SYNC, lastDataSyncDate); - prefs.setValue(Constants.LAST_META_SYNC_STATUS, isMetaOk); - prefs.setValue(Constants.LAST_META_SYNC_NO_NETWORK, noNetwork); - - cancelNotification(); - - if (!isMetaOk) - return Result.failure(createOutputData(false, message.toString())); - - presenter.startPeriodicMetaWork(); - - return Result.success(createOutputData(true, message.toString())); - } else { - return Result.failure(createOutputData(false, getApplicationContext().getString(R.string.error_init_session))); - } - } - - @Override - public void onStopped() { - cancelNotification(); - super.onStopped(); - } - - private Data createOutputData(boolean state, String message) { - return new Data.Builder() - .putBoolean(METADATA_STATE, state) - .putString(METADATA_MESSAGE, message) - .build(); - } - - private String errorStackTrace(@Nullable Exception exception) { - if (exception == null) - return ""; - Writer writer = new StringWriter(); - exception.printStackTrace(new PrintWriter(writer)); - return writer.toString(); - } - - private StringBuilder composeErrorMessageInfo(D2Error error) { - StringBuilder builder = new StringBuilder("Cause: ") - .append(resourceManager.parseD2Error(error)) - .append("\n\n") - .append("Exception: ") - .append(errorStackTrace((error).originalException()).split("\n\t")[0]) - .append("\n\n"); - - if (error.created() != null) { - builder.append("Created: ") - .append(error.created().toString()) - .append("\n\n"); - } - - if (error.httpErrorCode() != null) { - builder.append("Http Error Code: ") - .append(error.httpErrorCode()) - .append("\n\n"); - } - - if (error.errorComponent() != null) { - builder.append("Error component: ") - .append(error.errorComponent()) - .append("\n\n"); - } - - if (error.url() != null) { - builder.append("Url: ") - .append(error.url()) - .append("\n\n"); - } - - builder.append("StackTrace: ") - .append(errorStackTrace(error).split("\n\t")[0]) - .append("\n\n"); - - return builder; - } - - private void triggerNotification(String title, String content, int progress) { - NotificationManager notificationManager = (NotificationManager) getApplicationContext().getSystemService(Context.NOTIFICATION_SERVICE); - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - NotificationChannel mChannel = new NotificationChannel(METADATA_CHANNEL, "MetadataSync", NotificationManager.IMPORTANCE_HIGH); - notificationManager.createNotificationChannel(mChannel); - } - NotificationCompat.Builder notificationBuilder = - new NotificationCompat.Builder(getApplicationContext(), METADATA_CHANNEL) - .setSmallIcon(R.drawable.ic_sync) - .setContentTitle(title) - .setContentText(content) - .setOngoing(true) - .setOnlyAlertOnce(true) - .setAutoCancel(false) - .setProgress(100, progress, false) - .setPriority(NotificationCompat.PRIORITY_DEFAULT); - - setForegroundAsync(new ForegroundInfo( - SyncMetadataWorker.SYNC_METADATA_ID, - notificationBuilder.build(), - Build.VERSION.SDK_INT >= Build.VERSION_CODES.R ? ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC : 0 - )); - } - - private void cancelNotification() { - NotificationManagerCompat notificationManager = - NotificationManagerCompat.from(getApplicationContext()); - notificationManager.cancel(SYNC_METADATA_ID); - } - - public interface OnProgressUpdate { - void onProgressUpdate(int progress); - } -} diff --git a/app/src/main/java/org/dhis2/data/service/SyncMetadataWorkerComponent.java b/app/src/main/java/org/dhis2/data/service/SyncMetadataWorkerComponent.java deleted file mode 100644 index 20d6b70c2da..00000000000 --- a/app/src/main/java/org/dhis2/data/service/SyncMetadataWorkerComponent.java +++ /dev/null @@ -1,13 +0,0 @@ -package org.dhis2.data.service; - -import androidx.annotation.NonNull; - -import org.dhis2.commons.di.dagger.PerService; - -import dagger.Subcomponent; - -@PerService -@Subcomponent(modules = SyncMetadataWorkerModule.class) -public interface SyncMetadataWorkerComponent { - void inject(@NonNull SyncMetadataWorker syncMetadataWorker); -} diff --git a/app/src/main/java/org/dhis2/data/service/SyncMetadataWorkerModule.kt b/app/src/main/java/org/dhis2/data/service/SyncMetadataWorkerModule.kt deleted file mode 100644 index 33fcbff9d17..00000000000 --- a/app/src/main/java/org/dhis2/data/service/SyncMetadataWorkerModule.kt +++ /dev/null @@ -1,35 +0,0 @@ -package org.dhis2.data.service - -import dagger.Module -import dagger.Provides -import org.dhis2.commons.di.dagger.PerService -import org.dhis2.commons.prefs.PreferenceProvider -import org.dhis2.data.service.workManager.WorkManagerController -import org.dhis2.utils.analytics.AnalyticsHelper -import org.hisp.dhis.android.core.D2 - -@Module -class SyncMetadataWorkerModule { - @Provides - @PerService - fun syncRepository(d2: D2): SyncRepository = SyncRepositoryImpl(d2) - - @Provides - @PerService - internal fun syncPresenter( - d2: D2, - preferences: PreferenceProvider, - workManagerController: WorkManagerController, - analyticsHelper: AnalyticsHelper, - syncStatusController: SyncStatusController, - syncRepository: SyncRepository, - ): SyncPresenter = - SyncPresenterImpl( - d2, - preferences, - workManagerController, - analyticsHelper, - syncStatusController, - syncRepository, - ) -} diff --git a/app/src/main/java/org/dhis2/data/service/SyncPresenter.java b/app/src/main/java/org/dhis2/data/service/SyncPresenter.java index 507a0fabd4a..297ca06c001 100644 --- a/app/src/main/java/org/dhis2/data/service/SyncPresenter.java +++ b/app/src/main/java/org/dhis2/data/service/SyncPresenter.java @@ -1,28 +1,15 @@ package org.dhis2.data.service; +import androidx.work.ListenableWorker; + import org.hisp.dhis.android.core.arch.call.D2Progress; import org.hisp.dhis.android.core.imports.TrackerImportConflict; -import org.hisp.dhis.android.core.tracker.exporter.TrackerD2Progress; import java.util.List; -import androidx.work.ListenableWorker; - import io.reactivex.Observable; interface SyncPresenter { - void syncAndDownloadEvents() throws Exception; - - void syncAndDownloadTeis() throws Exception; - - void syncMetadata(SyncMetadataWorker.OnProgressUpdate progressUpdate) throws Exception; - - void syncAndDownloadDataValues() throws Exception; - - void syncReservedValues(); - - SyncResult checkSyncStatus(); - Observable syncGranularEvent(String eventUid); ListenableWorker.Result blockSyncGranularProgram(String programUid); @@ -57,21 +44,5 @@ interface SyncPresenter { List messageTrackerImportConflict(String uid); - void startPeriodicDataWork(); - - void startPeriodicMetaWork(); - - void downloadResources(); - ListenableWorker.Result blockSyncGranularDataValues(String dataSetUid, String orgUnitUid, String attrOptionCombo, String periodId, String[] catOptionCombo); - - void logTimeToFinish(long millisToFinish, String eventName); - - void updateProyectAnalytics(); - - void initSyncControllerMap(); - - void finishSync(); - - void setNetworkUnavailable(); } diff --git a/app/src/main/java/org/dhis2/data/service/SyncPresenterImpl.kt b/app/src/main/java/org/dhis2/data/service/SyncPresenterImpl.kt index 5f235e6521e..f4df0fcb443 100644 --- a/app/src/main/java/org/dhis2/data/service/SyncPresenterImpl.kt +++ b/app/src/main/java/org/dhis2/data/service/SyncPresenterImpl.kt @@ -2,127 +2,34 @@ package org.dhis2.data.service import androidx.annotation.VisibleForTesting import androidx.work.Data -import androidx.work.ExistingWorkPolicy import androidx.work.ListenableWorker import io.reactivex.Completable import io.reactivex.Observable -import org.dhis2.bindings.toSeconds +import kotlinx.coroutines.runBlocking import org.dhis2.commons.bindings.enrollment import org.dhis2.commons.bindings.program import org.dhis2.commons.date.DateUtils -import org.dhis2.commons.prefs.Preference.Companion.DATA import org.dhis2.commons.prefs.Preference.Companion.EVENT_MAX import org.dhis2.commons.prefs.Preference.Companion.EVENT_MAX_DEFAULT import org.dhis2.commons.prefs.Preference.Companion.LIMIT_BY_ORG_UNIT import org.dhis2.commons.prefs.Preference.Companion.LIMIT_BY_PROGRAM -import org.dhis2.commons.prefs.Preference.Companion.META -import org.dhis2.commons.prefs.Preference.Companion.TEI_MAX -import org.dhis2.commons.prefs.Preference.Companion.TEI_MAX_DEFAULT -import org.dhis2.commons.prefs.Preference.Companion.TIME_DAILY -import org.dhis2.commons.prefs.Preference.Companion.TIME_DATA -import org.dhis2.commons.prefs.Preference.Companion.TIME_META import org.dhis2.commons.prefs.PreferenceProvider -import org.dhis2.data.service.workManager.WorkManagerController -import org.dhis2.data.service.workManager.WorkerItem -import org.dhis2.data.service.workManager.WorkerType -import org.dhis2.utils.analytics.AnalyticsHelper -import org.dhis2.utils.analytics.matomo.DEFAULT_EXTERNAL_TRACKER_NAME +import org.dhis2.mobile.sync.domain.SyncStatusController import org.hisp.dhis.android.core.D2 import org.hisp.dhis.android.core.arch.call.D2Progress -import org.hisp.dhis.android.core.arch.call.D2ProgressStatus import org.hisp.dhis.android.core.common.State -import org.hisp.dhis.android.core.fileresource.FileResourceDomainType import org.hisp.dhis.android.core.imports.TrackerImportConflict import org.hisp.dhis.android.core.program.ProgramType -import org.hisp.dhis.android.core.settings.GeneralSettings import org.hisp.dhis.android.core.settings.LimitScope import org.hisp.dhis.android.core.settings.ProgramSettings -import org.hisp.dhis.android.core.systeminfo.DHISVersion -import timber.log.Timber import java.util.Calendar -import kotlin.math.ceil class SyncPresenterImpl( private val d2: D2, private val preferences: PreferenceProvider, - private val workManagerController: WorkManagerController, - private val analyticsHelper: AnalyticsHelper, - private val syncStatusController: SyncStatusController, private val syncRepository: SyncRepository, + private val syncStatusController: SyncStatusController, ) : SyncPresenter { - override fun initSyncControllerMap() { - Completable - .fromCallable { - val programMap: Map = - d2 - .programModule() - .programs() - .blockingGetUids() - .map { programUid -> - programUid to D2ProgressStatus(false, null) - }.toMap() - val aggregateMap: Map = - d2.dataSetModule().dataSets().blockingGetUids().associateWith { - D2ProgressStatus(false, null) - } - val allMap = - programMap - .toMutableMap() - .apply { - putAll(aggregateMap) - }.toMap() - syncStatusController.initDownloadProcess(allMap) - }.blockingAwait() - } - - override fun finishSync() { - syncStatusController.finishSync() - } - - override fun setNetworkUnavailable() { - syncStatusController.onNetworkUnavailable() - } - - override fun syncAndDownloadEvents() { - val (eventLimit, limitByOU, limitByProgram) = getDownloadLimits() - val programEventUids = - d2 - .programModule() - .programs() - .byProgramType() - .eq(ProgramType.WITHOUT_REGISTRATION) - .blockingGetUids() - syncStatusController.startDownloadingEvents() - Completable - .fromObservable(d2.eventModule().events().upload()) - .andThen( - Completable - .fromObservable( - d2 - .eventModule() - .eventDownloader() - .limit(eventLimit) - .limitByOrgunit(limitByOU) - .limitByProgram(limitByProgram) - .download() - .doOnNext { d2Progress -> - syncStatusController.updateDownloadProcess( - d2Progress.programs().filter { entry -> - programEventUids.contains(entry.key) - }, - ) - }, - ).doOnError { - Timber.d("error while downloading Events") - }.onErrorComplete() - .doOnComplete { - syncStatusController.finishDownloadingEvents( - programEventUids, - ) - }, - ).blockingAwait() - } - @VisibleForTesting fun getDownloadLimits(): Triple { val programSettings = getProgramSetting() @@ -148,231 +55,6 @@ class SyncPresenterImpl( return Triple(eventLimit, limitByOU, limitByProgram) } - override fun syncAndDownloadTeis() { - val programSettings = getProgramSetting() - val globalProgramSettings = programSettings?.globalSettings() - - val teiLimit = - globalProgramSettings?.teiDownload() ?: preferences.getInt(TEI_MAX, TEI_MAX_DEFAULT) - val limitByOU = - globalProgramSettings?.settingDownload()?.let { - it == LimitScope.PER_ORG_UNIT || it == LimitScope.PER_OU_AND_PROGRAM - } ?: preferences.getBoolean(LIMIT_BY_ORG_UNIT, false) - val limitByProgram = - globalProgramSettings?.settingDownload()?.let { - it == LimitScope.PER_PROGRAM || it == LimitScope.PER_OU_AND_PROGRAM - } ?: preferences.getBoolean(LIMIT_BY_PROGRAM, false) - - val trackerProgramUids = - d2 - .programModule() - .programs() - .byProgramType() - .eq(ProgramType.WITH_REGISTRATION) - .blockingGetUids() - - syncStatusController.startDownloadingTracker() - - Completable - .fromObservable(d2.trackedEntityModule().trackedEntityInstances().upload()) - .andThen( - Completable - .fromObservable( - d2 - .trackedEntityModule() - .trackedEntityInstanceDownloader() - .limit(teiLimit) - .limitByOrgunit(limitByOU) - .limitByProgram(limitByProgram) - .download() - .doOnNext { data -> - val percentage = data.percentage() - val callsDone = data.doneCalls().size - val totalCalls = data.totalCalls() - Timber.d("$percentage% $callsDone/$totalCalls") - syncStatusController.updateDownloadProcess( - data.programs().filter { entry -> - trackerProgramUids.contains(entry.key) - }, - ) - }, - ).doOnError { Timber.d("error while downloading TEIs") } - .onErrorComplete() - .doOnComplete { - syncStatusController.finishDownloadingTracker( - trackerProgramUids, - ) - }, - ).blockingAwait() - } - - override fun syncAndDownloadDataValues() { - val dataSetUids = d2.dataSetModule().dataSets().blockingGetUids() - if (dataSetUids.isNotEmpty()) { - syncStatusController.startDownloadingDataSets() - Completable - .fromObservable(d2.dataValueModule().dataValues().upload()) - .doOnError { Timber.d("error while downloading Datasets") } - .andThen( - Completable.fromObservable( - d2.dataSetModule().dataSetCompleteRegistrations().upload(), - ), - ).doOnError { Timber.d("error while downloading Datasets") } - .andThen( - Completable - .fromObservable( - d2 - .aggregatedModule() - .data() - .download() - .doOnNext { - syncStatusController.updateDownloadProcess(it.dataSets()) - }, - ).doOnError { Timber.d("error while downloading Datasets") } - .onErrorComplete() - .doOnComplete { - syncStatusController.finishDownloadingTracker( - dataSetUids, - ) - }, - ).blockingAwait() - } - } - - override fun syncMetadata(progressUpdate: SyncMetadataWorker.OnProgressUpdate) { - Completable - .fromObservable( - d2 - .metadataModule() - .download() - .doOnNext { data -> - Timber.log(1, data.toString()) - progressUpdate.onProgressUpdate(ceil(data.percentage() ?: 0.0).toInt()) - }.doOnComplete { - updateProyectAnalytics() - setUpSMS() - }, - ).doOnError { - Timber.d("error while downloading Metadata") - }.onErrorComplete() - .andThen( - d2.mapsModule().mapLayersDownloader().downloadMetadata(), - ).andThen( - Completable.fromObservable( - d2 - .fileResourceModule() - .fileResourceDownloader() - .byDomainType() - .eq(FileResourceDomainType.ICON) - .download(), - ), - ).blockingAwait() - } - - private fun setUpSMS() { - val globalSettings = getSettings() - - globalSettings?.let { - if (!globalSettings.smsGateway().isNullOrEmpty()) { - d2 - .smsModule() - .configCase() - .setGatewayNumber(globalSettings.smsGateway()) - .andThen( - if (!globalSettings.smsResultSender().isNullOrEmpty()) { - d2 - .smsModule() - .configCase() - .setConfirmationSenderNumber(globalSettings.smsResultSender()) - } else { - Completable.complete() - }, - ).andThen( - d2.smsModule().configCase().setModuleEnabled(true), - ).andThen( - d2.smsModule().configCase().refreshMetadataIds(), - ).blockingAwait() - } - } - } - - override fun downloadResources() { - if (d2.systemInfoModule().versionManager().isGreaterThan(DHISVersion.V2_32)) { - syncStatusController.initDownloadMedia() - Completable - .fromObservable( - d2 - .fileResourceModule() - .fileResourceDownloader() - .byDomainType() - .eq(FileResourceDomainType.DATA_VALUE) - .download(), - ).blockingAwait() - } - } - - override fun syncReservedValues() { - val maxNumberOfValuesToReserve = - getSettings()?.let { - it.reservedValues() ?: 100 - } ?: 100 - Completable - .fromObservable( - d2 - .trackedEntityModule() - .reservedValueManager() - .downloadAllReservedValues(maxNumberOfValuesToReserve), - ).doOnError { - Timber.d("error while downloading reserved values") - }.blockingAwait() - } - - override fun checkSyncStatus(): SyncResult { - val eventsOk = - d2 - .eventModule() - .events() - .byAggregatedSyncState() - .notIn(State.SYNCED) - .blockingGet() - .isEmpty() - val teiOk = - d2 - .trackedEntityModule() - .trackedEntityInstances() - .byAggregatedSyncState() - .notIn(State.SYNCED, State.RELATIONSHIP) - .blockingGet() - .isEmpty() - - if (eventsOk && teiOk) { - return SyncResult.SYNC - } - - val anyEventsToPostOrToUpdate = - d2 - .eventModule() - .events() - .byAggregatedSyncState() - .`in`(State.TO_POST, State.TO_UPDATE) - .blockingGet() - .isNotEmpty() - val anyTeiToPostOrToUpdate = - d2 - .trackedEntityModule() - .trackedEntityInstances() - .byAggregatedSyncState() - .`in`(State.TO_POST, State.TO_UPDATE) - .blockingGet() - .isNotEmpty() - - if (anyEventsToPostOrToUpdate || anyTeiToPostOrToUpdate) { - return SyncResult.INCOMPLETE - } - - return SyncResult.ERROR - } - override fun syncGranularEvent(eventUid: String): Observable { Completable.fromObservable(syncRepository.uploadEvent(eventUid)).blockingAwait() return syncRepository @@ -388,7 +70,9 @@ class SyncPresenterImpl( return if (!checkSyncProgramStatus(programUid)) { ListenableWorker.Result.failure() } else { - syncStatusController.updateSingleProgramToSuccess(programUid) + runBlocking { + syncStatusController.updateSingleProgramToSuccess(programUid) + } ListenableWorker.Result.success() } } @@ -719,7 +403,7 @@ class SyncPresenterImpl( .byTrackedEntityInstanceUid() .eq(uid) .blockingGet() - if (trackerImportConflicts != null && trackerImportConflicts.isNotEmpty()) { + if (!trackerImportConflicts.isNullOrEmpty()) { return trackerImportConflicts } @@ -730,7 +414,7 @@ class SyncPresenterImpl( .byEventUid() .eq(uid) .blockingGet() - if (trackerImportConflicts != null && trackerImportConflicts.isNotEmpty()) { + if (trackerImportConflicts.isNotEmpty()) { return trackerImportConflicts } @@ -741,73 +425,15 @@ class SyncPresenterImpl( .byEnrollmentUid() .eq(uid) .blockingGet() - return if (trackerImportConflicts != null && trackerImportConflicts.isNotEmpty()) { - trackerImportConflicts - } else { + return trackerImportConflicts.ifEmpty { null } } - override fun startPeriodicDataWork() { - val seconds = - getSettings()?.dataSync()?.toSeconds() ?: preferences.getInt(TIME_DATA, TIME_DAILY) - workManagerController.cancelUniqueWork(DATA) - - if (seconds != 0) { - val workerItem = - WorkerItem( - DATA, - WorkerType.DATA, - seconds.toLong(), - policy = ExistingWorkPolicy.REPLACE, - ) - - workManagerController.syncDataForWorker(workerItem) - } - } - - override fun startPeriodicMetaWork() { - val seconds = - getSettings()?.metadataSync()?.toSeconds() ?: preferences.getInt(TIME_META, TIME_DAILY) - workManagerController.cancelUniqueWork(META) - - if (seconds != 0) { - val workerItem = - WorkerItem( - META, - WorkerType.METADATA, - seconds.toLong(), - policy = ExistingWorkPolicy.REPLACE, - ) - - workManagerController.syncDataForWorker(workerItem) - } - } - - private fun getSettings(): GeneralSettings? = d2.settingModule().generalSetting().blockingGet() - - private fun getProgramSetting(): ProgramSettings? = d2.settingModule().programSetting().blockingGet() - - override fun logTimeToFinish( - millisToFinish: Long, - eventName: String, - ) { - analyticsHelper.setEvent( - eventName, - (millisToFinish / 60000.0).toString(), - eventName, - ) - } - - override fun updateProyectAnalytics() { - getSettings()?.let { - if (it.matomoID() != null && it.matomoURL() != null) { - analyticsHelper.updateMatomoSecondaryTracker( - it.matomoURL()!!, - it.matomoID()!!, - DEFAULT_EXTERNAL_TRACKER_NAME, - ) - } - } ?: analyticsHelper.clearMatomoSecondaryTracker() - } + private fun getProgramSetting(): ProgramSettings? = + d2 + .settingModule() + .synchronizationSettings() + .blockingGet() + ?.programSettings() } diff --git a/app/src/main/java/org/dhis2/data/service/SyncStatusController.kt b/app/src/main/java/org/dhis2/data/service/SyncStatusController.kt deleted file mode 100644 index 19373e1a7b9..00000000000 --- a/app/src/main/java/org/dhis2/data/service/SyncStatusController.kt +++ /dev/null @@ -1,190 +0,0 @@ -package org.dhis2.data.service - -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.launch -import org.dhis2.commons.viewmodel.DispatcherProvider -import org.hisp.dhis.android.core.arch.call.D2ProgressStatus -import org.hisp.dhis.android.core.arch.call.D2ProgressSyncStatus -import timber.log.Timber - -class SyncStatusController( - private val dispatcher: DispatcherProvider, -) { - private var progressStatusMap: Map = emptyMap() - private val downloadStatus = MutableStateFlow(SyncStatusData(isInitialSync = true)) - - fun observeDownloadProcess(): StateFlow = downloadStatus - - fun initDownloadProcess(programDownload: Map) { - Timber.tag("SYNC").d("INIT DATA SYNC") - progressStatusMap = programDownload - CoroutineScope(dispatcher.io()).launch { - downloadStatus.emit( - SyncStatusData( - running = true, - downloadingEvents = false, - downloadingTracker = false, - downloadingDataSetValues = false, - false, - progressStatusMap, - ), - ) - } - } - - fun updateDownloadProcess(programDownload: Map) { - Timber.tag("SYNC").d("Updating PROGRAM") - progressStatusMap = - progressStatusMap.toMutableMap().also { - it.putAll(programDownload) - } - CoroutineScope(dispatcher.io()).launch { - downloadStatus.emit( - downloadStatus.value.copy(programSyncStatusMap = progressStatusMap), - ) - } - } - - fun finishSync() { - Timber.tag("SYNC").d("FINISH DATA SYNC") - progressStatusMap = progressStatusMap.toMutableMap() - CoroutineScope(dispatcher.io()).launch { - downloadStatus.emit( - downloadStatus.value.copy( - running = false, - programSyncStatusMap = progressStatusMap, - ), - ) - } - } - - fun onNetworkUnavailable() { - progressStatusMap = - progressStatusMap.toMutableMap().mapValues { entry -> - if (entry.value.isComplete) { - entry.value - } else { - entry.value.copy(isComplete = true, D2ProgressSyncStatus.ERROR) - } - } - CoroutineScope(dispatcher.io()).launch { - downloadStatus.emit( - SyncStatusData(true, programSyncStatusMap = progressStatusMap), - ) - } - } - - fun startDownloadingEvents() { - CoroutineScope(dispatcher.io()).launch { - downloadStatus.emit( - downloadStatus.value.copy(running = true, downloadingEvents = true), - ) - } - } - - fun finishDownloadingEvents(eventProgramUids: List) { - Timber.tag("SYNC").d("FINISHED EVENTS") - progressStatusMap = - progressStatusMap.toMutableMap().mapValues { entry -> - if (!eventProgramUids.contains(entry.key) || entry.value.isComplete) { - entry.value - } else { - entry.value.copy(isComplete = true, D2ProgressSyncStatus.ERROR) - } - } - CoroutineScope(dispatcher.io()).launch { - downloadStatus.emit( - downloadStatus.value.copy( - downloadingEvents = false, - programSyncStatusMap = progressStatusMap, - ), - ) - } - } - - fun startDownloadingTracker() { - CoroutineScope(dispatcher.io()).launch { - downloadStatus.emit( - downloadStatus.value.copy(downloadingTracker = true), - ) - } - } - - fun finishDownloadingTracker(trackerProgramUids: List) { - Timber.tag("SYNC").d("FINISHED TRACKER") - - progressStatusMap = - progressStatusMap.toMutableMap().mapValues { entry -> - if (!trackerProgramUids.contains(entry.key) || entry.value.isComplete) { - entry.value - } else { - entry.value.copy(isComplete = true, D2ProgressSyncStatus.ERROR) - } - } - CoroutineScope(dispatcher.io()).launch { - downloadStatus.emit( - downloadStatus.value.copy( - downloadingTracker = false, - programSyncStatusMap = progressStatusMap, - ), - ) - } - } - - fun updateSingleProgramToSuccess(programUid: String) { - progressStatusMap = - progressStatusMap.toMutableMap().mapValues { entry -> - if (programUid != entry.key) { - entry.value - } else { - entry.value.copy(isComplete = true, D2ProgressSyncStatus.SUCCESS) - } - } - CoroutineScope(dispatcher.io()).launch { - downloadStatus.emit( - SyncStatusData(false, programSyncStatusMap = progressStatusMap), - ) - } - } - - fun initDownloadMedia() { - Timber.tag("SYNC").d("INIT FILES") - CoroutineScope(dispatcher.io()).launch { - downloadStatus.emit( - downloadStatus.value.copy(downloadingMedia = true), - ) - } - } - - fun restore() { - CoroutineScope(dispatcher.io()).launch { - downloadStatus.emit(SyncStatusData()) - } - } - - fun startDownloadingDataSets() { - CoroutineScope(dispatcher.io()).launch { - downloadStatus.emit( - downloadStatus.value.copy(downloadingDataSetValues = true), - ) - } - } - - fun finishDownloadingDataSets(dataSetUids: List) { - progressStatusMap = - progressStatusMap.toMutableMap().mapValues { entry -> - if (!dataSetUids.contains(entry.key) || entry.value.isComplete) { - entry.value - } else { - entry.value.copy(isComplete = true, D2ProgressSyncStatus.ERROR) - } - } - CoroutineScope(dispatcher.io()).launch { - downloadStatus.emit( - downloadStatus.value.copy(downloadingDataSetValues = false, programSyncStatusMap = progressStatusMap), - ) - } - } -} diff --git a/app/src/main/java/org/dhis2/data/service/VersionRepository.kt b/app/src/main/java/org/dhis2/data/service/VersionRepository.kt index 23771279cc4..49c65337ce8 100644 --- a/app/src/main/java/org/dhis2/data/service/VersionRepository.kt +++ b/app/src/main/java/org/dhis2/data/service/VersionRepository.kt @@ -5,11 +5,8 @@ import android.content.BroadcastReceiver import android.content.Context import android.content.Intent import android.content.IntentFilter -import android.net.Uri -import android.os.Build import android.os.Environment import androidx.core.content.ContextCompat -import androidx.core.content.FileProvider import androidx.core.net.toUri import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.SharedFlow @@ -44,7 +41,7 @@ class VersionRepository( fun download( context: Context, - onDownloadCompleted: (Uri) -> Unit, + onDownloadCompleted: (String) -> Unit, onDownloading: () -> Unit, ) { val url = @@ -60,9 +57,8 @@ class VersionRepository( )}/$fileName" val apkFile = File(destination) - val apkUri = uriFromFile(context, apkFile) if (apkFile.exists()) { - onDownloadCompleted(apkUri) + onDownloadCompleted(destination) } else if (fileName?.endsWith("apk") == true) { val request = DownloadManager @@ -86,7 +82,7 @@ class VersionRepository( ctxt: Context, intent: Intent?, ) { - onDownloadCompleted(apkUri) + onDownloadCompleted(destination) } } ContextCompat.registerReceiver( @@ -96,20 +92,10 @@ class VersionRepository( ContextCompat.RECEIVER_EXPORTED, ) } else { - url?.let { onDownloadCompleted(it.toUri()) } + url?.let { onDownloadCompleted(url) } } } - private fun uriFromFile( - context: Context, - file: File, - ): Uri = - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { - FileProvider.getUriForFile(context, BuildConfig.APPLICATION_ID + ".provider", file) - } else { - Uri.fromFile(file) - } - fun getUrl(): String? = d2 .settingModule() diff --git a/app/src/main/java/org/dhis2/data/service/workManager/WorkManagerController.kt b/app/src/main/java/org/dhis2/data/service/workManager/WorkManagerController.kt index 9723f3177bd..f583c04b714 100644 --- a/app/src/main/java/org/dhis2/data/service/workManager/WorkManagerController.kt +++ b/app/src/main/java/org/dhis2/data/service/workManager/WorkManagerController.kt @@ -34,16 +34,6 @@ import androidx.work.WorkInfo interface WorkManagerController { fun syncDataForWorker(workerItem: WorkerItem) - fun syncMetaDataForWorker( - metadataWorkerTag: String, - workName: String, - ) - - fun syncDataForWorker( - dataWorkerTag: String, - workName: String, - ) - fun beginUniqueWork(workerItem: WorkerItem) fun enqueuePeriodicWork(workerItem: WorkerItem) diff --git a/app/src/main/java/org/dhis2/data/service/workManager/WorkManagerControllerImpl.kt b/app/src/main/java/org/dhis2/data/service/workManager/WorkManagerControllerImpl.kt index 26158e84383..ceef5322de2 100644 --- a/app/src/main/java/org/dhis2/data/service/workManager/WorkManagerControllerImpl.kt +++ b/app/src/main/java/org/dhis2/data/service/workManager/WorkManagerControllerImpl.kt @@ -30,16 +30,13 @@ package org.dhis2.data.service.workManager import androidx.lifecycle.LiveData import androidx.lifecycle.MediatorLiveData -import androidx.work.ExistingWorkPolicy import androidx.work.OneTimeWorkRequest import androidx.work.PeriodicWorkRequest import androidx.work.WorkInfo import androidx.work.WorkManager import androidx.work.await import org.dhis2.data.service.CheckVersionWorker -import org.dhis2.data.service.SyncDataWorker import org.dhis2.data.service.SyncGranularWorker -import org.dhis2.data.service.SyncMetadataWorker import java.util.concurrent.TimeUnit class WorkManagerControllerImpl( @@ -55,32 +52,6 @@ class WorkManagerControllerImpl( } } - override fun syncMetaDataForWorker( - metadataWorkerTag: String, - workName: String, - ) { - val workerOneBuilder = OneTimeWorkRequest.Builder(SyncMetadataWorker::class.java) - workerOneBuilder - .addTag(metadataWorkerTag) - - workManager - .beginUniqueWork(workName, ExistingWorkPolicy.KEEP, workerOneBuilder.build()) - .enqueue() - } - - override fun syncDataForWorker( - dataWorkerTag: String, - workName: String, - ) { - val workerTwoBuilder = OneTimeWorkRequest.Builder(SyncDataWorker::class.java) - workerTwoBuilder - .addTag(dataWorkerTag) - - workManager - .beginUniqueWork(workName, ExistingWorkPolicy.KEEP, workerTwoBuilder.build()) - .enqueue() - } - override fun beginUniqueWork(workerItem: WorkerItem) { val request = createOneTimeBuilder(workerItem).build() workerItem.policy?.let { @@ -132,8 +103,6 @@ class WorkManagerControllerImpl( private fun createOneTimeBuilder(workerItem: WorkerItem): OneTimeWorkRequest.Builder { val syncBuilder = when (workerItem.workerType) { - WorkerType.METADATA -> OneTimeWorkRequest.Builder(SyncMetadataWorker::class.java) - WorkerType.DATA -> OneTimeWorkRequest.Builder(SyncDataWorker::class.java) WorkerType.GRANULAR -> OneTimeWorkRequest.Builder(SyncGranularWorker::class.java) WorkerType.NEW_VERSION -> OneTimeWorkRequest.Builder(CheckVersionWorker::class.java) } @@ -155,20 +124,6 @@ class WorkManagerControllerImpl( val syncBuilder = when (workerItem.workerType) { - WorkerType.METADATA -> { - PeriodicWorkRequest.Builder( - SyncMetadataWorker::class.java, - seconds, - TimeUnit.SECONDS, - ) - } - WorkerType.DATA -> { - PeriodicWorkRequest.Builder( - SyncDataWorker::class.java, - seconds, - TimeUnit.SECONDS, - ) - } WorkerType.GRANULAR -> { PeriodicWorkRequest.Builder( SyncGranularWorker::class.java, @@ -176,6 +131,7 @@ class WorkManagerControllerImpl( TimeUnit.SECONDS, ) } + WorkerType.NEW_VERSION -> PeriodicWorkRequest.Builder( CheckVersionWorker::class.java, diff --git a/app/src/main/java/org/dhis2/data/service/workManager/WorkerType.kt b/app/src/main/java/org/dhis2/data/service/workManager/WorkerType.kt index 6c77fc50fb5..050519a6e2c 100644 --- a/app/src/main/java/org/dhis2/data/service/workManager/WorkerType.kt +++ b/app/src/main/java/org/dhis2/data/service/workManager/WorkerType.kt @@ -29,8 +29,6 @@ package org.dhis2.data.service.workManager enum class WorkerType { - METADATA, - DATA, GRANULAR, NEW_VERSION, } diff --git a/app/src/main/java/org/dhis2/data/user/UserComponent.java b/app/src/main/java/org/dhis2/data/user/UserComponent.java index eebf6102a13..821e61f4cd7 100644 --- a/app/src/main/java/org/dhis2/data/user/UserComponent.java +++ b/app/src/main/java/org/dhis2/data/user/UserComponent.java @@ -8,14 +8,8 @@ import org.dhis2.commons.featureconfig.di.FeatureConfigActivityComponent; import org.dhis2.commons.featureconfig.di.FeatureConfigActivityModule; import org.dhis2.commons.filters.data.FilterPresenter; -import org.dhis2.data.service.SyncDataWorkerComponent; -import org.dhis2.data.service.SyncDataWorkerModule; import org.dhis2.data.service.SyncGranularRxComponent; import org.dhis2.data.service.SyncGranularRxModule; -import org.dhis2.data.service.SyncInitWorkerComponent; -import org.dhis2.data.service.SyncInitWorkerModule; -import org.dhis2.data.service.SyncMetadataWorkerComponent; -import org.dhis2.data.service.SyncMetadataWorkerModule; import org.dhis2.usescases.about.AboutComponent; import org.dhis2.usescases.about.AboutModule; import org.dhis2.usescases.datasets.datasetDetail.DataSetDetailComponent; @@ -70,8 +64,6 @@ import org.dhis2.usescases.teiDashboard.dialogs.scheduling.SchedulingModule; import org.dhis2.usescases.teiDashboard.teiProgramList.TeiProgramListComponent; import org.dhis2.usescases.teiDashboard.teiProgramList.TeiProgramListModule; -import org.dhis2.utils.session.PinModule; -import org.dhis2.utils.session.SessionComponent; import dagger.Subcomponent; import dhis2.org.analytics.charts.ui.di.AnalyticsFragmentComponent; @@ -133,12 +125,6 @@ public interface UserComponent { @NonNull ReservedValueComponent plus(ReservedValueModule reservedValueModule); - @NonNull - SyncDataWorkerComponent plus(SyncDataWorkerModule syncDataWorkerModule); - - @NonNull - SyncMetadataWorkerComponent plus(SyncMetadataWorkerModule syncDataWorkerModule); - @NonNull EventCaptureComponent plus(EventCaptureModule eventCaptureModule); @@ -151,9 +137,6 @@ public interface UserComponent { @NonNull SyncComponent plus(SyncModule syncModule); - @NonNull - SyncInitWorkerComponent plus(SyncInitWorkerModule syncInitWorkerModule); - @NonNull EnrollmentComponent plus(EnrollmentModule enrollmentModule); @@ -185,9 +168,6 @@ public interface UserComponent { @NonNull EventDetailsComponent plus(EventDetailsModule eventDetailsModule); - @NonNull - SessionComponent plus(PinModule pinModule); - @NonNull SchedulingComponent plus(SchedulingModule schedulingModule); } diff --git a/app/src/main/java/org/dhis2/di/KoinInitialization.kt b/app/src/main/java/org/dhis2/di/KoinInitialization.kt index 61bbf879f81..be3b7cb076c 100644 --- a/app/src/main/java/org/dhis2/di/KoinInitialization.kt +++ b/app/src/main/java/org/dhis2/di/KoinInitialization.kt @@ -1,19 +1,26 @@ package org.dhis2.di import android.app.Application +import org.dhis2.BuildConfig import org.dhis2.android.rtsm.di.stockModule +import org.dhis2.appModule +import org.dhis2.commons.di.filterModule import org.dhis2.commons.di.resourceManagerModule import org.dhis2.commons.filters.periods.di.filterPeriodsModule import org.dhis2.data.biometric.biometricModule import org.dhis2.mobile.aggregates.di.aggregatesModule import org.dhis2.mobile.commons.di.commonsModule import org.dhis2.mobile.login.main.di.loginModule +import org.dhis2.mobile.sync.di.syncModule +import org.dhis2.tracker.search.di.trackerSearchModule import org.dhis2.usescases.datasets.di.dataSetModules +import org.dhis2.usescases.searchTrackEntity.di.searchTEKoinModule import org.dhis2.usescases.settingsprogram.di.settingsProgramModule import org.dhis2.utils.analytics.matomo.matomoModule import org.hisp.dhis.android.core.D2Configuration import org.koin.android.ext.koin.androidContext import org.koin.android.ext.koin.androidLogger +import org.koin.androidx.workmanager.koin.workManagerFactory import org.koin.core.context.startKoin object KoinInitialization { @@ -21,18 +28,30 @@ object KoinInitialization { startKoin { androidLogger() androidContext(this@invoke) + properties( + mapOf( + "sentryDsn" to BuildConfig.SENTRY_DSN, + "isTrainingFlavor" to (BuildConfig.FLAVOR == "dhis2Training"), + ), + ) + workManagerFactory() modules( + appModule, serverModule(d2Configuration), commonsModule, aggregatesModule, + filterModule, filterPeriodsModule, resourceManagerModule, dataSetModules, stockModule, loginModule, + trackerSearchModule, + searchTEKoinModule, settingsProgramModule, biometricModule, matomoModule, + syncModule, ) } } diff --git a/app/src/main/java/org/dhis2/usescases/datasets/datasetDetail/DataSetDetailActivity.java b/app/src/main/java/org/dhis2/usescases/datasets/datasetDetail/DataSetDetailActivity.java index 5bc7f3c3c27..054df4253f6 100644 --- a/app/src/main/java/org/dhis2/usescases/datasets/datasetDetail/DataSetDetailActivity.java +++ b/app/src/main/java/org/dhis2/usescases/datasets/datasetDetail/DataSetDetailActivity.java @@ -105,14 +105,12 @@ private void configureBottomNavigation() { binding.navigationBar.setOnItemSelectedListener(item -> { Fragment newFragment = null; - switch (item.getItemId()) { - case R.id.navigation_list_view: - newFragment = DataSetListFragment.newInstance(dataSetUid, accessWriteData); - break; - case R.id.navigation_analytics: - presenter.trackDataSetAnalytics(); - newFragment = GroupAnalyticsFragment.Companion.forDataSet(dataSetUid); - break; + int itemId = item.getItemId(); + if (itemId == R.id.navigation_list_view) { + newFragment = DataSetListFragment.newInstance(dataSetUid, accessWriteData); + } else if (itemId == R.id.navigation_analytics) { + presenter.trackDataSetAnalytics(); + newFragment = GroupAnalyticsFragment.Companion.forDataSet(dataSetUid); } FragmentTransaction transaction = getSupportFragmentManager().beginTransaction(); if (fragment == null ||(newFragment != null && !fragment.getClass().toString().equals(newFragment.getClass().toString()))) { diff --git a/app/src/main/java/org/dhis2/usescases/development/ConflictGenerator.kt b/app/src/main/java/org/dhis2/usescases/development/ConflictGenerator.kt index 601483fe600..99c7dced63c 100644 --- a/app/src/main/java/org/dhis2/usescases/development/ConflictGenerator.kt +++ b/app/src/main/java/org/dhis2/usescases/development/ConflictGenerator.kt @@ -1,3 +1,5 @@ +@file:OptIn(LegacyDataValueApi::class) + package org.dhis2.usescases.development import kotlinx.coroutines.runBlocking @@ -8,6 +10,7 @@ import org.hisp.dhis.android.core.D2 import org.hisp.dhis.android.core.common.State import org.hisp.dhis.android.core.datavalue.DataValue import org.hisp.dhis.android.core.datavalue.DataValueConflict +import org.hisp.dhis.android.core.datavalue.LegacyDataValueApi import org.hisp.dhis.android.core.enrollment.Enrollment import org.hisp.dhis.android.core.event.Event import org.hisp.dhis.android.core.imports.ImportStatus @@ -125,24 +128,37 @@ class ConflictGenerator( dataValueConflict.categoryOptionCombo() != null && dataValueConflict.attributeOptionCombo() != null ) { - val dataValue = - d2 - .dataValueModule() - .dataValues() - .value( - dataValueConflict.period()!!, - dataValueConflict.orgUnit()!!, - dataValueConflict.dataElement()!!, - dataValueConflict.categoryOptionCombo()!!, - dataValueConflict.attributeOptionCombo()!!, - ).blockingGet() - val dv = - dataValue?.toBuilder()?.syncState(State.SYNCED)?.build() - dv?.let { - runBlocking { - d2.databaseAdapter().upsertObject(it, DataValue::class) + d2 + .dataSetModule() + .dataSetCompleteRegistrations() + .byPeriod() + .eq(dataValueConflict.period()!!) + .byOrganisationUnitUid() + .eq(dataValueConflict.orgUnit()!!) + .byAttributeOptionComboUid() + .eq(dataValueConflict.attributeOptionCombo()!!) + .blockingGet() + .forEach { + val dataValue = + d2 + .dataValueModule() + .dataValues() + .value( + dataValueConflict.period()!!, + dataValueConflict.orgUnit()!!, + dataValueConflict.dataElement()!!, + dataValueConflict.categoryOptionCombo()!!, + dataValueConflict.attributeOptionCombo()!!, + it.dataSet(), + ).blockingGet() + val dv = + dataValue?.toBuilder()?.syncState(State.SYNCED)?.build() + dv?.let { + runBlocking { + d2.databaseAdapter().upsertObject(it, DataValue::class) + } + } } - } } }.also { runBlocking { @@ -423,7 +439,9 @@ class ConflictGenerator( try { runBlocking { d2.databaseAdapter().upsertObject(conflict, TrackerImportConflict::class) - d2.databaseAdapter().execSQL(updateEvent(event.uid(), importStatus.toSyncState().name)) + d2 + .databaseAdapter() + .execSQL(updateEvent(event.uid(), importStatus.toSyncState().name)) } } catch (e: Exception) { Timber.e(e) @@ -529,7 +547,7 @@ class ConflictGenerator( syncState: String, ): String = "UPDATE Enrollment SET syncState = '$syncState'," + - " aggregatedSyncState = '$syncState' where uid = '$enrollmentUid'" + " aggregatedSyncState = '$syncState' where uid = '$enrollmentUid'" private fun updateTei( teiUid: String, diff --git a/app/src/main/java/org/dhis2/usescases/enrollment/EnrollmentModule.kt b/app/src/main/java/org/dhis2/usescases/enrollment/EnrollmentModule.kt index 53ecfd232e1..d0324b7a46d 100644 --- a/app/src/main/java/org/dhis2/usescases/enrollment/EnrollmentModule.kt +++ b/app/src/main/java/org/dhis2/usescases/enrollment/EnrollmentModule.kt @@ -8,7 +8,6 @@ import io.reactivex.processors.PublishProcessor import org.dhis2.commons.data.EntryMode import org.dhis2.commons.di.dagger.PerActivity import org.dhis2.commons.matomo.MatomoAnalyticsController -import org.dhis2.commons.network.NetworkUtils import org.dhis2.commons.prefs.PreferenceProvider import org.dhis2.commons.resources.DhisPeriodUtils import org.dhis2.commons.resources.EventResourcesProvider @@ -39,7 +38,7 @@ import org.dhis2.form.ui.provider.LegendValueProviderImpl import org.dhis2.form.ui.provider.UiEventTypesProviderImpl import org.dhis2.mobile.commons.customintents.CustomIntentRepository import org.dhis2.mobile.commons.customintents.CustomIntentRepositoryImpl -import org.dhis2.mobile.commons.providers.FieldErrorMessageProvider +import org.dhis2.mobile.commons.network.NetworkStatusProvider import org.dhis2.mobile.commons.reporting.CrashReportController import org.dhis2.usescases.teiDashboard.TeiAttributesProvider import org.dhis2.utils.analytics.AnalyticsHelper @@ -187,23 +186,22 @@ class EnrollmentModule( d2: D2, enrollmentRepository: EnrollmentObjectRepository, crashReportController: CrashReportController, - networkUtils: NetworkUtils, searchTEIRepository: SearchTEIRepository, resourceManager: ResourceManager, - ): ValueStore { - val fieldErrorMessageProvider = FieldErrorMessageProvider() - return ValueStoreImpl( + networkStatusProvider: NetworkStatusProvider, + dispatcherProvider: DispatcherProvider, + ): ValueStore = + ValueStoreImpl( d2, enrollmentRepository.blockingGet()?.trackedEntityInstance()!!, EntryMode.ATTR, DhisEnrollmentUtils(d2), crashReportController, - networkUtils, searchTEIRepository, - fieldErrorMessageProvider, resourceManager, + networkStatusProvider, + dispatcherProvider, ) - } @Provides @PerActivity diff --git a/app/src/main/java/org/dhis2/usescases/eventsWithoutRegistration/eventCapture/eventCaptureFragment/EventCaptureFormPresenter.kt b/app/src/main/java/org/dhis2/usescases/eventsWithoutRegistration/eventCapture/eventCaptureFragment/EventCaptureFormPresenter.kt index 86ca05bc1c7..4bf38c2a121 100644 --- a/app/src/main/java/org/dhis2/usescases/eventsWithoutRegistration/eventCapture/eventCaptureFragment/EventCaptureFormPresenter.kt +++ b/app/src/main/java/org/dhis2/usescases/eventsWithoutRegistration/eventCapture/eventCaptureFragment/EventCaptureFormPresenter.kt @@ -72,8 +72,8 @@ class EventCaptureFormPresenter( 1, ) to false - EventNonEditableReason.ORGUNIT_IS_NOT_IN_CAPTURE_SCOPE -> - resourceManager.getString(R.string.edition_orgunit_capture_scope) to + EventNonEditableReason.ORGUNIT_IS_NOT_IN_USER_SCOPE -> + resourceManager.getString(R.string.edition_orgunit_user_scope) to false } view.showNonEditableMessage(reason, canBeReOpened) diff --git a/app/src/main/java/org/dhis2/usescases/eventsWithoutRegistration/eventDetails/providers/EventDetailResourcesProvider.kt b/app/src/main/java/org/dhis2/usescases/eventsWithoutRegistration/eventDetails/providers/EventDetailResourcesProvider.kt index 5057dbcf06d..5fe7fc1a62f 100644 --- a/app/src/main/java/org/dhis2/usescases/eventsWithoutRegistration/eventDetails/providers/EventDetailResourcesProvider.kt +++ b/app/src/main/java/org/dhis2/usescases/eventsWithoutRegistration/eventDetails/providers/EventDetailResourcesProvider.kt @@ -46,8 +46,8 @@ class EventDetailResourcesProvider( R.string.edition_enrollment_is_no_open_V2, 1, ) - EventNonEditableReason.ORGUNIT_IS_NOT_IN_CAPTURE_SCOPE -> - resourceManager.getString(R.string.edition_orgunit_capture_scope) + EventNonEditableReason.ORGUNIT_IS_NOT_IN_USER_SCOPE -> + resourceManager.getString(R.string.edition_orgunit_user_scope) } fun provideButtonUpdate() = resourceManager.getString(R.string.update) diff --git a/app/src/main/java/org/dhis2/usescases/eventsWithoutRegistration/eventInitial/EventInitialRepositoryImpl.kt b/app/src/main/java/org/dhis2/usescases/eventsWithoutRegistration/eventInitial/EventInitialRepositoryImpl.kt index 1672ed3d2a0..180a1523caf 100644 --- a/app/src/main/java/org/dhis2/usescases/eventsWithoutRegistration/eventInitial/EventInitialRepositoryImpl.kt +++ b/app/src/main/java/org/dhis2/usescases/eventsWithoutRegistration/eventInitial/EventInitialRepositoryImpl.kt @@ -315,6 +315,7 @@ class EventInitialRepositoryImpl internal constructor( .programStageSections() .byProgramStageUid() .eq(stage!!.uid()) + .orderBySortOrder(RepositoryScope.OrderByDirection.ASC) .blockingGet() stageSections @@ -369,6 +370,7 @@ class EventInitialRepositoryImpl internal constructor( .withDataElements() .byProgramStageUid() .eq(event.programStage()) + .orderBySortOrder(RepositoryScope.OrderByDirection.ASC) .blockingGet() val stageDataElements = d2 diff --git a/app/src/main/java/org/dhis2/usescases/general/SessionManagerActivity.kt b/app/src/main/java/org/dhis2/usescases/general/SessionManagerActivity.kt index 9ccd6cd9450..a8bd1f4a477 100644 --- a/app/src/main/java/org/dhis2/usescases/general/SessionManagerActivity.kt +++ b/app/src/main/java/org/dhis2/usescases/general/SessionManagerActivity.kt @@ -7,9 +7,8 @@ import android.view.WindowManager import androidx.appcompat.app.AppCompatActivity import androidx.core.app.ActivityOptionsCompat import androidx.lifecycle.lifecycleScope -import io.reactivex.Observable import io.reactivex.subjects.BehaviorSubject -import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch import org.dhis2.App import org.dhis2.R import org.dhis2.bindings.app @@ -18,10 +17,9 @@ import org.dhis2.commons.ActivityResultObserver import org.dhis2.commons.locationprovider.LocationProvider import org.dhis2.commons.service.SessionManagerServiceImpl import org.dhis2.commons.ui.extensions.handleInsets -import org.dhis2.commons.viewmodel.DispatcherProvider import org.dhis2.data.server.OpenIdSession.LogOutReason -import org.dhis2.data.service.SyncStatusController import org.dhis2.data.service.workManager.WorkManagerController +import org.dhis2.mobile.sync.domain.SyncStatusController import org.dhis2.usescases.login.LoginActivity import org.dhis2.usescases.login.LoginActivity.Companion.bundle import org.dhis2.usescases.main.MainActivity @@ -30,8 +28,9 @@ import org.dhis2.usescases.splash.SplashActivity import org.dhis2.utils.analytics.AnalyticsHelper import org.dhis2.utils.analytics.CLICK import org.dhis2.utils.analytics.FORGOT_CODE -import org.dhis2.utils.session.PIN_DIALOG_TAG -import org.dhis2.utils.session.PinDialog +import org.dhis2.mobile.login.pin.addPinBottomSheet +import org.dhis2.mobile.login.pin.domain.model.PinMode +import org.koin.android.ext.android.inject import javax.inject.Inject abstract class SessionManagerActivity : @@ -46,28 +45,17 @@ abstract class SessionManagerActivity : @Inject lateinit var locationProvider: LocationProvider - fun observableLifeCycle(): Observable = lifeCycleObservable - open var handleEdgeToEdge = true @Inject lateinit var analyticsHelper: AnalyticsHelper - private var pinDialog: PinDialog? = null + private var pinComposeView: androidx.compose.ui.platform.ComposeView? = null private var lifeCycleObservable: BehaviorSubject = BehaviorSubject.create() - var syncStatusController: SyncStatusController = - SyncStatusController( - object : DispatcherProvider { - override fun io() = Dispatchers.IO - - override fun computation() = Dispatchers.Default - - override fun ui() = Dispatchers.Main - }, - ) + val syncStatusController: SyncStatusController by inject() override fun onCreate(savedInstanceState: Bundle?) { val serverComponent = (applicationContext as App).serverComponent @@ -147,23 +135,27 @@ abstract class SessionManagerActivity : this.activityResultObserver = activityResultObserver } - private fun initPinDialog() { - pinDialog = - PinDialog( - PinDialog.Mode.ASK, - (this is LoginActivity), - { - startActivity(MainActivity::class.java, null, true, true, null) - null - }, - { - analyticsHelper.setEvent(FORGOT_CODE, CLICK, FORGOT_CODE) - if (this !is LoginActivity) { - startActivity(LoginActivity::class.java, null, true, true, null) - } - null - }, - ) + private fun showPinBottomSheet() { + if (pinComposeView != null) return + pinComposeView = addPinBottomSheet( + mode = PinMode.ASK, + onSuccess = { + startActivity(MainActivity::class.java, null, true, true, null) + }, + onDismiss = { + analyticsHelper.setEvent(FORGOT_CODE, CLICK, FORGOT_CODE) + if (this !is LoginActivity) { + startActivity(LoginActivity::class.java, null, true, true, null) + } + }, + ) + } + + private fun removePinBottomSheet() { + pinComposeView?.let { view -> + (window?.decorView as? android.view.ViewGroup)?.removeView(view) + } + pinComposeView = null } override fun unsubscribe() { @@ -196,10 +188,6 @@ abstract class SessionManagerActivity : if (finishCurrent) finish() } - private fun showPinDialog() { - pinDialog!!.show(supportFragmentManager, PIN_DIALOG_TAG) - } - @Deprecated("Deprecated in Java") override fun onActivityResult( requestCode: Int, @@ -223,14 +211,15 @@ abstract class SessionManagerActivity : this !is LoginActivity ) { workManagerController.cancelAllWork() - syncStatusController.restore() + lifecycleScope.launch { + syncStatusController.restore() + } } } override fun onStop() { super.onStop() - val dialog = pinDialog - dialog?.dismissAllowingStateLoss() + removePinBottomSheet() } override fun onDestroy() { @@ -254,11 +243,8 @@ abstract class SessionManagerActivity : comesFromImageSource = false } else { if (this.app().isSessionBlocked && this !is SplashActivity && this !is LoginActivity) { - if (pinDialog == null) { - initPinDialog() - showPinDialog() - } else if (pinDialog?.isVisible == false) { - showPinDialog() + if (pinComposeView == null) { + showPinBottomSheet() } } else { if (this !is LoginActivity && this !is SplashActivity) { @@ -270,9 +256,8 @@ abstract class SessionManagerActivity : private fun sessionAction(accountsCount: Int) { if (this.app().isSessionBlocked && this !is SplashActivity) { - if (pinDialog == null) { - initPinDialog() - showPinDialog() + if (pinComposeView == null) { + showPinBottomSheet() } } else { navigateToLogin(accountsCount) diff --git a/app/src/main/java/org/dhis2/usescases/main/MainActivity.kt b/app/src/main/java/org/dhis2/usescases/main/MainActivity.kt index 9526973f96b..a457e2570ab 100644 --- a/app/src/main/java/org/dhis2/usescases/main/MainActivity.kt +++ b/app/src/main/java/org/dhis2/usescases/main/MainActivity.kt @@ -21,6 +21,7 @@ import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.remember +import androidx.compose.ui.platform.ComposeView import androidx.compose.ui.platform.ViewCompositionStrategy import androidx.constraintlayout.widget.ConstraintSet import androidx.core.app.NotificationCompat @@ -47,6 +48,7 @@ import org.dhis2.commons.orgunitselector.OUTreeFragment import org.dhis2.commons.sync.OnDismissListener import org.dhis2.commons.sync.SyncContext import org.dhis2.databinding.ActivityMainBinding +import org.dhis2.mobile.sync.data.SyncBackgroundJobAction import org.dhis2.usescases.development.DevelopmentActivity import org.dhis2.usescases.general.ActivityGlobalAbstract import org.dhis2.usescases.login.LoginActivity @@ -57,9 +59,10 @@ import org.dhis2.utils.customviews.navigationbar.NavigationPage import org.dhis2.utils.customviews.navigationbar.NavigationPageConfigurator import org.dhis2.utils.extension.navigateTo import org.dhis2.utils.granularsync.SyncStatusDialog -import org.dhis2.utils.session.PIN_DIALOG_TAG -import org.dhis2.utils.session.PinDialog +import org.dhis2.mobile.login.pin.addPinBottomSheet +import org.dhis2.mobile.login.pin.domain.model.PinMode import org.hisp.dhis.mobile.ui.designsystem.component.navigationBar.NavigationBar +import org.koin.android.ext.android.inject import java.io.File import javax.inject.Inject @@ -87,12 +90,15 @@ class MainActivity : @Inject lateinit var pageConfigurator: NavigationPageConfigurator + private val syncBackgroundJobAction: SyncBackgroundJobAction by inject() + private val getDevActivityContent = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { // no-op } private var backDropActive = false + private var pinComposeView: ComposeView? = null private val requestWritePermissions = registerForActivityResult(ActivityResultContracts.RequestPermission()) { granted -> @@ -110,8 +116,6 @@ class MainActivity : } } - private var isPinLayoutVisible = false - private lateinit var mainNavigator: MainNavigator private val navigationLauncher = @@ -148,6 +152,8 @@ class MainActivity : it.plus( MainModule( view = this, + syncStatusController = syncStatusController, + syncBackgroundJobAction = syncBackgroundJobAction, forceToNotSynced = intent.getBooleanExtra(AVOID_SYNC, false), ), ) @@ -259,6 +265,7 @@ class MainActivity : override fun onPause() { presenter.onDetach() + removePinBottomSheet() super.onPause() } @@ -508,23 +515,31 @@ class MainActivity : override fun onLockClick() { if (!presenter.isPinStored()) { + if (pinComposeView != null) return binding.mainDrawerLayout.closeDrawers() - PinDialog( - PinDialog.Mode.SET, - true, - { presenter.blockSession() }, - {}, - ).show(supportFragmentManager, PIN_DIALOG_TAG) - isPinLayoutVisible = true + pinComposeView = addPinBottomSheet( + mode = PinMode.SET, + onSuccess = { + removePinBottomSheet() + presenter.blockSession() + }, + onDismiss = { removePinBottomSheet() }, + ) } else { presenter.blockSession() } } + private fun removePinBottomSheet() { + pinComposeView?.let { view -> + (window?.decorView as? android.view.ViewGroup)?.removeView(view) + } + pinComposeView = null + } + private fun backPressed() { when { !mainNavigator.isHome() -> presenter.onNavigateBackToHome() - isPinLayoutVisible -> isPinLayoutVisible = false else -> back() } } diff --git a/app/src/main/java/org/dhis2/usescases/main/MainModule.kt b/app/src/main/java/org/dhis2/usescases/main/MainModule.kt index 04ce3cab5f2..7de752f9734 100644 --- a/app/src/main/java/org/dhis2/usescases/main/MainModule.kt +++ b/app/src/main/java/org/dhis2/usescases/main/MainModule.kt @@ -15,7 +15,6 @@ import org.dhis2.commons.schedulers.SchedulerProvider import org.dhis2.commons.viewmodel.DispatcherProvider import org.dhis2.data.biometric.CryptographyManager import org.dhis2.data.server.UserManager -import org.dhis2.data.service.SyncStatusController import org.dhis2.data.service.VersionRepository import org.dhis2.data.service.workManager.WorkManagerController import org.dhis2.mobile.commons.biometrics.CryptographicActions @@ -25,6 +24,8 @@ import org.dhis2.mobile.commons.network.NetworkStatusProvider import org.dhis2.mobile.commons.network.NetworkStatusProviderImpl import org.dhis2.mobile.commons.resources.D2ErrorMessageProvider import org.dhis2.mobile.commons.resources.D2ErrorMessageProviderImpl +import org.dhis2.mobile.sync.data.SyncBackgroundJobAction +import org.dhis2.mobile.sync.domain.SyncStatusController import org.dhis2.usescases.login.SyncIsPerformedInteractor import org.dhis2.usescases.main.domain.LogoutUser import org.dhis2.usescases.settings.DeleteUserData @@ -35,6 +36,8 @@ import org.hisp.dhis.android.core.D2 class MainModule( val view: MainView, private val forceToNotSynced: Boolean, + private val syncStatusController: SyncStatusController, + private val syncBackgroundJobAction: SyncBackgroundJobAction, ) { @Provides @PerActivity @@ -49,7 +52,6 @@ class MainModule( userManager: UserManager, deleteUserData: DeleteUserData, syncIsPerformedInteractor: SyncIsPerformedInteractor, - syncStatusController: SyncStatusController, versionRepository: VersionRepository, dispatcherProvider: DispatcherProvider, logoutUser: LogoutUser, @@ -67,6 +69,7 @@ class MainModule( deleteUserData, syncIsPerformedInteractor, syncStatusController, + syncBackgroundJobAction, versionRepository, dispatcherProvider, forceToNotSynced, @@ -77,13 +80,11 @@ class MainModule( @PerActivity fun provideLogoutUser( homeRepository: HomeRepository, - workManagerController: WorkManagerController, - syncStatusController: SyncStatusController, filterManager: FilterManager, ): LogoutUser = LogoutUser( homeRepository, - workManagerController, + syncBackgroundJobAction, syncStatusController, filterManager, ) @@ -151,10 +152,12 @@ class MainModule( workManagerController: WorkManagerController, preferencesProvider: PreferenceProvider, filterManager: FilterManager, + dispatcherProvider: DispatcherProvider ): DeleteUserData = DeleteUserData( workManagerController, filterManager, preferencesProvider, + dispatcherProvider, ) } diff --git a/app/src/main/java/org/dhis2/usescases/main/MainPresenter.kt b/app/src/main/java/org/dhis2/usescases/main/MainPresenter.kt index a651681199b..00b7764dd44 100644 --- a/app/src/main/java/org/dhis2/usescases/main/MainPresenter.kt +++ b/app/src/main/java/org/dhis2/usescases/main/MainPresenter.kt @@ -16,6 +16,7 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import org.dhis2.BuildConfig import org.dhis2.commons.Constants import org.dhis2.commons.filters.FilterManager @@ -34,12 +35,14 @@ import org.dhis2.commons.prefs.PreferenceProvider import org.dhis2.commons.schedulers.SchedulerProvider import org.dhis2.commons.viewmodel.DispatcherProvider import org.dhis2.data.server.UserManager -import org.dhis2.data.service.SyncStatusController -import org.dhis2.data.service.SyncStatusData import org.dhis2.data.service.VersionRepository import org.dhis2.data.service.workManager.WorkManagerController import org.dhis2.data.service.workManager.WorkerItem import org.dhis2.data.service.workManager.WorkerType +import org.dhis2.mobile.commons.domain.invoke +import org.dhis2.mobile.sync.data.SyncBackgroundJobAction +import org.dhis2.mobile.sync.domain.SyncStatusController +import org.dhis2.mobile.sync.model.SyncStatusData import org.dhis2.usescases.login.SyncIsPerformedInteractor import org.dhis2.usescases.main.domain.LogoutUser import org.dhis2.usescases.settings.DeleteUserData @@ -72,6 +75,7 @@ class MainPresenter( private val deleteUserData: DeleteUserData, private val syncIsPerformedInteractor: SyncIsPerformedInteractor, private val syncStatusController: SyncStatusController, + private val syncBackgroundJobAction: SyncBackgroundJobAction, private val versionRepository: VersionRepository, val dispatcherProvider: DispatcherProvider, private val forceToNotSynced: Boolean, @@ -230,7 +234,7 @@ class MainPresenter( .user() .blockingGet() ?.uid() ?: "" - } catch (e: Exception) { + } catch (_: Exception) { "" } @@ -249,21 +253,26 @@ class MainPresenter( fun onDeleteAccount() { view.showProgressDeleteNotification() - try { - repository.checkDeleteBiometricsPermission() - workManagerController.cancelAllWork() - syncStatusController.restore() - deleteUserData.wipeCacheAndPreferences(view.obtainFileView()) - userManager.d2?.wipeModule()?.wipeEverything() - userManager.d2 - ?.userModule() - ?.accountManager() - ?.deleteCurrentAccount() - view.cancelNotifications() - - view.goToLogin(repository.accountsCount(), isDeletion = true) - } catch (exception: Exception) { - Timber.e(exception) + launch(dispatcherProvider.io()) { + try { + repository.checkDeleteBiometricsPermission() + syncBackgroundJobAction.cancelAll() + syncStatusController.restore() + deleteUserData.wipeCacheAndPreferences(view.obtainFileView()) + userManager.d2?.wipeModule()?.wipeEverything() + userManager.d2 + ?.userModule() + ?.accountManager() + ?.deleteCurrentAccount() + + view.cancelNotifications() + + withContext(dispatcherProvider.ui()) { + view.goToLogin(repository.accountsCount(), isDeletion = true) + } + } catch (exception: Exception) { + Timber.e(exception) + } } } @@ -308,8 +317,7 @@ class MainPresenter( fun launchInitialDataSync() { checkVersionUpdate() - workManagerController - .syncDataForWorker(Constants.DATA_NOW, Constants.INITIAL_SYNC) + syncBackgroundJobAction.launchDataSync(0) } fun observeDataSync(): StateFlow = syncStatusController.observeDownloadProcess() @@ -373,7 +381,7 @@ class MainPresenter( versionRepository.download( context = context, onDownloadCompleted = { - onDownloadCompleted(it) + onDownloadCompleted(it.toUri()) downloadingVersion.value = false }, onDownloading = { downloadingVersion.value = true }, diff --git a/app/src/main/java/org/dhis2/usescases/main/data/HomeRepository.kt b/app/src/main/java/org/dhis2/usescases/main/data/HomeRepository.kt index f9740fb75ec..5b3f7ec6aa3 100644 --- a/app/src/main/java/org/dhis2/usescases/main/data/HomeRepository.kt +++ b/app/src/main/java/org/dhis2/usescases/main/data/HomeRepository.kt @@ -34,4 +34,8 @@ interface HomeRepository { suspend fun getInitialSyncDone(): Boolean suspend fun isImportedDb(): Boolean + + suspend fun stopBackgroundSync() + + suspend fun restoreSyncStatus() } diff --git a/app/src/main/java/org/dhis2/usescases/main/data/HomeRepositoryImpl.kt b/app/src/main/java/org/dhis2/usescases/main/data/HomeRepositoryImpl.kt index 09203ab805e..64ea8dc8aa4 100644 --- a/app/src/main/java/org/dhis2/usescases/main/data/HomeRepositoryImpl.kt +++ b/app/src/main/java/org/dhis2/usescases/main/data/HomeRepositoryImpl.kt @@ -8,9 +8,11 @@ import org.dhis2.commons.bindings.isStockProgram import org.dhis2.commons.bindings.programs import org.dhis2.commons.prefs.Preference import org.dhis2.commons.prefs.Preference.Companion.PIN +import org.dhis2.data.service.workManager.WorkManagerController import org.dhis2.mobile.commons.coroutine.Dispatcher import org.dhis2.mobile.commons.error.DomainErrorMapper import org.dhis2.mobile.commons.providers.PreferenceProvider +import org.dhis2.mobile.sync.domain.SyncStatusController import org.dhis2.usescases.main.HomeItemData import org.dhis2.usescases.settings.deleteCache import org.dhis2.usescases.sync.WAS_INITIAL_SYNC_DONE @@ -26,6 +28,8 @@ class HomeRepositoryImpl( private val d2: D2, private val charts: Charts?, private val preferences: PreferenceProvider, + private val workManagerController: WorkManagerController, + private val syncStatusController: SyncStatusController, private val domainErrorMapper: DomainErrorMapper, private val dispatcher: Dispatcher, ) : HomeRepository { @@ -187,4 +191,13 @@ class HomeRepositoryImpl( .accountManager() .deleteCurrentAccount() } + + override suspend fun stopBackgroundSync() { + workManagerController.cancelAllWorkAndWait() + workManagerController.pruneWork() + } + + override suspend fun restoreSyncStatus() { + syncStatusController.restore() + } } diff --git a/app/src/main/java/org/dhis2/usescases/main/domain/CheckSingleNavigation.kt b/app/src/main/java/org/dhis2/usescases/main/domain/CheckSingleNavigation.kt new file mode 100644 index 00000000000..a3e066abb70 --- /dev/null +++ b/app/src/main/java/org/dhis2/usescases/main/domain/CheckSingleNavigation.kt @@ -0,0 +1,22 @@ +package org.dhis2.usescases.main.domain + +import org.dhis2.mobile.commons.domain.UseCase +import org.dhis2.mobile.commons.error.DomainError +import org.dhis2.usescases.main.HomeItemData +import org.dhis2.usescases.main.data.HomeRepository + +class CheckSingleNavigation( + private val homeRepository: HomeRepository, +) : UseCase { + override suspend fun invoke(input: Unit): Result = + try { + val homeItemCount = homeRepository.homeItemCount() + if (homeItemCount == 1) { + Result.success(homeRepository.singleHomeItemData()) + } else { + Result.failure(DomainError.ConfigurationError("Expected exactly one home item")) + } + } catch (domainError: DomainError) { + Result.failure(domainError) + } +} diff --git a/app/src/main/java/org/dhis2/usescases/main/domain/ConfigureHomeNavigationBar.kt b/app/src/main/java/org/dhis2/usescases/main/domain/ConfigureHomeNavigationBar.kt new file mode 100644 index 00000000000..2d2d368432d --- /dev/null +++ b/app/src/main/java/org/dhis2/usescases/main/domain/ConfigureHomeNavigationBar.kt @@ -0,0 +1,24 @@ +package org.dhis2.usescases.main.domain + +import org.dhis2.mobile.commons.domain.UseCase +import org.dhis2.mobile.commons.error.DomainError +import org.dhis2.usescases.main.data.HomeRepository +import org.dhis2.usescases.main.domain.model.BottomNavigationItem + +class ConfigureHomeNavigationBar( + private val homeRepository: HomeRepository, +) : UseCase> { + override suspend operator fun invoke(input: Unit) = + try { + val list = + buildList { + add(BottomNavigationItem.Program) + if (homeRepository.hasHomeAnalytics()) { + add(BottomNavigationItem.Analytics) + } + } + Result.success(list) + } catch (_: DomainError) { + Result.success(listOf(BottomNavigationItem.Program)) + } +} diff --git a/app/src/main/java/org/dhis2/usescases/main/domain/DeleteAccount.kt b/app/src/main/java/org/dhis2/usescases/main/domain/DeleteAccount.kt new file mode 100644 index 00000000000..c8f6eb9df43 --- /dev/null +++ b/app/src/main/java/org/dhis2/usescases/main/domain/DeleteAccount.kt @@ -0,0 +1,25 @@ +package org.dhis2.usescases.main.domain + +import org.dhis2.commons.filters.FilterManager +import org.dhis2.mobile.commons.domain.UseCase +import org.dhis2.mobile.commons.error.DomainError +import org.dhis2.usescases.main.data.HomeRepository +import java.io.File + +class DeleteAccount( + private val filterManager: FilterManager, + private val repository: HomeRepository, +) : UseCase { + override suspend fun invoke(input: File?): Result = + try { + filterManager.clearAllFilters() + input?.let { repository.clearCache(it) } + repository.clearPreferences() + repository.wipeAll() + repository.deleteCurrentAccount() + val accountCount = repository.accountsCount() + Result.success(accountCount) + } catch (domainError: DomainError) { + Result.failure(domainError) + } +} diff --git a/app/src/main/java/org/dhis2/usescases/main/domain/GetHomeFilters.kt b/app/src/main/java/org/dhis2/usescases/main/domain/GetHomeFilters.kt new file mode 100644 index 00000000000..ac75779773d --- /dev/null +++ b/app/src/main/java/org/dhis2/usescases/main/domain/GetHomeFilters.kt @@ -0,0 +1,17 @@ +package org.dhis2.usescases.main.domain + +import org.dhis2.commons.filters.FilterItem +import org.dhis2.commons.filters.data.FilterRepository +import org.dhis2.mobile.commons.domain.UseCase +import org.dhis2.mobile.commons.error.DomainError + +class GetHomeFilters( + private val filterRepository: FilterRepository, +) : UseCase> { + override suspend fun invoke(input: Unit): Result> = + try { + Result.success(filterRepository.homeFilters()) + } catch (domainError: DomainError) { + Result.failure(domainError) + } +} diff --git a/app/src/main/java/org/dhis2/usescases/main/domain/GetLockAction.kt b/app/src/main/java/org/dhis2/usescases/main/domain/GetLockAction.kt new file mode 100644 index 00000000000..002a6ffa476 --- /dev/null +++ b/app/src/main/java/org/dhis2/usescases/main/domain/GetLockAction.kt @@ -0,0 +1,22 @@ +package org.dhis2.usescases.main.domain + +import org.dhis2.mobile.commons.domain.UseCase +import org.dhis2.mobile.commons.error.DomainError +import org.dhis2.usescases.main.data.HomeRepository +import org.dhis2.usescases.main.domain.model.LockAction + +class GetLockAction( + private val repository: HomeRepository, +) : UseCase { + override suspend fun invoke(input: Unit): Result = + try { + val isPinSet = repository.isPinStored() + if (isPinSet) { + Result.success(LockAction.BlockSession) + } else { + Result.success(LockAction.CreatePin) + } + } catch (domainError: DomainError) { + Result.failure(domainError) + } +} diff --git a/app/src/main/java/org/dhis2/usescases/main/domain/GetUserName.kt b/app/src/main/java/org/dhis2/usescases/main/domain/GetUserName.kt new file mode 100644 index 00000000000..064ee7c7722 --- /dev/null +++ b/app/src/main/java/org/dhis2/usescases/main/domain/GetUserName.kt @@ -0,0 +1,20 @@ +package org.dhis2.usescases.main.domain + +import org.dhis2.mobile.commons.domain.UseCase +import org.dhis2.mobile.commons.error.DomainError +import org.dhis2.usescases.main.data.HomeRepository + +class GetUserName( + private val homeRepository: HomeRepository, +) : UseCase { + override suspend operator fun invoke(input: Unit) = + try { + val user = homeRepository.user() + val firstName = user?.firstName() + val surname = user?.surname() + val userName = listOfNotNull(firstName, surname).joinToString(" ") + Result.success(userName) + } catch (domainError: DomainError) { + Result.failure(domainError) + } +} diff --git a/app/src/main/java/org/dhis2/usescases/main/domain/LaunchInitialSync.kt b/app/src/main/java/org/dhis2/usescases/main/domain/LaunchInitialSync.kt new file mode 100644 index 00000000000..886d645c9ff --- /dev/null +++ b/app/src/main/java/org/dhis2/usescases/main/domain/LaunchInitialSync.kt @@ -0,0 +1,33 @@ +package org.dhis2.usescases.main.domain + +import org.dhis2.data.service.VersionRepository +import org.dhis2.mobile.commons.domain.UseCase +import org.dhis2.mobile.commons.error.DomainError +import org.dhis2.mobile.sync.data.SyncBackgroundJobAction +import org.dhis2.usescases.main.data.HomeRepository + +class LaunchInitialSync( + private val skipSync: Boolean, + private val homeRepository: HomeRepository, + private val versionRepository: VersionRepository, + private val syncBackgroundJobAction: SyncBackgroundJobAction, +) : UseCase { + override suspend fun invoke(input: Unit): Result = + try { + if (skipSync || homeRepository.isImportedDb() || homeRepository.getInitialSyncDone()) { + Result.success(InitialSyncAction.Skip) + } else { + versionRepository.checkVersionUpdates() + syncBackgroundJobAction.launchDataSync(0) + Result.success(InitialSyncAction.Syncing) + } + } catch (domainError: DomainError) { + Result.failure(domainError) + } +} + +sealed interface InitialSyncAction { + data object Skip : InitialSyncAction + + data object Syncing : InitialSyncAction +} diff --git a/app/src/main/java/org/dhis2/usescases/main/domain/LogoutUser.kt b/app/src/main/java/org/dhis2/usescases/main/domain/LogoutUser.kt index a54b2277973..7ce9f53b918 100644 --- a/app/src/main/java/org/dhis2/usescases/main/domain/LogoutUser.kt +++ b/app/src/main/java/org/dhis2/usescases/main/domain/LogoutUser.kt @@ -1,20 +1,21 @@ package org.dhis2.usescases.main.domain import org.dhis2.commons.filters.FilterManager -import org.dhis2.data.service.SyncStatusController -import org.dhis2.data.service.workManager.WorkManagerController +import org.dhis2.mobile.commons.domain.UseCase +import org.dhis2.mobile.sync.data.SyncBackgroundJobAction +import org.dhis2.mobile.sync.domain.SyncStatusController import org.dhis2.usescases.main.HomeRepository typealias AccountCount = Int class LogoutUser( private val repository: HomeRepository, - private val workManagerController: WorkManagerController, + private val syncBackgroundJobAction: SyncBackgroundJobAction, private val syncStatusController: SyncStatusController, private val filterManager: FilterManager, -) { - suspend operator fun invoke(): Result { - workManagerController.cancelAllWorkAndWait() +) : UseCase { + override suspend operator fun invoke(input: Unit): Result { + syncBackgroundJobAction.cancelAll() syncStatusController.restore() filterManager.clearAllFilters() diff --git a/app/src/main/java/org/dhis2/usescases/main/domain/ScheduleNewVersionAlert.kt b/app/src/main/java/org/dhis2/usescases/main/domain/ScheduleNewVersionAlert.kt new file mode 100644 index 00000000000..ef60d039491 --- /dev/null +++ b/app/src/main/java/org/dhis2/usescases/main/domain/ScheduleNewVersionAlert.kt @@ -0,0 +1,32 @@ +package org.dhis2.usescases.main.domain + +import androidx.work.ExistingWorkPolicy +import org.dhis2.commons.Constants +import org.dhis2.data.service.VersionRepository +import org.dhis2.data.service.workManager.WorkManagerController +import org.dhis2.data.service.workManager.WorkerItem +import org.dhis2.data.service.workManager.WorkerType +import org.dhis2.mobile.commons.domain.UseCase +import org.dhis2.mobile.commons.error.DomainError +import kotlin.time.Duration.Companion.days + +class ScheduleNewVersionAlert( + private val workManagerController: WorkManagerController, + private val versionRepository: VersionRepository, +) : UseCase { + override suspend fun invoke(input: Unit): Result = + try { + val workerItem = + WorkerItem( + Constants.NEW_APP_VERSION, + WorkerType.NEW_VERSION, + delayInSeconds = 1.days.inWholeSeconds, + policy = ExistingWorkPolicy.REPLACE, + ) + workManagerController.beginUniqueWork(workerItem) + versionRepository.removeVersionInfo() + Result.success(Unit) + } catch (domainError: DomainError) { + Result.failure(domainError) + } +} diff --git a/app/src/main/java/org/dhis2/usescases/main/domain/UpdateInitialSyncStatus.kt b/app/src/main/java/org/dhis2/usescases/main/domain/UpdateInitialSyncStatus.kt new file mode 100644 index 00000000000..c62d756a229 --- /dev/null +++ b/app/src/main/java/org/dhis2/usescases/main/domain/UpdateInitialSyncStatus.kt @@ -0,0 +1,17 @@ +package org.dhis2.usescases.main.domain + +import org.dhis2.mobile.commons.domain.UseCase +import org.dhis2.mobile.commons.error.DomainError +import org.dhis2.usescases.main.data.HomeRepository + +class UpdateInitialSyncStatus( + val repository: HomeRepository, +) : UseCase { + override suspend fun invoke(input: Unit): Result = + try { + repository.setInitialSyncDone() + Result.success(Unit) + } catch (domainError: DomainError) { + Result.failure(domainError) + } +} diff --git a/app/src/main/java/org/dhis2/usescases/main/domain/model/BottomNavigationItem.kt b/app/src/main/java/org/dhis2/usescases/main/domain/model/BottomNavigationItem.kt new file mode 100644 index 00000000000..43b4696c439 --- /dev/null +++ b/app/src/main/java/org/dhis2/usescases/main/domain/model/BottomNavigationItem.kt @@ -0,0 +1,7 @@ +package org.dhis2.usescases.main.domain.model + +sealed interface BottomNavigationItem { + data object Program : BottomNavigationItem + + data object Analytics : BottomNavigationItem +} diff --git a/app/src/main/java/org/dhis2/usescases/main/domain/model/DownloadMethod.kt b/app/src/main/java/org/dhis2/usescases/main/domain/model/DownloadMethod.kt new file mode 100644 index 00000000000..c28de7abf9c --- /dev/null +++ b/app/src/main/java/org/dhis2/usescases/main/domain/model/DownloadMethod.kt @@ -0,0 +1,11 @@ +package org.dhis2.usescases.main.domain.model + +sealed interface DownloadMethod { + data class Url( + val url: String, + ) : DownloadMethod + + data class File( + val path: String, + ) : DownloadMethod +} diff --git a/app/src/main/java/org/dhis2/usescases/main/domain/model/LockAction.kt b/app/src/main/java/org/dhis2/usescases/main/domain/model/LockAction.kt new file mode 100644 index 00000000000..01df14106f6 --- /dev/null +++ b/app/src/main/java/org/dhis2/usescases/main/domain/model/LockAction.kt @@ -0,0 +1,7 @@ +package org.dhis2.usescases.main.domain.model + +sealed interface LockAction { + data object CreatePin : LockAction + + data object BlockSession : LockAction +} diff --git a/app/src/main/java/org/dhis2/usescases/main/program/ProgramFragment.kt b/app/src/main/java/org/dhis2/usescases/main/program/ProgramFragment.kt index 9b3511c0f0b..6340a06b260 100644 --- a/app/src/main/java/org/dhis2/usescases/main/program/ProgramFragment.kt +++ b/app/src/main/java/org/dhis2/usescases/main/program/ProgramFragment.kt @@ -21,6 +21,7 @@ import org.dhis2.App import org.dhis2.R import org.dhis2.commons.sync.OnDismissListener import org.dhis2.commons.sync.SyncContext +import org.dhis2.mobile.sync.domain.SyncStatusController import org.dhis2.usescases.general.FragmentGlobalAbstract import org.dhis2.usescases.main.navigateTo import org.dhis2.usescases.main.toHomeItemData @@ -28,12 +29,15 @@ import org.dhis2.utils.HelpManager import org.dhis2.utils.analytics.SELECT_PROGRAM import org.dhis2.utils.analytics.TYPE_PROGRAM_SELECTED import org.dhis2.utils.granularsync.SyncStatusDialog +import org.koin.android.ext.android.inject import timber.log.Timber import javax.inject.Inject class ProgramFragment : FragmentGlobalAbstract(), ProgramView { + private val syncStatusController: SyncStatusController by inject() + @Inject lateinit var programViewModelFactory: ProgramViewModelFactory @@ -51,7 +55,10 @@ class ProgramFragment : override fun onAttach(context: Context) { super.onAttach(context) activity?.let { - (it.applicationContext as App).userComponent()?.plus(ProgramModule(this))?.inject(this) + (it.applicationContext as App) + .userComponent() + ?.plus(ProgramModule(this, syncStatusController)) + ?.inject(this) } } diff --git a/app/src/main/java/org/dhis2/usescases/main/program/ProgramModule.kt b/app/src/main/java/org/dhis2/usescases/main/program/ProgramModule.kt index f69e5d56660..72536aa84bd 100644 --- a/app/src/main/java/org/dhis2/usescases/main/program/ProgramModule.kt +++ b/app/src/main/java/org/dhis2/usescases/main/program/ProgramModule.kt @@ -13,12 +13,13 @@ import org.dhis2.commons.resources.ResourceManager import org.dhis2.commons.schedulers.SchedulerProvider import org.dhis2.commons.viewmodel.DispatcherProvider import org.dhis2.data.dhislogic.DhisProgramUtils -import org.dhis2.data.service.SyncStatusController +import org.dhis2.mobile.sync.domain.SyncStatusController import org.hisp.dhis.android.core.D2 @Module class ProgramModule( private val view: ProgramView, + private val syncStatusController: SyncStatusController, ) { @Provides @PerFragment @@ -28,7 +29,6 @@ class ProgramModule( featureConfigRepository: FeatureConfigRepository, matomoAnalyticsController: MatomoAnalyticsController, filterManager: FilterManager, - syncStatusController: SyncStatusController, schedulerProvider: SchedulerProvider, ): ProgramViewModelFactory = ProgramViewModelFactory( diff --git a/app/src/main/java/org/dhis2/usescases/main/program/ProgramRepository.kt b/app/src/main/java/org/dhis2/usescases/main/program/ProgramRepository.kt index 8070022df75..e670e95e689 100644 --- a/app/src/main/java/org/dhis2/usescases/main/program/ProgramRepository.kt +++ b/app/src/main/java/org/dhis2/usescases/main/program/ProgramRepository.kt @@ -1,7 +1,7 @@ package org.dhis2.usescases.main.program import io.reactivex.Flowable -import org.dhis2.data.service.SyncStatusData +import org.dhis2.mobile.sync.model.SyncStatusData interface ProgramRepository { fun homeItems(syncStatusData: SyncStatusData): Flowable> diff --git a/app/src/main/java/org/dhis2/usescases/main/program/ProgramRepositoryImpl.kt b/app/src/main/java/org/dhis2/usescases/main/program/ProgramRepositoryImpl.kt index 7f1d6ed5119..fb87f283a06 100644 --- a/app/src/main/java/org/dhis2/usescases/main/program/ProgramRepositoryImpl.kt +++ b/app/src/main/java/org/dhis2/usescases/main/program/ProgramRepositoryImpl.kt @@ -8,7 +8,7 @@ import org.dhis2.commons.resources.MetadataIconProvider import org.dhis2.commons.resources.ResourceManager import org.dhis2.commons.schedulers.SchedulerProvider import org.dhis2.data.dhislogic.DhisProgramUtils -import org.dhis2.data.service.SyncStatusData +import org.dhis2.mobile.sync.model.SyncStatusData import org.hisp.dhis.android.core.D2 import org.hisp.dhis.android.core.common.State import org.hisp.dhis.android.core.program.Program @@ -178,7 +178,7 @@ internal class ProgramRepositoryImpl( private fun getTrackerTeiCount(program: Program): Int { val teiIds = filterPresenter - .filteredTrackerProgram(program) + .filteredTrackerProgram(program.uid()) .offlineFirst() .blockingGetUids() val teiCount = teiIds.size diff --git a/app/src/main/java/org/dhis2/usescases/main/program/ProgramUi.kt b/app/src/main/java/org/dhis2/usescases/main/program/ProgramUi.kt index 65e761d05d2..bbfcca25910 100644 --- a/app/src/main/java/org/dhis2/usescases/main/program/ProgramUi.kt +++ b/app/src/main/java/org/dhis2/usescases/main/program/ProgramUi.kt @@ -57,9 +57,9 @@ import org.dhis2.R import org.dhis2.commons.bindings.addIf import org.dhis2.commons.date.toDateSpan import org.dhis2.commons.ui.icons.toIconData -import org.dhis2.data.service.SyncStatusData import org.dhis2.mobile.commons.extensions.toColorInt import org.dhis2.mobile.commons.model.MetadataIconData +import org.dhis2.mobile.sync.model.SyncStatusData import org.hisp.dhis.android.core.common.State import org.hisp.dhis.mobile.ui.designsystem.component.AdditionalInfoItem import org.hisp.dhis.mobile.ui.designsystem.component.Avatar diff --git a/app/src/main/java/org/dhis2/usescases/main/program/ProgramViewModel.kt b/app/src/main/java/org/dhis2/usescases/main/program/ProgramViewModel.kt index 007acb62cf5..cc0ccd829d6 100644 --- a/app/src/main/java/org/dhis2/usescases/main/program/ProgramViewModel.kt +++ b/app/src/main/java/org/dhis2/usescases/main/program/ProgramViewModel.kt @@ -18,7 +18,8 @@ import org.dhis2.commons.matomo.Labels.Companion.CLICK_ON import org.dhis2.commons.matomo.MatomoAnalyticsController import org.dhis2.commons.schedulers.SchedulerProvider import org.dhis2.commons.viewmodel.DispatcherProvider -import org.dhis2.data.service.SyncStatusController +import org.dhis2.mobile.sync.domain.SyncStatusController +import org.koin.core.component.KoinComponent import timber.log.Timber import java.util.concurrent.TimeUnit @@ -31,7 +32,8 @@ class ProgramViewModel internal constructor( private val filterManager: FilterManager, private val syncStatusController: SyncStatusController, private val schedulerProvider: SchedulerProvider, -) : ViewModel() { +) : ViewModel(), + KoinComponent { private val _programs = MutableLiveData>() val programs: LiveData> = _programs private val refreshData = PublishProcessor.create() diff --git a/app/src/main/java/org/dhis2/usescases/main/program/ProgramViewModelFactory.kt b/app/src/main/java/org/dhis2/usescases/main/program/ProgramViewModelFactory.kt index 1ac164a3b1c..e3b2f78c0ad 100644 --- a/app/src/main/java/org/dhis2/usescases/main/program/ProgramViewModelFactory.kt +++ b/app/src/main/java/org/dhis2/usescases/main/program/ProgramViewModelFactory.kt @@ -7,7 +7,7 @@ import org.dhis2.commons.filters.FilterManager import org.dhis2.commons.matomo.MatomoAnalyticsController import org.dhis2.commons.schedulers.SchedulerProvider import org.dhis2.commons.viewmodel.DispatcherProvider -import org.dhis2.data.service.SyncStatusController +import org.dhis2.mobile.sync.domain.SyncStatusController class ProgramViewModelFactory( private val view: ProgramView, diff --git a/app/src/main/java/org/dhis2/usescases/notes/noteDetail/NoteDetailPresenter.kt b/app/src/main/java/org/dhis2/usescases/notes/noteDetail/NoteDetailPresenter.kt index e6177b63925..2b2ae97b545 100644 --- a/app/src/main/java/org/dhis2/usescases/notes/noteDetail/NoteDetailPresenter.kt +++ b/app/src/main/java/org/dhis2/usescases/notes/noteDetail/NoteDetailPresenter.kt @@ -2,6 +2,7 @@ package org.dhis2.usescases.notes.noteDetail import io.reactivex.disposables.CompositeDisposable import org.dhis2.commons.schedulers.SchedulerProvider +import org.dhis2.usescases.notes.NotesIdlingResource import timber.log.Timber class NoteDetailPresenter( @@ -33,6 +34,8 @@ class NoteDetailPresenter( disposable.add( repository .saveNote(noteType, uid, message) + .doOnSubscribe { NotesIdlingResource.increment() } + .doFinally { NotesIdlingResource.decrement() } .subscribeOn(scheduler.io()) .observeOn(scheduler.ui()) .subscribe( diff --git a/app/src/main/java/org/dhis2/usescases/programEventDetail/ProgramEventMapper.kt b/app/src/main/java/org/dhis2/usescases/programEventDetail/ProgramEventMapper.kt index 896f7665d49..0538363ac81 100644 --- a/app/src/main/java/org/dhis2/usescases/programEventDetail/ProgramEventMapper.kt +++ b/app/src/main/java/org/dhis2/usescases/programEventDetail/ProgramEventMapper.kt @@ -275,6 +275,7 @@ class ProgramEventMapper( .byProgramStageUid() .eq(programStage) .withDataElements() + .orderBySortOrder(RepositoryScope.OrderByDirection.ASC) .blockingGet() private fun getCategoryOptionCombo(attributeOptionCombo: String?) = diff --git a/app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchRepository.java b/app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchRepository.java index 0c199898d14..80965866353 100644 --- a/app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchRepository.java +++ b/app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchRepository.java @@ -33,9 +33,6 @@ public interface SearchRepository { void clearFetchedList(); - @NonNull - Flowable> searchTeiForMap(SearchParametersModel searchParametersModel, boolean isOnline); - @NonNull Observable> saveToEnroll(@NonNull String teiType, @NonNull String orgUnitUID, @NonNull String programUid, @Nullable String teiUid, HashMap> queryData, @Nullable String fromRelationshipUid); @@ -47,8 +44,6 @@ public interface SearchRepository { TrackedEntityType getTrackedEntityType(); - List getEventsForMap(List teis); - Observable downloadTei(String teiUid); TeiDownloadResult download(String teiUid, @Nullable String enrollmentUid, String reason); @@ -85,9 +80,4 @@ public interface SearchRepository { boolean filtersApplyOnGlobalSearch(); - @NotNull HashSet getFetchedTeiUIDs(); - - SearchParametersModel getSavedSearchParameters(); - - FilterManager getSavedFilters(); } diff --git a/app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchRepositoryImpl.java b/app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchRepositoryImpl.java index cd66d341233..ce0ebbcecbb 100644 --- a/app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchRepositoryImpl.java +++ b/app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchRepositoryImpl.java @@ -8,16 +8,14 @@ import org.dhis2.bindings.ValueExtensionsKt; import org.dhis2.commons.Constants; import org.dhis2.commons.data.EntryMode; -import org.dhis2.commons.data.EventModel; -import org.dhis2.commons.data.EventViewModelType; import org.dhis2.commons.date.DateUtils; import org.dhis2.commons.filters.FilterManager; import org.dhis2.commons.filters.data.FilterPresenter; import org.dhis2.commons.filters.sorting.SortingItem; import org.dhis2.commons.network.NetworkUtils; -import org.dhis2.commons.resources.DhisPeriodUtils; import org.dhis2.commons.resources.MetadataIconProvider; import org.dhis2.commons.resources.ResourceManager; +import org.dhis2.commons.viewmodel.DispatcherProvider; import org.dhis2.data.dhislogic.DhisEnrollmentUtils; import org.dhis2.data.forms.dataentry.SearchTEIRepository; import org.dhis2.data.forms.dataentry.ValueStore; @@ -29,6 +27,7 @@ import org.dhis2.metadata.usecases.TrackedEntityInstanceConfiguration; import org.dhis2.mobile.commons.customintents.CustomIntentRepository; import org.dhis2.mobile.commons.model.CustomIntentActionTypeModel; +import org.dhis2.mobile.commons.network.NetworkStatusProvider; import org.dhis2.mobile.commons.providers.FieldErrorMessageProvider; import org.dhis2.mobile.commons.reporting.CrashReportController; import org.dhis2.tracker.data.ProfilePictureProvider; @@ -75,6 +74,7 @@ import org.hisp.dhis.android.core.trackedentity.search.TrackedEntitySearchItemAttribute; import org.hisp.dhis.android.core.trackedentity.search.TrackedEntitySearchItemHelper; import org.jetbrains.annotations.NotNull; +import org.matomo.sdk.dispatcher.Dispatcher; import java.util.ArrayList; import java.util.Collections; @@ -86,7 +86,6 @@ import java.util.Map; import dhis2.org.analytics.charts.Charts; -import io.reactivex.Flowable; import io.reactivex.Observable; import io.reactivex.Single; import kotlin.Pair; @@ -98,16 +97,14 @@ public class SearchRepositoryImpl implements SearchRepository { private final ResourceManager resources; private final D2 d2; private final SearchSortingValueSetter sortingValueSetter; + private final DispatcherProvider dispatcherProvider; private TrackedEntitySearchCollectionRepository trackedEntityInstanceQuery; - public SearchParametersModel savedSearchParameters; - private FilterManager savedFilters; private FilterPresenter filterPresenter; - private DhisPeriodUtils periodUtils; private String currentProgram; private final Charts charts; private final CrashReportController crashReportController; private DateUtils dateUtils; - private final NetworkUtils networkUtils; + private final NetworkStatusProvider networkStatusProvider; private final SearchTEIRepository searchTEIRepository; private TrackedEntityInstanceDownloader downloadRepository = null; private ThemeManager themeManager; @@ -132,28 +129,27 @@ public class SearchRepositoryImpl implements SearchRepository { FilterPresenter filterPresenter, ResourceManager resources, SearchSortingValueSetter sortingValueSetter, - DhisPeriodUtils periodUtils, Charts charts, CrashReportController crashReportController, - NetworkUtils networkUtils, + NetworkStatusProvider networkStatusProvider, SearchTEIRepository searchTEIRepository, ThemeManager themeManager, MetadataIconProvider metadataIconProvider, ProfilePictureProvider profilePictureProvider, DateUtils dateUtils, - CustomIntentRepository customIntentRepository + CustomIntentRepository customIntentRepository, + DispatcherProvider dispatcherProvider ) { this.teiType = teiType; this.d2 = d2; this.resources = resources; this.sortingValueSetter = sortingValueSetter; this.filterPresenter = filterPresenter; - this.periodUtils = periodUtils; this.charts = charts; this.crashReportController = crashReportController; this.dateUtils = dateUtils; this.currentProgram = initialProgram; - this.networkUtils = networkUtils; + this.networkStatusProvider = networkStatusProvider; this.searchTEIRepository = searchTEIRepository; this.themeManager = themeManager; this.teiDownloader = new TeiDownloader( @@ -165,6 +161,7 @@ public class SearchRepositoryImpl implements SearchRepository { this.metadataIconProvider = metadataIconProvider; this.profilePictureProvider = profilePictureProvider; this.customIntentRepository = customIntentRepository; + this.dispatcherProvider = dispatcherProvider; } @@ -184,36 +181,15 @@ public void clearFetchedList() { fetchedTeiUids.clear(); } - @NonNull - @Override - public Flowable> searchTeiForMap(SearchParametersModel searchParametersModel, boolean isOnline) { - - boolean allowCache = false; - if (!searchParametersModel.equals(savedSearchParameters) || !FilterManager.getInstance().equals(savedFilters)) { - trackedEntityInstanceQuery = getFilteredRepository(searchParametersModel); - } else { - allowCache = true; - } - - if (isOnline && FilterManager.getInstance().getStateFilters().isEmpty()) - return trackedEntityInstanceQuery.allowOnlineCache().eq(allowCache).offlineFirst().get().toFlowable() - .flatMapIterable(list -> list) - .map(tei -> transform(tei, searchParametersModel.getSelectedProgram(), false, FilterManager.getInstance().getSortingItem())) - .toList().toFlowable(); - else - return trackedEntityInstanceQuery.allowOnlineCache().eq(allowCache).offlineOnly().get().toFlowable() - .flatMapIterable(list -> list) - .map(tei -> transform(tei, searchParametersModel.getSelectedProgram(), true, FilterManager.getInstance().getSortingItem())) - .toList().toFlowable(); - } @Override public TrackedEntitySearchCollectionRepository getFilteredRepository(SearchParametersModel searchParametersModel) { - this.savedSearchParameters = searchParametersModel.copy(); - this.savedFilters = FilterManager.getInstance().copy(); + String programUid = searchParametersModel.getSelectedProgram() != null + ? searchParametersModel.getSelectedProgram().uid() + : null; trackedEntityInstanceQuery = filterPresenter.filteredTrackedEntityInstances( - searchParametersModel.getSelectedProgram(), teiType + programUid, teiType ); for (int i = 0; i < searchParametersModel.getQueryData().keySet().size(); i++) { @@ -305,10 +281,10 @@ public Observable> saveToEnroll(@NonNull String teiType, EntryMode.ATTR, new DhisEnrollmentUtils(d2), crashReportController, - networkUtils, searchTEIRepository, - new FieldErrorMessageProvider(), - resources + resources, + networkStatusProvider, + dispatcherProvider ); if (queryData.containsKey(Constants.ENROLLMENT_DATE_UID)) @@ -666,20 +642,6 @@ public boolean filtersApplyOnGlobalSearch() { !FilterManager.getInstance().getStateFilters().isEmpty(); } - @Override - public @NotNull HashSet getFetchedTeiUIDs() { - return fetchedTeiUids; - } - - @Override - public SearchParametersModel getSavedSearchParameters() { - return savedSearchParameters; - } - - @Override - public FilterManager getSavedFilters() { - return savedFilters; - } @Override public Observable getTrackedEntityType(String trackedEntityUid) { @@ -691,59 +653,6 @@ public TrackedEntityType getTrackedEntityType() { return d2.trackedEntityModule().trackedEntityTypes().uid(teiType).blockingGet(); } - @Override - public List getEventsForMap(List teis) { - List eventModels = new ArrayList<>(); - List teiUidList = new ArrayList<>(); - for (SearchTeiModel tei : teis) { - teiUidList.add(tei.getTei().uid()); - } - - List events = d2.eventModule().events() - .byTrackedEntityInstanceUids(teiUidList) - .byDeleted().isFalse() - .blockingGet(); - - HashMap cacheStages = new HashMap<>(); - - for (Event event : events) { - if (!cacheStages.containsKey(event.programStage())) { - ProgramStage stage = d2.programModule().programStages() - .uid(event.programStage()) - .blockingGet(); - cacheStages.put(event.programStage(), stage); - } - - eventModels.add( - new EventModel( - EventViewModelType.EVENT, - cacheStages.get(event.programStage()), - event, - 0, - null, - true, - true, - orgUnitName(event.organisationUnit()), - true, - null, - null, - false, - false, - false, - false, - false, - 0, - periodUtils.getPeriodUIString(cacheStages.get(event.programStage()).periodType(), event.eventDate() != null ? event.eventDate() : event.dueDate(), Locale.getDefault()), - null, - metadataIconProvider.invoke(cacheStages.get(event.programStage()).style()), - true, - true - )); - } - - return eventModels; - } - private String orgUnitName(String orgUnitUid) { if (!orgUnitNameCache.containsKey(orgUnitUid)) { OrganisationUnit organisationUnit = d2.organisationUnitModule() diff --git a/app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchRepositoryImplKt.kt b/app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchRepositoryImplKt.kt index 63daa1aa06b..3f699212450 100644 --- a/app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchRepositoryImplKt.kt +++ b/app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchRepositoryImplKt.kt @@ -2,54 +2,35 @@ package org.dhis2.usescases.searchTrackEntity -import androidx.paging.PagingData -import androidx.paging.map import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.FlowPreview -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.debounce -import kotlinx.coroutines.flow.flatMapLatest -import kotlinx.coroutines.flow.map import kotlinx.coroutines.withContext import org.dhis2.commons.filters.FilterManager -import org.dhis2.commons.resources.MetadataIconProvider import org.dhis2.commons.viewmodel.DispatcherProvider import org.dhis2.data.search.SearchParametersModel -import org.dhis2.form.model.FieldUiModel -import org.dhis2.form.model.OptionSetConfiguration -import org.dhis2.form.ui.FieldViewModelFactory import org.dhis2.maps.model.MapItemModel import org.dhis2.mobile.commons.customintents.CustomIntentRepository -import org.dhis2.mobile.commons.extensions.toColor import org.dhis2.mobile.commons.model.CustomIntentActionTypeModel -import org.dhis2.mobile.commons.model.CustomIntentModel +import org.dhis2.tracker.input.model.TrackerInputType +import org.dhis2.tracker.input.ui.action.FieldUid import org.dhis2.usescases.events.EventInfoProvider import org.dhis2.usescases.tracker.TrackedEntityInstanceInfoProvider import org.hisp.dhis.android.core.D2 -import org.hisp.dhis.android.core.arch.repositories.scope.RepositoryScope -import org.hisp.dhis.android.core.common.ObjectStyle import org.hisp.dhis.android.core.common.State import org.hisp.dhis.android.core.common.ValueType import org.hisp.dhis.android.core.event.EventStatus import org.hisp.dhis.android.core.program.Program -import org.hisp.dhis.android.core.program.ProgramTrackedEntityAttribute -import org.hisp.dhis.android.core.program.SectionRenderingType import org.hisp.dhis.android.core.relationship.RelationshipItem import org.hisp.dhis.android.core.relationship.RelationshipItemTrackedEntityInstance -import org.hisp.dhis.android.core.trackedentity.TrackedEntityAttribute import org.hisp.dhis.android.core.trackedentity.search.TrackedEntitySearchCollectionRepository import org.hisp.dhis.android.core.trackedentity.search.TrackedEntitySearchItem import org.hisp.dhis.android.core.trackedentity.search.TrackedEntitySearchItemHelper.toTrackedEntityInstance -import org.hisp.dhis.mobile.ui.designsystem.theme.SurfaceColor import timber.log.Timber class SearchRepositoryImplKt( private val searchRepositoryJava: SearchRepository, private val d2: D2, private val dispatcher: DispatcherProvider, - private val fieldViewModelFactory: FieldViewModelFactory, - private val metadataIconProvider: MetadataIconProvider, private val trackedEntityInstanceInfoProvider: TrackedEntityInstanceInfoProvider, private val eventInfoProvider: EventInfoProvider, private val customIntentRepository: CustomIntentRepository, @@ -62,86 +43,35 @@ class SearchRepositoryImplKt( private val fetchedTeiUids = HashSet() - override fun searchTrackedEntities( - searchParametersModel: SearchParametersModel, - isOnline: Boolean, - ): Flow> = - trackedEntitySearchQuery(searchParametersModel, isOnline) - .getPagingData(10) - - private fun trackedEntitySearchQuery( - searchParametersModel: SearchParametersModel, - isOnline: Boolean, - ): TrackedEntitySearchCollectionRepository { - var allowCache = false - savedSearchParameters = searchParametersModel.copy() - savedFilters = FilterManager.getInstance().copy() - - if (searchParametersModel != savedSearchParameters || - !FilterManager - .getInstance() - .sameFilters(savedFilters) - ) { - trackedEntityInstanceQuery = - searchRepositoryJava.getFilteredRepository(searchParametersModel) - } else { - trackedEntityInstanceQuery = - searchRepositoryJava.getFilteredRepository(searchParametersModel) - allowCache = true + override fun saveSearchValuesAndGetAllowCache( + queryData: MutableMap?>?, + programUid: String?, + ): Boolean { + if (!this::savedSearchParameters.isInitialized) { + savedSearchParameters = + SearchParametersModel( + queryData = queryData, + selectedProgram = searchRepositoryJava.getProgram(programUid), + ) } - - if (fetchedTeiUids.isNotEmpty() && searchParametersModel.selectedProgram == null) { - trackedEntityInstanceQuery = - trackedEntityInstanceQuery.excludeUids().`in`(fetchedTeiUids.toList()) + if (!this::savedFilters.isInitialized) { + savedFilters = FilterManager.getInstance().copy() } - - val pagerFlow = - if (isOnline && FilterManager.getInstance().stateFilters.isEmpty()) { - trackedEntityInstanceQuery.allowOnlineCache().eq(allowCache).offlineFirst() - } else { - trackedEntityInstanceQuery.allowOnlineCache().eq(allowCache).offlineOnly() - } - - return pagerFlow + val allowCache = + queryData == savedSearchParameters.queryData && + FilterManager + .getInstance() + .sameFilters(savedFilters) + savedSearchParameters = savedSearchParameters.copy(queryData = queryData) + savedFilters = FilterManager.getInstance().copy() + return allowCache } - override suspend fun searchParameters( - programUid: String?, - teiTypeUid: String, - ): List = - withContext(dispatcher.io()) { - val searchParameters = - programUid?.let { - programTrackedEntityAttributes(programUid) - } ?: trackedEntitySearchFields(teiTypeUid) - - sortSearchParameters(searchParameters) + override fun getExcludeValues(): HashSet? = + fetchedTeiUids.ifEmpty { + null } - fun sortSearchParameters(parameters: List): List = - parameters.sortedWith( - compareByDescending { - it.renderingType?.isQROrBarcode() == true && isUnique(it.uid) - }.thenByDescending { - it.renderingType?.isQROrBarcode() == true - }.thenByDescending { isUnique(it.uid) }, - ) - - private fun isUnique(teaUid: String): Boolean = - d2 - .trackedEntityModule() - .trackedEntityAttributes() - .uid(teaUid) - .blockingGet() - ?.unique() ?: false - - override suspend fun searchTrackedEntitiesImmediate( - searchParametersModel: SearchParametersModel, - isOnline: Boolean, - ): List = - trackedEntitySearchQuery(searchParametersModel, isOnline) - .blockingGet() - override fun searchTeiForMap( searchParametersModel: SearchParametersModel, isOnline: Boolean, @@ -234,6 +164,50 @@ class SearchRepositoryImplKt( ) } + override fun trackerValueTypeToSDKValueType(trackerInputType: TrackerInputType): ValueType? = + when (trackerInputType) { + TrackerInputType.TEXT -> ValueType.TEXT + TrackerInputType.LONG_TEXT -> ValueType.LONG_TEXT + TrackerInputType.LETTER -> ValueType.LETTER + TrackerInputType.PHONE_NUMBER -> ValueType.PHONE_NUMBER + TrackerInputType.EMAIL -> ValueType.EMAIL + TrackerInputType.URL -> ValueType.URL + TrackerInputType.NUMBER -> ValueType.NUMBER + TrackerInputType.INTEGER -> ValueType.INTEGER + TrackerInputType.INTEGER_POSITIVE -> ValueType.INTEGER_POSITIVE + TrackerInputType.INTEGER_NEGATIVE -> ValueType.INTEGER_NEGATIVE + TrackerInputType.INTEGER_ZERO_OR_POSITIVE -> ValueType.INTEGER_ZERO_OR_POSITIVE + TrackerInputType.PERCENTAGE -> ValueType.PERCENTAGE + TrackerInputType.UNIT_INTERVAL -> ValueType.UNIT_INTERVAL + TrackerInputType.AGE -> ValueType.AGE + TrackerInputType.ORGANISATION_UNIT -> ValueType.ORGANISATION_UNIT + TrackerInputType.DATE_TIME -> ValueType.DATETIME + TrackerInputType.DATE -> ValueType.DATE + TrackerInputType.TIME -> ValueType.TIME + TrackerInputType.HORIZONTAL_CHECKBOXES, + TrackerInputType.VERTICAL_CHECKBOXES, + TrackerInputType.HORIZONTAL_RADIOBUTTONS, + TrackerInputType.VERTICAL_RADIOBUTTONS, + -> ValueType.BOOLEAN + + TrackerInputType.YES_ONLY_SWITCH, + TrackerInputType.YES_ONLY_CHECKBOX, + -> ValueType.TRUE_ONLY + + TrackerInputType.QR_CODE, + TrackerInputType.BAR_CODE, + -> ValueType.TEXT + + TrackerInputType.MULTI_SELECTION -> ValueType.MULTI_TEXT + TrackerInputType.DROPDOWN, + TrackerInputType.PERIOD_SELECTOR, + TrackerInputType.MATRIX, + TrackerInputType.SEQUENTIAL, + TrackerInputType.NOT_SUPPORTED, + TrackerInputType.CUSTOM_INTENT, + -> ValueType.TEXT + } + override fun searchRelationshipsForMap( teis: List, selectedProgram: Program?, @@ -313,6 +287,32 @@ class SearchRepositoryImplKt( } } + override fun validateValue( + inputType: TrackerInputType, + value: String, + ): Any = + { + when (inputType) { + TrackerInputType.DATE -> { + ValueType.DATE.validator.validate(value) + } + TrackerInputType.DATE_TIME -> { + ValueType.DATETIME.validator.validate(value) + } + TrackerInputType.TIME -> { + ValueType.TIME.validator.validate(value) + } + + TrackerInputType.AGE -> { + ValueType.AGE.validator.validate(value) + } + + else -> { + false + } + } + } + override fun searchEventForMap( teiUids: List, selectedProgram: Program?, @@ -345,176 +345,12 @@ class SearchRepositoryImplKt( } } - private fun programTrackedEntityAttributes(programUid: String): List { - val searchableAttributes = - d2 - .programModule() - .programTrackedEntityAttributes() - .withRenderType() - .byProgram() - .eq(programUid) - .orderBySortOrder(RepositoryScope.OrderByDirection.ASC) - .blockingGet() - .filter { programAttribute -> - val isSearchable = programAttribute.searchable()!! - val isUnique = - d2 - .trackedEntityModule() - .trackedEntityAttributes() - .uid(programAttribute.trackedEntityAttribute()!!.uid()) - .blockingGet() - ?.unique() === java.lang.Boolean.TRUE - isSearchable || isUnique - } - - val program = - d2 - .programModule() - .programs() - .uid(programUid) - .blockingGet() - - return searchableAttributes - .mapNotNull { programAttribute -> - d2 - .trackedEntityModule() - .trackedEntityAttributes() - .uid(programAttribute.trackedEntityAttribute()!!.uid()) - .blockingGet() - ?.let { attribute -> - val searchFlow = MutableStateFlow("") - val optionSetConfiguration = - attribute.optionSet()?.let { - OptionSetConfiguration( - searchEmitter = searchFlow, - optionFlow = - searchFlow.debounce(300).flatMapLatest { - d2 - .optionModule() - .options() - .orderBySortOrder(RepositoryScope.OrderByDirection.ASC) - .byOptionSetUid() - .eq(attribute.optionSet()!!.uid()) - .getPagingData(10) - .map { pagingData -> - pagingData.map { option -> - OptionSetConfiguration.OptionData( - option, - metadataIconProvider( - option.style(), - program?.style()?.color()?.toColor() - ?: SurfaceColor.Primary, - ), - ) - } - } - }, - onSearch = { searchFlow.value = it }, - ) - } - val customIntentModel = - customIntentRepository.getCustomIntent( - triggerUid = attribute.uid(), - orgUnitUid = null, - actionType = CustomIntentActionTypeModel.SEARCH, - ) - createField( - trackedEntityAttribute = attribute, - programTrackedEntityAttribute = programAttribute, - optionSetConfiguration = optionSetConfiguration, - customIntent = customIntentModel, - ) - } - }.filter { parameter -> - parameter.valueType !== ValueType.IMAGE && - parameter.valueType !== ValueType.COORDINATE && - parameter.valueType !== ValueType.FILE_RESOURCE - } - } - - private fun trackedEntitySearchFields(teiTypeUid: String): List { - val teTypeAttributes = - d2 - .trackedEntityModule() - .trackedEntityTypeAttributes() - .byTrackedEntityTypeUid() - .eq(teiTypeUid) - .bySearchable() - .isTrue - .blockingGet() - - return teTypeAttributes - .mapNotNull { typeAttribute -> - d2 - .trackedEntityModule() - .trackedEntityAttributes() - .uid(typeAttribute.trackedEntityAttribute()!!.uid()) - .blockingGet() - ?.let { attribute -> - val searchEmitter = MutableStateFlow("") - val optionSetConfiguration = - attribute.optionSet()?.let { - OptionSetConfiguration( - searchEmitter = searchEmitter, - optionFlow = - d2 - .optionModule() - .options() - .byOptionSetUid() - .eq(attribute.optionSet()!!.uid()) - .orderBySortOrder(RepositoryScope.OrderByDirection.ASC) - .getPagingData(10) - .map { pagingData -> - pagingData.map { option -> - OptionSetConfiguration.OptionData( - option, - metadataIconProvider( - option.style(), - SurfaceColor.Primary, - ), - ) - } - }, - onSearch = { searchEmitter.value = it }, - ) - } - - createField( - trackedEntityAttribute = attribute, - programTrackedEntityAttribute = null, - optionSetConfiguration = optionSetConfiguration, - ) - } - }.filter { parameter -> - parameter.valueType !== ValueType.IMAGE && - parameter.valueType !== ValueType.COORDINATE && - parameter.valueType !== ValueType.FILE_RESOURCE - } - } - - private fun createField( - trackedEntityAttribute: TrackedEntityAttribute, - programTrackedEntityAttribute: ProgramTrackedEntityAttribute?, - optionSetConfiguration: OptionSetConfiguration?, - customIntent: CustomIntentModel? = null, - ): FieldUiModel = - fieldViewModelFactory.create( - id = trackedEntityAttribute.uid(), - label = trackedEntityAttribute.displayFormName() ?: "", - valueType = trackedEntityAttribute.valueType()!!, - mandatory = false, - optionSet = trackedEntityAttribute.optionSet()?.uid(), - value = null, - programStageSection = null, - allowFutureDates = programTrackedEntityAttribute?.allowFutureDate() ?: true, - editable = true, - renderingType = SectionRenderingType.LISTING, - description = null, - fieldRendering = programTrackedEntityAttribute?.renderType()?.mobile(), - objectStyle = trackedEntityAttribute.style() ?: ObjectStyle.builder().build(), - fieldMask = trackedEntityAttribute.fieldMask(), - optionSetConfiguration = optionSetConfiguration, - featureType = null, - customIntentModel = customIntent, - ) + override suspend fun getCustomIntent(fieldUid: FieldUid) = + withContext(dispatcher.io()) { + customIntentRepository.getCustomIntent( + triggerUid = fieldUid, + orgUnitUid = null, + actionType = CustomIntentActionTypeModel.SEARCH, + ) + } } diff --git a/app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchRepositoryKt.kt b/app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchRepositoryKt.kt index 1b57f339c79..17f56679dee 100644 --- a/app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchRepositoryKt.kt +++ b/app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchRepositoryKt.kt @@ -1,34 +1,24 @@ package org.dhis2.usescases.searchTrackEntity -import androidx.paging.PagingData -import kotlinx.coroutines.flow.Flow import org.dhis2.data.search.SearchParametersModel -import org.dhis2.form.model.FieldUiModel import org.dhis2.maps.model.MapItemModel +import org.dhis2.mobile.commons.model.CustomIntentModel +import org.dhis2.tracker.input.model.TrackerInputType +import org.dhis2.tracker.input.ui.action.FieldUid +import org.hisp.dhis.android.core.common.ValueType import org.hisp.dhis.android.core.program.Program -import org.hisp.dhis.android.core.trackedentity.search.TrackedEntitySearchItem interface SearchRepositoryKt { - fun searchTrackedEntities( - searchParametersModel: SearchParametersModel, - isOnline: Boolean, - ): Flow> - - suspend fun searchParameters( - programUid: String?, - teiTypeUid: String, - ): List - - suspend fun searchTrackedEntitiesImmediate( - searchParametersModel: SearchParametersModel, - isOnline: Boolean, - ): List - fun searchTeiForMap( searchParametersModel: SearchParametersModel, isOnline: Boolean, ): List + fun validateValue( + inputType: TrackerInputType, + value: String, + ): Any + fun searchEventForMap( teiUids: List, selectedProgram: Program?, @@ -38,4 +28,15 @@ interface SearchRepositoryKt { teis: List, selectedProgram: Program?, ): List + + suspend fun getCustomIntent(fieldUid: FieldUid): CustomIntentModel? + + fun saveSearchValuesAndGetAllowCache( + queryData: MutableMap?>?, + programUid: String?, + ): Boolean + + fun getExcludeValues(): HashSet? + + fun trackerValueTypeToSDKValueType(trackerInputType: TrackerInputType): ValueType? } diff --git a/app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchTEActivity.kt b/app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchTEActivity.kt index 87885c681dc..e91d4083cb6 100644 --- a/app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchTEActivity.kt +++ b/app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchTEActivity.kt @@ -54,7 +54,6 @@ import org.dhis2.commons.sync.SyncContext import org.dhis2.commons.sync.SyncContext.TrackerProgramTei import org.dhis2.data.forms.dataentry.ProgramAdapter import org.dhis2.databinding.ActivitySearchBinding -import org.dhis2.form.ui.intent.FormIntent.OnSave import org.dhis2.mobile.commons.orgunit.OrgUnitSelectorScope import org.dhis2.tracker.NavigationBarUIState import org.dhis2.ui.ThemeManager @@ -66,7 +65,7 @@ import org.dhis2.usescases.searchTrackEntity.LegacyInteraction.OnSyncIconClick import org.dhis2.usescases.searchTrackEntity.LegacyInteraction.OnTeiClick import org.dhis2.usescases.searchTrackEntity.listView.SearchTEList.Companion.get import org.dhis2.usescases.searchTrackEntity.mapView.SearchTEMap.Companion.get -import org.dhis2.usescases.searchTrackEntity.searchparameters.initSearchScreen +import org.dhis2.usescases.searchTrackEntity.searchparameters.provideSearchScreen import org.dhis2.usescases.searchTrackEntity.ui.SearchScreenConfigurator import org.dhis2.utils.customviews.BreakTheGlassBottomDialog import org.dhis2.utils.customviews.navigationbar.NavigationPage @@ -75,7 +74,6 @@ import org.dhis2.utils.granularsync.shouldLaunchSyncDialog import org.dhis2.utils.isLandscape import org.dhis2.utils.isPortrait import org.hisp.dhis.android.core.arch.call.D2Progress -import org.hisp.dhis.android.core.common.ValueType import org.hisp.dhis.android.core.organisationunit.OrganisationUnit import org.hisp.dhis.mobile.ui.designsystem.component.navigationBar.NavigationBar import org.hisp.dhis.mobile.ui.designsystem.theme.DHIS2Theme @@ -232,6 +230,7 @@ class SearchTEActivity : initialProgram, context, initialQuery, + syncStatusController, ), ) searchComponent?.inject(this) @@ -301,7 +300,7 @@ class SearchTEActivity : override fun onSaveInstanceState(outState: Bundle) { super.onSaveInstanceState(outState) - outState.putSerializable(Constants.QUERY_DATA, viewModel.queryData as Serializable) + outState.putSerializable(Constants.QUERY_DATA, viewModel.queryDataAsMap() as Serializable) outState.putString(CURRENT_SCREEN, currentContent?.name) } @@ -336,7 +335,7 @@ class SearchTEActivity : } private fun initSearchParameters() { - initSearchScreen( + provideSearchScreen( binding.searchContainer, viewModel, initialProgram, @@ -352,14 +351,9 @@ class SearchTEActivity : if (selectedOrgUnits.isNotEmpty()) { selectedOrgUnit = selectedOrgUnits[0].uid() } - viewModel.onParameterIntent( - OnSave( - uid, - selectedOrgUnit, - ValueType.ORGANISATION_UNIT, - null, - true, - ), + viewModel.onValueChange( + fieldUid = uid, + value = selectedOrgUnit, ) }.orgUnitScope(orgUnitScope) .build() diff --git a/app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchTEIViewModel.kt b/app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchTEIViewModel.kt index 3d645b7f686..c6fc35f741d 100644 --- a/app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchTEIViewModel.kt +++ b/app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchTEIViewModel.kt @@ -23,6 +23,7 @@ import kotlinx.coroutines.async import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.emitAll import kotlinx.coroutines.flow.emptyFlow @@ -43,9 +44,7 @@ import org.dhis2.commons.filters.FilterManager import org.dhis2.commons.network.NetworkUtils import org.dhis2.commons.resources.ResourceManager import org.dhis2.commons.viewmodel.DispatcherProvider -import org.dhis2.data.search.SearchParametersModel -import org.dhis2.form.model.FieldUiModelImpl -import org.dhis2.form.ui.intent.FormIntent +import org.dhis2.form.ui.customintent.CustomIntentResult import org.dhis2.form.ui.provider.DisplayNameProvider import org.dhis2.maps.extensions.toStringProperty import org.dhis2.maps.layer.MapLayer @@ -54,13 +53,24 @@ import org.dhis2.maps.managers.MapManager import org.dhis2.maps.usecases.MapStyleConfiguration import org.dhis2.mobile.commons.coroutine.CoroutineTracker import org.dhis2.tracker.NavigationBarUIState +import org.dhis2.tracker.input.model.TrackerInputType +import org.dhis2.tracker.input.ui.action.CustomIntentUid +import org.dhis2.tracker.input.ui.action.FieldUid +import org.dhis2.tracker.input.ui.action.TrackerInputAction +import org.dhis2.tracker.input.ui.mapper.toTrackerInputUiState +import org.dhis2.tracker.input.ui.state.TrackerOptionItem +import org.dhis2.tracker.search.data.transformDomainTeiToSDKTei +import org.dhis2.tracker.search.domain.FetchOptionSetOptions +import org.dhis2.tracker.search.domain.FetchSearchParameters +import org.dhis2.tracker.search.domain.SearchTrackedEntities +import org.dhis2.tracker.search.model.FetchSearchParametersData +import org.dhis2.tracker.search.model.QueryData +import org.dhis2.tracker.search.model.SearchTrackedEntitiesInput +import org.dhis2.tracker.search.ui.state.SearchParametersUiState import org.dhis2.usescases.searchTrackEntity.listView.SearchResult -import org.dhis2.usescases.searchTrackEntity.searchparameters.model.SearchParametersUiState import org.dhis2.usescases.searchTrackEntity.ui.UnableToSearchOutsideData import org.dhis2.utils.customviews.navigationbar.NavigationPage import org.dhis2.utils.customviews.navigationbar.NavigationPageConfigurator -import org.hisp.dhis.android.core.arch.helpers.Result -import org.hisp.dhis.android.core.common.ValueType import org.hisp.dhis.android.core.maintenance.D2ErrorCode import org.hisp.dhis.mobile.ui.designsystem.component.navigationBar.NavigationBarItem import org.maplibre.geojson.Feature @@ -81,9 +91,18 @@ class SearchTEIViewModel( private val resourceManager: ResourceManager, private val displayNameProvider: DisplayNameProvider, private val filterManager: FilterManager, + private val searchTrackedEntities: SearchTrackedEntities, + private val fetchSearchParameters: FetchSearchParameters, + private val fetchOptionSetOptions: FetchOptionSetOptions, ) : ViewModel() { private var layersVisibility: Map = emptyMap() + // Store option set flows per field UID + private val optionSetFlows = mutableMapOf>>() + + // Store search query states for option sets + private val optionSetSearchQueries = mutableMapOf>() + private val pageConfiguration = MutableLiveData() private val _navigationBarUIState = @@ -93,11 +112,6 @@ class SearchTEIViewModel( val navigationBarUIState: MutableState> = _navigationBarUIState - val queryData = - mutableMapOf?>().apply { - initialQuery?.let { putAll(it) } - } - private val _legacyInteraction = MutableLiveData() val legacyInteraction: LiveData = _legacyInteraction @@ -136,12 +150,20 @@ class SearchTEIViewModel( var searchParametersUiState by mutableStateOf(SearchParametersUiState()) + val queryDataList = + mutableListOf().apply { + initialQuery?.let { addAll(it.toQueryDataList()) } + } + var mapManager: MapManager? = null private var fetchJob: Job? = null private val onNewSearch = MutableSharedFlow(extraBufferCapacity = 1) + private val _searchActions = Channel() + val searchActions = _searchActions.receiveAsFlow() + val searchPagingData = onNewSearch .onStart { emit(Unit) } @@ -174,6 +196,52 @@ class SearchTEIViewModel( } } + /** + * Get or create option set flow for a given field. + */ + fun getOptionSetFlow( + fieldUid: String, + optionSetUid: String, + ): Flow> = + optionSetFlows.getOrPut(fieldUid) { + flow { + val searchQuery = + optionSetSearchQueries.getOrPut(fieldUid) { + MutableStateFlow(null) + } + + searchQuery.collect { query -> + val result = + fetchOptionSetOptions( + FetchOptionSetOptions.Params( + optionSetUid = optionSetUid, + pageSize = 10, + searchQuery = query, + ), + ) + + result.fold( + onSuccess = { optionsFlow -> + emitAll(optionsFlow) + }, + onFailure = { + emit(PagingData.empty()) + }, + ) + } + }.cachedIn(viewModelScope) + } + + /** + * Handle search in option sets. + */ + fun onOptionSetSearch( + fieldUid: String, + query: String, + ) { + optionSetSearchQueries[fieldUid]?.value = query.takeIf { it.isNotBlank() } + } + private fun loadNavigationBarItems() { CoroutineTracker.increment() val pageConfigurator = searchNavPageConfigurator.initVariables() @@ -261,7 +329,7 @@ class SearchTEIViewModel( isSearching = searching, searchForm = SearchForm( - queryHasData = queryData.isNotEmpty(), + queryHasData = queryDataList.isNotEmpty(), minAttributesToSearch = searchRepository .getProgram(initialProgramUid) @@ -298,7 +366,7 @@ class SearchTEIViewModel( isSearching = searching, searchForm = SearchForm( - queryHasData = queryData.isNotEmpty(), + queryHasData = queryDataList.isNotEmpty(), minAttributesToSearch = searchRepository .getProgram(initialProgramUid) @@ -338,7 +406,7 @@ class SearchTEIViewModel( isSearching = searching, searchForm = SearchForm( - queryHasData = queryData.isNotEmpty(), + queryHasData = queryDataList.isNotEmpty(), minAttributesToSearch = searchRepository .getProgram(initialProgramUid) @@ -381,9 +449,24 @@ class SearchTEIViewModel( values: List?, ) { if (values.isNullOrEmpty()) { - queryData.remove(uid) + queryDataList.removeIf { it.attributeId == uid } } else { - queryData[uid] = values + if (queryDataList.none { it.attributeId == uid }) { + queryDataList.add( + QueryData( + attributeId = uid, + values = values, + searchOperator = searchParametersUiState.items.firstOrNull { it.uid == uid }?.searchOperator, + ), + ) + } else { + queryDataList + .indexOfFirst { it.attributeId == uid } + .takeIf { it != -1 } + ?.let { index -> + queryDataList[index] = queryDataList[index].copy(values = values) + } + } } updateSearchParameters(uid, values) @@ -393,19 +476,21 @@ class SearchTEIViewModel( private fun updateSearchParameters( uid: String, values: List?, + errorMessage: String? = null, ) { val updatedItems = searchParametersUiState.items.map { if (it.uid == uid) { - (it as FieldUiModelImpl).copy( + it.copy( value = values?.joinToString(","), displayName = displayNameProvider.provideDisplayName( - valueType = it.valueType, + valueType = searchRepositoryKt.trackerValueTypeToSDKValueType(it.valueType), value = values?.joinToString(","), optionSet = it.optionSet, - periodType = it.periodSelector?.type, + periodType = null, ), + error = errorMessage, ) } else { it @@ -415,7 +500,7 @@ class SearchTEIViewModel( } fun clearQueryData() { - queryData.clear() + queryDataList.clear() clearSearchParameters() updateSearch() performSearch() @@ -424,7 +509,7 @@ class SearchTEIViewModel( private fun clearSearchParameters() { val updatedItems = searchParametersUiState.items.map { - (it as FieldUiModelImpl).copy(value = null, displayName = null) + it.copy(value = null, displayName = null) } searchParametersUiState = searchParametersUiState.copy( @@ -441,124 +526,151 @@ class SearchTEIViewModel( currentSearchList.copy( searchForm = currentSearchList.searchForm.copy( - queryHasData = queryData.isNotEmpty(), + queryHasData = queryDataList.isNotEmpty(), ), ), ) } - searchParametersUiState = searchParametersUiState.copy(searchEnabled = queryData.isNotEmpty()) + searchParametersUiState = + searchParametersUiState.copy(searchEnabled = queryDataList.isNotEmpty()) } - private suspend fun loadSearchResults() = - withContext(dispatchers.io()) { - val searchParametersModel = - SearchParametersModel( - selectedProgram = searchRepository.getProgram(initialProgramUid), - queryData = queryData, + private fun loadSearchResults(): Flow> = + flow { + // get uids to exclude for possible duplicates + val excludeValues = searchRepositoryKt.getExcludeValues() + + val isOnline = searching && networkUtils.isOnline() + val selectedProgram = searchRepository.getProgram(initialProgramUid) + + val allowCache = + searchRepositoryKt.saveSearchValuesAndGetAllowCache( + queryDataAsMap(), + selectedProgram?.uid(), ) - val getPagingData = - searchRepositoryKt.searchTrackedEntities( - searchParametersModel, - searching && networkUtils.isOnline(), + val newTrackerSearchModel = + SearchTrackedEntitiesInput( + selectedProgram = selectedProgram?.uid(), + allowCache = allowCache, + excludeValues = excludeValues, + hasStateFilters = filterManager.stateFilters.isNotEmpty(), + isOnline = isOnline, + queryDataList = queryDataList, ) + val results = searchTrackedEntities.invoke(newTrackerSearchModel) - return@withContext getPagingData.map { pagingData -> - pagingData.map { item -> - withContext(dispatchers.io()) { - if ( - searching && - networkUtils.isOnline() && - filterManager.stateFilters.isEmpty() - ) { - searchRepository.transform( - item, - searchParametersModel.selectedProgram, - false, - filterManager.sortingItem, - ) - } else { + emitAll( + results.getOrThrow().map { pagingData -> + pagingData.map { item -> + withContext(dispatchers.io()) { + // TODO Create a new SearchTeiModel that does not use + // SDK objects and remove this mapping from the domain model back to the SDK one + val sdkTei = transformDomainTeiToSDKTei(item) + val searchOnline = + isOnline && + filterManager.stateFilters.isEmpty() searchRepository.transform( - item, - searchParametersModel.selectedProgram, - true, + sdkTei, + selectedProgram, + !searchOnline, filterManager.sortingItem, ) } } - } - } + }, + ) } - private suspend fun loadDisplayInListResults() = - withContext(dispatchers.io()) { - val searchParametersModel = - SearchParametersModel( - selectedProgram = searchRepository.getProgram(initialProgramUid), - queryData = queryData, + private fun loadDisplayInListResults(): Flow> = + flow { + val excludeValues = searchRepositoryKt.getExcludeValues() + val selectedProgram = searchRepository.getProgram(initialProgramUid) + + val allowCache = + searchRepositoryKt.saveSearchValuesAndGetAllowCache( + queryDataAsMap(), + selectedProgram?.uid(), ) - val getPagingData = - searchRepositoryKt.searchTrackedEntities( - searchParametersModel, - false, + val newTrackerSearchModel = + SearchTrackedEntitiesInput( + selectedProgram = selectedProgram?.uid(), + allowCache = allowCache, + excludeValues = excludeValues, + hasStateFilters = filterManager.stateFilters.isNotEmpty(), + isOnline = false, + queryDataList = queryDataList, ) + val results = searchTrackedEntities.invoke(newTrackerSearchModel) - return@withContext getPagingData.map { pagingData -> - pagingData.map { item -> - withContext(dispatchers.io()) { - searchRepository.transform( - item, - searchParametersModel.selectedProgram, - true, - filterManager.sortingItem, - ) + emitAll( + results.getOrThrow().map { pagingData -> + pagingData.map { item -> + withContext(dispatchers.io()) { + // TODO Create a new SearchTeiModel that does not use + // SDK objects and remove this mapping from the domain model back to the SDK one + val sdkTei = transformDomainTeiToSDKTei(item) + searchRepository.transform( + sdkTei, + selectedProgram, + true, + filterManager.sortingItem, + ) + } } - } - } + }, + ) } - suspend fun fetchGlobalResults() = - withContext(dispatchers.io()) { - val searchParametersModel = - SearchParametersModel( - selectedProgram = null, - queryData = queryData, - ) - val getPagingData = - searchRepositoryKt.searchTrackedEntities( - searchParametersModel, - searching && networkUtils.isOnline(), - ) + fun fetchGlobalResults(): Flow>? { + // get uids to exclude for possible duplicates + return if (searching) { + flow { + val excludeValues = searchRepositoryKt.getExcludeValues() - return@withContext if (searching) { - getPagingData.map { pagingData -> - pagingData.map { item -> - withContext(dispatchers.io()) { - if ( - searching && - networkUtils.isOnline() && - filterManager.stateFilters.isEmpty() - ) { - searchRepository.transform( - item, - searchParametersModel.selectedProgram, - false, - filterManager.sortingItem, - ) - } else { + val isOnline = searching && networkUtils.isOnline() + val selectedProgram = searchRepository.getProgram(initialProgramUid) + + val allowCache = + searchRepositoryKt.saveSearchValuesAndGetAllowCache( + queryDataAsMap(), + selectedProgram?.uid(), + ) + val newTrackerSearchModel = + SearchTrackedEntitiesInput( + selectedProgram = null, + allowCache = allowCache, + excludeValues = excludeValues, + hasStateFilters = filterManager.stateFilters.isNotEmpty(), + isOnline = isOnline, + queryDataList = queryDataList, + ) + val results = searchTrackedEntities.invoke(newTrackerSearchModel) + + emitAll( + results.getOrThrow().map { pagingData -> + pagingData.map { item -> + withContext(dispatchers.io()) { + // TODO Create a new SearchTeiModel that does not use + // SDK objects and remove this mapping from the domain model back to the SDK one + val sdkTei = transformDomainTeiToSDKTei(item) + val searchOnline = + isOnline && + filterManager.stateFilters.isEmpty() searchRepository.transform( - item, - searchParametersModel.selectedProgram, - true, + sdkTei, + selectedProgram, + !searchOnline, filterManager.sortingItem, ) } } - } - } - } else { - null + }, + ) } + } else { + null } + } fun fetchMapResults() { CoroutineTracker.increment() @@ -567,7 +679,7 @@ class SearchTEIViewModel( val data = mapDataRepository.getTrackerMapData( searchRepository.getProgram(initialProgramUid), - queryData, + queryDataAsMap(), layersVisibility, ) _mapResults.send(data) @@ -589,10 +701,10 @@ class SearchTEIViewModel( viewModelScope.launch(dispatchers.io()) { try { if (canPerformSearch()) { - searching = queryData.isNotEmpty() + searching = queryDataList.isNotEmpty() searchParametersUiState = searchParametersUiState.copy( - clearSearchEnabled = queryData.isNotEmpty(), + clearSearchEnabled = queryDataList.isNotEmpty(), searchedItems = getFriendlyQueryData(), ) @@ -621,7 +733,8 @@ class SearchTEIViewModel( R.string.search_min_num_attr, minAttributesToSearch, ) - searchParametersUiState = searchParametersUiState.copy(minAttributesMessage = message) + searchParametersUiState = + searchParametersUiState.copy(minAttributesMessage = message) searchParametersUiState.updateMinAttributeWarning(true) setSearchScreen() _refreshData.postValue(Unit) @@ -637,12 +750,12 @@ class SearchTEIViewModel( private fun minAttributesToSearchCheck(): Boolean = searchRepository.getProgram(initialProgramUid)?.let { program -> - (program.minAttributesRequiredToSearch() ?: 0) <= queryData.size + (program.minAttributesRequiredToSearch() ?: 0) <= queryDataList.size } ?: true private fun displayFrontPageList(): Boolean = searchRepository.getProgram(initialProgramUid)?.let { program -> - program.displayFrontPageList() == true && queryData.isEmpty() + program.displayFrontPageList() == true && queryDataList.isEmpty() } ?: false private fun canDisplayResult( @@ -663,10 +776,10 @@ class SearchTEIViewModel( } fun queryDataByProgram(programUid: String?): MutableMap> = - searchRepository.filterQueryForProgram(queryData, programUid) + searchRepository.filterQueryForProgram(queryDataAsMap(), programUid) fun onEnrollClick() { - _legacyInteraction.postValue(LegacyInteraction.OnEnrollClick(queryData)) + _legacyInteraction.postValue(LegacyInteraction.OnEnrollClick(queryDataAsMap())) } fun onAddRelationship( @@ -704,7 +817,7 @@ class SearchTEIViewModel( LegacyInteraction.OnEnroll( initialProgramUid, downloadResult.teiUid, - queryData, + queryDataAsMap(), ), ) } else { @@ -791,7 +904,7 @@ class SearchTEIViewModel( hasGlobalResults == null && searchRepository.getProgram(initialProgramUid) != null && - searchRepository.filterQueryForProgram(queryData, null).isNotEmpty() && + searchRepository.filterQueryForProgram(queryDataAsMap(), null).isNotEmpty() && searchRepository.filtersApplyOnGlobalSearch() -> { listOf( SearchResult( @@ -972,118 +1085,93 @@ class SearchTEIViewModel( fetchJob?.cancel() fetchJob = viewModelScope.launch { - val fieldUiModels = - searchRepositoryKt.searchParameters(programUid, teiTypeUid) - searchParametersUiState = searchParametersUiState.copy(items = fieldUiModels) - } - } - - fun onParameterIntent(formIntent: FormIntent) = - when (formIntent) { - is FormIntent.OnTextChange -> { - updateQuery( - formIntent.uid, - formIntent.value?.split(","), - ) - } - - is FormIntent.OnSave -> { - updateQuery( - formIntent.uid, - formIntent.value?.split(","), - ) - } - - is FormIntent.OnQrCodeScanned -> { - onQrCodeScanned(formIntent) - } - - is FormIntent.OnFocus -> { - val updatedItems = - searchParametersUiState.items.map { field -> - if (field.focused && field.uid != formIntent.uid) { - val validation = - field.value - ?.takeIf { - field.valueType in - listOf( - ValueType.DATE, - ValueType.DATETIME, - ValueType.AGE, - ValueType.TIME, - ) - }?.let { value -> field.valueType?.validator?.validate(value) } - - (field as FieldUiModelImpl).copy( - focused = false, - error = - when (validation) { - is Result.Failure -> resourceManager.getString(R.string.formatting_error) - else -> null - }, - ) - } else if (field.uid == formIntent.uid) { - (field as FieldUiModelImpl).copy(focused = true) - } else { - field - } - } - searchParametersUiState = searchParametersUiState.copy(items = updatedItems) - } - - is FormIntent.ClearValue -> { - updateQuery( - formIntent.uid, - null, - ) - } + fetchSearchParameters + .invoke( + input = + FetchSearchParametersData( + teiTypeUid = teiTypeUid, + programUid = programUid, + ), + ).fold( + onSuccess = { searchParameters -> + val newItems = + searchParameters.map { searchParameter -> + searchParameter.toTrackerInputUiState() + } + searchParametersUiState = + searchParametersUiState.copy( + items = newItems, + ) - else -> { - // no-op + queryDataList.forEachIndexed { index, entry -> + val searchOperator = + newItems + .firstOrNull { it.uid == entry.attributeId } + ?.searchOperator + queryDataList[index] = entry.copy(searchOperator = searchOperator) + } + }, + onFailure = { + // TODO(Implement error) + }, + ) } - } + } - private fun onQrCodeScanned(formIntent: FormIntent.OnQrCodeScanned) { + private fun onQrCodeScanned( + uid: String, + value: String?, + ) { viewModelScope.launch { updateQuery( - formIntent.uid, - formIntent.value?.let { listOf(it) }, + uid, + value?.let { listOf(it) }, ) - searching = queryData.isNotEmpty() + searching = queryDataList.isNotEmpty() searchParametersUiState = searchParametersUiState.copy( - clearSearchEnabled = queryData.isNotEmpty(), + clearSearchEnabled = queryDataList.isNotEmpty(), searchedItems = getFriendlyQueryData(), ) - val searchParametersModel = - SearchParametersModel( - selectedProgram = searchRepository.getProgram(initialProgramUid), - queryData = queryData, - ) val isOnline = searching && networkUtils.isOnline() - val trackedEntities = - async(dispatchers.io()) { - searchRepositoryKt.searchTrackedEntitiesImmediate( - searchParametersModel = searchParametersModel, - isOnline = isOnline, - ) - }.await() + val selectedProgram = searchRepository.getProgram(initialProgramUid) + + // get uids to exclude for possible duplicates + val excludeValues = searchRepositoryKt.getExcludeValues() + + val newTrackerSearchModel = + SearchTrackedEntitiesInput( + selectedProgram = selectedProgram?.uid(), + allowCache = false, // No need for cache in immediate search + excludeValues = excludeValues, + hasStateFilters = filterManager.stateFilters.isNotEmpty(), + isOnline = isOnline, + queryDataList = queryDataList, + ) + + // Use invokeImmediate for QR code scanning to get immediate non-paginated results + val trackedEntitiesResult = searchTrackedEntities.invokeImmediate(newTrackerSearchModel) + + val trackedEntities = trackedEntitiesResult.getOrNull() ?: emptyList() if (trackedEntities.isEmpty() || trackedEntities.size > 1) return@launch val tei = trackedEntities.first() + + // Transform domain model to SDK model for compatibility with existing code + val sdkTei = + withContext(dispatchers.io()) { + transformDomainTeiToSDKTei(tei) + } + val searchTeiModel = withContext(dispatchers.io()) { searchRepository.transform( - // searchItem = - tei, - // selectedProgram = - searchParametersModel.selectedProgram, - // offlineOnly = + sdkTei, + selectedProgram, !(isOnline && filterManager.stateFilters.isEmpty()), - // sortingItem = filterManager.sortingItem, ) } @@ -1106,7 +1194,7 @@ class SearchTEIViewModel( val updatedItems = searchParametersUiState.items.map { if (it.focused) { - (it as FieldUiModelImpl).copy(focused = false) + it.copy(focused = false) } else { it } @@ -1121,35 +1209,37 @@ class SearchTEIViewModel( .forEach { item -> when (item.valueType) { - ValueType.ORGANISATION_UNIT, ValueType.MULTI_TEXT -> { + TrackerInputType.ORGANISATION_UNIT, TrackerInputType.MULTI_SELECTION -> { map[item.uid] = (item.displayName ?: "") } - ValueType.DATE, ValueType.AGE -> { + TrackerInputType.DATE, TrackerInputType.AGE -> { item.value?.let { map[item.uid] = it.toFriendlyDate() } } - ValueType.DATETIME -> { + TrackerInputType.DATE_TIME -> { item.value?.let { map[item.uid] = it.toFriendlyDateTime() } } - ValueType.BOOLEAN -> { - map[item.uid] = "${item.label}: ${item.value}" - } - - ValueType.TRUE_ONLY -> { + TrackerInputType.YES_ONLY_SWITCH, + TrackerInputType.YES_ONLY_CHECKBOX, + TrackerInputType.HORIZONTAL_RADIOBUTTONS, + TrackerInputType.VERTICAL_RADIOBUTTONS, + TrackerInputType.HORIZONTAL_CHECKBOXES, + TrackerInputType.VERTICAL_CHECKBOXES, + -> { item.value?.let { - if (it == "true") { - map[item.uid] = item.label + if (it == "true" || it == "false") { + map[item.uid] = "${item.label}: $it" } } } - ValueType.PERCENTAGE -> { + TrackerInputType.PERCENTAGE -> { item.value?.let { map[item.uid] = it.toPercentage() } @@ -1175,4 +1265,157 @@ class SearchTEIViewModel( this.layersVisibility = layersVisibility fetchMapResults() } + + fun launchCustomIntent( + fieldUid: FieldUid, + customIntentUid: CustomIntentUid, + ) { + viewModelScope.launch { + searchRepositoryKt.getCustomIntent(fieldUid)?.let { customIntentModel -> + _searchActions.send( + TrackerInputAction.LaunchCustomIntent( + fieldUid = fieldUid, + customIntentModel = customIntentModel, + ), + ) + } + } + } + + fun launchScan( + fieldUid: String, + optionSet: String?, + renderType: TrackerInputType, + ) { + val scanType = + if (renderType == TrackerInputType.QR_CODE) { + TrackerInputType.QR_CODE + } else { + TrackerInputType.BAR_CODE + } + + viewModelScope.launch { + _searchActions.send( + TrackerInputAction.Scan( + fieldUid = fieldUid, + optionSet = optionSet, + renderType = scanType, + ), + ) + } + } + + fun onValueChange( + fieldUid: String, + value: String?, + ) { + updateQuery( + fieldUid, + value?.split(","), + ) + } + + fun onItemClick(fieldUid: FieldUid) { + searchParametersUiState + .copy( + items = + searchParametersUiState.items.map { + if (it.uid == fieldUid) { + it.copy(focused = true) + } else { + it.copy(focused = false) + } + }, + ).let { + searchParametersUiState = it + } + } + + fun handleCustomIntentResult(customIntentResult: CustomIntentResult) { + when (customIntentResult) { + is CustomIntentResult.Error -> { + updateSearchParameters( + customIntentResult.fieldUid, + null, + resourceManager.getString(R.string.custom_intent_error), + ) + } + + is CustomIntentResult.Success -> { + updateSearchParameters( + customIntentResult.fieldUid, + listOf(customIntentResult.value), + ) + } + } + } + + fun handleScanResult( + fieldUid: String, + value: String?, + ) { + onQrCodeScanned( + uid = fieldUid, + value = value, + ) + value?.let { + updateSearchParameters( + uid = fieldUid, + values = listOf(value), + ) + } + } + + /** + * + * Converts the internal queryDataList to a map representation. + * + * Since the previous map representation of queryData (Map>) is used outside + * of the SearchTEIViewModel, this function helps to represent the new and refactor. + * + * QueryData list into the Map> on those places. + * We will continue to refactor the map from other places it was used in the future. And these + * methods will help us do a smooth refactor without breaking changes. + * + * After all QueryData maps are refactored, this function will be removed. + * + * @return A mutableMap with attribute IDs as keys and their corresponding value lists + * + */ + fun queryDataAsMap() = queryDataList.toMap() + + /** + * + * Converts the mutableList of [QueryData] to a mutableMap where keys are attribute IDs + * and values are lists of strings. + * + * @return A mutableMap with attribute IDs as keys and their corresponding value lists + * + */ + private fun MutableList.toMap(): MutableMap?> = + this + .associate { queryData -> + val valueList = queryData.values + queryData.attributeId to valueList + }.toMutableMap() + + /** + * + * Converts a mutableMap to a mutableList of [QueryData] where map keys are attribute IDs + * and map values are lists of strings. + * + * @return A mutableList of [QueryData] objects + * + */ + private fun MutableMap?>.toQueryDataList() = + this + .map { (attributeId, valuesList) -> + QueryData( + attributeId = attributeId, + values = valuesList, + searchOperator = null, + ) + }.toMutableList() + + // } diff --git a/app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchTEModule.java b/app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchTEModule.java index 2bebf70266c..48630a68fb2 100644 --- a/app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchTEModule.java +++ b/app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchTEModule.java @@ -28,7 +28,6 @@ import org.dhis2.data.enrollment.EnrollmentUiDataHelper; import org.dhis2.data.forms.dataentry.SearchTEIRepository; import org.dhis2.data.forms.dataentry.SearchTEIRepositoryImpl; -import org.dhis2.data.service.SyncStatusController; import org.dhis2.data.sorting.SearchSortingValueSetter; import org.dhis2.form.data.metadata.FileResourceConfiguration; import org.dhis2.form.data.metadata.OptionSetConfiguration; @@ -59,10 +58,26 @@ import org.dhis2.maps.model.MapScope; import org.dhis2.maps.usecases.MapStyleConfiguration; import org.dhis2.maps.utils.DhisMapUtils; +import org.dhis2.mobile.commons.coroutine.Dispatcher; import org.dhis2.mobile.commons.customintents.CustomIntentRepository; import org.dhis2.mobile.commons.customintents.CustomIntentRepositoryImpl; +import org.dhis2.mobile.commons.error.DomainErrorMapper; +import org.dhis2.mobile.commons.network.NetworkStatusProvider; +import org.dhis2.mobile.commons.network.NetworkStatusProviderImpl; import org.dhis2.mobile.commons.reporting.CrashReportController; +import org.dhis2.mobile.commons.resources.D2ErrorMessageProvider; +import org.dhis2.mobile.commons.resources.D2ErrorMessageProviderImpl; +import org.dhis2.mobile.sync.domain.SyncStatusController; import org.dhis2.tracker.data.ProfilePictureProvider; +import org.dhis2.tracker.search.data.OptionSetRepository; +import org.dhis2.tracker.search.data.OptionSetRepositoryImpl; +import org.dhis2.tracker.search.data.SearchParametersRepository; +import org.dhis2.tracker.search.data.SearchParametersRepositoryImpl; +import org.dhis2.tracker.search.data.SearchTrackedEntityRepository; +import org.dhis2.tracker.search.data.SearchTrackedEntityRepositoryImpl; +import org.dhis2.tracker.search.domain.FetchOptionSetOptions; +import org.dhis2.tracker.search.domain.FetchSearchParameters; +import org.dhis2.tracker.search.domain.SearchTrackedEntities; import org.dhis2.ui.ThemeManager; import org.dhis2.usescases.events.EventInfoProvider; import org.dhis2.usescases.searchTrackEntity.ui.mapper.TEICardMapper; @@ -85,17 +100,21 @@ public class SearchTEModule { private final String initialProgram; private final Context moduleContext; private final Map> initialQuery; + private final SyncStatusController syncStatusController; public SearchTEModule(SearchTEContractsModule.View view, String tEType, String initialProgram, Context context, - Map> initialQuery) { + Map> initialQuery, + SyncStatusController syncStatusController + ) { this.view = view; this.teiType = tEType; this.initialProgram = initialProgram; this.moduleContext = context; this.initialQuery = initialQuery; + this.syncStatusController = syncStatusController; } @Provides @@ -113,7 +132,6 @@ SearchTEContractsModule.Presenter providePresenter(D2 d2, PreferenceProvider preferenceProvider, FilterRepository filterRepository, MatomoAnalyticsController matomoAnalyticsController, - SyncStatusController syncStatusController, ResourceManager resourceManager, ColorUtils colorUtils) { return new SearchTEPresenter(view, d2, searchRepository, schedulerProvider, @@ -150,15 +168,15 @@ SearchRepository searchRepository(@NonNull D2 d2, FilterPresenter filterPresenter, ResourceManager resources, SearchSortingValueSetter searchSortingValueSetter, - DhisPeriodUtils periodUtils, Charts charts, CrashReportController crashReportController, - NetworkUtils networkUtils, + NetworkStatusProvider networkStatusProvider, SearchTEIRepository searchTEIRepository, ThemeManager themeManager, MetadataIconProvider metadataIconProvider, DateUtils dateUtils, - CustomIntentRepository customIntentRepository) { + CustomIntentRepository customIntentRepository, + DispatcherProvider dispatcherProvider) { ProfilePictureProvider profilePictureProvider = new ProfilePictureProvider(d2); return new SearchRepositoryImpl(teiType, initialProgram, @@ -166,16 +184,16 @@ SearchRepository searchRepository(@NonNull D2 d2, filterPresenter, resources, searchSortingValueSetter, - periodUtils, charts, crashReportController, - networkUtils, + networkStatusProvider, searchTEIRepository, themeManager, metadataIconProvider, profilePictureProvider, dateUtils, - customIntentRepository); + customIntentRepository, + dispatcherProvider); } @Provides @@ -184,7 +202,6 @@ SearchRepositoryKt searchRepositoryKt( SearchRepository searchRepository, D2 d2, DispatcherProvider dispatcherProvider, - FieldViewModelFactory fieldViewModelFactory, MetadataIconProvider metadataIconProvider, ColorUtils colorUtils, DateUtils dateUtils, @@ -198,8 +215,6 @@ SearchRepositoryKt searchRepositoryKt( searchRepository, d2, dispatcherProvider, - fieldViewModelFactory, - metadataIconProvider, new TrackedEntityInstanceInfoProvider( d2, profilePictureProvider, @@ -327,7 +342,10 @@ SearchTeiViewModelFactory providesViewModelFactory( ResourceManager resourceManager, DisplayNameProvider displayNameProvider, FilterManager filterManager, - ProgramConfigurationRepository programConfigurationRepository + ProgramConfigurationRepository programConfigurationRepository, + SearchTrackedEntities searchTrackedEntities, + FetchSearchParameters fetchSearchParameters, + FetchOptionSetOptions fetchOptionSetOptions ) { return new SearchTeiViewModelFactory( searchRepository, @@ -346,7 +364,84 @@ SearchTeiViewModelFactory providesViewModelFactory( ), resourceManager, displayNameProvider, - filterManager + filterManager, + searchTrackedEntities, + fetchSearchParameters, + fetchOptionSetOptions + ); + } + + @Provides + @PerActivity + FetchSearchParameters provideFetchSearchParametersUseCase( + SearchParametersRepository searchParametersRepository + ) { + return new FetchSearchParameters( + new Dispatcher(), + searchParametersRepository + ); + } + + @Provides + @PerActivity + FetchOptionSetOptions provideFetchOptionSetOptionsUseCase( + OptionSetRepository optionSetRepository + ) { + return new FetchOptionSetOptions( + optionSetRepository + ); + } + + @Provides + @PerActivity + SearchParametersRepository provideSearchParametersRepository( + D2 d2, + CustomIntentRepository customIntentRepository, + DomainErrorMapper domainErrorMapper + ) { + return new SearchParametersRepositoryImpl( + d2, + customIntentRepository, + domainErrorMapper + ); + } + + @Provides + @PerActivity + OptionSetRepository provideOptionSetRepository( + D2 d2, + DomainErrorMapper domainErrorMapper + ) { + return new OptionSetRepositoryImpl( + d2, + domainErrorMapper + ); + } + + + + @Provides + @PerActivity + SearchTrackedEntities provideLoadSearchResultsUseCase( + SearchTrackedEntityRepository searchTrackedEntityRepository, + CustomIntentRepository customIntentRepository + ) { + return new SearchTrackedEntities( + searchTrackedEntityRepository, + customIntentRepository, + this.teiType + ); + } + + @Provides + @PerActivity + SearchTrackedEntityRepository provideLoadSearchResultsRepository( + D2 d2, + FilterPresenter filterPresenter + ) { + return new SearchTrackedEntityRepositoryImpl( + d2, + filterPresenter ); } @@ -418,4 +513,29 @@ WorkingListViewModelFactory provideWorkingListViewModelFactory( ) { return new WorkingListViewModelFactory(initialProgram, filterRepository); } + + @Provides + @PerActivity + DomainErrorMapper provideDomainErrorMapper( + D2ErrorMessageProvider d2ErrorMessageProvider, + NetworkStatusProvider networkStatusProvider + ) { + return new DomainErrorMapper( + d2ErrorMessageProvider, + networkStatusProvider + ); + } + + @Provides + @PerActivity + D2ErrorMessageProvider provideD2ErrorMessageProvider() { + return new D2ErrorMessageProviderImpl(); + } + + @Provides + @PerActivity + NetworkStatusProvider provideNetworkStatusProvider() { + + return new NetworkStatusProviderImpl(moduleContext); + } } diff --git a/app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchTEPresenter.java b/app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchTEPresenter.java index ac7cef1d4e2..f1fc82c79a4 100644 --- a/app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchTEPresenter.java +++ b/app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchTEPresenter.java @@ -27,7 +27,6 @@ import org.dhis2.commons.filters.data.FilterRepository; import org.dhis2.commons.matomo.MatomoAnalyticsController; import org.dhis2.commons.orgunitselector.OUTreeFragment; -import org.dhis2.mobile.commons.orgunit.OrgUnitSelectorScope; import org.dhis2.commons.prefs.Preference; import org.dhis2.commons.prefs.PreferenceProvider; import org.dhis2.commons.resources.ColorUtils; @@ -36,8 +35,9 @@ import org.dhis2.commons.schedulers.SchedulerProvider; import org.dhis2.commons.schedulers.SingleEventEnforcer; import org.dhis2.commons.schedulers.SingleEventEnforcerImpl; -import org.dhis2.data.service.SyncStatusController; import org.dhis2.maps.model.StageStyle; +import org.dhis2.mobile.commons.orgunit.OrgUnitSelectorScope; +import org.dhis2.mobile.sync.domain.SyncStatusController; import org.dhis2.utils.analytics.AnalyticsHelper; import org.hisp.dhis.android.core.D2; import org.hisp.dhis.android.core.common.FeatureType; @@ -84,7 +84,6 @@ public class SearchTEPresenter implements SearchTEContractsModule.Presenter { private final DisableHomeFiltersFromSettingsApp disableHomeFilters; private final MatomoAnalyticsController matomoAnalyticsController; private final SyncStatusController syncStatusController; - private final ColorUtils colorUtils; public SearchTEPresenter(SearchTEContractsModule.View view, @@ -320,7 +319,7 @@ public void enroll(String programUid, String uid, HashMap> ); } - private void enrollInOrgUnit(String orgUnitUid, String programUid, String uid, HashMap> queryData) { + private void enrollInOrgUnit(String orgUnitUid, String programUid, String uid, HashMap> queryData) { compositeDisposable.add( searchRepository.saveToEnroll(trackedEntity.uid(), orgUnitUid, programUid, uid, queryData, view.fromRelationshipTEI()) .subscribeOn(schedulerProvider.computation()) diff --git a/app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchTeiViewModelFactory.kt b/app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchTeiViewModelFactory.kt index ee636e4696e..6244a20203b 100644 --- a/app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchTeiViewModelFactory.kt +++ b/app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchTeiViewModelFactory.kt @@ -8,6 +8,9 @@ import org.dhis2.commons.resources.ResourceManager import org.dhis2.commons.viewmodel.DispatcherProvider import org.dhis2.form.ui.provider.DisplayNameProvider import org.dhis2.maps.usecases.MapStyleConfiguration +import org.dhis2.tracker.search.domain.FetchOptionSetOptions +import org.dhis2.tracker.search.domain.FetchSearchParameters +import org.dhis2.tracker.search.domain.SearchTrackedEntities class SearchTeiViewModelFactory( private val searchRepository: SearchRepository, @@ -22,6 +25,9 @@ class SearchTeiViewModelFactory( private val resourceManager: ResourceManager, private val displayNameProvider: DisplayNameProvider, private val filterManager: FilterManager, + private val searchTrackedEntities: SearchTrackedEntities, + private val fetchSearchParameters: FetchSearchParameters, + private val fetchOptionSetOptions: FetchOptionSetOptions, ) : ViewModelProvider.Factory { override fun create(modelClass: Class): T = SearchTEIViewModel( @@ -37,5 +43,8 @@ class SearchTeiViewModelFactory( resourceManager, displayNameProvider, filterManager, + searchTrackedEntities, + fetchSearchParameters, + fetchOptionSetOptions, ) as T } diff --git a/app/src/main/java/org/dhis2/usescases/searchTrackEntity/di/SearchTEKoinModule.kt b/app/src/main/java/org/dhis2/usescases/searchTrackEntity/di/SearchTEKoinModule.kt new file mode 100644 index 00000000000..710d7a9d45e --- /dev/null +++ b/app/src/main/java/org/dhis2/usescases/searchTrackEntity/di/SearchTEKoinModule.kt @@ -0,0 +1,76 @@ +package org.dhis2.usescases.searchTrackEntity.di + +import org.dhis2.usescases.searchTrackEntity.SearchRepository +import org.dhis2.usescases.searchTrackEntity.SearchTEIViewModel +import org.dhis2.usescases.searchTrackEntity.SearchTeiViewModelFactory +import org.koin.core.module.dsl.viewModel +import org.koin.core.parameter.parametersOf +import org.koin.dsl.module + +/** + * Koin module for Search Tracked Entity feature. + * This module provides dependencies for SearchTEIViewModel and related components. + * + * Note: This module coexists with the legacy Dagger SearchTEModule during the migration. + */ +val searchTEKoinModule = + module { + // SearchTeiViewModelFactory + // Factory for creating SearchTEIViewModel instances with SearchTrackedEntities + factory { params -> + val initialProgramUid: String? = params.getOrNull() + val initialQuery: MutableMap?>? = params.getOrNull() + val teType: String = params.get() // teType is required + + // Get SearchRepository from Dagger to access teType + val searchRepository: SearchRepository = get() + + SearchTeiViewModelFactory( + searchRepository = searchRepository, + searchRepositoryKt = get(), + searchNavPageConfigurator = get(), + initialProgramUid = initialProgramUid, + initialQuery = initialQuery, + mapDataRepository = get(), + networkUtils = get(), + dispatchers = get(), + mapStyleConfig = get(), + resourceManager = get(), + displayNameProvider = get(), + filterManager = get(), + searchTrackedEntities = get { parametersOf(teType) }, + fetchSearchParameters = get(), + fetchOptionSetOptions = get(), + ) + } + + // SearchTEIViewModel + // Note: This ViewModel requires parameters (initialProgramUid, initialQuery, teType) + // Use: viewModel { parametersOf(programUid, queryMap, teType) } + viewModel { params -> + val initialProgramUid: String? = params.getOrNull() + val initialQuery: MutableMap?>? = params.getOrNull() + val teType: String = params.get() // teType is required + + // Get SearchRepository from Dagger + val searchRepository: SearchRepository = get() + + SearchTEIViewModel( + initialProgramUid = initialProgramUid, + initialQuery = initialQuery, + searchRepository = searchRepository, + searchRepositoryKt = get(), + searchNavPageConfigurator = get(), + mapDataRepository = get(), + networkUtils = get(), + dispatchers = get(), + mapStyleConfig = get(), + resourceManager = get(), + displayNameProvider = get(), + filterManager = get(), + searchTrackedEntities = get { parametersOf(teType) }, + fetchSearchParameters = get(), + fetchOptionSetOptions = get(), + ) + } + } diff --git a/app/src/main/java/org/dhis2/usescases/searchTrackEntity/listView/SearchTEList.kt b/app/src/main/java/org/dhis2/usescases/searchTrackEntity/listView/SearchTEList.kt index b0193d2f4a3..e585a6c3950 100644 --- a/app/src/main/java/org/dhis2/usescases/searchTrackEntity/listView/SearchTEList.kt +++ b/app/src/main/java/org/dhis2/usescases/searchTrackEntity/listView/SearchTEList.kt @@ -266,7 +266,7 @@ class SearchTEList : FragmentGlobalAbstract() { val teTypeName by viewModel.teTypeName.observeAsState() val hasQueryData = remember(viewModel.searchParametersUiState) { - viewModel.queryData.isNotEmpty() + viewModel.queryDataList.isNotEmpty() } updateLayoutParams { diff --git a/app/src/main/java/org/dhis2/usescases/searchTrackEntity/searchparameters/SearchParametersScreen.kt b/app/src/main/java/org/dhis2/usescases/searchTrackEntity/searchparameters/SearchParametersScreen.kt deleted file mode 100644 index 3189760befc..00000000000 --- a/app/src/main/java/org/dhis2/usescases/searchTrackEntity/searchparameters/SearchParametersScreen.kt +++ /dev/null @@ -1,421 +0,0 @@ -package org.dhis2.usescases.searchTrackEntity.searchparameters - -import android.content.res.Configuration -import androidx.activity.compose.rememberLauncherForActivityResult -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.calculateEndPadding -import androidx.compose.foundation.layout.calculateStartPadding -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.itemsIndexed -import androidx.compose.foundation.shape.CornerSize -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.outlined.Cancel -import androidx.compose.material.icons.outlined.ErrorOutline -import androidx.compose.material3.Icon -import androidx.compose.material3.Scaffold -import androidx.compose.material3.SnackbarDuration -import androidx.compose.material3.SnackbarHost -import androidx.compose.material3.SnackbarHostState -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.platform.ComposeView -import androidx.compose.ui.platform.LocalConfiguration -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.platform.LocalFocusManager -import androidx.compose.ui.platform.LocalLayoutDirection -import androidx.compose.ui.platform.testTag -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.dp -import com.journeyapps.barcodescanner.ScanOptions -import kotlinx.coroutines.flow.collectLatest -import kotlinx.coroutines.launch -import org.dhis2.R -import org.dhis2.commons.Constants -import org.dhis2.commons.resources.ColorUtils -import org.dhis2.commons.resources.ResourceManager -import org.dhis2.form.data.scan.ScanContract -import org.dhis2.form.model.FieldUiModel -import org.dhis2.form.model.FieldUiModelImpl -import org.dhis2.form.ui.event.RecyclerViewUiEvents -import org.dhis2.form.ui.intent.FormIntent -import org.dhis2.mobile.commons.orgunit.OrgUnitSelectorScope -import org.dhis2.usescases.searchTrackEntity.SearchTEIViewModel -import org.dhis2.usescases.searchTrackEntity.searchparameters.model.SearchParametersUiState -import org.dhis2.usescases.searchTrackEntity.searchparameters.provider.provideParameterSelectorItem -import org.hisp.dhis.android.core.common.ValueType -import org.hisp.dhis.mobile.ui.designsystem.component.AdditionalInfoItemColor -import org.hisp.dhis.mobile.ui.designsystem.component.Button -import org.hisp.dhis.mobile.ui.designsystem.component.ButtonStyle -import org.hisp.dhis.mobile.ui.designsystem.component.InfoBar -import org.hisp.dhis.mobile.ui.designsystem.component.parameter.ParameterSelectorItem -import org.hisp.dhis.mobile.ui.designsystem.theme.Radius -import org.hisp.dhis.mobile.ui.designsystem.theme.Shape -import org.hisp.dhis.mobile.ui.designsystem.theme.SurfaceColor -import org.hisp.dhis.mobile.ui.designsystem.theme.TextColor - -@Composable -fun SearchParametersScreen( - resourceManager: ResourceManager, - uiState: SearchParametersUiState, - intentHandler: (FormIntent) -> Unit, - onShowOrgUnit: ( - uid: String, - preselectedOrgUnits: List, - orgUnitScope: OrgUnitSelectorScope, - label: String, - ) -> Unit, - onSearch: () -> Unit, - onClear: () -> Unit, - onClose: () -> Unit, -) { - val snackBarHostState = remember { SnackbarHostState() } - val coroutineScope = rememberCoroutineScope() - val focusManager = LocalFocusManager.current - val configuration = LocalConfiguration.current - - val scanContract = remember { ScanContract() } - val qrScanLauncher = - rememberLauncherForActivityResult( - contract = scanContract, - ) { result -> - result.contents?.let { qrData -> - val intent = - FormIntent.OnQrCodeScanned( - uid = result.originalIntent.getStringExtra(Constants.UID)!!, - value = qrData, - valueType = ValueType.TEXT, - ) - intentHandler(intent) - } - } - - val callback = - remember { - object : FieldUiModel.Callback { - override fun intent(intent: FormIntent) { - intentHandler.invoke(intent) - } - - override fun recyclerViewUiEvents(uiEvent: RecyclerViewUiEvents) { - when (uiEvent) { - is RecyclerViewUiEvents.OpenOrgUnitDialog -> - onShowOrgUnit( - uiEvent.uid, - uiEvent.value?.let { listOf(it) } ?: emptyList(), - uiEvent.orgUnitSelectorScope ?: OrgUnitSelectorScope.UserSearchScope(), - uiEvent.label, - ) - - is RecyclerViewUiEvents.ScanQRCode -> { - qrScanLauncher.launch( - ScanOptions().apply { - setDesiredBarcodeFormats() - setPrompt("") - setBeepEnabled(true) - setBarcodeImageEnabled(false) - addExtra(Constants.UID, uiEvent.uid) - uiEvent.optionSet?.let { - addExtra( - Constants.OPTION_SET, - uiEvent.optionSet, - ) - } - addExtra(Constants.SCAN_RENDERING_TYPE, uiEvent.renderingType) - }, - ) - } - - else -> { - // no-op - } - } - } - } - } - - uiState.minAttributesMessage?.let { message -> - coroutineScope.launch { - uiState.shouldShowMinAttributeWarning.collectLatest { - if (it) { - snackBarHostState.showSnackbar( - message = message, - duration = SnackbarDuration.Short, - ) - uiState.updateMinAttributeWarning(false) - } - } - } - } - - LaunchedEffect(uiState.isOnBackPressed) { - uiState.isOnBackPressed.collectLatest { - if (it) { - focusManager.clearFocus() - onClose() - } - } - } - - val backgroundShape = - when (configuration.orientation) { - Configuration.ORIENTATION_LANDSCAPE -> - RoundedCornerShape( - topStart = CornerSize(Radius.L), - topEnd = CornerSize(Radius.NoRounding), - bottomEnd = CornerSize(Radius.NoRounding), - bottomStart = CornerSize(Radius.NoRounding), - ) - - else -> Shape.LargeTop - } - - Scaffold( - containerColor = Color.Transparent, - snackbarHost = { - SnackbarHost( - hostState = snackBarHostState, - modifier = - Modifier.padding( - start = 8.dp, - top = 8.dp, - end = 8.dp, - bottom = 48.dp, - ), - ) - }, - ) { paddingValues -> - val layoutDirection = LocalLayoutDirection.current - Column( - modifier = - Modifier - .fillMaxSize() - .background(color = Color.White, shape = backgroundShape) - .padding( - top = 0.dp, - bottom = paddingValues.calculateBottomPadding(), - start = paddingValues.calculateStartPadding(layoutDirection), - end = paddingValues.calculateEndPadding(layoutDirection), - ), - ) { - LazyColumn( - modifier = - Modifier - .weight(1F), - ) { - if (uiState.items.isEmpty()) { - item { - Box( - modifier = - Modifier - .fillMaxWidth() - .padding(16.dp), - contentAlignment = Alignment.Center, - ) { - InfoBar( - modifier = Modifier.testTag("EMPTY_SEARCH_ATTRIBUTES_TEXT_TAG"), - text = resourceManager.getString(R.string.empty_search_attributes_message), - icon = { - Icon( - imageVector = Icons.Outlined.ErrorOutline, - contentDescription = "warning", - tint = AdditionalInfoItemColor.WARNING.color, - ) - }, - textColor = AdditionalInfoItemColor.WARNING.color, - backgroundColor = AdditionalInfoItemColor.WARNING.color.copy(alpha = 0.1f), - ) - } - } - } else { - itemsIndexed( - items = uiState.items, - key = { _, fieldUiModel -> - fieldUiModel.uid - }, - ) { index, fieldUiModel -> - fieldUiModel.setCallback(callback) - ParameterSelectorItem( - modifier = - Modifier - .testTag("SEARCH_PARAM_ITEM"), - model = - provideParameterSelectorItem( - resources = resourceManager, - focusManager = focusManager, - fieldUiModel = fieldUiModel, - callback = callback, - onNextClicked = { - val nextIndex = index + 1 - if (nextIndex < uiState.items.size) { - uiState.items[nextIndex].onItemClick() - } - }, - ), - ) - } - } - - if (uiState.clearSearchEnabled) { - item { - Box( - modifier = Modifier.fillMaxWidth(), - contentAlignment = Alignment.Center, - ) { - Button( - modifier = Modifier.padding(16.dp, 24.dp, 16.dp, 8.dp), - style = ButtonStyle.TEXT, - text = resourceManager.getString(R.string.clear_search), - icon = { - Icon( - imageVector = Icons.Outlined.Cancel, - contentDescription = resourceManager.getString(R.string.clear_search), - tint = SurfaceColor.Primary, - ) - }, - ) { - focusManager.clearFocus() - onClear() - } - } - } - } - } - - Button( - enabled = uiState.searchEnabled, - modifier = - Modifier - .fillMaxWidth() - .padding(16.dp, 8.dp, 16.dp, 8.dp) - .testTag("SEARCH_BUTTON"), - style = ButtonStyle.FILLED, - text = resourceManager.getString(R.string.search), - icon = { - val iconTint = - if (uiState.searchEnabled) { - TextColor.OnPrimary - } else { - TextColor.OnDisabledSurface - } - - Icon( - painter = painterResource(id = R.drawable.ic_search), - contentDescription = null, - tint = iconTint, - ) - }, - ) { - focusManager.clearFocus() - onSearch() - } - } - } -} - -@Preview(showBackground = true) -@Composable -fun SearchFormPreview() { - SearchParametersScreen( - resourceManager = ResourceManager(LocalContext.current, ColorUtils()), - uiState = - SearchParametersUiState( - items = - buildList { - repeat(times = 20) { index -> - add( - FieldUiModelImpl( - uid = "uid$index", - label = "Label $index", - autocompleteList = emptyList(), - optionSetConfiguration = null, - valueType = ValueType.TEXT, - ), - ) - } - }, - ), - intentHandler = {}, - onShowOrgUnit = { _, _, _, _ -> }, - onSearch = {}, - onClear = {}, - onClose = {}, - ) -} - -@Preview(showBackground = true) -@Composable -fun SearchFormPreviewWithClear() { - SearchParametersScreen( - resourceManager = ResourceManager(LocalContext.current, ColorUtils()), - uiState = - SearchParametersUiState( - items = - buildList { - repeat(times = 20) { index -> - add( - FieldUiModelImpl( - uid = "uid$index", - label = "Label $index", - value = "test value", - autocompleteList = emptyList(), - optionSetConfiguration = null, - valueType = ValueType.TEXT, - ), - ) - } - }, - ), - intentHandler = {}, - onShowOrgUnit = { _, _, _, _ -> }, - onSearch = {}, - onClear = {}, - onClose = {}, - ) -} - -fun initSearchScreen( - composeView: ComposeView, - viewModel: SearchTEIViewModel, - program: String?, - teiType: String, - resources: ResourceManager, - onShowOrgUnit: ( - uid: String, - preselectedOrgUnits: List, - orgUnitScope: OrgUnitSelectorScope, - label: String, - ) -> Unit, - onClear: () -> Unit, -) { - viewModel.fetchSearchParameters( - programUid = program, - teiTypeUid = teiType, - ) - composeView.setContent { - SearchParametersScreen( - resourceManager = resources, - uiState = viewModel.searchParametersUiState, - onSearch = viewModel::onSearch, - intentHandler = viewModel::onParameterIntent, - onShowOrgUnit = onShowOrgUnit, - onClear = { - onClear() - viewModel.clearQueryData() - viewModel.clearFocus() - }, - onClose = { viewModel.clearFocus() }, - ) - } -} diff --git a/app/src/main/java/org/dhis2/usescases/searchTrackEntity/searchparameters/SearchParametersScreenProvider.kt b/app/src/main/java/org/dhis2/usescases/searchTrackEntity/searchparameters/SearchParametersScreenProvider.kt new file mode 100644 index 00000000000..d65d6591b1e --- /dev/null +++ b/app/src/main/java/org/dhis2/usescases/searchTrackEntity/searchparameters/SearchParametersScreenProvider.kt @@ -0,0 +1,175 @@ +package org.dhis2.usescases.searchTrackEntity.searchparameters + +import android.content.res.Configuration +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.compose.runtime.remember +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.LocalConfiguration +import com.journeyapps.barcodescanner.ScanOptions +import org.dhis2.commons.Constants +import org.dhis2.commons.resources.ResourceManager +import org.dhis2.form.R +import org.dhis2.form.data.scan.ScanContract +import org.dhis2.form.ui.customintent.CustomIntentActivityResultContract +import org.dhis2.form.ui.customintent.CustomIntentInput +import org.dhis2.mobile.commons.extensions.ObserveAsEvents +import org.dhis2.mobile.commons.orgunit.OrgUnitSelectorScope +import org.dhis2.tracker.input.ui.action.TrackerInputAction +import org.dhis2.tracker.input.ui.action.TrackerInputUiEvent +import org.dhis2.tracker.search.ui.action.SearchScreenUiEvent +import org.dhis2.tracker.search.ui.screen.SearchParametersScreen +import org.dhis2.usescases.searchTrackEntity.SearchTEIViewModel + +fun provideSearchScreen( + composeView: ComposeView, + viewModel: SearchTEIViewModel, + program: String?, + teiType: String, + resources: ResourceManager, + onShowOrgUnit: ( + uid: String, + preselectedOrgUnits: List, + orgUnitScope: OrgUnitSelectorScope, + label: String, + ) -> Unit, + onClear: () -> Unit, +) { + viewModel.fetchSearchParameters( + programUid = program, + teiTypeUid = teiType, + ) + composeView.setContent { + val customIntentlauncher = + rememberLauncherForActivityResult( + contract = CustomIntentActivityResultContract(), + onResult = viewModel::handleCustomIntentResult, + ) + + val scanContract = remember { ScanContract() } + + val qrScanLauncher = + rememberLauncherForActivityResult( + contract = scanContract, + ) { result -> + result.contents?.let { qrData -> + viewModel.handleScanResult( + fieldUid = result.originalIntent.getStringExtra(Constants.UID)!!, + value = qrData, + ) + } + } + + ObserveAsEvents( + flow = viewModel.searchActions, + ) { action -> + when (action) { + is TrackerInputAction.LaunchCustomIntent -> { + customIntentlauncher.launch( + with(action) { + CustomIntentInput( + fieldUid = fieldUid, + customIntent = customIntentModel, + defaultTitle = + customIntentModel.name + ?: resources.getString(R.string.select_app_intent), + ) + }, + ) + } + + is TrackerInputAction.Scan -> { + with(action) { + qrScanLauncher.launch( + ScanOptions().apply { + setDesiredBarcodeFormats() + setPrompt("") + setBeepEnabled(true) + setBarcodeImageEnabled(false) + addExtra(Constants.UID, fieldUid) + optionSet?.let { + addExtra( + Constants.OPTION_SET, + it, + ) + } + addExtra( + Constants.SCAN_RENDERING_TYPE, + renderType, + ) + }, + ) + } + } + + is TrackerInputAction.ValueChanged -> { + viewModel.onValueChange( + fieldUid = action.fieldUid, + value = action.value, + ) + } + } + } + + val configuration = LocalConfiguration.current + + val isLandscape = configuration.orientation == Configuration.ORIENTATION_LANDSCAPE + SearchParametersScreen( + externalUiState = viewModel.searchParametersUiState, + onSearchScreenUiEvent = { + when (it) { + is SearchScreenUiEvent.OnSearchButtonClicked -> + viewModel.onSearch() + + is SearchScreenUiEvent.OnClearSearchButtonClicked -> { + onClear() + viewModel.clearQueryData() + viewModel.clearFocus() + } + + is SearchScreenUiEvent.OnCloseClicked -> viewModel.clearFocus() + } + }, + isLandscape = isLandscape, + onTrackerInputUiEvent = { + when (it) { + is TrackerInputUiEvent.OnScanButtonClicked -> + viewModel.launchScan( + it.uid, + it.optionSet, + it.renderType, + ) + + is TrackerInputUiEvent.OnOrgUnitButtonClicked -> + onShowOrgUnit( + it.uid, + it.value?.let { listOf(it) } + ?: emptyList(), + it.orgUnitSelectorScope + ?: OrgUnitSelectorScope.UserSearchScope(), + it.label, + ) + + is TrackerInputUiEvent.OnLaunchCustomIntent -> + viewModel.launchCustomIntent( + it.uid, + it.customIntentUid, + ) + + is TrackerInputUiEvent.OnItemClick -> viewModel.onItemClick(it.uid) + + is TrackerInputUiEvent.OnValueChange -> + viewModel.onValueChange( + fieldUid = it.uid, + value = it.value, + ) + } + }, + getOptionSetFlow = { fieldUid, optionSetUid -> + viewModel.getOptionSetFlow(fieldUid, optionSetUid) + }, + onOptionSetSearch = { fieldUid, query -> + viewModel.onOptionSetSearch(fieldUid, query) + }, + ) + } +} diff --git a/app/src/main/java/org/dhis2/usescases/searchTrackEntity/searchparameters/provider/ParameterSelectorItemProvider.kt b/app/src/main/java/org/dhis2/usescases/searchTrackEntity/searchparameters/provider/ParameterSelectorItemProvider.kt deleted file mode 100644 index 1709f044e45..00000000000 --- a/app/src/main/java/org/dhis2/usescases/searchTrackEntity/searchparameters/provider/ParameterSelectorItemProvider.kt +++ /dev/null @@ -1,138 +0,0 @@ -package org.dhis2.usescases.searchTrackEntity.searchparameters.provider - -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.outlined.AddCircleOutline -import androidx.compose.material.icons.outlined.QrCode2 -import androidx.compose.material3.Icon -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.remember -import androidx.compose.ui.Modifier -import androidx.compose.ui.focus.FocusManager -import androidx.compose.ui.focus.FocusRequester -import androidx.compose.ui.focus.focusRequester -import org.dhis2.R -import org.dhis2.commons.resources.ResourceManager -import org.dhis2.form.model.FieldUiModel -import org.dhis2.form.model.UiRenderType -import org.dhis2.form.ui.event.RecyclerViewUiEvents -import org.dhis2.form.ui.provider.inputfield.FieldProvider -import org.hisp.dhis.android.core.common.ValueType -import org.hisp.dhis.mobile.ui.designsystem.component.InputStyle -import org.hisp.dhis.mobile.ui.designsystem.component.parameter.model.ParameterSelectorItemModel -import org.hisp.dhis.mobile.ui.designsystem.resource.provideDHIS2Icon -import org.hisp.dhis.mobile.ui.designsystem.theme.SurfaceColor - -@Composable -fun provideParameterSelectorItem( - resources: ResourceManager, - focusManager: FocusManager, - fieldUiModel: FieldUiModel, - callback: FieldUiModel.Callback, - onNextClicked: () -> Unit, -): ParameterSelectorItemModel { - val focusRequester = remember { FocusRequester() } - - val status = - if (fieldUiModel.focused) { - ParameterSelectorItemModel.Status.FOCUSED - } else if (fieldUiModel.value.isNullOrEmpty()) { - ParameterSelectorItemModel.Status.CLOSED - } else { - ParameterSelectorItemModel.Status.UNFOCUSED - } - - LaunchedEffect(key1 = status) { - if (status == ParameterSelectorItemModel.Status.FOCUSED) { - focusRequester.requestFocus() - } - } - - return ParameterSelectorItemModel( - icon = { ProvideIcon(fieldUiModel.valueType, fieldUiModel.renderingType) }, - label = fieldUiModel.label, - helper = resources.getString(R.string.optional), - inputField = { - FieldProvider( - modifier = - Modifier - .focusRequester(focusRequester), - inputStyle = InputStyle.ParameterInputStyle(), - fieldUiModel = fieldUiModel, - uiEventHandler = callback::recyclerViewUiEvents, - intentHandler = callback::intent, - resources = resources, - focusManager = focusManager, - onNextClicked = onNextClicked, - onFileSelected = { - // Not supported for search - }, - reEvaluateCustomIntentRequestParameters = false, - ) - }, - status = status, - onExpand = { - performOnExpandActions(fieldUiModel, callback) - }, - ) -} - -private fun performOnExpandActions( - fieldUiModel: FieldUiModel, - callback: FieldUiModel.Callback, -) { - fieldUiModel.onItemClick() - - if (fieldUiModel.renderingType == UiRenderType.QR_CODE || - fieldUiModel.renderingType == UiRenderType.BAR_CODE - ) { - callback.recyclerViewUiEvents( - RecyclerViewUiEvents.ScanQRCode( - uid = fieldUiModel.uid, - optionSet = fieldUiModel.optionSet, - renderingType = fieldUiModel.renderingType, - ), - ) - } -} - -@Composable -private fun ProvideIcon( - valueType: ValueType?, - renderingType: UiRenderType?, -) = when (valueType) { - ValueType.TEXT -> { - when (renderingType) { - UiRenderType.QR_CODE, UiRenderType.GS1_DATAMATRIX -> { - Icon( - imageVector = Icons.Outlined.QrCode2, - contentDescription = "Icon Button", - tint = SurfaceColor.Primary, - ) - } - - UiRenderType.BAR_CODE -> { - Icon( - painter = provideDHIS2Icon("material_barcode_scanner"), - contentDescription = "Icon Button", - tint = SurfaceColor.Primary, - ) - } - - else -> { - Icon( - imageVector = Icons.Outlined.AddCircleOutline, - contentDescription = "Icon Button", - tint = SurfaceColor.Primary, - ) - } - } - } - - else -> - Icon( - imageVector = Icons.Outlined.AddCircleOutline, - contentDescription = "Icon Button", - tint = SurfaceColor.Primary, - ) -} diff --git a/app/src/main/java/org/dhis2/usescases/searchTrackEntity/ui/mapper/TEICardMapper.kt b/app/src/main/java/org/dhis2/usescases/searchTrackEntity/ui/mapper/TEICardMapper.kt index 0ff1b4721e9..794ba07e04d 100644 --- a/app/src/main/java/org/dhis2/usescases/searchTrackEntity/ui/mapper/TEICardMapper.kt +++ b/app/src/main/java/org/dhis2/usescases/searchTrackEntity/ui/mapper/TEICardMapper.kt @@ -148,7 +148,7 @@ class TEICardMapper( ownerOrgUnit = searchTEIModel.ownerOrgUnit, ) } - if (searchTEIModel.displayOrgUnit) { + if (searchTEIModel.displayOrgUnit && searchTEIModel.enrolledOrgUnit != null) { checkEnrolledIn( list = list, enrolledOrgUnit = searchTEIModel.enrolledOrgUnit, diff --git a/app/src/main/java/org/dhis2/usescases/settings/DeleteUserData.kt b/app/src/main/java/org/dhis2/usescases/settings/DeleteUserData.kt index 42f9f2f0762..1d8bbcf8856 100644 --- a/app/src/main/java/org/dhis2/usescases/settings/DeleteUserData.kt +++ b/app/src/main/java/org/dhis2/usescases/settings/DeleteUserData.kt @@ -1,17 +1,22 @@ package org.dhis2.usescases.settings +import kotlinx.coroutines.withContext import org.dhis2.commons.filters.FilterManager import org.dhis2.commons.prefs.PreferenceProvider +import org.dhis2.commons.viewmodel.DispatcherProvider import org.dhis2.data.service.workManager.WorkManagerController import java.io.File -class DeleteUserData( +class DeleteUserData( private val workManagerController: WorkManagerController, private val filterManager: FilterManager, private val preferencesProvider: PreferenceProvider, + private val dispatcherProvider: DispatcherProvider, ) { - fun wipeCacheAndPreferences(file: File?) { - filterManager.clearAllFilters() + suspend fun wipeCacheAndPreferences(file: File?) { + withContext(dispatcherProvider.ui()) { + filterManager.clearAllFilters() + } workManagerController.cancelAllWork() workManagerController.pruneWork() if (file != null) { diff --git a/app/src/main/java/org/dhis2/usescases/settings/SettingsRepository.kt b/app/src/main/java/org/dhis2/usescases/settings/SettingsRepository.kt index 6c620134340..e3206c93de6 100644 --- a/app/src/main/java/org/dhis2/usescases/settings/SettingsRepository.kt +++ b/app/src/main/java/org/dhis2/usescases/settings/SettingsRepository.kt @@ -1,5 +1,6 @@ package org.dhis2.usescases.settings +import android.text.format.DateFormat import io.reactivex.Single import org.dhis2.BuildConfig import org.dhis2.bindings.toSeconds @@ -15,6 +16,7 @@ import org.dhis2.commons.prefs.Preference.Companion.TIME_DAILY import org.dhis2.commons.prefs.Preference.Companion.TIME_WEEKLY import org.dhis2.commons.prefs.PreferenceProvider import org.dhis2.data.service.SyncResult +import org.dhis2.mobile.sync.data.SyncBackgroundJobAction import org.dhis2.usescases.settings.models.DataSettingsViewModel import org.dhis2.usescases.settings.models.MetadataSettingsViewModel import org.dhis2.usescases.settings.models.ReservedValueSettingsViewModel @@ -29,10 +31,13 @@ import org.hisp.dhis.android.core.settings.SynchronizationSettings import org.hisp.dhis.android.core.sms.domain.interactor.ConfigCase import timber.log.Timber +private const val dateTimeFormat = "dd/MM/yyyy HH:mm" + class SettingsRepository( val d2: D2, val prefs: PreferenceProvider, val featureConfigRepository: FeatureConfigRepository, + private val syncBackgroundJobAction: SyncBackgroundJobAction, ) { private val syncSettings: SynchronizationSettings? get() = @@ -49,12 +54,7 @@ class SettingsRepository( null } private val programSettings: ProgramSettings? - get() = - if (d2.settingModule().programSetting().blockingExists()) { - d2.settingModule().programSetting().blockingGet() - } else { - null - } + get() = syncSettings?.programSettings() private val smsConfig: ConfigCase.SmsConfig get() = d2 @@ -68,6 +68,9 @@ class SettingsRepository( DataSettingsViewModel( dataSyncPeriod = dataPeriod(), lastDataSync = prefs.getString(Constants.LAST_DATA_SYNC, "-")!!, + nextDataSync = syncBackgroundJobAction.getNextDataSync()?.let { + DateFormat.format(dateTimeFormat, it).toString() + }, syncHasErrors = !prefs.getBoolean(Constants.LAST_DATA_SYNC_STATUS, true), dataHasErrors = dataHasErrors(), dataHasWarnings = dataHasWarning(), @@ -85,6 +88,12 @@ class SettingsRepository( MetadataSettingsViewModel( metadataSyncPeriod = metadataPeriod(), lastMetadataSync = prefs.getString(Constants.LAST_META_SYNC, "-")!!, + nextMetadataSync = syncBackgroundJobAction.getNextMetadataSync()?.let { + DateFormat.format(dateTimeFormat, it).toString() + }, + nextSettingsSync = syncBackgroundJobAction.getNextSettingsSync()?.let { + DateFormat.format(dateTimeFormat, it).toString() + }, hasErrors = !prefs.getBoolean(Constants.LAST_META_SYNC_STATUS, true), canEdit = syncSettings?.metadataSync() == null, syncInProgress = false, @@ -179,13 +188,13 @@ class SettingsRepository( .isNotEmpty() private fun metadataPeriod(): Int = - generalSettings?.metadataSync()?.toSeconds() ?: prefs.getInt( + syncSettings?.metadataSync()?.toSeconds() ?: prefs.getInt( Preference.TIME_META, TIME_WEEKLY, ) private fun dataPeriod(): Int = - generalSettings?.dataSync()?.toSeconds() ?: prefs.getInt( + syncSettings?.dataSync()?.toSeconds() ?: prefs.getInt( Preference.TIME_DATA, TIME_DAILY, ) @@ -231,14 +240,11 @@ class SettingsRepository( private fun getLimitedScopeFromPreferences(): LimitScope { val byOrgUnit = prefs.getBoolean(Constants.LIMIT_BY_ORG_UNIT, false) val byProgram = prefs.getBoolean(Constants.LIMIT_BY_PROGRAM, false) - return if (byOrgUnit && !byProgram) { - LimitScope.PER_ORG_UNIT - } else if (!byOrgUnit && byProgram) { - LimitScope.PER_PROGRAM - } else if (byOrgUnit && byProgram) { - LimitScope.PER_OU_AND_PROGRAM - } else { - LimitScope.GLOBAL + return when { + byOrgUnit && byProgram -> LimitScope.PER_OU_AND_PROGRAM + byOrgUnit && !byProgram -> LimitScope.PER_ORG_UNIT + !byOrgUnit && byProgram -> LimitScope.PER_PROGRAM + else -> LimitScope.GLOBAL } } @@ -253,6 +259,7 @@ class SettingsRepository( suspend fun saveLimitScope(limitScope: LimitScope) { when (limitScope) { LimitScope.ALL_ORG_UNITS -> { + // Do nothing } LimitScope.GLOBAL -> { @@ -289,7 +296,7 @@ class SettingsRepository( .setGatewayNumber(gatewayNumber) .blockingAwait() } catch (e: Exception) { - Timber.d(e.message) + Timber.d(e) } } diff --git a/app/src/main/java/org/dhis2/usescases/settings/SyncManagerFragment.kt b/app/src/main/java/org/dhis2/usescases/settings/SyncManagerFragment.kt index e744c147084..77922793602 100644 --- a/app/src/main/java/org/dhis2/usescases/settings/SyncManagerFragment.kt +++ b/app/src/main/java/org/dhis2/usescases/settings/SyncManagerFragment.kt @@ -17,12 +17,14 @@ import org.dhis2.commons.data.FormFileProvider import org.dhis2.commons.data.FormFileProvider.init import org.dhis2.commons.resources.ColorUtils import org.dhis2.mobile.login.authentication.TwoFASettingsActivity +import org.dhis2.mobile.sync.data.SyncBackgroundJobAction import org.dhis2.usescases.general.FragmentGlobalAbstract import org.dhis2.usescases.reservedValue.ReservedValueActivity import org.dhis2.usescases.settings.models.ErrorViewModel import org.dhis2.usescases.settings.ui.SettingsScreen import org.dhis2.usescases.settingsprogram.SettingsProgramActivity import org.hisp.dhis.mobile.ui.designsystem.theme.DHIS2Theme +import org.koin.android.ext.android.inject import timber.log.Timber import java.io.File import javax.inject.Inject @@ -32,6 +34,7 @@ class SyncManagerFragment : FragmentGlobalAbstract() { lateinit var settingsViewModelFactory: SettingsViewModelFactory private val presenter: SyncManagerPresenter by viewModels { settingsViewModelFactory } + private val syncBackgroundJobAction: SyncBackgroundJobAction by inject() @JvmField @Inject @@ -41,7 +44,7 @@ class SyncManagerFragment : FragmentGlobalAbstract() { super.onAttach(context) app() .userComponent() - ?.plus(SyncManagerModule()) + ?.plus(SyncManagerModule(syncBackgroundJobAction)) ?.inject(this) } diff --git a/app/src/main/java/org/dhis2/usescases/settings/SyncManagerModule.kt b/app/src/main/java/org/dhis2/usescases/settings/SyncManagerModule.kt index 2ca357252eb..c1819f82b8e 100644 --- a/app/src/main/java/org/dhis2/usescases/settings/SyncManagerModule.kt +++ b/app/src/main/java/org/dhis2/usescases/settings/SyncManagerModule.kt @@ -10,8 +10,8 @@ import org.dhis2.commons.prefs.PreferenceProvider import org.dhis2.commons.resources.ResourceManager import org.dhis2.commons.viewmodel.DispatcherProvider import org.dhis2.data.service.VersionRepository -import org.dhis2.data.service.workManager.WorkManagerController import org.dhis2.mobile.commons.files.FileHandlerImpl +import org.dhis2.mobile.sync.data.SyncBackgroundJobAction import org.dhis2.usescases.settings.domain.CheckVersionUpdate import org.dhis2.usescases.settings.domain.DeleteLocalData import org.dhis2.usescases.settings.domain.ExportDatabase @@ -27,7 +27,9 @@ import org.dhis2.utils.analytics.AnalyticsHelper import org.hisp.dhis.android.core.D2 @Module -class SyncManagerModule { +class SyncManagerModule( + private val syncBackgroundJobAction: SyncBackgroundJobAction, +) { @Provides @PerFragment fun provideViewModelFactory( @@ -150,11 +152,10 @@ class SyncManagerModule { @Provides @PerFragment fun provideLaunchSync( - workManagerController: WorkManagerController, preferenceProvider: PreferenceProvider, analyticsHelper: AnalyticsHelper, ) = LaunchSync( - workManagerController, + syncBackgroundJobAction, preferenceProvider, analyticsHelper, ) @@ -170,6 +171,7 @@ class SyncManagerModule { d2, preferenceProvider, featureConfigRepository, + syncBackgroundJobAction, ) @Provides diff --git a/app/src/main/java/org/dhis2/usescases/settings/SyncManagerPresenter.kt b/app/src/main/java/org/dhis2/usescases/settings/SyncManagerPresenter.kt index ae5c98a3f97..d33719c18ed 100644 --- a/app/src/main/java/org/dhis2/usescases/settings/SyncManagerPresenter.kt +++ b/app/src/main/java/org/dhis2/usescases/settings/SyncManagerPresenter.kt @@ -12,7 +12,6 @@ import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch -import org.dhis2.commons.Constants import org.dhis2.commons.network.NetworkUtils import org.dhis2.commons.viewmodel.DispatcherProvider import org.dhis2.usescases.settings.domain.CheckVersionUpdate @@ -28,6 +27,7 @@ import org.dhis2.usescases.settings.domain.UpdateSyncSettings import org.dhis2.usescases.settings.models.DeleteDataState import org.dhis2.usescases.settings.models.ErrorViewModel import org.dhis2.usescases.settings.models.SettingsState +import org.dhis2.usescases.settings.models.SyncStateInput import org.hisp.dhis.android.core.settings.LimitScope import java.io.File @@ -114,14 +114,21 @@ class SyncManagerPresenter( } private suspend fun loadData() { - val settingsState = - getSettingsState( + getSettingsState( + SyncStateInput( openedItem = _settingsState.value?.openedItem, hasConnection = _settingsState.value?.hasConnection == true, metadataSyncInProgress = syncWorkInfo.value.metadataSyncProgress == LaunchSync.SyncStatus.InProgress, dataSyncInProgress = syncWorkInfo.value.dataSyncProgress == LaunchSync.SyncStatus.InProgress, - ) - _settingsState.update { settingsState } + ), + ).fold( + onSuccess = { settingsState -> + _settingsState.update { settingsState } + }, + onFailure = { + // do nothing + }, + ) } fun onItemClick(settingsItem: SettingItem) { @@ -361,18 +368,14 @@ class SyncManagerPresenter( fun onSyncDataPeriodChanged(period: Int) { viewModelScope.launch(dispatcherProvider.io()) { launchSync(LaunchSync.SyncAction.UpdateSyncDataPeriod(period)) - if (period == Constants.TIME_MANUAL) { - loadData() - } + loadData() } } fun onSyncMetaPeriodChanged(period: Int) { viewModelScope.launch(dispatcherProvider.io()) { launchSync(LaunchSync.SyncAction.UpdateSyncMetadataPeriod(period)) - if (period == Constants.TIME_MANUAL) { - loadData() - } + loadData() } } diff --git a/app/src/main/java/org/dhis2/usescases/settings/domain/GetSettingsState.kt b/app/src/main/java/org/dhis2/usescases/settings/domain/GetSettingsState.kt index 106706ebb74..cb5f700318f 100644 --- a/app/src/main/java/org/dhis2/usescases/settings/domain/GetSettingsState.kt +++ b/app/src/main/java/org/dhis2/usescases/settings/domain/GetSettingsState.kt @@ -1,40 +1,42 @@ package org.dhis2.usescases.settings.domain +import org.dhis2.mobile.commons.domain.UseCase import org.dhis2.usescases.settings.GatewayValidator -import org.dhis2.usescases.settings.SettingItem import org.dhis2.usescases.settings.SettingsRepository import org.dhis2.usescases.settings.models.SettingsState +import org.dhis2.usescases.settings.models.SyncStateInput class GetSettingsState( private val settingsRepository: SettingsRepository, private val gatewayValidator: GatewayValidator, -) { - suspend operator fun invoke( - openedItem: SettingItem?, - hasConnection: Boolean, - metadataSyncInProgress: Boolean, - dataSyncInProgress: Boolean, - ): SettingsState = - SettingsState( - openedItem = openedItem, - hasConnection = hasConnection, - metadataSettingsViewModel = - settingsRepository.metaSync().blockingGet().copy( - syncInProgress = metadataSyncInProgress, - ), - dataSettingsViewModel = - settingsRepository.dataSync().blockingGet().copy( - syncInProgress = dataSyncInProgress, - ), - syncParametersViewModel = settingsRepository.syncParameters().blockingGet(), - reservedValueSettingsViewModel = settingsRepository.reservedValues().blockingGet(), - smsSettingsViewModel = - with(settingsRepository.sms().blockingGet()) { - copy( - gatewayValidationResult = gatewayValidator(this.gatewayNumber), - ) - }, - isTwoFAConfigured = settingsRepository.isTwoFAConfigured(), - versionName = settingsRepository.getVersionName(), - ) +) : UseCase { + override suspend fun invoke(input: SyncStateInput) = + try { + val state = + SettingsState( + openedItem = input.openedItem, + hasConnection = input.hasConnection, + metadataSettingsViewModel = + settingsRepository.metaSync().blockingGet().copy( + syncInProgress = input.metadataSyncInProgress, + ), + dataSettingsViewModel = + settingsRepository.dataSync().blockingGet().copy( + syncInProgress = input.dataSyncInProgress, + ), + syncParametersViewModel = settingsRepository.syncParameters().blockingGet(), + reservedValueSettingsViewModel = settingsRepository.reservedValues().blockingGet(), + smsSettingsViewModel = + with(settingsRepository.sms().blockingGet()) { + copy( + gatewayValidationResult = gatewayValidator(this.gatewayNumber), + ) + }, + isTwoFAConfigured = settingsRepository.isTwoFAConfigured(), + versionName = settingsRepository.getVersionName(), + ) + Result.success(state) + } catch (e: Exception) { + Result.failure(e) + } } diff --git a/app/src/main/java/org/dhis2/usescases/settings/domain/LaunchSync.kt b/app/src/main/java/org/dhis2/usescases/settings/domain/LaunchSync.kt index 11b0e0286c3..20d213fb8c1 100644 --- a/app/src/main/java/org/dhis2/usescases/settings/domain/LaunchSync.kt +++ b/app/src/main/java/org/dhis2/usescases/settings/domain/LaunchSync.kt @@ -1,27 +1,25 @@ package org.dhis2.usescases.settings.domain -import androidx.lifecycle.asFlow -import androidx.work.ExistingPeriodicWorkPolicy -import androidx.work.ExistingWorkPolicy -import androidx.work.WorkInfo import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.merge -import kotlinx.coroutines.flow.update +import kotlinx.coroutines.flow.updateAndGet import org.dhis2.commons.Constants import org.dhis2.commons.matomo.Actions import org.dhis2.commons.matomo.Categories import org.dhis2.commons.prefs.PreferenceProvider -import org.dhis2.data.service.workManager.WorkManagerController -import org.dhis2.data.service.workManager.WorkerItem -import org.dhis2.data.service.workManager.WorkerType +import org.dhis2.mobile.commons.providers.TIME_DATA +import org.dhis2.mobile.commons.providers.TIME_META +import org.dhis2.mobile.sync.data.SyncBackgroundJobAction +import org.dhis2.mobile.sync.model.SyncJobStatus import org.dhis2.utils.analytics.AnalyticsHelper import org.dhis2.utils.analytics.CLICK import org.dhis2.utils.analytics.SYNC_DATA_NOW import org.dhis2.utils.analytics.SYNC_METADATA_NOW +import org.dhis2.mobile.sync.model.SyncStatus as Status class LaunchSync( - private val workManagerController: WorkManagerController, + private val syncBackgroundJobAction: SyncBackgroundJobAction, private val preferenceProvider: PreferenceProvider, private val analyticsHelper: AnalyticsHelper, ) { @@ -34,21 +32,19 @@ class LaunchSync( ) private val metadataWorkInfo = - workManagerController - .getWorkInfosByTagLiveData(Constants.META_NOW) - .asFlow() + syncBackgroundJobAction + .observeMetadataJob() .map { workStatuses -> - var workState: WorkInfo.State? = workStatuses.getOrNull(0)?.state - onWorkStatusesUpdate(workState, Constants.META_NOW) + val currentSyncStatus = combinedStatus(workStatuses) + syncStatus.updateAndGet { it.copy(metadataSyncProgress = currentSyncStatus) } } private val dataWorkInfo = - workManagerController - .getWorkInfosByTagLiveData(Constants.DATA_NOW) - .asFlow() + syncBackgroundJobAction + .observeDataJob() .map { workStatuses -> - var workState: WorkInfo.State? = workStatuses.getOrNull(0)?.state - onWorkStatusesUpdate(workState, Constants.DATA_NOW) + val currentSyncStatus = combinedStatus(workStatuses) + syncStatus.updateAndGet { it.copy(dataSyncProgress = currentSyncStatus) } } val syncWorkInfo = merge(metadataWorkInfo, dataWorkInfo) @@ -85,9 +81,9 @@ class LaunchSync( metadataWasRunning: Boolean, dataWasRunning: Boolean, ) = metadataSyncProgress == SyncStatus.Finished && - metadataWasRunning || - dataSyncProgress == SyncStatus.Finished && - dataWasRunning + metadataWasRunning || + dataSyncProgress == SyncStatus.Finished && + dataWasRunning } suspend operator fun invoke(syncAction: SyncAction) { @@ -99,131 +95,50 @@ class LaunchSync( } } - private fun onWorkStatusesUpdate( - workState: WorkInfo.State?, - workerTag: String, - ): SyncStatusProgress { - if (workState != null) { - when (workState) { - WorkInfo.State.CANCELLED -> - when (workerTag) { - Constants.META_NOW -> syncStatus.update { it.copy(metadataSyncProgress = SyncStatus.Cancelled) } - Constants.DATA_NOW -> syncStatus.update { it.copy(dataSyncProgress = SyncStatus.Cancelled) } - else -> syncStatus - } - - WorkInfo.State.ENQUEUED, - WorkInfo.State.RUNNING, - WorkInfo.State.BLOCKED, - -> - when (workerTag) { - Constants.META_NOW -> syncStatus.update { it.copy(metadataSyncProgress = SyncStatus.InProgress) } - Constants.DATA_NOW -> syncStatus.update { it.copy(dataSyncProgress = SyncStatus.InProgress) } - else -> syncStatus - } - - else -> - when (workerTag) { - Constants.META_NOW -> syncStatus.update { it.copy(metadataSyncProgress = SyncStatus.Finished) } - Constants.DATA_NOW -> syncStatus.update { it.copy(dataSyncProgress = SyncStatus.Finished) } - else -> syncStatus - } - } - } else { - when (workerTag) { - Constants.META_NOW -> syncStatus.update { it.copy(metadataSyncProgress = SyncStatus.Finished) } - Constants.DATA_NOW -> syncStatus.update { it.copy(dataSyncProgress = SyncStatus.Finished) } - else -> syncStatus - } - } - - return syncStatus.value - } - private fun syncData() { analyticsHelper.trackMatomoEvent(Categories.SETTINGS, Actions.SYNC_CONFIG, CLICK) analyticsHelper.setEvent(SYNC_DATA_NOW, CLICK, SYNC_DATA_NOW) - val workerItem = - WorkerItem( - Constants.DATA_NOW, - WorkerType.DATA, - null, - null, - ExistingWorkPolicy.KEEP, - null, - ) - workManagerController.syncDataForWorker(workerItem) + syncBackgroundJobAction.launchDataSync(0) } private fun syncMeta() { analyticsHelper.setEvent(SYNC_METADATA_NOW, CLICK, SYNC_METADATA_NOW) - val workerItem = - WorkerItem( - Constants.META_NOW, - WorkerType.METADATA, - null, - null, - ExistingWorkPolicy.KEEP, - null, - ) - workManagerController.syncDataForWorker(workerItem) + syncBackgroundJobAction.launchMetadataSync(0) } - private fun updateSyncDataPeriod(seconds: Int) { + private suspend fun updateSyncDataPeriod(seconds: Int) { if (seconds != Constants.TIME_MANUAL) { syncData(seconds) } else { - cancelPendingWork(Constants.DATA) + preferenceProvider.setValue(TIME_DATA, 0) + syncBackgroundJobAction.cancelDataSync() } } - private fun updateSyncMetadataPeriod(seconds: Int) { + private suspend fun updateSyncMetadataPeriod(seconds: Int) { if (seconds != Constants.TIME_MANUAL) { syncMeta(seconds) } else { - cancelPendingWork(Constants.META) + preferenceProvider.setValue(TIME_META, 0) + syncBackgroundJobAction.cancelMetadataSync() } } private fun syncMeta(seconds: Int) { analyticsHelper.trackMatomoEvent(Categories.SETTINGS, Actions.SYNC_DATA, CLICK) - preferenceProvider.setValue(Constants.TIME_META, seconds) - workManagerController.cancelUniqueWork(Constants.META) - val workerItem = - WorkerItem( - Constants.META, - WorkerType.METADATA, - seconds.toLong(), - null, - null, - ExistingPeriodicWorkPolicy.REPLACE, - ) - workManagerController.enqueuePeriodicWork(workerItem) + preferenceProvider.setValue(TIME_META, seconds) + syncBackgroundJobAction.launchMetadataSync(seconds.toLong()) } private fun syncData(seconds: Int) { - preferenceProvider.setValue(Constants.TIME_DATA, seconds) - workManagerController.cancelUniqueWork(Constants.DATA) - val workerItem = - WorkerItem( - Constants.DATA, - WorkerType.DATA, - seconds.toLong(), - null, - null, - ExistingPeriodicWorkPolicy.REPLACE, - ) - workManagerController.enqueuePeriodicWork(workerItem) + preferenceProvider.setValue(TIME_DATA, seconds) + syncBackgroundJobAction.launchDataSync(seconds.toLong()) } - private fun cancelPendingWork(tag: String) { - preferenceProvider.setValue( - when (tag) { - Constants.DATA -> Constants.TIME_DATA - else -> Constants.TIME_META - }, - 0, - ) - workManagerController.cancelUniqueWork(tag) + private fun combinedStatus(workStatuses: List) = when { + workStatuses.any { (it.status is Status.Running) or (it.status is Status.Blocked) } -> SyncStatus.InProgress + workStatuses.all { it.status is Status.Enqueue } -> SyncStatus.None + workStatuses.all { it.status is Status.Cancelled } -> SyncStatus.Cancelled + else -> SyncStatus.Finished } } diff --git a/app/src/main/java/org/dhis2/usescases/settings/models/DataSettingsViewModel.kt b/app/src/main/java/org/dhis2/usescases/settings/models/DataSettingsViewModel.kt index 2b33dc87b92..b5da929d972 100644 --- a/app/src/main/java/org/dhis2/usescases/settings/models/DataSettingsViewModel.kt +++ b/app/src/main/java/org/dhis2/usescases/settings/models/DataSettingsViewModel.kt @@ -5,6 +5,7 @@ import org.dhis2.data.service.SyncResult data class DataSettingsViewModel( val dataSyncPeriod: Int, val lastDataSync: String, + val nextDataSync: String?, val syncHasErrors: Boolean, val dataHasErrors: Boolean, val dataHasWarnings: Boolean, diff --git a/app/src/main/java/org/dhis2/usescases/settings/models/MetadataSettingsViewModel.kt b/app/src/main/java/org/dhis2/usescases/settings/models/MetadataSettingsViewModel.kt index ab89e43b8ee..ebe42e80c1e 100644 --- a/app/src/main/java/org/dhis2/usescases/settings/models/MetadataSettingsViewModel.kt +++ b/app/src/main/java/org/dhis2/usescases/settings/models/MetadataSettingsViewModel.kt @@ -3,6 +3,8 @@ package org.dhis2.usescases.settings.models data class MetadataSettingsViewModel( val metadataSyncPeriod: Int, val lastMetadataSync: String, + val nextMetadataSync: String?, + val nextSettingsSync: String?, val hasErrors: Boolean, val canEdit: Boolean, val syncInProgress: Boolean, diff --git a/app/src/main/java/org/dhis2/usescases/settings/models/SyncStateInput.kt b/app/src/main/java/org/dhis2/usescases/settings/models/SyncStateInput.kt new file mode 100644 index 00000000000..e9a6d045b28 --- /dev/null +++ b/app/src/main/java/org/dhis2/usescases/settings/models/SyncStateInput.kt @@ -0,0 +1,10 @@ +package org.dhis2.usescases.settings.models + +import org.dhis2.usescases.settings.SettingItem + +data class SyncStateInput( + val openedItem: SettingItem?, + val hasConnection: Boolean, + val metadataSyncInProgress: Boolean, + val dataSyncInProgress: Boolean, +) diff --git a/app/src/main/java/org/dhis2/usescases/settings/ui/SettingsScreen.kt b/app/src/main/java/org/dhis2/usescases/settings/ui/SettingsScreen.kt index 2feb5e357de..a8e058bac17 100644 --- a/app/src/main/java/org/dhis2/usescases/settings/ui/SettingsScreen.kt +++ b/app/src/main/java/org/dhis2/usescases/settings/ui/SettingsScreen.kt @@ -7,7 +7,6 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.Snackbar import androidx.compose.material3.SnackbarDefaults @@ -22,7 +21,9 @@ import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.dp +import org.dhis2.commons.resources.ColorUtils import org.dhis2.usescases.settings.SettingItem import org.dhis2.usescases.settings.SyncManagerPresenter import org.dhis2.usescases.settings.models.DeleteDataState @@ -172,12 +173,17 @@ private fun SettingItemList( exportingDatabase: Boolean, onSettingsUiAction: (SettingsUiAction) -> Unit, ) { + val context = LocalContext.current + val primaryColor = remember(context) { + ColorUtils().getThemePrimaryColor(context) + } + LazyColumn( modifier = modifier .fillMaxSize() .imePadding() - .background(MaterialTheme.colorScheme.primary) + .background(primaryColor) .background(Color.White, RoundedCornerShape(topStart = 16.dp, topEnd = 16.dp)) .clip(RoundedCornerShape(topStart = 16.dp, topEnd = 16.dp)), contentPadding = PaddingValues(8.dp), diff --git a/app/src/main/java/org/dhis2/usescases/settings/ui/SyncDataSettingItem.kt b/app/src/main/java/org/dhis2/usescases/settings/ui/SyncDataSettingItem.kt index b1711f42252..b3851633d18 100644 --- a/app/src/main/java/org/dhis2/usescases/settings/ui/SyncDataSettingItem.kt +++ b/app/src/main/java/org/dhis2/usescases/settings/ui/SyncDataSettingItem.kt @@ -23,6 +23,7 @@ import androidx.compose.ui.semantics.semantics import androidx.compose.ui.semantics.testTag import androidx.compose.ui.text.capitalize import androidx.compose.ui.text.intl.Locale +import org.dhis2.BuildConfig import org.dhis2.R import org.dhis2.bindings.EVERY_12_HOUR import org.dhis2.bindings.EVERY_24_HOUR @@ -76,7 +77,12 @@ internal fun SyncDataSettingItem( } else -> { - provideDefaultInfoItems(dataSettings.dataSyncPeriod, dataSettings.lastDataSync, context) + provideDefaultInfoItems( + dataSettings.dataSyncPeriod, + dataSettings.lastDataSync, + dataSettings.nextDataSync, + context + ) } } @@ -95,13 +101,13 @@ internal fun SyncDataSettingItem( ) { if (dataSettings.canEdit) { var selectedItem by - remember { - mutableStateOf( - DropdownItem( - label = syncPeriodLabel(dataSettings.dataSyncPeriod, context), - ), - ) - } + remember { + mutableStateOf( + DropdownItem( + label = syncPeriodLabel(dataSettings.dataSyncPeriod, context), + ), + ) + } val dataSyncPeriods = listOf( stringResource(R.string.thirty_minutes), @@ -173,19 +179,33 @@ internal fun SyncDataSettingItem( private fun provideDefaultInfoItems( dataSyncPeriod: Int, lastDataSync: String, + nextDataSync: String?, context: Context, ): List = - listOf( - AdditionalInfoItem( - key = stringResource(R.string.settings_sync_period_v2), - value = syncPeriodLabel(dataSyncPeriod, context), - ), - AdditionalInfoItem( - key = stringResource(R.string.last_data_sync), - value = lastDataSync, - color = TextColor.OnSurface, - ), - ) + buildList { + add( + AdditionalInfoItem( + key = stringResource(R.string.settings_sync_period_v2), + value = syncPeriodLabel(dataSyncPeriod, context), + ) + ) + add( + AdditionalInfoItem( + key = stringResource(R.string.last_data_sync), + value = lastDataSync, + color = TextColor.OnSurface, + ) + ) + nextDataSync?.takeIf { BuildConfig.DEBUG }?.let { + add( + AdditionalInfoItem( + key = "Next Sync On", + value = nextDataSync, + color = TextColor.OnSurface, + ) + ) + } + } @Composable private fun provideSyncErrorInfo( diff --git a/app/src/main/java/org/dhis2/usescases/settings/ui/SyncMetadataSettingItem.kt b/app/src/main/java/org/dhis2/usescases/settings/ui/SyncMetadataSettingItem.kt index 7d78ecc6996..3b983201d0b 100644 --- a/app/src/main/java/org/dhis2/usescases/settings/ui/SyncMetadataSettingItem.kt +++ b/app/src/main/java/org/dhis2/usescases/settings/ui/SyncMetadataSettingItem.kt @@ -1,6 +1,7 @@ package org.dhis2.usescases.settings.ui import android.content.Context +import androidx.annotation.StringRes import androidx.compose.foundation.layout.Arrangement.Absolute.spacedBy import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxWidth @@ -20,10 +21,13 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.capitalize import androidx.compose.ui.text.intl.Locale import androidx.compose.ui.unit.dp +import org.dhis2.BuildConfig import org.dhis2.R +import org.dhis2.bindings.EVERY_12_HOUR import org.dhis2.bindings.EVERY_24_HOUR +import org.dhis2.bindings.EVERY_6_HOUR import org.dhis2.bindings.EVERY_7_DAYS -import org.dhis2.commons.Constants +import org.dhis2.commons.Constants.TIME_MANUAL import org.dhis2.usescases.settings.SettingItem import org.dhis2.usescases.settings.models.MetadataSettingsViewModel import org.hisp.dhis.mobile.ui.designsystem.component.AdditionalInfoItem @@ -59,6 +63,8 @@ internal fun SyncMetadataSettingItem( provideDefaultInfoItems( metadataSettings.metadataSyncPeriod, metadataSettings.lastMetadataSync, + metadataSettings.nextMetadataSync, + metadataSettings.nextSettingsSync, context, ) } @@ -77,19 +83,29 @@ internal fun SyncMetadataSettingItem( if (metadataSettings.canEdit) { val metaSyncPeriods = listOf( - stringResource(R.string.a_day), - stringResource(R.string.a_week), - stringResource(R.string.Manual), + SyncMetadataPeriods.Every6Hours, + SyncMetadataPeriods.Every12Hours, + SyncMetadataPeriods.Every24Hours, + SyncMetadataPeriods.EveryWeek, + SyncMetadataPeriods.Manual, ) + val dropdownItemLabel = + metaSyncPeriods.map { + stringResource(it.label) + } + var selectedItem by - remember { - mutableStateOf( - DropdownItem( - label = syncPeriodLabel(metadataSettings.metadataSyncPeriod, context), + remember { + mutableStateOf( + DropdownItem( + label = syncPeriodLabel( + metadataSettings.metadataSyncPeriod, + context ), - ) - } + ), + ) + } var inputSyncConfigurationState by remember { mutableStateOf(InputShellState.UNFOCUSED) } @@ -101,21 +117,14 @@ internal fun SyncMetadataSettingItem( itemCount = metaSyncPeriods.size, onSearchOption = {}, fetchItem = { index -> - DropdownItem(metaSyncPeriods[index]) + DropdownItem(dropdownItemLabel[index]) }, selectedItem = selectedItem, onResetButtonClicked = { }, onItemSelected = { index, newItem -> selectedItem = newItem inputSyncConfigurationState = InputShellState.UNFOCUSED - when (index) { - 0 -> onSyncMetaPeriodChanged(EVERY_24_HOUR) - 1 -> onSyncMetaPeriodChanged(EVERY_7_DAYS) - 2 -> onSyncMetaPeriodChanged(Constants.TIME_MANUAL) - else -> { - // do nothing - } - } + onSyncMetaPeriodChanged(metaSyncPeriods[index].syncPeriod) }, showSearchBar = false, loadOptions = {}, @@ -148,18 +157,42 @@ internal fun SyncMetadataSettingItem( private fun provideDefaultInfoItems( metadataSyncPeriod: Int, lastMetadataSync: String, + nextMetadataSync: String?, + nextSettingsSync: String?, context: Context, -) = listOf( - AdditionalInfoItem( - key = stringResource(R.string.settings_sync_period_v2), - value = syncPeriodLabel(metadataSyncPeriod, context), - ), - AdditionalInfoItem( - key = stringResource(R.string.last_data_sync), - value = lastMetadataSync, - color = TextColor.OnSurface, - ), -) +) = buildList { + add( + AdditionalInfoItem( + key = stringResource(R.string.settings_sync_period_v2), + value = syncPeriodLabel(metadataSyncPeriod, context), + ) + ) + add( + AdditionalInfoItem( + key = stringResource(R.string.last_data_sync), + value = lastMetadataSync, + color = TextColor.OnSurface, + ) + ) + nextMetadataSync?.takeIf { BuildConfig.DEBUG }?.let { + add( + AdditionalInfoItem( + key = "Next sync on", + value = it, + color = TextColor.OnSurface, + ) + ) + } + nextSettingsSync?.takeIf { BuildConfig.DEBUG }?.let { + add( + AdditionalInfoItem( + key = "Next settings sync on", + value = it, + color = TextColor.OnSurface, + ) + ) + } +} @Composable private fun provideHasErrorItems( @@ -193,3 +226,18 @@ private fun provideSyncInProgressInfoItems( isConstantItem = true, ), ) + +internal sealed class SyncMetadataPeriods( + @StringRes val label: Int, + val syncPeriod: Int, +) { + data object Every24Hours : SyncMetadataPeriods(R.string.a_day, EVERY_24_HOUR) + + data object Every12Hours : SyncMetadataPeriods(R.string.every_12_hours, EVERY_12_HOUR) + + data object Every6Hours : SyncMetadataPeriods(R.string.every_6_hours, EVERY_6_HOUR) + + data object EveryWeek : SyncMetadataPeriods(R.string.a_week, EVERY_7_DAYS) + + data object Manual : SyncMetadataPeriods(R.string.Manual, TIME_MANUAL) +} diff --git a/app/src/main/java/org/dhis2/usescases/splash/SplashActivity.kt b/app/src/main/java/org/dhis2/usescases/splash/SplashActivity.kt index dfe8ad83645..18cc352356a 100644 --- a/app/src/main/java/org/dhis2/usescases/splash/SplashActivity.kt +++ b/app/src/main/java/org/dhis2/usescases/splash/SplashActivity.kt @@ -55,23 +55,38 @@ class SplashActivity : override fun onResume() { super.onResume() - if (BuildConfig.DEBUG || !RootBeer(this).isRootedWithoutBusyBoxCheck) { - if (!isDebuggerEnable() || !detectDebugger()) { - presenter.init() - } else { - showRootedDialog( - getString(R.string.security_title), - getString(R.string.security_debugger_message), - ) - } - } else { - showRootedDialog( + val isRooted = !BuildConfig.DEBUG && RootBeer(this).isRootedWithBusyBoxCheck + val isDebuggerActive = isDebuggerEnable() && detectDebugger() + + when { + isRooted -> showRootedDialog( getString(R.string.security_title), getString(R.string.security_rooted_message), ) + + isDebuggerActive -> showRootedDialog( + getString(R.string.security_title), + getString(R.string.security_debugger_message), + ) + + else -> presenter.init() } } + private fun isDebuggerEnable(): Boolean = + if (!BuildConfig.DEBUG && BuildConfig.FLAVOR != "dhis2Training") { + context.applicationContext.applicationInfo.flags and FLAG_DEBUGGABLE != 0 + } else { + false + } + + private fun detectDebugger(): Boolean = + if (!BuildConfig.DEBUG && BuildConfig.FLAVOR != "dhis2Training") { + Debug.isDebuggerConnected() + } else { + false + } + override fun onPause() { presenter.destroy() super.onPause() @@ -153,18 +168,4 @@ class SplashActivity : ) } } - - private fun isDebuggerEnable(): Boolean = - if (!BuildConfig.DEBUG) { - context.applicationContext.applicationInfo.flags and FLAG_DEBUGGABLE != 0 - } else { - false - } - - private fun detectDebugger(): Boolean = - if (!BuildConfig.DEBUG) { - Debug.isDebuggerConnected() - } else { - false - } } diff --git a/app/src/main/java/org/dhis2/usescases/sync/SyncActivity.kt b/app/src/main/java/org/dhis2/usescases/sync/SyncActivity.kt index 0b0a027a4e4..1cb60bc3cc7 100644 --- a/app/src/main/java/org/dhis2/usescases/sync/SyncActivity.kt +++ b/app/src/main/java/org/dhis2/usescases/sync/SyncActivity.kt @@ -5,18 +5,23 @@ import android.os.Bundle import android.view.View.GONE import androidx.appcompat.content.res.AppCompatResources import androidx.databinding.DataBindingUtil -import androidx.work.WorkInfo +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle +import kotlinx.coroutines.launch import org.dhis2.App import org.dhis2.R import org.dhis2.bindings.Bindings import org.dhis2.bindings.userComponent import org.dhis2.databinding.ActivitySynchronizationBinding +import org.dhis2.mobile.sync.data.SyncBackgroundJobAction import org.dhis2.usescases.general.ActivityGlobalAbstract import org.dhis2.usescases.login.LoginActivity import org.dhis2.usescases.main.MainActivity import org.dhis2.utils.OnDialogClickListener import org.dhis2.utils.extension.navigateTo import org.dhis2.utils.extension.share +import org.koin.android.ext.android.inject import javax.inject.Inject class SyncActivity : @@ -30,20 +35,23 @@ class SyncActivity : @Inject lateinit var animations: SyncAnimations + private val backgroundJobAction: SyncBackgroundJobAction by inject() + override fun onCreate(savedInstanceState: Bundle?) { val serverComponent = (applicationContext as App).serverComponent() - userComponent()?.plus(SyncModule(this, serverComponent))?.inject(this) ?: finish() + userComponent()?.plus(SyncModule(this, backgroundJobAction, serverComponent))?.inject(this) + ?: finish() super.onCreate(savedInstanceState) binding = DataBindingUtil.setContentView(this, R.layout.activity_synchronization) binding.presenter = presenter - presenter.sync() - } - override fun onResume() { - super.onResume() - presenter.observeSyncProcess().observe(this) { workInfoList: List -> - presenter.handleSyncInfo(workInfoList) + lifecycleScope.launch { + repeatOnLifecycle(Lifecycle.State.STARTED) { + presenter.observeSyncProcess().collect(presenter::handleSyncInfo) + } } + + presenter.sync() } override fun setMetadataSyncStarted() { diff --git a/app/src/main/java/org/dhis2/usescases/sync/SyncInjector.kt b/app/src/main/java/org/dhis2/usescases/sync/SyncInjector.kt index 1abecbbd72e..12e307c465f 100644 --- a/app/src/main/java/org/dhis2/usescases/sync/SyncInjector.kt +++ b/app/src/main/java/org/dhis2/usescases/sync/SyncInjector.kt @@ -7,7 +7,7 @@ import org.dhis2.commons.di.dagger.PerActivity import org.dhis2.commons.prefs.PreferenceProvider import org.dhis2.commons.schedulers.SchedulerProvider import org.dhis2.data.server.ServerComponent -import org.dhis2.data.service.workManager.WorkManagerController +import org.dhis2.mobile.sync.data.SyncBackgroundJobAction @PerActivity @Subcomponent(modules = [SyncModule::class]) @@ -18,6 +18,7 @@ interface SyncComponent { @Module class SyncModule( private val view: SyncView, + private val backgroundJobAction: SyncBackgroundJobAction, serverComponent: ServerComponent?, ) { private val userManager = serverComponent?.userManager() @@ -26,14 +27,13 @@ class SyncModule( @PerActivity fun providePresenter( schedulerProvider: SchedulerProvider, - workManagerController: WorkManagerController, preferences: PreferenceProvider, ): SyncPresenter = SyncPresenter( view, userManager, schedulerProvider, - workManagerController, + backgroundJobAction, preferences, ) } diff --git a/app/src/main/java/org/dhis2/usescases/sync/SyncPresenter.kt b/app/src/main/java/org/dhis2/usescases/sync/SyncPresenter.kt index 90735348a02..91d7c5e58bc 100644 --- a/app/src/main/java/org/dhis2/usescases/sync/SyncPresenter.kt +++ b/app/src/main/java/org/dhis2/usescases/sync/SyncPresenter.kt @@ -1,15 +1,14 @@ package org.dhis2.usescases.sync -import androidx.lifecycle.LiveData -import androidx.work.WorkInfo import io.reactivex.disposables.CompositeDisposable -import org.dhis2.commons.Constants import org.dhis2.commons.prefs.Preference import org.dhis2.commons.prefs.PreferenceProvider import org.dhis2.commons.schedulers.SchedulerProvider import org.dhis2.data.server.UserManager -import org.dhis2.data.service.METADATA_MESSAGE -import org.dhis2.data.service.workManager.WorkManagerController +import org.dhis2.mobile.sync.data.METADATA_SYNC_NOW +import org.dhis2.mobile.sync.data.SyncBackgroundJobAction +import org.dhis2.mobile.sync.model.SyncJobStatus +import org.dhis2.mobile.sync.model.SyncStatus import timber.log.Timber const val WAS_INITIAL_SYNC_DONE = "WasInitialSyncDone" @@ -18,35 +17,35 @@ class SyncPresenter internal constructor( private val view: SyncView, private val userManager: UserManager?, private val schedulerProvider: SchedulerProvider, - private val workManagerController: WorkManagerController, + private val backgroundJobAction: SyncBackgroundJobAction, private val preferences: PreferenceProvider, ) { private val disposable = CompositeDisposable() fun sync() { - workManagerController - .syncMetaDataForWorker(Constants.META_NOW, Constants.INITIAL_SYNC) + backgroundJobAction.launchMetadataSync(0) } - fun observeSyncProcess(): LiveData> = workManagerController.getWorkInfosForUniqueWorkLiveData(Constants.INITIAL_SYNC) + fun observeSyncProcess() = backgroundJobAction.observeMetadataJob() - fun handleSyncInfo(workInfoList: List) { + fun handleSyncInfo(workInfoList: List) { workInfoList.forEach { workInfo -> - if (workInfo.tags.contains(Constants.META_NOW)) { - handleMetaState(workInfo.state, workInfo.outputData.getString(METADATA_MESSAGE)) + if (workInfo.tags.contains(METADATA_SYNC_NOW)) { + handleMetaState(workInfo.status, workInfo.message) } } } private fun handleMetaState( - state: WorkInfo.State, + state: SyncStatus, message: String?, ) { when (state) { - WorkInfo.State.RUNNING -> view.setMetadataSyncStarted() - WorkInfo.State.SUCCEEDED -> view.setMetadataSyncSucceed() - WorkInfo.State.FAILED -> view.showMetadataFailedMessage(message) + SyncStatus.Running -> view.setMetadataSyncStarted() + SyncStatus.Succeed -> view.setMetadataSyncSucceed() + SyncStatus.Failed -> view.showMetadataFailedMessage(message) else -> { + // do nothing } } } diff --git a/app/src/main/java/org/dhis2/usescases/teiDashboard/TeiDashboardMobileActivity.kt b/app/src/main/java/org/dhis2/usescases/teiDashboard/TeiDashboardMobileActivity.kt index aeb40312665..4620323adc1 100644 --- a/app/src/main/java/org/dhis2/usescases/teiDashboard/TeiDashboardMobileActivity.kt +++ b/app/src/main/java/org/dhis2/usescases/teiDashboard/TeiDashboardMobileActivity.kt @@ -3,7 +3,6 @@ package org.dhis2.usescases.teiDashboard import android.app.ActivityOptions import android.content.Context import android.content.Intent -import android.os.Build import android.os.Bundle import android.os.Handler import android.os.Looper @@ -630,15 +629,13 @@ class TeiDashboardMobileActivity : } binding.executePendingBindings() setTheme(themeManager.getProgramTheme()) - if (Build.VERSION.SDK_INT > Build.VERSION_CODES.LOLLIPOP) { - val window = window - window.addFlags(WindowManager.LayoutParams.FLAG_DRAWS_SYSTEM_BAR_BACKGROUNDS) - val typedValue = TypedValue() - val a = obtainStyledAttributes(typedValue.data, intArrayOf(R.attr.colorPrimaryDark)) - val colorToReturn = a.getColor(0, 0) - a.recycle() - window.statusBarColor = colorToReturn - } + val window = window + window.addFlags(WindowManager.LayoutParams.FLAG_DRAWS_SYSTEM_BAR_BACKGROUNDS) + val typedValue = TypedValue() + val a = obtainStyledAttributes(typedValue.data, intArrayOf(R.attr.colorPrimaryDark)) + val colorToReturn = a.getColor(0, 0) + a.recycle() + window.statusBarColor = colorToReturn } override fun updateNoteBadge(numberOfNotes: Int) { diff --git a/app/src/main/java/org/dhis2/usescases/teiDashboard/dashboardfragments/indicators/BaseIndicatorRepository.kt b/app/src/main/java/org/dhis2/usescases/teiDashboard/dashboardfragments/indicators/BaseIndicatorRepository.kt index 7c1f1e179af..3b9159c663e 100644 --- a/app/src/main/java/org/dhis2/usescases/teiDashboard/dashboardfragments/indicators/BaseIndicatorRepository.kt +++ b/app/src/main/java/org/dhis2/usescases/teiDashboard/dashboardfragments/indicators/BaseIndicatorRepository.kt @@ -9,6 +9,7 @@ import dhis2.org.analytics.charts.ui.SectionType import io.reactivex.Flowable import io.reactivex.Observable import org.dhis2.commons.resources.ResourceManager +import org.dhis2.mobileProgramRules.RuleConstants import org.dhis2.mobileProgramRules.RuleEngineHelper import org.hisp.dhis.android.core.D2 import org.hisp.dhis.android.core.arch.helpers.UidGeneratorImpl @@ -111,6 +112,8 @@ abstract class BaseIndicatorRepository( val ruleAction = ruleEffect.ruleAction if (ruleEffect.data?.contains("#{") == false) { if (ruleAction.type == ProgramRuleActionType.DISPLAYKEYVALUEPAIR.name) { + val legendSet = ruleAction.values[RuleConstants.LEGENDSET_LABEL] + val color = getLegendColor(ruleEffect.data, legendSet) val indicator = IndicatorModel( ProgramIndicator @@ -119,7 +122,7 @@ abstract class BaseIndicatorRepository( .displayName((ruleAction).content()) .build(), ruleEffect.data, - null, + color, ruleAction.values["location"] ?: DEFAULT_LOCATION, resourceManager.defaultIndicatorLabel(), ) @@ -131,9 +134,9 @@ abstract class BaseIndicatorRepository( ProgramIndicator .builder() .uid(UidGeneratorImpl().generate()) - .displayName(resourceManager.defaultIndicatorLabel()) + .displayName("${ruleAction.content() ?: ""}${ruleEffect.data}") .build(), - "${ruleAction.content() ?: ""}${ruleEffect.data}", + "", null, ruleAction.values["location"] ?: DEFAULT_LOCATION, resourceManager.defaultIndicatorLabel(), @@ -147,6 +150,29 @@ abstract class BaseIndicatorRepository( return indicators } + private fun getLegendColor( + data: String?, + legendSet: String?, + ): String? { + if (data.isNullOrEmpty() || legendSet.isNullOrEmpty()) return null + + val legendValue = getNumberValue(data) ?: return null + + val legends = + d2 + .legendSetModule() + .legends() + .byStartValue() + .smallerThan(legendValue) + .byEndValue() + .biggerOrEqualTo(legendValue) + .byLegendSet() + .eq(legendSet) + .blockingGet() + + return legends.firstOrNull()?.color() + } + private fun getLegendColorForIndicator( indicator: ProgramIndicator, value: String?, @@ -240,4 +266,16 @@ abstract class BaseIndicatorRepository( } } } + + companion object { + private val NUMBER_REGEX = """-?\b(\d{1,3}(,\d{3})*(\.\d+)?|\d+(\.\d+)?)\b""".toRegex() + + internal fun getNumberValue(data: String): Double? { + return NUMBER_REGEX + .find(data) + ?.value + ?.replace(",", "") + ?.toDoubleOrNull() + } + } } diff --git a/app/src/main/java/org/dhis2/usescases/teiDashboard/teiProgramList/TeiProgramListActivity.java b/app/src/main/java/org/dhis2/usescases/teiDashboard/teiProgramList/TeiProgramListActivity.java index d9535cdf9fd..70df38985c7 100644 --- a/app/src/main/java/org/dhis2/usescases/teiDashboard/teiProgramList/TeiProgramListActivity.java +++ b/app/src/main/java/org/dhis2/usescases/teiDashboard/teiProgramList/TeiProgramListActivity.java @@ -34,7 +34,7 @@ public class TeiProgramListActivity extends ActivityGlobalAbstract implements Te @Override public void onCreate(@Nullable Bundle savedInstanceState) { String trackedEntityId = getIntent().getStringExtra("TEI_UID"); - ((App) getApplicationContext()).userComponent().plus(new TeiProgramListModule(this, trackedEntityId)).inject(this); + ((App) getApplicationContext()).userComponent().plus(new TeiProgramListModule(this, trackedEntityId, getSyncStatusController())).inject(this); super.onCreate(savedInstanceState); binding = DataBindingUtil.setContentView(this, R.layout.activity_tei_program_list); binding.setPresenter(presenter); diff --git a/app/src/main/java/org/dhis2/usescases/teiDashboard/teiProgramList/TeiProgramListInteractor.java b/app/src/main/java/org/dhis2/usescases/teiDashboard/teiProgramList/TeiProgramListInteractor.java index 3f389a6a94a..a2d2f905f16 100644 --- a/app/src/main/java/org/dhis2/usescases/teiDashboard/teiProgramList/TeiProgramListInteractor.java +++ b/app/src/main/java/org/dhis2/usescases/teiDashboard/teiProgramList/TeiProgramListInteractor.java @@ -8,7 +8,7 @@ import org.dhis2.commons.dialogs.calendarpicker.OnDatePickerListener; import org.dhis2.commons.orgunitselector.OUTreeFragment; import org.dhis2.mobile.commons.orgunit.OrgUnitSelectorScope; -import org.dhis2.data.service.SyncStatusController; +import org.dhis2.mobile.sync.domain.SyncStatusController; import org.dhis2.usescases.main.program.ProgramDownloadState; import org.dhis2.usescases.main.program.ProgramUiModel; import org.hisp.dhis.android.core.organisationunit.OrganisationUnit; diff --git a/app/src/main/java/org/dhis2/usescases/teiDashboard/teiProgramList/TeiProgramListModule.java b/app/src/main/java/org/dhis2/usescases/teiDashboard/teiProgramList/TeiProgramListModule.java index 91d1b696c16..bd6a5e6c556 100644 --- a/app/src/main/java/org/dhis2/usescases/teiDashboard/teiProgramList/TeiProgramListModule.java +++ b/app/src/main/java/org/dhis2/usescases/teiDashboard/teiProgramList/TeiProgramListModule.java @@ -4,10 +4,8 @@ import org.dhis2.commons.di.dagger.PerActivity; import org.dhis2.commons.prefs.PreferenceProvider; -import org.dhis2.commons.resources.ColorUtils; import org.dhis2.commons.resources.MetadataIconProvider; -import org.dhis2.commons.resources.ResourceManager; -import org.dhis2.data.service.SyncStatusController; +import org.dhis2.mobile.sync.domain.SyncStatusController; import org.dhis2.usescases.main.program.ProgramViewModelMapper; import org.dhis2.utils.analytics.AnalyticsHelper; import org.hisp.dhis.android.core.D2; @@ -22,9 +20,12 @@ public class TeiProgramListModule { private final TeiProgramListContract.View view; private final String teiUid; - TeiProgramListModule(TeiProgramListContract.View view, String teiUid) { + private final SyncStatusController syncStatusController; + + TeiProgramListModule(TeiProgramListContract.View view, String teiUid, SyncStatusController syncStatusController) { this.view = view; this.teiUid = teiUid; + this.syncStatusController = syncStatusController; } @Provides @@ -45,8 +46,7 @@ TeiProgramListContract.Presenter providesPresenter(TeiProgramListContract.Intera @Provides @PerActivity - TeiProgramListContract.Interactor provideInteractor(@NonNull TeiProgramListRepository teiProgramListRepository, - SyncStatusController syncStatusController) { + TeiProgramListContract.Interactor provideInteractor(@NonNull TeiProgramListRepository teiProgramListRepository) { return new TeiProgramListInteractor(teiProgramListRepository, syncStatusController); } diff --git a/app/src/main/java/org/dhis2/utils/WebViewActivity.kt b/app/src/main/java/org/dhis2/utils/WebViewActivity.kt deleted file mode 100644 index 386312314cf..00000000000 --- a/app/src/main/java/org/dhis2/utils/WebViewActivity.kt +++ /dev/null @@ -1,57 +0,0 @@ -package org.dhis2.utils - -import android.annotation.SuppressLint -import android.app.Activity -import android.os.Bundle -import android.view.View -import android.webkit.WebResourceRequest -import android.webkit.WebView -import android.webkit.WebViewClient -import androidx.databinding.DataBindingUtil -import org.dhis2.R -import org.dhis2.databinding.ActivityWebviewBinding - -class WebViewActivity : Activity() { - companion object { - const val WEB_VIEW_URL = "url" - } - - @SuppressLint("SetJavaScriptEnabled") - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - val binding: ActivityWebviewBinding = - DataBindingUtil.setContentView(this, R.layout.activity_webview) - - val url = intent?.extras?.getString(WEB_VIEW_URL) - - url?.let { - // Avoid the WebView to automatically redirect to a browser - binding.webView.webViewClient = - object : WebViewClient() { - override fun shouldOverrideUrlLoading( - view: WebView?, - request: WebResourceRequest, - ): Boolean = super.shouldOverrideUrlLoading(view, request) - - // Compatibility with APIs below 24 - @Deprecated("Deprecated in Java") - override fun shouldOverrideUrlLoading( - view: WebView?, - url: String?, - ): Boolean = super.shouldOverrideUrlLoading(view, url) - } - - binding.webView.settings.javaScriptEnabled = true - binding.webView.loadUrl(it) - } - } - - fun backToLogin(view: View) { - finish() - } - - @Deprecated("Deprecated in Java") - override fun onBackPressed() { - finish() - } -} diff --git a/app/src/main/java/org/dhis2/utils/analytics/AnalyticsHelper.kt b/app/src/main/java/org/dhis2/utils/analytics/AnalyticsHelper.kt index 3812af80ab1..3300d7e70d1 100644 --- a/app/src/main/java/org/dhis2/utils/analytics/AnalyticsHelper.kt +++ b/app/src/main/java/org/dhis2/utils/analytics/AnalyticsHelper.kt @@ -2,6 +2,7 @@ package org.dhis2.utils.analytics import org.dhis2.commons.matomo.MatomoAnalyticsController import org.dhis2.mobile.commons.reporting.AnalyticActions +import org.dhis2.utils.analytics.matomo.DEFAULT_EXTERNAL_TRACKER_NAME import javax.inject.Inject class AnalyticsHelper @@ -28,12 +29,11 @@ class AnalyticsHelper override fun updateMatomoSecondaryTracker( matomoUrl: String, matomoID: Int, - trackerName: String, ) { matomoAnalyticsController.updateDhisImplementationTracker( matomoUrl, matomoID, - trackerName, + DEFAULT_EXTERNAL_TRACKER_NAME, ) } diff --git a/app/src/main/java/org/dhis2/utils/granularsync/GranularSyncPresenter.kt b/app/src/main/java/org/dhis2/utils/granularsync/GranularSyncPresenter.kt index 0448e4f7353..cadabacca57 100644 --- a/app/src/main/java/org/dhis2/utils/granularsync/GranularSyncPresenter.kt +++ b/app/src/main/java/org/dhis2/utils/granularsync/GranularSyncPresenter.kt @@ -59,9 +59,12 @@ import org.dhis2.commons.viewmodel.DispatcherProvider import org.dhis2.data.service.workManager.WorkManagerController import org.dhis2.data.service.workManager.WorkerItem import org.dhis2.data.service.workManager.WorkerType +import org.dhis2.mobile.sync.data.SyncBackgroundJobAction import org.dhis2.usescases.sms.SmsSendingService import org.hisp.dhis.android.core.D2 import org.hisp.dhis.android.core.common.State +import org.koin.core.component.KoinComponent +import org.koin.core.component.inject import timber.log.Timber class GranularSyncPresenter( @@ -73,7 +76,8 @@ class GranularSyncPresenter( private val syncContext: SyncContext, private val workManagerController: WorkManagerController, private val smsSyncProvider: SMSSyncProvider, -) : ViewModel() { +) : ViewModel(), + KoinComponent { private val workerName: String private var disposable: CompositeDisposable = CompositeDisposable() private lateinit var states: MutableLiveData> @@ -82,6 +86,8 @@ class GranularSyncPresenter( private val _currentState = MutableStateFlow(null) val currentState: StateFlow = _currentState + private val syncBackgroundJobAction: SyncBackgroundJobAction by inject() + init { workerName = workerName() } @@ -172,7 +178,7 @@ class GranularSyncPresenter( workManagerController.beginUniqueWork(workerItem) } else { - workManagerController.syncDataForWorker(Constants.DATA_NOW, Constants.INITIAL_SYNC) + syncBackgroundJobAction.launchDataSync(0) } } return observeWorkInfo() diff --git a/app/src/main/java/org/dhis2/utils/granularsync/SyncStatusDialog.kt b/app/src/main/java/org/dhis2/utils/granularsync/SyncStatusDialog.kt index b37d679c104..ea90a4b0dd3 100644 --- a/app/src/main/java/org/dhis2/utils/granularsync/SyncStatusDialog.kt +++ b/app/src/main/java/org/dhis2/utils/granularsync/SyncStatusDialog.kt @@ -90,7 +90,7 @@ class SyncStatusDialog : override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - setStyle(STYLE_NORMAL, org.dhis2.ui.R.style.CustomBottomSheetDialogTheme) + setStyle(STYLE_NORMAL, org.dhis2.commons.R.style.CustomBottomSheetDialogTheme) } override fun onCreateView( diff --git a/app/src/main/java/org/dhis2/utils/granularsync/SyncStatusItem.kt b/app/src/main/java/org/dhis2/utils/granularsync/SyncStatusItem.kt index 7d87562e767..a0557714a75 100644 --- a/app/src/main/java/org/dhis2/utils/granularsync/SyncStatusItem.kt +++ b/app/src/main/java/org/dhis2/utils/granularsync/SyncStatusItem.kt @@ -18,7 +18,7 @@ import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.res.vectorResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -import org.dhis2.ui.R +import org.dhis2.commons.R @Composable fun SyncStatusItem( diff --git a/app/src/main/java/org/dhis2/utils/session/PinDialog.kt b/app/src/main/java/org/dhis2/utils/session/PinDialog.kt deleted file mode 100644 index caa274c6db8..00000000000 --- a/app/src/main/java/org/dhis2/utils/session/PinDialog.kt +++ /dev/null @@ -1,142 +0,0 @@ -package org.dhis2.utils.session - -import android.app.Dialog -import android.os.Bundle -import android.os.Handler -import android.os.Process -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import android.view.Window -import android.widget.Toast -import androidx.fragment.app.DialogFragment -import androidx.fragment.app.FragmentManager -import org.dhis2.R -import org.dhis2.bindings.app -import org.dhis2.databinding.DialogPinBinding -import javax.inject.Inject - -const val PIN_DIALOG_TAG: String = "PINDIALOG" - -class PinDialog( - val mode: Mode, - private val canBeClosed: Boolean, - private val unlockCallback: () -> Unit, - private val forgotPinCallback: () -> Unit, -) : DialogFragment(), - PinView { - private lateinit var binding: DialogPinBinding - - @Inject - lateinit var presenter: PinPresenter - - enum class Mode { - SET, - ASK, - } - - private var pinAttempts = 0 - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - setStyle(STYLE_NO_TITLE, android.R.style.Theme_DeviceDefault_Light_NoActionBar) - app().createSessionComponent(PinModule(this)).inject(this) - } - - override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { - val dialog = super.onCreateDialog(savedInstanceState) - dialog.window!!.apply { - requestFeature(Window.FEATURE_NO_TITLE) - setBackgroundDrawableResource(android.R.color.transparent) - setWindowAnimations(R.style.pin_dialog_animation) - isCancelable = false - } - return dialog - } - - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle?, - ): View? { - binding = DialogPinBinding.inflate(layoutInflater, container, false) - binding.closeButton.apply { - visibility = if (canBeClosed) View.VISIBLE else View.GONE - setOnClickListener { closeDialog() } - } - - when (mode) { - Mode.ASK -> { - binding.title.text = getString(R.string.unblock_session) - binding.forgotCode.apply { - visibility = View.VISIBLE - setOnClickListener { recoverPin() } - } - } - Mode.SET -> { - binding.title.text = getString(R.string.set_pin) - binding.forgotCode.visibility = View.GONE - } - } - - binding.pinLockView.attachIndicatorDots(binding.indicatorDots) - binding.pinLockView.onPinSet { - when (mode) { - Mode.SET -> { - presenter.savePin(it) - blockSession() - } - Mode.ASK -> - presenter.unlockSession( - it, - attempts = pinAttempts, - onPinCorrect = unlockCallback, - onError = { - pinAttempts += 1 - Toast - .makeText( - context, - getString(R.string.wrong_pin), - Toast.LENGTH_LONG, - ).show() - binding.pinLockView.resetPinLockView() - }, - onTwoManyAttempts = { recoverPin() }, - ) - } - } - - return binding.root - } - - private fun blockSession() { - Handler().postDelayed( - { Process.killProcess(Process.myPid()) }, - 1500, - ) - } - - override fun closeDialog() { - dismissAllowingStateLoss() - } - - override fun dismiss() { - app().releaseSessionComponent() - dismissAllowingStateLoss() - } - - override fun recoverPin() { - presenter.logOut() - forgotPinCallback.invoke() - dismissAllowingStateLoss() - } - - override fun show( - manager: FragmentManager, - tag: String?, - ) { - if (manager.findFragmentByTag(tag) == null) { - manager.beginTransaction().add(this, tag).commitAllowingStateLoss() - } - } -} diff --git a/app/src/main/java/org/dhis2/utils/session/PinExtensions.kt b/app/src/main/java/org/dhis2/utils/session/PinExtensions.kt deleted file mode 100644 index 68c0f5319f3..00000000000 --- a/app/src/main/java/org/dhis2/utils/session/PinExtensions.kt +++ /dev/null @@ -1,23 +0,0 @@ -package org.dhis2.utils.session - -import com.andrognito.pinlockview.PinLockListener -import com.andrognito.pinlockview.PinLockView - -inline fun PinLockView.onPinSet(crossinline continuation: (String) -> Unit) { - setPinLockListener( - object : PinLockListener { - override fun onEmpty() { - } - - override fun onComplete(pin: String) { - continuation(pin) - } - - override fun onPinChange( - pinLength: Int, - intermediatePin: String?, - ) { - } - }, - ) -} diff --git a/app/src/main/java/org/dhis2/utils/session/PinModule.kt b/app/src/main/java/org/dhis2/utils/session/PinModule.kt deleted file mode 100644 index b09629983b3..00000000000 --- a/app/src/main/java/org/dhis2/utils/session/PinModule.kt +++ /dev/null @@ -1,42 +0,0 @@ -/* - * Copyright (c) 2004 - 2019, University of Oslo - * All rights reserved. - * Redistribution and use in source and binary forms, with or without - * modification, are permitted provided that the following conditions are met: - * Redistributions of source code must retain the above copyright notice, this - * list of conditions and the following disclaimer. - * Redistributions in binary form must reproduce the above copyright notice, - * this list of conditions and the following disclaimer in the documentation - * and/or other materials provided with the distribution. - * Neither the name of the HISP project nor the names of its contributors may - * be used to endorse or promote products derived from this software without - * specific prior written permission. - * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND - * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED - * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE - * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR - * ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES - * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; - * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON - * ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT - * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS - * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. - */ - -package org.dhis2.utils.session - -import dagger.Module -import dagger.Provides -import org.dhis2.commons.prefs.PreferenceProvider -import org.hisp.dhis.android.core.D2 - -@Module -class PinModule( - val view: PinView, -) { - @Provides - fun providesPresenter( - d2: D2, - preferenceProvider: PreferenceProvider, - ): PinPresenter = PinPresenter(view, preferenceProvider, d2) -} diff --git a/app/src/main/java/org/dhis2/utils/session/PinPresenter.kt b/app/src/main/java/org/dhis2/utils/session/PinPresenter.kt deleted file mode 100644 index ddbd8e97112..00000000000 --- a/app/src/main/java/org/dhis2/utils/session/PinPresenter.kt +++ /dev/null @@ -1,58 +0,0 @@ -package org.dhis2.utils.session - -import org.dhis2.commons.prefs.Preference -import org.dhis2.commons.prefs.PreferenceProvider -import org.hisp.dhis.android.core.D2 -import timber.log.Timber - -class PinPresenter( - val view: PinView, - val preferenceProvider: PreferenceProvider, - val d2: D2, -) { - fun unlockSession( - pin: String, - attempts: Int, - onPinCorrect: () -> Unit, - onError: () -> Unit, - onTwoManyAttempts: () -> Unit, - ) { - val pinStored = - d2 - .dataStoreModule() - .localDataStore() - .value(Preference.PIN) - .blockingGet() - ?.value() - when { - pinStored == pin -> { - onPinCorrect() - } - attempts < 2 -> onError() - else -> onTwoManyAttempts() - } - } - - fun savePin(pin: String) { - d2 - .dataStoreModule() - .localDataStore() - .value(Preference.PIN) - .blockingSet(pin) - preferenceProvider.setValue(Preference.SESSION_LOCKED, true) - } - - fun logOut() { - try { - d2 - .dataStoreModule() - .localDataStore() - .value(Preference.PIN) - .blockingDelete() - d2.userModule().blockingLogOut() - preferenceProvider.setValue(Preference.SESSION_LOCKED, false) - } catch (e: Exception) { - Timber.e(e) - } - } -} diff --git a/app/src/main/java/org/dhis2/utils/session/PinView.kt b/app/src/main/java/org/dhis2/utils/session/PinView.kt deleted file mode 100644 index 1f5df61eb7d..00000000000 --- a/app/src/main/java/org/dhis2/utils/session/PinView.kt +++ /dev/null @@ -1,7 +0,0 @@ -package org.dhis2.utils.session - -interface PinView { - fun closeDialog() - - fun recoverPin() -} diff --git a/app/src/main/java/org/dhis2/utils/session/SessionComponent.kt b/app/src/main/java/org/dhis2/utils/session/SessionComponent.kt deleted file mode 100644 index ea6b8372ca4..00000000000 --- a/app/src/main/java/org/dhis2/utils/session/SessionComponent.kt +++ /dev/null @@ -1,33 +0,0 @@ -/* - * Copyright (c) 2004 - 2019, University of Oslo - * All rights reserved. - * Redistribution and use in source and binary forms, with or without - * modification, are permitted provided that the following conditions are met: - * Redistributions of source code must retain the above copyright notice, this - * list of conditions and the following disclaimer. - * Redistributions in binary form must reproduce the above copyright notice, - * this list of conditions and the following disclaimer in the documentation - * and/or other materials provided with the distribution. - * Neither the name of the HISP project nor the names of its contributors may - * be used to endorse or promote products derived from this software without - * specific prior written permission. - * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND - * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED - * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE - * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR - * ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES - * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; - * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON - * ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT - * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS - * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. - */ - -package org.dhis2.utils.session - -import dagger.Subcomponent - -@Subcomponent(modules = [PinModule::class]) -interface SessionComponent { - fun inject(pinDialog: PinDialog) -} diff --git a/app/src/main/res/layout-land/layout_pin.xml b/app/src/main/res/layout-land/layout_pin.xml deleted file mode 100644 index 3a8dd114cc2..00000000000 --- a/app/src/main/res/layout-land/layout_pin.xml +++ /dev/null @@ -1,89 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml index c8565416d31..046b0745396 100644 --- a/app/src/main/res/layout/activity_main.xml +++ b/app/src/main/res/layout/activity_main.xml @@ -193,11 +193,6 @@ app:itemTextColor="@color/text_black_333" app:menu="@menu/main_menu" /> - - diff --git a/app/src/main/res/layout/dialog_pin.xml b/app/src/main/res/layout/dialog_pin.xml deleted file mode 100644 index 2a7ee79489a..00000000000 --- a/app/src/main/res/layout/dialog_pin.xml +++ /dev/null @@ -1,87 +0,0 @@ - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/layout/layout_pin.xml b/app/src/main/res/layout/layout_pin.xml deleted file mode 100644 index 284544d1295..00000000000 --- a/app/src/main/res/layout/layout_pin.xml +++ /dev/null @@ -1,101 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/values-ar/strings.xml b/app/src/main/res/values-ar/strings.xml index 88db2cf3a2b..323c3fe4957 100644 --- a/app/src/main/res/values-ar/strings.xml +++ b/app/src/main/res/values-ar/strings.xml @@ -37,7 +37,6 @@ خطأ في المزامنة حدث خطأ أثناء المزامنة. يرجى اعادة المحاولة. انتهت المزامنة ولكن لم نتلق تأكيدًا من الخادم لجميع السجلات. هذه السجلات لا تزال تحمل علامة \"غير مزامن\" في التطبيق. نوصي بإعادة محاولة المزامنة. - تحذير: تم مزامنة معظم البيانات الخاصة بك ولكن يوجد تعارضات في بعض الحقول. قم بعمل مزامنة للتكوين الخاص بك وحاول مرة أخرى. إذا استمر التحذير ، فتحقق من حالة المزامنة: يتم وضع علامة @ على البرامج و TEI والأحداث المتعارضة. حدث خطأ ولم يستجب الخادم. تواصل مع مديرك. تم العثور على %s نتيجة @@ -488,6 +487,7 @@ قبول متابعة رابط الخادم + أدخل عنوان URL لخادم DHIS 2 لمؤسستك على النحو الذي حصلت عليه من مسؤول النظام.\n\n أو بدلاً من ذلك ، يمكنك مسح رمز الـ QR الذي يحتوي على عنوان URL لخادمك نسيت الرمز الخاص بك؟ ما الذي تريد فعله لهذا الحدث؟ إنهاء @@ -789,7 +789,7 @@ ليس لديك إذن لتحرير هذه البيانات هذه البيانات غير قابلة للتعديل لأنه تم اغلاق الوحدة التنظيمية ليس لديك إذن لتحرير هذه البيانات - لا يمكنك تحرير البيانات من هذه الوحدة التنظيمية + لا يمكنك تحرير البيانات من هذه الوحدة التنظيمية إعادة البحث البحث عبر الإتصال بالسيرفر عن طريق الإنترنت ارجع الرسالة التالية: تحذير قواعد البرنامج diff --git a/app/src/main/res/values-cs/strings.xml b/app/src/main/res/values-cs/strings.xml index 3adb854eeac..bd8d02aa02d 100644 --- a/app/src/main/res/values-cs/strings.xml +++ b/app/src/main/res/values-cs/strings.xml @@ -37,7 +37,6 @@ Synchronizace dokončena, ale neobdrželi jsme potvrzení serveru pro všechny záznamy. Tyto záznamy jsou v aplikaci stále označeny jako „offline“. Doporučujeme synchronizaci zopakovat. CHYBA: V procesu synchronizace se něco pokazilo. Pokud máte připojení, zkuste to prosím znovu. Pokud chyba přetrvává, obraťte se na správce. Ve vašich datech jsou rozpory. Synchronizujte prosím svou konfiguraci, zkontrolujte svá data a zkuste synchronizaci provést znovu. - UPOZORNĚNÍ: Většina vašich dat je synchronizována, ale některá pole vykazovala konflikty. Synchronizujte svou konfiguraci a zkuste to znovu. Pokud varování přetrvává, zkontrolujte jejich stav synchronizace: programy, TEI a události s konflikty jsou označeny jako @. Některá vaše data se nepodařilo synchronizovat. Došlo k chybě a server neodpovídá. Kontaktujte svého správce. @@ -682,9 +681,9 @@ Detaily Komentáře Uloženo! - Chcete zkontrolovat kvalitu dat? + Chcete zkontrolovat kvalitu dat\? Všechno vypadá dobře! - Chcete také vyplnit soubor dat? + Chcete také vyplnit soubor dat\? Označit jak dokončené Název Vzorec @@ -808,7 +807,7 @@ K úpravě těchto údajů nemáte oprávnění Tato data nelze upravovat, protože organizační jednotka je zavřená K úpravě těchto údajů nemáte oprávnění - Data z této organizační jednotky nemůžete upravovat + Data z této organizační jednotky nemůžete upravovat Obnovit vyhledávání Online vyhledávání vrátilo následující zprávu: Upozornění na pravidla programu diff --git a/app/src/main/res/values-es-rES/strings.xml b/app/src/main/res/values-es-rES/strings.xml index 4224606fe9b..955d1e8deef 100644 --- a/app/src/main/res/values-es-rES/strings.xml +++ b/app/src/main/res/values-es-rES/strings.xml @@ -34,7 +34,6 @@ Error en sincronizacion La sincronizacion ha fallado. Intentelo de nuevo. La sincronización ha terminado pero no se ha recibido confirmación del servidor en algunos rescursos. Éstos estarán marcados como \"no sincronizados\" en la App. Recomendamos reintentar la sincronización. - WARNING: la mayoría de sus datos se han sincronizado, pero algunos generaron conflictos. Sincronice su configuración y pruebe otra vez. Si el error persiste, revise el estado de sincronización: los programas, TEIs y events con conflictos tienen el icono @ Hubo un error y el serivdor no respondío. Contacte su administrador. %s resultados @@ -461,6 +460,7 @@ Confirmar Seguir URL de servidor + Introduzca la URL del servidor DHIS 2 de su organización proporcionada por su administrador. También puede escanear un código QR con la URL de su servidor. ¿Olvido su código? ¿Que quiere hacer con este evento? FIN @@ -643,9 +643,9 @@ Detalles Comentarios ¡Guardado! - ¿Quiere validar la calidad de los datos? + ¿Quieree validar la calidad de los datos\? ¡Parece que todo está bien! - ¿Quiere completar también el set de datos? + ¿Quiere completar también el set de datos\? Marcar como completado Nombre Fórmula @@ -764,7 +764,7 @@ No tiene permisos de edición para estos datos. Los datos no son editables por que la unidad organizativa está cerrada. No tiene permisos de edición para estos datos. - No puede editar datos en esta unidad organizativa. + No puede editar datos en esta unidad organizativa. Reiniciar búsqueda La búsqueda online a devuelto este mensaje: Advertencia de reglas del programa diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 0ba47fac40a..c2d1d6772e1 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -34,7 +34,6 @@ Error en sincronizacion La sincronizacion ha fallado. Intentelo de nuevo. La sincronización ha terminado pero no se ha recibido confirmación del servidor en algunos rescursos. Éstos estarán marcados como \"no sincronizados\" en la App. Recomendamos reintentar la sincronización. - WARNING: la mayoría de sus datos se han sincronizado, pero algunos generaron conflictos. Sincronice su configuración y pruebe otra vez. Si el error persiste, revise el estado de sincronización: los programas, TEIs y events con conflictos tienen el icono @ Hubo un error y el serivdor no respondío. Contacte su administrador. %s resultados @@ -492,6 +491,7 @@ Confirmar Seguir URL de servidor + Introduzca la URL del servidor DHIS 2 de su organización proporcionada por su administrador. También puede escanear un código QR con la URL de su servidor. ¿Olvido su código? ¿Que quiere hacer con este evento? FIN @@ -675,9 +675,9 @@ Detalles Comentarios ¡Guardado! - ¿Quiere validar la calidad de los datos? + ¿Quieree validar la calidad de los datos\? ¡Parece que todo está bien! - ¿Quiere completar también el set de datos? + ¿Quiere completar también el set de datos\? Marcar como completado Nombre Fórmula @@ -801,7 +801,7 @@ No tiene permisos de edición para estos datos. Los datos no son editables por que la unidad organizativa está cerrada. No tiene permisos de edición para estos datos. - No puede editar datos en esta unidad organizativa. + No puede editar datos en esta unidad organizativa. Reiniciar búsqueda La búsqueda online a devuelto este mensaje: Advertencia de reglas del programa diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index d7e5e5cab2f..154cb28b97b 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -34,7 +34,6 @@ Problème de synchronisation Une erreur s´est produite pendant sync. Essayez à nouveau. La synchronisation est terminée, mais nous n\'avons pas reçu de confirmation du serveur pour tous les enregistrements. Ces enregistrements sont toujours marqués comme \"hors ligne\" dans l\'application. Nous vous recommandons de réessayer la synchronisation. - AVERTISSEMENT: La plupart de vos données ont été syncrhonisées mais certains champs ont eu des conflits. Synchronisez votre configuration et ressayez, Si l\'erreur persiste, vérifiez le statut de synchronisation: les programmes, TEIs et évènements avec conflits sont marqués avec @. Une erreur s\'est produite et le serveur n\'a pas répondu. Contactez votre administrateur. %s résultats trouvé @@ -112,7 +111,7 @@ Mettre à jour Date Unités d´Organisation - Il n\'y a pas d\'Unité d\'organisation Ouvert pour la date sélectionnée. Merci de sélectionner une autre date ou une autre Unité d\'Organisation. + Il n\'y a pas d\'unité d\'organisation ouverte pour la date sélectionnée. Merci de sélectionner une autre date ou une autre unité d\'organisation. Options de catégorie Latitude Longitude @@ -299,7 +298,7 @@ Aucune donnée Code QR invalide Cet attribut ne peut pas être ajouté - Cette inscription ne peu pas être ajoutée + Cette entité ne peut pas être enrôlée À propos Programmes disponibles pour l´admission @@ -469,6 +468,7 @@ Accord Continuer URL du serveur + Saisissez l\'URL du serveur DHIS 2 de votre organisation tel que fourni par votre administrateur système.\n\nVous pouvez également scanner le code QR avec le lien URL du serveur Oublié le code? Que souhaitez-vous faire pour cet évènement? FIN @@ -486,7 +486,7 @@ Maintenant vous pouvez vérifier l\'information de synchronisation en cliquant ici Cette donnée est enregistrée seulement sur votre appareil et n\'a pas été synchronisée avec le serveur. Vous êtes à jour! Toutes vos données ont été envoyées au serveur. - Vous ne pouvez pas établir de relation une donnée avec elle-même + Vous ne pouvez pas établir de relation entre une donnée et elle-même Dévérouiller la session Terminer Ouvrir @@ -498,7 +498,7 @@ Hebdomadaire Semaine Paramètre de synchronisation: %s - La donnée à laquelle vous essayer d\'acceéer n\'a pas d\'enrôlement. Vous ne pouvez pas accéder au tableau de bord. + La donnée à laquelle vous essayer d\'accéder n\'a pas d\'enrôlement. Vous ne pouvez pas accéder au tableau de bord. Erreur de formattage Supprimer 1%s Vous n\'avez pas l\'autorité pour supprimer cette donnée @@ -651,9 +651,9 @@ Details Commentaires Enregistré! - Voulez-vous vérifier la qualité des données? + Voulez-vous vérifier la qualité des données\? Tout a l\'air bien! - Voulez-vous également compléter l\'ensemble de données? + Voulez-vous également compléter l\'ensemble de données\? Marquer comme terminé Nom Formule @@ -761,14 +761,14 @@ Valeurs Semaine %d %s à %s Ne pas montrer de nouveau - 1%s ne peut pas être analysé comme une date + %s ne peut pas être analysé comme une date Ce champ n\'a pas pu être mis à jour. Veuillez réessayer Disponible Ces données ne sont pas modifiables car elles sont marquées comme terminées Cette donnée n\'est pas modifiable car son heure d\'édition est expirée Vous n\'êtes pas autorisé à modifier ces données Vous n\'êtes pas autorisé à modifier ces données - Vous ne pouvez pas modifier les données de cette unité organisationnelle + Vous ne pouvez pas modifier les données de cette unité organisationnelle Réinitialiser la recherche La recherche en ligne a renvoyé le message suivant : Avertissement relatif aux règles du programme diff --git a/app/src/main/res/values-hi-rIN/strings.xml b/app/src/main/res/values-hi-rIN/strings.xml index e103b8d156d..d8deda45566 100644 --- a/app/src/main/res/values-hi-rIN/strings.xml +++ b/app/src/main/res/values-hi-rIN/strings.xml @@ -79,7 +79,7 @@ केवल 0 से 1 के मान ही अनुमति हैं केवल 0 से 100 के वैल्यूज़ अनुमति हैं नया इवेंट जनरेट करें - क्या आप एक और इवेंट बनाना चाहते हैं? + क्या आप एक और इवेंट बनाना चाहते हैं\? इंडिकेटर्स इंडिकेटर्स खत्म @@ -129,7 +129,7 @@ पूरा हुआ डेटा पूरा हुआ - क्या आप सुनिश्चित हैं? + क्या आप सुनिश्चित हैं\? तारीख यह फ़ील्ड आवश्यक है पीछे जाएं @@ -162,8 +162,8 @@ इवेंट खोज साफ़ करें वैकल्पिक - उपयोग के लिए कोई खोज फ़ील्ड कॉन्फ़िगर नहीं है\nकृपया अपने एडमिन से संपर्क करें + उपयोग के लिए कोई खोज फ़ील्ड कॉन्फ़िगर नहीं है और दिखाएँ कम दिखाएँ - यह सेक्शन सही तरह से कॉन्फ़िगर नहीं है\nअपने एडमिन से संपर्क करें + यह सेक्शन सही तरह से कॉन्फ़िगर नहीं है\n अपने एडमिन से संपर्क करें diff --git a/app/src/main/res/values-id/strings.xml b/app/src/main/res/values-id/strings.xml index e7bde4535ac..7087c637509 100644 --- a/app/src/main/res/values-id/strings.xml +++ b/app/src/main/res/values-id/strings.xml @@ -31,7 +31,6 @@ Sync error Terjadi kesalahan saat sinkronisasi. Silakan coba kembali. - PERINGATAN: Sebagian besar data telah tersinkronisasi tapi terdapat beberapa konflik. Sinkronisasi konfigurasi Anda dan coba kembali. Jika status sinkronisasi: program, TEI, dan event yang berkonflik ditandai sebagai @. Terjadi kesalahan dan server tidak merespon. Hubungi administrator anda. %s hasil ditemukan @@ -453,6 +452,7 @@ Setuju Terus URL server + Masukkan url server DHIS 2 untuk organisasi Anda seperti yang disediakan oleh administrator sistem Anda.\n\nAtau, Anda dapat memindai kode QR yang berisi URL server Anda Lupa kode Anda? Apa yang ingin Anda lakukan untuk even ini? AKHIR @@ -745,9 +745,9 @@ Rincian Komentar Tersimpan! - Apakah Anda ingin memeriksa kualitas data? + Apakah Anda ingin memeriksa kualitas data\? Semuanya terlihat bagus! - Apakah Anda juga ingin melengkapi data set? + Apakah Anda juga ingin melengkapi data set\? Tandai sebagai selesai Nama Formula diff --git a/app/src/main/res/values-lo/strings.xml b/app/src/main/res/values-lo/strings.xml index 04563b74d4d..59cb7a5189c 100644 --- a/app/src/main/res/values-lo/strings.xml +++ b/app/src/main/res/values-lo/strings.xml @@ -32,7 +32,6 @@ ຄໍາຜິດທີ່ເກີດຈາກການທໍາງານຮ່ວມກັນຂອງລະບົບ ມີຄໍາຜິດເກີດຂື້ນໃນຂະນະທີ່ລະບົບທໍາງານຮ່ວມກັນ. ກະລຸນາ, ທົດລອງເບີ່ງອີກ... ອັບເດດຂໍ້ມູນໃຫ້ຄືກັນສຳເລັດແລ້ວ ແຕ່ພວກເຮົາບໍ່ໄດ້ຮັບການຢືນຢັນຈາກເຄື່ອງຄອມພິວເຕິ້ໜ່ວຍແມ່ສຳລັບບັນທຶກທັງໝົດ. ເຫຼົ່ານັ້ນຍັງຖືກໝາຍເປັນ \"ອອບລາຍ\" ໃນແອັບ. ພວກເຮົາແນະນຳໃຫ້ລອງການຕັ້ງຄ່າການຊິ້ງຂໍ້ມູນອີກຄັ້ງ. - ຂໍ້ຄວນລະວັງ: ຂໍ້ມູນຂອງທ່ານໄດ້ຖຶກຊິ້ງແລ້ວ ແຕ່ບາງຂໍ້ມູນມີຂໍ້ຂັດແຍ້ງ. ກົດການຕັ້ງຄ່າການຊິ້ງຂໍ້ມູນ ແລະ ລອງໃໝ່ອີກຄັ້ງ. ຖ້າຂໍ້ຄວນລະວັງຍັງຄົງຢູ່ ໃຫ້ກວດເບີ່ງສະຖານະການຊິິ້ງຂອງ: ໂປຣແກຣມ, TEI\'s ແລະ ເຫດການທີ່ມີການຂັດແຍ້ງຈະຖຶກໝາຍເປັນ @. ເກີດຂໍ້ຜິດພາດ ແລະເຄື່ອງຄອມພິວເຕິ້ໜ່ວຍແມ່ບໍ່ຕອບສະໜອງ. ຕິດຕໍ່ຜູ້ດູແລລະບົບຂອງທ່ານ. %sຜົນໄດ້ຮັບທີ່ຖືກພົບເຫັນ @@ -465,6 +464,7 @@ ຍິນຍອມ ຕໍ່ໄປ ທີ່ຢູ່ຂອງເຄື່ອງຄອມພີວເຕີໜ່ວຍແມ່ + ປ້ອນທີ່ຢູ່ຂອງຄອມພິວເຕີໜ່ວຍແມ່ DHIS 2 ສຳລັບອົງກອນຂອງທ່ານ ຕາມທີ່ຜູ້ດູແລລະບົບຂອງທ່ານໃຫ້ມາ ຫຼື ທ່ານສາມາດສະແກນຄິວອາໂຄ້ດທີ່ມີທີ່ຢູ່ຂອງຄອມພິວເຕີໜ່ວຍແມ່ຂອງທ່ານ ລືມລະຫັດຂອງທ່ານ? ທ່ານຕ້ອງເຮັດຫຍັງສໍາລັບເຫດການນີ້? ສິ້ນສຸດ @@ -644,9 +644,9 @@ ລາຍລະອຽດ ຄຳເຫັນ ບັນທຶກແລ້ວ! - ທ່ານຕ້ອງການກວດສອບຄຸນນະພາບຂໍ້ມູນນີ້ບໍ? + ທ່ານຕ້ອງການກວດສອບຄຸນນະພາບຂໍ້ມູນນີ້ບໍ\? ທຸກຢ່າງຮຽບຮ້ອຍດີ - ທ່ານຕ້ອງການເຮັດຊຸດຂໍ້ມູນໃຫ້ຄົບຖ້ວນບໍ? + ທ່ານຕ້ອງການເຮັດຊຸດຂໍ້ມູນໃຫ້ຄົບຖ້ວນບໍ\? ເຮັດໃຫ້ສຳເລັດແລ້ວ ຊື່ Formula @@ -763,7 +763,7 @@ ທ່ານບໍ່ໄດ້ຮັບອານຸຍາດໃນການແກ້ໄຂຂໍ້ມູນນີ້ ຂໍ້ມູນນີ້ບໍ່ສາມາດແກ້ໄຂໄດ້ເນື່ອງຈາກຫົວຫນ່ວຍການຈັດຕັ້ງຖືກປິດໃວ້ຢູ່ ທ່ານບໍ່ໄດ້ຮັບອານຸຍາດໃນການແກ້ໄຂຂໍ້ມູນນີ້ - ທ່ານບໍ່ສາມາດແກ້ໄຂຂໍ້ມູນຈາກຫົວຫນ່ວຍການຈັດຕັ້ງນີ້ໄດ້ + ທ່ານບໍ່ສາມາດແກ້ໄຂຂໍ້ມູນຈາກຫົວຫນ່ວຍການຈັດຕັ້ງນີ້ໄດ້ ລີເຊັດການຄົ້ນຫາ ການຄົ້ນຫາອອນໄລນ໌ສົ່ງຂໍ້ຄວາມຕໍ່ໄປນີ້: ຂໍ້ຄວນລະວັງກົດຂອງສາຍງານ diff --git a/app/src/main/res/values-nb/strings.xml b/app/src/main/res/values-nb/strings.xml index cad1a18384a..5421c33a4fc 100644 --- a/app/src/main/res/values-nb/strings.xml +++ b/app/src/main/res/values-nb/strings.xml @@ -32,7 +32,6 @@ Synkroniseringsfeil En feil har oppstått under synkronisering. Vennligst prøv på nytt - ADVARSEL: Mesteparten av dine data er synkroniserte men noen felter presenterte konflinkter. Synkroniser din konfigurasjon og prøv igjen. Hvis advarselen vedvarer, sjekk deres synkroniseringsstatus: programmer, TEI\'s og hendelser med konflikter er merket som @. %sresultater funnet Legg til ny Legg til @@ -454,6 +453,7 @@ Enig Fortsett Server URL + Angi DHIS 2 server URL for din organisasjons som er oppgitt av din systemadministrator.\n\nAlternativt, kan du skanne en QR kode som inneholder URL til din server Har du glemt koden? Hva vil du gjøre for denne hendelsen? SLUTT @@ -633,9 +633,9 @@ Detaljer Kommentarer Lagret! - Vil du sjekke datakvaliteten? + Vil du sjekke datakvaliteten\? Alt ser bra ut - Vil du også fullføre datasettet? + Vil du også fullføre datasettet\? Merk som fullført Navn Formel diff --git a/app/src/main/res/values-nl/strings.xml b/app/src/main/res/values-nl/strings.xml index fcc4441995e..ab850589d2b 100644 --- a/app/src/main/res/values-nl/strings.xml +++ b/app/src/main/res/values-nl/strings.xml @@ -33,7 +33,6 @@ Synchronisatiefout Er is een fout opgetreden tijdens de synchronisatie. Probeer het opnieuw. De synchronisatie is voltooid, maar we hebben geen serverbevestiging ontvangen voor alle records. Die gegevens zijn nog steeds gemarkeerd als \"offline\" in de app. We raden aan om de synchronisatie opnieuw te proberen. - WAARSCHUWING: de meeste van uw gegevens zijn gesynchroniseerd, maar sommige velden leverden problemen op. Synchroniseer uw configuratie en probeer het opnieuw. Als de waarschuwing aanhoudt, controleer dan hun synchronisatiestatus: programma\'s, TEI\'s en gebeurtenissen met conflicten zijn gemarkeerd als @. Er is een fout opgetreden en de server heeft niet gereageerd. Neem contact op met uw beheerder. %s resultaten gevonden @@ -466,6 +465,7 @@ Mee eens zijn Doorgaan Server-URL + Voer de DHIS 2-server-URL voor uw organisatie in zoals verstrekt door uw systeembeheerder.\n\nU kunt ook een QR-code scannen met de URL van uw server Ben je je code vergeten? Wat zou je willen doen voor dit evenement? EINDE @@ -648,9 +648,9 @@ Details Opmerkingen Opgeslagen! - Wilt u de datakwaliteit controleren? + Wilt u de datakwaliteit controleren\? Alles ziet er goed uit! - Wil je ook de dataset compleet maken? + Wil je ook de dataset compleet maken\? Markeer als voltooid Naam Formule @@ -770,7 +770,7 @@ U heeft geen toestemming om deze gegevens te bewerken Deze gegevens zijn niet bewerkbaar omdat de organisatie-eenheid gesloten is U heeft geen toestemming om deze gegevens te bewerken - U kunt gegevens van deze organisatie-eenheid niet bewerken + U kunt gegevens van deze organisatie-eenheid niet bewerken Zoeken resetten De online zoekopdracht leverde het volgende bericht op: Waarschuwing programmaregels diff --git a/app/src/main/res/values-pt/strings.xml b/app/src/main/res/values-pt/strings.xml index 2af82023cec..3b4c1441ee8 100644 --- a/app/src/main/res/values-pt/strings.xml +++ b/app/src/main/res/values-pt/strings.xml @@ -34,7 +34,6 @@ Erro de sincronização Ocorreu um erro durante a sincronização. Por favor tente novamente. A sincronização foi concluída, mas não recebemos a confirmação do servidor para todos os registos. Esses registos ainda estão marcados como “offline” na aplicação. Recomendamos que tente novamente a sincronização. - AVISO: A maioria dos seus dados é sincronizada, mas alguns campos apresentaram conflitos. Sincronize sua configuração e tente novamente. Se o aviso persistir, verifique o status de sincronização: programas, TEIs e eventos com conflitos são marcados como @. Ocorreu um erro e o servidor não respondeu. Contacte o seu administrador. %s resultados encontrados @@ -489,6 +488,7 @@ Aceita Continuar url do servidor + Digite o URL do servidor DHIS 2 da sua organização, conforme fornecido pelo administrador do sistema.\n\nAlternativamente, você pode digitalizar um código QR contendo o URL do seu servido Esqueceu seu código? O que você gostaria de fazer para este evento? FIM @@ -671,9 +671,9 @@ Detalhes Comentarios Gravado! - Você quer verificar a qualidade dos dados? + Você quer verificar a qualidade dos dados\? Tudo parece bem! - Você também deseja completar o conjunto de dados? + Você também deseja completar o conjunto de dados\? Marcar como completado Nome Fórmula @@ -796,7 +796,7 @@ Não tem permissão para editar estes dados Estes dados não são editáveis porque a unidade organizacional está encerrada Não tem permissão para editar estes dados - Não é possível editar dados desta unidade organizacional + Não é possível editar dados desta unidade organizacional Repor a pesquisa A pesquisa em linha deu a seguinte mensagem: Aviso de regras do programa diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index bbf63ec9bdb..7714bdd2226 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -35,7 +35,6 @@ Ошибка синхронизации Во время синхронизации произошла ошибка. Пожалуйста, повторите попытку. Синхронизация завершена, но мы не получили подтверждения сервера для всех записей. Эти записи по-прежнему отмечены в приложении как \"оффлайн\". Мы рекомендуем повторить синхронизацию. - ВНИМАНИЕ: Большая часть данных синхронизирована, но некоторые поля представлены с конфликтами. Синхронизируйте конфигурацию и повторите попытку. Если предупреждение сохраняется, проверьте статус синхронизации: программы, отслеживаемые объекты и события с конфликтами отмечены как @. Произошла ошибка, и сервер не ответил. Обратитесь к своему администратору. %s результатов найдено @@ -475,6 +474,7 @@ Согласен Продолжить Адрес веб-страницы сервера + Введите URL-адрес сервера DHIS 2 для Вашей организации, предоставленный Вашим системным администратором. Другой вариант - отсканировать QR-код, содержащий URL-адрес Вашего сервера. Забыли свой код? Что бы Вы хотели сделать для этого события? КОНЕЦ @@ -645,9 +645,9 @@ Детали Комментарии Сохранено! - Вы хотите проверить качество данных? + Вы хотите проверить качество данных\? Все выглядит хорошо! - Вы также хотите завершить набор данных? + Вы также хотите завершить набор данных\? Отметить как завершенное Название Формула @@ -767,7 +767,7 @@ У Вас нет прав на редактирование этих данных Эти данные нельзя редактировать, потому что организационное подразделение закрыто У вас нет прав на редактирование этих данных - Вы не можете редактировать данные из этой Организационной единицы + Вы не можете редактировать данные из этой Организационной единицы Сброс поиска Онлайн поиск дал следующее сообщение: Предупреждение о правилах программы diff --git a/app/src/main/res/values-uk/strings.xml b/app/src/main/res/values-uk/strings.xml index 255156fec32..14a44145ab3 100644 --- a/app/src/main/res/values-uk/strings.xml +++ b/app/src/main/res/values-uk/strings.xml @@ -35,7 +35,6 @@ Помилка синхронізації Під час синхронізації сталася помилка. Будь ласка, повторіть спробу. Синхронізацію завершено, але ми не отримали підтвердження від серверу для усіх записів. Ці записи все ще позначено як \"офлайн\" у програмі. Рекомендуємо повторити спробу синхронізації. - ПОПЕРЕДЖЕННЯ: Більшість Ваших даних синхронізовано, але виникли конфлікти в деяких полях. Синхронізуйте конфігурацію та повторіть спробу. Якщо попередження не зникає, перевірте стан синхронізації: програм, об’єктів, які відстежуються та випадків з конфліктами позначені як @. Сталася помилка, і сервер не відповідає. Зверніться до свого адміністратора. %s результатів знайдено @@ -478,6 +477,7 @@ Погодитись Продовжити URL-адреса серверу + Введіть URL-адресу серверу DHIS 2 для Вашої організації, надану Вашим системним адміністратором. Альтернативно, Ви можете відсканувати QR-код, що містить URL-адресу Вашого сервера Забули код? Що б Ви хотіли зробити для цього випадку? КІН @@ -660,9 +660,9 @@ Деталі Коментарі Збережено! - Хочете перевірити якість даних? + Хочете перевірити якість даних\? Все виглядає добре! - Ви бажаєте також завершити набір даних? + Ви бажаєте також завершити набір даних\? Позначити як виконане Ім\'я Формула @@ -783,7 +783,7 @@ Ви не маєте дозволу редагувати ці дані Ці дані не можна редагувати, оскільки організаційний підрозділ закрито Ви не маєте дозволу редагувати ці дані - Ви не можете редагувати дані з цього організаційного підрозділу + Ви не можете редагувати дані з цього організаційного підрозділу Скинути пошук Онлайн-пошук дав таке повідомлення: Попередження про правила програми diff --git a/app/src/main/res/values-uz-rUZ/strings.xml b/app/src/main/res/values-uz-rUZ/strings.xml index 05321637d82..39c1a3447cb 100644 --- a/app/src/main/res/values-uz-rUZ/strings.xml +++ b/app/src/main/res/values-uz-rUZ/strings.xml @@ -26,7 +26,6 @@ Sinxronlashda xatolik Sinxronlash paytida xatolik yuz berdi. Iltimos, qayta urinib koʼring - OGOHLАNTIRISh: Maʼlumotlarning aksariyat qismi sinxronlashtirildi. Lekin, baʼzi ziddiyatlar kelib chiqdi. Konfiguratsiyani sinxronlang va qaytadan urinib koʼring. Аgar ogohlantirish davom etsa, ularning sinxronlash holatini tekshiring: @ sifatida belgilangan dasturlar, TEIni va hodisa/tadbirlarni. 1 %s natijalar topildi Янгисини қўшиш Qoʼshish @@ -435,6 +434,7 @@ Roziman Davom etish Server URLi + Tashkilotingiz uchun tizim maʼmuri tomonidan taqdim etilgan DHIS2 server URL manzilini kiriting. \n\ Boshqa holda, siz serveringiz URL manzilini oʼz ichiga olgan QR kodini skanerlashingiz mumkin. Kodingizni unutdingizmi? Ushbu hodisa/tadbir uchun nima qilmoqchisiz? TАMOM @@ -612,9 +612,9 @@ Тафсилотлари Izohlar Saqlandi! - Maʼlumotlar sifatini tekshirishni xoxlaysizmi? + Maʼlumotlar sifatini tekshirishni xoxlaysizmi\? Koʼrinishidan hammasi yaxshi! - Maʼlumotlar toʼplamini toʼldirishni xoxlaysizmi? + Maʼlumotlar toʼplamini toʼldirishni xoxlaysizmi\? Tugallangan deb belgilang Исми Formula diff --git a/app/src/main/res/values-uz/strings.xml b/app/src/main/res/values-uz/strings.xml index af150944719..93865dddd30 100644 --- a/app/src/main/res/values-uz/strings.xml +++ b/app/src/main/res/values-uz/strings.xml @@ -31,7 +31,6 @@ Синхронлашда хатолик Синхронлаш пайтида хатолик юз берди. Илтимос, қайта уриниб кўринг - ОГОҲЛАНТИРИШ: Маълумотларнинг аксарият қисми синхронлаштирилди. Лекин, баъзи зиддиятлар келиб чиқди. Конфигурацияни синхронланг ва қайтадан уриниб кўринг. Агар огоҳлантириш давом этса, уларнинг синхронлаш ҳолатини текширинг: @ сифатида белгиланган дастурлар, TEIни ва ҳодиса/тадбирларни. Бу ерда хатолик юз берди ва сервер жавоб бермади. Ўз администраторингиз билан алоқа боғланг. 1 %s натижалар топилди @@ -454,6 +453,7 @@ Розиман Давом этиш Сервер URLи + Ташкилотингиз учун тизим маъмури томонидан тақдим этилган DHIS2 сервер URL манзилини киритинг. \n\ Бошқа ҳолда, сиз серверингиз URL манзилини ўз ичига олган QR кодини сканерлашингиз мумкин. Кодингизни унутдингизми? Ушбу ҳодиса/тадбир учун нима қилмоқчисиз? ТАМОМ @@ -635,9 +635,9 @@ Тафсилотлари Изоҳлар Сақланди! - Маълумотлар сифатини текширишни хохлайсизми? + Маълумотлар сифатини текширишни хохлайсизми\? Кўринишидан ҳаммаси яхши! - Маълумотлар тўпламини тўлдиришни хохлайсизми? + Маълумотлар тўпламини тўлдиришни хохлайсизми\? Тугалланган сифатида белгилансин Исми Формула diff --git a/app/src/main/res/values-vi/strings.xml b/app/src/main/res/values-vi/strings.xml index 03a600a9022..baa576b366d 100644 --- a/app/src/main/res/values-vi/strings.xml +++ b/app/src/main/res/values-vi/strings.xml @@ -32,7 +32,6 @@ Lỗi đồng bộ Xảy ra lỗi trong quá trình đồng bộ. Vui lòng thử lại. Đồng bộ đã hoàn tất nhưng chúng tôi không nhận được xác nhận máy chủ cho tất cả dữ liệu. Các dữ liệu vẫn được đánh dấu là \"Ngoại tuyến\" trong Ứng dụng. Chúng tôi khuyên bạn thử đồng bộ lại. - CẢNH BÁO: Hầu hết dữ liệu của bạn đã được đồng bộ nhưng một vài dữ liệu vẫn bị xung đột. Hãy thử đồng bộ cấu hình và thử lại. Nếu cảnh báo vẫn tiếp diễn, kiểm tra trạng thái đồng bộ của chương trình, các Đối Tượng Theo Dõi và các sự kiện với các dấu hiệu xung đột là @. Có lỗi và máy chủ không phản hồi. Liên hệ quản trị viên của bạn. Tìm thấy %s kết quả @@ -486,6 +485,7 @@ Đồng ý Tiếp tục Đường dẫn đến máy chủ + Nhập đường dẫn liên kết đến máy chủ DHIS 2 với đơn vị được cung cấp bởi quản lý của bạn.\n\n Hoặc bạn có thể quét mã QR có chứa đường dẫn liên kết đến máy chủ. Bạn đã quên mã của bạn phải không? Bạn muốn làm gì với sự kiện này? KẾT THÚC @@ -668,9 +668,9 @@ Chi tiết Ghi chú Đã lưu! - Ban có muốn kiểm tra chất lượng dữ liệu không? + Ban có muốn kiểm tra chất lượng dữ liệu không\? Mọi thứ đều ổn! - Bạn cũng muốn hoàn tất biểu nhập phải không? + Bạn cũng muốn hoàn tất biểu nhập phải không\? Đánh dấu đã hoàn tất Tên Công thức @@ -785,7 +785,7 @@ Xem Radar Không thể cài đặt kiểu tọa độ Đã theo dõi %s - %s không thể chuyển đổi thành Ngày + %s không thể chuyển đổi dữ liệu Ngày Không thể cập nhật trường dữ liệu này. Xin vui lòng thử lại. Chỉnh sửa Hiện có @@ -794,7 +794,7 @@ Bạn không có quyền chỉnh sửa dữ liệu này Dữ liệu này không thể sửa bởi vì đơn vị đã bị đóng Bạn không có quyền chỉnh sửa dữ liệu này - Bạn không thể chỉnh sửa dữ liệu từ đơn vị này + Bạn không thể chỉnh sửa dữ liệu từ đơn vị này Đặt lại Tìm Kiếm Tìm kiếm trực tuyến trả về tin nhắn sau: Cảnh báo quy tắc chương trình diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 8371a7a180e..f3fc5015bcb 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -32,7 +32,6 @@ 同步错误 同步中发生错误,请重试 同步完成,但我们没有收到所有记录的服务器确认。这些记录在应用程序中仍被标记为“离线”状态。我们建议重试同步。 - 警告:你的大多数数据已经同步,但是有些字段存在冲突,同步你的配置再试。如果警告仍然存在,检查你的同步状态:项目、跟踪实体和事件如果有冲突会标记为@。 发生错误,服务器未响应。与您的管理员联系。 发现结果:1%s @@ -485,6 +484,7 @@ 同意 继续 服务器URL + 输入管理员提供的服务器地址。\n\n 可选的你可以扫描服务器地址 二维码 忘记你的数字口令 对该事件你想做什么? 结束 @@ -792,7 +792,7 @@ 您无权编辑此数据 此数据不可修改,因为单位部门已关闭 您无权编辑此数据 - 您无法编辑此机构的数据 + 您无法编辑此机构的数据 重置搜索 网上搜索返回如下信息: 项目规则警告 diff --git a/app/src/main/res/values-zh/strings.xml b/app/src/main/res/values-zh/strings.xml index ef76fabce31..6da46f0ff47 100644 --- a/app/src/main/res/values-zh/strings.xml +++ b/app/src/main/res/values-zh/strings.xml @@ -34,7 +34,6 @@ 同步完成,但我们没有收到所有记录的服务器确认。这些记录在应用程序中仍被标记为“离线”状态。我们建议重试同步。 失败:同步过程中出了问题。如果有连接,请重试。如果错误仍然存在,请联系您的管理员。 您的数据存在冲突。请同步您的配置,查看数据并尝试再次同步。 - 警告:你的大多数数据已经同步,但是有些字段存在冲突,同步你的配置再试。如果警告仍然存在,检查你的同步状态:项目、跟踪实体和事件如果有冲突会标记为@。 您的部分数据未能同步。 发生错误,服务器未响应。与您的管理员联系。 @@ -820,7 +819,7 @@ 您无权编辑此数据 此数据不可修改,因为单位部门已关闭 您无权编辑此数据 - 您无法编辑此机构的数据 + 您无法编辑此机构的数据 重置搜索 网上搜索返回如下信息: 项目规则警告 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 43c1d6df602..e9a55d33666 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -37,7 +37,6 @@ Sync finished but we did not receive server confirmation for all records. Those record are still marked as \"offline\" in the App. We recommend to retry the synchronization. FAIL: Something went wrong in the sync process. If you have connection please try again. If the error persists, contact your administrator. There are conflicts in your data. Please sync your configuration, review your data and try syncing again. - WARNING: Most of your data is synced but some fields presented conflicts. Sync your configuration and try again. If warning persists, check their sync status: programs, TEI\'s and events with conflicts are marked as @. Some of your data failed to sync. There was an error and the server did not respond. Contact your administrator. @@ -827,7 +826,7 @@ You do not have permission to edit this data This data is not editable because the organization unit is closed You do not have permission to edit this data - You cannot edit data from this organisation unit + You cannot edit data from this organisation unit Reset search The online search returned the following message: Program rules warning diff --git a/app/src/test/java/org/dhis2/bindings/SettingsExtensionsTest.kt b/app/src/test/java/org/dhis2/bindings/SettingsExtensionsTest.kt index c2cc572746f..21bb995381c 100644 --- a/app/src/test/java/org/dhis2/bindings/SettingsExtensionsTest.kt +++ b/app/src/test/java/org/dhis2/bindings/SettingsExtensionsTest.kt @@ -8,6 +8,7 @@ class SettingsExtensionsTest { private val metadataSyncingPeriods = arrayListOf( EVERY_HOUR, + EVERY_6_HOUR, EVERY_12_HOUR, EVERY_24_HOUR, EVERY_7_DAYS, diff --git a/app/src/test/java/org/dhis2/bindings/TEICardExtensionsTest.kt b/app/src/test/java/org/dhis2/bindings/TEICardExtensionsTest.kt index 6aa51739cbf..dd5fb510726 100644 --- a/app/src/test/java/org/dhis2/bindings/TEICardExtensionsTest.kt +++ b/app/src/test/java/org/dhis2/bindings/TEICardExtensionsTest.kt @@ -8,6 +8,7 @@ import org.dhis2.commons.resources.ColorUtils import org.dhis2.commons.resources.ResourceManager import org.dhis2.mobile.commons.model.MetadataIconData import org.hisp.dhis.android.core.common.ObjectStyle +import org.hisp.dhis.android.core.common.ObjectWithUid import org.hisp.dhis.android.core.program.Program import org.junit.Test import org.mockito.kotlin.any @@ -128,5 +129,7 @@ class TEICardExtensionsTest { .color("color") .icon("icon") .build(), - ).build() + ).categoryCombo(ObjectWithUid.create("categoryComboUid")) + .enrollmentCategoryCombo(ObjectWithUid.create("categoryComboUid")) + .build() } diff --git a/app/src/test/java/org/dhis2/data/dhislogic/DhisEnrollmentUtilsTest.kt b/app/src/test/java/org/dhis2/data/dhislogic/DhisEnrollmentUtilsTest.kt index 0fe12fafebd..bc018e273e9 100644 --- a/app/src/test/java/org/dhis2/data/dhislogic/DhisEnrollmentUtilsTest.kt +++ b/app/src/test/java/org/dhis2/data/dhislogic/DhisEnrollmentUtilsTest.kt @@ -47,6 +47,7 @@ class DhisEnrollmentUtilsTest { .builder() .uid("enrollmentUid") .status(EnrollmentStatus.CANCELLED) + .attributeOptionCombo("attributeOptionComboUid") .build() val result = dhisEnrollmentUtils.isEventEnrollmentOpen( @@ -92,6 +93,7 @@ class DhisEnrollmentUtilsTest { .builder() .uid("enrollmentUid") .status(EnrollmentStatus.ACTIVE) + .attributeOptionCombo("attributeOptionComboUid") .build() val result = dhisEnrollmentUtils.isEventEnrollmentOpen( diff --git a/app/src/test/java/org/dhis2/data/dhislogic/DhisPeriodUtilsTest.kt b/app/src/test/java/org/dhis2/data/dhislogic/DhisPeriodUtilsTest.kt index 423bc8ad1ae..7e32ff6d492 100644 --- a/app/src/test/java/org/dhis2/data/dhislogic/DhisPeriodUtilsTest.kt +++ b/app/src/test/java/org/dhis2/data/dhislogic/DhisPeriodUtilsTest.kt @@ -154,6 +154,31 @@ class DhisPeriodUtilsTest { ) } + @Test + fun `WeeklyFriday period should return expected result`() { + whenever( + periodHelper.blockingGetPeriodForPeriodTypeAndDate( + PeriodType.WeeklyFriday, + testDate, + ), + ) doReturn + Period + .builder() + .periodId("2019ThuW2") + .startDate(GregorianCalendar(2019, 0, 11).time) + .endDate(GregorianCalendar(2019, 0, 17).time) + .build() + + Assert.assertEquals( + "Week 0 2019-01-11 To 2019-01-17", + periodUtils.getPeriodUIString( + PeriodType.WeeklyFriday, + testDate, + Locale.ENGLISH, + ), + ) + } + @Test fun `WeeklySaturday period should return expected result`() { whenever( @@ -430,6 +455,31 @@ class DhisPeriodUtilsTest { ) } + @Test + fun `FinancialFeb period should return expected result`() { + whenever( + periodHelper.blockingGetPeriodForPeriodTypeAndDate( + PeriodType.FinancialFeb, + testDate, + ), + ) doReturn + Period + .builder() + .periodId("periodId") + .startDate(GregorianCalendar(2018, 1, 1).time) + .endDate(GregorianCalendar(2019, 0, 31).time) + .build() + + Assert.assertEquals( + "Feb 2018 - Jan 2019", + periodUtils.getPeriodUIString( + PeriodType.FinancialFeb, + testDate, + Locale.ENGLISH, + ), + ) + } + @Test fun `FinancialApril period should return expected result`() { whenever( @@ -480,6 +530,56 @@ class DhisPeriodUtilsTest { ) } + @Test + fun `FinancialAug period should return expected result`() { + whenever( + periodHelper.blockingGetPeriodForPeriodTypeAndDate( + PeriodType.FinancialAug, + testDate, + ), + ) doReturn + Period + .builder() + .periodId("periodId") + .startDate(GregorianCalendar(2018, 7, 1).time) + .endDate(GregorianCalendar(2019, 6, 31).time) + .build() + + Assert.assertEquals( + "Aug 2018 - Jul 2019", + periodUtils.getPeriodUIString( + PeriodType.FinancialAug, + testDate, + Locale.ENGLISH, + ), + ) + } + + @Test + fun `FinancialSep period should return expected result`() { + whenever( + periodHelper.blockingGetPeriodForPeriodTypeAndDate( + PeriodType.FinancialSep, + testDate, + ), + ) doReturn + Period + .builder() + .periodId("periodId") + .startDate(GregorianCalendar(2018, 8, 1).time) + .endDate(GregorianCalendar(2019, 7, 31).time) + .build() + + Assert.assertEquals( + "Sep 2018 - Aug 2019", + periodUtils.getPeriodUIString( + PeriodType.FinancialSep, + testDate, + Locale.ENGLISH, + ), + ) + } + @Test fun `FinancialOct period should return expected result`() { whenever( diff --git a/app/src/test/java/org/dhis2/data/dhislogic/EnrollmentEventGeneratorTest.kt b/app/src/test/java/org/dhis2/data/dhislogic/EnrollmentEventGeneratorTest.kt index dff2410bd0c..77fc808aa2d 100644 --- a/app/src/test/java/org/dhis2/data/dhislogic/EnrollmentEventGeneratorTest.kt +++ b/app/src/test/java/org/dhis2/data/dhislogic/EnrollmentEventGeneratorTest.kt @@ -1,6 +1,7 @@ package org.dhis2.data.dhislogic import org.dhis2.commons.Constants +import org.hisp.dhis.android.core.common.ObjectWithUid import org.hisp.dhis.android.core.enrollment.Enrollment import org.hisp.dhis.android.core.period.PeriodType import org.hisp.dhis.android.core.program.Program @@ -163,6 +164,7 @@ class EnrollmentEventGeneratorTest { GregorianCalendar(2019, 0, 1).time, ).program(PROGRAM_UID) .organisationUnit(ENROLLMENT_ORG_UNIT) + .attributeOptionCombo("attributeOptionComboUid") .build() private fun mockedProgram(useFirstStage: Boolean) = @@ -170,6 +172,8 @@ class EnrollmentEventGeneratorTest { .builder() .uid(PROGRAM_UID) .useFirstStageDuringRegistration(useFirstStage) + .categoryCombo(ObjectWithUid.create("categoryComboUid")) + .enrollmentCategoryCombo(ObjectWithUid.create("categoryComboUid")) .build() private fun mockedAutogeneratedEvents( diff --git a/app/src/test/java/org/dhis2/data/filter/FilterRepositoryTest.kt b/app/src/test/java/org/dhis2/data/filter/FilterRepositoryTest.kt index fb4eb711151..1fa1ddc8cb8 100644 --- a/app/src/test/java/org/dhis2/data/filter/FilterRepositoryTest.kt +++ b/app/src/test/java/org/dhis2/data/filter/FilterRepositoryTest.kt @@ -18,6 +18,7 @@ import org.dhis2.commons.filters.workingLists.ProgramStageToWorkingListItemMappe import org.dhis2.commons.filters.workingLists.TeiFilterToWorkingListItemMapper import org.hisp.dhis.android.core.D2 import org.hisp.dhis.android.core.category.CategoryCombo +import org.hisp.dhis.android.core.common.ObjectWithUid import org.hisp.dhis.android.core.program.Program import org.hisp.dhis.android.core.settings.FilterSetting import org.hisp.dhis.android.core.settings.HomeFilter @@ -211,6 +212,8 @@ class FilterRepositoryTest { Program .builder() .uid("random") + .categoryCombo(ObjectWithUid.create("categoryComboUid")) + .enrollmentCategoryCombo(ObjectWithUid.create("categoryComboUid")) .programType(org.hisp.dhis.android.core.program.ProgramType.WITH_REGISTRATION) .build() val catCombo = @@ -286,6 +289,8 @@ class FilterRepositoryTest { Program .builder() .uid("random") + .categoryCombo(ObjectWithUid.create("categoryComboUid")) + .enrollmentCategoryCombo(ObjectWithUid.create("categoryComboUid")) .programType(org.hisp.dhis.android.core.program.ProgramType.WITH_REGISTRATION) .build() val catCombo = @@ -380,6 +385,8 @@ class FilterRepositoryTest { Program .builder() .uid("random") + .categoryCombo(ObjectWithUid.create("categoryComboUid")) + .enrollmentCategoryCombo(ObjectWithUid.create("categoryComboUid")) .programType(org.hisp.dhis.android.core.program.ProgramType.WITH_REGISTRATION) .build() val catCombo = @@ -459,6 +466,8 @@ class FilterRepositoryTest { Program .builder() .uid("random") + .categoryCombo(ObjectWithUid.create("categoryComboUid")) + .enrollmentCategoryCombo(ObjectWithUid.create("categoryComboUid")) .programType(org.hisp.dhis.android.core.program.ProgramType.WITH_REGISTRATION) .trackedEntityType( TrackedEntityType @@ -600,6 +609,8 @@ class FilterRepositoryTest { Program .builder() .uid("random") + .categoryCombo(ObjectWithUid.create("categoryComboUid")) + .enrollmentCategoryCombo(ObjectWithUid.create("categoryComboUid")) .programType(org.hisp.dhis.android.core.program.ProgramType.WITH_REGISTRATION) .trackedEntityType( TrackedEntityType diff --git a/app/src/test/java/org/dhis2/data/forms/dataentry/ValueStoreTest.kt b/app/src/test/java/org/dhis2/data/forms/dataentry/ValueStoreTest.kt index d177b550eec..d686b14769a 100644 --- a/app/src/test/java/org/dhis2/data/forms/dataentry/ValueStoreTest.kt +++ b/app/src/test/java/org/dhis2/data/forms/dataentry/ValueStoreTest.kt @@ -1,13 +1,17 @@ package org.dhis2.data.forms.dataentry +import kotlinx.coroutines.Dispatchers import org.dhis2.commons.data.EntryMode import org.dhis2.commons.network.NetworkUtils import org.dhis2.commons.resources.ResourceManager +import org.dhis2.commons.viewmodel.DispatcherProvider import org.dhis2.data.dhislogic.DhisEnrollmentUtils import org.dhis2.form.model.ValueStoreResult +import org.dhis2.mobile.commons.network.NetworkStatusProvider import org.dhis2.mobile.commons.providers.FieldErrorMessageProvider import org.dhis2.mobile.commons.reporting.CrashReportController import org.hisp.dhis.android.core.D2 +import org.hisp.dhis.android.core.common.ObjectWithUid import org.hisp.dhis.android.core.common.ValueType import org.hisp.dhis.android.core.dataelement.DataElement import org.hisp.dhis.android.core.option.Option @@ -27,11 +31,14 @@ class ValueStoreTest { private lateinit var dvValueStore: ValueStore private val d2: D2 = Mockito.mock(D2::class.java, Mockito.RETURNS_DEEP_STUBS) private val dhisEnrollmentUtils: DhisEnrollmentUtils = DhisEnrollmentUtils(d2) - private val fieldErrorMessageProvider: FieldErrorMessageProvider = mock() private val crashReportController: CrashReportController = mock() - private val networkUtils: NetworkUtils = mock() + private val networkStatusProvider: NetworkStatusProvider = mock() private val searchTEIRepository: SearchTEIRepository = mock() private val resourceManager: ResourceManager = mock() + private val dispatchers: DispatcherProvider = + mock { + on { io() } doReturn Dispatchers.IO + } @Before fun setUp() { @@ -42,10 +49,10 @@ class ValueStoreTest { EntryMode.ATTR, dhisEnrollmentUtils, crashReportController, - networkUtils, searchTEIRepository, - fieldErrorMessageProvider, resourceManager, + networkStatusProvider, + dispatchers, ) deValueStore = ValueStoreImpl( @@ -54,10 +61,10 @@ class ValueStoreTest { EntryMode.DE, dhisEnrollmentUtils, crashReportController, - networkUtils, searchTEIRepository, - fieldErrorMessageProvider, resourceManager, + networkStatusProvider, + dispatchers, ) dvValueStore = ValueStoreImpl( @@ -66,10 +73,10 @@ class ValueStoreTest { EntryMode.DV, dhisEnrollmentUtils, crashReportController, - networkUtils, searchTEIRepository, - fieldErrorMessageProvider, resourceManager, + networkStatusProvider, + dispatchers, ) } @@ -346,6 +353,7 @@ class ValueStoreTest { .builder() .uid("fieldUid") .valueType(ValueType.TEXT) + .categoryCombo(ObjectWithUid.create("categoryComboUid")) .build() val storeResult = deValueStore.deleteOptionValueIfSelected( @@ -418,6 +426,7 @@ class ValueStoreTest { .builder() .uid(testingUid) .valueType(ValueType.TEXT) + .categoryCombo(ObjectWithUid.create("categoryComboUid")) .build() whenever( d2 @@ -509,6 +518,7 @@ class ValueStoreTest { .builder() .uid("uid") .valueType(ValueType.TEXT) + .categoryCombo(ObjectWithUid.create("categoryComboUid")) .build() private fun mockedUniqueAttribute(): TrackedEntityAttribute = diff --git a/app/src/test/java/org/dhis2/data/services/SyncPresenterTest.kt b/app/src/test/java/org/dhis2/data/services/SyncPresenterTest.kt index f0b474d582a..32d7b2e1199 100644 --- a/app/src/test/java/org/dhis2/data/services/SyncPresenterTest.kt +++ b/app/src/test/java/org/dhis2/data/services/SyncPresenterTest.kt @@ -1,41 +1,32 @@ package org.dhis2.data.services -import io.reactivex.Completable import io.reactivex.Observable import org.dhis2.commons.bindings.program import org.dhis2.commons.prefs.PreferenceProvider import org.dhis2.data.service.SyncPresenterImpl import org.dhis2.data.service.SyncRepository import org.dhis2.data.service.SyncResult -import org.dhis2.data.service.SyncStatusController -import org.dhis2.data.service.workManager.WorkManagerController -import org.dhis2.utils.analytics.AnalyticsHelper +import org.dhis2.mobile.sync.domain.SyncStatusController import org.hisp.dhis.android.core.D2 -import org.hisp.dhis.android.core.arch.call.BaseD2Progress import org.hisp.dhis.android.core.arch.call.D2Progress import org.hisp.dhis.android.core.arch.call.D2ProgressStatus import org.hisp.dhis.android.core.arch.call.D2ProgressSyncStatus import org.hisp.dhis.android.core.common.State import org.hisp.dhis.android.core.event.Event -import org.hisp.dhis.android.core.fileresource.FileResourceDomainType import org.hisp.dhis.android.core.program.Program import org.hisp.dhis.android.core.program.ProgramType -import org.hisp.dhis.android.core.settings.GeneralSettings import org.hisp.dhis.android.core.settings.LimitScope import org.hisp.dhis.android.core.settings.ProgramSetting import org.hisp.dhis.android.core.settings.ProgramSettings +import org.hisp.dhis.android.core.settings.SynchronizationSettings import org.hisp.dhis.android.core.trackedentity.TrackedEntityInstance import org.hisp.dhis.android.core.tracker.exporter.TrackerD2Progress import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Test import org.mockito.Mockito -import org.mockito.kotlin.any import org.mockito.kotlin.doReturn import org.mockito.kotlin.mock -import org.mockito.kotlin.times -import org.mockito.kotlin.verify -import org.mockito.kotlin.verifyNoMoreInteractions import org.mockito.kotlin.whenever class SyncPresenterTest { @@ -43,8 +34,6 @@ class SyncPresenterTest { private val d2: D2 = Mockito.mock(D2::class.java, Mockito.RETURNS_DEEP_STUBS) private val preferences: PreferenceProvider = mock() - private val workManagerController: WorkManagerController = mock() - private val analyticsHelper: AnalyticsHelper = mock() private val syncStatusController: SyncStatusController = mock() private val syncRepository: SyncRepository = mock() @@ -54,10 +43,8 @@ class SyncPresenterTest { SyncPresenterImpl( d2, preferences, - workManagerController, - analyticsHelper, - syncStatusController, syncRepository, + syncStatusController, ) } @@ -69,8 +56,14 @@ class SyncPresenterTest { 200, LimitScope.GLOBAL, ) + val mockedSyncSettings = + mock { + on { programSettings() } doReturn mockedProgramSettings + } - whenever(d2.settingModule().programSetting().blockingGet()) doReturn mockedProgramSettings + whenever( + d2.settingModule().synchronizationSettings().blockingGet(), + ) doReturn mockedSyncSettings val (eventLimit, limitByOU, limitByProgram) = presenter.getDownloadLimits() @@ -86,8 +79,14 @@ class SyncPresenterTest { LimitScope.PER_OU_AND_PROGRAM, ) - whenever(d2.settingModule().programSetting().blockingGet()) doReturn mockedProgramSettings + val mockedSyncSettings = + mock { + on { programSettings() } doReturn mockedProgramSettings + } + whenever( + d2.settingModule().synchronizationSettings().blockingGet(), + ) doReturn mockedSyncSettings val (eventLimit, limitByOU, limitByProgram) = presenter.getDownloadLimits() assertTrue(eventLimit == 200 && limitByOU && limitByProgram) @@ -102,8 +101,14 @@ class SyncPresenterTest { LimitScope.PER_PROGRAM, ) - whenever(d2.settingModule().programSetting().blockingGet()) doReturn mockedProgramSettings + val mockedSyncSettings = + mock { + on { programSettings() } doReturn mockedProgramSettings + } + whenever( + d2.settingModule().synchronizationSettings().blockingGet(), + ) doReturn mockedSyncSettings val (eventLimit, limitByOU, limitByProgram) = presenter.getDownloadLimits() assertTrue(eventLimit == 200 && !limitByOU && limitByProgram) @@ -118,137 +123,19 @@ class SyncPresenterTest { LimitScope.PER_ORG_UNIT, ) - whenever(d2.settingModule().programSetting().blockingGet()) doReturn mockedProgramSettings + val mockedSyncSettings = + mock { + on { programSettings() } doReturn mockedProgramSettings + } + whenever( + d2.settingModule().synchronizationSettings().blockingGet(), + ) doReturn mockedSyncSettings val (eventLimit, limitByOU, limitByProgram) = presenter.getDownloadLimits() assertTrue(eventLimit == 200 && limitByOU && !limitByProgram) } - @Test - fun `Should configure secondary tracker if configuration exists`() { - whenever( - d2.metadataModule().download(), - ) doReturn - Observable.fromArray( - BaseD2Progress.empty(2), - ) - whenever( - d2.settingModule().generalSetting().blockingGet(), - ) doReturn - GeneralSettings - .builder() - .encryptDB(false) - .matomoID(11111) - .matomoURL("MatomoURL") - .build() - whenever( - d2.mapsModule().mapLayersDownloader().downloadMetadata(), - ) doReturn Completable.complete() - - whenever( - d2 - .fileResourceModule() - .fileResourceDownloader() - .byDomainType() - .eq(FileResourceDomainType.ICON) - .download(), - ) doReturn Observable.just(BaseD2Progress.empty(1)) - - presenter.syncMetadata { } - - verify(analyticsHelper, times(1)).updateMatomoSecondaryTracker(any(), any(), any()) - } - - @Test - fun `Should not configure secondary tracker if matomo settings is missing`() { - whenever( - d2.metadataModule().download(), - ) doReturn - Observable.fromArray( - BaseD2Progress.empty(2), - ) - whenever( - d2.settingModule().generalSetting().blockingGet(), - ) doReturn - GeneralSettings - .builder() - .encryptDB(false) - .build() - whenever( - d2.mapsModule().mapLayersDownloader().downloadMetadata(), - ) doReturn Completable.complete() - whenever( - d2 - .fileResourceModule() - .fileResourceDownloader() - .byDomainType() - .eq(FileResourceDomainType.ICON) - .download(), - ) doReturn Observable.just(BaseD2Progress.empty(1)) - presenter.syncMetadata { } - - verifyNoMoreInteractions(analyticsHelper) - } - - @Test - fun `Should not configure secondary tracker if no configuration exists`() { - whenever( - d2.metadataModule().download(), - ) doReturn - Observable.fromArray( - BaseD2Progress.empty(2), - ) - whenever( - d2.settingModule().generalSetting().blockingGet(), - ) doReturn null - whenever( - d2.mapsModule().mapLayersDownloader().downloadMetadata(), - ) doReturn Completable.complete() - whenever( - d2.mapsModule().mapLayersDownloader().downloadMetadata(), - ) doReturn Completable.complete() - whenever( - d2 - .fileResourceModule() - .fileResourceDownloader() - .byDomainType() - .eq(FileResourceDomainType.ICON) - .download(), - ) doReturn Observable.just(BaseD2Progress.empty(1)) - presenter.syncMetadata { } - - verify(analyticsHelper, times(0)).updateMatomoSecondaryTracker(any(), any(), any()) - } - - @Test - fun `Should clear secondary tracker`() { - whenever( - d2.metadataModule().download(), - ) doReturn - Observable.fromArray( - BaseD2Progress.empty(2), - ) - whenever( - d2.settingModule().generalSetting().blockingGet(), - ) doReturn null - whenever( - d2.mapsModule().mapLayersDownloader().downloadMetadata(), - ) doReturn Completable.complete() - whenever( - d2 - .fileResourceModule() - .fileResourceDownloader() - .byDomainType() - .eq(FileResourceDomainType.ICON) - .download(), - ) doReturn Observable.just(BaseD2Progress.empty(1)) - presenter.syncMetadata { } - - verify(analyticsHelper, times(0)).updateMatomoSecondaryTracker(any(), any(), any()) - verify(analyticsHelper).clearMatomoSecondaryTracker() - } - @Test fun `Should return successfully SYNC if tei enrollment and events are ok`() { whenever( diff --git a/app/src/test/java/org/dhis2/data/sorting/SearchSortingValueSetterTest.kt b/app/src/test/java/org/dhis2/data/sorting/SearchSortingValueSetterTest.kt index 36203ba359f..3770540ec16 100644 --- a/app/src/test/java/org/dhis2/data/sorting/SearchSortingValueSetterTest.kt +++ b/app/src/test/java/org/dhis2/data/sorting/SearchSortingValueSetterTest.kt @@ -7,6 +7,7 @@ import org.dhis2.data.enrollment.EnrollmentUiDataHelper import org.dhis2.usescases.searchTrackEntity.SearchTeiModel import org.hisp.dhis.android.core.D2 import org.hisp.dhis.android.core.arch.repositories.scope.RepositoryScope +import org.hisp.dhis.android.core.common.ObjectWithUid import org.hisp.dhis.android.core.enrollment.Enrollment import org.hisp.dhis.android.core.enrollment.EnrollmentStatus import org.hisp.dhis.android.core.event.Event @@ -447,6 +448,8 @@ class SearchSortingValueSetterTest { Program .builder() .uid("programUid") + .categoryCombo(ObjectWithUid.create("categoryComboUid")) + .enrollmentCategoryCombo(ObjectWithUid.create("categoryComboUid")) .enrollmentDateLabel("programEnrollmentDateLabel") .build() @@ -481,6 +484,8 @@ class SearchSortingValueSetterTest { Program .builder() .uid("programUid") + .categoryCombo(ObjectWithUid.create("categoryComboUid")) + .enrollmentCategoryCombo(ObjectWithUid.create("categoryComboUid")) .build() val result = @@ -542,6 +547,7 @@ class SearchSortingValueSetterTest { .status(EnrollmentStatus.ACTIVE) .program("programUid") .enrollmentDate(Date.from(Instant.parse("2020-01-01T00:00:00.00Z"))) + .attributeOptionCombo("attributeOptionComboUid") .build(), ) tei = diff --git a/app/src/test/java/org/dhis2/uicomponents/map/geometry/MapCoordinateFieldToFeatureCollectionTest.kt b/app/src/test/java/org/dhis2/uicomponents/map/geometry/MapCoordinateFieldToFeatureCollectionTest.kt index c63760f9c95..55bcaa81c08 100644 --- a/app/src/test/java/org/dhis2/uicomponents/map/geometry/MapCoordinateFieldToFeatureCollectionTest.kt +++ b/app/src/test/java/org/dhis2/uicomponents/map/geometry/MapCoordinateFieldToFeatureCollectionTest.kt @@ -7,6 +7,7 @@ import org.dhis2.maps.utils.CoordinateAttributeInfo import org.dhis2.maps.utils.CoordinateDataElementInfo import org.hisp.dhis.android.core.common.FeatureType import org.hisp.dhis.android.core.common.Geometry +import org.hisp.dhis.android.core.common.ObjectWithUid import org.hisp.dhis.android.core.dataelement.DataElement import org.hisp.dhis.android.core.enrollment.Enrollment import org.hisp.dhis.android.core.event.Event @@ -69,11 +70,13 @@ class MapCoordinateFieldToFeatureCollectionTest { .builder() .uid("deUid") .displayFormName("deName") + .categoryCombo(ObjectWithUid.create("categoryComboUid")) .build(), Enrollment .builder() .uid("enrollmentUid") .trackedEntityInstance("teiUid") + .attributeOptionCombo("attributeOptionComboUid") .build(), Geometry .builder() @@ -96,11 +99,13 @@ class MapCoordinateFieldToFeatureCollectionTest { .builder() .uid("de2Uid") .displayFormName("de2Name") + .categoryCombo(ObjectWithUid.create("categoryComboUid")) .build(), Enrollment .builder() .uid("enrollmentUid") .trackedEntityInstance("teiUid") + .attributeOptionCombo("attributeOptionComboUid") .build(), Geometry .builder() @@ -123,11 +128,13 @@ class MapCoordinateFieldToFeatureCollectionTest { .builder() .uid("deUid") .displayFormName("deName") + .categoryCombo(ObjectWithUid.create("categoryComboUid")) .build(), Enrollment .builder() .uid("enrollmentUid") .trackedEntityInstance("teiUid") + .attributeOptionCombo("attributeOptionComboUid") .build(), Geometry .builder() diff --git a/app/src/test/java/org/dhis2/uicomponents/map/geometry/mapper/featurecollection/MapDataElementToFeatureTest.kt b/app/src/test/java/org/dhis2/uicomponents/map/geometry/mapper/featurecollection/MapDataElementToFeatureTest.kt index 41076b5017b..4945cb931f5 100644 --- a/app/src/test/java/org/dhis2/uicomponents/map/geometry/mapper/featurecollection/MapDataElementToFeatureTest.kt +++ b/app/src/test/java/org/dhis2/uicomponents/map/geometry/mapper/featurecollection/MapDataElementToFeatureTest.kt @@ -6,6 +6,7 @@ import org.dhis2.maps.utils.CoordinateDataElementInfo import org.dhis2.uicomponents.map.geometry.MapEventToFeatureCollectionTest import org.hisp.dhis.android.core.common.FeatureType import org.hisp.dhis.android.core.common.Geometry +import org.hisp.dhis.android.core.common.ObjectWithUid import org.hisp.dhis.android.core.dataelement.DataElement import org.hisp.dhis.android.core.enrollment.Enrollment import org.hisp.dhis.android.core.event.Event @@ -78,11 +79,13 @@ class MapDataElementToFeatureTest { .builder() .uid("deUid") .displayFormName("deName") + .categoryCombo(ObjectWithUid.create("categoryComboUid")) .build(), Enrollment .builder() .uid("enrollmentUid") .trackedEntityInstance("teiUid") + .attributeOptionCombo("attributeOptionComboUid") .build(), Geometry .builder() @@ -105,11 +108,13 @@ class MapDataElementToFeatureTest { .builder() .uid("de2Uid") .displayFormName("de2Name") + .categoryCombo(ObjectWithUid.create("categoryComboUid")) .build(), Enrollment .builder() .uid("enrollmentUid") .trackedEntityInstance("teiUid") + .attributeOptionCombo("attributeOptionComboUid") .build(), Geometry .builder() @@ -132,11 +137,13 @@ class MapDataElementToFeatureTest { .builder() .uid("deUid") .displayFormName("deName") + .categoryCombo(ObjectWithUid.create("categoryComboUid")) .build(), Enrollment .builder() .uid("enrollmentUid") .trackedEntityInstance("teiUid") + .attributeOptionCombo("attributeOptionComboUid") .build(), Geometry .builder() diff --git a/app/src/test/java/org/dhis2/usescases/enrollment/EnrollmentFormRepositoryTest.kt b/app/src/test/java/org/dhis2/usescases/enrollment/EnrollmentFormRepositoryTest.kt index 8b59fa59d37..0d5185fd534 100644 --- a/app/src/test/java/org/dhis2/usescases/enrollment/EnrollmentFormRepositoryTest.kt +++ b/app/src/test/java/org/dhis2/usescases/enrollment/EnrollmentFormRepositoryTest.kt @@ -3,6 +3,7 @@ package org.dhis2.usescases.enrollment import org.dhis2.data.dhislogic.DhisEnrollmentUtils import org.hisp.dhis.android.core.D2 import org.hisp.dhis.android.core.arch.repositories.`object`.ReadOnlyOneObjectRepositoryFinalImpl +import org.hisp.dhis.android.core.common.ObjectWithUid import org.hisp.dhis.android.core.enrollment.Enrollment import org.hisp.dhis.android.core.enrollment.EnrollmentObjectRepository import org.hisp.dhis.android.core.enrollment.EnrollmentStatus @@ -35,6 +36,8 @@ class EnrollmentFormRepositoryTest { .builder() .uid("programUid") .displayName("programName") + .categoryCombo(ObjectWithUid.create("categoryComboUid")) + .enrollmentCategoryCombo(ObjectWithUid.create("categoryComboUid")) .build() whenever(enrollmentRepository.blockingGet()) doReturn Enrollment diff --git a/app/src/test/java/org/dhis2/usescases/enrollment/EnrollmentPresenterImplTest.kt b/app/src/test/java/org/dhis2/usescases/enrollment/EnrollmentPresenterImplTest.kt index 5b5233bf151..8471215a86f 100644 --- a/app/src/test/java/org/dhis2/usescases/enrollment/EnrollmentPresenterImplTest.kt +++ b/app/src/test/java/org/dhis2/usescases/enrollment/EnrollmentPresenterImplTest.kt @@ -15,6 +15,7 @@ import org.hisp.dhis.android.core.common.Access import org.hisp.dhis.android.core.common.DataAccess import org.hisp.dhis.android.core.common.FeatureType import org.hisp.dhis.android.core.common.Geometry +import org.hisp.dhis.android.core.common.ObjectWithUid import org.hisp.dhis.android.core.enrollment.EnrollmentAccess import org.hisp.dhis.android.core.enrollment.EnrollmentObjectRepository import org.hisp.dhis.android.core.enrollment.EnrollmentStatus @@ -73,6 +74,8 @@ class EnrollmentPresenterImplTest { Program .builder() .uid("") + .categoryCombo(ObjectWithUid.create("categoryComboUid")) + .enrollmentCategoryCombo(ObjectWithUid.create("categoryComboUid")) .access( Access .builder() @@ -94,6 +97,8 @@ class EnrollmentPresenterImplTest { Program .builder() .uid("") + .categoryCombo(ObjectWithUid.create("categoryComboUid")) + .enrollmentCategoryCombo(ObjectWithUid.create("categoryComboUid")) .access( Access .builder() @@ -161,7 +166,13 @@ class EnrollmentPresenterImplTest { .geometry(geometry) .uid("random") .build() - val program = Program.builder().uid("tUID").build() + val program = + Program + .builder() + .uid("tUID") + .categoryCombo(ObjectWithUid.create("categoryComboUid")) + .enrollmentCategoryCombo(ObjectWithUid.create("categoryComboUid")) + .build() whenever(teiRepository.blockingGet()) doReturn tei whenever(programRepository.blockingGet()) doReturn program @@ -193,7 +204,13 @@ class EnrollmentPresenterImplTest { .geometry(geometry) .uid("random") .build() - val program = Program.builder().uid("tUID").build() + val program = + Program + .builder() + .uid("tUID") + .categoryCombo(ObjectWithUid.create("categoryComboUid")) + .enrollmentCategoryCombo(ObjectWithUid.create("categoryComboUid")) + .build() whenever(teiRepository.blockingGet()) doReturn tei whenever(programRepository.blockingGet()) doReturn program diff --git a/app/src/test/java/org/dhis2/usescases/eventsWithoutRegistration/eventCapture/EventCaptureRepositoryImplTest.kt b/app/src/test/java/org/dhis2/usescases/eventsWithoutRegistration/eventCapture/EventCaptureRepositoryImplTest.kt index e79e8acf17a..6329a7dbeb8 100644 --- a/app/src/test/java/org/dhis2/usescases/eventsWithoutRegistration/eventCapture/EventCaptureRepositoryImplTest.kt +++ b/app/src/test/java/org/dhis2/usescases/eventsWithoutRegistration/eventCapture/EventCaptureRepositoryImplTest.kt @@ -5,6 +5,7 @@ import io.reactivex.Single import org.dhis2.data.dhislogic.AUTH_ALL import org.dhis2.data.dhislogic.AUTH_UNCOMPLETE_EVENT import org.hisp.dhis.android.core.D2 +import org.hisp.dhis.android.core.common.ObjectWithUid import org.hisp.dhis.android.core.dataelement.DataElement import org.hisp.dhis.android.core.enrollment.Enrollment import org.hisp.dhis.android.core.enrollment.EnrollmentStatus @@ -129,6 +130,7 @@ class EventCaptureRepositoryImplTest { .builder() .uid(trackerEventEnrollmentUid) .status(EnrollmentStatus.CANCELLED) + .attributeOptionCombo("attributeOptionComboUid") .build() val repository = @@ -752,7 +754,11 @@ class EventCaptureRepositoryImplTest { .sortOrder(sectionOrderC) .dataElements( mutableListOf( - DataElement.builder().uid(sectionCDataElementA).build(), + DataElement + .builder() + .uid(sectionCDataElementA) + .categoryCombo(ObjectWithUid.create("categoryComboUid")) + .build(), ), ).build(), ProgramStageSection @@ -762,8 +768,16 @@ class EventCaptureRepositoryImplTest { .sortOrder(sectionOrderB) .dataElements( mutableListOf( - DataElement.builder().uid(sectionBDataElementA).build(), - DataElement.builder().uid(sectionBDataElementB).build(), + DataElement + .builder() + .uid(sectionBDataElementA) + .categoryCombo(ObjectWithUid.create("categoryComboUid")) + .build(), + DataElement + .builder() + .uid(sectionBDataElementB) + .categoryCombo(ObjectWithUid.create("categoryComboUid")) + .build(), ), ).build(), ProgramStageSection @@ -773,7 +787,11 @@ class EventCaptureRepositoryImplTest { .sortOrder(sectionOrderA) .dataElements( mutableListOf( - DataElement.builder().uid(sectionADataElementA).build(), + DataElement + .builder() + .uid(sectionADataElementA) + .categoryCombo(ObjectWithUid.create("categoryComboUid")) + .build(), ), ).build(), ) diff --git a/app/src/test/java/org/dhis2/usescases/eventsWithoutRegistration/eventInitial/EventInitialPresenterTest.kt b/app/src/test/java/org/dhis2/usescases/eventsWithoutRegistration/eventInitial/EventInitialPresenterTest.kt index 0ba99a2cf04..af4c0c5a36e 100644 --- a/app/src/test/java/org/dhis2/usescases/eventsWithoutRegistration/eventInitial/EventInitialPresenterTest.kt +++ b/app/src/test/java/org/dhis2/usescases/eventsWithoutRegistration/eventInitial/EventInitialPresenterTest.kt @@ -12,6 +12,7 @@ import org.dhis2.usescases.eventsWithoutRegistration.eventCapture.EventFieldMapp import org.dhis2.utils.analytics.AnalyticsHelper import org.hisp.dhis.android.core.common.FeatureType import org.hisp.dhis.android.core.common.Geometry +import org.hisp.dhis.android.core.common.ObjectWithUid import org.hisp.dhis.android.core.event.Event import org.hisp.dhis.android.core.event.EventEditableStatus.Editable import org.hisp.dhis.android.core.event.EventEditableStatus.NonEditable @@ -364,7 +365,13 @@ class EventInitialPresenterTest { programStageUid: String?, moreOrgUnits: Boolean = false, ) { - val program = Program.builder().uid(uid).build() + val program = + Program + .builder() + .uid(uid) + .categoryCombo(ObjectWithUid.create("categoryComboUid")) + .enrollmentCategoryCombo(ObjectWithUid.create("categoryComboUid")) + .build() val orgUnits = mutableListOf( OrganisationUnit diff --git a/app/src/test/java/org/dhis2/usescases/eventsWithoutRegistration/eventInitial/EventInitialRepositoryImplTest.kt b/app/src/test/java/org/dhis2/usescases/eventsWithoutRegistration/eventInitial/EventInitialRepositoryImplTest.kt index 501f6b8559f..eeb1fec1baf 100644 --- a/app/src/test/java/org/dhis2/usescases/eventsWithoutRegistration/eventInitial/EventInitialRepositoryImplTest.kt +++ b/app/src/test/java/org/dhis2/usescases/eventsWithoutRegistration/eventInitial/EventInitialRepositoryImplTest.kt @@ -259,6 +259,8 @@ class EventInitialRepositoryImplTest { Program .builder() .uid("programUid") + .categoryCombo(ObjectWithUid.create("categoryComboUid")) + .enrollmentCategoryCombo(ObjectWithUid.create("categoryComboUid")) .access(Access.create(true, true, DataAccess.create(true, hasAccess))) .build() } diff --git a/app/src/test/java/org/dhis2/usescases/main/MainPresenterTest.kt b/app/src/test/java/org/dhis2/usescases/main/MainPresenterTest.kt index 2604bc66e6a..19cf71e08ef 100644 --- a/app/src/test/java/org/dhis2/usescases/main/MainPresenterTest.kt +++ b/app/src/test/java/org/dhis2/usescases/main/MainPresenterTest.kt @@ -25,17 +25,23 @@ import org.dhis2.commons.schedulers.SchedulerProvider import org.dhis2.commons.viewmodel.DispatcherProvider import org.dhis2.data.schedulers.TrampolineSchedulerProvider import org.dhis2.data.server.UserManager -import org.dhis2.data.service.SyncStatusController import org.dhis2.data.service.VersionRepository import org.dhis2.data.service.workManager.WorkManagerController +import org.dhis2.mobile.commons.domain.invoke +import org.dhis2.mobile.sync.data.SyncBackgroundJobAction +import org.dhis2.mobile.sync.domain.SyncStatusController import org.dhis2.usescases.login.SyncIsPerformedInteractor import org.dhis2.usescases.main.domain.LogoutUser import org.dhis2.usescases.settings.DeleteUserData +import org.hisp.dhis.android.core.D2 +import org.hisp.dhis.android.core.arch.helpers.DateUtils import org.hisp.dhis.android.core.category.CategoryCombo import org.hisp.dhis.android.core.category.CategoryOptionCombo import org.hisp.dhis.android.core.configuration.internal.DatabaseAccount import org.hisp.dhis.android.core.systeminfo.SystemInfo +import org.hisp.dhis.android.core.user.AccountManager import org.hisp.dhis.android.core.user.User +import org.hisp.dhis.android.core.user.UserModule import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Rule @@ -66,6 +72,7 @@ class MainPresenterTest { private val deleteUserData: DeleteUserData = mock() private val syncIsPerfomedInteractor: SyncIsPerformedInteractor = mock() private val syncStatusController: SyncStatusController = mock() + private val syncBackgroundJobAction: SyncBackgroundJobAction = mock() private val versionRepository: VersionRepository = mock() private val testingDispatcher = UnconfinedTestDispatcher() private val dispatcherProvider: DispatcherProvider = @@ -77,6 +84,9 @@ class MainPresenterTest { private val forceToNotSynced: Boolean = false private val logoutUser: LogoutUser = mock() + private val accountManager: AccountManager = mock() + private val userModule: UserModule = mock() + private val d2: D2 = mock() @Rule @JvmField @@ -100,6 +110,7 @@ class MainPresenterTest { deleteUserData, syncIsPerfomedInteractor, syncStatusController, + syncBackgroundJobAction, versionRepository, dispatcherProvider, forceToNotSynced, @@ -177,26 +188,26 @@ class MainPresenterTest { } @Test - fun `Should go to delete account`() { + fun `Should go to delete account`() = runTest { val randomFile = File("random") + whenever(view.obtainFileView()) doReturn randomFile - whenever(userManager.d2) doReturn mock() - whenever(userManager.d2.userModule()) doReturn mock() - whenever(userManager.d2.userModule().accountManager()) doReturn mock() - whenever(view.obtainFileView()) doReturn randomFile + whenever(userManager.d2) doReturn d2 + whenever(d2.userModule()) doReturn userModule + whenever(userModule.accountManager()) doReturn accountManager whenever(repository.accountsCount()) doReturn 1 presenter.onDeleteAccount() verify(view).showProgressDeleteNotification() verify(deleteUserData).wipeCacheAndPreferences(randomFile) - verify(userManager.d2?.userModule()?.accountManager())?.deleteCurrentAccount() + verify(accountManager).deleteCurrentAccount() verify(view).cancelNotifications() verify(view).goToLogin(1, true) } @Test - fun `Should go to manage account`() { + fun `Should go to manage account`() = runTest { val firstRandomUserAccount = DatabaseAccount .builder() @@ -204,7 +215,7 @@ class MainPresenterTest { .serverUrl("https://www.random.com/") .encrypted(false) .databaseName("none") - .databaseCreationDate("16/2/2012") + .databaseCreationDate(DateUtils.SIMPLE_DATE_FORMAT.parse("2012-2-16")) .build() val secondRandomUserAccount = DatabaseAccount @@ -213,31 +224,25 @@ class MainPresenterTest { .serverUrl("https://www.random.com/") .encrypted(false) .databaseName("none") - .databaseCreationDate("16/2/2012") + .databaseCreationDate(DateUtils.SIMPLE_DATE_FORMAT.parse("2012-2-16")) .build() val randomFile = File("random") whenever(view.obtainFileView()) doReturn randomFile - whenever(userManager.d2) doReturn mock() - whenever(userManager.d2.userModule()) doReturn mock() - whenever(userManager.d2.userModule().accountManager()) doReturn mock() - whenever( - userManager.d2 - .userModule() - .accountManager() - .getAccounts(), - ) doReturn - listOf( - firstRandomUserAccount, - secondRandomUserAccount, - ) + whenever(userManager.d2) doReturn d2 + whenever(d2.userModule()) doReturn userModule + whenever(userModule.accountManager()) doReturn accountManager + whenever(accountManager.getAccounts()) doReturn listOf( + firstRandomUserAccount, + secondRandomUserAccount, + ) whenever(repository.accountsCount()) doReturn 2 presenter.onDeleteAccount() verify(deleteUserData).wipeCacheAndPreferences(randomFile) - verify(userManager.d2?.userModule()?.accountManager())?.deleteCurrentAccount() + verify(accountManager).deleteCurrentAccount() verify(view).showProgressDeleteNotification() verify(view).cancelNotifications() verify(view).goToLogin(2, true) diff --git a/app/src/test/java/org/dhis2/usescases/main/domain/CheckSingleNavigationTest.kt b/app/src/test/java/org/dhis2/usescases/main/domain/CheckSingleNavigationTest.kt new file mode 100644 index 00000000000..afa1fbd1c78 --- /dev/null +++ b/app/src/test/java/org/dhis2/usescases/main/domain/CheckSingleNavigationTest.kt @@ -0,0 +1,70 @@ +package org.dhis2.usescases.main.domain + +import kotlinx.coroutines.test.runTest +import org.dhis2.mobile.commons.domain.invoke +import org.dhis2.mobile.commons.error.DomainError +import org.dhis2.usescases.main.HomeItemData +import org.dhis2.usescases.main.data.HomeRepository +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.given +import org.mockito.kotlin.mock +import org.mockito.kotlin.times +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import org.mockito.kotlin.willAnswer + +class CheckSingleNavigationTest { + private val homeRepository: HomeRepository = mock() + private lateinit var checkSingleNavigation: CheckSingleNavigation + + @Before + fun setUp() { + checkSingleNavigation = CheckSingleNavigation(homeRepository) + } + + @Test + fun `should return HomeDataItem if there is only one program`() = + runTest { + whenever(homeRepository.homeItemCount()) doReturn 1 + whenever(homeRepository.singleHomeItemData()) doReturn + HomeItemData.EventProgram( + "eventUid", + "eventLabel", + true, + ) + assertTrue(checkSingleNavigation().isSuccess) + } + + @Test + fun `should return failure if more than one program`() = + runTest { + whenever(homeRepository.homeItemCount()) doReturn 2 + with(checkSingleNavigation()) { + verify(homeRepository, times(0)).singleHomeItemData() + assertTrue(isFailure) + } + } + + @Test + fun `should return failure if there are no programs`() = + runTest { + whenever(homeRepository.homeItemCount()) doReturn 0 + with(checkSingleNavigation()) { + verify(homeRepository, times(0)).singleHomeItemData() + assertTrue(isFailure) + } + } + + @Test + fun `should return failure if there is a domain exception`() = + runTest { + given(homeRepository.homeItemCount()) willAnswer { throw DomainError.DatabaseError("Test") } + with(checkSingleNavigation()) { + assertTrue(isFailure) + assertTrue(exceptionOrNull() is DomainError) + } + } +} diff --git a/app/src/test/java/org/dhis2/usescases/main/domain/ConfigureHomeNavigationBarTest.kt b/app/src/test/java/org/dhis2/usescases/main/domain/ConfigureHomeNavigationBarTest.kt new file mode 100644 index 00000000000..27aa04a1d22 --- /dev/null +++ b/app/src/test/java/org/dhis2/usescases/main/domain/ConfigureHomeNavigationBarTest.kt @@ -0,0 +1,57 @@ +package org.dhis2.usescases.main.domain + +import kotlinx.coroutines.test.runTest +import org.dhis2.mobile.commons.domain.invoke +import org.dhis2.mobile.commons.error.DomainError +import org.dhis2.usescases.main.data.HomeRepository +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.given +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever +import org.mockito.kotlin.willAnswer + +class ConfigureHomeNavigationBarTest { + private val homeRepository: HomeRepository = mock() + lateinit var configureHomeNavigationBar: ConfigureHomeNavigationBar + + @Before + fun setUp() { + configureHomeNavigationBar = + ConfigureHomeNavigationBar( + homeRepository = homeRepository, + ) + } + + @Test + fun `should return programs and analytics items if configured`() = + runTest { + whenever(homeRepository.hasHomeAnalytics()) doReturn true + with(configureHomeNavigationBar()) { + assertTrue(isSuccess) + assertTrue(getOrNull()?.size == 2) + } + } + + @Test + fun `should return just programs if analytics is not configured`() = + runTest { + whenever(homeRepository.hasHomeAnalytics()) doReturn false + with(configureHomeNavigationBar()) { + assertTrue(isSuccess) + assertTrue(getOrNull()?.size == 1) + } + } + + @Test + fun `should return just programs if an exception is thrown`() = + runTest { + given(homeRepository.hasHomeAnalytics()) willAnswer { throw DomainError.DatabaseError("Test") } + with(configureHomeNavigationBar()) { + assertTrue(isSuccess) + assertTrue(getOrNull()?.size == 1) + } + } +} diff --git a/app/src/test/java/org/dhis2/usescases/main/domain/DeleteAccountTest.kt b/app/src/test/java/org/dhis2/usescases/main/domain/DeleteAccountTest.kt new file mode 100644 index 00000000000..a3f6f112651 --- /dev/null +++ b/app/src/test/java/org/dhis2/usescases/main/domain/DeleteAccountTest.kt @@ -0,0 +1,65 @@ +package org.dhis2.usescases.main.domain + +import junit.framework.Assert.assertTrue +import kotlinx.coroutines.test.runTest +import org.dhis2.commons.filters.FilterManager +import org.dhis2.mobile.commons.error.DomainError +import org.dhis2.usescases.main.data.HomeRepository +import org.junit.Before +import org.junit.Test +import org.mockito.Mockito.mock +import org.mockito.kotlin.any +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.given +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import org.mockito.kotlin.willAnswer +import kotlin.io.path.createTempFile + +class DeleteAccountTest { + private val filterManager: FilterManager = mock() + private val repository: HomeRepository = mock() + private lateinit var deleteAccount: DeleteAccount + + @Before + fun setUp() { + deleteAccount = + DeleteAccount( + filterManager = filterManager, + repository = repository, + ) + } + + @Test + fun `should successfully return and clear all data`() = + runTest { + val cacheFile = createTempFile().toFile() + whenever(repository.clearCache(any())) doReturn true + whenever(repository.accountsCount()) doReturn 3 + with(deleteAccount(cacheFile)) { + verify(filterManager).clearAllFilters() + verify(repository).clearCache(cacheFile) + verify(repository).clearPreferences() + verify(repository).wipeAll() + verify(repository).deleteCurrentAccount() + assertTrue(isSuccess) + assertTrue(getOrNull() == 3) + } + } + + @Test + fun `should return failure when domain exception is thrown`() = + runTest { + val cacheFile = createTempFile().toFile() + whenever(repository.clearCache(any())) doReturn true + given(repository.deleteCurrentAccount()) willAnswer { throw DomainError.DatabaseError("Test") } + with(deleteAccount(cacheFile)) { + verify(filterManager).clearAllFilters() + verify(repository).clearCache(cacheFile) + verify(repository).clearPreferences() + verify(repository).wipeAll() + verify(repository).deleteCurrentAccount() + assertTrue(isFailure) + } + } +} diff --git a/app/src/test/java/org/dhis2/usescases/main/domain/DownloadNewVersionTest.kt b/app/src/test/java/org/dhis2/usescases/main/domain/DownloadNewVersionTest.kt new file mode 100644 index 00000000000..caaec27534d --- /dev/null +++ b/app/src/test/java/org/dhis2/usescases/main/domain/DownloadNewVersionTest.kt @@ -0,0 +1,60 @@ +package org.dhis2.usescases.main.domain + +import android.content.Context +import kotlinx.coroutines.test.runTest +import org.dhis2.data.service.VersionRepository +import org.dhis2.mobile.commons.error.DomainError +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.mockito.kotlin.any +import org.mockito.kotlin.given +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever +import org.mockito.kotlin.willAnswer + +class DownloadNewVersionTest { + private val versionRepository: VersionRepository = mock() + private lateinit var downloadNewVersion: DownloadNewVersion + + @Before + fun setUp() { + downloadNewVersion = DownloadNewVersion(versionRepository) + } + + @Test + fun `should successfully download new version`() = + runTest { + // GIVEN + val fakeUriPath = "fakeUriPath" + whenever( + versionRepository.download( + context = any(), + onDownloadCompleted = any(), + onDownloading = any(), + ), + ).thenAnswer { + // Simulate the callback being called upon successful download + val onDownloadCompletedCallback = it.getArgument<(String) -> Unit>(1) + onDownloadCompletedCallback.invoke(fakeUriPath) + } + val context: Context = mock() + + with(downloadNewVersion(context)) { + assertTrue(isSuccess) + } + } + + @Test + fun `should return failure if an exception is thrown`() = + runTest { + given(versionRepository.download(any(), any(), any())) willAnswer { + throw DomainError.DatabaseError("Test") + } + val context: Context = mock() + + with(downloadNewVersion(context)) { + assertTrue(isFailure) + } + } +} diff --git a/app/src/test/java/org/dhis2/usescases/main/domain/GetHomeFiltersTest.kt b/app/src/test/java/org/dhis2/usescases/main/domain/GetHomeFiltersTest.kt new file mode 100644 index 00000000000..cc5fb863bd5 --- /dev/null +++ b/app/src/test/java/org/dhis2/usescases/main/domain/GetHomeFiltersTest.kt @@ -0,0 +1,44 @@ +package org.dhis2.usescases.main.domain + +import kotlinx.coroutines.test.runTest +import org.dhis2.commons.filters.FilterItem +import org.dhis2.commons.filters.data.FilterRepository +import org.dhis2.mobile.commons.domain.invoke +import org.dhis2.mobile.commons.error.DomainError +import org.junit.Before +import org.junit.Test +import org.mockito.Mockito.mock +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.given +import org.mockito.kotlin.whenever +import org.mockito.kotlin.willAnswer + +class GetHomeFiltersTest { + private val filterRepository: FilterRepository = mock() + private lateinit var getHomeFilters: GetHomeFilters + + @Before + fun setUp() { + getHomeFilters = GetHomeFilters(filterRepository) + } + + @Test + fun `should return a list of home filters`() = + runTest { + val expectedFilters: List = mock() + whenever(filterRepository.homeFilters()) doReturn expectedFilters + val result = getHomeFilters() + assert(result.isSuccess) + assert(result.getOrNull() == expectedFilters) + } + + @Test + fun `should return a failure when an exception is thrown`() = + runTest { + given(filterRepository.homeFilters()) willAnswer { + throw DomainError.DatabaseError("Test") + } + val result = getHomeFilters() + assert(result.isFailure) + } +} diff --git a/app/src/test/java/org/dhis2/usescases/main/domain/GetLockActionTest.kt b/app/src/test/java/org/dhis2/usescases/main/domain/GetLockActionTest.kt new file mode 100644 index 00000000000..3db9ea7323e --- /dev/null +++ b/app/src/test/java/org/dhis2/usescases/main/domain/GetLockActionTest.kt @@ -0,0 +1,53 @@ +package org.dhis2.usescases.main.domain + +import kotlinx.coroutines.test.runTest +import org.dhis2.mobile.commons.domain.invoke +import org.dhis2.mobile.commons.error.DomainError +import org.dhis2.usescases.main.data.HomeRepository +import org.dhis2.usescases.main.domain.model.LockAction +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.given +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever +import org.mockito.kotlin.willAnswer + +class GetLockActionTest { + private val homeRepository: HomeRepository = mock() + private lateinit var getLockAction: GetLockAction + + @Before + fun setUp() { + getLockAction = GetLockAction(homeRepository) + } + + @Test + fun `should return BlockSession action if pin is set`() = + runTest { + whenever(homeRepository.isPinStored()) doReturn true + val result = getLockAction() + assertTrue(result.isSuccess) + assertTrue(result.getOrNull() is LockAction.BlockSession) + } + + @Test + fun `should return CreatePin action if pin is set`() = + runTest { + whenever(homeRepository.isPinStored()) doReturn false + val result = getLockAction() + assertTrue(result.isSuccess) + assertTrue(result.getOrNull() is LockAction.CreatePin) + } + + @Test + fun `should return failure if an exception is thrown`() = + runTest { + given(homeRepository.isPinStored()) willAnswer { + throw DomainError.DatabaseError("Test") + } + val result = getLockAction() + assertTrue(result.isFailure) + } +} diff --git a/app/src/test/java/org/dhis2/usescases/main/domain/GetUserNameTest.kt b/app/src/test/java/org/dhis2/usescases/main/domain/GetUserNameTest.kt new file mode 100644 index 00000000000..aee8436306d --- /dev/null +++ b/app/src/test/java/org/dhis2/usescases/main/domain/GetUserNameTest.kt @@ -0,0 +1,99 @@ +package org.dhis2.usescases.main.domain + +import kotlinx.coroutines.test.runTest +import org.dhis2.mobile.commons.domain.invoke +import org.dhis2.mobile.commons.error.DomainError +import org.dhis2.usescases.main.data.HomeRepository +import org.hisp.dhis.android.core.user.User +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.given +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever +import org.mockito.kotlin.willAnswer + +class GetUserNameTest { + private val homeRepository: HomeRepository = mock() + private lateinit var getUserName: GetUserName + + @Before + fun setUp() { + getUserName = GetUserName(homeRepository) + } + + @Test + fun `should return full name if user exists`() = + runTest { + val user: User = + mock { + on { firstName() } doReturn "Peter" + on { surname() } doReturn "Jones" + } + whenever(homeRepository.user()) doReturn user + + val result = getUserName() + + assertTrue(result.isSuccess) + assertEquals("Peter Jones", result.getOrNull()) + } + + @Test + fun `should return surname if first name is null`() = + runTest { + val user: User = + mock { + on { firstName() } doReturn null + on { surname() } doReturn "Jones" + } + whenever(homeRepository.user()) doReturn user + + val result = getUserName() + + assertTrue(result.isSuccess) + assertEquals("Jones", result.getOrNull()) + } + + @Test + fun `should return first name if surname is null`() = + runTest { + val user: User = + mock { + on { firstName() } doReturn "Peter" + on { surname() } doReturn null + } + whenever(homeRepository.user()) doReturn user + + val result = getUserName() + + assertTrue(result.isSuccess) + assertEquals("Peter", result.getOrNull()) + } + + @Test + fun `should return empty string if user is null`() = + runTest { + whenever(homeRepository.user()) doReturn null + + val result = getUserName() + + assertTrue(result.isSuccess) + assertEquals("", result.getOrNull()) + } + + @Test + fun `should return failure if repository throws error`() = + runTest { + val exception = DomainError.DatabaseError("Error") + given(homeRepository.user()) willAnswer { + throw exception + } + + val result = getUserName() + + assertTrue(result.isFailure) + assertEquals(exception, result.exceptionOrNull()) + } +} diff --git a/app/src/test/java/org/dhis2/usescases/main/domain/LaunchInitialSyncTest.kt b/app/src/test/java/org/dhis2/usescases/main/domain/LaunchInitialSyncTest.kt new file mode 100644 index 00000000000..17d861dfa9a --- /dev/null +++ b/app/src/test/java/org/dhis2/usescases/main/domain/LaunchInitialSyncTest.kt @@ -0,0 +1,144 @@ +package org.dhis2.usescases.main.domain + +import kotlinx.coroutines.test.runTest +import org.dhis2.data.service.VersionRepository +import org.dhis2.mobile.commons.domain.invoke +import org.dhis2.mobile.commons.error.DomainError +import org.dhis2.mobile.sync.data.SyncBackgroundJobAction +import org.dhis2.usescases.main.data.HomeRepository +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.given +import org.mockito.kotlin.mock +import org.mockito.kotlin.verify +import org.mockito.kotlin.verifyNoInteractions +import org.mockito.kotlin.whenever + +class LaunchInitialSyncTest { + private val homeRepository: HomeRepository = mock() + private val versionRepository: VersionRepository = mock() + private val syncBackgroundJobAction: SyncBackgroundJobAction = mock() + private lateinit var launchInitialSync: LaunchInitialSync + + @Test + fun `should return Skip if skipSync is true`() = + runTest { + launchInitialSync = + LaunchInitialSync( + skipSync = true, + homeRepository = homeRepository, + versionRepository = versionRepository, + syncBackgroundJobAction = syncBackgroundJobAction, + ) + + val result = launchInitialSync() + + assertTrue(result.isSuccess) + assertEquals(InitialSyncAction.Skip, result.getOrNull()) + verifyNoInteractions(homeRepository) + verifyNoInteractions(versionRepository) + } + + @Test + fun `should return Skip if database is imported`() = + runTest { + whenever(homeRepository.isImportedDb()) doReturn true + launchInitialSync = + LaunchInitialSync( + skipSync = false, + homeRepository = homeRepository, + versionRepository = versionRepository, + syncBackgroundJobAction = syncBackgroundJobAction, + ) + + val result = launchInitialSync() + + assertTrue(result.isSuccess) + assertEquals(InitialSyncAction.Skip, result.getOrNull()) + } + + @Test + fun `should return Skip if initial sync is done`() = + runTest { + whenever(homeRepository.isImportedDb()) doReturn false + whenever(homeRepository.getInitialSyncDone()) doReturn true + launchInitialSync = + LaunchInitialSync( + skipSync = false, + homeRepository = homeRepository, + versionRepository = versionRepository, + syncBackgroundJobAction = syncBackgroundJobAction, + ) + + val result = launchInitialSync() + + assertTrue(result.isSuccess) + assertEquals(InitialSyncAction.Skip, result.getOrNull()) + } + + @Test + fun `should return Syncing and launch initial sync`() = + runTest { + whenever(homeRepository.isImportedDb()) doReturn false + whenever(homeRepository.getInitialSyncDone()) doReturn false + launchInitialSync = + LaunchInitialSync( + skipSync = false, + homeRepository = homeRepository, + versionRepository = versionRepository, + syncBackgroundJobAction = syncBackgroundJobAction, + ) + + val result = launchInitialSync() + + assertTrue(result.isSuccess) + assertEquals(InitialSyncAction.Syncing, result.getOrNull()) + verify(versionRepository).checkVersionUpdates() + } + + @Test + fun `should return failure if check version update fails`() = + runTest { + val exception = DomainError.DatabaseError("Error") + whenever(homeRepository.isImportedDb()) doReturn false + whenever(homeRepository.getInitialSyncDone()) doReturn false + given(versionRepository.checkVersionUpdates()).willAnswer { + throw exception + } + launchInitialSync = + LaunchInitialSync( + skipSync = false, + homeRepository = homeRepository, + versionRepository = versionRepository, + syncBackgroundJobAction = syncBackgroundJobAction, + ) + + val result = launchInitialSync() + + assertTrue(result.isFailure) + assertEquals(exception, result.exceptionOrNull()) + } + + @Test + fun `should return failure if home repository fails`() = + runTest { + val exception = DomainError.DatabaseError("Error") + given(homeRepository.isImportedDb()).willAnswer { + throw exception + } + launchInitialSync = + LaunchInitialSync( + skipSync = false, + homeRepository = homeRepository, + versionRepository = versionRepository, + syncBackgroundJobAction = syncBackgroundJobAction, + ) + + val result = launchInitialSync() + + assertTrue(result.isFailure) + assertEquals(exception, result.exceptionOrNull()) + } +} diff --git a/app/src/test/java/org/dhis2/usescases/main/domain/LogoutUserTest.kt b/app/src/test/java/org/dhis2/usescases/main/domain/LogoutUserTest.kt index df31b8e52fa..7c3b8170f4f 100644 --- a/app/src/test/java/org/dhis2/usescases/main/domain/LogoutUserTest.kt +++ b/app/src/test/java/org/dhis2/usescases/main/domain/LogoutUserTest.kt @@ -2,9 +2,10 @@ package org.dhis2.usescases.main.domain import kotlinx.coroutines.test.runTest import org.dhis2.commons.filters.FilterManager -import org.dhis2.data.service.SyncStatusController -import org.dhis2.data.service.workManager.WorkManagerController +import org.dhis2.mobile.commons.domain.invoke import org.dhis2.mobile.commons.error.DomainError +import org.dhis2.mobile.sync.data.SyncBackgroundJobAction +import org.dhis2.mobile.sync.domain.SyncStatusController import org.dhis2.usescases.main.HomeRepository import org.junit.Assert.assertTrue import org.junit.Assert.fail @@ -18,8 +19,8 @@ import org.mockito.kotlin.whenever class LogoutUserTest { private val repository: HomeRepository = mock() - private val workManagerController: WorkManagerController = mock() private val syncStatusController: SyncStatusController = mock() + private val syncBackgroundJobAction: SyncBackgroundJobAction = mock() private val filterManager: FilterManager = mock() private lateinit var logoutUser: LogoutUser @@ -29,7 +30,7 @@ class LogoutUserTest { logoutUser = LogoutUser( repository, - workManagerController, + syncBackgroundJobAction, syncStatusController, filterManager, ) @@ -41,7 +42,7 @@ class LogoutUserTest { whenever(repository.logOut()) doReturn Result.success(Unit) whenever(repository.accountsCount()) doReturn 1 val result = logoutUser() - verify(workManagerController).cancelAllWorkAndWait() + verify(syncBackgroundJobAction).cancelAll() verify(syncStatusController).restore() verify(filterManager).clearAllFilters() verify(repository).clearSessionLock() @@ -57,7 +58,7 @@ class LogoutUserTest { val testException = DomainError.UnexpectedError("test") whenever(repository.clearSessionLock()) doReturn Result.failure(testException) val result = logoutUser() - verify(workManagerController).cancelAllWorkAndWait() + verify(syncBackgroundJobAction).cancelAll() verify(syncStatusController).restore() verify(filterManager).clearAllFilters() verify(repository).clearSessionLock() @@ -80,7 +81,7 @@ class LogoutUserTest { assertTrue(e == testException) } - verify(workManagerController).cancelAllWorkAndWait() + verify(syncBackgroundJobAction).cancelAll() verify(syncStatusController).restore() verify(filterManager).clearAllFilters() verify(repository).clearSessionLock() diff --git a/app/src/test/java/org/dhis2/usescases/main/domain/ScheduleNewVersionAlertTest.kt b/app/src/test/java/org/dhis2/usescases/main/domain/ScheduleNewVersionAlertTest.kt new file mode 100644 index 00000000000..77f6c45144e --- /dev/null +++ b/app/src/test/java/org/dhis2/usescases/main/domain/ScheduleNewVersionAlertTest.kt @@ -0,0 +1,55 @@ +package org.dhis2.usescases.main.domain + +import kotlinx.coroutines.test.runTest +import org.dhis2.data.service.VersionRepository +import org.dhis2.data.service.workManager.WorkManagerController +import org.dhis2.mobile.commons.domain.invoke +import org.dhis2.mobile.commons.error.DomainError +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.mockito.kotlin.any +import org.mockito.kotlin.given +import org.mockito.kotlin.mock +import org.mockito.kotlin.verify + +class ScheduleNewVersionAlertTest { + private val workManagerController: WorkManagerController = mock() + private val versionRepository: VersionRepository = mock() + private lateinit var scheduleNewVersionAlert: ScheduleNewVersionAlert + + @Before + fun setUp() { + scheduleNewVersionAlert = + ScheduleNewVersionAlert( + workManagerController = workManagerController, + versionRepository = versionRepository, + ) + } + + @Test + fun `should schedule new version alert and remove version info`() = + runTest { + val result = scheduleNewVersionAlert() + + assertTrue(result.isSuccess) + + verify(workManagerController).beginUniqueWork(any()) + verify(versionRepository).removeVersionInfo() + } + + @Test + fun `should return failure when remove version info fails`() = + runTest { + val exception = DomainError.DatabaseError("Error") + given(versionRepository.removeVersionInfo()).willAnswer { + throw exception + } + + val result = scheduleNewVersionAlert() + + assertTrue(result.isFailure) + assertEquals(exception, result.exceptionOrNull()) + } +} diff --git a/app/src/test/java/org/dhis2/usescases/main/domain/UpdateInitialSyncStatusTest.kt b/app/src/test/java/org/dhis2/usescases/main/domain/UpdateInitialSyncStatusTest.kt new file mode 100644 index 00000000000..11589c76b5f --- /dev/null +++ b/app/src/test/java/org/dhis2/usescases/main/domain/UpdateInitialSyncStatusTest.kt @@ -0,0 +1,46 @@ +package org.dhis2.usescases.main.domain + +import kotlinx.coroutines.test.runTest +import org.dhis2.mobile.commons.domain.invoke +import org.dhis2.mobile.commons.error.DomainError +import org.dhis2.usescases.main.data.HomeRepository +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.mockito.kotlin.given +import org.mockito.kotlin.mock +import org.mockito.kotlin.verify + +class UpdateInitialSyncStatusTest { + private val homeRepository: HomeRepository = mock() + private lateinit var updateInitialSyncStatus: UpdateInitialSyncStatus + + @Before + fun setUp() { + updateInitialSyncStatus = UpdateInitialSyncStatus(homeRepository) + } + + @Test + fun `should call set initial sync done`() = + runTest { + val result = updateInitialSyncStatus() + + assertTrue(result.isSuccess) + verify(homeRepository).setInitialSyncDone() + } + + @Test + fun `should return failure when repository fails`() = + runTest { + val exception = DomainError.DatabaseError("Error") + given(homeRepository.setInitialSyncDone()).willAnswer { + throw exception + } + + val result = updateInitialSyncStatus() + + assertTrue(result.isFailure) + assertEquals(exception, result.exceptionOrNull()) + } +} diff --git a/app/src/test/java/org/dhis2/usescases/main/program/ProgramRepositoryImplTest.kt b/app/src/test/java/org/dhis2/usescases/main/program/ProgramRepositoryImplTest.kt index ff8c3653a59..00994f23b3f 100644 --- a/app/src/test/java/org/dhis2/usescases/main/program/ProgramRepositoryImplTest.kt +++ b/app/src/test/java/org/dhis2/usescases/main/program/ProgramRepositoryImplTest.kt @@ -12,12 +12,13 @@ import org.dhis2.commons.resources.MetadataIconProvider import org.dhis2.commons.resources.ResourceManager import org.dhis2.data.dhislogic.DhisProgramUtils import org.dhis2.data.schedulers.TrampolineSchedulerProvider -import org.dhis2.data.service.SyncStatusData import org.dhis2.mobile.commons.model.MetadataIconData +import org.dhis2.mobile.sync.model.SyncStatusData import org.hisp.dhis.android.core.D2 import org.hisp.dhis.android.core.common.Access import org.hisp.dhis.android.core.common.DataAccess import org.hisp.dhis.android.core.common.ObjectStyle +import org.hisp.dhis.android.core.common.ObjectWithUid import org.hisp.dhis.android.core.common.State import org.hisp.dhis.android.core.dataset.DataSet import org.hisp.dhis.android.core.dataset.DataSetInstanceSummary @@ -88,6 +89,7 @@ class ProgramRepositoryImplTest { .builder() .uid("dataSetUid") .description("description") + .categoryCombo(ObjectWithUid.create("categoryComboUid")) .style( ObjectStyle .builder() @@ -242,7 +244,7 @@ class ProgramRepositoryImplTest { filterPresenter.filteredTrackerProgram(any()).offlineFirst(), ) doReturn mock() whenever( - filterPresenter.filteredTrackerProgram(any()).offlineFirst().blockingGetUids(), + filterPresenter.filteredTrackerProgram(any()).offlineFirst().blockingGetUids(), ) doReturn listOf("0", "1") } @@ -274,6 +276,8 @@ class ProgramRepositoryImplTest { .displayName("program1") .programType(ProgramType.WITHOUT_REGISTRATION) .style(ObjectStyle.builder().build()) + .categoryCombo(ObjectWithUid.create("categoryComboUid")) + .enrollmentCategoryCombo(ObjectWithUid.create("categoryComboUid")) .build(), Program .builder() @@ -281,6 +285,8 @@ class ProgramRepositoryImplTest { .displayName("program2") .programType(ProgramType.WITH_REGISTRATION) .style(ObjectStyle.builder().build()) + .categoryCombo(ObjectWithUid.create("categoryComboUid")) + .enrollmentCategoryCombo(ObjectWithUid.create("categoryComboUid")) .trackedEntityType( TrackedEntityType .builder() diff --git a/app/src/test/java/org/dhis2/usescases/main/program/ProgramViewModelTest.kt b/app/src/test/java/org/dhis2/usescases/main/program/ProgramViewModelTest.kt index 0b3804dde34..5f8271dc73c 100644 --- a/app/src/test/java/org/dhis2/usescases/main/program/ProgramViewModelTest.kt +++ b/app/src/test/java/org/dhis2/usescases/main/program/ProgramViewModelTest.kt @@ -16,10 +16,10 @@ import org.dhis2.commons.filters.FilterManager import org.dhis2.commons.matomo.MatomoAnalyticsController import org.dhis2.commons.viewmodel.DispatcherProvider import org.dhis2.data.schedulers.TestSchedulerProvider -import org.dhis2.data.service.SyncStatusController -import org.dhis2.data.service.SyncStatusData import org.dhis2.mobile.commons.extensions.toColor import org.dhis2.mobile.commons.model.MetadataIconData +import org.dhis2.mobile.sync.domain.SyncStatusController +import org.dhis2.mobile.sync.model.SyncStatusData import org.dhis2.utils.MainCoroutineScopeRule import org.hisp.dhis.android.core.common.State import org.hisp.dhis.mobile.ui.designsystem.component.ImageCardData diff --git a/app/src/test/java/org/dhis2/usescases/notes/NotesRepositoryTest.kt b/app/src/test/java/org/dhis2/usescases/notes/NotesRepositoryTest.kt index b931b2efaa0..87cb0762bcd 100644 --- a/app/src/test/java/org/dhis2/usescases/notes/NotesRepositoryTest.kt +++ b/app/src/test/java/org/dhis2/usescases/notes/NotesRepositoryTest.kt @@ -4,6 +4,7 @@ import io.reactivex.Single import org.hisp.dhis.android.core.D2 import org.hisp.dhis.android.core.common.Access import org.hisp.dhis.android.core.common.DataAccess +import org.hisp.dhis.android.core.common.ObjectWithUid import org.hisp.dhis.android.core.enrollment.Enrollment import org.hisp.dhis.android.core.note.Note import org.hisp.dhis.android.core.program.Program @@ -94,6 +95,8 @@ class NotesRepositoryTest { Program .builder() .uid(UUID.randomUUID().toString()) + .categoryCombo(ObjectWithUid.create("categoryComboUid")) + .enrollmentCategoryCombo(ObjectWithUid.create("categoryComboUid")) .access( Access .builder() @@ -171,6 +174,11 @@ class NotesRepositoryTest { .eq(teiUid) .one() .blockingGet(), - ) doReturn Enrollment.builder().uid(enrollmentUid).build() + ) doReturn + Enrollment + .builder() + .uid(enrollmentUid) + .attributeOptionCombo("attributeOptionComboUid") + .build() } } diff --git a/app/src/test/java/org/dhis2/usescases/notes/noteDetail/NoteDetailRepositoryTest.kt b/app/src/test/java/org/dhis2/usescases/notes/noteDetail/NoteDetailRepositoryTest.kt index e228758c385..c8c2fd556ae 100644 --- a/app/src/test/java/org/dhis2/usescases/notes/noteDetail/NoteDetailRepositoryTest.kt +++ b/app/src/test/java/org/dhis2/usescases/notes/noteDetail/NoteDetailRepositoryTest.kt @@ -153,6 +153,11 @@ class NoteDetailRepositoryTest { .eq(teiUid) .one() .blockingGet(), - ) doReturn Enrollment.builder().uid("EnrollmentUid").build() + ) doReturn + Enrollment + .builder() + .uid("EnrollmentUid") + .attributeOptionCombo("attributeOptionComboUid") + .build() } } diff --git a/app/src/test/java/org/dhis2/usescases/programEventDetail/ProgramEventDetailPresenterTest.kt b/app/src/test/java/org/dhis2/usescases/programEventDetail/ProgramEventDetailPresenterTest.kt index 494382f094c..bffb0e8e37f 100644 --- a/app/src/test/java/org/dhis2/usescases/programEventDetail/ProgramEventDetailPresenterTest.kt +++ b/app/src/test/java/org/dhis2/usescases/programEventDetail/ProgramEventDetailPresenterTest.kt @@ -15,6 +15,7 @@ import org.dhis2.commons.prefs.PreferenceProvider import org.dhis2.data.schedulers.TrampolineSchedulerProvider import org.hisp.dhis.android.core.category.CategoryCombo import org.hisp.dhis.android.core.category.CategoryOptionCombo +import org.hisp.dhis.android.core.common.ObjectWithUid import org.hisp.dhis.android.core.program.Program import org.junit.After import org.junit.Assert.assertTrue @@ -69,7 +70,13 @@ class ProgramEventDetailPresenterTest { @Test fun `Should init screen`() { - val program = Program.builder().uid("programUid").build() + val program = + Program + .builder() + .uid("programUid") + .categoryCombo(ObjectWithUid.create("categoryComboUid")) + .enrollmentCategoryCombo(ObjectWithUid.create("categoryComboUid")) + .build() whenever(repository.getAccessDataWrite()) doReturn true whenever(repository.program()) doReturn Single.just(program) diff --git a/app/src/test/java/org/dhis2/usescases/programEventDetail/ProgramEventMapperTest.kt b/app/src/test/java/org/dhis2/usescases/programEventDetail/ProgramEventMapperTest.kt index cb20610a333..6ba25515874 100644 --- a/app/src/test/java/org/dhis2/usescases/programEventDetail/ProgramEventMapperTest.kt +++ b/app/src/test/java/org/dhis2/usescases/programEventDetail/ProgramEventMapperTest.kt @@ -7,6 +7,7 @@ import org.dhis2.mobile.commons.model.MetadataIconData import org.hisp.dhis.android.core.D2 import org.hisp.dhis.android.core.category.CategoryOptionCombo import org.hisp.dhis.android.core.common.ObjectStyle +import org.hisp.dhis.android.core.common.ObjectWithUid import org.hisp.dhis.android.core.common.State import org.hisp.dhis.android.core.event.Event import org.hisp.dhis.android.core.event.EventStatus @@ -226,6 +227,8 @@ class ProgramEventMapperTest { .uid("programUid") .completeEventsExpiryDays(0) .expiryDays(0) + .categoryCombo(ObjectWithUid.create("categoryComboUid")) + .enrollmentCategoryCombo(ObjectWithUid.create("categoryComboUid")) .build() private fun dummyCategoryOptionCombo() = diff --git a/app/src/test/java/org/dhis2/usescases/searchTrackEntity/SearchRepositoryTest.kt b/app/src/test/java/org/dhis2/usescases/searchTrackEntity/SearchRepositoryTest.kt index b2437a4242b..ff3113ab982 100644 --- a/app/src/test/java/org/dhis2/usescases/searchTrackEntity/SearchRepositoryTest.kt +++ b/app/src/test/java/org/dhis2/usescases/searchTrackEntity/SearchRepositoryTest.kt @@ -7,7 +7,6 @@ import org.dhis2.commons.filters.Filters import org.dhis2.commons.filters.data.FilterPresenter import org.dhis2.commons.filters.sorting.SortingItem import org.dhis2.commons.network.NetworkUtils -import org.dhis2.commons.resources.DhisPeriodUtils import org.dhis2.commons.resources.MetadataIconProvider import org.dhis2.commons.resources.ResourceManager import org.dhis2.commons.viewmodel.DispatcherProvider @@ -16,8 +15,8 @@ import org.dhis2.data.sorting.SearchSortingValueSetter import org.dhis2.form.model.FieldUiModel import org.dhis2.form.model.FieldUiModelImpl import org.dhis2.form.model.UiRenderType -import org.dhis2.form.ui.FieldViewModelFactory import org.dhis2.mobile.commons.customintents.CustomIntentRepository +import org.dhis2.mobile.commons.network.NetworkStatusProvider import org.dhis2.mobile.commons.reporting.CrashReportController import org.dhis2.tracker.data.ProfilePictureProvider import org.dhis2.ui.ThemeManager @@ -26,6 +25,7 @@ import org.hisp.dhis.android.core.arch.repositories.filters.internal.BooleanFilt import org.hisp.dhis.android.core.arch.repositories.filters.internal.StringFilterConnector import org.hisp.dhis.android.core.arch.repositories.`object`.ReadOnlyOneObjectRepositoryFinalImpl import org.hisp.dhis.android.core.arch.repositories.scope.RepositoryScope +import org.hisp.dhis.android.core.common.ObjectWithUid import org.hisp.dhis.android.core.common.State import org.hisp.dhis.android.core.common.ValueType import org.hisp.dhis.android.core.enrollment.Enrollment @@ -46,7 +46,6 @@ import org.hisp.dhis.android.core.trackedentity.TrackedEntityType import org.hisp.dhis.android.core.trackedentity.search.TrackedEntitySearchItem import org.hisp.dhis.android.core.trackedentity.search.TrackedEntitySearchItemAttribute import org.hisp.dhis.android.core.trackedentity.search.TrackedEntitySearchItemHelper -import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue import org.junit.Before @@ -62,7 +61,6 @@ import java.util.Date class SearchRepositoryTest { private val d2: D2 = Mockito.mock(D2::class.java, Mockito.RETURNS_DEEP_STUBS) - private val fieldViewModelFactory: FieldViewModelFactory = mock() private val metadataIconProvider: MetadataIconProvider = mock() private val dispatchers: DispatcherProvider = mock { @@ -75,26 +73,30 @@ class SearchRepositoryTest { private val trackedEntitySearchItemHelper: TrackedEntitySearchItemHelper = mock() private val enrollmentCollectionRepository: EnrollmentCollectionRepository = mock() - private val stringFilterConnector: StringFilterConnector = mock() - private val booleanFilterConnector: BooleanFilterConnector = mock() + private val stringFilterConnector: StringFilterConnector = + mock() + private val booleanFilterConnector: BooleanFilterConnector = + mock() private val programCollectionRepository: ProgramCollectionRepository = mock() - private val programReadOnlyOneObjectRepository: ReadOnlyOneObjectRepositoryFinalImpl = mock() + private val programReadOnlyOneObjectRepository: ReadOnlyOneObjectRepositoryFinalImpl = + mock() private val eventCollectionRepository: EventCollectionRepository = mock() private val eventStatusFilterConnector: EventStatusFilterConnector = mock() - private val stringEventFilterConnector: StringFilterConnector = mock() + private val stringEventFilterConnector: StringFilterConnector = + mock() private val orgUnitCollectionRepository: OrganisationUnitCollectionRepository = mock() - private val readOnlyOneObjectRepository: ReadOnlyOneObjectRepositoryFinalImpl = mock() + private val readOnlyOneObjectRepository: ReadOnlyOneObjectRepositoryFinalImpl = + mock() private val filterPresenter: FilterPresenter = mock() private val resourceManager: ResourceManager = mock() private val sortingValueSetter: SearchSortingValueSetter = mock() - private val dhisPeriodUtils: DhisPeriodUtils = mock() private val charts: Charts = mock() private val crashReporterController: CrashReportController = mock() - private val networkUtils: NetworkUtils = mock() + private val networkUtils: NetworkStatusProvider = mock() private val searchTEIRepository: SearchTEIRepository = mock() private val themeManager: ThemeManager = mock() private val profilePictureProvider: ProfilePictureProvider = mock() @@ -111,7 +113,9 @@ class SearchRepositoryTest { ) val trackedEntityAttributeCollection = mock() - whenever(d2.trackedEntityModule().trackedEntityAttributes()).thenReturn(trackedEntityAttributeCollection) + whenever(d2.trackedEntityModule().trackedEntityAttributes()).thenReturn( + trackedEntityAttributeCollection, + ) whenever(trackedEntityAttributeCollection.uid(anyString())).thenAnswer { invocation -> val uid = invocation.arguments[0] as String trackedEntityAttributes[uid] ?: createTrackedEntityAttributeRepository(uid, false) @@ -122,8 +126,6 @@ class SearchRepositoryTest { searchRepositoryJava = mock(), d2 = d2, dispatcher = dispatchers, - fieldViewModelFactory = fieldViewModelFactory, - metadataIconProvider = metadataIconProvider, trackedEntityInstanceInfoProvider = mock(), eventInfoProvider = mock(), customIntentRepository = customIntentRepository, @@ -137,7 +139,6 @@ class SearchRepositoryTest { filterPresenter, resourceManager, sortingValueSetter, - dhisPeriodUtils, charts, crashReporterController, networkUtils, @@ -147,30 +148,20 @@ class SearchRepositoryTest { profilePictureProvider, dateUtils, customIntentRepository, + dispatchers ) } - @Test - fun shouldSortSearchParametersCorrectly() { - val mockData = createMockData() - val sortedData = searchRepository.sortSearchParameters(mockData) - - assertEquals("unique-code", sortedData[0].uid) - assertEquals("bp-number", sortedData[1].uid) - assertEquals("qr-code", sortedData[2].uid) - assertEquals("bar-code", sortedData[3].uid) - assertEquals("unique-id", sortedData[4].uid) - assertEquals("national-id", sortedData[5].uid) - assertEquals("first-name", sortedData[6].uid) - assertEquals("last-name", sortedData[7].uid) - assertEquals("phone-number", sortedData[8].uid) - assertEquals("state", sortedData[9].uid) - } - @Test fun shouldTransformToSearchTeiModelWithOverdueEvents() { val searchItem = getTrackedEntitySearchItem("header") - val program = Program.builder().uid("programUid").build() + val program = + Program + .builder() + .uid("programUid") + .categoryCombo(ObjectWithUid.create("categoryComboUid")) + .enrollmentCategoryCombo(ObjectWithUid.create("categoryComboUid")) + .build() val sorting = SortingItem.create(Filters.ENROLLMENT_DATE) val tei = TrackedEntitySearchItemHelper.toTrackedEntityInstance(searchItem) @@ -203,7 +194,13 @@ class SearchRepositoryTest { @Test fun shouldTransformToSearchTeiModelWithOverdueScheduledEvents() { val searchItem = getTrackedEntitySearchItem("header") - val program = Program.builder().uid("programUid").build() + val program = + Program + .builder() + .uid("programUid") + .categoryCombo(ObjectWithUid.create("categoryComboUid")) + .enrollmentCategoryCombo(ObjectWithUid.create("categoryComboUid")) + .build() val sorting = SortingItem.create(Filters.ENROLLMENT_DATE) val tei = TrackedEntitySearchItemHelper.toTrackedEntityInstance(searchItem) @@ -235,7 +232,13 @@ class SearchRepositoryTest { @Test fun shouldTransformToSearchTeiModelWithOutOverdueEvents() { val searchItem = getTrackedEntitySearchItem("header") - val program = Program.builder().uid("programUid").build() + val program = + Program + .builder() + .uid("programUid") + .categoryCombo(ObjectWithUid.create("categoryComboUid")) + .enrollmentCategoryCombo(ObjectWithUid.create("categoryComboUid")) + .build() val sorting = SortingItem.create(Filters.ENROLLMENT_DATE) val tei = TrackedEntitySearchItemHelper.toTrackedEntityInstance(searchItem) @@ -343,7 +346,8 @@ class SearchRepositoryTest { enrollmentCollectionRepository.blockingGet(), ) doReturn enrollmentsForInfoToReturn - val programUid = if (enrollmentsForInfoToReturn.isNotEmpty()) enrollmentsForInfoToReturn[0].program() else "programUid" + val programUid = + if (enrollmentsForInfoToReturn.isNotEmpty()) enrollmentsForInfoToReturn[0].program() else "programUid" whenever(d2.programModule().programs()) doReturn programCollectionRepository whenever( programCollectionRepository.uid(any()), @@ -355,6 +359,8 @@ class SearchRepositoryTest { .builder() .uid(programUid) .displayFrontPageList(true) + .categoryCombo(ObjectWithUid.create("categoryComboUid")) + .enrollmentCategoryCombo(ObjectWithUid.create("categoryComboUid")) .build() // Mock setOverdueEvents @@ -389,13 +395,23 @@ class SearchRepositoryTest { whenever( eventCollectionRepository.blockingGet(), ) doReturn eventsToReturn.filter { it.status() == EventStatus.OVERDUE } - whenever(eventStatusFilterConnector.eq(EventStatus.SCHEDULE)).thenReturn(eventCollectionRepository) - whenever(eventCollectionRepository.byStatus().eq(EventStatus.SCHEDULE)).thenReturn(eventCollectionRepository) - whenever(eventCollectionRepository.byProgramUid().eq(any())).thenReturn(eventCollectionRepository) - whenever(eventCollectionRepository.orderByDueDate(RepositoryScope.OrderByDirection.DESC)).thenReturn(eventCollectionRepository) + whenever(eventStatusFilterConnector.eq(EventStatus.SCHEDULE)).thenReturn( + eventCollectionRepository, + ) + whenever(eventCollectionRepository.byStatus().eq(EventStatus.SCHEDULE)).thenReturn( + eventCollectionRepository, + ) + whenever(eventCollectionRepository.byProgramUid().eq(any())).thenReturn( + eventCollectionRepository, + ) + whenever(eventCollectionRepository.orderByDueDate(RepositoryScope.OrderByDirection.DESC)).thenReturn( + eventCollectionRepository, + ) whenever(eventCollectionRepository.blockingGet()).thenReturn(eventsToReturn.filter { it.status() == EventStatus.SCHEDULE }) // mock orgUnitName(orgUnitUid) - whenever(d2.organisationUnitModule().organisationUnits()) doReturn orgUnitCollectionRepository + whenever( + d2.organisationUnitModule().organisationUnits(), + ) doReturn orgUnitCollectionRepository whenever( orgUnitCollectionRepository.uid(any()), ) doReturn readOnlyOneObjectRepository @@ -451,6 +467,7 @@ class SearchRepositoryTest { .organisationUnit(orgUnitUid) .program(programUid) .status(status) + .attributeOptionCombo("attributeOptionComboUid") .build() private fun createEvent( diff --git a/app/src/test/java/org/dhis2/usescases/searchTrackEntity/SearchTEIViewModelTest.kt b/app/src/test/java/org/dhis2/usescases/searchTrackEntity/SearchTEIViewModelTest.kt index 546d952cbd0..3c7e0dd9e23 100644 --- a/app/src/test/java/org/dhis2/usescases/searchTrackEntity/SearchTEIViewModelTest.kt +++ b/app/src/test/java/org/dhis2/usescases/searchTrackEntity/SearchTEIViewModelTest.kt @@ -23,18 +23,24 @@ import org.dhis2.commons.filters.FilterManager import org.dhis2.commons.network.NetworkUtils import org.dhis2.commons.resources.ResourceManager import org.dhis2.commons.viewmodel.DispatcherProvider -import org.dhis2.data.search.SearchParametersModel -import org.dhis2.form.model.FieldUiModel -import org.dhis2.form.model.FieldUiModelImpl -import org.dhis2.form.ui.intent.FormIntent +import org.dhis2.form.ui.customintent.CustomIntentResult import org.dhis2.form.ui.provider.DisplayNameProvider import org.dhis2.maps.geometry.mapper.EventsByProgramStage import org.dhis2.maps.usecases.MapStyleConfiguration +import org.dhis2.mobile.commons.model.CustomIntentModel +import org.dhis2.tracker.input.model.TrackerInputType +import org.dhis2.tracker.input.ui.action.TrackerInputAction +import org.dhis2.tracker.input.ui.state.TrackerInputUiState +import org.dhis2.tracker.search.domain.FetchOptionSetOptions +import org.dhis2.tracker.search.domain.FetchSearchParameters +import org.dhis2.tracker.search.domain.SearchTrackedEntities +import org.dhis2.tracker.search.model.SearchTrackedEntitiesInput import org.dhis2.usescases.searchTrackEntity.listView.SearchResult.SearchResultType import org.dhis2.utils.customviews.navigationbar.NavigationPage -import org.hisp.dhis.android.core.common.ValueType +import org.hisp.dhis.android.core.common.ObjectWithUid import org.hisp.dhis.android.core.program.Program import org.hisp.dhis.android.core.trackedentity.TrackedEntityType +import org.hisp.dhis.mobile.ui.designsystem.component.Orientation import org.hisp.dhis.mobile.ui.designsystem.component.navigationBar.NavigationBarItem import org.junit.After import org.junit.Assert.assertTrue @@ -43,12 +49,13 @@ import org.junit.Rule import org.junit.Test import org.maplibre.geojson.BoundingBox import org.mockito.kotlin.any +import org.mockito.kotlin.anyOrNull import org.mockito.kotlin.doReturn +import org.mockito.kotlin.eq import org.mockito.kotlin.mock -import org.mockito.kotlin.times +import org.mockito.kotlin.never import org.mockito.kotlin.verify import org.mockito.kotlin.whenever -import kotlin.text.get @OptIn(ExperimentalCoroutinesApi::class) class SearchTEIViewModelTest { @@ -59,10 +66,7 @@ class SearchTEIViewModelTest { private val initialProgram = "programUid" private val initialQuery = mutableMapOf?>() private val repository: SearchRepository = mock() - private val repositoryKt: SearchRepositoryKt = - mock { - on { searchTrackedEntities(any(), any()) } doReturn flowOf(PagingData.empty()) - } + private val repositoryKt: SearchRepositoryKt = mock() private val pageConfigurator: SearchPageConfigurator = mock() private val mapDataRepository: MapDataRepository = mock() private val networkUtils: NetworkUtils = mock() @@ -70,6 +74,13 @@ class SearchTEIViewModelTest { private val resourceManager: ResourceManager = mock() private val displayNameProvider: DisplayNameProvider = mock() private val filterManager: FilterManager = mock() + private val searchTrackedEntities: SearchTrackedEntities = + mock { + onBlocking { invoke(any()) } doReturn Result.success(flowOf(PagingData.empty())) + } + + private val fetchSearchParameters: FetchSearchParameters = mock() + private val fetchOptionSetOptions: FetchOptionSetOptions = mock() @ExperimentalCoroutinesApi private val testingDispatcher = StandardTestDispatcher() @@ -83,6 +94,8 @@ class SearchTEIViewModelTest { whenever(repository.canCreateInProgramWithoutSearch()) doReturn true whenever(repository.getTrackedEntityType()) doReturn testingTrackedEntityType() whenever(repository.filtersApplyOnGlobalSearch()) doReturn true + whenever(repositoryKt.getExcludeValues()) doReturn HashSet() + whenever(repositoryKt.saveSearchValuesAndGetAllowCache(any(), any())) doReturn true viewModel = SearchTEIViewModel( initialProgram, @@ -103,6 +116,9 @@ class SearchTEIViewModelTest { resourceManager = resourceManager, displayNameProvider = displayNameProvider, filterManager = filterManager, + searchTrackedEntities = searchTrackedEntities, + fetchSearchParameters = fetchSearchParameters, + fetchOptionSetOptions = fetchOptionSetOptions, ) testingDispatcher.scheduler.advanceUntilIdle() } @@ -194,74 +210,61 @@ class SearchTEIViewModelTest { @Test fun `Should update query data`() { - viewModel.onParameterIntent( - FormIntent.OnSave( - uid = "testingUid", - value = "testingValue", - valueType = ValueType.TEXT, - ), + viewModel.onValueChange( + fieldUid = "testingUid", + value = "testingValue", ) - val queryData = viewModel.queryData + val queryData = viewModel.queryDataList assertTrue(queryData.isNotEmpty()) - assertTrue(queryData["testingUid"]?.size == 1) - val values = queryData["testingUid"] - assertTrue(values?.contains("testingValue") == true) + assertTrue(queryData.size == 1) + val data = queryData.first { it.attributeId == "testingUid" } + assertTrue(data.values?.get(0) == "testingValue") } @Test fun `Should update query data when list of values is passed`() { - viewModel.onParameterIntent( - FormIntent.OnSave( - uid = "testingUid", - value = "testingValue,testingValue2", - valueType = ValueType.TEXT, - ), + viewModel.onValueChange( + fieldUid = "testingUid", + value = "testingValue,testingValue2", ) - val queryData = viewModel.queryData + val queryData = viewModel.queryDataList assertTrue(queryData.isNotEmpty()) - assertTrue(queryData.containsKey("testingUid")) - val values = queryData["testingUid"] - assertTrue(values?.size == 2) - assertTrue(values?.contains("testingValue") == true) - assertTrue(values?.contains("testingValue2") == true) + assertTrue(queryData.any { it.attributeId == "testingUid" }) + val data = queryData.first { it.attributeId == "testingUid" } + assertTrue(data.values?.size == 2) + assertTrue(data.values?.contains("testingValue") == true) + assertTrue(data.values?.contains("testingValue2") == true) } @Test fun `Should update query data when various list of values are passed`() { - viewModel.onParameterIntent( - FormIntent.OnSave( - uid = "testingUid", - value = "testingValue,testingValue2", - valueType = ValueType.TEXT, - ), + viewModel.onValueChange( + fieldUid = "testingUid", + value = "testingValue,testingValue2", ) - - viewModel.onParameterIntent( - FormIntent.OnSave( - uid = "testingUid2", - value = "testingValue,testingValue2", - valueType = ValueType.TEXT, - ), + viewModel.onValueChange( + fieldUid = "testingUid2", + value = "testingValue,testingValue2", ) - val queryData = viewModel.queryData + val queryData = viewModel.queryDataList assertTrue(queryData.isNotEmpty()) - assertTrue(queryData.containsKey("testingUid")) - val values1 = queryData["testingUid"] - assertTrue(values1?.size == 2) - assertTrue(values1?.contains("testingValue") == true) - assertTrue(values1?.contains("testingValue2") == true) - - assertTrue(queryData.containsKey("testingUid")) - val values2 = queryData["testingUid2"] - assertTrue(values2?.size == 2) - assertTrue(values2?.contains("testingValue") == true) - assertTrue(values2?.contains("testingValue2") == true) + assertTrue(queryData.any { it.attributeId == "testingUid" }) + val data1 = queryData.first { it.attributeId == "testingUid" } + assertTrue(data1.values?.size == 2) + assertTrue(data1.values?.contains("testingValue") == true) + assertTrue(data1.values?.contains("testingValue2") == true) + + assertTrue(queryData.any { it.attributeId == "testingUid2" }) + val data2 = queryData.first { it.attributeId == "testingUid2" } + assertTrue(data2.values?.size == 2) + assertTrue(data2.values?.contains("testingValue") == true) + assertTrue(data2.values?.contains("testingValue2") == true) } @ExperimentalCoroutinesApi @@ -273,12 +276,17 @@ class SearchTEIViewModelTest { viewModel.searchPagingData.take(1).asSnapshot() - verify(repositoryKt).searchTrackedEntities( - SearchParametersModel( - selectedProgram = testingProgram, - queryData = mutableMapOf(), + verify(searchTrackedEntities).invoke( + eq( + SearchTrackedEntitiesInput( + selectedProgram = testingProgram.uid(), + queryDataList = mutableListOf(), + allowCache = true, + excludeValues = emptySet(), + hasStateFilters = false, + isOnline = false, + ), ), - false, ) } @@ -289,21 +297,7 @@ class SearchTEIViewModelTest { setCurrentProgram(testingProgram) viewModel.searchPagingData.test { awaitItem() - verify(repositoryKt, times(0)).searchTrackedEntities( - SearchParametersModel( - selectedProgram = testingProgram, - queryData = mutableMapOf(), - ), - true, - ) - - verify(repositoryKt, times(0)).searchTrackedEntities( - SearchParametersModel( - selectedProgram = testingProgram, - queryData = mutableMapOf(), - ), - false, - ) + verify(searchTrackedEntities, never()).invoke(any()) } } @@ -333,7 +327,7 @@ class SearchTEIViewModelTest { whenever( mapDataRepository.getTrackerMapData( testingProgram(), - viewModel.queryData, + viewModel.queryDataAsMap(), ), ) doReturn trackerMapData @@ -361,13 +355,11 @@ class SearchTEIViewModelTest { setCurrentProgram(testingProgram()) viewModel.setListScreen() viewModel.setSearchScreen() - viewModel.onParameterIntent( - FormIntent.OnSave( - uid = "testingUid", - value = "testingValue", - valueType = ValueType.TEXT, - ), + viewModel.onValueChange( + fieldUid = "testingUid", + value = "testingValue", ) + viewModel.onSearch() assertTrue(viewModel.refreshData.value != null) @@ -379,7 +371,7 @@ class SearchTEIViewModelTest { whenever( mapDataRepository.getTrackerMapData( testingProgram(), - viewModel.queryData, + viewModel.queryDataAsMap(), ), ) doReturn TrackerMapData( @@ -397,13 +389,11 @@ class SearchTEIViewModelTest { setCurrentProgram(testingProgram()) viewModel.setMapScreen() viewModel.setSearchScreen() - viewModel.onParameterIntent( - FormIntent.OnSave( - uid = "testingUid", - value = "testingValue", - valueType = ValueType.TEXT, - ), + viewModel.onValueChange( + fieldUid = "testingUid", + value = "testingValue", ) + viewModel.onSearch() testingDispatcher.scheduler.advanceUntilIdle() @@ -411,14 +401,14 @@ class SearchTEIViewModelTest { assertTrue(viewModel.refreshData.value != null) verify(mapDataRepository).getTrackerMapData( testingProgram(), - viewModel.queryData, + viewModel.queryDataAsMap(), ) } @Test fun `Should filter query data for new program`() { viewModel.queryDataByProgram("programUid") - verify(repository).filterQueryForProgram(viewModel.queryData, "programUid") + verify(repository).filterQueryForProgram(viewModel.queryDataAsMap(), "programUid") } @Test @@ -510,8 +500,11 @@ class SearchTEIViewModelTest { setCurrentProgram(testingProgram(maxTeiCountToReturn = 1)) setAllowCreateBeforeSearch(false) whenever( - repository.filterQueryForProgram(viewModel.queryData, null), - ) doReturn mapOf("field" to listOf("value")) + repository.filterQueryForProgram( + any(), + anyOrNull(), + ), + ) doReturn mapOf("testingUid" to listOf("testingValue")) performSearch() viewModel.onDataLoaded(1) @@ -526,7 +519,7 @@ class SearchTEIViewModelTest { fun `Should return unable to search outside result for search`() { setCurrentProgram(testingProgram(maxTeiCountToReturn = 1)) setAllowCreateBeforeSearch(false) - whenever(repository.filterQueryForProgram(viewModel.queryData, null)) doReturn mapOf() + whenever(repository.filterQueryForProgram(viewModel.queryDataAsMap(), null)) doReturn mapOf() whenever(repository.trackedEntityTypeFields()) doReturn listOf("Field_1", "Field_2") performSearch() @@ -717,7 +710,8 @@ class SearchTEIViewModelTest { @Test fun `should return user-friendly names on search parameters fields`() { - viewModel.searchParametersUiState = viewModel.searchParametersUiState.copy(items = getFieldUIModels()) + viewModel.searchParametersUiState = + viewModel.searchParametersUiState.copy(items = getTrackerInputModels()) val expectedMap = mapOf( "uid1" to "Friendly OrgUnit Name", @@ -725,7 +719,7 @@ class SearchTEIViewModelTest { "uid3" to "21/02/2024", "uid4" to "21/02/2024 - 01:00", "uid5" to "Boolean: false", - "uid6" to "Yes Only", + "uid6" to "Yes Only: true", "uid7" to "Text value", "uid9" to "18%", ) @@ -737,17 +731,19 @@ class SearchTEIViewModelTest { @Test fun `should clear uiState when clearing data`() { - viewModel.searchParametersUiState = viewModel.searchParametersUiState.copy(items = getFieldUIModels()) + viewModel.searchParametersUiState = + viewModel.searchParametersUiState.copy(items = getTrackerInputModels()) performSearch() viewModel.clearQueryData() - assert(viewModel.queryData.isEmpty()) + assert(viewModel.queryDataList.isEmpty()) assert(viewModel.searchParametersUiState.items.all { it.value == null }) assert(viewModel.searchParametersUiState.searchedItems.isEmpty()) } @Test fun `should return date without format`() { - viewModel.searchParametersUiState = viewModel.searchParametersUiState.copy(items = getMalformedDateFieldUIModels()) + viewModel.searchParametersUiState = + viewModel.searchParametersUiState.copy(items = getMalformedDateFieldUIModels()) val expectedMap = mapOf( "uid1" to "04", @@ -792,6 +788,9 @@ class SearchTEIViewModelTest { resourceManager = resourceManager, displayNameProvider = displayNameProvider, filterManager = filterManager, + searchTrackedEntities = searchTrackedEntities, + fetchSearchParameters = fetchSearchParameters, + fetchOptionSetOptions = fetchOptionSetOptions, ) testingDispatcher.scheduler.advanceUntilIdle() @@ -838,6 +837,9 @@ class SearchTEIViewModelTest { }, displayNameProvider = displayNameProvider, filterManager = filterManager, + searchTrackedEntities = searchTrackedEntities, + fetchSearchParameters = fetchSearchParameters, + fetchOptionSetOptions = fetchOptionSetOptions, ) testingDispatcher.scheduler.advanceUntilIdle() @@ -863,93 +865,292 @@ class SearchTEIViewModelTest { ) } - private fun getMalformedDateFieldUIModels(): List = + @Test + fun `should send launch custom intent action`() = + runTest { + val customIntentModel: CustomIntentModel = mock() + whenever(repositoryKt.getCustomIntent(any())) doReturn customIntentModel + viewModel.searchActions.test { + viewModel.launchCustomIntent("fieldUid", "customIntentUid") + assertTrue(awaitItem() is TrackerInputAction.LaunchCustomIntent) + } + } + + @Test + fun `should set error if custom intent result is error`() { + whenever(resourceManager.getString(R.string.custom_intent_error)) doReturn "Custom intent error message" + viewModel.searchParametersUiState = + viewModel.searchParametersUiState.copy(items = customIntentFieldUIModels()) + viewModel.handleCustomIntentResult( + CustomIntentResult.Error("fieldUid"), + ) + assertTrue( + viewModel.searchParametersUiState.items + .first() + .error != null, + ) + } + + @Test + fun `should update values if custom intent result is successful`() = + runTest { + viewModel.searchParametersUiState = + viewModel.searchParametersUiState.copy(items = customIntentFieldUIModels()) + viewModel.handleCustomIntentResult( + CustomIntentResult.Success("fieldUid", "customValue"), + ) + assertTrue( + viewModel.searchParametersUiState.items + .first() + .error == null, + ) + assertTrue( + viewModel.searchParametersUiState.items + .first() + .value == "customValue", + ) + } + + private fun customIntentFieldUIModels() = + listOf( + TrackerInputUiState( + uid = "fieldUid", + label = "CustomIntent", + value = null, + focused = false, + valueType = TrackerInputType.ORGANISATION_UNIT, + description = null, + mandatory = false, + editable = true, + legend = null, + orientation = Orientation.HORIZONTAL, + optionSetConfiguration = null, + customIntentUid = "customIntentUid", + displayName = "Friendly OrgUnit Name", + orgUnitSelectorScope = null, + searchOperator = null, + minCharactersToSearch = null, + optionSet = null, + error = null, + warning = null, + ), + ) + + private fun getMalformedDateFieldUIModels(): List = listOf( - FieldUiModelImpl( + TrackerInputUiState( uid = "uid1", label = "Date", value = "04", - autocompleteList = emptyList(), + focused = false, + valueType = TrackerInputType.DATE, + description = null, + mandatory = false, + editable = true, + legend = null, + orientation = Orientation.HORIZONTAL, optionSetConfiguration = null, - valueType = ValueType.DATE, + customIntentUid = null, + displayName = "Friendly OrgUnit Name", + orgUnitSelectorScope = null, + searchOperator = null, + minCharactersToSearch = null, + optionSet = null, + error = null, + warning = null, ), ) - private fun getFieldUIModels(): List = + private fun getTrackerInputModels(): List = listOf( - FieldUiModelImpl( + TrackerInputUiState( uid = "uid1", label = "Org Unit", value = "orgUnitUid", - displayName = "Friendly OrgUnit Name", - autocompleteList = emptyList(), + focused = false, + valueType = TrackerInputType.ORGANISATION_UNIT, + description = null, + mandatory = false, + editable = true, + legend = null, + orientation = Orientation.HORIZONTAL, optionSetConfiguration = null, - valueType = ValueType.ORGANISATION_UNIT, + customIntentUid = null, + displayName = "Friendly OrgUnit Name", + orgUnitSelectorScope = null, + searchOperator = null, + minCharactersToSearch = null, + optionSet = null, + error = null, + warning = null, ), - FieldUiModelImpl( + TrackerInputUiState( uid = "uid2", label = "Gender", value = "M", - displayName = "Male", - autocompleteList = emptyList(), + focused = false, + valueType = TrackerInputType.MULTI_SELECTION, + description = null, + mandatory = false, + editable = true, + legend = null, + orientation = Orientation.HORIZONTAL, optionSetConfiguration = null, - valueType = ValueType.MULTI_TEXT, + customIntentUid = null, + displayName = "Male", + orgUnitSelectorScope = null, + searchOperator = null, + minCharactersToSearch = null, + optionSet = null, + error = null, + warning = null, ), - FieldUiModelImpl( + TrackerInputUiState( uid = "uid3", label = "Date", value = "2024-02-21", - autocompleteList = emptyList(), + focused = false, + valueType = TrackerInputType.DATE, + description = null, + mandatory = false, + editable = true, + legend = null, + orientation = Orientation.HORIZONTAL, optionSetConfiguration = null, - valueType = ValueType.DATE, + customIntentUid = null, + displayName = "21/02/2024", + orgUnitSelectorScope = null, + searchOperator = null, + minCharactersToSearch = null, + optionSet = null, + error = null, + warning = null, ), - FieldUiModelImpl( + TrackerInputUiState( uid = "uid4", label = "Date and Time", value = "2024-02-21T01:00", - autocompleteList = emptyList(), + focused = false, + valueType = TrackerInputType.DATE_TIME, + description = null, + mandatory = false, + editable = true, + legend = null, + orientation = Orientation.HORIZONTAL, optionSetConfiguration = null, - valueType = ValueType.DATETIME, + customIntentUid = null, + displayName = "21/02/2024 - 01:00", + orgUnitSelectorScope = null, + searchOperator = null, + minCharactersToSearch = null, + optionSet = null, + error = null, + warning = null, ), - FieldUiModelImpl( + TrackerInputUiState( uid = "uid5", label = "Boolean", value = "false", - autocompleteList = emptyList(), + focused = false, + valueType = TrackerInputType.HORIZONTAL_CHECKBOXES, + description = null, + mandatory = false, + editable = true, + legend = null, + orientation = Orientation.HORIZONTAL, optionSetConfiguration = null, - valueType = ValueType.BOOLEAN, + customIntentUid = null, + displayName = "Boolean: false", + orgUnitSelectorScope = null, + searchOperator = null, + minCharactersToSearch = null, + optionSet = null, + error = null, + warning = null, ), - FieldUiModelImpl( + TrackerInputUiState( uid = "uid6", label = "Yes Only", value = "true", - autocompleteList = emptyList(), + focused = false, + valueType = TrackerInputType.YES_ONLY_SWITCH, + description = null, + mandatory = false, + editable = true, + legend = null, + orientation = Orientation.HORIZONTAL, optionSetConfiguration = null, - valueType = ValueType.TRUE_ONLY, + customIntentUid = null, + displayName = "Yes Only; true", + orgUnitSelectorScope = null, + searchOperator = null, + minCharactersToSearch = null, + optionSet = null, + error = null, + warning = null, ), - FieldUiModelImpl( + TrackerInputUiState( uid = "uid7", label = "Text", value = "Text value", - autocompleteList = emptyList(), + focused = false, + valueType = TrackerInputType.TEXT, + description = null, + mandatory = false, + editable = true, + legend = null, + orientation = Orientation.HORIZONTAL, optionSetConfiguration = null, - valueType = ValueType.TEXT, + customIntentUid = null, + displayName = "Text value", + orgUnitSelectorScope = null, + searchOperator = null, + minCharactersToSearch = null, + optionSet = null, + error = null, + warning = null, ), - FieldUiModelImpl( + TrackerInputUiState( uid = "uid8", label = "Other field", value = null, - autocompleteList = emptyList(), + focused = false, + valueType = TrackerInputType.TEXT, + description = null, + mandatory = false, + editable = true, + legend = null, + orientation = Orientation.HORIZONTAL, optionSetConfiguration = null, - valueType = ValueType.TEXT, + customIntentUid = null, + displayName = "Male", + orgUnitSelectorScope = null, + searchOperator = null, + minCharactersToSearch = null, + optionSet = null, + error = null, + warning = null, ), - FieldUiModelImpl( + TrackerInputUiState( uid = "uid9", label = "Percentage", value = "18", - autocompleteList = emptyList(), + focused = false, + valueType = TrackerInputType.PERCENTAGE, + description = null, + mandatory = false, + editable = true, + legend = null, + orientation = Orientation.HORIZONTAL, optionSetConfiguration = null, - valueType = ValueType.PERCENTAGE, + customIntentUid = null, + displayName = "18%", + orgUnitSelectorScope = null, + searchOperator = null, + minCharactersToSearch = null, + optionSet = null, + error = null, + warning = null, ), ) @@ -964,6 +1165,8 @@ class SearchTEIViewModelTest { .displayFrontPageList(displayFrontPageList) .minAttributesRequiredToSearch(minAttributesToSearch) .trackedEntityType(TrackedEntityType.builder().uid("teTypeUid").build()) + .categoryCombo(ObjectWithUid.create("categoryComboUid")) + .enrollmentCategoryCombo(ObjectWithUid.create("categoryComboUid")) .apply { maxTeiCountToReturn?.let { maxTeiCountToReturn(maxTeiCountToReturn) @@ -979,12 +1182,9 @@ class SearchTEIViewModelTest { @ExperimentalCoroutinesApi private fun performSearch() { - viewModel.onParameterIntent( - FormIntent.OnSave( - uid = "testingUid", - value = "testingValue", - valueType = ValueType.TEXT, - ), + viewModel.onValueChange( + fieldUid = "testingUid", + value = "testingValue", ) viewModel.setListScreen() viewModel.setSearchScreen() diff --git a/app/src/test/java/org/dhis2/usescases/searchTrackEntity/SearchTEPresenterTest.kt b/app/src/test/java/org/dhis2/usescases/searchTrackEntity/SearchTEPresenterTest.kt index aaac8009b41..449ecab97c9 100644 --- a/app/src/test/java/org/dhis2/usescases/searchTrackEntity/SearchTEPresenterTest.kt +++ b/app/src/test/java/org/dhis2/usescases/searchTrackEntity/SearchTEPresenterTest.kt @@ -10,9 +10,10 @@ import org.dhis2.commons.prefs.PreferenceProvider import org.dhis2.commons.resources.ColorUtils import org.dhis2.commons.resources.ResourceManager import org.dhis2.data.schedulers.TestSchedulerProvider -import org.dhis2.data.service.SyncStatusController +import org.dhis2.mobile.sync.domain.SyncStatusController import org.dhis2.utils.analytics.AnalyticsHelper import org.hisp.dhis.android.core.D2 +import org.hisp.dhis.android.core.common.ObjectWithUid import org.hisp.dhis.android.core.program.Program import org.hisp.dhis.android.core.trackedentity.TrackedEntityType import org.junit.After @@ -58,6 +59,8 @@ class SearchTEPresenterTest { .uid(initialProgram) .displayFrontPageList(true) .minAttributesRequiredToSearch(0) + .categoryCombo(ObjectWithUid.create("categoryComboUid")) + .enrollmentCategoryCombo(ObjectWithUid.create("categoryComboUid")) .build() whenever( @@ -98,6 +101,8 @@ class SearchTEPresenterTest { .uid("uid") .displayFrontPageList(true) .minAttributesRequiredToSearch(1) + .categoryCombo(ObjectWithUid.create("categoryComboUid")) + .enrollmentCategoryCombo(ObjectWithUid.create("categoryComboUid")) .build() presenter.setProgramForTesting(program) @@ -115,6 +120,8 @@ class SearchTEPresenterTest { .uid("uid") .displayFrontPageList(true) .minAttributesRequiredToSearch(1) + .categoryCombo(ObjectWithUid.create("categoryComboUid")) + .enrollmentCategoryCombo(ObjectWithUid.create("categoryComboUid")) .build() val newSelectedProgram = @@ -123,6 +130,8 @@ class SearchTEPresenterTest { .uid("uid2") .displayFrontPageList(true) .minAttributesRequiredToSearch(1) + .categoryCombo(ObjectWithUid.create("categoryComboUid")) + .enrollmentCategoryCombo(ObjectWithUid.create("categoryComboUid")) .build() whenever( diff --git a/app/src/test/java/org/dhis2/usescases/searchTrackEntity/ui/mapper/TEICardMapperTest.kt b/app/src/test/java/org/dhis2/usescases/searchTrackEntity/ui/mapper/TEICardMapperTest.kt index cd9dd6ffdfd..8e4c670254d 100644 --- a/app/src/test/java/org/dhis2/usescases/searchTrackEntity/ui/mapper/TEICardMapperTest.kt +++ b/app/src/test/java/org/dhis2/usescases/searchTrackEntity/ui/mapper/TEICardMapperTest.kt @@ -7,6 +7,7 @@ import org.dhis2.commons.date.toDateSpan import org.dhis2.commons.date.toOverdueOrScheduledUiText import org.dhis2.commons.resources.ResourceManager import org.dhis2.usescases.searchTrackEntity.SearchTeiModel +import org.hisp.dhis.android.core.common.ObjectWithUid import org.hisp.dhis.android.core.common.State import org.hisp.dhis.android.core.enrollment.Enrollment import org.hisp.dhis.android.core.enrollment.EnrollmentStatus @@ -134,6 +135,7 @@ class TEICardMapperTest { .uid("EnrollmentUid") .program("programUid") .status(EnrollmentStatus.COMPLETED) + .attributeOptionCombo("attributeOptionComboUid") .build(), ) setAttributeValues(attributeValues) @@ -143,6 +145,8 @@ class TEICardMapperTest { .builder() .uid("Program1Uid") .displayName("Program 1") + .categoryCombo(ObjectWithUid.create("categoryComboUid")) + .enrollmentCategoryCombo(ObjectWithUid.create("categoryComboUid")) .build(), null, ) @@ -151,6 +155,8 @@ class TEICardMapperTest { .builder() .uid("Program2Uid") .displayName("Program 2") + .categoryCombo(ObjectWithUid.create("categoryComboUid")) + .enrollmentCategoryCombo(ObjectWithUid.create("categoryComboUid")) .build(), null, ) @@ -162,6 +168,7 @@ class TEICardMapperTest { .builder() .uid("EnrollmentUid") .followUp(true) + .attributeOptionCombo("attributeOptionComboUid") .build(), ) } diff --git a/app/src/test/java/org/dhis2/usescases/settings/DeleteUserDataTest.kt b/app/src/test/java/org/dhis2/usescases/settings/DeleteUserDataTest.kt index 76e9340f115..ede8a776864 100644 --- a/app/src/test/java/org/dhis2/usescases/settings/DeleteUserDataTest.kt +++ b/app/src/test/java/org/dhis2/usescases/settings/DeleteUserDataTest.kt @@ -1,28 +1,39 @@ package org.dhis2.usescases.settings +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.runTest import org.dhis2.commons.filters.FilterManager import org.dhis2.commons.prefs.PreferenceProvider +import org.dhis2.commons.viewmodel.DispatcherProvider import org.dhis2.data.service.workManager.WorkManagerController import org.junit.Before import org.junit.Test +import org.mockito.kotlin.doReturn import org.mockito.kotlin.mock import org.mockito.kotlin.verify +@OptIn(ExperimentalCoroutinesApi::class) class DeleteUserDataTest { private lateinit var deleteUserData: DeleteUserData private val workManagerController: WorkManagerController = mock() private val filterManager: FilterManager = mock() private val preferencesProvider: PreferenceProvider = mock() + private val testingDispatcher = UnconfinedTestDispatcher() + private val dispatcherProvider: DispatcherProvider = mock { + on { io() } doReturn testingDispatcher + on { ui() } doReturn testingDispatcher + } @Before fun setup() { deleteUserData = - DeleteUserData(workManagerController, filterManager, preferencesProvider) + DeleteUserData(workManagerController, filterManager, preferencesProvider, dispatcherProvider) } @Test - fun `Should delete user data`() { + fun `Should delete user data`() = runTest { deleteUserData.wipeCacheAndPreferences(null) verify(workManagerController).cancelAllWork() diff --git a/app/src/test/java/org/dhis2/usescases/settings/SettingsRepositoryTest.kt b/app/src/test/java/org/dhis2/usescases/settings/SettingsRepositoryTest.kt index 4f241f06d06..fd3c9394c0f 100644 --- a/app/src/test/java/org/dhis2/usescases/settings/SettingsRepositoryTest.kt +++ b/app/src/test/java/org/dhis2/usescases/settings/SettingsRepositoryTest.kt @@ -19,6 +19,7 @@ import org.dhis2.commons.prefs.Preference.Companion.TIME_META import org.dhis2.commons.prefs.Preference.Companion.TIME_WEEKLY import org.dhis2.commons.prefs.PreferenceProvider import org.dhis2.data.server.UserManager +import org.dhis2.mobile.sync.data.SyncBackgroundJobAction import org.hisp.dhis.android.core.D2 import org.hisp.dhis.android.core.common.State import org.hisp.dhis.android.core.settings.DataSyncPeriod @@ -27,6 +28,7 @@ import org.hisp.dhis.android.core.settings.LimitScope import org.hisp.dhis.android.core.settings.MetadataSyncPeriod import org.hisp.dhis.android.core.settings.ProgramSetting import org.hisp.dhis.android.core.settings.ProgramSettings +import org.hisp.dhis.android.core.settings.SynchronizationSettings import org.hisp.dhis.android.core.sms.domain.interactor.ConfigCase import org.junit.Before import org.junit.Test @@ -42,6 +44,11 @@ class SettingsRepositoryTest { Mockito.mock(UserManager::class.java, Mockito.RETURNS_DEEP_STUBS) private val preferencesProvider: PreferenceProvider = mock() private val featureConfigRepository: FeatureConfigRepository = mock() + private val syncBackgroundJobAction: SyncBackgroundJobAction = mock { + on { getNextSettingsSync() } doReturn null + on { getNextMetadataSync() } doReturn null + on { getNextDataSync() } doReturn null + } private val smsConfig: ConfigCase.SmsConfig = mock { on { isModuleEnabled } doReturn true @@ -62,6 +69,7 @@ class SettingsRepositoryTest { d2, preferencesProvider, featureConfigRepository, + syncBackgroundJobAction, ) configurePreferences() configureDataCount() @@ -70,6 +78,7 @@ class SettingsRepositoryTest { @Test fun `Should return metadata period from general settings if exist`() { + configureSyncSettings(true) configureGeneralSettings(true) val testObserver = settingsRepository.metaSync().test() testObserver @@ -92,6 +101,7 @@ class SettingsRepositoryTest { @Test fun `Should return data period from general settings if exist`() { + configureSyncSettings(true) configureGeneralSettings(true) configureDataErrors() val testObserver = settingsRepository.dataSync().test() @@ -116,8 +126,9 @@ class SettingsRepositoryTest { @Test fun `Should return parameters from general settings if exist`() { + configureSyncSettings(true) configureGeneralSettings(true) - configureProgramSettings(true) + val testObserver = settingsRepository.syncParameters().test() testObserver .assertNoErrors() @@ -130,8 +141,9 @@ class SettingsRepositoryTest { @Test fun `Should return parameters from preferences if general settings does not exist`() { + configureSyncSettings(false) configureGeneralSettings(false) - configureProgramSettings(false) + val testObserver = settingsRepository.syncParameters().test() testObserver .assertNoErrors() @@ -146,6 +158,7 @@ class SettingsRepositoryTest { fun `Should return reserved values from settings if exist`() { configureGeneralSettings(true) val testObserver = settingsRepository.reservedValues().test() + testObserver .assertNoErrors() .assertValue { @@ -157,6 +170,7 @@ class SettingsRepositoryTest { fun `Should return reserved values from preferences if settings does not exist`() { configureGeneralSettings(false) val testObserver = settingsRepository.reservedValues().test() + testObserver .assertNoErrors() .assertValue { @@ -168,6 +182,7 @@ class SettingsRepositoryTest { fun `Should return editable sms configuration if settings does not exist`() { configureGeneralSettings(false) val testObserver = settingsRepository.sms().test() + testObserver .assertNoErrors() .assertValue { @@ -175,6 +190,17 @@ class SettingsRepositoryTest { } } + private fun configureSyncSettings(hasSyncSettings: Boolean) { + whenever( + d2.settingModule().synchronizationSettings().blockingExists(), + ) doReturn hasSyncSettings + if (hasSyncSettings) { + whenever( + d2.settingModule().synchronizationSettings().blockingGet(), + ) doReturn mockedSyncSettings() + } + } + private fun configureGeneralSettings(hasGeneralSettings: Boolean) { whenever(d2.settingModule().generalSetting().blockingExists()) doReturn hasGeneralSettings @@ -185,15 +211,6 @@ class SettingsRepositoryTest { } } - private fun configureProgramSettings(hasProgramSettings: Boolean) { - whenever(d2.settingModule().programSetting().blockingExists()) doReturn - hasProgramSettings - if (hasProgramSettings) { - whenever(d2.settingModule().programSetting().blockingGet()) doReturn - mockedProgramSettings() - } - } - private fun configurePreferences() { whenever( preferencesProvider.getString(Constants.LAST_META_SYNC, "-"), @@ -469,11 +486,17 @@ class SettingsRepositoryTest { whenever(d2.smsModule().configCase()) doReturn configCase } - private fun mockedGeneralSettings(): GeneralSettings = - GeneralSettings + private fun mockedSyncSettings() = + SynchronizationSettings .builder() .dataSync(SETTINGS_DATA_PERIOD) .metadataSync(SETTINGS_METADATA_PERIOD) + .programSettings(mockedProgramSettings()) + .build() + + private fun mockedGeneralSettings(): GeneralSettings = + GeneralSettings + .builder() .encryptDB(SETTINGS_ENCRYPT) .reservedValues(SETTINGS_RV) .build() diff --git a/app/src/test/java/org/dhis2/usescases/settings/SyncManagerPresenterTest.kt b/app/src/test/java/org/dhis2/usescases/settings/SyncManagerPresenterTest.kt index 4d70a7d708a..7fe223dfad8 100644 --- a/app/src/test/java/org/dhis2/usescases/settings/SyncManagerPresenterTest.kt +++ b/app/src/test/java/org/dhis2/usescases/settings/SyncManagerPresenterTest.kt @@ -39,7 +39,6 @@ import org.junit.Before import org.junit.Rule import org.junit.Test import org.mockito.kotlin.any -import org.mockito.kotlin.anyOrNull import org.mockito.kotlin.doReturn import org.mockito.kotlin.doReturnConsecutively import org.mockito.kotlin.mock @@ -130,6 +129,8 @@ class SyncManagerPresenterTest { MetadataSettingsViewModel( metadataSyncPeriod = 100, lastMetadataSync = "test", + nextMetadataSync = null, + nextSettingsSync = null, hasErrors = false, canEdit = false, syncInProgress = false, @@ -139,6 +140,7 @@ class SyncManagerPresenterTest { DataSettingsViewModel( dataSyncPeriod = 100, lastDataSync = "test", + nextDataSync = null, syncHasErrors = false, dataHasErrors = false, dataHasWarnings = false, @@ -223,21 +225,20 @@ class SyncManagerPresenterTest { val timeoutTest = 1 whenever( getSettingsState.invoke( - anyOrNull(), - any(), - any(), any(), ), ) doReturnConsecutively listOf( - mockedSettingState(), - mockedSettingState().copy( - smsSettingsViewModel = - mockedSMSViewModel().copy( - isEnabled = true, - gatewayNumber = gatewayNumberTest, - responseTimeout = timeoutTest, - ), + Result.success(mockedSettingState()), + Result.success( + mockedSettingState().copy( + smsSettingsViewModel = + mockedSMSViewModel().copy( + isEnabled = true, + gatewayNumber = gatewayNumberTest, + responseTimeout = timeoutTest, + ), + ), ), ) @@ -254,7 +255,7 @@ class SyncManagerPresenterTest { awaitItem() presenter.enableSmsModule(true, gatewayNumberTest, timeoutTest) awaitItem() - verify(getSettingsState, times(2)).invoke(anyOrNull(), any(), any(), any()) + verify(getSettingsState, times(2)).invoke(any()) } } @@ -262,13 +263,8 @@ class SyncManagerPresenterTest { fun `Should not save gateway if validation fails`() = runTest { whenever( - getSettingsState.invoke( - anyOrNull(), - any(), - any(), - any(), - ), - ) doReturn mockedSettingState() + getSettingsState.invoke(any()), + ) doReturn Result.success(mockedSettingState()) val gatewayNumberTest = "+111" whenever( @@ -310,13 +306,8 @@ class SyncManagerPresenterTest { runTest { val smsResultSender = "test" whenever( - getSettingsState.invoke( - anyOrNull(), - any(), - any(), - any(), - ), - ) doReturn mockedSettingState() + getSettingsState.invoke(any()), + ) doReturn Result.success(mockedSettingState()) whenever(updateSmsResponse(any())) doReturn UpdateSmsResponse.UpdateSmsResponseResult.ValidationError( GatewayValidator.GatewayValidationResult.Invalid, @@ -364,17 +355,14 @@ class SyncManagerPresenterTest { fun `Should load data when setting manual trigger`() = runTest { whenever( - getSettingsState.invoke( - anyOrNull(), - any(), - any(), - any(), - ), + getSettingsState.invoke(any()), ) doReturnConsecutively listOf( - mockedSettingState(), - mockedSettingState().copy( - dataSettingsViewModel = mockedDataViewModel().copy(dataSyncPeriod = 0), + Result.success(mockedSettingState()), + Result.success( + mockedSettingState().copy( + dataSettingsViewModel = mockedDataViewModel().copy(dataSyncPeriod = 0), + ), ), ) @@ -402,13 +390,8 @@ class SyncManagerPresenterTest { fun `Should open clicked item`() = runTest { whenever( - getSettingsState.invoke( - anyOrNull(), - any(), - any(), - any(), - ), - ) doReturn mockedSettingState() + getSettingsState.invoke(any()), + ) doReturn Result.success(mockedSettingState()) presenter.settingsState.test { awaitItem() @@ -483,13 +466,8 @@ class SyncManagerPresenterTest { fun shouldUpdateSyncStatus() = runTest { whenever( - getSettingsState.invoke( - anyOrNull(), - any(), - any(), - any(), - ), - ) doReturn mockedSettingState() + getSettingsState.invoke(any()), + ) doReturn Result.success(mockedSettingState()) val dataSyncStartedProgress = LaunchSync.SyncStatusProgress( diff --git a/app/src/test/java/org/dhis2/usescases/settings/domain/LaunchSyncTest.kt b/app/src/test/java/org/dhis2/usescases/settings/domain/LaunchSyncTest.kt index 750be9ec62f..36d38577a53 100644 --- a/app/src/test/java/org/dhis2/usescases/settings/domain/LaunchSyncTest.kt +++ b/app/src/test/java/org/dhis2/usescases/settings/domain/LaunchSyncTest.kt @@ -1,22 +1,19 @@ package org.dhis2.usescases.settings.domain import androidx.arch.core.executor.testing.InstantTaskExecutorRule -import androidx.lifecycle.MutableLiveData -import androidx.work.ExistingPeriodicWorkPolicy -import androidx.work.ExistingWorkPolicy -import androidx.work.WorkInfo import app.cash.turbine.test import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.test.UnconfinedTestDispatcher import kotlinx.coroutines.test.resetMain import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.setMain import org.dhis2.commons.Constants import org.dhis2.commons.prefs.PreferenceProvider -import org.dhis2.data.service.workManager.WorkManagerController -import org.dhis2.data.service.workManager.WorkerItem -import org.dhis2.data.service.workManager.WorkerType +import org.dhis2.mobile.sync.data.SyncBackgroundJobAction +import org.dhis2.mobile.sync.model.SyncJobStatus +import org.dhis2.mobile.sync.model.SyncStatus import org.dhis2.utils.analytics.AnalyticsHelper import org.junit.After import org.junit.Assert.assertEquals @@ -39,21 +36,21 @@ class LaunchSyncTest { private val testingDispatcher = UnconfinedTestDispatcher() private lateinit var launchSync: LaunchSync - private val workManagerController: WorkManagerController = mock() private val preferenceProvider: PreferenceProvider = mock() private val analyticsHelper: AnalyticsHelper = mock() - private val mockedMetadataWorkInfo = MutableLiveData>() - private val mockedDataWorkInfo = MutableLiveData>() + private val mockedMetadataWorkInfo = MutableStateFlow>(emptyList()) + private val mockedDataWorkInfo = MutableStateFlow>(emptyList()) + private val syncBackgroundJobAction: SyncBackgroundJobAction = mock() @Before fun setUp() { Dispatchers.setMain(testingDispatcher) - whenever(workManagerController.getWorkInfosByTagLiveData(Constants.META_NOW)) doReturn mockedMetadataWorkInfo - whenever(workManagerController.getWorkInfosByTagLiveData(Constants.DATA_NOW)) doReturn mockedDataWorkInfo + whenever(syncBackgroundJobAction.observeMetadataJob()) doReturn mockedMetadataWorkInfo + whenever(syncBackgroundJobAction.observeDataJob()) doReturn mockedDataWorkInfo launchSync = LaunchSync( - workManagerController = workManagerController, + syncBackgroundJobAction = syncBackgroundJobAction, preferenceProvider = preferenceProvider, analyticsHelper = analyticsHelper, ) @@ -67,71 +64,33 @@ class LaunchSyncTest { @Test fun shouldStartSyncMetadata() = runTest { - val expectedWorkerItem = - WorkerItem( - Constants.META_NOW, - WorkerType.METADATA, - null, - null, - ExistingWorkPolicy.KEEP, - null, - ) launchSync(LaunchSync.SyncAction.SyncMetadata) - verify(workManagerController, times(1)).syncDataForWorker(expectedWorkerItem) + verify(syncBackgroundJobAction, times(1)).launchMetadataSync(0) } @Test fun shouldStartSyncData() = runTest { - val expectedWorkerItem = - WorkerItem( - Constants.DATA_NOW, - WorkerType.DATA, - null, - null, - ExistingWorkPolicy.KEEP, - null, - ) launchSync(LaunchSync.SyncAction.SyncData) - verify(workManagerController, times(1)).syncDataForWorker(expectedWorkerItem) + verify(syncBackgroundJobAction, times(1)).launchDataSync(0) } @Test fun shouldUpdateSyncDataPeriod() = runTest { val newPeriod = 13 - val expectedWorkerItem = - WorkerItem( - Constants.DATA, - WorkerType.DATA, - newPeriod.toLong(), - null, - null, - ExistingPeriodicWorkPolicy.REPLACE, - ) launchSync(LaunchSync.SyncAction.UpdateSyncDataPeriod(newPeriod)) verify(preferenceProvider, times(1)).setValue(Constants.TIME_DATA, newPeriod) - verify(workManagerController, times(1)).cancelUniqueWork(Constants.DATA) - verify(workManagerController, times(1)).enqueuePeriodicWork(expectedWorkerItem) + verify(syncBackgroundJobAction, times(1)).launchDataSync(newPeriod.toLong()) } @Test fun shouldUpdateSyncMetadataPeriod() = runTest { val newPeriod = 13 - val expectedWorkerItem = - WorkerItem( - Constants.META, - WorkerType.METADATA, - newPeriod.toLong(), - null, - null, - ExistingPeriodicWorkPolicy.REPLACE, - ) launchSync(LaunchSync.SyncAction.UpdateSyncMetadataPeriod(newPeriod)) verify(preferenceProvider, times(1)).setValue(Constants.TIME_META, newPeriod) - verify(workManagerController, times(1)).cancelUniqueWork(Constants.META) - verify(workManagerController, times(1)).enqueuePeriodicWork(expectedWorkerItem) + verify(syncBackgroundJobAction, times(1)).launchMetadataSync(newPeriod.toLong()) } @Test @@ -139,7 +98,7 @@ class LaunchSyncTest { runTest { launchSync(LaunchSync.SyncAction.UpdateSyncDataPeriod(0)) verify(preferenceProvider, times(1)).setValue(Constants.TIME_DATA, 0) - verify(workManagerController, times(1)).cancelUniqueWork(Constants.DATA) + verify(syncBackgroundJobAction, times(1)).cancelDataSync() } @Test @@ -147,56 +106,123 @@ class LaunchSyncTest { runTest { launchSync(LaunchSync.SyncAction.UpdateSyncMetadataPeriod(0)) verify(preferenceProvider, times(1)).setValue(Constants.TIME_META, 0) - verify(workManagerController, times(1)).cancelUniqueWork(Constants.META) + verify(syncBackgroundJobAction, times(1)).cancelMetadataSync() } @Test fun shouldUpdateProgressStatus() = runTest { val startedMetadataWorkInfo = - mock { - on { state } doReturn WorkInfo.State.RUNNING + mock { + on { status } doReturn SyncStatus.Running } val startedDataWorkInfo = - mock { - on { state } doReturn WorkInfo.State.RUNNING + mock { + on { status } doReturn SyncStatus.Running } val finishedMetadataWorkInfo = - mock { - on { state } doReturn WorkInfo.State.SUCCEEDED + mock { + on { status } doReturn SyncStatus.Succeed } val finishedDataWorkInfo = - mock { - on { state } doReturn WorkInfo.State.SUCCEEDED + mock { + on { status } doReturn SyncStatus.Succeed } launchSync.syncWorkInfo.test { - mockedMetadataWorkInfo.postValue(listOf(startedMetadataWorkInfo)) - assertState(awaitItem(), LaunchSync.SyncStatus.InProgress, LaunchSync.SyncStatus.None) - mockedDataWorkInfo.postValue(listOf(startedDataWorkInfo)) + awaitItem() + awaitItem() + mockedMetadataWorkInfo.emit(listOf(startedMetadataWorkInfo)) + assertState( + awaitItem(), + LaunchSync.SyncStatus.InProgress, + LaunchSync.SyncStatus.None + ) + mockedDataWorkInfo.emit(listOf(startedDataWorkInfo)) with(awaitItem()) { assertState( this, LaunchSync.SyncStatus.InProgress, LaunchSync.SyncStatus.InProgress, ) - assertFalse(this.hasSyncFinished(metadataWasRunning = true, dataWasRunning = false)) + assertFalse( + this.hasSyncFinished( + metadataWasRunning = true, + dataWasRunning = false + ) + ) } - mockedMetadataWorkInfo.postValue(listOf(finishedMetadataWorkInfo)) + mockedMetadataWorkInfo.emit(listOf(finishedMetadataWorkInfo)) with(awaitItem()) { assertState( this, LaunchSync.SyncStatus.Finished, LaunchSync.SyncStatus.InProgress, ) - assertTrue(this.hasSyncFinished(metadataWasRunning = true, dataWasRunning = true)) + assertTrue( + this.hasSyncFinished( + metadataWasRunning = true, + dataWasRunning = true + ) + ) } - mockedDataWorkInfo.postValue(listOf(finishedDataWorkInfo)) - assertState(awaitItem(), LaunchSync.SyncStatus.Finished, LaunchSync.SyncStatus.Finished) + mockedDataWorkInfo.emit(listOf(finishedDataWorkInfo)) + assertState( + awaitItem(), + LaunchSync.SyncStatus.Finished, + LaunchSync.SyncStatus.Finished + ) cancelAndIgnoreRemainingEvents() } } + @Test + fun shouldCombineMetadataProgress() = + runTest { + launchSync.syncWorkInfo.test { + awaitItem() + awaitItem() + syncStatuses.forEach { metadataSyncStatus -> + syncStatuses.forEach { metadataNowSyncStatus -> + mockedMetadataWorkInfo.emit( + listOf( + metadataSyncStatus, + metadataNowSyncStatus + ) + ) + val expectedValue = when { + (metadataSyncStatus.status is SyncStatus.Running) or (metadataNowSyncStatus.status is SyncStatus.Running) -> LaunchSync.SyncStatus.InProgress + (metadataSyncStatus.status is SyncStatus.Blocked) or (metadataNowSyncStatus.status is SyncStatus.Blocked) -> LaunchSync.SyncStatus.InProgress + (metadataSyncStatus.status is SyncStatus.Enqueue) and (metadataNowSyncStatus.status is SyncStatus.Enqueue) -> LaunchSync.SyncStatus.None + (metadataSyncStatus.status is SyncStatus.Cancelled) and (metadataNowSyncStatus.status is SyncStatus.Cancelled) -> LaunchSync.SyncStatus.Cancelled + else -> LaunchSync.SyncStatus.Finished + + + } + + assertState( + awaitItem(), + expectedValue, + LaunchSync.SyncStatus.None + ) + } + } + } + } + + private val syncStatuses = listOf( + mockedMetadataSyncJobStatus(SyncStatus.Enqueue), + mockedMetadataSyncJobStatus(SyncStatus.Running), + mockedMetadataSyncJobStatus(SyncStatus.Succeed), + mockedMetadataSyncJobStatus(SyncStatus.Failed), + mockedMetadataSyncJobStatus(SyncStatus.Blocked), + mockedMetadataSyncJobStatus(SyncStatus.Cancelled), + ) + + private fun mockedMetadataSyncJobStatus(mockedStatus: SyncStatus) = mock { + on { status } doReturn mockedStatus + } + private fun assertState( syncStatusProgress: LaunchSync.SyncStatusProgress, metadataSyncProgress: LaunchSync.SyncStatus, diff --git a/app/src/test/java/org/dhis2/usescases/sync/SyncPresenterTest.kt b/app/src/test/java/org/dhis2/usescases/sync/SyncPresenterTest.kt index 07b6c765d3b..c54d0634dd2 100644 --- a/app/src/test/java/org/dhis2/usescases/sync/SyncPresenterTest.kt +++ b/app/src/test/java/org/dhis2/usescases/sync/SyncPresenterTest.kt @@ -1,17 +1,15 @@ package org.dhis2.usescases.sync -import androidx.work.Data -import androidx.work.WorkInfo import io.reactivex.Completable import io.reactivex.Single -import org.dhis2.commons.Constants import org.dhis2.commons.prefs.Preference import org.dhis2.commons.prefs.PreferenceProvider import org.dhis2.commons.schedulers.SchedulerProvider import org.dhis2.data.schedulers.TrampolineSchedulerProvider import org.dhis2.data.server.UserManager -import org.dhis2.data.service.METADATA_MESSAGE -import org.dhis2.data.service.workManager.WorkManagerController +import org.dhis2.mobile.sync.data.SyncBackgroundJobAction +import org.dhis2.mobile.sync.model.SyncJobStatus +import org.dhis2.mobile.sync.model.SyncStatus import org.junit.Before import org.junit.Test import org.mockito.Mockito @@ -20,7 +18,6 @@ import org.mockito.kotlin.mock import org.mockito.kotlin.times import org.mockito.kotlin.verify import org.mockito.kotlin.whenever -import java.util.UUID class SyncPresenterTest { lateinit var presenter: SyncPresenter @@ -28,9 +25,10 @@ class SyncPresenterTest { private val userManager: UserManager = Mockito.mock(UserManager::class.java, Mockito.RETURNS_DEEP_STUBS) private val schedulerProvider: SchedulerProvider = TrampolineSchedulerProvider() - private val workManagerController: WorkManagerController = mock() private val preferences: PreferenceProvider = mock() + private val backgroundJobAction: SyncBackgroundJobAction = mock() + @Before fun setUp() { presenter = @@ -38,7 +36,7 @@ class SyncPresenterTest { view, userManager, schedulerProvider, - workManagerController, + backgroundJobAction, preferences, ) } @@ -46,35 +44,31 @@ class SyncPresenterTest { @Test fun `Should start initial sync`() { presenter.sync() - verify(workManagerController, times(1)).syncMetaDataForWorker( - Constants.META_NOW, - Constants.INITIAL_SYNC, - ) + verify(backgroundJobAction, times(1)).launchMetadataSync(0) } @Test fun `Should return work info live data`() { presenter.observeSyncProcess() - verify(workManagerController, times(1)) - .getWorkInfosForUniqueWorkLiveData(Constants.INITIAL_SYNC) + verify(backgroundJobAction, times(1)).observeMetadataJob() } @Test fun `Should set metadata sync started`() { - presenter.handleSyncInfo(arrayListOf(metaWorkInfo(WorkInfo.State.RUNNING))) + presenter.handleSyncInfo(arrayListOf(metaSyncJobStatus(SyncStatus.Running))) verify(view, times(1)).setMetadataSyncStarted() } @Test fun `Should set metadata sync succeeded`() { - presenter.handleSyncInfo(arrayListOf(metaWorkInfo(WorkInfo.State.SUCCEEDED))) + presenter.handleSyncInfo(arrayListOf(metaSyncJobStatus(SyncStatus.Succeed))) verify(view, times(1)).setMetadataSyncSucceed() } @Test fun `Should show metadata sync error message`() { val message = "Error message" - presenter.handleSyncInfo(arrayListOf(metaWorkInfo(WorkInfo.State.FAILED, message))) + presenter.handleSyncInfo(arrayListOf(metaSyncJobStatus(SyncStatus.Failed, message))) verify(view, times(1)).showMetadataFailedMessage(message) } @@ -97,17 +91,12 @@ class SyncPresenterTest { verify(view, times(1)).goToLogin() } - private fun metaWorkInfo( - state: WorkInfo.State, + private fun metaSyncJobStatus( + state: SyncStatus, message: String? = null, - ): WorkInfo = - WorkInfo( - id = UUID.randomUUID(), - state = state, - outputData = Data.Builder().apply { putString(METADATA_MESSAGE, message) }.build(), - tags = setOf(Constants.META_NOW), - progress = Data.EMPTY, - runAttemptCount = 0, - generation = 0, - ) + ) = SyncJobStatus( + status = state, + message = message, + tags = listOf("METADATA_SYNC_NOW"), + ) } diff --git a/app/src/test/java/org/dhis2/usescases/teiDashboard/DashboardRepositoryImplTest.kt b/app/src/test/java/org/dhis2/usescases/teiDashboard/DashboardRepositoryImplTest.kt index 8e81092c0b1..3fe08ab4fac 100644 --- a/app/src/test/java/org/dhis2/usescases/teiDashboard/DashboardRepositoryImplTest.kt +++ b/app/src/test/java/org/dhis2/usescases/teiDashboard/DashboardRepositoryImplTest.kt @@ -102,6 +102,7 @@ class DashboardRepositoryImplTest { .uid("enrollment_1") .deleted(true) .trackedEntityInstance(teiUid) + .attributeOptionCombo("attributeOptionComboUid") .build() val enrollment2 = getMockingEnrollment() @@ -588,6 +589,7 @@ class DashboardRepositoryImplTest { Enrollment .builder() .uid("enrollmentUid") + .attributeOptionCombo("attributeOptionComboUid") .build() private fun getMockSingleEvent(): Event = diff --git a/app/src/test/java/org/dhis2/usescases/teiDashboard/TeiAttributesProviderTest.kt b/app/src/test/java/org/dhis2/usescases/teiDashboard/TeiAttributesProviderTest.kt index 68e2e2c7c8a..be022f1f663 100644 --- a/app/src/test/java/org/dhis2/usescases/teiDashboard/TeiAttributesProviderTest.kt +++ b/app/src/test/java/org/dhis2/usescases/teiDashboard/TeiAttributesProviderTest.kt @@ -267,7 +267,13 @@ class TeiAttributesProviderTest { .byTrackedEntityTypeUid() .eq(teType) .blockingGet()[0], - ) doReturn Program.builder().uid(program).build() + ) doReturn + Program + .builder() + .uid(program) + .categoryCombo(ObjectWithUid.create("categoryComboUid")) + .enrollmentCategoryCombo(ObjectWithUid.create("categoryComboUid")) + .build() whenever(d2.programModule().programTrackedEntityAttributes()) doReturn mock() whenever(d2.programModule().programTrackedEntityAttributes().byProgram()) doReturn mock() diff --git a/app/src/test/java/org/dhis2/usescases/teiDashboard/TeiDashboardPresenterTest.kt b/app/src/test/java/org/dhis2/usescases/teiDashboard/TeiDashboardPresenterTest.kt index f727dbf0576..ed5c643f437 100644 --- a/app/src/test/java/org/dhis2/usescases/teiDashboard/TeiDashboardPresenterTest.kt +++ b/app/src/test/java/org/dhis2/usescases/teiDashboard/TeiDashboardPresenterTest.kt @@ -12,6 +12,7 @@ import org.dhis2.mobile.commons.model.MetadataIconData import org.dhis2.utils.analytics.AnalyticsHelper import org.dhis2.utils.analytics.CLICK import org.dhis2.utils.analytics.DELETE_TEI +import org.hisp.dhis.android.core.common.ObjectWithUid import org.hisp.dhis.android.core.enrollment.Enrollment import org.hisp.dhis.android.core.enrollment.EnrollmentStatus import org.hisp.dhis.android.core.organisationunit.OrganisationUnit @@ -65,9 +66,20 @@ class TeiDashboardPresenterTest { @Test fun `Should set program and restore adapter`() { val programUid = "programUid" - val program = Program.builder().uid(programUid).build() + val program = + Program + .builder() + .uid(programUid) + .categoryCombo(ObjectWithUid.create("categoryComboUid")) + .enrollmentCategoryCombo(ObjectWithUid.create("categoryComboUid")) + .build() val trackedEntityInstance = TrackedEntityInstance.builder().uid(teiUid).build() - val enrollment = Enrollment.builder().uid("enrollmentUid").build() + val enrollment = + Enrollment + .builder() + .uid("enrollmentUid") + .attributeOptionCombo("attributeOptionComboUid") + .build() val programStages = listOf(ProgramStage.builder().uid("programStageUid").build()) val trackedEntityAttributes = listOf( @@ -81,7 +93,12 @@ class TeiDashboardPresenterTest { val programs = listOf( Pair( - Program.builder().uid(programUid).build(), + Program + .builder() + .uid(programUid) + .categoryCombo(ObjectWithUid.create("categoryComboUid")) + .enrollmentCategoryCombo(ObjectWithUid.create("categoryComboUid")) + .build(), MetadataIconData.defaultIcon(), ), ) diff --git a/app/src/test/java/org/dhis2/usescases/teiDashboard/dashboardfragments/indicators/BaseIndicatorRepositoryTest.kt b/app/src/test/java/org/dhis2/usescases/teiDashboard/dashboardfragments/indicators/BaseIndicatorRepositoryTest.kt new file mode 100644 index 00000000000..4fef338aebb --- /dev/null +++ b/app/src/test/java/org/dhis2/usescases/teiDashboard/dashboardfragments/indicators/BaseIndicatorRepositoryTest.kt @@ -0,0 +1,19 @@ +package org.dhis2.usescases.teiDashboard.dashboardfragments.indicators + +import org.dhis2.usescases.teiDashboard.dashboardfragments.indicators.BaseIndicatorRepository.Companion.getNumberValue +import org.junit.Test + +class BaseIndicatorRepositoryTest { + + @Test + fun `Should parse number value`() { + assert(getNumberValue("54") == 54.0) + assert(getNumberValue("54.3") == 54.3) + assert(getNumberValue("-12.2") == -12.2) + assert(getNumberValue("23456.6") == 23456.6) + assert(getNumberValue("23,456.6") == 23456.6) + assert(getNumberValue("54.1%") == 54.1) + assert(getNumberValue("54.1 %") == 54.1) + assert(getNumberValue("<14322>") == 14322.0) + } +} diff --git a/app/src/test/java/org/dhis2/usescases/teiDashboard/dashboardfragments/indicators/EventIndicatorRepositoryTest.kt b/app/src/test/java/org/dhis2/usescases/teiDashboard/dashboardfragments/indicators/EventIndicatorRepositoryTest.kt index 6ef547a9758..c3623f11e80 100644 --- a/app/src/test/java/org/dhis2/usescases/teiDashboard/dashboardfragments/indicators/EventIndicatorRepositoryTest.kt +++ b/app/src/test/java/org/dhis2/usescases/teiDashboard/dashboardfragments/indicators/EventIndicatorRepositoryTest.kt @@ -70,7 +70,12 @@ class EventIndicatorRepositoryTest { .eq("programUid") .one() .blockingGet(), - ) doReturn Enrollment.builder().uid("enrollmentUid").build() + ) doReturn + Enrollment + .builder() + .uid("enrollmentUid") + .attributeOptionCombo("attributeOptionComboUid") + .build() whenever( resourceManager.sectionIndicators(), ) doReturn "Indicators" diff --git a/app/src/test/java/org/dhis2/usescases/teiDashboard/dashboardfragments/indicators/TrackerAnalyticsRepositoryTest.kt b/app/src/test/java/org/dhis2/usescases/teiDashboard/dashboardfragments/indicators/TrackerAnalyticsRepositoryTest.kt index a9ae3d0ccb1..818520bae8e 100644 --- a/app/src/test/java/org/dhis2/usescases/teiDashboard/dashboardfragments/indicators/TrackerAnalyticsRepositoryTest.kt +++ b/app/src/test/java/org/dhis2/usescases/teiDashboard/dashboardfragments/indicators/TrackerAnalyticsRepositoryTest.kt @@ -76,7 +76,12 @@ class TrackerAnalyticsRepositoryTest { .eq("programUid") .one() .blockingGet(), - ) doReturn Enrollment.builder().uid("enrollmentUid").build() + ) doReturn + Enrollment + .builder() + .uid("enrollmentUid") + .attributeOptionCombo("attributeOptionComboUid") + .build() whenever( resourceManager.sectionCharts(), ) doReturn "Charts" diff --git a/app/src/test/java/org/dhis2/usescases/teiDashboard/dashboardfragments/relationships/RelationshipPresenterTest.kt b/app/src/test/java/org/dhis2/usescases/teiDashboard/dashboardfragments/relationships/RelationshipPresenterTest.kt index bbc4d713712..36fb4159bd1 100644 --- a/app/src/test/java/org/dhis2/usescases/teiDashboard/dashboardfragments/relationships/RelationshipPresenterTest.kt +++ b/app/src/test/java/org/dhis2/usescases/teiDashboard/dashboardfragments/relationships/RelationshipPresenterTest.kt @@ -245,6 +245,7 @@ class RelationshipPresenterTest { Enrollment .builder() .uid("enrollmentUid") + .attributeOptionCombo("attributeOptionComboUid") .build(), ) diff --git a/app/src/test/java/org/dhis2/usescases/teiDashboard/dialogs/scheduling/SchedulingViewModelTest.kt b/app/src/test/java/org/dhis2/usescases/teiDashboard/dialogs/scheduling/SchedulingViewModelTest.kt index 7ad7accf8d9..14400aa54d3 100644 --- a/app/src/test/java/org/dhis2/usescases/teiDashboard/dialogs/scheduling/SchedulingViewModelTest.kt +++ b/app/src/test/java/org/dhis2/usescases/teiDashboard/dialogs/scheduling/SchedulingViewModelTest.kt @@ -33,7 +33,12 @@ class SchedulingViewModelTest { private val testingDispatcher = UnconfinedTestDispatcher() - private val enrollment = Enrollment.builder().uid(ENROLLMENT_UID).build() + private val enrollment = + Enrollment + .builder() + .uid(ENROLLMENT_UID) + .attributeOptionCombo("attributeOptionComboUid") + .build() private val programStage = ProgramStage.builder().uid(STAGE).build() private val enrollmentObjectRepository: EnrollmentObjectRepository = diff --git a/app/src/test/java/org/dhis2/usescases/teiDashboard/teiProgramList/TeiProgramListRepositoryImplTest.kt b/app/src/test/java/org/dhis2/usescases/teiDashboard/teiProgramList/TeiProgramListRepositoryImplTest.kt index c10529a1bd4..ec2de96bd51 100644 --- a/app/src/test/java/org/dhis2/usescases/teiDashboard/teiProgramList/TeiProgramListRepositoryImplTest.kt +++ b/app/src/test/java/org/dhis2/usescases/teiDashboard/teiProgramList/TeiProgramListRepositoryImplTest.kt @@ -5,6 +5,7 @@ import org.dhis2.commons.date.DateUtils import org.dhis2.commons.resources.MetadataIconProvider import org.dhis2.usescases.main.program.ProgramViewModelMapper import org.hisp.dhis.android.core.D2 +import org.hisp.dhis.android.core.common.ObjectWithUid import org.hisp.dhis.android.core.enrollment.Enrollment import org.hisp.dhis.android.core.enrollment.EnrollmentCreateProjection import org.hisp.dhis.android.core.program.Program @@ -61,6 +62,8 @@ class TeiProgramListRepositoryImplTest { .builder() .uid("programUid") .displayIncidentDate(true) + .categoryCombo(ObjectWithUid.create("categoryComboUid")) + .enrollmentCategoryCombo(ObjectWithUid.create("categoryComboUid")) .build() whenever( @@ -73,6 +76,7 @@ class TeiProgramListRepositoryImplTest { Enrollment .builder() .uid("enrollmentUid") + .attributeOptionCombo("attributeOptionComboUid") .build() val testObservable = @@ -125,6 +129,8 @@ class TeiProgramListRepositoryImplTest { .builder() .uid("programUid") .displayIncidentDate(false) + .categoryCombo(ObjectWithUid.create("categoryComboUid")) + .enrollmentCategoryCombo(ObjectWithUid.create("categoryComboUid")) .build() whenever( @@ -137,6 +143,7 @@ class TeiProgramListRepositoryImplTest { Enrollment .builder() .uid("enrollmentUid") + .attributeOptionCombo("attributeOptionComboUid") .build() val testObservable = diff --git a/app/src/test/java/org/dhis2/usescases/teiDashboard/ui/mapper/InfoBarMapperTest.kt b/app/src/test/java/org/dhis2/usescases/teiDashboard/ui/mapper/InfoBarMapperTest.kt index b9224df9966..b52bee833c6 100644 --- a/app/src/test/java/org/dhis2/usescases/teiDashboard/ui/mapper/InfoBarMapperTest.kt +++ b/app/src/test/java/org/dhis2/usescases/teiDashboard/ui/mapper/InfoBarMapperTest.kt @@ -5,6 +5,7 @@ import org.dhis2.commons.resources.ResourceManager import org.dhis2.mobile.commons.model.MetadataIconData import org.dhis2.usescases.teiDashboard.DashboardEnrollmentModel import org.dhis2.usescases.teiDashboard.ui.model.InfoBarType +import org.hisp.dhis.android.core.common.ObjectWithUid import org.hisp.dhis.android.core.common.State import org.hisp.dhis.android.core.enrollment.Enrollment import org.hisp.dhis.android.core.enrollment.EnrollmentStatus @@ -164,6 +165,7 @@ class InfoBarMapperTest { .status(status) .followUp(followup) .program("Program1Uid") + .attributeOptionCombo("attributeOptionComboUid") .build() private fun setPrograms() = @@ -173,6 +175,8 @@ class InfoBarMapperTest { .builder() .uid("Program1Uid") .displayName("Program 1") + .categoryCombo(ObjectWithUid.create("categoryComboUid")) + .enrollmentCategoryCombo(ObjectWithUid.create("categoryComboUid")) .build(), MetadataIconData.defaultIcon(), ), @@ -181,6 +185,8 @@ class InfoBarMapperTest { .builder() .uid("Program2Uid") .displayName("Program 2") + .categoryCombo(ObjectWithUid.create("categoryComboUid")) + .enrollmentCategoryCombo(ObjectWithUid.create("categoryComboUid")) .build(), MetadataIconData.defaultIcon(), ), diff --git a/app/src/test/java/org/dhis2/usescases/teiDashboard/ui/mapper/QuickActionsMapperTest.kt b/app/src/test/java/org/dhis2/usescases/teiDashboard/ui/mapper/QuickActionsMapperTest.kt index af590ae0550..2343906cd72 100644 --- a/app/src/test/java/org/dhis2/usescases/teiDashboard/ui/mapper/QuickActionsMapperTest.kt +++ b/app/src/test/java/org/dhis2/usescases/teiDashboard/ui/mapper/QuickActionsMapperTest.kt @@ -117,6 +117,7 @@ class QuickActionsMapperTest { .status(status) .followUp(followup) .program("Program1Uid") + .attributeOptionCombo("attributeOptionComboUid") .build() val tei = TrackedEntityInstance diff --git a/app/src/test/java/org/dhis2/usescases/teiDashboard/ui/mapper/TEIDetailMapperTest.kt b/app/src/test/java/org/dhis2/usescases/teiDashboard/ui/mapper/TEIDetailMapperTest.kt index 93de79a22de..f8c2e9e03d4 100644 --- a/app/src/test/java/org/dhis2/usescases/teiDashboard/ui/mapper/TEIDetailMapperTest.kt +++ b/app/src/test/java/org/dhis2/usescases/teiDashboard/ui/mapper/TEIDetailMapperTest.kt @@ -4,6 +4,7 @@ import org.dhis2.R import org.dhis2.commons.resources.ResourceManager import org.dhis2.mobile.commons.model.MetadataIconData import org.dhis2.usescases.teiDashboard.DashboardEnrollmentModel +import org.hisp.dhis.android.core.common.ObjectWithUid import org.hisp.dhis.android.core.common.State import org.hisp.dhis.android.core.enrollment.Enrollment import org.hisp.dhis.android.core.enrollment.EnrollmentStatus @@ -123,6 +124,7 @@ class TEIDetailMapperTest { .status(EnrollmentStatus.COMPLETED) .program("Program1Uid") .organisationUnit("orgUnitUid") + .attributeOptionCombo("attributeOptionComboUid") .build() private fun setPrograms() = @@ -132,6 +134,8 @@ class TEIDetailMapperTest { .builder() .uid("Program1Uid") .displayName("Program 1") + .categoryCombo(ObjectWithUid.create("categoryComboUid")) + .enrollmentCategoryCombo(ObjectWithUid.create("categoryComboUid")) .build(), MetadataIconData.defaultIcon(), ), @@ -140,6 +144,8 @@ class TEIDetailMapperTest { .builder() .uid("Program2Uid") .displayName("Program 2") + .categoryCombo(ObjectWithUid.create("categoryComboUid")) + .enrollmentCategoryCombo(ObjectWithUid.create("categoryComboUid")) .build(), MetadataIconData.defaultIcon(), ), diff --git a/app/src/test/java/org/dhis2/usescases/tracker/TrackedEntityInstanceInfoProviderTests.kt b/app/src/test/java/org/dhis2/usescases/tracker/TrackedEntityInstanceInfoProviderTests.kt index 20b0453b82e..5827f790ab7 100644 --- a/app/src/test/java/org/dhis2/usescases/tracker/TrackedEntityInstanceInfoProviderTests.kt +++ b/app/src/test/java/org/dhis2/usescases/tracker/TrackedEntityInstanceInfoProviderTests.kt @@ -8,6 +8,7 @@ import org.dhis2.mobile.commons.model.MetadataIconData import org.dhis2.tracker.data.ProfilePictureProvider import org.hisp.dhis.android.core.D2 import org.hisp.dhis.android.core.common.ObjectStyle +import org.hisp.dhis.android.core.common.ObjectWithUid import org.hisp.dhis.android.core.program.Program import org.hisp.dhis.android.core.trackedentity.TrackedEntityInstance import org.hisp.dhis.mobile.ui.designsystem.component.AdditionalInfoItem @@ -216,7 +217,12 @@ class TrackedEntityInstanceInfoProviderTests { .build() private fun mockProgram(hasStyle: Boolean = false): Program { - val program = Program.builder().uid("programUid") + val program = + Program + .builder() + .uid("programUid") + .categoryCombo(ObjectWithUid.create("categoryComboUid")) + .enrollmentCategoryCombo(ObjectWithUid.create("categoryComboUid")) if (hasStyle) { program.style( diff --git a/app/src/test/java/org/dhis2/utils/granularsync/GranularSyncPresenterTest.kt b/app/src/test/java/org/dhis2/utils/granularsync/GranularSyncPresenterTest.kt index e1fd0ebf854..ae77334adfb 100644 --- a/app/src/test/java/org/dhis2/utils/granularsync/GranularSyncPresenterTest.kt +++ b/app/src/test/java/org/dhis2/utils/granularsync/GranularSyncPresenterTest.kt @@ -14,7 +14,6 @@ import kotlinx.coroutines.runBlocking import kotlinx.coroutines.test.UnconfinedTestDispatcher import kotlinx.coroutines.test.resetMain import kotlinx.coroutines.test.setMain -import org.dhis2.commons.Constants import org.dhis2.commons.sync.ConflictType import org.dhis2.commons.sync.SyncContext import org.dhis2.commons.viewmodel.DispatcherProvider @@ -512,7 +511,6 @@ class GranularSyncPresenterTest { val resultLiveData = presenter.initGranularSync() resultLiveData.observeForever(workInfoObserver) - verify(workManager).syncDataForWorker(Constants.DATA_NOW, Constants.INITIAL_SYNC) verify(workInfoObserver).onChanged(anyList()) } diff --git a/app/src/test/java/org/dhis2/utils/session/PinPresenterTest.kt b/app/src/test/java/org/dhis2/utils/session/PinPresenterTest.kt deleted file mode 100644 index 09e73782e96..00000000000 --- a/app/src/test/java/org/dhis2/utils/session/PinPresenterTest.kt +++ /dev/null @@ -1,165 +0,0 @@ -package org.dhis2.utils.session - -import org.dhis2.commons.prefs.Preference -import org.dhis2.commons.prefs.PreferenceProvider -import org.hisp.dhis.android.core.D2 -import org.junit.Before -import org.junit.Test -import org.mockito.Mockito -import org.mockito.kotlin.doReturn -import org.mockito.kotlin.mock -import org.mockito.kotlin.times -import org.mockito.kotlin.verify -import org.mockito.kotlin.whenever - -class PinPresenterTest { - lateinit var presenter: PinPresenter - private var pinView: PinView = mock() - private var preferenceProvider: PreferenceProvider = mock() - private val d2: D2 = Mockito.mock(D2::class.java, Mockito.RETURNS_DEEP_STUBS) - - private val onPinCorrect: () -> Unit = mock() - private val onError: () -> Unit = mock() - private val onTwoManyAttempts: () -> Unit = mock() - - @Before - fun setUp() { - presenter = PinPresenter(pinView, preferenceProvider, d2) - } - - @Test - fun `Should return true if pin is correct`() { - val testPin = "testPin" - - whenever(d2.dataStoreModule()) doReturn mock() - whenever(d2.dataStoreModule().localDataStore()) doReturn mock() - whenever( - d2.dataStoreModule().localDataStore().value(Preference.PIN), - ) doReturn mock() - whenever( - d2 - .dataStoreModule() - .localDataStore() - .value(Preference.PIN) - .blockingGet(), - ) doReturn mock() - whenever( - d2 - .dataStoreModule() - .localDataStore() - .value(Preference.PIN) - .blockingGet() - ?.value(), - ) doReturn testPin - - presenter.unlockSession( - testPin, - attempts = 0, - onError = onError, - onPinCorrect = onPinCorrect, - onTwoManyAttempts = onTwoManyAttempts, - ) - - // SESSION_LOCKED stays true (PIN is still configured), no state change - verify(onPinCorrect).invoke() - } - - @Test - fun `Should return false if pin is wrong`() { - val testPin = "testPin" - val wrongPin = "wrongPin" - - whenever(d2.dataStoreModule()) doReturn mock() - whenever(d2.dataStoreModule().localDataStore()) doReturn mock() - whenever( - d2.dataStoreModule().localDataStore().value(Preference.PIN), - ) doReturn mock() - whenever( - d2 - .dataStoreModule() - .localDataStore() - .value(Preference.PIN) - .blockingGet(), - ) doReturn mock() - whenever( - d2 - .dataStoreModule() - .localDataStore() - .value(Preference.PIN) - .blockingGet() - ?.value(), - ) doReturn testPin - - presenter.unlockSession( - wrongPin, - attempts = 0, - onError = onError, - onPinCorrect = onPinCorrect, - onTwoManyAttempts = onTwoManyAttempts, - ) - - verify(onError).invoke() - } - - @Test - fun `Should call onTwoManyAttempts when try 3 times`() { - val testPin = "testPin" - val wrongPin = "wrongPin" - - whenever(d2.dataStoreModule()) doReturn mock() - whenever(d2.dataStoreModule().localDataStore()) doReturn mock() - whenever( - d2.dataStoreModule().localDataStore().value(Preference.PIN), - ) doReturn mock() - whenever( - d2 - .dataStoreModule() - .localDataStore() - .value(Preference.PIN) - .blockingGet(), - ) doReturn mock() - whenever( - d2 - .dataStoreModule() - .localDataStore() - .value(Preference.PIN) - .blockingGet() - ?.value(), - ) doReturn testPin - - presenter.unlockSession( - wrongPin, - attempts = 3, - onError = onError, - onPinCorrect = onPinCorrect, - onTwoManyAttempts = onTwoManyAttempts, - ) - - verify(onTwoManyAttempts).invoke() - } - - @Test - fun `Should save pin and block session`() { - val testPin = "testPin" - - whenever(d2.dataStoreModule()) doReturn mock() - whenever(d2.dataStoreModule().localDataStore()) doReturn mock() - whenever(d2.dataStoreModule().localDataStore().value(Preference.PIN)) doReturn mock() - - presenter.savePin(testPin) - verify(d2.dataStoreModule().localDataStore().value(Preference.PIN)).blockingSet(testPin) - verify(preferenceProvider, times(1)).setValue(Preference.SESSION_LOCKED, true) - } - - @Test - fun `Should clear pin and block session when logout`() { - whenever(d2.dataStoreModule()) doReturn mock() - whenever(d2.dataStoreModule().localDataStore()) doReturn mock() - whenever(d2.dataStoreModule().localDataStore().value(Preference.PIN)) doReturn mock() - - presenter.logOut() - - verify(d2.dataStoreModule().localDataStore().value(Preference.PIN)).blockingDelete() - verify(preferenceProvider, times(1)).setValue(Preference.SESSION_LOCKED, false) - } -} diff --git a/build.gradle.kts b/build.gradle.kts index 7b62571c3c3..a923bc4363b 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -18,6 +18,8 @@ plugins { alias(libs.plugins.kotlin.compose.compiler) apply false alias(libs.plugins.ksp) apply false alias(libs.plugins.cyclonedx) + alias(libs.plugins.kotlin.multiplatform) apply false + alias(libs.plugins.android.kotlin.multiplatform.library) apply false } @@ -92,6 +94,8 @@ allprojects { } tasks.withType { + // ensures test results are not cached between test runs + outputs.upToDateWhen { false } afterSuite( KotlinClosure2({ desc: TestDescriptor, result: TestResult -> if (result.resultType == TestResult.ResultType.FAILURE) { diff --git a/commons/build.gradle.kts b/commons/build.gradle.kts index da9af0110d0..3d207288041 100644 --- a/commons/build.gradle.kts +++ b/commons/build.gradle.kts @@ -2,8 +2,7 @@ import org.jetbrains.kotlin.gradle.dsl.JvmTarget plugins { id("com.android.library") - kotlin("android") - kotlin("kapt") + alias(libs.plugins.legacy.kapt) id("com.google.devtools.ksp") id("kotlin-parcelize") alias(libs.plugins.kotlin.compose.compiler) @@ -61,9 +60,12 @@ kotlin { } } +kapt { + correctErrorTypes = true +} + dependencies { implementation(fileTree(mapOf("dir" to "libs", "include" to listOf("*.jar")))) - api(project(":ui-components")) implementation(project(":commonskmm")) api(libs.dhis2.android.sdk) { @@ -81,6 +83,7 @@ dependencies { api(libs.androidx.viewModelKtx) api(libs.androidx.recyclerView) debugApi(libs.androidx.compose.uitooling) + api(libs.androidx.compose.preview) api(libs.androidx.compose.ui) api(libs.androidx.compose.livedata) api(libs.androidx.compose.paging) @@ -103,16 +106,16 @@ dependencies { api(libs.barcodeScanner.zxing.android) { exclude("com.google.zxing", "core") } - testApi(libs.test.junit) - testApi(libs.test.mockitoCore) - testApi(libs.test.mockitoKotlin) - testApi(libs.test.mockitoInline) - androidTestApi(libs.test.mockitoCore) - androidTestApi(libs.test.mockitoKotlin) - androidTestApi(libs.test.dexmaker.mockitoInline) - androidTestApi(libs.test.junit.ext) - androidTestApi(libs.test.espresso) - androidTestApi(libs.test.espresso.idlingresource) + testImplementation(libs.test.junit) + testImplementation(libs.test.mockitoCore) + testImplementation(libs.test.mockitoKotlin) + testImplementation(libs.test.mockitoInline) + androidTestImplementation(libs.test.mockitoCore) + androidTestImplementation(libs.test.mockitoKotlin) + androidTestImplementation(libs.test.dexmaker.mockitoInline) + androidTestImplementation(libs.test.junit.ext) + androidTestImplementation(libs.test.espresso) + androidTestImplementation(libs.test.espresso.idlingresource) api(libs.test.espresso.idlingresource) api(libs.test.espresso.idlingconcurrent) api(libs.analytics.sentry) diff --git a/commons/src/main/java/org/dhis2/commons/bindings/CommonExtensions.kt b/commons/src/main/java/org/dhis2/commons/bindings/CommonExtensions.kt index a5bbdcd6ae5..3bf11e76cb1 100644 --- a/commons/src/main/java/org/dhis2/commons/bindings/CommonExtensions.kt +++ b/commons/src/main/java/org/dhis2/commons/bindings/CommonExtensions.kt @@ -5,7 +5,6 @@ import android.graphics.Color import android.graphics.Outline import android.graphics.PorterDuff import android.graphics.Typeface -import android.os.Build import android.text.method.ScrollingMovementMethod import android.util.TypedValue import android.view.View @@ -116,45 +115,41 @@ val Int.px: Int get() = (this / Resources.getSystem().displayMetrics.density).toInt() fun View.clipWithRoundedCorners(curvedRadio: Int = 16.dp) { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { - outlineProvider = - object : ViewOutlineProvider() { - override fun getOutline( - view: View, - outline: Outline, - ) { - outline.setRoundRect( - 0, - 0, - view.width, - view.height + curvedRadio, - curvedRadio.toFloat(), - ) - } + outlineProvider = + object : ViewOutlineProvider() { + override fun getOutline( + view: View, + outline: Outline, + ) { + outline.setRoundRect( + 0, + 0, + view.width, + view.height + curvedRadio, + curvedRadio.toFloat(), + ) } - clipToOutline = true - } + } + clipToOutline = true } fun View.clipWithAllRoundedCorners(curvedRadio: Int = 16.dp) { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { - outlineProvider = - object : ViewOutlineProvider() { - override fun getOutline( - view: View, - outline: Outline, - ) { - outline.setRoundRect( - 0, - 0, - view.width, - view.height, - curvedRadio.toFloat(), - ) - } + outlineProvider = + object : ViewOutlineProvider() { + override fun getOutline( + view: View, + outline: Outline, + ) { + outline.setRoundRect( + 0, + 0, + view.width, + view.height, + curvedRadio.toFloat(), + ) } - clipToOutline = true - } + } + clipToOutline = true } fun HorizontalScrollView.scrollToPosition(viewTag: String) { diff --git a/commons/src/main/java/org/dhis2/commons/bindings/TEICardExtensions.kt b/commons/src/main/java/org/dhis2/commons/bindings/TEICardExtensions.kt index 1e8c74bf814..a3974ce22b0 100644 --- a/commons/src/main/java/org/dhis2/commons/bindings/TEICardExtensions.kt +++ b/commons/src/main/java/org/dhis2/commons/bindings/TEICardExtensions.kt @@ -146,7 +146,7 @@ private fun SquareWithNumber(number: Int) { text = "+$number", color = "#6f6f6f".toColor(), textAlign = TextAlign.Center, - fontFamily = FontFamily(Font(org.dhis2.ui.R.font.rubik_regular)), + fontFamily = FontFamily(Font(R.font.rubik_regular)), ) } } diff --git a/commons/src/main/java/org/dhis2/commons/date/DateExtensions.kt b/commons/src/main/java/org/dhis2/commons/date/DateExtensions.kt index 478779db2ab..938b3241953 100644 --- a/commons/src/main/java/org/dhis2/commons/date/DateExtensions.kt +++ b/commons/src/main/java/org/dhis2/commons/date/DateExtensions.kt @@ -186,10 +186,11 @@ fun Date?.toUi(): String? = this?.let { DateUtils.uiDateFormat().format(this) } fun Dhis2PeriodType.toUiStringResource() = when (this) { Dhis2PeriodType.Weekly, + Dhis2PeriodType.WeeklyWednesday, + Dhis2PeriodType.WeeklyThursday, + Dhis2PeriodType.WeeklyFriday, Dhis2PeriodType.WeeklySaturday, Dhis2PeriodType.WeeklySunday, - Dhis2PeriodType.WeeklyThursday, - Dhis2PeriodType.WeeklyWednesday, -> R.string.period_weekly_title Dhis2PeriodType.BiWeekly -> R.string.period_biweekly_title @@ -205,8 +206,11 @@ fun Dhis2PeriodType.toUiStringResource() = -> R.string.period_six_monthly_title Dhis2PeriodType.Yearly -> R.string.period_yearly_title + Dhis2PeriodType.FinancialFeb, Dhis2PeriodType.FinancialApril, Dhis2PeriodType.FinancialJuly, + Dhis2PeriodType.FinancialAug, + Dhis2PeriodType.FinancialSep, Dhis2PeriodType.FinancialOct, Dhis2PeriodType.FinancialNov, -> R.string.period_financial_year_title diff --git a/commons/src/main/java/org/dhis2/commons/di/FilterModule.kt b/commons/src/main/java/org/dhis2/commons/di/FilterModule.kt new file mode 100644 index 00000000000..acf03d69a6c --- /dev/null +++ b/commons/src/main/java/org/dhis2/commons/di/FilterModule.kt @@ -0,0 +1,82 @@ +package org.dhis2.commons.di + +import org.dhis2.commons.filters.FilterManager +import org.dhis2.commons.filters.FilterResources +import org.dhis2.commons.filters.data.FilterPresenter +import org.dhis2.commons.filters.data.FilterRepository +import org.dhis2.commons.filters.data.GetFiltersApplyingWebAppConfig +import org.dhis2.commons.filters.workingLists.EventFilterToWorkingListItemMapper +import org.dhis2.commons.filters.workingLists.ProgramStageToWorkingListItemMapper +import org.dhis2.commons.filters.workingLists.TeiFilterToWorkingListItemMapper +import org.dhis2.commons.resources.EventResourcesProvider +import org.dhis2.commons.resources.ResourceManager +import org.koin.core.module.dsl.factoryOf +import org.koin.dsl.module + +val mainFilterModule = + module { + // Singleton for GetFiltersApplyingWebAppConfig + single { + GetFiltersApplyingWebAppConfig() + } + + // FilterManager - singleton per server + single { + FilterManager.initWith(get()) + } + + // FilterResources + factory { + FilterResources( + resourceManager = get(), + eventResourcesProvider = get(), + ) + } + + // EventResourcesProvider + factory { + EventResourcesProvider( + d2 = get(), + resourceManager = get(), + ) + } + + // Working list mappers + factory { + EventFilterToWorkingListItemMapper( + defaultWorkingListLabel = get().defaultWorkingListLabel(), + ) + } + + factory { + TeiFilterToWorkingListItemMapper( + defaultWorkingListLabel = get().defaultWorkingListLabel(), + ) + } + + factory { + ProgramStageToWorkingListItemMapper( + defaultWorkingListLabel = get().defaultWorkingListLabel(), + ) + } + + // FilterRepository + factory { + FilterRepository( + d2 = get(), + resources = get(), + getFiltersApplyingWebAppConfig = get(), + eventFilterToWorkingListItemMapper = get(), + teiFilterToWorkingListItemMapper = get(), + programStageToWorkingListItemMapper = get(), + ) + } + + // FilterPresenter + factoryOf(::FilterPresenter) + } + +val filterModule = + module { + includes(mainFilterModule, resourceManagerModule) + } diff --git a/commons/src/main/java/org/dhis2/commons/di/ResourceManagerModule.kt b/commons/src/main/java/org/dhis2/commons/di/ResourceManagerModule.kt index e67a160e02a..8f98dff4375 100644 --- a/commons/src/main/java/org/dhis2/commons/di/ResourceManagerModule.kt +++ b/commons/src/main/java/org/dhis2/commons/di/ResourceManagerModule.kt @@ -3,6 +3,7 @@ package org.dhis2.commons.di import org.dhis2.commons.periods.data.PeriodLabelProvider import org.dhis2.commons.resources.ColorUtils import org.dhis2.commons.resources.ResourceManager +import org.koin.android.ext.koin.androidContext import org.koin.dsl.module val resourceManagerModule = @@ -10,8 +11,9 @@ val resourceManagerModule = single { ColorUtils() } - factory { params -> - ResourceManager(params.get(), get()) + + single { + ResourceManager(androidContext(), get()) } factory { diff --git a/commons/src/main/java/org/dhis2/commons/dialogs/bottomsheet/BottomSheetDialog.kt b/commons/src/main/java/org/dhis2/commons/dialogs/bottomsheet/BottomSheetDialog.kt index 411f9948d65..fa052662cd2 100644 --- a/commons/src/main/java/org/dhis2/commons/dialogs/bottomsheet/BottomSheetDialog.kt +++ b/commons/src/main/java/org/dhis2/commons/dialogs/bottomsheet/BottomSheetDialog.kt @@ -28,7 +28,7 @@ import androidx.compose.ui.text.style.TextDecoration import com.google.android.material.bottomsheet.BottomSheetBehavior import com.google.android.material.bottomsheet.BottomSheetDialog import com.google.android.material.bottomsheet.BottomSheetDialogFragment -import org.dhis2.ui.R +import org.dhis2.commons.R import org.hisp.dhis.mobile.ui.designsystem.component.BottomSheetShell import org.hisp.dhis.mobile.ui.designsystem.component.Button import org.hisp.dhis.mobile.ui.designsystem.component.ButtonBlock diff --git a/commons/src/main/java/org/dhis2/commons/dialogs/bottomsheet/BottomSheetDialogContent.kt b/commons/src/main/java/org/dhis2/commons/dialogs/bottomsheet/BottomSheetDialogContent.kt index 9f18de0dcb7..f1ab514d53a 100644 --- a/commons/src/main/java/org/dhis2/commons/dialogs/bottomsheet/BottomSheetDialogContent.kt +++ b/commons/src/main/java/org/dhis2/commons/dialogs/bottomsheet/BottomSheetDialogContent.kt @@ -39,7 +39,7 @@ import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import org.dhis2.ui.R +import org.dhis2.commons.R import org.hisp.dhis.mobile.ui.designsystem.component.Button import org.hisp.dhis.mobile.ui.designsystem.component.ButtonStyle import org.hisp.dhis.mobile.ui.designsystem.theme.TextColor diff --git a/commons/src/main/java/org/dhis2/commons/dialogs/bottomsheet/DeleteBottomSheetDialog.kt b/commons/src/main/java/org/dhis2/commons/dialogs/bottomsheet/DeleteBottomSheetDialog.kt index fd31a01b0d0..112d4979088 100644 --- a/commons/src/main/java/org/dhis2/commons/dialogs/bottomsheet/DeleteBottomSheetDialog.kt +++ b/commons/src/main/java/org/dhis2/commons/dialogs/bottomsheet/DeleteBottomSheetDialog.kt @@ -15,7 +15,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.platform.ComposeView import androidx.compose.ui.platform.ViewCompositionStrategy import com.google.android.material.bottomsheet.BottomSheetDialogFragment -import org.dhis2.ui.R +import org.dhis2.commons.R import org.hisp.dhis.mobile.ui.designsystem.component.BottomSheetShell import org.hisp.dhis.mobile.ui.designsystem.component.Button import org.hisp.dhis.mobile.ui.designsystem.component.ButtonBlock diff --git a/commons/src/main/java/org/dhis2/commons/dialogs/bottomsheet/DialogButtonStyle.kt b/commons/src/main/java/org/dhis2/commons/dialogs/bottomsheet/DialogButtonStyle.kt index 6261f09a93d..d372c0e526d 100644 --- a/commons/src/main/java/org/dhis2/commons/dialogs/bottomsheet/DialogButtonStyle.kt +++ b/commons/src/main/java/org/dhis2/commons/dialogs/bottomsheet/DialogButtonStyle.kt @@ -1,7 +1,7 @@ package org.dhis2.commons.dialogs.bottomsheet import androidx.compose.ui.graphics.Color -import org.dhis2.ui.R +import org.dhis2.commons.R import org.hisp.dhis.mobile.ui.designsystem.component.ButtonStyle import org.hisp.dhis.mobile.ui.designsystem.theme.SurfaceColor diff --git a/commons/src/main/java/org/dhis2/commons/dialogs/calendarpicker/CalendarPicker.kt b/commons/src/main/java/org/dhis2/commons/dialogs/calendarpicker/CalendarPicker.kt index 97b8897df33..67433ea0706 100644 --- a/commons/src/main/java/org/dhis2/commons/dialogs/calendarpicker/CalendarPicker.kt +++ b/commons/src/main/java/org/dhis2/commons/dialogs/calendarpicker/CalendarPicker.kt @@ -3,12 +3,9 @@ package org.dhis2.commons.dialogs.calendarpicker import android.app.AlertDialog import android.app.Dialog import android.content.Context -import android.os.Build import android.view.LayoutInflater import android.view.View import android.widget.DatePicker -import android.widget.LinearLayout -import androidx.databinding.BindingAdapter import org.dhis2.commons.R import org.dhis2.commons.databinding.CalendarPickerViewBinding import org.dhis2.commons.dialogs.calendarpicker.di.CalendarPickerComponentProvider @@ -174,13 +171,3 @@ class CalendarPicker( } } } - -@BindingAdapter("versionCustomVisibility") -fun setCustomVisibility( - linearLayout: LinearLayout, - check: Boolean, -) { - if (check && Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) { - linearLayout.visibility = View.GONE - } -} diff --git a/commons/src/main/java/org/dhis2/commons/filters/data/FilterPresenter.kt b/commons/src/main/java/org/dhis2/commons/filters/data/FilterPresenter.kt index b9e2fd347e5..a857bc0013f 100644 --- a/commons/src/main/java/org/dhis2/commons/filters/data/FilterPresenter.kt +++ b/commons/src/main/java/org/dhis2/commons/filters/data/FilterPresenter.kt @@ -34,15 +34,15 @@ class FilterPresenter fun filteredEventProgram(program: Program): EventQueryCollectionRepository = eventProgramFilterSearchHelper.getFilteredEventRepository(program) - fun filteredTrackerProgram(program: Program): TrackedEntitySearchCollectionRepository = - trackerFilterSearchHelper.getFilteredProgramRepository(program.uid()) + fun filteredTrackerProgram(program: String): TrackedEntitySearchCollectionRepository = + trackerFilterSearchHelper.getFilteredProgramRepository(program) fun filteredTrackedEntityTypes(trackedEntityTypeUid: String): TrackedEntitySearchCollectionRepository = trackerFilterSearchHelper .getFilteredTrackedEntityTypeRepository(trackedEntityTypeUid) fun filteredTrackedEntityInstances( - program: Program?, + program: String?, trackedEntityTypeUid: String, ): TrackedEntitySearchCollectionRepository = program?.let { filteredTrackerProgram(program) } diff --git a/commons/src/main/java/org/dhis2/commons/filters/periods/data/FilterPeriodsRepository.kt b/commons/src/main/java/org/dhis2/commons/filters/periods/data/FilterPeriodsRepository.kt index 3470bfd0eea..7aa86a2c200 100644 --- a/commons/src/main/java/org/dhis2/commons/filters/periods/data/FilterPeriodsRepository.kt +++ b/commons/src/main/java/org/dhis2/commons/filters/periods/data/FilterPeriodsRepository.kt @@ -25,6 +25,7 @@ class FilterPeriodsRepository( FilterPeriodType.WEEKLY, FilterPeriodType.WEEKLY_WEDNESDAY, FilterPeriodType.WEEKLY_THURSDAY, + FilterPeriodType.WEEKLY_FRIDAY, FilterPeriodType.WEEKLY_SATURDAY, FilterPeriodType.WEEKLY_SUNDAY, FilterPeriodType.BI_WEEKLY, @@ -36,8 +37,11 @@ class FilterPeriodsRepository( FilterPeriodType.SIX_MONTHLY_APRIL, FilterPeriodType.SIX_MONTHLY_NOV, FilterPeriodType.YEARLY, + FilterPeriodType.FINANCIAL_FEB, FilterPeriodType.FINANCIAL_APRIL, FilterPeriodType.FINANCIAL_JULY, + FilterPeriodType.FINANCIAL_AUG, + FilterPeriodType.FINANCIAL_SEP, FilterPeriodType.FINANCIAL_OCT, FilterPeriodType.FINANCIAL_NOV, ) @@ -48,6 +52,7 @@ class FilterPeriodsRepository( FilterPeriodType.WEEKLY -> PeriodType.Weekly FilterPeriodType.WEEKLY_WEDNESDAY -> PeriodType.WeeklyWednesday FilterPeriodType.WEEKLY_THURSDAY -> PeriodType.WeeklyThursday + FilterPeriodType.WEEKLY_FRIDAY -> PeriodType.WeeklyFriday FilterPeriodType.WEEKLY_SATURDAY -> PeriodType.WeeklySaturday FilterPeriodType.WEEKLY_SUNDAY -> PeriodType.WeeklySunday FilterPeriodType.BI_WEEKLY -> PeriodType.BiWeekly @@ -59,8 +64,11 @@ class FilterPeriodsRepository( FilterPeriodType.SIX_MONTHLY_APRIL -> PeriodType.SixMonthlyApril FilterPeriodType.SIX_MONTHLY_NOV -> PeriodType.SixMonthlyNov FilterPeriodType.YEARLY -> PeriodType.Yearly + FilterPeriodType.FINANCIAL_FEB -> PeriodType.FinancialFeb FilterPeriodType.FINANCIAL_APRIL -> PeriodType.FinancialApril FilterPeriodType.FINANCIAL_JULY -> PeriodType.FinancialJuly + FilterPeriodType.FINANCIAL_AUG -> PeriodType.FinancialAug + FilterPeriodType.FINANCIAL_SEP -> PeriodType.FinancialSep FilterPeriodType.FINANCIAL_OCT -> PeriodType.FinancialOct FilterPeriodType.FINANCIAL_NOV -> PeriodType.FinancialNov FilterPeriodType.NONE -> PeriodType.Daily diff --git a/commons/src/main/java/org/dhis2/commons/filters/periods/data/PeriodTypeLabelProvider.kt b/commons/src/main/java/org/dhis2/commons/filters/periods/data/PeriodTypeLabelProvider.kt index 6f8730da45a..677f4750651 100644 --- a/commons/src/main/java/org/dhis2/commons/filters/periods/data/PeriodTypeLabelProvider.kt +++ b/commons/src/main/java/org/dhis2/commons/filters/periods/data/PeriodTypeLabelProvider.kt @@ -11,6 +11,7 @@ class PeriodTypeLabelProvider { FilterPeriodType.WEEKLY -> R.string.weekly FilterPeriodType.WEEKLY_WEDNESDAY -> R.string.weekly_start_wednesday FilterPeriodType.WEEKLY_THURSDAY -> R.string.weekly_start_thursday + FilterPeriodType.WEEKLY_FRIDAY -> R.string.weekly_start_friday FilterPeriodType.WEEKLY_SATURDAY -> R.string.weekly_start_saturday FilterPeriodType.WEEKLY_SUNDAY -> R.string.weekly_start_sunday FilterPeriodType.BI_WEEKLY -> R.string.bi_weekly @@ -22,8 +23,11 @@ class PeriodTypeLabelProvider { FilterPeriodType.SIX_MONTHLY_APRIL -> R.string.six_monthly_april FilterPeriodType.SIX_MONTHLY_NOV -> R.string.six_monthly_nov FilterPeriodType.YEARLY -> R.string.YEARLY + FilterPeriodType.FINANCIAL_FEB -> R.string.financial_year_february FilterPeriodType.FINANCIAL_APRIL -> R.string.financial_year_april FilterPeriodType.FINANCIAL_JULY -> R.string.financial_year_july + FilterPeriodType.FINANCIAL_AUG -> R.string.financial_year_august + FilterPeriodType.FINANCIAL_SEP -> R.string.financial_year_september FilterPeriodType.FINANCIAL_OCT -> R.string.financial_year_october FilterPeriodType.FINANCIAL_NOV -> R.string.financial_year_november } diff --git a/commons/src/main/java/org/dhis2/commons/filters/periods/di/FilterPeriodsModule.kt b/commons/src/main/java/org/dhis2/commons/filters/periods/di/FilterPeriodsModule.kt index 15bb0d6d421..402e5f224c5 100644 --- a/commons/src/main/java/org/dhis2/commons/filters/periods/di/FilterPeriodsModule.kt +++ b/commons/src/main/java/org/dhis2/commons/filters/periods/di/FilterPeriodsModule.kt @@ -7,7 +7,6 @@ import org.dhis2.commons.filters.periods.domain.GetFilterPeriods import org.dhis2.commons.filters.periods.ui.viewmodel.FilterPeriodsDialogViewmodel import org.koin.core.module.dsl.singleOf import org.koin.core.module.dsl.viewModel -import org.koin.core.parameter.parametersOf import org.koin.dsl.module val filterPeriodsModule = @@ -34,7 +33,7 @@ val filterPeriodsModule = FilterPeriodsDialogViewmodel( getFilterPeriods = get(), getFilterPeriodTypes = get(), - resourceManager = get { parametersOf(params.get()) }, + resourceManager = get(), periodTypeLabelProvider = get(), launchMode = params.get(), ) diff --git a/commons/src/main/java/org/dhis2/commons/filters/periods/model/FilterPeriodType.kt b/commons/src/main/java/org/dhis2/commons/filters/periods/model/FilterPeriodType.kt index e7af37e9a96..4d0fea03ad1 100644 --- a/commons/src/main/java/org/dhis2/commons/filters/periods/model/FilterPeriodType.kt +++ b/commons/src/main/java/org/dhis2/commons/filters/periods/model/FilterPeriodType.kt @@ -6,6 +6,7 @@ enum class FilterPeriodType { WEEKLY, WEEKLY_WEDNESDAY, WEEKLY_THURSDAY, + WEEKLY_FRIDAY, WEEKLY_SATURDAY, WEEKLY_SUNDAY, BI_WEEKLY, @@ -17,8 +18,11 @@ enum class FilterPeriodType { SIX_MONTHLY_APRIL, SIX_MONTHLY_NOV, YEARLY, + FINANCIAL_FEB, FINANCIAL_APRIL, FINANCIAL_JULY, + FINANCIAL_AUG, + FINANCIAL_SEP, FINANCIAL_OCT, FINANCIAL_NOV, } diff --git a/commons/src/main/java/org/dhis2/commons/locationprovider/LocationProviderImpl.kt b/commons/src/main/java/org/dhis2/commons/locationprovider/LocationProviderImpl.kt index 2959bc49df4..b924db56dde 100644 --- a/commons/src/main/java/org/dhis2/commons/locationprovider/LocationProviderImpl.kt +++ b/commons/src/main/java/org/dhis2/commons/locationprovider/LocationProviderImpl.kt @@ -10,7 +10,6 @@ import android.location.LocationListener import android.location.LocationManager import androidx.core.app.ActivityCompat import androidx.core.location.LocationListenerCompat -import okhttp3.internal.toImmutableList private const val FUSED_LOCATION_PROVIDER = "fused" @@ -69,7 +68,7 @@ open class LocationProviderImpl( onLocationProviderChanged() } } - val deviceProviders = locationManager.allProviders.toImmutableList() + val deviceProviders = locationManager.allProviders.toList() if (deviceProviders.contains(LocationManager.NETWORK_PROVIDER)) { locationManager.requestLocationUpdates( LocationManager.NETWORK_PROVIDER, diff --git a/commons/src/main/java/org/dhis2/commons/periods/data/PeriodLabelProvider.kt b/commons/src/main/java/org/dhis2/commons/periods/data/PeriodLabelProvider.kt index cecd1dde871..a3b6aa7db02 100644 --- a/commons/src/main/java/org/dhis2/commons/periods/data/PeriodLabelProvider.kt +++ b/commons/src/main/java/org/dhis2/commons/periods/data/PeriodLabelProvider.kt @@ -57,6 +57,7 @@ class PeriodLabelProvider( PeriodType.Weekly, PeriodType.WeeklyWednesday, PeriodType.WeeklyThursday, + PeriodType.WeeklyFriday, PeriodType.WeeklySaturday, PeriodType.WeeklySunday, PeriodType.BiWeekly, @@ -80,9 +81,13 @@ class PeriodLabelProvider( PeriodType.BiMonthly, PeriodType.SixMonthly, PeriodType.SixMonthlyApril, PeriodType.Quarterly, PeriodType.QuarterlyNov, + PeriodType.FinancialFeb, PeriodType.FinancialApril, PeriodType.FinancialJuly, + PeriodType.FinancialAug, + PeriodType.FinancialSep, PeriodType.FinancialOct, + PeriodType.FinancialNov, -> if (periodBetweenYears) { FROM_TO_LABEL.format( @@ -116,6 +121,7 @@ class PeriodLabelProvider( PeriodType.Weekly, PeriodType.WeeklyWednesday, PeriodType.WeeklyThursday, + PeriodType.WeeklyFriday, PeriodType.WeeklySaturday, PeriodType.WeeklySunday, -> { @@ -181,9 +187,13 @@ class PeriodLabelProvider( ) } + PeriodType.FinancialFeb, PeriodType.FinancialApril, PeriodType.FinancialJuly, + PeriodType.FinancialAug, + PeriodType.FinancialSep, PeriodType.FinancialOct, + PeriodType.FinancialNov, -> FROM_TO_LABEL.format( SimpleDateFormat(MONTH_YEAR_FULL_FORMAT, locale).format(periodStartDate), diff --git a/commons/src/main/java/org/dhis2/commons/popupmenu/AppMenuHelper.kt b/commons/src/main/java/org/dhis2/commons/popupmenu/AppMenuHelper.kt index 4cec7237932..5f6a19cf9ff 100644 --- a/commons/src/main/java/org/dhis2/commons/popupmenu/AppMenuHelper.kt +++ b/commons/src/main/java/org/dhis2/commons/popupmenu/AppMenuHelper.kt @@ -1,7 +1,6 @@ package org.dhis2.commons.popupmenu import android.content.Context -import android.os.Build import android.view.ContextThemeWrapper import android.view.Gravity import android.view.View @@ -26,11 +25,7 @@ class AppMenuHelper private constructor( fun show() { val contextWrapper = ContextThemeWrapper(context, R.style.PopupMenuMarginStyle) popupMenu = - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP_MR1) { - PopupMenu(contextWrapper, anchor, Gravity.END, 0, R.style.PopupMenuMarginStyle) - } else { - PopupMenu(contextWrapper, anchor, Gravity.END) - } + PopupMenu(contextWrapper, anchor, Gravity.END, 0, R.style.PopupMenuMarginStyle) try { val fields = popupMenu.javaClass.declaredFields for (field in fields) { diff --git a/commons/src/main/java/org/dhis2/commons/reporting/CrashReportModule.kt b/commons/src/main/java/org/dhis2/commons/reporting/CrashReportModule.kt index 31f48c6c5a1..c90bd0f6ce4 100644 --- a/commons/src/main/java/org/dhis2/commons/reporting/CrashReportModule.kt +++ b/commons/src/main/java/org/dhis2/commons/reporting/CrashReportModule.kt @@ -11,5 +11,8 @@ import javax.inject.Singleton class CrashReportModule internal constructor() { @Provides @Singleton - fun provideCrashReportController(context: Context): CrashReportController = CrashReportControllerImpl(context) + fun provideCrashReportController(context: Context): CrashReportController = CrashReportControllerImpl( + context = context, + sentryDsn = "", + ) } diff --git a/commons/src/main/java/org/dhis2/commons/resources/ColorUtils.kt b/commons/src/main/java/org/dhis2/commons/resources/ColorUtils.kt index cea91e1e319..0cf64b8f39a 100644 --- a/commons/src/main/java/org/dhis2/commons/resources/ColorUtils.kt +++ b/commons/src/main/java/org/dhis2/commons/resources/ColorUtils.kt @@ -8,8 +8,11 @@ import android.graphics.PorterDuff import android.graphics.drawable.Drawable import android.util.TypedValue import androidx.core.graphics.ColorUtils +import androidx.core.graphics.toColorInt import org.dhis2.commons.R import java.util.Objects +import kotlin.math.pow +import androidx.compose.ui.graphics.Color as ComposeColor class ColorUtils { companion object { @@ -24,16 +27,7 @@ class ColorUtils { val b = hexColor[3] newHexColor = "#$r$r$g$g$b$b" // formatted to #ffff } - return Color.parseColor(newHexColor) - } - - fun getPrimaryColorWithAlpha( - context: Context, - primaryLight: ColorType, - alpha: Float, - ): Int { - val primaryColor = getPrimaryColor(context, primaryLight) - return ColorUtils.setAlphaComponent(primaryColor, 155) + return newHexColor.toColorInt() } fun withAlpha(color: Int): Int = ColorUtils.setAlphaComponent(color, 155) @@ -67,27 +61,18 @@ class ColorUtils { return drawableToTint } - fun tintDrawableWithColor( - drawableToTint: Drawable, - tintColor: Int, - ): Drawable { - drawableToTint.setTint(tintColor) - drawableToTint.setTintMode(PorterDuff.Mode.SRC_IN) - return drawableToTint - } - fun getContrastColor(color: Int): Int = if (getContrast(color) > 0.179) { - Color.parseColor("#b3000000") + "#b3000000".toColorInt() } else { - Color.parseColor("#e6ffffff") + "#e6ffffff".toColorInt() } fun getAlphaContrastColor(color: Int): Int = if (getContrast(color) > 0.500) { - Color.parseColor("#b3000000") + "#b3000000".toColorInt() } else { - Color.parseColor("#e6ffffff") + "#e6ffffff".toColorInt() } private fun getContrast(color: Int): Double { @@ -99,7 +84,7 @@ class ColorUtils { var green: Double? = null var blue: Double? = null rgb.forEach { - if (it <= 0.03928) it / 12.92 else Math.pow((it + 0.055) / 1.055, 2.4) + if (it <= 0.03928) it / 12.92 else ((it + 0.055) / 1.055).pow(2.4) if (red == null) { red = it } else if (green == null) { @@ -111,80 +96,21 @@ class ColorUtils { return 0.2126 * red!! + 0.7152 * green!! + 0.0722 * blue!! } - fun getThemeFromColor(color: String?): Int = - when (color) { - "#ffcdd2" -> R.style.colorPrimary_Pink - "#e57373" -> R.style.colorPrimary_e57 - "#d32f2f" -> R.style.colorPrimary_d32 - "#f06292" -> R.style.colorPrimary_f06 - "#c2185b" -> R.style.colorPrimary_c21 - "#880e4f" -> R.style.colorPrimary_880 - "#f50057" -> R.style.colorPrimary_f50 - "#e1bee7" -> R.style.colorPrimary_e1b - "#ba68c8" -> R.style.colorPrimary_ba6 - "#8e24aa" -> R.style.colorPrimary_8e2 - "#aa00ff" -> R.style.colorPrimary_aa0 - "#7e57c2" -> R.style.colorPrimary_7e5 - "#4527a0" -> R.style.colorPrimary_452 - "#7c4dff" -> R.style.colorPrimary_7c4 - "#6200ea" -> R.style.colorPrimary_620 - "#c5cae9" -> R.style.colorPrimary_c5c - "#7986cb" -> R.style.colorPrimary_798 - "#3949ab" -> R.style.colorPrimary_394 - "#304ffe" -> R.style.colorPrimary_304 - "#e3f2fd" -> R.style.colorPrimary_e3f - "#64b5f6" -> R.style.colorPrimary_64b - "#1976d2" -> R.style.colorPrimary_197 - "#0288d1" -> R.style.colorPrimary_028 - "#40c4ff" -> R.style.colorPrimary_40c - "#00b0ff" -> R.style.colorPrimary_00b - "#80deea" -> R.style.colorPrimary_80d - "#00acc1" -> R.style.colorPrimary_00a - "#00838f" -> R.style.colorPrimary_008 - "#006064" -> R.style.colorPrimary_006 - "#e0f2f1" -> R.style.colorPrimary_e0f - "#80cbc4" -> R.style.colorPrimary_80c - "#00695c" -> R.style.colorPrimary_0069 - "#64ffda" -> R.style.colorPrimary_64f - "#c8e6c9" -> R.style.colorPrimary_c8e - "#66bb6a" -> R.style.colorPrimary_66b - "#2e7d32" -> R.style.colorPrimary_2e7 - "#60ad5e" -> R.style.colorPrimary_60a - "#00e676" -> R.style.colorPrimary_00e - "#aed581" -> R.style.colorPrimary_aed - "#689f38" -> R.style.colorPrimary_689 - "#33691e" -> R.style.colorPrimary_336 - "#76ff03" -> R.style.colorPrimary_76f - "#64dd17" -> R.style.colorPrimary_64d - "#cddc39" -> R.style.colorPrimary_cdd - "#9e9d24" -> R.style.colorPrimary_9e9 - "#827717" -> R.style.colorPrimary_827 - "#fff9c4" -> R.style.colorPrimary_fff - "#fbc02d" -> R.style.colorPrimary_fbc - "#f57f17" -> R.style.colorPrimary_f57 - "#ffff00" -> R.style.colorPrimary_ffff - "#ffcc80" -> R.style.colorPrimary_ffc - "#ffccbc" -> R.style.colorPrimary_ffcc - "#ffab91" -> R.style.colorPrimary_ffa - "#bcaaa4" -> R.style.colorPrimary_bca - "#8d6e63" -> R.style.colorPrimary_8d6 - "#4e342e" -> R.style.colorPrimary_4e3 - "#fafafa" -> R.style.colorPrimary_faf - "#bdbdbd" -> R.style.colorPrimary_bdb - "#757575" -> R.style.colorPrimary_757 - "#424242" -> R.style.colorPrimary_424 - "#cfd8dc" -> R.style.colorPrimary_cfd - "#b0bec5" -> R.style.colorPrimary_b0b - "#607d8b" -> R.style.colorPrimary_607 - "#37474f" -> R.style.colorPrimary_374 - else -> -1 - } + fun getThemeFromColor(color: String?): Int = paletteThemes[color] ?: -1 // fun getPrimaryColor( context: Context, colorType: ColorType, ): Int = context.getPrimaryColor(colorType) + + fun getThemePrimaryColor(context: Context): ComposeColor { + val typedValue = TypedValue() + val a = context.theme.obtainStyledAttributes(typedValue.data, intArrayOf(android.R.attr.colorPrimary)) + val color = a.getColor(0, 0) + a.recycle() + return ComposeColor(color) + } } enum class ColorType { diff --git a/commons/src/main/java/org/dhis2/commons/resources/DhisPeriodUtils.kt b/commons/src/main/java/org/dhis2/commons/resources/DhisPeriodUtils.kt index 834db2aa3f1..b8a90f020c3 100644 --- a/commons/src/main/java/org/dhis2/commons/resources/DhisPeriodUtils.kt +++ b/commons/src/main/java/org/dhis2/commons/resources/DhisPeriodUtils.kt @@ -34,6 +34,7 @@ class DhisPeriodUtils( PeriodType.Weekly, PeriodType.WeeklyWednesday, PeriodType.WeeklyThursday, + PeriodType.WeeklyFriday, PeriodType.WeeklySaturday, PeriodType.WeeklySunday, -> { @@ -73,9 +74,13 @@ class DhisPeriodUtils( PeriodType.QuarterlyNov, PeriodType.SixMonthly, PeriodType.SixMonthlyApril, + PeriodType.FinancialFeb, PeriodType.FinancialApril, PeriodType.FinancialJuly, + PeriodType.FinancialAug, + PeriodType.FinancialSep, PeriodType.FinancialOct, + PeriodType.FinancialNov, -> formattedDate = periodString.format( diff --git a/commons/src/main/java/org/dhis2/commons/resources/PaletteThemes.kt b/commons/src/main/java/org/dhis2/commons/resources/PaletteThemes.kt new file mode 100644 index 00000000000..a3a9c88ed45 --- /dev/null +++ b/commons/src/main/java/org/dhis2/commons/resources/PaletteThemes.kt @@ -0,0 +1,90 @@ +package org.dhis2.commons.resources + +import org.dhis2.commons.R + +/** + * Maps each hex color to its corresponding Android theme style resource. + */ +val paletteThemes: Map = + mapOf( + // Reds / Pinks + "#ffcdd2" to R.style.colorPrimary_Pink, + "#e57373" to R.style.colorPrimary_e57, + "#d32f2f" to R.style.colorPrimary_d32, + "#f06292" to R.style.colorPrimary_f06, + "#c2185b" to R.style.colorPrimary_c21, + "#880e4f" to R.style.colorPrimary_880, + "#f50057" to R.style.colorPrimary_f50, + // Purples + "#e1bee7" to R.style.colorPrimary_e1b, + "#ba68c8" to R.style.colorPrimary_ba6, + "#8e24aa" to R.style.colorPrimary_8e2, + "#aa00ff" to R.style.colorPrimary_aa0, + // Deep Purples / Indigos + "#7e57c2" to R.style.colorPrimary_7e5, + "#4527a0" to R.style.colorPrimary_452, + "#7c4dff" to R.style.colorPrimary_7c4, + "#6200ea" to R.style.colorPrimary_620, + "#c5cae9" to R.style.colorPrimary_c5c, + "#7986cb" to R.style.colorPrimary_798, + "#3949ab" to R.style.colorPrimary_394, + "#304ffe" to R.style.colorPrimary_304, + // Blues + "#e3f2fd" to R.style.colorPrimary_e3f, + "#64b5f6" to R.style.colorPrimary_64b, + "#1976d2" to R.style.colorPrimary_197, + "#0288d1" to R.style.colorPrimary_028, + "#40c4ff" to R.style.colorPrimary_40c, + "#00b0ff" to R.style.colorPrimary_00b, + // Cyans / Teals + "#80deea" to R.style.colorPrimary_80d, + "#00acc1" to R.style.colorPrimary_00a, + "#00838f" to R.style.colorPrimary_008, + "#006064" to R.style.colorPrimary_006, + "#e0f2f1" to R.style.colorPrimary_e0f, + "#80cbc4" to R.style.colorPrimary_80c, + "#00695c" to R.style.colorPrimary_0069, + "#64ffda" to R.style.colorPrimary_64f, + // Greens + "#c8e6c9" to R.style.colorPrimary_c8e, + "#66bb6a" to R.style.colorPrimary_66b, + "#2e7d32" to R.style.colorPrimary_2e7, + "#60ad5e" to R.style.colorPrimary_60a, + "#00e676" to R.style.colorPrimary_00e, + // Light Greens / Limes + "#aed581" to R.style.colorPrimary_aed, + "#689f38" to R.style.colorPrimary_689, + "#33691e" to R.style.colorPrimary_336, + "#76ff03" to R.style.colorPrimary_76f, + "#64dd17" to R.style.colorPrimary_64d, + "#cddc39" to R.style.colorPrimary_cdd, + "#9e9d24" to R.style.colorPrimary_9e9, + "#827717" to R.style.colorPrimary_827, + // Yellows + "#fff9c4" to R.style.colorPrimary_fff, + "#fbc02d" to R.style.colorPrimary_fbc, + "#f57f17" to R.style.colorPrimary_f57, + "#ffff00" to R.style.colorPrimary_ffff, + // Oranges / Browns + "#ffcc80" to R.style.colorPrimary_ffc, + "#ffccbc" to R.style.colorPrimary_ffcc, + "#ffab91" to R.style.colorPrimary_ffa, + "#bcaaa4" to R.style.colorPrimary_bca, + "#8d6e63" to R.style.colorPrimary_8d6, + "#4e342e" to R.style.colorPrimary_4e3, + // Greys + "#fafafa" to R.style.colorPrimary_faf, + "#bdbdbd" to R.style.colorPrimary_bdb, + "#757575" to R.style.colorPrimary_757, + "#424242" to R.style.colorPrimary_424, + // Blue Greys + "#cfd8dc" to R.style.colorPrimary_cfd, + "#b0bec5" to R.style.colorPrimary_b0b, + "#607d8b" to R.style.colorPrimary_607, + "#37474f" to R.style.colorPrimary_374, + // User settings old styles + "#ea5911" to R.style.OrangeTheme, + "#467e4a" to R.style.GreenTheme, + "#276696" to R.style.AppTheme, + "#b40303" to R.style.RedTheme, + ) diff --git a/ui-components/src/main/res/drawable/ic_delete.xml b/commons/src/main/res/drawable/ic_delete.xml similarity index 100% rename from ui-components/src/main/res/drawable/ic_delete.xml rename to commons/src/main/res/drawable/ic_delete.xml diff --git a/ui-components/src/main/res/drawable/ic_error_outline.xml b/commons/src/main/res/drawable/ic_error_outline.xml similarity index 100% rename from ui-components/src/main/res/drawable/ic_error_outline.xml rename to commons/src/main/res/drawable/ic_error_outline.xml diff --git a/ui-components/src/main/res/drawable/ic_event_status_complete.xml b/commons/src/main/res/drawable/ic_event_status_complete.xml similarity index 100% rename from ui-components/src/main/res/drawable/ic_event_status_complete.xml rename to commons/src/main/res/drawable/ic_event_status_complete.xml diff --git a/ui-components/src/main/res/drawable/ic_file.xml b/commons/src/main/res/drawable/ic_file.xml similarity index 100% rename from ui-components/src/main/res/drawable/ic_file.xml rename to commons/src/main/res/drawable/ic_file.xml diff --git a/ui-components/src/main/res/drawable/ic_file_download.xml b/commons/src/main/res/drawable/ic_file_download.xml similarity index 100% rename from ui-components/src/main/res/drawable/ic_file_download.xml rename to commons/src/main/res/drawable/ic_file_download.xml diff --git a/ui-components/src/main/res/drawable/ic_home_negative.xml b/commons/src/main/res/drawable/ic_home_negative.xml similarity index 100% rename from ui-components/src/main/res/drawable/ic_home_negative.xml rename to commons/src/main/res/drawable/ic_home_negative.xml diff --git a/ui-components/src/main/res/drawable/ic_home_outline.xml b/commons/src/main/res/drawable/ic_home_outline.xml similarity index 100% rename from ui-components/src/main/res/drawable/ic_home_outline.xml rename to commons/src/main/res/drawable/ic_home_outline.xml diff --git a/ui-components/src/main/res/drawable/ic_home_positive.xml b/commons/src/main/res/drawable/ic_home_positive.xml similarity index 100% rename from ui-components/src/main/res/drawable/ic_home_positive.xml rename to commons/src/main/res/drawable/ic_home_positive.xml diff --git a/ui-components/src/main/res/drawable/ic_input_info.xml b/commons/src/main/res/drawable/ic_input_info.xml similarity index 100% rename from ui-components/src/main/res/drawable/ic_input_info.xml rename to commons/src/main/res/drawable/ic_input_info.xml diff --git a/ui-components/src/main/res/drawable/ic_saved_check.xml b/commons/src/main/res/drawable/ic_saved_check.xml similarity index 100% rename from ui-components/src/main/res/drawable/ic_saved_check.xml rename to commons/src/main/res/drawable/ic_saved_check.xml diff --git a/ui-components/src/main/res/drawable/ic_tree_node_close.xml b/commons/src/main/res/drawable/ic_tree_node_close.xml similarity index 100% rename from ui-components/src/main/res/drawable/ic_tree_node_close.xml rename to commons/src/main/res/drawable/ic_tree_node_close.xml diff --git a/ui-components/src/main/res/drawable/ic_tree_node_default.xml b/commons/src/main/res/drawable/ic_tree_node_default.xml similarity index 100% rename from ui-components/src/main/res/drawable/ic_tree_node_default.xml rename to commons/src/main/res/drawable/ic_tree_node_default.xml diff --git a/ui-components/src/main/res/drawable/ic_tree_node_open.xml b/commons/src/main/res/drawable/ic_tree_node_open.xml similarity index 100% rename from ui-components/src/main/res/drawable/ic_tree_node_open.xml rename to commons/src/main/res/drawable/ic_tree_node_open.xml diff --git a/ui-components/src/main/res/drawable/ic_warning_alert.xml b/commons/src/main/res/drawable/ic_warning_alert.xml similarity index 100% rename from ui-components/src/main/res/drawable/ic_warning_alert.xml rename to commons/src/main/res/drawable/ic_warning_alert.xml diff --git a/ui-components/src/main/res/drawable/image_not_supported.xml b/commons/src/main/res/drawable/image_not_supported.xml similarity index 100% rename from ui-components/src/main/res/drawable/image_not_supported.xml rename to commons/src/main/res/drawable/image_not_supported.xml diff --git a/commons/src/main/res/layout/calendar_picker_view.xml b/commons/src/main/res/layout/calendar_picker_view.xml index 9e0dae8fcbf..65fdbd8f1c5 100644 --- a/commons/src/main/res/layout/calendar_picker_view.xml +++ b/commons/src/main/res/layout/calendar_picker_view.xml @@ -31,7 +31,6 @@ diff --git a/commons/src/main/res/values-ar/strings.xml b/commons/src/main/res/values-ar/strings.xml index 3259c450693..149d9166be8 100644 --- a/commons/src/main/res/values-ar/strings.xml +++ b/commons/src/main/res/values-ar/strings.xml @@ -250,4 +250,5 @@ ربع السنة ستة أشهر سنة - + تنظيف الكل + diff --git a/commons/src/main/res/values-cs/strings.xml b/commons/src/main/res/values-cs/strings.xml index 16d1303beb7..39aa4f0340a 100644 --- a/commons/src/main/res/values-cs/strings.xml +++ b/commons/src/main/res/values-cs/strings.xml @@ -328,4 +328,6 @@ Import databáze se nezdařil Neplatný soubor Není v režimu registrace TOTP 2FA + + Vymazat vše diff --git a/commons/src/main/res/values-en-rUS/strings.xml b/commons/src/main/res/values-en-rUS/strings.xml index a9cba0beec0..b72c89af550 100644 --- a/commons/src/main/res/values-en-rUS/strings.xml +++ b/commons/src/main/res/values-en-rUS/strings.xml @@ -83,4 +83,5 @@ Database import failed Invalid file Not in TOTP 2FA enrollment mode - + + diff --git a/commons/src/main/res/values-es/strings.xml b/commons/src/main/res/values-es/strings.xml index 608a9933e83..e932a2879ae 100644 --- a/commons/src/main/res/values-es/strings.xml +++ b/commons/src/main/res/values-es/strings.xml @@ -346,4 +346,5 @@ Año fiscal Importación de la base de datos fallida Archivo no válido - + Limpiar todo + diff --git a/commons/src/main/res/values-fr/strings.xml b/commons/src/main/res/values-fr/strings.xml index 0007a893d5d..72f5db28171 100644 --- a/commons/src/main/res/values-fr/strings.xml +++ b/commons/src/main/res/values-fr/strings.xml @@ -220,4 +220,5 @@ Mois Trimestre Année - + Tout effacer + diff --git a/commons/src/main/res/values-lo/strings.xml b/commons/src/main/res/values-lo/strings.xml index 1c3812c27c4..b56aa1d51e7 100644 --- a/commons/src/main/res/values-lo/strings.xml +++ b/commons/src/main/res/values-lo/strings.xml @@ -227,4 +227,5 @@ ອາທິດ ເດືອນ ປີ - + ລຶບທັງໝົດ + diff --git a/commons/src/main/res/values-nb/strings.xml b/commons/src/main/res/values-nb/strings.xml index ea9b4ffa21a..315bebd06d5 100644 --- a/commons/src/main/res/values-nb/strings.xml +++ b/commons/src/main/res/values-nb/strings.xml @@ -154,4 +154,5 @@ Måned Kvartal År - + Fjern alt + diff --git a/commons/src/main/res/values-pt-rBR/strings.xml b/commons/src/main/res/values-pt-rBR/strings.xml index 8708140ea77..4aa6983ff14 100644 --- a/commons/src/main/res/values-pt-rBR/strings.xml +++ b/commons/src/main/res/values-pt-rBR/strings.xml @@ -74,4 +74,5 @@ Semana Mês Ano - + Limpar tudo + diff --git a/commons/src/main/res/values-pt/strings.xml b/commons/src/main/res/values-pt/strings.xml index df8ac8934cb..39a2d819503 100644 --- a/commons/src/main/res/values-pt/strings.xml +++ b/commons/src/main/res/values-pt/strings.xml @@ -273,4 +273,5 @@ Mês Trimestral Ano - + Limpar tudo + diff --git a/commons/src/main/res/values-ru/strings.xml b/commons/src/main/res/values-ru/strings.xml index 091ced00e8e..1961a02d8e8 100644 --- a/commons/src/main/res/values-ru/strings.xml +++ b/commons/src/main/res/values-ru/strings.xml @@ -331,4 +331,6 @@ Месяц Квартал Год - + Нет доступных периодов + Очистить все + diff --git a/commons/src/main/res/values-sv/strings.xml b/commons/src/main/res/values-sv/strings.xml index 20f9a44f588..95291c34402 100644 --- a/commons/src/main/res/values-sv/strings.xml +++ b/commons/src/main/res/values-sv/strings.xml @@ -78,4 +78,5 @@ Ta bort Dagligen År - + Rensa alla + diff --git a/commons/src/main/res/values-uk/strings.xml b/commons/src/main/res/values-uk/strings.xml index 9d3be7fc57f..e6cafb5a74f 100644 --- a/commons/src/main/res/values-uk/strings.xml +++ b/commons/src/main/res/values-uk/strings.xml @@ -264,4 +264,5 @@ Щодня Тиждень Рік - + Очистити все + diff --git a/commons/src/main/res/values-uz-rUZ/strings.xml b/commons/src/main/res/values-uz-rUZ/strings.xml index a119ce757b7..08d9efbf1e3 100644 --- a/commons/src/main/res/values-uz-rUZ/strings.xml +++ b/commons/src/main/res/values-uz-rUZ/strings.xml @@ -136,4 +136,5 @@ Кунлик Hafta Yil - + Barchasini tozalash + diff --git a/commons/src/main/res/values-uz/strings.xml b/commons/src/main/res/values-uz/strings.xml index 3965897d0ef..035658fff84 100644 --- a/commons/src/main/res/values-uz/strings.xml +++ b/commons/src/main/res/values-uz/strings.xml @@ -168,4 +168,5 @@ Ой Чорак Йил - + Барчасини тозалаш + diff --git a/commons/src/main/res/values-vi/strings.xml b/commons/src/main/res/values-vi/strings.xml index 5f6402a327a..32c878e2fb8 100644 --- a/commons/src/main/res/values-vi/strings.xml +++ b/commons/src/main/res/values-vi/strings.xml @@ -202,7 +202,7 @@ Phiên bản của Cài Đặt Ứng Dụng Web này không hỗ trợ Cài Đặt Ứng Dụng Web không được cài trong máy chủ. Máy chủ mất quá nhiều thời gian để trả lời. Chúng tôi đã hủy yêu cầu. - Không thể cập nhật mối quan hệ với bệnh nhân + Mối quan hệ không thể cập nhật. Có quá nhiều thời điểm Chúng tôi không thể tìm thấy đường dẫn liên kết. Vui lòng kiểm tra nó. Tài khoảng của bạn đã bị vô hiệu. Nếu đây là lỗi, vui lòng liên hệ quản lý của bạn. @@ -271,4 +271,6 @@ Nhập cơ sở dữ liệu không thành công Tệp không hợp lệ Không ở chế độ đăng ký TOTP 2FA + + Xóa tất cả diff --git a/commons/src/main/res/values-zh-rCN/strings.xml b/commons/src/main/res/values-zh-rCN/strings.xml index f7f33fdbf19..6b655484185 100644 --- a/commons/src/main/res/values-zh-rCN/strings.xml +++ b/commons/src/main/res/values-zh-rCN/strings.xml @@ -328,4 +328,6 @@ 每月六次 财政年度 - + 无可用时段 + 全清除 + diff --git a/commons/src/main/res/values-zh/strings.xml b/commons/src/main/res/values-zh/strings.xml index f0a63a42cb1..031f5dbb1d7 100644 --- a/commons/src/main/res/values-zh/strings.xml +++ b/commons/src/main/res/values-zh/strings.xml @@ -50,6 +50,7 @@ 两周 开始于周三的周报 开始于周四的周报 + 每周从周五开始 开始于周六的周报 开始于周日的周报 每月 @@ -61,8 +62,11 @@ 从 7 月开始,每 6 个月一次 开始于11月的半年 每年 + 从 2 月开始的财务年度 开始于四月的财政年 开始于六月的财政年 + 从 8 月开始的财政年度 + 9 月开始的财政年度 开始于十月的财政年 开始于11月的财政年 @@ -332,4 +336,6 @@ 数据库导入失败 无效文件 未进入 TOTP 2FA 注册模式 + + 全清除 diff --git a/commons/src/main/res/values/strings.xml b/commons/src/main/res/values/strings.xml index 0ff4f103756..86841ba4fb0 100644 --- a/commons/src/main/res/values/strings.xml +++ b/commons/src/main/res/values/strings.xml @@ -51,6 +51,7 @@ Bi-weekly Weekly starting Wednesday Weekly starting Thursday + Weekly starting Friday Weekly starting Saturday Weekly starting Sunday Monthly @@ -62,8 +63,11 @@ Six-monthly starting July Six-monthly starting November Yearly + Financial year starting February Financial year starting April Financial year starting July + Financial year starting August + Financial year starting September Financial year starting October Financial year starting November @@ -344,4 +348,6 @@ Database import failed Invalid file Not in TOTP 2FA enrollment mode + + Clear all diff --git a/commons/src/main/res/values/styles.xml b/commons/src/main/res/values/styles.xml index 459deafba55..14aea64adc7 100644 --- a/commons/src/main/res/values/styles.xml +++ b/commons/src/main/res/values/styles.xml @@ -69,6 +69,14 @@ ?colorPrimary + + + + - - - \ No newline at end of file diff --git a/whatsnew/whatsnew-en-US b/whatsnew/whatsnew-en-US index 9e29dc675af..0be924354d9 100644 --- a/whatsnew/whatsnew-en-US +++ b/whatsnew/whatsnew-en-US @@ -1,7 +1,10 @@ -This is a patch version that fixes: -- ANDROAPP-7269 Crash on search -- ANDROAPP-7293 Bottom sheet landscape behavior -- ANDROAPP-7345 Changes to enrollment date not respected by program rules -- ANDROAPP-7368 Crash when trying to update fields in Tracker -.... -You can find all the details on Jira and Github. \ No newline at end of file +Version 3.4.0 includes: +- Search performance: configure operators and min characters for TEI searches +- Custom theme: DHIS2 instance color reflected in the Android app +- Feedback widget: Markdown formatting and legend-based color coding +- Program rule action priority ordering +- Redesigned PIN login screen +- Metadata sync: new 6h and 12h intervals with daily config detection +- Improved event ordering for consistent sync and calculations + +Details: github.com/dhis2/dhis2-android-capture-app \ No newline at end of file