From 8ac54b863fbe5b591ed801370ce649d5ddbf5367 Mon Sep 17 00:00:00 2001 From: danielTari Date: Mon, 7 Sep 2026 14:44:55 +0200 Subject: [PATCH] update documentation for 1.15.0 release --- docs/content/developer/analytics.md | 86 ++-- docs/content/developer/apk-distribution.md | 8 +- docs/content/developer/compatibility.md | 1 + docs/content/developer/data-store.md | 31 +- docs/content/developer/database.md | 4 +- docs/content/developer/dhis2-services.md | 42 +- docs/content/developer/error-management.md | 45 +- docs/content/developer/getting-started.md | 55 ++- docs/content/developer/maps.md | 8 +- .../developer/modules-and-repositories.md | 87 ++-- docs/content/developer/object-style.md | 8 +- docs/content/developer/overview.md | 4 +- .../developer/program-indicator-engine.md | 6 +- docs/content/developer/public-api.md | 32 +- docs/content/developer/settings.md | 24 +- docs/content/developer/sms.md | 80 ++-- .../developer/validation-rule-engine.md | 4 +- docs/content/developer/workflow.md | 438 +++++++++++++----- 18 files changed, 613 insertions(+), 350 deletions(-) diff --git a/docs/content/developer/analytics.md b/docs/content/developer/analytics.md index 9bc319ea6bc..231ecb758a7 100644 --- a/docs/content/developer/analytics.md +++ b/docs/content/developer/analytics.md @@ -12,17 +12,17 @@ This module follows similar concepts to analytics endpoint in the web API. They For example, a basic query that get the number of "ANC 1st visit" (DataElement "fbfJHSPpUQD") in the last 3 months (RelativePeriod) and filtered by a the orgunit "Ngelehun CHC" (Absolute OrganisationUnit "DiszpKrYNg8") would be like this: -```java +```kotlin d2.analyticsModule().analytics() - .withDimension(new DimensionItem.DataItem.DataElementItem("fbfJHSPpUQD")) - .withDimension(new DimensionItem.PeriodItem.Relative(RelativePeriod.LAST_3_MONTHS)) - .withFilter(new DimensionItem.OrganisationUnitItem.Absolute("DiszpKrYNg8")) - .evaluate(); + .withDimension(DimensionItem.DataItem.DataElementItem("fbfJHSPpUQD")) + .withDimension(DimensionItem.PeriodItem.Relative(RelativePeriod.LAST_3_MONTHS)) + .withFilter(DimensionItem.OrganisationUnitItem.Absolute("DiszpKrYNg8")) + .suspendEvaluate() ``` -The engine will return a `Result` object containing either a `DimensionalResponse` or an `AnalyticsException`. Let's take a look at the structure of the `DimensionalResponse`. We use the Kotlin representation for convenience, but the Java representation would be very similar: +The engine will return a `Result` object containing either a `DimensionalResponse` or an `AnalyticsException`. Let's take a look at the structure of the `DimensionalResponse`: -```kt +```kotlin DimensionalResponse( metadata = mapOf( "fbfJHSPpUQD" to MetadataItem.DataElementItem, @@ -76,21 +76,21 @@ Properties included in the `DimensionalResponse` object: DimensionItems can be used either as dimensions or as filters. And multiple items of the same dimension can be combined in the same query. For example, this query gets "ANC 1st visit" (DataElement "fbfJHSPpUQD") and "ANC 1-3 Dropout Rate" (Indicator "ReUHfIn0pTQ") disaggregated by the category "Location: Fixed/Outreach" (Category "fMZEcRHuamy") using the options "Fixed" (CategoryOption "qkPbeWaFsnU") and "Outreach" (CategoryOption "wbrDrL2aYEc") within the last 3 months (Relative Period) in the UserOrganisationUnit (Relative OrganisationUnit), classifying the values by data item legendSet and overriding the aggregation type: -```java +```kotlin d2.analyticsModule().analytics() - .withDimension(new DimensionItem.DataItem.DataElementItem("fbfJHSPpUQD")) - .withDimension(new DimensionItem.DataItem.IndicatorItem("ReUHfIn0pTQ")) - .withDimension(new DimensionItem.CategoryItem("fMZEcRHuamy", "qkPbeWaFsnU")) - .withDimension(new DimensionItem.CategoryItem("fMZEcRHuamy", "wbrDrL2aYEc")) + .withDimension(DimensionItem.DataItem.DataElementItem("fbfJHSPpUQD")) + .withDimension(DimensionItem.DataItem.IndicatorItem("ReUHfIn0pTQ")) + .withDimension(DimensionItem.CategoryItem("fMZEcRHuamy", "qkPbeWaFsnU")) + .withDimension(DimensionItem.CategoryItem("fMZEcRHuamy", "wbrDrL2aYEc")) - .withFilter(new DimensionItem.PeriodItem.Relative(RelativePeriod.LAST_3_MONTHS)) - .withFilter(new DimensionItem.OrganisationUnitItem.Relative(RelativeOrganisationUnit.USER_ORGUNIT)) + .withFilter(DimensionItem.PeriodItem.Relative(RelativePeriod.LAST_3_MONTHS)) + .withFilter(DimensionItem.OrganisationUnitItem.Relative(RelativeOrganisationUnit.USER_ORGUNIT)) - .withLegendStrategy(AnalyticsLegendStrategy.ByDataItem.INSTANCE) + .withLegendStrategy(AnalyticsLegendStrategy.ByDataItem) .withAggregationType(AggregationType.LAST) - .evaluate(); + .suspendEvaluate() ``` The evaluator imposes some restrictions to the parameters passed as dimensions or filters (they are similar to those imposed by the analtyics web api): @@ -103,13 +103,37 @@ Additionally, the query is evaluated against the local metadata and data, which 1. DimensionItems (DataElement, Indicator, OrganisationUnit, ...) must be downloaded in the device. By default, the SDK downloads all the dataSets and programs accessible to the user and the related metadata. 2. Data must be downloaded in the device. The evaluation only takes into account the data stored in the local database. -There are three options to define the legendSet strategy. The class `AnalyticsLegendStrategy` is a sealed class in Kotlin, so the keyword `INSTANCE` must be appended at the end of the object values when coding in Java. Code examples: +#### Program indicator disaggregations { #android_sdk_program_indicator_disaggregations } -```java +A program indicator can declare a category combination and a set of *category mappings*: each mapping assigns, for one category, a filter expression to every category option. When a query includes a program indicator as a data item together with category dimensions, the engine resolves those mappings and applies the corresponding expressions as additional conditions. + +The relevant metadata is downloaded with the programs and exposed through: + +- `ProgramIndicator.categoryCombo()` and `ProgramIndicator.attributeCombo()`: the category combinations the indicator + is disaggregated by. The new filters `byCategoryCombo()` and `byAttributeCombo()` are available in + `ProgramIndicatorCollectionRepository`. +- `ProgramIndicator.categoryMappingIds()`: the uids of the `CategoryMapping` objects that apply to the indicator. +- `CategoryMapping` (`uid`, `program`, `categoryId`, `mappingName`, `optionMappings`) and `CategoryOptionMapping` + (`categoryMapping`, `optionId`, `filter`). + +Querying a disaggregated indicator is no different from any other query: just add the category dimensions. + +```kotlin +d2.analyticsModule().analytics() + .withDimension(DimensionItem.DataItem.ProgramIndicatorItem("programIndicatorUid")) + .withDimension(DimensionItem.CategoryItem("categoryUid", "categoryOptionUid1")) + .withDimension(DimensionItem.CategoryItem("categoryUid", "categoryOptionUid2")) + .withFilter(DimensionItem.PeriodItem.Relative(RelativePeriod.LAST_3_MONTHS)) + .suspendEvaluate() +``` + +There are three options to define the legendSet strategy. `AnalyticsLegendStrategy` is a sealed class; from Java, the object values require the `INSTANCE` suffix (`AnalyticsLegendStrategy.ByDataItem.INSTANCE`). Code examples: + +```kotlin d2.analyticsModule().analytics() - .withLegendStrategy(AnalyticsLegendStrategy.ByDataItem.INSTANCE) // Data items use their own LegendSet - .withLegendStrategy(AnalyticsLegendStrategy.None.INSTANCE) // LegendSets are not used - .withLegendStrategy(new AnalyticsLegendStrategy.Fixed("fqs276KXCXi")) // The provided LegendSet will be used for all data items + .withLegendStrategy(AnalyticsLegendStrategy.ByDataItem) // Data items use their own LegendSet + .withLegendStrategy(AnalyticsLegendStrategy.None) // LegendSets are not used + .withLegendStrategy(AnalyticsLegendStrategy.Fixed("fqs276KXCXi")) // The provided LegendSet will be used for all data items ``` ### Visualization analytics { #android_sdk_visualization_analytics } @@ -138,17 +162,17 @@ The expected representation of the visualization would be something like this: We can get the result of the visualization by calling the "visualizations" repository within the analtyics module. Optionally, we can override the values for Period and OrganisationUnit. This is useful to expose filters in the UI to allow easy modifications of the results. -```java +```kotlin d2.analyticsModule().visualizations() .withVisualization("SwtkWZFhrFQ") [.withPeriods()] [.withOrganisationUnits()] - .evaluate(); + .suspendEvaluate() ``` The method will return a `Result` with two possible values: a `GridAnalyticsResponse` and an `AnalyticsResponse`. Let's take a look at the structure of the `GridAnalyticsResponse`. We use the Kotlin representation for convenience: -```kt +```kotlin GridAnalyticsResponse( metadata = mapOf( "fbfJHSPpUQD" to MetadataItem.DataElementItem, @@ -517,7 +541,7 @@ A common use-case is to generate a list of event or enrollments that meet a cert For example, a query that contains all the ACTIVE enrollments in the program "fbfJHSPpUQD" whose attribute "p4mRWMtCxtB" has a value between 40 and 50 would look like this. Note that the status is included as a filter, so there is not an explicit column for it. -```kt +```kotlin d2.analyticsModule().trackerLineList() .withEnrollmentOutput("fbfJHSPpUQD") .withFilter( @@ -537,12 +561,12 @@ d2.analyticsModule().trackerLineList() ), ), ) - .evaluate() + .suspendEvaluate() ``` The response is a Result object of TrackerLineListResponse, which has the following structure: -```kt +```kotlin TrackerLineListResponse( metadata = mapOf( "p4mRWMtCxtB" to MetadataItem.TrackedEntityAttributeItem @@ -575,7 +599,7 @@ Optionally, it is possible to use a TrackerVisualization object (called EventVis For example, this query uses the configuration in the TrackerVisualization "s85urBIkN0z" and adds or overrides the column ProgramStatusItem filtering by ACTIVE. -```kt +```kotlin d2.analyticsModule().trackerLineList() .withTrackerVisualization("s85urBIkN0z") .withColumn( @@ -585,7 +609,7 @@ d2.analyticsModule().trackerLineList() ) ) ) - .evaluate() + .suspendEvaluate() ``` In order to use the TrackerVisualization objects, they must be set in the "Analytics" section of the Android Settings webapp. Currently, it is not possible to download on-demand visualizations from the server, just to downloaded through the Android Settings webapp. @@ -598,14 +622,14 @@ A common use-case is to generate an event line list of a repeatable stage in the For example, let's suppose we have a repeatable stage with two dataElements (height and weight) and one indicator based on those values (BMI, Body Mass Index). We would like to show the evolution of those values across the events -```java +```kotlin d2.analyticsModule().eventLineList() .byProgramStage().eq("stage_id") .byTrackedEntityInstance().eq("tei_id") .withDataElement("height_id") .withDataElement("weight_id") .withProgramIndicator("BMI_id") - .evaluate(); + .suspendEvaluate() ``` The result would be a list of events with the evaluated values (dataelement and indicators) as well as some handy `displayName` properties to display the result in a table or chart. diff --git a/docs/content/developer/apk-distribution.md b/docs/content/developer/apk-distribution.md index 57edba00e38..8ad6cc7f662 100644 --- a/docs/content/developer/apk-distribution.md +++ b/docs/content/developer/apk-distribution.md @@ -7,14 +7,14 @@ The APK distribution app allows the definition of different versions by user gro The app can get this version by using the LatestAppVersion repository: -```kt -d2.settingModule().latestAppVersion().get() +```kotlin +d2.settingModule().latestAppVersion().suspendGet() ``` This version is updated with each metadata sync. To check for updates without triggering a full metadata sync, this method can be used: -```kt -d2.settingModule().latestAppVersion().download() +```kotlin +d2.settingModule().latestAppVersion().suspendDownload() ``` Once the download is completed, the version can be read from the database as usual. diff --git a/docs/content/developer/compatibility.md b/docs/content/developer/compatibility.md index 0db5fd4be7c..a0e4ca2e82b 100644 --- a/docs/content/developer/compatibility.md +++ b/docs/content/developer/compatibility.md @@ -4,6 +4,7 @@ Compatibility table between DHIS2 Android SDK library, DHIS2 core and Android SD | SDK | DHIS2 core | Android SDK | Rule engine* | |--------|--------------|-------------|-----------------| +| 1.15.X | 2.30 -> 2.43 | 23 - 37 | 2.0.47 - 2.0.48 | | 1.14.X | 2.30 -> 2.43 | 23 - 36 | 2.0.47 - 2.0.48 | | 1.13.X | 2.30 -> 2.42 | 21 - 35 | 2.0.47 - 2.0.48 | | 1.12.X | 2.30 -> 2.42 | 21 - 35 | 2.0.47 - 2.0.48 | diff --git a/docs/content/developer/data-store.md b/docs/content/developer/data-store.md index 22500a4a254..62c40a4894a 100644 --- a/docs/content/developer/data-store.md +++ b/docs/content/developer/data-store.md @@ -1,6 +1,6 @@ # Data Store { #android_sdk_data_store } -```java +```kotlin d2.dataStoreModule().dataStore() ``` @@ -18,30 +18,32 @@ The behavior is similar to the rest of the data: 2. Read or modify the DataStore entries. 3. If there are any modifications in the entries, call the method to upload the entries to the server. -```java +`DataStoreEntry.value()` holds the raw JSON value of the entry, exactly as stored in the server DataStore. Apps are expected to parse it according to the namespace schema. + +```kotlin // Download d2.dataStoreModule().dataStoreDownloader() - .byNamespace().in("namespace1", "namespace2") - .download(); + .byNamespace().`in`("namespace1", "namespace2") + .flowDownload() // Read example -List entries = d2.dataStoreModule().dataStore() +val entries = d2.dataStoreModule().dataStore() .byNamespace().eq("namespace1") - .byKey().in("key1", "key2") - .get() + .byKey().`in`("key1", "key2") + .suspendGet() // Write example d2.dataStoreModule().dataStore() .value("namespace1", "key1") - .set("value"); + .suspendSet("value") // Upload -d2.dataStoreModule().dataStore().upload(); +d2.dataStoreModule().dataStore().flowUpload() ``` ## Local Data Store { #android_sdk_local_data_store } -```java +```kotlin d2.dataStoreModule().localDataStore() ``` @@ -49,14 +51,13 @@ This repository is ideal for storing any kind of information. This collection supports key value pairs (`KeyValuePair`) and it can be stored as others values in the SDK. -```java +```kotlin // Access the object repository -LocalDataStoreObjectRepository objectRepository = - d2.dataStoreModule().localDataStore().value("key"); +val objectRepository = d2.dataStoreModule().localDataStore().value("key") // Set or update a key value pair -objectRepository.set("value"); +objectRepository.suspendSet("value") // Remove key value pair -objectRepository.delete(); +objectRepository.suspendDelete() ``` \ No newline at end of file diff --git a/docs/content/developer/database.md b/docs/content/developer/database.md index f8094c17ac4..b2ee8c0b553 100644 --- a/docs/content/developer/database.md +++ b/docs/content/developer/database.md @@ -29,7 +29,7 @@ The database can be exported and imported in a different device. One of the main use cases of this functionality is debugging: sometimes it is hard to know the reason for a sync problem or a bug, and it is very useful to replicate the issue in an emulator or a different device. -```kt +```kotlin // Export database val database = d2.maintenanceModule().databaseImportExport().exportLoggedUserDatabase() @@ -39,7 +39,7 @@ val metadata = d2.maintenanceModule().databaseImportExport().importDatabase(data // The metadata object contains information about the database (serverUrl, username,...) // Once the database is imported, it is possible to login as usual -d2.userModule().login("username", "password", "serverUrl") +d2.userModule().suspendLogIn("username", "password", "serverUrl") ``` The export process encrypts the database using ZIP encryption, so the database file can't be read unless the right user credentials are provided. diff --git a/docs/content/developer/dhis2-services.md b/docs/content/developer/dhis2-services.md index 7ab502e4806..5c28926b259 100644 --- a/docs/content/developer/dhis2-services.md +++ b/docs/content/developer/dhis2-services.md @@ -6,22 +6,22 @@ SDK repositories give access to metadata and allow to create and modify data, bu In order to make this task easier, the SDK includes some services that evaluate pure DHIS2 business logic. They are located in their related module and usually are suffixed by `Service`. Some examples of this: -```java +```kotlin // Event services d2.eventModule().eventService() - | .canAddEventToEnrollment("enrollment_uid", "program_stage_uid") - | .getEditableStatus("event_uid") - | .isEditable("event_uid") - | .hasDataWriteAccess("event_uid") + | .suspendCanAddEventToEnrollment("enrollment_uid", "program_stage_uid") + | .suspendGetEditableStatus("event_uid") + | .suspendIsEditable("event_uid") + | .suspendHasDataWriteAccess("event_uid") // Enrollment services d2.enrollmentModule().enrollmentService() - | .getEnrollmentAccess("tracked_entity_instance_uid", "program_uid") - | .isOpen("enrollment_uid") + | .suspendGetEnrollmentAccess("tracked_entity_instance_uid", "program_uid") + | .suspendIsOpen("enrollment_uid") // Tracked entity instance services d2.trackedEntityModule().trackedEntityInstanceService() - | .inheritAttributes("from_tei_uid", "to_tei_uid", "program_uid") + | .blockingInheritAttributes("from_tei_uid", "to_tei_uid", "program_uid") ``` Check the javadoc documentation in the IDE to know more details about each method. @@ -32,23 +32,23 @@ The SDK include a enum helper class called `ValueType`. This class defines all t To access the type of value you can simply access it through the methods of the valueType. -```java - valueType.isInteger(); - valueType.isDecimal(); - valueType.isNumeric(); - valueType.isBoolean(); - valueType.isText(); - valueType.isDate(); - valueType.isFile(); - valueType.isCoordinate(); - valueType.isGeo(); - valueType.isJson(); +```kotlin +valueType.isInteger +valueType.isDecimal +valueType.isNumeric +valueType.isBoolean +valueType.isText +valueType.isDate +valueType.isFile +valueType.isCoordinate +valueType.isGeo +valueType.isJson ``` To validate a value starting from its valueType it can be done in the following way: -```java - valueType.getValidator().validate("value"); +```kotlin +valueType.validator.validate("value") ``` This validator will return a `Result` which can be `Success` or `Failure`. In addition each `ValueType` will return different types of errors making it easier to identify what the problem is if the value does not pass validation. \ No newline at end of file diff --git a/docs/content/developer/error-management.md b/docs/content/developer/error-management.md index d5ff145a748..7ab8cd9d962 100644 --- a/docs/content/developer/error-management.md +++ b/docs/content/developer/error-management.md @@ -12,33 +12,42 @@ Errors that happen in the context of the SDK are wrapped in a type of exception: Any operation requested to the SDK can throw an error. +- For `suspend` operations, the error is thrown directly and can be caught as usual: + + ```kotlin + try { + d2.userModule().suspendLogIn(username, password, url) + } catch (d2Error: D2Error) { + Log.e("LOGIN", "${d2Error.errorComponent()} ${d2Error.httpErrorCode()} ${d2Error.errorCode()}") + } + ``` + - For operations returning RxJava objects, the errors can be extracted in the following way: - ```java - d2.userModule().logIn(username, password, url) + ```kotlin + d2.userModule().rxLogIn(username, password, url) .subscribe( - user -> { }, - error -> { - if (error instanceof D2Error) { - D2Error d2Error = (D2Error) error; - Log.e("LOGIN", d2Error.errorComponent() + " " + d2Error.httpErrorCode() + " " + d2Error.errorCode()); + { user -> }, + { error -> + if (error is D2Error) { + Log.e("LOGIN", "${error.errorComponent()} ${error.httpErrorCode()} ${error.errorCode()}") } - } - ); + }, + ) ``` - For blocking operations, it is also possible to retrieve a `D2Error`. - The errors can be extracted by caching them as shown in the following + The errors can be extracted by catching them as shown in the following code snippet: - ```java + ```kotlin try { - d2.userModule().blockingLogIn(username, password, url); - } catch (Exception e) { - if (e.getCause() instanceof D2Error) { - D2Error d2Error = (D2Error) e.getCause(); - Log.e("LOGIN", d2Error.errorComponent() + " " + d2Error.httpErrorCode() + " " + d2Error.errorCode()); + d2.userModule().blockingLogIn(username, password, url) + } catch (e: Exception) { + val cause = e.cause + if (cause is D2Error) { + Log.e("LOGIN", "${cause.errorComponent()} ${cause.httpErrorCode()} ${cause.errorCode()}") } } ``` @@ -47,10 +56,10 @@ Any operation requested to the SDK can throw an error. analyzed afterwards and diagnose possible problems. They can be accessed through it's own repository: -```java +```kotlin d2.maintenanceModule().d2Errors() .byD2ErrorComponent().eq(D2ErrorComponent.Server) - .get(); + .suspendGet() ``` The SDK team is now working together with the core team in order to provide a full list of common error codes, but it's still a work in progress. diff --git a/docs/content/developer/getting-started.md b/docs/content/developer/getting-started.md index 34d142bef61..02a5bf929b6 100644 --- a/docs/content/developer/getting-started.md +++ b/docs/content/developer/getting-started.md @@ -33,28 +33,29 @@ In order to start using the SDK, the first step is to initialize a `D2` object. The minimum configuration that needs to be passed to the `D2Manager` is the following: -```java -D2Configuration configuration = D2Configuration.builder() +```kotlin +val configuration = D2Configuration.builder() .context(context) - .build(); + .build() ``` Using the configuration you can instantiate `D2`. -```java -Single d2Single = D2Manager.instantiateD2(configuration); -``` +```kotlin +// Coroutines +val d2 = D2Manager.suspendInstantiateD2(configuration) -Once the Single is completed, you can access D2 with the following method: +// RxJava +val d2Single: Single = D2Manager.rxInstantiateD2(configuration) -```java -D2 d2 = D2Manager.getD2(); +// Blocking. Must not be called from the main thread. +val d2 = D2Manager.blockingInstantiateD2(configuration) ``` -If you are not using RxJava, you can instantiate `D2` in a blocking way: +Once instantiated, you can access D2 anywhere with the following method: -```java -D2 d2 = D2Manager.blockingInstantiateD2(configuration); +```kotlin +val d2 = D2Manager.getD2() ``` The object `D2Configuration` has a lot of fields to configure the behavior of the SDK. @@ -89,18 +90,18 @@ dependencies { After adding the `play-services-safetynet` dependency just create a method that you can used to install the security provider -```java -public static void initialize(Context context){ +```kotlin +fun initialize(context: Context) { try { // .... - ProviderInstaller.installIfNeeded(context.getApplicationContext()); + ProviderInstaller.installIfNeeded(context.applicationContext) // .... - } catch (GooglePlayServicesRepairableException e) { - Log.e(TAG, e.toString()); - } catch (GooglePlayServicesNotAvailableException e) { - Log.e(TAG, e.toString()); - } catch (NoSuchAlgorithmException e) { - Log.e(TAG, e.toString()); + } catch (e: GooglePlayServicesRepairableException) { + Log.e(TAG, e.toString()) + } catch (e: GooglePlayServicesNotAvailableException) { + Log.e(TAG, e.toString()) + } catch (e: NoSuchAlgorithmException) { + Log.e(TAG, e.toString()) } } ``` @@ -120,14 +121,12 @@ dependencies { Setting up conscrypt is similar to the Google’s provider like in the following code : -```java -public static void initialize(){ +```kotlin +fun initialize() { try { - Security.insertProviderAt(Conscrypt.newProvider(), 1); - } catch(Exception e) { - Log.e(TAG, e.toString()); - } catch (NoSuchAlgorithmException e) { - Log.e(TAG, e.toString()); + Security.insertProviderAt(Conscrypt.newProvider(), 1) + } catch (e: Exception) { + Log.e(TAG, e.toString()) } } ``` diff --git a/docs/content/developer/maps.md b/docs/content/developer/maps.md index de31c670dd8..931a8009c88 100644 --- a/docs/content/developer/maps.md +++ b/docs/content/developer/maps.md @@ -12,8 +12,8 @@ Additionally, it is possible to define custom basemaps in the Maintenance app. All these map layers are downloaded in a separate call: -```java -d2.mapsModule().mapLayersDownloader().downloadMetadata() +```kotlin +d2.mapsModule().mapLayersDownloader().suspendDownloadMetadata() ``` > **Important** @@ -22,11 +22,11 @@ d2.mapsModule().mapLayersDownloader().downloadMetadata() Then, map layers can be accessed by using the corresponding collection repository, as usual: -```java +```kotlin d2.mapsModule().mapLayers() .byName().eq("map_layer") .withImageryProviders() - .get() + .suspendGet() ``` These map layers contain useful information to display them using a SDK for maps, in particular: diff --git a/docs/content/developer/modules-and-repositories.md b/docs/content/developer/modules-and-repositories.md index 996facc631f..fb0cd7f9f49 100644 --- a/docs/content/developer/modules-and-repositories.md +++ b/docs/content/developer/modules-and-repositories.md @@ -6,54 +6,70 @@ Modules are the layer below `D2`. They act as a wrapper for related functionalit Repositories act as a facade for the DB (or web API in some cases). They offer read capabilities for metadata and read/write for data. -## Dealing with return types: RxJava { #android_sdk_dealing_with_rxjava } +## Dealing with return types { #android_sdk_dealing_with_return_types } -The SDK uses RxJava classes (Observable, Single, Completable, Flowable) as the preferred return type for all the methods. The reasons for choosing RxJava classes are mainly two: +Most of the actions in the SDK are time consuming and must be executed in a secondary thread. To make that explicit, and to notify about the progress of long operations such as metadata or data sync, every asynchronous method is exposed in three flavours: -- **To facilitate the asynchronous treatment of returned objects.** Most of the actions in the SDK are time consuming and must be executed in a secondary thread. These return types force the app to deal with this asynchronous behavior. -- **To notify about progress.** Methods like metadata or data sync might take several minutes to finish. From a user perspective, it is very helpful to have a sense of progress. +| Prefix | Return type | Notes | +|--------|-------------|-------| +| `suspend` | the value itself | Kotlin `suspend` function. **Recommended**, and used in the examples throughout this guide. | +| `rx` | RxJava (`Single`, `Completable`, `Observable`, `Flowable`) | For apps already built around RxJava. | +| `blocking` | the value itself | Must not be called from the main thread. | -This does not mean that applications are forced to use RxJava in their code: they are only forced to deal with the asynchronous behavior of some methods. The SDK usually exposes *blocking* version of every method. +Operations that report progress (metadata and data download, data upload, reserved value download) do not have a `suspend` variant, because a single return value cannot convey progress. They expose a `flow` variant instead, which returns a `Flow`: `flowDownload()`, `flowUpload()`, `flowDownloadReservedValues()`. -For example, the same query using RxJava and AsyncTask: +```kotlin +// Coroutines +val programs = d2.programModule().programs().suspendGet() -*Using RxJava* - -```java +// RxJava d2.programModule().programs() .subscribeOn(Schedulers.io()) .observeOn(AndroidSchedulers.mainThread()) - .get() - .subscribe(programs -> {}); //List -``` - -*Using AsyncTask* + .rxGet() + .subscribe { programs -> } // List -```java -new AsyncTask>() { - protected List doInBackground() { - return d2.programModule().programs().blockingGet(); - } - - protected void onPostExecute(List programs) { - - } -}.execute(); +// Blocking, from a background thread +val programs = d2.programModule().programs().blockingGet() ``` +> **Important** +> +> Unprefixed RxJava methods are **deprecated** in favour of their `rx` counterparts, so that the coroutine variants can coexist with them. The old names still work, but they will be removed in a future release. +> +> The table below lists the drop-in replacement, which keeps the RxJava return type. When migrating, consider moving to the `suspend` variant instead. +> +> | Deprecated | Drop-in replacement | +> |------------|---------------------| +> | `get()` | `rxGet()` | +> | `count()` | `rxCount()` | +> | `isEmpty()` | `rxIsEmpty()` | +> | `getUids()` | `rxGetUids()` | +> | `exists()` | `rxExists()` | +> | `add(o)` | `rxAdd(o)` | +> | `set(value)` | `rxSet(value)` | +> | `delete()` / `deleteIfExist()` | `rxDelete()` / `rxDeleteIfExist()` | +> | `download()` | `rxDownload()` | +> | `upload()` | `rxUpload()` | +> | `evaluate()` | `rxEvaluate()` | +> +> The same rename applies to most module and service methods (`logIn()`, `logOut()`, `isLogged()`, `checkServerUrl()`, `validate()`, the `EventService` and `EnrollmentService` methods, the reserved value manager, …): the replacement is the same name prefixed with `rx`, and a `suspend` variant is available as well. `D2Manager.instantiateD2()` follows the same pattern, replaced by `D2Manager.rxInstantiateD2()`. +> +> Independently of that rename, `getPaged(int)` is also deprecated, replaced by `getPagingData(int)`, which returns a `Flow>` instead of a `LiveData>`. + Accessing the database is time consuming and it's recommended to do it in a separate thread using any of the recommended methods. However, procedures that involve accessing the web API, like log in, metadata or data download or upload **must** run in a separate thread, otherwise Android will throw an error. ## Query building { #android_sdk_query_building } -Repositories offer a builder syntax with compile-time validation to access the resources. A typical query is composed of some modifiers (filter, order, nested fields) and ends with an action (get, count, getPaged,...). +Repositories offer a builder syntax with compile-time validation to access the resources. A typical query is composed of some modifiers (filter, order, nested fields) and ends with an action (`suspendGet`/`rxGet`/`blockingGet`, `suspendCount`/`rxCount`/`blockingCount`, `getPagingData`,...). -```java +```kotlin // Generic syntax d2.. .[ filter | orderBy | nested fields ] - .; + . // An example for events d2.eventModule().events() @@ -61,7 +77,7 @@ d2.eventModule().events() .byEventDate().after(Date("2019-05-05")) .orderByEventDate(DESC) .withTrackedEntityDataValues() - .get(); + .suspendGet() ``` ### Filters { #android_sdk_filters } @@ -74,10 +90,11 @@ Common filter operators include: - **String matching**: `like()`, `notLike()` - **Collection**: `in()` - matches any value in the provided list - **Null checks**: `isNull()`, `isNotNull()` +- **Emptiness**: `isNullOrBlank()`, `isNotNullAndIsNotBlank()` - available in data value filters, where the event query repository exposes them as `isEmpty(Boolean)` Several filters can be appended to the same query in any order. Filters are joined globally using the operator "AND". This means that a query like -```java +```kotlin d2.eventModule().events() .byOrganisationUnitUid().eq("DiszpKrYNg8") .byEventDate().after(Date("2019-05-05")) @@ -88,12 +105,12 @@ will return the events assigned to the orgunit "DiszpKrYNg8" **AND** whose event The `in()` operator is particularly useful for querying multiple values at once: -```java +```kotlin // Query tracked entity instances with specific data values d2.trackedEntityModule().trackedEntityInstanceQuery() - .byDataValue("dataElementUid").in("value1", "value2", "value3") + .byDataValue("dataElementUid").`in`("value1", "value2", "value3") .onlineFirst() - .get(); + .suspendGet() ``` ### Order by { #android_sdk_order_by } @@ -102,7 +119,7 @@ Ordering modifiers are prefixed by the keyword "orderBy". Several "orderBy" modifiers can be appended to the same query. The order of the "orderBy" modifiers within the query determines the order priority. This means that a query like -```java +```kotlin d2.eventModule().events() .orderByEventDate(DESC) .orderByLastUpdated(DESC) @@ -119,7 +136,7 @@ Due to performance issues, this kind of properties are not included by default: Several properties can be appended in the same query in any order. For example, a query like -```java +```kotlin d2.programModule().programs() .withTrackedEntityType() ... @@ -134,7 +151,7 @@ The SDK include some helpers in the package `org.hisp.dhis.android.core.arch.hel - `AccessHelper`: related to access (sharing settings) object. - `CollectionsHelper`: common operations to collections. - `CoordinateHelper`, `GeometryHelper`: geospatial data manipulation. -- `FileResizeHelper`, `FileResourceDirectoryHelper`: file resource manipulation. +- `FileResizerHelper`, `FileCompressionHelper`, `FileResourceDirectoryHelper`: file resource manipulation. - `UidsHelper`: common operations to collections of objects with uid. - `UserHelper`: operations related to user authentication. - `ValueType`: list of different value types and their validators. diff --git a/docs/content/developer/object-style.md b/docs/content/developer/object-style.md index 51dc3aa247d..f7c480f6df8 100644 --- a/docs/content/developer/object-style.md +++ b/docs/content/developer/object-style.md @@ -14,17 +14,17 @@ DHIS2 includes a predefined set of icons. Those icons are included in the SDK an Staring on version v41, it is possible to upload user-defined icons and assign them to metadata objects. The actual images of these icons are stored as a FileResource and must be explicitly downloaded in a separate query. -```kt +```kotlin d2.fileResourceModule().fileResourceDownloader() .byDomainType().eq(FileResourceDomainType.ICON) - .download() + .flowDownload() ``` You can get the information about a particular icon by using the IconCollectionRepository. It returns an Icon object, which is a sealed class with two possible values: Default or Custom. The way to render the actual image will depend on the Icon type. -```kt +```kotlin val icon = d2.iconModule().icons().key("icon_key").blockingGet() @@ -48,6 +48,6 @@ icon?.let { It contains the Hex value for the color. It can be used to customize the background, text color, line headings, etc. -```kt +```kotlin program.style().color() // For example #9C33FF ``` diff --git a/docs/content/developer/overview.md b/docs/content/developer/overview.md index 4a57a66c13a..9277864d5ff 100644 --- a/docs/content/developer/overview.md +++ b/docs/content/developer/overview.md @@ -10,8 +10,8 @@ Main goals: ## Technology overview { #android_sdk_technology_overview } -The SDK is mainly written in Java 8 using the reduced subset of features allowed in the minimum Android API version, although newer components are implemented in [Kotlin](https://kotlinlang.org/), which is the language recommend by Google for building Android apps. The SDK uses some Android-specific components, such as libraries to create paged list (LiveData, PagedList) or to access to file system. For this reason, currently **the SDK is only runnable in an Android environment**. +The SDK is entirely written in [Kotlin](https://kotlinlang.org/), which is the language recommended by Google for building Android apps. The SDK uses some Android-specific components, such as libraries to create paged list (LiveData, PagedList) or to access to file system. For this reason, currently **the SDK is only runnable in an Android environment**. -It uses [RxJava](https://github.com/ReactiveX/RxJava) to facilitate the asynchronous treatment of some methods. Although it is optional, we recommend this approach to ensure non-blocking calls. +Every asynchronous operation is exposed in three flavours: a Kotlin `suspend` function (`suspendGet()`), an [RxJava](https://github.com/ReactiveX/RxJava) variant (`rxGet()`) and a blocking variant (`blockingGet()`). Coroutines are the recommended approach for new code; the RxJava variants are fully supported. See [Dealing with return types](#android_sdk_dealing_with_return_types). Other libraries internally used by the SDK are: [Koin](https://insert-koin.io/) for dependency injection, [Kotlinx Serialization](https://github.com/Kotlin/kotlinx.serialization) for JSON parsing, [Ktor](https://github.com/ktorio/ktor) and [OkHttpClient](https://square.github.io/okhttp/) for API communication, [Room](https://developer.android.com/training/data-storage/room) for database persistence, or [SQLCipher](https://www.zetetic.net/sqlcipher/) for DB encryption. diff --git a/docs/content/developer/program-indicator-engine.md b/docs/content/developer/program-indicator-engine.md index 0e203f3204a..4ce8274aff8 100644 --- a/docs/content/developer/program-indicator-engine.md +++ b/docs/content/developer/program-indicator-engine.md @@ -8,14 +8,14 @@ A bad example, "Number of active TEIs": it would always be 1. In order to trigger the Program Indicator Engine, just execute: -```java +```kotlin d2.programModule() .programIndicatorEngine() - .getEnrollmentProgramIndicatorValue(, ); + .getEnrollmentProgramIndicatorValue(, ) d2.programModule() .programIndicatorEngine() - .getEventProgramIndicatorValue(, ); + .getEventProgramIndicatorValue(, ) ``` If the evaluation of the "filter" component returns false, the result is null. diff --git a/docs/content/developer/public-api.md b/docs/content/developer/public-api.md index 3b5177f2b8f..76bfa632e3b 100644 --- a/docs/content/developer/public-api.md +++ b/docs/content/developer/public-api.md @@ -1,8 +1,30 @@ # API documentation {#api_documentation} -DHIS2 Android SDK API documentation is hosted -in [Github repository](https://dhis2.github.io/dhis2-android-sdk/api/index.html). +DHIS2 Android SDK API documentation is hosted in [Github repository](https://dhis2.github.io/dhis2-android-sdk/api/index.html). -This documentation provides information about the DHIS2 Android SDK's public API, including -available data models and the methods for interacting with them. It serves as a reference for -developers integrating the SDK into their applications. \ No newline at end of file +This documentation provides information about the DHIS2 Android SDK's public API, including available data models and the methods for interacting with them. It serves as a reference for developers integrating the SDK into their applications. + +## Domain models { #android_sdk_domain_models } + +Domain models (`Program`, `Event`, `Enrollment`, `TrackedEntityInstance`, `DataSet`, `DataValue`, …) are immutable Kotlin data classes. Each one offers: + +- **Accessor methods**, e.g. `event.program()`. These keep the same names and signatures they have always had, so existing code is unaffected. +- **Kotlin property getters**, e.g. `event.program`, for use from Kotlin. +- **A builder**, obtained with `Event.builder()` for a new instance or `event.toBuilder()` to derive one from an existing object. +- **The usual data class members**: `copy()`, `equals()`, `hashCode()` and `toString()`. + +```kotlin +val event = d2.eventModule().events().uid(eventUid).blockingGet() + +// Accessor, property and copy +val programUid = event.program() +val sameUid = event.program +val completed = event.copy(status = EventStatus.COMPLETED) + +// Builder +val modified = event.toBuilder().status(EventStatus.COMPLETED).build() +``` + +## Binary compatibility { #android_sdk_binary_compatibility } + +The public API surface is tracked in a dump file (`core/api/core.api`) that is validated on every build, so unintended breaking changes are caught before a release. Deprecated members are kept for at least one minor release cycle and are annotated with a `ReplaceWith` hint that Android Studio can apply automatically. diff --git a/docs/content/developer/settings.md b/docs/content/developer/settings.md index a4e836bba9a..4036b0a1c5d 100644 --- a/docs/content/developer/settings.md +++ b/docs/content/developer/settings.md @@ -2,7 +2,7 @@ Settings are downloaded on every metadata synchronization. There are different kinds of settings: -```java +```kotlin d2.settingModule() ``` @@ -20,7 +20,7 @@ This SDK downloads this configuration in every metadata synchronization and pers ### General settings { #android_sdk_general_settings } -```java +```kotlin d2.settingModule().generalSetting() ``` @@ -37,7 +37,7 @@ It gives additional information about app settings: ### Synchronization settings { #android_sdk_synchronization_settings } -```java +```kotlin d2.settingModule().synchronizationSettings() ``` @@ -47,10 +47,11 @@ If offers additional parameters to control metadata/data synchronization. - **TrackerImporterVersion:** version of the tracker importer: *V1* refers to the legacy tracker importer (`/api/trackedEntityInstances` endpoint); *V2* refers to the importer introduced in 2.37 (`/api/tracker` endpoint). - **ProgramSettings:** this section controls the program data synchronization parameters. It has a section to define global or default parameters to be used in the synchronization of all programs. Additionally it allows to set specific settings for particular programs. All these parameters are consumed by the SDK and used in the synchronization process. - **DataSetsSettings:** this section controls the aggregated data synchronization parameters. It has a section to define global or default parameters to be used in the synchronization of all dataSets. Additionally it allows to set specific setting for particular dataSets. All these parameters are consumed by the SDK and used in the synchronization process. +- **ImageSettings:** `ProgramSetting` and `DataSetSetting` include an `imageSettings()` map that defines the **upload quality** of the images captured for a given data element or tracked entity attribute. The map is keyed by the item uid, and the SDK consumes it automatically when adding a file resource. See [Image compression](#android_sdk_image_compression). ### Appearance settings { #android_sdk_appearance_settings } -```java +```kotlin d2.settingModule().appearanceSettings() ``` @@ -70,7 +71,7 @@ Most of the settings refer to visual components so they are usually consumed by ### Analytic settings { #android_sdk_analytic_settings } -```java +```kotlin d2.settingModule().analyticsSetting() d2.settingModule().analyticsSetting().teis() @@ -91,7 +92,7 @@ These settings refer to visual components so they must be consumed by the app. ### Custom intents { #android_sdk_custom_intents } -```java +```kotlin d2.settingModule().customIntents() d2.settingModule().customIntentService() @@ -108,14 +109,13 @@ Each custom intent configuration includes: The SDK provides a `CustomIntentService` to evaluate request parameters based on the current context: -```java +```kotlin // Get custom intents -List intents = d2.settingModule().customIntents() - .blockingGet(); +val intents = d2.settingModule().customIntents().suspendGet() // Evaluate parameters for a specific context (optionally with orgunit) -CustomIntentContext context = new CustomIntentContext("orgunitUid"); +val context = CustomIntentContext("orgunitUid") -Map params = d2.settingModule().customIntentService() - .blockingEvaluateRequestParams(customIntent, context); +val params = d2.settingModule().customIntentService() + .suspendEvaluateRequestParams(customIntent, context) ``` diff --git a/docs/content/developer/sms.md b/docs/content/developer/sms.md index 774fe5982b9..e2b7615fb17 100644 --- a/docs/content/developer/sms.md +++ b/docs/content/developer/sms.md @@ -8,7 +8,7 @@ For testing purposes you can use the [DHIS2 Android SMS Gateway](https://github. In the SDK, the SMS module can be accessed from `D2`. -```java +```kotlin d2.smsModule() ``` @@ -27,23 +27,23 @@ A typical workflow to use the SMS Module would be like: This a code example of a typical workflow (it used blocking methods for code simplicity): -```java +```kotlin // Enable SMS Module -d2.smsModule().configCase().setModuleEnabled(true).blockingAwait(); +d2.smsModule().configCase().setModuleEnabled(true).blockingAwait() // Sync SMS Module metadata using SMS Module -d2.smsModule().configCase().refreshMetadataIds().blockingAwait(); +d2.smsModule().configCase().refreshMetadataIds().blockingAwait() // or using metadata module -d2.metadataModule().blockingDownload(); +d2.metadataModule().blockingDownload() // Configure, at least, the gateway number. See ConfigCase for more parameters -d2.smsModule().configCase().setGatewayNumber("gateway-number").blockingAwait(); +d2.smsModule().configCase().setGatewayNumber("gateway-number").blockingAwait() // Send data. For example a tracker event: -SmsSubmitCase case = d2.smsModule().smsSubmitCase(); -Integer numSMSs = case.convertTrackerEvent("event-uid").blockingGet(); +val submitCase = d2.smsModule().smsSubmitCase() +val numSMSs = submitCase.convertTrackerEvent("event-uid").blockingGet() -case.send().blockingSubscribe(); +submitCase.send().blockingSubscribe() ``` ## SMS version { #android_sdk_sms_version } @@ -52,7 +52,7 @@ SMSs are sent in a compressed format from/to the server. This task is done by th The SDK includes the latest available version of the compression library, but there is no guarantee that the server is using it as well. For this reason, it is required to check the server version in order to enable/disable some functionalities. The SMS version in the server can be checked by: -```java +```kotlin d2.systemInfoModule().versionManager().getSmsVersion() ``` @@ -74,7 +74,7 @@ For more information, please check [SMS compression repository](https://github.c ## ConfigCase { #android_sdk_sms_config_case } -```java +```kotlin d2.smsModule().configCase() ``` @@ -90,8 +90,8 @@ There are other optional parameters to control if the SDK should wait a response Use this case to create a new submission and send it. Submission cases are not reusable and can only be sent once. To create a new submission case call the method: -```java -SmsSubmitCase case = d2.smsModule().smsSubmitCase(); +```kotlin +val submitCase = d2.smsModule().smsSubmitCase() ``` There are two options to send a SMS: ask for permissions and send the SMS directly inside the application; or get the compressed message and use an external app to send the SMS. @@ -106,11 +106,11 @@ A submission involve the following steps: As an example, sending a tracker event will be like: -```java -SmsSubmitCase case = d2.smsModule().smsSubmitCase(); -Integer numSMSs = case.convertTrackerEvent("event-uid").blockingGet(); +```kotlin +val submitCase = d2.smsModule().smsSubmitCase() +val numSMSs = submitCase.convertTrackerEvent("event-uid").blockingGet() -case.send().blockingSubscribe(); +submitCase.send().blockingSubscribe() ``` > **Important** > @@ -129,8 +129,8 @@ The methods above returns a single with the number of messages that the items takes up. An example of the use of these methods is shown in the next snippet. -```java -Single convertTask = d2.smsModule().smsSubmitCase() +```kotlin +val convertTask: Single = d2.smsModule().smsSubmitCase() .convertEnrollment("enrollment_uid") ``` @@ -138,7 +138,7 @@ To send the data converted earlier the Sdk provides a `send()` method that returns a stream of the current states. Also it is possible to get the submission id by calling the method `getSubmissionId()`. -```java +```kotlin d2.smsModule().smsSubmitCase().send() ``` @@ -149,22 +149,20 @@ the result cannot be found, it returns an error. The date accepted is the minimum date for which confirmation is going to be checked, this is used to skip old messages that may have the same submission id. -```java -d2.smsModule().smsSubmitCase().checkConfirmationSms(new Date()); +```kotlin +d2.smsModule().smsSubmitCase().checkConfirmationSms(Date()) ``` -These methods can fail and return a `PreconditionFailed` object if some -conditions are not satisfied. The preconditions errors are: - -- `NO_NETWORK`. -- `NO_CHECK_NETWORK_PERMISSION`. -- `NO_RECEIVE_SMS_PERMISSION`. -- `NO_SEND_SMS_PERMISSION`. -- `NO_GATEWAY_NUMBER_SET`. -- `NO_USER_LOGGED_IN`. -- `NO_METADATA_DOWNLOADED`. -- `SMS_MODULE_DISABLED`. +These methods fail with an error if some conditions are not satisfied. The preconditions checked before sending are: +- Network available. +- Check network state permission granted. +- Receive SMS permission granted. +- Send SMS permission granted. +- Gateway number configured. +- A user is logged in. +- SMS metadata has been downloaded. +- The SMS module is enabled. ### Sending SMS using an external application { #android_sdk_sms_external_submit_case } @@ -177,23 +175,23 @@ A submission using an external application involves the following steps: As an example, sending a tracker event will be like: -```java -SmsSubmitCase case = d2.smsModule().smsSubmitCase(); -String message = case.compressTrackerEvent("event-uid").blockingGet(); +```kotlin +val submitCase = d2.smsModule().smsSubmitCase() +val message = submitCase.compressTrackerEvent("event-uid").blockingGet() // Use an external application to send the SMS // Optionally mark the case as SENT_VIA_SMS -case.markAsSentViaSMS(); +submitCase.markAsSentViaSMS() // If you get a response from the server, you can check if the message corresponds to the case or not. -boolean isResponseMessage = case.isConfirmationMessage("sender_number", "message").blockingGet(); +val isResponseMessage = submitCase.isConfirmationMessage("sender_number", "message").blockingGet() ``` ## QrCodeCase { #android_sdk_sms_qr_code_case } -```java +```kotlin d2.smsModule().qrCodeCase() ``` @@ -213,6 +211,6 @@ Also it is possible to get compressed strings that can be used to delete events: These methods returns a `Single` with the compressed data. The next code snippet shows an example of how it can be used. -```java -Single convertTask = d2.smsModule().qrCodeCase().generateEnrollmentCode(enrollmentUid); +```kotlin +val convertTask: Single = d2.smsModule().qrCodeCase().generateEnrollmentCode(enrollmentUid) ``` diff --git a/docs/content/developer/validation-rule-engine.md b/docs/content/developer/validation-rule-engine.md index bfe7dd7b3e8..1de7eb0b513 100644 --- a/docs/content/developer/validation-rule-engine.md +++ b/docs/content/developer/validation-rule-engine.md @@ -6,10 +6,10 @@ Validation rules associated to a particular dataSet can be evaluated using the v > > Currently it is not possible to evaluate validation rules acrross different dataSets, periods, organisationUnits or attributeOptionCombos. -```java +```kotlin d2.validationModule() .validationEngine() - .validate(, , , ); + .suspendValidate(, , , ) ``` It returns a validation result containing the list of violations. Each violation includes helpful methods to get a human-readable representation of the conflict. diff --git a/docs/content/developer/workflow.md b/docs/content/developer/workflow.md index 66fb63df725..f9c391a66ba 100644 --- a/docs/content/developer/workflow.md +++ b/docs/content/developer/workflow.md @@ -18,46 +18,48 @@ The first step is to validate the server url in order to know if it is a valid D This method is not bullet-proof and might return false positives: it is difficult to tell if the url is valid or not in old DHIS2 versions. In such cases, the SDK returns it as valid to continue with the login. -````java -d2.serverModule().checkServerUrl(serverUrl) +````kotlin +d2.serverModule().suspendCheckServerUrl(serverUrl) ```` -If successful, this method will return a `LoginConfig` object, which includes useful information about the login, such as the application title, the country flag, the list of OidcProviders,... . +If successful, this method will return a `LoginConfig` object, which includes useful information about the login, such as the application title, the country flag, the list of OidcProviders, and the `isOauthEnabled` flag that tells whether the server supports the [OAuth2 login](#android_sdk_login_oauth2). ## Login/Logout { #android_sdk_login_logout } Before interacting with the server it is required to login into the DHIS 2 instance. -```java -d2.userModule().logIn(username, password, serverUrl) +```kotlin +d2.userModule().suspendLogIn(username, password, serverUrl) -d2.userModule().logOut() +d2.userModule().suspendLogOut() ``` +RxJava (`rxLogIn`, `rxLogOut`) and blocking (`blockingLogIn`, `blockingLogOut`) variants are available as well. + As of version 1.6.0, the SDK supports the storage of information for multiple accounts, which means keeping a separate database for each pair user-server. Despite of that, only one account can active (or logged in) simultaneously. That means that only one user can be authenticated in only one server at the same time. The number of maximum allowed accounts can be configured by the app (it defaults to one). A new account is automatically created after a successful login for a new pair user-server. If the number of accounts exceeds the maximum configured, the oldest account and its related database are automatically removed. -```java +```kotlin // Get the account list -d2.userModule().accountManager().getAccounts(); +d2.userModule().accountManager().getAccounts() // Get the account for current user, or null if the user is not authenticated yet -d2.userModule().accountManager().getCurrentAccount(); +d2.userModule().accountManager().getCurrentAccount() // Delete account for current user -d2.userModule().accountManager().deleteCurrentAccount(); +d2.userModule().accountManager().deleteCurrentAccount() // Get/set the maximum number of accounts -d2.userModule().accountManager().getMaxAccounts(); -d2.userModule().accountManager().setMaxAccounts(); +d2.userModule().accountManager().getMaxAccounts() +d2.userModule().accountManager().setMaxAccounts(maxAccounts) ``` The accountManager exposes an observable that emits an event when the current account is deleted. It includes the reason why the account was deleted. -```java +```kotlin // Emits an event when the current account is deleted -d2.userModule().accountManager().accountDeletionObservable(); +d2.userModule().accountManager().accountDeletionObservable() ``` After a logout, the SDK keeps track of the last logged user so that it is able to differentiate recurring and new users. It also keeps a hash of the user credentials in order to authenticate the user even when there is no connectivity. Given that said, the login method will: @@ -82,8 +84,8 @@ Logout method removes user credentials, so a new login is required before any in The SDK includes support for OpenID. To perform a login using OpenID an OpenIDConnectConfig is required: -```java -OpenIDConnectConfig openIdConfig = new OpenIDConnectConfig(clientId, redirectUri, discoveryUri, authorizationUrl, tokenUrl, prompt); +```kotlin +val openIdConfig = OpenIDConnectConfig(clientId, redirectUri, discoveryUri, authorizationUrl, tokenUrl, prompt) ``` It is mandatory to either provide a discoveryUri or both authorizationUrl and tokenUrl. @@ -92,20 +94,20 @@ The `prompt` parameter is optional and, when provided, is forwarded to the OpenI This configuration can be used to perform a login. -```java -d2.userModule().openIdHandler().logIn(openIdConfig) +```kotlin +val intentWithRequestCode = d2.userModule().openIdHandler().blockingLogIn(openIdConfig) ``` This call returns an IntentWithRequestCode which in an android app allows starting the OpenID login screen from the configuration provider. -```java -startActivityForResult(intentWithRequestCode.getIntent(), intentWithRequestCode.getRequestCode()); +```kotlin +startActivityForResult(intentWithRequestCode.intent, intentWithRequestCode.requestCode) ``` Upon a successful login, the returned intent data can be used alongside the server url to start the sync. -```java -d2.userModule().openIdHandler().handleLogInResponse(serverUrl, data, requestCode); +```kotlin +d2.userModule().openIdHandler().blockingHandleLogInResponse(serverUrl, data, requestCode) ``` It is mandatory to include the following activity in the application Manifest file: @@ -135,6 +137,143 @@ In order to configure all parameters check the following OpenID providers guidel |[Azure AD](https://docs.microsoft.com/es-es/azure/active-directory-b2c/signin-appauth-android?tabs=app-reg-ga) | |[WS02](https://medium.com/@maduranga.siriwardena/configuring-appauth-android-with-wso2-identity-server-8d378835c10a) | +Like OAuth2, an OpenID Connect account can define a PIN that acts as its offline code, so that later logins open the account without a browser. Set it right after the first login with `d2.userModule().openIdHandler().suspendSetPin(pin)`; from then on the account is handled exactly like an OAuth2 one — see [Offline login and PIN](#android_sdk_login_token_offline) and [Reacting to token expiry](#android_sdk_login_token_expiry), where the relevant error code is `OPEN_ID_CONNECT_NO_VALID_TOKEN`. + +## Login with OAuth2 { #android_sdk_login_oauth2 } + +The SDK supports logging in against DHIS2 servers that expose an OAuth2 authorization server, using the authorization code flow with PKCE and a client registered per device through Dynamic Client Registration (DCR). It is available for **DHIS2 2.43 and above**, and `LoginConfig.isOauthEnabled` (returned by `suspendCheckServerUrl`) tells whether a given server supports it. Tokens are stored per account and refreshed transparently by the SDK. + +Everything is exposed through a single handler: + +```kotlin +val oauth2 = d2.userModule().oauth2Handler() +``` + +The flow is made of **two independent ceremonies**, and most logins only need the second one: + +| | Ceremony | Produces | How often | +|---|---|---|---| +| **A** | **Device enrollment** (DCR) | a `client_id` and a device key pair | once per device and server | +| **B** | **Authorization** (code + PKCE) | an access token and a refresh token | every time tokens are needed | + +They expire on completely different schedules: the tokens from B last minutes or hours, while the registration from A survives for the lifetime of the installation. That is what makes re-login cheap — when the tokens expire the app only repeats ceremony B, and the local database is untouched. `oauth2.isDeviceRegistered()` discriminates between the two. + +Unlike the OpenID Connect flow, this one does not use AppAuth: the app opens the URL itself (a Custom Tab is recommended) and receives the redirect in its own activity. The redirect URI is fixed to `dhis2oauth://oauth` (`OAuth2Config.DEFAULT_REDIRECT_URI`); the `redirectUri` field of `OAuth2Config` is only honoured when building the logout URL. The filter is usually declared on the login activity that started the flow, with `launchMode="singleTask"` so that the browser reuses the existing instance and the pending authorization state is still there when the redirect arrives: + +```xml + + + + + + + + +``` + +A single entry point covers first login, re-login and retries. `suspendCheckServerUrl` is what discovers and stores the server OAuth2 endpoints, so it is **required before every authorization**, on re-login too: without a stored authorization endpoint `blockingLogIn` fails with an `IllegalStateException` rather than a `D2Error`. + +```kotlin +private val config = OAuth2Config(serverUrl = serverUrl) + +fun startOAuth2Login() { + d2.serverModule().suspendCheckServerUrl(serverUrl).getOrThrow() + + val url = if (oauth2.isDeviceRegistered()) { + oauth2.blockingLogIn(config) // ceremony B + } else { + oauth2.blockingBuildEnrollmentUrl(serverUrl) // ceremony A + } + openInCustomTab(url) +} +``` + +Both ceremonies redirect to the same URI, so the handler tells them apart by the parameters that came back: the enrollment response carries an initial access token, the authorization response carries an authorization code, and the authorization server reports its own failures in `error`. + +```kotlin +fun handleRedirect(uri: Uri) { + uri.getQueryParameter("error")?.let { return onAuthorizationError(it) } + + val state = uri.getQueryParameter("state") ?: return + + when { + uri.getQueryParameter("iat") != null -> { + // End of ceremony A. The device is registered, but there is no session yet: + // chain ceremony B straight away, or the user lands on an app with no session. + oauth2.blockingHandleEnrollmentResponse(serverUrl, uri.getQueryParameter("iat")!!, state) + openInCustomTab(oauth2.blockingLogIn(config)) + } + uri.getQueryParameter("code") != null -> { + // End of ceremony B. The session is open and the database is loaded. + val user = oauth2.blockingHandleLogInResponse( + existingUsername = expectedUsername, // null on a first login + serverUrl = serverUrl, + authorizationCode = uri.getQueryParameter("code")!!, + state = state, + ) + onLoggedIn(user) + } + } +} +``` + +Both handler calls verify the `state` against the one generated when the URL was built. These four entry points are blocking and hit the network — there is no `suspend` variant, so run them off the main thread. + +On a re-login the existing account database is reused (matched by normalized server URL plus username), data pending upload is preserved, the configured PIN is kept, and no logout is required first. + +### Offline login and PIN { #android_sdk_login_token_offline } + +Once an account exists, the user can open it without a browser. A token-based account can define a PIN that acts as its offline code — this applies to **both OAuth2 and OpenID Connect** accounts, which behave identically once they exist: + +```kotlin +oauth2.suspendSetPin("1234") +oauth2.suspendChangePin("1234", "5678") + +// Every later login, offline and without a browser +d2.userModule().suspendLogIn(username, pin, serverUrl) +``` + +This opens the local database without contacting the server, but it does **not** obtain new tokens. A wrong PIN reports `BAD_CREDENTIALS_OFFLINE_CODE`, distinct from the `BAD_CREDENTIALS` used for password accounts. + +### Reacting to token expiry { #android_sdk_login_token_expiry } + +While a session is open the SDK refreshes the access token on its own: any call that gets a `401` triggers a refresh with the stored refresh token and is retried, and the app sees nothing. When the refresh fails because the device is offline or the server errored, the stored tokens are kept so a later call can retry; only a token the server explicitly rejects is discarded. + +The outcomes that are not recoverable that way surface as a `D2Error`: + +| Error code | What happened | What the app must do | +|---|---|---| +| `OAUTH2_NO_VALID_TOKEN` | the refresh token was rejected, or there is no usable token left | ceremony **B** | +| `OPEN_ID_CONNECT_NO_VALID_TOKEN` | the same situation for an OpenID Connect account | a new OpenID login | +| `OAUTH2_DEVICE_NOT_REGISTERED` | the device was never enrolled, or `resetRegistration()` was called | ceremony **A**, then **B** | +| `OAUTH2_INCOMPLETE_REGISTRATION` | the registration is unusable, typically because the device key was invalidated after the user changed the device lock | `resetRegistration()`, then **A** and **B** | +| `OAUTH2_INVALID_STATE` / `OAUTH2_INVALID_IAT` | the `state` or the initial access token in the redirect did not verify | restart the ceremony | + +On these the SDK **does not close the session**: the account keeps working offline, so **being logged in is not evidence that the account can sync**. Catch the code for whichever type the account uses and send the user back through that type's first-login ceremony. + +### Logout and reset { #android_sdk_login_oauth2_logout } + +```kotlin +// Close the session. Metadata, data and the device registration are kept. +oauth2.suspendLogOut() + +// Optionally send the user through the server logout page as well, to drop the browser session. +openInCustomTab(oauth2.blockingBuildLogoutUrl(config)) + +// Discard the device registration and delete its key. The next login needs ceremony A again. +oauth2.resetRegistration() +``` + +`suspendLogOut` is the everyday operation: it clears the credentials so a new login is required, and the next one is a plain ceremony B. `resetRegistration` is the recovery hatch — use it when the registration itself is broken, not on logout. + +Finally, keep the server URL consistent. The SDK normalizes it (case of protocol and domain, trailing slash, a trailing `/api`), so cosmetic differences are fine, but `http` and `https` are *not* equivalent and resolve to different accounts. + ## Two-Factor Authentication {#android_sdk_two_factor_authentication} The SDK now lets your app enable, disable, enter 2FA enrollment mode and query TOTP-based 2FA via `TwoFactorAuthManager`: @@ -172,10 +311,12 @@ Behavior notes: Metadata synchronization is usually the first step after login. It fetches and persists the metadata needed by the current user. To launch metadata synchronization we must execute: -```java -d2.metadataModule().download(); +```kotlin +d2.metadataModule().blockingDownload() ``` +`d2.metadataModule().download()` returns an `Observable` if the app wants to follow the progress of the synchronization. + In order to save bandwidth usage and storage space, the SDK does not synchronize all the metadata in the server but a subset. This subset is defined as the metadata required by the user in order to perform data entry tasks: render programs and datasets, execute program rules, evaluate in-line program indicators, etc. Based on that, metadata sync includes the following elements: @@ -213,7 +354,7 @@ This partial metadata synchronization may expose server-side misconfiguration is The SDK does not fail the synchronization, but it stores the errors in a table for inspection. These errors can be accessed by: -```java +```kotlin d2.maintenanceModule().foreignKeyViolations() ``` @@ -269,20 +410,22 @@ skipped and it will continue with the next pages. This is an example of how it can be used. -```java +```kotlin d2.trackedEntityModule().trackedEntityInstanceDownloader() .[filters] .[limits] - .download() + .flowDownload() ``` -```java +```kotlin d2.eventModule().eventDownloader() .[filters] .[limits] - .download() + .flowDownload() ``` +`flowDownload()` returns a `Flow` so the app can follow the progress of the download. The downloaders also expose `blockingDownload()` and `rxDownload()`. + Currently, it is possible to specify the next filters: - `byProgramUid()`. Filters by program uid and downloads the not synced @@ -308,20 +451,22 @@ These limits can also be combined with each other. Other properties: - `overwrite()`. By default, the SDK does not overwrite data in the device in a status other than SYNCED. If you want to overwrite the data in the device, no matter the status it has, add this method to the query chain. +- `downloadFileResources()`. The downloaders accept this modifier to download the file resources referenced by the downloaded payload in the same call, instead of issuing a separate query. The download is scoped to the payload being downloaded, so synchronizing one program no longer pulls the pending files of the other programs. The next snippet of code shows an example of the TrackedEntityInstanceDownloader usage. -```java +```kotlin d2.trackedEntityModule().trackedEntityInstanceDownloader() .byProgramUid("program-uid") .limitByOrgunit(true) .limitByProgram(true) .limit(50) - .download() + .downloadFileResources(true) + .flowDownload() ``` -Additionally, if you want the images associated to `Image` data values available to be downloaded in the device, you must download them. See [*Dealing with FileResources*](#android_sdk_file_resources) section for more details. +Alternatively, the images and files associated to `Image` and `File` values can be downloaded in a separate query. See [*Dealing with FileResources*](#android_sdk_file_resources) section for more details. ### Tracker data search @@ -335,11 +480,11 @@ The tracked entity instance search is a powerful tool that follows a builder pattern and allows the download of tracked entity instances filtering by **different parameters**. -```java +```kotlin d2.trackedEntityModule().trackedEntitySearch() .[repository mode] .[filters] - .get() + .suspendGet() ``` The source where the TEIs are retrieved from is defined by the **repository mode**. @@ -366,11 +511,11 @@ Additionally, the repository offers different strategies to fetch data: If this method is called several times, conditions are appended with an AND connector. For example: - ```java + ```kotlin d2.trackedEntityModule().trackedEntitySearch() .byAttribute("uid1").eq("value1") .byAttribute("uid2").eq("value2") - .get() + .suspendGet() ``` That means that the instance must have attribute `uid1` with value @@ -380,11 +525,11 @@ Additionally, the repository offers different strategies to fetch data: method is called several times, conditions are appended with an AND connector. For example: - ```java + ```kotlin d2.trackedEntityModule().trackedEntitySearch() .byFilter("uid1").eq("value1") .byFilter("uid2").eq("value2") - .get() + .suspendGet() ``` That means that the instance must have attribute `uid1` with value @@ -420,7 +565,7 @@ Additionally, the repository offers different strategies to fetch data: Example: -```java +```kotlin d2.trackedEntityModule().trackedEntitySearch() .byOrgUnits().eq("orgunitUid") .byOrgUnitMode().eq(OrganisationUnitMode.DESCENDANTS) @@ -436,10 +581,10 @@ to fully download them using the `byUid()` filter of the `TrackedEntityInstanceD It could happen that you add filters to the query repository in different parts of the application and you don't have a clear picture about the filters applied, specially when using working lists because they add a set of parameters. In order to solve this, you can access the filter scope at any moment in the repository: -```java +```kotlin d2.trackedEntityModule().trackedEntitySearch() .[ filters ] - .getScope(); + .getScope() ``` In addition to the standard `getPaged(int)` and `getDataSource()` methods that are available in all the repositories, the TrackedEntitySearch repository exposes a method to wrap the response in a `Result` object: the `getResultDataSource()`. This method is kind of a workaround to deal with the lack of error management in the Version 2 of the Android Paging Library (it is hardly improved in version 3). Using this dataSource you can catch search errors, such as "Min attributes required" or "Max tei count reached". @@ -453,23 +598,25 @@ There are three concepts related to building a predifined filter for tracker obj - **EventFilters**: they define filters to be used against Event objects. - **ProgramStageWorkingList**: they define filters to be used against TrackedEntity objects and they add support to filter by event-related data. It is mandatory to specify a particular ProgramStage. +Each attribute or data value condition within these filters may define an `isEmpty` property, which matches the values that are missing or blank when true, and the values that are present and not blank when false. The SDK evaluates it both in offline and online queries. + As usual, they have their own collection repository and can be applied in "search" repositories. For example: -```java +```kotlin // Get the filters -List filters = d2.trackedEntityModule().trackedEntityInstanceFilters().blockingGet(); -List filters = d2.eventModule().eventFilters().blockingGet(); -List workingLists = d2.programModule().programStageWorkingLists().blockingGet(); +val teiFilters = d2.trackedEntityModule().trackedEntityInstanceFilters().suspendGet() +val eventFilters = d2.eventModule().eventFilters().suspendGet() +val workingLists = d2.programModule().programStageWorkingLists().suspendGet() // Apply the filters d2.trackedEntityModule().trackedEntitySearch() .byTrackedEntityInstanceFilter().eq("filterUid") .byProgramStageWorkingList().eq("workingListUid") - .get() + .suspendGet() d2.eventModule().eventQuery() .byEventFilter().eq("filterUid") - .get(); + .suspendGet() ``` ### Ownership @@ -478,17 +625,17 @@ The concept of ownership is supported in the SDK. In short, each pair trackedEnt You can get the program owners for each trackedEntityInstance by using the repository: -```java +```kotlin d2.trackedEntityModule().trackedEntityInstances() .withProgramOwners() - .get(); + .suspendGet() ``` Also, you can permanently transfer the ownership by using the OwnershipManager. This transfer will be automatically uploaded to the server in the next synchronization. -```java +```kotlin d2.trackedEntityModule().ownershipManager() - .transfer(teiUid, programUid, ownerOrgunit); + .suspendTransfer(teiUid, programUid, ownerOrgunit) ``` ### Break the glass @@ -501,25 +648,25 @@ The "Break the glass" concept is based on the ownership of the pair trackedEntit 4. If so, request the ownwership using the ownership module (see code snippet below). 5. Try again the query in step 2. -```java -TrackedEntityInstanceDownloader teiRepository = d2.trackedEntityModule().trackedEntityInstanceDownloader() +```kotlin +val teiRepository = d2.trackedEntityModule().trackedEntityInstanceDownloader() .byUid().eq(teiUid) - .byProgramUid(programUid); + .byProgramUid(programUid) try { - teiRepository.blockingDownload(); -} catch (RuntimeException e) { - if (e.getCause() instanceof D2Error && - ((D2Error) e.getCause()).errorCode() == D2ErrorCode.OWNERSHIP_ACCESS_DENIED) { + teiRepository.blockingDownload() +} catch (e: RuntimeException) { + val cause = e.cause + if (cause is D2Error && cause.errorCode() == D2ErrorCode.OWNERSHIP_ACCESS_DENIED) { // Show a dialog to the user and capture the reason to break the glass - String reason = "Reason to break the glass"; + val reason = "Reason to break the glass" // Break the glass d2.trackedEntityModule().ownershipManager() - .blockingBreakGlass(teiUid, programUid, reason); + .suspendBreakGlass(teiUid, programUid, reason) // Download again - teiRepository.blockingDownload(); + teiRepository.blockingDownload() } else { // Deal with other exceptions } @@ -540,19 +687,19 @@ In general, there are two different cases to manage data creation/edition/deleti And in code this would look like: -```java -String eventUid = d2.eventModule().events().add( - EventCreateProjection.create("enrollment", "program", "programStage", "orgUnit", "attCombo")); +```kotlin +val eventUid = d2.eventModule().events().suspendAdd( + EventCreateProjection.create("enrollment", "program", "programStage", "orgUnit", "attCombo")) -d2.eventModule().events().uid(eventUid).setStatus(COMPLETED); +d2.eventModule().events().uid(eventUid).setStatus(EventStatus.COMPLETED) ``` **Non-identifiable objects** (TrackedEntityAttributeValue, TrackedEntityDataValue). These repositories have a `value()` method that gives you access to edition methods for a single object. The parameters accepted by this method are the parameters that unambiguously identify a value. For example, writing a TrackedEntityDataValue would be like: -```java -d2.trackedEntityModule().trackedEntityDataValues().value(eventUid, dataElementid).set(“5”); +```kotlin +d2.trackedEntityModule().trackedEntityDataValues().value(eventUid, dataElementid).suspendSet("5") ``` Data values of type `Image` involve an additional step to create/update/read the associated file resource. More details in the [*Dealing with FileResources*](#android_sdk_file_resources) section below. @@ -569,12 +716,12 @@ The restrictions that must be followed by the app are these ones: ### Tracker data upload -TrackedEntityInstance and Event repositories have an `upload()` method to upload Tracker data and Event data (without registration) respectively. If the repository scope has been reduced by filter methods, only filtered objects will be uploaded. +TrackedEntityInstance and Event repositories have an `upload` method to upload Tracker data and Event data (without registration) respectively. If the repository scope has been reduced by filter methods, only filtered objects will be uploaded. -```java +```kotlin d2.( trackedEntityModule() | eventModule() ) .[ filters ] - .upload(); + .flowUpload() ``` Data whose state is `ERROR` or `WARNING` cannot be uploaded. It is required to solve the conflicts before attempting a new upload: this means to do a modification in the problematic data, which forces their state back to `TO_UPDATE`. @@ -585,7 +732,7 @@ As of version 2.37, a new tracker importer was introduced (`/api/tracker` endpoi Server response is parsed to ensure that data has been correctly uploaded to the server. In case the server response includes import conflicts, these conflicts are stored in the database, so the app can check them and take an action to solve them. -```java +```kotlin d2.importModule().trackerImportConflicts() ``` @@ -599,20 +746,20 @@ Tracked Entity Attributes configured as **unique** and **automatically generated The app is responsible for reserving generated values before going offline. This can be triggered by: -```java +```kotlin // Reserve values for all the unique and automatically generated trackedEntityAttributes. -d2.trackedEntityModule().reservedValueManager().downloadAllReservedValues(numValuesToFillUp) +d2.trackedEntityModule().reservedValueManager().flowDownloadAllReservedValues(numValuesToFillUp) // Reserve values for a particular trackedEntityAttribute. -d2.trackedEntityModule().reservedValueManager().downloadReservedValues("attributeUid", numValuesToFillUp) +d2.trackedEntityModule().reservedValueManager().flowDownloadReservedValues("attributeUid", numValuesToFillUp) ``` Depending on how long the app expects to be offline, it can decide the quantity of values to reserve. In case the attribute pattern is dependant on the orgunit code, the SDK will reserve values for all the relevant orgunits. More details about the logic in Javadoc. Reserved values can be obtained by: -```java -d2.trackedEntityModule().reservedValueManager().getValue("attributeUid", "orgunitUid") +```kotlin +d2.trackedEntityModule().reservedValueManager().suspendGetValue("attributeUid", "orgunitUid") ``` ### Tracker data: relationships @@ -631,7 +778,7 @@ Relationships are accessed by using the relationships module. Query relationships associated to a TEI. -```java +```kotlin d2.relationshipModule().relationships().getByItem( RelationshipHelper.teiItem("trackedEntityInstanceUid") ) @@ -639,7 +786,7 @@ d2.relationshipModule().relationships().getByItem( Query relationships associated to an enrollment. -```java +```kotlin d2.relationshipModule().relationships().getByItem( RelationshipHelper.enrollmentItem("enrollmentUid") ) @@ -647,7 +794,7 @@ d2.relationshipModule().relationships().getByItem( Or query relationships associated to an event. -```java +```kotlin d2.relationshipModule().relationships().getByItem( RelationshipHelper.eventItem("eventUid") ) @@ -655,27 +802,27 @@ d2.relationshipModule().relationships().getByItem( In the same module you can create new relationships of any type using the `RelationshipHelper` to model the relationship and adding them later to the relationship collection repository: -```java -Relationship relationship = RelationshipHelper.teiToTeiRelationship("fromTEIUid", "toTEIUid", "relationshipTypeUid"); +```kotlin +val relationship = RelationshipHelper.teiToTeiRelationship("fromTEIUid", "toTEIUid", "relationshipTypeUid") -d2.relationshipModule().relationships().add(relationship); +d2.relationshipModule().relationships().suspendAdd(relationship) ``` If the related trackedEntityInstance does not exist yet and there are attribute values that must be inherited, you can use the following method to inherit attribute values from one TEI to another in the context of a certain program. Only those attribute marked as `inherit` will be inherited. -```java +```kotlin d2.trackedEntityModule().trackedEntityInstanceService() - .inheritAttributes("fromTeiUid", "toTeiUid", "programUid"); + .blockingInheritAttributes("fromTeiUid", "toTeiUid", "programUid") ``` In order to access the `dataElements` and `attributes` associated to a `relationshipConstraint`, they can be accessed through the `trackerDataView` property as in the following examples: -```java -relationshipType.toConstraint().trackerDataView().attributes(); +```kotlin +relationshipType.toConstraint().trackerDataView().attributes() ``` -```java -relationshipType.toConstraint().trackerDataView().dataElements(); +```kotlin +relationshipType.toConstraint().trackerDataView().dataElements() ``` ## Aggregated data { #android_sdk_aggregated_data } @@ -686,8 +833,8 @@ relationshipType.toConstraint().trackerDataView().dataElements(); > > See [Settings App](#android_sdk_settings_app) section to know how this application can be used to control synchronization parameters. -```java -d2.aggregatedModule().data().download() +```kotlin +d2.aggregatedModule().data().flowDownload() ``` By default, the SDK downloads **aggregated data values**, **dataset @@ -746,8 +893,8 @@ In order to write data values or data set complete registrations, it's mandatory the provided period ids must be already present in that table, otherwise, a Foreign Key error will be thrown. To prevent that situation, the `PeriodHelper` is exposed inside the `PeriodModule`. Before adding aggregated data related to a dataSet, the following method must be called: -```java -Single> periods = d2.periodModule().periodHelper().getPeriodsForDataSet("dataSetUid"); +```kotlin +val periods: List = d2.periodModule().periodHelper().blockingGetPeriodsForDataSet("dataSetUid") ``` This will ensure that: @@ -759,11 +906,11 @@ This will ensure that: DataValueCollectionRepository has a `value()` method that gives access to edition methods. The parameters accepted by this method are the parameters that unambiguously identify a value. -```java -DataValueObjectRepository valueRepository = d2.dataValueModule().dataValues() - .value("periodId", "orgunitId", "dataElementId", "categoryOptionComboId", "attributeOptionComboId"); +```kotlin +val valueRepository = d2.dataValueModule().dataValues() + .value("periodId", "orgunitId", "dataElementId", "categoryOptionComboId", "attributeOptionComboId") -valueRepository.set("value") +valueRepository.suspendSet("value") ``` #### Data set complete registration @@ -775,53 +922,53 @@ new completions and delete them. To add a new data set complete registration is available an `add()` method: -```java +```kotlin d2.dataSetModule().dataSetCompleteRegistrations() - .add(dataSetCompleteRegistration); + .suspendAdd(dataSetCompleteRegistration) ``` In order to remove them from the database, the repository has a `value()` -method that gives access to deletion methods (`delete()` and -`deleteIfExist()`). The parameters accepted by this method are the -parameters that unambiguously identify the data set complete -registration. +method that gives access to deletion methods (`rxDelete()` and +`rxDeleteIfExist()`, plus their `blocking` and `suspend` variants). The +parameters accepted by this method are the parameters that unambiguously +identify the data set complete registration. -```java +```kotlin d2.dataSetModule().dataSetCompleteRegistrations() .value("periodId", "orgunitId", "dataSetUid","attributeOptionCombo") - .delete() + .suspendDelete() ``` ### Aggregated data upload -DataValueCollectionRepository has an `upload()` method to upload aggregated data values. +DataValueCollectionRepository has an `upload` method to upload aggregated data values. `flowUpload()` returns a `Flow`; `blockingUpload()` and `rxUpload()` are also available. -```java -d2.dataValueModule().dataValues().upload(); +```kotlin +d2.dataValueModule().dataValues().flowUpload() ``` ### DataSet instances A DataSetInstance in the SDK is a handy representation of the existing aggregated data. A DataSetInstance represents a unique combination of DataSet - Period - Orgunit - AttributeOptionCombo and includes extra information like sync state, value count or displayName for some properties. -```java +```kotlin d2.dataSetModule().dataSetInstances() .[ filters ] - .get() + .suspendGet() // For example d2.dataSetModule().dataSetInstances() .byDataSetUid().eq("datasetUid") .byOrganisationUnitUid().eq("orgunitUid") - .byPeriod().in("201901", "201902") - .get(); + .byPeriod().`in`("201901", "201902") + .suspendGet() ``` If you only need a high level overview of the aggregated data status, you can use the repository `DataSetInstanceSummary`. It accepts the same filters and returns a count of `DataSetInstance` for each combination. ## Dealing with FileResources { #android_sdk_file_resources } -The SDK offers a module (the `FileResourceModule`) and two helpers (the `FileResourceDirectoryHelper` and `FileResizerHelper`) that allow to work with files. +The SDK offers a module (the `FileResourceModule`) and three helpers (the `FileResourceDirectoryHelper`, the `FileResizerHelper` and the `FileCompressionHelper`) that allow to work with files. In the context of a mobile connection, dealing with fileResources could be high bandwidth consuming. For this reason, fileResources are not downloaded by default when downloading data and they must be explicitly downloaded if wanted. The recommendation is to download to fileResources only if it is important to have them in the device. If they are not downloaded, there is no negative consequence in terms of data integrity; the only consequence is that they are not available in the device. @@ -834,14 +981,14 @@ This module contains methods to download the file resources associated with the - **File resources download**. The `fileResourceDownloader()` offers methods to filter the fileResources we want to download. It will search for values that match the filters and whose file resource has not been previously downloaded. - ```kt + ```kotlin d2.fileResourceModule().fileResourceDownloader() .byDomainType().eq(FileResourceDomainType.DATA_VALUE) .byDataDomainType().eq(FileResourceDataDomainType.TRACKER) .byElementType().eq(FileResourceElementType.DATA_ELEMENT) - .byValueType().in(FileResourceValueType.IMAGE, FileResourceValueType.FILE_RESOURCE) + .byValueType().`in`(FileResourceValueType.IMAGE, FileResourceValueType.FILE_RESOURCE) .byMaxContentLength().eq(2000000) - .download() + .flowDownload() ``` The SDK has a default maxContentLength of 6000000. @@ -849,23 +996,68 @@ The `fileResourceDownloader()` offers methods to filter the fileResources we wan After downloading the files, you can obtain the different file resources downloaded through the repository. - **File resource collection repository**. -Through this repository it is possible to request files, save new ones and upload them to the server. +Through this repository it is possible to request files and save new ones. - **Get**. It behaves in a similar fashion to any other SDK repository. It allows to get collections by applying different filters if desired. - ```java + ```kotlin d2.fileResourceModule().fileResources() .[ filters ] - .get() + .suspendGet() ``` - - **Add**. To save a file you have to add it using the `add()` method of the repository by providing an object of type `File`. The `add()` method will return the uid that was generated when adding the file. This uid should be used to update the tracked entity attribute value or the tracked entity data value associated with the file resource. - - ```java - d2.fileResourceModule().fileResources() - .add(file); // Single The fileResource uid + - **Add**. To save a file it has to added it using the `processAndAdd()` method of the repository, providing an object of type `File` and a `ResourceContext`. The method returns the uid that was generated when adding the file. This uid should be used to update the tracked entity attribute value or the tracked entity data value associated with the file resource. + + ```kotlin + // An image captured in the context of a program + val uid = d2.fileResourceModule().fileResources().blockingProcessAndAdd( + file, + ResourceContext.ImageContext.ProgramImageContext(programUid, dataElementUid), + ) + + // An image captured in the context of a dataSet + val uid = d2.fileResourceModule().fileResources().blockingProcessAndAdd( + file, + ResourceContext.ImageContext.DatasetImageContext(dataSetUid, dataElementUid), + ) + + // A non-image file: stored as it is + val uid = d2.fileResourceModule().fileResources().blockingProcessAndAdd( + file, + ResourceContext.FileContext, + ) ``` + `rxProcessAndAdd()` and `suspendProcessAndAdd()` are available as well. + +### Image compression { #android_sdk_image_compression } + +Images added through `processAndAdd()` with an `ImageContext` are compressed before being stored, so that uploads stay reasonably sized. The behaviour is driven by the **upload quality** configured per data element or attribute in the Android Settings web app (see [Settings app](#android_sdk_settings_app)): + +| `UploadQuality` | Behaviour | +|-----------------|-----------| +| `DEFAULT` | The image is compressed towards a target size of 600 KB, keeping its proportions. This is the value used when nothing is configured. | +| `ORIGINAL` | The image is stored exactly as provided, with no compression. | + +Compression keeps the format when the server supports it, converting to JPEG (or PNG, when the image uses transparency) otherwise. If the image cannot be decoded, or compressing it does not make it smaller, the original file is used and a warning is logged. + +The configured value can also be read directly. Both methods are `suspend` functions: + +```kotlin +val programQuality = d2.settingModule().programSetting() + .getImageQualityFromProgramSettings(programUid, dataElementUid) + +val dataSetQuality = d2.settingModule().dataSetSetting() + .getImageQualityFromDataSetSetting(dataSetUid, dataElementUid) +``` + +The compression itself is also exposed as a helper, in case the app needs to compress a file outside the file resource flow: + +```kotlin +val compressed = FileCompressionHelper.compressFile(file) // 600 KB target +val smaller = FileCompressionHelper.compressFile(file, 200 * 1024L) // custom target +``` + ### File resizer helper The Sdk provides a helper to resize image files (`FileResizerHelper`). This helper contains a `resizeFile()` method that accepts the file you want to reduce and the dimension to which you want to reduce it.