diff --git a/.gitignore b/.gitignore index 57b17320cc3..401f82742fe 100644 --- a/.gitignore +++ b/.gitignore @@ -32,4 +32,4 @@ wear/pumpcontrol/* *.preferences_pb /buildSrc/.kotlin .claude/settings.local.json -/CLAUDE.md +.claude/CLAUDE_COMMANDS.md diff --git a/_docs/icons/ic_none.svg b/_docs/icons/ic_none.svg index 8c8ef23e3d2..30f64ebe610 100644 --- a/_docs/icons/ic_none.svg +++ b/_docs/icons/ic_none.svg @@ -1,13 +1,15 @@ - - - - - - - - + height="24px" viewBox="0 0 24 24" enable-background="new 0 0 24 24" xml:space="preserve"> + + diff --git a/app/src/benchmark/kotlin/app/aaps/utils/LeakCanaryConfig.kt b/app/src/benchmark/kotlin/app/aaps/utils/LeakCanaryConfig.kt new file mode 100644 index 00000000000..488c7631ceb --- /dev/null +++ b/app/src/benchmark/kotlin/app/aaps/utils/LeakCanaryConfig.kt @@ -0,0 +1,11 @@ +package app.aaps.utils + +import app.aaps.core.interfaces.utils.fabric.FabricPrivacy + +@Suppress("UNUSED_PARAMETER") +fun configureLeakCanary( + isEnabled: Boolean = false, + fabricPrivacy: FabricPrivacy? = null +) { + // no-op for benchmark variant +} 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"> Сортировать элементы Единицы Ошибка авторизации - Катетер помпы + Канюля Идентификация не задана в режиме разработчика Удалить выбранные элементы diff --git a/app/src/main/res/values-vi-rVN/strings.xml b/app/src/main/res/values-vi-rVN/strings.xml index f6278ed1656..507cf41df53 100644 --- a/app/src/main/res/values-vi-rVN/strings.xml +++ b/app/src/main/res/values-vi-rVN/strings.xml @@ -1,26 +1,26 @@ - %1$s Tùy chọn + Tùy Chọn %1$s Thoát Độ lệch Lưu Wear Giới thiệu - Khi bật tính năng Autosense, hãy nhớ nhập đầy đủ tất cả lượng carb đã ăn. Nếu không, sự lệch carb sẽ bị nhận diện sai thành thay đổi độ nhạy insulin!! + Khi dùng Autosense, cần nhập đủ lượng carb đã ăn. Nếu không, hệ thống sẽ hiểu sai lệch carb thành thay đổi độ nhạy insulin KHÔNG HỢP LỆ Tăng dần âm lượng cho cảnh báo và thông báo Cảnh báo cục bộ - Cảnh báo khi thiếu dữ liệu BG + Cảnh báo khi mất dữ liệu ĐH Cảnh báo khi bơm mất kết nối Ngưỡng mất kết nối bơm [phút] - Cảnh báo khi cần carb + Cảnh báo khi cần bổ sung carb Mở điều hướng Đóng điều hướng Xóa mục Sắp xếp các mục Đơn vị Xác thực không thành công - Cannula + Kim luồn Chưa thiết lập nhận dạng ở chế độ phát triển Xóa mục đã chọn diff --git a/app/src/main/res/xml/network_security_config.xml b/app/src/main/res/xml/network_security_config.xml new file mode 100644 index 00000000000..6da0e74aee8 --- /dev/null +++ b/app/src/main/res/xml/network_security_config.xml @@ -0,0 +1,7 @@ + + + + localhost + 127.0.0.1 + + diff --git a/buildSrc/src/main/kotlin/Versions.kt b/buildSrc/src/main/kotlin/Versions.kt index 1a244ae5ed1..d20423b908e 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.4.0.0" + const val appVersion = "3.4.1.0" const val versionCode = 1500 const val compileSdk = 36 diff --git a/buildSrc/src/main/kotlin/android-app-dependencies.gradle.kts b/buildSrc/src/main/kotlin/android-app-dependencies.gradle.kts index f8619831472..04121028f77 100644 --- a/buildSrc/src/main/kotlin/android-app-dependencies.gradle.kts +++ b/buildSrc/src/main/kotlin/android-app-dependencies.gradle.kts @@ -42,6 +42,10 @@ android { targetCompatibility = Versions.javaVersion } + kotlinOptions { + freeCompilerArgs = freeCompilerArgs + "-opt-in=kotlin.time.ExperimentalTime" + } + lint { checkReleaseBuilds = false disable += "MissingTranslation" diff --git a/buildSrc/src/main/kotlin/android-module-dependencies.gradle.kts b/buildSrc/src/main/kotlin/android-module-dependencies.gradle.kts index c95e2635364..941f23f6497 100644 --- a/buildSrc/src/main/kotlin/android-module-dependencies.gradle.kts +++ b/buildSrc/src/main/kotlin/android-module-dependencies.gradle.kts @@ -35,6 +35,10 @@ android { targetCompatibility = Versions.javaVersion } + kotlinOptions { + freeCompilerArgs = freeCompilerArgs + "-opt-in=kotlin.time.ExperimentalTime" + } + lint { checkReleaseBuilds = false disable += "MissingTranslation" diff --git a/core/data/src/main/kotlin/app/aaps/core/data/configuration/Constants.kt b/core/data/src/main/kotlin/app/aaps/core/data/configuration/Constants.kt index d524ad7426f..34564988f39 100644 --- a/core/data/src/main/kotlin/app/aaps/core/data/configuration/Constants.kt +++ b/core/data/src/main/kotlin/app/aaps/core/data/configuration/Constants.kt @@ -11,7 +11,7 @@ object Constants { const val notificationID = 556677 // OpenAPS algorithm - const val ALLOW_SMB_WITH_HIGH_TT = 100 + const val NORMAL_TARGET_MGDL = 99 // 5.5 mmol/l = 99.1 mg/dL; use 99 to ensure consistent behavior across mg/dL and mmol/l units // SMS COMMUNICATOR const val remoteBolusMinDistance = 15 * 60 * 1000L diff --git a/core/data/src/main/kotlin/app/aaps/core/data/model/RM.kt b/core/data/src/main/kotlin/app/aaps/core/data/model/RM.kt index ada5eb92293..6b0822374aa 100644 --- a/core/data/src/main/kotlin/app/aaps/core/data/model/RM.kt +++ b/core/data/src/main/kotlin/app/aaps/core/data/model/RM.kt @@ -63,6 +63,7 @@ data class RM( fun isClosedLoopOrLgs() = this == CLOSED_LOOP || this == CLOSED_LOOP_LGS fun isLoopRunning() = this == OPEN_LOOP || this == CLOSED_LOOP || this == CLOSED_LOOP_LGS fun isSuspended() = this == DISCONNECTED_PUMP || this == SUSPENDED_BY_PUMP || this == SUSPENDED_BY_USER || this == SUSPENDED_BY_DST || this == SUPER_BOLUS + fun isPumpSuspended() = this == DISCONNECTED_PUMP || this == SUSPENDED_BY_PUMP // DISABLED_LOOP is added to "mustBeTemporary" to be properly rendered in NS fun mustBeTemporary() = this == DISCONNECTED_PUMP || this == SUSPENDED_BY_PUMP || this == SUSPENDED_BY_USER || this == SUSPENDED_BY_DST || this == SUPER_BOLUS || this == DISABLED_LOOP diff --git a/core/data/src/main/kotlin/app/aaps/core/data/pump/defs/PumpType.kt b/core/data/src/main/kotlin/app/aaps/core/data/pump/defs/PumpType.kt index 2de1a2c3f2b..81d1d68283c 100644 --- a/core/data/src/main/kotlin/app/aaps/core/data/pump/defs/PumpType.kt +++ b/core/data/src/main/kotlin/app/aaps/core/data/pump/defs/PumpType.kt @@ -7,7 +7,7 @@ package app.aaps.core.data.pump.defs enum class PumpType( val description: String, private val manufacturer: ManufacturerType? = null, - private val model: String = "NONE", + val model: String = "NONE", private val bolusSize: Double = 0.0, private val specialBolusSize: DoseStepSize? = null, private val extendedBolusSettings: DoseSettings? = null, diff --git a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/pump/BlePreCheck.kt b/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/pump/BlePreCheck.kt index 93fccd2825c..8f5500f6b23 100644 --- a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/pump/BlePreCheck.kt +++ b/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/pump/BlePreCheck.kt @@ -4,5 +4,6 @@ import androidx.appcompat.app.AppCompatActivity interface BlePreCheck { - fun prerequisitesCheck(activity: AppCompatActivity): Boolean + fun prerequisitesCheck(activity: AppCompatActivity, additionalPermissions: List? = null): Boolean + } \ No newline at end of file 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-cs-rCZ/strings.xml b/core/interfaces/src/main/res/values-cs-rCZ/strings.xml index 7807bd88acc..a6df4ee3fda 100644 --- a/core/interfaces/src/main/res/values-cs-rCZ/strings.xml +++ b/core/interfaces/src/main/res/values-cs-rCZ/strings.xml @@ -2,6 +2,7 @@ Podávání %1$.2fU + %1$.2fU / %2$.2fU podáno Bolus %1$.2fU byl úspěšně aplikován Změněn ovladač pumpy. diff --git a/core/interfaces/src/main/res/values-es-rES/strings.xml b/core/interfaces/src/main/res/values-es-rES/strings.xml index 558b55ce9bc..14c06339da9 100644 --- a/core/interfaces/src/main/res/values-es-rES/strings.xml +++ b/core/interfaces/src/main/res/values-es-rES/strings.xml @@ -2,6 +2,7 @@ Entregando %1$.2fU + %1$.2fU / %2$.2fU entregado Bolo %1$.2fU entregado con éxito Controlador de bomba cambiado diff --git a/core/interfaces/src/main/res/values-it-rIT/strings.xml b/core/interfaces/src/main/res/values-it-rIT/strings.xml index a2b67247e65..4b98976f7ba 100644 --- a/core/interfaces/src/main/res/values-it-rIT/strings.xml +++ b/core/interfaces/src/main/res/values-it-rIT/strings.xml @@ -2,6 +2,7 @@ Erogazione di %1$.2fU + %1$.2f U / %2$.2f U erogate Bolo di %1$.2fU erogato con successo Driver micro cambiato. diff --git a/core/interfaces/src/main/res/values-nb-rNO/strings.xml b/core/interfaces/src/main/res/values-nb-rNO/strings.xml index dd8c6a90af5..7aad400fdb0 100644 --- a/core/interfaces/src/main/res/values-nb-rNO/strings.xml +++ b/core/interfaces/src/main/res/values-nb-rNO/strings.xml @@ -2,6 +2,7 @@ Leverer %1$.2f E + %1$.2f E / %2$.2f E levert Bolus på %1$.2f E ble levert Pumpedriver er endret. diff --git a/core/interfaces/src/main/res/values-nl-rNL/strings.xml b/core/interfaces/src/main/res/values-nl-rNL/strings.xml index d3a3e3e7b5f..edbf0b2ce51 100644 --- a/core/interfaces/src/main/res/values-nl-rNL/strings.xml +++ b/core/interfaces/src/main/res/values-nl-rNL/strings.xml @@ -2,6 +2,7 @@ Toedienen van %1$.2fE + %1$.2f E / %2$.2f E toegediend Bolus van %1$.2fE succesvol toegediend Pomp stuurprogramma gewijzigd. @@ -12,6 +13,18 @@ %1$.1fu geleden %1$.1f dagen geleden %1$.0f dagen geleden + + %1$s dag %2$s uur geleden + %1$s dagen %2$s uur geleden + + + %1$s uur geleden + %1$s uur geleden + + + een moment geleden + %1$s minuten geleden + seconden geleden over %1$.0f dagen binnen %1$.0f dagen diff --git a/core/interfaces/src/main/res/values-pl-rPL/strings.xml b/core/interfaces/src/main/res/values-pl-rPL/strings.xml index b377814aad3..139012ff9d8 100644 --- a/core/interfaces/src/main/res/values-pl-rPL/strings.xml +++ b/core/interfaces/src/main/res/values-pl-rPL/strings.xml @@ -1,12 +1,36 @@ + Dostarczam %1$.2fU + Bolus %1$.2fU dostarczony prawidłowo Zmieniono ster. pompy. + %1$dmin temu + %1$ds temu %1$d minut temu + %1$.1fh temu %1$.1f dni temu %1$.0f dni temu + + %1$s dzień %2$s godziny temu + %1$s dni %2$s godziny temu + %1$s dni %2$s godziny temu + %1$s dni %2$s godziny temu + + + %1$s godzinę temu + %1$s godziny temu + %1$s godzin temu + %1$s godzin temu + + + chwilę temu + %1$s minuty temu + %1$s minut temu + %1$s minut temu + + sekundy temu za %1$.0f dni za %1$.0f dni h @@ -32,6 +56,7 @@ Łączenie przez %1$d s Nawiązywanie połączenia Połączono + Zautoryzowano Rozłączanie Oczekiwanie na rozłączenie @@ -65,4 +90,8 @@ Domyślna tarcza zegarka, w tym zewnętrzne podglądy dla obserwujących. Możesz kliknąć na przycisk EKSPORT TARCZY aby wygenerować szablon Domyślna Tarcza Więcej tarcz zegarka + %1$.2f U/h @%2$s %3$d/%4$d\' + %1$.2f%% @%2$s %3$d/%4$d\' + E %1$.2f U/h @%2$s %3$d/%4$d min + Na zawsze diff --git a/core/interfaces/src/main/res/values-ru-rRU/strings.xml b/core/interfaces/src/main/res/values-ru-rRU/strings.xml index ff8835540e1..c627c40fc98 100644 --- a/core/interfaces/src/main/res/values-ru-rRU/strings.xml +++ b/core/interfaces/src/main/res/values-ru-rRU/strings.xml @@ -2,14 +2,15 @@ Подается %1$.2fед + Введено %1$.2fед / %2$.2fед Болюс %1$.2fед. введен успешно Драйвер помпы изменен. - %1$d мин. назад + %1$d мин назад %1$d сек назад %1$d минут назад - %1$.1fч. назад + %1$.1fч назад %1$s дн назад %1$s дн назад @@ -55,9 +56,9 @@ Связь установлена за %1$d сек Подтверждение связи - соединение установлено + Подключено Авторизовано - разъединение + Разъединение Ожидание разъединения Создано: %1$s @@ -91,7 +92,7 @@ Циферблат по умолчанию Еще циферблаты %1$.2f ед/ч @%2$s %3$d/%4$d\' - %1$.2f ед/ч @%2$s %3$d/%4$d\' + %1$.2f % @%2$s %3$d/%4$d\' E %1$.2f ед/ч @%2$s %3$d/%4$d мин Навсегда diff --git a/core/interfaces/src/main/res/values-sk-rSK/strings.xml b/core/interfaces/src/main/res/values-sk-rSK/strings.xml index 4452b164e52..15060751e7d 100644 --- a/core/interfaces/src/main/res/values-sk-rSK/strings.xml +++ b/core/interfaces/src/main/res/values-sk-rSK/strings.xml @@ -1,8 +1,9 @@ - Podávanie %1$.2fJI - Bolus %1$.2fJI podaný úspešne + Podávanie %1$.2fU + %1$.2fU / %2$.2fU podaných + Bolus %1$.2fU podaný úspešne Ovládač pumpy zmenený. @@ -90,8 +91,8 @@ Predvolený ciferník vrátane externých zobrazení pre sledovateľov, môžete kliknúť na tlačidlo EXPORTOVAŤ CIFERNÍK ak chcete vygenerovať šablónu Predvolený ciferník Ďalšie ciferníky - %1$.2f JI/h @%2$s %3$d/%4$d + %1$.2f U/h @%2$s %3$d/%4$d %1$.2f%% @%2$s %3$d/%4$d - E %1$.2f JI/h @%2$s %3$d/%4$d min + E %1$.2f U/h @%2$s %3$d/%4$d min Navždy diff --git a/core/interfaces/src/main/res/values-vi-rVN/strings.xml b/core/interfaces/src/main/res/values-vi-rVN/strings.xml index 96d43dcdd5d..e72cad1e0c7 100644 --- a/core/interfaces/src/main/res/values-vi-rVN/strings.xml +++ b/core/interfaces/src/main/res/values-vi-rVN/strings.xml @@ -2,6 +2,7 @@ Đang tiêm %1$.2f U + Đã truyền %1$.2fU trên %2$.2fU Đã tiêm Bolus thành công %1$.2f U Thay trình điều khiển Bơm. @@ -61,14 +62,14 @@ Hiển thị IOB Hiển thị chi tiết IOB Hiển thị COB - Hiển thị Delta - Hiển thị chi tiết Delta + Hiển thị độ lệch + Hiển thị chi tiết độ lệch Hiển thị AvgDelta Hiển thị mục tiêu tạm thời Hiển thị mức ống chứa Hiển thị pin điện thoại Hiển thị mức pin Rig - Hiển thị liều basal + Hiển thị liều nền Hiển thị trạng thái vòng lặp Hiển thị đường huyết Hiển thị BGI diff --git a/core/interfaces/src/main/res/values-zh-rTW/strings.xml b/core/interfaces/src/main/res/values-zh-rTW/strings.xml index 062ebc3796b..61716ba879e 100644 --- a/core/interfaces/src/main/res/values-zh-rTW/strings.xml +++ b/core/interfaces/src/main/res/values-zh-rTW/strings.xml @@ -2,6 +2,7 @@ 正在注射 %1$.2fU + %1$.2fU / %2$.2fU 已輸送 注射 %1$.2fU 已成功完成 幫浦驅動程式已更改。 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/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/objects/src/main/kotlin/app/aaps/core/objects/extensions/ExtendedBolusExtension.kt b/core/objects/src/main/kotlin/app/aaps/core/objects/extensions/ExtendedBolusExtension.kt index 2a7460f56a1..08c8dc5af26 100644 --- a/core/objects/src/main/kotlin/app/aaps/core/objects/extensions/ExtendedBolusExtension.kt +++ b/core/objects/src/main/kotlin/app/aaps/core/objects/extensions/ExtendedBolusExtension.kt @@ -1,5 +1,6 @@ package app.aaps.core.objects.extensions +import app.aaps.core.data.configuration.Constants import app.aaps.core.data.model.BS import app.aaps.core.data.model.EB import app.aaps.core.data.model.TB @@ -84,7 +85,7 @@ fun EB.iobCalc( val result = IobTotal(time) val realDuration = getPassedDurationToTimeInMinutes(time) var sensitivityRatio = lastAutosensResult.ratio - val normalTarget = 100.0 + val normalTarget = Constants.NORMAL_TARGET_MGDL.toDouble() if (exerciseMode && isTempTarget && profile.getTargetMgdl() >= normalTarget + 5) { // w/ target 100, temp target 110 = .89, 120 = 0.8, 140 = 0.67, 160 = .57, and 200 = .44 // e.g.: Sensitivity ratio set to 0.8 based on temp target of 120; Adjusting basal from 1.65 to 1.35; ISF from 58.9 to 73.6 diff --git a/core/objects/src/main/kotlin/app/aaps/core/objects/extensions/TemporaryBasalExtension.kt b/core/objects/src/main/kotlin/app/aaps/core/objects/extensions/TemporaryBasalExtension.kt index a96be727b04..e9c71dfea84 100644 --- a/core/objects/src/main/kotlin/app/aaps/core/objects/extensions/TemporaryBasalExtension.kt +++ b/core/objects/src/main/kotlin/app/aaps/core/objects/extensions/TemporaryBasalExtension.kt @@ -1,5 +1,6 @@ package app.aaps.core.objects.extensions +import app.aaps.core.data.configuration.Constants import app.aaps.core.data.model.BS import app.aaps.core.data.model.TB import app.aaps.core.data.time.T @@ -111,7 +112,7 @@ fun TB.iobCalc( val realDuration = getPassedDurationToTimeInMinutes(time) var netBasalAmount = 0.0 var sensitivityRatio = lastAutosensResult.ratio - val normalTarget = 100.0 + val normalTarget = Constants.NORMAL_TARGET_MGDL.toDouble() if (exerciseMode && isTempTarget && profile.getTargetMgdl() >= normalTarget + 5) { // w/ target 100, temp target 110 = .89, 120 = 0.8, 140 = 0.67, 160 = .57, and 200 = .44 // e.g.: Sensitivity ratio set to 0.8 based on temp target of 120; Adjusting basal from 1.65 to 1.35; ISF from 58.9 to 73.6 diff --git a/core/objects/src/main/res/drawable/ic_none.xml b/core/objects/src/main/res/drawable/ic_none.xml index 7bfd47e2359..1067ff74ccc 100644 --- a/core/objects/src/main/res/drawable/ic_none.xml +++ b/core/objects/src/main/res/drawable/ic_none.xml @@ -1,7 +1,5 @@ - - - + 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/core/ui/src/main/res/values-nb-rNO/strings.xml b/core/ui/src/main/res/values-nb-rNO/strings.xml index 7860395e002..a951011e1e8 100644 --- a/core/ui/src/main/res/values-nb-rNO/strings.xml +++ b/core/ui/src/main/res/values-nb-rNO/strings.xml @@ -157,7 +157,7 @@ Login Logg ut Prime/fylling - Rotasjon av stedsområder + Rotasjon av sted Insulin Avbryt midlertidig mål Lukket Loop diff --git a/core/ui/src/main/res/values-nl-rNL/strings.xml b/core/ui/src/main/res/values-nl-rNL/strings.xml index a6e98c7072e..66bed9d55fc 100644 --- a/core/ui/src/main/res/values-nl-rNL/strings.xml +++ b/core/ui/src/main/res/values-nl-rNL/strings.xml @@ -89,6 +89,7 @@ Alarm Exporteer instellingen Loop deactiveren + Loop uitgeschakeld Hervat loop Onderbreek loop Duur [min] @@ -102,6 +103,7 @@ Naam: Tijd WiFi SSID + Laden… Notities Verwijder Nieuwe toevoegen @@ -115,8 +117,10 @@ Tijdsduur g Pomp onderbreken + Pomp is actief Niet ingesteld Loop pauzeren + Loop onderbroken voor zomer/wintertijd (DST) Trendpijl Autosens nodig @@ -151,12 +155,17 @@ Basaal IOB Ongeldig Aanmelden + Uitloggen Prime/Vul + Site rotatie Insuline Stop tijdelijk doel Closed loop Open loop Stop bij laag + Pomp niet verbonden + Pomp is onderbroken + Modus teruggezet DIA KH-ratio ISF @@ -215,6 +224,8 @@ Bewegen : %1$s Infuus wissel CGM Sens. ingebracht + Pomp sites + CGM sites CGM Sens. Start CGM Sensor stoppen Hulphond waarschuwing @@ -230,6 +241,7 @@ OpenAPS Offline Pomp bat. wissel Tijdelijk streefdoel + Actieve modus Tijdelijk streefdoel Tijdelijk streefdoel annuleren Bolus wizard @@ -247,6 +259,39 @@ Loop NS Opnemen + Borst rechts + Borst links + Rechter bovenarm + Linker bovenarm + Rechter bovenarm achterzijde + Linker bovenarm achterzijde + Rechterbovenkant buik buitenzijde + Linker bovenkant buik buitenzijde + Rechter onderkant buik buitenzijde + Linker onderkant buik buitenzijde + Rechter bovenkant buik voorzijde + Linker bovenkant buik voorzijde + Rechter onderkant buik voorzijde + Linker onderkant buik voorzijde + Rechterbil + Linkerbil + Buitenzijde bovenbeen hoog, rechts + Buitenzijde bovenbeen hoog, links + Buitenzijde bovenbeen laag, rechts + Buitenzijde bovenbeen laag, links + Voorzijde bovenbeen hoog, rechts + Voorzijde bovenbeen hoog, links + Voorzijde bovenbeen laag, rechts + Voorzijde bovenbeen laag, links + Omhoog + Omhoog rechts + Rechts + Omlaag rechts + Omlaag + Omlaag links + Links + Omhoog links + Midden Connectie verlopen @@ -292,6 +337,8 @@ LGS LOOP MODUS OPEN LOOP MODUS LOOP UITGESCHAKELD + LOOP GEACTIVEERD + POMP ACTIEF VERBIND OPNIEW VERBINDING VERBREKEN HERVATTEN @@ -304,7 +351,9 @@ ANNULEER VERLENGDE BOLUS STOP TIJDELIJK DOEL CAREPORTAL + SENSOR LOCATIE INFUUSPLEK WIJZIGING + SITE LOCATIE RESERVOIRWISSEL KALIBREREN VUL BOLUS @@ -367,6 +416,9 @@ LOOP VERANDERD LOOP VERWIJDERD OVERIGE + BEDRIJFSMODE + BEDRIJFSMODE VERWIJDERD + BEDRIJFSMODE GEWIJZIGD Profiel laag doel Profiel hoog doel @@ -591,4 +643,5 @@ Basaal: %1$.2f%% (%2$.2f E/h)
Duur: %3$d min
]]>
Basaal: %1$.2f E/h (%2$.2f%%)
Duur: %3$d min
]]>
+ "AAPS speelt geluid"
diff --git a/core/ui/src/main/res/values-pl-rPL/strings.xml b/core/ui/src/main/res/values-pl-rPL/strings.xml index a6d05fc04ce..b27cdfd50d6 100644 --- a/core/ui/src/main/res/values-pl-rPL/strings.xml +++ b/core/ui/src/main/res/values-pl-rPL/strings.xml @@ -89,6 +89,7 @@ Alarm Eksport ustawień Wyłącz pętle + Pętla wyłączona Wznów pętlę Wstrzymaj pętle Czas trwania [min] @@ -102,6 +103,7 @@ Nazwa: Czas WiFi SSID + Ładowanie… Notatki Usuń Dodaj nowy @@ -115,8 +117,10 @@ Czas trwania g Pompa wstrzymana + Pompa uruchomiona Nie skonfigurowano Pętla wstrzymana + Pętla zawieszona przez zmianę czasu (na letni lub zimowy) Strzałka trendu Auto sens wym @@ -151,12 +155,17 @@ IOB z bazy NIEPRAWIDŁOWY Zaloguj się + Wyloguj się Rozpocznij/Wypełnij + Zmiana miejsca Insulina Zatrzymaj cel tymczasowy Zamknięta pętla Otwarta pętla Zawieszenie przy niskiej glikemii + Pompa odłączona + Pompa wstrzymana + Tryb przywrócony DIA IC ISF @@ -215,6 +224,8 @@ Ćwiczenie: %1$s Zmiana wkłucia Założenie sensora CGM + Miejsca pompy/wkłucia + Miejsca Sensora Uruchomienie sensora CGM Zatrzymanie sensora CGM Alarm D.A.D. @@ -230,6 +241,7 @@ OpenAPS Rozłączony (Offline) Zmiana baterii pompy Cel tymczasowy (TT) + Tryb pracy Wartość celu tymczasowego Odrzuć Cel tymczasowy Kalkulator bolusa @@ -247,6 +259,39 @@ Pętla NS Wpis + Prawa strona klatki + Lewa strona klatki + Górny bok prawego ramienia + Górny bok lewego ramienia + Górny tył prawego ramienia + Górny tył lewego ramienia + Górny prawy bok brzucha + Górny lewy bok brzucha + Dolny prawy bok brzucha + Dolny lewy bok brzucha + Górny prawy przód brzucha + Górny lewy przód brzucha + Dolny prawy przód brzucha + Dolny lewy przód brzucha + Prawy pośladek + Lewy pośladek + Górny bok prawego uda + Górny bok lewego uda + Dolny bok prawego uda + Dolny bok lewego uda + Górny przód prawego uda + Górny przód lewego uda + Dolny przód prawego uda + Dolny przód lewego uda + Góra + Góra - Prawo + Prawo + Dół - Prawo + Dół + Dół - Lewo + Lewo + Góra - Lewo + Środek Przekroczono limit czasu połączenia @@ -292,6 +337,8 @@ TRYB ZAWIESZENIA PRZY NISKIEJ GLIKEMI (LGS) TRYB OTWARTEJ PĘTLI PĘTLA WYŁĄCZONA + PĘTLA WZNOWIONA + POMPA DZIAŁA POŁĄCZ PONOWNIE ROZŁĄCZ WZNÓW @@ -304,7 +351,9 @@ ANULUJ ROZSZERZONY BOLUS ANULUJ CEL TYMCZASOWY PORTALOPIEKI + MIEJSCE SENSORA ZMIANA MIEJSCA WKŁUCIA + MIEJSCE WKŁUCIA ZMIANA ZBIORNIKA KALIBRACJA BOLUS NA WYPEŁNIENIE @@ -367,6 +416,9 @@ PĘTLA ZMIENIONA PĘTLA USUNIĘTA INNE + TRYB DZIAŁANIA + USUNIĘTO TRYB DZIAŁANIA + ZAKTUALIZOWANO TRYB DZIAŁANIA Dolna granica celu profilu Górna granica celu profilu @@ -407,6 +459,12 @@ %1$.0f%% Baza Baza % + Wrażliwość + ISF dla Kalkulatora i Absorpcji węglowodanów: %1$.1f + Wartość Autosens: %.0f%% + AS: %.0f%% + Autosens w algorytmie: %.0f%% + Alg: %.0f%% plik użytkownik @@ -591,4 +649,5 @@ Dawka: %1$.2f%% (%2$.2f U/h)
Czas: %3$d min
]]>
Dawka: %1$.2f U/h (%2$.2f%%)
Czas: %3$d min
]]>
+ "AAPS odtwarza dźwięk"
diff --git a/core/ui/src/main/res/values-sk-rSK/strings.xml b/core/ui/src/main/res/values-sk-rSK/strings.xml index 214cc79fd0e..3c336c09099 100644 --- a/core/ui/src/main/res/values-sk-rSK/strings.xml +++ b/core/ui/src/main/res/values-sk-rSK/strings.xml @@ -9,12 +9,12 @@ Celkový IOB: DC Pumpa nedostupná - JI - %1$.2f JI/h + U + %1$.2f U/h Pumpa nie je inicializovaná, profil nenastavený! Chyba pri aktualizovaní bazálneho profilu Nenačítaný žiadny platný bazál z pumpy - IOB obmedzený na %1$.1f JI: %2$s + IOB obmedzený na %1$.1f U: %2$s UZAVRETÝ OKRUH DEAKTIVOVANÝ OBMEDZENÍM Typ udalosti Znova načítať @@ -50,10 +50,10 @@ Pozastavené CDD celkom Staré dáta - Podávanie %1$.2f J inzulínu + Podávanie %1$.2f U inzulínu Čakám na pumpu AAPS spustený - %1$+.2f JI + %1$+.2f U %1$d g %1$.2f h %1$d min @@ -175,12 +175,12 @@ Aktívny inzulín (IOB) CIELE: VÝSLEDOK OAPS: - %1$.2f JI ( %2$.2f JI/h ) - %1$.2f JI/h (%2$.2fE) @%3$s - %1$.2f JI/h @%2$s + %1$.2f U ( %2$.2f U/h ) + %1$.2f U/h (%2$.2fE) @%3$s + %1$.2f U/h @%2$s %1$.0f%% @%2$s - E %1$.2f JI/h @%2$s %3$s/%4$s min - %1$.2f JI/h %2$s/%3$s\' + E %1$.2f U/h @%2$s %3$s/%4$s min + %1$.2f U/h %2$s/%3$s\' Exportovať nastavenia Povolenie na zisťovanie polohy nebolo udelené. Povoľte v nastaveniach systému Android. @@ -192,9 +192,9 @@ Bazálne hodnoty nie sú zarovnané na celé hodiny: %1$s Hodnota bazálu nahradená minimálnou možnou: %1$s Hodnota bazálu nahradená maximálnou možnou: %1$s - /JI - JI/h - g/JI + /U + U/h + g/U Spustiť profil %1$d%% na %2$d min @@ -435,7 +435,7 @@ »%1$s« %2$.2f je mimo pevne nastavených limitov Hodnota bazálu - BOLUS %1$.2f JI + BOLUS %1$.2f U SACHARIDY %1$d g PREDĹŽENÝ BOLUS %1$.2f U %2$d min Načítavanie udalostí @@ -446,10 +446,10 @@ NAČÍTAŤ CDD NASTAVIŤ PROFIL NASTAVIŤ POUŽ. NASTAVENIA - SMB BOLUS %1$.2f JI + SMB BOLUS %1$.2f U SPUSTIŤ PUMPU ZASTAVIŤ PUMPU - DOČAS. BAZÁL %1$.2f JI/h %2$d min + DOČAS. BAZÁL %1$.2f U/h %2$d min DOČAS. BAZÁL %1$d%% %2$d min INSIGHT NASTAVIŤ TBR CEZ OZNÁMENIE NAČÍTASTAV %1$s @@ -482,7 +482,7 @@ !!!!! Detekovaná pomalá absorbcia sacharidov: %2$d%% času. Prekontrolujte kalkuláciu. COB môže byť úplne iné, môže byť podaného viac inzulínu!!!!!]]> Podaj túto časť z výsledku kalkulácie [%] Časový limit starej glykémie [min] - Použité obmedzenie bolusu: %1$.2f JI na %2$.2f JI + Použité obmedzenie bolusu: %1$.2f U na %2$.2f U Bolus bude iba zaznamenaný (nie pumpou vydaný) Spustiť výstrahu, keď je čas na jedlo Žiadna akcia nevybraná, nič sa neudeje @@ -493,14 +493,14 @@ Neznáme COB! Chýbajú glykémie, alebo bola práve reštartovaná aplikácia? Sacharidy mimo povolený rozsah! Kalk (IC: %1$.1f, ISF: %2$.1f) - Sacharidy: %1$.2fJI - COB: %1$.0fg %2$.2fJI - Gly: %1$.2fJI - IOB: %1$.2fJI - Superbolus: %1$.2fJI - 15min trend: %1$.2fJI - Percentá: %1$.2fJI x %2$d%% ≈ %3$.2fJI - Inzulín mimo povolený rozsah!\nNie je možné podať %1$.2fJI + Sacharidy: %1$.2fU + COB: %1$.0fg %2$.2fU + Gly: %1$.2fU + IOB: %1$.2fU + Superbolus: %1$.2fU + 15min trend: %1$.2fU + Percentá: %1$.2fU x %2$d%% ≈ %3$.2fU + Inzulín mimo povolený rozsah!\nNie je možné podať %1$.2fU DC: %1$s %1$s do %2$s Pumpa nedostupná! @@ -529,7 +529,7 @@ Odpojené Pripájanie Kliknuté pripojiť k pumpe - %1$.0f / %2$d JI + %1$.0f / %2$d U Jednotiek za deň Ikona pumpy Zobraziť profil @@ -548,11 +548,11 @@ Inicializácia ... %1$d s - Max bazál obmedzený na %1$.2f JI/h: %2$s + Max bazál obmedzený na %1$.2f U/h: %2$s limit pumpy Bazál obmedzený na %1$d%%: %2$s požadovaná kladná hodnota - Bolus obmedzený na %1$.1f JI: %2$s + Bolus obmedzený na %1$.1f U: %2$s Potvrdenie Správa @@ -642,12 +642,12 @@ So Ne - %1$.1f JI - %1$.2f JI - Hodnota: %1$.2f%% (%2$.2f JI/h) Trvanie: %3$d min - Hodnota: %1$.2f JI/h (%2$.2f%%) Trvanie: %3$d min - Hodnota: %1$.2f%% (%2$.2f JI/h)
Trvanie: %3$d min
]]>
- Hodnota: %1$.2f JI/h (%2$.2f%%)
Trvanie: %3$d min
]]>
+ %1$.1f U + %1$.2f U + Hodnota: %1$.2f%% (%2$.2f U/h) Trvanie: %3$d min + Hodnota: %1$.2f U/h (%2$.2f%%) Trvanie: %3$d min + Hodnota: %1$.2f%% (%2$.2f U/h)
Trvanie: %3$d min
]]>
+ Hodnota: %1$.2f U/h (%2$.2f%%)
Trvanie: %3$d min
]]>
"AAPS prehráva zvuk"
diff --git a/core/ui/src/main/res/values-vi-rVN/protection.xml b/core/ui/src/main/res/values-vi-rVN/protection.xml index bb9e1b106f1..06ec333884e 100644 --- a/core/ui/src/main/res/values-vi-rVN/protection.xml +++ b/core/ui/src/main/res/values-vi-rVN/protection.xml @@ -11,14 +11,14 @@ Mã PIN ứng dụng Mật khẩu liều Bolus Mã PIN liều Bolus - Thời gian lưu giữ mật khẩu và mã PIN [giây] + Thời gian lưu mật khẩu và mã PIN [giây] Thời gian trước khi phải nhập mật khẩu hoặc mã PIN Sinh trắc học Mật khẩu tùy chọn Mã PIN tùy chọn Không bảo mật Phương án dự phòng không an toàn - Để bảo mật sinh trắc học hoạt động hiệu quả, cần phải đặt mật khẩu chính làm phương án dự phòng.\n\nVui lòng đặt mật khẩu chính! + Để dùng sinh trắc học, bạn cần thiết lập mật khẩu chính làm phương án dự phòng.\n\n Cài đặt mật khẩu chính ngay! Mật khẩu đã được đặt! Mật khẩu dùng cho Xuất dữ liệu đã được xóa! Mã PIN đã được đặt! diff --git a/core/ui/src/main/res/values-vi-rVN/strings.xml b/core/ui/src/main/res/values-vi-rVN/strings.xml index 2b6d4425182..bf30c32223b 100644 --- a/core/ui/src/main/res/values-vi-rVN/strings.xml +++ b/core/ui/src/main/res/values-vi-rVN/strings.xml @@ -1,9 +1,9 @@ - Làm tươi + Làm mới Lỗi - Đã cập nhật cấu hình Basal trong bơm + Đã cập nhật Hồ sơ liều nền trong bơm Dữ liệu đầu vào không hợp lệ Đã áp dụng giới hạn! Total IOB: @@ -11,8 +11,8 @@ Không thể kết nối với bơm U %1$.2f U/h - Bơm chưa được khởi tạo, cấu hình (profile) chưa được thiết lập! - Không thể cập nhật cấu hình basal + Bơm chưa được khởi tạo, Hồ sơ chưa được thiết lập! + Không thể cập nhật Hồ sơ liều nền Không đọc được liều basal hợp lệ từ bơm Giới hạn IOB: %1$.1f U – Lý do: %2$s. VÒNG LẶP BỊ TẮT DO GIỚI HẠN AN TOÀN @@ -21,11 +21,11 @@ mg/dl mmol/l Lưu - Nhắc lại - Virtual Pump + Nhắc sau + Bơm mô phỏng Giới hạn Superbolus - Pump đã tạm dừng + Bơm đã tạm dừng Người dùng Kết quả @@ -44,14 +44,14 @@ Im lặng Thành công Cài đặt nâng cao - Tiêm liều Bolus mở rộng thất bại + Tiêm liều Bolus kéo dài thất bại Chế độ APS - Liều Bolus mở rộng + Liều Bolus kéo dài Đã tạm dừng - TDD Total + Tổng TDD Dữ liệu cũ Đang tiêm %1$.2f U Insulin - Đang chờ phản hồi từ Pump + Đang chờ phản hồi từ Bơm AAPS đã khởi động %1$+.2f U %1$d g @@ -61,8 +61,8 @@ Vui lòng chờ… Dừng lại Carbs - Cấu hình không hợp lệ! - KHÔNG CÓ PROFILE + Hồ sơ không hợp lệ! + KHÔNG CÓ HỒ SƠ ]]> Ngày Đơn vị @@ -74,7 +74,7 @@ Thời gian hoạt động của Insulin Tỷ lệ Insulin/Carb (I:C) Độ nhạy Insulin (ISF) - Tỷ lệ liều Basal + Tỷ lệ liều nền Mục tiêu đường huyết g % @@ -82,7 +82,7 @@ Giám sát BT Tắt Bluetooth của điện thoại trong một giây nếu không thể kết nối với bơm. Điều này có thể hữu ích trên một số điện thoại khi ngăn xếp Bluetooth bị treo. ĐỒNG Ý - Đã cập nhật thời gian bơm insulin + Đã cập nhật thời gian bơm Thoát Xóa bản ghi này Vòng lặp bị vô hiệu hóa @@ -94,7 +94,7 @@ Tạm dừng vòng lặp Thời lượng [phút] Thông báo - Chưa tải cấu hình nào từ NS + Chưa tải Hồ sơ nào từ NS tồn tại không tồn tại Đường huyết @@ -117,7 +117,7 @@ Thời lượng g Bơm đã tạm dừng - Pump đang chạy + Bơm đang chạy Chưa được thiết lập Vòng lặp đã tạm dừng Vòng lặp bị tạm dừng do thay đổi múi giờ (DST) @@ -140,14 +140,14 @@ TIR ]]> XÓA - Kích hoạt cấu hình + Kích hoạt Hồ sơ đặt lại - Thiếu ProfileSwitch. Vui lòng thực hiện chuyển cấu hình (profile switch) hoặc nhấn ‘Kích hoạt cấu hình’ (Activate Profile) trong LocalProfile. - Cấu hình + Thiếu chuyển đổi hồ sơ. Vui lòng thực hiện chuyển đổi hồ sơ hoặc nhấn “Kích hoạt hồ sơ” trong Hồ sơ cục bộ. + Hồ sơ Chọn để xóa Bạn có chắc chắn muốn xóa %1$d mục không Điều trị - Tạo cấu hình mới từ cấu hình này không? + Tạo Hồ sơ mới từ Hồ sơ này không? Cài đặt Wizard Xu hướng 15 phút COB @@ -157,22 +157,22 @@ Đăng nhập Đăng xuất Prime/Fill - Chuyển vị trí tiêm + Chuyển vị trí Insulin Dừng mục tiêu tạm thời Vòng lặp kín Vòng lặp mở Tạm ngừng khi đường huyết thấp - Pump đã ngắt kết nối - Pump đã tạm ngưng + Bơm đã ngắt kết nối + Bơm đã tạm ngưng Chế độ đã được khôi phục DIA IC ISF - Hủy liều basal tạm thời thất bại - Hủy liều bolus mở rộng thất bại + Hủy liều nền tạm thời thất bại + Hủy liều bolus kéo dài thất bại Tải trạng thái bơm lên NS hoặc Tidepool - Insulin đang hoạt động (IOB) + Insulin còn hoạt động (IOB) MỤC TIÊU: KẾT QUẢ OAPS: %1$.2f U ( %2$.2f U/h ) @@ -189,23 +189,23 @@ Mật khẩu không khớp Mã PIN không khớp - Giá trị liều basal không khớp với giờ: %1$s - Giá trị basal đã được thay bằng giá trị tối thiểu được hỗ trợ: %1$s - Giá trị basal đã được thay bằng giá trị tối đa được hỗ trợ: %1$s + Giá trị liều nền không khớp với giờ: %1$s + Giá trị liều nền đã được thay bằng giá trị tối thiểu được hỗ trợ: %1$s + Giá trị liều nền đã được thay bằng giá trị tối đa được hỗ trợ: %1$s /U U/h g/U - Bắt đầu cấu hình %1$d%% trong %2$d phút + Bắt đầu Hồ sơ %1$d%% trong %2$d phút - Huỷ liều basal tạm - Cho basal tạm thời tiếp tục + Huỷ liều liều nền tạm thời + Cho liều nền tạm thời tiếp tục Tỷ lệ Thời lượng Lý do Không có thay đổi nào được yêu cầu - Cấu hình không hợp lệ: %1$s + Hồ sơ không hợp lệ: %1$s %1$d phút @@ -224,24 +224,24 @@ Bài tập luyện : %1$s Thay vị trí kim truyền Gắn cảm biến CGM - Các vị trí tiêm - Các vị trí CGM + Vị trí tiêm + Vị trí CGM Kích hoạt cảm biến CGM Dừng cảm biến CGM Cảnh báo D.A.D Thay ống chứa insulin - Chuyển cấu hình + Chuyển Hồ sơ Bolus bữa ăn nhẹ Bolus cho bữa ăn Bolus hiệu chỉnh Bolus kết hợp - Bắt đầu basal tạm thời - Kết thúc Basal tạm thời + Bắt đầu liều nền tạm thời + Kết thúc liều nền tạm thời Điều chỉnh Carbs OpenAPS Offline - Thay pin Pump + Thay pin Bơm Mục tiêu Tạm thời - Chế độ hoạt động + Chế độ đang chạy Giá trị mục tiêu tạm thời Huỷ mục tiêu tạm thời Tính toán Bolus @@ -314,7 +314,7 @@ Trọng lượng Kết quả có thể sai lệch nếu tính cả bolus dùng để mồi/đổ đầy ống! Dữ liệu cũ, vui lòng nhấn \'RELOAD\' - Tổng liều basal + Tổng liều nền cơ bản TBB * 2 Nhận diện thời gian @@ -322,17 +322,17 @@ BOLUS TÍNH TOÁN BOLUS GỌI Ý BOLUS - BOLUS MỞ RỘNG + LIỀU BOLUS KÉO DÀI SUPERBOLUS TBR CARBS CARBS MỞ RỘNG TEMP BASAL TEMP TARGET - CẤU HÌNH MỚI - NHÂN BẢN CẤU HÌNH - LƯU CẤU HÌNH - CHUYỂN CẤU HÌNH - ĐÃ NHÂN BẢN CHUYỂN CẤU HÌNH + HỒ SƠ MỚI + NHÂN BẢN HỒ SƠ + LƯU TRỮ HỒ SƠ + CHUYỂN HỒ SƠ + ĐÃ NHÂN BẢN CHUYỂN HỒ SƠ CHẾ ĐỘ VÒNG KÍN CHẾ ĐỘ VÒNG KÍN LGS VÒNG LẶP MỞ @@ -343,12 +343,12 @@ NGẮT KẾT NỐI TIẾP TỤC TẠM DỪNG - CHO PHÉP PHẦN CỨNG PUMP + CHO PHÉP PHẦN CỨNG BƠM XÓA KHÓA GHÉP NỐI - CHO PHÉP BASAL TẠM THỜI - HỦY TEMP BASAL + CHO PHÉP LIỀU NỀN TẠM THỜI + HỦY LIỀU NỀN TẠM HỦY LIỀU BOLUS - HỦY BOLUS MỞ RỘNG + HỦY LIỀU BOLUS KÉO DÀI CANCEL TEMP TARGET CAREPORTAL VỊ TRÍ CẢM BIẾN @@ -359,7 +359,7 @@ BOLUS MỒI ĐIỀU TRỊ CẬP NHẬT CAREPORTAL TỪ NS - CẬP NHẬT CẤU HÌNH TỪ NS + LÀM MỚI CHUYỂN HỒ SƠ TỪ NS CẬP NHẬT ĐIỀU TRỊ TỪ NS CẬP NHẬT TEMP TARGET TỪ NS ĐÃ XÓA AUTOMATION @@ -367,12 +367,12 @@ ĐÃ XÓA CAREPORTAL ĐÃ XÓA BOLUS ĐÃ XÓA CARBS - ĐÃ XÓA TEMP BASAL - ĐÃ XÓA BOLUS MỞ RỘNG + ĐÃ XÓA LIỀU NỀN TẠM + ĐÃ XÓA LIỀU BOLUS KÉO DÀI BỮA ĂN ĐÃ XÓA BỮA ĂN - ĐÃ XÓA CẤU HÌNH - ĐÃ XÓA CHUYỂN CẤU HÌNH + ĐÃ XÓA HỒ SƠ + ĐÃ XÓA CHUYỂN HỒ SƠ SỰ KIỆN KHỞI ĐỘNG LẠI ĐÃ XÓA ĐÃ XÓA ĐIỀU TRỊ ĐÃ XÓA TEMP TARGET @@ -420,45 +420,45 @@ ĐÃ XÓA CHẾ ĐỘ THỂ DỤC ĐÃ CẬP NHẬT CHẾ ĐỘ THỂ DỤC - Cấu hình Mục tiêu Thấp - Cấu hình Mục tiêu Cao + Hồ sơ Mục tiêu Thấp + Hồ sơ Mục tiêu Cao Giá trị thấp của mục tiêu tạm thời Giá trị cao của mục tiêu tạm thời Giá trị mục tiêu tạm thời - Cấu hình giá trị DIA - Giá trị độ nhạy insulin trong cấu hình - Giá trị basal tối đa trong cấu hình - Giá trị basal hiện tại - Giá trị tỷ lệ carb trong cấu hình + Hồ sơ giá trị DIA + Giá trị độ nhạy insulin trong Hồ sơ + Giá trị liều nền tối đa trong Hồ sơ + Giá trị liều nền hiện tại + Giá trị tỷ lệ carb trong Hồ sơ %1$.2f bị giới hạn ở %2$.2f »%1$s« nằm ngoài giới hạn an toàn »%1$s« %2$.2f nằm ngoài giới hạn an toàn - Giá trị liều Basal + Giá trị liều liều nền BOLUS %1$.2f U CARBS %1$d g - BOLUS MỞ RỘNG %1$.2f U trong %2$d phút + LIỀU BOLUS KÉO DÀI %1$.2f U trong %2$d phút TẢI SỰ KIỆN XOÁ CẢNH BÁO HUỶ KÍCH HOẠT CẬP NHẬT THỜI GIAN TẢI LỊCH SỬ %1$d TẢI TDDs - THIẾT LẬP CẤU HÌNH + THIẾT LẬP HỒ SƠ CÀI ĐẶT NGƯỜI DÙNG SMB BOLUS %1$.2f U - KHỞI ĐỘNG PUMP - DỪNG PUMP - LIỀU BASAL TẠM THỜI %1$.2f U/h %2$d min - LIỀU BASAL TẠM THỜI %1$d%% %2$d min + KHỞI ĐỘNG BƠM + DỪNG BƠM + LIỀU NỀN TẠM THỜI %1$.2f U/h %2$d min + LIỀU NỀN TẠM THỜI %1$d%% %2$d min INSIGHT: TBR VƯỢT QUÁ GIỚI HẠN TRẠNG THÁI %1$s Kết nối. Trạng thái đã hết hạn. - Kết nối. Liều Basal Basal đã quá hạn. + Giữ kết nối. Liều nền đã hết hạn. SMS %1$.0f%% Liều Basal - Liều Basal % + Liều nền % Độ nhạy ISF dùng cho tính toán và hấp thụ carb: %1$.1f Giá trị Autosens: %.0f%% @@ -470,7 +470,7 @@ Autotune Tune days : - Chọn cấu hình để điều chỉnh + Chọn Hồ sơ để điều chỉnh Hiển thị cảnh báo qua thông báo hệ thống Cảnh báo khẩn cấp @@ -489,7 +489,7 @@ Carb = 0. Không có hành động nào được thực hiện! Không cần insulin! Chưa có BG gần đây để tính toán! - Chưa có cấu hình nào hoạt động! + Chưa có Hồ sơ nào hoạt động! COB không xác định! Thiếu dữ liệu BG hoặc ứng dụng vừa khởi động lại? Vi phạm giới hạn carb! Calc (IC: %1$.1f, ISF: %2$.1f) @@ -519,28 +519,28 @@ Mở khóa cài đặt Pin - Ngăn chứa insulin + Bình chứa insulin Kết nối lần cuối Liều bolus gần nhất - Tỷ lệ liều basal - Liều basal tạm thời - Liều Bolus mở rộng + Tỷ lệ liều nền cơ bản + Liều nền tạm thời + Liều Bolus kéo dài Số sê-ri Ngắt kết nối Đang kết nối - Đã nhấn kết nối với pump + Đã nhấn kết nối với Bơm %1$.0f / %2$d U Đơn vị hàng ngày - Biểu tượng pump - Xem cấu hình - Lịch sử pump + Biểu tượng Bơm + Xem Hồ sơ + Lịch sử bơm Thống kê Thay trình điều khiển bơm. Thiết bị đã thay đổi Bolus thành công - Lỗi khi tiêm liều basal tạm thời + Lỗi khi tiêm liều nền tạm thời Chưa đặt - Pump đang bận + Bơm đang bận Lỗi kết nối bơm Đang đọc lịch sử bơm Đã xóa bỏ mật khẩu! @@ -548,8 +548,8 @@ Đang khởi tạo... %1$d s - Giới hạn tỷ lệ basal tối đa ở %1$.2f U/h do %2$s - giới hạn pump + Giới hạn tỷ lệ nền tối đa ở %1$.2f U/h do %2$s + giới hạn Bơm Giới hạn tỷ lệ phần trăm tối đa %1$d%% do %2$s phải nhập giá trị dương Giới hạn bolus ở %1$.1f U do %2$s @@ -583,12 +583,12 @@ Đã đạt giới hạn cho phép %1$dh %2$dm - Event time + Thời điểm Ứng dụng yêu cầu quyền Bluetooth Yêu cầu người dùng - Pump đã ghép nối + Bơm đã ghép nối Thiết bị không hỗ trợ Bluetooth. Không hỗ trợ Bluetooth hoặc thiết bị chưa được ghép nối. @@ -600,7 +600,7 @@ Thiếu quyền nhắn tin SMS - Đừng tắt ứng dụng này! + Đừng tắt ứng dụng! Đã vô hiệu hóa tải lên nhật ký sự cố! \n\nDocumentation:\nhttps://wiki.aaps.app\n\nFacebook:\nhttps://www.facebook.com/groups/AndroidAPSUsers diff --git a/core/validators/src/main/kotlin/app/aaps/core/validators/preferences/AdaptiveListIntPreference.kt b/core/validators/src/main/kotlin/app/aaps/core/validators/preferences/AdaptiveListIntPreference.kt index 2b85cfb5688..78edd64cb51 100644 --- a/core/validators/src/main/kotlin/app/aaps/core/validators/preferences/AdaptiveListIntPreference.kt +++ b/core/validators/src/main/kotlin/app/aaps/core/validators/preferences/AdaptiveListIntPreference.kt @@ -30,6 +30,27 @@ open class AdaptiveListIntPreference( (context.applicationContext as HasAndroidInjector).androidInjector().inject(this) intKey?.let { key = it.key } + + // Migrate old Int values to String for ListPreference compatibility + // AdaptiveListIntPreference extends ListPreference which stores values as String, + // but old code may have stored IntKey values as actual Integers. + // This causes ClassCastException when ListPreference tries to read the value. + intKey?.let { prefKey -> + val sp = android.preference.PreferenceManager.getDefaultSharedPreferences(ctx) + try { + val oldValue = sp.getInt(prefKey.key, -1) + if (oldValue != -1) { + // Migrate: remove Int value, write as String + sp.edit() + .remove(prefKey.key) + .putString(prefKey.key, oldValue.toString()) + .apply() + } + } catch (e: ClassCastException) { + // Already a String, no migration needed + } + } + title?.let { this.title = context.getString(it) } dialogMessage?.let { this.dialogMessage = context.getString(it) } dialogTitle?.let { this.dialogTitle = context.getString(it) } diff --git a/core/validators/src/main/res/values-vi-rVN/validator.xml b/core/validators/src/main/res/values-vi-rVN/validator.xml index a2df29e6bef..6cbe68d6782 100644 --- a/core/validators/src/main/res/values-vi-rVN/validator.xml +++ b/core/validators/src/main/res/values-vi-rVN/validator.xml @@ -1,7 +1,7 @@ Chỉ được phép nhập các chữ số. - Chỉ được phép nhập các chữ số trong khoảng %1$s - %2$s. + Chỉ chấp nhận số trong khoảng %1$s - %2$s. Trường này không được chứa ký tự đặc biệt nào Chỉ được phép nhập các chữ cái chuẩn Trường này không được để trống 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/gradle/libs.versions.toml b/gradle/libs.versions.toml index 7a7711270c6..df388538f16 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -163,7 +163,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.20.0" } com-github-guepardoapps-kulid = { group = "com.github.guepardoapps", name = "kulid", version = "2.0.0.0" } diff --git a/implementation/src/main/kotlin/app/aaps/implementation/pump/BlePreCheckImpl.kt b/implementation/src/main/kotlin/app/aaps/implementation/pump/BlePreCheckImpl.kt index 88b43b58cba..a28603bc8e4 100644 --- a/implementation/src/main/kotlin/app/aaps/implementation/pump/BlePreCheckImpl.kt +++ b/implementation/src/main/kotlin/app/aaps/implementation/pump/BlePreCheckImpl.kt @@ -7,6 +7,8 @@ import android.content.pm.PackageManager import androidx.appcompat.app.AppCompatActivity import androidx.core.app.ActivityCompat import androidx.core.content.ContextCompat +import app.aaps.core.interfaces.logging.AAPSLogger +import app.aaps.core.interfaces.logging.LTag import app.aaps.core.interfaces.pump.BlePreCheck import app.aaps.core.interfaces.resources.ResourceHelper import app.aaps.core.ui.dialogs.OKDialog @@ -17,15 +19,18 @@ import javax.inject.Singleton @Singleton class BlePreCheckImpl @Inject constructor( private val context: Context, - private val rh: ResourceHelper + private val rh: ResourceHelper, + private val aapsLogger: AAPSLogger ) : BlePreCheck { companion object { private const val PERMISSION_REQUEST_BLUETOOTH = 30242 // arbitrary. + } - override fun prerequisitesCheck(activity: AppCompatActivity): Boolean { + + override fun prerequisitesCheck(activity: AppCompatActivity, additionalPermissions: List?): Boolean { if (!activity.packageManager.hasSystemFeature(PackageManager.FEATURE_BLUETOOTH_LE)) { OKDialog.show(activity, rh.gs(app.aaps.core.ui.R.string.message), rh.gs(app.aaps.core.ui.R.string.ble_not_supported)) return false @@ -37,6 +42,10 @@ class BlePreCheckImpl @Inject constructor( return false } + if (!checkAdditionalPermissions(additionalPermissions, activity)) { + return false; + } + val bluetoothAdapter = (context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager?)?.adapter // Ensures Bluetooth is available on the device and it is enabled. bluetoothAdapter?.safeEnable(3000) @@ -47,4 +56,35 @@ class BlePreCheckImpl @Inject constructor( } return true } + + + private fun checkAdditionalPermissions(additionalPermissions: List?, activity: AppCompatActivity): Boolean { + + if (additionalPermissions==null || additionalPermissions.size==0) { + aapsLogger.debug(LTag.PUMP, "No additional permissions found !") + return true + } + + aapsLogger.info(LTag.PUMP, "Additional permissions check (${additionalPermissions.size}): ${additionalPermissions}") + + val nonPermittedItems = mutableListOf() + + for (permission in additionalPermissions) { + if (ContextCompat.checkSelfPermission(context, permission) != PackageManager.PERMISSION_GRANTED) { + nonPermittedItems.add(permission) + } + } + + aapsLogger.info(LTag.PUMP, "Non permitted items: ${nonPermittedItems}") + + if (nonPermittedItems.size > 0) { + ActivityCompat.requestPermissions(activity, nonPermittedItems.toTypedArray(), PERMISSION_REQUEST_BLUETOOTH) + return false + } + + return true + } + + + } \ No newline at end of file diff --git a/implementation/src/main/res/values-nl-rNL/strings.xml b/implementation/src/main/res/values-nl-rNL/strings.xml index 5aace5f4cb0..2d64d227d16 100644 --- a/implementation/src/main/res/values-nl-rNL/strings.xml +++ b/implementation/src/main/res/values-nl-rNL/strings.xml @@ -17,4 +17,12 @@ TIR \'s nachts Koolhydraten + Laatste connectie: %1$d min geleden + Alarm: %1$s + Laatste bolus: %1$s E @ %2$s + Tijdelijk: %1$s + Verlengd: %1$s + Reservoir: %1$dE + Batt: %1$d%% + Pomp niet geïnitialiseerd diff --git a/implementation/src/main/res/values-pl-rPL/strings.xml b/implementation/src/main/res/values-pl-rPL/strings.xml index a717523520f..3d794813d58 100644 --- a/implementation/src/main/res/values-pl-rPL/strings.xml +++ b/implementation/src/main/res/values-pl-rPL/strings.xml @@ -17,4 +17,12 @@ Nocny TIR Węglowodany + Ost. Poł.: %1$d m. temu + Alarm: %1$s + Ost. Bolus: %1$sU @ %2$s + Tymcz.: %1$s + Ext: %1$s + Zbior.: %1$dU + Bat.: %1$d%% + Pompa nie zainicjal. diff --git a/implementation/src/main/res/values-sk-rSK/strings.xml b/implementation/src/main/res/values-sk-rSK/strings.xml index 1b205c97d8b..e64c700bc89 100644 --- a/implementation/src/main/res/values-sk-rSK/strings.xml +++ b/implementation/src/main/res/values-sk-rSK/strings.xml @@ -19,10 +19,10 @@ Posledné Spoj: pred %1$d min Upozornenie: %1$s - Posledný bolus: %1$sJI @ %2$s + Posledný bolus: %1$sU @ %2$s Doč. bazál: %1$s Ext: %1$s - Zásobník: %1$dJI + Zásobník: %1$dU Bat: %1$d%% Pumpa nie je inicializovaná
diff --git a/implementation/src/main/res/values-vi-rVN/strings.xml b/implementation/src/main/res/values-vi-rVN/strings.xml index 5d056c52233..3f650642529 100644 --- a/implementation/src/main/res/values-vi-rVN/strings.xml +++ b/implementation/src/main/res/values-vi-rVN/strings.xml @@ -1,7 +1,7 @@ - Lệnh đang được thực thi ngay bây giờ - Giá trị basal thấp hơn mức tối thiểu. Cấu hình chưa được thiết lập! + Đang xử lý lệch + Giá trị liều nền thấp hơn mức tối thiểu. Hồ sơ chưa được thiết lập! Quá thấp Thấp @@ -17,7 +17,7 @@ TIR ban đêm Carbs - LastConn: %1$d min ago + Kết nối gần nhất: %1$d phút trước Cảnh báo: %1$s LastBolus: %1$sU @ %2$s Temp: %1$s diff --git a/plugins/aps/src/main/kotlin/app/aaps/plugins/aps/openAPSAutoISF/DetermineBasalAutoISF.kt b/plugins/aps/src/main/kotlin/app/aaps/plugins/aps/openAPSAutoISF/DetermineBasalAutoISF.kt index 81160a7cbe8..e145fa15fb1 100644 --- a/plugins/aps/src/main/kotlin/app/aaps/plugins/aps/openAPSAutoISF/DetermineBasalAutoISF.kt +++ b/plugins/aps/src/main/kotlin/app/aaps/plugins/aps/openAPSAutoISF/DetermineBasalAutoISF.kt @@ -1,5 +1,6 @@ package app.aaps.plugins.aps.openAPSAutoISF +import app.aaps.core.data.configuration.Constants import app.aaps.core.interfaces.aps.APSResult import app.aaps.core.interfaces.aps.AutosensResult import app.aaps.core.interfaces.aps.CurrentTemp @@ -219,7 +220,7 @@ class DetermineBasalAutoISF @Inject constructor( // var origin_sens = "" var exercise_ratio = 1.0 val high_temptarget_raises_sensitivity = profile.exercise_mode || profile.high_temptarget_raises_sensitivity - val normalTarget = 100 // evaluate high/low temptarget against 100, not scheduled target (which might change) + val normalTarget = Constants.NORMAL_TARGET_MGDL // evaluate high/low temptarget against normal target, not scheduled target (which might change) // when temptarget is 160 mg/dL, run 50% basal (120 = 75%; 140 = 60%), 80 mg/dL with low_temptarget_lowers_sensitivity would give 1.5x basal, but is limited to autosens_max (1.2x by default) val halfBasalTarget = profile.half_basal_exercise_target diff --git a/plugins/aps/src/main/kotlin/app/aaps/plugins/aps/openAPSAutoISF/OpenAPSAutoISFPlugin.kt b/plugins/aps/src/main/kotlin/app/aaps/plugins/aps/openAPSAutoISF/OpenAPSAutoISFPlugin.kt index 374b8d3b272..101907c904f 100644 --- a/plugins/aps/src/main/kotlin/app/aaps/plugins/aps/openAPSAutoISF/OpenAPSAutoISFPlugin.kt +++ b/plugins/aps/src/main/kotlin/app/aaps/plugins/aps/openAPSAutoISF/OpenAPSAutoISFPlugin.kt @@ -144,7 +144,7 @@ open class OpenAPSAutoISFPlugin @Inject constructor( val iobThresholdPercent; get() = preferences.get(IntKey.ApsAutoIsfIobThPercent) private val exerciseMode; get() = SMBDefaults.exercise_mode private val highTemptargetRaisesSensitivity; get() = preferences.get(BooleanKey.ApsAutoIsfHighTtRaisesSens) - val normalTarget = 100 + val normalTarget = Constants.NORMAL_TARGET_MGDL private val minutesClass; get() = if (preferences.get(IntKey.ApsMaxSmbFrequency) == 1) 6L else 30L // ga-zelle: later get correct 1 min CGM flag from glucoseStatus ? ... or from apsResults? override fun onStart() { diff --git a/plugins/aps/src/main/kotlin/app/aaps/plugins/aps/openAPSSMB/DetermineBasalSMB.kt b/plugins/aps/src/main/kotlin/app/aaps/plugins/aps/openAPSSMB/DetermineBasalSMB.kt index c5373b4cecf..351a10003c7 100644 --- a/plugins/aps/src/main/kotlin/app/aaps/plugins/aps/openAPSSMB/DetermineBasalSMB.kt +++ b/plugins/aps/src/main/kotlin/app/aaps/plugins/aps/openAPSSMB/DetermineBasalSMB.kt @@ -68,7 +68,7 @@ class DetermineBasalSMB @Inject constructor( if (!microBolusAllowed) { consoleError.add("SMB disabled (!microBolusAllowed)") return false - } else if (!profile.allowSMB_with_high_temptarget && profile.temptargetSet && target_bg > Constants.ALLOW_SMB_WITH_HIGH_TT) { + } else if (!profile.allowSMB_with_high_temptarget && profile.temptargetSet && target_bg > Constants.NORMAL_TARGET_MGDL) { consoleError.add("SMB disabled due to high temptarget of $target_bg") return false } @@ -220,7 +220,7 @@ class DetermineBasalSMB @Inject constructor( var sensitivityRatio: Double val high_temptarget_raises_sensitivity = profile.exercise_mode || profile.high_temptarget_raises_sensitivity - val normalTarget = 100 // evaluate high/low temptarget against 100, not scheduled target (which might change) + val normalTarget = Constants.NORMAL_TARGET_MGDL // evaluate high/low temptarget against normal target, not scheduled target (which might change) // when temptarget is 160 mg/dL, run 50% basal (120 = 75%; 140 = 60%), 80 mg/dL with low_temptarget_lowers_sensitivity would give 1.5x basal, but is limited to autosens_max (1.2x by default) val halfBasalTarget = profile.half_basal_exercise_target diff --git a/plugins/aps/src/main/res/values-ru-rRU/strings.xml b/plugins/aps/src/main/res/values-ru-rRU/strings.xml index 9831c6b2fff..fe5d2b258e8 100644 --- a/plugins/aps/src/main/res/values-ru-rRU/strings.xml +++ b/plugins/aps/src/main/res/values-ru-rRU/strings.xml @@ -68,7 +68,7 @@ Всегда включать супер микро болюс SMB Включать SMB всегда, вне зависимости от активных углеводов, ВЦ или болюсов. Возможно только на источниках СК с хорошей фильтрацией данных Включать супер микро болюс SMB после углеводов - Включать SMB на 6ч после приема пищи, даже при отсутствии активных углеводов. Возможно только на источниках СК с продвинутой фильтрацией данных + Включать SMB на 6ч после приема пищи, даже при отсутствии активных углеводов. Возможно только на источниках ГК с улучшенной фильтрацией данных Включать супер микро болюсы при активных углеводах COB Включить супер микро болюс SMB, когда имеются активные углеводы COB. Включить супер микро болюсы SMB с временными целями diff --git a/plugins/aps/src/main/res/values-sk-rSK/strings.xml b/plugins/aps/src/main/res/values-sk-rSK/strings.xml index 676519638be..f13b54b2ae0 100644 --- a/plugins/aps/src/main/res/values-sk-rSK/strings.xml +++ b/plugins/aps/src/main/res/values-sk-rSK/strings.xml @@ -44,10 +44,10 @@ Dáta detekcie citlivosti Ladenie skriptu Použi automatickú detekciu citlivosti - Max. JI/h, ktoré je možné nastaviť pre dočas. bazál + Max. U/h, ktoré je možné nastaviť pre dočas. bazál Táto hodnota je nazývaná v kontexte OpenAPS ako \"max basal\" - Maximálny bazálny IOB, ktorý OpenAPS môže podať [JI] - Táto hodnota je nazývaná v kontexte OpenAPS ako max IOB, je to maximálne množstvo inzulínu v [JI], ktoré APS môže naraz podať. + Maximálny bazálny IOB, ktorý OpenAPS môže podať [U] + Táto hodnota je nazývaná v kontexte OpenAPS ako max IOB, je to maximálne množstvo inzulínu v [U], ktoré APS môže naraz podať. Štandardná hodnota: zapnuté\nToto je používané, aby automatická detekcia citlivosti mohla okrem cieľovej hodnoty glykémie, upravovať aj citlivosť, prevody a bazály. Autosense takisto upravuje cieľovú glykémiu Štandardná hodnota: 3.0 (AMA), alebo 8.0 (SMB) mg/dl/5min. Táto hodnota definuje minimálnu časť vstrebaných sacharidov za každých 5min. Táto hodnota ovplyvňuje výpočet COB. @@ -59,7 +59,7 @@ Zmysluplné, pokiaľ dáta z xDrip+ obsahujú veľký šum. Max násobiteľ denného najvyššieho bazálu Max násobiteľ súčasného bazálu - Maximálne celkové IOB, ktoré OpenAPS nemôže prekročiť [JI] + Maximálne celkové IOB, ktoré OpenAPS nemôže prekročiť [U] Táto hodnota je v kontexte OpenAPS nazývaná Max IOB.\nOpenAPS nikdy nepridá inzulín, pokiaľ je súčasné IOB väčšie, ako táto hodnota Povoliť UAM Povoliť SMB @@ -91,7 +91,7 @@ Povoliť dynamickú citlivosť Upravte algoritmus pre použitie dynamickej citlivosti namiesto hodnoty z profilu - IOB obmedzený na %1$.1f JI: %2$s + IOB obmedzený na %1$.1f U: %2$s maximálna hodnota v nastaveniach pevný limit @@ -143,8 +143,8 @@ Profil : Posledné spustenie : Upozornenie : - Vybraný profil má %1$d hodnôt IC. Autotune bude používať %2$.2f g/JI - Vybraný profil má %1$d hodnôt ISF. Autotune bude používať %2$.1f %3$s/JI + Vybraný profil má %1$d hodnôt IC. Autotune bude používať %2$.2f g/U + Vybraný profil má %1$d hodnôt ISF. Autotune bude používať %2$.1f %3$s/U Chyba vstupných dát, skúste znova spustiť Autotune, alebo znížte počet dní Chyba vo vstupných dátach, zvýšte počet dní Autotune spustený, prosím buďte trpezliví diff --git a/plugins/aps/src/main/res/values-vi-rVN/strings.xml b/plugins/aps/src/main/res/values-vi-rVN/strings.xml index 182b3540334..19614fb87d1 100644 --- a/plugins/aps/src/main/res/values-vi-rVN/strings.xml +++ b/plugins/aps/src/main/res/values-vi-rVN/strings.xml @@ -1,8 +1,8 @@ - Kích hoạt hệ số nhạy cảm insulin dựa trên tổng liều hằng ngày (TDD) để điều chỉnh liều basal và mục tiêu đường huyết - Sử dụng TDD 24 giờ gần nhất hoặc TDD trung bình 7 ngày để tính độ nhạy insulin, từ đó tăng hoặc giảm tốc độ basal, đồng thời điều chỉnh mục tiêu đường huyết nếu các tùy chọn này được bật, tương tự như cách Autosens hoạt động. Khuyến nghị nên bắt đầu với tùy chọn này ở trạng thái tắt + Kích hoạt hệ số nhạy cảm insulin dựa trên tổng liều hằng ngày (TDD) để điều chỉnh liều liều nền và mục tiêu đường huyết + Sử dụng TDD 24 giờ gần nhất hoặc TDD trung bình 7 ngày để tính độ nhạy insulin, từ đó tăng hoặc giảm tốc độ liều nền, đồng thời điều chỉnh mục tiêu đường huyết nếu các tùy chọn này được bật, tương tự như cách Autosens hoạt động. Khuyến nghị nên bắt đầu với tùy chọn này ở trạng thái tắt Hệ số điều chỉnh DynamicISF (%) Hệ số điều chỉnh cho DynamicISF. Đặt lớn hơn 100% để có liều hiệu chỉnh mạnh hơn, và nhỏ hơn 100% để có liều hiệu chỉnh nhẹ hơn. Mục tiêu tạm thời cao làm tăng độ nhạy @@ -12,7 +12,7 @@ OpenAPS SMB Dynamic ISF Auto ISF - Tần suất SMB sẽ được bơm (tính bằng phút) + Tần suất SMB sẽ được bơm (phút) Kháng insulin làm giảm mục tiêu Khi phát hiện kháng insulin, hãy hạ thấp mức đường huyết mục tiêu Độ nhạy tăng mục tiêu @@ -35,35 +35,35 @@ Lần chạy gần nhất Tham số đầu vào Trạng thái đường huyết - Current temp + Liều tạm thời hiện tại Dữ liệu IOB - Cấu hình + Hồ sơ Dữ liệu bữa ăn Yêu cầu Giới hạn Dữ liệu Autosens Gỡ lỗi script Sử dụng tính năng Autosens - Liều Basal tạm thời tối đa (U/giờ) - Giá trị này được gọi là basal tối đa trong OpenAPS - Lượng IOB basal tối đa mà OpenAPS có thể cung cấp [U] + Liều nền tạm thời tối đa (U/giờ) + Giá trị này được gọi là liều nền tối đa trong OpenAPS + Lượng IOB liều nền tối đa mà OpenAPS có thể cung cấp [U] Giá trị này trong OpenAPS được gọi là Max IOB.\n Đây là lượng insulin tối đa [U] mà APS có thể đưa vào cùng một lúc. - Mặc định: true.\n Tùy chọn này cho phép Autosens điều chỉnh mục tiêu đường huyết (BG), ngoài việc điều chỉnh ISF và basal. + Mặc định: true.\n Tùy chọn này cho phép Autosens điều chỉnh mục tiêu đường huyết (BG), ngoài việc điều chỉnh ISF và liều nền. Autosens cũng điều chỉnh cả mục tiêu - Mặc định: 3 (AMA) hoặc 8 (SMB). Đây là thiết lập cho tác động mặc định của hấp thụ carb trong 5 phút. Giá trị mặc định tương ứng với 3 mg/dl/5 phút. Tham số này ảnh hưởng đến tốc độ giảm COB, cũng như lượng carb hấp thụ được giả định khi tính toán đường huyết dự đoán trong tương lai, trong trường hợp đường huyết giảm nhanh hơn dự kiến hoặc tăng ít hơn dự kiến. - Mặc định: 3. Đây là một giới hạn an toàn quan trọng của OpenAPS. Nó giới hạn liều basal tối đa của bạn bằng 3 lần liều basal lớn nhất của bạn (áp dụng cho người này). Thông thường bạn không cần thay đổi, nhưng bạn nên biết rằng đây là những gì được nói đến khi nhắc đến ‘3x max daily; 4x current’ cho giới hạn an toàn. - Giá trị mặc định: 4. Đây là nửa còn lại của các giới hạn an toàn chính của OpenAPS, và là nửa còn lại của ‘3x max daily; 4x current’ trong các giới hạn an toàn. Điều này có nghĩa là liều basal của bạn, bất kể liều basal tối đa được thiết lập trên bơm, không thể cao hơn con số này nhân với mức basal hiện tại. Điều này nhằm ngăn người dùng rơi vào tình huống nguy hiểm khi thiết lập liều basal tối đa quá cao trước khi hiểu cách thuật toán hoạt động. Giá trị mặc định là 4x; hầu hết người dùng sẽ không cần điều chỉnh, và thay vào đó có thể cần điều chỉnh các cài đặt khác nếu cảm thấy “đang chạm” vào giới hạn an toàn này. + Mặc định: 3 (AMA) hoặc 8 (SMB). Đây là thiết lập cho tác động mặc định của hấp thụ carb trong 5 phút. Giá trị mặc định tương ứng với 3 mg/dl/5 phút. Tham số này ảnh hưởng đến tốc độ giảm COB, cũng như lượng carb hấp thụ được giả định khi tính toán đường huyết dự đoán trong dự kiến, trong trường hợp đường huyết giảm nhanh hơn dự kiến hoặc tăng ít hơn dự kiến. + Mặc định: 3. Đây là một giới hạn an toàn quan trọng của OpenAPS. Nó giới hạn liều liều nền tối đa của bạn bằng 3 lần liều liều nền lớn nhất của bạn (áp dụng cho người này). Thông thường bạn không cần thay đổi, nhưng bạn nên biết rằng đây là những gì được nói đến khi nhắc đến ‘3x max daily; 4x current’ cho giới hạn an toàn. + Giá trị mặc định: 4. Đây là nửa còn lại của các giới hạn an toàn chính của OpenAPS, và là nửa còn lại của ‘3x max daily; 4x current’ trong các giới hạn an toàn. Điều này có nghĩa là liều liều nền của bạn, bất kể liều liều nền tối đa được thiết lập trên bơm, không thể cao hơn con số này nhân với mức liều nền hiện tại. Điều này nhằm ngăn người dùng rơi vào tình huống nguy hiểm khi thiết lập liều liều nền tối đa quá cao trước khi hiểu cách thuật toán hoạt động. Giá trị mặc định là 4x; hầu hết người dùng sẽ không cần điều chỉnh, và thay vào đó có thể cần điều chỉnh các cài đặt khác nếu cảm thấy “đang chạm” vào giới hạn an toàn này. Giá trị mặc định: 2\nBolus snooze được kích hoạt sau khi bạn thực hiện một bolus bữa ăn, để vòng lặp không điều chỉnh bằng các temp thấp khi bạn vừa ăn. Ví dụ ở đây và giá trị mặc định là 2; nghĩa là với DIA 3 giờ, bolus snooze sẽ được loại bỏ dần trong 1,5 giờ (3DIA/2). Chú ý!\nThông thường bạn không cần thay đổi các giá trị bên dưới. Vui lòng BẤM VÀO ĐÂY và ĐỌC kỹ nội dung, đảm bảo bạn HIỂU trước khi thay đổi bất kỳ giá trị nào. Luôn dùng delta trung bình ngắn thay cho delta đơn giản Hữu ích khi dữ liệu từ các nguồn chưa lọc, như xDrip+, bị nhiễu. Hệ số an toàn tối đa trong ngày - Hệ số an toàn của basal hiện tại + Hệ số an toàn của liều nền hiện tại Tổng IOB tối đa mà OpenAPS không được vượt quá [U] Giá trị này trong OpenAPS được gọi là Max IOB. \nOpenAPS sẽ không đưa thêm insulin nếu IOB hiện tại lớn hơn giá trị này Kích hoạt UAM Kích hoạt SMB - Sử dụng Super Micro Boluses (SMB) thay cho basal tạm thời để có tác dụng nhanh hơn + Sử dụng Super Micro Boluses (SMB) thay cho liều nền tạm thời để có tác dụng nhanh hơn Phát hiện bữa ăn không khai báo Luôn bật SMB Bật SMB mọi lúc, không phụ thuộc vào COB, mục tiêu tạm thời hoặc bolus. Chỉ khả dụng khi sử dụng nguồn BG có bộ lọc dữ liệu nâng cao @@ -75,31 +75,31 @@ Bật SMB khi có mục tiêu tạm thời đang hoạt động (sắp ăn, tập thể dục) Bật SMB với mục tiêu tạm thời cao Bật SMB khi có mục tiêu tạm thời cao đang hoạt động (tập thể dục, trên 100 mg/dl hoặc 5,5 mmol/l) - Số phút basal tối đa để giới hạn cho SMB + Số phút liều nền tối đa để giới hạn cho SMB Số phút tối đa SMB cho UAM - Số phút basal tối đa để giới hạn SMB cho UAM + Số phút liều nền tối đa để giới hạn SMB cho UAM Lượng carb tối thiểu cần thiết để đưa ra gợi ý Số gram carbs tối thiểu để hiển thị cảnh báo gợi ý carbs. Gợi ý carbs dưới mức này sẽ không kích hoạt thông báo. Đường huyết dưới mức này sẽ tạm dừng việc tiêm insulin. Mức mặc định sử dụng mô hình mục tiêu chuẩn. Người dùng có thể đặt giá trị trong khoảng 60 mg/dL (3,3 mmol/L) đến 100 mg/dL (5,5 mmol/L). Các giá trị dưới 65 mg/dL (3,6 mmol/L) sẽ sử dụng mô hình mặc định BG dưới ngưỡng này sẽ tạm dừng do hạ đường huyết - Tăng basal tối đa vì thiết lập nhỏ hơn basal tối đa trong Cấu hình - Hệ số nhân basal tối đa - Hệ số nhân basal tối đa theo ngày - SMB đã bị tắt trong phần Cài đặt - UAM đã bị tắt trong phần Cài đặt - Autosens đã bị tắt trong phần Cài đặt + Tăng liều nền tối đa vì thiết lập nhỏ hơn liều nền tối đa trong Hồ sơ + hệ số nhân liều nền tối đa + hệ số nhân liều nền tối đa theo ngày + SMB đã bị tắt trong phần Tùy chọn + UAM đã bị tắt trong Tùy chọn + Autosens đã bị tắt trong phần Tùy chọn Bật độ nhạy động (dynamic sensitivity) - Điều chỉnh thuật toán để dùng độ nhạy insulin động thay cho giá trị Cấu hình + Điều chỉnh thuật toán để dùng độ nhạy insulin động thay cho giá trị hồ sơ Giới hạn IOB ở mức %1$.1f U do %2$s - Giá trị tối đa trong phần Cài đặt + giá trị tối đa trong phần Tùy chọn giới hạn cứng Đỉnh (Peak) Chạy ngay bây giờ Một liều bolus đã được bơm trong vòng 3 phút trước, bỏ qua SMB - Pump chưa được khởi tạo! + Bơm chưa được khởi tạo! LOOP Bật hoặc tắt chức năng thực thi để kích hoạt vòng lặp. KHÔNG CÓ APS ĐƯỢC CHỌN HOẶC CUNG CẤP KẾT QUẢ @@ -108,43 +108,43 @@ Bỏ qua 30 phút Đề xuất lượng carb Có đề xuất mới - Basal được cài đặt đúng + Liều nền được cài đặt đúng Lần chạy gần nhất APS Yêu cầu Đã áp dụng giới hạn an toàn - Thời gian yêu cầu basal tạm thời - Thời gian thực hiện basal tạm thời - Basal tạm thời được cài đặt bởi bơm - Thời gian yêu cầu SMB - Thời gian thực hiện SMB + Yêu cầu liều nền tạm thời lúc + Thực hiện liều nền tạm thời lúc + Liều nền tạm thời bởi bơm + Yêu cầu SMB lúc + Thực hiện SMB lúc SMB được cài đặt bởi bơm Thay đổi yêu cầu tối thiểu [%] Open Loop sẽ chỉ hiển thị yêu cầu thay đổi mới nếu thay đổi lớn hơn giá trị này theo %. Giá trị mặc định là 20% Chuyển sang SMB. Không đủ dữ liệu TDD. - Chuyển sang độ nhạy insulin theo Cấu hình. Không đủ dữ liệu. Lý do: %1$s + Chuyển sang Hồ sơ độ nhạy insulin. Không đủ dữ liệu. Lý do: %1$s - Hướng dẫn hỗ trợ điều chỉnh Cấu hình(ISF, tỉ lệ carb và liều basal) + Hướng dẫn hỗ trợ điều chỉnh Hồ sơ (ISF, tỷ lệ carb và liều nền) AT Cài đặt Autotune - Tự động chuyển Cấu hình - Nếu bật, Autotune sẽ tự động cập nhật và áp dụng Cấu hình đầu vào sau khi tính toán theo quy tắc tự động. - Phân loại UAM như basal - Chỉ bật khi bạn luôn nhập đúng toàn bộ carb đã ăn. Khi đó, Autotune sẽ dùng các mức tăng đột ngột để đề xuất điều chỉnh basal. + Tự động chuyển Hồ sơ + Nếu bật, Autotune sẽ tự động cập nhật và áp dụng Hồ sơ đầu vào sau khi tính toán theo quy tắc tự động. + Phân loại UAM như liều nền + Chỉ bật khi bạn luôn nhập đúng toàn bộ carb đã ăn. Khi đó, Autotune sẽ dùng các mức tăng đột ngột để đề xuất điều chỉnh liều nền. Điều chỉnh đường cong insulin Chỉ bật nếu bạn sử dụng ‘free peak’. Tùy chọn này sẽ điều chỉnh thời gian đỉnh và thời gian hoạt động của insulin Số ngày dữ liệu Áp dụng kết quả trung bình trong IC/ISF theo nhịp sinh học - Autotune sẽ không điều chỉnh các biến đổi theo nhịp sinh học (circadian). Tùy chọn này chỉ áp dụng việc hiệu chỉnh trung bình của IC và ISF vào Cấu hình nhịp sinh học của bạn + Autotune sẽ không điều chỉnh các biến đổi theo nhịp sinh học (circadian). Tùy chọn này chỉ áp dụng việc hiệu chỉnh trung bình của IC và ISF vào hồ sơ nhịp sinh học của bạn Ghi nhiều thông tin nhật ký hơn để hỗ trợ gỡ lỗi Chỉ bật khi được nhà phát triển yêu cầu để gửi thêm thông tin nhật ký, hỗ trợ gỡ lỗi plugin Autotune Số ngày dữ liệu mặc định được Autotune xử lý (tối đa 30) Đã tinh chỉnh - Cấu hình : + Hồ sơ: Lần chạy gần nhất : Cảnh báo : - Cấu hình đã chọn có %1$d giá trị IC. Autotune sẽ sử dụng %2$.2f g/U - Cấu hình đã chọn có %1$d giá trị ISF. Autotune sẽ sử dụng %2$.1f %3$s/U + Hồ sơ đã chọn có %1$d giá trị IC. Autotune sẽ sử dụng %2$.2f g/U + Hồ sơ đã chọn có %1$d giá trị ISF. Autotune sẽ sử dụng %2$.1f %3$s/U Dữ liệu đầu vào lỗi, hãy thử chạy lại Autotune hoặc giảm số ngày Dữ liệu đầu vào lỗi, hãy tăng số ngày Đang bắt đầu tính toán Autotune, vui lòng chờ @@ -155,20 +155,20 @@ % Thiếu Chạy Autotune - Kiểm tra Cấu hình đầu vào - So sánh các Cấu hình - Sao chép sang Cấu hình cục bộ - Cập nhật Cấu hình đầu vào - Hoàn nguyên Cấu hình đầu vào - Tạo Cấu hình cục bộ mới từ Cấu hình Autotune này? - Cập nhật Cấu hình %1$s bằng Cấu hình Autotune? - Hoàn nguyên Cấu hình %1$s về Cấu hình Đầu vào không? - Cấu hình không hợp lệ + Kiểm tra Hồ sơ đầu vào + So sánh các Hồ sơ + Sao chép sang Hồ sơ cục bộ + Cập nhật Hồ sơ đầu vào + Hoàn nguyên Hồ sơ đầu vào + Tạo hồ sơ cục bộ mới từ Hồ sơ Autotune này? + Cập nhật Hồ sơ %1$s bằng Hồ sơ Autotune? + Hoàn nguyên Hồ sơ %1$s về Hồ sơ Đầu vào không? + Hồ sơ không hợp lệ Không khả dụng - Pump đã ngắt kết nối + Bơm đã ngắt kết nối - Mục tiêu tập luyện với 50% basal - Đặt thành một số, ví dụ 160, có nghĩa là khi mục tiêu tạm thời (temp target) là 160 mg/dL và tùy chọn \'High temptarget raises sensitivity=true\' được bật, Aaps sẽ chạy 50%% basal ở mức này (120 = 75%%; 140 = 60%%). + Mục tiêu tập luyện với 50% liều nền + Đặt thành một số, ví dụ 160, có nghĩa là khi mục tiêu tạm thời (temp target) là 160 mg/dL và tùy chọn \'High temptarget raises sensitivity=true\' được bật, Aaps sẽ chạy 50%% liều nền ở mức này (120 = 75%%; 140 = 60%%). Cài đặt AutoISF AutoISF cho phép điều chỉnh Hệ số độ nhạy insulin (ISF) trong nhiều tình huống thay đổi đường huyết. \n\nNó cũng có thể điều chỉnh cài đặt phân phối SMB Cài đặt phân phối SMB diff --git a/plugins/aps/src/test/kotlin/app/aaps/plugins/aps/openAPSAutoISF/OpenAPSAutoISFPluginTest.kt b/plugins/aps/src/test/kotlin/app/aaps/plugins/aps/openAPSAutoISF/OpenAPSAutoISFPluginTest.kt index 24e0633071c..bee4ddc8deb 100644 --- a/plugins/aps/src/test/kotlin/app/aaps/plugins/aps/openAPSAutoISF/OpenAPSAutoISFPluginTest.kt +++ b/plugins/aps/src/test/kotlin/app/aaps/plugins/aps/openAPSAutoISF/OpenAPSAutoISFPluginTest.kt @@ -1,6 +1,7 @@ package app.aaps.plugins.aps.openAPSAutoISF import app.aaps.core.data.aps.SMBDefaults +import app.aaps.core.data.configuration.Constants import app.aaps.core.interfaces.aps.GlucoseStatusAutoIsf import app.aaps.core.interfaces.aps.OapsProfileAutoIsf import app.aaps.core.interfaces.bgQualityCheck.BgQualityCheck @@ -69,7 +70,7 @@ class OpenAPSAutoISFPluginTest : TestBaseWithProfile() { var ttSet = false var exerciseMode = false val targetBg = 120.0 - val normalTarget = 100 + val normalTarget = Constants.NORMAL_TARGET_MGDL assertThat(openAPSAutoISFPlugin.withinISFlimits(1.7, autoIsfMin, autoIsfMax, sens, originSens, ttSet, exerciseMode, targetBg, normalTarget)).isEqualTo(1.2) // upper limit assertThat(openAPSAutoISFPlugin.withinISFlimits(0.5, autoIsfMin, autoIsfMax, sens, originSens, ttSet, exerciseMode, targetBg, normalTarget)).isEqualTo(0.7) // lower limit sens = 1.5 // from Autosens diff --git a/plugins/automation/src/main/res/values-nl-rNL/strings.xml b/plugins/automation/src/main/res/values-nl-rNL/strings.xml index 67f7658d332..7cc82144409 100644 --- a/plugins/automation/src/main/res/values-nl-rNL/strings.xml +++ b/plugins/automation/src/main/res/values-nl-rNL/strings.xml @@ -78,6 +78,7 @@ BG verschil BG verschil [%1$s] Huidige locatie + Kies van kaart Locatie Glucose [%1$s]: Streefdoel [%1$s]: @@ -140,4 +141,9 @@ Aantal stappen per %1$s minuten %2$s %3$.0f Metingsduur + SMB in-/uitschakelen + Wijzig SMB naar %1$s + Nieuwe SMB modus: + AAN + UIT diff --git a/plugins/automation/src/main/res/values-pl-rPL/strings.xml b/plugins/automation/src/main/res/values-pl-rPL/strings.xml index 9cfcc3a55aa..cfb7f1c3aaf 100644 --- a/plugins/automation/src/main/res/values-pl-rPL/strings.xml +++ b/plugins/automation/src/main/res/values-pl-rPL/strings.xml @@ -78,6 +78,7 @@ Różnica poziomu cukru Różnica poziomu cukru [%1$s] Obecna lokalizacja + Wybierz z mapy Lokalizacja Poziom [%1$s]: Cel [%1$s]: @@ -136,5 +137,13 @@ Błąd podczas ostatniego uruchomienia Autotune Wykryto Autotune w toku, anulowano próbę kolejnego uruchomienia + Liczba kroków + Liczba kroków na %1$s minut %2$s %3$.0f + Czas trwania pomiaru + Włącz/wyłącz SMB + Zmień SMB na %1$s + Nowy tryb SMB: + + WYŁ diff --git a/plugins/automation/src/main/res/values-sk-rSK/strings.xml b/plugins/automation/src/main/res/values-sk-rSK/strings.xml index fc631899aec..c2f610af630 100644 --- a/plugins/automation/src/main/res/values-sk-rSK/strings.xml +++ b/plugins/automation/src/main/res/values-sk-rSK/strings.xml @@ -102,7 +102,7 @@ Stav zásobníka %1$s %2$.0f Vek batérie v pumpe Úroveň nabitia batérie v pumpe %1$s %2$.0f - IOB [JI]: + IOB [U]: Vzdial. [m]: Čas zotavovania Každých diff --git a/plugins/automation/src/main/res/values-vi-rVN/strings.xml b/plugins/automation/src/main/res/values-vi-rVN/strings.xml index 6cc7af62240..286043fc12a 100644 --- a/plugins/automation/src/main/res/values-vi-rVN/strings.xml +++ b/plugins/automation/src/main/res/values-vi-rVN/strings.xml @@ -17,14 +17,14 @@ Không tạm dừng Tạm dừng vòng lặp %1$d phút Thông báo: %1$s - Chuyển Cấu hình sang - Chuyển Cấu hình sang %1$s + Chuyển Hồ sơ sang + Chuyển Hồ sơ sang %1$s Kết nối gần nhất đến bơm Lần kết nối gần nhất với bơm [phút trước] Lần kết nối gần nhất với bơm %1$s %2$s phút trước Đã thiết lập - Tỷ lệ phần trăm Cấu hình - Bắt đầu Cấu hình %1$d%% + Tỷ lệ phần trăm Hồ sơ + Bắt đầu Hồ sơ %1$d%% Tỷ lệ [%]: Gửi SMS: %1$s Gửi SMS đến tất cả các số @@ -42,7 +42,7 @@ Mục tiêu tạm thời không tồn tại Mục tiêu tạm thời %1$s %2$.0f %3$s Mục tiêu tạm thời %1$s %2$.1f %3$s - Cấu hình pct %1$s %2$d + Hồ sơ pct %1$s %2$d IOB %1$s %2$.1f Hoặc Hoặc loại trừ @@ -75,8 +75,8 @@ Autosens %1$s %2$s %% Autosens % %3$s %1$s %2$s - BG difference - BG difference [%1$s] + Chênh lệch đường huyết + Chênh lệch đường huyết [%1$s] Vị trí hiện tại Chọn từ bản đồ Vị trí @@ -96,12 +96,12 @@ Pod đã kích hoạt Thời gian insulin Insulin đã dùng %1$s được %2$.1f giờ - Pump battery age - Pump battery age %1$s %2$.1f hour + Pin của Bơm + Pin của bơm đã dùng %1$s %2$.1f giờ Reservoir level Reservoir level %1$s %2$.0f - Pump battery level - Mức pin của pump %1$s %2$.0f + Mức pin của Bơm + Mức pin của Bơm %1$s %2$.0f IOB [U]: Dist [m]: Thời gian định kỳ @@ -130,10 +130,10 @@ Đến giờ tiêm bolus! \nHãy chạy Bolus wizard và thực hiện tính toán lại. Lỗi khi cài đặt báo thức sắp tới - Autotune profile %1$s + Hồ sơ Autotune %1$s Chạy Autotune - Autotune đã chạy mà không có chuyển đổi Cấu hình - Autotune đã chạy và Cấu hình được tự động chuyển đổi + Autotune đã chạy mà không có chuyển đổi Hồ sơ + Autotune đã chạy và Hồ sơ được tự động chuyển đổi Lỗi trong lần chạy Autotune gần nhất Phát hiện một lần chạy Autotune khác, tiến trình đã bị hủy 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" /> + uri?.let { + // Check if user selected a subdirectory instead of root AAPS directory + val lastPathSegment = uri.lastPathSegment ?: "" + + // Extract directory name from the path + // First remove the storage prefix (e.g., "primary:") + val pathAfterColon = when { + lastPathSegment.contains(":") -> lastPathSegment.substringAfterLast(":") + else -> lastPathSegment + } + // Then get just the last directory name (e.g., "AAPS/preferences" -> "preferences") + val directoryName = pathAfterColon.substringAfterLast("/", pathAfterColon) + + // Warn if user selected a subdirectory instead of root AAPS directory + // These subdirectories are managed by the app + val managedSubdirectories = listOf("preferences", "extra", "exports", "temp") + if (managedSubdirectories.any { it.equals(directoryName, ignoreCase = true) }) { + WarningDialog.showWarning( + this, + rh.gs(R.string.warning_wrong_directory_selected), + rh.gs(R.string.warning_wrong_directory_message, directoryName), + android.R.string.ok + ) + return@registerForActivityResult + } + contentResolver.takePersistableUriPermission(uri, Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION) preferences.put(StringKey.AapsDirectoryUri, uri.toString()) rxBus.send(EventAAPSDirectorySelected(uri.path ?: "UNKNOWN")) @@ -107,6 +135,15 @@ open class DaggerAppCompatActivityWithResult : DaggerAppCompatActivity() { super.attachBaseContext(LocaleHelper.wrap(newBase)) } + override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { + super.onActivityResult(requestCode, resultCode, data) + + // Handle cloud import result + if (requestCode == CloudConstants.CLOUD_IMPORT_REQUEST_CODE && resultCode == RESULT_OK) { + importExportPrefs.doImportSharedPreferences(this) + } + } + // Used for SetupWizardActivity open fun updateButtons() {} } \ No newline at end of file diff --git a/plugins/configuration/src/main/kotlin/app/aaps/plugins/configuration/di/CloudStorageModule.kt b/plugins/configuration/src/main/kotlin/app/aaps/plugins/configuration/di/CloudStorageModule.kt new file mode 100644 index 00000000000..d4384d78000 --- /dev/null +++ b/plugins/configuration/src/main/kotlin/app/aaps/plugins/configuration/di/CloudStorageModule.kt @@ -0,0 +1,40 @@ +package app.aaps.plugins.configuration.di + +import app.aaps.plugins.configuration.maintenance.cloud.CloudStorageProvider +import app.aaps.plugins.configuration.maintenance.cloud.providers.googledrive.GoogleDriveProvider +import dagger.Binds +import dagger.Module +import dagger.multibindings.IntoSet + +/** + * Dagger module for cloud storage providers. + * + * This module uses multi-binding to automatically collect all CloudStorageProvider + * implementations. Adding a new cloud provider is as simple as: + * 1. Create a class implementing CloudStorageProvider + * 2. Add a new @Binds @IntoSet method here + * + * Runtime Provider Selection: + * - All providers registered here are injected into CloudStorageManager as a Set + * - CloudStorageManager stores them in a map keyed by storageType + * - User selects which provider to use via SharedPreferences (PREF_CLOUD_STORAGE_TYPE) + * - CloudStorageManager.getActiveProvider() returns the provider based on user's selection + * - This allows runtime switching without code changes + * + * The CloudStorageManager will automatically receive all registered providers + * without needing to know about specific implementations at compile time. + */ +@Module +abstract class CloudStorageModule { + + /** + * Bind GoogleDriveProvider into the set of cloud storage providers. + */ + @Binds + @IntoSet + abstract fun bindGoogleDriveProvider(provider: GoogleDriveProvider): CloudStorageProvider + + // Future providers can be added here: + // @Binds @IntoSet abstract fun bindDropboxProvider(provider: DropboxProvider): CloudStorageProvider + // @Binds @IntoSet abstract fun bindOneDriveProvider(provider: OneDriveProvider): CloudStorageProvider +} diff --git a/plugins/configuration/src/main/kotlin/app/aaps/plugins/configuration/di/ConfigurationModule.kt b/plugins/configuration/src/main/kotlin/app/aaps/plugins/configuration/di/ConfigurationModule.kt index 10c0086e9c2..63190440361 100644 --- a/plugins/configuration/src/main/kotlin/app/aaps/plugins/configuration/di/ConfigurationModule.kt +++ b/plugins/configuration/src/main/kotlin/app/aaps/plugins/configuration/di/ConfigurationModule.kt @@ -13,6 +13,7 @@ import app.aaps.plugins.configuration.configBuilder.RunningConfigurationImpl import app.aaps.plugins.configuration.maintenance.FileListProviderImpl import app.aaps.plugins.configuration.maintenance.ImportExportPrefsImpl import app.aaps.plugins.configuration.maintenance.MaintenanceFragment +import app.aaps.plugins.configuration.maintenance.activities.CloudPrefImportListActivity import app.aaps.plugins.configuration.maintenance.activities.CustomWatchfaceImportListActivity import app.aaps.plugins.configuration.maintenance.activities.LogSettingActivity import app.aaps.plugins.configuration.maintenance.activities.PrefImportListActivity @@ -25,7 +26,8 @@ import dagger.android.ContributesAndroidInjector @Module( includes = [ ConfigurationModule.Bindings::class, - SetupWizardModule::class + SetupWizardModule::class, + CloudStorageModule::class ] ) abstract class ConfigurationModule { @@ -37,6 +39,7 @@ abstract class ConfigurationModule { @ContributesAndroidInjector abstract fun contributesCsvExportWorker(): ImportExportPrefsImpl.CsvExportWorker @ContributesAndroidInjector abstract fun contributesApsResultExportWorker(): ImportExportPrefsImpl.ApsResultExportWorker @ContributesAndroidInjector abstract fun contributesPrefImportListActivity(): PrefImportListActivity + @ContributesAndroidInjector abstract fun contributesCloudPrefImportListActivity(): CloudPrefImportListActivity @ContributesAndroidInjector abstract fun contributesCustomWatchfaceImportListActivity(): CustomWatchfaceImportListActivity @ContributesAndroidInjector abstract fun encryptedPrefsFormatInjector(): EncryptedPrefsFormat @ContributesAndroidInjector abstract fun prefImportListProviderInjector(): FileListProvider diff --git a/plugins/configuration/src/main/kotlin/app/aaps/plugins/configuration/maintenance/ImportExportPrefsImpl.kt b/plugins/configuration/src/main/kotlin/app/aaps/plugins/configuration/maintenance/ImportExportPrefsImpl.kt index d35b75a8e82..e7679177f67 100644 --- a/plugins/configuration/src/main/kotlin/app/aaps/plugins/configuration/maintenance/ImportExportPrefsImpl.kt +++ b/plugins/configuration/src/main/kotlin/app/aaps/plugins/configuration/maintenance/ImportExportPrefsImpl.kt @@ -13,7 +13,9 @@ import androidx.core.content.ContextCompat import androidx.documentfile.provider.DocumentFile import androidx.fragment.app.Fragment import androidx.fragment.app.FragmentActivity +import androidx.lifecycle.lifecycleScope import androidx.work.ExistingWorkPolicy +import kotlinx.coroutines.launch import androidx.work.OneTimeWorkRequest import androidx.work.WorkManager import androidx.work.WorkerParameters @@ -67,6 +69,13 @@ import app.aaps.plugins.configuration.maintenance.data.PrefsFormat import app.aaps.plugins.configuration.maintenance.data.PrefsStatusImpl import app.aaps.plugins.configuration.maintenance.dialogs.PrefImportSummaryDialog import app.aaps.plugins.configuration.maintenance.formats.EncryptedPrefsFormat +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 app.aaps.plugins.configuration.maintenance.cloud.ImportSourceDialog +import app.aaps.plugins.configuration.maintenance.PrefsMetadataKeyImpl +import app.aaps.plugins.configuration.maintenance.activities.CloudPrefImportListActivity import app.aaps.shared.impl.weardata.ZipWatchfaceFormat import dagger.Reusable import io.reactivex.rxjava3.disposables.CompositeDisposable @@ -77,6 +86,10 @@ import java.io.FileNotFoundException import java.io.IOException import javax.inject.Inject +// Added for dialog callback explicit types +import android.content.DialogInterface +import androidx.appcompat.app.AlertDialog + /** * Created by mike on 03.07.2016. */ @@ -100,9 +113,18 @@ class ImportExportPrefsImpl @Inject constructor( private val context: Context, private val dataWorkerStorage: DataWorkerStorage, private val activePlugin: ActivePlugin, - private val configBuilder: ConfigBuilder + private val configBuilder: ConfigBuilder, + private val cloudStorageManager: CloudStorageManager, + private val exportOptionsDialog: ExportOptionsDialog, + private val importSourceDialog: ImportSourceDialog ) : ImportExportPrefs { + companion object { + var cloudPrefsFiles: List = 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 +253,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,40 +350,363 @@ class ImportExportPrefsImpl @Inject constructor( } private fun exportSharedPreferences(activity: FragmentActivity) { + // Check export destination preference for user settings + val localEnabled = exportOptionsDialog.isSettingsLocalEnabled() + val cloudEnabled = exportOptionsDialog.isSettingsCloudEnabled() + val isCloudActive = cloudStorageManager.isCloudStorageActive() + + // Determine export destinations + val exportToLocal = localEnabled + val exportToCloud = cloudEnabled && isCloudActive + + aapsLogger.info(LTag.CORE, "${CloudConstants.LOG_PREFIX} EXPORT exportToLocal=$exportToLocal, exportToCloud=$exportToCloud") + + if (exportToLocal && exportToCloud) { + // Export to both: local first, then cloud + exportToBoth(activity) + return + } + + if (exportToCloud) { + 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) + } + + /** + * Export to both local and cloud storage + * First export to local, then to cloud + */ + private fun exportToBoth(activity: FragmentActivity) { + // Check local directory first + val directoryUri = preferences.getIfExists(StringKey.AapsDirectoryUri) + if (directoryUri.isNullOrEmpty()) { + ToastUtils.errorToast(activity, rh.gs(R.string.error_accessing_filesystem_select_aaps_directory_properly)) + return + } + + prefFileList.ensureExportDirExists() + val newFile = prefFileList.newPreferenceFile() + + if (newFile == null) { + ToastUtils.errorToast(activity, rh.gs(R.string.exported_failed)) + return + } + + // Ask password once, then export to both destinations + askToConfirmExport(activity, newFile) { password -> + // Export to local first + doExportToLocal(activity, newFile, password) + // Then export to cloud + doExportToCloud(activity, password) + } + } + + 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 + } askToConfirmExport(activity, newFile) { password -> - // Save preferences - val exportResultMessage = if (savePreferences(newFile, password)) - rh.gs(R.string.exported) - else - rh.gs(R.string.exported_failed) + doExportToLocal(activity, newFile, password) + } + } + + /** + * Perform local export without password prompt + */ + private fun doExportToLocal(activity: FragmentActivity, newFile: DocumentFile, password: String) { + val exportResultMessage = if (savePreferences(newFile, password)) + rh.gs(R.string.exported) + else + rh.gs(R.string.exported_failed) + + ToastUtils.okToast(activity, exportResultMessage) + + disposable += persistenceLayer.insertPumpTherapyEventIfNewByTimestamp( + therapyEvent = TE.asSettingsExport(error = exportResultMessage), + timestamp = dateUtil.now(), + action = Action.EXPORT_SETTINGS, + source = Sources.Automation, + note = "Manual Local: $exportResultMessage", + listValues = listOf() + ).subscribe() + } + + private fun exportToCloud(activity: FragmentActivity) { + activity.lifecycleScope.launch { + // Pre-check cloud connection before asking for password + 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 + } + + 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 + } - // Send toast alert to overview - ToastUtils.okToast(activity, exportResultMessage) + // Create temp file for password prompt display + 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 + } - // Register this event - disposable += persistenceLayer.insertPumpTherapyEventIfNewByTimestamp( - therapyEvent = TE.asSettingsExport(error = exportResultMessage), - timestamp = dateUtil.now(), - action = Action.EXPORT_SETTINGS, // Signal export was done.... - source = Sources.Automation, - note = "Manual: $exportResultMessage", - listValues = listOf() - ).subscribe() + val timeLocal = org.joda.time.LocalDateTime.now().toString(org.joda.time.format.DateTimeFormat.forPattern("yyyy-MM-dd'_'HHmmss")) + val exportFileName = "${timeLocal}_${config.FLAVOR}.json" + val tempDoc = tempDir.createFile("application/json", exportFileName) + if (tempDoc == null) { + aapsLogger.error(LTag.CORE, "${CloudConstants.LOG_PREFIX} EXPORT_CREATE_TEMP_FAIL") + ToastUtils.errorToast(activity, rh.gs(R.string.exported_failed)) + return@launch + } + + askToConfirmExport(activity, tempDoc) { password -> + // Delete the temp file created for prompt, doExportToCloud will create its own + tempDoc.delete() + doExportToCloud(activity, password) + } } } + + /** + * Perform cloud export without password prompt + */ + private fun doExportToCloud(activity: FragmentActivity, password: String) { + 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 + } + + 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 + 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.export_to_cloud_failed)) + return@launch + } + + val timeLocal = org.joda.time.LocalDateTime.now().toString(org.joda.time.format.DateTimeFormat.forPattern("yyyy-MM-dd'_'HHmmss")) + val exportFileName = "${timeLocal}_${config.FLAVOR}.json" + val tempDoc = tempDir.createFile("application/json", exportFileName) + if (tempDoc == null) { + aapsLogger.error(LTag.CORE, "${CloudConstants.LOG_PREFIX} EXPORT_CREATE_TEMP_FAIL") + ToastUtils.errorToast(activity, rh.gs(R.string.export_to_cloud_failed)) + return@launch + } + + 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.export_to_cloud_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.export_to_cloud_failed)) + tempDoc.delete() + return@launch + } + + provider.getOrCreateFolderPath(CloudConstants.CLOUD_PATH_SETTINGS)?.let { + provider.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 = provider.uploadFileToPath( + exportFileName, bytes, "application/json", CloudConstants.CLOUD_PATH_SETTINGS + ) + if (uploadedFileId == null) { + uploadedFileId = provider.uploadFile(exportFileName, bytes, "application/json") + } + + 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) + } + + ToastUtils.infoToast(activity, exportResultMessage) + + disposable += persistenceLayer.insertPumpTherapyEventIfNewByTimestamp( + therapyEvent = TE.asSettingsExport(error = exportResultMessage), + timestamp = dateUtil.now(), + action = Action.EXPORT_SETTINGS, + source = Sources.Automation, + note = "Manual Cloud: $exportResultMessage", + listValues = listOf() + ).subscribe() + + tempDoc.delete() + } catch (e: Exception) { + aapsLogger.error(LTag.CORE, "${CloudConstants.LOG_PREFIX} EXPORT_EXCEPTION", e) + ToastUtils.errorToast(activity, rh.gs(R.string.export_to_cloud_failed)) + } + } + } + + override fun exportSharedPreferencesNonInteractive(context: Context, password: String): Boolean { + // Check export destination preferences (same logic as manual export) + val localEnabled = exportOptionsDialog.isSettingsLocalEnabled() + val cloudEnabled = exportOptionsDialog.isSettingsCloudEnabled() + val isCloudActive = cloudStorageManager.isCloudStorageActive() + + val exportToLocal = localEnabled + val exportToCloud = cloudEnabled && isCloudActive + + aapsLogger.info(LTag.CORE, "${CloudConstants.LOG_PREFIX} NONINTERACTIVE_EXPORT exportToLocal=$exportToLocal, exportToCloud=$exportToCloud") + + // Export to local if enabled + var localResult = true + if (exportToLocal) { + prefFileList.ensureExportDirExists() + val newFile = prefFileList.newPreferenceFile() + if (newFile != null) { + localResult = savePreferences(newFile, password) + aapsLogger.info(LTag.CORE, "${CloudConstants.LOG_PREFIX} NONINTERACTIVE_EXPORT_LOCAL result=$localResult") + } else { + aapsLogger.error(LTag.CORE, "${CloudConstants.LOG_PREFIX} NONINTERACTIVE_EXPORT_LOCAL_NO_FILE") + localResult = false + } + } + + // Export to cloud if enabled + if (exportToCloud) { + 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 + } + + 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 + } + + 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() } + } + + tempDoc.delete() + + if (fileContent != null) { + // Use uploadFileToPath for consistent folder structure + var uploadedFileId = provider.uploadFileToPath( + fileName, fileContent, "application/json", CloudConstants.CLOUD_PATH_SETTINGS + ) + if (uploadedFileId == null) { + uploadedFileId = provider.uploadFile(fileName, fileContent, "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 if at least one export method succeeded or was started + return if (exportToLocal && exportToCloud) { + localResult // Cloud is async, return local result + } else if (exportToCloud) { + true // Cloud export started (async) + } else { + localResult // Only local export + } } override fun importSharedPreferences(activity: FragmentActivity) { + // Check if both local and cloud are enabled - show selection dialog + if (importSourceDialog.shouldShowSourceSelection()) { + importSourceDialog.showImportSourceDialog(activity) { source -> + when (source) { + ImportSourceDialog.ImportSource.LOCAL -> importFromLocal(activity) + ImportSourceDialog.ImportSource.CLOUD -> importFromCloud(activity) + } + } + return + } + + // Only one source enabled - use it directly + val singleSource = importSourceDialog.getSingleEnabledSource() + when (singleSource) { + ImportSourceDialog.ImportSource.CLOUD -> { + importFromCloud(activity) + return + } + ImportSourceDialog.ImportSource.LOCAL, null -> { + importFromLocal(activity) + } + } + } + + private fun importFromLocal(activity: FragmentActivity) { + // 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 +719,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 +882,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 +928,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 +947,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 +994,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..b9febfaf17a 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,79 @@ 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() { + val isAllCloud = exportOptionsDialog.isAllCloudEnabled() + val isCloudActive = cloudStorageManager.isCloudStorageActive() + + // Log button text + val isLogCloud = isAllCloud || exportOptionsDialog.isLogCloudEnabled() + binding.logSend.text = rh.gs( + if (isLogCloud) R.string.send_logs_to_cloud else R.string.send_all_logs + ) + + // CSV button text + val isCsvCloud = isAllCloud || exportOptionsDialog.isCsvCloudEnabled() + binding.exportCsv.text = rh.gs( + if (isCsvCloud) R.string.export_csv_to_cloud else R.string.export_csv_to_local + ) + + // Settings export/import destinations + val isSettingsLocal = exportOptionsDialog.isSettingsLocalEnabled() + val isSettingsCloud = exportOptionsDialog.isSettingsCloudEnabled() + val bothEnabled = isSettingsLocal && isSettingsCloud && isCloudActive + val cloudOnly = isSettingsCloud && isCloudActive && !isSettingsLocal + + // Export button text + binding.navExport.text = rh.gs( + when { + bothEnabled -> R.string.export_settings_both + cloudOnly -> R.string.export_settings_cloud + else -> R.string.export_settings_local + } + ) + + // Import button text + binding.navImport.text = rh.gs( + when { + bothEnabled -> R.string.import_settings_both + cloudOnly -> R.string.import_settings_cloud + else -> 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..831735df062 --- /dev/null +++ b/plugins/configuration/src/main/kotlin/app/aaps/plugins/configuration/maintenance/cloud/CloudConstants.kt @@ -0,0 +1,66 @@ +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_EXPORT = "/AAPS/export" + const val CLOUD_PATH_SETTINGS = "${CLOUD_PATH_EXPORT}/preferences" + const val CLOUD_PATH_LOGS = "${CLOUD_PATH_EXPORT}/logs" + const val CLOUD_PATH_USER_ENTRIES = "${CLOUD_PATH_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..5d3b7b29ccf --- /dev/null +++ b/plugins/configuration/src/main/kotlin/app/aaps/plugins/configuration/maintenance/cloud/CloudDirectoryDialog.kt @@ -0,0 +1,375 @@ +package app.aaps.plugins.configuration.maintenance.cloud + +import android.content.Intent +import android.view.View +import android.widget.ImageView +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) + + // Authorization status section UI elements + val authStatusSection = dialogView.findViewById(R.id.authorization_status_section) + val authStatusText = dialogView.findViewById(R.id.authorization_status_text) + val authCheckIcon = dialogView.findViewById(R.id.authorization_check_icon) + val cloudPathText = dialogView.findViewById(R.id.cloud_path_text) + + val currentType = cloudStorageManager.getActiveStorageType() + val isCloudSelected = currentType == StorageTypes.GOOGLE_DRIVE + + // Initial state: set radio button based on current settings + googleDriveRadio.isChecked = isCloudSelected + + // Update authorization status section + updateAuthorizationStatusSection( + activity, + authStatusSection, + authStatusText, + authCheckIcon, + cloudPathText + ) + + // Use MaterialAlertDialogBuilder with DialogTheme to match project style + // Title is now in the layout itself, so we don't use setCustomTitle + val dialog = MaterialAlertDialogBuilder(activity, app.aaps.core.ui.R.style.DialogTheme) + .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)) { _, _ -> + // Still enable local storage even when user declines cloud export + exportOptionsDialog.enableLocalStorage() + 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() + } + + /** + * Update authorization status section based on current cloud storage state + */ + private fun updateAuthorizationStatusSection( + activity: DaggerAppCompatActivityWithResult, + authStatusSection: LinearLayout, + authStatusText: TextView, + authCheckIcon: ImageView, + cloudPathText: TextView + ) { + val provider = cloudStorageManager.getProvider(StorageTypes.GOOGLE_DRIVE) + val hasCredentials = provider?.hasValidCredentials() == true + val hasConnectionError = cloudStorageManager.hasConnectionError() + + if (hasCredentials) { + // Show authorization status section + authStatusSection.visibility = View.VISIBLE + + if (hasConnectionError) { + // Need re-authorization - use provider's resource ID + authStatusText.text = rh.gs(provider.reAuthRequiredTextResId) + authStatusText.setTextColor(activity.getColor(R.color.cloud_status_warning)) + authCheckIcon.setImageResource(R.drawable.ic_error) + authCheckIcon.setColorFilter(activity.getColor(R.color.cloud_status_warning)) + } else { + // Authorized successfully - use provider's resource ID + authStatusText.text = rh.gs(provider.authorizedTextResId) + authStatusText.setTextColor(activity.getColor(R.color.cloud_status_success)) + authCheckIcon.setImageResource(R.drawable.ic_meta_ok) + authCheckIcon.setColorFilter(activity.getColor(R.color.cloud_status_success)) + } + + // Set cloud path + cloudPathText.text = CloudConstants.CLOUD_PATH_EXPORT + } else { + // Hide authorization status section if no credentials + authStatusSection.visibility = View.GONE + } + } +} 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..2e218dac0f4 --- /dev/null +++ b/plugins/configuration/src/main/kotlin/app/aaps/plugins/configuration/maintenance/cloud/CloudStorageProvider.kt @@ -0,0 +1,193 @@ +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 + + /** + * String resource ID for "authorized" status text (e.g., "Google Drive Authorized") + */ + val authorizedTextResId: Int + + /** + * String resource ID for "re-authorization required" status text + */ + val reAuthRequiredTextResId: 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..6be0ce5c8a1 --- /dev/null +++ b/plugins/configuration/src/main/kotlin/app/aaps/plugins/configuration/maintenance/cloud/ExportOptionsDialog.kt @@ -0,0 +1,298 @@ +package app.aaps.plugins.configuration.maintenance.cloud + +import android.content.Context +import android.widget.CheckBox +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 settingsLocalCheckbox = dialogView.findViewById(R.id.settings_local_checkbox) + val settingsCloudCheckbox = dialogView.findViewById(R.id.settings_cloud_checkbox) + 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) + settingsLocalCheckbox.isChecked = sp.getBoolean(PREF_SETTINGS_LOCAL_ENABLED, true) // Default to local + settingsCloudCheckbox.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 + settingsCloudCheckbox.isEnabled = false + csvCloudSwitch.isEnabled = false + + // Force disable cloud options and enable local/email options + logCloudSwitch.isChecked = false + settingsCloudCheckbox.isChecked = false + csvCloudSwitch.isChecked = false + + if (!logEmailSwitch.isChecked && !logCloudSwitch.isChecked) { + logEmailSwitch.isChecked = true + } + if (!settingsLocalCheckbox.isChecked && !settingsCloudCheckbox.isChecked) { + settingsLocalCheckbox.isChecked = true + } + if (!csvLocalSwitch.isChecked && !csvCloudSwitch.isChecked) { + csvLocalSwitch.isChecked = true + } + } + + // Set up mutual exclusivity for log and csv rows (they still use switches) + setupMutualExclusivity(logEmailSwitch, logCloudSwitch) + setupMutualExclusivity(csvLocalSwitch, csvCloudSwitch) + + // Set up at-least-one-selected logic for settings checkboxes + setupAtLeastOneSelected(settingsLocalCheckbox, settingsCloudCheckbox) + + // Apply master All-Cloud behavior - shared logic for both initialization and user interaction + val applyAllCloudState: (Boolean) -> Unit = { enabled -> + if (enabled) { + // Require cloud for all rows, but preserve local settings checkbox state + logCloudSwitch.isChecked = true + settingsCloudCheckbox.isChecked = true + csvCloudSwitch.isChecked = true + logEmailSwitch.isChecked = false + // settingsLocalCheckbox - keep current state, don't force uncheck + csvLocalSwitch.isChecked = false + + // Disable per-row toggles when master is on (except settings local which can be toggled) + logEmailSwitch.isEnabled = false + logCloudSwitch.isEnabled = false + // settingsLocalCheckbox stays enabled - user can choose to have both local+cloud + settingsCloudCheckbox.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 + settingsLocalCheckbox.isEnabled = true + settingsCloudCheckbox.isEnabled = isCloudConfigured + csvLocalSwitch.isEnabled = true + csvCloudSwitch.isEnabled = isCloudConfigured + } + } + + // Apply initial state based on saved preferences + if (allCloudSwitch.isChecked && isCloudConfigured) { + applyAllCloudState(true) + } + + 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, settingsLocalCheckbox.isChecked) + sp.putBoolean(PREF_SETTINGS_CLOUD_ENABLED, settingsCloudCheckbox.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 + } + } + } + + /** + * Set up at-least-one-selected logic for two checkboxes + * Both can be selected, but at least one must be selected + */ + private fun setupAtLeastOneSelected(checkbox1: CheckBox, checkbox2: CheckBox) { + checkbox1.setOnCheckedChangeListener { _, isChecked -> + // Ensure at least one is checked + if (!isChecked && !checkbox2.isChecked) { + checkbox1.isChecked = true + } + } + + checkbox2.setOnCheckedChangeListener { _, isChecked -> + // Ensure at least one is checked + if (!isChecked && !checkbox1.isChecked) { + checkbox2.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 isSettingsLocalEnabled(): Boolean { + val value = sp.getBoolean(PREF_SETTINGS_LOCAL_ENABLED, true) + aapsLogger.info(LTag.CORE, "$LOG_PREFIX ExportDestination: isSettingsLocalEnabled=$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 + } + + /** + * Check if both local and cloud are enabled for settings export + */ + fun isSettingsBothEnabled(): Boolean { + val localEnabled = sp.getBoolean(PREF_SETTINGS_LOCAL_ENABLED, true) + val cloudEnabled = sp.getBoolean(PREF_SETTINGS_CLOUD_ENABLED, false) + val bothEnabled = localEnabled && cloudEnabled + aapsLogger.info(LTag.CORE, "$LOG_PREFIX ExportDestination: isSettingsBothEnabled=$bothEnabled (local=$localEnabled, cloud=$cloudEnabled)") + return bothEnabled + } + + 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, true) + sp.putBoolean(PREF_SETTINGS_CLOUD_ENABLED, true) + sp.putBoolean(PREF_CSV_LOCAL_ENABLED, false) + sp.putBoolean(PREF_CSV_CLOUD_ENABLED, true) + } + + /** + * Enable local storage for settings export + * This is called when user declines cloud export but we still want to ensure local storage is enabled + */ + fun enableLocalStorage() { + aapsLogger.info(LTag.CORE, "$LOG_PREFIX ExportDestination: Enabling local storage") + sp.putBoolean(PREF_SETTINGS_LOCAL_ENABLED, true) + } +} diff --git a/plugins/configuration/src/main/kotlin/app/aaps/plugins/configuration/maintenance/cloud/ImportSourceDialog.kt b/plugins/configuration/src/main/kotlin/app/aaps/plugins/configuration/maintenance/cloud/ImportSourceDialog.kt new file mode 100644 index 00000000000..4f72b2ccca8 --- /dev/null +++ b/plugins/configuration/src/main/kotlin/app/aaps/plugins/configuration/maintenance/cloud/ImportSourceDialog.kt @@ -0,0 +1,93 @@ +package app.aaps.plugins.configuration.maintenance.cloud + +import android.widget.LinearLayout +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.plugins.configuration.R +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Dialog to let user choose import source when both local and cloud are enabled + */ +@Singleton +class ImportSourceDialog @Inject constructor( + private val aapsLogger: AAPSLogger, + private val rh: ResourceHelper, + private val exportOptionsDialog: ExportOptionsDialog, + private val cloudStorageManager: CloudStorageManager +) { + + enum class ImportSource { + LOCAL, + CLOUD + } + + /** + * Check if import source selection dialog should be shown + * Returns true if both local and cloud are enabled for settings import + */ + fun shouldShowSourceSelection(): Boolean { + val localEnabled = exportOptionsDialog.isSettingsLocalEnabled() + val cloudEnabled = exportOptionsDialog.isSettingsCloudEnabled() + val isCloudActive = cloudStorageManager.isCloudStorageActive() + + // Show selection dialog only when both are enabled and cloud is properly configured + val shouldShow = localEnabled && cloudEnabled && isCloudActive + aapsLogger.info(LTag.CORE, "${CloudConstants.LOG_PREFIX} ImportSourceDialog: shouldShowSourceSelection=$shouldShow (local=$localEnabled, cloud=$cloudEnabled, cloudActive=$isCloudActive)") + return shouldShow + } + + /** + * Get the single enabled import source if only one is enabled + * Returns null if both are enabled (should show dialog) + */ + fun getSingleEnabledSource(): ImportSource? { + val localEnabled = exportOptionsDialog.isSettingsLocalEnabled() + val cloudEnabled = exportOptionsDialog.isSettingsCloudEnabled() + val isCloudActive = cloudStorageManager.isCloudStorageActive() + + return when { + localEnabled && (!cloudEnabled || !isCloudActive) -> ImportSource.LOCAL + cloudEnabled && isCloudActive && !localEnabled -> ImportSource.CLOUD + else -> null // Both enabled, need to show dialog + } + } + + /** + * Show import source selection dialog + */ + fun showImportSourceDialog( + activity: FragmentActivity, + onSourceSelected: (ImportSource) -> Unit + ) { + aapsLogger.info(LTag.CORE, "${CloudConstants.LOG_PREFIX} ImportSourceDialog: Showing import source selection dialog") + + val dialogView = activity.layoutInflater.inflate(R.layout.dialog_import_source, null) + + val localStorageRow = dialogView.findViewById(R.id.local_storage_row) + val cloudStorageRow = dialogView.findViewById(R.id.cloud_storage_row) + + val dialog = AlertDialog.Builder(activity) + .setView(dialogView) + .setNegativeButton(rh.gs(app.aaps.core.ui.R.string.cancel), null) + .create() + + localStorageRow.setOnClickListener { + aapsLogger.info(LTag.CORE, "${CloudConstants.LOG_PREFIX} ImportSourceDialog: User selected LOCAL") + dialog.dismiss() + onSourceSelected(ImportSource.LOCAL) + } + + cloudStorageRow.setOnClickListener { + aapsLogger.info(LTag.CORE, "${CloudConstants.LOG_PREFIX} ImportSourceDialog: User selected CLOUD") + dialog.dismiss() + onSourceSelected(ImportSource.CLOUD) + } + + dialog.show() + } +} 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..6885ba2f055 --- /dev/null +++ b/plugins/configuration/src/main/kotlin/app/aaps/plugins/configuration/maintenance/cloud/providers/googledrive/GoogleDriveManager.kt @@ -0,0 +1,1419 @@ +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 + // Notify UI to update cloud storage error state immediately + rxBus.send(EventCloudStorageStatusChanged()) + } + + /** + * 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..b8cc077ac8c --- /dev/null +++ b/plugins/configuration/src/main/kotlin/app/aaps/plugins/configuration/maintenance/cloud/providers/googledrive/GoogleDriveProvider.kt @@ -0,0 +1,203 @@ +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 + + override val authorizedTextResId: Int = R.string.google_drive_authorized + + override val reAuthRequiredTextResId: Int = R.string.google_drive_reauth_required + + // ==================== 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..36b398afab3 --- /dev/null +++ b/plugins/configuration/src/main/res/layout/dialog_cloud_directory.xml @@ -0,0 +1,197 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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..149dcecc83d --- /dev/null +++ b/plugins/configuration/src/main/res/layout/dialog_export_options.xml @@ -0,0 +1,201 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/plugins/configuration/src/main/res/layout/dialog_import_source.xml b/plugins/configuration/src/main/res/layout/dialog_import_source.xml new file mode 100644 index 00000000000..1db07485942 --- /dev/null +++ b/plugins/configuration/src/main/res/layout/dialog_import_source.xml @@ -0,0 +1,125 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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" /> - +