Skip to content

Commit

Permalink
Support Configurable Database Export (#3685)
Browse files Browse the repository at this point in the history
* App database download feature

* Run spotlessApply

* Make Export DB menu option configurable

* Upgrade version code to 13 and name to 2.1.0

* Code cleanup

* Make zip parameters configurable

* Change zip password to charArray

* Update android/quest/build.gradle.kts

Co-authored-by: Elly Kitoto <[email protected]>

---------

Co-authored-by: Elly Kitoto <[email protected]>
  • Loading branch information
qiarie and ellykits authored Jan 15, 2025
1 parent 7758c43 commit e10a6a8
Show file tree
Hide file tree
Showing 15 changed files with 349 additions and 2 deletions.
4 changes: 2 additions & 2 deletions android/buildSrc/src/main/kotlin/BuildConfigs.kt
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@ object BuildConfigs {
const val minSdk = 26
const val compileSdk = 34
const val targetSdk = 34
const val versionCode = 12
const val versionName = "2.0.2"
const val versionCode = 13
const val versionName = "2.1.0"
const val applicationId = "org.smartregister.opensrp"
const val jvmToolchain = 17
const val kotlinCompilerExtensionVersion = "1.5.8"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -82,4 +82,5 @@ enum class SettingsOptions {
RESET_DATA,
INSIGHTS,
CONTACT_HELP,
DATABASE_EXPORT,
}
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ const val SDF_E_MMM_DD_YYYY = "E, MMM dd yyyy"
const val DEFAULT_FORMAT_SDF_DD_MM_YYYY = "EEE, MMM dd - hh:mm a"
const val SDF_YYYY_MMM_DD_HH_MM_SS = "yyyy-MM-dd HH:mm:ss"
const val MMM_D_HH_MM_AA = "MMM d, hh:mm aa"
const val SDF_YYYYMMDD_HHMMSS = "yyyyMMdd-HHmmss"

fun yesterday(): Date = DateTimeType.now().apply { add(Calendar.DATE, -1) }.value

Expand Down
3 changes: 3 additions & 0 deletions android/engine/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,9 @@
<string name="percentage_progress" translatable="false">%1$d%%</string>
<string name="error_occurred">Something went wrong…</string>
<string name="insights">Insights</string>
<string name="export_db" translatable="false">Export DB</string>
<string name="exporting_db" translatable="false">Exporting DB...</string>
<string name="share_file" translatable="false">Share File</string>
<string name="contact_help">"Contact help"</string>
<string name="offline_map">"Offline Maps"</string>
<string name="dismiss">Dismiss</string>
Expand Down
9 changes: 9 additions & 0 deletions android/gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -75,27 +75,35 @@ retrofit = "2.9.0"
retrofit-mock = "2.9.0"
retrofit2-kotlinx-serialization-converter = "0.8.0"
robolectric = "4.13"
room = "2.5.2"
rules = "1.6.1"
security-crypto = "1.1.0-alpha06"
slf4j-nop = "2.0.7"
spotlessPluginGradle = "6.25.0"
sqlcipher = "4.5.4"
stax-api = "1.0-2"
timber = "5.0.1"
uiautomator = "2.3.0"
work = "2.9.1"
xercesImpl = "2.12.2"
androidFragmentCompose = "1.8.5"
zip4j = "2.11.5"

[libraries]
accompanist-flowlayout = { group = "com.google.accompanist", name = "accompanist-flowlayout", version.ref = "accompanist" }
accompanist-placeholder = { group = "com.google.accompanist", name = "accompanist-placeholder", version.ref = "accompanist" }
activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activity-compose" }
android-database-sqlcipher = { module = "net.zetetic:android-database-sqlcipher", version.ref = "sqlcipher" }
androidx-camera-camera2 = { group = "androidx.camera", name = "camera-camera2", version.ref = "androidx-camera"}
androidx-camera-extensions = { group = "androidx.camera", name = "camera-extensions", version.ref = "androidx-camera"}
androidx-camera-lifecycle = { group = "androidx.camera", name = "camera-lifecycle", version.ref = "androidx-camera"}
androidx-camera-mlkit-vision = {group = "androidx.camera", name = "camera-mlkit-vision", version.ref = "androidx-camera"}
androidx-camera-view = {group = "androidx.camera", name = "camera-view", version.ref = "androidx-camera"}
activity-ktx = { group = "androidx.activity", name = "activity-ktx", version.ref = "activity-compose" }
androidx-room-compiler = { module = "androidx.room:room-compiler", version.ref = "room" }
androidx-room-ktx = { module = "androidx.room:room-ktx", version.ref = "room" }
androidx-room-runtime = { module = "androidx.room:room-runtime", version.ref = "room" }
androidx-room-testing = { module = "androidx.room:room-testing", version.ref = "room" }
appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "appcompat" }
benchmark-junit = { group = "androidx.benchmark", name = "benchmark-junit4", version.ref = "benchmark-junit" }
cardview = { group = "androidx.cardview", name = "cardview", version.ref = "cardview" }
Expand Down Expand Up @@ -203,6 +211,7 @@ work-testing = { group = "androidx.work", name = "work-testing", version.ref = "
workflow = { group = "org.smartregister", name = "workflow", version.ref = "fhir-sdk-workflow" }
xercesImpl = { group = "xerces", name = "xercesImpl", version.ref = "xercesImpl" }
androidx-fragment-compose = { group = "androidx.fragment", name = "fragment-compose", version.ref = "androidFragmentCompose" }
zip4j = { module = "net.lingala.zip4j:zip4j", version.ref = "zip4j" }


[plugins]
Expand Down
9 changes: 9 additions & 0 deletions android/quest/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -512,6 +512,15 @@ dependencies {
debugImplementation(libs.fragment.testing)
// debugImplementation(libs.leakcanary.android)

kapt(libs.androidx.room.compiler)
implementation(libs.androidx.room.ktx)
implementation(libs.androidx.room.runtime)
testImplementation(libs.androidx.room.testing)

implementation(libs.android.database.sqlcipher)

implementation(libs.zip4j)

// Annotation processors for test
kaptTest(libs.dagger.hilt.android.compiler)
kaptAndroidTest(libs.dagger.hilt.android.compiler)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,12 @@ class UserSettingScreenTest {
composeRule.onNodeWithText("Contact help").assertHasClickAction()
}

@Test
fun testExportDbIsClickable() {
initComposable()
composeRule.onNodeWithText("Export DB").assertHasClickAction()
}

@Test
fun testOnClickingInsightsAllDataSavedToastShown() {
initComposable()
Expand All @@ -185,6 +191,7 @@ class UserSettingScreenTest {
showAppInsights: Boolean = true,
hasOfflineMaps: Boolean = true,
showContactHelp: Boolean = true,
isDatabaseExportEnabled: Boolean = true,
) {
scenario.onActivity { activity ->
activity.setContent {
Expand All @@ -209,6 +216,7 @@ class UserSettingScreenTest {
enableAppInsights = showAppInsights,
showOfflineMaps = hasOfflineMaps,
enableHelpContacts = showContactHelp,
enableDatabaseExport = isDatabaseExportEnabled,
)
}

Expand Down
1 change: 1 addition & 0 deletions android/quest/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" android:maxSdkVersion="32" />
<uses-permission android:name="android.permission.CAMERA" />
<uses-feature android:name="android.hardware.camera" />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,8 @@ class UserSettingFragment : Fragment(), OnSyncListener {
allowP2PSync = userSettingViewModel.enabledDeviceToDeviceSync(),
enableHelpContacts =
userSettingViewModel.enableMenuOption(SettingsOptions.CONTACT_HELP),
enableDatabaseExport =
userSettingViewModel.enableMenuOption(SettingsOptions.DATABASE_EXPORT),
)
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ import androidx.compose.material.icons.automirrored.rounded.Logout
import androidx.compose.material.icons.rounded.ChevronRight
import androidx.compose.material.icons.rounded.DeleteForever
import androidx.compose.material.icons.rounded.Insights
import androidx.compose.material.icons.rounded.IosShare
import androidx.compose.material.icons.rounded.Map
import androidx.compose.material.icons.rounded.Phone
import androidx.compose.material.icons.rounded.Share
Expand Down Expand Up @@ -129,6 +130,7 @@ fun UserSettingScreen(
showOfflineMaps: Boolean = false,
allowP2PSync: Boolean = false,
enableHelpContacts: Boolean = false,
enableDatabaseExport: Boolean = false,
) {
val context = LocalContext.current
val (showProgressBar, messageResource) = progressBarState
Expand Down Expand Up @@ -355,6 +357,16 @@ fun UserSettingScreen(
)
}

if (enableDatabaseExport) {
UserSettingRow(
icon = Icons.Rounded.IosShare,
text = stringResource(id = R.string.export_db),
clickListener = { onEvent(UserSettingsEvent.ExportDB(true, context)) },
modifier = modifier.testTag(USER_SETTING_ROW_INSIGHTS),
showProgressIndicator = showProgressIndicatorFlow.collectAsState().value,
)
}

UserSettingRow(
icon = Icons.AutoMirrored.Rounded.Logout,
text = stringResource(id = R.string.logout),
Expand Down Expand Up @@ -524,5 +536,6 @@ fun UserSettingPreview() {
showOfflineMaps = true,
allowP2PSync = true,
enableHelpContacts = true,
enableDatabaseExport = true,
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -17,13 +17,16 @@
package org.smartregister.fhircore.quest.ui.usersetting

import android.content.Context
import android.os.Environment
import android.widget.Toast
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.work.WorkManager
import com.google.android.fhir.FhirEngine
import dagger.hilt.android.lifecycle.HiltViewModel
import java.io.File
import java.io.IOException
import java.util.Locale
import javax.inject.Inject
import kotlinx.coroutines.flow.MutableSharedFlow
Expand All @@ -33,6 +36,9 @@ import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withContext
import net.lingala.zip4j.model.ZipParameters
import net.lingala.zip4j.model.enums.CompressionLevel
import net.lingala.zip4j.model.enums.EncryptionMethod
import org.smartregister.fhircore.engine.R
import org.smartregister.fhircore.engine.configuration.ConfigType
import org.smartregister.fhircore.engine.configuration.ConfigurationRegistry
Expand All @@ -46,22 +52,30 @@ import org.smartregister.fhircore.engine.util.DispatcherProvider
import org.smartregister.fhircore.engine.util.SecureSharedPreference
import org.smartregister.fhircore.engine.util.SharedPreferenceKey
import org.smartregister.fhircore.engine.util.SharedPreferencesHelper
import org.smartregister.fhircore.engine.util.extension.SDF_YYYYMMDD_HHMMSS
import org.smartregister.fhircore.engine.util.extension.SDF_YYYY_MMM_DD_HH_MM_SS
import org.smartregister.fhircore.engine.util.extension.countUnSyncedResources
import org.smartregister.fhircore.engine.util.extension.fetchLanguages
import org.smartregister.fhircore.engine.util.extension.formatDate
import org.smartregister.fhircore.engine.util.extension.getActivity
import org.smartregister.fhircore.engine.util.extension.isDeviceOnline
import org.smartregister.fhircore.engine.util.extension.launchActivityWithNoBackStackHistory
import org.smartregister.fhircore.engine.util.extension.reformatDate
import org.smartregister.fhircore.engine.util.extension.refresh
import org.smartregister.fhircore.engine.util.extension.setAppLocale
import org.smartregister.fhircore.engine.util.extension.showToast
import org.smartregister.fhircore.engine.util.extension.today
import org.smartregister.fhircore.quest.BuildConfig
import org.smartregister.fhircore.quest.navigation.MainNavigationScreen
import org.smartregister.fhircore.quest.ui.appsetting.AppSettingActivity
import org.smartregister.fhircore.quest.ui.login.AccountAuthenticator
import org.smartregister.fhircore.quest.ui.login.LoginActivity
import org.smartregister.fhircore.quest.util.DBUtils
import org.smartregister.fhircore.quest.util.FileUtils
import org.smartregister.p2p.utils.startP2PScreen
import timber.log.Timber

private const val FHIR_ENGINE_DB_PASSPHRASE = "fhirEngineDbPassphrase"

@HiltViewModel
class UserSettingViewModel
Expand Down Expand Up @@ -177,6 +191,10 @@ constructor(
is UserSettingsEvent.ShowInsightsScreen -> {
event.navController.navigate(MainNavigationScreen.Insight.route)
}
is UserSettingsEvent.ExportDB -> {
updateProgressBarState(true, R.string.exporting_db)
copyDatabase(event.context) { updateProgressBarState(false, R.string.exporting_db) }
}
}
}

Expand Down Expand Up @@ -228,4 +246,63 @@ constructor(
suspend fun emitSnackBarState(snackBarMessageConfig: SnackBarMessageConfig) {
_snackBarStateFlow.emit(snackBarMessageConfig)
}

private fun copyDatabase(context: Context, onCopyCompleteListener: () -> Unit) {
viewModelScope.launch(dispatcherProvider.io()) {
try {
val passphrase = DBUtils.getEncryptionPassphrase(FHIR_ENGINE_DB_PASSPHRASE)
val dbFilename = if (BuildConfig.DEBUG) "resources" else "resources_encrypted"
val dbFile = File("/data/data/${context.packageName}/databases/$dbFilename.db")

val downloadsDir =
Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS)

val username = secureSharedPreference.retrieveSessionUsername()
val practitionerId =
sharedPreferencesHelper.read(SharedPreferenceKey.PRACTITIONER_ID.name, username)

val appName = applicationConfiguration.appTitle.replace(" ", "_")
val fileTimestamp = today().formatDate(SDF_YYYYMMDD_HHMMSS)
val filename = "${appName}_${username}_${practitionerId}_$fileTimestamp.db"
val backupFile =
File(
downloadsDir,
filename,
)

val dbCopied =
if (BuildConfig.DEBUG) {
DBUtils.copyUnencryptedDb(dbFile, backupFile)
} else {
DBUtils.decryptDb(dbFile, backupFile, passphrase)
}

if (dbCopied) {
val zipFile = File("${backupFile.absolutePath}.zip")
val practitionerUuid = practitionerId!!.substring(0, practitionerId.indexOf("-"))
val zipPassword = "${username}_$practitionerUuid".toCharArray()
val zipParameters =
ZipParameters().apply {
isEncryptFiles = true
compressionLevel = CompressionLevel.HIGHER
encryptionMethod = EncryptionMethod.AES
}

FileUtils.zipFiles(
zipFile,
listOf(backupFile),
zipPassword,
zipParameters,
true,
)

if (zipFile.exists()) FileUtils.shareFile(context, zipFile)
}
} catch (e: IOException) {
Timber.e(e, "Failed to copy application's database")
} finally {
onCopyCompleteListener.invoke()
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -40,4 +40,6 @@ sealed class UserSettingsEvent {
data class OnLaunchOfflineMap(val isShow: Boolean, val context: Context) : UserSettingsEvent()

data class ShowInsightsScreen(val navController: NavController) : UserSettingsEvent()

data class ExportDB(val isShow: Boolean, val context: Context) : UserSettingsEvent()
}
Loading

0 comments on commit e10a6a8

Please sign in to comment.