From 9f83909e8ce1200848d686311963e9417548098a Mon Sep 17 00:00:00 2001 From: Xavier Molloy Date: Wed, 17 Jun 2026 11:44:19 +0200 Subject: [PATCH 01/11] fix: [ANDROAPP-7661] condition race to sync image using granular sync (#4918) --- .../main/java/org/dhis2/data/service/SyncPresenterImpl.kt | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) 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 28f480d09f..d15745a193 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), ) } From 60ab6098ca42ce7cabd82a3d0c9b3f77465b75a9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jorge=20S=C3=A1nchez=20Fern=C3=A1ndez?= Date: Thu, 18 Jun 2026 12:08:55 +0200 Subject: [PATCH 02/11] fix: [ANDROAPP-7647] clear stale program results on new TEI search (#4920) --- .../listView/SearchTEList.kt | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) 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 47fc387e9d..7d6eb880d6 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) + } } From 5381f01288917121802b9c5500dbd5a44ae0281b Mon Sep 17 00:00:00 2001 From: Pablo Date: Fri, 19 Jun 2026 11:35:45 +0200 Subject: [PATCH 03/11] fix: [ANDROAPP-7637] Program tab appears when analytics is not configured (#4916) --- app/src/main/java/org/dhis2/usescases/main/MainViewModel.kt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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 cbbbac8702..f4d9452d76 100644 --- a/app/src/main/java/org/dhis2/usescases/main/MainViewModel.kt +++ b/app/src/main/java/org/dhis2/usescases/main/MainViewModel.kt @@ -198,7 +198,8 @@ class MainViewModel( _homeScreenState.update { it.copy( filterButtonVisible = true, - bottomNavigationBarVisible = true, + bottomNavigationBarVisible = + it.currentScreen.isHome() && it.navigationBarItems.size > 1, syncButtonVisible = true, ) } From 772732141c8567c30b0899ea2f229b97b8a4caad Mon Sep 17 00:00:00 2001 From: Pablo Date: Fri, 19 Jun 2026 14:11:04 +0200 Subject: [PATCH 04/11] build: Migrate App to kotlin [skip size] (#4928) --- app/src/main/java/org/dhis2/App.java | 360 ------------------ app/src/main/java/org/dhis2/App.kt | 336 ++++++++++++++++ .../mobile/sync/data/AndroidSyncRepository.kt | 38 ++ .../mobile/sync/di/SyncModule.android.kt | 3 + .../dhis2/mobile/sync/data/SyncRepository.kt | 10 + .../mobile/sync/domain/CheckPeriodicJobs.kt | 30 ++ .../org/dhis2/mobile/sync/domain/SyncData.kt | 10 + .../dhis2/mobile/sync/domain/SyncMetadata.kt | 11 + .../mobile/sync/domain/SyncMetadataTest.kt | 6 + 9 files changed, 444 insertions(+), 360 deletions(-) delete mode 100644 app/src/main/java/org/dhis2/App.java create mode 100644 app/src/main/java/org/dhis2/App.kt create mode 100644 sync/src/commonMain/kotlin/org/dhis2/mobile/sync/domain/CheckPeriodicJobs.kt 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 d1fc1043f4..0000000000 --- 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 0000000000..e558684fa4 --- /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/sync/src/androidMain/kotlin/org/dhis2/mobile/sync/data/AndroidSyncRepository.kt b/sync/src/androidMain/kotlin/org/dhis2/mobile/sync/data/AndroidSyncRepository.kt index a511fd29e7..357f625e5c 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 d35a2be4ae..797064d858 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 b9e4bb6a7f..46b61c7ba5 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 0000000000..c1cfa60557 --- /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 82ada6462a..f272d28ed9 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 5e522ccb47..1a5d64032b 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 39bb092ba0..950539c5aa 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 From d21da9b05c610238c94bb600518c7da4e777d1a1 Mon Sep 17 00:00:00 2001 From: Ferdy Rodriguez Date: Mon, 22 Jun 2026 07:13:06 +0200 Subject: [PATCH 05/11] fixes that now it shows the new version dialog on Initial sync and when the user manually checks for updates in the Settings menu (#4927) --- .../dhis2/data/service/CheckVersionWorker.kt | 2 +- .../dhis2/data/service/VersionRepository.kt | 9 +++++---- .../main/java/org/dhis2/di/ServerModule.kt | 4 ++-- .../org/dhis2/usescases/main/MainModule.kt | 1 - .../org/dhis2/usescases/main/MainViewModel.kt | 4 ++-- .../main/domain/LaunchInitialSync.kt | 7 ++++++- .../usescases/settings/SyncManagerFragment.kt | 4 +++- .../usescases/settings/SyncManagerModule.kt | 2 +- .../settings/SyncManagerPresenter.kt | 4 +++- .../settings/domain/CheckVersionUpdate.kt | 19 ++++++++---------- .../main/domain/LaunchInitialSyncTest.kt | 20 +++++++++++-------- .../settings/SyncManagerPresenterTest.kt | 1 + .../settings/domain/CheckVersionUpdateTest.kt | 8 ++++---- 13 files changed, 48 insertions(+), 37 deletions(-) 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 531d10c6f0..626532a855 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/VersionRepository.kt b/app/src/main/java/org/dhis2/data/service/VersionRepository.kt index 49c65337ce..6edce0112f 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 e7c4ea4805..1594493acd 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/main/MainModule.kt b/app/src/main/java/org/dhis2/usescases/main/MainModule.kt index 4e9b14cc5f..a8119a4744 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 f4d9452d76..f9add35b63 100644 --- a/app/src/main/java/org/dhis2/usescases/main/MainViewModel.kt +++ b/app/src/main/java/org/dhis2/usescases/main/MainViewModel.kt @@ -164,13 +164,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(), 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 886d645c9f..2be4d829b4 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/settings/SyncManagerFragment.kt b/app/src/main/java/org/dhis2/usescases/settings/SyncManagerFragment.kt index 7792279360..5388b2e0a3 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 c1819f82b8..51ec74e59d 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 d33719c18e..a23c557a6c 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 f570fbe643..d331c76deb 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/test/java/org/dhis2/usescases/main/domain/LaunchInitialSyncTest.kt b/app/src/test/java/org/dhis2/usescases/main/domain/LaunchInitialSyncTest.kt index 17d861dfa9..fbb09035be 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 7fe223dfad..5a33800a24 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 2ba584a13e..dd2d2af94e 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() } } From 44eb22cb7a9a02b99f6e1467a75e513eafc8412f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Manu=20Mu=C3=B1oz?= Date: Tue, 23 Jun 2026 10:28:24 +0200 Subject: [PATCH 06/11] fix: [ANDROAPP-7668] don't delete file value when not downloaded (#4923) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Manu Muñoz --- .../src/main/java/org/dhis2/commons/bindings/ValueExtensions.kt | 2 ++ 1 file changed, 2 insertions(+) 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 e3273bf880..014481f6c7 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 From 4cc7238fd22e6510addfc3f016da774e0a3a127a Mon Sep 17 00:00:00 2001 From: Xavier Molloy Date: Tue, 23 Jun 2026 12:02:31 +0200 Subject: [PATCH 07/11] fix: [ANDROAPP-7658] Do not display sync infobar from event capture activity (#4917) * fix: [ANDROAPP-7658] Do not display sync infobar if teiDashboard is displayed from the event capture activity * fix: [ANDROAPP-7658] fix unit tests * fix: [ANDROAPP-7658] hide sync button in landscape when creating new events --- .../eventCapture/EventCaptureActivity.kt | 4 +- .../teidata/TEIDataFragment.kt | 8 +++- .../teidata/TEIDataModule.kt | 40 ++++++++++--------- .../teidata/TEIDataPresenter.kt | 3 ++ .../data/TeiDataPresenterTest.kt | 1 + 5 files changed, 35 insertions(+), 21 deletions(-) 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 daa9d93d5d..fb492b847a 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/teiDashboard/dashboardfragments/teidata/TEIDataFragment.kt b/app/src/main/java/org/dhis2/usescases/teiDashboard/dashboardfragments/teidata/TEIDataFragment.kt index 1f27a1808f..8e3ea87c3a 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 @@ -129,6 +129,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 +138,7 @@ class TEIDataFragment : programUid, teiUid, enrollmentUid, + fragmentFromEventCaptureActivity, requireActivity().activityResultRegistry, ), )?.inject(this@TEIDataFragment) @@ -246,7 +248,7 @@ class TEIDataFragment : 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, @@ -758,12 +760,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 484f9a05b8..6cd71c968b 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 1beb0ac69e..8c0c057c5b 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, @@ -237,6 +238,8 @@ class TEIDataPresenter( } } + fun fragmentIsFromEventCaptureActivity(): Boolean = fragmentIsFromEventCaptureActivity + fun completeEnrollment() { val hasWriteAccessInProgram = programUid?.let { 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 2fe28392f1..101fe6c8b6 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, From 0ba1ba09ef563934e0598962a206ffad4005918a Mon Sep 17 00:00:00 2001 From: Ferdy Rodriguez Date: Tue, 23 Jun 2026 14:51:50 +0200 Subject: [PATCH 08/11] fix: [ANDROAPP-7637] Rechecks navigational bar server config after metadata sync (#4933) * Rechecks navigational bar server config after metadata sync * Fix integration tests of MainViewModel --- .../org/dhis2/usescases/main/MainViewModel.kt | 46 +++++++++++++++++++ .../main/MainViewModelIntegrationTest.kt | 3 ++ .../dhis2/usescases/main/MainViewModelTest.kt | 6 +++ 3 files changed, 55 insertions(+) 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 f9add35b63..75f9537c86 100644 --- a/app/src/main/java/org/dhis2/usescases/main/MainViewModel.kt +++ b/app/src/main/java/org/dhis2/usescases/main/MainViewModel.kt @@ -9,6 +9,7 @@ import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.flow.receiveAsFlow @@ -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 @@ -127,6 +131,11 @@ class MainViewModel( _homeEffects.send(HomeEffect.OrgUnitFilterRequest) }.launchIn(viewModelScope) + syncBackgroundJobAction + .observeMetadataJob() + .onEach(::handleMetadataSync) + .launchIn(viewModelScope) + launchUseCase(dispatcher.io()) { launchInitialSync().fold( onSuccess = ::handleInitialSyncResult, @@ -223,6 +232,43 @@ class MainViewModel( } } + 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) } 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 9f14b3c78b..ea5540a447 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 34d6b3e81f..2887dfdf97 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 @@ -48,6 +51,7 @@ import org.dhis2.utils.MainCoroutineScopeRule import org.dhis2.utils.analytics.CLOSE_SESSION import org.dhis2.utils.customviews.navigationbar.NavigationPage import org.hisp.dhis.mobile.ui.designsystem.component.navigationBar.NavigationBarItem +import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Rule @@ -96,6 +100,7 @@ class MainViewModelTest { private val syncStatusFlow = MutableStateFlow(SyncStatusData()) private val newVersionFlow = MutableSharedFlow() + private val metadataJobFlow = MutableStateFlow>(emptyList()) @Before fun setUp() = runTest { @@ -108,6 +113,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, From 6ef5a2b2e2901276ba719a736a9cf63c7e543025 Mon Sep 17 00:00:00 2001 From: Pablo Date: Wed, 24 Jun 2026 13:13:03 +0200 Subject: [PATCH 09/11] fix: [ANDROAPP-7664] Improve load time of dashboard screen [skip size] (#4919) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: [ANDROAPP-7664] Improve load time of dashboard screen * Remove default analytics related tests * set runTest in dashboardViewModelTest * fix dashboard android tests * revert changes to AndroidIdlingResource.kt * chained events calling increment * remove unnecessary ui thread call * use unconditional coroutine tracker methods * use unconditional coroutine tracker methods * fix infinite collection in CredentialsViewModel.kt * add decrement to error flow * test: [ANDROAPP-7664] decrement idling resource on error in note detail presenter * force decrement to idle * fix sonarcloud comments * do not retry on timeout * Root cause — DashboardViewModel.navigationBarUIState used NavigationBarUIState(items=emptyList(), TEIDashboardItems.DETAILS) as the stateIn initial value. This caused the LaunchedEffect in TeiDashboardMobileActivity to immediately navigate to TEIDataFragment (the DETAILS tab) on first composition, even in landscape mode where the DETAILS tab isn't displayed. loadNavigationBarItems() would then quickly determine DETAILS shouldn't be shown in landscape and switch the selected item to the first real tab (ANALYTICS, NOTES, etc.), causing TEIDataFragment to be immediately paused. onPause() calls presenter.onDettach() → compositeDisposable.clear(), but because the outer switchMap subscription uses subscribeOn(io), the increment() inside the switchMap body can run asynchronously on the IO thread after the clear. This left TeiDataIdlingResourceSingleton's counter stuck at 1 — TEI_DATA never became idle — and Espresso timed out. Fix (DashboardViewModel.kt) — Changed the stateIn initial value from NavigationBarUIState(items=emptyList(), TEIDashboardItems.DETAILS) to NavigationBarUIState() (null selectedItem). This means: - No fragment navigation happens until loadNavigationBarItems() completes with the actual items - In landscape, TEIDataFragment is never created and TEI_DATA stays idle - In portrait, navigation to DETAILS happens once (after nav items load), exactly the same net result as before --------- Co-authored-by: Xavier Molloy --- .../java/org/dhis2/common/BaseRobot.kt | 9 +- .../java/org/dhis2/usescases/BaseTest.kt | 6 + .../teidashboard/TeiDashboardTest.kt | 6 + .../teidashboard/robot/TeiDashboardRobot.kt | 2 + .../notes/noteDetail/NoteDetailPresenter.kt | 13 +- .../teiDashboard/DashboardRepository.kt | 2 + .../teiDashboard/DashboardRepositoryImpl.kt | 2 + .../teiDashboard/DashboardViewModel.kt | 205 ++++++++++-------- .../TeiDashboardMobileActivity.kt | 36 +-- .../teidata/TEIDataFragment.kt | 43 ++-- .../teidata/TEIDataPresenter.kt | 10 +- .../teidata/TeiDataIdlingResourceSingleton.kt | 4 +- .../teiDashboard/ui/TeiDashboardMenu.kt | 2 +- .../teiDashboard/DashboardViewModelTest.kt | 123 +++++++---- .../coroutine/AndroidIdlingResource.kt | 10 + .../coroutine/CoroutineIdlingResource.kt | 4 + .../commons/coroutine/CoroutineTracker.kt | 8 + .../commons/coroutine/NoOpIdlingResource.kt | 8 + .../commons/extensions/ViewModelExtensions.kt | 4 +- .../analytics/charts/ChartsRepositoryImpl.kt | 112 +--------- .../org/analytics/charts/di/ChartsInjector.kt | 41 ++-- .../org/analytics/charts/di/ChartsModule.kt | 18 -- .../charts/mappers/DataElementToGraph.kt | 61 ------ .../charts/mappers/ProgramIndicatorToGraph.kt | 61 ------ .../analytics/charts/ChartsRepositoryTest.kt | 177 +++++---------- .../main/ui/viewmodel/CredentialsViewModel.kt | 9 +- 26 files changed, 387 insertions(+), 589 deletions(-) delete mode 100644 dhis_android_analytics/src/main/java/dhis2/org/analytics/charts/mappers/DataElementToGraph.kt delete mode 100644 dhis_android_analytics/src/main/java/dhis2/org/analytics/charts/mappers/ProgramIndicatorToGraph.kt diff --git a/app/src/androidTest/java/org/dhis2/common/BaseRobot.kt b/app/src/androidTest/java/org/dhis2/common/BaseRobot.kt index 57f243124e..2ae634a763 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 b2d59d43d2..87f38c22af 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 1e67d65ca1..05fb9d383e 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 7ffebe013f..cf15d25d36 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/usescases/notes/noteDetail/NoteDetailPresenter.kt b/app/src/main/java/org/dhis2/usescases/notes/noteDetail/NoteDetailPresenter.kt index 2b2ae97b54..228ea2455e 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/teiDashboard/DashboardRepository.kt b/app/src/main/java/org/dhis2/usescases/teiDashboard/DashboardRepository.kt index 3eee5ec3f2..0e6d41ff4d 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 63a78db477..26981329a0 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 3cd6968a1a..daa3d09392 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 4620323adc..a31566dddf 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 8e3ea87c3a..6a9a5d254c 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 @@ -154,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) @@ -168,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) + } + } } } @@ -237,11 +250,11 @@ 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, ) @@ -314,7 +327,7 @@ class TEIDataFragment : TeiDetailDashboard( infoBarModels = listOfNotNull(syncInfoBar, followUpInfoBar, enrollmentInfoBar), card = card, - isGrouped = groupingEvents ?: true, + isGrouped = groupingEvents, timelineEventHeaderModel = TimelineEventsHeaderModel( displayEventCreationButton, @@ -376,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()) @@ -385,7 +398,7 @@ class TEIDataFragment : binding.header = when { !dashboardModel?.teiHeader.isNullOrEmpty() -> { - dashboardModel?.teiHeader + dashboardModel.teiHeader } else -> { @@ -429,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() } } } @@ -442,7 +455,7 @@ class TEIDataFragment : try { startActivity(chooser) - } catch (e: ActivityNotFoundException) { + } catch (_: ActivityNotFoundException) { Timber.e("No activity found that can handle this action") } } 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 8c0c057c5b..5f23d235ea 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 @@ -111,8 +111,8 @@ class TEIDataPresenter( sectionFlowable, groupingFlowable, ::Pair, - ).doOnNext { increment() } - .switchMap { stageAndGrouping -> + ).switchMap { stageAndGrouping -> + increment() Flowable .zip( teiDataRepository @@ -131,6 +131,7 @@ class TEIDataPresenter( calcResult, ) }.subscribeOn(schedulerProvider.io()) + .doOnCancel { decrement() } }.subscribeOn(schedulerProvider.io()) .observeOn(schedulerProvider.ui()) .subscribe( @@ -138,7 +139,10 @@ class TEIDataPresenter( _events.postValue(events) decrement() }, - Timber.Forest::d, + { t -> + Timber.e(t) + decrement() + }, ), ) 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 03dd98b44e..fe1c2e6665 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 0953f86f41..a84cda784b 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/teiDashboard/DashboardViewModelTest.kt b/app/src/test/java/org/dhis2/usescases/teiDashboard/DashboardViewModelTest.kt index 15fafeeb49..8490b90910 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/commonskmm/src/androidMain/kotlin/org/dhis2/mobile/commons/coroutine/AndroidIdlingResource.kt b/commonskmm/src/androidMain/kotlin/org/dhis2/mobile/commons/coroutine/AndroidIdlingResource.kt index 939d2479d6..c0e5953d95 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 0e8103045d..1996627909 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 e51ac96bca..7fcabc50b8 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 b423289b54..d15cee9e43 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 27f2c919e5..f7313dd7f8 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 e1ad5bc864..6a2d20c978 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 9b365ed6e9..22a0eca91f 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 d57cbeb701..70dfc10943 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 ecd0098997..0000000000 --- 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 fef93ad943..0000000000 --- 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 9b0f711619..52084337da 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/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 1b528d92bf..c8d89fbdb9 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() { From d472ff6e4c8372fbb18677b3d26fb2fdffa74905 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9s=20Miguel=20Rubio?= Date: Fri, 3 Jul 2026 11:20:18 +0200 Subject: [PATCH 10/11] fix: [ANDROAPP-7694] allow cancel metadata sync on log out and delete account (#4958) --- .../org/dhis2/usescases/main/MainViewModel.kt | 21 +++++-- .../dhis2/usescases/main/MainViewModelTest.kt | 63 ++++++++++++++++++- 2 files changed, 78 insertions(+), 6 deletions(-) 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 75f9537c86..07ea34113a 100644 --- a/app/src/main/java/org/dhis2/usescases/main/MainViewModel.kt +++ b/app/src/main/java/org/dhis2/usescases/main/MainViewModel.kt @@ -5,11 +5,11 @@ 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 import kotlinx.coroutines.flow.launchIn -import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.flow.receiveAsFlow @@ -82,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() @@ -131,10 +133,7 @@ class MainViewModel( _homeEffects.send(HomeEffect.OrgUnitFilterRequest) }.launchIn(viewModelScope) - syncBackgroundJobAction - .observeMetadataJob() - .onEach(::handleMetadataSync) - .launchIn(viewModelScope) + observeMetadataSyncJob() launchUseCase(dispatcher.io()) { launchInitialSync().fold( @@ -232,6 +231,14 @@ 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)) { @@ -274,6 +281,7 @@ class MainViewModel( } fun logOut() { + metadataSyncObserverJob?.cancel() launchUseCase(dispatcher.io()) { matomoAnalyticsController.trackEvent(HOME, CLOSE_SESSION, CLICK) logOutUser().fold( @@ -282,6 +290,7 @@ class MainViewModel( }, onFailure = { Timber.e(it) + observeMetadataSyncJob() }, ) } @@ -289,6 +298,7 @@ class MainViewModel( context(context: Context) fun onDeleteAccount() { + metadataSyncObserverJob?.cancel() launchUseCase(dispatcher.io()) { _homeEffects.send(HomeEffect.ShowDeleteNotification) deleteAccount(context.cacheDir).fold( @@ -298,6 +308,7 @@ class MainViewModel( }, onFailure = { Timber.e(it) + observeMetadataSyncJob() }, ) } 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 2887dfdf97..7b972eeb16 100644 --- a/app/src/test/java/org/dhis2/usescases/main/MainViewModelTest.kt +++ b/app/src/test/java/org/dhis2/usescases/main/MainViewModelTest.kt @@ -51,7 +51,6 @@ import org.dhis2.utils.MainCoroutineScopeRule import org.dhis2.utils.analytics.CLOSE_SESSION import org.dhis2.utils.customviews.navigationbar.NavigationPage import org.hisp.dhis.mobile.ui.designsystem.component.navigationBar.NavigationBarItem -import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Rule @@ -59,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 @@ -102,6 +102,15 @@ class MainViewModelTest { 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 { Dispatchers.setMain(testingDispatcher) @@ -171,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 { From afa4607b1681e6c020e02b6d2a81241a80b364ff Mon Sep 17 00:00:00 2001 From: Xavier Molloy Date: Mon, 6 Jul 2026 12:20:17 +0200 Subject: [PATCH 11/11] build: 3.4.1 release MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * build: Release 3.4.1 * update release notes Signed-off-by: Manu Muñoz * chore: update dependency verification metadata --------- Signed-off-by: Manu Muñoz Co-authored-by: Manu Muñoz Co-authored-by: @dhis2-bot --- RELEASE.md | 102 +++++++++++++++++++++++++++++-- gradle/libs.versions.toml | 8 +-- gradle/verification-metadata.xml | 10 +++ whatsnew/whatsnew-en-US | 8 ++- 4 files changed, 118 insertions(+), 10 deletions(-) diff --git a/RELEASE.md b/RELEASE.md index d6427abcdc..8d34896494 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/gradle/libs.versions.toml b/gradle/libs.versions.toml index 579a4e2234..f01cf78432 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 2e930a4cd2..c1a0d8aa7d 100644 --- a/gradle/verification-metadata.xml +++ b/gradle/verification-metadata.xml @@ -4062,6 +4062,16 @@ + + + + + + + + + + diff --git a/whatsnew/whatsnew-en-US b/whatsnew/whatsnew-en-US index d7d138dfd2..2a9f6e9b9f 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