diff --git a/RELEASE.md b/RELEASE.md index d6427abcdc4..8d34896494e 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -1,7 +1,101 @@ -# Release notes - Android App for DHIS2 - 3.4.0.1 +# Release notes - Android App for DHIS2 - 3.4.1 -This patch release includes a hotfix. +### Bug -## Bugs fixed +[ANDROAPP-6516](https://dhis2.atlassian.net/browse/ANDROAPP-6516) Values in events and program stages not being updated when syncing with server from them -* [ANDROAPP-7634](https://dhis2.atlassian.net/browse/ANDROAPP-7634) SMS module is accidentally enabled when ASWA is installed +[ANDROAPP-6611](https://dhis2.atlassian.net/browse/ANDROAPP-6611) Synchronization does not automatically start on re-connection + +[ANDROAPP-7238](https://dhis2.atlassian.net/browse/ANDROAPP-7238) Analytics configured with data type int in web show 1 decimal in app + +[ANDROAPP-7287](https://dhis2.atlassian.net/browse/ANDROAPP-7287) Flicker when no teis in tei list + +[ANDROAPP-7352](https://dhis2.atlassian.net/browse/ANDROAPP-7352) Delete TEI/Delete Enrollment is not showing in the menu with the ALL authority + +[ANDROAPP-7364](https://dhis2.atlassian.net/browse/ANDROAPP-7364) Server URL not well formed error + +[ANDROAPP-7398](https://dhis2.atlassian.net/browse/ANDROAPP-7398) Wrong Snackbar color + +[ANDROAPP-7406](https://dhis2.atlassian.net/browse/ANDROAPP-7406) Cards are not always displayed in map view + +[ANDROAPP-7412](https://dhis2.atlassian.net/browse/ANDROAPP-7412) Offline Biometric Login is not accessible + +[ANDROAPP-7431](https://dhis2.atlassian.net/browse/ANDROAPP-7431) username does not trim trailing white spaces + +[ANDROAPP-7439](https://dhis2.atlassian.net/browse/ANDROAPP-7439) Custom label \(at least enrollment\) is not displayed when editing a TE + +[ANDROAPP-7446](https://dhis2.atlassian.net/browse/ANDROAPP-7446) Incorrect flow to create TEI: Display front page list and Allow user to create a TEI without searching + +[ANDROAPP-7458](https://dhis2.atlassian.net/browse/ANDROAPP-7458) Cannot select lower options in large option sets with checkbox or radio button configuration + +[ANDROAPP-7499](https://dhis2.atlassian.net/browse/ANDROAPP-7499) New Network Status Provider does not handle VPNs and local connections + +[ANDROAPP-7520](https://dhis2.atlassian.net/browse/ANDROAPP-7520) SSO: Logout does not end IdP session + +[ANDROAPP-7523](https://dhis2.atlassian.net/browse/ANDROAPP-7523) Cursor moves to the end when updating value + +[ANDROAPP-7527](https://dhis2.atlassian.net/browse/ANDROAPP-7527) NullPointerException: The callable returned a null value + +[ANDROAPP-7528](https://dhis2.atlassian.net/browse/ANDROAPP-7528) ApplicationNotResponding: ANR + +[ANDROAPP-7531](https://dhis2.atlassian.net/browse/ANDROAPP-7531) Program rule: not triggered unless selection is made in option set + +[ANDROAPP-7532](https://dhis2.atlassian.net/browse/ANDROAPP-7532) \[ANR\] In TeiDashboard with displayAnalytics\(\) + +[ANDROAPP-7533](https://dhis2.atlassian.net/browse/ANDROAPP-7533) Working list results fluctuating and loading inconsistently + +[ANDROAPP-7541](https://dhis2.atlassian.net/browse/ANDROAPP-7541) AppTheme color not changing text colors + +[ANDROAPP-7544](https://dhis2.atlassian.net/browse/ANDROAPP-7544) \[Mobile-UI\] When the DateTime input has focus a small section of the supporting Text background color is shown + +[ANDROAPP-7546](https://dhis2.atlassian.net/browse/ANDROAPP-7546) Error when assigning more than one option to multi-select field in the Android capture app + +[ANDROAPP-7564](https://dhis2.atlassian.net/browse/ANDROAPP-7564) Description of assigned \(disabled\) data elements are not shown in the android capture + +[ANDROAPP-7565](https://dhis2.atlassian.net/browse/ANDROAPP-7565) Crash - toggle between orientation in events + +[ANDROAPP-7566](https://dhis2.atlassian.net/browse/ANDROAPP-7566) Numeric value type in search screen does not allow decimals + +[ANDROAPP-7567](https://dhis2.atlassian.net/browse/ANDROAPP-7567) Unsupported fields are visible in search screen + +[ANDROAPP-7568](https://dhis2.atlassian.net/browse/ANDROAPP-7568) Polygons are not correctly stored in the device + +[ANDROAPP-7572](https://dhis2.atlassian.net/browse/ANDROAPP-7572) New events use enrollment OU instead of ownership OU after TEI transfer + +[ANDROAPP-7574](https://dhis2.atlassian.net/browse/ANDROAPP-7574) Crash whilst sync \(could not replicate\) + +[ANDROAPP-7582](https://dhis2.atlassian.net/browse/ANDROAPP-7582) Unable to close bottomsheet in very long org unit list + +[ANDROAPP-7588](https://dhis2.atlassian.net/browse/ANDROAPP-7588) Sync message not consistent when offline + +[ANDROAPP-7590](https://dhis2.atlassian.net/browse/ANDROAPP-7590) Sync error due to program rules the assign value of calculated value + +[ANDROAPP-7601](https://dhis2.atlassian.net/browse/ANDROAPP-7601) Change orientation: Main content area goes blank/white + +[ANDROAPP-7603](https://dhis2.atlassian.net/browse/ANDROAPP-7603) Allow screenshots and screen sharing in Training APK + +[ANDROAPP-7613](https://dhis2.atlassian.net/browse/ANDROAPP-7613) IllegalArgumentException: Can't represent a width of 0 and height of 276000 in Constraints + +[ANDROAPP-7614](https://dhis2.atlassian.net/browse/ANDROAPP-7614) IllegalStateException: No database is currently activated. + +[ANDROAPP-7619](https://dhis2.atlassian.net/browse/ANDROAPP-7619) \[ANR\] Home filters + +[ANDROAPP-7632](https://dhis2.atlassian.net/browse/ANDROAPP-7632) Conscrypt crashing for API 37 + +[ANDROAPP-7635](https://dhis2.atlassian.net/browse/ANDROAPP-7635) Cannot access TEI as it is marked incorrectly as online + +[ANDROAPP-7637](https://dhis2.atlassian.net/browse/ANDROAPP-7637) Programs tab appears when analytics is not configured + +[ANDROAPP-7646](https://dhis2.atlassian.net/browse/ANDROAPP-7646) Local search results is not returned if remote request fails + +[ANDROAPP-7647](https://dhis2.atlassian.net/browse/ANDROAPP-7647) Past results are not cleared when a new search is made + +[ANDROAPP-7658](https://dhis2.atlassian.net/browse/ANDROAPP-7658) Program Rule Errors do not prevent syncing of data + +[ANDROAPP-7661](https://dhis2.atlassian.net/browse/ANDROAPP-7661) Race condition to sync images using granular sync + +[ANDROAPP-7668](https://dhis2.atlassian.net/browse/ANDROAPP-7668) Images/files \(FileResource\) sometimes not downloaded during granular sync — subsequent edit uploads an empty image Data Element overwriting the image in server + +[ANDROAPP-7674](https://dhis2.atlassian.net/browse/ANDROAPP-7674) Crash opening map in tablet from teidashboard + +[ANDROAPP-7694](https://dhis2.atlassian.net/browse/ANDROAPP-7694) App crash on logout and \(occasionally\) delete account \ 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 57f243124e9..2ae634a7635 100644 --- a/app/src/androidTest/java/org/dhis2/common/BaseRobot.kt +++ b/app/src/androidTest/java/org/dhis2/common/BaseRobot.kt @@ -11,6 +11,7 @@ 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.IdlingResourceTimeoutException import androidx.test.espresso.NoMatchingViewException import androidx.test.espresso.UiController import androidx.test.espresso.ViewAction @@ -130,9 +131,11 @@ open class BaseRobot { } catch (e: Exception) { - if (tries == maxTries) { - throw e - } + if (tries == maxTries) throw e + // Retrying on idling timeouts blocks ~26s per attempt and leaves + // IdlingResourceRegistry's internal callback registered, corrupting state + // for subsequent tests. Fail fast so Espresso cleans up normally. + if (e is IdlingResourceTimeoutException || e.cause is IdlingResourceTimeoutException) throw e sleep(waitMillisPerTry) } diff --git a/app/src/androidTest/java/org/dhis2/usescases/BaseTest.kt b/app/src/androidTest/java/org/dhis2/usescases/BaseTest.kt index b2d59d43d24..87f38c22af6 100644 --- a/app/src/androidTest/java/org/dhis2/usescases/BaseTest.kt +++ b/app/src/androidTest/java/org/dhis2/usescases/BaseTest.kt @@ -3,6 +3,7 @@ package org.dhis2.usescases import android.content.Context import android.os.Build import androidx.test.espresso.IdlingRegistry +import androidx.test.espresso.idling.CountingIdlingResource import androidx.test.espresso.intent.Intents import androidx.test.platform.app.InstrumentationRegistry import androidx.test.rule.GrantPermissionRule @@ -132,6 +133,11 @@ open class BaseTest { } private fun unregisterCountingIdlingResource() { + idlingResources.filterIsInstance().forEach { resource -> + while (!resource.isIdleNow) { + resource.decrement() + } + } IdlingResourceProvider.idlingResource = NoOpIdlingResource IdlingRegistry.getInstance() .unregister(*idlingResources.toTypedArray()) 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 1e67d65ca19..05fb9d383e8 100644 --- a/app/src/androidTest/java/org/dhis2/usescases/teidashboard/TeiDashboardTest.kt +++ b/app/src/androidTest/java/org/dhis2/usescases/teidashboard/TeiDashboardTest.kt @@ -24,6 +24,7 @@ import org.dhis2.usescases.teidashboard.robot.noteRobot import org.dhis2.usescases.teidashboard.robot.teiDashboardRobot import org.hisp.dhis.android.core.mockwebserver.ResponseController import org.junit.Assume +import org.junit.Ignore import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith @@ -200,6 +201,7 @@ class TeiDashboardTest : BaseTest() { prepareTeiOpenedForReferralProgrammeAndLaunchActivity(rule) teiDashboardRobot(composeTestRule) { + waitUntilActivityVisible() clickOnMenuMoreOptions() clickOnTimelineEvents() clickOnFab() @@ -233,6 +235,7 @@ class TeiDashboardTest : BaseTest() { prepareTeiOpenedWithNoPreviousEventProgrammeAndLaunchActivity(rule) teiDashboardRobot(composeTestRule) { + waitUntilActivityVisible() clickOnMenuMoreOptions() clickOnTimelineEvents() clickOnFab() @@ -259,6 +262,7 @@ class TeiDashboardTest : BaseTest() { val babyPostNatal = "Baby Postnatal" teiDashboardRobot(composeTestRule) { + waitUntilActivityVisible() clickOnMenuMoreOptions() clickOnTimelineEvents() clickOnEventWithTitle(babyPostNatal) @@ -335,6 +339,7 @@ class TeiDashboardTest : BaseTest() { prepareTeiToEnrollToOtherProgramAndLaunchActivity(rule) teiDashboardRobot(composeTestRule) { + waitUntilActivityVisible() clickOnMenuMoreOptions() clickOnTimelineEvents() clickOnMenuMoreOptions() @@ -365,6 +370,7 @@ class TeiDashboardTest : BaseTest() { } + @Ignore("Default analytics have been removed. We need to add some analytics to the database") @Test fun shouldShowAnalytics() { mockWebServerRobot.addResponse( 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 7ffebe013fb..cf15d25d364 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 @@ -264,6 +264,8 @@ class TeiDashboardRobot(val composeTestRule: ComposeTestRule) : BaseRobot() { composeTestRule.onNodeWithText(timelineLabel).performClick() } catch (e: NoMatchingViewException) { checkIfGroupedEventsIsVisible() + } finally { + composeTestRule.waitForIdle() } } } diff --git a/app/src/main/java/org/dhis2/App.java b/app/src/main/java/org/dhis2/App.java deleted file mode 100644 index d1fc1043f4e..00000000000 --- a/app/src/main/java/org/dhis2/App.java +++ /dev/null @@ -1,360 +0,0 @@ -package org.dhis2; - -import static org.dhis2.utils.analytics.AnalyticsConstants.DATA_STORE_ANALYTICS_PERMISSION_KEY; - -import android.content.Context; -import android.os.Looper; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.lifecycle.Lifecycle; -import androidx.lifecycle.LifecycleObserver; -import androidx.lifecycle.OnLifecycleEvent; -import androidx.lifecycle.ProcessLifecycleOwner; - -import org.dhis2.commons.di.dagger.PerActivity; -import org.dhis2.commons.di.dagger.PerServer; -import org.dhis2.commons.di.dagger.PerUser; -import org.dhis2.commons.dialogs.calendarpicker.di.CalendarPickerComponent; -import org.dhis2.commons.dialogs.calendarpicker.di.CalendarPickerModule; -import org.dhis2.commons.featureconfig.di.FeatureConfigActivityComponent; -import org.dhis2.commons.featureconfig.di.FeatureConfigActivityModule; -import org.dhis2.commons.featureconfig.di.FeatureConfigModule; -import org.dhis2.commons.filters.data.FilterPresenter; -import org.dhis2.commons.network.NetworkUtilsModule; -import org.dhis2.commons.orgunitselector.OUTreeComponent; -import org.dhis2.commons.orgunitselector.OUTreeModule; -import org.dhis2.commons.prefs.Preference; -import org.dhis2.commons.prefs.PreferenceModule; -import org.dhis2.commons.schedulers.SchedulerModule; -import org.dhis2.commons.schedulers.SchedulersProviderImpl; -import org.dhis2.commons.service.SessionManagerModule; -import org.dhis2.commons.sync.SyncComponentProvider; -import org.dhis2.data.dispatcher.DispatcherModule; -import org.dhis2.data.server.SSLContextInitializer; -import org.dhis2.data.server.ServerComponent; -import org.dhis2.data.server.ServerModule; -import org.dhis2.data.server.UserManager; -import org.dhis2.data.service.workManager.WorkManagerModule; -import org.dhis2.data.user.UserComponent; -import org.dhis2.data.user.UserModule; -import org.dhis2.di.KoinInitialization; -import org.dhis2.maps.MapController; -import org.dhis2.usescases.crash.CrashActivity; -import org.dhis2.usescases.teiDashboard.TeiDashboardComponent; -import org.dhis2.usescases.teiDashboard.TeiDashboardModule; -import org.dhis2.utils.analytics.AnalyticsModule; -import org.dhis2.utils.granularsync.SyncStatusDialogProvider; -import org.dhis2.utils.timber.DebugTree; -import org.hisp.dhis.android.core.D2Manager; -import org.hisp.dhis.android.core.datastore.KeyValuePair; -import org.jetbrains.annotations.NotNull; - -import java.io.IOException; -import java.net.SocketException; - -import javax.inject.Singleton; - -import cat.ereza.customactivityoncrash.config.CaocConfig; -import dhis2.org.analytics.charts.ui.di.AnalyticsFragmentComponent; -import dhis2.org.analytics.charts.ui.di.AnalyticsFragmentModule; -import io.reactivex.Scheduler; -import io.reactivex.android.plugins.RxAndroidPlugins; -import io.reactivex.android.schedulers.AndroidSchedulers; -import io.reactivex.exceptions.UndeliverableException; -import io.reactivex.plugins.RxJavaPlugins; -import io.sentry.SentryLevel; -import io.sentry.android.core.SentryAndroid; -import timber.log.Timber; - -public class App extends android.app.Application implements Components, LifecycleObserver { - - @NonNull - @Singleton - AppComponent appComponent; - - @Nullable - @PerServer - protected ServerComponent serverComponent; - - @Nullable - @PerUser - protected UserComponent userComponent; - - @Nullable - @PerActivity - private TeiDashboardComponent dashboardComponent; - - private boolean fromBackGround = false; - private boolean recreated; - - @Override - public void onCreate() { - super.onCreate(); - - ProcessLifecycleOwner.get().getLifecycle().addObserver(this); - - MapController.Companion.init(this); - - setUpAppComponent(); - if (BuildConfig.DEBUG) { - Timber.plant(new DebugTree()); - } - - setUpSecurityProvider(); - setUpServerComponent(); - - KoinInitialization.INSTANCE.invoke(this, ServerModule.getD2Configuration(this)); - - initCrashController(); - setUpRxPlugin(); - initCustomCrashActivity(); - } - - public void initCrashController() { - if (areTrackingPermissionGranted()) { - if (BuildConfig.SENTRY_DSN.isEmpty()) { - Timber.w("Sentry DSN is empty. Skipping Sentry initialization."); - return; - } - SentryAndroid.init(this, options -> { - options.setDsn(BuildConfig.SENTRY_DSN); - options.setAnrReportInDebug(true); - - // Add a callback that will be used before the event is sent to Sentry. - // With this callback, you can modify the event or, when returning null, also discard the event. - options.setBeforeSend((event, hint) -> { - if (SentryLevel.DEBUG.equals(event.getLevel())) - return null; - else - return event; - }); - options.setEnvironment(BuildConfig.DEBUG ? "debug" : "production"); - options.setDebug(BuildConfig.DEBUG); - // Enable view hierarchy for crashes - options.setAttachViewHierarchy(true); - // Enable the performance API by setting a sample-rate - options.setTracesSampleRate(BuildConfig.DEBUG ? 1.0 : 0.1); - // Enable profiling when starting transactions - options.setProfilesSampleRate(BuildConfig.DEBUG ? 1.0 : 0.1); - }); - } - } - - private void setUpSecurityProvider() { - SSLContextInitializer.INSTANCE.initializeSSLContext(this); - } - - private void initCustomCrashActivity() { - CaocConfig.Builder.create() - .errorActivity(CrashActivity.class) - .apply(); - } - - private void setUpAppComponent() { - appComponent = prepareAppComponent().build(); - appComponent.inject(this); - } - - protected void setUpServerComponent() { - serverComponent = appComponent.plus(new ServerModule()); - if (Boolean.TRUE.equals(serverComponent.userManager().isUserLoggedIn().blockingFirst())) - setUpUserComponent(); - } - - - protected void setUpUserComponent() { - UserManager userManager = serverComponent == null - ? null : serverComponent.userManager(); - if (userManager != null && userManager.isUserLoggedIn().blockingFirst()) { - userComponent = serverComponent.plus(new UserModule()); - } - } - - //////////////////////////////////////////////////////////////////////// - // App component - - /// ///////////////////////////////////////////////////////////////////// - @NonNull - protected AppComponent.Builder prepareAppComponent() { - return DaggerAppComponent.builder() - .appModule(new AppModule(this)) - .schedulerModule(new SchedulerModule(new SchedulersProviderImpl())) - .analyticsModule(new AnalyticsModule()) - .preferenceModule(new PreferenceModule()) - .networkUtilsModule(new NetworkUtilsModule()) - .workManagerController(new WorkManagerModule()) - .sessionManagerService(new SessionManagerModule()) - .coroutineDispatchers(new DispatcherModule()) - .featureConfigModule(new FeatureConfigModule()); - } - - @NonNull - @Override - public AppComponent appComponent() { - return appComponent; - } - - //////////////////////////////////////////////////////////////////////// - // Server component - - /// ///////////////////////////////////////////////////////////////////// - - @Override - public ServerComponent createServerComponent() { - if (!D2Manager.INSTANCE.isD2Instantiated()) - setUpServerComponent(); - return serverComponent; - } - - @Nullable - @Override - public ServerComponent serverComponent() { - return serverComponent; - } - - @Override - public void releaseServerComponent() { - serverComponent = null; - } - - @Nullable - public ServerComponent getServerComponent() { - return serverComponent; - } - - //////////////////////////////////////////////////////////////////////// - // User component - - /// ///////////////////////////////////////////////////////////////////// - - @Override - public UserComponent createUserComponent() { - return (userComponent = serverComponent.plus(new UserModule())); - } - - @Override - public UserComponent userComponent() { - return userComponent; - } - - @Override - public void releaseUserComponent() { - userComponent = null; - } - - //////////////////////////////////////////////////////////////////////// - // Dashboard component - - /// ///////////////////////////////////////////////////////////////////// - @NonNull - public TeiDashboardComponent createDashboardComponent(@NonNull TeiDashboardModule dashboardModule) { - if (dashboardComponent != null) { - this.recreated = true; - } - dashboardComponent = userComponent.plus(dashboardModule); - return dashboardComponent; - } - - @Nullable - public TeiDashboardComponent dashboardComponent() { - return dashboardComponent; - } - - public void releaseDashboardComponent() { - if (!this.recreated) { - dashboardComponent = null; - } else { - recreated = false; - } - } - - @OnLifecycleEvent(Lifecycle.Event.ON_STOP) - public void onAppBackgrounded() { - Timber.tag("BG").d("App in background"); - } - - @OnLifecycleEvent(Lifecycle.Event.ON_START) - public void onAppForegrounded() { - Timber.tag("BG").d("App in foreground"); - fromBackGround = true; - } - - public void disableBackGroundFlag() { - fromBackGround = false; - } - - public boolean isSessionBlocked() { - boolean shouldShowPinDialog = fromBackGround && appComponent().preferenceProvider().getBoolean(Preference.SESSION_LOCKED, false); - fromBackGround = false; - return shouldShowPinDialog; - } - - private void setUpRxPlugin() { - Scheduler asyncMainThreadScheduler = AndroidSchedulers.from(Looper.getMainLooper(), true); - RxAndroidPlugins.setInitMainThreadSchedulerHandler(schedulerCallable -> asyncMainThreadScheduler); - RxJavaPlugins.setErrorHandler(e -> { - if (e instanceof UndeliverableException) { - e = e.getCause(); - } - if ((e instanceof IOException) || (e instanceof SocketException)) { - return; - } - if ((e instanceof NullPointerException) || (e instanceof IllegalArgumentException)) { - Timber.d("Error in app"); - Thread.currentThread().getUncaughtExceptionHandler() - .uncaughtException(Thread.currentThread(), e); - } - if (e instanceof IllegalStateException) { - Timber.d("Error in RxJava"); - Thread.currentThread().getUncaughtExceptionHandler() - .uncaughtException(Thread.currentThread(), e); - } - Timber.d(e); - }); - } - - @Override - public FeatureConfigActivityComponent provideFeatureConfigActivityComponent() { - return userComponent.plus(new FeatureConfigActivityModule()); - } - - @Override - public CalendarPickerComponent provideCalendarPickerComponent() { - return userComponent.plus(new CalendarPickerModule()); - } - - @Override - public AnalyticsFragmentComponent provideAnalyticsFragmentComponent(AnalyticsFragmentModule module) { - return userComponent.plus(module); - } - - @Override - public FilterPresenter provideFilterPresenter() { - return userComponent.filterPresenter(); - } - - @org.jetbrains.annotations.Nullable - @Override - public OUTreeComponent provideOUTreeComponent(@NotNull OUTreeModule module) { - return serverComponent.plus(module); - } - - @NonNull - @Override - public SyncComponentProvider getSyncComponentProvider() { - return new SyncStatusDialogProvider(); - } - - private boolean areTrackingPermissionGranted() { - boolean isUserLoggedIn = serverComponent != null && - serverComponent.userManager().isUserLoggedIn().blockingFirst(); - if (!D2Manager.isD2Instantiated() || !isUserLoggedIn) { - return false; - } - KeyValuePair granted = D2Manager.getD2().dataStoreModule().localDataStore() - .value(DATA_STORE_ANALYTICS_PERMISSION_KEY).blockingGet(); - return granted != null && Boolean.parseBoolean(granted.value()); - } - -} diff --git a/app/src/main/java/org/dhis2/App.kt b/app/src/main/java/org/dhis2/App.kt new file mode 100644 index 00000000000..e558684fa49 --- /dev/null +++ b/app/src/main/java/org/dhis2/App.kt @@ -0,0 +1,336 @@ +package org.dhis2 + +import android.app.Application +import android.os.Looper +import androidx.lifecycle.DefaultLifecycleObserver +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.ProcessLifecycleOwner +import cat.ereza.customactivityoncrash.config.CaocConfig +import dhis2.org.analytics.charts.ui.di.AnalyticsFragmentComponent +import dhis2.org.analytics.charts.ui.di.AnalyticsFragmentModule +import io.reactivex.android.plugins.RxAndroidPlugins +import io.reactivex.android.schedulers.AndroidSchedulers +import io.reactivex.exceptions.UndeliverableException +import io.reactivex.plugins.RxJavaPlugins +import io.sentry.SentryLevel +import io.sentry.SentryOptions +import io.sentry.android.core.SentryAndroid +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.launch +import org.dhis2.commons.di.dagger.PerActivity +import org.dhis2.commons.di.dagger.PerServer +import org.dhis2.commons.di.dagger.PerUser +import org.dhis2.commons.dialogs.calendarpicker.di.CalendarPickerComponent +import org.dhis2.commons.dialogs.calendarpicker.di.CalendarPickerModule +import org.dhis2.commons.featureconfig.di.FeatureConfigActivityComponent +import org.dhis2.commons.featureconfig.di.FeatureConfigActivityModule +import org.dhis2.commons.featureconfig.di.FeatureConfigModule +import org.dhis2.commons.filters.data.FilterPresenter +import org.dhis2.commons.network.NetworkUtilsModule +import org.dhis2.commons.orgunitselector.OUTreeComponent +import org.dhis2.commons.orgunitselector.OUTreeModule +import org.dhis2.commons.prefs.Preference +import org.dhis2.commons.prefs.PreferenceModule +import org.dhis2.commons.schedulers.SchedulerModule +import org.dhis2.commons.schedulers.SchedulersProviderImpl +import org.dhis2.commons.service.SessionManagerModule +import org.dhis2.commons.sync.SyncComponentProvider +import org.dhis2.data.dispatcher.DispatcherModule +import org.dhis2.data.server.SSLContextInitializer +import org.dhis2.data.server.ServerComponent +import org.dhis2.data.server.ServerModule +import org.dhis2.data.server.ServerModule.Companion.getD2Configuration +import org.dhis2.data.service.workManager.WorkManagerModule +import org.dhis2.data.user.UserComponent +import org.dhis2.data.user.UserModule +import org.dhis2.di.KoinInitialization.invoke +import org.dhis2.maps.MapController +import org.dhis2.mobile.commons.domain.invoke +import org.dhis2.mobile.commons.network.NetworkStatusProvider +import org.dhis2.mobile.sync.domain.CheckPeriodicJobs +import org.dhis2.usescases.crash.CrashActivity +import org.dhis2.usescases.teiDashboard.TeiDashboardComponent +import org.dhis2.usescases.teiDashboard.TeiDashboardModule +import org.dhis2.utils.analytics.AnalyticsModule +import org.dhis2.utils.analytics.DATA_STORE_ANALYTICS_PERMISSION_KEY +import org.dhis2.utils.granularsync.SyncStatusDialogProvider +import org.dhis2.utils.timber.DebugTree +import org.hisp.dhis.android.core.D2Manager +import org.koin.android.ext.android.inject +import timber.log.Timber +import timber.log.Timber.Forest.plant +import java.io.IOException +import javax.inject.Singleton + +open class App : Application(), Components, DefaultLifecycleObserver { + @Singleton + lateinit var appComponent: AppComponent + + @PerServer + var serverComponent: ServerComponent? = null + protected set + + @PerUser + protected var userComponent: UserComponent? = null + + @PerActivity + private var dashboardComponent: TeiDashboardComponent? = null + + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + private val networkStatusProvider: NetworkStatusProvider by inject() + private val checkPeriodicJobs: CheckPeriodicJobs by inject() + + private var fromBackGround = false + private var recreated = false + + override fun onCreate() { + super.onCreate() + + ProcessLifecycleOwner.get().lifecycle.addObserver(this) + + MapController.init(this) + + setUpAppComponent() + if (BuildConfig.DEBUG) { + plant(DebugTree()) + } + + setUpSecurityProvider() + setUpServerComponent() + + this(getD2Configuration(this)) + + initCrashController() + setUpRxPlugin() + initCustomCrashActivity() + observeConnectivity() + } + + + private fun observeConnectivity() { + scope.launch { + networkStatusProvider.connectionStatus + .filter { it } + .collect { + checkAndEnqueueSyncJobs() + } + } + } + + private fun checkAndEnqueueSyncJobs() { + scope.launch { + checkPeriodicJobs() + } + } + + fun initCrashController() { + if (areTrackingPermissionGranted()) { + initSentry() + } + } + + private fun initSentry() { + if (BuildConfig.SENTRY_DSN.isEmpty()) { + Timber.w("Sentry DSN is empty. Skipping Sentry initialization.") + return + } + SentryAndroid.init(this) { options -> + options.setDsn(BuildConfig.SENTRY_DSN) + options.isAnrReportInDebug = true + + // Add a callback that will be used before the event is sent to Sentry. + // With this callback, you can modify the event or, when returning null, also discard the event. + options.beforeSend = + SentryOptions.BeforeSendCallback { event, _ -> + if (SentryLevel.DEBUG == event.level) null else event + } + options.environment = if (BuildConfig.DEBUG) "debug" else "production" + options.isDebug = BuildConfig.DEBUG + // Enable view hierarchy for crashes + options.isAttachViewHierarchy = true + // Enable the performance API by setting a sample-rate + options.setTracesSampleRate(if (BuildConfig.DEBUG) 1.0 else 0.1) + // Enable profiling when starting transactions + options.setProfilesSampleRate(if (BuildConfig.DEBUG) 1.0 else 0.1) + } + } + + private fun setUpSecurityProvider() { + SSLContextInitializer.initializeSSLContext(this) + } + + private fun initCustomCrashActivity() { + CaocConfig.Builder.create() + .errorActivity(CrashActivity::class.java) + .apply() + } + + private fun setUpAppComponent() { + appComponent = prepareAppComponent().build() + appComponent.inject(this) + } + + protected open fun setUpServerComponent() { + serverComponent = appComponent.plus(ServerModule()) + if (serverComponent?.userManager()?.isUserLoggedIn() + ?.blockingFirst() == true + ) setUpUserComponent() + } + + + protected open fun setUpUserComponent() { + serverComponent?.userManager()?.takeIf { it.isUserLoggedIn.blockingFirst() }?.let { + userComponent = serverComponent!!.plus(UserModule()) + } + } + + protected open fun prepareAppComponent(): AppComponent.Builder { + return DaggerAppComponent.builder() + .appModule(AppModule(this)) + .schedulerModule(SchedulerModule(SchedulersProviderImpl())) + .analyticsModule(AnalyticsModule()) + .preferenceModule(PreferenceModule()) + .networkUtilsModule(NetworkUtilsModule()) + .workManagerController(WorkManagerModule()) + .sessionManagerService(SessionManagerModule()) + .coroutineDispatchers(DispatcherModule()) + .featureConfigModule(FeatureConfigModule()) + } + + override fun appComponent(): AppComponent { + return appComponent + } + + override fun createServerComponent(): ServerComponent { + if (!D2Manager.isD2Instantiated()) setUpServerComponent() + return serverComponent!! + } + + override fun serverComponent(): ServerComponent? { + return serverComponent + } + + override fun releaseServerComponent() { + serverComponent = null + } + + override fun createUserComponent(): UserComponent { + return (serverComponent!!.plus(UserModule()).also { userComponent = it }) + } + + override fun userComponent(): UserComponent? { + return userComponent + } + + override fun releaseUserComponent() { + userComponent = null + } + + fun createDashboardComponent(dashboardModule: TeiDashboardModule): TeiDashboardComponent { + if (dashboardComponent != null) { + this.recreated = true + } + dashboardComponent = userComponent?.plus(dashboardModule) + return dashboardComponent!! + } + + fun dashboardComponent(): TeiDashboardComponent? { + return dashboardComponent + } + + fun releaseDashboardComponent() { + if (!this.recreated) { + dashboardComponent = null + } else { + recreated = false + } + } + + override fun onStop(owner: LifecycleOwner) { + Timber.tag("BG").d("App in background") + } + + override fun onStart(owner: LifecycleOwner) { + Timber.tag("BG").d("App in foreground") + fromBackGround = true + } + + fun disableBackGroundFlag() { + fromBackGround = false + } + + val isSessionBlocked: Boolean + get() { + val shouldShowPinDialog = + fromBackGround && appComponent().preferenceProvider() + .getBoolean(Preference.SESSION_LOCKED, false) + fromBackGround = false + return shouldShowPinDialog + } + + private fun setUpRxPlugin() { + val asyncMainThreadScheduler = AndroidSchedulers.from(Looper.getMainLooper(), true) + RxAndroidPlugins.setInitMainThreadSchedulerHandler { asyncMainThreadScheduler } + RxJavaPlugins.setErrorHandler { e -> + var e = e + if (e is UndeliverableException) { + e = e.cause + } + if (e is IOException) { + return@setErrorHandler + } + if ((e is NullPointerException) || (e is IllegalArgumentException)) { + Timber.d("Error in app") + Thread.currentThread().uncaughtExceptionHandler?.uncaughtException( + Thread.currentThread(), + e + ) + } + if (e is IllegalStateException) { + Timber.d("Error in RxJava") + Thread.currentThread().uncaughtExceptionHandler?.uncaughtException( + Thread.currentThread(), + e + ) + } + Timber.d(e) + } + } + + override fun provideFeatureConfigActivityComponent(): FeatureConfigActivityComponent? { + return userComponent?.plus(FeatureConfigActivityModule()) + } + + override fun provideCalendarPickerComponent(): CalendarPickerComponent? { + return userComponent?.plus(CalendarPickerModule()) + } + + override fun provideAnalyticsFragmentComponent(module: AnalyticsFragmentModule?): AnalyticsFragmentComponent? { + return userComponent?.plus(module) + } + + override fun provideFilterPresenter(): FilterPresenter? { + return userComponent?.filterPresenter() + } + + override fun provideOUTreeComponent(module: OUTreeModule): OUTreeComponent? { + return serverComponent?.plus(module) + } + + override val syncComponentProvider: SyncComponentProvider + get() = SyncStatusDialogProvider() + + private fun areTrackingPermissionGranted(): Boolean { + val isUserLoggedIn = serverComponent != null && + serverComponent!!.userManager().isUserLoggedIn().blockingFirst() + if (!D2Manager.isD2Instantiated() || !isUserLoggedIn) { + return false + } + val granted = D2Manager.getD2().dataStoreModule().localDataStore() + .value(DATA_STORE_ANALYTICS_PERMISSION_KEY).blockingGet() + return granted != null && granted.value().toBoolean() + } +} diff --git a/app/src/main/java/org/dhis2/data/service/CheckVersionWorker.kt b/app/src/main/java/org/dhis2/data/service/CheckVersionWorker.kt index 531d10c6f03..626532a8556 100644 --- a/app/src/main/java/org/dhis2/data/service/CheckVersionWorker.kt +++ b/app/src/main/java/org/dhis2/data/service/CheckVersionWorker.kt @@ -11,7 +11,7 @@ class CheckVersionWorker( ) : CoroutineWorker(context, workerParams) { override suspend fun doWork(): Result { - versionRepository.checkVersionUpdates() + versionRepository.downloadLatestVersionInfo() return Result.success() } } 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 28f480d09f8..d15745a193c 100644 --- a/app/src/main/java/org/dhis2/data/service/SyncPresenterImpl.kt +++ b/app/src/main/java/org/dhis2/data/service/SyncPresenterImpl.kt @@ -63,7 +63,7 @@ class SyncPresenterImpl( syncRepository .downLoadEvent(eventUid, programUid) .map { it as D2Progress } - .mergeWith(syncRepository.downloadEventFiles(eventUid)) + .concatWith(syncRepository.downloadEventFiles(eventUid)) } ?: Observable.empty() } @@ -184,7 +184,7 @@ class SyncPresenterImpl( syncRepository.downloadEventProgram(uid) } }?.map { it as D2Progress } - ?.mergeWith(syncRepository.downloadProgramFiles(uid)) + ?.concatWith(syncRepository.downloadProgramFiles(uid)) ?: Observable.empty() override fun syncGranularTEI(uid: String): Observable { @@ -198,7 +198,7 @@ class SyncPresenterImpl( return syncRepository .downloadTei(teiUid, programUid) .map { it as D2Progress } - .mergeWith( + .concatWith( syncRepository.downloadTeiFiles(teiUid, programUid), ) } 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 49c65337ce8..6edce0112f8 100644 --- a/app/src/main/java/org/dhis2/data/service/VersionRepository.kt +++ b/app/src/main/java/org/dhis2/data/service/VersionRepository.kt @@ -21,12 +21,12 @@ class VersionRepository( private val _newAppVersion = MutableSharedFlow(replay = 1) val newAppVersion: SharedFlow get() = _newAppVersion - suspend fun downloadLatestVersionInfo() { + suspend fun downloadLatestVersionInfo(): String? { d2.settingModule().latestAppVersion().blockingDownload() - checkVersionUpdates() + return checkVersionUpdates() } - suspend fun getLatestVersionInfo(): String? = + private fun getLatestVersionInfo(): String? = d2 .settingModule() .latestAppVersion() @@ -34,9 +34,10 @@ class VersionRepository( ?.version() .takeIf { it?.newVersion(BuildConfig.VERSION_NAME) ?: false } - suspend fun checkVersionUpdates() { + private suspend fun checkVersionUpdates(): String? { val versionNameOrNull = getLatestVersionInfo() _newAppVersion.emit(versionNameOrNull) + return versionNameOrNull } fun download( diff --git a/app/src/main/java/org/dhis2/di/ServerModule.kt b/app/src/main/java/org/dhis2/di/ServerModule.kt index e7c4ea4805f..1594493acd8 100644 --- a/app/src/main/java/org/dhis2/di/ServerModule.kt +++ b/app/src/main/java/org/dhis2/di/ServerModule.kt @@ -3,7 +3,7 @@ package org.dhis2.di import org.dhis2.data.service.VersionRepository import org.hisp.dhis.android.core.D2Configuration import org.hisp.dhis.android.core.D2Manager -import org.koin.core.module.dsl.factoryOf +import org.koin.core.module.dsl.singleOf import org.koin.dsl.module fun serverModule(d2Configuration: D2Configuration) = @@ -19,5 +19,5 @@ fun serverModule(d2Configuration: D2Configuration) = D2Manager.getD2() } } - factoryOf(::VersionRepository) + singleOf(::VersionRepository) } diff --git a/app/src/main/java/org/dhis2/usescases/eventsWithoutRegistration/eventCapture/EventCaptureActivity.kt b/app/src/main/java/org/dhis2/usescases/eventsWithoutRegistration/eventCapture/EventCaptureActivity.kt index daa9d93d5db..fb492b847a6 100644 --- a/app/src/main/java/org/dhis2/usescases/eventsWithoutRegistration/eventCapture/EventCaptureActivity.kt +++ b/app/src/main/java/org/dhis2/usescases/eventsWithoutRegistration/eventCapture/EventCaptureActivity.kt @@ -142,7 +142,7 @@ class EventCaptureActivity : ViewModelProvider(this, viewModelFactory)[DashboardViewModel::class.java] supportFragmentManager .beginTransaction() - .replace(R.id.tei_column, newInstance(programUid, teiUid, enrollmentUid)) + .replace(R.id.tei_column, newInstance(programUid, teiUid, enrollmentUid, true)) .commit() dashboardViewModel?.updateSelectedEventUid(eventUid) } @@ -655,6 +655,8 @@ class EventCaptureActivity : putExtra(Constants.EVENT_UID, eventUid) putExtra(Constants.PROGRAM_UID, programUid) putExtra(Constants.EVENT_MODE, eventMode) + putExtra("FRAGMENT_FROM_EVENT_CAPTURE_ACTIVITY", true) + } } } 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 4e9b14cc5fa..a8119a47442 100644 --- a/app/src/main/java/org/dhis2/usescases/main/MainModule.kt +++ b/app/src/main/java/org/dhis2/usescases/main/MainModule.kt @@ -60,7 +60,6 @@ val mainModule = module { factory { FilterManager.getInstance() } - singleOf(::VersionRepository) factoryOf(::ResourceManager) factory { params -> MainNavigator( diff --git a/app/src/main/java/org/dhis2/usescases/main/MainViewModel.kt b/app/src/main/java/org/dhis2/usescases/main/MainViewModel.kt index cbbbac8702b..07ea34113a3 100644 --- a/app/src/main/java/org/dhis2/usescases/main/MainViewModel.kt +++ b/app/src/main/java/org/dhis2/usescases/main/MainViewModel.kt @@ -5,6 +5,7 @@ import android.net.Uri import androidx.core.net.toUri import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.Job import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted @@ -29,8 +30,11 @@ import org.dhis2.commons.viewmodel.DispatcherProvider import org.dhis2.mobile.commons.domain.invoke import org.dhis2.mobile.commons.extensions.launchUseCase import org.dhis2.mobile.commons.providers.PreferenceProvider +import org.dhis2.mobile.sync.data.METADATA_SYNC_NOW import org.dhis2.mobile.sync.data.SyncBackgroundJobAction import org.dhis2.mobile.sync.domain.SyncStatusController +import org.dhis2.mobile.sync.model.SyncJobStatus +import org.dhis2.mobile.sync.model.SyncStatus import org.dhis2.mobile.sync.model.SyncStatusData import org.dhis2.usescases.main.domain.CheckSingleNavigation import org.dhis2.usescases.main.domain.ConfigureHomeNavigationBar @@ -78,6 +82,8 @@ class MainViewModel( ) : ViewModel() { private val _homeScreenState = MutableStateFlow(defaultHomeScreenState) + private var metadataSyncObserverJob: Job? = null + private val _homeEffects = Channel(Channel.BUFFERED) val homeEffects = _homeEffects.receiveAsFlow() @@ -127,6 +133,8 @@ class MainViewModel( _homeEffects.send(HomeEffect.OrgUnitFilterRequest) }.launchIn(viewModelScope) + observeMetadataSyncJob() + launchUseCase(dispatcher.io()) { launchInitialSync().fold( onSuccess = ::handleInitialSyncResult, @@ -164,13 +172,13 @@ class MainViewModel( val navigationBarItems = configureHomeNavigationBar().getOrDefault(emptyList()) val homeFilters = getHomeFilters().getOrDefault(emptyList()) - _homeScreenState.update { + _homeScreenState.update { current -> HomeScreenState( userName = userName, navigationBarItems = navigationBarItems, homeFilters = homeFilters, activeFilters = filterManager.totalFilters, - versionToUpdate = VersionToUpdateState.None, + versionToUpdate = current.versionToUpdate, filterButtonVisible = initialScreen.isPrograms(), bottomNavigationBarVisible = initialScreen.isHome() && navigationBarItems.size > 1, syncButtonVisible = initialScreen.isHome(), @@ -198,7 +206,8 @@ class MainViewModel( _homeScreenState.update { it.copy( filterButtonVisible = true, - bottomNavigationBarVisible = true, + bottomNavigationBarVisible = + it.currentScreen.isHome() && it.navigationBarItems.size > 1, syncButtonVisible = true, ) } @@ -222,11 +231,57 @@ class MainViewModel( } } + private fun observeMetadataSyncJob() { + metadataSyncObserverJob = + syncBackgroundJobAction + .observeMetadataJob() + .onEach(::handleMetadataSync) + .launchIn(viewModelScope) + } + + private fun handleMetadataSync(jobs: List) { + jobs.forEach { job -> + if (job.tags.contains(METADATA_SYNC_NOW)) { + when (job.status) { + SyncStatus.Running, SyncStatus.Enqueue -> { + _homeScreenState.update { + it.copy( + filterButtonVisible = false, + bottomNavigationBarVisible = false, + syncButtonVisible = false, + ) + } + } + SyncStatus.Succeed -> { + launchUseCase(dispatcher.io()) { + val navigationBarItems = + configureHomeNavigationBar().getOrDefault(emptyList()) + _homeScreenState.update { + it.copy( + filterButtonVisible = true, + navigationBarItems = navigationBarItems, + bottomNavigationBarVisible = + it.currentScreen.isHome() && navigationBarItems.size > 1, + syncButtonVisible = true, + ) + } + } + } + SyncStatus.Failed -> _homeScreenState.update { it.copy(syncButtonVisible = true) } + else -> { + // Do nothing + } + } + } + } + } + fun setOrgUnitFilters(selectedOrgUnits: List) { filterManager.addOrgUnits(selectedOrgUnits) } fun logOut() { + metadataSyncObserverJob?.cancel() launchUseCase(dispatcher.io()) { matomoAnalyticsController.trackEvent(HOME, CLOSE_SESSION, CLICK) logOutUser().fold( @@ -235,6 +290,7 @@ class MainViewModel( }, onFailure = { Timber.e(it) + observeMetadataSyncJob() }, ) } @@ -242,6 +298,7 @@ class MainViewModel( context(context: Context) fun onDeleteAccount() { + metadataSyncObserverJob?.cancel() launchUseCase(dispatcher.io()) { _homeEffects.send(HomeEffect.ShowDeleteNotification) deleteAccount(context.cacheDir).fold( @@ -251,6 +308,7 @@ class MainViewModel( }, onFailure = { Timber.e(it) + observeMetadataSyncJob() }, ) } 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 index 886d645c9ff..2be4d829b4e 100644 --- a/app/src/main/java/org/dhis2/usescases/main/domain/LaunchInitialSync.kt +++ b/app/src/main/java/org/dhis2/usescases/main/domain/LaunchInitialSync.kt @@ -5,6 +5,7 @@ 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 +import timber.log.Timber class LaunchInitialSync( private val skipSync: Boolean, @@ -17,7 +18,11 @@ class LaunchInitialSync( if (skipSync || homeRepository.isImportedDb() || homeRepository.getInitialSyncDone()) { Result.success(InitialSyncAction.Skip) } else { - versionRepository.checkVersionUpdates() + try { + versionRepository.downloadLatestVersionInfo() + } catch (e: Exception) { + Timber.w(e, "Version check failed during initial sync") + } syncBackgroundJobAction.launchDataSync(0) Result.success(InitialSyncAction.Syncing) } 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 2b2ae97b545..228ea2455e7 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 @@ -27,6 +27,7 @@ class NoteDetailPresenter( } fun save() { + NotesIdlingResource.increment() val data = view.getNewNote() val noteType = data.first val uid = data.second @@ -34,13 +35,17 @@ class NoteDetailPresenter( disposable.add( repository .saveNote(noteType, uid, message) - .doOnSubscribe { NotesIdlingResource.increment() } - .doFinally { NotesIdlingResource.decrement() } .subscribeOn(scheduler.io()) .observeOn(scheduler.ui()) .subscribe( - { view.noteSaved() }, - Timber::d, + { + view.noteSaved() + NotesIdlingResource.decrement() + }, + { error -> + Timber.d(error) + NotesIdlingResource.decrement() + }, ), ) } 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 47fc387e9d2..7d6eb880d65 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 @@ -339,6 +339,10 @@ class SearchTEList : FragmentGlobalAbstract() { } liveAdapter.addLoadStateListener { state -> + val adapterDetached = !listAdapter.adapters.contains(liveAdapter) + if (adapterDetached && state.refresh is LoadState.NotLoading) { + restoreProgramAdapterAfterRefresh() + } if (state.append == LoadState.Loading) { displayResult( listOf(SearchResult(SearchResult.SearchResultType.LOADING)), @@ -375,6 +379,8 @@ class SearchTEList : FragmentGlobalAbstract() { displayResult(null) } + private var lastSearchPagingData: Any? = null + private fun initData() { displayLoadingData() @@ -388,6 +394,10 @@ class SearchTEList : FragmentGlobalAbstract() { lifecycleScope.launch { repeatOnLifecycle(Lifecycle.State.STARTED) { viewModel.searchPagingData.collectLatest { data -> + if (data !== lastSearchPagingData) { + lastSearchPagingData = data + hideStaleProgramResults() + } liveAdapter.submitData(lifecycle, data) } } @@ -454,4 +464,14 @@ class SearchTEList : FragmentGlobalAbstract() { resultAdapter.submitList(result) } } + + private fun hideStaleProgramResults() { + listAdapter.removeAdapter(liveAdapter) + initLoading(listOf(SearchResult(SearchResult.SearchResultType.LOADING))) + } + + private fun restoreProgramAdapterAfterRefresh() { + listAdapter.addAdapter(1, liveAdapter) + initLoading(null) + } } 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 77922793602..5388b2e0a33 100644 --- a/app/src/main/java/org/dhis2/usescases/settings/SyncManagerFragment.kt +++ b/app/src/main/java/org/dhis2/usescases/settings/SyncManagerFragment.kt @@ -17,6 +17,7 @@ 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.data.service.VersionRepository import org.dhis2.mobile.sync.data.SyncBackgroundJobAction import org.dhis2.usescases.general.FragmentGlobalAbstract import org.dhis2.usescases.reservedValue.ReservedValueActivity @@ -35,6 +36,7 @@ class SyncManagerFragment : FragmentGlobalAbstract() { private val presenter: SyncManagerPresenter by viewModels { settingsViewModelFactory } private val syncBackgroundJobAction: SyncBackgroundJobAction by inject() + private val versionRepository: VersionRepository by inject() @JvmField @Inject @@ -44,7 +46,7 @@ class SyncManagerFragment : FragmentGlobalAbstract() { super.onAttach(context) app() .userComponent() - ?.plus(SyncManagerModule(syncBackgroundJobAction)) + ?.plus(SyncManagerModule(syncBackgroundJobAction, versionRepository)) ?.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 c1819f82b8e..51ec74e59db 100644 --- a/app/src/main/java/org/dhis2/usescases/settings/SyncManagerModule.kt +++ b/app/src/main/java/org/dhis2/usescases/settings/SyncManagerModule.kt @@ -29,6 +29,7 @@ import org.hisp.dhis.android.core.D2 @Module class SyncManagerModule( private val syncBackgroundJobAction: SyncBackgroundJobAction, + private val versionRepository: VersionRepository, ) { @Provides @PerFragment @@ -140,7 +141,6 @@ class SyncManagerModule( @Provides @PerFragment fun provideCheckVersionUpdate( - versionRepository: VersionRepository, settingsMessages: SettingsMessages, resourceManager: ResourceManager, ) = CheckVersionUpdate( 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 d33719c18ed..a23c557a6ce 100644 --- a/app/src/main/java/org/dhis2/usescases/settings/SyncManagerPresenter.kt +++ b/app/src/main/java/org/dhis2/usescases/settings/SyncManagerPresenter.kt @@ -14,6 +14,8 @@ import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import org.dhis2.commons.network.NetworkUtils import org.dhis2.commons.viewmodel.DispatcherProvider +import org.dhis2.mobile.commons.domain.invoke +import org.dhis2.mobile.commons.extensions.launchUseCase import org.dhis2.usescases.settings.domain.CheckVersionUpdate import org.dhis2.usescases.settings.domain.DeleteLocalData import org.dhis2.usescases.settings.domain.ExportDatabase @@ -142,7 +144,7 @@ class SyncManagerPresenter( } fun onCheckVersionUpdate() { - viewModelScope.launch(dispatcherProvider.io()) { + launchUseCase(dispatcherProvider.io()) { checkVersionUpdate() } } diff --git a/app/src/main/java/org/dhis2/usescases/settings/domain/CheckVersionUpdate.kt b/app/src/main/java/org/dhis2/usescases/settings/domain/CheckVersionUpdate.kt index f570fbe6434..d331c76debe 100644 --- a/app/src/main/java/org/dhis2/usescases/settings/domain/CheckVersionUpdate.kt +++ b/app/src/main/java/org/dhis2/usescases/settings/domain/CheckVersionUpdate.kt @@ -1,25 +1,22 @@ package org.dhis2.usescases.settings.domain -import androidx.lifecycle.MutableLiveData import org.dhis2.R import org.dhis2.commons.resources.ResourceManager import org.dhis2.data.service.VersionRepository +import org.dhis2.mobile.commons.domain.UseCase class CheckVersionUpdate( private val versionRepository: VersionRepository, private val settingsMessages: SettingsMessages, private val resourceManager: ResourceManager, -) { - private val updatesLoading = MutableLiveData() - - suspend operator fun invoke() { - updatesLoading.postValue(true) - val newVersion = versionRepository.getLatestVersionInfo() - if (newVersion != null) { - versionRepository.checkVersionUpdates() - } else { +) : UseCase { + override suspend fun invoke(input: Unit): Result = try { + val newVersion = versionRepository.downloadLatestVersionInfo() + if (newVersion == null) { settingsMessages.sendMessage(resourceManager.getString(R.string.no_updates)) } - updatesLoading.postValue(false) + Result.success(Unit) + } catch (e: Exception) { + Result.failure(e) } } diff --git a/app/src/main/java/org/dhis2/usescases/teiDashboard/DashboardRepository.kt b/app/src/main/java/org/dhis2/usescases/teiDashboard/DashboardRepository.kt index 3eee5ec3f20..0e6d41ff4dd 100644 --- a/app/src/main/java/org/dhis2/usescases/teiDashboard/DashboardRepository.kt +++ b/app/src/main/java/org/dhis2/usescases/teiDashboard/DashboardRepository.kt @@ -112,4 +112,6 @@ interface DashboardRepository { fun teiCanBeTransferred(): Boolean fun enrollmentHasWriteAccess(): Boolean + + fun isProgramSelected(): Boolean } diff --git a/app/src/main/java/org/dhis2/usescases/teiDashboard/DashboardRepositoryImpl.kt b/app/src/main/java/org/dhis2/usescases/teiDashboard/DashboardRepositoryImpl.kt index 63a78db477e..26981329a07 100644 --- a/app/src/main/java/org/dhis2/usescases/teiDashboard/DashboardRepositoryImpl.kt +++ b/app/src/main/java/org/dhis2/usescases/teiDashboard/DashboardRepositoryImpl.kt @@ -92,6 +92,8 @@ class DashboardRepositoryImpl( .map { it } .toObservable() + override fun isProgramSelected(): Boolean = programUid?.isNotEmpty() == true + override fun getEnrollmentEventsWithDisplay( programUid: String?, teiUid: String, diff --git a/app/src/main/java/org/dhis2/usescases/teiDashboard/DashboardViewModel.kt b/app/src/main/java/org/dhis2/usescases/teiDashboard/DashboardViewModel.kt index 3cd6968a1ad..daa3d093929 100644 --- a/app/src/main/java/org/dhis2/usescases/teiDashboard/DashboardViewModel.kt +++ b/app/src/main/java/org/dhis2/usescases/teiDashboard/DashboardViewModel.kt @@ -15,7 +15,12 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import kotlinx.coroutines.async import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.onStart +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import org.dhis2.R @@ -65,127 +70,143 @@ class DashboardViewModel( private val _isLoading = MutableLiveData(false) val isLoading: LiveData = _isLoading - private val _dashboardModel = MutableLiveData() - var dashboardModel: LiveData = _dashboardModel + private val _dashboardModel = MutableStateFlow(null) + var dashboardModel: StateFlow = + _dashboardModel + .onStart { + if (repository.isProgramSelected()) fetchDashboardModel() + }.stateIn( + viewModelScope, + SharingStarted.WhileSubscribed(5000L), + null, + ) - private val _groupByStage = MutableLiveData() - val groupByStage: LiveData = _groupByStage + private val _groupByStage = MutableStateFlow(false) + val groupByStage: StateFlow = + _groupByStage + .onStart { + fetchGrouping() + }.stateIn( + viewModelScope, + SharingStarted.WhileSubscribed(5000L), + false, + ) private val _noEnrollmentSelected = MutableLiveData(false) val noEnrollmentSelected: LiveData = _noEnrollmentSelected private val _navigationBarUIState = MutableStateFlow>(NavigationBarUIState()) - val navigationBarUIState = _navigationBarUIState.asStateFlow() + val navigationBarUIState = + _navigationBarUIState + .onStart { + loadNavigationBarItems() + }.stateIn( + viewModelScope, + SharingStarted.WhileSubscribed(5000L), + NavigationBarUIState(), + ) private val _relationshipTopBarIconState = MutableStateFlow(RelationshipTopBarIconState.List()) val relationshipTopBarIconState = _relationshipTopBarIconState.asStateFlow() - init { - fetchDashboardModel() - fetchGrouping() - } - private fun fetchDashboardModel() { viewModelScope.launch(dispatcher.io()) { - CoroutineTracker.increment() - val result = - async { - repository.getDashboardModel() - } - withContext(dispatcher.ui()) { - try { - val model = result.await() - _dashboardModel.postValue(model) - if (model is DashboardEnrollmentModel) { - _showFollowUpBar.value = - model.currentEnrollment.followUp() ?: false - _syncNeeded.value = - model.currentEnrollment.aggregatedSyncState() != SYNCED - _showStatusBar.value = model.currentEnrollment.status() - _state.value = - model.currentEnrollment.aggregatedSyncState() - _noEnrollmentSelected.value = false - loadNavigationBarItems() - } else { - _noEnrollmentSelected.value = true - } - } catch (e: Exception) { - Timber.e(e) - } finally { - CoroutineTracker.decrement() + CoroutineTracker.unconditionalIncrement() + try { + val model = repository.getDashboardModel() + _dashboardModel.emit(model) + if (model is DashboardEnrollmentModel) { + _showFollowUpBar.value = + model.currentEnrollment.followUp() ?: false + _syncNeeded.value = + model.currentEnrollment.aggregatedSyncState() != SYNCED + _showStatusBar.value = model.currentEnrollment.status() + _state.value = + model.currentEnrollment.aggregatedSyncState() + _noEnrollmentSelected.postValue(false) + } else { + _noEnrollmentSelected.postValue(true) } + } catch (e: Exception) { + Timber.e(e) + } finally { + CoroutineTracker.unconditionalDecrement() } } } - private suspend fun loadNavigationBarItems() { - val enrollmentItems = mutableListOf>() - - if (isPortrait()) { - enrollmentItems.add( - NavigationBarItem( - id = TEIDashboardItems.DETAILS, - icon = Icons.AutoMirrored.Outlined.Assignment, - selectedIcon = Icons.AutoMirrored.Filled.Assignment, - label = resourcesManager.getString(R.string.navigation_tei_data), - ), - ) - } + private suspend fun loadNavigationBarItems() = + withContext(dispatcher.io()) { + CoroutineTracker.unconditionalIncrement() + try { + val enrollmentItems = mutableListOf>() + + if (isPortrait()) { + enrollmentItems.add( + NavigationBarItem( + id = TEIDashboardItems.DETAILS, + icon = Icons.AutoMirrored.Outlined.Assignment, + selectedIcon = Icons.AutoMirrored.Filled.Assignment, + label = resourcesManager.getString(R.string.navigation_tei_data), + ), + ) + } - if (withContext(dispatcher.io()) { repository.programHasAnalytics() }) { - enrollmentItems.add( - NavigationBarItem( - id = TEIDashboardItems.ANALYTICS, - icon = Icons.Outlined.BarChart, - selectedIcon = Icons.Filled.BarChart, - label = resourcesManager.getString(R.string.navigation_analytics), - ), - ) - } + if (repository.programHasAnalytics()) { + enrollmentItems.add( + NavigationBarItem( + id = TEIDashboardItems.ANALYTICS, + icon = Icons.Outlined.BarChart, + selectedIcon = Icons.Filled.BarChart, + label = resourcesManager.getString(R.string.navigation_analytics), + ), + ) + } - if (pageConfigurator.displayRelationships()) { - enrollmentItems.add( - NavigationBarItem( - id = TEIDashboardItems.RELATIONSHIPS, - icon = Icons.Outlined.Hub, - selectedIcon = Icons.Filled.Hub, - label = resourcesManager.getString(R.string.navigation_relations), - ), - ) - } + if (pageConfigurator.displayRelationships()) { + enrollmentItems.add( + NavigationBarItem( + id = TEIDashboardItems.RELATIONSHIPS, + icon = Icons.Outlined.Hub, + selectedIcon = Icons.Filled.Hub, + label = resourcesManager.getString(R.string.navigation_relations), + ), + ) + } - enrollmentItems.add( - NavigationBarItem( - id = TEIDashboardItems.NOTES, - icon = Icons.AutoMirrored.Outlined.StickyNote2, - selectedIcon = Icons.AutoMirrored.Filled.StickyNote2, - label = resourcesManager.getString(R.string.navigation_notes), - ), - ) - - _navigationBarUIState.value = _navigationBarUIState.value.copy(items = enrollmentItems) - - if (navigationBarUIState.value.items.none { it.id == navigationBarUIState.value.selectedItem }) { - onNavigationItemSelected( - navigationBarUIState.value.items - .first() - .id, - ) + enrollmentItems.add( + NavigationBarItem( + id = TEIDashboardItems.NOTES, + icon = Icons.AutoMirrored.Outlined.StickyNote2, + selectedIcon = Icons.AutoMirrored.Filled.StickyNote2, + label = resourcesManager.getString(R.string.navigation_notes), + ), + ) + + _navigationBarUIState.update { + it.copy(items = enrollmentItems) + } + + if (enrollmentItems.none { it.id == _navigationBarUIState.value.selectedItem }) { + val selectedItem = enrollmentItems.first() + onNavigationItemSelected(selectedItem.id) + } + } finally { + CoroutineTracker.unconditionalDecrement() + } } - } private fun fetchGrouping() { viewModelScope.launch(dispatcher.io()) { - val result = - async { - repository.getGrouping() - } + CoroutineTracker.unconditionalIncrement() try { - _groupByStage.postValue(result.await()) + _groupByStage.emit(repository.getGrouping()) } catch (e: Exception) { Timber.e(e) + } finally { + CoroutineTracker.unconditionalDecrement() } } } 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 4620323adc1..a31566dddf4 100644 --- a/app/src/main/java/org/dhis2/usescases/teiDashboard/TeiDashboardMobileActivity.kt +++ b/app/src/main/java/org/dhis2/usescases/teiDashboard/TeiDashboardMobileActivity.kt @@ -10,8 +10,10 @@ import android.util.TypedValue import android.view.View import android.view.WindowManager import androidx.activity.result.contract.ActivityResultContracts +import androidx.activity.viewModels import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.MoveDown +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf @@ -22,12 +24,16 @@ import androidx.compose.ui.text.style.TextAlign import androidx.core.content.ContextCompat import androidx.core.view.ViewCompat import androidx.databinding.DataBindingUtil +import androidx.lifecycle.Lifecycle import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData -import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle import com.google.android.material.floatingactionbutton.FloatingActionButton import com.google.android.material.snackbar.Snackbar +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.launch import org.dhis2.App import org.dhis2.R import org.dhis2.commons.Constants @@ -126,7 +132,7 @@ class TeiDashboardMobileActivity : var programUid: String? = null var enrollmentUid: String? = null lateinit var binding: ActivityDashboardMobileBinding - private lateinit var dashboardViewModel: DashboardViewModel + private val dashboardViewModel: DashboardViewModel by viewModels { viewModelFactory } private var relationshipMap: MutableLiveData = MutableLiveData(false) @@ -193,8 +199,6 @@ class TeiDashboardMobileActivity : ).inject(this) setTheme(themeManager.getProgramTheme()) super.onCreate(savedInstanceState) - dashboardViewModel = - ViewModelProvider(this, viewModelFactory)[DashboardViewModel::class.java] binding = DataBindingUtil.setContentView(this, R.layout.activity_dashboard_mobile) showLoadingProgress(true) binding.presenter = presenter @@ -234,13 +238,17 @@ class TeiDashboardMobileActivity : } private fun observeDashboardModel() { - dashboardViewModel.dashboardModel.observe(this) { - if (sessionManagerServiceImpl.isUserLoggedIn()) { - when (it) { - is DashboardEnrollmentModel -> setData(it) - is DashboardTEIModel -> setDataWithOutProgram(it) - else -> // Do nothing - Unit + lifecycleScope.launch { + repeatOnLifecycle(Lifecycle.State.STARTED) { + dashboardViewModel.dashboardModel.collectLatest { + if (sessionManagerServiceImpl.isUserLoggedIn()) { + when (it) { + is DashboardEnrollmentModel -> setData(it) + is DashboardTEIModel -> setDataWithOutProgram(it) + else -> // Do nothing + Unit + } + } } } } @@ -351,8 +359,10 @@ class TeiDashboardMobileActivity : dashboardViewModel.onNavigationItemSelected(itemId) } - uiState.selectedItem?.let { - navigateToFragment(it) + LaunchedEffect(uiState.selectedItem) { + uiState.selectedItem?.let { + navigateToFragment(it) + } } } } diff --git a/app/src/main/java/org/dhis2/usescases/teiDashboard/dashboardfragments/teidata/TEIDataFragment.kt b/app/src/main/java/org/dhis2/usescases/teiDashboard/dashboardfragments/teidata/TEIDataFragment.kt index 1f27a1808f1..6a9a5d254ca 100644 --- a/app/src/main/java/org/dhis2/usescases/teiDashboard/dashboardfragments/teidata/TEIDataFragment.kt +++ b/app/src/main/java/org/dhis2/usescases/teiDashboard/dashboardfragments/teidata/TEIDataFragment.kt @@ -3,7 +3,6 @@ package org.dhis2.usescases.teiDashboard.dashboardfragments.teidata import android.content.ActivityNotFoundException import android.content.Context import android.content.Intent -import android.net.Uri import android.os.Bundle import android.view.LayoutInflater import android.view.View @@ -14,10 +13,14 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.livedata.observeAsState import androidx.core.app.ActivityOptionsCompat import androidx.core.content.ContextCompat +import androidx.core.net.toUri import androidx.fragment.app.activityViewModels import androidx.fragment.app.setFragmentResultListener +import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.lifecycleScope import androidx.lifecycle.map +import androidx.lifecycle.repeatOnLifecycle import androidx.recyclerview.widget.DividerItemDecoration import com.bumptech.glide.Glide import com.bumptech.glide.load.resource.bitmap.CircleCrop @@ -26,6 +29,7 @@ import com.google.android.material.snackbar.Snackbar import io.reactivex.Flowable import io.reactivex.Single import io.reactivex.functions.Consumer +import kotlinx.coroutines.launch import org.dhis2.R import org.dhis2.bindings.app import org.dhis2.commons.Constants @@ -129,6 +133,7 @@ class TEIDataFragment : getString("TEI_UID") ?: throw NullPointerException("A TEI uid is required to launch fragment") val enrollmentUid = getString("ENROLLMENT_UID") ?: "" + val fragmentFromEventCaptureActivity = getBoolean("FRAGMENT_FROM_EVENT_CAPTURE_ACTIVITY", false) app() .dashboardComponent() ?.plus( @@ -137,6 +142,7 @@ class TEIDataFragment : programUid, teiUid, enrollmentUid, + fragmentFromEventCaptureActivity, requireActivity().activityResultRegistry, ), )?.inject(this@TEIDataFragment) @@ -152,10 +158,6 @@ class TEIDataFragment : .inflate(inflater, container, false) .also { binding -> this.binding = binding - dashboardViewModel.groupByStage.observe(viewLifecycleOwner) { group -> - showLoadingProgress(true) - presenter.onGroupingChanged(group) - } with(dashboardViewModel) { eventUid().observe(viewLifecycleOwner, ::displayGenerateEvent) @@ -166,8 +168,21 @@ class TEIDataFragment : showDetailCard() } } - dashboardModel.observe(viewLifecycleOwner) { - presenter.checkIfHasToDisplayGenerateEvent() + lifecycleScope.launch { + repeatOnLifecycle(Lifecycle.State.STARTED) { + dashboardModel.collect { + presenter.checkIfHasToDisplayGenerateEvent() + } + + } + } + lifecycleScope.launch { + repeatOnLifecycle(Lifecycle.State.STARTED) { + dashboardViewModel.groupByStage.collect { group -> + showLoadingProgress(true) + presenter.onGroupingChanged(group) + } + } } } @@ -235,18 +250,18 @@ class TEIDataFragment : private fun showDetailCard() { binding.detailCard.setContent { if (isUserLoggedIn()) { - val dashboardModel by dashboardViewModel.dashboardModel.observeAsState() + val dashboardModel by dashboardViewModel.dashboardModel.collectAsState() val followUp by dashboardViewModel.showFollowUpBar.collectAsState() val syncNeeded by dashboardViewModel.syncNeeded.collectAsState() val enrollmentStatus by dashboardViewModel.showStatusBar.collectAsState() - val groupingEvents by dashboardViewModel.groupByStage.observeAsState() + val groupingEvents by dashboardViewModel.groupByStage.collectAsState() val displayEventCreationButton by presenter.shouldDisplayEventCreationButton.observeAsState( false, ) val eventCount by presenter.events.map { it.count() }.observeAsState(0) val syncInfoBar = - dashboardModel.takeIf { it is DashboardEnrollmentModel }?.let { + dashboardModel.takeIf { it is DashboardEnrollmentModel && !presenter.fragmentIsFromEventCaptureActivity() }?.let { infoBarMapper.map( infoBarType = InfoBarType.SYNC, item = dashboardModel as DashboardEnrollmentModel, @@ -312,7 +327,7 @@ class TEIDataFragment : TeiDetailDashboard( infoBarModels = listOfNotNull(syncInfoBar, followUpInfoBar, enrollmentInfoBar), card = card, - isGrouped = groupingEvents ?: true, + isGrouped = groupingEvents, timelineEventHeaderModel = TimelineEventsHeaderModel( displayEventCreationButton, @@ -374,7 +389,7 @@ class TEIDataFragment : binding.cardFront.teiImage.visibility = View.VISIBLE Glide .with(this) - .load(dashboardModel?.avatarPath) + .load(dashboardModel.avatarPath) .fallback(R.drawable.photo_temp_gray) .transition(DrawableTransitionOptions.withCrossFade()) .transform(CircleCrop()) @@ -383,7 +398,7 @@ class TEIDataFragment : binding.header = when { !dashboardModel?.teiHeader.isNullOrEmpty() -> { - dashboardModel?.teiHeader + dashboardModel.teiHeader } else -> { @@ -427,11 +442,11 @@ class TEIDataFragment : Intent(action).apply { when (action) { Intent.ACTION_DIAL -> { - data = Uri.parse("tel:$value") + data = "tel:$value".toUri() } Intent.ACTION_SENDTO -> { - data = Uri.parse("mailto:$value") + data = "mailto:$value".toUri() } } } @@ -440,7 +455,7 @@ class TEIDataFragment : try { startActivity(chooser) - } catch (e: ActivityNotFoundException) { + } catch (_: ActivityNotFoundException) { Timber.e("No activity found that can handle this action") } } @@ -758,12 +773,16 @@ class TEIDataFragment : programUid: String?, teiUid: String?, enrollmentUid: String?, + fragmentFromEventCaptureActivity: Boolean? = null, ): TEIDataFragment { val fragment = TEIDataFragment() val args = Bundle() args.putString("PROGRAM_UID", programUid) args.putString("TEI_UID", teiUid) args.putString("ENROLLMENT_UID", enrollmentUid) + fragmentFromEventCaptureActivity?.let { + args.putBoolean("FRAGMENT_FROM_EVENT_CAPTURE_ACTIVITY", fragmentFromEventCaptureActivity) + } fragment.arguments = args return fragment } diff --git a/app/src/main/java/org/dhis2/usescases/teiDashboard/dashboardfragments/teidata/TEIDataModule.kt b/app/src/main/java/org/dhis2/usescases/teiDashboard/dashboardfragments/teidata/TEIDataModule.kt index 484f9a05b85..6cd71c968b7 100644 --- a/app/src/main/java/org/dhis2/usescases/teiDashboard/dashboardfragments/teidata/TEIDataModule.kt +++ b/app/src/main/java/org/dhis2/usescases/teiDashboard/dashboardfragments/teidata/TEIDataModule.kt @@ -39,6 +39,7 @@ class TEIDataModule( private val programUid: String?, private val teiUid: String, private val enrollmentUid: String, + private val fragmentIsFromEventCaptureActivity: Boolean, private val registry: ActivityResultRegistry, ) { @Provides @@ -61,25 +62,26 @@ class TEIDataModule( preferences: PreferenceProvider, ): TEIDataPresenter = TEIDataPresenter( - view, - d2, - dashboardRepository, - teiDataRepository, - ruleEngineHelper, - programUid, - teiUid, - enrollmentUid, - schedulerProvider, - analyticsHelper, - valueStore, - optionsRepository, - getNewEventCreationTypeOptions, - eventCreationOptionsMapper, - contractHandler, - dispatcherProvider, - createEventUseCase, - d2ErrorUtils, - preferences, + view = view, + d2 = d2, + dashboardRepository = dashboardRepository, + teiDataRepository = teiDataRepository, + ruleEngineHelper = ruleEngineHelper, + programUid = programUid, + teiUid = teiUid, + enrollmentUid = enrollmentUid, + fragmentIsFromEventCaptureActivity = fragmentIsFromEventCaptureActivity, + schedulerProvider = schedulerProvider, + analyticsHelper = analyticsHelper, + valueStore = valueStore, + optionsRepository = optionsRepository, + getNewEventCreationTypeOptions = getNewEventCreationTypeOptions, + eventCreationOptionsMapper = eventCreationOptionsMapper, + contractHandler = contractHandler, + dispatcher = dispatcherProvider, + createEventUseCase = createEventUseCase, + d2ErrorUtils = d2ErrorUtils, + preferences = preferences, ) @Provides diff --git a/app/src/main/java/org/dhis2/usescases/teiDashboard/dashboardfragments/teidata/TEIDataPresenter.kt b/app/src/main/java/org/dhis2/usescases/teiDashboard/dashboardfragments/teidata/TEIDataPresenter.kt index 1beb0ac69e8..5f23d235eaa 100644 --- a/app/src/main/java/org/dhis2/usescases/teiDashboard/dashboardfragments/teidata/TEIDataPresenter.kt +++ b/app/src/main/java/org/dhis2/usescases/teiDashboard/dashboardfragments/teidata/TEIDataPresenter.kt @@ -65,6 +65,7 @@ class TEIDataPresenter( private var programUid: String?, private val teiUid: String, private val enrollmentUid: String, + private val fragmentIsFromEventCaptureActivity: Boolean, private val schedulerProvider: SchedulerProvider, private val analyticsHelper: AnalyticsHelper, private val valueStore: FormValueStore, @@ -110,8 +111,8 @@ class TEIDataPresenter( sectionFlowable, groupingFlowable, ::Pair, - ).doOnNext { increment() } - .switchMap { stageAndGrouping -> + ).switchMap { stageAndGrouping -> + increment() Flowable .zip( teiDataRepository @@ -130,6 +131,7 @@ class TEIDataPresenter( calcResult, ) }.subscribeOn(schedulerProvider.io()) + .doOnCancel { decrement() } }.subscribeOn(schedulerProvider.io()) .observeOn(schedulerProvider.ui()) .subscribe( @@ -137,7 +139,10 @@ class TEIDataPresenter( _events.postValue(events) decrement() }, - Timber.Forest::d, + { t -> + Timber.e(t) + decrement() + }, ), ) @@ -237,6 +242,8 @@ class TEIDataPresenter( } } + fun fragmentIsFromEventCaptureActivity(): Boolean = fragmentIsFromEventCaptureActivity + fun completeEnrollment() { val hasWriteAccessInProgram = programUid?.let { diff --git a/app/src/main/java/org/dhis2/usescases/teiDashboard/dashboardfragments/teidata/TeiDataIdlingResourceSingleton.kt b/app/src/main/java/org/dhis2/usescases/teiDashboard/dashboardfragments/teidata/TeiDataIdlingResourceSingleton.kt index 03dd98b44e1..fe1c2e6665f 100644 --- a/app/src/main/java/org/dhis2/usescases/teiDashboard/dashboardfragments/teidata/TeiDataIdlingResourceSingleton.kt +++ b/app/src/main/java/org/dhis2/usescases/teiDashboard/dashboardfragments/teidata/TeiDataIdlingResourceSingleton.kt @@ -9,9 +9,7 @@ object TeiDataIdlingResourceSingleton { val countingIdlingResource = CountingIdlingResource(RESOURCE) fun increment() { - if (countingIdlingResource.isIdleNow) { - countingIdlingResource.increment() - } + countingIdlingResource.increment() countingIdlingResource.dumpStateToLogs() } diff --git a/app/src/main/java/org/dhis2/usescases/teiDashboard/ui/TeiDashboardMenu.kt b/app/src/main/java/org/dhis2/usescases/teiDashboard/ui/TeiDashboardMenu.kt index 0953f86f419..a84cda784b9 100644 --- a/app/src/main/java/org/dhis2/usescases/teiDashboard/ui/TeiDashboardMenu.kt +++ b/app/src/main/java/org/dhis2/usescases/teiDashboard/ui/TeiDashboardMenu.kt @@ -112,7 +112,7 @@ private fun MutableList>.addTimelineOrGroupBySt dashboardViewModel: DashboardViewModel, resourceManager: ResourceManager, ) { - if (dashboardViewModel.groupByStage.value != false) { + if (dashboardViewModel.groupByStage.value) { add( MenuItemData( id = EnrollmentMenuItem.VIEW_TIMELINE, diff --git a/app/src/test/java/org/dhis2/usescases/main/MainViewModelIntegrationTest.kt b/app/src/test/java/org/dhis2/usescases/main/MainViewModelIntegrationTest.kt index 9f14b3c78b5..ea5540a4474 100644 --- a/app/src/test/java/org/dhis2/usescases/main/MainViewModelIntegrationTest.kt +++ b/app/src/test/java/org/dhis2/usescases/main/MainViewModelIntegrationTest.kt @@ -20,6 +20,7 @@ import org.dhis2.commons.viewmodel.DispatcherProvider import org.dhis2.mobile.commons.providers.PreferenceProvider import org.dhis2.mobile.sync.data.SyncBackgroundJobAction import org.dhis2.mobile.sync.domain.SyncStatusController +import org.dhis2.mobile.sync.model.SyncJobStatus import org.dhis2.mobile.sync.model.SyncStatusData import org.dhis2.usescases.main.data.HomeRepository import org.dhis2.usescases.main.domain.CheckSingleNavigation @@ -83,6 +84,7 @@ class MainViewModelIntegrationTest { private val scheduleNewVersionAlert: ScheduleNewVersionAlert = mock() private val syncStatusFlow = MutableStateFlow(SyncStatusData()) + private val metadataJobFlow = MutableStateFlow>(emptyList()) private val newVersionFlow = MutableSharedFlow() private val filterManagerFlow = MutableStateFlow(0) @@ -120,6 +122,7 @@ class MainViewModelIntegrationTest { ) whenever(syncStatusController.observeDownloadProcess()) doReturn syncStatusFlow + whenever(syncBackgroundJobAction.observeMetadataJob()) doReturn metadataJobFlow whenever(scheduleNewVersionAlert.newVersionFlow) doReturn newVersionFlow whenever(filterManager.asFlow(any())) doReturn filterManagerFlow whenever(filterManager.periodRequest) doReturn mock() diff --git a/app/src/test/java/org/dhis2/usescases/main/MainViewModelTest.kt b/app/src/test/java/org/dhis2/usescases/main/MainViewModelTest.kt index 34d6b3e81f6..7b972eeb16c 100644 --- a/app/src/test/java/org/dhis2/usescases/main/MainViewModelTest.kt +++ b/app/src/test/java/org/dhis2/usescases/main/MainViewModelTest.kt @@ -24,8 +24,11 @@ import org.dhis2.commons.resources.ResourceManager import org.dhis2.commons.viewmodel.DispatcherProvider import org.dhis2.mobile.commons.domain.invoke import org.dhis2.mobile.commons.providers.PreferenceProvider +import org.dhis2.mobile.sync.data.METADATA_SYNC_NOW import org.dhis2.mobile.sync.data.SyncBackgroundJobAction import org.dhis2.mobile.sync.domain.SyncStatusController +import org.dhis2.mobile.sync.model.SyncJobStatus +import org.dhis2.mobile.sync.model.SyncStatus import org.dhis2.mobile.sync.model.SyncStatusData import org.dhis2.usescases.main.domain.CheckSingleNavigation import org.dhis2.usescases.main.domain.ConfigureHomeNavigationBar @@ -55,6 +58,7 @@ import org.junit.Test import org.mockito.kotlin.any import org.mockito.kotlin.doReturn import org.mockito.kotlin.mock +import org.mockito.kotlin.never import org.mockito.kotlin.times import org.mockito.kotlin.verify import org.mockito.kotlin.whenever @@ -96,6 +100,16 @@ class MainViewModelTest { private val syncStatusFlow = MutableStateFlow(SyncStatusData()) private val newVersionFlow = MutableSharedFlow() + private val metadataJobFlow = MutableStateFlow>(emptyList()) + + private val succeededMetadataJob = + listOf( + SyncJobStatus( + tags = listOf(METADATA_SYNC_NOW), + status = SyncStatus.Succeed, + message = null, + ), + ) @Before fun setUp() = runTest { @@ -108,6 +122,7 @@ class MainViewModelTest { whenever(filterManager.ouTreeFlowable()) doReturn mock() whenever(launchInitialSync()) doReturn Result.success(InitialSyncAction.Skip) whenever(checkSingleNavigation()) doReturn Result.failure(Exception("no single navigation")) + whenever(syncBackgroundJobAction.observeMetadataJob()) doReturn metadataJobFlow viewModel = MainViewModel( preferences = preferences, @@ -165,6 +180,58 @@ class MainViewModelTest { } } + @Test + fun `Should reconfigure navigation bar when metadata sync succeeds`() = + runTest { + whenever(configureHomeNavigationBar()) doReturn Result.success(emptyList()) + metadataJobFlow.emit(succeededMetadataJob) + advanceUntilIdle() + verify(configureHomeNavigationBar).invoke(Unit) + } + + @Test + fun `Should not reconfigure navigation bar when metadata sync emits after logout`() = + runTest { + whenever(logOutUser()) doReturn Result.success(1) + viewModel.homeEffects.test { + viewModel.logOut() + metadataJobFlow.emit(succeededMetadataJob) + advanceUntilIdle() + assertTrue(awaitItem() == HomeEffect.GoToLogin(1, false)) + verify(configureHomeNavigationBar, never()).invoke(Unit) + } + } + + @Test + fun `Should keep observing metadata sync when logout fails`() = + runTest { + whenever(logOutUser()) doReturn Result.failure(Exception("logout failed")) + whenever(configureHomeNavigationBar()) doReturn Result.success(emptyList()) + viewModel.logOut() + advanceUntilIdle() + metadataJobFlow.emit(succeededMetadataJob) + advanceUntilIdle() + verify(configureHomeNavigationBar).invoke(Unit) + } + + @Test + fun `Should not reconfigure navigation bar when metadata sync emits after account deletion`() = + runTest { + whenever(deleteAccount(any())) doReturn Result.success(1) + viewModel.homeEffects.test { + val mockedContext = mock() + whenever(mockedContext.cacheDir) doReturn File("random") + with(mockedContext) { + viewModel.onDeleteAccount() + } + metadataJobFlow.emit(succeededMetadataJob) + advanceUntilIdle() + assertTrue(awaitItem() == HomeEffect.ShowDeleteNotification) + assertTrue(awaitItem() == HomeEffect.GoToLogin(1, true)) + verify(configureHomeNavigationBar, never()).invoke(Unit) + } + } + @Test fun `Should block session`() = runTest { 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 index 17d861dfa9a..fbb09035be3 100644 --- a/app/src/test/java/org/dhis2/usescases/main/domain/LaunchInitialSyncTest.kt +++ b/app/src/test/java/org/dhis2/usescases/main/domain/LaunchInitialSyncTest.kt @@ -12,6 +12,7 @@ import org.junit.Test import org.mockito.kotlin.doReturn import org.mockito.kotlin.given import org.mockito.kotlin.mock +import org.mockito.kotlin.never import org.mockito.kotlin.verify import org.mockito.kotlin.verifyNoInteractions import org.mockito.kotlin.whenever @@ -57,6 +58,7 @@ class LaunchInitialSyncTest { assertTrue(result.isSuccess) assertEquals(InitialSyncAction.Skip, result.getOrNull()) + verify(versionRepository, never()).downloadLatestVersionInfo() } @Test @@ -76,10 +78,11 @@ class LaunchInitialSyncTest { assertTrue(result.isSuccess) assertEquals(InitialSyncAction.Skip, result.getOrNull()) + verify(versionRepository, never()).downloadLatestVersionInfo() } @Test - fun `should return Syncing and launch initial sync`() = + fun `should return Syncing, check version and launch initial sync`() = runTest { whenever(homeRepository.isImportedDb()) doReturn false whenever(homeRepository.getInitialSyncDone()) doReturn false @@ -95,17 +98,17 @@ class LaunchInitialSyncTest { assertTrue(result.isSuccess) assertEquals(InitialSyncAction.Syncing, result.getOrNull()) - verify(versionRepository).checkVersionUpdates() + verify(versionRepository).downloadLatestVersionInfo() + verify(syncBackgroundJobAction).launchDataSync(0) } @Test - fun `should return failure if check version update fails`() = + fun `should still launch sync even if version check fails`() = runTest { - val exception = DomainError.DatabaseError("Error") whenever(homeRepository.isImportedDb()) doReturn false whenever(homeRepository.getInitialSyncDone()) doReturn false - given(versionRepository.checkVersionUpdates()).willAnswer { - throw exception + given(versionRepository.downloadLatestVersionInfo()).willAnswer { + throw RuntimeException("Network error") } launchInitialSync = LaunchInitialSync( @@ -117,8 +120,9 @@ class LaunchInitialSyncTest { val result = launchInitialSync() - assertTrue(result.isFailure) - assertEquals(exception, result.exceptionOrNull()) + assertTrue(result.isSuccess) + assertEquals(InitialSyncAction.Syncing, result.getOrNull()) + verify(syncBackgroundJobAction).launchDataSync(0) } @Test 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 7fe223dfad8..5a33800a241 100644 --- a/app/src/test/java/org/dhis2/usescases/settings/SyncManagerPresenterTest.kt +++ b/app/src/test/java/org/dhis2/usescases/settings/SyncManagerPresenterTest.kt @@ -15,6 +15,7 @@ import org.dhis2.bindings.toDate import org.dhis2.commons.Constants import org.dhis2.commons.network.NetworkUtils import org.dhis2.commons.viewmodel.DispatcherProvider +import org.dhis2.mobile.commons.domain.invoke import org.dhis2.usescases.settings.domain.CheckVersionUpdate import org.dhis2.usescases.settings.domain.DeleteLocalData import org.dhis2.usescases.settings.domain.ExportDatabase diff --git a/app/src/test/java/org/dhis2/usescases/settings/domain/CheckVersionUpdateTest.kt b/app/src/test/java/org/dhis2/usescases/settings/domain/CheckVersionUpdateTest.kt index 2ba584a13e6..dd2d2af94ef 100644 --- a/app/src/test/java/org/dhis2/usescases/settings/domain/CheckVersionUpdateTest.kt +++ b/app/src/test/java/org/dhis2/usescases/settings/domain/CheckVersionUpdateTest.kt @@ -4,6 +4,7 @@ import kotlinx.coroutines.test.runTest import org.dhis2.R import org.dhis2.commons.resources.ResourceManager import org.dhis2.data.service.VersionRepository +import org.dhis2.mobile.commons.domain.invoke import org.junit.Before import org.junit.Test import org.mockito.Mockito.mock @@ -31,19 +32,18 @@ class CheckVersionUpdateTest { @Test fun `should check version update`() = runTest { - whenever(versionRepository.getLatestVersionInfo()) doReturn "new.version.name" + whenever(versionRepository.downloadLatestVersionInfo()) doReturn "new.version.name" checkVersionUpdate() - verify(versionRepository, times(1)).checkVersionUpdates() + verify(versionRepository, times(1)).downloadLatestVersionInfo() } @Test fun `should send no new version message`() = runTest { val noUpdateMsg = "No updates" - whenever(versionRepository.getLatestVersionInfo()) doReturn null + whenever(versionRepository.downloadLatestVersionInfo()) doReturn null whenever(resourceManager.getString(R.string.no_updates)) doReturn noUpdateMsg checkVersionUpdate() verify(settingsMessages, times(1)).sendMessage(noUpdateMsg) - verify(versionRepository, times(0)).checkVersionUpdates() } } diff --git a/app/src/test/java/org/dhis2/usescases/teiDashboard/DashboardViewModelTest.kt b/app/src/test/java/org/dhis2/usescases/teiDashboard/DashboardViewModelTest.kt index 15fafeeb496..8490b909105 100644 --- a/app/src/test/java/org/dhis2/usescases/teiDashboard/DashboardViewModelTest.kt +++ b/app/src/test/java/org/dhis2/usescases/teiDashboard/DashboardViewModelTest.kt @@ -1,12 +1,14 @@ package org.dhis2.usescases.teiDashboard import androidx.arch.core.executor.testing.InstantTaskExecutorRule +import app.cash.turbine.test import io.reactivex.Observable import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.StandardTestDispatcher import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.setMain import org.dhis2.commons.resources.ResourceManager import org.dhis2.commons.viewmodel.DispatcherProvider @@ -47,52 +49,60 @@ class DashboardViewModelTest { @Before fun setUp() { Dispatchers.setMain(testingDispatcher) + whenever(repository.isProgramSelected()) doReturn true } @Test - fun shouldFetchEnrollmentModel() { + fun shouldFetchEnrollmentModel() = runTest { mockEnrollmentModel() mockGrouping(true) val dashboardViewModel = getViewModel() - with(dashboardViewModel) { - assertTrue(dashboardModel.value == mockedEnrollmentModel) - assertTrue(showFollowUpBar.value) - assertTrue(!syncNeeded.value) - assertTrue(showStatusBar.value == EnrollmentStatus.ACTIVE) - assertTrue(state.value == State.SYNCED) + dashboardViewModel.dashboardModel.test { + awaitItem() + with(awaitItem()) { + assertTrue(this == mockedEnrollmentModel) + assertTrue(dashboardViewModel.showFollowUpBar.value) + assertTrue(!dashboardViewModel.syncNeeded.value) + assertTrue(dashboardViewModel.showStatusBar.value == EnrollmentStatus.ACTIVE) + assertTrue(dashboardViewModel.state.value == State.SYNCED) + } } } @Test - fun shouldFetchTeiModel() { + fun shouldFetchTeiModel() = runTest { mockTeiModel() mockGrouping(false) val dashboardViewModel = getViewModel() - with(dashboardViewModel) { - assertTrue(dashboardModel.value == mockedTeiModel) - assertTrue(!showFollowUpBar.value) - assertTrue(!syncNeeded.value) - assertTrue(showStatusBar.value == null) - assertTrue(state.value == null) + dashboardViewModel.dashboardModel.test { + awaitItem() + with(awaitItem()) { + assertTrue(this == mockedTeiModel) + assertTrue(!dashboardViewModel.showFollowUpBar.value) + assertTrue(!dashboardViewModel.syncNeeded.value) + assertTrue(dashboardViewModel.showStatusBar.value == null) + assertTrue(dashboardViewModel.state.value == null) + } } } @Test - fun shouldSetGrouping() { + fun shouldSetGrouping() = runTest { mockEnrollmentModel() mockGrouping(false) val dashboardViewModel = getViewModel() - with(dashboardViewModel) { - assertTrue(groupByStage.value == false) - setGrouping(true) + dashboardViewModel.groupByStage.test { + assertTrue(!awaitItem()) + dashboardViewModel.setGrouping(true) verify(repository).setGrouping(true) - assertTrue(groupByStage.value == true) + assertTrue(awaitItem()) + cancelAndIgnoreRemainingEvents() } } @@ -109,51 +119,68 @@ class DashboardViewModelTest { } @Test - fun shouldSetFollowUpOnEnrollment() { + fun shouldSetFollowUpOnEnrollment() = runTest { mockEnrollmentModel() mockGrouping(false) - with(getViewModel()) { - onFollowUp() - verify(repository).setFollowUp("enrollmentUid") - assertTrue(state.value == State.TO_UPDATE) - verify(analyticsHelper).setEvent(ACTIVE_FOLLOW_UP, "false", FOLLOW_UP) + val viewModel = getViewModel() + viewModel.dashboardModel.test { + awaitItem() + awaitItem() + with(viewModel) { + onFollowUp() + verify(repository).setFollowUp("enrollmentUid") + assertTrue(state.value == State.TO_UPDATE) + verify(analyticsHelper).setEvent(ACTIVE_FOLLOW_UP, "false", FOLLOW_UP) + } } } @Test - fun shouldUpdateEnrollmentStatus() { + fun shouldUpdateEnrollmentStatus() = runTest { mockEnrollmentModel() mockGrouping(false) - - with(getViewModel()) { - whenever(repository.updateEnrollmentStatus(any(), any())) doReturn - Observable.just( - StatusChangeResultCode.CHANGED, + val viewModel = getViewModel() + viewModel.dashboardModel.test { + awaitItem() + awaitItem() + with(viewModel) { + whenever(repository.updateEnrollmentStatus(any(), any())) doReturn + Observable.just( + StatusChangeResultCode.CHANGED, + ) + whenever(mockedEnrollmentModel.currentEnrollment) doReturn mockedCompletedEnrollment + updateEnrollmentStatus(EnrollmentStatus.COMPLETED) + testingDispatcher.scheduler.advanceUntilIdle() + verify(repository).updateEnrollmentStatus( + "enrollmentUid", + EnrollmentStatus.COMPLETED ) - whenever(mockedEnrollmentModel.currentEnrollment) doReturn mockedCompletedEnrollment - updateEnrollmentStatus(EnrollmentStatus.COMPLETED) - testingDispatcher.scheduler.advanceUntilIdle() - verify(repository).updateEnrollmentStatus("enrollmentUid", EnrollmentStatus.COMPLETED) - assertTrue(showStatusBar.value == EnrollmentStatus.COMPLETED) - assertTrue(syncNeeded.value) - assertTrue(state.value == State.TO_UPDATE) + assertTrue(showStatusBar.value == EnrollmentStatus.COMPLETED) + assertTrue(syncNeeded.value) + assertTrue(state.value == State.TO_UPDATE) + } } } @Test - fun shouldShowMessageIfErrorWhileUpdatingEnrollmentStatus() { + fun shouldShowMessageIfErrorWhileUpdatingEnrollmentStatus() = runTest { mockEnrollmentModel() mockGrouping(false) - - with(getViewModel()) { - whenever(repository.updateEnrollmentStatus(any(), any())) doReturn - Observable.just( - StatusChangeResultCode.FAILED, - ) - updateEnrollmentStatus(EnrollmentStatus.COMPLETED) - testingDispatcher.scheduler.advanceUntilIdle() - assertTrue(showStatusErrorMessages.value == StatusChangeResultCode.FAILED) + val viewModel = getViewModel() + + viewModel.dashboardModel.test { + awaitItem() + awaitItem() + with(viewModel) { + whenever(repository.updateEnrollmentStatus(any(), any())) doReturn + Observable.just( + StatusChangeResultCode.FAILED, + ) + updateEnrollmentStatus(EnrollmentStatus.COMPLETED) + testingDispatcher.scheduler.advanceUntilIdle() + assertTrue(showStatusErrorMessages.value == StatusChangeResultCode.FAILED) + } } } diff --git a/app/src/test/java/org/dhis2/usescases/teiDashboard/dashboardfragments/data/TeiDataPresenterTest.kt b/app/src/test/java/org/dhis2/usescases/teiDashboard/dashboardfragments/data/TeiDataPresenterTest.kt index 2fe28392f1a..101fe6c8b62 100644 --- a/app/src/test/java/org/dhis2/usescases/teiDashboard/dashboardfragments/data/TeiDataPresenterTest.kt +++ b/app/src/test/java/org/dhis2/usescases/teiDashboard/dashboardfragments/data/TeiDataPresenterTest.kt @@ -96,6 +96,7 @@ class TeiDataPresenterTest { programUid, teiUid, enrollmentUid, + false, schedulers, analytics, valueStore, diff --git a/commons/src/main/java/org/dhis2/commons/bindings/ValueExtensions.kt b/commons/src/main/java/org/dhis2/commons/bindings/ValueExtensions.kt index e3273bf880f..014481f6c76 100644 --- a/commons/src/main/java/org/dhis2/commons/bindings/ValueExtensions.kt +++ b/commons/src/main/java/org/dhis2/commons/bindings/ValueExtensions.kt @@ -243,6 +243,8 @@ fun TrackedEntityDataValueObjectRepository.blockingGetValueCheck( ) ) { blockingGet() + } else if (it.valueType()?.isFile == true) { + null } else { blockingDeleteIfExist() null diff --git a/commonskmm/src/androidMain/kotlin/org/dhis2/mobile/commons/coroutine/AndroidIdlingResource.kt b/commonskmm/src/androidMain/kotlin/org/dhis2/mobile/commons/coroutine/AndroidIdlingResource.kt index 939d2479d6a..c0e5953d95f 100644 --- a/commonskmm/src/androidMain/kotlin/org/dhis2/mobile/commons/coroutine/AndroidIdlingResource.kt +++ b/commonskmm/src/androidMain/kotlin/org/dhis2/mobile/commons/coroutine/AndroidIdlingResource.kt @@ -13,6 +13,11 @@ object AndroidIdlingResource : CoroutineIdlingResource { countingIdlingResource.dumpStateToLogs() } + override fun unconditionalIncrement() { + countingIdlingResource.increment() + countingIdlingResource.dumpStateToLogs() + } + override fun decrement() { if (!countingIdlingResource.isIdleNow) { countingIdlingResource.decrement() @@ -20,5 +25,10 @@ object AndroidIdlingResource : CoroutineIdlingResource { countingIdlingResource.dumpStateToLogs() } + override fun unconditionalDecrement() { + countingIdlingResource.decrement() + countingIdlingResource.dumpStateToLogs() + } + fun getIdlingResource() = countingIdlingResource } diff --git a/commonskmm/src/commonMain/kotlin/org/dhis2/mobile/commons/coroutine/CoroutineIdlingResource.kt b/commonskmm/src/commonMain/kotlin/org/dhis2/mobile/commons/coroutine/CoroutineIdlingResource.kt index 0e8103045dd..19966279093 100644 --- a/commonskmm/src/commonMain/kotlin/org/dhis2/mobile/commons/coroutine/CoroutineIdlingResource.kt +++ b/commonskmm/src/commonMain/kotlin/org/dhis2/mobile/commons/coroutine/CoroutineIdlingResource.kt @@ -3,5 +3,9 @@ package org.dhis2.mobile.commons.coroutine interface CoroutineIdlingResource { fun increment() + fun unconditionalIncrement() + fun decrement() + + fun unconditionalDecrement() } diff --git a/commonskmm/src/commonMain/kotlin/org/dhis2/mobile/commons/coroutine/CoroutineTracker.kt b/commonskmm/src/commonMain/kotlin/org/dhis2/mobile/commons/coroutine/CoroutineTracker.kt index e51ac96bca3..7fcabc50b82 100644 --- a/commonskmm/src/commonMain/kotlin/org/dhis2/mobile/commons/coroutine/CoroutineTracker.kt +++ b/commonskmm/src/commonMain/kotlin/org/dhis2/mobile/commons/coroutine/CoroutineTracker.kt @@ -9,6 +9,10 @@ object CoroutineTracker { IdlingResourceProvider.idlingResource.increment() } + fun unconditionalIncrement() { + IdlingResourceProvider.idlingResource.unconditionalIncrement() + } + fun decrement() { var current: Int var next: Int @@ -18,4 +22,8 @@ object CoroutineTracker { } while (!activeTasks.compareAndSet(current, next)) IdlingResourceProvider.idlingResource.decrement() } + + fun unconditionalDecrement() { + IdlingResourceProvider.idlingResource.unconditionalDecrement() + } } diff --git a/commonskmm/src/commonMain/kotlin/org/dhis2/mobile/commons/coroutine/NoOpIdlingResource.kt b/commonskmm/src/commonMain/kotlin/org/dhis2/mobile/commons/coroutine/NoOpIdlingResource.kt index b423289b545..d15cee9e433 100644 --- a/commonskmm/src/commonMain/kotlin/org/dhis2/mobile/commons/coroutine/NoOpIdlingResource.kt +++ b/commonskmm/src/commonMain/kotlin/org/dhis2/mobile/commons/coroutine/NoOpIdlingResource.kt @@ -5,7 +5,15 @@ object NoOpIdlingResource : CoroutineIdlingResource { // No-op: Do nothing } + override fun unconditionalIncrement() { + // No-op: Do nothing + } + override fun decrement() { // No-op: Do nothing } + + override fun unconditionalDecrement() { + // No-op: Do nothing + } } diff --git a/commonskmm/src/commonMain/kotlin/org/dhis2/mobile/commons/extensions/ViewModelExtensions.kt b/commonskmm/src/commonMain/kotlin/org/dhis2/mobile/commons/extensions/ViewModelExtensions.kt index 27f2c919e50..f7313dd7f83 100644 --- a/commonskmm/src/commonMain/kotlin/org/dhis2/mobile/commons/extensions/ViewModelExtensions.kt +++ b/commonskmm/src/commonMain/kotlin/org/dhis2/mobile/commons/extensions/ViewModelExtensions.kt @@ -19,10 +19,10 @@ fun ViewModel.launchUseCase( dispatcher: CoroutineContext = EmptyCoroutineContext, block: suspend CoroutineScope.() -> Unit, ) = viewModelScope.launch(dispatcher) { - CoroutineTracker.increment() + CoroutineTracker.unconditionalIncrement() try { block() } finally { - CoroutineTracker.decrement() + CoroutineTracker.unconditionalDecrement() } } diff --git a/dhis_android_analytics/src/main/java/dhis2/org/analytics/charts/ChartsRepositoryImpl.kt b/dhis_android_analytics/src/main/java/dhis2/org/analytics/charts/ChartsRepositoryImpl.kt index e1ad5bc8641..6a2d20c9781 100644 --- a/dhis_android_analytics/src/main/java/dhis2/org/analytics/charts/ChartsRepositoryImpl.kt +++ b/dhis_android_analytics/src/main/java/dhis2/org/analytics/charts/ChartsRepositoryImpl.kt @@ -8,8 +8,6 @@ import dhis2.org.analytics.charts.data.AnalyticResources import dhis2.org.analytics.charts.data.Graph import dhis2.org.analytics.charts.data.GraphFilters import dhis2.org.analytics.charts.mappers.AnalyticsTeiSettingsToGraph -import dhis2.org.analytics.charts.mappers.DataElementToGraph -import dhis2.org.analytics.charts.mappers.ProgramIndicatorToGraph import dhis2.org.analytics.charts.mappers.VisualizationToGraph import dhis2.org.analytics.charts.providers.AnalyticsFilterProvider import dhis2.org.analytics.charts.ui.OrgUnitFilterType @@ -20,11 +18,8 @@ import org.hisp.dhis.android.core.analytics.trackerlinelist.TrackerLineListRespo import org.hisp.dhis.android.core.arch.repositories.paging.PageConfig import org.hisp.dhis.android.core.common.RelativeOrganisationUnit import org.hisp.dhis.android.core.common.RelativePeriod -import org.hisp.dhis.android.core.dataelement.DataElement import org.hisp.dhis.android.core.enrollment.Enrollment import org.hisp.dhis.android.core.organisationunit.OrganisationUnit -import org.hisp.dhis.android.core.period.PeriodType -import org.hisp.dhis.android.core.program.ProgramIndicator import org.hisp.dhis.android.core.settings.AnalyticsDhisVisualizationType import org.hisp.dhis.android.core.settings.AnalyticsDhisVisualizationsGroup import org.hisp.dhis.android.core.settings.AnalyticsDhisVisualizationsSetting @@ -33,8 +28,6 @@ class ChartsRepositoryImpl( private val d2: D2, private val visualizationToGraph: VisualizationToGraph, private val analyticsTeiSettingsToGraph: AnalyticsTeiSettingsToGraph, - private val dataElementToGraph: DataElementToGraph, - private val programIndicatorToGraph: ProgramIndicatorToGraph, private val analyticsResources: AnalyticResources, private val analyticsFilterProvider: AnalyticsFilterProvider, ) : ChartsRepository { @@ -45,9 +38,7 @@ class ChartsRepositoryImpl( if (enrollment?.trackedEntityInstance() == null) return emptyList() val settingsAnalytics = getSettingsAnalytics(enrollment) - return settingsAnalytics.ifEmpty { - getDefaultAnalytics(enrollment) - } + return settingsAnalytics } override fun getVisualizationGroups(uid: String?): List = @@ -447,73 +438,6 @@ class ChartsRepositoryImpl( ) } - private suspend fun getDefaultAnalytics(enrollment: Enrollment): List = - getRepeatableProgramStages(enrollment.program()) - .map { programStage -> - - val period = programStage.periodType() ?: PeriodType.Daily - - getNumericDataElements(programStage.uid()) - .map { dataElement -> - val selectedRelativePeriod = - analyticsFilterProvider.visualizationPeriod( - enrollment.trackedEntityInstance()!! + - programStage.uid() + - dataElement.uid(), - ) - val selectedOrgUnits = - analyticsFilterProvider.visualizationOrgUnits( - enrollment.trackedEntityInstance()!! + - programStage.uid() + - dataElement.uid(), - ) - dataElementToGraph.map( - dataElement, - programStage.uid(), - enrollment.trackedEntityInstance()!!, - period, - selectedRelativePeriod, - selectedOrgUnits, - true, - ) - }.union( - getStageIndicators(enrollment.program()).map { programIndicator -> - val selectedRelativePeriod = - analyticsFilterProvider.visualizationPeriod( - enrollment.trackedEntityInstance()!! + - programStage.uid() + - programIndicator.uid(), - ) - val selectedOrgUnits = - analyticsFilterProvider.visualizationOrgUnits( - enrollment.trackedEntityInstance()!! + - programStage.uid() + - programIndicator.uid(), - ) - programIndicatorToGraph.map( - programIndicator, - programStage.uid(), - enrollment.trackedEntityInstance()!!, - period, - selectedRelativePeriod, - selectedOrgUnits, - true, - ) - }, - ) - }.flatten() - .filter { it.canBeShown() } - - private fun getRepeatableProgramStages(program: String?) = - d2 - .programModule() - .programStages() - .byProgramUid() - .eq(program) - .byRepeatable() - .eq(true) - .blockingGet() - private fun getEnrollment(enrollmentUid: String) = d2 .enrollmentModule() @@ -521,40 +445,6 @@ class ChartsRepositoryImpl( .uid(enrollmentUid) .blockingGet() - private fun getNumericDataElements(stageUid: String): List = - d2 - .programModule() - .programStageDataElements() - .byProgramStage() - .eq(stageUid) - .blockingGet() - .filter { - d2 - .dataElementModule() - .dataElements() - .uid(it.dataElement()?.uid()) - .blockingGet() - ?.valueType() - ?.isNumeric ?: false - }.mapNotNull { - d2 - .dataElementModule() - .dataElements() - .uid( - it.dataElement()?.uid(), - ).blockingGet() - } - - private fun getStageIndicators(programUid: String?): List = - d2 - .programModule() - .programIndicators() - .byDisplayInForm() - .isTrue - .byProgramUid() - .eq(programUid) - .blockingGet() - override fun setVisualizationPeriods( visualizationUid: String, lineListingColumnId: Int?, diff --git a/dhis_android_analytics/src/main/java/dhis2/org/analytics/charts/di/ChartsInjector.kt b/dhis_android_analytics/src/main/java/dhis2/org/analytics/charts/di/ChartsInjector.kt index 9b365ed6e9b..22a0eca91f8 100644 --- a/dhis_android_analytics/src/main/java/dhis2/org/analytics/charts/di/ChartsInjector.kt +++ b/dhis_android_analytics/src/main/java/dhis2/org/analytics/charts/di/ChartsInjector.kt @@ -8,14 +8,12 @@ import dhis2.org.analytics.charts.Charts import dhis2.org.analytics.charts.ChartsRepository import dhis2.org.analytics.charts.ChartsRepositoryImpl import dhis2.org.analytics.charts.DhisAnalyticCharts -import dhis2.org.analytics.charts.domain.GetEnrollmentAnalyticsUseCase import dhis2.org.analytics.charts.data.AnalyticResources +import dhis2.org.analytics.charts.domain.GetEnrollmentAnalyticsUseCase import dhis2.org.analytics.charts.mappers.AnalyticDataElementToDataElementData import dhis2.org.analytics.charts.mappers.AnalyticIndicatorToIndicatorData import dhis2.org.analytics.charts.mappers.AnalyticTeiSettingsToSettingsAnalyticsModel import dhis2.org.analytics.charts.mappers.AnalyticsTeiSettingsToGraph -import dhis2.org.analytics.charts.mappers.DataElementToGraph -import dhis2.org.analytics.charts.mappers.ProgramIndicatorToGraph import dhis2.org.analytics.charts.mappers.VisualizationToGraph import dhis2.org.analytics.charts.providers.AnalyticsFilterProvider import dhis2.org.analytics.charts.providers.ChartCoordinatesProvider @@ -47,8 +45,6 @@ class ChartsModule { d2: D2, visualizationToGraph: VisualizationToGraph, analyticsTeiSettingsToGraph: AnalyticsTeiSettingsToGraph, - dataElementToGraph: DataElementToGraph, - indicatorToGraph: ProgramIndicatorToGraph, analyticsResources: AnalyticResources, analyticsFilterProvider: AnalyticsFilterProvider, ): ChartsRepository = @@ -56,14 +52,13 @@ class ChartsModule { d2, visualizationToGraph, analyticsTeiSettingsToGraph, - dataElementToGraph, - indicatorToGraph, analyticsResources, analyticsFilterProvider, ) @Provides - internal fun provideAnalyticFilters(d2: D2): AnalyticsFilterProvider = AnalyticsFilterProvider(d2) + internal fun provideAnalyticFilters(d2: D2): AnalyticsFilterProvider = + AnalyticsFilterProvider(d2) @Provides internal fun provideVisualizationToGraph( @@ -86,7 +81,8 @@ class ChartsModule { ) @Provides - internal fun nutritionDataProvider(): NutritionDataProvider = RuleEngineNutritionDataProviderImpl() + internal fun nutritionDataProvider(): NutritionDataProvider = + RuleEngineNutritionDataProviderImpl() @Provides internal fun provideAnalyticSettingsMapper( @@ -98,18 +94,6 @@ class ChartsModule { analyticIndicatorMapper, ) - @Provides - internal fun provideDataElementToGraph( - periodStepProvider: PeriodStepProvider, - chartCoordinatesProvider: ChartCoordinatesProvider, - ): DataElementToGraph = DataElementToGraph(periodStepProvider, chartCoordinatesProvider) - - @Provides - internal fun provideIndicatorToGraph( - periodStepProvider: PeriodStepProvider, - chartCoordinatesProvider: ChartCoordinatesProvider, - ): ProgramIndicatorToGraph = ProgramIndicatorToGraph(periodStepProvider, chartCoordinatesProvider) - @Provides internal fun periodStepProvider( d2: D2, @@ -121,7 +105,8 @@ class ChartsModule { d2: D2, periodStepProvider: PeriodStepProvider, resourceManager: ResourceManager, - ): ChartCoordinatesProvider = ChartCoordinatesProviderImpl(d2, periodStepProvider, resourceManager) + ): ChartCoordinatesProvider = + ChartCoordinatesProviderImpl(d2, periodStepProvider, resourceManager) @Provides internal fun provideResourceManager( @@ -133,17 +118,21 @@ class ChartsModule { internal fun analyticResources(context: Context): AnalyticResources = AnalyticResources(context) @Provides - internal fun provideAnalyticDataElementMapper(): AnalyticDataElementToDataElementData = AnalyticDataElementToDataElementData() + internal fun provideAnalyticDataElementMapper(): AnalyticDataElementToDataElementData = + AnalyticDataElementToDataElementData() @Provides - internal fun provideAnalyticIndicatorMapper(): AnalyticIndicatorToIndicatorData = AnalyticIndicatorToIndicatorData() + internal fun provideAnalyticIndicatorMapper(): AnalyticIndicatorToIndicatorData = + AnalyticIndicatorToIndicatorData() @Provides - internal fun bindStorageFeatureImpl(analyticsCharts: DhisAnalyticCharts): Charts = analyticsCharts + internal fun bindStorageFeatureImpl(analyticsCharts: DhisAnalyticCharts): Charts = + analyticsCharts @Provides internal fun provideGetEnrollmentAnalyticsUseCase( chartsRepository: ChartsRepository, dispatcherProvider: DispatcherProvider, - ): GetEnrollmentAnalyticsUseCase = GetEnrollmentAnalyticsUseCase(chartsRepository, dispatcherProvider) + ): GetEnrollmentAnalyticsUseCase = + GetEnrollmentAnalyticsUseCase(chartsRepository, dispatcherProvider) } diff --git a/dhis_android_analytics/src/main/java/dhis2/org/analytics/charts/di/ChartsModule.kt b/dhis_android_analytics/src/main/java/dhis2/org/analytics/charts/di/ChartsModule.kt index d57cbeb7018..70dfc10943b 100644 --- a/dhis_android_analytics/src/main/java/dhis2/org/analytics/charts/di/ChartsModule.kt +++ b/dhis_android_analytics/src/main/java/dhis2/org/analytics/charts/di/ChartsModule.kt @@ -9,8 +9,6 @@ import dhis2.org.analytics.charts.mappers.AnalyticDataElementToDataElementData import dhis2.org.analytics.charts.mappers.AnalyticIndicatorToIndicatorData import dhis2.org.analytics.charts.mappers.AnalyticTeiSettingsToSettingsAnalyticsModel import dhis2.org.analytics.charts.mappers.AnalyticsTeiSettingsToGraph -import dhis2.org.analytics.charts.mappers.DataElementToGraph -import dhis2.org.analytics.charts.mappers.ProgramIndicatorToGraph import dhis2.org.analytics.charts.mappers.VisualizationToGraph import dhis2.org.analytics.charts.providers.AnalyticsFilterProvider import dhis2.org.analytics.charts.providers.ChartCoordinatesProvider @@ -77,20 +75,6 @@ val chartsModule = module { ) } - factory { - DataElementToGraph( - periodStepProvider = get(), - chartCoordinatesProvider = get() - ) - } - - factory { - ProgramIndicatorToGraph( - periodStepProvider = get(), - chartCoordinatesProvider = get() - ) - } - factoryOf(::AnalyticResources) factoryOf(::AnalyticsFilterProvider) @@ -100,8 +84,6 @@ val chartsModule = module { d2 = get(), visualizationToGraph = get(), analyticsTeiSettingsToGraph = get(), - dataElementToGraph = get(), - programIndicatorToGraph = get(), analyticsResources = get(), analyticsFilterProvider = get() ) diff --git a/dhis_android_analytics/src/main/java/dhis2/org/analytics/charts/mappers/DataElementToGraph.kt b/dhis_android_analytics/src/main/java/dhis2/org/analytics/charts/mappers/DataElementToGraph.kt deleted file mode 100644 index ecd0098997e..00000000000 --- a/dhis_android_analytics/src/main/java/dhis2/org/analytics/charts/mappers/DataElementToGraph.kt +++ /dev/null @@ -1,61 +0,0 @@ -package dhis2.org.analytics.charts.mappers - -import dhis2.org.analytics.charts.data.Graph -import dhis2.org.analytics.charts.data.GraphFilters -import dhis2.org.analytics.charts.data.SerieData -import dhis2.org.analytics.charts.providers.ChartCoordinatesProvider -import dhis2.org.analytics.charts.providers.PeriodStepProvider -import org.hisp.dhis.android.core.common.RelativePeriod -import org.hisp.dhis.android.core.dataelement.DataElement -import org.hisp.dhis.android.core.period.PeriodType - -class DataElementToGraph( - private val periodStepProvider: PeriodStepProvider, - private val chartCoordinatesProvider: ChartCoordinatesProvider, -) { - suspend fun map( - dataElement: DataElement, - stageUid: String, - teiUid: String, - stagePeriod: PeriodType, - selectedRelativePeriod: List?, - selectedOrgUnits: List?, - isDefault: Boolean = false, - ): Graph { - val coordinates = - chartCoordinatesProvider.dataElementCoordinates( - stageUid, - teiUid, - dataElement.uid(), - selectedRelativePeriod, - selectedOrgUnits, - isDefault, - ) - - val serie = - if (coordinates.isNotEmpty()) { - listOf( - SerieData( - dataElement.displayFormName() ?: dataElement.uid(), - coordinates, - ), - ) - } else { - emptyList() - } - - return Graph( - title = "${stagePeriod.name}-${dataElement.displayFormName()}", - series = serie, - periodToDisplayDefault = null, - eventPeriodType = stagePeriod, - periodStep = periodStepProvider.periodStep(stagePeriod), - visualizationUid = "${teiUid}${stageUid}${dataElement.uid()}", - graphFilters = - GraphFilters.Visualization( - periodToDisplaySelected = selectedRelativePeriod?.firstOrNull(), - orgUnitsSelected = selectedOrgUnits ?: emptyList(), - ), - ) - } -} diff --git a/dhis_android_analytics/src/main/java/dhis2/org/analytics/charts/mappers/ProgramIndicatorToGraph.kt b/dhis_android_analytics/src/main/java/dhis2/org/analytics/charts/mappers/ProgramIndicatorToGraph.kt deleted file mode 100644 index fef93ad9439..00000000000 --- a/dhis_android_analytics/src/main/java/dhis2/org/analytics/charts/mappers/ProgramIndicatorToGraph.kt +++ /dev/null @@ -1,61 +0,0 @@ -package dhis2.org.analytics.charts.mappers - -import dhis2.org.analytics.charts.data.Graph -import dhis2.org.analytics.charts.data.GraphFilters -import dhis2.org.analytics.charts.data.SerieData -import dhis2.org.analytics.charts.providers.ChartCoordinatesProvider -import dhis2.org.analytics.charts.providers.PeriodStepProvider -import org.hisp.dhis.android.core.common.RelativePeriod -import org.hisp.dhis.android.core.period.PeriodType -import org.hisp.dhis.android.core.program.ProgramIndicator - -class ProgramIndicatorToGraph( - private val periodStepProvider: PeriodStepProvider, - private val chartCoordinatesProvider: ChartCoordinatesProvider, -) { - suspend fun map( - programIndicator: ProgramIndicator, - stageUid: String, - teiUid: String, - stagePeriod: PeriodType, - selectedRelativePeriod: List?, - selectedOrgUnits: List?, - isDefault: Boolean = false, - ): Graph { - val coordinates = - chartCoordinatesProvider.indicatorCoordinates( - stageUid, - teiUid, - programIndicator.uid(), - selectedRelativePeriod, - selectedOrgUnits, - isDefault, - ) - - val serie = - if (coordinates.isNotEmpty()) { - listOf( - SerieData( - programIndicator.displayName() ?: programIndicator.uid(), - coordinates, - ), - ) - } else { - emptyList() - } - - return Graph( - title = "${stagePeriod.name}-${programIndicator.displayName()}", - series = serie, - periodToDisplayDefault = null, - eventPeriodType = stagePeriod, - periodStep = periodStepProvider.periodStep(stagePeriod), - visualizationUid = "${teiUid}${stageUid}${programIndicator.uid()}", - graphFilters = - GraphFilters.Visualization( - periodToDisplaySelected = selectedRelativePeriod?.firstOrNull(), - orgUnitsSelected = selectedOrgUnits ?: emptyList(), - ), - ) - } -} diff --git a/dhis_android_analytics/src/test/java/dhis2/org/analytics/charts/ChartsRepositoryTest.kt b/dhis_android_analytics/src/test/java/dhis2/org/analytics/charts/ChartsRepositoryTest.kt index 9b0f7116197..52084337da6 100644 --- a/dhis_android_analytics/src/test/java/dhis2/org/analytics/charts/ChartsRepositoryTest.kt +++ b/dhis_android_analytics/src/test/java/dhis2/org/analytics/charts/ChartsRepositoryTest.kt @@ -6,11 +6,10 @@ import dhis2.org.analytics.charts.data.GraphFieldValue import dhis2.org.analytics.charts.data.GraphPoint import dhis2.org.analytics.charts.data.SerieData import dhis2.org.analytics.charts.mappers.AnalyticsTeiSettingsToGraph -import dhis2.org.analytics.charts.mappers.DataElementToGraph -import dhis2.org.analytics.charts.mappers.ProgramIndicatorToGraph import dhis2.org.analytics.charts.mappers.VisualizationToGraph import dhis2.org.analytics.charts.providers.AnalyticsFilterProvider import dhis2.org.analytics.charts.ui.OrgUnitFilterType +import kotlinx.coroutines.test.runTest import org.hisp.dhis.android.core.D2 import org.hisp.dhis.android.core.analytics.AnalyticsException import org.hisp.dhis.android.core.analytics.aggregated.DimensionItem @@ -44,15 +43,12 @@ import org.mockito.kotlin.doReturn import org.mockito.kotlin.mock import org.mockito.kotlin.verify import org.mockito.kotlin.whenever -import kotlinx.coroutines.test.runTest import java.util.Date class ChartsRepositoryTest { private val d2: D2 = Mockito.mock(D2::class.java, Mockito.RETURNS_DEEP_STUBS) private val visualizationToGraph: VisualizationToGraph = mock() private val analyticsTeiSettingsToGraph: AnalyticsTeiSettingsToGraph = mock() - private val dataElementToGraph: DataElementToGraph = mock() - private val programIndicatorToGraph: ProgramIndicatorToGraph = mock() private val analyticsResources: AnalyticResources = mock() private val analyticsFilterProvider: AnalyticsFilterProvider = mock() @@ -61,8 +57,6 @@ class ChartsRepositoryTest { d2, visualizationToGraph, analyticsTeiSettingsToGraph, - dataElementToGraph, - programIndicatorToGraph, analyticsResources, analyticsFilterProvider, ) @@ -76,13 +70,13 @@ class ChartsRepositoryTest { .uid(any()) .blockingGet(), ) doReturn - Enrollment - .builder() - .uid("enrollmentUid") - .program("programUid") - .trackedEntityInstance(null) - .attributeOptionCombo("attributeOptionComboUid") - .build() + Enrollment + .builder() + .uid("enrollmentUid") + .program("programUid") + .trackedEntityInstance(null) + .attributeOptionCombo("attributeOptionComboUid") + .build() val result = repository.getAnalyticsForEnrollment("enrollmentUid") assertTrue( result.isEmpty(), @@ -100,83 +94,8 @@ class ChartsRepositoryTest { val result = repository.getAnalyticsForEnrollment("enrollmentUid") assertTrue( result.isNotEmpty() && - result.size == mockedSettingsGraphs().size && - result[0].title == "settings_1", - ) - } - - @Test - fun `Should get default analytics if settings is null`() = runTest { - mockEnrollmentCall() - mockAnalyticsSettingsCall(null) - mockRepeatableStagesCall() - mockNumericDataElements(false) - - whenever( - dataElementToGraph.map(any(), any(), any(), any(), anyOrNull(), anyOrNull(), any()), - ) doReturn mockedDataElementGraph() - mockIndicators(false) - whenever( - programIndicatorToGraph.map( - any(), - any(), - any(), - any(), - anyOrNull(), - anyOrNull(), - any(), - ), - ) doReturn mockedIndicatorGraph() - val result = repository.getAnalyticsForEnrollment("enrollmentUid") - assertTrue( - result.isNotEmpty() && - result.size == 2 && - result[0].title == "de_graph_1" && - result[1].title == "indicator_graph_1", - ) - } - - @Test - fun `Should get default analytics if settings is null and return only dataElement graphs`() = runTest { - mockEnrollmentCall() - mockAnalyticsSettingsCall(null) - mockRepeatableStagesCall() - mockNumericDataElements(false) - whenever( - dataElementToGraph.map(any(), any(), any(), any(), anyOrNull(), anyOrNull(), any()), - ) doReturn mockedDataElementGraph() - mockIndicators(true) - val result = repository.getAnalyticsForEnrollment("enrollmentUid") - assertTrue( - result.isNotEmpty() && - result.size == 1 && - result[0].title == "de_graph_1", - ) - } - - @Test - fun `Should get default analytics if settings is null and return only indicator graphs`() = runTest { - mockEnrollmentCall() - mockAnalyticsSettingsCall(null) - mockRepeatableStagesCall() - mockNumericDataElements(true) - mockIndicators(false) - whenever( - programIndicatorToGraph.map( - any(), - any(), - any(), - any(), - anyOrNull(), - anyOrNull(), - any(), - ), - ) doReturn mockedIndicatorGraph() - val result = repository.getAnalyticsForEnrollment("enrollmentUid") - assertTrue( - result.isNotEmpty() && - result.size == 1 && - result[0].title == "indicator_graph_1", + result.size == mockedSettingsGraphs().size && + result[0].title == "settings_1", ) } @@ -212,7 +131,12 @@ class ChartsRepositoryTest { val mockedVisualizationGroup: AnalyticsDhisVisualizationsGroup = mock() val visualizationSetting: AnalyticsDhisVisualizationsSetting = mock { - on { program() } doReturn mapOf(Pair("programUid", listOf(mockedVisualizationGroup))) + on { program() } doReturn mapOf( + Pair( + "programUid", + listOf(mockedVisualizationGroup) + ) + ) } whenever( d2 @@ -231,7 +155,12 @@ class ChartsRepositoryTest { val visualizationSetting: AnalyticsDhisVisualizationsSetting = mock { on { program() } doReturn mapOf() - on { dataSet() } doReturn mapOf(Pair("dataSetUid", listOf(mockedVisualizationGroup))) + on { dataSet() } doReturn mapOf( + Pair( + "dataSetUid", + listOf(mockedVisualizationGroup) + ) + ) } whenever( d2 @@ -521,11 +450,11 @@ class ChartsRepositoryTest { } ?: this }.blockingEvaluate(), ) doReturn - if (analyticsException == null) { - Result.Success(mockedAnalyticResponse) - } else { - Result.Failure(analyticsException) - } + if (analyticsException == null) { + Result.Success(mockedAnalyticResponse) + } else { + Result.Failure(analyticsException) + } } private fun mockEnrollmentCall() { @@ -536,13 +465,13 @@ class ChartsRepositoryTest { .uid(any()) .blockingGet(), ) doReturn - Enrollment - .builder() - .uid("enrollmentUid") - .program("programUid") - .trackedEntityInstance("teiUid") - .attributeOptionCombo("attributeOptionComboUid") - .build() + Enrollment + .builder() + .uid("enrollmentUid") + .program("programUid") + .trackedEntityInstance("teiUid") + .attributeOptionCombo("attributeOptionComboUid") + .build() } private fun mockAnalyticsSettingsCall(result: List?) { @@ -610,12 +539,12 @@ class ChartsRepositoryTest { .eq(true) .blockingGet(), ) doReturn - listOf( - ProgramStage - .builder() - .uid("stage_1") - .build(), - ) + listOf( + ProgramStage + .builder() + .uid("stage_1") + .build(), + ) } private fun mockIndicators(emptyList: Boolean) { @@ -665,12 +594,12 @@ class ChartsRepositoryTest { .eq("programUid") .blockingGet(), ) doReturn - listOf( - ProgramIndicator - .builder() - .uid("indicator_1") - .build(), - ) + listOf( + ProgramIndicator + .builder() + .uid("indicator_1") + .build(), + ) } } @@ -707,13 +636,13 @@ class ChartsRepositoryTest { .eq("stage_1") .blockingGet(), ) doReturn - listOf( - ProgramStageDataElement - .builder() - .uid("psde_uid_1") - .dataElement(ObjectWithUid.create("de_1")) - .build(), - ) + listOf( + ProgramStageDataElement + .builder() + .uid("psde_uid_1") + .dataElement(ObjectWithUid.create("de_1")) + .build(), + ) } whenever( diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 579a4e2234d..f01cf784327 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -4,14 +4,14 @@ lifecycleRuntimeCompose = "2.9.6" navigationCompose = "2.9.2" sdk = "36" minSdk = "23" -vCode = "155" -vName = "3.4.1-RC" +vCode = "156" +vName = "3.4.1" gradle = "9.0.1" kotlin = '2.3.20' dagger = '2.57.2' jacoco = '0.8.14' -designSystem = "0.7.1-SNAPSHOT" -dhis2sdk = "1.14.1-SNAPSHOT" +designSystem = "0.7.1" +dhis2sdk = "1.14.1" ruleEngine = "3.7.2" expressionParser = "1.4.1" appcompat = "1.7.1" diff --git a/gradle/verification-metadata.xml b/gradle/verification-metadata.xml index 2e930a4cd2f..c1a0d8aa7d3 100644 --- a/gradle/verification-metadata.xml +++ b/gradle/verification-metadata.xml @@ -4062,6 +4062,16 @@ + + + + + + + + + + diff --git a/login/src/commonMain/kotlin/org/dhis2/mobile/login/main/ui/viewmodel/CredentialsViewModel.kt b/login/src/commonMain/kotlin/org/dhis2/mobile/login/main/ui/viewmodel/CredentialsViewModel.kt index 1b528d92bf2..c8d89fbdb9d 100644 --- a/login/src/commonMain/kotlin/org/dhis2/mobile/login/main/ui/viewmodel/CredentialsViewModel.kt +++ b/login/src/commonMain/kotlin/org/dhis2/mobile/login/main/ui/viewmodel/CredentialsViewModel.kt @@ -6,6 +6,8 @@ import coil3.PlatformContext import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update @@ -115,13 +117,12 @@ class CredentialsViewModel( ) init { - launchUseCase { - appLinkNavigation.appLink.collect { urlString -> + appLinkNavigation.appLink + .onEach { urlString -> if (credentialsScreenState.value.loginState is LoginState.Running) { handleOAuthCallbacks(urlString) } - } - } + }.launchIn(viewModelScope) } private fun loadData() { diff --git a/sync/src/androidMain/kotlin/org/dhis2/mobile/sync/data/AndroidSyncRepository.kt b/sync/src/androidMain/kotlin/org/dhis2/mobile/sync/data/AndroidSyncRepository.kt index a511fd29e74..357f625e5cf 100644 --- a/sync/src/androidMain/kotlin/org/dhis2/mobile/sync/data/AndroidSyncRepository.kt +++ b/sync/src/androidMain/kotlin/org/dhis2/mobile/sync/data/AndroidSyncRepository.kt @@ -27,6 +27,7 @@ import org.dhis2.mobile.sync.model.SMSConfigResult import org.dhis2.mobile.sync.model.SyncResult import org.dhis2.mobile.sync.model.toSyncPeriod import org.hisp.dhis.android.core.D2 +import org.hisp.dhis.android.core.D2Manager 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 @@ -194,6 +195,43 @@ class AndroidSyncRepository( .blockingSet(stackTrace) } + override suspend fun isLoggedIn() = D2Manager.isD2Instantiated() && d2.userModule().blockingIsLogged() + + override suspend fun isServerAvailable(syncJobName: String): Boolean { + val isServerAvailable = + try { + d2.systemInfoModule().ping().blockingGet() + true + } catch (e: Exception) { + false + } + + return isServerAvailable + } + + override suspend fun setUnnavailableFlag(syncJobName: String) { + d2 + .dataStoreModule() + .localDataStore() + .value(syncJobName) + .blockingSet("unavailable") + } + + override suspend fun removeUnnavailableFlag(syncJobName: String) { + d2 + .dataStoreModule() + .localDataStore() + .value(syncJobName) + .blockingDeleteIfExist() + } + + override suspend fun isPeriodicJobFlagged(syncJobName: String): Boolean = + d2 + .dataStoreModule() + .localDataStore() + .value(syncJobName) + .blockingExists() + private suspend fun syncResult(): SyncResult { val eventsOk = d2 diff --git a/sync/src/androidMain/kotlin/org/dhis2/mobile/sync/di/SyncModule.android.kt b/sync/src/androidMain/kotlin/org/dhis2/mobile/sync/di/SyncModule.android.kt index d35a2be4aec..797064d8582 100644 --- a/sync/src/androidMain/kotlin/org/dhis2/mobile/sync/di/SyncModule.android.kt +++ b/sync/src/androidMain/kotlin/org/dhis2/mobile/sync/di/SyncModule.android.kt @@ -8,6 +8,7 @@ import org.dhis2.mobile.sync.data.SyncDataWorker import org.dhis2.mobile.sync.data.SyncMetadataWorker import org.dhis2.mobile.sync.data.SyncRepository import org.dhis2.mobile.sync.data.SyncSettingsWorker +import org.dhis2.mobile.sync.domain.CheckPeriodicJobs import org.dhis2.mobile.sync.domain.SyncData import org.dhis2.mobile.sync.domain.SyncMetadata import org.dhis2.mobile.sync.domain.SyncSettings @@ -41,6 +42,8 @@ actual val syncModule = factoryOf(::SyncSettings) + factoryOf(::CheckPeriodicJobs) + workerOf(::SyncDataWorker) workerOf(::SyncMetadataWorker) workerOf(::SyncSettingsWorker) diff --git a/sync/src/commonMain/kotlin/org/dhis2/mobile/sync/data/SyncRepository.kt b/sync/src/commonMain/kotlin/org/dhis2/mobile/sync/data/SyncRepository.kt index b9e4bb6a7fc..46b61c7ba59 100644 --- a/sync/src/commonMain/kotlin/org/dhis2/mobile/sync/data/SyncRepository.kt +++ b/sync/src/commonMain/kotlin/org/dhis2/mobile/sync/data/SyncRepository.kt @@ -51,5 +51,15 @@ interface SyncRepository { suspend fun saveDataSyncError(stackTrace: String) + suspend fun isServerAvailable(syncJobName: String): Boolean + + suspend fun setUnnavailableFlag(syncJobName: String) + + suspend fun removeUnnavailableFlag(syncJobName: String) + + suspend fun isPeriodicJobFlagged(syncJobName: String): Boolean + + suspend fun isLoggedIn(): Boolean + suspend fun toggleSMS(enable: Boolean): Result } diff --git a/sync/src/commonMain/kotlin/org/dhis2/mobile/sync/domain/CheckPeriodicJobs.kt b/sync/src/commonMain/kotlin/org/dhis2/mobile/sync/domain/CheckPeriodicJobs.kt new file mode 100644 index 00000000000..c1cfa605577 --- /dev/null +++ b/sync/src/commonMain/kotlin/org/dhis2/mobile/sync/domain/CheckPeriodicJobs.kt @@ -0,0 +1,30 @@ +package org.dhis2.mobile.sync.domain + +import org.dhis2.mobile.commons.domain.UseCase +import org.dhis2.mobile.sync.data.SyncBackgroundJobAction +import org.dhis2.mobile.sync.data.SyncRepository + +private const val SYNC_METADATA_NAME = "SYNC_METADATA" +private const val SYNC_DATA_NAME = "SYNC_DATA" + +class CheckPeriodicJobs( + private val syncRepository: SyncRepository, + private val syncBackgroundJobAction: SyncBackgroundJobAction, +) : UseCase { + override suspend fun invoke(input: Unit): Result { + if (!syncRepository.isLoggedIn()) return Result.failure(Exception("User not logged in")) + + val isMetadataSyncFlagged = syncRepository.isPeriodicJobFlagged(SYNC_METADATA_NAME) + val isDataSyncFlagged = syncRepository.isPeriodicJobFlagged(SYNC_DATA_NAME) + + if (isMetadataSyncFlagged) { + syncBackgroundJobAction.launchMetadataSync(0) + } + + if (isDataSyncFlagged) { + syncBackgroundJobAction.launchDataSync(0) + } + + return Result.success(Unit) + } +} diff --git a/sync/src/commonMain/kotlin/org/dhis2/mobile/sync/domain/SyncData.kt b/sync/src/commonMain/kotlin/org/dhis2/mobile/sync/domain/SyncData.kt index 82ada6462a8..f272d28ed92 100644 --- a/sync/src/commonMain/kotlin/org/dhis2/mobile/sync/domain/SyncData.kt +++ b/sync/src/commonMain/kotlin/org/dhis2/mobile/sync/domain/SyncData.kt @@ -6,12 +6,22 @@ import org.dhis2.mobile.sync.data.SyncRepository import org.dhis2.mobile.sync.model.DataSyncProgress import org.dhis2.mobile.sync.model.DataSyncTask +private const val SYNC_DATA_NAME = "SYNC_DATA" + class SyncData( private val repository: SyncRepository, private val syncStatusController: SyncStatusController, ) : UseCase<(progress: DataSyncProgress) -> Unit, Unit> { override suspend fun invoke(input: (progress: DataSyncProgress) -> Unit): Result = try { + when (repository.isServerAvailable(SYNC_DATA_NAME)) { + true -> repository.removeUnnavailableFlag(SYNC_DATA_NAME) + false -> { + repository.setUnnavailableFlag(SYNC_DATA_NAME) + return Result.failure(DomainError.NetworkError("Server not available")) + } + } + syncStatusController.initDownloadProcess( repository.getAllProgramsInitialStatus().getOrNull() ?: emptyMap(), ) diff --git a/sync/src/commonMain/kotlin/org/dhis2/mobile/sync/domain/SyncMetadata.kt b/sync/src/commonMain/kotlin/org/dhis2/mobile/sync/domain/SyncMetadata.kt index 5e522ccb47a..1a5d64032be 100644 --- a/sync/src/commonMain/kotlin/org/dhis2/mobile/sync/domain/SyncMetadata.kt +++ b/sync/src/commonMain/kotlin/org/dhis2/mobile/sync/domain/SyncMetadata.kt @@ -1,17 +1,28 @@ package org.dhis2.mobile.sync.domain import org.dhis2.mobile.commons.domain.UseCase +import org.dhis2.mobile.commons.error.DomainError import org.dhis2.mobile.sync.data.SyncBackgroundJobAction import org.dhis2.mobile.sync.data.SyncRepository import org.dhis2.mobile.sync.model.SMSConfigResult import org.dhis2.mobile.sync.model.SyncPeriod +private const val SYNC_METADATA_NAME = "SYNC_METADATA" + class SyncMetadata( private val repository: SyncRepository, private val syncBackgroundJobAction: SyncBackgroundJobAction, ) : UseCase<(progress: Int) -> Unit, Unit> { override suspend fun invoke(input: (progress: Int) -> Unit): Result = try { + when (repository.isServerAvailable(SYNC_METADATA_NAME)) { + true -> repository.removeUnnavailableFlag(SYNC_METADATA_NAME) + false -> { + repository.setUnnavailableFlag(SYNC_METADATA_NAME) + return Result.failure(DomainError.NetworkError("Server not available")) + } + } + val initialMetadataSyncPeriod = repository.currentMetadataSyncPeriod() val initialDataSyncPeriod = repository.currentDataSyncPeriod() diff --git a/sync/src/commonTest/kotlin/org/dhis2/mobile/sync/domain/SyncMetadataTest.kt b/sync/src/commonTest/kotlin/org/dhis2/mobile/sync/domain/SyncMetadataTest.kt index 39bb092ba0d..950539c5aa0 100644 --- a/sync/src/commonTest/kotlin/org/dhis2/mobile/sync/domain/SyncMetadataTest.kt +++ b/sync/src/commonTest/kotlin/org/dhis2/mobile/sync/domain/SyncMetadataTest.kt @@ -26,6 +26,7 @@ class SyncMetadataTest { @Test fun `Should not trigger background jobs if sync periods do not change`() = runBlocking { + whenever(repository.isServerAvailable(any())) doReturn true whenever(repository.currentMetadataSyncPeriod()) doReturn SyncPeriod.Manual whenever(repository.currentDataSyncPeriod()) doReturn SyncPeriod.Manual whenever(repository.syncMetadata(any())) doReturn Result.success(Unit) @@ -41,6 +42,7 @@ class SyncMetadataTest { @Test fun `Should cancel metadata sync if period changes to manual`() = runBlocking { + whenever(repository.isServerAvailable(any())) doReturn true whenever(repository.currentMetadataSyncPeriod()).thenReturn( SyncPeriod.Every24Hour, SyncPeriod.Manual, @@ -59,6 +61,7 @@ class SyncMetadataTest { @Test fun `Should re-launch metadata sync if period changes`() = runBlocking { + whenever(repository.isServerAvailable(any())) doReturn true whenever(repository.currentMetadataSyncPeriod()).thenReturn( SyncPeriod.Every24Hour, SyncPeriod.Every7Days, @@ -76,6 +79,7 @@ class SyncMetadataTest { @Test fun `Should cancel data sync if period changes to manual`() = runBlocking { + whenever(repository.isServerAvailable(any())) doReturn true whenever(repository.currentMetadataSyncPeriod()) doReturn SyncPeriod.Manual whenever(repository.currentDataSyncPeriod()).thenReturn( SyncPeriod.Every24Hour, @@ -93,6 +97,7 @@ class SyncMetadataTest { @Test fun `Should re-launch data sync if period changes`() = runBlocking { + whenever(repository.isServerAvailable(any())) doReturn true whenever(repository.currentMetadataSyncPeriod()) doReturn SyncPeriod.Manual whenever(repository.currentDataSyncPeriod()).thenReturn( SyncPeriod.Every24Hour, @@ -110,6 +115,7 @@ class SyncMetadataTest { @Test fun `Should return failure and save state when metadata sync fails`() = runBlocking { + whenever(repository.isServerAvailable(any())) doReturn true val exception = Exception("Sync failed") whenever(repository.currentMetadataSyncPeriod()) doReturn SyncPeriod.Manual whenever(repository.currentDataSyncPeriod()) doReturn SyncPeriod.Manual diff --git a/whatsnew/whatsnew-en-US b/whatsnew/whatsnew-en-US index d7d138dfd25..2a9f6e9b9f0 100644 --- a/whatsnew/whatsnew-en-US +++ b/whatsnew/whatsnew-en-US @@ -1,5 +1,9 @@ This is a patch version that fixes: +- ANDROAPP-6516 Values in events and program stages not being updated when syncing with server from them +- ANDROAPP-7287 Flicker when no TEIs in tei list +- ANDROAPP-7364 Server URL not well formed error +- ANDROAPP-7398 Wrong SnackBar color +- ANDROAPP-7431 Username does not trim trailing white spaces -- ANDROAPP-7634 SMS module is accidentally enabled when ASWA is installed - +.... You can find all the details on Jira and Github. \ No newline at end of file