From 15563af4a2c26a54c590d913e2fb99b88eaf5160 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 9 Jun 2025 09:07:37 +0000 Subject: [PATCH 01/48] chore(deps): bump com.polidea.rxandroidble3:rxandroidble Bumps [com.polidea.rxandroidble3:rxandroidble](https://github.com/dariuszseweryn/RxAndroidBle) from 1.19.0 to 1.19.1. - [Release notes](https://github.com/dariuszseweryn/RxAndroidBle/releases) - [Changelog](https://github.com/dariuszseweryn/RxAndroidBle/blob/master/CHANGELOG.md) - [Commits](https://github.com/dariuszseweryn/RxAndroidBle/commits) --- updated-dependencies: - dependency-name: com.polidea.rxandroidble3:rxandroidble dependency-version: 1.19.1 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index a9c8c01fee4..923e9096ff4 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -148,7 +148,7 @@ com-github-kenglxn-qrgen-android = { group = "com.github.kenglxn.QRGen", name = com-github-rtchagas-pingplacepicker = { group = "com.github.rtchagas", name = "pingplacepicker", version = "3.0.1" } io-socket-client = { group = "io.socket", name = "socket.io-client", version = "2.1.2" } io-kotlintest-runner-junit5 = { group = "io.kotlintest", name = "kotlintest-runner-junit5", version = "3.4.2" } -com-polidea-rxandroidble3 = { group = "com.polidea.rxandroidble3", name = "rxandroidble", version = "1.19.0" } +com-polidea-rxandroidble3 = { group = "com.polidea.rxandroidble3", name = "rxandroidble", version = "1.19.1" } com-jakewharton-rx3-replaying-share = { group = "com.jakewharton.rx3", name = "replaying-share", version = "3.0.0" } commons-codec = { group = "commons-codec", name = "commons-codec", version = "1.18.0" } com-github-guepardoapps-kulid = { group = "com.github.guepardoapps", name = "kulid", version = "2.0.0.0" } From 651234264c0b1fc1f1ca132484b2b6ea17442888 Mon Sep 17 00:00:00 2001 From: Milos Kozak Date: Wed, 31 Dec 2025 11:02:39 +0100 Subject: [PATCH 02/48] 3.4.0.0-dev --- buildSrc/src/main/kotlin/Versions.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/buildSrc/src/main/kotlin/Versions.kt b/buildSrc/src/main/kotlin/Versions.kt index 70218541834..35e8d12e7f4 100644 --- a/buildSrc/src/main/kotlin/Versions.kt +++ b/buildSrc/src/main/kotlin/Versions.kt @@ -4,7 +4,7 @@ import org.jetbrains.kotlin.gradle.dsl.JvmTarget @Suppress("ConstPropertyName") object Versions { - const val appVersion = "3.3.3.0-dev-d" + const val appVersion = "3.4.0.0-dev" const val versionCode = 1500 const val compileSdk = 36 From 9f9f9884e710d21006514e7111c0207d61775a00 Mon Sep 17 00:00:00 2001 From: Philoul Date: Thu, 8 Jan 2026 13:04:22 +0100 Subject: [PATCH 03/48] Dash BolusProgress amount moved toEventOverviewBolusProgress --- .../aaps/pump/omnipod/dash/OmnipodDashPumpPlugin.kt | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/pump/omnipod/dash/src/main/kotlin/app/aaps/pump/omnipod/dash/OmnipodDashPumpPlugin.kt b/pump/omnipod/dash/src/main/kotlin/app/aaps/pump/omnipod/dash/OmnipodDashPumpPlugin.kt index 0aa93b79d9f..14439107a12 100644 --- a/pump/omnipod/dash/src/main/kotlin/app/aaps/pump/omnipod/dash/OmnipodDashPumpPlugin.kt +++ b/pump/omnipod/dash/src/main/kotlin/app/aaps/pump/omnipod/dash/OmnipodDashPumpPlugin.kt @@ -700,10 +700,7 @@ class OmnipodDashPumpPlugin @Inject constructor( continue } val percent = (waited.toFloat() / estimatedDeliveryTimeSeconds) * 100 - updateBolusProgressDialog( - rh.gs(app.aaps.core.interfaces.R.string.bolus_delivering, Round.roundTo(percent * requestedBolusAmount / 100, PodConstants.POD_PULSE_BOLUS_UNITS)), - percent.toInt() - ) + rxBus.send(EventOverviewBolusProgress(rh, percent = percent.toInt())) } (1..BOLUS_RETRIES).forEach { tryNumber -> @@ -731,10 +728,7 @@ class OmnipodDashPumpPlugin @Inject constructor( // delivery not complete yet val remainingUnits = podStateManager.lastBolus!!.bolusUnitsRemaining val percent = ((requestedBolusAmount - remainingUnits) / requestedBolusAmount) * 100 - updateBolusProgressDialog( - rh.gs(app.aaps.core.interfaces.R.string.bolus_delivering, Round.roundTo(requestedBolusAmount - remainingUnits, PodConstants.POD_PULSE_BOLUS_UNITS)), - percent.toInt() - ) + rxBus.send(EventOverviewBolusProgress(rh, percent = percent.toInt())) val sleepSeconds = if (bolusCanceled) BOLUS_RETRY_INTERVAL_MS From 57df9026cf892368df6d89e1c6f37fc50dce0f5f Mon Sep 17 00:00:00 2001 From: Philoul Date: Fri, 9 Jan 2026 08:58:31 +0100 Subject: [PATCH 04/48] Fix Wear BolusProgress with Total Amount --- .../core/interfaces/pump/BolusProgressData.kt | 2 ++ .../rx/events/EventOverviewBolusProgress.kt | 15 ++++++++++----- core/interfaces/src/main/res/values/strings.xml | 1 + .../app/aaps/plugins/sync/wear/WearPlugin.kt | 2 +- 4 files changed, 14 insertions(+), 6 deletions(-) diff --git a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/pump/BolusProgressData.kt b/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/pump/BolusProgressData.kt index b6c796a2fb4..30d590b5660 100644 --- a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/pump/BolusProgressData.kt +++ b/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/pump/BolusProgressData.kt @@ -20,6 +20,7 @@ object BolusProgressData { bolusEnded = false stopPressed = false status = "" + wearStatus = "" percent = 0 } @@ -48,6 +49,7 @@ object BolusProgressData { * Last received status update */ var status = "" + var wearStatus = "" var percent = 0 var bolusEnded = false diff --git a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/rx/events/EventOverviewBolusProgress.kt b/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/rx/events/EventOverviewBolusProgress.kt index 3e33b7f45f7..a86339558fa 100644 --- a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/rx/events/EventOverviewBolusProgress.kt +++ b/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/rx/events/EventOverviewBolusProgress.kt @@ -8,12 +8,13 @@ import kotlin.math.min /** * Custom status message and percent */ -class EventOverviewBolusProgress(status: String, val id: Long? = null, percent: Int? = null) : Event() { +class EventOverviewBolusProgress(status: String, val id: Long? = null, percent: Int? = null, wearStatus: String? = null) : Event() { init { if (id == BolusProgressData.id || id == null) { BolusProgressData.status = status percent?.let { BolusProgressData.percent = it } + BolusProgressData.wearStatus = wearStatus ?: status } } @@ -22,9 +23,10 @@ class EventOverviewBolusProgress(status: String, val id: Long? = null, percent: */ constructor(rh: ResourceHelper, delivered: Double, id: Long? = null) : this( - rh.gs(R.string.bolus_delivering, delivered), + status = rh.gs(R.string.bolus_delivering, delivered), id = id, - percent = min((delivered / BolusProgressData.insulin * 100).toInt(), 100) + percent = min((delivered / BolusProgressData.insulin * 100).toInt(), 100), + wearStatus = rh.gs(R.string.bolus_delivered_so_far, delivered, BolusProgressData.insulin) ) /** @@ -37,6 +39,9 @@ class EventOverviewBolusProgress(status: String, val id: Long? = null, percent: if (percent == 100) rh.gs(R.string.bolus_delivered_successfully, BolusProgressData.insulin) else rh.gs(R.string.bolus_delivering, BolusProgressData.insulin * percent / 100.0), id = id, - percent = min(percent, 100) - ) + percent = min(percent, 100), + wearStatus = + if (percent == 100) rh.gs(R.string.bolus_delivered_successfully, BolusProgressData.insulin) + else rh.gs(R.string.bolus_delivered_so_far, BolusProgressData.insulin * percent / 100.0, BolusProgressData.insulin) + ) } \ No newline at end of file diff --git a/core/interfaces/src/main/res/values/strings.xml b/core/interfaces/src/main/res/values/strings.xml index 486268e11dd..379ca57538e 100644 --- a/core/interfaces/src/main/res/values/strings.xml +++ b/core/interfaces/src/main/res/values/strings.xml @@ -2,6 +2,7 @@ Delivering %1$.2fU + %1$.2fU / %2$.2fU delivered Bolus %1$.2fU delivered successfully diff --git a/plugins/sync/src/main/kotlin/app/aaps/plugins/sync/wear/WearPlugin.kt b/plugins/sync/src/main/kotlin/app/aaps/plugins/sync/wear/WearPlugin.kt index bed229d395c..8167b0dc542 100644 --- a/plugins/sync/src/main/kotlin/app/aaps/plugins/sync/wear/WearPlugin.kt +++ b/plugins/sync/src/main/kotlin/app/aaps/plugins/sync/wear/WearPlugin.kt @@ -91,7 +91,7 @@ class WearPlugin @Inject constructor( .observeOn(aapsSchedulers.io) .subscribe({ event: EventOverviewBolusProgress -> if (!BolusProgressData.isSMB || preferences.get(BooleanKey.WearNotifyOnSmb)) { - if (isEnabled()) rxBus.send(EventMobileToWear(EventData.BolusProgress(percent = BolusProgressData.percent, status = BolusProgressData.status))) + if (isEnabled()) rxBus.send(EventMobileToWear(EventData.BolusProgress(percent = BolusProgressData.percent, status = BolusProgressData.wearStatus))) } }, fabricPrivacy::logException) disposable += rxBus From 5ff8fe111d1891a70640a562664825507f6be088 Mon Sep 17 00:00:00 2001 From: Angus Date: Sat, 10 Jan 2026 21:30:11 +0800 Subject: [PATCH 05/48] Add backup to Google Drive --- app/src/main/AndroidManifest.xml | 3 +- .../main/res/xml/network_security_config.xml | 7 + .../kotlin/app/aaps/core/keys/BooleanKey.kt | 9 + .../kotlin/app/aaps/core/keys/StringKey.kt | 5 + .../app/aaps/core/ui/toast/ToastUtils.kt | 4 + .../ui/src/main/res/drawable/ic_directory.xml | 4 +- .../serialisation/SealedClassHelper.kt | 9 +- plugins/configuration/build.gradle.kts | 6 + .../src/main/AndroidManifest.xml | 4 + .../DaggerAppCompatActivityWithResult.kt | 10 + .../configuration/di/CloudStorageModule.kt | 40 + .../configuration/di/ConfigurationModule.kt | 5 +- .../maintenance/ImportExportPrefsImpl.kt | 492 +++++- .../maintenance/MaintenanceFragment.kt | 138 +- .../maintenance/MaintenancePlugin.kt | 103 +- .../activities/CloudPrefImportListActivity.kt | 210 +++ .../maintenance/cloud/CloudConstants.kt | 65 + .../maintenance/cloud/CloudDirectoryDialog.kt | 319 ++++ .../maintenance/cloud/CloudModels.kt | 58 + .../maintenance/cloud/CloudStorageManager.kt | 183 +++ .../maintenance/cloud/CloudStorageProvider.kt | 183 +++ .../maintenance/cloud/ExportOptionsDialog.kt | 264 +++ .../events/EventCloudStorageStatusChanged.kt | 9 + .../googledrive/GoogleDriveManager.kt | 1417 +++++++++++++++++ .../googledrive/GoogleDriveProvider.kt | 199 +++ .../src/main/res/drawable/ic_cloud_upload.xml | 10 + .../src/main/res/drawable/ic_error.xml | 9 + .../main/res/drawable/ic_export_options.xml | 13 + .../src/main/res/drawable/ic_google_drive.xml | 13 + .../res/layout/dialog_cloud_directory.xml | 86 + .../main/res/layout/dialog_export_options.xml | 211 +++ .../main/res/layout/maintenance_fragment.xml | 87 +- .../maintenance_import_list_activity.xml | 27 +- .../src/main/res/values/strings.xml | 85 + 34 files changed, 4255 insertions(+), 32 deletions(-) create mode 100644 app/src/main/res/xml/network_security_config.xml create mode 100644 plugins/configuration/src/main/kotlin/app/aaps/plugins/configuration/di/CloudStorageModule.kt create mode 100644 plugins/configuration/src/main/kotlin/app/aaps/plugins/configuration/maintenance/activities/CloudPrefImportListActivity.kt create mode 100644 plugins/configuration/src/main/kotlin/app/aaps/plugins/configuration/maintenance/cloud/CloudConstants.kt create mode 100644 plugins/configuration/src/main/kotlin/app/aaps/plugins/configuration/maintenance/cloud/CloudDirectoryDialog.kt create mode 100644 plugins/configuration/src/main/kotlin/app/aaps/plugins/configuration/maintenance/cloud/CloudModels.kt create mode 100644 plugins/configuration/src/main/kotlin/app/aaps/plugins/configuration/maintenance/cloud/CloudStorageManager.kt create mode 100644 plugins/configuration/src/main/kotlin/app/aaps/plugins/configuration/maintenance/cloud/CloudStorageProvider.kt create mode 100644 plugins/configuration/src/main/kotlin/app/aaps/plugins/configuration/maintenance/cloud/ExportOptionsDialog.kt create mode 100644 plugins/configuration/src/main/kotlin/app/aaps/plugins/configuration/maintenance/cloud/events/EventCloudStorageStatusChanged.kt create mode 100644 plugins/configuration/src/main/kotlin/app/aaps/plugins/configuration/maintenance/cloud/providers/googledrive/GoogleDriveManager.kt create mode 100644 plugins/configuration/src/main/kotlin/app/aaps/plugins/configuration/maintenance/cloud/providers/googledrive/GoogleDriveProvider.kt create mode 100644 plugins/configuration/src/main/res/drawable/ic_cloud_upload.xml create mode 100644 plugins/configuration/src/main/res/drawable/ic_error.xml create mode 100644 plugins/configuration/src/main/res/drawable/ic_export_options.xml create mode 100644 plugins/configuration/src/main/res/drawable/ic_google_drive.xml create mode 100644 plugins/configuration/src/main/res/layout/dialog_cloud_directory.xml create mode 100644 plugins/configuration/src/main/res/layout/dialog_export_options.xml diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index da29c11f5e5..f69fc78c976 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -37,7 +37,8 @@ android:restoreAnyVersion="true" android:roundIcon="${appIconRound}" android:supportsRtl="true" - android:theme="@style/AppTheme.Launcher"> + android:theme="@style/AppTheme.Launcher" + android:networkSecurityConfig="@xml/network_security_config"> + + + localhost + 127.0.0.1 + + diff --git a/core/keys/src/main/kotlin/app/aaps/core/keys/BooleanKey.kt b/core/keys/src/main/kotlin/app/aaps/core/keys/BooleanKey.kt index ba1d1ff6607..fece627f4a2 100644 --- a/core/keys/src/main/kotlin/app/aaps/core/keys/BooleanKey.kt +++ b/core/keys/src/main/kotlin/app/aaps/core/keys/BooleanKey.kt @@ -120,4 +120,13 @@ enum class BooleanKey( SiteRotationManagePump("site_rotation_manage_pump", defaultValue = false), SiteRotationManageCgm("site_rotation_manage_cgm", defaultValue = false), + // Export destination settings + ExportAllCloudEnabled("export_all_cloud_enabled", defaultValue = false), + ExportLogEmailEnabled("export_log_email_enabled", defaultValue = true), + ExportLogCloudEnabled("export_log_cloud_enabled", defaultValue = false), + ExportSettingsLocalEnabled("export_settings_local_enabled", defaultValue = true), + ExportSettingsCloudEnabled("export_settings_cloud_enabled", defaultValue = false), + ExportCsvLocalEnabled("export_csv_local_enabled", defaultValue = true), + ExportCsvCloudEnabled("export_csv_cloud_enabled", defaultValue = false), + } \ No newline at end of file diff --git a/core/keys/src/main/kotlin/app/aaps/core/keys/StringKey.kt b/core/keys/src/main/kotlin/app/aaps/core/keys/StringKey.kt index 89a881088cf..62f46309c81 100644 --- a/core/keys/src/main/kotlin/app/aaps/core/keys/StringKey.kt +++ b/core/keys/src/main/kotlin/app/aaps/core/keys/StringKey.kt @@ -51,6 +51,11 @@ enum class StringKey( NsClientWifiSsids("ns_wifi_ssids", "", dependency = BooleanKey.NsClientUseWifi), NsClientAccessToken("nsclient_token", "", isPassword = true), + // Google Drive settings + GoogleDriveStorageType("google_drive_storage_type", "local"), + GoogleDriveFolderId("google_drive_folder_id", ""), + GoogleDriveRefreshToken("google_drive_refresh_token", "", isPassword = true), + PumpCommonBolusStorage("pump_sync_storage_bolus", ""), PumpCommonTbrStorage("pump_sync_storage_tbr", ""), } \ No newline at end of file diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/toast/ToastUtils.kt b/core/ui/src/main/kotlin/app/aaps/core/ui/toast/ToastUtils.kt index c0bd135b48d..e041e633079 100644 --- a/core/ui/src/main/kotlin/app/aaps/core/ui/toast/ToastUtils.kt +++ b/core/ui/src/main/kotlin/app/aaps/core/ui/toast/ToastUtils.kt @@ -38,6 +38,10 @@ object ToastUtils { graphicalToast(ctx, ctx?.getString(id), R.drawable.ic_toast_info, true) } + fun longInfoToast(ctx: Context?, string: String?) { + graphicalToast(ctx, string, R.drawable.ic_toast_info, false) + } + fun okToast(ctx: Context?, string: String?, isShort: Boolean = true) { graphicalToast(ctx, string, R.drawable.ic_toast_check, isShort) } diff --git a/core/ui/src/main/res/drawable/ic_directory.xml b/core/ui/src/main/res/drawable/ic_directory.xml index 17ca703adb5..ed024e7343c 100644 --- a/core/ui/src/main/res/drawable/ic_directory.xml +++ b/core/ui/src/main/res/drawable/ic_directory.xml @@ -1,5 +1,5 @@ - + - + diff --git a/database/impl/src/main/kotlin/app/aaps/database/serialisation/SealedClassHelper.kt b/database/impl/src/main/kotlin/app/aaps/database/serialisation/SealedClassHelper.kt index c76245a625b..014d369b696 100644 --- a/database/impl/src/main/kotlin/app/aaps/database/serialisation/SealedClassHelper.kt +++ b/database/impl/src/main/kotlin/app/aaps/database/serialisation/SealedClassHelper.kt @@ -29,7 +29,14 @@ object SealedClassHelper { jsonReader.beginObject() val nextName = jsonReader.nextName() val innerClass = kClass.sealedSubclasses.firstOrNull { it.simpleName == nextName } - ?: throw Exception("$nextName is not a child of the sealed class ${kClass.qualifiedName}") + if (innerClass == null) { + // Skip the unknown value and return UNKNOWN if available + jsonReader.skipValue() + jsonReader.endObject() + @Suppress("UNCHECKED_CAST") + val unknownInstance = kClass.sealedSubclasses.firstOrNull { it.simpleName == "UNKNOWN" }?.objectInstance as T? + return unknownInstance + } val x = gson.fromJson(jsonReader, innerClass.javaObjectType) jsonReader.endObject() // if there a static object, actually return that diff --git a/plugins/configuration/build.gradle.kts b/plugins/configuration/build.gradle.kts index c4b9f5e474b..aa342a3ab37 100644 --- a/plugins/configuration/build.gradle.kts +++ b/plugins/configuration/build.gradle.kts @@ -31,6 +31,12 @@ dependencies { api(libs.androidx.work.runtime) // Maintenance api(libs.androidx.gridlayout) + + // HTTP client for Google Drive API + implementation(libs.com.squareup.okhttp3.okhttp) + + // Chrome Custom Tabs for OAuth flow + api(libs.androidx.browser) ksp(libs.com.google.dagger.compiler) ksp(libs.com.google.dagger.android.processor) diff --git a/plugins/configuration/src/main/AndroidManifest.xml b/plugins/configuration/src/main/AndroidManifest.xml index 745b5a7b1a2..0bd949c2557 100644 --- a/plugins/configuration/src/main/AndroidManifest.xml +++ b/plugins/configuration/src/main/AndroidManifest.xml @@ -17,6 +17,10 @@ android:name=".maintenance.activities.PrefImportListActivity" android:exported="false" android:theme="@style/AppTheme" /> + = emptyList() + var cloudNextPageToken: String? = null + var cloudTotalFilesCount: Int = 0 // Total count of settings files + } + override var selectedImportFile: PrefsFile? = null override fun prefsFileExists(): Boolean = prefFileList.listPreferenceFiles().isNotEmpty() @@ -231,6 +251,34 @@ class ImportExportPrefsImpl @Inject constructor( ) } + // Added: Confirmation dialog for non-filename targets (e.g., Google Drive) that can force password prompt + private fun askToConfirmExport(activity: FragmentActivity, targetDisplayName: String, forcePrompt: Boolean = false, then: ((password: String) -> Unit)) { + if (!assureMasterPasswordSet(activity, app.aaps.core.ui.R.string.nav_export)) return + + if (!forcePrompt) { + val (password, isExpired, isAboutToExpire) = exportPasswordDataStore.getPasswordFromDataStore(context) + if (password.isNotEmpty() && !(isExpired || isAboutToExpire)) { + then(password) + return + } + } + exportPasswordDataStore.clearPasswordDataStore(context) + + TwoMessagesAlertDialog.showAlert( + activity, + rh.gs(app.aaps.core.ui.R.string.nav_export), + rh.gs(R.string.export_to) + " " + targetDisplayName + " ?", + rh.gs(R.string.password_preferences_encrypt_prompt), + { + askForMasterPassIfNeeded(activity, R.string.preferences_export_canceled) { pwd -> + then(exportPasswordDataStore.putPasswordToDataStore(context, pwd)) + } + }, + null, + R.drawable.ic_header_export + ) + } + private fun askToConfirmImport(activity: FragmentActivity, fileToImport: PrefsFile, then: ((password: String) -> Unit)) { if (!assureMasterPasswordSet(activity, R.string.import_setting)) return TwoMessagesAlertDialog.showAlert( @@ -300,9 +348,35 @@ class ImportExportPrefsImpl @Inject constructor( } private fun exportSharedPreferences(activity: FragmentActivity) { + // Check export destination preference for user settings + // If settings cloud is enabled (either via master switch or individual setting) AND cloud is configured, use cloud + val useCloudExport = (exportOptionsDialog.isSettingsCloudEnabled()) && + cloudStorageManager.isCloudStorageActive() + + if (useCloudExport) { + exportToCloud(activity) + return + } + + // Local export requires AAPS base directory + val directoryUri = preferences.getIfExists(StringKey.AapsDirectoryUri) + if (directoryUri.isNullOrEmpty()) { + ToastUtils.errorToast(activity, rh.gs(R.string.error_accessing_filesystem_select_aaps_directory_properly)) + return + } + exportToLocal(activity) + } + + private fun exportToLocal(activity: FragmentActivity) { prefFileList.ensureExportDirExists() - val newFile = prefFileList.newPreferenceFile() ?: return + val newFile = prefFileList.newPreferenceFile() + + if (newFile == null) { + ToastUtils.errorToast(activity, rh.gs(R.string.exported_failed)) + return + } + // Use the same password prompt as cloud export (with file name display) askToConfirmExport(activity, newFile) { password -> // Save preferences val exportResultMessage = if (savePreferences(newFile, password)) @@ -324,16 +398,225 @@ class ImportExportPrefsImpl @Inject constructor( ).subscribe() } } + + private fun exportToCloud(activity: FragmentActivity) { + activity.lifecycleScope.launch { + try { + val provider = cloudStorageManager.getActiveProvider() + if (provider == null) { + aapsLogger.error(LTag.CORE, "${CloudConstants.LOG_PREFIX} EXPORT_NO_PROVIDER") + ToastUtils.errorToast(activity, rh.gs(R.string.cloud_connection_failed)) + return@launch + } + + aapsLogger.info(LTag.CORE, "${CloudConstants.LOG_PREFIX} EXPORT_START testConnection") + if (!provider.testConnection()) { + aapsLogger.error(LTag.CORE, "${CloudConstants.LOG_PREFIX} EXPORT_CONN_FAIL") + ToastUtils.errorToast(activity, rh.gs(R.string.cloud_connection_failed)) + return@launch + } - override fun exportSharedPreferencesNonInteractive(context: Context, password: String): Boolean { - prefFileList.ensureExportDirExists() - val newFile = prefFileList.newPreferenceFile() ?: return false + // Create temp file using AAPS/temp SAF directory + val tempDir = prefFileList.ensureTempDirExists() + if (tempDir == null) { + aapsLogger.error(LTag.CORE, "${CloudConstants.LOG_PREFIX} EXPORT_NO_TEMP_DIR") + ToastUtils.errorToast(activity, rh.gs(R.string.exported_failed)) + return@launch + } + + // Same filename format as local: yyyy-MM-dd_HHmmss_FLAVOUR.json + val timeLocal = org.joda.time.LocalDateTime.now().toString(org.joda.time.format.DateTimeFormat.forPattern("yyyy-MM-dd'_'HHmmss")) + val exportFileName = "${timeLocal}_${config.FLAVOR}.json" + aapsLogger.info(LTag.CORE, "${CloudConstants.LOG_PREFIX} EXPORT_FILENAME name='${exportFileName}'") + val tempDoc = tempDir.createFile("application/json", exportFileName) + if (tempDoc == null) { + aapsLogger.error(LTag.CORE, "${CloudConstants.LOG_PREFIX} EXPORT_CREATE_TEMP_FAIL name='${exportFileName}'") + ToastUtils.errorToast(activity, rh.gs(R.string.exported_failed)) + return@launch + } + + // Use the same password prompt as local export (with file name display) + askToConfirmExport(activity, tempDoc) { password -> + activity.lifecycleScope.launch { + try { + aapsLogger.info(LTag.CORE, "${CloudConstants.LOG_PREFIX} EXPORT_PASSWORD_OK savingPrefs") + val saved = savePreferences(tempDoc, password) + if (!saved) { + aapsLogger.error(LTag.CORE, "${CloudConstants.LOG_PREFIX} EXPORT_SAVE_PREFS_FAIL") + ToastUtils.errorToast(activity, rh.gs(R.string.exported_failed)) + tempDoc.delete() + return@launch + } + + val bytes = activity.contentResolver.openInputStream(tempDoc.uri)?.use { it.readBytes() } + if (bytes == null) { + aapsLogger.error(LTag.CORE, "${CloudConstants.LOG_PREFIX} EXPORT_READ_TEMP_FAIL") + ToastUtils.errorToast(activity, rh.gs(R.string.exported_failed)) + tempDoc.delete() + return@launch + } + + // Re-get provider in case it changed + val activeProvider = cloudStorageManager.getActiveProvider() + if (activeProvider == null) { + aapsLogger.error(LTag.CORE, "${CloudConstants.LOG_PREFIX} EXPORT_PROVIDER_GONE") + ToastUtils.errorToast(activity, rh.gs(R.string.exported_failed)) + tempDoc.delete() + return@launch + } + + // First ensure selected folder points to fixed path for compatibility + aapsLogger.info(LTag.CORE, "${CloudConstants.LOG_PREFIX} EXPORT_RESOLVE_FOLDER path='${CloudConstants.CLOUD_PATH_SETTINGS}'") + activeProvider.getOrCreateFolderPath(CloudConstants.CLOUD_PATH_SETTINGS)?.let { + aapsLogger.info(LTag.CORE, "${CloudConstants.LOG_PREFIX} EXPORT_RESOLVE_FOLDER_OK id='${it}'") + activeProvider.setSelectedFolderId(it) + } - // Registering export settings event already done by automation - return savePreferences(newFile, password) + ToastUtils.longInfoToast(context, rh.gs(R.string.uploading_to_cloud)) + + var uploadedFileId = activeProvider.uploadFileToPath( + exportFileName, + bytes, + "application/json", + CloudConstants.CLOUD_PATH_SETTINGS + ) + aapsLogger.info(LTag.CORE, "${CloudConstants.LOG_PREFIX} EXPORT_PRIMARY_DONE id='${uploadedFileId}'") + // Fallback: if path upload fails, use legacy API to selected folder + if (uploadedFileId == null) { + aapsLogger.info(LTag.CORE, "${CloudConstants.LOG_PREFIX} EXPORT_FALLBACK_START") + uploadedFileId = activeProvider.uploadFile(exportFileName, bytes, "application/json") + aapsLogger.info(LTag.CORE, "${CloudConstants.LOG_PREFIX} EXPORT_FALLBACK_DONE id='${uploadedFileId}'") + } + + val exportResultMessage = if (uploadedFileId != null) { + rh.gs(R.string.exported_to_cloud) + "\n" + rh.gs(R.string.cloud_directory_path, CloudConstants.CLOUD_PATH_SETTINGS) + } else { + rh.gs(R.string.export_to_cloud_failed) + } + aapsLogger.info(LTag.CORE, "${CloudConstants.LOG_PREFIX} EXPORT_RESULT success=${uploadedFileId != null} id='${uploadedFileId}'") + + ToastUtils.infoToast(activity, exportResultMessage) + + // Record Therapy event + disposable += persistenceLayer.insertPumpTherapyEventIfNewByTimestamp( + therapyEvent = TE.asSettingsExport(error = exportResultMessage), + timestamp = dateUtil.now(), + action = Action.EXPORT_SETTINGS, + source = Sources.Automation, + note = "Manual: $exportResultMessage", + listValues = listOf() + ).subscribe() + + tempDoc.delete() + aapsLogger.info(LTag.CORE, "${CloudConstants.LOG_PREFIX} EXPORT_TEMP_DELETED name='${exportFileName}'") + } catch (e: Exception) { + aapsLogger.error(LTag.CORE, "${CloudConstants.LOG_PREFIX} EXPORT_EXCEPTION", e) + ToastUtils.errorToast(activity, rh.gs(R.string.export_to_cloud_failed)) + tempDoc.delete() + } + } + } + } catch (e: Exception) { + aapsLogger.error(LTag.CORE, "${CloudConstants.LOG_PREFIX} EXPORT_OUTER_EXCEPTION", e) + ToastUtils.errorToast(activity, rh.gs(R.string.export_to_cloud_failed)) + } + } + } + + override fun exportSharedPreferencesNonInteractive(context: Context, password: String): Boolean { + // Check if user selected cloud export for settings in export options dialog + val useCloudExport = exportOptionsDialog.isSettingsCloudEnabled() && + cloudStorageManager.isCloudStorageActive() + + if (useCloudExport) { + // Export to cloud only + kotlinx.coroutines.GlobalScope.launch(Dispatchers.IO) { + try { + val provider = cloudStorageManager.getActiveProvider() + if (provider == null) { + aapsLogger.error(LTag.CORE, "${CloudConstants.LOG_PREFIX} NONINTERACTIVE_EXPORT_NO_PROVIDER") + return@launch + } + + if (!provider.testConnection()) { + aapsLogger.error(LTag.CORE, "${CloudConstants.LOG_PREFIX} NONINTERACTIVE_EXPORT_CONN_FAIL") + return@launch + } + + // Create temp file for cloud export + val tempDir = prefFileList.ensureTempDirExists() + if (tempDir == null) { + aapsLogger.error(LTag.CORE, "${CloudConstants.LOG_PREFIX} NONINTERACTIVE_EXPORT_NO_TEMP_DIR") + return@launch + } + + val timeLocal = org.joda.time.LocalDateTime.now().toString(org.joda.time.format.DateTimeFormat.forPattern("yyyy-MM-dd'_'HHmmss")) + val fileName = "${timeLocal}_${config.FLAVOR}.json" + val tempDoc = tempDir.createFile("application/json", fileName) + if (tempDoc == null) { + aapsLogger.error(LTag.CORE, "${CloudConstants.LOG_PREFIX} NONINTERACTIVE_EXPORT_CREATE_TEMP_FAIL") + return@launch + } + + // Save preferences to temp file + val saved = savePreferences(tempDoc, password) + if (!saved) { + aapsLogger.error(LTag.CORE, "${CloudConstants.LOG_PREFIX} NONINTERACTIVE_EXPORT_SAVE_TEMP_FAIL") + tempDoc.delete() + return@launch + } + + val fileContent = tempDoc.uri.let { uri -> + context.contentResolver.openInputStream(uri)?.use { it.readBytes() } + } + + // Delete temp file after reading + tempDoc.delete() + + if (fileContent != null) { + val uploadedFileId = provider.uploadFile( + fileName = fileName, + content = fileContent, + mimeType = "application/json" + ) + if (uploadedFileId != null) { + aapsLogger.info(LTag.CORE, "${CloudConstants.LOG_PREFIX} NONINTERACTIVE_EXPORT_CLOUD_OK fileName=$fileName fileId=$uploadedFileId") + } else { + aapsLogger.error(LTag.CORE, "${CloudConstants.LOG_PREFIX} NONINTERACTIVE_EXPORT_CLOUD_FAIL") + } + } else { + aapsLogger.error(LTag.CORE, "${CloudConstants.LOG_PREFIX} NONINTERACTIVE_EXPORT_READ_FILE_FAIL") + } + } catch (e: Exception) { + aapsLogger.error(LTag.CORE, "${CloudConstants.LOG_PREFIX} NONINTERACTIVE_EXPORT_EXCEPTION", e) + } + } + return true // Cloud export started (async) + } else { + // Export to local + prefFileList.ensureExportDirExists() + val newFile = prefFileList.newPreferenceFile() ?: return false + return savePreferences(newFile, password) + } } override fun importSharedPreferences(activity: FragmentActivity) { + // Check import source preference for user settings + // If settings cloud is enabled (either via master switch or individual setting) AND cloud is configured, use cloud + val useCloudImport = (exportOptionsDialog.isAllCloudEnabled() || exportOptionsDialog.isSettingsCloudEnabled()) && + cloudStorageManager.isCloudStorageActive() + + if (useCloudImport) { + importFromCloud(activity) + return + } + + // Local import requires AAPS base directory + val directoryUri = preferences.getIfExists(StringKey.AapsDirectoryUri) + if (directoryUri.isNullOrEmpty()) { + ToastUtils.errorToast(activity, rh.gs(R.string.error_accessing_filesystem_select_aaps_directory_properly)) + return + } try { if (activity is DaggerAppCompatActivityWithResult) @@ -346,6 +629,112 @@ class ImportExportPrefsImpl @Inject constructor( } } + private fun importFromCloud(activity: FragmentActivity) { + // Show loading indicator + val progressDialog = AlertDialog.Builder(activity) + .setTitle(rh.gs(R.string.import_from_cloud)) + .setMessage(rh.gs(R.string.loading_from_cloud)) + .setCancelable(false) + .create() + progressDialog.show() + + activity.lifecycleScope.launch { + try { + val provider = cloudStorageManager.getActiveProvider() + if (provider == null) { + progressDialog.dismiss() + ToastUtils.errorToast(activity, rh.gs(R.string.cloud_connection_failed)) + return@launch + } + + if (!provider.testConnection()) { + progressDialog.dismiss() + ToastUtils.errorToast(activity, rh.gs(R.string.cloud_connection_failed)) + return@launch + } + // Auto-locate to fixed settings folder as list source, fall back to selected/root if failed + val settingsFolderId = provider.getOrCreateFolderPath(CloudConstants.CLOUD_PATH_SETTINGS) + aapsLogger.info(LTag.CORE, "${CloudConstants.LOG_PREFIX} IMPORT_CLOUD getOrCreateFolderPath(${CloudConstants.CLOUD_PATH_SETTINGS}) returned: $settingsFolderId") + if (!settingsFolderId.isNullOrEmpty()) { + provider.setSelectedFolderId(settingsFolderId) + aapsLogger.info(LTag.CORE, "${CloudConstants.LOG_PREFIX} IMPORT_CLOUD setSelectedFolderId: $settingsFolderId") + } else { + aapsLogger.warn(LTag.CORE, "${CloudConstants.LOG_PREFIX} IMPORT_CLOUD getOrCreateFolderPath returned null/empty, will use default folder") + } + + ToastUtils.infoToast(activity, rh.gs(R.string.cloud_directory_path, CloudConstants.CLOUD_PATH_SETTINGS)) + + // Count total files first using the provider interface + cloudTotalFilesCount = provider.countSettingsFiles() + + val page = provider.listSettingsFiles(pageSize = CloudConstants.DEFAULT_PAGE_SIZE, pageToken = null) + val files = page.files + cloudNextPageToken = page.nextPageToken + if (files.isEmpty()) { + progressDialog.dismiss() + ToastUtils.warnToast(activity, rh.gs(R.string.no_settings_files_found)) + return@launch + } + + // Filter files matching yyyy-MM-dd_HHmmss*.json pattern + val namePattern = Regex("^\\d{4}-\\d{2}-\\d{2}_\\d{6}.*\\.json$", RegexOption.IGNORE_CASE) + val matchingFiles = files.filter { f -> namePattern.containsMatchIn(f.name) } + + // Download all files and parse metadata, just like local files + val prefsFiles = mutableListOf() + var processedFiles = 0 + for (file in matchingFiles) { + try { + // Update progress + progressDialog.setMessage(rh.gs(R.string.loading_file_progress, file.name, processedFiles + 1, matchingFiles.size)) + + val bytes = provider.downloadFile(file.id) + if (bytes != null) { + val content = String(bytes, Charsets.UTF_8) + // Parse file metadata + val metadata = encryptedPrefsFormat.loadMetadata(content) + val prefsFile = PrefsFile(file.name, content, metadata) + prefsFiles.add(prefsFile) + } + } catch (e: Exception) { + aapsLogger.warn(LTag.CORE, "Failed to load metadata for ${file.name}", e) + // If metadata parsing fails, still add the file but without metadata + try { + val bytes = provider.downloadFile(file.id) + if (bytes != null) { + val content = String(bytes, Charsets.UTF_8) + val prefsFile = PrefsFile(file.name, content, emptyMap()) + prefsFiles.add(prefsFile) + } + } catch (e2: Exception) { + aapsLogger.error(LTag.CORE, "Failed to download ${file.name}", e2) + } + } + processedFiles++ + } + + progressDialog.dismiss() + + if (prefsFiles.isEmpty()) { + ToastUtils.warnToast(activity, rh.gs(R.string.no_settings_files_found)) + return@launch + } + + // Use CloudPrefImportListActivity to display file details + cloudPrefsFiles = prefsFiles // Store file list temporarily + val intent = Intent(activity, CloudPrefImportListActivity::class.java) + if (activity is DaggerAppCompatActivityWithResult) { + activity.startActivityForResult(intent, CloudConstants.CLOUD_IMPORT_REQUEST_CODE) + } + + } catch (e: Exception) { + progressDialog.dismiss() + aapsLogger.error(LTag.CORE, "Cloud import init failed", e) + ToastUtils.errorToast(activity, rh.gs(R.string.cloud_folder_error)) + } + } + } + override fun importCustomWatchface(fragment: Fragment) { fragment.activity?.let { importCustomWatchface(it) } } @@ -403,6 +792,10 @@ class ImportExportPrefsImpl @Inject constructor( sp.putString(key, value) } } + + // All settings including Google Drive settings and export destination preferences + // are now imported from backup file. If tokens are invalid, user can re-authorize. + activePlugin.afterImport() restartAppAfterImport(activity) } else { @@ -445,11 +838,13 @@ class ImportExportPrefsImpl @Inject constructor( } override fun exportUserEntriesCsv(activity: FragmentActivity) { + aapsLogger.info(LTag.CORE, "${CloudConstants.LOG_PREFIX} CSV_EXPORT exportUserEntriesCsv called, enqueuing WorkManager") WorkManager.getInstance(activity).enqueueUniqueWork( "export", ExistingWorkPolicy.APPEND_OR_REPLACE, OneTimeWorkRequest.Builder(CsvExportWorker::class.java).build() ) + aapsLogger.info(LTag.CORE, "${CloudConstants.LOG_PREFIX} CSV_EXPORT WorkManager enqueued") } class CsvExportWorker( @@ -462,14 +857,41 @@ class ImportExportPrefsImpl @Inject constructor( @Inject lateinit var userEntryPresentationHelper: UserEntryPresentationHelper @Inject lateinit var storage: Storage @Inject lateinit var persistenceLayer: PersistenceLayer + @Inject lateinit var cloudStorageManager: CloudStorageManager + @Inject lateinit var exportOptionsDialog: ExportOptionsDialog override suspend fun doWorkAndLog(): Result { - val entries = persistenceLayer.getUserEntryFilteredDataFromTime(MidnightTime.calc() - T.days(90).msecs()).blockingGet() + aapsLogger.info(LTag.CORE, "${CloudConstants.LOG_PREFIX} CSV_EXPORT doWorkAndLog started") + + val entries: List = try { + persistenceLayer.getUserEntryFilteredDataFromTime(MidnightTime.calc() - T.days(90).msecs()).blockingGet() + } catch (e: Exception) { + aapsLogger.error(LTag.CORE, "CSV_EXPORT Error reading user entries from database", e) + ToastUtils.longErrorToast(context, rh.gs(R.string.csv_upload_error) + ": Database read error") + return Result.failure(workDataOf("Error" to "Database read error: ${e.message}")) + } + + aapsLogger.info(LTag.CORE, "${CloudConstants.LOG_PREFIX} CSV_EXPORT entries count=${entries.size}") + + val isCsvCloudEnabled = exportOptionsDialog.isCsvCloudEnabled() + val isCloudActive = cloudStorageManager.isCloudStorageActive() + aapsLogger.info(LTag.CORE, "${CloudConstants.LOG_PREFIX} CSV_EXPORT isCsvCloudEnabled=$isCsvCloudEnabled, isCloudActive=$isCloudActive") + + if (isCsvCloudEnabled && isCloudActive) { + aapsLogger.info(LTag.CORE, "${CloudConstants.LOG_PREFIX} CSV_EXPORT calling exportToCloud") + return exportToCloud(entries) + } else { + aapsLogger.info(LTag.CORE, "${CloudConstants.LOG_PREFIX} CSV_EXPORT calling exportToLocal") + return exportToLocal(entries) + } + } + + private suspend fun exportToLocal(userEntries: List): Result { prefFileList.ensureExportDirExists() val newFile = prefFileList.newExportCsvFile() ?: return Result.failure() var ret = Result.success() try { - saveCsv(newFile, entries) + saveCsv(newFile, userEntries) ToastUtils.okToast(context, rh.gs(R.string.ue_exported)) } catch (e: FileNotFoundException) { ToastUtils.errorToast(context, rh.gs(R.string.filenotfound) + " " + newFile) @@ -482,6 +904,58 @@ class ImportExportPrefsImpl @Inject constructor( } return ret } + + private suspend fun exportToCloud(userEntries: List): Result { + aapsLogger.info(LTag.CORE, "${CloudConstants.LOG_PREFIX} CSV_EXPORT_CLOUD started with ${userEntries.size} entries") + try { + val provider = cloudStorageManager.getActiveProvider() + if (provider == null) { + aapsLogger.error(LTag.CORE, "${CloudConstants.LOG_PREFIX} CSV_EXPORT_CLOUD no active provider") + ToastUtils.longErrorToast(context, rh.gs(R.string.csv_upload_failed)) + return Result.failure(workDataOf("Error" to "No active cloud provider")) + } + + val contents = userEntryPresentationHelper.userEntriesToCsv(userEntries) + val fileName = "UserEntries_${org.joda.time.LocalDateTime.now().toString(org.joda.time.format.DateTimeFormat.forPattern("yyyy-MM-dd_HHmmss"))}.csv" + aapsLogger.info(LTag.CORE, "${CloudConstants.LOG_PREFIX} CSV_EXPORT_CLOUD fileName=$fileName, contents length=${contents.length}") + + // First locate selected folder to fixed path + val folderId = provider.getOrCreateFolderPath(CloudConstants.CLOUD_PATH_USER_ENTRIES) + aapsLogger.info(LTag.CORE, "${CloudConstants.LOG_PREFIX} CSV_EXPORT_CLOUD folderId=$folderId") + folderId?.let { provider.setSelectedFolderId(it) } + + ToastUtils.longInfoToast(context, rh.gs(R.string.uploading_to_cloud)) + aapsLogger.info(LTag.CORE, "${CloudConstants.LOG_PREFIX} CSV_EXPORT_CLOUD uploading...") + + var uploadedFileId = provider.uploadFileToPath( + fileName, + contents.toByteArray(Charsets.UTF_8), + "text/csv", + CloudConstants.CLOUD_PATH_USER_ENTRIES + ) + aapsLogger.info(LTag.CORE, "${CloudConstants.LOG_PREFIX} CSV_EXPORT_CLOUD uploadFileToPath result=$uploadedFileId") + + if (uploadedFileId == null) { + aapsLogger.info(LTag.CORE, "${CloudConstants.LOG_PREFIX} CSV_EXPORT_CLOUD trying fallback uploadFile") + uploadedFileId = provider.uploadFile(fileName, contents.toByteArray(Charsets.UTF_8), "text/csv") + aapsLogger.info(LTag.CORE, "${CloudConstants.LOG_PREFIX} CSV_EXPORT_CLOUD fallback uploadFile result=$uploadedFileId") + } + + if (uploadedFileId != null) { + aapsLogger.info(LTag.CORE, "${CloudConstants.LOG_PREFIX} CSV_EXPORT_CLOUD SUCCESS") + ToastUtils.okToast(context, rh.gs(R.string.csv_uploaded_to_cloud) + "\n" + rh.gs(R.string.cloud_directory_path, CloudConstants.CLOUD_PATH_USER_ENTRIES), isShort = false) + return Result.success() + } else { + aapsLogger.error(LTag.CORE, "${CloudConstants.LOG_PREFIX} CSV_EXPORT_CLOUD FAILED - uploadedFileId is null") + ToastUtils.longErrorToast(context, rh.gs(R.string.csv_upload_failed)) + return Result.failure(workDataOf("Error" to "Cloud upload failed")) + } + } catch (e: Exception) { + aapsLogger.error(LTag.CORE, "${CloudConstants.LOG_PREFIX} CSV_EXPORT_CLOUD EXCEPTION", e) + ToastUtils.longErrorToast(context, rh.gs(R.string.csv_upload_error)) + return Result.failure(workDataOf("Error" to "Exception: ${e.message}")) + } + } private fun saveCsv(file: DocumentFile, userEntries: List) { try { diff --git a/plugins/configuration/src/main/kotlin/app/aaps/plugins/configuration/maintenance/MaintenanceFragment.kt b/plugins/configuration/src/main/kotlin/app/aaps/plugins/configuration/maintenance/MaintenanceFragment.kt index 6bed0a4669e..544b57aaee0 100644 --- a/plugins/configuration/src/main/kotlin/app/aaps/plugins/configuration/maintenance/MaintenanceFragment.kt +++ b/plugins/configuration/src/main/kotlin/app/aaps/plugins/configuration/maintenance/MaintenanceFragment.kt @@ -38,6 +38,10 @@ import app.aaps.plugins.configuration.R import app.aaps.plugins.configuration.activities.DaggerAppCompatActivityWithResult import app.aaps.plugins.configuration.databinding.MaintenanceFragmentBinding import app.aaps.plugins.configuration.maintenance.activities.LogSettingActivity +import app.aaps.plugins.configuration.maintenance.cloud.CloudStorageManager +import app.aaps.plugins.configuration.maintenance.cloud.CloudDirectoryDialog +import app.aaps.plugins.configuration.maintenance.cloud.ExportOptionsDialog +import app.aaps.plugins.configuration.maintenance.cloud.events.EventCloudStorageStatusChanged import dagger.android.support.DaggerFragment import io.reactivex.rxjava3.core.Completable import io.reactivex.rxjava3.disposables.CompositeDisposable @@ -64,6 +68,9 @@ class MaintenanceFragment : DaggerFragment() { @Inject lateinit var uiInteraction: UiInteraction @Inject lateinit var activePlugin: ActivePlugin @Inject lateinit var fileListProvider: FileListProvider + @Inject lateinit var cloudStorageManager: CloudStorageManager + @Inject lateinit var cloudDirectoryDialog: CloudDirectoryDialog + @Inject lateinit var exportOptionsDialog: ExportOptionsDialog private val disposable = CompositeDisposable() private var inMenu = false @@ -172,13 +179,54 @@ class MaintenanceFragment : DaggerFragment() { importExportPrefs.importSharedPreferences(activity as FragmentActivity) } } + // Local directory: only used for selecting AAPS base folder binding.directory.setOnClickListener { - maintenancePlugin.selectAapsDirectory(requireActivity() as DaggerAppCompatActivityWithResult) + (requireActivity() as? DaggerAppCompatActivityWithResult)?.let { act -> + maintenancePlugin.selectAapsDirectory(act) + } + } + // Cloud directory: choose not to use or Google Drive + binding.cloudDirectory.setOnClickListener { + (requireActivity() as? DaggerAppCompatActivityWithResult)?.let { act -> + cloudDirectoryDialog.showCloudDirectoryDialog( + act, + onLocalSelected = { /* Choose not to use cloud: set storage type to local, no action */ }, + onCloudSelected = { /* Authorization and folder selection handled in dialog */ }, + onStorageChanged = { + updateStorageErrorState() + updateDynamicButtonText() + updateExportOptionsButtonState() + } + ) + } + } + + // Cloud directory error icon click - show toast with error info + binding.cloudDirectoryErrorIcon.setOnClickListener { + app.aaps.core.ui.toast.ToastUtils.warnToast(requireContext(), rh.gs(R.string.cloud_token_expired_or_invalid)) + } + + // Export destination: configure destination for various export functions + binding.exportOptions.setOnClickListener { + val hasCloudDirectory = cloudStorageManager.isCloudStorageActive() + if (!hasCloudDirectory) { + // Show message if cloud directory is not set up + app.aaps.core.ui.toast.ToastUtils.warnToast(requireContext(), rh.gs(R.string.setup_cloud_directory_first)) + return@setOnClickListener + } + (requireActivity() as? DaggerAppCompatActivityWithResult)?.let { act -> + exportOptionsDialog.showExportOptionsDialog(act) { + // Settings changed callback - update button text + updateDynamicButtonText() + } + } } binding.navLogsettings.setOnClickListener { startActivity(Intent(activity, LogSettingActivity::class.java)) } binding.exportCsv.setOnClickListener { + aapsLogger.info(LTag.CORE, "CSV_EXPORT exportCsv button clicked") activity?.let { activity -> OKDialog.showConfirmation(activity, rh.gs(app.aaps.core.ui.R.string.ue_export_to_csv) + "?") { + aapsLogger.info(LTag.CORE, "CSV_EXPORT user confirmed, calling exportUserEntriesCsv") uel.log(Action.EXPORT_CSV, Sources.Maintenance) importExportPrefs.exportUserEntriesCsv(activity) } @@ -190,7 +238,33 @@ class MaintenanceFragment : DaggerFragment() { override fun onResume() { super.onResume() - if (inMenu) queryProtection() else updateProtectedUi() + // Check and restore cloud settings (prevent settings loss after app update) + checkAndRestoreCloudSettings() + + // Subscribe to cloud storage status changes to update UI immediately + disposable += rxBus + .toObservable(EventCloudStorageStatusChanged::class.java) + .observeOn(aapsSchedulers.main) + .subscribe({ updateStorageErrorState() }, fabricPrivacy::logException) + + if (inMenu) queryProtection() else { + updateProtectedUi() + updateStorageErrorState() + updateDynamicButtonText() + updateExportOptionsButtonState() + } + } + + /** + * Check and restore cloud storage settings + */ + private fun checkAndRestoreCloudSettings() { + try { + // Trigger auto-restore logic + cloudStorageManager.getActiveStorageType() + } catch (e: Exception) { + aapsLogger.warn(LTag.CORE, "Failed to check cloud storage settings", e) + } } @Synchronized @@ -204,6 +278,66 @@ class MaintenanceFragment : DaggerFragment() { val isLocked = protectionCheck.isLocked(PREFERENCES) binding.mainLayout.visibility = isLocked.not().toVisibility() binding.unlock.visibility = isLocked.toVisibility() + + // Update storage error state when UI becomes available + if (!isLocked) { + updateStorageErrorState() + updateDynamicButtonText() + } + } + + private fun updateStorageErrorState() { + // Local directory - no error icon needed (local storage doesn't have connection errors) + binding.directoryErrorIcon.visibility = View.GONE + + // Cloud directory error - show when cloud is active but token is invalid/expired + val isCloudActive = cloudStorageManager.isCloudStorageActive() + val provider = cloudStorageManager.getActiveProvider() + val hasValidCredentials = provider?.hasValidCredentials() ?: false + val hasCloudConnectionError = provider?.hasConnectionError() ?: false + + // Show error icon if cloud is active but credentials are invalid or there's a connection error + val showCloudError = isCloudActive && (!hasValidCredentials || hasCloudConnectionError) + binding.cloudDirectoryErrorIcon.visibility = if (showCloudError) View.VISIBLE else View.GONE + } + + /** + * Update export options button state based on cloud directory selection + */ + private fun updateExportOptionsButtonState() { + val hasCloudDirectory = cloudStorageManager.isCloudStorageActive() + binding.exportOptions.alpha = if (hasCloudDirectory) 1.0f else 0.5f + } + + /** + * Update dynamic button text based on export destination settings + */ + private fun updateDynamicButtonText() { + // Check if log should be sent to cloud + val isLogCloudEnabled = exportOptionsDialog.isAllCloudEnabled() || exportOptionsDialog.isLogCloudEnabled() + if (isLogCloudEnabled) { + binding.logSend.text = rh.gs(R.string.send_logs_to_cloud) + } else { + binding.logSend.text = rh.gs(R.string.send_all_logs) + } + + // Check if CSV should be exported to cloud or local + val isCsvCloudEnabled = exportOptionsDialog.isAllCloudEnabled() || exportOptionsDialog.isCsvCloudEnabled() + if (isCsvCloudEnabled) { + binding.exportCsv.text = rh.gs(R.string.export_csv_to_cloud) + } else { + binding.exportCsv.text = rh.gs(R.string.export_csv_to_local) + } + + // Check if settings should be exported/imported to cloud or local + val isSettingsCloudEnabled = exportOptionsDialog.isAllCloudEnabled() || exportOptionsDialog.isSettingsCloudEnabled() + if (isSettingsCloudEnabled) { + binding.navExport.text = rh.gs(R.string.export_settings_cloud) + binding.navImport.text = rh.gs(R.string.import_settings_cloud) + } else { + binding.navExport.text = rh.gs(R.string.export_settings_local) + binding.navImport.text = rh.gs(R.string.import_settings_local) + } } private fun queryProtection() { diff --git a/plugins/configuration/src/main/kotlin/app/aaps/plugins/configuration/maintenance/MaintenancePlugin.kt b/plugins/configuration/src/main/kotlin/app/aaps/plugins/configuration/maintenance/MaintenancePlugin.kt index c8398cbc848..19f23c003ea 100644 --- a/plugins/configuration/src/main/kotlin/app/aaps/plugins/configuration/maintenance/MaintenancePlugin.kt +++ b/plugins/configuration/src/main/kotlin/app/aaps/plugins/configuration/maintenance/MaintenancePlugin.kt @@ -31,6 +31,13 @@ import app.aaps.core.validators.preferences.AdaptiveStringPreference import app.aaps.core.validators.preferences.AdaptiveSwitchPreference import app.aaps.plugins.configuration.R import app.aaps.plugins.configuration.activities.DaggerAppCompatActivityWithResult +import app.aaps.plugins.configuration.maintenance.cloud.CloudConstants +import app.aaps.plugins.configuration.maintenance.cloud.CloudStorageManager +import app.aaps.plugins.configuration.maintenance.cloud.StorageTypes +import app.aaps.plugins.configuration.maintenance.cloud.ExportOptionsDialog +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch import java.io.BufferedInputStream import java.io.BufferedOutputStream import java.io.File @@ -53,7 +60,9 @@ class MaintenancePlugin @Inject constructor( private val config: Config, private val fileListProvider: FileListProvider, private val loggerUtils: LoggerUtils, - private val uel: UserEntryLogger + private val uel: UserEntryLogger, + private val cloudStorageManager: CloudStorageManager, + private val exportOptionsDialog: ExportOptionsDialog ) : PluginBase( PluginDescription() .mainType(PluginType.GENERAL) @@ -69,16 +78,25 @@ class MaintenancePlugin @Inject constructor( ) { fun sendLogs() { - val recipient = preferences.get(StringKey.MaintenanceEmail) val amount = preferences.get(IntKey.MaintenanceLogsAmount) val logs = getLogFiles(amount) val zipFile = fileListProvider.ensureTempDirExists()?.createFile("application/zip", constructName()) ?: return aapsLogger.debug("zipFile: ${zipFile.name}") val zip = zipLogs(zipFile, logs) - val attachmentUri = zip.uri - val emailIntent: Intent = this.sendMail(attachmentUri, recipient, "Log Export") - aapsLogger.debug("sending emailIntent") - context.startActivity(emailIntent) + + // Check export destination preference (master switch or individual setting) + if ((exportOptionsDialog.isLogCloudEnabled()) && + cloudStorageManager.isCloudStorageActive()) { + // Send to Cloud Drive + sendLogsToCloudDrive(zip) + } else { + // Send via email (default behavior) + val recipient = preferences.get(StringKey.MaintenanceEmail) + val attachmentUri = zip.uri + val emailIntent: Intent = this.sendMail(attachmentUri, recipient, "Log Export") + aapsLogger.debug("sending emailIntent") + context.startActivity(emailIntent) + } } fun deleteLogs(keep: Int) { @@ -235,6 +253,79 @@ class MaintenancePlugin @Inject constructor( return emailIntent } + private fun sendLogsToCloudDrive(zipFile: DocumentFile) { + try { + aapsLogger.debug("Sending logs to cloud storage") + + // Read zip file contents + val inputStream = context.contentResolver.openInputStream(zipFile.uri) + val bytes = inputStream?.use { it.readBytes() } + + if (bytes != null) { + // Upload to cloud storage + CoroutineScope(Dispatchers.IO).launch { + try { + val provider = cloudStorageManager.getActiveProvider() + if (provider == null) { + aapsLogger.error("No active cloud provider") + fallbackToEmailLogs(zipFile) + return@launch + } + + // First set selected folder, then try path upload + provider.getOrCreateFolderPath(CloudConstants.CLOUD_PATH_LOGS)?.let { + provider.setSelectedFolderId(it) + } + + ToastUtils.longInfoToast(context, rh.gs(R.string.uploading_to_cloud)) + + var uploadedFileId = provider.uploadFileToPath( + zipFile.name ?: "logs.zip", + bytes, + "application/zip", + CloudConstants.CLOUD_PATH_LOGS + ) + if (uploadedFileId == null) { + uploadedFileId = provider.uploadFile(zipFile.name ?: "logs.zip", bytes, "application/zip") + } + + if (uploadedFileId != null) { + aapsLogger.debug("Logs successfully uploaded to cloud storage: $uploadedFileId") + ToastUtils.infoToast(context, rh.gs(R.string.logs_uploaded_to_cloud) + "\n" + rh.gs(R.string.cloud_directory_path, CloudConstants.CLOUD_PATH_LOGS)) + } else { + aapsLogger.error("Failed to upload logs to cloud storage") + ToastUtils.errorToast(context, rh.gs(R.string.logs_upload_failed)) + + // Fallback to email + fallbackToEmailLogs(zipFile) + } + } catch (e: Exception) { + aapsLogger.error("Error uploading logs to cloud storage", e) + ToastUtils.errorToast(context, rh.gs(R.string.logs_upload_error)) + + // Fallback to email + fallbackToEmailLogs(zipFile) + } + } + } else { + aapsLogger.error("Failed to read zip file contents") + fallbackToEmailLogs(zipFile) + } + } catch (e: Exception) { + aapsLogger.error("Error preparing logs for cloud upload", e) + fallbackToEmailLogs(zipFile) + } + } + + private fun fallbackToEmailLogs(zipFile: DocumentFile) { + aapsLogger.debug("Falling back to email for log sending") + val recipient = preferences.get(StringKey.MaintenanceEmail) + val attachmentUri = zipFile.uri + val emailIntent: Intent = this.sendMail(attachmentUri, recipient, "Log Export") + aapsLogger.debug("sending emailIntent") + context.startActivity(emailIntent) + } + fun selectAapsDirectory(activity: DaggerAppCompatActivityWithResult) { try { uel.log(Action.SELECT_DIRECTORY, Sources.Maintenance) diff --git a/plugins/configuration/src/main/kotlin/app/aaps/plugins/configuration/maintenance/activities/CloudPrefImportListActivity.kt b/plugins/configuration/src/main/kotlin/app/aaps/plugins/configuration/maintenance/activities/CloudPrefImportListActivity.kt new file mode 100644 index 00000000000..8053d936c09 --- /dev/null +++ b/plugins/configuration/src/main/kotlin/app/aaps/plugins/configuration/maintenance/activities/CloudPrefImportListActivity.kt @@ -0,0 +1,210 @@ +package app.aaps.plugins.configuration.maintenance.activities + +import android.content.Intent +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView +import app.aaps.core.interfaces.maintenance.FileListProvider +import app.aaps.core.interfaces.maintenance.ImportExportPrefs +import app.aaps.core.interfaces.maintenance.PrefsFile +import app.aaps.core.interfaces.resources.ResourceHelper +import app.aaps.core.ui.activities.TranslatedDaggerAppCompatActivity +import app.aaps.plugins.configuration.R +import app.aaps.plugins.configuration.databinding.MaintenanceImportListActivityBinding +import app.aaps.plugins.configuration.databinding.MaintenanceImportListItemBinding +import app.aaps.plugins.configuration.maintenance.PrefsMetadataKeyImpl +import app.aaps.plugins.configuration.maintenance.data.PrefsStatusImpl +import app.aaps.plugins.configuration.maintenance.ImportExportPrefsImpl +import app.aaps.plugins.configuration.maintenance.cloud.CloudConstants +import app.aaps.plugins.configuration.maintenance.cloud.CloudStorageManager +import app.aaps.plugins.configuration.maintenance.formats.EncryptedPrefsFormat +import javax.inject.Inject +import androidx.lifecycle.lifecycleScope +import kotlinx.coroutines.launch + +class CloudPrefImportListActivity : TranslatedDaggerAppCompatActivity() { + + @Inject lateinit var rh: ResourceHelper + @Inject lateinit var fileListProvider: FileListProvider + @Inject lateinit var importExportPrefs: ImportExportPrefs + @Inject lateinit var cloudStorageManager: CloudStorageManager + @Inject lateinit var encryptedPrefsFormat: EncryptedPrefsFormat + + private lateinit var binding: MaintenanceImportListActivityBinding + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + binding = MaintenanceImportListActivityBinding.inflate(layoutInflater) + val view = binding.root + setContentView(view) + + title = rh.gs(R.string.import_from_cloud) + supportActionBar?.setDisplayHomeAsUpEnabled(true) + supportActionBar?.setDisplayShowHomeEnabled(true) + supportActionBar?.setDisplayShowTitleEnabled(true) + + binding.recyclerview.layoutManager = LinearLayoutManager(this) + + // Use cloud file list + val cloudFiles = ImportExportPrefsImpl.cloudPrefsFiles.toMutableList() + val adapter = RecyclerViewAdapter(cloudFiles) + binding.recyclerview.adapter = adapter + + // Update file count display + updateFileCountDisplay(cloudFiles.size) + + // Show or hide "Load More" and update button text + updateLoadMoreButton(cloudFiles.size) + binding.loadMore.setOnClickListener { + // Click load more: fetch PAGE_SIZE more + binding.loadMore.isEnabled = false + binding.loadMore.text = rh.gs(R.string.loading) + lifecycleScope.launch { + val nextToken = ImportExportPrefsImpl.cloudNextPageToken + if (nextToken == null) { + binding.loadMore.visibility = View.GONE + return@launch + } + + // Get active cloud provider + val provider = cloudStorageManager.getActiveProvider() + if (provider == null) { + binding.loadMore.visibility = View.GONE + return@launch + } + + val currentLoadedCount = cloudFiles.size // Already loaded count + val page = provider.listSettingsFiles(CloudConstants.DEFAULT_PAGE_SIZE, nextToken) + ImportExportPrefsImpl.cloudNextPageToken = page.nextPageToken + // Download and parse each entry then append + val appended = mutableListOf() + val namePattern = Regex("^\\d{4}-\\d{2}-\\d{2}_\\d{6}.*\\.json$", RegexOption.IGNORE_CASE) + val filesToProcess = page.files.filter { namePattern.containsMatchIn(it.name) } + var processedCount = 0 + + for (f in filesToProcess) { + try { + // Update progress on button - add current loaded count + processedCount++ + val currentItemNumber = currentLoadedCount + processedCount + val totalItemsInThisBatch = currentLoadedCount + filesToProcess.size + runOnUiThread { + binding.loadMore.text = rh.gs(R.string.loading_progress, currentItemNumber, totalItemsInThisBatch) + } + + val bytes = provider.downloadFile(f.id) + if (bytes != null) { + val content = String(bytes, Charsets.UTF_8) + val metadata = encryptedPrefsFormat.loadMetadata(content) + appended.add(PrefsFile(f.name, content, metadata)) + } + } catch (_: Exception) { + // Ignore single entry error + } + } + val start = cloudFiles.size + cloudFiles.addAll(appended) + adapter.notifyItemRangeInserted(start, appended.size) + binding.loadMore.isEnabled = true + updateLoadMoreButton(cloudFiles.size) + updateFileCountDisplay(cloudFiles.size) + } + } + } + + private fun updateLoadMoreButton(currentCount: Int) { + if (ImportExportPrefsImpl.cloudNextPageToken == null) { + binding.loadMore.visibility = View.GONE + } else { + binding.loadMore.visibility = View.VISIBLE + // Calculate remaining files to load + val totalCount = ImportExportPrefsImpl.cloudTotalFilesCount + val remainingCount = if (totalCount > 0) { + minOf(CloudConstants.DEFAULT_PAGE_SIZE, totalCount - currentCount) + } else { + CloudConstants.DEFAULT_PAGE_SIZE + } + binding.loadMore.text = rh.gs(R.string.load_more_with_count, remainingCount, currentCount) + } + } + + private fun updateFileCountDisplay(currentCount: Int) { + val totalCount = ImportExportPrefsImpl.cloudTotalFilesCount + if (totalCount > 0) { + binding.fileCount.visibility = View.VISIBLE + if (currentCount >= totalCount || ImportExportPrefsImpl.cloudNextPageToken == null) { + // All files loaded + binding.fileCount.text = rh.gs(R.string.cloud_import_file_count_all, currentCount) + } else { + // Partial files loaded + binding.fileCount.text = rh.gs(R.string.cloud_import_file_count, currentCount, totalCount) + } + } else { + binding.fileCount.visibility = View.GONE + } + } + + inner class RecyclerViewAdapter internal constructor(private var prefFileList: MutableList) : RecyclerView.Adapter() { + + inner class PrefFileViewHolder(val maintenanceImportListItemBinding: MaintenanceImportListItemBinding) : RecyclerView.ViewHolder(maintenanceImportListItemBinding.root) { + + init { + with(maintenanceImportListItemBinding) { + root.isClickable = true + maintenanceImportListItemBinding.root.setOnClickListener { + val i = Intent() + // Set selected file + importExportPrefs.selectedImportFile = prefFileList[filelistName.tag as Int] + setResult(RESULT_OK, i) + finish() + } + } + } + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): PrefFileViewHolder { + val binding = MaintenanceImportListItemBinding.inflate(LayoutInflater.from(parent.context), parent, false) + return PrefFileViewHolder(binding) + } + + override fun getItemCount(): Int = prefFileList.size + + override fun onBindViewHolder(holder: PrefFileViewHolder, position: Int) { + val prefFile = prefFileList[position] + with(holder.maintenanceImportListItemBinding) { + filelistName.text = prefFile.name + filelistName.tag = position + + metalineName.visibility = View.VISIBLE + metaDateTimeIcon.visibility = View.VISIBLE + metaAppVersion.visibility = View.VISIBLE + + prefFile.metadata[PrefsMetadataKeyImpl.AAPS_FLAVOUR]?.let { + metaVariantFormat.text = it.value + val colorAttr = if (it.status == PrefsStatusImpl.OK) app.aaps.core.ui.R.attr.metadataTextOkColor else app.aaps.core.ui.R.attr.metadataTextWarningColor + metaVariantFormat.setTextColor(rh.gac(metaVariantFormat.context, colorAttr)) + } + + prefFile.metadata[PrefsMetadataKeyImpl.CREATED_AT]?.let { + metaDateTime.text = fileListProvider.formatExportedAgo(it.value) + } + + prefFile.metadata[PrefsMetadataKeyImpl.AAPS_VERSION]?.let { + metaAppVersion.text = it.value + val colorAttr = if (it.status == PrefsStatusImpl.OK) app.aaps.core.ui.R.attr.metadataTextOkColor else app.aaps.core.ui.R.attr.metadataTextWarningColor + metaAppVersion.setTextColor(rh.gac(metaVariantFormat.context, colorAttr)) + } + + prefFile.metadata[PrefsMetadataKeyImpl.DEVICE_NAME]?.let { + metaDeviceName.text = it.value + } + + } + } + + } + +} diff --git a/plugins/configuration/src/main/kotlin/app/aaps/plugins/configuration/maintenance/cloud/CloudConstants.kt b/plugins/configuration/src/main/kotlin/app/aaps/plugins/configuration/maintenance/cloud/CloudConstants.kt new file mode 100644 index 00000000000..c2d6ee5b841 --- /dev/null +++ b/plugins/configuration/src/main/kotlin/app/aaps/plugins/configuration/maintenance/cloud/CloudConstants.kt @@ -0,0 +1,65 @@ +package app.aaps.plugins.configuration.maintenance.cloud + +/** + * Centralized management of cloud-related constants. + * + * This object contains constants that are shared across all cloud storage providers. + */ +object CloudConstants { + // Log prefix for cloud-related logs + const val LOG_PREFIX = "[Cloud]" + + // Cloud storage paths - these are logical paths that providers should map to their structure + const val CLOUD_PATH_SETTINGS = "AAPS/export/preferences" + const val CLOUD_PATH_LOGS = "AAPS/export/logs" + const val CLOUD_PATH_USER_ENTRIES = "AAPS/export/user_entries" + + // Activity request codes + const val CLOUD_IMPORT_REQUEST_CODE = 1001 + + // SharedPreferences keys (provider-agnostic) + // Use the same key as GoogleDriveManager for backward compatibility + const val PREF_CLOUD_STORAGE_TYPE = "google_drive_storage_type" + + // Default page size for file listing + const val DEFAULT_PAGE_SIZE = 5 +} + +/** + * Storage type constants. + * Each cloud provider should have a unique type identifier. + */ +object StorageTypes { + const val LOCAL = "local" + const val GOOGLE_DRIVE = "google_drive" + + // Future cloud storage providers: + // const val DROPBOX = "dropbox" + // const val ONEDRIVE = "onedrive" + // const val AZURE_BLOB = "azure_blob" + // const val AWS_S3 = "aws_s3" + + /** + * Get all available storage types + */ + val ALL_TYPES = listOf(LOCAL, GOOGLE_DRIVE) + + /** + * Get all cloud storage types (excluding local) + */ + val CLOUD_TYPES = listOf(GOOGLE_DRIVE) + + /** + * Check if the given storage type is a cloud storage type + */ + fun isCloudStorage(storageType: String): Boolean { + return storageType in CLOUD_TYPES + } + + /** + * Check if the given storage type is valid + */ + fun isValidStorageType(storageType: String): Boolean { + return storageType in ALL_TYPES + } +} diff --git a/plugins/configuration/src/main/kotlin/app/aaps/plugins/configuration/maintenance/cloud/CloudDirectoryDialog.kt b/plugins/configuration/src/main/kotlin/app/aaps/plugins/configuration/maintenance/cloud/CloudDirectoryDialog.kt new file mode 100644 index 00000000000..3701609f51a --- /dev/null +++ b/plugins/configuration/src/main/kotlin/app/aaps/plugins/configuration/maintenance/cloud/CloudDirectoryDialog.kt @@ -0,0 +1,319 @@ +package app.aaps.plugins.configuration.maintenance.cloud + +import android.content.Intent +import android.widget.LinearLayout +import android.widget.RadioButton +import android.widget.TextView +import app.aaps.plugins.configuration.activities.DaggerAppCompatActivityWithResult +import androidx.lifecycle.lifecycleScope +import app.aaps.core.interfaces.logging.AAPSLogger +import app.aaps.core.interfaces.logging.LTag +import app.aaps.core.interfaces.resources.ResourceHelper +import app.aaps.core.ui.toast.ToastUtils +import app.aaps.plugins.configuration.R +import app.aaps.plugins.configuration.maintenance.MaintenancePlugin +import app.aaps.plugins.configuration.activities.SingleFragmentActivity +import app.aaps.core.interfaces.plugin.ActivePlugin +import kotlinx.coroutines.launch +import javax.inject.Inject +import javax.inject.Singleton +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import app.aaps.core.ui.dialogs.AlertDialogHelper +import androidx.browser.customtabs.CustomTabsIntent +import androidx.core.net.toUri + +/** + * Dialog for selecting cloud directory. + * + * This dialog is provider-agnostic and can be extended to support multiple cloud storage providers + * in the future (e.g., Dropbox, OneDrive, etc.) + */ +@Singleton +class CloudDirectoryDialog @Inject constructor( + private val aapsLogger: AAPSLogger, + private val rh: ResourceHelper, + private val cloudStorageManager: CloudStorageManager, + private val maintenancePlugin: MaintenancePlugin, + private val activePlugin: ActivePlugin, + private val exportOptionsDialog: ExportOptionsDialog +) { + + companion object { + private const val LOG_PREFIX = CloudConstants.LOG_PREFIX + } + + /** + * Show cloud directory dialog + */ + fun showCloudDirectoryDialog( + activity: DaggerAppCompatActivityWithResult, + onLocalSelected: () -> Unit = { maintenancePlugin.selectAapsDirectory(activity) }, + onCloudSelected: () -> Unit = {}, + onStorageChanged: () -> Unit = {} + ) { + val dialogView = activity.layoutInflater.inflate(R.layout.dialog_cloud_directory, null) + + // UI elements + val googleDriveRow = dialogView.findViewById(R.id.google_drive_row) + val googleDriveRadio = dialogView.findViewById(R.id.google_drive_radio) + val clearAction = dialogView.findViewById(R.id.clear_action) + + val currentType = cloudStorageManager.getActiveStorageType() + val isCloudSelected = currentType == StorageTypes.GOOGLE_DRIVE + + // Initial state: set radio button based on current settings + googleDriveRadio.isChecked = isCloudSelected + + // Use MaterialAlertDialogBuilder with DialogTheme to match project style + val dialog = MaterialAlertDialogBuilder(activity, app.aaps.core.ui.R.style.DialogTheme) + .setCustomTitle(AlertDialogHelper.buildCustomTitle( + activity, + rh.gs(R.string.select_storage_type) + )) + .setView(dialogView) + .setNegativeButton(rh.gs(app.aaps.core.ui.R.string.cancel), null) + .create() + + // Top-right clear: equivalent to the original local storage clear action + clearAction.setOnClickListener { + val wasCloud = cloudStorageManager.isCloudStorageActive() + val proceedClear: () -> Unit = { + cloudStorageManager.clearAllCredentials() + cloudStorageManager.setActiveStorageType(StorageTypes.LOCAL) + cloudStorageManager.clearConnectionError() + // Reset export destination settings to local/email mode + exportOptionsDialog.resetToLocalSettings() + onLocalSelected() + onStorageChanged() + ToastUtils.infoToast(activity, rh.gs(app.aaps.core.ui.R.string.success)) + // Reflect selection state on UI + googleDriveRadio.isChecked = false + dialog.dismiss() + } + if (wasCloud) { + MaterialAlertDialogBuilder(activity, app.aaps.core.ui.R.style.DialogTheme) + .setCustomTitle(AlertDialogHelper.buildCustomTitle(activity, rh.gs(R.string.clear_cloud_settings))) + .setMessage(rh.gs(R.string.clear_cloud_settings_message)) + .setPositiveButton(rh.gs(app.aaps.core.ui.R.string.yes)) { _, _ -> proceedClear() } + .setNegativeButton(rh.gs(app.aaps.core.ui.R.string.no), null) + .show() + } else { + proceedClear() + } + } + + // Google Drive row click + val handleGoogleDriveClick = { + googleDriveRadio.isChecked = true + dialog.dismiss() + handleCloudSelection(activity, StorageTypes.GOOGLE_DRIVE, onCloudSelected, onStorageChanged) + } + googleDriveRow.setOnClickListener { handleGoogleDriveClick() } + googleDriveRadio.setOnClickListener { handleGoogleDriveClick() } + + dialog.show() + } + + /** Handle cloud storage selection */ + private fun handleCloudSelection( + activity: DaggerAppCompatActivityWithResult, + storageType: String, + onSuccess: () -> Unit, + onStorageChanged: () -> Unit + ) { + activity.lifecycleScope.launch { + try { + val provider = cloudStorageManager.getProvider(storageType) + if (provider == null) { + aapsLogger.error(LTag.CORE, "$LOG_PREFIX No provider available for $storageType") + ToastUtils.errorToast(activity, "Cloud provider not available") + return@launch + } + + if (provider.hasValidCredentials()) { + if (provider.testConnection()) { + cloudStorageManager.setActiveStorageType(storageType) + // Automatically point to fixed settings directory + val folderId = provider.getOrCreateFolderPath(CloudConstants.CLOUD_PATH_SETTINGS) + if (!folderId.isNullOrEmpty()) provider.setSelectedFolderId(folderId) + onSuccess() + onStorageChanged() + } else { + showReauthorizeDialog(activity, storageType, onSuccess, onStorageChanged) + } + } else { + startAuthFlow(activity, storageType, onSuccess, onStorageChanged) + } + } catch (e: Exception) { + aapsLogger.error(LTag.CORE, "$LOG_PREFIX Error handling cloud selection", e) + ToastUtils.errorToast(activity, rh.gs(R.string.cloud_auth_error, e.message ?: "")) + } + } + } + + /** + * Show dialog asking if user wants to enable cloud export after successful connection + */ + private fun showEnableCloudExportDialog( + activity: DaggerAppCompatActivityWithResult, + onStorageChanged: () -> Unit + ) { + activity.runOnUiThread { + MaterialAlertDialogBuilder(activity, app.aaps.core.ui.R.style.DialogTheme) + .setCustomTitle(AlertDialogHelper.buildCustomTitle(activity, rh.gs(R.string.enable_cloud_export_title))) + .setMessage(rh.gs(R.string.enable_cloud_export_message)) + .setPositiveButton(rh.gs(app.aaps.core.ui.R.string.yes)) { _, _ -> + exportOptionsDialog.enableAllCloud() + onStorageChanged() + ToastUtils.longInfoToast(activity, rh.gs(R.string.can_change_in_export_settings)) + } + .setNegativeButton(rh.gs(app.aaps.core.ui.R.string.no)) { _, _ -> + ToastUtils.longInfoToast(activity, rh.gs(R.string.can_change_in_export_settings)) + } + .show() + } + } + + /** Start auth flow for cloud provider */ + private suspend fun startAuthFlow( + activity: DaggerAppCompatActivityWithResult, + storageType: String, + onSuccess: () -> Unit, + onStorageChanged: () -> Unit + ) { + try { + val provider = cloudStorageManager.getProvider(storageType) ?: return + + // Start OAuth/PKCE auth flow (provider-agnostic) + val authUrl = provider.startAuth() + if (authUrl == null) { + ToastUtils.errorToast(activity, rh.gs(R.string.cloud_auth_start_failed)) + return + } + val customTabsIntent = CustomTabsIntent.Builder() + .setShowTitle(true) + .build() + customTabsIntent.launchUrl(activity, authUrl.toUri()) + showWaitingDialog(activity, provider.displayName) { cancelled -> + if (cancelled) { + ToastUtils.infoToast(activity, rh.gs(R.string.cloud_auth_cancelled)) + } else { + activity.lifecycleScope.launch { + val authCode = provider.waitForAuthCode(60000) + if (authCode != null) { + if (provider.completeAuth(authCode)) { + cloudStorageManager.setActiveStorageType(storageType) + ToastUtils.infoToast(activity, rh.gs(R.string.cloud_auth_success)) + // Explicitly open maintenance page to prevent main page from stealing focus + openMaintenanceScreen(activity) + onSuccess() + onStorageChanged() + // Automatically point to fixed settings directory and skip selection + val folderId = provider.getOrCreateFolderPath(CloudConstants.CLOUD_PATH_SETTINGS) + if (!folderId.isNullOrEmpty()) provider.setSelectedFolderId(folderId) + // Ask user if they want to enable cloud export + showEnableCloudExportDialog(activity, onStorageChanged) + } else { + ToastUtils.errorToast(activity, rh.gs(R.string.cloud_auth_failed)) + } + } else { + ToastUtils.errorToast(activity, rh.gs(R.string.cloud_auth_timeout)) + } + } + } + } + } catch (e: Exception) { + aapsLogger.error(LTag.CORE, "$LOG_PREFIX Error starting auth flow", e) + ToastUtils.errorToast(activity, rh.gs(R.string.cloud_auth_error, e.message)) + } + } + + /** + * Explicitly open SingleFragmentActivity containing MaintenanceFragment, with a delayed + * secondary attempt to prevent the browser closing/system returning to the previous app + * from stealing focus back to MainActivity. + */ + private fun openMaintenanceScreen(activity: DaggerAppCompatActivityWithResult) { + try { + val list = activePlugin.getPluginsList() + val idx = list.indexOfFirst { it is MaintenancePlugin } + if (idx >= 0) { + val intent = Intent(activity, SingleFragmentActivity::class.java) + .setAction("CloudDirectoryDialog") + .putExtra("plugin", idx) + .addFlags(Intent.FLAG_ACTIVITY_REORDER_TO_FRONT or Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_NO_ANIMATION) + activity.startActivity(intent) + // Delay again to counteract possible late system foreground actions + activity.window?.decorView?.postDelayed({ + try { activity.startActivity(intent) } catch (_: Exception) { } + }, 1200) + } else { + // Fallback: if index not found, still try to bring current Activity to foreground + bringAppToForeground(activity) + } + } catch (_: Exception) { } + } + + // Fallback method: bring app to foreground (using launch Intent, with double REORDER_TO_FRONT to stabilize focus) + private fun bringAppToForeground(activity: DaggerAppCompatActivityWithResult) { + try { + val launchIntent = activity.packageManager.getLaunchIntentForPackage(activity.packageName)?.apply { + addFlags(Intent.FLAG_ACTIVITY_REORDER_TO_FRONT or Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_NO_ANIMATION) + } + if (launchIntent != null) { + activity.startActivity(launchIntent) + activity.window?.decorView?.postDelayed({ + try { activity.startActivity(launchIntent) } catch (_: Exception) { } + }, 800) + } + } catch (_: Exception) { } + } + + /** Waiting dialog for authorization */ + private fun showWaitingDialog( + activity: DaggerAppCompatActivityWithResult, + providerName: String, + onResult: (cancelled: Boolean) -> Unit + ) { + val dialog = MaterialAlertDialogBuilder(activity, app.aaps.core.ui.R.style.DialogTheme) + .setCustomTitle(AlertDialogHelper.buildCustomTitle( + activity, + rh.gs(R.string.cloud_authorization_title, providerName) + )) + .setMessage(rh.gs(R.string.cloud_authorization_message, providerName)) + .setNegativeButton(rh.gs(app.aaps.core.ui.R.string.cancel)) { _, _ -> + onResult(true) + } + .setCancelable(false) + .create() + + dialog.show() + + // Immediately signal waiting started (dialog stays until flow finishes externally) + onResult(false) + dialog.dismiss() + } + + /** Reauthorize dialog */ + private fun showReauthorizeDialog( + activity: DaggerAppCompatActivityWithResult, + storageType: String, + onSuccess: () -> Unit, + onStorageChanged: () -> Unit + ) { + MaterialAlertDialogBuilder(activity, app.aaps.core.ui.R.style.DialogTheme) + .setCustomTitle(AlertDialogHelper.buildCustomTitle( + activity, + rh.gs(R.string.cloud_connection_failed) + )) + .setMessage(rh.gs(R.string.cloud_reauthorize_message)) + .setPositiveButton(rh.gs(R.string.reauthorize)) { _, _ -> + cloudStorageManager.clearAllCredentials() + activity.lifecycleScope.launch { + startAuthFlow(activity, storageType, onSuccess, onStorageChanged) + } + } + .setNegativeButton(rh.gs(app.aaps.core.ui.R.string.cancel), null) + .show() + } +} diff --git a/plugins/configuration/src/main/kotlin/app/aaps/plugins/configuration/maintenance/cloud/CloudModels.kt b/plugins/configuration/src/main/kotlin/app/aaps/plugins/configuration/maintenance/cloud/CloudModels.kt new file mode 100644 index 00000000000..318f882c249 --- /dev/null +++ b/plugins/configuration/src/main/kotlin/app/aaps/plugins/configuration/maintenance/cloud/CloudModels.kt @@ -0,0 +1,58 @@ +package app.aaps.plugins.configuration.maintenance.cloud + +/** + * Represents a file in cloud storage. + * This is a provider-agnostic data class that can be used across different cloud services. + */ +data class CloudFile( + /** + * Unique identifier for this file in the cloud storage + */ + val id: String, + + /** + * File name + */ + val name: String, + + /** + * MIME type of the file + */ + val mimeType: String +) + +/** + * Represents a folder in cloud storage. + * This is a provider-agnostic data class that can be used across different cloud services. + */ +data class CloudFolder( + /** + * Unique identifier for this folder in the cloud storage + */ + val id: String, + + /** + * Folder name + */ + val name: String +) + +/** + * Result of a file listing operation with pagination support. + */ +data class CloudFileListResult( + /** + * List of files returned in this page + */ + val files: List, + + /** + * Token for fetching the next page, null if this is the last page + */ + val nextPageToken: String? = null, + + /** + * Total number of files (if known), -1 if unknown + */ + val totalCount: Int = -1 +) diff --git a/plugins/configuration/src/main/kotlin/app/aaps/plugins/configuration/maintenance/cloud/CloudStorageManager.kt b/plugins/configuration/src/main/kotlin/app/aaps/plugins/configuration/maintenance/cloud/CloudStorageManager.kt new file mode 100644 index 00000000000..37f8d4ac6ee --- /dev/null +++ b/plugins/configuration/src/main/kotlin/app/aaps/plugins/configuration/maintenance/cloud/CloudStorageManager.kt @@ -0,0 +1,183 @@ +package app.aaps.plugins.configuration.maintenance.cloud + +import app.aaps.core.interfaces.logging.AAPSLogger +import app.aaps.core.interfaces.logging.LTag +import app.aaps.core.interfaces.sharedPreferences.SP +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Cloud Storage Manager - Factory class for managing cloud storage providers. + * + * This class provides a unified interface for accessing cloud storage providers. + * It manages provider registration, selection, and lifecycle. + * + * The providers are automatically registered via Dagger multi-binding. + * To add a new cloud provider: + * 1. Create a class that implements CloudStorageProvider + * 2. Add a @Binds @IntoSet binding in CloudStorageModule + * 3. Add the storage type to StorageTypes + * + * Usage: + * ``` + * // Get the active provider (based on user settings) + * val provider = cloudStorageManager.getActiveProvider() + * + * // Get a specific provider by type + * val googleDrive = cloudStorageManager.getProvider(StorageTypes.GOOGLE_DRIVE) + * + * // List all available providers + * val providers = cloudStorageManager.getAvailableProviders() + * ``` + */ +@Singleton +class CloudStorageManager @Inject constructor( + private val aapsLogger: AAPSLogger, + private val sp: SP, + /** + * Set of all cloud storage providers, injected via Dagger multi-binding. + * This allows adding new providers without modifying this class. + */ + cloudStorageProviders: Set<@JvmSuppressWildcards CloudStorageProvider> +) { + + companion object { + private const val LOG_PREFIX = "[CloudStorageManager]" + } + + /** + * Map of storage type to provider instance + */ + private val providers: Map + + init { + // Build provider map from the injected set + providers = cloudStorageProviders.associateBy { it.storageType } + + aapsLogger.info(LTag.CORE, "$LOG_PREFIX Initialized with ${providers.size} provider(s): ${providers.keys.joinToString()}") + } + + /** + * Get the currently active provider based on user settings. + * @return The active CloudStorageProvider, or null if local storage is selected + */ + fun getActiveProvider(): CloudStorageProvider? { + val activeType = sp.getString(CloudConstants.PREF_CLOUD_STORAGE_TYPE, StorageTypes.LOCAL) + + if (activeType == StorageTypes.LOCAL) { + return null + } + + return providers[activeType] + } + + /** + * Get a specific provider by its storage type. + * @param storageType The storage type identifier (e.g., "google_drive") + * @return The provider, or null if not found + */ + fun getProvider(storageType: String): CloudStorageProvider? { + return providers[storageType] + } + + /** + * Get all available cloud storage providers. + * @return List of all registered providers + */ + fun getAvailableProviders(): List { + return providers.values.toList() + } + + /** + * Get all cloud storage types that have valid credentials. + * @return List of storage types with valid credentials + */ + fun getAuthenticatedProviders(): List { + return providers.values.filter { it.hasValidCredentials() } + } + + /** + * Check if any cloud provider has valid credentials. + * @return true if at least one provider is authenticated + */ + fun hasAnyCloudCredentials(): Boolean { + return providers.values.any { it.hasValidCredentials() } + } + + /** + * Get the currently selected storage type. + * @return The storage type identifier + */ + fun getActiveStorageType(): String { + return sp.getString(CloudConstants.PREF_CLOUD_STORAGE_TYPE, StorageTypes.LOCAL) + } + + /** + * Set the active storage type. + * @param storageType The storage type to set + */ + fun setActiveStorageType(storageType: String) { + if (StorageTypes.isValidStorageType(storageType)) { + sp.putString(CloudConstants.PREF_CLOUD_STORAGE_TYPE, storageType) + aapsLogger.info(LTag.CORE, "$LOG_PREFIX Active storage type set to: $storageType") + } else { + aapsLogger.warn(LTag.CORE, "$LOG_PREFIX Invalid storage type: $storageType") + } + } + + /** + * Check if cloud storage is currently active. + * @return true if the active storage type is a cloud type + */ + fun isCloudStorageActive(): Boolean { + return StorageTypes.isCloudStorage(getActiveStorageType()) + } + + /** + * Check if the active provider has a connection error. + * @return true if there's a connection error + */ + fun hasConnectionError(): Boolean { + return getActiveProvider()?.hasConnectionError() ?: false + } + + /** + * Clear connection error on the active provider. + */ + fun clearConnectionError() { + getActiveProvider()?.clearConnectionError() + } + + /** + * Clear credentials for all providers. + * Use this for a complete sign-out from all cloud services. + */ + fun clearAllCredentials() { + providers.values.forEach { provider -> + try { + provider.clearCredentials() + aapsLogger.info(LTag.CORE, "$LOG_PREFIX Cleared credentials for: ${provider.storageType}") + } catch (e: Exception) { + aapsLogger.error(LTag.CORE, "$LOG_PREFIX Error clearing credentials for ${provider.storageType}", e) + } + } + } + + /** + * Get the display name for a storage type. + * @param storageType The storage type + * @return Human-readable display name + */ + fun getDisplayName(storageType: String): String { + return providers[storageType]?.displayName ?: storageType + } + + /** + * Get the icon resource ID for a storage type. + * @param storageType The storage type + * @return Drawable resource ID, or 0 if not found + */ + fun getIconResId(storageType: String): Int { + return providers[storageType]?.iconResId ?: 0 + } +} diff --git a/plugins/configuration/src/main/kotlin/app/aaps/plugins/configuration/maintenance/cloud/CloudStorageProvider.kt b/plugins/configuration/src/main/kotlin/app/aaps/plugins/configuration/maintenance/cloud/CloudStorageProvider.kt new file mode 100644 index 00000000000..c042b6b8c13 --- /dev/null +++ b/plugins/configuration/src/main/kotlin/app/aaps/plugins/configuration/maintenance/cloud/CloudStorageProvider.kt @@ -0,0 +1,183 @@ +package app.aaps.plugins.configuration.maintenance.cloud + +/** + * Abstract interface for cloud storage providers. + * + * This interface defines the contract that all cloud storage providers must implement, + * enabling easy extension to support additional cloud services like Dropbox, Azure, OneDrive, etc. + * + * Usage: + * - Implement this interface for each cloud storage provider + * - Register the implementation in CloudStorageManager + * - The application code uses CloudStorageManager to interact with the active provider + */ +interface CloudStorageProvider { + + /** + * Unique identifier for this storage type (e.g., "google_drive", "dropbox", "azure_blob") + */ + val storageType: String + + /** + * Human-readable display name for this provider + */ + val displayName: String + + /** + * Drawable resource ID for the provider's icon + */ + val iconResId: Int + + // ==================== Authentication ==================== + + /** + * Start the authentication flow. + * @return The authorization URL to open in browser, or null if auth is not needed + */ + suspend fun startAuth(): String? + + /** + * Complete the authentication flow with the received auth code. + * @param authCode The authorization code received from the OAuth callback + * @return true if authentication was successful + */ + suspend fun completeAuth(authCode: String): Boolean + + /** + * Check if there are valid stored credentials. + * @return true if valid credentials exist + */ + fun hasValidCredentials(): Boolean + + /** + * Clear all stored credentials and settings. + */ + fun clearCredentials() + + /** + * Get a valid access token, refreshing if necessary. + * @return Valid access token or null if unable to obtain one + */ + suspend fun getValidAccessToken(): String? + + // ==================== Connection ==================== + + /** + * Test the connection to the cloud service. + * @return true if connection is successful + */ + suspend fun testConnection(): Boolean + + /** + * Check if there's a connection error. + * @return true if there's an active connection error + */ + fun hasConnectionError(): Boolean + + /** + * Clear the connection error state. + */ + fun clearConnectionError() + + // ==================== Folder Operations ==================== + + /** + * Get or create a folder path in the cloud storage. + * @param path The folder path (e.g., "AAPS/export/settings") + * @return The folder ID or null if failed + */ + suspend fun getOrCreateFolderPath(path: String): String? + + /** + * Create a single folder. + * @param name Folder name + * @param parentId Parent folder ID (use "root" for root folder) + * @return The created folder ID or null if failed + */ + suspend fun createFolder(name: String, parentId: String = "root"): String? + + /** + * List folders in the specified parent folder. + * @param parentId Parent folder ID (use "root" for root folder) + * @return List of folders + */ + suspend fun listFolders(parentId: String = "root"): List + + // ==================== File Operations ==================== + + /** + * Upload a file to the specified path. + * @param fileName File name + * @param content File content as bytes + * @param mimeType MIME type of the file + * @param path Target folder path + * @return The uploaded file ID or null if failed + */ + suspend fun uploadFileToPath( + fileName: String, + content: ByteArray, + mimeType: String, + path: String + ): String? + + /** + * Upload a file to the currently selected folder. + * @param fileName File name + * @param content File content as bytes + * @param mimeType MIME type of the file + * @return The uploaded file ID or null if failed + */ + suspend fun uploadFile( + fileName: String, + content: ByteArray, + mimeType: String + ): String? + + /** + * Download a file by its ID. + * @param fileId The file ID + * @return File content as bytes or null if failed + */ + suspend fun downloadFile(fileId: String): ByteArray? + + /** + * List settings files (JSON files) in the selected folder. + * @param pageSize Maximum number of files to return + * @param pageToken Page token for pagination + * @return List of settings files + */ + suspend fun listSettingsFiles( + pageSize: Int = 10, + pageToken: String? = null + ): CloudFileListResult + + // ==================== Selected Folder ==================== + + /** + * Get the currently selected folder ID. + * @return The selected folder ID or empty string if none selected + */ + fun getSelectedFolderId(): String + + /** + * Set the selected folder ID. + * @param folderId The folder ID to select + */ + fun setSelectedFolderId(folderId: String) + + // ==================== OAuth Helpers ==================== + + /** + * Wait for OAuth authorization code (used during OAuth/PKCE auth flow). + * Providers that use browser-based auth should implement this. + * @param timeoutMs Maximum time to wait in milliseconds + * @return The authorization code, or null if timeout/cancelled + */ + suspend fun waitForAuthCode(timeoutMs: Long = 60000): String? = null + + /** + * Count the number of settings files in the selected folder. + * @return Number of settings files + */ + suspend fun countSettingsFiles(): Int = 0 +} diff --git a/plugins/configuration/src/main/kotlin/app/aaps/plugins/configuration/maintenance/cloud/ExportOptionsDialog.kt b/plugins/configuration/src/main/kotlin/app/aaps/plugins/configuration/maintenance/cloud/ExportOptionsDialog.kt new file mode 100644 index 00000000000..53f2db933f9 --- /dev/null +++ b/plugins/configuration/src/main/kotlin/app/aaps/plugins/configuration/maintenance/cloud/ExportOptionsDialog.kt @@ -0,0 +1,264 @@ +package app.aaps.plugins.configuration.maintenance.cloud + +import android.content.Context +import android.widget.Switch +import androidx.appcompat.app.AlertDialog +import androidx.fragment.app.FragmentActivity +import app.aaps.core.interfaces.logging.AAPSLogger +import app.aaps.core.interfaces.logging.LTag +import app.aaps.core.interfaces.resources.ResourceHelper +import app.aaps.core.interfaces.sharedPreferences.SP +import app.aaps.core.ui.toast.ToastUtils +import app.aaps.plugins.configuration.R +import app.aaps.plugins.configuration.activities.DaggerAppCompatActivityWithResult +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class ExportOptionsDialog @Inject constructor( + private val aapsLogger: AAPSLogger, + private val rh: ResourceHelper, + private val sp: SP, + private val cloudStorageManager: CloudStorageManager +) { + + companion object { + private const val LOG_PREFIX = CloudConstants.LOG_PREFIX + + // SharedPreferences keys for export destination settings + const val PREF_ALL_CLOUD_ENABLED = "export_all_cloud_enabled" + const val PREF_LOG_EMAIL_ENABLED = "export_log_email_enabled" + const val PREF_LOG_CLOUD_ENABLED = "export_log_cloud_enabled" + const val PREF_SETTINGS_LOCAL_ENABLED = "export_settings_local_enabled" + const val PREF_SETTINGS_CLOUD_ENABLED = "export_settings_cloud_enabled" + const val PREF_CSV_LOCAL_ENABLED = "export_csv_local_enabled" + const val PREF_CSV_CLOUD_ENABLED = "export_csv_cloud_enabled" + } + + /** + * Show export options configuration dialog + */ + fun showExportOptionsDialog( + activity: DaggerAppCompatActivityWithResult, + onSettingsChanged: () -> Unit = {} + ) { + val dialogView = activity.layoutInflater.inflate(R.layout.dialog_export_options, null) + + val allCloudSwitch = dialogView.findViewById(R.id.all_cloud_switch) + val logEmailSwitch = dialogView.findViewById(R.id.log_email_switch) + val logCloudSwitch = dialogView.findViewById(R.id.log_cloud_switch) + val settingsLocalSwitch = dialogView.findViewById(R.id.settings_local_switch) + val settingsCloudSwitch = dialogView.findViewById(R.id.settings_cloud_switch) + val csvLocalSwitch = dialogView.findViewById(R.id.csv_local_switch) + val csvCloudSwitch = dialogView.findViewById(R.id.csv_cloud_switch) + + // Load current settings + allCloudSwitch.isChecked = sp.getBoolean(PREF_ALL_CLOUD_ENABLED, false) + logEmailSwitch.isChecked = sp.getBoolean(PREF_LOG_EMAIL_ENABLED, true) // Default to email + logCloudSwitch.isChecked = sp.getBoolean(PREF_LOG_CLOUD_ENABLED, false) + settingsLocalSwitch.isChecked = sp.getBoolean(PREF_SETTINGS_LOCAL_ENABLED, true) // Default to local + settingsCloudSwitch.isChecked = sp.getBoolean(PREF_SETTINGS_CLOUD_ENABLED, false) + csvLocalSwitch.isChecked = sp.getBoolean(PREF_CSV_LOCAL_ENABLED, true) // Default to local + csvCloudSwitch.isChecked = sp.getBoolean(PREF_CSV_CLOUD_ENABLED, false) + + // Check if cloud directory is configured + val isCloudConfigured = cloudStorageManager.isCloudStorageActive() + + // Disable cloud options if not configured + if (!isCloudConfigured) { + logCloudSwitch.isEnabled = false + settingsCloudSwitch.isEnabled = false + csvCloudSwitch.isEnabled = false + + // Force disable cloud options and enable local/email options + logCloudSwitch.isChecked = false + settingsCloudSwitch.isChecked = false + csvCloudSwitch.isChecked = false + + if (!logEmailSwitch.isChecked && !logCloudSwitch.isChecked) { + logEmailSwitch.isChecked = true + } + if (!settingsLocalSwitch.isChecked && !settingsCloudSwitch.isChecked) { + settingsLocalSwitch.isChecked = true + } + if (!csvLocalSwitch.isChecked && !csvCloudSwitch.isChecked) { + csvLocalSwitch.isChecked = true + } + } + + // Apply master All-Cloud behavior - for initialization (BEFORE setting up listeners) + // This ensures the UI reflects the correct state before any listener is attached + if (allCloudSwitch.isChecked && isCloudConfigured) { + // When All-Cloud is enabled, ensure all cloud options are checked + logCloudSwitch.isChecked = true + settingsCloudSwitch.isChecked = true + csvCloudSwitch.isChecked = true + logEmailSwitch.isChecked = false + settingsLocalSwitch.isChecked = false + csvLocalSwitch.isChecked = false + + // Disable per-row toggles when master is on + logEmailSwitch.isEnabled = false + logCloudSwitch.isEnabled = false + settingsLocalSwitch.isEnabled = false + settingsCloudSwitch.isEnabled = false + csvLocalSwitch.isEnabled = false + csvCloudSwitch.isEnabled = false + } + + // Set up mutual exclusivity for each row (AFTER initial state is set) + setupMutualExclusivity(logEmailSwitch, logCloudSwitch) + setupMutualExclusivity(settingsLocalSwitch, settingsCloudSwitch) + setupMutualExclusivity(csvLocalSwitch, csvCloudSwitch) + + // Apply master All-Cloud behavior - for user interaction (sets values) + val applyAllCloudState: (Boolean) -> Unit = { enabled -> + if (enabled) { + // Require cloud for all rows + logCloudSwitch.isChecked = true + settingsCloudSwitch.isChecked = true + csvCloudSwitch.isChecked = true + logEmailSwitch.isChecked = false + settingsLocalSwitch.isChecked = false + csvLocalSwitch.isChecked = false + + // Disable per-row toggles when master is on + logEmailSwitch.isEnabled = false + logCloudSwitch.isEnabled = false + settingsLocalSwitch.isEnabled = false + settingsCloudSwitch.isEnabled = false + csvLocalSwitch.isEnabled = false + csvCloudSwitch.isEnabled = false + } else { + // Re-enable per-row toggles (cloud options depend on configuration) + // Don't reset values - just allow user to change them + logEmailSwitch.isEnabled = true + logCloudSwitch.isEnabled = isCloudConfigured + settingsLocalSwitch.isEnabled = true + settingsCloudSwitch.isEnabled = isCloudConfigured + csvLocalSwitch.isEnabled = true + csvCloudSwitch.isEnabled = isCloudConfigured + } + } + + allCloudSwitch.setOnCheckedChangeListener { _, isChecked -> + // If cloud not configured, block enabling master switch + val cloudConfigured = cloudStorageManager.isCloudStorageActive() + if (isChecked && !cloudConfigured) { + ToastUtils.warnToast(activity, rh.gs(R.string.cloud_connection_failed)) + allCloudSwitch.isChecked = false + return@setOnCheckedChangeListener + } + applyAllCloudState(isChecked) + } + + val dialog = AlertDialog.Builder(activity) + .setTitle(rh.gs(R.string.export_options)) + .setView(dialogView) + .setPositiveButton(rh.gs(app.aaps.core.ui.R.string.ok)) { _, _ -> + // Save settings + sp.putBoolean(PREF_ALL_CLOUD_ENABLED, allCloudSwitch.isChecked) + sp.putBoolean(PREF_LOG_EMAIL_ENABLED, logEmailSwitch.isChecked) + sp.putBoolean(PREF_LOG_CLOUD_ENABLED, logCloudSwitch.isChecked) + sp.putBoolean(PREF_SETTINGS_LOCAL_ENABLED, settingsLocalSwitch.isChecked) + sp.putBoolean(PREF_SETTINGS_CLOUD_ENABLED, settingsCloudSwitch.isChecked) + sp.putBoolean(PREF_CSV_LOCAL_ENABLED, csvLocalSwitch.isChecked) + sp.putBoolean(PREF_CSV_CLOUD_ENABLED, csvCloudSwitch.isChecked) + + onSettingsChanged() + ToastUtils.infoToast(activity, rh.gs(R.string.export_options_updated)) + } + .setNegativeButton(rh.gs(app.aaps.core.ui.R.string.cancel), null) + .create() + + dialog.show() + } + + /** + * Set up mutual exclusivity between two switches - when one is turned on, the other is turned off + */ + private fun setupMutualExclusivity(switch1: Switch, switch2: Switch) { + switch1.setOnCheckedChangeListener { _, isChecked -> + if (isChecked && switch2.isChecked) { + switch2.isChecked = false + } + // Ensure at least one is checked + if (!isChecked && !switch2.isChecked) { + switch1.isChecked = true + } + } + + switch2.setOnCheckedChangeListener { _, isChecked -> + if (isChecked && switch1.isChecked) { + switch1.isChecked = false + } + // Ensure at least one is checked + if (!isChecked && !switch1.isChecked) { + switch2.isChecked = true + } + } + } + + /** + * Get current export destination preferences + */ + fun isAllCloudEnabled(): Boolean { + val value = sp.getBoolean(PREF_ALL_CLOUD_ENABLED, false) + aapsLogger.info(LTag.CORE, "$LOG_PREFIX ExportDestination: isAllCloudEnabled=$value") + return value + } + + fun isLogCloudEnabled(): Boolean { + val value = sp.getBoolean(PREF_LOG_CLOUD_ENABLED, false) + aapsLogger.info(LTag.CORE, "$LOG_PREFIX ExportDestination: isLogCloudEnabled=$value") + return value + } + + fun isSettingsCloudEnabled(): Boolean { + val value = sp.getBoolean(PREF_SETTINGS_CLOUD_ENABLED, false) + aapsLogger.info(LTag.CORE, "$LOG_PREFIX ExportDestination: isSettingsCloudEnabled=$value") + return value + } + + fun isCsvCloudEnabled(): Boolean { + val value = sp.getBoolean(PREF_CSV_CLOUD_ENABLED, false) + aapsLogger.info(LTag.CORE, "$LOG_PREFIX ExportDestination: isCsvCloudEnabled=$value") + return value + } + + /** + * Reset all export settings to local/email mode + * - Disable "All Cloud" + * - Set Log to "Email" (disable cloud) + * - Set Settings to "Local" (disable cloud) + * - Set CSV to "Local" (disable cloud) + */ + fun resetToLocalSettings() { + aapsLogger.info(LTag.CORE, "$LOG_PREFIX ExportDestination: Resetting all settings to local/email mode") + sp.putBoolean(PREF_ALL_CLOUD_ENABLED, false) + sp.putBoolean(PREF_LOG_EMAIL_ENABLED, true) + sp.putBoolean(PREF_LOG_CLOUD_ENABLED, false) + sp.putBoolean(PREF_SETTINGS_LOCAL_ENABLED, true) + sp.putBoolean(PREF_SETTINGS_CLOUD_ENABLED, false) + sp.putBoolean(PREF_CSV_LOCAL_ENABLED, true) + sp.putBoolean(PREF_CSV_CLOUD_ENABLED, false) + } + + /** + * Enable "All Cloud" option for cloud export + * - Enable "All Cloud" switch + * - Set Log to "Cloud" (disable email) + * - Set Settings to "Cloud" (disable local) + * - Set CSV to "Cloud" (disable local) + */ + fun enableAllCloud() { + aapsLogger.info(LTag.CORE, "$LOG_PREFIX ExportDestination: Enabling all cloud mode") + sp.putBoolean(PREF_ALL_CLOUD_ENABLED, true) + sp.putBoolean(PREF_LOG_EMAIL_ENABLED, false) + sp.putBoolean(PREF_LOG_CLOUD_ENABLED, true) + sp.putBoolean(PREF_SETTINGS_LOCAL_ENABLED, false) + sp.putBoolean(PREF_SETTINGS_CLOUD_ENABLED, true) + sp.putBoolean(PREF_CSV_LOCAL_ENABLED, false) + sp.putBoolean(PREF_CSV_CLOUD_ENABLED, true) + } +} diff --git a/plugins/configuration/src/main/kotlin/app/aaps/plugins/configuration/maintenance/cloud/events/EventCloudStorageStatusChanged.kt b/plugins/configuration/src/main/kotlin/app/aaps/plugins/configuration/maintenance/cloud/events/EventCloudStorageStatusChanged.kt new file mode 100644 index 00000000000..09399592e63 --- /dev/null +++ b/plugins/configuration/src/main/kotlin/app/aaps/plugins/configuration/maintenance/cloud/events/EventCloudStorageStatusChanged.kt @@ -0,0 +1,9 @@ +package app.aaps.plugins.configuration.maintenance.cloud.events + +import app.aaps.core.interfaces.rx.events.Event + +/** + * Event fired when cloud storage connection status changes. + * This allows UI components to update immediately when connection errors occur. + */ +class EventCloudStorageStatusChanged : Event() diff --git a/plugins/configuration/src/main/kotlin/app/aaps/plugins/configuration/maintenance/cloud/providers/googledrive/GoogleDriveManager.kt b/plugins/configuration/src/main/kotlin/app/aaps/plugins/configuration/maintenance/cloud/providers/googledrive/GoogleDriveManager.kt new file mode 100644 index 00000000000..d63769e035e --- /dev/null +++ b/plugins/configuration/src/main/kotlin/app/aaps/plugins/configuration/maintenance/cloud/providers/googledrive/GoogleDriveManager.kt @@ -0,0 +1,1417 @@ +package app.aaps.plugins.configuration.maintenance.cloud.providers.googledrive + +import android.content.Context +import android.net.Uri +import app.aaps.core.interfaces.logging.AAPSLogger +import app.aaps.core.interfaces.logging.LTag +import app.aaps.core.interfaces.notifications.Notification +import app.aaps.core.interfaces.resources.ResourceHelper +import app.aaps.core.interfaces.rx.bus.RxBus +import app.aaps.core.interfaces.rx.events.EventNewNotification +import app.aaps.core.interfaces.sharedPreferences.SP +import app.aaps.plugins.configuration.R +import app.aaps.plugins.configuration.maintenance.cloud.CloudConstants +import app.aaps.plugins.configuration.maintenance.cloud.events.EventCloudStorageStatusChanged +import kotlinx.coroutines.* +import okhttp3.* +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.RequestBody.Companion.toRequestBody +import org.json.JSONArray +import org.json.JSONObject +import java.io.IOException +import java.io.OutputStream +import java.net.ServerSocket +import java.net.Socket +import java.net.URLEncoder +import java.security.MessageDigest +import java.security.SecureRandom +import java.util.* +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class GoogleDriveManager @Inject constructor( + private val aapsLogger: AAPSLogger, + private val rh: ResourceHelper, + private val sp: SP, + private val rxBus: RxBus, + private val context: Context +) { + + companion object { + private const val CLIENT_ID = "705061051276-3ied5cqa3kqhb0hpr7p0rggoffhq46ef.apps.googleusercontent.com" + private const val REDIRECT_PORT = 8080 + private const val REDIRECT_URI = "http://localhost:$REDIRECT_PORT/oauth/callback" + private const val SCOPE = "https://www.googleapis.com/auth/drive.file" + private const val AUTH_URL = "https://accounts.google.com/o/oauth2/v2/auth" + private const val TOKEN_URL = "https://oauth2.googleapis.com/token" + private const val DRIVE_API_URL = "https://www.googleapis.com/drive/v3" + private const val UPLOAD_URL = "https://www.googleapis.com/upload/drive/v3" + private val LOG_PREFIX = CloudConstants.LOG_PREFIX + + // SharedPreferences keys + private const val PREF_GOOGLE_DRIVE_REFRESH_TOKEN = "google_drive_refresh_token" + private const val PREF_GOOGLE_DRIVE_ACCESS_TOKEN = "google_drive_access_token" + private const val PREF_GOOGLE_DRIVE_TOKEN_EXPIRY = "google_drive_token_expiry" + private const val PREF_GOOGLE_DRIVE_STORAGE_TYPE = "google_drive_storage_type" + private const val PREF_GOOGLE_DRIVE_FOLDER_ID = "google_drive_folder_id" + + // Storage types + const val STORAGE_TYPE_LOCAL = "local" + const val STORAGE_TYPE_GOOGLE_DRIVE = "google_drive" + + // Notification IDs + const val NOTIFICATION_GOOGLE_DRIVE_ERROR = Notification.USER_MESSAGE + 100 + + } + + private val client = OkHttpClient() + private val pathCache = mutableMapOf() // cache for resolved folder paths + + // Error state tracking + private var connectionError = false + private var errorNotificationId: Int? = null + + // Local server related + private var localServer: ServerSocket? = null + private var authCodeReceived: String? = null + private var authState: String? = null + private var serverJob: Job? = null + + /** + * Check if there is a valid refresh token + */ + fun hasValidRefreshToken(): Boolean { + return sp.getString(PREF_GOOGLE_DRIVE_REFRESH_TOKEN, "").isNotBlank() + } + + /** + * Get current storage type with default value + */ + fun getStorageType(): String { + val storageType = sp.getString(PREF_GOOGLE_DRIVE_STORAGE_TYPE, STORAGE_TYPE_LOCAL) + // If there is a refresh token but storage type is local, settings may have been reset, try to restore + if (storageType == STORAGE_TYPE_LOCAL && hasValidRefreshToken()) { + // Check if there is a valid folder ID + val folderId = sp.getString(PREF_GOOGLE_DRIVE_FOLDER_ID, "") + if (folderId.isNotEmpty()) { + aapsLogger.info(LTag.CORE, "$LOG_PREFIX Restoring Google Drive storage type from token presence") + sp.putString(PREF_GOOGLE_DRIVE_STORAGE_TYPE, STORAGE_TYPE_GOOGLE_DRIVE) + return STORAGE_TYPE_GOOGLE_DRIVE + } + } + return storageType + } + + /** + * Set storage type + */ + fun setStorageType(type: String) { + sp.putString(PREF_GOOGLE_DRIVE_STORAGE_TYPE, type) + } + + /** + * Start OAuth2 authentication flow using PKCE + * + * Note: This implementation uses a local server approach instead of traditional Android OAuth2 + * because AAPS is an open-source medical application where each user must compile their own APK. + * Since each compilation uses a different JKS (Java KeyStore) for signing, we cannot rely on + * a fixed JKS for Google OAuth2 verification. Therefore, we use a local HTTP server to receive + * the OAuth callback, which works regardless of the APK signing certificate. + */ + suspend fun startPKCEAuth(): String { + return withContext(Dispatchers.IO) { + try { + // Start local server + startLocalServer() + + // Generate code verifier and code challenge + val codeVerifier = generateCodeVerifier() + val codeChallenge = generateCodeChallenge(codeVerifier) + + // Save code verifier for later use + sp.putString("google_drive_code_verifier", codeVerifier) + + // Build authorization URL + val authUrl = buildAuthUrl(codeChallenge) + aapsLogger.debug(LTag.CORE, "$LOG_PREFIX Google Drive auth URL: $authUrl") + + authUrl + } catch (e: Exception) { + aapsLogger.error(LTag.CORE, "$LOG_PREFIX Error starting PKCE auth", e) + throw e + } + } + } + + /** + * Handle authorization code and obtain refresh token + */ + suspend fun exchangeCodeForTokens(authCode: String): Boolean { + return withContext(Dispatchers.IO) { + try { + val codeVerifier = sp.getString("google_drive_code_verifier", "") + if (codeVerifier.isEmpty()) { + throw IllegalStateException("Code verifier not found") + } + + val requestBody = FormBody.Builder() + .add("client_id", CLIENT_ID) + .add("code", authCode) + .add("code_verifier", codeVerifier) + .add("grant_type", "authorization_code") + .add("redirect_uri", REDIRECT_URI) + .build() + + val request = Request.Builder() + .url(TOKEN_URL) + .post(requestBody) + .build() + + val response = client.newCall(request).execute() + val responseBody = response.body?.string() ?: "" + + if (response.isSuccessful) { + val jsonResponse = JSONObject(responseBody) + val refreshToken = jsonResponse.optString("refresh_token") + val accessToken = jsonResponse.optString("access_token") + val expiresIn = jsonResponse.optLong("expires_in", 3600) + + if (refreshToken.isNotEmpty()) { + sp.putString(PREF_GOOGLE_DRIVE_REFRESH_TOKEN, refreshToken) + sp.putString(PREF_GOOGLE_DRIVE_ACCESS_TOKEN, accessToken) + sp.putLong(PREF_GOOGLE_DRIVE_TOKEN_EXPIRY, System.currentTimeMillis() + expiresIn * 1000) + + // Clear code verifier + sp.remove("google_drive_code_verifier") + + // Clear any previous connection error since authorization succeeded + clearConnectionError() + + aapsLogger.info(LTag.CORE, "$LOG_PREFIX Google Drive tokens obtained successfully") + return@withContext true + } + } + + aapsLogger.error(LTag.CORE, "$LOG_PREFIX Failed to exchange code for tokens: $responseBody") + false + } catch (e: Exception) { + aapsLogger.error(LTag.CORE, "$LOG_PREFIX Error exchanging code for tokens", e) + false + } + } + } + + /** + * Get a valid access token + */ + suspend fun getValidAccessToken(): String? = withContext(Dispatchers.IO) { + try { + val cachedToken = sp.getString(PREF_GOOGLE_DRIVE_ACCESS_TOKEN, "") + val expiry = sp.getLong(PREF_GOOGLE_DRIVE_TOKEN_EXPIRY, 0) + + // If token still has more than 5 minutes of validity, use directly + if (cachedToken.isNotEmpty() && System.currentTimeMillis() < expiry - 300_000) { + return@withContext cachedToken + } + + val refreshToken = sp.getString(PREF_GOOGLE_DRIVE_REFRESH_TOKEN, "") + if (refreshToken.isEmpty()) { + aapsLogger.warn(LTag.CORE, "$LOG_PREFIX Missing refresh token when refreshing access token") + showConnectionError(rh.gs(R.string.cloud_token_expired_or_invalid)) + return@withContext null + } + + val requestBody = FormBody.Builder() + .add("client_id", CLIENT_ID) + .add("grant_type", "refresh_token") + .add("refresh_token", refreshToken) + .build() + + val request = Request.Builder() + .url(TOKEN_URL) + .post(requestBody) + .build() + + val response = client.newCall(request).execute() + val responseBody = response.body?.string() ?: "" + + if (response.isSuccessful) { + val jsonResponse = JSONObject(responseBody) + val newAccessToken = jsonResponse.optString("access_token") + val expiresIn = jsonResponse.optLong("expires_in", 3600) + + if (newAccessToken.isNotEmpty()) { + sp.putString(PREF_GOOGLE_DRIVE_ACCESS_TOKEN, newAccessToken) + sp.putLong(PREF_GOOGLE_DRIVE_TOKEN_EXPIRY, System.currentTimeMillis() + expiresIn * 1000) + clearConnectionError() + return@withContext newAccessToken + } + } else { + aapsLogger.error(LTag.CORE, "$LOG_PREFIX Failed to refresh access token: code=${response.code} body=${responseBody.take(200)}") + // Check if token is expired or revoked + handleApiError(response.code, responseBody, rh.gs(R.string.google_drive_token_refresh_failed)) + } + + null + } catch (e: Exception) { + aapsLogger.error(LTag.CORE, "$LOG_PREFIX Error refreshing access token", e) + showConnectionError(rh.gs(R.string.google_drive_token_refresh_error, e.message ?: "")) + null + } + } + + /** + * Test Google Drive connection + */ + suspend fun testConnection(): Boolean { + return withContext(Dispatchers.IO) { + try { + val accessToken = getValidAccessToken() + if (accessToken == null) { + // Error already shown in getValidAccessToken + return@withContext false + } + val request = Request.Builder() + .url("$DRIVE_API_URL/about?fields=user") + .header("Authorization", "Bearer $accessToken") + .build() + val response = client.newCall(request).execute() + if (response.isSuccessful) { + clearConnectionError() + return@withContext true + } else { + // Check if token expired/revoked + handleApiError(response.code, "", rh.gs(R.string.google_drive_connection_test_failed)) + return@withContext false + } + } catch (e: Exception) { + aapsLogger.error(LTag.CORE, "$LOG_PREFIX Error testing Google Drive connection", e) + showConnectionError(rh.gs(R.string.google_drive_connect_error, e.message ?: "")) + false + } + } + } + + /** + * List folders in Google Drive + */ + suspend fun listFolders(parentId: String = "root"): List { + return withContext(Dispatchers.IO) { + try { + val accessToken = getValidAccessToken() + if (accessToken == null) { + // Error already shown in getValidAccessToken + return@withContext emptyList() + } + val url = "$DRIVE_API_URL/files?q=mimeType='application/vnd.google-apps.folder' and '$parentId' in parents and trashed=false&fields=files(id,name)&supportsAllDrives=true&includeItemsFromAllDrives=true" + val request = Request.Builder() + .url(url) + .header("Authorization", "Bearer $accessToken") + .build() + val response = client.newCall(request).execute() + val responseBody = response.body?.string() ?: "" + if (response.isSuccessful) { + clearConnectionError() + val jsonResponse = JSONObject(responseBody) + val files = jsonResponse.getJSONArray("files") + val folders = mutableListOf() + for (i in 0 until files.length()) { + val file = files.getJSONObject(i) + folders.add(DriveFolder(id = file.getString("id"), name = file.getString("name"))) + } + return@withContext folders + } else { + aapsLogger.error(LTag.CORE, "$LOG_PREFIX List folders failed: ${response.code} ${response.message} body=${responseBody}") + // Check if token expired/revoked + handleApiError(response.code, responseBody, rh.gs(R.string.google_drive_list_folders_failed)) + return@withContext emptyList() + } + } catch (e: Exception) { + aapsLogger.error(LTag.CORE, "$LOG_PREFIX Error listing Google Drive folders", e) + showConnectionError(rh.gs(R.string.google_drive_list_folders_error, e.message ?: "")) + emptyList() + } + } + } + + /** + * Create folder + */ + suspend fun createFolder(name: String, parentId: String = "root"): String? { + return withContext(Dispatchers.IO) { + try { + val accessToken = getValidAccessToken() ?: return@withContext null + aapsLogger.debug(LTag.CORE, "$LOG_PREFIX GDRIVE creating folder: name='$name' parentId=$parentId") + val metadata = JSONObject().apply { + put("name", name) + put("mimeType", "application/vnd.google-apps.folder") + put("parents", JSONArray().put(parentId)) + } + + val requestBody = metadata.toString().toRequestBody("application/json".toMediaType()) + + val request = Request.Builder() + .url("$DRIVE_API_URL/files?fields=id&supportsAllDrives=true") + .header("Authorization", "Bearer $accessToken") + .post(requestBody) + .build() + + val response = client.newCall(request).execute() + val responseBody = response.body?.string() ?: "" + + if (response.isSuccessful) { + clearConnectionError() + val jsonResponse = JSONObject(responseBody) + val id = jsonResponse.optString("id").takeIf { it.isNotEmpty() } + if (id == null) { + aapsLogger.error(LTag.CORE, "$LOG_PREFIX GDRIVE Create folder missing id for name='$name' under parent=$parentId body=$responseBody") + }else{ + aapsLogger.info(LTag.CORE, "$LOG_PREFIX FOLDER_CREATE_OK name='$name' parent=$parentId id=$id") + } + return@withContext id + } else { + aapsLogger.error(LTag.CORE, "$LOG_PREFIX GDRIVE Failed to create folder: $responseBody") + return@withContext null + } + } catch (e: Exception) { + aapsLogger.error(LTag.CORE, "$LOG_PREFIX GDRIVE Error creating folder", e) + null + } + } + } + + /** + * Upload file to Google Drive (multipart/related) + */ + suspend fun uploadFile(fileName: String, fileContent: ByteArray, mimeType: String = "application/octet-stream"): String? { + return withContext(Dispatchers.IO) { + try { + val accessToken = getValidAccessToken() + if (accessToken == null) { + // Error already shown in getValidAccessToken + return@withContext null + } + debugCurrentUser(accessToken) + // Resolve target folder: always prefer CloudConstants by file type; fallback to selected folder, then root + val inferredPath = inferCloudPathFor(fileName) + val folderId = resolveFolderIdForUpload(inferredPath) ?: return@withContext null + if (inferredPath != null) { + aapsLogger.info(LTag.CORE, "$LOG_PREFIX UPLOAD_START pathHint='$inferredPath' usingFolderId=$folderId file=$fileName size=${fileContent.size} mimeHint=$mimeType") + } else { + aapsLogger.info(LTag.CORE, "$LOG_PREFIX UPLOAD_START noPathHint usingFolderId=$folderId file=$fileName size=${fileContent.size} mimeHint=$mimeType") + } + + // Metadata JSON body with its own Content-Type + val metadataJson = JSONObject().apply { + put("name", fileName) + put("parents", JSONArray().put(folderId)) + }.toString() + val metadataBody = metadataJson.toRequestBody("application/json; charset=UTF-8".toMediaType()) + + // File body with its own Content-Type (guess when needed) + val effectiveMime = guessMimeType(fileName, mimeType) + if (effectiveMime != mimeType) aapsLogger.info(LTag.CORE, "$LOG_PREFIX MIME_ADJUST original=$mimeType effective=$effectiveMime file=$fileName") + val mediaBody = fileContent.toRequestBody(effectiveMime.toMediaType()) + + val multipart = MultipartBody.Builder() + .setType("multipart/related".toMediaType()) + // OkHttp automatically generates Content-Type header for each part based on RequestBody.contentType(); + // Manually adding Content-Type will trigger IllegalArgumentException: Unexpected header: Content-Type. + .addPart(metadataBody) + .addPart(mediaBody) + .build() + + val request = Request.Builder() + .url("$UPLOAD_URL/files?uploadType=multipart&fields=id&supportsAllDrives=true") + .header("Authorization", "Bearer $accessToken") + .post(multipart) + .build() + + val response = client.newCall(request).execute() + val responseBodyStr = response.body?.string() ?: "" + aapsLogger.info(LTag.CORE, "$LOG_PREFIX UPLOAD_RESPONSE code=${response.code} message='${response.message}' hasBody=${responseBodyStr.isNotEmpty()} folderId=$folderId file=$fileName") + if (responseBodyStr.isNotEmpty()) aapsLogger.info(LTag.CORE, "$LOG_PREFIX UPLOAD_RESPONSE_BODY ${responseBodyStr.take(500)}") + + if (response.isSuccessful) { + val jsonResponse = JSONObject(responseBodyStr.ifEmpty { "{}" }) + val id = jsonResponse.optString("id").takeIf { it.isNotEmpty() } + if (id == null) { + aapsLogger.error(LTag.CORE, "$LOG_PREFIX UPLOAD_NO_ID folderId=$folderId file=$fileName rawBody='${responseBodyStr.take(200)}'") + showConnectionError(rh.gs(R.string.google_drive_upload_no_id)) + return@withContext null + } + // Post-upload verification + val verified = verifyFileExists(id, accessToken) + return@withContext if (verified) { + clearConnectionError() + aapsLogger.info(LTag.CORE, "$LOG_PREFIX UPLOAD_OK id=$id file=$fileName folderId=$folderId") + logFilePathChain(id, accessToken, "UPLOAD_OK_CHAIN") + debugListFolderSnapshot(folderId, accessToken, label = "AFTER_UPLOAD") + id + } else { + aapsLogger.error(LTag.CORE, "$LOG_PREFIX UPLOAD_VERIFY_FAIL id=$id file=$fileName folderId=$folderId") + showConnectionError(rh.gs(R.string.google_drive_upload_verify_failed)) + null + } + } else { + aapsLogger.error(LTag.CORE, "$LOG_PREFIX UPLOAD_FAIL code=${response.code} message='${response.message}' folderId=$folderId body=${responseBodyStr.take(300)}") + showConnectionError(rh.gs(R.string.google_drive_upload_failed, response.code.toString())) + null + } + } catch (e: Exception) { + aapsLogger.error(LTag.CORE, "$LOG_PREFIX EXCEPTION uploadFile file=$fileName", e) + showConnectionError(rh.gs(R.string.google_drive_upload_error, e.message ?: "")) + null + } + } + } + + /** + * Infer default cloud path based on filename (used when no folder is selected). + */ + private fun inferCloudPathFor(fileName: String): String? { + val lower = fileName.lowercase(Locale.getDefault()) + return when { + lower.endsWith(".json") -> CloudConstants.CLOUD_PATH_SETTINGS + lower.endsWith(".csv") -> CloudConstants.CLOUD_PATH_USER_ENTRIES + lower.endsWith(".zip") -> CloudConstants.CLOUD_PATH_LOGS + else -> null + } + } + + /** Ensure path always starts with AAPS/ */ + private fun normalizeAapsPath(path: String?): String? { + if (path.isNullOrBlank()) return path + val trimmed = path.trim('/', ' ') + return if (trimmed.startsWith("AAPS/")) trimmed else "AAPS/$trimmed" + } + + /** + * Ensure specified cloud path exists; if creation fails, send notification and return null. + */ + private suspend fun ensureCloudPathOrError(path: String): String? { + val normalized = normalizeAapsPath(path) ?: path + if (normalized != path) aapsLogger.info(LTag.CORE, "$LOG_PREFIX ENSURE_PATH_NORMALIZE original='$path' normalized='$normalized'") + val id = getOrCreateFolderPath(normalized) + if (id.isNullOrEmpty()) { + aapsLogger.error(LTag.CORE, "$LOG_PREFIX Unable to ensure cloud path '$normalized'") + showConnectionError(rh.gs(R.string.google_drive_folder_access_error, normalized)) + return null + } + return id + } + + private suspend fun resolveFolderIdForUpload(pathHint: String?): String? { + if (!pathHint.isNullOrBlank()) { + val ensured = ensureCloudPathOrError(pathHint) + if (ensured != null) { + val stored = getSelectedFolderId() + if (stored != ensured) { + aapsLogger.info(LTag.CORE, "$LOG_PREFIX FOLDER_RESOLVE_UPDATE path='$pathHint' newId=$ensured oldId=${stored?.ifEmpty { "" } ?: ""}") + setSelectedFolderId(ensured) + } else { + aapsLogger.info(LTag.CORE, "$LOG_PREFIX FOLDER_RESOLVE_REUSE path='$pathHint' id=$ensured") + } + } else { + aapsLogger.error(LTag.CORE, "$LOG_PREFIX FOLDER_RESOLVE_FAILED path='$pathHint'") + } + return ensured + } + + val stored = getSelectedFolderId()?.ifEmpty { null } + if (stored != null) { + aapsLogger.info(LTag.CORE, "$LOG_PREFIX FOLDER_RESOLVE_USE_STORED storedId=$stored pathHint=''") + return stored + } + + aapsLogger.info(LTag.CORE, "$LOG_PREFIX FOLDER_RESOLVE_DEFAULT_ROOT") + return "root" + } + + /** + * Set selected folder ID + */ + fun setSelectedFolderId(folderId: String) { + aapsLogger.info(LTag.CORE, "$LOG_PREFIX SET_SELECTED_FOLDER folderId=$folderId") + sp.putString(PREF_GOOGLE_DRIVE_FOLDER_ID, folderId) + } + + /** + * Get selected folder ID + */ + fun getSelectedFolderId(): String { + val folderId = sp.getString(PREF_GOOGLE_DRIVE_FOLDER_ID, "") + aapsLogger.info(LTag.CORE, "$LOG_PREFIX GET_SELECTED_FOLDER folderId='$folderId'") + return folderId + } + + /** + * Clear Google Drive related settings + */ + fun clearGoogleDriveSettings() { + sp.remove(PREF_GOOGLE_DRIVE_REFRESH_TOKEN) + sp.remove(PREF_GOOGLE_DRIVE_ACCESS_TOKEN) + sp.remove(PREF_GOOGLE_DRIVE_TOKEN_EXPIRY) + sp.remove(PREF_GOOGLE_DRIVE_FOLDER_ID) + sp.remove("google_drive_code_verifier") + } + + /** + * Show connection error notification + */ + private fun showConnectionError(message: String) { + connectionError = true + val notificationId = NOTIFICATION_GOOGLE_DRIVE_ERROR + errorNotificationId = notificationId + + val notification = Notification( + notificationId, + message, + Notification.URGENT, + 60 + ) + rxBus.send(EventNewNotification(notification)) + // Notify UI to update cloud storage error state immediately + rxBus.send(EventCloudStorageStatusChanged()) + } + + /** + * Check if response indicates token expired or revoked + */ + private fun isTokenExpiredOrRevoked(responseCode: Int, responseBody: String = ""): Boolean { + return responseCode == 401 || responseCode == 403 || + responseBody.contains("invalid_grant") || + responseBody.contains("Token has been expired or revoked") + } + + /** + * Handle API error response and show appropriate error message + * @param responseCode HTTP response code + * @param responseBody Response body for additional error detection + * @param fallbackMessage Message to show when error is not token-related + */ + private fun handleApiError(responseCode: Int, responseBody: String = "", fallbackMessage: String) { + if (isTokenExpiredOrRevoked(responseCode, responseBody)) { + showConnectionError(rh.gs(R.string.cloud_token_expired_or_invalid)) + } else { + showConnectionError(fallbackMessage) + } + } + + + + /** + * Generate code verifier + */ + private fun generateCodeVerifier(): String { + val bytes = ByteArray(32) + SecureRandom().nextBytes(bytes) + return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes) + } + + /** + * Generate code challenge + */ + private fun generateCodeChallenge(codeVerifier: String): String { + val bytes = codeVerifier.toByteArray(Charsets.US_ASCII) + val messageDigest = MessageDigest.getInstance("SHA-256") + val digest = messageDigest.digest(bytes) + return Base64.getUrlEncoder().withoutPadding().encodeToString(digest) + } + + /** + * Build authorization URL + */ + private fun buildAuthUrl(codeChallenge: String): String { + val state = UUID.randomUUID().toString() + sp.putString("google_drive_oauth_state", state) + + return Uri.parse(AUTH_URL).buildUpon() + .appendQueryParameter("client_id", CLIENT_ID) + .appendQueryParameter("redirect_uri", REDIRECT_URI) + .appendQueryParameter("response_type", "code") + .appendQueryParameter("scope", SCOPE) + .appendQueryParameter("code_challenge", codeChallenge) + .appendQueryParameter("code_challenge_method", "S256") + .appendQueryParameter("state", state) + .appendQueryParameter("access_type", "offline") + .appendQueryParameter("prompt", "consent") + .build() + .toString() + } + + /** + * Start local HTTP server to receive OAuth callback + */ + private fun startLocalServer() { + try { + // Stop existing server + stopLocalServer() + + // Create new server + localServer = ServerSocket(REDIRECT_PORT) + localServer?.soTimeout = 1000 // Set timeout to avoid permanent blocking + + // Start server to handle requests + serverJob = CoroutineScope(Dispatchers.IO).launch { + try { + aapsLogger.debug(LTag.CORE, "$LOG_PREFIX Local OAuth server started on port $REDIRECT_PORT") + + val server = localServer ?: return@launch + while (isActive && !server.isClosed) { + try { + val clientSocket = try { server.accept() } catch (toe: java.net.SocketTimeoutException) { null } + clientSocket?.let { socket -> + launch { handleHttpRequest(socket) } + } + } catch (e: Exception) { + if (!server.isClosed) { + aapsLogger.error(LTag.CORE, "$LOG_PREFIX Error accepting connection", e) + } + } + } + } catch (e: Exception) { + val server = localServer + if (server != null && !server.isClosed) { + aapsLogger.error(LTag.CORE, "$LOG_PREFIX Server error", e) + } + } + } + } catch (e: Exception) { + aapsLogger.error(LTag.CORE, "$LOG_PREFIX Failed to start local OAuth server", e) + throw e + } + } + + /** + * Stop local HTTP server + */ + private fun stopLocalServer() { + try { + serverJob?.cancel() + localServer?.close() + localServer = null + aapsLogger.debug(LTag.CORE, "$LOG_PREFIX Local OAuth server stopped") + } catch (e: Exception) { + aapsLogger.error(LTag.CORE, "$LOG_PREFIX Error stopping server", e) + } + } + + /** + * Handle HTTP request + */ + private suspend fun handleHttpRequest(socket: Socket) { + try { + val input = socket.getInputStream().bufferedReader() + val output = socket.getOutputStream() + + // Read HTTP request + val requestLine = input.readLine() + if (requestLine == null) { + socket.close() + return + } + + aapsLogger.debug(LTag.CORE, "$LOG_PREFIX HTTP Request: $requestLine") + + // Parse request path + val parts = requestLine.split(" ") + if (parts.size >= 2) { + val path = parts[1] + if (path.startsWith("/oauth/callback")) { + handleOAuthCallback(path, output) + } else { + sendHttpResponse(output, 404, "Not Found") + } + } else { + sendHttpResponse(output, 400, "Bad Request") + } + + socket.close() + } catch (e: Exception) { + aapsLogger.error(LTag.CORE, "$LOG_PREFIX Error handling HTTP request", e) + try { + socket.close() + } catch (ignored: Exception) {} + } + } + + /** + * Handle OAuth callback + */ + private suspend fun handleOAuthCallback(path: String, output: OutputStream) { + try { + // Parse query parameters + val queryIndex = path.indexOf('?') + val params = if (queryIndex >= 0) { + parseQueryString(path.substring(queryIndex + 1)) + } else { + emptyMap() + } + + val code = params["code"] + val state = params["state"] + val error = params["error"] + + aapsLogger.debug(LTag.CORE, "$LOG_PREFIX OAuth callback received - code: ${code != null}, state: $state, error: $error") + + if (error != null) { + sendHttpResponse(output, 400, "OAuth error: $error") + return + } + + if (code != null && state != null) { + // Verify state + val savedState = sp.getString("google_drive_oauth_state", "") + if (state == savedState) { + authCodeReceived = code + authState = state + sendHttpResponse(output, 200, "Authorization successful! You can close this window.") + } else { + sendHttpResponse(output, 400, "Invalid state parameter") + } + } else { + sendHttpResponse(output, 400, "Missing code or state parameter") + } + } catch (e: Exception) { + aapsLogger.error(LTag.CORE, "$LOG_PREFIX Error handling OAuth callback", e) + sendHttpResponse(output, 500, "Internal server error") + } finally { + // Delay closing server + CoroutineScope(Dispatchers.IO).launch { + delay(2000) // Wait 2 seconds for response to complete + stopLocalServer() + } + } + } + + /** + * Parse query string + */ + private fun parseQueryString(query: String?): Map { + if (query.isNullOrEmpty()) return emptyMap() + + return query.split("&").associate { param -> + val keyValue = param.split("=", limit = 2) + val key = keyValue[0] + val value = if (keyValue.size > 1) keyValue[1] else "" + key to value + } + } + + /** + * Send HTTP response + */ + private fun sendHttpResponse(output: OutputStream, statusCode: Int, message: String) { + try { + val statusText = when (statusCode) { + 200 -> "OK" + 400 -> "Bad Request" + 404 -> "Not Found" + 500 -> "Internal Server Error" + else -> "Unknown" + } + val autoCloseScript = if (statusCode == 200) """ + + """ else "" + val metaRefresh = if (statusCode == 200) "" else "" + val className = if (statusCode == 200) "success" else "error" + val htmlContent = """ + + + + AAPS Google Drive Authorization + + $metaRefresh + + + +

AAPS Google Drive Authorization

+

$message

+ $autoCloseScript + + + """.trimIndent() + val response = "HTTP/1.1 $statusCode $statusText\r\n" + + "Content-Type: text/html; charset=UTF-8\r\n" + + "Content-Length: ${htmlContent.toByteArray().size}\r\n" + + "Connection: close\r\n" + + "\r\n" + + htmlContent + output.write(response.toByteArray()) + output.flush() + } catch (e: Exception) { + aapsLogger.error(LTag.CORE, "$LOG_PREFIX Error sending HTTP response", e) + } + } + + /** + * Wait and get authorization code + */ + suspend fun waitForAuthCode(timeoutMs: Long = 60000): String? { + return withContext(Dispatchers.IO) { + val startTime = System.currentTimeMillis() + + while (authCodeReceived == null && System.currentTimeMillis() - startTime < timeoutMs) { + delay(500) + } + + val code = authCodeReceived + authCodeReceived = null // Clear used authorization code + authState = null + + code + } + } + + /** + * Check if there is a connection error + */ + fun hasConnectionError(): Boolean { + val storageType = sp.getString(PREF_GOOGLE_DRIVE_STORAGE_TYPE, STORAGE_TYPE_LOCAL) + return storageType == STORAGE_TYPE_GOOGLE_DRIVE && connectionError + } + + /** + * Clear connection error state + */ + fun clearConnectionError() { + connectionError = false + errorNotificationId?.let { id -> + // TODO: Implement logic to clear notification + } + errorNotificationId = null + } + + /** + * List settings files (json) in current folder + */ + suspend fun listSettingsFiles(): List = withContext(Dispatchers.IO) { + try { + val accessToken = getValidAccessToken() ?: return@withContext emptyList() + val folderId = getSelectedFolderId()?.ifEmpty { "root" } ?: "root" + val query = "'$folderId' in parents and trashed=false" + val encodedQuery = URLEncoder.encode(query, "UTF-8") + val url = "$DRIVE_API_URL/files?q=$encodedQuery&fields=files(id,name,modifiedTime,mimeType)&pageSize=50&supportsAllDrives=true&includeItemsFromAllDrives=true" + aapsLogger.info(LTag.CORE, "$LOG_PREFIX LIST_SETTINGS_FILES_START folderId=$folderId query='$query' encodedQuery=$encodedQuery") + val request = Request.Builder() + .url(url) + .header("Authorization", "Bearer $accessToken") + .build() + val response = client.newCall(request).execute() + val body = response.body?.string() ?: "" + if (!response.isSuccessful) { + aapsLogger.error(LTag.CORE, "$LOG_PREFIX FOLDER_LIST_FAIL folderId=$folderId code=${response.code} body=${body.take(300)}") + showConnectionError(rh.gs(R.string.google_drive_list_settings_failed)) + return@withContext emptyList() + } + clearConnectionError() + aapsLogger.info(LTag.CORE, "$LOG_PREFIX LIST_SETTINGS_FILES_OK count=${body.length} folderId=$folderId") + val json = JSONObject(body) + val arr = json.optJSONArray("files") ?: JSONArray() + val result = mutableListOf() + for (i in 0 until arr.length()) { + val f = arr.getJSONObject(i) + val mimeType = f.optString("mimeType", "") + // Filter out folders + if (mimeType != "application/vnd.google-apps.folder") { + result.add(DriveFile(id = f.getString("id"), name = f.getString("name"))) + } + } + result + } catch (e: Exception) { + aapsLogger.error(LTag.CORE, "$LOG_PREFIX Error listing settings files", e) + showConnectionError(rh.gs(R.string.google_drive_list_settings_error, e.message ?: "")) + emptyList() + } + } + + /** + * Download file content + */ + suspend fun downloadFile(fileId: String): ByteArray? = withContext(Dispatchers.IO) { + try { + val accessToken = getValidAccessToken() ?: return@withContext null + val request = Request.Builder() + .url("$DRIVE_API_URL/files/$fileId?alt=media") + .header("Authorization", "Bearer $accessToken") + .build() + val response = client.newCall(request).execute() + if (!response.isSuccessful) { + val msg = response.body?.string() + aapsLogger.error(LTag.CORE, "$LOG_PREFIX Failed to download file: ${msg}") + showConnectionError(rh.gs(R.string.google_drive_download_failed)) + return@withContext null + } + clearConnectionError() + response.body?.bytes() + } catch (e: Exception) { + aapsLogger.error(LTag.CORE, "$LOG_PREFIX Error downloading file", e) + showConnectionError(rh.gs(R.string.google_drive_download_error, e.message ?: "")) + null + } + } + + /** + * List settings files (json) in current folder with pagination + */ + suspend fun listSettingsFilesPaged(pageToken: String? = null, pageSize: Int = 10): DriveFilePage = withContext(Dispatchers.IO) { + try { + val accessToken = getValidAccessToken() ?: return@withContext DriveFilePage(emptyList(), null) + val folderId = getSelectedFolderId().ifEmpty { "root" } + aapsLogger.info(LTag.CORE, "$LOG_PREFIX LIST_SETTINGS_PAGED folderId='$folderId' pageToken=$pageToken pageSize=$pageSize") + val query = "'$folderId' in parents and trashed=false" + val encodedQuery = URLEncoder.encode(query, "UTF-8") + val base = StringBuilder() + .append("$DRIVE_API_URL/files?q=").append(encodedQuery) + .append("&fields=files(id,name,modifiedTime,mimeType),nextPageToken") + .append("&pageSize=").append(pageSize) + .append("&supportsAllDrives=true&includeItemsFromAllDrives=true") + if (!pageToken.isNullOrEmpty()) base.append("&pageToken=").append(pageToken) + val request = Request.Builder() + .url(base.toString()) + .header("Authorization", "Bearer $accessToken") + .build() + val response = client.newCall(request).execute() + val body = response.body?.string() ?: "" + if (!response.isSuccessful) { + aapsLogger.error(LTag.CORE, "$LOG_PREFIX Failed to list settings files (paged): $body") + showConnectionError(rh.gs(R.string.google_drive_list_settings_failed)) + return@withContext DriveFilePage(emptyList(), null) + } + clearConnectionError() + val json = JSONObject(body) + val arr = json.optJSONArray("files") ?: JSONArray() + val result = mutableListOf() + for (i in 0 until arr.length()) { + val f = arr.getJSONObject(i) + val mimeType = f.optString("mimeType", "") + // Filter out folders + if (mimeType != "application/vnd.google-apps.folder") { + result.add(DriveFile(id = f.getString("id"), name = f.getString("name"))) + } + } + val next = json.optString("nextPageToken", "").ifEmpty { null } + DriveFilePage(result, next) + } catch (e: Exception) { + aapsLogger.error(LTag.CORE, "$LOG_PREFIX Error listing settings files (paged)", e) + showConnectionError(rh.gs(R.string.google_drive_list_settings_error, e.message ?: "")) + DriveFilePage(emptyList(), null) + } + } + + /** + * Count total settings files matching yyyy-MM-dd_HHmmss*.json pattern in current folder + */ + suspend fun countSettingsFiles(): Int = withContext(Dispatchers.IO) { + try { + val accessToken = getValidAccessToken() ?: return@withContext 0 + val folderId = getSelectedFolderId().ifEmpty { "root" } + aapsLogger.info(LTag.CORE, "$LOG_PREFIX COUNT_SETTINGS_FILES folderId='$folderId'") + val query = "'$folderId' in parents and trashed=false" + val encodedQuery = URLEncoder.encode(query, "UTF-8") + val namePattern = Regex("^\\d{4}-\\d{2}-\\d{2}_\\d{6}.*\\.json$", RegexOption.IGNORE_CASE) + + var totalCount = 0 + var pageToken: String? = null + + do { + val base = StringBuilder() + .append("$DRIVE_API_URL/files?q=").append(encodedQuery) + .append("&fields=files(name,mimeType),nextPageToken") + .append("&pageSize=100") // Use larger page size for counting + .append("&supportsAllDrives=true&includeItemsFromAllDrives=true") + if (!pageToken.isNullOrEmpty()) base.append("&pageToken=").append(pageToken) + + val request = Request.Builder() + .url(base.toString()) + .header("Authorization", "Bearer $accessToken") + .build() + val response = client.newCall(request).execute() + val body = response.body?.string() ?: "" + + if (!response.isSuccessful) { + aapsLogger.error(LTag.CORE, "$LOG_PREFIX Failed to count settings files: $body") + return@withContext 0 + } + + val json = JSONObject(body) + val arr = json.optJSONArray("files") ?: JSONArray() + + for (i in 0 until arr.length()) { + val f = arr.getJSONObject(i) + val mimeType = f.optString("mimeType", "") + val name = f.optString("name", "") + // Filter out folders and count only matching files + if (mimeType != "application/vnd.google-apps.folder" && namePattern.containsMatchIn(name)) { + totalCount++ + } + } + + pageToken = json.optString("nextPageToken", "").ifEmpty { null } + } while (pageToken != null) + + totalCount + } catch (e: Exception) { + aapsLogger.error(LTag.CORE, "$LOG_PREFIX Error counting settings files", e) + 0 + } + } + + /** + * Get or create multi-level folders (expressed as path), return final folder ID. + * All paths are automatically prefixed with "AAPS/" if not already present. + * Example: path = "export/preferences" -> creates "AAPS/export/preferences" + * + * Special handling for "AAPS" folder: Always reuses existing AAPS folder if one exists in root. + */ + suspend fun getOrCreateFolderPath(path: String, baseParentId: String = "root"): String? = withContext(Dispatchers.IO) { + try { + // Normalize path to always start with AAPS/ + val normalizedPath = normalizeAapsPath(path) ?: return@withContext null + var trimmed = normalizedPath.trim('/',' ') + + val segments = trimmed.split('/').filter { it.isNotBlank() } + var currentParentId = baseParentId + val accumulated = mutableListOf() + + for ((index, seg) in segments.withIndex()) { + accumulated.add(seg) + val currentPath = accumulated.joinToString("/") + val parentPath = accumulated.dropLast(1).joinToString("/") + val cachedId = pathCache[currentPath] + if (cachedId != null) { + aapsLogger.info(LTag.CORE, "$LOG_PREFIX FOLDER_SEGMENT_CACHE_HIT level=${index + 1} path='/${currentPath}' id=$cachedId") + currentParentId = cachedId + continue + } + + val parentDisplay = if (parentPath.isEmpty()) "/" else "/$parentPath" + aapsLogger.info(LTag.CORE, "$LOG_PREFIX FOLDER_SEGMENT_CHECK level=${index + 1} name='$seg' parentPath='$parentDisplay' parentId=$currentParentId fullPath='/$currentPath'") + + val existingId = findFolderIdByName(seg, currentParentId) + + val resolvedId = existingId ?: createFolder(seg, currentParentId) ?: run { + aapsLogger.error(LTag.CORE, "$LOG_PREFIX FOLDER_SEGMENT_CREATE_FAIL name='$seg' parentPath='$parentDisplay' parentId=$currentParentId requested='/$currentPath'") + return@withContext null + } + + if (existingId != null) { + aapsLogger.info(LTag.CORE, "$LOG_PREFIX FOLDER_SEGMENT_EXIST level=${index + 1} name='$seg' id=$resolvedId fullPath='/$currentPath'") + } else { + aapsLogger.info(LTag.CORE, "$LOG_PREFIX FOLDER_SEGMENT_CREATED level=${index + 1} name='$seg' id=$resolvedId fullPath='/$currentPath'") + } + + pathCache[currentPath] = resolvedId + currentParentId = resolvedId + } + + aapsLogger.info(LTag.CORE, "$LOG_PREFIX FOLDER_PATH_READY path='$path' finalId=$currentParentId") + pathCache[trimmed] = currentParentId + currentParentId + } catch (e: Exception) { + aapsLogger.error(LTag.CORE, "$LOG_PREFIX Error ensuring folder path $path", e) + null + } + } + + /** + * Find subfolder by name under specified parent, return its ID if exists; otherwise return null. + */ + private suspend fun findFolderIdByName(name: String, parentId: String): String? = withContext(Dispatchers.IO) { + try { + val accessToken = getValidAccessToken() ?: return@withContext null + val query = "mimeType='application/vnd.google-apps.folder' and name='$name' and '$parentId' in parents and trashed=false" + val encodedQuery = URLEncoder.encode(query, "UTF-8") + val url = "$DRIVE_API_URL/files?q=$encodedQuery&fields=files(id,name)&pageSize=1&supportsAllDrives=true&includeItemsFromAllDrives=true" + val request = Request.Builder() + .url(url) + .header("Authorization", "Bearer $accessToken") + .build() + val response = client.newCall(request).execute() + val body = response.body?.string() ?: "" + if (!response.isSuccessful) return@withContext null + val json = JSONObject(body) + val arr = json.optJSONArray("files") ?: JSONArray() + if (arr.length() == 0) return@withContext null + arr.getJSONObject(0).getString("id") + } catch (_: Exception) { + null + } + } + + /** + * Upload file to specified cloud path (automatically creates folders). + */ + suspend fun uploadFileToPath(fileName: String, fileContent: ByteArray, mimeType: String, path: String): String? { + return withContext(Dispatchers.IO) { + try { + aapsLogger.info(LTag.CORE, "$LOG_PREFIX UPLOAD_PATH_REQUESTED path='$path' file=$fileName size=${fileContent.size}") + val folderId = resolveFolderIdForUpload(path) ?: run { + aapsLogger.error(LTag.CORE, "$LOG_PREFIX Cannot resolve target path '$path'") + showConnectionError("Cannot create destination path") + return@withContext null + } + val accessToken = getValidAccessToken() ?: return@withContext null + try { + debugCurrentUser(accessToken) + } catch (e: Exception) { + aapsLogger.error(LTag.CORE, "$LOG_PREFIX DEBUG_USER_FAILED (non-critical)", e) + } + aapsLogger.info(LTag.CORE, "$LOG_PREFIX UPLOAD_PATH_START pathHint='$path' folderId=$folderId file=$fileName size=${fileContent.size} mimeHint=$mimeType") + + val metadataJson = JSONObject().apply { + put("name", fileName) + put("parents", JSONArray().put(folderId)) + }.toString() + val metadataBody = metadataJson.toRequestBody("application/json; charset=UTF-8".toMediaType()) + val effectiveMime = guessMimeType(fileName, mimeType) + if (effectiveMime != mimeType) aapsLogger.info(LTag.CORE, "$LOG_PREFIX MIME_ADJUST original=$mimeType effective=$effectiveMime file=$fileName") + val mediaBody = fileContent.toRequestBody(effectiveMime.toMediaType()) + + val multipart = MultipartBody.Builder() + .setType("multipart/related".toMediaType()) + // Same as above, remove custom Content-Type header, let OkHttp automatically add based on body. + .addPart(metadataBody) + .addPart(mediaBody) + .build() + + val request = Request.Builder() + .url("$UPLOAD_URL/files?uploadType=multipart&fields=id&supportsAllDrives=true") + .header("Authorization", "Bearer $accessToken") + .post(multipart) + .build() + + val response = client.newCall(request).execute() + val responseBodyStr = response.body?.string() ?: "" + aapsLogger.info(LTag.CORE, "$LOG_PREFIX UPLOAD_PATH_RESPONSE code=${response.code} message='${response.message}' hasBody=${responseBodyStr.isNotEmpty()} path='$path' file=$fileName") + if (responseBodyStr.isNotEmpty()) aapsLogger.info(LTag.CORE, "$LOG_PREFIX UPLOAD_PATH_RESPONSE_BODY ${responseBodyStr.take(500)}") + if (response.isSuccessful) { + val jsonResponse = JSONObject(responseBodyStr.ifEmpty { "{}" }) + val id = jsonResponse.optString("id").takeIf { it.isNotEmpty() } + if (id == null) { + aapsLogger.error(LTag.CORE, "$LOG_PREFIX UPLOAD_PATH_NO_ID path='$path' file=$fileName rawBody='${responseBodyStr.take(200)}'") + showConnectionError("Upload succeeded but no id returned") + return@withContext null + } + val verified = verifyFileExists(id, accessToken) + return@withContext if (verified) { + clearConnectionError() + aapsLogger.info(LTag.CORE, "$LOG_PREFIX UPLOAD_PATH_OK id=$id path='$path' file=$fileName") + logFilePathChain(id, accessToken, "UPLOAD_PATH_OK_CHAIN") + debugListFolderSnapshot(folderId, accessToken, label = "AFTER_UPLOAD_PATH") + id + } else { + aapsLogger.error(LTag.CORE, "$LOG_PREFIX UPLOAD_PATH_VERIFY_FAIL id=$id path='$path' file=$fileName") + showConnectionError("Upload verification failed") + null + } + } else { + aapsLogger.error(LTag.CORE, "$LOG_PREFIX UPLOAD_PATH_FAIL path='$path' code=${response.code} message='${response.message}' body=${responseBodyStr.take(300)}") + showConnectionError("Upload failed: ${response.code}") + null + } + } catch (e: Exception) { + aapsLogger.error(LTag.CORE, "$LOG_PREFIX EXCEPTION uploadFileToPath path='$path' file=$fileName", e) + showConnectionError("Error uploading file: ${e.message}") + null + } + } + } + + /** + * Infer MIME type for common file extensions, avoid using imprecise octet-stream. + */ + private fun guessMimeType(fileName: String, provided: String?): String { + val prov = provided?.trim().orEmpty() + if (prov.isNotEmpty() && prov != "application/octet-stream") return prov + val lower = fileName.lowercase(Locale.getDefault()) + return when { + lower.endsWith(".json") -> "application/json; charset=UTF-8" + lower.endsWith(".csv") -> "text/csv; charset=UTF-8" + lower.endsWith(".zip") -> "application/zip" + else -> if (prov.isNotEmpty()) prov else "application/octet-stream" + } + } + + /** + * Verify if file actually exists (immediately call Drive API to retrieve metadata). + * If retrieval fails or file is marked as trashed, verification is considered failed. + */ + private fun verifyFileExists(fileId: String, accessToken: String): Boolean { + return try { + val req = Request.Builder() + .url("$DRIVE_API_URL/files/$fileId?fields=id,name,parents,mimeType,trashed,webViewLink,createdTime,modifiedTime,size&supportsAllDrives=true") + .header("Authorization", "Bearer $accessToken") + .build() + client.newCall(req).execute().use { resp -> + val bodyStr = resp.body?.string() ?: "" + if (!resp.isSuccessful) { + aapsLogger.error(LTag.CORE, "$LOG_PREFIX VERIFY_FAIL id=$fileId code=${resp.code} body='${bodyStr.take(300)}'") + return false + } + val json = JSONObject(bodyStr) + val trashed = json.optBoolean("trashed", false) + if (trashed) { + aapsLogger.error(LTag.CORE, "$LOG_PREFIX VERIFY_FAIL_TRASHED id=$fileId json='${bodyStr.take(200)}'") + return false + } + aapsLogger.info(LTag.CORE, "$LOG_PREFIX VERIFY_OK id=$fileId name=${json.optString("name")} parents=${json.optJSONArray("parents")?.toString() ?: "[]"} mime=${json.optString("mimeType")} size=${json.optLong("size", -1)} webViewLink=${json.optString("webViewLink")} created=${json.optString("createdTime")} modified=${json.optString("modifiedTime")}") + true + } + } catch (e: Exception) { + aapsLogger.error(LTag.CORE, "$LOG_PREFIX VERIFY_EXCEPTION id=$fileId", e) + false + } + } + + private fun debugCurrentUser(accessToken: String) { + try { + val req = Request.Builder() + .url("$DRIVE_API_URL/about?fields=user(emailAddress,displayName)&supportsAllDrives=true") + .header("Authorization", "Bearer $accessToken") + .build() + client.newCall(req).execute().use { resp -> + val body = resp.body?.string() ?: "" + if (resp.isSuccessful) { + runCatching { JSONObject(body).optJSONObject("user") }.getOrNull()?.let { u -> + aapsLogger.info(LTag.CORE, "$LOG_PREFIX USER email=${u.optString("emailAddress")} display=${u.optString("displayName")}") + } + } else { + aapsLogger.info(LTag.CORE, "$LOG_PREFIX USER_FAIL code=${resp.code}") + } + } + } catch (e: Exception) { + aapsLogger.error(LTag.CORE, "$LOG_PREFIX USER_EXCEPTION", e) + } + } + + private fun debugListFolderSnapshot(folderId: String, accessToken: String, label: String) { + try { + val query = "'$folderId' in parents and trashed=false" + val url = "$DRIVE_API_URL/files?q=${Uri.encode(query)}&fields=files(id,name,mimeType,modifiedTime),nextPageToken&pageSize=20&supportsAllDrives=true&includeItemsFromAllDrives=true" + val req = Request.Builder().url(url).header("Authorization", "Bearer $accessToken").build() + client.newCall(req).execute().use { resp -> + val body = resp.body?.string() ?: "" + if (!resp.isSuccessful) { + aapsLogger.debug(LTag.CORE, "$LOG_PREFIX FOLDER_LIST_FAIL_DEBUG (non-critical) folderId=$folderId code=${resp.code}") + return + } + val json = JSONObject(body) + val arr = json.optJSONArray("files") ?: JSONArray() + val summary = StringBuilder() + for (i in 0 until arr.length()) { + val f = arr.getJSONObject(i) + summary.append(f.optString("name")).append('(').append(f.optString("id")).append(") ") + } + aapsLogger.info(LTag.CORE, "$LOG_PREFIX FOLDER_SNAPSHOT label=$label folderId=$folderId items=${arr.length()} list='${summary.toString().trim()}'") + } + } catch (e: Exception) { + aapsLogger.debug(LTag.CORE, "$LOG_PREFIX FOLDER_SNAPSHOT_EXCEPTION (non-critical) folderId=$folderId: ${e.message}") + } + } + + private fun logFilePathChain(fileId: String, accessToken: String, tag: String) { + try { + val chain = mutableListOf() + var currentId: String? = fileId + var safety = 0 + var reachedRoot = false + var abort = false + while (currentId != null && safety++ < 12 && !abort) { + val req = Request.Builder() + .url("$DRIVE_API_URL/files/$currentId?fields=id,name,parents&supportsAllDrives=true") + .header("Authorization", "Bearer $accessToken") + .build() + client.newCall(req).execute().use { resp -> + val body = resp.body?.string() ?: "" + if (!resp.isSuccessful) { + aapsLogger.debug(LTag.CORE, "$LOG_PREFIX PATH_CHAIN_FAIL_DEBUG (non-critical) id=$currentId code=${resp.code} partial='${chain.joinToString("/")}'") + abort = true + } + if (!abort) { + val json = JSONObject(body) + chain.add(json.optString("name")) + val parentsArr = json.optJSONArray("parents") + currentId = if (parentsArr != null && parentsArr.length() > 0) parentsArr.getString(0) else null + if (currentId == "root") { + chain.add("root") + reachedRoot = true + } + } + } + if (reachedRoot) break + } + val status = if (reachedRoot) "COMPLETE" else if (abort) "ABORT" else "PARTIAL" + aapsLogger.info(LTag.CORE, "$LOG_PREFIX $tag status=$status chain='${chain.joinToString("/")}' depth=${chain.size}") + } catch (e: Exception) { + aapsLogger.debug(LTag.CORE, "$LOG_PREFIX PATH_CHAIN_EXCEPTION (non-critical) fileId=$fileId: ${e.message}") + } + } +} + +/** + * Google Drive folder data class + */ +data class DriveFolder( + val id: String, + val name: String +) + +/** Google Drive file data class */ +data class DriveFile( + val id: String, + val name: String +) + +/** Paginated result data class */ +data class DriveFilePage( + val files: List, + val nextPageToken: String?, + val totalCount: Int? = null // Optional total count of files matching pattern +) diff --git a/plugins/configuration/src/main/kotlin/app/aaps/plugins/configuration/maintenance/cloud/providers/googledrive/GoogleDriveProvider.kt b/plugins/configuration/src/main/kotlin/app/aaps/plugins/configuration/maintenance/cloud/providers/googledrive/GoogleDriveProvider.kt new file mode 100644 index 00000000000..ee3bfed5442 --- /dev/null +++ b/plugins/configuration/src/main/kotlin/app/aaps/plugins/configuration/maintenance/cloud/providers/googledrive/GoogleDriveProvider.kt @@ -0,0 +1,199 @@ +package app.aaps.plugins.configuration.maintenance.cloud.providers.googledrive + +import android.content.Context +import app.aaps.core.interfaces.logging.AAPSLogger +import app.aaps.core.interfaces.logging.LTag +import app.aaps.core.interfaces.resources.ResourceHelper +import app.aaps.core.interfaces.rx.bus.RxBus +import app.aaps.core.interfaces.sharedPreferences.SP +import app.aaps.plugins.configuration.R +import app.aaps.plugins.configuration.maintenance.cloud.CloudConstants +import app.aaps.plugins.configuration.maintenance.cloud.CloudFile +import app.aaps.plugins.configuration.maintenance.cloud.CloudFileListResult +import app.aaps.plugins.configuration.maintenance.cloud.CloudFolder +import app.aaps.plugins.configuration.maintenance.cloud.CloudStorageProvider +import app.aaps.plugins.configuration.maintenance.cloud.StorageTypes +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Google Drive implementation of CloudStorageProvider. + * + * This class adapts GoogleDriveManager to the CloudStorageProvider interface, + * enabling the unified cloud storage architecture. + */ +@Singleton +class GoogleDriveProvider @Inject constructor( + private val aapsLogger: AAPSLogger, + private val rh: ResourceHelper, + private val sp: SP, + private val rxBus: RxBus, + private val context: Context, + private val googleDriveManager: GoogleDriveManager +) : CloudStorageProvider { + + companion object { + private const val LOG_PREFIX = "[GoogleDriveProvider]" + } + + private fun gLog(message: String) { + aapsLogger.info(LTag.CORE, "$LOG_PREFIX $message") + } + + // ==================== Provider Identity ==================== + + override val storageType: String = StorageTypes.GOOGLE_DRIVE + + override val displayName: String + get() = rh.gs(R.string.storage_google_drive) + + override val iconResId: Int = R.drawable.ic_google_drive + + // ==================== Authentication ==================== + + override suspend fun startAuth(): String? { + gLog("startAuth") + return googleDriveManager.startPKCEAuth() + } + + override suspend fun completeAuth(authCode: String): Boolean { + gLog("completeAuth") + return googleDriveManager.exchangeCodeForTokens(authCode) + } + + override fun hasValidCredentials(): Boolean { + return googleDriveManager.hasValidRefreshToken() + } + + override fun clearCredentials() { + gLog("clearCredentials") + googleDriveManager.clearGoogleDriveSettings() + } + + override suspend fun getValidAccessToken(): String? { + return googleDriveManager.getValidAccessToken() + } + + // ==================== Connection ==================== + + override suspend fun testConnection(): Boolean { + gLog("testConnection") + return googleDriveManager.testConnection() + } + + override fun hasConnectionError(): Boolean { + return googleDriveManager.hasConnectionError() + } + + override fun clearConnectionError() { + googleDriveManager.clearConnectionError() + } + + // ==================== Folder Operations ==================== + + override suspend fun getOrCreateFolderPath(path: String): String? { + gLog("getOrCreateFolderPath: $path") + return googleDriveManager.getOrCreateFolderPath(path) + } + + override suspend fun createFolder(name: String, parentId: String): String? { + gLog("createFolder: $name under $parentId") + return googleDriveManager.createFolder(name, parentId) + } + + override suspend fun listFolders(parentId: String): List { + gLog("listFolders: $parentId") + return googleDriveManager.listFolders(parentId).map { driveFolder -> + CloudFolder( + id = driveFolder.id, + name = driveFolder.name + ) + } + } + + // ==================== File Operations ==================== + + override suspend fun uploadFileToPath( + fileName: String, + content: ByteArray, + mimeType: String, + path: String + ): String? { + gLog("uploadFileToPath: $fileName to $path") + return googleDriveManager.uploadFileToPath(fileName, content, mimeType, path) + } + + override suspend fun uploadFile( + fileName: String, + content: ByteArray, + mimeType: String + ): String? { + gLog("uploadFile: $fileName") + return googleDriveManager.uploadFile(fileName, content, mimeType) + } + + override suspend fun downloadFile(fileId: String): ByteArray? { + gLog("downloadFile: $fileId") + return googleDriveManager.downloadFile(fileId) + } + + override suspend fun listSettingsFiles( + pageSize: Int, + pageToken: String? + ): CloudFileListResult { + gLog("listSettingsFiles: pageSize=$pageSize") + val result = googleDriveManager.listSettingsFilesPaged(pageToken, pageSize) + + return CloudFileListResult( + files = result.files.map { driveFile -> + CloudFile( + id = driveFile.id, + name = driveFile.name, + mimeType = guessMimeType(driveFile.name) + ) + }, + nextPageToken = result.nextPageToken, + totalCount = result.totalCount ?: -1 + ) + } + + // ==================== Selected Folder ==================== + + override fun getSelectedFolderId(): String { + return googleDriveManager.getSelectedFolderId() + } + + override fun setSelectedFolderId(folderId: String) { + gLog("setSelectedFolderId: $folderId") + googleDriveManager.setSelectedFolderId(folderId) + } + + // ==================== OAuth Helpers ==================== + + /** + * Wait for OAuth authorization code (used during PKCE auth flow) + */ + override suspend fun waitForAuthCode(timeoutMs: Long): String? { + return googleDriveManager.waitForAuthCode(timeoutMs) + } + + /** + * Count settings files in the selected folder + */ + override suspend fun countSettingsFiles(): Int { + return googleDriveManager.countSettingsFiles() + } + + /** + * Guess MIME type based on file extension + */ + private fun guessMimeType(fileName: String): String { + val lower = fileName.lowercase() + return when { + lower.endsWith(".json") -> "application/json" + lower.endsWith(".csv") -> "text/csv" + lower.endsWith(".zip") -> "application/zip" + else -> "application/octet-stream" + } + } +} diff --git a/plugins/configuration/src/main/res/drawable/ic_cloud_upload.xml b/plugins/configuration/src/main/res/drawable/ic_cloud_upload.xml new file mode 100644 index 00000000000..b9896d7810b --- /dev/null +++ b/plugins/configuration/src/main/res/drawable/ic_cloud_upload.xml @@ -0,0 +1,10 @@ + + + + diff --git a/plugins/configuration/src/main/res/drawable/ic_error.xml b/plugins/configuration/src/main/res/drawable/ic_error.xml new file mode 100644 index 00000000000..c9307610a20 --- /dev/null +++ b/plugins/configuration/src/main/res/drawable/ic_error.xml @@ -0,0 +1,9 @@ + + + diff --git a/plugins/configuration/src/main/res/drawable/ic_export_options.xml b/plugins/configuration/src/main/res/drawable/ic_export_options.xml new file mode 100644 index 00000000000..4a43f3ac0a9 --- /dev/null +++ b/plugins/configuration/src/main/res/drawable/ic_export_options.xml @@ -0,0 +1,13 @@ + + + diff --git a/plugins/configuration/src/main/res/drawable/ic_google_drive.xml b/plugins/configuration/src/main/res/drawable/ic_google_drive.xml new file mode 100644 index 00000000000..d92d05ad9d2 --- /dev/null +++ b/plugins/configuration/src/main/res/drawable/ic_google_drive.xml @@ -0,0 +1,13 @@ + + + diff --git a/plugins/configuration/src/main/res/layout/dialog_cloud_directory.xml b/plugins/configuration/src/main/res/layout/dialog_cloud_directory.xml new file mode 100644 index 00000000000..f37e89d26de --- /dev/null +++ b/plugins/configuration/src/main/res/layout/dialog_cloud_directory.xml @@ -0,0 +1,86 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/plugins/configuration/src/main/res/layout/dialog_export_options.xml b/plugins/configuration/src/main/res/layout/dialog_export_options.xml new file mode 100644 index 00000000000..d6af83da40b --- /dev/null +++ b/plugins/configuration/src/main/res/layout/dialog_export_options.xml @@ -0,0 +1,211 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/plugins/configuration/src/main/res/layout/maintenance_fragment.xml b/plugins/configuration/src/main/res/layout/maintenance_fragment.xml index 0321e6108cf..6433bf446f6 100644 --- a/plugins/configuration/src/main/res/layout/maintenance_fragment.xml +++ b/plugins/configuration/src/main/res/layout/maintenance_fragment.xml @@ -133,20 +133,97 @@ app:columnCount="2"> + + + + + + + + + + + + + + + + + + + app:layout_row="2" /> + app:layout_row="2" /> diff --git a/plugins/configuration/src/main/res/layout/maintenance_import_list_activity.xml b/plugins/configuration/src/main/res/layout/maintenance_import_list_activity.xml index ce7701a5192..172f369dcfe 100644 --- a/plugins/configuration/src/main/res/layout/maintenance_import_list_activity.xml +++ b/plugins/configuration/src/main/res/layout/maintenance_import_list_activity.xml @@ -1,4 +1,4 @@ - + + + android:scrollbars="vertical" /> - +