Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
# Android/Java/Gradle
.gradle/
.gradle-user/
.idea/
.DS_Store
/build/
Expand Down Expand Up @@ -141,3 +142,4 @@ code-visualizer/
# Markdown files (except README.md)
*.md
!README.md
.run/
21 changes: 0 additions & 21 deletions .idea/deploymentTargetSelector.xml

This file was deleted.

6 changes: 4 additions & 2 deletions app/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,8 @@ android {
applicationId "com.ash.simpledataentry"
minSdk 24
targetSdk 35
versionCode 1
versionName "1.0"
versionCode 2
versionName "1.0.1-beta"

testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables {
Expand Down Expand Up @@ -66,6 +66,7 @@ android {
}
buildFeatures {
compose true
buildConfig true
}
composeOptions {
kotlinCompilerExtensionVersion '1.5.1'
Expand All @@ -89,6 +90,7 @@ dependencies {
implementation 'androidx.compose.material3:material3'
implementation 'androidx.navigation:navigation-compose:2.7.7'
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-rx2:1.7.3'
implementation "com.google.android.material:material:1.13.0"
// Unit testing
testImplementation 'junit:junit:4.13.2'
testImplementation 'org.mockito:mockito-core:5.7.0'
Expand Down
7 changes: 4 additions & 3 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,8 @@ android {
applicationId = "com.ash.simpledataentry"
minSdk = 24
targetSdk = 35
versionCode = 1
versionName = "1.0"
versionCode = 2
versionName = "1.0.1-beta"

testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
Expand All @@ -36,6 +36,7 @@ android {
}
buildFeatures {
compose = true
buildConfig = true
}
}

Expand All @@ -56,4 +57,4 @@ dependencies {
androidTestImplementation(libs.androidx.ui.test.junit4)
debugImplementation(libs.androidx.ui.tooling)
debugImplementation(libs.androidx.ui.test.manifest)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,11 @@ class AccountManager @Inject constructor() {
Log.d(TAG, "Active account set to: $accountId")
}

fun clearActiveAccountId(context: Context) {
getPrefs(context).edit().remove(KEY_ACTIVE_ACCOUNT_ID).commit()
Log.d(TAG, "Active account cleared")
}

/**
* Get active account info
*/
Expand Down
80 changes: 59 additions & 21 deletions app/src/main/java/com/ash/simpledataentry/data/SessionManager.kt
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ import java.security.MessageDigest
import java.nio.charset.StandardCharsets
import org.hisp.dhis.android.core.maintenance.D2Error
import org.hisp.dhis.android.core.maintenance.D2ErrorCode
import java.util.concurrent.TimeUnit

/**
* Result of downloading a single metadata type
Expand Down Expand Up @@ -89,6 +90,13 @@ class SessionManager @Inject constructor(
}
}

private fun isOptionalUseCasesMissing(error: Throwable?): Boolean {
val message = error?.message ?: return false
return message.contains("stockUseCases", ignoreCase = true) ||
message.contains("USE_CASES", ignoreCase = true) ||
message.contains("E1005", ignoreCase = true)
}

/**
* Initialize D2 SDK (shared across all accounts).
* Note: D2 SDK uses single database, account isolation handled by Room.
Expand Down Expand Up @@ -361,8 +369,6 @@ class SessionManager @Inject constructor(
))

// Set this as the active account
accountManager.setActiveAccountId(context, accountInfo.accountId)

// SECURITY ENHANCEMENT: Store password hash for secure offline validation
val passwordHash = hashPassword(dhis2Config.password, dhis2Config.username + dhis2Config.serverUrl)
val prefs = context.getSharedPreferences("session_prefs", Context.MODE_PRIVATE)
Expand All @@ -373,10 +379,6 @@ class SessionManager @Inject constructor(
putLong("hash_created", System.currentTimeMillis())
}

// Emit account change event for ViewModels (using accountId, not username@serverUrl)
_currentAccountId.value = accountInfo.accountId
Log.d("SessionManager", "Account changed, notifying observers: ${accountInfo.accountId}")

// Step 3: Download Metadata (30-80%) - RESILIENT, UI LOCKED
// Use resilient metadata download with granular progress feedback
val metadataResult = downloadMetadataResilient(onProgress = onProgress)
Expand Down Expand Up @@ -411,6 +413,13 @@ class SessionManager @Inject constructor(
}
}

// Now that metadata is available, mark account as active
accountManager.setActiveAccountId(context, accountInfo.accountId)

// Emit account change event for ViewModels (using accountId, not username@serverUrl)
_currentAccountId.value = accountInfo.accountId
Log.d("SessionManager", "Account changed, notifying observers: ${accountInfo.accountId}")

onProgress(NavigationProgress(
phase = LoadingPhase.DOWNLOADING_METADATA,
overallPercentage = 80,
Expand Down Expand Up @@ -491,7 +500,23 @@ class SessionManager @Inject constructor(

Log.e("SessionManager", "Enhanced login failed: errorCode=$errorCode, description=${d2Error?.errorDescription()}", e)
onProgress(NavigationProgress.error(userMessage))
throw e
secureLogout(context)
accountManager.clearActiveAccountId(context)
_currentAccountId.value = null

val mappedException = when (errorCode) {
D2ErrorCode.SERVER_CONNECTION_ERROR,
D2ErrorCode.UNKNOWN_HOST,
D2ErrorCode.SOCKET_TIMEOUT,
D2ErrorCode.SSL_ERROR,
D2ErrorCode.URL_NOT_FOUND,
D2ErrorCode.SERVER_URL_MALFORMED -> java.io.IOException(userMessage)
D2ErrorCode.BAD_CREDENTIALS,
D2ErrorCode.USER_ACCOUNT_DISABLED,
D2ErrorCode.USER_ACCOUNT_LOCKED -> SecurityException(userMessage)
else -> Exception(userMessage)
}
throw mappedException
}
}

Expand Down Expand Up @@ -842,6 +867,7 @@ class SessionManager @Inject constructor(

var lastError: String? = null
val maxRetries = 3
val progressTimeoutSeconds = 60L

for (attempt in 1..maxRetries) {
Log.d("SessionManager", "Metadata download attempt $attempt of $maxRetries")
Expand All @@ -858,25 +884,37 @@ class SessionManager @Inject constructor(
try {
// Following official DHIS2 Capture app pattern:
// Use non-blocking download() with onErrorComplete() to swallow errors
io.reactivex.Completable.fromObservable(
d2Instance.metadataModule().download()
.doOnNext { progress ->
val percent = progress.percentage() ?: 0.0
Log.d("SessionManager", "Metadata progress: ${percent.toInt()}%")
val downloadObservable = d2Instance.metadataModule().download()
.doOnNext { progress ->
val percent = progress.percentage() ?: 0.0
Log.d("SessionManager", "Metadata progress: ${percent.toInt()}%")
onProgress(NavigationProgress(
phase = LoadingPhase.DOWNLOADING_METADATA,
overallPercentage = 30 + (percent * 0.5).toInt(),
phaseTitle = "Downloading Metadata",
phaseDetail = "Progress: ${percent.toInt()}%"
))
}
.timeout(progressTimeoutSeconds, TimeUnit.SECONDS)

io.reactivex.Completable.fromObservable(downloadObservable)
.doOnError { error ->
if (isOptionalUseCasesMissing(error)) {
Log.w("SessionManager", "Optional server config missing: USE_CASES/stockUseCases (continuing)")
onProgress(NavigationProgress(
phase = LoadingPhase.DOWNLOADING_METADATA,
overallPercentage = 30 + (percent * 0.5).toInt(),
overallPercentage = 35,
phaseTitle = "Downloading Metadata",
phaseDetail = "Progress: ${percent.toInt()}%"
phaseDetail = "Optional server config missing (USE_CASES/stockUseCases). Ask admin to add it."
))
downloadError = null
} else {
Log.w("SessionManager", "Metadata download error: ${error.message}")
downloadError = error.message
}
)
.doOnError { error ->
Log.w("SessionManager", "Metadata download error: ${error.message}")
downloadError = error.message
}
.onErrorComplete() // Swallow errors like official app
.blockingAwait()
}
.onErrorComplete { error -> isOptionalUseCasesMissing(error) }
.blockingAwait()

Log.d("SessionManager", "Metadata download stream completed for attempt $attempt")
} catch (e: Exception) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,47 @@ class MetadataCacheService @Inject constructor(

baseData.copy(sdkDataValues = sdkDataValues)
}

/**
* Force-refresh data values for a specific dataset instance and cache them in Room.
* Returns the number of values fetched from the server.
*/
suspend fun refreshDataValues(
datasetId: String,
period: String,
orgUnit: String,
attributeOptionCombo: String
): Int = withContext(Dispatchers.IO) {
val defaultCombo = d2.categoryModule().categoryOptionCombos()
.byDisplayName().eq("default")
.one()
.blockingGet()
?.uid()
.orEmpty()

val resolvedAttr = if (attributeOptionCombo.isBlank()) defaultCombo else attributeOptionCombo

val rawSdkDataValues = d2.dataValueModule().dataValues()
.byDataSetUid(datasetId)
.byPeriod().eq(period)
.byOrganisationUnitUid().eq(orgUnit)
.byAttributeOptionComboUid().eq(resolvedAttr)
.blockingGet()

if (rawSdkDataValues.isEmpty() && resolvedAttr != defaultCombo && defaultCombo.isNotBlank()) {
val fallbackValues = d2.dataValueModule().dataValues()
.byDataSetUid(datasetId)
.byPeriod().eq(period)
.byOrganisationUnitUid().eq(orgUnit)
.byAttributeOptionComboUid().eq(defaultCombo)
.blockingGet()
storeDataValuesInRoom(datasetId, period, orgUnit, defaultCombo, fallbackValues)
fallbackValues.size
} else {
storeDataValuesInRoom(datasetId, period, orgUnit, resolvedAttr, rawSdkDataValues)
rawSdkDataValues.size
}
}

/**
* Get sections for a dataset with caching
Expand All @@ -166,6 +207,10 @@ class MetadataCacheService @Inject constructor(
.withDataElements()
.byDataSetUid().eq(datasetId)
.blockingGet()
.sortedWith(
compareBy<org.hisp.dhis.android.core.dataset.Section> { it.sortOrder() ?: Int.MAX_VALUE }
.thenBy { it.displayName() ?: "" }
)

if (sections.isEmpty()) {
val dataSet = d2.dataSetModule().dataSets()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -44,41 +44,37 @@ class AuthRepositoryImpl @Inject constructor(
context: Context,
onProgress: (NavigationProgress) -> Unit
): Boolean {
return try {
// Blocking metadata download with UI lock (completes before returning)
sessionManager.loginWithProgress(context, Dhis2Config(serverUrl, username, password), backgroundSyncManager, onProgress)

// CRITICAL: Clear metadata caches after successful login (handles user-switch scenarios)
metadataCacheService.clearAllCaches()

// CRITICAL: Start async background data sync AFTER metadata completes
// UI is unlocked, user can navigate immediately
Log.d("AuthRepositoryImpl", "Metadata sync complete - starting background data sync")
backgroundScope.launch {
var syncSuccess = false
var syncMessage: String? = null

sessionManager.startBackgroundDataSync(context) { success, message ->
syncSuccess = success
syncMessage = message
}
// Blocking metadata download with UI lock (completes before returning)
sessionManager.loginWithProgress(context, Dhis2Config(serverUrl, username, password), backgroundSyncManager, onProgress)

// CRITICAL: Clear metadata caches after successful login (handles user-switch scenarios)
metadataCacheService.clearAllCaches()

// CRITICAL: Start async background data sync AFTER metadata completes
// UI is unlocked, user can navigate immediately
Log.d("AuthRepositoryImpl", "Metadata sync complete - starting background data sync")
backgroundScope.launch {
var syncSuccess = false
var syncMessage: String? = null

sessionManager.startBackgroundDataSync(context) { success, message ->
syncSuccess = success
syncMessage = message
}

// Show non-intrusive toast notification when background sync completes
withContext(Dispatchers.Main) {
val toastMessage = if (syncSuccess) {
"✓ Data sync complete"
} else {
"⚠ Data sync incomplete: ${syncMessage ?: "Unknown error"}"
}
Toast.makeText(context, toastMessage, Toast.LENGTH_LONG).show()
Log.d("AuthRepositoryImpl", "Background sync completed: $toastMessage")
// Show non-intrusive toast notification when background sync completes
withContext(Dispatchers.Main) {
val toastMessage = if (syncSuccess) {
"✓ Data sync complete"
} else {
"⚠ Data sync incomplete: ${syncMessage ?: "Unknown error"}"
}
Toast.makeText(context, toastMessage, Toast.LENGTH_LONG).show()
Log.d("AuthRepositoryImpl", "Background sync completed: $toastMessage")
}

true
} catch (e: Exception) {
false
}

return true
}

/**
Expand Down Expand Up @@ -128,4 +124,4 @@ class SystemRepositoryImpl @Inject constructor(
throw e
}
}
}
}
Loading