From 914c5d01f2441e336d70c99ae81633e8338c41bd Mon Sep 17 00:00:00 2001 From: Pablo Pajuelo Date: Thu, 2 Jul 2026 15:10:15 +0200 Subject: [PATCH] feat: [ANDROAPP-7605] Add granular sync usecase for programs --- .../sync/data/SyncProgramRepositoryImpl.kt | 87 ++++++++++++++++ .../mobile/sync/data/SyncProgramRepository.kt | 23 +++++ .../dhis2/mobile/sync/domain/SyncProgram.kt | 53 ++++++++++ .../dhis2/mobile/sync/model/ProgramType.kt | 9 ++ .../mobile/sync/domain/SyncProgramTest.kt | 99 +++++++++++++++++++ 5 files changed, 271 insertions(+) create mode 100644 sync/src/androidMain/kotlin/org/dhis2/mobile/sync/data/SyncProgramRepositoryImpl.kt create mode 100644 sync/src/commonMain/kotlin/org/dhis2/mobile/sync/data/SyncProgramRepository.kt create mode 100644 sync/src/commonMain/kotlin/org/dhis2/mobile/sync/domain/SyncProgram.kt create mode 100644 sync/src/commonMain/kotlin/org/dhis2/mobile/sync/model/ProgramType.kt create mode 100644 sync/src/commonTest/kotlin/org/dhis2/mobile/sync/domain/SyncProgramTest.kt diff --git a/sync/src/androidMain/kotlin/org/dhis2/mobile/sync/data/SyncProgramRepositoryImpl.kt b/sync/src/androidMain/kotlin/org/dhis2/mobile/sync/data/SyncProgramRepositoryImpl.kt new file mode 100644 index 0000000000..7e8bac77f1 --- /dev/null +++ b/sync/src/androidMain/kotlin/org/dhis2/mobile/sync/data/SyncProgramRepositoryImpl.kt @@ -0,0 +1,87 @@ +package org.dhis2.mobile.sync.data + +import org.dhis2.mobile.sync.model.ProgramType +import org.hisp.dhis.android.core.D2 +import org.hisp.dhis.android.core.common.State +import org.hisp.dhis.android.core.program.ProgramType as CoreProgramType + +internal class SyncProgramRepositoryImpl( + private val d2: D2, +) : SyncProgramRepository { + override suspend fun getProgramType(programUid: String): ProgramType = + when ( + d2 + .programModule() + .programs() + .uid(programUid) + .suspendGet() + ?.programType + ) { + CoreProgramType.WITH_REGISTRATION -> ProgramType.Tracker + CoreProgramType.WITHOUT_REGISTRATION -> ProgramType.Event + null -> ProgramType.None + } + + override suspend fun uploadTrackerProgram(programUid: String) { + d2 + .trackedEntityModule() + .trackedEntityInstances() + .byProgramUids(listOf(programUid)) + .blockingUpload() + } + + override suspend fun downloadTrackerProgram(programUid: String) { + d2 + .trackedEntityModule() + .trackedEntityInstanceDownloader() + .byProgramUid(programUid) + .blockingDownload() + } + + override suspend fun uploadEventProgram(programUid: String) { + d2 + .eventModule() + .events() + .byProgramUid() + .eq(programUid) + .blockingUpload() + } + + override suspend fun downloadEventProgram(programUid: String) { + d2 + .eventModule() + .eventDownloader() + .byProgramUid(programUid) + .blockingDownload() + } + + override suspend fun downloadFileResources(programUid: String) { + d2 + .fileResourceModule() + .fileResourceDownloader() + .byProgramUid() + .eq(programUid) + .blockingDownload() + } + + override suspend fun allTeisAreSynced(programUid: String) = + d2 + .trackedEntityModule() + .trackedEntityInstances() + .byProgramUids(listOf(programUid)) + .byAggregatedSyncState() + .notIn(State.SYNCED, State.RELATIONSHIP) + .blockingGet() + .isEmpty() + + override suspend fun allEventsAreSynced(programUid: String) = + d2 + .eventModule() + .events() + .byProgramUid() + .eq(programUid) + .byAggregatedSyncState() + .notIn(State.SYNCED) + .blockingGet() + .isEmpty() +} diff --git a/sync/src/commonMain/kotlin/org/dhis2/mobile/sync/data/SyncProgramRepository.kt b/sync/src/commonMain/kotlin/org/dhis2/mobile/sync/data/SyncProgramRepository.kt new file mode 100644 index 0000000000..d6079d0d77 --- /dev/null +++ b/sync/src/commonMain/kotlin/org/dhis2/mobile/sync/data/SyncProgramRepository.kt @@ -0,0 +1,23 @@ +package org.dhis2.mobile.sync.data + +import org.dhis2.mobile.sync.model.ProgramType + +typealias IsSynced = Boolean + +internal interface SyncProgramRepository { + suspend fun getProgramType(programUid: String): ProgramType + + suspend fun uploadTrackerProgram(programUid: String) + + suspend fun downloadTrackerProgram(programUid: String) + + suspend fun uploadEventProgram(programUid: String) + + suspend fun downloadEventProgram(programUid: String) + + suspend fun downloadFileResources(programUid: String) + + suspend fun allTeisAreSynced(programUid: String): Boolean + + suspend fun allEventsAreSynced(programUid: String): Boolean +} diff --git a/sync/src/commonMain/kotlin/org/dhis2/mobile/sync/domain/SyncProgram.kt b/sync/src/commonMain/kotlin/org/dhis2/mobile/sync/domain/SyncProgram.kt new file mode 100644 index 0000000000..ba8d24afa1 --- /dev/null +++ b/sync/src/commonMain/kotlin/org/dhis2/mobile/sync/domain/SyncProgram.kt @@ -0,0 +1,53 @@ +package org.dhis2.mobile.sync.domain + +import org.dhis2.mobile.commons.domain.UseCase +import org.dhis2.mobile.sync.data.SyncProgramRepository +import org.dhis2.mobile.sync.model.ProgramType + +internal typealias ProgramUid = String + +internal class SyncProgram( + private val syncProgramRepository: SyncProgramRepository, + private val syncStatusController: SyncStatusController, +) : UseCase { + override suspend fun invoke(input: ProgramUid): Result = + try { + val isSynced = + when (syncProgramRepository.getProgramType(input)) { + ProgramType.Event -> syncEvents(input) + ProgramType.Tracker -> syncTracker(input) + ProgramType.None -> return Result.failure(Exception("Unknown program")) + } + + if (isSynced) { + syncStatusController.updateSingleProgramToSuccess(input) + Result.success(Unit) + } else { + Result.failure(Exception("Status is not synced")) + } + } catch (e: Exception) { + Result.failure(e) + } + + private suspend fun syncEvents(programUid: ProgramUid): Boolean = + with(syncProgramRepository) { + uploadEventProgram(programUid) + val isSynced = allEventsAreSynced(programUid) + if (isSynced) { + downloadEventProgram(programUid) + downloadFileResources(programUid) + } + isSynced + } + + private suspend fun syncTracker(programUid: ProgramUid): Boolean = + with(syncProgramRepository) { + uploadTrackerProgram(programUid) + val isSynced = allTeisAreSynced(programUid) + if (isSynced) { + downloadTrackerProgram(programUid) + downloadFileResources(programUid) + } + isSynced + } +} diff --git a/sync/src/commonMain/kotlin/org/dhis2/mobile/sync/model/ProgramType.kt b/sync/src/commonMain/kotlin/org/dhis2/mobile/sync/model/ProgramType.kt new file mode 100644 index 0000000000..30427ee36c --- /dev/null +++ b/sync/src/commonMain/kotlin/org/dhis2/mobile/sync/model/ProgramType.kt @@ -0,0 +1,9 @@ +package org.dhis2.mobile.sync.model + +sealed interface ProgramType { + data object Event : ProgramType + + data object Tracker : ProgramType + + data object None : ProgramType +} diff --git a/sync/src/commonTest/kotlin/org/dhis2/mobile/sync/domain/SyncProgramTest.kt b/sync/src/commonTest/kotlin/org/dhis2/mobile/sync/domain/SyncProgramTest.kt new file mode 100644 index 0000000000..621f541e46 --- /dev/null +++ b/sync/src/commonTest/kotlin/org/dhis2/mobile/sync/domain/SyncProgramTest.kt @@ -0,0 +1,99 @@ +package org.dhis2.mobile.sync.domain + +import kotlinx.coroutines.runBlocking +import org.dhis2.mobile.sync.data.SyncProgramRepository +import org.dhis2.mobile.sync.model.ProgramType +import org.junit.Test +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import kotlin.test.assertTrue + +class SyncProgramTest { + private val repository: SyncProgramRepository = mock() + private val syncStatusController = SyncStatusController() + private val useCase = SyncProgram(repository, syncStatusController) + + @Test + fun `should sync event program successfully when all events are synced`() = + runBlocking { + val programUid = "eventProgramUid" + whenever(repository.getProgramType(programUid)).thenReturn(ProgramType.Event) + whenever(repository.allEventsAreSynced(programUid)).thenReturn(true) + + val result = useCase(programUid) + + assertTrue(result.isSuccess) + verify(repository).uploadEventProgram(programUid) + verify(repository).downloadEventProgram(programUid) + verify(repository).downloadFileResources(programUid) + } + + @Test + fun `should not download event program when events are not synced`() = + runBlocking { + val programUid = "eventProgramUid" + whenever(repository.getProgramType(programUid)).thenReturn(ProgramType.Event) + whenever(repository.allEventsAreSynced(programUid)).thenReturn(false) + + val result = useCase(programUid) + + assertTrue(result.isFailure) + verify(repository).uploadEventProgram(programUid) + verify(repository, never()).downloadEventProgram(programUid) + verify(repository, never()).downloadFileResources(programUid) + } + + @Test + fun `should sync tracker program successfully when all teis are synced`() = + runBlocking { + val programUid = "trackerProgramUid" + whenever(repository.getProgramType(programUid)).thenReturn(ProgramType.Tracker) + whenever(repository.allTeisAreSynced(programUid)).thenReturn(true) + + val result = useCase(programUid) + + assertTrue(result.isSuccess) + verify(repository).uploadTrackerProgram(programUid) + verify(repository).downloadTrackerProgram(programUid) + verify(repository).downloadFileResources(programUid) + } + + @Test + fun `should not download tracker program when teis are not synced`() = + runBlocking { + val programUid = "trackerProgramUid" + whenever(repository.getProgramType(programUid)).thenReturn(ProgramType.Tracker) + whenever(repository.allTeisAreSynced(programUid)).thenReturn(false) + + val result = useCase(programUid) + + assertTrue(result.isFailure) + verify(repository).uploadTrackerProgram(programUid) + verify(repository, never()).downloadTrackerProgram(programUid) + verify(repository, never()).downloadFileResources(programUid) + } + + @Test + fun `should return failure for unknown program type`() = + runBlocking { + val programUid = "unknownProgramUid" + whenever(repository.getProgramType(programUid)).thenReturn(ProgramType.None) + + val result = useCase(programUid) + + assertTrue(result.isFailure) + } + + @Test + fun `should return failure when getProgramType throws`() = + runBlocking { + val programUid = "programUid" + whenever(repository.getProgramType(programUid)).thenThrow(RuntimeException("type error")) + + val result = useCase(programUid) + + assertTrue(result.isFailure) + } +}