diff --git a/.circleci/config.yml b/.circleci/config.yml index 89820b0a5201..835036c3d333 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -264,7 +264,15 @@ jobs: command: | export ANDROID_SDK_ROOT=/usr/lib/android-sdk export ANDROID_HOME=/usr/lib/android-sdk - $GRADLE_PIN ./gradlew --continue -Dorg.gradle.daemon=true -Dorg.gradle.workers.max=8 testFullDebugUnitTest + # jvmTest and testAndroidHostTest cover the multiplatform modules. They have no build + # variants, so testFullDebugUnitTest does not exist for them and their tests would + # silently not run at all - which is what happened to :core:data and :core:nssdk, and + # showed up as a Codecov drop the moment :core:keys converted too. + # Both task names are unqualified on purpose: Gradle runs them in whichever projects + # have them, so a module converting later is picked up without editing this file. + # Deliberately not `allTests`: that would pull in the Kotlin/Native toolchain for the + # mingw and iOS targets, whose tests cannot run on a Linux box anyway. + $GRADLE_PIN ./gradlew --continue -Dorg.gradle.daemon=true -Dorg.gradle.workers.max=8 testFullDebugUnitTest jvmTest testAndroidHostTest - run: name: Non-app module androidTests (Gradle, emu-5554) diff --git a/.github/workflows/ios-ci.yml b/.github/workflows/ios-ci.yml new file mode 100644 index 000000000000..135c928d7250 --- /dev/null +++ b/.github/workflows/ios-ci.yml @@ -0,0 +1,137 @@ +name: iOS CI (Kotlin/Native) + +# The only place the Apple side can actually be checked. +# +# Klibs for iosArm64 / iosSimulatorArm64 cross compile on any host, so a developer on Windows or +# Linux already knows the shared code COMPILES for iOS. What no other machine can do is RUN it: +# `iosSimulatorArm64Test` is disabled off macOS with "simulator tests require macOS", and it reports +# SKIPPED rather than failing, so a green build elsewhere says nothing about iOS behaviour. +# +# That gap matters most for the four platform actuals under the shared data spine. `utcOffset` is +# written to every record, takes part in contentEqualsTo and is validated by Nightscout, and it comes +# from NSTimeZone on Apple and TimeZone.getOffset on the JVM. NumberFormatParityTest and +# SystemTimeZoneTest in commonTest exist to compare them; this job is where they finally execute. +# +# No secrets: nothing is signed, published or uploaded. + +on: + workflow_dispatch: + push: + paths: + - 'core/data/**' + - 'core/nssdk/**' + - 'core/keys/**' + - 'gradle/libs.versions.toml' + - 'buildSrc/**' + - '.github/workflows/ios-ci.yml' + pull_request: + paths: + - 'core/data/**' + - 'core/nssdk/**' + - 'core/keys/**' + - 'gradle/libs.versions.toml' + - 'buildSrc/**' + +jobs: + ios: + runs-on: macos-latest + timeout-minutes: 60 + + steps: + - uses: actions/checkout@v5 + with: + fetch-depth: 1 + + - name: Load jdk version + run: | + JAVA_VERSION=$(jq -r '.default' .github/jdk-map.json) + echo "JAVA_VERSION=$JAVA_VERSION" >> $GITHUB_ENV + + - name: Set up JDK + uses: actions/setup-java@v5 + with: + java-version: ${{ env.JAVA_VERSION }} + distribution: 'temurin' + cache: gradle + + # Kotlin/Native downloads its own toolchain on first use; without this every run pays for it. + - name: Cache Kotlin/Native + uses: actions/cache@v4 + with: + path: ~/.konan + key: konan-${{ runner.os }}-${{ hashFiles('gradle/libs.versions.toml') }} + restore-keys: konan-${{ runner.os }}- + + - name: Compile the Apple targets + run: | + # Actions runs `bash -e` but NOT pipefail, so without this the exit code of the pipeline + # would be tee's and a failed Gradle build would report success. + set -o pipefail + ./gradlew --console=plain --no-daemon \ + :core:data:compileKotlinIosArm64 :core:data:compileKotlinIosSimulatorArm64 \ + :core:nssdk:compileKotlinIosArm64 :core:nssdk:compileKotlinIosSimulatorArm64 \ + :core:keys:compileKotlinIosArm64 :core:keys:compileKotlinIosSimulatorArm64 \ + | tee compile.log + + # A disabled Kotlin/Native task reports SKIPPED and still exits 0, so "BUILD SUCCESSFUL" alone + # would stay green even if the whole Apple side silently stopped building. Cross-compilability + # is also transitive: a single cinterop anywhere in the project dependency graph turns these + # into no-ops. Assert the work happened rather than that nothing complained. + - name: Assert the Apple compiles really ran + run: | + if grep -E "Task :core:(data|nssdk|keys):compileKotlinIos(Arm64|SimulatorArm64) (SKIPPED|NO-SOURCE)" compile.log; then + echo "::error::An iOS compile task was skipped instead of executed." + exit 1 + fi + for m in data nssdk keys; do + for t in iosArm64 iosSimulatorArm64; do + MANIFEST=$(find core/$m/build/classes/kotlin/$t/main/klib -name manifest 2>/dev/null | head -1) + if [ -z "$MANIFEST" ]; then + echo "::error::core/$m produced no $t klib" + exit 1 + fi + echo "core/$m $t -> $(grep native_targets "$MANIFEST")" + done + done + + # The part that only exists here. commonTest runs through Kotlin/Native against the real + # NSNumberFormatter and NSTimeZone, which is what no other host can do. + # + # Only :core:data has commonTest today, so it is the one module asserted below. The other two + # are still run: if either grows shared tests later they start executing here with no change to + # this file, and until then they are simply NO-SOURCE. + - name: Run the shared tests on the iOS simulator + # GitHub's macOS runners are UTC, and at UTC every assertion in SystemTimeZoneTest passes + # trivially - the offset is zero whatever unit it is in, so a seconds-instead-of-milliseconds + # bug in the NSTimeZone actual would sail straight through. Pin a zone that has both a + # non-zero offset and daylight saving, so the whole-minutes, range and whole-hour-DST + # assertions actually mean something. Foundation honours TZ on macOS, as does the JVM. + env: + TZ: Europe/Prague + run: | + set -o pipefail + echo "Running with TZ=$TZ" + ./gradlew --console=plain --no-daemon \ + :core:data:iosSimulatorArm64Test :core:nssdk:iosSimulatorArm64Test :core:keys:iosSimulatorArm64Test \ + | tee test.log + + - name: Assert the iOS tests really ran + run: | + if grep -E "Task :core:data:iosSimulatorArm64Test (SKIPPED|NO-SOURCE)" test.log; then + echo "::error::Module core:data had its iOS tests skipped instead of executed. On macOS they must run." + exit 1 + fi + FOUND=$(find core/data/build/test-results/iosSimulatorArm64Test -name "TEST-*.xml" 2>/dev/null | wc -l) + echo "iOS test result files for :core:data: $FOUND" + [ "$FOUND" -gt 0 ] || { echo "::error::No iOS test results were produced."; exit 1; } + # Surface the counts, so a run that executed zero assertions is visible in the log. + grep -ho 'tests="[0-9]*" skipped="[0-9]*" failures="[0-9]*" errors="[0-9]*"' \ + core/data/build/test-results/iosSimulatorArm64Test/TEST-*.xml || true + + - name: Upload test results + if: always() + uses: actions/upload-artifact@v4 + with: + name: ios-test-results + path: core/*/build/test-results/iosSimulatorArm64Test/** + if-no-files-found: warn diff --git a/_docs/KMP_IOS_FEASIBILITY.md b/_docs/KMP_IOS_FEASIBILITY.md new file mode 100644 index 000000000000..da7c3a908160 --- /dev/null +++ b/_docs/KMP_IOS_FEASIBILITY.md @@ -0,0 +1,2115 @@ +# Kotlin Multiplatform and a possible iOS client + +Written 2026-08-05. Notes from a review of how far AAPS is from sharing code with an iOS app. + +The goal that started this: **an iOS AAPSClient (follower), not a master app.** Not necessarily a +full KMP app, just a way to avoid writing everything again from zero. + +All numbers below were measured on the `dev` branch. They are counts of files under `src/`, +build folders excluded. + +--- + +## 1. Short answer + +*(Written when there was no KMP setup at all. Three modules are multiplatform now - `:core:data`, +`:core:nssdk` and `:core:keys` - and the last of them compiles for real iOS targets. See section 7 +for where things actually stand; the rest of this section is kept because the reasoning still holds.)* + +There was **no KMP setup in the project** when this note was written. No module used the +multiplatform plugin. (`:pump:combov2:comboctl` has `commonMain` / `androidMain` folder names left +over from the upstream project, but it builds as a normal Android library.) + +The shape of the code is much better than in a typical Android app, mostly because of the Compose +migration and the RxJava removal. The realistic path is **Kotlin Multiplatform with Compose +Multiplatform for the UI**, done step by step, starting with a small working slice on a real iPhone. + +--- + +## 2. What is already fine + +| Area | State | Why it matters | +|--------------------------------|--------------------------------------------------------------------------------------------------|----------------------------------------| +| `:core:data` | 58 files, plain `java-library`, **0** Android imports. Two `expect`/`actual` away, see section 8 | The first module to make multiplatform | +| `:core:keys` | 46 files, **0** Android and **0** `java.*` imports since Wave 3 | Only the 381 `R.string` ids block it | +| Room | 46 DAOs, **0** RxJava return types, 22 `suspend`, already on `BundledSQLiteDriver` | This is exactly the Room KMP setup | +| Compose | **0** XML layouts in `:core:ui`, 2 left in `:ui` | Compose Multiplatform can use this | +| `LocalContext.current` | 7 in `:core:ui`, 8 in `:ui` | Very small coupling to Android | +| Network code | 5 files touch Retrofit / OkHttp, 1 touches socket.io | REST part is small enough for Ktor | +| kotlinx.serialization | 64 files (Gson: 34) | Already the main choice | +| kotlinx-datetime | Declared in `libs.versions.toml` | Ready to use | +| `androidx.lifecycle` ViewModel | Multiplatform since 2.8 | Most of the 129 uses are fine | +| Vico charts | Ships a `multiplatform` artifact | Only the artifact name changes | + +The biggest surprise was `:core:ui`. Of its 434 files: + +- 424 are under `compose/` +- **0** import Dagger or `javax.inject` +- 27 import `app.aaps.core.interfaces` +- 34 import `app.aaps.core.keys` +- **only 16 import `android.*`** + +Wave 14 re-measured that last line, because it is the one that decides whether a shared Compose UI is +realistic, and an earlier count of "427 of 434" was wrong - it grepped `^import android` without the +dot, which also matches `androidx`. The real figure is 16. The other 424 import `androidx.*`, and +almost all of those are `androidx.compose.*`, which Compose Multiplatform publishes under the *same +package names*. What is left is a tail of about 26 non-Compose androidx imports - +`core.graphics` (6), `lifecycle.compose` (5), `annotation` (3), `activity.compose` (3), and single +uses of `fragment.app`, `appcompat.app`, `core.view`, `core.content` and `activity.result`. + +So most of the Compose work of the last year can be reused. + +--- + +## 3. What does not work on iOS + +### Libraries with no Kotlin/Native version + +| Library | Files | Replacement | +|--------------------------------------------------------|--------------------------------------------------------|-----------------------------------------| +| Dagger / Hilt | ~300 | kotlin-inject, Metro, or Koin | +| RxJava 3 | RxBus in 35 `:ui`, 19 config, 18 sync, 13 impl, 12 aps | SharedFlow | +| Retrofit + OkHttp | 9 / 11, **0 in `:core:nssdk`** | Ktor client - done there, see section 8 | +| socket.io-client | 1 (`NSClientV3Service`) | **Do not replace** - see section 3a | +| Gson | 46, **0 in `:core:nssdk`** | kotlinx.serialization - see section 8 | +| `org.json` (`JSONObject`) | 227, **0 in `:core:nssdk`** | kotlinx `JsonObject` - see section 8 | +| WorkManager | 44 | See warning below | +| joda-time | 5 | kotlinx-datetime | +| `java.text.DecimalFormat` | 74 | Done, see section 8 | +| `java.text.SimpleDateFormat` | 7 | kotlinx-datetime formatting | +| `java.util.concurrent.TimeUnit` | 108 files, but only 93 sites convertible | `kotlin.time.Duration` | +| `Executors`, `ConcurrentHashMap` | 14 | Coroutine dispatchers, map plus mutex | +| `java.security`, `javax.crypto` | ~20 | cryptography-kotlin, or expect/actual | +| `java.io.File` | 12 | okio | +| spongycastle, tink-android | 2 | as above | +| commons-lang3, Guava | 4 | Inline the few helpers | +| slf4j, logback-android | 4 | Kermit or Napier | +| kotlin-reflect | 9 | No reflection on Native, must go | +| Firebase | 4 | GitLive Firebase KMP, or expect/actual | +| Play Services | 2 | Android only by nature | +| androidx.glance (widgets) | 7 | WidgetKit is Swift only, no reuse | +| Garmin, osmdroid, androidsvg, appauth, java-otp, zxing | 1-3 each | Mostly not client features | + +### Android framework + +`Context` (28 impl, 28 sync, 16 source, **15 in `:core:interfaces`**), SharedPreferences, +notifications, the NSClient foreground service, DocumentFile / SAF (9 files), +Fragment / AppCompatActivity (12), and `R.string` (~136 `:ui`, 61 `:core:ui`, 60 impl, 60 sync). + +### Two that are not only porting cost + +- **socket.io** - not a library choice, a protocol the server dictates. See section 3a; it is the + one entry in the table above that must be **kept and abstracted**, not replaced. +- **WorkManager** - iOS has no equivalent. `BGTaskScheduler` only gives wake ups that the system may + delay for hours. A follower that expects to run every 5 minutes cannot work that way. The real + answer is push (APNs) from a server, which changes the design, not only the code. + +### Habits that block portability + +1. **Android types in `:core:interfaces`** - 49 of 253 files import Android (`SP`, + `ResourceHelper`, `UiInteraction`, `PluginDescription`). It is the contract module, so anything + depending on it inherits Android. +2. **Resource ids used as data** - `@StringRes Int` inside `PluginDescription`, notifications and + `UserEntryPresentationHelper`. An `Int` resource id means nothing off Android. +3. **`PluginBase` is an Android class** - the plugin registry is the spine of the app. +4. **Field `@Inject` into Fragments and Services** - constructor injection ports, field injection + does not. +5. **RxBus as a global untyped bus** - the main reason logic classes cannot move. +6. **Reflection based serialization** - Gson plus kotlin-reflect. +7. **Assuming background execution is always possible** - the deepest assumption of all. + +--- + +## 3a. socket.io - keep it, do not replace it + +**Nightscout runs a Socket.IO server.** That is the reason AAPS uses a Socket.IO client, and it is +not a choice this project gets to revisit. + +Socket.IO is not "WebSocket with a helper library". It is its own protocol on top: Engine.IO +framing, a handshake, namespaces, acks and its own reconnect rules. **A plain Ktor WebSocket client +cannot talk to a Socket.IO server at all.** Polling is not a fallback either - dropping the socket +means giving up push updates, which is the whole point of NSClientV3. + +So this dependency is **abstracted, not removed**: + +| Where | What | +|---------|---------------------------------------------------------| +| Android | keep `io.socket:socket.io-client:2.1.2` **unchanged** | +| iOS | `socket.io-client-swift` | +| shared | a small `expect interface` over the calls actually used | + +Both clients are written by the Socket.IO project itself, so protocol compatibility follows the +server rather than a third party's reimplementation. That matters more here than saving a +dependency. + +**The API surface is tiny.** `NSClientV3Service` uses only: + +``` +IO.socket(url) · .on(event, listener) · .off(event, listener) +.connect() · .disconnect() · .emit(..., Ack) +Socket.EVENT_CONNECT / EVENT_DISCONNECT +``` + +with the events `create`, `update`, `delete`, `announcement`, `alarm`, `urgent_alarm`, +`clear_alarm`. About 30 lines of `expect` / `actual` covers it. + +Kotlin Multiplatform Socket.IO libraries do exist - [moko-socket-io], [KotSock] (both wrap the same +two native clients), and pure Kotlin ports such as [dyte-io/socketio-kotlin] and [kmp-socketio]. For +this API surface a third party wrapper buys about 30 lines while adding a dependency on the sync +path. A pure Kotlin port is worse: it would **replace the working Android client** with a +reimplementation, which is the wrong direction of risk for a medical app. + +### Where to look first + +Since the plan is to write our own small wrapper rather than depend on one, the useful reading is +the existing wrappers' source, because they already are the `expect` / `actual` we would write: + +| Link | Why | +|---------------------------|--------------------------------------------------------------------------------------------------------------| +| [moko-socket-io] | Cleanest reference. Small, `expect class Socket` over the two native clients. | +| [KotSock] | Same approach, a second opinion on the API shape. | +| [moko-socket-io-sample] | A working KMP app using it, by the moko maintainer. Shows the CocoaPods wiring for `socket.io-client-swift`. | +| [KMP Socket.IO deep dive] | Walks through building exactly this kind of wrapper. Closest thing to the POC we would be reproducing. | + +**Check the dates before trusting any of them as a dependency.** moko-socket-io states Gradle 6.8+, +Android API 16+, iOS 11.0+ - those baselines are from around 2021, while this project is on Gradle +9.6.1 and Kotlin 2.4.10. That is a warning sign for a live dependency on the sync path, and no +problem at all for reading it as a pattern. + +[moko-socket-io]: https://github.com/icerockdev/moko-socket-io + +[moko-socket-io-sample]: https://github.com/Alex009/moko-socket-io-sample + +[KotSock]: https://github.com/whiterabb17/KotSock + +[dyte-io/socketio-kotlin]: https://github.com/dyte-io/socketio-kotlin + +[kmp-socketio]: https://klibs.io/project/HackWebRTC/kmp-socketio + +[KMP Socket.IO deep dive]: https://rahuljindaltech.medium.com/building-cross-platform-libraries-with-kotlin-multiplatform-kmp-kmm-a-deep-dive-into-socket-io-89e58c3b221c + +### Protocol versions - checked + +Socket.IO major versions are **not** wire compatible, so the server and both clients have to agree. +Checked on 2026-08-05: + +| Part | Version | Protocol | +|----------------------------------------|------------------------------------|--------------------------------------| +| Nightscout `cgm-remote-monitor` 15.0.7 | `socket.io ~4.5.4` | **v4** | +| AAPS today, Android | `io.socket:socket.io-client:2.1.2` | v3 / v4 - correct | +| iOS should use | `Socket.IO-Client-Swift` **16.x** | v3 / v4 | +| moko-socket-io 0.6.0, iOS half | `Socket.IO-Client-Swift ~> 15.2.0` | **v2 - will not talk to Nightscout** | + +`socket.io-client-java` 2.x speaks Socket.IO 3 / 4. On the Swift side that generation only arrives +in **16.0.0** (February 2024, "now supports Socket.IO 3 servers"); 15.x is Socket.IO 2 only. + +So **moko-socket-io cannot be used against Nightscout as it is**, and simply bumping its pod to 16.x +is not a fix either - v15 to v16 had breaking API changes (there is an official `15to16` migration +guide), so moko's own wrapper code would not compile against it. + +Writing our own wrapper avoids the whole question, because we pick both versions ourselves: +`socket.io-client:2.1.2` unchanged on Android, `Socket.IO-Client-Swift` 16.x on iOS. + +Useful escape hatch: the Swift 16.x client can still reach a Socket.IO 2 server by passing +`.version(.two)` to the manager. So 16.x is the right choice even for someone running an old +Nightscout. + +### One more thing to check before writing it + +**The two clients may not behave the same.** `NSClientV3Service` already carries a comment that +*"java io.client doesn't support multiplexing, create 2 sockets"*, and a leak note about +`Manager.nsps` being a process-static, never-pruned cache. Whether the Swift client shares those +quirks is exactly what an `expect` / `actual` boundary hides until it bites. + +--- + +## 4. Options + +### A - Native SwiftUI client, no shared code + +A follower reads Nightscout, shows it, and writes treatments back. That is a REST and socket +contract, not AAPS code. LoopFollow and Nightguard already do this. Cheapest, but nothing is +shared and the two apps drift apart forever. + +### B - Shared KMP core, SwiftUI on top + +Share `:core:data`, `:core:keys`, a Ktor Nightscout client, the Room layer, and the calculations +(IOB / COB, profile, TIR / TDD). UI written twice. + +### C - KMP plus Compose Multiplatform <- recommended + +Share logic **and** UI. This is more realistic here than in most projects because the UI is already +Compose and 84% of `:core:ui` has no Android or DI coupling. + +The first thought was that this drags the whole repo onto KMP. **That was wrong.** A +`kotlin("multiplatform")` module with `androidTarget()` publishes a normal Android variant, so +`:app`, `:plugins:*` and the pump drivers keep consuming it unchanged. The KMP plugin applies per +module, not per repo. + +The decisive argument for C is maintenance, not the initial cost: with two UIs, every future screen +has to be built twice, in two languages, forever. + +### Scope note + +A client does not need all ~50 modules. Roughly a dozen: `:core:data`, `:core:keys`, +`:core:interfaces` (narrowed), `:core:ui`, `:core:graph`, `:database:*`, the Nightscout sync path +and the overview UI. Pump drivers, automation, SMS, Garmin and wear never need to be KMP. + +### One honest warning + +Compose Multiplatform on iOS is stable, but it does not feel like a native iOS app. Scrolling, +text fields and accessibility differ from SwiftUI. For an app that people build themselves this is +probably an acceptable trade, but **test it on a real iPhone early**, not after months of work. + +--- + +## 5. Practical setup for iOS + +### Hard requirements + +1. **A Mac.** Xcode runs only on macOS. Needed for every option, even Compose Multiplatform, because + the app shell, signing and device runs all go through Xcode. +2. **Apple Developer Program, 99 USD per year.** A free Apple ID works but certificates expire after + **7 days**, there is no TestFlight, and **push notifications are not available**. Since push is + the only real answer to the iOS background limits, the paid account is in practice required. + +### Where the code should live + +``` +AndroidAPS repo (this one) + shared/client-core/ <- the only module with kotlin("multiplatform") + nothing else in the repo changes + | CI builds an XCFramework and publishes it (SPM) + v +AAPSClient-iOS repo (new) + AAPSClient.xcodeproj + Sources/ <- Swift, or a thin shell around Compose Multiplatform +``` + +Reasons: + +- The shared Kotlin **must** stay in this repo. It is taken from the existing code and would rot in + a separate repo within one release. +- The iOS app should **not** be here. An `iosApp/` folder would force the KMP toolchain on every + Android contributor and a Mac runner on the main CI. +- AndroidAPS is public, so **GitHub Actions macOS runners are free** for building the framework. + +### Tools + +| Task | Tool | +|----------------------------|------------------------------------------| +| Shared Kotlin | Android Studio or IntelliJ, as today | +| Swift and SwiftUI | Xcode (required) | +| Run on device or simulator | Xcode | +| Signing and provisioning | Xcode | +| Compose Multiplatform UI | Android Studio, Xcode only for the shell | + +### What to learn + +- **Swift** is close to Kotlin. Optionals, closures, `struct` / `class`, `protocol` as interface. + One to two weeks to be productive. +- **SwiftUI** is close to Compose. `VStack` is `Column`, `@State` is + `remember { mutableStateOf() }`, + `.padding()` is `Modifier.padding()`. +- **Xcode itself is the painful part**, not Swift. Signing, provisioning profiles and entitlements + are where the first days go. +- For KMP, use **SKIE** (Touchlab, free). Without it Kotlin `suspend` becomes completion handlers + and `Flow` does not bridge to Swift at all. + +### Distribution + +The App Store is not realistic for this kind of app. The working model already exists in this +community: **the Loop approach** - the user forks the repo, puts their own Apple credentials into +GitHub secrets, and GitHub Actions builds and uploads to **their own** TestFlight. Builds last +90 days and renew automatically. No Mac needed by the end user. + +This is real work, not an afterthought. Plan for it. + +--- + +## 6. Resources and translations + +> **Superseded by wave 14 - compose-resources is not being used.** This section works through what +> adopting it would mean, and the conclusion turned out to be wrong for a reason it never considered: +> the same `values-XX` folder names *parse* under compose-resources but do not *match* a request that +> carries no region, which is what this app sends for 22 of its 25 languages. The current answer is +> simpler than anything below - **Android keeps AAPT and nothing moves**, and only a platform neutral +> name crosses the boundary. The three problems listed further down are still real, and are still the +> reasons compose-resources was rejected. Keep reading for those; ignore the migration plan. + +**Translations survive almost unchanged.** Compose Multiplatform Resources uses the same +`strings.xml` format and the same `values-XX` folder names as Android. + +``` +core/ui/src/commonMain/composeResources/ + values/strings.xml <- English + values-cs-rCZ/strings.xml <- the same files as today + values-de-rDE/strings.xml + drawable/ font/ files/ +``` + +**Crowdin only needs a path change** in `crowdin.yml`, from +`src/main/res/values-%android_locale%/strings.xml` to +`src/commonMain/composeResources/values-%android_locale%/strings.xml`. Translation memory and the +existing translations are untouched. + +### Three real problems + +1. **`getString()` outside Compose is `suspend`.** In a Composable, `stringResource(Res.string.x)` + is normal. Everywhere else (ViewModels, workers, notifications, Nightscout upload) the function + is suspending. `ResourceHelper.gs()` is used from about 60 files in `:implementation` and 60 in + `:plugins:sync`, and those are not Composables. + Two answers, both worth using: + - Keep user visible strings **out of shared logic**. Return typed errors or enums and map them + to + text in the UI layer. Same idea as the existing rule about keeping `@ColorInt` out of domain + models. + - Where a string really must be resolved off the UI thread, `moko-resources` gives synchronous + access through `StringDesc`. +2. **iOS needs `CFBundleLocalizations` in `Info.plist`.** Without it iOS reports the wrong preferred + language and resource lookup quietly falls back to English. +3. **`Res` is generated per module**, so `:core:ui` and `:ui` each have their own. Same situation as + today with `app.aaps.core.ui.R` and `app.aaps.ui.R`. It also needs `publicResClass = true`, or + the + generated `Res` is internal and another module cannot see it. +4. **No public locale override.** `ResourceEnvironment` has an `internal` constructor and + `getSystemResourceEnvironment()` takes no parameters, so there is no supported way to ask for " + the + English text" while the UI is in another language. `SearchIndexBuilder` does exactly that, + through + `rh.gsNotLocalised(id)`, so for that one caller compose-resources is a feature loss rather than a + port. This is the problem that decided wave 10. + +### What this means for `:core:keys` + +The 368 strings themselves move without trouble. The real work is that the key classes carry +`@StringRes Int`, and a Compose Multiplatform resource is a `StringResource`, not an `Int`. That +type change is the job, not the translations. + +**Done, in wave 10 - but not with `StringResource`.** The key classes now carry `TextRef`, a two +case +sealed interface owned by `:core:keys` with no Android and no compose-resources types in it. Putting +`StringResource` directly on the keys was tried and rejected: problem 1 above is fatal for it, and +there is a fourth problem the list above misses - compose-resources has **no public locale override +**, +so the always-English search index could not be built at all. `TextRef` moves the type change to the +call sites once, and leaves the choice of resource system to each module, later. + +--- + +## 7. Suggested order of work + +**Do not remove all blockers first and start KMP afterwards.** That is how large migrations die: +a year of work against a static list, fixing things that turn out not to matter, with no feedback. + +| Step | Work | +|-------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| **0** | **Thin slice on a real iPhone first.** `:core:data` to KMP, a small Ktor call to Nightscout, one Compose Multiplatform screen showing a glucose value. Weeks, not months, because `:core:data` is already clean. | +| 1 | `DecimalFormat` and `TimeUnit` cleanup (see section 8) | +| 2 | `:core:keys` off resource ids - this gates everything in `:core:ui` | +| 3 | Narrow common interfaces out of `:core:interfaces` | +| 4 | `:core:ui` to KMP, resources to compose-resources | +| 5 | Ktor Nightscout client, Room KMP | +| 6 | Xcode shell, Koin or kotlin-inject, SKIE, first real screen | + +Step 0 proves the toolchain works, gives an honest answer about how Compose Multiplatform feels on +iOS, and makes every later step demand driven: a blocker is removed because it stands between you +and the next screen, not because it is on a list. + +**Where this stands.** Steps 1, 2 and 5 are **done**, and step 0 is half done - out of order, because +`:core:nssdk` turned out to be sliceable after all. Three modules now build for Kotlin/Native: + +| Module | State | +|---------------|----------------------------------------------------------------------------------------| +| `:core:data` | multiplatform, 2 seams, **real iOS targets**, 15 tests run on Native (waves 5, 15) | +| `:core:nssdk` | multiplatform, 72 files in `commonMain`, **real iOS targets** (waves 6-9, 15) | +| `:core:keys` | multiplatform, 47 files in `commonMain`, **real iOS targets** (wave 14) | + +All three build for `iosArm64` and `iosSimulatorArm64` on Windows. Together they are the data models, +the complete Nightscout read/write client and the preference keys - the whole spine a follower needs +below the UI - and the entire platform-specific surface under them is **four** `actual`s. + +Nothing is left of the original blocker list inside `:core:nssdk` - `org.json`, Gson, joda, +Retrofit, +OkHttp, `android.*` and `java.io` are all gone from it. + +**Step 2 is done (wave 14).** `:core:keys` hands out `TextRef` rather than bare resource ids +(wave 10), owns only strings it actually uses (wave 11), nothing outside it reads its `R` class +(wave 12), and it is now a `com.android.kotlin.multiplatform.library` targeting android + jvm + +iosArm64 + iosSimulatorArm64. Its strings never moved: they are still AAPT resources in +`src/androidMain/res`, and a generated name/id pair is what crosses the platform boundary. + +That leaves steps 3, 4 and 6. **Step 0's remaining half is smaller than this note assumed**: Apple +klibs cross compile on Windows, so "does it compile for iOS" is answerable locally and already +answered for three modules. What still needs a Mac is running iOS tests, linking a framework, and the +only question that really matters - how Compose Multiplatform *feels* on a real iPhone. + +--- + +## 8. Work done so far + +Committed on `dev`: + +| Commit | What | +|--------------|-------------------------------------------------------| +| `e5f4e27626` | Migrate DecimalFormat | +| `a42d823c93` | Eliminate TimeUnit | +| `e1068e77db` | `:core:keys` remove JVM dependency | +| `35b5399798` | Extract dependencies | +| `1aed547f7a` | cleanup (`TB.isInProgress` moved out of `:core:data`) | + +Committed on `kmp/core-data-experiment`, a throwaway branch kept because the work turned out to be +worth keeping (see waves 5 and 6): + +| Commit | What | +|--------------|------------------------------------------------------------------------------------| +| `67ecb1e696` | Going KMP - `:core:data` is a real multiplatform module | +| `e24476237b` | Prepare `:core:nssdk` - dead code out, defaults in, characterization tests | +| `f6a4e85e8a` | `:core:nssdk` `org.json` -> kotlinx | +| `350f486be4` | `:core:nssdk` Gson -> kotlinx.serialization | +| `cb7b8fa924` | `:core:nssdk` date parsing, eliminate joda | +| `e92ec082d3` | `:core:nssdk` tests - the contract suite, written against Retrofit before the port | +| `ed9c87599f` | `:core:nssdk` Ktor migration | +| `37e146861f` | version `4.0.0-dev-b-kmp` | +| `307b1ed615` | `TextRef` (wave 10) | +| `defd131dec` | `:core:keys` eliminate dependencies (wave 11) | +| `95da0fa7ca` | more `TextRef` migration (wave 11) | +| `6fdb924e6b` | `:core:keys` String migration (wave 12) | +| `c8c1069817` | Fix Danish language selection - `dk` matched no folder, see section 9a | +| `29dd6ff33e` | `:core:keys` `TextRef.Named` - generated names + id map, 342 call sites (wave 14) | +| `eeddd8182c` | `:core:keys` kmp - multiplatform module, real iOS targets (wave 14) | + +### Wave 1 - `DecimalFormat` removed + +Survey found **177 uses in 74 files**, 19 different patterns, and **29 places where +`java.text.DecimalFormat` appeared in an API signature** - including Compose parameters such as +`valueFormat: DecimalFormat = DecimalFormat("0.0")` in `SliderWithButtons`, `PlusMinusEdit`, +`ValueInputDialog`, `NumberInputRow` and five more in `:ui`. Those signatures were blocking +`:core:ui` from `commonMain`, so this step is on the critical path for Compose Multiplatform. + +**What was added** in `:core:data`: + +- `app.aaps.core.data.format.NumberFormat` - pure Kotlin. Holds `minIntegerDigits`, + `minFractionDigits`, `maxFractionDigits` plus named constants for all 17 patterns found + (`INTEGER`, `DECIMAL_1` to `DECIMAL_3`, `DECIMAL_6`, `UP_TO_2_DECIMALS`, + `DECIMAL_1_UP_TO_2`, ...). +- `NumberFormatPlatform` - the only remaining user of `java.text`. Becomes `expect` / `actual` when + `:core:data` goes KMP. + +**The formatting is not reimplemented.** Matching `DecimalFormat` half-even rounding on doubles bit +for bit in pure Kotlin is hard, and the test suite cannot detect a mistake (see the locale note +below). Delegating gives the KMP win with no change in behaviour. + +Two details that would have caused silent bugs: + +- `isGroupingUsed = false`. `DecimalFormat()` groups by default, so `1234.5` would have become + `1,234.5`. +- A `ThreadLocal` cache. `DecimalFormat` is not thread safe, and `StringUtil` currently shares one + mutable array between threads. + +**The cache key must hold the locale.** A first version keyed it on (format, separator) only. A +cached `DecimalFormat` carries the WHOLE symbol set, not only the separator, and the app can change +language while it runs (`ComposeMainActivity` calls `recreate()`, the process stays alive). Swedish, +Norwegian and Lithuanian use a comma AND a real MINUS SIGN (U+2212); German and Czech use a comma +and a plain hyphen. Same key, different symbols, so after a language switch negative numbers kept +the old sign until the app was killed. Fixed by putting `Locale` in the key. The test +`symbols follow the locale after it changes` switches de -> sv -> de and en -> ne-NP and checks both +the sign and the digit shapes. + +**Locale behaviour is unchanged on purpose.** The separator comes from the platform, so a Czech or +German device still shows a comma. `format(value, SEPARATOR_DOT)` is the explicit way to ask for a +dot, for server data and files. + +**Result:** 57 files across 11 modules migrated, all 29 API leaks closed, `:app` and `:wear` +compile, +**2319 unit tests pass with 0 failures**. + +**A latent bug was found and neutralised.** 13 automation triggers used `DecimalFormat("1")` and +`DecimalFormat("0.1")`. Those are not digit placeholders - the digits are literals: + +``` +"1" format(100.0) -> "1100" format(-5.5) -> "-16" +"0.1" format(720.0) -> "720.1" format(0.5) -> "0.1" +``` + +It never reached users, because `decimalFormat` in `InputDouble`, `InputDelta` and `InputBg` is a +private field that is only written, never read. This was checked properly afterwards, including the +JSON that triggers write to preferences and sync - see "Was behaviour preserved?" below. The dead +parameter is worth deleting separately. + +**Two files were left alone on purpose:** + +| File | Reason | +|-------------------------------------------|------------------------------------------------------------------| +| `wear/SmallestDoubleString.kt` | Builds patterns from a runtime string, needs real logic | +| `shared/impl/src/test/DateUtilOldImpl.kt` | A frozen copy of the old implementation kept as a test reference | + +### Two locale bugs fixed on the way + +**`StringUtil.getFormattedValueUS` is now the extension `Number.formatUS(decimals)`.** It builds the +`DecimalFormat` with `DecimalFormatSymbols(Locale.US)` instead of formatting with the device locale +and then replacing a comma with a dot. That replace did nothing on locales that use another +separator, for example Arabic. A new test checks the output in `en`, `de-DE`, `cs-CZ`, `fr-FR` and +`ar-SA`. + +It stays on `java.text` and **adds no module dependency**. `:core:utils` carries Firebase, +WorkManager, spongycastle and tink, so it is not a KMP candidate anyway, and a +`:core:utils -> :core:data` edge would only slow the build down. + +The old shared `DecimalFormatters` array is gone too. `DecimalFormat` is not thread safe, so two +threads formatting at the same time could produce wrong text. + +**`DateUtilImpl.qs()` no longer groups.** The old code used `DecimalFormat()` with no pattern, which +groups by default, and only the decimal separator was overridden. The grouping separator stayed +locale dependent, so for values over 1000: + +| locale | `qs(1234.5, 1)` before | after | +|--------|------------------------|----------| +| en | `1,234.5` | `1234.5` | +| de | `1.234.5` (two dots) | `1234.5` | +| cs | `1234.5` | `1234.5` | +| ar | Arabic-Indic digits | `1234.5` | + +Grouping was never intended - the method forces a dot separator, so it clearly wants neutral output. +Its only caller is `niceTimeScalar` ("3 days", "45 minutes"). No grouping support had to be added to +`NumberFormat`. + +### Wave 2 - duration types (done) + +**`java.util.concurrent.TimeUnit` really is a blocker** - it does not exist on Kotlin/Native. Of 226 +uses in 108 files, **94 were constant conversions and are now `kotlin.time`**: + +``` +TimeUnit.MINUTES.toMillis(x) -> x.minutes.inWholeMilliseconds +TimeUnit.MILLISECONDS.toMinutes(x) -> x.milliseconds.inWholeMinutes +``` + +51 files changed. The other 111 uses stay, because the API they are passed to demands a `TimeUnit`: +`Single.timeout` (36), `WorkRequest.setInitialDelay` (8), `Semaphore.tryAcquire` (7), RxJava +schedulers, OkHttp timeouts. Those files keep the import. + +Two traps that a plain search and replace would have fallen into: + +- `plugins/automation/elements/InputDuration.kt` declares its **own** `enum class TimeUnit`, and + `ActionStartTempTarget.kt` imports both it and `java.util.concurrent.TimeUnit`. The local one is + always written as `InputDuration.TimeUnit.MINUTES`, so matching on `TimeUnit..to(` + cannot hit it - but a rename over the bare word would have broken it. +- Three calls in `:pump:eopatch` nest one `TimeUnit` call inside another, for example + `TimeUnit.MILLISECONDS.toMinutes(millis - TimeUnit.HOURS.toMillis(hours))`. Those were done by + hand, since the argument needs balanced bracket matching, not a regex. + +**`T` (`core/data/time/T.kt`) was left as it is.** It is imported by 221 files, and it is **not** a +KMP blocker: it is pure Kotlin in a pure Kotlin module. `T.now()` was the only line using a JVM API +(`System.currentTimeMillis()`), and it turned out to have **no callers at all** apart from its own +test, so it was deleted. `T` is now fully portable as written, and nothing forces a migration. + +Replacing `T` with `kotlin.time.Duration` everywhere was tried and then reverted. It gives no +portability gain, and deprecating a class used by 221 files costs about that many build warnings for +everyone until the files move over. If it is ever done, keep the quirk that `T.months(n)` counts a +month as 31 days. + +### Wave 3 - `:core:keys` off the JVM (done) + +`ComposedKey.composeKey` used `String.format(Locale.ENGLISH, key + format, *arguments)`. It is now +plain Kotlin that walks the template itself. + +Two reasons, and the first matters more than the portability one: + +1. The result becomes a **SharedPreferences key name**. `String.format` follows a locale, and a + locale with its own digits (Nepali, Bengali, Burmese, ...) would write `%d` in that script. The + key would differ and the stored setting would be lost. `Locale.ENGLISH` was passed to avoid that; + building the text by hand removes the risk instead of working around it. +2. `String.format` is JVM only. + +Every format in the app is `%d` or `%s` (19 uses, nothing else), so no library and no `expect` / +`actual` is needed. Anything else now **throws** `IllegalArgumentException`: an unknown specifier, a +lonely `%`, a wrong argument count, or a `%d` given something that is not a whole number. That last +guard matters - `String.format("%d", 1.5)` used to throw, while plain substitution would have +quietly produced a different key and lost the setting. + +`ComposedKeyTest` compares the new output against the real `String.format(Locale.ENGLISH, ...)` for +**every composed key enum in the app**, iterating `.entries` so a new key is covered automatically, +and repeats it in `ar-SA`, `ne-NP`, `bn-IN`, `my-MM`, `fa-IR` and `th-TH-u-nu-thai`. + +`:core:keys` had no test source set, so `id("test-module-dependencies")` was added. Test only, no +effect on the main compile. + +**What is left in `:core:keys`: nothing but resources.** Zero `android.*` imports, zero `java.*` +imports. The only blocker is **381 `R.string` references in 6 files** - the keys carry preference +titles and summaries as raw `Int` resource ids. That is the resources job in section 6, not a +separate problem. + +> **That paragraph was wrong**, and it was wrong in a way worth remembering. It came from grepping +> `^import android` / `^import java`, which finds nothing when the type is written **fully qualified +> inline** or is **auto-imported**. Wave 11 found two real ones: +> `android.content.Context` in `StringPreferenceKey.kt` (written fully qualified, so no import line) +> and `java.lang.Class` in five places (`java.lang` needs no import). The same mistake shape recurred +> twice more later - see the note at the end of wave 12. Grep for *usage*, not for imports. + +### Wave 4 - `:core:data` ambient state (partly done) + +Two reads of ambient system state were removed from the models: + +- **`TB.isInProgress`** was a property computing `System.currentTimeMillis() in timestamp..end`. It + is now `fun TB.isInProgress(dateUtil: DateUtil)` in `:core:objects`, matching the `EB` sibling two + lines away in the same file, which already worked that way. One call site. Besides portability it + removes a hidden non-determinism: the same object could compare unequal to itself across a tick, + and it could not be tested without waiting for real time. +- **`DoseStepSize.description`** used `String.format(Locale.ENGLISH, "%.3f", ...)`; it now uses + `NumberFormat.DECIMAL_3` with `SEPARATOR_DOT`. The Wave 1 work had already built the replacement. + +**`java.util.TimeZone` in 17 model files stays, and should be solved with `expect` / `actual`.** + +Every one of them is the same line: + +```kotlin +var utcOffset: Long = TimeZone.getDefault().getOffset(timestamp).toLong(), +``` + +Removing the default and passing `utcOffset` in from the callers was tried and **reverted**. Why it +is the wrong shape here: + +- `:core:data` cannot use `DateUtil`. `DateUtil` lives in `:core:interfaces`, which already depends + on `:core:data`, so the reverse edge is a cycle. And a data class default parameter has nowhere to + receive an injected dependency anyway. +- The ripple is large. The compiler first reported 2 errors; the real count was 38 and still growing + when the attempt was stopped, with test sources not yet compiled. Gradle stops at the first + failing + module and Kotlin caps reported errors per file - in `PersistenceLayerImpl` it named 10 of 31. +- **`utcOffset` is not a throwaway field.** It is stored in every table, it is part of + `contentEqualsTo` (so a changed value makes a record compare unequal and re-sync), it is uploaded + to Nightscout - where the server validates it and `NSAndroidClientImpl` has to catch + `"Bad or missing utcOffset field"` 400s and retry with 0 - it goes to Open Humans, and it is shown + in the user entry history. Recomputing it by hand at 50+ sites risks silently changing stored + values; `expect` / `actual` keeps the computation identical at every site and changes no call + site. + +So when `:core:data` becomes multiplatform: + +```kotlin +// commonMain +expect fun systemUtcOffsetAt(timestamp: Long): Long // the models keep their default +// jvmMain -> TimeZone.getDefault().getOffset(timestamp) +// iosMain -> NSTimeZone +``` + +The same reasoning does **not** apply to `isInProgress`. That is a value computed on demand at +arbitrary times, so passing `DateUtil` is right there. A constructor default evaluated once at +creation is the weaker case, and the 154 call sites that care already pass `utcOffset` explicitly. + +**State of `:core:data` now:** + +| Was | Now | +|---------------------------------|----------------------------------------------------| +| `System.currentTimeMillis()` | gone (`T.now()` deleted, `isInProgress` moved out) | +| `java.util.Locale` | gone (`DoseStepSize`) | +| `java.util.concurrent.TimeUnit` | gone (Wave 2) | +| `java.util.TimeZone`, 17 files | `expect` / `actual`, no call site changes | +| `NumberFormatPlatform` | `expect` / `actual`, by design | + +Two `expect` / `actual` declarations away from compiling as `commonMain`. + +### Wave 5 - `:core:data` really is multiplatform (done) + +`:core:data` now uses `kotlin("multiplatform")` with a `jvm()` target and `mingwX64()` as a stand-in +for iOS (real iOS targets need macOS and Xcode, so Windows cannot build them - `mingwX64` proves the +code compiles for Kotlin/Native, which is the part that was in doubt). + +**Zero consumer changes.** All 13 modules that depend on `:core:data` build unmodified: an Android +consumer still sees a normal library, because a multiplatform module publishes a normal variant. +That was the single most valuable thing to learn, and the reason it was worth doing on a branch. + +Two `expect` / `actual` seams, both deliberate: + +| Seam | Why | +|--------------------------------|--------------------------------------------------------------------------| +| `NumberFormatPlatform` | number formatting is genuinely platform work | +| `systemUtcOffsetAt(timestamp)` | replaces `TimeZone.getDefault()` in 17 model files, no call site changed | + +The `mingwX64` target needed one opt-in, `kotlin.experimental.ExperimentalNativeApi`, because +`ICfg.iobCalcForTreatment` uses `assert()`. + +Verified on WSA: all four flavours install, run, and sync against a live Nightscout. + +### Wave 6 - `:core:nssdk` off `org.json` (done) + +**Worth recording, because the first assessment was wrong.** The module was written off as something +that could not be salami-sliced: the Gson annotations, the joda date parsing, the `IOException` +hierarchy and the converter all form one wire-format contract, so the argument went that it had to +move in a single step or not at all. That is true *of the converter*, but the converter is not the +only seam. The carrier type is a separate one, and it came out first, alone, with the converter +untouched. The lesson generalises: "these things are coupled" is a claim about one axis, and it is +worth checking whether some other axis cuts cleanly before accepting a big-bang migration. + +`org.json` is a JVM and Android API. It was in the **public API** of `NSAndroidClient` (10 methods) +and `RunningConfiguration` (2), carrying profiles and settings - the two document kinds AAPS does +not +model, because it does not own their shape. It is now gone from the module. + +Why it could be done alone: both directions already round-tripped through text, so `org.json` was +only ever a carrier. + +```kotlin +JSONObject(json.asJsonObject.toString()) // read - Gson tree -> text -> org.json +api.createSetting(JsonParser.parseString(doc.toString())) // write - org.json -> text -> Gson tree +``` + +Swapping the carrier for kotlinx `JsonObject` left the write line **character for character +identical** and changed one parse call on the read side. Gson, Retrofit and the 210 +`@SerializedName` +annotations were not touched. + +**The trap, and why tests came first.** The two libraries disagree about a missing key, and nothing +about the difference shows up at compile time: + +| accessor | `org.json`, missing key | `org.json`, explicit `null` | kotlinx, missing key | +|-----------------|-------------------------|---------------------------------|----------------------| +| `optString` | `""` | `"null"` - the four letter text | `null` | +| `optJSONObject` | `null` | `null` | `null` | +| `optLong(k, 0)` | `0` | `0` | `null` | +| `optBoolean` | `false` | `false` | `null` | + +A straight translation would silently flip every downstream `isEmpty()` and `?:`. So the swap was +done through `OrgJsonCompat`, kotlinx accessors that reproduce `org.json` exactly, golden-mastered +against the real thing over 21 inputs x 5 accessors. The type change is then equivalent by +construction rather than by inspection. + +The one genuine divergence: reading an **object** through `optString` differs in key order, because +`org.json` iterates a hash map and its order is unspecified. No call site does it - all six keys +read +through `optString` hold strings - so it is documented and skipped rather than pinned. + +**What stays on `org.json`, on purpose.** `JsonBridge` marks the two boundaries: + +- **socket.io** hands every payload over as `org.json.JSONObject`. That is the library's API, so the + conversion happens as the event arrives and everything downstream is kotlinx. +- **The profile subsystem** - `ProfileStore`, `PureProfile`, `DataSyncSelector.PairProfileStore` - + is + `org.json` throughout `:core:interfaces`. Converting it is its own project. Profiles are converted + where they cross into the client instead. + +Both live in `:plugins:sync`, which is Android only by nature (WorkManager, a socket.io `Service`), +so the `org.json` quirks stay out of the shared modules. + +**Dead code removed on the way:** + +- `RemoteProfileStore` - an abandoned attempt to model the profile store as a typed class, with a + commented-out `Store` / `SimpleProfile` / `ProfileEntry` model still in the file. Zero consumers. + This was the "nested JSON I could not get working with kotlinx" that came up in discussion - the + archaeology was still in the file. +- `NSAndroidRxClient` - dead, and `kotlinx.coroutines.rx3` went with it. +- Both `?: return@Listener` guards in `NSClientV3Service.onDataDelete`. `optString` never returns + null, so neither could ever fire; Kotlin allowed them only because `org.json` is Java and the type + is the platform type `String!`. + +**Immutability was the only real code change.** kotlinx `JsonObject` cannot be mutated, so six +in-place `put` calls became rebuilds. Five were mechanical; the sixth was not - +`RunningConfigurationPublisher` mutated a *nested* object to mask `NsClientAllowClientControl` +inside +`syncedPrefs`, and that is now an explicit rebuild preserving key order and the masking semantics. + +The wire format's mixed typing was preserved deliberately: `isFakingTempsByExtendedBoluses` is a +real +JSON boolean while every `syncedPrefs` value is a string, exactly as `org.json` wrote them. + +**Left unfixed, on purpose.** `optStringCompat` faithfully reproduces the `"null"` quirk, so a +server +sending `{"message":null}` still writes the word "null" into the user visible NSClient log. Changing +behaviour during a type migration is how regressions get smuggled in; that is a separate change. + +**What is left in `:core:nssdk`:** + +| Dependency | Files | Slice | +|-----------------------|-------|--------------------------------------------------------| +| Gson | 17 | the converter switch | +| joda-time | 1 | with the converter - `RemoteTreatment` lenient dates | +| Retrofit | 4 | Ktor | +| OkHttp | 3 | Ktor | +| `java.io.IOException` | 1 | with Ktor - the exception hierarchy | +| `android.*` | 2 | with Ktor - mainly `Context` for the OkHttp disk cache | + +Verified on WSA with a full sync. That specifically exercises the riskiest part: settings and +profile +reads both go through the changed Gson type adapter, and kotlinx `JsonObject` also implements +`Map`, so Gson picking its built-in map adapter instead of the registered one +was a real possibility that would have failed quietly rather than thrown. + +### Wave 7 - `:core:nssdk` off Gson (done) + +190 `@SerializedName` -> `@SerialName`, `@Serializable` on 19 classes, and the Retrofit converter +swapped for `converter-kotlinx-serialization`. That artifact is **official and ships with Retrofit +3.0.0**, same group and version as the Gson converter it replaces, so it needed no third party +dependency - and it goes away with Retrofit itself when the client moves to Ktor. + +Two things got smaller rather than bigger: + +- The Gson `JsonDeserializer` that built `JsonObject` is **gone**. kotlinx reads `JsonObject` + natively, so the adapter, the `GsonBuilder` and the `Gson` instance all went with it. +- `DeviceStatusMapper` used to rebuild all four schema-less subtrees (`pump.extended`, + `openaps.suggested` / `enacted` / `iob`) by printing each to text and parsing it back, because one + side was a Gson tree and the other a kotlinx tree. Both sides are kotlinx now, so those seven + conversions became a straight assignment. + +#### The configuration is the whole decision + +`NsSdkJson.kt` holds one `Json` instance shared by Retrofit and the string mappers. Every flag is +there to match Gson, not because it is a good default in the abstract: + +| Flag | Why | +|-------------------------|----------------------------------------------------------------------------------| +| `ignoreUnknownKeys` | documents carry fields this version has never seen | +| `explicitNulls = false` | Gson omits nulls; NS rejects some explicit nulls | +| `isLenient` | one measured case: a bare **number** arriving in a `String` field (`created_at`) | +| `encodeDefaults` | **backward compatibility** - see below | +| `coerceInputValues` | **forward compatibility** - see below | + +Two of those five were added only because a test failed, and both are the difference between working +and quietly breaking somebody else's device: + +- **`encodeDefaults`** - kotlinx's default is the **opposite** of Gson's. Gson writes every non-null + field; kotlinx omits any field equal to its default. Without this, `isReadOnly = false`, + `duration = 0`, `utcOffset = 0` and every `LastModified.Collections` counter simply stop being + written. Absent is not the same as false to a reader that has not been updated. +- **`coerceInputValues`** - an **unknown enum value** throws in kotlinx but was mapped to `null` by + Gson. `eventType` is an enum. A *newer* AAPS adding one event type would otherwise make every + older + client throw on that record, inside a socket.io listener with no try/catch. + +Only one case needed `isLenient`. Strict kotlinx already accepts a quoted number in a `Long` / +`Double` / `Int` field and `"true"` in a `Boolean` field, which was a pleasant surprise - the gap +between the two libraries is much narrower than it looks. + +#### The trap that nearly shipped + +**A non-null field with no default is optional under Gson and mandatory under kotlinx.** Gson builds +objects with `Unsafe.allocateInstance`, never calls the constructor, and leaves such a field as +`null` / `0` / `0.0`. kotlinx treats the identical declaration as required and throws. + +Neither `coerceInputValues` nor `explicitNulls` rescues it - **both only apply to properties that +already have a default.** And a page is decoded in one pass, so one bad document loses the whole +page, not one record. + +`RemoteFood` had four such fields. Nightscout's `food` collection also stores **quickpick** +documents written by the NS food editor, which have no `portion` and no `carbs` - `FoodMapper` +already has an `else -> return null` branch for exactly those, but the parse now died before the +mapper ran. Measured: a list of `[good food, quickpick]` returned **zero** items. `LoadFoodsWorker` +then fails, and it sits mid-chain, so it also cancels the profile, settings and devicestatus workers +every fifth loop. + +`RemoteEntry.type` was the same shape on the glucose feed, where `LoadBgWorker` only advances its +cursor after a successful decode - so one typeless record would have stopped all glucose and +re-requested the same page forever. + +Fixed by giving those fields the values Gson used to leave behind (`""`, `0`, `0.0`), which restores +the old tolerant behaviour exactly. `encodeDefaults = true` means the upload format does not change. + +**This was found by an adversarial audit, not by the tests.** The characterization tests were +written specifically to catch behaviour changes and they missed it, because there was **no food +decode test at all** - `FoodExtensionKtTest` is object-to-object and `LoadFoodsWorkerTest` mocks +`getFoods()`. A test suite only pins the paths somebody thought to write a test for. + +#### Verified on a device, both versions at once + +A Pixel emulator ran a **new master against a pre-KMP client** on a live Nightscout instance. Every +record type that can be created by hand was created and followed end to end: + +| Record | Old client result | +|---------------------------------------------|-----------------------------------------------------| +| Carbs | `◄ INSERT`, visible in Treatments history | +| Bolus (with nested `icfg`) | `◄ INSERT Bolus`, visible in Treatments history | +| Profile switch | `◄ INSERT ProfileSwitch` + `EffectiveProfileSwitch` | +| Temporary target (new **and** PATCH update) | `◄ INSERT TemporaryTarget` | +| Therapy event | `◄ INSERT TherapyEvent` | +| Glucose, devicestatus, settings | received and applied | + +The actual bytes are the best evidence. This is what the new master uploaded for a 20 g carb entry: + +```json +{ + "date": 1786012366166, + "utcOffset": 0, + "app": "AAPS", + "isValid": true, + "isReadOnly": false, + "eventType": "Meal Bolus", + "carbs": 20.0, + "notes": "" +} +``` + +`utcOffset:0`, `isValid:true` and `isReadOnly:false` are all present - that is `encodeDefaults` +working. Without it those three vanish. Nulls are omitted, as Gson did. + +The strongest single result is the **signed client-control round trip**: the old client sent an +HMAC-signed envelope, the new master verified it and acked, and the old client verified the ack +back. +A signature only verifies if the serialized bytes match, so that is close to proof rather than +inference. + +Not covered on the device: temp basal and extended bolus (need real pump or loop activity), and the +**food upload path does not exist** - `QueueCounter` has no food counter, AAPS syncs food +download-only. The food fix rests on unit tests. + +### Wave 8 - `:core:nssdk` off Retrofit and OkHttp (done) + +The HTTP client moved to **Ktor on the OkHttp engine**, so the app reuses the OkHttp it already +ships +rather than carrying two HTTP stacks. On iOS the engine becomes Darwin and nothing else changes, +which is the whole reason for the move. + +#### The map came first, and it was worth it + +Before any code changed, five parallel readers mapped what the Retrofit stack actually guaranteed, +and each finding was then attacked by an agent trying to refute it. That produced a written contract +and **30 silent-failure risks**. Three were serious enough to have shipped: + +- **The 304 signal only existed because of the OkHttp disk cache.** `response.raw().networkResponse + ?.code` can only be 304 when the cache revalidates a GET and merges the result into a 200. Ktor + has + no equivalent, so `code` would have become a constant 200 - and `LoadBgWorker`'s + `response.code != 304 && processSgvs(...)` would have become an unconditional process. The paging + loop never exits, `storeGlucoseValuesToDb()` is never reached, and **BG never lands in the + database** while the worker looks busy. +- **Static query pairs are hidden inside the `@GET` strings**, not in `@Query` parameters - + `v3/profile?sort$desc=date&limit=1`. Rebuild the URL from the method parameters and they vanish. + Losing `limit=1` there makes `LoadProfileStoreWorker` take `profiles[profiles.size - 1]` from an + unsorted list, silently applying the **wrong profile**: different basal, ISF and IC. +- **The `utcOffset` auto-retry reads the raw 400 body text.** Ktor has no separate `errorBody()`, + and + a body read lazily or only on success loses that string. The record is then dropped permanently + while the sync cursor advances. + +#### Tests before code, against the old stack + +49 characterization tests were written **while Retrofit was still in place**, using **MockWebServer +** + +- a real HTTP server on localhost - rather than Ktor's `MockEngine`. That choice is the point: + `MockEngine` only exists after the swap, so tests written with it could never have proved anything + about the behaviour before it. The same files ran unchanged against both stacks. + +They pin: every endpoint URL as a literal string; the read/write status asymmetry and its **request +counts**; the `utcOffset` fallback verified by inspecting both request bodies; the auth headers, +refresh and clock-skew paths; ETag parsing; and the `{"result": ...}` envelope, which is applied +inconsistently per endpoint. + +Two divergences were caught this way rather than in the field: + +| | | +|---------------------------|---------------------------------------------------------------------------------------| +| `$` in query keys | survived on the first try - Ktor's `encodedParameters` leaves it literal | +| `/` inside a path segment | Ktor does **not** encode it, Retrofit does (`a%2Fb`). Fixed with `encodeSlash = true` | + +#### Decisions worth recording + +- **`expectSuccess = false`.** Ktor's default throws on any non-2xx, which would make the whole 4xx + ladder dead code - and because `ClientRequestException` is not in the retry exclusion list, every + such call would also be retried four times before surfacing. +- **No `HttpRequestRetry`.** It would multiply with the existing `retry()`, and the `utcOffset` + fallback re-enters a public method, so one reading could produce over ten POSTs in a single sync. +- **Auth is hand written, not Ktor's `bearer` provider.** The provider differs in four ways, two of + which lose data quietly: it omits the header when it has no token (Nightscout then answers **200 + with the anonymous role**, and uploads stop with nothing logged), and it refreshes on 401 only, + while Nightscout also answers 403. It also never sees the response body, so the clock-skew error + could not exist. +- **The 304 brake was replaced, not reproduced.** The workers now stop when the cursor cannot + advance + (`lastServerModified <= lastLoaded`), which is what the 304 always meant. Only the modified-since + path can stall that way; the first load pages by date and carries no server timestamp. +- **`close()` was added** to `NSAndroidClient` and is called from `restartOnChange`. The Retrofit + client leaked quietly on every URL or token change; a Ktor engine holds real connections. + +Also fixed on the way, deliberately and separately: `getVersion` / `getStatus` threw +`retrofit2.HttpException` and `NullPointerException`, neither a `NightscoutException`; they now +throw +`UnsuccessfulNightscoutException`. And a 10 s connect timeout is now explicit - OkHttp applied one +by +default and Ktor does not, so a dead host would otherwise hang for the full 60 s socket timeout. + +**Left broken on purpose:** `updateFood` and `deleteFood` declare an `{identifier}` the path does +not +contain, so Retrofit refused to build them and **no request was ever sent** - food edits have never +synced, because the endpoint is broken on the Nightscout side. A hand-written Ktor URL would have +turned "never sends" into `PATCH /api/v3/food` and `DELETE /api/v3/food` **with no identifier**, a +request against the whole collection on a live server. They reproduce the local failure instead, +verified by a test asserting zero requests. + +#### Verified on a device + +A new Ktor master against a **pre-KMP client** on a live Nightscout: writes accepted (201), reads +across status / lastModified / settings / devicestatus / treatments, socket.io push received, and +the +old Gson/Retrofit client stored what Ktor wrote. The auth flow ran for real - a 401 triggered +`GET /api/v2/authorization/request/` and a 200 - which exercised the hand-written interceptor +and the leading-slash refresh URL together. + +Turning the WebSocket **off** was needed to see any of the REST paging at all: with push enabled the +load workers barely run. With it off, `◄ RCV TR END` on the modified-since path confirmed the new +cursor brake terminates instead of spinning. + +Not observed on the wire: the `$` operators, because `date$gt` / `created_at$gt` / `sort$desc` only +appear on **first-load** paths and every collection was already synced. They are covered by two test +files asserting the same literal URLs against Retrofit and against Ktor, so the bytes are identical +and Nightscout cannot tell them apart. + +### Wave 9 - `:core:nssdk` is multiplatform (done) + +Same shape as `:core:data`: `jvm()` plus `mingwX64()` as the Kotlin/Native compile proof. **72 files +in `commonMain`**, and all four consumer modules build unchanged. + +Only four things blocked `commonMain`, and none needed design work: + +| Blocker | Fix | +|----------------------------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `@JvmSynthetic` on an `internal` helper | dropped - it was hiding an already-internal function from Java | +| `KClass` in `retry` | `KClass`. Exact-class matching and the `catch (Exception)` are unchanged, so which exceptions are excluded and how many attempts happen stay identical | +| `Dispatchers.IO`, 2 sites | `expect val nsIoDispatcher`; the JVM actual is the same `Dispatchers.IO` as before | +| the Ktor engine and its logging interceptor | `expect fun nsHttpClient(...)` - OkHttp on JVM, CIO on Native | + +#### The crypto did not need solving + +`ClientControlCrypto` and `ClientControlPairingCrypto` are `javax.crypto` and `java.security`, and +they looked like the blocker. They are not: **nothing inside the module calls them** - the only +internal reference is a KDoc link - so they simply live in `jvmMain`. No `expect` / `actual`, no +stub, no crypto library, and the Native target compiles without them. + +The code stays in `:core:nssdk`, declared as its JVM part. When client-control is wanted on another +platform it is a source-set move **within this module** plus actuals, not a redesign. + +Golden vectors were extracted first, in `ClientControlCryptoVectorsTest`: fixed inputs with their +exact outputs, so a second implementation can be checked byte for byte rather than by "it is also +HMAC-SHA256". The algorithms are standards and interoperate by definition; the **packaging** is what +differs and what fails silently: + +- **GCM tag placement** - JCE returns `ciphertext ‖ tag` from one `doFinal`; Apple's CryptoKit keeps + the tag separate. The vectors pin `wrapped.size == plaintext.size + 16`. +- **Hex case** - signatures are compared as text, so lower case is part of the wire format. +- **PIN encoding** - ASCII digits today, which is why any encoding agrees. Worth knowing before + anyone widens the alphabet. + +Those values cannot change without breaking every deployed AAPS, so a failure there is a real +incompatibility, not a stale test. + +#### What a desktop JVM target would cost + +Nothing, for the crypto. The pattern here is a plain `jvm()` target, not `androidTarget()`, so the +single `jvmMain` already serves Android - and `javax.crypto` is Java SE, so Windows, Linux and macOS +desktop builds use the same code untouched. Only Kotlin/Native targets need a second implementation. + +**Web would be different**, and it is a decision to take early rather than late: WebCrypto is +async-only, so `sign()` and `wrap()` would have to become `suspend`, and retrofitting that ripples +through every caller. + +#### Honest limits of the proof + +`mingwX64` is a **compile proof, not a shipping target**: request logging is not wired up there, and +CIO stands in for what an iOS build would use (Darwin). What it does prove is real - 72 files of +wire +layer, models, mappers and HTTP client with no JVM API anywhere in them. + +### Wave 10 - `TextRef`, a seam for strings (phase 1 done) + +Every user facing string in AAPS is an Android `R.string` id: a plain `Int` handed out by AAPT at +build time. The preference keys alone carry 394 of them - a title and often a summary on each of +about 300 constants, spread over 40 enums in `:core:keys` and the plugins. An iOS or desktop client +has no AAPT and therefore no such ids, so this is the next real blocker after the network layer. + +#### The obvious answer does not fit + +The KMP answer is Compose Multiplatform Resources: keep the same `strings.xml` and the same +`values-XX` folders, let the Gradle plugin generate a `Res` object per module, and read strings with +`stringResource(Res.string.x)` in Compose or `getString(Res.string.x)` outside it. Translation stays +in Crowdin exactly as it is now, which was the main worry and turned out to be a non-issue. + +Two things stop it from going on the key classes themselves: + +- **`getString()` is `suspend`.** In Compose that is fine. Outside Compose it is not: a preference + key's title is read from view models, notification builders and the search index, and none of them + are suspend today. +- **There is no public way to ask for a specific locale.** `SearchIndexBuilder` builds a second, + always-English index so a user with a translated UI can still search using the English term they + read in the docs. Today that is `rh.gsNotLocalised(id)`. compose-resources has a + `ResourceEnvironment`, but its constructor is `internal` and `getSystemResourceEnvironment()` + takes + no parameters, so the English index cannot be built at all. That is a feature loss, not a port. + +moko-resources was evaluated as the alternative and rejected: it solves the suspend problem and has +a +real locale override, but it is a third party plugin, it wants its own `MR` object and its own +`StringDesc`, and adopting it means every module on the KMP path takes a dependency on it before any +of them is actually multiplatform. + +#### What was built instead + +`TextRef` is a two case sealed interface in `:core:keys`, with no Android types in it: + +```kotlin +sealed interface TextRef { + data class Res(val id: Int, val args: List = emptyList()) : TextRef + data class Literal(val text: String) : TextRef +} +``` + +It is deliberately **not** a resource system. It is a seam. `PreferenceKey.titleResId: Int` became +`PreferenceKey.title: TextRef`, and `summaryResId: Int?` became `summary: TextRef?`, so nothing +outside the resolvers passes a bare resource id around any more. There are exactly two resolvers: + +- `app.aaps.core.ui.compose.stringResource(ref)` for Compose, +- `ResourceHelper.gs(ref)` / `gsNotLocalised(ref)` for everything else, both default methods on the + interface so no implementation had to change. + +Today `TextRef.Res.id` is always an ordinary AAPT id and both resolvers just pass it through. When a +module later does move its strings to `commonMain/composeResources`, that module's ids can be +encoded as negative tokens and only these two functions learn about it. The call sites do not change +a second time. + +`TextRef.Literal` is the other half, and it is what makes the type worth having rather than a +`typealias`: some titles are not resources at all. It also collapses the "resource id **or** already +resolved string" property pairs that had started to appear. + +#### Phase 1, and what it cost + +The 40 preference key enums were converted by script, and every enum **constant** is byte for byte +unchanged - they still say `titleResId = R.string.x`. Only the constructor parameter turned private +and two property overrides were added after the constants: + +```kotlin +override val title: TextRef = TextRef.Res(titleResId) +override val summary: TextRef? = summaryResId?.let { TextRef.Res(it) } +``` + +That kept all 272 constants and their 394 resource references untouched. `PreferenceSubScreenDef` +got +the same treatment for the same reason - roughly fifty plugin call sites build it with +`titleResId = R.string.x`. + +`:core:interfaces` had to change `implementation(project(":core:keys"))` to `api`, because `TextRef` +now appears in a `ResourceHelper` signature. That is not a new module edge, only a widened one. + +The `if (titleResId == 0) return` guards in nine composables went with it: a title is now a non-null +`TextRef`, so "no title" is not representable at the call site. + +**One of those guards was not dead, and the first check for that was wrong.** Grepping for +`titleResId = 0` finds nothing, which is what the guards were removed on - but 26 key enums +*defaulted* the parameter to `0`, and one constant took the default: +`RileyLinkStringPreferenceKey.MacAddress`. It is storage, not a preference - the pairing wizard +writes it and it is on no screen - so the composables never saw it, but it is registered, so it did +reach `SearchIndexBuilder` and would have been indexed with an empty title. + +Rather than guard it, the key moved to where it belonged: `RileyLinkStringKey`, the +`StringNonPreferenceKey` enum sitting next to it that already held the RileyLink device `Name`. The +preference key string, the default and `exportable` are unchanged, so a paired RileyLink keeps its +address and settings export is unaffected - `isExportableKey()` walks all registered enums, and both +were already registered together. `getAllPreferenceKeys()` filters to `PreferenceKey`, so the key +simply stops reaching the index. No guard, no magic number. + +With the last user of the default gone, `titleResId: Int = 0` was removed from all 26 enums, so the +compiler now rejects a preference key declared without a title. That is the part worth keeping: the +trap cannot come back. + +#### A sentinel that had been waiting to bite + +`SearchableItem.Wiki` carries a page title that comes from the ReadTheDocs API, so it is plain text, +not a resource. It was stored as `titleResId = 0`, and `SearchIndexBuilder.safeGetString()` maps `0` +to `""` - which would make every wiki hit unfindable by its own title. + +It never fired. Wiki results are a live search, not part of the built index: `WikiSearchRepository` +fills `SearchIndexEntry` itself, with `localizedTitle = title`, and never calls +`createIndexEntry()`. +So the `0` sat there unread, correct only by the accident that nothing looked at it. It is now +`TextRef.Literal(wikiTitle)`, and the snippet is the summary, which is what both fields meant all +along. The point is not a fixed bug - it is that the sentinel could not survive the type change, +whereas an `Int` field will hold a lie indefinitely. + +#### Deliberately left for later + +`IntPreferenceKey.entries: Map` and `resolvedEntries: Map?` are the same +resource-id-or-string pair that `TextRef` exists to collapse, and collapsing them would delete +`IntKeyWithEntries` and `withEntries` entirely. It is not in phase 1 because it touches plugin call +sites, and phase 1 was scoped to `:core:*`. + +`UnitType.valueResId()` / `rangeResId()` stay `Int?` for now. They are format templates that need +arguments supplied at the call site. (They lived in `:core:keys`, not `:core:ui` as an earlier +version of this note said. Wave 11 moved them.) + +### Wave 11 - `TextRef` phase 2, and `:core:keys` owns only its own strings (done) + +Committed as `95da0fa7ca` and `6fdb924e6b`. Three separate jobs that together leave `:core:keys` +with no raw Android resource id in its API and nothing in it that other modules reach into. + +#### The entries maps + +`IntPreferenceKey.entries: Map` and `StringPreferenceKey.entries: Map` became +`Map`. The 100 `value to R.string.x` pairs did **not** change: the same trick as +`title` - the constructor parameter turned private (`entriesResIds`) and a computed property exposes +it. 11 enums, 17 named arguments. + +`resolvedEntries` is **deleted**. It was the parallel `Map?` that took precedence over +`entries`, i.e. exactly the resource-id-or-string pair `TextRef` exists to collapse. `withEntries` +now takes `Map`, so callers choose `Res` or `Literal` per entry. Two read sites in +`AdaptivePreferenceItem` collapsed to one each. + +That change is what made a real fix possible: `EopatchPumpPlugin` built its reminder labels as +`"$it U"` and `"$it hr"` - string concatenation no translator can reach. They now use the existing +translated templates `units_format_insulin_int` and `units_format_hours`. Note the visible +consequence: expiration entries read "1 h", not "1 hr". Had `withEntries` kept `Map` and +wrapped in `Literal` internally, the bug would have been frozen in place. + +#### UnitType split + +`UnitType.kt` is now the enum plus `decimalPlaces()` and `step()` - zero resources, zero Android. The +resource mapping moved to `core/ui/.../UnitTypeText.kt` as `unitLabel(): TextRef?`, +`rangeText(value, min, max): TextRef?` (arguments taken here, so a bare template cannot reach the +screen) and `valueFormatResId(): Int?`, which stays an `Int` because the slider applies it to +whatever value the user drags to. + +#### The unitLabel pair, and an identity check on a resource id + +`formatSliderDisplayValue` took **both** `unitLabelResId: Int = 0` **and** `unitLabel: String = ""`. +One `unitLabel: TextRef?` replaced both, killing the sentinel and the pair, across ~26 files. + +Worse than the sentinel: three places detected a semantic concept by comparing resource ids - +`unitLabelResId == KeysR.string.units_min` in `FormatUtils`, `SliderWithButtons` and +`ValueInputDialog`. All three are now an explicit `asDuration: Boolean`. That kind of check is +exactly what breaks when ids stop being AAPT ids. + +#### `:core:keys` stops hosting other people's strings + +40 `units_*` strings moved to `:core:ui`, where 99 of their references already were - only **two** +were used from inside `:core:keys` (`units_mgdl`, `units_mmol` in `StringKey.GeneralUnits`), and they +became `TextRef.Literal("mg/dL")` / `TextRef.Literal("mmol/L")`, which is honest because they read +the same in every locale. `prefs_range_title` moved too (external-only). + +**The translations were moved by hand, per locale, in the same commit.** Not left to a later Crowdin +sync - see wave 12 for why that matters. + +Side effect worth knowing: after this move `:core:keys` has **zero format placeholders**, zero +plurals and zero string-arrays. Those are the categories that are hard build errors in +compose-resources, so the module that is about to convert is now the clean one. + +#### Deleted as dead + +`getDependingOn` had **no callers** anywhere - dependency visibility is computed reactively from +`preferenceKey.dependency` in `PreferenceState.kt`. Removed from the interface, both +`PreferencesImpl`s, the `PreviewUtils` stub and its one test. + +`rangeText` was **not** deleted, and the reasoning is worth recording. It looked dead - grep for +`min = Int.MIN_VALUE` finds nothing. But seven pump enums **default** `min`/`max` to the extreme +values, and three Insight constants take the default, so `hasValidRange` is false for them and the +branch runs. It returns null today only because those keys have no `unitType`. Same grep-the-explicit- +form mistake as wave 3. + +### Wave 12 - clearing the way for compose-resources (done, not yet converted) + +Two preparatory stages, both green, both on device. + +**Stage 1 - `TextRef.Res` renamed to `TextRef.AndroidRes`** across 187 sites. Pure rename. It frees +the name `Res` for the compose-resources form and, more importantly, states the truth: an Android +resource id is *one* kind of text reference, not the only one. Pump drivers keep AAPT resources +permanently, so two variants is the end state, not scaffolding. + +**Stage 2a - the `@StringRes Int` APIs take `TextRef`.** Five `:core:keys` strings were read as +Android ints by three other modules through APIs that cannot take a `StringResource`. Rather than +duplicate those strings (which would mean deleting them from Crowdin later - see wave 12's warning), +the APIs changed: + +| API | Change | +|----------------------------------|-------------------------------------------------------------------| +| `SWItem.label` | `Int?` -> `TextRef?`; `label(Int)` delegates | +| `SWScreen.header` | `Int = 0` sentinel -> `TextRef?`; `with(Int)` delegates | +| `SWEventListener.textLabel` | `Int = 0` sentinel -> `TextRef?` | +| `NumberInputRow.labelResId` | -> `labelRef: TextRef?`, `Int` overload keeps **79** call sites | +| `PasswordCheck.queryPassword` / `setPassword` | `TextRef` overloads; `Int` versions delegate | + +The delegating-overload trick is why this was ~5 call-site changes and not ~70. External callers now +ask the key for its own label - `StringKey.ProtectionMasterPassword.title` instead of +`R.string.master_password` - which also deleted a genuine pre-existing duplicate (`master_password` +and `pref_title_master_password` were both "Master password"). + +**Result: no module outside `:core:keys` references its `R` class.** That was the precondition. + +> **A recurring mistake, recorded so it stops happening.** Three times this wave I grepped for one +> syntactic form and concluded something was absent: `titleResId = 0` (missed the constructor +> *default*), `^import android` (missed fully-qualified and auto-imported use), `min = Int.MIN_VALUE` +> (missed the default again). Two of those nearly deleted live code. "I found no matches" is weak +> evidence, especially before a deletion. + +### Wave 13 - what the compose-resources spike proved + +A throwaway standalone project (not in the repo) answered the questions that decide the conversion. + +| Question | Answer | +|---|---| +| Does CMP resolve on Kotlin 2.4.10 / Gradle 9.6.1? | **Yes** - plugin `1.11.1`, `BUILD SUCCESSFUL`, generated a public `Res` | +| Do Android-style locale folders survive? | **Yes** - `values-de-rDE`, `values-pt-rBR`, `values-zh-rCN`, `values-iw-rIL` all parse into `LanguageQualifier` + `RegionQualifier`. **No renaming needed.** | +| Does `mingwX64` work? | **No.** `components-resources:1.11.1` publishes Native only for `ios_arm64`, `ios_simulator_arm64`, `macos_arm64` | +| Build cost? | baseline **15.8s**, +compose plugin **17.7s**, +328 strings x 31 locales **29.3s** | + +The build-cost split matters: the **Compose compiler plugin is ~2s**; the other **~11.6s is resource +codegen**, which scales with the number of locales, not with code. My worry that the IR transform +over the enums would be expensive was wrong. + +The `mingwX64` answer only matters because it was our Windows-buildable proxy for "compiles to +Native". The **real** target set is Android + iOS + JVM-on-Windows, and compose-resources supports +all three. So `mingwX64` is scaffolding we can drop for this module; the cost is that its Native side +can then only be compiled on macOS. + +Two corrections to earlier reasoning in this note: + +- ~~`ResourceEnvironment`'s constructor is **public** in 1.11.1~~ - **this was wrong, and it was the + load bearing claim.** Wave 14 downloaded the published `components-resources:1.11.1` sources jar + and read it: the declaration is `class ResourceEnvironment internal constructor(...)`. The only + public producer is `getSystemResourceEnvironment()`, which reads `Locale.getDefault()`. So there is + still **no way to ask for a specific locale**, and the always English search index still cannot be + built. Wave 10's original objection stood the whole time. 1.12.0-beta03 does not fix it either - + the constructor is still internal and has gained a fifth parameter. +- Holding a `StringResource` costs **kotlin-stdlib only** - `components-resources` declares nothing + else in `apiElements`; Compose appears only in `runtimeElements`. (Still true.) + +Still true: **every `getString` overload is `suspend`.** There is no synchronous variant. Wave 14 +adds one more: `stringResource()` is a **blocking** read on every platform except JS, including +inside composition. + +**The spike also asked the wrong question about locale folders.** It checked that `values-de-rDE` +*parses* into a `LanguageQualifier` plus a `RegionQualifier`. It never checked that such a folder +*matches* a request. It does not, when the request carries no region - see wave 14. + +There is real precedent on this exact toolchain: **Meshtastic-Android** runs a dedicated +`:core:resources` module (1747 strings, ~40 locales, `publicResClass = true`) on AGP 9.3.1 / +Kotlin 2.4.10 / CMP 1.11.1, and **Todometer-KMP** targets android + jvm + iosArm64 + +iosSimulatorArm64. Both build iOS only on `macos-latest` runners - which is the answer for a +maintainer with no Mac, and free for a public repo. + +### Wave 14 - `:core:keys` is multiplatform, and the string plan changed (done) + +Committed as `c8c1069817`, `29dd6ff33e` and `eeddd8182c`. This wave overturned the destination that +waves 10 to 13 had been walking towards, so the reasoning matters more than the diff. + +#### compose-resources cannot serve this app, and no version of it can + +Two facts, both read out of the **published** `components-resources:1.11.1` sources jar rather than a +docs page or a GitHub branch: + +- `class ResourceEnvironment internal constructor(...)`. The only public producer is + `getSystemResourceEnvironment()`, which reads `Locale.getDefault()`. There is no way to ask for + English while the UI is in another language. Wave 13 claimed this had been fixed; it had not, and + 1.12.0-beta03 still has it internal, now with a fifth parameter. +- `filterByLocale` has exactly three steps, and the source comment says so: exact language+region, + then language **with no region qualifier**, then no locale qualifiers at all. There is no + sibling-region fallback. + +That second one is the fatal one, and nobody had looked at it. `LocaleHelper.currentLocale()` builds +a **region-less** `Locale` for 22 of the 25 in-app languages, and every `:core:keys` folder is +region-pinned (`values-cs-rCZ`; no bare `values-cs` exists anywhere). Walk the algorithm: step 1 +needs a region and there is none, step 2 needs a folder without a region and there is none, so it +falls through to the unqualified default - **English**. Concretely, 8 of the 11 translated locales +would have shown English preference screens: bg, cs, es, fr, it, nb, ro, sk. It also breaks device +locales whose region differs from the folder - de_AT, fr_CA, es_MX, nl_BE, zh_HK - which AAPT +resolves correctly today. + +Build green, no crash, no test catches it, and CI runs no tests. This is exactly the failure shape +section 9a describes, and it would have been introduced deliberately. + +#### moko-resources was the better library and still lost + +It does no locale matching of its own: it generates real `values-XX` files and emits +`StringResource(R.string.key)`, so Android keeps AAPT semantics, and `StringDesc.LocaleType.Custom` +is a real locale override. Both defects gone. But `0.26.4` shipped 2026-05-06, one month **before** +Kotlin 2.4.0, with no commits since, 175 open issues and a catalog pinned to Kotlin 2.1.0/2.3.20. A +medical app's string layer is the wrong place for an unattended dependency. + +#### What was built instead + +The insight is that the module shape was never the blocker. `:core:data` has been multiplatform since +wave 5 and 39 modules consume it with a plain `implementation(project(...))`. What blocked +`:core:keys` was the `R.string` **`Int`** inside 6 enum files - a *content* problem. + +`buildSrc/src/main/kotlin/GenerateKeyStringsTask.kt` makes one pass over `res/values/strings.xml` and +emits two files: + +| Generated | Where | What | +|-----------|-------|------| +| `KeysStrings` | `commonMain` | 327 `val x: TextRef = TextRef.Named("x")` - no Android types | +| `KeysStringIds` | `androidMain` | 327 `"x" to R.string.x`, plus `idOf(name)` | + +Same pass, so they cannot drift: a string deleted from the XML disappears from both and any call site +naming it stops compiling. `TextRef` gained `Named(name, args)`; the 6 enums now take +`override val title: TextRef` directly and the computed `AndroidRes` properties are gone; 342 +references were rewritten; the three resolvers gained a `Named` branch. + +The KDoc in `TextRef.kt` that argued against names was **conditionally right and absolutely wrong**. +It said a name needs `Resources.getIdentifier()` - reflective, invisible to R8, silently 0 for a +typo. All three objections die against a *generated* map: no reflection, R8 sees 327 literal +`R.string.x` references, and a typo does not compile. + +Net effect: **zero resource files moved, `crowdin.yml` untouched, AAPT still resolves every locale, +and `gsNotLocalised` still works.** Only one of the 19 production `gsNotLocalised` call sites touches +`:core:keys` at all - `SearchIndexBuilder.kt:340`. The other 18 resolve `:plugins:sync` and +plugin-name strings and were never at risk. + +#### The module flip, and three things it taught + +`com.android.kotlin.multiplatform.library`, `android { }` **inside** `kotlin { }`, +`androidResources { enable = true }`, targets android + jvm + iosArm64 + iosSimulatorArm64. Sources +went to `commonMain` (47), `androidMain` (res), `androidHostTest` (1 test); git recorded every one as +a pure rename. + +- **The flavour fear was unfounded.** That AGP extension really does expose no `productFlavors`, but + AGP matches a flavourless library to any app variant. All 5 flavours of `:app` and `:wear` build. +- **`platform()` does not exist** in a Kotlin source set dependency block. Use + `project.dependencies.platform(...)`. +- **Dropping `android-module-dependencies` silently switches `MissingTranslation` on** and restores + `checkReleaseBuilds` to its default `true`. With 19 empty locales that could fail a release build, + so the `lint { }` block has to be restated in the module. + +#### iOS compiles here + +Kotlin/Native has cross compiled klibs for Apple targets from any host since 2.2.20, on by default - +`PropertiesProvider.kt:595` reads `... ?: true`. `compileKotlinIosArm64` and +`compileKotlinIosSimulatorArm64` genuinely **execute** on the Windows machine this project is +developed on and produce real klibs, against the 177 prebuilt `ios_arm64` klibs already sitting in +`~/.konan`. Nothing is fetched from Apple and Xcode is not involved. + +A Mac is still needed to **run** iOS tests, to link frameworks and XCFrameworks, and for cinterop. +Those tasks are `enabled = false` and report **SKIPPED**, not failed - so a build stays green even if +the entire Apple side silently stops compiling. Any CI job must assert the task *executed*. + +#### Verified + +`:app` + `:wear` green across all 5 flavours; `testFullDebugUnitTest allTests` green; the resource +baseline is byte-identical before and after the move (3924 elements, 31 dirs, 12/12 name lists); and +on an emulator the English screens render titles and summaries, Czech renders `:core:keys` strings in +Czech, and Danish renders Danish. + +`runtests.bat` / `.sh` now run `testFullDebugUnitTest allTests`. Without `allTests` a multiplatform +module has no `testFullDebugUnitTest` task and silently runs **no tests at all** - which is what had +been happening to `:core:data` and `:core:nssdk` since waves 5 to 9, unnoticed because +`failOnNoDiscoveredTests = false`. + +### Wave 15 - the data spine builds for iOS, and 15 tests run on Native (done) + +Committed as `a4291a17cc`, `01678c46dc` and `47798f9e84`. + +`:core:data` and `:core:nssdk` gained `iosArm64()` and `iosSimulatorArm64()`. Both produce genuine +klibs - `native_targets=ios_arm64` and `ios_simulator_arm64` in the manifests - built on Windows. + +**The whole shared data spine needed exactly four `actual`s**, which is the real headline. Grep all +three multiplatform modules for `expect` and that is the entire platform surface underneath a +complete Nightscout read/write client: + +| `expect` | iOS `actual` | +|----------|--------------| +| `systemUtcOffsetAt` | `NSTimeZone.localTimeZone.secondsFromGMTForDate` - the direct counterpart of `TimeZone.getOffset(timestamp)`, per-moment so DST is right | +| `NumberFormatPlatform` | `NSNumberFormatter` - same CLDR data as `DecimalFormat`, with grouping off, half-even rounding and an explicit separator | +| `nsHttpClient` | Ktor **Darwin**, i.e. `NSURLSession` - the system's own connections, proxies and certificate validation | +| `nsIoDispatcher` | `Dispatchers.IO`, which Kotlin/Native does provide on Apple targets | + +These are real implementations, not stubs, and they compile here because the Apple platform klibs +ship **pre-generated inside the Windows Kotlin/Native distribution** - 177 of them for `ios_arm64`, +including `Foundation`. Running cinterop against a new header needs a Mac; consuming what is already +generated does not. + +**`mingwX64` stays, and the reason is the opposite of what was assumed.** It is obsolete as a +*compile* proxy now that the real targets build. But it is the only Kotlin/Native target whose tests +can *run* on Windows - KGP says so plainly: *"Native task 'iosSimulatorArm64Test' is disabled ... +cannot run on the current host (windows-x86_64). Reason: simulator tests require macOS."* + +So `:core:data` got a `commonTest`. `ICfgTest` (8) and `SourceSensorExtensionsTest` (7) moved there +from `jvmTest` and were rewritten from Truth + JUnit 5 to `kotlin.test`; `NumberFormatTest` stayed on +the JVM because it *is* the oracle - it compares against `java.text.DecimalFormat`. Result: **15 tests +now execute through Kotlin/Native**, and the same 15 still run on the JVM. That matters more than it +sounds for `ICfgTest`, which is dosing arithmetic: the point of sharing it with a client is that it +computes the same numbers on every platform, and until now nothing checked that off the JVM. + +Two build details worth keeping: + +- The `ExperimentalNativeApi` opt-in lived inside the `mingwX64 { }` block. It has to move to + `targets.withType`, or `ICfg.iobCalcForTreatment`'s `assert()` will not compile + for iOS. +- `getByName("iosMain")` fails with *"KotlinSourceSet with name 'iosMain' not found"*. The default + hierarchy template creates it **after** the build script is evaluated, so use the lazy `iosMain { }` + accessor. + +**The one thing this could not prove was whether `NSNumberFormatter` and `NSTimeZone` actually agree +with `DecimalFormat` and `TimeZone.getOffset`** - which matters, because `utcOffset` is stored in +every record, takes part in `contentEqualsTo` and is validated by Nightscout, so a units mistake +would corrupt data quietly rather than crash. That gap is closed in wave 16. + +### Wave 16 - the shared code runs on Apple, and the actuals are correct (done) + +Committed as `e115e407a7`, `08f5384826` and `c017e469b4`. + +Two `commonTest` suites were added to `:core:data` to pin platform behaviour as literal expectations +rather than as a comparison, so they mean something on a target that has no JVM to compare against: +`NumberFormatParityTest` (10 tests) and `SystemTimeZoneTest` (4). + +**They found a real divergence on their first run, on Windows.** `0.35` is not representable as a +double - the nearest one is `0.34999999999999997779...`, just below the midpoint. `DecimalFormat` +works from that true decimal value and rounds **down** to `0.3`. The mingw actual multiplies by ten +first, and `0.35 * 10.0 == 3.5` exactly in IEEE-754, so it sees a perfect tie, applies half-even, and +produces `0.4`. Ties are now pinned only on exactly representable values, where every platform must +agree; the non-representable case is documented instead. `DecimalFormat` is the reference, because it +is what every existing AAPS number has been rendered with. + +`.github/workflows/ios-ci.yml` runs on `macos-latest`, needs no secrets, and asserts the tasks +**executed** rather than that the build was green - a disabled Kotlin/Native task reports SKIPPED and +still exits 0, so "BUILD SUCCESSFUL" on its own would stay green if the whole Apple side silently +stopped building. It also checks each klib manifest names the target it claims. + +Result: **29 tests execute on the iOS simulator, zero failures** - the same 29 that run on JVM and +mingw - and all six klibs build on macOS. + +The first run passed but proved less than it looked: GitHub's macOS runners are **UTC**, where every +assertion in `SystemTimeZoneTest` holds trivially because the offset is zero in any unit. The job now +pins `TZ=Europe/Prague`, which has both a non-zero offset and daylight saving. There the winter offset +is 3,600,000 ms, so a seconds-returning implementation would fail both the whole-minutes check +(`3600 % 60000 != 0`) and the whole-hour DST delta. It passes, which is what actually confirms the +`* 1000` in the `NSTimeZone` actual. + +Two traps worth remembering from writing that workflow: + +- GitHub Actions runs `bash -e` but **not** `pipefail`, so `./gradlew ... | tee log` returns *tee's* + exit code and a failed build reports success. Same trap as the pipe warning further down, in a new + place. +- Kotlin/Native rejects a **comma** inside a backtick test name, which the JVM accepts. + +### Was behaviour preserved? + +An audit ran five parallel agents against the migrated code, each trying to find an input where old +and new differ, with a second pass trying to refute what they found. Results worth keeping: + +- **`DecimalFormat` -> `NumberFormat`: identical.** 17 patterns against ~90 values across 57 + locales, + then all 17 against every `Locale.getAvailableLocales()` (~800). Zero differences, including NaN, + infinities, -0.0, 1e300 and the half-even boundaries. +- **`TimeUnit` -> `kotlin.time`: identical where it can be reached.** Checked by loading the real + `kotlin-stdlib-2.4.10.jar` and comparing by reflection over 42 million values. They diverge only + past about 146 million years, which no call site can reach. All 93 changed lines were checked + individually for operator precedence, including the `?:` re-parenthesising in `TreatmentMapper` + and the `hours.hours` local-shadowing in `PatchManager`. +- **The automation `DecimalFormat("1")` change really is invisible.** `:plugins:automation` has + neither Gson nor kotlinx.serialization, `Trigger.toJSON()` is hand written and emits only + value/comparator/units, and the reflection in `ActionEditors.kt` reads only `"text"`, `"daysBack"` + and friends. The field is written and never read. + +Three real changes, all of them fixes, and one bug that the audit caught and that is now fixed (the +formatter cache, above): + +| Change | Effect | +|-------------------------------------|----------------------------------------------------------------------------| +| `qs()` no longer groups | `1234.5` with 1 digit: en `1,234.5` -> `1234.5`, de `1.234.5` -> `1234.5` | +| `formatUS` uses `Locale.US` symbols | correct in Arabic; also swaps U+2212 for `-` on sv, nb, lt, hr, fi, sl, et | +| automation `%d` patterns | the literal-digit bug is gone, but nothing renders it | + +`qs()` also gained a `max(0, digits)` guard: `DecimalFormat` used to clamp a negative +`maximumFractionDigits` to 0, while `NumberFormat` throws. No caller passes a negative, but the +guard +keeps the old contract on a public interface method. + +### Wave 17 - what the in-repo CMP spike proved, and its recipe (spike now deleted) + +Wave 13's spike was a standalone project outside the repo and only asked about **compose-resources**. +A second spike, `:spike:cmp`, lived *inside* the build for several waves and asked a different +question: does Compose Multiplatform work in **this** build, next to the androidx Compose the app +already uses. It has been deleted now that it has nothing left to answer, so its result and its build +recipe are recorded here. + +**What it proved.** Two real `:core:ui` files - `PlusMinusEdit` and its `Helpers` - compiled unchanged +from `commonMain` for `android`, `iosArm64` and `iosSimulatorArm64`, against the real +`:core:data` and `:core:keys` (not stubs), with `stringResource(TextRef)` as an `expect`/`actual` +seam. That is the whole `:core:ui` module flip in miniature, so the flip is a build-configuration +problem rather than an open technical question. + +**The recipe, which the flip should reuse:** + +```kotlin +plugins { + kotlin("multiplatform") + alias(libs.plugins.android.kmp.library) // NOT com.android.library - AGP 9 refuses that with KMP + alias(libs.plugins.compose.compiler) // ships with Kotlin; org.jetbrains.compose hard-fails without it + alias(libs.plugins.compose.multiplatform) +} +``` + +Four deliberate omissions, each of which would have cost a debugging session to rediscover: + +- **No `compose.components.resources`.** compose-resources was rejected for AAPS (it cannot match a + region-less locale against a region-pinned folder). `generateResClass` defaults to `auto`, which + generates nothing unless that dependency is present - so leaving it out *is* the opt-out. +- **No `jvm()` target.** It pulls in the desktop Compose surface (skiko-awt) and gives the module + another way to fail without saying anything about iOS. +- **No `androidResources`.** Off by default for a KMP library. `:core:ui` *does* own `res/`, so unlike + the spike it must enable it - and that is exactly where CMP-9547 (resources not packaged under + AGP 9) becomes relevant, which it could not be for the spike. +- **`lint { checkReleaseBuilds = false }` restated inline**, because `android-module-dependencies` + applies `com.android.library` and so cannot be applied to a multiplatform module. Same reason as + `:core:keys`. + +`material-icons-extended` needed its CMP artifact (`libs.cmp.material.icons.extended`): both copied +files use `Icons.Filled.Remove`, which is not in the core icon set. + +The general form of the question was already answered in public - coil-kt/coil and plainhub/plain-app +both ship these four plugins on Kotlin 2.4.10 + AGP 9.3.1 + CMP 1.11.1 - so the spike only ever +checked that nothing repo-specific interferes. Nothing did. + +### Wave 18 - `:core:interfaces` starts moving, and three bugs fall out (done) + +Commits `7ba30dcdc6`, `8e928f3036`, `ba3c465093`. + +**The strategy was wrong at first, and the correction matters.** `:core:interfaces` was measured the +same way `:core:ui` was - "has an Android import, therefore blocked" - which is a reasonable question +for a UI module and the wrong one for a module of *interfaces*. An Android type in a signature there +is usually a leak in the contract, not a property of the concept. The split is three ways: + +1. **direct elimination** - a multiplatform equivalent exists +2. **abstraction + platform implementation** - the concept is real everywhere, only the type is + Android's; narrow the contract and let the Android implementation keep the Android type +3. **genuinely platform-only** - pump BLE, wear tiles, notification channels; stays in `androidMain` + +#### `androidx` is not uniformly Android, and guessing it is got this wrong twice + +Probed against a real `compileKotlinIosArm64`, not assumed: + +| artifact | verdict | +|---|---| +| `androidx.collection` | **multiplatform** since 1.4.0 - `LongSparseArray` compiles for iOS unchanged | +| `androidx.compose.*` | **free** - Compose Multiplatform publishes the same package names | +| `androidx.annotation` | **split** - `@VisibleForTesting` is common, `@StringRes`/`@RawRes` are not | +| `appcompat`, `fragment`, `activity`, `documentfile` | Android only | + +That correction alone moved 17 files out of the blocked set. `LongSparseArray` had been written down +as "replace with a plain `Map`" - which would have been a **bug**, not just wasted work: it iterates +in ascending key order, and the sensitivity/autosens plugins and the TDD/TIR screens walk it by +index. A `HashMap` would have silently reordered them. + +Corrected count: **64 directly blocked, 85 after propagation, 168 clean** of 253. + +#### org.json, and why the oracle version decides what "correct" means + +`OrgJsonCompat` moved from `:plugins:sync` to `:core:data` so the profile spine can reach it. Its +first parity run failed, and the reason is worth keeping: **Android's `org.json` and Maven's +`org.json:json` are different implementations.** `optString` of a JSON null gives `"null"` on Android +and `""` in Crockford's. The right oracle is `com.vaadin.external.google:android-json`, which is +AOSP's own implementation repackaged for the JVM - it is what `jsonassert` already pulled in, so the +existing Android module tests were measured against it too. It is now declared explicitly. + +Two more findings from that suite: + +- **`optInt` was wrong.** `org.json` reads a numeric **string** through `Double.intValue()`, which + *saturates*, but a real JSON **number** through `Long.intValue()`, which *truncates*. Reading both + through `Long.toInt()` looks obviously right and turns `"1785992181588"` into **-714213548**. +- **Reading is safe to shim; writing is not.** The old suite only covered reads. `org.json` writes a + whole-numbered double without its fraction (`1.0` -> `1`), rejects non-finite doubles that kotlinx + happily emits as invalid JSON, and accepts malformed input kotlinx rejects. Since + `Profile.toPureNsJson` feeds Nightscout, those change bytes on the wire. They are asserted as + divergences rather than hidden. + +**The org.json call sites were deliberately NOT converted.** They are viral: `Profile.toPureNsJson()`, +`ProfileStore.with()`, `SingleProfile.ic/isf/basal` and `APSResult.json()` carry `JSONObject` and +`JSONArray` in their **contracts**, and 164 files touch `org.json`. That is its own deliberate piece, +starting from characterization tests over real profile documents. + +#### A locale bug on the wire + +`RT.toISOString` built the APS result timestamp with `SimpleDateFormat(pattern, Locale.getDefault())`. +`SimpleDateFormat` resolves its calendar from the locale, and a Thai locale selects the Buddhist +calendar: the year went out as **2569 instead of 2026**, a timestamp 543 years in the future, for any +user whose phone is set to Thai. Pre-existing on `dev`, fixed by the conversion because the kotlinx +formatter is locale independent. `RtIsoStringParityTest` asserts the defect existed in the old code +too, so the claim is measured rather than asserted. + +The formatter is built explicitly instead of using `Instant.toString()`, which drops trailing zeros +and omits the fraction on a whole second - the format has to stay `.SSS` always. + +#### joda is gone from `:core:interfaces` + +Only `RT.kt` used it. The parser matters more than it looks: `RT.deserialize` runs on **device status +written by other Nightscout uploaders**, so `+0200` without a colon, offset-less values and bare dates +all arrive in practice, and kotlinx rejects all three. `:core:nssdk` already had a proven lenient +parser, so that logic moved to `:core:data` and is pinned against real joda over 15 shapes. + +Two judgement calls, both deliberate: + +- it is **knowingly duplicated** in `:core:nssdk`, which has *zero* project dependencies; coupling a + standalone SDK module to the app's data module to share ~30 lines is the worse trade +- joda **threw** on garbage while the nssdk copy returns `0L`, so the shared function returns `Long?` + and each caller keeps its own behaviour - `RT` throws, nssdk maps to `0L` + +#### Also found, deliberately not fixed + +`MidnightTime.calc(time)` reads its cache, computes on a miss, and **never stores the result**. The +map is always empty, the `THRESHOLD` reset is dead, and the existing `resetCache` test passes +trivially. It predates the multiplatform work. Adding the write back changes memory use on a hot path +(the graph renderer calls this per point), so it is left alone and recorded here instead. + +The DST sweeps written for that file are the useful part: the old test only asked "is this midnight, +today, in this zone", which any roughly-right implementation passes. It now runs hour by hour across +both 2026 transitions in Europe/Prague, with a guard test asserting the sweep really crosses an offset +change - otherwise a UTC CI machine makes the whole thing vacuous, the same trap as the iOS workflow. + +### Decisions taken, so they are not re-opened by the next analysis + +**`CustomAction` / `CustomActionType` stay, even though they are dead.** A seven-agent pass over the +pump layer classified them as safe to eliminate, with good evidence: nothing constructs a +`CustomAction` anywhere, the only `CustomActionType` implementation is referenced only by its own +declaration, and `git log -S` traces the loss to commit `060ec9d218` ("MDT: compose migration"), which +deleted MedtronicPumpPlugin's wake-up-and-tune / clear-bolus-block / reset-RileyLink actions. + +That evidence is right and the conclusion is still no: this is a deliberate **extension point** for +pump drivers that fell out of use as a side effect of a UI migration, not accidental cruft. Deleting +it would foreclose bringing those actions back. The retention is recorded in the KDoc of both types, +because any future dead-code sweep will find them again and the finding will look new. + +When the feature is revived, `CustomAction.name` is an Android string resource id and needs the same +`TextRef` treatment as everything else here. + +**Dagger stays out of every KMP module, and its wiring goes in one file per module under +`app/src/main/kotlin/app/aaps/di/`.** A module can be multiplatform only if it carries no Dagger +annotation anywhere - not in `commonMain`, and not in `androidMain` either. `CoreObjectsModule`, +`VirtualPumpModule`, `SmoothingPluginsModule` and `CalibrationPluginsModule` are that lift, one file +per converted module so a later swap is a per-module move rather than a repo-wide edit. + +`androidMain` looks like it should be allowed, because only the iOS targets lack Java. It is not, and +the way it fails is the reason this is written down. Probed on `:plugins:smoothing` with +`add("kspAndroid", dagger.compiler)` and a trivial `@Inject constructor` plus `@Module`/`@Provides` +in `androidMain`: + +- `kspAndroidMain` runs and writes `ProbeThing_Factory.java` and `ProbeModule_ProvideNameFactory.java` + into `build/generated/ksp/android/androidMain/java/` +- the only `JavaCompile` task in the whole build is `:buildSrc:compileJava NO-SOURCE` +- `build/classes/` gets the hand written Kotlin and **no** `*_Factory.class` +- **the build passes** - `BUILD SUCCESSFUL` + +So Dagger emits Java into a target that has no javac, and nothing reports it. The module looks +converted and its factories do not exist. This is not a Dagger bug to wait out: Dagger generates Java +even on its KSP backend (https://dagger.dev/dev-guide/ksp.html), and moving it to Kotlin output is a +change Google has not made. + +Practical consequence for planning: **a module's conversion cost is roughly its Dagger count.** +`:plugins:sensitivity` (7 files, no Dagger, no `android.*` imports) is cheap. `:plugins:configuration` +is 22 of 23 files Dagger-bound and is therefore mostly DI work, whatever its Android surface looks +like. + +The exit, when it exists: Dagger PR #5234 (merged 30 July 2026) makes the Hilt Gradle plugin +configure itself under `com.android.kotlin.multiplatform.library`, but only for the android target and +it skips the Java specific steps when `isKmpProject`. We are on Dagger 2.60.1 (6 July 2026), which +predates it. When a release containing it appears, re-run the probe above and check for +`*_Factory.class`, not for a green build. If the generated Java compiles, these `di/` files can move +back into their own modules one at a time. + +**The `QuickWizardEditor` re-indentation stays in the branch.** ~260 of its ~290 changed lines are a +re-indent: the body of `if (mode == QuickWizardMode.WIZARD) {` was previously indented at the same +level as the `if` itself, which reads as though the block is not there. The new indentation is +correct, and the file had to be touched for the `TextRef` migration anyway. It does bury the ~30 real +lines during review - that is the known cost, accepted rather than overlooked. + +--- + +## 9. Open decisions + +1. ~~Add `:core:utils` -> `:core:data`?~~ **Decided: no.** It would slow the build down for no real + gain, and `:core:utils` is not a KMP candidate. `StringUtil` was fixed in place instead, with + `Locale.US` symbols and no new dependency. +2. ~~Add grouping support to `NumberFormat`?~~ **Decided: not needed.** The grouping in + `DateUtilImpl.qs()` was accidental and broken, so it was removed rather than reproduced. +3. ~~Wave 2 size?~~ **Decided: convert `TimeUnit` only, leave `T` alone.** `TimeUnit` is a real + blocker and had to go. `T` is not, so replacing it would have been style work paid for with about + 221 build warnings. The dead `T.now()` was removed instead, which is a real cleanup with no cost. +4. ~~Use `DateUtil` instead of `System.currentTimeMillis()` everywhere?~~ **Decided: only where it + is + needed.** The principle is right - `DateUtil` is an interface, so it mocks in tests and can + differ + per platform - but the sweep would be **547 sites in 227 files**, plus 62 `TimeZone.getDefault()` + in 57 files, and most of it sits in pump drivers that will never be KMP. Do it in the modules on + the KMP path, leave `:pump:*` and `:wear` alone, and make it a rule for new code so the number + stops growing. +5. ~~Which branch for the first KMP module?~~ **Decided: `kmp/core-data-experiment`, and it worked. + ** + The fear was that converting `:core:data` to `kotlin("multiplatform")` would change how Gradle + resolves it for the 13 dependent modules. It does not - a multiplatform module still publishes a + normal Android variant, and all 13 built unmodified. The branch was meant to be thrown away and + is + now worth merging. +6. **Still open: `wear/SmallestDoubleString.kt`** - the last `DecimalFormat` in a non-test file that + is not the deliberate platform seam. It builds patterns from a runtime string, so it needs real + logic rather than a mapping. +7. ~~The `:core:nssdk` converter switch.~~ **Done - see wave 7.** The joda parsing turned out **not + ** + to be part of the contract: it is a plain helper on `RemoteTreatment`, called after decoding, so + it + moves on its own schedule. The ~8 non-null fields were the real answer to this question, and the + answer was "give them defaults" - see the trap in wave 7. `RemoteStatusResponse` was left strict + on + purpose: `v3/status` is AAPS's own call to a server it just authenticated against, and a reply + missing those fields is not something to carry on from. +8. ~~Retrofit converter, or straight to Ktor?~~ **Decided: the converter first, and the reasoning in + the earlier version of this note was wrong.** It argued that Ktor's failure mode is louder, but + going straight to Ktor does not *replace* the serialization change, it *bundles* it - you get the + quiet-wrong-data risk anyway, plus transport risk, in one commit. Separating them meant the + characterization tests could actually do their job on the serialization half. The throwaway + dependency cost nothing in the end: Retrofit 3.0.0 ships an official + `converter-kotlinx-serialization`, so it was one catalog line and no third party code. +9. ~~The OkHttp disk cache needs a `Context`.~~ **Resolved: there is no cache.** It existed only so + a + revalidated GET could surface as a 304, which the paging workers used as their stop condition. + That is now expressed directly - stop when the cursor cannot advance - so the cache, the + `Context` + and the two `Cache` instances that shared one directory are all gone. See wave 8. +10. ~~R8 has never run.~~ **Not a risk: AAPS does not minify.** Both convention plugins + (`android-app-dependencies` and `android-module-dependencies`) set `isMinifyEnabled = false` for + the `release` build type, so R8 never shrinks or obfuscates and the usual + "kotlinx.serialization needs keep rules" problem cannot occur here. A release build was run + anyway: it succeeds, and all five generated `$$serializer` classes plus the Retrofit kotlinx + converter are present in the release dex. The `benchmark` variant (`initWith(release)` plus + debug + signing) installs and starts clean; its runtime sync check is still outstanding because the + emulator lost DNS, which starved both apps equally. +11. **Still open: merge `kmp/core-data-experiment` into `dev`.** Waves 5 to 9 all live there. + Nothing + argues against it any more - the branch was device-verified in mixed-version pairs at each + step - + but it is a real merge of a wire format change and deserves its own decision. It gets riskier + the + longer it waits: `dev` has not moved yet, so the merge is still a fast-forward. +12. **Still open: client-control crypto on a non-JVM platform.** It sits in `jvmMain` and nothing in + `commonMain` calls it, so no target needs it today. A **follower** never signs commands or + unwraps pairing offers, so this only becomes real when another platform should *send* commands. + Golden vectors are ready; the choice is then `expect` / `actual` with a hand written Apple + implementation, or cryptography-kotlin. Prefer deciding it when the platform exists, not before. +13. **Still open: does web ever matter?** It is the one target that changes the **API shape** rather + than just the implementation - WebCrypto is async-only, so `sign()` and `wrap()` would have to + become `suspend`. Cheap to decide now, expensive to retrofit. +14. ~~compose-resources, moko-resources, or something else for strings?~~ **Reopened and decided + again in wave 14: neither. Android keeps AAPT, and only a platform neutral *handle* is shared.** + compose-resources is not the destination after all - it cannot match a region-less locale against + a region-pinned folder, and it has no locale override, so it would have broken 8 of the 11 + translated locales and the always English search index at the same time. moko-resources solves + both, but its latest release predates Kotlin 2.4 with no commits since. `TextRef` is still the + seam, and it is now what makes the answer cheap: the backend is hidden from 342 call sites. +15. ~~Collapse `IntPreferenceKey.entries` / `resolvedEntries`.~~ **Done in wave 11.** `entries` is + `Map`, `resolvedEntries` is deleted, `withEntries` takes `TextRef` - which is what + let the Eopatch `"$it U"` concatenation become a translated template. +16. ~~`RileyLinkStringPreferenceKey.MacAddress` should be a `NonPreferenceKey`.~~ **Done - moved to + `RileyLinkStringKey`.** It is storage, not a preference: no title, no screen, written by the + pairing wizard. With it gone the `titleResId = 0` default came out of all 26 enums, so a + preference key without a title no longer compiles. See wave 10. +17. ~~Does `:core:keys` need its own strings?~~ **Yes, structurally.** It is a leaf module - zero + project dependencies - and `:core:ui` depends on **it**. So the strings cannot move to `:core:ui` + (cycle), and a key naming its own title at compile time means they must live in the same module. + What changed in wave 14 is only *how* they are named: the strings stayed exactly where they were, + in `res/values*`, and the module became multiplatform around them. +18. ~~compose-resources on `:core:keys`, or negative-integer tokens in `TextRef`?~~ + **Overturned in wave 14. Neither: `TextRef.Named` plus a generated id map.** The conclusion that + tokens were wrong survives - they encode a handle without providing a table behind it - but + compose-resources turned out to be worse, not better. A *generated* name is what the reasoning + missed: it is not the reflective `getIdentifier()` lookup that the token scheme was invented to + avoid, and it needs no resource framework at all on the platform that already has one. +19. **Still open: when to enable `MissingTranslation` lint.** It is disabled repo-wide and is the + reason 19 languages silently lost `:core:keys`. Wave 14 makes this less urgent but not moot: the + generator now prints per locale completeness on every build of `:core:keys`, so that one module + is covered. The other 32 string owning modules still have no detector. +20. **Still open: what comes after `:core:keys`.** It holds 1063 strings and every module depends on + it, so the string question returns at a larger scale - though wave 14's answer scales with it, + because nothing has to move. The bigger question for `:core:ui` is not strings at all: it is 16 + files importing `android.*` and a handful of non-Compose `androidx` artifacts. +21. ~~Do Apple targets need a Mac to compile?~~ **No - decided in wave 14.** Kotlin/Native has cross + compiled klibs for Apple targets from any host since 2.2.20, on by default. `:core:keys` compiles + for `iosArm64` and `iosSimulatorArm64` on the Windows machine this project is developed on. A Mac + is still needed to *run* iOS tests, to link frameworks, and for cinterop. + +Waves 1 to 4 are committed on `dev`. Waves 5 to 14 are committed on `kmp` (HEAD `eeddd8182c`), which +is now **20 ahead and 1 behind `dev`**. It stopped being a fast-forward when `dev` moved, so the +merge in open decision 11 is a real merge and gets no cheaper by waiting. + +Waves 5 to 9 were each verified against a live Nightscout before the next started - waves 5 and 6 on +WSA, waves 7 to 9 on an emulator running a new master against a **pre-KMP client**, so every step was +checked in a mixed-version pair. Waves 10 to 12 are compile-time refactors with no wire format in +them, and were verified on the emulator by reading the UI: preference screens, nested sections, +search, the client's sync badges, twelve setup-wizard screens, and the treatment dialogs. The failure +mode of these waves is a **blank label**, not a crash, so they need eyes rather than logcat. + +Still unverified at runtime, because they need hardware the emulator cannot provide: the Eopatch and +Medtrum entry lists, and the Insight `rangeText` path. + +The `FoodManagement` comma defect in section 10 is found but not fixed. + +--- + +## 9a. LIVE BUG - `:core:keys` translations are missing in 19 of 30 languages + +**This is on `dev` today, it predates all KMP work, and nothing in the build can see it.** + +> **A second, separate translation bug was found while checking this one, and is now fixed** +> (`c8c1069817`). The in-app language list offered `"dk"` for Danish, but `dk` is a country code - +> the ISO 639 language code is `da`, and all 32 modules keep their Danish text in `values-da-rDK`. +> No `values-dk` folder exists anywhere in the repository, so `Locale.Builder().setLanguage("dk")` +> matched nothing and **picking Danish rendered the entire app in English**. The translations were +> there the whole time and simply unreachable. Fixed at the single choke point, +> `LocaleHelper.currentLocale()`, which now maps `dk` to `da`; the stored preference value is left +> alone, because `GeneralLanguage` syncs between master and client. Device-verified. +> +> Two more offered languages have no folders anywhere - `af` (Afrikaans) and `ga` (Irish) - but +> nothing is lost there, because they were never translated. The picker simply promises more than it +> can deliver. + +`core/keys/src/main/res/values/strings.xml` has 327 strings. Only **11 locales** have translations: +bg, cs, es, fr, it, nb, ro, sk, vi, zh-rCN, zh-rTW. The other **19 locale files exist but contain +zero `` elements**: de, ru, pl, nl, pt-rBR, pt-rPT, tr, sv, uk, ar, ca, da, el, hr, hu, iw, +ko, lt, sr. + +The proof it is a regression, not merely untranslated work: +`core/ui/src/main/res/values-de-rDE/strings.xml` still carries the marker +``. The German text was removed from +`:core:ui` when the string moved, and never arrived. German users see these strings in English. + +**Why nothing caught it:** `buildSrc/.../android-module-dependencies.gradle.kts:30-31` does +`disable += "MissingTranslation"` and `disable += "ExtraTranslation"` for **every** Android library, +and CI runs only `:app:assemble` / `:wear:assemble` - no lint, no tests. There is currently no +mechanism in this repo that can notice a lost string. + +### What was already done about it + +A Crowdin **TM pre-translate** was run against `fileId 5662` (`/dev/core/keys/.../strings.xml`) for +all 19 languages, with `translateUntranslatedOnly: true` and `autoApproveOption: "none"`. + +| | before | after | +|---|---|---| +| de | 18 | 150 | +| ru | 40 | 176 | +| pl / tr | 0 | 150 | +| nl | 11 | 157 | +| ... | | typically 100-150 of 327 | + +**Recovery is partial (~40%), and this tells us something.** If all these strings had simply *moved*, +TM would have matched nearly all of them. It did not - so roughly 60% were **never translated in any +locale**. It is two problems: a genuine relocation loss, sitting on a larger backlog of new strings +from the Compose preferences migration. + +**Nothing is approved.** The project exports approved translations only, so a `download` still +returns empty files until someone approves in the web editor. The entries are also fuzzy matches +(`translateWithPerfectMatchOnly: false`), which is why they were left for review. + +### Why this blocks the string move + +Phase 3 relocates 327 strings x 30 locales. Right now you **cannot tell a relocation failure from +the pre-existing gap** - for 19 languages there is nothing left to break. Fix and verify first, then +move. Concretely, before converting: + +1. Approve the pre-translated suggestions (or decide to discard them). +2. Turn `MissingTranslation` back on - **warning level plus a CI report**, not an error gate, or the + first run is unusable. +3. Re-download and commit, so the repo has a known-good baseline. + +Useful commands are in the session notes: the CLI needs `-b dev`, a minimal temp `crowdin.yml` with +`preserve_hierarchy: true`, and `--base-path`. The API route avoids the export build entirely: +`GET /api/v2/projects/309752/languages/{lang}/translations?fileId=5662`. + +--- + +## 9b. ~~Where to resume - `:core:keys` to compose-resources~~ Superseded by wave 14 + +**The recipe that used to be here would not have built.** It applied `com.android.library` next to +`kotlin("multiplatform")`, which AGP 9 refuses outright, and it moved 30 locale folders into +`composeResources`, where they would have stopped matching the app's own language setting. It is kept +only as a record of what was planned; what actually happened is wave 14, and the current state is: + +- `:core:keys` **is** a multiplatform module - `com.android.kotlin.multiplatform.library`, targets + android + jvm + iosArm64 + iosSimulatorArm64, all 47 sources in `commonMain`. +- Its strings **did not move**. They are still `src/androidMain/res/values*/strings.xml`, still + processed by AAPT, still the same paths in `crowdin.yml`. +- No `R.string` id appears in its public API any more. + +### Follow-ups, in rough priority order + +1. **Merge `kmp` into `dev`** - no longer a fast-forward, and it gets less free every day. + (Open decision 11.) +2. **The translation work in section 9a** - 19 of 30 locales are still empty shells. +3. **`PluginDescription.description: Int`** - the `-1` sentinel, set by 57 files. It is now a smaller + job than it was: `TextRef.Named` exists, and the pattern for generating names is in `buildSrc`. +4. **Remaining `!= 0` / `!= -1` resource sentinels** - `SearchableItem` (2), `MainDrawer`, + `ManageBottomSheet`, `TreatmentBottomSheet`, `PreferenceScreenView`, `QuickLaunchResolver`, + `InfoStep`, `SWEventListener`. Harmless today. +5. **`IntPreferenceKey.entries` in the pump modules** still use `entriesResIds`; fine while pumps stay + Android. +6. ~~Add Apple targets to `:core:data` / `:core:nssdk`.~~ **Done - wave 15.** +7. ~~A `macos-latest` CI job.~~ **Done - wave 16.** `.github/workflows/ios-ci.yml`. +8. **A resolver for `TextRef.Named` off Android.** `KeysStrings` is in `commonMain`, but the id map + that turns a name into text is `androidMain`. Nothing needs this until there is a non-Android UI, + and the client only needs the system language, so it is a small generated table when it comes. + +### Environment notes for another machine + +- **`:wear:kspFullDebugKotlin` fails on the first run of almost every build** with + `this and base files have different roots: C:\...\dagger-2.60.1.jar!... and E:\Github\AndroidAPS3\wear`. + It is **not flaky** - it is KSP relativising a path from the Gradle cache on `C:` against a project + on `E:`. A re-run succeeds because the output is cached. Fix properly by putting `GRADLE_USER_HOME` + on the same drive as the project. +- **`:pump:combov2:comboctl` and `:pump:danars-emulator`** have async tests that time out under a + fully parallel build but pass when run alone. Verified twice each. Do not chase them. +- Use redirect, not pipe, for gradle output - a pipe reports the *pipe's* exit code, so a failed + build looks like it passed. + +--- + +## 10. Side finding - the decimal separator + +While reviewing the `.replace(",", ".")` calls, the history turned out to be relevant. + +**On a comma locale the numeric keyboard gives a comma, and `toDouble()` accepts only a dot.** +The project has been bitten by this before: + +- `52830f620f` "Allow comma in NumberPicker" (2019) - AAPS forked the platform `DigitsKeyListener` + into a 199 line `DigitsKeyListenerWithComma`, because the stock one swallowed the comma key and + `"1,5"` became `"15"`. +- `5b36b312ce` "Correction bug Steampunk" (2019) - a watch face parsed a delta string the phone had + formatted with the device locale, and threw on comma locales. This is exactly the + format then reparse round trip. +- `f9fa82f1ef` "replace swedish minus sign" (2018) - the Swedish keyboard emits U+2212, so the parse + silently returned 0 instead of a negative number. + +**Today the app is well defended.** Only 4 places use `KeyboardType.Decimal`, and three of them +strip +the comma: `NumberInputRow` (4 read points), `PlusMinusEdit`, `ValueInputDialog`. `SafeParse` does +it +too. There is no `android:inputType` left anywhere - all text input is Compose now. + +### One live defect + +`FoodManagementScreen.kt:357` uses `KeyboardType.Decimal` with a raw `OutlinedTextField`, and +`FoodManagementViewModel.kt:173` parses it with: + +```kotlin +portion = state.editorPortion.toDoubleOrNull() ?: 0.0 +``` + +A user on a comma locale types `12,5`, and the food is saved with `portion = 0.0`. No error, no red +field, Save stays enabled. On an edit this destroys the old value, and the `0.0` syncs to +Nightscout. + +**Severity is low.** `portion` is display only - nothing multiplies by it, and the wizard receives +`food.carbs` (an `Int`, `KeyboardType.Number`). So this is lost metadata, not a wrong dose. The fix +is to use the shared `NumberInputRow` instead of a raw text field. + +### The tests cannot see locale bugs + +`TestBase.kt:28` calls `Locale.setDefault(Locale.ENGLISH)` with no restore, so it leaks into the +whole Gradle test worker. There is no `@Config(qualifiers = ...)` anywhere. On top of that, 19 test +assertions normalise the separator themselves: + +```kotlin +assertThat(sut.to1Decimal(1.33).replace(",", ".")).isEqualTo("1.3") // passes for "1,3" too +``` + +Those tests cannot fail on a locale bug. Worth noting that `7a5d8e2ec9` "use US locale in tests" +(2018) fixed this properly for `ProfileSealedTest` by pinning the locale, and the 2020 Kotlin +rewrite `89d1de9710` lost the pin and put the `.replace()` back. + +This is why the new `NumberFormatTest` sets `ENGLISH`, `GERMAN` and `cs-CZ` explicitly, compares raw +output against real `DecimalFormat` for 17 patterns and 43 values, and restores the locale +afterwards. + +### Not merged, maybe worth taking + +`b7b1571f72` "OTP: format token with `Locale.ROOT` so it stays ASCII digits" exists only on +`origin/fix/code-quality-audit`. On `dev`, `OneTimePassword.kt:75` still uses `Locale.getDefault()`, +so an Arabic locale device would render OTP tokens with Arabic-Indic digits. diff --git a/app/src/androidTest/kotlin/app/aaps/CobExtendedCarbsTest.kt b/app/src/androidTest/kotlin/app/aaps/CobExtendedCarbsTest.kt index 701920750029..86f6f412bb6d 100644 --- a/app/src/androidTest/kotlin/app/aaps/CobExtendedCarbsTest.kt +++ b/app/src/androidTest/kotlin/app/aaps/CobExtendedCarbsTest.kt @@ -88,7 +88,7 @@ class CobExtendedCarbsTest : HiltInstrumentedTest() { // ==================== Helpers ==================== private fun setupEnvironment() = runTest { - rxHelper.listen(EventAutosensCalculationFinished::class.java) + rxHelper.listen(EventAutosensCalculationFinished::class) l.findByName(LTag.EVENTS.name).enabled = true assertThat(config.APS).isTrue() @@ -123,7 +123,7 @@ class CobExtendedCarbsTest : HiltInstrumentedTest() { // Create the profile switch and wait for the resulting EffectiveProfileSwitch (written by the // command queue once the pump push succeeds). Replaces old EventEffectiveProfileSwitchChanged. - val epsList = waits.awaitDbChange(EPS::class.java, what = "EffectiveProfileSwitch after createProfileSwitch") { + val epsList = waits.awaitDbChange(EPS::class, what = "EffectiveProfileSwitch after createProfileSwitch") { val result = profileFunction.createProfileSwitch( profileStore = store, profileName = profileName, @@ -185,17 +185,17 @@ class CobExtendedCarbsTest : HiltInstrumentedTest() { * 2. Autosens calculation to complete (replaces old EventNewHistoryData + EventAutosensCalculationFinished) */ private suspend fun insertBgAndWait(now: Long) { - rxHelper.resetState(EventAutosensCalculationFinished::class.java) + rxHelper.resetState(EventAutosensCalculationFinished::class) // Insert BG and wait for the GV flow emission (replaces old EventNewBG) - val gvList = waits.awaitDbChange(GV::class.java, what = "GlucoseValue after BG insert") { + val gvList = waits.awaitDbChange(GV::class, what = "GlucoseValue after BG insert") { insertFlatBgData(now, 60, 100.0) } aapsLogger.info(LTag.CORE, "GV flow emitted ${gvList.size} entries") assertThat(gvList).isNotEmpty() // Wait for autosens calculation triggered by BG insertion, then for the calc to fully settle - assertThat(rxHelper.waitFor(EventAutosensCalculationFinished::class.java, maxSeconds = 60, comment = "initial calc").first).isTrue() + assertThat(rxHelper.waitFor(EventAutosensCalculationFinished::class, maxSeconds = 60, comment = "initial calc").first).isTrue() waits.awaitCalculationFinished("initial calc settle") } @@ -203,7 +203,7 @@ class CobExtendedCarbsTest : HiltInstrumentedTest() { * Trigger recalculation by inserting a new BG and wait for autosens to complete. */ private suspend fun triggerCalculationAndWait(now: Long) { - rxHelper.resetState(EventAutosensCalculationFinished::class.java) + rxHelper.resetState(EventAutosensCalculationFinished::class) val newBg = listOf( GV( @@ -216,7 +216,7 @@ class CobExtendedCarbsTest : HiltInstrumentedTest() { ) ) persistenceLayer.insertCgmSourceData(Sources.Random, newBg, emptyList(), null) - assertThat(rxHelper.waitFor(EventAutosensCalculationFinished::class.java, maxSeconds = 60, comment = "autosens").first).isTrue() + assertThat(rxHelper.waitFor(EventAutosensCalculationFinished::class, maxSeconds = 60, comment = "autosens").first).isTrue() waits.awaitCalculationFinished("autosens settle") } @@ -400,12 +400,12 @@ class CobExtendedCarbsTest : HiltInstrumentedTest() { setupEnvironment() val now = dateUtil.now() - rxHelper.resetState(EventAutosensCalculationFinished::class.java) - waits.awaitDbChange(GV::class.java, what = "GlucoseValue after BG insert") { + rxHelper.resetState(EventAutosensCalculationFinished::class) + waits.awaitDbChange(GV::class, what = "GlucoseValue after BG insert") { insertFlatBgData(now, 240, 100.0) } insertCarbs(now - 4 * 60 * 60_000L, 10.0, 15 * 60_000L) - assertThat(rxHelper.waitFor(EventAutosensCalculationFinished::class.java, maxSeconds = 60, comment = "autosens").first).isTrue() + assertThat(rxHelper.waitFor(EventAutosensCalculationFinished::class, maxSeconds = 60, comment = "autosens").first).isTrue() waits.awaitCalculationFinished("absorption settle") assertCobBounded(10.0) @@ -417,12 +417,12 @@ class CobExtendedCarbsTest : HiltInstrumentedTest() { setupEnvironment() val now = dateUtil.now() - rxHelper.resetState(EventAutosensCalculationFinished::class.java) - waits.awaitDbChange(GV::class.java, what = "GlucoseValue after BG insert") { + rxHelper.resetState(EventAutosensCalculationFinished::class) + waits.awaitDbChange(GV::class, what = "GlucoseValue after BG insert") { insertFlatBgData(now, 240, 100.0) } insertCarbs(now - 4 * 60 * 60_000L, 10.0, 0) - assertThat(rxHelper.waitFor(EventAutosensCalculationFinished::class.java, maxSeconds = 60, comment = "autosens").first).isTrue() + assertThat(rxHelper.waitFor(EventAutosensCalculationFinished::class, maxSeconds = 60, comment = "autosens").first).isTrue() waits.awaitCalculationFinished("absorption settle") assertCobBounded(10.0) @@ -436,8 +436,8 @@ class CobExtendedCarbsTest : HiltInstrumentedTest() { setupEnvironment() val now = dateUtil.now() - rxHelper.resetState(EventAutosensCalculationFinished::class.java) - waits.awaitDbChange(GV::class.java, what = "GlucoseValue after BG insert") { + rxHelper.resetState(EventAutosensCalculationFinished::class) + waits.awaitDbChange(GV::class, what = "GlucoseValue after BG insert") { insertBgData(now, 60, { minutesAgo -> 200.0 - minutesAgo * (100.0 / 60.0) }, TrendArrow.FORTY_FIVE_UP) } insertCarbs(now - 30 * 60_000L, 35.0, 2 * 60 * 60_000L) @@ -452,8 +452,8 @@ class CobExtendedCarbsTest : HiltInstrumentedTest() { setupEnvironment() val now = dateUtil.now() - rxHelper.resetState(EventAutosensCalculationFinished::class.java) - waits.awaitDbChange(GV::class.java, what = "GlucoseValue after BG insert") { + rxHelper.resetState(EventAutosensCalculationFinished::class) + waits.awaitDbChange(GV::class, what = "GlucoseValue after BG insert") { insertBgData(now, 60, { minutesAgo -> 180.0 - minutesAgo * (100.0 / 60.0) }, TrendArrow.FORTY_FIVE_UP) } insertCarbs(now - 20 * 60_000L, 20.0, 0) @@ -468,12 +468,12 @@ class CobExtendedCarbsTest : HiltInstrumentedTest() { setupEnvironment() val now = dateUtil.now() - rxHelper.resetState(EventAutosensCalculationFinished::class.java) - waits.awaitDbChange(GV::class.java, what = "GlucoseValue after BG insert") { + rxHelper.resetState(EventAutosensCalculationFinished::class) + waits.awaitDbChange(GV::class, what = "GlucoseValue after BG insert") { insertBgData(now, 240, { minutesAgo -> 250.0 - minutesAgo * (170.0 / 240.0) }, TrendArrow.FORTY_FIVE_UP) } insertCarbs(now - 4 * 60 * 60_000L, 10.0, 15 * 60_000L) - assertThat(rxHelper.waitFor(EventAutosensCalculationFinished::class.java, maxSeconds = 60, comment = "autosens").first).isTrue() + assertThat(rxHelper.waitFor(EventAutosensCalculationFinished::class, maxSeconds = 60, comment = "autosens").first).isTrue() waits.awaitCalculationFinished("absorption settle") assertCobBounded(10.0) diff --git a/app/src/androidTest/kotlin/app/aaps/LoopTest.kt b/app/src/androidTest/kotlin/app/aaps/LoopTest.kt index 1645410de23a..a494ec1aee64 100644 --- a/app/src/androidTest/kotlin/app/aaps/LoopTest.kt +++ b/app/src/androidTest/kotlin/app/aaps/LoopTest.kt @@ -96,10 +96,10 @@ class LoopTest : HiltInstrumentedTest() { source = Sources.Aaps, listValues = listOf(ValueWithUnit.SimpleString("Migration")) ) - rxHelper.listen(EventLoopSetLastRunGui::class.java) - rxHelper.listen(EventResetOpenAPSGui::class.java) - rxHelper.listen(EventOpenAPSUpdateGui::class.java) - rxHelper.listen(EventAPSCalculationFinished::class.java) + rxHelper.listen(EventLoopSetLastRunGui::class) + rxHelper.listen(EventResetOpenAPSGui::class) + rxHelper.listen(EventOpenAPSUpdateGui::class) + rxHelper.listen(EventAPSCalculationFinished::class) objectivesPlugin.onStart() // Enable event logging @@ -110,7 +110,7 @@ class LoopTest : HiltInstrumentedTest() { // Loop should be limited by unfinished objectives loop.invoke("test1", allowNotification = false) - var loopStatusEvent = rxHelper.waitFor(EventLoopSetLastRunGui::class.java, comment = "step1") + var loopStatusEvent = rxHelper.waitFor(EventLoopSetLastRunGui::class, comment = "step1") assertThat(loopStatusEvent.first).isTrue() assertThat((loopStatusEvent.second as EventLoopSetLastRunGui).text).contains("Loop disabled by user") @@ -120,7 +120,7 @@ class LoopTest : HiltInstrumentedTest() { // Now there should be missing profile (profileFunction as ProfileFunctionImpl).cache.clear() loop.invoke("test2", allowNotification = false) - loopStatusEvent = rxHelper.waitFor(EventLoopSetLastRunGui::class.java, comment = "step2") + loopStatusEvent = rxHelper.waitFor(EventLoopSetLastRunGui::class, comment = "step2") assertThat(loopStatusEvent.first).isTrue() assertThat((loopStatusEvent.second as EventLoopSetLastRunGui).text).contains("NO PROFILE SET") @@ -156,13 +156,13 @@ class LoopTest : HiltInstrumentedTest() { assertThat(rxHelper.waitUntil("step3: pump profile set") { runBlocking { pumpSync.expectedPumpState() }.profile != null }).isTrue() // Loop should run — may get "NO APS SELECTED" (no glucose) or a real result (stale glucose cache) - rxHelper.listen(EventLoopUpdateGui::class.java) + rxHelper.listen(EventLoopUpdateGui::class) loop.invoke("test3", allowNotification = false) // Accept either: error event (no glucose) or update event (APS produced result from cached data) assertThat( rxHelper.waitUntil("step4: loop completed") { - rxHelper.waitFor(EventLoopSetLastRunGui::class.java, maxSeconds = 1, comment = "step4").first || - rxHelper.waitFor(EventLoopUpdateGui::class.java, maxSeconds = 1, comment = "step4").first + rxHelper.waitFor(EventLoopSetLastRunGui::class, maxSeconds = 1, comment = "step4").first || + rxHelper.waitFor(EventLoopUpdateGui::class, maxSeconds = 1, comment = "step4").first } ).isTrue() @@ -180,7 +180,7 @@ class LoopTest : HiltInstrumentedTest() { // GV insertion triggers calculation via observeChanges(GV) → scheduleHistoryDataChange (5s debounce) // The IOB/COB autosens phase may exit early ("No bucketed data") so EventAutosensCalculationFinished // is not guaranteed. Wait for EventAPSCalculationFinished which fires when loop runs. - assertThat(rxHelper.waitFor(EventAPSCalculationFinished::class.java, maxSeconds = 60, comment = "step6").first).isTrue() + assertThat(rxHelper.waitFor(EventAPSCalculationFinished::class, maxSeconds = 60, comment = "step6").first).isTrue() Thread.sleep(5000) assertThat(loop.lastRun).isNotNull() } diff --git a/app/src/androidTest/kotlin/app/aaps/ReplayApsResultsTest.kt b/app/src/androidTest/kotlin/app/aaps/ReplayApsResultsTest.kt index 35b132dac3c0..9b2b1fdbacac 100644 --- a/app/src/androidTest/kotlin/app/aaps/ReplayApsResultsTest.kt +++ b/app/src/androidTest/kotlin/app/aaps/ReplayApsResultsTest.kt @@ -3,6 +3,7 @@ package app.aaps import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.platform.app.InstrumentationRegistry import app.aaps.core.data.model.GlucoseUnit +import app.aaps.core.interfaces.aps.APSResult import app.aaps.core.interfaces.aps.AutosensResult import app.aaps.core.interfaces.aps.CurrentTemp import app.aaps.core.interfaces.aps.GlucoseStatusAutoIsf @@ -118,7 +119,7 @@ class ReplayApsResultsTest : HiltInstrumentedTest() { JSONAssert.assertEquals( "Error in file $filename", output.toString(), - result?.json()?.apply { + result?.jsonOrg()?.apply { // this is added afterwards to json. Copy from original put("timestamp", output.getString("timestamp")) }.toString(), @@ -239,22 +240,22 @@ class ReplayApsResultsTest : HiltInstrumentedTest() { aapsLogger.info(LTag.APS, resultKt.toString()) - aapsLogger.debug(LTag.APS, result?.json()?.getString("reason") ?: "") + aapsLogger.debug(LTag.APS, result?.jsonOrg()?.getString("reason") ?: "") aapsLogger.debug(LTag.APS, resultKt.reason.toString()) aapsLogger.debug(LTag.APS, "File: $filename") // assertThat(resultKt.reason.toString()).isEqualTo(result?.json?.getString("reason")) - assertThat(resultKt.tick ?: "").isEqualTo(result?.json()?.optString("tick")) - assertThat(resultKt.eventualBG ?: Double.NaN).isEqualTo(result?.json()?.optDouble("eventualBG")) - assertThat(resultKt.targetBG ?: Double.NaN).isEqualTo(result?.json()?.optDouble("targetBG")) - assertThat(resultKt.insulinReq ?: Double.NaN).isEqualTo(result?.json()?.optDouble("insulinReq")) - assertThat(resultKt.carbsReq ?: 0).isEqualTo(result?.json()?.optInt("carbsReq")) - assertThat(resultKt.carbsReqWithin ?: 0).isEqualTo(result?.json()?.optInt("carbsReqWithin")) - assertThat(resultKt.units ?: Double.NaN).isEqualTo(result?.json()?.optDouble("units")) - assertThat(resultKt.sensitivityRatio ?: Double.NaN).isEqualTo(result?.json()?.optDouble("sensitivityRatio")) - assertThat(resultKt.duration ?: 0).isEqualTo(result?.json()?.optInt("duration")) - assertThat(resultKt.rate ?: Double.NaN).isEqualTo(result?.json()?.optDouble("rate")) - assertThat(resultKt.COB ?: Double.NaN).isEqualTo(result?.json()?.optDouble("COB")) - assertThat(resultKt.IOB ?: Double.NaN).isEqualTo(result?.json()?.optDouble("IOB")) + assertThat(resultKt.tick ?: "").isEqualTo(result?.jsonOrg()?.optString("tick")) + assertThat(resultKt.eventualBG ?: Double.NaN).isEqualTo(result?.jsonOrg()?.optDouble("eventualBG")) + assertThat(resultKt.targetBG ?: Double.NaN).isEqualTo(result?.jsonOrg()?.optDouble("targetBG")) + assertThat(resultKt.insulinReq ?: Double.NaN).isEqualTo(result?.jsonOrg()?.optDouble("insulinReq")) + assertThat(resultKt.carbsReq ?: 0).isEqualTo(result?.jsonOrg()?.optInt("carbsReq")) + assertThat(resultKt.carbsReqWithin ?: 0).isEqualTo(result?.jsonOrg()?.optInt("carbsReqWithin")) + assertThat(resultKt.units ?: Double.NaN).isEqualTo(result?.jsonOrg()?.optDouble("units")) + assertThat(resultKt.sensitivityRatio ?: Double.NaN).isEqualTo(result?.jsonOrg()?.optDouble("sensitivityRatio")) + assertThat(resultKt.duration ?: 0).isEqualTo(result?.jsonOrg()?.optInt("duration")) + assertThat(resultKt.rate ?: Double.NaN).isEqualTo(result?.jsonOrg()?.optDouble("rate")) + assertThat(resultKt.COB ?: Double.NaN).isEqualTo(result?.jsonOrg()?.optDouble("COB")) + assertThat(resultKt.IOB ?: Double.NaN).isEqualTo(result?.jsonOrg()?.optDouble("IOB")) } private fun testOpenAPSSMBDynamicISF(filename: String, input: JSONObject, output: JSONObject, injector: HasAndroidInjector) { @@ -283,7 +284,7 @@ class ReplayApsResultsTest : HiltInstrumentedTest() { JSONAssert.assertEquals( "Error in file $filename", output.toString(), - result?.json()?.apply { + result?.jsonOrg()?.apply { // this is added afterwards to json. Copy from original put("timestamp", output.getString("timestamp")) }.toString(), @@ -404,23 +405,23 @@ class ReplayApsResultsTest : HiltInstrumentedTest() { aapsLogger.info(LTag.APS, resultKt.toString()) - aapsLogger.debug(LTag.APS, result?.json()?.getString("reason") ?: "") + aapsLogger.debug(LTag.APS, result?.jsonOrg()?.getString("reason") ?: "") aapsLogger.debug(LTag.APS, resultKt.reason.toString()) aapsLogger.debug(LTag.APS, "File: $filename") -// assertThat(resultKt.reason.toString()).isEqualTo(result?.json()?.getString("reason")) - assertThat(resultKt.tick ?: "").isEqualTo(result?.json()?.optString("tick")) - assertThat(resultKt.eventualBG ?: Double.NaN).isEqualTo(result?.json()?.optDouble("eventualBG")) - assertThat(resultKt.targetBG ?: Double.NaN).isEqualTo(result?.json()?.optDouble("targetBG")) - assertThat(resultKt.insulinReq ?: Double.NaN).isEqualTo(result?.json()?.optDouble("insulinReq")) - assertThat(resultKt.carbsReq ?: 0).isEqualTo(result?.json()?.optInt("carbsReq")) - assertThat(resultKt.carbsReqWithin ?: 0).isEqualTo(result?.json()?.optInt("carbsReqWithin")) - assertThat(resultKt.units ?: Double.NaN).isEqualTo(result?.json()?.optDouble("units")) - assertThat(resultKt.sensitivityRatio ?: Double.NaN).isEqualTo(result?.json()?.optDouble("sensitivityRatio")) - assertThat(resultKt.duration ?: 0).isEqualTo(result?.json()?.optInt("duration")) - assertThat(resultKt.rate ?: Double.NaN).isEqualTo(result?.json()?.optDouble("rate")) - assertThat(resultKt.COB ?: Double.NaN).isEqualTo(result?.json()?.optDouble("COB")) - assertThat(resultKt.IOB ?: Double.NaN).isEqualTo(result?.json()?.optDouble("IOB")) - assertThat(resultKt.variable_sens ?: Double.NaN).isEqualTo(result?.json()?.optDouble("variable_sens")) +// assertThat(resultKt.reason.toString()).isEqualTo(result?.jsonOrg()?.getString("reason")) + assertThat(resultKt.tick ?: "").isEqualTo(result?.jsonOrg()?.optString("tick")) + assertThat(resultKt.eventualBG ?: Double.NaN).isEqualTo(result?.jsonOrg()?.optDouble("eventualBG")) + assertThat(resultKt.targetBG ?: Double.NaN).isEqualTo(result?.jsonOrg()?.optDouble("targetBG")) + assertThat(resultKt.insulinReq ?: Double.NaN).isEqualTo(result?.jsonOrg()?.optDouble("insulinReq")) + assertThat(resultKt.carbsReq ?: 0).isEqualTo(result?.jsonOrg()?.optInt("carbsReq")) + assertThat(resultKt.carbsReqWithin ?: 0).isEqualTo(result?.jsonOrg()?.optInt("carbsReqWithin")) + assertThat(resultKt.units ?: Double.NaN).isEqualTo(result?.jsonOrg()?.optDouble("units")) + assertThat(resultKt.sensitivityRatio ?: Double.NaN).isEqualTo(result?.jsonOrg()?.optDouble("sensitivityRatio")) + assertThat(resultKt.duration ?: 0).isEqualTo(result?.jsonOrg()?.optInt("duration")) + assertThat(resultKt.rate ?: Double.NaN).isEqualTo(result?.jsonOrg()?.optDouble("rate")) + assertThat(resultKt.COB ?: Double.NaN).isEqualTo(result?.jsonOrg()?.optDouble("COB")) + assertThat(resultKt.IOB ?: Double.NaN).isEqualTo(result?.jsonOrg()?.optDouble("IOB")) + assertThat(resultKt.variable_sens ?: Double.NaN).isEqualTo(result?.jsonOrg()?.optDouble("variable_sens")) } private fun testOpenAPSAMA(filename: String, input: JSONObject, output: JSONObject, injector: HasAndroidInjector) { @@ -442,7 +443,7 @@ class ReplayApsResultsTest : HiltInstrumentedTest() { JSONAssert.assertEquals( "Error in file $filename", output.toString(), - result?.json()?.apply { + result?.jsonOrg()?.apply { // this is added afterwards to json. Copy from original put("timestamp", output.getString("timestamp")) }.toString(), @@ -558,23 +559,23 @@ class ReplayApsResultsTest : HiltInstrumentedTest() { aapsLogger.info(LTag.APS, resultKt.toString()) - aapsLogger.debug(LTag.APS, result?.json()?.getString("reason") ?: "") + aapsLogger.debug(LTag.APS, result?.jsonOrg()?.getString("reason") ?: "") aapsLogger.debug(LTag.APS, resultKt.reason.toString()) aapsLogger.debug(LTag.APS, "File: $filename") -// assertThat(resultKt.reason.toString()).isEqualTo(result?.json()?.getString("reason")) - assertThat(resultKt.tick ?: "").isEqualTo(result?.json()?.optString("tick")) - assertThat(resultKt.eventualBG ?: Double.NaN).isEqualTo(result?.json()?.optDouble("eventualBG")) - assertThat(resultKt.targetBG ?: Double.NaN).isEqualTo(result?.json()?.optDouble("targetBG")) - assertThat(resultKt.insulinReq ?: Double.NaN).isEqualTo(result?.json()?.optDouble("insulinReq")) - assertThat(resultKt.carbsReq ?: 0).isEqualTo(result?.json()?.optInt("carbsReq")) - assertThat(resultKt.carbsReqWithin ?: 0).isEqualTo(result?.json()?.optInt("carbsReqWithin")) - assertThat(resultKt.units ?: Double.NaN).isEqualTo(result?.json()?.optDouble("units")) - assertThat(resultKt.sensitivityRatio ?: Double.NaN).isEqualTo(result?.json()?.optDouble("sensitivityRatio")) - assertThat(resultKt.duration ?: 0).isEqualTo(result?.json()?.optInt("duration")) - assertThat(resultKt.rate ?: Double.NaN).isEqualTo(result?.json()?.optDouble("rate")) - assertThat(resultKt.COB ?: Double.NaN).isEqualTo(result?.json()?.optDouble("COB")) - assertThat(resultKt.IOB ?: Double.NaN).isEqualTo(result?.json()?.optDouble("IOB")) - assertThat(resultKt.variable_sens ?: Double.NaN).isEqualTo(result?.json()?.optDouble("variable_sens")) +// assertThat(resultKt.reason.toString()).isEqualTo(result?.jsonOrg()?.getString("reason")) + assertThat(resultKt.tick ?: "").isEqualTo(result?.jsonOrg()?.optString("tick")) + assertThat(resultKt.eventualBG ?: Double.NaN).isEqualTo(result?.jsonOrg()?.optDouble("eventualBG")) + assertThat(resultKt.targetBG ?: Double.NaN).isEqualTo(result?.jsonOrg()?.optDouble("targetBG")) + assertThat(resultKt.insulinReq ?: Double.NaN).isEqualTo(result?.jsonOrg()?.optDouble("insulinReq")) + assertThat(resultKt.carbsReq ?: 0).isEqualTo(result?.jsonOrg()?.optInt("carbsReq")) + assertThat(resultKt.carbsReqWithin ?: 0).isEqualTo(result?.jsonOrg()?.optInt("carbsReqWithin")) + assertThat(resultKt.units ?: Double.NaN).isEqualTo(result?.jsonOrg()?.optDouble("units")) + assertThat(resultKt.sensitivityRatio ?: Double.NaN).isEqualTo(result?.jsonOrg()?.optDouble("sensitivityRatio")) + assertThat(resultKt.duration ?: 0).isEqualTo(result?.jsonOrg()?.optInt("duration")) + assertThat(resultKt.rate ?: Double.NaN).isEqualTo(result?.jsonOrg()?.optDouble("rate")) + assertThat(resultKt.COB ?: Double.NaN).isEqualTo(result?.jsonOrg()?.optDouble("COB")) + assertThat(resultKt.IOB ?: Double.NaN).isEqualTo(result?.jsonOrg()?.optDouble("IOB")) + assertThat(resultKt.variable_sens ?: Double.NaN).isEqualTo(result?.jsonOrg()?.optDouble("variable_sens")) } private fun testOpenAPSSMBAutoISF(filename: String, input: JSONObject, output: JSONObject, injector: HasAndroidInjector) { @@ -599,7 +600,7 @@ class ReplayApsResultsTest : HiltInstrumentedTest() { JSONAssert.assertEquals( "Error in file $filename", output.toString(), - result.json()?.apply { + result.jsonOrg()?.apply { // this is added afterwards to json. Copy from original put("timestamp", output.getString("timestamp")) }.toString(), @@ -750,23 +751,23 @@ class ReplayApsResultsTest : HiltInstrumentedTest() { // // aapsLogger.info(LTag.APS, resultKt.toString()) // - // aapsLogger.debug(LTag.APS, result?.json()?.getString("reason") ?: "") + // aapsLogger.debug(LTag.APS, result?.jsonOrg()?.getString("reason") ?: "") // aapsLogger.debug(LTag.APS, resultKt.reason.toString()) aapsLogger.debug(LTag.APS, "File: $filename") // // assertThat(resultKt.reason.toString()).isEqualTo(result?.json?.getString("reason")) - assertThat(resultKt.tick ?: "").isEqualTo(result.json()?.optString("tick")) - assertThat(resultKt.eventualBG ?: 0.0).isWithin(1.0).of(result.json()?.optDouble("eventualBG") ?: 0.0) - assertThat(resultKt.targetBG ?: Double.NaN).isEqualTo(result.json()?.optDouble("targetBG")) - assertThat(resultKt.insulinReq ?: Double.NaN).isEqualTo(result.json()?.optDouble("insulinReq")) - assertThat(resultKt.carbsReq ?: 0).isEqualTo(result.json()?.optInt("carbsReq")) - assertThat(resultKt.carbsReqWithin ?: 0).isEqualTo(result.json()?.optInt("carbsReqWithin")) - assertThat(resultKt.units ?: Double.NaN).isEqualTo(result.json()?.optDouble("units")) - assertThat(resultKt.sensitivityRatio ?: Double.NaN).isEqualTo(result.json()?.optDouble("sensitivityRatio")) - assertThat(resultKt.duration ?: 0).isEqualTo(result.json()?.optInt("duration")) - assertThat(resultKt.rate ?: Double.NaN).isEqualTo(result.json()?.optDouble("rate")) - assertThat(resultKt.COB ?: Double.NaN).isEqualTo(result.json()?.optDouble("COB")) - assertThat(resultKt.IOB ?: Double.NaN).isEqualTo(result.json()?.optDouble("IOB")) - assertThat(resultKt.variable_sens ?: Double.NaN).isEqualTo(result.json()?.optDouble("variable_sens")) + assertThat(resultKt.tick ?: "").isEqualTo(result.jsonOrg()?.optString("tick")) + assertThat(resultKt.eventualBG ?: 0.0).isWithin(1.0).of(result.jsonOrg()?.optDouble("eventualBG") ?: 0.0) + assertThat(resultKt.targetBG ?: Double.NaN).isEqualTo(result.jsonOrg()?.optDouble("targetBG")) + assertThat(resultKt.insulinReq ?: Double.NaN).isEqualTo(result.jsonOrg()?.optDouble("insulinReq")) + assertThat(resultKt.carbsReq ?: 0).isEqualTo(result.jsonOrg()?.optInt("carbsReq")) + assertThat(resultKt.carbsReqWithin ?: 0).isEqualTo(result.jsonOrg()?.optInt("carbsReqWithin")) + assertThat(resultKt.units ?: Double.NaN).isEqualTo(result.jsonOrg()?.optDouble("units")) + assertThat(resultKt.sensitivityRatio ?: Double.NaN).isEqualTo(result.jsonOrg()?.optDouble("sensitivityRatio")) + assertThat(resultKt.duration ?: 0).isEqualTo(result.jsonOrg()?.optInt("duration")) + assertThat(resultKt.rate ?: Double.NaN).isEqualTo(result.jsonOrg()?.optDouble("rate")) + assertThat(resultKt.COB ?: Double.NaN).isEqualTo(result.jsonOrg()?.optDouble("COB")) + assertThat(resultKt.IOB ?: Double.NaN).isEqualTo(result.jsonOrg()?.optDouble("IOB")) + assertThat(resultKt.variable_sens ?: Double.NaN).isEqualTo(result.jsonOrg()?.optDouble("variable_sens")) } enum class TestSource { ASSET, FILE } @@ -804,4 +805,14 @@ class ReplayApsResultsTest : HiltInstrumentedTest() { TestSource.FILE -> JSONObject(storage.getFileContents(File(path))).apply { put("filename", name) } } } + + /** + * [APSResult.json] hands back an immutable kotlinx document now. Every assertion in this file was + * written against `org.json` and leans on its accessor defaults - `optDouble` gives NaN for a + * missing key, `optInt` gives 0, `optString` gives "" - and two places still add a timestamp to + * the document before comparing it. Reading the same bytes back through `org.json` keeps all of + * that exactly as written, which also makes this file say that the new document is the same + * document. This is androidTest, so the extra parse costs nothing. + */ + private fun APSResult.jsonOrg(): JSONObject? = json()?.let { JSONObject(it.toString()) } } diff --git a/app/src/androidTest/kotlin/app/aaps/e2e/AbstractDanaEmulatorUiTest.kt b/app/src/androidTest/kotlin/app/aaps/e2e/AbstractDanaEmulatorUiTest.kt index bb68bdb5dbf5..e34ed662231e 100644 --- a/app/src/androidTest/kotlin/app/aaps/e2e/AbstractDanaEmulatorUiTest.kt +++ b/app/src/androidTest/kotlin/app/aaps/e2e/AbstractDanaEmulatorUiTest.kt @@ -22,6 +22,8 @@ import app.aaps.core.interfaces.profile.ProfileFunction import app.aaps.core.interfaces.profile.ProfileRepository import app.aaps.core.interfaces.queue.CommandQueue import app.aaps.core.interfaces.utils.DateUtil +import app.aaps.core.objects.extensions.singleBlock +import app.aaps.core.objects.extensions.singleTargetBlock import app.aaps.core.keys.BooleanKey import app.aaps.core.keys.BooleanNonKey import app.aaps.core.keys.StringKey @@ -32,7 +34,6 @@ import app.aaps.plugins.aps.utils.StaticInjector import app.aaps.pump.dana.DanaPump import com.google.common.truth.Truth.assertThat import kotlinx.coroutines.runBlocking -import org.json.JSONArray import org.junit.After import org.junit.Before import java.io.File @@ -192,14 +193,13 @@ abstract class AbstractDanaEmulatorUiTest { * deliberately invalid — fill them in or the profile switch below is rejected as invalid. */ private fun seedLocalProfile() { - val profile = profileRepository.newDraft().apply { - mgdl = true - ic = JSONArray(singleValue(10.0)) - isf = JSONArray(singleValue(50.0)) - basal = JSONArray(singleValue(0.5)) - targetLow = JSONArray(singleValue(100.0)) - targetHigh = JSONArray(singleValue(110.0)) - } + val profile = profileRepository.newDraft().copy( + mgdl = true, + ic = singleBlock(10.0), + isf = singleBlock(50.0), + basal = singleBlock(0.5), + target = singleTargetBlock(100.0, 110.0) + ) check(profile.name == PROFILE_NAME) { "Expected the draft to be named $PROFILE_NAME, got ${profile.name}" } runBlocking { profileRepository.add(profile) }.getOrThrow() } @@ -242,9 +242,6 @@ abstract class AbstractDanaEmulatorUiTest { } /** The profile-editor JSON shape: a single all-day value. */ - private fun singleValue(value: Double) = - """[{"time":"00:00","timeAsSeconds":0,"value":$value}]""" - /** Polls [supplier] until it returns non-null or [timeoutMs] elapses. */ private fun awaitNotNull(timeoutMs: Long, supplier: () -> T?): T? { val end = SystemClock.uptimeMillis() + timeoutMs diff --git a/app/src/androidTest/kotlin/app/aaps/e2e/DanaRPairWizardUiTest.kt b/app/src/androidTest/kotlin/app/aaps/e2e/DanaRPairWizardUiTest.kt index d20f13ad15dc..1d2e86a8b931 100644 --- a/app/src/androidTest/kotlin/app/aaps/e2e/DanaRPairWizardUiTest.kt +++ b/app/src/androidTest/kotlin/app/aaps/e2e/DanaRPairWizardUiTest.kt @@ -27,6 +27,8 @@ import app.aaps.core.interfaces.profile.ProfileRepository import app.aaps.core.interfaces.pump.Pump import app.aaps.core.interfaces.queue.CommandQueue import app.aaps.core.interfaces.utils.DateUtil +import app.aaps.core.objects.extensions.singleBlock +import app.aaps.core.objects.extensions.singleTargetBlock import app.aaps.core.keys.BooleanComposedKey import app.aaps.core.keys.BooleanNonKey import app.aaps.core.keys.StringKey @@ -42,7 +44,6 @@ import com.google.common.truth.Truth.assertThat import dagger.hilt.android.testing.HiltAndroidRule import dagger.hilt.android.testing.HiltAndroidTest import kotlinx.coroutines.runBlocking -import org.json.JSONArray import org.junit.After import org.junit.Before import org.junit.Rule @@ -172,14 +173,13 @@ class DanaRPairWizardUiTest { } private fun seedLocalProfile() { - val profile = profileRepository.newDraft().apply { - mgdl = true - ic = JSONArray(singleValue(10.0)) - isf = JSONArray(singleValue(50.0)) - basal = JSONArray(singleValue(0.5)) - targetLow = JSONArray(singleValue(100.0)) - targetHigh = JSONArray(singleValue(110.0)) - } + val profile = profileRepository.newDraft().copy( + mgdl = true, + ic = singleBlock(10.0), + isf = singleBlock(50.0), + basal = singleBlock(0.5), + target = singleTargetBlock(100.0, 110.0) + ) runBlocking { profileRepository.add(profile) }.getOrThrow() } @@ -199,8 +199,6 @@ class DanaRPairWizardUiTest { checkNotNull(switch) { "Could not activate the seeded local profile" } } - private fun singleValue(value: Double) = """[{"time":"00:00","timeAsSeconds":0,"value":$value}]""" - // ---- ui helpers (same contract as DanaRSPairWizardUiTest) ----------------------------------- private fun byText(s: String): BySelector = By.text(Pattern.compile(Pattern.quote(s), Pattern.CASE_INSENSITIVE)) diff --git a/app/src/androidTest/kotlin/app/aaps/e2e/DanaRSPairWizardUiTest.kt b/app/src/androidTest/kotlin/app/aaps/e2e/DanaRSPairWizardUiTest.kt index 07ff63c29e12..c1c69d3a0098 100644 --- a/app/src/androidTest/kotlin/app/aaps/e2e/DanaRSPairWizardUiTest.kt +++ b/app/src/androidTest/kotlin/app/aaps/e2e/DanaRSPairWizardUiTest.kt @@ -29,6 +29,8 @@ import app.aaps.core.interfaces.queue.CommandQueue import app.aaps.core.interfaces.pump.ble.PairingState import app.aaps.core.interfaces.pump.ble.PairingStep import app.aaps.core.interfaces.utils.DateUtil +import app.aaps.core.objects.extensions.singleBlock +import app.aaps.core.objects.extensions.singleTargetBlock import app.aaps.core.keys.BooleanComposedKey import app.aaps.core.keys.BooleanNonKey import app.aaps.core.keys.StringKey @@ -43,7 +45,6 @@ import com.google.common.truth.Truth.assertThat import dagger.hilt.android.testing.HiltAndroidRule import dagger.hilt.android.testing.HiltAndroidTest import kotlinx.coroutines.runBlocking -import org.json.JSONArray import org.junit.After import org.junit.Before import org.junit.Rule @@ -195,14 +196,13 @@ class DanaRSPairWizardUiTest { } private fun seedLocalProfile() { - val profile = profileRepository.newDraft().apply { - mgdl = true - ic = JSONArray(singleValue(10.0)) - isf = JSONArray(singleValue(50.0)) - basal = JSONArray(singleValue(0.5)) - targetLow = JSONArray(singleValue(100.0)) - targetHigh = JSONArray(singleValue(110.0)) - } + val profile = profileRepository.newDraft().copy( + mgdl = true, + ic = singleBlock(10.0), + isf = singleBlock(50.0), + basal = singleBlock(0.5), + target = singleTargetBlock(100.0, 110.0) + ) runBlocking { profileRepository.add(profile) }.getOrThrow() } @@ -227,8 +227,6 @@ class DanaRSPairWizardUiTest { preferences.put(BooleanComposedKey.ConfigBuilderEnabled, "PUMP_VirtualPumpPlugin", value = false) } - private fun singleValue(value: Double) = """[{"time":"00:00","timeAsSeconds":0,"value":$value}]""" - // ---- ui helpers (same contract as DanaRsEmulatorUiTest) ------------------------------------- private fun byText(s: String): BySelector = By.text(Pattern.compile(Pattern.quote(s), Pattern.CASE_INSENSITIVE)) diff --git a/app/src/androidTest/kotlin/app/aaps/e2e/EquilActivationWizardUiTest.kt b/app/src/androidTest/kotlin/app/aaps/e2e/EquilActivationWizardUiTest.kt index a0f9bc27eb6d..9a17aafc5653 100644 --- a/app/src/androidTest/kotlin/app/aaps/e2e/EquilActivationWizardUiTest.kt +++ b/app/src/androidTest/kotlin/app/aaps/e2e/EquilActivationWizardUiTest.kt @@ -27,6 +27,8 @@ import app.aaps.core.interfaces.profile.ProfileRepository import app.aaps.core.interfaces.pump.ble.BleTransport import app.aaps.core.interfaces.queue.CommandQueue import app.aaps.core.interfaces.utils.DateUtil +import app.aaps.core.objects.extensions.singleBlock +import app.aaps.core.objects.extensions.singleTargetBlock import app.aaps.core.keys.BooleanComposedKey import app.aaps.core.keys.BooleanKey import app.aaps.core.keys.BooleanNonKey @@ -41,7 +43,6 @@ import com.google.common.truth.Truth.assertThat import dagger.hilt.android.testing.HiltAndroidRule import dagger.hilt.android.testing.HiltAndroidTest import kotlinx.coroutines.runBlocking -import org.json.JSONArray import org.junit.After import org.junit.Before import org.junit.Rule @@ -222,14 +223,13 @@ class EquilActivationWizardUiTest { } private fun seedLocalProfile() { - val profile = profileRepository.newDraft().apply { - mgdl = true - ic = JSONArray(singleValue(10.0)) - isf = JSONArray(singleValue(50.0)) - basal = JSONArray(singleValue(0.5)) - targetLow = JSONArray(singleValue(100.0)) - targetHigh = JSONArray(singleValue(110.0)) - } + val profile = profileRepository.newDraft().copy( + mgdl = true, + ic = singleBlock(10.0), + isf = singleBlock(50.0), + basal = singleBlock(0.5), + target = singleTargetBlock(100.0, 110.0) + ) runBlocking { profileRepository.add(profile) }.getOrThrow() } @@ -249,8 +249,6 @@ class EquilActivationWizardUiTest { checkNotNull(switch) { "Could not activate the seeded local profile" } } - private fun singleValue(value: Double) = """[{"time":"00:00","timeAsSeconds":0,"value":$value}]""" - // ---- ui helpers (same contract as DanaRSPairWizardUiTest) ----------------------------------- private fun byText(s: String): BySelector = By.text(Pattern.compile(Pattern.quote(s), Pattern.CASE_INSENSITIVE)) diff --git a/app/src/androidTest/kotlin/app/aaps/e2e/EquilEmulatorActivationTest.kt b/app/src/androidTest/kotlin/app/aaps/e2e/EquilEmulatorActivationTest.kt index 6806a2768418..928f11def068 100644 --- a/app/src/androidTest/kotlin/app/aaps/e2e/EquilEmulatorActivationTest.kt +++ b/app/src/androidTest/kotlin/app/aaps/e2e/EquilEmulatorActivationTest.kt @@ -31,6 +31,8 @@ import app.aaps.core.interfaces.resources.ResourceHelper import app.aaps.core.interfaces.rx.bus.RxBus import app.aaps.core.interfaces.utils.DateUtil import app.aaps.core.interfaces.utils.HardLimits +import app.aaps.core.objects.extensions.singleBlock +import app.aaps.core.objects.extensions.singleTargetBlock import app.aaps.core.keys.BooleanComposedKey import app.aaps.core.keys.BooleanKey import app.aaps.core.keys.BooleanNonKey @@ -61,7 +63,6 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking -import org.json.JSONArray import org.junit.After import org.junit.Rule import org.junit.Test @@ -462,14 +463,13 @@ class EquilEmulatorActivationTest { } private fun seedLocalProfile() { - val profile = profileRepository.newDraft().apply { - mgdl = true - ic = JSONArray(singleValue(10.0)) - isf = JSONArray(singleValue(50.0)) - basal = JSONArray(singleValue(0.5)) - targetLow = JSONArray(singleValue(100.0)) - targetHigh = JSONArray(singleValue(110.0)) - } + val profile = profileRepository.newDraft().copy( + mgdl = true, + ic = singleBlock(10.0), + isf = singleBlock(50.0), + basal = singleBlock(0.5), + target = singleTargetBlock(100.0, 110.0) + ) runBlocking { profileRepository.add(profile) }.getOrThrow() } @@ -505,9 +505,6 @@ class EquilEmulatorActivationTest { return null } - private fun singleValue(value: Double) = - """[{"time":"00:00","timeAsSeconds":0,"value":$value}]""" - private fun clearAllSharedPrefs() { val ctx = instrumentation.targetContext File(ctx.applicationInfo.dataDir, "shared_prefs").listFiles()?.forEach { f -> diff --git a/app/src/androidTest/kotlin/app/aaps/helpers/IntegrationWaits.kt b/app/src/androidTest/kotlin/app/aaps/helpers/IntegrationWaits.kt index fc1905e60aeb..52c235a6b0ca 100644 --- a/app/src/androidTest/kotlin/app/aaps/helpers/IntegrationWaits.kt +++ b/app/src/androidTest/kotlin/app/aaps/helpers/IntegrationWaits.kt @@ -1,5 +1,6 @@ package app.aaps.helpers +import kotlin.reflect.KClass import app.aaps.core.interfaces.db.PersistenceLayer import app.aaps.core.interfaces.iob.IobCobCalculator import app.aaps.core.interfaces.logging.AAPSLogger @@ -38,8 +39,8 @@ class IntegrationWaits @Inject constructor( * On timeout this fails with a message naming [what] instead of an opaque coroutine timeout. */ suspend fun awaitDbChange( - type: Class, - what: String = type.simpleName, + type: KClass, + what: String = type.simpleName ?: "?", timeoutMs: Long = 40_000, action: suspend () -> Unit ): List = coroutineScope { diff --git a/app/src/androidTest/kotlin/app/aaps/helpers/RxHelper.kt b/app/src/androidTest/kotlin/app/aaps/helpers/RxHelper.kt index 349c1585a4ef..1093c42a97d8 100644 --- a/app/src/androidTest/kotlin/app/aaps/helpers/RxHelper.kt +++ b/app/src/androidTest/kotlin/app/aaps/helpers/RxHelper.kt @@ -3,14 +3,17 @@ package app.aaps.helpers import app.aaps.core.data.time.T import app.aaps.core.interfaces.logging.AAPSLogger import app.aaps.core.interfaces.logging.LTag -import app.aaps.core.interfaces.rx.AapsSchedulers import app.aaps.core.interfaces.rx.bus.RxBus +import app.aaps.core.interfaces.rx.collectResilient import app.aaps.core.interfaces.rx.events.Event import app.aaps.core.interfaces.utils.DateUtil -import app.aaps.core.interfaces.utils.fabric.FabricPrivacy -import io.reactivex.rxjava3.disposables.CompositeDisposable -import io.reactivex.rxjava3.kotlin.plusAssign +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancelChildren import java.util.concurrent.atomic.AtomicBoolean +import kotlin.reflect.KClass import javax.inject.Inject /** @@ -21,15 +24,15 @@ import javax.inject.Inject */ class RxHelper @Inject constructor( private val rxBus: RxBus, - private val aapsSchedulers: AapsSchedulers, - private val fabricPrivacy: FabricPrivacy, private val dateUtil: DateUtil, private val aapsLogger: AAPSLogger ) { - private val hashMap = HashMap, AtomicBoolean>() - private val eventHashMap = HashMap, Event>() - private val disposable = CompositeDisposable() + private val hashMap = HashMap, AtomicBoolean>() + private val eventHashMap = HashMap, Event>() + + // Lives as long as the helper; clear() cancels its collectors, like clearing the CompositeDisposable. + private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) /** * Register class for listening @@ -37,18 +40,17 @@ class RxHelper @Inject constructor( * @param clazz Class to observe * @return AtomicBoolean trigger */ - fun listen(clazz: Class): AtomicBoolean = + fun listen(clazz: KClass): AtomicBoolean = hashMap[clazz] ?: AtomicBoolean(false).also { ab -> hashMap[clazz] = ab - // Setup RxBus tracking - disposable += rxBus - .toObservable(clazz) - .observeOn(aapsSchedulers.io) - .subscribe({ - aapsLogger.info(LTag.EVENTS, "==>> ${clazz.simpleName} registered") - ab.set(true) - eventHashMap[clazz] = it - }, fabricPrivacy::logException) + // Setup RxBus tracking. UNDISPATCHED because RxBus has no replay: a test that sends an + // event right after listen() returns must not race the collector starting. + rxBus.toFlow(clazz) + .collectResilient(scope, aapsLogger, LTag.EVENTS, start = CoroutineStart.UNDISPATCHED) { + aapsLogger.info(LTag.EVENTS, "==>> ${clazz.simpleName} registered") + ab.set(true) + eventHashMap[clazz] = it + } } /** @@ -57,7 +59,7 @@ class RxHelper @Inject constructor( * @param clazz Class to observe * @param maxSeconds max waiting time in seconds */ - fun waitFor(clazz: Class, maxSeconds: Long = 40, comment: String = ""): Pair { + fun waitFor(clazz: KClass, maxSeconds: Long = 40, comment: String = ""): Pair { val watcher = hashMap[clazz] ?: error("Class not registered ${clazz.simpleName}") val start = dateUtil.now() while (!watcher.get()) { @@ -78,7 +80,7 @@ class RxHelper @Inject constructor( * * @param clazz Class */ - fun resetState(clazz: Class) { + fun resetState(clazz: KClass) { hashMap[clazz]?.set(false) eventHashMap.remove(clazz) } @@ -105,6 +107,8 @@ class RxHelper @Inject constructor( } fun clear() { - disposable.clear() + // Cancels the running collectors but keeps the scope usable, the way CompositeDisposable.clear() + // left its container usable. A plain scope.cancel() would make every later listen() do nothing. + scope.coroutineContext.cancelChildren() } } \ No newline at end of file diff --git a/app/src/androidTest/kotlin/app/aaps/plugins/aps/openAPS/APSResultObject.kt b/app/src/androidTest/kotlin/app/aaps/plugins/aps/openAPS/APSResultObject.kt index 31ab3c1fc5fc..93e21b6ce240 100644 --- a/app/src/androidTest/kotlin/app/aaps/plugins/aps/openAPS/APSResultObject.kt +++ b/app/src/androidTest/kotlin/app/aaps/plugins/aps/openAPS/APSResultObject.kt @@ -1,6 +1,5 @@ package app.aaps.plugins.aps.openAPS -import android.text.Spanned import app.aaps.core.data.model.GV import app.aaps.core.data.model.SourceSensor import app.aaps.core.data.model.TrendArrow @@ -30,10 +29,11 @@ import app.aaps.core.keys.interfaces.Preferences import app.aaps.core.objects.extensions.convertedToAbsolute import app.aaps.core.objects.extensions.convertedToPercent import app.aaps.core.ui.R -import app.aaps.core.utils.HtmlHelper import dagger.android.HasAndroidInjector import kotlinx.coroutines.runBlocking -import org.json.JSONObject +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put import javax.inject.Inject import kotlin.math.abs import kotlin.math.max @@ -123,35 +123,6 @@ open class APSResultObject(protected val injector: HasAndroidInjector) : APSResu } else rh.gs(R.string.nochangerequested) } - override suspend fun resultAsSpanned(): Spanned = HtmlHelper.fromHtml(resultAsHtmlString()) - override suspend fun resultAsHtmlString(): String { - val pump = activePlugin.activePump - if (isChangeRequested()) { - // rate - var ret: String = - if (rate == 0.0 && duration == 0) rh.gs(R.string.cancel_temp) + "
" - else if (rate == -1.0) rh.gs(R.string.let_temp_basal_run) + "
" - else if (usePercent) "" + rh.gs(R.string.rate) + ": " + decimalFormatter.to2Decimal(percent.toDouble()) + "% " + - "(" + decimalFormatter.to2Decimal(percent * ch.fromPump(pump.baseBasalRate) / 100.0) + " U/h)
" + - "" + rh.gs(R.string.duration) + ": " + decimalFormatter.to2Decimal(duration.toDouble()) + " min
" - else "" + rh.gs(R.string.rate) + ": " + decimalFormatter.to2Decimal(rate) + " U/h " + - "(" + decimalFormatter.to2Decimal(rate / ch.fromPump(pump.baseBasalRate) * 100.0) + "%)
" + - "" + rh.gs(R.string.duration) + ": " + decimalFormatter.to2Decimal(duration.toDouble()) + " min
" - - // smb - if (smb != 0.0) ret += "" + "SMB" + ": " + decimalFormatter.toPumpSupportedBolus(smb, activePlugin.activePump.pumpDescription.bolusStep) + "
" - if (isCarbsRequired) { - ret += "$carbsRequiredText
" - } - - // reason - ret += "" + rh.gs(R.string.reason) + ": " + reason.replace("<", "<").replace(">", ">") - return ret - } - return if (isCarbsRequired) carbsRequiredText - else rh.gs(R.string.nochangerequested) - } - override fun newAndClone(): APSResult { val newResult = APSResultObject(injector) doClone(newResult) @@ -178,14 +149,12 @@ open class APSResultObject(protected val injector: HasAndroidInjector) : APSResu newResult.targetBG = targetBG } - override fun json(): JSONObject? { - val json = JSONObject() + override fun json(): JsonObject? = buildJsonObject { if (runBlocking { isChangeRequested() }) { - json.put("rate", rate) - json.put("duration", duration) - json.put("reason", reason) + put("rate", rate) + put("duration", duration) + put("reason", reason) } - return json } override val predictionsAsGv: MutableList diff --git a/app/src/androidTest/kotlin/app/aaps/plugins/aps/openAPSAMA/DetermineBasalAdapterAMAJS.kt b/app/src/androidTest/kotlin/app/aaps/plugins/aps/openAPSAMA/DetermineBasalAdapterAMAJS.kt index 9747831200c8..942f9b609163 100644 --- a/app/src/androidTest/kotlin/app/aaps/plugins/aps/openAPSAMA/DetermineBasalAdapterAMAJS.kt +++ b/app/src/androidTest/kotlin/app/aaps/plugins/aps/openAPSAMA/DetermineBasalAdapterAMAJS.kt @@ -19,7 +19,7 @@ import app.aaps.core.keys.DoubleKey import app.aaps.core.keys.interfaces.Preferences import app.aaps.core.objects.extensions.convertToJSONArray import app.aaps.core.objects.extensions.convertedToAbsolute -import app.aaps.core.objects.extensions.getPassedDurationToTimeInMinutes +import app.aaps.core.data.model.getPassedDurationToTimeInMinutes import app.aaps.core.objects.extensions.plannedRemainingMinutes import app.aaps.plugins.aps.logger.LoggerCallback import app.aaps.plugins.aps.utils.ScriptReader diff --git a/app/src/androidTest/kotlin/app/aaps/plugins/aps/openAPSAMA/DetermineBasalResultAMAFromJS.kt b/app/src/androidTest/kotlin/app/aaps/plugins/aps/openAPSAMA/DetermineBasalResultAMAFromJS.kt index 06417a00ad81..e2673d07adc5 100644 --- a/app/src/androidTest/kotlin/app/aaps/plugins/aps/openAPSAMA/DetermineBasalResultAMAFromJS.kt +++ b/app/src/androidTest/kotlin/app/aaps/plugins/aps/openAPSAMA/DetermineBasalResultAMAFromJS.kt @@ -4,6 +4,9 @@ import app.aaps.core.interfaces.aps.Predictions import app.aaps.core.interfaces.utils.DateUtil import app.aaps.plugins.aps.openAPS.APSResultObject import dagger.android.HasAndroidInjector +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.jsonObject import org.json.JSONObject import org.mozilla.javascript.NativeObject import javax.inject.Inject @@ -56,7 +59,9 @@ class DetermineBasalResultAMAFromJS @Inject constructor(injector: HasAndroidInje return newResult } - override fun json(): JSONObject? = json + // The JS bridge hands over an org.json document and predictions() reads predBGs out of it, so the + // field stays as it is and only the contract is converted. androidTest, so a reparse costs nothing. + override fun json(): JsonObject? = json?.let { Json.parseToJsonElement(it.toString()).jsonObject } override fun predictions(): Predictions { val predictions = Predictions() diff --git a/app/src/androidTest/kotlin/app/aaps/plugins/aps/openAPSAMA/TestOpenAPSAMAPlugin.kt b/app/src/androidTest/kotlin/app/aaps/plugins/aps/openAPSAMA/TestOpenAPSAMAPlugin.kt index d965cfa7de7f..0956b8e2a556 100644 --- a/app/src/androidTest/kotlin/app/aaps/plugins/aps/openAPSAMA/TestOpenAPSAMAPlugin.kt +++ b/app/src/androidTest/kotlin/app/aaps/plugins/aps/openAPSAMA/TestOpenAPSAMAPlugin.kt @@ -1,5 +1,6 @@ package app.aaps.plugins.aps.openAPSAMA +import app.aaps.core.keys.interfaces.TextRef import app.aaps.core.data.plugin.PluginType import app.aaps.core.interfaces.aps.APS import app.aaps.core.interfaces.aps.APSResult @@ -36,6 +37,7 @@ import app.aaps.core.keys.DoubleKey import app.aaps.core.keys.interfaces.Preferences import app.aaps.core.objects.constraints.ConstraintObject import app.aaps.core.objects.extensions.target +import app.aaps.core.objects.extensions.with import app.aaps.core.utils.MidnightUtils import app.aaps.plugins.aps.R import app.aaps.plugins.aps.events.EventOpenAPSUpdateGui @@ -43,6 +45,7 @@ import app.aaps.plugins.aps.events.EventResetOpenAPSGui import app.aaps.plugins.aps.openAPSSMB.GlucoseStatusCalculatorSMB import app.aaps.plugins.aps.utils.ScriptReader import dagger.android.HasAndroidInjector +import kotlinx.serialization.json.put import org.json.JSONException import javax.inject.Inject import javax.inject.Singleton @@ -54,7 +57,7 @@ class TestOpenAPSAMAPlugin @Inject constructor( aapsLogger: AAPSLogger, private val rxBus: RxBus, private val constraintChecker: ConstraintsChecker, - rh: ResourceHelper, + override val rh: ResourceHelper, private val profileFunction: ProfileFunction, private val activePlugin: ActivePlugin, private val iobCobCalculator: IobCobCalculator, @@ -73,10 +76,10 @@ class TestOpenAPSAMAPlugin @Inject constructor( ) : PluginBase( PluginDescription() .mainType(PluginType.APS) - .pluginName(R.string.openapsama) - .shortName(R.string.oaps_shortname) + .pluginName(TextRef.AndroidRes(R.string.openapsama)) + .shortName(TextRef.AndroidRes(R.string.oaps_shortname)) .preferencesVisibleInSimpleMode(false) - .description(R.string.description_ama), + .description(TextRef.AndroidRes(R.string.description_ama)), aapsLogger, rh ), APS, PluginConstraints { @@ -142,13 +145,13 @@ class TestOpenAPSAMAPlugin @Inject constructor( var minBg = hardLimits.verifyHardLimits( Round.roundTo(profile.getTargetLowMgdl(), 0.1), - app.aaps.core.ui.R.string.profile_low_target, + app.aaps.core.interfaces.R.string.profile_low_target, HardLimits.LIMIT_MIN_BG ) var maxBg = hardLimits.verifyHardLimits( Round.roundTo(profile.getTargetHighMgdl(), 0.1), - app.aaps.core.ui.R.string.profile_high_target, + app.aaps.core.interfaces.R.string.profile_high_target, HardLimits.LIMIT_MAX_BG ) var targetBg = @@ -176,14 +179,14 @@ class TestOpenAPSAMAPlugin @Inject constructor( HardLimits.LIMIT_TEMP_TARGET_BG ) } - if (!hardLimits.checkHardLimits(profile.iCfg.dia, app.aaps.core.ui.R.string.profile_dia, hardLimits.diaRange())) return + if (!hardLimits.checkHardLimits(profile.iCfg.dia, app.aaps.core.interfaces.R.string.profile_dia, hardLimits.diaRange())) return if (!hardLimits.checkHardLimits( profile.getIcTimeFromMidnight(MidnightUtils.secondsFromMidnight()), - app.aaps.core.ui.R.string.profile_carbs_ratio_value, + app.aaps.core.interfaces.R.string.profile_carbs_ratio_value, hardLimits.icRange() ) ) return - if (!hardLimits.checkHardLimits(profile.getIsfMgdl("test"), app.aaps.core.ui.R.string.profile_sensitivity_value, HardLimits.LIMIT_ISF)) return + if (!hardLimits.checkHardLimits(profile.getIsfMgdl("test"), app.aaps.core.interfaces.R.string.profile_sensitivity_value, HardLimits.LIMIT_ISF)) return if (!hardLimits.checkHardLimits(profile.getMaxDailyBasal(), app.aaps.core.ui.R.string.profile_max_daily_basal_value, 0.02, hardLimits.maxBasal())) return if (!hardLimits.checkHardLimits(ch.fromPump(pump.baseBasalRate), app.aaps.core.ui.R.string.current_basal_value, 0.01, hardLimits.maxBasal())) return startPart = System.currentTimeMillis() @@ -229,13 +232,18 @@ class TestOpenAPSAMAPlugin @Inject constructor( false //determineBasalResultAMA.iob = iobArray[0] val now = System.currentTimeMillis() - determineBasalResultAMA.json()?.put("timestamp", dateUtil.toISOString(now)) determineBasalResultAMA.inputConstraints = inputConstraints //lastDetermineBasalAdapter = determineBasalAdapterAMAJS lastAPSResult = determineBasalResultAMA as DetermineBasalResultAMAFromJS lastAPSRun = now if (config.isEnabled(ExternalOptions.UNFINISHED_MODE)) - importExportPrefs.exportApsResult(this::class.simpleName, determineBasalAdapterAMAJS.json(), determineBasalResultAMA.json()) + importExportPrefs.exportApsResult( + this::class.simpleName, + determineBasalAdapterAMAJS.json().toString(), + // The timestamp used to be put into the stored document a few lines up. That document + // is immutable now, so it goes on here, where it is actually used. + determineBasalResultAMA.json()?.with { put("timestamp", dateUtil.toISOString(now)) }?.toString() + ) rxBus.send(EventAPSCalculationFinished()) } rxBus.send(EventOpenAPSUpdateGui()) diff --git a/app/src/androidTest/kotlin/app/aaps/plugins/aps/openAPSSMB/DetermineBasalAdapterSMBJS.kt b/app/src/androidTest/kotlin/app/aaps/plugins/aps/openAPSSMB/DetermineBasalAdapterSMBJS.kt index ba54ed77d934..a5eca01a05b0 100644 --- a/app/src/androidTest/kotlin/app/aaps/plugins/aps/openAPSSMB/DetermineBasalAdapterSMBJS.kt +++ b/app/src/androidTest/kotlin/app/aaps/plugins/aps/openAPSSMB/DetermineBasalAdapterSMBJS.kt @@ -22,7 +22,7 @@ import app.aaps.core.keys.IntKey import app.aaps.core.keys.interfaces.Preferences import app.aaps.core.objects.extensions.convertToJSONArray import app.aaps.core.objects.extensions.convertedToAbsolute -import app.aaps.core.objects.extensions.getPassedDurationToTimeInMinutes +import app.aaps.core.data.model.getPassedDurationToTimeInMinutes import app.aaps.core.objects.extensions.plannedRemainingMinutes import app.aaps.plugins.aps.logger.LoggerCallback import app.aaps.plugins.aps.utils.ScriptReader diff --git a/app/src/androidTest/kotlin/app/aaps/plugins/aps/openAPSSMB/DetermineBasalResultSMBFromJS.kt b/app/src/androidTest/kotlin/app/aaps/plugins/aps/openAPSSMB/DetermineBasalResultSMBFromJS.kt index 7aa080477b69..0dc95b9a9787 100644 --- a/app/src/androidTest/kotlin/app/aaps/plugins/aps/openAPSSMB/DetermineBasalResultSMBFromJS.kt +++ b/app/src/androidTest/kotlin/app/aaps/plugins/aps/openAPSSMB/DetermineBasalResultSMBFromJS.kt @@ -6,6 +6,9 @@ import app.aaps.core.interfaces.utils.DateUtil import app.aaps.plugins.aps.openAPS.APSResultObject import dagger.android.HasAndroidInjector import org.json.JSONException +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.jsonObject import org.json.JSONObject import javax.inject.Inject @@ -73,7 +76,9 @@ class DetermineBasalResultSMBFromJS private constructor(injector: HasAndroidInje return newResult } - override fun json(): JSONObject? = json + // The JS bridge hands over an org.json document and predictions() reads predBGs out of it, so the + // field stays as it is and only the contract is converted. androidTest, so a reparse costs nothing. + override fun json(): JsonObject? = json?.let { Json.parseToJsonElement(it.toString()).jsonObject } override fun predictions(): Predictions { val predictions = Predictions() diff --git a/app/src/androidTest/kotlin/app/aaps/plugins/aps/openAPSSMB/TestOpenAPSSMBPlugin.kt b/app/src/androidTest/kotlin/app/aaps/plugins/aps/openAPSSMB/TestOpenAPSSMBPlugin.kt index 306ed85b79e0..29b12052f3a7 100644 --- a/app/src/androidTest/kotlin/app/aaps/plugins/aps/openAPSSMB/TestOpenAPSSMBPlugin.kt +++ b/app/src/androidTest/kotlin/app/aaps/plugins/aps/openAPSSMB/TestOpenAPSSMBPlugin.kt @@ -1,6 +1,7 @@ package app.aaps.plugins.aps.openAPSSMB import android.content.Context +import app.aaps.core.keys.interfaces.TextRef import app.aaps.core.data.aps.SMBDefaults import app.aaps.core.data.plugin.PluginType import app.aaps.core.interfaces.aps.APS @@ -53,7 +54,7 @@ open class TestOpenAPSSMBPlugin @Inject constructor( aapsLogger: AAPSLogger, private val rxBus: RxBus, private val constraintChecker: ConstraintsChecker, - rh: ResourceHelper, + override val rh: ResourceHelper, private val profileFunction: ProfileFunction, val context: Context, private val activePlugin: ActivePlugin, @@ -72,10 +73,10 @@ open class TestOpenAPSSMBPlugin @Inject constructor( ) : PluginBase( PluginDescription() .mainType(PluginType.APS) - .pluginName(R.string.openapssmb) - .shortName(app.aaps.core.ui.R.string.smb_shortname) + .pluginName(TextRef.AndroidRes(R.string.openapssmb)) + .shortName(TextRef.AndroidRes(app.aaps.core.ui.R.string.smb_shortname)) .preferencesVisibleInSimpleMode(false) - .description(R.string.description_smb) + .description(TextRef.AndroidRes(R.string.description_smb)) .setDefault(), aapsLogger, rh ), APS, PluginConstraints { @@ -147,13 +148,13 @@ open class TestOpenAPSSMBPlugin @Inject constructor( var minBg = hardLimits.verifyHardLimits( Round.roundTo(profile.getTargetLowMgdl(), 0.1), - app.aaps.core.ui.R.string.profile_low_target, + app.aaps.core.interfaces.R.string.profile_low_target, HardLimits.LIMIT_MIN_BG ) var maxBg = hardLimits.verifyHardLimits( Round.roundTo(profile.getTargetHighMgdl(), 0.1), - app.aaps.core.ui.R.string.profile_high_target, + app.aaps.core.interfaces.R.string.profile_high_target, HardLimits.LIMIT_MAX_BG ) var targetBg = @@ -181,14 +182,14 @@ open class TestOpenAPSSMBPlugin @Inject constructor( HardLimits.LIMIT_TEMP_TARGET_BG ) } - if (!hardLimits.checkHardLimits(profile.iCfg.dia, app.aaps.core.ui.R.string.profile_dia, hardLimits.diaRange())) return + if (!hardLimits.checkHardLimits(profile.iCfg.dia, app.aaps.core.interfaces.R.string.profile_dia, hardLimits.diaRange())) return if (!hardLimits.checkHardLimits( profile.getIcTimeFromMidnight(MidnightUtils.secondsFromMidnight()), - app.aaps.core.ui.R.string.profile_carbs_ratio_value, + app.aaps.core.interfaces.R.string.profile_carbs_ratio_value, hardLimits.icRange() ) ) return - if (!hardLimits.checkHardLimits(profile.getIsfMgdl("test"), app.aaps.core.ui.R.string.profile_sensitivity_value, HardLimits.LIMIT_ISF)) return + if (!hardLimits.checkHardLimits(profile.getIsfMgdl("test"), app.aaps.core.interfaces.R.string.profile_sensitivity_value, HardLimits.LIMIT_ISF)) return if (!hardLimits.checkHardLimits(profile.getMaxDailyBasal(), app.aaps.core.ui.R.string.profile_max_daily_basal_value, 0.02, hardLimits.maxBasal())) return if (!hardLimits.checkHardLimits(ch.fromPump(pump.baseBasalRate), app.aaps.core.ui.R.string.current_basal_value, 0.01, hardLimits.maxBasal())) return startPart = System.currentTimeMillis() @@ -282,7 +283,9 @@ open class TestOpenAPSSMBPlugin @Inject constructor( .isTempBasalRequested = false //determineBasalResultSMB.iob = iobArray[0] - determineBasalResultSMB.json()?.put("timestamp", dateUtil.toISOString(now)) + // The timestamp put() that stood here wrote into the stored document for the export + // below, which is commented out. The document is immutable now, so it is added at the + // export instead - see the commented call and TestOpenAPSAMAPlugin for the live one. determineBasalResultSMB.inputConstraints = inputConstraints //lastDetermineBasalAdapter = determineBasalAdapterSMBJS lastAPSResult = determineBasalResultSMB as DetermineBasalResultSMBFromJS @@ -293,7 +296,8 @@ open class TestOpenAPSSMBPlugin @Inject constructor( // is DetermineBasalAdapterSMBJS -> OpenAPSSMBPlugin::class.simpleName // is DetermineBasalAdapterSMBDynamicISFJS -> OpenAPSSMBDynamicISFPlugin::class.simpleName // else -> "Error" - // }, determineBasalAdapterSMBJS.json(), determineBasalResultSMB.json() + // }, determineBasalAdapterSMBJS.json().toString(), + // determineBasalResultSMB.json()?.with { put("timestamp", dateUtil.toISOString(now)) }?.toString() // ) rxBus.send(EventAPSCalculationFinished()) } diff --git a/app/src/androidTest/kotlin/app/aaps/plugins/aps/openAPSSMBAutoISF/DetermineBasalAdapterAutoISFJS.kt b/app/src/androidTest/kotlin/app/aaps/plugins/aps/openAPSSMBAutoISF/DetermineBasalAdapterAutoISFJS.kt index b4ae4d7795d9..ece5f7bb0cc2 100644 --- a/app/src/androidTest/kotlin/app/aaps/plugins/aps/openAPSSMBAutoISF/DetermineBasalAdapterAutoISFJS.kt +++ b/app/src/androidTest/kotlin/app/aaps/plugins/aps/openAPSSMBAutoISF/DetermineBasalAdapterAutoISFJS.kt @@ -24,7 +24,7 @@ import app.aaps.core.keys.IntKey import app.aaps.core.keys.interfaces.Preferences import app.aaps.core.objects.extensions.convertToJSONArray import app.aaps.core.objects.extensions.convertedToAbsolute -import app.aaps.core.objects.extensions.getPassedDurationToTimeInMinutes +import app.aaps.core.data.model.getPassedDurationToTimeInMinutes import app.aaps.core.objects.extensions.plannedRemainingMinutes import app.aaps.core.objects.profile.ProfileSealed import app.aaps.plugins.aps.logger.LoggerCallback diff --git a/app/src/androidTest/kotlin/app/aaps/plugins/aps/openAPSSMBDynamicISF/DetermineBasalAdapterSMBDynamicISFJS.kt b/app/src/androidTest/kotlin/app/aaps/plugins/aps/openAPSSMBDynamicISF/DetermineBasalAdapterSMBDynamicISFJS.kt index 41d7bae68f06..dd5a6e276f82 100644 --- a/app/src/androidTest/kotlin/app/aaps/plugins/aps/openAPSSMBDynamicISF/DetermineBasalAdapterSMBDynamicISFJS.kt +++ b/app/src/androidTest/kotlin/app/aaps/plugins/aps/openAPSSMBDynamicISF/DetermineBasalAdapterSMBDynamicISFJS.kt @@ -23,7 +23,7 @@ import app.aaps.core.keys.UnitDoubleKey import app.aaps.core.keys.interfaces.Preferences import app.aaps.core.objects.extensions.convertToJSONArray import app.aaps.core.objects.extensions.convertedToAbsolute -import app.aaps.core.objects.extensions.getPassedDurationToTimeInMinutes +import app.aaps.core.data.model.getPassedDurationToTimeInMinutes import app.aaps.core.objects.extensions.plannedRemainingMinutes import app.aaps.plugins.aps.logger.LoggerCallback import app.aaps.plugins.aps.openAPSSMB.DetermineBasalResultSMBFromJS diff --git a/app/src/androidTest/kotlin/app/aaps/plugins/aps/openAPSSMBDynamicISF/TestOpenAPSSMBDynamicISFPlugin.kt b/app/src/androidTest/kotlin/app/aaps/plugins/aps/openAPSSMBDynamicISF/TestOpenAPSSMBDynamicISFPlugin.kt index e2c19c4983d8..fd699448f7d7 100644 --- a/app/src/androidTest/kotlin/app/aaps/plugins/aps/openAPSSMBDynamicISF/TestOpenAPSSMBDynamicISFPlugin.kt +++ b/app/src/androidTest/kotlin/app/aaps/plugins/aps/openAPSSMBDynamicISF/TestOpenAPSSMBDynamicISFPlugin.kt @@ -1,6 +1,7 @@ package app.aaps.plugins.aps.openAPSSMBDynamicISF import android.content.Context +import app.aaps.core.keys.interfaces.TextRef import app.aaps.core.data.time.T import app.aaps.core.interfaces.aps.DetermineBasalAdapter import app.aaps.core.interfaces.bgQualityCheck.BgQualityCheck @@ -81,9 +82,9 @@ class TestOpenAPSSMBDynamicISFPlugin @Inject constructor( init { pluginDescription - .pluginName(R.string.openaps_smb_dynamic_isf) - .description(R.string.description_smb_dynamic_isf) - .shortName(R.string.dynisf_shortname) + .pluginName(TextRef.AndroidRes(R.string.openaps_smb_dynamic_isf)) + .description(TextRef.AndroidRes(R.string.description_smb_dynamic_isf)) + .shortName(TextRef.AndroidRes(R.string.dynisf_shortname)) .preferencesVisibleInSimpleMode(true) .setDefault(false) } @@ -96,10 +97,9 @@ class TestOpenAPSSMBDynamicISFPlugin @Inject constructor( if (tdd1D == null || tdd7D == null || tddLast4H == null || tddLast8to4H == null || tddLast24H == null || !dynIsfEnabled.value()) { notificationManager.post( NotificationId.SMB_FALLBACK, - R.string.fallback_smb_no_tdd, + TextRef.AndroidRes(R.string.fallback_smb_no_tdd), level = NotificationLevel.INFO, - validTo = dateUtil.now() + T.mins(1).msecs() - ) + validTo = dateUtil.now() + T.mins(1).msecs()) DetermineBasalAdapterSMBJS(ScriptReader(), injector) } else { notificationManager.dismiss(NotificationId.SMB_FALLBACK) diff --git a/app/src/main/kotlin/app/aaps/ComposeMainActivity.kt b/app/src/main/kotlin/app/aaps/ComposeMainActivity.kt index f63a200c7f73..8cf3009a5342 100644 --- a/app/src/main/kotlin/app/aaps/ComposeMainActivity.kt +++ b/app/src/main/kotlin/app/aaps/ComposeMainActivity.kt @@ -81,11 +81,13 @@ import app.aaps.core.interfaces.logging.AAPSLogger import app.aaps.core.interfaces.logging.LTag import app.aaps.core.interfaces.maintenance.FileListProvider import app.aaps.core.interfaces.navigation.ElementType +import app.aaps.core.interfaces.notifications.AlarmSound import app.aaps.core.interfaces.notifications.NotificationId import app.aaps.core.interfaces.notifications.NotificationLevel import app.aaps.core.interfaces.notifications.NotificationManager import app.aaps.core.interfaces.overview.graph.OverviewDataCache import app.aaps.core.interfaces.plugin.ActivePlugin +import app.aaps.core.interfaces.plugin.PluginPermissions import app.aaps.core.interfaces.plugin.PluginBase import app.aaps.core.interfaces.profile.ProfileUtil import app.aaps.core.interfaces.protection.ExportPasswordDataStore @@ -121,6 +123,7 @@ import app.aaps.core.ui.compose.ScreenMode import app.aaps.core.ui.compose.dialogs.GlobalDialogHost import app.aaps.core.ui.compose.dialogs.GlobalSnackbarHost import app.aaps.core.ui.compose.dialogs.OkDialog +import app.aaps.core.ui.compose.dialogs.PasswordCheckHost import app.aaps.core.ui.compose.navigation.NavigationRequest import app.aaps.core.ui.compose.preference.LocalCheckPassword import app.aaps.core.ui.compose.preference.LocalClearExportPasswordStore @@ -192,6 +195,7 @@ class ComposeMainActivity : AppCompatActivity() { @Inject lateinit var cryptoUtil: CryptoUtil @Inject lateinit var exportPasswordDataStore: ExportPasswordDataStore @Inject lateinit var activePlugin: ActivePlugin + @Inject lateinit var pluginPermissions: PluginPermissions @Inject lateinit var nsClient: NsClient @Inject lateinit var clientControlActionDispatcher: ClientControlActionDispatcher @Inject lateinit var automationRuntime: AutomationRuntime @@ -248,7 +252,7 @@ class ComposeMainActivity : AppCompatActivity() { private val siteRotationManagementViewModel: SiteRotationManagementViewModel by viewModels() private val pumpCommunicationStatus by lazy { - PumpCommunicationStatus(rxBus, commandQueue, this, lifecycleScope) + PumpCommunicationStatus(rxBus, commandQueue, rh, lifecycleScope) } private var navController: NavHostController? = null private val _autoShowNotifications = mutableStateOf(false) @@ -334,7 +338,7 @@ class ComposeMainActivity : AppCompatActivity() { LocalProfileUtil provides profileUtil, LocalCheckPassword provides cryptoUtil::checkPassword, LocalHashPassword provides cryptoUtil::hashPassword, - LocalClearExportPasswordStore provides { exportPasswordDataStore.clearPasswordDataStore(this@ComposeMainActivity) }, + LocalClearExportPasswordStore provides { exportPasswordDataStore.clearPasswordDataStore() }, LocalVisibilityContext provides visibilityContext ) { AapsTheme { @@ -374,6 +378,10 @@ class ComposeMainActivity : AppCompatActivity() { // renders one modal dialog at a time. GlobalDialogHost(rxBus = rxBus) + // Root-level password prompt. Any caller can ask for a password from plain + // Kotlin; the dialog appears here, so PasswordCheck needs no Context. + PasswordCheckHost(passwordCheck = passwordCheck) + // The single app-level pending modal for ANY client-control round-trip // (insulin / scenes / synced-preference edits). Hosted once here, feature- // independent; round-trips are single-in-flight so at most one shows. Applied is @@ -497,11 +505,11 @@ class ComposeMainActivity : AppCompatActivity() { protectionCheck = protectionCheck, preferences = preferences, checkPassword = cryptoUtil::checkPassword, - showBiometric = { activity, titleRes, onGranted, onCancelled, onDenied -> - BiometricCheck.biometricPrompt(activity, titleRes, rxBus, onGranted, onCancelled, onDenied, passwordCheck) + showBiometric = { activity, title, onGranted, onCancelled, onDenied -> + BiometricCheck.biometricPrompt(activity, title, rxBus, onGranted, onCancelled, onDenied, passwordCheck) }, - showBiometricSimple = { activity, titleRes, onSuccess, onFallback, onCancel -> - BiometricCheck.biometricPromptSimple(activity, titleRes, rxBus, onSuccess, onFallback, onCancel) + showBiometricSimple = { activity, title, onSuccess, onFallback, onCancel -> + BiometricCheck.biometricPromptSimple(activity, title, rxBus, onSuccess, onFallback, onCancel) } ) @@ -643,7 +651,7 @@ class ComposeMainActivity : AppCompatActivity() { isSimpleMode = state.isSimpleMode, onNavigate = { request -> handleNavigationRequest(request, navController) }, onActionsError = { comment, title -> - uiInteraction.runAlarm(comment, title, app.aaps.core.ui.R.raw.boluserror) + uiInteraction.runAlarm(comment, title, AlarmSound.BOLUS_ERROR) }, ) @@ -789,6 +797,7 @@ class ComposeMainActivity : AppCompatActivity() { swDefinition = swDefinition, rxBus = rxBus, activePlugin = activePlugin, + pluginPermissions = pluginPermissions, automationRuntime = automationRuntime, preferences = preferences, rh = rh, @@ -799,7 +808,7 @@ class ComposeMainActivity : AppCompatActivity() { visibilityContext = visibilityContext, onNavigationRequest = { request, nc -> handleNavigationRequest(request, nc) }, onShowDeliveryError = { comment, titleResId -> - uiInteraction.runAlarm(comment, rh.gs(titleResId), app.aaps.core.ui.R.raw.boluserror) + uiInteraction.runAlarm(comment, rh.gs(titleResId), AlarmSound.BOLUS_ERROR) }, withProtection = { protection, action -> withProtection(protection, action) }, requestEditModeAuthorization = { onGranted -> diff --git a/app/src/main/kotlin/app/aaps/MainApp.kt b/app/src/main/kotlin/app/aaps/MainApp.kt index 723f0f8b30c5..493d871cdfb4 100644 --- a/app/src/main/kotlin/app/aaps/MainApp.kt +++ b/app/src/main/kotlin/app/aaps/MainApp.kt @@ -49,6 +49,7 @@ import app.aaps.core.interfaces.profile.ProfileRepository import app.aaps.core.interfaces.profile.ProfileUtil import app.aaps.core.interfaces.protection.ExportPasswordDataStore import app.aaps.core.interfaces.resources.ResourceHelper +import app.aaps.core.interfaces.resources.TextRefIdRegistry import app.aaps.core.interfaces.rx.bus.RxBus import app.aaps.core.interfaces.rx.events.EventAppInitialized import app.aaps.core.interfaces.rx.events.EventShowSnackbar @@ -73,10 +74,10 @@ import app.aaps.core.keys.StringKey import app.aaps.core.keys.StringNonKey import app.aaps.core.keys.UnitDoubleKey import app.aaps.core.keys.interfaces.Preferences +import app.aaps.core.keys.interfaces.TextRef import app.aaps.core.objects.crypto.CryptoUtil import app.aaps.core.objects.profile.ProfileSealed import app.aaps.core.ui.locale.LocaleHelper -import app.aaps.core.utils.JsonHelper import app.aaps.database.AppRepository import app.aaps.implementation.lifecycle.ProcessLifecycleListener import app.aaps.implementation.plugin.PluginStore @@ -89,8 +90,12 @@ import app.aaps.implementation.receivers.TimeDateOrTZChangeReceiver import app.aaps.plugins.aps.loop.runningMode.RunningModeExpiryScheduler import app.aaps.plugins.aps.loop.runningMode.RunningModeReconciler import app.aaps.plugins.automation.AutomationRuntime +import app.aaps.plugins.calibration.CalibrationStringIds import app.aaps.plugins.constraints.objectives.keys.ObjectivesLongComposedKey import app.aaps.plugins.constraints.signatureVerifier.SignatureVerifierPlugin +import app.aaps.plugins.sensitivity.SensitivityStringIds +import app.aaps.plugins.smoothing.SmoothingStringIds +import app.aaps.pump.virtual.VirtualStringIds import app.aaps.ui.activityMonitor.ActivityMonitor import app.aaps.utils.configureLeakCanary import com.google.firebase.Firebase @@ -110,7 +115,9 @@ import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.delay import kotlinx.coroutines.launch import kotlinx.coroutines.withTimeoutOrNull -import org.json.JSONObject +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.jsonObject import rxdogtag2.RxDogTag import java.io.IOException import java.util.Locale @@ -185,6 +192,8 @@ class MainApp : Application(), HasAndroidInjector, Configuration.Provider { override fun onCreate() { super.onCreate() + registerStringOwners() + // Here should be everything injected aapsLogger.debug("onCreate") ProcessLifecycleOwner.get().lifecycle.addObserver(processLifecycleListener.get()) @@ -195,7 +204,7 @@ class MainApp : Application(), HasAndroidInjector, Configuration.Provider { // Visible activities host their own GlobalSnackbarHost that also // subscribes; those win while UI is present. appScope.launch { - rxBus.toFlow(EventShowSnackbar::class.java).collect { event -> + rxBus.toFlow(EventShowSnackbar::class).collect { event -> val uiVisible = ProcessLifecycleOwner.get().lifecycle.currentState .isAtLeast(Lifecycle.State.STARTED) if (!uiVisible) { @@ -447,7 +456,7 @@ class MainApp : Application(), HasAndroidInjector, Configuration.Provider { if (serialNumber != null) { preferences.put(StringKey.ProtectionMasterPassword, cryptoUtil.hashPassword(serialNumber)) fh.delete() - exportPasswordDataStore.clearPasswordDataStore(this@MainApp) + exportPasswordDataStore.clearPasswordDataStore() config.showInitSnackbar(getString(app.aaps.core.ui.R.string.password_set)) } else { aapsLogger.warn(LTag.CORE, "Password reset timed out waiting for pump serial number") @@ -458,7 +467,7 @@ class MainApp : Application(), HasAndroidInjector, Configuration.Provider { private fun exportPasswordResetCheck() { val fh = fileListProvider.ensureExtraDirExists()?.findFile("ExportPasswordReset") if (fh?.exists() == true) { - exportPasswordDataStore.clearPasswordDataStore(this@MainApp) + exportPasswordDataStore.clearPasswordDataStore() fh.delete() config.showInitSnackbar(getString(app.aaps.core.ui.R.string.datastore_password_cleared)) } @@ -469,27 +478,27 @@ class MainApp : Application(), HasAndroidInjector, Configuration.Provider { if (config.isDev() && preferences.get(StringKey.MaintenanceIdentification).isBlank()) notificationManager.post( id = NotificationId.IDENTIFICATION_NOT_SET, - R.string.identification_not_set, + TextRef.AndroidRes(R.string.identification_not_set), level = NotificationLevel.INFO, - actions = listOf(NotificationAction(R.string.set) {}), + actions = listOf(NotificationAction(TextRef.AndroidRes(R.string.set)) {}), validityCheck = { config.isDev() && preferences.get(StringKey.MaintenanceIdentification).isBlank() } ) // Master password not set if (preferences.get(StringKey.ProtectionMasterPassword) == "") notificationManager.post( id = NotificationId.MASTER_PASSWORD_NOT_SET, - app.aaps.core.ui.R.string.master_password_not_set, + TextRef.AndroidRes(app.aaps.core.ui.R.string.master_password_not_set), level = NotificationLevel.NORMAL, - actions = listOf(NotificationAction(R.string.set) {}), + actions = listOf(NotificationAction(TextRef.AndroidRes(R.string.set)) {}), validityCheck = { preferences.get(StringKey.ProtectionMasterPassword) == "" } ) // AAPS directory not selected if (preferences.getIfExists(StringKey.AapsDirectoryUri).isNullOrEmpty()) notificationManager.post( id = NotificationId.AAPS_DIR_NOT_SELECTED, - app.aaps.core.ui.R.string.aaps_directory_not_selected, + TextRef.AndroidRes(app.aaps.core.ui.R.string.aaps_directory_not_selected), level = NotificationLevel.LOW, - actions = listOf(NotificationAction(R.string.select) {}), + actions = listOf(NotificationAction(TextRef.AndroidRes(R.string.select)) {}), validityCheck = { preferences.getIfExists(StringKey.AapsDirectoryUri).isNullOrEmpty() } ) } @@ -973,8 +982,16 @@ class MainApp : Application(), HasAndroidInjector, Configuration.Provider { @Suppress("UNCHECKED_CAST") (versionCheckersUtils::class.declaredMemberProperties.find { it.name == "definition" } as KMutableProperty?) ?.let { - val merged = JsonHelper.merge(it.getter.call(versionCheckersUtils) as JSONObject, JSONObject(firebaseRemoteConfig.getString("defs"))) - it.setter.call(versionCheckersUtils, merged) + // `definition` is read through reflection, so the cast below is unchecked and the + // compiler cannot see it. It said JSONObject long after the property became a + // kotlinx JsonObject, and the app crashed on start as soon as a remote config + // fetch actually succeeded - which needs network and Play Services, so no test or + // CI build ever reached it. Keep the two types here in step by hand. + val current = it.getter.call(versionCheckersUtils) as JsonObject + val remote = Json.parseToJsonElement(firebaseRemoteConfig.getString("defs")).jsonObject + // Plus on the maps is a shallow merge with the remote keys winning, which is what + // the JsonHelper.merge that used to be here did. + it.setter.call(versionCheckersUtils, JsonObject(current + remote)) } } else aapsLogger.error("RemoteConfig fetch failed") } @@ -990,4 +1007,23 @@ class MainApp : Application(), HasAndroidInjector, Configuration.Provider { uiInteraction.stopAlarm("onTerminate") super.onTerminate() } + + /** + * Teaches the resolvers which module owns which string names. + * + * A `TextRef.Named` carries an owner and a name, and both the Compose and the ResourceHelper + * paths need a way to turn that into an `R.string` id. `:core:keys`, `:core:interfaces` and + * `:core:ui` are resolved directly because `:core:ui` sits above them, but a plugin or pump + * module sits ABOVE `:core:ui`, so it can only be reached from here - `:app` is the one place + * that depends on all of them. + * + * Without this the lookup answers null and the raw name is drawn: `virtual_pump_shortname` + * instead of "Virtual Pump". + */ + private fun registerStringOwners() { + TextRefIdRegistry.register("virtual") { name -> VirtualStringIds.idOf(name) } + TextRefIdRegistry.register("smoothing") { name -> SmoothingStringIds.idOf(name) } + TextRefIdRegistry.register("calibration") { name -> CalibrationStringIds.idOf(name) } + TextRefIdRegistry.register("sensitivity") { name -> SensitivityStringIds.idOf(name) } + } } diff --git a/app/src/main/kotlin/app/aaps/compose/navigation/AppNavGraph.kt b/app/src/main/kotlin/app/aaps/compose/navigation/AppNavGraph.kt index 6a06de50a95e..22c60d643384 100644 --- a/app/src/main/kotlin/app/aaps/compose/navigation/AppNavGraph.kt +++ b/app/src/main/kotlin/app/aaps/compose/navigation/AppNavGraph.kt @@ -40,6 +40,7 @@ import app.aaps.core.interfaces.maintenance.FileListProvider import app.aaps.core.interfaces.navigation.ElementType import app.aaps.core.interfaces.plugin.ActivePlugin import app.aaps.core.interfaces.plugin.PermissionGroup +import app.aaps.core.interfaces.plugin.PluginPermissions import app.aaps.core.interfaces.plugin.PluginBase import app.aaps.core.interfaces.protection.ProtectionCheck import app.aaps.core.interfaces.resources.ResourceHelper @@ -148,6 +149,7 @@ fun NavGraphBuilder.appNavGraph( swDefinition: SWDefinition, rxBus: RxBus, activePlugin: ActivePlugin, + pluginPermissions: PluginPermissions, automationRuntime: AutomationRuntime, preferences: Preferences, rh: ResourceHelper, @@ -749,8 +751,8 @@ fun NavGraphBuilder.appNavGraph( onRequestDirectoryAccess = onRequestDirectoryAccess, onRequestPermission = onRequestPermission, permissionItems = { - val allGroups = activePlugin.collectAllPermissions(navController.context) - val missingGroups = activePlugin.collectMissingPermissions(navController.context) + val allGroups = pluginPermissions.collectAllPermissions(navController.context) + val missingGroups = pluginPermissions.collectMissingPermissions(navController.context) val missingSets = missingGroups.map { it.permissions.toSet() }.toSet() allGroups.map { group -> group to (group.permissions.toSet() !in missingSets) } }, diff --git a/app/src/main/kotlin/app/aaps/di/CalibrationPluginsModule.kt b/app/src/main/kotlin/app/aaps/di/CalibrationPluginsModule.kt new file mode 100644 index 000000000000..9fd04cb63eae --- /dev/null +++ b/app/src/main/kotlin/app/aaps/di/CalibrationPluginsModule.kt @@ -0,0 +1,74 @@ +package app.aaps.di + +import app.aaps.core.interfaces.db.PersistenceLayer +import app.aaps.core.interfaces.di.AllConfigs +import app.aaps.core.interfaces.iob.GlucoseStatusProvider +import app.aaps.core.interfaces.logging.AAPSLogger +import app.aaps.core.interfaces.notifications.NotificationManager +import app.aaps.core.interfaces.plugin.PluginBase +import app.aaps.core.interfaces.profile.ProfileUtil +import app.aaps.core.interfaces.resources.ResourceHelper +import app.aaps.core.interfaces.rx.bus.RxBus +import app.aaps.core.interfaces.utils.DateUtil +import app.aaps.plugins.calibration.LinearCalibrationPlugin +import app.aaps.plugins.calibration.NoCalibrationPlugin +import dagger.Binds +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import dagger.multibindings.IntKey +import dagger.multibindings.IntoMap +import javax.inject.Singleton + +/** + * Dagger wiring for `:plugins:calibration`, lifted out of the plugin module so it can be + * multiplatform. + * + * One such file per converted module, so that moving off this arrangement later is a per-module move. + * Why no KMP module may carry a Dagger annotation - and why the mistake passes the build instead of + * failing it - is in `_docs/KMP_IOS_FEASIBILITY.md`, under "Decisions taken". + * + * Registration keeps its @IntKey block 700-710, step 10. + */ +@Module +@InstallIn(SingletonComponent::class) +class CalibrationPluginsModule { + + @Provides + @Singleton + fun provideNoCalibrationPlugin(aapsLogger: AAPSLogger, rh: ResourceHelper): NoCalibrationPlugin = + NoCalibrationPlugin(aapsLogger, rh) + + @Provides + @Singleton + fun provideLinearCalibrationPlugin( + aapsLogger: AAPSLogger, + rh: ResourceHelper, + dateUtil: DateUtil, + persistenceLayer: PersistenceLayer, + notificationManager: NotificationManager, + glucoseStatusProvider: GlucoseStatusProvider, + rxBus: RxBus, + profileUtil: ProfileUtil + ): LinearCalibrationPlugin = LinearCalibrationPlugin( + aapsLogger, rh, dateUtil, persistenceLayer, notificationManager, glucoseStatusProvider, rxBus, profileUtil + ) + + @Module + @InstallIn(SingletonComponent::class) + abstract class Bindings { + + @Binds + @AllConfigs + @IntoMap + @IntKey(700) + abstract fun bindNoCalibrationPlugin(plugin: NoCalibrationPlugin): PluginBase + + @Binds + @AllConfigs + @IntoMap + @IntKey(710) + abstract fun bindLinearCalibrationPlugin(plugin: LinearCalibrationPlugin): PluginBase + } +} diff --git a/app/src/main/kotlin/app/aaps/di/CoreObjectsModule.kt b/app/src/main/kotlin/app/aaps/di/CoreObjectsModule.kt new file mode 100644 index 000000000000..e28dd421048b --- /dev/null +++ b/app/src/main/kotlin/app/aaps/di/CoreObjectsModule.kt @@ -0,0 +1,120 @@ +package app.aaps.di + +import android.content.Context +import android.telephony.SmsManager +import app.aaps.core.interfaces.aps.Loop +import app.aaps.core.interfaces.automation.Automation +import app.aaps.core.interfaces.configuration.Config +import app.aaps.core.interfaces.constraints.ConstraintsChecker +import app.aaps.core.interfaces.db.PersistenceLayer +import app.aaps.core.interfaces.di.ApplicationScope +import app.aaps.core.interfaces.insulin.ConcentrationHelper +import app.aaps.core.interfaces.iob.GlucoseStatusProvider +import app.aaps.core.interfaces.iob.IobCobCalculator +import app.aaps.core.interfaces.logging.AAPSLogger +import app.aaps.core.interfaces.logging.UserEntryLogger +import app.aaps.core.interfaces.nsclient.ProcessedDeviceStatusData +import app.aaps.core.interfaces.profile.ProfileFunction +import app.aaps.core.interfaces.profile.ProfileUtil +import app.aaps.core.interfaces.resources.ResourceHelper +import app.aaps.core.interfaces.rx.bus.RxBus +import app.aaps.core.interfaces.utils.DateUtil +import app.aaps.core.keys.interfaces.Preferences +import app.aaps.core.objects.crypto.CryptoUtil +import app.aaps.core.objects.runningMode.RunningModeGuard +import app.aaps.core.objects.wizard.BolusWizard +import app.aaps.core.objects.wizard.QuickWizard +import app.aaps.core.objects.wizard.QuickWizardEntry +import app.aaps.core.interfaces.bolus.WizardBolusExecutor +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import kotlinx.coroutines.CoroutineScope +import javax.inject.Provider +import javax.inject.Singleton + +/** + * Constructs the :core:objects classes. + * + * They carry no `@Inject constructor` of their own, because `javax.inject` is JVM only and would + * keep that module from ever becoming multiplatform. Providing them here keeps the graph identical - + * same scopes, same instances - and is the same trade already made for `BolusProgressData`. + * + * One such file per converted module, so that moving off this arrangement later is a per-module move. + * :app is the one module that can never become multiplatform, so wiring put here never has to move + * again; :implementation would probably also be safe, being the ANDROID implementation of + * :core:interfaces, but that is a bet on the future and this needs no bet. + * + * Why no KMP module may carry a Dagger annotation - and why the mistake passes the build instead of + * failing it - is in `_docs/KMP_IOS_FEASIBILITY.md`, under "Decisions taken". + */ +@Suppress("unused") +@Module +@InstallIn(SingletonComponent::class) +class CoreObjectsModule { + + @Suppress("DEPRECATION") + @Provides + fun smsManager(context: Context): SmsManager? = context.getSystemService(SmsManager::class.java) + + @Provides + @Singleton + fun provideCryptoUtil(aapsLogger: AAPSLogger): CryptoUtil = CryptoUtil(aapsLogger) + + @Provides + @Singleton + fun provideRunningModeGuard(loop: Loop, rh: ResourceHelper, rxBus: RxBus): RunningModeGuard = + RunningModeGuard(loop, rh, rxBus) + + @Provides + @Singleton + fun provideQuickWizard(preferences: Preferences, quickWizardEntry: Provider): QuickWizard = + QuickWizard(preferences) { quickWizardEntry.get() } + + @Provides + fun provideQuickWizardEntry( + aapsLogger: AAPSLogger, + preferences: Preferences, + profileFunction: ProfileFunction, + loop: Loop, + iobCobCalculator: IobCobCalculator, + persistenceLayer: PersistenceLayer, + dateUtil: DateUtil, + glucoseStatusProvider: GlucoseStatusProvider, + bolusWizard: Provider, + quickWizard: Provider + ): QuickWizardEntry = QuickWizardEntry( + aapsLogger, preferences, profileFunction, loop, iobCobCalculator, persistenceLayer, dateUtil, + glucoseStatusProvider, { bolusWizard.get() }, { quickWizard.get() } + ) + + @Suppress("LongParameterList") + @Provides + fun provideBolusWizard( + aapsLogger: AAPSLogger, + rh: ResourceHelper, + rxBus: RxBus, + preferences: Preferences, + profileFunction: ProfileFunction, + profileUtil: ProfileUtil, + constraintChecker: ConstraintsChecker, + loop: Loop, + iobCobCalculator: IobCobCalculator, + dateUtil: DateUtil, + config: Config, + uel: UserEntryLogger, + automation: Automation, + glucoseStatusProvider: GlucoseStatusProvider, + persistenceLayer: PersistenceLayer, + processedDeviceStatusData: ProcessedDeviceStatusData, + runningModeGuard: RunningModeGuard, + ch: ConcentrationHelper, + wizardBolusExecutor: WizardBolusExecutor, + @ApplicationScope appScope: CoroutineScope + ): BolusWizard = BolusWizard( + aapsLogger, rh, rxBus, preferences, profileFunction, profileUtil, constraintChecker, loop, + iobCobCalculator, dateUtil, config, uel, automation, glucoseStatusProvider, persistenceLayer, + processedDeviceStatusData, runningModeGuard, ch, wizardBolusExecutor, appScope + ) +} diff --git a/app/src/main/kotlin/app/aaps/di/SensitivityPluginsModule.kt b/app/src/main/kotlin/app/aaps/di/SensitivityPluginsModule.kt new file mode 100644 index 000000000000..3e60293f4759 --- /dev/null +++ b/app/src/main/kotlin/app/aaps/di/SensitivityPluginsModule.kt @@ -0,0 +1,89 @@ +package app.aaps.di + +import app.aaps.core.interfaces.di.AllConfigs +import app.aaps.core.interfaces.logging.AAPSLogger +import app.aaps.core.interfaces.plugin.ActivePlugin +import app.aaps.core.interfaces.plugin.PluginBase +import app.aaps.core.interfaces.resources.ResourceHelper +import app.aaps.core.interfaces.utils.DateUtil +import app.aaps.core.keys.interfaces.Preferences +import app.aaps.plugins.sensitivity.SensitivityAAPSPlugin +import app.aaps.plugins.sensitivity.SensitivityOref1Plugin +import app.aaps.plugins.sensitivity.SensitivityWeightedAveragePlugin +import dagger.Binds +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import dagger.multibindings.IntKey +import dagger.multibindings.IntoMap +import javax.inject.Singleton + +/** + * Dagger wiring for `:plugins:sensitivity`, lifted out of the plugin module so it can be + * multiplatform. + * + * One such file per converted module, so that moving off this arrangement later is a per-module move. + * Why no KMP module may carry a Dagger annotation - and why the mistake passes the build instead of + * failing it - is in `_docs/KMP_IOS_FEASIBILITY.md`, under "Decisions taken". + * + * Self-registration into the global @AllConfigs plugin map keeps its @IntKey block 100-120, step 10. + * See PluginsListModule for the overall @IntKey ordering overview. + */ +@Module +@InstallIn(SingletonComponent::class) +class SensitivityPluginsModule { + + @Provides + @Singleton + fun provideSensitivityAAPSPlugin( + aapsLogger: AAPSLogger, + rh: ResourceHelper, + preferences: Preferences, + dateUtil: DateUtil, + activePlugin: ActivePlugin + ): SensitivityAAPSPlugin = SensitivityAAPSPlugin(aapsLogger, rh, preferences, dateUtil, activePlugin) + + @Provides + @Singleton + fun provideSensitivityWeightedAveragePlugin( + aapsLogger: AAPSLogger, + rh: ResourceHelper, + preferences: Preferences, + dateUtil: DateUtil, + activePlugin: ActivePlugin + ): SensitivityWeightedAveragePlugin = SensitivityWeightedAveragePlugin(aapsLogger, rh, preferences, dateUtil, activePlugin) + + @Provides + @Singleton + fun provideSensitivityOref1Plugin( + aapsLogger: AAPSLogger, + rh: ResourceHelper, + preferences: Preferences, + dateUtil: DateUtil + ): SensitivityOref1Plugin = SensitivityOref1Plugin(aapsLogger, rh, preferences, dateUtil) + + @Module + @InstallIn(SingletonComponent::class) + @Suppress("unused") + abstract class Bindings { + + @Binds + @AllConfigs + @IntoMap + @IntKey(100) + abstract fun bindSensitivityAAPSPlugin(plugin: SensitivityAAPSPlugin): PluginBase + + @Binds + @AllConfigs + @IntoMap + @IntKey(110) + abstract fun bindSensitivityWeightedAveragePlugin(plugin: SensitivityWeightedAveragePlugin): PluginBase + + @Binds + @AllConfigs + @IntoMap + @IntKey(120) + abstract fun bindSensitivityOref1Plugin(plugin: SensitivityOref1Plugin): PluginBase + } +} diff --git a/app/src/main/kotlin/app/aaps/di/SmoothingPluginsModule.kt b/app/src/main/kotlin/app/aaps/di/SmoothingPluginsModule.kt new file mode 100644 index 000000000000..da701e40bf16 --- /dev/null +++ b/app/src/main/kotlin/app/aaps/di/SmoothingPluginsModule.kt @@ -0,0 +1,88 @@ +package app.aaps.di + +import app.aaps.core.interfaces.db.PersistenceLayer +import app.aaps.core.interfaces.di.AllConfigs +import app.aaps.core.interfaces.logging.AAPSLogger +import app.aaps.core.interfaces.plugin.PluginBase +import app.aaps.core.interfaces.resources.ResourceHelper +import app.aaps.core.keys.interfaces.Preferences +import app.aaps.plugins.smoothing.AvgSmoothingPlugin +import app.aaps.plugins.smoothing.ExponentialSmoothingPlugin +import app.aaps.plugins.smoothing.NoSmoothingPlugin +import app.aaps.plugins.smoothing.UnscentedKalmanFilterPlugin +import dagger.Binds +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import dagger.multibindings.IntKey +import dagger.multibindings.IntoMap +import javax.inject.Singleton + +/** + * Dagger wiring for `:plugins:smoothing`, lifted out of the plugin module so it can be multiplatform. + * + * One such file per converted module, so that moving off this arrangement later is a per-module move. + * Why no KMP module may carry a Dagger annotation - and why the mistake passes the build instead of + * failing it - is in `_docs/KMP_IOS_FEASIBILITY.md`, under "Decisions taken". + * + * Self-registration into the global @AllConfigs plugin map keeps its @IntKey block 600-630, step 10. + * See PluginsListModule for the overall @IntKey ordering overview. + */ +@Module +@InstallIn(SingletonComponent::class) +class SmoothingPluginsModule { + + @Provides + @Singleton + fun provideNoSmoothingPlugin(aapsLogger: AAPSLogger, rh: ResourceHelper): NoSmoothingPlugin = + NoSmoothingPlugin(aapsLogger, rh) + + @Provides + @Singleton + fun provideExponentialSmoothingPlugin(aapsLogger: AAPSLogger, rh: ResourceHelper): ExponentialSmoothingPlugin = + ExponentialSmoothingPlugin(aapsLogger, rh) + + @Provides + @Singleton + fun provideAvgSmoothingPlugin(aapsLogger: AAPSLogger, rh: ResourceHelper): AvgSmoothingPlugin = + AvgSmoothingPlugin(aapsLogger, rh) + + @Provides + @Singleton + fun provideUnscentedKalmanFilterPlugin( + aapsLogger: AAPSLogger, + rh: ResourceHelper, + preferences: Preferences, + persistenceLayer: PersistenceLayer + ): UnscentedKalmanFilterPlugin = UnscentedKalmanFilterPlugin(aapsLogger, rh, preferences, persistenceLayer) + + @Module + @InstallIn(SingletonComponent::class) + abstract class Bindings { + + @Binds + @AllConfigs + @IntoMap + @IntKey(600) + abstract fun bindNoSmoothingPlugin(plugin: NoSmoothingPlugin): PluginBase + + @Binds + @AllConfigs + @IntoMap + @IntKey(610) + abstract fun bindExponentialSmoothingPlugin(plugin: ExponentialSmoothingPlugin): PluginBase + + @Binds + @AllConfigs + @IntoMap + @IntKey(620) + abstract fun bindAvgSmoothingPlugin(plugin: AvgSmoothingPlugin): PluginBase + + @Binds + @AllConfigs + @IntoMap + @IntKey(630) + abstract fun bindUnscentedKalmanFilterPlugin(plugin: UnscentedKalmanFilterPlugin): PluginBase + } +} diff --git a/app/src/main/kotlin/app/aaps/di/VirtualPumpModule.kt b/app/src/main/kotlin/app/aaps/di/VirtualPumpModule.kt new file mode 100644 index 000000000000..72de9da15dce --- /dev/null +++ b/app/src/main/kotlin/app/aaps/di/VirtualPumpModule.kt @@ -0,0 +1,93 @@ +package app.aaps.di + +import app.aaps.core.interfaces.configuration.Config +import app.aaps.core.interfaces.db.PersistenceLayer +import app.aaps.core.interfaces.di.AllConfigs +import app.aaps.core.interfaces.di.ApplicationScope +import app.aaps.core.interfaces.insulin.ConcentrationHelper +import app.aaps.core.interfaces.logging.AAPSLogger +import app.aaps.core.interfaces.plugin.PluginBase +import app.aaps.core.interfaces.profile.ProfileFunction +import app.aaps.core.interfaces.pump.BolusProgressData +import app.aaps.core.interfaces.pump.PumpEnactResult +import app.aaps.core.interfaces.pump.PumpSync +import app.aaps.core.interfaces.pump.VirtualPump +import app.aaps.core.interfaces.queue.CommandQueue +import app.aaps.core.interfaces.resources.ResourceHelper +import app.aaps.core.interfaces.rx.bus.RxBus +import app.aaps.core.interfaces.utils.DateUtil +import app.aaps.core.keys.interfaces.Preferences +import app.aaps.pump.virtual.VirtualPumpPlugin +import dagger.Binds +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import dagger.multibindings.IntKey +import dagger.multibindings.IntoMap +import kotlinx.coroutines.CoroutineScope +import javax.inject.Provider +import javax.inject.Singleton + +/** + * Dagger wiring for `:pump:virtual`, lifted out of the pump module so it can be multiplatform. + * + * One such file per converted module, so that moving off this arrangement later is a per-module move. + * Why no KMP module may carry a Dagger annotation - and why the mistake passes the build instead of + * failing it - is in `_docs/KMP_IOS_FEASIBILITY.md`, under "Decisions taken". + */ +@Module +@InstallIn(SingletonComponent::class) +class VirtualPumpModule { + + @Provides + @Singleton + fun provideVirtualPumpPlugin( + aapsLogger: AAPSLogger, + rxBus: RxBus, + rh: ResourceHelper, + preferences: Preferences, + commandQueue: CommandQueue, + pumpSync: PumpSync, + config: Config, + dateUtil: DateUtil, + persistenceLayer: PersistenceLayer, + // A plain factory rather than javax.inject.Provider: that type is JVM only and would pin the + // plugin to one platform. Dagger still supplies it, from this side. + pumpEnactResult: Provider, + ch: ConcentrationHelper, + profileFunction: ProfileFunction, + bolusProgressData: BolusProgressData, + @ApplicationScope appScope: CoroutineScope + ): VirtualPumpPlugin = VirtualPumpPlugin( + aapsLogger = aapsLogger, + rxBus = rxBus, + rh = rh, + preferences = preferences, + commandQueue = commandQueue, + pumpSync = pumpSync, + config = config, + dateUtil = dateUtil, + persistenceLayer = persistenceLayer, + pumpEnactResultProvider = { pumpEnactResult.get() }, + ch = ch, + profileFunction = profileFunction, + bolusProgressData = bolusProgressData, + appScope = appScope + ) + + @Module + @InstallIn(SingletonComponent::class) + abstract class Bindings { + + // VirtualPump self-registers as @AllConfigs (present in every build config), @IntKey(1000). + // Real pump drivers use the @PumpDriver map at @IntKey 1010+. + @Binds + @AllConfigs + @IntoMap + @IntKey(1000) + abstract fun bindVirtualPumpPlugin(plugin: VirtualPumpPlugin): PluginBase + + @Binds abstract fun bindVirtualPump(virtualPumpPlugin: VirtualPumpPlugin): VirtualPump + } +} diff --git a/app/src/main/kotlin/app/aaps/history/HistoryBrowserData.kt b/app/src/main/kotlin/app/aaps/history/HistoryBrowserData.kt index 22e36db15686..c64e7f78e737 100644 --- a/app/src/main/kotlin/app/aaps/history/HistoryBrowserData.kt +++ b/app/src/main/kotlin/app/aaps/history/HistoryBrowserData.kt @@ -55,8 +55,8 @@ class HistoryBrowserData @Inject constructor( ) override val iobCobCalculator: IobCobCalculator = IobCobCalculatorPlugin( - aapsLogger, aapsSchedulers, rxBus, preferences, rh, profileFunction, activePlugin, - fabricPrivacy, dateUtil, persistenceLayer, overviewData, calculationWorkflow, decimalFormatter, processedTbrEbData, + aapsLogger, rxBus, preferences, rh, profileFunction, activePlugin, + dateUtil, persistenceLayer, overviewData, calculationWorkflow, decimalFormatter, processedTbrEbData, signals ) { cache } diff --git a/app/src/main/kotlin/app/aaps/implementations/UiInteractionImpl.kt b/app/src/main/kotlin/app/aaps/implementations/UiInteractionImpl.kt index 6c0e209cf7cf..ca7fc49cb2e5 100644 --- a/app/src/main/kotlin/app/aaps/implementations/UiInteractionImpl.kt +++ b/app/src/main/kotlin/app/aaps/implementations/UiInteractionImpl.kt @@ -4,7 +4,7 @@ import android.content.Context import android.content.Intent import android.os.Looper import android.widget.Toast -import androidx.annotation.RawRes +import app.aaps.core.interfaces.notifications.AlarmSound import androidx.lifecycle.Lifecycle import androidx.lifecycle.ProcessLifecycleOwner import app.aaps.ComposeMainActivity @@ -28,6 +28,7 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch import javax.inject.Inject import javax.inject.Provider +import kotlin.reflect.KClass @Reusable class UiInteractionImpl @Inject constructor( @@ -42,10 +43,10 @@ class UiInteractionImpl @Inject constructor( @ApplicationScope private val appScope: CoroutineScope ) : UiInteraction { - override val mainActivity: Class<*> = ComposeMainActivity::class.java - override val errorHelperActivity: Class<*> = ErrorActivity::class.java + override val mainActivity: KClass<*> = ComposeMainActivity::class + override val errorHelperActivity: KClass<*> = ErrorActivity::class - override fun runAlarm(status: String, title: String, @RawRes soundId: Int) { + override fun runAlarm(status: String, title: String, sound: AlarmSound?) { // Persist the error as an announcement at fire time — gated by the NS-announcement // preference + APS build. Done here (not in ErrorActivity) so the record is written for // every alarm with the true trigger time, regardless of whether/how it is later @@ -66,8 +67,8 @@ class UiInteractionImpl @Inject constructor( // from non-main threads. From those contexts we skip the foreground-direct optimization // entirely and use the FSI path, which is safe from any thread. if (Looper.myLooper() != Looper.getMainLooper()) { - aapsLogger.debug(LTag.CORE, "runAlarm (off-main → FSI): $title - $status (sound=$soundId)") - alarmNotificationManager.postFullScreenAlarm(status = status, title = title, soundId = soundId) + aapsLogger.debug(LTag.CORE, "runAlarm (off-main → FSI): $title - $status (sound=$sound)") + alarmNotificationManager.postFullScreenAlarm(status = status, title = title, sound = sound) return } @@ -77,9 +78,9 @@ class UiInteractionImpl @Inject constructor( // • Activity opens instantly, owns ramped audio from 0. // • Works because the caller's process is already foreground (Android's // background-activity-start restriction does not apply). - aapsLogger.debug(LTag.CORE, "runAlarm (foreground direct): $title - $status (sound=$soundId)") - val intent = Intent(context, errorHelperActivity).apply { - putExtra(AlarmIntent.EXTRA_SOUND_ID, soundId) + aapsLogger.debug(LTag.CORE, "runAlarm (foreground direct): $title - $status (sound=$sound)") + val intent = Intent(context, errorHelperActivity.java).apply { + putExtra(AlarmIntent.EXTRA_SOUND, sound?.name) putExtra(AlarmIntent.EXTRA_STATUS, status) putExtra(AlarmIntent.EXTRA_TITLE, title) addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP) @@ -90,14 +91,14 @@ class UiInteractionImpl @Inject constructor( // Defensive: if the activity start is rejected for any reason, fall back to // the FSI notification path so the alert is never silently lost. aapsLogger.error(LTag.CORE, "runAlarm: direct startActivity failed, falling back to FSI", ex) - postFsiFallback(status, title, soundId) + postFsiFallback(status, title, sound) } } else { // Background path — FSI notification. Android auto-launches the activity on // lockscreen/idle, or shows a heads-up (with channel sound) when the user is // active in another app. - aapsLogger.debug(LTag.CORE, "runAlarm (background via FSI): $title - $status (sound=$soundId)") - alarmNotificationManager.postFullScreenAlarm(status = status, title = title, soundId = soundId) + aapsLogger.debug(LTag.CORE, "runAlarm (background via FSI): $title - $status (sound=$sound)") + alarmNotificationManager.postFullScreenAlarm(status = status, title = title, sound = sound) } } @@ -116,8 +117,8 @@ class UiInteractionImpl @Inject constructor( * visible signal that something tried to alarm. Best-effort; Toast can also fail (e.g. * if a system overlay permission is denied) but it costs nothing to try. */ - private fun postFsiFallback(status: String, title: String, @RawRes soundId: Int) { - alarmNotificationManager.postFullScreenAlarm(status = status, title = title, soundId = soundId) + private fun postFsiFallback(status: String, title: String, sound: AlarmSound?) { + alarmNotificationManager.postFullScreenAlarm(status = status, title = title, sound = sound) // Toast must be created on the main thread (we are — runAlarm guards above). runCatching { Toast.makeText(context, "ALARM: $title — $status", Toast.LENGTH_LONG).show() diff --git a/buildSrc/src/main/kotlin/GenerateKeyStringsTask.kt b/buildSrc/src/main/kotlin/GenerateKeyStringsTask.kt new file mode 100644 index 000000000000..19b9662cffe3 --- /dev/null +++ b/buildSrc/src/main/kotlin/GenerateKeyStringsTask.kt @@ -0,0 +1,248 @@ +import org.gradle.api.DefaultTask +import org.gradle.api.GradleException +import org.gradle.api.file.DirectoryProperty +import org.gradle.api.file.RegularFileProperty +import org.gradle.api.provider.Property +import org.gradle.api.tasks.Input +import org.gradle.api.tasks.InputDirectory +import org.gradle.api.tasks.OutputDirectory +import org.gradle.api.tasks.OutputFile +import org.gradle.api.tasks.PathSensitive +import org.gradle.api.tasks.PathSensitivity +import org.gradle.api.tasks.TaskAction +import java.io.File +import javax.xml.parsers.DocumentBuilderFactory + +/** + * Turns a module's `res/values/strings.xml` into two generated Kotlin files, so preference keys can + * name a string without naming an Android resource id. + * + * Why this exists: a key enum that says `titleResId = R.string.x` carries an `Int` that only means + * something on Android, which is what stops the module compiling for iOS or a desktop JVM. The + * generated `KeysStrings` object replaces that `Int` with a `TextRef.Named("x")` - a plain string + * name that every platform can hold - while the generated id map keeps Android resolving through + * AAPT exactly as before. + * + * The pair is generated from the same XML in one pass, so the two sides cannot drift: a string + * deleted from `strings.xml` disappears from both, and any call site that still names it stops + * compiling. + * + * This is deliberately not `Resources.getIdentifier()`. That is a reflective lookup R8 cannot see, + * it would keep every string alive, and a typo would silently return 0. A generated map is plain + * code: R8 sees literal `R.string.x` references, and a typo is a compile error. + * + * It also reports translation completeness per locale, which nothing else in this build does - + * `MissingTranslation` and `ExtraTranslation` lint are disabled for every library module. + */ +abstract class GenerateKeyStringsTask : DefaultTask() { + + /** The module's `src/main/res` (or `src/androidMain/res`) directory. */ + @get:InputDirectory + @get:PathSensitive(PathSensitivity.RELATIVE) + abstract val resDir: DirectoryProperty + + /** Package for both generated files. Must match the module's namespace, so `R` resolves. */ + @get:Input + abstract val packageName: Property + + /** + * Which module owns these names, for example `keys` or `ui`. + * + * A string name is only unique inside one module - `ns_wifi_ssids` exists in both `:core:keys` + * and `:core:ui` with different translations - so the resolver needs to know which map to look + * in rather than guessing by order. + */ + @get:Input + abstract val owner: Property + + /** Name of the platform neutral object, for example `KeysStrings`. */ + @get:Input + abstract val objectName: Property + + /** Name of the Android id map object, for example `KeysStringIds`. */ + @get:Input + abstract val idsObjectName: Property + + /** Holds the generated object of [TextRef] names. Platform neutral - no Android types. */ + @get:OutputDirectory + abstract val commonOutputDir: DirectoryProperty + + /** Holds the generated name to `R.string` id map. Android only. */ + @get:OutputDirectory + abstract val androidOutputDir: DirectoryProperty + + /** Per locale completeness report. */ + @get:OutputFile + abstract val reportFile: RegularFileProperty + + @TaskAction + fun generate() { + val res = resDir.get().asFile + val baseDir = File(res, "values") + if (!baseDir.isDirectory) throw GradleException("No values/ directory under $res") + + val names = readStringNames(baseDir) + if (names.isEmpty()) throw GradleException("No elements under $baseDir") + + val duplicates = names.groupBy { it }.filterValues { it.size > 1 }.keys + if (duplicates.isNotEmpty()) { + // AAPT would reject these too, so this cannot fire on a project that builds. + throw GradleException("Duplicate string names under $baseDir: ${duplicates.sorted()}") + } + + val unsafe = names.filterNot { it.isSafeKotlinIdentifier() } + if (unsafe.isNotEmpty()) { + // A name that is not a valid identifier cannot become a property on the generated + // object. Renaming it in strings.xml is the fix, but that also renames it in Crowdin, + // so it has to be a deliberate act rather than something the generator papers over. + throw GradleException( + "These string names cannot be generated as Kotlin properties: ${unsafe.sorted()}. " + + "Rename them under $baseDir." + ) + } + + writeCommon(names.sorted()) + writeAndroid(names.sorted()) + writeReport(res, names.toSet()) + } + + private fun writeCommon(names: List) { + val dir = commonOutputDir.get().asFile + dir.deleteRecursively() + val pkg = packageName.get() + val obj = objectName.get() + val file = File(dir, pkg.replace('.', '/') + "/$obj.kt") + file.parentFile.mkdirs() + file.writeText( + buildString { + append(GENERATED_HEADER) + append("package $pkg\n\n") + append("import app.aaps.core.keys.interfaces.TextRef\n\n") + append("/**\n") + append(" * Every string this module owns, as a platform neutral [TextRef].\n") + append(" *\n") + append(" * Generated from `res/values/strings.xml`. Use these instead of `R.string.*` so the\n") + append(" * declaring code stays free of Android resource ids.\n") + append(" */\n") + append("object $obj {\n\n") + val ownerName = owner.get() + names.forEach { append(" val $it: TextRef = TextRef.Named(\"$ownerName\", \"$it\")\n") } + append("}\n") + } + ) + } + + private fun writeAndroid(names: List) { + val dir = androidOutputDir.get().asFile + dir.deleteRecursively() + val pkg = packageName.get() + val obj = idsObjectName.get() + val named = objectName.get() + val file = File(dir, pkg.replace('.', '/') + "/$obj.kt") + file.parentFile.mkdirs() + file.writeText( + buildString { + append(GENERATED_HEADER) + append("package $pkg\n\n") + append("/**\n") + append(" * Maps the names in [$named] back to this module's AAPT resource ids.\n") + append(" *\n") + append(" * This is what keeps Android on its own resource system: a `TextRef.Named` is resolved to\n") + append(" * an `R.string` id here and then read through `Resources`, so locale matching, the\n") + append(" * always English lookup and `MissingTranslation` lint all behave exactly as they did when\n") + append(" * the keys carried ids directly.\n") + append(" */\n") + append("object $obj {\n\n") + append(" private val ids: Map = mapOf(\n") + names.forEach { append(" \"$it\" to R.string.$it,\n") } + append(" )\n\n") + append(" /**\n") + append(" * The resource id for [name], or null when this module does not own that name.\n") + append(" *\n") + append(" * Null means someone built a `TextRef.Named` by hand instead of taking it from\n") + append(" * [$named]. Callers show the raw name rather than crashing, which makes the mistake\n") + append(" * visible on screen without taking the app down.\n") + append(" */\n") + append(" fun idOf(name: String): Int? = ids[name]\n") + append("}\n") + } + ) + } + + private fun writeReport(res: File, expected: Set) { + val report = StringBuilder() + report.append("Translation completeness for ${res.parentFile.parentFile.parentFile.name}\n") + report.append("Base locale has ${expected.size} strings.\n\n") + + val locales = res.listFiles() + ?.filter { it.isDirectory && it.name.startsWith("values-") } + ?.sortedBy { it.name } + .orEmpty() + + var empty = 0 + var partial = 0 + locales.forEach { dir -> + val present = readStringNames(dir).toSet() + val missing = expected - present + val extra = present - expected + val state = when { + present.isEmpty() -> { empty++; "EMPTY" } + missing.isEmpty() && extra.isEmpty() -> "complete" + else -> { partial++; "PARTIAL" } + } + report.append("%-18s %4d/%d %s\n".format(dir.name, present.size, expected.size, state)) + if (extra.isNotEmpty()) report.append(" not in base locale: ${extra.sorted()}\n") + } + + report.append("\n${locales.size} locales: ${locales.size - empty - partial} complete, $partial partial, $empty empty.\n") + + val out = reportFile.get().asFile + out.parentFile.mkdirs() + out.writeText(report.toString()) + + // A warning, not a failure. 19 of these locales are empty today for reasons that predate + // this task, so failing here would make the build red on the first run. The point is that + // the number is now visible and can be tightened later. + if (empty > 0 || partial > 0) { + logger.warn("$name: $partial partial and $empty empty locales - see ${out.absolutePath}") + } + } + + /** + * Every `` in EVERY xml file of a `values*` directory, because that is what AAPT does. + * + * Reading only `strings.xml` is the obvious shortcut and it is wrong: `:core:ui` keeps 1059 + * strings in `strings.xml`, 32 in `protection.xml` and 60 in `strings_scene_wizard.xml`, and a + * generator that missed the other two would leave 92 names unresolvable while looking correct. + * Files with no `` at all - colors, styles, layout aliases - simply contribute nothing. + */ + private fun readStringNames(dir: File): List { + val files = dir.listFiles { f: File -> f.isFile && f.extension.equals("xml", ignoreCase = true) } + ?.sortedBy { it.name } + .orEmpty() + val builder = DocumentBuilderFactory.newInstance() + .apply { isNamespaceAware = false } + .newDocumentBuilder() + return files.flatMap { file -> + val nodes = builder.parse(file).getElementsByTagName("string") + (0 until nodes.length).mapNotNull { i -> + nodes.item(i).attributes?.getNamedItem("name")?.nodeValue + } + } + } + + private fun String.isSafeKotlinIdentifier(): Boolean = + matches(Regex("[A-Za-z_][A-Za-z0-9_]*")) && this !in KOTLIN_KEYWORDS + + private companion object { + + const val GENERATED_HEADER = + "// Generated by GenerateKeyStringsTask from res/values/strings.xml. Do not edit.\n\n" + + val KOTLIN_KEYWORDS = setOf( + "as", "break", "class", "continue", "do", "else", "false", "for", "fun", "if", "in", + "interface", "is", "null", "object", "package", "return", "super", "this", "throw", + "true", "try", "typealias", "typeof", "val", "var", "when", "while" + ) + } +} diff --git a/buildSrc/src/main/kotlin/Versions.kt b/buildSrc/src/main/kotlin/Versions.kt index 204039ec9942..8b9ded63720f 100644 --- a/buildSrc/src/main/kotlin/Versions.kt +++ b/buildSrc/src/main/kotlin/Versions.kt @@ -5,7 +5,7 @@ import org.jetbrains.kotlin.gradle.dsl.JvmTarget object Versions { // On change edit aaps-ci.yml - const val appVersion = "4.0.0-dev-b" + const val appVersion = "4.0.0-dev-b-kmp" const val versionCode = 1500 const val compileSdk = 37 diff --git a/core/data/build.gradle.kts b/core/data/build.gradle.kts index 36579954b059..13b79005932c 100644 --- a/core/data/build.gradle.kts +++ b/core/data/build.gradle.kts @@ -1,19 +1,58 @@ plugins { - id("java-library") - id("org.jetbrains.kotlin.jvm") + kotlin("multiplatform") } -java { - sourceCompatibility = Versions.javaVersion - targetCompatibility = Versions.javaVersion -} +kotlin { + jvm { + // Every consumer of :core:data is still an Android or JVM module, so this variant is the + // one they resolve. Nothing about them changes. + compilerOptions { + jvmTarget.set(Versions.jvmTarget) + } + } + + // Real Apple targets. Kotlin/Native cross compiles klibs for them from any host, so these do + // build on Windows - only linking, cinterop and running their tests need a Mac. + iosArm64() + iosSimulatorArm64() + + // mingwX64 stays, and not out of habit: it is the only Kotlin/Native target whose tests can + // actually RUN on this machine. iosSimulatorArm64Test is disabled off macOS ("simulator tests + // require macOS"), so without mingw there would be no way to execute common code through + // Kotlin/Native at all before a Mac appears. + mingwX64() -dependencies { - testImplementation(libs.org.junit.jupiter) - testImplementation(libs.com.google.truth) - testRuntimeOnly(libs.org.junit.platform.launcher) + // No module-wide opt-in for ExperimentalNativeApi. `assert` is not in the common standard + // library at all, so opting in could never have fixed it; the fix is the devAssert + // expect/actual, and the Native actual scopes its own opt-in to one file. + + sourceSets { + commonMain { + dependencies { + api(project.dependencies.platform(libs.kotlinx.serialization.bom)) + api(libs.kotlinx.serialization.json) + api(libs.kotlinx.datetime) + } + } + getByName("commonTest") { + dependencies { + implementation(kotlin("test")) + } + } + getByName("jvmTest") { + dependencies { + implementation(libs.org.junit.jupiter) + implementation(libs.com.google.truth) + runtimeOnly(libs.org.junit.platform.launcher) + // The oracle for OrgJsonCompatParityTest, and the only place org.json may appear. + implementation(libs.org.json.android) + // The oracle for IsoDateParserParityTest - the joda parser being replaced. + implementation(libs.joda.time) + } + } + } } tasks.withType { useJUnitPlatform() -} \ No newline at end of file +} diff --git a/core/data/src/main/kotlin/app/aaps/core/data/aps/AverageTDD.kt b/core/data/src/commonMain/kotlin/app/aaps/core/data/aps/AverageTDD.kt similarity index 83% rename from core/data/src/main/kotlin/app/aaps/core/data/aps/AverageTDD.kt rename to core/data/src/commonMain/kotlin/app/aaps/core/data/aps/AverageTDD.kt index bd07f18421c4..5d8a50f0fe1f 100644 --- a/core/data/src/main/kotlin/app/aaps/core/data/aps/AverageTDD.kt +++ b/core/data/src/commonMain/kotlin/app/aaps/core/data/aps/AverageTDD.kt @@ -2,7 +2,7 @@ package app.aaps.core.data.aps import app.aaps.core.data.model.TDD -data class AverageTDD ( +data class AverageTDD( var data: TDD, val allDaysHaveCarbs: Boolean ) \ No newline at end of file diff --git a/core/data/src/main/kotlin/app/aaps/core/data/aps/BasalData.kt b/core/data/src/commonMain/kotlin/app/aaps/core/data/aps/BasalData.kt similarity index 100% rename from core/data/src/main/kotlin/app/aaps/core/data/aps/BasalData.kt rename to core/data/src/commonMain/kotlin/app/aaps/core/data/aps/BasalData.kt diff --git a/core/data/src/main/kotlin/app/aaps/core/data/aps/SMBDefaults.kt b/core/data/src/commonMain/kotlin/app/aaps/core/data/aps/SMBDefaults.kt similarity index 100% rename from core/data/src/main/kotlin/app/aaps/core/data/aps/SMBDefaults.kt rename to core/data/src/commonMain/kotlin/app/aaps/core/data/aps/SMBDefaults.kt diff --git a/core/data/src/main/kotlin/app/aaps/core/data/configuration/Constants.kt b/core/data/src/commonMain/kotlin/app/aaps/core/data/configuration/Constants.kt similarity index 100% rename from core/data/src/main/kotlin/app/aaps/core/data/configuration/Constants.kt rename to core/data/src/commonMain/kotlin/app/aaps/core/data/configuration/Constants.kt diff --git a/core/data/src/commonMain/kotlin/app/aaps/core/data/datetime/IsoDateParser.kt b/core/data/src/commonMain/kotlin/app/aaps/core/data/datetime/IsoDateParser.kt new file mode 100644 index 000000000000..2703987dd125 --- /dev/null +++ b/core/data/src/commonMain/kotlin/app/aaps/core/data/datetime/IsoDateParser.kt @@ -0,0 +1,75 @@ +package app.aaps.core.data.datetime + +import kotlinx.datetime.LocalDate +import kotlinx.datetime.LocalDateTime +import kotlinx.datetime.TimeZone +import kotlinx.datetime.UtcOffset +import kotlinx.datetime.atStartOfDayIn +import kotlinx.datetime.toInstant + +/** + * Lenient ISO 8601 parsing, matching what joda's `ISODateTimeFormat.dateTimeParser()` accepted. + * + * joda is a JVM library so it cannot go to iOS, and kotlinx-datetime is multiplatform but strict: + * `Instant.parse` rejects `+0200` without a colon, a value with no offset at all, and a bare date - + * all of which arrive today from Nightscout device status written by other uploaders. + * + * The trick is to take any explicit offset off the end **first**, then parse what is left as a plain + * local value. One small parser then covers every shape rather than needing a format per variant: + * + * - `2026-08-06T04:56:19.555Z`, `...+02:00`, `...+0200`, `...-04:00` -> that exact instant + * - `2026-08-06T04:56:19.555`, `2026-08-06T04:56` -> **local** time, as joda read it + * - `2026-08-06` -> **local** midnight + * - lower case `t` / `z` -> accepted + * - anything else -> null + * + * Returning null rather than a sentinel is deliberate: the two callers want different things from a + * failure. `:core:nssdk` maps it to `0L` so one bad treatment does not abort a whole sync, while + * `RT` throws, because joda threw there and a silent 1970 timestamp inside an APS result would be + * worse than a loud failure. + * + * `IsoDateParserParityTest` pins every shape above against real joda output. + * + * **Knowingly duplicated** in `:core:nssdk` (`RemoteTreatment.parseCreatedAt`). That module has no + * project dependencies at all, and adding one to share ~30 lines would couple a standalone SDK to + * the app's data module. If a third caller appears, revisit that trade. + */ +fun parseIsoToEpochMillisOrNull(isoDateString: String): Long? { + val text = isoDateString.trim().uppercase() + if (text.isEmpty()) return null + + // Peel off a trailing zone designator, so the rest is a plain local date-time. + var local = text + var offset: UtcOffset? = null + if (text.endsWith("Z")) { + local = text.dropLast(1) + offset = UtcOffset.ZERO + } else { + offsetAtEnd.find(text)?.let { match -> + val parsed = runCatching { UtcOffset.parse(withOffsetColon(match.value)) }.getOrNull() + if (parsed != null) { + local = text.substring(0, match.range.first) + offset = parsed + } + } + } + + val zone = TimeZone.currentSystemDefault() + + runCatching { LocalDateTime.parse(local) }.getOrNull()?.let { dateTime -> + val instant = offset?.let { dateTime.toInstant(it) } ?: dateTime.toInstant(zone) + return instant.toEpochMilliseconds() + } + // Date only. joda gave local midnight, and a date without a time never carries an offset. + runCatching { LocalDate.parse(local) }.getOrNull()?.let { date -> + return date.atStartOfDayIn(zone).toEpochMilliseconds() + } + return null +} + +/** A trailing `+HH:MM` / `+HHMM` offset. Anchored so it cannot match the date's own dashes. */ +private val offsetAtEnd = Regex("""[+-]\d{2}:?\d{2}$""") + +/** `+0200` -> `+02:00`; already-correct input is returned unchanged. */ +private fun withOffsetColon(offset: String): String = + if (offset.contains(':')) offset else offset.substring(0, 3) + ":" + offset.substring(3) diff --git a/core/data/src/main/kotlin/app/aaps/core/data/format/NumberFormat.kt b/core/data/src/commonMain/kotlin/app/aaps/core/data/format/NumberFormat.kt similarity index 86% rename from core/data/src/main/kotlin/app/aaps/core/data/format/NumberFormat.kt rename to core/data/src/commonMain/kotlin/app/aaps/core/data/format/NumberFormat.kt index 0734a7cdc1d5..4edca1bdd939 100644 --- a/core/data/src/main/kotlin/app/aaps/core/data/format/NumberFormat.kt +++ b/core/data/src/commonMain/kotlin/app/aaps/core/data/format/NumberFormat.kt @@ -27,7 +27,8 @@ package app.aaps.core.data.format class NumberFormat( val minIntegerDigits: Int = 1, val minFractionDigits: Int = 0, - val maxFractionDigits: Int = minFractionDigits + val maxFractionDigits: Int = minFractionDigits, + val rounding: NumberRounding = NumberRounding.HALF_EVEN ) { init { @@ -55,11 +56,12 @@ class NumberFormat( other is NumberFormat && minIntegerDigits == other.minIntegerDigits && minFractionDigits == other.minFractionDigits && - maxFractionDigits == other.maxFractionDigits + maxFractionDigits == other.maxFractionDigits && + rounding == other.rounding - override fun hashCode(): Int = (minIntegerDigits * 31 + minFractionDigits) * 31 + maxFractionDigits + override fun hashCode(): Int = ((minIntegerDigits * 31 + minFractionDigits) * 31 + maxFractionDigits) * 31 + rounding.ordinal - override fun toString(): String = "NumberFormat($minIntegerDigits, $minFractionDigits, $maxFractionDigits)" + override fun toString(): String = "NumberFormat($minIntegerDigits, $minFractionDigits, $maxFractionDigits, $rounding)" companion object { @@ -96,6 +98,15 @@ class NumberFormat( /** Three decimals, plus a fourth one when it is not zero. Old pattern `"0.000#"`. */ val DECIMAL_3_UP_TO_4 = NumberFormat(minFractionDigits = 3, maxFractionDigits = 4) + /** + * Fixed decimals, ties away from zero. + * + * For a single value on screen, where the reader expects .5 to go up rather than to the + * even neighbour. See [NumberRounding]. + */ + fun withDecimalsHalfUp(decimals: Int): NumberFormat = + NumberFormat(minFractionDigits = decimals, rounding = NumberRounding.HALF_UP) + /** Format with the given number of fixed decimals. */ fun withDecimals(decimals: Int): NumberFormat = when (decimals) { 0 -> INTEGER diff --git a/core/data/src/commonMain/kotlin/app/aaps/core/data/format/NumberFormatPlatform.kt b/core/data/src/commonMain/kotlin/app/aaps/core/data/format/NumberFormatPlatform.kt new file mode 100644 index 000000000000..69db6ab373e7 --- /dev/null +++ b/core/data/src/commonMain/kotlin/app/aaps/core/data/format/NumberFormatPlatform.kt @@ -0,0 +1,27 @@ +package app.aaps.core.data.format + +/** + * Renders a [NumberFormat] as text. + * + * This is the only place in the app that still uses a platform number formatter. Everything else + * works with [NumberFormat], which is pure Kotlin. + * + * The locale data (separators, minus sign, digit shapes) belongs to the platform - it is the CLDR + * database that ships with the JVM and with iOS. Writing it again in common Kotlin would mean + * carrying megabytes of it and then disagreeing with the operating system, so this stays a seam. + * + * Output must match the old `java.text.DecimalFormat` patterns: no grouping separator, rounding + * half-even. `NumberFormatTest` in `jvmTest` checks that against the real `DecimalFormat`. + */ +expect object NumberFormatPlatform { + + /** Decimal separator for text that must not depend on the locale, for example server data. */ + val SEPARATOR_DOT: Char + + /** Decimal separator of the current locale. */ + val localeSeparator: Char + + fun format(format: NumberFormat, value: Double): String + + fun format(format: NumberFormat, value: Double, separator: Char): String +} diff --git a/core/data/src/commonMain/kotlin/app/aaps/core/data/format/NumberRounding.kt b/core/data/src/commonMain/kotlin/app/aaps/core/data/format/NumberRounding.kt new file mode 100644 index 000000000000..ed7cd4e45067 --- /dev/null +++ b/core/data/src/commonMain/kotlin/app/aaps/core/data/format/NumberRounding.kt @@ -0,0 +1,28 @@ +package app.aaps.core.data.format + +/** + * What to do with a value that sits exactly halfway between two renderable ones. + * + * Only reachable when the halfway point is exactly representable as a `Double`, which is why the + * choice matters far less often than it looks. Rounding to whole numbers has reachable ties, because + * `x.5` is a dyadic rational. Rounding to one decimal does not: a tie there would have to be + * `(2k+1)/20`, and the factor of 5 in the denominator means no `Double` ever lands on it. + */ +enum class NumberRounding { + + /** + * Ties go to the even neighbour: `0.5` renders as `0`, `1.5` as `2`. + * + * Banker's rounding. It exists to stop a bias from building up when many rounded values are + * summed, and it is the default because it is what `DecimalFormat` has always done here. + */ + HALF_EVEN, + + /** + * Ties go away from zero: `0.5` renders as `1`. + * + * What a reader expects from a single number on screen, so it is the right choice for a value + * shown on its own rather than one that will be added up. + */ + HALF_UP +} diff --git a/core/data/src/main/kotlin/app/aaps/core/data/iob/CobInfo.kt b/core/data/src/commonMain/kotlin/app/aaps/core/data/iob/CobInfo.kt similarity index 100% rename from core/data/src/main/kotlin/app/aaps/core/data/iob/CobInfo.kt rename to core/data/src/commonMain/kotlin/app/aaps/core/data/iob/CobInfo.kt diff --git a/core/data/src/main/kotlin/app/aaps/core/data/iob/InMemoryGlucoseValue.kt b/core/data/src/commonMain/kotlin/app/aaps/core/data/iob/InMemoryGlucoseValue.kt similarity index 100% rename from core/data/src/main/kotlin/app/aaps/core/data/iob/InMemoryGlucoseValue.kt rename to core/data/src/commonMain/kotlin/app/aaps/core/data/iob/InMemoryGlucoseValue.kt diff --git a/core/data/src/main/kotlin/app/aaps/core/data/iob/Iob.kt b/core/data/src/commonMain/kotlin/app/aaps/core/data/iob/Iob.kt similarity index 100% rename from core/data/src/main/kotlin/app/aaps/core/data/iob/Iob.kt rename to core/data/src/commonMain/kotlin/app/aaps/core/data/iob/Iob.kt diff --git a/core/data/src/commonMain/kotlin/app/aaps/core/data/json/OrgJsonCompat.kt b/core/data/src/commonMain/kotlin/app/aaps/core/data/json/OrgJsonCompat.kt new file mode 100644 index 000000000000..82032f9f2fec --- /dev/null +++ b/core/data/src/commonMain/kotlin/app/aaps/core/data/json/OrgJsonCompat.kt @@ -0,0 +1,147 @@ +package app.aaps.core.data.json + +import app.aaps.core.data.json.OrgJsonCompat.optStringCompat +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.booleanOrNull +import kotlinx.serialization.json.doubleOrNull +import kotlinx.serialization.json.longOrNull + +/** + * Accessors on kotlinx [JsonObject] that behave exactly like the `org.json` ones they replace. + * + * `org.json` is a JVM and Android API, so it cannot go to iOS. Code moving to `commonMain` has to + * stop using it, but the two libraries disagree about a missing key and nothing about that + * disagreement shows up at compile time: + * + * ``` + * doc.optString("k") // org.json : "" when absent, never null + * doc["k"]?.jsonPrimitive?.content // kotlinx : null when absent + * ``` + * + * A straight translation would silently flip every downstream `isEmpty()` check and `?:` fallback. + * These functions keep the old behaviour so the type swap changes nothing that can be observed, and + * `OrgJsonCompatParityTest` checks each one against a real `org.json` over a shared matrix of inputs. + * + * **This is a migration shim, not a design.** It deliberately copies quirks that are probably bugs - + * see [optStringCompat] returning the four letter text `null`. They are kept so the move off + * `org.json` is provably behaviour preserving. Fixing them is a separate, visible change. + * + * It used to live in `:plugins:sync`, which is Android only, on the argument that the quirks should + * stay out of shared modules. That argument stopped holding when `:core:interfaces` itself had to + * move: the profile spine (`Profile.toPureNsJson`, `ProfileStore`, `SingleProfile`) carries + * `JSONObject` and `JSONArray` in its **contracts**, so the shim has to be visible wherever those + * contracts are. + * + * Read this together with the divergences the parity test documents but does NOT hide - see the + * `writes differ` section there. Reading is safe to shim; writing changes the bytes on the wire. + */ +object OrgJsonCompat { + + /** + * Same as `org.json.JSONObject.optString(name)`. + * + * - missing key -> `""` + * - explicit JSON null -> the four letter text `"null"`, because `org.json` renders its + * `JSONObject.NULL` sentinel through `String.valueOf` + * - string -> the string itself, unquoted + * - number or boolean -> its text form + * - object or array -> its JSON text + * + * Never returns Kotlin null. Callers written against `org.json` rely on that, sometimes without + * meaning to. + */ + fun JsonObject.optStringCompat(key: String): String { + val element = this[key] ?: return "" + return when (element) { + is JsonNull -> "null" + is JsonPrimitive -> element.content + else -> element.toString() + } + } + + /** + * Same as `org.json.JSONObject.optJSONObject(name)`: null when the key is missing, when the + * value is an explicit JSON null, or when it holds anything that is not an object. + */ + fun JsonObject.optJsonObjectCompat(key: String): JsonObject? = this[key] as? JsonObject + + /** + * Same as `org.json.JSONObject.optLong(name, fallback)`. + * + * Coerces like `org.json` does: a numeric string is parsed, and a floating point value is + * truncated toward zero. Anything that cannot be read as a number gives [fallback]. + */ + fun JsonObject.optLongCompat(key: String, fallback: Long): Long { + val primitive = this[key] as? JsonPrimitive ?: return fallback + if (primitive is JsonNull) return fallback + primitive.longOrNull?.let { return it } + primitive.doubleOrNull?.let { return it.toLong() } + return fallback + } + + /** + * Same as `org.json.JSONObject.optBoolean(name)`: false unless the value is a real `true`, or + * the text `"true"` in any case. `org.json` accepts the string form too. + */ + fun JsonObject.optBooleanCompat(key: String): Boolean { + val primitive = this[key] as? JsonPrimitive ?: return false + if (primitive is JsonNull) return false + primitive.booleanOrNull?.let { return it } + return primitive.content.equals("true", ignoreCase = true) + } + + /** Same as `org.json.JSONObject.optJSONArray(name)`. */ + fun JsonObject.optJsonArrayCompat(key: String): JsonArray? = this[key] as? JsonArray + + /** + * Same as `org.json.JSONObject.optDouble(name, fallback)`. + * + * Needed by the profile spine, where every schedule value is a double. Coerces a numeric string + * the way `org.json` does. + */ + fun JsonObject.optDoubleCompat(key: String, fallback: Double): Double { + val primitive = this[key] as? JsonPrimitive ?: return fallback + if (primitive is JsonNull) return fallback + return primitive.doubleOrNull ?: fallback + } + + /** + * Same as `org.json.JSONObject.optInt(name, fallback)`, including its inconsistency about + * out-of-range values - which is why this reads more carefully than the others. + * + * `org.json` takes two different routes depending on how the value was written, and they + * disagree once the value does not fit in an `Int`: + * + * - a real JSON **number** is a boxed `Number`, and `org.json` calls `intValue()` on it, which + * TRUNCATES the low 32 bits - so `1785992181588` reads back as `-714213548` + * - a **quoted** number is text, and `org.json` falls back to `Double.intValue()`, which + * SATURATES - so `"1785992181588"` reads back as `2147483647` + * + * Reading both through `Long.toInt()` looks obviously right and is wrong for the quoted case: + * it silently turns a large positive number negative. The parity test catches exactly that. + */ + fun JsonObject.optIntCompat(key: String, fallback: Int): Int { + val primitive = this[key] as? JsonPrimitive ?: return fallback + if (primitive is JsonNull) return fallback + return if (primitive.isString) { + primitive.content.toIntOrNull() + ?: primitive.content.toDoubleOrNull()?.toInt() // saturates, like Double.intValue() + ?: fallback + } else { + primitive.longOrNull?.toInt() // truncates, like Long.intValue() + ?: primitive.doubleOrNull?.toInt() + ?: fallback + } + } + + /** + * Same as `org.json.JSONObject.has(name)`. + * + * Note this is `has`, not "has a usable value": `org.json` answers true for an explicit JSON + * null, and so does this. + */ + fun JsonObject.hasCompat(key: String): Boolean = containsKey(key) +} diff --git a/core/data/src/main/kotlin/app/aaps/core/data/model/ActiveSceneState.kt b/core/data/src/commonMain/kotlin/app/aaps/core/data/model/ActiveSceneState.kt similarity index 100% rename from core/data/src/main/kotlin/app/aaps/core/data/model/ActiveSceneState.kt rename to core/data/src/commonMain/kotlin/app/aaps/core/data/model/ActiveSceneState.kt diff --git a/core/data/src/main/kotlin/app/aaps/core/data/model/BCR.kt b/core/data/src/commonMain/kotlin/app/aaps/core/data/model/BCR.kt similarity index 96% rename from core/data/src/main/kotlin/app/aaps/core/data/model/BCR.kt rename to core/data/src/commonMain/kotlin/app/aaps/core/data/model/BCR.kt index df17cc9d09ae..58e47497c632 100644 --- a/core/data/src/main/kotlin/app/aaps/core/data/model/BCR.kt +++ b/core/data/src/commonMain/kotlin/app/aaps/core/data/model/BCR.kt @@ -1,6 +1,6 @@ package app.aaps.core.data.model -import java.util.TimeZone +import app.aaps.core.data.time.systemUtcOffsetAt data class BCR( override var id: Long = 0, @@ -10,7 +10,7 @@ data class BCR( override var referenceId: Long? = null, override var ids: IDs = IDs(), override var timestamp: Long, - var utcOffset: Long = TimeZone.getDefault().getOffset(timestamp).toLong(), + var utcOffset: Long = systemUtcOffsetAt(timestamp), var targetBGLow: Double, var targetBGHigh: Double, var isf: Double, diff --git a/core/data/src/main/kotlin/app/aaps/core/data/model/BS.kt b/core/data/src/commonMain/kotlin/app/aaps/core/data/model/BS.kt similarity index 92% rename from core/data/src/main/kotlin/app/aaps/core/data/model/BS.kt rename to core/data/src/commonMain/kotlin/app/aaps/core/data/model/BS.kt index 15c490b185cb..38ebd95fba5f 100644 --- a/core/data/src/main/kotlin/app/aaps/core/data/model/BS.kt +++ b/core/data/src/commonMain/kotlin/app/aaps/core/data/model/BS.kt @@ -1,6 +1,6 @@ package app.aaps.core.data.model -import java.util.TimeZone +import app.aaps.core.data.time.systemUtcOffsetAt data class BS( override var id: Long = 0, @@ -10,7 +10,7 @@ data class BS( override var referenceId: Long? = null, override var ids: IDs = IDs(), override var timestamp: Long, - var utcOffset: Long = TimeZone.getDefault().getOffset(timestamp).toLong(), + var utcOffset: Long = systemUtcOffsetAt(timestamp), var amount: Double, var type: Type, var notes: String? = null, diff --git a/core/data/src/commonMain/kotlin/app/aaps/core/data/model/BolusExtension.kt b/core/data/src/commonMain/kotlin/app/aaps/core/data/model/BolusExtension.kt new file mode 100644 index 000000000000..cc268700d566 --- /dev/null +++ b/core/data/src/commonMain/kotlin/app/aaps/core/data/model/BolusExtension.kt @@ -0,0 +1,14 @@ +package app.aaps.core.data.model + +import app.aaps.core.data.iob.Iob + +/** + * Insulin on board from this bolus at [time]. + * + * Lives next to the model because that is all it touches: the work is done by + * [ICfg.iobCalcForTreatment], which is already here. It used to sit in `:core:objects`, which made + * every chart that draws an insulin curve depend on that module. + */ +fun BS.iobCalc(time: Long): Iob = + if (!isValid || type == BS.Type.PRIMING) Iob() + else iCfg.iobCalcForTreatment(this, time) diff --git a/core/data/src/main/kotlin/app/aaps/core/data/model/BolusWizardData.kt b/core/data/src/commonMain/kotlin/app/aaps/core/data/model/BolusWizardData.kt similarity index 100% rename from core/data/src/main/kotlin/app/aaps/core/data/model/BolusWizardData.kt rename to core/data/src/commonMain/kotlin/app/aaps/core/data/model/BolusWizardData.kt diff --git a/core/data/src/main/kotlin/app/aaps/core/data/model/CA.kt b/core/data/src/commonMain/kotlin/app/aaps/core/data/model/CA.kt similarity index 92% rename from core/data/src/main/kotlin/app/aaps/core/data/model/CA.kt rename to core/data/src/commonMain/kotlin/app/aaps/core/data/model/CA.kt index 3c286fab03d9..93181686e755 100644 --- a/core/data/src/main/kotlin/app/aaps/core/data/model/CA.kt +++ b/core/data/src/commonMain/kotlin/app/aaps/core/data/model/CA.kt @@ -1,6 +1,6 @@ package app.aaps.core.data.model -import java.util.TimeZone +import app.aaps.core.data.time.systemUtcOffsetAt import kotlin.math.abs import kotlin.time.Duration.Companion.hours @@ -12,7 +12,7 @@ data class CA( override var referenceId: Long? = null, override var ids: IDs = IDs(), override var timestamp: Long, - var utcOffset: Long = TimeZone.getDefault().getOffset(timestamp).toLong(), + var utcOffset: Long = systemUtcOffsetAt(timestamp), /** Duration in milliseconds */ var duration: Long, var amount: Double, diff --git a/core/data/src/main/kotlin/app/aaps/core/data/model/CAL.kt b/core/data/src/commonMain/kotlin/app/aaps/core/data/model/CAL.kt similarity index 86% rename from core/data/src/main/kotlin/app/aaps/core/data/model/CAL.kt rename to core/data/src/commonMain/kotlin/app/aaps/core/data/model/CAL.kt index 0fe7da81ecda..6ae6d7a99d81 100644 --- a/core/data/src/main/kotlin/app/aaps/core/data/model/CAL.kt +++ b/core/data/src/commonMain/kotlin/app/aaps/core/data/model/CAL.kt @@ -1,6 +1,6 @@ package app.aaps.core.data.model -import java.util.TimeZone +import app.aaps.core.data.time.systemUtcOffsetAt /** * Calibration pair used by the linear calibration engine: a fingerstick reference value @@ -15,7 +15,7 @@ data class CAL( override var referenceId: Long? = null, override var ids: IDs = IDs(), override var timestamp: Long, - var utcOffset: Long = TimeZone.getDefault().getOffset(timestamp).toLong(), + var utcOffset: Long = systemUtcOffsetAt(timestamp), var fingerstickMgdl: Double, var sensorMgdlAtPairing: Double ) : HasIDs, TimeStamped diff --git a/core/data/src/main/kotlin/app/aaps/core/data/model/DS.kt b/core/data/src/commonMain/kotlin/app/aaps/core/data/model/DS.kt similarity index 79% rename from core/data/src/main/kotlin/app/aaps/core/data/model/DS.kt rename to core/data/src/commonMain/kotlin/app/aaps/core/data/model/DS.kt index dca4d7d898ef..a384fe252127 100644 --- a/core/data/src/main/kotlin/app/aaps/core/data/model/DS.kt +++ b/core/data/src/commonMain/kotlin/app/aaps/core/data/model/DS.kt @@ -1,12 +1,12 @@ package app.aaps.core.data.model -import java.util.TimeZone +import app.aaps.core.data.time.systemUtcOffsetAt data class DS( var id: Long = 0, var ids: IDs = IDs(), override var timestamp: Long, - var utcOffset: Long = TimeZone.getDefault().getOffset(timestamp).toLong(), + var utcOffset: Long = systemUtcOffsetAt(timestamp), var device: String? = null, var pump: String? = null, var enacted: String? = null, diff --git a/core/data/src/commonMain/kotlin/app/aaps/core/data/model/DevAssert.kt b/core/data/src/commonMain/kotlin/app/aaps/core/data/model/DevAssert.kt new file mode 100644 index 000000000000..ed082c90f4d9 --- /dev/null +++ b/core/data/src/commonMain/kotlin/app/aaps/core/data/model/DevAssert.kt @@ -0,0 +1,16 @@ +package app.aaps.core.data.model + +/** + * A developer check that behaves like `assert` on every target. + * + * `kotlin.assert` is **not in the common standard library**. The JVM has it, and Kotlin/Native has + * its own behind `@ExperimentalNativeApi`, but there is no common declaration - so `assert(...)` in + * `commonMain` type-checks in each target's own compilation and then fails the *metadata* + * compilation with "Unresolved reference 'assert'". That failure only appears once some other module + * consumes this one from its own `commonMain`, which is exactly when it matters. + * + * Kept as an assertion rather than turned into `require()`: JVM assertions are off in production and + * on under `-ea` in tests, while `require()` always throws. Swapping them would change behaviour in + * shipped code. + */ +expect fun devAssert(value: Boolean) diff --git a/core/data/src/commonMain/kotlin/app/aaps/core/data/model/DurationExtensions.kt b/core/data/src/commonMain/kotlin/app/aaps/core/data/model/DurationExtensions.kt new file mode 100644 index 000000000000..fe7f28177f98 --- /dev/null +++ b/core/data/src/commonMain/kotlin/app/aaps/core/data/model/DurationExtensions.kt @@ -0,0 +1,26 @@ +package app.aaps.core.data.model + +import app.aaps.core.data.time.T +import kotlin.math.min +import kotlin.math.roundToInt + +/** + * How long a temporary basal or extended bolus has been running, and how long it was meant to run. + * + * These live next to the models rather than in `:core:objects` because they read nothing but the + * record's own timestamp and duration. That matters beyond tidiness: `:core:objects` depends on + * `:core:ui`, so anything that stayed there could not be used from `:core:ui` without a dependency + * cycle - which is exactly what kept the `toStringFull` family from moving to the layer that draws. + */ + +/** Minutes elapsed between the start and [time], never counting past the end. */ +fun TB.getPassedDurationToTimeInMinutes(time: Long): Int = + ((min(time, end) - timestamp) / 60.0 / 1000).roundToInt() + +/** Minutes elapsed between the start and [time], never counting past the end. */ +fun EB.getPassedDurationToTimeInMinutes(time: Long): Int = + ((min(time, end) - timestamp) / 60.0 / 1000).roundToInt() + +/** The full planned length, in minutes. */ +val TB.durationInMinutes: Long + get() = T.msecs(duration).mins() diff --git a/core/data/src/main/kotlin/app/aaps/core/data/model/EB.kt b/core/data/src/commonMain/kotlin/app/aaps/core/data/model/EB.kt similarity index 91% rename from core/data/src/main/kotlin/app/aaps/core/data/model/EB.kt rename to core/data/src/commonMain/kotlin/app/aaps/core/data/model/EB.kt index 11669e2947b5..77f462e9af91 100644 --- a/core/data/src/main/kotlin/app/aaps/core/data/model/EB.kt +++ b/core/data/src/commonMain/kotlin/app/aaps/core/data/model/EB.kt @@ -1,6 +1,6 @@ package app.aaps.core.data.model -import java.util.TimeZone +import app.aaps.core.data.time.systemUtcOffsetAt data class EB( override var id: Long = 0, @@ -10,7 +10,7 @@ data class EB( override var referenceId: Long? = null, override var ids: IDs = IDs(), override var timestamp: Long, - var utcOffset: Long = TimeZone.getDefault().getOffset(timestamp).toLong(), + var utcOffset: Long = systemUtcOffsetAt(timestamp), /** Duration in milliseconds */ var duration: Long, var amount: Double, diff --git a/core/data/src/main/kotlin/app/aaps/core/data/model/EPS.kt b/core/data/src/commonMain/kotlin/app/aaps/core/data/model/EPS.kt similarity index 95% rename from core/data/src/main/kotlin/app/aaps/core/data/model/EPS.kt rename to core/data/src/commonMain/kotlin/app/aaps/core/data/model/EPS.kt index f6e14f45b6a2..24aec79fc856 100644 --- a/core/data/src/main/kotlin/app/aaps/core/data/model/EPS.kt +++ b/core/data/src/commonMain/kotlin/app/aaps/core/data/model/EPS.kt @@ -2,7 +2,7 @@ package app.aaps.core.data.model import app.aaps.core.data.model.data.Block import app.aaps.core.data.model.data.TargetBlock -import java.util.TimeZone +import app.aaps.core.data.time.systemUtcOffsetAt data class EPS( override var id: Long = 0, @@ -12,7 +12,7 @@ data class EPS( override var referenceId: Long? = null, override var ids: IDs = IDs(), override var timestamp: Long, - var utcOffset: Long = TimeZone.getDefault().getOffset(timestamp).toLong(), + var utcOffset: Long = systemUtcOffsetAt(timestamp), var basalBlocks: List, var isfBlocks: List, var icBlocks: List, diff --git a/core/data/src/main/kotlin/app/aaps/core/data/model/FD.kt b/core/data/src/commonMain/kotlin/app/aaps/core/data/model/FD.kt similarity index 100% rename from core/data/src/main/kotlin/app/aaps/core/data/model/FD.kt rename to core/data/src/commonMain/kotlin/app/aaps/core/data/model/FD.kt diff --git a/core/data/src/main/kotlin/app/aaps/core/data/model/GV.kt b/core/data/src/commonMain/kotlin/app/aaps/core/data/model/GV.kt similarity index 81% rename from core/data/src/main/kotlin/app/aaps/core/data/model/GV.kt rename to core/data/src/commonMain/kotlin/app/aaps/core/data/model/GV.kt index 0c6e6da546ce..ab8222294fc3 100644 --- a/core/data/src/main/kotlin/app/aaps/core/data/model/GV.kt +++ b/core/data/src/commonMain/kotlin/app/aaps/core/data/model/GV.kt @@ -1,6 +1,6 @@ package app.aaps.core.data.model -import java.util.TimeZone +import app.aaps.core.data.time.systemUtcOffsetAt data class GV( override var id: Long = 0, @@ -10,7 +10,7 @@ data class GV( override var referenceId: Long? = null, override var ids: IDs = IDs(), override var timestamp: Long, - var utcOffset: Long = TimeZone.getDefault().getOffset(timestamp).toLong(), + var utcOffset: Long = systemUtcOffsetAt(timestamp), var raw: Double?, var value: Double, var trendArrow: TrendArrow, diff --git a/core/data/src/main/kotlin/app/aaps/core/data/model/GlucoseUnit.kt b/core/data/src/commonMain/kotlin/app/aaps/core/data/model/GlucoseUnit.kt similarity index 71% rename from core/data/src/main/kotlin/app/aaps/core/data/model/GlucoseUnit.kt rename to core/data/src/commonMain/kotlin/app/aaps/core/data/model/GlucoseUnit.kt index 32259e8babf1..abba5fbf0239 100644 --- a/core/data/src/main/kotlin/app/aaps/core/data/model/GlucoseUnit.kt +++ b/core/data/src/commonMain/kotlin/app/aaps/core/data/model/GlucoseUnit.kt @@ -7,10 +7,11 @@ enum class GlucoseUnit(val asText: String) { MGDL("mg/dl"), MMOL("mmol"); - val displayLabel: String get() = when (this) { - MGDL -> "mg/dL" - MMOL -> "mmol/L" - } + val displayLabel: String + get() = when (this) { + MGDL -> "mg/dL" + MMOL -> "mmol/L" + } companion object { diff --git a/core/data/src/main/kotlin/app/aaps/core/data/model/HR.kt b/core/data/src/commonMain/kotlin/app/aaps/core/data/model/HR.kt similarity index 89% rename from core/data/src/main/kotlin/app/aaps/core/data/model/HR.kt rename to core/data/src/commonMain/kotlin/app/aaps/core/data/model/HR.kt index 296c35e99f5c..842968faf3a3 100644 --- a/core/data/src/main/kotlin/app/aaps/core/data/model/HR.kt +++ b/core/data/src/commonMain/kotlin/app/aaps/core/data/model/HR.kt @@ -1,6 +1,6 @@ package app.aaps.core.data.model -import java.util.TimeZone +import app.aaps.core.data.time.systemUtcOffsetAt /** Heart rate values measured by a user smartwatch or the like. */ data class HR( @@ -13,7 +13,7 @@ data class HR( var beatsPerMinute: Double, /** Source device that measured the heart rate. */ var device: String, - var utcOffset: Long = TimeZone.getDefault().getOffset(timestamp).toLong(), + var utcOffset: Long = systemUtcOffsetAt(timestamp), var version: Int = 0, var dateCreated: Long = -1, var isValid: Boolean = true, diff --git a/core/data/src/main/kotlin/app/aaps/core/data/model/HasIDs.kt b/core/data/src/commonMain/kotlin/app/aaps/core/data/model/HasIDs.kt similarity index 100% rename from core/data/src/main/kotlin/app/aaps/core/data/model/HasIDs.kt rename to core/data/src/commonMain/kotlin/app/aaps/core/data/model/HasIDs.kt diff --git a/core/data/src/main/kotlin/app/aaps/core/data/model/ICfg.kt b/core/data/src/commonMain/kotlin/app/aaps/core/data/model/ICfg.kt similarity index 97% rename from core/data/src/main/kotlin/app/aaps/core/data/model/ICfg.kt rename to core/data/src/commonMain/kotlin/app/aaps/core/data/model/ICfg.kt index 0b362a2e9b32..387bd8547746 100644 --- a/core/data/src/main/kotlin/app/aaps/core/data/model/ICfg.kt +++ b/core/data/src/commonMain/kotlin/app/aaps/core/data/model/ICfg.kt @@ -29,9 +29,10 @@ data class ICfg( constructor(insulinLabel: String, peak: Int, dia: Double, concentration: Double) : this(insulinLabel = insulinLabel, insulinEndTime = (dia * 3600 * 1000).toLong(), insulinPeakTime = (peak * 60000).toLong(), concentration = concentration) + /** - * Used in InsulinPlugin (insulin editor) - */ + * Used in InsulinPlugin (insulin editor) + */ fun isEqual(iCfg: ICfg?): Boolean { iCfg?.let { iCfg -> if (insulinLabel != iCfg.insulinLabel) @@ -46,6 +47,7 @@ data class ICfg( } return false } + /** * DIA (insulinEndTime) in hours rounded to 1 decimal place */ @@ -96,8 +98,8 @@ data class ICfg( fun deepClone(): ICfg = ICfg(insulinLabel, insulinEndTime, insulinPeakTime, concentration).also { it.insulinNickname = insulinNickname } fun iobCalcForTreatment(bolus: BS, time: Long): Iob { - assert(insulinEndTime != 0L) - assert(insulinPeakTime != 0L) + devAssert(insulinEndTime != 0L) + devAssert(insulinPeakTime != 0L) val result = Iob() if (bolus.amount != 0.0) { val bolusTime = bolus.timestamp @@ -124,6 +126,7 @@ data class ICfg( } companion object { + // Math-validity floors for iobCalcForTreatment. They only engage for corrupt/degenerate iCfg // and are no-ops for real configs; they are NOT the medical HardLimits. private const val MIN_DIA_MINUTES = 300.0 // 5 h (mirrors HardLimits.MIN_DIA); floors corrupt/sentinel DIA <= 0 diff --git a/core/data/src/main/kotlin/app/aaps/core/data/model/IDs.kt b/core/data/src/commonMain/kotlin/app/aaps/core/data/model/IDs.kt similarity index 100% rename from core/data/src/main/kotlin/app/aaps/core/data/model/IDs.kt rename to core/data/src/commonMain/kotlin/app/aaps/core/data/model/IDs.kt diff --git a/core/data/src/main/kotlin/app/aaps/core/data/model/NE.kt b/core/data/src/commonMain/kotlin/app/aaps/core/data/model/NE.kt similarity index 100% rename from core/data/src/main/kotlin/app/aaps/core/data/model/NE.kt rename to core/data/src/commonMain/kotlin/app/aaps/core/data/model/NE.kt diff --git a/core/data/src/main/kotlin/app/aaps/core/data/model/PS.kt b/core/data/src/commonMain/kotlin/app/aaps/core/data/model/PS.kt similarity index 95% rename from core/data/src/main/kotlin/app/aaps/core/data/model/PS.kt rename to core/data/src/commonMain/kotlin/app/aaps/core/data/model/PS.kt index 3f6bd5be0c7e..1549e728c968 100644 --- a/core/data/src/main/kotlin/app/aaps/core/data/model/PS.kt +++ b/core/data/src/commonMain/kotlin/app/aaps/core/data/model/PS.kt @@ -2,7 +2,7 @@ package app.aaps.core.data.model import app.aaps.core.data.model.data.Block import app.aaps.core.data.model.data.TargetBlock -import java.util.TimeZone +import app.aaps.core.data.time.systemUtcOffsetAt data class PS( override var id: Long = 0, @@ -12,7 +12,7 @@ data class PS( override var referenceId: Long? = null, override var ids: IDs = IDs(), override var timestamp: Long, - var utcOffset: Long = TimeZone.getDefault().getOffset(timestamp).toLong(), + var utcOffset: Long = systemUtcOffsetAt(timestamp), var basalBlocks: List, var isfBlocks: List, var icBlocks: List, diff --git a/core/data/src/main/kotlin/app/aaps/core/data/model/RM.kt b/core/data/src/commonMain/kotlin/app/aaps/core/data/model/RM.kt similarity index 96% rename from core/data/src/main/kotlin/app/aaps/core/data/model/RM.kt rename to core/data/src/commonMain/kotlin/app/aaps/core/data/model/RM.kt index cf30b4213941..b1da8e025979 100644 --- a/core/data/src/main/kotlin/app/aaps/core/data/model/RM.kt +++ b/core/data/src/commonMain/kotlin/app/aaps/core/data/model/RM.kt @@ -1,6 +1,6 @@ package app.aaps.core.data.model -import java.util.TimeZone +import app.aaps.core.data.time.systemUtcOffsetAt data class RM( override var id: Long = 0, @@ -10,7 +10,7 @@ data class RM( override var referenceId: Long? = null, override var ids: IDs = IDs(), override var timestamp: Long, - var utcOffset: Long = TimeZone.getDefault().getOffset(timestamp).toLong(), + var utcOffset: Long = systemUtcOffsetAt(timestamp), /** Current running mode. */ var mode: Mode, /** Planned duration in milliseconds */ diff --git a/core/data/src/main/kotlin/app/aaps/core/data/model/SC.kt b/core/data/src/commonMain/kotlin/app/aaps/core/data/model/SC.kt similarity index 92% rename from core/data/src/main/kotlin/app/aaps/core/data/model/SC.kt rename to core/data/src/commonMain/kotlin/app/aaps/core/data/model/SC.kt index b03a68eb3317..3116ff38d2fd 100644 --- a/core/data/src/main/kotlin/app/aaps/core/data/model/SC.kt +++ b/core/data/src/commonMain/kotlin/app/aaps/core/data/model/SC.kt @@ -1,6 +1,6 @@ package app.aaps.core.data.model -import java.util.TimeZone +import app.aaps.core.data.time.systemUtcOffsetAt /** Steps count values measured by a user smartwatch or the like. */ data class SC( @@ -18,7 +18,7 @@ data class SC( var steps180min: Int, /** Source device that measured the steps count. */ var device: String, - var utcOffset: Long = TimeZone.getDefault().getOffset(timestamp).toLong(), + var utcOffset: Long = systemUtcOffsetAt(timestamp), override var version: Int = 0, override var dateCreated: Long = -1, override var isValid: Boolean = true, diff --git a/core/data/src/main/kotlin/app/aaps/core/data/model/Scene.kt b/core/data/src/commonMain/kotlin/app/aaps/core/data/model/Scene.kt similarity index 100% rename from core/data/src/main/kotlin/app/aaps/core/data/model/Scene.kt rename to core/data/src/commonMain/kotlin/app/aaps/core/data/model/Scene.kt diff --git a/core/data/src/main/kotlin/app/aaps/core/data/model/SceneAction.kt b/core/data/src/commonMain/kotlin/app/aaps/core/data/model/SceneAction.kt similarity index 100% rename from core/data/src/main/kotlin/app/aaps/core/data/model/SceneAction.kt rename to core/data/src/commonMain/kotlin/app/aaps/core/data/model/SceneAction.kt diff --git a/core/data/src/main/kotlin/app/aaps/core/data/model/SceneEndAction.kt b/core/data/src/commonMain/kotlin/app/aaps/core/data/model/SceneEndAction.kt similarity index 100% rename from core/data/src/main/kotlin/app/aaps/core/data/model/SceneEndAction.kt rename to core/data/src/commonMain/kotlin/app/aaps/core/data/model/SceneEndAction.kt diff --git a/core/data/src/main/kotlin/app/aaps/core/data/model/SceneLifecycle.kt b/core/data/src/commonMain/kotlin/app/aaps/core/data/model/SceneLifecycle.kt similarity index 100% rename from core/data/src/main/kotlin/app/aaps/core/data/model/SceneLifecycle.kt rename to core/data/src/commonMain/kotlin/app/aaps/core/data/model/SceneLifecycle.kt diff --git a/core/data/src/main/kotlin/app/aaps/core/data/model/SourceSensor.kt b/core/data/src/commonMain/kotlin/app/aaps/core/data/model/SourceSensor.kt similarity index 100% rename from core/data/src/main/kotlin/app/aaps/core/data/model/SourceSensor.kt rename to core/data/src/commonMain/kotlin/app/aaps/core/data/model/SourceSensor.kt diff --git a/core/data/src/main/kotlin/app/aaps/core/data/model/SourceSensorExtensions.kt b/core/data/src/commonMain/kotlin/app/aaps/core/data/model/SourceSensorExtensions.kt similarity index 100% rename from core/data/src/main/kotlin/app/aaps/core/data/model/SourceSensorExtensions.kt rename to core/data/src/commonMain/kotlin/app/aaps/core/data/model/SourceSensorExtensions.kt diff --git a/core/data/src/main/kotlin/app/aaps/core/data/model/TB.kt b/core/data/src/commonMain/kotlin/app/aaps/core/data/model/TB.kt similarity index 86% rename from core/data/src/main/kotlin/app/aaps/core/data/model/TB.kt rename to core/data/src/commonMain/kotlin/app/aaps/core/data/model/TB.kt index 08e89c8e225e..36c2dc3d5e75 100644 --- a/core/data/src/main/kotlin/app/aaps/core/data/model/TB.kt +++ b/core/data/src/commonMain/kotlin/app/aaps/core/data/model/TB.kt @@ -1,6 +1,6 @@ package app.aaps.core.data.model -import java.util.TimeZone +import app.aaps.core.data.time.systemUtcOffsetAt data class TB( override var id: Long = 0, @@ -10,7 +10,7 @@ data class TB( override var referenceId: Long? = null, override var ids: IDs = IDs(), override var timestamp: Long, - var utcOffset: Long = TimeZone.getDefault().getOffset(timestamp).toLong(), + var utcOffset: Long = systemUtcOffsetAt(timestamp), var type: Type, var isAbsolute: Boolean, var rate: Double, @@ -51,9 +51,6 @@ data class TB( } } - val isInProgress: Boolean - get() = System.currentTimeMillis() in timestamp..timestamp + duration - val end get() = timestamp + duration diff --git a/core/data/src/main/kotlin/app/aaps/core/data/model/TDD.kt b/core/data/src/commonMain/kotlin/app/aaps/core/data/model/TDD.kt similarity index 86% rename from core/data/src/main/kotlin/app/aaps/core/data/model/TDD.kt rename to core/data/src/commonMain/kotlin/app/aaps/core/data/model/TDD.kt index ad697fdf5438..2af742b53242 100644 --- a/core/data/src/main/kotlin/app/aaps/core/data/model/TDD.kt +++ b/core/data/src/commonMain/kotlin/app/aaps/core/data/model/TDD.kt @@ -1,6 +1,6 @@ package app.aaps.core.data.model -import java.util.TimeZone +import app.aaps.core.data.time.systemUtcOffsetAt data class TDD( override var id: Long = 0, @@ -10,7 +10,7 @@ data class TDD( override var referenceId: Long? = null, override var ids: IDs = IDs(), override var timestamp: Long, - var utcOffset: Long = TimeZone.getDefault().getOffset(timestamp).toLong(), + var utcOffset: Long = systemUtcOffsetAt(timestamp), var basalAmount: Double = 0.0, var bolusAmount: Double = 0.0, var totalAmount: Double = 0.0, // if zero it's calculated as basalAmount + bolusAmount diff --git a/core/data/src/main/kotlin/app/aaps/core/data/model/TE.kt b/core/data/src/commonMain/kotlin/app/aaps/core/data/model/TE.kt similarity index 98% rename from core/data/src/main/kotlin/app/aaps/core/data/model/TE.kt rename to core/data/src/commonMain/kotlin/app/aaps/core/data/model/TE.kt index f4c168e4c3fd..8ee4ec950635 100644 --- a/core/data/src/main/kotlin/app/aaps/core/data/model/TE.kt +++ b/core/data/src/commonMain/kotlin/app/aaps/core/data/model/TE.kt @@ -1,6 +1,6 @@ package app.aaps.core.data.model -import java.util.TimeZone +import app.aaps.core.data.time.systemUtcOffsetAt data class TE( override var id: Long = 0, @@ -10,7 +10,7 @@ data class TE( override var referenceId: Long? = null, override var ids: IDs = IDs(), override var timestamp: Long, - var utcOffset: Long = TimeZone.getDefault().getOffset(timestamp).toLong(), + var utcOffset: Long = systemUtcOffsetAt(timestamp), /** Duration in milliseconds */ var duration: Long = 0, var type: Type, diff --git a/core/data/src/main/kotlin/app/aaps/core/data/model/TT.kt b/core/data/src/commonMain/kotlin/app/aaps/core/data/model/TT.kt similarity index 93% rename from core/data/src/main/kotlin/app/aaps/core/data/model/TT.kt rename to core/data/src/commonMain/kotlin/app/aaps/core/data/model/TT.kt index 3e2ff6480cf6..d4fe67ca603e 100644 --- a/core/data/src/main/kotlin/app/aaps/core/data/model/TT.kt +++ b/core/data/src/commonMain/kotlin/app/aaps/core/data/model/TT.kt @@ -1,6 +1,6 @@ package app.aaps.core.data.model -import java.util.TimeZone +import app.aaps.core.data.time.systemUtcOffsetAt data class TT( override var id: Long = 0, @@ -10,7 +10,7 @@ data class TT( override var referenceId: Long? = null, override var ids: IDs = IDs(), override var timestamp: Long, - var utcOffset: Long = TimeZone.getDefault().getOffset(timestamp).toLong(), + var utcOffset: Long = systemUtcOffsetAt(timestamp), var reason: Reason, var highTarget: Double, // in mgdl var lowTarget: Double, // in mgdl diff --git a/core/data/src/main/kotlin/app/aaps/core/data/model/TTPreset.kt b/core/data/src/commonMain/kotlin/app/aaps/core/data/model/TTPreset.kt similarity index 100% rename from core/data/src/main/kotlin/app/aaps/core/data/model/TTPreset.kt rename to core/data/src/commonMain/kotlin/app/aaps/core/data/model/TTPreset.kt diff --git a/core/data/src/main/kotlin/app/aaps/core/data/model/TimeStamped.kt b/core/data/src/commonMain/kotlin/app/aaps/core/data/model/TimeStamped.kt similarity index 100% rename from core/data/src/main/kotlin/app/aaps/core/data/model/TimeStamped.kt rename to core/data/src/commonMain/kotlin/app/aaps/core/data/model/TimeStamped.kt diff --git a/core/data/src/main/kotlin/app/aaps/core/data/model/TrendArrow.kt b/core/data/src/commonMain/kotlin/app/aaps/core/data/model/TrendArrow.kt similarity index 100% rename from core/data/src/main/kotlin/app/aaps/core/data/model/TrendArrow.kt rename to core/data/src/commonMain/kotlin/app/aaps/core/data/model/TrendArrow.kt diff --git a/core/data/src/main/kotlin/app/aaps/core/data/model/UE.kt b/core/data/src/commonMain/kotlin/app/aaps/core/data/model/UE.kt similarity index 76% rename from core/data/src/main/kotlin/app/aaps/core/data/model/UE.kt rename to core/data/src/commonMain/kotlin/app/aaps/core/data/model/UE.kt index 1bfed86b6f31..31e6feb21f52 100644 --- a/core/data/src/main/kotlin/app/aaps/core/data/model/UE.kt +++ b/core/data/src/commonMain/kotlin/app/aaps/core/data/model/UE.kt @@ -1,14 +1,14 @@ package app.aaps.core.data.model +import app.aaps.core.data.time.systemUtcOffsetAt import app.aaps.core.data.ue.Action import app.aaps.core.data.ue.Sources import app.aaps.core.data.ue.ValueWithUnit -import java.util.TimeZone data class UE( var id: Long = 0L, override var timestamp: Long, - var utcOffset: Long = TimeZone.getDefault().getOffset(timestamp).toLong(), + var utcOffset: Long = systemUtcOffsetAt(timestamp), var action: Action, var source: Sources, var note: String, diff --git a/core/data/src/main/kotlin/app/aaps/core/data/model/data/Block.kt b/core/data/src/commonMain/kotlin/app/aaps/core/data/model/data/Block.kt similarity index 79% rename from core/data/src/main/kotlin/app/aaps/core/data/model/data/Block.kt rename to core/data/src/commonMain/kotlin/app/aaps/core/data/model/data/Block.kt index 7a009a8d6638..2325226735c6 100644 --- a/core/data/src/main/kotlin/app/aaps/core/data/model/data/Block.kt +++ b/core/data/src/commonMain/kotlin/app/aaps/core/data/model/data/Block.kt @@ -2,7 +2,7 @@ package app.aaps.core.data.model.data import kotlin.time.Duration.Companion.days -data class Block(var duration: Long, var amount: Double) +data class Block(val duration: Long, val amount: Double) fun List.checkSanity(): Boolean { var sum = 0L diff --git a/core/data/src/main/kotlin/app/aaps/core/data/model/data/TargetBlock.kt b/core/data/src/commonMain/kotlin/app/aaps/core/data/model/data/TargetBlock.kt similarity index 71% rename from core/data/src/main/kotlin/app/aaps/core/data/model/data/TargetBlock.kt rename to core/data/src/commonMain/kotlin/app/aaps/core/data/model/data/TargetBlock.kt index 24bed37e01c2..c152e33f190f 100644 --- a/core/data/src/main/kotlin/app/aaps/core/data/model/data/TargetBlock.kt +++ b/core/data/src/commonMain/kotlin/app/aaps/core/data/model/data/TargetBlock.kt @@ -2,7 +2,7 @@ package app.aaps.core.data.model.data import kotlin.time.Duration.Companion.days -data class TargetBlock(var duration: Long, var lowTarget: Double, var highTarget: Double) +data class TargetBlock(val duration: Long, val lowTarget: Double, val highTarget: Double) fun List.checkSanity(): Boolean { var sum = 0L diff --git a/core/data/src/main/kotlin/app/aaps/core/data/plugin/PluginType.kt b/core/data/src/commonMain/kotlin/app/aaps/core/data/plugin/PluginType.kt similarity index 100% rename from core/data/src/main/kotlin/app/aaps/core/data/plugin/PluginType.kt rename to core/data/src/commonMain/kotlin/app/aaps/core/data/plugin/PluginType.kt diff --git a/core/data/src/main/kotlin/app/aaps/core/data/pump/defs/Capability.kt b/core/data/src/commonMain/kotlin/app/aaps/core/data/pump/defs/Capability.kt similarity index 100% rename from core/data/src/main/kotlin/app/aaps/core/data/pump/defs/Capability.kt rename to core/data/src/commonMain/kotlin/app/aaps/core/data/pump/defs/Capability.kt diff --git a/core/data/src/main/kotlin/app/aaps/core/data/pump/defs/DoseSettings.kt b/core/data/src/commonMain/kotlin/app/aaps/core/data/pump/defs/DoseSettings.kt similarity index 100% rename from core/data/src/main/kotlin/app/aaps/core/data/pump/defs/DoseSettings.kt rename to core/data/src/commonMain/kotlin/app/aaps/core/data/pump/defs/DoseSettings.kt diff --git a/core/data/src/main/kotlin/app/aaps/core/data/pump/defs/DoseStepSize.kt b/core/data/src/commonMain/kotlin/app/aaps/core/data/pump/defs/DoseStepSize.kt similarity index 81% rename from core/data/src/main/kotlin/app/aaps/core/data/pump/defs/DoseStepSize.kt rename to core/data/src/commonMain/kotlin/app/aaps/core/data/pump/defs/DoseStepSize.kt index e0078146a641..8c3a6bc97ad8 100644 --- a/core/data/src/main/kotlin/app/aaps/core/data/pump/defs/DoseStepSize.kt +++ b/core/data/src/commonMain/kotlin/app/aaps/core/data/pump/defs/DoseStepSize.kt @@ -1,6 +1,7 @@ package app.aaps.core.data.pump.defs -import java.util.Locale +import app.aaps.core.data.format.NumberFormat +import app.aaps.core.data.format.NumberFormatPlatform enum class DoseStepSize(private val entries: Array) { @@ -55,15 +56,18 @@ enum class DoseStepSize(private val entries: Array) { for (entry in entries) { if (first) first = false else sb.append(", ") - sb.append(String.format(Locale.ENGLISH, "%.3f", entry.value)) + sb.append(entry.value.dotted()) .append(" {") - .append(String.format(Locale.ENGLISH, "%.3f", entry.from)) + .append(entry.from.dotted()) .append("-") if (entry.to == Double.MAX_VALUE) sb.append("~}") - else sb.append(String.format(Locale.ENGLISH, "%.3f", entry.to)).append("}") + else sb.append(entry.to.dotted()).append("}") } }.toString() + /** Three decimals with a dot, whatever the device locale is. This text goes into logs. */ + private fun Double.dotted(): String = NumberFormat.DECIMAL_3.format(this, NumberFormatPlatform.SEPARATOR_DOT) + // to = this value is not included, but would actually mean <, so for rates between 0.025-0.975 u/h, we would have [from=0, to=10] internal class DoseStepSizeEntry(var from: Double, var to: Double, var value: Double) diff --git a/core/data/src/main/kotlin/app/aaps/core/data/pump/defs/ManufacturerType.kt b/core/data/src/commonMain/kotlin/app/aaps/core/data/pump/defs/ManufacturerType.kt similarity index 100% rename from core/data/src/main/kotlin/app/aaps/core/data/pump/defs/ManufacturerType.kt rename to core/data/src/commonMain/kotlin/app/aaps/core/data/pump/defs/ManufacturerType.kt diff --git a/core/data/src/main/kotlin/app/aaps/core/data/pump/defs/PumpCapability.kt b/core/data/src/commonMain/kotlin/app/aaps/core/data/pump/defs/PumpCapability.kt similarity index 100% rename from core/data/src/main/kotlin/app/aaps/core/data/pump/defs/PumpCapability.kt rename to core/data/src/commonMain/kotlin/app/aaps/core/data/pump/defs/PumpCapability.kt diff --git a/core/data/src/main/kotlin/app/aaps/core/data/pump/defs/PumpDescription.kt b/core/data/src/commonMain/kotlin/app/aaps/core/data/pump/defs/PumpDescription.kt similarity index 100% rename from core/data/src/main/kotlin/app/aaps/core/data/pump/defs/PumpDescription.kt rename to core/data/src/commonMain/kotlin/app/aaps/core/data/pump/defs/PumpDescription.kt diff --git a/core/data/src/main/kotlin/app/aaps/core/data/pump/defs/PumpTempBasalType.kt b/core/data/src/commonMain/kotlin/app/aaps/core/data/pump/defs/PumpTempBasalType.kt similarity index 100% rename from core/data/src/main/kotlin/app/aaps/core/data/pump/defs/PumpTempBasalType.kt rename to core/data/src/commonMain/kotlin/app/aaps/core/data/pump/defs/PumpTempBasalType.kt diff --git a/core/data/src/main/kotlin/app/aaps/core/data/pump/defs/PumpType.kt b/core/data/src/commonMain/kotlin/app/aaps/core/data/pump/defs/PumpType.kt similarity index 100% rename from core/data/src/main/kotlin/app/aaps/core/data/pump/defs/PumpType.kt rename to core/data/src/commonMain/kotlin/app/aaps/core/data/pump/defs/PumpType.kt diff --git a/core/data/src/main/kotlin/app/aaps/core/data/pump/defs/TimeChangeType.kt b/core/data/src/commonMain/kotlin/app/aaps/core/data/pump/defs/TimeChangeType.kt similarity index 100% rename from core/data/src/main/kotlin/app/aaps/core/data/pump/defs/TimeChangeType.kt rename to core/data/src/commonMain/kotlin/app/aaps/core/data/pump/defs/TimeChangeType.kt diff --git a/core/data/src/commonMain/kotlin/app/aaps/core/data/time/SystemTimeZone.kt b/core/data/src/commonMain/kotlin/app/aaps/core/data/time/SystemTimeZone.kt new file mode 100644 index 000000000000..121ea8d00d9d --- /dev/null +++ b/core/data/src/commonMain/kotlin/app/aaps/core/data/time/SystemTimeZone.kt @@ -0,0 +1,13 @@ +package app.aaps.core.data.time + +/** + * Time zone offset in milliseconds for a given moment, DST aware. + * + * The records keep this as `utcOffset`. It is written to the database, it takes part in + * `contentEqualsTo` (so a changed value makes a record look different and sync again), it is sent to + * Nightscout - which validates it and answers 400 when it is wrong - and it goes to Open Humans. + * So the value has to stay exactly what the old `TimeZone.getDefault().getOffset(timestamp)` + * produced. Keeping it as a platform function does that at every call site at once, without + * touching the models or the ~154 places that already pass `utcOffset` themselves. + */ +expect fun systemUtcOffsetAt(timestamp: Long): Long diff --git a/core/data/src/main/kotlin/app/aaps/core/data/time/T.kt b/core/data/src/commonMain/kotlin/app/aaps/core/data/time/T.kt similarity index 100% rename from core/data/src/main/kotlin/app/aaps/core/data/time/T.kt rename to core/data/src/commonMain/kotlin/app/aaps/core/data/time/T.kt diff --git a/core/data/src/main/kotlin/app/aaps/core/data/ue/Action.kt b/core/data/src/commonMain/kotlin/app/aaps/core/data/ue/Action.kt similarity index 100% rename from core/data/src/main/kotlin/app/aaps/core/data/ue/Action.kt rename to core/data/src/commonMain/kotlin/app/aaps/core/data/ue/Action.kt diff --git a/core/data/src/main/kotlin/app/aaps/core/data/ue/Sources.kt b/core/data/src/commonMain/kotlin/app/aaps/core/data/ue/Sources.kt similarity index 100% rename from core/data/src/main/kotlin/app/aaps/core/data/ue/Sources.kt rename to core/data/src/commonMain/kotlin/app/aaps/core/data/ue/Sources.kt diff --git a/core/data/src/main/kotlin/app/aaps/core/data/ue/ValueWithUnit.kt b/core/data/src/commonMain/kotlin/app/aaps/core/data/ue/ValueWithUnit.kt similarity index 100% rename from core/data/src/main/kotlin/app/aaps/core/data/ue/ValueWithUnit.kt rename to core/data/src/commonMain/kotlin/app/aaps/core/data/ue/ValueWithUnit.kt diff --git a/core/data/src/main/kotlin/app/aaps/core/data/ui/ConfirmationLine.kt b/core/data/src/commonMain/kotlin/app/aaps/core/data/ui/ConfirmationLine.kt similarity index 100% rename from core/data/src/main/kotlin/app/aaps/core/data/ui/ConfirmationLine.kt rename to core/data/src/commonMain/kotlin/app/aaps/core/data/ui/ConfirmationLine.kt diff --git a/core/data/src/commonTest/kotlin/app/aaps/core/data/format/NumberFormatParityTest.kt b/core/data/src/commonTest/kotlin/app/aaps/core/data/format/NumberFormatParityTest.kt new file mode 100644 index 000000000000..159790dbffbf --- /dev/null +++ b/core/data/src/commonTest/kotlin/app/aaps/core/data/format/NumberFormatParityTest.kt @@ -0,0 +1,127 @@ +package app.aaps.core.data.format + +import app.aaps.core.data.format.NumberFormatPlatform.SEPARATOR_DOT +import kotlin.test.Test +import kotlin.test.assertEquals + +/** + * The same expected text on every platform. + * + * [NumberFormatPlatform] is an `expect object`, so each target formats numbers with its own + * library - `java.text.DecimalFormat` on the JVM, `NSNumberFormatter` on Apple, hand written + * arithmetic on mingw. They are supposed to agree. Nothing checked that until now: `NumberFormatTest` + * pins the JVM against real `DecimalFormat` and is a fine oracle, but it is a `jvmTest` and cannot + * say anything about the others. + * + * These are literal expectations rather than a comparison, so they work on a target that has no JVM + * to compare against. On Windows they run for JVM and mingw today; on a machine that can run Apple + * tests the same file checks `NSNumberFormatter` with no extra work. That is the point - the trap is + * armed before the platform that might spring it exists. + * + * Every case passes [SEPARATOR_DOT] explicitly, so the decimal separator cannot vary with the + * machine's locale. What is still assumed is that the default locale uses **ASCII digits**; a device + * set to Arabic renders Arabic-Indic digits by design, and that is the intended behaviour rather + * than something to pin here. + */ +class NumberFormatParityTest { + + @Test + fun `no grouping above a thousand`() { + // The bug wave 1 fixed: DecimalFormat() groups by default, so this used to be "1,234.5" on + // en and "1.234,5" on de. Grouping is off everywhere now. + assertEquals("1234.5", NumberFormat.DECIMAL_1.format(1234.5, SEPARATOR_DOT)) + assertEquals("1234567", NumberFormat.INTEGER.format(1234567.0, SEPARATOR_DOT)) + } + + @Test + fun `rounding is half even`() { + // Half-even is what DecimalFormat does, and what NSNumberFormatterRoundHalfEven must match: + // an exact tie goes to the nearest EVEN digit, so 2.5 rounds down and 3.5 rounds up. + // + // Every value here is exactly representable as a double (2.5, 3.5, 0.25, 0.125, 0.375 are + // all sums of powers of two), which is deliberate - see the note below. + assertEquals("2", NumberFormat.INTEGER.format(2.5, SEPARATOR_DOT)) + assertEquals("4", NumberFormat.INTEGER.format(3.5, SEPARATOR_DOT)) + assertEquals("0.2", NumberFormat.DECIMAL_1.format(0.25, SEPARATOR_DOT)) + assertEquals("0.12", NumberFormat.DECIMAL_2.format(0.125, SEPARATOR_DOT)) + assertEquals("0.38", NumberFormat.DECIMAL_2.format(0.375, SEPARATOR_DOT)) + } + + /** + * A value that only *looks* like a tie is not part of this contract, because the platforms + * genuinely disagree and the first version of this test caught it. + * + * `0.35` is not representable as a double - the nearest one is `0.34999999999999997779...`, + * just below the midpoint. `DecimalFormat` works from that true decimal value and rounds **down** + * to `0.3`. The mingw actual multiplies by ten first, and `0.35 * 10.0 == 3.5` exactly in + * IEEE-754 arithmetic, so it sees a perfect tie, applies half-even, and produces `0.4`. + * + * Neither is a bug in the tests. `DecimalFormat` is the reference - it is what every existing + * AAPS number has been rendered with - so a platform that differs here is the one that is wrong. + * mingw is explicitly an experiment rather than a shipping target and is left as it is, but + * **`NSNumberFormatter` must be checked against this case on a Mac before an iOS client ships**, + * because unlike mingw it would be rendering real doses. + */ + @Test + fun `values that are not exactly representable are deliberately not pinned`() { + // Asserting only that it produces one decimal, not which one. + val text = NumberFormat.DECIMAL_1.format(0.35, SEPARATOR_DOT) + assertEquals(3, text.length, "expected a single decimal digit but got '$text'") + } + + @Test + fun `minimum fraction digits are padded`() { + assertEquals("1.0", NumberFormat.DECIMAL_1.format(1.0, SEPARATOR_DOT)) + assertEquals("1.00", NumberFormat.DECIMAL_2.format(1.0, SEPARATOR_DOT)) + assertEquals("1.000", NumberFormat.DECIMAL_3.format(1.0, SEPARATOR_DOT)) + assertEquals("0.50", NumberFormat.DECIMAL_2.format(0.5, SEPARATOR_DOT)) + } + + @Test + fun `minimum integer digits are padded`() { + assertEquals("05", NumberFormat.INTEGER_2_DIGITS.format(5.0, SEPARATOR_DOT)) + assertEquals("12", NumberFormat.INTEGER_2_DIGITS.format(12.0, SEPARATOR_DOT)) + } + + @Test + fun `trailing zeros above the minimum are dropped`() { + assertEquals("1", NumberFormat.UP_TO_2_DECIMALS.format(1.0, SEPARATOR_DOT)) + assertEquals("1.5", NumberFormat.UP_TO_2_DECIMALS.format(1.5, SEPARATOR_DOT)) + assertEquals("1.25", NumberFormat.UP_TO_2_DECIMALS.format(1.25, SEPARATOR_DOT)) + // one decimal always, a second only when it is not zero + assertEquals("1.0", NumberFormat.DECIMAL_1_UP_TO_2.format(1.0, SEPARATOR_DOT)) + assertEquals("1.25", NumberFormat.DECIMAL_1_UP_TO_2.format(1.25, SEPARATOR_DOT)) + } + + @Test + fun `negative numbers keep their sign`() { + assertEquals("-1.5", NumberFormat.DECIMAL_1.format(-1.5, SEPARATOR_DOT)) + assertEquals("-1234.5", NumberFormat.DECIMAL_1.format(-1234.5, SEPARATOR_DOT)) + assertEquals("-5", NumberFormat.INTEGER.format(-5.0, SEPARATOR_DOT)) + } + + @Test + fun `zero formats without a sign`() { + assertEquals("0", NumberFormat.INTEGER.format(0.0, SEPARATOR_DOT)) + assertEquals("0.0", NumberFormat.DECIMAL_1.format(0.0, SEPARATOR_DOT)) + } + + @Test + fun `insulin and glucose values a user actually sees`() { + // The shapes that reach the screen and Nightscout, so a platform difference here would be + // visible rather than theoretical. + assertEquals("1.25", NumberFormat.DECIMAL_2.format(1.25, SEPARATOR_DOT)) + assertEquals("0.05", NumberFormat.DECIMAL_2.format(0.05, SEPARATOR_DOT)) + assertEquals("5.5", NumberFormat.DECIMAL_1.format(5.5, SEPARATOR_DOT)) + assertEquals("120", NumberFormat.INTEGER.format(120.0, SEPARATOR_DOT)) + assertEquals("0.001", NumberFormat.DECIMAL_3.format(0.001, SEPARATOR_DOT)) + } + + @Test + fun `the explicit dot separator does not depend on the locale`() { + // Whatever the machine's locale is, asking for a dot gives a dot. This is what makes server + // data and exported files stable. + assertEquals("3.5", NumberFormat.DECIMAL_1.format(3.5, SEPARATOR_DOT)) + assertEquals('.', SEPARATOR_DOT) + } +} diff --git a/core/data/src/test/kotlin/app/aaps/core/data/model/ICfgTest.kt b/core/data/src/commonTest/kotlin/app/aaps/core/data/model/ICfgTest.kt similarity index 59% rename from core/data/src/test/kotlin/app/aaps/core/data/model/ICfgTest.kt rename to core/data/src/commonTest/kotlin/app/aaps/core/data/model/ICfgTest.kt index 06bb49ab2b64..f9498d8c7df4 100644 --- a/core/data/src/test/kotlin/app/aaps/core/data/model/ICfgTest.kt +++ b/core/data/src/commonTest/kotlin/app/aaps/core/data/model/ICfgTest.kt @@ -1,8 +1,19 @@ package app.aaps.core.data.model -import com.google.common.truth.Truth.assertThat -import org.junit.jupiter.api.Test +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue +/** + * In `commonTest`, so this runs through Kotlin/Native as well as the JVM - which matters more here + * than for most tests, because `iobCalcForTreatment` is dosing arithmetic and the whole point of + * sharing it with a client is that it computes the same numbers everywhere. + * + * `kotlin.test` rather than Truth and JUnit 5, because neither of those exists off the JVM. Truth's + * `isGreaterThan` / `isAtLeast` / `isFinite` become plain boolean assertions with a message, so a + * failure still says what it expected. + */ class ICfgTest { private fun bolus(amount: Double, iCfg: ICfg, timestamp: Long = 0L) = @@ -13,9 +24,9 @@ class ICfgTest { val iCfg = ICfg(insulinLabel = "test", peak = 75, dia = 6.0, concentration = 1.0) // 30 min after a 1 U bolus val iob = iCfg.iobCalcForTreatment(bolus(1.0, iCfg, timestamp = 0L), time = 30 * 60 * 1000L) - assertThat(iob.iobContrib).isFinite() - assertThat(iob.iobContrib).isGreaterThan(0.0) - assertThat(iob.iobContrib).isLessThan(1.0) + assertTrue(iob.iobContrib.isFinite(), "iobContrib was ${iob.iobContrib}") + assertTrue(iob.iobContrib > 0.0, "expected > 0.0 but was ${iob.iobContrib}") + assertTrue(iob.iobContrib < 1.0, "expected < 1.0 but was ${iob.iobContrib}") } @Test @@ -24,9 +35,9 @@ class ICfgTest { // dia rounds to 0.0 -> td = 0 -> the `t < td` gate would never fire -> silent zero IOB. val iCfg = ICfg(insulinLabel = "sentinel", insulinEndTime = -1L, insulinPeakTime = -1L, concentration = 1.0) val iob = iCfg.iobCalcForTreatment(bolus(5.0, iCfg, timestamp = 0L), time = 30 * 60 * 1000L) - assertThat(iob.iobContrib).isFinite() + assertTrue(iob.iobContrib.isFinite(), "iobContrib was ${iob.iobContrib}") // 5 U bolus 30 min ago must still be counted as on board, not silently dropped to 0. - assertThat(iob.iobContrib).isGreaterThan(0.0) + assertTrue(iob.iobContrib > 0.0, "expected > 0.0 but was ${iob.iobContrib}") } @Test @@ -34,8 +45,8 @@ class ICfgTest { // dia 5h -> td = 300 min; peak 150 min makes 2*tp == td -> original formula divides by zero. val iCfg = ICfg(insulinLabel = "singular", peak = 150, dia = 5.0, concentration = 1.0) val iob = iCfg.iobCalcForTreatment(bolus(2.0, iCfg, timestamp = 0L), time = 60 * 60 * 1000L) - assertThat(iob.iobContrib).isFinite() - assertThat(iob.activityContrib).isFinite() + assertTrue(iob.iobContrib.isFinite(), "iobContrib was ${iob.iobContrib}") + assertTrue(iob.activityContrib.isFinite(), "activityContrib was ${iob.activityContrib}") } @Test @@ -43,8 +54,8 @@ class ICfgTest { // 2*tp > td would yield a negative tau and negative iobContrib, inflating dosing. val iCfg = ICfg(insulinLabel = "negativeTau", peak = 200, dia = 5.0, concentration = 1.0) val iob = iCfg.iobCalcForTreatment(bolus(3.0, iCfg, timestamp = 0L), time = 60 * 60 * 1000L) - assertThat(iob.iobContrib).isFinite() - assertThat(iob.iobContrib).isAtLeast(0.0) + assertTrue(iob.iobContrib.isFinite(), "iobContrib was ${iob.iobContrib}") + assertTrue(iob.iobContrib >= 0.0, "expected >= 0.0 but was ${iob.iobContrib}") } @Test @@ -52,7 +63,7 @@ class ICfgTest { val iCfg = ICfg(insulinLabel = "test", peak = 75, dia = 5.0, concentration = 1.0) // 6 h after bolus, well past the 5 h DIA val iob = iCfg.iobCalcForTreatment(bolus(1.0, iCfg, timestamp = 0L), time = 6 * 60 * 60 * 1000L) - assertThat(iob.iobContrib).isEqualTo(0.0) + assertEquals(0.0, iob.iobContrib) } // The DB v33 migration stamps this sentinel onto every pre-ICfg row, including the active profile's. @@ -61,18 +72,18 @@ class ICfgTest { fun `the v33 migration sentinel is not a usable insulin`() { val sentinel = ICfg(insulinLabel = "", insulinEndTime = -1, insulinPeakTime = -1, concentration = 1.0) - assertThat(sentinel.isUsable).isFalse() - assertThat(sentinel.dia).isEqualTo(0.0) // the value that blocks the loop + assertFalse(sentinel.isUsable) + assertEquals(0.0, sentinel.dia) // the value that blocks the loop } @Test fun `a real insulin is usable`() { - assertThat(ICfg(insulinLabel = "test", peak = 75, dia = 5.0, concentration = 1.0).isUsable).isTrue() + assertTrue(ICfg(insulinLabel = "test", peak = 75, dia = 5.0, concentration = 1.0).isUsable) } @Test fun `a zero or negative DIA or peak is not usable`() { - assertThat(ICfg(insulinLabel = "", insulinEndTime = 0, insulinPeakTime = 4_500_000, concentration = 1.0).isUsable).isFalse() - assertThat(ICfg(insulinLabel = "", insulinEndTime = 18_000_000, insulinPeakTime = 0, concentration = 1.0).isUsable).isFalse() + assertFalse(ICfg(insulinLabel = "", insulinEndTime = 0, insulinPeakTime = 4_500_000, concentration = 1.0).isUsable) + assertFalse(ICfg(insulinLabel = "", insulinEndTime = 18_000_000, insulinPeakTime = 0, concentration = 1.0).isUsable) } } diff --git a/core/data/src/commonTest/kotlin/app/aaps/core/data/model/SourceSensorExtensionsTest.kt b/core/data/src/commonTest/kotlin/app/aaps/core/data/model/SourceSensorExtensionsTest.kt new file mode 100644 index 000000000000..8a596c3bca8e --- /dev/null +++ b/core/data/src/commonTest/kotlin/app/aaps/core/data/model/SourceSensorExtensionsTest.kt @@ -0,0 +1,58 @@ +package app.aaps.core.data.model + +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +/** + * In `commonTest`, so this runs through Kotlin/Native as well as the JVM. `kotlin.test` rather than + * Truth and JUnit 5, because neither of those exists off the JVM. + */ +class SourceSensorExtensionsTest { + + @Test + fun `dexcom native sensors support advanced filtering`() { + assertTrue(SourceSensor.DEXCOM_NATIVE_UNKNOWN.advancedFilteringSupported()) + assertTrue(SourceSensor.DEXCOM_G6_NATIVE.advancedFilteringSupported()) + assertTrue(SourceSensor.DEXCOM_G7_NATIVE.advancedFilteringSupported()) + assertTrue(SourceSensor.DEXCOM_G6_NATIVE_XDRIP.advancedFilteringSupported()) + assertTrue(SourceSensor.DEXCOM_G7_NATIVE_XDRIP.advancedFilteringSupported()) + assertTrue(SourceSensor.DEXCOM_G7_XDRIP.advancedFilteringSupported()) + } + + @Test + fun `libre 2 and 3 support advanced filtering`() { + assertTrue(SourceSensor.LIBRE_2.advancedFilteringSupported()) + assertTrue(SourceSensor.LIBRE_2_NATIVE.advancedFilteringSupported()) + assertTrue(SourceSensor.LIBRE_3.advancedFilteringSupported()) + } + + @Test + fun `syai and random support advanced filtering`() { + assertTrue(SourceSensor.SYAI_TAG.advancedFilteringSupported()) + assertTrue(SourceSensor.RANDOM.advancedFilteringSupported()) + } + + @Test + fun `medtronic does not support advanced filtering`() { + assertFalse(SourceSensor.MM_600_SERIES.advancedFilteringSupported()) + assertFalse(SourceSensor.MM_SIMPLERA.advancedFilteringSupported()) + } + + @Test + fun `eversense does not support advanced filtering`() { + assertFalse(SourceSensor.EVERSENSE.advancedFilteringSupported()) + } + + @Test + fun `libre 1 sensors do not support advanced filtering`() { + assertFalse(SourceSensor.LIBRE_1_OTHER.advancedFilteringSupported()) + assertFalse(SourceSensor.LIBRE_1_NET.advancedFilteringSupported()) + assertFalse(SourceSensor.LIBRE_1_BUBBLE.advancedFilteringSupported()) + } + + @Test + fun `unknown does not support advanced filtering`() { + assertFalse(SourceSensor.UNKNOWN.advancedFilteringSupported()) + } +} diff --git a/core/data/src/commonTest/kotlin/app/aaps/core/data/time/SystemTimeZoneTest.kt b/core/data/src/commonTest/kotlin/app/aaps/core/data/time/SystemTimeZoneTest.kt new file mode 100644 index 000000000000..c2f5890d7880 --- /dev/null +++ b/core/data/src/commonTest/kotlin/app/aaps/core/data/time/SystemTimeZoneTest.kt @@ -0,0 +1,70 @@ +package app.aaps.core.data.time + +import kotlin.math.abs +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * Invariants that must hold for [systemUtcOffsetAt] on every platform. + * + * This value is not cosmetic. It is stored as `utcOffset` on every record, it takes part in + * `contentEqualsTo` - so a changed value makes a record compare unequal and sync again - it is + * uploaded to Nightscout, which validates it and answers 400 when it is wrong, and it goes to Open + * Humans. A platform returning it in the wrong unit would corrupt data quietly rather than crash. + * + * The offset cannot be pinned to a literal, because it depends on the machine's time zone. What can + * be pinned is its shape, and that is enough to catch the mistake this actually invites: returning + * **seconds instead of milliseconds**. `NSTimeZone.secondsFromGMTForDate` is named for what it + * returns, so the `* 1000` in the Apple actual is easy to drop. + * + * Honest limits: on a machine set to UTC every assertion here passes trivially, because the offset + * is zero in any unit. CI often runs in UTC. These tests are therefore strongest on a developer + * machine in a real zone, and they are still worth having - a units bug that survives to a Mac would + * be caught the moment anyone runs them somewhere other than UTC. + */ +class SystemTimeZoneTest { + + // 2026-01-15T12:00:00Z and 2026-07-15T12:00:00Z - one in each half of the year, so that in a + // zone with daylight saving exactly one of them is in DST. + private val winter = 1_768_478_400_000L + private val summer = 1_784_116_800_000L + + @Test + fun `offset is a whole number of minutes`() { + // Every zone in use today is a whole number of minutes from UTC. Seconds returned in place + // of milliseconds would leave a remainder here for any non-zero offset. + assertEquals(0L, systemUtcOffsetAt(winter) % 60_000L, "offset ${systemUtcOffsetAt(winter)} is not a whole number of minutes") + assertEquals(0L, systemUtcOffsetAt(summer) % 60_000L, "offset ${systemUtcOffsetAt(summer)} is not a whole number of minutes") + } + + @Test + fun `offset is within the range of real time zones`() { + // -12:00 (Baker Island) to +14:00 (Kiribati), in milliseconds. + listOf(winter, summer).forEach { at -> + val offset = systemUtcOffsetAt(at) + assertTrue(offset in -12 * 3_600_000L..14 * 3_600_000L, "offset $offset at $at is outside any real time zone") + } + } + + @Test + // Kotlin/Native rejects a comma in a backtick name, so this reads a little stiffly. + fun `daylight saving changes the offset by a whole hour or not at all`() { + // The strongest unit check available without pinning a zone: where DST applies the two + // halves of the year differ by exactly one hour in milliseconds. An implementation + // returning seconds would differ by 3600 instead, and fail here. + val delta = abs(systemUtcOffsetAt(summer) - systemUtcOffsetAt(winter)) + assertTrue( + delta == 0L || delta == 3_600_000L || delta == 1_800_000L, + "summer/winter offsets differ by $delta ms, which is neither zero, half an hour nor a whole hour" + ) + } + + @Test + fun `the same moment always gives the same offset`() { + // It is read per record and must not drift between calls, or contentEqualsTo would start + // reporting records as changed when nothing changed. + assertEquals(systemUtcOffsetAt(winter), systemUtcOffsetAt(winter)) + assertEquals(systemUtcOffsetAt(summer), systemUtcOffsetAt(summer)) + } +} diff --git a/core/data/src/iosMain/kotlin/app/aaps/core/data/format/NumberFormatPlatform.ios.kt b/core/data/src/iosMain/kotlin/app/aaps/core/data/format/NumberFormatPlatform.ios.kt new file mode 100644 index 000000000000..f578a7940041 --- /dev/null +++ b/core/data/src/iosMain/kotlin/app/aaps/core/data/format/NumberFormatPlatform.ios.kt @@ -0,0 +1,54 @@ +package app.aaps.core.data.format + +import platform.Foundation.NSLocale +import platform.Foundation.NSNumber +import platform.Foundation.NSNumberFormatter +import platform.Foundation.NSNumberFormatterDecimalStyle +import platform.Foundation.NSNumberFormatterRoundHalfEven +import platform.Foundation.NSNumberFormatterRoundHalfUp +import platform.Foundation.currentLocale + +/** + * `NSNumberFormatter` is backed by the same CLDR data as the JVM `DecimalFormat`, so this is meant to + * produce the same text rather than an approximation of it. Every setting here mirrors one line of + * the JVM actual: no grouping, half-even rounding, and an explicit decimal separator. + * + * A formatter is built per call instead of being cached. `NSNumberFormatter` is not thread safe, and + * the JVM side solves that with a `ThreadLocal` cache; there is no need to carry that complexity + * until something measures it, because this formats screen values rather than a hot loop. + * + * **This has not been checked against the JVM output.** The two implementations agreeing is the + * whole point of using `NSNumberFormatter`, but comparing them needs a machine that can run + * Kotlin/Native tests for an Apple target, which Windows cannot do. + */ +actual object NumberFormatPlatform { + + actual val SEPARATOR_DOT: Char = '.' + + actual val localeSeparator: Char + get() = formatter().decimalSeparator?.firstOrNull() ?: SEPARATOR_DOT + + actual fun format(format: NumberFormat, value: Double): String = format(format, value, localeSeparator) + + actual fun format(format: NumberFormat, value: Double, separator: Char): String = + formatter().apply { + setMinimumIntegerDigits(format.minIntegerDigits.toULong()) + setMinimumFractionDigits(format.minFractionDigits.toULong()) + setMaximumFractionDigits(format.maxFractionDigits.toULong()) + // Old patterns like "0.00" never grouped, and NSNumberFormatter groups by default. + setUsesGroupingSeparator(false) + setRoundingMode( + when (format.rounding) { + NumberRounding.HALF_EVEN -> NSNumberFormatterRoundHalfEven + NumberRounding.HALF_UP -> NSNumberFormatterRoundHalfUp + } + ) + setDecimalSeparator(separator.toString()) + }.stringFromNumber(NSNumber(double = value)) ?: "" + + private fun formatter(): NSNumberFormatter = + NSNumberFormatter().apply { + setNumberStyle(NSNumberFormatterDecimalStyle) + setLocale(NSLocale.currentLocale) + } +} diff --git a/core/data/src/iosMain/kotlin/app/aaps/core/data/time/SystemTimeZone.ios.kt b/core/data/src/iosMain/kotlin/app/aaps/core/data/time/SystemTimeZone.ios.kt new file mode 100644 index 000000000000..745188c89602 --- /dev/null +++ b/core/data/src/iosMain/kotlin/app/aaps/core/data/time/SystemTimeZone.ios.kt @@ -0,0 +1,18 @@ +package app.aaps.core.data.time + +import platform.Foundation.NSDate +import platform.Foundation.NSTimeZone +import platform.Foundation.dateWithTimeIntervalSince1970 +import platform.Foundation.localTimeZone + +/** + * `secondsFromGMTForDate` is the direct counterpart of `TimeZone.getOffset(timestamp)`: it answers + * for a given moment, so it accounts for daylight saving at that moment rather than today. + * + * The value is milliseconds because that is what the records store, and it has to keep matching the + * JVM one exactly - see the note on the expect declaration. + */ +actual fun systemUtcOffsetAt(timestamp: Long): Long = + NSTimeZone.localTimeZone + .secondsFromGMTForDate(NSDate.dateWithTimeIntervalSince1970(timestamp / 1000.0)) + .toLong() * 1000L diff --git a/core/data/src/main/kotlin/app/aaps/core/data/format/NumberFormatPlatform.kt b/core/data/src/jvmMain/kotlin/app/aaps/core/data/format/NumberFormatPlatform.jvm.kt similarity index 63% rename from core/data/src/main/kotlin/app/aaps/core/data/format/NumberFormatPlatform.kt rename to core/data/src/jvmMain/kotlin/app/aaps/core/data/format/NumberFormatPlatform.jvm.kt index 31ec568c4832..2d1ce5195f1c 100644 --- a/core/data/src/main/kotlin/app/aaps/core/data/format/NumberFormatPlatform.kt +++ b/core/data/src/jvmMain/kotlin/app/aaps/core/data/format/NumberFormatPlatform.jvm.kt @@ -5,23 +5,11 @@ import java.text.DecimalFormat import java.text.DecimalFormatSymbols import java.util.Locale -/** - * Renders a [NumberFormat] as text. - * - * This is the only place in the app that still uses a platform number formatter. Everything else - * works with [NumberFormat], which is pure Kotlin. When this module is built for other platforms, - * this object becomes the platform specific part and nothing else has to change. - * - * Output is the same as the old `java.text.DecimalFormat` patterns: - * grouping separators are off, and rounding is half-even. - */ -object NumberFormatPlatform { - - /** Decimal separator for text that must not depend on the locale, for example server data. */ - const val SEPARATOR_DOT = '.' - - /** Decimal separator of the current locale. */ - val localeSeparator: Char get() = DecimalFormatSymbols.getInstance().decimalSeparator +actual object NumberFormatPlatform { + + actual val SEPARATOR_DOT: Char = '.' + + actual val localeSeparator: Char get() = DecimalFormatSymbols.getInstance().decimalSeparator // The locale is part of the key. A cached formatter keeps ALL the symbols of the locale it was // built with, not only the decimal separator - the minus sign and the digit shapes too. The app @@ -37,9 +25,9 @@ object NumberFormatPlatform { override fun initialValue(): MutableMap = HashMap() } - fun format(format: NumberFormat, value: Double): String = format(format, value, localeSeparator) + actual fun format(format: NumberFormat, value: Double): String = format(format, value, localeSeparator) - fun format(format: NumberFormat, value: Double, separator: Char): String = + actual fun format(format: NumberFormat, value: Double, separator: Char): String = formatterFor(Key(format, separator, Locale.getDefault())).format(value) private fun formatterFor(key: Key): DecimalFormat = @@ -50,8 +38,11 @@ object NumberFormatPlatform { maximumFractionDigits = key.format.maxFractionDigits // Old patterns like "0.00" never grouped. DecimalFormat() does by default. isGroupingUsed = false - // Same as the default of DecimalFormat, set here so it cannot drift. - roundingMode = RoundingMode.HALF_EVEN + // HALF_EVEN is the DecimalFormat default, set here so it cannot drift. + roundingMode = when (key.format.rounding) { + NumberRounding.HALF_EVEN -> RoundingMode.HALF_EVEN + NumberRounding.HALF_UP -> RoundingMode.HALF_UP + } decimalFormatSymbols = DecimalFormatSymbols.getInstance(key.locale).apply { decimalSeparator = key.separator } } } diff --git a/core/data/src/jvmMain/kotlin/app/aaps/core/data/model/DevAssert.jvm.kt b/core/data/src/jvmMain/kotlin/app/aaps/core/data/model/DevAssert.jvm.kt new file mode 100644 index 000000000000..a4fb8c0d73e4 --- /dev/null +++ b/core/data/src/jvmMain/kotlin/app/aaps/core/data/model/DevAssert.jvm.kt @@ -0,0 +1,6 @@ +package app.aaps.core.data.model + +/** Plain `kotlin.assert`: active under `-ea` (so in tests), a no-op in production. */ +actual fun devAssert(value: Boolean) { + assert(value) +} diff --git a/core/data/src/jvmMain/kotlin/app/aaps/core/data/time/SystemTimeZone.jvm.kt b/core/data/src/jvmMain/kotlin/app/aaps/core/data/time/SystemTimeZone.jvm.kt new file mode 100644 index 000000000000..df1876dc6f9d --- /dev/null +++ b/core/data/src/jvmMain/kotlin/app/aaps/core/data/time/SystemTimeZone.jvm.kt @@ -0,0 +1,7 @@ +package app.aaps.core.data.time + +import java.util.TimeZone + +/** Exactly what the models did before, so the stored `utcOffset` does not change. */ +actual fun systemUtcOffsetAt(timestamp: Long): Long = + TimeZone.getDefault().getOffset(timestamp).toLong() diff --git a/core/data/src/jvmTest/kotlin/app/aaps/core/data/datetime/IsoDateParserParityTest.kt b/core/data/src/jvmTest/kotlin/app/aaps/core/data/datetime/IsoDateParserParityTest.kt new file mode 100644 index 000000000000..dd068e26026c --- /dev/null +++ b/core/data/src/jvmTest/kotlin/app/aaps/core/data/datetime/IsoDateParserParityTest.kt @@ -0,0 +1,103 @@ +package app.aaps.core.data.datetime + +import com.google.common.truth.Truth.assertThat +import com.google.common.truth.Truth.assertWithMessage +import org.joda.time.DateTime +import org.joda.time.format.ISODateTimeFormat +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import java.util.TimeZone + +/** + * Pins [parseIsoToEpochMillisOrNull] against joda's `ISODateTimeFormat.dateTimeParser()`. + * + * These strings come from **Nightscout device status written by other uploaders**, not only from + * AAPS itself, so the shapes are not hypothetical. A strict parser that rejected one of them would + * not fail loudly - the value would land in 1970 or the whole document would be dropped, and the + * only symptom would be a missing reading. + * + * The zone is pinned to a non-UTC one with daylight saving because the offset-less shapes are + * resolved in the **local** zone: on a UTC machine those cases would agree no matter what the + * implementation did, and the test would prove nothing. + */ +class IsoDateParserParityTest { + + private lateinit var original: TimeZone + + @BeforeEach fun pinZone() { + original = TimeZone.getDefault() + TimeZone.setDefault(TimeZone.getTimeZone("Europe/Prague")) + } + + @AfterEach fun restoreZone() { + TimeZone.setDefault(original) + } + + private fun joda(text: String): Long = + DateTime.parse(text, ISODateTimeFormat.dateTimeParser()).toDate().time + + /** Shapes joda accepts, which therefore have to keep working. */ + private val accepted = listOf( + "2026-08-06T04:56:19.555Z", + "2026-08-06T04:56:19Z", + "2026-08-06T04:56:19.555+02:00", + "2026-08-06T04:56:19.555+0200", // no colon - kotlinx alone rejects this + "2026-08-06T04:56:19.555-04:00", + "2026-08-06T04:56:19.555-0430", + "2017-11-19T22:50:34.417+0200", // from the historical DateUtil test + "2017-12-03T16:09:25.000Z", + "2017-12-22T00:32:30Z", + // Real Nightscout data: `startDate` carries SEVEN fractional digits while `created_at` in the + // same document carries three. A parser pinned to `.SSS` would reject half of every profile. + "2026-04-26T08:48:38.9980000Z", + "2026-04-26T08:48:38.998Z", + "2026-08-06T04:56:19.555", // no offset at all - local + "2026-08-06T04:56:19", // no offset, no fraction - local + "2026-08-06T04:56", // minutes only - local + "2026-01-15T04:56:19.555", // winter, local: offset differs from summer + "2026-08-06", // date only - local midnight + "2026-01-15" + ) + + @Test fun `every shape joda accepts parses to the same instant`() { + accepted.forEach { text -> + assertWithMessage("input %s", text) + .that(parseIsoToEpochMillisOrNull(text)).isEqualTo(joda(text)) + } + } + + @Test fun `lower case t and z are accepted like joda`() { + listOf("2026-08-06t04:56:19.555z", "2026-08-06t04:56:19.555Z").forEach { text -> + assertWithMessage("input %s", text) + .that(parseIsoToEpochMillisOrNull(text)).isEqualTo(joda(text)) + } + } + + @Test fun `surrounding whitespace is tolerated`() { + assertThat(parseIsoToEpochMillisOrNull(" 2026-08-06T04:56:19.555Z ")) + .isEqualTo(joda("2026-08-06T04:56:19.555Z")) + } + + /** + * Proves the offset-less cases above are not vacuous: in Europe/Prague the same wall clock time + * maps to two different instants depending on the time of year, so a parser that ignored the + * local zone would fail one of them. + */ + @Test fun `offset-less values really are resolved in the local zone`() { + val summer = parseIsoToEpochMillisOrNull("2026-08-06T04:56:19.555")!! + val winter = parseIsoToEpochMillisOrNull("2026-01-15T04:56:19.555")!! + val summerUtc = parseIsoToEpochMillisOrNull("2026-08-06T04:56:19.555Z")!! + val winterUtc = parseIsoToEpochMillisOrNull("2026-01-15T04:56:19.555Z")!! + + assertThat(summerUtc - summer).isEqualTo(2 * 3_600_000L) // CEST + assertThat(winterUtc - winter).isEqualTo(1 * 3_600_000L) // CET + } + + @Test fun `garbage returns null rather than a wrong instant`() { + listOf("", " ", "not a date", "2026-13-45T99:99:99Z", "Z", "+02:00").forEach { text -> + assertWithMessage("input %s", text) + .that(parseIsoToEpochMillisOrNull(text)).isNull() + } + } +} diff --git a/core/data/src/test/kotlin/app/aaps/core/data/format/NumberFormatTest.kt b/core/data/src/jvmTest/kotlin/app/aaps/core/data/format/NumberFormatTest.kt similarity index 100% rename from core/data/src/test/kotlin/app/aaps/core/data/format/NumberFormatTest.kt rename to core/data/src/jvmTest/kotlin/app/aaps/core/data/format/NumberFormatTest.kt diff --git a/core/data/src/jvmTest/kotlin/app/aaps/core/data/json/OrgJsonCompatParityTest.kt b/core/data/src/jvmTest/kotlin/app/aaps/core/data/json/OrgJsonCompatParityTest.kt new file mode 100644 index 000000000000..058399cb9296 --- /dev/null +++ b/core/data/src/jvmTest/kotlin/app/aaps/core/data/json/OrgJsonCompatParityTest.kt @@ -0,0 +1,253 @@ +package app.aaps.core.data.json + +import app.aaps.core.data.json.OrgJsonCompat.hasCompat +import app.aaps.core.data.json.OrgJsonCompat.optBooleanCompat +import app.aaps.core.data.json.OrgJsonCompat.optDoubleCompat +import app.aaps.core.data.json.OrgJsonCompat.optIntCompat +import app.aaps.core.data.json.OrgJsonCompat.optJsonArrayCompat +import app.aaps.core.data.json.OrgJsonCompat.optJsonObjectCompat +import app.aaps.core.data.json.OrgJsonCompat.optLongCompat +import app.aaps.core.data.json.OrgJsonCompat.optStringCompat +import com.google.common.truth.Truth.assertThat +import com.google.common.truth.Truth.assertWithMessage +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.jsonObject +import org.json.JSONObject +import org.junit.jupiter.api.Test + +/** + * Golden master for [OrgJsonCompat], plus a written record of where the two libraries genuinely + * disagree. + * + * Every read case below is parsed **twice**, once with `org.json` and once with kotlinx, and the two + * results are required to match. `org.json` is the reference: whatever it does today is what the + * replacement has to keep doing, quirks included. The accessor differences are invisible to the + * compiler - if `optString` starts returning null for a missing key nothing fails to build, a + * downstream `isEmpty()` just quietly takes the other branch - and this is what makes that visible. + * + * **The oracle is `org.json:json` from Maven, which is NOT byte for byte the same implementation as + * Android's.** Android ships a Harmony derived `org.json`; Maven ships Crockford's. They agree on + * every accessor exercised here, which is why this test is worth running, but do not extend it to + * anything where the two implementations could reasonably differ (iteration order is the known one) + * without checking on a device first. + * + * The second half is deliberately different in kind. It does not assert that the libraries agree - + * it asserts that they do NOT, and pins exactly how. Those are the cases a migration has to shim or + * accept, and a test that quietly skipped them would be worse than no test. + */ +class OrgJsonCompatParityTest { + + /** One row of the matrix: a document, and the key to read out of it. */ + private data class Case(val name: String, val json: String, val key: String) + + private val cases = listOf( + Case("missing key", """{"other":1}""", "k"), + Case("explicit null", """{"k":null}""", "k"), + Case("plain string", """{"k":"hello"}""", "k"), + Case("empty string", """{"k":""}""", "k"), + Case("string with spaces", """{"k":"a b c"}""", "k"), + Case("string that looks numeric", """{"k":"1785992181588"}""", "k"), + Case("string true", """{"k":"true"}""", "k"), + Case("string TRUE upper", """{"k":"TRUE"}""", "k"), + Case("string false", """{"k":"false"}""", "k"), + Case("int", """{"k":42}""", "k"), + Case("zero", """{"k":0}""", "k"), + Case("negative int", """{"k":-6}""", "k"), + Case("big long", """{"k":1785992181588}""", "k"), + Case("double", """{"k":0.692}""", "k"), + Case("negative double", """{"k":-0.411}""", "k"), + Case("integral double", """{"k":1.0}""", "k"), + Case("boolean true", """{"k":true}""", "k"), + Case("boolean false", """{"k":false}""", "k"), + Case("nested object", """{"k":{"a":1}}""", "k"), + Case("empty object", """{"k":{}}""", "k"), + Case("array", """{"k":[1,2,3]}""", "k"), + Case("empty array", """{"k":[]}""", "k") + ) + + private fun orgJson(case: Case) = JSONObject(case.json) + private fun kotlinxJson(case: Case): JsonObject = Json.parseToJsonElement(case.json).jsonObject + + // ---------------------------------------------------------------- reads must match + + @Test + fun `optString matches org json for every case`() { + cases.forEach { case -> + assertWithMessage(case.name) + .that(kotlinxJson(case).optStringCompat(case.key)) + .isEqualTo(orgJson(case).optString(case.key)) + } + } + + @Test + fun `optLong matches org json for every case`() { + cases.forEach { case -> + assertWithMessage(case.name) + .that(kotlinxJson(case).optLongCompat(case.key, 0L)) + .isEqualTo(orgJson(case).optLong(case.key, 0L)) + } + } + + @Test + fun `optLong honours a non zero fallback for every case`() { + cases.forEach { case -> + assertWithMessage(case.name) + .that(kotlinxJson(case).optLongCompat(case.key, -1L)) + .isEqualTo(orgJson(case).optLong(case.key, -1L)) + } + } + + @Test + fun `optDouble matches org json for every case`() { + cases.forEach { case -> + assertWithMessage(case.name) + .that(kotlinxJson(case).optDoubleCompat(case.key, 0.0)) + .isEqualTo(orgJson(case).optDouble(case.key, 0.0)) + } + } + + @Test + fun `optInt matches org json for every case`() { + cases.forEach { case -> + assertWithMessage(case.name) + .that(kotlinxJson(case).optIntCompat(case.key, 0)) + .isEqualTo(orgJson(case).optInt(case.key, 0)) + } + } + + @Test + fun `optBoolean matches org json for every case`() { + cases.forEach { case -> + assertWithMessage(case.name) + .that(kotlinxJson(case).optBooleanCompat(case.key)) + .isEqualTo(orgJson(case).optBoolean(case.key)) + } + } + + @Test + fun `has matches org json for every case`() { + cases.forEach { case -> + assertWithMessage(case.name) + .that(kotlinxJson(case).hasCompat(case.key)) + .isEqualTo(orgJson(case).has(case.key)) + } + } + + @Test + fun `optJSONObject presence matches org json for every case`() { + cases.forEach { case -> + assertWithMessage(case.name) + .that(kotlinxJson(case).optJsonObjectCompat(case.key) != null) + .isEqualTo(orgJson(case).optJSONObject(case.key) != null) + } + } + + @Test + fun `optJSONArray presence matches org json for every case`() { + cases.forEach { case -> + assertWithMessage(case.name) + .that(kotlinxJson(case).optJsonArrayCompat(case.key) != null) + .isEqualTo(orgJson(case).optJSONArray(case.key) != null) + } + } + + // ---------------------------------------------------------------- writes differ + // + // Everything above says "these agree, rely on it". Everything below says "these do NOT agree, + // and here is exactly how" - because the profile spine SERIALIZES (Profile.toPureNsJson feeds + // Nightscout), and a value that reads back fine can still go onto the wire in a different shape. + + /** + * The one that would actually change bytes sent to Nightscout. + * + * `org.json` renders a whole-numbered double WITHOUT a fractional part, so a basal rate of + * `1.0` is uploaded as `1`. kotlinx keeps the double it was given and writes `1.0`. Both parse + * back to the same number, so nothing round-trips wrong inside AAPS - but the uploaded document + * is not byte identical, and anything comparing documents as text (a sync hash, a diff, a + * strict consumer) would see every whole basal rate change. + */ + @Test + fun `whole numbered doubles serialize differently`() { + val org = JSONObject().put("basal", 1.0).toString() + val kotlinx = buildJsonObject { put("basal", JsonPrimitive(1.0)) }.toString() + + assertThat(org).isEqualTo("""{"basal":1}""") + assertThat(kotlinx).isEqualTo("""{"basal":1.0}""") + assertWithMessage("if this ever passes, the divergence is gone and the shim can drop it") + .that(org).isNotEqualTo(kotlinx) + } + + /** A non-whole double is written the same way by both, so only the integral case needs care. */ + @Test + fun `fractional doubles serialize identically`() { + val org = JSONObject().put("basal", 0.825).toString() + val kotlinx = buildJsonObject { put("basal", JsonPrimitive(0.825)) }.toString() + assertThat(org).isEqualTo(kotlinx) + } + + /** + * `org.json` refuses a non finite double outright; kotlinx accepts it and emits text that is not + * valid JSON. A corrupt profile value therefore fails loudly today and would fail silently after + * a naive migration - the receiver is what would break, and much later. + */ + @Test + fun `non finite doubles are rejected by org json but not by kotlinx`() { + var orgThrew = false + try { + JSONObject().put("k", Double.NaN) + } catch (_: Exception) { + orgThrew = true + } + assertThat(orgThrew).isTrue() + + val kotlinx = buildJsonObject { put("k", JsonPrimitive(Double.NaN)) }.toString() + assertThat(kotlinx).isEqualTo("""{"k":NaN}""") + } + + /** + * `org.json` accepts input that is not JSON - single quotes, unquoted keys - because it was + * written to be forgiving. kotlinx rejects it unless explicitly made lenient. + * + * This matters for anything read from outside AAPS: a hand-edited profile, an old export, a + * third party uploader. Strictness is better, but it is a behaviour change, not a free win. + */ + @Test + fun `org json accepts malformed input that kotlinx rejects`() { + val malformed = """{k:"v"}""" // unquoted key - not JSON + + assertThat(JSONObject(malformed).optString("k")).isEqualTo("v") + + var kotlinxThrew = false + try { + Json.parseToJsonElement(malformed) + } catch (_: Exception) { + kotlinxThrew = true + } + assertWithMessage("kotlinx must reject what org.json tolerated").that(kotlinxThrew).isTrue() + + // ...and it can be made to accept it, if a call site turns out to need that. + val lenient = Json { isLenient = true } + assertThat(lenient.parseToJsonElement(malformed).jsonObject.optStringCompat("k")).isEqualTo("v") + } + + /** + * kotlinx preserves insertion order; this `org.json` does not guarantee it. + * + * Pinned only as "kotlinx is ordered", NOT as a comparison, because iteration order is exactly + * where the Maven and Android implementations are allowed to differ - asserting the oracle's + * order here would pin the wrong library's behaviour. + */ + @Test + fun `kotlinx preserves insertion order`() { + val doc = buildJsonObject { + put("z", JsonPrimitive(1)) + put("a", JsonPrimitive(2)) + put("m", JsonPrimitive(3)) + } + assertThat(doc.keys.toList()).containsExactly("z", "a", "m").inOrder() + assertThat(doc.toString()).isEqualTo("""{"z":1,"a":2,"m":3}""") + } +} diff --git a/core/data/src/mingwX64Main/kotlin/app/aaps/core/data/format/NumberFormatPlatform.mingw.kt b/core/data/src/mingwX64Main/kotlin/app/aaps/core/data/format/NumberFormatPlatform.mingw.kt new file mode 100644 index 000000000000..f072a5c64935 --- /dev/null +++ b/core/data/src/mingwX64Main/kotlin/app/aaps/core/data/format/NumberFormatPlatform.mingw.kt @@ -0,0 +1,54 @@ +package app.aaps.core.data.format + +import kotlin.math.abs +import kotlin.math.pow +import kotlin.math.round +import kotlin.math.truncate + +/** + * Experiment only. + * + * mingwX64 exists here to prove that `commonMain` holds no JVM API. It is not a shipping target, so + * this actual does the simple thing rather than reaching for the platform locale data. A real iOS + * actual would use `NSNumberFormatter`, which is backed by the same CLDR data as the JVM one. + */ +actual object NumberFormatPlatform { + + actual val SEPARATOR_DOT: Char = '.' + + /** No locale data on this target, so always a dot. */ + actual val localeSeparator: Char = '.' + + actual fun format(format: NumberFormat, value: Double): String = format(format, value, localeSeparator) + + actual fun format(format: NumberFormat, value: Double, separator: Char): String { + if (value.isNaN()) return "NaN" + if (value.isInfinite()) return if (value > 0) "∞" else "-∞" + + val negative = value < 0 || (value == 0.0 && 1.0 / value < 0) + val scale = 10.0.pow(format.maxFractionDigits) + val scaled = abs(value) * scale + val floor = truncate(scaled) + val rest = scaled - floor + val rounded = when { + rest > 0.5 -> floor + 1 + rest < 0.5 -> floor + // Exactly halfway. Where the two modes differ - see NumberRounding. + format.rounding == NumberRounding.HALF_UP -> floor + 1 + floor.toLong() % 2L == 0L -> floor + else -> floor + 1 + } + + var whole = truncate(rounded / scale).toLong().toString() + var fraction = round(rounded - truncate(rounded / scale) * scale).toLong().toString() + if (format.maxFractionDigits > 0) fraction = fraction.padStart(format.maxFractionDigits, '0') + else fraction = "" + // drop trailing zeros above the minimum + while (fraction.length > format.minFractionDigits && fraction.endsWith('0')) + fraction = fraction.dropLast(1) + + whole = whole.padStart(format.minIntegerDigits, '0') + val sign = if (negative && (whole.any { it != '0' } || fraction.any { it != '0' })) "-" else "" + return if (fraction.isEmpty()) "$sign$whole" else "$sign$whole$separator$fraction" + } +} diff --git a/core/data/src/mingwX64Main/kotlin/app/aaps/core/data/time/SystemTimeZone.mingw.kt b/core/data/src/mingwX64Main/kotlin/app/aaps/core/data/time/SystemTimeZone.mingw.kt new file mode 100644 index 000000000000..f0784c472fa5 --- /dev/null +++ b/core/data/src/mingwX64Main/kotlin/app/aaps/core/data/time/SystemTimeZone.mingw.kt @@ -0,0 +1,8 @@ +package app.aaps.core.data.time + +/** + * Experiment only - see [app.aaps.core.data.format.NumberFormatPlatform]. + * + * A real iOS actual would use `NSTimeZone.localTimeZone.secondsFromGMTForDate`. + */ +actual fun systemUtcOffsetAt(timestamp: Long): Long = 0L diff --git a/core/data/src/nativeMain/kotlin/app/aaps/core/data/model/DevAssert.native.kt b/core/data/src/nativeMain/kotlin/app/aaps/core/data/model/DevAssert.native.kt new file mode 100644 index 000000000000..56019f649763 --- /dev/null +++ b/core/data/src/nativeMain/kotlin/app/aaps/core/data/model/DevAssert.native.kt @@ -0,0 +1,14 @@ +@file:OptIn(ExperimentalNativeApi::class) + +package app.aaps.core.data.model + +import kotlin.experimental.ExperimentalNativeApi + +/** + * Kotlin/Native has `assert` too, behind an opt-in. Scoping the opt-in to this one file is better + * than a Gradle-wide `optIn`, which would silently allow experimental Native APIs anywhere in the + * module - and which does not reach the metadata compilation anyway. + */ +actual fun devAssert(value: Boolean) { + assert(value) +} diff --git a/core/data/src/test/kotlin/app/aaps/core/data/model/SourceSensorExtensionsTest.kt b/core/data/src/test/kotlin/app/aaps/core/data/model/SourceSensorExtensionsTest.kt deleted file mode 100644 index a12bc51baa19..000000000000 --- a/core/data/src/test/kotlin/app/aaps/core/data/model/SourceSensorExtensionsTest.kt +++ /dev/null @@ -1,53 +0,0 @@ -package app.aaps.core.data.model - -import com.google.common.truth.Truth.assertThat -import org.junit.jupiter.api.Test - -class SourceSensorExtensionsTest { - - @Test - fun `dexcom native sensors support advanced filtering`() { - assertThat(SourceSensor.DEXCOM_NATIVE_UNKNOWN.advancedFilteringSupported()).isTrue() - assertThat(SourceSensor.DEXCOM_G6_NATIVE.advancedFilteringSupported()).isTrue() - assertThat(SourceSensor.DEXCOM_G7_NATIVE.advancedFilteringSupported()).isTrue() - assertThat(SourceSensor.DEXCOM_G6_NATIVE_XDRIP.advancedFilteringSupported()).isTrue() - assertThat(SourceSensor.DEXCOM_G7_NATIVE_XDRIP.advancedFilteringSupported()).isTrue() - assertThat(SourceSensor.DEXCOM_G7_XDRIP.advancedFilteringSupported()).isTrue() - } - - @Test - fun `libre 2 and 3 support advanced filtering`() { - assertThat(SourceSensor.LIBRE_2.advancedFilteringSupported()).isTrue() - assertThat(SourceSensor.LIBRE_2_NATIVE.advancedFilteringSupported()).isTrue() - assertThat(SourceSensor.LIBRE_3.advancedFilteringSupported()).isTrue() - } - - @Test - fun `syai and random support advanced filtering`() { - assertThat(SourceSensor.SYAI_TAG.advancedFilteringSupported()).isTrue() - assertThat(SourceSensor.RANDOM.advancedFilteringSupported()).isTrue() - } - - @Test - fun `medtronic does not support advanced filtering`() { - assertThat(SourceSensor.MM_600_SERIES.advancedFilteringSupported()).isFalse() - assertThat(SourceSensor.MM_SIMPLERA.advancedFilteringSupported()).isFalse() - } - - @Test - fun `eversense does not support advanced filtering`() { - assertThat(SourceSensor.EVERSENSE.advancedFilteringSupported()).isFalse() - } - - @Test - fun `libre 1 sensors do not support advanced filtering`() { - assertThat(SourceSensor.LIBRE_1_OTHER.advancedFilteringSupported()).isFalse() - assertThat(SourceSensor.LIBRE_1_NET.advancedFilteringSupported()).isFalse() - assertThat(SourceSensor.LIBRE_1_BUBBLE.advancedFilteringSupported()).isFalse() - } - - @Test - fun `unknown does not support advanced filtering`() { - assertThat(SourceSensor.UNKNOWN.advancedFilteringSupported()).isFalse() - } -} diff --git a/core/graph/build.gradle.kts b/core/graph/build.gradle.kts index 1e751269d2e1..a11c70240e47 100644 --- a/core/graph/build.gradle.kts +++ b/core/graph/build.gradle.kts @@ -1,32 +1,87 @@ +import kotlin.math.min + plugins { - alias(libs.plugins.android.library) + kotlin("multiplatform") + // NOT com.android.library. AGP 9 refuses that plugin together with the multiplatform plugin. + // Same reason as :core:keys, :core:data, :core:utils, :core:interfaces and :core:ui. + alias(libs.plugins.android.kmp.library) alias(libs.plugins.compose.compiler) - id("android-module-dependencies") - id("test-module-dependencies") - id("compose-test-module-dependencies") - id("jacoco-module-dependencies") + alias(libs.plugins.compose.multiplatform) } -android { - - namespace = "app.aaps.core.graph" +kotlin { + android { + namespace = "app.aaps.core.graph" + compileSdk = Versions.compileSdk + minSdk = min(Versions.minSdk, Versions.wearMinSdk) + androidResources { enable = true } + // isIncludeAndroidResources is what makes Robolectric work - see :core:ui for the detail. + withHostTest { + isIncludeAndroidResources = true + isReturnDefaultValues = true + } + compilerOptions { jvmTarget.set(Versions.jvmTarget) } - buildFeatures { - compose = true + lint { + checkReleaseBuilds = false + disable += "MissingTranslation" + disable += "ExtraTranslation" + } } -} -dependencies { - implementation(project(":core:data")) - implementation(project(":core:interfaces")) - implementation(project(":core:objects")) - implementation(project(":core:ui")) + iosArm64() + iosSimulatorArm64() + + sourceSets { + commonMain { + dependencies { + api(project(":core:data")) + api(project(":core:interfaces")) + api(project(":core:ui")) + + api(libs.cmp.runtime) + api(libs.cmp.foundation) + api(libs.cmp.ui) + api(libs.cmp.material3) + // Vico publishes Apple targets of its own, so the charts are shared rather than + // reimplemented. This is the only third-party UI library in commonMain. + api(libs.com.patrykandpatrick.vico.compose) + implementation(libs.cmp.ui.tooling.preview) + } + } - implementation(libs.androidx.compose.ui.tooling.preview) - debugImplementation(libs.androidx.compose.ui.tooling) + androidMain { + dependencies { + + api(project.dependencies.platform(libs.androidx.compose.bom)) + api(libs.androidx.compose.runtime) + api(libs.androidx.ui) + + implementation(libs.androidx.compose.ui.tooling.preview) + // Was debugImplementation; the multiplatform library target has no build types. + implementation(libs.androidx.compose.ui.tooling) + } + } + + getByName("androidHostTest") { + dependencies { + implementation(libs.org.junit.jupiter) + implementation(libs.org.junit.jupiter.api) + implementation(libs.org.mockito.junit.jupiter) + implementation(libs.org.mockito.kotlin) + implementation(libs.com.google.truth) + implementation(libs.kotlinx.coroutines.test) + implementation(project.dependencies.platform(libs.androidx.compose.bom)) + implementation(libs.androidx.compose.ui.test.junit4) + implementation(libs.org.robolectric) + implementation(libs.androidx.compose.ui.test.manifest) + runtimeOnly(libs.org.junit.vintage.engine) + runtimeOnly(libs.org.junit.platform.launcher) + } + } + } +} - api(platform(libs.androidx.compose.bom)) - api(libs.androidx.compose.runtime) - api(libs.androidx.ui) - api(libs.com.patrykandpatrick.vico.compose) +tasks.withType { + useJUnitPlatform() } diff --git a/core/graph/src/test/kotlin/app/aaps/core/graph/profile/ProfileViewerContentTest.kt b/core/graph/src/androidHostTest/kotlin/app/aaps/core/graph/profile/ProfileViewerContentTest.kt similarity index 100% rename from core/graph/src/test/kotlin/app/aaps/core/graph/profile/ProfileViewerContentTest.kt rename to core/graph/src/androidHostTest/kotlin/app/aaps/core/graph/profile/ProfileViewerContentTest.kt diff --git a/core/graph/src/main/AndroidManifest.xml b/core/graph/src/androidMain/AndroidManifest.xml similarity index 100% rename from core/graph/src/main/AndroidManifest.xml rename to core/graph/src/androidMain/AndroidManifest.xml diff --git a/core/graph/src/main/kotlin/app/aaps/core/graph/BasalProfileGraphCompose.kt b/core/graph/src/commonMain/kotlin/app/aaps/core/graph/BasalProfileGraphCompose.kt similarity index 100% rename from core/graph/src/main/kotlin/app/aaps/core/graph/BasalProfileGraphCompose.kt rename to core/graph/src/commonMain/kotlin/app/aaps/core/graph/BasalProfileGraphCompose.kt diff --git a/core/graph/src/main/kotlin/app/aaps/core/graph/IcProfileGraphCompose.kt b/core/graph/src/commonMain/kotlin/app/aaps/core/graph/IcProfileGraphCompose.kt similarity index 100% rename from core/graph/src/main/kotlin/app/aaps/core/graph/IcProfileGraphCompose.kt rename to core/graph/src/commonMain/kotlin/app/aaps/core/graph/IcProfileGraphCompose.kt diff --git a/core/graph/src/main/kotlin/app/aaps/core/graph/InsulinGraphCompose.kt b/core/graph/src/commonMain/kotlin/app/aaps/core/graph/InsulinGraphCompose.kt similarity index 97% rename from core/graph/src/main/kotlin/app/aaps/core/graph/InsulinGraphCompose.kt rename to core/graph/src/commonMain/kotlin/app/aaps/core/graph/InsulinGraphCompose.kt index 4b2b2d4daf17..f80b208bdb62 100644 --- a/core/graph/src/main/kotlin/app/aaps/core/graph/InsulinGraphCompose.kt +++ b/core/graph/src/commonMain/kotlin/app/aaps/core/graph/InsulinGraphCompose.kt @@ -9,13 +9,14 @@ import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color -import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.TextStyle import androidx.compose.ui.unit.dp import app.aaps.core.data.model.BS import app.aaps.core.data.model.ICfg +import app.aaps.core.data.model.iobCalc import app.aaps.core.data.time.T -import app.aaps.core.objects.extensions.iobCalc +import app.aaps.core.ui.UiStrings +import app.aaps.core.ui.compose.stringResource import com.patrykandpatrick.vico.compose.cartesian.CartesianChartHost import com.patrykandpatrick.vico.compose.cartesian.Zoom import com.patrykandpatrick.vico.compose.cartesian.axis.Axis @@ -37,7 +38,6 @@ import com.patrykandpatrick.vico.compose.common.component.rememberTextComponent import com.patrykandpatrick.vico.compose.common.data.ExtraStore import com.patrykandpatrick.vico.compose.common.rememberHorizontalLegend import kotlin.math.floor -import app.aaps.core.ui.R as CoreUiR private val InsulinLegendLabelKey = ExtraStore.Key>() @@ -65,8 +65,8 @@ fun InsulinGraphCompose( modifier: Modifier = Modifier ) { val modelProducer = remember { CartesianChartModelProducer() } - val activityLabel = stringResource(CoreUiR.string.activity) - val iobLabel = stringResource(CoreUiR.string.iob) + val activityLabel = stringResource(UiStrings.activity) + val iobLabel = stringResource(UiStrings.iob) LaunchedEffect(iCfg.insulinPeakTime, iCfg.insulinEndTime, iCfg.concentration, diaSample) { val dia = diaSample ?: iCfg.dia diff --git a/core/graph/src/main/kotlin/app/aaps/core/graph/IsfProfileGraphCompose.kt b/core/graph/src/commonMain/kotlin/app/aaps/core/graph/IsfProfileGraphCompose.kt similarity index 100% rename from core/graph/src/main/kotlin/app/aaps/core/graph/IsfProfileGraphCompose.kt rename to core/graph/src/commonMain/kotlin/app/aaps/core/graph/IsfProfileGraphCompose.kt diff --git a/core/graph/src/main/kotlin/app/aaps/core/graph/TargetBgProfileGraphCompose.kt b/core/graph/src/commonMain/kotlin/app/aaps/core/graph/TargetBgProfileGraphCompose.kt similarity index 100% rename from core/graph/src/main/kotlin/app/aaps/core/graph/TargetBgProfileGraphCompose.kt rename to core/graph/src/commonMain/kotlin/app/aaps/core/graph/TargetBgProfileGraphCompose.kt diff --git a/core/graph/src/main/kotlin/app/aaps/core/graph/profile/ProfileCompareRowBuilders.kt b/core/graph/src/commonMain/kotlin/app/aaps/core/graph/profile/ProfileCompareRowBuilders.kt similarity index 92% rename from core/graph/src/main/kotlin/app/aaps/core/graph/profile/ProfileCompareRowBuilders.kt rename to core/graph/src/commonMain/kotlin/app/aaps/core/graph/profile/ProfileCompareRowBuilders.kt index 9a162409e71c..2fa11a2561d0 100644 --- a/core/graph/src/main/kotlin/app/aaps/core/graph/profile/ProfileCompareRowBuilders.kt +++ b/core/graph/src/commonMain/kotlin/app/aaps/core/graph/profile/ProfileCompareRowBuilders.kt @@ -2,12 +2,12 @@ package app.aaps.core.graph.profile import app.aaps.core.data.format.NumberFormat import app.aaps.core.data.model.GlucoseUnit +import app.aaps.core.interfaces.InterfacesStrings import app.aaps.core.interfaces.profile.Profile import app.aaps.core.interfaces.profile.ProfileFunction import app.aaps.core.interfaces.profile.ProfileUtil -import app.aaps.core.interfaces.resources.ResourceHelper +import app.aaps.core.interfaces.resources.TextResolver import app.aaps.core.interfaces.utils.DateUtil -import app.aaps.core.ui.R /** * Pre-computed data for profile comparison (base vs effective, or any two profiles). @@ -38,7 +38,7 @@ fun buildProfileCompareData( profile2: Profile, profileName1: String, profileName2: String, - rh: ResourceHelper, + rh: TextResolver, dateUtil: DateUtil, profileUtil: ProfileUtil, profileFunction: ProfileFunction @@ -53,10 +53,10 @@ fun buildProfileCompareData( targetRows = buildTargetRows(profile1, profile2, dateUtil, profileUtil), baseName = profileName1, effectiveName = profileName2, - shortHourUnit = rh.gs(app.aaps.core.interfaces.R.string.shorthour), - icUnits = rh.gs(R.string.profile_carbs_per_unit), - isfUnits = rh.gs(if (units == GlucoseUnit.MGDL) R.string.profile_isf_units_mgdl else R.string.profile_isf_units_mmol), - basalUnits = rh.gs(R.string.profile_ins_units_per_hour), + shortHourUnit = rh.gs(InterfacesStrings.shorthour), + icUnits = rh.gs(InterfacesStrings.profile_carbs_per_unit), + isfUnits = rh.gs(if (units == GlucoseUnit.MGDL) InterfacesStrings.profile_isf_units_mgdl else InterfacesStrings.profile_isf_units_mmol), + basalUnits = rh.gs(InterfacesStrings.profile_ins_units_per_hour), targetUnits = units.displayLabel ) } diff --git a/core/graph/src/main/kotlin/app/aaps/core/graph/profile/ProfileViewerContent.kt b/core/graph/src/commonMain/kotlin/app/aaps/core/graph/profile/ProfileViewerContent.kt similarity index 93% rename from core/graph/src/main/kotlin/app/aaps/core/graph/profile/ProfileViewerContent.kt rename to core/graph/src/commonMain/kotlin/app/aaps/core/graph/profile/ProfileViewerContent.kt index 2ddf3f8cca27..945243e1adb5 100644 --- a/core/graph/src/main/kotlin/app/aaps/core/graph/profile/ProfileViewerContent.kt +++ b/core/graph/src/commonMain/kotlin/app/aaps/core/graph/profile/ProfileViewerContent.kt @@ -14,7 +14,6 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp @@ -24,8 +23,9 @@ import app.aaps.core.graph.IsfProfileGraphCompose import app.aaps.core.graph.TargetBgProfileGraphCompose import app.aaps.core.interfaces.insulin.ConcentrationType import app.aaps.core.interfaces.profile.Profile -import app.aaps.core.ui.R +import app.aaps.core.ui.UiStrings import app.aaps.core.ui.compose.AapsTheme +import app.aaps.core.ui.compose.stringResource /** * Data class representing a single row in a profile comparison table. @@ -70,7 +70,7 @@ fun ProfileSingleContent( ) { Column(modifier = Modifier.padding(16.dp)) { ProfileRow( - label = stringResource(R.string.units_label), + label = stringResource(UiStrings.units_label), value = profile.units.displayLabel ) } @@ -83,7 +83,7 @@ fun ProfileSingleContent( ) { Column(modifier = Modifier.padding(16.dp)) { ProfileRow( - label = stringResource(R.string.insulin_label), + label = stringResource(UiStrings.insulin_label), value = iCfg.insulinLabel ) } @@ -100,7 +100,7 @@ fun ProfileSingleContent( ) { Column(modifier = Modifier.padding(16.dp)) { ProfileRow( - label = stringResource(R.string.ic_label), + label = stringResource(UiStrings.ic_label), value = getIcList(profile) ) IcProfileGraphCompose( @@ -123,7 +123,7 @@ fun ProfileSingleContent( ) { Column(modifier = Modifier.padding(16.dp)) { ProfileRow( - label = stringResource(R.string.isf_label), + label = stringResource(UiStrings.isf_label), value = getIsfList(profile) ) IsfProfileGraphCompose( @@ -146,7 +146,7 @@ fun ProfileSingleContent( ) { Column(modifier = Modifier.padding(16.dp)) { ProfileRow( - label = stringResource(R.string.basal_label), + label = stringResource(UiStrings.basal_label), value = getBasalList(profile) ) // Sum displayed above graph @@ -179,7 +179,7 @@ fun ProfileSingleContent( ) { Column(modifier = Modifier.padding(16.dp)) { ProfileRow( - label = stringResource(R.string.target_label), + label = stringResource(UiStrings.target_label), value = getTargetList(profile) ) TargetBgProfileGraphCompose( @@ -229,7 +229,7 @@ fun ProfileCompareContent( ) { Column(modifier = Modifier.padding(16.dp)) { Text( - text = stringResource(R.string.profile), + text = stringResource(UiStrings.profile), style = MaterialTheme.typography.labelMedium, fontWeight = FontWeight.Bold, modifier = Modifier.padding(bottom = 8.dp) @@ -268,20 +268,20 @@ fun ProfileCompareContent( ) { Column(modifier = Modifier.padding(16.dp)) { ProfileInlineRow( - label = stringResource(R.string.insulin_label), + label = stringResource(UiStrings.insulin_label), value = iCfg.insulinLabel ) ProfileInlineRow( - label = stringResource(R.string.concentration_label), + label = stringResource(UiStrings.concentration_label), value = stringResource(ConcentrationType.fromDouble(iCfg.concentration).label) ) ProfileInlineRow( - label = stringResource(R.string.peak_label), - value = stringResource(R.string.format_mins, iCfg.peak) + label = stringResource(UiStrings.peak_label), + value = stringResource(UiStrings.format_mins, iCfg.peak) ) ProfileInlineRow( - label = stringResource(R.string.dia_label), - value = stringResource(R.string.format_hours, iCfg.dia) + label = stringResource(UiStrings.dia_label), + value = stringResource(UiStrings.format_hours, iCfg.dia) ) } } @@ -296,7 +296,7 @@ fun ProfileCompareContent( ) { Column(modifier = Modifier.padding(16.dp)) { Text( - text = stringResource(R.string.ic_label), + text = stringResource(UiStrings.ic_label), style = MaterialTheme.typography.labelMedium, fontWeight = FontWeight.Bold, modifier = Modifier.padding(bottom = 8.dp) @@ -329,7 +329,7 @@ fun ProfileCompareContent( ) { Column(modifier = Modifier.padding(16.dp)) { Text( - text = stringResource(R.string.isf_label), + text = stringResource(UiStrings.isf_label), style = MaterialTheme.typography.labelMedium, fontWeight = FontWeight.Bold, modifier = Modifier.padding(bottom = 8.dp) @@ -362,7 +362,7 @@ fun ProfileCompareContent( ) { Column(modifier = Modifier.padding(16.dp)) { Text( - text = stringResource(R.string.basal_label), + text = stringResource(UiStrings.basal_label), style = MaterialTheme.typography.labelMedium, fontWeight = FontWeight.Bold, modifier = Modifier.padding(bottom = 8.dp) @@ -395,7 +395,7 @@ fun ProfileCompareContent( ) { Column(modifier = Modifier.padding(16.dp)) { Text( - text = stringResource(R.string.target_label), + text = stringResource(UiStrings.target_label), style = MaterialTheme.typography.labelMedium, fontWeight = FontWeight.Bold, modifier = Modifier.padding(bottom = 8.dp) diff --git a/core/graph/src/main/kotlin/app/aaps/core/graph/profile/ProfileViewerContentPreviews.kt b/core/graph/src/commonMain/kotlin/app/aaps/core/graph/profile/ProfileViewerContentPreviews.kt similarity index 100% rename from core/graph/src/main/kotlin/app/aaps/core/graph/profile/ProfileViewerContentPreviews.kt rename to core/graph/src/commonMain/kotlin/app/aaps/core/graph/profile/ProfileViewerContentPreviews.kt diff --git a/core/graph/src/main/kotlin/app/aaps/core/graph/profile/ProfileViewerScreen.kt b/core/graph/src/commonMain/kotlin/app/aaps/core/graph/profile/ProfileViewerScreen.kt similarity index 97% rename from core/graph/src/main/kotlin/app/aaps/core/graph/profile/ProfileViewerScreen.kt rename to core/graph/src/commonMain/kotlin/app/aaps/core/graph/profile/ProfileViewerScreen.kt index 07c94e9313f2..ebe12af9ecfe 100644 --- a/core/graph/src/main/kotlin/app/aaps/core/graph/profile/ProfileViewerScreen.kt +++ b/core/graph/src/commonMain/kotlin/app/aaps/core/graph/profile/ProfileViewerScreen.kt @@ -27,12 +27,12 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector -import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import app.aaps.core.interfaces.profile.Profile -import app.aaps.core.ui.R +import app.aaps.core.ui.UiStrings import app.aaps.core.ui.compose.AapsTheme +import app.aaps.core.ui.compose.stringResource /** * Data class containing all information needed to display a profile viewer screen. @@ -154,7 +154,7 @@ fun ProfileViewerScreen( } IconButton(onClick = onClose) { - Icon(Icons.Filled.Close, contentDescription = stringResource(R.string.close)) + Icon(Icons.Filled.Close, contentDescription = stringResource(UiStrings.close)) } } } @@ -165,7 +165,7 @@ fun ProfileViewerScreen( // Show error message if no profile is set if (data.profile == null) { Text( - text = stringResource(R.string.no_profile_set), + text = stringResource(UiStrings.no_profile_set), modifier = Modifier .fillMaxWidth() .padding(16.dp), @@ -210,7 +210,7 @@ fun ProfileViewerScreen( .padding(vertical = 4.dp) ) { Text( - text = stringResource(R.string.date), + text = stringResource(UiStrings.date), modifier = Modifier.weight(1f), style = MaterialTheme.typography.bodySmall, fontWeight = FontWeight.Bold diff --git a/core/graph/src/main/kotlin/app/aaps/core/graph/vico/AdaptiveStepConnector.kt b/core/graph/src/commonMain/kotlin/app/aaps/core/graph/vico/AdaptiveStepConnector.kt similarity index 100% rename from core/graph/src/main/kotlin/app/aaps/core/graph/vico/AdaptiveStepConnector.kt rename to core/graph/src/commonMain/kotlin/app/aaps/core/graph/vico/AdaptiveStepConnector.kt diff --git a/core/graph/src/main/kotlin/app/aaps/core/graph/vico/PointConnectors.kt b/core/graph/src/commonMain/kotlin/app/aaps/core/graph/vico/PointConnectors.kt similarity index 100% rename from core/graph/src/main/kotlin/app/aaps/core/graph/vico/PointConnectors.kt rename to core/graph/src/commonMain/kotlin/app/aaps/core/graph/vico/PointConnectors.kt diff --git a/core/interfaces/build.gradle.kts b/core/interfaces/build.gradle.kts index 288f7853b18d..b298658ca78f 100644 --- a/core/interfaces/build.gradle.kts +++ b/core/interfaces/build.gradle.kts @@ -1,49 +1,142 @@ import kotlin.math.min plugins { - alias(libs.plugins.android.library) + kotlin("multiplatform") + // NOT com.android.library. AGP 9 refuses that plugin together with the multiplatform plugin: + // "The 'com.android.library' (or 'com.android.application') plugin is not compatible with the + // 'org.jetbrains.kotlin.multiplatform' plugin since AGP 9.0." + alias(libs.plugins.android.kmp.library) + // The Compose COMPILER, which ships with Kotlin and compiles @Composable for every target. alias(libs.plugins.compose.compiler) - id("kotlin-parcelize") + // The Compose Multiplatform framework. Needed because the compiler plugin above is applied per + // project rather than per target and fails any compilation with no Compose runtime on the class + // path - which is what kept the Apple targets out of this module at first. + alias(libs.plugins.compose.multiplatform) id("kotlinx-serialization") - id("android-module-dependencies") - id("test-module-dependencies") - id("compose-test-module-dependencies") - id("jacoco-module-dependencies") } -android { +// One task, not one per variant. A multiplatform module has no product flavours, and a Kotlin source +// set takes a task provider directly, so the Android variant API this used to go through is not +// needed. Same generator as :core:keys and :core:ui, pointed at this module's strings: it lets the +// data enums here (ConcentrationType, InsulinType, CwfMetadataKey) carry a TextRef instead of an +// R.string Int. The strings themselves do not move, and AAPT keeps resolving them as before. +val generateInterfacesStrings = tasks.register("generateInterfacesStrings") { + resDir.set(layout.projectDirectory.dir("src/androidMain/res")) + packageName.set("app.aaps.core.interfaces") + owner.set("interfaces") + objectName.set("InterfacesStrings") + idsObjectName.set("InterfacesStringIds") + reportFile.set(layout.buildDirectory.file("reports/interfacesStrings/translations.txt")) + // Set explicitly: addGeneratedSourceDirectory only applies a convention derived from the task + // name, so both properties would land on one directory and the second file written would delete + // the first. + commonOutputDir.set(layout.buildDirectory.dir("generated/interfacesStrings/common")) + androidOutputDir.set(layout.buildDirectory.dir("generated/interfacesStrings/android")) +} - namespace = "app.aaps.core.interfaces" - defaultConfig { - minSdk = min(Versions.minSdk, Versions.wearMinSdk) - } +kotlin { + android { + namespace = "app.aaps.core.interfaces" + compileSdk = Versions.compileSdk + minSdk = min(Versions.minSdk, Versions.wearMinSdk) // Compatible with wear module + // Off by default for a multiplatform library, unlike a plain android library. + androidResources { enable = true } + // Creates the androidHostTest compilation, which also pulls in commonTest. + withHostTest { } + compilerOptions { jvmTarget.set(Versions.jvmTarget) } - buildFeatures { - compose = true + // Restated from android-module-dependencies, which this module can no longer apply. Without + // it MissingTranslation would switch on for the first time here and the locale files that are + // empty today would fail a release build. + lint { + checkReleaseBuilds = false + disable += "MissingTranslation" + disable += "ExtraTranslation" + } } -} -dependencies { - implementation(project(":core:data")) - implementation(project(":core:keys")) + // Apple klibs cross compile on Windows. Linking and running still need a Mac, and those tasks + // report SKIPPED rather than failing. + // + // These are what keep commonMain honest. The split was made by compiling for iosArm64 and moving + // whatever failed, so keeping the target means a java.* import added to commonMain later fails + // the build instead of quietly compiling on Android. + // + // Deliberately no jvm() target: it pulls in the desktop Compose surface (skiko-awt) and gives the + // module another way to fail without saying anything about iOS. Recorded in wave 17 of + // _docs/KMP_IOS_FEASIBILITY.md. + iosArm64() + iosSimulatorArm64() + + sourceSets { + commonMain { + kotlin.srcDir(generateInterfacesStrings.flatMap { it.commonOutputDir }) + dependencies { + api(project(":core:data")) + api(project(":core:keys")) + + // project.dependencies.platform, because a Kotlin source set dependency block has no + // platform() of its own. + api(project.dependencies.platform(libs.kotlinx.serialization.bom)) + api(libs.kotlinx.serialization.json) + api(libs.kotlinx.serialization.protobuf) + api(libs.kotlinx.datetime) + api(project.dependencies.platform(libs.kotlinx.coroutines.bom)) + api(libs.kotlinx.coroutines.core) + // Multiplatform since 1.4.0, so LongSparseArray is usable from common code. + // AutosensDataStore, TddCalculator and TirCalculator all expose it, so it stays api. + api(libs.androidx.collection) + // The CMP runtime, so the compose compiler plugin has something to compile against on + // every target. On Android CMP delegates to androidx, so the composeBom still decides + // the Android versions and nothing about the Android build changes. + api(libs.cmp.runtime) + // ImageVector and friends live here, not in the runtime. Several interfaces in this + // module carry an icon, and that alone used to be enough to make them Android only. + api(libs.cmp.ui) + } + } - // Dependency Injection - api(libs.com.google.dagger.android) - api(libs.com.google.dagger.hilt.android) + androidMain { + // Android only: the string name to R.string id map. + kotlin.srcDir(generateInterfacesStrings.flatMap { it.androidOutputDir }) + dependencies { + // Everything here was `api` on the old android library and the 41 consumer modules + // resolve these transitively, so they must stay exported. They are Android or JVM + // only, which is exactly why they belong to this source set rather than commonMain. - api(libs.androidx.appcompat) - api(libs.androidx.compose.ui) - api(libs.androidx.documentfile) + // Dependency Injection + api(libs.com.google.dagger.android) + api(libs.com.google.dagger.hilt.android) - api(platform(libs.kotlinx.serialization.bom)) - api(libs.kotlinx.serialization.json) - api(libs.kotlinx.serialization.protobuf) + api(libs.androidx.appcompat) + api(libs.androidx.compose.ui) + api(libs.androidx.documentfile) - api(libs.org.apache.commons.lang3) - api(libs.net.danlew.android.joda) + api(libs.org.apache.commons.lang3) + api(libs.net.danlew.android.joda) - //RxBus / RxJava base - api(libs.io.reactivex.rxjava3.rxkotlin) + //RxBus / RxJava base + api(libs.io.reactivex.rxjava3.rxkotlin) + } + } - testImplementation(libs.io.reactivex.rxjava3.rxandroid) -} \ No newline at end of file + // Hand written rather than taken from test-module-dependencies, because that convention + // plugin applies com.android.library and so cannot be used here. + getByName("androidHostTest") { + dependencies { + implementation(libs.org.junit.jupiter) + implementation(libs.org.junit.jupiter.api) + implementation(libs.com.google.truth) + implementation(libs.org.mockito.kotlin) + implementation(libs.org.mockito.junit.jupiter) + implementation(libs.kotlinx.coroutines.test) + implementation(libs.io.reactivex.rxjava3.rxandroid) + runtimeOnly(libs.org.junit.platform.launcher) + } + } + } +} + +tasks.withType { + useJUnitPlatform() +} diff --git a/core/interfaces/src/androidHostTest/kotlin/app/aaps/core/interfaces/aps/RtIsoStringParityTest.kt b/core/interfaces/src/androidHostTest/kotlin/app/aaps/core/interfaces/aps/RtIsoStringParityTest.kt new file mode 100644 index 000000000000..c813ef18108a --- /dev/null +++ b/core/interfaces/src/androidHostTest/kotlin/app/aaps/core/interfaces/aps/RtIsoStringParityTest.kt @@ -0,0 +1,114 @@ +package app.aaps.core.interfaces.aps + +import com.google.common.truth.Truth.assertThat +import com.google.common.truth.Truth.assertWithMessage +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import java.text.DateFormat +import java.text.SimpleDateFormat +import java.util.Locale +import java.util.TimeZone + +/** + * Pins [RT.TimestampToIsoSerializer.toISOString] against the `SimpleDateFormat` implementation it is + * being converted away from. + * + * This string is not cosmetic: it is what the APS result timestamp is serialized to, so it travels + * into stored results and to Nightscout. The format has to stay `yyyy-MM-dd'T'HH:mm:ss.SSS'Z'` in + * UTC, with exactly three fractional digits, including when the fraction is zero - which rules out + * `Instant.toString()`, since that drops trailing zeros and omits the fraction entirely on a whole + * second. + */ +class RtIsoStringParityTest { + + private lateinit var originalLocale: Locale + + @BeforeEach fun save() { + originalLocale = Locale.getDefault() + } + + @AfterEach fun restore() { + Locale.setDefault(originalLocale) + } + + /** A literal copy of the original body, so the comparison is against real old behaviour. */ + private fun reference(date: Long, locale: Locale): String { + val f: DateFormat = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", locale) + f.timeZone = TimeZone.getTimeZone("UTC") + return f.format(date) + } + + private val samples = listOf( + 0L, // epoch + 1_785_992_181_588L, // arbitrary, non zero millis + 1_785_992_181_000L, // whole second - the case Instant.toString() would render differently + 1_785_992_181_007L, // sub-10 millis, needs zero padding + 1_767_225_600_000L, // 2026-01-01T00:00:00Z + 253_402_300_799_000L // year 9999 + ) + + @Test fun `matches SimpleDateFormat in a neutral locale`() { + Locale.setDefault(Locale.US) + samples.forEach { t -> + assertWithMessage("t=%s", t) + .that(RT.TimestampToIsoSerializer.toISOString(t)) + .isEqualTo(reference(t, Locale.US)) + } + } + + @Test fun `always has exactly three fractional digits`() { + Locale.setDefault(Locale.US) + samples.forEach { t -> + assertWithMessage("t=%s", t) + .that(RT.TimestampToIsoSerializer.toISOString(t)) + .matches("""\d{4,}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z""") + } + } + + @Test fun `round trips through the parser`() { + Locale.setDefault(Locale.US) + samples.forEach { t -> + val text = RT.TimestampToIsoSerializer.toISOString(t) + assertWithMessage("t=%s via %s", t, text) + .that(RT.TimestampToIsoSerializer.fromISODateString(text)).isEqualTo(t) + } + } + + /** + * The reason this conversion is worth doing beyond portability. + * + * `SimpleDateFormat` resolves its calendar from the locale, and a Thai locale selects the + * Buddhist calendar - so the old code rendered the year as 2569 rather than 2026 for a user + * whose phone is set to Thai. That is a wire format bug, not a display preference: the receiver + * gets a timestamp 543 years in the future. + * + * This asserts the defect exists in the OLD implementation, so the claim is measured rather than + * assumed, and so the test fails loudly if a JDK change ever makes it untrue. + */ + @Test fun `the SimpleDateFormat version was locale sensitive`() { + val thai = Locale.forLanguageTag("th-TH-u-ca-buddhist") + val inThai = reference(1_785_992_181_588L, thai) + val inUs = reference(1_785_992_181_588L, Locale.US) + + assertWithMessage("old implementation in Thai locale: %s", inThai) + .that(inThai).isNotEqualTo(inUs) + assertThat(inUs).startsWith("2026-") + assertThat(inThai).startsWith("2569-") + } + + /** ...and the replacement must not be, whatever the phone's locale is set to. */ + @Test fun `the new version is locale independent`() { + val expected = RT.TimestampToIsoSerializer.toISOString(1_785_992_181_588L) + listOf( + Locale.forLanguageTag("th-TH-u-ca-buddhist"), + Locale.forLanguageTag("ar-EG-u-nu-arab"), + Locale.forLanguageTag("hi-IN"), + Locale.forLanguageTag("ja-JP-u-ca-japanese") + ).forEach { locale -> + Locale.setDefault(locale) + assertWithMessage("locale %s", locale) + .that(RT.TimestampToIsoSerializer.toISOString(1_785_992_181_588L)).isEqualTo(expected) + } + } +} diff --git a/core/interfaces/src/test/kotlin/app/aaps/core/interfaces/db/ClockSkewCompensationTest.kt b/core/interfaces/src/androidHostTest/kotlin/app/aaps/core/interfaces/db/ClockSkewCompensationTest.kt similarity index 100% rename from core/interfaces/src/test/kotlin/app/aaps/core/interfaces/db/ClockSkewCompensationTest.kt rename to core/interfaces/src/androidHostTest/kotlin/app/aaps/core/interfaces/db/ClockSkewCompensationTest.kt diff --git a/core/interfaces/src/test/kotlin/app/aaps/core/interfaces/insulin/ConcentrationTypeTest.kt b/core/interfaces/src/androidHostTest/kotlin/app/aaps/core/interfaces/insulin/ConcentrationTypeTest.kt similarity index 100% rename from core/interfaces/src/test/kotlin/app/aaps/core/interfaces/insulin/ConcentrationTypeTest.kt rename to core/interfaces/src/androidHostTest/kotlin/app/aaps/core/interfaces/insulin/ConcentrationTypeTest.kt diff --git a/core/interfaces/src/test/kotlin/app/aaps/core/interfaces/pump/BolusProgressDataTest.kt b/core/interfaces/src/androidHostTest/kotlin/app/aaps/core/interfaces/pump/BolusProgressDataTest.kt similarity index 98% rename from core/interfaces/src/test/kotlin/app/aaps/core/interfaces/pump/BolusProgressDataTest.kt rename to core/interfaces/src/androidHostTest/kotlin/app/aaps/core/interfaces/pump/BolusProgressDataTest.kt index 3dfd1eec9781..2e1c06055f70 100644 --- a/core/interfaces/src/test/kotlin/app/aaps/core/interfaces/pump/BolusProgressDataTest.kt +++ b/core/interfaces/src/androidHostTest/kotlin/app/aaps/core/interfaces/pump/BolusProgressDataTest.kt @@ -25,7 +25,7 @@ class BolusProgressDataTest { whenever(ch.fromPump(any(), any())).thenAnswer { (it.arguments[0] as PumpInsulin).cU } whenever(ch.bolusProgressString(any(), any())).thenReturn("") whenever(ch.bolusProgressString(any(), any(), any())).thenReturn("") - sut = BolusProgressData(ch, rh, TestScope()) + sut = BolusProgressData(ch, TestScope()) } @Test diff --git a/core/interfaces/src/test/kotlin/app/aaps/core/interfaces/pump/PumpInsulinTest.kt b/core/interfaces/src/androidHostTest/kotlin/app/aaps/core/interfaces/pump/PumpInsulinTest.kt similarity index 100% rename from core/interfaces/src/test/kotlin/app/aaps/core/interfaces/pump/PumpInsulinTest.kt rename to core/interfaces/src/androidHostTest/kotlin/app/aaps/core/interfaces/pump/PumpInsulinTest.kt diff --git a/core/interfaces/src/test/kotlin/app/aaps/core/interfaces/pump/PumpRateTest.kt b/core/interfaces/src/androidHostTest/kotlin/app/aaps/core/interfaces/pump/PumpRateTest.kt similarity index 100% rename from core/interfaces/src/test/kotlin/app/aaps/core/interfaces/pump/PumpRateTest.kt rename to core/interfaces/src/androidHostTest/kotlin/app/aaps/core/interfaces/pump/PumpRateTest.kt diff --git a/core/interfaces/src/androidHostTest/kotlin/app/aaps/core/interfaces/rx/ChunkedOnQuietPeriodTest.kt b/core/interfaces/src/androidHostTest/kotlin/app/aaps/core/interfaces/rx/ChunkedOnQuietPeriodTest.kt new file mode 100644 index 000000000000..86e3b6338917 --- /dev/null +++ b/core/interfaces/src/androidHostTest/kotlin/app/aaps/core/interfaces/rx/ChunkedOnQuietPeriodTest.kt @@ -0,0 +1,90 @@ +package app.aaps.core.interfaces.rx + +import com.google.common.truth.Truth.assertThat +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Test + +/** + * Covers [chunkedOnQuietPeriod], the Flow replacement for the RxJava + * `publish { shared.buffer(shared.debounce(quietPeriod)) }` batching used for the Wear health + * events. The virtual clock of [runTest] makes the quiet period exact instead of flaky. + */ +@OptIn(ExperimentalCoroutinesApi::class) +internal class ChunkedOnQuietPeriodTest { + + private val quietPeriod = 1_000L + + @Test + fun `items sent in one burst come out as a single batch`() = runTest { + val source = MutableSharedFlow() + val batches = mutableListOf>() + val collector = launch { source.chunkedOnQuietPeriod(quietPeriod).collect { batches += it } } + runCurrent() + + source.emit(1) + advanceTimeBy(quietPeriod / 2) + source.emit(2) + advanceTimeBy(quietPeriod / 2) + source.emit(3) + + // Every item restarted the timer, so nothing is out yet even though 1.5 periods passed. + advanceTimeBy(quietPeriod - 1) + runCurrent() + assertThat(batches).isEmpty() + + advanceTimeBy(1) + runCurrent() + assertThat(batches).containsExactly(listOf(1, 2, 3)) + collector.cancel() + } + + @Test + fun `a new burst after the quiet period starts a new batch`() = runTest { + val source = MutableSharedFlow() + val batches = mutableListOf>() + val collector = launch { source.chunkedOnQuietPeriod(quietPeriod).collect { batches += it } } + runCurrent() + + source.emit(1) + advanceTimeBy(quietPeriod + 1) + runCurrent() + source.emit(2) + source.emit(3) + advanceTimeBy(quietPeriod + 1) + runCurrent() + + // Batches must not carry items over from the batch before them. + assertThat(batches).containsExactly(listOf(1), listOf(2, 3)).inOrder() + collector.cancel() + } + + @Test + fun `a source that ends still delivers the last batch`() = runTest { + // flowOf completes right away, so the batch is only released by the quiet period timer. + val batches = flowOf(1, 2, 3).chunkedOnQuietPeriod(quietPeriod).toList() + + assertThat(batches).containsExactly(listOf(1, 2, 3)) + } + + @Test + fun `a source that sends nothing produces no batch`() = runTest { + val source = MutableSharedFlow() + val batches = mutableListOf>() + val collector = launch { source.chunkedOnQuietPeriod(quietPeriod).collect { batches += it } } + runCurrent() + + advanceTimeBy(quietPeriod * 5) + runCurrent() + + // The timer only runs while a batch is open, so an idle source stays silent. + assertThat(batches).isEmpty() + collector.cancel() + } +} diff --git a/core/interfaces/src/androidHostTest/kotlin/app/aaps/core/interfaces/rx/ResilientCollectStartTest.kt b/core/interfaces/src/androidHostTest/kotlin/app/aaps/core/interfaces/rx/ResilientCollectStartTest.kt new file mode 100644 index 000000000000..c81f5d21c09c --- /dev/null +++ b/core/interfaces/src/androidHostTest/kotlin/app/aaps/core/interfaces/rx/ResilientCollectStartTest.kt @@ -0,0 +1,75 @@ +package app.aaps.core.interfaces.rx + +import app.aaps.core.interfaces.logging.AAPSLogger +import app.aaps.core.interfaces.logging.LTag +import com.google.common.truth.Truth.assertThat +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.flow.MutableSharedFlow +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Test +import org.mockito.kotlin.mock + +/** + * The subscription window in [collectResilient], and the [CoroutineStart] that closes it. + * + * This is the shape `RxBus` publishes through - a `MutableSharedFlow` with **replay 0** - so anything + * emitted before a collector has actually subscribed is dropped, with no error and no log. The default + * `launchIn` only *schedules* the collector, so converting an RxJava `subscribe()` (which registered + * synchronously) to a Flow collector silently acquires that window. + * + * The first test is the interesting one: it does not assert the event is lost, because that is a race + * and would be flaky in the other direction. It asserts the guarantee that matters - that with + * UNDISPATCHED the emission is *never* lost - and the second test documents that the default gives no + * such guarantee by showing subscription has not happened yet when the call returns. + */ +class ResilientCollectStartTest { + + private val aapsLogger: AAPSLogger = mock() + private val scope = CoroutineScope(Dispatchers.Default + SupervisorJob()) + + @AfterEach fun tearDown() = scope.cancel() + + private fun busLikeFlow() = MutableSharedFlow( + extraBufferCapacity = 64, + onBufferOverflow = BufferOverflow.DROP_OLDEST + ) + + @Test + fun `undispatched start receives an item emitted immediately after subscribing`() { + val bus = busLikeFlow() + val seen = mutableListOf() + + bus.collectResilient(scope, aapsLogger, LTag.CORE, start = CoroutineStart.UNDISPATCHED) { seen += it } + // No await, no delay: this is the exact pattern a converted RxBus subscription has to survive, + // and the whole point is that the subscribe already happened above. + val delivered = bus.tryEmit("event") + + assertThat(delivered).isTrue() + assertThat(bus.subscriptionCount.value).isEqualTo(1) + } + + @Test + fun `the default start has not subscribed yet when the call returns`() { + val bus = busLikeFlow() + + bus.collectResilient(scope, aapsLogger, LTag.CORE) { } + + // Nothing is collecting yet, so a replay-0 source would drop anything emitted right now. This + // is why the bus subscriptions ask for UNDISPATCHED and a StateFlow collector need not. + assertThat(bus.subscriptionCount.value).isEqualTo(0) + } + + @Test + fun `undispatched start has subscribed by the time the call returns`() { + val bus = busLikeFlow() + + bus.collectResilient(scope, aapsLogger, LTag.CORE, start = CoroutineStart.UNDISPATCHED) { } + + assertThat(bus.subscriptionCount.value).isEqualTo(1) + } +} diff --git a/core/interfaces/src/test/kotlin/app/aaps/core/interfaces/rx/weardata/EventDataTest.kt b/core/interfaces/src/androidHostTest/kotlin/app/aaps/core/interfaces/rx/weardata/EventDataTest.kt similarity index 100% rename from core/interfaces/src/test/kotlin/app/aaps/core/interfaces/rx/weardata/EventDataTest.kt rename to core/interfaces/src/androidHostTest/kotlin/app/aaps/core/interfaces/rx/weardata/EventDataTest.kt diff --git a/core/interfaces/src/test/kotlin/app/aaps/core/interfaces/rx/weardata/LoopStatusDataTest.kt b/core/interfaces/src/androidHostTest/kotlin/app/aaps/core/interfaces/rx/weardata/LoopStatusDataTest.kt similarity index 100% rename from core/interfaces/src/test/kotlin/app/aaps/core/interfaces/rx/weardata/LoopStatusDataTest.kt rename to core/interfaces/src/androidHostTest/kotlin/app/aaps/core/interfaces/rx/weardata/LoopStatusDataTest.kt diff --git a/core/interfaces/src/androidHostTest/kotlin/app/aaps/core/interfaces/tempTargets/TempTargetPresetExtensionsTest.kt b/core/interfaces/src/androidHostTest/kotlin/app/aaps/core/interfaces/tempTargets/TempTargetPresetExtensionsTest.kt new file mode 100644 index 000000000000..e2c03f2232a5 --- /dev/null +++ b/core/interfaces/src/androidHostTest/kotlin/app/aaps/core/interfaces/tempTargets/TempTargetPresetExtensionsTest.kt @@ -0,0 +1,94 @@ +package app.aaps.core.interfaces.tempTargets + +import app.aaps.core.data.model.TT +import app.aaps.core.data.model.TTPreset +import com.google.common.truth.Truth.assertThat +import org.junit.jupiter.api.Test + +/** + * Pins the temp-target preset list, which is a **stored preference** on every existing install. + * + * There was no test on the parser at all. That is thin for a reader whose failure mode is silent: it + * catches everything and answers with an empty list, so a regression does not crash - the user simply + * finds their presets gone. These tests were written against the `org.json` implementation and kept + * unchanged across the move to kotlinx, so they say the new reader accepts exactly what the old one + * wrote. + */ +class TempTargetPresetExtensionsTest { + + private val presets = listOf( + TTPreset(id = "eating", name = "Eating soon", reason = TT.Reason.EATING_SOON, targetValue = 90.0, duration = 2_700_000L, isDeletable = false), + TTPreset(id = "activity", name = null, reason = TT.Reason.ACTIVITY, targetValue = 140.0, duration = 5_400_000L, isDeletable = true) + ) + + @Test + fun `a written list reads back unchanged`() { + assertThat(presets.toJson().toTTPresets()).isEqualTo(presets) + } + + /** Exactly what an existing install has stored, written by the previous org.json code. */ + @Test + fun `an already stored document still parses`() { + val stored = """[{"id":"eating","name":"Eating soon","reason":"Eating Soon","targetValue":90,""" + + """"duration":2700000,"isDeletable":false},{"id":"activity","reason":"Activity",""" + + """"targetValue":140,"duration":5400000,"isDeletable":true}]""" + + val parsed = stored.toTTPresets() + + assertThat(parsed).hasSize(2) + assertThat(parsed[0].id).isEqualTo("eating") + assertThat(parsed[0].name).isEqualTo("Eating soon") + assertThat(parsed[0].reason).isEqualTo(TT.Reason.EATING_SOON) + assertThat(parsed[0].targetValue).isEqualTo(90.0) + assertThat(parsed[0].duration).isEqualTo(2_700_000L) + assertThat(parsed[0].isDeletable).isFalse() + // A missing "name" is null, not an empty string. + assertThat(parsed[1].name).isNull() + assertThat(parsed[1].isDeletable).isTrue() + } + + /** Numbers written as quoted strings occur in the wild and were accepted before. */ + @Test + fun `quoted numbers are accepted`() { + val quoted = """[{"id":"a","reason":"Activity","targetValue":"140.5","duration":"5400000","isDeletable":"true"}]""" + + val parsed = quoted.toTTPresets() + + assertThat(parsed).hasSize(1) + assertThat(parsed[0].targetValue).isEqualTo(140.5) + assertThat(parsed[0].duration).isEqualTo(5_400_000L) + assertThat(parsed[0].isDeletable).isTrue() + } + + /** An explicit JSON null for name behaves like an absent one. */ + @Test + fun `an explicit null name is treated as absent`() { + val withNull = """[{"id":"a","name":null,"reason":"Activity","targetValue":140,"duration":1,"isDeletable":true}]""" + + assertThat(withNull.toTTPresets().single().name).isNull() + } + + @Test + fun `empty and blank documents give an empty list`() { + assertThat("".toTTPresets()).isEmpty() + assertThat("[]".toTTPresets()).isEmpty() + } + + /** + * Unreadable input must never propagate. The list is read on a settings screen and in the wizard; + * throwing there would take the screen down over a corrupt preference. + */ + @Test + fun `malformed input gives an empty list rather than throwing`() { + assertThat("not json".toTTPresets()).isEmpty() + assertThat("""{"not":"an array"}""".toTTPresets()).isEmpty() + assertThat("""[{"id":"a"}]""".toTTPresets()).isEmpty() // missing required fields + } + + @Test + fun `a name is omitted rather than written as null`() { + val json = listOf(presets[1]).toJson() + + assertThat(json).doesNotContain("name") + } +} diff --git a/core/interfaces/src/test/kotlin/app/aaps/core/interfaces/utils/SafeParseTest.kt b/core/interfaces/src/androidHostTest/kotlin/app/aaps/core/interfaces/utils/SafeParseTest.kt similarity index 100% rename from core/interfaces/src/test/kotlin/app/aaps/core/interfaces/utils/SafeParseTest.kt rename to core/interfaces/src/androidHostTest/kotlin/app/aaps/core/interfaces/utils/SafeParseTest.kt diff --git a/core/interfaces/src/main/AndroidManifest.xml b/core/interfaces/src/androidMain/AndroidManifest.xml similarity index 99% rename from core/interfaces/src/main/AndroidManifest.xml rename to core/interfaces/src/androidMain/AndroidManifest.xml index aebf62b71101..0689101c51ea 100644 --- a/core/interfaces/src/main/AndroidManifest.xml +++ b/core/interfaces/src/androidMain/AndroidManifest.xml @@ -1,4 +1,5 @@ + \ No newline at end of file diff --git a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/di/APS.kt b/core/interfaces/src/androidMain/kotlin/app/aaps/core/interfaces/di/APS.kt similarity index 100% rename from core/interfaces/src/main/kotlin/app/aaps/core/interfaces/di/APS.kt rename to core/interfaces/src/androidMain/kotlin/app/aaps/core/interfaces/di/APS.kt diff --git a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/di/AllConfigs.kt b/core/interfaces/src/androidMain/kotlin/app/aaps/core/interfaces/di/AllConfigs.kt similarity index 100% rename from core/interfaces/src/main/kotlin/app/aaps/core/interfaces/di/AllConfigs.kt rename to core/interfaces/src/androidMain/kotlin/app/aaps/core/interfaces/di/AllConfigs.kt diff --git a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/di/ApplicationScope.kt b/core/interfaces/src/androidMain/kotlin/app/aaps/core/interfaces/di/ApplicationScope.kt similarity index 100% rename from core/interfaces/src/main/kotlin/app/aaps/core/interfaces/di/ApplicationScope.kt rename to core/interfaces/src/androidMain/kotlin/app/aaps/core/interfaces/di/ApplicationScope.kt diff --git a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/di/NotNSClient.kt b/core/interfaces/src/androidMain/kotlin/app/aaps/core/interfaces/di/NotNSClient.kt similarity index 100% rename from core/interfaces/src/main/kotlin/app/aaps/core/interfaces/di/NotNSClient.kt rename to core/interfaces/src/androidMain/kotlin/app/aaps/core/interfaces/di/NotNSClient.kt diff --git a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/di/PumpDriver.kt b/core/interfaces/src/androidMain/kotlin/app/aaps/core/interfaces/di/PumpDriver.kt similarity index 100% rename from core/interfaces/src/main/kotlin/app/aaps/core/interfaces/di/PumpDriver.kt rename to core/interfaces/src/androidMain/kotlin/app/aaps/core/interfaces/di/PumpDriver.kt diff --git a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/maintenance/FileListProvider.kt b/core/interfaces/src/androidMain/kotlin/app/aaps/core/interfaces/maintenance/FileListProvider.kt similarity index 100% rename from core/interfaces/src/main/kotlin/app/aaps/core/interfaces/maintenance/FileListProvider.kt rename to core/interfaces/src/androidMain/kotlin/app/aaps/core/interfaces/maintenance/FileListProvider.kt diff --git a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/notifications/NotificationHolder.kt b/core/interfaces/src/androidMain/kotlin/app/aaps/core/interfaces/notifications/NotificationHolder.kt similarity index 75% rename from core/interfaces/src/main/kotlin/app/aaps/core/interfaces/notifications/NotificationHolder.kt rename to core/interfaces/src/androidMain/kotlin/app/aaps/core/interfaces/notifications/NotificationHolder.kt index 862b1ca9199c..c3527b82cf37 100644 --- a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/notifications/NotificationHolder.kt +++ b/core/interfaces/src/androidMain/kotlin/app/aaps/core/interfaces/notifications/NotificationHolder.kt @@ -2,7 +2,6 @@ package app.aaps.core.interfaces.notifications import android.app.Notification import android.app.PendingIntent -import android.content.Context interface NotificationHolder { @@ -10,6 +9,6 @@ interface NotificationHolder { val notificationID: Int var notification: Notification - fun openAppIntent(context: Context): PendingIntent? + fun openAppIntent(): PendingIntent? fun createNotificationChannel() } \ No newline at end of file diff --git a/core/interfaces/src/androidMain/kotlin/app/aaps/core/interfaces/plugin/PluginBaseExtension.kt b/core/interfaces/src/androidMain/kotlin/app/aaps/core/interfaces/plugin/PluginBaseExtension.kt new file mode 100644 index 000000000000..4bd8116bdbca --- /dev/null +++ b/core/interfaces/src/androidMain/kotlin/app/aaps/core/interfaces/plugin/PluginBaseExtension.kt @@ -0,0 +1,21 @@ +package app.aaps.core.interfaces.plugin + +import android.content.Context +import android.content.pm.PackageManager +import androidx.core.content.ContextCompat + +/** + * Returns [PluginBase.requiredPermissions] that are not yet granted. + * Special permission groups are excluded - they need dedicated checks. + * + * This was a member of [PluginBase]. It took a [Context] and asked Android whether a permission is + * granted, and it was the only thing in that class that touched Android at all - so one method kept + * the base class every plugin extends, and everything that names it, out of common code. As an + * extension it lives where it belongs and [PluginBase] itself is platform neutral. + */ +fun PluginBase.missingPermissions(context: Context): List = + requiredPermissions().filter { group -> + !group.special && group.permissions.any { permission -> + ContextCompat.checkSelfPermission(context, permission) != PackageManager.PERMISSION_GRANTED + } + } diff --git a/core/interfaces/src/androidMain/kotlin/app/aaps/core/interfaces/plugin/PluginPermissions.kt b/core/interfaces/src/androidMain/kotlin/app/aaps/core/interfaces/plugin/PluginPermissions.kt new file mode 100644 index 000000000000..95de55f0e0d9 --- /dev/null +++ b/core/interfaces/src/androidMain/kotlin/app/aaps/core/interfaces/plugin/PluginPermissions.kt @@ -0,0 +1,24 @@ +package app.aaps.core.interfaces.plugin + +import android.content.Context + +/** + * Permission state collected across all enabled plugins. + * + * Kept apart from [ActivePlugin] because it needs a [Context] and the Android permission model, + * and both exist only on Android. The same store implements both interfaces. + */ +interface PluginPermissions { + + /** + * Collects missing permissions across all enabled plugins, deduplicated by permission set. + */ + fun collectMissingPermissions(context: Context): List + + /** + * Collects all required permissions (both global and plugin-declared), + * regardless of grant status. Used by the permission UI to show both + * granted and missing permissions. + */ + fun collectAllPermissions(context: Context): List +} diff --git a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/pump/BlePreCheck.kt b/core/interfaces/src/androidMain/kotlin/app/aaps/core/interfaces/pump/BlePreCheck.kt similarity index 83% rename from core/interfaces/src/main/kotlin/app/aaps/core/interfaces/pump/BlePreCheck.kt rename to core/interfaces/src/androidMain/kotlin/app/aaps/core/interfaces/pump/BlePreCheck.kt index 9e2895de5c72..0be9e7f0b893 100644 --- a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/pump/BlePreCheck.kt +++ b/core/interfaces/src/androidMain/kotlin/app/aaps/core/interfaces/pump/BlePreCheck.kt @@ -1,6 +1,5 @@ package app.aaps.core.interfaces.pump -import android.content.Context import androidx.appcompat.app.AppCompatActivity interface BlePreCheck { @@ -12,6 +11,6 @@ interface BlePreCheck { * Attempts to enable Bluetooth if permissions are granted. * Returns [BlePreCheckResult] indicating the current state. */ - fun checkBleReady(context: Context): BlePreCheckResult + fun checkBleReady(): BlePreCheckResult } \ No newline at end of file diff --git a/core/interfaces/src/androidMain/kotlin/app/aaps/core/interfaces/pump/BluetoothPermission.android.kt b/core/interfaces/src/androidMain/kotlin/app/aaps/core/interfaces/pump/BluetoothPermission.android.kt new file mode 100644 index 000000000000..e72beac19e46 --- /dev/null +++ b/core/interfaces/src/androidMain/kotlin/app/aaps/core/interfaces/pump/BluetoothPermission.android.kt @@ -0,0 +1,18 @@ +package app.aaps.core.interfaces.pump + +import android.Manifest +import android.annotation.SuppressLint +import app.aaps.core.interfaces.InterfacesStrings +import app.aaps.core.interfaces.plugin.PermissionGroup + +/** + * `InlinedApi` because BLUETOOTH_CONNECT and BLUETOOTH_SCAN are newer than minSdk. Inlining the + * constants is what the old code did too - the strings are stable and the request is skipped on + * older versions, where the permissions are granted at install time. + */ +@SuppressLint("InlinedApi") +internal actual fun bluetoothPermissionGroup(): PermissionGroup? = PermissionGroup( + permissions = listOf(Manifest.permission.BLUETOOTH_CONNECT, Manifest.permission.BLUETOOTH_SCAN), + rationaleTitle = InterfacesStrings.permission_bluetooth_title, + rationaleDescription = InterfacesStrings.permission_bluetooth_description, +) diff --git a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/pump/rfcomm/RfcommTransport.kt b/core/interfaces/src/androidMain/kotlin/app/aaps/core/interfaces/pump/rfcomm/RfcommTransport.kt similarity index 100% rename from core/interfaces/src/main/kotlin/app/aaps/core/interfaces/pump/rfcomm/RfcommTransport.kt rename to core/interfaces/src/androidMain/kotlin/app/aaps/core/interfaces/pump/rfcomm/RfcommTransport.kt diff --git a/core/interfaces/src/androidMain/kotlin/app/aaps/core/interfaces/resources/ResourceHelper.kt b/core/interfaces/src/androidMain/kotlin/app/aaps/core/interfaces/resources/ResourceHelper.kt new file mode 100644 index 000000000000..aeb4dcd50546 --- /dev/null +++ b/core/interfaces/src/androidMain/kotlin/app/aaps/core/interfaces/resources/ResourceHelper.kt @@ -0,0 +1,72 @@ +package app.aaps.core.interfaces.resources + +import androidx.annotation.PluralsRes +import androidx.annotation.StringRes +import app.aaps.core.interfaces.InterfacesStringIds +import app.aaps.core.keys.KeysStringIds +import app.aaps.core.keys.interfaces.TextRef +import app.aaps.core.keys.interfaces.TextRef.Companion.withArgs + +interface ResourceHelper : TextResolver { + + fun gs(@StringRes id: Int): String + fun gs(@StringRes id: Int, vararg args: Any?): String + fun gq(@PluralsRes id: Int, quantity: Int, vararg args: Any?): String + fun gsNotLocalised(@StringRes id: Int, vararg args: Any?): String + + /** + * Resolves a [TextRef] outside Compose. Inside Compose use + * `app.aaps.core.ui.compose.stringResource` instead. + * + * Every non-Compose reader of a preference title goes through here, which is the point: a module + * that stops naming resource ids in its own code changes only this method, not its call sites. + * + * [TextRef.Named] still ends up in `Resources` on Android - the name is turned into an id first - + * so locale matching behaves exactly as it does for [TextRef.AndroidRes]. + */ + override fun gs(ref: TextRef): String = when (ref) { + is TextRef.Literal -> ref.text + is TextRef.AndroidRes -> + if (ref.args.isEmpty()) gs(ref.id) + else gs(ref.id, *ref.args.toTypedArray()) + + is TextRef.Named -> { + val id = keysIdOf(ref) + when { + id == null -> ref.name + ref.args.isEmpty() -> gs(id) + else -> gs(id, *ref.args.toTypedArray()) + } + } + } + + /** Same, with format arguments - mirrors `gs(id, vararg)`. */ + override fun gs(ref: TextRef, vararg args: Any): String = gs(ref.withArgs(*args)) + + /** Same, but always in English - used to build the search index. */ + override fun gsNotLocalised(ref: TextRef): String = when (ref) { + is TextRef.Literal -> ref.text + is TextRef.AndroidRes -> gsNotLocalised(ref.id, *ref.args.toTypedArray()) + is TextRef.Named -> keysIdOf(ref) + ?.let { gsNotLocalised(it, *ref.args.toTypedArray()) } + ?: ref.name + } + + override fun shortTextMode(): Boolean +} + +/** + * Resolves a [TextRef.Named] that this module can see. + * + * Two owners are resolvable directly: `keys`, via the `:core:keys` dependency, and `interfaces`, + * whose map is generated into this module. + * + * Anything else is asked of [TextRefIdRegistry], which is how a module further up - `:core:ui` and + * its `ui` names - makes itself resolvable here. Only when nobody has claimed the owner does this + * fall back to showing the raw name, which is visibly wrong rather than silently blank. + */ +private fun keysIdOf(ref: TextRef.Named): Int? = when (ref.owner) { + "keys" -> KeysStringIds.idOf(ref.name) + "interfaces" -> InterfacesStringIds.idOf(ref.name) + else -> TextRefIdRegistry.idOf(ref) +} diff --git a/core/interfaces/src/androidMain/kotlin/app/aaps/core/interfaces/resources/TextRefIdRegistry.kt b/core/interfaces/src/androidMain/kotlin/app/aaps/core/interfaces/resources/TextRefIdRegistry.kt new file mode 100644 index 000000000000..9bda921ee230 --- /dev/null +++ b/core/interfaces/src/androidMain/kotlin/app/aaps/core/interfaces/resources/TextRefIdRegistry.kt @@ -0,0 +1,30 @@ +package app.aaps.core.interfaces.resources + +import app.aaps.core.keys.interfaces.TextRef + +/** + * Where a module tells the rest of the app how to resolve the names it owns. + * + * `ResourceHelper` can only see the id maps of the modules it depends on - `keys` and `interfaces`. + * A name owned by a module further up, such as `ui`, is invisible to it, and the fallback is to show + * the raw name. That is fine while such names are only read from Composables, because + * `app.aaps.core.ui.compose.stringResource` lives in `:core:ui` and can see all of them. It stops + * being fine the moment a non-Compose caller wants one - which is what happened when the shared + * builders started naming their strings instead of numbering them, and `format_carbs` appeared on + * the overview instead of "12 g". + * + * Registration happens once, from `ResourceHelperImpl`, which is downstream of every module that + * owns strings and is constructed before anything can ask it for text. + */ +object TextRefIdRegistry { + + private val lookups = mutableMapOf Int?>() + + /** Teaches the resolver how to turn a name owned by [owner] into a resource id. */ + fun register(owner: String, idOf: (String) -> Int?) { + lookups[owner] = idOf + } + + /** The id for [ref], or null when no module has claimed that owner. */ + fun idOf(ref: TextRef.Named): Int? = lookups[ref.owner]?.invoke(ref.name) +} diff --git a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/rx/AapsSchedulers.kt b/core/interfaces/src/androidMain/kotlin/app/aaps/core/interfaces/rx/AapsSchedulers.kt similarity index 100% rename from core/interfaces/src/main/kotlin/app/aaps/core/interfaces/rx/AapsSchedulers.kt rename to core/interfaces/src/androidMain/kotlin/app/aaps/core/interfaces/rx/AapsSchedulers.kt diff --git a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/sharedPreferences/SP.kt b/core/interfaces/src/androidMain/kotlin/app/aaps/core/interfaces/sharedPreferences/SP.kt similarity index 88% rename from core/interfaces/src/main/kotlin/app/aaps/core/interfaces/sharedPreferences/SP.kt rename to core/interfaces/src/androidMain/kotlin/app/aaps/core/interfaces/sharedPreferences/SP.kt index f7a153ae1e90..07272d52dfd6 100644 --- a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/sharedPreferences/SP.kt +++ b/core/interfaces/src/androidMain/kotlin/app/aaps/core/interfaces/sharedPreferences/SP.kt @@ -17,19 +17,13 @@ interface SP { fun clear() - fun remove(@StringRes resourceID: Int) fun remove(key: String) fun putBoolean(key: String, value: Boolean) - fun putBoolean(@StringRes resourceID: Int, value: Boolean) fun putDouble(key: String, value: Double) - fun putDouble(@StringRes resourceID: Int, value: Double) fun putLong(key: String, value: Long) - fun putLong(@StringRes resourceID: Int, value: Long) fun putInt(key: String, value: Int) - fun putInt(@StringRes resourceID: Int, value: Int) fun putString(key: String, value: String) - fun putString(@StringRes resourceID: Int, value: String) } /** diff --git a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/smsCommunicator/Sms.kt b/core/interfaces/src/androidMain/kotlin/app/aaps/core/interfaces/smsCommunicator/Sms.kt similarity index 92% rename from core/interfaces/src/main/kotlin/app/aaps/core/interfaces/smsCommunicator/Sms.kt rename to core/interfaces/src/androidMain/kotlin/app/aaps/core/interfaces/smsCommunicator/Sms.kt index 66c38d69433c..8039430b7b0d 100644 --- a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/smsCommunicator/Sms.kt +++ b/core/interfaces/src/androidMain/kotlin/app/aaps/core/interfaces/smsCommunicator/Sms.kt @@ -1,6 +1,7 @@ package app.aaps.core.interfaces.smsCommunicator import android.telephony.SmsMessage +import kotlin.time.Clock class Sms { @@ -22,7 +23,7 @@ class Sms { constructor(phoneNumber: String, text: String) { this.phoneNumber = phoneNumber this.text = text - date = System.currentTimeMillis() + date = Clock.System.now().toEpochMilliseconds() sent = true } diff --git a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/smsCommunicator/SmsCommunicator.kt b/core/interfaces/src/androidMain/kotlin/app/aaps/core/interfaces/smsCommunicator/SmsCommunicator.kt similarity index 100% rename from core/interfaces/src/main/kotlin/app/aaps/core/interfaces/smsCommunicator/SmsCommunicator.kt rename to core/interfaces/src/androidMain/kotlin/app/aaps/core/interfaces/smsCommunicator/SmsCommunicator.kt diff --git a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/storage/Storage.kt b/core/interfaces/src/androidMain/kotlin/app/aaps/core/interfaces/storage/Storage.kt similarity index 99% rename from core/interfaces/src/main/kotlin/app/aaps/core/interfaces/storage/Storage.kt rename to core/interfaces/src/androidMain/kotlin/app/aaps/core/interfaces/storage/Storage.kt index 3c4e1c7792a4..631ebc47a00c 100644 --- a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/storage/Storage.kt +++ b/core/interfaces/src/androidMain/kotlin/app/aaps/core/interfaces/storage/Storage.kt @@ -8,11 +8,14 @@ import java.io.File interface Storage { fun putFileContents(file: File, contents: String) + @Throws(SecurityException::class) fun putFileContents(contentResolver: ContentResolver, file: DocumentFile, contents: String) fun getFileContents(file: File): String + @Throws(SecurityException::class) fun getFileContents(contentResolver: ContentResolver, file: DocumentFile): String + @Throws(SecurityException::class) fun getBinaryFileContents(contentResolver: ContentResolver, file: DocumentFile): ByteArray? } diff --git a/core/interfaces/src/androidMain/kotlin/app/aaps/core/interfaces/utils/DateUtilAndroid.kt b/core/interfaces/src/androidMain/kotlin/app/aaps/core/interfaces/utils/DateUtilAndroid.kt new file mode 100644 index 000000000000..2228f3744f2a --- /dev/null +++ b/core/interfaces/src/androidMain/kotlin/app/aaps/core/interfaces/utils/DateUtilAndroid.kt @@ -0,0 +1,32 @@ +package app.aaps.core.interfaces.utils + +import app.aaps.core.interfaces.R +import app.aaps.core.interfaces.resources.ResourceHelper +import kotlin.time.Duration.Companion.days +import kotlin.time.Duration.Companion.hours +import kotlin.time.Duration.Companion.milliseconds + +/** + * Formats an age as "2 days 3 hours ago". + * + * This is an Android extension rather than a [DateUtil] member because it needs plurals, and a + * [app.aaps.core.keys.interfaces.TextRef] cannot carry a plural today. Everything else on [DateUtil] + * resolves through [app.aaps.core.interfaces.resources.TextResolver] and works on any platform. + */ +fun timeAgoFullString(milliseconds: Long, rh: ResourceHelper): String = + when { + milliseconds <= 0 -> "" + + else -> { + val duration = milliseconds.milliseconds + val days = duration.inWholeDays + val hours = (duration - days.days).inWholeHours + val minutes = (duration - days.days - hours.hours).inWholeMinutes + when { + days > 0 -> rh.gq(R.plurals.plurals_day_hour_ago, days.toInt(), days.toString(), hours.toString()) + hours > 0 -> rh.gq(R.plurals.plurals_hour_ago, hours.toInt(), hours.toString()) + minutes > 0 -> rh.gq(R.plurals.plurals_minute_ago, minutes.toInt(), minutes.toString()) + else -> rh.gs(R.string.seconds_ago) + } + } + } diff --git a/core/interfaces/src/androidMain/kotlin/app/aaps/core/interfaces/utils/ReadableDuration.kt b/core/interfaces/src/androidMain/kotlin/app/aaps/core/interfaces/utils/ReadableDuration.kt new file mode 100644 index 000000000000..535b071ba4f7 --- /dev/null +++ b/core/interfaces/src/androidMain/kotlin/app/aaps/core/interfaces/utils/ReadableDuration.kt @@ -0,0 +1,77 @@ +package app.aaps.core.interfaces.utils + +import androidx.annotation.PluralsRes +import androidx.annotation.StringRes +import app.aaps.core.interfaces.resources.ResourceHelper + +/** + * The strings [readableDuration] needs. Each caller passes its own set, so a driver keeps the wording + * and the translations it already has. + * + * @param momentsAgo shown under ten seconds + * @param lessThanAMinuteAgo shown under a minute + * @param timeAgo wraps the amount, for example `%1$s ago` + * @param compositeTime joins two amounts, for example `%1$s and %2$s` + * @param minutes plural for minutes, taking the count as its only argument + * @param hours plural for hours + * @param days plural for days + */ +class DurationLabels( + @StringRes val momentsAgo: Int, + @StringRes val lessThanAMinuteAgo: Int, + @StringRes val timeAgo: Int, + @StringRes val compositeTime: Int, + @PluralsRes val minutes: Int, + @PluralsRes val hours: Int, + @PluralsRes val days: Int +) + +/** + * Formats how long ago something happened, as "5 minutes ago" or "2 hours and 10 minutes ago". + * + * The Omnipod Eros, Omnipod Dash and Equil overviews each carried their own copy of this, identical + * apart from the resource names, which is also where most of the project's plural use sat. It lives + * here once now, and it is the only place that has to change when plurals get a + * [app.aaps.core.keys.interfaces.TextRef] form - a plural cannot be expressed as a TextRef today. + * + * It stays on Android because plural selection must come from the platform: plural categories are not + * just one and other. Czech has one, few, many and other; Arabic has six. `getQuantityString` uses the + * platform CLDR data, and iOS would use a `.stringsdict` the same way. Shared code must never pick the + * form itself. + * + * @param millis how long ago, in milliseconds + */ +fun ResourceHelper.readableDuration(millis: Long, labels: DurationLabels): String { + val seconds = millis / 1000 + val minutes = seconds / 60 + val hours = minutes / 60 + + return when { + seconds < 10 -> gs(labels.momentsAgo) + seconds < 60 -> gs(labels.lessThanAMinuteAgo) + seconds < 60 * 60 -> gs(labels.timeAgo, gq(labels.minutes, minutes.toInt(), minutes.toInt())) + + seconds < 24 * 60 * 60 -> { + val minutesLeft = (minutes % 60).toInt() + if (minutesLeft > 0) + gs( + labels.timeAgo, + gs(labels.compositeTime, gq(labels.hours, hours.toInt(), hours.toInt()), gq(labels.minutes, minutesLeft, minutesLeft)) + ) + else + gs(labels.timeAgo, gq(labels.hours, hours.toInt(), hours.toInt())) + } + + else -> { + val days = (hours / 24).toInt() + val hoursLeft = (hours % 24).toInt() + if (hoursLeft > 0) + gs( + labels.timeAgo, + gs(labels.compositeTime, gq(labels.days, days, days), gq(labels.hours, hoursLeft, hoursLeft)) + ) + else + gs(labels.timeAgo, gq(labels.days, days, days)) + } + } +} diff --git a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/utils/fabric/FabricPrivacy.kt b/core/interfaces/src/androidMain/kotlin/app/aaps/core/interfaces/utils/fabric/FabricPrivacy.kt similarity index 100% rename from core/interfaces/src/main/kotlin/app/aaps/core/interfaces/utils/fabric/FabricPrivacy.kt rename to core/interfaces/src/androidMain/kotlin/app/aaps/core/interfaces/utils/fabric/FabricPrivacy.kt diff --git a/core/interfaces/src/main/res/values-ar-rSA/strings.xml b/core/interfaces/src/androidMain/res/values-ar-rSA/strings.xml similarity index 100% rename from core/interfaces/src/main/res/values-ar-rSA/strings.xml rename to core/interfaces/src/androidMain/res/values-ar-rSA/strings.xml diff --git a/core/interfaces/src/main/res/values-bg-rBG/strings.xml b/core/interfaces/src/androidMain/res/values-bg-rBG/strings.xml similarity index 70% rename from core/interfaces/src/main/res/values-bg-rBG/strings.xml rename to core/interfaces/src/androidMain/res/values-bg-rBG/strings.xml index 23002596d8ef..d2f82dc6426d 100644 --- a/core/interfaces/src/main/res/values-bg-rBG/strings.xml +++ b/core/interfaces/src/androidMain/res/values-bg-rBG/strings.xml @@ -115,4 +115,37 @@ U200 U300 U500 + мг/дл/E + ммол/л/E + %1$+.2fЕ + %1$d гр + %1$s: %2$s + въглехидрати + Аларма след %1$d мин + Помпата е спряна + Loop изключен + Няма връзка с помпата + Базалните стойности не са за кръгли часове: %1$s + Базалната стойност е заместена от минимално поддържаната стойност: %1$s + Базалната стойност е заместена от максимално поддържаната стойност %1$s + Е/ч + гр/Е + %1$d мин + Запиши + Болус + УДЪЛЖЕН БОЛУС + Профил ниска цел + Профил висока цел + Профил време на действие на инсулин + Профил чувствителност + Профил въглехидратно число + »%1$s« %2$.2f е извън ограниченията + Базална стойност + COB срещу IOB + !!!!! Засечено бавно усвояване на въглехидрати: %1$d%% от времето. Проверете калкулацията. Въглехидратите може би са прекалено много и това ще доведе до много инсулин !!!!! + Приложено болус ограничение: %1$.2f Е към %2$.2f Е + Само запиши (без да да го стартираш в помпата) + Аларма, когато е време за хранене + Уд въглехидрати %1$dгр / %2$dч (+%3$dмин) + %1$.2fЕ diff --git a/core/interfaces/src/main/res/values-ca-rES/strings.xml b/core/interfaces/src/androidMain/res/values-ca-rES/strings.xml similarity index 56% rename from core/interfaces/src/main/res/values-ca-rES/strings.xml rename to core/interfaces/src/androidMain/res/values-ca-rES/strings.xml index 33d27d8d3a0d..463d1c637cd6 100644 --- a/core/interfaces/src/main/res/values-ca-rES/strings.xml +++ b/core/interfaces/src/androidMain/res/values-ca-rES/strings.xml @@ -40,4 +40,27 @@ + %1$+.2f U + %1$d g + Carbs + Executar alarma en %1$d min + Bomba aturada + Llaç aturat + Valors basals no alineats amb les hores: %1$s + Valor basal reemplaçat pel màxim valor acceptat: %1$s + U/h + g/U + %1$d min + Registre + Bolus + CARBS ESTESOS + Objectiu baix del perfil + Objectiu alt del perfil + Valor DIA del perfil + Valor sensibilitat del perfil + Valor ràtio de carbohidrats del perfil + »%1$s« %2$.2f està fora dels límits estrictes + Valor basal + COB vs IOB + Executar alarma quan sigui hora de menjar diff --git a/core/interfaces/src/main/res/values-cs-rCZ/strings.xml b/core/interfaces/src/androidMain/res/values-cs-rCZ/strings.xml similarity index 73% rename from core/interfaces/src/main/res/values-cs-rCZ/strings.xml rename to core/interfaces/src/androidMain/res/values-cs-rCZ/strings.xml index 4a037251eb27..68b109c51902 100644 --- a/core/interfaces/src/main/res/values-cs-rCZ/strings.xml +++ b/core/interfaces/src/androidMain/res/values-cs-rCZ/strings.xml @@ -121,4 +121,37 @@ U200 U300 U500 + mg/dL/U + mmol/L/U + %1$+.2f U + %1$d g + %1$s: %2$s + Sacharidy + Spustit alarm za %1$d min + Pumpa pozastavena + Smyčka pozastavena + Pumpa odpojena + Hodnoty bazálů nejsou zarovnané na celé hodiny: %1$s + Hodnota bazálu byla nahrazena minimální možnou: %1$s + Hodnota bazálu nahrazena maximální možnou: %1$s + U/h + g/U + %1$d min + Záznam + Bolus + ROZLOŽENÉ SACHARIDY + Dolní cíl profilu + Horní cíl profilu + Hodnota DIA profilu + Hodnota citlivosti profilu + Inzulino-sacharidový poměr profilu + »%1$s« %2$.2f je mimo pevně nastavené limity + Hodnota bazálu + COB vs. IOB + !!!!! Byla zjištěna pomalá absorpce sacharidů: %1$d%% času. Zkontrolujte svůj výpočet. COB může být nadhodnoceno, takže by mohlo být podáno více inzulínu !!!!! + Použito omezení bolusu: %1$.2f U na %2$.2f U + Bolus nebude pumpou vydán, pouze zaznamenán + Spustit alarm, když je čas na jídlo + eSach %1$dg / %2$dh (+%3$dmin) + %1$.2f U diff --git a/core/interfaces/src/main/res/values-da-rDK/strings.xml b/core/interfaces/src/androidMain/res/values-da-rDK/strings.xml similarity index 65% rename from core/interfaces/src/main/res/values-da-rDK/strings.xml rename to core/interfaces/src/androidMain/res/values-da-rDK/strings.xml index 02ae26204847..145542501fc4 100644 --- a/core/interfaces/src/main/res/values-da-rDK/strings.xml +++ b/core/interfaces/src/androidMain/res/values-da-rDK/strings.xml @@ -63,4 +63,31 @@ + %1$+.2f IE + %1$d g + Kulhydrater + Kør alarm om %1$d min + Pumpe er afbrudt + Loop suspenderet + Basalværdier ikke angivet i hele timer: %1$s + Basal dosis erstattet af minimal understøttet dosis: %1$s + Basal dosis erstattet af maximal understøttet dosis: %1$s + E/t + g/E + %1$d min + Registrér + Bolus + FORLÆNGET KH + Nedre målværdi for profilen + Øvre målværdi for profilen + Profil DIA værdi + Profil følsomhed værdi + Profil KH ratio værdi + »%1$s« %2$.2f er uden for absolutte grænser + Basalværdi + COB vs IOB + Bolus-begrænsning anvendt: %1$.2f IE til %2$.2f IE + Bolus registreres kun (bliver ikke leveret af pumpe) + Kør alarm når det er tid til at spise + %1$.2f IE diff --git a/core/interfaces/src/main/res/values-de-rDE/strings.xml b/core/interfaces/src/androidMain/res/values-de-rDE/strings.xml similarity index 65% rename from core/interfaces/src/main/res/values-de-rDE/strings.xml rename to core/interfaces/src/androidMain/res/values-de-rDE/strings.xml index 53a3c1423bc1..bc8f7b462721 100644 --- a/core/interfaces/src/main/res/values-de-rDE/strings.xml +++ b/core/interfaces/src/androidMain/res/values-de-rDE/strings.xml @@ -64,4 +64,31 @@ + %1$+.2f IE + %1$d g + Kohlenhydrate + Alarm in %1$d Min. + Pumpe pausiert + Loop pausiert + Basalraten beginnen nicht zur vollen Stunde: %1$s + Basal-Wert wurde durch den kleinst möglichen Wert ersetzt: %1$s + Basal-Wert wurde durch größt möglichen Wert ersetzt: %1$s + IE/h + g/IE + %1$d min. + Eintrag + Bolus + VERLÄNGERTE KOHLENHYDRATE + Profil unteres Ziel + Profil oberes Ziel + Profil Insulinwirkdauer + Profil Sensitivitätswert + Profil KH-Faktor + »%1$s« %2$.2f ist außerhalb der fest programmierten Grenzen + Basal-Wert + COB vs IOB + Bolus Einschränkung angewendet: %1$.2f U bis %2$.2f U + Bolus wird nur aufgezeichnet (Die Pumpe gibt kein Insulin ab!) + Alarmiere mich, wenn es Zeit zum Essen ist. + %1$.2f IE diff --git a/core/interfaces/src/main/res/values-el-rGR/strings.xml b/core/interfaces/src/androidMain/res/values-el-rGR/strings.xml similarity index 63% rename from core/interfaces/src/main/res/values-el-rGR/strings.xml rename to core/interfaces/src/androidMain/res/values-el-rGR/strings.xml index 5c65459a6287..fac2c7314a0d 100644 --- a/core/interfaces/src/main/res/values-el-rGR/strings.xml +++ b/core/interfaces/src/androidMain/res/values-el-rGR/strings.xml @@ -63,4 +63,31 @@ + %1$+.2f U + %1$d g + Υδατάνθρακες + Εκτέλεση συναγερμού σε %1$d λεπτά + Η αντλία είναι σε παύση + Κύκλωμα σε αναστολή + Οι τιμές του βασικού ρυθμού δεν αντιστοιχούν σε ώρες: %1$s + Η τιμή του βασικού αντικαταστάθηκε από την ελάχιστη υποστηριζόμενη τιμή: %1$s + Η τιμή του βασικού αντικαταστάθηκε από την μέγιστη υποστηριζόμενη τιμή: %1$s + U/h + g/U + %1$d λεπτά + Εγγραφή + Bolus + ΕΚΤΕΤΑΜΕΝΟΙ ΥΔΑΤΑΝΘΡΑΚΕΣ + Χαμηλός στόχος προφίλ + Υψηλός στόχος προφίλ + Τιμή DIA του προφίλ + Τιμή ευαισθησίας προφίλ + Τιμή αναλογίας υδατανθράκων προφίλ + Η τιμή »%1$s« %2$.2f είναι εκτός ορίων + Τιμή Βασικού ρυθμού + COB vs IOB (ενεργοί υδατάνθρακες vs ενεργή ινσουλίνη) + Ορίστηκε περιορισμός Bolus: %1$.2f U σε %2$.2f U + Το Bolus μόνο θα καταγραφεί (δε θα χορηγηθεί από την αντλία) + Εκτέλεση συναγερμού όταν έρθει η ώρα να φάτε + %1$.2f U diff --git a/core/interfaces/src/main/res/values-es-rES/strings.xml b/core/interfaces/src/androidMain/res/values-es-rES/strings.xml similarity index 72% rename from core/interfaces/src/main/res/values-es-rES/strings.xml rename to core/interfaces/src/androidMain/res/values-es-rES/strings.xml index 017a7fadb5c7..72f6350d354b 100644 --- a/core/interfaces/src/main/res/values-es-rES/strings.xml +++ b/core/interfaces/src/androidMain/res/values-es-rES/strings.xml @@ -115,4 +115,37 @@ U200 U300 U500 + mg/dL/U + mmol/L/U + %1$+.2f U + %1$d g + %1$s: %2$s + Carbohidratos + Ejecutar alarma en %1$d min + Bomba suspendida + Lazo suspendido + Bomba desconectada + Valores basales no alineados a las horas: %1$s + Valor basal cambiado al valor mínimo soportado: %1$s + Valor basal reemplazado por el valor máximo soportado: %1$s + U/h + g/U + %1$d min + Registro + Bolo + CARBOHIDRATOS EXTENDIDOS + Perfil de objetivo bajo + Perfil de objetivo alto + Valor DIA del perfil + Valor de sensibilidad del perfil + Valor del ratio de carbohidratos del perfil + »%1$s« %2$.2f está fuera de los límites estrictos + Valor basal + COB vs IOB + ¡¡¡Absorción lenta de hidratos detectada: %1$d%% del tiempo. Revisa tus cálculos. Los COB pueden estar sobreestimados y el sistema podría darte demasiada insulina!!! + Restricción de bolo aplicada: %1$.2f U a %2$.2f U + El bolo sólo se anotará (no será entregado por la bomba) + Ejecutar alarma cuando sea hora de comer + eCarbs %1$dg / %2$dh (+%3$dmin) + %1$.2f U diff --git a/core/interfaces/src/main/res/values-fr-rFR/strings.xml b/core/interfaces/src/androidMain/res/values-fr-rFR/strings.xml similarity index 71% rename from core/interfaces/src/main/res/values-fr-rFR/strings.xml rename to core/interfaces/src/androidMain/res/values-fr-rFR/strings.xml index f2383e366834..1d163b670b01 100644 --- a/core/interfaces/src/main/res/values-fr-rFR/strings.xml +++ b/core/interfaces/src/androidMain/res/values-fr-rFR/strings.xml @@ -115,4 +115,37 @@ U200 U300 U500 + mg/dL/U + mmol/L/U + %1$+.2f U + %1$d g + %1$s: %2$s + Glucides + Alerter dans %1$d min + Pompe arrêtée + La Boucle est suspendue + Pompe déconnectée + Valeurs des débits de basal non alignées sur des heures: %1$s + Valeur de basal remplacée par la valeur minimale autorisée : %1$s + Valeur de basal remplacée par la valeur maximale autorisée : %1$s + U/h + g/U + %1$d min + Enregistrement + Bolus + GLUCIDES ÉTENDUS + Cible basse du profil + Cible haute du profil + Valeur DAI du profil + Valeur de sensibilité du profil + Rapport glucides/insuline de profil + \"%1$s\" %2$.2f est en dehors des limites + Valeur de Basal + GA vs IA + !!!!! Absorption lente des glucides détectée dans %1$d%% des cas. Vérifiez de nouveau votre calcul. Les GA (Glucides Actifs) peuvent être surestimés et alors trop d\'insuline pourrait être injectée !!!!! + Contrainte de Bolus appliquée : %1$.2f U vers %2$.2f U + Les bolus seront seulement enregistrés (pas délivrés par la pompe) + Alerter quand il est temps de manger + eGlucides %1$d g / %2$d h (+%3$d min) + %1$.2f U diff --git a/core/interfaces/src/main/res/values-hr-rHR/strings.xml b/core/interfaces/src/androidMain/res/values-hr-rHR/strings.xml similarity index 60% rename from core/interfaces/src/main/res/values-hr-rHR/strings.xml rename to core/interfaces/src/androidMain/res/values-hr-rHR/strings.xml index 6bb9ce88c443..668813f4fa21 100644 --- a/core/interfaces/src/main/res/values-hr-rHR/strings.xml +++ b/core/interfaces/src/androidMain/res/values-hr-rHR/strings.xml @@ -31,4 +31,20 @@ + %1$+.2f U + %1$d g + UH + Uključi alarm za %1$d min + Petlja suspendirana + Bazalne vrijednosti nisu usklađene sa satima: %1$s + Bazalna vrijednost zamijenjena minimalnom podržanom vrijednošću: %1$s + Bazalna vrijednost zamijenjena maksimalnom podržanom vrijednošću: %1$s + U/h + g/U + Bolus + Profil niski cilj + Profil visoki cilj + Profil DIA vrijednost + Vrijednost osjetljivosti profila + Vrijednost omjera ugljikohidrata profila diff --git a/core/interfaces/src/main/res/values-hu-rHU/strings.xml b/core/interfaces/src/androidMain/res/values-hu-rHU/strings.xml similarity index 92% rename from core/interfaces/src/main/res/values-hu-rHU/strings.xml rename to core/interfaces/src/androidMain/res/values-hu-rHU/strings.xml index 3b0b5e398458..30a17d0b11fc 100644 --- a/core/interfaces/src/main/res/values-hu-rHU/strings.xml +++ b/core/interfaces/src/androidMain/res/values-hu-rHU/strings.xml @@ -57,4 +57,9 @@ + %1$+.2f E + E/ó + g/E + Bólus + %1$.2f E diff --git a/core/interfaces/src/main/res/values-it-rIT/strings.xml b/core/interfaces/src/androidMain/res/values-it-rIT/strings.xml similarity index 72% rename from core/interfaces/src/main/res/values-it-rIT/strings.xml rename to core/interfaces/src/androidMain/res/values-it-rIT/strings.xml index b76ab36086c4..db6a32aa013e 100644 --- a/core/interfaces/src/main/res/values-it-rIT/strings.xml +++ b/core/interfaces/src/androidMain/res/values-it-rIT/strings.xml @@ -115,4 +115,37 @@ U200 U300 U500 + mg/dL/U + mmol/L/U + %1$+.2f U + %1$d g + %1$s: %2$s + CHO + Esegui allarme in %1$d min + Micro sospeso + Loop sospeso + Micro disconnesso + Valori basali non allineati alle ore: %1$s + Valore basale sostituito dal minimo valore supportato: %1$s + Valore basale sostituito dal massimo valore supportato: %1$s + U/h + g/U + %1$d min + Record + Bolo + CHO ESTESI + Target basso (profilo) + Target alto (profilo) + Valore DIA (profilo) + Valore sensibilità (profilo) + Valore rapporto CHO (profilo) + »%1$s« %2$.2f è fuori dai limiti consentiti + Valore basale + COB vs IOB + !!!!! Rilevato assorbimento lento dei carboidrati: %1$d%% del tempo. Ricontrolla il tuo calcolo. I COB potrebbero essere sovrastimati e potrebbe essere somministrata più insulina !!!!! + Vincolo bolo applicato: %1$.2f U a %2$.2f U + Il bolo sarà solo registrato (non erogato dal micro) + Esegui allarme quando è tempo di mangiare + eCarbs %1$dg / %2$dh (+%3$dmin) + %1$.2f U diff --git a/core/interfaces/src/main/res/values-iw-rIL/strings.xml b/core/interfaces/src/androidMain/res/values-iw-rIL/strings.xml similarity index 66% rename from core/interfaces/src/main/res/values-iw-rIL/strings.xml rename to core/interfaces/src/androidMain/res/values-iw-rIL/strings.xml index f09e639c8e44..31844860a3ef 100644 --- a/core/interfaces/src/main/res/values-iw-rIL/strings.xml +++ b/core/interfaces/src/androidMain/res/values-iw-rIL/strings.xml @@ -69,4 +69,31 @@ + %1$+.2f יח\' + %1$d גר\' + פחמימות + הפעל התראה בעוד %1$d דקות + משאבה מושהית + לולאה מושהית + ערכי הבזאלי לא מותאמים לשעות: %1$s + ערכי הבזאלי הוחלפו בערכים הנתמכים המינימליים: %1$s + ערכי הבזאלי הוחלפו בערכים הנתמכים המינימליים: %1$s + יח\'\\שעה + גר\'\\יח\' + %1$d דק\' + הקלטה + בולוס + פחמימות מורכבות + ערך המטרה הנמוך של הפרופיל + ערך המטרה הגבוה של הפרופיל + ערך DIA של הפרופיל + ערך הרגישות של הפרופיל + יחס הפחמימות של הפרופיל + »הערך %1$s« %2$.2f מחוץ לתחום הקשיח + ערך בזאלי + פחמ\' פעילות לעומת אינ\' פעיל + מגבלת בולוס יושמה: %1$.2f עד %2$.2f יח\' + בולוס רשום בלבד (לא מוזרק על ידי המשאבה) + הפעל התראה כשצריכים לאכול + %1$.2f יח\' diff --git a/core/interfaces/src/main/res/values-ko-rKR/strings.xml b/core/interfaces/src/androidMain/res/values-ko-rKR/strings.xml similarity index 65% rename from core/interfaces/src/main/res/values-ko-rKR/strings.xml rename to core/interfaces/src/androidMain/res/values-ko-rKR/strings.xml index bbc60da1f588..dee7009c383d 100644 --- a/core/interfaces/src/main/res/values-ko-rKR/strings.xml +++ b/core/interfaces/src/androidMain/res/values-ko-rKR/strings.xml @@ -64,4 +64,31 @@ + %1$+.2f U + %1$d g + 탄수화물 + %1$d분 뒤 알람 울림 + 펌프 일시중지됨 + Loop 일시중지 + Basal값이 시간단위로 설정되지 않았습니다: %1$s + 지원되는 최대 값으로 Basal 값이 대체되었습니다: %1$s + 지원되는 최대값으로 Basal값이 대체되었습니다:%1$s + U/h + g/U + %1$d 분 + 기록 + Bolus + 확장 탄수화물 + 프로파일 저혈당 목표 + 프로파일 고혈당 목표 + 프로파일 DIA 값 + 프로파일 민감도 값 + 프로파일의 탄수화물 비율 값 + »%1$s« %2$.2f이 \'고정된 한계값\'을 벗어났습니다. + Basal 값 + COB vs IOB + 적용된 Bolus 제약 조건: %1$.2f U 에서 %2$.2f U + Bolus는 이 경우(펌프를 통해 공급되지 않음) 에만 기록됩니다 + 식사 시간이 되면 알람을 울리기 + %1$.2f U diff --git a/core/interfaces/src/main/res/values-lt-rLT/strings.xml b/core/interfaces/src/androidMain/res/values-lt-rLT/strings.xml similarity index 70% rename from core/interfaces/src/main/res/values-lt-rLT/strings.xml rename to core/interfaces/src/androidMain/res/values-lt-rLT/strings.xml index cf5fc1a3c5c4..b8f5c8f44399 100644 --- a/core/interfaces/src/main/res/values-lt-rLT/strings.xml +++ b/core/interfaces/src/androidMain/res/values-lt-rLT/strings.xml @@ -72,4 +72,31 @@ + %1$+.2f vv + %1$dg + Angliavandeniai + Pranešti po %1$d min + Pompa sustabdyta + Ciklas sustabdytas + Bazės reikšmės nesuderintos su valandomis: %1$s + Nustatyta mažiausia galima valandinės bazės vertė: %1$s + Nustatyta didžiausia galima valandinės bazės vertė: %1$s + v/val + g/v + %1$d min. + Įrašas + Bolusas + IŠTĘSTI AV + Profilio tikslo žemoji riba + Profilio tikslo aukštoji riba + Profilio IVT reikšmė + Profilio JIF reikšmė + Profilio IA reikšmė + »%1$s« %2$.2f viršija griežtą limitą + Valandinė bazė + AAO prieš AIO + Pritaikytas boluso apribojimas: %1$.2f v iki %2$.2f v + Bolusas bus tik įrašytas (nebus suleistas) + Pranešti apie laiką valgyti + %1$.2f V diff --git a/core/interfaces/src/main/res/values-nb-rNO/strings.xml b/core/interfaces/src/androidMain/res/values-nb-rNO/strings.xml similarity index 71% rename from core/interfaces/src/main/res/values-nb-rNO/strings.xml rename to core/interfaces/src/androidMain/res/values-nb-rNO/strings.xml index 1803f76ec68b..0996ba83b13e 100644 --- a/core/interfaces/src/main/res/values-nb-rNO/strings.xml +++ b/core/interfaces/src/androidMain/res/values-nb-rNO/strings.xml @@ -115,4 +115,37 @@ U200 U300 U500 + mg/dL/E + mmol/L/E + %1$+.2f E + %1$d g + %1$s: %2$s + Karbo + Aktiver alarm om %1$d min + Pumpen er pauset + Loop pauset + Pumpe frakoblet + Basalverdier er ikke angitt på hele timer: %1$s + Basalverdi erstattet med minste tillate verdi: %1$s + Basalverdi erstattet med høyeste tillate verdi: %1$s + E/t + g/E + %1$d min + Registrer + Bolus + FORLENGET KARBO + Profil lavt mål + Profil høyt mål + Profil DIA verdi + Profilens insulinfølsomhet + Profilens insulin-til-karbohydratforhold (IK) + »%1$s« %2$.2f er utenfor lovlige grenseverdier + Basalverdi + COB vs IOB + !!!!! Langsom karboabsorpsjon oppdaget: %1$d%% av tiden. Dobbeltsjekk beregningen din. COB kan overestimeres, og dermed kan mer insulin gis !!!!! + Bolus begrensning brukt: %1$.2f E til %2$.2f E + Bolus vil bare bli loggført (ikke levert av pumpe) + Aktiver alarm når det er på tide å spise + eKarbo %1$dg / %2$dt (+%3$dmin) + %1$.2f E diff --git a/core/interfaces/src/main/res/values-nl-rNL/strings.xml b/core/interfaces/src/androidMain/res/values-nl-rNL/strings.xml similarity index 73% rename from core/interfaces/src/main/res/values-nl-rNL/strings.xml rename to core/interfaces/src/androidMain/res/values-nl-rNL/strings.xml index 75085630e9d5..90567de4bb28 100644 --- a/core/interfaces/src/main/res/values-nl-rNL/strings.xml +++ b/core/interfaces/src/androidMain/res/values-nl-rNL/strings.xml @@ -93,4 +93,32 @@ + %1$+.2f E + %1$d g + Koolhydraten + Start alarm over %1$d min + Pomp onderbreken + Loop pauzeren + Pomp niet verbonden + Basaalstanden niet ingesteld in hele uren: %1$s + Minimum basaalwaarde is vervangen door minimaal ondersteunde waarde: %1$s + Basale waarde vervangen door maximale ondersteunde waarde: %1$s + E/u + g/E + %1$d min + Opnemen + Bolus + VERLENGDE KOOLHYDRATEN + Profiel laag doel + Profiel hoog doel + Profiel DIA waarde + Profiel gevoeligheidswaarde + Profiel koolhydraten ratio waarde + »%1$s« %2$.2f is buiten de harde limiet + Basaal waarde + COB vs IOB + Bolusbeperking toegepast: %1$.2f E naar %2$.2f E + Bolus wordt alleen geregistreerd (niet toegediend door pomp) + Start alarm wanneer het tijd is om te eten + %1$.2f E diff --git a/core/interfaces/src/main/res/values-pl-rPL/strings.xml b/core/interfaces/src/androidMain/res/values-pl-rPL/strings.xml similarity index 73% rename from core/interfaces/src/main/res/values-pl-rPL/strings.xml rename to core/interfaces/src/androidMain/res/values-pl-rPL/strings.xml index 635431130f1b..b1d76a4998e3 100644 --- a/core/interfaces/src/main/res/values-pl-rPL/strings.xml +++ b/core/interfaces/src/androidMain/res/values-pl-rPL/strings.xml @@ -98,4 +98,32 @@ + %1$+.2f U + %1$d g + Węglowodany + Uruchom alarm za %1$d min + Pompa wstrzymana + Pętla wstrzymana + Pompa odłączona + Wartości bazy nie są ustawione w pełnych godzinach: %1$s + Wartość bazy zastąpiona minimalną obsługiwaną wartością: %1$s + Wartość bazy zastąpiona maksymalną obsługiwaną wartością: %1$s + U/h + g/U + %1$d min + Wpis + Bolus + PRZEDŁUŻONE WĘGLOWODANY + Dolna granica celu profilu + Górna granica celu profilu + Wartość DIA profilu + Wartość wrażliwości profilu + Stosunek węglowodanów profilu + Wartość »%1$s« %2$.2f jest poza dopuszczalną granicą + Wartość bazy + COB vs IOB + Zastosowano ograniczenie bolusa: %1$.2f U do %2$.2f U + Bolus zostanie jedynie odnotowany (nie będzie podany przez pompę) + Uruchom alarm kiedy będzie czas na jedzenie + %1$.2f U diff --git a/core/interfaces/src/main/res/values-pt-rBR/strings.xml b/core/interfaces/src/androidMain/res/values-pt-rBR/strings.xml similarity index 65% rename from core/interfaces/src/main/res/values-pt-rBR/strings.xml rename to core/interfaces/src/androidMain/res/values-pt-rBR/strings.xml index b0c40c560016..6c044f1799ec 100644 --- a/core/interfaces/src/main/res/values-pt-rBR/strings.xml +++ b/core/interfaces/src/androidMain/res/values-pt-rBR/strings.xml @@ -61,4 +61,30 @@ + %1$+.2f U + %1$d g + Carbos + Disparar alarme em %1$d min + Bomba suspensa + Loop suspenso + Valores das basais não definidos por horas: %1$s + Valor da basal alterado para o valor mínimo suportado: %1$s + Valor da basal alterado para o valor máximo suportado: %1$s + U/h + g/U + %1$d min + Gravar + Bólus + CARBOIDRATOS ESTENDIDOS + Alvo de perfil de hipoglicemia + Alvo de perfil de hiperglicemia + Valor do perfil da DAI + Valor do perfil de sensibilidade + Valor do perfil da taxa de carboidratos + »%1$s« %2$.2f está fora dos limites estabelecidos + Valor basal + CA vs IA + Restrição de bólus aplicada: %1$.2f U para %2$.2f U + Bolus será apenas registrado (não administrado pela bomba) + Disparar alarme quando for a hora de comer diff --git a/core/interfaces/src/main/res/values-pt-rPT/strings.xml b/core/interfaces/src/androidMain/res/values-pt-rPT/strings.xml similarity index 67% rename from core/interfaces/src/main/res/values-pt-rPT/strings.xml rename to core/interfaces/src/androidMain/res/values-pt-rPT/strings.xml index bb509112d76b..c529b46ff64e 100644 --- a/core/interfaces/src/main/res/values-pt-rPT/strings.xml +++ b/core/interfaces/src/androidMain/res/values-pt-rPT/strings.xml @@ -61,4 +61,29 @@ + %1$+.2f U + %1$d g + Hidratos + Executar alarme em %1$d min + Bomba suspensa + Loop suspenso + Valores das basais não definidos por horas: %1$s + Valor da basal alterado para o valor mínimo suportado: %1$s + Valor da basal alterado para o valor máximo suportado: %1$s + U/h + g/U + %1$d min + Registo + Bólus + HC LENTOS + Valor mínimo alvo do perfil + Valor máximo alvo do perfil + Valor Perfil DIA + Valor Perfil Sensibilidade + Valor Perfil Rácio Hidratos + »%1$s« %2$.2f está fora dos limites permitidos + Valor da Basal + HCA vs IA + Executar alarme quando for tempo de comer + %1$.2f U diff --git a/core/interfaces/src/main/res/values-ro-rRO/strings.xml b/core/interfaces/src/androidMain/res/values-ro-rRO/strings.xml similarity index 71% rename from core/interfaces/src/main/res/values-ro-rRO/strings.xml rename to core/interfaces/src/androidMain/res/values-ro-rRO/strings.xml index 85de88413b04..fe0a34f28dac 100644 --- a/core/interfaces/src/main/res/values-ro-rRO/strings.xml +++ b/core/interfaces/src/androidMain/res/values-ro-rRO/strings.xml @@ -118,4 +118,37 @@ U200 U300 U500 + mg/dL/U + mmol/l/U + %1$+.2f U + %1$d g + %1$s: %2$s + Carbohidrați + Rulați alarma în %1$d minute + Pompă suspendată + Buclă suspendată + Pompă deconectată + Valori bazale nesincronizate cu ora: %1$s + Valoarea bazalei a fost înlocuită cu valoarea minimă posibilă: %1$s + Valoarea bazalei a fost înlocuită cu valoarea maximă posibilă: %1$s + U/h + g/U + %1$d min + Înregistrare + Bolus + CARBOHIDRAȚI EXTINȘI + Profil ținta joasă + Profil țintă ridicată + Valoare profil DIA + Valoare sensibilitate profil + Valoarea raportului carbohidrați din profil + »%1$s« %2$.2f este in afara limitelor stabilite + Valoare rata bazală + COB vs IOB + !!!!! S-a detectat o absorbție lentă de carbohidrați: %1$d%% din timp. Verificați de două ori calculul. COB poate fi supraestimat, astfel încât mai multă insulină poate fi administrată !!!!! + Este aplicată limitarea bolusului %1$.2f U la %2$.2f U + Bolusul doar va fi înregistrat (nu va fi administrat de pompă) + Executați alarma când este timpul să mâncați + carbohidrați extinși %1$dg / %2$dh (+%3$dmin) + %1$.2f U diff --git a/core/interfaces/src/main/res/values-ru-rRU/strings.xml b/core/interfaces/src/androidMain/res/values-ru-rRU/strings.xml similarity index 75% rename from core/interfaces/src/main/res/values-ru-rRU/strings.xml rename to core/interfaces/src/androidMain/res/values-ru-rRU/strings.xml index 0c1dad398fb8..0855704ee0a4 100644 --- a/core/interfaces/src/main/res/values-ru-rRU/strings.xml +++ b/core/interfaces/src/androidMain/res/values-ru-rRU/strings.xml @@ -121,4 +121,32 @@ 200 ед 300 ед 500 ед + %1$+.2f ед + %1$d гр + Углеводы + Напомнить через %1$d мин + Работа помпы остановлена + ЗЦ остановлен + Помпа отключена + Базальные значения не выровнены по часам: %1$s + Значение базала заменено минимальной поддерживаемой величиной: %1$s + Значение базала заменено максимальной поддерживаемой величиной: %1$s + ед/ч + г/ед + %1$d мин. + Запись + Болюс + ПРОЛОНГИРОВАННЫЕ УГЛЕВОДЫ + Нижнее целевое значение профиля + Верхнее целевое значение профиля + Значение длительности действия инсулина DIA в профиле + Значение чувствительности в профиле + Значение Углеводного коэффициента IC в профиле + »%1$s« %2$.2f за пределами жестких ограничений + Величина базала + угл COB к инс IOB + Применено ограничение болюса: %1$.2f ед. до %2$.2f ед. + Болюс будет только записан (без подачи помпой) + Напомнить о еде + %1$.2f ед diff --git a/core/interfaces/src/main/res/values-sk-rSK/strings.xml b/core/interfaces/src/androidMain/res/values-sk-rSK/strings.xml similarity index 73% rename from core/interfaces/src/main/res/values-sk-rSK/strings.xml rename to core/interfaces/src/androidMain/res/values-sk-rSK/strings.xml index f567a8da7497..9d1caaf4296b 100644 --- a/core/interfaces/src/main/res/values-sk-rSK/strings.xml +++ b/core/interfaces/src/androidMain/res/values-sk-rSK/strings.xml @@ -121,4 +121,37 @@ U200 U300 U500 + mg/dL/U + mmol/L/U + %1$+.2f U + %1$d g + %1$s: %2$s + Sacharidy + Spustiť výstrahu za %1$d min + Pumpa pozastavená + Uzavretý okruh pozastavený + Pumpa odpojená + 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 + U/h + g/U + %1$d min. + Záznam + Bolus + ROZLOŽENÉ SACHARIDY + Dolný cieľ profilu + Horný cieľ profilu + Profilová hodnota DIA + Profilová hodnota citlivosti + Profilový inzulino-sacharidový pomer + »%1$s« %2$.2f je mimo pevne nastavených limitov + Hodnota bazálu + COB vs. IOB + !!!!! Detekovaná pomalá absorbcia sacharidov: %1$d%% času. Radšej dvakrát skontrolujte kalkuláciu. COB môže byť úplne iné, môže byť podaného viac inzulínu!!!!! + 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 + eSacharidy %1$dg / %2$dh (+%3$dmin) + %1$.2f U diff --git a/core/interfaces/src/main/res/values-sr-rCS/strings.xml b/core/interfaces/src/androidMain/res/values-sr-rCS/strings.xml similarity index 93% rename from core/interfaces/src/main/res/values-sr-rCS/strings.xml rename to core/interfaces/src/androidMain/res/values-sr-rCS/strings.xml index af6285f56cdd..a3fe5dbba241 100644 --- a/core/interfaces/src/main/res/values-sr-rCS/strings.xml +++ b/core/interfaces/src/androidMain/res/values-sr-rCS/strings.xml @@ -23,4 +23,6 @@ + Uglj. hidrati + Bolus diff --git a/core/interfaces/src/main/res/values-sv-rSE/strings.xml b/core/interfaces/src/androidMain/res/values-sv-rSE/strings.xml similarity index 65% rename from core/interfaces/src/main/res/values-sv-rSE/strings.xml rename to core/interfaces/src/androidMain/res/values-sv-rSE/strings.xml index 849aa94dfdfb..7d26e3f953cc 100644 --- a/core/interfaces/src/main/res/values-sv-rSE/strings.xml +++ b/core/interfaces/src/androidMain/res/values-sv-rSE/strings.xml @@ -63,4 +63,31 @@ + %1$+.2f U + %1$dg + Kolhydrater + Larma om %1$d min + Pump pausad + Loop pausad + Profilens basaler är inte satta på hel timme: %1$s + Basalvärdet ersatt med det lägsta tillåtna: %1$s + Basalvärdet ersatt med det högsta tillåtna: %1$s + U/h + g/U + %1$d min + Post + Bolus + FÖRLÄNGDA KH + Nedre målvärde för profilen + Övre målvärde för profilen + Profilens DIA + Profilens insulinkänslighetsvärde + Profilens KH-kvot + »%1$s« %2$.2f är utanför hårda gränser + Basaldos + COB kontra IOB + Bolusbegränsning tillämpad: %1$.2f U till %2$.2f U + Bolus kommer bara att loggas (inte levereras av pumpen) + Larma när det är dags att äta + %1$.2fU diff --git a/core/interfaces/src/main/res/values-tr-rTR/strings.xml b/core/interfaces/src/androidMain/res/values-tr-rTR/strings.xml similarity index 69% rename from core/interfaces/src/main/res/values-tr-rTR/strings.xml rename to core/interfaces/src/androidMain/res/values-tr-rTR/strings.xml index d05ac9c9779e..0d11ed08e167 100644 --- a/core/interfaces/src/main/res/values-tr-rTR/strings.xml +++ b/core/interfaces/src/androidMain/res/values-tr-rTR/strings.xml @@ -72,4 +72,32 @@ + %1$+.2f Ü + %1$d g + Karbonhidrat + Alarmı %1$d dakika içinde çalıştır + Pompa Durduruldu + Döngü duraklatıldı + Pompa bağlantısı kesildi + Bazal değerler saatlerle uyumlu değil: %1$s + Desteklenen minimum değerle değiştirilen bazal değer: %1$s + Bazal değeri maksimum desteklenen değerle değiştirilir: %1$s + Ü/s + g/Ü + %1$d dak + Kayıt + Bolus + YAYMA KARBONHİDRAT + Düşük hedef profili + Yüksek hedef profili + Profil DIA değeri + Profil duyarlılık değeri + Profil karbonhidrat oranı değeri + »%1$s« %2$.2f sınırların dışında + Bazal değer + AKRB vs AİNS + Bolus kısıtlaması uygulandı: %1$.2f Ü ile %2$.2f Ü + Bolus yalnızca kaydedilecektir (pompa ile iletilmez) + Yemek zamanı alarmı çalıştır + %1$.2f Ü diff --git a/core/interfaces/src/main/res/values-uk-rUA/strings.xml b/core/interfaces/src/androidMain/res/values-uk-rUA/strings.xml similarity index 98% rename from core/interfaces/src/main/res/values-uk-rUA/strings.xml rename to core/interfaces/src/androidMain/res/values-uk-rUA/strings.xml index bba313568cff..69e8a885da15 100644 --- a/core/interfaces/src/main/res/values-uk-rUA/strings.xml +++ b/core/interfaces/src/androidMain/res/values-uk-rUA/strings.xml @@ -60,4 +60,5 @@ + %1$.2f од diff --git a/core/interfaces/src/main/res/values-vi-rVN/strings.xml b/core/interfaces/src/androidMain/res/values-vi-rVN/strings.xml similarity index 70% rename from core/interfaces/src/main/res/values-vi-rVN/strings.xml rename to core/interfaces/src/androidMain/res/values-vi-rVN/strings.xml index 58de1e23a3d5..0c695c39d5ee 100644 --- a/core/interfaces/src/main/res/values-vi-rVN/strings.xml +++ b/core/interfaces/src/androidMain/res/values-vi-rVN/strings.xml @@ -112,4 +112,37 @@ U200 U300 U500 + mg/dL/U + mmol/L/U + %1$+.2f U + %1$d g + %1$s: %2$s + Carbs + Báo động sau %1$d phút + Bơm đã tạm dừng + Vòng lặp đã tạm dừng + Bơm đã ngắt kết nối + 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/h + g/U + %1$d phút + Record + Bolus + CARBS MỞ RỘNG + Cấu hình mục tiêu thấp + Cấu hình mục tiêu cao + Cấu hình giá trị DIA + Cấu hình giá trị độ nhạy + Cấu hình tỷ lệ Carb + »%1$s« %2$.2f nằm ngoài giới hạn an toàn + Giá trị liều liều nền + COB với IOB + !!!!! Phát hiện hấp thu carb chậm trong %1$d%% thời gian. Hãy kiểm tra lại phép tính của bạn. COB có thể bị ước tính cao, do đó có thể dẫn đến việc cung cấp nhiều insulin hơn !!!!! + Giới hạn bolus đã áp dụng: %1$.2f U đến %2$.2f U + Liều Bolus chỉ được ghi lại (không tiêm qua Bơm) + Chạy báo thức khi đến giờ ăn + eCarbs %1$dg / %2$dh (+%3$dmin) + %1$.2f U diff --git a/core/interfaces/src/main/res/values-zh-rCN/strings.xml b/core/interfaces/src/androidMain/res/values-zh-rCN/strings.xml similarity index 71% rename from core/interfaces/src/main/res/values-zh-rCN/strings.xml rename to core/interfaces/src/androidMain/res/values-zh-rCN/strings.xml index b00f2290749b..5310cddf1193 100644 --- a/core/interfaces/src/main/res/values-zh-rCN/strings.xml +++ b/core/interfaces/src/androidMain/res/values-zh-rCN/strings.xml @@ -112,4 +112,37 @@ U200 U300 U500 + mg/dL/U + mmol/L/U + %1$+.2f U + %1$d 克 + %1$s: %2$s + 碳水化合物 + 在 %1$d 分钟内运行提醒 + 泵暂停了 + 闭环暂停了 + 泵已断开 + 基础率值与小时数不一致:%1$s + 基础率已设为支持的最小值:%1$s + 基础率值被泵支持的最大值:%1$s 替换了 + U/h + 克/U + %1$d 分钟 + 记录 + 大剂量 + 扩展碳水 + 个人配置低目标 + 个人配置高目标 + 配置文件DIA值 + 配置文件敏感系数值 + 配置文件碳水系数值 + »%1$s« %2$.2f 超出了硬限制 + 基础率值 + 活性碳水vs活性胰岛素 + !!!!!检测到碳水化合物吸收缓慢:%1$d%%的时间。仔细检查你的计算。COB可能被高估,因此可能注射过多的胰岛素!!!!! + 已应用推注限制: %1$.2f U 到 %2$.2f U + 仅记录大剂量数值(泵不会输注) + 在应当吃饭时提醒 + eCarbs %1$dg / %2$dh (+%3$dmin) + %1$.2f U diff --git a/core/interfaces/src/main/res/values-zh-rTW/strings.xml b/core/interfaces/src/androidMain/res/values-zh-rTW/strings.xml similarity index 71% rename from core/interfaces/src/main/res/values-zh-rTW/strings.xml rename to core/interfaces/src/androidMain/res/values-zh-rTW/strings.xml index 83891176af55..05c7da8dccff 100644 --- a/core/interfaces/src/main/res/values-zh-rTW/strings.xml +++ b/core/interfaces/src/androidMain/res/values-zh-rTW/strings.xml @@ -124,4 +124,37 @@ Other U200 U300 U500 + mg/dL/U + mmol/L/U + %1$+.2f U + %1$d g + %1$s: %2$s + 碳水化合物 + 在 %1$d 分鐘內運行警報 + 幫浦已暫停 + 循環已暫停 + 幫浦已中斷連線 + 基礎率值未對齊小時:%1$s + 基礎率值已被最低支援值取代:%1$s + 基礎率值已被最高支援值取代:%1$s + U/h + g/U + %1$d 分鐘 + 紀錄 + 注射 + 延長碳水化合物 + 設定檔低目標 + 設定檔高目標 + 設定檔 DIA 值 + 設定檔敏感度值 + 設定檔碳水化合物比率值 + »%1$s« %2$.2f 超出硬限制範圍 + 基礎率值 + COB 對 IOB + !!!!! 偵測到碳水吸收偏慢:%1$d%% 的時間。請再次檢查你的計算。活性碳水化合物可能被高估,因此可能會注射過多胰島素 !!!!! + 注射限制已套用:%1$.2f U 至 %2$.2f U + 僅紀錄注射(不由幫浦傳送) + 當到達用餐時間時提醒我 + eCarbs %1$dg / %2$dh (+%3$dmin) + %1$.2f U diff --git a/core/interfaces/src/main/res/values/strings.xml b/core/interfaces/src/androidMain/res/values/strings.xml similarity index 72% rename from core/interfaces/src/main/res/values/strings.xml rename to core/interfaces/src/androidMain/res/values/strings.xml index f307b7a52071..98849418971f 100644 --- a/core/interfaces/src/main/res/values/strings.xml +++ b/core/interfaces/src/androidMain/res/values/strings.xml @@ -125,4 +125,37 @@ U300 U500 + mg/dL/U + mmol/L/U + %1$+.2f U + %1$d g + %1$s: %2$s + Carbs + Run alarm in %1$d min + Pump suspended + Loop suspended + Pump disconnected + Basal values not aligned to hours: %1$s + Basal value replaced by minimum supported value: %1$s + Basal value replaced by maximum supported value: %1$s + U/h + g/U + %1$d min + Record + Bolus + EXTENDED CARBS + Profile low target + Profile high target + Profile DIA value + Profile sensitivity value + Profile carbs ratio value + »%1$s« %2$.2f is out of hard limits + Basal value + COB vs IOB + !!!!! Slow carbs absorption detected: %1$d%% of time. Double check your calculation. COB can be overestimated thus more insulin could be given !!!!! + Bolus constraint applied: %1$.2f U to %2$.2f U + Bolus will be recorded only (not delivered by pump) + Run alarm when is time to eat + eCarbs %1$dg / %2$dh (+%3$dmin) + %1$.2f U diff --git a/core/interfaces/src/main/res/values/wear_paths.xml b/core/interfaces/src/androidMain/res/values/wear_paths.xml similarity index 100% rename from core/interfaces/src/main/res/values/wear_paths.xml rename to core/interfaces/src/androidMain/res/values/wear_paths.xml diff --git a/core/interfaces/src/commonMain/kotlin/app/aaps/annotations/OpenForTesting.kt b/core/interfaces/src/commonMain/kotlin/app/aaps/annotations/OpenForTesting.kt new file mode 100644 index 000000000000..9aae295b2a95 --- /dev/null +++ b/core/interfaces/src/commonMain/kotlin/app/aaps/annotations/OpenForTesting.kt @@ -0,0 +1,17 @@ +package app.aaps.annotations + +/** + * Annotate a class with [OpenForTesting] if it should be extendable for testing. + * + * There used to be two declarations of this, one per build variant, the debug one carrying an extra + * `@OpenClass` meta-annotation. Nothing ever read it: the allOpen plugin is configured with this + * annotation's own name (`all-open-dependencies.gradle.kts`), not with the meta-annotation, so the two + * declarations behaved identically and the variant split did nothing. One declaration now, and it + * holds no Android types, so it lives in common code. + * + * Note this means an annotated class is open in release builds too. That was already the case - the + * three modules applying allOpen apply it to every variant - it was just not visible while there were + * two files suggesting otherwise. + */ +@Target(AnnotationTarget.CLASS) +annotation class OpenForTesting \ No newline at end of file diff --git a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/alerts/LocalAlertUtils.kt b/core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/alerts/LocalAlertUtils.kt similarity index 100% rename from core/interfaces/src/main/kotlin/app/aaps/core/interfaces/alerts/LocalAlertUtils.kt rename to core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/alerts/LocalAlertUtils.kt diff --git a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/aps/APS.kt b/core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/aps/APS.kt similarity index 100% rename from core/interfaces/src/main/kotlin/app/aaps/core/interfaces/aps/APS.kt rename to core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/aps/APS.kt diff --git a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/aps/APSResult.kt b/core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/aps/APSResult.kt similarity index 90% rename from core/interfaces/src/main/kotlin/app/aaps/core/interfaces/aps/APSResult.kt rename to core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/aps/APSResult.kt index 85d8718d9938..eeb8981bd793 100644 --- a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/aps/APSResult.kt +++ b/core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/aps/APSResult.kt @@ -1,9 +1,8 @@ package app.aaps.core.interfaces.aps -import android.text.Spanned import app.aaps.core.data.model.GV import app.aaps.core.interfaces.constraints.Constraint -import org.json.JSONObject +import kotlinx.serialization.json.JsonObject interface APSResult { @@ -52,10 +51,8 @@ interface APSResult { val iob: IobTotal? get() = iobData?.get(0) suspend fun resultAsString(): String - suspend fun resultAsSpanned(): Spanned - suspend fun resultAsHtmlString(): String fun newAndClone(): APSResult - fun json(): JSONObject? + fun json(): JsonObject? fun predictions(): Predictions? fun rawData(): Any diff --git a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/aps/AutosensData.kt b/core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/aps/AutosensData.kt similarity index 100% rename from core/interfaces/src/main/kotlin/app/aaps/core/interfaces/aps/AutosensData.kt rename to core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/aps/AutosensData.kt diff --git a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/aps/AutosensDataStore.kt b/core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/aps/AutosensDataStore.kt similarity index 100% rename from core/interfaces/src/main/kotlin/app/aaps/core/interfaces/aps/AutosensDataStore.kt rename to core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/aps/AutosensDataStore.kt diff --git a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/aps/AutosensResult.kt b/core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/aps/AutosensResult.kt similarity index 100% rename from core/interfaces/src/main/kotlin/app/aaps/core/interfaces/aps/AutosensResult.kt rename to core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/aps/AutosensResult.kt diff --git a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/aps/CurrentTemp.kt b/core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/aps/CurrentTemp.kt similarity index 100% rename from core/interfaces/src/main/kotlin/app/aaps/core/interfaces/aps/CurrentTemp.kt rename to core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/aps/CurrentTemp.kt diff --git a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/aps/GlucoseStatus.kt b/core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/aps/GlucoseStatus.kt similarity index 100% rename from core/interfaces/src/main/kotlin/app/aaps/core/interfaces/aps/GlucoseStatus.kt rename to core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/aps/GlucoseStatus.kt diff --git a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/aps/GlucoseStatusAutoIsf.kt b/core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/aps/GlucoseStatusAutoIsf.kt similarity index 100% rename from core/interfaces/src/main/kotlin/app/aaps/core/interfaces/aps/GlucoseStatusAutoIsf.kt rename to core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/aps/GlucoseStatusAutoIsf.kt diff --git a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/aps/GlucoseStatusSMB.kt b/core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/aps/GlucoseStatusSMB.kt similarity index 100% rename from core/interfaces/src/main/kotlin/app/aaps/core/interfaces/aps/GlucoseStatusSMB.kt rename to core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/aps/GlucoseStatusSMB.kt diff --git a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/aps/IobTotal.kt b/core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/aps/IobTotal.kt similarity index 100% rename from core/interfaces/src/main/kotlin/app/aaps/core/interfaces/aps/IobTotal.kt rename to core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/aps/IobTotal.kt diff --git a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/aps/Loop.kt b/core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/aps/Loop.kt similarity index 96% rename from core/interfaces/src/main/kotlin/app/aaps/core/interfaces/aps/Loop.kt rename to core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/aps/Loop.kt index 40fed3418443..732b154c3973 100644 --- a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/aps/Loop.kt +++ b/core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/aps/Loop.kt @@ -7,6 +7,7 @@ import app.aaps.core.data.ue.ValueWithUnit import app.aaps.core.interfaces.constraints.Constraint import app.aaps.core.interfaces.profile.Profile import app.aaps.core.interfaces.pump.PumpEnactResult +import kotlin.time.Clock interface Loop { @@ -26,7 +27,7 @@ interface Loop { var tbrSetByPump: PumpEnactResult? = null var smbSetByPump: PumpEnactResult? = null var source: String? = null - var lastAPSRun = System.currentTimeMillis() + var lastAPSRun = Clock.System.now().toEpochMilliseconds() var lastTBREnact: Long = 0 var lastSMBEnact: Long = 0 var lastTBRRequest: Long = 0 diff --git a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/aps/MealData.kt b/core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/aps/MealData.kt similarity index 100% rename from core/interfaces/src/main/kotlin/app/aaps/core/interfaces/aps/MealData.kt rename to core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/aps/MealData.kt diff --git a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/aps/OapsProfile.kt b/core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/aps/OapsProfile.kt similarity index 100% rename from core/interfaces/src/main/kotlin/app/aaps/core/interfaces/aps/OapsProfile.kt rename to core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/aps/OapsProfile.kt diff --git a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/aps/OapsProfileAutoIsf.kt b/core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/aps/OapsProfileAutoIsf.kt similarity index 100% rename from core/interfaces/src/main/kotlin/app/aaps/core/interfaces/aps/OapsProfileAutoIsf.kt rename to core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/aps/OapsProfileAutoIsf.kt diff --git a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/aps/Predictions.kt b/core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/aps/Predictions.kt similarity index 100% rename from core/interfaces/src/main/kotlin/app/aaps/core/interfaces/aps/Predictions.kt rename to core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/aps/Predictions.kt diff --git a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/aps/RT.kt b/core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/aps/RT.kt similarity index 58% rename from core/interfaces/src/main/kotlin/app/aaps/core/interfaces/aps/RT.kt rename to core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/aps/RT.kt index 4633660cc91a..225483e63140 100644 --- a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/aps/RT.kt +++ b/core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/aps/RT.kt @@ -1,5 +1,10 @@ package app.aaps.core.interfaces.aps +import app.aaps.core.data.datetime.parseIsoToEpochMillisOrNull +import kotlinx.datetime.LocalDateTime +import kotlinx.datetime.TimeZone +import kotlinx.datetime.format.char +import kotlinx.datetime.toLocalDateTime import kotlinx.serialization.KSerializer import kotlinx.serialization.Serializable import kotlinx.serialization.descriptors.PrimitiveKind @@ -8,12 +13,7 @@ import kotlinx.serialization.descriptors.SerialDescriptor import kotlinx.serialization.encoding.Decoder import kotlinx.serialization.encoding.Encoder import kotlinx.serialization.json.Json -import org.joda.time.DateTime -import org.joda.time.format.ISODateTimeFormat -import java.text.DateFormat -import java.text.SimpleDateFormat -import java.util.Locale -import java.util.TimeZone +import kotlin.time.Instant @Serializable data class RT( @@ -44,7 +44,6 @@ data class RT( var variable_sens: Double? = null, var isfMgdlForCarbs: Double? = null, // used to pass to AAPS client - var consoleLog: MutableList? = null, var consoleError: MutableList? = null ) { @@ -76,19 +75,40 @@ data class RT( return fromISODateString(decoder.decodeString()) } - fun fromISODateString(isoDateString: String): Long { - val parser = ISODateTimeFormat.dateTimeParser() - val dateTime = DateTime.parse(isoDateString, parser) - return dateTime.toDate().time + /** + * Was joda's `ISODateTimeFormat.dateTimeParser()`. The replacement accepts the same shapes - + * this parses device status written by other Nightscout uploaders, not only what AAPS wrote, + * so `+0200` without a colon and offset-less values have to keep working. + * + * It still throws on input it cannot read, as joda did. A silent epoch-0 timestamp inside an + * APS result would be worse than a loud failure. + */ + fun fromISODateString(isoDateString: String): Long = + parseIsoToEpochMillisOrNull(isoDateString) + ?: throw IllegalArgumentException("Invalid format: $isoDateString") + + /** + * `yyyy-MM-dd'T'HH:mm:ss.SSS'Z'` in UTC, always with three fractional digits. + * + * Built explicitly rather than with `Instant.toString()`, which drops trailing zeros and + * omits the fraction altogether on a whole second. + */ + private val isoOut = LocalDateTime.Format { + year(); char('-'); monthNumber(); char('-'); day() + char('T') + hour(); char(':'); minute(); char(':'); second() + char('.'); secondFraction(3) } - fun toISOString(date: Long): String { - @Suppress("SpellCheckingInspection", "LocalVariableName") - val FORMAT_DATE_ISO_OUT = "yyyy-MM-dd'T'HH:mm:ss.SSS'Z'" - val f: DateFormat = SimpleDateFormat(FORMAT_DATE_ISO_OUT, Locale.getDefault()) - f.timeZone = TimeZone.getTimeZone("UTC") - return f.format(date) - } + /** + * This used to be `SimpleDateFormat(pattern, Locale.getDefault())`, which resolves its + * calendar from the locale. On a phone set to Thai that selects the Buddhist calendar and + * the year was written as 2569 instead of 2026 - a wire timestamp 543 years in the future. + * The formatter here is locale independent, which fixes that as well as removing the JVM + * dependency. `RtIsoStringParityTest` pins both halves. + */ + fun toISOString(date: Long): String = + isoOut.format(Instant.fromEpochMilliseconds(date).toLocalDateTime(TimeZone.UTC)) + "Z" } companion object { diff --git a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/aps/Sensitivity.kt b/core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/aps/Sensitivity.kt similarity index 99% rename from core/interfaces/src/main/kotlin/app/aaps/core/interfaces/aps/Sensitivity.kt rename to core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/aps/Sensitivity.kt index 499a7b65b881..7479522250a6 100644 --- a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/aps/Sensitivity.kt +++ b/core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/aps/Sensitivity.kt @@ -41,6 +41,7 @@ interface Sensitivity { siteChanges: List, profileSwitches: List ): AutosensResult + fun maxAbsorptionHours(): Double val isMinCarbsAbsorptionDynamic: Boolean diff --git a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/automation/Automation.kt b/core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/automation/Automation.kt similarity index 100% rename from core/interfaces/src/main/kotlin/app/aaps/core/interfaces/automation/Automation.kt rename to core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/automation/Automation.kt diff --git a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/automation/AutomationEvent.kt b/core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/automation/AutomationEvent.kt similarity index 100% rename from core/interfaces/src/main/kotlin/app/aaps/core/interfaces/automation/AutomationEvent.kt rename to core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/automation/AutomationEvent.kt diff --git a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/autotune/Autotune.kt b/core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/autotune/Autotune.kt similarity index 100% rename from core/interfaces/src/main/kotlin/app/aaps/core/interfaces/autotune/Autotune.kt rename to core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/autotune/Autotune.kt diff --git a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/bgQualityCheck/BgQualityCheck.kt b/core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/bgQualityCheck/BgQualityCheck.kt similarity index 100% rename from core/interfaces/src/main/kotlin/app/aaps/core/interfaces/bgQualityCheck/BgQualityCheck.kt rename to core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/bgQualityCheck/BgQualityCheck.kt diff --git a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/bolus/BatchAction.kt b/core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/bolus/BatchAction.kt similarity index 100% rename from core/interfaces/src/main/kotlin/app/aaps/core/interfaces/bolus/BatchAction.kt rename to core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/bolus/BatchAction.kt diff --git a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/bolus/BatchExecutor.kt b/core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/bolus/BatchExecutor.kt similarity index 100% rename from core/interfaces/src/main/kotlin/app/aaps/core/interfaces/bolus/BatchExecutor.kt rename to core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/bolus/BatchExecutor.kt diff --git a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/bolus/WizardBolusExecutor.kt b/core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/bolus/WizardBolusExecutor.kt similarity index 100% rename from core/interfaces/src/main/kotlin/app/aaps/core/interfaces/bolus/WizardBolusExecutor.kt rename to core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/bolus/WizardBolusExecutor.kt diff --git a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/bolus/WizardExecutor.kt b/core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/bolus/WizardExecutor.kt similarity index 100% rename from core/interfaces/src/main/kotlin/app/aaps/core/interfaces/bolus/WizardExecutor.kt rename to core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/bolus/WizardExecutor.kt diff --git a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/calibration/AddEntryResult.kt b/core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/calibration/AddEntryResult.kt similarity index 100% rename from core/interfaces/src/main/kotlin/app/aaps/core/interfaces/calibration/AddEntryResult.kt rename to core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/calibration/AddEntryResult.kt diff --git a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/calibration/Calibration.kt b/core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/calibration/Calibration.kt similarity index 100% rename from core/interfaces/src/main/kotlin/app/aaps/core/interfaces/calibration/Calibration.kt rename to core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/calibration/Calibration.kt diff --git a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/calibration/CalibrationContext.kt b/core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/calibration/CalibrationContext.kt similarity index 100% rename from core/interfaces/src/main/kotlin/app/aaps/core/interfaces/calibration/CalibrationContext.kt rename to core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/calibration/CalibrationContext.kt diff --git a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/clientcontrol/ActionProgress.kt b/core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/clientcontrol/ActionProgress.kt similarity index 100% rename from core/interfaces/src/main/kotlin/app/aaps/core/interfaces/clientcontrol/ActionProgress.kt rename to core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/clientcontrol/ActionProgress.kt diff --git a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/clientcontrol/ClientControlActionDispatcher.kt b/core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/clientcontrol/ClientControlActionDispatcher.kt similarity index 100% rename from core/interfaces/src/main/kotlin/app/aaps/core/interfaces/clientcontrol/ClientControlActionDispatcher.kt rename to core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/clientcontrol/ClientControlActionDispatcher.kt diff --git a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/clientcontrol/PendingAction.kt b/core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/clientcontrol/PendingAction.kt similarity index 100% rename from core/interfaces/src/main/kotlin/app/aaps/core/interfaces/clientcontrol/PendingAction.kt rename to core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/clientcontrol/PendingAction.kt diff --git a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/configuration/Config.kt b/core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/configuration/Config.kt similarity index 100% rename from core/interfaces/src/main/kotlin/app/aaps/core/interfaces/configuration/Config.kt rename to core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/configuration/Config.kt diff --git a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/configuration/ConfigBuilder.kt b/core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/configuration/ConfigBuilder.kt similarity index 100% rename from core/interfaces/src/main/kotlin/app/aaps/core/interfaces/configuration/ConfigBuilder.kt rename to core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/configuration/ConfigBuilder.kt diff --git a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/configuration/RunningConfigurationKeys.kt b/core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/configuration/RunningConfigurationKeys.kt similarity index 100% rename from core/interfaces/src/main/kotlin/app/aaps/core/interfaces/configuration/RunningConfigurationKeys.kt rename to core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/configuration/RunningConfigurationKeys.kt diff --git a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/constraints/Constraint.kt b/core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/constraints/Constraint.kt similarity index 100% rename from core/interfaces/src/main/kotlin/app/aaps/core/interfaces/constraints/Constraint.kt rename to core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/constraints/Constraint.kt diff --git a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/constraints/ConstraintsChecker.kt b/core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/constraints/ConstraintsChecker.kt similarity index 100% rename from core/interfaces/src/main/kotlin/app/aaps/core/interfaces/constraints/ConstraintsChecker.kt rename to core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/constraints/ConstraintsChecker.kt diff --git a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/constraints/Objectives.kt b/core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/constraints/Objectives.kt similarity index 100% rename from core/interfaces/src/main/kotlin/app/aaps/core/interfaces/constraints/Objectives.kt rename to core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/constraints/Objectives.kt diff --git a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/constraints/PluginConstraints.kt b/core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/constraints/PluginConstraints.kt similarity index 100% rename from core/interfaces/src/main/kotlin/app/aaps/core/interfaces/constraints/PluginConstraints.kt rename to core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/constraints/PluginConstraints.kt diff --git a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/constraints/PumpPluginConstraints.kt b/core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/constraints/PumpPluginConstraints.kt similarity index 100% rename from core/interfaces/src/main/kotlin/app/aaps/core/interfaces/constraints/PumpPluginConstraints.kt rename to core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/constraints/PumpPluginConstraints.kt diff --git a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/constraints/Safety.kt b/core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/constraints/Safety.kt similarity index 100% rename from core/interfaces/src/main/kotlin/app/aaps/core/interfaces/constraints/Safety.kt rename to core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/constraints/Safety.kt diff --git a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/db/ClockSkewCompensation.kt b/core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/db/ClockSkewCompensation.kt similarity index 100% rename from core/interfaces/src/main/kotlin/app/aaps/core/interfaces/db/ClockSkewCompensation.kt rename to core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/db/ClockSkewCompensation.kt diff --git a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/db/PersistenceLayer.kt b/core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/db/PersistenceLayer.kt similarity index 99% rename from core/interfaces/src/main/kotlin/app/aaps/core/interfaces/db/PersistenceLayer.kt rename to core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/db/PersistenceLayer.kt index 0a143b5c9297..3cec74dd81c2 100644 --- a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/db/PersistenceLayer.kt +++ b/core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/db/PersistenceLayer.kt @@ -29,6 +29,7 @@ import app.aaps.core.data.ue.ValueWithUnit import app.aaps.core.interfaces.aps.APSResult import kotlinx.coroutines.flow.Flow import kotlin.reflect.KClass +import kotlin.time.Clock /** * Read-only diagnostics gathered before a startup VACUUM. @@ -87,7 +88,7 @@ interface PersistenceLayer { * @param T The domain type to observe (BS, CA, EB, TB, TT, TE, PS, EPS, etc.) * @return Flow that emits list of changed entities of type T */ - fun observeChanges(type: Class): Flow> + fun observeChanges(type: KClass): Flow> /** * Observe all database changes, emitting the set of domain types that changed in each transaction @@ -1290,7 +1291,7 @@ interface PersistenceLayer { */ suspend fun insertPumpTherapyEventIfNewByTimestamp( therapyEvent: TE, - timestamp: Long = System.currentTimeMillis(), + timestamp: Long = Clock.System.now().toEpochMilliseconds(), action: Action, source: Sources, note: String?, @@ -1648,4 +1649,4 @@ interface PersistenceLayer { * ``` */ inline fun PersistenceLayer.observeChanges(): Flow> = - observeChanges(T::class.java) \ No newline at end of file + observeChanges(T::class) \ No newline at end of file diff --git a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/db/ProcessedTbrEbData.kt b/core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/db/ProcessedTbrEbData.kt similarity index 100% rename from core/interfaces/src/main/kotlin/app/aaps/core/interfaces/db/ProcessedTbrEbData.kt rename to core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/db/ProcessedTbrEbData.kt diff --git a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/dst/DstHelper.kt b/core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/dst/DstHelper.kt similarity index 100% rename from core/interfaces/src/main/kotlin/app/aaps/core/interfaces/dst/DstHelper.kt rename to core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/dst/DstHelper.kt diff --git a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/graph/Scale.kt b/core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/graph/Scale.kt similarity index 100% rename from core/interfaces/src/main/kotlin/app/aaps/core/interfaces/graph/Scale.kt rename to core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/graph/Scale.kt diff --git a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/graph/SeriesData.kt b/core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/graph/SeriesData.kt similarity index 100% rename from core/interfaces/src/main/kotlin/app/aaps/core/interfaces/graph/SeriesData.kt rename to core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/graph/SeriesData.kt diff --git a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/insulin/ConcentrationHelper.kt b/core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/insulin/ConcentrationHelper.kt similarity index 99% rename from core/interfaces/src/main/kotlin/app/aaps/core/interfaces/insulin/ConcentrationHelper.kt rename to core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/insulin/ConcentrationHelper.kt index 13bee700702a..2a927cc76ef0 100644 --- a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/insulin/ConcentrationHelper.kt +++ b/core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/insulin/ConcentrationHelper.kt @@ -115,7 +115,7 @@ interface ConcentrationHelper { * @param durationInMin Int * @return String with units (U100) or with both units if not U100 and ago if less than 6 hour ago, else null */ - fun insulinDeliveryAgoString(amount: PumpInsulin, totalAmount: PumpInsulin,startTime: Long, durationInMin: Int? = null): String + fun insulinDeliveryAgoString(amount: PumpInsulin, totalAmount: PumpInsulin, startTime: Long, durationInMin: Int? = null): String /** * show insulinConcentration as a String i.e. "U100", "U200", ... diff --git a/core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/insulin/ConcentrationType.kt b/core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/insulin/ConcentrationType.kt new file mode 100644 index 000000000000..32c5a45a502f --- /dev/null +++ b/core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/insulin/ConcentrationType.kt @@ -0,0 +1,21 @@ +package app.aaps.core.interfaces.insulin + +import app.aaps.core.interfaces.InterfacesStrings +import app.aaps.core.keys.interfaces.TextRef + +enum class ConcentrationType(val value: Double, val label: TextRef) { + UNKNOWN(-1.0, InterfacesStrings.unknown), + U10(0.1, InterfacesStrings.u10), + U40(0.4, InterfacesStrings.u40), + U50(0.5, InterfacesStrings.u50), + U100(1.0, InterfacesStrings.u100), + U200(2.0, InterfacesStrings.u200), + U300(3.0, InterfacesStrings.u300), + U500(5.0, InterfacesStrings.u500); + + companion object { + + fun fromDouble(type: Double) = entries.firstOrNull { it.value == type } ?: UNKNOWN + fun fromInt(type: Int) = entries.firstOrNull { it.value * 100 == type.toDouble() } ?: UNKNOWN + } +} diff --git a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/insulin/InsulinManager.kt b/core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/insulin/InsulinManager.kt similarity index 100% rename from core/interfaces/src/main/kotlin/app/aaps/core/interfaces/insulin/InsulinManager.kt rename to core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/insulin/InsulinManager.kt diff --git a/core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/insulin/InsulinType.kt b/core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/insulin/InsulinType.kt new file mode 100644 index 000000000000..1ad9b4cc9908 --- /dev/null +++ b/core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/insulin/InsulinType.kt @@ -0,0 +1,30 @@ +package app.aaps.core.interfaces.insulin + +import app.aaps.core.data.model.ICfg +import app.aaps.core.interfaces.InterfacesStrings +import app.aaps.core.interfaces.resources.TextResolver +import app.aaps.core.keys.interfaces.TextRef + +enum class InsulinType(val value: Int, val insulinEndTime: Long, val insulinPeakTime: Long, val label: TextRef, val comment: TextRef) { + UNKNOWN(-1, 0, 0, InterfacesStrings.unknown, InterfacesStrings.unknown), + + // int FAST_ACTING_INSULIN = 0; // old model no longer available + // int FAST_ACTING_INSULIN_PROLONGED = 1; // old model no longer available + OREF_RAPID_ACTING(2, 8 * 3600 * 1000, 75 * 60000, InterfacesStrings.rapid_acting_oref, InterfacesStrings.fast_acting_insulin_comment), + OREF_ULTRA_RAPID_ACTING(3, 8 * 3600 * 1000, 55 * 60000, InterfacesStrings.ultra_rapid_oref, InterfacesStrings.ultra_fast_acting_insulin_comment), + OREF_FREE_PEAK(4, 8 * 3600 * 1000, 50 * 60000, InterfacesStrings.free_peak_oref, InterfacesStrings.insulin_peak_time), + OREF_LYUMJEV(5, 8 * 3600 * 1000, 45 * 60000, InterfacesStrings.lyumjev, InterfacesStrings.lyumjev); + + val iCfg: ICfg + get() = ICfg(this.name, insulinEndTime, insulinPeakTime, 1.0) + + /** Provide iCfg with a default friendly name on insulin creation from template */ + fun getICfg(rh: TextResolver): ICfg = ICfg(rh.gs(this.label), insulinEndTime, insulinPeakTime, 1.0) + + companion object { + + private val map = entries.associateBy(InsulinType::value) + fun fromInt(type: Int) = map[type] ?: OREF_RAPID_ACTING + fun fromPeak(insulinPeakTime: Long) = entries.firstOrNull { it.insulinPeakTime == insulinPeakTime } ?: OREF_FREE_PEAK + } +} diff --git a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/iob/GlucoseStatusProvider.kt b/core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/iob/GlucoseStatusProvider.kt similarity index 100% rename from core/interfaces/src/main/kotlin/app/aaps/core/interfaces/iob/GlucoseStatusProvider.kt rename to core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/iob/GlucoseStatusProvider.kt diff --git a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/iob/IobCobCalculator.kt b/core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/iob/IobCobCalculator.kt similarity index 100% rename from core/interfaces/src/main/kotlin/app/aaps/core/interfaces/iob/IobCobCalculator.kt rename to core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/iob/IobCobCalculator.kt diff --git a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/local/LocaleDependentSetting.kt b/core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/local/LocaleDependentSetting.kt similarity index 99% rename from core/interfaces/src/main/kotlin/app/aaps/core/interfaces/local/LocaleDependentSetting.kt rename to core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/local/LocaleDependentSetting.kt index 08afbaf305c6..0cef8e4f15ad 100644 --- a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/local/LocaleDependentSetting.kt +++ b/core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/local/LocaleDependentSetting.kt @@ -1,5 +1,6 @@ package app.aaps.core.interfaces.local interface LocaleDependentSetting { + val ntpServer: String } \ No newline at end of file diff --git a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/logging/AAPSLogger.kt b/core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/logging/AAPSLogger.kt similarity index 100% rename from core/interfaces/src/main/kotlin/app/aaps/core/interfaces/logging/AAPSLogger.kt rename to core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/logging/AAPSLogger.kt diff --git a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/logging/L.kt b/core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/logging/L.kt similarity index 100% rename from core/interfaces/src/main/kotlin/app/aaps/core/interfaces/logging/L.kt rename to core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/logging/L.kt diff --git a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/logging/LTag.kt b/core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/logging/LTag.kt similarity index 100% rename from core/interfaces/src/main/kotlin/app/aaps/core/interfaces/logging/LTag.kt rename to core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/logging/LTag.kt diff --git a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/logging/LogElement.kt b/core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/logging/LogElement.kt similarity index 100% rename from core/interfaces/src/main/kotlin/app/aaps/core/interfaces/logging/LogElement.kt rename to core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/logging/LogElement.kt diff --git a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/logging/LoggerUtils.kt b/core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/logging/LoggerUtils.kt similarity index 100% rename from core/interfaces/src/main/kotlin/app/aaps/core/interfaces/logging/LoggerUtils.kt rename to core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/logging/LoggerUtils.kt diff --git a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/logging/UserEntryLogger.kt b/core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/logging/UserEntryLogger.kt similarity index 100% rename from core/interfaces/src/main/kotlin/app/aaps/core/interfaces/logging/UserEntryLogger.kt rename to core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/logging/UserEntryLogger.kt diff --git a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/maintenance/CloudDirectoryManager.kt b/core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/maintenance/CloudDirectoryManager.kt similarity index 100% rename from core/interfaces/src/main/kotlin/app/aaps/core/interfaces/maintenance/CloudDirectoryManager.kt rename to core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/maintenance/CloudDirectoryManager.kt diff --git a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/maintenance/CloudModels.kt b/core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/maintenance/CloudModels.kt similarity index 100% rename from core/interfaces/src/main/kotlin/app/aaps/core/interfaces/maintenance/CloudModels.kt rename to core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/maintenance/CloudModels.kt diff --git a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/maintenance/CloudStorageProvider.kt b/core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/maintenance/CloudStorageProvider.kt similarity index 96% rename from core/interfaces/src/main/kotlin/app/aaps/core/interfaces/maintenance/CloudStorageProvider.kt rename to core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/maintenance/CloudStorageProvider.kt index b6405a3b18c7..9a50a6c2f67a 100644 --- a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/maintenance/CloudStorageProvider.kt +++ b/core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/maintenance/CloudStorageProvider.kt @@ -1,6 +1,7 @@ package app.aaps.core.interfaces.maintenance import androidx.compose.ui.graphics.vector.ImageVector +import app.aaps.core.keys.interfaces.TextRef /** * Abstract interface for cloud storage providers. @@ -31,14 +32,14 @@ interface CloudStorageProvider { val icon: ImageVector /** - * String resource ID for "authorized" status text (e.g., "Google Drive Authorized") + * Text for "authorized" status (e.g., "Google Drive Authorized") */ - val authorizedTextResId: Int + val authorizedText: TextRef /** - * String resource ID for "re-authorization required" status text + * Text for "re-authorization required" status */ - val reAuthRequiredTextResId: Int + val reAuthRequiredText: TextRef // ==================== Authentication ==================== diff --git a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/maintenance/ImportExportPrefs.kt b/core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/maintenance/ImportExportPrefs.kt similarity index 89% rename from core/interfaces/src/main/kotlin/app/aaps/core/interfaces/maintenance/ImportExportPrefs.kt rename to core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/maintenance/ImportExportPrefs.kt index 004669f21eba..95ae26c23681 100644 --- a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/maintenance/ImportExportPrefs.kt +++ b/core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/maintenance/ImportExportPrefs.kt @@ -1,9 +1,6 @@ package app.aaps.core.interfaces.maintenance -import android.content.Context -import androidx.fragment.app.FragmentActivity import app.aaps.core.interfaces.rx.weardata.CwfData -import org.json.JSONObject /** Where to send the export. */ enum class ExportDestination { @@ -50,11 +47,17 @@ data class ExportPreparation( interface ImportExportPrefs { fun exportCustomWatchface(customWatchface: CwfData, withDate: Boolean = true) - fun exportSharedPreferences(activity: FragmentActivity) - fun exportSharedPreferencesNonInteractive(context: Context, password: String): Boolean - fun exportUserEntriesCsv(context: Context) + fun exportSharedPreferencesNonInteractive(password: String): Boolean + fun exportUserEntriesCsv() suspend fun executeCsvExport(): ExportResult - fun exportApsResult(algorithm: String?, input: JSONObject, output: JSONObject?) + + /** + * Write one APS run to a debug file (engineering mode only). + * + * [input] and [output] are whole JSON documents, already serialised. Nothing here reads them - + * they are wrapped in a small envelope and written out - so text is all this needs. + */ + fun exportApsResult(algorithm: String?, input: String, output: String?) // Compose export support — discrete steps, no UI diff --git a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/maintenance/Maintenance.kt b/core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/maintenance/Maintenance.kt similarity index 100% rename from core/interfaces/src/main/kotlin/app/aaps/core/interfaces/maintenance/Maintenance.kt rename to core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/maintenance/Maintenance.kt diff --git a/core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/maintenance/PrefMetadata.kt b/core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/maintenance/PrefMetadata.kt new file mode 100644 index 000000000000..723e9e2c48ef --- /dev/null +++ b/core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/maintenance/PrefMetadata.kt @@ -0,0 +1,3 @@ +package app.aaps.core.interfaces.maintenance + +data class PrefMetadata(var value: String, var status: PrefsStatus, var info: String? = null) diff --git a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/maintenance/Prefs.kt b/core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/maintenance/Prefs.kt similarity index 100% rename from core/interfaces/src/main/kotlin/app/aaps/core/interfaces/maintenance/Prefs.kt rename to core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/maintenance/Prefs.kt diff --git a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/maintenance/PrefsFile.kt b/core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/maintenance/PrefsFile.kt similarity index 65% rename from core/interfaces/src/main/kotlin/app/aaps/core/interfaces/maintenance/PrefsFile.kt rename to core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/maintenance/PrefsFile.kt index 98c92c4ffa4c..999cc0b7cf67 100644 --- a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/maintenance/PrefsFile.kt +++ b/core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/maintenance/PrefsFile.kt @@ -1,18 +1,13 @@ package app.aaps.core.interfaces.maintenance -import android.os.Parcelable -import kotlinx.parcelize.Parcelize -import kotlinx.parcelize.RawValue - -@Parcelize data class PrefsFile( val name: String, val content: String, // metadata here is used only for list display - val metadata: @RawValue Map, + val metadata: Map, // Stable unique identifier from the storage provider (e.g. Google Drive file id). // Null for local files, which are uniquely identified by their name. val id: String? = null -) : Parcelable \ No newline at end of file +) diff --git a/core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/maintenance/PrefsMetadataKey.kt b/core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/maintenance/PrefsMetadataKey.kt new file mode 100644 index 000000000000..a04f751d12b3 --- /dev/null +++ b/core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/maintenance/PrefsMetadataKey.kt @@ -0,0 +1,17 @@ +package app.aaps.core.interfaces.maintenance + +import androidx.compose.ui.graphics.vector.ImageVector +import app.aaps.core.keys.interfaces.TextRef + +interface PrefsMetadataKey { + + val key: String + val icon: ImageVector + val label: Int + + /** + * The value as it should be shown, as a reference rather than resolved text - the layer that + * draws decides the language. + */ + fun formatForDisplay(value: String): TextRef +} \ No newline at end of file diff --git a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/maintenance/PrefsStatus.kt b/core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/maintenance/PrefsStatus.kt similarity index 78% rename from core/interfaces/src/main/kotlin/app/aaps/core/interfaces/maintenance/PrefsStatus.kt rename to core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/maintenance/PrefsStatus.kt index aaf238d8f96f..a5e2f526fc2c 100644 --- a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/maintenance/PrefsStatus.kt +++ b/core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/maintenance/PrefsStatus.kt @@ -1,12 +1,11 @@ package app.aaps.core.interfaces.maintenance -import android.os.Parcelable import androidx.compose.ui.graphics.vector.ImageVector -interface PrefsStatus : Parcelable { +interface PrefsStatus { val icon: ImageVector val isOk: Boolean get() = false val isWarning: Boolean get() = false val isError: Boolean get() = false -} \ No newline at end of file +} diff --git a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/navigation/ElementCategory.kt b/core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/navigation/ElementCategory.kt similarity index 99% rename from core/interfaces/src/main/kotlin/app/aaps/core/interfaces/navigation/ElementCategory.kt rename to core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/navigation/ElementCategory.kt index 4b24f1c7f101..92aa3ce975e8 100644 --- a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/navigation/ElementCategory.kt +++ b/core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/navigation/ElementCategory.kt @@ -4,6 +4,7 @@ package app.aaps.core.interfaces.navigation * Logical grouping for configuration screens and bottom sheets. */ enum class ElementCategory { + TREATMENT, CGM, MANAGEMENT, diff --git a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/navigation/ElementType.kt b/core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/navigation/ElementType.kt similarity index 100% rename from core/interfaces/src/main/kotlin/app/aaps/core/interfaces/navigation/ElementType.kt rename to core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/navigation/ElementType.kt diff --git a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/notifications/AapsNotification.kt b/core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/notifications/AapsNotification.kt similarity index 72% rename from core/interfaces/src/main/kotlin/app/aaps/core/interfaces/notifications/AapsNotification.kt rename to core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/notifications/AapsNotification.kt index fea873d4334f..dccf3c07b30c 100644 --- a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/notifications/AapsNotification.kt +++ b/core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/notifications/AapsNotification.kt @@ -1,15 +1,15 @@ package app.aaps.core.interfaces.notifications -import androidx.annotation.RawRes +import kotlin.time.Clock data class AapsNotification( val id: NotificationId, val instanceKey: Int, val text: String, val level: NotificationLevel, - val date: Long = System.currentTimeMillis(), + val date: Long = Clock.System.now().toEpochMilliseconds(), val validTo: Long = 0L, - @RawRes val soundRes: Int? = null, + val sound: AlarmSound? = null, val actions: List = emptyList(), val validityCheck: (() -> Boolean)? = null ) diff --git a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/notifications/AlarmIntent.kt b/core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/notifications/AlarmIntent.kt similarity index 83% rename from core/interfaces/src/main/kotlin/app/aaps/core/interfaces/notifications/AlarmIntent.kt rename to core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/notifications/AlarmIntent.kt index 89483d636f77..cfedbf5cad0a 100644 --- a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/notifications/AlarmIntent.kt +++ b/core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/notifications/AlarmIntent.kt @@ -8,7 +8,9 @@ package app.aaps.core.interfaces.notifications object AlarmIntent { /** Raw sound resource id; the activity uses it to play audio with volume ramp. */ - const val EXTRA_SOUND_ID = "soundId" + /** The [app.aaps.core.interfaces.notifications.AlarmSound] name. A name, not a resource id: an id + * is build specific, and this value outlives the process that wrote it. */ + const val EXTRA_SOUND = "sound" /** Alarm status / body text. */ const val EXTRA_STATUS = "status" diff --git a/core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/notifications/AlarmSound.kt b/core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/notifications/AlarmSound.kt new file mode 100644 index 000000000000..d04b5f94b7bc --- /dev/null +++ b/core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/notifications/AlarmSound.kt @@ -0,0 +1,33 @@ +package app.aaps.core.interfaces.notifications + +/** + * One of the alarm sounds AAPS ships. + * + * These used to travel as bare `R.raw.*` ints. An id is Android only, so it kept every declaring + * file out of common code, and it carried two further problems that this type removes: + * + * - **Two different "no sound".** `runAlarm` took `soundId: Int = 0` while `AapsNotification` took a + * nullable id, and the player guarded with `if (soundRes == 0) return` in two places. Absence is + * now spelled `null`, once, and the compiler enforces it. + * - **An id in an Intent.** The chosen sound is passed to the full screen alarm activity as an + * extra, and a resource id is a build specific number - the same value means something else in + * the next build. The stable [name] travels instead. + * + * Deliberately a closed set: the sound files live in `:core:ui/res/raw` and there are four of them. + * Adding one means adding an entry here, which makes the `when` in the Android resolver stop + * compiling until it is handled. + */ +enum class AlarmSound { + + /** Standard alarm. */ + ALARM, + + /** Urgent alarm - used for the Nightscout urgent announcement path. */ + URGENT_ALARM, + + /** General error. */ + ERROR, + + /** Bolus delivery failure. */ + BOLUS_ERROR +} diff --git a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/notifications/AlarmSoundPlayer.kt b/core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/notifications/AlarmSoundPlayer.kt similarity index 90% rename from core/interfaces/src/main/kotlin/app/aaps/core/interfaces/notifications/AlarmSoundPlayer.kt rename to core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/notifications/AlarmSoundPlayer.kt index e7d3b37649d0..4fc16b8b5a9d 100644 --- a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/notifications/AlarmSoundPlayer.kt +++ b/core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/notifications/AlarmSoundPlayer.kt @@ -1,6 +1,5 @@ package app.aaps.core.interfaces.notifications -import androidx.annotation.RawRes import app.aaps.core.interfaces.notifications.AlarmSoundPlayer.Companion.OWNER_FULLSCREEN import app.aaps.core.interfaces.notifications.AlarmSoundPlayer.Companion.OWNER_INTERNAL @@ -23,7 +22,7 @@ import app.aaps.core.interfaces.notifications.AlarmSoundPlayer.Companion.OWNER_I interface AlarmSoundPlayer { /** - * Start looping playback of [soundRes], recording [ownerTag] as the current owner. Any previous + * Start looping playback of [sound], recording [ownerTag] as the current owner. Any previous * playback (from either owner) is stopped first. * * @param postedAtElapsedRealtime [android.os.SystemClock.elapsedRealtime] when an accompanying @@ -32,7 +31,7 @@ interface AlarmSoundPlayer { * auto-launch). Pass 0 (the default) when there is no accompanying channel sound — the * duration probe is then skipped entirely. */ - fun play(@RawRes soundRes: Int, ownerTag: String, postedAtElapsedRealtime: Long = 0L) + fun play(sound: AlarmSound, ownerTag: String, postedAtElapsedRealtime: Long = 0L) /** Stop and release playback **only if** [ownerTag] is the current owner. No-op otherwise. */ fun stop(ownerTag: String) diff --git a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/notifications/NotificationAction.kt b/core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/notifications/NotificationAction.kt similarity index 58% rename from core/interfaces/src/main/kotlin/app/aaps/core/interfaces/notifications/NotificationAction.kt rename to core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/notifications/NotificationAction.kt index 5a475000cc46..5ee0d5799ac3 100644 --- a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/notifications/NotificationAction.kt +++ b/core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/notifications/NotificationAction.kt @@ -1,8 +1,8 @@ package app.aaps.core.interfaces.notifications -import androidx.annotation.StringRes +import app.aaps.core.keys.interfaces.TextRef data class NotificationAction( - @StringRes val buttonTextRes: Int, + val buttonText: TextRef, val action: () -> Unit ) diff --git a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/notifications/NotificationCategory.kt b/core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/notifications/NotificationCategory.kt similarity index 100% rename from core/interfaces/src/main/kotlin/app/aaps/core/interfaces/notifications/NotificationCategory.kt rename to core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/notifications/NotificationCategory.kt diff --git a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/notifications/NotificationHandle.kt b/core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/notifications/NotificationHandle.kt similarity index 100% rename from core/interfaces/src/main/kotlin/app/aaps/core/interfaces/notifications/NotificationHandle.kt rename to core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/notifications/NotificationHandle.kt diff --git a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/notifications/NotificationId.kt b/core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/notifications/NotificationId.kt similarity index 99% rename from core/interfaces/src/main/kotlin/app/aaps/core/interfaces/notifications/NotificationId.kt rename to core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/notifications/NotificationId.kt index c2c21feb7589..ec4734ecad30 100644 --- a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/notifications/NotificationId.kt +++ b/core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/notifications/NotificationId.kt @@ -45,10 +45,12 @@ enum class NotificationId( // Pump — general EXTENDED_BOLUS_DISABLED(IMPORTANT, PUMP), PUMP_ERROR(URGENT, PUMP), + // Equil low-battery alarm. MUST stay on its own id (historically it mis-used FAILED_UPDATE_PROFILE): the // unified profile-set logic may dismiss FAILED_UPDATE_PROFILE on a successful write, which would otherwise // silently clear a live Equil battery alarm. EQUIL_LOW_BATTERY(URGENT, PUMP), + // A user/remote (non-SMB) bolus failed to deliver — surfaced once, here, from the executor (the entry // dialog is gone by the time the async result arrives). SMB failures stay silent (the loop self-corrects). BOLUS_DELIVERY_FAILED(URGENT, PUMP), @@ -68,6 +70,7 @@ enum class NotificationId( BLUETOOTH_NOT_ENABLED(INFO, PUMP), PATCH_NOT_ACTIVE(NORMAL, PUMP), PUMP_SETTINGS_FAILED(NORMAL, PUMP), + // Pump clock / time-zone update failed (Medtrum, Omnipod Eros). MUST stay on its own id (Eros historically // mis-used FAILED_UPDATE_PROFILE): the unified profile-set logic dismisses FAILED_UPDATE_PROFILE on a successful // write, which would otherwise silently clear a live time-update-failed card (and vice-versa). @@ -111,6 +114,7 @@ enum class NotificationId( // Pump — Dana DANA_PUMP_ALARM(URGENT, PUMP), + // "Bolus block" enabled in pump settings - blocks all bolus delivery (wrong configuration for AAPS) DANA_BOLUS_BLOCK(URGENT, PUMP), diff --git a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/notifications/NotificationLevel.kt b/core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/notifications/NotificationLevel.kt similarity index 100% rename from core/interfaces/src/main/kotlin/app/aaps/core/interfaces/notifications/NotificationLevel.kt rename to core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/notifications/NotificationLevel.kt diff --git a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/notifications/NotificationManager.kt b/core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/notifications/NotificationManager.kt similarity index 74% rename from core/interfaces/src/main/kotlin/app/aaps/core/interfaces/notifications/NotificationManager.kt rename to core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/notifications/NotificationManager.kt index 9ca9f100e1cc..0c11c0d4c0c5 100644 --- a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/notifications/NotificationManager.kt +++ b/core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/notifications/NotificationManager.kt @@ -1,8 +1,8 @@ package app.aaps.core.interfaces.notifications -import androidx.annotation.RawRes -import androidx.annotation.StringRes +import app.aaps.core.keys.interfaces.TextRef import kotlinx.coroutines.flow.StateFlow +import kotlin.time.Clock interface NotificationManager { @@ -16,7 +16,7 @@ interface NotificationManager { text: String, level: NotificationLevel = id.defaultLevel, validMinutes: Int = 0, - @RawRes soundRes: Int? = null, + sound: AlarmSound? = null, actions: List = emptyList(), validityCheck: (() -> Boolean)? = null ): NotificationHandle @@ -25,22 +25,28 @@ interface NotificationManager { id: NotificationId, text: String, level: NotificationLevel = id.defaultLevel, - date: Long = System.currentTimeMillis(), + date: Long = Clock.System.now().toEpochMilliseconds(), validTo: Long = 0L, - @RawRes soundRes: Int? = null, + sound: AlarmSound? = null, actions: List = emptyList(), validityCheck: (() -> Boolean)? = null ): NotificationHandle + /** + * Posts a notification whose text is resolved when it is shown. + * + * Android callers keep writing `post(id, R.string.x, arg)`: that goes through the extension in + * `NotificationManagerAndroid`, which wraps the id in a [TextRef.AndroidRes]. Keeping the id out of + * this declaration is what lets the interface be platform neutral. + */ fun post( id: NotificationId, - @StringRes textRes: Int, - vararg formatArgs: Any?, + textRef: TextRef, level: NotificationLevel = id.defaultLevel, validMinutes: Int = 0, - date: Long = System.currentTimeMillis(), + date: Long = Clock.System.now().toEpochMilliseconds(), validTo: Long = 0L, - @RawRes soundRes: Int? = null, + sound: AlarmSound? = null, actions: List = emptyList(), validityCheck: (() -> Boolean)? = null ): NotificationHandle diff --git a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/nsclient/NSAlarm.kt b/core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/nsclient/NSAlarm.kt similarity index 100% rename from core/interfaces/src/main/kotlin/app/aaps/core/interfaces/nsclient/NSAlarm.kt rename to core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/nsclient/NSAlarm.kt diff --git a/core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/nsclient/NSClientLog.kt b/core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/nsclient/NSClientLog.kt new file mode 100644 index 000000000000..6e76dca6b6be --- /dev/null +++ b/core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/nsclient/NSClientLog.kt @@ -0,0 +1,25 @@ +package app.aaps.core.interfaces.nsclient + +import kotlinx.serialization.json.JsonElement +import kotlin.concurrent.atomics.AtomicLong +import kotlin.concurrent.atomics.ExperimentalAtomicApi +import kotlin.concurrent.atomics.fetchAndIncrement +import kotlin.time.Clock + +@OptIn(ExperimentalAtomicApi::class) +class NSClientLog( + val action: String, + val logText: String? = null, + val json: JsonElement? = null +) { + + val date: Long = Clock.System.now().toEpochMilliseconds() + val id: Long = idCounter.fetchAndIncrement() + + companion object { + + // kotlin.concurrent.atomics rather than java.util.concurrent: same semantics, but it exists on + // every target, so this class can move to commonMain. + private val idCounter = AtomicLong(0) + } +} diff --git a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/nsclient/NSClientRepository.kt b/core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/nsclient/NSClientRepository.kt similarity index 78% rename from core/interfaces/src/main/kotlin/app/aaps/core/interfaces/nsclient/NSClientRepository.kt rename to core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/nsclient/NSClientRepository.kt index 7911d25d1cc7..996498605567 100644 --- a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/nsclient/NSClientRepository.kt +++ b/core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/nsclient/NSClientRepository.kt @@ -1,10 +1,7 @@ package app.aaps.core.interfaces.nsclient import kotlinx.coroutines.flow.StateFlow -import kotlinx.serialization.json.Json import kotlinx.serialization.json.JsonElement -import kotlinx.serialization.json.JsonObject -import org.json.JSONObject /** * Repository interface for NSClient UI state management. @@ -46,12 +43,6 @@ interface NSClientRepository { addLog(action, logText, null as JsonElement?) } - /** Add a new log entry with JSONObject payload */ - fun addLog(action: String, logText: String?, json: JSONObject) { - val jsonObject = json.let { Json.parseToJsonElement(it.toString()) as JsonObject } - addLog(action, logText, jsonObject) - } - /** Clear all log entries */ fun clearLog() } diff --git a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/nsclient/ProcessedDeviceStatusData.kt b/core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/nsclient/ProcessedDeviceStatusData.kt similarity index 100% rename from core/interfaces/src/main/kotlin/app/aaps/core/interfaces/nsclient/ProcessedDeviceStatusData.kt rename to core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/nsclient/ProcessedDeviceStatusData.kt diff --git a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/nsclient/StoreDataForDb.kt b/core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/nsclient/StoreDataForDb.kt similarity index 100% rename from core/interfaces/src/main/kotlin/app/aaps/core/interfaces/nsclient/StoreDataForDb.kt rename to core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/nsclient/StoreDataForDb.kt diff --git a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/overview/LastBgData.kt b/core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/overview/LastBgData.kt similarity index 100% rename from core/interfaces/src/main/kotlin/app/aaps/core/interfaces/overview/LastBgData.kt rename to core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/overview/LastBgData.kt diff --git a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/overview/OverviewData.kt b/core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/overview/OverviewData.kt similarity index 100% rename from core/interfaces/src/main/kotlin/app/aaps/core/interfaces/overview/OverviewData.kt rename to core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/overview/OverviewData.kt diff --git a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/overview/graph/AapsClientStatusData.kt b/core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/overview/graph/AapsClientStatusData.kt similarity index 100% rename from core/interfaces/src/main/kotlin/app/aaps/core/interfaces/overview/graph/AapsClientStatusData.kt rename to core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/overview/graph/AapsClientStatusData.kt diff --git a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/overview/graph/CalculationResults.kt b/core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/overview/graph/CalculationResults.kt similarity index 99% rename from core/interfaces/src/main/kotlin/app/aaps/core/interfaces/overview/graph/CalculationResults.kt rename to core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/overview/graph/CalculationResults.kt index 8095eaa91080..22b526db5666 100644 --- a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/overview/graph/CalculationResults.kt +++ b/core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/overview/graph/CalculationResults.kt @@ -233,6 +233,7 @@ data class TargetLineData( * Bolus type for rendering (different shapes/colors) */ enum class BolusType { + NORMAL, // Regular bolus (triangle shape) SMB // Super micro bolus (small diamond) } @@ -273,6 +274,7 @@ data class ExtendedBolusGraphPoint( * Therapy event type for rendering (different shapes/colors) */ enum class TherapyEventType { + MBG, // Manual blood glucose FINGER_STICK, // Finger stick BG check ANNOUNCEMENT, // Announcement diff --git a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/overview/graph/GraphConfiguration.kt b/core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/overview/graph/GraphConfiguration.kt similarity index 97% rename from core/interfaces/src/main/kotlin/app/aaps/core/interfaces/overview/graph/GraphConfiguration.kt rename to core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/overview/graph/GraphConfiguration.kt index 99c491dbf53c..21397c414646 100644 --- a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/overview/graph/GraphConfiguration.kt +++ b/core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/overview/graph/GraphConfiguration.kt @@ -1,5 +1,6 @@ package app.aaps.core.interfaces.overview.graph +import app.aaps.core.interfaces.overview.graph.GraphConfig.Companion.MAX_GRAPH_HEIGHT_DP import kotlinx.coroutines.flow.StateFlow /** diff --git a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/overview/graph/OverviewDataCache.kt b/core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/overview/graph/OverviewDataCache.kt similarity index 100% rename from core/interfaces/src/main/kotlin/app/aaps/core/interfaces/overview/graph/OverviewDataCache.kt rename to core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/overview/graph/OverviewDataCache.kt diff --git a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/plugin/ActivePlugin.kt b/core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/plugin/ActivePlugin.kt similarity index 87% rename from core/interfaces/src/main/kotlin/app/aaps/core/interfaces/plugin/ActivePlugin.kt rename to core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/plugin/ActivePlugin.kt index ce0ba77369a6..74b079f3d990 100644 --- a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/plugin/ActivePlugin.kt +++ b/core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/plugin/ActivePlugin.kt @@ -1,6 +1,5 @@ package app.aaps.core.interfaces.plugin -import android.content.Context import app.aaps.core.data.plugin.PluginType import app.aaps.core.interfaces.aps.APS import app.aaps.core.interfaces.aps.Sensitivity @@ -13,6 +12,7 @@ import app.aaps.core.interfaces.pump.PumpWithConcentration import app.aaps.core.interfaces.smoothing.Smoothing import app.aaps.core.interfaces.source.BgSource import app.aaps.core.interfaces.sync.Sync +import kotlin.reflect.KClass interface ActivePlugin { @@ -94,7 +94,7 @@ interface ActivePlugin { /** * List of all plugins implementing interface */ - fun getSpecificPluginsListByInterface(interfaceClass: Class<*>): ArrayList + fun getSpecificPluginsListByInterface(interfaceClass: KClass<*>): ArrayList /** * Pre-process all plugin types and validate active plugins (ie. only only one plugin for type is selected) @@ -134,16 +134,4 @@ interface ActivePlugin { * See: [app.aaps.core.interfaces.maintenance.ImportExportPrefs.doImportSharedPreferences] */ fun afterImport() - - /** - * Collects missing permissions across all enabled plugins, deduplicated by permission set. - */ - fun collectMissingPermissions(context: Context): List - - /** - * Collects all required permissions (both global and plugin-declared), - * regardless of grant status. Used by the permission UI to show both - * granted and missing permissions. - */ - fun collectAllPermissions(context: Context): List } \ No newline at end of file diff --git a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/plugin/OwnDatabasePlugin.kt b/core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/plugin/OwnDatabasePlugin.kt similarity index 100% rename from core/interfaces/src/main/kotlin/app/aaps/core/interfaces/plugin/OwnDatabasePlugin.kt rename to core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/plugin/OwnDatabasePlugin.kt diff --git a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/plugin/PermissionGroup.kt b/core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/plugin/PermissionGroup.kt similarity index 89% rename from core/interfaces/src/main/kotlin/app/aaps/core/interfaces/plugin/PermissionGroup.kt rename to core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/plugin/PermissionGroup.kt index 9a9d58a36668..5eba9a88712a 100644 --- a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/plugin/PermissionGroup.kt +++ b/core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/plugin/PermissionGroup.kt @@ -1,6 +1,6 @@ package app.aaps.core.interfaces.plugin -import androidx.annotation.StringRes +import app.aaps.core.keys.interfaces.TextRef /** * Declares a group of related runtime permissions that a plugin requires. @@ -17,8 +17,8 @@ import androidx.annotation.StringRes */ data class PermissionGroup( val permissions: List, - @StringRes val rationaleTitle: Int, - @StringRes val rationaleDescription: Int, + val rationaleTitle: TextRef, + val rationaleDescription: TextRef, val special: Boolean = false, val alwaysShowAction: Boolean = false, ) diff --git a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/plugin/PermissionProvider.kt b/core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/plugin/PermissionProvider.kt similarity index 100% rename from core/interfaces/src/main/kotlin/app/aaps/core/interfaces/plugin/PermissionProvider.kt rename to core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/plugin/PermissionProvider.kt diff --git a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/plugin/PluginBase.kt b/core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/plugin/PluginBase.kt similarity index 84% rename from core/interfaces/src/main/kotlin/app/aaps/core/interfaces/plugin/PluginBase.kt rename to core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/plugin/PluginBase.kt index fca8ec43ba0a..5c55dd4a835f 100644 --- a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/plugin/PluginBase.kt +++ b/core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/plugin/PluginBase.kt @@ -1,19 +1,15 @@ package app.aaps.core.interfaces.plugin -import android.content.Context -import android.content.pm.PackageManager -import androidx.core.content.ContextCompat import app.aaps.core.data.plugin.PluginType 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.resources.TextResolver import app.aaps.core.keys.interfaces.PreferenceItem import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking -import org.jetbrains.annotations.TestOnly /** * Created by mike on 09.06.2016. @@ -21,7 +17,7 @@ import org.jetbrains.annotations.TestOnly abstract class PluginBase( val pluginDescription: PluginDescription, val aapsLogger: AAPSLogger, - val rh: ResourceHelper + open val rh: TextResolver ) { protected val pluginScope = CoroutineScope(Dispatchers.Default + Job()) @@ -33,27 +29,30 @@ abstract class PluginBase( private var state = State.NOT_INITIALIZED open val name: String - get() = if (pluginDescription.pluginName == -1) "UNKNOWN" else rh.gs(pluginDescription.pluginName) + get() = pluginDescription.pluginName?.let { rh.gs(it) } ?: "UNKNOWN" /** * Stable identity for syncing the active-plugin selection (see the `ActivePlugin*` keys). Defaults to * the class simple name — matching the legacy `RunningConfiguration` encoding, so dual-write stays * consistent. Override to decouple from the class name (survive rename / R8) when a durable id is needed. */ - open val pluginId: String get() = javaClass.simpleName + // this::class.simpleName rather than javaClass.simpleName, so this file is not tied to the JVM. It + // gives the same string: a plugin is always a named class. simpleName is only null for an anonymous + // one, which would be a bug worth failing on rather than syncing an empty id. + open val pluginId: String get() = this::class.simpleName!! //only if translation exists // use long name as fallback val nameShort: String get() { - if (pluginDescription.shortName == -1) return name - val translatedName = rh.gs(pluginDescription.shortName) + val shortNameRef = pluginDescription.shortName ?: return name + val translatedName = rh.gs(shortNameRef) return if (translatedName.trim { it <= ' ' }.isNotEmpty()) translatedName else name // use long name as fallback } val description: String? - get() = if (pluginDescription.description == -1) null else rh.gs(pluginDescription.description) + get() = pluginDescription.description?.let { rh.gs(it) } fun getType(): PluginType = pluginDescription.mainType @@ -121,7 +120,6 @@ abstract class PluginBase( * Version of setPluginEnabled used for testing only. * OnStart/OnStop is called directly. */ - @TestOnly fun setPluginEnabledBlocking(type: PluginType, newState: Boolean) { if (type == pluginDescription.mainType) { if (newState) { // enabling plugin @@ -174,15 +172,4 @@ abstract class PluginBase( * Override in subclasses to declare permissions. */ open fun requiredPermissions(): List = emptyList() - - /** - * Returns [requiredPermissions] that are not yet granted. - * Special permission groups are excluded — they need dedicated checks. - */ - fun missingPermissions(context: Context): List = - requiredPermissions().filter { group -> - !group.special && group.permissions.any { permission -> - ContextCompat.checkSelfPermission(context, permission) != PackageManager.PERMISSION_GRANTED - } - } } \ No newline at end of file diff --git a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/plugin/PluginBaseWithPreferences.kt b/core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/plugin/PluginBaseWithPreferences.kt similarity index 88% rename from core/interfaces/src/main/kotlin/app/aaps/core/interfaces/plugin/PluginBaseWithPreferences.kt rename to core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/plugin/PluginBaseWithPreferences.kt index 141e2db945ea..8c6028d30a91 100644 --- a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/plugin/PluginBaseWithPreferences.kt +++ b/core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/plugin/PluginBaseWithPreferences.kt @@ -1,7 +1,7 @@ package app.aaps.core.interfaces.plugin import app.aaps.core.interfaces.logging.AAPSLogger -import app.aaps.core.interfaces.resources.ResourceHelper +import app.aaps.core.interfaces.resources.TextResolver import app.aaps.core.keys.interfaces.NonPreferenceKey import app.aaps.core.keys.interfaces.Preferences @@ -10,14 +10,14 @@ import app.aaps.core.keys.interfaces.Preferences */ abstract class PluginBaseWithPreferences( pluginDescription: PluginDescription, - val ownPreferences: List> = emptyList(), + val ownPreferences: List = emptyList(), aapsLogger: AAPSLogger, - rh: ResourceHelper, + rh: TextResolver, val preferences: Preferences ) : PluginBase(pluginDescription, aapsLogger, rh) { init { - ownPreferences.forEach { preferences.registerPreferences(it) } + preferences.registerPreferences(ownPreferences) } /** diff --git a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/plugin/PluginDescription.kt b/core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/plugin/PluginDescription.kt similarity index 80% rename from core/interfaces/src/main/kotlin/app/aaps/core/interfaces/plugin/PluginDescription.kt rename to core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/plugin/PluginDescription.kt index fc221370e929..f88972065ba1 100644 --- a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/plugin/PluginDescription.kt +++ b/core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/plugin/PluginDescription.kt @@ -2,6 +2,7 @@ package app.aaps.core.interfaces.plugin import androidx.compose.ui.graphics.vector.ImageVector import app.aaps.core.data.plugin.PluginType +import app.aaps.core.keys.interfaces.TextRef open class PluginDescription { @@ -20,9 +21,9 @@ open class PluginDescription { var neverVisible = false var alwaysEnabled = false var showInList = { true } - var pluginName = -1 - var shortName = -1 - var description = -1 + var pluginName: TextRef? = null + var shortName: TextRef? = null + var description: TextRef? = null var enableByDefault = false var defaultPlugin = false @@ -35,10 +36,10 @@ open class PluginDescription { fun showInList(showInList: () -> Boolean): PluginDescription = this.also { it.showInList = showInList } fun icon(icon: ImageVector): PluginDescription = this.also { it.icon = icon } - fun pluginName(pluginName: Int): PluginDescription = this.also { it.pluginName = pluginName } - fun shortName(shortName: Int): PluginDescription = this.also { it.shortName = shortName } + fun pluginName(pluginName: TextRef): PluginDescription = this.also { it.pluginName = pluginName } + fun shortName(shortName: TextRef): PluginDescription = this.also { it.shortName = shortName } fun enableByDefault(enableByDefault: Boolean): PluginDescription = this.also { it.enableByDefault = enableByDefault } - fun description(description: Int): PluginDescription = this.also { it.description = description } + fun description(description: TextRef): PluginDescription = this.also { it.description = description } fun setDefault(value: Boolean = true): PluginDescription = this.also { it.defaultPlugin = value } fun preferencesVisibleInSimpleMode(value: Boolean): PluginDescription = this.also { it.preferencesVisibleInSimpleMode = value } fun composeContent(provider: (PluginBase) -> Any): PluginDescription = this.also { it.composeContentProvider = provider } diff --git a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/profile/EffectiveProfile.kt b/core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/profile/EffectiveProfile.kt similarity index 99% rename from core/interfaces/src/main/kotlin/app/aaps/core/interfaces/profile/EffectiveProfile.kt rename to core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/profile/EffectiveProfile.kt index fe6abd7a902c..3829a44dba1c 100644 --- a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/profile/EffectiveProfile.kt +++ b/core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/profile/EffectiveProfile.kt @@ -11,7 +11,6 @@ interface EffectiveProfile : Profile { /** Applied insulin configuration */ override val iCfg: ICfg - /** * Convert EffectiveProfile to Concentrated using iCfg.concentration value * diff --git a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/profile/Profile.kt b/core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/profile/Profile.kt similarity index 88% rename from core/interfaces/src/main/kotlin/app/aaps/core/interfaces/profile/Profile.kt rename to core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/profile/Profile.kt index b225366aa70b..130d3f627381 100644 --- a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/profile/Profile.kt +++ b/core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/profile/Profile.kt @@ -6,11 +6,11 @@ import app.aaps.core.interfaces.configuration.Config import app.aaps.core.interfaces.notifications.NotificationManager import app.aaps.core.interfaces.nsclient.ProcessedDeviceStatusData import app.aaps.core.interfaces.pump.Pump -import app.aaps.core.interfaces.resources.ResourceHelper +import app.aaps.core.interfaces.resources.TextResolver import app.aaps.core.interfaces.utils.DateUtil import app.aaps.core.interfaces.utils.HardLimits import app.aaps.core.interfaces.utils.Round -import org.json.JSONObject +import kotlinx.serialization.json.JsonObject interface Profile { @@ -24,7 +24,7 @@ interface Profile { /** * Check validity of profile */ - fun isValid(from: String, pump: Pump, config: Config, rh: ResourceHelper, notificationManager: NotificationManager, hardLimits: HardLimits, sendNotifications: Boolean): ValidityCheck + fun isValid(from: String, pump: Pump, config: Config, rh: TextResolver, notificationManager: NotificationManager, hardLimits: HardLimits, sendNotifications: Boolean): ValidityCheck /** * Units used for ISF & target @@ -127,13 +127,13 @@ interface Profile { */ fun getTargetHighMgdlTimeFromMidnight(timeAsSeconds: Int): Double - fun getIcList(rh: ResourceHelper, dateUtil: DateUtil): String - fun getIsfList(rh: ResourceHelper, dateUtil: DateUtil): String - fun getBasalList(rh: ResourceHelper, dateUtil: DateUtil): String - fun getTargetList(rh: ResourceHelper, dateUtil: DateUtil): String + fun getIcList(rh: TextResolver, dateUtil: DateUtil): String + fun getIsfList(rh: TextResolver, dateUtil: DateUtil): String + fun getBasalList(rh: TextResolver, dateUtil: DateUtil): String + fun getTargetList(rh: TextResolver, dateUtil: DateUtil): String fun convertToNonCustomizedProfile(dateUtil: DateUtil): PureProfile - fun toPureNsJson(dateUtil: DateUtil): JSONObject + fun toPureNsJson(dateUtil: DateUtil): JsonObject fun getMaxDailyBasal(): Double fun baseBasalSum(): Double fun percentageBasalSum(): Double diff --git a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/profile/ProfileFunction.kt b/core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/profile/ProfileFunction.kt similarity index 100% rename from core/interfaces/src/main/kotlin/app/aaps/core/interfaces/profile/ProfileFunction.kt rename to core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/profile/ProfileFunction.kt diff --git a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/profile/ProfileRepository.kt b/core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/profile/ProfileRepository.kt similarity index 86% rename from core/interfaces/src/main/kotlin/app/aaps/core/interfaces/profile/ProfileRepository.kt rename to core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/profile/ProfileRepository.kt index b2bcf17cfbd1..ebf5daf0fd33 100644 --- a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/profile/ProfileRepository.kt +++ b/core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/profile/ProfileRepository.kt @@ -21,11 +21,12 @@ interface ProfileRepository { * The current profile list. Emits whenever profiles are added, removed, cloned, * or replaced (e.g. by an NS push via [loadFromNs]). * - * **Mutability contract:** [SingleProfile] is a mutable holder (its `JSONArray` fields - * are mutable, and the list spine is a snapshot but the elements are shared). Callers - * that intend to edit a profile must [SingleProfile.deepClone] first; mutating an - * element from this flow corrupts repository state and will leak across other - * collectors. To commit an edited clone back, call [replace]. + * [SingleProfile] is immutable, so a profile taken from here can be held, compared and passed + * around freely. Edit with `copy()` and commit through [replace]. + * + * Because the elements compare structurally, a mutation that produces an identical list is + * **not** re-emitted. Collectors that must react to every mutation, not only to every change of + * content, should watch [revision] instead. */ val profiles: StateFlow> @@ -39,6 +40,19 @@ interface ProfileRepository { */ val profile: StateFlow + /** + * Counts mutations. Bumped once per completed mutation, after [profiles] and [profile] already + * hold the new state, so a collector here can read either `.value` and get the matching (or a + * newer) snapshot. + * + * This exists because [profiles] deduplicates: [SingleProfile] compares structurally, so + * re-storing an identical list produces no emit at all. Some actions are about the *event*, not + * the content — [reset] must reload the editor's working copy even when the stored profile turned + * out to be byte-identical to what the user was editing. Watching this instead of [profiles] + * keeps those working without giving up deduplication for everyone else. + */ + val revision: StateFlow + /** * Clone the profile at [index]. The clone is appended with " copy" suffixed to its name. * @@ -147,7 +161,7 @@ interface ProfileRepository { /** * Replace the profile at [index] with [profile] and persist. Used by the editor to commit - * a snapshot-edited profile back to the store. + * an edited profile back to the store. * * @return [Result.success] if the replacement was persisted, or [Result.failure] with * [IndexOutOfBoundsException] if [index] is no longer valid (e.g. profiles were diff --git a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/profile/ProfileStore.kt b/core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/profile/ProfileStore.kt similarity index 66% rename from core/interfaces/src/main/kotlin/app/aaps/core/interfaces/profile/ProfileStore.kt rename to core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/profile/ProfileStore.kt index 236df2b382fe..f0db70373db5 100644 --- a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/profile/ProfileStore.kt +++ b/core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/profile/ProfileStore.kt @@ -1,15 +1,15 @@ package app.aaps.core.interfaces.profile -import org.json.JSONObject +import kotlinx.serialization.json.JsonObject interface ProfileStore { - fun with(data: JSONObject): ProfileStore + fun with(data: JsonObject): ProfileStore - fun getData(): JSONObject + fun getData(): JsonObject fun getStartDate(): Long fun getDefaultProfile(): PureProfile? - fun getDefaultProfileJson(): JSONObject? + fun getDefaultProfileJson(): JsonObject? fun getDefaultProfileName(): String? fun getProfileList(): ArrayList fun getSpecificProfile(profileName: String): PureProfile? diff --git a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/profile/ProfileUtil.kt b/core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/profile/ProfileUtil.kt similarity index 100% rename from core/interfaces/src/main/kotlin/app/aaps/core/interfaces/profile/ProfileUtil.kt rename to core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/profile/ProfileUtil.kt diff --git a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/profile/ProfileValidationError.kt b/core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/profile/ProfileValidationError.kt similarity index 100% rename from core/interfaces/src/main/kotlin/app/aaps/core/interfaces/profile/ProfileValidationError.kt rename to core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/profile/ProfileValidationError.kt diff --git a/core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/profile/PureProfile.kt b/core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/profile/PureProfile.kt new file mode 100644 index 000000000000..0cf9ed550bdc --- /dev/null +++ b/core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/profile/PureProfile.kt @@ -0,0 +1,37 @@ +package app.aaps.core.interfaces.profile + +import app.aaps.core.data.model.GlucoseUnit +import app.aaps.core.data.model.ICfg +import app.aaps.core.data.model.data.Block +import app.aaps.core.data.model.data.TargetBlock + +/** + * Pure profile like it's entered by user. Contains only data. + * + * It used to carry the source JSON alongside the blocks. Nothing read it — every consumer worked + * from the blocks — while every construction paid to build it, so it is gone. Render on demand with + * `Profile.toPureNsJson` when JSON is actually needed. + */ +class PureProfile( + var basalBlocks: List, + var isfBlocks: List, + var icBlocks: List, + var targetBlocks: List, + var iCfg: ICfg? = null, + var glucoseUnit: GlucoseUnit, + /** + * Offset from UTC in milliseconds, **for the moment the profile was built**. + * + * This was a whole `java.util.TimeZone`, but the only thing ever read off it was + * `rawOffset` - the zone's *standard* offset, which ignores daylight saving by definition. So + * `Europe/Prague` gave +01:00 even in July. The single consumer turns the number back into a zone + * name for the Nightscout profile document by looking for a zone at that offset **now**, and in + * July no European zone is at +01:00, so the document named an unrelated zone instead. + * + * Holding the resolved offset makes that impossible: the value is DST aware at the point it is + * taken, matching `utcOffset` on every other record and [app.aaps.core.data.time.systemUtcOffsetAt]. + * Nothing is lost that was not already lost - the zone's identity went away the moment `rawOffset` + * reduced it to a number. + */ + var utcOffset: Long +) \ No newline at end of file diff --git a/core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/profile/SingleProfile.kt b/core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/profile/SingleProfile.kt new file mode 100644 index 000000000000..28f45919267c --- /dev/null +++ b/core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/profile/SingleProfile.kt @@ -0,0 +1,27 @@ +package app.aaps.core.interfaces.profile + +import app.aaps.core.data.model.data.Block +import app.aaps.core.data.model.data.TargetBlock + +/** + * One entry in the local profile list. + * + * Pairs a [name] with profile data ([ic], [isf], [basal], [target]) and the glucose unit flag + * ([mgdl]). The schedules are plain Kotlin data: JSON exists only at the edges — the stored + * preference document and the Nightscout wire format — never in memory. + * + * Immutable and structurally comparable. Edit with [copy]; there is nothing to clone defensively, + * so a profile handed out by [ProfileRepository] can be held and compared freely. + * + * Low and high targets live in ONE [TargetBlock] list rather than two parallel ones. The two JSON + * arrays must always have matching times and lengths, and pairing them here makes that a property of + * the type instead of something each reader has to re-check. + */ +data class SingleProfile( + val name: String, + val mgdl: Boolean, + val ic: List, + val isf: List, + val basal: List, + val target: List +) diff --git a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/profiling/Profiler.kt b/core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/profiling/Profiler.kt similarity index 100% rename from core/interfaces/src/main/kotlin/app/aaps/core/interfaces/profiling/Profiler.kt rename to core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/profiling/Profiler.kt diff --git a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/protection/ExportPasswordDataStore.kt b/core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/protection/ExportPasswordDataStore.kt similarity index 59% rename from core/interfaces/src/main/kotlin/app/aaps/core/interfaces/protection/ExportPasswordDataStore.kt rename to core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/protection/ExportPasswordDataStore.kt index 375a8a509998..6441a1f0c0e4 100644 --- a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/protection/ExportPasswordDataStore.kt +++ b/core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/protection/ExportPasswordDataStore.kt @@ -1,29 +1,27 @@ package app.aaps.core.interfaces.protection -import android.content.Context - interface ExportPasswordDataStore { /*** * Check Export password functionality * Returns true when Export password store is enabled. */ - fun exportPasswordStoreEnabled() : Boolean + fun exportPasswordStoreEnabled(): Boolean /*** * Clear password currently stored. */ - fun clearPasswordDataStore(context: Context): String + fun clearPasswordDataStore(): String /*** * Put password to local phone's datastore. */ - fun putPasswordToDataStore(context: Context, password: String): String + fun putPasswordToDataStore(password: String): String /*** * Get password from local phone's data store. * Return pair (true,) or (false,"") */ - fun getPasswordFromDataStore(context: Context): Triple + fun getPasswordFromDataStore(): Triple - } \ No newline at end of file +} diff --git a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/protection/PasswordCheck.kt b/core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/protection/PasswordCheck.kt similarity index 54% rename from core/interfaces/src/main/kotlin/app/aaps/core/interfaces/protection/PasswordCheck.kt rename to core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/protection/PasswordCheck.kt index a52a81de711c..e92643e45034 100644 --- a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/protection/PasswordCheck.kt +++ b/core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/protection/PasswordCheck.kt @@ -1,17 +1,26 @@ package app.aaps.core.interfaces.protection -import android.content.Context -import androidx.annotation.StringRes import app.aaps.core.keys.interfaces.StringPreferenceKey +import app.aaps.core.keys.interfaces.TextRef +import kotlinx.coroutines.flow.StateFlow +/** + * Asks the user for a password, and decides whether the answer was right. + * + * No `Context` and no resource ids: the prompt is published as a [PasswordRequest] on [request] and + * drawn by `PasswordCheckHost`, which the UI places once near its root. See [PasswordRequest] for + * why that indirection exists. + */ interface PasswordCheck { + /** The prompt to show, or null when nothing is being asked. */ + val request: StateFlow + /** * Asks for "managed" kind of password, checking if it is valid. */ fun queryPassword( - context: Context, - @StringRes labelId: Int, + label: TextRef, preference: StringPreferenceKey, ok: ((String) -> Unit)?, cancel: (() -> Unit)? = null, @@ -20,8 +29,7 @@ interface PasswordCheck { ) fun setPassword( - context: Context, - @StringRes labelId: Int, + label: TextRef, preference: StringPreferenceKey, ok: ((String) -> Unit)? = null, cancel: (() -> Unit)? = null, @@ -35,8 +43,11 @@ interface PasswordCheck { * since this query does NOT check validity of password. */ fun queryAnyPassword( - context: Context, @StringRes labelId: Int, preference: StringPreferenceKey, @StringRes passwordExplanation: Int?, - @StringRes passwordWarning: Int?, ok: ((String) -> Unit)?, cancel: (() -> Unit)? = null + label: TextRef, + preference: StringPreferenceKey, + passwordExplanation: TextRef?, + passwordWarning: TextRef?, + ok: ((String) -> Unit)?, + cancel: (() -> Unit)? = null ) - -} \ No newline at end of file +} diff --git a/core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/protection/PasswordRequest.kt b/core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/protection/PasswordRequest.kt new file mode 100644 index 000000000000..2ed388ce8245 --- /dev/null +++ b/core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/protection/PasswordRequest.kt @@ -0,0 +1,63 @@ +package app.aaps.core.interfaces.protection + +import app.aaps.core.keys.interfaces.TextRef + +/** + * A password prompt waiting to be shown. + * + * [PasswordCheck] does not present anything itself. It publishes one of these and the UI - which + * hosts `PasswordCheckHost` once, near its root - renders the matching dialog. That indirection is + * what removes the Android `Context` from the contract: the old implementation built an + * `android.app.Dialog` around a `ComposeView`, which needed an Activity to hang a window on and a + * hand written lifecycle owner to satisfy the view tree. Rendering inside the composition that is + * already running needs neither, and works the same on every platform. + * + * The callbacks carry the whole outcome, so the host stays dumb: it collects passwords and reports + * them back, and every decision about whether a password is CORRECT stays in the implementation. + */ +sealed interface PasswordRequest { + + /** Title of the prompt. */ + val label: TextRef + + /** Invoked when the user backs out. The implementation turns this into the caller's `cancel`. */ + val onCancel: () -> Unit + + /** + * Ask for an existing password and check it. + * + * [onConfirm] receives what the user typed; the implementation compares it and decides whether + * that means `ok` or `fail`, including counting attempts. + */ + data class Query( + override val label: TextRef, + val pinInput: Boolean, + val onConfirm: (String) -> Unit, + override val onCancel: () -> Unit + ) : PasswordRequest + + /** + * Set or clear a password. + * + * [onConfirm] receives both entries so the implementation can reject a mismatch, and an empty + * password means "clear it". + */ + data class Set( + override val label: TextRef, + val pinInput: Boolean, + val onConfirm: (String, String) -> Unit, + override val onCancel: () -> Unit + ) : PasswordRequest + + /** + * Ask for a free-form password that is NOT checked against anything - used when the password is + * about to encrypt something rather than unlock it. + */ + data class QueryAny( + override val label: TextRef, + val explanation: TextRef?, + val warning: TextRef?, + val onConfirm: (String) -> Unit, + override val onCancel: () -> Unit + ) : PasswordRequest +} diff --git a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/protection/ProtectionCheck.kt b/core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/protection/ProtectionCheck.kt similarity index 98% rename from core/interfaces/src/main/kotlin/app/aaps/core/interfaces/protection/ProtectionCheck.kt rename to core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/protection/ProtectionCheck.kt index a690a51a9492..cc08491850f6 100644 --- a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/protection/ProtectionCheck.kt +++ b/core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/protection/ProtectionCheck.kt @@ -1,6 +1,6 @@ package app.aaps.core.interfaces.protection -import androidx.annotation.StringRes +import app.aaps.core.keys.interfaces.TextRef import kotlinx.coroutines.flow.StateFlow /** @@ -61,7 +61,7 @@ data class ProtectionRequest( val id: Long, val protection: ProtectionCheck.Protection, val type: ProtectionType, - @StringRes val titleRes: Int, + val title: TextRef, val onResult: (ProtectionResult) -> Unit ) diff --git a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/protection/SecureEncrypt.kt b/core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/protection/SecureEncrypt.kt similarity index 100% rename from core/interfaces/src/main/kotlin/app/aaps/core/interfaces/protection/SecureEncrypt.kt rename to core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/protection/SecureEncrypt.kt diff --git a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/pump/BlePreCheckResult.kt b/core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/pump/BlePreCheckResult.kt similarity index 100% rename from core/interfaces/src/main/kotlin/app/aaps/core/interfaces/pump/BlePreCheckResult.kt rename to core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/pump/BlePreCheckResult.kt diff --git a/core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/pump/BluetoothPermission.kt b/core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/pump/BluetoothPermission.kt new file mode 100644 index 000000000000..3d539c89fd66 --- /dev/null +++ b/core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/pump/BluetoothPermission.kt @@ -0,0 +1,14 @@ +package app.aaps.core.interfaces.pump + +import app.aaps.core.interfaces.plugin.PermissionGroup + +/** + * The runtime permissions a pump driver needs to talk to hardware over Bluetooth, or null when the + * platform has no such thing. + * + * This is the one part of [PumpPluginBase] that cannot be shared: Android gates BLUETOOTH_CONNECT and + * BLUETOOTH_SCAN behind a runtime request, while iOS declares its Bluetooth use in `Info.plist` and + * has no permission list to hand back. Answering null there is the truthful answer - it is not an + * empty list that happens to work, it is "this platform does not ask". + */ +internal expect fun bluetoothPermissionGroup(): PermissionGroup? diff --git a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/pump/BolusProgressData.kt b/core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/pump/BolusProgressData.kt similarity index 76% rename from core/interfaces/src/main/kotlin/app/aaps/core/interfaces/pump/BolusProgressData.kt rename to core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/pump/BolusProgressData.kt index 24c142070bc7..6e64002663f7 100644 --- a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/pump/BolusProgressData.kt +++ b/core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/pump/BolusProgressData.kt @@ -1,8 +1,10 @@ package app.aaps.core.interfaces.pump -import app.aaps.core.interfaces.di.ApplicationScope +import app.aaps.core.interfaces.InterfacesStrings import app.aaps.core.interfaces.insulin.ConcentrationHelper -import app.aaps.core.interfaces.resources.ResourceHelper +import app.aaps.core.interfaces.pump.BolusProgressData.Companion.AUTO_CLEAR_DELAY_MS +import app.aaps.core.keys.interfaces.TextRef +import app.aaps.core.keys.interfaces.TextRef.Companion.withArgs import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow @@ -10,21 +12,25 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch -import java.util.concurrent.atomic.AtomicLong -import javax.inject.Inject -import javax.inject.Singleton +import kotlin.concurrent.atomics.AtomicLong +import kotlin.concurrent.atomics.ExperimentalAtomicApi +import kotlin.concurrent.atomics.incrementAndFetch /** * Core-controlled bolus progress state. * * Lifecycle is managed by the command queue (start/complete/clear), * pump drivers only report progress via [updateProgress]. + * + * Deliberately carries no DI annotations. `javax.inject` is a JVM library and does not resolve in + * commonMain, so a class meant to be shared cannot be annotated - the Android graph provides it + * instead (`ImplementationModule.provideBolusProgressData`, which keeps the singleton scope and + * supplies the application-scoped CoroutineScope). */ -@Singleton -class BolusProgressData @Inject constructor( +@OptIn(ExperimentalAtomicApi::class) +class BolusProgressData( val ch: ConcentrationHelper, - val rh: ResourceHelper, - @ApplicationScope private val appScope: CoroutineScope, + private val appScope: CoroutineScope, ) { private val _state = MutableStateFlow(null) @@ -35,7 +41,7 @@ class BolusProgressData @Inject constructor( private val generation = AtomicLong(0) /** Snapshot of the current generation, captured by the client watchdog when it arms (see [markStalled]). */ - val currentGeneration: Long get() = generation.get() + val currentGeneration: Long get() = generation.load() /** * Called by CommandQueue before bolus delivery starts. @@ -45,14 +51,14 @@ class BolusProgressData @Inject constructor( * wipe the progress state of a NEWER bolus that has already started (see [clear]). */ fun start(insulin: Double, isSMB: Boolean, isPriming: Boolean = false): Long { - val gen = generation.incrementAndGet() + val gen = generation.incrementAndFetch() _state.value = BolusProgressState( insulin = insulin, isSMB = isSMB, isPriming = isPriming, percent = 0, - status = "", - wearStatus = "", + status = TextRef.Literal(""), + wearStatus = TextRef.Literal(""), delivered = PumpInsulin(0.0), stopPressed = false, stopDeliveryEnabled = true @@ -64,7 +70,7 @@ class BolusProgressData @Inject constructor( * Called by pump drivers to report delivery progress. * Purely informational — does not control dialog lifecycle. */ - fun updateProgress(percent: Int, status: String, delivered: PumpInsulin = PumpInsulin(0.0)) { + fun updateProgress(percent: Int, status: TextRef, delivered: PumpInsulin = PumpInsulin(0.0)) { // A fresh frame proves liveness → clear any prior stall flag (lets the client dialog recover). _state.update { it?.copy(percent = percent, status = status, wearStatus = status, delivered = delivered, stalled = false) } } @@ -77,11 +83,12 @@ class BolusProgressData @Inject constructor( _state.value?.let { state -> val insulin = state.insulin val delivered = if (state.isPriming) PumpInsulin(insulin * percent / 100) - else PumpInsulin(insulin / ch.concentration * percent / 100) - val status = if (percent < 100) ch.bolusProgressString(delivered, state.isPriming) - else rh.gs(app.aaps.core.interfaces.R.string.bolus_delivered_successfully, insulin) - val wearStatus = if (percent < 100) ch.bolusProgressString(delivered, insulin, state.isPriming) - else rh.gs(app.aaps.core.interfaces.R.string.bolus_delivered_successfully, insulin) + else PumpInsulin(insulin / ch.concentration * percent / 100) + val done = InterfacesStrings.bolus_delivered_successfully.withArgs(insulin) + val status = if (percent < 100) TextRef.Literal(ch.bolusProgressString(delivered, state.isPriming)) + else done + val wearStatus = if (percent < 100) TextRef.Literal(ch.bolusProgressString(delivered, insulin, state.isPriming)) + else done _state.update { it?.copy(percent = percent, status = status, wearStatus = wearStatus, delivered = delivered, stalled = false) } } } @@ -94,8 +101,8 @@ class BolusProgressData @Inject constructor( _state.value?.let { state -> val insulin = state.insulin val percent = (ch.fromPump(delivered, state.isPriming) / insulin * 100).toInt().coerceAtMost(100) - val status = ch.bolusProgressString(delivered, state.isPriming) - val wearStatus = ch.bolusProgressString(delivered, insulin, state.isPriming) + val status = TextRef.Literal(ch.bolusProgressString(delivered, state.isPriming)) + val wearStatus = TextRef.Literal(ch.bolusProgressString(delivered, insulin, state.isPriming)) _state.update { it?.copy(percent = percent, status = status, wearStatus = wearStatus, delivered = delivered, stalled = false) } } } @@ -126,7 +133,7 @@ class BolusProgressData @Inject constructor( * mirror, whose relayed frames are timestamp-ordered. A per-command MASTER bolus MUST use the generation-scoped * [completeAndAutoClear] overload instead (same rationale as [clear] vs [clear]). */ - fun completeAndAutoClear() = completeAndAutoClear(generation.get()) + fun completeAndAutoClear() = completeAndAutoClear(generation.load()) /** * Generation-scoped completion for a single master bolus command (mirror of [clear]). Guards BOTH the immediate @@ -137,11 +144,11 @@ class BolusProgressData @Inject constructor( */ fun completeAndAutoClear(expectedGeneration: Long) { _state.update { current -> - if (current != null && generation.get() == expectedGeneration) current.copy(percent = 100, stalled = false) else current + if (current != null && generation.load() == expectedGeneration) current.copy(percent = 100, stalled = false) else current } appScope.launch { delay(AUTO_CLEAR_DELAY_MS) - if (generation.get() == expectedGeneration) _state.value = null + if (generation.load() == expectedGeneration) _state.value = null } } @@ -176,7 +183,7 @@ class BolusProgressData @Inject constructor( * a no-op instead of wiping the state it just installed. */ fun clear(expectedGeneration: Long) { - _state.update { current -> if (generation.get() == expectedGeneration) null else current } + _state.update { current -> if (generation.load() == expectedGeneration) null else current } } /** @@ -194,12 +201,13 @@ class BolusProgressData @Inject constructor( */ fun markStalled(expectedGeneration: Long) { _state.update { - if (it != null && it.percent < 100 && generation.get() == expectedGeneration) it.copy(stalled = true) + if (it != null && it.percent < 100 && generation.load() == expectedGeneration) it.copy(stalled = true) else it } } companion object { + private const val AUTO_CLEAR_DELAY_MS = 5_000L } } @@ -209,8 +217,14 @@ data class BolusProgressState( val isSMB: Boolean, val isPriming: Boolean, val percent: Int, - val status: String, - val wearStatus: String, + /** + * Kept as an unresolved reference rather than rendered text: the dialog resolves it with Compose, + * while the wear and Tizen bridges resolve it with their own ResourceHelper before putting it on + * the wire. Those bridges are why this cannot simply be a TextRef all the way to the watch - a + * different APK cannot resolve this one's resource ids, so the phone has to render before sending. + */ + val status: TextRef, + val wearStatus: TextRef, val delivered: PumpInsulin, val stopPressed: Boolean, val stopDeliveryEnabled: Boolean, diff --git a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/pump/Dana.kt b/core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/pump/Dana.kt similarity index 100% rename from core/interfaces/src/main/kotlin/app/aaps/core/interfaces/pump/Dana.kt rename to core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/pump/Dana.kt diff --git a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/pump/DetailedBolusInfo.kt b/core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/pump/DetailedBolusInfo.kt similarity index 90% rename from core/interfaces/src/main/kotlin/app/aaps/core/interfaces/pump/DetailedBolusInfo.kt rename to core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/pump/DetailedBolusInfo.kt index 2271f81984a6..e2d5b6e68ecc 100644 --- a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/pump/DetailedBolusInfo.kt +++ b/core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/pump/DetailedBolusInfo.kt @@ -1,6 +1,5 @@ package app.aaps.core.interfaces.pump -import android.content.Context import app.aaps.core.data.model.BCR import app.aaps.core.data.model.BS import app.aaps.core.data.model.CA @@ -8,20 +7,20 @@ import app.aaps.core.data.model.ICfg import app.aaps.core.data.model.IDs import app.aaps.core.data.model.TE import app.aaps.core.data.pump.defs.PumpType +import kotlin.time.Clock class DetailedBolusInfo { - val id = System.currentTimeMillis() + val id = Clock.System.now().toEpochMilliseconds() // Requesting parameters for driver - @JvmField var insulin = 0.0 - @JvmField var carbs = 0.0 + var insulin = 0.0 + var carbs = 0.0 // Additional requesting parameters - @JvmField var timestamp = System.currentTimeMillis() + var timestamp = Clock.System.now().toEpochMilliseconds() var lastKnownBolusTime: Long = 0 // for SMB check var deliverAtTheLatest: Long = 0 // SMB should be delivered within 1 min from this time - @Transient var context: Context? = null // context for progress dialog // Prefilled info for storing to db var bolusCalculatorResult: BCR? = null diff --git a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/pump/DetailedBolusInfoStorage.kt b/core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/pump/DetailedBolusInfoStorage.kt similarity index 100% rename from core/interfaces/src/main/kotlin/app/aaps/core/interfaces/pump/DetailedBolusInfoStorage.kt rename to core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/pump/DetailedBolusInfoStorage.kt diff --git a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/pump/Diaconn.kt b/core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/pump/Diaconn.kt similarity index 100% rename from core/interfaces/src/main/kotlin/app/aaps/core/interfaces/pump/Diaconn.kt rename to core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/pump/Diaconn.kt diff --git a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/pump/Insight.kt b/core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/pump/Insight.kt similarity index 100% rename from core/interfaces/src/main/kotlin/app/aaps/core/interfaces/pump/Insight.kt rename to core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/pump/Insight.kt diff --git a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/pump/MappedStateFlow.kt b/core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/pump/MappedStateFlow.kt similarity index 100% rename from core/interfaces/src/main/kotlin/app/aaps/core/interfaces/pump/MappedStateFlow.kt rename to core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/pump/MappedStateFlow.kt diff --git a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/pump/Medtrum.kt b/core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/pump/Medtrum.kt similarity index 100% rename from core/interfaces/src/main/kotlin/app/aaps/core/interfaces/pump/Medtrum.kt rename to core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/pump/Medtrum.kt diff --git a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/pump/OmnipodDash.kt b/core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/pump/OmnipodDash.kt similarity index 100% rename from core/interfaces/src/main/kotlin/app/aaps/core/interfaces/pump/OmnipodDash.kt rename to core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/pump/OmnipodDash.kt diff --git a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/pump/OmnipodEros.kt b/core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/pump/OmnipodEros.kt similarity index 100% rename from core/interfaces/src/main/kotlin/app/aaps/core/interfaces/pump/OmnipodEros.kt rename to core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/pump/OmnipodEros.kt diff --git a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/pump/Pump.kt b/core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/pump/Pump.kt similarity index 95% rename from core/interfaces/src/main/kotlin/app/aaps/core/interfaces/pump/Pump.kt rename to core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/pump/Pump.kt index 49c18cf4cd39..97ee0d476c4c 100644 --- a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/pump/Pump.kt +++ b/core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/pump/Pump.kt @@ -8,7 +8,7 @@ import app.aaps.core.interfaces.pump.actions.CustomAction import app.aaps.core.interfaces.pump.actions.CustomActionType import app.aaps.core.interfaces.queue.CustomCommand import kotlinx.coroutines.flow.StateFlow -import org.json.JSONObject +import kotlinx.serialization.json.JsonObject /** * This interface defines the communication from AAPS-core to pump drivers. @@ -208,12 +208,17 @@ interface Pump { suspend fun cancelExtendedBolus(): PumpEnactResult /** - * Status to be passed to NS. + * Driver specific entries for the "extended" section of the Nightscout pump status. * * This info is displayed when user hover over pump pill in NS. * Except common information every driver can add own info here. + * + * The driver **returns** its entries and the caller merges them. It used to be handed a mutable + * object to write into, which cannot work once that document is an immutable tree - and a return + * value is the clearer contract anyway, since a driver can no longer accidentally remove or + * overwrite the common fields. */ - fun updateExtendedJsonStatus(extendedStatus: JSONObject) {} + fun extendedStatus(): JsonObject = JsonObject(emptyMap()) /** * Manufacturer type. Usually defined by used plugin diff --git a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/pump/PumpEnactResult.kt b/core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/pump/PumpEnactResult.kt similarity index 100% rename from core/interfaces/src/main/kotlin/app/aaps/core/interfaces/pump/PumpEnactResult.kt rename to core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/pump/PumpEnactResult.kt diff --git a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/pump/PumpInsulin.kt b/core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/pump/PumpInsulin.kt similarity index 100% rename from core/interfaces/src/main/kotlin/app/aaps/core/interfaces/pump/PumpInsulin.kt rename to core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/pump/PumpInsulin.kt diff --git a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/pump/PumpPluginBase.kt b/core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/pump/PumpPluginBase.kt similarity index 64% rename from core/interfaces/src/main/kotlin/app/aaps/core/interfaces/pump/PumpPluginBase.kt rename to core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/pump/PumpPluginBase.kt index dd2e14bb7f2a..dd71a24f0fc7 100644 --- a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/pump/PumpPluginBase.kt +++ b/core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/pump/PumpPluginBase.kt @@ -1,17 +1,14 @@ package app.aaps.core.interfaces.pump -import android.Manifest -import android.annotation.SuppressLint -import android.os.Handler -import android.os.HandlerThread +import app.aaps.core.data.model.devAssert import app.aaps.core.data.plugin.PluginType -import app.aaps.core.interfaces.R +import app.aaps.core.interfaces.InterfacesStrings import app.aaps.core.interfaces.logging.AAPSLogger import app.aaps.core.interfaces.plugin.PermissionGroup import app.aaps.core.interfaces.plugin.PluginBaseWithPreferences import app.aaps.core.interfaces.plugin.PluginDescription import app.aaps.core.interfaces.queue.CommandQueue -import app.aaps.core.interfaces.resources.ResourceHelper +import app.aaps.core.interfaces.resources.TextResolver import app.aaps.core.keys.interfaces.NonPreferenceKey import app.aaps.core.keys.interfaces.Preferences import kotlinx.coroutines.Job @@ -23,20 +20,18 @@ import kotlinx.coroutines.launch */ abstract class PumpPluginBase( pluginDescription: PluginDescription, - ownPreferences: List> = emptyList(), + ownPreferences: List = emptyList(), aapsLogger: AAPSLogger, - rh: ResourceHelper, + rh: TextResolver, preferences: Preferences, val commandQueue: CommandQueue ) : PluginBaseWithPreferences(pluginDescription, ownPreferences, aapsLogger, rh, preferences) { - var handler: Handler? = null private var initialReadStatusJob: Job? = null override suspend fun onStart() { super.onStart() - assert(getType() == PluginType.PUMP) - handler = Handler(HandlerThread(this::class.java.simpleName + "Handler").also { it.start() }.looper) + devAssert(getType() == PluginType.PUMP) // Run the initial status read in the background so this onStart() returns immediately. // Pump drivers call super.onStart() first and then launch their own async hardware init // (e.g. ComboV2 sets up Bluetooth and its pumpManager in a coroutine). If we suspended @@ -45,7 +40,7 @@ abstract class PumpPluginBase( initialReadStatusJob = pluginScope.launch { delay(6000) if ((this@PumpPluginBase as? Pump)?.isConfigured() != false) - commandQueue.readStatus(rh.gs(R.string.pump_driver_changed)) + commandQueue.readStatus(rh.gs(InterfacesStrings.pump_driver_changed)) } } @@ -53,17 +48,13 @@ abstract class PumpPluginBase( super.onStop() initialReadStatusJob?.cancel() initialReadStatusJob = null - handler?.removeCallbacksAndMessages(null) - handler?.looper?.quit() - handler = null } - @SuppressLint("InlinedApi") - override fun requiredPermissions(): List = listOf( - PermissionGroup( - permissions = listOf(Manifest.permission.BLUETOOTH_CONNECT, Manifest.permission.BLUETOOTH_SCAN), - rationaleTitle = R.string.permission_bluetooth_title, - rationaleDescription = R.string.permission_bluetooth_description, - ) - ) + /** + * The Bluetooth permissions a hardware pump needs, on the platforms that have such a concept. + * + * Empty on iOS, where Bluetooth is declared in `Info.plist` rather than requested at runtime - + * see [bluetoothPermissionGroup]. + */ + override fun requiredPermissions(): List = listOfNotNull(bluetoothPermissionGroup()) } \ No newline at end of file diff --git a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/pump/PumpProfile.kt b/core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/pump/PumpProfile.kt similarity index 100% rename from core/interfaces/src/main/kotlin/app/aaps/core/interfaces/pump/PumpProfile.kt rename to core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/pump/PumpProfile.kt diff --git a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/pump/PumpRate.kt b/core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/pump/PumpRate.kt similarity index 100% rename from core/interfaces/src/main/kotlin/app/aaps/core/interfaces/pump/PumpRate.kt rename to core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/pump/PumpRate.kt diff --git a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/pump/PumpStatusProvider.kt b/core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/pump/PumpStatusProvider.kt similarity index 71% rename from core/interfaces/src/main/kotlin/app/aaps/core/interfaces/pump/PumpStatusProvider.kt rename to core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/pump/PumpStatusProvider.kt index a5d92cf852bc..aecb3f3b9204 100644 --- a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/pump/PumpStatusProvider.kt +++ b/core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/pump/PumpStatusProvider.kt @@ -1,6 +1,6 @@ package app.aaps.core.interfaces.pump -import org.json.JSONObject +import kotlinx.serialization.json.JsonObject interface PumpStatusProvider { @@ -12,5 +12,5 @@ interface PumpStatusProvider { /** * Generate JSON status of pump sent to the NS */ - suspend fun generatePumpJsonStatus(): JSONObject + suspend fun generatePumpJsonStatus(): JsonObject } diff --git a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/pump/PumpSync.kt b/core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/pump/PumpSync.kt similarity index 96% rename from core/interfaces/src/main/kotlin/app/aaps/core/interfaces/pump/PumpSync.kt rename to core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/pump/PumpSync.kt index 0451e1e5350c..ec1f17594256 100644 --- a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/pump/PumpSync.kt +++ b/core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/pump/PumpSync.kt @@ -7,13 +7,14 @@ import app.aaps.core.data.model.TE import app.aaps.core.data.pump.defs.PumpType import app.aaps.core.data.time.T import app.aaps.core.data.ue.Sources -import app.aaps.core.interfaces.R +import app.aaps.core.interfaces.InterfacesStrings import app.aaps.core.interfaces.profile.Profile -import app.aaps.core.interfaces.resources.ResourceHelper +import app.aaps.core.interfaces.resources.TextResolver import app.aaps.core.interfaces.utils.DateUtil import kotlin.math.max import kotlin.math.min import kotlin.math.roundToInt +import kotlin.time.Clock /** * This interface allows pump drivers to push data changes (creation and update of treatments, temporary basals and extended boluses) back to AAPS-core. @@ -93,19 +94,19 @@ interface PumpSync { ) { val end: Long get() = timestamp + duration - val plannedRemainingMinutes: Long get() = max(T.msecs(end - System.currentTimeMillis()).mins(), 0L) + val plannedRemainingMinutes: Long get() = max(T.msecs(end - Clock.System.now().toEpochMilliseconds()).mins(), 0L) fun convertedToAbsolute(time: Long, profile: Profile): Double = if (isAbsolute) rate else profile.getBasal(time) * rate / 100 - fun toStringFull(dateUtil: DateUtil, rh: ResourceHelper): String { + fun toStringFull(dateUtil: DateUtil, rh: TextResolver): String { return when { isAbsolute -> { - rh.gs(R.string.temp_basal_absolute_rate, rate, dateUtil.timeString(timestamp), getPassedDurationToTimeInMinutes(dateUtil.now()), durationInMinutes) + rh.gs(InterfacesStrings.temp_basal_absolute_rate, rate, dateUtil.timeString(timestamp), getPassedDurationToTimeInMinutes(dateUtil.now()), durationInMinutes) } else -> { // percent - rh.gs(R.string.temp_basal_percent_rate, rate, dateUtil.timeString(timestamp), getPassedDurationToTimeInMinutes(dateUtil.now()), durationInMinutes) + rh.gs(InterfacesStrings.temp_basal_percent_rate, rate, dateUtil.timeString(timestamp), getPassedDurationToTimeInMinutes(dateUtil.now()), durationInMinutes) } } } @@ -132,13 +133,13 @@ interface PumpSync { get() = timestamp + duration val plannedRemainingMinutes: Long - get() = max(T.msecs(end - System.currentTimeMillis()).mins(), 0L) + get() = max(T.msecs(end - Clock.System.now().toEpochMilliseconds()).mins(), 0L) private fun getPassedDurationToTimeInMinutes(time: Long): Int = ((min(time, end) - timestamp) / 60.0 / 1000).roundToInt() - fun toStringFull(dateUtil: DateUtil, rh: ResourceHelper): String = - rh.gs(R.string.temp_basal_extended_bolus, rate, dateUtil.timeString(timestamp), getPassedDurationToTimeInMinutes(dateUtil.now()), T.msecs(duration).mins()) + fun toStringFull(dateUtil: DateUtil, rh: TextResolver): String = + rh.gs(InterfacesStrings.temp_basal_extended_bolus, rate, dateUtil.timeString(timestamp), getPassedDurationToTimeInMinutes(dateUtil.now()), T.msecs(duration).mins()) } diff --git a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/pump/PumpWithConcentration.kt b/core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/pump/PumpWithConcentration.kt similarity index 100% rename from core/interfaces/src/main/kotlin/app/aaps/core/interfaces/pump/PumpWithConcentration.kt rename to core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/pump/PumpWithConcentration.kt diff --git a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/pump/TemporaryBasalStorage.kt b/core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/pump/TemporaryBasalStorage.kt similarity index 100% rename from core/interfaces/src/main/kotlin/app/aaps/core/interfaces/pump/TemporaryBasalStorage.kt rename to core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/pump/TemporaryBasalStorage.kt diff --git a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/pump/VirtualPump.kt b/core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/pump/VirtualPump.kt similarity index 100% rename from core/interfaces/src/main/kotlin/app/aaps/core/interfaces/pump/VirtualPump.kt rename to core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/pump/VirtualPump.kt diff --git a/core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/pump/actions/CustomAction.kt b/core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/pump/actions/CustomAction.kt new file mode 100644 index 000000000000..16f710e4ee94 --- /dev/null +++ b/core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/pump/actions/CustomAction.kt @@ -0,0 +1,24 @@ +package app.aaps.core.interfaces.pump.actions + +import androidx.compose.ui.graphics.vector.ImageVector + +/** + * A pump driver's own action, shown in the Actions tab. + * + * **Currently constructed by nothing, and that is intentional - do not delete it as dead code.** + * The only producer was MedtronicPumpPlugin (wake-up-and-tune, clear-bolus-block, reset-RileyLink), + * removed by commit 060ec9d218 "MDT: compose migration". The extension point is kept so those + * actions, or a driver's equivalent, can come back without redesigning the contract. + * + * A dead-code sweep will flag this, [CustomActionType], [app.aaps.core.interfaces.pump.Pump.getCustomActions] + * and `Pump.executeCustomAction` together - they are one feature, retained deliberately. + * + * For the multiplatform work: [name] is an Android string resource id, so it needs the usual TextRef + * treatment whenever the feature is actually revived. + */ +data class CustomAction( + val name: Int, + val customActionType: CustomActionType, + val icon: ImageVector, + var isEnabled: Boolean = true +) \ No newline at end of file diff --git a/core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/pump/actions/CustomActionType.kt b/core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/pump/actions/CustomActionType.kt new file mode 100644 index 000000000000..100414cf4ab7 --- /dev/null +++ b/core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/pump/actions/CustomActionType.kt @@ -0,0 +1,12 @@ +package app.aaps.core.interfaces.pump.actions + +/** + * Identifies a [CustomAction] when it is dispatched back to its driver. + * + * Retained deliberately along with [CustomAction] - see the note there before removing it as dead + * code. Its only implementation, `MedtronicCustomActionType`, is likewise unreferenced today. + */ +interface CustomActionType { + + fun getKey(): String +} \ No newline at end of file diff --git a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/pump/ble/BleTransport.kt b/core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/pump/ble/BleTransport.kt similarity index 100% rename from core/interfaces/src/main/kotlin/app/aaps/core/interfaces/pump/ble/BleTransport.kt rename to core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/pump/ble/BleTransport.kt diff --git a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/pump/defs/PumpDescriptionExtension.kt b/core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/pump/defs/PumpDescriptionExtension.kt similarity index 100% rename from core/interfaces/src/main/kotlin/app/aaps/core/interfaces/pump/defs/PumpDescriptionExtension.kt rename to core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/pump/defs/PumpDescriptionExtension.kt diff --git a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/pump/defs/PumpDeviceState.kt b/core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/pump/defs/PumpDeviceState.kt similarity index 100% rename from core/interfaces/src/main/kotlin/app/aaps/core/interfaces/pump/defs/PumpDeviceState.kt rename to core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/pump/defs/PumpDeviceState.kt diff --git a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/pump/defs/PumpTypeExtension.kt b/core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/pump/defs/PumpTypeExtension.kt similarity index 81% rename from core/interfaces/src/main/kotlin/app/aaps/core/interfaces/pump/defs/PumpTypeExtension.kt rename to core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/pump/defs/PumpTypeExtension.kt index 087c68157c41..cf48148a689d 100644 --- a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/pump/defs/PumpTypeExtension.kt +++ b/core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/pump/defs/PumpTypeExtension.kt @@ -16,11 +16,6 @@ fun PumpType.determineCorrectBolusSize(bolusAmount: Double): Double = fun PumpType.determineCorrectBolusStepSize(bolusAmount: Double): Double = specialBolusSize()?.getStepSizeForAmount(bolusAmount) ?: bolusSize() -fun PumpType.determineCorrectExtendedBolusSize(bolusAmount: Double): Double { - val ebSettings = extendedBolusSettings() ?: throw IllegalStateException() - return Round.roundTo(min(bolusAmount, ebSettings.maxDose), ebSettings.step) -} - fun PumpType.determineCorrectBasalSize(basalAmount: Double): Double { val tSettings = tbrSettings() ?: throw IllegalStateException() return Round.roundTo(min(basalAmount, tSettings.maxDose), baseBasalSpecialSteps()?.getStepSizeForAmount(basalAmount) ?: baseBasalStep()) diff --git a/core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/queue/Callback.kt b/core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/queue/Callback.kt new file mode 100644 index 000000000000..72464b80eff8 --- /dev/null +++ b/core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/queue/Callback.kt @@ -0,0 +1,21 @@ +package app.aaps.core.interfaces.queue + +import app.aaps.core.interfaces.pump.PumpEnactResult + +/** + * Result handler for a queued [Command]. + * + * Declares its own [run] instead of implementing `java.lang.Runnable`. Nothing ever handed a + * Callback to something that wanted a Runnable, and Runnable is JVM only, so the supertype only + * tied this file to one platform. + */ +abstract class Callback { + + lateinit var result: PumpEnactResult + fun result(result: PumpEnactResult): Callback { + this.result = result + return this + } + + abstract fun run() +} diff --git a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/queue/Command.kt b/core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/queue/Command.kt similarity index 73% rename from core/interfaces/src/main/kotlin/app/aaps/core/interfaces/queue/Command.kt rename to core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/queue/Command.kt index a0216aa0c78b..3349d4d4e29a 100644 --- a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/queue/Command.kt +++ b/core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/queue/Command.kt @@ -1,13 +1,20 @@ package app.aaps.core.interfaces.queue import app.aaps.core.interfaces.pump.PumpEnactResult -import javax.inject.Provider interface Command { val commandType: CommandType val callback: Callback? - val pumpEnactResultProvider: Provider + + /** + * Makes a fresh [PumpEnactResult]. + * + * A plain factory function rather than a `javax.inject.Provider`: that type is Dagger vocabulary, + * and it appeared in the public API of this interface, which pinned every implementer and this + * whole file to a JVM-only DI framework. Android still injects a `Provider` and passes its `::get`. + */ + val pumpEnactResultProvider: () -> PumpEnactResult enum class CommandType { BOLUS, @@ -34,6 +41,7 @@ interface Command { suspend fun executeWithCallback() { callback?.result(execute())?.run() } + fun status(): String fun log(): String @@ -45,6 +53,6 @@ interface Command { * Return success = true to avoid command failed dialog */ fun cancel(commentResId: Int, success: Boolean = true) { - callback?.result(pumpEnactResultProvider.get().success(success).comment(commentResId))?.run() + callback?.result(pumpEnactResultProvider().success(success).comment(commentResId))?.run() } } \ No newline at end of file diff --git a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/queue/CommandQueue.kt b/core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/queue/CommandQueue.kt similarity index 90% rename from core/interfaces/src/main/kotlin/app/aaps/core/interfaces/queue/CommandQueue.kt rename to core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/queue/CommandQueue.kt index 2a9e3a93fa1f..56f39597c6aa 100644 --- a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/queue/CommandQueue.kt +++ b/core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/queue/CommandQueue.kt @@ -1,11 +1,12 @@ package app.aaps.core.interfaces.queue -import android.text.Spanned +import androidx.compose.ui.text.AnnotatedString import app.aaps.core.interfaces.profile.EffectiveProfile import app.aaps.core.interfaces.profile.Profile import app.aaps.core.interfaces.pump.DetailedBolusInfo import app.aaps.core.interfaces.pump.PumpEnactResult import app.aaps.core.interfaces.pump.PumpSync +import kotlin.reflect.KClass /** * **Deadlock warning** — the queue is processed by a single app-owned `CommandExecutor` loop; one @@ -55,8 +56,8 @@ interface CommandQueue { suspend fun deactivate(): PumpEnactResult suspend fun updateTime(): PumpEnactResult suspend fun customCommand(customCommand: CustomCommand): PumpEnactResult - fun isCustomCommandRunning(customCommandType: Class): Boolean - fun isCustomCommandInQueue(customCommandType: Class): Boolean - fun spannedStatus(): Spanned + fun isCustomCommandRunning(customCommandType: KClass): Boolean + fun isCustomCommandInQueue(customCommandType: KClass): Boolean + fun statusAsAnnotated(): AnnotatedString suspend fun isThisProfileSet(requestedProfile: EffectiveProfile): Boolean } \ No newline at end of file diff --git a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/queue/CustomCommand.kt b/core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/queue/CustomCommand.kt similarity index 86% rename from core/interfaces/src/main/kotlin/app/aaps/core/interfaces/queue/CustomCommand.kt rename to core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/queue/CustomCommand.kt index 312a4dfbb68f..115974661d69 100644 --- a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/queue/CustomCommand.kt +++ b/core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/queue/CustomCommand.kt @@ -1,12 +1,10 @@ package app.aaps.core.interfaces.queue -import java.io.Serializable - /** * Implement this interface for every custom pump command that you want to be able to queue * See [app.aaps.core.interfaces.queue.CommandQueue.customCommand] for queuing a custom command. */ -interface CustomCommand : Serializable { +interface CustomCommand { /** * @return short description of this command to be used in [app.aaps.core.interfaces.queue.Command.status] diff --git a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/receivers/Intents.kt b/core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/receivers/Intents.kt similarity index 99% rename from core/interfaces/src/main/kotlin/app/aaps/core/interfaces/receivers/Intents.kt rename to core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/receivers/Intents.kt index fe1a272a2f76..77c3e6789349 100644 --- a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/receivers/Intents.kt +++ b/core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/receivers/Intents.kt @@ -44,8 +44,10 @@ interface Intents { // Broadcast status const val AAPS_BROADCAST = "info.nightscout.androidaps.status" + // Patched Ottai App -> AAPS (International) const val OTTAI_APP = "info.nightscout.androidaps.action.OTTAI_APP" + // Patched Ottai App -> AAPS (China) const val OTTAI_APP_CN = "cn.diyaps.sharing.OT_APP" @@ -54,6 +56,7 @@ interface Intents { // Patched Sino App -> AAPS const val SINO_APP = "cn.diyaps.sharing.SINO_APP" + // Patched Syai Tag App -> AAPS const val SYAI_APP = "info.nightscout.androidaps.action.SYAI_TAG_APP" diff --git a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/receivers/ReceiverStatusStore.kt b/core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/receivers/ReceiverStatusStore.kt similarity index 100% rename from core/interfaces/src/main/kotlin/app/aaps/core/interfaces/receivers/ReceiverStatusStore.kt rename to core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/receivers/ReceiverStatusStore.kt diff --git a/core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/resources/TextResolver.kt b/core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/resources/TextResolver.kt new file mode 100644 index 000000000000..900414cd0ade --- /dev/null +++ b/core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/resources/TextResolver.kt @@ -0,0 +1,31 @@ +package app.aaps.core.interfaces.resources + +import app.aaps.core.keys.interfaces.TextRef + +/** + * Turns a [TextRef] into a String outside Compose. Inside Compose use + * `app.aaps.core.ui.compose.stringResource` instead. + * + * This is the part of string resolution that every platform can implement, so it is what shared code + * depends on. On Android `ResourceHelper` extends it and adds the resource id overloads, which only + * mean something where Android resources exist. + * + * The split exists because most files in this module never call a resolver - they only name the type + * in a signature, for example `fun minAgo(rh: ResourceHelper, time: Long?)`. Naming [TextResolver] + * instead lets those files move to commonMain without any change to their callers, since every + * `ResourceHelper` is a [TextResolver]. + */ +interface TextResolver { + + /** Resolves [ref] in the current language. */ + fun gs(ref: TextRef): String + + /** Same, with format arguments. */ + fun gs(ref: TextRef, vararg args: Any): String + + /** Same, but always in English - used for data that is stored or uploaded, not displayed. */ + fun gsNotLocalised(ref: TextRef): String + + /** True on a small screen, where the UI uses shorter texts. */ + fun shortTextMode(): Boolean +} diff --git a/core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/rx/ChunkedOnQuietPeriod.kt b/core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/rx/ChunkedOnQuietPeriod.kt new file mode 100644 index 000000000000..2b8fd274f9be --- /dev/null +++ b/core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/rx/ChunkedOnQuietPeriod.kt @@ -0,0 +1,39 @@ +package app.aaps.core.interfaces.rx + +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.transformLatest + +/** + * Groups items into batches and emits a batch once nothing new arrived for [quietPeriodMs]. + * + * This is the Flow replacement for the RxJava idiom + * `publish { shared -> shared.buffer(shared.debounce(quietPeriod)) }`, which we used to coalesce + * bursts of events (for example the Wear Data Layer replaying its queue after a reconnect). The + * timer stays idle while nothing arrives, unlike a fixed window, so a single event is still + * delivered after one quiet period instead of waiting for a window boundary. + * + * `transformLatest` cancels and joins the previous block before it starts the new one, so the batch + * is only ever touched by one coroutine and needs no lock: every new item restarts the [delay], and + * the block reaches the emit only after a full quiet period. + * + * A batch that is waiting for its quiet period is dropped if the collector is cancelled. That is + * what the RxJava version did when its subscription was disposed, and it keeps items from being + * stored twice. + * + * @param quietPeriodMs how long the source has to stay silent before the batch is emitted + */ +@OptIn(ExperimentalCoroutinesApi::class) +fun Flow.chunkedOnQuietPeriod(quietPeriodMs: Long): Flow> = flow { + // Built inside flow { } so every collection of the result gets its own batch. + val batch = mutableListOf() + transformLatest { item -> + batch += item + delay(quietPeriodMs) + val complete = batch.toList() + batch.clear() + emit(complete) + }.collect { emit(it) } +} diff --git a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/rx/ResilientCollect.kt b/core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/rx/ResilientCollect.kt similarity index 64% rename from core/interfaces/src/main/kotlin/app/aaps/core/interfaces/rx/ResilientCollect.kt rename to core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/rx/ResilientCollect.kt index 873ff6736f83..a6f704c45a4c 100644 --- a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/rx/ResilientCollect.kt +++ b/core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/rx/ResilientCollect.kt @@ -4,12 +4,14 @@ import app.aaps.core.interfaces.logging.AAPSLogger import app.aaps.core.interfaces.logging.LTag import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.CoroutineStart import kotlinx.coroutines.Job import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.collect import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.retryWhen +import kotlinx.coroutines.launch /** * Collects a long-lived [Flow] resiliently in [scope], logging under [tag]. @@ -30,12 +32,30 @@ import kotlinx.coroutines.flow.retryWhen * NOTE: this only addresses *exceptions*. Collection is sequential, so a [block] that never returns * (e.g. an awaited callback that is lost) still blocks all subsequent emissions without throwing — * bound such calls with a timeout (`withTimeoutOrNull`) at the call site. + * + * ### [start], and when you need it + * + * By default the collecting coroutine is *scheduled*, so it has not subscribed yet when this function + * returns. On a source that replays (a `StateFlow`, a DB observer) that is harmless - a late + * subscriber still gets the current value. + * + * On a source with **no replay** it is not harmless. `RxBus` publishes through a `MutableSharedFlow` + * with `replay = 0`, so anything emitted between this call and the collector actually starting is + * dropped, silently and without a trace. RxJava's `subscribe()` registered synchronously and had no + * such window, so a subscription converted from Rx acquires this gap unless it asks not to. + * + * Pass [CoroutineStart.UNDISPATCHED] there: the coroutine then runs on the calling thread up to its + * first suspension, which for a bare `collect` is exactly the subscribe, and resumes on [scope]'s + * dispatcher afterwards. Note this also means that if the source *does* emit during subscribe (a + * `StateFlow`'s current value), that first [block] runs on the caller's thread - which is why this is + * opt-in rather than the default. */ fun Flow.collectResilient( scope: CoroutineScope, aapsLogger: AAPSLogger, tag: LTag, restartDelayMs: Long = 1000L, + start: CoroutineStart = CoroutineStart.DEFAULT, block: suspend (T) -> Unit ): Job = onEach { item -> @@ -56,4 +76,4 @@ fun Flow.collectResilient( true } } - .launchIn(scope) + .let { flow -> scope.launch(start = start) { flow.collect() } } diff --git a/core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/rx/bus/RxBus.kt b/core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/rx/bus/RxBus.kt new file mode 100644 index 000000000000..ecce6be7fa7b --- /dev/null +++ b/core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/rx/bus/RxBus.kt @@ -0,0 +1,30 @@ +package app.aaps.core.interfaces.rx.bus + +import app.aaps.core.interfaces.rx.events.Event +import kotlinx.coroutines.flow.Flow +import kotlin.reflect.KClass + +/** + * A simple event bus for communication between different parts of the application. + */ +interface RxBus { + + /** + * Sends an event to the bus. + * + * @param event The event to send. + */ + fun send(event: Event) + + /** + * Subscribes to events of a specific type. + * + * The bus has no replay, so a collector only sees what is sent after it starts. Collect with + * `app.aaps.core.interfaces.rx.collectResilient` and `CoroutineStart.UNDISPATCHED` when the + * subscription is made from a constructor or `onStart`, so nothing sent right after it is lost. + * + * @param eventType The class of the event to listen for. + * @return A [Flow] that emits events of the specified type. + */ + fun toFlow(eventType: KClass): Flow +} diff --git a/core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/rx/events/Event.kt b/core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/rx/events/Event.kt new file mode 100644 index 000000000000..08f5e57861e0 --- /dev/null +++ b/core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/rx/events/Event.kt @@ -0,0 +1,13 @@ +package app.aaps.core.interfaces.rx.events + +/** + * Base class for all events posted on the event bus. + * + * Events that carry data are `data class`es, so they print their values themselves. This default is + * for the ones that carry none: for those the name is the whole message, and it is what the previous + * reflection based toString produced for them anyway. + */ +abstract class Event { + + override fun toString(): String = this::class.simpleName ?: "Event" +} diff --git a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/rx/events/EventAPSCalculationFinished.kt b/core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/rx/events/EventAPSCalculationFinished.kt similarity index 100% rename from core/interfaces/src/main/kotlin/app/aaps/core/interfaces/rx/events/EventAPSCalculationFinished.kt rename to core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/rx/events/EventAPSCalculationFinished.kt diff --git a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/rx/events/EventAcceptOpenLoopChange.kt b/core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/rx/events/EventAcceptOpenLoopChange.kt similarity index 100% rename from core/interfaces/src/main/kotlin/app/aaps/core/interfaces/rx/events/EventAcceptOpenLoopChange.kt rename to core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/rx/events/EventAcceptOpenLoopChange.kt diff --git a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/rx/events/EventAppExit.kt b/core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/rx/events/EventAppExit.kt similarity index 100% rename from core/interfaces/src/main/kotlin/app/aaps/core/interfaces/rx/events/EventAppExit.kt rename to core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/rx/events/EventAppExit.kt diff --git a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/rx/events/EventAppInitialized.kt b/core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/rx/events/EventAppInitialized.kt similarity index 100% rename from core/interfaces/src/main/kotlin/app/aaps/core/interfaces/rx/events/EventAppInitialized.kt rename to core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/rx/events/EventAppInitialized.kt diff --git a/core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/rx/events/EventAutosensCalculationFinished.kt b/core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/rx/events/EventAutosensCalculationFinished.kt new file mode 100644 index 000000000000..c552b469eb6d --- /dev/null +++ b/core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/rx/events/EventAutosensCalculationFinished.kt @@ -0,0 +1,3 @@ +package app.aaps.core.interfaces.rx.events + +data class EventAutosensCalculationFinished(val triggeredByNewBG: Boolean) : EventLoop() diff --git a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/rx/events/EventBTChange.kt b/core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/rx/events/EventBTChange.kt similarity index 80% rename from core/interfaces/src/main/kotlin/app/aaps/core/interfaces/rx/events/EventBTChange.kt rename to core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/rx/events/EventBTChange.kt index 306825e88e76..e9f2e1d00a91 100644 --- a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/rx/events/EventBTChange.kt +++ b/core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/rx/events/EventBTChange.kt @@ -7,7 +7,7 @@ package app.aaps.core.interfaces.rx.events * @param deviceName The name of the device, or null if not available. * @param deviceAddress The address of the device, or null if not available. */ -class EventBTChange(val state: Change, val deviceName: String?, @Suppress("unused") val deviceAddress: String? = null) : Event() { +data class EventBTChange(val state: Change, val deviceName: String?, @Suppress("unused") val deviceAddress: String? = null) : Event() { /** * Represents the connection state of a Bluetooth device. diff --git a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/rx/events/EventBucketedDataCreated.kt b/core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/rx/events/EventBucketedDataCreated.kt similarity index 100% rename from core/interfaces/src/main/kotlin/app/aaps/core/interfaces/rx/events/EventBucketedDataCreated.kt rename to core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/rx/events/EventBucketedDataCreated.kt diff --git a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/rx/events/EventCalibrationChanged.kt b/core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/rx/events/EventCalibrationChanged.kt similarity index 100% rename from core/interfaces/src/main/kotlin/app/aaps/core/interfaces/rx/events/EventCalibrationChanged.kt rename to core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/rx/events/EventCalibrationChanged.kt diff --git a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/rx/events/EventConcentrationChange.kt b/core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/rx/events/EventConcentrationChange.kt similarity index 100% rename from core/interfaces/src/main/kotlin/app/aaps/core/interfaces/rx/events/EventConcentrationChange.kt rename to core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/rx/events/EventConcentrationChange.kt diff --git a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/rx/events/EventConfigBuilderChange.kt b/core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/rx/events/EventConfigBuilderChange.kt similarity index 100% rename from core/interfaces/src/main/kotlin/app/aaps/core/interfaces/rx/events/EventConfigBuilderChange.kt rename to core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/rx/events/EventConfigBuilderChange.kt diff --git a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/rx/events/EventCustomActionsChanged.kt b/core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/rx/events/EventCustomActionsChanged.kt similarity index 100% rename from core/interfaces/src/main/kotlin/app/aaps/core/interfaces/rx/events/EventCustomActionsChanged.kt rename to core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/rx/events/EventCustomActionsChanged.kt diff --git a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/rx/events/EventDiaconnG8PumpLogReset.kt b/core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/rx/events/EventDiaconnG8PumpLogReset.kt similarity index 100% rename from core/interfaces/src/main/kotlin/app/aaps/core/interfaces/rx/events/EventDiaconnG8PumpLogReset.kt rename to core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/rx/events/EventDiaconnG8PumpLogReset.kt diff --git a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/rx/events/EventInitializationChanged.kt b/core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/rx/events/EventInitializationChanged.kt similarity index 100% rename from core/interfaces/src/main/kotlin/app/aaps/core/interfaces/rx/events/EventInitializationChanged.kt rename to core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/rx/events/EventInitializationChanged.kt diff --git a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/rx/events/EventLoop.kt b/core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/rx/events/EventLoop.kt similarity index 100% rename from core/interfaces/src/main/kotlin/app/aaps/core/interfaces/rx/events/EventLoop.kt rename to core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/rx/events/EventLoop.kt diff --git a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/rx/events/EventLoopUpdateGui.kt b/core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/rx/events/EventLoopUpdateGui.kt similarity index 100% rename from core/interfaces/src/main/kotlin/app/aaps/core/interfaces/rx/events/EventLoopUpdateGui.kt rename to core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/rx/events/EventLoopUpdateGui.kt diff --git a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/rx/events/EventMobileToWear.kt b/core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/rx/events/EventMobileToWear.kt similarity index 80% rename from core/interfaces/src/main/kotlin/app/aaps/core/interfaces/rx/events/EventMobileToWear.kt rename to core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/rx/events/EventMobileToWear.kt index be731216cc28..dfff12517ec1 100644 --- a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/rx/events/EventMobileToWear.kt +++ b/core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/rx/events/EventMobileToWear.kt @@ -8,4 +8,4 @@ import app.aaps.core.interfaces.rx.weardata.EventData * * @param payload The data to send. */ -class EventMobileToWear(val payload: EventData) : Event() \ No newline at end of file +data class EventMobileToWear(val payload: EventData) : Event() \ No newline at end of file diff --git a/core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/rx/events/EventMobileToWearWatchface.kt b/core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/rx/events/EventMobileToWearWatchface.kt new file mode 100644 index 000000000000..9d3c62bbddaf --- /dev/null +++ b/core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/rx/events/EventMobileToWearWatchface.kt @@ -0,0 +1,3 @@ +package app.aaps.core.interfaces.rx.events + +data class EventMobileToWearWatchface(val payload: ByteArray) : Event() \ No newline at end of file diff --git a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/rx/events/EventNewOpenLoopNotification.kt b/core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/rx/events/EventNewOpenLoopNotification.kt similarity index 100% rename from core/interfaces/src/main/kotlin/app/aaps/core/interfaces/rx/events/EventNewOpenLoopNotification.kt rename to core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/rx/events/EventNewOpenLoopNotification.kt diff --git a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/rx/events/EventNsClientStatusUpdated.kt b/core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/rx/events/EventNsClientStatusUpdated.kt similarity index 100% rename from core/interfaces/src/main/kotlin/app/aaps/core/interfaces/rx/events/EventNsClientStatusUpdated.kt rename to core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/rx/events/EventNsClientStatusUpdated.kt diff --git a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/rx/events/EventNtpStatus.kt b/core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/rx/events/EventNtpStatus.kt similarity index 69% rename from core/interfaces/src/main/kotlin/app/aaps/core/interfaces/rx/events/EventNtpStatus.kt rename to core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/rx/events/EventNtpStatus.kt index cf7c61ae8797..147251de11f6 100644 --- a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/rx/events/EventNtpStatus.kt +++ b/core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/rx/events/EventNtpStatus.kt @@ -6,4 +6,4 @@ package app.aaps.core.interfaces.rx.events * @param status The status message. * @param percent The progress percentage. */ -class EventNtpStatus(val status: String, val percent: Int) : Event() \ No newline at end of file +data class EventNtpStatus(val status: String, val percent: Int) : Event() \ No newline at end of file diff --git a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/rx/events/EventProfileChangeRequested.kt b/core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/rx/events/EventProfileChangeRequested.kt similarity index 88% rename from core/interfaces/src/main/kotlin/app/aaps/core/interfaces/rx/events/EventProfileChangeRequested.kt rename to core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/rx/events/EventProfileChangeRequested.kt index 16056ce81816..f52d6a5fca46 100644 --- a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/rx/events/EventProfileChangeRequested.kt +++ b/core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/rx/events/EventProfileChangeRequested.kt @@ -9,4 +9,4 @@ package app.aaps.core.interfaces.rx.events * own ProfileSwitch on end — an internal, automatic write the user shouldn't have to acknowledge * (issue #4959). Defaults to false so every existing sender keeps its current behavior. */ -class EventProfileChangeRequested(val silent: Boolean = false) : Event() +data class EventProfileChangeRequested(val silent: Boolean = false) : Event() diff --git a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/rx/events/EventPumpStatusChanged.kt b/core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/rx/events/EventPumpStatusChanged.kt similarity index 75% rename from core/interfaces/src/main/kotlin/app/aaps/core/interfaces/rx/events/EventPumpStatusChanged.kt rename to core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/rx/events/EventPumpStatusChanged.kt index 30c9f68111be..a40ef67c3e47 100644 --- a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/rx/events/EventPumpStatusChanged.kt +++ b/core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/rx/events/EventPumpStatusChanged.kt @@ -1,7 +1,8 @@ package app.aaps.core.interfaces.rx.events -import android.content.Context -import app.aaps.core.interfaces.R +import app.aaps.core.interfaces.InterfacesStrings +import app.aaps.core.keys.interfaces.TextRef +import app.aaps.core.keys.interfaces.TextRef.Companion.withArgs /** * Fired when the pump connection status changes. @@ -90,15 +91,15 @@ class EventPumpStatusChanged : EventStatus { /** * Gets a human-readable status message for the startup wizard. */ - override fun getStatus(context: Context): String { + override fun getStatus(): TextRef { return when (status) { - Status.CONNECTING -> context.getString(R.string.connecting_for, secondsElapsed) - Status.HANDSHAKING -> context.getString(R.string.handshaking) - Status.CONNECTED -> context.getString(R.string.connected) - Status.PERFORMING -> performingAction - Status.WAITING_FOR_DISCONNECTION -> context.getString(R.string.waiting_for_disconnection) - Status.DISCONNECTING -> context.getString(R.string.disconnecting) - Status.DISCONNECTED -> "" + Status.CONNECTING -> InterfacesStrings.connecting_for.withArgs(secondsElapsed) + Status.HANDSHAKING -> InterfacesStrings.handshaking + Status.CONNECTED -> InterfacesStrings.connected + Status.PERFORMING -> TextRef.Literal(performingAction) + Status.WAITING_FOR_DISCONNECTION -> InterfacesStrings.waiting_for_disconnection + Status.DISCONNECTING -> InterfacesStrings.disconnecting + Status.DISCONNECTED -> TextRef.Literal("") } } } \ No newline at end of file diff --git a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/rx/events/EventQueueChanged.kt b/core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/rx/events/EventQueueChanged.kt similarity index 100% rename from core/interfaces/src/main/kotlin/app/aaps/core/interfaces/rx/events/EventQueueChanged.kt rename to core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/rx/events/EventQueueChanged.kt diff --git a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/rx/events/EventRefreshButtonState.kt b/core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/rx/events/EventRefreshButtonState.kt similarity index 71% rename from core/interfaces/src/main/kotlin/app/aaps/core/interfaces/rx/events/EventRefreshButtonState.kt rename to core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/rx/events/EventRefreshButtonState.kt index aa59de9db86c..74f492cac98e 100644 --- a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/rx/events/EventRefreshButtonState.kt +++ b/core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/rx/events/EventRefreshButtonState.kt @@ -5,4 +5,4 @@ package app.aaps.core.interfaces.rx.events * * @param newState The new state of the button (e.g., enabled/disabled). */ -class EventRefreshButtonState(val newState: Boolean) : Event() \ No newline at end of file +data class EventRefreshButtonState(val newState: Boolean) : Event() \ No newline at end of file diff --git a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/rx/events/EventRefreshOverview.kt b/core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/rx/events/EventRefreshOverview.kt similarity index 72% rename from core/interfaces/src/main/kotlin/app/aaps/core/interfaces/rx/events/EventRefreshOverview.kt rename to core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/rx/events/EventRefreshOverview.kt index 40715eba2665..a54057916198 100644 --- a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/rx/events/EventRefreshOverview.kt +++ b/core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/rx/events/EventRefreshOverview.kt @@ -6,4 +6,4 @@ package app.aaps.core.interfaces.rx.events * @param from A string indicating the source of the refresh request. * @param now If true, the refresh should happen immediately. */ -class EventRefreshOverview(var from: String, val now: Boolean = false) : Event() \ No newline at end of file +data class EventRefreshOverview(var from: String, val now: Boolean = false) : Event() \ No newline at end of file diff --git a/core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/rx/events/EventSWRLStatus.kt b/core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/rx/events/EventSWRLStatus.kt new file mode 100644 index 000000000000..4a5357dddc71 --- /dev/null +++ b/core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/rx/events/EventSWRLStatus.kt @@ -0,0 +1,13 @@ +package app.aaps.core.interfaces.rx.events + +import app.aaps.core.keys.interfaces.TextRef + +/** + * Fired to update the setup wizard with the RileyLink status. + * + * @param status The RileyLink status message. + */ +data class EventSWRLStatus(val status: String) : EventStatus() { + + override fun getStatus(): TextRef = TextRef.Literal(status) +} \ No newline at end of file diff --git a/core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/rx/events/EventSWSyncStatus.kt b/core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/rx/events/EventSWSyncStatus.kt new file mode 100644 index 000000000000..c9cf253f7fef --- /dev/null +++ b/core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/rx/events/EventSWSyncStatus.kt @@ -0,0 +1,13 @@ +package app.aaps.core.interfaces.rx.events + +import app.aaps.core.keys.interfaces.TextRef + +/** + * Fired to update the setup wizard with the sync status. + * + * @param status The sync status message. + */ +data class EventSWSyncStatus(val status: String) : EventStatus() { + + override fun getStatus(): TextRef = TextRef.Literal(status) +} \ No newline at end of file diff --git a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/rx/events/EventSWUpdate.kt b/core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/rx/events/EventSWUpdate.kt similarity index 73% rename from core/interfaces/src/main/kotlin/app/aaps/core/interfaces/rx/events/EventSWUpdate.kt rename to core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/rx/events/EventSWUpdate.kt index 5a9999affbfb..eb96d4051a0c 100644 --- a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/rx/events/EventSWUpdate.kt +++ b/core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/rx/events/EventSWUpdate.kt @@ -5,4 +5,4 @@ package app.aaps.core.interfaces.rx.events * * @param redraw If true, forces a redraw of the wizard. */ -class EventSWUpdate(var redraw: Boolean) : Event() \ No newline at end of file +data class EventSWUpdate(var redraw: Boolean) : Event() \ No newline at end of file diff --git a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/rx/events/EventShowDialog.kt b/core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/rx/events/EventShowDialog.kt similarity index 100% rename from core/interfaces/src/main/kotlin/app/aaps/core/interfaces/rx/events/EventShowDialog.kt rename to core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/rx/events/EventShowDialog.kt diff --git a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/rx/events/EventShowSnackbar.kt b/core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/rx/events/EventShowSnackbar.kt similarity index 96% rename from core/interfaces/src/main/kotlin/app/aaps/core/interfaces/rx/events/EventShowSnackbar.kt rename to core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/rx/events/EventShowSnackbar.kt index 3ac39ec9a7df..df968aa80820 100644 --- a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/rx/events/EventShowSnackbar.kt +++ b/core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/rx/events/EventShowSnackbar.kt @@ -13,7 +13,7 @@ package app.aaps.core.interfaces.rx.events * @param key Optional dedup key. If set, rapid duplicates with the same * key collapse so retry loops don't flood the host. */ -class EventShowSnackbar( +data class EventShowSnackbar( val message: String, val type: Type = Type.Info, val key: String? = null diff --git a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/rx/events/EventStatus.kt b/core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/rx/events/EventStatus.kt similarity index 57% rename from core/interfaces/src/main/kotlin/app/aaps/core/interfaces/rx/events/EventStatus.kt rename to core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/rx/events/EventStatus.kt index c45ea1c0f1a2..33058b4d1e47 100644 --- a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/rx/events/EventStatus.kt +++ b/core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/rx/events/EventStatus.kt @@ -1,6 +1,6 @@ package app.aaps.core.interfaces.rx.events -import android.content.Context +import app.aaps.core.keys.interfaces.TextRef /** * Base class for events that carry a status message used in UI updates @@ -10,8 +10,7 @@ abstract class EventStatus : Event() { /** * Gets the status message. * - * @param context The context. - * @return The status message string. + * @return The status message, as a platform neutral reference. */ - abstract fun getStatus(context: Context): String + abstract fun getStatus(): TextRef } \ No newline at end of file diff --git a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/rx/events/EventUpdateGui.kt b/core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/rx/events/EventUpdateGui.kt similarity index 100% rename from core/interfaces/src/main/kotlin/app/aaps/core/interfaces/rx/events/EventUpdateGui.kt rename to core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/rx/events/EventUpdateGui.kt diff --git a/core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/rx/events/EventUpdateOverviewCalcProgress.kt b/core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/rx/events/EventUpdateOverviewCalcProgress.kt new file mode 100644 index 000000000000..1cf49b4af2f8 --- /dev/null +++ b/core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/rx/events/EventUpdateOverviewCalcProgress.kt @@ -0,0 +1,3 @@ +package app.aaps.core.interfaces.rx.events + +data class EventUpdateOverviewCalcProgress(val from: String) : Event() \ No newline at end of file diff --git a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/rx/events/EventUpdateSelectedWatchface.kt b/core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/rx/events/EventUpdateSelectedWatchface.kt similarity index 100% rename from core/interfaces/src/main/kotlin/app/aaps/core/interfaces/rx/events/EventUpdateSelectedWatchface.kt rename to core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/rx/events/EventUpdateSelectedWatchface.kt diff --git a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/rx/events/EventWearDataToMobile.kt b/core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/rx/events/EventWearDataToMobile.kt similarity index 79% rename from core/interfaces/src/main/kotlin/app/aaps/core/interfaces/rx/events/EventWearDataToMobile.kt rename to core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/rx/events/EventWearDataToMobile.kt index 02415db3507f..44977b5343d7 100644 --- a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/rx/events/EventWearDataToMobile.kt +++ b/core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/rx/events/EventWearDataToMobile.kt @@ -8,4 +8,4 @@ import app.aaps.core.interfaces.rx.weardata.EventData * * @param payload The data to send. */ -class EventWearDataToMobile(val payload: EventData) : Event() \ No newline at end of file +data class EventWearDataToMobile(val payload: EventData) : Event() \ No newline at end of file diff --git a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/rx/events/EventWearToMobile.kt b/core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/rx/events/EventWearToMobile.kt similarity index 77% rename from core/interfaces/src/main/kotlin/app/aaps/core/interfaces/rx/events/EventWearToMobile.kt rename to core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/rx/events/EventWearToMobile.kt index 7fb2628361af..479e6ef551e6 100644 --- a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/rx/events/EventWearToMobile.kt +++ b/core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/rx/events/EventWearToMobile.kt @@ -7,4 +7,4 @@ import app.aaps.core.interfaces.rx.weardata.EventData * * @param payload The data to send. */ -class EventWearToMobile(val payload: EventData) : Event() \ No newline at end of file +data class EventWearToMobile(val payload: EventData) : Event() \ No newline at end of file diff --git a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/rx/events/EventWearUpdateGui.kt b/core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/rx/events/EventWearUpdateGui.kt similarity index 73% rename from core/interfaces/src/main/kotlin/app/aaps/core/interfaces/rx/events/EventWearUpdateGui.kt rename to core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/rx/events/EventWearUpdateGui.kt index 8428dbe15466..d2fe195a9952 100644 --- a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/rx/events/EventWearUpdateGui.kt +++ b/core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/rx/events/EventWearUpdateGui.kt @@ -8,4 +8,4 @@ import app.aaps.core.interfaces.rx.weardata.CwfData * @param customWatchfaceData Data for a custom watchface, or null if not applicable. * @param exportFile If true, the data should be exported to a file. */ -class EventWearUpdateGui(val customWatchfaceData: CwfData? = null, val exportFile: Boolean = false) : Event() \ No newline at end of file +data class EventWearUpdateGui(val customWatchfaceData: CwfData? = null, val exportFile: Boolean = false) : Event() \ No newline at end of file diff --git a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/rx/events/EventWearUpdateTiles.kt b/core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/rx/events/EventWearUpdateTiles.kt similarity index 100% rename from core/interfaces/src/main/kotlin/app/aaps/core/interfaces/rx/events/EventWearUpdateTiles.kt rename to core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/rx/events/EventWearUpdateTiles.kt diff --git a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/rx/weardata/CwfData.kt b/core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/rx/weardata/CwfData.kt similarity index 100% rename from core/interfaces/src/main/kotlin/app/aaps/core/interfaces/rx/weardata/CwfData.kt rename to core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/rx/weardata/CwfData.kt diff --git a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/rx/weardata/CwfFile.kt b/core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/rx/weardata/CwfFile.kt similarity index 100% rename from core/interfaces/src/main/kotlin/app/aaps/core/interfaces/rx/weardata/CwfFile.kt rename to core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/rx/weardata/CwfFile.kt diff --git a/core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/rx/weardata/CwfMetaDataKey.kt b/core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/rx/weardata/CwfMetaDataKey.kt new file mode 100644 index 000000000000..24c797b4344d --- /dev/null +++ b/core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/rx/weardata/CwfMetaDataKey.kt @@ -0,0 +1,43 @@ +package app.aaps.core.interfaces.rx.weardata + +import app.aaps.core.interfaces.InterfacesStrings +import app.aaps.core.keys.interfaces.TextRef + +enum class CwfMetadataKey(val key: String, val label: TextRef, val isPref: Boolean) { + + CWF_NAME("name", InterfacesStrings.metadata_label_watchface_name, false), + CWF_FILENAME("filename", InterfacesStrings.metadata_wear_import_filename, false), + CWF_AUTHOR("author", InterfacesStrings.metadata_label_watchface_author, false), + CWF_CREATED_AT("created_at", InterfacesStrings.metadata_label_watchface_created_at, false), + CWF_VERSION("cwf_version", InterfacesStrings.metadata_label_plugin_version, false), + CWF_AUTHOR_VERSION("author_version", InterfacesStrings.metadata_label_watchface_name_version, false), + CWF_COMMENT("comment", InterfacesStrings.metadata_label_watchface_infos, false), + CWF_AUTHORIZATION("cwf_authorization", InterfacesStrings.metadata_label_watchface_authorization, false), + CWF_PREF_WATCH_SHOW_DETAILED_IOB("key_show_detailed_iob", InterfacesStrings.pref_show_detailed_iob, true), + CWF_PREF_WATCH_SHOW_DETAILED_DELTA("key_show_detailed_delta", InterfacesStrings.pref_show_detailed_delta, true), + CWF_PREF_WATCH_SHOW_BGI("key_show_bgi", InterfacesStrings.pref_show_bgi, true), + CWF_PREF_WATCH_SHOW_IOB("key_show_iob", InterfacesStrings.pref_show_iob, true), + CWF_PREF_WATCH_SHOW_COB("key_show_cob", InterfacesStrings.pref_show_cob, true), + CWF_PREF_WATCH_SHOW_DELTA("key_show_delta", InterfacesStrings.pref_show_delta, true), + CWF_PREF_WATCH_SHOW_AVG_DELTA("key_show_avg_delta", InterfacesStrings.pref_show_avgdelta, true), + CWF_PREF_WATCH_SHOW_TEMP_TARGET("key_show_temp_target", InterfacesStrings.pref_show_tempTarget, true), + CWF_PREF_WATCH_SHOW_RESERVOIR_LEVEL("key_show_reservoir_level", InterfacesStrings.pref_show_reservoir_level, true), + CWF_PREF_WATCH_SHOW_UPLOADER_BATTERY("key_show_uploader_battery", InterfacesStrings.pref_show_phone_battery, true), + CWF_PREF_WATCH_SHOW_RIG_BATTERY("key_show_rig_battery", InterfacesStrings.pref_show_rig_battery, true), + CWF_PREF_WATCH_SHOW_TEMP_BASAL("key_show_temp_basal", InterfacesStrings.pref_show_basal_rate, true), + CWF_PREF_WATCH_SHOW_DIRECTION("key_show_direction", InterfacesStrings.pref_show_direction_arrow, true), + CWF_PREF_WATCH_SHOW_AGO("key_show_ago", InterfacesStrings.pref_show_ago, true), + CWF_PREF_WATCH_SHOW_BG("key_show_bg", InterfacesStrings.pref_show_bg, true), + CWF_PREF_WATCH_SHOW_LOOP_STATUS("key_show_loop_status", InterfacesStrings.pref_show_loop_status, true), + CWF_PREF_WATCH_SHOW_WEEK_NUMBER("key_show_week_number", InterfacesStrings.pref_show_week_number, true), + CWF_PREF_WATCH_SHOW_DATE("key_show_date", InterfacesStrings.pref_show_date, true), + CWF_PREF_WATCH_SHOW_SECONDS("key_show_seconds", InterfacesStrings.pref_show_seconds, true); + + companion object { + + fun fromKey(key: String): CwfMetadataKey? = + entries.firstOrNull { it.key == key } + } +} + +typealias CwfMetadataMap = MutableMap diff --git a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/rx/weardata/EventData.kt b/core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/rx/weardata/EventData.kt similarity index 97% rename from core/interfaces/src/main/kotlin/app/aaps/core/interfaces/rx/weardata/EventData.kt rename to core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/rx/weardata/EventData.kt index 25686b653f70..67d3ea72f761 100644 --- a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/rx/weardata/EventData.kt +++ b/core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/rx/weardata/EventData.kt @@ -5,8 +5,8 @@ import kotlinx.serialization.ExperimentalSerializationApi import kotlinx.serialization.Serializable import kotlinx.serialization.json.Json import kotlinx.serialization.protobuf.ProtoBuf -import java.util.Date -import java.util.Objects +import kotlin.time.Clock +import kotlin.time.Instant @Serializable sealed class EventData : Event() { @@ -29,14 +29,14 @@ sealed class EventData : Event() { fun deserialize(json: String) = try { lenientJson.decodeFromString(serializer(), json) } catch (_: Exception) { - Error(System.currentTimeMillis()) + Error(Clock.System.now().toEpochMilliseconds()) } @ExperimentalSerializationApi fun deserializeByte(byteArray: ByteArray) = try { ProtoBuf.decodeFromByteArray(serializer(), byteArray) } catch (_: Exception) { - Error(System.currentTimeMillis()) + Error(Clock.System.now().toEpochMilliseconds()) } } @@ -63,7 +63,7 @@ sealed class EventData : Event() { } override fun hashCode(): Int { - return Objects.hash(timeStamp, fingerprint) + return 31 * timeStamp.hashCode() + fingerprint.hashCode() } } @@ -163,7 +163,7 @@ sealed class EventData : Event() { ) : EventData() { override fun toString() = - "HR ${beatsPerMinute.toInt()} at ${Date(timestamp)} for ${duration / 1000.0}sec $device" + "HR ${beatsPerMinute.toInt()} at ${Instant.fromEpochMilliseconds(timestamp)} for ${duration / 1000.0}sec $device" } @Serializable @@ -180,7 +180,7 @@ sealed class EventData : Event() { ) : EventData() { override fun toString() = - "STEPS 5min: $steps5min, 10min: $steps10min, 15min: $steps15min, 30min: $steps30min, 60min: $steps60min, 180min: $steps180min at ${Date(timestamp)} for ${duration / 1000.0}sec $device" + "STEPS 5min: $steps5min, 10min: $steps10min, 15min: $steps15min, 30min: $steps30min, 60min: $steps60min, 180min: $steps180min at ${Instant.fromEpochMilliseconds(timestamp)} for ${duration / 1000.0}sec $device" } @Serializable @@ -310,7 +310,7 @@ sealed class EventData : Event() { } override fun hashCode(): Int { - return Objects.hash(timeStamp, color) + return 31 * timeStamp.hashCode() + color.hashCode() } override fun compareTo(other: SingleBg): Int { diff --git a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/rx/weardata/LoopStatusData.kt b/core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/rx/weardata/LoopStatusData.kt similarity index 99% rename from core/interfaces/src/main/kotlin/app/aaps/core/interfaces/rx/weardata/LoopStatusData.kt rename to core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/rx/weardata/LoopStatusData.kt index 2beb8e3183b4..cbb78f2cfde5 100644 --- a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/rx/weardata/LoopStatusData.kt +++ b/core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/rx/weardata/LoopStatusData.kt @@ -16,8 +16,10 @@ data class LoopStatusData( /** End time (epoch ms) of a temporary running mode (suspend/disconnect/superbolus), null when the mode is permanent */ val modeEndTime: Long? = null ) { + @Serializable enum class LoopMode { + CLOSED, OPEN, LGS, diff --git a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/rx/weardata/ResData.kt b/core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/rx/weardata/ResData.kt similarity index 100% rename from core/interfaces/src/main/kotlin/app/aaps/core/interfaces/rx/weardata/ResData.kt rename to core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/rx/weardata/ResData.kt diff --git a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/rx/weardata/ResFormat.kt b/core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/rx/weardata/ResFormat.kt similarity index 100% rename from core/interfaces/src/main/kotlin/app/aaps/core/interfaces/rx/weardata/ResFormat.kt rename to core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/rx/weardata/ResFormat.kt diff --git a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/scenes/ActiveSceneSync.kt b/core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/scenes/ActiveSceneSync.kt similarity index 100% rename from core/interfaces/src/main/kotlin/app/aaps/core/interfaces/scenes/ActiveSceneSync.kt rename to core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/scenes/ActiveSceneSync.kt diff --git a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/scenes/ClientControlSendResult.kt b/core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/scenes/ClientControlSendResult.kt similarity index 100% rename from core/interfaces/src/main/kotlin/app/aaps/core/interfaces/scenes/ClientControlSendResult.kt rename to core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/scenes/ClientControlSendResult.kt diff --git a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/scenes/SceneActions.kt b/core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/scenes/SceneActions.kt similarity index 100% rename from core/interfaces/src/main/kotlin/app/aaps/core/interfaces/scenes/SceneActions.kt rename to core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/scenes/SceneActions.kt diff --git a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/scenes/SceneAutomationApi.kt b/core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/scenes/SceneAutomationApi.kt similarity index 100% rename from core/interfaces/src/main/kotlin/app/aaps/core/interfaces/scenes/SceneAutomationApi.kt rename to core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/scenes/SceneAutomationApi.kt diff --git a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/scenes/SceneChainResolver.kt b/core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/scenes/SceneChainResolver.kt similarity index 100% rename from core/interfaces/src/main/kotlin/app/aaps/core/interfaces/scenes/SceneChainResolver.kt rename to core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/scenes/SceneChainResolver.kt diff --git a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/scenes/SceneIconResolver.kt b/core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/scenes/SceneIconResolver.kt similarity index 100% rename from core/interfaces/src/main/kotlin/app/aaps/core/interfaces/scenes/SceneIconResolver.kt rename to core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/scenes/SceneIconResolver.kt diff --git a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/scenes/SceneStore.kt b/core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/scenes/SceneStore.kt similarity index 100% rename from core/interfaces/src/main/kotlin/app/aaps/core/interfaces/scenes/SceneStore.kt rename to core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/scenes/SceneStore.kt diff --git a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/scenes/Scenes.kt b/core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/scenes/Scenes.kt similarity index 100% rename from core/interfaces/src/main/kotlin/app/aaps/core/interfaces/scenes/Scenes.kt rename to core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/scenes/Scenes.kt diff --git a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/smoothing/Smoothing.kt b/core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/smoothing/Smoothing.kt similarity index 100% rename from core/interfaces/src/main/kotlin/app/aaps/core/interfaces/smoothing/Smoothing.kt rename to core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/smoothing/Smoothing.kt diff --git a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/source/BgSource.kt b/core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/source/BgSource.kt similarity index 100% rename from core/interfaces/src/main/kotlin/app/aaps/core/interfaces/source/BgSource.kt rename to core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/source/BgSource.kt diff --git a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/source/DexcomBoyda.kt b/core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/source/DexcomBoyda.kt similarity index 100% rename from core/interfaces/src/main/kotlin/app/aaps/core/interfaces/source/DexcomBoyda.kt rename to core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/source/DexcomBoyda.kt diff --git a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/source/NSClientSource.kt b/core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/source/NSClientSource.kt similarity index 100% rename from core/interfaces/src/main/kotlin/app/aaps/core/interfaces/source/NSClientSource.kt rename to core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/source/NSClientSource.kt diff --git a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/source/XDripSource.kt b/core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/source/XDripSource.kt similarity index 100% rename from core/interfaces/src/main/kotlin/app/aaps/core/interfaces/source/XDripSource.kt rename to core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/source/XDripSource.kt diff --git a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/stats/DexcomTIR.kt b/core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/stats/DexcomTIR.kt similarity index 63% rename from core/interfaces/src/main/kotlin/app/aaps/core/interfaces/stats/DexcomTIR.kt rename to core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/stats/DexcomTIR.kt index e6fdef1587d5..386f6f38c2fe 100644 --- a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/stats/DexcomTIR.kt +++ b/core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/stats/DexcomTIR.kt @@ -1,10 +1,5 @@ package app.aaps.core.interfaces.stats -import android.content.Context -import android.widget.TableRow -import android.widget.TextView -import app.aaps.core.interfaces.profile.ProfileUtil - /** * Interface for Dexcom-style Time In Range (TIR) statistics. * @@ -37,54 +32,6 @@ interface DexcomTIR { */ fun calculateSD(): Double - /** - * Creates an Android TextView displaying the estimated HbA1c. - * - * HbA1c is calculated from mean glucose using the formula: - * HbA1c (%) = (mean + 46.7) / 28.7 - * HbA1c (mmol/mol) = ((mean + 46.7) / 28.7 - 2.15) * 10.929 - * - * @param context Android context for creating the TextView - * @return TextView with formatted HbA1c value, or empty if no data - */ - fun toHbA1cView(context: Context): TextView - - /** - * Creates an Android TextView displaying the standard deviation. - * - * @param context Android context for creating the TextView - * @param profileUtil Utility for converting glucose values to user's preferred units - * @return TextView with formatted standard deviation value - */ - fun toSDView(context: Context, profileUtil: ProfileUtil): TextView - - /** - * Creates an Android TextView displaying the range headers with threshold values. - * - * Shows the 5 glucose ranges with their thresholds for both day and night periods. - * - * @param context Android context for creating the TextView - * @param profileUtil Utility for converting glucose values to user's preferred units - * @return TextView with formatted range headers - */ - fun toRangeHeaderView(context: Context, profileUtil: ProfileUtil): TextView - - /** - * Creates an Android TableRow with column headers for the TIR table. - * - * @param context Android context for creating the TableRow - * @return TableRow with headers: Very Low, Low, In Range, High, Very High - */ - fun toTableRowHeader(context: Context): TableRow - - /** - * Creates an Android TableRow with percentage values for each glucose range. - * - * @param context Android context for creating the TableRow - * @return TableRow with formatted percentage values for each range - */ - fun toTableRow(context: Context): TableRow - // Data accessors for Compose /** diff --git a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/stats/DexcomTirCalculator.kt b/core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/stats/DexcomTirCalculator.kt similarity index 100% rename from core/interfaces/src/main/kotlin/app/aaps/core/interfaces/stats/DexcomTirCalculator.kt rename to core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/stats/DexcomTirCalculator.kt diff --git a/core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/stats/TIR.kt b/core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/stats/TIR.kt new file mode 100644 index 000000000000..5a1a8e8deb2a --- /dev/null +++ b/core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/stats/TIR.kt @@ -0,0 +1,17 @@ +package app.aaps.core.interfaces.stats + +interface TIR { + + val date: Long + val lowThreshold: Double + val highThreshold: Double + var below: Int + var inRange: Int + var above: Int + var error: Int + var count: Int + fun error() + fun below() + fun inRange() + fun above() +} diff --git a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/stats/TddCalculator.kt b/core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/stats/TddCalculator.kt similarity index 100% rename from core/interfaces/src/main/kotlin/app/aaps/core/interfaces/stats/TddCalculator.kt rename to core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/stats/TddCalculator.kt diff --git a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/stats/TirCalculator.kt b/core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/stats/TirCalculator.kt similarity index 100% rename from core/interfaces/src/main/kotlin/app/aaps/core/interfaces/stats/TirCalculator.kt rename to core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/stats/TirCalculator.kt diff --git a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/sync/DataSyncSelector.kt b/core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/sync/DataSyncSelector.kt similarity index 96% rename from core/interfaces/src/main/kotlin/app/aaps/core/interfaces/sync/DataSyncSelector.kt rename to core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/sync/DataSyncSelector.kt index 11c428c1a290..31c3ea702c27 100644 --- a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/sync/DataSyncSelector.kt +++ b/core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/sync/DataSyncSelector.kt @@ -14,7 +14,7 @@ import app.aaps.core.data.model.RM import app.aaps.core.data.model.TB import app.aaps.core.data.model.TE import app.aaps.core.data.model.TT -import org.json.JSONObject +import kotlinx.serialization.json.JsonObject interface DataSyncSelector { @@ -38,7 +38,7 @@ interface DataSyncSelector { data class PairProfileSwitch(override val value: PS, override val id: Long, override var confirmed: Boolean = false) : DataPair data class PairEffectiveProfileSwitch(override val value: EPS, override val id: Long, override var confirmed: Boolean = false) : DataPair data class PairRunningMode(override val value: RM, override val id: Long, override var confirmed: Boolean = false) : DataPair - data class PairProfileStore(override val value: JSONObject, override val id: Long, override var confirmed: Boolean = false) : DataPair + data class PairProfileStore(override val value: JsonObject, override val id: Long, override var confirmed: Boolean = false) : DataPair data class PairDeviceStatus(override val value: DS, override val id: Long, override var confirmed: Boolean = false) : DataPair fun queueSize(): Long diff --git a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/sync/DataSyncSelectorXdrip.kt b/core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/sync/DataSyncSelectorXdrip.kt similarity index 100% rename from core/interfaces/src/main/kotlin/app/aaps/core/interfaces/sync/DataSyncSelectorXdrip.kt rename to core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/sync/DataSyncSelectorXdrip.kt diff --git a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/sync/NsClient.kt b/core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/sync/NsClient.kt similarity index 100% rename from core/interfaces/src/main/kotlin/app/aaps/core/interfaces/sync/NsClient.kt rename to core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/sync/NsClient.kt diff --git a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/sync/Sync.kt b/core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/sync/Sync.kt similarity index 100% rename from core/interfaces/src/main/kotlin/app/aaps/core/interfaces/sync/Sync.kt rename to core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/sync/Sync.kt diff --git a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/sync/Tidepool.kt b/core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/sync/Tidepool.kt similarity index 100% rename from core/interfaces/src/main/kotlin/app/aaps/core/interfaces/sync/Tidepool.kt rename to core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/sync/Tidepool.kt diff --git a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/sync/XDripBroadcast.kt b/core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/sync/XDripBroadcast.kt similarity index 100% rename from core/interfaces/src/main/kotlin/app/aaps/core/interfaces/sync/XDripBroadcast.kt rename to core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/sync/XDripBroadcast.kt diff --git a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/tempTargets/TempTargetPresetExtensions.kt b/core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/tempTargets/TempTargetPresetExtensions.kt similarity index 56% rename from core/interfaces/src/main/kotlin/app/aaps/core/interfaces/tempTargets/TempTargetPresetExtensions.kt rename to core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/tempTargets/TempTargetPresetExtensions.kt index 2b2782ced130..8db6dd490872 100644 --- a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/tempTargets/TempTargetPresetExtensions.kt +++ b/core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/tempTargets/TempTargetPresetExtensions.kt @@ -5,8 +5,15 @@ import app.aaps.core.data.model.TT import app.aaps.core.data.model.TTPreset import app.aaps.core.keys.StringNonKey import app.aaps.core.keys.interfaces.Preferences -import org.json.JSONArray -import org.json.JSONObject +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.addJsonObject +import kotlinx.serialization.json.buildJsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import kotlinx.serialization.json.put /** * Parse JSON string into a list of TTPreset. @@ -19,17 +26,17 @@ fun String.toTTPresets(): List { if (isEmpty() || this == "[]") { emptyList() } else { - val jsonArray = JSONArray(this) - (0 until jsonArray.length()).map { i -> - val obj = jsonArray.getJSONObject(i) - val reason = TT.Reason.fromString(obj.getString("reason")) + (Json.parseToJsonElement(this) as JsonArray).map { element -> + val obj = element.jsonObject + // `content` rather than the typed accessors so a quoted number still reads, which is + // what org.json's getDouble/getLong did and what documents in the wild contain. TTPreset( - id = obj.getString("id"), - name = if (obj.has("name") && !obj.isNull("name")) obj.getString("name") else null, - reason = reason, - targetValue = obj.getDouble("targetValue"), - duration = obj.getLong("duration"), - isDeletable = obj.getBoolean("isDeletable") + id = obj.getValue("id").jsonPrimitive.content, + name = (obj["name"] as? JsonPrimitive)?.takeUnless { it is JsonNull }?.content, + reason = TT.Reason.fromString(obj.getValue("reason").jsonPrimitive.content), + targetValue = obj.getValue("targetValue").jsonPrimitive.content.toDouble(), + duration = obj.getValue("duration").jsonPrimitive.content.toDouble().toLong(), + isDeletable = obj.getValue("isDeletable").jsonPrimitive.content.toBooleanStrict() ) } } @@ -42,21 +49,20 @@ fun String.toTTPresets(): List { * Convert a list of TTPreset to JSON string. * nameRes is NOT persisted — Android resource IDs change between builds. */ -fun List.toJson(): String { - val jsonArray = JSONArray() - forEach { preset -> - val obj = JSONObject().apply { - put("id", preset.id) - preset.name?.let { put("name", it) } - put("reason", preset.reason.text) - put("targetValue", preset.targetValue) - put("duration", preset.duration) - put("isDeletable", preset.isDeletable) +fun List.toJson(): String = + buildJsonArray { + this@toJson.forEach { preset -> + addJsonObject { + put("id", preset.id) + // Absent rather than null, as before - the reader treats the two the same either way. + preset.name?.let { put("name", it) } + put("reason", preset.reason.text) + put("targetValue", preset.targetValue) + put("duration", preset.duration) + put("isDeletable", preset.isDeletable) + } } - jsonArray.put(obj) - } - return jsonArray.toString() -} + }.toString() /** * Get all TT presets from preferences. diff --git a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/ui/IconsProvider.kt b/core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/ui/IconsProvider.kt similarity index 100% rename from core/interfaces/src/main/kotlin/app/aaps/core/interfaces/ui/IconsProvider.kt rename to core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/ui/IconsProvider.kt diff --git a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/ui/UiInteraction.kt b/core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/ui/UiInteraction.kt similarity index 65% rename from core/interfaces/src/main/kotlin/app/aaps/core/interfaces/ui/UiInteraction.kt rename to core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/ui/UiInteraction.kt index 5a6988a4e54e..973263bff777 100644 --- a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/ui/UiInteraction.kt +++ b/core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/ui/UiInteraction.kt @@ -1,26 +1,27 @@ package app.aaps.core.interfaces.ui -import androidx.annotation.RawRes +import app.aaps.core.interfaces.notifications.AlarmSound +import kotlin.reflect.KClass /** * Interface to use activities located in different modules - * usage: startActivity(Intent(context, activityNames.xxxx)) + * usage: startActivity(Intent(context, activityNames.xxxx.java)) */ interface UiInteraction { /** The main activity of the application. */ - val mainActivity: Class<*> + val mainActivity: KClass<*> /** The activity for displaying error information. */ - val errorHelperActivity: Class<*> + val errorHelperActivity: KClass<*> /** * Show ErrorHelperActivity and start alarm. * @param status message inside dialog * @param title title of dialog - * @param soundId sound resource. if == 0 alarm is not started + * @param sound alarm sound, or null for a silent alarm */ - fun runAlarm(status: String, title: String, @RawRes soundId: Int = 0) + fun runAlarm(status: String, title: String, sound: AlarmSound? = null) /** * Stops any currently playing alarm (cancels FSI + all sound notifications). diff --git a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/userEntry/UserEntryPresentationHelper.kt b/core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/userEntry/UserEntryPresentationHelper.kt similarity index 100% rename from core/interfaces/src/main/kotlin/app/aaps/core/interfaces/userEntry/UserEntryPresentationHelper.kt rename to core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/userEntry/UserEntryPresentationHelper.kt diff --git a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/utils/DateUtil.kt b/core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/utils/DateUtil.kt similarity index 94% rename from core/interfaces/src/main/kotlin/app/aaps/core/interfaces/utils/DateUtil.kt rename to core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/utils/DateUtil.kt index 6613a5d30f3c..06d6e84ccce0 100644 --- a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/utils/DateUtil.kt +++ b/core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/utils/DateUtil.kt @@ -1,7 +1,6 @@ package app.aaps.core.interfaces.utils -import app.aaps.core.interfaces.resources.ResourceHelper -import java.util.concurrent.TimeUnit +import app.aaps.core.interfaces.resources.TextResolver /** * The Class DateUtil. A modern utility class for handling dates, times, and durations using the `java.time` API. @@ -74,7 +73,7 @@ interface DateUtil { * @param rh A resource helper to get localized strings like "Today". * @return The relative date string. */ - fun dateStringRelative(mills: Long, rh: ResourceHelper): String + fun dateStringRelative(mills: Long, rh: TextResolver): String /** * Formats a timestamp into a short date string (e.g., "10/27" or "27/10") based on the user's 12/24 hour preference. @@ -255,7 +254,7 @@ interface DateUtil { * @param time The timestamp in milliseconds. Can be null. * @return The relative time string. */ - fun minAgo(rh: ResourceHelper, time: Long?): String + fun minAgo(rh: TextResolver, time: Long?): String /** * Returns a string describing how many minutes or seconds ago a timestamp was. @@ -264,7 +263,7 @@ interface DateUtil { * @param time The timestamp in milliseconds. Can be null. * @return The relative time string (e.g., "30 sec ago" or "3 min ago"). */ - fun minOrSecAgo(rh: ResourceHelper, time: Long?): String + fun minOrSecAgo(rh: TextResolver, time: Long?): String /** * Formats a duration into a short bracketed string with a leading '+', using seconds under @@ -273,7 +272,7 @@ interface DateUtil { * @param durationMs The duration in milliseconds. Negative values return an empty string. * @return The formatted duration string, or empty if negative. */ - fun minOrSec(rh: ResourceHelper, durationMs: Long): String + fun minOrSec(rh: TextResolver, durationMs: Long): String /** * Returns a short string showing the difference in minutes between now and a given time, with a sign. @@ -289,7 +288,7 @@ interface DateUtil { * @param time The timestamp in milliseconds. Can be null. * @return The verbose relative time string. */ - fun minAgoLong(rh: ResourceHelper, time: Long?): String + fun minAgoLong(rh: TextResolver, time: Long?): String /** * Returns a string describing how many hours ago a timestamp was. @@ -297,7 +296,7 @@ interface DateUtil { * @param rh Resource helper for localized strings. * @return The relative hours string. */ - fun hourAgo(time: Long, rh: ResourceHelper): String + fun hourAgo(time: Long, rh: TextResolver): String /** * Returns a string describing how many days ago (or in how many days) a timestamp is. @@ -306,7 +305,7 @@ interface DateUtil { * @param round If true, rounds to the nearest whole day. Otherwise uses fractional days. * @return The relative days string. */ - fun dayAgo(time: Long, rh: ResourceHelper, round: Boolean = false): String + fun dayAgo(time: Long, rh: TextResolver, round: Boolean = false): String /** * Calculates the timestamp for the beginning of the day (midnight) for a given timestamp. @@ -329,7 +328,7 @@ interface DateUtil { * @param withParentheses Whether to wrap the result in parentheses (e.g. "(30')" vs "30'"). Defaults to true. * @return A formatted duration string like "(1h 30')". */ - fun timeFrameString(timeInMillis: Long, rh: ResourceHelper, withParentheses: Boolean = true): String + fun timeFrameString(timeInMillis: Long, rh: TextResolver, withParentheses: Boolean = true): String /** * Calculates the elapsed time since a given timestamp and formats it as a duration. @@ -337,7 +336,7 @@ interface DateUtil { * @param rh Resource helper. * @return A formatted duration string of the elapsed time. */ - fun sinceString(timestamp: Long, rh: ResourceHelper): String + fun sinceString(timestamp: Long, rh: TextResolver): String /** * Calculates the time remaining until a future timestamp and formats it as a duration. @@ -346,7 +345,7 @@ interface DateUtil { * @param withParentheses Whether to wrap the result in parentheses (e.g. "(30')" vs "30'"). Defaults to true. * @return A formatted duration string of the remaining time. */ - fun untilString(timestamp: Long, rh: ResourceHelper, withParentheses: Boolean = true): String + fun untilString(timestamp: Long, rh: TextResolver, withParentheses: Boolean = true): String /** * Formats a remaining duration as a localized "time remaining" string. @@ -357,7 +356,7 @@ interface DateUtil { * @param rh Resource helper for the localized format string. * @return The formatted "time remaining" string. */ - fun timeRemainingString(timeInMillis: Long, rh: ResourceHelper): String + fun timeRemainingString(timeInMillis: Long, rh: TextResolver): String /** * Gets the current system time in milliseconds. @@ -430,7 +429,7 @@ interface DateUtil { * @return A map containing the total number of full days, leftover hours, and leftover minutes. */ //Map:{DAYS=1, HOURS=3, MINUTES=46, SECONDS=40, MILLISECONDS=0, MICROSECONDS=0, NANOSECONDS=0} - fun computeDiff(date1: Long, date2: Long): Map + fun computeDiff(date1: Long, date2: Long): TimeDiff /** * Converts a duration in milliseconds into a human-readable "age" string (e.g., "5 days 3 hours"). @@ -439,9 +438,7 @@ interface DateUtil { * @param rh Resource helper to get localized unit strings. * @return The formatted age string. */ - fun age(milliseconds: Long, useShortText: Boolean, rh: ResourceHelper): String - fun timeAgoFullString(milliseconds: Long, rh: ResourceHelper): String - + fun age(milliseconds: Long, useShortText: Boolean, rh: TextResolver): String /** * Converts a duration in milliseconds into a simplified, human-readable string with the largest appropriate unit. @@ -450,7 +447,8 @@ interface DateUtil { * @param rh Resource helper to get localized unit strings (e.g., "second", "seconds"). * @return The formatted string with a single unit (e.g., "5 days"). */ - fun niceTimeScalar(time: Long, rh: ResourceHelper): String + fun niceTimeScalar(time: Long, rh: TextResolver): String + /** * A thread-safe, locale-agnostic utility to format a double into a string with a specific number of decimal digits. * It is optimized to use a cached formatter on the UI thread. diff --git a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/utils/DecimalFormatter.kt b/core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/utils/DecimalFormatter.kt similarity index 100% rename from core/interfaces/src/main/kotlin/app/aaps/core/interfaces/utils/DecimalFormatter.kt rename to core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/utils/DecimalFormatter.kt diff --git a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/utils/HardLimits.kt b/core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/utils/HardLimits.kt similarity index 98% rename from core/interfaces/src/main/kotlin/app/aaps/core/interfaces/utils/HardLimits.kt rename to core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/utils/HardLimits.kt index abb1a7804486..7506179c141a 100644 --- a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/utils/HardLimits.kt +++ b/core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/utils/HardLimits.kt @@ -73,8 +73,8 @@ interface HardLimits { //No IOB at all const val MAX_IOB_LGS = 0.0 - const val MAX_CARBS_DURATION_HOURS = 10L - const val MAX_CARBS = 400 + const val MAX_CARBS_DURATION_HOURS = 10L + const val MAX_CARBS = 400 } fun maxBolus(): Double diff --git a/core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/utils/MidnightTime.kt b/core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/utils/MidnightTime.kt new file mode 100644 index 000000000000..d0008d36e256 --- /dev/null +++ b/core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/utils/MidnightTime.kt @@ -0,0 +1,74 @@ +package app.aaps.core.interfaces.utils + +import app.aaps.core.interfaces.utils.MidnightTime.calc +import kotlinx.datetime.DateTimeUnit +import kotlinx.datetime.LocalDateTime +import kotlinx.datetime.LocalTime +import kotlinx.datetime.TimeZone +import kotlinx.datetime.atStartOfDayIn +import kotlinx.datetime.minus +import kotlinx.datetime.toInstant +import kotlinx.datetime.toLocalDateTime +import kotlin.time.Clock +import kotlin.time.Instant + +object MidnightTime { + + private fun zone() = TimeZone.currentSystemDefault() + + /** + * Epoch time of last midnight + * + * @return epoch millis + */ + fun calc(): Long = calc(Clock.System.now().toEpochMilliseconds()) + + /** + * Today's time with 'minutes' from midnight + * + * @param minutes minutes to add + * @return epoch millis of today with hh:mm:00 + */ + fun calcMidnightPlusMinutes(minutes: Int): Long { + val h = (minutes / 60) % 24 + val m = minutes % 60 + val tz = zone() + val date = Clock.System.now().toLocalDateTime(tz).date + return LocalDateTime(date, LocalTime(h, m)).toInstant(tz).toEpochMilliseconds() + } + + /** + * Epoch time of last midnight before 'time' + * + * @param time time of the day + * @return epoch millis + */ + fun calc(time: Long): Long { + val tz = zone() + return Instant.fromEpochMilliseconds(time).toLocalDateTime(tz).date.atStartOfDayIn(tz).toEpochMilliseconds() + } + + /** + * Epoch time of last midnight 'days' back + * + * @param daysBack how many days back + * @return epoch millis of midnight + */ + fun calcDaysBack(daysBack: Long): Long = calcDaysBack(Clock.System.now().toEpochMilliseconds(), daysBack) + + /** + * Epoch time of last midnight 'days' back from time + * + * @param time start time + * @param daysBack how many days back + * @return epoch millis of midnight + */ + fun calcDaysBack(time: Long, daysBack: Long): Long { + val tz = zone() + // Subtract whole calendar days on the DATE, then take the start of that day, so a daylight + // saving change inside the range shifts the result by the offset rather than by a fixed + // number of milliseconds. + val date = Instant.fromEpochMilliseconds(time).toLocalDateTime(tz).date.minus(daysBack.toInt(), DateTimeUnit.DAY) + return date.atStartOfDayIn(tz).toEpochMilliseconds() + } +} diff --git a/core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/utils/Round.kt b/core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/utils/Round.kt new file mode 100644 index 000000000000..55a475b2f737 --- /dev/null +++ b/core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/utils/Round.kt @@ -0,0 +1,84 @@ +package app.aaps.core.interfaces.utils + +import kotlin.math.abs +import kotlin.math.ceil +import kotlin.math.floor +import kotlin.math.pow +import kotlin.math.roundToLong + +/** + * Created by mike on 20.06.2016. + */ +object Round { + + fun roundTo(x: Double, step: Double): Double { + // Was java.security.InvalidParameterException, which is a JCA class and not what argument + // checking is for. Nothing caught it - every use of it in this repo is a throw - so the type + // change reaches no handler. + require(!x.isNaN()) { "Parameter is NaN" } + return if (x == 0.0) 0.0 + else times(x = (x / step).roundToLong(), step = step) + } + + fun floorTo(x: Double, step: Double): Double { + if (x == 0.0) return 0.0 + val q = x / step + // IEEE-754: dividing an on-grid value can land a hair BELOW the integer + // (0.15 / 0.05 == 2.9999999999999996), so a naive floor() drops a whole step. + // Snap to the grid when the quotient is within tolerance of an integer, else floor. + // This never pushes the result above x, so the "must not deliver more than asked / + // must not exceed the max constraint" invariant in SafetyPlugin still holds. + val n = if (abs(q - q.roundToLong()) < 1e-9) q.roundToLong() else floor(q).toLong() + return times(x = n, step = step) + } + + fun ceilTo(x: Double, step: Double): Double { + if (x == 0.0) return 0.0 + val q = x / step + // IEEE-754 mirror of floorTo: dividing an on-grid value can land a hair ABOVE the integer + // (0.07 / 0.01 == 7.000000000000000888), so a naive ceil() adds a whole step. Snap to the + // grid when the quotient is within tolerance of an integer, else ceil. Never pushes the + // result below x, so callers that round UP to stay within a limit keep that guarantee. + val n = if (abs(q - q.roundToLong()) < 1e-9) q.roundToLong() else ceil(q).toLong() + return times(x = n, step = step) + } + + /** + * `x * step`, giving the [Double] nearest to the **decimal** product. + * + * A plain `x * step` is not good enough here: `3 * 0.05` is `0.15000000000000002` and + * `12 * 0.05` is `0.6000000000000001`. Those are dose and rate values, so the extra digits + * travel into pump commands, comparisons and the Nightscout payload. + * + * This used to be `BigDecimal.valueOf(x).multiply(BigDecimal.valueOf(step)).toDouble()`, which is + * JVM only. The same answer comes out of integer arithmetic: `BigDecimal.valueOf(step)` is exactly + * the decimal that `step.toString()` spells, so reading that text back as `unscaled / 10^scale` + * loses nothing, the multiply is an exact [Long] one, and the single closing division is correctly + * rounded. So the result is the nearest double to the true decimal product - the same value the + * old code produced, by construction rather than by approximation. + * + * Limit worth knowing, which BigDecimal did not have: the product is exact while it stays under + * 2^53 (about 9.0e15), and it is roughly `|x * step| * 10^scale`. At the deepest step any pump + * uses (0.0001, so scale 4) that allows values up to about 9e11. Doses, rates and glucose values + * are far below it, so this is a note rather than a guard - a hard check here could stop the loop + * over an input that is merely large. + */ + private fun times(x: Long, step: Double): Double { + // Shortest round-trip text, the same source BigDecimal.valueOf(double) reads. Below 1e-3 it + // arrives in scientific notation ("1.0E-4" for 0.0001, a step that is really used), so the + // exponent has to be folded into the scale rather than assumed absent. + val text = step.toString() + val e = text.indexOf('E') + val mantissa = if (e < 0) text else text.substring(0, e) + val exponent = if (e < 0) 0 else text.substring(e + 1).toInt() + val point = mantissa.indexOf('.') + val unscaled = (if (point < 0) mantissa else mantissa.substring(0, point) + mantissa.substring(point + 1)).toLong() + val scale = (if (point < 0) 0 else mantissa.length - point - 1) - exponent + + val product = x * unscaled + return if (scale >= 0) product / 10.0.pow(scale) else product * 10.0.pow(-scale) + } + + fun isSame(d1: Double, d2: Double): Boolean = + abs(d1 - d2) <= 0.000001 +} diff --git a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/utils/SafeParse.kt b/core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/utils/SafeParse.kt similarity index 100% rename from core/interfaces/src/main/kotlin/app/aaps/core/interfaces/utils/SafeParse.kt rename to core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/utils/SafeParse.kt diff --git a/core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/utils/TimeDiff.kt b/core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/utils/TimeDiff.kt new file mode 100644 index 000000000000..9e2341f6f909 --- /dev/null +++ b/core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/utils/TimeDiff.kt @@ -0,0 +1,23 @@ +package app.aaps.core.interfaces.utils + +/** + * A duration split into whole calendar-style components. + * + * Replaces the `Map` this used to be returned as. Two reasons, + * beyond `TimeUnit` being a JVM type that cannot go to commonMain: + * + * - every lookup was `diff[TimeUnit.DAYS] ?: 0`, which reads as if the key might be missing. It never + * was - the map was always built with all seven entries - so the elvis was dead code that hid a + * guarantee rather than expressing it. + * - only [days] and [hours] are actually read anywhere. The rest stay because they cost nothing and + * dropping them would be a separate decision. + */ +data class TimeDiff( + val days: Long, + val hours: Long, + val minutes: Long, + val seconds: Long, + val milliseconds: Long, + val microseconds: Long, + val nanoseconds: Long +) diff --git a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/utils/Translator.kt b/core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/utils/Translator.kt similarity index 100% rename from core/interfaces/src/main/kotlin/app/aaps/core/interfaces/utils/Translator.kt rename to core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/utils/Translator.kt diff --git a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/utils/TrendCalculator.kt b/core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/utils/TrendCalculator.kt similarity index 100% rename from core/interfaces/src/main/kotlin/app/aaps/core/interfaces/utils/TrendCalculator.kt rename to core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/utils/TrendCalculator.kt diff --git a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/versionChecker/VersionCheckerUtils.kt b/core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/versionChecker/VersionCheckerUtils.kt similarity index 100% rename from core/interfaces/src/main/kotlin/app/aaps/core/interfaces/versionChecker/VersionCheckerUtils.kt rename to core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/versionChecker/VersionCheckerUtils.kt diff --git a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/versionChecker/VersionDefinition.kt b/core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/versionChecker/VersionDefinition.kt similarity index 53% rename from core/interfaces/src/main/kotlin/app/aaps/core/interfaces/versionChecker/VersionDefinition.kt rename to core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/versionChecker/VersionDefinition.kt index b13a2c84220d..822ff16a6301 100644 --- a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/versionChecker/VersionDefinition.kt +++ b/core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/versionChecker/VersionDefinition.kt @@ -1,8 +1,8 @@ package app.aaps.core.interfaces.versionChecker -import org.json.JSONObject +import kotlinx.serialization.json.JsonObject fun interface VersionDefinition { - fun invoke(): JSONObject + fun invoke(): JsonObject } \ No newline at end of file diff --git a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/widget/WidgetUpdater.kt b/core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/widget/WidgetUpdater.kt similarity index 100% rename from core/interfaces/src/main/kotlin/app/aaps/core/interfaces/widget/WidgetUpdater.kt rename to core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/widget/WidgetUpdater.kt diff --git a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/workflow/CalculationSignals.kt b/core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/workflow/CalculationSignals.kt similarity index 100% rename from core/interfaces/src/main/kotlin/app/aaps/core/interfaces/workflow/CalculationSignals.kt rename to core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/workflow/CalculationSignals.kt diff --git a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/workflow/CalculationWorkflow.kt b/core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/workflow/CalculationWorkflow.kt similarity index 100% rename from core/interfaces/src/main/kotlin/app/aaps/core/interfaces/workflow/CalculationWorkflow.kt rename to core/interfaces/src/commonMain/kotlin/app/aaps/core/interfaces/workflow/CalculationWorkflow.kt diff --git a/core/interfaces/src/debug/kotlin/app/aaps/annotations/OpenForTesting.kt b/core/interfaces/src/debug/kotlin/app/aaps/annotations/OpenForTesting.kt deleted file mode 100644 index 7e97b21b6e69..000000000000 --- a/core/interfaces/src/debug/kotlin/app/aaps/annotations/OpenForTesting.kt +++ /dev/null @@ -1,15 +0,0 @@ -package app.aaps.annotations - -/** - * This is the actual annotation that makes the class open. Don't use it directly, only through [OpenForTesting] - * which has a NOOP replacement in production. - */ -@Target(AnnotationTarget.ANNOTATION_CLASS) -annotation class OpenClass - -/** - * Annotate a class with [OpenForTesting] if it should be extendable for testing. - */ -@OpenClass -@Target(AnnotationTarget.CLASS) -annotation class OpenForTesting \ No newline at end of file diff --git a/core/interfaces/src/iosMain/kotlin/app/aaps/core/interfaces/pump/BluetoothPermission.ios.kt b/core/interfaces/src/iosMain/kotlin/app/aaps/core/interfaces/pump/BluetoothPermission.ios.kt new file mode 100644 index 000000000000..17994560c587 --- /dev/null +++ b/core/interfaces/src/iosMain/kotlin/app/aaps/core/interfaces/pump/BluetoothPermission.ios.kt @@ -0,0 +1,10 @@ +package app.aaps.core.interfaces.pump + +import app.aaps.core.interfaces.plugin.PermissionGroup + +/** + * iOS has no runtime permission to request here. Bluetooth access is declared once in `Info.plist` + * (`NSBluetoothAlwaysUsageDescription`) and the system prompts on first use, so there is nothing for + * a plugin to list or for the permission screen to ask for. + */ +internal actual fun bluetoothPermissionGroup(): PermissionGroup? = null diff --git a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/insulin/ConcentrationType.kt b/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/insulin/ConcentrationType.kt deleted file mode 100644 index 1f602a8592b1..000000000000 --- a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/insulin/ConcentrationType.kt +++ /dev/null @@ -1,24 +0,0 @@ -package app.aaps.core.interfaces.insulin - -import androidx.annotation.StringRes -import app.aaps.core.data.model.ICfg -import app.aaps.core.interfaces.R -import app.aaps.core.interfaces.insulin.InsulinType.OREF_RAPID_ACTING -import app.aaps.core.interfaces.resources.ResourceHelper - -enum class ConcentrationType(val value: Double, @StringRes val label: Int) { - UNKNOWN(-1.0, R.string.unknown), - U10(0.1, R.string.u10), - U40(0.4, R.string.u40), - U50(0.5, R.string.u50), - U100(1.0, R.string.u100), - U200(2.0, R.string.u200), - U300(3.0, R.string.u300), - U500(5.0, R.string.u500); - - companion object { - - fun fromDouble(type: Double) = values().firstOrNull {it.value == type} ?:UNKNOWN - fun fromInt(type: Int) = values().firstOrNull {it.value * 100 == type.toDouble()} ?:UNKNOWN - } -} \ No newline at end of file diff --git a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/insulin/InsulinType.kt b/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/insulin/InsulinType.kt deleted file mode 100644 index c65bbe25fbbf..000000000000 --- a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/insulin/InsulinType.kt +++ /dev/null @@ -1,30 +0,0 @@ -package app.aaps.core.interfaces.insulin - -import androidx.annotation.StringRes -import app.aaps.core.data.model.ICfg -import app.aaps.core.interfaces.R -import app.aaps.core.interfaces.resources.ResourceHelper - -enum class InsulinType(val value: Int, val insulinEndTime: Long, val insulinPeakTime: Long, @StringRes val label: Int, @StringRes val comment: Int) { - UNKNOWN(-1, 0, 0, R.string.unknown, R.string.unknown), - - // int FAST_ACTING_INSULIN = 0; // old model no longer available - // int FAST_ACTING_INSULIN_PROLONGED = 1; // old model no longer available - OREF_RAPID_ACTING(2, 8 * 3600 * 1000, 75 * 60000, R.string.rapid_acting_oref, R.string.fast_acting_insulin_comment), - OREF_ULTRA_RAPID_ACTING(3, 8 * 3600 * 1000, 55 * 60000, R.string.ultra_rapid_oref, R.string.ultra_fast_acting_insulin_comment), - OREF_FREE_PEAK(4, 8 * 3600 * 1000, 50 * 60000, R.string.free_peak_oref, R.string.insulin_peak_time), - OREF_LYUMJEV(5, 8 * 3600 * 1000, 45 * 60000, R.string.lyumjev, R.string.lyumjev); - - val iCfg: ICfg - get() = ICfg(this.name, insulinEndTime, insulinPeakTime, 1.0) - - /** Provide iCfg with a default friendly name on insulin creation from template */ - fun getICfg(rh: ResourceHelper): ICfg = ICfg(rh.gs(this.label), insulinEndTime, insulinPeakTime, 1.0) - - companion object { - - private val map = entries.associateBy(InsulinType::value) - fun fromInt(type: Int) = map[type] ?:OREF_RAPID_ACTING - fun fromPeak(insulinPeakTime: Long) = values().firstOrNull {it.insulinPeakTime == insulinPeakTime} ?:OREF_FREE_PEAK - } -} \ No newline at end of file diff --git a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/maintenance/PrefMetadata.kt b/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/maintenance/PrefMetadata.kt deleted file mode 100644 index 747a5c7eab66..000000000000 --- a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/maintenance/PrefMetadata.kt +++ /dev/null @@ -1,7 +0,0 @@ -package app.aaps.core.interfaces.maintenance - -import android.os.Parcelable -import kotlinx.parcelize.Parcelize - -@Parcelize -data class PrefMetadata(var value: String, var status: PrefsStatus, var info: String? = null) : Parcelable diff --git a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/maintenance/PrefsMetadataKey.kt b/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/maintenance/PrefsMetadataKey.kt deleted file mode 100644 index 2d7729c0d19d..000000000000 --- a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/maintenance/PrefsMetadataKey.kt +++ /dev/null @@ -1,12 +0,0 @@ -package app.aaps.core.interfaces.maintenance - -import android.content.Context -import androidx.compose.ui.graphics.vector.ImageVector - -interface PrefsMetadataKey { - - val key: String - val icon: ImageVector - val label: Int - fun formatForDisplay(context: Context, value: String): String -} \ No newline at end of file diff --git a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/nsclient/NSClientLog.kt b/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/nsclient/NSClientLog.kt deleted file mode 100644 index 8206fd655b14..000000000000 --- a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/nsclient/NSClientLog.kt +++ /dev/null @@ -1,19 +0,0 @@ -package app.aaps.core.interfaces.nsclient - -import kotlinx.serialization.json.JsonElement -import java.util.concurrent.atomic.AtomicLong - -class NSClientLog( - val action: String, - val logText: String? = null, - val json: JsonElement? = null -) { - - val date: Long = System.currentTimeMillis() - val id: Long = idCounter.getAndIncrement() - - companion object { - - private val idCounter = AtomicLong(0) - } -} diff --git a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/nsclient/NSSettingsStatus.kt b/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/nsclient/NSSettingsStatus.kt deleted file mode 100644 index eee956301c22..000000000000 --- a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/nsclient/NSSettingsStatus.kt +++ /dev/null @@ -1,12 +0,0 @@ -package app.aaps.core.interfaces.nsclient - -import org.json.JSONObject - -interface NSSettingsStatus { - - fun handleNewData(status: JSONObject) - fun getVersion(): String - fun extendedPumpSettings(setting: String?): Double - fun pumpExtendedSettingsFields(): String - fun getExtendedWarnValue(plugin: String, property: String): Double? -} \ No newline at end of file diff --git a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/profile/PureProfile.kt b/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/profile/PureProfile.kt deleted file mode 100644 index 9b59106d1176..000000000000 --- a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/profile/PureProfile.kt +++ /dev/null @@ -1,23 +0,0 @@ -package app.aaps.core.interfaces.profile - -import app.aaps.core.data.model.GlucoseUnit -import app.aaps.core.data.model.ICfg -import app.aaps.core.data.model.data.Block -import app.aaps.core.data.model.data.TargetBlock -import org.json.JSONObject -import java.util.TimeZone - -/** - * Pure profile like it's entered by user. Contains only data and it's serialized version in JSON - */ -class PureProfile( - /** Source json data (must correspond to the rest of the profile) */ - var jsonObject: JSONObject, - var basalBlocks: List, - var isfBlocks: List, - var icBlocks: List, - var targetBlocks: List, - var iCfg: ICfg? = null, - var glucoseUnit: GlucoseUnit, - var timeZone: TimeZone -) \ No newline at end of file diff --git a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/profile/SingleProfile.kt b/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/profile/SingleProfile.kt deleted file mode 100644 index b56fe163a5ea..000000000000 --- a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/profile/SingleProfile.kt +++ /dev/null @@ -1,35 +0,0 @@ -package app.aaps.core.interfaces.profile - -import org.json.JSONArray - -/** - * One entry in the local profile list. - * - * Pairs a [name] with profile data ([ic], [isf], [basal], target ranges) and the glucose - * unit flag ([mgdl]). The [JSONArray] fields hold the per-hour-block schedules in the same - * shape as Nightscout profile JSON, so they round-trip cleanly through the profile store. - * - * Use [deepClone] before mutating an instance you've received from [ProfileRepository] — - * the repository holds references and the `JSONArray`s are mutable. - */ -class SingleProfile( - var name: String, - var mgdl: Boolean, - var ic: JSONArray, - var isf: JSONArray, - var basal: JSONArray, - var targetLow: JSONArray, - var targetHigh: JSONArray, -) { - - fun deepClone(): SingleProfile = - SingleProfile( - name = name, - mgdl = mgdl, - ic = JSONArray(ic.toString()), - isf = JSONArray(isf.toString()), - basal = JSONArray(basal.toString()), - targetLow = JSONArray(targetLow.toString()), - targetHigh = JSONArray(targetHigh.toString()) - ) -} diff --git a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/pump/actions/CustomAction.kt b/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/pump/actions/CustomAction.kt deleted file mode 100644 index 2e6caee9667d..000000000000 --- a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/pump/actions/CustomAction.kt +++ /dev/null @@ -1,10 +0,0 @@ -package app.aaps.core.interfaces.pump.actions - -import androidx.compose.ui.graphics.vector.ImageVector - -data class CustomAction( - val name: Int, - val customActionType: CustomActionType, - val icon: ImageVector, - var isEnabled: Boolean = true -) \ No newline at end of file diff --git a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/pump/actions/CustomActionType.kt b/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/pump/actions/CustomActionType.kt deleted file mode 100644 index 370695b0c480..000000000000 --- a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/pump/actions/CustomActionType.kt +++ /dev/null @@ -1,6 +0,0 @@ -package app.aaps.core.interfaces.pump.actions - -interface CustomActionType { - - fun getKey(): String -} \ No newline at end of file diff --git a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/queue/Callback.kt b/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/queue/Callback.kt deleted file mode 100644 index 19eda218aa09..000000000000 --- a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/queue/Callback.kt +++ /dev/null @@ -1,12 +0,0 @@ -package app.aaps.core.interfaces.queue - -import app.aaps.core.interfaces.pump.PumpEnactResult - -abstract class Callback : Runnable { - - lateinit var result: PumpEnactResult - fun result(result: PumpEnactResult): Callback { - this.result = result - return this - } -} \ No newline at end of file diff --git a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/resources/ResourceHelper.kt b/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/resources/ResourceHelper.kt deleted file mode 100644 index 0ad70b593ba4..000000000000 --- a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/resources/ResourceHelper.kt +++ /dev/null @@ -1,34 +0,0 @@ -package app.aaps.core.interfaces.resources - -import android.content.res.AssetFileDescriptor -import android.graphics.Bitmap -import android.graphics.drawable.Drawable -import android.util.DisplayMetrics -import androidx.annotation.ArrayRes -import androidx.annotation.BoolRes -import androidx.annotation.ColorInt -import androidx.annotation.ColorRes -import androidx.annotation.DrawableRes -import androidx.annotation.PluralsRes -import androidx.annotation.RawRes -import androidx.annotation.StringRes - -interface ResourceHelper { - - fun gs(@StringRes id: Int): String - fun gs(@StringRes id: Int, vararg args: Any?): String - fun gq(@PluralsRes id: Int, quantity: Int, vararg args: Any?): String - fun gsNotLocalised(@StringRes id: Int, vararg args: Any?): String - @ColorInt fun gc(@ColorRes id: Int): Int - fun gd(@DrawableRes id: Int): Drawable? - fun gb(@BoolRes id: Int): Boolean - fun gcs(@ColorRes id: Int): String - fun gsa(@ArrayRes id: Int): Array - fun openRawResourceFd(@RawRes id: Int): AssetFileDescriptor? - - fun decodeResource(id: Int): Bitmap - fun getDisplayMetrics(): DisplayMetrics - fun dpToPx(dp: Int): Int - fun dpToPx(dp: Float): Int - fun shortTextMode(): Boolean -} diff --git a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/rx/bus/RxBus.kt b/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/rx/bus/RxBus.kt deleted file mode 100644 index 474242e5d7f9..000000000000 --- a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/rx/bus/RxBus.kt +++ /dev/null @@ -1,35 +0,0 @@ -package app.aaps.core.interfaces.rx.bus - -import app.aaps.core.interfaces.rx.events.Event -import io.reactivex.rxjava3.core.Observable -import kotlinx.coroutines.flow.Flow - -/** - * A simple event bus for communication between different parts of the application. - */ -interface RxBus { - - /** - * Sends an event to the bus. - * - * @param event The event to send. - */ - fun send(event: Event) - - /** - * Subscribes to events of a specific type via RxJava Observable. - * - * @param eventType The class of the event to listen for. - * @return An [Observable] that emits events of the specified type. - */ - fun toObservable(eventType: Class): Observable - - /** - * Subscribes to events of a specific type via coroutines Flow. - * Use this in Compose/coroutine code instead of [toObservable]. - * - * @param eventType The class of the event to listen for. - * @return A [Flow] that emits events of the specified type. - */ - fun toFlow(eventType: Class): Flow -} \ No newline at end of file diff --git a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/rx/events/Event.kt b/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/rx/events/Event.kt deleted file mode 100644 index e1a7edefb727..000000000000 --- a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/rx/events/Event.kt +++ /dev/null @@ -1,18 +0,0 @@ -package app.aaps.core.interfaces.rx.events - -import org.apache.commons.lang3.builder.ReflectionToStringBuilder -import org.apache.commons.lang3.builder.ToStringStyle - -/** Base class for all events posted on the event bus. */ -abstract class Event { - - override fun toString(): String { - return ReflectionToStringBuilder.toString(this) - } - - companion object { - init { - ReflectionToStringBuilder.setDefaultStyle(ToStringStyle.SHORT_PREFIX_STYLE) - } - } -} \ No newline at end of file diff --git a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/rx/events/EventAutosensCalculationFinished.kt b/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/rx/events/EventAutosensCalculationFinished.kt deleted file mode 100644 index 09aabdd6cf99..000000000000 --- a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/rx/events/EventAutosensCalculationFinished.kt +++ /dev/null @@ -1,3 +0,0 @@ -package app.aaps.core.interfaces.rx.events - -class EventAutosensCalculationFinished(val triggeredByNewBG: Boolean) : EventLoop() diff --git a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/rx/events/EventMobileToWearWatchface.kt b/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/rx/events/EventMobileToWearWatchface.kt deleted file mode 100644 index 2fcac3e2338c..000000000000 --- a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/rx/events/EventMobileToWearWatchface.kt +++ /dev/null @@ -1,3 +0,0 @@ -package app.aaps.core.interfaces.rx.events - -class EventMobileToWearWatchface(val payload: ByteArray) : Event() \ No newline at end of file diff --git a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/rx/events/EventSWRLStatus.kt b/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/rx/events/EventSWRLStatus.kt deleted file mode 100644 index 19236d831011..000000000000 --- a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/rx/events/EventSWRLStatus.kt +++ /dev/null @@ -1,13 +0,0 @@ -package app.aaps.core.interfaces.rx.events - -import android.content.Context - -/** - * Fired to update the setup wizard with the RileyLink status. - * - * @param status The RileyLink status message. - */ -class EventSWRLStatus(val status: String) : EventStatus() { - - override fun getStatus(context: Context): String = status -} \ No newline at end of file diff --git a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/rx/events/EventSWSyncStatus.kt b/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/rx/events/EventSWSyncStatus.kt deleted file mode 100644 index 55b1586de8c5..000000000000 --- a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/rx/events/EventSWSyncStatus.kt +++ /dev/null @@ -1,13 +0,0 @@ -package app.aaps.core.interfaces.rx.events - -import android.content.Context - -/** - * Fired to update the setup wizard with the sync status. - * - * @param status The sync status message. - */ -class EventSWSyncStatus(val status: String) : EventStatus() { - - override fun getStatus(context: Context): String = status -} \ No newline at end of file diff --git a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/rx/events/EventUpdateOverviewCalcProgress.kt b/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/rx/events/EventUpdateOverviewCalcProgress.kt deleted file mode 100644 index 9f54da091559..000000000000 --- a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/rx/events/EventUpdateOverviewCalcProgress.kt +++ /dev/null @@ -1,3 +0,0 @@ -package app.aaps.core.interfaces.rx.events - -class EventUpdateOverviewCalcProgress(val from: String) : Event() \ No newline at end of file diff --git a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/rx/weardata/CwfMetaDataKey.kt b/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/rx/weardata/CwfMetaDataKey.kt deleted file mode 100644 index 1f1f7e57d7f2..000000000000 --- a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/rx/weardata/CwfMetaDataKey.kt +++ /dev/null @@ -1,43 +0,0 @@ -package app.aaps.core.interfaces.rx.weardata - -import androidx.annotation.StringRes -import app.aaps.core.interfaces.R - -enum class CwfMetadataKey(val key: String, @StringRes val label: Int, val isPref: Boolean) { - - CWF_NAME("name", R.string.metadata_label_watchface_name, false), - CWF_FILENAME("filename", R.string.metadata_wear_import_filename, false), - CWF_AUTHOR("author", R.string.metadata_label_watchface_author, false), - CWF_CREATED_AT("created_at", R.string.metadata_label_watchface_created_at, false), - CWF_VERSION("cwf_version", R.string.metadata_label_plugin_version, false), - CWF_AUTHOR_VERSION("author_version", R.string.metadata_label_watchface_name_version, false), - CWF_COMMENT("comment", R.string.metadata_label_watchface_infos, false), - CWF_AUTHORIZATION("cwf_authorization", R.string.metadata_label_watchface_authorization, false), - CWF_PREF_WATCH_SHOW_DETAILED_IOB("key_show_detailed_iob", R.string.pref_show_detailed_iob, true), - CWF_PREF_WATCH_SHOW_DETAILED_DELTA("key_show_detailed_delta", R.string.pref_show_detailed_delta, true), - CWF_PREF_WATCH_SHOW_BGI("key_show_bgi", R.string.pref_show_bgi, true), - CWF_PREF_WATCH_SHOW_IOB("key_show_iob", R.string.pref_show_iob, true), - CWF_PREF_WATCH_SHOW_COB("key_show_cob", R.string.pref_show_cob, true), - CWF_PREF_WATCH_SHOW_DELTA("key_show_delta", R.string.pref_show_delta, true), - CWF_PREF_WATCH_SHOW_AVG_DELTA("key_show_avg_delta", R.string.pref_show_avgdelta, true), - CWF_PREF_WATCH_SHOW_TEMP_TARGET("key_show_temp_target", R.string.pref_show_tempTarget ,true), - CWF_PREF_WATCH_SHOW_RESERVOIR_LEVEL("key_show_reservoir_level", R.string.pref_show_reservoir_level ,true), - CWF_PREF_WATCH_SHOW_UPLOADER_BATTERY("key_show_uploader_battery", R.string.pref_show_phone_battery, true), - CWF_PREF_WATCH_SHOW_RIG_BATTERY("key_show_rig_battery", R.string.pref_show_rig_battery, true), - CWF_PREF_WATCH_SHOW_TEMP_BASAL("key_show_temp_basal", R.string.pref_show_basal_rate, true), - CWF_PREF_WATCH_SHOW_DIRECTION("key_show_direction", R.string.pref_show_direction_arrow, true), - CWF_PREF_WATCH_SHOW_AGO("key_show_ago", R.string.pref_show_ago, true), - CWF_PREF_WATCH_SHOW_BG("key_show_bg", R.string.pref_show_bg, true), - CWF_PREF_WATCH_SHOW_LOOP_STATUS("key_show_loop_status", R.string.pref_show_loop_status, true), - CWF_PREF_WATCH_SHOW_WEEK_NUMBER("key_show_week_number", R.string.pref_show_week_number, true), - CWF_PREF_WATCH_SHOW_DATE("key_show_date", R.string.pref_show_date, true), - CWF_PREF_WATCH_SHOW_SECONDS("key_show_seconds", R.string.pref_show_seconds, true); - - companion object { - - fun fromKey(key: String): CwfMetadataKey? = - entries.firstOrNull { it.key == key } - } -} - -typealias CwfMetadataMap = MutableMap \ No newline at end of file diff --git a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/stats/TIR.kt b/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/stats/TIR.kt deleted file mode 100644 index b2a7776d33d9..000000000000 --- a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/stats/TIR.kt +++ /dev/null @@ -1,25 +0,0 @@ -package app.aaps.core.interfaces.stats - -import android.content.Context -import android.widget.TableRow -import app.aaps.core.interfaces.resources.ResourceHelper -import app.aaps.core.interfaces.utils.DateUtil - -interface TIR { - - val date: Long - val lowThreshold: Double - val highThreshold: Double - var below: Int - var inRange: Int - var above: Int - var error: Int - var count: Int - fun error() - fun below() - fun inRange() - fun above() - - fun toTableRow(context: Context, rh: ResourceHelper, dateUtil: DateUtil): TableRow - fun toTableRow(context: Context, rh: ResourceHelper, days: Int): TableRow -} diff --git a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/utils/MidnightTime.kt b/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/utils/MidnightTime.kt deleted file mode 100644 index b4b5c3c7a515..000000000000 --- a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/utils/MidnightTime.kt +++ /dev/null @@ -1,86 +0,0 @@ -package app.aaps.core.interfaces.utils - -import androidx.annotation.VisibleForTesting -import androidx.collection.LongSparseArray -import java.time.Instant -import java.time.LocalDateTime -import java.time.LocalTime -import java.time.ZoneId - -object MidnightTime { - - @VisibleForTesting - val times = LongSparseArray() - - private const val THRESHOLD = 100000 - - /** - * Epoch time of last midnight - * - * @return epoch millis - */ - fun calc(): Long = - LocalDateTime.now().atZone(ZoneId.systemDefault()) - .with(LocalTime.of(0, 0, 0, 0)) - .toInstant().toEpochMilli() - - /** - * Today's time with 'minutes' from midnight - * - * @param minutes minutes to add - * @return epoch millis of today with hh:mm:00 - */ - fun calcMidnightPlusMinutes(minutes: Int): Long { - val h = (minutes / 60) % 24 - val m = minutes % 60 - return LocalDateTime.now().atZone(ZoneId.systemDefault()) - .with(LocalTime.of(h, m, 0, 0)) - .toInstant().toEpochMilli() - } - - /** - * Epoch time of last midnight before 'time' - * - * @param time time of the day - * @return epoch millis - */ - fun calc(time: Long): Long { - synchronized(times) { - val m = times[time] ?: Instant.ofEpochMilli(time).atZone(ZoneId.systemDefault()) - .with(LocalTime.of(0, 0, 0, 0)) - .toInstant().toEpochMilli() - if (times.size() > THRESHOLD) resetCache() - return m - } - } - - /** - * Epoch time of last midnight 'days' back - * - * @param daysBack how many days back - * @return epoch millis of midnight - */ - fun calcDaysBack(daysBack: Long): Long = - LocalDateTime.now().atZone(ZoneId.systemDefault()) - .with(LocalTime.of(0, 0, 0, 0)) - .minusDays(daysBack) - .toInstant().toEpochMilli() - - /** - * Epoch time of last midnight 'days' back from time - * - * @param time start time - * @param daysBack how many days back - * @return epoch millis of midnight - */ - fun calcDaysBack(time: Long, daysBack: Long): Long = - Instant.ofEpochMilli(time).atZone(ZoneId.systemDefault()) - .with(LocalTime.of(0, 0, 0, 0)) - .minusDays(daysBack) - .toInstant().toEpochMilli() - - @VisibleForTesting - fun resetCache() { - times.clear() - } -} \ No newline at end of file diff --git a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/utils/Round.kt b/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/utils/Round.kt deleted file mode 100644 index 414d04b9bc06..000000000000 --- a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/utils/Round.kt +++ /dev/null @@ -1,46 +0,0 @@ -package app.aaps.core.interfaces.utils - -import java.math.BigDecimal -import java.security.InvalidParameterException -import kotlin.math.abs -import kotlin.math.ceil -import kotlin.math.floor -import kotlin.math.roundToLong - -/** - * Created by mike on 20.06.2016. - */ -object Round { - - fun roundTo(x: Double, step: Double): Double { - if (x.isNaN()) throw InvalidParameterException("Parameter is NaN") - return if (x == 0.0) 0.0 - else BigDecimal.valueOf((x / step).roundToLong()).multiply(BigDecimal.valueOf(step)).toDouble() - } - - fun floorTo(x: Double, step: Double): Double { - if (x == 0.0) return 0.0 - val q = x / step - // IEEE-754: dividing an on-grid value can land a hair BELOW the integer - // (0.15 / 0.05 == 2.9999999999999996), so a naive floor() drops a whole step. - // Snap to the grid when the quotient is within tolerance of an integer, else floor. - // This never pushes the result above x, so the "must not deliver more than asked / - // must not exceed the max constraint" invariant in SafetyPlugin still holds. - val n = if (abs(q - q.roundToLong()) < 1e-9) q.roundToLong() else floor(q).toLong() - return BigDecimal.valueOf(n).multiply(BigDecimal.valueOf(step)).toDouble() - } - - fun ceilTo(x: Double, step: Double): Double { - if (x == 0.0) return 0.0 - val q = x / step - // IEEE-754 mirror of floorTo: dividing an on-grid value can land a hair ABOVE the integer - // (0.07 / 0.01 == 7.000000000000000888), so a naive ceil() adds a whole step. Snap to the - // grid when the quotient is within tolerance of an integer, else ceil. Never pushes the - // result below x, so callers that round UP to stay within a limit keep that guarantee. - val n = if (abs(q - q.roundToLong()) < 1e-9) q.roundToLong() else ceil(q).toLong() - return BigDecimal.valueOf(n).multiply(BigDecimal.valueOf(step)).toDouble() - } - - fun isSame(d1: Double, d2: Double): Boolean = - abs(d1 - d2) <= 0.000001 -} \ No newline at end of file diff --git a/core/interfaces/src/release/kotlin/app/aaps/annotations/OpenForTesting.kt b/core/interfaces/src/release/kotlin/app/aaps/annotations/OpenForTesting.kt deleted file mode 100644 index 4783a1db974f..000000000000 --- a/core/interfaces/src/release/kotlin/app/aaps/annotations/OpenForTesting.kt +++ /dev/null @@ -1,8 +0,0 @@ -package app.aaps.annotations - -/** - * Annotate a class with [OpenForTesting] if it should be extendable for testing. - * In production the class remains final. - */ -@Target(AnnotationTarget.CLASS) -annotation class OpenForTesting \ No newline at end of file diff --git a/core/keys/build.gradle.kts b/core/keys/build.gradle.kts index 5cb050f2a824..edd65741929b 100644 --- a/core/keys/build.gradle.kts +++ b/core/keys/build.gradle.kts @@ -1,21 +1,91 @@ import kotlin.math.min plugins { - alias(libs.plugins.android.library) - id("android-module-dependencies") - // Test only. composeKey() builds preference key names by hand instead of using String.format, - // so it needs tests that pin the exact text it produces. - id("test-module-dependencies") + kotlin("multiplatform") + // NOT com.android.library. AGP 9 refuses that plugin together with the multiplatform plugin: + // "The 'com.android.library' (or 'com.android.application') plugin is not compatible with the + // 'org.jetbrains.kotlin.multiplatform' plugin since AGP 9.0." + alias(libs.plugins.android.kmp.library) } -android { - namespace = "app.aaps.core.keys" - defaultConfig { +// One task, not one per variant: a multiplatform module has no product flavours, and a Kotlin +// source set takes a task provider directly, so the Android variant API is not needed here. +val generateKeyStrings = tasks.register("generateKeyStrings") { + resDir.set(layout.projectDirectory.dir("src/androidMain/res")) + packageName.set("app.aaps.core.keys") + owner.set("keys") + objectName.set("KeysStrings") + idsObjectName.set("KeysStringIds") + reportFile.set(layout.buildDirectory.file("reports/keyStrings/translations.txt")) + commonOutputDir.set(layout.buildDirectory.dir("generated/keyStrings/common")) + androidOutputDir.set(layout.buildDirectory.dir("generated/keyStrings/android")) +} + +kotlin { + // The Android half. The strings stay in src/androidMain/res and keep being processed by AAPT, + // which is the whole point: Android goes on resolving locales the way it always has, including + // the always English lookup the search index needs. + android { + namespace = "app.aaps.core.keys" + compileSdk = Versions.compileSdk minSdk = min(Versions.minSdk, Versions.wearMinSdk) // Compatible with wear module + // Off by default for a multiplatform library, unlike a plain android library. + androidResources { enable = true } + // Creates the androidHostTest compilation, which also pulls in commonTest. + withHostTest { } + compilerOptions { jvmTarget.set(Versions.jvmTarget) } + + // Restated from android-module-dependencies, which this module can no longer apply. Without + // it MissingTranslation would switch on for the first time here, and the 19 locale files + // that are empty today would fail a release build. The generator's per locale report covers + // the same ground and is on by default, so nothing is actually lost by keeping this off. + lint { + checkReleaseBuilds = false + disable += "MissingTranslation" + disable += "ExtraTranslation" + } + } + + // Desktop JVM, for a client on Windows. + jvm { + compilerOptions { jvmTarget.set(Versions.jvmTarget) } + } + + // Declared unconditionally. Kotlin/Native cross compiles klibs for Apple targets from any host + // since 2.2.20, so these really do compile on Windows - only linking, cinterop and running + // tests need a Mac, and those tasks report SKIPPED rather than failing. + iosArm64() + iosSimulatorArm64() + + sourceSets { + commonMain { + // Platform neutral: 327 string names as TextRef, no Android types. + kotlin.srcDir(generateKeyStrings.flatMap { it.commonOutputDir }) + dependencies { + // project.dependencies.platform, because a Kotlin source set dependency block has + // no platform() of its own. + api(project.dependencies.platform(libs.kotlinx.coroutines.bom)) + api(libs.kotlinx.coroutines.core) + } + } + androidMain { + // Android only: the name to R.string id map. + kotlin.srcDir(generateKeyStrings.flatMap { it.androidOutputDir }) + } + // Hand written rather than taken from test-module-dependencies, because that convention + // plugin applies com.android.library and so cannot be used here. Only what the one test + // in this module actually needs. + getByName("androidHostTest") { + dependencies { + implementation(libs.org.junit.jupiter) + implementation(libs.org.junit.jupiter.api) + implementation(libs.com.google.truth) + runtimeOnly(libs.org.junit.platform.launcher) + } + } } } -dependencies { - api(platform(libs.kotlinx.coroutines.bom)) - api(libs.kotlinx.coroutines.core) +tasks.withType { + useJUnitPlatform() } diff --git a/core/keys/src/test/kotlin/app/aaps/core/keys/interfaces/ComposedKeyTest.kt b/core/keys/src/androidHostTest/kotlin/app/aaps/core/keys/interfaces/ComposedKeyTest.kt similarity index 96% rename from core/keys/src/test/kotlin/app/aaps/core/keys/interfaces/ComposedKeyTest.kt rename to core/keys/src/androidHostTest/kotlin/app/aaps/core/keys/interfaces/ComposedKeyTest.kt index cc899885c5e9..577f7c228f34 100644 --- a/core/keys/src/test/kotlin/app/aaps/core/keys/interfaces/ComposedKeyTest.kt +++ b/core/keys/src/androidHostTest/kotlin/app/aaps/core/keys/interfaces/ComposedKeyTest.kt @@ -44,8 +44,8 @@ class ComposedKeyTest { @Test fun `gives the same text as the old String format for every key`() { for ((key, argument) in allKeys()) - // Truth reads the message as a template, and key.format itself holds %s or %d, - // so it has to go in as a placeholder value, not inside the text. + // Truth reads the message as a template, and key.format itself holds %s or %d, + // so it has to go in as a placeholder value, not inside the text. assertWithMessage("key '%s' format '%s'", key.key, key.format) .that(key.composeKey(argument)) .isEqualTo(oldComposeKey(key, argument)) diff --git a/core/keys/src/main/res/values-ar-rSA/strings.xml b/core/keys/src/androidMain/res/values-ar-rSA/strings.xml similarity index 100% rename from core/keys/src/main/res/values-ar-rSA/strings.xml rename to core/keys/src/androidMain/res/values-ar-rSA/strings.xml diff --git a/core/keys/src/main/res/values-bg-rBG/strings.xml b/core/keys/src/androidMain/res/values-bg-rBG/strings.xml similarity index 94% rename from core/keys/src/main/res/values-bg-rBG/strings.xml rename to core/keys/src/androidMain/res/values-bg-rBG/strings.xml index 83c692bd0c71..47ce78ccbddf 100644 --- a/core/keys/src/main/res/values-bg-rBG/strings.xml +++ b/core/keys/src/androidMain/res/values-bg-rBG/strings.xml @@ -235,48 +235,8 @@ Дете Единици - мг/дл - ммол/л - Ед - Е/ч - мин - сек - ч - дни - гр - кг - г - % - %1$d гр - %1$d мин - %1$d сек - %1$d ч - %1$.0f ч - %1$d дни - %1$d%% - %1$.1f Ед - %1$d Ед - %1$.1f Ед/ч - %1$s мг/дл - %1$.1f - %1$.2f - %1$.3f - %1$d гр (%2$d - %3$d) - %1$d мин (%2$d - %3$d) - %1$d сек (%2$d - %3$d) - %1$d ч (%2$d - %3$d) - %1$.0f ч (%2$.0f - %3$.0f) - %1$d дни (%2$d - %3$d) - %1$d%% (%2$d - %3$d) - %1$d Ед (%2$d - %3$d) - %1$.1f Ед (%2$.1f - %3$.1f) - %1$.1f Е/ч (%2$.1f - %3$.1f) - %1$s мг/дл (%2$s - %3$s) - %1$.1f (%2$.1f - %3$.1f) - %1$.2f (%2$.2f - %3$.2f) - %1$.3f (%2$.3f - %3$.3f) Език Системния English @@ -417,5 +377,4 @@ Стойността на КЗ под която инсулина се спира. По подразбиране се използва стандарните стойности. Потребителят може да настройва стойности между 60mg/dl (3.3ммол/л) и 100mg/dl(5,5ммол/л). Стойности под 65/3.6 ще доведат до използване на стандартния модел. - Диапазон на визуализация diff --git a/core/keys/src/main/res/values-ca-rES/strings.xml b/core/keys/src/androidMain/res/values-ca-rES/strings.xml similarity index 100% rename from core/keys/src/main/res/values-ca-rES/strings.xml rename to core/keys/src/androidMain/res/values-ca-rES/strings.xml diff --git a/core/keys/src/main/res/values-cs-rCZ/strings.xml b/core/keys/src/androidMain/res/values-cs-rCZ/strings.xml similarity index 93% rename from core/keys/src/main/res/values-cs-rCZ/strings.xml rename to core/keys/src/androidMain/res/values-cs-rCZ/strings.xml index 43ea2bc7274b..52955dd9e678 100644 --- a/core/keys/src/main/res/values-cs-rCZ/strings.xml +++ b/core/keys/src/androidMain/res/values-cs-rCZ/strings.xml @@ -235,48 +235,8 @@ Dítě Jednotky - mg/dL - mmol/L - U - U/h - min - s - h - dní - g - kg - r - % - %1$d g - %1$d min - %1$d s - %1$d h - %1$.0f h - %1$d dní - %1$d%% - %1$.1f U - %1$d U - %1$.1f U/h - %1$s mg/dL - %1$.1f - %1$.2f - %1$.3f - %1$d g (%2$d - %3$d) - %1$d min (%2$d - %3$d) - %1$d s (%2$d - %3$d) - %1$d h (%2$d - %3$d) - %1$.0f h (%2$.0f - %3$.0f) - %1$d dní (%2$d - %3$d) - %1$d%% (%2$d - %3$d) - %1$d U (%2$d - %3$d) - %1$.1f U (%2$.1f - %3$.1f) - %1$.1f U/h (%2$.1f - %3$.1f) - %1$s mg/dL (%2$s - %3$s) - %1$.1f (%2$.1f - %3$.1f) - %1$.2f (%2$.2f - %3$.2f) - %1$.3f (%2$.3f - %3$.3f) Jazyk Výchozí systémový Angličtina @@ -417,5 +377,4 @@ Hodnota glykémie, pod kterou je pozastaven inzulín. Výchozí hodnota používá standardní model cíle. Uživatel může nastavit hodnotu mezi 60 mg/dl (3,3 mmol/l) a 100 mg/dl (5,5 mmol/l). Hodnoty pod 65/3,6 způsobí použití výchozího modelu - Rozsah pro vizualizaci diff --git a/core/keys/src/main/res/values-da-rDK/strings.xml b/core/keys/src/androidMain/res/values-da-rDK/strings.xml similarity index 100% rename from core/keys/src/main/res/values-da-rDK/strings.xml rename to core/keys/src/androidMain/res/values-da-rDK/strings.xml diff --git a/core/keys/src/main/res/values-de-rDE/strings.xml b/core/keys/src/androidMain/res/values-de-rDE/strings.xml similarity index 100% rename from core/keys/src/main/res/values-de-rDE/strings.xml rename to core/keys/src/androidMain/res/values-de-rDE/strings.xml diff --git a/core/keys/src/main/res/values-el-rGR/strings.xml b/core/keys/src/androidMain/res/values-el-rGR/strings.xml similarity index 100% rename from core/keys/src/main/res/values-el-rGR/strings.xml rename to core/keys/src/androidMain/res/values-el-rGR/strings.xml diff --git a/core/keys/src/main/res/values-es-rES/strings.xml b/core/keys/src/androidMain/res/values-es-rES/strings.xml similarity index 94% rename from core/keys/src/main/res/values-es-rES/strings.xml rename to core/keys/src/androidMain/res/values-es-rES/strings.xml index ff8817386211..96e6c97bce80 100644 --- a/core/keys/src/main/res/values-es-rES/strings.xml +++ b/core/keys/src/androidMain/res/values-es-rES/strings.xml @@ -235,48 +235,8 @@ Menor Unidades - mg/dL - mmol/L - U - U/h - min - seg - h - días - g - kg - a - % - %1$d g - %1$d min - %1$d seg - %1$d h - %1$.0f h - %1$d días - %1$d%% - %1$.1f U - %1$d U - %1$.1f U/h - %1$s mg/dL - %1$.1f - %1$.2f - %1$.3f - %1$d g (%2$d - %3$d) - %1$d min (%2$d - %3$d) - %1$d seg (%2$d - %3$d) - %1$d h (%2$d - %3$d) - %1$.0f h (%2$.0f - %3$.0f) - %1$d días (%2$d - %3$d) - %1$d%% (%2$d - %3$d) - %1$d U (%2$d - %3$d) - %1$.1f U (%2$.1f - %3$.1f) - %1$.1f U/h (%2$.1f - %3$.1f) - %1$s mg/dL (%2$s - %3$s) - %1$.1f (%2$.1f - %3$.1f) - %1$.2f (%2$.2f - %3$.2f) - %1$.3f (%2$.3f - %3$.3f) Idioma Predeterminado del sistema Inglés @@ -419,5 +379,4 @@ Conceptos cl Valor de glucosa por debajo del cual se suspende la insulina. El valor predeterminado utiliza el modelo de objetivo estándar. El usuario puede establecer un valor entre 60 mg/dl (3.3 mmol/l) y 100 mg/dl (5.5 mmol/l). Los valores inferiores a 65/3.6 darán como resultado el uso del modelo predeterminado. - Rango para visualización diff --git a/core/keys/src/main/res/values-fr-rFR/strings.xml b/core/keys/src/androidMain/res/values-fr-rFR/strings.xml similarity index 94% rename from core/keys/src/main/res/values-fr-rFR/strings.xml rename to core/keys/src/androidMain/res/values-fr-rFR/strings.xml index 73e825e8c9a3..5fbbc20865a0 100644 --- a/core/keys/src/main/res/values-fr-rFR/strings.xml +++ b/core/keys/src/androidMain/res/values-fr-rFR/strings.xml @@ -235,48 +235,8 @@ Enfant Unités - mg/dL - mmol/L - U - U/h - m - s - h - jours - g - kg - a - % - %1$d g - %1$d m - %1$d s - %1$d h - %1$.0f h - %1$d jours - %1$d%% - %1$.1f U - %1$d U - %1$.1f U/h - %1$s mg/dL - %1$.1f - %1$.2f - %1$.3f - %1$d g (%2$d - %3$d) - %1$d m (%2$d - %3$d) - %1$d s (%2$d - %3$d) - %1$d h (%2$d - %3$d) - %1$.0f h (%2$.0f - %3$.0f) - %1$d jours (%2$d - %3$d) - %1$d%% (%2$d - %3$d) - %1$d U (%2$d - %3$d) - %1$.1f U (%2$.1f - %3$.1f) - %1$.1f U/h (%2$.1f - %3$.1f) - %1$s mg/dL (%2$s - %3$s) - %1$.1f (%2$.1f - %3$.1f) - %1$.2f (%2$.2f - %3$.2f) - %1$.3f (%2$.3f - %3$.3f) Langue Système Anglais @@ -417,5 +377,4 @@ Valeur glycémique au-dessous de laquelle l\'injection de l\'insuline est suspendu. La valeur par défaut utilise le modèle standard de la cible. L\'utilisateur peut choisir entre 60mg/dl (3.3mmol/l) et 100mg/dl (5.5mmol/l). Les valeurs au-dessous de 65/3.6 déclenchent l\'utilisation du modèle standard - Fourchette de visualisation diff --git a/core/keys/src/main/res/values-hr-rHR/strings.xml b/core/keys/src/androidMain/res/values-hr-rHR/strings.xml similarity index 100% rename from core/keys/src/main/res/values-hr-rHR/strings.xml rename to core/keys/src/androidMain/res/values-hr-rHR/strings.xml diff --git a/core/keys/src/main/res/values-hu-rHU/strings.xml b/core/keys/src/androidMain/res/values-hu-rHU/strings.xml similarity index 100% rename from core/keys/src/main/res/values-hu-rHU/strings.xml rename to core/keys/src/androidMain/res/values-hu-rHU/strings.xml diff --git a/core/keys/src/main/res/values-it-rIT/strings.xml b/core/keys/src/androidMain/res/values-it-rIT/strings.xml similarity index 93% rename from core/keys/src/main/res/values-it-rIT/strings.xml rename to core/keys/src/androidMain/res/values-it-rIT/strings.xml index faaff64325af..797e6a9b4e80 100644 --- a/core/keys/src/main/res/values-it-rIT/strings.xml +++ b/core/keys/src/androidMain/res/values-it-rIT/strings.xml @@ -235,48 +235,8 @@ Bambino Unità - mg/dL - mmol/L - U - U/h - min - sec - h - giorni - g - kg - y - % - %1$d g - %1$d min - %1$d sec - %1$d h - %1$.0f h - %1$d giorni - %1$d%% - %1$.1f U - %1$d U - %1$.1f U/h - %1$s mg/dL - %1$.1f - %1$.2f - %1$.3f - %1$d g (%2$d - %3$d) - %1$d min (%2$d - %3$d) - %1$d sec (%2$d - %3$d) - %1$d h (%2$d - %3$d) - %1$.0f h (%2$.0f - %3$.0f) - %1$d giorni (%2$d - %3$d) - %1$d%% (%2$d - %3$d) - %1$d U (%2$d - %3$d) - %1$.1f U (%2$.1f - %3$.1f) - %1$.1f U/h (%2$.1f - %3$.1f) - %1$s mg/dL (%2$s - %3$s) - %1$.1f (%2$.1f - %3$.1f) - %1$.2f (%2$.2f - %3$.2f) - %1$.3f (%2$.3f - %3$.3f) Lingua Predefinito di sistema English @@ -417,5 +377,4 @@ Glicemia sotto la quale l\'erogazione d\'insulina è sospesa. Il valore predefinito utilizza il modello target standard. L\'utente può impostare un valore compreso tra 60 mg/dl (3.3mmol/l) e 100mg/dl (5.5mmol/l). Con valori inferiori a 65 mg/dl (3.6mmol/l) viene usato il modello predefinito - Intervallo di visualizzazione diff --git a/core/keys/src/main/res/values-iw-rIL/strings.xml b/core/keys/src/androidMain/res/values-iw-rIL/strings.xml similarity index 100% rename from core/keys/src/main/res/values-iw-rIL/strings.xml rename to core/keys/src/androidMain/res/values-iw-rIL/strings.xml diff --git a/core/keys/src/main/res/values-ko-rKR/strings.xml b/core/keys/src/androidMain/res/values-ko-rKR/strings.xml similarity index 100% rename from core/keys/src/main/res/values-ko-rKR/strings.xml rename to core/keys/src/androidMain/res/values-ko-rKR/strings.xml diff --git a/core/keys/src/main/res/values-lt-rLT/strings.xml b/core/keys/src/androidMain/res/values-lt-rLT/strings.xml similarity index 100% rename from core/keys/src/main/res/values-lt-rLT/strings.xml rename to core/keys/src/androidMain/res/values-lt-rLT/strings.xml diff --git a/core/keys/src/main/res/values-nb-rNO/strings.xml b/core/keys/src/androidMain/res/values-nb-rNO/strings.xml similarity index 93% rename from core/keys/src/main/res/values-nb-rNO/strings.xml rename to core/keys/src/androidMain/res/values-nb-rNO/strings.xml index bc2db387ab73..79a0a4d25f15 100644 --- a/core/keys/src/main/res/values-nb-rNO/strings.xml +++ b/core/keys/src/androidMain/res/values-nb-rNO/strings.xml @@ -235,48 +235,8 @@ Barn Enheter - mg/dL - mmol/L - E - E/t - min - sek - t - dager - g - kg - år - % - %1$d g - %1$d min - %1$d sek - %1$d t - %1$.0f t - %1$d dager - %1$d%% - %1$.1f E - %1$d E - %1$.1f E/t - %1$s mg/dL - %1$.1f - %1$.2f - %1$.3f - %1$d g (%2$d - %3$d) - %1$d min (%2$d - %3$d) - %1$d sek (%2$d - %3$d) - %1$d t (%2$d - %3$d) - %1$.0f t (%2$.0f - %3$.0f) - %1$d dager (%2$d - %3$d) - %1$d%% (%2$d - %3$d) - %1$d E (%2$d - %3$d) - %1$.1f E (%2$.1f - %3$.1f) - %1$.1f E/t (%2$.1f - %3$.1f) - %1$s mg/dL (%2$s - %3$s) - %1$.1f (%2$.1f - %3$.1f) - %1$.2f (%2$.2f - %3$.2f) - %1$.3f (%2$.3f - %3$.3f) Språk Systemstandard Engelsk @@ -417,5 +377,4 @@ BS-verdien som insulinet stanses under. Standardverdien bruker standard målmodell. Brukeren kan angi verdi mellom 60 mg/dL (3,3 mmol/L) og 100 mg/dL (5,5 mmol/L). Verdier under 65/3,6 resulterer i bruk av standardmodellen. - Område for visualisering diff --git a/core/keys/src/main/res/values-nl-rNL/strings.xml b/core/keys/src/androidMain/res/values-nl-rNL/strings.xml similarity index 100% rename from core/keys/src/main/res/values-nl-rNL/strings.xml rename to core/keys/src/androidMain/res/values-nl-rNL/strings.xml diff --git a/core/keys/src/main/res/values-pl-rPL/strings.xml b/core/keys/src/androidMain/res/values-pl-rPL/strings.xml similarity index 100% rename from core/keys/src/main/res/values-pl-rPL/strings.xml rename to core/keys/src/androidMain/res/values-pl-rPL/strings.xml diff --git a/core/keys/src/main/res/values-pt-rBR/strings.xml b/core/keys/src/androidMain/res/values-pt-rBR/strings.xml similarity index 100% rename from core/keys/src/main/res/values-pt-rBR/strings.xml rename to core/keys/src/androidMain/res/values-pt-rBR/strings.xml diff --git a/core/keys/src/main/res/values-pt-rPT/strings.xml b/core/keys/src/androidMain/res/values-pt-rPT/strings.xml similarity index 100% rename from core/keys/src/main/res/values-pt-rPT/strings.xml rename to core/keys/src/androidMain/res/values-pt-rPT/strings.xml diff --git a/core/keys/src/main/res/values-ro-rRO/strings.xml b/core/keys/src/androidMain/res/values-ro-rRO/strings.xml similarity index 94% rename from core/keys/src/main/res/values-ro-rRO/strings.xml rename to core/keys/src/androidMain/res/values-ro-rRO/strings.xml index ec59e949e190..9aff4fd1166a 100644 --- a/core/keys/src/main/res/values-ro-rRO/strings.xml +++ b/core/keys/src/androidMain/res/values-ro-rRO/strings.xml @@ -235,48 +235,8 @@ Copil Unități - mg/dL - mmol/L - U - U/h - minute - secunde - h - zile - g - kg - an - % - %1$d g - %1$d min - %1$d sec - %1$d h - %1$.0f h - %1$d zile - %1$d%% - %1$.1f U - %1$d U - %1$.1f U/h - %1$s mg/dL - %1$.1f - %1$.2f - %1$.3f - %1$d g (%2$d - %3$d) - %1$d min (%2$d - %3$d) - %1$d sec (%2$d - %3$d) - %1$d h (%2$d - %3$d) - %1$.0f h (%2$.0f - %3$.0f) - %1$d zile (%2$d - %3$d) - %1$d%% (%2$d - %3$d) - %1$d U (%2$d - %3$d) - %1$.1f U (%2$.1f - %3$.1f) - %1$.1f U/o (%2$.1f - %3$.1f) - %1$s mg/dL (%2$s - %3$s) - %1$.1f (%2$.1f - %3$.1f) - %1$.2f (%2$.2f - %3$.2f) - %1$.3f (%2$.3f - %3$.3f) Limba Implicit sistem Engleză @@ -417,5 +377,4 @@ Valoarea glicemiei sub care administrarea insulinei este suspendată. Valoarea implicită folosește modelul standard de țintă. Utilizatorul poate seta valoarea între 60mg/dl (3,3mmol/l) și 100 mg/dl (5,5mmol/l). Valorile sub 65/3,6 determină utilizarea modelului standard - Intervalul pentru vizualizare diff --git a/core/keys/src/main/res/values-ru-rRU/strings.xml b/core/keys/src/androidMain/res/values-ru-rRU/strings.xml similarity index 100% rename from core/keys/src/main/res/values-ru-rRU/strings.xml rename to core/keys/src/androidMain/res/values-ru-rRU/strings.xml diff --git a/core/keys/src/main/res/values-sk-rSK/strings.xml b/core/keys/src/androidMain/res/values-sk-rSK/strings.xml similarity index 93% rename from core/keys/src/main/res/values-sk-rSK/strings.xml rename to core/keys/src/androidMain/res/values-sk-rSK/strings.xml index 431bca078afb..2d0754d10950 100644 --- a/core/keys/src/main/res/values-sk-rSK/strings.xml +++ b/core/keys/src/androidMain/res/values-sk-rSK/strings.xml @@ -235,48 +235,8 @@ Dieťa Jednotky - mg/dL - mmol/L - U - U/h - min - sek - h - dní - g - kg - r - % - %1$d g - %1$d min - %1$d sek - %1$d h - %1$.0f h - %1$d dní - %1$d%% - %1$.1f U - %1$d U - %1$.1f U/h - %1$s mg/dL - %1$.1f - %1$.2f - %1$.3f - %1$d g (%2$d - %3$d) - %1$d min (%2$d - %3$d) - %1$d s (%2$d - %3$d) - %1$d h (%2$d - %3$d) - %1$.0f h (%2$.0f - %3$.0f) - %1$d dní (%2$d - %3$d) - %1$d%% (%2$d - %3$d) - %1$d U (%2$d - %3$d) - %1$.1f U (%2$.1f - %3$.1f) - %1$.1f U/h (%2$.1f - %3$.1f) - %1$s mg/dL (%2$s - %3$s) - %1$.1f (%2$.1f - %3$.1f) - %1$.2f (%2$.2f - %3$.2f) - %1$.3f (%2$.3f - %3$.3f) Jazyk Východzí systémový Angličtina @@ -417,5 +377,4 @@ Hodnota glykémie, pod ktorú bude podávanie inzulínu zastavené. Východzia hodnota využíva štandardný cieľový model. Užívateľ môže nastaviť hodnoty od 3,3 mmol/l do 5,5 mmol/l. Pri hodnotách pod 3,6 mmol/l se použije východzí model. - Rozsah pre zobrazenie diff --git a/core/keys/src/main/res/values-sr-rCS/strings.xml b/core/keys/src/androidMain/res/values-sr-rCS/strings.xml similarity index 100% rename from core/keys/src/main/res/values-sr-rCS/strings.xml rename to core/keys/src/androidMain/res/values-sr-rCS/strings.xml diff --git a/core/keys/src/main/res/values-sv-rSE/strings.xml b/core/keys/src/androidMain/res/values-sv-rSE/strings.xml similarity index 100% rename from core/keys/src/main/res/values-sv-rSE/strings.xml rename to core/keys/src/androidMain/res/values-sv-rSE/strings.xml diff --git a/core/keys/src/main/res/values-tr-rTR/strings.xml b/core/keys/src/androidMain/res/values-tr-rTR/strings.xml similarity index 100% rename from core/keys/src/main/res/values-tr-rTR/strings.xml rename to core/keys/src/androidMain/res/values-tr-rTR/strings.xml diff --git a/core/keys/src/main/res/values-uk-rUA/strings.xml b/core/keys/src/androidMain/res/values-uk-rUA/strings.xml similarity index 100% rename from core/keys/src/main/res/values-uk-rUA/strings.xml rename to core/keys/src/androidMain/res/values-uk-rUA/strings.xml diff --git a/core/keys/src/main/res/values-vi-rVN/strings.xml b/core/keys/src/androidMain/res/values-vi-rVN/strings.xml similarity index 94% rename from core/keys/src/main/res/values-vi-rVN/strings.xml rename to core/keys/src/androidMain/res/values-vi-rVN/strings.xml index 6da1032a9e6c..3b5c99e8dac1 100644 --- a/core/keys/src/main/res/values-vi-rVN/strings.xml +++ b/core/keys/src/androidMain/res/values-vi-rVN/strings.xml @@ -235,48 +235,8 @@ Trẻ em Đơn vị - mg/dl - mmol/L - U - U/h - phút - giây - h - ngày - g - kg - y - % - %1$d g - %1$d phút - %1$d giây - %1$d h - %1$.0f h - %1$d ngày - %1$d%% - %1$.1f U - %1$d U - %1$.1f U/h - %1$s mg/dL - %1$.1f - %1$.2f - %1$.3f - %1$d g (%2$d - %3$d) - %1$d phút (%2$d - %3$d) - %1$d giây (%2$d - %3$d) - %1$d h (%2$d - %3$d) - %1$.0f h (%2$.0f - %3$.0f) - %1$d ngày (%2$d - %3$d) - %1$d%% (%2$d - %3$d) - %1$d U (%2$d - %3$d) - %1$.1f U (%2$.1f - %3$.1f) - %1$.1f U/h (%2$.1f - %3$.1f) - %1$s mg/dL (%2$s - %3$s) - %1$.1f (%2$.1f - %3$.1f) - %1$.2f (%2$.2f - %3$.2f) - %1$.3f (%2$.3f - %3$.3f) Ngôn ngữ Mặc định hệ thống Tiếng Anh @@ -417,5 +377,4 @@ Đườ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 - Phạm vi để hiển thị diff --git a/core/keys/src/main/res/values-zh-rCN/strings.xml b/core/keys/src/androidMain/res/values-zh-rCN/strings.xml similarity index 93% rename from core/keys/src/main/res/values-zh-rCN/strings.xml rename to core/keys/src/androidMain/res/values-zh-rCN/strings.xml index 1f5f551c0e73..12ac05361407 100644 --- a/core/keys/src/main/res/values-zh-rCN/strings.xml +++ b/core/keys/src/androidMain/res/values-zh-rCN/strings.xml @@ -235,48 +235,8 @@ 儿童 单位 - mg/dL - mmol/L - U - U/h - - - 小时 - - g - kg - - % - %1$d g - %1$d 分 - %1$d 秒 - %1$d 小时 - %1$.0f 小时 - %1$d 天 - %1$d%% - %1$.1f U - %1$d U - %1$.1f U/h - %1$s mg/dL - %1$.1f - %1$.2f - %1$.3f - %1$d g (%2$d - %3$d) - %1$d 分(%2$d - %3$d) - %1$d 秒(%2$d - %3$d) - %1$d 小时(%2$d - %3$d) - %1$.0f 小时(%2$.0f - %3$.0f) - %1$d 天 (%2$d - %3$d) - %1$d%% (%2$d - %3$d) - %1$d U (%2$d - %3$d) - %1$.1f U (%2$.1f - %3$.1f) - %1$.1f U/h (%2$.1f - %3$.1f) - %1$s mg/dL (%2$s - %3$s) - %1$.1f (%2$.1f - %3$.1f) - %1$.2f (%2$.2f - %3$.2f) - %1$.3f (%2$.3f - %3$.3f) 语言 系统默认 英语 @@ -417,5 +377,4 @@ 血糖值低于此值时,胰岛素暂停使用。默认值使用标准目标模型。用户可以在 60mg/dl (3.3mmol/l) 和 100mg/dl (5.5mmol/l) 之间设置。低于 65/3.6 的值将使用默认模型 - 可视化范围 diff --git a/core/keys/src/main/res/values-zh-rTW/strings.xml b/core/keys/src/androidMain/res/values-zh-rTW/strings.xml similarity index 93% rename from core/keys/src/main/res/values-zh-rTW/strings.xml rename to core/keys/src/androidMain/res/values-zh-rTW/strings.xml index 8b123279e816..7595e9fec31f 100644 --- a/core/keys/src/main/res/values-zh-rTW/strings.xml +++ b/core/keys/src/androidMain/res/values-zh-rTW/strings.xml @@ -236,48 +236,8 @@ 兒童 單位 - mg/dL - mmol/L - U - U/h - 分鐘 - - 小時 - - - 公斤 - - % - %1$d 克 - %1$d 分鐘 - %1$d 秒 - %1$d 小時 - %1$.0f 小時 - %1$d 天 - %1$d%% - %1$.1f U - %1$d U - %1$.1f U/h - %1$s mg/dL - %1$.1f - %1$.2f - %1$.3f - %1$d g (%2$d - %3$d) - %1$d 分鐘 (%2$d - %3$d) - %1$d 秒 (%2$d - %3$d) - %1$d 小時 (%2$d - %3$d) - %1$.0f 小時 (%2$.0f - %3$.0f) - %1$d 天 (%2$d - %3$d) - %1$d%% (%2$d - %3$d) - %1$d U (%2$d - %3$d) - %1$.1f U (%2$.1f - %3$.1f) - %1$.1f U/h (%2$.1f - %3$.1f) - %1$s mg/dL (%2$s - %3$s) - %1$.1f (%2$.1f - %3$.1f) - %1$.2f (%2$.2f - %3$.2f) - %1$.3f (%2$.3f - %3$.3f) 語系 系統預設 英文 @@ -418,5 +378,4 @@ 當血糖低於此值時,將暫停胰島素。預設值使用標準目標模型。使用者可將數值設定在 60 mg/dl(3.3 mmol/l)與 100 mg/dl(5.5 mmol/l)之間。若低於 65/3.6,則改用預設模型。 - 視覺化範圍 diff --git a/core/keys/src/main/res/values/strings.xml b/core/keys/src/androidMain/res/values/strings.xml similarity index 93% rename from core/keys/src/main/res/values/strings.xml rename to core/keys/src/androidMain/res/values/strings.xml index a8849d5aaee0..61c12ea5090a 100644 --- a/core/keys/src/main/res/values/strings.xml +++ b/core/keys/src/androidMain/res/values/strings.xml @@ -263,50 +263,10 @@ Units - mg/dL - mmol/L - U - U/h - min - sec - h - days - g - kg - y - % - %1$d g - %1$d min - %1$d sec - %1$d h - %1$.0f h - %1$d days - %1$d%% - %1$.1f U - %1$d U - %1$.1f U/h - %1$s mg/dL - %1$.1f - %1$.2f - %1$.3f - %1$d g (%2$d - %3$d) - %1$d min (%2$d - %3$d) - %1$d sec (%2$d - %3$d) - %1$d h (%2$d - %3$d) - %1$.0f h (%2$.0f - %3$.0f) - %1$d days (%2$d - %3$d) - %1$d%% (%2$d - %3$d) - %1$d U (%2$d - %3$d) - %1$.1f U (%2$.1f - %3$.1f) - %1$.1f U/h (%2$.1f - %3$.1f) - %1$s mg/dL (%2$s - %3$s) - %1$.1f (%2$.1f - %3$.1f) - %1$.2f (%2$.2f - %3$.2f) - %1$.3f (%2$.3f - %3$.3f) Language System default English @@ -464,6 +424,5 @@ - Range for visualization diff --git a/core/keys/src/main/kotlin/app/aaps/core/keys/BooleanComposedKey.kt b/core/keys/src/commonMain/kotlin/app/aaps/core/keys/BooleanComposedKey.kt similarity index 100% rename from core/keys/src/main/kotlin/app/aaps/core/keys/BooleanComposedKey.kt rename to core/keys/src/commonMain/kotlin/app/aaps/core/keys/BooleanComposedKey.kt diff --git a/core/keys/src/commonMain/kotlin/app/aaps/core/keys/BooleanKey.kt b/core/keys/src/commonMain/kotlin/app/aaps/core/keys/BooleanKey.kt new file mode 100644 index 000000000000..3ccc46f9364d --- /dev/null +++ b/core/keys/src/commonMain/kotlin/app/aaps/core/keys/BooleanKey.kt @@ -0,0 +1,264 @@ +package app.aaps.core.keys + +import app.aaps.core.keys.interfaces.BooleanPreferenceKey +import app.aaps.core.keys.interfaces.ElementVisibility +import app.aaps.core.keys.interfaces.PreferenceEnabledCondition +import app.aaps.core.keys.interfaces.SyncChannel +import app.aaps.core.keys.interfaces.SyncDirection +import app.aaps.core.keys.interfaces.SyncSpec +import app.aaps.core.keys.interfaces.TextRef + +enum class BooleanKey( + override val key: String, + override val defaultValue: Boolean, + override val title: TextRef, + override val summary: TextRef? = null, + override val preferenceType: PreferenceType = PreferenceType.SWITCH, + override val calculatedDefaultValue: Boolean = false, + override val defaultedBySM: Boolean = false, + override val showInApsMode: Boolean = true, + override val showInNsClientMode: Boolean = true, + override val showInPumpControlMode: Boolean = true, + override val dependency: BooleanPreferenceKey? = null, + override val negativeDependency: BooleanPreferenceKey? = null, + override val hideParentScreenIfHidden: Boolean = false, + override val engineeringModeOnly: Boolean = false, + override val exportable: Boolean = true, + override val visibility: ElementVisibility = ElementVisibility.ALWAYS, + override val enabledCondition: PreferenceEnabledCondition = PreferenceEnabledCondition.ALWAYS, + override val sync: SyncSpec? = null +) : BooleanPreferenceKey { + + GeneralSimpleMode(key = "simple_mode", defaultValue = true, title = KeysStrings.pref_title_simple_mode, sync = SyncSpec(SyncChannel.Cold, SyncDirection.Bidirectional)), + GeneralInsulinConcentration( + key = "insulin_concentration_enabled", defaultValue = false, title = KeysStrings.pref_title_insulin_concentration, summary = KeysStrings.pref_summary_insulin_concentration, + defaultedBySM = true, + enabledCondition = PreferenceEnabledCondition { it.isConcentrationEnabled }, + sync = SyncSpec(SyncChannel.Cold, SyncDirection.Bidirectional) + ), + OverviewKeepScreenOn(key = "keep_screen_on", defaultValue = false, title = KeysStrings.pref_title_keep_screen_on, summary = KeysStrings.pref_summary_keep_screen_on, calculatedDefaultValue = true), + OverviewShowTreatmentButton(key = "show_treatment_button", defaultValue = false, title = KeysStrings.pref_title_show_treatment_button, defaultedBySM = true), + OverviewShowWizardButton(key = "show_wizard_button", defaultValue = true, title = KeysStrings.pref_title_show_wizard_button, defaultedBySM = true), + OverviewShowInsulinButton(key = "show_insulin_button", defaultValue = true, title = KeysStrings.pref_title_show_insulin_button, defaultedBySM = true), + OverviewShowCarbsButton(key = "show_carbs_button", defaultValue = true, title = KeysStrings.pref_title_show_carbs_button, defaultedBySM = true), + OverviewShowCgmButton(key = "show_cgm_button", defaultValue = false, title = KeysStrings.pref_title_show_cgm_button, summary = KeysStrings.pref_summary_show_cgm_button, defaultedBySM = true, showInNsClientMode = false), + OverviewShowCalibrationButton( + key = "show_calibration_button", + defaultValue = false, + title = KeysStrings.pref_title_show_calibration_button, + summary = KeysStrings.pref_summary_show_calibration_button, + defaultedBySM = true, + showInNsClientMode = false + ), + OverviewShowNotesInDialogs(key = "show_notes_entry_dialogs", defaultValue = false, title = KeysStrings.pref_title_show_notes_in_dialogs, defaultedBySM = true), + OverviewUseBolusAdvisor("use_bolus_advisor", true, KeysStrings.pref_title_use_bolus_advisor, KeysStrings.pref_summary_use_bolus_advisor, defaultedBySM = true, sync = SyncSpec(SyncChannel.Cold, SyncDirection.Bidirectional)), + OverviewUseBolusReminder("use_bolus_reminder", true, KeysStrings.pref_title_use_bolus_reminder, KeysStrings.pref_summary_use_bolus_reminder, defaultedBySM = true, sync = SyncSpec(SyncChannel.Cold, SyncDirection.Bidirectional)), + + @Deprecated("Remove support") + OverviewUseSuperBolus("key_usersuperbolus", false, KeysStrings.pref_title_use_super_bolus, KeysStrings.pref_summary_use_super_bolus, defaultedBySM = true, hideParentScreenIfHidden = true), + + PumpBtWatchdog( + "bt_watchdog", false, KeysStrings.pref_title_bt_watchdog, KeysStrings.pref_summary_bt_watchdog, + sync = SyncSpec(SyncChannel.Cold, SyncDirection.Bidirectional) + ), + + AlertMissedBgReading("enable_missed_bg_readings", false, KeysStrings.pref_title_alert_missed_bg_reading), + AlertPumpUnreachable("enable_pump_unreachable_alert", true, KeysStrings.pref_title_alert_pump_unreachable), + AlertCarbsRequired("enable_carbs_required_alert_local", true, KeysStrings.pref_title_alert_carbs_required), + AlertUrgentAsAndroidNotification("raise_urgent_alarms_as_android_notification", true, KeysStrings.pref_title_alert_urgent_as_android_notification), + AlertIncreaseVolume("gradually_increase_notification_volume", true, KeysStrings.pref_title_alert_increase_volume), + AlertOverrideDoNotDisturb("alert_override_dnd", true, KeysStrings.pref_title_alert_override_dnd, KeysStrings.pref_summary_alert_override_dnd, defaultedBySM = true), + + BgSourceUploadToNs("dexcomg5_nsupload", true, KeysStrings.pref_title_bg_source_upload_to_ns, defaultedBySM = true, hideParentScreenIfHidden = true), + BgSourceCreateSensorChange("dexcom_lognssensorchange", true, KeysStrings.pref_title_bg_source_create_sensor_change, KeysStrings.pref_summary_bg_source_create_sensor_change, defaultedBySM = true), + BgSourceRandomBgRandomize("randombg_randomize", true, KeysStrings.pref_title_random_bg_randomize, KeysStrings.pref_summary_random_bg_randomize, defaultedBySM = true), + + ApsUseDynamicSensitivity("use_dynamic_sensitivity", false, KeysStrings.pref_title_aps_use_dynamic_sensitivity, KeysStrings.pref_summary_aps_use_dynamic_sensitivity, sync = SyncSpec(SyncChannel.Cold, SyncDirection.Bidirectional)), + ApsUseAutosens( + "openapsama_useautosens", true, KeysStrings.pref_title_aps_use_autosens, defaultedBySM = true, + // Hidden only while the active APS both offers dynamic sensitivity and has it enabled. + // A plain negativeDependency on ApsUseDynamicSensitivity would also hide it on algorithms + // whose screens never show that toggle (AMA, AutoISF), with no way to reveal it (issue #4482). + visibility = ElementVisibility { !(it.apsOffersDynamicSensitivity && it.preferences.get(ApsUseDynamicSensitivity)) }, + sync = SyncSpec(SyncChannel.Cold, SyncDirection.Bidirectional) + ), + ApsUseSmb("use_smb", true, KeysStrings.pref_title_aps_use_smb, KeysStrings.pref_summary_aps_use_smb, defaultedBySM = true, sync = SyncSpec(SyncChannel.Cold, SyncDirection.Bidirectional)), + ApsUseSmbWithHighTt( + "enableSMB_with_high_temptarget", + false, + KeysStrings.pref_title_aps_use_smb_with_high_tt, + KeysStrings.pref_summary_aps_use_smb_with_high_tt, + defaultedBySM = true, + dependency = ApsUseSmb, + sync = SyncSpec(SyncChannel.Cold, SyncDirection.Bidirectional) + ), + ApsUseSmbAlways( + "enableSMB_always", true, KeysStrings.pref_title_aps_use_smb_always, KeysStrings.pref_summary_aps_use_smb_always, defaultedBySM = true, dependency = ApsUseSmb, + visibility = ElementVisibility.ADVANCED_FILTERING, + sync = SyncSpec(SyncChannel.Cold, SyncDirection.Bidirectional) + ), + ApsUseSmbWithCob( + "enableSMB_with_COB", true, KeysStrings.pref_title_aps_use_smb_with_cob, KeysStrings.pref_summary_aps_use_smb_with_cob, defaultedBySM = true, dependency = ApsUseSmb, + visibility = ElementVisibility { !it.preferences.get(ApsUseSmbAlways) || !it.advancedFilteringSupported }, + sync = SyncSpec(SyncChannel.Cold, SyncDirection.Bidirectional) + ), + ApsUseSmbWithLowTt( + "enableSMB_with_temptarget", true, KeysStrings.pref_title_aps_use_smb_with_low_tt, KeysStrings.pref_summary_aps_use_smb_with_low_tt, defaultedBySM = true, dependency = ApsUseSmb, + visibility = ElementVisibility { !it.preferences.get(ApsUseSmbAlways) || !it.advancedFilteringSupported }, + sync = SyncSpec(SyncChannel.Cold, SyncDirection.Bidirectional) + ), + ApsUseSmbAfterCarbs( + "enableSMB_after_carbs", true, KeysStrings.pref_title_aps_use_smb_after_carbs, KeysStrings.pref_summary_aps_use_smb_after_carbs, defaultedBySM = true, dependency = ApsUseSmb, + visibility = ElementVisibility { !it.preferences.get(ApsUseSmbAlways) && it.advancedFilteringSupported }, + sync = SyncSpec(SyncChannel.Cold, SyncDirection.Bidirectional) + ), + ApsUseUam("use_uam", true, KeysStrings.pref_title_aps_use_uam, KeysStrings.pref_summary_aps_use_uam, defaultedBySM = true, sync = SyncSpec(SyncChannel.Cold, SyncDirection.Bidirectional)), + ApsSensitivityRaisesTarget( + "sensitivity_raises_target", true, KeysStrings.pref_title_aps_sensitivity_raises_target, KeysStrings.pref_summary_aps_sensitivity_raises_target, defaultedBySM = true, + visibility = ElementVisibility { + if (it.preferences.get(ApsUseDynamicSensitivity)) { + it.preferences.get(ApsDynIsfAdjustSensitivity) + } else { + it.preferences.get(ApsUseAutosens) + } + }, + sync = SyncSpec(SyncChannel.Cold, SyncDirection.Bidirectional) + ), + ApsResistanceLowersTarget( + "resistance_lowers_target", true, KeysStrings.pref_title_aps_resistance_lowers_target, KeysStrings.pref_summary_aps_resistance_lowers_target, defaultedBySM = true, + visibility = ElementVisibility { + if (it.preferences.get(ApsUseDynamicSensitivity)) { + it.preferences.get(ApsDynIsfAdjustSensitivity) + } else { + it.preferences.get(ApsUseAutosens) + } + }, + sync = SyncSpec(SyncChannel.Cold, SyncDirection.Bidirectional) + ), + ApsAlwaysUseShortDeltas( + "always_use_shortavg", + false, + KeysStrings.pref_title_aps_always_use_short_deltas, + KeysStrings.pref_summary_aps_always_use_short_deltas, + defaultedBySM = true, + hideParentScreenIfHidden = true, + sync = SyncSpec(SyncChannel.Cold, SyncDirection.Bidirectional) + ), + ApsDynIsfAdjustSensitivity( + "dynisf_adjust_sensitivity", + false, + KeysStrings.pref_title_aps_dynisf_adjust_sensitivity, + KeysStrings.pref_summary_aps_dynisf_adjust_sensitivity, + defaultedBySM = true, + dependency = ApsUseDynamicSensitivity, + sync = SyncSpec(SyncChannel.Cold, SyncDirection.Bidirectional) + ), + ApsAmaAutosensAdjustTargets( + "autosens_adjust_targets", + true, + KeysStrings.pref_title_aps_autosens_adjust_targets, + KeysStrings.pref_summary_aps_autosens_adjust_targets, + defaultedBySM = true, + sync = SyncSpec(SyncChannel.Cold, SyncDirection.Bidirectional) + ), + ApsAutoIsfHighTtRaisesSens( + "high_temptarget_raises_sensitivity", + false, + KeysStrings.pref_title_aps_high_tt_raises_sensitivity, + KeysStrings.pref_summary_aps_high_tt_raises_sensitivity, + defaultedBySM = true, + sync = SyncSpec(SyncChannel.Cold, SyncDirection.Bidirectional) + ), + ApsAutoIsfLowTtLowersSens( + "low_temptarget_lowers_sensitivity", + false, + KeysStrings.pref_title_aps_low_tt_lowers_sensitivity, + KeysStrings.pref_summary_aps_low_tt_lowers_sensitivity, + defaultedBySM = true, + sync = SyncSpec(SyncChannel.Cold, SyncDirection.Bidirectional) + ), + ApsUseAutoIsfWeights("openapsama_enable_autoISF", false, KeysStrings.pref_title_aps_use_autoisf_weights, KeysStrings.pref_summary_aps_use_autoisf_weights, defaultedBySM = true, sync = SyncSpec(SyncChannel.Cold, SyncDirection.Bidirectional)), + ApsAutoIsfSmbOnEvenTarget( + "Enable alternative activation of SMB always", + false, + KeysStrings.pref_title_aps_smb_on_even_target, + KeysStrings.pref_summary_aps_smb_on_even_target, + defaultedBySM = true, + sync = SyncSpec(SyncChannel.Cold, SyncDirection.Bidirectional) + ), + + MaintenanceEnableFabric("enable_fabric2", true, KeysStrings.pref_title_maintenance_enable_fabric, defaultedBySM = true, hideParentScreenIfHidden = true), + + // Master-only (not a follower client): unattended settings export backs up the local config, which on a + // client is derived from the master. showInNsClientMode=false hides it in apsMode + pumpControlMode only; + // hideParentScreenIfHidden collapses the now-empty "Unattended Settings Export" subscreen on a client. + MaintenanceEnableExportSettingsAutomation("enable_unattended_export", false, KeysStrings.pref_title_maintenance_enable_export_automation, defaultedBySM = false, showInNsClientMode = false, hideParentScreenIfHidden = true), + + AutotuneAutoSwitchProfile("autotune_auto", false, KeysStrings.pref_title_autotune_auto_switch_profile, KeysStrings.pref_summary_autotune_auto_switch_profile), + AutotuneCategorizeUamAsBasal("categorize_uam_as_basal", false, KeysStrings.pref_title_autotune_categorize_uam_as_basal, KeysStrings.pref_summary_autotune_categorize_uam_as_basal), + AutotuneTuneInsulinCurve("autotune_tune_insulin_curve", false, KeysStrings.pref_title_autotune_tune_insulin_curve), + AutotuneCircadianIcIsf("autotune_circadian_ic_isf", false, KeysStrings.pref_title_autotune_circadian_ic_isf, KeysStrings.pref_summary_autotune_circadian_ic_isf), + AutotuneAdditionalLog("autotune_additional_log", false, KeysStrings.pref_title_autotune_additional_log), + + SmsAllowRemoteCommands("smscommunicator_remotecommandsallowed", false, KeysStrings.pref_title_sms_allow_remote_commands), + SmsReportPumpUnreachable("smscommunicator_report_pump_unreachable", true, KeysStrings.pref_title_sms_report_pump_unreachable, KeysStrings.pref_summary_sms_report_pump_unreachable), + + VirtualPumpStatusUpload("virtualpump_uploadstatus", false, KeysStrings.pref_title_virtual_pump_status_upload, showInNsClientMode = false), + NsClientUploadData("ns_upload", true, KeysStrings.pref_title_ns_upload_data, KeysStrings.pref_summary_ns_upload_data, showInNsClientMode = false, hideParentScreenIfHidden = true), + NsClientAcceptCgmData("ns_receive_cgm", false, KeysStrings.pref_title_ns_receive_cgm, KeysStrings.pref_summary_ns_receive_cgm, showInNsClientMode = false, hideParentScreenIfHidden = true), + NsClientAcceptProfileStore("ns_receive_profile_store", false, KeysStrings.pref_title_ns_receive_profile_store, KeysStrings.pref_summary_ns_receive_profile_store, showInNsClientMode = false, hideParentScreenIfHidden = true), + NsClientAcceptTempTarget("ns_receive_temp_target", false, KeysStrings.pref_title_ns_receive_temp_target, KeysStrings.pref_summary_ns_receive_temp_target, showInNsClientMode = false, hideParentScreenIfHidden = true), + NsClientAcceptProfileSwitch("ns_receive_profile_switch", false, KeysStrings.pref_title_ns_receive_profile_switch, KeysStrings.pref_summary_ns_receive_profile_switch, showInNsClientMode = false, hideParentScreenIfHidden = true), + NsClientAcceptInsulin("ns_receive_insulin", false, KeysStrings.pref_title_ns_receive_insulin, KeysStrings.pref_summary_ns_receive_insulin, showInNsClientMode = false, hideParentScreenIfHidden = true), + NsClientAcceptCarbs("ns_receive_carbs", false, KeysStrings.pref_title_ns_receive_carbs, KeysStrings.pref_summary_ns_receive_carbs, showInNsClientMode = false, hideParentScreenIfHidden = true), + NsClientAcceptTherapyEvent("ns_receive_therapy_events", false, KeysStrings.pref_title_ns_receive_therapy_event, KeysStrings.pref_summary_ns_receive_therapy_event, showInNsClientMode = false, hideParentScreenIfHidden = true), + NsClientAcceptRunningMode("ns_receive_running_mode", false, KeysStrings.pref_title_ns_receive_running_mode, KeysStrings.pref_summary_ns_receive_running_mode, showInNsClientMode = false, hideParentScreenIfHidden = true), + NsClientAcceptTbrEb("ns_receive_tbr_eb", false, KeysStrings.pref_title_ns_receive_tbr_eb, KeysStrings.pref_summary_ns_receive_tbr_eb, showInNsClientMode = false, engineeringModeOnly = true), + NsClientNotificationsFromAlarms("ns_alarms", false, KeysStrings.pref_title_ns_notifications_from_alarms, calculatedDefaultValue = true), + NsClientNotificationsFromAnnouncements("ns_announcements", false, KeysStrings.pref_title_ns_notifications_from_announcements, calculatedDefaultValue = true), + NsClientUseCellular("ns_cellular", true, KeysStrings.pref_title_ns_use_cellular), + NsClientUseRoaming("ns_allow_roaming", true, KeysStrings.pref_title_ns_use_roaming, dependency = NsClientUseCellular), + NsClientUseWifi("ns_wifi", true, KeysStrings.pref_title_ns_use_wifi), + NsClientUseOnBattery("ns_battery", true, KeysStrings.pref_title_ns_use_on_battery), + NsClientUseOnCharging("ns_charging", true, KeysStrings.pref_title_ns_use_on_charging), + NsClientLogAppStart("ns_log_app_started_event", false, KeysStrings.pref_title_ns_log_app_start, calculatedDefaultValue = true), + NsClientCreateAnnouncementsFromErrors("ns_create_announcements_from_errors", false, KeysStrings.pref_title_ns_create_announcements_from_errors, calculatedDefaultValue = true, showInNsClientMode = false), + NsClientCreateAnnouncementsFromCarbsReq("ns_create_announcements_from_carbs_req", false, KeysStrings.pref_title_ns_create_announcements_from_carbs_req, calculatedDefaultValue = true, showInNsClientMode = false), + NsClientSlowSync("ns_sync_slow", false, KeysStrings.pref_title_ns_slow_sync), + NsClient3UseWs("ns_use_ws", true, KeysStrings.pref_title_ns_use_ws, KeysStrings.pref_summary_ns_use_ws), + NsClientAllowClientControl( + "ns_allow_client_control", false, + KeysStrings.pref_title_ns_allow_client_control, KeysStrings.pref_summary_ns_allow_client_control, + // The rich stop/allow-communication switch lives on the Authorized clients screen; it is ALSO exposed in a + // "Remote control" category on the NSCv3 settings screen (NSClientV3Plugin.getPreferenceScreenContent) so it + // is reachable from search. Default OFF, but ON in simple mode (resolved in PreferencesImpl.calculatedDefaultValue). Hidden on a client. + calculatedDefaultValue = true, showInNsClientMode = false, + // Remote control rides the WebSocket — hide the toggle (and its single-item "Remote control" parent category) + // when WS is off, and on a client where the key is already hidden (so the category never shows empty). + dependency = NsClient3UseWs, hideParentScreenIfHidden = true, + // Synced master→client (MasterOnly — the client mirrors, never pushes back) so a paired client knows + // whether the master is accepting commands and can gate its UI. buildSyncedPrefs publishes the EFFECTIVE + // value for this key (see RunningConfigurationImpl), not the raw default. + sync = SyncSpec(SyncChannel.Cold, SyncDirection.MasterOnly) + ), + OpenHumansWifiOnly("oh_wifi_only", true, KeysStrings.pref_title_openhumans_wifi_only), + OpenHumansChargingOnly("oh_charging_only", false, KeysStrings.pref_title_openhumans_charging_only), + XdripSendStatus("xdrip_send_status", false, KeysStrings.pref_title_xdrip_send_status), + XdripSendDetailedIob("xdripstatus_detailediob", true, KeysStrings.pref_title_xdrip_send_detailed_iob, KeysStrings.pref_summary_xdrip_send_detailed_iob, defaultedBySM = true, hideParentScreenIfHidden = true), + XdripSendBgi("xdripstatus_showbgi", true, KeysStrings.pref_title_xdrip_send_bgi, KeysStrings.pref_summary_xdrip_send_bgi, defaultedBySM = true, hideParentScreenIfHidden = true), + WearControl(key = "wearcontrol", defaultValue = false, title = KeysStrings.pref_title_wear_control, summary = KeysStrings.pref_summary_wear_control), + WearWizardBg(key = "wearwizard_bg", defaultValue = true, title = KeysStrings.pref_title_wear_wizard_bg, dependency = WearControl, hideParentScreenIfHidden = true), + WearWizardTt(key = "wearwizard_tt", defaultValue = false, title = KeysStrings.pref_title_wear_wizard_tt, dependency = WearControl, hideParentScreenIfHidden = true), + WearWizardTrend(key = "wearwizard_trend", defaultValue = false, title = KeysStrings.pref_title_wear_wizard_trend, dependency = WearControl, hideParentScreenIfHidden = true), + WearWizardCob(key = "wearwizard_cob", defaultValue = true, title = KeysStrings.pref_title_wear_wizard_cob, dependency = WearControl, hideParentScreenIfHidden = true), + WearWizardIob(key = "wearwizard_iob", defaultValue = true, title = KeysStrings.pref_title_wear_wizard_iob, dependency = WearControl, hideParentScreenIfHidden = true), + WearCustomWatchfaceAuthorization(key = "wear_custom_watchface_autorization", defaultValue = false, title = KeysStrings.pref_title_wear_custom_watchface_authorization), + WearNotifyOnSmb(key = "wear_notifySMB", defaultValue = true, title = KeysStrings.pref_title_wear_notify_on_smb, summary = KeysStrings.pref_summary_wear_notify_on_smb), + WearBroadcastData(key = "wear_broadcast_data", defaultValue = false, title = KeysStrings.pref_title_wear_broadcast_data, summary = KeysStrings.pref_summary_wear_broadcast_data, showInApsMode = false, showInPumpControlMode = false), + + SiteRotationManagePump("site_rotation_manage_pump", defaultValue = false, title = KeysStrings.pref_title_site_rotation_manage_pump, sync = SyncSpec(SyncChannel.Cold, SyncDirection.Bidirectional)), + SiteRotationManageCgm("site_rotation_manage_cgm", defaultValue = false, title = KeysStrings.pref_title_site_rotation_manage_cgm, sync = SyncSpec(SyncChannel.Cold, SyncDirection.Bidirectional)), + + ; + +} diff --git a/core/keys/src/main/kotlin/app/aaps/core/keys/BooleanNonKey.kt b/core/keys/src/commonMain/kotlin/app/aaps/core/keys/BooleanNonKey.kt similarity index 100% rename from core/keys/src/main/kotlin/app/aaps/core/keys/BooleanNonKey.kt rename to core/keys/src/commonMain/kotlin/app/aaps/core/keys/BooleanNonKey.kt diff --git a/core/keys/src/main/kotlin/app/aaps/core/keys/DoubleKey.kt b/core/keys/src/commonMain/kotlin/app/aaps/core/keys/DoubleKey.kt similarity index 61% rename from core/keys/src/main/kotlin/app/aaps/core/keys/DoubleKey.kt rename to core/keys/src/commonMain/kotlin/app/aaps/core/keys/DoubleKey.kt index 37a366146432..b9f03e3f4114 100644 --- a/core/keys/src/main/kotlin/app/aaps/core/keys/DoubleKey.kt +++ b/core/keys/src/commonMain/kotlin/app/aaps/core/keys/DoubleKey.kt @@ -5,14 +5,15 @@ import app.aaps.core.keys.interfaces.DoublePreferenceKey import app.aaps.core.keys.interfaces.SyncChannel import app.aaps.core.keys.interfaces.SyncDirection import app.aaps.core.keys.interfaces.SyncSpec +import app.aaps.core.keys.interfaces.TextRef enum class DoubleKey( override val key: String, override val defaultValue: Double, override val min: Double, override val max: Double, - override val titleResId: Int, - override val summaryResId: Int? = null, + override val title: TextRef, + override val summary: TextRef? = null, override val preferenceType: PreferenceType = PreferenceType.TEXT_FIELD, override val defaultedBySM: Boolean = false, override val calculatedBySM: Boolean = false, @@ -32,8 +33,8 @@ enum class DoubleKey( defaultValue = 0.5, min = -5.0, max = 5.0, - titleResId = R.string.pref_title_insulin_button_increment_1, - summaryResId = R.string.insulin_increment_button_message, + title = KeysStrings.pref_title_insulin_button_increment_1, + summary = KeysStrings.insulin_increment_button_message, defaultedBySM = true, dependency = BooleanKey.OverviewShowInsulinButton, unitType = UnitType.INSULIN, @@ -44,8 +45,8 @@ enum class DoubleKey( defaultValue = 1.0, min = -5.0, max = 5.0, - titleResId = R.string.pref_title_insulin_button_increment_2, - summaryResId = R.string.insulin_increment_button_message, + title = KeysStrings.pref_title_insulin_button_increment_2, + summary = KeysStrings.insulin_increment_button_message, defaultedBySM = true, dependency = BooleanKey.OverviewShowInsulinButton, unitType = UnitType.INSULIN, @@ -56,24 +57,24 @@ enum class DoubleKey( defaultValue = 2.0, min = -5.0, max = 5.0, - titleResId = R.string.pref_title_insulin_button_increment_3, - summaryResId = R.string.insulin_increment_button_message, + title = KeysStrings.pref_title_insulin_button_increment_3, + summary = KeysStrings.insulin_increment_button_message, defaultedBySM = true, dependency = BooleanKey.OverviewShowInsulinButton, unitType = UnitType.INSULIN, sync = SyncSpec(SyncChannel.Cold, SyncDirection.Bidirectional) ), - ActionsFillButton1(key = "fill_button1", defaultValue = 0.3, min = 0.05, max = 20.0, titleResId = R.string.pref_title_fill_button_1, defaultedBySM = true, hideParentScreenIfHidden = true, unitType = UnitType.INSULIN), - ActionsFillButton2(key = "fill_button2", defaultValue = 0.0, min = 0.0, max = 20.0, titleResId = R.string.pref_title_fill_button_2, defaultedBySM = true, unitType = UnitType.INSULIN), - ActionsFillButton3(key = "fill_button3", defaultValue = 0.0, min = 0.0, max = 20.0, titleResId = R.string.pref_title_fill_button_3, defaultedBySM = true, unitType = UnitType.INSULIN), - SafetyMaxBolus(key = "treatmentssafety_maxbolus", defaultValue = 3.0, min = 0.1, max = 60.0, titleResId = R.string.pref_title_max_bolus, unitType = UnitType.INSULIN, sync = SyncSpec(SyncChannel.Cold, SyncDirection.Bidirectional)), + ActionsFillButton1(key = "fill_button1", defaultValue = 0.3, min = 0.05, max = 20.0, title = KeysStrings.pref_title_fill_button_1, defaultedBySM = true, hideParentScreenIfHidden = true, unitType = UnitType.INSULIN), + ActionsFillButton2(key = "fill_button2", defaultValue = 0.0, min = 0.0, max = 20.0, title = KeysStrings.pref_title_fill_button_2, defaultedBySM = true, unitType = UnitType.INSULIN), + ActionsFillButton3(key = "fill_button3", defaultValue = 0.0, min = 0.0, max = 20.0, title = KeysStrings.pref_title_fill_button_3, defaultedBySM = true, unitType = UnitType.INSULIN), + SafetyMaxBolus(key = "treatmentssafety_maxbolus", defaultValue = 3.0, min = 0.1, max = 60.0, title = KeysStrings.pref_title_max_bolus, unitType = UnitType.INSULIN, sync = SyncSpec(SyncChannel.Cold, SyncDirection.Bidirectional)), ApsMaxBasal( key = "openapsma_max_basal", defaultValue = 1.0, min = 0.1, max = 25.0, - titleResId = R.string.pref_title_max_basal, - summaryResId = R.string.openapsma_max_basal_summary, + title = KeysStrings.pref_title_max_basal, + summary = KeysStrings.openapsma_max_basal_summary, defaultedBySM = true, calculatedBySM = true, unitType = UnitType.INSULIN_RATE, @@ -84,8 +85,8 @@ enum class DoubleKey( defaultValue = 3.0, min = 0.0, max = 70.0, - titleResId = R.string.pref_title_smb_max_iob, - summaryResId = R.string.openapssmb_max_iob_summary, + title = KeysStrings.pref_title_smb_max_iob, + summary = KeysStrings.openapssmb_max_iob_summary, defaultedBySM = true, calculatedBySM = true, unitType = UnitType.INSULIN, @@ -96,8 +97,8 @@ enum class DoubleKey( defaultValue = 1.5, min = 0.0, max = 25.0, - titleResId = R.string.pref_title_ama_max_iob, - summaryResId = R.string.openapsma_max_iob_summary, + title = KeysStrings.pref_title_ama_max_iob, + summary = KeysStrings.openapsma_max_iob_summary, defaultedBySM = true, calculatedBySM = true, unitType = UnitType.INSULIN, @@ -108,8 +109,8 @@ enum class DoubleKey( defaultValue = 3.0, min = 1.0, max = 10.0, - titleResId = R.string.pref_title_max_daily_multiplier, - summaryResId = R.string.openapsama_max_daily_safety_multiplier_summary, + title = KeysStrings.pref_title_max_daily_multiplier, + summary = KeysStrings.openapsama_max_daily_safety_multiplier_summary, defaultedBySM = true, unitType = UnitType.DOUBLE, sync = SyncSpec(SyncChannel.Cold, SyncDirection.Bidirectional) @@ -119,8 +120,8 @@ enum class DoubleKey( defaultValue = 4.0, min = 1.0, max = 10.0, - titleResId = R.string.pref_title_current_basal_multiplier, - summaryResId = R.string.openapsama_current_basal_safety_multiplier_summary, + title = KeysStrings.pref_title_current_basal_multiplier, + summary = KeysStrings.openapsama_current_basal_safety_multiplier_summary, defaultedBySM = true, unitType = UnitType.DOUBLE, sync = SyncSpec(SyncChannel.Cold, SyncDirection.Bidirectional) @@ -130,8 +131,8 @@ enum class DoubleKey( defaultValue = 2.0, min = 1.0, max = 10.0, - titleResId = R.string.pref_title_bolus_snooze_divisor, - summaryResId = R.string.openapsama_bolus_snooze_dia_divisor_summary, + title = KeysStrings.pref_title_bolus_snooze_divisor, + summary = KeysStrings.openapsama_bolus_snooze_dia_divisor_summary, defaultedBySM = true, unitType = UnitType.DOUBLE, sync = SyncSpec(SyncChannel.Cold, SyncDirection.Bidirectional) @@ -141,8 +142,8 @@ enum class DoubleKey( defaultValue = 3.0, min = 1.0, max = 12.0, - titleResId = R.string.pref_title_ama_min_5m_carbs_impact, - summaryResId = R.string.openapsama_min_5m_carb_impact_summary, + title = KeysStrings.pref_title_ama_min_5m_carbs_impact, + summary = KeysStrings.openapsama_min_5m_carb_impact_summary, defaultedBySM = true, unitType = UnitType.DOUBLE, sync = SyncSpec(SyncChannel.Cold, SyncDirection.Bidirectional) @@ -152,8 +153,8 @@ enum class DoubleKey( defaultValue = 8.0, min = 1.0, max = 12.0, - titleResId = R.string.pref_title_smb_min_5m_carbs_impact, - summaryResId = R.string.openapsama_min_5m_carb_impact_summary, + title = KeysStrings.pref_title_smb_min_5m_carbs_impact, + summary = KeysStrings.openapsama_min_5m_carb_impact_summary, defaultedBySM = true, unitType = UnitType.DOUBLE, sync = SyncSpec(SyncChannel.Cold, SyncDirection.Bidirectional) @@ -163,8 +164,8 @@ enum class DoubleKey( defaultValue = 6.0, min = 4.0, max = 10.0, - titleResId = R.string.pref_title_absorption_cutoff, - summaryResId = R.string.absorption_cutoff_summary, + title = KeysStrings.pref_title_absorption_cutoff, + summary = KeysStrings.absorption_cutoff_summary, unitType = UnitType.HOURS_DOUBLE, sync = SyncSpec(SyncChannel.Cold, SyncDirection.Bidirectional) ), @@ -173,8 +174,8 @@ enum class DoubleKey( defaultValue = 6.0, min = 4.0, max = 10.0, - titleResId = R.string.pref_title_absorption_maxtime, - summaryResId = R.string.absorption_max_time_summary, + title = KeysStrings.pref_title_absorption_maxtime, + summary = KeysStrings.absorption_max_time_summary, unitType = UnitType.HOURS_DOUBLE, sync = SyncSpec(SyncChannel.Cold, SyncDirection.Bidirectional) ), @@ -183,8 +184,8 @@ enum class DoubleKey( defaultValue = 0.7, min = 0.1, max = 1.0, - titleResId = R.string.pref_title_autosens_min, - summaryResId = R.string.openapsama_autosens_min_summary, + title = KeysStrings.pref_title_autosens_min, + summary = KeysStrings.openapsama_autosens_min_summary, defaultedBySM = true, unitType = UnitType.DOUBLE, sync = SyncSpec(SyncChannel.Cold, SyncDirection.Bidirectional) @@ -194,21 +195,41 @@ enum class DoubleKey( defaultValue = 1.2, min = 0.5, max = 3.0, - titleResId = R.string.pref_title_autosens_max, - summaryResId = R.string.openapsama_autosens_max_summary, + title = KeysStrings.pref_title_autosens_max, + summary = KeysStrings.openapsama_autosens_max_summary, + defaultedBySM = true, + unitType = UnitType.DOUBLE, + sync = SyncSpec(SyncChannel.Cold, SyncDirection.Bidirectional) + ), + ApsAutoIsfMin( + key = "autoISF_min", + defaultValue = 1.0, + min = 0.3, + max = 1.0, + title = KeysStrings.pref_title_autoisf_min, + summary = KeysStrings.openapsama_autoISF_min_summary, + defaultedBySM = true, + unitType = UnitType.DOUBLE, + sync = SyncSpec(SyncChannel.Cold, SyncDirection.Bidirectional) + ), + ApsAutoIsfMax( + key = "autoISF_max", + defaultValue = 1.0, + min = 1.0, + max = 3.0, + title = KeysStrings.pref_title_autoisf_max, + summary = KeysStrings.openapsama_autoISF_max_summary, defaultedBySM = true, unitType = UnitType.DOUBLE, sync = SyncSpec(SyncChannel.Cold, SyncDirection.Bidirectional) ), - ApsAutoIsfMin(key = "autoISF_min", defaultValue = 1.0, min = 0.3, max = 1.0, titleResId = R.string.pref_title_autoisf_min, summaryResId = R.string.openapsama_autoISF_min_summary, defaultedBySM = true, unitType = UnitType.DOUBLE, sync = SyncSpec(SyncChannel.Cold, SyncDirection.Bidirectional)), - ApsAutoIsfMax(key = "autoISF_max", defaultValue = 1.0, min = 1.0, max = 3.0, titleResId = R.string.pref_title_autoisf_max, summaryResId = R.string.openapsama_autoISF_max_summary, defaultedBySM = true, unitType = UnitType.DOUBLE, sync = SyncSpec(SyncChannel.Cold, SyncDirection.Bidirectional)), ApsAutoIsfBgAccelWeight( key = "bgAccel_ISF_weight", defaultValue = 0.0, min = 0.0, max = 1.0, - titleResId = R.string.pref_title_bg_accel_weight, - summaryResId = R.string.openapsama_bgAccel_ISF_weight_summary, + title = KeysStrings.pref_title_bg_accel_weight, + summary = KeysStrings.openapsama_bgAccel_ISF_weight_summary, defaultedBySM = true, unitType = UnitType.DOUBLE_2, sync = SyncSpec(SyncChannel.Cold, SyncDirection.Bidirectional) @@ -218,8 +239,8 @@ enum class DoubleKey( defaultValue = 0.0, min = 0.0, max = 1.0, - titleResId = R.string.pref_title_bg_brake_weight, - summaryResId = R.string.openapsama_bgBrake_ISF_weight_summary, + title = KeysStrings.pref_title_bg_brake_weight, + summary = KeysStrings.openapsama_bgBrake_ISF_weight_summary, defaultedBySM = true, unitType = UnitType.DOUBLE_2, sync = SyncSpec(SyncChannel.Cold, SyncDirection.Bidirectional) @@ -229,8 +250,8 @@ enum class DoubleKey( defaultValue = 0.0, min = 0.0, max = 2.0, - titleResId = R.string.pref_title_low_bg_weight, - summaryResId = R.string.openapsama_lower_ISFrange_weight_summary, + title = KeysStrings.pref_title_low_bg_weight, + summary = KeysStrings.openapsama_lower_ISFrange_weight_summary, defaultedBySM = true, unitType = UnitType.DOUBLE_2, sync = SyncSpec(SyncChannel.Cold, SyncDirection.Bidirectional) @@ -240,8 +261,8 @@ enum class DoubleKey( defaultValue = 0.0, min = 0.0, max = 2.0, - titleResId = R.string.pref_title_high_bg_weight, - summaryResId = R.string.openapsama_higher_ISFrange_weight_summary, + title = KeysStrings.pref_title_high_bg_weight, + summary = KeysStrings.openapsama_higher_ISFrange_weight_summary, defaultedBySM = true, unitType = UnitType.DOUBLE_2, sync = SyncSpec(SyncChannel.Cold, SyncDirection.Bidirectional) @@ -251,21 +272,41 @@ enum class DoubleKey( defaultValue = 0.0, min = 0.0, max = 100.0, - titleResId = R.string.pref_title_smb_delivery_ratio_bg_range, - summaryResId = R.string.openapsama_smb_delivery_ratio_bg_range_summary, + title = KeysStrings.pref_title_smb_delivery_ratio_bg_range, + summary = KeysStrings.openapsama_smb_delivery_ratio_bg_range_summary, defaultedBySM = true, unitType = UnitType.DOUBLE, sync = SyncSpec(SyncChannel.Cold, SyncDirection.Bidirectional) ), - ApsAutoIsfPpWeight(key = "pp_ISF_weight", defaultValue = 0.0, min = 0.0, max = 0.15, titleResId = R.string.pref_title_pp_weight, summaryResId = R.string.openapsama_pp_ISF_weight_summary, defaultedBySM = true, unitType = UnitType.DOUBLE_3, sync = SyncSpec(SyncChannel.Cold, SyncDirection.Bidirectional)), - ApsAutoIsfDuraWeight(key = "dura_ISF_weight", defaultValue = 0.0, min = 0.0, max = 3.0, titleResId = R.string.pref_title_dura_weight, summaryResId = R.string.openapsama_dura_ISF_weight_summary, defaultedBySM = true, unitType = UnitType.DOUBLE_2, sync = SyncSpec(SyncChannel.Cold, SyncDirection.Bidirectional)), + ApsAutoIsfPpWeight( + key = "pp_ISF_weight", + defaultValue = 0.0, + min = 0.0, + max = 0.15, + title = KeysStrings.pref_title_pp_weight, + summary = KeysStrings.openapsama_pp_ISF_weight_summary, + defaultedBySM = true, + unitType = UnitType.DOUBLE_3, + sync = SyncSpec(SyncChannel.Cold, SyncDirection.Bidirectional) + ), + ApsAutoIsfDuraWeight( + key = "dura_ISF_weight", + defaultValue = 0.0, + min = 0.0, + max = 3.0, + title = KeysStrings.pref_title_dura_weight, + summary = KeysStrings.openapsama_dura_ISF_weight_summary, + defaultedBySM = true, + unitType = UnitType.DOUBLE_2, + sync = SyncSpec(SyncChannel.Cold, SyncDirection.Bidirectional) + ), ApsAutoIsfSmbDeliveryRatio( key = "openapsama_smb_delivery_ratio", defaultValue = 0.5, min = 0.1, max = 1.0, - titleResId = R.string.pref_title_smb_delivery_ratio, - summaryResId = R.string.openapsama_smb_delivery_ratio_summary, + title = KeysStrings.pref_title_smb_delivery_ratio, + summary = KeysStrings.openapsama_smb_delivery_ratio_summary, defaultedBySM = true, unitType = UnitType.DOUBLE_2, sync = SyncSpec(SyncChannel.Cold, SyncDirection.Bidirectional) @@ -275,8 +316,8 @@ enum class DoubleKey( defaultValue = 0.5, min = 0.1, max = 1.0, - titleResId = R.string.pref_title_smb_delivery_ratio_min, - summaryResId = R.string.openapsama_smb_delivery_ratio_min_summary, + title = KeysStrings.pref_title_smb_delivery_ratio_min, + summary = KeysStrings.openapsama_smb_delivery_ratio_min_summary, defaultedBySM = true, unitType = UnitType.DOUBLE_2, sync = SyncSpec(SyncChannel.Cold, SyncDirection.Bidirectional) @@ -286,8 +327,8 @@ enum class DoubleKey( defaultValue = 0.5, min = 0.5, max = 1.0, - titleResId = R.string.pref_title_smb_delivery_ratio_max, - summaryResId = R.string.openapsama_smb_delivery_ratio_max_summary, + title = KeysStrings.pref_title_smb_delivery_ratio_max, + summary = KeysStrings.openapsama_smb_delivery_ratio_max_summary, defaultedBySM = true, unitType = UnitType.DOUBLE_2, sync = SyncSpec(SyncChannel.Cold, SyncDirection.Bidirectional) @@ -297,11 +338,13 @@ enum class DoubleKey( defaultValue = 1.0, min = 1.0, max = 5.0, - titleResId = R.string.pref_title_smb_max_range_extension, - summaryResId = R.string.openapsama_smb_max_range_extension_summary, + title = KeysStrings.pref_title_smb_max_range_extension, + summary = KeysStrings.openapsama_smb_max_range_extension_summary, defaultedBySM = true, unitType = UnitType.DOUBLE, sync = SyncSpec(SyncChannel.Cold, SyncDirection.Bidirectional) ), -} \ No newline at end of file + ; + +} diff --git a/core/keys/src/main/kotlin/app/aaps/core/keys/DoubleNonKey.kt b/core/keys/src/commonMain/kotlin/app/aaps/core/keys/DoubleNonKey.kt similarity index 100% rename from core/keys/src/main/kotlin/app/aaps/core/keys/DoubleNonKey.kt rename to core/keys/src/commonMain/kotlin/app/aaps/core/keys/DoubleNonKey.kt diff --git a/core/keys/src/main/kotlin/app/aaps/core/keys/IntComposedKey.kt b/core/keys/src/commonMain/kotlin/app/aaps/core/keys/IntComposedKey.kt similarity index 100% rename from core/keys/src/main/kotlin/app/aaps/core/keys/IntComposedKey.kt rename to core/keys/src/commonMain/kotlin/app/aaps/core/keys/IntComposedKey.kt diff --git a/core/keys/src/main/kotlin/app/aaps/core/keys/IntKey.kt b/core/keys/src/commonMain/kotlin/app/aaps/core/keys/IntKey.kt similarity index 69% rename from core/keys/src/main/kotlin/app/aaps/core/keys/IntKey.kt rename to core/keys/src/commonMain/kotlin/app/aaps/core/keys/IntKey.kt index 1e2e26f4c8da..d39ee5ece7a6 100644 --- a/core/keys/src/main/kotlin/app/aaps/core/keys/IntKey.kt +++ b/core/keys/src/commonMain/kotlin/app/aaps/core/keys/IntKey.kt @@ -7,16 +7,17 @@ import app.aaps.core.keys.interfaces.PreferenceEnabledCondition import app.aaps.core.keys.interfaces.SyncChannel import app.aaps.core.keys.interfaces.SyncDirection import app.aaps.core.keys.interfaces.SyncSpec +import app.aaps.core.keys.interfaces.TextRef enum class IntKey( override val key: String, override val defaultValue: Int, override val min: Int, override val max: Int, - override val titleResId: Int, - override val summaryResId: Int? = null, + override val title: TextRef, + override val summary: TextRef? = null, override val preferenceType: PreferenceType = PreferenceType.TEXT_FIELD, - override val entries: Map = emptyMap(), + private val entriesRefs: Map = emptyMap(), override val defaultedBySM: Boolean = false, override val calculatedDefaultValue: Boolean = false, override val showInApsMode: Boolean = true, @@ -38,8 +39,8 @@ enum class IntKey( defaultValue = 5, min = -50, max = 50, - titleResId = R.string.pref_title_carbs_button_increment_1, - summaryResId = R.string.carb_increment_button_message, + title = KeysStrings.pref_title_carbs_button_increment_1, + summary = KeysStrings.carb_increment_button_message, defaultedBySM = true, dependency = BooleanKey.OverviewShowCarbsButton, unitType = UnitType.GRAMS, @@ -50,8 +51,8 @@ enum class IntKey( defaultValue = 10, min = -50, max = 50, - titleResId = R.string.pref_title_carbs_button_increment_2, - summaryResId = R.string.carb_increment_button_message, + title = KeysStrings.pref_title_carbs_button_increment_2, + summary = KeysStrings.carb_increment_button_message, defaultedBySM = true, dependency = BooleanKey.OverviewShowCarbsButton, unitType = UnitType.GRAMS, @@ -62,8 +63,8 @@ enum class IntKey( defaultValue = 20, min = -50, max = 50, - titleResId = R.string.pref_title_carbs_button_increment_3, - summaryResId = R.string.carb_increment_button_message, + title = KeysStrings.pref_title_carbs_button_increment_3, + summary = KeysStrings.carb_increment_button_message, defaultedBySM = true, dependency = BooleanKey.OverviewShowCarbsButton, unitType = UnitType.GRAMS, @@ -75,7 +76,7 @@ enum class IntKey( defaultValue = 48, min = 24, max = 240, - titleResId = R.string.pref_title_cage_warning, + title = KeysStrings.pref_title_cage_warning, defaultedBySM = true, unitType = UnitType.HOURS, sync = SyncSpec(SyncChannel.Cold, SyncDirection.Bidirectional) @@ -85,7 +86,7 @@ enum class IntKey( defaultValue = 72, min = 24, max = 240, - titleResId = R.string.pref_title_cage_critical, + title = KeysStrings.pref_title_cage_critical, defaultedBySM = true, unitType = UnitType.HOURS, sync = SyncSpec(SyncChannel.Cold, SyncDirection.Bidirectional) @@ -95,7 +96,7 @@ enum class IntKey( defaultValue = 72, min = 24, max = 240, - titleResId = R.string.pref_title_iage_warning, + title = KeysStrings.pref_title_iage_warning, defaultedBySM = true, visibility = ElementVisibility.NON_PATCH_PUMP, unitType = UnitType.HOURS, @@ -106,7 +107,7 @@ enum class IntKey( defaultValue = 144, min = 24, max = 240, - titleResId = R.string.pref_title_iage_critical, + title = KeysStrings.pref_title_iage_critical, defaultedBySM = true, visibility = ElementVisibility.NON_PATCH_PUMP, unitType = UnitType.HOURS, @@ -117,7 +118,7 @@ enum class IntKey( defaultValue = 216, min = 24, max = 720, - titleResId = R.string.pref_title_sage_warning, + title = KeysStrings.pref_title_sage_warning, defaultedBySM = true, unitType = UnitType.HOURS, sync = SyncSpec(SyncChannel.Cold, SyncDirection.Bidirectional) @@ -127,7 +128,7 @@ enum class IntKey( defaultValue = 240, min = 24, max = 720, - titleResId = R.string.pref_title_sage_critical, + title = KeysStrings.pref_title_sage_critical, defaultedBySM = true, unitType = UnitType.HOURS, sync = SyncSpec(SyncChannel.Cold, SyncDirection.Bidirectional) @@ -137,7 +138,7 @@ enum class IntKey( defaultValue = 25, min = 0, max = 100, - titleResId = R.string.pref_title_sbat_warning, + title = KeysStrings.pref_title_sbat_warning, defaultedBySM = true, unitType = UnitType.PERCENT, sync = SyncSpec(SyncChannel.Cold, SyncDirection.Bidirectional) @@ -147,7 +148,7 @@ enum class IntKey( defaultValue = 5, min = 0, max = 100, - titleResId = R.string.pref_title_sbat_critical, + title = KeysStrings.pref_title_sbat_critical, defaultedBySM = true, unitType = UnitType.PERCENT, sync = SyncSpec(SyncChannel.Cold, SyncDirection.Bidirectional) @@ -157,7 +158,7 @@ enum class IntKey( defaultValue = 216, min = 24, max = 1000, - titleResId = R.string.pref_title_bage_warning, + title = KeysStrings.pref_title_bage_warning, defaultedBySM = true, unitType = UnitType.HOURS, sync = SyncSpec(SyncChannel.Cold, SyncDirection.Bidirectional) @@ -167,7 +168,7 @@ enum class IntKey( defaultValue = 240, min = 24, max = 1000, - titleResId = R.string.pref_title_bage_critical, + title = KeysStrings.pref_title_bage_critical, defaultedBySM = true, unitType = UnitType.HOURS, sync = SyncSpec(SyncChannel.Cold, SyncDirection.Bidirectional) @@ -177,7 +178,7 @@ enum class IntKey( defaultValue = 80, min = 0, max = 300, - titleResId = R.string.pref_title_res_warning, + title = KeysStrings.pref_title_res_warning, defaultedBySM = true, unitType = UnitType.INSULIN_INT, sync = SyncSpec(SyncChannel.Cold, SyncDirection.Bidirectional) @@ -187,7 +188,7 @@ enum class IntKey( defaultValue = 10, min = 0, max = 300, - titleResId = R.string.pref_title_res_critical, + title = KeysStrings.pref_title_res_critical, defaultedBySM = true, unitType = UnitType.INSULIN_INT, sync = SyncSpec(SyncChannel.Cold, SyncDirection.Bidirectional) @@ -197,7 +198,7 @@ enum class IntKey( defaultValue = 51, min = 0, max = 100, - titleResId = R.string.pref_title_batt_warning, + title = KeysStrings.pref_title_batt_warning, defaultedBySM = true, unitType = UnitType.PERCENT, sync = SyncSpec(SyncChannel.Cold, SyncDirection.Bidirectional) @@ -207,7 +208,7 @@ enum class IntKey( defaultValue = 26, min = 0, max = 100, - titleResId = R.string.pref_title_batt_critical, + title = KeysStrings.pref_title_batt_critical, defaultedBySM = true, unitType = UnitType.PERCENT, sync = SyncSpec(SyncChannel.Cold, SyncDirection.Bidirectional) @@ -217,8 +218,8 @@ enum class IntKey( defaultValue = 100, min = 10, max = 100, - titleResId = R.string.pref_title_bolus_percentage, - summaryResId = R.string.deliverpartofboluswizard, + title = KeysStrings.pref_title_bolus_percentage, + summary = KeysStrings.deliverpartofboluswizard, unitType = UnitType.PERCENT, sync = SyncSpec(SyncChannel.Cold, SyncDirection.Bidirectional) ), @@ -227,8 +228,8 @@ enum class IntKey( defaultValue = 16, min = 6, max = 120, - titleResId = R.string.pref_title_reset_bolus_percentage_time, - summaryResId = R.string.deliver_part_of_boluswizard_reset_time, + title = KeysStrings.pref_title_reset_bolus_percentage_time, + summary = KeysStrings.deliver_part_of_boluswizard_reset_time, defaultedBySM = true, engineeringModeOnly = true, unitType = UnitType.MIN, @@ -239,7 +240,7 @@ enum class IntKey( defaultValue = 1, min = 0, max = 180, - titleResId = R.string.pref_title_protection_timeout, + title = KeysStrings.pref_title_protection_timeout, defaultedBySM = true, unitType = UnitType.SEC, visibility = ElementVisibility.stringNotEmpty { StringKey.ProtectionMasterPassword } @@ -252,15 +253,15 @@ enum class IntKey( defaultValue = ProtectionType.NONE.ordinal, min = ProtectionType.NONE.ordinal, max = ProtectionType.CUSTOM_PIN.ordinal, - titleResId = R.string.pref_title_protection_type_application, - summaryResId = R.string.pref_summary_protection_type_application, + title = KeysStrings.pref_title_protection_type_application, + summary = KeysStrings.pref_summary_protection_type_application, preferenceType = PreferenceType.LIST, - entries = mapOf( - ProtectionType.NONE.ordinal to R.string.noprotection, - ProtectionType.BIOMETRIC.ordinal to R.string.biometric, - ProtectionType.MASTER_PASSWORD.ordinal to R.string.master_password, - ProtectionType.CUSTOM_PASSWORD.ordinal to R.string.custom_password, - ProtectionType.CUSTOM_PIN.ordinal to R.string.custom_pin + entriesRefs = mapOf( + ProtectionType.NONE.ordinal to KeysStrings.noprotection, + ProtectionType.BIOMETRIC.ordinal to KeysStrings.biometric, + ProtectionType.MASTER_PASSWORD.ordinal to KeysStrings.master_password, + ProtectionType.CUSTOM_PASSWORD.ordinal to KeysStrings.custom_password, + ProtectionType.CUSTOM_PIN.ordinal to KeysStrings.custom_pin ), visibility = ElementVisibility.stringNotEmpty { StringKey.ProtectionMasterPassword } ), @@ -269,15 +270,15 @@ enum class IntKey( defaultValue = ProtectionType.NONE.ordinal, min = ProtectionType.NONE.ordinal, max = ProtectionType.CUSTOM_PIN.ordinal, - titleResId = R.string.pref_title_protection_type_bolus, - summaryResId = R.string.pref_summary_protection_type_bolus, + title = KeysStrings.pref_title_protection_type_bolus, + summary = KeysStrings.pref_summary_protection_type_bolus, preferenceType = PreferenceType.LIST, - entries = mapOf( - ProtectionType.NONE.ordinal to R.string.noprotection, - ProtectionType.BIOMETRIC.ordinal to R.string.biometric, - ProtectionType.MASTER_PASSWORD.ordinal to R.string.master_password, - ProtectionType.CUSTOM_PASSWORD.ordinal to R.string.custom_password, - ProtectionType.CUSTOM_PIN.ordinal to R.string.custom_pin + entriesRefs = mapOf( + ProtectionType.NONE.ordinal to KeysStrings.noprotection, + ProtectionType.BIOMETRIC.ordinal to KeysStrings.biometric, + ProtectionType.MASTER_PASSWORD.ordinal to KeysStrings.master_password, + ProtectionType.CUSTOM_PASSWORD.ordinal to KeysStrings.custom_password, + ProtectionType.CUSTOM_PIN.ordinal to KeysStrings.custom_pin ), visibility = ElementVisibility.stringNotEmpty { StringKey.ProtectionMasterPassword }, enabledCondition = PreferenceEnabledCondition { ctx -> @@ -289,26 +290,26 @@ enum class IntKey( defaultValue = ProtectionType.NONE.ordinal, min = ProtectionType.NONE.ordinal, max = ProtectionType.CUSTOM_PIN.ordinal, - titleResId = R.string.pref_title_protection_type_settings, - summaryResId = R.string.pref_summary_protection_type_settings, + title = KeysStrings.pref_title_protection_type_settings, + summary = KeysStrings.pref_summary_protection_type_settings, preferenceType = PreferenceType.LIST, - entries = mapOf( - ProtectionType.NONE.ordinal to R.string.noprotection, - ProtectionType.BIOMETRIC.ordinal to R.string.biometric, - ProtectionType.MASTER_PASSWORD.ordinal to R.string.master_password, - ProtectionType.CUSTOM_PASSWORD.ordinal to R.string.custom_password, - ProtectionType.CUSTOM_PIN.ordinal to R.string.custom_pin + entriesRefs = mapOf( + ProtectionType.NONE.ordinal to KeysStrings.noprotection, + ProtectionType.BIOMETRIC.ordinal to KeysStrings.biometric, + ProtectionType.MASTER_PASSWORD.ordinal to KeysStrings.master_password, + ProtectionType.CUSTOM_PASSWORD.ordinal to KeysStrings.custom_password, + ProtectionType.CUSTOM_PIN.ordinal to KeysStrings.custom_pin ), visibility = ElementVisibility.stringNotEmpty { StringKey.ProtectionMasterPassword } ), - SafetyMaxCarbs(key = "treatmentssafety_maxcarbs", defaultValue = 48, min = 1, max = 200, titleResId = R.string.pref_title_max_carbs, unitType = UnitType.GRAMS, sync = SyncSpec(SyncChannel.Cold, SyncDirection.Bidirectional)), + SafetyMaxCarbs(key = "treatmentssafety_maxcarbs", defaultValue = 48, min = 1, max = 200, title = KeysStrings.pref_title_max_carbs, unitType = UnitType.GRAMS, sync = SyncSpec(SyncChannel.Cold, SyncDirection.Bidirectional)), LoopOpenModeMinChange( key = "loop_openmode_min_change", defaultValue = 30, min = 0, max = 50, - titleResId = R.string.pref_title_open_mode_min_change, - summaryResId = R.string.loop_open_mode_min_change_summary, + title = KeysStrings.pref_title_open_mode_min_change, + summary = KeysStrings.loop_open_mode_min_change_summary, defaultedBySM = true, unitType = UnitType.PERCENT ), @@ -317,7 +318,7 @@ enum class IntKey( defaultValue = 3, min = 1, max = 10, - titleResId = R.string.pref_title_smb_frequency, + title = KeysStrings.pref_title_smb_frequency, defaultedBySM = true, dependency = BooleanKey.ApsUseSmb, unitType = UnitType.MIN, @@ -328,14 +329,14 @@ enum class IntKey( defaultValue = 30, min = 15, max = 120, - titleResId = R.string.pref_title_smb_max_minutes, + title = KeysStrings.pref_title_smb_max_minutes, defaultedBySM = true, dependency = BooleanKey.ApsUseSmb, unitType = UnitType.MIN, sync = SyncSpec(SyncChannel.Cold, SyncDirection.Bidirectional) ), ApsUamMaxMinutesOfBasalToLimitSmb( - key = "uamsmbmaxminutes", defaultValue = 30, min = 15, max = 120, titleResId = R.string.pref_title_uam_smb_max_minutes, summaryResId = R.string.uam_smb_max_minutes, defaultedBySM = true, dependency = BooleanKey.ApsUseSmb, + key = "uamsmbmaxminutes", defaultValue = 30, min = 15, max = 120, title = KeysStrings.pref_title_uam_smb_max_minutes, summary = KeysStrings.uam_smb_max_minutes, defaultedBySM = true, dependency = BooleanKey.ApsUseSmb, visibility = ElementVisibility { it.preferences.get(BooleanKey.ApsUseUam) }, unitType = UnitType.MIN, sync = SyncSpec(SyncChannel.Cold, SyncDirection.Bidirectional) @@ -345,8 +346,8 @@ enum class IntKey( defaultValue = 1, min = 1, max = 100, - titleResId = R.string.pref_title_carbs_request_threshold, - summaryResId = R.string.carbs_req_threshold_summary, + title = KeysStrings.pref_title_carbs_request_threshold, + summary = KeysStrings.carbs_req_threshold_summary, defaultedBySM = true, unitType = UnitType.GRAMS, sync = SyncSpec(SyncChannel.Cold, SyncDirection.Bidirectional) @@ -356,8 +357,8 @@ enum class IntKey( defaultValue = 160, min = 120, max = 200, - titleResId = R.string.pref_title_half_basal_exercise_target, - summaryResId = R.string.half_basal_exercise_target_summary, + title = KeysStrings.pref_title_half_basal_exercise_target, + summary = KeysStrings.half_basal_exercise_target_summary, defaultedBySM = true, unitType = UnitType.MGDL, sync = SyncSpec(SyncChannel.Cold, SyncDirection.Bidirectional) @@ -367,8 +368,8 @@ enum class IntKey( defaultValue = 100, min = 10, max = 100, - titleResId = R.string.pref_title_iob_threshold_percent, - summaryResId = R.string.openapsama_iob_threshold_percent_summary, + title = KeysStrings.pref_title_iob_threshold_percent, + summary = KeysStrings.openapsama_iob_threshold_percent_summary, defaultedBySM = true, unitType = UnitType.PERCENT, sync = SyncSpec(SyncChannel.Cold, SyncDirection.Bidirectional) @@ -378,8 +379,8 @@ enum class IntKey( defaultValue = 100, min = 1, max = 300, - titleResId = R.string.pref_title_dynisf_adjustment_factor, - summaryResId = R.string.dyn_isf_adjust_summary, + title = KeysStrings.pref_title_dynisf_adjustment_factor, + summary = KeysStrings.dyn_isf_adjust_summary, dependency = BooleanKey.ApsUseDynamicSensitivity, unitType = UnitType.PERCENT, sync = SyncSpec(SyncChannel.Cold, SyncDirection.Bidirectional) @@ -389,19 +390,19 @@ enum class IntKey( defaultValue = 24, min = 4, max = 24, - titleResId = R.string.pref_title_autosens_period, - summaryResId = R.string.openapsama_autosens_period_summary, + title = KeysStrings.pref_title_autosens_period, + summary = KeysStrings.openapsama_autosens_period_summary, calculatedDefaultValue = true, unitType = UnitType.HOURS, sync = SyncSpec(SyncChannel.Cold, SyncDirection.Bidirectional) ), - MaintenanceLogsAmount(key = "maintenance_logs_amount", defaultValue = 2, min = 1, max = 10, titleResId = R.string.pref_title_logs_amount, defaultedBySM = true), + MaintenanceLogsAmount(key = "maintenance_logs_amount", defaultValue = 2, min = 1, max = 10, title = KeysStrings.pref_title_logs_amount, defaultedBySM = true), AlertsStaleDataThreshold( key = "missed_bg_readings_threshold", defaultValue = 30, min = 15, max = 10000, - titleResId = R.string.pref_title_stale_data_threshold, + title = KeysStrings.pref_title_stale_data_threshold, defaultedBySM = true, dependency = BooleanKey.AlertMissedBgReading, unitType = UnitType.MIN @@ -411,20 +412,20 @@ enum class IntKey( defaultValue = 30, min = 30, max = 300, - titleResId = R.string.pref_title_pump_unreachable_threshold, + title = KeysStrings.pref_title_pump_unreachable_threshold, defaultedBySM = true, dependency = BooleanKey.AlertPumpUnreachable, unitType = UnitType.MIN ), - AutotuneDefaultTuneDays(key = "autotune_default_tune_days", defaultValue = 5, min = 1, max = 30, titleResId = R.string.pref_title_autotune_days, summaryResId = R.string.autotune_default_tune_days_summary, unitType = UnitType.DAYS), + AutotuneDefaultTuneDays(key = "autotune_default_tune_days", defaultValue = 5, min = 1, max = 30, title = KeysStrings.pref_title_autotune_days, summary = KeysStrings.autotune_default_tune_days_summary, unitType = UnitType.DAYS), SmsRemoteBolusDistance( key = "smscommunicator_remotebolusmindistance", defaultValue = 15, min = 3, max = 60, - titleResId = R.string.pref_title_sms_remote_bolus_distance, + title = KeysStrings.pref_title_sms_remote_bolus_distance, unitType = UnitType.MIN, // Enabled only when multiple phone numbers are configured (2FA requirement) enabledCondition = PreferenceEnabledCondition { ctx -> @@ -433,22 +434,25 @@ enum class IntKey( } ), - BgSourceRandomInterval(key = "randombg_interval_min", defaultValue = 5, min = 1, max = 15, titleResId = R.string.pref_title_random_bg_interval, defaultedBySM = true, unitType = UnitType.MIN), - NsClientAlarmStaleData(key = "ns_alarm_stale_data_value", defaultValue = 16, min = 15, max = 120, titleResId = R.string.pref_title_alarm_stale_data, unitType = UnitType.MIN), - NsClientUrgentAlarmStaleData(key = "ns_alarm_urgent_stale_data_value", defaultValue = 31, min = 30, max = 180, titleResId = R.string.pref_title_urgent_alarm_stale_data, unitType = UnitType.MIN), + BgSourceRandomInterval(key = "randombg_interval_min", defaultValue = 5, min = 1, max = 15, title = KeysStrings.pref_title_random_bg_interval, defaultedBySM = true, unitType = UnitType.MIN), + NsClientAlarmStaleData(key = "ns_alarm_stale_data_value", defaultValue = 16, min = 15, max = 120, title = KeysStrings.pref_title_alarm_stale_data, unitType = UnitType.MIN), + NsClientUrgentAlarmStaleData(key = "ns_alarm_urgent_stale_data_value", defaultValue = 31, min = 30, max = 180, title = KeysStrings.pref_title_urgent_alarm_stale_data, unitType = UnitType.MIN), SiteRotationUserProfile( key = "site_rotation_user_profile", defaultValue = 0, min = 0, max = 2, - titleResId = R.string.pref_title_site_rotation_profile, + title = KeysStrings.pref_title_site_rotation_profile, preferenceType = PreferenceType.LIST, - entries = mapOf( - 0 to R.string.site_rotation_profile_man, - 1 to R.string.site_rotation_profile_woman, - 2 to R.string.site_rotation_profile_child + entriesRefs = mapOf( + 0 to KeysStrings.site_rotation_profile_man, + 1 to KeysStrings.site_rotation_profile_woman, + 2 to KeysStrings.site_rotation_profile_child ), sync = SyncSpec(SyncChannel.Cold, SyncDirection.Bidirectional) ), -} \ No newline at end of file + ; + + override val entries: Map = entriesRefs +} diff --git a/core/keys/src/main/kotlin/app/aaps/core/keys/IntNonKey.kt b/core/keys/src/commonMain/kotlin/app/aaps/core/keys/IntNonKey.kt similarity index 100% rename from core/keys/src/main/kotlin/app/aaps/core/keys/IntNonKey.kt rename to core/keys/src/commonMain/kotlin/app/aaps/core/keys/IntNonKey.kt diff --git a/core/keys/src/main/kotlin/app/aaps/core/keys/IntentKey.kt b/core/keys/src/commonMain/kotlin/app/aaps/core/keys/IntentKey.kt similarity index 76% rename from core/keys/src/main/kotlin/app/aaps/core/keys/IntentKey.kt rename to core/keys/src/commonMain/kotlin/app/aaps/core/keys/IntentKey.kt index a847e837c19c..77ab5552897a 100644 --- a/core/keys/src/main/kotlin/app/aaps/core/keys/IntentKey.kt +++ b/core/keys/src/commonMain/kotlin/app/aaps/core/keys/IntentKey.kt @@ -2,6 +2,7 @@ package app.aaps.core.keys import app.aaps.core.keys.interfaces.BooleanPreferenceKey import app.aaps.core.keys.interfaces.IntentPreferenceKey +import app.aaps.core.keys.interfaces.TextRef /** * Legacy IntentKey enum - keys have been migrated to module-specific key enums: @@ -15,8 +16,8 @@ import app.aaps.core.keys.interfaces.IntentPreferenceKey */ enum class IntentKey( override val key: String, - override val titleResId: Int, - override val summaryResId: Int? = null, + override val title: TextRef, + override val summary: TextRef? = null, override val preferenceType: PreferenceType = PreferenceType.CLICK, override val defaultedBySM: Boolean = false, override val showInApsMode: Boolean = true, @@ -26,4 +27,10 @@ enum class IntentKey( override val negativeDependency: BooleanPreferenceKey? = null, override val hideParentScreenIfHidden: Boolean = false, override val exportable: Boolean = false -) : IntentPreferenceKey \ No newline at end of file +) : IntentPreferenceKey { + + // This enum has no constants (see the note above), but the interface still requires the + // properties. The `;` is what separates the - empty - constant list from the members. + ; + +} diff --git a/core/keys/src/main/kotlin/app/aaps/core/keys/LongComposedKey.kt b/core/keys/src/commonMain/kotlin/app/aaps/core/keys/LongComposedKey.kt similarity index 100% rename from core/keys/src/main/kotlin/app/aaps/core/keys/LongComposedKey.kt rename to core/keys/src/commonMain/kotlin/app/aaps/core/keys/LongComposedKey.kt diff --git a/core/keys/src/main/kotlin/app/aaps/core/keys/LongNonKey.kt b/core/keys/src/commonMain/kotlin/app/aaps/core/keys/LongNonKey.kt similarity index 100% rename from core/keys/src/main/kotlin/app/aaps/core/keys/LongNonKey.kt rename to core/keys/src/commonMain/kotlin/app/aaps/core/keys/LongNonKey.kt diff --git a/core/keys/src/main/kotlin/app/aaps/core/keys/PreferenceType.kt b/core/keys/src/commonMain/kotlin/app/aaps/core/keys/PreferenceType.kt similarity index 100% rename from core/keys/src/main/kotlin/app/aaps/core/keys/PreferenceType.kt rename to core/keys/src/commonMain/kotlin/app/aaps/core/keys/PreferenceType.kt diff --git a/core/keys/src/main/kotlin/app/aaps/core/keys/ProfileComposedBooleanKey.kt b/core/keys/src/commonMain/kotlin/app/aaps/core/keys/ProfileComposedBooleanKey.kt similarity index 100% rename from core/keys/src/main/kotlin/app/aaps/core/keys/ProfileComposedBooleanKey.kt rename to core/keys/src/commonMain/kotlin/app/aaps/core/keys/ProfileComposedBooleanKey.kt diff --git a/core/keys/src/main/kotlin/app/aaps/core/keys/ProfileComposedStringKey.kt b/core/keys/src/commonMain/kotlin/app/aaps/core/keys/ProfileComposedStringKey.kt similarity index 100% rename from core/keys/src/main/kotlin/app/aaps/core/keys/ProfileComposedStringKey.kt rename to core/keys/src/commonMain/kotlin/app/aaps/core/keys/ProfileComposedStringKey.kt diff --git a/core/keys/src/main/kotlin/app/aaps/core/keys/ProfileIntKey.kt b/core/keys/src/commonMain/kotlin/app/aaps/core/keys/ProfileIntKey.kt similarity index 100% rename from core/keys/src/main/kotlin/app/aaps/core/keys/ProfileIntKey.kt rename to core/keys/src/commonMain/kotlin/app/aaps/core/keys/ProfileIntKey.kt diff --git a/core/keys/src/main/kotlin/app/aaps/core/keys/ProtectionType.kt b/core/keys/src/commonMain/kotlin/app/aaps/core/keys/ProtectionType.kt similarity index 100% rename from core/keys/src/main/kotlin/app/aaps/core/keys/ProtectionType.kt rename to core/keys/src/commonMain/kotlin/app/aaps/core/keys/ProtectionType.kt diff --git a/core/keys/src/main/kotlin/app/aaps/core/keys/StringKey.kt b/core/keys/src/commonMain/kotlin/app/aaps/core/keys/StringKey.kt similarity index 50% rename from core/keys/src/main/kotlin/app/aaps/core/keys/StringKey.kt rename to core/keys/src/commonMain/kotlin/app/aaps/core/keys/StringKey.kt index 78a79dce3c62..9d1dbc677e14 100644 --- a/core/keys/src/main/kotlin/app/aaps/core/keys/StringKey.kt +++ b/core/keys/src/commonMain/kotlin/app/aaps/core/keys/StringKey.kt @@ -1,21 +1,28 @@ package app.aaps.core.keys import app.aaps.core.keys.interfaces.BooleanPreferenceKey -import app.aaps.core.keys.interfaces.PreferenceEnabledCondition import app.aaps.core.keys.interfaces.ElementVisibility +import app.aaps.core.keys.interfaces.PreferenceEnabledCondition import app.aaps.core.keys.interfaces.StringPreferenceKey import app.aaps.core.keys.interfaces.StringValidator import app.aaps.core.keys.interfaces.SyncChannel import app.aaps.core.keys.interfaces.SyncDirection import app.aaps.core.keys.interfaces.SyncSpec +import app.aaps.core.keys.interfaces.TextRef enum class StringKey( override val key: String, override val defaultValue: String, - override val titleResId: Int, - override val summaryResId: Int? = null, + override val title: TextRef, + override val summary: TextRef? = null, override val preferenceType: PreferenceType = PreferenceType.TEXT_FIELD, - override val entries: Map = emptyMap(), + private val entriesRefs: Map = emptyMap(), + /** + * Entry labels that are not a resource in this module. Only the units preference needs this: + * "mg/dL" and "mmol/L" read the same in every language, and the `units_*` strings themselves + * now live in `:core:ui`, which this module cannot depend on. + */ + private val entriesLiterals: Map = emptyMap(), override val defaultedBySM: Boolean = false, override val showInApsMode: Boolean = true, override val showInNsClientMode: Boolean = true, @@ -36,116 +43,116 @@ enum class StringKey( GeneralUnits( key = "units", defaultValue = "mg/dl", - titleResId = R.string.pref_title_units, + title = KeysStrings.pref_title_units, preferenceType = PreferenceType.LIST, - entries = mapOf( - "mg/dl" to R.string.units_mgdl, - "mmol" to R.string.units_mmol + entriesLiterals = mapOf( + "mg/dl" to "mg/dL", + "mmol" to "mmol/L" ), sync = SyncSpec(SyncChannel.Cold, SyncDirection.Bidirectional) ), GeneralLanguage( key = "language", defaultValue = "default", - titleResId = R.string.pref_title_language, + title = KeysStrings.pref_title_language, preferenceType = PreferenceType.LIST, - entries = mapOf( - "default" to R.string.lang_default, - "en" to R.string.lang_en, - "af" to R.string.lang_af, - "bg" to R.string.lang_bg, - "cs" to R.string.lang_cs, - "de" to R.string.lang_de, - "dk" to R.string.lang_dk, - "fr" to R.string.lang_fr, - "nl" to R.string.lang_nl, - "es" to R.string.lang_es, - "el" to R.string.lang_el, - "ga" to R.string.lang_ga, - "it" to R.string.lang_it, - "ko" to R.string.lang_ko, - "lt" to R.string.lang_lt, - "nb" to R.string.lang_nb, - "pl" to R.string.lang_pl, - "pt" to R.string.lang_pt, - "pt_BR" to R.string.lang_pt_br, - "ro" to R.string.lang_ro, - "ru" to R.string.lang_ru, - "sk" to R.string.lang_sk, - "sv" to R.string.lang_sv, - "tr" to R.string.lang_tr, - "zh_TW" to R.string.lang_zh_tw, - "zh_CN" to R.string.lang_zh_cn + entriesRefs = mapOf( + "default" to KeysStrings.lang_default, + "en" to KeysStrings.lang_en, + "af" to KeysStrings.lang_af, + "bg" to KeysStrings.lang_bg, + "cs" to KeysStrings.lang_cs, + "de" to KeysStrings.lang_de, + "dk" to KeysStrings.lang_dk, + "fr" to KeysStrings.lang_fr, + "nl" to KeysStrings.lang_nl, + "es" to KeysStrings.lang_es, + "el" to KeysStrings.lang_el, + "ga" to KeysStrings.lang_ga, + "it" to KeysStrings.lang_it, + "ko" to KeysStrings.lang_ko, + "lt" to KeysStrings.lang_lt, + "nb" to KeysStrings.lang_nb, + "pl" to KeysStrings.lang_pl, + "pt" to KeysStrings.lang_pt, + "pt_BR" to KeysStrings.lang_pt_br, + "ro" to KeysStrings.lang_ro, + "ru" to KeysStrings.lang_ru, + "sk" to KeysStrings.lang_sk, + "sv" to KeysStrings.lang_sv, + "tr" to KeysStrings.lang_tr, + "zh_TW" to KeysStrings.lang_zh_tw, + "zh_CN" to KeysStrings.lang_zh_cn ), defaultedBySM = true ), GeneralPatientName( key = "patient_name", defaultValue = "", - titleResId = R.string.pref_title_patient_name, - summaryResId = R.string.pref_summary_patient_name, + title = KeysStrings.pref_title_patient_name, + summary = KeysStrings.pref_summary_patient_name, validator = StringValidator.personName() ), GeneralDarkMode( key = "use_dark_mode", defaultValue = "dark", - titleResId = R.string.pref_title_app_color_scheme, - summaryResId = R.string.pref_summary_theme_switcher, + title = KeysStrings.pref_title_app_color_scheme, + summary = KeysStrings.pref_summary_theme_switcher, preferenceType = PreferenceType.LIST, - entries = mapOf( - "dark" to R.string.pref_dark_theme, - "light" to R.string.pref_light_theme, - "system" to R.string.pref_follow_system_theme + entriesRefs = mapOf( + "dark" to KeysStrings.pref_dark_theme, + "light" to KeysStrings.pref_light_theme, + "system" to KeysStrings.pref_follow_system_theme ), defaultedBySM = true ), - AapsDirectoryUri(key = "aaps_directory", defaultValue = "", titleResId = R.string.pref_title_aaps_directory), + AapsDirectoryUri(key = "aaps_directory", defaultValue = "", title = KeysStrings.pref_title_aaps_directory), - ProtectionMasterPassword(key = "master_password", defaultValue = "", titleResId = R.string.pref_title_master_password, isPassword = true, isHashed = true), + ProtectionMasterPassword(key = "master_password", defaultValue = "", title = KeysStrings.pref_title_master_password, isPassword = true, isHashed = true), ProtectionSettingsPassword( - key = "settings_password", defaultValue = "", titleResId = R.string.pref_title_settings_password, isPassword = true, isHashed = true, + key = "settings_password", defaultValue = "", title = KeysStrings.pref_title_settings_password, isPassword = true, isHashed = true, visibility = ElementVisibility.intEquals({ IntKey.ProtectionTypeSettings }, ProtectionType.CUSTOM_PASSWORD.ordinal) ), ProtectionSettingsPin( - key = "settings_pin", defaultValue = "", titleResId = R.string.pref_title_settings_pin, isPin = true, isHashed = true, + key = "settings_pin", defaultValue = "", title = KeysStrings.pref_title_settings_pin, isPin = true, isHashed = true, visibility = ElementVisibility.intEquals({ IntKey.ProtectionTypeSettings }, ProtectionType.CUSTOM_PIN.ordinal) ), ProtectionApplicationPassword( - key = "application_password", defaultValue = "", titleResId = R.string.pref_title_application_password, isPassword = true, isHashed = true, + key = "application_password", defaultValue = "", title = KeysStrings.pref_title_application_password, isPassword = true, isHashed = true, visibility = ElementVisibility.intEquals({ IntKey.ProtectionTypeApplication }, ProtectionType.CUSTOM_PASSWORD.ordinal) ), ProtectionApplicationPin( - key = "application_pin", defaultValue = "", titleResId = R.string.pref_title_application_pin, isPin = true, isHashed = true, + key = "application_pin", defaultValue = "", title = KeysStrings.pref_title_application_pin, isPin = true, isHashed = true, visibility = ElementVisibility.intEquals({ IntKey.ProtectionTypeApplication }, ProtectionType.CUSTOM_PIN.ordinal) ), ProtectionBolusPassword( - key = "bolus_password", defaultValue = "", titleResId = R.string.pref_title_bolus_password, isPassword = true, isHashed = true, + key = "bolus_password", defaultValue = "", title = KeysStrings.pref_title_bolus_password, isPassword = true, isHashed = true, visibility = ElementVisibility.intEquals({ IntKey.ProtectionTypeBolus }, ProtectionType.CUSTOM_PASSWORD.ordinal) ), ProtectionBolusPin( - key = "bolus_pin", defaultValue = "", titleResId = R.string.pref_title_bolus_pin, isPin = true, isHashed = true, + key = "bolus_pin", defaultValue = "", title = KeysStrings.pref_title_bolus_pin, isPin = true, isHashed = true, visibility = ElementVisibility.intEquals({ IntKey.ProtectionTypeBolus }, ProtectionType.CUSTOM_PIN.ordinal) ), - SafetyAge(key = "age", defaultValue = "adult", titleResId = R.string.pref_title_patient_age, preferenceType = PreferenceType.LIST, sync = SyncSpec(SyncChannel.Cold, SyncDirection.Bidirectional)), + SafetyAge(key = "age", defaultValue = "adult", title = KeysStrings.pref_title_patient_age, preferenceType = PreferenceType.LIST, sync = SyncSpec(SyncChannel.Cold, SyncDirection.Bidirectional)), MaintenanceEmail( key = "maintenance_logs_email", defaultValue = "logs@aaps.app", - titleResId = R.string.maintenance_email, + title = KeysStrings.maintenance_email, defaultedBySM = true, validator = StringValidator.email() ), - MaintenanceIdentification(key = "email_for_crash_report", defaultValue = "", titleResId = R.string.pref_title_identification), + MaintenanceIdentification(key = "email_for_crash_report", defaultValue = "", title = KeysStrings.pref_title_identification), AutomationLocation( key = "location", defaultValue = "PASSIVE", - titleResId = R.string.pref_title_automation_location, + title = KeysStrings.pref_title_automation_location, preferenceType = PreferenceType.LIST, - entries = mapOf( - "PASSIVE" to R.string.automation_location_passive, - "NETWORK" to R.string.automation_location_network, - "GPS" to R.string.automation_location_gps + entriesRefs = mapOf( + "PASSIVE" to KeysStrings.automation_location_passive, + "NETWORK" to KeysStrings.automation_location_network, + "GPS" to KeysStrings.automation_location_gps ), sync = SyncSpec(SyncChannel.Cold, SyncDirection.Bidirectional) ), @@ -153,51 +160,55 @@ enum class StringKey( SmsAllowedNumbers( key = "smscommunicator_allowednumbers", defaultValue = "", - titleResId = R.string.smscommunicator_allowednumbers, - summaryResId = R.string.smscommunicator_allowednumbers_summary, + title = KeysStrings.smscommunicator_allowednumbers, + summary = KeysStrings.smscommunicator_allowednumbers_summary, validator = StringValidator.multiPhone() ), SmsOtpPassword( key = "smscommunicator_otp_password", defaultValue = "", - titleResId = R.string.smscommunicator_otp_pin, - summaryResId = R.string.smscommunicator_otp_pin_summary, + title = KeysStrings.smscommunicator_otp_pin, + summary = KeysStrings.smscommunicator_otp_pin_summary, dependency = BooleanKey.SmsAllowRemoteCommands, isPassword = true, validator = StringValidator.pinStrength() ), - VirtualPumpType(key = "virtualpump_type", defaultValue = "Generic AAPS", titleResId = R.string.pref_title_virtual_pump_type, preferenceType = PreferenceType.LIST), + VirtualPumpType(key = "virtualpump_type", defaultValue = "Generic AAPS", title = KeysStrings.pref_title_virtual_pump_type, preferenceType = PreferenceType.LIST), NsClientUrl( key = "nsclientinternal_url", defaultValue = "", - titleResId = R.string.ns_client_url_title, - summaryResId = R.string.ns_client_url_summary, + title = KeysStrings.ns_client_url_title, + summary = KeysStrings.ns_client_url_summary, validator = StringValidator.httpsUrl() ), NsClientApiSecret( key = "nsclientinternal_api_secret", defaultValue = "", - titleResId = R.string.ns_client_secret_title, - summaryResId = R.string.ns_client_secret_summary, + title = KeysStrings.ns_client_secret_title, + summary = KeysStrings.ns_client_secret_summary, isPassword = true, validator = StringValidator.minLength(12) ), NsClientWifiSsids( key = "ns_wifi_ssids", defaultValue = "", - titleResId = R.string.ns_wifi_ssids, - summaryResId = R.string.ns_wifi_ssids_summary, + title = KeysStrings.ns_wifi_ssids, + summary = KeysStrings.ns_wifi_ssids_summary, dependency = BooleanKey.NsClientUseWifi ), NsClientAccessToken( key = "nsclient_token", defaultValue = "", - titleResId = R.string.nsclient_token_title, - summaryResId = R.string.nsclient_token_summary, + title = KeysStrings.nsclient_token_title, + summary = KeysStrings.nsclient_token_summary, isPassword = true, validator = StringValidator.minLength(17) ), + ; + + override val entries: Map = + entriesRefs + entriesLiterals.mapValues { TextRef.Literal(it.value) } } diff --git a/core/keys/src/main/kotlin/app/aaps/core/keys/StringNonKey.kt b/core/keys/src/commonMain/kotlin/app/aaps/core/keys/StringNonKey.kt similarity index 100% rename from core/keys/src/main/kotlin/app/aaps/core/keys/StringNonKey.kt rename to core/keys/src/commonMain/kotlin/app/aaps/core/keys/StringNonKey.kt diff --git a/core/keys/src/main/kotlin/app/aaps/core/keys/UnitDoubleKey.kt b/core/keys/src/commonMain/kotlin/app/aaps/core/keys/UnitDoubleKey.kt similarity index 74% rename from core/keys/src/main/kotlin/app/aaps/core/keys/UnitDoubleKey.kt rename to core/keys/src/commonMain/kotlin/app/aaps/core/keys/UnitDoubleKey.kt index cec1956effa7..82a7795d552e 100644 --- a/core/keys/src/main/kotlin/app/aaps/core/keys/UnitDoubleKey.kt +++ b/core/keys/src/commonMain/kotlin/app/aaps/core/keys/UnitDoubleKey.kt @@ -4,6 +4,7 @@ import app.aaps.core.keys.interfaces.BooleanPreferenceKey import app.aaps.core.keys.interfaces.SyncChannel import app.aaps.core.keys.interfaces.SyncDirection import app.aaps.core.keys.interfaces.SyncSpec +import app.aaps.core.keys.interfaces.TextRef import app.aaps.core.keys.interfaces.UnitDoublePreferenceKey enum class UnitDoubleKey( @@ -11,8 +12,8 @@ enum class UnitDoubleKey( override val defaultValue: Double, override val minMgdl: Int, override val maxMgdl: Int, - override val titleResId: Int, - override val summaryResId: Int? = null, + override val title: TextRef, + override val summary: TextRef? = null, override val preferenceType: PreferenceType = PreferenceType.TEXT_FIELD, override val defaultedBySM: Boolean = false, override val showInApsMode: Boolean = true, @@ -25,17 +26,19 @@ enum class UnitDoubleKey( override val sync: SyncSpec? = null ) : UnitDoublePreferenceKey { - OverviewLowMark(key = "low_mark", defaultValue = 72.0, minMgdl = 25, maxMgdl = 160, titleResId = R.string.pref_title_low_mark, sync = SyncSpec(SyncChannel.Cold, SyncDirection.Bidirectional)), - OverviewHighMark(key = "high_mark", defaultValue = 180.0, minMgdl = 90, maxMgdl = 250, titleResId = R.string.pref_title_high_mark, sync = SyncSpec(SyncChannel.Cold, SyncDirection.Bidirectional)), + OverviewLowMark(key = "low_mark", defaultValue = 72.0, minMgdl = 25, maxMgdl = 160, title = KeysStrings.pref_title_low_mark, sync = SyncSpec(SyncChannel.Cold, SyncDirection.Bidirectional)), + OverviewHighMark(key = "high_mark", defaultValue = 180.0, minMgdl = 90, maxMgdl = 250, title = KeysStrings.pref_title_high_mark, sync = SyncSpec(SyncChannel.Cold, SyncDirection.Bidirectional)), ApsLgsThreshold( key = "lgsThreshold", defaultValue = 65.0, minMgdl = 60, maxMgdl = 100, - titleResId = R.string.pref_title_lgs_threshold, - summaryResId = R.string.lgs_threshold_summary, + title = KeysStrings.pref_title_lgs_threshold, + summary = KeysStrings.lgs_threshold_summary, defaultedBySM = true, dependency = BooleanKey.ApsUseDynamicSensitivity, sync = SyncSpec(SyncChannel.Cold, SyncDirection.Bidirectional) ) -} \ No newline at end of file + ; + +} diff --git a/core/keys/src/commonMain/kotlin/app/aaps/core/keys/UnitType.kt b/core/keys/src/commonMain/kotlin/app/aaps/core/keys/UnitType.kt new file mode 100644 index 000000000000..67a4428a058f --- /dev/null +++ b/core/keys/src/commonMain/kotlin/app/aaps/core/keys/UnitType.kt @@ -0,0 +1,47 @@ +package app.aaps.core.keys + +/** + * Enum defining unit types for preference values. + * Used to format values with appropriate units in UI. + * + * The mapping from a unit to its text lives in `:core:ui` (`UnitTypeText.kt`), not here, so that + * this file stays free of Android resources. + */ +enum class UnitType { + + NONE, + GRAMS, + MIN, + SEC, + HOURS, + HOURS_DOUBLE, + DAYS, + PERCENT, + INSULIN, + INSULIN_INT, + INSULIN_RATE, + DOUBLE, + DOUBLE_2, + DOUBLE_3, + MGDL +} + +/** + * Returns the number of decimal places for this unit type. + */ +fun UnitType.decimalPlaces(): Int = when (this) { + UnitType.DOUBLE_3 -> 3 + UnitType.DOUBLE_2 -> 2 + UnitType.INSULIN, UnitType.INSULIN_RATE, UnitType.DOUBLE, UnitType.HOURS_DOUBLE -> 1 + else -> 0 +} + +/** + * Returns the step size for slider/increment controls. + */ +fun UnitType.step(): Double = when (this) { + UnitType.DOUBLE_3 -> 0.001 + UnitType.DOUBLE_2 -> 0.01 + UnitType.INSULIN, UnitType.INSULIN_RATE, UnitType.DOUBLE, UnitType.HOURS_DOUBLE -> 0.1 + else -> 1.0 +} diff --git a/core/keys/src/main/kotlin/app/aaps/core/keys/interfaces/BooleanComposedNonPreferenceKey.kt b/core/keys/src/commonMain/kotlin/app/aaps/core/keys/interfaces/BooleanComposedNonPreferenceKey.kt similarity index 100% rename from core/keys/src/main/kotlin/app/aaps/core/keys/interfaces/BooleanComposedNonPreferenceKey.kt rename to core/keys/src/commonMain/kotlin/app/aaps/core/keys/interfaces/BooleanComposedNonPreferenceKey.kt diff --git a/core/keys/src/main/kotlin/app/aaps/core/keys/interfaces/BooleanNonPreferenceKey.kt b/core/keys/src/commonMain/kotlin/app/aaps/core/keys/interfaces/BooleanNonPreferenceKey.kt similarity index 100% rename from core/keys/src/main/kotlin/app/aaps/core/keys/interfaces/BooleanNonPreferenceKey.kt rename to core/keys/src/commonMain/kotlin/app/aaps/core/keys/interfaces/BooleanNonPreferenceKey.kt diff --git a/core/keys/src/main/kotlin/app/aaps/core/keys/interfaces/BooleanPreferenceKey.kt b/core/keys/src/commonMain/kotlin/app/aaps/core/keys/interfaces/BooleanPreferenceKey.kt similarity index 100% rename from core/keys/src/main/kotlin/app/aaps/core/keys/interfaces/BooleanPreferenceKey.kt rename to core/keys/src/commonMain/kotlin/app/aaps/core/keys/interfaces/BooleanPreferenceKey.kt diff --git a/core/keys/src/main/kotlin/app/aaps/core/keys/interfaces/ComposedKey.kt b/core/keys/src/commonMain/kotlin/app/aaps/core/keys/interfaces/ComposedKey.kt similarity index 100% rename from core/keys/src/main/kotlin/app/aaps/core/keys/interfaces/ComposedKey.kt rename to core/keys/src/commonMain/kotlin/app/aaps/core/keys/interfaces/ComposedKey.kt diff --git a/core/keys/src/main/kotlin/app/aaps/core/keys/interfaces/DoubleComposedNonPreferenceKey.kt b/core/keys/src/commonMain/kotlin/app/aaps/core/keys/interfaces/DoubleComposedNonPreferenceKey.kt similarity index 100% rename from core/keys/src/main/kotlin/app/aaps/core/keys/interfaces/DoubleComposedNonPreferenceKey.kt rename to core/keys/src/commonMain/kotlin/app/aaps/core/keys/interfaces/DoubleComposedNonPreferenceKey.kt diff --git a/core/keys/src/main/kotlin/app/aaps/core/keys/interfaces/DoubleNonPreferenceKey.kt b/core/keys/src/commonMain/kotlin/app/aaps/core/keys/interfaces/DoubleNonPreferenceKey.kt similarity index 100% rename from core/keys/src/main/kotlin/app/aaps/core/keys/interfaces/DoubleNonPreferenceKey.kt rename to core/keys/src/commonMain/kotlin/app/aaps/core/keys/interfaces/DoubleNonPreferenceKey.kt diff --git a/core/keys/src/main/kotlin/app/aaps/core/keys/interfaces/DoublePreferenceKey.kt b/core/keys/src/commonMain/kotlin/app/aaps/core/keys/interfaces/DoublePreferenceKey.kt similarity index 100% rename from core/keys/src/main/kotlin/app/aaps/core/keys/interfaces/DoublePreferenceKey.kt rename to core/keys/src/commonMain/kotlin/app/aaps/core/keys/interfaces/DoublePreferenceKey.kt diff --git a/core/keys/src/main/kotlin/app/aaps/core/keys/interfaces/ElementVisibility.kt b/core/keys/src/commonMain/kotlin/app/aaps/core/keys/interfaces/ElementVisibility.kt similarity index 100% rename from core/keys/src/main/kotlin/app/aaps/core/keys/interfaces/ElementVisibility.kt rename to core/keys/src/commonMain/kotlin/app/aaps/core/keys/interfaces/ElementVisibility.kt diff --git a/core/keys/src/main/kotlin/app/aaps/core/keys/interfaces/IntComposedNonPreferenceKey.kt b/core/keys/src/commonMain/kotlin/app/aaps/core/keys/interfaces/IntComposedNonPreferenceKey.kt similarity index 100% rename from core/keys/src/main/kotlin/app/aaps/core/keys/interfaces/IntComposedNonPreferenceKey.kt rename to core/keys/src/commonMain/kotlin/app/aaps/core/keys/interfaces/IntComposedNonPreferenceKey.kt diff --git a/core/keys/src/main/kotlin/app/aaps/core/keys/interfaces/IntNonPreferenceKey.kt b/core/keys/src/commonMain/kotlin/app/aaps/core/keys/interfaces/IntNonPreferenceKey.kt similarity index 100% rename from core/keys/src/main/kotlin/app/aaps/core/keys/interfaces/IntNonPreferenceKey.kt rename to core/keys/src/commonMain/kotlin/app/aaps/core/keys/interfaces/IntNonPreferenceKey.kt diff --git a/core/keys/src/main/kotlin/app/aaps/core/keys/interfaces/IntPreferenceKey.kt b/core/keys/src/commonMain/kotlin/app/aaps/core/keys/interfaces/IntPreferenceKey.kt similarity index 56% rename from core/keys/src/main/kotlin/app/aaps/core/keys/interfaces/IntPreferenceKey.kt rename to core/keys/src/commonMain/kotlin/app/aaps/core/keys/interfaces/IntPreferenceKey.kt index f5ad7da82267..6e377f009b49 100644 --- a/core/keys/src/main/kotlin/app/aaps/core/keys/interfaces/IntPreferenceKey.kt +++ b/core/keys/src/commonMain/kotlin/app/aaps/core/keys/interfaces/IntPreferenceKey.kt @@ -29,36 +29,32 @@ interface IntPreferenceKey : PreferenceKey, IntNonPreferenceKey { /** * Entries for LIST type preferences. - * Map of stored value -> label resource ID. + * Map of stored value -> label. * Empty map means no entries (not a list preference). */ - val entries: Map + val entries: Map get() = emptyMap() - /** - * Runtime-resolved entries for LIST type preferences. - * Map of stored value -> resolved label string. - * When set, this takes precedence over [entries] resource IDs. - */ - val resolvedEntries: Map? - get() = null } /** - * Wrapper that attaches runtime-resolved entries to an IntPreferenceKey. + * Wrapper that attaches entries to an IntPreferenceKey. * Uses delegation to preserve all other properties from the original key. */ class IntKeyWithEntries( private val delegate: IntPreferenceKey, - override val resolvedEntries: Map + override val entries: Map ) : IntPreferenceKey by delegate /** - * Creates a new IntPreferenceKey with runtime-resolved entries attached. - * Use this when entries need to be resolved at runtime (e.g., programmatic values). + * Creates a new IntPreferenceKey with entries attached. + * Use this when the entries are only known at run time - a generated range, or a list that depends + * on the connected device. * - * @param entries Map of stored value -> resolved label string + * @param entries Map of stored value -> label. Use [TextRef.AndroidRes] with arguments for anything the + * user reads, so it stays translatable; [TextRef.Literal] only for text that is genuinely not a + * resource, such as a device name. * @return A new IntPreferenceKey with the entries attached */ -fun IntPreferenceKey.withEntries(entries: Map): IntPreferenceKey = +fun IntPreferenceKey.withEntries(entries: Map): IntPreferenceKey = IntKeyWithEntries(this, entries) \ No newline at end of file diff --git a/core/keys/src/main/kotlin/app/aaps/core/keys/interfaces/IntentPreferenceKey.kt b/core/keys/src/commonMain/kotlin/app/aaps/core/keys/interfaces/IntentPreferenceKey.kt similarity index 71% rename from core/keys/src/main/kotlin/app/aaps/core/keys/interfaces/IntentPreferenceKey.kt rename to core/keys/src/commonMain/kotlin/app/aaps/core/keys/interfaces/IntentPreferenceKey.kt index db04b3002892..cbbe34b4497f 100644 --- a/core/keys/src/main/kotlin/app/aaps/core/keys/interfaces/IntentPreferenceKey.kt +++ b/core/keys/src/commonMain/kotlin/app/aaps/core/keys/interfaces/IntentPreferenceKey.kt @@ -10,18 +10,11 @@ interface IntentPreferenceKey : PreferenceKey { get() = null /** - * Activity class to launch (for PreferenceType.ACTIVITY). - * If set, clicking the preference will launch this activity. - */ - val activityClass: Class<*>? - get() = null - - /** - * String resource ID for confirmation dialog message. + * Confirmation dialog message. * When set, clicking this preference shows an OK/Cancel dialog before executing onClick. - * The dialog title uses [titleResId]. + * The dialog title uses [title]. */ - val confirmationMessageResId: Int? + val confirmationMessage: TextRef? get() = null /** @@ -31,13 +24,6 @@ interface IntentPreferenceKey : PreferenceKey { val onClick: (() -> Unit)? get() = null - /** - * Runtime-attached activity class. - * When set, this takes precedence over [activityClass]. - */ - val runtimeActivityClass: Class<*>? - get() = null - /** * Runtime-attached URL. * When set, this takes precedence over [urlResId]. @@ -65,14 +51,6 @@ class IntentKeyWithClick( override val onClick: () -> Unit ) : IntentPreferenceKey by delegate -/** - * Wrapper that attaches an activity class to an IntentPreferenceKey. - */ -class IntentKeyWithActivity( - private val delegate: IntentPreferenceKey, - override val runtimeActivityClass: Class<*> -) : IntentPreferenceKey by delegate - /** * Wrapper that attaches a URL to an IntentPreferenceKey. */ @@ -87,12 +65,6 @@ class IntentKeyWithUrl( fun IntentPreferenceKey.withClick(onClick: () -> Unit): IntentPreferenceKey = IntentKeyWithClick(this, onClick) -/** - * Creates a new IntentPreferenceKey with an activity class attached. - */ -fun IntentPreferenceKey.withActivity(activityClass: Class<*>): IntentPreferenceKey = - IntentKeyWithActivity(this, activityClass) - /** * Creates a new IntentPreferenceKey with a URL attached. */ diff --git a/core/keys/src/main/kotlin/app/aaps/core/keys/interfaces/LongComposedNonPreferenceKey.kt b/core/keys/src/commonMain/kotlin/app/aaps/core/keys/interfaces/LongComposedNonPreferenceKey.kt similarity index 100% rename from core/keys/src/main/kotlin/app/aaps/core/keys/interfaces/LongComposedNonPreferenceKey.kt rename to core/keys/src/commonMain/kotlin/app/aaps/core/keys/interfaces/LongComposedNonPreferenceKey.kt diff --git a/core/keys/src/main/kotlin/app/aaps/core/keys/interfaces/LongNonPreferenceKey.kt b/core/keys/src/commonMain/kotlin/app/aaps/core/keys/interfaces/LongNonPreferenceKey.kt similarity index 100% rename from core/keys/src/main/kotlin/app/aaps/core/keys/interfaces/LongNonPreferenceKey.kt rename to core/keys/src/commonMain/kotlin/app/aaps/core/keys/interfaces/LongNonPreferenceKey.kt diff --git a/core/keys/src/main/kotlin/app/aaps/core/keys/interfaces/LongPreferenceKey.kt b/core/keys/src/commonMain/kotlin/app/aaps/core/keys/interfaces/LongPreferenceKey.kt similarity index 100% rename from core/keys/src/main/kotlin/app/aaps/core/keys/interfaces/LongPreferenceKey.kt rename to core/keys/src/commonMain/kotlin/app/aaps/core/keys/interfaces/LongPreferenceKey.kt diff --git a/core/keys/src/main/kotlin/app/aaps/core/keys/interfaces/NonPreferenceKey.kt b/core/keys/src/commonMain/kotlin/app/aaps/core/keys/interfaces/NonPreferenceKey.kt similarity index 100% rename from core/keys/src/main/kotlin/app/aaps/core/keys/interfaces/NonPreferenceKey.kt rename to core/keys/src/commonMain/kotlin/app/aaps/core/keys/interfaces/NonPreferenceKey.kt diff --git a/core/keys/src/main/kotlin/app/aaps/core/keys/interfaces/PreferenceEnabledCondition.kt b/core/keys/src/commonMain/kotlin/app/aaps/core/keys/interfaces/PreferenceEnabledCondition.kt similarity index 100% rename from core/keys/src/main/kotlin/app/aaps/core/keys/interfaces/PreferenceEnabledCondition.kt rename to core/keys/src/commonMain/kotlin/app/aaps/core/keys/interfaces/PreferenceEnabledCondition.kt diff --git a/core/keys/src/main/kotlin/app/aaps/core/keys/interfaces/PreferenceKey.kt b/core/keys/src/commonMain/kotlin/app/aaps/core/keys/interfaces/PreferenceKey.kt similarity index 91% rename from core/keys/src/main/kotlin/app/aaps/core/keys/interfaces/PreferenceKey.kt rename to core/keys/src/commonMain/kotlin/app/aaps/core/keys/interfaces/PreferenceKey.kt index bc7598fcef92..8c8a55a616ea 100644 --- a/core/keys/src/main/kotlin/app/aaps/core/keys/interfaces/PreferenceKey.kt +++ b/core/keys/src/commonMain/kotlin/app/aaps/core/keys/interfaces/PreferenceKey.kt @@ -22,17 +22,16 @@ interface PreferenceKey : NonPreferenceKey, PreferenceItem { override val key: String /** - * String resource ID for preference title. - * Use ResourceHelper.gs(titleResId) for localized string. + * Preference title. + * Use `ResourceHelper.gs(title)` outside Compose, or `stringResource(title)` inside it. */ - val titleResId: Int + val title: TextRef /** - * String resource ID for preference summary/description. - * Use ResourceHelper.gs(summaryResId) for localized string. - * null means no summary. + * Preference summary/description. + * null means no summary - there is no "empty" sentinel any more. */ - val summaryResId: Int? + val summary: TextRef? get() = null /** diff --git a/core/keys/src/main/kotlin/app/aaps/core/keys/interfaces/Preferences.kt b/core/keys/src/commonMain/kotlin/app/aaps/core/keys/interfaces/Preferences.kt similarity index 97% rename from core/keys/src/main/kotlin/app/aaps/core/keys/interfaces/Preferences.kt rename to core/keys/src/commonMain/kotlin/app/aaps/core/keys/interfaces/Preferences.kt index 5cae08f6c736..bcdbd9abc83e 100644 --- a/core/keys/src/main/kotlin/app/aaps/core/keys/interfaces/Preferences.kt +++ b/core/keys/src/commonMain/kotlin/app/aaps/core/keys/interfaces/Preferences.kt @@ -590,17 +590,15 @@ interface Preferences { fun getIfExists(key: String): NonPreferenceKey? /** - * Find all [app.aaps.core.keys.interfaces.PreferenceKey] which have `dependency` or `negativeDependency` - * @param key string representation of key - * @return list of [app.aaps.core.keys.interfaces.PreferenceKey] - */ - fun getDependingOn(key: String): List - - /** - * Make new class available to Preference system - * Called from PluginBase::init + * Make new keys available to the Preference system. + * Called from PluginBase::init, normally as `registerPreferences(MyKey.entries + MyOtherKey.entries)`. + * + * Takes the key constants rather than their enum class: a `Class` only exists on the JVM, and + * the only thing the old signature ever did with it was read `enumConstants`. + * + * Registering the same key twice is allowed and does nothing. */ - fun registerPreferences(clazz: Class) + fun registerPreferences(keys: List) /** * List all stored preferences formatters diff --git a/core/keys/src/main/kotlin/app/aaps/core/keys/interfaces/StringComposedNonPreferenceKey.kt b/core/keys/src/commonMain/kotlin/app/aaps/core/keys/interfaces/StringComposedNonPreferenceKey.kt similarity index 100% rename from core/keys/src/main/kotlin/app/aaps/core/keys/interfaces/StringComposedNonPreferenceKey.kt rename to core/keys/src/commonMain/kotlin/app/aaps/core/keys/interfaces/StringComposedNonPreferenceKey.kt diff --git a/core/keys/src/main/kotlin/app/aaps/core/keys/interfaces/StringNonPreferenceKey.kt b/core/keys/src/commonMain/kotlin/app/aaps/core/keys/interfaces/StringNonPreferenceKey.kt similarity index 100% rename from core/keys/src/main/kotlin/app/aaps/core/keys/interfaces/StringNonPreferenceKey.kt rename to core/keys/src/commonMain/kotlin/app/aaps/core/keys/interfaces/StringNonPreferenceKey.kt diff --git a/core/keys/src/main/kotlin/app/aaps/core/keys/interfaces/StringPreferenceKey.kt b/core/keys/src/commonMain/kotlin/app/aaps/core/keys/interfaces/StringPreferenceKey.kt similarity index 50% rename from core/keys/src/main/kotlin/app/aaps/core/keys/interfaces/StringPreferenceKey.kt rename to core/keys/src/commonMain/kotlin/app/aaps/core/keys/interfaces/StringPreferenceKey.kt index dfa125b377d3..a93e546ee95a 100644 --- a/core/keys/src/main/kotlin/app/aaps/core/keys/interfaces/StringPreferenceKey.kt +++ b/core/keys/src/commonMain/kotlin/app/aaps/core/keys/interfaces/StringPreferenceKey.kt @@ -19,20 +19,12 @@ interface StringPreferenceKey : PreferenceKey, StringNonPreferenceKey { /** * Entries for LIST type preferences. - * Map of stored value -> label resource ID. + * Map of stored value -> label. * Empty map means no entries (not a list preference). */ - val entries: Map + val entries: Map get() = emptyMap() - /** - * Runtime-resolved entries for LIST type preferences. - * Map of stored value -> resolved label string. - * When set, this takes precedence over [entries] resource IDs. - */ - val resolvedEntries: Map? - get() = null - /** * Validator for the string value. * Used to validate input before accepting it. @@ -43,46 +35,49 @@ interface StringPreferenceKey : PreferenceKey, StringNonPreferenceKey { } /** - * Wrapper that attaches runtime-resolved entries to a StringPreferenceKey. + * Wrapper that attaches entries to a StringPreferenceKey. * Uses delegation to preserve all other properties from the original key. */ class StringKeyWithEntries( private val delegate: StringPreferenceKey, - override val resolvedEntries: Map + override val entries: Map ) : StringPreferenceKey by delegate /** - * Creates a new StringPreferenceKey with runtime-resolved entries attached. - * Use this when entries need to be resolved at runtime (e.g., from plugins). + * Creates a new StringPreferenceKey with entries attached. + * Use this when the entries are only known at run time - for example a list that depends on the + * connected device, or on values computed from another setting. * - * @param entries Map of stored value -> resolved label string + * @param entries Map of stored value -> label. Use [TextRef.AndroidRes] with arguments for anything the + * user reads, so it stays translatable; [TextRef.Literal] only for text that is genuinely not a + * resource, such as a device name. * @return A new StringPreferenceKey with the entries attached */ -fun StringPreferenceKey.withEntries(entries: Map): StringPreferenceKey = +fun StringPreferenceKey.withEntries(entries: Map): StringPreferenceKey = StringKeyWithEntries(this, entries) /** - * Wrapper that attaches a context-dependent entries provider to a StringPreferenceKey. - * The provider is called at compose time with the current Context. + * Wrapper that attaches a runtime entries provider to a StringPreferenceKey. + * The provider is called at compose time, and the labels it returns are resolved there. * If the provider returns an empty map, shows a disabled preference with the empty message. */ class StringKeyWithEntriesProvider( private val delegate: StringPreferenceKey, - val entriesProvider: (android.content.Context) -> Map, - val emptyEntriesMessageResId: Int? = null + val entriesProvider: () -> Map, + val emptyEntriesMessage: TextRef? = null ) : StringPreferenceKey by delegate /** - * Creates a new StringPreferenceKey with a context-dependent entries provider. - * Use this when entries need to be resolved at compose time with Context access - * (e.g., Bluetooth devices requiring permission checks). + * Creates a new StringPreferenceKey with a runtime entries provider. + * Use this when the set of entries is only known at run time - for example when it depends on the + * connected pump model, or on devices found by a scan. * - * @param provider Function that takes Context and returns Map of stored value -> label - * @param emptyEntriesMessageResId Optional resource ID for message to show when entries are empty + * @param provider Function returning Map of stored value -> label + * @param emptyEntriesMessage Optional message to show when entries are empty * @return A new StringKeyWithEntriesProvider */ fun StringPreferenceKey.withEntriesProvider( - provider: (android.content.Context) -> Map, - emptyEntriesMessageResId: Int? = null + provider: () -> Map, + emptyEntriesMessage: TextRef? = null ): StringKeyWithEntriesProvider = - StringKeyWithEntriesProvider(this, provider, emptyEntriesMessageResId) \ No newline at end of file + StringKeyWithEntriesProvider(this, provider, emptyEntriesMessage) \ No newline at end of file diff --git a/core/keys/src/main/kotlin/app/aaps/core/keys/interfaces/StringValidator.kt b/core/keys/src/commonMain/kotlin/app/aaps/core/keys/interfaces/StringValidator.kt similarity index 100% rename from core/keys/src/main/kotlin/app/aaps/core/keys/interfaces/StringValidator.kt rename to core/keys/src/commonMain/kotlin/app/aaps/core/keys/interfaces/StringValidator.kt diff --git a/core/keys/src/main/kotlin/app/aaps/core/keys/interfaces/SyncSpec.kt b/core/keys/src/commonMain/kotlin/app/aaps/core/keys/interfaces/SyncSpec.kt similarity index 92% rename from core/keys/src/main/kotlin/app/aaps/core/keys/interfaces/SyncSpec.kt rename to core/keys/src/commonMain/kotlin/app/aaps/core/keys/interfaces/SyncSpec.kt index 0f5d86e4a307..d3bbfa9784ab 100644 --- a/core/keys/src/main/kotlin/app/aaps/core/keys/interfaces/SyncSpec.kt +++ b/core/keys/src/commonMain/kotlin/app/aaps/core/keys/interfaces/SyncSpec.kt @@ -5,7 +5,10 @@ package app.aaps.core.keys.interfaces * - [Cold]: the rarely-changing config doc (plugin config, settings). * - [Hot]: the small, frequently-republished runtime doc (e.g. active scene). */ -enum class SyncChannel { Cold, Hot } +enum class SyncChannel { + + Cold, Hot +} /** * Sync authority for a preference key: @@ -13,7 +16,10 @@ enum class SyncChannel { Cold, Hot } * - [Bidirectional]: the client may also edit it; the edit is pushed to the master, which applies * last-writer-wins by the per-key modified stamp and republishes so devices converge. */ -enum class SyncDirection { MasterOnly, Bidirectional } +enum class SyncDirection { + + MasterOnly, Bidirectional +} /** * Single source of truth for how a preference key participates in device-to-device sync. Declared diff --git a/core/keys/src/commonMain/kotlin/app/aaps/core/keys/interfaces/TextRef.kt b/core/keys/src/commonMain/kotlin/app/aaps/core/keys/interfaces/TextRef.kt new file mode 100644 index 000000000000..38e67cb37c0c --- /dev/null +++ b/core/keys/src/commonMain/kotlin/app/aaps/core/keys/interfaces/TextRef.kt @@ -0,0 +1,89 @@ +package app.aaps.core.keys.interfaces + +/** + * A reference to user visible text, without saying where that text comes from. + * + * Preference keys used to carry a bare `Int` resource id. That works only on Android, so it blocks + * this module from becoming multiplatform. A [TextRef] says "there is some text here" and leaves the + * question of how to find it to whoever draws the screen - `ResourceHelper.gs(ref)` outside Compose, + * `stringResource(ref)` inside it. + * + * ### Do not persist it + * + * A [TextRef] is meaningful **only inside one running process**. [AndroidRes.id] must never be + * written to preferences, to the database, to a Nightscout document, to a wear message or into a + * crash report as a number, because the same number means different things on different builds. + * Persist the preference `key` instead, which is a stable string. + * + * ### Names and ids + * + * [Named] is for a module that has stopped naming Android resource ids in its own code. [AndroidRes] + * is for everything else, and there is a lot of it, so both stay. + * + * An earlier version of this note argued that a name could not work, because resolving one needs + * `Resources.getIdentifier()` - a reflective lookup R8 cannot see, which would keep every string + * alive and silently return 0 for a typo. That is true of a hand written name and false of a + * generated one: `GenerateKeyStringsTask` emits the names and their id map together from the same + * `strings.xml`, so R8 sees ordinary `R.string.x` references and a typo does not compile. + */ +sealed interface TextRef { + + /** + * A string from an Android `R.string.*` table, in a module that still owns AAPT resources. + * + * Most of the app is still this form, and that is fine - a module only needs to change when it + * itself becomes multiplatform. The two resource forms coexist so modules can migrate one at a + * time rather than all at once. + * + * [args] are format arguments, in the order the format string expects. They are not checked at + * compile time - no worse than `stringResource(id, a, b)` today, but the arguments now travel + * further from the format string, so a mismatch shows up when the text is built. + */ + data class AndroidRes(val id: Int, val args: List = emptyList()) : TextRef + + /** + * A string named rather than numbered, so the declaring code holds nothing Android specific. + * + * [name] is the `name` attribute from `strings.xml`. Take these from the generated object for + * the owning module - `KeysStrings` for `:core:keys`, `UiStrings` for `:core:ui` - never by + * writing the string out by hand, because only the generated form is checked against the XML at + * build time. + * + * [owner] says which module's `strings.xml` [name] came from, because a name is only unique + * within one module. `ns_wifi_ssids` really does exist in both `:core:keys` and `:core:ui` with + * different translations in Bulgarian, Norwegian and Chinese, so a resolver that guessed by + * lookup order would silently pick one of them. Tagging the owner makes it unambiguous and costs + * nothing at the call sites, because only the generator ever constructs this. + * + * Each platform resolves the pair its own way. Android looks it up in that module's generated id + * map and then reads it through `Resources`, so locale matching and the always English lookup + * work exactly as they did when keys carried ids. + * + * [args] behave as in [AndroidRes]. + */ + data class Named(val owner: String, val name: String, val args: List = emptyList()) : TextRef + + /** + * Text that is only known at run time - a scanned pump name, a wiki page title, a user label. + * + * This replaces the `titleResId = 0` and `titleResId = -1` sentinels that used to mean "there is + * no resource here", which callers had to remember to test for. + */ + data class Literal(val text: String) : TextRef + + companion object { + + /** + * The same reference with format arguments attached. + * + * The generated objects hand out argument-free references, because a generator cannot know + * what a call site wants to substitute. This is how a call site supplies them: + * `UiStrings.some_format.withArgs(count, unit)`. + */ + fun TextRef.withArgs(vararg args: Any): TextRef = when (this) { + is Named -> copy(args = args.toList()) + is AndroidRes -> copy(args = args.toList()) + is Literal -> this + } + } +} diff --git a/core/keys/src/main/kotlin/app/aaps/core/keys/interfaces/UnitDoublePreferenceKey.kt b/core/keys/src/commonMain/kotlin/app/aaps/core/keys/interfaces/UnitDoublePreferenceKey.kt similarity index 100% rename from core/keys/src/main/kotlin/app/aaps/core/keys/interfaces/UnitDoublePreferenceKey.kt rename to core/keys/src/commonMain/kotlin/app/aaps/core/keys/interfaces/UnitDoublePreferenceKey.kt diff --git a/core/keys/src/main/kotlin/app/aaps/core/keys/interfaces/VisibilityContext.kt b/core/keys/src/commonMain/kotlin/app/aaps/core/keys/interfaces/VisibilityContext.kt similarity index 100% rename from core/keys/src/main/kotlin/app/aaps/core/keys/interfaces/VisibilityContext.kt rename to core/keys/src/commonMain/kotlin/app/aaps/core/keys/interfaces/VisibilityContext.kt 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 deleted file mode 100644 index f4fc24e9c397..000000000000 --- a/core/keys/src/main/kotlin/app/aaps/core/keys/BooleanKey.kt +++ /dev/null @@ -1,261 +0,0 @@ -package app.aaps.core.keys - -import app.aaps.core.keys.interfaces.BooleanPreferenceKey -import app.aaps.core.keys.interfaces.ElementVisibility -import app.aaps.core.keys.interfaces.PreferenceEnabledCondition -import app.aaps.core.keys.interfaces.SyncChannel -import app.aaps.core.keys.interfaces.SyncDirection -import app.aaps.core.keys.interfaces.SyncSpec - -enum class BooleanKey( - override val key: String, - override val defaultValue: Boolean, - override val titleResId: Int, - override val summaryResId: Int? = null, - override val preferenceType: PreferenceType = PreferenceType.SWITCH, - override val calculatedDefaultValue: Boolean = false, - override val defaultedBySM: Boolean = false, - override val showInApsMode: Boolean = true, - override val showInNsClientMode: Boolean = true, - override val showInPumpControlMode: Boolean = true, - override val dependency: BooleanPreferenceKey? = null, - override val negativeDependency: BooleanPreferenceKey? = null, - override val hideParentScreenIfHidden: Boolean = false, - override val engineeringModeOnly: Boolean = false, - override val exportable: Boolean = true, - override val visibility: ElementVisibility = ElementVisibility.ALWAYS, - override val enabledCondition: PreferenceEnabledCondition = PreferenceEnabledCondition.ALWAYS, - override val sync: SyncSpec? = null -) : BooleanPreferenceKey { - - GeneralSimpleMode(key = "simple_mode", defaultValue = true, titleResId = R.string.pref_title_simple_mode, sync = SyncSpec(SyncChannel.Cold, SyncDirection.Bidirectional)), - GeneralInsulinConcentration( - key = "insulin_concentration_enabled", defaultValue = false, titleResId = R.string.pref_title_insulin_concentration, summaryResId = R.string.pref_summary_insulin_concentration, - defaultedBySM = true, - enabledCondition = PreferenceEnabledCondition { it.isConcentrationEnabled }, - sync = SyncSpec(SyncChannel.Cold, SyncDirection.Bidirectional) - ), - OverviewKeepScreenOn(key = "keep_screen_on", defaultValue = false, titleResId = R.string.pref_title_keep_screen_on, summaryResId = R.string.pref_summary_keep_screen_on, calculatedDefaultValue = true), - OverviewShowTreatmentButton(key = "show_treatment_button", defaultValue = false, titleResId = R.string.pref_title_show_treatment_button, defaultedBySM = true), - OverviewShowWizardButton(key = "show_wizard_button", defaultValue = true, titleResId = R.string.pref_title_show_wizard_button, defaultedBySM = true), - OverviewShowInsulinButton(key = "show_insulin_button", defaultValue = true, titleResId = R.string.pref_title_show_insulin_button, defaultedBySM = true), - OverviewShowCarbsButton(key = "show_carbs_button", defaultValue = true, titleResId = R.string.pref_title_show_carbs_button, defaultedBySM = true), - OverviewShowCgmButton(key = "show_cgm_button", defaultValue = false, titleResId = R.string.pref_title_show_cgm_button, summaryResId = R.string.pref_summary_show_cgm_button, defaultedBySM = true, showInNsClientMode = false), - OverviewShowCalibrationButton( - key = "show_calibration_button", - defaultValue = false, - titleResId = R.string.pref_title_show_calibration_button, - summaryResId = R.string.pref_summary_show_calibration_button, - defaultedBySM = true, - showInNsClientMode = false - ), - OverviewShowNotesInDialogs(key = "show_notes_entry_dialogs", defaultValue = false, titleResId = R.string.pref_title_show_notes_in_dialogs, defaultedBySM = true), - OverviewUseBolusAdvisor("use_bolus_advisor", true, R.string.pref_title_use_bolus_advisor, R.string.pref_summary_use_bolus_advisor, defaultedBySM = true, sync = SyncSpec(SyncChannel.Cold, SyncDirection.Bidirectional)), - OverviewUseBolusReminder("use_bolus_reminder", true, R.string.pref_title_use_bolus_reminder, R.string.pref_summary_use_bolus_reminder, defaultedBySM = true, sync = SyncSpec(SyncChannel.Cold, SyncDirection.Bidirectional)), - - @Deprecated("Remove support") - OverviewUseSuperBolus("key_usersuperbolus", false, R.string.pref_title_use_super_bolus, R.string.pref_summary_use_super_bolus, defaultedBySM = true, hideParentScreenIfHidden = true), - - PumpBtWatchdog( - "bt_watchdog", false, R.string.pref_title_bt_watchdog, R.string.pref_summary_bt_watchdog, - sync = SyncSpec(SyncChannel.Cold, SyncDirection.Bidirectional) - ), - - AlertMissedBgReading("enable_missed_bg_readings", false, R.string.pref_title_alert_missed_bg_reading), - AlertPumpUnreachable("enable_pump_unreachable_alert", true, R.string.pref_title_alert_pump_unreachable), - AlertCarbsRequired("enable_carbs_required_alert_local", true, R.string.pref_title_alert_carbs_required), - AlertUrgentAsAndroidNotification("raise_urgent_alarms_as_android_notification", true, R.string.pref_title_alert_urgent_as_android_notification), - AlertIncreaseVolume("gradually_increase_notification_volume", true, R.string.pref_title_alert_increase_volume), - AlertOverrideDoNotDisturb("alert_override_dnd", true, R.string.pref_title_alert_override_dnd, R.string.pref_summary_alert_override_dnd, defaultedBySM = true), - - BgSourceUploadToNs("dexcomg5_nsupload", true, R.string.pref_title_bg_source_upload_to_ns, defaultedBySM = true, hideParentScreenIfHidden = true), - BgSourceCreateSensorChange("dexcom_lognssensorchange", true, R.string.pref_title_bg_source_create_sensor_change, R.string.pref_summary_bg_source_create_sensor_change, defaultedBySM = true), - BgSourceRandomBgRandomize("randombg_randomize", true, R.string.pref_title_random_bg_randomize, R.string.pref_summary_random_bg_randomize, defaultedBySM = true), - - ApsUseDynamicSensitivity("use_dynamic_sensitivity", false, R.string.pref_title_aps_use_dynamic_sensitivity, R.string.pref_summary_aps_use_dynamic_sensitivity, sync = SyncSpec(SyncChannel.Cold, SyncDirection.Bidirectional)), - ApsUseAutosens( - "openapsama_useautosens", true, R.string.pref_title_aps_use_autosens, defaultedBySM = true, - // Hidden only while the active APS both offers dynamic sensitivity and has it enabled. - // A plain negativeDependency on ApsUseDynamicSensitivity would also hide it on algorithms - // whose screens never show that toggle (AMA, AutoISF), with no way to reveal it (issue #4482). - visibility = ElementVisibility { !(it.apsOffersDynamicSensitivity && it.preferences.get(ApsUseDynamicSensitivity)) }, - sync = SyncSpec(SyncChannel.Cold, SyncDirection.Bidirectional) - ), - ApsUseSmb("use_smb", true, R.string.pref_title_aps_use_smb, R.string.pref_summary_aps_use_smb, defaultedBySM = true, sync = SyncSpec(SyncChannel.Cold, SyncDirection.Bidirectional)), - ApsUseSmbWithHighTt( - "enableSMB_with_high_temptarget", - false, - R.string.pref_title_aps_use_smb_with_high_tt, - R.string.pref_summary_aps_use_smb_with_high_tt, - defaultedBySM = true, - dependency = ApsUseSmb, - sync = SyncSpec(SyncChannel.Cold, SyncDirection.Bidirectional) - ), - ApsUseSmbAlways( - "enableSMB_always", true, R.string.pref_title_aps_use_smb_always, R.string.pref_summary_aps_use_smb_always, defaultedBySM = true, dependency = ApsUseSmb, - visibility = ElementVisibility.ADVANCED_FILTERING, - sync = SyncSpec(SyncChannel.Cold, SyncDirection.Bidirectional) - ), - ApsUseSmbWithCob( - "enableSMB_with_COB", true, R.string.pref_title_aps_use_smb_with_cob, R.string.pref_summary_aps_use_smb_with_cob, defaultedBySM = true, dependency = ApsUseSmb, - visibility = ElementVisibility { !it.preferences.get(ApsUseSmbAlways) || !it.advancedFilteringSupported }, - sync = SyncSpec(SyncChannel.Cold, SyncDirection.Bidirectional) - ), - ApsUseSmbWithLowTt( - "enableSMB_with_temptarget", true, R.string.pref_title_aps_use_smb_with_low_tt, R.string.pref_summary_aps_use_smb_with_low_tt, defaultedBySM = true, dependency = ApsUseSmb, - visibility = ElementVisibility { !it.preferences.get(ApsUseSmbAlways) || !it.advancedFilteringSupported }, - sync = SyncSpec(SyncChannel.Cold, SyncDirection.Bidirectional) - ), - ApsUseSmbAfterCarbs( - "enableSMB_after_carbs", true, R.string.pref_title_aps_use_smb_after_carbs, R.string.pref_summary_aps_use_smb_after_carbs, defaultedBySM = true, dependency = ApsUseSmb, - visibility = ElementVisibility { !it.preferences.get(ApsUseSmbAlways) && it.advancedFilteringSupported }, - sync = SyncSpec(SyncChannel.Cold, SyncDirection.Bidirectional) - ), - ApsUseUam("use_uam", true, R.string.pref_title_aps_use_uam, R.string.pref_summary_aps_use_uam, defaultedBySM = true, sync = SyncSpec(SyncChannel.Cold, SyncDirection.Bidirectional)), - ApsSensitivityRaisesTarget( - "sensitivity_raises_target", true, R.string.pref_title_aps_sensitivity_raises_target, R.string.pref_summary_aps_sensitivity_raises_target, defaultedBySM = true, - visibility = ElementVisibility { - if (it.preferences.get(ApsUseDynamicSensitivity)) { - it.preferences.get(ApsDynIsfAdjustSensitivity) - } else { - it.preferences.get(ApsUseAutosens) - } - }, - sync = SyncSpec(SyncChannel.Cold, SyncDirection.Bidirectional) - ), - ApsResistanceLowersTarget( - "resistance_lowers_target", true, R.string.pref_title_aps_resistance_lowers_target, R.string.pref_summary_aps_resistance_lowers_target, defaultedBySM = true, - visibility = ElementVisibility { - if (it.preferences.get(ApsUseDynamicSensitivity)) { - it.preferences.get(ApsDynIsfAdjustSensitivity) - } else { - it.preferences.get(ApsUseAutosens) - } - }, - sync = SyncSpec(SyncChannel.Cold, SyncDirection.Bidirectional) - ), - ApsAlwaysUseShortDeltas( - "always_use_shortavg", - false, - R.string.pref_title_aps_always_use_short_deltas, - R.string.pref_summary_aps_always_use_short_deltas, - defaultedBySM = true, - hideParentScreenIfHidden = true, - sync = SyncSpec(SyncChannel.Cold, SyncDirection.Bidirectional) - ), - ApsDynIsfAdjustSensitivity( - "dynisf_adjust_sensitivity", - false, - R.string.pref_title_aps_dynisf_adjust_sensitivity, - R.string.pref_summary_aps_dynisf_adjust_sensitivity, - defaultedBySM = true, - dependency = ApsUseDynamicSensitivity, - sync = SyncSpec(SyncChannel.Cold, SyncDirection.Bidirectional) - ), - ApsAmaAutosensAdjustTargets( - "autosens_adjust_targets", - true, - R.string.pref_title_aps_autosens_adjust_targets, - R.string.pref_summary_aps_autosens_adjust_targets, - defaultedBySM = true, - sync = SyncSpec(SyncChannel.Cold, SyncDirection.Bidirectional) - ), - ApsAutoIsfHighTtRaisesSens( - "high_temptarget_raises_sensitivity", - false, - R.string.pref_title_aps_high_tt_raises_sensitivity, - R.string.pref_summary_aps_high_tt_raises_sensitivity, - defaultedBySM = true, - sync = SyncSpec(SyncChannel.Cold, SyncDirection.Bidirectional) - ), - ApsAutoIsfLowTtLowersSens( - "low_temptarget_lowers_sensitivity", - false, - R.string.pref_title_aps_low_tt_lowers_sensitivity, - R.string.pref_summary_aps_low_tt_lowers_sensitivity, - defaultedBySM = true, - sync = SyncSpec(SyncChannel.Cold, SyncDirection.Bidirectional) - ), - ApsUseAutoIsfWeights("openapsama_enable_autoISF", false, R.string.pref_title_aps_use_autoisf_weights, R.string.pref_summary_aps_use_autoisf_weights, defaultedBySM = true, sync = SyncSpec(SyncChannel.Cold, SyncDirection.Bidirectional)), - ApsAutoIsfSmbOnEvenTarget( - "Enable alternative activation of SMB always", - false, - R.string.pref_title_aps_smb_on_even_target, - R.string.pref_summary_aps_smb_on_even_target, - defaultedBySM = true, - sync = SyncSpec(SyncChannel.Cold, SyncDirection.Bidirectional) - ), - - MaintenanceEnableFabric("enable_fabric2", true, R.string.pref_title_maintenance_enable_fabric, defaultedBySM = true, hideParentScreenIfHidden = true), - - // Master-only (not a follower client): unattended settings export backs up the local config, which on a - // client is derived from the master. showInNsClientMode=false hides it in apsMode + pumpControlMode only; - // hideParentScreenIfHidden collapses the now-empty "Unattended Settings Export" subscreen on a client. - MaintenanceEnableExportSettingsAutomation("enable_unattended_export", false, R.string.pref_title_maintenance_enable_export_automation, defaultedBySM = false, showInNsClientMode = false, hideParentScreenIfHidden = true), - - AutotuneAutoSwitchProfile("autotune_auto", false, R.string.pref_title_autotune_auto_switch_profile, R.string.pref_summary_autotune_auto_switch_profile), - AutotuneCategorizeUamAsBasal("categorize_uam_as_basal", false, R.string.pref_title_autotune_categorize_uam_as_basal, R.string.pref_summary_autotune_categorize_uam_as_basal), - AutotuneTuneInsulinCurve("autotune_tune_insulin_curve", false, R.string.pref_title_autotune_tune_insulin_curve), - AutotuneCircadianIcIsf("autotune_circadian_ic_isf", false, R.string.pref_title_autotune_circadian_ic_isf, R.string.pref_summary_autotune_circadian_ic_isf), - AutotuneAdditionalLog("autotune_additional_log", false, R.string.pref_title_autotune_additional_log), - - SmsAllowRemoteCommands("smscommunicator_remotecommandsallowed", false, R.string.pref_title_sms_allow_remote_commands), - SmsReportPumpUnreachable("smscommunicator_report_pump_unreachable", true, R.string.pref_title_sms_report_pump_unreachable, R.string.pref_summary_sms_report_pump_unreachable), - - VirtualPumpStatusUpload("virtualpump_uploadstatus", false, R.string.pref_title_virtual_pump_status_upload, showInNsClientMode = false), - NsClientUploadData("ns_upload", true, R.string.pref_title_ns_upload_data, R.string.pref_summary_ns_upload_data, showInNsClientMode = false, hideParentScreenIfHidden = true), - NsClientAcceptCgmData("ns_receive_cgm", false, R.string.pref_title_ns_receive_cgm, R.string.pref_summary_ns_receive_cgm, showInNsClientMode = false, hideParentScreenIfHidden = true), - NsClientAcceptProfileStore("ns_receive_profile_store", false, R.string.pref_title_ns_receive_profile_store, R.string.pref_summary_ns_receive_profile_store, showInNsClientMode = false, hideParentScreenIfHidden = true), - NsClientAcceptTempTarget("ns_receive_temp_target", false, R.string.pref_title_ns_receive_temp_target, R.string.pref_summary_ns_receive_temp_target, showInNsClientMode = false, hideParentScreenIfHidden = true), - NsClientAcceptProfileSwitch("ns_receive_profile_switch", false, R.string.pref_title_ns_receive_profile_switch, R.string.pref_summary_ns_receive_profile_switch, showInNsClientMode = false, hideParentScreenIfHidden = true), - NsClientAcceptInsulin("ns_receive_insulin", false, R.string.pref_title_ns_receive_insulin, R.string.pref_summary_ns_receive_insulin, showInNsClientMode = false, hideParentScreenIfHidden = true), - NsClientAcceptCarbs("ns_receive_carbs", false, R.string.pref_title_ns_receive_carbs, R.string.pref_summary_ns_receive_carbs, showInNsClientMode = false, hideParentScreenIfHidden = true), - NsClientAcceptTherapyEvent("ns_receive_therapy_events", false, R.string.pref_title_ns_receive_therapy_event, R.string.pref_summary_ns_receive_therapy_event, showInNsClientMode = false, hideParentScreenIfHidden = true), - NsClientAcceptRunningMode("ns_receive_running_mode", false, R.string.pref_title_ns_receive_running_mode, R.string.pref_summary_ns_receive_running_mode, showInNsClientMode = false, hideParentScreenIfHidden = true), - NsClientAcceptTbrEb("ns_receive_tbr_eb", false, R.string.pref_title_ns_receive_tbr_eb, R.string.pref_summary_ns_receive_tbr_eb, showInNsClientMode = false, engineeringModeOnly = true), - NsClientNotificationsFromAlarms("ns_alarms", false, R.string.pref_title_ns_notifications_from_alarms, calculatedDefaultValue = true), - NsClientNotificationsFromAnnouncements("ns_announcements", false, R.string.pref_title_ns_notifications_from_announcements, calculatedDefaultValue = true), - NsClientUseCellular("ns_cellular", true, R.string.pref_title_ns_use_cellular), - NsClientUseRoaming("ns_allow_roaming", true, R.string.pref_title_ns_use_roaming, dependency = NsClientUseCellular), - NsClientUseWifi("ns_wifi", true, R.string.pref_title_ns_use_wifi), - NsClientUseOnBattery("ns_battery", true, R.string.pref_title_ns_use_on_battery), - NsClientUseOnCharging("ns_charging", true, R.string.pref_title_ns_use_on_charging), - NsClientLogAppStart("ns_log_app_started_event", false, R.string.pref_title_ns_log_app_start, calculatedDefaultValue = true), - NsClientCreateAnnouncementsFromErrors("ns_create_announcements_from_errors", false, R.string.pref_title_ns_create_announcements_from_errors, calculatedDefaultValue = true, showInNsClientMode = false), - NsClientCreateAnnouncementsFromCarbsReq("ns_create_announcements_from_carbs_req", false, R.string.pref_title_ns_create_announcements_from_carbs_req, calculatedDefaultValue = true, showInNsClientMode = false), - NsClientSlowSync("ns_sync_slow", false, R.string.pref_title_ns_slow_sync), - NsClient3UseWs("ns_use_ws", true, R.string.pref_title_ns_use_ws, R.string.pref_summary_ns_use_ws), - NsClientAllowClientControl( - "ns_allow_client_control", false, - R.string.pref_title_ns_allow_client_control, R.string.pref_summary_ns_allow_client_control, - // The rich stop/allow-communication switch lives on the Authorized clients screen; it is ALSO exposed in a - // "Remote control" category on the NSCv3 settings screen (NSClientV3Plugin.getPreferenceScreenContent) so it - // is reachable from search. Default OFF, but ON in simple mode (resolved in PreferencesImpl.calculatedDefaultValue). Hidden on a client. - calculatedDefaultValue = true, showInNsClientMode = false, - // Remote control rides the WebSocket — hide the toggle (and its single-item "Remote control" parent category) - // when WS is off, and on a client where the key is already hidden (so the category never shows empty). - dependency = NsClient3UseWs, hideParentScreenIfHidden = true, - // Synced master→client (MasterOnly — the client mirrors, never pushes back) so a paired client knows - // whether the master is accepting commands and can gate its UI. buildSyncedPrefs publishes the EFFECTIVE - // value for this key (see RunningConfigurationImpl), not the raw default. - sync = SyncSpec(SyncChannel.Cold, SyncDirection.MasterOnly) - ), - OpenHumansWifiOnly("oh_wifi_only", true, R.string.pref_title_openhumans_wifi_only), - OpenHumansChargingOnly("oh_charging_only", false, R.string.pref_title_openhumans_charging_only), - XdripSendStatus("xdrip_send_status", false, R.string.pref_title_xdrip_send_status), - XdripSendDetailedIob("xdripstatus_detailediob", true, R.string.pref_title_xdrip_send_detailed_iob, R.string.pref_summary_xdrip_send_detailed_iob, defaultedBySM = true, hideParentScreenIfHidden = true), - XdripSendBgi("xdripstatus_showbgi", true, R.string.pref_title_xdrip_send_bgi, R.string.pref_summary_xdrip_send_bgi, defaultedBySM = true, hideParentScreenIfHidden = true), - WearControl(key = "wearcontrol", defaultValue = false, titleResId = R.string.pref_title_wear_control, summaryResId = R.string.pref_summary_wear_control), - WearWizardBg(key = "wearwizard_bg", defaultValue = true, titleResId = R.string.pref_title_wear_wizard_bg, dependency = WearControl, hideParentScreenIfHidden = true), - WearWizardTt(key = "wearwizard_tt", defaultValue = false, titleResId = R.string.pref_title_wear_wizard_tt, dependency = WearControl, hideParentScreenIfHidden = true), - WearWizardTrend(key = "wearwizard_trend", defaultValue = false, titleResId = R.string.pref_title_wear_wizard_trend, dependency = WearControl, hideParentScreenIfHidden = true), - WearWizardCob(key = "wearwizard_cob", defaultValue = true, titleResId = R.string.pref_title_wear_wizard_cob, dependency = WearControl, hideParentScreenIfHidden = true), - WearWizardIob(key = "wearwizard_iob", defaultValue = true, titleResId = R.string.pref_title_wear_wizard_iob, dependency = WearControl, hideParentScreenIfHidden = true), - WearCustomWatchfaceAuthorization(key = "wear_custom_watchface_autorization", defaultValue = false, titleResId = R.string.pref_title_wear_custom_watchface_authorization), - WearNotifyOnSmb(key = "wear_notifySMB", defaultValue = true, titleResId = R.string.pref_title_wear_notify_on_smb, summaryResId = R.string.pref_summary_wear_notify_on_smb), - WearBroadcastData(key = "wear_broadcast_data", defaultValue = false, titleResId = R.string.pref_title_wear_broadcast_data, summaryResId = R.string.pref_summary_wear_broadcast_data, showInApsMode = false, showInPumpControlMode = false), - - SiteRotationManagePump("site_rotation_manage_pump", defaultValue = false, titleResId = R.string.pref_title_site_rotation_manage_pump, sync = SyncSpec(SyncChannel.Cold, SyncDirection.Bidirectional)), - SiteRotationManageCgm("site_rotation_manage_cgm", defaultValue = false, titleResId = R.string.pref_title_site_rotation_manage_cgm, sync = SyncSpec(SyncChannel.Cold, SyncDirection.Bidirectional)), - -} diff --git a/core/keys/src/main/kotlin/app/aaps/core/keys/UnitType.kt b/core/keys/src/main/kotlin/app/aaps/core/keys/UnitType.kt deleted file mode 100644 index 2473526e7d1a..000000000000 --- a/core/keys/src/main/kotlin/app/aaps/core/keys/UnitType.kt +++ /dev/null @@ -1,106 +0,0 @@ -package app.aaps.core.keys - -/** - * Enum defining unit types for preference values. - * Used to format values with appropriate units in UI. - */ -enum class UnitType { - - NONE, - GRAMS, - MIN, - SEC, - HOURS, - HOURS_DOUBLE, - DAYS, - PERCENT, - INSULIN, - INSULIN_INT, - INSULIN_RATE, - DOUBLE, - DOUBLE_2, - DOUBLE_3, - MGDL -} - -/** - * Returns the resource ID for formatting a single value with this unit type. - * Use with stringResource(resId, value) in Compose. - */ -fun UnitType.valueResId(): Int? = when (this) { - UnitType.NONE -> null - UnitType.GRAMS -> R.string.units_format_grams - UnitType.MIN -> R.string.units_format_min - UnitType.SEC -> R.string.units_format_sec - UnitType.HOURS -> R.string.units_format_hours - UnitType.HOURS_DOUBLE -> R.string.units_format_hours_double - UnitType.DAYS -> R.string.units_format_days - UnitType.PERCENT -> R.string.units_format_percent - UnitType.INSULIN -> R.string.units_format_insulin - UnitType.INSULIN_INT -> R.string.units_format_insulin_int - UnitType.INSULIN_RATE -> R.string.units_format_insulin_rate - UnitType.DOUBLE -> R.string.units_format_double - UnitType.DOUBLE_2 -> R.string.units_format_double_2 - UnitType.DOUBLE_3 -> R.string.units_format_double_3 - UnitType.MGDL -> R.string.units_format_mgdl -} - -/** - * Returns the resource ID for formatting a value with range (value, min, max). - * Use with stringResource(resId, value, min, max) in Compose. - */ -fun UnitType.rangeResId(): Int? = when (this) { - UnitType.NONE -> null - UnitType.GRAMS -> R.string.units_format_grams_range - UnitType.MIN -> R.string.units_format_min_range - UnitType.SEC -> R.string.units_format_sec_range - UnitType.HOURS -> R.string.units_format_hours_range - UnitType.HOURS_DOUBLE -> R.string.units_format_hours_double_range - UnitType.DAYS -> R.string.units_format_days_range - UnitType.PERCENT -> R.string.units_format_percent_range - UnitType.INSULIN -> R.string.units_format_insulin_range - UnitType.INSULIN_INT -> R.string.units_format_insulin_int_range - UnitType.INSULIN_RATE -> R.string.units_format_insulin_rate_range - UnitType.DOUBLE -> R.string.units_format_double_range - UnitType.DOUBLE_2 -> R.string.units_format_double_2_range - UnitType.DOUBLE_3 -> R.string.units_format_double_3_range - UnitType.MGDL -> R.string.units_format_mgdl_range -} - -/** - * Returns the number of decimal places for this unit type. - */ -fun UnitType.decimalPlaces(): Int = when (this) { - UnitType.DOUBLE_3 -> 3 - UnitType.DOUBLE_2 -> 2 - UnitType.INSULIN, UnitType.INSULIN_RATE, UnitType.DOUBLE, UnitType.HOURS_DOUBLE -> 1 - else -> 0 -} - -/** - * Returns the step size for slider/increment controls. - */ -fun UnitType.step(): Double = when (this) { - UnitType.DOUBLE_3 -> 0.001 - UnitType.DOUBLE_2 -> 0.01 - UnitType.INSULIN, UnitType.INSULIN_RATE, UnitType.DOUBLE, UnitType.HOURS_DOUBLE -> 0.1 - else -> 1.0 -} - -/** - * Returns the resource ID for the unit label string (e.g., "min", "U", "h"). - * Use with stringResource(resId) in Compose for slider value display. - */ -fun UnitType.unitLabelResId(): Int? = when (this) { - UnitType.NONE -> null - UnitType.GRAMS -> R.string.units_grams - UnitType.MIN -> R.string.units_min - UnitType.SEC -> R.string.units_sec - UnitType.HOURS, UnitType.HOURS_DOUBLE -> R.string.units_hours - UnitType.DAYS -> R.string.units_days - UnitType.PERCENT -> R.string.units_percent - UnitType.INSULIN, UnitType.INSULIN_INT -> R.string.units_insulin - UnitType.INSULIN_RATE -> R.string.units_insulin_rate - UnitType.DOUBLE, UnitType.DOUBLE_2, UnitType.DOUBLE_3 -> null // No unit label for generic doubles - UnitType.MGDL -> R.string.units_mgdl -} diff --git a/core/nssdk/build.gradle.kts b/core/nssdk/build.gradle.kts index 62e0973fb1cb..34009dfb42d4 100644 --- a/core/nssdk/build.gradle.kts +++ b/core/nssdk/build.gradle.kts @@ -1,28 +1,75 @@ plugins { - alias(libs.plugins.android.library) + kotlin("multiplatform") id("kotlinx-serialization") - id("android-module-dependencies") - id("test-module-dependencies") - id("jacoco-module-dependencies") } -android { - namespace = "app.aaps.core.nssdk" -} +kotlin { + jvm { + // Every consumer of :core:nssdk is still an Android module, and they resolve this variant. + // Nothing about them changes. + compilerOptions { + jvmTarget.set(Versions.jvmTarget) + } + } + + // Real Apple targets, same as :core:data. They cross compile on Windows; only linking, cinterop + // and running their tests need a Mac. + iosArm64() + iosSimulatorArm64() -dependencies { - implementation(libs.com.squareup.retrofit2.retrofit) - implementation(libs.com.squareup.retrofit2.converter.gson) - api(libs.com.squareup.okhttp3.okhttp) - api(libs.com.squareup.okhttp3.logging.interceptor) - api(libs.net.danlew.android.joda) + // Kept because it is the only Kotlin/Native target whose tests can run on Windows. + mingwX64() - api(libs.kotlin.stdlib.jdk8) + sourceSets { + getByName("commonMain") { + dependencies { + api(libs.io.ktor.client.core) + implementation(libs.io.ktor.client.content.negotiation) + implementation(libs.io.ktor.serialization.kotlinx.json) + api(libs.kotlinx.datetime) + api(libs.kotlinx.coroutines.core) + api(libs.kotlinx.serialization.json) + } + } + getByName("jvmMain") { + dependencies { + // OkHttp is both the Ktor engine and, on the JVM side, what the rest of the app + // already uses. The client-control crypto in this source set is javax.crypto. + api(libs.com.squareup.okhttp3.okhttp) + implementation(libs.io.ktor.client.okhttp) + } + } + // Accessor rather than getByName: iosMain is created by the default hierarchy template, + // which is applied after this block is evaluated, so getByName("iosMain") fails. + iosMain { + dependencies { + // Darwin runs on NSURLSession, so an iOS build gets the system's own connection + // handling rather than a second HTTP stack. + implementation(libs.io.ktor.client.darwin) + } + } + getByName("mingwX64Main") { + dependencies { + // CIO is Ktor's own multiplatform engine, enough for the compile proof on Windows. + implementation(libs.io.ktor.client.cio) + } + } + getByName("jvmTest") { + dependencies { + implementation(kotlin("test")) + implementation(libs.org.junit.jupiter) + implementation(libs.org.junit.jupiter.api) + runtimeOnly(libs.org.junit.platform.launcher) + implementation(libs.com.google.truth) + implementation(libs.org.mockito.kotlin) + implementation(libs.kotlinx.coroutines.test) + // A real HTTP server on localhost, so the contract tests exercise the whole stack. + implementation(libs.com.squareup.okhttp3.mockwebserver) + } + } + } +} - api(platform(libs.kotlinx.coroutines.bom)) - api(libs.kotlinx.coroutines.core) - runtimeOnly(libs.kotlinx.coroutines.android) - implementation(libs.kotlinx.coroutines.rx3) - api(platform(libs.kotlinx.serialization.bom)) - api(libs.kotlinx.serialization.json) -} \ No newline at end of file +tasks.withType { + useJUnitPlatform() +} diff --git a/core/nssdk/src/main/kotlin/app/aaps/core/nssdk/NSAndroidCallbackClientImpl.kt b/core/nssdk/src/commonMain/kotlin/app/aaps/core/nssdk/NSAndroidCallbackClientImpl.kt similarity index 88% rename from core/nssdk/src/main/kotlin/app/aaps/core/nssdk/NSAndroidCallbackClientImpl.kt rename to core/nssdk/src/commonMain/kotlin/app/aaps/core/nssdk/NSAndroidCallbackClientImpl.kt index e971cd57e253..c9e37eb4660f 100644 --- a/core/nssdk/src/main/kotlin/app/aaps/core/nssdk/NSAndroidCallbackClientImpl.kt +++ b/core/nssdk/src/commonMain/kotlin/app/aaps/core/nssdk/NSAndroidCallbackClientImpl.kt @@ -3,15 +3,15 @@ package app.aaps.core.nssdk import app.aaps.core.nssdk.interfaces.NSAndroidCallbackClient import app.aaps.core.nssdk.interfaces.NSAndroidClient import app.aaps.core.nssdk.localmodel.Status +import app.aaps.core.nssdk.utils.nsIoDispatcher import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.launch class NSAndroidCallbackClientImpl(private val client: NSAndroidClient) : NSAndroidCallbackClient { - private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + private val scope = CoroutineScope(nsIoDispatcher + SupervisorJob()) @Suppress("TooGenericExceptionCaught") override fun getStatus(callback: NSAndroidCallbackClient.NSCallback): NSAndroidCallbackClient.NSCancellable = diff --git a/core/nssdk/src/main/kotlin/app/aaps/core/nssdk/NSAndroidClientImpl.kt b/core/nssdk/src/commonMain/kotlin/app/aaps/core/nssdk/NSAndroidClientImpl.kt similarity index 70% rename from core/nssdk/src/main/kotlin/app/aaps/core/nssdk/NSAndroidClientImpl.kt rename to core/nssdk/src/commonMain/kotlin/app/aaps/core/nssdk/NSAndroidClientImpl.kt index 25880d90dfad..14785c123db3 100644 --- a/core/nssdk/src/main/kotlin/app/aaps/core/nssdk/NSAndroidClientImpl.kt +++ b/core/nssdk/src/commonMain/kotlin/app/aaps/core/nssdk/NSAndroidClientImpl.kt @@ -1,6 +1,5 @@ package app.aaps.core.nssdk -import android.content.Context import app.aaps.core.nssdk.exceptions.DateHeaderOutOfToleranceException import app.aaps.core.nssdk.exceptions.InvalidAccessTokenException import app.aaps.core.nssdk.exceptions.InvalidFormatNightscoutException @@ -25,25 +24,25 @@ import app.aaps.core.nssdk.mapper.toRemoteFood import app.aaps.core.nssdk.mapper.toRemoteTreatment import app.aaps.core.nssdk.mapper.toSgv import app.aaps.core.nssdk.mapper.toTreatment -import app.aaps.core.nssdk.networking.NetworkStackBuilder +import app.aaps.core.nssdk.networking.NsKtorClient +import app.aaps.core.nssdk.networking.NsUrl import app.aaps.core.nssdk.remotemodel.LastModified import app.aaps.core.nssdk.remotemodel.RemoteDeviceStatus import app.aaps.core.nssdk.remotemodel.RemoteEntry import app.aaps.core.nssdk.remotemodel.RemoteFood +import app.aaps.core.nssdk.remotemodel.RemoteStatusResponse import app.aaps.core.nssdk.remotemodel.RemoteTreatment +import app.aaps.core.nssdk.utils.nsIoDispatcher import app.aaps.core.nssdk.utils.retry import app.aaps.core.nssdk.utils.toNotNull -import com.google.gson.JsonParser import kotlinx.coroutines.CoroutineDispatcher -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext -import okhttp3.logging.HttpLoggingInterceptor -import org.json.JSONObject +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive /** * * This client uses suspend functions and therefore is only visible in Kotlin (@JvmSynthetic). - * An RxJava version can be found here [NSAndroidRxClientImpl] * * @param baseUrl the baseURL of the NightScout Instance * @param accessToken the access token of a role found in the admin panel of the NightScout instance @@ -65,19 +64,28 @@ import org.json.JSONObject class NSAndroidClientImpl( baseUrl: String, accessToken: String, - context: Context, logging: Boolean, - logger: HttpLoggingInterceptor.Logger, - private val dispatcher: CoroutineDispatcher = Dispatchers.IO + logger: (String) -> Unit, + private val dispatcher: CoroutineDispatcher = nsIoDispatcher ) : NSAndroidClient { - internal val api = NetworkStackBuilder.getApi( - baseUrl = baseUrl, - context = context, - accessToken = accessToken, + private val stack = NsKtorClient.stack( + baseUrl = NsUrl.toBaseUrl(baseUrl), + refreshToken = accessToken, logging = logging, logger = logger ) + + internal val api = stack.api + + /** + * Releases the HTTP engine. + * + * `NSClientV3Plugin` builds a new client whenever the URL, token or WebSocket setting changes, + * and the old one used to be dropped without being closed - a leak that was invisible with + * Retrofit but real with a Ktor engine holding connections. + */ + override fun close() = stack.close() override var lastStatus: Status? = null /* @@ -98,11 +106,28 @@ class NSAndroidClientImpl( // TODO: we need a minimum NightscoutVersion for APIv3. Add to documentation override suspend fun getVersion(): String = callWrapper(dispatcher) { - api.statusSimple().result!!.version + fetchStatus().version } override suspend fun getStatus(): Status = callWrapper(dispatcher) { - api.statusSimple().result!!.toLocal().also { lastStatus = it } + fetchStatus().toLocal().also { lastStatus = it } + } + + /** + * `v3/status`, or a [UnsuccessfulNightscoutException]. + * + * These two used to read `api.statusSimple().result!!`, which threw `retrofit2.HttpException` + * for a non-2xx and `NullPointerException` for a 200 without a `result` - neither of them a + * `NightscoutException`, and the first one impossible to keep once Retrofit is gone. Both now + * fail as the rest of the client does. The only caller, `LoadStatusWorker`, catches broad + * `Exception`, so nothing downstream notices the change. + */ + private suspend fun fetchStatus(): RemoteStatusResponse { + val response = api.statusSimple() + if (!response.isSuccessful) + throw UnsuccessfulNightscoutException(response.errorBody() ?: response.message()) + return response.body()?.result + ?: throw UnsuccessfulNightscoutException("Status response has no result") } override suspend fun getLastModified(): LastModified = callWrapper(dispatcher) { @@ -110,8 +135,8 @@ class NSAndroidClientImpl( val response = api.lastModified() if (response.isSuccessful) { return@callWrapper response.body()?.result ?: throw UnsuccessfulNightscoutException("Unsuccessful") - } else if (response.code() in 400..499) - throw InvalidParameterNightscoutException(response.errorBody()?.string() ?: response.message()) + } else if (response.code in 400..499) + throw InvalidParameterNightscoutException(response.errorBody() ?: response.message()) else throw UnsuccessfulNightscoutException("Unsuccessful") } @@ -121,13 +146,13 @@ class NSAndroidClientImpl( val response = api.getSgvs() if (response.isSuccessful) { return@callWrapper NSAndroidClient.ReadResponse( - code = response.raw().networkResponse?.code ?: response.code(), + code = response.code, lastServerModified = 0, values = response.body()?.result?.map(RemoteEntry::toSgv).toNotNull() // no calibrations: this endpoint is server-filtered to type=sgv ) - } else if (response.code() in 400..499) - throw InvalidParameterNightscoutException(response.errorBody()?.string() ?: response.message()) + } else if (response.code in 400..499) + throw InvalidParameterNightscoutException(response.errorBody() ?: response.message()) else throw UnsuccessfulNightscoutException("Unsuccessful") } @@ -136,16 +161,15 @@ class NSAndroidClientImpl( val response = api.getSgvsModifiedSince(from, limit) if (response.isSuccessful) { - val eTagString = response.headers()["ETag"] - val eTag = eTagString?.substring(3, eTagString.length - 1)?.toLong() + val eTag = response.eTagAsLong() return@callWrapper NSAndroidClient.ReadResponse( - code = response.raw().networkResponse?.code ?: response.code(), + code = response.code, lastServerModified = eTag, values = response.body()?.result?.map(RemoteEntry::toSgv).toNotNull(), calibrations = response.body()?.result?.mapNotNull(RemoteEntry::toCalibrationMbg) ?: emptyList() ) - } else if (response.code() in 400..499) - throw InvalidParameterNightscoutException(response.errorBody()?.string() ?: response.message()) + } else if (response.code in 400..499) + throw InvalidParameterNightscoutException(response.errorBody() ?: response.message()) else throw UnsuccessfulNightscoutException("Unsuccessful") } @@ -155,13 +179,13 @@ class NSAndroidClientImpl( val response = api.getSgvsNewerThan(from, limit) if (response.isSuccessful) { return@callWrapper NSAndroidClient.ReadResponse( - code = response.raw().networkResponse?.code ?: response.code(), + code = response.code, lastServerModified = 0, values = response.body()?.result?.map(RemoteEntry::toSgv).toNotNull(), calibrations = response.body()?.result?.mapNotNull(RemoteEntry::toCalibrationMbg) ?: emptyList() ) - } else if (response.code() in 400..499) - throw InvalidParameterNightscoutException(response.errorBody()?.string() ?: response.message()) + } else if (response.code in 400..499) + throw InvalidParameterNightscoutException(response.errorBody() ?: response.message()) else throw UnsuccessfulNightscoutException("Unsuccessful") } @@ -172,31 +196,31 @@ class NSAndroidClientImpl( remoteEntry.app = "AAPS" val response = api.createEntry(remoteEntry) val responseBody = response.body() - val errorResponse = response.errorBody()?.string() - if (response.code() == 200 || response.code() == 201) { + val errorResponse = response.errorBody() + if (response.code == 200 || response.code == 201) { return@callWrapper CreateUpdateResponse( - response = response.code(), + response = response.code, identifier = responseBody?.identifier, isDeduplication = responseBody?.isDeduplication == true, deduplicatedIdentifier = responseBody?.deduplicatedIdentifier, lastModified = responseBody?.lastModified ) - } else if (response.code() == 400 && errorResponse?.contains("Bad or missing utcOffset field") == true && nsSgvV3.utcOffset != 0L) { + } else if (response.code == 400 && errorResponse?.contains("Bad or missing utcOffset field") == true && nsSgvV3.utcOffset != 0L) { // Record can be originally uploaded without utcOffset // because utcOffset is mandatory and cannot be change, try 0 nsSgvV3.utcOffset = 0 return@callWrapper createSgv(nsSgvV3) - } else if (response.code() == 400 && errorResponse?.contains("cannot be modified by the client") == true) { + } else if (response.code == 400 && errorResponse?.contains("cannot be modified by the client") == true) { // there is different field to field in AAPS // not possible to upload return@callWrapper CreateUpdateResponse( - response = response.code(), + response = response.code, identifier = null, errorResponse = errorResponse ) - } else if (response.code() in 400..499) { + } else if (response.code in 400..499) { return@callWrapper CreateUpdateResponse( - response = response.code(), + response = response.code, identifier = null, errorResponse = errorResponse ?: response.message() ) @@ -214,22 +238,22 @@ class NSAndroidClientImpl( val response = if (nsSgvV3.isValid) api.updateEntry(remoteEntry, identifier) else api.deleteEntry(identifier) - if (response.isSuccessful || response.code() == 404) { // OK or not found + if (response.isSuccessful || response.code == 404) { // OK or not found return@callWrapper CreateUpdateResponse( - response = response.code(), + response = response.code, identifier = null, isDeduplication = false, deduplicatedIdentifier = null, lastModified = null ) - } else if (response.code() in 400..499) { + } else if (response.code in 400..499) { return@callWrapper CreateUpdateResponse( - response = response.code(), + response = response.code, identifier = null, - errorResponse = response.errorBody()?.string() ?: response.message() + errorResponse = response.errorBody() ?: response.message() ) } else - throw UnsuccessfulNightscoutException(response.errorBody()?.string() ?: response.message()) + throw UnsuccessfulNightscoutException(response.errorBody() ?: response.message()) } override suspend fun createCalibration(nsMbgV3: NSMbgV3): CreateUpdateResponse = callWrapper(dispatcher) { @@ -238,28 +262,28 @@ class NSAndroidClientImpl( remoteEntry.app = "AAPS" val response = api.createEntry(remoteEntry) val responseBody = response.body() - val errorResponse = response.errorBody()?.string() - if (response.code() == 200 || response.code() == 201) { + val errorResponse = response.errorBody() + if (response.code == 200 || response.code == 201) { return@callWrapper CreateUpdateResponse( - response = response.code(), + response = response.code, identifier = responseBody?.identifier, isDeduplication = responseBody?.isDeduplication == true, deduplicatedIdentifier = responseBody?.deduplicatedIdentifier, lastModified = responseBody?.lastModified ) - } else if (response.code() == 400 && errorResponse?.contains("Bad or missing utcOffset field") == true && nsMbgV3.utcOffset != 0L) { + } else if (response.code == 400 && errorResponse?.contains("Bad or missing utcOffset field") == true && nsMbgV3.utcOffset != 0L) { nsMbgV3.utcOffset = 0 return@callWrapper createCalibration(nsMbgV3) - } else if (response.code() == 400 && errorResponse?.contains("cannot be modified by the client") == true) { + } else if (response.code == 400 && errorResponse?.contains("cannot be modified by the client") == true) { // there is different field to field in AAPS, not possible to upload return@callWrapper CreateUpdateResponse( - response = response.code(), + response = response.code, identifier = null, errorResponse = errorResponse ) - } else if (response.code() in 400..499) { + } else if (response.code in 400..499) { return@callWrapper CreateUpdateResponse( - response = response.code(), + response = response.code, identifier = null, errorResponse = errorResponse ?: response.message() ) @@ -277,22 +301,22 @@ class NSAndroidClientImpl( val response = if (nsMbgV3.isValid) api.updateEntry(remoteEntry, identifier) else api.deleteEntry(identifier) - if (response.isSuccessful || response.code() == 404) { // OK or not found + if (response.isSuccessful || response.code == 404) { // OK or not found return@callWrapper CreateUpdateResponse( - response = response.code(), + response = response.code, identifier = null, isDeduplication = false, deduplicatedIdentifier = null, lastModified = null ) - } else if (response.code() in 400..499) { + } else if (response.code in 400..499) { return@callWrapper CreateUpdateResponse( - response = response.code(), + response = response.code, identifier = null, - errorResponse = response.errorBody()?.string() ?: response.message() + errorResponse = response.errorBody() ?: response.message() ) } else - throw UnsuccessfulNightscoutException(response.errorBody()?.string() ?: response.message()) + throw UnsuccessfulNightscoutException(response.errorBody() ?: response.message()) } override suspend fun getTreatmentsNewerThan(createdAt: String, limit: Int): NSAndroidClient.ReadResponse> = callWrapper(dispatcher) { @@ -300,12 +324,12 @@ class NSAndroidClientImpl( val response = api.getTreatmentsNewerThan(createdAt, limit) if (response.isSuccessful) { return@callWrapper NSAndroidClient.ReadResponse( - code = response.raw().networkResponse?.code ?: response.code(), + code = response.code, lastServerModified = 0, values = response.body()?.result?.map(RemoteTreatment::toTreatment).toNotNull() ) - } else if (response.code() in 400..499) - throw InvalidParameterNightscoutException(response.errorBody()?.string() ?: response.message()) + } else if (response.code in 400..499) + throw InvalidParameterNightscoutException(response.errorBody() ?: response.message()) else throw UnsuccessfulNightscoutException("Unsuccessful") } @@ -314,14 +338,13 @@ class NSAndroidClientImpl( val response = api.getTreatmentsModifiedSince(from, limit) if (response.isSuccessful) { - val eTagString = response.headers()["ETag"] - val eTag = eTagString?.substring(3, eTagString.length - 1)?.toLong() + val eTag = response.eTagAsLong() return@callWrapper NSAndroidClient.ReadResponse( - code = response.raw().networkResponse?.code ?: response.code(), lastServerModified = eTag, values = response.body()?.result?.map + code = response.code, lastServerModified = eTag, values = response.body()?.result?.map (RemoteTreatment::toTreatment).toNotNull() ) - } else if (response.code() in 400..499) - throw InvalidParameterNightscoutException(response.errorBody()?.string() ?: response.message()) + } else if (response.code in 400..499) + throw InvalidParameterNightscoutException(response.errorBody() ?: response.message()) else throw UnsuccessfulNightscoutException("Unsuccessful") } @@ -331,8 +354,8 @@ class NSAndroidClientImpl( val response = api.getDeviceStatusModifiedSince(from) if (response.isSuccessful) { return@callWrapper response.body()?.result?.map(RemoteDeviceStatus::toNSDeviceStatus).toNotNull() - } else if (response.code() in 400..499) - throw InvalidParameterNightscoutException(response.errorBody()?.string() ?: response.message()) + } else if (response.code in 400..499) + throw InvalidParameterNightscoutException(response.errorBody() ?: response.message()) else throw UnsuccessfulNightscoutException("Unsuccessful") } @@ -342,9 +365,9 @@ class NSAndroidClientImpl( nsDeviceStatus.app = "AAPS" val response = api.createDeviceStatus(nsDeviceStatus.toRemoteDeviceStatus()) if (response.isSuccessful) { - if (response.code() == 200 || response.code() == 201) { + if (response.code == 200 || response.code == 201) { return@callWrapper CreateUpdateResponse( - response = response.code(), + response = response.code, identifier = response.body()?.identifier, isDeduplication = response.body()?.isDeduplication, deduplicatedIdentifier = response.body()?.deduplicatedIdentifier, @@ -352,9 +375,9 @@ class NSAndroidClientImpl( ) } else throw UnknownResponseNightscoutException("Unsuccessful") } else return@callWrapper CreateUpdateResponse( - response = response.code(), + response = response.code, identifier = null, - errorResponse = response.errorBody()?.string() ?: response.message() + errorResponse = response.errorBody() ?: response.message() ) } @@ -363,31 +386,31 @@ class NSAndroidClientImpl( val remoteTreatment = nsTreatment.toRemoteTreatment() ?: throw InvalidFormatNightscoutException("Invalid format") remoteTreatment.app = "AAPS" val response = api.createTreatment(remoteTreatment) - val errorResponse = response.errorBody()?.string() - if (response.code() == 200 || response.code() == 201) { + val errorResponse = response.errorBody() + if (response.code == 200 || response.code == 201) { return@callWrapper CreateUpdateResponse( - response = response.code(), + response = response.code, identifier = response.body()?.identifier, isDeduplication = response.body()?.isDeduplication == true, deduplicatedIdentifier = response.body()?.deduplicatedIdentifier, lastModified = response.body()?.lastModified ) - } else if (response.code() == 400 && errorResponse?.contains("Bad or missing utcOffset field") == true && nsTreatment.utcOffset != 0L) { + } else if (response.code == 400 && errorResponse?.contains("Bad or missing utcOffset field") == true && nsTreatment.utcOffset != 0L) { // Record can be originally uploaded without utcOffset // because utcOffset is mandatory and cannot be change, try 0 nsTreatment.utcOffset = 0 return@callWrapper createTreatment(nsTreatment) - } else if (response.code() == 400 && errorResponse?.contains("cannot be modified by the client") == true) { + } else if (response.code == 400 && errorResponse?.contains("cannot be modified by the client") == true) { // there is different field to field in AAPS // not possible to upload return@callWrapper CreateUpdateResponse( - response = response.code(), + response = response.code, identifier = null, errorResponse = errorResponse ) - } else if (response.code() in 400..499) { + } else if (response.code in 400..499) { return@callWrapper CreateUpdateResponse( - response = response.code(), + response = response.code, identifier = null, errorResponse = errorResponse ?: response.message() ) @@ -405,22 +428,22 @@ class NSAndroidClientImpl( val response = if (nsTreatment.isValid) api.updateTreatment(remoteTreatment, identifier) else api.deleteTreatment(identifier) - if (response.isSuccessful || response.code() == 404) { // OK or not found + if (response.isSuccessful || response.code == 404) { // OK or not found return@callWrapper CreateUpdateResponse( - response = response.code(), + response = response.code, identifier = null, isDeduplication = false, deduplicatedIdentifier = null, lastModified = null ) - } else if (response.code() in 400..499) { + } else if (response.code in 400..499) { return@callWrapper CreateUpdateResponse( - response = response.code(), + response = response.code, identifier = null, - errorResponse = response.errorBody()?.string() ?: response.message() + errorResponse = response.errorBody() ?: response.message() ) } else - throw UnsuccessfulNightscoutException(response.errorBody()?.string() ?: response.message()) + throw UnsuccessfulNightscoutException(response.errorBody() ?: response.message()) } override suspend fun getFoods(limit: Int): NSAndroidClient.ReadResponse> = callWrapper(dispatcher) { @@ -428,12 +451,12 @@ class NSAndroidClientImpl( val response = api.getFoods(limit) if (response.isSuccessful) { return@callWrapper NSAndroidClient.ReadResponse( - code = response.raw().networkResponse?.code ?: response.code(), + code = response.code, lastServerModified = 0, values = response.body()?.result?.map(RemoteFood::toNSFood).toNotNull() ) - } else if (response.code() in 400..499) - throw InvalidParameterNightscoutException(response.errorBody()?.string() ?: response.message()) + } else if (response.code in 400..499) + throw InvalidParameterNightscoutException(response.errorBody() ?: response.message()) else throw UnsuccessfulNightscoutException("Unsuccessful") } @@ -442,8 +465,7 @@ class NSAndroidClientImpl( override suspend fun getFoodsModifiedSince(from: Long, limit: Int): NSAndroidClient.ReadResponse> = callWrapper(dispatcher) { val response = api.getFoodsModifiedSince(from, limit) - val eTagString = response.headers()["ETag"] - val eTag = eTagString?.substring(3, eTagString.length - 1)?.toLong() ?: throw UnsuccessfulNightscoutException() + val eTag = response.eTagAsLong() ?: throw UnsuccessfulNightscoutException() if (response.isSuccessful) { return@callWrapper NSAndroidClient.ReadResponse(eTag, response.body()?.result?.map(RemoteFood::toNSFood).toNotNull()) } else { @@ -457,23 +479,23 @@ class NSAndroidClientImpl( remoteFood.app = "AAPS" val response = api.createFood(remoteFood) if (response.isSuccessful) { - if (response.code() == 200 || response.code() == 201) { + if (response.code == 200 || response.code == 201) { return@callWrapper CreateUpdateResponse( - response = response.code(), + response = response.code, identifier = response.body()?.identifier, isDeduplication = response.body()?.isDeduplication, deduplicatedIdentifier = response.body()?.deduplicatedIdentifier, lastModified = response.body()?.lastModified ) } else throw UnsuccessfulNightscoutException("Unsuccessful") - } else if (response.code() in 400..499) { + } else if (response.code in 400..499) { return@callWrapper CreateUpdateResponse( - response = response.code(), + response = response.code, identifier = null, - errorResponse = response.errorBody()?.string() ?: response.message() + errorResponse = response.errorBody() ?: response.message() ) } else - throw UnsuccessfulNightscoutException(response.errorBody()?.string() ?: response.message()) + throw UnsuccessfulNightscoutException(response.errorBody() ?: response.message()) } override suspend fun updateFood(nsFood: NSFood): CreateUpdateResponse = callWrapper(dispatcher) { @@ -483,154 +505,152 @@ class NSAndroidClientImpl( val response = if (nsFood.isValid) api.updateFood(remoteFood, identifier) else api.deleteFood(identifier) - if (response.isSuccessful || response.code() == 404) { // OK or not found + if (response.isSuccessful || response.code == 404) { // OK or not found return@callWrapper CreateUpdateResponse( - response = response.code(), + response = response.code, identifier = null, isDeduplication = false, deduplicatedIdentifier = null, lastModified = null ) - } else if (response.code() in 400..499) { + } else if (response.code in 400..499) { return@callWrapper CreateUpdateResponse( - response = response.code(), + response = response.code, identifier = null, - errorResponse = response.errorBody()?.string() ?: response.message() + errorResponse = response.errorBody() ?: response.message() ) } else - throw UnsuccessfulNightscoutException(response.errorBody()?.string() ?: response.message()) + throw UnsuccessfulNightscoutException(response.errorBody() ?: response.message()) } - override suspend fun createProfileStore(remoteProfileStore: JSONObject): CreateUpdateResponse = callWrapper(dispatcher) { - remoteProfileStore.put("app", "AAPS") - val response = api.createProfile(JsonParser.parseString(remoteProfileStore.toString()).asJsonObject) + override suspend fun createProfileStore(remoteProfileStore: JsonObject): CreateUpdateResponse = callWrapper(dispatcher) { + // kotlinx JsonObject is immutable, so the app name goes into a copy instead of being put in + // place. Same result on the wire: an existing "app" key is replaced, a new one is added. + val stamped = JsonObject(remoteProfileStore + ("app" to JsonPrimitive("AAPS"))) + val response = api.createProfile(stamped) if (response.isSuccessful) { - if (response.code() == 200 || response.code() == 201) { + if (response.code == 200 || response.code == 201) { return@callWrapper CreateUpdateResponse( - response = response.code(), + response = response.code, identifier = response.body()?.identifier, isDeduplication = response.body()?.isDeduplication, deduplicatedIdentifier = response.body()?.deduplicatedIdentifier, lastModified = response.body()?.lastModified ) } else throw UnsuccessfulNightscoutException("Unsuccessful") - } else if (response.code() in 400..499) { + } else if (response.code in 400..499) { return@callWrapper CreateUpdateResponse( - response = response.code(), + response = response.code, identifier = null, - errorResponse = response.errorBody()?.string() ?: response.message() + errorResponse = response.errorBody() ?: response.message() ) } else - throw UnsuccessfulNightscoutException(response.errorBody()?.string() ?: response.message()) + throw UnsuccessfulNightscoutException(response.errorBody() ?: response.message()) } - override suspend fun getLastProfileStore(): NSAndroidClient.ReadResponse> = callWrapper(dispatcher) { + override suspend fun getLastProfileStore(): NSAndroidClient.ReadResponse> = callWrapper(dispatcher) { val response = api.getLastProfile() if (response.isSuccessful) { - val eTagString = response.headers()["ETag"] - val eTag = eTagString?.substring(3, eTagString.length - 1)?.toLong() - return@callWrapper NSAndroidClient.ReadResponse(code = response.raw().networkResponse?.code ?: response.code(), lastServerModified = eTag, values = response.body()?.result.toNotNull()) - } else if (response.code() in 400..499) - throw InvalidParameterNightscoutException(response.errorBody()?.string() ?: response.message()) + val eTag = response.eTagAsLong() + return@callWrapper NSAndroidClient.ReadResponse(code = response.code, lastServerModified = eTag, values = response.body()?.result.toNotNull()) + } else if (response.code in 400..499) + throw InvalidParameterNightscoutException(response.errorBody() ?: response.message()) else throw UnsuccessfulNightscoutException("Unsuccessful") } - override suspend fun getProfileModifiedSince(from: Long): NSAndroidClient.ReadResponse> = callWrapper(dispatcher) { + override suspend fun getProfileModifiedSince(from: Long): NSAndroidClient.ReadResponse> = callWrapper(dispatcher) { val response = api.getProfileModifiedSince(from) if (response.isSuccessful) { - val eTagString = response.headers()["ETag"] - val eTag = eTagString?.substring(3, eTagString.length - 1)?.toLong() - return@callWrapper NSAndroidClient.ReadResponse(code = response.raw().networkResponse?.code ?: response.code(), lastServerModified = eTag, values = response.body()?.result.toNotNull()) - } else if (response.code() in 400..499) - throw InvalidParameterNightscoutException(response.errorBody()?.string() ?: response.message()) + val eTag = response.eTagAsLong() + return@callWrapper NSAndroidClient.ReadResponse(code = response.code, lastServerModified = eTag, values = response.body()?.result.toNotNull()) + } else if (response.code in 400..499) + throw InvalidParameterNightscoutException(response.errorBody() ?: response.message()) else throw UnsuccessfulNightscoutException("Unsuccessful") } - override suspend fun getSettings(identifier: String): NSAndroidClient.ReadResponse = callWrapper(dispatcher) { + override suspend fun getSettings(identifier: String): NSAndroidClient.ReadResponse = callWrapper(dispatcher) { val response = api.getSetting(identifier) if (response.isSuccessful) { - val eTagString = response.headers()["ETag"] - val eTag = eTagString?.substring(3, eTagString.length - 1)?.toLong() + val eTag = response.eTagAsLong() return@callWrapper NSAndroidClient.ReadResponse( - code = response.raw().networkResponse?.code ?: response.code(), + code = response.code, lastServerModified = eTag, values = response.body()?.result ) - } else if (response.code() == 404) { + } else if (response.code == 404) { return@callWrapper NSAndroidClient.ReadResponse(code = 404, lastServerModified = null, values = null) - } else if (response.code() in 400..499) - throw InvalidParameterNightscoutException(response.errorBody()?.string() ?: response.message()) + } else if (response.code in 400..499) + throw InvalidParameterNightscoutException(response.errorBody() ?: response.message()) else throw UnsuccessfulNightscoutException("Unsuccessful") } - override suspend fun getSettingsModifiedSince(from: Long, limit: Int): NSAndroidClient.ReadResponse> = callWrapper(dispatcher) { + override suspend fun getSettingsModifiedSince(from: Long, limit: Int): NSAndroidClient.ReadResponse> = callWrapper(dispatcher) { val response = api.getSettingsModifiedSince(from, limit) if (response.isSuccessful) { - val eTagString = response.headers()["ETag"] - val eTag = eTagString?.substring(3, eTagString.length - 1)?.toLong() + val eTag = response.eTagAsLong() return@callWrapper NSAndroidClient.ReadResponse( - code = response.raw().networkResponse?.code ?: response.code(), + code = response.code, lastServerModified = eTag, values = response.body()?.result.toNotNull() ) - } else if (response.code() in 400..499) - throw InvalidParameterNightscoutException(response.errorBody()?.string() ?: response.message()) + } else if (response.code in 400..499) + throw InvalidParameterNightscoutException(response.errorBody() ?: response.message()) else throw UnsuccessfulNightscoutException("Unsuccessful") } - override suspend fun searchSettings(limit: Int): NSAndroidClient.ReadResponse> = callWrapper(dispatcher) { + override suspend fun searchSettings(limit: Int): NSAndroidClient.ReadResponse> = callWrapper(dispatcher) { val response = api.searchSettings(limit) if (response.isSuccessful) { - val eTagString = response.headers()["ETag"] - val eTag = eTagString?.substring(3, eTagString.length - 1)?.toLong() + val eTag = response.eTagAsLong() return@callWrapper NSAndroidClient.ReadResponse( - code = response.raw().networkResponse?.code ?: response.code(), + code = response.code, lastServerModified = eTag, values = response.body()?.result.toNotNull() ) - } else if (response.code() in 400..499) - throw InvalidParameterNightscoutException(response.errorBody()?.string() ?: response.message()) + } else if (response.code in 400..499) + throw InvalidParameterNightscoutException(response.errorBody() ?: response.message()) else throw UnsuccessfulNightscoutException("Unsuccessful") } - override suspend fun createSettings(settings: JSONObject): CreateUpdateResponse = callWrapper(dispatcher) { + override suspend fun createSettings(settings: JsonObject): CreateUpdateResponse = callWrapper(dispatcher) { - settings.put("app", "AAPS") - val response = api.createSetting(JsonParser.parseString(settings.toString()).asJsonObject) + // See createProfileStore: kotlinx JsonObject is immutable, so stamp a copy. + val stamped = JsonObject(settings + ("app" to JsonPrimitive("AAPS"))) + val response = api.createSetting(stamped) if (response.isSuccessful) { - if (response.code() == 200 || response.code() == 201) { + if (response.code == 200 || response.code == 201) { return@callWrapper CreateUpdateResponse( - response = response.code(), + response = response.code, identifier = response.body()?.identifier, isDeduplication = response.body()?.isDeduplication, deduplicatedIdentifier = response.body()?.deduplicatedIdentifier, lastModified = response.body()?.lastModified ) } else throw UnsuccessfulNightscoutException("Unsuccessful") - } else if (response.code() in 400..499) { + } else if (response.code in 400..499) { return@callWrapper CreateUpdateResponse( - response = response.code(), + response = response.code, identifier = null, - errorResponse = response.errorBody()?.string() ?: response.message() + errorResponse = response.errorBody() ?: response.message() ) } else - throw UnsuccessfulNightscoutException(response.errorBody()?.string() ?: response.message()) + throw UnsuccessfulNightscoutException(response.errorBody() ?: response.message()) } - override suspend fun patchSettings(identifier: String, settings: JSONObject): CreateUpdateResponse = callWrapper(dispatcher) { + override suspend fun patchSettings(identifier: String, settings: JsonObject): CreateUpdateResponse = callWrapper(dispatcher) { - val response = api.patchSetting(JsonParser.parseString(settings.toString()).asJsonObject, identifier) - if (response.code() == 404) { + val response = api.patchSetting(settings, identifier) + if (response.code == 404) { return@callWrapper CreateUpdateResponse( response = 404, identifier = null, @@ -640,41 +660,41 @@ class NSAndroidClientImpl( ) } else if (response.isSuccessful) { return@callWrapper CreateUpdateResponse( - response = response.code(), + response = response.code, identifier = response.body()?.identifier, isDeduplication = response.body()?.isDeduplication == true, deduplicatedIdentifier = response.body()?.deduplicatedIdentifier, lastModified = response.body()?.lastModified ) - } else if (response.code() in 400..499) { + } else if (response.code in 400..499) { return@callWrapper CreateUpdateResponse( - response = response.code(), + response = response.code, identifier = null, - errorResponse = response.errorBody()?.string() ?: response.message() + errorResponse = response.errorBody() ?: response.message() ) } else - throw UnsuccessfulNightscoutException(response.errorBody()?.string() ?: response.message()) + throw UnsuccessfulNightscoutException(response.errorBody() ?: response.message()) } - override suspend fun updateSettings(identifier: String, settings: JSONObject): CreateUpdateResponse = callWrapper(dispatcher) { + override suspend fun updateSettings(identifier: String, settings: JsonObject): CreateUpdateResponse = callWrapper(dispatcher) { - val response = api.updateSetting(JsonParser.parseString(settings.toString()).asJsonObject, identifier) + val response = api.updateSetting(settings, identifier) if (response.isSuccessful) { return@callWrapper CreateUpdateResponse( - response = response.code(), + response = response.code, identifier = response.body()?.identifier, isDeduplication = response.body()?.isDeduplication == true, deduplicatedIdentifier = response.body()?.deduplicatedIdentifier, lastModified = response.body()?.lastModified ) - } else if (response.code() in 400..499) { + } else if (response.code in 400..499) { return@callWrapper CreateUpdateResponse( - response = response.code(), + response = response.code, identifier = null, - errorResponse = response.errorBody()?.string() ?: response.message() + errorResponse = response.errorBody() ?: response.message() ) } else - throw UnsuccessfulNightscoutException(response.errorBody()?.string() ?: response.message()) + throw UnsuccessfulNightscoutException(response.errorBody() ?: response.message()) } override suspend fun deleteSettings(identifier: String): CreateUpdateResponse = deleteSettingsInternal(identifier, permanent = false) @@ -685,22 +705,22 @@ class NSAndroidClientImpl( private suspend fun deleteSettingsInternal(identifier: String, permanent: Boolean): CreateUpdateResponse = callWrapper(dispatcher) { val response = api.deleteSetting(identifier, if (permanent) true else null) - if (response.isSuccessful || response.code() == 404) { + if (response.isSuccessful || response.code == 404) { return@callWrapper CreateUpdateResponse( - response = response.code(), + response = response.code, identifier = null, isDeduplication = false, deduplicatedIdentifier = null, lastModified = null ) - } else if (response.code() in 400..499) { + } else if (response.code in 400..499) { return@callWrapper CreateUpdateResponse( - response = response.code(), + response = response.code, identifier = null, - errorResponse = response.errorBody()?.string() ?: response.message() + errorResponse = response.errorBody() ?: response.message() ) } else - throw UnsuccessfulNightscoutException(response.errorBody()?.string() ?: response.message()) + throw UnsuccessfulNightscoutException(response.errorBody() ?: response.message()) } private suspend fun callWrapper(dispatcher: CoroutineDispatcher, block: suspend () -> T): T = diff --git a/core/nssdk/src/commonMain/kotlin/app/aaps/core/nssdk/NsSdkJson.kt b/core/nssdk/src/commonMain/kotlin/app/aaps/core/nssdk/NsSdkJson.kt new file mode 100644 index 000000000000..68282d19423b --- /dev/null +++ b/core/nssdk/src/commonMain/kotlin/app/aaps/core/nssdk/NsSdkJson.kt @@ -0,0 +1,63 @@ +package app.aaps.core.nssdk + +import kotlinx.serialization.json.Json + +/** + * The one JSON configuration the Nightscout wire layer uses, for both Retrofit and the string + * mappers, so they cannot drift apart. + * + * Each flag is here to match what Gson did before, not because it is a good default in the + * abstract. `GsonTypeCoercionTest` pins the old behaviour and must keep passing. + * + * - **`ignoreUnknownKeys`** - Nightscout documents carry fields this version has never seen, from + * other uploaders and newer AAPS builds. Gson dropped them silently; without this, kotlinx throws. + * + * - **`explicitNulls = false`** - on the way out, a null field is omitted rather than written as + * `"field": null`. Gson omits nulls by default, and NS validation rejects some explicit nulls. + * + * - **`isLenient`** - the one that needs justifying. It exists for a single measured difference: + * a bare **number arriving in a `String` field**. Strict kotlinx already accepts a quoted number + * in a `Long` / `Double` / `Int` field and `"true"` in a `Boolean` field, so those need nothing. + * The gap is real data, not a hypothetical: [app.aaps.core.nssdk.remotemodel.RemoteTreatment] + * declares `created_at` as `String?` and its own comment records that servers send it "with string + * others with long". + * + * The cost is that some malformed JSON which throws today would parse instead. That is the safer + * direction here: the socket.io listener that feeds this + * (`NSClientV3Service.onDataCreateUpdate`) has no try/catch, so a throw escapes onto the socket + * callback thread and loses the record. `RealNightscoutTreatmentTest` pins which malformed inputs + * still throw. + * + * - **`encodeDefaults = true`** - required for backward compatibility, and the easiest one to get + * wrong, because kotlinx's default is the opposite of Gson's. + * + * Gson writes every non-null field. kotlinx omits any field whose value equals its default, so + * `isReadOnly = false`, `duration = 0` and every one of the nullable fields that were given a + * `= null` default would simply stop being written. A document AAPS uploads has to stay readable + * by older AAPS versions and by the Nightscout server's own validation, neither of which is + * updated at the same time as this app - and NS rejects some documents outright for a missing + * field (`"Bad or missing utcOffset field"`). Absent is not the same as false. + * + * `NsSdkWireFormatTest` compares what goes onto the wire, field by field. + * + * - **`coerceInputValues = true`** - forward compatibility with senders that are newer than this + * app, and the other flag that is not optional. + * + * An **unknown enum value** throws in kotlinx but was silently mapped to `null` by Gson. That is + * not a corner case: `eventType` is an enum, Nightscout is written to by many uploaders, and a + * newer AAPS adding one event type would otherwise make every older client throw on that record - + * inside a socket.io listener with no try/catch, so the record is lost and the exception escapes + * onto the callback thread. Coercing to the default (`null`) keeps the old, tolerant behaviour: + * the treatment is simply not recognised and is skipped, which is what already happens for an + * absent `eventType`. + * + * Found by `RealNightscoutTreatmentTest`, which parses an `"eventType":"Something New"` record for + * exactly this reason. + */ +internal val nsSdkJson = Json { + ignoreUnknownKeys = true + explicitNulls = false + isLenient = true + encodeDefaults = true + coerceInputValues = true +} diff --git a/core/nssdk/src/main/kotlin/app/aaps/core/nssdk/exceptions/DateHeaderOutOfToleranceException.kt b/core/nssdk/src/commonMain/kotlin/app/aaps/core/nssdk/exceptions/DateHeaderOutOfToleranceException.kt similarity index 100% rename from core/nssdk/src/main/kotlin/app/aaps/core/nssdk/exceptions/DateHeaderOutOfToleranceException.kt rename to core/nssdk/src/commonMain/kotlin/app/aaps/core/nssdk/exceptions/DateHeaderOutOfToleranceException.kt diff --git a/core/nssdk/src/main/kotlin/app/aaps/core/nssdk/exceptions/InvalidAccessTokenException.kt b/core/nssdk/src/commonMain/kotlin/app/aaps/core/nssdk/exceptions/InvalidAccessTokenException.kt similarity index 100% rename from core/nssdk/src/main/kotlin/app/aaps/core/nssdk/exceptions/InvalidAccessTokenException.kt rename to core/nssdk/src/commonMain/kotlin/app/aaps/core/nssdk/exceptions/InvalidAccessTokenException.kt diff --git a/core/nssdk/src/main/kotlin/app/aaps/core/nssdk/exceptions/InvalidFormatNightscoutException.kt b/core/nssdk/src/commonMain/kotlin/app/aaps/core/nssdk/exceptions/InvalidFormatNightscoutException.kt similarity index 100% rename from core/nssdk/src/main/kotlin/app/aaps/core/nssdk/exceptions/InvalidFormatNightscoutException.kt rename to core/nssdk/src/commonMain/kotlin/app/aaps/core/nssdk/exceptions/InvalidFormatNightscoutException.kt diff --git a/core/nssdk/src/main/kotlin/app/aaps/core/nssdk/exceptions/InvalidParameterNightscoutException.kt b/core/nssdk/src/commonMain/kotlin/app/aaps/core/nssdk/exceptions/InvalidParameterNightscoutException.kt similarity index 100% rename from core/nssdk/src/main/kotlin/app/aaps/core/nssdk/exceptions/InvalidParameterNightscoutException.kt rename to core/nssdk/src/commonMain/kotlin/app/aaps/core/nssdk/exceptions/InvalidParameterNightscoutException.kt diff --git a/core/nssdk/src/commonMain/kotlin/app/aaps/core/nssdk/exceptions/NightscoutException.kt b/core/nssdk/src/commonMain/kotlin/app/aaps/core/nssdk/exceptions/NightscoutException.kt new file mode 100644 index 000000000000..845355bd1b60 --- /dev/null +++ b/core/nssdk/src/commonMain/kotlin/app/aaps/core/nssdk/exceptions/NightscoutException.kt @@ -0,0 +1,20 @@ +package app.aaps.core.nssdk.exceptions + +import kotlinx.io.IOException + +/** + * Base class for everything this client throws. + * + * The base is **kotlinx-io**'s `IOException`, not `java.io.IOException`, because the latter is a JVM + * type and would keep this module off iOS. On the JVM kotlinx-io declares it as + * `actual typealias IOException = java.io.IOException`, so the two are the *same class* there and + * existing `catch (e: java.io.IOException)` blocks keep catching - `PairingOfferFetcher` and + * `PairingOfferPublisher` in `:plugins:sync` rely on that, and `deleteOffer` in particular must + * never throw, because a pairing offer left on the server keeps a PIN brute-force window open. + * + * `NightscoutExceptionTest` asserts the identity per subclass rather than trusting it: a hand rolled + * `expect class` would compile just as happily and silently turn those catches into non-catches. + * + * kotlinx-io arrives transitively with Ktor, so this costs no new dependency. + */ +abstract class NightscoutException(message: String, cause: Throwable? = null) : IOException(message, cause) \ No newline at end of file diff --git a/core/nssdk/src/main/kotlin/app/aaps/core/nssdk/exceptions/UnknownResponseNightscoutException.kt b/core/nssdk/src/commonMain/kotlin/app/aaps/core/nssdk/exceptions/UnknownResponseNightscoutException.kt similarity index 100% rename from core/nssdk/src/main/kotlin/app/aaps/core/nssdk/exceptions/UnknownResponseNightscoutException.kt rename to core/nssdk/src/commonMain/kotlin/app/aaps/core/nssdk/exceptions/UnknownResponseNightscoutException.kt diff --git a/core/nssdk/src/main/kotlin/app/aaps/core/nssdk/exceptions/UnsuccessfulNightscoutException.kt b/core/nssdk/src/commonMain/kotlin/app/aaps/core/nssdk/exceptions/UnsuccessfulNightscoutException.kt similarity index 100% rename from core/nssdk/src/main/kotlin/app/aaps/core/nssdk/exceptions/UnsuccessfulNightscoutException.kt rename to core/nssdk/src/commonMain/kotlin/app/aaps/core/nssdk/exceptions/UnsuccessfulNightscoutException.kt diff --git a/core/nssdk/src/main/kotlin/app/aaps/core/nssdk/interfaces/NSAndroidCallbackClient.kt b/core/nssdk/src/commonMain/kotlin/app/aaps/core/nssdk/interfaces/NSAndroidCallbackClient.kt similarity index 100% rename from core/nssdk/src/main/kotlin/app/aaps/core/nssdk/interfaces/NSAndroidCallbackClient.kt rename to core/nssdk/src/commonMain/kotlin/app/aaps/core/nssdk/interfaces/NSAndroidCallbackClient.kt diff --git a/core/nssdk/src/main/kotlin/app/aaps/core/nssdk/interfaces/NSAndroidClient.kt b/core/nssdk/src/commonMain/kotlin/app/aaps/core/nssdk/interfaces/NSAndroidClient.kt similarity index 82% rename from core/nssdk/src/main/kotlin/app/aaps/core/nssdk/interfaces/NSAndroidClient.kt rename to core/nssdk/src/commonMain/kotlin/app/aaps/core/nssdk/interfaces/NSAndroidClient.kt index 1eeabb8d2e04..383a3fd12a86 100644 --- a/core/nssdk/src/main/kotlin/app/aaps/core/nssdk/interfaces/NSAndroidClient.kt +++ b/core/nssdk/src/commonMain/kotlin/app/aaps/core/nssdk/interfaces/NSAndroidClient.kt @@ -8,7 +8,7 @@ import app.aaps.core.nssdk.localmodel.food.NSFood import app.aaps.core.nssdk.localmodel.treatment.CreateUpdateResponse import app.aaps.core.nssdk.localmodel.treatment.NSTreatment import app.aaps.core.nssdk.remotemodel.LastModified -import org.json.JSONObject +import kotlinx.serialization.json.JsonObject interface NSAndroidClient { @@ -40,9 +40,9 @@ interface NSAndroidClient { suspend fun createDeviceStatus(nsDeviceStatus: NSDeviceStatus): CreateUpdateResponse suspend fun getDeviceStatusModifiedSince(from: Long): List - suspend fun createProfileStore(remoteProfileStore: JSONObject): CreateUpdateResponse - suspend fun getProfileModifiedSince(from: Long): ReadResponse> - suspend fun getLastProfileStore(): ReadResponse> + suspend fun createProfileStore(remoteProfileStore: JsonObject): CreateUpdateResponse + suspend fun getProfileModifiedSince(from: Long): ReadResponse> + suspend fun getLastProfileStore(): ReadResponse> suspend fun createTreatment(nsTreatment: NSTreatment): CreateUpdateResponse suspend fun updateTreatment(nsTreatment: NSTreatment): CreateUpdateResponse @@ -52,20 +52,23 @@ interface NSAndroidClient { suspend fun createFood(nsFood: NSFood): CreateUpdateResponse suspend fun updateFood(nsFood: NSFood): CreateUpdateResponse - suspend fun getSettings(identifier: String): ReadResponse + suspend fun getSettings(identifier: String): ReadResponse /** History pull. Server requires `api:settings:admin` permission. */ - suspend fun getSettingsModifiedSince(from: Long, limit: Int = 100): ReadResponse> + suspend fun getSettingsModifiedSince(from: Long, limit: Int = 100): ReadResponse> /** List all settings docs. Server requires `api:settings:admin` permission. */ - suspend fun searchSettings(limit: Int = 100): ReadResponse> - suspend fun createSettings(settings: JSONObject): CreateUpdateResponse - suspend fun patchSettings(identifier: String, settings: JSONObject): CreateUpdateResponse + suspend fun searchSettings(limit: Int = 100): ReadResponse> + suspend fun createSettings(settings: JsonObject): CreateUpdateResponse + suspend fun patchSettings(identifier: String, settings: JsonObject): CreateUpdateResponse /** Upsert: replaces existing doc with [identifier], or inserts if absent. NS3 "UPDATE" semantics. */ - suspend fun updateSettings(identifier: String, settings: JSONObject): CreateUpdateResponse + suspend fun updateSettings(identifier: String, settings: JsonObject): CreateUpdateResponse suspend fun deleteSettings(identifier: String): CreateUpdateResponse + /** Releases the HTTP engine. Call when the client is replaced or no longer needed. */ + fun close() + /** Hard delete via NS `?permanent=true` — removes the doc instead of soft-deleting (tombstoning) it. */ suspend fun deleteSettingsPermanent(identifier: String): CreateUpdateResponse } diff --git a/core/nssdk/src/main/kotlin/app/aaps/core/nssdk/interfaces/RunningConfiguration.kt b/core/nssdk/src/commonMain/kotlin/app/aaps/core/nssdk/interfaces/RunningConfiguration.kt similarity index 85% rename from core/nssdk/src/main/kotlin/app/aaps/core/nssdk/interfaces/RunningConfiguration.kt rename to core/nssdk/src/commonMain/kotlin/app/aaps/core/nssdk/interfaces/RunningConfiguration.kt index c6ba3e20a177..29e50a4629db 100644 --- a/core/nssdk/src/main/kotlin/app/aaps/core/nssdk/interfaces/RunningConfiguration.kt +++ b/core/nssdk/src/commonMain/kotlin/app/aaps/core/nssdk/interfaces/RunningConfiguration.kt @@ -1,16 +1,16 @@ package app.aaps.core.nssdk.interfaces import app.aaps.core.nssdk.localmodel.configuration.NSRunningConfiguration -import org.json.JSONObject +import kotlinx.serialization.json.JsonObject interface RunningConfiguration { // called in AAPS mode only — "cold" settings doc: plugin config that changes only on user edits. - fun configuration(): JSONObject + fun configuration(): JsonObject // called in AAPS mode only — "hot" settings doc: runtime state that changes frequently // (active scene lifecycle) plus computed runtime flags. Published to a separate identifier. - fun activeSceneConfiguration(): JSONObject + fun activeSceneConfiguration(): JsonObject // called in NSClient mode only — apply the cold doc (everything except the active scene). fun applyCold(configuration: NSRunningConfiguration) diff --git a/core/nssdk/src/main/kotlin/app/aaps/core/nssdk/localmodel/ApiPermission.kt b/core/nssdk/src/commonMain/kotlin/app/aaps/core/nssdk/localmodel/ApiPermission.kt similarity index 99% rename from core/nssdk/src/main/kotlin/app/aaps/core/nssdk/localmodel/ApiPermission.kt rename to core/nssdk/src/commonMain/kotlin/app/aaps/core/nssdk/localmodel/ApiPermission.kt index df83ecb989ac..a476b8d97ea2 100644 --- a/core/nssdk/src/main/kotlin/app/aaps/core/nssdk/localmodel/ApiPermission.kt +++ b/core/nssdk/src/commonMain/kotlin/app/aaps/core/nssdk/localmodel/ApiPermission.kt @@ -6,6 +6,7 @@ data class ApiPermission( val update: Boolean, val delete: Boolean ) { + val full: Boolean get() = this.create && this.read && this.update && this.delete } diff --git a/core/nssdk/src/main/kotlin/app/aaps/core/nssdk/localmodel/ApiPermissions.kt b/core/nssdk/src/commonMain/kotlin/app/aaps/core/nssdk/localmodel/ApiPermissions.kt similarity index 99% rename from core/nssdk/src/main/kotlin/app/aaps/core/nssdk/localmodel/ApiPermissions.kt rename to core/nssdk/src/commonMain/kotlin/app/aaps/core/nssdk/localmodel/ApiPermissions.kt index 84266d0155ec..cf0e255a9ca9 100644 --- a/core/nssdk/src/main/kotlin/app/aaps/core/nssdk/localmodel/ApiPermissions.kt +++ b/core/nssdk/src/commonMain/kotlin/app/aaps/core/nssdk/localmodel/ApiPermissions.kt @@ -8,6 +8,7 @@ data class ApiPermissions( val settings: ApiPermission, val treatments: ApiPermission ) { + fun isFull() = deviceStatus.full && entries.full && food.full && profile.full && settings.full && treatments.full fun isRead() = deviceStatus.read && entries.read && food.read && profile.read && settings.read && treatments.read } diff --git a/core/nssdk/src/main/kotlin/app/aaps/core/nssdk/localmodel/Status.kt b/core/nssdk/src/commonMain/kotlin/app/aaps/core/nssdk/localmodel/Status.kt similarity index 100% rename from core/nssdk/src/main/kotlin/app/aaps/core/nssdk/localmodel/Status.kt rename to core/nssdk/src/commonMain/kotlin/app/aaps/core/nssdk/localmodel/Status.kt diff --git a/core/nssdk/src/main/kotlin/app/aaps/core/nssdk/localmodel/Storage.kt b/core/nssdk/src/commonMain/kotlin/app/aaps/core/nssdk/localmodel/Storage.kt similarity index 100% rename from core/nssdk/src/main/kotlin/app/aaps/core/nssdk/localmodel/Storage.kt rename to core/nssdk/src/commonMain/kotlin/app/aaps/core/nssdk/localmodel/Storage.kt diff --git a/core/nssdk/src/main/kotlin/app/aaps/core/nssdk/localmodel/clientcontrol/AckEnvelope.kt b/core/nssdk/src/commonMain/kotlin/app/aaps/core/nssdk/localmodel/clientcontrol/AckEnvelope.kt similarity index 100% rename from core/nssdk/src/main/kotlin/app/aaps/core/nssdk/localmodel/clientcontrol/AckEnvelope.kt rename to core/nssdk/src/commonMain/kotlin/app/aaps/core/nssdk/localmodel/clientcontrol/AckEnvelope.kt diff --git a/core/nssdk/src/main/kotlin/app/aaps/core/nssdk/localmodel/clientcontrol/AuthorizedClient.kt b/core/nssdk/src/commonMain/kotlin/app/aaps/core/nssdk/localmodel/clientcontrol/AuthorizedClient.kt similarity index 100% rename from core/nssdk/src/main/kotlin/app/aaps/core/nssdk/localmodel/clientcontrol/AuthorizedClient.kt rename to core/nssdk/src/commonMain/kotlin/app/aaps/core/nssdk/localmodel/clientcontrol/AuthorizedClient.kt diff --git a/core/nssdk/src/main/kotlin/app/aaps/core/nssdk/localmodel/clientcontrol/BolusPreview.kt b/core/nssdk/src/commonMain/kotlin/app/aaps/core/nssdk/localmodel/clientcontrol/BolusPreview.kt similarity index 100% rename from core/nssdk/src/main/kotlin/app/aaps/core/nssdk/localmodel/clientcontrol/BolusPreview.kt rename to core/nssdk/src/commonMain/kotlin/app/aaps/core/nssdk/localmodel/clientcontrol/BolusPreview.kt diff --git a/core/nssdk/src/main/kotlin/app/aaps/core/nssdk/localmodel/clientcontrol/ClientControlMessage.kt b/core/nssdk/src/commonMain/kotlin/app/aaps/core/nssdk/localmodel/clientcontrol/ClientControlMessage.kt similarity index 100% rename from core/nssdk/src/main/kotlin/app/aaps/core/nssdk/localmodel/clientcontrol/ClientControlMessage.kt rename to core/nssdk/src/commonMain/kotlin/app/aaps/core/nssdk/localmodel/clientcontrol/ClientControlMessage.kt diff --git a/core/nssdk/src/main/kotlin/app/aaps/core/nssdk/localmodel/clientcontrol/MasterPairing.kt b/core/nssdk/src/commonMain/kotlin/app/aaps/core/nssdk/localmodel/clientcontrol/MasterPairing.kt similarity index 100% rename from core/nssdk/src/main/kotlin/app/aaps/core/nssdk/localmodel/clientcontrol/MasterPairing.kt rename to core/nssdk/src/commonMain/kotlin/app/aaps/core/nssdk/localmodel/clientcontrol/MasterPairing.kt diff --git a/core/nssdk/src/main/kotlin/app/aaps/core/nssdk/localmodel/clientcontrol/PairingOffer.kt b/core/nssdk/src/commonMain/kotlin/app/aaps/core/nssdk/localmodel/clientcontrol/PairingOffer.kt similarity index 100% rename from core/nssdk/src/main/kotlin/app/aaps/core/nssdk/localmodel/clientcontrol/PairingOffer.kt rename to core/nssdk/src/commonMain/kotlin/app/aaps/core/nssdk/localmodel/clientcontrol/PairingOffer.kt diff --git a/core/nssdk/src/main/kotlin/app/aaps/core/nssdk/localmodel/clientcontrol/PairingPayload.kt b/core/nssdk/src/commonMain/kotlin/app/aaps/core/nssdk/localmodel/clientcontrol/PairingPayload.kt similarity index 100% rename from core/nssdk/src/main/kotlin/app/aaps/core/nssdk/localmodel/clientcontrol/PairingPayload.kt rename to core/nssdk/src/commonMain/kotlin/app/aaps/core/nssdk/localmodel/clientcontrol/PairingPayload.kt diff --git a/core/nssdk/src/main/kotlin/app/aaps/core/nssdk/localmodel/clientcontrol/ProgressEnvelope.kt b/core/nssdk/src/commonMain/kotlin/app/aaps/core/nssdk/localmodel/clientcontrol/ProgressEnvelope.kt similarity index 100% rename from core/nssdk/src/main/kotlin/app/aaps/core/nssdk/localmodel/clientcontrol/ProgressEnvelope.kt rename to core/nssdk/src/commonMain/kotlin/app/aaps/core/nssdk/localmodel/clientcontrol/ProgressEnvelope.kt diff --git a/core/nssdk/src/main/kotlin/app/aaps/core/nssdk/localmodel/clientcontrol/SignedEnvelope.kt b/core/nssdk/src/commonMain/kotlin/app/aaps/core/nssdk/localmodel/clientcontrol/SignedEnvelope.kt similarity index 100% rename from core/nssdk/src/main/kotlin/app/aaps/core/nssdk/localmodel/clientcontrol/SignedEnvelope.kt rename to core/nssdk/src/commonMain/kotlin/app/aaps/core/nssdk/localmodel/clientcontrol/SignedEnvelope.kt diff --git a/core/nssdk/src/main/kotlin/app/aaps/core/nssdk/localmodel/configuration/NSRunningConfiguration.kt b/core/nssdk/src/commonMain/kotlin/app/aaps/core/nssdk/localmodel/configuration/NSRunningConfiguration.kt similarity index 100% rename from core/nssdk/src/main/kotlin/app/aaps/core/nssdk/localmodel/configuration/NSRunningConfiguration.kt rename to core/nssdk/src/commonMain/kotlin/app/aaps/core/nssdk/localmodel/configuration/NSRunningConfiguration.kt diff --git a/core/nssdk/src/main/kotlin/app/aaps/core/nssdk/localmodel/devicestatus/NSDeviceStatus.kt b/core/nssdk/src/commonMain/kotlin/app/aaps/core/nssdk/localmodel/devicestatus/NSDeviceStatus.kt similarity index 100% rename from core/nssdk/src/main/kotlin/app/aaps/core/nssdk/localmodel/devicestatus/NSDeviceStatus.kt rename to core/nssdk/src/commonMain/kotlin/app/aaps/core/nssdk/localmodel/devicestatus/NSDeviceStatus.kt diff --git a/core/nssdk/src/main/kotlin/app/aaps/core/nssdk/localmodel/entry/Direction.kt b/core/nssdk/src/commonMain/kotlin/app/aaps/core/nssdk/localmodel/entry/Direction.kt similarity index 100% rename from core/nssdk/src/main/kotlin/app/aaps/core/nssdk/localmodel/entry/Direction.kt rename to core/nssdk/src/commonMain/kotlin/app/aaps/core/nssdk/localmodel/entry/Direction.kt diff --git a/core/nssdk/src/main/kotlin/app/aaps/core/nssdk/localmodel/entry/NSMbgV3.kt b/core/nssdk/src/commonMain/kotlin/app/aaps/core/nssdk/localmodel/entry/NSMbgV3.kt similarity index 100% rename from core/nssdk/src/main/kotlin/app/aaps/core/nssdk/localmodel/entry/NSMbgV3.kt rename to core/nssdk/src/commonMain/kotlin/app/aaps/core/nssdk/localmodel/entry/NSMbgV3.kt diff --git a/core/nssdk/src/main/kotlin/app/aaps/core/nssdk/localmodel/entry/NSSgvV3.kt b/core/nssdk/src/commonMain/kotlin/app/aaps/core/nssdk/localmodel/entry/NSSgvV3.kt similarity index 100% rename from core/nssdk/src/main/kotlin/app/aaps/core/nssdk/localmodel/entry/NSSgvV3.kt rename to core/nssdk/src/commonMain/kotlin/app/aaps/core/nssdk/localmodel/entry/NSSgvV3.kt diff --git a/core/nssdk/src/main/kotlin/app/aaps/core/nssdk/localmodel/entry/NsUnits.kt b/core/nssdk/src/commonMain/kotlin/app/aaps/core/nssdk/localmodel/entry/NsUnits.kt similarity index 100% rename from core/nssdk/src/main/kotlin/app/aaps/core/nssdk/localmodel/entry/NsUnits.kt rename to core/nssdk/src/commonMain/kotlin/app/aaps/core/nssdk/localmodel/entry/NsUnits.kt diff --git a/core/nssdk/src/main/kotlin/app/aaps/core/nssdk/localmodel/food/NSFood.kt b/core/nssdk/src/commonMain/kotlin/app/aaps/core/nssdk/localmodel/food/NSFood.kt similarity index 100% rename from core/nssdk/src/main/kotlin/app/aaps/core/nssdk/localmodel/food/NSFood.kt rename to core/nssdk/src/commonMain/kotlin/app/aaps/core/nssdk/localmodel/food/NSFood.kt diff --git a/core/nssdk/src/main/kotlin/app/aaps/core/nssdk/localmodel/treatment/CreateUpdateResponse.kt b/core/nssdk/src/commonMain/kotlin/app/aaps/core/nssdk/localmodel/treatment/CreateUpdateResponse.kt similarity index 100% rename from core/nssdk/src/main/kotlin/app/aaps/core/nssdk/localmodel/treatment/CreateUpdateResponse.kt rename to core/nssdk/src/commonMain/kotlin/app/aaps/core/nssdk/localmodel/treatment/CreateUpdateResponse.kt diff --git a/core/nssdk/src/commonMain/kotlin/app/aaps/core/nssdk/localmodel/treatment/EventType.kt b/core/nssdk/src/commonMain/kotlin/app/aaps/core/nssdk/localmodel/treatment/EventType.kt new file mode 100644 index 000000000000..6c627627ab88 --- /dev/null +++ b/core/nssdk/src/commonMain/kotlin/app/aaps/core/nssdk/localmodel/treatment/EventType.kt @@ -0,0 +1,47 @@ +package app.aaps.core.nssdk.localmodel.treatment + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Suppress("unused") +@Serializable +enum class EventType(val text: String) { + + @SerialName("Site Change") CANNULA_CHANGE("Site Change"), + @SerialName("Insulin Change") INSULIN_CHANGE("Insulin Change"), + @SerialName("Pump Battery Change") PUMP_BATTERY_CHANGE("Pump Battery Change"), + @SerialName("Sensor Change") SENSOR_CHANGE("Sensor Change"), + @SerialName("Sensor Start") SENSOR_STARTED("Sensor Start"), + @SerialName("Sensor Stop") SENSOR_STOPPED("Sensor Stop"), + @SerialName("BG Check") FINGER_STICK_BG_VALUE("BG Check"), + @SerialName("Exercise") EXERCISE("Exercise"), + @SerialName("Announcement") ANNOUNCEMENT("Announcement"), + @SerialName("SettingsExport") SETTINGS_EXPORT("Settings Export"), + @SerialName("Question") QUESTION("Question"), + @SerialName("Note") NOTE("Note"), + @SerialName("OpenAPS Offline") APS_OFFLINE("OpenAPS Offline"), + @SerialName("D.A.D. Alert") DAD_ALERT("D.A.D. Alert"), + @SerialName("Mbg") NS_MBG("Mbg"), + + // Used but not as a Therapy Event (use constants only) + @SerialName("Carb Correction") CARBS_CORRECTION("Carb Correction"), + @SerialName("Bolus Wizard") BOLUS_WIZARD("Bolus Wizard"), + @SerialName("Correction Bolus") CORRECTION_BOLUS("Correction Bolus"), + @SerialName("Meal Bolus") MEAL_BOLUS("Meal Bolus"), + @SerialName("Combo Bolus") COMBO_BOLUS("Combo Bolus"), + @SerialName("Temporary Target") TEMPORARY_TARGET("Temporary Target"), + @SerialName("Temporary Target Cancel") TEMPORARY_TARGET_CANCEL("Temporary Target Cancel"), + @SerialName("Profile Switch") PROFILE_SWITCH("Profile Switch"), + @SerialName("Snack Bolus") SNACK_BOLUS("Snack Bolus"), + @SerialName("Temp Basal") TEMPORARY_BASAL("Temp Basal"), + @SerialName("Temp Basal Start") TEMPORARY_BASAL_START("Temp Basal Start"), + @SerialName("Temp Basal End") TEMPORARY_BASAL_END("Temp Basal End"), + + @SerialName("") ERROR(""), + @SerialName("") NONE(""); + + companion object { + + fun fromString(text: String?) = entries.firstOrNull { it.text == text } ?: NONE + } +} \ No newline at end of file diff --git a/core/nssdk/src/main/kotlin/app/aaps/core/nssdk/localmodel/treatment/NSBolus.kt b/core/nssdk/src/commonMain/kotlin/app/aaps/core/nssdk/localmodel/treatment/NSBolus.kt similarity index 100% rename from core/nssdk/src/main/kotlin/app/aaps/core/nssdk/localmodel/treatment/NSBolus.kt rename to core/nssdk/src/commonMain/kotlin/app/aaps/core/nssdk/localmodel/treatment/NSBolus.kt diff --git a/core/nssdk/src/main/kotlin/app/aaps/core/nssdk/localmodel/treatment/NSBolusWizard.kt b/core/nssdk/src/commonMain/kotlin/app/aaps/core/nssdk/localmodel/treatment/NSBolusWizard.kt similarity index 100% rename from core/nssdk/src/main/kotlin/app/aaps/core/nssdk/localmodel/treatment/NSBolusWizard.kt rename to core/nssdk/src/commonMain/kotlin/app/aaps/core/nssdk/localmodel/treatment/NSBolusWizard.kt diff --git a/core/nssdk/src/main/kotlin/app/aaps/core/nssdk/localmodel/treatment/NSCarbs.kt b/core/nssdk/src/commonMain/kotlin/app/aaps/core/nssdk/localmodel/treatment/NSCarbs.kt similarity index 100% rename from core/nssdk/src/main/kotlin/app/aaps/core/nssdk/localmodel/treatment/NSCarbs.kt rename to core/nssdk/src/commonMain/kotlin/app/aaps/core/nssdk/localmodel/treatment/NSCarbs.kt diff --git a/core/nssdk/src/main/kotlin/app/aaps/core/nssdk/localmodel/treatment/NSEffectiveProfileSwitch.kt b/core/nssdk/src/commonMain/kotlin/app/aaps/core/nssdk/localmodel/treatment/NSEffectiveProfileSwitch.kt similarity index 100% rename from core/nssdk/src/main/kotlin/app/aaps/core/nssdk/localmodel/treatment/NSEffectiveProfileSwitch.kt rename to core/nssdk/src/commonMain/kotlin/app/aaps/core/nssdk/localmodel/treatment/NSEffectiveProfileSwitch.kt diff --git a/core/nssdk/src/main/kotlin/app/aaps/core/nssdk/localmodel/treatment/NSExtendedBolus.kt b/core/nssdk/src/commonMain/kotlin/app/aaps/core/nssdk/localmodel/treatment/NSExtendedBolus.kt similarity index 100% rename from core/nssdk/src/main/kotlin/app/aaps/core/nssdk/localmodel/treatment/NSExtendedBolus.kt rename to core/nssdk/src/commonMain/kotlin/app/aaps/core/nssdk/localmodel/treatment/NSExtendedBolus.kt diff --git a/core/nssdk/src/main/kotlin/app/aaps/core/nssdk/localmodel/treatment/NSICfg.kt b/core/nssdk/src/commonMain/kotlin/app/aaps/core/nssdk/localmodel/treatment/NSICfg.kt similarity index 100% rename from core/nssdk/src/main/kotlin/app/aaps/core/nssdk/localmodel/treatment/NSICfg.kt rename to core/nssdk/src/commonMain/kotlin/app/aaps/core/nssdk/localmodel/treatment/NSICfg.kt diff --git a/core/nssdk/src/main/kotlin/app/aaps/core/nssdk/localmodel/treatment/NSOfflineEvent.kt b/core/nssdk/src/commonMain/kotlin/app/aaps/core/nssdk/localmodel/treatment/NSOfflineEvent.kt similarity index 100% rename from core/nssdk/src/main/kotlin/app/aaps/core/nssdk/localmodel/treatment/NSOfflineEvent.kt rename to core/nssdk/src/commonMain/kotlin/app/aaps/core/nssdk/localmodel/treatment/NSOfflineEvent.kt diff --git a/core/nssdk/src/main/kotlin/app/aaps/core/nssdk/localmodel/treatment/NSProfileSwitch.kt b/core/nssdk/src/commonMain/kotlin/app/aaps/core/nssdk/localmodel/treatment/NSProfileSwitch.kt similarity index 100% rename from core/nssdk/src/main/kotlin/app/aaps/core/nssdk/localmodel/treatment/NSProfileSwitch.kt rename to core/nssdk/src/commonMain/kotlin/app/aaps/core/nssdk/localmodel/treatment/NSProfileSwitch.kt diff --git a/core/nssdk/src/main/kotlin/app/aaps/core/nssdk/localmodel/treatment/NSTemporaryBasal.kt b/core/nssdk/src/commonMain/kotlin/app/aaps/core/nssdk/localmodel/treatment/NSTemporaryBasal.kt similarity index 100% rename from core/nssdk/src/main/kotlin/app/aaps/core/nssdk/localmodel/treatment/NSTemporaryBasal.kt rename to core/nssdk/src/commonMain/kotlin/app/aaps/core/nssdk/localmodel/treatment/NSTemporaryBasal.kt diff --git a/core/nssdk/src/main/kotlin/app/aaps/core/nssdk/localmodel/treatment/NSTemporaryTarget.kt b/core/nssdk/src/commonMain/kotlin/app/aaps/core/nssdk/localmodel/treatment/NSTemporaryTarget.kt similarity index 100% rename from core/nssdk/src/main/kotlin/app/aaps/core/nssdk/localmodel/treatment/NSTemporaryTarget.kt rename to core/nssdk/src/commonMain/kotlin/app/aaps/core/nssdk/localmodel/treatment/NSTemporaryTarget.kt diff --git a/core/nssdk/src/main/kotlin/app/aaps/core/nssdk/localmodel/treatment/NSTherapyEvent.kt b/core/nssdk/src/commonMain/kotlin/app/aaps/core/nssdk/localmodel/treatment/NSTherapyEvent.kt similarity index 84% rename from core/nssdk/src/main/kotlin/app/aaps/core/nssdk/localmodel/treatment/NSTherapyEvent.kt rename to core/nssdk/src/commonMain/kotlin/app/aaps/core/nssdk/localmodel/treatment/NSTherapyEvent.kt index fbd9e680aba8..62861e5ad29d 100644 --- a/core/nssdk/src/main/kotlin/app/aaps/core/nssdk/localmodel/treatment/NSTherapyEvent.kt +++ b/core/nssdk/src/commonMain/kotlin/app/aaps/core/nssdk/localmodel/treatment/NSTherapyEvent.kt @@ -33,10 +33,12 @@ data class NSTherapyEvent( var glucoseType: MeterType? = null, ) : NSTreatment { + @Serializable enum class MeterType(val text: String) { - @com.google.gson.annotations.SerializedName("Finger") FINGER("Finger"), - @com.google.gson.annotations.SerializedName("Sensor") SENSOR("Sensor"), - @com.google.gson.annotations.SerializedName("Manual") MANUAL("Manual") + + @SerialName("Finger") FINGER("Finger"), + @SerialName("Sensor") SENSOR("Sensor"), + @SerialName("Manual") MANUAL("Manual") ; companion object { diff --git a/core/nssdk/src/main/kotlin/app/aaps/core/nssdk/localmodel/treatment/NSTreatment.kt b/core/nssdk/src/commonMain/kotlin/app/aaps/core/nssdk/localmodel/treatment/NSTreatment.kt similarity index 99% rename from core/nssdk/src/main/kotlin/app/aaps/core/nssdk/localmodel/treatment/NSTreatment.kt rename to core/nssdk/src/commonMain/kotlin/app/aaps/core/nssdk/localmodel/treatment/NSTreatment.kt index 38c4802e9e9c..55a1ea26fe18 100644 --- a/core/nssdk/src/main/kotlin/app/aaps/core/nssdk/localmodel/treatment/NSTreatment.kt +++ b/core/nssdk/src/commonMain/kotlin/app/aaps/core/nssdk/localmodel/treatment/NSTreatment.kt @@ -9,6 +9,7 @@ import kotlinx.serialization.json.JsonClassDiscriminator @Serializable @JsonClassDiscriminator("__type") sealed interface NSTreatment { + var date: Long? val device: String? val identifier: String? diff --git a/core/nssdk/src/main/kotlin/app/aaps/core/nssdk/mapper/ApiPermissionMapper.kt b/core/nssdk/src/commonMain/kotlin/app/aaps/core/nssdk/mapper/ApiPermissionMapper.kt similarity index 100% rename from core/nssdk/src/main/kotlin/app/aaps/core/nssdk/mapper/ApiPermissionMapper.kt rename to core/nssdk/src/commonMain/kotlin/app/aaps/core/nssdk/mapper/ApiPermissionMapper.kt diff --git a/core/nssdk/src/main/kotlin/app/aaps/core/nssdk/mapper/DeviceStatusMapper.kt b/core/nssdk/src/commonMain/kotlin/app/aaps/core/nssdk/mapper/DeviceStatusMapper.kt similarity index 75% rename from core/nssdk/src/main/kotlin/app/aaps/core/nssdk/mapper/DeviceStatusMapper.kt rename to core/nssdk/src/commonMain/kotlin/app/aaps/core/nssdk/mapper/DeviceStatusMapper.kt index 77390eeffe13..89e2f03a6a40 100644 --- a/core/nssdk/src/main/kotlin/app/aaps/core/nssdk/mapper/DeviceStatusMapper.kt +++ b/core/nssdk/src/commonMain/kotlin/app/aaps/core/nssdk/mapper/DeviceStatusMapper.kt @@ -1,16 +1,14 @@ package app.aaps.core.nssdk.mapper import app.aaps.core.nssdk.localmodel.devicestatus.NSDeviceStatus +import app.aaps.core.nssdk.nsSdkJson import app.aaps.core.nssdk.remotemodel.RemoteDeviceStatus -import com.google.gson.Gson -import com.google.gson.JsonParser -import kotlinx.serialization.json.Json fun NSDeviceStatus.convertToRemoteAndBack(): NSDeviceStatus = toRemoteDeviceStatus().toNSDeviceStatus() fun String.toNSDeviceStatus(): NSDeviceStatus = - Gson().fromJson(this, RemoteDeviceStatus::class.java).toNSDeviceStatus() + nsSdkJson.decodeFromString(RemoteDeviceStatus.serializer(), this).toNSDeviceStatus() internal fun RemoteDeviceStatus.toNSDeviceStatus(): NSDeviceStatus = NSDeviceStatus( @@ -44,6 +42,11 @@ internal fun NSDeviceStatus.toRemoteDeviceStatus(): RemoteDeviceStatus = openaps = openaps?.toRemoteDeviceStatusOpenAps() ) +// The schema-less subtrees (pump.extended, openaps.suggested / enacted / iob) used to be Gson trees +// on the remote side and kotlinx trees on the local side, so every one of them was rebuilt by +// printing it to text and parsing it back. Both sides are kotlinx now, so they are carried straight +// across - no copy, no reparse, and no chance of the round trip changing anything. + internal fun RemoteDeviceStatus.Pump.toNSDeviceStatusPump(): NSDeviceStatus.Pump = NSDeviceStatus.Pump( clock = clock, @@ -51,7 +54,7 @@ internal fun RemoteDeviceStatus.Pump.toNSDeviceStatusPump(): NSDeviceStatus.Pump reservoirDisplayOverride = reservoirDisplayOverride, battery = NSDeviceStatus.Pump.Battery(battery?.percent, battery?.voltage), status = NSDeviceStatus.Pump.Status(status?.status, status?.timestamp), - extended = extended?.let { Json.decodeFromString(it.toString()) } + extended = extended ) internal fun NSDeviceStatus.Pump.toRemoteDeviceStatusPump(): RemoteDeviceStatus.Pump = @@ -61,19 +64,19 @@ internal fun NSDeviceStatus.Pump.toRemoteDeviceStatusPump(): RemoteDeviceStatus. reservoirDisplayOverride = reservoirDisplayOverride, battery = RemoteDeviceStatus.Pump.Battery(battery?.percent, battery?.voltage), status = RemoteDeviceStatus.Pump.Status(status?.status, status?.timestamp), - extended = extended?.let { JsonParser.parseString(it.toString()).asJsonObject } + extended = extended ) internal fun RemoteDeviceStatus.OpenAps.toNSDeviceStatusOpenAps(): NSDeviceStatus.OpenAps = NSDeviceStatus.OpenAps( - suggested = suggested?.let { Json.decodeFromString(it.toString()) }, - enacted = enacted?.let { Json.decodeFromString(it.toString()) }, - iob = iob?.let { Json.decodeFromString(it.toString()) } + suggested = suggested, + enacted = enacted, + iob = iob ) internal fun NSDeviceStatus.OpenAps.toRemoteDeviceStatusOpenAps(): RemoteDeviceStatus.OpenAps = RemoteDeviceStatus.OpenAps( - suggested = suggested?.let { JsonParser.parseString(it.toString()).asJsonObject }, - enacted = enacted?.let { JsonParser.parseString(it.toString()).asJsonObject }, - iob = iob?.let { JsonParser.parseString(it.toString()).asJsonObject } + suggested = suggested, + enacted = enacted, + iob = iob ) diff --git a/core/nssdk/src/main/kotlin/app/aaps/core/nssdk/mapper/FoodMapper.kt b/core/nssdk/src/commonMain/kotlin/app/aaps/core/nssdk/mapper/FoodMapper.kt similarity index 94% rename from core/nssdk/src/main/kotlin/app/aaps/core/nssdk/mapper/FoodMapper.kt rename to core/nssdk/src/commonMain/kotlin/app/aaps/core/nssdk/mapper/FoodMapper.kt index 052779742eb5..bc5df014716b 100644 --- a/core/nssdk/src/main/kotlin/app/aaps/core/nssdk/mapper/FoodMapper.kt +++ b/core/nssdk/src/commonMain/kotlin/app/aaps/core/nssdk/mapper/FoodMapper.kt @@ -1,8 +1,8 @@ package app.aaps.core.nssdk.mapper import app.aaps.core.nssdk.localmodel.food.NSFood +import app.aaps.core.nssdk.nsSdkJson import app.aaps.core.nssdk.remotemodel.RemoteFood -import com.google.gson.Gson /** * Convert to [RemoteFood] and back to [NSFood] @@ -14,7 +14,7 @@ fun NSFood.convertToRemoteAndBack(): NSFood? = toRemoteFood().toNSFood() fun String.toNSFood(): NSFood? = - Gson().fromJson(this, RemoteFood::class.java).toNSFood() + nsSdkJson.decodeFromString(RemoteFood.serializer(), this).toNSFood() internal fun RemoteFood.toNSFood(): NSFood? { when (type) { diff --git a/core/nssdk/src/main/kotlin/app/aaps/core/nssdk/mapper/ICfgMapper.kt b/core/nssdk/src/commonMain/kotlin/app/aaps/core/nssdk/mapper/ICfgMapper.kt similarity index 100% rename from core/nssdk/src/main/kotlin/app/aaps/core/nssdk/mapper/ICfgMapper.kt rename to core/nssdk/src/commonMain/kotlin/app/aaps/core/nssdk/mapper/ICfgMapper.kt diff --git a/core/nssdk/src/main/kotlin/app/aaps/core/nssdk/mapper/MbgMapper.kt b/core/nssdk/src/commonMain/kotlin/app/aaps/core/nssdk/mapper/MbgMapper.kt similarity index 94% rename from core/nssdk/src/main/kotlin/app/aaps/core/nssdk/mapper/MbgMapper.kt rename to core/nssdk/src/commonMain/kotlin/app/aaps/core/nssdk/mapper/MbgMapper.kt index 67746c7f8ca5..95d2f4a7cedd 100644 --- a/core/nssdk/src/main/kotlin/app/aaps/core/nssdk/mapper/MbgMapper.kt +++ b/core/nssdk/src/commonMain/kotlin/app/aaps/core/nssdk/mapper/MbgMapper.kt @@ -2,11 +2,11 @@ package app.aaps.core.nssdk.mapper import app.aaps.core.nssdk.localmodel.entry.NSMbgV3 import app.aaps.core.nssdk.localmodel.entry.NsUnits +import app.aaps.core.nssdk.nsSdkJson import app.aaps.core.nssdk.remotemodel.RemoteEntry -import com.google.gson.Gson fun String.toCalibrationMbg(): NSMbgV3? = - Gson().fromJson(this, RemoteEntry::class.java).toCalibrationMbg() + nsSdkJson.decodeFromString(RemoteEntry.serializer(), this).toCalibrationMbg() /** * Maps a NS `entries` document to an AAPS calibration mbg. diff --git a/core/nssdk/src/main/kotlin/app/aaps/core/nssdk/mapper/StatusResponseMapper.kt b/core/nssdk/src/commonMain/kotlin/app/aaps/core/nssdk/mapper/StatusResponseMapper.kt similarity index 100% rename from core/nssdk/src/main/kotlin/app/aaps/core/nssdk/mapper/StatusResponseMapper.kt rename to core/nssdk/src/commonMain/kotlin/app/aaps/core/nssdk/mapper/StatusResponseMapper.kt diff --git a/core/nssdk/src/main/kotlin/app/aaps/core/nssdk/mapper/StorageMapper.kt b/core/nssdk/src/commonMain/kotlin/app/aaps/core/nssdk/mapper/StorageMapper.kt similarity index 100% rename from core/nssdk/src/main/kotlin/app/aaps/core/nssdk/mapper/StorageMapper.kt rename to core/nssdk/src/commonMain/kotlin/app/aaps/core/nssdk/mapper/StorageMapper.kt diff --git a/core/nssdk/src/main/kotlin/app/aaps/core/nssdk/mapper/SvgMapper.kt b/core/nssdk/src/commonMain/kotlin/app/aaps/core/nssdk/mapper/SvgMapper.kt similarity index 94% rename from core/nssdk/src/main/kotlin/app/aaps/core/nssdk/mapper/SvgMapper.kt rename to core/nssdk/src/commonMain/kotlin/app/aaps/core/nssdk/mapper/SvgMapper.kt index f1facbedd7c1..94557517a306 100644 --- a/core/nssdk/src/main/kotlin/app/aaps/core/nssdk/mapper/SvgMapper.kt +++ b/core/nssdk/src/commonMain/kotlin/app/aaps/core/nssdk/mapper/SvgMapper.kt @@ -3,14 +3,14 @@ package app.aaps.core.nssdk.mapper import app.aaps.core.nssdk.localmodel.entry.Direction import app.aaps.core.nssdk.localmodel.entry.NSSgvV3 import app.aaps.core.nssdk.localmodel.entry.NsUnits +import app.aaps.core.nssdk.nsSdkJson import app.aaps.core.nssdk.remotemodel.RemoteEntry -import com.google.gson.Gson fun NSSgvV3.convertToRemoteAndBack(): NSSgvV3? = toRemoteEntry().toSgv() fun String.toNSSgvV3(): NSSgvV3? = - Gson().fromJson(this, RemoteEntry::class.java).toSgv() + nsSdkJson.decodeFromString(RemoteEntry.serializer(), this).toSgv() internal fun RemoteEntry.toSgv(): NSSgvV3? { diff --git a/core/nssdk/src/main/kotlin/app/aaps/core/nssdk/mapper/TreatmentMapper.kt b/core/nssdk/src/commonMain/kotlin/app/aaps/core/nssdk/mapper/TreatmentMapper.kt similarity index 99% rename from core/nssdk/src/main/kotlin/app/aaps/core/nssdk/mapper/TreatmentMapper.kt rename to core/nssdk/src/commonMain/kotlin/app/aaps/core/nssdk/mapper/TreatmentMapper.kt index 22587961b9bc..a0ac93fd85ab 100644 --- a/core/nssdk/src/main/kotlin/app/aaps/core/nssdk/mapper/TreatmentMapper.kt +++ b/core/nssdk/src/commonMain/kotlin/app/aaps/core/nssdk/mapper/TreatmentMapper.kt @@ -13,8 +13,8 @@ import app.aaps.core.nssdk.localmodel.treatment.NSTemporaryBasal import app.aaps.core.nssdk.localmodel.treatment.NSTemporaryTarget import app.aaps.core.nssdk.localmodel.treatment.NSTherapyEvent import app.aaps.core.nssdk.localmodel.treatment.NSTreatment +import app.aaps.core.nssdk.nsSdkJson import app.aaps.core.nssdk.remotemodel.RemoteTreatment -import com.google.gson.Gson import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.minutes @@ -28,7 +28,7 @@ fun NSTreatment.convertToRemoteAndBack(): NSTreatment? = toRemoteTreatment()?.toTreatment() fun String.toNSTreatment(): NSTreatment? = - Gson().fromJson(this, RemoteTreatment::class.java).toTreatment() + nsSdkJson.decodeFromString(RemoteTreatment.serializer(), this).toTreatment() internal fun RemoteTreatment.toTreatment(): NSTreatment? { val treatmentTimestamp = timestamp() diff --git a/core/nssdk/src/commonMain/kotlin/app/aaps/core/nssdk/networking/NightscoutApi.kt b/core/nssdk/src/commonMain/kotlin/app/aaps/core/nssdk/networking/NightscoutApi.kt new file mode 100644 index 000000000000..ebf77d03c316 --- /dev/null +++ b/core/nssdk/src/commonMain/kotlin/app/aaps/core/nssdk/networking/NightscoutApi.kt @@ -0,0 +1,285 @@ +package app.aaps.core.nssdk.networking + +import app.aaps.core.nssdk.remotemodel.LastModified +import app.aaps.core.nssdk.remotemodel.NSResponse +import app.aaps.core.nssdk.remotemodel.RemoteCreateUpdateResponse +import app.aaps.core.nssdk.remotemodel.RemoteDeviceStatus +import app.aaps.core.nssdk.remotemodel.RemoteEntry +import app.aaps.core.nssdk.remotemodel.RemoteFood +import app.aaps.core.nssdk.remotemodel.RemoteStatusResponse +import app.aaps.core.nssdk.remotemodel.RemoteTreatment +import io.ktor.client.HttpClient +import io.ktor.client.request.HttpRequestBuilder +import io.ktor.client.request.request +import io.ktor.client.request.setBody +import io.ktor.client.statement.bodyAsText +import io.ktor.http.ContentType +import io.ktor.http.HttpMethod +import io.ktor.http.URLBuilder +import io.ktor.http.appendPathSegments +import io.ktor.http.contentType +import io.ktor.http.takeFrom +import kotlinx.serialization.DeserializationStrategy +import kotlinx.serialization.builtins.ListSerializer +import kotlinx.serialization.json.JsonObject + +/** + * The Nightscout API v3 endpoints, on Ktor. + * + * Replaces the Retrofit interface. The method names, parameters and order are kept identical so the + * change in `NSAndroidClientImpl` is a type swap rather than a rewrite - the point is that the + * contract tests stay meaningful, not that the code gets prettier. + * + * ### Two URL rules that matter + * + * **Query keys keep their `$`.** `date$gt`, `created_at$gt` and `sort$desc` are Nightscout operators, + * and Retrofit was told `encoded = true` so they went out untouched. Ktor percent-encodes by default, + * and `date%24gt` is simply a different, unknown filter - Nightscout ignores it and answers **200 + * with unfiltered rows**. That is why every query below goes through [URLBuilder.encodedParameters] + * rather than `parameters`. + * + * **Path segments are encoded.** Identifiers come from the server and may contain characters that + * would otherwise create a new path segment, so [appendPathSegments] (which encodes) is used, exactly + * as Retrofit's `@Path` did by default. + * + * `NsSdkUrlContractTest` pins the resulting URL for every endpoint, as a literal string. + */ +internal class NightscoutApi( + private val client: HttpClient, + /** Fully formed, e.g. `https://host/api/` - see `NsUrl.toBaseUrl`. */ + private val baseUrl: String +) { + + // ---------------------------------------------------------------- plumbing + + private fun HttpRequestBuilder.nsUrl(vararg segments: String, query: URLBuilder.() -> Unit = {}) { + url { + takeFrom(baseUrl) + // encodeSlash = true so an identifier containing "/" stays ONE segment and addresses one + // document, instead of silently becoming a different path. Retrofit's @Path did this by + // default (it produced "a%2Fb"); Ktor's default does not, so it has to be asked for. + appendPathSegments(segments.toList(), encodeSlash = true) + query() + } + } + + /** + * Issues the request and reads the body **once**, whatever the status. + * + * See [NsHttpResponse] - the client inspects error bodies as raw text, so the body cannot be + * read lazily or only on success. + */ + private suspend fun call( + method: HttpMethod, + deserializer: DeserializationStrategy, + block: HttpRequestBuilder.() -> Unit + ): NsHttpResponse { + val response = client.request { + this.method = method + block() + } + return NsHttpResponse( + code = response.status.value, + eTagHeader = response.headers["ETag"], + bodyText = response.bodyAsText(), + deserializer = deserializer + ) + } + + private inline fun HttpRequestBuilder.jsonBody(value: T) { + contentType(ContentType.Application.Json) + setBody(value) + } + + // ---------------------------------------------------------------- status + + suspend fun statusSimple() = + call(HttpMethod.Get, NSResponse.serializer(RemoteStatusResponse.serializer())) { nsUrl("v3", "status") } + + suspend fun lastModified() = + call(HttpMethod.Get, NSResponse.serializer(LastModified.serializer())) { nsUrl("v3", "lastModified") } + + // ---------------------------------------------------------------- entries + + private val entryListSerializer = NSResponse.serializer(ListSerializer(RemoteEntry.serializer())) + + suspend fun getSgvs() = call(HttpMethod.Get, entryListSerializer) { + nsUrl("v3", "entries") { + encodedParameters.append("sort\$desc", "date") + encodedParameters.append("type", "sgv") + } + } + + suspend fun getSgvsNewerThan(date: Long, limit: Int) = call(HttpMethod.Get, entryListSerializer) { + nsUrl("v3", "entries") { + encodedParameters.append("sort", "date") + encodedParameters.append("date\$gt", date.toString()) + encodedParameters.append("limit", limit.toString()) + } + } + + suspend fun getSgvsModifiedSince(from: Long, limit: Int) = call(HttpMethod.Get, entryListSerializer) { + nsUrl("v3", "entries", "history", from.toString()) { + encodedParameters.append("limit", limit.toString()) + } + } + + suspend fun createEntry(remoteEntry: RemoteEntry) = + call(HttpMethod.Post, RemoteCreateUpdateResponse.serializer()) { + nsUrl("v3", "entries"); jsonBody(remoteEntry) + } + + suspend fun updateEntry(remoteEntry: RemoteEntry, identifier: String) = + call(HttpMethod.Patch, NSResponse.serializer(RemoteCreateUpdateResponse.serializer())) { + nsUrl("v3", "entries", identifier); jsonBody(remoteEntry) + } + + suspend fun deleteEntry(identifier: String) = + call(HttpMethod.Delete, NSResponse.serializer(RemoteCreateUpdateResponse.serializer())) { + nsUrl("v3", "entries", identifier) + } + + // ---------------------------------------------------------------- treatments + + private val treatmentListSerializer = NSResponse.serializer(ListSerializer(RemoteTreatment.serializer())) + + suspend fun getTreatmentsNewerThan(createdAt: String, limit: Int) = call(HttpMethod.Get, treatmentListSerializer) { + nsUrl("v3", "treatments") { + encodedParameters.append("sort", "created_at") + encodedParameters.append("created_at\$gt", createdAt) + encodedParameters.append("limit", limit.toString()) + } + } + + suspend fun getTreatmentsModifiedSince(from: Long, limit: Int) = call(HttpMethod.Get, treatmentListSerializer) { + nsUrl("v3", "treatments", "history", from.toString()) { + encodedParameters.append("limit", limit.toString()) + } + } + + suspend fun createTreatment(remoteTreatment: RemoteTreatment) = + call(HttpMethod.Post, RemoteCreateUpdateResponse.serializer()) { + nsUrl("v3", "treatments"); jsonBody(remoteTreatment) + } + + suspend fun updateTreatment(remoteTreatment: RemoteTreatment, identifier: String) = + call(HttpMethod.Patch, RemoteCreateUpdateResponse.serializer()) { + nsUrl("v3", "treatments", identifier); jsonBody(remoteTreatment) + } + + suspend fun deleteTreatment(identifier: String) = + call(HttpMethod.Delete, RemoteCreateUpdateResponse.serializer()) { + nsUrl("v3", "treatments", identifier) + } + + // ---------------------------------------------------------------- device status + + suspend fun createDeviceStatus(remoteDeviceStatus: RemoteDeviceStatus) = + call(HttpMethod.Post, RemoteCreateUpdateResponse.serializer()) { + nsUrl("v3", "devicestatus"); jsonBody(remoteDeviceStatus) + } + + suspend fun getDeviceStatusModifiedSince(from: Long) = + call(HttpMethod.Get, NSResponse.serializer(ListSerializer(RemoteDeviceStatus.serializer()))) { + nsUrl("v3", "devicestatus", "history", from.toString()) + } + + // ---------------------------------------------------------------- food + + suspend fun getFoods(limit: Int) = + call(HttpMethod.Get, NSResponse.serializer(ListSerializer(RemoteFood.serializer()))) { + nsUrl("v3", "food") { encodedParameters.append("limit", limit.toString()) } + } + + suspend fun createFood(remoteFood: RemoteFood) = + call(HttpMethod.Post, RemoteCreateUpdateResponse.serializer()) { + nsUrl("v3", "food"); jsonBody(remoteFood) + } + + /** + * Food update and delete **deliberately do not send a request. Do not "fix" these.** + * + * The Retrofit versions declared an `{identifier}` parameter that was missing from the path, so + * Retrofit refused to build them and no request was ever made - the throw is swallowed by the + * broad catch in `NSClientV3Plugin`, and food edits have never synced. The matching endpoint is + * broken on the Nightscout side, so sending would not help. + * + * Writing the "obvious" URL here would turn "never sends" into `PATCH /api/v3/food` and + * `DELETE /api/v3/food` **with no identifier** - a request against the whole collection on a + * live Nightscout. So the local failure is reproduced on purpose, keeping today's behaviour. + * When Nightscout supports these, change both sides together. + */ + @Suppress("UNUSED_PARAMETER") + suspend fun updateFood(remoteFood: RemoteFood, identifier: String): NsHttpResponse = + error("v3/food update is not supported by Nightscout - no request is sent, see NightscoutApi.updateFood") + + @Suppress("UNUSED_PARAMETER") + suspend fun deleteFood(identifier: String): NsHttpResponse = + error("v3/food delete is not supported by Nightscout - no request is sent, see NightscoutApi.updateFood") + + // ---------------------------------------------------------------- profile + + private val jsonObjectListSerializer = NSResponse.serializer(ListSerializer(JsonObject.serializer())) + + suspend fun getProfileModifiedSince(from: Long, limit: Int = 10) = call(HttpMethod.Get, jsonObjectListSerializer) { + nsUrl("v3", "profile", "history", from.toString()) { + encodedParameters.append("limit", limit.toString()) + } + } + + suspend fun getLastProfile() = call(HttpMethod.Get, jsonObjectListSerializer) { + nsUrl("v3", "profile") { + encodedParameters.append("sort\$desc", "date") + encodedParameters.append("limit", "1") + } + } + + suspend fun createProfile(profile: JsonObject) = + call(HttpMethod.Post, RemoteCreateUpdateResponse.serializer()) { + nsUrl("v3", "profile"); jsonBody(profile) + } + + // ---------------------------------------------------------------- settings + + suspend fun getSetting(identifier: String) = + call(HttpMethod.Get, NSResponse.serializer(JsonObject.serializer())) { + nsUrl("v3", "settings", identifier) + } + + suspend fun getSettingsModifiedSince(from: Long, limit: Int = 100) = call(HttpMethod.Get, jsonObjectListSerializer) { + nsUrl("v3", "settings", "history", from.toString()) { + encodedParameters.append("limit", limit.toString()) + } + } + + suspend fun searchSettings(limit: Int = 100) = call(HttpMethod.Get, jsonObjectListSerializer) { + nsUrl("v3", "settings") { encodedParameters.append("limit", limit.toString()) } + } + + suspend fun createSetting(settings: JsonObject) = + call(HttpMethod.Post, RemoteCreateUpdateResponse.serializer()) { + nsUrl("v3", "settings"); jsonBody(settings) + } + + suspend fun patchSetting(settings: JsonObject, identifier: String) = + call(HttpMethod.Patch, RemoteCreateUpdateResponse.serializer()) { + nsUrl("v3", "settings", identifier); jsonBody(settings) + } + + /** PUT (NS3 "UPDATE") = upsert. Replaces an existing doc, or inserts if absent. */ + suspend fun updateSetting(settings: JsonObject, identifier: String) = + call(HttpMethod.Put, RemoteCreateUpdateResponse.serializer()) { + nsUrl("v3", "settings", identifier); jsonBody(settings) + } + + /** + * [permanent] `null` -> soft delete (tombstone); `true` -> NS `?permanent=true` hard delete. + * A null must send **no parameter at all** - `permanent=false` would be a different request. + */ + suspend fun deleteSetting(identifier: String, permanent: Boolean?) = + call(HttpMethod.Delete, RemoteCreateUpdateResponse.serializer()) { + nsUrl("v3", "settings", identifier) { + permanent?.let { encodedParameters.append("permanent", it.toString()) } + } + } +} diff --git a/core/nssdk/src/commonMain/kotlin/app/aaps/core/nssdk/networking/NsAuth.kt b/core/nssdk/src/commonMain/kotlin/app/aaps/core/nssdk/networking/NsAuth.kt new file mode 100644 index 000000000000..129c4d82cd76 --- /dev/null +++ b/core/nssdk/src/commonMain/kotlin/app/aaps/core/nssdk/networking/NsAuth.kt @@ -0,0 +1,109 @@ +package app.aaps.core.nssdk.networking + +import app.aaps.core.nssdk.exceptions.DateHeaderOutOfToleranceException +import app.aaps.core.nssdk.exceptions.InvalidAccessTokenException +import app.aaps.core.nssdk.networking.Status.MESSAGE_DATE_HEADER_OUT_OF_TOLERANCE +import app.aaps.core.nssdk.nsSdkJson +import app.aaps.core.nssdk.remotemodel.RemoteAuthResponse +import io.ktor.client.HttpClient +import io.ktor.client.call.save +import io.ktor.client.plugins.HttpSend +import io.ktor.client.plugins.plugin +import io.ktor.client.request.get +import io.ktor.client.statement.bodyAsText +import io.ktor.http.HttpHeaders +import io.ktor.http.URLBuilder +import io.ktor.http.appendPathSegments +import io.ktor.http.takeFrom +import kotlin.time.Clock + +/** + * The Nightscout token dance, as an `HttpSend` interceptor. + * + * This is written by hand rather than with Ktor's `Auth` / `bearer` provider, because that provider + * differs in ways that lose data without saying so: + * + * - it **omits** the header when it has no token. Nightscout does not reject an unauthenticated + * request - it answers **200 with the anonymous role**, which flows into `hasWritePermission` and + * makes `DataSyncWorker` skip the whole upload block. Nothing is logged. Here the header is always + * sent, even while the token is still empty. + * - it refreshes on 401 only; Nightscout also answers **403**. + * - its refresh hook never sees the response body, so the clock-skew case below could not exist. + * + * `NsSdkAuthContractTest` pins all of it. + */ +internal object NsAuth { + + fun install(client: HttpClient, refreshClient: HttpClient, baseUrl: String, refreshToken: String) { + // The current Bearer token. Deliberately unsynchronised, matching the Retrofit interceptor: + // concurrent 401s each refresh. Serialising them is an improvement, but one to make on + // purpose with its own test rather than as a side effect of changing transport. + var jwtToken = "" + + client.plugin(HttpSend).intercept { request -> + request.headers[HttpHeaders.Date] = nowMillis().toString() + request.headers[HttpHeaders.Authorization] = "Bearer $jwtToken" + + // save() so the body can be read here AND again by the caller - Ktor streams it once. + val firstCall = execute(request).save() + if (firstCall.response.status.value !in REFRESHABLE) return@intercept firstCall + + // Checked BEFORE refreshing: a clock that is too far off is not fixed by a new token, and + // the caller needs to see this specific error to tell the user to fix the time. + if (firstCall.response.bodyAsText().contains(MESSAGE_DATE_HEADER_OUT_OF_TOLERANCE)) + throw DateHeaderOutOfToleranceException("Data header out of tolerance") + + val refreshed = refresh(refreshClient, baseUrl, refreshToken) ?: return@intercept firstCall + jwtToken = refreshed + + // Rebuilt rather than replayed, so Date is recomputed along with the new token. Exactly + // one retry - never a loop. + request.headers[HttpHeaders.Date] = nowMillis().toString() + request.headers[HttpHeaders.Authorization] = "Bearer $jwtToken" + execute(request) + } + } + + /** + * Asks for a new access token. + * + * Returns the token on success, or null to mean "carry on with the original failed response" - + * which is what the Retrofit version did for any refresh answer that was neither 200 nor a + * 401/403. + */ + private suspend fun refresh(refreshClient: HttpClient, baseUrl: String, refreshToken: String): String? { + val response = refreshClient.get(refreshUrl(baseUrl, refreshToken)) + return when { + response.status.value in REFRESHABLE -> + throw InvalidAccessTokenException("Invalid access token") + + response.status.value != 200 -> null + else -> + runCatching { + nsSdkJson.decodeFromString(RemoteAuthResponse.serializer(), response.bodyAsText()).token + }.getOrNull() + } + } + + /** + * `https:///api/v2/authorization/request/`, built from the **host only**. + * + * The Retrofit annotation began with a slash (`/api/v2/...`), which meant it was resolved against + * the host root and any sub-path was dropped. That is reproduced here on purpose: whether a + * sub-path install should keep its sub-path when refreshing is a Nightscout question, and this + * change is about transport, not about answering it. + */ + private fun refreshUrl(baseUrl: String, refreshToken: String): String = + URLBuilder().apply { + takeFrom(baseUrl) + encodedPathSegments = emptyList() + appendPathSegments(listOf("api", "v2", "authorization", "request", refreshToken), encodeSlash = true) + }.buildString() + + // kotlin.time.Clock rather than System.currentTimeMillis(), which is JVM only. kotlinx-datetime + // is already a dependency of this module. + private fun nowMillis(): Long = Clock.System.now().toEpochMilliseconds() + + /** Nightscout answers 403 as well as 401 for an expired token. */ + private val REFRESHABLE = listOf(401, 403) +} diff --git a/core/nssdk/src/commonMain/kotlin/app/aaps/core/nssdk/networking/NsHttpResponse.kt b/core/nssdk/src/commonMain/kotlin/app/aaps/core/nssdk/networking/NsHttpResponse.kt new file mode 100644 index 000000000000..65baa159ba2d --- /dev/null +++ b/core/nssdk/src/commonMain/kotlin/app/aaps/core/nssdk/networking/NsHttpResponse.kt @@ -0,0 +1,59 @@ +package app.aaps.core.nssdk.networking + +import app.aaps.core.nssdk.nsSdkJson +import kotlinx.serialization.DeserializationStrategy + +/** + * One HTTP response, with the body already read into a string. + * + * Reading the body **once, up front** is the whole point of this type. Retrofit handed out a + * `Response` with a separate `body()` and `errorBody()`, and the client leans on that: an error + * body is inspected as raw text to decide whether to re-send a record with `utcOffset = 0` + * (`NSAndroidClientImpl.createSgv`). Ktor has no such split - the body is a stream that can be + * consumed only once - so a port that reads it lazily, or reads it only on success, silently loses + * that text and drops the record for good. + * + * Holding the text also means the status ladders in `NSAndroidClientImpl` can stay as they are. + * + * Bodies here are small: single documents, or pages already limited by a `limit` parameter. + */ +internal class NsHttpResponse( + val code: Int, + private val eTagHeader: String?, + /** Raw response text, always read, success or not. */ + val bodyText: String, + private val deserializer: DeserializationStrategy +) { + + val isSuccessful: Boolean get() = code in 200..299 + + /** + * The decoded body, or null if it cannot be decoded. + * + * Null rather than a throw, because that is what Retrofit's `body()` did for an error response, + * and several call sites read `response.body()?.identifier` on paths that also accept failure. + */ + fun body(): T? = + if (bodyText.isEmpty()) null + else runCatching { nsSdkJson.decodeFromString(deserializer, bodyText) }.getOrNull() + + /** + * The raw text of a failed response, matching Retrofit's `errorBody()?.string()`. + * + * Null for a successful response so the callers' `errorResponse ?: response.message()` fallbacks + * behave as before. + */ + fun errorBody(): String? = if (isSuccessful) null else bodyText.ifEmpty { null } + + /** Stand-in for Retrofit's `Response.message()`, used only as a fallback when there is no body. */ + fun message(): String = "HTTP $code" + + /** + * `lastServerModified`, parsed from the weak ETag Nightscout sends (`W/""`). + * + * The arithmetic is copied exactly from the Retrofit version, including the fact that it is + * positional rather than a pattern match, so a header in any other shape still throws. Real + * Nightscout always sends the weak form; `NsSdkResponseContractTest` pins both cases. + */ + fun eTagAsLong(): Long? = eTagHeader?.substring(3, eTagHeader.length - 1)?.toLong() +} diff --git a/core/nssdk/src/commonMain/kotlin/app/aaps/core/nssdk/networking/NsKtorClient.kt b/core/nssdk/src/commonMain/kotlin/app/aaps/core/nssdk/networking/NsKtorClient.kt new file mode 100644 index 000000000000..d9179ac067a5 --- /dev/null +++ b/core/nssdk/src/commonMain/kotlin/app/aaps/core/nssdk/networking/NsKtorClient.kt @@ -0,0 +1,88 @@ +package app.aaps.core.nssdk.networking + +import app.aaps.core.nssdk.nsSdkJson +import io.ktor.client.HttpClient +import io.ktor.client.HttpClientConfig +import io.ktor.client.plugins.HttpTimeout +import io.ktor.client.plugins.contentnegotiation.ContentNegotiation +import io.ktor.serialization.kotlinx.json.json + +/** + * Creates the client with a platform engine, and whatever logging that engine supports. + * + * The engine is the one part of the HTTP stack that cannot be shared: JVM and Android use OkHttp + * (which is what the app already ships, so nothing new is added), and Apple targets use Darwin. + * Request logging is engine specific too, which is why it lives here rather than in the common + * configuration below. + */ +internal expect fun nsHttpClient( + logging: Boolean, + logger: (String) -> Unit, + configure: HttpClientConfig<*>.() -> Unit +): HttpClient + +/** + * Builds the Ktor client used to talk to Nightscout. + * + * Two settings are load bearing and must not be "improved": + * + * - **`expectSuccess = false`.** Ktor's default throws on any non-2xx. The whole client is written + * the other way round: a 4xx is a value, not an exception. `getSettings` returns a 404 result, + * `updateSvg` and `deleteSettings` treat 404 as success, and every write returns + * `CreateUpdateResponse(response = )`. Turning this on would make all of that dead code - + * and because `ClientRequestException` is not in the retry exclusion list, each of those calls + * would also be retried four times before failing. See `NsSdkStatusContractTest`. + * - **No `HttpRequestRetry` plugin.** Retries are already done one level up by + * `NSAndroidClientImpl.callWrapper`. Adding Ktor's plugin would multiply with it, and the + * `utcOffset` fallback re-enters a public method, so a single reading could produce well over ten + * POSTs inside one sync. + * + * There is deliberately **no cache**. The OkHttp disk cache existed only so a revalidated GET could + * surface as a 304, which the paging workers used as their stop condition. That signal is replaced + * by "stop when the cursor cannot advance", which says what it means and needs no `Context`. + */ +internal object NsKtorClient { + + /** + * Everything the client needs, built together so the two [HttpClient]s can be closed as a pair. + * + * There are two on purpose: the refresh call must not carry an `Authorization` header, or the + * interceptor would recurse into itself. + */ + class Stack(val api: NightscoutApi, private val main: HttpClient, private val refresh: HttpClient) { + + fun close() { + main.close() + refresh.close() + } + } + + fun stack(baseUrl: String, refreshToken: String, logging: Boolean, logger: (String) -> Unit): Stack { + val main = build(logging, logger) + val refresh = build(logging, logger) + NsAuth.install(main, refresh, baseUrl, refreshToken) + return Stack(NightscoutApi(main, baseUrl), main, refresh) + } + + fun build(logging: Boolean, logger: (String) -> Unit): HttpClient = nsHttpClient(logging, logger) { + expectSuccess = false + + install(ContentNegotiation) { + json(nsSdkJson) + } + + install(HttpTimeout) { + socketTimeoutMillis = SOCKET_TIMEOUT + connectTimeoutMillis = CONNECT_TIMEOUT + } + } + + /** Matches the old OkHttp read timeout. */ + private const val SOCKET_TIMEOUT = 60L * 1000 + + /** + * New, and deliberately so: OkHttp applied its own 10 s connect default, which Ktor does not. + * Leaving it unset would let a dead host hang until the socket timeout instead. + */ + private const val CONNECT_TIMEOUT = 10L * 1000 +} diff --git a/core/nssdk/src/commonMain/kotlin/app/aaps/core/nssdk/networking/NsUrl.kt b/core/nssdk/src/commonMain/kotlin/app/aaps/core/nssdk/networking/NsUrl.kt new file mode 100644 index 000000000000..7e12e5345c4a --- /dev/null +++ b/core/nssdk/src/commonMain/kotlin/app/aaps/core/nssdk/networking/NsUrl.kt @@ -0,0 +1,26 @@ +package app.aaps.core.nssdk.networking + +/** + * Turns what the caller supplies into the base URL the client uses. + * + * This is all that is left of the old `NetworkStackBuilder`: the OkHttp client, its disk cache, the + * two Retrofit instances and the auth interceptor are gone, replaced by [NsKtorClient] and + * [NsAuth]. + */ +internal object NsUrl { + + /** + * Production callers pass a bare host with an optional sub-path - `NSClientV3Plugin.setClient` + * strips the scheme first - so `host.com` and `host.com/ns` behave exactly as before. + * + * An input that already carries a scheme is used as it stands. That is what lets a unit test + * point the client at `http://localhost:`, and it also stops a stored `http://host` from + * turning into `https://http://host/api/`, which is what the old string concatenation produced + * (the caller only strips `https://`). + */ + fun toBaseUrl(hostOrUrl: String): String { + val trimmed = hostOrUrl.trimEnd('/') + val withScheme = if (trimmed.startsWith("http://") || trimmed.startsWith("https://")) trimmed else "https://$trimmed" + return "$withScheme/api/" + } +} diff --git a/core/nssdk/src/main/kotlin/app/aaps/core/nssdk/networking/Status.kt b/core/nssdk/src/commonMain/kotlin/app/aaps/core/nssdk/networking/Status.kt similarity index 100% rename from core/nssdk/src/main/kotlin/app/aaps/core/nssdk/networking/Status.kt rename to core/nssdk/src/commonMain/kotlin/app/aaps/core/nssdk/networking/Status.kt diff --git a/core/nssdk/src/main/kotlin/app/aaps/core/nssdk/remotemodel/LastModified.kt b/core/nssdk/src/commonMain/kotlin/app/aaps/core/nssdk/remotemodel/LastModified.kt similarity index 51% rename from core/nssdk/src/main/kotlin/app/aaps/core/nssdk/remotemodel/LastModified.kt rename to core/nssdk/src/commonMain/kotlin/app/aaps/core/nssdk/remotemodel/LastModified.kt index c15f2722e28d..819268feb95f 100644 --- a/core/nssdk/src/main/kotlin/app/aaps/core/nssdk/remotemodel/LastModified.kt +++ b/core/nssdk/src/commonMain/kotlin/app/aaps/core/nssdk/remotemodel/LastModified.kt @@ -1,6 +1,6 @@ package app.aaps.core.nssdk.remotemodel -import com.google.gson.annotations.SerializedName +import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable /** @@ -9,18 +9,18 @@ import kotlinx.serialization.Serializable **/ @Serializable data class LastModified( - @SerializedName("collections") val collections: Collections + @SerialName("collections") val collections: Collections ) { @Serializable data class Collections( - @SerializedName("devicestatus") var devicestatus: Long = 0, // devicestatus collection - @SerializedName("entries") var entries: Long = 0, // entries collection - @SerializedName("profile") var profile: Long = 0, // profile collection - @SerializedName("treatments") var treatments: Long = 0, // treatments collection - @SerializedName("foods") var foods: Long = 0, // foods collection - @SerializedName("settings") var settings: Long = 0 // settings collection + @SerialName("devicestatus") var devicestatus: Long = 0, // devicestatus collection + @SerialName("entries") var entries: Long = 0, // entries collection + @SerialName("profile") var profile: Long = 0, // profile collection + @SerialName("treatments") var treatments: Long = 0, // treatments collection + @SerialName("foods") var foods: Long = 0, // foods collection + @SerialName("settings") var settings: Long = 0 // settings collection ) fun set(colName: String, value: Long) { diff --git a/core/nssdk/src/commonMain/kotlin/app/aaps/core/nssdk/remotemodel/RemoteAuthResponse.kt b/core/nssdk/src/commonMain/kotlin/app/aaps/core/nssdk/remotemodel/RemoteAuthResponse.kt new file mode 100644 index 000000000000..8189816cd5fa --- /dev/null +++ b/core/nssdk/src/commonMain/kotlin/app/aaps/core/nssdk/remotemodel/RemoteAuthResponse.kt @@ -0,0 +1,11 @@ +package app.aaps.core.nssdk.remotemodel + +import kotlinx.serialization.Serializable + +/** + * All three fields stay non-null and without defaults on purpose: this is the response to a + * successful token refresh, so a reply missing any of them is not something to carry on from. + * Gson would have left them null and failed later; kotlinx fails here, which is the better place. + */ +@Serializable +internal data class RemoteAuthResponse(val token: String, val iat: Long, val exp: Long) diff --git a/core/nssdk/src/commonMain/kotlin/app/aaps/core/nssdk/remotemodel/RemoteDeviceStatus.kt b/core/nssdk/src/commonMain/kotlin/app/aaps/core/nssdk/remotemodel/RemoteDeviceStatus.kt new file mode 100644 index 000000000000..cc110c3d4847 --- /dev/null +++ b/core/nssdk/src/commonMain/kotlin/app/aaps/core/nssdk/remotemodel/RemoteDeviceStatus.kt @@ -0,0 +1,66 @@ +package app.aaps.core.nssdk.remotemodel + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.JsonObject + +/** + * DeviceStatus coming from uploader or AAPS + * + **/ +@Serializable +internal data class RemoteDeviceStatus( + @SerialName("app") var app: String? = null, + @SerialName("identifier") + val identifier: String? = null, // string Main addressing, required field that identifies document in the collection. The client should not create the identifier, the server automatically assigns it when the document is inserted. + @SerialName("srvCreated") + val srvCreated: Long? = null, // integer($int64) example: 1525383610088 The server's timestamp of document insertion into the database (Unix epoch in ms). This field appears only for documents which were inserted by API v3. + @SerialName("srvModified") + val srvModified: Long? = null, // integer($int64) example: 1525383610088 The server's timestamp of the last document modification in the database (Unix epoch in ms). This field appears only for documents which were somehow modified by API v3 (inserted, updated or deleted). + @SerialName("created_at") + val createdAt: String? = null, // string or string timestamp on previous version of api, in my examples, a lot of treatments don't have date, only created_at, some of them with string others with long... + @SerialName("date") val date: Long? = null, // date as milliseconds + @SerialName("uploaderBattery") val uploaderBattery: Int? = null,// integer($int64) + @SerialName("isCharging") val isCharging: Boolean? = null, + @SerialName("device") val device: String? = null, // "openaps://samsung SM-G970F" + + @SerialName("uploader") val uploader: Uploader? = null, + @SerialName("pump") val pump: Pump? = null, + @SerialName("openaps") val openaps: OpenAps? = null +) { + + @Serializable + data class Pump( + @SerialName("clock") val clock: String? = null, // timestamp in ISO + @SerialName("reservoir") val reservoir: Double? = null, + @SerialName("reservoir_display_override") val reservoirDisplayOverride: String? = null, + @SerialName("battery") val battery: Battery? = null, + @SerialName("status") val status: Status? = null, + @SerialName("extended") val extended: JsonObject? = null // schema-less, content depends on the pump driver + ) { + + @Serializable + data class Battery( + @SerialName("percent") val percent: Int? = null, + @SerialName("voltage") val voltage: Double? = null + ) + + @Serializable + data class Status( + @SerialName("status") val status: String? = null, + @SerialName("timestamp") val timestamp: String? = null + ) + } + + @Serializable + data class OpenAps( + @SerialName("suggested") val suggested: JsonObject? = null, + @SerialName("enacted") val enacted: JsonObject? = null, + @SerialName("iob") val iob: JsonObject? = null + ) + + @Serializable + data class Uploader( + @SerialName("battery") val battery: Int? = null + ) +} diff --git a/core/nssdk/src/commonMain/kotlin/app/aaps/core/nssdk/remotemodel/RemoteEntry.kt b/core/nssdk/src/commonMain/kotlin/app/aaps/core/nssdk/remotemodel/RemoteEntry.kt new file mode 100644 index 000000000000..1d0eace198e7 --- /dev/null +++ b/core/nssdk/src/commonMain/kotlin/app/aaps/core/nssdk/remotemodel/RemoteEntry.kt @@ -0,0 +1,47 @@ +package app.aaps.core.nssdk.remotemodel + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +/* +* Depending on the type, different other fields are present. +* Those technically need to be optional. +* +* On upload a sanity check still needs to be done to verify that all mandatory fields for that type are there. +* +* TODO: Find out all types with their optional and mandatory fields +* +* */ +@Serializable +internal data class RemoteEntry( + // Default on purpose, same reason as RemoteFood: `entries` is a shared multi-writer collection + // and Nightscout does not mark `type` required. Gson left it null and both SvgMapper and + // MbgMapper then skipped the record; kotlinx would treat it as mandatory and throw for the whole + // page, and LoadBgWorker only advances its cursor after a successful decode - so one such record + // would stop all glucose reception and re-request the same page forever. + @SerialName("type") val type: String = "", // sgv, mbg, cal, etc; Bolus type NORMAL, SMB, PRIMING + @SerialName("sgv") val sgv: Double? = null, // number The glucose reading. (only available for sgv types) + @SerialName("dateString") val dateString: String? = null, + @SerialName("date") var date: Long? = null, // required ? TODO: date and dateString are redundant - are both needed? how to handle inconsistency then? Only expose one to clients? + @SerialName("device") val device: String? = null, // The device from which the data originated (including serial number of the device, if it is relevant and safe). + @SerialName("direction") val direction: String? = null, // TODO: what implicit convention for the directions exists? + @SerialName("identifier") val identifier: String? = null, + @SerialName("srvModified") val srvModified: Long? = null, + @SerialName("srvCreated") val srvCreated: Long? = null, + // Philoul Others fields below found in API v3 doc + @SerialName("app") var app: String? = null, + @SerialName("utcOffset") var utcOffset: Long? = null, // Local UTC offset (timezone) of the event in minutes. This field can be set either directly by the client (in the incoming document) or it is + // automatically parsed from the date field. + @SerialName("subject") val subject: String? = null, // Name of the security subject (within Nightscout scope) which has created the document. This field is automatically set by the server from the passed token or JWT. + @SerialName("modifiedBy") val modifiedBy: String? = null, // Name of the security subject (within Nightscout scope) which has patched or deleted the document for the last time. This field is automatically set by the server. + @SerialName("isValid") val isValid: Boolean? = null, // A flag set by the server only for deleted documents. This field appears only within history operation and for documents which were deleted by API v3 (and they always have a false value) + @SerialName("isReadOnly") val isReadOnly: Boolean? = null, // A flag set by client that locks the document from any changes. Every document marked with isReadOnly=true is forever immutable and cannot even be deleted. + @SerialName("noise") val noise: Double? = null, // 0 or 1 found in the export, I don't know if other values possible ? + @SerialName("filtered") val filtered: Double? = null, // The raw filtered value directly from CGM transmitter. (only available for sgv types) + @SerialName("unfiltered") val unfiltered: Double? = null, // The raw unfiltered value directly from CGM transmitter. (only available for sgv types) + @SerialName("units") val units: String? = null, // The units for the glucose value, mg/dl or mmol/l. It is strongly recommended to fill in this field. + @SerialName("mbg") val mbg: Double? = null, // Manual blood glucose reading (only available for mbg types). AAPS uses a marked mbg to carry a calibration fingerstick. + // AAPS-specific calibration fields, carried on a marked `mbg` entry so a follower can re-fit the calibration curve. + @SerialName("sensorMgdlAtPairing") val sensorMgdlAtPairing: Double? = null, // sensor value at the moment the fingerstick was entered + @SerialName("isCalibration") val isCalibration: Boolean? = null // marker: true when this mbg is an AAPS calibration pair (vs a foreign manual BG) +) diff --git a/core/nssdk/src/commonMain/kotlin/app/aaps/core/nssdk/remotemodel/RemoteFood.kt b/core/nssdk/src/commonMain/kotlin/app/aaps/core/nssdk/remotemodel/RemoteFood.kt new file mode 100644 index 000000000000..8dfeaafb526e --- /dev/null +++ b/core/nssdk/src/commonMain/kotlin/app/aaps/core/nssdk/remotemodel/RemoteFood.kt @@ -0,0 +1,51 @@ +package app.aaps.core.nssdk.remotemodel + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +/** + * Depending on the type, different other fields are present. + * Those technically need to be optional. + * + * On upload a sanity check still needs to be done to verify that all mandatory fields for that type are there. + * + **/ +@Serializable +internal data class RemoteFood( + // These four carry defaults on purpose. The Nightscout `food` collection is shared: besides food + // records it also holds "quickpick" documents written by the Nightscout food editor, which have + // no portion and no carbs. Gson filled a missing non-null field with null / 0 / 0.0 and the + // `else -> return null` branch in FoodMapper then dropped the document, which is what that + // branch is for. kotlinx instead treats a non-null field without a default as **mandatory** and + // throws - and `v3/food` is decoded as one list, so a single quickpick would lose the whole food + // database. The defaults below reproduce Gson's values exactly, so the old tolerant behaviour is + // restored. `encodeDefaults = true` means the upload format does not change. + @SerialName("type") val type: String = "", // we are interesting in type "food" + @SerialName("date") val date: Long? = null, + @SerialName("name") val name: String = "", + @SerialName("category") val category: String? = null, + @SerialName("subcategory") val subcategory: String? = null, + @SerialName("unit") val unit: String? = null, + @SerialName("portion") val portion: Double = 0.0, + @SerialName("carbs") val carbs: Int = 0, + @SerialName("gi") val gi: Int? = null, + @SerialName("energy") val energy: Int? = null, + @SerialName("protein") val protein: Int? = null, + @SerialName("fat") val fat: Int? = null, + @SerialName("identifier") + val identifier: String?, // string Main addressing, required field that identifies document in the collection. The client should not create the identifier, the server automatically assigns it when the document is inserted. + @SerialName("isValid") + val isValid: Boolean?, // A flag set by the server only for deleted documents. This field appears only within history operation and for documents which were deleted by API v3 (and they always have a false value) + @SerialName("isReadOnly") + val isReadOnly: Boolean?, // A flag set by client that locks the document from any changes. Every document marked with isReadOnly=true is forever immutable and cannot even be deleted. + @SerialName("app") var app: String? = null, // Application or system in which the record was entered by human or device for the first time. + @SerialName("device") val device: String? = null, // string The device from which the data originated (including serial number of the device, if it is relevant and safe). + @SerialName("srvCreated") + val srvCreated: Long? = null, // integer($int64) example: 1525383610088 The server's timestamp of document insertion into the database (Unix epoch in ms). This field appears only for documents which were inserted by API v3. + @SerialName("subject") + val subject: String? = null, // string Name of the security subject (within Nightscout scope) which has created the document. This field is automatically set by the server from the passed token or JWT. + @SerialName("srvModified") + val srvModified: Long? = null, // integer($int64) example: 1525383610088 The server's timestamp of the last document modification in the database (Unix epoch in ms). This field appears only for documents which were somehow modified by API v3 (inserted, updated or deleted). + @SerialName("modifiedBy") + val modifiedBy: String? = null // string Name of the security subject (within Nightscout scope) which has patched or deleted the document for the last time. This field is automatically set by the server. +) diff --git a/core/nssdk/src/commonMain/kotlin/app/aaps/core/nssdk/remotemodel/RemoteICfg.kt b/core/nssdk/src/commonMain/kotlin/app/aaps/core/nssdk/remotemodel/RemoteICfg.kt new file mode 100644 index 000000000000..7effc011fb28 --- /dev/null +++ b/core/nssdk/src/commonMain/kotlin/app/aaps/core/nssdk/remotemodel/RemoteICfg.kt @@ -0,0 +1,21 @@ +package app.aaps.core.nssdk.remotemodel + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +/** + * Insulin configuration attached to a treatment. + * + * Defaults are here for the same reason as in [RemoteFood]: a non-null field without a default is + * **mandatory** for kotlinx, and this is the only strict nested object on the treatments feed. Only + * a present-but-partial `icfg` would throw today, and nothing writes one - but a treatments page is + * decoded in a single pass, so if a future version ever adds a fifth field, every older reader would + * lose the whole page rather than one record. The defaults cost nothing and remove that trap. + */ +@Serializable +data class RemoteICfg( + @SerialName("insulinLabel") val insulinLabel: String = "", + @SerialName("insulinEndTime") val insulinEndTime: Long = 0, + @SerialName("insulinPeakTime") val insulinPeakTime: Long = 0, + @SerialName("concentration") val concentration: Double = 0.0 +) \ No newline at end of file diff --git a/core/nssdk/src/commonMain/kotlin/app/aaps/core/nssdk/remotemodel/RemoteStatusResponse.kt b/core/nssdk/src/commonMain/kotlin/app/aaps/core/nssdk/remotemodel/RemoteStatusResponse.kt new file mode 100644 index 000000000000..e46b3a385128 --- /dev/null +++ b/core/nssdk/src/commonMain/kotlin/app/aaps/core/nssdk/remotemodel/RemoteStatusResponse.kt @@ -0,0 +1,60 @@ +package app.aaps.core.nssdk.remotemodel + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +internal data class NSResponse(val result: T? = null) + +@Serializable +internal data class RemoteStatusResponse( + @SerialName("version") val version: String, + @SerialName("apiVersion") val apiVersion: String, + @SerialName("srvDate") val srvDate: Long, + @SerialName("storage") val storage: RemoteStorage, + @SerialName("apiPermissions") val apiPermissions: RemoteApiPermissions +) + +@Serializable +internal data class RemoteStorage( + @SerialName("storage") val storage: String, + @SerialName("version") val version: String +) + +@Serializable +internal data class RemoteCreateUpdateResponse( + @SerialName("identifier") val identifier: String? = null, + @SerialName("isDeduplication") val isDeduplication: Boolean? = null, + @SerialName("deduplicatedIdentifier") val deduplicatedIdentifier: String? = null, + @SerialName("lastModified") val lastModified: Long? = null +) + +@Serializable +internal data class RemoteApiPermissions( + @SerialName("devicestatus") val deviceStatus: RemoteApiPermission, + @SerialName("entries") val entries: RemoteApiPermission, + @SerialName("food") val food: RemoteApiPermission, + @SerialName("profile") val profile: RemoteApiPermission, + @SerialName("settings") val settings: RemoteApiPermission, + @SerialName("treatments") val treatments: RemoteApiPermission +) + +internal typealias RemoteApiPermission = String + +internal val RemoteApiPermission.create: Boolean + get() = this.contains('c') + +internal val RemoteApiPermission.read: Boolean + get() = this.contains('r') + +internal val RemoteApiPermission.update: Boolean + get() = this.contains('u') + +internal val RemoteApiPermission.delete: Boolean + get() = this.contains('d') + +internal val RemoteApiPermission.readCreate: Boolean + get() = this.read && this.create + +internal val RemoteApiPermission.full: Boolean + get() = this.create && this.read && this.update && this.delete diff --git a/core/nssdk/src/commonMain/kotlin/app/aaps/core/nssdk/remotemodel/RemoteTreatment.kt b/core/nssdk/src/commonMain/kotlin/app/aaps/core/nssdk/remotemodel/RemoteTreatment.kt new file mode 100644 index 000000000000..b0cb3febded7 --- /dev/null +++ b/core/nssdk/src/commonMain/kotlin/app/aaps/core/nssdk/remotemodel/RemoteTreatment.kt @@ -0,0 +1,197 @@ +package app.aaps.core.nssdk.remotemodel + +import app.aaps.core.nssdk.localmodel.treatment.EventType +import kotlinx.datetime.LocalDate +import kotlinx.datetime.LocalDateTime +import kotlinx.datetime.TimeZone +import kotlinx.datetime.UtcOffset +import kotlinx.datetime.atStartOfDayIn +import kotlinx.datetime.toInstant +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +/* +* Depending on the type, different other fields are present. +* Those technically need to be optional. +* +* On upload a sanity check still needs to be done to verify that all mandatory fields for that type are there. +* +* TODO: Find out all types with their optional and mandatory fields +* +* */ +@Serializable +internal data class RemoteTreatment( + @SerialName("identifier") + val identifier: String? = null, // string Main addressing, required field that identifies document in the collection. The client should not create the identifier, the server automatically assigns it when the document is inserted. + @SerialName("date") + var date: Long? = null, // integer($int64) or string required timestamp when the record or event occurred, you can choose from three input formats Unix epoch in milliseconds (1525383610088), Unix epoch in seconds (1525383610), ISO 8601 with optional timezone ('2018-05-03T21:40:10.088Z' or '2018-05-03T23:40:10.088+02:00') + @SerialName("mills") val mills: Long? = null, // integer($int64) or string required timestamp when the record or event occurred, you can choose from three input formats Unix + @SerialName("timestamp") + val timestamp: Long? = null, // integer($int64) or string required timestamp when the record or event occurred, you can choose from three input formats Unix epoch in milliseconds (1525383610088), Unix epoch in seconds (1525383610), ISO 8601 with optional timezone ('2018-05-03T21:40:10.088Z' or '2018-05-03T23:40:10.088+02:00') + @SerialName("created_at") val created_at: String? = null, // integer($int64) or string timestamp on previous version of api, in my examples, a lot of treatments don't have date, only created_at, some of them with string others with long... + @SerialName("utcOffset") + var utcOffset: Long? = null, // integer Local UTC offset (timezone) of the event in minutes. This field can be set either directly by the client (in the incoming document) or it is automatically parsed from the date field. + @SerialName("app") var app: String? = null, // Application or system in which the record was entered by human or device for the first time. + @SerialName("device") val device: String? = null, // string The device from which the data originated (including serial number of the device, if it is relevant and safe). + @SerialName("srvCreated") + val srvCreated: Long? = null, // integer($int64) example: 1525383610088 The server's timestamp of document insertion into the database (Unix epoch in ms). This field appears only for documents which were inserted by API v3. + @SerialName("subject") val subject: String? = null, // string Name of the security subject (within Nightscout scope) which has created the document. This field is automatically set by the server from the passed token or JWT. + @SerialName("srvModified") + val srvModified: Long? = null, // integer($int64) example: 1525383610088 The server's timestamp of the last document modification in the database (Unix epoch in ms). This field appears only for documents which were somehow modified by API v3 (inserted, updated or deleted). + @SerialName("modifiedBy") val modifiedBy: String? = null, // string Name of the security subject (within Nightscout scope) which has patched or deleted the document for the last time. This field is automatically set by the server. + @SerialName("isValid") + val isValid: Boolean? = null, // boolean A flag set by the server only for deleted documents. This field appears only within history operation and for documents which were deleted by API v3 (and they always have a false value) + @SerialName("isReadOnly") val isReadOnly: Boolean? = null, // boolean A flag set by client that locks the document from any changes. Every document marked with isReadOnly=true is forever immutable and cannot even be deleted. + @SerialName("eventType") + val eventType: EventType? = null, // string "BG Check", "Snack Bolus", "Meal Bolus", "Correction Bolus", "Carb Correction", "Combo Bolus", "Announcement", "Note", "Question", "Exercise", "Site Change", "Sensor Start", "Sensor Change", "Pump Battery Change", "Insulin Change", "Temp Basal", "Profile Switch", "D.A.D. Alert", "Temporary Target", "OpenAPS Offline", "Bolus Wizard" + @SerialName("glucose") val glucose: Double? = null, // double Current glucose + @SerialName("glucoseType") val glucoseType: String? = null, // string example: "Sensor", "Finger", "Manual" + @SerialName("units") val units: String? = null, // string The units for the glucose value, mg/dl or mmol/l. It is strongly recommended to fill in this field. + @SerialName("carbs") val carbs: Double? = null, // number... Amount of carbs given. + @SerialName("protein") val protein: Int? = null, // number... Amount of protein given. + @SerialName("fat") val fat: Int? = null, // number... Amount of fat given. + @SerialName("insulin") val insulin: Double? = null, // number... Amount of insulin, if any. + /** Duration in minutes */ + @SerialName("duration") val duration: Long? = null, // number... Duration in minutes. + /** Duration in milliseconds */ + @SerialName("durationInMilliseconds") val durationInMilliseconds: Long? = null, // number... Duration in milliseconds. + @SerialName("preBolus") val preBolus: Int? = null, // number... How many minutes the bolus was given before the meal started. + @SerialName("splitNow") val splitNow: Int? = null, // number... Immediate part of combo bolus (in percent). + @SerialName("splitExt") val splitExt: Int? = null, // number... Extended part of combo bolus (in percent). + @SerialName("percent") val percent: Double? = null, // number... Eventual basal change in percent. + @SerialName("absolute") val absolute: Double? = null, // number... Eventual basal change in absolute value (insulin units per hour). + @SerialName("targetTop") val targetTop: Double? = null, // number... Top limit of temporary target. + @SerialName("targetBottom") val targetBottom: Double? = null, // number... Bottom limit of temporary target. + @SerialName("profile") val profile: String? = null, // string Name of the profile to which the pump has been switched. + @SerialName("reason") val reason: String? = null, // string For example the reason why the profile has been switched or why the temporary target has been set. + @SerialName("mode") val mode: String? = null, // string RunningMode + @SerialName("location") val location: String? = null, // string Location for site management defined in TE.Location + @SerialName("arrow") val arrow: String? = null, // string Arrow for site management defined in TE.Arrow + @SerialName("autoForced") val autoForced: Boolean? = null, // boolean RunningMode + @SerialName("reasons") val reasons: String? = null, // string RunningMode + @SerialName("notes") val notes: String? = null, // string Description/notes of treatment. + @SerialName("enteredBy") val enteredBy: String? = null, // string Who entered the treatment. + + @SerialName("endId") val endId: Long? = null, // long id of record which ended this + @SerialName("pumpId") val pumpId: Long? = null, // long or "Meal Bolus", "Correction Bolus", "Combo Bolus" ex 4102 not sure if long or int + @SerialName("pumpType") val pumpType: String? = null, // string "Meal Bolus", "Correction Bolus", "Combo Bolus" ex "ACCU_CHEK_INSIGHT_BLUETOOTH", + @SerialName("pumpSerial") val pumpSerial: String? = null, // string "Meal Bolus", "Correction Bolus", "Combo Bolus" "33013206", + + // other fields found in examples but not in documentation + @SerialName("profileJson") val profileJson: String? = null, // string "Profile Switch" ex json toString "{\"units\":\"mg\\/dl\",\"dia\":5,\"timezone\":\"Africa\\/Cairo\", + // \"sens\":[{\"time\":\"00:00\",\"timeAsSeconds\":0,\"value\":60},{\"time\":\"07:00\",\"timeAsSeconds\":25200,\"value\":60},{\"time\":\"08:00\",\"timeAsSeconds\":28800,\"value\":61.33333333333333},{\"time\":\"09:00\",\"timeAsSeconds\":32400,\"value\":65.33333333333333},{\"time\":\"10:00\",\"timeAsSeconds\":36000,\"value\":69.33333333333333},{\"time\":\"11:00\",\"timeAsSeconds\":39600,\"value\":73.33333333333333},{\"time\":\"13:00\",\"timeAsSeconds\":46800,\"value\":72},{\"time\":\"14:00\",\"timeAsSeconds\":50400,\"value\":68},{\"time\":\"15:00\",\"timeAsSeconds\":54000,\"value\":65.33333333333333},{\"time\":\"16:00\",\"timeAsSeconds\":57600,\"value\":65.33333333333333}],\"carbratio\":[{\"time\":\"00:00\",\"timeAsSeconds\":0,\"value\":5.7333333333333325},{\"time\":\"11:00\",\"timeAsSeconds\":39600,\"value\":7.333333333333333},{\"time\":\"16:00\",\"timeAsSeconds\":57600,\"value\":6.666666666666666}],\"basal\":[{\"time\":\"00:00\",\"timeAsSeconds\":0,\"value\":0.5249999999999999},{\"time\":\"01:00\",\"timeAsSeconds\":3600,\"value\":0.585},{\"time\":\"02:00\",\"timeAsSeconds\":7200,\"value\":0.6375},{\"time\":\"03:00\",\"timeAsSeconds\":10800,\"value\":0.5625},{\"time\":\"04:00\",\"timeAsSeconds\":14400,\"value\":0.4575},{\"time\":\"05:00\",\"timeAsSeconds\":18000,\"value\":0.5175},{\"time\":\"06:00\",\"timeAsSeconds\":21600,\"value\":0.48},{\"time\":\"07:00\",\"timeAsSeconds\":25200,\"value\":0.51},{\"time\":\"08:00\",\"timeAsSeconds\":28800,\"value\":0.48750000000000004},{\"time\":\"09:00\",\"timeAsSeconds\":32400,\"value\":0.48},{\"time\":\"10:00\",\"timeAsSeconds\":36000,\"value\":0.48750000000000004},{\"time\":\"11:00\",\"timeAsSeconds\":39600,\"value\":0.5025000000000001},{\"time\":\"12:00\",\"timeAsSeconds\":43200,\"value\":0.5549999999999999},{\"time\":\"13:00\",\"timeAsSeconds\":46800,\"value\":0.5700000000000001},{\"time\":\"14:00\",\"timeAsSeconds\":50400,\"value\":0.5700000000000001},{\"time\":\"15:00\",\"timeAsSeconds\":54000,\"value\":0.5775},{\"time\":\"16:00\",\"timeAsSeconds\":57600,\"value\":0.51},{\"time\":\"17:00\",\"timeAsSeconds\":61200,\"value\":0.54},{\"time\":\"18:00\",\"timeAsSeconds\":64800,\"value\":0.48750000000000004},{\"time\":\"19:00\",\"timeAsSeconds\":68400,\"value\":0.5249999999999999},{\"time\":\"20:00\",\"timeAsSeconds\":72000,\"value\":0.46499999999999997},{\"time\":\"21:00\",\"timeAsSeconds\":75600,\"value\":0.46499999999999997},{\"time\":\"22:00\",\"timeAsSeconds\":79200,\"value\":0.43499999999999994},{\"time\":\"23:00\",\"timeAsSeconds\":82800,\"value\":0.41250000000000003}],\"target_low\":[{\"time\":\"00:00\",\"timeAsSeconds\":0,\"value\":100},{\"time\":\"06:00\",\"timeAsSeconds\":21600,\"value\":90},{\"time\":\"09:00\",\"timeAsSeconds\":32400,\"value\":100},{\"time\":\"11:00\",\"timeAsSeconds\":39600,\"value\":90},{\"time\":\"14:00\",\"timeAsSeconds\":50400,\"value\":100},{\"time\":\"18:00\",\"timeAsSeconds\":64800,\"value\":90},{\"time\":\"21:00\",\"timeAsSeconds\":75600,\"value\":100}],\"target_high\":[{\"time\":\"00:00\",\"timeAsSeconds\":0,\"value\":100},{\"time\":\"06:00\",\"timeAsSeconds\":21600,\"value\":90},{\"time\":\"09:00\",\"timeAsSeconds\":32400,\"value\":100},{\"time\":\"11:00\",\"timeAsSeconds\":39600,\"value\":90},{\"time\":\"14:00\",\"timeAsSeconds\":50400,\"value\":100},{\"time\":\"18:00\",\"timeAsSeconds\":64800,\"value\":90},{\"time\":\"21:00\",\"timeAsSeconds\":75600,\"value\":100}]}", + @SerialName("originalProfileName") val originalProfileName: String? = null, // string "Effective Profile Switch" + @SerialName("originalCustomizedName") val originalCustomizedName: String? = null, // string "Effective Profile Switch" + @SerialName("originalTimeshift") val originalTimeshift: Long? = null, // long "Effective Profile Switch" + @SerialName("originalPercentage") val originalPercentage: Int? = null, // int "Effective Profile Switch" + @SerialName("originalDuration") val originalDuration: Long? = null, // long "Effective Profile Switch", RunningMode + @SerialName("originalEnd") val originalEnd: Long? = null, // long "Effective Profile Switch" + @SerialName("icfg") val iCfg: RemoteICfg? = null, // long "Effective Profile Switch" + + @SerialName("bolusCalculatorResult") + val bolusCalculatorResult: String? = null, // string "Bolus Wizard" json toString ex "bolusCalculatorResult": "{\"basalIOB\":-0.247,\"bolusIOB\":-1.837,\"carbs\":45.0,\"carbsInsulin\":9.0,\"cob\":0.0,\"cobInsulin\":0.0,\"dateCreated\":1626202788810,\"glucoseDifference\":44.0,\"glucoseInsulin\":0.8979591836734694,\"glucoseTrend\":5.5,\"glucoseValue\":134.0,\"ic\":5.0,\"id\":331,\"interfaceIDs_backing\":{\"nightscoutId\":\"60ede2a4c574da0004a3869d\"},\"isValid\":true,\"isf\":49.0,\"note\":\"\",\"otherCorrection\":0.0,\"percentageCorrection\":90,\"profileName\":\"Tuned 13/01 90%Lyum\",\"superbolusInsulin\":0.0,\"targetBGHigh\":90.0,\"targetBGLow\":90.0,\"timestamp\":1626202783325,\"totalInsulin\":7.34,\"trendInsulin\":0.336734693877551,\"utcOffset\":7200000,\"version\":1,\"wasBasalIOBUsed\":true,\"wasBolusIOBUsed\":true,\"wasCOBUsed\":true,\"wasGlucoseUsed\":true,\"wasSuperbolusUsed\":false,\"wasTempTargetUsed\":false,\"wasTrendUsed\":true,\"wereCarbsUsed\":false}", + @SerialName("type") val type: String? = null, // string "Meal Bolus", "Correction Bolus", "Combo Bolus", "Temp Basal" type of bolus "NORMAL", "SMB", "FAKE_EXTENDED" + @SerialName("isSMB") val isSMB: Boolean? = null, // boolean "Meal Bolus", "Correction Bolus", "Combo Bolus" + @SerialName("enteredinsulin") val enteredinsulin: Double? = null, // number... "Combo Bolus" insulin is missing only enteredinsulin field found + @SerialName("relative") val relative: Double? = null, // number... "Combo Bolus", "extendedEmulated" (not in doc see below) + @SerialName("isEmulatingTempBasal") val isEmulatingTempBasal: Boolean? = null, // boolean "Combo Bolus", "extendedEmulated" (not in doc see below) + @SerialName("isAnnouncement") val isAnnouncement: Boolean? = null, // boolean "Announcement" + @SerialName("rate") val rate: Double? = null, // Double "Temp Basal" absolute rate (could be calculated with percent and profile information...) + @SerialName("extendedEmulated") var extendedEmulated: RemoteTreatment? = null, // Gson of emulated EB + @SerialName("timeshift") val timeshift: Long? = null, // integer "Profile Switch" + @SerialName("percentage") val percentage: Int? = null, // integer "Profile Switch" + @SerialName("isBasalInsulin") val isBasalInsulin: Boolean? = null // boolean "Bolus" +) { + + /** + * Best timestamp this record can offer, in milliseconds. + * + * `date` is what AAPS and every API v3 writer send, so it is almost always the answer. The rest + * of the chain is for older API v1 documents and other uploaders, which often carry only + * `created_at` - as the comment on that field says, "some of them with string others with long". + */ + fun timestamp(): Long = + date ?: mills ?: timestamp ?: created_at?.let { parseCreatedAt(it) } ?: 0L + + /** + * `created_at` arrives in two shapes and both have to work. + * + * It is declared `String?`, but a number in that position is coerced to its text form, so an + * epoch written as `1525383610088` reaches here as the **digits** `"1525383610088"`. joda's ISO + * parser cannot read that and used to throw, which the catch turned into `0L` - putting the + * treatment at the epoch, in 1970, with no error anywhere. The epoch form is tried first now. + * + * Seconds are not accepted on purpose. `1525383610` is a valid epoch in seconds and also a valid + * epoch in milliseconds (17 January 1970), and nothing in the document says which is meant, so + * guessing would trade a visible 1970 date for an invisible wrong one. + */ + private fun parseCreatedAt(createdAt: String): Long { + val trimmed = createdAt.trim() + // Digits only (optionally signed) means an epoch, not an ISO date. + if (trimmed.isNotEmpty() && trimmed.all { it.isDigit() || it == '-' }) + trimmed.toLongOrNull()?.let { return it } + return fromISODateString(trimmed) + } + + /** + * Lenient ISO 8601 parsing, matching what joda's `ISODateTimeFormat.dateTimeParser()` used to + * accept here. joda is a JVM library, so it cannot go to iOS; kotlinx-datetime is multiplatform + * but strict, and a strict parser would return `0L` for shapes that work today - putting the + * treatment in 1970 with no error anywhere. + * + * The trick is to take any explicit offset off the end **first**, then parse what is left as a + * plain local value. That way one small parser covers every shape rather than needing a format + * per variant: + * + * - `2026-08-06T04:56:19.555Z`, `...+02:00`, `...+0200`, `...-04:00` -> that exact instant + * - `2026-08-06T04:56:19.555`, `2026-08-06T04:56` -> **local** time, as joda read it + * - `2026-08-06` -> **local** midnight + * - lower case `t` / `z` -> accepted + * - anything else -> `0L`, never a throw + * + * `CreatedAtParsingTest` pins all of it against the values joda produced. + */ + private fun fromISODateString(isoDateString: String): Long { + val text = isoDateString.trim().uppercase() + if (text.isEmpty()) return 0L + + // Peel off a trailing zone designator, so the rest is a plain local date-time. + var local = text + var offset: UtcOffset? = null + if (text.endsWith("Z")) { + local = text.dropLast(1) + offset = UtcOffset.ZERO + } else { + OFFSET_AT_END.find(text)?.let { match -> + val parsed = runCatching { UtcOffset.parse(withOffsetColon(match.value)) }.getOrNull() + if (parsed != null) { + local = text.substring(0, match.range.first) + offset = parsed + } + } + } + + val zone = TimeZone.currentSystemDefault() + + runCatching { LocalDateTime.parse(local) }.getOrNull()?.let { dateTime -> + val instant = offset?.let { dateTime.toInstant(it) } ?: dateTime.toInstant(zone) + return instant.toEpochMilliseconds() + } + // Date only. joda gave local midnight, and a date without a time never carries an offset. + runCatching { LocalDate.parse(local) }.getOrNull()?.let { date -> + return date.atStartOfDayIn(zone).toEpochMilliseconds() + } + return 0L + } + +} + +// File scope, not a companion: `@Serializable` generates its own companion to carry `serializer()`, +// and declaring a private one here would make that private too. + +/** A trailing `+HH:MM` / `+HHMM` offset. Anchored so it cannot match the date's own dashes. */ +private val OFFSET_AT_END = Regex("""[+-]\d{2}:?\d{2}$""") + +/** `+0200` -> `+02:00`; already-correct input is returned unchanged. */ +private fun withOffsetColon(offset: String): String = + if (offset.contains(':')) offset else offset.substring(0, 3) + ":" + offset.substring(3) diff --git a/core/nssdk/src/commonMain/kotlin/app/aaps/core/nssdk/remotemodel/examples.json b/core/nssdk/src/commonMain/kotlin/app/aaps/core/nssdk/remotemodel/examples.json new file mode 100644 index 000000000000..0ffff23e4df7 --- /dev/null +++ b/core/nssdk/src/commonMain/kotlin/app/aaps/core/nssdk/remotemodel/examples.json @@ -0,0 +1,489 @@ +// Entry +{ + "device": "xDrip-Follower", + "date": 1549414398005, + "dateString": "2019-02-06T01:53:18.005+0100", + "sgv": 98, + "delta": -1.132, + "direction": "Flat", + "type": "sgv", + "filtered": 90336, + "unfiltered": 89712, + "rssi": 100, + "noise": 1, + "sysTime": "2019-02-06T01:53:18.005+0100", + "identifier": "5c5a3007e0196f4d3d9aeafc", + "srvModified": 1549414398005, + "srvCreated": 1549414398005 +}, + +// G6 AAPS +{ +"_id": "60bace9f51e8150004f0973a", +"device": "AndroidAPS-DexcomG6", +"date": 1622855221000, +"dateString": "2021-06-05T01:07:01.000Z", +"isValid": true, +"sgv": 76, +"direction": "Flat", // DoubleDown, SingleDown, FortyFiveDown, Flat, FortyFiveUp, SingleUp, DoubleUp +"type": "sgv", +"created_at": "2021-06-05T01:08:47.234Z" +}, +// G6 DEXCOM APP Share +{ +"_id": "60cd4e7d5bcdeb30e43a248d", +"sgv": 90, +"date": 1624067551000, +"dateString": "2021-06-19T01:52:31.000Z", +"trend": 4, // 7 , 6 , 5 , 4 , 3 , 2 , 1 +"direction": "Flat", // DoubleDown, SingleDown, FortyFiveDown, Flat, FortyFiveUp, SingleUp, DoubleUp +"device": "share2", +"type": "sgv", +"utcOffset": 0, +"sysTime": "2021-06-19T01:52:31.000Z" +}, +// FSL1 xDrip +{ +"device": "AndroidAPS", +"date": 1588557121000, +"dateString": "2020-05-04T01:52:01Z", +"sgv": 76, +"direction": "Flat", // DoubleDown, SingleDown, FortyFiveDown, Flat, FortyFiveUp, SingleUp, DoubleUp +"type": "sgv", +"systime": "2020-05-04T01:52:01Z", +"utcOffset": 120 +}, +// LimiTTer xDrip +{ +"_id": "5ed06c9a0ea4dcb70fac6cc7", +"device": "xDrip-LimiTTer", +"date": 1590717591357, +"dateString": "2020-05-29T01:59:51.357Z", +"sgv": 114, +"delta": -2.942, +"direction": "Flat", +"type": "sgv", +"filtered": 127411.75515, +"unfiltered": 127411.75515, +"rssi": 100, +"noise": 1, +"sysTime": "2020-05-29T01:59:51.357Z", +"utcOffset": 120 +}, + +// API v3 requests for treatments +{ +"eventType": "BG Check", +"created_at": 1616966443000, +"units": "mg/dl", +"glucose": 57, +"NSCLIENT_ID": "1616966443000", +"identifier": "6060f32e9b9c5900045c858b", +"srvModified": 1616966443000, +"srvCreated": 1616966443000 +}, +{ +"eventType": "BG Check", +"created_at": 1617365936000, +"enteredBy": "AndroidAPS", +"units": "mg/dl", +"notes": "Coucou", +"glucose": 94, +"glucoseType": "Finger", +"identifier": "606727c058f71500041e1ed3", +"srvModified": 1617365936000, +"srvCreated": 1617365936000 +}, +{ +"eventType": "Meal Bolus", +"carbs": 45, +"created_at": "2021-07-13T18:19:43.325Z", +"isValid": true, +"date": 1626200383325, +"identifier": "60ede2a4c574da0004a3869c", +"srvModified": 1626200383325, +"srvCreated": 1626200383325 +}, +{ +"eventType": "Meal Bolus", +"insulin": 8.1, +"created_at": "2021-07-13T11:25:12.664Z", +"date": 1626175512664, +"type": "NORMAL", +"isValid": true, +"isSMB": false, +"pumpId": 4102, +"pumpType": "ACCU_CHEK_INSIGHT_BLUETOOTH", +"pumpSerial": "33013206", +"identifier": "60ed782dc574da0004a38595", +"srvModified": 1626175512664, +"srvCreated": 1626175512664 +}, +{ +"eventType": "Correction Bolus", +"insulin": 0.25, +"created_at": "2021-07-13T20:44:14.441Z", +"date": 1626209054441, +"type": "SMB", +"isValid": true, +"isSMB": true, +"pumpId": 4148, +"pumpType": "ACCU_CHEK_INSIGHT_BLUETOOTH", +"pumpSerial": "33013206", +"identifier": "60edfb34c574da0004a386d4", +"srvModified": 1626209054441, +"srvCreated": 1626209054441 +},{ +"eventType": "Carb Correction", +"carbs": 5, +"created_at": "2021-06-17T09:00:34.000Z", +"isValid": true, +"date": 1623920434000, +"identifier": "60cb2f351a94d4000483b692", +"srvModified": 1623920434000, +"srvCreated": 1623920434000 +}, +{ +"created_at": "2021-05-28T19:46:43.851Z", +"enteredBy": "openaps://AndroidAPS", +"eventType": "Combo Bolus", +"duration": 5, +"splitNow": 0, +"splitExt": 100, +"enteredinsulin": 0.7890262726962469, +"relative": 8.893749414356174, +"isValid": true, +"isEmulatingTempBasal": false, +"pumpId": 4, +"pumpType": "ACCU_CHEK_INSIGHT_BLUETOOTH", +"pumpSerial": "33010032", +"identifier": "60b148b419cf4300040b0195", +"srvModified": 1622231203851, +"srvCreated": 1622231203851 +}, +{ +"eventType": "Announcement", +"created_at": 1617350431592, +"enteredBy": "AndroidAPS", +"units": "mg/dl", +"notes": "5g de glucides requis dans 40 min.", +"isAnnouncement": true, +"identifier": "6066cf2508a6ed0004b4ed44", +"srvModified": 1617350431592, +"srvCreated": 1617350431592 +}, +{ +"eventType": "Note", +"created_at": 1617023462485, +"units": "mg/dl", +"notes": "AndroidAPS started - Logicom Le Hola FR", +"identifier": "6061d20b17619800047216b2", +"srvModified": 1617023462485, +"srvCreated": 1617023462485 +}, +{ +"eventType": "Exercise", +"created_at": 1617373066000, +"enteredBy": "AndroidAPS", +"units": "mg/dl", +"duration": 20, +"notes": "ten tab", +"identifier": "606727a658f71500041e1ed2", +"srvModified": 1617373066000, +"srvCreated": 1617373066000 +}, +{ +"eventType": "Exercise", +"isValid": true, +"created_at": "2021-07-09T18:15:22.000Z", +"enteredBy": "AndroidAPS", +"units": "mg/dl", +"duration": 105, +"notes": "🏓", +"identifier": "60e8b223b98ea2000472cbb3", +"srvModified": 1625854522000, +"srvCreated": 1625854522000 +}, +{ +"eventType": "Site Change", +"created_at": 1616312250000, +"units": "mg/dl", +"notes": "", +"NSCLIENT_ID": "1616312250000", +"identifier": "6056f7c1bc2dc60004e75499", +"srvModified": 1616312250000, +"srvCreated": 1616312250000 +}, +{ +"eventType": "Sensor Change", +"created_at": 1617373059000, +"enteredBy": "AndroidAPS", +"units": "mg/dl", +"identifier": "6067278d58f71500041e1ed1", +"srvModified": 1617373059000, +"srvCreated": 1617373059000 +}, +{ +"enteredBy": "AndroidAPS-DexcomG6", +"created_at": 1617799461000, +"eventType": "Sensor Change", +"NSCLIENT_ID": "1617961262771", +"identifier": "60702190403172000451e5dc", +"srvModified": 1617799461000, +"srvCreated": 1617799461000 +}, +{ +"eventType": "Pump Battery Change", +"created_at": 1616517575000, +"enteredBy": "AndroidAPS", +"units": "mg/dl", +"notes": "à peu près...", +"NSCLIENT_ID": "1616517575000", +"identifier": "605cbce3f9ed3b0004694ee8", +"srvModified": 1616517575000, +"srvCreated": 1616517575000 +}, +{ +"created_at": 1617576811000, +"eventType": "Pump Battery Change", +"NSCLIENT_ID": "1617577097394", +"glucoseType": "Manual", +"isValid": true, +"units": "mg/dl", +"identifier": "606a448d7c31f00004bb47ac", +"srvModified": 1617576811000, +"srvCreated": 1617576811000 +}, +{ +"eventType": "Insulin Change", +"created_at": 1616342559000, +"units": "mg/dl", +"notes": "Ajout manuel pour UE", +"NSCLIENT_ID": "1616342559000", +"identifier": "60576e6b5a34f900043e25f6", +"srvModified": 1616342559000, +"srvCreated": 1616342559000 +}, +{ +"created_at": "2021-07-13T20:44:12.891Z", +"enteredBy": "openaps://AndroidAPS", +"eventType": "Temp Basal", +"isValid": true, +"duration": 60, +"rate": 0, +"type": "NORMAL", +"absolute": 0, +"pumpId": 284835, +"pumpType": "ACCU_CHEK_INSIGHT_BLUETOOTH", +"pumpSerial": "33013206", +"identifier": "60edfb34c574da0004a386d3", +"srvModified": 1626209052891, +"srvCreated": 1626209052891 +}, +{ +"created_at": "2021-07-13T20:40:29.896Z", +"enteredBy": "openaps://AndroidAPS", +"eventType": "Temp Basal", +"isValid": true, +"duration": 3, +"rate": 2.4391549295774646, +"type": "FAKE_EXTENDED", +"absolute": 2.4391549295774646, +"pumpId": 4147, +"pumpType": "ACCU_CHEK_INSIGHT_BLUETOOTH", +"pumpSerial": "33013206", +"extendedEmulated": { +"created_at": "2021-07-13T20:40:29.896Z", +"enteredBy": "openaps://AndroidAPS", +"eventType": "Combo Bolus", +"duration": 3, +"splitNow": 0, +"splitExt": 100, +"enteredinsulin": 0.11, +"relative": 1.8591549295774648, +"isValid": true, +"isEmulatingTempBasal": true, +"pumpId": 4147, +"pumpType": "ACCU_CHEK_INSIGHT_BLUETOOTH", +"pumpSerial": "33013206" +}, +"identifier": "60edfa51c574da0004a386d0", +"srvModified": 1626208829896, +"srvCreated": 1626208829896 +}, +{ +"eventType": "OpenAPS Offline", +"created_at": 1616391934628, +"enteredBy": "openaps://AndroidAPS", +"units": "mg/dl", +"duration": 15, +"NSCLIENT_ID": "1616391934628", +"identifier": "60582f005a34f900043e2845", +"srvModified": 1616391934628, +"srvCreated": 1616391934628 +}, +{ +"created_at": "2021-06-26T13:36:47.000Z", +"enteredBy": "openaps://AndroidAPS", +"isValid": true, +"eventType": "Profile Switch", +"duration": 0, +"profile": "Tuned 13/01 90%Lyum", +"profileJson": "{\"units\":\"mg\\/dl\",\"dia\":5,\"timezone\":\"Africa\\/Cairo\",\"sens\":[{\"time\":\"00:00\",\"timeAsSeconds\":0,\"value\":45},{\"time\":\"07:00\",\"timeAsSeconds\":25200,\"value\":45},{\"time\":\"08:00\",\"timeAsSeconds\":28800,\"value\":46},{\"time\":\"09:00\",\"timeAsSeconds\":32400,\"value\":49},{\"time\":\"10:00\",\"timeAsSeconds\":36000,\"value\":52},{\"time\":\"11:00\",\"timeAsSeconds\":39600,\"value\":55},{\"time\":\"13:00\",\"timeAsSeconds\":46800,\"value\":54},{\"time\":\"14:00\",\"timeAsSeconds\":50400,\"value\":51},{\"time\":\"15:00\",\"timeAsSeconds\":54000,\"value\":49},{\"time\":\"16:00\",\"timeAsSeconds\":57600,\"value\":49}],\"carbratio\":[{\"time\":\"00:00\",\"timeAsSeconds\":0,\"value\":4.3},{\"time\":\"11:00\",\"timeAsSeconds\":39600,\"value\":5.5},{\"time\":\"16:00\",\"timeAsSeconds\":57600,\"value\":5}],\"basal\":[{\"time\":\"00:00\",\"timeAsSeconds\":0,\"value\":0.7},{\"time\":\"01:00\",\"timeAsSeconds\":3600,\"value\":0.78},{\"time\":\"02:00\",\"timeAsSeconds\":7200,\"value\":0.85},{\"time\":\"03:00\",\"timeAsSeconds\":10800,\"value\":0.75},{\"time\":\"04:00\",\"timeAsSeconds\":14400,\"value\":0.61},{\"time\":\"05:00\",\"timeAsSeconds\":18000,\"value\":0.69},{\"time\":\"06:00\",\"timeAsSeconds\":21600,\"value\":0.64},{\"time\":\"07:00\",\"timeAsSeconds\":25200,\"value\":0.68},{\"time\":\"08:00\",\"timeAsSeconds\":28800,\"value\":0.65},{\"time\":\"09:00\",\"timeAsSeconds\":32400,\"value\":0.64},{\"time\":\"10:00\",\"timeAsSeconds\":36000,\"value\":0.65},{\"time\":\"11:00\",\"timeAsSeconds\":39600,\"value\":0.67},{\"time\":\"12:00\",\"timeAsSeconds\":43200,\"value\":0.74},{\"time\":\"13:00\",\"timeAsSeconds\":46800,\"value\":0.76},{\"time\":\"14:00\",\"timeAsSeconds\":50400,\"value\":0.76},{\"time\":\"15:00\",\"timeAsSeconds\":54000,\"value\":0.77},{\"time\":\"16:00\",\"timeAsSeconds\":57600,\"value\":0.68},{\"time\":\"17:00\",\"timeAsSeconds\":61200,\"value\":0.72},{\"time\":\"18:00\",\"timeAsSeconds\":64800,\"value\":0.65},{\"time\":\"19:00\",\"timeAsSeconds\":68400,\"value\":0.7},{\"time\":\"20:00\",\"timeAsSeconds\":72000,\"value\":0.62},{\"time\":\"21:00\",\"timeAsSeconds\":75600,\"value\":0.62},{\"time\":\"22:00\",\"timeAsSeconds\":79200,\"value\":0.58},{\"time\":\"23:00\",\"timeAsSeconds\":82800,\"value\":0.55}],\"target_low\":[{\"time\":\"00:00\",\"timeAsSeconds\":0,\"value\":100},{\"time\":\"06:00\",\"timeAsSeconds\":21600,\"value\":90},{\"time\":\"09:00\",\"timeAsSeconds\":32400,\"value\":100},{\"time\":\"11:00\",\"timeAsSeconds\":39600,\"value\":90},{\"time\":\"14:00\",\"timeAsSeconds\":50400,\"value\":100},{\"time\":\"18:00\",\"timeAsSeconds\":64800,\"value\":90},{\"time\":\"21:00\",\"timeAsSeconds\":75600,\"value\":100}],\"target_high\":[{\"time\":\"00:00\",\"timeAsSeconds\":0,\"value\":100},{\"time\":\"06:00\",\"timeAsSeconds\":21600,\"value\":90},{\"time\":\"09:00\",\"timeAsSeconds\":32400,\"value\":100},{\"time\":\"11:00\",\"timeAsSeconds\":39600,\"value\":90},{\"time\":\"14:00\",\"timeAsSeconds\":50400,\"value\":100},{\"time\":\"18:00\",\"timeAsSeconds\":64800,\"value\":90},{\"time\":\"21:00\",\"timeAsSeconds\":75600,\"value\":100}]}", +"timeshift": 0, +"percentage": 100, +"identifier": "60d72d80aec46a0004f95163", +"srvModified": 1624714607000, +"srvCreated": 1624714607000 +}, +{ +"created_at": "2021-06-13T07:20:33.000Z", +"enteredBy": "openaps://AndroidAPS", +"isValid": true, +"eventType": "Profile Switch", +"duration": 150, +"profile": "Tuned 13/01 90%Lyum(75%)", +"profileJson": "{\"units\":\"mg\\/dl\",\"dia\":5,\"timezone\":\"Africa\\/Cairo\",\"sens\":[{\"time\":\"00:00\",\"timeAsSeconds\":0,\"value\":60},{\"time\":\"07:00\",\"timeAsSeconds\":25200,\"value\":60},{\"time\":\"08:00\",\"timeAsSeconds\":28800,\"value\":61.33333333333333},{\"time\":\"09:00\",\"timeAsSeconds\":32400,\"value\":65.33333333333333},{\"time\":\"10:00\",\"timeAsSeconds\":36000,\"value\":69.33333333333333},{\"time\":\"11:00\",\"timeAsSeconds\":39600,\"value\":73.33333333333333},{\"time\":\"13:00\",\"timeAsSeconds\":46800,\"value\":72},{\"time\":\"14:00\",\"timeAsSeconds\":50400,\"value\":68},{\"time\":\"15:00\",\"timeAsSeconds\":54000,\"value\":65.33333333333333},{\"time\":\"16:00\",\"timeAsSeconds\":57600,\"value\":65.33333333333333}],\"carbratio\":[{\"time\":\"00:00\",\"timeAsSeconds\":0,\"value\":5.7333333333333325},{\"time\":\"11:00\",\"timeAsSeconds\":39600,\"value\":7.333333333333333},{\"time\":\"16:00\",\"timeAsSeconds\":57600,\"value\":6.666666666666666}],\"basal\":[{\"time\":\"00:00\",\"timeAsSeconds\":0,\"value\":0.5249999999999999},{\"time\":\"01:00\",\"timeAsSeconds\":3600,\"value\":0.585},{\"time\":\"02:00\",\"timeAsSeconds\":7200,\"value\":0.6375},{\"time\":\"03:00\",\"timeAsSeconds\":10800,\"value\":0.5625},{\"time\":\"04:00\",\"timeAsSeconds\":14400,\"value\":0.4575},{\"time\":\"05:00\",\"timeAsSeconds\":18000,\"value\":0.5175},{\"time\":\"06:00\",\"timeAsSeconds\":21600,\"value\":0.48},{\"time\":\"07:00\",\"timeAsSeconds\":25200,\"value\":0.51},{\"time\":\"08:00\",\"timeAsSeconds\":28800,\"value\":0.48750000000000004},{\"time\":\"09:00\",\"timeAsSeconds\":32400,\"value\":0.48},{\"time\":\"10:00\",\"timeAsSeconds\":36000,\"value\":0.48750000000000004},{\"time\":\"11:00\",\"timeAsSeconds\":39600,\"value\":0.5025000000000001},{\"time\":\"12:00\",\"timeAsSeconds\":43200,\"value\":0.5549999999999999},{\"time\":\"13:00\",\"timeAsSeconds\":46800,\"value\":0.5700000000000001},{\"time\":\"14:00\",\"timeAsSeconds\":50400,\"value\":0.5700000000000001},{\"time\":\"15:00\",\"timeAsSeconds\":54000,\"value\":0.5775},{\"time\":\"16:00\",\"timeAsSeconds\":57600,\"value\":0.51},{\"time\":\"17:00\",\"timeAsSeconds\":61200,\"value\":0.54},{\"time\":\"18:00\",\"timeAsSeconds\":64800,\"value\":0.48750000000000004},{\"time\":\"19:00\",\"timeAsSeconds\":68400,\"value\":0.5249999999999999},{\"time\":\"20:00\",\"timeAsSeconds\":72000,\"value\":0.46499999999999997},{\"time\":\"21:00\",\"timeAsSeconds\":75600,\"value\":0.46499999999999997},{\"time\":\"22:00\",\"timeAsSeconds\":79200,\"value\":0.43499999999999994},{\"time\":\"23:00\",\"timeAsSeconds\":82800,\"value\":0.41250000000000003}],\"target_low\":[{\"time\":\"00:00\",\"timeAsSeconds\":0,\"value\":100},{\"time\":\"06:00\",\"timeAsSeconds\":21600,\"value\":90},{\"time\":\"09:00\",\"timeAsSeconds\":32400,\"value\":100},{\"time\":\"11:00\",\"timeAsSeconds\":39600,\"value\":90},{\"time\":\"14:00\",\"timeAsSeconds\":50400,\"value\":100},{\"time\":\"18:00\",\"timeAsSeconds\":64800,\"value\":90},{\"time\":\"21:00\",\"timeAsSeconds\":75600,\"value\":100}],\"target_high\":[{\"time\":\"00:00\",\"timeAsSeconds\":0,\"value\":100},{\"time\":\"06:00\",\"timeAsSeconds\":21600,\"value\":90},{\"time\":\"09:00\",\"timeAsSeconds\":32400,\"value\":100},{\"time\":\"11:00\",\"timeAsSeconds\":39600,\"value\":90},{\"time\":\"14:00\",\"timeAsSeconds\":50400,\"value\":100},{\"time\":\"18:00\",\"timeAsSeconds\":64800,\"value\":90},{\"time\":\"21:00\",\"timeAsSeconds\":75600,\"value\":100}]}", +"timeshift": 0, +"percentage": 100, +"identifier": "60c5b1f41b3715000420af27", +"srvModified": 1623568833000, +"srvCreated": 1623568833000 +}, +{ +"eventType": "Temporary Target", +"duration": 60, +"isValid": true, +"created_at": "2021-07-10T05:04:11.566Z", +"enteredBy": "AndroidAPS", +"reason": "Automation", +"targetBottom": 110, +"targetTop": 110, +"units": "mg/dl", +"identifier": "60e92a644fc2eb00045ece1b", +"srvModified": 1625893451566, +"srvCreated": 1625893451566 +}, +{ +"eventType": "Temporary Target", +"duration": 120, +"isValid": true, +"created_at": "2021-07-09T20:30:21.627Z", +"enteredBy": "AndroidAPS", +"reason": "Hypo", +"targetBottom": 140, +"targetTop": 140, +"units": "mg/dl", +"identifier": "60e8b1f2b98ea2000472cbb1", +"srvModified": 1625862621627, +"srvCreated": 1625862621627 +}, +{ +"eventType": "Bolus Wizard", +"created_at": "2021-07-13T18:59:43.325Z", +"isValid": true, +"bolusCalculatorResult": "{\"basalIOB\":-0.247,\"bolusIOB\":-1.837,\"carbs\":45.0,\"carbsInsulin\":9.0,\"cob\":0.0,\"cobInsulin\":0.0,\"dateCreated\":1626202788810,\"glucoseDifference\":44.0,\"glucoseInsulin\":0.8979591836734694,\"glucoseTrend\":5.5,\"glucoseValue\":134.0,\"ic\":5.0,\"id\":331,\"interfaceIDs_backing\":{\"nightscoutId\":\"60ede2a4c574da0004a3869d\"},\"isValid\":true,\"isf\":49.0,\"note\":\"\",\"otherCorrection\":0.0,\"percentageCorrection\":90,\"profileName\":\"Tuned 13/01 90%Lyum\",\"superbolusInsulin\":0.0,\"targetBGHigh\":90.0,\"targetBGLow\":90.0,\"timestamp\":1626202783325,\"totalInsulin\":7.34,\"trendInsulin\":0.336734693877551,\"utcOffset\":7200000,\"version\":1,\"wasBasalIOBUsed\":true,\"wasBolusIOBUsed\":true,\"wasCOBUsed\":true,\"wasGlucoseUsed\":true,\"wasSuperbolusUsed\":false,\"wasTempTargetUsed\":false,\"wasTrendUsed\":true,\"wereCarbsUsed\":false}", +"date": 1626202783325, +"glucose": 134, +"units": "mg/dl", +"notes": "", +"identifier": "60ede2a4c574da0004a3869d", +"srvModified": 1626202783325, +"srvCreated": 1626202783325 +}, +DEVICE STATUS with configuration +--------------------------------- +{ +"_id": "635abf2069a34517e83768cd", +"created_at": "2022-10-27T17:25:49.730Z", +"device": "openaps://samsung SM-G970F", +"pump": { +"battery": { +"percent": 100 +}, +"status": { +"status": "normal", +"timestamp": "2022-10-27T17:16:11.504Z" +}, +"extended": { +"Version": "3.1.0.3-dev-c-nscv3-8da78d7351-2022.10.25-19:56", +"LastBolus": "10/27/22 18:40", +"LastBolusAmount": 0.35, +"TempBasalAbsoluteRate": 0, +"TempBasalStart": "10/27/22 18:50", +"TempBasalRemaining": 24, +"BaseBasalRate": 1, +"ActiveProfile": "LocalProfile1" +}, +"reservoir": 191, +"clock": "2022-10-27T17:25:49.759Z" +}, +"openaps": { +"suggested": { +"temp": "absolute", +"bg": 72, +"tick": -6, +"eventualBG": 4, +"snoozeBG": 4, +"predBGs": { +"IOB": [72, 61, 51, 42, 39, 39, 39, 39, 39, 39, 39, 39, 39] +}, +"COB": 0, +"IOB": 0.052, +"reason": "COB: 0, Dev: -66, BGI: -0.88, ISF: 2.0, Target: 6.0; BG 4.0<4.4, but 25m left and 0 ~ req 0U/hr: no action required", +"timestamp": "2022-10-27T17:25:49.726Z" +}, +"iob": { +"iob": 0.052, +"basaliob": 0.052, +"activity": 0.0049, +"time": "2022-10-27T17:25:49.726Z" +} +}, +"uploaderBattery": 100, +"configuration": { +"insulin": 5, +"insulinConfiguration": {}, +"sensitivity": 2, +"sensitivityConfiguration": { +"openapsama_min_5m_carbimpact": 10, +"absorption_cutoff": 4, +"autosens_max": 1.2, +"autosens_min": 0.7 +}, +"overviewConfiguration": { +"units": "mmol", +"eatingsoon_duration": 0, +"eatingsoon_target": 0, +"activity_duration": 0, +"activity_target": 0, +"hypo_duration": 0, +"hypo_target": 0, +"low_mark": 4, +"high_mark": 0, +"statuslights_cage_warning": 48, +"statuslights_cage_critical": 72, +"statuslights_iage_warning": 72, +"statuslights_iage_critical": 144, +"statuslights_sage_warning": 216, +"statuslights_sage_critical": 240, +"statuslights_sbat_warning": 25, +"statuslights_sbat_critical": 5, +"statuslights_bage_warning": 216, +"statuslights_bage_critical": 240, +"statuslights_res_warning": 80, +"statuslights_res_critical": 10, +"statuslights_bat_warning": 25, +"statuslights_bat_critical": 5, +"boluswizard_percentage": 60 +}, +"safetyConfiguration": { +"age": "teenage", +"treatmentssafety_maxbolus": 4, +"treatmentssafety_maxcarbs": 60 +}, +"pump": "DanaR", +"version": "3.1.0.3-dev-c-nscv3" +}, +"mills": 1666891549730 +} \ No newline at end of file diff --git a/core/nssdk/src/main/kotlin/app/aaps/core/nssdk/utils/CoroutineUtils.kt b/core/nssdk/src/commonMain/kotlin/app/aaps/core/nssdk/utils/CoroutineUtils.kt similarity index 89% rename from core/nssdk/src/main/kotlin/app/aaps/core/nssdk/utils/CoroutineUtils.kt rename to core/nssdk/src/commonMain/kotlin/app/aaps/core/nssdk/utils/CoroutineUtils.kt index d94a92892052..8c97a745f71e 100644 --- a/core/nssdk/src/main/kotlin/app/aaps/core/nssdk/utils/CoroutineUtils.kt +++ b/core/nssdk/src/commonMain/kotlin/app/aaps/core/nssdk/utils/CoroutineUtils.kt @@ -7,7 +7,7 @@ import kotlin.reflect.KClass internal suspend fun retry( numberOfRetries: Int, delayBetweenRetries: Long, - excludedExceptions: List>, + excludedExceptions: List>, block: suspend () -> T ): T { repeat(numberOfRetries) { diff --git a/core/nssdk/src/commonMain/kotlin/app/aaps/core/nssdk/utils/IoDispatcher.kt b/core/nssdk/src/commonMain/kotlin/app/aaps/core/nssdk/utils/IoDispatcher.kt new file mode 100644 index 000000000000..7bfc131bf5de --- /dev/null +++ b/core/nssdk/src/commonMain/kotlin/app/aaps/core/nssdk/utils/IoDispatcher.kt @@ -0,0 +1,12 @@ +package app.aaps.core.nssdk.utils + +import kotlinx.coroutines.CoroutineDispatcher + +/** + * The dispatcher network calls run on. + * + * `Dispatchers.IO` is not part of the common coroutines API - it exists on JVM and on Native, but not + * as one shared declaration - so it is selected per platform here. The JVM actual is the same + * `Dispatchers.IO` this client has always used, so nothing about threading changes on Android. + */ +internal expect val nsIoDispatcher: CoroutineDispatcher diff --git a/core/nssdk/src/commonMain/kotlin/app/aaps/core/nssdk/utils/ListUtils.kt b/core/nssdk/src/commonMain/kotlin/app/aaps/core/nssdk/utils/ListUtils.kt new file mode 100644 index 000000000000..85797943dbc0 --- /dev/null +++ b/core/nssdk/src/commonMain/kotlin/app/aaps/core/nssdk/utils/ListUtils.kt @@ -0,0 +1,5 @@ +package app.aaps.core.nssdk.utils + +// @JvmSynthetic was here to hide this helper from Java callers. It is a JVM-only annotation, and the +// function is `internal` anyway, so it is dropped rather than made platform specific. +internal fun List?.toNotNull(): List = this?.filterNotNull() ?: listOf() diff --git a/core/nssdk/src/iosMain/kotlin/app/aaps/core/nssdk/networking/NsHttpClient.ios.kt b/core/nssdk/src/iosMain/kotlin/app/aaps/core/nssdk/networking/NsHttpClient.ios.kt new file mode 100644 index 000000000000..262dd01243b3 --- /dev/null +++ b/core/nssdk/src/iosMain/kotlin/app/aaps/core/nssdk/networking/NsHttpClient.ios.kt @@ -0,0 +1,22 @@ +package app.aaps.core.nssdk.networking + +import io.ktor.client.HttpClient +import io.ktor.client.HttpClientConfig +import io.ktor.client.engine.darwin.Darwin + +/** + * Apple engine: **Darwin**, which runs on `NSURLSession`. That is what an iOS build wants - it gets + * the system's own connection handling, proxy settings and certificate validation rather than a + * second HTTP stack, exactly as the JVM side reuses the OkHttp the app already ships. + * + * Request logging is still not wired up here. On JVM it is an OkHttp interceptor, which has no + * Darwin counterpart; the portable answer is Ktor's own `Logging` plugin, and that is worth adding + * when there is an iOS client to read the log. + */ +internal actual fun nsHttpClient( + logging: Boolean, + logger: (String) -> Unit, + configure: HttpClientConfig<*>.() -> Unit +): HttpClient = HttpClient(Darwin) { + configure() +} diff --git a/core/nssdk/src/iosMain/kotlin/app/aaps/core/nssdk/utils/IoDispatcher.ios.kt b/core/nssdk/src/iosMain/kotlin/app/aaps/core/nssdk/utils/IoDispatcher.ios.kt new file mode 100644 index 000000000000..05a0314cfd41 --- /dev/null +++ b/core/nssdk/src/iosMain/kotlin/app/aaps/core/nssdk/utils/IoDispatcher.ios.kt @@ -0,0 +1,11 @@ +package app.aaps.core.nssdk.utils + +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.IO + +/** + * Kotlin/Native does provide `Dispatchers.IO` on Apple targets, so blocking work gets its own pool + * here rather than sharing the default one the way the mingw stand-in has to. + */ +internal actual val nsIoDispatcher: CoroutineDispatcher = Dispatchers.IO diff --git a/core/nssdk/src/jvmMain/kotlin/app/aaps/core/nssdk/networking/NsHttpClient.jvm.kt b/core/nssdk/src/jvmMain/kotlin/app/aaps/core/nssdk/networking/NsHttpClient.jvm.kt new file mode 100644 index 000000000000..e6eb1341490f --- /dev/null +++ b/core/nssdk/src/jvmMain/kotlin/app/aaps/core/nssdk/networking/NsHttpClient.jvm.kt @@ -0,0 +1,33 @@ +package app.aaps.core.nssdk.networking + +import io.ktor.client.HttpClient +import io.ktor.client.HttpClientConfig +import io.ktor.client.engine.okhttp.OkHttp + +/** + * JVM and Android engine: **OkHttp**. + * + * The app already ships OkHttp for other things, so using it here adds Ktor's own layers rather than + * a second HTTP stack - which was the point of choosing this engine over CIO. + * + * The logging interceptor is OkHttp's, which is why request logging lives in the actual rather than + * in the shared configuration. An Apple target would install Darwin here and log differently. + */ +internal actual fun nsHttpClient( + logging: Boolean, + logger: (String) -> Unit, + configure: HttpClientConfig<*>.() -> Unit +): HttpClient = HttpClient(OkHttp) { + configure() + if (logging) { + engine { + addInterceptor { chain -> + val request = chain.request() + logger("--> ${request.method} ${request.url}") + val response = chain.proceed(request) + logger("<-- ${response.code} ${request.url}") + response + } + } + } +} diff --git a/core/nssdk/src/main/kotlin/app/aaps/core/nssdk/utils/ClientControlCrypto.kt b/core/nssdk/src/jvmMain/kotlin/app/aaps/core/nssdk/utils/ClientControlCrypto.kt similarity index 97% rename from core/nssdk/src/main/kotlin/app/aaps/core/nssdk/utils/ClientControlCrypto.kt rename to core/nssdk/src/jvmMain/kotlin/app/aaps/core/nssdk/utils/ClientControlCrypto.kt index e858f95cdac4..fa79ac4fe99f 100644 --- a/core/nssdk/src/main/kotlin/app/aaps/core/nssdk/utils/ClientControlCrypto.kt +++ b/core/nssdk/src/jvmMain/kotlin/app/aaps/core/nssdk/utils/ClientControlCrypto.kt @@ -3,6 +3,8 @@ package app.aaps.core.nssdk.utils import app.aaps.core.nssdk.localmodel.clientcontrol.AckEnvelope import app.aaps.core.nssdk.localmodel.clientcontrol.ProgressEnvelope import app.aaps.core.nssdk.localmodel.clientcontrol.SignedEnvelope +import app.aaps.core.nssdk.utils.ClientControlCrypto.bytesToHex +import app.aaps.core.nssdk.utils.ClientControlCrypto.hexToBytes import app.aaps.core.nssdk.utils.ClientControlCrypto.timestampWithinSkew import java.security.MessageDigest import java.security.SecureRandom diff --git a/core/nssdk/src/main/kotlin/app/aaps/core/nssdk/utils/ClientControlPairingCrypto.kt b/core/nssdk/src/jvmMain/kotlin/app/aaps/core/nssdk/utils/ClientControlPairingCrypto.kt similarity index 100% rename from core/nssdk/src/main/kotlin/app/aaps/core/nssdk/utils/ClientControlPairingCrypto.kt rename to core/nssdk/src/jvmMain/kotlin/app/aaps/core/nssdk/utils/ClientControlPairingCrypto.kt diff --git a/core/nssdk/src/jvmMain/kotlin/app/aaps/core/nssdk/utils/IoDispatcher.jvm.kt b/core/nssdk/src/jvmMain/kotlin/app/aaps/core/nssdk/utils/IoDispatcher.jvm.kt new file mode 100644 index 000000000000..56234b09194e --- /dev/null +++ b/core/nssdk/src/jvmMain/kotlin/app/aaps/core/nssdk/utils/IoDispatcher.jvm.kt @@ -0,0 +1,7 @@ +package app.aaps.core.nssdk.utils + +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers + +/** Unchanged from before the multiplatform split. */ +internal actual val nsIoDispatcher: CoroutineDispatcher = Dispatchers.IO diff --git a/core/nssdk/src/jvmTest/kotlin/app/aaps/core/nssdk/NsSdkWireFormatTest.kt b/core/nssdk/src/jvmTest/kotlin/app/aaps/core/nssdk/NsSdkWireFormatTest.kt new file mode 100644 index 000000000000..52e37ad24f09 --- /dev/null +++ b/core/nssdk/src/jvmTest/kotlin/app/aaps/core/nssdk/NsSdkWireFormatTest.kt @@ -0,0 +1,151 @@ +package app.aaps.core.nssdk + +import app.aaps.core.nssdk.localmodel.treatment.EventType +import app.aaps.core.nssdk.remotemodel.LastModified +import app.aaps.core.nssdk.remotemodel.RemoteTreatment +import com.google.common.truth.Truth.assertThat +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import org.junit.jupiter.api.Test + +/** + * Pins what AAPS puts **on the wire**, not what it reads off it. + * + * Backward compatibility depends on this side. An AAPS instance uploads documents that are read by + * Nightscout's own validation, by other uploaders, and by AAPS clients running an older version - + * none of which update at the same time as this app. A field that silently stops being written is + * not a local problem, it is a problem on every reader that has not been updated. + * + * The specific hazard is that kotlinx's default is the **opposite** of Gson's. Gson writes every + * non-null field. kotlinx omits any field whose value equals its default unless `encodeDefaults` is + * on, so `0`, `false` and every field that was given a `= null` default would quietly disappear. + * See [app.aaps.core.nssdk.nsSdkJson]. + */ +class NsSdkWireFormatTest { + + private fun encoded(treatment: RemoteTreatment) = + Json.parseToJsonElement(nsSdkJson.encodeToString(RemoteTreatment.serializer(), treatment)).jsonObject + + // ---------------------------------------------------------------- field names + + /** The wire names are the `@SerialName` ones, not the Kotlin property names. */ + @Test + fun `wire names are used, not property names`() { + val json = encoded( + RemoteTreatment( + eventType = EventType.CORRECTION_BOLUS, + date = 1785992179555, + created_at = "2026-08-06T04:56:19.555Z", + utcOffset = 120, + insulin = 0.25, + isValid = true, + isReadOnly = false + ) + ) + + assertThat(json.keys).containsAtLeast("eventType", "date", "created_at", "utcOffset", "insulin", "isValid", "isReadOnly") + // the Kotlin name must not leak onto the wire + assertThat(json.keys).doesNotContain("createdAt") + } + + // ---------------------------------------------------------------- values that equal a default + + /** + * The regression this file exists for. `false` and `0` are real values a reader needs, and both + * are also the natural default for their type - exactly what kotlinx would drop. + */ + @Test + fun `false and zero are written, not omitted`() { + val json = encoded( + RemoteTreatment( + eventType = EventType.TEMPORARY_TARGET, + date = 1785994614220, + isValid = true, + isReadOnly = false, // false, not absent + utcOffset = 0, // zero, not absent + duration = 0 // zero, not absent + ) + ) + + assertThat(json.keys).containsAtLeast("isReadOnly", "utcOffset", "duration") + assertThat(json["isReadOnly"]?.jsonPrimitive?.content).isEqualTo("false") + assertThat(json["utcOffset"]?.jsonPrimitive?.content).isEqualTo("0") + assertThat(json["duration"]?.jsonPrimitive?.content).isEqualTo("0") + } + + /** Same hazard on a model whose defaults are non-null: every counter must be written. */ + @Test + fun `zero collection timestamps are written`() { + val json = Json.parseToJsonElement( + nsSdkJson.encodeToString(LastModified.serializer(), LastModified(LastModified.Collections())) + ).jsonObject["collections"]!!.jsonObject + + assertThat(json.keys).containsExactly("devicestatus", "entries", "profile", "treatments", "foods", "settings") + assertThat(json["devicestatus"]?.jsonPrimitive?.content).isEqualTo("0") + assertThat(json["treatments"]?.jsonPrimitive?.content).isEqualTo("0") + } + + // ---------------------------------------------------------------- nulls stay off the wire + + /** + * The other half of matching Gson: a null field is omitted rather than written as + * `"field": null`. Nightscout validation rejects some explicit nulls, so this is not cosmetic. + */ + @Test + fun `null fields are omitted rather than written as null`() { + val json = encoded(RemoteTreatment(eventType = EventType.CARBS_CORRECTION, date = 1785987360000, carbs = 4.0)) + + assertThat(json.keys).containsAtLeast("eventType", "date", "carbs") + assertThat(json.keys).doesNotContain("insulin") + assertThat(json.keys).doesNotContain("notes") + assertThat(json.keys).doesNotContain("pumpSerial") + } + + // ---------------------------------------------------------------- enums + + /** Enums go out as their Nightscout text, not as the Kotlin constant name. */ + @Test + fun `event types are written as their Nightscout text`() { + assertThat(encoded(RemoteTreatment(eventType = EventType.CORRECTION_BOLUS))["eventType"]?.jsonPrimitive?.content) + .isEqualTo("Correction Bolus") + assertThat(encoded(RemoteTreatment(eventType = EventType.TEMPORARY_TARGET))["eventType"]?.jsonPrimitive?.content) + .isEqualTo("Temporary Target") + assertThat(encoded(RemoteTreatment(eventType = EventType.TEMPORARY_BASAL))["eventType"]?.jsonPrimitive?.content) + .isEqualTo("Temp Basal") + } + + // ---------------------------------------------------------------- numbers + + /** A whole number in a Double field keeps its decimal point, as Gson wrote it. */ + @Test + fun `numbers keep their form`() { + val json = encoded(RemoteTreatment(eventType = EventType.CARBS_CORRECTION, carbs = 4.0, insulin = 0.25)) + + assertThat(json["carbs"]?.jsonPrimitive?.content).isEqualTo("4.0") + assertThat(json["insulin"]?.jsonPrimitive?.content).isEqualTo("0.25") + } + + // ---------------------------------------------------------------- round trip against a real record + + /** + * A real uploaded record, parsed and written back out. Every field the wire model knows about has + * to come back with the same value - this is what an older reader will be looking at. + */ + @Test + fun `a real record survives being read and written again`() { + val original = """ + {"app":"AAPS","date":1785992179555,"eventType":"Correction Bolus","insulin":0.25, + "isBasalInsulin":false,"isReadOnly":false,"isValid":true,"pumpId":1785992181495, + "pumpSerial":"TESTSERIAL","pumpType":"DANA_RS","type":"SMB","utcOffset":120, + "created_at":"2026-08-06T04:56:19.555Z","identifier":"1bebd84b-8d61-58b2-a967-1b34a1e8c4d4"} + """.trimIndent() + + val parsed = nsSdkJson.decodeFromString(RemoteTreatment.serializer(), original) + val written = encoded(parsed) + val before = Json.parseToJsonElement(original).jsonObject + + for (key in before.keys) + assertThat(written[key]).isEqualTo(before[key]) + } +} diff --git a/core/nssdk/src/jvmTest/kotlin/app/aaps/core/nssdk/exceptions/NightscoutExceptionTest.kt b/core/nssdk/src/jvmTest/kotlin/app/aaps/core/nssdk/exceptions/NightscoutExceptionTest.kt new file mode 100644 index 000000000000..1949df2eea16 --- /dev/null +++ b/core/nssdk/src/jvmTest/kotlin/app/aaps/core/nssdk/exceptions/NightscoutExceptionTest.kt @@ -0,0 +1,69 @@ +package app.aaps.core.nssdk.exceptions + +import com.google.common.truth.Truth.assertThat +import org.junit.jupiter.api.Test + +/** + * Asserts that every exception this client throws is still a `java.io.IOException` on the JVM. + * + * The base class moved from `java.io.IOException` to `kotlinx.io.IOException` so the module can build + * for iOS. On the JVM kotlinx-io declares + * `actual typealias IOException = java.io.IOException`, so nothing changes - but "so it should be + * fine" is exactly the reasoning that let the Gson regression through, and the failure mode here is + * ugly: a `catch (e: IOException)` that silently stops catching. The compiler would not complain, + * and neither would any other test. + * + * Three call sites depend on it: + * - `PairingOfferFetcher.fetchAndUnwrap` - a network failure while looking for pairing offers must + * surface as "no offer", not as a crash on the pairing screen. + * - `PairingOfferPublisher.publishOffer` / `deleteOffer` - `deleteOffer` must never throw, because a + * pairing offer left behind on the server keeps a PIN brute-force window open. + * + * If this file ever fails, do not change the assertion - the exception hierarchy has silently + * stopped being catchable and those three sites need looking at. + */ +class NightscoutExceptionTest { + + private val all = listOf( + DateHeaderOutOfToleranceException("m"), + InvalidAccessTokenException("m"), + InvalidFormatNightscoutException("m"), + InvalidParameterNightscoutException("m"), + UnknownResponseNightscoutException("m"), + UnsuccessfulNightscoutException("m") + ) + + @Test + fun `every exception is a java IOException`() { + for (exception in all) + assertThat(exception).isInstanceOf(java.io.IOException::class.java) + } + + /** The catch sites are written against `java.io.IOException`, so prove that shape works. */ + @Test + fun `each one is caught by a java IOException catch block`() { + for (exception in all) { + var caught = false + try { + throw exception + } catch (_: java.io.IOException) { + caught = true + } + assertThat(caught).isTrue() + } + } + + /** They all keep a non-null message - workers write it straight into the user visible log. */ + @Test + fun `every exception carries its message`() { + for (exception in all) + assertThat(exception.message).isEqualTo("m") + } + + /** And they are all still `NightscoutException`, which is what the retry exclusion list matches on. */ + @Test + fun `every exception is a NightscoutException`() { + for (exception in all) + assertThat(exception).isInstanceOf(NightscoutException::class.java) + } +} diff --git a/core/nssdk/src/test/kotlin/app/aaps/core/nssdk/mapper/ApiPermissionMapperTest.kt b/core/nssdk/src/jvmTest/kotlin/app/aaps/core/nssdk/mapper/ApiPermissionMapperTest.kt similarity index 100% rename from core/nssdk/src/test/kotlin/app/aaps/core/nssdk/mapper/ApiPermissionMapperTest.kt rename to core/nssdk/src/jvmTest/kotlin/app/aaps/core/nssdk/mapper/ApiPermissionMapperTest.kt diff --git a/core/nssdk/src/jvmTest/kotlin/app/aaps/core/nssdk/mapper/CreatedAtParsingTest.kt b/core/nssdk/src/jvmTest/kotlin/app/aaps/core/nssdk/mapper/CreatedAtParsingTest.kt new file mode 100644 index 000000000000..574db40b2486 --- /dev/null +++ b/core/nssdk/src/jvmTest/kotlin/app/aaps/core/nssdk/mapper/CreatedAtParsingTest.kt @@ -0,0 +1,103 @@ +package app.aaps.core.nssdk.mapper + +import app.aaps.core.nssdk.nsSdkJson +import app.aaps.core.nssdk.remotemodel.RemoteTreatment +import com.google.common.truth.Truth.assertThat +import org.junit.jupiter.api.Test +import java.util.TimeZone + +/** + * Pins every `created_at` shape the parser has to cope with. + * + * This exists so joda can be removed from `:core:nssdk` without guessing. `ISODateTimeFormat + * .dateTimeParser()` is deliberately **lenient** - it accepts far more than RFC 3339 - while + * `kotlinx.datetime.Instant.parse` is strict. A straight swap would silently start returning `0L` + * for shapes that parse today, and `0L` means the treatment lands in 1970 with no error anywhere. + * + * `created_at` is only consulted when `date`, `mills` and `timestamp` are all absent, which AAPS + * never produces - but older API v1 documents and other uploaders do, and those are exactly the + * records with unusual date formats. + * + * Two of these shapes carry **no zone at all** and are therefore read as **local** time. Their + * expected value is computed from the machine's own offset rather than hard coded, so the test says + * what it means ("this is local time") and passes anywhere. Note that the JVM default zone cannot be + * swapped inside the test to check this: joda caches its own `DateTimeZone` default at class init + * and ignores a later `TimeZone.setDefault`. + */ +class CreatedAtParsingTest { + + private fun timestampOf(createdAt: String): Long = + nsSdkJson.decodeFromString( + RemoteTreatment.serializer(), + """{"eventType":"Correction Bolus","created_at":"$createdAt"}""" + ).timestamp() + + // 2026-08-06T04:56:19.555Z == 1785992179555 + private val utcMillis = 1785992179555L + private val midnightUtc = 1785974400000L + + /** Local offset at that moment, so the zone-less expectations hold in any time zone. */ + private fun offsetAt(instant: Long) = TimeZone.getDefault().getOffset(instant).toLong() + + @Test + fun `the shapes that must keep working`() { + val results = linkedMapOf( + "full UTC" to timestampOf("2026-08-06T04:56:19.555Z"), + "no millis" to timestampOf("2026-08-06T04:56:19Z"), + "offset with colon" to timestampOf("2026-08-06T06:56:19.555+02:00"), + "offset without colon" to timestampOf("2026-08-06T06:56:19.555+0200"), + "negative offset" to timestampOf("2026-08-06T00:56:19.555-04:00"), + "no zone at all" to timestampOf("2026-08-06T04:56:19.555"), + "no seconds" to timestampOf("2026-08-06T04:56Z"), + "date only" to timestampOf("2026-08-06"), + "one decimal" to timestampOf("2026-08-06T04:56:19.5Z"), + "lowercase t and z" to timestampOf("2026-08-06t04:56:19.555z") + ) + + // Asserted as one map so a single run shows the whole picture rather than the first failure. + assertThat(results).isEqualTo( + linkedMapOf( + "full UTC" to utcMillis, + "no millis" to 1785992179000L, + "offset with colon" to utcMillis, + "offset without colon" to utcMillis, + "negative offset" to utcMillis, + // no zone -> local wall clock, so the instant is earlier by the local offset + "no zone at all" to utcMillis - offsetAt(utcMillis), + "no seconds" to 1785992160000L, + "date only" to midnightUtc - offsetAt(midnightUtc), // local midnight + "one decimal" to 1785992179500L, + "lowercase t and z" to utcMillis + ) + ) + } + + /** + * The zone-less case stated on its own, because it is the one a stricter parser would get wrong + * in a way nothing else would notice: `Instant.parse` rejects it outright, and treating it as UTC + * would silently shift the treatment by the local offset. + */ + @Test + fun `a created_at without a zone is read as local time, not UTC`() { + val parsed = timestampOf("2026-08-06T04:56:19.555") + + assertThat(parsed).isEqualTo(utcMillis - offsetAt(utcMillis)) + // and it really is offset-dependent unless the machine happens to run on UTC + if (offsetAt(utcMillis) != 0L) assertThat(parsed).isNotEqualTo(utcMillis) + } + + /** Anything unparseable gives 0 rather than throwing - the record survives, dated 1970. */ + @Test + fun `unparseable text gives zero and does not throw`() { + assertThat(timestampOf("whenever")).isEqualTo(0L) + assertThat(timestampOf("")).isEqualTo(0L) + assertThat(timestampOf("not-a-date-at-all")).isEqualTo(0L) + } + + /** The epoch forms, which are handled before the ISO parser is reached. */ + @Test + fun `epoch forms are handled before ISO parsing`() { + assertThat(timestampOf("1785992179555")).isEqualTo(1785992179555L) + assertThat(timestampOf(" 1785992179555 ")).isEqualTo(1785992179555L) + } +} diff --git a/core/nssdk/src/jvmTest/kotlin/app/aaps/core/nssdk/mapper/DeviceStatusMapperTest.kt b/core/nssdk/src/jvmTest/kotlin/app/aaps/core/nssdk/mapper/DeviceStatusMapperTest.kt new file mode 100644 index 000000000000..b17922b4e71b --- /dev/null +++ b/core/nssdk/src/jvmTest/kotlin/app/aaps/core/nssdk/mapper/DeviceStatusMapperTest.kt @@ -0,0 +1,228 @@ +package app.aaps.core.nssdk.mapper + +import com.google.common.truth.Truth.assertThat +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import org.junit.jupiter.api.Test + +/** + * Device status carries **schema-less** JSON: `pump.extended`, `openaps.suggested`, `openaps.enacted` + * and `openaps.iob` hold whatever the pump driver and the APS algorithm put there. AAPS does not + * know their shape, it only has to carry them through without losing anything. + * + * These tests were written while the wire model still held those subtrees as **Gson** `JsonObject` + * and the local model held them as **kotlinx** `JsonObject`, so `DeviceStatusMapper` rebuilt every + * subtree by printing it to text and parsing it back. Both sides are kotlinx now and the subtrees + * are passed straight through, which is why they still pass unchanged - that was the point of + * asserting on parsed structure rather than on `toString()`. + * + * The last test is the important one: since the schema is unknown, a rewrite that quietly drops keys + * it does not recognise would pass every other assertion here. + */ +class DeviceStatusMapperTest { + + private val deviceStatusJson = """ + { + "app": "AAPS", + "identifier": "abc123", + "device": "AndroidAPS-DanaRS", + "created_at": "2023-01-02T15:29:39.460Z", + "date": 1672673379460, + "uploaderBattery": 88, + "isCharging": false, + "pump": { + "clock": "2023-01-02T15:29:39.460Z", + "reservoir": 123.4, + "battery": { "percent": 75 }, + "status": { "status": "normal", "timestamp": "2023-01-02T15:20:20.656Z" }, + "extended": { + "Version": "3.1.0.3-dev", + "PumpIOB": 0.692, + "LastBolus": "2.1.2023 16:20:20", + "BaseBasalRate": 0.5, + "ActiveProfile": "MyProfile" + } + }, + "openaps": { + "suggested": { + "temp": "absolute", + "bg": 133, + "tick": -6, + "eventualBG": 67, + "insulinReq": 0, + "sensitivityRatio": 1, + "variable_sens": 97.5, + "predBGs": { + "IOB": [133, 127, 121, 116, 111], + "ZT": [133, 127, 121, 115, 110], + "UAM": [133, 127, 121, 115, 110] + }, + "reason": "COB: 0, Dev: 0.1, BGI: -0.3; minGuardBG 2.1<4.0", + "COB": 0, + "IOB": 0.692, + "duration": 90, + "rate": 0 + }, + "enacted": { "temp": "absolute", "duration": 90, "rate": 0, "received": true }, + "iob": { "iob": 0.692, "basaliob": -0.411, "activity": 0.0126 } + } + } + """.trimIndent() + + // ------------------------------------------------------------------ parsing + + @Test + fun `parses the flat fields`() { + val status = deviceStatusJson.toNSDeviceStatus() + assertThat(status.app).isEqualTo("AAPS") + assertThat(status.device).isEqualTo("AndroidAPS-DanaRS") + assertThat(status.uploaderBattery).isEqualTo(88) + assertThat(status.pump?.battery?.percent).isEqualTo(75) + assertThat(status.pump?.status?.status).isEqualTo("normal") + } + + @Test + fun `keeps the schema-less pump extended subtree`() { + val extended = deviceStatusJson.toNSDeviceStatus().pump?.extended + assertThat(extended).isNotNull() + assertThat(extended!!.keys).containsExactly( + "Version", "PumpIOB", "LastBolus", "BaseBasalRate", "ActiveProfile" + ) + assertThat(extended["Version"]?.jsonPrimitive?.content).isEqualTo("3.1.0.3-dev") + assertThat(extended["PumpIOB"]?.jsonPrimitive?.content).isEqualTo("0.692") + } + + @Test + fun `keeps nesting three levels deep`() { + val suggested = deviceStatusJson.toNSDeviceStatus().openaps?.suggested + assertThat(suggested).isNotNull() + + val predBGs = suggested!!["predBGs"]?.jsonObject + assertThat(predBGs).isNotNull() + assertThat(predBGs!!.keys).containsExactly("IOB", "ZT", "UAM") + + val iobCurve = predBGs["IOB"]!!.jsonArray + assertThat(iobCurve).hasSize(5) + assertThat(iobCurve[0].jsonPrimitive.content).isEqualTo("133") + assertThat(iobCurve[4].jsonPrimitive.content).isEqualTo("111") + } + + @Test + fun `keeps numbers exactly as sent`() { + val suggested = deviceStatusJson.toNSDeviceStatus().openaps?.suggested!! + // A whole number must not gain a decimal point, and a decimal must not lose precision. + assertThat(suggested["bg"]?.jsonPrimitive?.content).isEqualTo("133") + assertThat(suggested["COB"]?.jsonPrimitive?.content).isEqualTo("0") + assertThat(suggested["tick"]?.jsonPrimitive?.content).isEqualTo("-6") + assertThat(suggested["variable_sens"]?.jsonPrimitive?.content).isEqualTo("97.5") + assertThat(suggested["IOB"]?.jsonPrimitive?.content).isEqualTo("0.692") + assertThat(deviceStatusJson.toNSDeviceStatus().openaps?.iob?.get("basaliob")?.jsonPrimitive?.content) + .isEqualTo("-0.411") + } + + @Test + fun `keeps a long reason string with punctuation`() { + val suggested = deviceStatusJson.toNSDeviceStatus().openaps?.suggested!! + assertThat(suggested["reason"]?.jsonPrimitive?.content) + .isEqualTo("COB: 0, Dev: 0.1, BGI: -0.3; minGuardBG 2.1<4.0") + } + + // ------------------------------------------------------------------ round trip + + @Test + fun `subtrees survive a round trip through the wire format`() { + val first = deviceStatusJson.toNSDeviceStatus() + val second = first.convertToRemoteAndBack() + + assertSameTree(first.pump?.extended, second.pump?.extended) + assertSameTree(first.openaps?.suggested, second.openaps?.suggested) + assertSameTree(first.openaps?.enacted, second.openaps?.enacted) + assertSameTree(first.openaps?.iob, second.openaps?.iob) + + // and the nesting specifically + val before = first.openaps?.suggested!!["predBGs"]!!.jsonObject["IOB"]!!.jsonArray + val after = second.openaps?.suggested!!["predBGs"]!!.jsonObject["IOB"]!!.jsonArray + assertThat(after).hasSize(before.size) + assertThat(after[0].jsonPrimitive.content).isEqualTo(before[0].jsonPrimitive.content) + } + + @Test + fun `missing subtrees stay null`() { + val json = """{"app":"AAPS","device":"d","date":1672673379460}""" + val status = json.toNSDeviceStatus() + assertThat(status.pump).isNull() + assertThat(status.openaps).isNull() + } + + @Test + fun `an empty subtree stays empty and does not become null`() { + val json = """{"app":"AAPS","date":1,"openaps":{"suggested":{}}}""" + val openaps = json.toNSDeviceStatus().openaps + assertThat(openaps?.suggested).isNotNull() + assertThat(openaps?.suggested?.keys).isEmpty() + assertThat(openaps?.enacted).isNull() // absent key -> null + } + + /** + * An **explicit** `null` subtree now decodes to Kotlin `null` instead of throwing. + * + * This is the deliberate behaviour change the previous version of this test predicted. Under + * Gson the field was declared `JsonObject?`, but Gson's adapter for the concrete `JsonObject` + * type rejected `JsonNull` rather than mapping it to `null`, so an absent key was fine and a + * written `null` threw. kotlinx maps it to `null`, which is what the declared type says. + * + * Changed on purpose, and it is an improvement: the caller + * `NSClientV3Service.onDataCreateUpdate` is a socket.io listener with no try/catch, so the old + * exception escaped onto the socket callback thread and lost the whole device status. AAPS never + * writes `null` here, but nothing stops another uploader from doing so. + */ + @Test + fun `an explicit null subtree decodes to null - changed on purpose`() { + val json = """{"app":"AAPS","date":1,"openaps":{"suggested":{},"enacted":null}}""" + + val openaps = json.toNSDeviceStatus().openaps + assertThat(openaps?.suggested).isNotNull() + assertThat(openaps?.enacted).isNull() + } + + /** + * The point of holding these as raw JSON is that AAPS does not know the schema. A future + * Nightscout, a new pump driver or a newer AAPS may add keys this version has never seen, and + * they must still be carried through untouched. + */ + @Test + fun `unknown keys from a newer sender are preserved`() { + val json = """ + {"app":"AAPS","date":1, + "openaps":{"suggested":{ + "bg":133, + "somethingFromTheFuture":"keep me", + "nestedFuture":{"deep":{"deeper":[1,2,3]}} + }}} + """.trimIndent() + + val suggested = json.toNSDeviceStatus().openaps?.suggested!! + assertThat(suggested["somethingFromTheFuture"]?.jsonPrimitive?.content).isEqualTo("keep me") + assertThat(suggested["nestedFuture"]?.jsonObject?.get("deep")?.jsonObject?.get("deeper")?.jsonArray) + .hasSize(3) + + // and they must still be there after a trip through the wire model + val back = json.toNSDeviceStatus().convertToRemoteAndBack().openaps?.suggested!! + assertThat(back["somethingFromTheFuture"]?.jsonPrimitive?.content).isEqualTo("keep me") + assertThat(back["nestedFuture"]?.jsonObject?.get("deep")?.jsonObject?.get("deeper")?.jsonArray) + .hasSize(3) + } + + /** Compare two subtrees by content, never by printed form. */ + private fun assertSameTree(expected: JsonObject?, actual: JsonObject?) { + if (expected == null) { + assertThat(actual).isNull() + return + } + assertThat(actual).isNotNull() + assertThat(actual!!.keys).isEqualTo(expected.keys) + for (key in expected.keys) assertThat(actual[key]).isEqualTo(expected[key]) + } +} diff --git a/core/nssdk/src/jvmTest/kotlin/app/aaps/core/nssdk/mapper/FoodAndEntryToleranceTest.kt b/core/nssdk/src/jvmTest/kotlin/app/aaps/core/nssdk/mapper/FoodAndEntryToleranceTest.kt new file mode 100644 index 000000000000..110f8b2503f4 --- /dev/null +++ b/core/nssdk/src/jvmTest/kotlin/app/aaps/core/nssdk/mapper/FoodAndEntryToleranceTest.kt @@ -0,0 +1,137 @@ +package app.aaps.core.nssdk.mapper + +import app.aaps.core.nssdk.nsSdkJson +import app.aaps.core.nssdk.remotemodel.NSResponse +import app.aaps.core.nssdk.remotemodel.RemoteEntry +import app.aaps.core.nssdk.remotemodel.RemoteFood +import com.google.common.truth.Truth.assertThat +import kotlinx.serialization.builtins.ListSerializer +import org.junit.jupiter.api.Test + +/** + * Regression tests for the one thing the Gson to kotlinx move changed that nothing was watching. + * + * Under Gson, a field declared non-null but **without a default** was quietly filled with + * `null` / `0` / `0.0` when the server did not send it, because Gson builds objects through + * `Unsafe.allocateInstance` and never calls the constructor. kotlinx treats exactly the same + * declaration as **mandatory** and throws. + * + * That difference is not per record. `v3/food` and `v3/entries` are decoded as one list, so a single + * unparseable document takes the whole page with it - and the workers only advance their cursor + * after a successful decode, so the same page is requested again forever. + * + * Neither `coerceInputValues` nor `explicitNulls` helps: both only apply to properties that already + * have a default. The fix was to give the affected fields the same values Gson used to leave behind. + * + * These collections are shared and multi-writer - Nightscout's own food editor, xDrip, Loop and + * other uploaders all write to them - so "AAPS always sends this field" is not a safe assumption. + */ +class FoodAndEntryToleranceTest { + + // ---------------------------------------------------------------- food + + /** + * A "quickpick" document. Nightscout stores these in the same `food` collection as real food, + * and they have no `portion` and no `carbs`. `FoodMapper` already has an `else -> return null` + * branch for exactly this - it must be reached, not pre-empted by a parse failure. + */ + @Test + fun `a quickpick food document parses and is then dropped by the mapper`() { + val json = """{"type":"quickpick","name":"Breakfast","foods":[],"hidden":false,"position":0}""" + + val food = nsSdkJson.decodeFromString(RemoteFood.serializer(), json) + assertThat(food.type).isEqualTo("quickpick") + assertThat(food.portion).isEqualTo(0.0) + assertThat(food.carbs).isEqualTo(0) + + // and the mapper drops it, exactly as it did under Gson + assertThat(food.toNSFood()).isNull() + } + + /** + * The case that made this severe: one bad document must not take the good ones with it. This is + * what `v3/food` actually returns - a list, decoded in a single pass. + */ + @Test + fun `a quickpick in the middle of a list does not lose the real food`() { + val json = """ + [{"type":"quickpick","name":"Breakfast","foods":[],"hidden":false}, + {"type":"food","name":"Apple","portion":100.0,"carbs":12,"unit":"g"}, + {"type":"food","name":"Bread","portion":50.0,"carbs":25,"unit":"g"}] + """.trimIndent() + + val foods = nsSdkJson.decodeFromString(ListSerializer(RemoteFood.serializer()), json) + assertThat(foods).hasSize(3) + + val mapped = foods.mapNotNull { it.toNSFood() } + assertThat(mapped).hasSize(2) + assertThat(mapped.map { it.name }).containsExactly("Apple", "Bread") + } + + /** A third-party uploader writing an explicit null instead of omitting the field. */ + @Test + fun `a food document with explicit nulls still parses`() { + val json = """{"type":"food","name":"Apple","portion":100.0,"carbs":null,"unit":null}""" + + val food = nsSdkJson.decodeFromString(RemoteFood.serializer(), json) + assertThat(food.carbs).isEqualTo(0) + assertThat(food.name).isEqualTo("Apple") + } + + /** A history/deleted document, where the server strips the payload. */ + @Test + fun `a stripped food document parses instead of throwing`() { + val json = """{"identifier":"abc123","srvModified":1785992181588,"isValid":false}""" + + val food = nsSdkJson.decodeFromString(RemoteFood.serializer(), json) + assertThat(food.type).isEmpty() + assertThat(food.toNSFood()).isNull() // not type "food" -> dropped, as before + } + + /** The whole response shape the client actually decodes. */ + @Test + fun `a food response wrapper survives a mixed list`() { + val json = """{"result":[{"type":"quickpick","name":"QP"},{"type":"food","name":"Apple","portion":100.0,"carbs":12}]}""" + + val response = nsSdkJson.decodeFromString(NSResponse.serializer(ListSerializer(RemoteFood.serializer())), json) + assertThat(response.result).hasSize(2) + assertThat(response.result?.mapNotNull { it.toNSFood() }).hasSize(1) + } + + // ---------------------------------------------------------------- entries + + /** + * `entries` is the glucose feed. The endpoints the follower uses are **not** filtered by type, + * so a record without `type` reaches the decoder. Losing the page here would stop all glucose. + */ + @Test + fun `an entry without type parses and is skipped by both mappers`() { + val json = """{"date":1785992179555,"sgv":120.0,"device":"someUploader"}""" + + val entry = nsSdkJson.decodeFromString(RemoteEntry.serializer(), json) + assertThat(entry.type).isEmpty() + assertThat(entry.toCalibrationMbg()).isNull() + } + + @Test + fun `an entry with an explicit null type parses`() { + val json = """{"date":1785992179555,"sgv":120.0,"type":null}""" + + val entry = nsSdkJson.decodeFromString(RemoteEntry.serializer(), json) + assertThat(entry.type).isEmpty() + } + + /** One typeless record must not take the real glucose values with it. */ + @Test + fun `a typeless entry in a list does not lose the real readings`() { + val json = """ + [{"date":1785992179555,"sgv":120.0}, + {"date":1785992479555,"sgv":125.0,"type":"sgv","dateString":"2026-08-06T05:01:19.555Z"}, + {"date":1785992779555,"sgv":130.0,"type":"sgv","dateString":"2026-08-06T05:06:19.555Z"}] + """.trimIndent() + + val entries = nsSdkJson.decodeFromString(ListSerializer(RemoteEntry.serializer()), json) + assertThat(entries).hasSize(3) + assertThat(entries.count { it.type == "sgv" }).isEqualTo(2) + } +} diff --git a/core/nssdk/src/jvmTest/kotlin/app/aaps/core/nssdk/mapper/KotlinxCoercionSpikeTest.kt b/core/nssdk/src/jvmTest/kotlin/app/aaps/core/nssdk/mapper/KotlinxCoercionSpikeTest.kt new file mode 100644 index 000000000000..8e08541ec398 --- /dev/null +++ b/core/nssdk/src/jvmTest/kotlin/app/aaps/core/nssdk/mapper/KotlinxCoercionSpikeTest.kt @@ -0,0 +1,129 @@ +package app.aaps.core.nssdk.mapper + +import com.google.common.truth.Truth.assertThat +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json +import org.junit.jupiter.api.Test + +/** + * A spike, not a contract test. + * + * `GsonTypeCoercionTest` pins what Gson does today with a wrong-typed field: it coerces a number + * into a `String`, and a numeric string into `Long` / `Double` / `Int` / `Boolean`. Before rewriting + * 190 annotations, this measures whether kotlinx.serialization can be **configured** to do the same, + * or whether every affected field needs a hand written serializer. Those are very different amounts + * of work, so the answer decides how the converter switch gets done. + * + * The mirror class below only carries the field shapes that matter, not the whole wire model. + */ +class KotlinxCoercionSpikeTest { + + @Serializable + private data class Mirror( + @SerialName("eventType") val eventType: String? = null, + @SerialName("created_at") val createdAt: String? = null, + @SerialName("date") val date: Long? = null, + @SerialName("insulin") val insulin: Double? = null, + @SerialName("percentage") val percentage: Int? = null, + @SerialName("isValid") val isValid: Boolean? = null + ) + + /** Every case from GsonTypeCoercionTest that relies on coercion. */ + private val cases = listOf( + "number into String" to """{"created_at":1785992179555}""", + "numeric string into Long" to """{"date":"1785992179555"}""", + "numeric string into Double" to """{"insulin":"0.25"}""", + "numeric string into Int" to """{"percentage":"90"}""", + "quoted true into Boolean" to """{"isValid":"true"}""", + "integer into Double" to """{"insulin":4}""" + ) + + private fun outcomes(json: Json): Map = + cases.associate { (name, text) -> name to runCatching { json.decodeFromString(Mirror.serializer(), text) }.isSuccess } + + /** + * Strict is the default, and it is far closer to Gson than expected: a **quoted number** is + * already accepted by `Long`, `Double` and `Int` fields, and `"true"` by a `Boolean` field. + * + * Exactly one case differs - a bare number arriving in a `String` field - and that is the one + * `created_at` actually hits in the wild. So the gap between the two libraries here is a single + * behaviour, not the broad class of coercions it first looked like. + */ + @Test + fun `strict kotlinx differs from Gson in exactly one case`() { + val strict = outcomes(Json { ignoreUnknownKeys = true; explicitNulls = false }) + + assertThat(strict).isEqualTo( + mapOf( + "number into String" to false, // <- the only gap + "numeric string into Long" to true, + "numeric string into Double" to true, + "numeric string into Int" to true, + "quoted true into Boolean" to true, + "integer into Double" to true + ) + ) + } + + /** + * The question this spike exists to answer. If lenient covers everything, the converter switch + * is a configuration choice. If it does not, the uncovered cases each need a serializer. + */ + @Test + fun `lenient kotlinx - what it does and does not recover`() { + val lenient = outcomes(Json { ignoreUnknownKeys = true; explicitNulls = false; isLenient = true }) + + // Asserted as a whole map so a failure prints the full picture in one go. + assertThat(lenient).isEqualTo( + mapOf( + "number into String" to true, + "numeric string into Long" to true, + "numeric string into Double" to true, + "numeric string into Int" to true, + "quoted true into Boolean" to true, + "integer into Double" to true + ) + ) + } + + /** Values, not just "did it parse" - a coercion that silently produces the wrong number is worse. */ + @Test + fun `lenient kotlinx produces the same values as Gson`() { + val json = Json { ignoreUnknownKeys = true; explicitNulls = false; isLenient = true } + + assertThat(json.decodeFromString(Mirror.serializer(), """{"created_at":1785992179555}""").createdAt) + .isEqualTo("1785992179555") + assertThat(json.decodeFromString(Mirror.serializer(), """{"date":"1785992179555"}""").date) + .isEqualTo(1785992179555L) + assertThat(json.decodeFromString(Mirror.serializer(), """{"insulin":"0.25"}""").insulin) + .isEqualTo(0.25) + assertThat(json.decodeFromString(Mirror.serializer(), """{"percentage":"90"}""").percentage) + .isEqualTo(90) + assertThat(json.decodeFromString(Mirror.serializer(), """{"isValid":"true"}""").isValid) + .isTrue() + } + + /** Unknown keys and absent fields, the other two things the wire layer depends on. */ + @Test + fun `unknown keys are ignored and absent fields fall back to the default`() { + val json = Json { ignoreUnknownKeys = true; explicitNulls = false; isLenient = true } + val parsed = json.decodeFromString( + Mirror.serializer(), + """{"eventType":"Correction Bolus","somethingFromTheFuture":{"a":[1,2]}}""" + ) + + assertThat(parsed.eventType).isEqualTo("Correction Bolus") + assertThat(parsed.date).isNull() + assertThat(parsed.insulin).isNull() + } + + /** An explicit JSON null must land as Kotlin null, the way Gson leaves it. */ + @Test + fun `explicit nulls decode to null`() { + val json = Json { ignoreUnknownKeys = true; explicitNulls = false; isLenient = true } + val parsed = json.decodeFromString(Mirror.serializer(), """{"insulin":null,"carbs":null}""") + + assertThat(parsed.insulin).isNull() + } +} diff --git a/core/nssdk/src/test/kotlin/app/aaps/core/nssdk/mapper/MbgMapperTest.kt b/core/nssdk/src/jvmTest/kotlin/app/aaps/core/nssdk/mapper/MbgMapperTest.kt similarity index 100% rename from core/nssdk/src/test/kotlin/app/aaps/core/nssdk/mapper/MbgMapperTest.kt rename to core/nssdk/src/jvmTest/kotlin/app/aaps/core/nssdk/mapper/MbgMapperTest.kt diff --git a/core/nssdk/src/jvmTest/kotlin/app/aaps/core/nssdk/mapper/RealNightscoutTreatmentTest.kt b/core/nssdk/src/jvmTest/kotlin/app/aaps/core/nssdk/mapper/RealNightscoutTreatmentTest.kt new file mode 100644 index 000000000000..5465275d27a7 --- /dev/null +++ b/core/nssdk/src/jvmTest/kotlin/app/aaps/core/nssdk/mapper/RealNightscoutTreatmentTest.kt @@ -0,0 +1,188 @@ +package app.aaps.core.nssdk.mapper + +import app.aaps.core.nssdk.localmodel.treatment.NSBolus +import app.aaps.core.nssdk.localmodel.treatment.NSCarbs +import app.aaps.core.nssdk.localmodel.treatment.NSTemporaryBasal +import app.aaps.core.nssdk.localmodel.treatment.NSTemporaryTarget +import com.google.common.truth.Truth.assertThat +import kotlinx.serialization.SerializationException +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertThrows + +/** + * Parsing tests built from real payloads taken from a Nightscout test instance + * (`/api/v1/treatments.json`, AAPS uploader, Dana RS pump). + * + * Two jobs: + * + * 1. Pin what the current parser produces for well formed records. The wire layer is about to be + * rewritten (Gson to kotlinx.serialization, Retrofit to Ktor) and the output has to stay the + * same. These are characterization tests: if one fails after the rewrite, the rewrite is wrong, + * not the test. + * 2. Show that a record with fields missing does not crash. Nightscout data comes from many + * uploaders and many app versions, so almost every field can be absent. + * + * The payloads are real but the identifying parts were replaced: `pumpSerial` and `subject`. + */ +class RealNightscoutTreatmentTest { + + // ---------------------------------------------------------------- complete records + + @Test + fun `parses a real SMB correction bolus`() { + val json = """ + {"_id":"6a7413f54d1ba8c2e419e30e","app":"AAPS","date":1785992179555, + "eventType":"Correction Bolus", + "icfg":{"concentration":1,"insulinEndTime":32040000,"insulinLabel":"Novorapid (1) 75m 8.9h U100","insulinPeakTime":4500000}, + "insulin":0.25,"isBasalInsulin":false,"isReadOnly":false,"isValid":true, + "pumpId":1785992181495,"pumpSerial":"TESTSERIAL","pumpType":"DANA_RS","type":"SMB","utcOffset":120, + "created_at":"2026-08-06T04:56:19.555Z","identifier":"1bebd84b-8d61-58b2-a967-1b34a1e8c4d4", + "srvModified":1785992181588,"srvCreated":1785992181588,"subject":"tester", + "mills":1785992179555,"carbs":null} + """.trimIndent() + + val treatment = json.toNSTreatment() + assertThat(treatment).isInstanceOf(NSBolus::class.java) + treatment as NSBolus + assertThat(treatment.insulin).isEqualTo(0.25) + assertThat(treatment.date).isEqualTo(1785992179555) + assertThat(treatment.identifier).isEqualTo("1bebd84b-8d61-58b2-a967-1b34a1e8c4d4") + assertThat(treatment.isValid).isTrue() + } + + @Test + fun `parses a real temp basal`() { + val json = """ + {"_id":"6a740f444d1ba8c2e419e301","absolute":2,"app":"AAPS","date":1785990980390, + "duration":25,"durationInMilliseconds":1500265,"eventType":"Temp Basal", + "isReadOnly":false,"isValid":true,"pumpId":1785990980390,"pumpSerial":"TESTSERIAL", + "pumpType":"DANA_RS","rate":2,"type":"NORMAL","utcOffset":120, + "created_at":"2026-08-06T04:36:20.390Z","identifier":"cb180766-42ed-55cc-a253-5200d4e0c2c6", + "srvModified":1785992481019,"srvCreated":1785990980561,"subject":"tester", + "endId":1785992480655,"modifiedBy":"tester","endmills":1785992480655, + "carbs":null,"insulin":null} + """.trimIndent() + + val treatment = json.toNSTreatment() + assertThat(treatment).isInstanceOf(NSTemporaryBasal::class.java) + treatment as NSTemporaryBasal + assertThat(treatment.date).isEqualTo(1785990980390) + assertThat(treatment.duration).isEqualTo(1500265) + assertThat(treatment.isValid).isTrue() + } + + @Test + fun `parses a real temporary target`() { + val json = """ + {"_id":"6a741d764d1ba8c2e419e322","app":"AAPS","date":1785994614220, + "duration":60,"durationInMilliseconds":3600000,"eventType":"Temporary Target", + "isReadOnly":false,"isValid":true,"reason":"Automation", + "targetBottom":144.12472,"targetTop":144.12472,"units":"mg/dl","utcOffset":120, + "created_at":"2026-08-06T05:36:54.220Z","identifier":"0426fdb9-a59b-59c5-a556-31f08d5382d0", + "srvModified":1785994614241,"srvCreated":1785994614241,"subject":"tester", + "mills":1785994614220,"endmills":1785998214220,"carbs":null,"insulin":null} + """.trimIndent() + + val treatment = json.toNSTreatment() + assertThat(treatment).isInstanceOf(NSTemporaryTarget::class.java) + treatment as NSTemporaryTarget + assertThat(treatment.targetBottom).isEqualTo(144.12472) + assertThat(treatment.targetTop).isEqualTo(144.12472) + assertThat(treatment.date).isEqualTo(1785994614220) + } + + @Test + fun `parses a real carb correction with notes`() { + val json = """ + {"_id":"6a74011c4d1ba8c2e419e2de","app":"AAPS","carbs":4,"date":1785987360000, + "eventType":"Carb Correction","isReadOnly":false,"isValid":true,"notes":"Random carbs", + "utcOffset":120,"created_at":"2026-08-06T03:36:00.000Z", + "identifier":"752f76ab-86af-5de0-a5af-e488f8459a91", + "srvModified":1785987356851,"srvCreated":1785987356851,"subject":"tester", + "mills":1785987360000,"insulin":null} + """.trimIndent() + + val treatment = json.toNSTreatment() + assertThat(treatment).isInstanceOf(NSCarbs::class.java) + treatment as NSCarbs + assertThat(treatment.carbs).isEqualTo(4.0) + assertThat(treatment.notes).isEqualTo("Random carbs") + } + + // ---------------------------------------------------------------- round trip + + @Test + fun `bolus survives a round trip to the wire format and back`() { + val json = """ + {"eventType":"Correction Bolus","date":1785992179555,"insulin":0.25,"type":"SMB", + "isValid":true,"utcOffset":120,"identifier":"abc","app":"AAPS"} + """.trimIndent() + + val first = json.toNSTreatment() + assertThat(first).isNotNull() + val second = first!!.convertToRemoteAndBack() + assertThat(second).isEqualTo(first) + } + + // ---------------------------------------------------------------- missing fields must not crash + + /** + * Nightscout records come from many uploaders and many app versions. Anything can be absent. + * None of these may throw - either a treatment comes back, or null, but never an exception. + */ + @Test + fun `records with missing fields do not crash the parser`() { + val cases = listOf( + "empty object" to "{}", + "only eventType" to """{"eventType":"Correction Bolus"}""", + "bolus without insulin" to """{"eventType":"Correction Bolus","date":1785992179555,"isValid":true}""", + "bolus without date" to """{"eventType":"Correction Bolus","insulin":0.25,"isValid":true}""", + "temp basal without duration" to """{"eventType":"Temp Basal","date":1785990980390,"rate":2,"isValid":true}""", + "temp basal without rate" to """{"eventType":"Temp Basal","date":1785990980390,"duration":25,"isValid":true}""", + "carbs without amount" to """{"eventType":"Carb Correction","date":1785987360000,"isValid":true}""", + "target without values" to """{"eventType":"Temporary Target","date":1785994614220,"duration":60,"isValid":true}""", + "unknown eventType" to """{"eventType":"Something New","date":1785992179555,"isValid":true}""", + "no eventType at all" to """{"date":1785992179555,"insulin":0.25,"isValid":true}""", + "explicit nulls" to """{"eventType":"Correction Bolus","date":1785992179555,"insulin":null,"carbs":null,"notes":null,"isValid":true}""", + "no utcOffset" to """{"eventType":"Correction Bolus","date":1785992179555,"insulin":0.25,"isValid":true}""", + "no identifier" to """{"eventType":"Correction Bolus","date":1785992179555,"insulin":0.25,"isValid":true}""" + ) + + for ((name, json) in cases) + try { + json.toNSTreatment() // may return null, must not throw + } catch (e: Exception) { + throw AssertionError("Parsing '$name' threw ${e::class.simpleName}: ${e.message}", e) + } + } + + /** + * Malformed text still throws, and still is not caught anywhere - `toNSTreatment()` has no + * guard even though its return type is nullable. This test records that, it does not endorse it. + * + * **The exception type changed** from Gson's `JsonSyntaxException` to + * `SerializationException`. That was called out before the migration precisely so it could not + * happen by accident: any `catch` further up written against the Gson type would silently stop + * catching. The callers were checked - `NSClientV3Service.onDataCreateUpdate` is a socket.io + * listener with no try/catch at all, so nothing was relying on the old type. + * + * If the parser is ever made defensive, change this test on purpose. + */ + @Test + fun `malformed json throws - exception type changed on purpose`() { + assertThrows { "hello".toNSTreatment() } + assertThrows { """{"eventType":"Correction Bolus","date":""".toNSTreatment() } + assertThrows { """[{"eventType":"Correction Bolus"}]""".toNSTreatment() } + assertThrows { """{"eventType":"Correction Bolus","date":"not-a-number"}""".toNSTreatment() } + } + + /** + * Empty text was a separate case under Gson: it returned null and the unguarded `.toTreatment()` + * then threw `NullPointerException`. kotlinx reports it as malformed input instead, which is the + * more honest answer for the same input. + */ + @Test + fun `empty text throws - now reported as malformed rather than NPE`() { + assertThrows { "".toNSTreatment() } + } +} diff --git a/core/nssdk/src/test/kotlin/app/aaps/core/nssdk/mapper/StatusResponseMapperTest.kt b/core/nssdk/src/jvmTest/kotlin/app/aaps/core/nssdk/mapper/StatusResponseMapperTest.kt similarity index 100% rename from core/nssdk/src/test/kotlin/app/aaps/core/nssdk/mapper/StatusResponseMapperTest.kt rename to core/nssdk/src/jvmTest/kotlin/app/aaps/core/nssdk/mapper/StatusResponseMapperTest.kt diff --git a/core/nssdk/src/jvmTest/kotlin/app/aaps/core/nssdk/mapper/WireTypeCoercionTest.kt b/core/nssdk/src/jvmTest/kotlin/app/aaps/core/nssdk/mapper/WireTypeCoercionTest.kt new file mode 100644 index 000000000000..eadbb5f3f872 --- /dev/null +++ b/core/nssdk/src/jvmTest/kotlin/app/aaps/core/nssdk/mapper/WireTypeCoercionTest.kt @@ -0,0 +1,179 @@ +package app.aaps.core.nssdk.mapper + +import app.aaps.core.nssdk.localmodel.treatment.NSBolus +import app.aaps.core.nssdk.localmodel.treatment.NSCarbs +import app.aaps.core.nssdk.nsSdkJson +import app.aaps.core.nssdk.remotemodel.RemoteTreatment +import com.google.common.truth.Truth.assertThat +import kotlinx.serialization.SerializationException +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertThrows + +/** + * Pins how the current parser reacts when a field arrives with the **wrong JSON type**. + * + * This is the part of the Gson to kotlinx.serialization move most likely to break quietly. Gson is + * permissive: it coerces a number into a `String` field, and a numeric string into a `Long` or + * `Double` field, without complaint. kotlinx.serialization is strict by default and throws instead. + * + * That matters because Nightscout data is not written by one uploader. `RemoteTreatment.created_at` + * is declared `String?` and its own comment in the model says: + * + * > "a lot of treatments don't have date, only created_at, some of them with string others with + * > long..." + * + * So mixed types in that field are not hypothetical, they are the documented reason the field looks + * the way it does. A strict parser would start throwing on records that parse fine today, and + * because `NSClientV3Service.onDataCreateUpdate` is a socket.io listener with no try/catch, that + * would escape onto the socket callback thread and drop the record. + * + * These tests record today's behaviour. After the converter switch they must still pass - if one + * cannot, that is a deliberate decision to make, not something to discover in the field. + */ +class WireTypeCoercionTest { + + private fun parse(json: String): RemoteTreatment = nsSdkJson.decodeFromString(RemoteTreatment.serializer(), json) + + // ---------------------------------------------------------------- number into a String field + + /** The documented real-world case: `created_at` sent as an epoch number, not a string. */ + @Test + fun `created_at as a number is coerced to a string`() { + val treatment = parse("""{"eventType":"Correction Bolus","created_at":1785992179555,"insulin":0.25}""") + + assertThat(treatment.created_at).isEqualTo("1785992179555") + } + + /** + * An epoch written as a number now survives into the timestamp. + * + * This used to give `0L` - joda's ISO parser cannot read "1785992179555", and the catch turned + * that into the epoch, so the treatment landed in 1970 with no error anywhere. It was pinned as + * broken while the converter was being swapped, then fixed separately. + * + * Only reachable when `date`, `mills` and `timestamp` are all absent, which AAPS never produces + * but older API v1 documents and other uploaders do. + */ + @Test + fun `a numeric created_at now survives into the timestamp`() { + val treatment = parse("""{"eventType":"Correction Bolus","created_at":1785992179555}""") + + assertThat(treatment.timestamp()).isEqualTo(1785992179555L) + } + + /** The other three fields still win, in order - the fallback must not change that. */ + @Test + fun `date mills and timestamp still take precedence over created_at`() { + val allFour = """{"eventType":"Correction Bolus","date":111,"mills":222,"timestamp":333,"created_at":444}""" + assertThat(parse(allFour).timestamp()).isEqualTo(111L) + + val noDate = """{"eventType":"Correction Bolus","mills":222,"timestamp":333,"created_at":444}""" + assertThat(parse(noDate).timestamp()).isEqualTo(222L) + + val onlyTimestamp = """{"eventType":"Correction Bolus","timestamp":333,"created_at":444}""" + assertThat(parse(onlyTimestamp).timestamp()).isEqualTo(333L) + } + + /** A quoted epoch is the same value as a bare one - the coercion happens before we see it. */ + @Test + fun `a quoted epoch created_at also works`() { + val treatment = parse("""{"eventType":"Correction Bolus","created_at":"1785992179555"}""") + + assertThat(treatment.timestamp()).isEqualTo(1785992179555L) + } + + /** + * Seconds are deliberately NOT converted. `1525383610` is a plausible epoch in seconds and also a + * real millisecond epoch (17 January 1970), and the document does not say which. Guessing would + * swap a visible 1970 date for an invisible wrong one. + */ + @Test + fun `an epoch in seconds is taken literally, not multiplied`() { + val treatment = parse("""{"eventType":"Correction Bolus","created_at":1525383610}""") + + assertThat(treatment.timestamp()).isEqualTo(1525383610L) + } + + /** The ISO string form, which does work. */ + @Test + fun `an ISO created_at parses into the timestamp`() { + val treatment = parse("""{"eventType":"Correction Bolus","created_at":"2026-08-06T04:56:19.555Z"}""") + + assertThat(treatment.timestamp()).isEqualTo(1785992179555L) + } + + /** Unparseable text is swallowed rather than thrown - the record survives with timestamp 0. */ + @Test + fun `an unparseable created_at gives timestamp zero, it does not throw`() { + val treatment = parse("""{"eventType":"Correction Bolus","created_at":"whenever"}""") + + assertThat(treatment.timestamp()).isEqualTo(0L) + } + + // ---------------------------------------------------------------- numeric string into a number field + + @Test + fun `a numeric string is coerced into a Long field`() { + val treatment = parse("""{"eventType":"Correction Bolus","date":"1785992179555"}""") + + assertThat(treatment.date).isEqualTo(1785992179555L) + } + + @Test + fun `a numeric string is coerced into a Double field`() { + val treatment = parse("""{"eventType":"Correction Bolus","insulin":"0.25"}""") + + assertThat(treatment.insulin).isEqualTo(0.25) + } + + @Test + fun `a numeric string is coerced into an Int field`() { + val treatment = parse("""{"eventType":"Profile Switch","percentage":"90"}""") + + assertThat(treatment.percentage).isEqualTo(90) + } + + /** A whole-number JSON value landing in a Double field. */ + @Test + fun `an integer is accepted by a Double field`() { + val treatment = parse("""{"eventType":"Carb Correction","carbs":4}""") + + assertThat(treatment.carbs).isEqualTo(4.0) + } + + // ---------------------------------------------------------------- booleans + + @Test + fun `the string true is coerced into a Boolean field`() { + val treatment = parse("""{"eventType":"Correction Bolus","isValid":"true"}""") + + assertThat(treatment.isValid).isTrue() + } + + // ---------------------------------------------------------------- what still throws + + /** Text that is not a number at all still fails, in both libraries. */ + @Test + fun `non numeric text in a number field throws`() { + assertThrows { parse("""{"eventType":"Correction Bolus","date":"not-a-number"}""") } + } + + // ---------------------------------------------------------------- end to end through the mapper + + /** + * The same coercions seen through the public entry point, so the pin covers what callers + * actually use rather than only the wire model. + */ + @Test + fun `coerced values survive through toNSTreatment`() { + val bolus = """{"eventType":"Correction Bolus","date":"1785992179555","insulin":"0.25","isValid":"true"}""" + .toNSTreatment() + assertThat(bolus).isInstanceOf(NSBolus::class.java) + assertThat((bolus as NSBolus).insulin).isEqualTo(0.25) + assertThat(bolus.date).isEqualTo(1785992179555L) + + val carbs = """{"eventType":"Carb Correction","date":1785987360000,"carbs":"4"}""".toNSTreatment() + assertThat(carbs).isInstanceOf(NSCarbs::class.java) + assertThat((carbs as NSCarbs).carbs).isEqualTo(4.0) + } +} diff --git a/core/nssdk/src/jvmTest/kotlin/app/aaps/core/nssdk/networking/NightscoutApiUrlTest.kt b/core/nssdk/src/jvmTest/kotlin/app/aaps/core/nssdk/networking/NightscoutApiUrlTest.kt new file mode 100644 index 000000000000..d789aad13557 --- /dev/null +++ b/core/nssdk/src/jvmTest/kotlin/app/aaps/core/nssdk/networking/NightscoutApiUrlTest.kt @@ -0,0 +1,154 @@ +package app.aaps.core.nssdk.networking + +import app.aaps.core.nssdk.remotemodel.RemoteFood +import com.google.common.truth.Truth.assertThat +import io.ktor.client.HttpClient +import kotlinx.coroutines.test.runTest +import kotlinx.serialization.json.JsonObject +import mockwebserver3.MockResponse +import mockwebserver3.MockWebServer +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test + +/** + * The same URL expectations as `NsSdkUrlContractTest`, asserted against the **Ktor** endpoint class + * directly, before it is wired into `NSAndroidClientImpl`. + * + * Running it separately means the riskiest single question - does Ktor keep the `$` in `date$gt`, or + * turn it into `%24`? - is answered before the swap rather than during it. A percent-encoded operator + * is not an error to Nightscout: it is an unknown filter, which is ignored, and the server answers + * **200 with unfiltered rows**. + * + * Every expected string here is copied from the Retrofit contract test, which is green against the + * live stack. When both files agree, the URL half of the port is done. + */ +class NightscoutApiUrlTest { + + private lateinit var server: MockWebServer + private lateinit var httpClient: HttpClient + private lateinit var api: NightscoutApi + + @BeforeEach + fun setUp() { + server = MockWebServer() + server.start() + httpClient = NsKtorClient.build(logging = false, logger = { }) + api = NightscoutApi(httpClient, NsUrl.toBaseUrl(server.url("/").toString().trimEnd('/'))) + } + + @AfterEach + fun tearDown() { + httpClient.close() + server.close() + } + + private suspend fun pathOf(body: String = """{"result":[]}""", call: suspend () -> Unit): String { + server.enqueue(MockResponse.Builder().code(200).body(body).build()) + runCatching { call() } + return server.takeRequest().target + } + + // ---------------------------------------------------------------- the $ operators + + @Test + fun `entries keep their dollar operators and static parts`() = runTest { + assertThat(pathOf { api.getSgvs() }) + .isEqualTo("/api/v3/entries?sort\$desc=date&type=sgv") + + assertThat(pathOf { api.getSgvsNewerThan(1700000000000, 250) }) + .isEqualTo("/api/v3/entries?sort=date&date\$gt=1700000000000&limit=250") + + assertThat(pathOf { api.getSgvsModifiedSince(1700000000000, 250) }) + .isEqualTo("/api/v3/entries/history/1700000000000?limit=250") + } + + @Test + fun `treatments keep their dollar operators and static parts`() = runTest { + assertThat(pathOf { api.getTreatmentsNewerThan("2024-01-01T00:00:00.000Z", 250) }) + .isEqualTo("/api/v3/treatments?sort=created_at&created_at\$gt=2024-01-01T00:00:00.000Z&limit=250") + + assertThat(pathOf { api.getTreatmentsModifiedSince(1700000000000, 250) }) + .isEqualTo("/api/v3/treatments/history/1700000000000?limit=250") + } + + @Test + fun `getLastProfile keeps sort and limit`() = runTest { + assertThat(pathOf { api.getLastProfile() }) + .isEqualTo("/api/v3/profile?sort\$desc=date&limit=1") + } + + @Test + fun `getProfileModifiedSince keeps the default limit`() = runTest { + assertThat(pathOf { api.getProfileModifiedSince(1700000000000) }) + .isEqualTo("/api/v3/profile/history/1700000000000?limit=10") + } + + // ---------------------------------------------------------------- settings + + @Test + fun `settings endpoints`() = runTest { + assertThat(pathOf(body = """{"result":{}}""") { api.getSetting("aaps") }) + .isEqualTo("/api/v3/settings/aaps") + + assertThat(pathOf { api.getSettingsModifiedSince(1700000000000, 100) }) + .isEqualTo("/api/v3/settings/history/1700000000000?limit=100") + + assertThat(pathOf { api.searchSettings(100) }) + .isEqualTo("/api/v3/settings?limit=100") + } + + /** Absence of `permanent` is what makes the delete a soft delete. */ + @Test + fun `a soft delete sends no permanent parameter`() = runTest { + val path = pathOf(body = """{}""") { api.deleteSetting("aaps_x", null) } + + assertThat(path).isEqualTo("/api/v3/settings/aaps_x") + assertThat(path).doesNotContain("permanent") + } + + @Test + fun `a permanent delete sends permanent true`() = runTest { + assertThat(pathOf(body = """{}""") { api.deleteSetting("aaps_x", true) }) + .isEqualTo("/api/v3/settings/aaps_x?permanent=true") + } + + // ---------------------------------------------------------------- writes + + @Test + fun `write endpoints target the right paths`() = runTest { + assertThat(pathOf(body = """{}""") { api.createSetting(JsonObject(emptyMap())) }) + .isEqualTo("/api/v3/settings") + + assertThat(pathOf(body = """{}""") { api.updateSetting(JsonObject(emptyMap()), "aaps") }) + .isEqualTo("/api/v3/settings/aaps") + + assertThat(pathOf(body = """{}""") { api.createProfile(JsonObject(emptyMap())) }) + .isEqualTo("/api/v3/profile") + } + + /** An identifier with characters that would otherwise split the path must stay one segment. */ + @Test + fun `an identifier is percent encoded into a single path segment`() = runTest { + val path = pathOf(body = """{"result":{}}""") { api.getSetting("a/b c") } + + assertThat(path).startsWith("/api/v3/settings/") + assertThat(path.removePrefix("/api/v3/settings/")).isEqualTo("a%2Fb%20c") + } + + // ---------------------------------------------------------------- food is deliberately inert + + /** + * Food update and delete must not reach the network. See `NightscoutApi.updateFood` - sending + * these would be a collection-wide request against a live Nightscout. + */ + @Test + fun `food update and delete send nothing`() = runTest { + val food = RemoteFood(name = "Apple", portion = 100.0, carbs = 12, identifier = "x", isValid = true, isReadOnly = false) + + runCatching { api.updateFood(food, identifier = "x") } + runCatching { api.deleteFood("x") } + + assertThat(server.requestCount).isEqualTo(0) + } +} diff --git a/core/nssdk/src/jvmTest/kotlin/app/aaps/core/nssdk/networking/NsSdkAuthContractTest.kt b/core/nssdk/src/jvmTest/kotlin/app/aaps/core/nssdk/networking/NsSdkAuthContractTest.kt new file mode 100644 index 000000000000..22849692467c --- /dev/null +++ b/core/nssdk/src/jvmTest/kotlin/app/aaps/core/nssdk/networking/NsSdkAuthContractTest.kt @@ -0,0 +1,242 @@ +package app.aaps.core.nssdk.networking + +import app.aaps.core.nssdk.NSAndroidClientImpl +import app.aaps.core.nssdk.exceptions.DateHeaderOutOfToleranceException +import app.aaps.core.nssdk.exceptions.InvalidAccessTokenException +import com.google.common.truth.Truth.assertThat +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.test.runTest +import mockwebserver3.Dispatcher +import mockwebserver3.MockResponse +import mockwebserver3.MockWebServer +import mockwebserver3.RecordedRequest +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertThrows +import java.util.concurrent.atomic.AtomicInteger + +/** + * Pins the authentication behaviour of `NSAuthInterceptor`. + * + * This is the riskiest part of the move to Ktor, because Ktor's ready made `Auth` / `bearer` + * provider differs from this interceptor in four ways, and two of them lose data silently: + * + * 1. **The header is always sent, even when the token is empty.** `bearer` omits it instead. An + * unauthenticated request to Nightscout does not fail - it answers **200 with the anonymous + * role**, which flows into `lastStatus` -> `hasWritePermission` -> `DataSyncWorker` skipping the + * entire upload block. Nothing is logged and nothing throws; uploads simply stop. + * 2. **Refresh fires on 401 and 403.** `bearer` only handles 401. + * 3. The failing body is inspected **before** refreshing, to detect a clock-skew rejection. A Ktor + * refresh hook never sees the body, so that error would disappear. + * 4. `bearer` serialises refreshes behind a `Mutex`; this code does not. That one is an improvement + * and may be adopted, but on purpose and with its own test - not as a side effect. + * + * Everything here is asserted through a real localhost server, so the file runs unchanged before and + * after the port. + */ +class NsSdkAuthContractTest { + + private lateinit var server: MockWebServer + private lateinit var client: NSAndroidClientImpl + + private val refreshCalls = AtomicInteger(0) + private var lastRefreshTarget: String? = null + private var refreshHadAuthHeader: Boolean? = null + + @BeforeEach + fun setUp() { + server = MockWebServer() + server.start() + client = NSAndroidClientImpl( + baseUrl = server.url("/").toString().trimEnd('/'), + accessToken = "REFRESH_TOKEN", + logging = false, + logger = { }, + dispatcher = Dispatchers.Unconfined + ) + } + + @AfterEach + fun tearDown() { + server.close() + } + + /** + * Routes by path: the token refresh endpoint is answered with [refresh], everything else with + * [data]. Using a dispatcher rather than a queue keeps the multi request flows readable. + */ + private fun route(data: () -> MockResponse, refresh: () -> MockResponse) { + server.dispatcher = object : Dispatcher() { + override fun dispatch(request: RecordedRequest): MockResponse { + val target = request.target + return if (target.contains("/authorization/request/")) { + refreshCalls.incrementAndGet() + lastRefreshTarget = target + refreshHadAuthHeader = request.headers["Authorization"] != null + refresh() + } else data() + } + } + } + + private fun ok(body: String = """{"result":[]}""") = MockResponse.Builder().code(200).body(body).build() + private fun status(code: Int, body: String = "") = MockResponse.Builder().code(code).body(body).build() + + // ---------------------------------------------------------------- headers on every request + + /** + * The empty-token case, stated on its own because it is the silent one. On the very first call + * `jwtToken` is still `""`, so the code builds `"Bearer $jwtToken"` = `"Bearer "` - and the HTTP + * layer strips the trailing space, so what actually goes on the wire is the bare word `Bearer`. + * + * The exact text matters: the point is that the header **is present**. Omitting it makes + * Nightscout answer 200 with the anonymous role instead of failing. + */ + @Test + fun `Authorization is sent even before any token exists`() = runTest { + server.enqueue(ok()) + + client.getSgvs() + + val sent = server.takeRequest() + assertThat(sent.headers["Authorization"]).isEqualTo("Bearer") + } + + /** `Date` is bare epoch milliseconds, not an RFC 1123 date. Nightscout compares it to its own clock. */ + @Test + fun `Date is sent as bare epoch milliseconds`() = runTest { + server.enqueue(ok()) + + client.getSgvs() + + val date = server.takeRequest().headers["Date"] + assertThat(date).isNotNull() + assertThat(date!!.all { it.isDigit() }).isTrue() + // sane range rather than an exact value: milliseconds, not seconds + assertThat(date.toLong()).isGreaterThan(1_600_000_000_000L) + } + + // ---------------------------------------------------------------- refresh triggers + + @Test + fun `a 401 triggers exactly one refresh and one replay`() = runTest { + val dataCalls = AtomicInteger(0) + route( + data = { if (dataCalls.incrementAndGet() == 1) status(401, "unauthorized") else ok() }, + refresh = { ok("""{"token":"NEW_JWT","iat":1,"exp":2}""") } + ) + + val response = client.getSgvs() + + assertThat(response.values).isEmpty() + assertThat(refreshCalls.get()).isEqualTo(1) + assertThat(dataCalls.get()).isEqualTo(2) // original + one replay, never more + } + + /** 403 must refresh too - Ktor's bearer provider would ignore it. */ + @Test + fun `a 403 also triggers a refresh`() = runTest { + val dataCalls = AtomicInteger(0) + route( + data = { if (dataCalls.incrementAndGet() == 1) status(403, "forbidden") else ok() }, + refresh = { ok("""{"token":"NEW_JWT","iat":1,"exp":2}""") } + ) + + client.getSgvs() + + assertThat(refreshCalls.get()).isEqualTo(1) + assertThat(dataCalls.get()).isEqualTo(2) + } + + /** After a successful refresh the replay carries the NEW token. */ + @Test + fun `the replay carries the refreshed token`() = runTest { + val seenAuth = mutableListOf() + val dataCalls = AtomicInteger(0) + server.dispatcher = object : Dispatcher() { + override fun dispatch(request: RecordedRequest): MockResponse = + if (request.target.contains("/authorization/request/")) ok("""{"token":"NEW_JWT","iat":1,"exp":2}""") + else { + seenAuth += request.headers["Authorization"] + if (dataCalls.incrementAndGet() == 1) status(401, "unauthorized") else ok() + } + } + + client.getSgvs() + + assertThat(seenAuth).hasSize(2) + assertThat(seenAuth[0]).isEqualTo("Bearer") // no token yet, trailing space stripped + assertThat(seenAuth[1]).isEqualTo("Bearer NEW_JWT") // refreshed + } + + // ---------------------------------------------------------------- the refresh call itself + + /** The refresh must not carry an Authorization header, or it would recurse into itself. */ + @Test + fun `the refresh request carries no Authorization header and uses the refresh token in the path`() = runTest { + val dataCalls = AtomicInteger(0) + route( + data = { if (dataCalls.incrementAndGet() == 1) status(401, "unauthorized") else ok() }, + refresh = { ok("""{"token":"NEW_JWT","iat":1,"exp":2}""") } + ) + + client.getSgvs() + + assertThat(refreshHadAuthHeader).isFalse() + assertThat(lastRefreshTarget).isEqualTo("/api/v2/authorization/request/REFRESH_TOKEN") + } + + // ---------------------------------------------------------------- refresh outcomes + + /** A refresh that is itself rejected means the refresh token is bad - a distinct, non-retried error. */ + @Test + fun `a rejected refresh throws InvalidAccessToken and is not retried`() = runTest { + route(data = { status(401, "unauthorized") }, refresh = { status(401, "nope") }) + + assertThrows { client.getSgvs() } + assertThat(refreshCalls.get()).isEqualTo(1) // excluded from retry, so exactly one attempt + } + + /** + * A refresh that fails for another reason (server error) returns the ORIGINAL 401, which the read + * path then maps to a 4xx error. That one is not excluded from retry, so the whole flow repeats. + */ + @Test + fun `a refresh that errors falls back to the original response`() = runTest { + route(data = { status(401, "unauthorized") }, refresh = { status(500, "boom") }) + + assertThrows { client.getSgvs() } + // 4 attempts of (data + refresh), because InvalidParameterNightscoutException is retried + assertThat(refreshCalls.get()).isEqualTo(4) + } + + // ---------------------------------------------------------------- clock skew + + /** + * The clock-skew path. Nightscout rejects a request whose `Date` is too far from its own clock, + * and refreshing would not help, so the body is checked BEFORE any refresh is attempted. + * + * A Ktor refresh hook never sees the response body, which is why this cannot be expressed with + * the stock `bearer` provider. + */ + @Test + fun `a clock skew rejection throws before any refresh is attempted`() = runTest { + route(data = { status(401, "Date header out of tolerance") }, refresh = { ok() }) + + assertThrows { client.getSgvs() } + assertThat(refreshCalls.get()).isEqualTo(0) // never even tried + } + + /** The check is a plain substring match on the body, so surrounding JSON does not hide it. */ + @Test + fun `the clock skew message is matched inside a JSON body`() = runTest { + route( + data = { status(401, """{"status":401,"message":"Date header out of tolerance, check your clock"}""") }, + refresh = { ok() } + ) + + assertThrows { client.getSgvs() } + assertThat(refreshCalls.get()).isEqualTo(0) + } +} diff --git a/core/nssdk/src/jvmTest/kotlin/app/aaps/core/nssdk/networking/NsSdkErrorBodyContractTest.kt b/core/nssdk/src/jvmTest/kotlin/app/aaps/core/nssdk/networking/NsSdkErrorBodyContractTest.kt new file mode 100644 index 000000000000..5d6a24b58a27 --- /dev/null +++ b/core/nssdk/src/jvmTest/kotlin/app/aaps/core/nssdk/networking/NsSdkErrorBodyContractTest.kt @@ -0,0 +1,174 @@ +package app.aaps.core.nssdk.networking + +import app.aaps.core.nssdk.NSAndroidClientImpl +import app.aaps.core.nssdk.localmodel.entry.Direction +import app.aaps.core.nssdk.localmodel.entry.NSSgvV3 +import app.aaps.core.nssdk.localmodel.entry.NsUnits +import com.google.common.truth.Truth.assertThat +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.test.runTest +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import mockwebserver3.MockResponse +import mockwebserver3.MockWebServer +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test + +/** + * Pins the behaviour that depends on reading the **raw text of an error body**. + * + * Nightscout refuses a record whose `utcOffset` it does not like, with + * `400 Bad or missing utcOffset field`. The client recognises that by a plain `contains` on the + * body text and re-sends the record with `utcOffset = 0` + * (`NSAndroidClientImpl.createSgv` / `createCalibration` / `createTreatment`). + * + * If a rewrite loses that text - by reading the body only on success, by consuming the stream before + * it is inspected, or by decoding it into a typed model - the retry never happens. The 400 then + * falls through to a plain `CreateUpdateResponse(400, ...)`, `NSClientV3Plugin` logs one `FAIL` line + * and **advances the sync cursor anyway**, so that reading or treatment is never uploaded again. + * No exception, no repeat attempt, one line in a log nobody reads. + * + * Ktor has no separate `errorBody()`. The body must be read once, up front, and reused - which is + * exactly the trap this test exists to catch. + */ +class NsSdkErrorBodyContractTest { + + private lateinit var server: MockWebServer + private lateinit var client: NSAndroidClientImpl + + @BeforeEach + fun setUp() { + server = MockWebServer() + server.start() + client = NSAndroidClientImpl( + baseUrl = server.url("/").toString().trimEnd('/'), + accessToken = "token", + logging = false, + logger = { }, + dispatcher = Dispatchers.Unconfined + ) + } + + @AfterEach + fun tearDown() { + server.close() + } + + private fun sgv(utcOffset: Long) = NSSgvV3( + date = 1785992179555, + device = "test", + identifier = null, + utcOffset = utcOffset, + isValid = true, + sgv = 120.0, + units = NsUnits.MG_DL, + direction = Direction.FLAT, + noise = null, + filtered = null, + unfiltered = null + ) + + // ---------------------------------------------------------------- the utcOffset fallback + + /** + * The whole point. A 400 naming `utcOffset` must cause a second POST carrying `utcOffset: 0`, + * and the caller must see the result of that second attempt, not the first failure. + */ + @Test + fun `a utcOffset rejection is retried once with utcOffset zero`() = runTest { + server.enqueue( + MockResponse.Builder().code(400) + .body("""{"status":400,"message":"Bad or missing utcOffset field"}""").build() + ) + server.enqueue(MockResponse.Builder().code(201).body("""{"identifier":"abc123"}""").build()) + + val response = client.createSgv(sgv(utcOffset = 120)) + + assertThat(response.response).isEqualTo(201) + assertThat(response.identifier).isEqualTo("abc123") + assertThat(server.requestCount).isEqualTo(2) + + // the first attempt carried the real offset, the second carried zero + val first = Json.parseToJsonElement(server.takeRequest().body!!.utf8()).jsonObject + val second = Json.parseToJsonElement(server.takeRequest().body!!.utf8()).jsonObject + assertThat(first["utcOffset"]?.jsonPrimitive?.content).isEqualTo("120") + assertThat(second["utcOffset"]?.jsonPrimitive?.content).isEqualTo("0") + } + + /** + * The fallback must not loop. A record already at `utcOffset = 0` is not re-sent, because the + * guard is `nsSgvV3.utcOffset != 0L`. + */ + @Test + fun `a record already at zero is not retried`() = runTest { + repeat(4) { + server.enqueue( + MockResponse.Builder().code(400) + .body("""{"status":400,"message":"Bad or missing utcOffset field"}""").build() + ) + } + + val response = client.createSgv(sgv(utcOffset = 0)) + + assertThat(response.response).isEqualTo(400) + assertThat(server.requestCount).isEqualTo(1) + } + + /** A different 400 must NOT trigger the fallback - only the utcOffset wording does. */ + @Test + fun `an unrelated 400 is returned as is`() = runTest { + server.enqueue( + MockResponse.Builder().code(400).body("""{"status":400,"message":"something else"}""").build() + ) + + val response = client.createSgv(sgv(utcOffset = 120)) + + assertThat(response.response).isEqualTo(400) + assertThat(response.errorResponse).contains("something else") + assertThat(server.requestCount).isEqualTo(1) + } + + // ---------------------------------------------------------------- the other body match + + /** + * `cannot be modified by the client` means the record exists with a field AAPS cannot change. + * It is reported back with the body text and must not be retried. + */ + @Test + fun `a cannot-be-modified rejection is returned with its body and not retried`() = runTest { + val body = """{"status":400,"message":"field cannot be modified by the client"}""" + server.enqueue(MockResponse.Builder().code(400).body(body).build()) + + val response = client.createSgv(sgv(utcOffset = 120)) + + assertThat(response.response).isEqualTo(400) + assertThat(response.errorResponse).isEqualTo(body) + assertThat(server.requestCount).isEqualTo(1) + } + + // ---------------------------------------------------------------- the body must survive at all + + /** The verbatim server body reaches the caller - not a summary, not null. */ + @Test + fun `the error body is passed through verbatim`() = runTest { + val body = """{"status":422,"message":"unprocessable","detail":["a","b"]}""" + server.enqueue(MockResponse.Builder().code(422).body(body).build()) + + val response = client.createSgv(sgv(utcOffset = 120)) + + assertThat(response.errorResponse).isEqualTo(body) + } + + /** `app` is stamped onto every uploaded entry - Nightscout uses it to attribute the record. */ + @Test + fun `uploaded entries are stamped with the app name`() = runTest { + server.enqueue(MockResponse.Builder().code(201).body("""{"identifier":"abc"}""").build()) + + client.createSgv(sgv(utcOffset = 120)) + + val sent = Json.parseToJsonElement(server.takeRequest().body!!.utf8()).jsonObject + assertThat(sent["app"]?.jsonPrimitive?.content).isEqualTo("AAPS") + } +} diff --git a/core/nssdk/src/jvmTest/kotlin/app/aaps/core/nssdk/networking/NsSdkResponseContractTest.kt b/core/nssdk/src/jvmTest/kotlin/app/aaps/core/nssdk/networking/NsSdkResponseContractTest.kt new file mode 100644 index 000000000000..782fa6c862bc --- /dev/null +++ b/core/nssdk/src/jvmTest/kotlin/app/aaps/core/nssdk/networking/NsSdkResponseContractTest.kt @@ -0,0 +1,244 @@ +package app.aaps.core.nssdk.networking + +import app.aaps.core.nssdk.NSAndroidClientImpl +import app.aaps.core.nssdk.exceptions.InvalidFormatNightscoutException +import app.aaps.core.nssdk.localmodel.entry.Direction +import app.aaps.core.nssdk.localmodel.entry.NSSgvV3 +import app.aaps.core.nssdk.localmodel.entry.NsUnits +import com.google.common.truth.Truth.assertThat +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.test.runTest +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import mockwebserver3.MockResponse +import mockwebserver3.MockWebServer +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertThrows + +/** + * Pins how a response is turned into values: the **ETag**, which drives the sync cursor, and the + * **`{"result": ...}` envelope**, which is not applied consistently across endpoints. + * + * The envelope is the nastier of the two. Some endpoints answer with the document directly and some + * wrap it, and there is no rule - it has to be copied per endpoint. Because `nsSdkJson` sets + * `ignoreUnknownKeys = true`, guessing wrong does **not** fail: the wrapper is treated as an unknown + * key, every field comes back null, and the caller sees a successful response full of nothing. For a + * create that means the returned `identifier` is null, and `NSClientV3Plugin` then re-uploads that + * record as a brand new document on every sync, forever. + * + * The ETag matters because `lastServerModified` is what advances the paging cursor. A null there + * makes the worker skip the cursor update, so the same window is fetched again next time. + */ +class NsSdkResponseContractTest { + + private lateinit var server: MockWebServer + private lateinit var client: NSAndroidClientImpl + + @BeforeEach + fun setUp() { + server = MockWebServer() + server.start() + client = NSAndroidClientImpl( + baseUrl = server.url("/").toString().trimEnd('/'), + accessToken = "token", + logging = false, + logger = { }, + dispatcher = Dispatchers.Unconfined + ) + } + + @AfterEach + fun tearDown() { + server.close() + } + + private fun sgv() = NSSgvV3( + date = 1785992179555, device = "test", identifier = "id-1", utcOffset = 0, + isValid = true, sgv = 120.0, units = NsUnits.MG_DL, direction = Direction.FLAT, + noise = null, filtered = null, unfiltered = null + ) + + // ================================================================ ETag + + /** + * Nightscout sends a weak ETag, `W/""`. The client strips the first three characters and + * the closing quote with `substring(3, length - 1)` and parses what is left as a Long. + */ + @Test + fun `a weak ETag becomes lastServerModified`() = runTest { + server.enqueue( + MockResponse.Builder().code(200) + .addHeader("ETag", """W/"1785992179555"""") + .body("""{"result":[]}""").build() + ) + + val response = client.getSgvsModifiedSince(1700000000000, 100) + + assertThat(response.lastServerModified).isEqualTo(1785992179555L) + } + + /** No ETag means no cursor update. The worker leaves `lastLoadedSrvModified` where it was. */ + @Test + fun `an absent ETag gives a null lastServerModified`() = runTest { + server.enqueue(MockResponse.Builder().code(200).body("""{"result":[]}""").build()) + + val response = client.getSgvsModifiedSince(1700000000000, 100) + + assertThat(response.lastServerModified).isNull() + } + + /** + * The parse is positional, not a pattern match, so a header that is not `W/"..."` breaks it. + * Recorded as it is: the exception escapes, and because it is not one of the three excluded + * types it is retried first - four requests, then it surfaces. + * + * Real Nightscout always sends the weak form, so this is a latent edge rather than a live bug. + * It is pinned so the port neither hides it nor makes it worse. + */ + @Test + fun `a malformed ETag throws after the retries - current behaviour, pinned`() = runTest { + repeat(8) { + server.enqueue( + MockResponse.Builder().code(200) + .addHeader("ETag", "not-an-etag") + .body("""{"result":[]}""").build() + ) + } + + assertThrows { client.getSgvsModifiedSince(1700000000000, 100) } + assertThat(server.requestCount).isEqualTo(4) + } + + // ================================================================ envelope + + /** + * `createEntry` answers with the create/update document **directly**, no `result` wrapper. + * `identifier` must survive - without it the reading is uploaded again as a new document forever. + */ + @Test + fun `entries create reads a bare body`() = runTest { + server.enqueue( + MockResponse.Builder().code(201) + .body("""{"identifier":"abc123","isDeduplication":false,"lastModified":1785992179555}""").build() + ) + + val response = client.createSgv(sgv()) + + assertThat(response.identifier).isEqualTo("abc123") + assertThat(response.lastModified).isEqualTo(1785992179555L) + } + + /** + * The failure mode, stated explicitly. Feeding the WRAPPED shape to that same bare endpoint does + * not throw - `ignoreUnknownKeys` swallows `result` and every field comes back null. + * + * This is what a wrong envelope choice looks like during the port: a green build, a 201, and a + * record that is re-uploaded on every sync. + */ + @Test + fun `a wrapped body on a bare endpoint yields nulls instead of failing`() = runTest { + server.enqueue( + MockResponse.Builder().code(201) + .body("""{"result":{"identifier":"abc123","lastModified":1785992179555}}""").build() + ) + + val response = client.createSgv(sgv()) + + assertThat(response.response).isEqualTo(201) // looks fine + assertThat(response.identifier).isNull() // but the identifier is gone + } + + /** + * `updateSvg` **ignores the response body completely** - it always returns `identifier = null` + * and reports only the status. So the envelope shape does not matter for this endpoint, and a + * port must not "helpfully" start reading the identifier here: callers rely on getting null. + */ + @Test + fun `entries update reports only the status and never an identifier`() = runTest { + server.enqueue( + MockResponse.Builder().code(200) + .body("""{"result":{"identifier":"abc123","lastModified":1785992179555}}""").build() + ) + + val response = client.updateSvg(sgv()) + + assertThat(response.response).isEqualTo(200) + assertThat(response.identifier).isNull() + assertThat(response.lastModified).isNull() + } + + /** 404 on an update is a success too - the record is simply not there any more. */ + @Test + fun `entries update treats 404 as success`() = runTest { + server.enqueue(MockResponse.Builder().code(404).body("""{"status":404}""").build()) + + val response = client.updateSvg(sgv()) + + assertThat(response.response).isEqualTo(404) + assertThat(server.requestCount).isEqualTo(1) + } + + /** + * A record with no identifier cannot be updated, and that is refused **before any request is + * made**. `InvalidFormatNightscoutException` is one of the three types excluded from retry, so + * it surfaces immediately rather than after four attempts. + */ + @Test + fun `updating a record without an identifier throws before any request`() = runTest { + val noIdentifier = NSSgvV3( + date = 1785992179555, device = "test", identifier = null, utcOffset = 0, + isValid = true, sgv = 120.0, units = NsUnits.MG_DL, direction = Direction.FLAT, + noise = null, filtered = null, unfiltered = null + ) + + assertThrows { client.updateSvg(noIdentifier) } + assertThat(server.requestCount).isEqualTo(0) + } + + /** Settings create is bare, like entries create. */ + @Test + fun `settings create reads a bare body`() = runTest { + server.enqueue(MockResponse.Builder().code(201).body("""{"identifier":"aaps"}""").build()) + + val response = client.createSettings(JsonObject(mapOf("a" to JsonPrimitive(1)))) + + assertThat(response.identifier).isEqualTo("aaps") + } + + /** Settings read is wrapped, and the document inside is schema-less. */ + @Test + fun `settings read unwraps the result`() = runTest { + server.enqueue( + MockResponse.Builder().code(200) + .body("""{"result":{"identifier":"aaps","runningConfig":{"pump":"Dana"}}}""").build() + ) + + val response = client.getSettings("aaps") + + assertThat(response.values).isNotNull() + assertThat(response.values!!.keys).containsAtLeast("identifier", "runningConfig") + } + + /** List reads are wrapped, and an empty list must stay an empty list rather than becoming null. */ + @Test + fun `an empty wrapped list stays empty`() = runTest { + server.enqueue(MockResponse.Builder().code(200).body("""{"result":[]}""").build()) + + val response = client.searchSettings(100) + + assertThat(response.values).isNotNull() + assertThat(response.values).isEmpty() + } + + /** A missing `result` on a wrapped endpoint gives an empty list, not a crash. */ + @Test + fun `a wrapped endpoint with no result gives an empty list`() = runTest { + server.enqueue(MockResponse.Builder().code(200).body("""{}""").build()) + + val response = client.searchSettings(100) + + assertThat(response.values).isEmpty() + } +} diff --git a/core/nssdk/src/jvmTest/kotlin/app/aaps/core/nssdk/networking/NsSdkStatusContractTest.kt b/core/nssdk/src/jvmTest/kotlin/app/aaps/core/nssdk/networking/NsSdkStatusContractTest.kt new file mode 100644 index 000000000000..06a3dbaaef9b --- /dev/null +++ b/core/nssdk/src/jvmTest/kotlin/app/aaps/core/nssdk/networking/NsSdkStatusContractTest.kt @@ -0,0 +1,187 @@ +package app.aaps.core.nssdk.networking + +import app.aaps.core.nssdk.NSAndroidClientImpl +import app.aaps.core.nssdk.exceptions.InvalidParameterNightscoutException +import app.aaps.core.nssdk.exceptions.UnsuccessfulNightscoutException +import com.google.common.truth.Truth.assertThat +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.test.runTest +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import mockwebserver3.MockResponse +import mockwebserver3.MockWebServer +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertThrows + +/** + * Pins how each HTTP status is turned into a result, and **how many requests that costs**. + * + * The request count is the important half. `callWrapper` wraps every call in `retry`, which makes + * **4 attempts** unless the exception's exact class is one of three excluded types + * (`NSAndroidClientImpl.callWrapper`, `utils/CoroutineUtils.kt`). So the number of requests is a + * direct, observable readout of whether an error path throws or returns: + * + * | path | 4xx behaviour | requests | + * | --- | --- | --- | + * | read (`getSgvs`, `searchSettings`) | throws `InvalidParameterNightscoutException` | **4** | + * | write (`createSettings`) | returns `CreateUpdateResponse(response = 4xx)` | **1** | + * | `getSettings` 404 | returns `ReadResponse(404, values = null)` | **1** | + * | `deleteSettings` 404 | treated as an already-done delete | **1** | + * + * This asymmetry is what Ktor's `expectSuccess = true` would destroy: every non-2xx would throw, + * the write ladders would become dead code, and because `ClientRequestException` is not in the + * exclusion list it would be retried 4 times before surfacing. `NSClientV3Plugin` would then stop + * advancing the sync cursor for that collection - quietly, with one log line. + * + * Driven through a real localhost server, so this file runs unchanged before and after the port. + */ +class NsSdkStatusContractTest { + + private lateinit var server: MockWebServer + private lateinit var client: NSAndroidClientImpl + + @BeforeEach + fun setUp() { + server = MockWebServer() + server.start() + client = NSAndroidClientImpl( + baseUrl = server.url("/").toString().trimEnd('/'), + accessToken = "token", + logging = false, + logger = { }, + dispatcher = Dispatchers.Unconfined + ) + } + + @AfterEach + fun tearDown() { + server.close() + } + + /** Answer every request with the same status and body - enough for the retry cases. */ + private fun alwaysRespond(code: Int, body: String = "") { + repeat(8) { server.enqueue(MockResponse.Builder().code(code).body(body).build()) } + } + + // ---------------------------------------------------------------- read paths + + @Test + fun `a 4xx on a read throws InvalidParameter and is retried four times`() = runTest { + alwaysRespond(400, """{"status":400,"message":"bad request"}""") + + assertThrows { client.getSgvs() } + assertThat(server.requestCount).isEqualTo(4) + } + + @Test + fun `a 5xx on a read throws Unsuccessful and is retried four times`() = runTest { + alwaysRespond(500, "server exploded") + + assertThrows { client.getSgvs() } + assertThat(server.requestCount).isEqualTo(4) + } + + /** A successful call must cost exactly one request - the baseline the counts above are read against. */ + @Test + fun `a successful read makes exactly one request`() = runTest { + server.enqueue(MockResponse.Builder().code(200).body("""{"result":[]}""").build()) + + val response = client.getSgvs() + + assertThat(response.values).isEmpty() + assertThat(server.requestCount).isEqualTo(1) + } + + /** + * A bare 304 is an error, and always was. + * + * Nightscout only answers 304 to a **conditional** request. OkHttp sent those because it had a + * disk cache entry with an ETag, and it then merged the 304 with the cached body and handed + * Retrofit a **200** whose `raw().networkResponse.code` was 304 - which is the only way + * `ReadResponse.code` could ever hold 304, and what the paging workers used as their stop signal. + * + * There is no cache now, so no conditional request is sent and no 304 comes back. If one ever did + * arrive it would fail `isSuccessful` (200..299) and throw, exactly as it would have before when + * there was no cached entry to merge with. Pinned so that stays true. + */ + @Test + fun `a bare 304 throws, as it did without a cache entry`() = runTest { + alwaysRespond(304) + + assertThrows { client.getSgvsModifiedSince(1700000000000, 100) } + } + + // ---------------------------------------------------------------- 404 as a normal answer + + /** `getSettings` turns 404 into an empty result, because "no settings doc yet" is not an error. */ + @Test + fun `getSettings maps 404 to a null value without throwing`() = runTest { + alwaysRespond(404, """{"status":404}""") + + val response = client.getSettings("aaps") + + assertThat(response.code).isEqualTo(404) + assertThat(response.values).isNull() + assertThat(server.requestCount).isEqualTo(1) + } + + /** + * A delete of something already gone is a success. `NSClientV3Plugin` relies on this to stop + * retrying a tombstoned identifier forever. + */ + @Test + fun `deleteSettings treats 404 as done`() = runTest { + alwaysRespond(404, """{"status":404}""") + + val response = client.deleteSettings("aaps_x") + + assertThat(response.response).isEqualTo(404) + assertThat(server.requestCount).isEqualTo(1) + } + + // ---------------------------------------------------------------- write paths + + /** + * A write never throws on 4xx. It returns the status **and the verbatim server body**, which the + * caller inspects - see `NsSdkErrorBodyContractTest` for the `utcOffset` path that depends on it. + */ + @Test + fun `a 4xx on a write returns the status and body, and makes one request`() = runTest { + val body = """{"status":400,"message":"Bad or missing utcOffset field"}""" + alwaysRespond(400, body) + + val response = client.createSettings(JsonObject(mapOf("a" to JsonPrimitive(1)))) + + assertThat(response.response).isEqualTo(400) + assertThat(response.errorResponse).isEqualTo(body) + assertThat(server.requestCount).isEqualTo(1) + } + + @Test + fun `a create accepts 200 and 201`() = runTest { + server.enqueue(MockResponse.Builder().code(201).body("""{"identifier":"abc"}""").build()) + val created = client.createSettings(JsonObject(mapOf("a" to JsonPrimitive(1)))) + assertThat(created.response).isEqualTo(201) + assertThat(created.identifier).isEqualTo("abc") + + server.enqueue(MockResponse.Builder().code(200).body("""{"identifier":"def"}""").build()) + val updated = client.createSettings(JsonObject(mapOf("a" to JsonPrimitive(1)))) + assertThat(updated.response).isEqualTo(200) + assertThat(updated.identifier).isEqualTo("def") + } + + /** + * `identifier` coming back from a create is load bearing: `NSClientV3Plugin` stores it as the + * Nightscout id, and without one the record is uploaded again as a new document, forever. + */ + @Test + fun `a create returns the identifier from the body`() = runTest { + server.enqueue(MockResponse.Builder().code(201).body("""{"identifier":"1bebd84b-8d61-58b2"}""").build()) + + val response = client.createSettings(JsonObject(mapOf("a" to JsonPrimitive(1)))) + + assertThat(response.identifier).isEqualTo("1bebd84b-8d61-58b2") + } +} diff --git a/core/nssdk/src/jvmTest/kotlin/app/aaps/core/nssdk/networking/NsSdkUrlContractTest.kt b/core/nssdk/src/jvmTest/kotlin/app/aaps/core/nssdk/networking/NsSdkUrlContractTest.kt new file mode 100644 index 000000000000..a7a9b093190b --- /dev/null +++ b/core/nssdk/src/jvmTest/kotlin/app/aaps/core/nssdk/networking/NsSdkUrlContractTest.kt @@ -0,0 +1,186 @@ +package app.aaps.core.nssdk.networking + +import app.aaps.core.nssdk.NSAndroidClientImpl +import com.google.common.truth.Truth.assertThat +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.test.runTest +import mockwebserver3.MockResponse +import mockwebserver3.MockWebServer +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test + +/** + * Pins the exact URL every endpoint puts on the wire. + * + * This is the highest value test in the module, because a wrong URL does not fail - Nightscout + * answers **200 with the wrong rows**. Two of the details being pinned here are invisible unless you + * read the Retrofit annotations very carefully: + * + * 1. **Static query pairs live inside the `@GET` string**, not in `@Query` parameters. For example + * `v3/profile?sort${'$'}desc=date&limit=1`. A port that rebuilds the URL from the method parameters + * silently drops them - and losing `limit=1` there makes `LoadProfileStoreWorker` pick the + * **wrong profile store**, which means different basal, ISF and IC, with a normal looking log line. + * 2. **`${'$'}` must stay a literal `${'$'}`.** `date${'$'}gt`, `created_at${'$'}gt` and `sort${'$'}desc` are Nightscout API v3 + * operators, and Retrofit is told `encoded = true` so they pass through untouched. Most URL + * builders percent-encode `${'$'}` to `%24` by default, which changes the query rather than failing. + * + * The test drives a **real HTTP server on localhost** rather than a library specific fake, so this + * same file runs unchanged against Retrofit today and against Ktor after the port. That is the whole + * point: a fake that only exists after the swap could never prove anything about the behaviour + * before it. + * + * Only the request line is asserted. Whether the response parses is irrelevant here, so each call is + * wrapped in `runCatching`. + */ +class NsSdkUrlContractTest { + + private lateinit var server: MockWebServer + private lateinit var client: NSAndroidClientImpl + + /** + * A plain temp directory, not JUnit's `@TempDir`. + * + * Nothing closes the OkHttp client (there is no `close()` on `NSAndroidClient`), so its `Cache` + * keeps the `journal` file open and `@TempDir` fails the test on Windows while trying to delete + * it - the assertions pass, the cleanup does not. Deleting best effort here keeps the failure + * signal honest. When the disk cache goes away with the Ktor port, this can become `@TempDir`. + */ + @BeforeEach + fun setUp() { + server = MockWebServer() + server.start() + client = NSAndroidClientImpl( + // Carries a scheme, so it is used as it stands and points at the local server. + baseUrl = server.url("/").toString().trimEnd('/'), + accessToken = "token", + logging = false, + logger = { }, + dispatcher = Dispatchers.Unconfined + ) + } + + @AfterEach + fun tearDown() { + server.close() + } + + /** Runs [call], ignores how it ends, and returns the request line the server saw. */ + private suspend fun pathOf(body: String = """{"result":[]}""", call: suspend () -> Unit): String { + server.enqueue(MockResponse.Builder().code(200).body(body).build()) + runCatching { call() } + return server.takeRequest().target + } + + // ---------------------------------------------------------------- entries + + @Test + fun `entries endpoints keep their static query parts`() = runTest { + assertThat(pathOf { client.getSgvs() }) + .isEqualTo("/api/v3/entries?sort\$desc=date&type=sgv") + + assertThat(pathOf { client.getSgvsNewerThan(1700000000000, 250) }) + .isEqualTo("/api/v3/entries?sort=date&date\$gt=1700000000000&limit=250") + + assertThat(pathOf { client.getSgvsModifiedSince(1700000000000, 250) }) + .isEqualTo("/api/v3/entries/history/1700000000000?limit=250") + } + + /** `getSgvs()` deliberately sends no `limit` - the server default applies. */ + @Test + fun `getSgvs sends no limit`() = runTest { + assertThat(pathOf { client.getSgvs() }).doesNotContain("limit") + } + + // ---------------------------------------------------------------- treatments + + @Test + fun `treatments endpoints keep their static query parts`() = runTest { + assertThat(pathOf { client.getTreatmentsNewerThan("2024-01-01T00:00:00.000Z", 250) }) + .isEqualTo("/api/v3/treatments?sort=created_at&created_at\$gt=2024-01-01T00:00:00.000Z&limit=250") + + assertThat(pathOf { client.getTreatmentsModifiedSince(1700000000000, 250) }) + .isEqualTo("/api/v3/treatments/history/1700000000000?limit=250") + } + + // ---------------------------------------------------------------- profile + + /** + * The dangerous one. Without `sort${'$'}desc=date&limit=1` the server returns an unordered list and + * `LoadProfileStoreWorker` takes `profiles[profiles.size - 1]` from it. + */ + @Test + fun `getLastProfileStore keeps sort and limit`() = runTest { + assertThat(pathOf { client.getLastProfileStore() }) + .isEqualTo("/api/v3/profile?sort\$desc=date&limit=1") + } + + /** `limit=10` comes from a default on the interface and is invisible at the call site. */ + @Test + fun `getProfileModifiedSince sends the interface default limit`() = runTest { + assertThat(pathOf { client.getProfileModifiedSince(1700000000000) }) + .isEqualTo("/api/v3/profile/history/1700000000000?limit=10") + } + + // ---------------------------------------------------------------- settings + + @Test + fun `settings read endpoints`() = runTest { + assertThat(pathOf(body = """{"result":{}}""") { client.getSettings("aaps") }) + .isEqualTo("/api/v3/settings/aaps") + + assertThat(pathOf { client.getSettingsModifiedSince(1700000000000, 100) }) + .isEqualTo("/api/v3/settings/history/1700000000000?limit=100") + + assertThat(pathOf { client.searchSettings(100) }) + .isEqualTo("/api/v3/settings?limit=100") + } + + /** + * Absence of `permanent` is what makes a delete a soft delete (a tombstone). Sending + * `permanent=false` would be a different operation, so the parameter must be missing entirely. + */ + @Test + fun `a soft delete sends no permanent parameter`() = runTest { + val path = pathOf(body = """{}""") { client.deleteSettings("aaps_x") } + + assertThat(path).isEqualTo("/api/v3/settings/aaps_x") + assertThat(path).doesNotContain("permanent") + } + + @Test + fun `a permanent delete sends permanent true`() = runTest { + assertThat(pathOf(body = """{}""") { client.deleteSettingsPermanent("aaps_x") }) + .isEqualTo("/api/v3/settings/aaps_x?permanent=true") + } + + /** + * How a path parameter is encoded. Identifiers come from Nightscout and are normally ids with no + * special characters, so this is a latent edge rather than a live case - but it decides whether a + * odd identifier addresses one document or a different path entirely, so the port has to match it. + */ + @Test + fun `an identifier is encoded into the path`() = runTest { + val path = pathOf(body = """{"result":{}}""") { client.getSettings("a/b c") } + + assertThat(path).startsWith("/api/v3/settings/") + assertThat(path.removePrefix("/api/v3/settings/")).isEqualTo("a%2Fb%20c") + } + + // ---------------------------------------------------------------- base url shapes + + /** A sub-path install must keep its sub-path on the data endpoints. */ + @Test + fun `a sub-path install keeps the sub-path`() { + assertThat(NsUrl.toBaseUrl("host.com/ns")).isEqualTo("https://host.com/ns/api/") + assertThat(NsUrl.toBaseUrl("host.com")).isEqualTo("https://host.com/api/") + assertThat(NsUrl.toBaseUrl("host.com/")).isEqualTo("https://host.com/api/") + } + + /** An explicit scheme is honoured - this is what lets the tests above reach localhost. */ + @Test + fun `an explicit scheme is kept`() { + assertThat(NsUrl.toBaseUrl("http://localhost:8080")).isEqualTo("http://localhost:8080/api/") + assertThat(NsUrl.toBaseUrl("https://host.com")).isEqualTo("https://host.com/api/") + } +} diff --git a/core/nssdk/src/test/kotlin/app/aaps/core/nssdk/utils/ClientControlCryptoTest.kt b/core/nssdk/src/jvmTest/kotlin/app/aaps/core/nssdk/utils/ClientControlCryptoTest.kt similarity index 100% rename from core/nssdk/src/test/kotlin/app/aaps/core/nssdk/utils/ClientControlCryptoTest.kt rename to core/nssdk/src/jvmTest/kotlin/app/aaps/core/nssdk/utils/ClientControlCryptoTest.kt diff --git a/core/nssdk/src/jvmTest/kotlin/app/aaps/core/nssdk/utils/ClientControlCryptoVectorsTest.kt b/core/nssdk/src/jvmTest/kotlin/app/aaps/core/nssdk/utils/ClientControlCryptoVectorsTest.kt new file mode 100644 index 000000000000..4c0e49265f76 --- /dev/null +++ b/core/nssdk/src/jvmTest/kotlin/app/aaps/core/nssdk/utils/ClientControlCryptoVectorsTest.kt @@ -0,0 +1,143 @@ +package app.aaps.core.nssdk.utils + +import com.google.common.truth.Truth.assertThat +import org.junit.jupiter.api.Test + +/** + * Golden vectors for the client-control crypto. + * + * These are fixed inputs with their exact expected outputs, produced by the current JVM + * implementation. They exist so a **second** implementation - Kotlin/Native for iOS, a desktop + * target, or a different library - can be checked byte for byte rather than by "it is also + * HMAC-SHA256". + * + * That distinction matters because the wire format is already frozen: AAPS masters and clients in + * the field exchange these values today. The primitives themselves are standards and interoperate by + * definition, but the **packaging** is where implementations differ and where a mismatch is silent: + * + * - **GCM tag placement.** JCE returns `ciphertext ‖ tag` from a single `doFinal`. Apple's CryptoKit + * models a sealed box with the tag held separately. Concatenate them differently and every unwrap + * fails - which this code deliberately reports as "wrong PIN", so it would look like user error. + * - **Hex case.** Signatures are compared as text with a constant-time string compare. One side + * emitting uppercase means every signature check fails. + * - **PIN encoding.** `PBEKeySpec` takes a char array; the PIN is ASCII digits, so any sane encoding + * agrees - but a non-ASCII PIN would not, which is worth knowing before anyone widens the alphabet. + * + * If a value here ever needs changing, the wire format has changed and every deployed AAPS stops + * being able to pair or verify. Treat a failure as a real incompatibility, not a stale test. + */ +class ClientControlCryptoVectorsTest { + + private fun hex(bytes: ByteArray) = bytes.joinToString("") { "%02x".format(it) } + private fun bytes(vararg values: Int) = ByteArray(values.size) { values[it].toByte() } + + /** 32 bytes, 0x00..0x1f - the HMAC secret length the protocol uses. */ + private val secret = ByteArray(32) { it.toByte() } + + /** 16 bytes, 0xa0.. - a fixed stand-in for a generated salt. */ + private val salt = ByteArray(16) { (0xa0 + it).toByte() } + + /** 12 bytes, 0xb0.. - a fixed stand-in for a generated IV. */ + private val iv = ByteArray(12) { (0xb0 + it).toByte() } + + // ================================================================ HMAC-SHA256 + + /** + * The signature over a canonical envelope string. Every command and every ack rides on this. + */ + @Test + fun `hmac vector - canonical command string`() { + val canonical = "clientId=abc|counter=5|type=SceneStop|timestamp=1785992179555" + + val signature = ClientControlCrypto.sign(secret, canonical) + + assertThat(signature).isEqualTo("e2ac7455db1ee06145fe8432e115eaca1a4e81375703ad907251cbb9d223d216") + } + + /** An empty payload, to pin the degenerate case as well. */ + @Test + fun `hmac vector - empty string`() { + assertThat(ClientControlCrypto.sign(secret, "")).isEqualTo("d38b42096d80f45f826b44a9d5607de72496a415d3f4a1a8c88e3bb9da8dc1cb") + } + + /** Non-ASCII input, so the UTF-8 encoding of the signed text is pinned too. */ + @Test + fun `hmac vector - non ascii`() { + assertThat(ClientControlCrypto.sign(secret, "poznámka ěščřž")).isEqualTo("fcefc8439c7d9f1d149bee6a4dfa2ec4631f8d23a8fd35bc9444c86a4a19b36d") + } + + /** The signature is lower-case hex, 64 characters. Compared as text, so case is part of the format. */ + @Test + fun `signatures are lower case hex of 64 characters`() { + val signature = ClientControlCrypto.sign(secret, "anything") + + assertThat(signature).hasLength(64) + assertThat(signature).isEqualTo(signature.lowercase()) + assertThat(signature.all { it.isDigit() || it in 'a'..'f' }).isTrue() + } + + // ================================================================ PBKDF2 + AES-256-GCM + + /** + * The wrapped pairing payload: PBKDF2-HMAC-SHA256 (200 000 iterations, 256 bit) over the PIN and + * salt, then AES-256-GCM with a 12 byte IV and a 128 bit tag. + */ + @Test + fun `wrap vector - pairing payload`() { + val plaintext = """{"clientId":"abc","secret":"00","expiresAt":1785992179555}""".toByteArray(Charsets.UTF_8) + + val wrapped = ClientControlPairingCrypto.wrap(plaintext, pin = "12345678", salt = salt, iv = iv) + + assertThat(hex(wrapped)).isEqualTo("f864c764382a5e82af17fccf545a14fea3b29eae0784eb2b1630ffe179e58b31c5fc8291afde74f7a0867579b244c6213a12eb9bf1382b1b6c569d2d1f74c25b52c6b253943d87026018") + } + + /** A short, fixed plaintext - easier to eyeball, and pins the tag placement on its own. */ + @Test + fun `wrap vector - short plaintext`() { + val wrapped = ClientControlPairingCrypto.wrap(bytes(1, 2, 3, 4), pin = "00000000", salt = salt, iv = iv) + + assertThat(hex(wrapped)).isEqualTo("f0ef78af68d41f05d7bfed02b15a252856f3620c") + } + + /** + * The GCM tag is **appended** to the ciphertext, so the output is 16 bytes longer than the input. + * An implementation that keeps the tag separate would produce a shorter blob and fail to unwrap. + */ + @Test + fun `the gcm tag is appended, making the output 16 bytes longer`() { + val plaintext = ByteArray(40) { it.toByte() } + + val wrapped = ClientControlPairingCrypto.wrap(plaintext, pin = "12345678", salt = salt, iv = iv) + + assertThat(wrapped.size).isEqualTo(plaintext.size + 16) + } + + // ================================================================ round trip + + @Test + fun `wrap and unwrap round trip`() { + val plaintext = "hello pairing".toByteArray(Charsets.UTF_8) + + val wrapped = ClientControlPairingCrypto.wrap(plaintext, pin = "13572468", salt = salt, iv = iv) + val unwrapped = ClientControlPairingCrypto.unwrap(wrapped, pin = "13572468", salt = salt, iv = iv) + + assertThat(unwrapped).isEqualTo(plaintext) + } + + /** A wrong PIN gives null, not an exception - callers cannot tell it from a corrupt blob. */ + @Test + fun `a wrong pin gives null`() { + val wrapped = ClientControlPairingCrypto.wrap("x".toByteArray(), pin = "11111111", salt = salt, iv = iv) + + assertThat(ClientControlPairingCrypto.unwrap(wrapped, pin = "22222222", salt = salt, iv = iv)).isNull() + } + + /** Tampering with a single byte must fail the auth tag. */ + @Test + fun `a tampered blob gives null`() { + val wrapped = ClientControlPairingCrypto.wrap("x".toByteArray(), pin = "11111111", salt = salt, iv = iv) + wrapped[0] = (wrapped[0] + 1).toByte() + + assertThat(ClientControlPairingCrypto.unwrap(wrapped, pin = "11111111", salt = salt, iv = iv)).isNull() + } +} diff --git a/core/nssdk/src/test/kotlin/app/aaps/core/nssdk/utils/ClientControlPairingCryptoTest.kt b/core/nssdk/src/jvmTest/kotlin/app/aaps/core/nssdk/utils/ClientControlPairingCryptoTest.kt similarity index 100% rename from core/nssdk/src/test/kotlin/app/aaps/core/nssdk/utils/ClientControlPairingCryptoTest.kt rename to core/nssdk/src/jvmTest/kotlin/app/aaps/core/nssdk/utils/ClientControlPairingCryptoTest.kt diff --git a/core/nssdk/src/main/AndroidManifest.xml b/core/nssdk/src/main/AndroidManifest.xml deleted file mode 100644 index a8800291f3c2..000000000000 --- a/core/nssdk/src/main/AndroidManifest.xml +++ /dev/null @@ -1,4 +0,0 @@ - - - - \ No newline at end of file diff --git a/core/nssdk/src/main/kotlin/app/aaps/core/nssdk/NSAndroidRxClientImpl.kt b/core/nssdk/src/main/kotlin/app/aaps/core/nssdk/NSAndroidRxClientImpl.kt deleted file mode 100644 index 57f8ae056889..000000000000 --- a/core/nssdk/src/main/kotlin/app/aaps/core/nssdk/NSAndroidRxClientImpl.kt +++ /dev/null @@ -1,15 +0,0 @@ -package app.aaps.core.nssdk - -import app.aaps.core.nssdk.interfaces.NSAndroidClient -import app.aaps.core.nssdk.interfaces.NSAndroidRxClient -import app.aaps.core.nssdk.localmodel.Status -import app.aaps.core.nssdk.remotemodel.LastModified -import io.reactivex.rxjava3.core.Single -import kotlinx.coroutines.rx3.rxSingle - -class NSAndroidRxClientImpl(private val client: NSAndroidClient) : NSAndroidRxClient { - - override fun getVersion(): Single = rxSingle { client.getVersion() } - override fun getStatus(): Single = rxSingle { client.getStatus() } - override fun getLastModified(): Single = rxSingle { client.getLastModified() } -} diff --git a/core/nssdk/src/main/kotlin/app/aaps/core/nssdk/exceptions/NightscoutException.kt b/core/nssdk/src/main/kotlin/app/aaps/core/nssdk/exceptions/NightscoutException.kt deleted file mode 100644 index a0c0fceab296..000000000000 --- a/core/nssdk/src/main/kotlin/app/aaps/core/nssdk/exceptions/NightscoutException.kt +++ /dev/null @@ -1,5 +0,0 @@ -package app.aaps.core.nssdk.exceptions - -import java.io.IOException - -abstract class NightscoutException(message: String, cause: Throwable? = null) : IOException(message, cause) \ No newline at end of file diff --git a/core/nssdk/src/main/kotlin/app/aaps/core/nssdk/interfaces/NSAndroidRxClient.kt b/core/nssdk/src/main/kotlin/app/aaps/core/nssdk/interfaces/NSAndroidRxClient.kt deleted file mode 100644 index 94ae49f1d651..000000000000 --- a/core/nssdk/src/main/kotlin/app/aaps/core/nssdk/interfaces/NSAndroidRxClient.kt +++ /dev/null @@ -1,13 +0,0 @@ -package app.aaps.core.nssdk.interfaces - -import app.aaps.core.nssdk.localmodel.Status -import app.aaps.core.nssdk.remotemodel.LastModified -import io.reactivex.rxjava3.core.Single - -interface NSAndroidRxClient { - - fun getVersion(): Single - fun getStatus(): Single - fun getLastModified(): Single -} - diff --git a/core/nssdk/src/main/kotlin/app/aaps/core/nssdk/localmodel/treatment/EventType.kt b/core/nssdk/src/main/kotlin/app/aaps/core/nssdk/localmodel/treatment/EventType.kt deleted file mode 100644 index 1c659cdd9f5a..000000000000 --- a/core/nssdk/src/main/kotlin/app/aaps/core/nssdk/localmodel/treatment/EventType.kt +++ /dev/null @@ -1,44 +0,0 @@ -package app.aaps.core.nssdk.localmodel.treatment - -import com.google.gson.annotations.SerializedName - -@Suppress("unused") -enum class EventType(val text: String) { - - @SerializedName("Site Change") CANNULA_CHANGE("Site Change"), - @SerializedName("Insulin Change") INSULIN_CHANGE("Insulin Change"), - @SerializedName("Pump Battery Change") PUMP_BATTERY_CHANGE("Pump Battery Change"), - @SerializedName("Sensor Change") SENSOR_CHANGE("Sensor Change"), - @SerializedName("Sensor Start") SENSOR_STARTED("Sensor Start"), - @SerializedName("Sensor Stop") SENSOR_STOPPED("Sensor Stop"), - @SerializedName("BG Check") FINGER_STICK_BG_VALUE("BG Check"), - @SerializedName("Exercise") EXERCISE("Exercise"), - @SerializedName("Announcement") ANNOUNCEMENT("Announcement"), - @SerializedName("SettingsExport") SETTINGS_EXPORT("Settings Export"), - @SerializedName("Question") QUESTION("Question"), - @SerializedName("Note") NOTE("Note"), - @SerializedName("OpenAPS Offline") APS_OFFLINE("OpenAPS Offline"), - @SerializedName("D.A.D. Alert") DAD_ALERT("D.A.D. Alert"), - @SerializedName("Mbg") NS_MBG("Mbg"), - - // Used but not as a Therapy Event (use constants only) - @SerializedName("Carb Correction") CARBS_CORRECTION("Carb Correction"), - @SerializedName("Bolus Wizard") BOLUS_WIZARD("Bolus Wizard"), - @SerializedName("Correction Bolus") CORRECTION_BOLUS("Correction Bolus"), - @SerializedName("Meal Bolus") MEAL_BOLUS("Meal Bolus"), - @SerializedName("Combo Bolus") COMBO_BOLUS("Combo Bolus"), - @SerializedName("Temporary Target") TEMPORARY_TARGET("Temporary Target"), - @SerializedName("Temporary Target Cancel") TEMPORARY_TARGET_CANCEL("Temporary Target Cancel"), - @SerializedName("Profile Switch") PROFILE_SWITCH("Profile Switch"), - @SerializedName("Snack Bolus") SNACK_BOLUS("Snack Bolus"), - @SerializedName("Temp Basal") TEMPORARY_BASAL("Temp Basal"), - @SerializedName("Temp Basal Start") TEMPORARY_BASAL_START("Temp Basal Start"), - @SerializedName("Temp Basal End") TEMPORARY_BASAL_END("Temp Basal End"), - - @SerializedName("") ERROR(""), - @SerializedName("") NONE(""); - - companion object { - fun fromString(text: String?) = entries.firstOrNull { it.text == text } ?: NONE - } -} \ No newline at end of file diff --git a/core/nssdk/src/main/kotlin/app/aaps/core/nssdk/networking/NSAuthInterceptor.kt b/core/nssdk/src/main/kotlin/app/aaps/core/nssdk/networking/NSAuthInterceptor.kt deleted file mode 100644 index 3b8d36768f8d..000000000000 --- a/core/nssdk/src/main/kotlin/app/aaps/core/nssdk/networking/NSAuthInterceptor.kt +++ /dev/null @@ -1,68 +0,0 @@ -package app.aaps.core.nssdk.networking - -import app.aaps.core.nssdk.exceptions.DateHeaderOutOfToleranceException -import app.aaps.core.nssdk.exceptions.InvalidAccessTokenException -import app.aaps.core.nssdk.networking.Status.MESSAGE_DATE_HEADER_OUT_OF_TOLERANCE -import app.aaps.core.nssdk.remotemodel.RemoteAuthResponse -import okhttp3.Interceptor -import okhttp3.Request -import okhttp3.Response -import retrofit2.Retrofit -import java.lang.System.currentTimeMillis - -internal class NSAuthInterceptor(private val refreshToken: String, private val retrofit: Retrofit) : - Interceptor { - - private var jwtToken = "" // the actual Bearer token - - @Suppress("MagicNumber") - override fun intercept(chain: Interceptor.Chain): Response { - - val originalRequest = chain.request() - val authenticationRequest = requestWithBearer(originalRequest) - val initialResponse = chain.proceed(authenticationRequest) - - return when (initialResponse.code) { - 403, 401 -> refreshTokenAndRetry(originalRequest, initialResponse, chain) - else -> initialResponse - } - } - - private fun requestWithBearer(originalRequest: Request): Request = originalRequest.newBuilder() - .addHeader("Date", currentTimeMillis().toString()) - .addHeader("Authorization", "Bearer $jwtToken") - .build() - - @Suppress("MagicNumber") - private fun refreshTokenAndRetry( - originalRequest: Request, - initialResponse: Response, - chain: Interceptor.Chain - ): Response { - - testCanRefresh(initialResponse) - - val authResponseResponse: retrofit2.Response? = retrofit - .create(NightscoutAuthRefreshService::class.java) - .refreshToken(refreshToken) - .execute() - - return when { - authResponseResponse == null -> initialResponse - authResponseResponse.code() in listOf(401, 403) -> throw InvalidAccessTokenException("Invalid access token") - authResponseResponse.code() != 200 -> initialResponse - else -> { - authResponseResponse.body()?.token?.let { jwtToken = it } - val newAuthenticationRequest = requestWithBearer(originalRequest) - chain.proceed(newAuthenticationRequest) - } - } - } - - private fun testCanRefresh(initialResponse: Response) { - // Todo: use proper reason code once it is supplied by remote - if (initialResponse.body.string().contains(MESSAGE_DATE_HEADER_OUT_OF_TOLERANCE)) { - throw DateHeaderOutOfToleranceException("Data header out of tolerance") - } - } -} diff --git a/core/nssdk/src/main/kotlin/app/aaps/core/nssdk/networking/NetworkStackBuilder.kt b/core/nssdk/src/main/kotlin/app/aaps/core/nssdk/networking/NetworkStackBuilder.kt deleted file mode 100644 index 32378507c223..000000000000 --- a/core/nssdk/src/main/kotlin/app/aaps/core/nssdk/networking/NetworkStackBuilder.kt +++ /dev/null @@ -1,109 +0,0 @@ -package app.aaps.core.nssdk.networking - -import android.content.Context -import com.google.gson.Gson -import com.google.gson.GsonBuilder -import com.google.gson.JsonDeserializer -import okhttp3.Cache -import okhttp3.OkHttpClient -import okhttp3.logging.HttpLoggingInterceptor -import org.json.JSONObject -import retrofit2.Retrofit -import retrofit2.converter.gson.GsonConverterFactory -import java.util.concurrent.TimeUnit - -internal object NetworkStackBuilder { - - @JvmSynthetic - internal fun getApi( - baseUrl: String, - context: Context, - accessToken: String, // refresh token - logging: Boolean, - logger: HttpLoggingInterceptor.Logger - ): NightscoutRemoteService = getRetrofit( - baseUrl = baseUrl, - context = context, - refreshToken = accessToken, - logging = logging, - logger = logger - ).create(NightscoutRemoteService::class.java) - - private fun getRetrofit( - baseUrl: String, - context: Context, - refreshToken: String, - logging: Boolean, - logger: HttpLoggingInterceptor.Logger - ): Retrofit = - Retrofit.Builder() - .baseUrl("https://$baseUrl/api/") - .client( - getOkHttpClient( - context = context, - logging = logging, - refreshToken = refreshToken, - authRefreshRetrofit = getAuthRefreshRetrofit(baseUrl, context, logging, logger), - logger = logger - ) - ) - .addConverterFactory(GsonConverterFactory.create(provideGson())) - .build() - - private fun getAuthRefreshRetrofit( - baseUrl: String, - context: Context, - logging: Boolean, - logger: HttpLoggingInterceptor.Logger - ): Retrofit = - Retrofit.Builder() - .baseUrl("https://$baseUrl/api/") - .client(getAuthRefreshOkHttpClient(context = context, logging = logging, logger = logger)) - .addConverterFactory(GsonConverterFactory.create(provideGson())) - .build() - - private fun getOkHttpClient( - context: Context, - logging: Boolean, - refreshToken: String, - authRefreshRetrofit: Retrofit, - logger: HttpLoggingInterceptor.Logger - ): OkHttpClient = OkHttpClient.Builder().run { - addInterceptor(NSAuthInterceptor(refreshToken, authRefreshRetrofit)) - commonOkHttpSetup(logging, context, logger) - } - - private fun getAuthRefreshOkHttpClient( - context: Context, - logging: Boolean, - logger: HttpLoggingInterceptor.Logger - ): OkHttpClient = OkHttpClient.Builder().run { commonOkHttpSetup(logging, context, logger) } - - private fun OkHttpClient.Builder.commonOkHttpSetup( - logging: Boolean, - context: Context, - logger: HttpLoggingInterceptor.Logger - ): OkHttpClient { - if (logging) { - addNetworkInterceptor( - HttpLoggingInterceptor(logger).also { it.level = HttpLoggingInterceptor.Level.BODY } - ) - } - cache(Cache(context.cacheDir, OK_HTTP_CACHE_SIZE)) - readTimeout(OK_HTTP_READ_TIMEOUT, TimeUnit.MILLISECONDS) - writeTimeout(OK_HTTP_WRITE_TIMEOUT, TimeUnit.MILLISECONDS) - return build() - } - - private val deserializer: JsonDeserializer = - JsonDeserializer { json, _, _ -> - JSONObject(json.asJsonObject.toString()) - } - private fun provideGson(): Gson = GsonBuilder().also { - it.registerTypeAdapter(JSONObject::class.java, deserializer) - }.create() - - private const val OK_HTTP_CACHE_SIZE = 10L * 1024 * 1024 - private const val OK_HTTP_READ_TIMEOUT = 60L * 1000 - private const val OK_HTTP_WRITE_TIMEOUT = 60L * 1000 -} diff --git a/core/nssdk/src/main/kotlin/app/aaps/core/nssdk/networking/NightscoutAuthRefreshService.kt b/core/nssdk/src/main/kotlin/app/aaps/core/nssdk/networking/NightscoutAuthRefreshService.kt deleted file mode 100644 index 7d0b88ab3694..000000000000 --- a/core/nssdk/src/main/kotlin/app/aaps/core/nssdk/networking/NightscoutAuthRefreshService.kt +++ /dev/null @@ -1,16 +0,0 @@ -package app.aaps.core.nssdk.networking - -import app.aaps.core.nssdk.remotemodel.RemoteAuthResponse -import retrofit2.Call -import retrofit2.http.GET -import retrofit2.http.Path - -/** - * Created by adrian on 2019-01-04. - */ - -internal interface NightscoutAuthRefreshService { - - @GET("/api/v2/authorization/request/{refreshToken}") - fun refreshToken(@Path("refreshToken") refreshToken: String): Call -} diff --git a/core/nssdk/src/main/kotlin/app/aaps/core/nssdk/networking/NightscoutRemoteService.kt b/core/nssdk/src/main/kotlin/app/aaps/core/nssdk/networking/NightscoutRemoteService.kt deleted file mode 100644 index 9f0389bd5c6d..000000000000 --- a/core/nssdk/src/main/kotlin/app/aaps/core/nssdk/networking/NightscoutRemoteService.kt +++ /dev/null @@ -1,129 +0,0 @@ -package app.aaps.core.nssdk.networking - -import app.aaps.core.nssdk.remotemodel.LastModified -import app.aaps.core.nssdk.remotemodel.NSResponse -import app.aaps.core.nssdk.remotemodel.RemoteCreateUpdateResponse -import app.aaps.core.nssdk.remotemodel.RemoteDeviceStatus -import app.aaps.core.nssdk.remotemodel.RemoteEntry -import app.aaps.core.nssdk.remotemodel.RemoteFood -import app.aaps.core.nssdk.remotemodel.RemoteStatusResponse -import app.aaps.core.nssdk.remotemodel.RemoteTreatment -import com.google.gson.JsonObject -import org.json.JSONObject -import retrofit2.Response -import retrofit2.http.Body -import retrofit2.http.DELETE -import retrofit2.http.GET -import retrofit2.http.PATCH -import retrofit2.http.POST -import retrofit2.http.PUT -import retrofit2.http.Path -import retrofit2.http.Query - -/** - * Created by adrian on 2019-12-23. - * - * https://github.com/nightscout/cgm-remote-monitor/blob/master/lib/api3/doc/tutorial.md - * - */ - -internal interface NightscoutRemoteService { - - @GET("v3/status") - // used to get the raw response for more error checking. E.g. to give the user better feedback after new settings. - suspend fun statusVerbose(): Response> - - @GET("v3/status") - suspend fun statusSimple(): NSResponse - - @GET("v3/lastModified") - suspend fun lastModified(): Response> - - @GET("v3/entries?sort\$desc=date&type=sgv") - suspend fun getSgvs(): Response>> - - @GET("v3/entries?sort=date") - suspend fun getSgvsNewerThan(@Query(value = "date\$gt", encoded = true) date: Long, @Query("limit") limit: Int): Response>> - - @GET("v3/entries/history/{from}") - suspend fun getSgvsModifiedSince(@Path("from") from: Long, @Query("limit") limit: Int): Response>> - - @POST("v3/entries") - suspend fun createEntry(@Body remoteEntry: RemoteEntry): Response - - @PATCH("v3/entries/{identifier}") - suspend fun updateEntry(@Body remoteEntry: RemoteEntry, @Path("identifier") identifier: String): Response> - - @DELETE("v3/entries/{identifier}") - suspend fun deleteEntry(@Path("identifier") identifier: String): Response> - - @GET("v3/treatments?sort=created_at") - suspend fun getTreatmentsNewerThan(@Query(value = "created_at\$gt", encoded = true) createdAt: String, @Query("limit") limit: Int): Response>> - - @GET("v3/treatments/history/{from}") - suspend fun getTreatmentsModifiedSince(@Path("from") from: Long, @Query("limit") limit: Int): Response>> - - @POST("v3/treatments") - suspend fun createTreatment(@Body remoteTreatment: RemoteTreatment): Response - - @PATCH("v3/treatments/{identifier}") - suspend fun updateTreatment(@Body remoteTreatment: RemoteTreatment, @Path("identifier") identifier: String): Response - - @DELETE("v3/treatments/{identifier}") - suspend fun deleteTreatment(@Path("identifier") identifier: String): Response - - @POST("v3/devicestatus") - suspend fun createDeviceStatus(@Body remoteDeviceStatus: RemoteDeviceStatus): Response - - @GET("v3/devicestatus/history/{from}") - suspend fun getDeviceStatusModifiedSince(@Path("from") from: Long): Response>> - - @GET("v3/food") - suspend fun getFoods(@Query("limit") limit: Int): Response>> - - /* - @GET("v3/food/history/{from}") - suspend fun getFoodsModifiedSince(@Path("from") from: Long, @Query("limit") limit: Int): Response>> - */ - @POST("v3/food") - suspend fun createFood(@Body remoteFood: RemoteFood): Response - - @PATCH("v3/food") - suspend fun updateFood(@Body remoteFood: RemoteFood, @Path("identifier") identifier: String): Response - - @DELETE("v3/food") - suspend fun deleteFood(@Path("identifier") identifier: String): Response - - @GET("v3/profile/history/{from}") - suspend fun getProfileModifiedSince(@Path("from") from: Long, @Query("limit") limit: Int = 10): Response>> - - @GET("v3/profile?sort\$desc=date&limit=1") - suspend fun getLastProfile(): Response>> - - @POST("v3/profile") - suspend fun createProfile(@Body profile: JsonObject): Response - - @GET("v3/settings/{identifier}") - suspend fun getSetting(@Path("identifier") identifier: String): Response> - - @GET("v3/settings/history/{from}") - suspend fun getSettingsModifiedSince(@Path("from") from: Long, @Query("limit") limit: Int = 100): Response>> - - @GET("v3/settings") - suspend fun searchSettings(@Query("limit") limit: Int = 100): Response>> - - @POST("v3/settings") - suspend fun createSetting(@Body settings: JsonObject): Response - - @PATCH("v3/settings/{identifier}") - suspend fun patchSetting(@Body settings: JsonObject, @Path("identifier") identifier: String): Response - - /** PUT (NS3 "UPDATE") = upsert. Replaces existing doc, or inserts if absent. */ - @PUT("v3/settings/{identifier}") - suspend fun updateSetting(@Body settings: JsonObject, @Path("identifier") identifier: String): Response - - /** [permanent] = `null` → soft delete (tombstone); `true` → NS `?permanent=true` hard delete. */ - @DELETE("v3/settings/{identifier}") - suspend fun deleteSetting(@Path("identifier") identifier: String, @Query("permanent") permanent: Boolean?): Response - -} diff --git a/core/nssdk/src/main/kotlin/app/aaps/core/nssdk/remotemodel/RemoteAuthResponse.kt b/core/nssdk/src/main/kotlin/app/aaps/core/nssdk/remotemodel/RemoteAuthResponse.kt deleted file mode 100644 index a50a27b27209..000000000000 --- a/core/nssdk/src/main/kotlin/app/aaps/core/nssdk/remotemodel/RemoteAuthResponse.kt +++ /dev/null @@ -1,3 +0,0 @@ -package app.aaps.core.nssdk.remotemodel - -internal data class RemoteAuthResponse(val token: String, val iat: Long, val exp: Long) diff --git a/core/nssdk/src/main/kotlin/app/aaps/core/nssdk/remotemodel/RemoteDeviceStatus.kt b/core/nssdk/src/main/kotlin/app/aaps/core/nssdk/remotemodel/RemoteDeviceStatus.kt deleted file mode 100644 index c828af5eabcf..000000000000 --- a/core/nssdk/src/main/kotlin/app/aaps/core/nssdk/remotemodel/RemoteDeviceStatus.kt +++ /dev/null @@ -1,59 +0,0 @@ -package app.aaps.core.nssdk.remotemodel - -import com.google.gson.JsonObject -import com.google.gson.annotations.SerializedName - -/** - * DeviceStatus coming from uploader or AAPS - * - **/ -internal data class RemoteDeviceStatus( - @SerializedName("app") var app: String? = null, - @SerializedName("identifier") - val identifier: String? = null, // string Main addressing, required field that identifies document in the collection. The client should not create the identifier, the server automatically assigns it when the document is inserted. - @SerializedName("srvCreated") - val srvCreated: Long? = null, // integer($int64) example: 1525383610088 The server's timestamp of document insertion into the database (Unix epoch in ms). This field appears only for documents which were inserted by API v3. - @SerializedName("srvModified") - val srvModified: Long? = null, // integer($int64) example: 1525383610088 The server's timestamp of the last document modification in the database (Unix epoch in ms). This field appears only for documents which were somehow modified by API v3 (inserted, updated or deleted). - @SerializedName("created_at") - val createdAt: String? = null, // string or string timestamp on previous version of api, in my examples, a lot of treatments don't have date, only created_at, some of them with string others with long... - @SerializedName("date") val date: Long?, // date as milliseconds - @SerializedName("uploaderBattery") val uploaderBattery: Int?,// integer($int64) - @SerializedName("isCharging") val isCharging: Boolean?, - @SerializedName("device") val device: String?, // "openaps://samsung SM-G970F" - - @SerializedName("uploader") val uploader: Uploader?, - @SerializedName("pump") val pump: Pump?, - @SerializedName("openaps") val openaps: OpenAps? -) { - - data class Pump( - @SerializedName("clock") val clock: String?, // timestamp in ISO - @SerializedName("reservoir") val reservoir: Double?, - @SerializedName("reservoir_display_override") val reservoirDisplayOverride: String?, - @SerializedName("battery") val battery: Battery?, - @SerializedName("status") val status: Status?, - @SerializedName("extended") val extended: JsonObject? // Gson, content depending on pump driver - ) { - - data class Battery( - @SerializedName("percent") val percent: Int?, - @SerializedName("voltage") val voltage: Double? - ) - - data class Status( - @SerializedName("status") val status: String?, - @SerializedName("timestamp") val timestamp: String? - ) - } - - data class OpenAps( - @SerializedName("suggested") val suggested: JsonObject?, // Gson - @SerializedName("enacted") val enacted: JsonObject?, // Gson - @SerializedName("iob") val iob: JsonObject? // Gson - ) - - data class Uploader( - @SerializedName("battery") val battery: Int? - ) -} diff --git a/core/nssdk/src/main/kotlin/app/aaps/core/nssdk/remotemodel/RemoteEntry.kt b/core/nssdk/src/main/kotlin/app/aaps/core/nssdk/remotemodel/RemoteEntry.kt deleted file mode 100644 index 0baa62f697a6..000000000000 --- a/core/nssdk/src/main/kotlin/app/aaps/core/nssdk/remotemodel/RemoteEntry.kt +++ /dev/null @@ -1,40 +0,0 @@ -package app.aaps.core.nssdk.remotemodel - -import com.google.gson.annotations.SerializedName - -/* -* Depending on the type, different other fields are present. -* Those technically need to be optional. -* -* On upload a sanity check still needs to be done to verify that all mandatory fields for that type are there. -* -* TODO: Find out all types with their optional and mandatory fields -* -* */ -internal data class RemoteEntry( - @SerializedName("type") val type: String, // sgv, mbg, cal, etc; Bolus type NORMAL, SMB, PRIMING - @SerializedName("sgv") val sgv: Double?, // number The glucose reading. (only available for sgv types) - @SerializedName("dateString") val dateString: String? = null, - @SerializedName("date") var date: Long?, // required ? TODO: date and dateString are redundant - are both needed? how to handle inconsistency then? Only expose one to clients? - @SerializedName("device") val device: String?, // The device from which the data originated (including serial number of the device, if it is relevant and safe). - @SerializedName("direction") val direction: String?, // TODO: what implicit convention for the directions exists? - @SerializedName("identifier") val identifier: String?, - @SerializedName("srvModified") val srvModified: Long?, - @SerializedName("srvCreated") val srvCreated: Long?, - // Philoul Others fields below found in API v3 doc - @SerializedName("app") var app: String? = null, - @SerializedName("utcOffset") var utcOffset: Long?, // Local UTC offset (timezone) of the event in minutes. This field can be set either directly by the client (in the incoming document) or it is - // automatically parsed from the date field. - @SerializedName("subject") val subject: String?, // Name of the security subject (within Nightscout scope) which has created the document. This field is automatically set by the server from the passed token or JWT. - @SerializedName("modifiedBy") val modifiedBy: String? = null, // Name of the security subject (within Nightscout scope) which has patched or deleted the document for the last time. This field is automatically set by the server. - @SerializedName("isValid") val isValid: Boolean?, // A flag set by the server only for deleted documents. This field appears only within history operation and for documents which were deleted by API v3 (and they always have a false value) - @SerializedName("isReadOnly") val isReadOnly: Boolean?, // A flag set by client that locks the document from any changes. Every document marked with isReadOnly=true is forever immutable and cannot even be deleted. - @SerializedName("noise") val noise: Double?, // 0 or 1 found in the export, I don't know if other values possible ? - @SerializedName("filtered") val filtered: Double?, // The raw filtered value directly from CGM transmitter. (only available for sgv types) - @SerializedName("unfiltered") val unfiltered: Double?, // The raw unfiltered value directly from CGM transmitter. (only available for sgv types) - @SerializedName("units") val units: String?, // The units for the glucose value, mg/dl or mmol/l. It is strongly recommended to fill in this field. - @SerializedName("mbg") val mbg: Double? = null, // Manual blood glucose reading (only available for mbg types). AAPS uses a marked mbg to carry a calibration fingerstick. - // AAPS-specific calibration fields, carried on a marked `mbg` entry so a follower can re-fit the calibration curve. - @SerializedName("sensorMgdlAtPairing") val sensorMgdlAtPairing: Double? = null, // sensor value at the moment the fingerstick was entered - @SerializedName("isCalibration") val isCalibration: Boolean? = null // marker: true when this mbg is an AAPS calibration pair (vs a foreign manual BG) -) diff --git a/core/nssdk/src/main/kotlin/app/aaps/core/nssdk/remotemodel/RemoteFood.kt b/core/nssdk/src/main/kotlin/app/aaps/core/nssdk/remotemodel/RemoteFood.kt deleted file mode 100644 index 5021c82ddda7..000000000000 --- a/core/nssdk/src/main/kotlin/app/aaps/core/nssdk/remotemodel/RemoteFood.kt +++ /dev/null @@ -1,41 +0,0 @@ -package app.aaps.core.nssdk.remotemodel - -import com.google.gson.annotations.SerializedName - -/** - * Depending on the type, different other fields are present. - * Those technically need to be optional. - * - * On upload a sanity check still needs to be done to verify that all mandatory fields for that type are there. - * - **/ -internal data class RemoteFood( - @SerializedName("type") val type: String, // we are interesting in type "food" - @SerializedName("date") val date: Long?, - @SerializedName("name") val name: String, - @SerializedName("category") val category: String?, - @SerializedName("subcategory") val subcategory: String?, - @SerializedName("unit") val unit: String?, - @SerializedName("portion") val portion: Double, - @SerializedName("carbs") val carbs: Int, - @SerializedName("gi") val gi: Int?, - @SerializedName("energy") val energy: Int?, - @SerializedName("protein") val protein: Int?, - @SerializedName("fat") val fat: Int?, - @SerializedName("identifier") - val identifier: String?, // string Main addressing, required field that identifies document in the collection. The client should not create the identifier, the server automatically assigns it when the document is inserted. - @SerializedName("isValid") - val isValid: Boolean?, // A flag set by the server only for deleted documents. This field appears only within history operation and for documents which were deleted by API v3 (and they always have a false value) - @SerializedName("isReadOnly") - val isReadOnly: Boolean?, // A flag set by client that locks the document from any changes. Every document marked with isReadOnly=true is forever immutable and cannot even be deleted. - @SerializedName("app") var app: String? = null, // Application or system in which the record was entered by human or device for the first time. - @SerializedName("device") val device: String? = null, // string The device from which the data originated (including serial number of the device, if it is relevant and safe). - @SerializedName("srvCreated") - val srvCreated: Long? = null, // integer($int64) example: 1525383610088 The server's timestamp of document insertion into the database (Unix epoch in ms). This field appears only for documents which were inserted by API v3. - @SerializedName("subject") - val subject: String? = null, // string Name of the security subject (within Nightscout scope) which has created the document. This field is automatically set by the server from the passed token or JWT. - @SerializedName("srvModified") - val srvModified: Long? = null, // integer($int64) example: 1525383610088 The server's timestamp of the last document modification in the database (Unix epoch in ms). This field appears only for documents which were somehow modified by API v3 (inserted, updated or deleted). - @SerializedName("modifiedBy") - val modifiedBy: String? = null // string Name of the security subject (within Nightscout scope) which has patched or deleted the document for the last time. This field is automatically set by the server. -) diff --git a/core/nssdk/src/main/kotlin/app/aaps/core/nssdk/remotemodel/RemoteICfg.kt b/core/nssdk/src/main/kotlin/app/aaps/core/nssdk/remotemodel/RemoteICfg.kt deleted file mode 100644 index 8b0540f7ff11..000000000000 --- a/core/nssdk/src/main/kotlin/app/aaps/core/nssdk/remotemodel/RemoteICfg.kt +++ /dev/null @@ -1,10 +0,0 @@ -package app.aaps.core.nssdk.remotemodel - -import com.google.gson.annotations.SerializedName - -data class RemoteICfg( - @SerializedName("insulinLabel") val insulinLabel: String, - @SerializedName("insulinEndTime") val insulinEndTime: Long, - @SerializedName("insulinPeakTime") val insulinPeakTime: Long, - @SerializedName("concentration") val concentration: Double -) \ No newline at end of file diff --git a/core/nssdk/src/main/kotlin/app/aaps/core/nssdk/remotemodel/RemoteProfileStore.kt b/core/nssdk/src/main/kotlin/app/aaps/core/nssdk/remotemodel/RemoteProfileStore.kt deleted file mode 100644 index 3d6912553761..000000000000 --- a/core/nssdk/src/main/kotlin/app/aaps/core/nssdk/remotemodel/RemoteProfileStore.kt +++ /dev/null @@ -1,49 +0,0 @@ -package app.aaps.core.nssdk.remotemodel - -import com.google.gson.annotations.SerializedName -import kotlinx.serialization.Contextual -import kotlinx.serialization.Serializable -import org.json.JSONObject - -/** - * DeviceStatus coming from uploader or AAPS - * - **/ -@Serializable -data class RemoteProfileStore( - @SerializedName("app") var app: String? = null, - @SerializedName("identifier") val identifier: String? = null, // string Main addressing, required field that identifies document in the collection. The client should not create the identifier, the server automatically assigns it when the document is inserted. - @SerializedName("srvCreated") val srvCreated: Long? = null, // integer($int64) example: 1525383610088 The server's timestamp of document insertion into the database (Unix epoch in ms). This field appears only for documents which were inserted by API v3. - @SerializedName("srvModified") val srvModified: Long? = null, // integer($int64) example: 1525383610088 The server's timestamp of the last document modification in the database (Unix epoch in ms). This field appears only for documents which were somehow modified by API v3 (inserted, updated or deleted). - @SerializedName("created_at") val createdAt: String? = null, // string or string timestamp on previous version of api, in my examples, a lot of treatments don't have date, only created_at, some of them with string others with long... - @SerializedName("date") val date: Long?, // date as milliseconds - @SerializedName("startDate") val startDate: Long?, // record valid from - @SerializedName("defaultProfile") val defaultProfile: String,// default profile in store - - //@Serializable(with = JSONSerializer::class) - @Contextual @SerializedName("store") val store: JSONObject -) { -/* - @Serializable data class Store( - val names: ArrayList, - val profiles: ArrayList - ) - - @Serializable data class SimpleProfile( - @SerializedName("dia") val dia: Double, - @SerializedName("carbratio") val carbratio: ArrayList, - @SerializedName("sens") val sens: ArrayList, - @SerializedName("basal") val basal: ArrayList, - @SerializedName("target_low") val target_low: ArrayList, - @SerializedName("target_high") val target_high: ArrayList, - @SerializedName("units") val units: String, // string The units for the glucose value, mg/dl or mmoll - @SerializedName("timezone") val timezone: String - ) - - @Serializable data class ProfileEntry( - @SerializedName("time") val time: String, - @SerializedName("timeAsSeconds") val timeAsSeconds: Long?, - @SerializedName("value") val value: Double - ) -*/ -} \ No newline at end of file diff --git a/core/nssdk/src/main/kotlin/app/aaps/core/nssdk/remotemodel/RemoteStatusResponse.kt b/core/nssdk/src/main/kotlin/app/aaps/core/nssdk/remotemodel/RemoteStatusResponse.kt deleted file mode 100644 index b9cc33544e41..000000000000 --- a/core/nssdk/src/main/kotlin/app/aaps/core/nssdk/remotemodel/RemoteStatusResponse.kt +++ /dev/null @@ -1,54 +0,0 @@ -package app.aaps.core.nssdk.remotemodel - -import com.google.gson.annotations.SerializedName - -internal data class NSResponse(val result: T?) - -internal data class RemoteStatusResponse( - @SerializedName("version") val version: String, - @SerializedName("apiVersion") val apiVersion: String, - @SerializedName("srvDate") val srvDate: Long, - @SerializedName("storage") val storage: RemoteStorage, - @SerializedName("apiPermissions") val apiPermissions: RemoteApiPermissions -) - -internal data class RemoteStorage( - @SerializedName("storage") val storage: String, - @SerializedName("version") val version: String -) - -internal data class RemoteCreateUpdateResponse( - @SerializedName("identifier") val identifier: String?, - @SerializedName("isDeduplication") val isDeduplication: Boolean?, - @SerializedName("deduplicatedIdentifier") val deduplicatedIdentifier: String?, - @SerializedName("lastModified") val lastModified: Long? -) - -internal data class RemoteApiPermissions( - @SerializedName("devicestatus") val deviceStatus: RemoteApiPermission, - @SerializedName("entries") val entries: RemoteApiPermission, - @SerializedName("food") val food: RemoteApiPermission, - @SerializedName("profile") val profile: RemoteApiPermission, - @SerializedName("settings") val settings: RemoteApiPermission, - @SerializedName("treatments") val treatments: RemoteApiPermission -) - -internal typealias RemoteApiPermission = String - -internal val RemoteApiPermission.create: Boolean - get() = this.contains('c') - -internal val RemoteApiPermission.read: Boolean - get() = this.contains('r') - -internal val RemoteApiPermission.update: Boolean - get() = this.contains('u') - -internal val RemoteApiPermission.delete: Boolean - get() = this.contains('d') - -internal val RemoteApiPermission.readCreate: Boolean - get() = this.read && this.create - -internal val RemoteApiPermission.full: Boolean - get() = this.create && this.read && this.update && this.delete diff --git a/core/nssdk/src/main/kotlin/app/aaps/core/nssdk/remotemodel/RemoteTreatment.kt b/core/nssdk/src/main/kotlin/app/aaps/core/nssdk/remotemodel/RemoteTreatment.kt deleted file mode 100644 index a64c8081be39..000000000000 --- a/core/nssdk/src/main/kotlin/app/aaps/core/nssdk/remotemodel/RemoteTreatment.kt +++ /dev/null @@ -1,103 +0,0 @@ -package app.aaps.core.nssdk.remotemodel - -import app.aaps.core.nssdk.localmodel.treatment.EventType -import com.google.gson.annotations.SerializedName -import org.joda.time.DateTime -import org.joda.time.format.ISODateTimeFormat - -/* -* Depending on the type, different other fields are present. -* Those technically need to be optional. -* -* On upload a sanity check still needs to be done to verify that all mandatory fields for that type are there. -* -* TODO: Find out all types with their optional and mandatory fields -* -* */ -internal data class RemoteTreatment( - @SerializedName("identifier") val identifier: String?, // string Main addressing, required field that identifies document in the collection. The client should not create the identifier, the server automatically assigns it when the document is inserted. - @SerializedName("date") var date: Long? = null, // integer($int64) or string required timestamp when the record or event occurred, you can choose from three input formats Unix epoch in milliseconds (1525383610088), Unix epoch in seconds (1525383610), ISO 8601 with optional timezone ('2018-05-03T21:40:10.088Z' or '2018-05-03T23:40:10.088+02:00') - @SerializedName("mills") val mills: Long? = null, // integer($int64) or string required timestamp when the record or event occurred, you can choose from three input formats Unix - @SerializedName("timestamp") val timestamp: Long? = null, // integer($int64) or string required timestamp when the record or event occurred, you can choose from three input formats Unix epoch in milliseconds (1525383610088), Unix epoch in seconds (1525383610), ISO 8601 with optional timezone ('2018-05-03T21:40:10.088Z' or '2018-05-03T23:40:10.088+02:00') - @SerializedName("created_at") val created_at: String? = null, // integer($int64) or string timestamp on previous version of api, in my examples, a lot of treatments don't have date, only created_at, some of them with string others with long... - @SerializedName("utcOffset") var utcOffset: Long? = null, // integer Local UTC offset (timezone) of the event in minutes. This field can be set either directly by the client (in the incoming document) or it is automatically parsed from the date field. - @SerializedName("app") var app : String? = null, // Application or system in which the record was entered by human or device for the first time. - @SerializedName("device") val device: String? = null, // string The device from which the data originated (including serial number of the device, if it is relevant and safe). - @SerializedName("srvCreated") val srvCreated: Long? = null, // integer($int64) example: 1525383610088 The server's timestamp of document insertion into the database (Unix epoch in ms). This field appears only for documents which were inserted by API v3. - @SerializedName("subject") val subject: String? = null, // string Name of the security subject (within Nightscout scope) which has created the document. This field is automatically set by the server from the passed token or JWT. - @SerializedName("srvModified") val srvModified: Long? = null, // integer($int64) example: 1525383610088 The server's timestamp of the last document modification in the database (Unix epoch in ms). This field appears only for documents which were somehow modified by API v3 (inserted, updated or deleted). - @SerializedName("modifiedBy") val modifiedBy: String? = null, // string Name of the security subject (within Nightscout scope) which has patched or deleted the document for the last time. This field is automatically set by the server. - @SerializedName("isValid") val isValid: Boolean? = null, // boolean A flag set by the server only for deleted documents. This field appears only within history operation and for documents which were deleted by API v3 (and they always have a false value) - @SerializedName("isReadOnly") val isReadOnly: Boolean? = null, // boolean A flag set by client that locks the document from any changes. Every document marked with isReadOnly=true is forever immutable and cannot even be deleted. - @SerializedName("eventType") val eventType: EventType?, // string "BG Check", "Snack Bolus", "Meal Bolus", "Correction Bolus", "Carb Correction", "Combo Bolus", "Announcement", "Note", "Question", "Exercise", "Site Change", "Sensor Start", "Sensor Change", "Pump Battery Change", "Insulin Change", "Temp Basal", "Profile Switch", "D.A.D. Alert", "Temporary Target", "OpenAPS Offline", "Bolus Wizard" - @SerializedName("glucose") val glucose: Double? = null, // double Current glucose - @SerializedName("glucoseType") val glucoseType: String? = null, // string example: "Sensor", "Finger", "Manual" - @SerializedName("units") val units: String? = null, // string The units for the glucose value, mg/dl or mmol/l. It is strongly recommended to fill in this field. - @SerializedName("carbs") val carbs: Double? = null, // number... Amount of carbs given. - @SerializedName("protein") val protein: Int? = null, // number... Amount of protein given. - @SerializedName("fat") val fat: Int? = null, // number... Amount of fat given. - @SerializedName("insulin") val insulin: Double? = null, // number... Amount of insulin, if any. - /** Duration in minutes */ - @SerializedName("duration") val duration: Long? = null, // number... Duration in minutes. - /** Duration in milliseconds */ - @SerializedName("durationInMilliseconds") val durationInMilliseconds: Long? = null, // number... Duration in milliseconds. - @SerializedName("preBolus") val preBolus: Int? = null, // number... How many minutes the bolus was given before the meal started. - @SerializedName("splitNow") val splitNow: Int? = null, // number... Immediate part of combo bolus (in percent). - @SerializedName("splitExt") val splitExt: Int? = null, // number... Extended part of combo bolus (in percent). - @SerializedName("percent") val percent: Double? = null, // number... Eventual basal change in percent. - @SerializedName("absolute") val absolute: Double? = null, // number... Eventual basal change in absolute value (insulin units per hour). - @SerializedName("targetTop") val targetTop: Double? = null, // number... Top limit of temporary target. - @SerializedName("targetBottom") val targetBottom: Double? = null, // number... Bottom limit of temporary target. - @SerializedName("profile") val profile: String? = null, // string Name of the profile to which the pump has been switched. - @SerializedName("reason") val reason: String? = null, // string For example the reason why the profile has been switched or why the temporary target has been set. - @SerializedName("mode") val mode: String? = null, // string RunningMode - @SerializedName("location") val location: String? = null, // string Location for site management defined in TE.Location - @SerializedName("arrow") val arrow: String? = null, // string Arrow for site management defined in TE.Arrow - @SerializedName("autoForced") val autoForced: Boolean? = null, // boolean RunningMode - @SerializedName("reasons") val reasons: String? = null, // string RunningMode - @SerializedName("notes") val notes: String? = null, // string Description/notes of treatment. - @SerializedName("enteredBy") val enteredBy: String? = null, // string Who entered the treatment. - - @SerializedName("endId") val endId: Long? = null, // long id of record which ended this - @SerializedName("pumpId") val pumpId: Long? = null, // long or "Meal Bolus", "Correction Bolus", "Combo Bolus" ex 4102 not sure if long or int - @SerializedName("pumpType") val pumpType: String? = null, // string "Meal Bolus", "Correction Bolus", "Combo Bolus" ex "ACCU_CHEK_INSIGHT_BLUETOOTH", - @SerializedName("pumpSerial") val pumpSerial: String? = null, // string "Meal Bolus", "Correction Bolus", "Combo Bolus" "33013206", - - // other fields found in examples but not in documentation - @SerializedName("profileJson") val profileJson: String? = null, // string "Profile Switch" ex json toString "{\"units\":\"mg\\/dl\",\"dia\":5,\"timezone\":\"Africa\\/Cairo\", - // \"sens\":[{\"time\":\"00:00\",\"timeAsSeconds\":0,\"value\":60},{\"time\":\"07:00\",\"timeAsSeconds\":25200,\"value\":60},{\"time\":\"08:00\",\"timeAsSeconds\":28800,\"value\":61.33333333333333},{\"time\":\"09:00\",\"timeAsSeconds\":32400,\"value\":65.33333333333333},{\"time\":\"10:00\",\"timeAsSeconds\":36000,\"value\":69.33333333333333},{\"time\":\"11:00\",\"timeAsSeconds\":39600,\"value\":73.33333333333333},{\"time\":\"13:00\",\"timeAsSeconds\":46800,\"value\":72},{\"time\":\"14:00\",\"timeAsSeconds\":50400,\"value\":68},{\"time\":\"15:00\",\"timeAsSeconds\":54000,\"value\":65.33333333333333},{\"time\":\"16:00\",\"timeAsSeconds\":57600,\"value\":65.33333333333333}],\"carbratio\":[{\"time\":\"00:00\",\"timeAsSeconds\":0,\"value\":5.7333333333333325},{\"time\":\"11:00\",\"timeAsSeconds\":39600,\"value\":7.333333333333333},{\"time\":\"16:00\",\"timeAsSeconds\":57600,\"value\":6.666666666666666}],\"basal\":[{\"time\":\"00:00\",\"timeAsSeconds\":0,\"value\":0.5249999999999999},{\"time\":\"01:00\",\"timeAsSeconds\":3600,\"value\":0.585},{\"time\":\"02:00\",\"timeAsSeconds\":7200,\"value\":0.6375},{\"time\":\"03:00\",\"timeAsSeconds\":10800,\"value\":0.5625},{\"time\":\"04:00\",\"timeAsSeconds\":14400,\"value\":0.4575},{\"time\":\"05:00\",\"timeAsSeconds\":18000,\"value\":0.5175},{\"time\":\"06:00\",\"timeAsSeconds\":21600,\"value\":0.48},{\"time\":\"07:00\",\"timeAsSeconds\":25200,\"value\":0.51},{\"time\":\"08:00\",\"timeAsSeconds\":28800,\"value\":0.48750000000000004},{\"time\":\"09:00\",\"timeAsSeconds\":32400,\"value\":0.48},{\"time\":\"10:00\",\"timeAsSeconds\":36000,\"value\":0.48750000000000004},{\"time\":\"11:00\",\"timeAsSeconds\":39600,\"value\":0.5025000000000001},{\"time\":\"12:00\",\"timeAsSeconds\":43200,\"value\":0.5549999999999999},{\"time\":\"13:00\",\"timeAsSeconds\":46800,\"value\":0.5700000000000001},{\"time\":\"14:00\",\"timeAsSeconds\":50400,\"value\":0.5700000000000001},{\"time\":\"15:00\",\"timeAsSeconds\":54000,\"value\":0.5775},{\"time\":\"16:00\",\"timeAsSeconds\":57600,\"value\":0.51},{\"time\":\"17:00\",\"timeAsSeconds\":61200,\"value\":0.54},{\"time\":\"18:00\",\"timeAsSeconds\":64800,\"value\":0.48750000000000004},{\"time\":\"19:00\",\"timeAsSeconds\":68400,\"value\":0.5249999999999999},{\"time\":\"20:00\",\"timeAsSeconds\":72000,\"value\":0.46499999999999997},{\"time\":\"21:00\",\"timeAsSeconds\":75600,\"value\":0.46499999999999997},{\"time\":\"22:00\",\"timeAsSeconds\":79200,\"value\":0.43499999999999994},{\"time\":\"23:00\",\"timeAsSeconds\":82800,\"value\":0.41250000000000003}],\"target_low\":[{\"time\":\"00:00\",\"timeAsSeconds\":0,\"value\":100},{\"time\":\"06:00\",\"timeAsSeconds\":21600,\"value\":90},{\"time\":\"09:00\",\"timeAsSeconds\":32400,\"value\":100},{\"time\":\"11:00\",\"timeAsSeconds\":39600,\"value\":90},{\"time\":\"14:00\",\"timeAsSeconds\":50400,\"value\":100},{\"time\":\"18:00\",\"timeAsSeconds\":64800,\"value\":90},{\"time\":\"21:00\",\"timeAsSeconds\":75600,\"value\":100}],\"target_high\":[{\"time\":\"00:00\",\"timeAsSeconds\":0,\"value\":100},{\"time\":\"06:00\",\"timeAsSeconds\":21600,\"value\":90},{\"time\":\"09:00\",\"timeAsSeconds\":32400,\"value\":100},{\"time\":\"11:00\",\"timeAsSeconds\":39600,\"value\":90},{\"time\":\"14:00\",\"timeAsSeconds\":50400,\"value\":100},{\"time\":\"18:00\",\"timeAsSeconds\":64800,\"value\":90},{\"time\":\"21:00\",\"timeAsSeconds\":75600,\"value\":100}]}", - @SerializedName("originalProfileName") val originalProfileName: String? = null, // string "Effective Profile Switch" - @SerializedName("originalCustomizedName") val originalCustomizedName: String? = null, // string "Effective Profile Switch" - @SerializedName("originalTimeshift") val originalTimeshift: Long? = null, // long "Effective Profile Switch" - @SerializedName("originalPercentage") val originalPercentage: Int? = null, // int "Effective Profile Switch" - @SerializedName("originalDuration") val originalDuration: Long? = null, // long "Effective Profile Switch", RunningMode - @SerializedName("originalEnd") val originalEnd: Long? = null, // long "Effective Profile Switch" - @SerializedName("icfg") val iCfg: RemoteICfg? = null, // long "Effective Profile Switch" - - @SerializedName("bolusCalculatorResult") val bolusCalculatorResult: String? = null, // string "Bolus Wizard" json toString ex "bolusCalculatorResult": "{\"basalIOB\":-0.247,\"bolusIOB\":-1.837,\"carbs\":45.0,\"carbsInsulin\":9.0,\"cob\":0.0,\"cobInsulin\":0.0,\"dateCreated\":1626202788810,\"glucoseDifference\":44.0,\"glucoseInsulin\":0.8979591836734694,\"glucoseTrend\":5.5,\"glucoseValue\":134.0,\"ic\":5.0,\"id\":331,\"interfaceIDs_backing\":{\"nightscoutId\":\"60ede2a4c574da0004a3869d\"},\"isValid\":true,\"isf\":49.0,\"note\":\"\",\"otherCorrection\":0.0,\"percentageCorrection\":90,\"profileName\":\"Tuned 13/01 90%Lyum\",\"superbolusInsulin\":0.0,\"targetBGHigh\":90.0,\"targetBGLow\":90.0,\"timestamp\":1626202783325,\"totalInsulin\":7.34,\"trendInsulin\":0.336734693877551,\"utcOffset\":7200000,\"version\":1,\"wasBasalIOBUsed\":true,\"wasBolusIOBUsed\":true,\"wasCOBUsed\":true,\"wasGlucoseUsed\":true,\"wasSuperbolusUsed\":false,\"wasTempTargetUsed\":false,\"wasTrendUsed\":true,\"wereCarbsUsed\":false}", - @SerializedName("type") val type: String? = null, // string "Meal Bolus", "Correction Bolus", "Combo Bolus", "Temp Basal" type of bolus "NORMAL", "SMB", "FAKE_EXTENDED" - @SerializedName("isSMB") val isSMB: Boolean? = null, // boolean "Meal Bolus", "Correction Bolus", "Combo Bolus" - @SerializedName("enteredinsulin") val enteredinsulin: Double? = null, // number... "Combo Bolus" insulin is missing only enteredinsulin field found - @SerializedName("relative") val relative: Double? = null, // number... "Combo Bolus", "extendedEmulated" (not in doc see below) - @SerializedName("isEmulatingTempBasal") val isEmulatingTempBasal: Boolean? = null, // boolean "Combo Bolus", "extendedEmulated" (not in doc see below) - @SerializedName("isAnnouncement") val isAnnouncement: Boolean? = null, // boolean "Announcement" - @SerializedName("rate") val rate: Double? = null, // Double "Temp Basal" absolute rate (could be calculated with percent and profile information...) - @SerializedName("extendedEmulated") var extendedEmulated: RemoteTreatment? = null, // Gson of emulated EB - @SerializedName("timeshift") val timeshift: Long? = null, // integer "Profile Switch" - @SerializedName("percentage") val percentage: Int? = null, // integer "Profile Switch" - @SerializedName("isBasalInsulin") val isBasalInsulin: Boolean? = null // boolean "Bolus" -) { - - fun timestamp(): Long { - return date ?: mills ?: timestamp ?: created_at?. let { fromISODateString(created_at) } ?: 0L - } - - private fun fromISODateString(isoDateString: String): Long = - try { - val parser = ISODateTimeFormat.dateTimeParser() - val dateTime = DateTime.parse(isoDateString, parser) - dateTime.toDate().time - } catch (_: Exception) { - 0L - } -} diff --git a/core/nssdk/src/main/kotlin/app/aaps/core/nssdk/remotemodel/examples.json b/core/nssdk/src/main/kotlin/app/aaps/core/nssdk/remotemodel/examples.json deleted file mode 100644 index 87b19cad8b79..000000000000 --- a/core/nssdk/src/main/kotlin/app/aaps/core/nssdk/remotemodel/examples.json +++ /dev/null @@ -1,490 +0,0 @@ -// Entry -{ - "device": "xDrip-Follower", - "date": 1549414398005, - "dateString": "2019-02-06T01:53:18.005+0100", - "sgv": 98, - "delta": -1.132, - "direction": "Flat", - "type": "sgv", - "filtered": 90336, - "unfiltered": 89712, - "rssi": 100, - "noise": 1, - "sysTime": "2019-02-06T01:53:18.005+0100", - "identifier": "5c5a3007e0196f4d3d9aeafc", - "srvModified": 1549414398005, - "srvCreated": 1549414398005 -}, - -// G6 AAPS -{ - "_id": "60bace9f51e8150004f0973a", - "device": "AndroidAPS-DexcomG6", - "date": 1622855221000, - "dateString": "2021-06-05T01:07:01.000Z", - "isValid": true, - "sgv": 76, - "direction": "Flat", // DoubleDown, SingleDown, FortyFiveDown, Flat, FortyFiveUp, SingleUp, DoubleUp - "type": "sgv", - "created_at": "2021-06-05T01:08:47.234Z" -}, -// G6 DEXCOM APP Share -{ - "_id": "60cd4e7d5bcdeb30e43a248d", - "sgv": 90, - "date": 1624067551000, - "dateString": "2021-06-19T01:52:31.000Z", - "trend": 4, // 7 , 6 , 5 , 4 , 3 , 2 , 1 - "direction": "Flat", // DoubleDown, SingleDown, FortyFiveDown, Flat, FortyFiveUp, SingleUp, DoubleUp - "device": "share2", - "type": "sgv", - "utcOffset": 0, - "sysTime": "2021-06-19T01:52:31.000Z" -}, -// FSL1 xDrip -{ - "device": "AndroidAPS", - "date": 1588557121000, - "dateString": "2020-05-04T01:52:01Z", - "sgv": 76, - "direction": "Flat", // DoubleDown, SingleDown, FortyFiveDown, Flat, FortyFiveUp, SingleUp, DoubleUp - "type": "sgv", - "systime": "2020-05-04T01:52:01Z", - "utcOffset": 120 -}, -// LimiTTer xDrip -{ - "_id": "5ed06c9a0ea4dcb70fac6cc7", - "device": "xDrip-LimiTTer", - "date": 1590717591357, - "dateString": "2020-05-29T01:59:51.357Z", - "sgv": 114, - "delta": -2.942, - "direction": "Flat", - "type": "sgv", - "filtered": 127411.75515, - "unfiltered": 127411.75515, - "rssi": 100, - "noise": 1, - "sysTime": "2020-05-29T01:59:51.357Z", - "utcOffset": 120 -}, - -// API v3 requests for treatments -{ - "eventType": "BG Check", - "created_at": 1616966443000, - "units": "mg/dl", - "glucose": 57, - "NSCLIENT_ID": "1616966443000", - "identifier": "6060f32e9b9c5900045c858b", - "srvModified": 1616966443000, - "srvCreated": 1616966443000 -}, -{ - "eventType": "BG Check", - "created_at": 1617365936000, - "enteredBy": "AndroidAPS", - "units": "mg/dl", - "notes": "Coucou", - "glucose": 94, - "glucoseType": "Finger", - "identifier": "606727c058f71500041e1ed3", - "srvModified": 1617365936000, - "srvCreated": 1617365936000 -}, -{ - "eventType": "Meal Bolus", - "carbs": 45, - "created_at": "2021-07-13T18:19:43.325Z", - "isValid": true, - "date": 1626200383325, - "identifier": "60ede2a4c574da0004a3869c", - "srvModified": 1626200383325, - "srvCreated": 1626200383325 -}, -{ - "eventType": "Meal Bolus", - "insulin": 8.1, - "created_at": "2021-07-13T11:25:12.664Z", - "date": 1626175512664, - "type": "NORMAL", - "isValid": true, - "isSMB": false, - "pumpId": 4102, - "pumpType": "ACCU_CHEK_INSIGHT_BLUETOOTH", - "pumpSerial": "33013206", - "identifier": "60ed782dc574da0004a38595", - "srvModified": 1626175512664, - "srvCreated": 1626175512664 -}, -{ - "eventType": "Correction Bolus", - "insulin": 0.25, - "created_at": "2021-07-13T20:44:14.441Z", - "date": 1626209054441, - "type": "SMB", - "isValid": true, - "isSMB": true, - "pumpId": 4148, - "pumpType": "ACCU_CHEK_INSIGHT_BLUETOOTH", - "pumpSerial": "33013206", - "identifier": "60edfb34c574da0004a386d4", - "srvModified": 1626209054441, - "srvCreated": 1626209054441 -},{ - "eventType": "Carb Correction", - "carbs": 5, - "created_at": "2021-06-17T09:00:34.000Z", - "isValid": true, - "date": 1623920434000, - "identifier": "60cb2f351a94d4000483b692", - "srvModified": 1623920434000, - "srvCreated": 1623920434000 -}, -{ -"created_at": "2021-05-28T19:46:43.851Z", -"enteredBy": "openaps://AndroidAPS", -"eventType": "Combo Bolus", -"duration": 5, -"splitNow": 0, -"splitExt": 100, -"enteredinsulin": 0.7890262726962469, -"relative": 8.893749414356174, -"isValid": true, -"isEmulatingTempBasal": false, -"pumpId": 4, -"pumpType": "ACCU_CHEK_INSIGHT_BLUETOOTH", -"pumpSerial": "33010032", -"identifier": "60b148b419cf4300040b0195", -"srvModified": 1622231203851, -"srvCreated": 1622231203851 -}, -{ - "eventType": "Announcement", - "created_at": 1617350431592, - "enteredBy": "AndroidAPS", - "units": "mg/dl", - "notes": "5g de glucides requis dans 40 min.", - "isAnnouncement": true, - "identifier": "6066cf2508a6ed0004b4ed44", - "srvModified": 1617350431592, - "srvCreated": 1617350431592 -}, -{ - "eventType": "Note", - "created_at": 1617023462485, - "units": "mg/dl", - "notes": "AndroidAPS started - Logicom Le Hola FR", - "identifier": "6061d20b17619800047216b2", - "srvModified": 1617023462485, - "srvCreated": 1617023462485 -}, -{ - "eventType": "Exercise", - "created_at": 1617373066000, - "enteredBy": "AndroidAPS", - "units": "mg/dl", - "duration": 20, - "notes": "ten tab", - "identifier": "606727a658f71500041e1ed2", - "srvModified": 1617373066000, - "srvCreated": 1617373066000 -}, -{ - "eventType": "Exercise", - "isValid": true, - "created_at": "2021-07-09T18:15:22.000Z", - "enteredBy": "AndroidAPS", - "units": "mg/dl", - "duration": 105, - "notes": "🏓", - "identifier": "60e8b223b98ea2000472cbb3", - "srvModified": 1625854522000, - "srvCreated": 1625854522000 -}, -{ - "eventType": "Site Change", - "created_at": 1616312250000, - "units": "mg/dl", - "notes": "", - "NSCLIENT_ID": "1616312250000", - "identifier": "6056f7c1bc2dc60004e75499", - "srvModified": 1616312250000, - "srvCreated": 1616312250000 -}, -{ - "eventType": "Sensor Change", - "created_at": 1617373059000, - "enteredBy": "AndroidAPS", - "units": "mg/dl", - "identifier": "6067278d58f71500041e1ed1", - "srvModified": 1617373059000, - "srvCreated": 1617373059000 -}, -{ - "enteredBy": "AndroidAPS-DexcomG6", - "created_at": 1617799461000, - "eventType": "Sensor Change", - "NSCLIENT_ID": "1617961262771", - "identifier": "60702190403172000451e5dc", - "srvModified": 1617799461000, - "srvCreated": 1617799461000 -}, -{ - "eventType": "Pump Battery Change", - "created_at": 1616517575000, - "enteredBy": "AndroidAPS", - "units": "mg/dl", - "notes": "à peu près...", - "NSCLIENT_ID": "1616517575000", - "identifier": "605cbce3f9ed3b0004694ee8", - "srvModified": 1616517575000, - "srvCreated": 1616517575000 -}, -{ - "created_at": 1617576811000, - "eventType": "Pump Battery Change", - "NSCLIENT_ID": "1617577097394", - "glucoseType": "Manual", - "isValid": true, - "units": "mg/dl", - "identifier": "606a448d7c31f00004bb47ac", - "srvModified": 1617576811000, - "srvCreated": 1617576811000 -}, -{ - "eventType": "Insulin Change", - "created_at": 1616342559000, - "units": "mg/dl", - "notes": "Ajout manuel pour UE", - "NSCLIENT_ID": "1616342559000", - "identifier": "60576e6b5a34f900043e25f6", - "srvModified": 1616342559000, - "srvCreated": 1616342559000 -}, -{ - "created_at": "2021-07-13T20:44:12.891Z", - "enteredBy": "openaps://AndroidAPS", - "eventType": "Temp Basal", - "isValid": true, - "duration": 60, - "rate": 0, - "type": "NORMAL", - "absolute": 0, - "pumpId": 284835, - "pumpType": "ACCU_CHEK_INSIGHT_BLUETOOTH", - "pumpSerial": "33013206", - "identifier": "60edfb34c574da0004a386d3", - "srvModified": 1626209052891, - "srvCreated": 1626209052891 -}, -{ - "created_at": "2021-07-13T20:40:29.896Z", - "enteredBy": "openaps://AndroidAPS", - "eventType": "Temp Basal", - "isValid": true, - "duration": 3, - "rate": 2.4391549295774646, - "type": "FAKE_EXTENDED", - "absolute": 2.4391549295774646, - "pumpId": 4147, - "pumpType": "ACCU_CHEK_INSIGHT_BLUETOOTH", - "pumpSerial": "33013206", - "extendedEmulated": { - "created_at": "2021-07-13T20:40:29.896Z", - "enteredBy": "openaps://AndroidAPS", - "eventType": "Combo Bolus", - "duration": 3, - "splitNow": 0, - "splitExt": 100, - "enteredinsulin": 0.11, - "relative": 1.8591549295774648, - "isValid": true, - "isEmulatingTempBasal": true, - "pumpId": 4147, - "pumpType": "ACCU_CHEK_INSIGHT_BLUETOOTH", - "pumpSerial": "33013206" - }, - "identifier": "60edfa51c574da0004a386d0", - "srvModified": 1626208829896, - "srvCreated": 1626208829896 -}, -{ - "eventType": "OpenAPS Offline", - "created_at": 1616391934628, - "enteredBy": "openaps://AndroidAPS", - "units": "mg/dl", - "duration": 15, - "NSCLIENT_ID": "1616391934628", - "identifier": "60582f005a34f900043e2845", - "srvModified": 1616391934628, - "srvCreated": 1616391934628 -}, -{ - "created_at": "2021-06-26T13:36:47.000Z", - "enteredBy": "openaps://AndroidAPS", - "isValid": true, - "eventType": "Profile Switch", - "duration": 0, - "profile": "Tuned 13/01 90%Lyum", - "profileJson": "{\"units\":\"mg\\/dl\",\"dia\":5,\"timezone\":\"Africa\\/Cairo\",\"sens\":[{\"time\":\"00:00\",\"timeAsSeconds\":0,\"value\":45},{\"time\":\"07:00\",\"timeAsSeconds\":25200,\"value\":45},{\"time\":\"08:00\",\"timeAsSeconds\":28800,\"value\":46},{\"time\":\"09:00\",\"timeAsSeconds\":32400,\"value\":49},{\"time\":\"10:00\",\"timeAsSeconds\":36000,\"value\":52},{\"time\":\"11:00\",\"timeAsSeconds\":39600,\"value\":55},{\"time\":\"13:00\",\"timeAsSeconds\":46800,\"value\":54},{\"time\":\"14:00\",\"timeAsSeconds\":50400,\"value\":51},{\"time\":\"15:00\",\"timeAsSeconds\":54000,\"value\":49},{\"time\":\"16:00\",\"timeAsSeconds\":57600,\"value\":49}],\"carbratio\":[{\"time\":\"00:00\",\"timeAsSeconds\":0,\"value\":4.3},{\"time\":\"11:00\",\"timeAsSeconds\":39600,\"value\":5.5},{\"time\":\"16:00\",\"timeAsSeconds\":57600,\"value\":5}],\"basal\":[{\"time\":\"00:00\",\"timeAsSeconds\":0,\"value\":0.7},{\"time\":\"01:00\",\"timeAsSeconds\":3600,\"value\":0.78},{\"time\":\"02:00\",\"timeAsSeconds\":7200,\"value\":0.85},{\"time\":\"03:00\",\"timeAsSeconds\":10800,\"value\":0.75},{\"time\":\"04:00\",\"timeAsSeconds\":14400,\"value\":0.61},{\"time\":\"05:00\",\"timeAsSeconds\":18000,\"value\":0.69},{\"time\":\"06:00\",\"timeAsSeconds\":21600,\"value\":0.64},{\"time\":\"07:00\",\"timeAsSeconds\":25200,\"value\":0.68},{\"time\":\"08:00\",\"timeAsSeconds\":28800,\"value\":0.65},{\"time\":\"09:00\",\"timeAsSeconds\":32400,\"value\":0.64},{\"time\":\"10:00\",\"timeAsSeconds\":36000,\"value\":0.65},{\"time\":\"11:00\",\"timeAsSeconds\":39600,\"value\":0.67},{\"time\":\"12:00\",\"timeAsSeconds\":43200,\"value\":0.74},{\"time\":\"13:00\",\"timeAsSeconds\":46800,\"value\":0.76},{\"time\":\"14:00\",\"timeAsSeconds\":50400,\"value\":0.76},{\"time\":\"15:00\",\"timeAsSeconds\":54000,\"value\":0.77},{\"time\":\"16:00\",\"timeAsSeconds\":57600,\"value\":0.68},{\"time\":\"17:00\",\"timeAsSeconds\":61200,\"value\":0.72},{\"time\":\"18:00\",\"timeAsSeconds\":64800,\"value\":0.65},{\"time\":\"19:00\",\"timeAsSeconds\":68400,\"value\":0.7},{\"time\":\"20:00\",\"timeAsSeconds\":72000,\"value\":0.62},{\"time\":\"21:00\",\"timeAsSeconds\":75600,\"value\":0.62},{\"time\":\"22:00\",\"timeAsSeconds\":79200,\"value\":0.58},{\"time\":\"23:00\",\"timeAsSeconds\":82800,\"value\":0.55}],\"target_low\":[{\"time\":\"00:00\",\"timeAsSeconds\":0,\"value\":100},{\"time\":\"06:00\",\"timeAsSeconds\":21600,\"value\":90},{\"time\":\"09:00\",\"timeAsSeconds\":32400,\"value\":100},{\"time\":\"11:00\",\"timeAsSeconds\":39600,\"value\":90},{\"time\":\"14:00\",\"timeAsSeconds\":50400,\"value\":100},{\"time\":\"18:00\",\"timeAsSeconds\":64800,\"value\":90},{\"time\":\"21:00\",\"timeAsSeconds\":75600,\"value\":100}],\"target_high\":[{\"time\":\"00:00\",\"timeAsSeconds\":0,\"value\":100},{\"time\":\"06:00\",\"timeAsSeconds\":21600,\"value\":90},{\"time\":\"09:00\",\"timeAsSeconds\":32400,\"value\":100},{\"time\":\"11:00\",\"timeAsSeconds\":39600,\"value\":90},{\"time\":\"14:00\",\"timeAsSeconds\":50400,\"value\":100},{\"time\":\"18:00\",\"timeAsSeconds\":64800,\"value\":90},{\"time\":\"21:00\",\"timeAsSeconds\":75600,\"value\":100}]}", - "timeshift": 0, - "percentage": 100, - "identifier": "60d72d80aec46a0004f95163", - "srvModified": 1624714607000, - "srvCreated": 1624714607000 -}, -{ - "created_at": "2021-06-13T07:20:33.000Z", - "enteredBy": "openaps://AndroidAPS", - "isValid": true, - "eventType": "Profile Switch", - "duration": 150, - "profile": "Tuned 13/01 90%Lyum(75%)", - "profileJson": "{\"units\":\"mg\\/dl\",\"dia\":5,\"timezone\":\"Africa\\/Cairo\",\"sens\":[{\"time\":\"00:00\",\"timeAsSeconds\":0,\"value\":60},{\"time\":\"07:00\",\"timeAsSeconds\":25200,\"value\":60},{\"time\":\"08:00\",\"timeAsSeconds\":28800,\"value\":61.33333333333333},{\"time\":\"09:00\",\"timeAsSeconds\":32400,\"value\":65.33333333333333},{\"time\":\"10:00\",\"timeAsSeconds\":36000,\"value\":69.33333333333333},{\"time\":\"11:00\",\"timeAsSeconds\":39600,\"value\":73.33333333333333},{\"time\":\"13:00\",\"timeAsSeconds\":46800,\"value\":72},{\"time\":\"14:00\",\"timeAsSeconds\":50400,\"value\":68},{\"time\":\"15:00\",\"timeAsSeconds\":54000,\"value\":65.33333333333333},{\"time\":\"16:00\",\"timeAsSeconds\":57600,\"value\":65.33333333333333}],\"carbratio\":[{\"time\":\"00:00\",\"timeAsSeconds\":0,\"value\":5.7333333333333325},{\"time\":\"11:00\",\"timeAsSeconds\":39600,\"value\":7.333333333333333},{\"time\":\"16:00\",\"timeAsSeconds\":57600,\"value\":6.666666666666666}],\"basal\":[{\"time\":\"00:00\",\"timeAsSeconds\":0,\"value\":0.5249999999999999},{\"time\":\"01:00\",\"timeAsSeconds\":3600,\"value\":0.585},{\"time\":\"02:00\",\"timeAsSeconds\":7200,\"value\":0.6375},{\"time\":\"03:00\",\"timeAsSeconds\":10800,\"value\":0.5625},{\"time\":\"04:00\",\"timeAsSeconds\":14400,\"value\":0.4575},{\"time\":\"05:00\",\"timeAsSeconds\":18000,\"value\":0.5175},{\"time\":\"06:00\",\"timeAsSeconds\":21600,\"value\":0.48},{\"time\":\"07:00\",\"timeAsSeconds\":25200,\"value\":0.51},{\"time\":\"08:00\",\"timeAsSeconds\":28800,\"value\":0.48750000000000004},{\"time\":\"09:00\",\"timeAsSeconds\":32400,\"value\":0.48},{\"time\":\"10:00\",\"timeAsSeconds\":36000,\"value\":0.48750000000000004},{\"time\":\"11:00\",\"timeAsSeconds\":39600,\"value\":0.5025000000000001},{\"time\":\"12:00\",\"timeAsSeconds\":43200,\"value\":0.5549999999999999},{\"time\":\"13:00\",\"timeAsSeconds\":46800,\"value\":0.5700000000000001},{\"time\":\"14:00\",\"timeAsSeconds\":50400,\"value\":0.5700000000000001},{\"time\":\"15:00\",\"timeAsSeconds\":54000,\"value\":0.5775},{\"time\":\"16:00\",\"timeAsSeconds\":57600,\"value\":0.51},{\"time\":\"17:00\",\"timeAsSeconds\":61200,\"value\":0.54},{\"time\":\"18:00\",\"timeAsSeconds\":64800,\"value\":0.48750000000000004},{\"time\":\"19:00\",\"timeAsSeconds\":68400,\"value\":0.5249999999999999},{\"time\":\"20:00\",\"timeAsSeconds\":72000,\"value\":0.46499999999999997},{\"time\":\"21:00\",\"timeAsSeconds\":75600,\"value\":0.46499999999999997},{\"time\":\"22:00\",\"timeAsSeconds\":79200,\"value\":0.43499999999999994},{\"time\":\"23:00\",\"timeAsSeconds\":82800,\"value\":0.41250000000000003}],\"target_low\":[{\"time\":\"00:00\",\"timeAsSeconds\":0,\"value\":100},{\"time\":\"06:00\",\"timeAsSeconds\":21600,\"value\":90},{\"time\":\"09:00\",\"timeAsSeconds\":32400,\"value\":100},{\"time\":\"11:00\",\"timeAsSeconds\":39600,\"value\":90},{\"time\":\"14:00\",\"timeAsSeconds\":50400,\"value\":100},{\"time\":\"18:00\",\"timeAsSeconds\":64800,\"value\":90},{\"time\":\"21:00\",\"timeAsSeconds\":75600,\"value\":100}],\"target_high\":[{\"time\":\"00:00\",\"timeAsSeconds\":0,\"value\":100},{\"time\":\"06:00\",\"timeAsSeconds\":21600,\"value\":90},{\"time\":\"09:00\",\"timeAsSeconds\":32400,\"value\":100},{\"time\":\"11:00\",\"timeAsSeconds\":39600,\"value\":90},{\"time\":\"14:00\",\"timeAsSeconds\":50400,\"value\":100},{\"time\":\"18:00\",\"timeAsSeconds\":64800,\"value\":90},{\"time\":\"21:00\",\"timeAsSeconds\":75600,\"value\":100}]}", - "timeshift": 0, - "percentage": 100, - "identifier": "60c5b1f41b3715000420af27", - "srvModified": 1623568833000, - "srvCreated": 1623568833000 -}, -{ - "eventType": "Temporary Target", - "duration": 60, - "isValid": true, - "created_at": "2021-07-10T05:04:11.566Z", - "enteredBy": "AndroidAPS", - "reason": "Automation", - "targetBottom": 110, - "targetTop": 110, - "units": "mg/dl", - "identifier": "60e92a644fc2eb00045ece1b", - "srvModified": 1625893451566, - "srvCreated": 1625893451566 -}, -{ - "eventType": "Temporary Target", - "duration": 120, - "isValid": true, - "created_at": "2021-07-09T20:30:21.627Z", - "enteredBy": "AndroidAPS", - "reason": "Hypo", - "targetBottom": 140, - "targetTop": 140, - "units": "mg/dl", - "identifier": "60e8b1f2b98ea2000472cbb1", - "srvModified": 1625862621627, - "srvCreated": 1625862621627 -}, -{ - "eventType": "Bolus Wizard", - "created_at": "2021-07-13T18:59:43.325Z", - "isValid": true, - "bolusCalculatorResult": "{\"basalIOB\":-0.247,\"bolusIOB\":-1.837,\"carbs\":45.0,\"carbsInsulin\":9.0,\"cob\":0.0,\"cobInsulin\":0.0,\"dateCreated\":1626202788810,\"glucoseDifference\":44.0,\"glucoseInsulin\":0.8979591836734694,\"glucoseTrend\":5.5,\"glucoseValue\":134.0,\"ic\":5.0,\"id\":331,\"interfaceIDs_backing\":{\"nightscoutId\":\"60ede2a4c574da0004a3869d\"},\"isValid\":true,\"isf\":49.0,\"note\":\"\",\"otherCorrection\":0.0,\"percentageCorrection\":90,\"profileName\":\"Tuned 13/01 90%Lyum\",\"superbolusInsulin\":0.0,\"targetBGHigh\":90.0,\"targetBGLow\":90.0,\"timestamp\":1626202783325,\"totalInsulin\":7.34,\"trendInsulin\":0.336734693877551,\"utcOffset\":7200000,\"version\":1,\"wasBasalIOBUsed\":true,\"wasBolusIOBUsed\":true,\"wasCOBUsed\":true,\"wasGlucoseUsed\":true,\"wasSuperbolusUsed\":false,\"wasTempTargetUsed\":false,\"wasTrendUsed\":true,\"wereCarbsUsed\":false}", - "date": 1626202783325, - "glucose": 134, - "units": "mg/dl", - "notes": "", - "identifier": "60ede2a4c574da0004a3869d", - "srvModified": 1626202783325, - "srvCreated": 1626202783325 -}, - -DEVICE STATUS with configuration ---------------------------------- -{ - "_id": "635abf2069a34517e83768cd", - "created_at": "2022-10-27T17:25:49.730Z", - "device": "openaps://samsung SM-G970F", - "pump": { - "battery": { - "percent": 100 - }, - "status": { - "status": "normal", - "timestamp": "2022-10-27T17:16:11.504Z" - }, - "extended": { - "Version": "3.1.0.3-dev-c-nscv3-8da78d7351-2022.10.25-19:56", - "LastBolus": "10/27/22 18:40", - "LastBolusAmount": 0.35, - "TempBasalAbsoluteRate": 0, - "TempBasalStart": "10/27/22 18:50", - "TempBasalRemaining": 24, - "BaseBasalRate": 1, - "ActiveProfile": "LocalProfile1" - }, - "reservoir": 191, - "clock": "2022-10-27T17:25:49.759Z" - }, - "openaps": { - "suggested": { - "temp": "absolute", - "bg": 72, - "tick": -6, - "eventualBG": 4, - "snoozeBG": 4, - "predBGs": { - "IOB": [72, 61, 51, 42, 39, 39, 39, 39, 39, 39, 39, 39, 39] - }, - "COB": 0, - "IOB": 0.052, - "reason": "COB: 0, Dev: -66, BGI: -0.88, ISF: 2.0, Target: 6.0; BG 4.0<4.4, but 25m left and 0 ~ req 0U/hr: no action required", - "timestamp": "2022-10-27T17:25:49.726Z" - }, - "iob": { - "iob": 0.052, - "basaliob": 0.052, - "activity": 0.0049, - "time": "2022-10-27T17:25:49.726Z" - } - }, - "uploaderBattery": 100, - "configuration": { - "insulin": 5, - "insulinConfiguration": {}, - "sensitivity": 2, - "sensitivityConfiguration": { - "openapsama_min_5m_carbimpact": 10, - "absorption_cutoff": 4, - "autosens_max": 1.2, - "autosens_min": 0.7 - }, - "overviewConfiguration": { - "units": "mmol", - "eatingsoon_duration": 0, - "eatingsoon_target": 0, - "activity_duration": 0, - "activity_target": 0, - "hypo_duration": 0, - "hypo_target": 0, - "low_mark": 4, - "high_mark": 0, - "statuslights_cage_warning": 48, - "statuslights_cage_critical": 72, - "statuslights_iage_warning": 72, - "statuslights_iage_critical": 144, - "statuslights_sage_warning": 216, - "statuslights_sage_critical": 240, - "statuslights_sbat_warning": 25, - "statuslights_sbat_critical": 5, - "statuslights_bage_warning": 216, - "statuslights_bage_critical": 240, - "statuslights_res_warning": 80, - "statuslights_res_critical": 10, - "statuslights_bat_warning": 25, - "statuslights_bat_critical": 5, - "boluswizard_percentage": 60 - }, - "safetyConfiguration": { - "age": "teenage", - "treatmentssafety_maxbolus": 4, - "treatmentssafety_maxcarbs": 60 - }, - "pump": "DanaR", - "version": "3.1.0.3-dev-c-nscv3" - }, - "mills": 1666891549730 -} \ No newline at end of file diff --git a/core/nssdk/src/main/kotlin/app/aaps/core/nssdk/utils/ListUtils.kt b/core/nssdk/src/main/kotlin/app/aaps/core/nssdk/utils/ListUtils.kt deleted file mode 100644 index ebef730e4405..000000000000 --- a/core/nssdk/src/main/kotlin/app/aaps/core/nssdk/utils/ListUtils.kt +++ /dev/null @@ -1,4 +0,0 @@ -package app.aaps.core.nssdk.utils - -@JvmSynthetic -internal fun List?.toNotNull(): List = this?.filterNotNull() ?: listOf() diff --git a/core/nssdk/src/mingwX64Main/kotlin/app/aaps/core/nssdk/networking/NsHttpClient.mingwX64.kt b/core/nssdk/src/mingwX64Main/kotlin/app/aaps/core/nssdk/networking/NsHttpClient.mingwX64.kt new file mode 100644 index 000000000000..bd47112c9732 --- /dev/null +++ b/core/nssdk/src/mingwX64Main/kotlin/app/aaps/core/nssdk/networking/NsHttpClient.mingwX64.kt @@ -0,0 +1,21 @@ +package app.aaps.core.nssdk.networking + +import io.ktor.client.HttpClient +import io.ktor.client.HttpClientConfig +import io.ktor.client.engine.cio.CIO + +/** + * Native engine: **CIO**, Ktor's own pure-Kotlin engine. + * + * This target is a compile-time proof that commonMain carries no JVM API - it is not a shipping + * platform. An Apple target would use Darwin here instead, which is the engine an iOS build wants. + * + * Request logging is engine specific and is not wired up for this target, because nothing runs it. + */ +internal actual fun nsHttpClient( + logging: Boolean, + logger: (String) -> Unit, + configure: HttpClientConfig<*>.() -> Unit +): HttpClient = HttpClient(CIO) { + configure() +} diff --git a/core/nssdk/src/mingwX64Main/kotlin/app/aaps/core/nssdk/utils/IoDispatcher.mingwX64.kt b/core/nssdk/src/mingwX64Main/kotlin/app/aaps/core/nssdk/utils/IoDispatcher.mingwX64.kt new file mode 100644 index 000000000000..0d205d214b61 --- /dev/null +++ b/core/nssdk/src/mingwX64Main/kotlin/app/aaps/core/nssdk/utils/IoDispatcher.mingwX64.kt @@ -0,0 +1,12 @@ +package app.aaps.core.nssdk.utils + +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers + +/** + * Native has no separate IO pool, so blocking work shares the default dispatcher. + * + * This target exists to prove commonMain carries no JVM API; an Apple target would use + * `Dispatchers.IO` here, which Kotlin/Native does provide. + */ +internal actual val nsIoDispatcher: CoroutineDispatcher = Dispatchers.Default diff --git a/core/objects/build.gradle.kts b/core/objects/build.gradle.kts index 3300912e32e7..32a436d5440e 100644 --- a/core/objects/build.gradle.kts +++ b/core/objects/build.gradle.kts @@ -1,33 +1,87 @@ +import kotlin.math.min + plugins { - alias(libs.plugins.android.library) - alias(libs.plugins.ksp) - id("kotlin-parcelize") - id("android-module-dependencies") - id("all-open-dependencies") - id("test-module-dependencies") - id("jacoco-module-dependencies") + kotlin("multiplatform") + // NOT com.android.library. AGP 9 refuses that plugin together with the multiplatform plugin. + alias(libs.plugins.android.kmp.library) + kotlin("plugin.allopen") + // No KSP and no kotlin-parcelize: this module runs no annotation processor since its DI moved + // to :app, and nothing here is @Parcelize. } -android { - namespace = "app.aaps.core.objects" +// Restated from all-open-dependencies, which applies com.android.library and cannot be used here. +allOpen { + annotation("app.aaps.annotations.OpenForTesting") } -dependencies { - implementation(project(":core:data")) - implementation(project(":core:interfaces")) - implementation(project(":core:keys")) - implementation(project(":core:ui")) - implementation(project(":core:utils")) +kotlin { + android { + namespace = "app.aaps.core.objects" + compileSdk = Versions.compileSdk + minSdk = min(Versions.minSdk, Versions.wearMinSdk) + // This module owns no resources, but its tests read R classes from :core:interfaces and + // :core:ui through :shared:tests. Without this the R jars never reach the test classpath and + // every test touching one dies with NoClassDefFoundError. + androidResources { enable = true } + withHostTest { + isIncludeAndroidResources = true + isReturnDefaultValues = true + } + compilerOptions { jvmTarget.set(Versions.jvmTarget) } + lint { + checkReleaseBuilds = false + disable += "MissingTranslation" + disable += "ExtraTranslation" + } + } - testImplementation(project(":shared:tests")) - testImplementation(project(":shared:impl")) + iosArm64() + iosSimulatorArm64() - api(libs.kotlin.stdlib.jdk8) + sourceSets { + commonMain { + dependencies { + api(project(":core:data")) + api(project(":core:interfaces")) + api(project(":core:keys")) + api(project(":core:utils")) + } + } + androidMain { + dependencies { + api(libs.kotlin.stdlib.jdk8) + } + } + getByName("androidHostTest") { + dependencies { + implementation(project(":shared:tests")) + implementation(project(":shared:impl")) + implementation(libs.org.junit.jupiter) + implementation(libs.org.junit.jupiter.api) + implementation(libs.org.mockito.junit.jupiter) + implementation(libs.org.mockito.kotlin) + implementation(libs.com.google.truth) + implementation(libs.kotlinx.coroutines.test) + implementation(libs.net.danlew.android.joda) + implementation(libs.org.skyscreamer.jsonassert) + // The platform org.json on the Android unit-test classpath is a stub. + implementation(libs.org.json.android) + runtimeOnly(libs.org.junit.platform.launcher) + } + } + } +} - api(libs.com.google.dagger.android) - api(libs.com.google.dagger.android.support) +// :shared:tests and :shared:impl still carry the five product flavours. A multiplatform module asks +// for none, so Gradle cannot choose a variant - pin the test classpaths to `full`. +listOf("androidHostTestCompileClasspath", "androidHostTestRuntimeClasspath").forEach { name -> + configurations.named(name) { + attributes { + attribute(com.android.build.api.attributes.ProductFlavorAttr.of("standard"), objects.named("full")) + } + } +} - ksp(libs.com.google.dagger.compiler) - ksp(libs.com.google.dagger.hilt.compiler) - ksp(libs.com.google.dagger.android.processor) -} \ No newline at end of file +tasks.withType { + useJUnitPlatform() +} diff --git a/core/objects/src/test/kotlin/app/aaps/core/objects/constraints/ConstraintObjectTest.kt b/core/objects/src/androidHostTest/kotlin/app/aaps/core/objects/constraints/ConstraintObjectTest.kt similarity index 100% rename from core/objects/src/test/kotlin/app/aaps/core/objects/constraints/ConstraintObjectTest.kt rename to core/objects/src/androidHostTest/kotlin/app/aaps/core/objects/constraints/ConstraintObjectTest.kt diff --git a/core/objects/src/test/kotlin/app/aaps/core/objects/extensions/BlockExtensionKtTest.kt b/core/objects/src/androidHostTest/kotlin/app/aaps/core/objects/extensions/BlockExtensionKtTest.kt similarity index 100% rename from core/objects/src/test/kotlin/app/aaps/core/objects/extensions/BlockExtensionKtTest.kt rename to core/objects/src/androidHostTest/kotlin/app/aaps/core/objects/extensions/BlockExtensionKtTest.kt diff --git a/core/objects/src/androidHostTest/kotlin/app/aaps/core/objects/extensions/BlockRenderTest.kt b/core/objects/src/androidHostTest/kotlin/app/aaps/core/objects/extensions/BlockRenderTest.kt new file mode 100644 index 000000000000..cc239918fc68 --- /dev/null +++ b/core/objects/src/androidHostTest/kotlin/app/aaps/core/objects/extensions/BlockRenderTest.kt @@ -0,0 +1,266 @@ +package app.aaps.core.objects.extensions + +import app.aaps.core.data.model.data.Block +import app.aaps.core.data.model.data.TargetBlock +import app.aaps.core.data.time.T +import app.aaps.core.interfaces.utils.DateUtil +import com.google.common.truth.Truth.assertThat +import com.google.common.truth.Truth.assertWithMessage +import org.json.JSONArray +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever +import java.util.Locale + +/** + * Pins the block → JSON renderers. + * + * These author two formats AAPS does not own alone: the profile document that master and client + * exchange (and that a 3.4.x build must still be able to read), and the Nightscout profile store. A + * renderer that drifted from [blockFromJson] would not fail loudly — profiles would round-trip to + * slightly different times or values, and the first sign would be a wrong basal rate. + * + * So the central property here is **round-trip identity**: rendering a schedule and parsing it back + * must return the same blocks, for every schedule the parser accepts. + */ +class BlockRenderTest { + + private val dateUtil: DateUtil = mock() + private lateinit var originalLocale: Locale + + @BeforeEach fun setUp() { + // toSeconds() is the real parser's job; here it only has to agree with what we render. + whenever(dateUtil.toSeconds(org.mockito.kotlin.any())).thenAnswer { invocation -> + val text = invocation.getArgument(0) + text.substringBefore(':').toInt() * 3600 + text.substringAfter(':').toInt() * 60 + } + originalLocale = Locale.getDefault() + } + + @AfterEach fun restoreLocale() { + Locale.setDefault(originalLocale) + } + + private fun blocks(vararg hoursAndValue: Pair): List = + hoursAndValue.map { (hours, value) -> Block(T.hours(hours.toLong()).msecs(), value) } + + private fun targets(vararg hoursLowHigh: Triple): List = + hoursLowHigh.map { (hours, low, high) -> TargetBlock(T.hours(hours.toLong()).msecs(), low, high) } + + @Test + fun `a whole-day single block renders as one 00-00 entry`() { + val json = singleBlock(0.1).toJSONArray() + + assertThat(json.length()).isEqualTo(1) + assertThat(json.getJSONObject(0).getString("time")).isEqualTo("00:00") + assertThat(json.getJSONObject(0).getInt("timeAsSeconds")).isEqualTo(0) + assertThat(json.getJSONObject(0).getDouble("value")).isEqualTo(0.1) + } + + @Test + fun `start times are the running sum of the durations before them`() { + val json = blocks(1 to 1.0, 2 to 2.0, 21 to 3.0).toJSONArray() + + assertThat((0 until json.length()).map { json.getJSONObject(it).getString("time") }) + .containsExactly("00:00", "01:00", "03:00").inOrder() + assertThat((0 until json.length()).map { json.getJSONObject(it).getInt("timeAsSeconds") }) + .containsExactly(0, 3600, 10800).inOrder() + } + + @Test + fun `hours past nine keep two digits`() { + val json = blocks(10 to 1.0, 14 to 2.0).toJSONArray() + + assertThat(json.getJSONObject(1).getString("time")).isEqualTo("10:00") + } + + /** + * `String.format("%02d")` renders digits with the *locale's* zero digit, so under a locale like + * ar-EG it produces "٠١:٠٠" — which no reader parses back. The editor used to build times that + * way. Rendering must not depend on the device locale at all. + */ + @Test + fun `times are ASCII regardless of the device locale`() { + Locale.setDefault(Locale.forLanguageTag("ar-EG")) + + val json = blocks(1 to 1.0, 23 to 2.0).toJSONArray() + + assertThat(json.getJSONObject(1).getString("time")).isEqualTo("01:00") + } + + @Test + fun `a rendered schedule parses back to the same blocks`() { + val cases = listOf( + singleBlock(0.1), + blocks(1 to 1.0, 23 to 2.0), + blocks(1 to 1.0, 2 to 2.0, 21 to 3.0), + List(24) { Block(T.hours(1).msecs(), it * 0.05) } + ) + + cases.forEach { original -> + assertWithMessage("round trip of %s", original) + .that(blockFromJsonArray(original.toJSONArray(), dateUtil)).isEqualTo(original) + } + } + + @Test + fun `a rendered target schedule parses back to the same blocks`() { + val cases = listOf( + singleTargetBlock(110.0, 120.0), + targets(Triple(6, 100.0, 110.0), Triple(18, 105.0, 115.0)), + List(24) { TargetBlock(T.hours(1).msecs(), 100.0 + it, 120.0 + it) } + ) + + cases.forEach { original -> + val parsed = targetBlockFromJsonArray(original.lowToJSONArray(), original.highToJSONArray(), dateUtil) + assertWithMessage("round trip of %s", original).that(parsed).isEqualTo(original) + } + } + + /** Low and high are rendered as two arrays but must stay aligned entry for entry. */ + @Test + fun `low and high render to matching times`() { + val target = targets(Triple(6, 100.0, 110.0), Triple(18, 105.0, 115.0)) + + val low = target.lowToJSONArray() + val high = target.highToJSONArray() + + assertThat(low.length()).isEqualTo(high.length()) + for (i in 0 until low.length()) { + assertThat(high.getJSONObject(i).getString("time")).isEqualTo(low.getJSONObject(i).getString("time")) + } + assertThat(low.getJSONObject(1).getDouble("value")).isEqualTo(105.0) + assertThat(high.getJSONObject(1).getDouble("value")).isEqualTo(115.0) + } + + /** + * The shape a 3.4.x build wrote and still has to be able to read: exactly these three fields, + * `time` as `HH:MM`. Adding or renaming one would be invisible to every test above, which only + * check that our own reader agrees with our own writer. + */ + @Test + fun `an entry carries exactly time, timeAsSeconds and value`() { + val entry = blocks(24 to 0.5).toJSONArray().getJSONObject(0) + + assertThat(entry.keys().asSequence().toList()).containsExactly("time", "timeAsSeconds", "value") + } + + @Test + fun `an empty schedule renders as an empty array rather than failing`() { + assertThat(emptyList().toJSONArray().length()).isEqualTo(0) + assertThat(emptyList().lowToJSONArray().length()).isEqualTo(0) + } + + /** + * A profile saved by an older build under a locale with non-ASCII digits. + * + * `String.format("%02d:00")` wrote the locale's own digits, and `DateUtil.toSeconds` matches + * ASCII `\d` only, so it silently answers 0 for every entry. Nothing downstream notices: all-zero + * start times still divide evenly by 3600, so the parse "succeeds" and returns blocks of zero + * duration. Since the stored document is now re-rendered from these blocks, reading such a `time` + * would flatten the whole schedule to 00:00 and publish that to the sync channel and to + * Nightscout. Leading with `timeAsSeconds` makes the broken text irrelevant. + */ + @Test + fun `a time written with non-ASCII digits is harmless because timeAsSeconds leads`() { + val arabicDigits = JSONArray( + """[{"time":"٠٠:٠٠","timeAsSeconds":0,"value":0.6}, + {"time":"٠٦:٠٠","timeAsSeconds":21600,"value":1.2}, + {"time":"٢٠:٠٠","timeAsSeconds":72000,"value":0.9}]""" + ) + + val parsed = blockFromJsonArray(arabicDigits, dateUtil) + + assertThat(parsed).isEqualTo( + listOf( + Block(T.hours(6).msecs(), 0.6), + Block(T.hours(14).msecs(), 1.2), + Block(T.hours(4).msecs(), 0.9) + ) + ) + } + + /** The same recovery for the paired target arrays. */ + @Test + fun `non-ASCII target times are harmless too`() { + val low = JSONArray("""[{"time":"٠٠:٠٠","timeAsSeconds":0,"value":100},{"time":"٠٦:٠٠","timeAsSeconds":21600,"value":105}]""") + val high = JSONArray("""[{"time":"٠٠:٠٠","timeAsSeconds":0,"value":110},{"time":"٠٦:٠٠","timeAsSeconds":21600,"value":115}]""") + + assertThat(targetBlockFromJsonArray(low, high, dateUtil)).isEqualTo( + listOf(TargetBlock(T.hours(6).msecs(), 100.0, 110.0), TargetBlock(T.hours(18).msecs(), 105.0, 115.0)) + ) + } + + /** + * `timeAsSeconds` is the source of truth, so a non-zero one wins over a readable but disagreeing + * `time`. This is the field the editor has always read its rows back from. + */ + @Test + fun `a non-zero timeAsSeconds wins over a disagreeing time`() { + val conflicting = JSONArray( + """[{"time":"00:00","timeAsSeconds":0,"value":1.0}, + {"time":"06:00","timeAsSeconds":28800,"value":2.0}]""" + ) + + // 28800 = 08:00, not the 06:00 the text claims. + assertThat(blockFromJsonArray(conflicting, dateUtil)) + .isEqualTo(listOf(Block(T.hours(8).msecs(), 1.0), Block(T.hours(16).msecs(), 2.0))) + } + + /** + * The mirror of the ar-SA bug, and the reason zero means "ask `time`". + * + * An uploader that omits `timeAsSeconds` from a defaults-filled struct writes 0 on every entry. + * Trusting that blindly would flatten the whole schedule to midnight - exactly the failure the + * fallback exists to prevent, just from the other direction. + */ + @Test + fun `an all-zero timeAsSeconds falls back to time rather than flattening`() { + val zeroed = JSONArray( + """[{"time":"00:00","timeAsSeconds":0,"value":0.6}, + {"time":"06:00","timeAsSeconds":0,"value":1.2}, + {"time":"20:00","timeAsSeconds":0,"value":0.9}]""" + ) + + assertThat(blockFromJsonArray(zeroed, dateUtil)).isEqualTo( + listOf( + Block(T.hours(6).msecs(), 0.6), + Block(T.hours(14).msecs(), 1.2), + Block(T.hours(4).msecs(), 0.9) + ) + ) + } + + /** A genuine midnight block is zero in both fields and must survive the fallback intact. */ + @Test + fun `a real midnight block still reads as zero`() { + val parsed = blockFromJsonArray(JSONArray("""[{"time":"00:00","timeAsSeconds":0,"value":0.7}]"""), dateUtil) + + assertThat(parsed).isEqualTo(listOf(Block(T.hours(24).msecs(), 0.7))) + } + + /** Zero seconds with an unreadable time is still a midnight block, not a rejection. */ + @Test + fun `zero seconds with an unreadable time is honoured`() { + val parsed = blockFromJsonArray(JSONArray("""[{"time":"٠٠:٠٠","timeAsSeconds":0,"value":0.7}]"""), dateUtil) + + assertThat(parsed).isEqualTo(listOf(Block(T.hours(24).msecs(), 0.7))) + } + + /** With neither field usable there is nothing to recover, so the profile is still invalid. */ + @Test + fun `an unreadable time and no timeAsSeconds is still rejected`() { + assertThat(blockFromJsonArray(JSONArray("""[{"time":"٠٦:٠٠","value":1.0}]"""), dateUtil)).isNull() + assertThat(blockFromJsonArray(JSONArray("""[{"value":1.0}]"""), dateUtil)).isNull() + } + + /** Guards the adapter itself: the kotlinx form and the org.json form must not diverge. */ + @Test + fun `the org json adapter matches the kotlinx renderer`() { + val schedule = blocks(1 to 1.0, 23 to 2.0) + + assertThat(JSONArray(schedule.toJsonArray().toString()).toString()).isEqualTo(schedule.toJSONArray().toString()) + } +} diff --git a/core/objects/src/test/kotlin/app/aaps/core/objects/extensions/FlowExtensionKtTest.kt b/core/objects/src/androidHostTest/kotlin/app/aaps/core/objects/extensions/FlowExtensionKtTest.kt similarity index 100% rename from core/objects/src/test/kotlin/app/aaps/core/objects/extensions/FlowExtensionKtTest.kt rename to core/objects/src/androidHostTest/kotlin/app/aaps/core/objects/extensions/FlowExtensionKtTest.kt diff --git a/core/objects/src/test/kotlin/app/aaps/core/objects/extensions/JSONObjectExtTest.kt b/core/objects/src/androidHostTest/kotlin/app/aaps/core/objects/extensions/JSONObjectExtTest.kt similarity index 54% rename from core/objects/src/test/kotlin/app/aaps/core/objects/extensions/JSONObjectExtTest.kt rename to core/objects/src/androidHostTest/kotlin/app/aaps/core/objects/extensions/JSONObjectExtTest.kt index fca0f4cf05af..00aa99dc15aa 100644 --- a/core/objects/src/test/kotlin/app/aaps/core/objects/extensions/JSONObjectExtTest.kt +++ b/core/objects/src/androidHostTest/kotlin/app/aaps/core/objects/extensions/JSONObjectExtTest.kt @@ -7,13 +7,24 @@ import app.aaps.core.keys.StringNonKey import app.aaps.core.keys.UnitDoubleKey import app.aaps.core.keys.interfaces.Preferences import com.google.common.truth.Truth.assertThat +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.double +import kotlinx.serialization.json.int +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import kotlinx.serialization.json.long +import kotlinx.serialization.json.put +import kotlinx.serialization.json.putJsonObject import org.json.JSONObject import org.junit.jupiter.api.Test import org.mockito.kotlin.mock import org.mockito.kotlin.verify import org.mockito.kotlin.whenever -/** Covers the org.json [JSONObject] preference extensions: putIfThereIsValue skip rules + put/store round-trip. */ +/** + * Covers the [JSONObject] preference extensions (putIfThereIsValue skip rules + put/store round-trip) + * and their kotlinx twins, including [with], which replaced writing into an already built document. + */ class JSONObjectExtTest { @Test @@ -90,4 +101,84 @@ class JSONObjectExtTest { JSONObject().store(IntKey.OverviewCarbsButtonIncrement1, prefs) // key not present → no-op // nothing to verify beyond no exception; getInt on absent key would have thrown inside store } + + // region kotlinx twins + + @Test + fun kotlinxPutIfThereIsValue_writesNonZeroSkipsZeroAndNull() { + val j = buildJsonObject { + putIfThereIsValue("i", 5) + putIfThereIsValue("iz", 0) + putIfThereIsValue("inull", null as Int?) + putIfThereIsValue("l", 5L) + putIfThereIsValue("lz", 0L) + putIfThereIsValue("d", 1.5) + putIfThereIsValue("dz", 0.0) + putIfThereIsValue("s", "x") + putIfThereIsValue("se", "") + } + assertThat(j.getValue("i").jsonPrimitive.int).isEqualTo(5) + assertThat(j.containsKey("iz")).isFalse() + assertThat(j.containsKey("inull")).isFalse() + assertThat(j.getValue("l").jsonPrimitive.long).isEqualTo(5L) + assertThat(j.containsKey("lz")).isFalse() + assertThat(j.getValue("d").jsonPrimitive.double).isEqualTo(1.5) + assertThat(j.containsKey("dz")).isFalse() + assertThat(j.getValue("s").jsonPrimitive.content).isEqualTo("x") + assertThat(j.containsKey("se")).isFalse() + } + + /** + * [with] replaces code that used to keep writing into a finished document, so it has to behave the + * same way a repeated `put` did: entries add, and a repeat of an existing name replaces it. + */ + @Test + fun with_addsEntries() { + val base = buildJsonObject { put("a", 1) } + + val merged = base.with { put("b", 2) } + + assertThat(merged.getValue("a").jsonPrimitive.int).isEqualTo(1) + assertThat(merged.getValue("b").jsonPrimitive.int).isEqualTo(2) + } + + @Test + fun with_laterEntryWinsOverExistingOne() { + val base = buildJsonObject { put("a", 1) } + + val merged = base.with { put("a", 99) } + + assertThat(merged.getValue("a").jsonPrimitive.int).isEqualTo(99) + assertThat(merged).hasSize(1) + } + + /** The source is a value: merging must hand back a new document and leave the old one alone. */ + @Test + fun with_leavesTheSourceUntouched() { + val base = buildJsonObject { put("a", 1) } + + base.with { put("b", 2) } + + assertThat(base.containsKey("b")).isFalse() + assertThat(base).hasSize(1) + } + + @Test + fun with_addingNothingGivesAnEqualDocument() { + val base = buildJsonObject { put("a", 1); put("b", "x") } + + assertThat(base.with { }).isEqualTo(base) + } + + /** Nested values survive the copy - the merge is shallow, so the sub-document is carried over as is. */ + @Test + fun with_keepsNestedDocuments() { + val base = buildJsonObject { putJsonObject("inner") { put("deep", 7) } } + + val merged = base.with { put("a", 1) } + + assertThat(merged.getValue("inner").jsonObject.getValue("deep").jsonPrimitive.int).isEqualTo(7) + } + + // endregion } diff --git a/core/objects/src/test/kotlin/app/aaps/core/objects/extensions/ProfileExtensionKtTest.kt b/core/objects/src/androidHostTest/kotlin/app/aaps/core/objects/extensions/ProfileExtensionKtTest.kt similarity index 100% rename from core/objects/src/test/kotlin/app/aaps/core/objects/extensions/ProfileExtensionKtTest.kt rename to core/objects/src/androidHostTest/kotlin/app/aaps/core/objects/extensions/ProfileExtensionKtTest.kt diff --git a/core/objects/src/test/kotlin/app/aaps/core/objects/extensions/SceneSerializerTest.kt b/core/objects/src/androidHostTest/kotlin/app/aaps/core/objects/extensions/SceneSerializerTest.kt similarity index 100% rename from core/objects/src/test/kotlin/app/aaps/core/objects/extensions/SceneSerializerTest.kt rename to core/objects/src/androidHostTest/kotlin/app/aaps/core/objects/extensions/SceneSerializerTest.kt diff --git a/core/objects/src/test/kotlin/app/aaps/core/objects/interfaces/iob/IobTest.kt b/core/objects/src/androidHostTest/kotlin/app/aaps/core/objects/interfaces/iob/IobTest.kt similarity index 100% rename from core/objects/src/test/kotlin/app/aaps/core/objects/interfaces/iob/IobTest.kt rename to core/objects/src/androidHostTest/kotlin/app/aaps/core/objects/interfaces/iob/IobTest.kt diff --git a/core/objects/src/test/kotlin/app/aaps/core/objects/interfaces/iob/IobTotalTest.kt b/core/objects/src/androidHostTest/kotlin/app/aaps/core/objects/interfaces/iob/IobTotalTest.kt similarity index 100% rename from core/objects/src/test/kotlin/app/aaps/core/objects/interfaces/iob/IobTotalTest.kt rename to core/objects/src/androidHostTest/kotlin/app/aaps/core/objects/interfaces/iob/IobTotalTest.kt diff --git a/core/objects/src/test/kotlin/app/aaps/core/objects/interfaces/iob/MealDataTest.kt b/core/objects/src/androidHostTest/kotlin/app/aaps/core/objects/interfaces/iob/MealDataTest.kt similarity index 100% rename from core/objects/src/test/kotlin/app/aaps/core/objects/interfaces/iob/MealDataTest.kt rename to core/objects/src/androidHostTest/kotlin/app/aaps/core/objects/interfaces/iob/MealDataTest.kt diff --git a/core/objects/src/test/kotlin/app/aaps/core/objects/interfaces/pump/defs/PluginDescriptionTest.kt b/core/objects/src/androidHostTest/kotlin/app/aaps/core/objects/interfaces/pump/defs/PluginDescriptionTest.kt similarity index 81% rename from core/objects/src/test/kotlin/app/aaps/core/objects/interfaces/pump/defs/PluginDescriptionTest.kt rename to core/objects/src/androidHostTest/kotlin/app/aaps/core/objects/interfaces/pump/defs/PluginDescriptionTest.kt index d5de6971a812..64598dd9411d 100644 --- a/core/objects/src/test/kotlin/app/aaps/core/objects/interfaces/pump/defs/PluginDescriptionTest.kt +++ b/core/objects/src/androidHostTest/kotlin/app/aaps/core/objects/interfaces/pump/defs/PluginDescriptionTest.kt @@ -2,6 +2,7 @@ package app.aaps.core.objects.interfaces.pump.defs import app.aaps.core.data.plugin.PluginType import app.aaps.core.interfaces.plugin.PluginDescription +import app.aaps.core.keys.interfaces.TextRef import com.google.common.truth.Truth.assertThat import org.junit.jupiter.api.Test @@ -28,13 +29,13 @@ class PluginDescriptionTest { } @Test fun pluginName() { - val pluginDescription = PluginDescription().pluginName(10) - assertThat(pluginDescription.pluginName.toLong()).isEqualTo(10) + val ref = TextRef.AndroidRes(10) + assertThat(PluginDescription().pluginName(ref).pluginName).isEqualTo(ref) } @Test fun shortNameTest() { - val pluginDescription = PluginDescription().shortName(10) - assertThat(pluginDescription.shortName.toLong()).isEqualTo(10) + val ref = TextRef.AndroidRes(10) + assertThat(PluginDescription().shortName(ref).shortName).isEqualTo(ref) } @Test fun enableByDefault() { diff --git a/core/objects/src/test/kotlin/app/aaps/core/objects/interfaces/pump/defs/PumpDescriptionTest.kt b/core/objects/src/androidHostTest/kotlin/app/aaps/core/objects/interfaces/pump/defs/PumpDescriptionTest.kt similarity index 98% rename from core/objects/src/test/kotlin/app/aaps/core/objects/interfaces/pump/defs/PumpDescriptionTest.kt rename to core/objects/src/androidHostTest/kotlin/app/aaps/core/objects/interfaces/pump/defs/PumpDescriptionTest.kt index 3eb69bbaf08f..44a178c253fd 100644 --- a/core/objects/src/test/kotlin/app/aaps/core/objects/interfaces/pump/defs/PumpDescriptionTest.kt +++ b/core/objects/src/androidHostTest/kotlin/app/aaps/core/objects/interfaces/pump/defs/PumpDescriptionTest.kt @@ -1,7 +1,6 @@ package app.aaps.core.objects.interfaces.pump.defs import app.aaps.core.data.pump.defs.Capability -import app.aaps.core.data.pump.defs.PumpCapability import app.aaps.core.data.pump.defs.PumpDescription import app.aaps.core.data.pump.defs.PumpTempBasalType import app.aaps.core.data.pump.defs.PumpType diff --git a/core/objects/src/androidHostTest/kotlin/app/aaps/core/objects/interfaces/utils/MidnightTimeParityTest.kt b/core/objects/src/androidHostTest/kotlin/app/aaps/core/objects/interfaces/utils/MidnightTimeParityTest.kt new file mode 100644 index 000000000000..354ec6cba9d0 --- /dev/null +++ b/core/objects/src/androidHostTest/kotlin/app/aaps/core/objects/interfaces/utils/MidnightTimeParityTest.kt @@ -0,0 +1,128 @@ +package app.aaps.core.objects.interfaces.utils + +import app.aaps.core.interfaces.utils.MidnightTime +import com.google.common.truth.Truth.assertWithMessage +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import java.time.Instant +import java.time.LocalTime +import java.time.ZoneId +import java.util.TimeZone + +/** + * Pins [MidnightTime] against the `java.time` implementation it is being converted away from. + * + * The reference below is a literal copy of the original bodies. Every assertion compares the live + * [MidnightTime] to it, so the conversion to kotlinx-datetime has to reproduce the old answer + * exactly rather than merely produce a plausible one. + * + * **Why this file exists rather than trusting the existing [MidnightTimeTest].** That suite only + * asks "is the result midnight, today, in this machine's zone". Every implementation that is roughly + * right passes it. The interesting cases are the two hours a year when local midnight is not a + * simple offset from UTC, and this repository has already shipped a fix for exactly that + * (`calculated day's difference over DST correctly`). So the sweep below runs hour by hour across + * both transitions. + * + * The zone is pinned to Europe/Prague for the same reason the iOS workflow pins it: a CI machine is + * usually UTC, where every one of these assertions holds trivially because the offset never moves. + */ +class MidnightTimeParityTest { + + private lateinit var original: TimeZone + + @BeforeEach fun pinZone() { + original = TimeZone.getDefault() + TimeZone.setDefault(TimeZone.getTimeZone("Europe/Prague")) + } + + @AfterEach fun restoreZone() { + TimeZone.setDefault(original) + } + + // ------------------------------------------------------------------ the reference + + private fun referenceCalc(time: Long): Long = + Instant.ofEpochMilli(time).atZone(ZoneId.systemDefault()) + .with(LocalTime.of(0, 0, 0, 0)) + .toInstant().toEpochMilli() + + private fun referenceCalcDaysBack(time: Long, daysBack: Long): Long = + Instant.ofEpochMilli(time).atZone(ZoneId.systemDefault()) + .with(LocalTime.of(0, 0, 0, 0)) + .minusDays(daysBack) + .toInstant().toEpochMilli() + + // ------------------------------------------------------------------ sweeps + + /** 2026-03-29 in Europe/Prague: 02:00 CET jumps straight to 03:00 CEST, so 02:xx never happens. */ + private val springForward = Instant.parse("2026-03-28T00:00:00Z").toEpochMilli() + + /** 2026-10-25 in Europe/Prague: 03:00 CEST falls back to 02:00 CET, so 02:xx happens twice. */ + private val fallBack = Instant.parse("2026-10-24T00:00:00Z").toEpochMilli() + + private val hour = 3_600_000L + + private fun sweep(from: Long, hours: Int, label: String) { + for (i in 0 until hours) { + val t = from + i * hour + assertWithMessage("%s: calc(%s)", label, Instant.ofEpochMilli(t)) + .that(MidnightTime.calc(t)).isEqualTo(referenceCalc(t)) + } + } + + @Test fun `calc matches java time across spring forward`() = sweep(springForward, 72, "spring") + + @Test fun `calc matches java time across fall back`() = sweep(fallBack, 72, "fall") + + @Test fun `calcDaysBack matches java time across spring forward`() { + for (i in 0 until 72) { + val t = springForward + i * hour + for (daysBack in 0L..7L) + assertWithMessage("spring: calcDaysBack(%s, %s)", Instant.ofEpochMilli(t), daysBack) + .that(MidnightTime.calcDaysBack(t, daysBack)) + .isEqualTo(referenceCalcDaysBack(t, daysBack)) + } + } + + @Test fun `calcDaysBack matches java time across fall back`() { + for (i in 0 until 72) { + val t = fallBack + i * hour + for (daysBack in 0L..7L) + assertWithMessage("fall: calcDaysBack(%s, %s)", Instant.ofEpochMilli(t), daysBack) + .that(MidnightTime.calcDaysBack(t, daysBack)) + .isEqualTo(referenceCalcDaysBack(t, daysBack)) + } + } + + /** + * Proves the sweeps above are not vacuous. + * + * If the default zone failed to apply, or if a zone without daylight saving were chosen, every + * midnight would sit at the same offset from UTC and the parity assertions would hold for any + * implementation that simply floored to a whole day. This asserts the opposite: across the + * spring transition the local midnights really do land on two different UTC offsets, so the + * sweep is exercising the case it claims to. + */ + @Test fun `the sweep actually crosses an offset change`() { + val offsets = (0 until 72) + .map { springForward + it * hour } + .map { t -> Math.floorMod(MidnightTime.calc(t), 24 * hour) } + .toSet() + assertWithMessage("local midnight must sit at more than one UTC offset across a DST change") + .that(offsets.size).isGreaterThan(1) + } + + /** + * A whole ordinary year, one sample a day, so the sweeps above are not the only evidence and a + * mistake that only shows outside a transition week still fails. + */ + @Test fun `calc matches java time over a full year`() { + val start = Instant.parse("2026-01-01T12:00:00Z").toEpochMilli() + for (day in 0 until 365) { + val t = start + day * 24 * hour + assertWithMessage("day %s (%s)", day, Instant.ofEpochMilli(t)) + .that(MidnightTime.calc(t)).isEqualTo(referenceCalc(t)) + } + } +} diff --git a/core/objects/src/test/kotlin/app/aaps/core/objects/interfaces/utils/MidnightTimeTest.kt b/core/objects/src/androidHostTest/kotlin/app/aaps/core/objects/interfaces/utils/MidnightTimeTest.kt similarity index 87% rename from core/objects/src/test/kotlin/app/aaps/core/objects/interfaces/utils/MidnightTimeTest.kt rename to core/objects/src/androidHostTest/kotlin/app/aaps/core/objects/interfaces/utils/MidnightTimeTest.kt index 06deec9fd747..1ae7be3c5b2d 100644 --- a/core/objects/src/test/kotlin/app/aaps/core/objects/interfaces/utils/MidnightTimeTest.kt +++ b/core/objects/src/androidHostTest/kotlin/app/aaps/core/objects/interfaces/utils/MidnightTimeTest.kt @@ -57,10 +57,14 @@ class MidnightTimeTest { assertThat(MidnightTime.calcDaysBack(5)).isEqualTo(c.timeInMillis) } - @Test fun resetCache() { + /** + * `calc` used to consult a cache that nothing ever wrote to, so it always recomputed. The cache + * is gone; this pins that repeated calls still agree, which is all the cache could ever have + * promised. + */ + @Test fun repeatedCallsAgree() { val now = System.currentTimeMillis() - MidnightTime.calc(now) - MidnightTime.resetCache() - assertThat(MidnightTime.times.size().toLong()).isEqualTo(0L) + + assertThat(MidnightTime.calc(now)).isEqualTo(MidnightTime.calc(now)) } } diff --git a/core/objects/src/androidHostTest/kotlin/app/aaps/core/objects/interfaces/utils/RoundTest.kt b/core/objects/src/androidHostTest/kotlin/app/aaps/core/objects/interfaces/utils/RoundTest.kt new file mode 100644 index 000000000000..8a7f494ace74 --- /dev/null +++ b/core/objects/src/androidHostTest/kotlin/app/aaps/core/objects/interfaces/utils/RoundTest.kt @@ -0,0 +1,155 @@ +package app.aaps.core.objects.interfaces.utils + +import app.aaps.core.interfaces.utils.Round +import com.google.common.truth.Truth.assertThat +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertThrows +import java.math.BigDecimal +import kotlin.math.ceil +import kotlin.math.floor + +class RoundTest { + + @Test + fun roundToTest() { + assertThat(Round.roundTo(0.54, 0.05)).isWithin(0.00000000000000000001).of(0.55) + assertThat(Round.roundTo(-3.2553715764602713, 0.01)).isWithin(0.00000000000000000001).of(-3.26) + assertThat(Round.roundTo(0.8156666666666667, 0.001)).isWithin(0.00000000000000000001).of(0.816) + assertThat(Round.roundTo(0.235, 0.001)).isWithin(0.00000000000000000001).of(0.235) + assertThat(Round.roundTo(0.3, 0.1)).isWithin(0.00000000000000001).of(0.3) + assertThat(Round.roundTo(0.0016960652144170627, 0.0001)).isWithin(0.00000000000000000001).of(0.0017) + assertThat(Round.roundTo(0.007804436682291013, 0.0001)).isWithin(0.00000000000000000001).of(0.0078) + assertThat(Round.roundTo(0.6, 0.05)).isWithin(0.00000000000000000001).of(0.6) + assertThat(Round.roundTo(1.49, 1.0)).isWithin(0.00000000000000000001).of(1.0) + assertThat(Round.roundTo(0.0, 1.0)).isWithin(0.00000000000000000001).of(0.0) + } + + @Test + fun floorToTest() { + // Genuine floors: a value strictly between two steps must still floor DOWN + assertThat(Round.floorTo(0.54, 0.05)).isWithin(0.00000001).of(0.5) + assertThat(Round.floorTo(1.59, 1.0)).isWithin(0.00000001).of(1.0) + assertThat(Round.floorTo(0.0, 1.0)).isWithin(0.00000001).of(0.0) + // Regression: on-grid values must not lose a whole step to IEEE-754 (x/step lands just below an integer) + assertThat(Round.floorTo(0.15, 0.05)).isWithin(0.00000001).of(0.15) + assertThat(Round.floorTo(0.30, 0.05)).isWithin(0.00000001).of(0.30) + assertThat(Round.floorTo(0.95, 0.05)).isWithin(0.00000001).of(0.95) + assertThat(Round.floorTo(0.30, 0.1)).isWithin(0.00000001).of(0.30) + assertThat(Round.floorTo(1.20, 0.1)).isWithin(0.00000001).of(1.20) + assertThat(Round.floorTo(0.29, 0.01)).isWithin(0.00000001).of(0.29) + } + + @Test + fun ceilToTest() { + // Genuine ceilings: a value strictly between two steps must still ceil UP + assertThat(Round.ceilTo(0.54, 0.1)).isWithin(0.00000001).of(0.6) + assertThat(Round.ceilTo(1.49999, 1.0)).isWithin(0.00000001).of(2.0) + assertThat(Round.ceilTo(0.0, 1.0)).isWithin(0.00000001).of(0.0) + // Regression: on-grid values must not gain a whole step to IEEE-754 (x/step lands just above an integer) + assertThat(Round.ceilTo(0.07, 0.01)).isWithin(0.00000001).of(0.07) + assertThat(Round.ceilTo(0.14, 0.01)).isWithin(0.00000001).of(0.14) + assertThat(Round.ceilTo(0.28, 0.01)).isWithin(0.00000001).of(0.28) + assertThat(Round.ceilTo(0.56, 0.01)).isWithin(0.00000001).of(0.56) + } + + @Test + fun isSameTest() { + assertThat(Round.isSame(0.54, 0.54)).isTrue() + } + + /** + * NaN reaching a dose rounding function means an upstream calculation already went wrong, so it + * has to be rejected rather than rounded into a number. The throw had no test at all. + */ + @Test + fun `roundTo rejects NaN`() { + assertThrows { Round.roundTo(Double.NaN, 0.05) } + } + + // region parity with the old BigDecimal implementation + + /** Exactly what Round did before: BigDecimal.valueOf(n).multiply(BigDecimal.valueOf(step)).toDouble(). */ + private fun oldWay(n: Long, step: Double): Double = + BigDecimal.valueOf(n).multiply(BigDecimal.valueOf(step)).toDouble() + + /** Every step size any driver in this repo actually asks for, plus 1.0 for whole units. */ + private val realSteps = listOf(0.0001, 0.001, 0.01, 0.025, 0.05, 0.1, 0.5, 1.0) + + /** + * The reason this can be swept rather than spot-checked: `times` claims to produce *the same + * double* as BigDecimal did, not merely a close one. A sweep over every real step size and a wide + * range of grid positions either holds exactly or does not. + * + * `isEqualTo`, deliberately - a tolerance here would hide precisely the digits this code exists to + * get rid of. + */ + @Test + fun `roundTo matches the old BigDecimal result exactly`() { + for (step in realSteps) + for (n in -2000..2000) { + val x = n * step + if (x == 0.0) continue + assertThat(Round.roundTo(x, step)).isEqualTo(oldWay(n.toLong(), step)) + } + } + + @Test + fun `floorTo and ceilTo match the old BigDecimal result exactly`() { + for (step in realSteps) + for (n in -2000..2000) { + val x = n * step + if (x == 0.0) continue + // On-grid input, so both directions land on n and the comparison is about the multiply. + assertThat(Round.floorTo(x, step)).isEqualTo(oldWay(n.toLong(), step)) + assertThat(Round.ceilTo(x, step)).isEqualTo(oldWay(n.toLong(), step)) + } + } + + /** + * Off-grid inputs, where floor and ceil pick different step counts. Keeps the sweep above from + * only ever exercising the snapping branch. + */ + @Test + fun `off grid values still match the old result in both directions`() { + for (step in realSteps) + for (n in -500..500) { + val x = n * step + step / 3.0 + if (x == 0.0) continue + assertThat(Round.floorTo(x, step)).isEqualTo(oldWay(floor(x / step).toLong(), step)) + assertThat(Round.ceilTo(x, step)).isEqualTo(oldWay(ceil(x / step).toLong(), step)) + } + } + + /** + * 0.0001 is below 1e-3, so Double.toString spells it "1.0E-4". Reading the scale by looking for a + * decimal point alone would get 1 instead of 4 and be out by a factor of a thousand. The step is + * genuinely used, so this is a real branch, not a hypothetical one. + */ + @Test + fun `a step written in scientific notation keeps its scale`() { + assertThat(0.0001.toString()).contains("E") // the premise, in case a toString ever changes + assertThat(Round.roundTo(0.0017, 0.0001)).isEqualTo(0.0017) + assertThat(Round.roundTo(0.00123456, 0.0001)).isEqualTo(0.0012) + assertThat(Round.floorTo(0.00019, 0.0001)).isEqualTo(0.0001) + } + + /** A whole-number step has no fractional digits at all - scale 0, which is its own branch. */ + @Test + fun `a whole number step works`() { + assertThat(Round.roundTo(7.4, 1.0)).isEqualTo(7.0) + assertThat(Round.roundTo(7.6, 1.0)).isEqualTo(8.0) + assertThat(Round.floorTo(7.9, 1.0)).isEqualTo(7.0) + assertThat(Round.ceilTo(7.1, 1.0)).isEqualTo(8.0) + } + + /** The digits this whole function exists to avoid. */ + @Test + fun `results carry no float noise`() { + assertThat(Round.roundTo(0.15, 0.05)).isEqualTo(0.15) // plain 3 * 0.05 is 0.15000000000000002 + assertThat(Round.roundTo(0.6, 0.05)).isEqualTo(0.6) // plain 12 * 0.05 is 0.6000000000000001 + assertThat(Round.roundTo(0.3, 0.1)).isEqualTo(0.3) // plain 3 * 0.1 is 0.30000000000000004 + assertThat(Round.roundTo(-3.2553715764602713, 0.01)).isEqualTo(-3.26) + } + + // endregion +} diff --git a/core/objects/src/test/kotlin/app/aaps/core/objects/interfaces/utils/TTest.kt b/core/objects/src/androidHostTest/kotlin/app/aaps/core/objects/interfaces/utils/TTest.kt similarity index 100% rename from core/objects/src/test/kotlin/app/aaps/core/objects/interfaces/utils/TTest.kt rename to core/objects/src/androidHostTest/kotlin/app/aaps/core/objects/interfaces/utils/TTest.kt diff --git a/core/objects/src/androidHostTest/kotlin/app/aaps/core/objects/profile/ProfileJsonCharacterizationTest.kt b/core/objects/src/androidHostTest/kotlin/app/aaps/core/objects/profile/ProfileJsonCharacterizationTest.kt new file mode 100644 index 000000000000..0c84b68602b6 --- /dev/null +++ b/core/objects/src/androidHostTest/kotlin/app/aaps/core/objects/profile/ProfileJsonCharacterizationTest.kt @@ -0,0 +1,352 @@ +package app.aaps.core.objects.profile + +import android.content.Context +import app.aaps.core.interfaces.resources.ResourceHelper +import app.aaps.core.interfaces.utils.DateUtil +import app.aaps.core.objects.extensions.blockFromJsonArray +import app.aaps.core.objects.extensions.targetBlockFromJsonArray +import app.aaps.shared.impl.utils.DateUtilImpl +import app.aaps.shared.tests.TestBase +import com.google.common.truth.Truth.assertThat +import kotlinx.serialization.builtins.MapSerializer +import kotlinx.serialization.builtins.serializer +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonObject +import org.json.JSONArray +import org.json.JSONObject +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.mockito.Mock + +/** + * Characterization of the profile JSON contract, written BEFORE the `org.json` -> kotlinx conversion. + * + * The profile document is the highest risk thing left to convert: it feeds Nightscout, it is read + * back from other uploaders and from older AAPS versions, and every number in it is a dosing + * parameter. So this file pins what `org.json` does today rather than what the schema looks like - + * the schema is the easy part. + * + * Three findings drive it, all measured rather than assumed: + * + * 1. Real profile documents carry numbers as **quoted strings** (`"value":"0.1"`) as well as real + * numbers - both shapes occur in the wild. Both `org.json` AND kotlinx coerce the quoted form, + * so this is NOT the hazard it looks like - measured, not assumed. See the test below. + * 2. A malformed schedule makes `blockFromJsonArray` return **null** (an invalid profile), not throw. + * Strict parsing would turn that into an exception, i.e. a crash where today there is a rejected + * profile. + * 3. The write side renders differently (`1` vs `1.0`, `mg\/dl` vs `mg/dl`) and it does NOT matter. + * That was investigated as a blocker and dismissed on evidence: `ProfileSealed.isEqual` compares + * decoded values hour by hour, no profile document is hashed or signed, and both forms decode to + * the same number or string. A rendering shim was planned and dropped. See section 3. + * + * So the risk lives entirely on the READ side: tolerate both quoted and unquoted numbers, tolerate + * missing AAPS fields and unknown server fields, accept two different ISO shapes, and keep returning + * null - not throwing - on malformed input. Those are the assertions a conversion must not break. + */ +class ProfileJsonCharacterizationTest : TestBase() { + + @Mock lateinit var rh: ResourceHelper + @Mock lateinit var context: Context + + private lateinit var dateUtil: DateUtil + + @BeforeEach fun setup() { + dateUtil = DateUtilImpl(context) + } + + private fun schedule(vararg entries: Pair): JSONArray = + JSONArray().also { arr -> + entries.forEach { (time, value) -> + arr.put(JSONObject().put("time", time).put("value", value)) + } + } + + // ---------------------------------------------------------------- 1. numbers arrive as strings + + /** + * `"0.1"` is a JSON *string*, and the whole profile spine depends on it being read as a number. + */ + @Test fun `schedule values are read from quoted strings`() { + val blocks = blockFromJsonArray(schedule("00:00" to "0.1", "12:00" to "0.25"), dateUtil) + + assertThat(blocks).isNotNull() + assertThat(blocks!!).hasSize(2) + assertThat(blocks[0].amount).isEqualTo(0.1) + assertThat(blocks[1].amount).isEqualTo(0.25) + } + + /** ...and as real JSON numbers, which is what AAPS itself writes. Both shapes must work. */ + @Test fun `schedule values are read from real numbers too`() { + val arr = JSONArray() + .put(JSONObject().put("time", "00:00").put("value", 0.1)) + .put(JSONObject().put("time", "12:00").put("value", 0.25)) + val blocks = blockFromJsonArray(arr, dateUtil) + + assertThat(blocks).isNotNull() + assertThat(blocks!![0].amount).isEqualTo(0.1) + assertThat(blocks[1].amount).isEqualTo(0.25) + } + + /** An integer-looking string is a double too - `"6"` is an ISF, not the integer 6. */ + @Test fun `integer looking strings become doubles`() { + val blocks = blockFromJsonArray(schedule("00:00" to "6"), dateUtil) + assertThat(blocks!![0].amount).isEqualTo(6.0) + } + + // ------------------------------------------------- 1b. target ranges: two arrays read in lockstep + + /** + * `target_low` and `target_high` are separate arrays read together, which gives this parser two + * rules the single-schedule one does not have. Neither was covered by any test before this file, + * so they are pinned here first - the conversion must not quietly drop them. + */ + @Test fun `target ranges pair low and high by position`() { + val blocks = targetBlockFromJsonArray( + schedule("00:00" to "5.5", "12:00" to "6.0"), + schedule("00:00" to "7.0", "12:00" to "7.5"), + dateUtil + ) + + assertThat(blocks).isNotNull() + assertThat(blocks!!).hasSize(2) + assertThat(blocks[0].lowTarget).isEqualTo(5.5) + assertThat(blocks[0].highTarget).isEqualTo(7.0) + assertThat(blocks[1].lowTarget).isEqualTo(6.0) + assertThat(blocks[1].highTarget).isEqualTo(7.5) + } + + /** Rule 1: the two arrays must be the same length. */ + @Test fun `mismatched target array lengths are rejected`() { + assertThat( + targetBlockFromJsonArray( + schedule("00:00" to "5.5", "12:00" to "6.0"), + schedule("00:00" to "7.0"), + dateUtil + ) + ).isNull() + } + + /** Rule 2: entry N of low and entry N of high must be for the same time. */ + @Test fun `target arrays with differing times are rejected`() { + assertThat( + targetBlockFromJsonArray( + schedule("00:00" to "5.5", "08:00" to "6.0", "12:00" to "6.5"), + schedule("00:00" to "7.0", "09:00" to "7.5", "12:00" to "8.0"), + dateUtil + ) + ).isNull() + } + + /** + * ...but NOT on the final entry, and that is a gap rather than a decision. + * + * The loop compares `tas1 != tas2` for entries 0..n-2. The last entry is handled after the loop, + * where only `last2`'s **value** is read - its time is never looked at. So two arrays that + * disagree about when the final segment starts are accepted, and the resulting block pairs a low + * from one time with a high from another. + * + * Pinned as current behaviour, deliberately NOT fixed here: this file exists to make the org.json + * conversion provably behaviour-preserving, and tightening a validation rule at the same time + * would make any later regression impossible to attribute. In practice both AAPS and Nightscout + * write the two arrays with identical times, so it is latent rather than active. + */ + @Test fun `a differing time on the LAST entry is not caught`() { + val blocks = targetBlockFromJsonArray( + schedule("00:00" to "5.5", "12:00" to "6.0"), + schedule("00:00" to "7.0", "13:00" to "7.5"), + dateUtil + ) + + assertThat(blocks).isNotNull() + assertThat(blocks!!).hasSize(2) + // the high value came from the 13:00 entry, the duration from the 12:00 one + assertThat(blocks[1].lowTarget).isEqualTo(6.0) + assertThat(blocks[1].highTarget).isEqualTo(7.5) + } + + /** ...and the hour-alignment rule applies here too. */ + @Test fun `target ranges not aligned to whole hours are rejected`() { + assertThat( + targetBlockFromJsonArray( + schedule("00:30" to "5.5", "12:00" to "6.0"), + schedule("00:30" to "7.0", "12:00" to "7.5"), + dateUtil + ) + ).isNull() + } + + @Test fun `a null target array yields null`() { + assertThat(targetBlockFromJsonArray(null, schedule("00:00" to "7.0"), dateUtil)).isNull() + assertThat(targetBlockFromJsonArray(schedule("00:00" to "5.5"), null, dateUtil)).isNull() + } + + // ---------------------------------------------------------------- 2. failure is null, not throw + + @Test fun `a non numeric value yields an invalid profile rather than an exception`() { + assertThat(blockFromJsonArray(schedule("00:00" to "not a number"), dateUtil)).isNull() + } + + @Test fun `a missing value key yields an invalid profile rather than an exception`() { + val arr = JSONArray().put(JSONObject().put("time", "00:00")) + assertThat(blockFromJsonArray(arr, dateUtil)).isNull() + } + + /** + * Hour alignment is a real rule, not an accident: a schedule that does not start on the hour is + * rejected. Worth pinning because a rewritten parser could easily drop the check. + */ + @Test fun `a schedule not aligned to whole hours is rejected`() { + assertThat(blockFromJsonArray(schedule("00:30" to "0.1", "12:00" to "0.2"), dateUtil)).isNull() + } + + @Test fun `a null array yields null`() { + assertThat(blockFromJsonArray(null, dateUtil)).isNull() + } + + // -------------------------------------------- 3. the write side differs, and it does NOT matter + + /** + * `org.json` renders a whole-numbered double bare (`1`), kotlinx keeps the fraction (`1.0`). + * + * Recorded so nobody "fixes" it: this was investigated as a possible blocker and is not one. + * Nothing in AAPS or Nightscout observes the rendering, because nothing compares profile + * documents as text: + * + * - `ProfileSealed.isEqual` walks the DECODED values hour by hour + * (`getBasalTimeFromMidnight(...) != ...`), so `1` and `1.0` compare equal. + * - No profile document is hashed or signed anywhere; the only `getData().toString()` is a log + * line in `ProfileRepositoryImpl`. + * - Both sides parse either form to the same double, so the round trip is lossless. + * + * A number-rendering shim was planned and then dropped on that evidence. Do not add one without + * first finding a consumer that actually reads the text. + */ + @Test fun `whole numbered values render differently but nothing observes it`() { + val org = JSONObject().put("value", 1.0).toString() + val kotlinx = buildJsonObject { put("value", JsonPrimitive(1.0)) }.toString() + + assertThat(org).isEqualTo("""{"value":1}""") + assertThat(kotlinx).isEqualTo("""{"value":1.0}""") + + // The part that makes it benign: both decode to the same number. + assertThat(JSONObject(org).getDouble("value")).isEqualTo(1.0) + assertThat(JSONObject(kotlinx).getDouble("value")).isEqualTo(1.0) + } + + /** A fractional basal rate is written identically by both - only the whole-numbered case differs at all. */ + @Test fun `fractional profile values serialize identically`() { + assertThat(JSONObject().put("value", 0.825).toString()) + .isEqualTo(buildJsonObject { put("value", JsonPrimitive(0.825)) }.toString()) + } + + // ---------------------------------------------------------------- 5. what a real NS store looks like + + /** + * Shape facts taken from a live Nightscout `/api/v1/profile.json`, not from the NS docs. + * + * The response is an **array** of store documents (the profile history), each holding a `store` + * keyed by user-chosen profile name. Nightscout adds its own fields (`_id`, `date`, `srvModified`, + * `app`, `subject`, `identifier`, `utcOffset`) and omits the AAPS-specific ones entirely - a real + * NS-authored profile has no `dia` and no `iCfg`, so those must stay optional on the read path. + */ + @Test fun `a real nightscout store document parses`() { + val nsShaped = """ + [{"_id":"6a4507e64d1ba8c2e4196ec1","defaultProfile":"Vsedni den", + "date":1782908904415,"created_at":"2026-07-01T12:28:24.415Z", + "startDate":"2026-07-01T12:28:24.4150000Z","utcOffset":0, + "store":{"Vsedni den":{ + "carbratio":[{"time":"00:00","timeAsSeconds":0,"value":8.1}], + "sens":[{"time":"00:00","timeAsSeconds":0,"value":10}], + "basal":[{"time":"00:00","timeAsSeconds":0,"value":1}, + {"time":"06:00","timeAsSeconds":21600,"value":1.27}], + "target_low":[{"time":"00:00","timeAsSeconds":0,"value":5.5}], + "target_high":[{"time":"00:00","timeAsSeconds":0,"value":5.5}], + "units":"mmol","timezone":"Europe/Prague"}}}] + """.trimIndent() + + val doc = JSONArray(nsShaped).getJSONObject(0) + assertThat(doc.getString("defaultProfile")).isEqualTo("Vsedni den") + assertThat(doc.has("dia")).isFalse() + assertThat(doc.has("iCfg")).isFalse() + + val profile = doc.getJSONObject("store").getJSONObject("Vsedni den") + val basal = blockFromJsonArray(profile.getJSONArray("basal"), dateUtil) + assertThat(basal).isNotNull() + assertThat(basal!![0].amount).isEqualTo(1.0) + assertThat(basal[1].amount).isEqualTo(1.27) + } + + /** + * Nightscout writes a whole-numbered rate **bare** - the live document contains `"value":1`, + * `"value":6`, `"value":10`, never `1.0`. + * + * Kept as a shape fact, not as an argument for a shim. What matters is the line below: whatever + * the rendering, it reads back as the same double, and that is all any consumer of a profile + * does with it. + */ + @Test fun `nightscout writes whole numbered rates without a fraction`() { + val fromNs = """{"basal":[{"time":"00:00","timeAsSeconds":0,"value":1}]}""" + val entry = JSONObject(fromNs).getJSONArray("basal").getJSONObject(0) + + assertThat(entry.getDouble("value")).isEqualTo(1.0) + // round-tripping it through org.json keeps the bare form + assertThat(JSONObject().put("value", entry.getDouble("value")).toString()).isEqualTo("""{"value":1}""") + } + + /** + * Softens the slash finding: Nightscout stores and returns `Europe/Prague` unescaped, so the + * `mg\/dl` form org.json emits is normalised away server-side. It is still a byte difference on + * the way out, but it does not survive a round trip - worth knowing before treating it as a + * blocker. + */ + @Test fun `nightscout returns slashes unescaped`() { + val fromNs = """{"timezone":"Europe/Prague"}""" + assertThat(fromNs).doesNotContain("\\/") + assertThat(JSONObject(fromNs).getString("timezone")).isEqualTo("Europe/Prague") + } + + // ---------------------------------------------------------------- 4. what strict parsing costs (less than expected) + + /** + * Quoted numbers are NOT the migration hazard they look like. + * + * The expectation going in was that a `Double` field would reject `"0.1"` without `isLenient`, + * because it is a JSON string where a number belongs. Measured, that is false: kotlinx coerces it + * in default (strict) configuration. Pinned here so the conversion does not switch `isLenient` on + * for a problem that does not exist - leniency would also start accepting genuinely malformed + * input that is rejected today. + * + * Caveat, stated rather than glossed: this exercises the built-in `Double` serializer directly and + * through a map. The `@Serializable` data-class code path could not be exercised from this module + * (no serialization plugin here), so if the conversion uses generated serializers, re-measure. + */ + @Test fun `kotlinx accepts quoted numbers without leniency`() { + assertThat(Json.decodeFromString(Double.serializer(), "\"0.1\"")).isEqualTo(0.1) + assertThat( + Json.decodeFromString(MapSerializer(String.serializer(), Double.serializer()), """{"value":"0.1"}""") + ).containsEntry("value", 0.1) + + // ...matching what org.json has always done, which is why the profile spine works today. + assertThat(JSONObject("""{"value":"0.1"}""").getDouble("value")).isEqualTo(0.1) + } + + /** + * `org.json` escapes a forward slash, kotlinx does not - `mg\/dl` versus `mg/dl`. + * + * Benign for the same reason as the whole-number case: both decode to the same string and nothing + * compares profile documents as text. Doubly so here, because Nightscout stores and returns the + * slash UNESCAPED (see the test below), so the difference does not even survive a round trip - + * `org.json` is the odd one out, not kotlinx. + */ + @Test fun `forward slashes are escaped by org json but not by kotlinx`() { + val org = JSONObject().put("units", "mg/dl").toString() + val kotlinx = buildJsonObject { put("units", JsonPrimitive("mg/dl")) }.toString() + + assertThat(org).isEqualTo("""{"units":"mg\/dl"}""") + assertThat(kotlinx).isEqualTo("""{"units":"mg/dl"}""") + // ...and both read back the same, which is why this hides so well. + assertThat(JSONObject(org).getString("units")) + .isEqualTo(Json.parseToJsonElement(kotlinx).let { (it as kotlinx.serialization.json.JsonObject) }["units"]!!.let { (it as JsonPrimitive).content }) + } +} diff --git a/core/objects/src/test/kotlin/app/aaps/core/objects/profile/ProfileSealedTest.kt b/core/objects/src/androidHostTest/kotlin/app/aaps/core/objects/profile/ProfileSealedTest.kt similarity index 97% rename from core/objects/src/test/kotlin/app/aaps/core/objects/profile/ProfileSealedTest.kt rename to core/objects/src/androidHostTest/kotlin/app/aaps/core/objects/profile/ProfileSealedTest.kt index 8cb934d2d98c..f5d80dd6bb04 100644 --- a/core/objects/src/test/kotlin/app/aaps/core/objects/profile/ProfileSealedTest.kt +++ b/core/objects/src/androidHostTest/kotlin/app/aaps/core/objects/profile/ProfileSealedTest.kt @@ -1,6 +1,7 @@ package app.aaps.core.objects.profile import android.content.Context +import app.aaps.core.interfaces.InterfacesStrings import app.aaps.core.interfaces.aps.APS import app.aaps.core.interfaces.configuration.Config import app.aaps.core.interfaces.notifications.NotificationManager @@ -42,6 +43,7 @@ class ProfileSealedTest : TestBase() { private lateinit var hardLimits: HardLimits private lateinit var dateUtil: DateUtil private lateinit var testPumpPlugin: TestPumpPlugin + //ICfg: dia 18000000 = 5.0h, peak = 4500000 = 75 private var okProfile = "{\"iCfg\":{\"insulinLabel\":\"\",\"insulinEndTime\":18000000,\"insulinPeakTime\":4500000,\"concentration\":\"1.0\"},\"carbratio\":[{\"time\":\"00:00\",\"value\":\"30\"}]," + "\"sens\":[{\"time\":\"00:00\",\"value\":\"6\"},{\"time\":\"2:00\",\"value\":\"6.2\"}],\"timezone\":\"UTC\",\"basal\":[{\"time\":\"00:00\",\"value\":\"0.1\"}],\"target_low\":[{\"time\":\"00:00\",\"value\":\"5\"}],\"target_high\":[{\"time\":\"00:00\",\"value\":\"5\"}],\"startDate\":\"1970-01-01T00:00:00.000Z\",\"units\":\"mmol\"}" @@ -64,10 +66,10 @@ class ProfileSealedTest : TestBase() { dateUtil = DateUtilImpl(context) hardLimits = HardLimitsMock(preferences, rh) whenever(activePlugin.activePump).thenReturn(testPumpPlugin) - whenever(rh.gs(app.aaps.core.ui.R.string.profile_isf_units_mgdl)).thenReturn("mg/dL/U") - whenever(rh.gs(app.aaps.core.ui.R.string.profile_isf_units_mmol)).thenReturn("mmol/L/U") - whenever(rh.gs(app.aaps.core.ui.R.string.profile_carbs_per_unit)).thenReturn("g/U") - whenever(rh.gs(app.aaps.core.ui.R.string.profile_ins_units_per_hour)).thenReturn("U/h") + whenever(rh.gs(InterfacesStrings.profile_isf_units_mgdl)).thenReturn("mg/dL/U") + whenever(rh.gs(InterfacesStrings.profile_isf_units_mmol)).thenReturn("mmol/L/U") + whenever(rh.gs(InterfacesStrings.profile_carbs_per_unit)).thenReturn("g/U") + whenever(rh.gs(InterfacesStrings.profile_ins_units_per_hour)).thenReturn("U/h") whenever(rh.gs(anyInt(), anyString())).thenReturn("") whenever(activePlugin.activeAPS).thenReturn(aps) } diff --git a/core/objects/src/androidHostTest/kotlin/app/aaps/core/objects/profile/ProfileTimeZoneTest.kt b/core/objects/src/androidHostTest/kotlin/app/aaps/core/objects/profile/ProfileTimeZoneTest.kt new file mode 100644 index 000000000000..7aa3052ddd23 --- /dev/null +++ b/core/objects/src/androidHostTest/kotlin/app/aaps/core/objects/profile/ProfileTimeZoneTest.kt @@ -0,0 +1,109 @@ +package app.aaps.core.objects.profile + +import android.content.Context +import app.aaps.core.objects.extensions.pureProfileFromJson +import app.aaps.shared.impl.utils.DateUtilImpl +import app.aaps.shared.tests.TestBase +import com.google.common.truth.Truth.assertThat +import org.json.JSONObject +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.mockito.Mock +import org.mockito.kotlin.spy +import org.mockito.kotlin.whenever + +/** + * The time zone a profile carries, which had no coverage at all. + * + * It used to be a whole `java.util.TimeZone` and the only thing read off it was `rawOffset` - the + * zone's **standard** offset, which ignores daylight saving by definition. `Europe/Prague` therefore + * reported +01:00 in July as well as in January. + * + * That mattered because the single consumer, `toPureNsJson`, turns the number back into a zone + * *name* for the Nightscout profile document by searching for a zone sitting at that offset **now**. + * In July nothing in Europe is at +01:00, so the document ended up naming an unrelated zone. The + * value is now resolved for the moment the profile is read, so the two halves agree. + * + * `now()` is stubbed rather than taken from the clock, or these tests would only say anything during + * one half of the year. + */ +class ProfileTimeZoneTest : TestBase() { + + @Mock lateinit var context: Context + + private lateinit var dateUtil: DateUtilImpl + + // 2026-01-15T12:00Z and 2026-07-15T12:00Z: Prague is CET (+01:00) at the first and CEST (+02:00) + // at the second. + private val winter = 1_768_478_400_000L + private val summer = 1_784_116_800_000L + + private val oneHour = 3_600_000L + private val twoHours = 7_200_000L + + @BeforeEach fun prepare() { + dateUtil = spy(DateUtilImpl(context)) + } + + private fun profileIn(zone: String, at: Long) = run { + whenever(dateUtil.now()).thenReturn(at) + pureProfileFromJson(JSONObject(profileJson(zone)), dateUtil)!! + } + + @Test + fun `a zone on daylight saving reports the summer offset in summer`() { + assertThat(profileIn("Europe/Prague", summer).utcOffset).isEqualTo(twoHours) + } + + /** + * The regression this replaced: `rawOffset` gave one hour here *and* in summer, so the two were + * indistinguishable and summer was the wrong one. + */ + @Test + fun `the same zone reports the winter offset in winter`() { + assertThat(profileIn("Europe/Prague", winter).utcOffset).isEqualTo(oneHour) + } + + @Test + fun `a zone without daylight saving reads the same all year`() { + // Africa/Lagos is +01:00 the whole year round, which is exactly why it used to be picked as + // the name for a summer Prague profile. + assertThat(profileIn("Africa/Lagos", summer).utcOffset).isEqualTo(oneHour) + assertThat(profileIn("Africa/Lagos", winter).utcOffset).isEqualTo(oneHour) + } + + @Test + fun `UTC is zero in both halves of the year`() { + assertThat(profileIn("UTC", summer).utcOffset).isEqualTo(0L) + assertThat(profileIn("UTC", winter).utcOffset).isEqualTo(0L) + } + + /** + * `java.util.TimeZone.getTimeZone` answered GMT for an id it did not recognise, so a corrupt or + * unknown zone silently became UTC rather than failing the whole profile. kotlinx throws instead, + * so that fallback is explicit now - and this says it still holds. + */ + @Test + fun `an unknown zone falls back to UTC instead of failing the profile`() { + val profile = profileIn("Not/AZone", summer) + + assertThat(profile.utcOffset).isEqualTo(0L) + assertThat(profile.basalBlocks).isNotEmpty() // the rest of the profile still parsed + } + + /** A profile with no timezone at all also has to keep parsing. */ + @Test + fun `a missing zone falls back to UTC`() { + whenever(dateUtil.now()).thenReturn(summer) + val noZone = """{"dia":6,"carbratio":[{"time":"00:00","value":30}],"sens":[{"time":"00:00","value":3}],""" + + """"basal":[{"time":"00:00","value":1}],"target_low":[{"time":"00:00","value":4.5}],""" + + """"target_high":[{"time":"00:00","value":7}],"units":"mmol"}""" + + assertThat(pureProfileFromJson(JSONObject(noZone), dateUtil)!!.utcOffset).isEqualTo(0L) + } + + private fun profileJson(zone: String) = + """{"dia":6,"carbratio":[{"time":"00:00","value":30}],"sens":[{"time":"00:00","value":3}],""" + + """"basal":[{"time":"00:00","value":1}],"target_low":[{"time":"00:00","value":4.5}],""" + + """"target_high":[{"time":"00:00","value":7}],"units":"mmol","timezone":"$zone"}""" +} diff --git a/core/objects/src/androidHostTest/kotlin/app/aaps/core/objects/profile/PureNsJsonTest.kt b/core/objects/src/androidHostTest/kotlin/app/aaps/core/objects/profile/PureNsJsonTest.kt new file mode 100644 index 000000000000..2b2fc4fdd49b --- /dev/null +++ b/core/objects/src/androidHostTest/kotlin/app/aaps/core/objects/profile/PureNsJsonTest.kt @@ -0,0 +1,115 @@ +package app.aaps.core.objects.profile + +import android.content.Context +import app.aaps.core.interfaces.plugin.ActivePlugin +import app.aaps.core.interfaces.resources.ResourceHelper +import app.aaps.core.objects.extensions.pureProfileFromJson +import app.aaps.shared.impl.utils.DateUtilImpl +import app.aaps.shared.tests.TestBase +import app.aaps.shared.tests.TestPumpPlugin +import com.google.common.truth.Truth.assertThat +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import org.json.JSONObject +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.mockito.Mock +import org.mockito.kotlin.whenever + +/** + * Pins the shape of the Nightscout profile document produced by `toPureNsJson`. + * + * Nothing covered this before: the sync tests call it only to build fixtures, and the one assertion + * in [ProfileSealedTest] is commented out. That is thin for a serializer whose output is written to + * the `profileJson` column, uploaded to Nightscout and read back by other AAPS builds - a change in + * the entry shape would surface as a wrong profile, not as a failing test. + * + * The five schedules are produced by one shared helper now, so the per-schedule assertions here are + * mostly about the boundaries each one walks. + */ +class PureNsJsonTest : TestBase() { + + @Mock lateinit var activePlugin: ActivePlugin + @Mock lateinit var rh: ResourceHelper + @Mock lateinit var context: Context + + /** Two blocks per schedule, switching at 02:00, so a boundary error cannot hide. */ + private val sourceProfile = + """{"dia":"5","carbratio":[{"time":"00:00","value":"30"},{"time":"02:00","value":"40"}], + "carbs_hr":"20","delay":"20","sens":[{"time":"00:00","value":"100"},{"time":"02:00","value":"110"}], + "timezone":"UTC","basal":[{"time":"00:00","value":"0.1"},{"time":"02:00","value":"0.2"}], + "target_low":[{"time":"00:00","value":"4"},{"time":"02:00","value":"4.5"}], + "target_high":[{"time":"00:00","value":"5"},{"time":"02:00","value":"5.5"}], + "startDate":"1970-01-01T00:00:00.000Z","units":"mmol"}""" + + private lateinit var dateUtil: DateUtilImpl + + @BeforeEach fun prepare() { + dateUtil = DateUtilImpl(context) + whenever(activePlugin.activePump).thenReturn(TestPumpPlugin(rh)) + } + + private fun json() = + ProfileSealed.Pure(pureProfileFromJson(JSONObject(sourceProfile), dateUtil)!!, activePlugin).toPureNsJson(dateUtil) + + @Test + fun `carries the five schedules plus units and timezone`() { + assertThat(json().keys).containsExactly("units", "timezone", "sens", "carbratio", "basal", "target_low", "target_high") + } + + @Test + fun `each entry is time, timeAsSeconds and value`() { + val first = json()["sens"]!!.jsonArray[0].jsonObject + + assertThat(first.keys).containsExactly("time", "timeAsSeconds", "value") + assertThat(first["time"]!!.jsonPrimitive.content).isEqualTo("00:00") + assertThat(first["timeAsSeconds"]!!.jsonPrimitive.content).isEqualTo("0") + } + + @Test + fun `every schedule walks its own block boundaries`() { + val o = json() + for (key in listOf("sens", "carbratio", "basal", "target_low", "target_high")) { + val entries = o[key]!!.jsonArray + assertThat(entries).hasSize(2) + assertThat(entries[0].jsonObject["time"]!!.jsonPrimitive.content).isEqualTo("00:00") + assertThat(entries[1].jsonObject["time"]!!.jsonPrimitive.content).isEqualTo("02:00") + assertThat(entries[1].jsonObject["timeAsSeconds"]!!.jsonPrimitive.content).isEqualTo("7200") + } + } + + @Test + fun `values come through per schedule`() { + val o = json() + fun valueAt(key: String, index: Int) = o[key]!!.jsonArray[index].jsonObject["value"]!!.jsonPrimitive.content.toDouble() + + assertThat(valueAt("sens", 0)).isWithin(0.001).of(100.0) + assertThat(valueAt("sens", 1)).isWithin(0.001).of(110.0) + assertThat(valueAt("carbratio", 1)).isWithin(0.001).of(40.0) + assertThat(valueAt("basal", 1)).isWithin(0.001).of(0.2) + // Low and high share boundaries but not values. + assertThat(valueAt("target_low", 1)).isWithin(0.001).of(4.5) + assertThat(valueAt("target_high", 1)).isWithin(0.001).of(5.5) + } + + /** + * The document round-trips back into the profile it came from. + * + * This is the assertion that actually matters: whatever the text looks like, another AAPS build + * reading it must rebuild the same blocks. It is also what makes the one cosmetic difference from + * the previous `org.json` output harmless - integral values now render as `100.0` rather than + * `100`, and the reader does not care. + */ + @Test + fun `the document parses back into the same blocks`() { + val original = ProfileSealed.Pure(pureProfileFromJson(JSONObject(sourceProfile), dateUtil)!!, activePlugin) + + val reparsed = pureProfileFromJson(JSONObject(original.toPureNsJson(dateUtil).toString()), dateUtil)!! + + assertThat(reparsed.isfBlocks).isEqualTo(original.isfBlocks) + assertThat(reparsed.icBlocks).isEqualTo(original.icBlocks) + assertThat(reparsed.basalBlocks).isEqualTo(original.basalBlocks) + assertThat(reparsed.targetBlocks).isEqualTo(original.targetBlocks) + } +} diff --git a/core/objects/src/androidHostTest/kotlin/app/aaps/core/objects/profile/PureProfileFromJsonParityTest.kt b/core/objects/src/androidHostTest/kotlin/app/aaps/core/objects/profile/PureProfileFromJsonParityTest.kt new file mode 100644 index 000000000000..5c34f413881e --- /dev/null +++ b/core/objects/src/androidHostTest/kotlin/app/aaps/core/objects/profile/PureProfileFromJsonParityTest.kt @@ -0,0 +1,108 @@ +package app.aaps.core.objects.profile + +import android.content.Context +import app.aaps.core.data.model.GlucoseUnit +import app.aaps.core.interfaces.resources.ResourceHelper +import app.aaps.core.interfaces.utils.DateUtil +import app.aaps.core.objects.extensions.pureProfileFromJson +import app.aaps.shared.impl.utils.DateUtilImpl +import app.aaps.shared.tests.TestBase +import com.google.common.truth.Truth.assertThat +import org.json.JSONObject +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.mockito.Mock + +/** + * `pureProfileFromJson` now has three entry points - `String`, kotlinx `JsonObject` and the + * `org.json` adapter - and callers are being moved off the `org.json` one a few at a time. + * + * These tests pin the three against each other, so a caller can be switched over without having to + * re-argue that the profile it reads is identical. They also pin the awkward rules that decide + * whether a profile is usable at all, because those are the ones a conversion is most likely to + * quietly drop. + */ +class PureProfileFromJsonParityTest : TestBase() { + + @Mock lateinit var rh: ResourceHelper + @Mock lateinit var context: Context + + private lateinit var dateUtil: DateUtil + + @BeforeEach fun setup() { + dateUtil = DateUtilImpl(context) + } + + private val validProfile = + """{"dia":"5","carbratio":[{"time":"00:00","value":"30"}],"sens":[{"time":"00:00","value":"100"}],""" + + """"basal":[{"time":"00:00","value":"1"}],"target_low":[{"time":"00:00","value":"4.5"}],""" + + """"target_high":[{"time":"00:00","value":"7"}],"units":"mmol","timezone":"UTC"}""" + + private fun fromText(text: String) = pureProfileFromJson(text, dateUtil) + private fun fromOrgJson(text: String) = pureProfileFromJson(JSONObject(text), dateUtil) + + @Test fun `the text entry point and the org json adapter read the same profile`() { + val viaText = fromText(validProfile) + val viaOrgJson = fromOrgJson(validProfile) + + assertThat(viaText).isNotNull() + assertThat(viaOrgJson).isNotNull() + assertThat(viaText!!.glucoseUnit).isEqualTo(viaOrgJson!!.glucoseUnit) + assertThat(viaText.basalBlocks).isEqualTo(viaOrgJson.basalBlocks) + assertThat(viaText.isfBlocks).isEqualTo(viaOrgJson.isfBlocks) + assertThat(viaText.icBlocks).isEqualTo(viaOrgJson.icBlocks) + assertThat(viaText.targetBlocks).isEqualTo(viaOrgJson.targetBlocks) + assertThat(viaText.utcOffset).isEqualTo(viaOrgJson.utcOffset) + } + + /** + * Quoted numbers are not a theoretical case - real Nightscout documents contain them, and the + * fixture above stores every value as a string. A reader that lost the coercion would answer + * null here rather than a profile. + */ + @Test fun `values quoted as strings are still read as numbers`() { + val profile = fromText(validProfile) + + assertThat(profile).isNotNull() + assertThat(profile!!.basalBlocks).hasSize(1) + assertThat(profile.basalBlocks[0].amount).isWithin(0.001).of(1.0) + } + + /** + * The most dangerous line in the file. `GlucoseUnit.fromText` never throws - it answers MGDL for + * anything it does not recognise - so a missing unit MUST reject the profile. If it did not, an + * mmol/L profile would be read as mg/dL and every target, ISF and correction would be out by 18x. + */ + @Test fun `a profile without units is rejected unless a default is supplied`() { + val noUnits = validProfile.replace(""","units":"mmol"""", "") + + assertThat(fromText(noUnits)).isNull() + assertThat(fromOrgJson(noUnits)).isNull() + + // ...and the caller supplied default is what rescues it, still as mmol. + assertThat(pureProfileFromJson(noUnits, dateUtil, "mmol")?.glucoseUnit).isEqualTo(GlucoseUnit.MMOL) + } + + @Test fun `a missing schedule makes the profile invalid rather than throwing`() { + val noCarbratio = validProfile.replace(""""carbratio":[{"time":"00:00","value":"30"}],""", "") + + assertThat(fromText(noCarbratio)).isNull() + assertThat(fromOrgJson(noCarbratio)).isNull() + } + + @Test fun `text that is not JSON at all gives an invalid profile rather than throwing`() { + assertThat(fromText("")).isNull() + assertThat(fromText("garbage")).isNull() + assertThat(fromText("[]")).isNull() + } + + /** + * An unknown zone falls back to UTC rather than throwing. `java.util.TimeZone.getTimeZone` + * answered GMT for an id it did not know and kotlinx throws instead, so the fallback is explicit. + */ + @Test fun `an unknown timezone falls back to UTC`() { + val oddZone = validProfile.replace(""""timezone":"UTC"""", """"timezone":"Mars/Olympus"""") + + assertThat(fromText(oddZone)?.utcOffset).isEqualTo(0L) + } +} diff --git a/core/objects/src/test/kotlin/app/aaps/core/objects/runningMode/PumpCommandGateTest.kt b/core/objects/src/androidHostTest/kotlin/app/aaps/core/objects/runningMode/PumpCommandGateTest.kt similarity index 100% rename from core/objects/src/test/kotlin/app/aaps/core/objects/runningMode/PumpCommandGateTest.kt rename to core/objects/src/androidHostTest/kotlin/app/aaps/core/objects/runningMode/PumpCommandGateTest.kt diff --git a/core/objects/src/test/kotlin/app/aaps/core/objects/utils/CryptoUtilTest.kt b/core/objects/src/androidHostTest/kotlin/app/aaps/core/objects/utils/CryptoUtilTest.kt similarity index 100% rename from core/objects/src/test/kotlin/app/aaps/core/objects/utils/CryptoUtilTest.kt rename to core/objects/src/androidHostTest/kotlin/app/aaps/core/objects/utils/CryptoUtilTest.kt diff --git a/core/objects/src/test/kotlin/app/aaps/core/objects/wizard/QuickWizardTest.kt b/core/objects/src/androidHostTest/kotlin/app/aaps/core/objects/wizard/QuickWizardTest.kt similarity index 83% rename from core/objects/src/test/kotlin/app/aaps/core/objects/wizard/QuickWizardTest.kt rename to core/objects/src/androidHostTest/kotlin/app/aaps/core/objects/wizard/QuickWizardTest.kt index 17abced1bb44..5d3efe05c0fe 100644 --- a/core/objects/src/test/kotlin/app/aaps/core/objects/wizard/QuickWizardTest.kt +++ b/core/objects/src/androidHostTest/kotlin/app/aaps/core/objects/wizard/QuickWizardTest.kt @@ -7,7 +7,8 @@ import app.aaps.core.keys.StringNonKey import app.aaps.shared.tests.TestBaseWithProfile import com.google.common.truth.Truth.assertThat import kotlinx.coroutines.flow.MutableStateFlow -import org.json.JSONArray +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonObject import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test import org.mockito.Mock @@ -24,14 +25,21 @@ class QuickWizardTest : TestBaseWithProfile() { @Mock lateinit var loop: Loop @Mock lateinit var persistenceLayer: PersistenceLayer @Mock lateinit var glucoseStatusProvider: GlucoseStatusProvider - @Mock lateinit var bolusWizardProvider: Provider - @Mock lateinit var quickWizardProvider: Provider + @Mock lateinit var bolusWizard: BolusWizard + @Mock lateinit var quickWizardMock: QuickWizard private val data1 = "{\"buttonText\":\"Meal\",\"carbs\":36,\"validFrom\":0,\"validTo\":18000," + "\"useBG\":0,\"useCOB\":0,\"useBolusIOB\":0,\"useBasalIOB\":0,\"useTrend\":0,\"useSuperBolus\":0,\"useTemptarget\":0}" private val data2 = "{\"buttonText\":\"Lunch\",\"carbs\":18,\"validFrom\":36000,\"validTo\":39600," + "\"useBG\":0,\"useCOB\":0,\"useBolusIOB\":1,\"useBasalIOB\":2,\"useTrend\":0,\"useSuperBolus\":0,\"useTemptarget\":0}" - private var array: JSONArray = JSONArray("[$data1,$data2]") + private var array: List = entries(data1, data2) + + /** + * Keeps the test data as the JSON text a real preference holds, and parses it the same way the + * production code does, so this still covers [QuickWizardEntryData.fromJsonObject]. + */ + private fun entries(vararg json: String): List = + json.map { QuickWizardEntryData.fromJsonObject(Json.parseToJsonElement(it) as JsonObject) } class MockedTime : QuickWizardEntry.Time() { @@ -45,10 +53,9 @@ class QuickWizardTest : TestBaseWithProfile() { fun setup() { whenever(preferences.get(StringNonKey.QuickWizard)).thenReturn("[]") whenever(preferences.observe(StringNonKey.QuickWizard)).thenReturn(MutableStateFlow("[]")) - val quickWizardEntry = QuickWizardEntry(aapsLogger, preferences, profileFunction, loop, iobCobCalculator, persistenceLayer, dateUtil, glucoseStatusProvider, bolusWizardProvider, quickWizardProvider) + val quickWizardEntry = QuickWizardEntry(aapsLogger, preferences, profileFunction, loop, iobCobCalculator, persistenceLayer, dateUtil, glucoseStatusProvider, { bolusWizard }, { quickWizardMock }) quickWizardEntry.time = mockedTime - val quickWizardEntryProvider = Provider { quickWizardEntry } - quickWizard = QuickWizard(preferences, quickWizardEntryProvider) + quickWizard = QuickWizard(preferences) { quickWizardEntry } } @Test @@ -104,7 +111,7 @@ class QuickWizardTest : TestBaseWithProfile() { @Test fun reorderAppliesThePermutation() { - quickWizard.setData(JSONArray("[$data1,$data2,$data3]")) + quickWizard.setData(entries(data1, data2, data3)) // order[newIndex] == oldIndex, so this reads "Dinner, Meal, Lunch". assertThat(quickWizard.reorder(listOf(2, 0, 1))).isTrue() @@ -114,7 +121,7 @@ class QuickWizardTest : TestBaseWithProfile() { @Test fun reorderWithTheIdentityOrderDoesNotWrite() { - quickWizard.setData(JSONArray("[$data1,$data2]")) + quickWizard.setData(entries(data1, data2)) clearInvocations(preferences) assertThat(quickWizard.reorder(listOf(0, 1))).isTrue() @@ -127,7 +134,7 @@ class QuickWizardTest : TestBaseWithProfile() { @Test fun reorderRejectsAnOrderThatIsNotAPermutation() { - quickWizard.setData(JSONArray("[$data1,$data2,$data3]")) + quickWizard.setData(entries(data1, data2, data3)) assertThat(quickWizard.reorder(listOf(0, 1))).isFalse() // too short assertThat(quickWizard.reorder(listOf(0, 1, 1))).isFalse() // duplicate diff --git a/core/objects/src/main/AndroidManifest.xml b/core/objects/src/androidMain/AndroidManifest.xml similarity index 100% rename from core/objects/src/main/AndroidManifest.xml rename to core/objects/src/androidMain/AndroidManifest.xml diff --git a/core/objects/src/main/kotlin/app/aaps/core/objects/crypto/CryptoUtil.kt b/core/objects/src/androidMain/kotlin/app/aaps/core/objects/crypto/CryptoUtil.kt similarity index 97% rename from core/objects/src/main/kotlin/app/aaps/core/objects/crypto/CryptoUtil.kt rename to core/objects/src/androidMain/kotlin/app/aaps/core/objects/crypto/CryptoUtil.kt index e9a76ed8ee63..ae5e72eebf0c 100644 --- a/core/objects/src/main/kotlin/app/aaps/core/objects/crypto/CryptoUtil.kt +++ b/core/objects/src/androidMain/kotlin/app/aaps/core/objects/crypto/CryptoUtil.kt @@ -14,12 +14,9 @@ import javax.crypto.SecretKeyFactory import javax.crypto.spec.GCMParameterSpec import javax.crypto.spec.PBEKeySpec import javax.crypto.spec.SecretKeySpec -import javax.inject.Inject -import javax.inject.Singleton @Suppress("SpellCheckingInspection") -@Singleton -class CryptoUtil @Inject constructor( +class CryptoUtil( val aapsLogger: AAPSLogger ) { diff --git a/core/objects/src/androidMain/kotlin/app/aaps/core/objects/extensions/BlockJsonAdapters.kt b/core/objects/src/androidMain/kotlin/app/aaps/core/objects/extensions/BlockJsonAdapters.kt new file mode 100644 index 000000000000..28af01896204 --- /dev/null +++ b/core/objects/src/androidMain/kotlin/app/aaps/core/objects/extensions/BlockJsonAdapters.kt @@ -0,0 +1,43 @@ +package app.aaps.core.objects.extensions + +import app.aaps.core.data.model.data.Block +import app.aaps.core.data.model.data.TargetBlock +import app.aaps.core.interfaces.utils.DateUtil +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonArray +import org.json.JSONArray + +/** + * `org.json` adapters for the schedule readers and writers in `BlockExtension`. + * + * The rules themselves live on kotlinx types in commonMain. Only these thin boundary adapters stay + * Android-only, because `org.json` is part of the Android platform and has no iOS counterpart. They + * go away with the callers that still hold `JSONArray`. + */ + +/** + * Bridge from `org.json` to kotlinx at the module boundary. + * + * Goes via text because that is the only lossless thing both libraries agree on, and the cost is + * paid once per schedule rather than per entry. Returns null rather than throwing so callers keep the + * existing "unreadable means invalid profile" behaviour. + */ +private fun JSONArray?.toKotlinxOrNull(): JsonArray? = + this?.let { runCatching { Json.parseToJsonElement(it.toString()) as? JsonArray }.getOrNull() } + +/** `org.json` entry point. Converts once at the boundary and delegates to [blockFromJson]. */ +fun blockFromJsonArray(jsonArray: JSONArray?, dateUtil: DateUtil): List? = + blockFromJson(jsonArray.toKotlinxOrNull(), dateUtil) + +/** `org.json` entry point. Converts once at the boundary and delegates to [targetBlockFromJson]. */ +fun targetBlockFromJsonArray(jsonArray1: JSONArray?, jsonArray2: JSONArray?, dateUtil: DateUtil): List? = + targetBlockFromJson(jsonArray1.toKotlinxOrNull(), jsonArray2.toKotlinxOrNull(), dateUtil) + +/** `org.json` adapter for [toJsonArray], mirroring [blockFromJsonArray] on the read side. */ +fun List.toJSONArray(): JSONArray = JSONArray(toJsonArray().toString()) + +/** `org.json` adapter for [lowToJsonArray]. */ +fun List.lowToJSONArray(): JSONArray = JSONArray(lowToJsonArray().toString()) + +/** `org.json` adapter for [highToJsonArray]. */ +fun List.highToJSONArray(): JSONArray = JSONArray(highToJsonArray().toString()) diff --git a/core/objects/src/androidMain/kotlin/app/aaps/core/objects/extensions/GlucoseValueJsonAdapters.kt b/core/objects/src/androidMain/kotlin/app/aaps/core/objects/extensions/GlucoseValueJsonAdapters.kt new file mode 100644 index 000000000000..330c25426fc3 --- /dev/null +++ b/core/objects/src/androidMain/kotlin/app/aaps/core/objects/extensions/GlucoseValueJsonAdapters.kt @@ -0,0 +1,19 @@ +package app.aaps.core.objects.extensions + +import app.aaps.core.data.model.GV +import app.aaps.core.interfaces.utils.DateUtil +import org.json.JSONObject + +/** + * `org.json` form of [toJsonObject], for the callers that still hold a `JSONObject`. + * + * Going back through the text is not just plumbing - it is what keeps the written bytes identical. + * `org.json` renders a whole-numbered Double as a bare integer (`100.0` becomes `100`) and kotlinx + * renders `100.0`. Reparsing here lets `org.json` re-normalise on the way out, so the + * `entries.json` file that autotune reads does not change at all. + * + * A non-finite `sgv` still fails loudly, as it did before: `org.json` refuses NaN and Infinity, and + * it refuses them on this reparse just as it used to refuse them on the original put. + */ +fun GV.toJson(isAdd: Boolean, dateUtil: DateUtil): JSONObject = + JSONObject(toJsonObject(isAdd, dateUtil).toString()) diff --git a/core/objects/src/androidMain/kotlin/app/aaps/core/objects/extensions/IobTotalJsonAdapters.kt b/core/objects/src/androidMain/kotlin/app/aaps/core/objects/extensions/IobTotalJsonAdapters.kt new file mode 100644 index 000000000000..324c9d56d3c7 --- /dev/null +++ b/core/objects/src/androidMain/kotlin/app/aaps/core/objects/extensions/IobTotalJsonAdapters.kt @@ -0,0 +1,30 @@ +package app.aaps.core.objects.extensions + +import app.aaps.core.interfaces.aps.IobTotal +import app.aaps.core.interfaces.utils.DateUtil +import org.json.JSONArray +import org.json.JSONObject + +/** + * `org.json` forms of [jsonObject] and [determineBasalJsonObject], for the callers that still hold an + * `org.json` document. + * + * Reparsing through the text is what keeps the produced bytes identical: `org.json` renders a whole + * numbered Double as a bare integer (`10.0` becomes `10`) and a negative zero as `-0`, and kotlinx + * renders `10.0`. Letting `org.json` re-render on the way out means the uploaded device status does + * not change at all. + */ + +fun IobTotal.json(dateUtil: DateUtil): JSONObject = + JSONObject(jsonObject(dateUtil).toString()) + +fun IobTotal.determineBasalJson(dateUtil: DateUtil): JSONObject = + JSONObject(determineBasalJsonObject(dateUtil).toString()) + +fun Array.convertToJSONArray(dateUtil: DateUtil): JSONArray { + val array = JSONArray() + for (i in this.indices) { + array.put(this[i].determineBasalJson(dateUtil)) + } + return array +} diff --git a/core/objects/src/main/kotlin/app/aaps/core/objects/extensions/JSONObjectExt.kt b/core/objects/src/androidMain/kotlin/app/aaps/core/objects/extensions/JSONObjectExt.kt similarity index 99% rename from core/objects/src/main/kotlin/app/aaps/core/objects/extensions/JSONObjectExt.kt rename to core/objects/src/androidMain/kotlin/app/aaps/core/objects/extensions/JSONObjectExt.kt index bf72af72df4c..2e078489241f 100644 --- a/core/objects/src/main/kotlin/app/aaps/core/objects/extensions/JSONObjectExt.kt +++ b/core/objects/src/androidMain/kotlin/app/aaps/core/objects/extensions/JSONObjectExt.kt @@ -68,3 +68,4 @@ fun JSONObject.putIfThereIsValue(key: String, value: Double?): JSONObject = fun JSONObject.putIfThereIsValue(key: String, value: String?): JSONObject = this.also { if (value != null && value.isNotEmpty()) it.put(key, value) } + diff --git a/core/objects/src/androidMain/kotlin/app/aaps/core/objects/extensions/ProfileSwitchJsonAdapters.kt b/core/objects/src/androidMain/kotlin/app/aaps/core/objects/extensions/ProfileSwitchJsonAdapters.kt new file mode 100644 index 000000000000..3cd1ed09b883 --- /dev/null +++ b/core/objects/src/androidMain/kotlin/app/aaps/core/objects/extensions/ProfileSwitchJsonAdapters.kt @@ -0,0 +1,20 @@ +package app.aaps.core.objects.extensions + +import app.aaps.core.interfaces.profile.PureProfile +import app.aaps.core.interfaces.utils.DateUtil +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonObject +import org.json.JSONObject + +/** + * `org.json` entry point for [pureProfileFromJson], kept while the callers still hold a `JSONObject`. + * + * The conversion goes via text because that is the only lossless thing both libraries agree on, and + * it is paid once per profile rather than per entry. A document `org.json` cannot even render, or one + * kotlinx refuses to parse, becomes null - an invalid profile - which is exactly what the callers + * already expect from an unreadable profile. + */ +fun pureProfileFromJson(jsonObject: JSONObject, dateUtil: DateUtil, defaultUnits: String? = null): PureProfile? { + val parsed = runCatching { Json.parseToJsonElement(jsonObject.toString()) as? JsonObject }.getOrNull() ?: return null + return pureProfileFromJson(parsed, dateUtil, defaultUnits) +} diff --git a/core/objects/src/main/kotlin/app/aaps/core/objects/workflow/LoggingWorker.kt b/core/objects/src/androidMain/kotlin/app/aaps/core/objects/workflow/LoggingWorker.kt similarity index 100% rename from core/objects/src/main/kotlin/app/aaps/core/objects/workflow/LoggingWorker.kt rename to core/objects/src/androidMain/kotlin/app/aaps/core/objects/workflow/LoggingWorker.kt diff --git a/core/objects/src/main/kotlin/app/aaps/core/objects/constraints/ConstraintObject.kt b/core/objects/src/commonMain/kotlin/app/aaps/core/objects/constraints/ConstraintObject.kt similarity index 98% rename from core/objects/src/main/kotlin/app/aaps/core/objects/constraints/ConstraintObject.kt rename to core/objects/src/commonMain/kotlin/app/aaps/core/objects/constraints/ConstraintObject.kt index 1c67fe981e69..f033783415a9 100644 --- a/core/objects/src/main/kotlin/app/aaps/core/objects/constraints/ConstraintObject.kt +++ b/core/objects/src/commonMain/kotlin/app/aaps/core/objects/constraints/ConstraintObject.kt @@ -69,7 +69,7 @@ class ConstraintObject>(private var value: T, private val aaps } private fun translateFrom(from: Any): String { - return from.javaClass.simpleName.replace("Plugin", "") + return from::class.simpleName.orEmpty().replace("Plugin", "") } override fun addReason(reason: String, from: Any) { diff --git a/core/objects/src/commonMain/kotlin/app/aaps/core/objects/extensions/BlockExtension.kt b/core/objects/src/commonMain/kotlin/app/aaps/core/objects/extensions/BlockExtension.kt new file mode 100644 index 000000000000..c4da75ecb1bf --- /dev/null +++ b/core/objects/src/commonMain/kotlin/app/aaps/core/objects/extensions/BlockExtension.kt @@ -0,0 +1,259 @@ +package app.aaps.core.objects.extensions + +import app.aaps.core.data.model.data.Block +import app.aaps.core.data.model.data.TargetBlock +import app.aaps.core.data.time.T +import app.aaps.core.interfaces.utils.DateUtil +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonArray +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put + +private fun getShiftedTimeSecs(originalSeconds: Int, timeShiftHours: Int): Int { + var shiftedSeconds = originalSeconds - timeShiftHours * 60 * 60 + shiftedSeconds = (shiftedSeconds + 24 * 60 * 60) % (24 * 60 * 60) + return shiftedSeconds +} + +/** + * Expands to 24 one-hour blocks, then merges neighbours that carry the same value. + * + * Builds the merged list forward instead of mutating durations in place, so [Block] can stay + * immutable. Same output as before: consecutive equal-valued hours collapse into one block whose + * duration is their sum. + */ +fun List.shiftBlock(multiplier: Double, timeShiftHours: Int): List { + val hourly = (0..23).map { blockValueBySeconds(it * 3600, multiplier, timeShiftHours) } + val merged = ArrayList(hourly.size) + for (amount in hourly) { + val last = merged.lastOrNull() + if (last != null && last.amount == amount) merged[merged.size - 1] = last.copy(duration = last.duration + HOUR_MS) + else merged.add(Block(HOUR_MS, amount)) + } + return merged +} + +/** Same merge, for the paired low/high target schedule. */ +fun List.shiftTargetBlock(timeShiftHours: Int): List { + val hourly = (0..23).map { + lowTargetBlockValueBySeconds(it * 3600, timeShiftHours) to highTargetBlockValueBySeconds(it * 3600, timeShiftHours) + } + val merged = ArrayList(hourly.size) + for ((low, high) in hourly) { + val last = merged.lastOrNull() + if (last != null && last.lowTarget == low && last.highTarget == high) + merged[merged.size - 1] = last.copy(duration = last.duration + HOUR_MS) + else merged.add(TargetBlock(HOUR_MS, low, high)) + } + return merged +} + +private const val HOUR_MS = 1000L * 60 * 60 + +fun List.blockValueBySeconds(secondsFromMidnight: Int, multiplier: Double, timeShiftHours: Int): Double { + var elapsed = 0L + val shiftedSeconds = getShiftedTimeSecs(secondsFromMidnight, timeShiftHours) + forEach { + if (shiftedSeconds >= elapsed && shiftedSeconds < elapsed + T.msecs(it.duration).secs()) return it.amount * multiplier + elapsed += T.msecs(it.duration).secs() + } + return last().amount * multiplier +} + +fun List.targetBlockValueBySeconds(secondsFromMidnight: Int, timeShiftHours: Int): Double { + var elapsed = 0L + val shiftedSeconds = getShiftedTimeSecs(secondsFromMidnight, timeShiftHours) + forEach { + if (shiftedSeconds >= elapsed && shiftedSeconds < elapsed + T.msecs(it.duration).secs()) return (it.lowTarget + it.highTarget) / 2.0 + elapsed += T.msecs(it.duration).secs() + } + return (last().lowTarget + last().highTarget) / 2.0 +} + +fun List.lowTargetBlockValueBySeconds(secondsFromMidnight: Int, timeShiftHours: Int): Double { + var elapsed = 0L + val shiftedSeconds = getShiftedTimeSecs(secondsFromMidnight, timeShiftHours) + forEach { + if (shiftedSeconds >= elapsed && shiftedSeconds < elapsed + T.msecs(it.duration).secs()) return it.lowTarget + elapsed += T.msecs(it.duration).secs() + } + return last().lowTarget +} + +fun List.highTargetBlockValueBySeconds(secondsFromMidnight: Int, timeShiftHours: Int): Double { + var elapsed = 0L + val shiftedSeconds = getShiftedTimeSecs(secondsFromMidnight, timeShiftHours) + forEach { + if (shiftedSeconds >= elapsed && shiftedSeconds < elapsed + T.msecs(it.duration).secs()) return it.highTarget + elapsed += T.msecs(it.duration).secs() + } + return last().highTarget +} + +/** The shape [DateUtil.toSeconds] can actually read: ASCII digits, `HH:MM`. It matches with `find`. */ +private val READABLE_TIME = Regex("""\d+:\d+""") + +/** + * Start-of-block seconds for entry [index]. + * + * `timeAsSeconds` is the source of truth, and `time` is read only when it is missing or zero. That + * matches how the profile editor always worked - it wrote `time` but read the rows back from + * `timeAsSeconds`, never from `time`. + * + * Preferring the integer matters because `time` is the fragile field. Older builds formatted it with + * the device locale's digits, so a profile saved under ar-SA (a locale AAPS ships) holds `"٠٦:٠٠"`, + * and [DateUtil.toSeconds] matches ASCII `\d` only, answering 0 without complaining. That used to be + * survivable - the editor showed the right rows and the stored array was written back verbatim - but + * the document is now re-rendered from whatever is parsed here, so a `time` we cannot read would + * collapse every block to 00:00 and publish that to the sync channel and to Nightscout. The damage + * would be silent, because all-zero starts still divide evenly by 3600 and so still parse. + * + * Treating **zero** as "ask `time`" is what keeps this from failing the same way in reverse: an + * uploader that omits `timeAsSeconds` from a defaults-filled struct writes 0 for every entry, and + * trusting that would flatten the schedule just as badly. A real midnight block is unaffected - its + * `time` reads back as 0 anyway - and when neither field can be read, a present-but-zero + * `timeAsSeconds` is still honoured. + * + * Null only when neither field is usable, which the callers turn into an invalid profile. + */ +private fun JsonArray.startSecondsAt(index: Int, dateUtil: DateUtil): Int? { + val entry = getOrNull(index) as? JsonObject + val seconds = (entry?.get("timeAsSeconds") as? JsonPrimitive)?.content?.toDoubleOrNull()?.toInt() + if (seconds != null && seconds != 0) return seconds + val time = (entry?.get("time") as? JsonPrimitive)?.content + if (time != null && READABLE_TIME.containsMatchIn(time)) return dateUtil.toSeconds(time) + // Absent -> null (invalid profile); present and zero -> a genuine midnight block. + return seconds +} + +/** + * `value` as org.json's `getDouble` would give it - coercing a quoted number, which real Nightscout + * and AAPS documents both contain. Null when absent or not numeric, which the callers turn into an + * invalid profile. + */ +private fun JsonArray.valueAt(index: Int): Double? = + ((getOrNull(index) as? JsonObject)?.get("value") as? JsonPrimitive)?.content?.toDoubleOrNull() + +/** + * Reads a profile schedule (`basal`, `sens`, `carbratio`) into blocks. + * + * The logic lives on kotlinx [JsonArray] so it can eventually move to commonMain; the `org.json` + * entry point below is a thin adapter kept while the callers still hold `JSONArray`. This is the + * inside-out step of the org.json migration - the parsing rules move first, the contracts follow. + * + * Behaviour is deliberately unchanged and is pinned by `ProfileJsonCharacterizationTest`: + * - a value may be a real number OR a quoted string; both occur in the wild + * - a schedule not aligned to whole hours is rejected + * - anything unreadable yields **null** (an invalid profile), never an exception + */ +fun blockFromJson(jsonArray: JsonArray?, dateUtil: DateUtil): List? { + if (jsonArray == null || jsonArray.isEmpty()) return null + val ret = ArrayList(jsonArray.size) + for (index in 0 until jsonArray.size - 1) { + val tas = jsonArray.startSecondsAt(index, dateUtil) ?: return null + val nextTas = jsonArray.startSecondsAt(index + 1, dateUtil) ?: return null + val value = jsonArray.valueAt(index) ?: return null + if (tas % 3600 != 0) return null + if (nextTas % 3600 != 0) return null + ret.add(index, Block((nextTas - tas) * 1000L, value)) + } + val lastTas = jsonArray.startSecondsAt(jsonArray.size - 1, dateUtil) ?: return null + val lastValue = jsonArray.valueAt(jsonArray.size - 1) ?: return null + ret.add(jsonArray.size - 1, Block((T.hours(24).secs() - lastTas) * 1000L, lastValue)) + return ret +} + +/** + * Reads `target_low` and `target_high` together into range blocks. + * + * Same inside-out treatment as [blockFromJson]. Rules pinned by `ProfileJsonCharacterizationTest`: + * the two arrays must be the same length, entry N of each must be for the same time, and every time + * must fall on the hour. + * + * The last entry's times are deliberately NOT compared - the original loop only checked entries + * 0..n-2 and read just the value of the final one. That is reproduced exactly rather than tightened, + * so this conversion stays provably behaviour-preserving; see + * `a differing time on the LAST entry is not caught`. + */ +fun targetBlockFromJson(jsonArray1: JsonArray?, jsonArray2: JsonArray?, dateUtil: DateUtil): List? { + if (jsonArray1 == null || jsonArray2 == null) return null + if (jsonArray1.isEmpty() || jsonArray1.size != jsonArray2.size) return null + val ret = ArrayList(jsonArray1.size) + for (index in 0 until jsonArray1.size - 1) { + val tas1 = jsonArray1.startSecondsAt(index, dateUtil) ?: return null + val value1 = jsonArray1.valueAt(index) ?: return null + val nextTas1 = jsonArray1.startSecondsAt(index + 1, dateUtil) ?: return null + val tas2 = jsonArray2.startSecondsAt(index, dateUtil) ?: return null + val value2 = jsonArray2.valueAt(index) ?: return null + if (tas1 != tas2) return null + if (tas1 % 3600 != 0) return null + if (nextTas1 % 3600 != 0) return null + ret.add(index, TargetBlock((nextTas1 - tas1) * 1000L, value1, value2)) + } + val lastIndex = jsonArray1.size - 1 + val lastTas1 = jsonArray1.startSecondsAt(lastIndex, dateUtil) ?: return null + val lastValue1 = jsonArray1.valueAt(lastIndex) ?: return null + val lastValue2 = jsonArray2.valueAt(lastIndex) ?: return null + ret.add(lastIndex, TargetBlock((T.hours(24).secs() - lastTas1) * 1000L, lastValue1, lastValue2)) + return ret +} + +/** + * `HH:00` for a whole-hour offset from midnight. + * + * Padded by hand rather than with `String.format`: `%02d` renders digits using the *locale's* zero + * digit, so under a locale such as ar-EG it writes `٠١:٠٠`, which no reader can parse back. The + * profile editor used to build times that way, so this also removes that trap. + */ +private fun hourLabel(secondsFromMidnight: Int): String { + val hours = secondsFromMidnight / 3600 + return (if (hours < 10) "0$hours" else "$hours") + ":00" +} + +/** + * Render a schedule back to the `[{time, timeAsSeconds, value}, …]` array shape used by both + * Nightscout and the stored profile document. + * + * Blocks carry durations, not start times, so each entry's start is the running sum of the blocks + * before it. Inverse of [blockFromJson] for any schedule that one accepts. + */ +fun List.toJsonArray(): JsonArray { + var startSeconds = 0 + return buildJsonArray { + for (block in this@toJsonArray) { + add(timeValueObject(startSeconds, block.amount)) + startSeconds += T.msecs(block.duration).secs().toInt() + } + } +} + +/** [toJsonArray] for the low side of a target schedule. */ +fun List.lowToJsonArray(): JsonArray = toJsonArray { it.lowTarget } + +/** [toJsonArray] for the high side of a target schedule. */ +fun List.highToJsonArray(): JsonArray = toJsonArray { it.highTarget } + +private fun List.toJsonArray(select: (TargetBlock) -> Double): JsonArray { + var startSeconds = 0 + return buildJsonArray { + for (block in this@toJsonArray) { + add(timeValueObject(startSeconds, select(block))) + startSeconds += T.msecs(block.duration).secs().toInt() + } + } +} + +private fun timeValueObject(startSeconds: Int, value: Double): JsonObject = + buildJsonObject { + put("time", hourLabel(startSeconds)) + put("timeAsSeconds", startSeconds) + put("value", value) + } + +/** One block covering the whole day — the shape a freshly seeded or damaged schedule takes. */ +fun singleBlock(value: Double): List = listOf(Block(T.hours(24).msecs(), value)) + +/** [singleBlock] for a target schedule. */ +fun singleTargetBlock(low: Double, high: Double): List = listOf(TargetBlock(T.hours(24).msecs(), low, high)) \ 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/commonMain/kotlin/app/aaps/core/objects/extensions/ExtendedBolusExtension.kt similarity index 79% rename from core/objects/src/main/kotlin/app/aaps/core/objects/extensions/ExtendedBolusExtension.kt rename to core/objects/src/commonMain/kotlin/app/aaps/core/objects/extensions/ExtendedBolusExtension.kt index aca9323dc154..8f03890ca356 100644 --- a/core/objects/src/main/kotlin/app/aaps/core/objects/extensions/ExtendedBolusExtension.kt +++ b/core/objects/src/commonMain/kotlin/app/aaps/core/objects/extensions/ExtendedBolusExtension.kt @@ -1,41 +1,26 @@ package app.aaps.core.objects.extensions +import kotlin.time.Clock 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 -import app.aaps.core.data.time.T +import app.aaps.core.data.model.getPassedDurationToTimeInMinutes +import app.aaps.core.data.model.iobCalc import app.aaps.core.interfaces.aps.AutosensResult import app.aaps.core.interfaces.aps.IobTotal -import app.aaps.core.interfaces.insulin.ConcentrationHelper import app.aaps.core.interfaces.profile.EffectiveProfile import app.aaps.core.interfaces.profile.Profile -import app.aaps.core.interfaces.pump.PumpRate -import app.aaps.core.interfaces.resources.ResourceHelper import app.aaps.core.interfaces.utils.DateUtil import kotlin.math.ceil import kotlin.math.max -import kotlin.math.min import kotlin.math.round -import kotlin.math.roundToInt fun EB.isInProgress(dateUtil: DateUtil): Boolean = dateUtil.now() in timestamp..timestamp + duration val EB.plannedRemainingMinutes: Int - get() = max(round((end - System.currentTimeMillis()) / 1000.0 / 60).toInt(), 0) - -fun EB.toStringFull(dateUtil: DateUtil, rh:ResourceHelper): String = - rh.gs(app.aaps.core.ui.R.string.extended_bolus_full, rate, dateUtil.timeString(timestamp), getPassedDurationToTimeInMinutes(dateUtil.now()), T.msecs(duration).mins()) - -fun EB.toStringFull(dateUtil: DateUtil, ch: ConcentrationHelper): String = - "${ch.basalRateString(PumpRate(rate), true)} ${dateUtil.timeString(timestamp)} ${getPassedDurationToTimeInMinutes(dateUtil.now())}/${T.msecs(duration).mins()}" - -fun EB.toStringMedium(dateUtil: DateUtil, rh:ResourceHelper): String = - rh.gs(app.aaps.core.ui.R.string.extended_bolus_medium, rate, getPassedDurationToTimeInMinutes(dateUtil.now()), T.msecs(duration).mins()) - -fun EB.getPassedDurationToTimeInMinutes(time: Long): Int = - ((min(time, end) - timestamp) / 60.0 / 1000).roundToInt() + get() = max(round((end - Clock.System.now().toEpochMilliseconds()) / 1000.0 / 60).toInt(), 0) fun EB.toTemporaryBasal(profile: Profile): TB = TB( diff --git a/core/objects/src/main/kotlin/app/aaps/core/objects/extensions/FlowExtension.kt b/core/objects/src/commonMain/kotlin/app/aaps/core/objects/extensions/FlowExtension.kt similarity index 95% rename from core/objects/src/main/kotlin/app/aaps/core/objects/extensions/FlowExtension.kt rename to core/objects/src/commonMain/kotlin/app/aaps/core/objects/extensions/FlowExtension.kt index 8ca16e868061..a270cc65f628 100644 --- a/core/objects/src/main/kotlin/app/aaps/core/objects/extensions/FlowExtension.kt +++ b/core/objects/src/commonMain/kotlin/app/aaps/core/objects/extensions/FlowExtension.kt @@ -1,5 +1,6 @@ package app.aaps.core.objects.extensions +import kotlin.time.Clock import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow @@ -49,7 +50,7 @@ fun StateFlow.freshness( scope: CoroutineScope, tickMs: Long = 60_000L, pristine: Boolean = true, - now: () -> Long = { System.currentTimeMillis() } + now: () -> Long = { Clock.System.now().toEpochMilliseconds() } ): StateFlow = combine(this, tickerFlow(tickMs)) { ts, _ -> if (ts == 0L) pristine diff --git a/core/objects/src/commonMain/kotlin/app/aaps/core/objects/extensions/GlucoseValueExtension.kt b/core/objects/src/commonMain/kotlin/app/aaps/core/objects/extensions/GlucoseValueExtension.kt new file mode 100644 index 000000000000..17ad272acbc4 --- /dev/null +++ b/core/objects/src/commonMain/kotlin/app/aaps/core/objects/extensions/GlucoseValueExtension.kt @@ -0,0 +1,42 @@ +package app.aaps.core.objects.extensions + +import app.aaps.core.data.configuration.Constants +import app.aaps.core.data.iob.InMemoryGlucoseValue +import app.aaps.core.data.model.GV +import app.aaps.core.data.model.GlucoseUnit +import app.aaps.core.interfaces.utils.DateUtil +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put + +/** + * Nightscout `entries` document for one reading. + * + * Write only - nothing here parses, so none of the lenient-read rules in [lenientInt] apply. Two + * things still have to be written exactly as they are: + * + * - `_id` is added only when it exists. `org.json` DELETES a key when you put null into it, so the + * old code emitted no `_id` at all for a reading that has no Nightscout id. Writing + * `put("_id", ids.nightscoutId)` unguarded would instead emit `"_id":null`, which a reader sees as + * a present-but-null id. + * - `timestamp` is put as a Long. Never convert it to Double first: that renders as `1.5147669E12` + * and destroys the date. + * + * Key order matches the old chained puts, because `glucoseToJSON` writes this into an + * `entries.json` file that users hand to oref0 autotune and read by eye. + */ +fun GV.toJsonObject(isAdd: Boolean, dateUtil: DateUtil): JsonObject = + buildJsonObject { + put("device", sourceSensor.text) + put("date", timestamp) + put("dateString", dateUtil.toISOString(timestamp)) + put("isValid", isValid) + put("sgv", value) + put("direction", trendArrow.text) + put("type", "sgv") + if (isAdd) ids.nightscoutId?.let { put("_id", it) } + } + +fun InMemoryGlucoseValue.valueToUnits(units: GlucoseUnit): Double = + if (units == GlucoseUnit.MGDL) recalculated + else recalculated * Constants.MGDL_TO_MMOLL diff --git a/core/objects/src/main/kotlin/app/aaps/core/objects/extensions/InMemoryGlucoseValueExtension.kt b/core/objects/src/commonMain/kotlin/app/aaps/core/objects/extensions/InMemoryGlucoseValueExtension.kt similarity index 100% rename from core/objects/src/main/kotlin/app/aaps/core/objects/extensions/InMemoryGlucoseValueExtension.kt rename to core/objects/src/commonMain/kotlin/app/aaps/core/objects/extensions/InMemoryGlucoseValueExtension.kt diff --git a/core/objects/src/commonMain/kotlin/app/aaps/core/objects/extensions/InsulinExtension.kt b/core/objects/src/commonMain/kotlin/app/aaps/core/objects/extensions/InsulinExtension.kt new file mode 100644 index 000000000000..b1d097194c98 --- /dev/null +++ b/core/objects/src/commonMain/kotlin/app/aaps/core/objects/extensions/InsulinExtension.kt @@ -0,0 +1,40 @@ +package app.aaps.core.objects.extensions + +import app.aaps.core.data.model.ICfg +import app.aaps.core.utils.lenientDouble +import app.aaps.core.utils.lenientLong +import app.aaps.core.utils.lenientString +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put + +fun ICfg.toJsonObject(): JsonObject = buildJsonObject { + put("insulinLabel", insulinLabel) + put("insulinEndTime", insulinEndTime) + put("insulinPeakTime", insulinPeakTime) + put("concentration", JsonPrimitive(concentration)) + put("insulinNickname", insulinNickname) +} + +/** + * used to restore configuration within InsulinPlugin and insulin Editor + * + * Reads leniently on purpose. `longOrNull` is `content.toLongOrNull()`, which answers null for + * `1.8E7` and for `18000000.5` - forms the `org.json` reader this replaced accepted, because it + * coerced through `Double`. Falling back to 0 there would mean `insulinEndTime == 0`, i.e. DIA 0, so + * IOB would decay at once and the loop would believe there is no insulin on board. See + * [lenientLong]. + */ +fun ICfg.Companion.fromJsonObject(json: JsonObject): ICfg { + val icfg = ICfg( + insulinLabel = json.lenientString("insulinLabel"), + insulinEndTime = json.lenientLong("insulinEndTime"), + insulinPeakTime = json.lenientLong("insulinPeakTime"), + concentration = json.lenientDouble("concentration", 1.0) + ) + + icfg.insulinNickname = json.lenientString("insulinNickname") + + return icfg +} diff --git a/core/objects/src/main/kotlin/app/aaps/core/objects/extensions/IobTotalExtension.kt b/core/objects/src/commonMain/kotlin/app/aaps/core/objects/extensions/IobTotalExtension.kt similarity index 58% rename from core/objects/src/main/kotlin/app/aaps/core/objects/extensions/IobTotalExtension.kt rename to core/objects/src/commonMain/kotlin/app/aaps/core/objects/extensions/IobTotalExtension.kt index 413cb91312d2..e99cf7cf296f 100644 --- a/core/objects/src/main/kotlin/app/aaps/core/objects/extensions/IobTotalExtension.kt +++ b/core/objects/src/commonMain/kotlin/app/aaps/core/objects/extensions/IobTotalExtension.kt @@ -3,24 +3,10 @@ package app.aaps.core.objects.extensions import app.aaps.core.interfaces.aps.IobTotal import app.aaps.core.interfaces.utils.DateUtil import app.aaps.core.interfaces.utils.Round -import org.json.JSONArray -import org.json.JSONException -import org.json.JSONObject - -fun IobTotal.copy(): IobTotal { - val i = IobTotal(time) - i.iob = iob - i.activity = activity - i.bolussnooze = bolussnooze - i.basaliob = basaliob - i.netbasalinsulin = netbasalinsulin - i.hightempinsulin = hightempinsulin - i.lastBolusTime = lastBolusTime - i.iobWithZeroTemp = iobWithZeroTemp?.copy() - i.netInsulin = netInsulin - i.extendedBolusInsulin = extendedBolusInsulin - return i -} +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonObjectBuilder +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put operator fun IobTotal.plus(other: IobTotal): IobTotal { iob += other.iob @@ -46,27 +32,34 @@ fun IobTotal.round(): IobTotal { return this } -fun IobTotal.json(dateUtil: DateUtil): JSONObject { - val json = JSONObject() - try { - json.put("iob", iob) - json.put("basaliob", basaliob) - json.put("activity", activity) - json.put("time", dateUtil.toISOString(time)) - } catch (_: JSONException) { - } - return json +/** + * Writes [value] only when it is a real number. + * + * kotlinx accepts NaN and Infinity and then emits the bare token `NaN`, which is not valid JSON and + * which `org.json` refuses to re-render - it would turn an uploaded device status into `null`. The + * `org.json` builder this replaced refused a non-finite value outright, so skipping the key keeps the + * old "a bad number never reaches the document" rule while leaving the rest of it intact. + */ +private fun JsonObjectBuilder.putIfFinite(key: String, value: Double) { + if (value.isFinite()) put(key, value) } -fun IobTotal.determineBasalJson(dateUtil: DateUtil): JSONObject { - val json = JSONObject() - try { - json.put("iob", iob) - json.put("basaliob", basaliob) - json.put("bolussnooze", bolussnooze) - json.put("activity", activity) - json.put("lastBolusTime", lastBolusTime) - json.put("time", dateUtil.toISOString(time)) +fun IobTotal.jsonObject(dateUtil: DateUtil): JsonObject = + buildJsonObject { + putIfFinite("iob", iob) + putIfFinite("basaliob", basaliob) + putIfFinite("activity", activity) + put("time", dateUtil.toISOString(time)) + } + +fun IobTotal.determineBasalJsonObject(dateUtil: DateUtil): JsonObject = + buildJsonObject { + putIfFinite("iob", iob) + putIfFinite("basaliob", basaliob) + putIfFinite("bolussnooze", bolussnooze) + putIfFinite("activity", activity) + put("lastBolusTime", lastBolusTime) + put("time", dateUtil.toISOString(time)) /* This is requested by SMB determine_basal but by based on Scott's info @@ -79,14 +72,8 @@ fun IobTotal.determineBasalJson(dateUtil: DateUtil): JSONObject { lastTemp.put("duration", lastTempDuration); json.put("lastTemp", lastTemp); */ - if (iobWithZeroTemp != null) { - val iwzt = iobWithZeroTemp!!.determineBasalJson(dateUtil) - json.put("iobWithZeroTemp", iwzt) - } - } catch (_: JSONException) { + iobWithZeroTemp?.let { put("iobWithZeroTemp", it.determineBasalJsonObject(dateUtil)) } } - return json -} fun IobTotal.Companion.combine(bolusIOB: IobTotal, basalIob: IobTotal): IobTotal { val result = IobTotal(bolusIOB.time) @@ -102,12 +89,3 @@ fun IobTotal.Companion.combine(bolusIOB: IobTotal, basalIob: IobTotal): IobTotal result.iobWithZeroTemp = basalIob.iobWithZeroTemp return result } - -fun Array.convertToJSONArray(dateUtil: DateUtil): JSONArray { - val array = JSONArray() - for (i in this.indices) { - array.put(this[i].determineBasalJson(dateUtil)) - } - return array -} - diff --git a/core/objects/src/commonMain/kotlin/app/aaps/core/objects/extensions/JsonObjectExtension.kt b/core/objects/src/commonMain/kotlin/app/aaps/core/objects/extensions/JsonObjectExtension.kt new file mode 100644 index 000000000000..151ff23ec892 --- /dev/null +++ b/core/objects/src/commonMain/kotlin/app/aaps/core/objects/extensions/JsonObjectExtension.kt @@ -0,0 +1,98 @@ +package app.aaps.core.objects.extensions + +import app.aaps.core.keys.interfaces.BooleanNonPreferenceKey +import app.aaps.core.keys.interfaces.DoubleNonPreferenceKey +import app.aaps.core.keys.interfaces.IntNonPreferenceKey +import app.aaps.core.keys.interfaces.LongNonPreferenceKey +import app.aaps.core.keys.interfaces.NonPreferenceKey +import app.aaps.core.keys.interfaces.Preferences +import app.aaps.core.keys.interfaces.StringNonPreferenceKey +import app.aaps.core.keys.interfaces.UnitDoublePreferenceKey +import app.aaps.core.utils.lenientBooleanOrNull +import app.aaps.core.utils.lenientDoubleOrNull +import app.aaps.core.utils.lenientIntOrNull +import app.aaps.core.utils.lenientLongOrNull +import app.aaps.core.utils.lenientStringOrNull +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonObjectBuilder +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put + +fun JsonObject.put(key: NonPreferenceKey, preferences: Preferences): JsonObject { + val primitive: JsonPrimitive = when (key) { + is IntNonPreferenceKey -> JsonPrimitive(preferences.get(key)) + is LongNonPreferenceKey -> JsonPrimitive(preferences.get(key)) + is DoubleNonPreferenceKey -> JsonPrimitive(preferences.get(key)) + is UnitDoublePreferenceKey -> JsonPrimitive(preferences.get(key)) + is StringNonPreferenceKey -> JsonPrimitive(preferences.get(key)) + is BooleanNonPreferenceKey -> JsonPrimitive(preferences.get(key)) + else -> error("Unsupported key type: ${key::class.simpleName}") + } + return JsonObject(this.toMutableMap().apply { this[key.key] = primitive }) +} + +fun JsonObject.put(key: BooleanNonPreferenceKey, value: Boolean): JsonObject = + JsonObject( + this.toMutableMap().apply { + this[key.key] = JsonPrimitive(value) + } + ) + +/** + * Reads leniently, the way the `org.json` twin does. + * + * The strict kotlinx accessors (`raw.int`, `raw.double`, `raw.boolean`) throw for a quoted number, + * while `org.json`'s `getInt` / `getDouble` / `getBoolean` coerce it - `"36"`, `36.9` and `"36.9"` all + * read back as 36 there. A value that cannot be read at all still throws, because the `org.json` + * version has no catch either and callers rely on an unreadable document failing loudly. + */ +fun JsonObject.store(key: NonPreferenceKey, preferences: Preferences): JsonObject { + if (!contains(key.key)) return this + fun unreadable(): Nothing = error("Cannot read ${key.key} as ${key::class.simpleName}") + when (key) { + is IntNonPreferenceKey -> preferences.put(key, lenientIntOrNull(key.key) ?: unreadable()) + is LongNonPreferenceKey -> preferences.put(key, lenientLongOrNull(key.key) ?: unreadable()) + is DoubleNonPreferenceKey -> preferences.put(key, lenientDoubleOrNull(key.key) ?: unreadable()) + is UnitDoublePreferenceKey -> preferences.put(key, lenientDoubleOrNull(key.key) ?: unreadable()) + is StringNonPreferenceKey -> preferences.put(key, lenientStringOrNull(key.key) ?: unreadable()) + is BooleanNonPreferenceKey -> preferences.put(key, lenientBooleanOrNull(key.key) ?: unreadable()) + else -> error("Unsupported key type: ${key::class.simpleName}") + } + return this +} + +/** + * kotlinx twins of the `org.json` `putIfThereIsValue`, with the same rule: skip nulls **and** zeros. + * + * The zero-skipping is not cosmetic. It is what keeps an idle temp basal or extended bolus out of the + * Nightscout device status entirely, rather than reporting a rate of 0 U/h. + */ +fun JsonObjectBuilder.putIfThereIsValue(key: String, value: Int?) { + if (value != null && value != 0) put(key, value) +} + +fun JsonObjectBuilder.putIfThereIsValue(key: String, value: Long?) { + if (value != null && value != 0L) put(key, value) +} + +fun JsonObjectBuilder.putIfThereIsValue(key: String, value: Double?) { + if (value != null && value != 0.0) put(key, value) +} + +fun JsonObjectBuilder.putIfThereIsValue(key: String, value: String?) { + if (value != null && value.isNotEmpty()) put(key, value) +} + +/** + * Copy of this document with [extra] entries added on top. + * + * A [JsonObject] cannot be written into after it is built, so code that used to take a finished + * document and keep putting into it needs a new document instead. Entries added here win over + * entries of the same name already present, which is what writing into the old object did. + */ +fun JsonObject.with(extra: JsonObjectBuilder.() -> Unit): JsonObject = + buildJsonObject { + this@with.forEach { (key, value) -> put(key, value) } + extra() + } diff --git a/core/objects/src/main/kotlin/app/aaps/core/objects/extensions/PreferencesExtension.kt b/core/objects/src/commonMain/kotlin/app/aaps/core/objects/extensions/PreferencesExtension.kt similarity index 100% rename from core/objects/src/main/kotlin/app/aaps/core/objects/extensions/PreferencesExtension.kt rename to core/objects/src/commonMain/kotlin/app/aaps/core/objects/extensions/PreferencesExtension.kt diff --git a/core/objects/src/main/kotlin/app/aaps/core/objects/extensions/ProfileExtension.kt b/core/objects/src/commonMain/kotlin/app/aaps/core/objects/extensions/ProfileExtension.kt similarity index 100% rename from core/objects/src/main/kotlin/app/aaps/core/objects/extensions/ProfileExtension.kt rename to core/objects/src/commonMain/kotlin/app/aaps/core/objects/extensions/ProfileExtension.kt diff --git a/core/objects/src/main/kotlin/app/aaps/core/objects/extensions/ProfileRepositoryExtensions.kt b/core/objects/src/commonMain/kotlin/app/aaps/core/objects/extensions/ProfileRepositoryExtensions.kt similarity index 100% rename from core/objects/src/main/kotlin/app/aaps/core/objects/extensions/ProfileRepositoryExtensions.kt rename to core/objects/src/commonMain/kotlin/app/aaps/core/objects/extensions/ProfileRepositoryExtensions.kt diff --git a/core/objects/src/commonMain/kotlin/app/aaps/core/objects/extensions/ProfileSwitchExtension.kt b/core/objects/src/commonMain/kotlin/app/aaps/core/objects/extensions/ProfileSwitchExtension.kt new file mode 100644 index 000000000000..e87a269f5a91 --- /dev/null +++ b/core/objects/src/commonMain/kotlin/app/aaps/core/objects/extensions/ProfileSwitchExtension.kt @@ -0,0 +1,117 @@ +package app.aaps.core.objects.extensions + +import app.aaps.core.data.configuration.Constants +import app.aaps.core.data.model.GlucoseUnit +import app.aaps.core.data.model.ICfg +import app.aaps.core.data.model.PS +import app.aaps.core.data.time.T +import app.aaps.core.data.time.systemUtcOffsetAt +import app.aaps.core.interfaces.profile.PureProfile +import app.aaps.core.interfaces.profile.SingleProfile +import app.aaps.core.interfaces.utils.DateUtil +import app.aaps.core.interfaces.utils.DecimalFormatter +import app.aaps.core.objects.profile.ProfileSealed +import app.aaps.core.utils.lenientString +import app.aaps.core.utils.lenientStringOrNull +import kotlinx.datetime.TimeZone +import kotlinx.datetime.offsetAt +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonObject +import kotlin.time.Instant + +fun PS.getCustomizedName(decimalFormatter: DecimalFormatter): String { + var name: String = profileName + if (Constants.LOCAL_PROFILE == name) { + name = decimalFormatter.to2Decimal(ProfileSealed.PS(value = this, activePlugin = null).percentageBasalSum()) + "U " + } + if (timeshift != 0L || percentage != 100) { + name += " ($percentage%" + if (timeshift != 0L) name += "," + T.msecs(timeshift).hours() + "h" + name += ")" + } + return name +} + +/** + * Convert a [SingleProfile] to a [PureProfile] for graph rendering, validation, or activation. + * + * Both types now hold the same block lists, so this is a field copy. It used to render the profile + * to JSON and parse it straight back, which is why the editor paid for a full serialise/parse round + * trip on every keystroke. + * + * Nullable only to keep the call sites unchanged — a [SingleProfile] always carries usable blocks, + * so this never actually returns null. + */ +fun SingleProfile.toPureProfile(dateUtil: DateUtil): PureProfile? = + PureProfile( + basalBlocks = basal, + isfBlocks = isf, + icBlocks = ic, + targetBlocks = target, + glucoseUnit = if (mgdl) GlucoseUnit.MGDL else GlucoseUnit.MMOL, + utcOffset = systemUtcOffsetAt(dateUtil.now()) + ) + +/** + * Reads a profile straight from its stored text. + * + * Most callers hold the profile as a string - out of the database, or off the Nightscout wire - and + * only built a JSON document to hand it over. Parsing here removes that step and keeps the JSON + * library out of the caller entirely. + * + * Unreadable text gives null, i.e. an invalid profile, the same answer an unreadable document gives. + */ +fun pureProfileFromJson(profileText: String, dateUtil: DateUtil, defaultUnits: String? = null): PureProfile? { + val parsed = runCatching { Json.parseToJsonElement(profileText) as? JsonObject }.getOrNull() ?: return null + return pureProfileFromJson(parsed, dateUtil, defaultUnits) +} + +/** + * Pure profile doesn't contain timestamp, percentage, timeshift, profileName + * + * Every read here is deliberately as forgiving as the `org.json` version was, because the rules + * decide whether a profile is usable at all: + * + * - **units** is the dangerous one. Absent, or explicitly JSON null, falls back to [defaultUnits], + * and only a still-missing value REJECTS the profile. That rejection has to survive, because + * `GlucoseUnit.fromText` never throws - it answers MGDL for anything it does not recognise, so a + * null slipping through would read an mmol/L profile as mg/dL and put every target, ISF and + * correction out by a factor of 18. + * - a schedule that is missing, is not an array, or cannot be read yields **null**, i.e. an invalid + * profile, never an exception. + */ +fun pureProfileFromJson(jsonObject: JsonObject, dateUtil: DateUtil, defaultUnits: String? = null): PureProfile? { + try { + val txtUnits = jsonObject.lenientStringOrNull("units") ?: defaultUnits ?: return null + val units = GlucoseUnit.fromText(txtUnits) + val iCfg = (jsonObject["iCfg"] as? JsonObject)?.let { ICfg.fromJsonObject(it) } + // The offset AT THIS MOMENT, not the zone's standard offset. Taking `rawOffset` here is what + // made a summer Prague profile claim +01:00 and then get named after some unrelated zone that + // really is at +01:00 in July. `java.util.TimeZone.getTimeZone` quietly answered GMT for an id + // it did not know, and kotlinx throws instead, so that fallback is kept explicitly. + val zoneName = jsonObject.lenientString("timezone", "UTC") + val zone = runCatching { TimeZone.of(zoneName) }.getOrDefault(TimeZone.UTC) + val utcOffset = zone.offsetAt(Instant.fromEpochMilliseconds(dateUtil.now())).totalSeconds * 1000L + + val isfBlocks = blockFromJson(jsonObject["sens"] as? JsonArray, dateUtil) ?: return null + val icBlocks = blockFromJson(jsonObject["carbratio"] as? JsonArray, dateUtil) + ?: return null + val basalBlocks = blockFromJson(jsonObject["basal"] as? JsonArray, dateUtil) + ?: return null + val targetBlocks = targetBlockFromJson(jsonObject["target_low"] as? JsonArray, jsonObject["target_high"] as? JsonArray, dateUtil) + ?: return null + + return PureProfile( + basalBlocks = basalBlocks, + isfBlocks = isfBlocks, + icBlocks = icBlocks, + targetBlocks = targetBlocks, + glucoseUnit = units, + utcOffset = utcOffset, + iCfg = iCfg + ) + } catch (_: Exception) { + return null + } +} diff --git a/core/objects/src/commonMain/kotlin/app/aaps/core/objects/extensions/SceneSerializer.kt b/core/objects/src/commonMain/kotlin/app/aaps/core/objects/extensions/SceneSerializer.kt new file mode 100644 index 000000000000..d9b4c8665035 --- /dev/null +++ b/core/objects/src/commonMain/kotlin/app/aaps/core/objects/extensions/SceneSerializer.kt @@ -0,0 +1,201 @@ +package app.aaps.core.objects.extensions + +import app.aaps.core.data.model.RM +import app.aaps.core.data.model.Scene +import app.aaps.core.data.model.SceneAction +import app.aaps.core.data.model.SceneEndAction +import app.aaps.core.data.model.TE +import app.aaps.core.data.model.TT +import app.aaps.core.utils.lenientBoolean +import app.aaps.core.utils.lenientBooleanOrNull +import app.aaps.core.utils.lenientDoubleOrNull +import app.aaps.core.utils.lenientInt +import app.aaps.core.utils.lenientString +import app.aaps.core.utils.lenientStringOrNull +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.buildJsonArray +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.put + +/** + * Scene list to and from the text held in `StringNonKey.SceneDefinitions`. + * + * Both entry points take and return a [String], so nothing outside this file ever sees the JSON + * library and no `org.json` adapter is needed. + * + * Reads go through the lenient helpers so the documents `org.json` used to write keep parsing: + * `defaultDurationMinutes` may be stored as `60`, `60.0` or `"60"` and still reads as 60. Writes + * differ in one harmless way - `org.json` printed a whole numbered Double as a bare integer + * (`140.0` as `140`) and kotlinx prints `140.0`. Both parse back to the same value, and only a scene + * the user actually edits gets rewritten. + */ + +/** + * Extension function to convert a list of Scene to JSON string. + * @return JSON string representation of scenes + */ +fun List.toJson(): String = + buildJsonArray { + this@toJson.forEach { scene -> + add( + buildJsonObject { + put("id", scene.id) + put("name", scene.name) + put("icon", scene.icon) + put("defaultDurationMinutes", scene.defaultDurationMinutes) + put("isDeletable", scene.isDeletable) + put("isEnabled", scene.isEnabled) + put("sortOrder", scene.sortOrder) + put("actions", scene.actions.toJsonArray()) + put("endAction", scene.endAction.toJsonObject()) + } + ) + } + }.toString() + +/** + * Extension function to parse JSON string into a list of Scene. + * @return List of Scene objects, or empty list if parsing fails + */ +fun String.toScenes(): List { + return try { + if (isEmpty() || this == "[]") { + emptyList() + } else { + val jsonArray = Json.parseToJsonElement(this) as JsonArray + // Skip a scene we cannot read, keep the rest. Without the per-entry catch, one missing + // "id" or "name" threw all the way out to the outer catch below and returned an empty + // list - a single damaged entry silently wiped the user's whole scene catalogue. This + // matches how an unknown action type is already handled: skipped, not fatal. + jsonArray.mapNotNull { element -> + runCatching { + val obj = element.jsonObject + Scene( + // id and name have no sensible default, so a missing one drops this scene. + id = obj.lenientStringOrNull("id") ?: error("scene without id"), + name = obj.lenientStringOrNull("name") ?: error("scene without name"), + icon = obj.lenientString("icon", "star"), + defaultDurationMinutes = obj.lenientInt("defaultDurationMinutes", 60), + isDeletable = obj.lenientBoolean("isDeletable", true), + isEnabled = obj.lenientBoolean("isEnabled", true), + sortOrder = obj.lenientInt("sortOrder", 0), + actions = (obj["actions"] as? JsonArray)?.toSceneActions() ?: emptyList(), + endAction = (obj["endAction"] as? JsonObject)?.toSceneEndAction() ?: SceneEndAction.Notification + ) + }.getOrNull() + } + } + } catch (_: Exception) { + emptyList() + } +} + +// --- SceneAction serialization --- + +private fun List.toJsonArray(): JsonArray = + buildJsonArray { + forEach { action -> + add( + buildJsonObject { + when (action) { + is SceneAction.TempTarget -> { + put("type", "temp_target") + put("reason", action.reason.text) + put("targetMgdl", action.targetMgdl) + } + + is SceneAction.ProfileSwitch -> { + put("type", "profile_switch") + put("profileName", action.profileName) + put("percentage", action.percentage) + put("timeShiftHours", action.timeShiftHours) + } + + is SceneAction.SmbToggle -> { + put("type", "smb_toggle") + put("enabled", action.enabled) + } + + is SceneAction.LoopModeChange -> { + put("type", "loop_mode") + put("mode", action.mode.name) + } + + is SceneAction.CarePortalEvent -> { + put("type", "careportal") + put("teType", action.type.text) + put("note", action.note) + } + } + } + ) + } + } + +private fun JsonArray.toSceneActions(): List { + return mapNotNull { element -> + val obj = element.jsonObject + when (obj.lenientStringOrNull("type")) { + "temp_target" -> SceneAction.TempTarget( + reason = TT.Reason.fromString(obj.lenientString("reason")), + // No default: an unreadable target must not silently become 0 mg/dl. + targetMgdl = obj.lenientDoubleOrNull("targetMgdl") ?: error("temp target without targetMgdl") + ) + + "profile_switch" -> SceneAction.ProfileSwitch( + profileName = obj.lenientString("profileName"), + percentage = obj.lenientInt("percentage", 100), + timeShiftHours = obj.lenientInt("timeShiftHours", 0) + ) + + "smb_toggle" -> SceneAction.SmbToggle( + // getBoolean threw for a missing or non-boolean value, and the throw was fatal to the + // whole list. Keeping it fatal to this action only, via the same error() route. + enabled = obj.lenientBooleanOrNull("enabled") ?: error("smb toggle without enabled") + ) + + "loop_mode" -> SceneAction.LoopModeChange( + // Deliberately swallowed, as before: an unreadable mode defaults to closed loop + // rather than dropping the action. + mode = try { + RM.Mode.valueOf(obj.lenientString("mode")) + } catch (_: Exception) { + RM.Mode.CLOSED_LOOP + } + ) + + "careportal" -> SceneAction.CarePortalEvent( + type = TE.Type.entries.firstOrNull { it.text == obj.lenientString("teType") } ?: TE.Type.NOTE, + note = obj.lenientString("note", "") + ) + + else -> null + } + } +} + +// --- SceneEndAction serialization --- + +private fun SceneEndAction.toJsonObject(): JsonObject = + buildJsonObject { + when (this@toJsonObject) { + is SceneEndAction.Notification -> put("type", "notification") + + is SceneEndAction.ChainScene -> { + put("type", "chain_scene") + put("sceneId", sceneId) + } + } + } + +private fun JsonObject.toSceneEndAction(): SceneEndAction = + when (lenientString("type", "notification")) { + "chain_scene" -> SceneEndAction.ChainScene( + lenientStringOrNull("sceneId") ?: error("chain scene without sceneId") + ) + + else -> SceneEndAction.Notification + } diff --git a/core/objects/src/main/kotlin/app/aaps/core/objects/extensions/TemporaryBasalExtension.kt b/core/objects/src/commonMain/kotlin/app/aaps/core/objects/extensions/TemporaryBasalExtension.kt similarity index 70% rename from core/objects/src/main/kotlin/app/aaps/core/objects/extensions/TemporaryBasalExtension.kt rename to core/objects/src/commonMain/kotlin/app/aaps/core/objects/extensions/TemporaryBasalExtension.kt index b6d9fd951add..f10fb54bca03 100644 --- a/core/objects/src/main/kotlin/app/aaps/core/objects/extensions/TemporaryBasalExtension.kt +++ b/core/objects/src/commonMain/kotlin/app/aaps/core/objects/extensions/TemporaryBasalExtension.kt @@ -1,28 +1,33 @@ package app.aaps.core.objects.extensions +import kotlin.time.Clock 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 +import app.aaps.core.data.model.getPassedDurationToTimeInMinutes +import app.aaps.core.data.model.iobCalc import app.aaps.core.interfaces.aps.AutosensResult import app.aaps.core.interfaces.aps.IobTotal -import app.aaps.core.interfaces.insulin.ConcentrationHelper import app.aaps.core.interfaces.profile.EffectiveProfile import app.aaps.core.interfaces.profile.Profile -import app.aaps.core.interfaces.pump.PumpRate -import app.aaps.core.interfaces.resources.ResourceHelper import app.aaps.core.interfaces.utils.DateUtil import kotlin.math.ceil import kotlin.math.max -import kotlin.math.min import kotlin.math.round -import kotlin.math.roundToInt -fun TB.getPassedDurationToTimeInMinutes(time: Long): Int = - ((min(time, end) - timestamp) / 60.0 / 1000).roundToInt() +/** + * True while the temporary basal is running. + * + * An extension taking [DateUtil], not a property on [TB], for the same reason as + * [app.aaps.core.objects.extensions.isInProgress] on `EB`: the answer depends on the current time, + * so the model itself stays a plain value that does not read the clock, and a test can control + * "now" instead of waiting for it. + */ +fun TB.isInProgress(dateUtil: DateUtil): Boolean = + dateUtil.now() in timestamp..timestamp + duration val TB.plannedRemainingMinutes: Int - get() = max(round((end - System.currentTimeMillis()) / 1000.0 / 60).toInt(), 0) + get() = max(round((end - Clock.System.now().toEpochMilliseconds()) / 1000.0 / 60).toInt(), 0) fun TB.convertedToAbsolute(time: Long, profile: Profile): Double = if (isAbsolute) rate @@ -32,50 +37,6 @@ fun TB.convertedToPercent(time: Long, profile: Profile): Int = if (!isAbsolute) rate.toInt() else (rate / profile.getBasal(time) * 100).toInt() -private fun TB.netExtendedRate(profile: Profile) = rate - profile.getBasal(timestamp) -val TB.durationInMinutes - get() = T.msecs(duration).mins() - -fun TB.toStringFull(profile: Profile, dateUtil: DateUtil, rh: ResourceHelper): String { - val timeAndDuration = "${dateUtil.timeString(timestamp)} ${getPassedDurationToTimeInMinutes(dateUtil.now())}/${durationInMinutes}'" - - return when { - type == TB.Type.FAKE_EXTENDED -> { - rh.gs(app.aaps.core.ui.R.string.temp_basal_tsf_fake_extended, rate, netExtendedRate(profile), timeAndDuration) - } - - isAbsolute -> { - rh.gs(app.aaps.core.ui.R.string.temp_basal_tsf_absolute, rate, timeAndDuration) - } - - else -> { // percent - rh.gs(app.aaps.core.ui.R.string.temp_basal_tsf_percent, rate, timeAndDuration) - } - } -} - -fun TB.toStringFull(profile: Profile, dateUtil: DateUtil, ch: ConcentrationHelper): String { - val timeAndDuration = "${dateUtil.timeString(timestamp)} ${getPassedDurationToTimeInMinutes(dateUtil.now())}/${durationInMinutes}'" - - return when { - type == TB.Type.FAKE_EXTENDED -> { - "${ch.basalRateString(PumpRate(rate), true)} (${netExtendedRate(profile)}E) $timeAndDuration" - } - - isAbsolute -> { - "${ch.basalRateString(PumpRate(rate), true)} $timeAndDuration" - } - - else -> { // percent - "${ch.basalRateString(PumpRate(rate), false)} $timeAndDuration" - } - } -} - -fun TB.toStringShort(rh: ResourceHelper): String = - if (isAbsolute || type == TB.Type.FAKE_EXTENDED) rh.gs(app.aaps.core.ui.R.string.pump_base_basal_rate, rate) - else rh.gs(app.aaps.core.ui.R.string.formatPercent, rate) - fun TB.iobCalc(time: Long, profile: EffectiveProfile): IobTotal { if (!isValid) return IobTotal(time) val result = IobTotal(time) diff --git a/core/objects/src/commonMain/kotlin/app/aaps/core/objects/extensions/TemporaryTargetExtension.kt b/core/objects/src/commonMain/kotlin/app/aaps/core/objects/extensions/TemporaryTargetExtension.kt new file mode 100644 index 000000000000..c9b3bf4597b0 --- /dev/null +++ b/core/objects/src/commonMain/kotlin/app/aaps/core/objects/extensions/TemporaryTargetExtension.kt @@ -0,0 +1,6 @@ +package app.aaps.core.objects.extensions + +import app.aaps.core.data.model.TT + +fun TT.target(): Double = + (this.lowTarget + this.highTarget) / 2 diff --git a/core/objects/src/main/kotlin/app/aaps/core/objects/extensions/TherapyEventExtension.kt b/core/objects/src/commonMain/kotlin/app/aaps/core/objects/extensions/TherapyEventExtension.kt similarity index 88% rename from core/objects/src/main/kotlin/app/aaps/core/objects/extensions/TherapyEventExtension.kt rename to core/objects/src/commonMain/kotlin/app/aaps/core/objects/extensions/TherapyEventExtension.kt index 903b5faa4bf3..7f3ad64fb532 100644 --- a/core/objects/src/main/kotlin/app/aaps/core/objects/extensions/TherapyEventExtension.kt +++ b/core/objects/src/commonMain/kotlin/app/aaps/core/objects/extensions/TherapyEventExtension.kt @@ -1,5 +1,6 @@ package app.aaps.core.objects.extensions +import kotlin.time.Clock import app.aaps.core.data.model.GlucoseUnit import app.aaps.core.data.model.IDs import app.aaps.core.data.model.TE @@ -7,7 +8,7 @@ import app.aaps.core.data.pump.defs.PumpType fun TE.Companion.asAnnouncement(error: String, pumpId: Long? = null, pumpType: PumpType? = null, pumpSerial: String? = null): TE = TE( - timestamp = System.currentTimeMillis(), + timestamp = Clock.System.now().toEpochMilliseconds(), type = TE.Type.ANNOUNCEMENT, duration = 0, note = error, enteredBy = "AAPS", @@ -23,7 +24,7 @@ fun TE.Companion.asAnnouncement(error: String, pumpId: Long? = null, pumpType: P fun TE.Companion.asSettingsExport(error: String, pumpId: Long? = null, pumpType: PumpType? = null, pumpSerial: String? = null): TE = TE( - timestamp = System.currentTimeMillis(), + timestamp = Clock.System.now().toEpochMilliseconds(), type = TE.Type.SETTINGS_EXPORT, duration = 0, note = error, enteredBy = "AAPS", diff --git a/core/objects/src/main/kotlin/app/aaps/core/objects/profile/ProfileSealed.kt b/core/objects/src/commonMain/kotlin/app/aaps/core/objects/profile/ProfileSealed.kt similarity index 78% rename from core/objects/src/main/kotlin/app/aaps/core/objects/profile/ProfileSealed.kt rename to core/objects/src/commonMain/kotlin/app/aaps/core/objects/profile/ProfileSealed.kt index 8877df668589..60603c942bea 100644 --- a/core/objects/src/main/kotlin/app/aaps/core/objects/profile/ProfileSealed.kt +++ b/core/objects/src/commonMain/kotlin/app/aaps/core/objects/profile/ProfileSealed.kt @@ -1,5 +1,6 @@ package app.aaps.core.objects.profile +import app.aaps.core.interfaces.InterfacesStrings import app.aaps.core.data.configuration.Constants import app.aaps.core.data.format.NumberFormat import app.aaps.core.data.model.GlucoseUnit @@ -8,6 +9,7 @@ import app.aaps.core.data.model.IDs import app.aaps.core.data.model.data.Block import app.aaps.core.data.model.data.TargetBlock import app.aaps.core.data.time.T +import app.aaps.core.data.time.systemUtcOffsetAt import app.aaps.core.interfaces.aps.APS import app.aaps.core.interfaces.configuration.Config import app.aaps.core.interfaces.notifications.NotificationId @@ -20,21 +22,24 @@ import app.aaps.core.interfaces.profile.Profile.ProfileValue import app.aaps.core.interfaces.profile.PureProfile import app.aaps.core.interfaces.pump.Pump import app.aaps.core.interfaces.pump.PumpProfile -import app.aaps.core.interfaces.resources.ResourceHelper +import app.aaps.core.interfaces.resources.TextResolver import app.aaps.core.interfaces.utils.DateUtil import app.aaps.core.interfaces.utils.HardLimits +import app.aaps.core.keys.interfaces.TextRef.Companion.withArgs import app.aaps.core.objects.extensions.blockValueBySeconds import app.aaps.core.objects.extensions.highTargetBlockValueBySeconds import app.aaps.core.objects.extensions.lowTargetBlockValueBySeconds import app.aaps.core.objects.extensions.shiftBlock import app.aaps.core.objects.extensions.shiftTargetBlock import app.aaps.core.objects.extensions.targetBlockValueBySeconds -import app.aaps.core.objects.extensions.toJson -import app.aaps.core.ui.R +import app.aaps.core.objects.extensions.toJsonObject import app.aaps.core.utils.MidnightUtils -import org.json.JSONArray -import org.json.JSONObject -import java.util.TimeZone +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.buildJsonArray +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put +import kotlin.time.Clock sealed class ProfileSealed( val id: Long, @@ -121,7 +126,7 @@ sealed class ProfileSealed( null, 0, 100, - value.timeZone.rawOffset.toLong(), + value.utcOffset, activePlugin?.activeAPS ) { @@ -148,14 +153,14 @@ sealed class ProfileSealed( null, 0, 100, - value.timeZone.rawOffset.toLong(), + value.utcOffset, null ), PumpProfile { override val iCfg = null } - override fun isValid(from: String, pump: Pump, config: Config, rh: ResourceHelper, notificationManager: NotificationManager, hardLimits: HardLimits, sendNotifications: Boolean): Profile.ValidityCheck { + override fun isValid(from: String, pump: Pump, config: Config, rh: TextResolver, notificationManager: NotificationManager, hardLimits: HardLimits, sendNotifications: Boolean): Profile.ValidityCheck { // Full validity = semantic (pump-independent) AND pump compatibility. Activating a profile // on the pump requires both; editing, local storage and Nightscout sync only require // [validateSemantic] (a profile that's merely incompatible with the *current* pump is still @@ -173,13 +178,13 @@ sealed class ProfileSealed( * the gate for editing, local storage and Nightscout sync — independent of the active pump, so * switching pumps never silently blocks profile sync. */ - fun validateSemantic(rh: ResourceHelper, hardLimits: HardLimits): Profile.ValidityCheck { + fun validateSemantic(rh: TextResolver, hardLimits: HardLimits): Profile.ValidityCheck { val validityCheck = Profile.ValidityCheck() for (basal in basalBlocks) { val basalAmount = basal.amount * percentage / 100.0 if (basalAmount !in 0.01..hardLimits.maxBasal()) { validityCheck.isValid = false - validityCheck.reasons.add(rh.gs(R.string.value_out_of_hard_limits, rh.gs(R.string.basal_value), basalAmount)) + validityCheck.reasons.add(rh.gs(InterfacesStrings.value_out_of_hard_limits, rh.gs(InterfacesStrings.basal_value), basalAmount)) break } } @@ -187,7 +192,7 @@ sealed class ProfileSealed( // Todo, add check for peak and concentration, (or delegate iCfg validity check to insulinPlugin which will have this function) if (it.dia !in hardLimits.diaRange()) { validityCheck.isValid = false - validityCheck.reasons.add(rh.gs(R.string.value_out_of_hard_limits, rh.gs(R.string.profile_dia), it.dia)) + validityCheck.reasons.add(rh.gs(InterfacesStrings.value_out_of_hard_limits, rh.gs(InterfacesStrings.profile_dia), it.dia)) } } for (ic in icBlocks) @@ -195,8 +200,8 @@ sealed class ProfileSealed( validityCheck.isValid = false validityCheck.reasons.add( rh.gs( - R.string.value_out_of_hard_limits, - rh.gs(R.string.profile_carbs_ratio_value), + InterfacesStrings.value_out_of_hard_limits, + rh.gs(InterfacesStrings.profile_carbs_ratio_value), ic.amount * 100.0 / percentage ) ) @@ -207,8 +212,8 @@ sealed class ProfileSealed( validityCheck.isValid = false validityCheck.reasons.add( rh.gs( - R.string.value_out_of_hard_limits, - rh.gs(R.string.profile_sensitivity_value), + InterfacesStrings.value_out_of_hard_limits, + rh.gs(InterfacesStrings.profile_sensitivity_value), isf.amount * 100.0 / percentage ) ) @@ -217,12 +222,12 @@ sealed class ProfileSealed( for (target in targetBlocks) { if (toMgdl(target.lowTarget, units) !in HardLimits.LIMIT_MIN_BG) { validityCheck.isValid = false - validityCheck.reasons.add(rh.gs(R.string.value_out_of_hard_limits, rh.gs(R.string.profile_low_target), target.lowTarget)) + validityCheck.reasons.add(rh.gs(InterfacesStrings.value_out_of_hard_limits, rh.gs(InterfacesStrings.profile_low_target), target.lowTarget)) break } if (toMgdl(target.highTarget, units) !in HardLimits.LIMIT_MAX_BG) { validityCheck.isValid = false - validityCheck.reasons.add(rh.gs(R.string.value_out_of_hard_limits, rh.gs(R.string.profile_high_target), target.highTarget)) + validityCheck.reasons.add(rh.gs(InterfacesStrings.value_out_of_hard_limits, rh.gs(InterfacesStrings.profile_high_target), target.highTarget)) break } } @@ -235,8 +240,15 @@ sealed class ProfileSealed( * pump's time granularity (30-min vs full-hour). This gates profile *activation* and drives the * non-blocking "won't run on this pump" warning shown while editing/viewing — it never blocks * editing, storage or sync. + * + * Pure: it reports, it does not repair. It used to also clamp `basal.amount` to the pump limit in + * place, which made a "validity check" quietly rewrite the caller's profile. No caller ever used + * the clamped value - both build a throwaway [Pure] first and read only [Profile.ValidityCheck] - + * and the clamp `break`s after the first offending block, so it could not have produced a + * consistently corrected profile anyway. Removing it is what let [app.aaps.core.data.model.data.Block] + * become immutable. */ - fun validatePump(from: String, pump: Pump, config: Config, rh: ResourceHelper, notificationManager: NotificationManager, sendNotifications: Boolean): Profile.ValidityCheck { + fun validatePump(from: String, pump: Pump, config: Config, rh: TextResolver, notificationManager: NotificationManager, sendNotifications: Boolean): Profile.ValidityCheck { val validityCheck = Profile.ValidityCheck() val description = pump.pumpDescription for (basal in basalBlocks) { @@ -246,39 +258,37 @@ sealed class ProfileSealed( val duration: Long = basal.duration if (duration % 3600000 != 0L) { if (sendNotifications && config.APS) { - notificationManager.post(NotificationId.BASAL_PROFILE_NOT_ALIGNED_TO_HOURS, R.string.basalprofilenotaligned, from) + notificationManager.post(NotificationId.BASAL_PROFILE_NOT_ALIGNED_TO_HOURS, InterfacesStrings.basalprofilenotaligned.withArgs(from)) } validityCheck.isValid = false validityCheck.reasons.add( - rh.gs(R.string.basalprofilenotaligned, from) + rh.gs(InterfacesStrings.basalprofilenotaligned, from) ) break } } // Check for minimal basal value if (basalAmount < description.basalMinimumRate) { - basal.amount = description.basalMinimumRate if (sendNotifications) sendBelowMinimumNotification(from, notificationManager, rh) validityCheck.isValid = false - validityCheck.reasons.add(rh.gs(R.string.minimalbasalvaluereplaced, from)) + validityCheck.reasons.add(rh.gs(InterfacesStrings.minimalbasalvaluereplaced, from)) break } else if (basalAmount > description.basalMaximumRate) { - basal.amount = description.basalMaximumRate if (sendNotifications) sendAboveMaximumNotification(from, notificationManager, rh) validityCheck.isValid = false - validityCheck.reasons.add(rh.gs(R.string.maximumbasalvaluereplaced, from)) + validityCheck.reasons.add(rh.gs(InterfacesStrings.maximumbasalvaluereplaced, from)) break } } return validityCheck } - protected open fun sendBelowMinimumNotification(from: String, notificationManager: NotificationManager, rh: ResourceHelper) { - notificationManager.post(NotificationId.MINIMAL_BASAL_VALUE_REPLACED, R.string.minimalbasalvaluereplaced, from) + protected open fun sendBelowMinimumNotification(from: String, notificationManager: NotificationManager, rh: TextResolver) { + notificationManager.post(NotificationId.MINIMAL_BASAL_VALUE_REPLACED, InterfacesStrings.minimalbasalvaluereplaced.withArgs(from)) } - protected open fun sendAboveMaximumNotification(from: String, notificationManager: NotificationManager, rh: ResourceHelper) { - notificationManager.post(NotificationId.MAXIMUM_BASAL_VALUE_REPLACED, R.string.maximumbasalvaluereplaced, from) + protected open fun sendAboveMaximumNotification(from: String, notificationManager: NotificationManager, rh: TextResolver) { + notificationManager.post(NotificationId.MAXIMUM_BASAL_VALUE_REPLACED, InterfacesStrings.maximumbasalvaluereplaced.withArgs(from)) } override val units: GlucoseUnit @@ -354,92 +364,63 @@ sealed class ProfileSealed( private fun getTargetHighTimeFromMidnight(timeAsSeconds: Int): Double = targetBlocks.highTargetBlockValueBySeconds(timeAsSeconds, timeshift) override fun getTargetHighMgdlTimeFromMidnight(timeAsSeconds: Int): Double = toMgdl(targetBlocks.highTargetBlockValueBySeconds(timeAsSeconds, timeshift), units) - override fun getIcList(rh: ResourceHelper, dateUtil: DateUtil): String = - getValuesList(icBlocks, 100.0 / percentage, NumberFormat.DECIMAL_1, rh.gs(R.string.profile_carbs_per_unit), dateUtil) + override fun getIcList(rh: TextResolver, dateUtil: DateUtil): String = + getValuesList(icBlocks, 100.0 / percentage, NumberFormat.DECIMAL_1, rh.gs(InterfacesStrings.profile_carbs_per_unit), dateUtil) - override fun getIsfList(rh: ResourceHelper, dateUtil: DateUtil): String = - getValuesList(isfBlocks, 100.0 / percentage, NumberFormat.DECIMAL_1, rh.gs(if (units == GlucoseUnit.MGDL) R.string.profile_isf_units_mgdl else R.string.profile_isf_units_mmol), dateUtil) + override fun getIsfList(rh: TextResolver, dateUtil: DateUtil): String = + getValuesList(isfBlocks, 100.0 / percentage, NumberFormat.DECIMAL_1, rh.gs(if (units == GlucoseUnit.MGDL) InterfacesStrings.profile_isf_units_mgdl else InterfacesStrings.profile_isf_units_mmol), dateUtil) - override fun getBasalList(rh: ResourceHelper, dateUtil: DateUtil): String = - getValuesList(basalBlocks, percentage / 100.0, NumberFormat.DECIMAL_2, rh.gs(R.string.profile_ins_units_per_hour), dateUtil) + override fun getBasalList(rh: TextResolver, dateUtil: DateUtil): String = + getValuesList(basalBlocks, percentage / 100.0, NumberFormat.DECIMAL_2, rh.gs(InterfacesStrings.profile_ins_units_per_hour), dateUtil) - override fun getTargetList(rh: ResourceHelper, dateUtil: DateUtil): String = getTargetValuesList(targetBlocks, NumberFormat.DECIMAL_1, units.displayLabel, dateUtil) + override fun getTargetList(rh: TextResolver, dateUtil: DateUtil): String = getTargetValuesList(targetBlocks, NumberFormat.DECIMAL_1, units.displayLabel, dateUtil) override fun convertToNonCustomizedProfile(dateUtil: DateUtil): PureProfile = PureProfile( - jsonObject = toPureNsJson(dateUtil), basalBlocks = basalBlocks.shiftBlock(percentage / 100.0, timeshift), isfBlocks = isfBlocks.shiftBlock(100.0 / percentage, timeshift), icBlocks = icBlocks.shiftBlock(100.0 / percentage, timeshift), targetBlocks = targetBlocks.shiftTargetBlock(timeshift), glucoseUnit = units, iCfg = iCfg, - timeZone = TimeZone.getDefault() + utcOffset = systemUtcOffsetAt(dateUtil.now()) ) - override fun toPureNsJson(dateUtil: DateUtil): JSONObject { - val o = JSONObject() - o.put("units", units.asText) - iCfg?.let { o.put("iCfg", it.toJson()) } - o.put("timezone", dateUtil.timeZoneByOffset(utcOffset)) - // SENS - val sens = JSONArray() + /** + * One `[{time, timeAsSeconds, value}, …]` schedule. + * + * The five schedules below used to be four near-identical loops with the entry shape written out + * each time. They differ only in which blocks set the boundaries and which accessor supplies the + * value, so that is all this takes: [durations] gives the hour boundaries, [valueAt] answers for + * each one. Note the value comes from the accessor, not from the block - the accessors apply + * percentage and time shift, which is why a raw block amount would be wrong here. + */ + private fun schedule(durations: List, valueAt: (Int) -> Double): JsonArray = buildJsonArray { var elapsedHours = 0L - isfBlocks.forEach { - sens.put( - JSONObject() - .put("time", NumberFormat.INTEGER_2_DIGITS.format(elapsedHours) + ":00") - .put("timeAsSeconds", T.hours(elapsedHours).secs()) - .put("value", getIsfTimeFromMidnight(T.hours(elapsedHours).secs().toInt())) - ) - elapsedHours += T.msecs(it.duration).hours() - } - o.put("sens", sens) - val carbratio = JSONArray() - elapsedHours = 0L - icBlocks.forEach { - carbratio.put( - JSONObject() - .put("time", NumberFormat.INTEGER_2_DIGITS.format(elapsedHours) + ":00") - .put("timeAsSeconds", T.hours(elapsedHours).secs()) - .put("value", getIcTimeFromMidnight(T.hours(elapsedHours).secs().toInt())) - ) - elapsedHours += T.msecs(it.duration).hours() - } - o.put("carbratio", carbratio) - val basal = JSONArray() - elapsedHours = 0L - basalBlocks.forEach { - basal.put( - JSONObject() - .put("time", NumberFormat.INTEGER_2_DIGITS.format(elapsedHours) + ":00") - .put("timeAsSeconds", T.hours(elapsedHours).secs()) - .put("value", getBasalTimeFromMidnight(T.hours(elapsedHours).secs().toInt())) - ) - elapsedHours += T.msecs(it.duration).hours() - } - o.put("basal", basal) - val targetLow = JSONArray() - val targetHigh = JSONArray() - elapsedHours = 0L - targetBlocks.forEach { - targetLow.put( - JSONObject() - .put("time", NumberFormat.INTEGER_2_DIGITS.format(elapsedHours) + ":00") - .put("timeAsSeconds", T.hours(elapsedHours).secs()) - .put("value", getTargetLowTimeFromMidnight(T.hours(elapsedHours).secs().toInt())) - ) - targetHigh.put( - JSONObject() - .put("time", NumberFormat.INTEGER_2_DIGITS.format(elapsedHours) + ":00") - .put("timeAsSeconds", T.hours(elapsedHours).secs()) - .put("value", getTargetHighTimeFromMidnight(T.hours(elapsedHours).secs().toInt())) + durations.forEach { duration -> + val seconds = T.hours(elapsedHours).secs() + add( + buildJsonObject { + put("time", NumberFormat.INTEGER_2_DIGITS.format(elapsedHours) + ":00") + put("timeAsSeconds", seconds) + put("value", valueAt(seconds.toInt())) + } ) - elapsedHours += T.msecs(it.duration).hours() + elapsedHours += T.msecs(duration).hours() } - o.put("target_low", targetLow) - o.put("target_high", targetHigh) - return o + } + + override fun toPureNsJson(dateUtil: DateUtil): JsonObject = buildJsonObject { + put("units", units.asText) + iCfg?.let { put("iCfg", it.toJsonObject()) } + put("timezone", dateUtil.timeZoneByOffset(utcOffset)) + put("sens", schedule(isfBlocks.map { it.duration }, ::getIsfTimeFromMidnight)) + put("carbratio", schedule(icBlocks.map { it.duration }, ::getIcTimeFromMidnight)) + put("basal", schedule(basalBlocks.map { it.duration }, ::getBasalTimeFromMidnight)) + // Low and high share the same boundaries, so they walk the same durations. + val targetDurations = targetBlocks.map { it.duration } + put("target_low", schedule(targetDurations, ::getTargetLowTimeFromMidnight)) + put("target_high", schedule(targetDurations, ::getTargetHighTimeFromMidnight)) } override fun getMaxDailyBasal(): Double = basalBlocks.maxByOrNull { it.amount }?.amount ?: 0.0 @@ -505,13 +486,12 @@ sealed class ProfileSealed( if (this is EffectiveProfile) PP( PureProfile( - jsonObject = JSONObject(), basalBlocks = basalBlocks.shiftBlock(percentage / 100.0 / iCfg.concentration, timeshift), isfBlocks = isfBlocks.shiftBlock(100.0 / percentage * iCfg.concentration, timeshift), icBlocks = icBlocks.shiftBlock(100.0 / percentage * iCfg.concentration, timeshift), targetBlocks = targetBlocks.shiftTargetBlock(timeshift), glucoseUnit = units, - timeZone = TimeZone.getDefault() + utcOffset = systemUtcOffsetAt(Clock.System.now().toEpochMilliseconds()) ), null ) @@ -544,6 +524,7 @@ sealed class ProfileSealed( elapsedSec += T.msecs(it.duration).secs().toInt() } }.toString() + private fun toMgdl(value: Double, units: GlucoseUnit): Double = if (units == GlucoseUnit.MGDL) value else value * Constants.MMOLL_TO_MGDL } diff --git a/core/objects/src/main/kotlin/app/aaps/core/objects/runningMode/PumpCommandGate.kt b/core/objects/src/commonMain/kotlin/app/aaps/core/objects/runningMode/PumpCommandGate.kt similarity index 100% rename from core/objects/src/main/kotlin/app/aaps/core/objects/runningMode/PumpCommandGate.kt rename to core/objects/src/commonMain/kotlin/app/aaps/core/objects/runningMode/PumpCommandGate.kt diff --git a/core/objects/src/main/kotlin/app/aaps/core/objects/runningMode/RunningModeGuard.kt b/core/objects/src/commonMain/kotlin/app/aaps/core/objects/runningMode/RunningModeGuard.kt similarity index 76% rename from core/objects/src/main/kotlin/app/aaps/core/objects/runningMode/RunningModeGuard.kt rename to core/objects/src/commonMain/kotlin/app/aaps/core/objects/runningMode/RunningModeGuard.kt index d5c45dafacd1..796567638026 100644 --- a/core/objects/src/main/kotlin/app/aaps/core/objects/runningMode/RunningModeGuard.kt +++ b/core/objects/src/commonMain/kotlin/app/aaps/core/objects/runningMode/RunningModeGuard.kt @@ -1,12 +1,11 @@ package app.aaps.core.objects.runningMode +import app.aaps.core.interfaces.InterfacesStrings import app.aaps.core.interfaces.aps.Loop -import app.aaps.core.interfaces.resources.ResourceHelper +import app.aaps.core.interfaces.resources.TextResolver import app.aaps.core.interfaces.rx.bus.RxBus import app.aaps.core.interfaces.rx.events.EventShowSnackbar -import app.aaps.core.ui.R -import javax.inject.Inject -import javax.inject.Singleton +import app.aaps.core.keys.interfaces.TextRef /** * Pre-check helper for UI / sync / automation entry points that call CommandQueue. @@ -19,10 +18,9 @@ import javax.inject.Singleton * Entry points use this guard to decline the action quietly (snackbar, SMS reply, watch * response) before ever touching CommandQueue. */ -@Singleton -class RunningModeGuard @Inject constructor( +class RunningModeGuard( private val loop: Loop, - private val rh: ResourceHelper, + private val rh: TextResolver, private val rxBus: RxBus ) { @@ -36,7 +34,7 @@ class RunningModeGuard @Inject constructor( suspend fun rejectionMessage(kind: PumpCommandGate.CommandKind): String? { val mode = loop.runningMode() val decision = PumpCommandGate.check(mode, kind) - return (decision as? PumpCommandGate.Decision.Reject)?.let { rh.gs(it.reason.toStringRes()) } + return (decision as? PumpCommandGate.Decision.Reject)?.let { rh.gs(it.reason.toTextRef()) } } /** @@ -53,11 +51,11 @@ class RunningModeGuard @Inject constructor( return true } - private fun PumpCommandGate.Reason.toStringRes(): Int = when (this) { - PumpCommandGate.Reason.PUMP_DISCONNECTED -> R.string.pump_disconnected + private fun PumpCommandGate.Reason.toTextRef(): TextRef = when (this) { + PumpCommandGate.Reason.PUMP_DISCONNECTED -> InterfacesStrings.pump_disconnected PumpCommandGate.Reason.LOOP_SUSPENDED_DST, - PumpCommandGate.Reason.SUPER_BOLUS_ACTIVE -> R.string.loopsuspended + PumpCommandGate.Reason.SUPER_BOLUS_ACTIVE -> InterfacesStrings.loopsuspended - PumpCommandGate.Reason.PUMP_REPORTED_SUSPENDED -> R.string.pumpsuspended + PumpCommandGate.Reason.PUMP_REPORTED_SUSPENDED -> InterfacesStrings.pumpsuspended } } diff --git a/core/objects/src/main/kotlin/app/aaps/core/objects/wizard/BolusWizard.kt b/core/objects/src/commonMain/kotlin/app/aaps/core/objects/wizard/BolusWizard.kt similarity index 88% rename from core/objects/src/main/kotlin/app/aaps/core/objects/wizard/BolusWizard.kt rename to core/objects/src/commonMain/kotlin/app/aaps/core/objects/wizard/BolusWizard.kt index dd85a53520c1..46029b35aa9c 100644 --- a/core/objects/src/main/kotlin/app/aaps/core/objects/wizard/BolusWizard.kt +++ b/core/objects/src/commonMain/kotlin/app/aaps/core/objects/wizard/BolusWizard.kt @@ -1,5 +1,6 @@ package app.aaps.core.objects.wizard +import kotlin.time.Clock import app.aaps.core.data.format.NumberFormat import app.aaps.core.data.model.BCR import app.aaps.core.data.model.BolusWizardData @@ -20,7 +21,6 @@ import app.aaps.core.interfaces.bolus.WizardBolusExecutor import app.aaps.core.interfaces.configuration.Config import app.aaps.core.interfaces.constraints.ConstraintsChecker import app.aaps.core.interfaces.db.PersistenceLayer -import app.aaps.core.interfaces.di.ApplicationScope import app.aaps.core.interfaces.insulin.ConcentrationHelper import app.aaps.core.interfaces.iob.GlucoseStatusProvider import app.aaps.core.interfaces.iob.IobCobCalculator @@ -32,7 +32,8 @@ import app.aaps.core.interfaces.profile.Profile import app.aaps.core.interfaces.profile.ProfileFunction import app.aaps.core.interfaces.profile.ProfileUtil import app.aaps.core.interfaces.pump.DetailedBolusInfo -import app.aaps.core.interfaces.resources.ResourceHelper +import app.aaps.core.interfaces.InterfacesStrings +import app.aaps.core.interfaces.resources.TextResolver import app.aaps.core.interfaces.rx.bus.RxBus import app.aaps.core.interfaces.rx.events.EventRefreshOverview import app.aaps.core.interfaces.rx.weardata.EventData @@ -44,18 +45,15 @@ import app.aaps.core.objects.constraints.ConstraintObject import app.aaps.core.objects.extensions.round import app.aaps.core.objects.runningMode.PumpCommandGate import app.aaps.core.objects.runningMode.RunningModeGuard -import app.aaps.core.utils.JsonHelper import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch -import java.util.Calendar -import javax.inject.Inject import kotlin.math.abs import kotlin.math.max import kotlin.math.min -class BolusWizard @Inject constructor( +class BolusWizard( private val aapsLogger: AAPSLogger, - private val rh: ResourceHelper, + private val rh: TextResolver, private val rxBus: RxBus, private val preferences: Preferences, private val profileFunction: ProfileFunction, @@ -73,7 +71,7 @@ class BolusWizard @Inject constructor( private val runningModeGuard: RunningModeGuard, private val ch: ConcentrationHelper, private val wizardBolusExecutor: WizardBolusExecutor, - @ApplicationScope private val appScope: CoroutineScope + private val appScope: CoroutineScope ) { var timeStamp = dateUtil.now() @@ -253,7 +251,7 @@ class BolusWizard @Inject constructor( // Insulin from superbolus for 2h. Get basal rate now and after 1h if (useSuperBolus) { insulinFromSuperBolus = profile.getBasal() - var timeAfter1h = System.currentTimeMillis() + var timeAfter1h = Clock.System.now().toEpochMilliseconds() timeAfter1h += T.hours(1).msecs() insulinFromSuperBolus += profile.getBasal(timeAfter1h) } @@ -382,24 +380,24 @@ class BolusWizard @Inject constructor( line( ConfirmationRole.BOLUS, rh.gs( - app.aaps.core.ui.R.string.confirmation_line, - rh.gs(app.aaps.core.ui.R.string.bolus), - rh.gs(app.aaps.core.ui.R.string.format_insulin_units, insulinAfterConstraints) + pct + InterfacesStrings.confirmation_line, + rh.gs(InterfacesStrings.bolus), + rh.gs(InterfacesStrings.format_insulin_units, insulinAfterConstraints) + pct ) ) } if (carbs > 0 && !advisor) { val timeShift = when { - carbTime > 0 -> " (+" + rh.gs(app.aaps.core.ui.R.string.mins, carbTime) + ")" - carbTime < 0 -> " (" + rh.gs(app.aaps.core.ui.R.string.mins, carbTime) + ")" + carbTime > 0 -> " (+" + rh.gs(InterfacesStrings.mins, carbTime) + ")" + carbTime < 0 -> " (" + rh.gs(InterfacesStrings.mins, carbTime) + ")" else -> "" } line( ConfirmationRole.CARBS, rh.gs( - app.aaps.core.ui.R.string.confirmation_line, - rh.gs(app.aaps.core.ui.R.string.carbs), - rh.gs(app.aaps.core.ui.R.string.format_carbs, carbs) + timeShift + InterfacesStrings.confirmation_line, + rh.gs(InterfacesStrings.carbs), + rh.gs(InterfacesStrings.format_carbs, carbs) + timeShift ) ) } @@ -407,46 +405,46 @@ class BolusWizard @Inject constructor( line( ConfirmationRole.COB, rh.gs( - app.aaps.core.ui.R.string.confirmation_line, - rh.gs(app.aaps.core.ui.R.string.cobvsiob), + InterfacesStrings.confirmation_line, + rh.gs(InterfacesStrings.cobvsiob), rh.gs( - app.aaps.core.ui.R.string.formatsignedinsulinunits, + InterfacesStrings.formatsignedinsulinunits, -insulinFromBolusIOB - insulinFromBasalIOB + insulinFromCOB + insulinFromBG ) ) ) val absorptionRate = iobCobCalculator.ads.slowAbsorptionPercentage(60) if (absorptionRate > .25) { - line(ConfirmationRole.COB, rh.gs(app.aaps.core.ui.R.string.slowabsorptiondetected_plain, (absorptionRate * 100).toInt())) + line(ConfirmationRole.COB, rh.gs(InterfacesStrings.slowabsorptiondetected_plain, (absorptionRate * 100).toInt())) } } if (abs(insulinAfterConstraints - calculatedTotalInsulin) > ch.bolusStep(insulinAfterConstraints)) { - line(ConfirmationRole.WARNING, rh.gs(app.aaps.core.ui.R.string.bolus_constraint_applied_warn, calculatedTotalInsulin, insulinAfterConstraints)) + line(ConfirmationRole.WARNING, rh.gs(InterfacesStrings.bolus_constraint_applied_warn, calculatedTotalInsulin, insulinAfterConstraints)) } if ((config.AAPSCLIENT || forcedRecordOnly) && insulinAfterConstraints > 0) { - line(ConfirmationRole.WARNING, rh.gs(app.aaps.core.ui.R.string.bolus_recorded_only)) + line(ConfirmationRole.WARNING, rh.gs(InterfacesStrings.bolus_recorded_only)) } if (useAlarm && !advisor && carbs > 0 && carbTime > 0) { - line(ConfirmationRole.INFO, rh.gs(app.aaps.core.ui.R.string.alarminxmin, carbTime)) + line(ConfirmationRole.INFO, rh.gs(InterfacesStrings.alarminxmin, carbTime)) } if (advisor) { - line(ConfirmationRole.INFO, rh.gs(app.aaps.core.ui.R.string.advisoralarm)) + line(ConfirmationRole.INFO, rh.gs(InterfacesStrings.advisoralarm)) } if (quickWizardEntry != null) { - val eCarbsYesNo = JsonHelper.safeGetInt(quickWizardEntry.storage, "useEcarbs", QuickWizardEntry.NO) + val eCarbsYesNo = quickWizardEntry.useEcarbs() if (eCarbsYesNo == QuickWizardEntry.YES) { - val timeOffset = JsonHelper.safeGetInt(quickWizardEntry.storage, "time", 0) - val duration = JsonHelper.safeGetInt(quickWizardEntry.storage, "duration", 0) - val carbs2 = JsonHelper.safeGetInt(quickWizardEntry.storage, "carbs2", 0) + val timeOffset = quickWizardEntry.time() + val duration = quickWizardEntry.duration() + val carbs2 = quickWizardEntry.carbs2() if (carbs2 > 0) { - val ecarbsMessage = rh.gs(app.aaps.core.ui.R.string.format_carbs, carbs2) + "/" + duration + "h (+" + timeOffset + "min)" - line(ConfirmationRole.INFO, rh.gs(app.aaps.core.ui.R.string.confirmation_line, rh.gs(app.aaps.core.ui.R.string.uel_extended_carbs), ecarbsMessage)) + val ecarbsMessage = rh.gs(InterfacesStrings.format_carbs, carbs2) + "/" + duration + "h (+" + timeOffset + "min)" + line(ConfirmationRole.INFO, rh.gs(InterfacesStrings.confirmation_line, rh.gs(InterfacesStrings.uel_extended_carbs), ecarbsMessage)) } } } if (eCarbsGrams > 0) { - line(ConfirmationRole.INFO, rh.gs(app.aaps.core.ui.R.string.wizard_ecarbs, eCarbsGrams, eCarbsDurationHours, eCarbsDelayMinutes)) + line(ConfirmationRole.INFO, rh.gs(InterfacesStrings.wizard_ecarbs, eCarbsGrams, eCarbsDurationHours, eCarbsDelayMinutes)) } } @@ -587,7 +585,7 @@ class BolusWizard @Inject constructor( bolus = detailedBolusInfo.createBolus(recordIcfg), action = action, source = source, - note = rh.gs(app.aaps.core.ui.R.string.record) + if (notes.isNotEmpty()) ": $notes" else "" + note = rh.gs(InterfacesStrings.record) + if (notes.isNotEmpty()) ": $notes" else "" ) } if (carbs > 0) { @@ -595,7 +593,7 @@ class BolusWizard @Inject constructor( carbs = detailedBolusInfo.createCarbs(), action = action, source = source, - note = notes.ifEmpty { rh.gs(app.aaps.core.ui.R.string.record) } + note = notes.ifEmpty { rh.gs(InterfacesStrings.record) } ) } persistenceLayer.insertOrUpdateBolusCalculatorResult(bolusCalculatorResult) @@ -632,7 +630,7 @@ class BolusWizard @Inject constructor( // delayMinutes is already the total delay from now — the caller folds the meal carbTime into it. // Do NOT add carbTime again here or the eCarbs record lands carbTime minutes too late. val totalDelayMinutes = delayMinutes - val eventTime = Calendar.getInstance().timeInMillis + (totalDelayMinutes * 60000L) + val eventTime = Clock.System.now().toEpochMilliseconds() + (totalDelayMinutes * 60000L) if (forcedRecordOnly) { uel.log( action = Action.EXTENDED_CARBS, @@ -656,7 +654,7 @@ class BolusWizard @Inject constructor( carbs = detailedBolusInfo.createCarbs(), action = Action.EXTENDED_CARBS, source = Sources.WizardDialog, - note = notes.ifEmpty { rh.gs(app.aaps.core.ui.R.string.record) } + note = notes.ifEmpty { rh.gs(InterfacesStrings.record) } ) } } else { @@ -668,17 +666,17 @@ class BolusWizard @Inject constructor( } private fun scheduleECarbsFromQuickWizardCompose(quickWizardEntry: QuickWizardEntry, onError: (String) -> Unit, forcedRecordOnly: Boolean = false) { - val eCarbsYesNo = JsonHelper.safeGetInt(quickWizardEntry.storage, "useEcarbs", QuickWizardEntry.NO) + val eCarbsYesNo = quickWizardEntry.useEcarbs() if (eCarbsYesNo == QuickWizardEntry.YES) { - val timeOffset = JsonHelper.safeGetInt(quickWizardEntry.storage, "time", 0) - val duration = JsonHelper.safeGetInt(quickWizardEntry.storage, "duration", 0) - val carbs2 = JsonHelper.safeGetInt(quickWizardEntry.storage, "carbs2", 0) + val timeOffset = quickWizardEntry.time() + val duration = quickWizardEntry.duration() + val carbs2 = quickWizardEntry.carbs2() - val currentTime = Calendar.getInstance().timeInMillis + val currentTime = Clock.System.now().toEpochMilliseconds() val eventTime: Long = currentTime + (timeOffset * 60000) if (carbs2 > 0) { - val buttonText = quickWizardEntry.storage.get("buttonText").toString() + val buttonText = quickWizardEntry.buttonText() if (forcedRecordOnly) { uel.log( action = Action.EXTENDED_CARBS, diff --git a/core/objects/src/main/kotlin/app/aaps/core/objects/wizard/QuickWizard.kt b/core/objects/src/commonMain/kotlin/app/aaps/core/objects/wizard/QuickWizard.kt similarity index 54% rename from core/objects/src/main/kotlin/app/aaps/core/objects/wizard/QuickWizard.kt rename to core/objects/src/commonMain/kotlin/app/aaps/core/objects/wizard/QuickWizard.kt index 25f172d04d79..bb0e679f8397 100644 --- a/core/objects/src/main/kotlin/app/aaps/core/objects/wizard/QuickWizard.kt +++ b/core/objects/src/commonMain/kotlin/app/aaps/core/objects/wizard/QuickWizard.kt @@ -12,20 +12,20 @@ import kotlinx.coroutines.flow.drop import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.update -import org.json.JSONArray -import org.json.JSONObject -import java.util.UUID -import javax.inject.Inject -import javax.inject.Provider -import javax.inject.Singleton - -@Singleton -class QuickWizard @Inject constructor( +import kotlin.concurrent.Volatile +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.buildJsonArray + +class QuickWizard( private val preferences: Preferences, - private val quickWizardEntryProvider: Provider + // A plain factory, not a javax.inject.Provider: that type is JVM only and would pin this class + // to one platform. Dagger still supplies it, as a method reference, from the Android side. + private val quickWizardEntryProvider: () -> QuickWizardEntry ) { - @Volatile private var storage = JSONArray() + @Volatile private var storage: List = emptyList() private val _changes = MutableStateFlow(0) @@ -41,50 +41,64 @@ class QuickWizard @Inject constructor( private val scope = CoroutineScope(Dispatchers.Default + SupervisorJob()) init { - storage = JSONArray(preferences.get(StringNonKey.QuickWizard)) + storage = parse(preferences.get(StringNonKey.QuickWizard)) setGuidsForOldEntries() // Keep the cache in lockstep with the persisted key. Covers edits from another screen and // master→client sync (applied via putRemote, which writes the key without going through save()). preferences.observe(StringNonKey.QuickWizard) .drop(1) // initial value already loaded above .onEach { - storage = JSONArray(it) + storage = parse(it) _changes.update { v -> v + 1 } } .launchIn(scope) } + /** + * Reads the stored list, skipping anything unreadable rather than losing the whole list. + * + * An element that is not an object used to be a `ClassCastException` at construction time, which + * meant a single damaged entry stopped the app from starting. + */ + private fun parse(raw: String): List = + runCatching { + (Json.parseToJsonElement(raw) as JsonArray).mapNotNull { element -> + (element as? JsonObject)?.let { QuickWizardEntryData.fromJsonObject(it) } + } + }.getOrDefault(emptyList()) + private fun setGuidsForOldEntries() { // for migration purposes; guid is a new required property - for (i in 0 until storage.length()) { - val entry = quickWizardEntryProvider.get().from(storage.get(i) as JSONObject, i) - if (entry.guid() == "") { - val guid = UUID.randomUUID().toString() - entry.storage.put("guid", guid) - } - } + val migrated = storage.map { if (it.guid == "") it.copy(guid = QuickWizardEntry.randomGuid()) else it } + if (migrated == storage) return + storage = migrated + // Persist immediately. While the entries were live JSONObjects this wrote through into the + // stored array and rode along on whatever save happened next; if none did, a DIFFERENT guid + // was generated on every app start, so nothing could resolve a legacy entry by guid across + // restarts. Saving here makes the migration stick the first time it runs. + save() } fun getActive(): QuickWizardEntry? { - for (i in 0 until storage.length()) { - val entry = quickWizardEntryProvider.get().from(storage.get(i) as JSONObject, i) + for (i in storage.indices) { + val entry = quickWizardEntryProvider().from(storage[i], i) if (entry.isActive()) return entry } return null } - fun setData(newData: JSONArray) { + fun setData(newData: List) { storage = newData } fun save() { - preferences.put(StringNonKey.QuickWizard, storage.toString()) + preferences.put(StringNonKey.QuickWizard, buildJsonArray { storage.forEach { add(it.toJsonObject()) } }.toString()) } - fun size(): Int = storage.length() + fun size(): Int = storage.size operator fun get(position: Int): QuickWizardEntry = - quickWizardEntryProvider.get().from(storage.get(position) as JSONObject, position) + quickWizardEntryProvider().from(storage[position], position) fun list(): ArrayList = ArrayList().also { @@ -92,8 +106,8 @@ class QuickWizard @Inject constructor( } fun get(guid: String): QuickWizardEntry? { - for (i in 0 until storage.length()) { - val entry = quickWizardEntryProvider.get().from(storage.get(i) as JSONObject, i) + for (i in storage.indices) { + val entry = quickWizardEntryProvider().from(storage[i], i) if (entry.guid() == guid) { return entry } @@ -118,30 +132,34 @@ class QuickWizard @Inject constructor( * is not a permutation of the current indices (the list changed underneath the caller). */ fun reorder(order: List): Boolean { - val size = storage.length() + val size = storage.size if (order.size != size || order.toSet() != (0 until size).toSet()) return false if (order.withIndex().all { (newIndex, oldIndex) -> newIndex == oldIndex }) return true - val reordered = JSONArray() - order.forEach { reordered.put(storage.get(it)) } - storage = reordered + storage = order.map { storage[it] } save() return true } fun newEmptyItem(): QuickWizardEntry { - return quickWizardEntryProvider.get() + return quickWizardEntryProvider() } fun addOrUpdate(newItem: QuickWizardEntry) { - if (newItem.position == -1) - storage.put(newItem.storage) - else - storage.put(newItem.position, newItem.storage) + // A position past the end appends. The old JSONArray.put(index, value) padded the list with + // nulls up to the index instead, and a padded slot then broke the readers at the next app + // start. A stale position is reachable: an entry can be read at index 2 and a shorter list + // arrive over sync before it is written back. + storage = + if (newItem.position < 0 || newItem.position >= storage.size) storage + newItem.data + else storage.toMutableList().also { it[newItem.position] = newItem.data } save() } fun remove(position: Int) { - storage.remove(position) + // Without the guard an out of range index changed nothing but still saved, pushing an + // unchanged list through the sync channel. + if (position < 0 || position >= storage.size) return + storage = storage.toMutableList().also { it.removeAt(position) } save() } diff --git a/core/objects/src/main/kotlin/app/aaps/core/objects/wizard/QuickWizardEntry.kt b/core/objects/src/commonMain/kotlin/app/aaps/core/objects/wizard/QuickWizardEntry.kt similarity index 52% rename from core/objects/src/main/kotlin/app/aaps/core/objects/wizard/QuickWizardEntry.kt rename to core/objects/src/commonMain/kotlin/app/aaps/core/objects/wizard/QuickWizardEntry.kt index 528779ea5fc6..954bbc7333bc 100644 --- a/core/objects/src/main/kotlin/app/aaps/core/objects/wizard/QuickWizardEntry.kt +++ b/core/objects/src/commonMain/kotlin/app/aaps/core/objects/wizard/QuickWizardEntry.kt @@ -13,19 +13,11 @@ import app.aaps.core.interfaces.profile.Profile import app.aaps.core.interfaces.profile.ProfileFunction import app.aaps.core.interfaces.utils.DateUtil import app.aaps.core.keys.BooleanKey -import app.aaps.core.keys.IntKey import app.aaps.core.keys.interfaces.Preferences import app.aaps.core.objects.extensions.valueToUnits -import app.aaps.core.utils.JsonHelper.safeGetDouble -import app.aaps.core.utils.JsonHelper.safeGetInt -import app.aaps.core.utils.JsonHelper.safeGetLong -import app.aaps.core.utils.JsonHelper.safeGetString import app.aaps.core.utils.MidnightUtils -import org.json.JSONException -import org.json.JSONObject -import java.util.UUID -import javax.inject.Inject -import javax.inject.Provider +import kotlin.uuid.ExperimentalUuidApi +import kotlin.uuid.Uuid enum class QuickWizardMode(val value: Int) { WIZARD(0), @@ -38,7 +30,7 @@ enum class QuickWizardMode(val value: Int) { } } -class QuickWizardEntry @Inject constructor( +class QuickWizardEntry( aapsLogger: AAPSLogger, private val preferences: Preferences, private val profileFunction: ProfileFunction, @@ -47,8 +39,9 @@ class QuickWizardEntry @Inject constructor( private val persistenceLayer: PersistenceLayer, private val dateUtil: DateUtil, private val glucoseStatusProvider: GlucoseStatusProvider, - private val bolusWizardProvider: Provider, - private val quickWizardProvider: Provider + // Plain factories rather than javax.inject.Provider - see QuickWizard for why. + private val bolusWizardProvider: () -> BolusWizard, + private val quickWizardProvider: () -> QuickWizard ) { // for mock @@ -61,64 +54,35 @@ class QuickWizardEntry @Inject constructor( var time = Time() - lateinit var storage: JSONObject + /** + * The preset itself, as plain data. + * + * This used to be a live `JSONObject` that was also the element inside [QuickWizard]'s array, so + * writing a field here changed the stored list with no explicit step. That aliasing is gone: + * anything that changes [data] must now hand the entry back to [QuickWizard] to be written. + */ + var data: QuickWizardEntryData = QuickWizardEntryData(guid = randomGuid(), validTo = 86340) var position: Int = -1 companion object { - const val YES = 0 - const val NO = 1 - const val POSITIVE_ONLY = 2 - const val NEGATIVE_ONLY = 3 - const val DEVICE_ALL = 0 - const val DEVICE_PHONE = 1 - const val DEVICE_WATCH = 2 - const val DEFAULT = 0 - const val CUSTOM = 1 - const val COOLDOWN_MILLIS = 1_800_000L // 1/2 hour - } - - init { - val guid = UUID.randomUUID().toString() - val emptyData = """{ - "guid": "$guid", - "buttonText": "", - "carbs": 0, - "validFrom": 0, - "validTo": 86340, - "device": "all", - "usePercentage": "default", - "percentage": 100 - }""".trimMargin() - try { - storage = JSONObject(emptyData) - } catch (e: JSONException) { - aapsLogger.error("Unhandled exception", e) - } + // Defined once in QuickWizardEntryData, which is common code. Re-exposed here so the existing + // `QuickWizardEntry.YES` style call sites do not have to change. + const val YES = QuickWizardEntryData.YES + const val NO = QuickWizardEntryData.NO + const val POSITIVE_ONLY = QuickWizardEntryData.POSITIVE_ONLY + const val NEGATIVE_ONLY = QuickWizardEntryData.NEGATIVE_ONLY + const val DEVICE_ALL = QuickWizardEntryData.DEVICE_ALL + const val DEVICE_PHONE = QuickWizardEntryData.DEVICE_PHONE + const val DEVICE_WATCH = QuickWizardEntryData.DEVICE_WATCH + const val COOLDOWN_MILLIS = QuickWizardEntryData.COOLDOWN_MILLIS + + @OptIn(ExperimentalUuidApi::class) + fun randomGuid(): String = Uuid.random().toString() } - /* - { - guid: string, - device: string, // (phone, watch, all) - buttonText: "Meal", - carbs: 36, - validFrom: 8 * 60 * 60, // seconds from midnight - validTo: 9 * 60 * 60, // seconds from midnight - useBG: 0, - useCOB: 0, - useBolusIOB: 0, - useBasalIOB: 0, - useTrend: 0, - useSuperBolus: 0, - useTemptarget: 0 - usePercentage: string, // default, custom - percentage: int, - } - */ - fun from(entry: JSONObject, position: Int): QuickWizardEntry { - // TODO set guid if missing for migration - storage = entry + fun from(entry: QuickWizardEntryData, position: Int): QuickWizardEntry { + data = entry this.position = position return this } @@ -171,8 +135,8 @@ class QuickWizardEntry @Inject constructor( } else if (useTrend() == NEGATIVE_ONLY && glucoseStatus != null && glucoseStatus.shortAvgDelta < 0) { trend = true } - val percentage = if (usePercentage() == DEFAULT) preferences.get(IntKey.OverviewBolusPercentage) else percentage() - return bolusWizardProvider.get().doCalc( + val percentage = percentage() + return bolusWizardProvider().doCalc( profile, profileName, tempTarget, @@ -196,58 +160,58 @@ class QuickWizardEntry @Inject constructor( ) //tbc, ok if only quickwizard, but if other sources elsewhere use Sources.QuickWizard } - fun mode(): QuickWizardMode = QuickWizardMode.fromValue(safeGetInt(storage, "mode", 0)) + fun mode(): QuickWizardMode = QuickWizardMode.fromValue(data.mode) - fun insulin(): Double = safeGetDouble(storage, "insulin", 0.0) + fun insulin(): Double = data.insulin - fun guid(): String = safeGetString(storage, "guid", "") + fun guid(): String = data.guid - fun device(): Int = safeGetInt(storage, "device", DEVICE_ALL) + fun device(): Int = data.device fun forDevice(device: Int) = device() == device || device() == DEVICE_ALL - fun buttonText(): String = safeGetString(storage, "buttonText", "") - - fun carbs(): Int = safeGetInt(storage, "carbs") + fun buttonText(): String = data.buttonText - fun validFrom(): Int = safeGetInt(storage, "validFrom") + fun carbs(): Int = data.carbs - fun validTo(): Int = safeGetInt(storage, "validTo") + fun validFrom(): Int = data.validFrom - fun useBG(): Int = safeGetInt(storage, "useBG", YES) + fun validTo(): Int = data.validTo - fun useCOB(): Int = safeGetInt(storage, "useCOB", NO) + fun useBG(): Int = data.useBG - fun useIOB(): Int = safeGetInt(storage, "useIOB", YES) + fun useCOB(): Int = data.useCOB - fun usePositiveIOBOnly(): Int = safeGetInt(storage, "usePositiveIOBOnly", NO) + fun useIOB(): Int = data.useIOB - fun useTrend(): Int = safeGetInt(storage, "useTrend", NO) + fun usePositiveIOBOnly(): Int = data.usePositiveIOBOnly - fun useSuperBolus(): Int = safeGetInt(storage, "useSuperBolus", NO) + fun useTrend(): Int = data.useTrend - fun useTempTarget(): Int = safeGetInt(storage, "useTempTarget", NO) + fun useSuperBolus(): Int = data.useSuperBolus - fun usePercentage(): Int = safeGetInt(storage, "usePercentage", CUSTOM) + fun useTempTarget(): Int = data.useTempTarget - fun percentage(): Int = safeGetInt(storage, "percentage", 100) + fun percentage(): Int = data.percentage - fun useEcarbs(): Int = safeGetInt(storage, "useEcarbs", NO) + fun useEcarbs(): Int = data.useEcarbs - fun carbs2(): Int = safeGetInt(storage, "carbs2") + fun carbs2(): Int = data.carbs2 - fun time(): Int = safeGetInt(storage, "time") + fun time(): Int = data.time - fun duration(): Int = safeGetInt(storage, "duration") + fun duration(): Int = data.duration - fun carbTime(): Int = safeGetInt(storage, "carbTime") + fun carbTime(): Int = data.carbTime - fun useAlarm(): Int = safeGetInt(storage, "useAlarm", NO) + fun useAlarm(): Int = data.useAlarm - fun lastUsed(): Long = safeGetLong(storage, "lastUsed") + fun lastUsed(): Long = data.lastUsed fun markAsUsed() { - storage.put("lastUsed", dateUtil.now()) - quickWizardProvider.get().save() + data = data.copy(lastUsed = dateUtil.now()) + // The write-back is explicit now. It used to work only because `storage` WAS the element + // inside QuickWizard's array, so a plain save() picked the new value up on its own. + quickWizardProvider().addOrUpdate(this) } } diff --git a/core/objects/src/commonMain/kotlin/app/aaps/core/objects/wizard/QuickWizardEntryData.kt b/core/objects/src/commonMain/kotlin/app/aaps/core/objects/wizard/QuickWizardEntryData.kt new file mode 100644 index 000000000000..e4e4c53aae7b --- /dev/null +++ b/core/objects/src/commonMain/kotlin/app/aaps/core/objects/wizard/QuickWizardEntryData.kt @@ -0,0 +1,133 @@ +package app.aaps.core.objects.wizard + +import app.aaps.core.utils.lenientDouble +import app.aaps.core.utils.lenientInt +import app.aaps.core.utils.lenientLong +import app.aaps.core.utils.lenientString +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put + +/** + * One quick wizard preset, as plain data. + * + * This replaces the live `JSONObject` the entry used to carry as its state. JSON is now only the + * storage format at the preference edge - [fromJsonObject] on the way in, [toJsonObject] on the way + * out - and nothing in between passes a document around. + * + * Every default below reproduces what the old string-keyed read produced, including the awkward ones: + * + * - `validTo` is 86340, i.e. 23:59, from the seeded template rather than from a reader default. + * - `device` reads as [DEVICE_ALL] because the template stored the *string* `"all"`, + * which `getInt` could not parse, so the per-key default won. + * - the `use*` flags each keep their own default; they are not uniformly NO. + */ +data class QuickWizardEntryData( + val guid: String = "", + val buttonText: String = "", + val device: Int = DEVICE_ALL, + val mode: Int = 0, + val insulin: Double = 0.0, + val carbs: Int = 0, + val validFrom: Int = 0, + val validTo: Int = 0, + val useBG: Int = YES, + val useCOB: Int = NO, + val useIOB: Int = YES, + val usePositiveIOBOnly: Int = NO, + val useTrend: Int = NO, + val useSuperBolus: Int = NO, + val useTempTarget: Int = NO, + val percentage: Int = 100, + val useEcarbs: Int = NO, + val carbs2: Int = 0, + val time: Int = 0, + val duration: Int = 0, + val carbTime: Int = 0, + val useAlarm: Int = NO, + val lastUsed: Long = 0 +) { + + companion object { + + // The flag values themselves. They live here rather than on `QuickWizardEntry` because that + // class is still Android only. `QuickWizardEntry` re-exposes each one under the same name, so + // the 38 existing call sites that write `QuickWizardEntry.YES` keep working unchanged. + const val YES = 0 + const val NO = 1 + const val POSITIVE_ONLY = 2 + const val NEGATIVE_ONLY = 3 + const val DEVICE_ALL = 0 + const val DEVICE_PHONE = 1 + const val DEVICE_WATCH = 2 + const val COOLDOWN_MILLIS = 1_800_000L // 1/2 hour + + /** + * Reads a stored preset. + * + * The lenient readers are what keep old documents working: `getInt` accepted a quoted number + * and truncated a fraction, so `36`, `"36"`, `36.9` and `"36.9"` all read back as 36, and an + * unreadable value fell to the per-key default rather than throwing. + */ + fun fromJsonObject(json: JsonObject): QuickWizardEntryData = + QuickWizardEntryData( + guid = json.lenientString("guid"), + buttonText = json.lenientString("buttonText"), + device = json.lenientInt("device", DEVICE_ALL), + mode = json.lenientInt("mode", 0), + insulin = json.lenientDouble("insulin", 0.0), + carbs = json.lenientInt("carbs"), + validFrom = json.lenientInt("validFrom"), + validTo = json.lenientInt("validTo"), + useBG = json.lenientInt("useBG", YES), + useCOB = json.lenientInt("useCOB", NO), + useIOB = json.lenientInt("useIOB", YES), + usePositiveIOBOnly = json.lenientInt("usePositiveIOBOnly", NO), + useTrend = json.lenientInt("useTrend", NO), + useSuperBolus = json.lenientInt("useSuperBolus", NO), + useTempTarget = json.lenientInt("useTempTarget", NO), + percentage = json.lenientInt("percentage", 100), + useEcarbs = json.lenientInt("useEcarbs", NO), + carbs2 = json.lenientInt("carbs2"), + time = json.lenientInt("time"), + duration = json.lenientInt("duration"), + carbTime = json.lenientInt("carbTime"), + useAlarm = json.lenientInt("useAlarm", NO), + lastUsed = json.lenientLong("lastUsed") + ) + } + + /** + * Writes the preset back. + * + * Every key is written, unlike the old document which only held the keys the editor had touched. + * That is safe because each reader default equals the value written here for an untouched field, + * and it removes the "is this key absent or really zero" question from the stored text. + */ + fun toJsonObject(): JsonObject = + buildJsonObject { + put("guid", guid) + put("buttonText", buttonText) + put("device", device) + put("mode", mode) + put("insulin", insulin) + put("carbs", carbs) + put("validFrom", validFrom) + put("validTo", validTo) + put("useBG", useBG) + put("useCOB", useCOB) + put("useIOB", useIOB) + put("usePositiveIOBOnly", usePositiveIOBOnly) + put("useTrend", useTrend) + put("useSuperBolus", useSuperBolus) + put("useTempTarget", useTempTarget) + put("percentage", percentage) + put("useEcarbs", useEcarbs) + put("carbs2", carbs2) + put("time", time) + put("duration", duration) + put("carbTime", carbTime) + put("useAlarm", useAlarm) + put("lastUsed", lastUsed) + } +} diff --git a/core/objects/src/main/kotlin/app/aaps/core/objects/workflow/CalculationSignalsImpl.kt b/core/objects/src/commonMain/kotlin/app/aaps/core/objects/workflow/CalculationSignalsImpl.kt similarity index 100% rename from core/objects/src/main/kotlin/app/aaps/core/objects/workflow/CalculationSignalsImpl.kt rename to core/objects/src/commonMain/kotlin/app/aaps/core/objects/workflow/CalculationSignalsImpl.kt diff --git a/core/objects/src/main/kotlin/app/aaps/core/objects/di/CoreModule.kt b/core/objects/src/main/kotlin/app/aaps/core/objects/di/CoreModule.kt deleted file mode 100644 index fffb98a95460..000000000000 --- a/core/objects/src/main/kotlin/app/aaps/core/objects/di/CoreModule.kt +++ /dev/null @@ -1,34 +0,0 @@ -package app.aaps.core.objects.di - -import android.content.Context -import android.telephony.SmsManager -import app.aaps.core.objects.wizard.BolusWizard -import app.aaps.core.objects.wizard.QuickWizardEntry -import dagger.Module -import dagger.Provides -import dagger.android.ContributesAndroidInjector -import dagger.hilt.InstallIn -import dagger.hilt.components.SingletonComponent - -@Suppress("unused") -@Module( - includes = [ - CoreModule.Bindings::class, - ] -) -@InstallIn(SingletonComponent::class) -open class CoreModule { - - @Suppress("unused") - @Module - @InstallIn(SingletonComponent::class) - interface Bindings { - - @ContributesAndroidInjector fun bolusWizardInjector(): BolusWizard - @ContributesAndroidInjector fun quickWizardEntryInjector(): QuickWizardEntry - } - - @Suppress("DEPRECATION") - @Provides - fun smsManager(context: Context): SmsManager? = context.getSystemService(SmsManager::class.java) -} \ No newline at end of file diff --git a/core/objects/src/main/kotlin/app/aaps/core/objects/extensions/BlockExtension.kt b/core/objects/src/main/kotlin/app/aaps/core/objects/extensions/BlockExtension.kt deleted file mode 100644 index 740dab3897c8..000000000000 --- a/core/objects/src/main/kotlin/app/aaps/core/objects/extensions/BlockExtension.kt +++ /dev/null @@ -1,137 +0,0 @@ -package app.aaps.core.objects.extensions - -import app.aaps.core.data.model.data.Block -import app.aaps.core.data.model.data.TargetBlock -import app.aaps.core.data.time.T -import app.aaps.core.interfaces.utils.DateUtil -import org.json.JSONArray -import org.json.JSONObject - -private fun getShiftedTimeSecs(originalSeconds: Int, timeShiftHours: Int): Int { - var shiftedSeconds = originalSeconds - timeShiftHours * 60 * 60 - shiftedSeconds = (shiftedSeconds + 24 * 60 * 60) % (24 * 60 * 60) - return shiftedSeconds -} - -fun List.shiftBlock(multiplier: Double, timeShiftHours: Int): List { - val newList = arrayListOf() - for (hour in 0..23) newList.add(Block(1000L * 60 * 60, blockValueBySeconds(hour * 3600, multiplier, timeShiftHours))) - for (i in newList.indices.reversed()) { - if (i > 0) - if (newList[i].amount == newList[i - 1].amount) { - newList[i - 1].duration += newList[i].duration - newList.removeAt(i) - } - } - return newList -} - -fun List.shiftTargetBlock(timeShiftHours: Int): List { - val newList = arrayListOf() - for (hour in 0..23) - newList.add(TargetBlock(1000L * 60 * 60, lowTargetBlockValueBySeconds(hour * 3600, timeShiftHours), highTargetBlockValueBySeconds(hour * 3600, timeShiftHours))) - for (i in newList.indices.reversed()) { - if (i > 0) - if (newList[i].lowTarget == newList[i - 1].lowTarget && newList[i].highTarget == newList[i - 1].highTarget) { - newList[i - 1].duration += newList[i].duration - newList.removeAt(i) - } - } - return newList -} - -fun List.blockValueBySeconds(secondsFromMidnight: Int, multiplier: Double, timeShiftHours: Int): Double { - var elapsed = 0L - val shiftedSeconds = getShiftedTimeSecs(secondsFromMidnight, timeShiftHours) - forEach { - if (shiftedSeconds >= elapsed && shiftedSeconds < elapsed + T.msecs(it.duration).secs()) return it.amount * multiplier - elapsed += T.msecs(it.duration).secs() - } - return last().amount * multiplier -} - -fun List.targetBlockValueBySeconds(secondsFromMidnight: Int, timeShiftHours: Int): Double { - var elapsed = 0L - val shiftedSeconds = getShiftedTimeSecs(secondsFromMidnight, timeShiftHours) - forEach { - if (shiftedSeconds >= elapsed && shiftedSeconds < elapsed + T.msecs(it.duration).secs()) return (it.lowTarget + it.highTarget) / 2.0 - elapsed += T.msecs(it.duration).secs() - } - return (last().lowTarget + last().highTarget) / 2.0 -} - -fun List.lowTargetBlockValueBySeconds(secondsFromMidnight: Int, timeShiftHours: Int): Double { - var elapsed = 0L - val shiftedSeconds = getShiftedTimeSecs(secondsFromMidnight, timeShiftHours) - forEach { - if (shiftedSeconds >= elapsed && shiftedSeconds < elapsed + T.msecs(it.duration).secs()) return it.lowTarget - elapsed += T.msecs(it.duration).secs() - } - return last().lowTarget -} - -fun List.highTargetBlockValueBySeconds(secondsFromMidnight: Int, timeShiftHours: Int): Double { - var elapsed = 0L - val shiftedSeconds = getShiftedTimeSecs(secondsFromMidnight, timeShiftHours) - forEach { - if (shiftedSeconds >= elapsed && shiftedSeconds < elapsed + T.msecs(it.duration).secs()) return it.highTarget - elapsed += T.msecs(it.duration).secs() - } - return last().highTarget -} - -fun blockFromJsonArray(jsonArray: JSONArray?, dateUtil: DateUtil): List? { - val size = jsonArray?.length() ?: return null - val ret = ArrayList(size) - try { - for (index in 0 until jsonArray.length() - 1) { - val o = jsonArray.getJSONObject(index) - val tas = dateUtil.toSeconds(o.getString("time")) - val next = jsonArray.getJSONObject(index + 1) - val nextTas = dateUtil.toSeconds(next.getString("time")) - val value = o.getDouble("value") - if (tas % 3600 != 0) return null - if (nextTas % 3600 != 0) return null - ret.add(index, Block((nextTas - tas) * 1000L, value)) - } - val last: JSONObject = jsonArray.getJSONObject(jsonArray.length() - 1) - val lastTas = dateUtil.toSeconds(last.getString("time")) - val value = last.getDouble("value") - ret.add(jsonArray.length() - 1, Block((T.hours(24).secs() - lastTas) * 1000L, value)) - } catch (e: Exception) { - return null - } - return ret -} - -fun targetBlockFromJsonArray(jsonArray1: JSONArray?, jsonArray2: JSONArray?, dateUtil: DateUtil): List? { - val size1 = jsonArray1?.length() ?: return null - val size2 = jsonArray2?.length() ?: return null - if (size1 != size2) return null - val ret = ArrayList(size1) - try { - for (index in 0 until jsonArray1.length() - 1) { - val o1: JSONObject = jsonArray1.getJSONObject(index) - val tas1 = dateUtil.toSeconds(o1.getString("time")) - val value1 = o1.getDouble("value") - val next1 = jsonArray1.getJSONObject(index + 1) - val nextTas1 = dateUtil.toSeconds(next1.getString("time")) - val o2 = jsonArray2.getJSONObject(index) - val tas2 = dateUtil.toSeconds(o2.getString("time")) - val value2 = o2.getDouble("value") - if (tas1 != tas2) return null - if (tas1 % 3600 != 0) return null - if (nextTas1 % 3600 != 0) return null - ret.add(index, TargetBlock((nextTas1 - tas1) * 1000L, value1, value2)) - } - val last1 = jsonArray1.getJSONObject(jsonArray1.length() - 1) - val lastTas1 = dateUtil.toSeconds(last1.getString("time")) - val value1 = last1.getDouble("value") - val last2 = jsonArray2.getJSONObject(jsonArray2.length() - 1) - val value2 = last2.getDouble("value") - ret.add(jsonArray1.length() - 1, TargetBlock((T.hours(24).secs() - lastTas1) * 1000L, value1, value2)) - } catch (e: Exception) { - return null - } - return ret -} \ No newline at end of file diff --git a/core/objects/src/main/kotlin/app/aaps/core/objects/extensions/BolusExtension.kt b/core/objects/src/main/kotlin/app/aaps/core/objects/extensions/BolusExtension.kt deleted file mode 100644 index e1ab958943d0..000000000000 --- a/core/objects/src/main/kotlin/app/aaps/core/objects/extensions/BolusExtension.kt +++ /dev/null @@ -1,8 +0,0 @@ -package app.aaps.core.objects.extensions - -import app.aaps.core.data.iob.Iob -import app.aaps.core.data.model.BS - -fun BS.iobCalc(time: Long): Iob = - if (!isValid || type == BS.Type.PRIMING) Iob() - else iCfg.iobCalcForTreatment(this, time) \ No newline at end of file diff --git a/core/objects/src/main/kotlin/app/aaps/core/objects/extensions/InsulinExtension.kt b/core/objects/src/main/kotlin/app/aaps/core/objects/extensions/InsulinExtension.kt deleted file mode 100644 index 9e9241d58527..000000000000 --- a/core/objects/src/main/kotlin/app/aaps/core/objects/extensions/InsulinExtension.kt +++ /dev/null @@ -1,45 +0,0 @@ -package app.aaps.core.objects.extensions - -import app.aaps.core.data.model.ICfg -import org.json.JSONObject -import kotlinx.serialization.json.JsonObject -import kotlinx.serialization.json.* - -/** used to save configuration within InsulinPlugin */ -fun ICfg.toJson(): JSONObject = JSONObject() - .put("insulinLabel", insulinLabel) - .put("insulinEndTime", insulinEndTime) - .put("insulinPeakTime", insulinPeakTime) - .put("concentration", concentration) - .put("insulinNickname", insulinNickname) - -/** used to restore configuration within InsulinPlugin and insulin Editor */ -fun ICfg.Companion.fromJson(json: JSONObject): ICfg = ICfg( - insulinLabel = json.optString("insulinLabel", ""), - insulinEndTime = json.optLong("insulinEndTime", 0), - insulinPeakTime = json.optLong("insulinPeakTime", 0), - concentration = json.optDouble("concentration", 1.0) - -) .also { it.insulinNickname = json.optString("insulinNickname", "") } - -fun ICfg.toJsonObject(): JsonObject = buildJsonObject { - put("insulinLabel", insulinLabel) - put("insulinEndTime", insulinEndTime) - put("insulinPeakTime", insulinPeakTime) - put("concentration", JsonPrimitive(concentration)) - put("insulinNickname", insulinNickname) -} - -/** used to restore configuration within InsulinPlugin and insulin Editor */ -fun ICfg.Companion.fromJsonObject(json: JsonObject): ICfg { - val icfg = ICfg( - insulinLabel = json["insulinLabel"]?.jsonPrimitive?.contentOrNull ?: "", - insulinEndTime = json["insulinEndTime"]?.jsonPrimitive?.longOrNull ?: 0, - insulinPeakTime = json["insulinPeakTime"]?.jsonPrimitive?.longOrNull ?: 0, - concentration = json["concentration"]?.jsonPrimitive?.doubleOrNull ?: 1.0 - ) - - icfg.insulinNickname = json["insulinNickname"]?.jsonPrimitive?.contentOrNull ?: "" - - return icfg -} \ No newline at end of file diff --git a/core/objects/src/main/kotlin/app/aaps/core/objects/extensions/JsonObjectExtension.kt b/core/objects/src/main/kotlin/app/aaps/core/objects/extensions/JsonObjectExtension.kt deleted file mode 100644 index 18d174f15086..000000000000 --- a/core/objects/src/main/kotlin/app/aaps/core/objects/extensions/JsonObjectExtension.kt +++ /dev/null @@ -1,51 +0,0 @@ -package app.aaps.core.objects.extensions - -import app.aaps.core.keys.interfaces.BooleanNonPreferenceKey -import app.aaps.core.keys.interfaces.DoubleNonPreferenceKey -import app.aaps.core.keys.interfaces.IntNonPreferenceKey -import app.aaps.core.keys.interfaces.LongNonPreferenceKey -import app.aaps.core.keys.interfaces.NonPreferenceKey -import app.aaps.core.keys.interfaces.Preferences -import app.aaps.core.keys.interfaces.StringNonPreferenceKey -import app.aaps.core.keys.interfaces.UnitDoublePreferenceKey -import kotlinx.serialization.json.JsonObject -import kotlinx.serialization.json.JsonPrimitive -import kotlinx.serialization.json.boolean -import kotlinx.serialization.json.double -import kotlinx.serialization.json.int -import kotlinx.serialization.json.long - -fun JsonObject.put(key: NonPreferenceKey, preferences: Preferences): JsonObject { - val primitive: JsonPrimitive = when (key) { - is IntNonPreferenceKey -> JsonPrimitive(preferences.get(key)) - is LongNonPreferenceKey -> JsonPrimitive(preferences.get(key)) - is DoubleNonPreferenceKey -> JsonPrimitive(preferences.get(key)) - is UnitDoublePreferenceKey -> JsonPrimitive(preferences.get(key)) - is StringNonPreferenceKey -> JsonPrimitive(preferences.get(key)) - is BooleanNonPreferenceKey -> JsonPrimitive(preferences.get(key)) - else -> error("Unsupported key type: ${key::class.simpleName}") - } - return JsonObject(this.toMutableMap().apply { this[key.key] = primitive }) -} - -fun JsonObject.put(key: BooleanNonPreferenceKey, value: Boolean): JsonObject = - JsonObject( - this.toMutableMap().apply { - this[key.key] = JsonPrimitive(value) - } - ) - -fun JsonObject.store(key: NonPreferenceKey, preferences: Preferences): JsonObject { - if (!contains(key.key)) return this - val raw = get(key.key) as JsonPrimitive - when (key) { - is IntNonPreferenceKey -> preferences.put(key, raw.int) - is LongNonPreferenceKey -> preferences.put(key, raw.long) - is DoubleNonPreferenceKey -> preferences.put(key, raw.double) - is UnitDoublePreferenceKey -> preferences.put(key, raw.double) - is StringNonPreferenceKey -> preferences.put(key, raw.content) - is BooleanNonPreferenceKey -> preferences.put(key, raw.boolean) - else -> error("Unsupported key type: ${key::class.simpleName}") - } - return this -} diff --git a/core/objects/src/main/kotlin/app/aaps/core/objects/extensions/ProfileSwitchExtension.kt b/core/objects/src/main/kotlin/app/aaps/core/objects/extensions/ProfileSwitchExtension.kt deleted file mode 100644 index 04cb8d77a202..000000000000 --- a/core/objects/src/main/kotlin/app/aaps/core/objects/extensions/ProfileSwitchExtension.kt +++ /dev/null @@ -1,81 +0,0 @@ -package app.aaps.core.objects.extensions - -import app.aaps.core.data.configuration.Constants -import app.aaps.core.data.model.GlucoseUnit -import app.aaps.core.data.model.ICfg -import app.aaps.core.data.model.PS -import app.aaps.core.data.time.T -import app.aaps.core.interfaces.profile.PureProfile -import app.aaps.core.interfaces.profile.SingleProfile -import app.aaps.core.interfaces.utils.DateUtil -import app.aaps.core.interfaces.utils.DecimalFormatter -import app.aaps.core.objects.profile.ProfileSealed -import app.aaps.core.utils.JsonHelper -import org.json.JSONObject -import java.util.TimeZone - -fun PS.getCustomizedName(decimalFormatter: DecimalFormatter): String { - var name: String = profileName - if (Constants.LOCAL_PROFILE == name) { - name = decimalFormatter.to2Decimal(ProfileSealed.PS(value = this, activePlugin = null).percentageBasalSum()) + "U " - } - if (timeshift != 0L || percentage != 100) { - name += " ($percentage%" - if (timeshift != 0L) name += "," + T.msecs(timeshift).hours() + "h" - name += ")" - } - return name -} - -/** - * Convert a [SingleProfile] to a [PureProfile] for graph rendering, validation, or - * activation. Single source of truth for the JSON-build pattern that was previously - * duplicated across ProfileManagementViewModel and ProfileEditorViewModel. - */ -fun SingleProfile.toPureProfile(dateUtil: DateUtil): PureProfile? { - val json = JSONObject().apply { - put("carbratio", ic) - put("sens", isf) - put("basal", basal) - put("target_low", targetLow) - put("target_high", targetHigh) - put("units", if (mgdl) GlucoseUnit.MGDL.asText else GlucoseUnit.MMOL.asText) - put("timezone", TimeZone.getDefault().id) - } - return pureProfileFromJson(json, dateUtil) -} - -/** - * Pure profile doesn't contain timestamp, percentage, timeshift, profileName - */ -fun pureProfileFromJson(jsonObject: JSONObject, dateUtil: DateUtil, defaultUnits: String? = null): PureProfile? { - try { - val txtUnits = JsonHelper.safeGetStringAllowNull(jsonObject, "units", defaultUnits) ?: return null - val units = GlucoseUnit.fromText(txtUnits) - val iCfg = JsonHelper.safeGetJSONObject(jsonObject, "iCfg", null)?.let { - ICfg.fromJson(it) - } - val timezone = TimeZone.getTimeZone(JsonHelper.safeGetString(jsonObject, "timezone", "UTC")) - - val isfBlocks = blockFromJsonArray(jsonObject.getJSONArray("sens"), dateUtil) ?: return null - val icBlocks = blockFromJsonArray(jsonObject.getJSONArray("carbratio"), dateUtil) - ?: return null - val basalBlocks = blockFromJsonArray(jsonObject.getJSONArray("basal"), dateUtil) - ?: return null - val targetBlocks = targetBlockFromJsonArray(jsonObject.getJSONArray("target_low"), jsonObject.getJSONArray("target_high"), dateUtil) - ?: return null - - return PureProfile( - jsonObject = jsonObject, - basalBlocks = basalBlocks, - isfBlocks = isfBlocks, - icBlocks = icBlocks, - targetBlocks = targetBlocks, - glucoseUnit = units, - timeZone = timezone, - iCfg = iCfg - ) - } catch (_: Exception) { - return null - } -} \ No newline at end of file diff --git a/core/objects/src/main/kotlin/app/aaps/core/objects/extensions/SceneSerializer.kt b/core/objects/src/main/kotlin/app/aaps/core/objects/extensions/SceneSerializer.kt deleted file mode 100644 index 7e2106024d63..000000000000 --- a/core/objects/src/main/kotlin/app/aaps/core/objects/extensions/SceneSerializer.kt +++ /dev/null @@ -1,160 +0,0 @@ -package app.aaps.core.objects.extensions - -import app.aaps.core.data.model.RM -import app.aaps.core.data.model.Scene -import app.aaps.core.data.model.SceneAction -import app.aaps.core.data.model.SceneEndAction -import app.aaps.core.data.model.TE -import app.aaps.core.data.model.TT -import org.json.JSONArray -import org.json.JSONObject - -/** - * Extension function to convert a list of Scene to JSON string. - * @return JSON string representation of scenes - */ -fun List.toJson(): String { - val jsonArray = JSONArray() - forEach { scene -> - val obj = JSONObject().apply { - put("id", scene.id) - put("name", scene.name) - put("icon", scene.icon) - put("defaultDurationMinutes", scene.defaultDurationMinutes) - put("isDeletable", scene.isDeletable) - put("isEnabled", scene.isEnabled) - put("sortOrder", scene.sortOrder) - put("actions", scene.actions.toJsonArray()) - put("endAction", scene.endAction.toJson()) - } - jsonArray.put(obj) - } - return jsonArray.toString() -} - -/** - * Extension function to parse JSON string into a list of Scene. - * @return List of Scene objects, or empty list if parsing fails - */ -fun String.toScenes(): List { - return try { - if (isEmpty() || this == "[]") { - emptyList() - } else { - val jsonArray = JSONArray(this) - (0 until jsonArray.length()).map { i -> - val obj = jsonArray.getJSONObject(i) - Scene( - id = obj.getString("id"), - name = obj.getString("name"), - icon = obj.optString("icon", "star"), - defaultDurationMinutes = obj.optInt("defaultDurationMinutes", 60), - isDeletable = obj.optBoolean("isDeletable", true), - isEnabled = obj.optBoolean("isEnabled", true), - sortOrder = obj.optInt("sortOrder", 0), - actions = obj.optJSONArray("actions")?.toSceneActions() ?: emptyList(), - endAction = obj.optJSONObject("endAction")?.toSceneEndAction() ?: SceneEndAction.Notification - ) - } - } - } catch (_: Exception) { - emptyList() - } -} - -// --- SceneAction serialization --- - -private fun List.toJsonArray(): JSONArray { - val arr = JSONArray() - forEach { action -> - val obj = JSONObject() - when (action) { - is SceneAction.TempTarget -> obj.apply { - put("type", "temp_target") - put("reason", action.reason.text) - put("targetMgdl", action.targetMgdl) - } - - is SceneAction.ProfileSwitch -> obj.apply { - put("type", "profile_switch") - put("profileName", action.profileName) - put("percentage", action.percentage) - put("timeShiftHours", action.timeShiftHours) - } - - is SceneAction.SmbToggle -> obj.apply { - put("type", "smb_toggle") - put("enabled", action.enabled) - } - - is SceneAction.LoopModeChange -> obj.apply { - put("type", "loop_mode") - put("mode", action.mode.name) - } - - is SceneAction.CarePortalEvent -> obj.apply { - put("type", "careportal") - put("teType", action.type.text) - put("note", action.note) - } - } - arr.put(obj) - } - return arr -} - -private fun JSONArray.toSceneActions(): List { - return (0 until length()).mapNotNull { i -> - val obj = getJSONObject(i) - when (obj.getString("type")) { - "temp_target" -> SceneAction.TempTarget( - reason = TT.Reason.fromString(obj.getString("reason")), - targetMgdl = obj.getDouble("targetMgdl") - ) - - "profile_switch" -> SceneAction.ProfileSwitch( - profileName = obj.getString("profileName"), - percentage = obj.optInt("percentage", 100), - timeShiftHours = obj.optInt("timeShiftHours", 0) - ) - - "smb_toggle" -> SceneAction.SmbToggle( - enabled = obj.getBoolean("enabled") - ) - - "loop_mode" -> SceneAction.LoopModeChange( - mode = try { - RM.Mode.valueOf(obj.getString("mode")) - } catch (_: Exception) { - RM.Mode.CLOSED_LOOP - } - ) - - "careportal" -> SceneAction.CarePortalEvent( - type = TE.Type.entries.firstOrNull { it.text == obj.getString("teType") } ?: TE.Type.NOTE, - note = obj.optString("note", "") - ) - - else -> null - } - } -} - -// --- SceneEndAction serialization --- - -private fun SceneEndAction.toJson(): JSONObject = JSONObject().apply { - when (this@toJson) { - is SceneEndAction.Notification -> put("type", "notification") - - is SceneEndAction.ChainScene -> { - put("type", "chain_scene") - put("sceneId", sceneId) - } - } -} - -private fun JSONObject.toSceneEndAction(): SceneEndAction = - when (optString("type", "notification")) { - "chain_scene" -> SceneEndAction.ChainScene(getString("sceneId")) - else -> SceneEndAction.Notification - } diff --git a/core/objects/src/test/kotlin/app/aaps/core/objects/interfaces/utils/RoundTest.kt b/core/objects/src/test/kotlin/app/aaps/core/objects/interfaces/utils/RoundTest.kt deleted file mode 100644 index 70bd8fcccdc2..000000000000 --- a/core/objects/src/test/kotlin/app/aaps/core/objects/interfaces/utils/RoundTest.kt +++ /dev/null @@ -1,55 +0,0 @@ -package app.aaps.core.objects.interfaces.utils - -import app.aaps.core.interfaces.utils.Round -import com.google.common.truth.Truth.assertThat -import org.junit.jupiter.api.Test - -class RoundTest { - - @Test - fun roundToTest() { - assertThat(Round.roundTo(0.54, 0.05)).isWithin(0.00000000000000000001).of(0.55) - assertThat(Round.roundTo(-3.2553715764602713, 0.01)).isWithin(0.00000000000000000001).of(-3.26) - assertThat(Round.roundTo(0.8156666666666667, 0.001)).isWithin(0.00000000000000000001).of(0.816) - assertThat(Round.roundTo(0.235, 0.001)).isWithin(0.00000000000000000001).of(0.235) - assertThat(Round.roundTo(0.3, 0.1)).isWithin(0.00000000000000001).of(0.3) - assertThat(Round.roundTo(0.0016960652144170627, 0.0001)).isWithin(0.00000000000000000001).of(0.0017) - assertThat(Round.roundTo(0.007804436682291013, 0.0001)).isWithin(0.00000000000000000001).of(0.0078) - assertThat(Round.roundTo(0.6, 0.05)).isWithin(0.00000000000000000001).of(0.6) - assertThat(Round.roundTo(1.49, 1.0)).isWithin(0.00000000000000000001).of(1.0) - assertThat(Round.roundTo(0.0, 1.0)).isWithin(0.00000000000000000001).of(0.0) - } - - @Test - fun floorToTest() { - // Genuine floors: a value strictly between two steps must still floor DOWN - assertThat(Round.floorTo(0.54, 0.05)).isWithin(0.00000001).of(0.5) - assertThat(Round.floorTo(1.59, 1.0)).isWithin(0.00000001).of(1.0) - assertThat(Round.floorTo(0.0, 1.0)).isWithin(0.00000001).of(0.0) - // Regression: on-grid values must not lose a whole step to IEEE-754 (x/step lands just below an integer) - assertThat(Round.floorTo(0.15, 0.05)).isWithin(0.00000001).of(0.15) - assertThat(Round.floorTo(0.30, 0.05)).isWithin(0.00000001).of(0.30) - assertThat(Round.floorTo(0.95, 0.05)).isWithin(0.00000001).of(0.95) - assertThat(Round.floorTo(0.30, 0.1)).isWithin(0.00000001).of(0.30) - assertThat(Round.floorTo(1.20, 0.1)).isWithin(0.00000001).of(1.20) - assertThat(Round.floorTo(0.29, 0.01)).isWithin(0.00000001).of(0.29) - } - - @Test - fun ceilToTest() { - // Genuine ceilings: a value strictly between two steps must still ceil UP - assertThat(Round.ceilTo(0.54, 0.1)).isWithin(0.00000001).of(0.6) - assertThat(Round.ceilTo(1.49999, 1.0)).isWithin(0.00000001).of(2.0) - assertThat(Round.ceilTo(0.0, 1.0)).isWithin(0.00000001).of(0.0) - // Regression: on-grid values must not gain a whole step to IEEE-754 (x/step lands just above an integer) - assertThat(Round.ceilTo(0.07, 0.01)).isWithin(0.00000001).of(0.07) - assertThat(Round.ceilTo(0.14, 0.01)).isWithin(0.00000001).of(0.14) - assertThat(Round.ceilTo(0.28, 0.01)).isWithin(0.00000001).of(0.28) - assertThat(Round.ceilTo(0.56, 0.01)).isWithin(0.00000001).of(0.56) - } - - @Test - fun isSameTest() { - assertThat(Round.isSame(0.54, 0.54)).isTrue() - } -} diff --git a/core/ui/build.gradle.kts b/core/ui/build.gradle.kts index 8222ddbdf6e0..44173e5fdfdc 100644 --- a/core/ui/build.gradle.kts +++ b/core/ui/build.gradle.kts @@ -1,42 +1,157 @@ import kotlin.math.min plugins { - alias(libs.plugins.android.library) + kotlin("multiplatform") + // NOT com.android.library. AGP 9 refuses that plugin together with the multiplatform plugin. + // Same reason as :core:keys, :core:data, :core:utils and :core:interfaces. + alias(libs.plugins.android.kmp.library) + // The Compose COMPILER, which ships with Kotlin and compiles @Composable for every target. alias(libs.plugins.compose.compiler) - id("android-module-dependencies") - id("test-module-dependencies") - id("compose-test-module-dependencies") - id("jacoco-module-dependencies") + // The Compose Multiplatform framework. The compiler plugin above is applied per project rather + // than per target, so every target needs a Compose runtime on its class path. + alias(libs.plugins.compose.multiplatform) } -android { - namespace = "app.aaps.core.ui" - defaultConfig { - minSdk = min(Versions.minSdk, Versions.wearMinSdk) +// One task, not one per variant: a multiplatform module has no product flavours, and a Kotlin source +// set takes a task provider directly. Same generator as :core:keys and :core:interfaces, pointed at +// this module's strings. The strings themselves do not move, and AAPT keeps resolving them on +// Android exactly as before. +val generateUiStrings = tasks.register("generateUiStrings") { + resDir.set(layout.projectDirectory.dir("src/androidMain/res")) + packageName.set("app.aaps.core.ui") + owner.set("ui") + objectName.set("UiStrings") + idsObjectName.set("UiStringIds") + reportFile.set(layout.buildDirectory.file("reports/uiStrings/translations.txt")) + // Set explicitly: addGeneratedSourceDirectory only applies a convention derived from the task + // name, so both properties would land on one directory and the second file written would delete + // the first. + commonOutputDir.set(layout.buildDirectory.dir("generated/uiStrings/common")) + androidOutputDir.set(layout.buildDirectory.dir("generated/uiStrings/android")) +} + +kotlin { + android { + namespace = "app.aaps.core.ui" + compileSdk = Versions.compileSdk + minSdk = min(Versions.minSdk, Versions.wearMinSdk) // Compatible with wear module + // Off by default for a multiplatform library, unlike a plain android library. This module is + // where most of the app's strings, drawables and raw alarm sounds live, so it must be on. + androidResources { enable = true } + // Creates the androidHostTest compilation, which also pulls in commonTest. + // isIncludeAndroidResources is what makes Robolectric work: the Compose UI tests here need a + // real merged resource table and the manifest that holds the activity createComposeRule() + // launches. Restated from test-module-dependencies, which this module can no longer apply. + withHostTest { + isIncludeAndroidResources = true + isReturnDefaultValues = true + } + compilerOptions { jvmTarget.set(Versions.jvmTarget) } + + // Restated from android-module-dependencies, which this module can no longer apply. Without + // it MissingTranslation would switch on for the first time here and every locale file that + // is incomplete today would fail a release build. + lint { + checkReleaseBuilds = false + disable += "MissingTranslation" + disable += "ExtraTranslation" + } } - buildFeatures { - compose = true + // Apple klibs cross compile on Windows. Linking and running still need a Mac, and those tasks + // report SKIPPED rather than failing. Keeping the target is what stops an android-only import + // from quietly reaching commonMain later. + // + // Deliberately no jvm() target, as in :core:interfaces: it pulls in the desktop Compose surface + // (skiko-awt) and gives the module another way to fail without saying anything about iOS. + iosArm64() + iosSimulatorArm64() + + sourceSets { + commonMain { + kotlin.srcDir(generateUiStrings.flatMap { it.commonOutputDir }) + dependencies { + api(project(":core:data")) + api(project(":core:interfaces")) + api(project(":core:keys")) + api(libs.kotlinx.datetime) + + // CMP rather than androidx. On Android CMP delegates to androidx, so the composeBom + // below still decides the Android versions and nothing about the Android build + // changes. + api(libs.cmp.runtime) + api(libs.cmp.foundation) + api(libs.cmp.ui) + // Replaces the deprecated androidx.compose.ui.backhandler.BackHandler. + api(libs.androidx.navigationevent.compose) + api(libs.cmp.material3) + api(libs.cmp.material.icons.extended) + implementation(libs.cmp.ui.tooling.preview) + } + } + + androidMain { + // Android only: the string name to R.string id map. + kotlin.srcDir(generateUiStrings.flatMap { it.androidOutputDir }) + dependencies { + // Everything here was `api` on the old android library and the consumer modules + // resolve it transitively, so it must stay exported. + api(libs.androidx.appcompat) + api(libs.com.google.android.material) + api(project.dependencies.platform(libs.androidx.compose.bom)) + api(libs.androidx.compose.material3) + api(libs.androidx.compose.material.icons.extended) + api(libs.androidx.compose.runtime) + api(libs.androidx.activity.compose) + api(libs.androidx.lifecycle.runtime.compose) + + api(libs.com.google.dagger.android) + api(libs.com.google.dagger.android.support) + + implementation(libs.androidx.compose.ui.tooling.preview) + // Was debugImplementation. The AGP multiplatform library target has no build types, + // so there is no debug-only configuration to put it in. It only matters for rendering + // @Preview, and R8 in the app module drops it from a release build. + implementation(libs.androidx.compose.ui.tooling) + } + } + + // Hand written rather than taken from test-module-dependencies and + // compose-test-module-dependencies, because both convention plugins apply + // com.android.library and so cannot be used here. + // + // createComposeRule() is a JUnit4 TestRule and RobolectricTestRunner is a JUnit4 runner, so + // these tests run JUnit4 style; the vintage engine bridges them onto the JUnit Platform. + getByName("androidHostTest") { + dependencies { + implementation(libs.org.junit.jupiter) + implementation(libs.org.junit.jupiter.api) + implementation(libs.org.mockito.junit.jupiter) + implementation(libs.org.mockito.kotlin) + implementation(libs.com.google.truth) + implementation(libs.kotlinx.coroutines.test) + implementation(project.dependencies.platform(libs.androidx.compose.bom)) + implementation(libs.androidx.compose.ui.test.junit4) + implementation(libs.org.robolectric) + // Was debugImplementation: supplies the manifest holding the activity that + // createComposeRule() launches. + implementation(libs.androidx.compose.ui.test.manifest) + runtimeOnly(libs.org.junit.vintage.engine) + runtimeOnly(libs.org.junit.platform.launcher) + } + } } } -dependencies { - api(libs.androidx.appcompat) - - api(libs.com.google.android.material) - api(platform(libs.androidx.compose.bom)) - api(libs.androidx.compose.material3) - api(libs.androidx.compose.material.icons.extended) - api(libs.androidx.compose.runtime) - api(libs.androidx.activity.compose) - api(libs.androidx.lifecycle.runtime.compose) - - api(libs.com.google.dagger.android) - api(libs.com.google.dagger.android.support) - - implementation(project(":core:interfaces")) - implementation(project(":core:keys")) - implementation(project(":core:data")) - implementation(libs.androidx.compose.ui.tooling.preview) - debugImplementation(libs.androidx.compose.ui.tooling) +tasks.withType { + useJUnitPlatform() + // Robolectric runs tests in its own classloader sandbox and rewrites bytecode, so the default + // JaCoCo on-the-fly agent records no coverage for the classes those tests exercise - here that is + // every Compose screen the UI tests drive. Restated from jacoco-module-dependencies, which applies + // com.android.library and so cannot be used by a multiplatform module. The jacoco plugin itself is + // already applied to every project by the root build file. + extensions.configure { + isIncludeNoLocationClasses = true + excludes = listOf("jdk.internal.*") + } } diff --git a/core/ui/src/test/kotlin/app/aaps/core/ui/compose/AapsCardTest.kt b/core/ui/src/androidHostTest/kotlin/app/aaps/core/ui/compose/AapsCardTest.kt similarity index 100% rename from core/ui/src/test/kotlin/app/aaps/core/ui/compose/AapsCardTest.kt rename to core/ui/src/androidHostTest/kotlin/app/aaps/core/ui/compose/AapsCardTest.kt diff --git a/core/ui/src/test/kotlin/app/aaps/core/ui/compose/AapsFabTest.kt b/core/ui/src/androidHostTest/kotlin/app/aaps/core/ui/compose/AapsFabTest.kt similarity index 100% rename from core/ui/src/test/kotlin/app/aaps/core/ui/compose/AapsFabTest.kt rename to core/ui/src/androidHostTest/kotlin/app/aaps/core/ui/compose/AapsFabTest.kt diff --git a/core/ui/src/test/kotlin/app/aaps/core/ui/compose/AapsSearchFieldTest.kt b/core/ui/src/androidHostTest/kotlin/app/aaps/core/ui/compose/AapsSearchFieldTest.kt similarity index 100% rename from core/ui/src/test/kotlin/app/aaps/core/ui/compose/AapsSearchFieldTest.kt rename to core/ui/src/androidHostTest/kotlin/app/aaps/core/ui/compose/AapsSearchFieldTest.kt diff --git a/core/ui/src/androidHostTest/kotlin/app/aaps/core/ui/compose/HtmlTextTest.kt b/core/ui/src/androidHostTest/kotlin/app/aaps/core/ui/compose/HtmlTextTest.kt new file mode 100644 index 000000000000..49b3803d71f0 --- /dev/null +++ b/core/ui/src/androidHostTest/kotlin/app/aaps/core/ui/compose/HtmlTextTest.kt @@ -0,0 +1,68 @@ +package app.aaps.core.ui.compose + +import androidx.compose.ui.text.font.FontWeight +import com.google.common.truth.Truth.assertThat +import org.junit.jupiter.api.Test + +internal class HtmlTextTest { + + @Test fun `plain text is unchanged`() { + assertThat("just text".htmlToAnnotatedString().text).isEqualTo("just text") + } + + @Test fun `br becomes a newline`() { + assertThat("a
b".htmlToAnnotatedString().text).isEqualTo("a\nb") + assertThat("a
b".htmlToAnnotatedString().text).isEqualTo("a\nb") + assertThat("a
b".htmlToAnnotatedString().text).isEqualTo("a\nb") + assertThat("a
b".htmlToAnnotatedString().text).isEqualTo("a\nb") + } + + @Test fun `b marks the enclosed text bold and nothing else`() { + val result = "noyesno".htmlToAnnotatedString() + assertThat(result.text).isEqualTo("noyesno") + val spans = result.spanStyles + assertThat(spans).hasSize(1) + assertThat(spans[0].item.fontWeight).isEqualTo(FontWeight.Bold) + assertThat(spans[0].start).isEqualTo(2) + assertThat(spans[0].end).isEqualTo(5) + } + + @Test fun `strong is treated as bold`() { + val result = "x".htmlToAnnotatedString() + assertThat(result.text).isEqualTo("x") + assertThat(result.spanStyles.single().item.fontWeight).isEqualTo(FontWeight.Bold) + } + + @Test fun `entities are decoded`() { + assertThat("a&b".htmlToAnnotatedString().text).isEqualTo("a&b") + assertThat("<tag>".htmlToAnnotatedString().text).isEqualTo("") + assertThat("say "hi"".htmlToAnnotatedString().text).isEqualTo("say \"hi\"") + assertThat("it's".htmlToAnnotatedString().text).isEqualTo("it's") + } + + @Test fun `a bare ampersand survives`() { + assertThat("a & b".htmlToAnnotatedString().text).isEqualTo("a & b") + assertThat("a ¬anentity; b".htmlToAnnotatedString().text).isEqualTo("a ¬anentity; b") + } + + @Test fun `an unknown tag is kept rather than dropped`() { + assertThat("2 < 3".htmlToAnnotatedString().text).isEqualTo("2 < 3") + assertThat("x".htmlToAnnotatedString().text).isEqualTo("x") + } + + @Test fun `an unclosed b still ends`() { + val result = "forever".htmlToAnnotatedString() + assertThat(result.text).isEqualTo("forever") + assertThat(result.spanStyles.single().end).isEqualTo(7) + } + + @Test fun `a stray closing tag is ignored`() { + assertThat("xy".htmlToAnnotatedString().text).isEqualTo("xy") + } + + @Test fun `the real pump enact result shape renders`() { + val result = "Success: false
Enacted: true".htmlToAnnotatedString() + assertThat(result.text).isEqualTo("Success: false\nEnacted: true") + assertThat(result.spanStyles).hasSize(2) + } +} diff --git a/core/ui/src/test/kotlin/app/aaps/core/ui/compose/QuickAddButtonsTest.kt b/core/ui/src/androidHostTest/kotlin/app/aaps/core/ui/compose/QuickAddButtonsTest.kt similarity index 100% rename from core/ui/src/test/kotlin/app/aaps/core/ui/compose/QuickAddButtonsTest.kt rename to core/ui/src/androidHostTest/kotlin/app/aaps/core/ui/compose/QuickAddButtonsTest.kt diff --git a/core/ui/src/test/kotlin/app/aaps/core/ui/compose/TonalIconTest.kt b/core/ui/src/androidHostTest/kotlin/app/aaps/core/ui/compose/TonalIconTest.kt similarity index 100% rename from core/ui/src/test/kotlin/app/aaps/core/ui/compose/TonalIconTest.kt rename to core/ui/src/androidHostTest/kotlin/app/aaps/core/ui/compose/TonalIconTest.kt diff --git a/core/ui/src/test/kotlin/app/aaps/core/ui/compose/dialogs/DatePickerModalTest.kt b/core/ui/src/androidHostTest/kotlin/app/aaps/core/ui/compose/dialogs/DatePickerModalTest.kt similarity index 100% rename from core/ui/src/test/kotlin/app/aaps/core/ui/compose/dialogs/DatePickerModalTest.kt rename to core/ui/src/androidHostTest/kotlin/app/aaps/core/ui/compose/dialogs/DatePickerModalTest.kt diff --git a/core/ui/src/test/kotlin/app/aaps/core/ui/compose/dialogs/ErrorDialogTest.kt b/core/ui/src/androidHostTest/kotlin/app/aaps/core/ui/compose/dialogs/ErrorDialogTest.kt similarity index 97% rename from core/ui/src/test/kotlin/app/aaps/core/ui/compose/dialogs/ErrorDialogTest.kt rename to core/ui/src/androidHostTest/kotlin/app/aaps/core/ui/compose/dialogs/ErrorDialogTest.kt index f253e839aaef..3b1c6e576484 100644 --- a/core/ui/src/test/kotlin/app/aaps/core/ui/compose/dialogs/ErrorDialogTest.kt +++ b/core/ui/src/androidHostTest/kotlin/app/aaps/core/ui/compose/dialogs/ErrorDialogTest.kt @@ -34,7 +34,8 @@ class ErrorDialogTest { @Test fun withPositiveButton_rendersBoth_andFiresCorrectCallbacks() { - var positive = 0; var dismiss = 0 + var positive = 0; + var dismiss = 0 compose.setContent { MaterialTheme { ErrorDialog( @@ -77,7 +78,8 @@ class ErrorDialogTest { @Test fun annotatedString_withPositiveButton_rendersBoth_andFiresCorrectCallbacks() { - var positive = 0; var dismiss = 0 + var positive = 0; + var dismiss = 0 compose.setContent { MaterialTheme { ErrorDialog( diff --git a/core/ui/src/test/kotlin/app/aaps/core/ui/compose/dialogs/GlobalDialogHostTest.kt b/core/ui/src/androidHostTest/kotlin/app/aaps/core/ui/compose/dialogs/GlobalDialogHostTest.kt similarity index 94% rename from core/ui/src/test/kotlin/app/aaps/core/ui/compose/dialogs/GlobalDialogHostTest.kt rename to core/ui/src/androidHostTest/kotlin/app/aaps/core/ui/compose/dialogs/GlobalDialogHostTest.kt index 6d9ed442d9f9..fe7bda0fb233 100644 --- a/core/ui/src/test/kotlin/app/aaps/core/ui/compose/dialogs/GlobalDialogHostTest.kt +++ b/core/ui/src/androidHostTest/kotlin/app/aaps/core/ui/compose/dialogs/GlobalDialogHostTest.kt @@ -11,7 +11,6 @@ import app.aaps.core.interfaces.rx.events.Event import app.aaps.core.interfaces.rx.events.EventShowDialog import app.aaps.core.ui.R import com.google.common.truth.Truth.assertThat -import io.reactivex.rxjava3.core.Observable import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.filter @@ -23,6 +22,7 @@ import org.robolectric.RobolectricTestRunner import org.robolectric.RuntimeEnvironment import org.robolectric.annotation.Config import org.robolectric.annotation.GraphicsMode +import kotlin.reflect.KClass /** * Integration test for the [GlobalDialogHost] router: it subscribes to [EventShowDialog] on an @@ -142,11 +142,8 @@ class GlobalDialogHostTest { check(events.tryEmit(event)) { "event buffer overflow" } } - override fun toObservable(eventType: Class): Observable = - throw UnsupportedOperationException("not needed in tests") - @Suppress("UNCHECKED_CAST") - override fun toFlow(eventType: Class): Flow = + override fun toFlow(eventType: KClass): Flow = events.filter { eventType.isInstance(it) } as Flow } } diff --git a/core/ui/src/test/kotlin/app/aaps/core/ui/compose/dialogs/GlobalSnackbarHostTest.kt b/core/ui/src/androidHostTest/kotlin/app/aaps/core/ui/compose/dialogs/GlobalSnackbarHostTest.kt similarity index 91% rename from core/ui/src/test/kotlin/app/aaps/core/ui/compose/dialogs/GlobalSnackbarHostTest.kt rename to core/ui/src/androidHostTest/kotlin/app/aaps/core/ui/compose/dialogs/GlobalSnackbarHostTest.kt index 9c1d4ea8dc30..e7fc3a7f5c13 100644 --- a/core/ui/src/test/kotlin/app/aaps/core/ui/compose/dialogs/GlobalSnackbarHostTest.kt +++ b/core/ui/src/androidHostTest/kotlin/app/aaps/core/ui/compose/dialogs/GlobalSnackbarHostTest.kt @@ -12,7 +12,6 @@ import app.aaps.core.keys.StringKey import app.aaps.core.keys.interfaces.Preferences import app.aaps.core.ui.compose.AapsTheme import app.aaps.core.ui.compose.LocalPreferences -import io.reactivex.rxjava3.core.Observable import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow @@ -25,6 +24,7 @@ import org.mockito.kotlin.mock import org.robolectric.RobolectricTestRunner import org.robolectric.annotation.Config import org.robolectric.annotation.GraphicsMode +import kotlin.reflect.KClass /** * Integration test for the [GlobalSnackbarHost] router: it subscribes to [EventShowSnackbar] on an @@ -74,11 +74,8 @@ class GlobalSnackbarHostTest { check(events.tryEmit(event)) { "event buffer overflow" } } - override fun toObservable(eventType: Class): Observable = - throw UnsupportedOperationException("not needed in tests") - @Suppress("UNCHECKED_CAST") - override fun toFlow(eventType: Class): Flow = + override fun toFlow(eventType: KClass): Flow = events.filter { eventType.isInstance(it) } as Flow } } diff --git a/core/ui/src/test/kotlin/app/aaps/core/ui/compose/dialogs/OkCancelDialogTest.kt b/core/ui/src/androidHostTest/kotlin/app/aaps/core/ui/compose/dialogs/OkCancelDialogTest.kt similarity index 100% rename from core/ui/src/test/kotlin/app/aaps/core/ui/compose/dialogs/OkCancelDialogTest.kt rename to core/ui/src/androidHostTest/kotlin/app/aaps/core/ui/compose/dialogs/OkCancelDialogTest.kt diff --git a/core/ui/src/test/kotlin/app/aaps/core/ui/compose/dialogs/OkDialogTest.kt b/core/ui/src/androidHostTest/kotlin/app/aaps/core/ui/compose/dialogs/OkDialogTest.kt similarity index 100% rename from core/ui/src/test/kotlin/app/aaps/core/ui/compose/dialogs/OkDialogTest.kt rename to core/ui/src/androidHostTest/kotlin/app/aaps/core/ui/compose/dialogs/OkDialogTest.kt diff --git a/core/ui/src/androidHostTest/kotlin/app/aaps/core/ui/compose/dialogs/PasswordCheckHostTest.kt b/core/ui/src/androidHostTest/kotlin/app/aaps/core/ui/compose/dialogs/PasswordCheckHostTest.kt new file mode 100644 index 000000000000..bee5089fa0fc --- /dev/null +++ b/core/ui/src/androidHostTest/kotlin/app/aaps/core/ui/compose/dialogs/PasswordCheckHostTest.kt @@ -0,0 +1,179 @@ +package app.aaps.core.ui.compose.dialogs + +import android.content.Context +import androidx.compose.material3.MaterialTheme +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.hasSetTextAction +import androidx.compose.ui.test.junit4.v2.createComposeRule +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performTextInput +import app.aaps.core.interfaces.protection.PasswordCheck +import app.aaps.core.interfaces.protection.PasswordRequest +import app.aaps.core.keys.interfaces.StringPreferenceKey +import app.aaps.core.keys.interfaces.TextRef +import app.aaps.core.ui.R +import com.google.common.truth.Truth.assertThat +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.annotation.Config +import org.robolectric.annotation.GraphicsMode + +/** + * Tests the [PasswordCheckHost] router: it watches [PasswordCheck.request] and renders the matching + * dialog, handing what the user typed back through the request's callbacks. + * + * The fake [PasswordCheck] only publishes requests - deciding whether a password is CORRECT belongs + * to the implementation, and this test covers the wiring between the two. + */ +@RunWith(RobolectricTestRunner::class) +@GraphicsMode(GraphicsMode.Mode.NATIVE) +@Config(sdk = [35]) +class PasswordCheckHostTest { + + @get:Rule + val compose = createComposeRule() + + private val passwordCheck = FakePasswordCheck() + + private lateinit var okLabel: String + private lateinit var cancelLabel: String + + @Before + fun setUp() { + val context: Context = RuntimeEnvironment.getApplication() + okLabel = context.getString(R.string.ok) + cancelLabel = context.getString(R.string.cancel) + compose.setContent { + MaterialTheme { + PasswordCheckHost(passwordCheck) + } + } + compose.waitForIdle() + } + + @Test + fun noRequest_showsNothing() { + compose.onNodeWithText(okLabel).assertDoesNotExist() + } + + @Test + fun queryRequest_passesTypedPasswordToOnConfirm() { + var entered: String? = null + passwordCheck.publish( + PasswordRequest.Query( + label = TextRef.Literal("Enter password"), + pinInput = false, + onConfirm = { entered = it }, + onCancel = {} + ) + ) + compose.waitForIdle() + + compose.onNodeWithText("Enter password").assertIsDisplayed() + compose.onNode(hasSetTextAction()).performTextInput("secret") + compose.onNodeWithText(okLabel).performClick() + compose.waitForIdle() + + assertThat(entered).isEqualTo("secret") + } + + @Test + fun queryRequest_cancel_firesOnCancel() { + var cancelled = 0 + passwordCheck.publish( + PasswordRequest.Query( + label = TextRef.Literal("Enter password"), + pinInput = false, + onConfirm = {}, + onCancel = { cancelled++ } + ) + ) + compose.waitForIdle() + + compose.onNodeWithText(cancelLabel).performClick() + compose.waitForIdle() + + assertThat(cancelled).isEqualTo(1) + } + + @Test + fun clearedRequest_hidesDialog() { + passwordCheck.publish( + PasswordRequest.Query( + label = TextRef.Literal("Enter password"), + pinInput = false, + onConfirm = {}, + onCancel = {} + ) + ) + compose.waitForIdle() + compose.onNodeWithText("Enter password").assertIsDisplayed() + + passwordCheck.publish(null) + compose.waitForIdle() + + compose.onNodeWithText("Enter password").assertDoesNotExist() + } + + @Test + fun queryAnyRequest_showsExplanationAndWarning() { + passwordCheck.publish( + PasswordRequest.QueryAny( + label = TextRef.Literal("Import password"), + explanation = TextRef.Literal("Password used to encrypt the file"), + warning = TextRef.Literal("A wrong password fails the import"), + onConfirm = {}, + onCancel = {} + ) + ) + compose.waitForIdle() + + compose.onNodeWithText("Password used to encrypt the file").assertIsDisplayed() + compose.onNodeWithText("A wrong password fails the import").assertIsDisplayed() + } + + private class FakePasswordCheck : PasswordCheck { + + private val _request = MutableStateFlow(null) + override val request: StateFlow = _request.asStateFlow() + + fun publish(value: PasswordRequest?) { + _request.value = value + } + + override fun queryPassword( + label: TextRef, + preference: StringPreferenceKey, + ok: ((String) -> Unit)?, + cancel: (() -> Unit)?, + fail: (() -> Unit)?, + pinInput: Boolean + ) = Unit + + override fun setPassword( + label: TextRef, + preference: StringPreferenceKey, + ok: ((String) -> Unit)?, + cancel: (() -> Unit)?, + clear: (() -> Unit)?, + pinInput: Boolean + ) = Unit + + override fun queryAnyPassword( + label: TextRef, + preference: StringPreferenceKey, + passwordExplanation: TextRef?, + passwordWarning: TextRef?, + ok: ((String) -> Unit)?, + cancel: (() -> Unit)? + ) = Unit + } +} diff --git a/core/ui/src/test/kotlin/app/aaps/core/ui/compose/dialogs/QueryAnyPasswordDialogTest.kt b/core/ui/src/androidHostTest/kotlin/app/aaps/core/ui/compose/dialogs/QueryAnyPasswordDialogTest.kt similarity index 100% rename from core/ui/src/test/kotlin/app/aaps/core/ui/compose/dialogs/QueryAnyPasswordDialogTest.kt rename to core/ui/src/androidHostTest/kotlin/app/aaps/core/ui/compose/dialogs/QueryAnyPasswordDialogTest.kt diff --git a/core/ui/src/test/kotlin/app/aaps/core/ui/compose/dialogs/QueryPasswordDialogTest.kt b/core/ui/src/androidHostTest/kotlin/app/aaps/core/ui/compose/dialogs/QueryPasswordDialogTest.kt similarity index 100% rename from core/ui/src/test/kotlin/app/aaps/core/ui/compose/dialogs/QueryPasswordDialogTest.kt rename to core/ui/src/androidHostTest/kotlin/app/aaps/core/ui/compose/dialogs/QueryPasswordDialogTest.kt diff --git a/core/ui/src/test/kotlin/app/aaps/core/ui/compose/dialogs/SetPasswordDialogTest.kt b/core/ui/src/androidHostTest/kotlin/app/aaps/core/ui/compose/dialogs/SetPasswordDialogTest.kt similarity index 100% rename from core/ui/src/test/kotlin/app/aaps/core/ui/compose/dialogs/SetPasswordDialogTest.kt rename to core/ui/src/androidHostTest/kotlin/app/aaps/core/ui/compose/dialogs/SetPasswordDialogTest.kt diff --git a/core/ui/src/test/kotlin/app/aaps/core/ui/compose/dialogs/ThreeButtonDialogTest.kt b/core/ui/src/androidHostTest/kotlin/app/aaps/core/ui/compose/dialogs/ThreeButtonDialogTest.kt similarity index 92% rename from core/ui/src/test/kotlin/app/aaps/core/ui/compose/dialogs/ThreeButtonDialogTest.kt rename to core/ui/src/androidHostTest/kotlin/app/aaps/core/ui/compose/dialogs/ThreeButtonDialogTest.kt index 8189979eb3f4..cc7c0f706ae6 100644 --- a/core/ui/src/test/kotlin/app/aaps/core/ui/compose/dialogs/ThreeButtonDialogTest.kt +++ b/core/ui/src/androidHostTest/kotlin/app/aaps/core/ui/compose/dialogs/ThreeButtonDialogTest.kt @@ -61,7 +61,9 @@ class ThreeButtonDialogTest { @Test fun primary_firesOnPrimaryOnly() { - var primary = 0; var secondary = 0; var dismiss = 0 + var primary = 0; + var secondary = 0; + var dismiss = 0 show(onPrimary = { primary++ }, onSecondary = { secondary++ }, onDismiss = { dismiss++ }) compose.onNodeWithText("End").performClick() assertThat(primary).isEqualTo(1) @@ -71,7 +73,9 @@ class ThreeButtonDialogTest { @Test fun secondary_firesOnSecondaryOnly() { - var primary = 0; var secondary = 0; var dismiss = 0 + var primary = 0; + var secondary = 0; + var dismiss = 0 show(onPrimary = { primary++ }, onSecondary = { secondary++ }, onDismiss = { dismiss++ }) compose.onNodeWithText("Skip to Cooldown").performClick() assertThat(secondary).isEqualTo(1) @@ -81,7 +85,9 @@ class ThreeButtonDialogTest { @Test fun cancel_firesOnDismissOnly() { - var primary = 0; var secondary = 0; var dismiss = 0 + var primary = 0; + var secondary = 0; + var dismiss = 0 show(onPrimary = { primary++ }, onSecondary = { secondary++ }, onDismiss = { dismiss++ }) compose.onNodeWithText(cancelLabel).performClick() assertThat(dismiss).isEqualTo(1) diff --git a/core/ui/src/test/kotlin/app/aaps/core/ui/compose/dialogs/TimePickerModalTest.kt b/core/ui/src/androidHostTest/kotlin/app/aaps/core/ui/compose/dialogs/TimePickerModalTest.kt similarity index 100% rename from core/ui/src/test/kotlin/app/aaps/core/ui/compose/dialogs/TimePickerModalTest.kt rename to core/ui/src/androidHostTest/kotlin/app/aaps/core/ui/compose/dialogs/TimePickerModalTest.kt diff --git a/core/ui/src/test/kotlin/app/aaps/core/ui/compose/dialogs/UnifiedAuthDialogTest.kt b/core/ui/src/androidHostTest/kotlin/app/aaps/core/ui/compose/dialogs/UnifiedAuthDialogTest.kt similarity index 100% rename from core/ui/src/test/kotlin/app/aaps/core/ui/compose/dialogs/UnifiedAuthDialogTest.kt rename to core/ui/src/androidHostTest/kotlin/app/aaps/core/ui/compose/dialogs/UnifiedAuthDialogTest.kt diff --git a/core/ui/src/test/kotlin/app/aaps/core/ui/compose/dialogs/ValueInputDialogTest.kt b/core/ui/src/androidHostTest/kotlin/app/aaps/core/ui/compose/dialogs/ValueInputDialogTest.kt similarity index 97% rename from core/ui/src/test/kotlin/app/aaps/core/ui/compose/dialogs/ValueInputDialogTest.kt rename to core/ui/src/androidHostTest/kotlin/app/aaps/core/ui/compose/dialogs/ValueInputDialogTest.kt index a41e865af02a..1788e5d3d7b9 100644 --- a/core/ui/src/test/kotlin/app/aaps/core/ui/compose/dialogs/ValueInputDialogTest.kt +++ b/core/ui/src/androidHostTest/kotlin/app/aaps/core/ui/compose/dialogs/ValueInputDialogTest.kt @@ -8,6 +8,7 @@ import androidx.compose.ui.test.junit4.v2.createComposeRule import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick import androidx.compose.ui.test.performTextReplacement +import app.aaps.core.keys.interfaces.TextRef import app.aaps.core.ui.R import com.google.common.truth.Truth.assertThat import org.junit.Before @@ -48,7 +49,7 @@ class ValueInputDialogTest { currentValue = currentValue, valueRange = 0.0..10.0, label = "Insulin", - unitLabel = "U", + unitLabel = TextRef.Literal("U"), onValueConfirm = onValueConfirm, onDismiss = onDismiss ) diff --git a/core/ui/src/test/kotlin/app/aaps/core/ui/compose/dialogs/YesNoCancelDialogTest.kt b/core/ui/src/androidHostTest/kotlin/app/aaps/core/ui/compose/dialogs/YesNoCancelDialogTest.kt similarity index 92% rename from core/ui/src/test/kotlin/app/aaps/core/ui/compose/dialogs/YesNoCancelDialogTest.kt rename to core/ui/src/androidHostTest/kotlin/app/aaps/core/ui/compose/dialogs/YesNoCancelDialogTest.kt index 22160d80bfcc..b60b594b7aec 100644 --- a/core/ui/src/test/kotlin/app/aaps/core/ui/compose/dialogs/YesNoCancelDialogTest.kt +++ b/core/ui/src/androidHostTest/kotlin/app/aaps/core/ui/compose/dialogs/YesNoCancelDialogTest.kt @@ -63,7 +63,9 @@ class YesNoCancelDialogTest { @Test fun yes_firesOnYesOnly() { - var yes = 0; var no = 0; var cancel = 0 + var yes = 0; + var no = 0; + var cancel = 0 show(onYes = { yes++ }, onNo = { no++ }, onCancel = { cancel++ }) compose.onNodeWithText(yesLabel).performClick() assertThat(yes).isEqualTo(1) @@ -73,7 +75,9 @@ class YesNoCancelDialogTest { @Test fun no_firesOnNoOnly() { - var yes = 0; var no = 0; var cancel = 0 + var yes = 0; + var no = 0; + var cancel = 0 show(onYes = { yes++ }, onNo = { no++ }, onCancel = { cancel++ }) compose.onNodeWithText(noLabel).performClick() assertThat(no).isEqualTo(1) @@ -83,7 +87,9 @@ class YesNoCancelDialogTest { @Test fun cancel_firesOnCancelOnly() { - var yes = 0; var no = 0; var cancel = 0 + var yes = 0; + var no = 0; + var cancel = 0 show(onYes = { yes++ }, onNo = { no++ }, onCancel = { cancel++ }) compose.onNodeWithText(cancelLabel).performClick() assertThat(cancel).isEqualTo(1) @@ -117,7 +123,9 @@ class YesNoCancelDialogTest { @Test fun annotatedString_yes_firesOnYesOnly() { - var yes = 0; var no = 0; var cancel = 0 + var yes = 0; + var no = 0; + var cancel = 0 showAnnotated(onYes = { yes++ }, onNo = { no++ }, onCancel = { cancel++ }) compose.onNodeWithText(yesLabel).performClick() assertThat(yes).isEqualTo(1) @@ -127,7 +135,9 @@ class YesNoCancelDialogTest { @Test fun annotatedString_no_firesOnNoOnly() { - var yes = 0; var no = 0; var cancel = 0 + var yes = 0; + var no = 0; + var cancel = 0 showAnnotated(onYes = { yes++ }, onNo = { no++ }, onCancel = { cancel++ }) compose.onNodeWithText(noLabel).performClick() assertThat(no).isEqualTo(1) @@ -137,7 +147,9 @@ class YesNoCancelDialogTest { @Test fun annotatedString_cancel_firesOnCancelOnly() { - var yes = 0; var no = 0; var cancel = 0 + var yes = 0; + var no = 0; + var cancel = 0 showAnnotated(onYes = { yes++ }, onNo = { no++ }, onCancel = { cancel++ }) compose.onNodeWithText(cancelLabel).performClick() assertThat(cancel).isEqualTo(1) diff --git a/core/ui/src/test/kotlin/app/aaps/core/ui/compose/navigation/ElementTypeStyleTest.kt b/core/ui/src/androidHostTest/kotlin/app/aaps/core/ui/compose/navigation/ElementTypeStyleTest.kt similarity index 93% rename from core/ui/src/test/kotlin/app/aaps/core/ui/compose/navigation/ElementTypeStyleTest.kt rename to core/ui/src/androidHostTest/kotlin/app/aaps/core/ui/compose/navigation/ElementTypeStyleTest.kt index 08184e5b9a84..76fc846e5a69 100644 --- a/core/ui/src/test/kotlin/app/aaps/core/ui/compose/navigation/ElementTypeStyleTest.kt +++ b/core/ui/src/androidHostTest/kotlin/app/aaps/core/ui/compose/navigation/ElementTypeStyleTest.kt @@ -51,13 +51,13 @@ class ElementTypeStyleTest { @Test fun typesWithZeroLabel_matchDocumentedDynamicSet() { - val actualZero = ElementType.entries.filter { it.labelResId() == 0 }.toSet() + val actualZero = ElementType.entries.filter { it.label() == null }.toSet() assertThat(actualZero).isEqualTo(typesWithDynamicLabel) } @Test fun typesWithZeroDescription_matchDocumentedSet() { - val actualZero = ElementType.entries.filter { it.descriptionResId() == 0 }.toSet() + val actualZero = ElementType.entries.filter { it.description() == null }.toSet() assertThat(actualZero).isEqualTo(typesWithoutDescription) } @@ -65,7 +65,7 @@ class ElementTypeStyleTest { fun searchableEntries_haveDisplayableLabel() { // A search hit with no label and no dynamic-label fallback would show as a blank row. val blank = ElementType.searchableEntries.filter { - it.labelResId() == 0 && it !in typesWithDynamicLabel + it.label() == null && it !in typesWithDynamicLabel } assertThat(blank).isEmpty() } diff --git a/core/ui/src/test/kotlin/app/aaps/core/ui/compose/navigation/ElementTypeTest.kt b/core/ui/src/androidHostTest/kotlin/app/aaps/core/ui/compose/navigation/ElementTypeTest.kt similarity index 100% rename from core/ui/src/test/kotlin/app/aaps/core/ui/compose/navigation/ElementTypeTest.kt rename to core/ui/src/androidHostTest/kotlin/app/aaps/core/ui/compose/navigation/ElementTypeTest.kt diff --git a/core/ui/src/test/kotlin/app/aaps/core/ui/compose/preference/AdaptivePreferenceItemsTest.kt b/core/ui/src/androidHostTest/kotlin/app/aaps/core/ui/compose/preference/AdaptivePreferenceItemsTest.kt similarity index 100% rename from core/ui/src/test/kotlin/app/aaps/core/ui/compose/preference/AdaptivePreferenceItemsTest.kt rename to core/ui/src/androidHostTest/kotlin/app/aaps/core/ui/compose/preference/AdaptivePreferenceItemsTest.kt index f9c2e3da0fea..80a9473a6188 100644 --- a/core/ui/src/test/kotlin/app/aaps/core/ui/compose/preference/AdaptivePreferenceItemsTest.kt +++ b/core/ui/src/androidHostTest/kotlin/app/aaps/core/ui/compose/preference/AdaptivePreferenceItemsTest.kt @@ -5,7 +5,6 @@ import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.junit4.v2.createComposeRule import androidx.compose.ui.test.onRoot -import app.aaps.core.interfaces.configuration.Config as AppConfig import app.aaps.core.keys.BooleanKey import app.aaps.core.keys.DoubleKey import app.aaps.core.keys.IntKey @@ -20,6 +19,7 @@ import org.mockito.kotlin.mock import org.robolectric.RobolectricTestRunner import org.robolectric.annotation.Config import org.robolectric.annotation.GraphicsMode +import app.aaps.core.interfaces.configuration.Config as AppConfig /** * Robolectric render tests for the Adaptive*PreferenceItem composables (key-driven rows), mirroring diff --git a/core/ui/src/test/kotlin/app/aaps/core/ui/compose/preference/MorePreferenceComponentsTest.kt b/core/ui/src/androidHostTest/kotlin/app/aaps/core/ui/compose/preference/MorePreferenceComponentsTest.kt similarity index 96% rename from core/ui/src/test/kotlin/app/aaps/core/ui/compose/preference/MorePreferenceComponentsTest.kt rename to core/ui/src/androidHostTest/kotlin/app/aaps/core/ui/compose/preference/MorePreferenceComponentsTest.kt index d9660f27f700..42a2242595b5 100644 --- a/core/ui/src/test/kotlin/app/aaps/core/ui/compose/preference/MorePreferenceComponentsTest.kt +++ b/core/ui/src/androidHostTest/kotlin/app/aaps/core/ui/compose/preference/MorePreferenceComponentsTest.kt @@ -8,11 +8,11 @@ import androidx.compose.ui.test.junit4.v2.createComposeRule import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.onRoot import androidx.compose.ui.test.performClick -import app.aaps.core.interfaces.configuration.Config as AppConfig import app.aaps.core.interfaces.profile.ProfileUtil import app.aaps.core.keys.StringKey import app.aaps.core.keys.UnitDoubleKey import app.aaps.core.keys.interfaces.Preferences +import app.aaps.core.keys.interfaces.TextRef import app.aaps.core.ui.compose.LocalConfig import app.aaps.core.ui.compose.LocalPreferences import app.aaps.core.ui.compose.LocalProfileUtil @@ -24,6 +24,7 @@ import org.mockito.kotlin.mock import org.robolectric.RobolectricTestRunner import org.robolectric.annotation.Config import org.robolectric.annotation.GraphicsMode +import app.aaps.core.interfaces.configuration.Config as AppConfig import app.aaps.core.ui.R as CoreUiR /** Robolectric render tests for the remaining preference composables (adaptive variants + base widgets). */ @@ -111,7 +112,7 @@ class MorePreferenceComponentsTest { fun clickableCategoryHeaderRendersTitle() { render { ClickablePreferenceCategoryHeader( - titleResId = CoreUiR.string.treatments, + title = TextRef.AndroidRes(CoreUiR.string.treatments), expanded = false, onToggle = {} ) @@ -143,7 +144,7 @@ class MorePreferenceComponentsTest { fun collapsibleCardShowsContentWhenExpanded() { render { CollapsibleCardSectionContent( - titleResId = CoreUiR.string.treatments, + title = TextRef.AndroidRes(CoreUiR.string.treatments), expanded = true, onToggle = {}, content = { Text("cardbody") } diff --git a/core/ui/src/test/kotlin/app/aaps/core/ui/compose/preference/PreferenceComponentsTest.kt b/core/ui/src/androidHostTest/kotlin/app/aaps/core/ui/compose/preference/PreferenceComponentsTest.kt similarity index 100% rename from core/ui/src/test/kotlin/app/aaps/core/ui/compose/preference/PreferenceComponentsTest.kt rename to core/ui/src/androidHostTest/kotlin/app/aaps/core/ui/compose/preference/PreferenceComponentsTest.kt index fc114a0d03b8..9ada2d3767bc 100644 --- a/core/ui/src/test/kotlin/app/aaps/core/ui/compose/preference/PreferenceComponentsTest.kt +++ b/core/ui/src/androidHostTest/kotlin/app/aaps/core/ui/compose/preference/PreferenceComponentsTest.kt @@ -10,7 +10,6 @@ import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.junit4.v2.createComposeRule import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick -import app.aaps.core.interfaces.configuration.Config as AppConfig import app.aaps.core.keys.interfaces.Preferences import app.aaps.core.ui.compose.LocalConfig import app.aaps.core.ui.compose.LocalPreferences @@ -22,6 +21,7 @@ import org.mockito.kotlin.mock import org.robolectric.RobolectricTestRunner import org.robolectric.annotation.Config import org.robolectric.annotation.GraphicsMode +import app.aaps.core.interfaces.configuration.Config as AppConfig /** Robolectric render tests for the base preference composables, mirroring their previews. */ @RunWith(RobolectricTestRunner::class) diff --git a/core/ui/src/test/kotlin/app/aaps/core/ui/compose/preference/PreferenceSheetContentTest.kt b/core/ui/src/androidHostTest/kotlin/app/aaps/core/ui/compose/preference/PreferenceSheetContentTest.kt similarity index 100% rename from core/ui/src/test/kotlin/app/aaps/core/ui/compose/preference/PreferenceSheetContentTest.kt rename to core/ui/src/androidHostTest/kotlin/app/aaps/core/ui/compose/preference/PreferenceSheetContentTest.kt index 8aee04ecbdbb..c439671a6914 100644 --- a/core/ui/src/test/kotlin/app/aaps/core/ui/compose/preference/PreferenceSheetContentTest.kt +++ b/core/ui/src/androidHostTest/kotlin/app/aaps/core/ui/compose/preference/PreferenceSheetContentTest.kt @@ -5,7 +5,6 @@ import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.junit4.v2.createComposeRule import androidx.compose.ui.test.onRoot -import app.aaps.core.interfaces.configuration.Config as AppConfig import app.aaps.core.interfaces.profile.ProfileUtil import app.aaps.core.keys.interfaces.Preferences import app.aaps.core.ui.compose.LocalConfig @@ -18,6 +17,7 @@ import org.mockito.kotlin.mock import org.robolectric.RobolectricTestRunner import org.robolectric.annotation.Config import org.robolectric.annotation.GraphicsMode +import app.aaps.core.interfaces.configuration.Config as AppConfig import app.aaps.core.ui.R as CoreUiR /** Robolectric render test for [PreferenceSheetContent]: renders a preference sub-screen definition. */ diff --git a/core/ui/src/test/kotlin/app/aaps/core/ui/compose/pump/PumpOverviewScreenTest.kt b/core/ui/src/androidHostTest/kotlin/app/aaps/core/ui/compose/pump/PumpOverviewScreenTest.kt similarity index 97% rename from core/ui/src/test/kotlin/app/aaps/core/ui/compose/pump/PumpOverviewScreenTest.kt rename to core/ui/src/androidHostTest/kotlin/app/aaps/core/ui/compose/pump/PumpOverviewScreenTest.kt index 36220abaff33..1063f5dc65be 100644 --- a/core/ui/src/test/kotlin/app/aaps/core/ui/compose/pump/PumpOverviewScreenTest.kt +++ b/core/ui/src/androidHostTest/kotlin/app/aaps/core/ui/compose/pump/PumpOverviewScreenTest.kt @@ -4,6 +4,7 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.junit4.v2.createComposeRule import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.text.AnnotatedString import app.aaps.core.ui.compose.StatusLevel import org.junit.Rule import org.junit.Test @@ -51,7 +52,7 @@ class PumpOverviewScreenTest { fun showsQueueStatus() { val state = PumpOverviewUiState( statusBanner = StatusBanner(text = "Idle"), - queueStatus = "Reading status" + queueStatus = AnnotatedString("Reading status") ) compose.setContent { MaterialTheme { PumpOverviewScreen(state = state) } diff --git a/core/ui/src/androidHostTest/kotlin/app/aaps/core/ui/compose/siteRotation/BodyViewContainsPointTest.kt b/core/ui/src/androidHostTest/kotlin/app/aaps/core/ui/compose/siteRotation/BodyViewContainsPointTest.kt new file mode 100644 index 000000000000..c40b5ff93597 --- /dev/null +++ b/core/ui/src/androidHostTest/kotlin/app/aaps/core/ui/compose/siteRotation/BodyViewContainsPointTest.kt @@ -0,0 +1,70 @@ +package app.aaps.core.ui.compose.siteRotation + +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.graphics.Path +import com.google.common.truth.Truth.assertThat +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import org.robolectric.annotation.GraphicsMode + +/** + * Pins the body-diagram hit test, which decides which site a tap selects. + * + * `containsPoint` used to be built on `android.graphics.Region`; it is now a Compose `Path` + * intersection so the code can leave Android. A hit test that is subtly wrong does not crash and + * does not fail a build - it just selects the wrong infusion site, or none - so it is worth pinning + * rather than eyeballing. + * + * Robolectric because `androidx.compose.ui.graphics.Path` needs a real graphics implementation. + */ +@RunWith(RobolectricTestRunner::class) +@GraphicsMode(GraphicsMode.Mode.NATIVE) +@Config(sdk = [35]) +class BodyViewContainsPointTest { + + private fun square(left: Float, top: Float, right: Float, bottom: Float) = + Path().apply { addRect(Rect(left, top, right, bottom)) } + + @Test + fun `a point well inside is a hit`() { + val zone = square(10f, 10f, 20f, 20f) + assertThat(zone.containsPoint(15f, 15f)).isTrue() + assertThat(zone.containsPoint(11f, 11f)).isTrue() + assertThat(zone.containsPoint(19f, 19f)).isTrue() + } + + @Test + fun `a point outside is not a hit`() { + val zone = square(10f, 10f, 20f, 20f) + assertThat(zone.containsPoint(5f, 15f)).isFalse() + assertThat(zone.containsPoint(25f, 15f)).isFalse() + assertThat(zone.containsPoint(15f, 5f)).isFalse() + assertThat(zone.containsPoint(15f, 25f)).isFalse() + } + + @Test + fun `a point far away is not a hit`() { + val zone = square(10f, 10f, 20f, 20f) + assertThat(zone.containsPoint(0f, 0f)).isFalse() + assertThat(zone.containsPoint(1000f, 1000f)).isFalse() + } + + @Test + fun `neighbouring zones do not both claim the same tap`() { + // The body diagram is a list of adjacent zones and the first hit wins, so two zones + // answering true for one tap would make the selection depend on list order. + val left = square(0f, 0f, 10f, 10f) + val right = square(20f, 0f, 30f, 10f) + assertThat(left.containsPoint(5f, 5f)).isTrue() + assertThat(right.containsPoint(5f, 5f)).isFalse() + assertThat(left.containsPoint(25f, 5f)).isFalse() + assertThat(right.containsPoint(25f, 5f)).isTrue() + } + + @Test + fun `an empty path never claims a tap`() { + assertThat(Path().containsPoint(0f, 0f)).isFalse() + } +} diff --git a/core/ui/src/androidHostTest/kotlin/app/aaps/core/ui/elements/WeekDayTest.kt b/core/ui/src/androidHostTest/kotlin/app/aaps/core/ui/elements/WeekDayTest.kt new file mode 100644 index 000000000000..c95556ba864f --- /dev/null +++ b/core/ui/src/androidHostTest/kotlin/app/aaps/core/ui/elements/WeekDayTest.kt @@ -0,0 +1,68 @@ +package app.aaps.core.ui.elements + +import com.google.common.truth.Truth.assertThat +import org.junit.jupiter.api.Test +import java.util.Calendar +import java.util.TimeZone + +/** + * Pins the weekday numbering, because getting it wrong is silent and wrong by exactly one day. + * + * `toCalendarInt()` / `getSelectedDays()` hand these numbers to `:plugins:automation` and + * `:plugins:aps`, and automation triggers persist them - so the values are a stored contract, not an + * implementation detail. They used to come from `java.util.Calendar`; they are now written out so + * the class does not need the JVM. This test is what makes that swap safe: it compares the literals + * against the real `Calendar` constants. + */ +class WeekDayTest { + + @Test + fun `calendar ints match java util Calendar exactly`() { + assertThat(WeekDay.DayOfWeek.MONDAY.toCalendarInt()).isEqualTo(Calendar.MONDAY) + assertThat(WeekDay.DayOfWeek.TUESDAY.toCalendarInt()).isEqualTo(Calendar.TUESDAY) + assertThat(WeekDay.DayOfWeek.WEDNESDAY.toCalendarInt()).isEqualTo(Calendar.WEDNESDAY) + assertThat(WeekDay.DayOfWeek.THURSDAY.toCalendarInt()).isEqualTo(Calendar.THURSDAY) + assertThat(WeekDay.DayOfWeek.FRIDAY.toCalendarInt()).isEqualTo(Calendar.FRIDAY) + assertThat(WeekDay.DayOfWeek.SATURDAY.toCalendarInt()).isEqualTo(Calendar.SATURDAY) + assertThat(WeekDay.DayOfWeek.SUNDAY.toCalendarInt()).isEqualTo(Calendar.SUNDAY) + } + + @Test + fun `fromCalendarInt round trips`() { + WeekDay.DayOfWeek.entries.forEach { day -> + assertThat(WeekDay.DayOfWeek.fromCalendarInt(day.toCalendarInt())).isEqualTo(day) + } + } + + @Test + fun `a timestamp resolves to the same weekday as Calendar does`() { + // Compared against Calendar in the default zone rather than asserted as a literal, so the + // test says what it means - "the new implementation agrees with the old one" - and keeps + // saying it wherever it runs. + val day = 24 * 60 * 60 * 1000L + var t = 1_767_225_600_000L // 2026-01-01T00:00:00Z + repeat(14) { + val expected = Calendar.getInstance(TimeZone.getDefault()).also { c -> c.timeInMillis = t } + .get(Calendar.DAY_OF_WEEK) + + val weekDay = WeekDay() + WeekDay.DayOfWeek.entries.forEach { weekDay[it] = false } + weekDay[WeekDay.DayOfWeek.fromCalendarInt(expected)] = true + + assertThat(weekDay.isSet(t)).isTrue() + t += day + } + } + + @Test + fun `only the matching day is set`() { + val monday = 1_767_398_400_000L // 2026-01-03T00:00:00Z, a Saturday in UTC + val weekDay = WeekDay() + val actual = WeekDay.DayOfWeek.entries.filter { day -> + weekDay.setAll(false) + weekDay[day] = true + weekDay.isSet(monday) + } + assertThat(actual).hasSize(1) + } +} diff --git a/core/ui/src/main/AndroidManifest.xml b/core/ui/src/androidMain/AndroidManifest.xml similarity index 100% rename from core/ui/src/main/AndroidManifest.xml rename to core/ui/src/androidMain/AndroidManifest.xml diff --git a/core/ui/src/androidMain/kotlin/app/aaps/core/ui/AlarmSoundResources.kt b/core/ui/src/androidMain/kotlin/app/aaps/core/ui/AlarmSoundResources.kt new file mode 100644 index 000000000000..cc467890254d --- /dev/null +++ b/core/ui/src/androidMain/kotlin/app/aaps/core/ui/AlarmSoundResources.kt @@ -0,0 +1,21 @@ +package app.aaps.core.ui + +import androidx.annotation.RawRes +import app.aaps.core.interfaces.notifications.AlarmSound + +/** + * The Android resource behind an [AlarmSound]. + * + * This is the single place that knows sounds are `R.raw` files at all, which is what lets every + * declaring interface stay free of Android. It lives in `:core:ui` because that is the module owning + * the audio files; the `when` is exhaustive, so a new [AlarmSound] fails to compile until it is + * given a file here. + */ +@get:RawRes +val AlarmSound.rawRes: Int + get() = when (this) { + AlarmSound.ALARM -> R.raw.alarm + AlarmSound.URGENT_ALARM -> R.raw.urgentalarm + AlarmSound.ERROR -> R.raw.error + AlarmSound.BOLUS_ERROR -> R.raw.boluserror + } diff --git a/core/ui/src/androidMain/kotlin/app/aaps/core/ui/compose/FontPadding.android.kt b/core/ui/src/androidMain/kotlin/app/aaps/core/ui/compose/FontPadding.android.kt new file mode 100644 index 000000000000..849a161e9768 --- /dev/null +++ b/core/ui/src/androidMain/kotlin/app/aaps/core/ui/compose/FontPadding.android.kt @@ -0,0 +1,5 @@ +package app.aaps.core.ui.compose + +import androidx.compose.ui.text.PlatformTextStyle + +actual fun noFontPaddingPlatformStyle(): PlatformTextStyle? = PlatformTextStyle(includeFontPadding = false) diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/ImportSummaryComponents.kt b/core/ui/src/androidMain/kotlin/app/aaps/core/ui/compose/ImportSummaryComponents.kt similarity index 97% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/ImportSummaryComponents.kt rename to core/ui/src/androidMain/kotlin/app/aaps/core/ui/compose/ImportSummaryComponents.kt index e7eb4dff8a81..2c715395a4bd 100644 --- a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/ImportSummaryComponents.kt +++ b/core/ui/src/androidMain/kotlin/app/aaps/core/ui/compose/ImportSummaryComponents.kt @@ -26,6 +26,7 @@ import app.aaps.core.interfaces.maintenance.PrefMetadata import app.aaps.core.interfaces.maintenance.PrefsMetadataKey import app.aaps.core.interfaces.rx.bus.RxBus import app.aaps.core.interfaces.rx.events.EventShowSnackbar +import app.aaps.core.ui.UiStrings /** * A single metadata summary item row with status icon, category icon, and formatted text. @@ -91,7 +92,7 @@ fun ImportSummaryItem( Spacer(modifier = Modifier.width(4.dp)) Text( - text = metaKey.formatForDisplay(context, metaEntry.value), + text = stringResource(metaKey.formatForDisplay(metaEntry.value)), style = MaterialTheme.typography.bodySmall, color = textColor, modifier = Modifier.weight(1f) @@ -150,7 +151,7 @@ private fun ImportDetailsDialog( }, confirmButton = { TextButton(onClick = onDismiss) { - Text(stringResource(android.R.string.ok)) + Text(stringResource(UiStrings.ok)) } }, properties = androidx.compose.ui.window.DialogProperties(dismissOnBackPress = true, dismissOnClickOutside = true) diff --git a/core/ui/src/androidMain/kotlin/app/aaps/core/ui/compose/PlatformTheme.android.kt b/core/ui/src/androidMain/kotlin/app/aaps/core/ui/compose/PlatformTheme.android.kt new file mode 100644 index 000000000000..1eb3402e3145 --- /dev/null +++ b/core/ui/src/androidMain/kotlin/app/aaps/core/ui/compose/PlatformTheme.android.kt @@ -0,0 +1,24 @@ +package app.aaps.core.ui.compose + +import android.app.Activity +import androidx.compose.runtime.Composable +import androidx.compose.runtime.SideEffect +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.LocalView +import androidx.core.view.WindowInsetsControllerCompat + +@Composable +actual fun SystemBarAppearance(isDark: Boolean) { + val view = LocalView.current + if (!view.isInEditMode) { + SideEffect { + val window = (view.context as Activity).window + val controller = WindowInsetsControllerCompat(window, view) + controller.isAppearanceLightStatusBars = !isDark + controller.isAppearanceLightNavigationBars = !isDark + } + } +} + +@Composable +actual fun smallestScreenWidthDp(): Int = LocalConfiguration.current.smallestScreenWidthDp diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/ProtectionHost.kt b/core/ui/src/androidMain/kotlin/app/aaps/core/ui/compose/ProtectionHost.kt similarity index 92% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/ProtectionHost.kt rename to core/ui/src/androidMain/kotlin/app/aaps/core/ui/compose/ProtectionHost.kt index 8ef0cc5ccb52..7f8880f6348f 100644 --- a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/ProtectionHost.kt +++ b/core/ui/src/androidMain/kotlin/app/aaps/core/ui/compose/ProtectionHost.kt @@ -37,8 +37,8 @@ fun ProtectionHost( protectionCheck: ProtectionCheck, preferences: Preferences, checkPassword: (password: String, hash: String) -> Boolean, - showBiometric: (FragmentActivity, Int, () -> Unit, () -> Unit, () -> Unit) -> Unit, - showBiometricSimple: (FragmentActivity, Int, () -> Unit, () -> Unit, () -> Unit) -> Unit = showBiometric + showBiometric: (FragmentActivity, String, () -> Unit, () -> Unit, () -> Unit) -> Unit, + showBiometricSimple: (FragmentActivity, String, () -> Unit, () -> Unit, () -> Unit) -> Unit = showBiometric ) { val context = LocalContext.current val activity = context as? FragmentActivity @@ -48,13 +48,15 @@ fun ProtectionHost( authRequest?.let { req -> var showDialog by remember(req.id) { mutableStateOf(!req.hasBiometric) } + // Read while still in composition: stringResource is @Composable, the biometric callback is not. + val biometricTitle = stringResource(app.aaps.core.ui.R.string.biometric_title) if (req.hasBiometric && !showDialog) { LaunchedEffect(req.id) { if (activity != null) { showBiometricSimple( activity, - app.aaps.core.ui.R.string.biometric_title, + biometricTitle, { // Biometric success → grant highest level that uses biometric protectionCheck.completeAuthRequest( @@ -93,6 +95,7 @@ fun ProtectionHost( val request by protectionCheck.pendingRequest.collectAsStateWithLifecycle() request?.let { req -> + val reqTitle = stringResource(req.title) when (req.type) { ProtectionType.NONE -> { LaunchedEffect(req.id) { @@ -105,7 +108,7 @@ fun ProtectionHost( if (activity != null) { showBiometric( activity, - req.titleRes, + reqTitle, { protectionCheck.completeRequest(req.id, ProtectionResult.GRANTED) }, { protectionCheck.completeRequest(req.id, ProtectionResult.CANCELLED) }, { protectionCheck.completeRequest(req.id, ProtectionResult.DENIED) } @@ -119,7 +122,7 @@ fun ProtectionHost( ProtectionType.MASTER_PASSWORD -> { val storedHash = preferences.get(StringKey.ProtectionMasterPassword) QueryPasswordDialog( - title = stringResource(req.titleRes), + title = stringResource(req.title), pinInput = false, onConfirm = { enteredPassword -> if (checkPassword(enteredPassword, storedHash)) { @@ -141,7 +144,7 @@ fun ProtectionHost( } val storedHash = preferences.get(passwordKey) QueryPasswordDialog( - title = stringResource(req.titleRes), + title = stringResource(req.title), pinInput = false, onConfirm = { enteredPassword -> if (checkPassword(enteredPassword, storedHash)) { @@ -163,7 +166,7 @@ fun ProtectionHost( } val storedHash = preferences.get(pinKey) QueryPasswordDialog( - title = stringResource(req.titleRes), + title = stringResource(req.title), pinInput = true, onConfirm = { enteredPin -> if (checkPassword(enteredPin, storedHash)) { diff --git a/core/ui/src/androidMain/kotlin/app/aaps/core/ui/compose/TextRefResource.android.kt b/core/ui/src/androidMain/kotlin/app/aaps/core/ui/compose/TextRefResource.android.kt new file mode 100644 index 000000000000..bb8f29e090bb --- /dev/null +++ b/core/ui/src/androidMain/kotlin/app/aaps/core/ui/compose/TextRefResource.android.kt @@ -0,0 +1,51 @@ +package app.aaps.core.ui.compose + +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.stringResource +import app.aaps.core.interfaces.InterfacesStringIds +import app.aaps.core.interfaces.resources.TextRefIdRegistry +import app.aaps.core.keys.KeysStringIds +import app.aaps.core.keys.interfaces.TextRef +import app.aaps.core.ui.UiStringIds + +/** + * Both resource forms end up in the platform `stringResource`. [TextRef.AndroidRes] carries the id + * already; [TextRef.Named] carries the name from `strings.xml` and is turned into an id first, so + * Android keeps doing its own locale matching in both cases. + */ +@Composable +actual fun stringResource(ref: TextRef): String = when (ref) { + is TextRef.Literal -> ref.text + is TextRef.AndroidRes -> + if (ref.args.isEmpty()) stringResource(ref.id) + else stringResource(ref.id, *ref.args.toTypedArray()) + + is TextRef.Named -> { + val id = androidIdOf(ref) + when { + id == null -> ref.name + ref.args.isEmpty() -> stringResource(id) + else -> stringResource(id, *ref.args.toTypedArray()) + } + } +} + +/** + * Which module's generated id map to look in. + * + * A name is only unique within one module, so this dispatches on the owner rather than trying the + * maps in some order - `ns_wifi_ssids` exists in both with different translations, and guessing + * would silently pick one. + * + * `:core:ui` can see its own three maps directly because it depends on `:core:keys` and + * `:core:interfaces`. Everything else - the plugin and pump modules, which `:core:ui` sits below - + * arrives through [TextRefIdRegistry], registered from `:app`. Without that fallback an unknown + * owner resolves to null and the raw name is drawn, which is how `format_carbs` once appeared on the + * overview instead of "12 g". + */ +private fun androidIdOf(ref: TextRef.Named): Int? = when (ref.owner) { + "keys" -> KeysStringIds.idOf(ref.name) + "ui" -> UiStringIds.idOf(ref.name) + "interfaces" -> InterfacesStringIds.idOf(ref.name) + else -> TextRefIdRegistry.idOf(ref) +} diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/pump/BlePreCheckHost.kt b/core/ui/src/androidMain/kotlin/app/aaps/core/ui/compose/pump/BlePreCheckHost.kt similarity index 82% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/pump/BlePreCheckHost.kt rename to core/ui/src/androidMain/kotlin/app/aaps/core/ui/compose/pump/BlePreCheckHost.kt index 22efefa9786b..aa427a52ddff 100644 --- a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/pump/BlePreCheckHost.kt +++ b/core/ui/src/androidMain/kotlin/app/aaps/core/ui/compose/pump/BlePreCheckHost.kt @@ -7,11 +7,11 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.res.stringResource import app.aaps.core.interfaces.pump.BlePreCheck import app.aaps.core.interfaces.pump.BlePreCheckResult -import app.aaps.core.ui.R +import app.aaps.core.ui.UiStrings import app.aaps.core.ui.compose.dialogs.OkDialog +import app.aaps.core.ui.compose.stringResource import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext @@ -40,15 +40,15 @@ fun BlePreCheckHost( LaunchedEffect(Unit) { checkResult = withContext(Dispatchers.IO) { - blePreCheck.checkBleReady(context) + blePreCheck.checkBleReady() } } when (checkResult) { BlePreCheckResult.BLE_NOT_SUPPORTED -> { OkDialog( - title = stringResource(R.string.message), - message = stringResource(R.string.ble_not_supported), + title = stringResource(UiStrings.message), + message = stringResource(UiStrings.ble_not_supported), onDismiss = { checkResult = null onFailed?.invoke() @@ -58,8 +58,8 @@ fun BlePreCheckHost( BlePreCheckResult.BLE_NOT_ENABLED -> { OkDialog( - title = stringResource(R.string.message), - message = stringResource(R.string.ble_not_enabled), + title = stringResource(UiStrings.message), + message = stringResource(UiStrings.ble_not_enabled), onDismiss = { checkResult = null onFailed?.invoke() @@ -69,8 +69,8 @@ fun BlePreCheckHost( BlePreCheckResult.PERMISSIONS_MISSING -> { OkDialog( - title = stringResource(R.string.message), - message = stringResource(R.string.ble_permissions_missing), + title = stringResource(UiStrings.message), + message = stringResource(UiStrings.ble_permissions_missing), onDismiss = { checkResult = null onFailed?.invoke() diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/pump/BluetoothPermissionsHost.kt b/core/ui/src/androidMain/kotlin/app/aaps/core/ui/compose/pump/BluetoothPermissionsHost.kt similarity index 100% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/pump/BluetoothPermissionsHost.kt rename to core/ui/src/androidMain/kotlin/app/aaps/core/ui/compose/pump/BluetoothPermissionsHost.kt diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/pump/KeepScreenOnEffect.kt b/core/ui/src/androidMain/kotlin/app/aaps/core/ui/compose/pump/KeepScreenOnEffect.kt similarity index 100% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/pump/KeepScreenOnEffect.kt rename to core/ui/src/androidMain/kotlin/app/aaps/core/ui/compose/pump/KeepScreenOnEffect.kt diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/extensions/ContextExtension.kt b/core/ui/src/androidMain/kotlin/app/aaps/core/ui/extensions/ContextExtension.kt similarity index 100% rename from core/ui/src/main/kotlin/app/aaps/core/ui/extensions/ContextExtension.kt rename to core/ui/src/androidMain/kotlin/app/aaps/core/ui/extensions/ContextExtension.kt diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/extensions/UIUtils.kt b/core/ui/src/androidMain/kotlin/app/aaps/core/ui/extensions/UIUtils.kt similarity index 100% rename from core/ui/src/main/kotlin/app/aaps/core/ui/extensions/UIUtils.kt rename to core/ui/src/androidMain/kotlin/app/aaps/core/ui/extensions/UIUtils.kt diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/locale/LocaleHelper.kt b/core/ui/src/androidMain/kotlin/app/aaps/core/ui/locale/LocaleHelper.kt similarity index 73% rename from core/ui/src/main/kotlin/app/aaps/core/ui/locale/LocaleHelper.kt rename to core/ui/src/androidMain/kotlin/app/aaps/core/ui/locale/LocaleHelper.kt index f0153cd24db1..42b98d42cbd4 100644 --- a/core/ui/src/main/kotlin/app/aaps/core/ui/locale/LocaleHelper.kt +++ b/core/ui/src/androidMain/kotlin/app/aaps/core/ui/locale/LocaleHelper.kt @@ -12,13 +12,20 @@ object LocaleHelper { context.getSharedPreferences("${context.packageName}_preferences", Context.MODE_PRIVATE) private fun selectedLanguage(context: Context): String = - // do not use app.aaps.core.keys.R.strings.kay_language = "language" to avoid module dependency + // do not use the key name "language" from :core:keys here, to avoid a module dependency if (defaultSharedPreferences(context).getBoolean("simple_mode", true)) "default" else defaultSharedPreferences(context).getString("language", "default") ?: "default" // injection not possible because of use in attachBaseContext //preferences.get(R.string.key_language, Locale.getDefault().language) + // The language list has offered "dk" for Danish from the start, but "dk" is a country code, + // not a language code. The ISO 639 code is "da", and every Danish translation lives in a + // values-da-rDK folder. A stored "dk" matched no folder, so picking Danish showed the whole + // app in English. The stored value is left as it is, because it is a preference that syncs + // between master and client; it is corrected here, where the Locale is built. + private fun isoLanguage(language: String): String = if (language == "dk") "da" else language + fun currentLocale(context: Context): Locale { val language = selectedLanguage(context) if (language == "default") return Locale.getDefault() @@ -29,7 +36,7 @@ object LocaleHelper { val country = language.substring(3, 5) Locale.Builder().setLanguage(lang).setRegion(country).build() } else { - Locale.Builder().setLanguage(language).build() + Locale.Builder().setLanguage(isoLanguage(language)).build() } } diff --git a/core/ui/src/androidMain/res/drawable/ic_eopatch2_128.xml b/core/ui/src/androidMain/res/drawable/ic_eopatch2_128.xml new file mode 100644 index 000000000000..aa9b076f4d3b --- /dev/null +++ b/core/ui/src/androidMain/res/drawable/ic_eopatch2_128.xml @@ -0,0 +1,43 @@ + + + + + + + + + + + diff --git a/core/ui/src/main/res/drawable/ic_equil_128.png b/core/ui/src/androidMain/res/drawable/ic_equil_128.png similarity index 100% rename from core/ui/src/main/res/drawable/ic_equil_128.png rename to core/ui/src/androidMain/res/drawable/ic_equil_128.png diff --git a/core/ui/src/androidMain/res/drawable/ic_error_red_48dp.xml b/core/ui/src/androidMain/res/drawable/ic_error_red_48dp.xml new file mode 100644 index 000000000000..d6bf9162354f --- /dev/null +++ b/core/ui/src/androidMain/res/drawable/ic_error_red_48dp.xml @@ -0,0 +1,10 @@ + + + diff --git a/core/ui/src/androidMain/res/drawable/ic_medtrum_128.xml b/core/ui/src/androidMain/res/drawable/ic_medtrum_128.xml new file mode 100644 index 000000000000..399a189e4267 --- /dev/null +++ b/core/ui/src/androidMain/res/drawable/ic_medtrum_128.xml @@ -0,0 +1,81 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/core/ui/src/main/res/drawable/ic_notif_aaps.xml b/core/ui/src/androidMain/res/drawable/ic_notif_aaps.xml similarity index 100% rename from core/ui/src/main/res/drawable/ic_notif_aaps.xml rename to core/ui/src/androidMain/res/drawable/ic_notif_aaps.xml diff --git a/core/ui/src/main/res/drawable/notif_icon.png b/core/ui/src/androidMain/res/drawable/notif_icon.png similarity index 100% rename from core/ui/src/main/res/drawable/notif_icon.png rename to core/ui/src/androidMain/res/drawable/notif_icon.png diff --git a/core/ui/src/androidMain/res/drawable/splash_logo.xml b/core/ui/src/androidMain/res/drawable/splash_logo.xml new file mode 100644 index 000000000000..d6fe6ac7716c --- /dev/null +++ b/core/ui/src/androidMain/res/drawable/splash_logo.xml @@ -0,0 +1,90 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/core/ui/src/main/res/mipmap-hdpi/ic_blueowl.png b/core/ui/src/androidMain/res/mipmap-hdpi/ic_blueowl.png similarity index 100% rename from core/ui/src/main/res/mipmap-hdpi/ic_blueowl.png rename to core/ui/src/androidMain/res/mipmap-hdpi/ic_blueowl.png diff --git a/core/ui/src/main/res/mipmap-hdpi/ic_greenowl.png b/core/ui/src/androidMain/res/mipmap-hdpi/ic_greenowl.png similarity index 100% rename from core/ui/src/main/res/mipmap-hdpi/ic_greenowl.png rename to core/ui/src/androidMain/res/mipmap-hdpi/ic_greenowl.png diff --git a/core/ui/src/main/res/mipmap-hdpi/ic_launcher.png b/core/ui/src/androidMain/res/mipmap-hdpi/ic_launcher.png similarity index 100% rename from core/ui/src/main/res/mipmap-hdpi/ic_launcher.png rename to core/ui/src/androidMain/res/mipmap-hdpi/ic_launcher.png diff --git a/core/ui/src/main/res/mipmap-hdpi/ic_launcher_round.png b/core/ui/src/androidMain/res/mipmap-hdpi/ic_launcher_round.png similarity index 100% rename from core/ui/src/main/res/mipmap-hdpi/ic_launcher_round.png rename to core/ui/src/androidMain/res/mipmap-hdpi/ic_launcher_round.png diff --git a/core/ui/src/main/res/mipmap-hdpi/ic_pumpcontrol.png b/core/ui/src/androidMain/res/mipmap-hdpi/ic_pumpcontrol.png similarity index 100% rename from core/ui/src/main/res/mipmap-hdpi/ic_pumpcontrol.png rename to core/ui/src/androidMain/res/mipmap-hdpi/ic_pumpcontrol.png diff --git a/core/ui/src/main/res/mipmap-hdpi/ic_yellowowl.png b/core/ui/src/androidMain/res/mipmap-hdpi/ic_yellowowl.png similarity index 100% rename from core/ui/src/main/res/mipmap-hdpi/ic_yellowowl.png rename to core/ui/src/androidMain/res/mipmap-hdpi/ic_yellowowl.png diff --git a/core/ui/src/main/res/mipmap-mdpi/ic_blueowl.png b/core/ui/src/androidMain/res/mipmap-mdpi/ic_blueowl.png similarity index 100% rename from core/ui/src/main/res/mipmap-mdpi/ic_blueowl.png rename to core/ui/src/androidMain/res/mipmap-mdpi/ic_blueowl.png diff --git a/core/ui/src/main/res/mipmap-mdpi/ic_greenowl.png b/core/ui/src/androidMain/res/mipmap-mdpi/ic_greenowl.png similarity index 100% rename from core/ui/src/main/res/mipmap-mdpi/ic_greenowl.png rename to core/ui/src/androidMain/res/mipmap-mdpi/ic_greenowl.png diff --git a/core/ui/src/main/res/mipmap-mdpi/ic_launcher.png b/core/ui/src/androidMain/res/mipmap-mdpi/ic_launcher.png similarity index 100% rename from core/ui/src/main/res/mipmap-mdpi/ic_launcher.png rename to core/ui/src/androidMain/res/mipmap-mdpi/ic_launcher.png diff --git a/core/ui/src/main/res/mipmap-mdpi/ic_launcher_round.png b/core/ui/src/androidMain/res/mipmap-mdpi/ic_launcher_round.png similarity index 100% rename from core/ui/src/main/res/mipmap-mdpi/ic_launcher_round.png rename to core/ui/src/androidMain/res/mipmap-mdpi/ic_launcher_round.png diff --git a/core/ui/src/main/res/mipmap-mdpi/ic_pumpcontrol.png b/core/ui/src/androidMain/res/mipmap-mdpi/ic_pumpcontrol.png similarity index 100% rename from core/ui/src/main/res/mipmap-mdpi/ic_pumpcontrol.png rename to core/ui/src/androidMain/res/mipmap-mdpi/ic_pumpcontrol.png diff --git a/core/ui/src/main/res/mipmap-mdpi/ic_yellowowl.png b/core/ui/src/androidMain/res/mipmap-mdpi/ic_yellowowl.png similarity index 100% rename from core/ui/src/main/res/mipmap-mdpi/ic_yellowowl.png rename to core/ui/src/androidMain/res/mipmap-mdpi/ic_yellowowl.png diff --git a/core/ui/src/main/res/mipmap-xhdpi/ic_blueowl.png b/core/ui/src/androidMain/res/mipmap-xhdpi/ic_blueowl.png similarity index 100% rename from core/ui/src/main/res/mipmap-xhdpi/ic_blueowl.png rename to core/ui/src/androidMain/res/mipmap-xhdpi/ic_blueowl.png diff --git a/core/ui/src/main/res/mipmap-xhdpi/ic_greenowl.png b/core/ui/src/androidMain/res/mipmap-xhdpi/ic_greenowl.png similarity index 100% rename from core/ui/src/main/res/mipmap-xhdpi/ic_greenowl.png rename to core/ui/src/androidMain/res/mipmap-xhdpi/ic_greenowl.png diff --git a/core/ui/src/main/res/mipmap-xhdpi/ic_launcher.png b/core/ui/src/androidMain/res/mipmap-xhdpi/ic_launcher.png similarity index 100% rename from core/ui/src/main/res/mipmap-xhdpi/ic_launcher.png rename to core/ui/src/androidMain/res/mipmap-xhdpi/ic_launcher.png diff --git a/core/ui/src/main/res/mipmap-xhdpi/ic_launcher_round.png b/core/ui/src/androidMain/res/mipmap-xhdpi/ic_launcher_round.png similarity index 100% rename from core/ui/src/main/res/mipmap-xhdpi/ic_launcher_round.png rename to core/ui/src/androidMain/res/mipmap-xhdpi/ic_launcher_round.png diff --git a/core/ui/src/main/res/mipmap-xhdpi/ic_pumpcontrol.png b/core/ui/src/androidMain/res/mipmap-xhdpi/ic_pumpcontrol.png similarity index 100% rename from core/ui/src/main/res/mipmap-xhdpi/ic_pumpcontrol.png rename to core/ui/src/androidMain/res/mipmap-xhdpi/ic_pumpcontrol.png diff --git a/core/ui/src/main/res/mipmap-xhdpi/ic_yellowowl.png b/core/ui/src/androidMain/res/mipmap-xhdpi/ic_yellowowl.png similarity index 100% rename from core/ui/src/main/res/mipmap-xhdpi/ic_yellowowl.png rename to core/ui/src/androidMain/res/mipmap-xhdpi/ic_yellowowl.png diff --git a/core/ui/src/main/res/mipmap-xxhdpi/ic_blueowl.png b/core/ui/src/androidMain/res/mipmap-xxhdpi/ic_blueowl.png similarity index 100% rename from core/ui/src/main/res/mipmap-xxhdpi/ic_blueowl.png rename to core/ui/src/androidMain/res/mipmap-xxhdpi/ic_blueowl.png diff --git a/core/ui/src/main/res/mipmap-xxhdpi/ic_greenowl.png b/core/ui/src/androidMain/res/mipmap-xxhdpi/ic_greenowl.png similarity index 100% rename from core/ui/src/main/res/mipmap-xxhdpi/ic_greenowl.png rename to core/ui/src/androidMain/res/mipmap-xxhdpi/ic_greenowl.png diff --git a/core/ui/src/main/res/mipmap-xxhdpi/ic_launcher.png b/core/ui/src/androidMain/res/mipmap-xxhdpi/ic_launcher.png similarity index 100% rename from core/ui/src/main/res/mipmap-xxhdpi/ic_launcher.png rename to core/ui/src/androidMain/res/mipmap-xxhdpi/ic_launcher.png diff --git a/core/ui/src/main/res/mipmap-xxhdpi/ic_launcher_round.png b/core/ui/src/androidMain/res/mipmap-xxhdpi/ic_launcher_round.png similarity index 100% rename from core/ui/src/main/res/mipmap-xxhdpi/ic_launcher_round.png rename to core/ui/src/androidMain/res/mipmap-xxhdpi/ic_launcher_round.png diff --git a/core/ui/src/main/res/mipmap-xxhdpi/ic_pumpcontrol.png b/core/ui/src/androidMain/res/mipmap-xxhdpi/ic_pumpcontrol.png similarity index 100% rename from core/ui/src/main/res/mipmap-xxhdpi/ic_pumpcontrol.png rename to core/ui/src/androidMain/res/mipmap-xxhdpi/ic_pumpcontrol.png diff --git a/core/ui/src/main/res/mipmap-xxhdpi/ic_yellowowl.png b/core/ui/src/androidMain/res/mipmap-xxhdpi/ic_yellowowl.png similarity index 100% rename from core/ui/src/main/res/mipmap-xxhdpi/ic_yellowowl.png rename to core/ui/src/androidMain/res/mipmap-xxhdpi/ic_yellowowl.png diff --git a/core/ui/src/main/res/mipmap-xxxhdpi/ic_blueowl.png b/core/ui/src/androidMain/res/mipmap-xxxhdpi/ic_blueowl.png similarity index 100% rename from core/ui/src/main/res/mipmap-xxxhdpi/ic_blueowl.png rename to core/ui/src/androidMain/res/mipmap-xxxhdpi/ic_blueowl.png diff --git a/core/ui/src/main/res/mipmap-xxxhdpi/ic_greenowl.png b/core/ui/src/androidMain/res/mipmap-xxxhdpi/ic_greenowl.png similarity index 100% rename from core/ui/src/main/res/mipmap-xxxhdpi/ic_greenowl.png rename to core/ui/src/androidMain/res/mipmap-xxxhdpi/ic_greenowl.png diff --git a/core/ui/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/core/ui/src/androidMain/res/mipmap-xxxhdpi/ic_launcher.png similarity index 100% rename from core/ui/src/main/res/mipmap-xxxhdpi/ic_launcher.png rename to core/ui/src/androidMain/res/mipmap-xxxhdpi/ic_launcher.png diff --git a/core/ui/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png b/core/ui/src/androidMain/res/mipmap-xxxhdpi/ic_launcher_round.png similarity index 100% rename from core/ui/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png rename to core/ui/src/androidMain/res/mipmap-xxxhdpi/ic_launcher_round.png diff --git a/core/ui/src/main/res/mipmap-xxxhdpi/ic_pumpcontrol.png b/core/ui/src/androidMain/res/mipmap-xxxhdpi/ic_pumpcontrol.png similarity index 100% rename from core/ui/src/main/res/mipmap-xxxhdpi/ic_pumpcontrol.png rename to core/ui/src/androidMain/res/mipmap-xxxhdpi/ic_pumpcontrol.png diff --git a/core/ui/src/main/res/mipmap-xxxhdpi/ic_yellowowl.png b/core/ui/src/androidMain/res/mipmap-xxxhdpi/ic_yellowowl.png similarity index 100% rename from core/ui/src/main/res/mipmap-xxxhdpi/ic_yellowowl.png rename to core/ui/src/androidMain/res/mipmap-xxxhdpi/ic_yellowowl.png diff --git a/core/ui/src/main/res/raw/alarm.mp3 b/core/ui/src/androidMain/res/raw/alarm.mp3 similarity index 100% rename from core/ui/src/main/res/raw/alarm.mp3 rename to core/ui/src/androidMain/res/raw/alarm.mp3 diff --git a/core/ui/src/main/res/raw/boluserror.mp3 b/core/ui/src/androidMain/res/raw/boluserror.mp3 similarity index 100% rename from core/ui/src/main/res/raw/boluserror.mp3 rename to core/ui/src/androidMain/res/raw/boluserror.mp3 diff --git a/core/ui/src/main/res/raw/error.mp3 b/core/ui/src/androidMain/res/raw/error.mp3 similarity index 100% rename from core/ui/src/main/res/raw/error.mp3 rename to core/ui/src/androidMain/res/raw/error.mp3 diff --git a/core/ui/src/main/res/raw/urgentalarm.mp3 b/core/ui/src/androidMain/res/raw/urgentalarm.mp3 similarity index 100% rename from core/ui/src/main/res/raw/urgentalarm.mp3 rename to core/ui/src/androidMain/res/raw/urgentalarm.mp3 diff --git a/core/ui/src/main/res/values-ar-rSA/protection.xml b/core/ui/src/androidMain/res/values-ar-rSA/protection.xml similarity index 100% rename from core/ui/src/main/res/values-ar-rSA/protection.xml rename to core/ui/src/androidMain/res/values-ar-rSA/protection.xml diff --git a/core/ui/src/main/res/values-ar-rSA/strings.xml b/core/ui/src/androidMain/res/values-ar-rSA/strings.xml similarity index 100% rename from core/ui/src/main/res/values-ar-rSA/strings.xml rename to core/ui/src/androidMain/res/values-ar-rSA/strings.xml diff --git a/core/ui/src/main/res/values-ar-rSA/strings_scene_wizard.xml b/core/ui/src/androidMain/res/values-ar-rSA/strings_scene_wizard.xml similarity index 100% rename from core/ui/src/main/res/values-ar-rSA/strings_scene_wizard.xml rename to core/ui/src/androidMain/res/values-ar-rSA/strings_scene_wizard.xml diff --git a/core/ui/src/main/res/values-bg-rBG/protection.xml b/core/ui/src/androidMain/res/values-bg-rBG/protection.xml similarity index 100% rename from core/ui/src/main/res/values-bg-rBG/protection.xml rename to core/ui/src/androidMain/res/values-bg-rBG/protection.xml diff --git a/core/ui/src/main/res/values-bg-rBG/strings.xml b/core/ui/src/androidMain/res/values-bg-rBG/strings.xml similarity index 96% rename from core/ui/src/main/res/values-bg-rBG/strings.xml rename to core/ui/src/androidMain/res/values-bg-rBG/strings.xml index ab9e0e565703..e2dad4637ce3 100644 --- a/core/ui/src/main/res/values-bg-rBG/strings.xml +++ b/core/ui/src/androidMain/res/values-bg-rBG/strings.xml @@ -38,8 +38,6 @@ Тип събитие мг/дл ммол/л - мг/дл/E - ммол/л/E %1$d мг/дл %1$.1f ммол/л Запиши @@ -105,18 +103,14 @@ Стари данни Ще стартира %1$.2fЕ болус AAPS стартирана - %1$+.2fЕ - %1$d гр %1$d+%2$d гр %1$.2f ч %1$d мин - %1$s: %2$s %1$s %2$s Цели Моля изчакайте... Стоп Натиснат СТОП - въглехидрати Грешен профил! НЕ Е ЗАДАДЕН ПРОФИЛ Не мога да сменя профил: няма избран инсулин. @@ -172,15 +166,12 @@ КЗ Калибрация CGM - Аларма след %1$d мин Помпата върна грешка. Проверете ръчно за реално доставените инсулин и въглехидрати Напомни ми за болус когато КЗ е нормална Продължителност гр. - Помпата е спряна Помпата е налична Не е конфигуриран - Loop изключен Кръгът е спрян поради смяна на времето нужн пада бързо @@ -230,7 +221,6 @@ Затворен кръг Отворен кръг Спиране на базал при ниска КЗ - Няма връзка с помпата Помпата е спряна Спри помпата Възстанови помпата @@ -266,11 +256,6 @@ Паролите не съвпадат ПИН кодовете не съвпадат - Базалните стойности не са за кръгли часове: %1$s - Базалната стойност е заместена от минимално поддържаната стойност: %1$s - Базалната стойност е заместена от максимално поддържаната стойност %1$s - Е/ч - гр/Е Стартирай профил %1$d%% за %2$d мин @@ -308,7 +293,6 @@ 30д - %1$d мин Careportal Проверка на КЗ @@ -492,7 +476,6 @@ Върни стандартната Кръг - Loop Найтскаут - Запиши В дясно под гърдите В ляво под гърдите Дясна ръка отстрани @@ -542,7 +525,6 @@ %1$d гр допълнителни въглехидрати ще са необходими до %2$d минути Базал - Болус ОДД Обща дневна доза @@ -554,7 +536,6 @@ УДЪЛЖЕН БОЛУС СУПЕРБОЛУС TBR (Временна базална доза) ВЪГЛЕХИДРАТИ - УДЪЛЖЕН БОЛУС ВРЕМЕНЕН БАЗАЛ ВРЕМЕННА ЦЕЛ НОВ ПРОФИЛ @@ -655,22 +636,15 @@ СЦЕНАРИЙ ДЕАКТИВИРАН ОТДАЛЕЧЕНАТА КОНФИГУРАЦИЯ Е СМЕНЕНА - Профил ниска цел - Профил висока цел Стойност цел минимум Стойност цел максимум Стойност временна цел - Профил време на действие на инсулин Време на действие на инсулин DIA Пик на инсулина - Профил чувствителност Профил макс базал Текущ базал - Профил въглехидратно число %1$.2f ограничен до %2$.2f »Стойността %1$s е извън ограниченията - »%1$s« %2$.2f е извън ограниченията - Базална стойност Няма избран профил Няма име на профила @@ -727,18 +701,12 @@ Съветник на болус Имаш висока захар. Вместо да се яде сега, се препоръчва да се изчака за по-добра захар. Искате ли да направите корекция сега и да ви напомня кога е време за ядене? В този случай няма да бъдат записвани въглехидрати и трябва да използвате съветника отново, когато ви напомня. - COB срещу IOB - !!!!! Засечено бавно усвояване на въглехидрати: %1$d%% от времето. Проверете калкулацията. Въглехидратите може би са прекалено много и това ще доведе до много инсулин !!!!! - Приложено болус ограничение: %1$.2f Е към %2$.2f Е - Само запиши (без да да го стартираш в помпата) - Аларма, когато е време за хранене КЗ Корекция Междинна сума Хляб, паста, кис мляко, плодове (ябълки, банани круши), картофи, ориз, бавни и нисковъглехидр храни, зеленчуци Паста, зърнени закуски, мед, сладка Лазаня, пица, хамбургери, пържени и печени картофи, чипс и др снаксове - Уд въглехидрати %1$dгр / %2$dч (+%3$dмин) Общо Започват от Сега @@ -983,7 +951,6 @@ Пон %1$.1f Е - %1$.2fЕ %1$+.2f Е Стойност: %1$.2f%% (%2$.2f Е/ч) Продължителност: %3$d мин Стойност: %1$.2f Е/ч (%2$.2f%%) Продължителност: %3$d мин @@ -1088,4 +1055,45 @@ ЧУВСТВ% ПРОМ ЧУВСТВ КРАЧКИ + дни + %1$d дни + %1$d дни (%2$d - %3$d) + %1$.1f + %1$.2f + %1$.2f (%2$.2f - %3$.2f) + %1$.3f + %1$.3f (%2$.3f - %3$.3f) + %1$.1f (%2$.1f - %3$.1f) + %1$d гр + %1$d гр (%2$d - %3$d) + %1$d ч + %1$.0f ч + %1$.0f ч (%2$.0f - %3$.0f) + %1$d ч (%2$d - %3$d) + %1$.1f Ед + %1$d Ед + %1$d Ед (%2$d - %3$d) + %1$.1f Ед (%2$.1f - %3$.1f) + %1$.1f Ед/ч + %1$.1f Е/ч (%2$.1f - %3$.1f) + %1$s мг/дл + %1$s мг/дл (%2$s - %3$s) + %1$d мин + %1$d мин (%2$d - %3$d) + %1$d%% + %1$d%% (%2$d - %3$d) + %1$d сек + %1$d сек (%2$d - %3$d) + гр + ч + Ед + Е/ч + кг + мг/дл + мин + ммол/л + % + сек + г + Диапазон на визуализация diff --git a/core/ui/src/main/res/values-bg-rBG/strings_scene_wizard.xml b/core/ui/src/androidMain/res/values-bg-rBG/strings_scene_wizard.xml similarity index 100% rename from core/ui/src/main/res/values-bg-rBG/strings_scene_wizard.xml rename to core/ui/src/androidMain/res/values-bg-rBG/strings_scene_wizard.xml diff --git a/core/ui/src/main/res/values-ca-rES/protection.xml b/core/ui/src/androidMain/res/values-ca-rES/protection.xml similarity index 100% rename from core/ui/src/main/res/values-ca-rES/protection.xml rename to core/ui/src/androidMain/res/values-ca-rES/protection.xml diff --git a/core/ui/src/main/res/values-ca-rES/strings.xml b/core/ui/src/androidMain/res/values-ca-rES/strings.xml similarity index 93% rename from core/ui/src/main/res/values-ca-rES/strings.xml rename to core/ui/src/androidMain/res/values-ca-rES/strings.xml index e9ee6d321df3..7731afea0acc 100644 --- a/core/ui/src/main/res/values-ca-rES/strings.xml +++ b/core/ui/src/androidMain/res/values-ca-rES/strings.xml @@ -41,13 +41,10 @@ En pausa TDD Total A punt de lliurar %1$.2f U - %1$+.2f U - %1$d g %1$.2f h Objectius Espereu si us plau… Stop - Carbs ]]> Data Unitats @@ -82,12 +79,9 @@ Dades procedents d\'una altra bomba. Canvieu el driver de la bomba per restablir el seu estat. Glucèmia Calibració - Executar alarma en %1$d min Durada g - Bomba aturada No configurat - Llaç aturat req cap desconegut @@ -114,10 +108,6 @@ Contrasenya incorrecta Les contrasenyes no coincideixen - Valors basals no alineats amb les hores: %1$s - Valor basal reemplaçat pel màxim valor acceptat: %1$s - U/h - g/U Iniciar perfil %1$d%% durant %2$d min @@ -131,7 +121,6 @@ Mitja - %1$d min Portal de cures Control glucèmia @@ -170,7 +159,6 @@ Personalitzat Llaç NS - Registre Temps de connexió esgotat @@ -182,7 +170,6 @@ SMB Basal - Bolus Detecció de temps @@ -192,7 +179,6 @@ BOLUS ESTÈS SUPERBOLUS TBR CARBS - CARBS ESTESOS BASAL TEMP OBJECTIU TEMP NOU PERFIL @@ -274,20 +260,13 @@ LLAÇ MODIFICAT LLAÇ ELIMINAT - Objectiu baix del perfil - Objectiu alt del perfil Valor mínim d\'objectiu temporal Valor màxim d\'objectiu temporal Valor objectiu temporal - Valor DIA del perfil - Valor sensibilitat del perfil Valor basal màxima del perfil Valor basal actual - Valor ràtio de carbohidrats del perfil %1$.2f limitat a %2$.2f »%1$s« supera els límits - »%1$s« %2$.2f està fora dels límits estrictes - Valor basal BOLUS %1$.2f U @@ -307,8 +286,6 @@ Alarma urgent INFO - COB vs IOB - Executar alarma quan sigui hora de menjar Cap acció sel·leccionada, no passarà res Adolescent diff --git a/core/ui/src/main/res/values-ca-rES/strings_scene_wizard.xml b/core/ui/src/androidMain/res/values-ca-rES/strings_scene_wizard.xml similarity index 100% rename from core/ui/src/main/res/values-ca-rES/strings_scene_wizard.xml rename to core/ui/src/androidMain/res/values-ca-rES/strings_scene_wizard.xml diff --git a/core/ui/src/main/res/values-cs-rCZ/protection.xml b/core/ui/src/androidMain/res/values-cs-rCZ/protection.xml similarity index 100% rename from core/ui/src/main/res/values-cs-rCZ/protection.xml rename to core/ui/src/androidMain/res/values-cs-rCZ/protection.xml diff --git a/core/ui/src/main/res/values-cs-rCZ/strings.xml b/core/ui/src/androidMain/res/values-cs-rCZ/strings.xml similarity index 96% rename from core/ui/src/main/res/values-cs-rCZ/strings.xml rename to core/ui/src/androidMain/res/values-cs-rCZ/strings.xml index ccc3d6d21dca..a181559e2cbf 100644 --- a/core/ui/src/main/res/values-cs-rCZ/strings.xml +++ b/core/ui/src/androidMain/res/values-cs-rCZ/strings.xml @@ -38,8 +38,6 @@ Typ události mg/dL mmol/L - mg/dL/U - mmol/L/U %1$d mg/dL %1$.1f mmol/L Uložit @@ -103,18 +101,14 @@ Stará data Podávání %1$.2f U inzulínu AAPS spuštěno - %1$+.2f U - %1$d g %1$d+%2$dg %1$.2f h %1$d min - %1$s: %2$s %1$s %2$s Cíle Počkejte prosím… Stop STISKNUTO STOP - Sacharidy Neplatný profil! ŽÁDNÝ PROFIL NENASTAVEN ]]> @@ -164,15 +158,12 @@ Gly Kalibrace CGM - Spustit alarm za %1$d min Podání bolusu skončilo chybou. Ručně zkontrolujte, kolik inzulinu a sacharidů se skutečně vydalo Připomenout bolus při obnovení glykémie Trvání g - Pumpa pozastavena Pumpa běží Není nakonfigurováno - Smyčka pozastavena Smyčka pozastavena kvůli změně času pož rychle klesající @@ -222,7 +213,6 @@ Uzavřená smyčka Otevřená smyčka Ochrana před nízkou glykémií - Pumpa odpojena Pumpa pozastavena Pozastavit pumpu Obnovit pumpu @@ -258,11 +248,6 @@ Hesla se neshodují PIN kódy se neshodují - Hodnoty bazálů nejsou zarovnané na celé hodiny: %1$s - Hodnota bazálu byla nahrazena minimální možnou: %1$s - Hodnota bazálu nahrazena maximální možnou: %1$s - U/h - g/U Spustit profil %1$d%% na %2$d min @@ -300,7 +285,6 @@ 30d - %1$d min Péče Kontrola glykémie @@ -484,7 +468,6 @@ Vrátit na výchozí Smyčka NS - Záznam Hruď vpravo Hruď vlevo Vnější strana pravé paže nahoře @@ -534,7 +517,6 @@ Požadováno dalších %1$d g sacharidů během %2$d minut Bazál - Bolus CDD Celková denní dávka @@ -546,7 +528,6 @@ PRODLOUŽENÝ BOLUS SUPERBOLUS TBR SACHARIDY - ROZLOŽENÉ SACHARIDY DOČASNÝ BAZÁL DOČASNÝ CÍL NOVÝ PROFIL @@ -647,22 +628,15 @@ SCÉNA DEAKTIVOVÁNA VZDÁLENÁ KONFIGURACE ZMĚNĚNA - Dolní cíl profilu - Horní cíl profilu Dolní hodnota dočasného cíle Horní hodnota dočasného cíle Hodnota dočasného cíle - Hodnota DIA profilu Hodnota DIA inzulínu Hodnota vrcholu inzulínu - Hodnota citlivosti profilu Maximální hodnota profilu bazálu Aktuální hodnota bazálu - Inzulino-sacharidový poměr profilu %1$.2f omezeno na %2$.2f »%1$s« je mimo pevně nastavené limity - »%1$s« %2$.2f je mimo pevně nastavené limity - Hodnota bazálu Nebyl vybrán žádný profil Chybí název profilu @@ -719,18 +693,12 @@ Poradce pro bolus Máte vysokou glykémii. Namísto jídla doporučujeme vyčkat na lepší glykémii a připomenout, až bude čas na jídlo. Přejete si poslat korekční bolus a připomenout, až bude čas k jídlu? V tomto případě nebudou zapsané žádné sacharidy, a později opět musíte spustit kalkulátor, jakmile vám to připomeneme. - COB vs. IOB - !!!!! Byla zjištěna pomalá absorpce sacharidů: %1$d%% času. Zkontrolujte svůj výpočet. COB může být nadhodnoceno, takže by mohlo být podáno více inzulínu !!!!! - Použito omezení bolusu: %1$.2f U na %2$.2f U - Bolus nebude pumpou vydán, pouze zaznamenán - Spustit alarm, když je čas na jídlo Gly Korekce Mezisoučet Chléb, slané pečivo, jogurt, ovoce (jablko, banán, hruška), brambory, rýže, pohanka, ovesné vločky, pomalé a nízkosacharidové potraviny, zelenina Knedlíky, těstoviny, asijská kuchyně bohatá na sacharidy a tuky, sladké a kynuté pečivo, cereálie, obilné kaše, med, marmeláda Lasagne, pizza, hamburgery, hranolky, pečené brambory, chipsy a podobné svačiny - eSach %1$dg / %2$dh (+%3$dmin) Celkem Čas sacharidů Nyní @@ -975,7 +943,6 @@ Po %1$.1f U - %1$.2f U %1$+.2fU Hodnota: %1$.2f%% (%2$.2f U/h) Trvání: %3$d min Hodnota: %1$.2f U/h (%2$.2f%%) Trvání: %3$d min @@ -1080,4 +1047,45 @@ SENZ% VSEN KROKY + dní + %1$d dní + %1$d dní (%2$d - %3$d) + %1$.1f + %1$.2f + %1$.2f (%2$.2f - %3$.2f) + %1$.3f + %1$.3f (%2$.3f - %3$.3f) + %1$.1f (%2$.1f - %3$.1f) + %1$d g + %1$d g (%2$d - %3$d) + %1$d h + %1$.0f h + %1$.0f h (%2$.0f - %3$.0f) + %1$d h (%2$d - %3$d) + %1$.1f U + %1$d U + %1$d U (%2$d - %3$d) + %1$.1f U (%2$.1f - %3$.1f) + %1$.1f U/h + %1$.1f U/h (%2$.1f - %3$.1f) + %1$s mg/dL + %1$s mg/dL (%2$s - %3$s) + %1$d min + %1$d min (%2$d - %3$d) + %1$d%% + %1$d%% (%2$d - %3$d) + %1$d s + %1$d s (%2$d - %3$d) + g + h + U + U/h + kg + mg/dL + min + mmol/L + % + s + r + Rozsah pro vizualizaci diff --git a/core/ui/src/main/res/values-cs-rCZ/strings_scene_wizard.xml b/core/ui/src/androidMain/res/values-cs-rCZ/strings_scene_wizard.xml similarity index 100% rename from core/ui/src/main/res/values-cs-rCZ/strings_scene_wizard.xml rename to core/ui/src/androidMain/res/values-cs-rCZ/strings_scene_wizard.xml diff --git a/core/ui/src/main/res/values-cy-rGB/protection.xml b/core/ui/src/androidMain/res/values-cy-rGB/protection.xml similarity index 100% rename from core/ui/src/main/res/values-cy-rGB/protection.xml rename to core/ui/src/androidMain/res/values-cy-rGB/protection.xml diff --git a/core/ui/src/main/res/values-da-rDK/protection.xml b/core/ui/src/androidMain/res/values-da-rDK/protection.xml similarity index 100% rename from core/ui/src/main/res/values-da-rDK/protection.xml rename to core/ui/src/androidMain/res/values-da-rDK/protection.xml diff --git a/core/ui/src/main/res/values-da-rDK/strings.xml b/core/ui/src/androidMain/res/values-da-rDK/strings.xml similarity index 93% rename from core/ui/src/main/res/values-da-rDK/strings.xml rename to core/ui/src/androidMain/res/values-da-rDK/strings.xml index 4d0c0717fc6a..109a9e5d073f 100644 --- a/core/ui/src/main/res/values-da-rDK/strings.xml +++ b/core/ui/src/androidMain/res/values-da-rDK/strings.xml @@ -43,13 +43,10 @@ TDD Total Leverer %1$.2f IE AAPS startet - %1$+.2f IE - %1$d g %1$.2f t Mål Vent venligst… Stop - Kulhydrater Ugyldig profil! INGEN PROFIL SAT ]]> @@ -90,12 +87,9 @@ Data kommer fra en anden pumpe. Skift pumpedriver for at nulstille pumpetilstand. BG Kalibrering - Kør alarm om %1$d min Varighed g - Pumpe er afbrudt Ikke konfigureret - Loop suspenderet krævet falder hurtigt falder @@ -138,11 +132,6 @@ Kodeordene stemmer ikke overens PIN-koder ikke identiske - Basalværdier ikke angivet i hele timer: %1$s - Basal dosis erstattet af minimal understøttet dosis: %1$s - Basal dosis erstattet af maximal understøttet dosis: %1$s - E/t - g/E Start profil %1$d%% i %2$d min @@ -157,7 +146,6 @@ Gennemsnit - %1$d min Behandlingsportal BS kontrol @@ -200,7 +188,6 @@ Brugerdefineret Loop NS - Registrér Forbindelsen fik timeout @@ -213,7 +200,6 @@ %1$d g ekstra kulhydrater kræves inden for %2$d minutter Basal - Bolus Tidsdetektering @@ -223,7 +209,6 @@ FORLÆNGET BOLUS SUPERBOLUS TBR KH - FORLÆNGET KH MIDLERTIDIG BASAL MIDLERTIDIG MÅL NY PROFIL @@ -306,20 +291,13 @@ LOOP ÆNDRET LOOP FJERNET - Nedre målværdi for profilen - Øvre målværdi for profilen Nedre målværdi for midlertidig mål Øvre målværdi for midlertidig mål Midlertidig mål værdi - Profil DIA værdi - Profil følsomhed værdi Maksimal profil basal værdi Aktuel basal værdi - Profil KH ratio værdi %1$.2f begrænset til %2$.2f »%1$s« er uden for absolutte grænser - »%1$s« %2$.2f er uden for absolutte grænser - Basalværdi BOLUS %1$.2f IE @@ -354,10 +332,6 @@ Bolus-rådgiver Du har høj glycæmi. I stedet for at spise nu anbefales det at vente på bedre glycæmi. Ønsker du at lave en korrektionsbolus nu og minde dig om, hvornår det er tid til at spise? I dette tilfælde vil ingen kulhydrater blive registreret, og du skal bruge guiden igen, når vi påminder dig. - COB vs IOB - Bolus-begrænsning anvendt: %1$.2f IE til %2$.2f IE - Bolus registreres kun (bliver ikke leveret af pumpe) - Kør alarm når det er tid til at spise Ingen handling valgt, intet vil ske Ingen nylig BG til at basere beregningen på! Ingen aktiv profil angivet! @@ -469,7 +443,6 @@ Man %1$.1f IE - %1$.2f IE diff --git a/core/ui/src/main/res/values-da-rDK/strings_scene_wizard.xml b/core/ui/src/androidMain/res/values-da-rDK/strings_scene_wizard.xml similarity index 100% rename from core/ui/src/main/res/values-da-rDK/strings_scene_wizard.xml rename to core/ui/src/androidMain/res/values-da-rDK/strings_scene_wizard.xml diff --git a/core/ui/src/main/res/values-de-rDE/protection.xml b/core/ui/src/androidMain/res/values-de-rDE/protection.xml similarity index 100% rename from core/ui/src/main/res/values-de-rDE/protection.xml rename to core/ui/src/androidMain/res/values-de-rDE/protection.xml diff --git a/core/ui/src/main/res/values-de-rDE/strings.xml b/core/ui/src/androidMain/res/values-de-rDE/strings.xml similarity index 93% rename from core/ui/src/main/res/values-de-rDE/strings.xml rename to core/ui/src/androidMain/res/values-de-rDE/strings.xml index de8fd698c888..0bbd0793ea4c 100644 --- a/core/ui/src/main/res/values-de-rDE/strings.xml +++ b/core/ui/src/androidMain/res/values-de-rDE/strings.xml @@ -44,13 +44,10 @@ Veraltete Daten Werde %1$.2f IE abgeben AAPS gestartet - %1$+.2f IE - %1$d g %1$.2f h Objectives (Ziele) Bitte warten… Stopp - Kohlenhydrate Ungültiges Profil! KEIN PROFIL GESETZT ]]> @@ -91,12 +88,9 @@ Daten kommen von einer anderen Pumpe. Wechsle den Pumpentreiber. BZ Kalibrierung - Alarm in %1$d Min. Dauer g - Pumpe pausiert Nicht konfiguriert - Loop pausiert angef. schnell fallend fallend @@ -141,11 +135,6 @@ Die Passwörter stimmen nicht überein. PINs stimmen nicht überein - Basalraten beginnen nicht zur vollen Stunde: %1$s - Basal-Wert wurde durch den kleinst möglichen Wert ersetzt: %1$s - Basal-Wert wurde durch größt möglichen Wert ersetzt: %1$s - IE/h - g/IE Profil %1$d%% für %2$d Min. starten @@ -160,7 +149,6 @@ Durchschnitt - %1$d min. Careportal BZ-Test @@ -203,7 +191,6 @@ Benutzerdefiniert Loop NS - Eintrag Zeitüberschreitung bei Verbindung @@ -216,7 +203,6 @@ %1$d g zusätzliche Kohlenhydrate innerhalb von %2$d Minuten erforderlich Basal - Bolus Zeiterkennung @@ -226,7 +212,6 @@ VERLÄNGERTER BOLUS SUPERBOLUS TBR KOHLENHYDRATE - VERLÄNGERTE KOHLENHYDRATE TEMP BASAL TEMP. ZIEL NEUES PROFIL @@ -309,20 +294,13 @@ LOOP GEÄNDERT LOOP ENTFERNT - Profil unteres Ziel - Profil oberes Ziel Temp. Ziel unterer Wert Temp. Ziel oberer Wert Temp. Ziel Wert - Profil Insulinwirkdauer - Profil Sensitivitätswert Profil max. Basalwert Aktueller Basalwert - Profil KH-Faktor %1$.2f limitiert auf %2$.2f »%1$s« ist außerhalb der fest programmierten Grenzen - »%1$s« %2$.2f ist außerhalb der fest programmierten Grenzen - Basal-Wert BOLUS %1$.2f IE @@ -357,10 +335,6 @@ Bolus-Rechner Deine BZ-Werte sind hoch. Statt jetzt zu essen solltest Du abwarten, bis die Werte gesunken sind. Willst du jetzt einen Korrekturbolus abgeben und erinnert werden, wenn es Zeit zum Essen ist? In diesem Fall werden die Kohlenhydrate nicht übernommen und Du musst nach der Erinnerung den Bolus-Rechner erneut verwenden. - COB vs IOB - Bolus Einschränkung angewendet: %1$.2f U bis %2$.2f U - Bolus wird nur aufgezeichnet (Die Pumpe gibt kein Insulin ab!) - Alarmiere mich, wenn es Zeit zum Essen ist. Keine Aktion ausgewählt, nichts wird geschehen. Kein aktueller BG liegt als Basis zur Berechnung vor! Kein aktives Profil gesetzt! @@ -473,7 +447,6 @@ Mo %1$.1f IE - %1$.2f IE diff --git a/core/ui/src/main/res/values-de-rDE/strings_scene_wizard.xml b/core/ui/src/androidMain/res/values-de-rDE/strings_scene_wizard.xml similarity index 100% rename from core/ui/src/main/res/values-de-rDE/strings_scene_wizard.xml rename to core/ui/src/androidMain/res/values-de-rDE/strings_scene_wizard.xml diff --git a/core/ui/src/main/res/values-el-rGR/protection.xml b/core/ui/src/androidMain/res/values-el-rGR/protection.xml similarity index 100% rename from core/ui/src/main/res/values-el-rGR/protection.xml rename to core/ui/src/androidMain/res/values-el-rGR/protection.xml diff --git a/core/ui/src/main/res/values-el-rGR/strings.xml b/core/ui/src/androidMain/res/values-el-rGR/strings.xml similarity index 92% rename from core/ui/src/main/res/values-el-rGR/strings.xml rename to core/ui/src/androidMain/res/values-el-rGR/strings.xml index b80c946327e8..d5c3e9aef591 100644 --- a/core/ui/src/main/res/values-el-rGR/strings.xml +++ b/core/ui/src/androidMain/res/values-el-rGR/strings.xml @@ -43,13 +43,10 @@ TDD σύνολο Πρόκειται να εγχυθούν %1$.2f μονάδες Το AAPS ξεκίνησε - %1$+.2f U - %1$d g %1$.2f h Βήματα Περιμένετε… Stop - Υδατάνθρακες Μη έγκυρο προφίλ! ΔΕΝ ΟΡΙΣΤΗΚΕ ΠΡΟΦΙΛ ]]> @@ -90,12 +87,9 @@ Τα δεδομένα προέρχονται από διαφορετική αντλία. Αλλάξτε τον οδηγό της αντλίας για να επαναφέρετε την κατάσταση της αντλίας. BG Καλιμπράρισμα - Εκτέλεση συναγερμού σε %1$d λεπτά Διάρκεια g - Η αντλία είναι σε παύση Δεν έχει ρυθμιστεί - Κύκλωμα σε αναστολή req ταχεία πτώση πτώση @@ -138,11 +132,6 @@ Οι κωδικοί δεν ταιριάζουν Τα PINs δεν ταιριάζουν - Οι τιμές του βασικού ρυθμού δεν αντιστοιχούν σε ώρες: %1$s - Η τιμή του βασικού αντικαταστάθηκε από την ελάχιστη υποστηριζόμενη τιμή: %1$s - Η τιμή του βασικού αντικαταστάθηκε από την μέγιστη υποστηριζόμενη τιμή: %1$s - U/h - g/U Έναρξη προφίλ %1$d%% για %2$d λεπτά @@ -157,7 +146,6 @@ Μέση - %1$d λεπτά Φροντίδα Έλεγχος BG @@ -200,7 +188,6 @@ Προσαρμογή Κύκλωμα NS - Εγγραφή Ο χρόνος σύνδεσης έληξε @@ -213,7 +200,6 @@ %1$d g επιπλέον υδατάνθρακες απαιτούνται μέσα σε %2$d λεπτά Βασικός Ρυθμός - Bolus Ανίχνευση χρόνου @@ -223,7 +209,6 @@ ΕΚΤΕΤΑΜΕΝΟ BOLUS SUPERBOLUS TBR ΥΔΑΤΑΝΘΡΑΚΕΣ - ΕΚΤΕΤΑΜΕΝΟΙ ΥΔΑΤΑΝΘΡΑΚΕΣ ΠΡΟΣΩΡΙΝΟΣ ΒΑΣΙΚΟΣ ΠΡΟΣΩΡΙΝΟΣ ΣΤΟΧΟΣ ΝΕΟ ΠΡΟΦΙΛ @@ -306,20 +291,13 @@ ΤΟ ΚΥΚΛΩΜΑ ΑΛΛΑΞΕ ΤΟ ΚΥΚΛΩΜΑ ΑΦΑΙΡΕΘΗΚΕ - Χαμηλός στόχος προφίλ - Υψηλός στόχος προφίλ Κάτω τιμή προσωρινού στόχου Πάνω τιμή προσωρινού στόχου Τιμή προσωρινού στόχου - Τιμή DIA του προφίλ - Τιμή ευαισθησίας προφίλ Μέγιστη τιμή βασικού ρυθμού προφίλ Τρέχουσα τιμή βασικού - Τιμή αναλογίας υδατανθράκων προφίλ Το %1$.2f περιορίζεται σε %2$.2f » Το%1$s« είναι εκτός ορίων - Η τιμή »%1$s« %2$.2f είναι εκτός ορίων - Τιμή Βασικού ρυθμού BOLUS %1$.2f U @@ -354,10 +332,6 @@ Σύμβουλος Bolus Έχετε υπεργλυκαιμία. Αντί να φάτε τώρα συνιστάτε να περιμένετε μια καλύτερη γλυκαιμία. Θέλετε να κάνετε ένα διορθωτικό bolus τώρα και να γίνει υπενθύμιση όταν έρθει η ώρα να φάτε; Σε αυτή την περίπτωση δε θα καταγραφούν καθόλου υδατάνθρακες και θα πρέπει να χρησιμοποιήσετε τον οδηγό ξανά μετά την υπενθύμιση. - COB vs IOB (ενεργοί υδατάνθρακες vs ενεργή ινσουλίνη) - Ορίστηκε περιορισμός Bolus: %1$.2f U σε %2$.2f U - Το Bolus μόνο θα καταγραφεί (δε θα χορηγηθεί από την αντλία) - Εκτέλεση συναγερμού όταν έρθει η ώρα να φάτε Δεν έχει επιλεγεί καμία ενέργεια, δεν υπάρχει τίποτα να κάνει Δεν υπάρχει πρόσφατή γλυκόζη αίματος για να γίνει υπολογισμός! Δεν ορίστηκε ενεργό προφίλ! @@ -469,7 +443,6 @@ Δευτ %1$.1f U - %1$.2f U diff --git a/core/ui/src/main/res/values-el-rGR/strings_scene_wizard.xml b/core/ui/src/androidMain/res/values-el-rGR/strings_scene_wizard.xml similarity index 100% rename from core/ui/src/main/res/values-el-rGR/strings_scene_wizard.xml rename to core/ui/src/androidMain/res/values-el-rGR/strings_scene_wizard.xml diff --git a/core/ui/src/main/res/values-es-rES/protection.xml b/core/ui/src/androidMain/res/values-es-rES/protection.xml similarity index 100% rename from core/ui/src/main/res/values-es-rES/protection.xml rename to core/ui/src/androidMain/res/values-es-rES/protection.xml diff --git a/core/ui/src/main/res/values-es-rES/strings.xml b/core/ui/src/androidMain/res/values-es-rES/strings.xml similarity index 96% rename from core/ui/src/main/res/values-es-rES/strings.xml rename to core/ui/src/androidMain/res/values-es-rES/strings.xml index 6f89d42104d0..27cc7c9ce7ce 100644 --- a/core/ui/src/main/res/values-es-rES/strings.xml +++ b/core/ui/src/androidMain/res/values-es-rES/strings.xml @@ -38,8 +38,6 @@ Tipo de evento mg/dL mmol/L - mg/dL/U - mmol/L/U %1$d mg/dL %1$.1f mmol/L Guardar @@ -105,18 +103,14 @@ Datos antiguos Entregando %1$.2f U AAPS iniciado - %1$+.2f U - %1$d g %1$d+%2$d g %1$.2f h %1$d min - %1$s: %2$s %1$s %2$s Objetivos Por favor, espere… Detener PARADA PULSADA - Carbohidratos Perfil inválido NINGÚN PERFIL ACTIVO No se puede cambiar de perfil: no hay ninguna insulina en uso y no se ha seleccionado ninguna @@ -172,15 +166,12 @@ BG Calibración MCG - Ejecutar alarma en %1$d min El bolo reportó un error. Comprueba manualmente la cantidad real de insulina entregada y la cantidad de carbohidratos Recordar bolo cuando la glucemia se recupere Duración g - Bomba suspendida Bomba en funcionamiento Sin configurar - Lazo suspendido Lazo suspendido por DST (Horario de verano) req bajando rápido @@ -230,7 +221,6 @@ Lazo cerrado Lazo abierto Suspensión por glucosa baja (LGS) - Bomba desconectada Bomba suspendida Suspender bomba Reanudar bomba @@ -266,11 +256,6 @@ Las ontraseñas no coinciden Los códigos PIN no coinciden - Valores basales no alineados a las horas: %1$s - Valor basal cambiado al valor mínimo soportado: %1$s - Valor basal reemplazado por el valor máximo soportado: %1$s - U/h - g/U Iniciar perfil %1$d%% durante %2$d min @@ -308,7 +293,6 @@ 30d - %1$d min Portal de cuidados Medir glucosa @@ -492,7 +476,6 @@ Restablecer valores predeterminados Lazo NS - Registro Cadera derecha Cadera izquierda Brazo superior derecho, exterior @@ -542,7 +525,6 @@ %1$d g carbohidratos adicionales necesarios en %2$d minutos Dosis Basal - Bolo TDD Dosis Diaria Total @@ -554,7 +536,6 @@ BOLO EXTENDIDO SUPERBOLO TBR CARBOHIDRATOS - CARBOHIDRATOS EXTENDIDOS BASAL TEMPORAL OBJETIVO TEMPORAL NUEVO PERFIL @@ -655,22 +636,15 @@ ESCENA DESACTIVADA CAMBIO EN CONFIGURACIÓN REMOTA - Perfil de objetivo bajo - Perfil de objetivo alto Valor inferior objetivo temporal Valor superior objetivo temporal Valor objetivo temporal - Valor DIA del perfil Valor de DIA de la insulina Valor del pico de la insulina - Valor de sensibilidad del perfil Máximo valor basal del perfil Valor basal actual - Valor del ratio de carbohidratos del perfil %1$.2f limitado a %2$.2f »%1$s« está fuera del límite estricto - »%1$s« %2$.2f está fuera de los límites estrictos - Valor basal Ningún perfil seleccionado Falta el nombre del perfil @@ -727,18 +701,12 @@ Asistente de bolo Tienes la glucosa alta. En lugar de comer ahora, se recomienda esperar a tener un mejor valor de glucosa. ¿Quieres poner un bolo de corrección ahora y recibir un aviso cuando sea un buen momento para comer? En este caso no se registrarán los carbohidratos y deberás utilizar el asistente de bolos después de recibir la notificación. - COB vs IOB - ¡¡¡Absorción lenta de hidratos detectada: %1$d%% del tiempo. Revisa tus cálculos. Los COB pueden estar sobreestimados y el sistema podría darte demasiada insulina!!! - Restricción de bolo aplicada: %1$.2f U a %2$.2f U - El bolo sólo se anotará (no será entregado por la bomba) - Ejecutar alarma cuando sea hora de comer Glucemia Corrección Subtotal Pan, pastas saladas, yogurt, fruta (manzana, plátano, pera), patatas, arroz, trigo sarraceno, avena, alimentos de absorción lenta y bajos en carbohidratos, verduras Empanadillas, pasta, cocina asiática rica en hidratos y grasas, bollería dulce y de masa fermentada (cruasanes, brioches, donuts), cereales, gachas de cereales, miel, mermelada Lasaña, pizza, hamburguesas, patatas fritas, patatas de bolsa y snacks similares - eCarbs %1$dg / %2$dh (+%3$dmin) Total Hora de los carbohidratos Ahora @@ -983,7 +951,6 @@ Lun %1$.1f U - %1$.2f U %1$+.2f U Ratio: %1$.2f%% (%2$.2f U/h) Duración: %3$d min Ratio: %1$.2f U/h (%2$.2f%%) Duración: %3$d min @@ -1088,4 +1055,45 @@ SENS% VSENS PASOS + días + %1$d días + %1$d días (%2$d - %3$d) + %1$.1f + %1$.2f + %1$.2f (%2$.2f - %3$.2f) + %1$.3f + %1$.3f (%2$.3f - %3$.3f) + %1$.1f (%2$.1f - %3$.1f) + %1$d g + %1$d g (%2$d - %3$d) + %1$d h + %1$.0f h + %1$.0f h (%2$.0f - %3$.0f) + %1$d h (%2$d - %3$d) + %1$.1f U + %1$d U + %1$d U (%2$d - %3$d) + %1$.1f U (%2$.1f - %3$.1f) + %1$.1f U/h + %1$.1f U/h (%2$.1f - %3$.1f) + %1$s mg/dL + %1$s mg/dL (%2$s - %3$s) + %1$d min + %1$d min (%2$d - %3$d) + %1$d%% + %1$d%% (%2$d - %3$d) + %1$d seg + %1$d seg (%2$d - %3$d) + g + h + U + U/h + kg + mg/dL + min + mmol/L + % + seg + a + Rango para visualización diff --git a/core/ui/src/main/res/values-es-rES/strings_scene_wizard.xml b/core/ui/src/androidMain/res/values-es-rES/strings_scene_wizard.xml similarity index 100% rename from core/ui/src/main/res/values-es-rES/strings_scene_wizard.xml rename to core/ui/src/androidMain/res/values-es-rES/strings_scene_wizard.xml diff --git a/core/ui/src/main/res/values-fi-rFI/protection.xml b/core/ui/src/androidMain/res/values-fi-rFI/protection.xml similarity index 100% rename from core/ui/src/main/res/values-fi-rFI/protection.xml rename to core/ui/src/androidMain/res/values-fi-rFI/protection.xml diff --git a/core/ui/src/main/res/values-fr-rFR/protection.xml b/core/ui/src/androidMain/res/values-fr-rFR/protection.xml similarity index 100% rename from core/ui/src/main/res/values-fr-rFR/protection.xml rename to core/ui/src/androidMain/res/values-fr-rFR/protection.xml diff --git a/core/ui/src/main/res/values-fr-rFR/strings.xml b/core/ui/src/androidMain/res/values-fr-rFR/strings.xml similarity index 96% rename from core/ui/src/main/res/values-fr-rFR/strings.xml rename to core/ui/src/androidMain/res/values-fr-rFR/strings.xml index e4adabe34ed3..9d22b0249dc8 100644 --- a/core/ui/src/main/res/values-fr-rFR/strings.xml +++ b/core/ui/src/androidMain/res/values-fr-rFR/strings.xml @@ -38,8 +38,6 @@ Type d\'évènement mg/dL mmol/L - mg/dL/U - mmol/L/U %1$d mg/dL %1$.1f mmol/L Enregistrer @@ -106,18 +104,14 @@ %1$.2f U vont être injectées Bolus injecté, mais les glucides n\'ont pas pu être enregistrés. Veuillez entrer les glucides à nouveau. AAPS démarré - %1$+.2f U - %1$d g %1$d+%2$d g %1$.2f h %1$d min - %1$s: %2$s %1$s %2$s Objectifs Merci de patienter... Arrêt ARRÊT APPUYÉ - Glucides Profil incorrect! PAS DE PROFIL SELECTIONNÉ Impossible de changer de profil : aucune insuline n\'est utilisée, et aucune insuline n\'a été sélectionnée. @@ -173,15 +167,12 @@ Gly Étalonnage MGC - Alerter dans %1$d min Erreur lors du Bolus. Vérifiez manuellement la quantité réellement injectée ainsi que les glucides actifs Rappeler le bolus quand la Gly remonte Durée g - Pompe arrêtée Pompe en fonctionnement Non configuré - La Boucle est suspendue Boucle suspendue par le changement d\'heure req en baisse rapide @@ -231,7 +222,6 @@ Boucle Fermée Boucle Ouverte Arrêt Glycémie Basse - Pompe déconnectée Pompe suspendue Suspendre la pompe Reprendre la pompe @@ -267,11 +257,6 @@ Mots de passe différents Les codes PIN ne correspondent pas - Valeurs des débits de basal non alignées sur des heures: %1$s - Valeur de basal remplacée par la valeur minimale autorisée : %1$s - Valeur de basal remplacée par la valeur maximale autorisée : %1$s - U/h - g/U Démarrer le profil %1$d%% pour %2$d min @@ -309,7 +294,6 @@ 30j - %1$d min Careportal Contrôle Glycémie @@ -493,7 +477,6 @@ Rétablir aux valeurs par défaut Boucle NS - Enregistrement Poitrine Droite Poitrine Gauche Bras Droit Extérieur @@ -543,7 +526,6 @@ %1$dg de glucides requis dans %2$d min. Basal - Bolus DTQ Dose Totale Quotidienne @@ -555,7 +537,6 @@ BOLUS ÉTENDU DBT SUPERBOLUS GLUCIDES - GLUCIDES ÉTENDUS BASAL TEMP CIBLE TEMP NOUVEAU PROFIL @@ -656,22 +637,15 @@ SCÉNARIO DÉSACTIVÉ CONFIG DISTANTE CHANGÉE - Cible basse du profil - Cible haute du profil Valeur basse de Cible Temp. Valeur haute de Cible Temp. Valeur de Cible Temporaire - Valeur DAI du profil Valeur DAI de l\'insuline Valeur du Pic de l\'insuline - Valeur de sensibilité du profil Basale maximale du profil Basale actuelle - Rapport glucides/insuline de profil %1$.2f limité à %2$.2f \"%1$s\" est en dehors des limites - \"%1$s\" %2$.2f est en dehors des limites - Valeur de Basal Aucun profil séléctionné Nom de profil manquant @@ -728,18 +702,12 @@ Assistant bolus Vous avez une glycémie élevée. Au lieu de manger maintenant, il est recommandé d\'attendre une meilleure glycémie. Voulez-vous faire un bolus de correction maintenant et avoir une alerte quand il sera temps de manger ? Dans ce cas, aucun glucide ne sera enregistré et vous devrez utiliser l\'assistant à nouveau lorsque nous vous le rappelons. - GA vs IA - !!!!! Absorption lente des glucides détectée dans %1$d%% des cas. Vérifiez de nouveau votre calcul. Les GA (Glucides Actifs) peuvent être surestimés et alors trop d\'insuline pourrait être injectée !!!!! - Contrainte de Bolus appliquée : %1$.2f U vers %2$.2f U - Les bolus seront seulement enregistrés (pas délivrés par la pompe) - Alerter quand il est temps de manger Gly Correction Sous-total Pain, viennoiseries, yaourts, fruits (pomme, banane, poire), pommes de terre, riz, sarrasin d\'avoine, aliments lents et à faible teneur en glucides, légumes Raviolis, pâtes, cuisine asiatique riche en glucides et graisses, pâtisseries sucrées et levures, céréales, porridge de céréales, miel, confiture Lasagne, pizza, hamburgers, frites, pommes de terre cuites, chips et collations similaires - eGlucides %1$d g / %2$d h (+%3$d min) Total Décalage horaire Maintenant @@ -984,7 +952,6 @@ Lun %1$.1f U - %1$.2f U %1$+.2f U Débit: %1$.2f%% (%2$.2f U/h) Durée: %3$d min Débit: %1$.2f U/h (%2$.2f%%) Durée: %3$d min @@ -1089,4 +1056,45 @@ SENS% V_SI PAS + jours + %1$d jours + %1$d jours (%2$d - %3$d) + %1$.1f + %1$.2f + %1$.2f (%2$.2f - %3$.2f) + %1$.3f + %1$.3f (%2$.3f - %3$.3f) + %1$.1f (%2$.1f - %3$.1f) + %1$d g + %1$d g (%2$d - %3$d) + %1$d h + %1$.0f h + %1$.0f h (%2$.0f - %3$.0f) + %1$d h (%2$d - %3$d) + %1$.1f U + %1$d U + %1$d U (%2$d - %3$d) + %1$.1f U (%2$.1f - %3$.1f) + %1$.1f U/h + %1$.1f U/h (%2$.1f - %3$.1f) + %1$s mg/dL + %1$s mg/dL (%2$s - %3$s) + %1$d m + %1$d m (%2$d - %3$d) + %1$d%% + %1$d%% (%2$d - %3$d) + %1$d s + %1$d s (%2$d - %3$d) + g + h + U + U/h + kg + mg/dL + m + mmol/L + % + s + a + Fourchette de visualisation diff --git a/core/ui/src/main/res/values-fr-rFR/strings_scene_wizard.xml b/core/ui/src/androidMain/res/values-fr-rFR/strings_scene_wizard.xml similarity index 100% rename from core/ui/src/main/res/values-fr-rFR/strings_scene_wizard.xml rename to core/ui/src/androidMain/res/values-fr-rFR/strings_scene_wizard.xml diff --git a/core/ui/src/main/res/values-hr-rHR/protection.xml b/core/ui/src/androidMain/res/values-hr-rHR/protection.xml similarity index 100% rename from core/ui/src/main/res/values-hr-rHR/protection.xml rename to core/ui/src/androidMain/res/values-hr-rHR/protection.xml diff --git a/core/ui/src/main/res/values-hr-rHR/strings.xml b/core/ui/src/androidMain/res/values-hr-rHR/strings.xml similarity index 92% rename from core/ui/src/main/res/values-hr-rHR/strings.xml rename to core/ui/src/androidMain/res/values-hr-rHR/strings.xml index 566360fb9db2..f4e55b5b63c8 100644 --- a/core/ui/src/main/res/values-hr-rHR/strings.xml +++ b/core/ui/src/androidMain/res/values-hr-rHR/strings.xml @@ -33,13 +33,10 @@ TDD Total Isporučit ću %1$.2f U AAPS je počeo - %1$+.2f U - %1$d g %1$.2f h Ciljevi Molimo pričekajte… Zaustavi - UH ]]> Datum Jedinice @@ -60,10 +57,8 @@ Aktivni UGH GUK Kalibracija - Uključi alarm za %1$d min Trajanje g - Petlja suspendirana potrebno brzo padajući padanje @@ -96,11 +91,6 @@ Lozinke se ne podudaraju PIN-ovi se ne podudaraju - Bazalne vrijednosti nisu usklađene sa satima: %1$s - Bazalna vrijednost zamijenjena minimalnom podržanom vrijednošću: %1$s - Bazalna vrijednost zamijenjena maksimalnom podržanom vrijednošću: %1$s - U/h - g/U Pokrenite profil %1$d%% na %2$d min @@ -128,7 +118,6 @@ %1$d g dodatnih ugljikohidrata potrebnih unutar %2$d minuta Bazal - Bolus Detekcija vremena @@ -172,16 +161,11 @@ PETLJA PROMIJENJENA PETLJA UKLONJENA - Profil niski cilj - Profil visoki cilj Privremena ciljana donja vrijednost Privremena ciljna gornja vrijednost Privremena ciljna vrijednost - Profil DIA vrijednost - Vrijednost osjetljivosti profila Maksimalna bazalna vrijednost profila Trenutna bazalna vrijednost - Vrijednost omjera ugljikohidrata profila %1$.0f%% diff --git a/core/ui/src/main/res/values-hr-rHR/strings_scene_wizard.xml b/core/ui/src/androidMain/res/values-hr-rHR/strings_scene_wizard.xml similarity index 100% rename from core/ui/src/main/res/values-hr-rHR/strings_scene_wizard.xml rename to core/ui/src/androidMain/res/values-hr-rHR/strings_scene_wizard.xml diff --git a/core/ui/src/main/res/values-hu-rHU/protection.xml b/core/ui/src/androidMain/res/values-hu-rHU/protection.xml similarity index 100% rename from core/ui/src/main/res/values-hu-rHU/protection.xml rename to core/ui/src/androidMain/res/values-hu-rHU/protection.xml diff --git a/core/ui/src/main/res/values-hu-rHU/strings.xml b/core/ui/src/androidMain/res/values-hu-rHU/strings.xml similarity index 95% rename from core/ui/src/main/res/values-hu-rHU/strings.xml rename to core/ui/src/androidMain/res/values-hu-rHU/strings.xml index 1766ccf5d1a2..8619d5373d0e 100644 --- a/core/ui/src/main/res/values-hu-rHU/strings.xml +++ b/core/ui/src/androidMain/res/values-hu-rHU/strings.xml @@ -15,7 +15,6 @@ Pumpa Némít - %1$+.2f E Kérem várjon… Leállít DIA @@ -54,8 +53,6 @@ Hibás jelszó Jelszavak nem egyeznek - E/ó - g/E Időtartam @@ -80,7 +77,6 @@ Bázis - Bólus BÓLUS MEGSZAKÍT @@ -146,7 +142,6 @@ Ke - %1$.2f E diff --git a/core/ui/src/main/res/values-hu-rHU/strings_scene_wizard.xml b/core/ui/src/androidMain/res/values-hu-rHU/strings_scene_wizard.xml similarity index 100% rename from core/ui/src/main/res/values-hu-rHU/strings_scene_wizard.xml rename to core/ui/src/androidMain/res/values-hu-rHU/strings_scene_wizard.xml diff --git a/core/ui/src/main/res/values-it-rIT/protection.xml b/core/ui/src/androidMain/res/values-it-rIT/protection.xml similarity index 100% rename from core/ui/src/main/res/values-it-rIT/protection.xml rename to core/ui/src/androidMain/res/values-it-rIT/protection.xml diff --git a/core/ui/src/main/res/values-it-rIT/strings.xml b/core/ui/src/androidMain/res/values-it-rIT/strings.xml similarity index 96% rename from core/ui/src/main/res/values-it-rIT/strings.xml rename to core/ui/src/androidMain/res/values-it-rIT/strings.xml index 56df516a2c17..fce6e5608bf9 100644 --- a/core/ui/src/main/res/values-it-rIT/strings.xml +++ b/core/ui/src/androidMain/res/values-it-rIT/strings.xml @@ -38,8 +38,6 @@ Tipo evento mg/dL mmol/L - mg/dL/U - mmol/L/U %1$d mg/dL %1$.1f mmol/L Salva @@ -103,18 +101,14 @@ Dati vecchi Sto per erogare %1$.2f U AAPS avviato - %1$+.2f U - %1$d g %1$d+%2$d g %1$.2f h %1$d min - %1$s: %2$s %1$s %2$s Obiettivi Attendi… Stop STOP PREMUTO - CHO Profilo non valido! NESSUN PROFILO IMPOSTATO ]]> @@ -164,15 +158,12 @@ BG Calibrazione CGM - Esegui allarme in %1$d min Segnalato errore sul bolo. Controlla manualmente la reale quantità erogata Ricorda il bolo quando la glicemia recupera Durata g - Micro sospeso Micro in esecuzione Non configurato - Loop sospeso Loop sospeso da DST ric discesa rapida @@ -222,7 +213,6 @@ Loop chiuso Loop aperto Sospensione a glicemia bassa (LGS) - Micro disconnesso Micro sospeso Sospendi micro Riprendi micro @@ -258,11 +248,6 @@ Le password non coincidono I PIN non coincidono - Valori basali non allineati alle ore: %1$s - Valore basale sostituito dal minimo valore supportato: %1$s - Valore basale sostituito dal massimo valore supportato: %1$s - U/h - g/U Avvia profilo %1$d%% per %2$d min @@ -300,7 +285,6 @@ 30gg - %1$d min Portale Controllo BG @@ -484,7 +468,6 @@ Ripristino delle impostazioni predefinite Loop NS - Record Fianco destro Fianco sinistro Braccio Esterno Superiore Destro @@ -534,7 +517,6 @@ %1$d g di CHO aggiuntivi richiesti entro %2$d minuti Basale - Bolo TDD Dose totale giornaliera @@ -546,7 +528,6 @@ BOLO ESTESO TBR SUPERBOLO CHO - CHO ESTESI BASALE TEMPORANEA TARGET TEMPORANEO NUOVO PROFILO @@ -647,22 +628,15 @@ SCENA DISATTIVATA CONFIG REMOTA MODIFICATA - Target basso (profilo) - Target alto (profilo) Valore inferiore target temporaneo Valore superiore target temporaneo Valore target temporaneo - Valore DIA (profilo) Valore del DIA dell’insulina Valore di picco dell\'insulina - Valore sensibilità (profilo) Valore max basale (profilo) Valore basale corrente - Valore rapporto CHO (profilo) %1$.2f limitato a %2$.2f »%1$s« è fuori dai limiti consentiti - »%1$s« %2$.2f è fuori dai limiti consentiti - Valore basale Nessun profilo selezionato Nome profilo mancante @@ -719,18 +693,12 @@ Consiglio bolo Hai una glicemia alta. Invece di mangiare ora, si consiglia di attendere una glicemia migliore. Vuoi fare adesso un bolo di correzione ed ricevere un promemoria quando è il momento di mangiare? In questo caso non verranno registrati carboidrati e dovrai usare di nuovo il calcolatore quando ti verrà mostrato il promemoria. - COB vs IOB - !!!!! Rilevato assorbimento lento dei carboidrati: %1$d%% del tempo. Ricontrolla il tuo calcolo. I COB potrebbero essere sovrastimati e potrebbe essere somministrata più insulina !!!!! - Vincolo bolo applicato: %1$.2f U a %2$.2f U - Il bolo sarà solo registrato (non erogato dal micro) - Esegui allarme quando è tempo di mangiare BG Correzione Subtotale Pane, pasticcini salati, yogurt, frutta (mela, banana, pera), patate, riso, grano saraceno, farina d\'avena, alimenti lenti e a basso contenuto di carboidrati, verdure Ravioli, pasta, cucina asiatica ricca di carboidrati e grassi, dolci dolci e lieviti, cereali, porridge di grano, miele, marmellata Lasagna, pizza, hamburger, patatine fritte, patate, chips e snack simili - eCarbs %1$dg / %2$dh (+%3$dmin) Totale Tempo Carbo Adesso @@ -969,7 +937,6 @@ Lun %1$.1f U - %1$.2f U %1$+.2f U Velocità: %1$.2f%% (%2$.2f U/h) Durata: %3$d min Velocità: %1$.2f U/h (%2$.2f%%) Durata: %3$d min @@ -1074,4 +1041,45 @@ SENS% VSENS STEPS + giorni + %1$d giorni + %1$d giorni (%2$d - %3$d) + %1$.1f + %1$.2f + %1$.2f (%2$.2f - %3$.2f) + %1$.3f + %1$.3f (%2$.3f - %3$.3f) + %1$.1f (%2$.1f - %3$.1f) + %1$d g + %1$d g (%2$d - %3$d) + %1$d h + %1$.0f h + %1$.0f h (%2$.0f - %3$.0f) + %1$d h (%2$d - %3$d) + %1$.1f U + %1$d U + %1$d U (%2$d - %3$d) + %1$.1f U (%2$.1f - %3$.1f) + %1$.1f U/h + %1$.1f U/h (%2$.1f - %3$.1f) + %1$s mg/dL + %1$s mg/dL (%2$s - %3$s) + %1$d min + %1$d min (%2$d - %3$d) + %1$d%% + %1$d%% (%2$d - %3$d) + %1$d sec + %1$d sec (%2$d - %3$d) + g + h + U + U/h + kg + mg/dL + min + mmol/L + % + sec + y + Intervallo di visualizzazione diff --git a/core/ui/src/main/res/values-it-rIT/strings_scene_wizard.xml b/core/ui/src/androidMain/res/values-it-rIT/strings_scene_wizard.xml similarity index 100% rename from core/ui/src/main/res/values-it-rIT/strings_scene_wizard.xml rename to core/ui/src/androidMain/res/values-it-rIT/strings_scene_wizard.xml diff --git a/core/ui/src/main/res/values-iw-rIL/protection.xml b/core/ui/src/androidMain/res/values-iw-rIL/protection.xml similarity index 100% rename from core/ui/src/main/res/values-iw-rIL/protection.xml rename to core/ui/src/androidMain/res/values-iw-rIL/protection.xml diff --git a/core/ui/src/main/res/values-iw-rIL/strings.xml b/core/ui/src/androidMain/res/values-iw-rIL/strings.xml similarity index 93% rename from core/ui/src/main/res/values-iw-rIL/strings.xml rename to core/ui/src/androidMain/res/values-iw-rIL/strings.xml index 0194e62cb9e2..ec7fee90b6f3 100644 --- a/core/ui/src/main/res/values-iw-rIL/strings.xml +++ b/core/ui/src/androidMain/res/values-iw-rIL/strings.xml @@ -44,13 +44,10 @@ נתונים ישנים עומד להזריק %1$.2f יח\' הופעל AndroidAPS - %1$+.2f יח\' - %1$d גר\' %1$.2f ש\' משימות נא להמתין… עצור - פחמימות פרופיל לא חוקי! לא הוגדר פרופיל ]]> @@ -91,12 +88,9 @@ הנתונים מגיעים ממשאבה אחרת. בחרו מחדש את סוג המשאבה כדי לאפס את מצב המשאבה. BG כיול - הפעל התראה בעוד %1$d דקות משך גר\' - משאבה מושהית לא מוגדר - לולאה מושהית נדרשים ירידה מהירה ירידה @@ -143,11 +137,6 @@ הסיסמאות אינן תואמות קודי PIN אינם תואמים - ערכי הבזאלי לא מותאמים לשעות: %1$s - ערכי הבזאלי הוחלפו בערכים הנתמכים המינימליים: %1$s - ערכי הבזאלי הוחלפו בערכים הנתמכים המינימליים: %1$s - יח\'\\שעה - גר\'\\יח\' הפעלת פרופיל %1$d%% במשך %2$d דק\' @@ -162,7 +151,6 @@ ממוצע - %1$d דק\' פורטל הטיפולים בדיקת רמת סוכר בדם @@ -205,7 +193,6 @@ מותאם אישית לולאה נייטסקאוט - הקלטה החיבור חרג ממגבלת הזמן @@ -218,7 +205,6 @@ %1$d גר\' פחמימות דרושות ב-%2$d הדקות הקרובות בזאלי - בולוס זיהוי שעה @@ -228,7 +214,6 @@ בולוס ממושך בזאלי זמני של סופר בולוס פחמימות - פחמימות מורכבות בזאלי זמני ערך מטרה זמני פרופיל חדש @@ -313,20 +298,13 @@ הלולאה שונתה הלולאה הוסרה - ערך המטרה הנמוך של הפרופיל - ערך המטרה הגבוה של הפרופיל ערך תחתון של המטרה הזמנית ערך עליון של המטרה הזמנית ערך המטרה הזמני - ערך DIA של הפרופיל - ערך הרגישות של הפרופיל מינון בזאלי מרבי של הפרופיל מינון בזאלי נוכחי - יחס הפחמימות של הפרופיל %1$.2f מוגבל ל- %2$.2f »הערך %1$s« מחוץ לתחום הקשיח - »הערך %1$s« %2$.2f מחוץ לתחום הקשיח - ערך בזאלי בולוס %1$.2f יח\' @@ -364,10 +342,6 @@ יועץ הבולוסים יש לכם היפרגליקמיה. במקום לאכול עכשיו מומלץ לחכות לרמת סוכר נמוכה יותר. האם תרצו לעשות בולוס תיקון עכשיו ולאחר מכן תופעל תזכורת לאכול? במקרה זה, לא יירשמו פחמימות ויהיה עליכם להשתמש באשף מחדש כאשר תגשו לאכול. - פחמ\' פעילות לעומת אינ\' פעיל - מגבלת בולוס יושמה: %1$.2f עד %2$.2f יח\' - בולוס רשום בלבד (לא מוזרק על ידי המשאבה) - הפעל התראה כשצריכים לאכול לא נבחרה פעולה, דבר לא יתבצע. אין נתוני סוכר לביסוס חישוב! לא הופעל פרופיל! @@ -489,7 +463,6 @@ ב\' %1$.1f יח\' - %1$.2f יח\' diff --git a/core/ui/src/main/res/values-iw-rIL/strings_scene_wizard.xml b/core/ui/src/androidMain/res/values-iw-rIL/strings_scene_wizard.xml similarity index 100% rename from core/ui/src/main/res/values-iw-rIL/strings_scene_wizard.xml rename to core/ui/src/androidMain/res/values-iw-rIL/strings_scene_wizard.xml diff --git a/core/ui/src/main/res/values-ko-rKR/protection.xml b/core/ui/src/androidMain/res/values-ko-rKR/protection.xml similarity index 100% rename from core/ui/src/main/res/values-ko-rKR/protection.xml rename to core/ui/src/androidMain/res/values-ko-rKR/protection.xml diff --git a/core/ui/src/main/res/values-ko-rKR/strings.xml b/core/ui/src/androidMain/res/values-ko-rKR/strings.xml similarity index 93% rename from core/ui/src/main/res/values-ko-rKR/strings.xml rename to core/ui/src/androidMain/res/values-ko-rKR/strings.xml index c24cc42d1b07..0cb84f47e64a 100644 --- a/core/ui/src/main/res/values-ko-rKR/strings.xml +++ b/core/ui/src/androidMain/res/values-ko-rKR/strings.xml @@ -44,13 +44,10 @@ 오래된 데이터 %1$.2f U을 주입합니다. AAPS 시작 - %1$+.2f U - %1$d g %1$.2f 시간 목표 잠시 기다려 주세요... 정지 - 탄수화물 유효하지 않은 프로파일! 프로파일 설정되지 않음 ]]> @@ -91,12 +88,9 @@ 다른 펌프에서 전송된 데이터. 펌프 상태 재설정을 위해 펌프 드라이버를 바꾸세요. 혈당 보정 - %1$d분 뒤 알람 울림 기간 g - 펌프 일시중지됨 설정되지 않음 - Loop 일시중지 필요량 급속 하락 하락 @@ -141,11 +135,6 @@ 비밀번호가 일치하지 않습니다. PIN이 일치하지 않습니다 - Basal값이 시간단위로 설정되지 않았습니다: %1$s - 지원되는 최대 값으로 Basal 값이 대체되었습니다: %1$s - 지원되는 최대값으로 Basal값이 대체되었습니다:%1$s - U/h - g/U 프로파일 %1$d%%을 %2$d 분 동안 시작 @@ -160,7 +149,6 @@ 평균 - %1$d 분 케어포털 혈당 체크 @@ -203,7 +191,6 @@ 사용자 정의 Loop NS - 기록 연결시간초과 @@ -216,7 +203,6 @@ %2$d 분 내에 %1$d g의 추가적인 탄수화물이 필요함 Basal - Bolus 시간 감지 @@ -226,7 +212,6 @@ 확장 bolus Superbolus TBR 탄수화물 - 확장 탄수화물 임시 basal 임시 목표 새로운 프로파일 @@ -309,20 +294,13 @@ Loop 변경됨 Loop 제거됨 - 프로파일 저혈당 목표 - 프로파일 고혈당 목표 임시 목표 최저값 임시 목표 최고값 임시 목표 수치 - 프로파일 DIA 값 - 프로파일 민감도 값 프로파일의 최대 basal 값 현재 basal 값 - 프로파일의 탄수화물 비율 값 %1$.2f은 %2$.2f까지 제한됨 »%1$s«이 \'고정된 한계값\'을 벗어났습니다. - »%1$s« %2$.2f이 \'고정된 한계값\'을 벗어났습니다. - Basal 값 BOLUS %1$.2f U @@ -357,10 +335,6 @@ Bolus 조언자 현재 높은 혈당을 가지고 있습니다. 지금 먹는 대신 더 나은 혈당을 기다리는 게 좋습니다. 지금 교정bolus 을 진행하고, 식사 시간을 알려드릴까요? 이 경우 탄수화물은 기록되지 않으며, 다시 알림을 줄 때 마법사를 다시 사용해야 합니다. - COB vs IOB - 적용된 Bolus 제약 조건: %1$.2f U 에서 %2$.2f U - Bolus는 이 경우(펌프를 통해 공급되지 않음) 에만 기록됩니다 - 식사 시간이 되면 알람을 울리기 선택한 실행이 없습니다. 아무런 실행이 되지 않습니다. 기저 계산에는 최근 BG가 없습니다! 활성화된 프로파일 설정이 없습니다! @@ -469,7 +443,6 @@ %1$.1f U - %1$.2f U diff --git a/core/ui/src/main/res/values-ko-rKR/strings_scene_wizard.xml b/core/ui/src/androidMain/res/values-ko-rKR/strings_scene_wizard.xml similarity index 100% rename from core/ui/src/main/res/values-ko-rKR/strings_scene_wizard.xml rename to core/ui/src/androidMain/res/values-ko-rKR/strings_scene_wizard.xml diff --git a/core/ui/src/main/res/values-lt-rLT/protection.xml b/core/ui/src/androidMain/res/values-lt-rLT/protection.xml similarity index 100% rename from core/ui/src/main/res/values-lt-rLT/protection.xml rename to core/ui/src/androidMain/res/values-lt-rLT/protection.xml diff --git a/core/ui/src/main/res/values-lt-rLT/strings.xml b/core/ui/src/androidMain/res/values-lt-rLT/strings.xml similarity index 94% rename from core/ui/src/main/res/values-lt-rLT/strings.xml rename to core/ui/src/androidMain/res/values-lt-rLT/strings.xml index b49b47c0fbc7..f43c198bfd9d 100644 --- a/core/ui/src/main/res/values-lt-rLT/strings.xml +++ b/core/ui/src/androidMain/res/values-lt-rLT/strings.xml @@ -44,13 +44,10 @@ Seni duomenys Bus suleista %1$.2f vv AAPS paleista - %1$+.2f vv - %1$dg %1$.2f val. Tikslai Palaukite… Stop - Angliavandeniai Netinkamas profilis! Nenustatytas profilis ]]> @@ -92,13 +89,10 @@ Duomenys gaunami iš kitos pompos. Pakeiskite pompos valdiklį. Glikemija Kalibravimas - Pranešti po %1$d min Boluso suleidimo klaida. Patikrinkite faktiškai suleistą insulino kiekį ir įvestą angliavandenių skaičių Trukmė g - Pompa sustabdyta Nesukonfigūruota - Ciklas sustabdytas reikal. greitai mažėja mažėja @@ -151,11 +145,6 @@ Slaptažodžiai nesutampa PIN kodai nesutampa - Bazės reikšmės nesuderintos su valandomis: %1$s - Nustatyta mažiausia galima valandinės bazės vertė: %1$s - Nustatyta didžiausia galima valandinės bazės vertė: %1$s - v/val - g/v Pradėti profilį %1$d%% %2$d min @@ -170,7 +159,6 @@ Vidurkis - %1$d min. Priežiūra Gliukomatis @@ -215,7 +203,6 @@ Pasirinktinis Ciklas NS - Įrašas Prijungimo laikas baigėsi @@ -228,7 +215,6 @@ Būtina suvartoti %1$d g papildomų AV per %2$d min Valandinė bazė - Bolusas Laiko nustatymas @@ -238,7 +224,6 @@ IŠTĘSTAS BOLUSAS SUPERBOLUSO LB AV - IŠTĘSTI AV LAIKINA BAZĖ LAIKINAS TIKSLAS NAUJAS PROFILIS @@ -323,20 +308,13 @@ CIKLAS PAKEISTAS CIKLAS PAŠALINTAS - Profilio tikslo žemoji riba - Profilio tikslo aukštoji riba Laikino tikslo žemoji riba Laikino tikslo viršutinė riba Laikino tikslo reikšmė - Profilio IVT reikšmė - Profilio JIF reikšmė Maksimali profilio VB reikšmė Dabartinė bazė - Profilio IA reikšmė %1$.2f apribota iki %2$.2f »%1$s« viršija griežtą limitą - »%1$s« %2$.2f viršija griežtą limitą - Valandinė bazė BOLUSAS %1$.2f vv @@ -374,10 +352,6 @@ Boluso patarėjas Jūsų glikemija yra aukšta. Užuot valgę dabar, turėtumėte palaukti, kol sumažės glikemija. Ar norite dabar susileisti korekcinį bolusą ir nustatyti priminimą, kada ateis laikas valgyti? Tokiu atveju angliavandeniai nebus įrašyti, o po priminimo turėsite vėl naudoti boluso skaičiuoklę. - AAO prieš AIO - Pritaikytas boluso apribojimas: %1$.2f v iki %2$.2f v - Bolusas bus tik įrašytas (nebus suleistas) - Pranešti apie laiką valgyti Veiksmas nepasirinktas, nieko neįvyks Nėra naujausių cukraus duomenų, kuriais būtų galima pagrįsti skaičiavimus! Neparinktas aktyvus profilis! @@ -507,7 +481,6 @@ P %1$.1f V - %1$.2f V Dydis: %1$.2f%% (%2$.2f vv/val) Trukmė: %3$d min Dydis: %1$.2f vv/val. (%2$.2f%%) Trukmė: %3$d min Dydis: %1$.2f%% (%2$.2f vv/val.)
Trukmė: %3$d min
]]>
diff --git a/core/ui/src/main/res/values-lt-rLT/strings_scene_wizard.xml b/core/ui/src/androidMain/res/values-lt-rLT/strings_scene_wizard.xml similarity index 100% rename from core/ui/src/main/res/values-lt-rLT/strings_scene_wizard.xml rename to core/ui/src/androidMain/res/values-lt-rLT/strings_scene_wizard.xml diff --git a/core/ui/src/main/res/values-nb-rNO/protection.xml b/core/ui/src/androidMain/res/values-nb-rNO/protection.xml similarity index 100% rename from core/ui/src/main/res/values-nb-rNO/protection.xml rename to core/ui/src/androidMain/res/values-nb-rNO/protection.xml diff --git a/core/ui/src/main/res/values-nb-rNO/strings.xml b/core/ui/src/androidMain/res/values-nb-rNO/strings.xml similarity index 96% rename from core/ui/src/main/res/values-nb-rNO/strings.xml rename to core/ui/src/androidMain/res/values-nb-rNO/strings.xml index 94cfe6efe835..cb12b4db25b0 100644 --- a/core/ui/src/main/res/values-nb-rNO/strings.xml +++ b/core/ui/src/androidMain/res/values-nb-rNO/strings.xml @@ -38,8 +38,6 @@ Hendelsestype mg/dL mmol/L - mg/dL/E - mmol/L/E %1$d mg/dL %1$.1f mmol/L Lagre @@ -106,18 +104,14 @@ Leverer %1$.2f enheter Bolusen ble levert, men karbohydratene kunne ikke lagres. Registrer karbo på nytt. AAPS startet - %1$+.2f E - %1$d g %1$d+%2$d g %1$.2f t %1$d min - %1$s: %2$s %1$s %2$s Opplæringsmål Vennligst vent… Stopp STOPP TRYKKET - Karbo Ugyldig profil! INGEN PROFIL VALGT Kan ikke bytte profil: Ingen insulin er i bruk, og ingen er valgt. @@ -173,15 +167,12 @@ BS Kalibrering Sensor - Aktiver alarm om %1$d min Det er registrert en feil med boluslevering. Sjekk manuelt om den er levert og hvor mye Påminnelse om bolus når BS kommer seg igjen Varighet g - Pumpen er pauset Pumpe i drift Ikke konfigurert - Loop pauset Loop suspendert av DST (Sommertid) nødv synker raskt @@ -231,7 +222,6 @@ Lukket Loop Åpen Loop Stopp ved lavt BS - Pumpe frakoblet Pumpe pauset Pause pumpe Gjenoppta pumpe @@ -267,11 +257,6 @@ Passord stemmer ikke overens PIN-kodene samsvarer ikke - Basalverdier er ikke angitt på hele timer: %1$s - Basalverdi erstattet med minste tillate verdi: %1$s - Basalverdi erstattet med høyeste tillate verdi: %1$s - E/t - g/E Start profil %1$d%% i %2$d min @@ -309,7 +294,6 @@ 30d - %1$d min Helseportal BS-kontroll @@ -493,7 +477,6 @@ Tilbakestill til standardinnstillinger Loop NS - Registrer Høyre bryst Venstre bryst Øvre høyre ytterside av armen @@ -543,7 +526,6 @@ %1$d g ekstra karbohydrater kreves innen %2$d minutter Basal - Bolus TDD Total daglig dose @@ -555,7 +537,6 @@ FORLENGET BOLUS SUPERBOLUS TBR KARBO - FORLENGET KARBO MIDL. BASAL MIDLERTIDIG MÅL NY PROFIL @@ -656,22 +637,15 @@ SCENE DEAKTIVERT FJERNKONFIGURASJON ENDRET - Profil lavt mål - Profil høyt mål Nedre grense for midlertidig mål Øvre grense for midlertidig mål Midlertidig målverdi - Profil DIA verdi Insulin DIA-verdi Insulin toppverdi - Profilens insulinfølsomhet Maksimal profil basalverdi Nåværende basalverdi - Profilens insulin-til-karbohydratforhold (IK) %1$.2f begrenset til %2$.2f »%1$s« er utenfor lovlige grenseverdier - »%1$s« %2$.2f er utenfor lovlige grenseverdier - Basalverdi Ingen profil valgt Mangler profilnavn @@ -728,18 +702,12 @@ Bolusveiviser Du har høyt blodsukker. I stedet for å spise nå er det bedre å utsette det til du har et lavere blodsukker. Ønsker du å sette en korreksjonsbolus nå og få en påminnelse om når det er på tide å spise? I dette tilfellet vil ingen karbohydrater registreres nå, og du må bruke boluskalkulatoren igjen når vi gir deg en påminnelse. - COB vs IOB - !!!!! Langsom karboabsorpsjon oppdaget: %1$d%% av tiden. Dobbeltsjekk beregningen din. COB kan overestimeres, og dermed kan mer insulin gis !!!!! - Bolus begrensning brukt: %1$.2f E til %2$.2f E - Bolus vil bare bli loggført (ikke levert av pumpe) - Aktiver alarm når det er på tide å spise BS Korreksjon Delsum Brød, bakverk, yoghurt, frukt (eple, banan, pære), poteter, ris, bokhvete, havregryn, langsom og lavkarbo mat, grønnsaker Dumplings, pasta, asiatisk mat rik på karbohydrater og fett, søt gjærbakst, frokostblandinger, grøt med korn, honning, syltetøy Lasagne, pizza, hamburgere, pommes frites, bakte poteter, chips og lignende snacks - eKarbo %1$dg / %2$dt (+%3$dmin) Total Karbotid @@ -984,7 +952,6 @@ Man %1$.1f E - %1$.2f E %1$+.2f E Dose: %1$.2f%% (%2$.2f E/t) Varighet: %3$d min Dose: %1$.2f E/t (%2$.2f%%) Varighet: %3$d min @@ -1089,4 +1056,45 @@ SENS% DISF SKRITT + dager + %1$d dager + %1$d dager (%2$d - %3$d) + %1$.1f + %1$.2f + %1$.2f (%2$.2f - %3$.2f) + %1$.3f + %1$.3f (%2$.3f - %3$.3f) + %1$.1f (%2$.1f - %3$.1f) + %1$d g + %1$d g (%2$d - %3$d) + %1$d t + %1$.0f t + %1$.0f t (%2$.0f - %3$.0f) + %1$d t (%2$d - %3$d) + %1$.1f E + %1$d E + %1$d E (%2$d - %3$d) + %1$.1f E (%2$.1f - %3$.1f) + %1$.1f E/t + %1$.1f E/t (%2$.1f - %3$.1f) + %1$s mg/dL + %1$s mg/dL (%2$s - %3$s) + %1$d min + %1$d min (%2$d - %3$d) + %1$d%% + %1$d%% (%2$d - %3$d) + %1$d sek + %1$d sek (%2$d - %3$d) + g + t + E + E/t + kg + mg/dL + min + mmol/L + % + sek + år + Område for visualisering diff --git a/core/ui/src/main/res/values-nb-rNO/strings_scene_wizard.xml b/core/ui/src/androidMain/res/values-nb-rNO/strings_scene_wizard.xml similarity index 100% rename from core/ui/src/main/res/values-nb-rNO/strings_scene_wizard.xml rename to core/ui/src/androidMain/res/values-nb-rNO/strings_scene_wizard.xml diff --git a/core/ui/src/main/res/values-night/colors.xml b/core/ui/src/androidMain/res/values-night/colors.xml similarity index 100% rename from core/ui/src/main/res/values-night/colors.xml rename to core/ui/src/androidMain/res/values-night/colors.xml diff --git a/core/ui/src/main/res/values-night/styles.xml b/core/ui/src/androidMain/res/values-night/styles.xml similarity index 100% rename from core/ui/src/main/res/values-night/styles.xml rename to core/ui/src/androidMain/res/values-night/styles.xml diff --git a/core/ui/src/main/res/values-nl-rNL/protection.xml b/core/ui/src/androidMain/res/values-nl-rNL/protection.xml similarity index 100% rename from core/ui/src/main/res/values-nl-rNL/protection.xml rename to core/ui/src/androidMain/res/values-nl-rNL/protection.xml diff --git a/core/ui/src/main/res/values-nl-rNL/strings.xml b/core/ui/src/androidMain/res/values-nl-rNL/strings.xml similarity index 94% rename from core/ui/src/main/res/values-nl-rNL/strings.xml rename to core/ui/src/androidMain/res/values-nl-rNL/strings.xml index 3943f84d1429..d0fbed7112f5 100644 --- a/core/ui/src/main/res/values-nl-rNL/strings.xml +++ b/core/ui/src/androidMain/res/values-nl-rNL/strings.xml @@ -44,13 +44,10 @@ Oude gegevens %1$.2f E toedienen AAPS gestart - %1$+.2f E - %1$d g %1$.2f u Doelen Even geduld a.u.b.… Stop - Koolhydraten Ongeldig profiel! GEEN PROFIEL INGESTELD ]]> @@ -94,14 +91,11 @@ Data komt van een andere pomp. Wijzig de pomp driver om de pomp status te resetten. BG Kalibratie - Start alarm over %1$d min Bolus fout geconstateerd. Controleer handmatig de werkelijk toegediende hoeveelheid insuline en koolhydraten Tijdsduur g - Pomp onderbreken Pomp is actief Niet ingesteld - Loop pauzeren Loop onderbroken voor zomer/wintertijd (DST) nodig snel dalend @@ -134,7 +128,6 @@ Closed loop Open loop Stop bij laag - Pomp niet verbonden Pomp is onderbroken Modus teruggezet DIA @@ -160,11 +153,6 @@ Wachtwoorden komen niet overeen PIN-codes komen niet overeen - Basaalstanden niet ingesteld in hele uren: %1$s - Minimum basaalwaarde is vervangen door minimaal ondersteunde waarde: %1$s - Basale waarde vervangen door maximale ondersteunde waarde: %1$s - E/u - g/E Start profiel %1$d%% voor %2$d min @@ -179,7 +167,6 @@ Gemiddelde - %1$d min Zorgportaal BG Controle @@ -227,7 +214,6 @@ Aangepast Loop NS - Opnemen Borst rechts Borst links Rechter bovenarm @@ -273,7 +259,6 @@ %1$d g extra koolhydraten nodig binnen %2$d minuten Basaal - Bolus Tijd detectie @@ -283,7 +268,6 @@ VERLENGDE BOLUS SUPERBOLUS TBR KOOLHYDRATEN - VERLENGDE KOOLHYDRATEN TIJDELIJK BASAAL TIJDELIJK DOEL NIEUW PROFIEL @@ -375,20 +359,13 @@ BEDRIJFSMODE VERWIJDERD BEDRIJFSMODE GEWIJZIGD - Profiel laag doel - Profiel hoog doel Tijdelijk streefdoel ondergrens Tijdelijk streefdoel bovengrens Tijdelijk streefdoel waarde - Profiel DIA waarde - Profiel gevoeligheidswaarde Maximale basaal waarde van het profiel Huidige basaal waarde - Profiel koolhydraten ratio waarde %1$.2f gelimiteerd tot %2$.2f »%1$s« is buiten de harde limiet - »%1$s« %2$.2f is buiten de harde limiet - Basaal waarde BOLUS %1$.2f E @@ -426,10 +403,6 @@ Bolusadviseur Je hebt een hoge bloedglucose. In plaats van te eten, is het nu aan te raden om te wachten op een betere bloedglucose. Wil je nu een correctiebolus uitvoeren en je laten weten wanneer het tijd is om te eten? In dit geval worden er geen koolhydraten opgenomen en moet je de wizard opnieuw gebruiken wanneer we je er aan herinneren. - COB vs IOB - Bolusbeperking toegepast: %1$.2f E naar %2$.2f E - Bolus wordt alleen geregistreerd (niet toegediend door pomp) - Start alarm wanneer het tijd is om te eten Geen actie geselecteerd, er zal niets uitgevoerd worden Geen recente BG om de berekening op te baseren! Geen actief profiel ingesteld! @@ -553,7 +526,6 @@ Ma %1$.1f E - %1$.2f E Basaal: %1$.2f%% (%2$.2f E/h) Duur: %3$d min Basaal: %1$.2f E/h (%2$.2f%%) Duur: %3$d min Basaal: %1$.2f%% (%2$.2f E/h)
Duur: %3$d min
]]>
diff --git a/core/ui/src/main/res/values-nl-rNL/strings_scene_wizard.xml b/core/ui/src/androidMain/res/values-nl-rNL/strings_scene_wizard.xml similarity index 100% rename from core/ui/src/main/res/values-nl-rNL/strings_scene_wizard.xml rename to core/ui/src/androidMain/res/values-nl-rNL/strings_scene_wizard.xml diff --git a/core/ui/src/main/res/values-pl-rPL/protection.xml b/core/ui/src/androidMain/res/values-pl-rPL/protection.xml similarity index 100% rename from core/ui/src/main/res/values-pl-rPL/protection.xml rename to core/ui/src/androidMain/res/values-pl-rPL/protection.xml diff --git a/core/ui/src/main/res/values-pl-rPL/strings.xml b/core/ui/src/androidMain/res/values-pl-rPL/strings.xml similarity index 94% rename from core/ui/src/main/res/values-pl-rPL/strings.xml rename to core/ui/src/androidMain/res/values-pl-rPL/strings.xml index c0ff8c2a2060..1943c48cbc4e 100644 --- a/core/ui/src/main/res/values-pl-rPL/strings.xml +++ b/core/ui/src/androidMain/res/values-pl-rPL/strings.xml @@ -44,13 +44,10 @@ Stare dane Zamierzam podać %1$.2f U AAPS uruchomiony - %1$+.2f U - %1$d g %1$.2f h Zadania Proszę czekać… Stop - Węglowodany Nieprawidłowy profil! NIE USTAWIONO PROFILU ]]> @@ -94,14 +91,11 @@ Dane pochodzą z innej pompy. Zmień sterownik pompy, aby zresetować stan pompy. BG Kalibracja - Uruchom alarm za %1$d min Błąd podczas podawania bolusa. Sprawdź ręcznie ile faktycznie podano insuliny i ilość węglowodanów Czas trwania g - Pompa wstrzymana Pompa uruchomiona Nie skonfigurowano - Pętla wstrzymana Pętla zawieszona przez zmianę czasu (na letni lub zimowy) wym szybko spada @@ -134,7 +128,6 @@ Zamknięta pętla Otwarta pętla Zawieszenie przy niskiej glikemii - Pompa odłączona Pompa wstrzymana Tryb przywrócony DIA @@ -160,11 +153,6 @@ Hasła się nie zgadzają Kody PIN nie pasują do siebie - Wartości bazy nie są ustawione w pełnych godzinach: %1$s - Wartość bazy zastąpiona minimalną obsługiwaną wartością: %1$s - Wartość bazy zastąpiona maksymalną obsługiwaną wartością: %1$s - U/h - g/U Uruchom profil %1$d%% na %2$d min @@ -179,7 +167,6 @@ Średnio - %1$d min PortalOpieki Sprawdź BG @@ -227,7 +214,6 @@ Niestandardowe Pętla NS - Wpis Prawa strona klatki Lewa strona klatki Górny bok prawego ramienia @@ -273,7 +259,6 @@ Zalecane podanie %1$d g węglowodanów w przeciągu %2$d minut Baza - Bolus Wykrywanie czasu @@ -283,7 +268,6 @@ BOLUS PRZEDŁUŻONY SUPERBOLUS TBR WĘGLOWODANY - PRZEDŁUŻONE WĘGLOWODANY BAZA TYMCZASOWA CEL TYMCZASOWY NOWY PROFIL @@ -375,20 +359,13 @@ USUNIĘTO TRYB DZIAŁANIA ZAKTUALIZOWANO TRYB DZIAŁANIA - Dolna granica celu profilu - Górna granica celu profilu Dolna wartość celu tymczasowego Górna wartość celu tymczasowego Wartość celu tymczasowego - Wartość DIA profilu - Wartość wrażliwości profilu Maksymalna wartość bazowa profilu Bieżąca wartość bazowa - Stosunek węglowodanów profilu %1$.2f ograniczone do %2$.2f Wartość »%1$s« jest poza dopuszczalną granicą - Wartość »%1$s« %2$.2f jest poza dopuszczalną granicą - Wartość bazy BOLUS %1$.2f U @@ -426,10 +403,6 @@ Doradca bolusa Masz wysoką glikemię. Zamiast jeść teraz, zaleca się poczekać na wyrównanie poziomu cukru. Czy chcesz wykonać teraz bolus korekcyjny i otrzymać przypomnienie, kiedy nadejdzie czas na posiłek? W tym przypadku żadne węglowodany nie zostaną zarejestrowane i należy ponownie użyć kalkulatora, po otrzymaniu przypomnienia o posiłku. - COB vs IOB - Zastosowano ograniczenie bolusa: %1$.2f U do %2$.2f U - Bolus zostanie jedynie odnotowany (nie będzie podany przez pompę) - Uruchom alarm kiedy będzie czas na jedzenie Nie wybrano żadnej akcji, zdarzenie nie będzie wprowadzone Brak niezbędnego do obliczeń bieżącego pomiaru glikemi! Nie ustawiono aktywnego profilu! @@ -559,7 +532,6 @@ Pon %1$.1f U - %1$.2f U Dawka: %1$.2f%% (%2$.2f U/h) Czas: %3$d min Dawka: %1$.2f U/h (%2$.2f%%) Czas: %3$d min Dawka: %1$.2f%% (%2$.2f U/h)
Czas: %3$d min
]]>
diff --git a/core/ui/src/main/res/values-pl-rPL/strings_scene_wizard.xml b/core/ui/src/androidMain/res/values-pl-rPL/strings_scene_wizard.xml similarity index 100% rename from core/ui/src/main/res/values-pl-rPL/strings_scene_wizard.xml rename to core/ui/src/androidMain/res/values-pl-rPL/strings_scene_wizard.xml diff --git a/core/ui/src/main/res/values-pt-rBR/protection.xml b/core/ui/src/androidMain/res/values-pt-rBR/protection.xml similarity index 100% rename from core/ui/src/main/res/values-pt-rBR/protection.xml rename to core/ui/src/androidMain/res/values-pt-rBR/protection.xml diff --git a/core/ui/src/main/res/values-pt-rBR/strings.xml b/core/ui/src/androidMain/res/values-pt-rBR/strings.xml similarity index 93% rename from core/ui/src/main/res/values-pt-rBR/strings.xml rename to core/ui/src/androidMain/res/values-pt-rBR/strings.xml index f113f452b6be..a20c990c0e1c 100644 --- a/core/ui/src/main/res/values-pt-rBR/strings.xml +++ b/core/ui/src/androidMain/res/values-pt-rBR/strings.xml @@ -43,13 +43,10 @@ DDT Total Iniciando aplicação de %1$.2f U AAPS iniciado - %1$+.2f U - %1$d g %1$.2f h Objetivos Por favor aguarde… Parar - Carbos Perfil inválido! SEM PERFIL DEFINIDO ]]> @@ -90,12 +87,9 @@ Os dados estão vindo de uma bomba diferente. Alterar o driver da bomba para redefinir o estado da bomba. GLIC Calibração - Disparar alarme em %1$d min Duração g - Bomba suspensa Não configurado - Loop suspenso req caindo rapidamente caindo @@ -138,11 +132,6 @@ As passwords não coincidem PINs não conferem - Valores das basais não definidos por horas: %1$s - Valor da basal alterado para o valor mínimo suportado: %1$s - Valor da basal alterado para o valor máximo suportado: %1$s - U/h - g/U Iniciar perfil %1$d%% para %2$d min @@ -157,7 +146,6 @@ Média - %1$d min Careportal Verificação BG @@ -200,7 +188,6 @@ Personalizado Loop NS - Gravar Ligação expirou @@ -213,7 +200,6 @@ %1$d g de carboidratos necessários em %2$d minutos Basal - Bólus Deteção de tempo @@ -223,7 +209,6 @@ BOLUS ESTENDIDO TBR SUPERBOLUS CARBOIDRATOS - CARBOIDRATOS ESTENDIDOS BASAL TEMPORÁRIO ALVO TEMPORÁRIO NOVO PERFIL @@ -306,20 +291,13 @@ LOOP ALTERADO LOOP REMOVIDO - Alvo de perfil de hipoglicemia - Alvo de perfil de hiperglicemia Valor menor do alvo temporário Valor maior do alvo temporário Valor do alvo temporário - Valor do perfil da DAI - Valor do perfil de sensibilidade Valor basal máximo do perfil Valor basal atual - Valor do perfil da taxa de carboidratos %1$.2f limitado a %2$.2f >>%1$s<< está fora dos limites estabelecidos - »%1$s« %2$.2f está fora dos limites estabelecidos - Valor basal Bolus de %1$.2f U @@ -354,10 +332,6 @@ Assistente de bolus Sua glicemia está alta. Em vez de comer agora, é recomendado esperar por uma glicemia melhor. Quer fazer um bolus de correção agora e ser lembrado de quando for hora de comer? Neste caso, nenhum carboidrato será registrado e você deverá usar o assistente novamente quando lembrarmos você. - CA vs IA - Restrição de bólus aplicada: %1$.2f U para %2$.2f U - Bolus será apenas registrado (não administrado pela bomba) - Disparar alarme quando for a hora de comer Nenhuma acção seleccionada, nada irá acontecer Nenhuma glicemia recente para base de cálculo! Nenhum perfil ativo definido! diff --git a/core/ui/src/main/res/values-pt-rBR/strings_scene_wizard.xml b/core/ui/src/androidMain/res/values-pt-rBR/strings_scene_wizard.xml similarity index 100% rename from core/ui/src/main/res/values-pt-rBR/strings_scene_wizard.xml rename to core/ui/src/androidMain/res/values-pt-rBR/strings_scene_wizard.xml diff --git a/core/ui/src/main/res/values-pt-rPT/protection.xml b/core/ui/src/androidMain/res/values-pt-rPT/protection.xml similarity index 100% rename from core/ui/src/main/res/values-pt-rPT/protection.xml rename to core/ui/src/androidMain/res/values-pt-rPT/protection.xml diff --git a/core/ui/src/main/res/values-pt-rPT/strings.xml b/core/ui/src/androidMain/res/values-pt-rPT/strings.xml similarity index 93% rename from core/ui/src/main/res/values-pt-rPT/strings.xml rename to core/ui/src/androidMain/res/values-pt-rPT/strings.xml index 939999c9e9ed..569f175ae7ca 100644 --- a/core/ui/src/main/res/values-pt-rPT/strings.xml +++ b/core/ui/src/androidMain/res/values-pt-rPT/strings.xml @@ -43,13 +43,10 @@ TID Total A ser administrado %1$.2f U AAPS iniciada - %1$+.2f U - %1$d g %1$.2f h Objectivos Por favor aguarde… Parar - Hidratos Perfil inválido! SEM PERFIL DEFINIDO ]]> @@ -90,12 +87,9 @@ Os dados vêm de uma bomba diferente. Altera o driver da bomba para redefinir o seu estado. GLIC Calibração - Executar alarme em %1$d min Duração g - Bomba suspensa Não configurado - Loop suspenso req a descer rapidamente a descer @@ -138,11 +132,6 @@ Palavras-passe não correspondem Os PINs não correspondem - Valores das basais não definidos por horas: %1$s - Valor da basal alterado para o valor mínimo suportado: %1$s - Valor da basal alterado para o valor máximo suportado: %1$s - U/h - g/U Iniciar perfil %1$d%% para %2$d min @@ -157,7 +146,6 @@ Média - %1$d min Careportal Verificar Glicose @@ -200,7 +188,6 @@ Personalizado Loop NS - Registo Ligação expirou @@ -213,7 +200,6 @@ %1$d g Hidratos Adicionais Necessários Dentro de %2$d Minutos Basal - Bólus Detecção de tempo @@ -223,7 +209,6 @@ BÓLUS PROLONGADO DBT SUPERBÓLUS HC - HC LENTOS BASAL TEMPORÁRIA ALVO TEMPORÁRIO NOVO PERFIL @@ -306,20 +291,13 @@ LOOP ALTERADO LOOP REMOVIDO - Valor mínimo alvo do perfil - Valor máximo alvo do perfil Valor inferior do alvo temporário Valor superior do alvo temporário Valor Alvo Temporário - Valor Perfil DIA - Valor Perfil Sensibilidade Valor Perfil Basal Máxima Valor Actual Basal - Valor Perfil Rácio Hidratos %1$.2f limitado a %2$.2f »%1$s« está fora dos limites máximos - »%1$s« %2$.2f está fora dos limites permitidos - Valor da Basal BOLUS %1$.2f U @@ -344,8 +322,6 @@ Alarme Urgente INFO - HCA vs IA - Executar alarme quando for tempo de comer Nenhuma ação seleccionada, nada irá acontecer Adolescente @@ -437,7 +413,6 @@ Seg %1$.1f U - %1$.2f U diff --git a/core/ui/src/main/res/values-pt-rPT/strings_scene_wizard.xml b/core/ui/src/androidMain/res/values-pt-rPT/strings_scene_wizard.xml similarity index 100% rename from core/ui/src/main/res/values-pt-rPT/strings_scene_wizard.xml rename to core/ui/src/androidMain/res/values-pt-rPT/strings_scene_wizard.xml diff --git a/core/ui/src/main/res/values-ro-rRO/protection.xml b/core/ui/src/androidMain/res/values-ro-rRO/protection.xml similarity index 100% rename from core/ui/src/main/res/values-ro-rRO/protection.xml rename to core/ui/src/androidMain/res/values-ro-rRO/protection.xml diff --git a/core/ui/src/main/res/values-ro-rRO/strings.xml b/core/ui/src/androidMain/res/values-ro-rRO/strings.xml similarity index 96% rename from core/ui/src/main/res/values-ro-rRO/strings.xml rename to core/ui/src/androidMain/res/values-ro-rRO/strings.xml index 2696c5326ebc..b402e450d865 100644 --- a/core/ui/src/main/res/values-ro-rRO/strings.xml +++ b/core/ui/src/androidMain/res/values-ro-rRO/strings.xml @@ -38,8 +38,6 @@ Tip eveniment mg/dL mmol/L - mg/dL/U - mmol/l/U %1$d mg/dL %1$.1f mmol/l Salvați @@ -106,18 +104,14 @@ Se vor administra %1$.2fU Bolus administrat, dar carbohidrații nu au putut fi salvați. Vă rugăm să introduceți carbohidrații din nou. AAPS pornit - %1$+.2f U - %1$d g %1$d+%2$d g %1$.2f h %1$d min - %1$s: %2$s %1$s %2$s Obiective Așteptați… Stop STOP APĂSAT - Carbohidrați Profil invalid! NICIUN PROFIL SETAT Nu se poate schimba profilul: nicio insulină nu este în uz și nu a fost selectată niciuna. @@ -173,15 +167,12 @@ Glicemie Calibrare CGM - Rulați alarma în %1$d minute Bolusarea a raportat o eroare. Verificați manual insulina administrată și cantitatea de carbohidrați Reamintiți de bolus atunci când glicemia revine Durată g - Pompă suspendată Pompa în funcțiune Nu este configurat - Buclă suspendată Bucla suspendată din cauza orei de vară necesar în scădere rapidă @@ -231,7 +222,6 @@ Buclă închisă Buclă deschisă Suspendare la glicemie scăzută - Pompă deconectată Pompă suspendată Suspendați pompa Reluați pompa @@ -267,11 +257,6 @@ Parolele nu corespund PIN-urile nu se potrivesc - Valori bazale nesincronizate cu ora: %1$s - Valoarea bazalei a fost înlocuită cu valoarea minimă posibilă: %1$s - Valoarea bazalei a fost înlocuită cu valoarea maximă posibilă: %1$s - U/h - g/U Utilizați profilul %1$d%% pentru %2$d min @@ -309,7 +294,6 @@ 30z - %1$d min Careportal Verificare glicemie @@ -493,7 +477,6 @@ Reveniți la valorile implicite Buclă NS - Înregistrare Piept dreapta Piept stânga Partea superioară laterală a brațului drept @@ -543,7 +526,6 @@ %1$d g carbohidrați suplimentari necesari în %2$d minute Bazală - Bolus DZT (Doza zilnică totală) Doza zilnică totală @@ -555,7 +537,6 @@ BOLUS EXTINS RBT SUPERBOLUS CARBOHIDRAȚI - CARBOHIDRAȚI EXTINȘI BAZALĂ TEMPORARĂ ȚINTĂ TEMPORARĂ PROFIL NOU @@ -656,22 +637,15 @@ SCENARIU DEZACTIVAT CONFIGURAȚIA PENTRU TELECOMANDĂ MODIFICATĂ - Profil ținta joasă - Profil țintă ridicată Limita inferioară a țintei temporare Limita superioară a țintei temporare Valoare țintă temporară - Valoare profil DIA Valoarea DIA a insulinei Vârful de acțiune al insulinei - Valoare sensibilitate profil Valoare bazală maximă a profilului Valoare bazală curentă - Valoarea raportului carbohidrați din profil %1$.2f limitat la %2$.2f »%1$s« este in afara limitelor stabilite - »%1$s« %2$.2f este in afara limitelor stabilite - Valoare rata bazală Niciun profil selectat Lipsește numele profilului @@ -728,18 +702,12 @@ Consilier bolus Ai glicemia crescută. În loc să mănânci acum, este recomandat să aştepți o glicemie mai bună. Vrei să faci un bolus de corecție acum și să îți reamintesc când este timpul să mănânci? În acest caz, niciun carbohidrat nu va fi înregistrat și trebuie să utilizezi din nou calculatorul de vbolus când îți voi reaminti. - COB vs IOB - !!!!! S-a detectat o absorbție lentă de carbohidrați: %1$d%% din timp. Verificați de două ori calculul. COB poate fi supraestimat, astfel încât mai multă insulină poate fi administrată !!!!! - Este aplicată limitarea bolusului %1$.2f U la %2$.2f U - Bolusul doar va fi înregistrat (nu va fi administrat de pompă) - Executați alarma când este timpul să mâncați Glicemie Corecție Subtotal Pâine, patiserie sărată, iaurt, fructe (măr, banană, pară), cartofi, orez, hrișcă, ovăz, alimente cu carbohidrați lenți și puțini, legume Găluște, paste, bucătărie asiatică bogată în carbohidrați și grăsimi, patiserie dulce și cu drojdie, cereale, terci de cereale, miere, gem Lasagna, pizza, hamburgeri, cartofi prăjiți, cartofi copți, chipsuri și gustări similare - carbohidrați extinși %1$dg / %2$dh (+%3$dmin) Total Timp carbohidrați Acum @@ -987,7 +955,6 @@ Lun %1$.1f U - %1$.2f U %1$+.2f U Rată: %1$.2f%% (%2$.2f U/h) Durată: %3$d min Rată: %1$.2f U/h (%2$.2f%%) Durată: %3$d min @@ -1092,4 +1059,45 @@ SENS% VSENS PAȘI + zile + %1$d zile + %1$d zile (%2$d - %3$d) + %1$.1f + %1$.2f + %1$.2f (%2$.2f - %3$.2f) + %1$.3f + %1$.3f (%2$.3f - %3$.3f) + %1$.1f (%2$.1f - %3$.1f) + %1$d g + %1$d g (%2$d - %3$d) + %1$d h + %1$.0f h + %1$.0f h (%2$.0f - %3$.0f) + %1$d h (%2$d - %3$d) + %1$.1f U + %1$d U + %1$d U (%2$d - %3$d) + %1$.1f U (%2$.1f - %3$.1f) + %1$.1f U/h + %1$.1f U/o (%2$.1f - %3$.1f) + %1$s mg/dL + %1$s mg/dL (%2$s - %3$s) + %1$d min + %1$d min (%2$d - %3$d) + %1$d%% + %1$d%% (%2$d - %3$d) + %1$d sec + %1$d sec (%2$d - %3$d) + g + h + U + U/h + kg + mg/dL + minute + mmol/L + % + secunde + an + Intervalul pentru vizualizare diff --git a/core/ui/src/main/res/values-ro-rRO/strings_scene_wizard.xml b/core/ui/src/androidMain/res/values-ro-rRO/strings_scene_wizard.xml similarity index 100% rename from core/ui/src/main/res/values-ro-rRO/strings_scene_wizard.xml rename to core/ui/src/androidMain/res/values-ro-rRO/strings_scene_wizard.xml diff --git a/core/ui/src/main/res/values-ru-rRU/protection.xml b/core/ui/src/androidMain/res/values-ru-rRU/protection.xml similarity index 100% rename from core/ui/src/main/res/values-ru-rRU/protection.xml rename to core/ui/src/androidMain/res/values-ru-rRU/protection.xml diff --git a/core/ui/src/main/res/values-ru-rRU/strings.xml b/core/ui/src/androidMain/res/values-ru-rRU/strings.xml similarity index 94% rename from core/ui/src/main/res/values-ru-rRU/strings.xml rename to core/ui/src/androidMain/res/values-ru-rRU/strings.xml index 625f72e95d74..4544af3934e9 100644 --- a/core/ui/src/main/res/values-ru-rRU/strings.xml +++ b/core/ui/src/androidMain/res/values-ru-rRU/strings.xml @@ -44,13 +44,10 @@ Старые данные Будет подано %1$.2f ед инс AAPS запущен - %1$+.2f ед - %1$d гр %1$.2f ч Цели Подождите… Стоп - Углеводы Недопустимый профиль! ПРОФИЛЬ НЕ ЗАДАН @@ -94,14 +91,11 @@ Данные поступают с другой помпы. Измените драйвер помпы, чтобы сбросить ее состояние. ГК калибровка - Напомнить через %1$d мин Возможная ошибка подачи болюса. Проверьте количество реально поданного инсулина и углеводов Длительность действия грамм - Работа помпы остановлена Помпа работает Не настроено - ЗЦ остановлен Помпа остановлена при переходе времени надо быстрое падение @@ -134,7 +128,6 @@ Замкнутый цикл Открытый цикл Приостановка помпы на низкой ГК - Помпа отключена Работа помпы приостановлена Режим возвращен к предыдущему состоянию Время действия инсулина DIA @@ -160,11 +153,6 @@ Пароли не совпадают PIN-коды не совпадают - Базальные значения не выровнены по часам: %1$s - Значение базала заменено минимальной поддерживаемой величиной: %1$s - Значение базала заменено максимальной поддерживаемой величиной: %1$s - ед/ч - г/ед Начать профиль %1$d%% на %2$d мин @@ -179,7 +167,6 @@ Средний - %1$d мин. Портал терапии Проверка ГК @@ -227,7 +214,6 @@ Пользовательский Замкнутый цикл NS - Запись Правая сторона груди Левая сторона груди Верхняя внешняя часть правого плеча @@ -273,7 +259,6 @@ Необходимо дополнительно %1$d г углеводов в течение %2$d минут базал - Болюс Определение времени @@ -283,7 +268,6 @@ ПРОЛОНГИРОВАННЫЙ БОЛЮС СУПЕРБОЛЮС ВБС/TBR УГЛЕВОДЫ - ПРОЛОНГИРОВАННЫЕ УГЛЕВОДЫ ВРЕМЕННЫЙ БАЗАЛ ВРЕМ ЦЕЛЬ НОВЫЙ ПРОФИЛЬ @@ -375,20 +359,13 @@ РАБОЧИЙ РЕЖИМ УДАЛЕН РАБОЧИЙ РЕЖИМ ОБНОВЛЕН - Нижнее целевое значение профиля - Верхнее целевое значение профиля Нижнее значение временного целевого уровня Верхнее значение временного целевого уровня Временное целевое значение - Значение длительности действия инсулина DIA в профиле - Значение чувствительности в профиле Максимальное значение базала в профиле Текущее значение базала - Значение Углеводного коэффициента IC в профиле %1$.2f ограничено до %2$.2f »%1$s« за пределами жестких ограничений - »%1$s« %2$.2f за пределами жестких ограничений - Величина базала БОЛЮС %1$.2f ЕД @@ -426,10 +403,6 @@ Помощник болюса У вас высокая гликемия. Лучше подождать, чем есть сейчас. Хотите сделать болюс на коррекцию и установить напоминание о приеме пищи? В этом случае углеводы не будут записаны и после напоминания надо будет снова воспользоваться помощником. - угл COB к инс IOB - Применено ограничение болюса: %1$.2f ед. до %2$.2f ед. - Болюс будет только записан (без подачи помпой) - Напомнить о еде Действие не выбрано, ничего не произойдет Нет данных ГК для основы расчета! Активный профиль не установлен! @@ -559,7 +532,6 @@ Пн %1$.1fед - %1$.2f ед Скорость: %1$.2f%% (%2$.2f U/h) Продолжительность: %3$d мин Скорость: %1$.2fед/ч (%2$.2f%%) Продолжительность %3$d мин Скорость:%1$.2f %% (%2$.2f ед/ч)
Продолжительность %3$d мин
]]>
diff --git a/core/ui/src/main/res/values-ru-rRU/strings_scene_wizard.xml b/core/ui/src/androidMain/res/values-ru-rRU/strings_scene_wizard.xml similarity index 100% rename from core/ui/src/main/res/values-ru-rRU/strings_scene_wizard.xml rename to core/ui/src/androidMain/res/values-ru-rRU/strings_scene_wizard.xml diff --git a/core/ui/src/main/res/values-sk-rSK/protection.xml b/core/ui/src/androidMain/res/values-sk-rSK/protection.xml similarity index 100% rename from core/ui/src/main/res/values-sk-rSK/protection.xml rename to core/ui/src/androidMain/res/values-sk-rSK/protection.xml diff --git a/core/ui/src/main/res/values-sk-rSK/strings.xml b/core/ui/src/androidMain/res/values-sk-rSK/strings.xml similarity index 96% rename from core/ui/src/main/res/values-sk-rSK/strings.xml rename to core/ui/src/androidMain/res/values-sk-rSK/strings.xml index 6bc8a35945a5..fcdb79c5201d 100644 --- a/core/ui/src/main/res/values-sk-rSK/strings.xml +++ b/core/ui/src/androidMain/res/values-sk-rSK/strings.xml @@ -38,8 +38,6 @@ Typ udalosti mg/dL mmol/L - mg/dL/U - mmol/L/U %1$d mg/dL %1$.1f mmol/l Uložiť @@ -105,18 +103,14 @@ Staré dáta Podávanie %1$.2f U inzulínu AAPS spustený - %1$+.2f U - %1$d g %1$d+%2$d g %1$.2f h %1$d min - %1$s: %2$s %1$s %2$s Ciele Čakajte prosím... Stop STLAČENÝ STOP - Sacharidy Neplatný profil! NENASTAVENÝ ŽIADNY PROFIL ]]> @@ -166,15 +160,12 @@ Glykémia Kalibrácia CGM - Spustiť výstrahu za %1$d min Bolus zaznamenal chybu. Manuálne skontrolujte skutočne podané množstvo inzulínu a sacharidov Upozornenie na bolus keď sa obnoví glykémia Trvanie g - Pumpa pozastavená Pumpa beží Nenakonfigurované - Uzavretý okruh pozastavený Uzavretý okruh pozastavený kvôli zmene času pož. rýchly pokles @@ -224,7 +215,6 @@ Uzavretý okruh Otvorený okruh Pozastavenie pri nízkej glykémii (LGS) - Pumpa odpojená Pumpa pozastavená Pozastaviť pumpu Obnoviť pumpu @@ -260,11 +250,6 @@ Heslá sa nezhodujú PIN kódy sa nezhodujú - 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 - U/h - g/U Spustiť profil %1$d%% na %2$d min @@ -302,7 +287,6 @@ 30d - %1$d min. Starostlivosť Kontrola glykémie @@ -486,7 +470,6 @@ Obnoviť predvolené Uzavretý okruh NS - Záznam Hruď vpravo Hruď vľavo Horná, vonkajšia strana pravej ruky @@ -536,7 +519,6 @@ Požadovaných dodatočných %1$d g sacharidov v priebehu %2$d minút Bazál - Bolus CDD Celková denná dávka @@ -548,7 +530,6 @@ PREDĹŽENÝ BOLUS SUPERBOLUS TBR SACHARIDY - ROZLOŽENÉ SACHARIDY DOČASNÝ BAZÁL DOČASNÝ CIEĽ NOVÝ PROFIL @@ -649,22 +630,15 @@ SCÉNA DEAKTIVOVANÁ VZDIALENÁ KONFIGURÁCIA ZMENENÁ - Dolný cieľ profilu - Horný cieľ profilu Dolná hodnota dočasného cieľa Horní hodnota dočasného cíle Hodnota dočasného cieľa - Profilová hodnota DIA DIA hodnota inzulínu Inzulínová hodnota vrcholu - Profilová hodnota citlivosti Profilová maximálna hodnota bazálu Aktuálna hodnota bazálu - Profilový inzulino-sacharidový pomer %1$.2f obmedzené na %2$.2f »%1$s« je mimo pevne nastavené limity - »%1$s« %2$.2f je mimo pevne nastavených limitov - Hodnota bazálu Nebol vybraný žiadny profil Chýba názov profilu @@ -721,18 +695,12 @@ Bolusový poradca Máte vysokú glykémiu. Namiesto jedla doporučujeme počkať na lepšiu glykémiu a pripomenúť, keď bude čas na jedlo. Prajete si poslať korekčný bolus a pripomenúť, keď bude čas na jedlo? V tomto prípade nebudú zapísané žiadne sacharidy, a neskôr musíte opäť spustiť kalkulačku, akonáhle vám to pripomenieme. - COB vs. IOB - !!!!! Detekovaná pomalá absorbcia sacharidov: %1$d%% času. Radšej dvakrát skontrolujte kalkuláciu. COB môže byť úplne iné, môže byť podaného viac inzulínu!!!!! - 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 Glykémia Korekcia Medzisúčet Chlieb, slané pečivo, jogurt, ovocie (jablko, banán, hruška), zemiaky, ryža, pohánka, ovsenné vločky, pomalé a nízko-sacharidové jedlá, zelenina Halušky, cestoviny, ázijská kuchyňa bohatá na sacharidy a tuky, sladké a kysnuté pečivo, cereálie, obilná kaša, med, džem Lasagne, pizza, hamburgery, hranolky, pečené zemiaky, chipsy a podobné snacky - eSacharidy %1$dg / %2$dh (+%3$dmin) Spolu Čas sacharidov Teraz @@ -977,7 +945,6 @@ Pon %1$.1f U - %1$.2f 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 @@ -1082,4 +1049,45 @@ SENS% VSENS KROKY + dní + %1$d dní + %1$d dní (%2$d - %3$d) + %1$.1f + %1$.2f + %1$.2f (%2$.2f - %3$.2f) + %1$.3f + %1$.3f (%2$.3f - %3$.3f) + %1$.1f (%2$.1f - %3$.1f) + %1$d g + %1$d g (%2$d - %3$d) + %1$d h + %1$.0f h + %1$.0f h (%2$.0f - %3$.0f) + %1$d h (%2$d - %3$d) + %1$.1f U + %1$d U + %1$d U (%2$d - %3$d) + %1$.1f U (%2$.1f - %3$.1f) + %1$.1f U/h + %1$.1f U/h (%2$.1f - %3$.1f) + %1$s mg/dL + %1$s mg/dL (%2$s - %3$s) + %1$d min + %1$d min (%2$d - %3$d) + %1$d%% + %1$d%% (%2$d - %3$d) + %1$d sek + %1$d s (%2$d - %3$d) + g + h + U + U/h + kg + mg/dL + min + mmol/L + % + sek + r + Rozsah pre zobrazenie diff --git a/core/ui/src/main/res/values-sk-rSK/strings_scene_wizard.xml b/core/ui/src/androidMain/res/values-sk-rSK/strings_scene_wizard.xml similarity index 100% rename from core/ui/src/main/res/values-sk-rSK/strings_scene_wizard.xml rename to core/ui/src/androidMain/res/values-sk-rSK/strings_scene_wizard.xml diff --git a/core/ui/src/main/res/values-sl-rSI/protection.xml b/core/ui/src/androidMain/res/values-sl-rSI/protection.xml similarity index 100% rename from core/ui/src/main/res/values-sl-rSI/protection.xml rename to core/ui/src/androidMain/res/values-sl-rSI/protection.xml diff --git a/core/ui/src/main/res/values-sr-rCS/protection.xml b/core/ui/src/androidMain/res/values-sr-rCS/protection.xml similarity index 100% rename from core/ui/src/main/res/values-sr-rCS/protection.xml rename to core/ui/src/androidMain/res/values-sr-rCS/protection.xml diff --git a/core/ui/src/main/res/values-sr-rCS/strings.xml b/core/ui/src/androidMain/res/values-sr-rCS/strings.xml similarity index 97% rename from core/ui/src/main/res/values-sr-rCS/strings.xml rename to core/ui/src/androidMain/res/values-sr-rCS/strings.xml index ccd67e6f99cd..0a38469c8be0 100644 --- a/core/ui/src/main/res/values-sr-rCS/strings.xml +++ b/core/ui/src/androidMain/res/values-sr-rCS/strings.xml @@ -12,7 +12,6 @@ Pumpa IOB: Isporučujem %1$.2f U - Uglj. hidrati Basal Izađi IOB @@ -45,7 +44,6 @@ Basal - Bolus diff --git a/core/ui/src/main/res/values-sr-rCS/strings_scene_wizard.xml b/core/ui/src/androidMain/res/values-sr-rCS/strings_scene_wizard.xml similarity index 100% rename from core/ui/src/main/res/values-sr-rCS/strings_scene_wizard.xml rename to core/ui/src/androidMain/res/values-sr-rCS/strings_scene_wizard.xml diff --git a/core/ui/src/main/res/values-sv-rSE/protection.xml b/core/ui/src/androidMain/res/values-sv-rSE/protection.xml similarity index 100% rename from core/ui/src/main/res/values-sv-rSE/protection.xml rename to core/ui/src/androidMain/res/values-sv-rSE/protection.xml diff --git a/core/ui/src/main/res/values-sv-rSE/strings.xml b/core/ui/src/androidMain/res/values-sv-rSE/strings.xml similarity index 93% rename from core/ui/src/main/res/values-sv-rSE/strings.xml rename to core/ui/src/androidMain/res/values-sv-rSE/strings.xml index 4a0bb67b81af..d1cc553a4a2e 100644 --- a/core/ui/src/main/res/values-sv-rSE/strings.xml +++ b/core/ui/src/androidMain/res/values-sv-rSE/strings.xml @@ -43,13 +43,10 @@ TDD totalt Kommer att leverera %1$.2f enheter AAPS startad - %1$+.2f U - %1$dg %1$.2f h Mål Var god vänta… Stopp - Kolhydrater Ogiltig profil! INGEN PROFIL VALD ]]> @@ -90,12 +87,9 @@ Data kommer från en annan pump. Byt pumpdrivrutin för att återställa. BG Kalibrering - Larma om %1$d min Duration g - Pump pausad Inte konfigurerad - Loop pausad beh sjunker snabbt sjunker @@ -138,11 +132,6 @@ Lösenorden stämmer inte överens PIN-koderna överensstämmer inte - Profilens basaler är inte satta på hel timme: %1$s - Basalvärdet ersatt med det lägsta tillåtna: %1$s - Basalvärdet ersatt med det högsta tillåtna: %1$s - U/h - g/U Byt till profil %1$d%% i %2$d min @@ -157,7 +146,6 @@ Medel - %1$d min Careportal BG-kontroll @@ -200,7 +188,6 @@ Anpassad Loop NS - Post Anslutningen tog för lång tid @@ -213,7 +200,6 @@ %1$d g kolhydrater behövs inom %2$d minuter Basal - Bolus Tidsdetektering @@ -223,7 +209,6 @@ FÖRLÄNGD BOLUS SUPERBOLUS TEMPBASAL KH - FÖRLÄNGDA KH TEMP BASAL TEMP MÅL NY PROFIL @@ -306,20 +291,13 @@ LOOP ÄNDRAD LOOP BORTTAGEN - Nedre målvärde för profilen - Övre målvärde för profilen Nedre värde för temp mål Övre värde för temp mål Temp målvärde - Profilens DIA - Profilens insulinkänslighetsvärde Maximal basaldos för profil Nuvarande basaldos - Profilens KH-kvot %1$.2f begränsat till %2$.2f »%1$s« är utanför de hårda gränserna - »%1$s« %2$.2f är utanför hårda gränser - Basaldos BOLUS %1$.2f U @@ -354,10 +332,6 @@ Bolusguide Ditt blodsocker är högt. Istället för att äta är det rekommenderat att vänta tills det sjunker. Vill du göra en korrigeringsbolus nu och få en påminnelse när det är dags att äta? I det här fallet kommer inga kolhydrater att registreras nu, utan du måste ange måltiden på nytt i kalkylatorn. - COB kontra IOB - Bolusbegränsning tillämpad: %1$.2f U till %2$.2f U - Bolus kommer bara att loggas (inte levereras av pumpen) - Larma när det är dags att äta Ingen åtgärd vald. Inget ändras. Inget nytt BG-värde att basera beräkning på! Ingen aktiv profil vald! @@ -461,7 +435,6 @@ Rensade poster - %1$.2fU diff --git a/core/ui/src/main/res/values-sv-rSE/strings_scene_wizard.xml b/core/ui/src/androidMain/res/values-sv-rSE/strings_scene_wizard.xml similarity index 100% rename from core/ui/src/main/res/values-sv-rSE/strings_scene_wizard.xml rename to core/ui/src/androidMain/res/values-sv-rSE/strings_scene_wizard.xml diff --git a/core/ui/src/main/res/values-sw600dp/layout.xml b/core/ui/src/androidMain/res/values-sw600dp/layout.xml similarity index 100% rename from core/ui/src/main/res/values-sw600dp/layout.xml rename to core/ui/src/androidMain/res/values-sw600dp/layout.xml diff --git a/core/ui/src/main/res/values-tr-rTR/protection.xml b/core/ui/src/androidMain/res/values-tr-rTR/protection.xml similarity index 100% rename from core/ui/src/main/res/values-tr-rTR/protection.xml rename to core/ui/src/androidMain/res/values-tr-rTR/protection.xml diff --git a/core/ui/src/main/res/values-tr-rTR/strings.xml b/core/ui/src/androidMain/res/values-tr-rTR/strings.xml similarity index 94% rename from core/ui/src/main/res/values-tr-rTR/strings.xml rename to core/ui/src/androidMain/res/values-tr-rTR/strings.xml index b6db66cdb8f4..a860ecfb37e4 100644 --- a/core/ui/src/main/res/values-tr-rTR/strings.xml +++ b/core/ui/src/androidMain/res/values-tr-rTR/strings.xml @@ -44,13 +44,10 @@ Eski veri %1$.2f Ü gönderilecek AAPS başladı - %1$+.2f Ü - %1$d g %1$.2f s Görevler Lütfen bekleyin… Dur - Karbonhidrat Geçersiz profil! PROFİL AYARLANMADI ]]> @@ -93,14 +90,11 @@ Veriler farklı pompadan geliyor. Pompa durumunu sıfırlamak için pompa sürücüsünü değiştirin. Kalibrasyon - Alarmı %1$d dakika içinde çalıştır Bolus bir hata bildirdi. Gerçek iletilen insülin ve karbonhidrat miktarını manuel olarak kontrol edin Süre gr - Pompa Durduruldu Pompa çalışıyor Yapılandırılmadı - Döngü duraklatıldı iht. hızla düşüyor düşüyor @@ -130,7 +124,6 @@ Kapalı Döngü Açık Döngü Düşük Glikoz Duraklatma (LGS) - Pompa bağlantısı kesildi Pompa Durduruldu Mod Döndü İES @@ -156,11 +149,6 @@ Şifreler eşleşmiyor PIN\'ler eşleşmiyor - Bazal değerler saatlerle uyumlu değil: %1$s - Desteklenen minimum değerle değiştirilen bazal değer: %1$s - Bazal değeri maksimum desteklenen değerle değiştirilir: %1$s - Ü/s - g/Ü %2$d dakika için %1$d%% profilini başlat @@ -175,7 +163,6 @@ Ortalama - %1$d dak Bakım Portalı KŞ Kontrol @@ -221,7 +208,6 @@ Özel Döngü NS - Kayıt Bağlantı zaman aşımına uğradı @@ -234,7 +220,6 @@ %2$d dakika içinde %1$d g ek karbonhidrat gerekiyor Bazal - Bolus Zaman algılama @@ -244,7 +229,6 @@ YAYMA BOLUS SÜPERBOLUS GBO KARBLR - YAYMA KARBONHİDRAT GEÇİCİ BAZAL GEÇİCİ HEDEF YENİ PROFİL @@ -330,20 +314,13 @@ DÖNGÜ DEĞİŞTİ DÖNGÜ KALDIRILDI - Düşük hedef profili - Yüksek hedef profili Geçici hedef alt değeri Geçici hedef üst değeri Geçici hedef değeri - Profil DIA değeri - Profil duyarlılık değeri Azami profil bazal değeri Mevcut bazal değer - Profil karbonhidrat oranı değeri %1$.2f, %2$.2f ile sınırlıdır »%1$s« sınırların dışında - »%1$s« %2$.2f sınırların dışında - Bazal değer İDF değerinde hata @@ -382,10 +359,6 @@ Bolus danışmanı Yüksek glisemiksiniz. Şimdi yemek yemek yerine daha iyi glisemi beklemeniz önerilir. Şimdi bir düzeltme bolusu yapmak ve yemek zamanı geldiğinde size hatırlatmak ister misiniz? Bu durumda karbonhidrat kaydı yapılmaz ve size hatırlattığımızda sihirbazı tekrar kullanmanız gerekir. - AKRB vs AİNS - Bolus kısıtlaması uygulandı: %1$.2f Ü ile %2$.2f Ü - Bolus yalnızca kaydedilecektir (pompa ile iletilmez) - Yemek zamanı alarmı çalıştır Seçili eylem yok, hiçbir şey olmayacak Hesaplamayı temel alacak yeni bir KŞ yok! Etkin profil ayarlanmadı! @@ -509,7 +482,6 @@ Pts %1$.1f Ü - %1$.2f Ü Oran: %1$.2f%% (%2$.2f Ü/s) Süre: %3$d dk Oran: %1$.2f Ü/s (%2$.2f%%) Süre: %3$d dk Oran: %1$.2f%% (%2$.2f Ü/s)
Süre: %3$d dk
]]>
diff --git a/core/ui/src/main/res/values-tr-rTR/strings_scene_wizard.xml b/core/ui/src/androidMain/res/values-tr-rTR/strings_scene_wizard.xml similarity index 100% rename from core/ui/src/main/res/values-tr-rTR/strings_scene_wizard.xml rename to core/ui/src/androidMain/res/values-tr-rTR/strings_scene_wizard.xml diff --git a/core/ui/src/main/res/values-uk-rUA/protection.xml b/core/ui/src/androidMain/res/values-uk-rUA/protection.xml similarity index 100% rename from core/ui/src/main/res/values-uk-rUA/protection.xml rename to core/ui/src/androidMain/res/values-uk-rUA/protection.xml diff --git a/core/ui/src/main/res/values-uk-rUA/strings.xml b/core/ui/src/androidMain/res/values-uk-rUA/strings.xml similarity index 97% rename from core/ui/src/main/res/values-uk-rUA/strings.xml rename to core/ui/src/androidMain/res/values-uk-rUA/strings.xml index a6b3034c74fd..b5e7b2b7c819 100644 --- a/core/ui/src/main/res/values-uk-rUA/strings.xml +++ b/core/ui/src/androidMain/res/values-uk-rUA/strings.xml @@ -51,7 +51,6 @@ - %1$.2f од diff --git a/core/ui/src/main/res/values-uk-rUA/strings_scene_wizard.xml b/core/ui/src/androidMain/res/values-uk-rUA/strings_scene_wizard.xml similarity index 100% rename from core/ui/src/main/res/values-uk-rUA/strings_scene_wizard.xml rename to core/ui/src/androidMain/res/values-uk-rUA/strings_scene_wizard.xml diff --git a/core/ui/src/main/res/values-vi-rVN/protection.xml b/core/ui/src/androidMain/res/values-vi-rVN/protection.xml similarity index 100% rename from core/ui/src/main/res/values-vi-rVN/protection.xml rename to core/ui/src/androidMain/res/values-vi-rVN/protection.xml diff --git a/core/ui/src/main/res/values-vi-rVN/strings.xml b/core/ui/src/androidMain/res/values-vi-rVN/strings.xml similarity index 96% rename from core/ui/src/main/res/values-vi-rVN/strings.xml rename to core/ui/src/androidMain/res/values-vi-rVN/strings.xml index 6e84df1904f0..25b4477d1e91 100644 --- a/core/ui/src/main/res/values-vi-rVN/strings.xml +++ b/core/ui/src/androidMain/res/values-vi-rVN/strings.xml @@ -38,8 +38,6 @@ Loại sự kiện mg/dl mmol/L - mg/dL/U - mmol/L/U %1$d mg/dL %1$.1f mmol/L Lưu @@ -106,18 +104,14 @@ Đang tiêm %1$.2f U Insulin Đã tiêm bolus, nhưng không thể lưu lượng carb. Vui lòng nhập lại lượng carb. AAPS đã khởi động - %1$+.2f U - %1$d g %1$d+%2$d g %1$.2f h %1$d phút - %1$s: %2$s %1$s %2$s Mục tiêu học tập Vui lòng chờ… Dừng lại Đã nhấn DỪNG - Carbs Cấu hình không hợp lệ! KHÔNG CÓ CẤU HÌNH Không thể chuyển cấu hình điều trị: không có loại insulin nào đang được sử dụng và cũng chưa có loại nào được chọn. @@ -173,15 +167,12 @@ BG Hiệu chuẩn CGM - Báo động sau %1$d phút Bolus gặp lỗi. Hãy kiểm tra thủ công lượng carb và insulin đã bơm Nhắc tiêm bolus khi đường huyết hồi phục Thời lượng g - Bơm đã tạm dừng 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ờ req giảm nhanh @@ -231,7 +222,6 @@ Vòng lặp kín Vòng lặp mở Tạm dừng khi BG Thấp - Bơm đã ngắt kết nối Bơm đã tạm ngưng Tạm dừng bơm Tiếp tục bơm @@ -267,11 +257,6 @@ Mật khẩu không khớp Mã PIN không khớp - 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/h - g/U Bắt đầu cấu hình %1$d%% trong %2$d phút @@ -309,7 +294,6 @@ 30d - %1$d phút Thông tin điều trị Kiểm tra BG @@ -493,7 +477,6 @@ Khôi phục về mặc định Vòng lặp NS - Record Ngực phải Ngực trái Phần ngoài cánh tay phải phía trên @@ -543,7 +526,6 @@ Cần thêm %1$d g carb trong vòng %2$d phút Basal - Bolus TDD Tổng liều hàng ngày @@ -555,7 +537,6 @@ LIỀU BOLUS KÉO DÀI SUPERBOLUS TBR CARBS - CARBS MỞ RỘNG TEMP BASAL TEMP TARGET CẤU HÌNH MỚI @@ -656,22 +637,15 @@ ĐÃ TẮT NGỮ CẢNH CẤU HÌNH TỪ XA ĐÃ THAY ĐỔI - Cấu hình mục tiêu thấp - Cấu hình 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 Thời gian tác dụng của insulin Thời điểm đạt đỉnh của insulin - Cấu hình giá trị độ nhạy Cấu hình liều nền tối đa Giá trị liều nền hiện tại - Cấu hình tỷ lệ Carb %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 liều nền Chưa chọn hồ sơ Thiếu tên cấu hình @@ -728,18 +702,12 @@ Gợi ý bolus Bạn đang có đường huyết cao. Thay vì ăn ngay, nên chờ đến khi đường huyết ổn định hơn. Bạn có muốn thực hiện bolus chỉnh sửa ngay và được nhắc khi đến giờ ăn? Trong trường hợp này, không có carbs nào được ghi nhận và bạn sẽ phải sử dụng wizard một lần nữa khi được nhắc. - COB với IOB - !!!!! Phát hiện hấp thu carb chậm trong %1$d%% thời gian. Hãy kiểm tra lại phép tính của bạn. COB có thể bị ước tính cao, do đó có thể dẫn đến việc cung cấp nhiều insulin hơn !!!!! - Giới hạn bolus đã áp dụng: %1$.2f U đến %2$.2f U - Liều Bolus chỉ được ghi lại (không tiêm qua Bơm) - Chạy báo thức khi đến giờ ăn BG Hiệu chỉnh Tổng phụ Bánh mì, bánh mặn, sữa chua, trái cây (táo, chuối, lê), khoai tây, cơm, kiều mạch, bột yến mạch, thực phẩm giàu carb chậm và thấp, rau củ Bánh bao, mì ống, ẩm thực châu Á giàu carb và chất béo, bánh ngọt và bánh men, ngũ cốc, cháo từ ngũ cốc, mật ong, mứt Lasagna, pizza, hamburger, khoai tây chiên, khoai tây nướng, snack như khoai tây lát chiên và các món tương tự - eCarbs %1$dg / %2$dh (+%3$dmin) Tổng cộng Thời gian ăn Bây giờ @@ -981,7 +949,6 @@ Mon %1$.1f U - %1$.2f U %1$+.2f U Tốc độ: %1$.2f%% (%2$.2f U/h) Thời gian: %3$d phút Tốc độ: %1$.2f U/h (%2$.2f%%) Thời gian: %3$d min @@ -1086,4 +1053,45 @@ SENS% VSENS STEPS + ngày + %1$d ngày + %1$d ngày (%2$d - %3$d) + %1$.1f + %1$.2f + %1$.2f (%2$.2f - %3$.2f) + %1$.3f + %1$.3f (%2$.3f - %3$.3f) + %1$.1f (%2$.1f - %3$.1f) + %1$d g + %1$d g (%2$d - %3$d) + %1$d h + %1$.0f h + %1$.0f h (%2$.0f - %3$.0f) + %1$d h (%2$d - %3$d) + %1$.1f U + %1$d U + %1$d U (%2$d - %3$d) + %1$.1f U (%2$.1f - %3$.1f) + %1$.1f U/h + %1$.1f U/h (%2$.1f - %3$.1f) + %1$s mg/dL + %1$s mg/dL (%2$s - %3$s) + %1$d phút + %1$d phút (%2$d - %3$d) + %1$d%% + %1$d%% (%2$d - %3$d) + %1$d giây + %1$d giây (%2$d - %3$d) + g + h + U + U/h + kg + mg/dl + phút + mmol/L + % + giây + y + Phạm vi để hiển thị diff --git a/core/ui/src/main/res/values-vi-rVN/strings_scene_wizard.xml b/core/ui/src/androidMain/res/values-vi-rVN/strings_scene_wizard.xml similarity index 100% rename from core/ui/src/main/res/values-vi-rVN/strings_scene_wizard.xml rename to core/ui/src/androidMain/res/values-vi-rVN/strings_scene_wizard.xml diff --git a/core/ui/src/main/res/values-zh-rCN/protection.xml b/core/ui/src/androidMain/res/values-zh-rCN/protection.xml similarity index 100% rename from core/ui/src/main/res/values-zh-rCN/protection.xml rename to core/ui/src/androidMain/res/values-zh-rCN/protection.xml diff --git a/core/ui/src/main/res/values-zh-rCN/strings.xml b/core/ui/src/androidMain/res/values-zh-rCN/strings.xml similarity index 96% rename from core/ui/src/main/res/values-zh-rCN/strings.xml rename to core/ui/src/androidMain/res/values-zh-rCN/strings.xml index 733fab28f5b5..9a1aa775bb92 100644 --- a/core/ui/src/main/res/values-zh-rCN/strings.xml +++ b/core/ui/src/androidMain/res/values-zh-rCN/strings.xml @@ -38,8 +38,6 @@ 事件类型 mg/dL mmol/L - mg/dL/U - mmol/L/U %1$d mg/dL %1$.1f mmol/L 保存 @@ -105,18 +103,14 @@ 旧的数据 将要输注 %1$.2f U AAPS 已启动 - %1$+.2f U - %1$d 克 %1$d+%2$d g %1$.2f 小时 %1$d min - %1$s: %2$s %1$s %2$s 目标 请稍等... 停止 已按下停止 - 碳水化合物 无效的配置文件 没有配置文件 无法切换配置文件:没有正在使用的胰岛素,也未选择任何胰岛素。 @@ -172,15 +166,12 @@ 血糖 校准 CGM - 在 %1$d 分钟内运行提醒 大剂量输注报告了一个错误。请手动检查实际输送的胰岛素和碳水化合物量 当血糖恢复时提醒推注 持续时间 - 泵暂停了 泵运行中 未配置 - 闭环暂停了 循环因夏令时DST而暂停 迅速下降 @@ -230,7 +221,6 @@ 闭环 开环 低血糖维持模式 - 泵已断开 泵已暂停 暂停泵 恢复泵 @@ -266,11 +256,6 @@ 密码不匹配 PIN 码不匹配。 - 基础率值与小时数不一致:%1$s - 基础率已设为支持的最小值:%1$s - 基础率值被泵支持的最大值:%1$s 替换了 - U/h - 克/U 启动配置文件 %1$d%%在 %2$d 分钟 @@ -308,7 +293,6 @@ 30天 - %1$d 分钟 护理记录 指血检查 @@ -492,7 +476,6 @@ 恢复为默认值 闭环 NS - 记录 右胸 左胸 右上臂外侧 @@ -542,7 +525,6 @@ %2$d 分钟内需要额外摄入 %1$d 克碳水化合物 基础率 - 大剂量 TDD 每日总剂量 @@ -554,7 +536,6 @@ 扩展大剂量 超级大剂量TBR 碳水 - 扩展碳水 临时基础率 临时目标 新配置文件 @@ -655,22 +636,15 @@ 情景已停用 远程配置已更改 - 个人配置低目标 - 个人配置高目标 临时目标值下限值 临时目标值上限值 临时目标值 - 配置文件DIA值 胰岛素作用时间 胰岛素峰值时间 - 配置文件敏感系数值 配置文件最大基础率值 当前基础率值 - 配置文件碳水系数值 %1$.2f 超过 %2$.2f的限制 »%1$s« 超出了硬限制 - »%1$s« %2$.2f 超出了硬限制 - 基础率值 未选择配置文件 缺少配置文件名称 @@ -727,18 +701,12 @@ 大剂量向导 您当前血糖偏高。建议暂时不要进食,等待血糖降至正常。是否现在进行校正推注,并在适宜进食时提醒您?此操作将不会记录碳水化合物,收到提醒后您必须再次使用向导。 - 活性碳水vs活性胰岛素 - !!!!!检测到碳水化合物吸收缓慢:%1$d%%的时间。仔细检查你的计算。COB可能被高估,因此可能注射过多的胰岛素!!!!! - 已应用推注限制: %1$.2f U 到 %2$.2f U - 仅记录大剂量数值(泵不会输注) - 在应当吃饭时提醒 血糖 修正 小计 面包、咸味糕点、酸奶、水果(苹果、香蕉、梨)、土豆、大米、荞麦、燕麦片、低碳水化合物食物、蔬菜 饺子、意大利面、富含碳水化合物和脂肪的亚洲美食、甜食和酵母糕点、谷物、谷物粥、蜂蜜、果酱 烤宽面条、披萨、汉堡、薯条、烤土豆、薯片和类似小吃 - eCarbs %1$dg / %2$dh (+%3$dmin) 总计 碳水时间 现在 @@ -980,7 +948,6 @@ 星期一 %1$.1f U - %1$.2f U %1$+.2f U 速率:%1$.2f%% (%2$.2f U/h) 持续时间:%3$d 分钟 速率:%1$.2f U/h (%2$.2f%%) 持续时间:%3$d 分钟 @@ -1085,4 +1052,45 @@ SENS% VSENS STEPS + + %1$d 天 + %1$d 天 (%2$d - %3$d) + %1$.1f + %1$.2f + %1$.2f (%2$.2f - %3$.2f) + %1$.3f + %1$.3f (%2$.3f - %3$.3f) + %1$.1f (%2$.1f - %3$.1f) + %1$d g + %1$d g (%2$d - %3$d) + %1$d 小时 + %1$.0f 小时 + %1$.0f 小时(%2$.0f - %3$.0f) + %1$d 小时(%2$d - %3$d) + %1$.1f U + %1$d U + %1$d U (%2$d - %3$d) + %1$.1f U (%2$.1f - %3$.1f) + %1$.1f U/h + %1$.1f U/h (%2$.1f - %3$.1f) + %1$s mg/dL + %1$s mg/dL (%2$s - %3$s) + %1$d 分 + %1$d 分(%2$d - %3$d) + %1$d%% + %1$d%% (%2$d - %3$d) + %1$d 秒 + %1$d 秒(%2$d - %3$d) + g + 小时 + U + U/h + kg + mg/dL + + mmol/L + % + + + 可视化范围 diff --git a/core/ui/src/main/res/values-zh-rCN/strings_scene_wizard.xml b/core/ui/src/androidMain/res/values-zh-rCN/strings_scene_wizard.xml similarity index 100% rename from core/ui/src/main/res/values-zh-rCN/strings_scene_wizard.xml rename to core/ui/src/androidMain/res/values-zh-rCN/strings_scene_wizard.xml diff --git a/core/ui/src/main/res/values-zh-rTW/protection.xml b/core/ui/src/androidMain/res/values-zh-rTW/protection.xml similarity index 100% rename from core/ui/src/main/res/values-zh-rTW/protection.xml rename to core/ui/src/androidMain/res/values-zh-rTW/protection.xml diff --git a/core/ui/src/main/res/values-zh-rTW/strings.xml b/core/ui/src/androidMain/res/values-zh-rTW/strings.xml similarity index 96% rename from core/ui/src/main/res/values-zh-rTW/strings.xml rename to core/ui/src/androidMain/res/values-zh-rTW/strings.xml index f9c22957c473..274d11d30f0e 100644 --- a/core/ui/src/main/res/values-zh-rTW/strings.xml +++ b/core/ui/src/androidMain/res/values-zh-rTW/strings.xml @@ -38,8 +38,6 @@ 事件類型 mg/dL mmol/L - mg/dL/U - mmol/L/U %1$d mg/dL %1$.1f mmol/L 儲存 @@ -106,18 +104,14 @@ 將注射 %1$.2f U 大劑量已注射 ,但無法儲存碳水。請重新輸入碳水。 AAPS 已啟動 - %1$+.2f U - %1$d g %1$d+%2$d 克 %1$.2f 小時 %1$d 分鐘 - %1$s: %2$s %1$s %2$s 目標 請稍候… 停止 已按下停止鍵 - 碳水化合物 無效的設定檔! 未設置設定檔 無法切換設定檔:目前沒有使用中的胰島素,且也未選取任何胰島素。 @@ -173,15 +167,12 @@ 血糖 校正 CGM - 在 %1$d 分鐘內運行警報 注射錯誤報告。請檢查實際注射的胰島素和碳水化合物量 當血糖恢復時提醒注射 持續時間 g - 幫浦已暫停 幫浦運行中 未配置 - 循環已暫停 因夏令時間切換,循環已暫停 需要 快速下降 @@ -231,7 +222,6 @@ 閉環 開環 低血糖暫停(LGS) - 幫浦已中斷連線 幫浦已暫停 暫停幫浦 恢復幫浦 @@ -267,11 +257,6 @@ 密碼不一致 PIN 碼不一致 - 基礎率值未對齊小時:%1$s - 基礎率值已被最低支援值取代:%1$s - 基礎率值已被最高支援值取代:%1$s - U/h - g/U 開始設定檔 %1$d%% 持續 %2$d 分鐘 @@ -309,7 +294,6 @@ 30 天 - %1$d 分鐘 照護入口 血糖檢查 @@ -495,7 +479,6 @@ 還原為預設值 循環 NS - 紀錄 右胸 左胸 右上臂外側 @@ -545,7 +528,6 @@ %1$d g 額外碳水化合物需要在 %2$d 分鐘內 基礎率 - 注射 TDD 每日總劑量 @@ -557,7 +539,6 @@ 延長注射 超級注射臨時基礎率 碳水化合物 - 延長碳水化合物 臨時基礎率 臨時目標 新設定檔 @@ -658,22 +639,15 @@ 場景已停用 遠端設定已變更 - 設定檔低目標 - 設定檔高目標 臨時目標低值 臨時目標高值 臨時目標值 - 設定檔 DIA 值 胰島素作用時間數值 胰島素峰值時間數值 - 設定檔敏感度值 最大設定檔基礎率值 目前基礎率值 - 設定檔碳水化合物比率值 %1$.2f 限制為 %2$.2f »%1$s« 超出硬限制範圍 - »%1$s« %2$.2f 超出硬限制範圍 - 基礎率值 未選擇設定檔 缺少設定檔名稱 @@ -730,18 +704,12 @@ 注射顧問 您的血糖過高。建議不要立即用餐,等待血糖恢復正常。您想現在進行修正注射,並提醒您何時用餐嗎?這種情況下不會紀錄碳水化合物,當我們提醒您時,您必須再次使用嚮導。 - COB 對 IOB - !!!!! 偵測到碳水吸收偏慢:%1$d%% 的時間。請再次檢查你的計算。活性碳水化合物可能被高估,因此可能會注射過多胰島素 !!!!! - 注射限制已套用:%1$.2f U 至 %2$.2f U - 僅紀錄注射(不由幫浦傳送) - 當到達用餐時間時提醒我 血糖 修正 小計 麵包、鹹點、優格、水果(蘋果、香蕉、梨)、馬鈴薯、白飯、蕎麥、燕麥、慢速與低碳水食物、蔬菜 水餃、義大利麵、富含碳水與脂肪的亞洲料理、甜點與酵母糕點、穀片、雜糧粥、蜂蜜、果醬 千層麵、披薩、漢堡、薯條、烤馬鈴薯、洋芋片及類似的零食 - eCarbs %1$dg / %2$dh (+%3$dmin) 總計 碳水時間 現在 @@ -983,7 +951,6 @@ 週一 %1$.1f U - %1$.2f U %1$+.2f U 速率:%1$.2f%%(%2$.2f U/h)持續時間:%3$d 分鐘 速率:%1$.2f U/h(%2$.2f%%)持續時間:%3$d 分鐘 @@ -1088,4 +1055,45 @@ SENS% VSENS STEPS + + %1$d 天 + %1$d 天 (%2$d - %3$d) + %1$.1f + %1$.2f + %1$.2f (%2$.2f - %3$.2f) + %1$.3f + %1$.3f (%2$.3f - %3$.3f) + %1$.1f (%2$.1f - %3$.1f) + %1$d 克 + %1$d g (%2$d - %3$d) + %1$d 小時 + %1$.0f 小時 + %1$.0f 小時 (%2$.0f - %3$.0f) + %1$d 小時 (%2$d - %3$d) + %1$.1f U + %1$d U + %1$d U (%2$d - %3$d) + %1$.1f U (%2$.1f - %3$.1f) + %1$.1f U/h + %1$.1f U/h (%2$.1f - %3$.1f) + %1$s mg/dL + %1$s mg/dL (%2$s - %3$s) + %1$d 分鐘 + %1$d 分鐘 (%2$d - %3$d) + %1$d%% + %1$d%% (%2$d - %3$d) + %1$d 秒 + %1$d 秒 (%2$d - %3$d) + + 小時 + U + U/h + 公斤 + mg/dL + 分鐘 + mmol/L + % + + + 視覺化範圍 diff --git a/core/ui/src/main/res/values-zh-rTW/strings_scene_wizard.xml b/core/ui/src/androidMain/res/values-zh-rTW/strings_scene_wizard.xml similarity index 100% rename from core/ui/src/main/res/values-zh-rTW/strings_scene_wizard.xml rename to core/ui/src/androidMain/res/values-zh-rTW/strings_scene_wizard.xml diff --git a/core/ui/src/main/res/values/colors.xml b/core/ui/src/androidMain/res/values/colors.xml similarity index 100% rename from core/ui/src/main/res/values/colors.xml rename to core/ui/src/androidMain/res/values/colors.xml diff --git a/core/ui/src/main/res/values/layout.xml b/core/ui/src/androidMain/res/values/layout.xml similarity index 100% rename from core/ui/src/main/res/values/layout.xml rename to core/ui/src/androidMain/res/values/layout.xml diff --git a/core/ui/src/main/res/values/protection.xml b/core/ui/src/androidMain/res/values/protection.xml similarity index 100% rename from core/ui/src/main/res/values/protection.xml rename to core/ui/src/androidMain/res/values/protection.xml diff --git a/core/ui/src/main/res/values/strings.xml b/core/ui/src/androidMain/res/values/strings.xml similarity index 96% rename from core/ui/src/main/res/values/strings.xml rename to core/ui/src/androidMain/res/values/strings.xml index 2487c4cc22f7..1e3efca8136e 100644 --- a/core/ui/src/main/res/values/strings.xml +++ b/core/ui/src/androidMain/res/values/strings.xml @@ -38,8 +38,6 @@ Event type mg/dL mmol/L - mg/dL/U - mmol/L/U %1$d mg/dL %1$.1f mmol/L Save @@ -70,7 +68,6 @@ Confirm PIN %1$s%2$s (%3$s – %4$s) Status lights - Copy NS settings (if exists)? Insulin BG Source Smoothing @@ -106,18 +103,14 @@ Going to deliver %1$.2f U Bolus delivered, but the carbs could not be saved. Please enter the carbs again. AAPS started - %1$+.2f U - %1$d g %1$d+%2$d g %1$.2f h %1$d min - %1$s: %2$s %1$s %2$s Objectives Please wait… Stop STOP PRESSED - Carbs Invalid profile! NO PROFILE SET Cannot switch profile: no insulin is in use, and none was selected. @@ -173,15 +166,12 @@ BG Calibration CGM - Run alarm in %1$d min Bolus reported an error. Manually check real delivered insulin and carb amount Remind for bolus when BG recovers Duration g - Pump suspended Pump running Not configured - Loop suspended Loop suspended by DST req falling rapidly @@ -232,7 +222,6 @@ Closed Loop Open Loop Low Glucose Suspend - Pump disconnected Pump suspended Suspend pump Resume pump @@ -270,11 +259,6 @@ PINs don\'t match - Basal values not aligned to hours: %1$s - Basal value replaced by minimum supported value: %1$s - Basal value replaced by maximum supported value: %1$s - U/h - g/U Start profile %1$d%% for %2$d min @@ -318,7 +302,6 @@ - %1$d min Careportal @@ -505,7 +488,6 @@ Revert to defaults Loop NS - Record Right Chest Left Chest Upper Right Outer Arm @@ -560,7 +542,6 @@ %1$d g additional carbs required within %2$d minutes Basal - Bolus TDD Total Daily Dose @@ -574,7 +555,6 @@ EXTENDED BOLUS SUPERBOLUS TBR CARBS - EXTENDED CARBS TEMP BASAL TEMP TARGET NEW PROFILE @@ -677,22 +657,15 @@ REMOTE CONFIG CHANGED - Profile low target - Profile high target Temporary target bottom value Temporary target top value Temporary target value - Profile DIA value Insulin DIA value Insulin Peak value - Profile sensitivity value Maximal profile basal value Current basal value - Profile carbs ratio value %1$.2f limited to %2$.2f »%1$s« is out of hard limits - »%1$s« %2$.2f is out of hard limits - Basal value No profile selected @@ -756,18 +729,12 @@ Bolus advisor You have high glycemia. Instead of eating now it\'s recommended to wait for better glycemia. Do you want to do a correction bolus now and remind you when it\'s time to eat? In this case no carbs will be recorded and you must use wizard again when we remind you. - COB vs IOB - !!!!! Slow carbs absorption detected: %1$d%% of time. Double check your calculation. COB can be overestimated thus more insulin could be given !!!!! - Bolus constraint applied: %1$.2f U to %2$.2f U - Bolus will be recorded only (not delivered by pump) - Run alarm when is time to eat BG Correction Subtotal Bread, savory pastries, yogurt, fruit (apple, banana, pear), potatoes, rice, buckwheat, oatmeal, slow and low carb foods, vegetables Dumplings, pasta, Asian cuisine rich in carbs and fats, sweet and yeast pastries, cereals, grain porridge, honey, jam Lasagna, pizza, hamburgers, fries, baked potatoes, chips and similar snacks - eCarbs %1$dg / %2$dh (+%3$dmin) Total Carb time Now @@ -1043,7 +1010,6 @@ %1$.1f U - %1$.2f U %1$+.2f U Rate: %1$.2f%% (%2$.2f U/h) Duration: %3$d min Rate: %1$.2f U/h (%2$.2f%%) Duration: %3$d min @@ -1159,4 +1125,45 @@ STEPS + days + %1$d days + %1$d days (%2$d - %3$d) + %1$.1f + %1$.2f + %1$.2f (%2$.2f - %3$.2f) + %1$.3f + %1$.3f (%2$.3f - %3$.3f) + %1$.1f (%2$.1f - %3$.1f) + %1$d g + %1$d g (%2$d - %3$d) + %1$d h + %1$.0f h + %1$.0f h (%2$.0f - %3$.0f) + %1$d h (%2$d - %3$d) + %1$.1f U + %1$d U + %1$d U (%2$d - %3$d) + %1$.1f U (%2$.1f - %3$.1f) + %1$.1f U/h + %1$.1f U/h (%2$.1f - %3$.1f) + %1$s mg/dL + %1$s mg/dL (%2$s - %3$s) + %1$d min + %1$d min (%2$d - %3$d) + %1$d%% + %1$d%% (%2$d - %3$d) + %1$d sec + %1$d sec (%2$d - %3$d) + g + h + U + U/h + kg + mg/dL + min + mmol/L + % + sec + y + Range for visualization diff --git a/core/ui/src/main/res/values/strings_scene_wizard.xml b/core/ui/src/androidMain/res/values/strings_scene_wizard.xml similarity index 100% rename from core/ui/src/main/res/values/strings_scene_wizard.xml rename to core/ui/src/androidMain/res/values/strings_scene_wizard.xml diff --git a/core/ui/src/main/res/values/styles.xml b/core/ui/src/androidMain/res/values/styles.xml similarity index 100% rename from core/ui/src/main/res/values/styles.xml rename to core/ui/src/androidMain/res/values/styles.xml diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/UiMode.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/UiMode.kt similarity index 100% rename from core/ui/src/main/kotlin/app/aaps/core/ui/UiMode.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/UiMode.kt diff --git a/core/ui/src/commonMain/kotlin/app/aaps/core/ui/clientcontrol/FailureReasonText.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/clientcontrol/FailureReasonText.kt new file mode 100644 index 000000000000..b9cb85c05a6a --- /dev/null +++ b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/clientcontrol/FailureReasonText.kt @@ -0,0 +1,31 @@ +package app.aaps.core.ui.clientcontrol + +import app.aaps.core.interfaces.clientcontrol.FailureReason +import app.aaps.core.keys.interfaces.TextRef +import app.aaps.core.ui.UiStrings + +/** + * The single localized-string mapping for a client-control [FailureReason], shared by the phone pending dialog + * (`ClientControlPendingDialog`) and the wear relay error path (`DataHandlerMobile`). Resolve the returned id on + * the SHOWING device (`stringResource` / `ResourceHelper.gs`) so the text is in that device's locale. + */ + +fun FailureReason.failText(): TextRef = when (this) { + FailureReason.NotPaired -> UiStrings.clientcontrol_fail_not_paired + FailureReason.NotReachable -> UiStrings.clientcontrol_fail_not_reachable + FailureReason.NoReply -> UiStrings.clientcontrol_fail_no_reply + FailureReason.Expired -> UiStrings.clientcontrol_fail_expired + FailureReason.Busy -> UiStrings.clientcontrol_fail_busy + FailureReason.SendFailed -> UiStrings.clientcontrol_fail_send_failed + FailureReason.NoActiveProfile -> UiStrings.clientcontrol_fail_no_active_profile + FailureReason.SceneNotFound -> UiStrings.clientcontrol_fail_scene_not_found + FailureReason.SceneDisabled -> UiStrings.clientcontrol_fail_scene_disabled + FailureReason.PartialFailure -> UiStrings.clientcontrol_fail_partial + FailureReason.ExecutionFailed -> UiStrings.clientcontrol_fail_execution + FailureReason.ControlDisabled -> UiStrings.clientcontrol_fail_control_disabled + FailureReason.NoAction -> UiStrings.no_action_selected + FailureReason.NoPendingBolus -> UiStrings.clientcontrol_fail_no_pending_bolus + FailureReason.BolusComputeFailed -> UiStrings.clientcontrol_fail_bolus_compute + FailureReason.Internal -> UiStrings.clientcontrol_fail_internal + FailureReason.Unknown -> UiStrings.clientcontrol_fail_unknown +} diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/AapsCard.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/AapsCard.kt similarity index 100% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/AapsCard.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/AapsCard.kt diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/AapsCardPreviews.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/AapsCardPreviews.kt similarity index 100% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/AapsCardPreviews.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/AapsCardPreviews.kt diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/AapsFab.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/AapsFab.kt similarity index 100% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/AapsFab.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/AapsFab.kt diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/AapsFabPreviews.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/AapsFabPreviews.kt similarity index 100% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/AapsFabPreviews.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/AapsFabPreviews.kt diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/AapsSearchField.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/AapsSearchField.kt similarity index 93% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/AapsSearchField.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/AapsSearchField.kt index 6932bc92c430..2af6d0cb1a5b 100644 --- a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/AapsSearchField.kt +++ b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/AapsSearchField.kt @@ -17,10 +17,9 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalFocusManager -import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.unit.dp -import app.aaps.core.ui.R +import app.aaps.core.ui.UiStrings /** * Rounded search field styled like Google Contacts search bar. @@ -40,7 +39,7 @@ fun AapsSearchField( query: String, onQueryChange: (String) -> Unit, modifier: Modifier = Modifier, - placeholder: String = stringResource(R.string.search), + placeholder: String = stringResource(UiStrings.search), ) { val focusManager = LocalFocusManager.current @@ -67,7 +66,7 @@ fun AapsSearchField( IconButton(onClick = { onQueryChange("") }) { Icon( Icons.Filled.Clear, - contentDescription = stringResource(R.string.clear), + contentDescription = stringResource(UiStrings.clear), tint = MaterialTheme.colorScheme.onSurfaceVariant ) } diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/AapsSearchFieldPreviews.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/AapsSearchFieldPreviews.kt similarity index 100% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/AapsSearchFieldPreviews.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/AapsSearchFieldPreviews.kt diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/AapsSpacing.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/AapsSpacing.kt similarity index 100% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/AapsSpacing.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/AapsSpacing.kt diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/AapsTheme.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/AapsTheme.kt similarity index 93% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/AapsTheme.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/AapsTheme.kt index f9b5a98209d5..3bd055d7774f 100644 --- a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/AapsTheme.kt +++ b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/AapsTheme.kt @@ -1,6 +1,5 @@ package app.aaps.core.ui.compose -import android.app.Activity import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.material3.LocalTextStyle import androidx.compose.material3.MaterialTheme @@ -9,20 +8,15 @@ import androidx.compose.material3.Typography import androidx.compose.material3.darkColorScheme import androidx.compose.material3.lightColorScheme import androidx.compose.runtime.Composable -import androidx.compose.ui.platform.LocalInspectionMode import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.ReadOnlyComposable -import androidx.compose.runtime.SideEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.compositionLocalOf import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.graphics.Color -import androidx.compose.ui.platform.LocalConfiguration -import androidx.compose.ui.platform.LocalView -import androidx.compose.ui.text.PlatformTextStyle +import androidx.compose.ui.platform.LocalInspectionMode import androidx.compose.ui.text.style.LineHeightStyle -import androidx.core.view.WindowInsetsControllerCompat import app.aaps.core.interfaces.configuration.Config import app.aaps.core.interfaces.profile.ProfileUtil import app.aaps.core.interfaces.utils.DateUtil @@ -267,15 +261,7 @@ fun AapsTheme( // Keep system bar icon color in sync with the AAPS-effective theme so // status/nav bar icons stay legible against the bar scrims (which use // colorScheme.surface). Reactive — no activity recreate needed. - val view = LocalView.current - if (!view.isInEditMode) { - SideEffect { - val window = (view.context as Activity).window - val controller = WindowInsetsControllerCompat(window, view) - controller.isAppearanceLightStatusBars = !isDark - controller.isAppearanceLightNavigationBars = !isDark - } - } + SystemBarAppearance(isDark) val scheme = if (isDark) darkColors else lightColors val profileViewerColors = if (isDark) DarkProfileHelperColors else LightProfileHelperColors @@ -284,7 +270,7 @@ fun AapsTheme( val snackbarColors = if (isDark) DarkSnackbarColors else LightSnackbarColors // Scale typography up on tablets. Orientation-independent (smallest-width signal). - val isTablet = LocalConfiguration.current.smallestScreenWidthDp >= TABLET_MIN_SW_DP + val isTablet = smallestScreenWidthDp() >= TABLET_MIN_SW_DP val typographyScale = if (isTablet) 1.5f else 1f val scaledMaterialTypography = remember(typographyScale) { Typography().withoutFontPadding().scaled(typographyScale) } @@ -304,7 +290,7 @@ fun AapsTheme( // Bare `Text()` (e.g. the overview chips) reads LocalTextStyle, not the typography, // so disable font padding here too — keeps the default size, just metric-centers glyphs. LocalTextStyle provides LocalTextStyle.current.copy( - platformStyle = PlatformTextStyle(includeFontPadding = false), + platformStyle = noFontPaddingPlatformStyle(), lineHeightStyle = LineHeightStyle( alignment = LineHeightStyle.Alignment.Center, trim = LineHeightStyle.Trim.None diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/AapsTopAppBar.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/AapsTopAppBar.kt similarity index 100% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/AapsTopAppBar.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/AapsTopAppBar.kt diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/AapsTopAppBarPreviews.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/AapsTopAppBarPreviews.kt similarity index 100% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/AapsTopAppBarPreviews.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/AapsTopAppBarPreviews.kt diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/AapsTypography.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/AapsTypography.kt similarity index 98% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/AapsTypography.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/AapsTypography.kt index 5c60493b8597..6c934638bfea 100644 --- a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/AapsTypography.kt +++ b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/AapsTypography.kt @@ -6,7 +6,6 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.Immutable import androidx.compose.runtime.compositionLocalOf import androidx.compose.runtime.remember -import androidx.compose.ui.text.PlatformTextStyle import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.LineHeightStyle @@ -100,7 +99,7 @@ fun aapsTypography(scale: Float = 1f): AapsTypography { */ fun Typography.withoutFontPadding(): Typography { fun TextStyle.noPad() = copy( - platformStyle = PlatformTextStyle(includeFontPadding = false), + platformStyle = noFontPaddingPlatformStyle(), lineHeightStyle = LineHeightStyle( alignment = LineHeightStyle.Alignment.Center, trim = LineHeightStyle.Trim.None diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/CarbTimeRow.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/CarbTimeRow.kt similarity index 91% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/CarbTimeRow.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/CarbTimeRow.kt index 10ce7e0b2a02..7a141fb12309 100644 --- a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/CarbTimeRow.kt +++ b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/CarbTimeRow.kt @@ -14,7 +14,6 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.relocation.bringIntoViewRequester import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.Notifications -import androidx.compose.material.icons.outlined.Schedule import androidx.compose.material3.FilledTonalButton import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme @@ -27,10 +26,8 @@ import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp -import app.aaps.core.ui.R -import app.aaps.core.keys.R as KeysR +import app.aaps.core.ui.UiStrings /** * Compact carb time row with inline expand/collapse. @@ -68,7 +65,11 @@ fun CarbTimeRow( var expanded by rememberSaveable { mutableStateOf(false) } val expandRequester = rememberBringIntoViewOnExpand(expanded) - Column(modifier = modifier.fillMaxWidth().bringIntoViewRequester(expandRequester)) { + Column( + modifier = modifier + .fillMaxWidth() + .bringIntoViewRequester(expandRequester) + ) { // Header row: icon + label + value + Change/OK button Row( verticalAlignment = Alignment.CenterVertically, @@ -81,13 +82,13 @@ fun CarbTimeRow( modifier = Modifier.weight(1f) ) { Text( - text = stringResource(R.string.wizard_carb_time) + ": ", + text = stringResource(UiStrings.wizard_carb_time) + ": ", style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.onSurfaceVariant ) if (offsetMinutes == 0) { Text( - text = stringResource(R.string.carb_time_now), + text = stringResource(UiStrings.carb_time_now), style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.onSurface ) @@ -119,7 +120,7 @@ fun CarbTimeRow( if (!expanded) { FilledTonalButton(onClick = { expanded = true }) { - Text(stringResource(R.string.change)) + Text(stringResource(UiStrings.change)) } } } @@ -135,12 +136,12 @@ fun CarbTimeRow( // Offset input NumberInputRow( - labelResId = R.string.time, + labelRef = UiStrings.time, value = offsetMinutes.toDouble(), onValueChange = { onOffsetChange(it.toInt()) }, valueRange = offsetRange.first.toDouble()..offsetRange.last.toDouble(), step = offsetStep.toDouble(), - unitLabelResId = KeysR.string.units_min + unitLabel = UiStrings.units_min ) // Alarm toggle (disabled when offset <= 0) @@ -166,7 +167,7 @@ fun CarbTimeRow( modifier = Modifier.size(20.dp) ) Text( - text = stringResource(R.string.wizard_set_alarm), + text = stringResource(UiStrings.wizard_set_alarm), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurface.copy(alpha = alarmAlpha) ) @@ -191,7 +192,7 @@ fun CarbTimeRow( @Composable private fun formatOffset(minutes: Int): String { return when { - minutes == 0 -> stringResource(R.string.carb_time_now) + minutes == 0 -> stringResource(UiStrings.carb_time_now) minutes > 0 -> "+${formatMinutesAsDuration(minutes)}" else -> "-${formatMinutesAsDuration(-minutes)}" } diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/ComposablePluginContent.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/ComposablePluginContent.kt similarity index 100% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/ComposablePluginContent.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/ComposablePluginContent.kt diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/ComposeScreenContent.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/ComposeScreenContent.kt similarity index 100% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/ComposeScreenContent.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/ComposeScreenContent.kt diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/ConfigPluginCard.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/ConfigPluginCard.kt similarity index 97% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/ConfigPluginCard.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/ConfigPluginCard.kt index dfb8f11ea100..936e203af5a5 100644 --- a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/ConfigPluginCard.kt +++ b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/ConfigPluginCard.kt @@ -32,9 +32,8 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.rememberVectorPainter -import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp -import app.aaps.core.ui.R +import app.aaps.core.ui.UiStrings /** * How a plugin category lets the user choose plugins. @@ -148,7 +147,7 @@ fun ConfigPluginCard( if (showSettings) { InCardActionRow( leadingIcon = Icons.Filled.Settings, - label = stringResource(R.string.settings), + label = stringResource(UiStrings.settings), onClick = onSettingsClick ) } @@ -158,7 +157,7 @@ fun ConfigPluginCard( } InCardActionRow( leadingIcon = Icons.AutoMirrored.Filled.OpenInNew, - label = stringResource(R.string.open_plugin), + label = stringResource(UiStrings.open_plugin), onClick = onOpenPluginClick ) } diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/DateTimeSection.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/DateTimeSection.kt similarity index 94% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/DateTimeSection.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/DateTimeSection.kt index 45f5cdc17c69..4d4308bd614f 100644 --- a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/DateTimeSection.kt +++ b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/DateTimeSection.kt @@ -14,9 +14,8 @@ import androidx.compose.material3.OutlinedTextFieldDefaults import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier -import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp -import app.aaps.core.ui.R +import app.aaps.core.ui.UiStrings /** * Shared date/time picker row with two read-only OutlinedTextFields. @@ -47,7 +46,7 @@ fun DateTimeSection( onValueChange = {}, readOnly = true, enabled = false, - label = { Text(stringResource(R.string.date)) }, + label = { Text(stringResource(UiStrings.date)) }, trailingIcon = { Icon( imageVector = Icons.Filled.DateRange, @@ -72,7 +71,7 @@ fun DateTimeSection( onValueChange = {}, readOnly = true, enabled = false, - label = { Text(stringResource(R.string.time)) }, + label = { Text(stringResource(UiStrings.time)) }, trailingIcon = { Icon( imageVector = Icons.Outlined.Schedule, diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/EventTimeRow.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/EventTimeRow.kt similarity index 93% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/EventTimeRow.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/EventTimeRow.kt index cd325f150cf1..79dddb38755b 100644 --- a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/EventTimeRow.kt +++ b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/EventTimeRow.kt @@ -18,9 +18,8 @@ import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp -import app.aaps.core.ui.R +import app.aaps.core.ui.UiStrings /** * Compact event time row with inline expand/collapse. @@ -58,12 +57,12 @@ fun EventTimeRow( modifier = Modifier.weight(1f) ) { Text( - text = stringResource(R.string.time) + ": ", + text = stringResource(UiStrings.time) + ": ", style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.onSurfaceVariant ) Text( - text = if (timeChanged) displayText else stringResource(R.string.carb_time_now), + text = if (timeChanged) displayText else stringResource(UiStrings.carb_time_now), style = MaterialTheme.typography.titleMedium, color = if (timeChanged) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface @@ -72,7 +71,7 @@ fun EventTimeRow( if (!expanded) { FilledTonalButton(onClick = { expanded = true }) { - Text(stringResource(R.string.change)) + Text(stringResource(UiStrings.change)) } } } diff --git a/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/FontPadding.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/FontPadding.kt new file mode 100644 index 000000000000..abaab2bc0931 --- /dev/null +++ b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/FontPadding.kt @@ -0,0 +1,13 @@ +package app.aaps.core.ui.compose + +import androidx.compose.ui.text.PlatformTextStyle + +/** + * The platform text style that turns off the legacy font padding, or null where there is none. + * + * `includeFontPadding` is an Android text-layout quirk: the platform reserves extra space above the + * glyphs from the font ascent, which stops text from centering in its line box. No other platform + * has it, and `PlatformTextStyle` only accepts the flag on Android, so this is the one line of + * [Typography.withoutFontPadding] that cannot be shared. + */ +expect fun noFontPaddingPlatformStyle(): PlatformTextStyle? diff --git a/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/FormatUtils.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/FormatUtils.kt new file mode 100644 index 000000000000..eef63b699d7f --- /dev/null +++ b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/FormatUtils.kt @@ -0,0 +1,81 @@ +package app.aaps.core.ui.compose + +import androidx.compose.runtime.Composable +import app.aaps.core.data.format.NumberFormat +import app.aaps.core.interfaces.resources.TextResolver +import app.aaps.core.keys.interfaces.TextRef +import app.aaps.core.ui.UiStrings +import kotlin.math.abs +import kotlin.math.roundToInt + +/** + * Formats minutes as duration string: "X h Y min" when >= 60 (omits minutes if zero), "X min" otherwise. + * Composable version using stringResource. + */ +@Composable +fun formatMinutesAsDuration(minutes: Int): String { + val abs = abs(minutes) + val sign = if (minutes < 0) "-" else "" + return if (abs >= 60) { + val hours = abs / 60 + val mins = abs % 60 + sign + if (mins == 0) stringResource(UiStrings.format_hours_only, hours) + else stringResource(UiStrings.format_hour_minute, hours, mins) + } else { + stringResource(UiStrings.format_mins, minutes) + } +} + +/** + * Formats minutes as duration string: "X h Y min" when >= 60 (omits minutes if zero), "X min" otherwise. + * Non-composable version using TextResolver. + */ +fun formatMinutesAsDuration(minutes: Int, rh: TextResolver): String { + val abs = abs(minutes) + val sign = if (minutes < 0) "-" else "" + return if (abs >= 60) { + val hours = abs / 60 + val mins = abs % 60 + sign + if (mins == 0) rh.gs(UiStrings.format_hours_only, hours) + else rh.gs(UiStrings.format_hour_minute, hours, mins) + } else { + rh.gs(UiStrings.format_mins, minutes) + } +} + +/** + * Formats a slider/input value for display, handling durations, resource format strings, + * unit labels, and plain value formatting. + * + * Priority order: + * 1. [asDuration] → "X h Y min" or "X min" + * 2. valueFormat → stringResource with value (as Int if formatAsInt, else Double) + * 3. unitLabel set → "formatted_value unitLabel" + * 4. Plain → valueFormat.format(value) + * + * @param asDuration render the value as a duration. This used to be inferred by comparing the label + * against `units_min`, which tied the behaviour to one particular string resource. Callers say what + * they mean instead. + */ +@Composable +fun formatSliderDisplayValue( + value: Double, + unitLabel: TextRef? = null, + valueFormatRef: TextRef? = null, + formatAsInt: Boolean = false, + valueFormat: NumberFormat, + asDuration: Boolean = false +): String { + val resolvedUnitLabel = unitLabel?.let { stringResource(it) } ?: "" + return when { + asDuration -> formatMinutesAsDuration(value.roundToInt()) + + valueFormatRef != null -> { + if (formatAsInt) stringResource(valueFormatRef, value.roundToInt()) + else stringResource(valueFormatRef, value) + } + + resolvedUnitLabel.isNotEmpty() -> "${valueFormat.format(value)} $resolvedUnitLabel" + else -> valueFormat.format(value) + } +} diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/GeneralColors.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/GeneralColors.kt similarity index 100% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/GeneralColors.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/GeneralColors.kt diff --git a/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/HtmlText.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/HtmlText.kt new file mode 100644 index 000000000000..033a6d5499ea --- /dev/null +++ b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/HtmlText.kt @@ -0,0 +1,80 @@ +package app.aaps.core.ui.compose + +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.font.FontWeight + +private val BoldStyle = SpanStyle(fontWeight = FontWeight.Bold) + +/** + * Turns the small amount of HTML the app uses into styled text. + * + * Only `` and `
` appear anywhere - 36 and 4 times across every English string resource, and + * nothing else - so this handles exactly that plus the usual character entities. It replaces + * `AnnotatedString.fromHtml`, which is an Android only API and was the last thing keeping the + * dialogs off commonMain. + * + * Anything it does not recognise is kept as written rather than dropped. A `<` in user text - a note + * or a pump name - therefore survives instead of being swallowed as a broken tag, which is a small + * improvement on the platform parser. + */ +fun String.htmlToAnnotatedString(): AnnotatedString { + val builder = AnnotatedString.Builder() + var openBold = 0 + var i = 0 + while (i < length) { + when (this[i]) { + '<' -> { + val close = indexOf('>', i) + val tag = if (close > i) substring(i + 1, close).trim().removeSuffix("/").trim().lowercase() else null + when (tag) { + "b", "strong" -> { + builder.pushStyle(BoldStyle); openBold++ + } + + "/b", "/strong" -> if (openBold > 0) { + builder.pop(); openBold-- + } + + "br" -> builder.append('\n') + // Not a tag this app produces. Keep the characters so nothing is silently lost. + else -> { + builder.append(this[i]); i++; continue + } + } + i = close + 1 + } + + '&' -> { + val semi = indexOf(';', i) + val entity = if (semi in (i + 1)..(i + MaxEntityLength)) substring(i + 1, semi) else null + val decoded = entity?.let { decodeEntity(it) } + if (decoded == null) { + builder.append(this[i]); i++ + } else { + builder.append(decoded); i = semi + 1 + } + } + + else -> { + builder.append(this[i]); i++ + } + } + } + // A resource with an unbalanced would otherwise leave the style open forever. + repeat(openBold) { builder.pop() } + return builder.toAnnotatedString() +} + +/** Longest entity name handled below, so a stray `&` in text is not scanned to the end of the string. */ +private const val MaxEntityLength = 6 + +private fun decodeEntity(name: String): String? = when (name.lowercase()) { + "amp" -> "&" + "lt" -> "<" + "gt" -> ">" + "quot" -> "\"" + "apos", "#39" -> "'" + "nbsp", "#160" -> " " + else -> null +} diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/InsulinSelector.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/InsulinSelector.kt similarity index 94% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/InsulinSelector.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/InsulinSelector.kt index 529a9e75fbff..eda1c7062d07 100644 --- a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/InsulinSelector.kt +++ b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/InsulinSelector.kt @@ -15,9 +15,9 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier -import androidx.compose.ui.res.stringResource import app.aaps.core.data.model.ICfg -import app.aaps.core.ui.R +import app.aaps.core.keys.interfaces.TextRef +import app.aaps.core.ui.UiStrings /** * Drop-down for picking one insulin configuration out of the catalogue. @@ -36,7 +36,7 @@ fun InsulinSelector( selected: ICfg?, onSelect: (ICfg) -> Unit, modifier: Modifier = Modifier, - labelResId: Int = R.string.select_insulin + label: TextRef = UiStrings.select_insulin ) { var expanded by remember { mutableStateOf(false) } @@ -49,7 +49,7 @@ fun InsulinSelector( value = selected?.insulinLabel ?: "", onValueChange = {}, readOnly = true, - label = { Text(stringResource(labelResId)) }, + label = { Text(stringResource(label)) }, trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) }, colors = ExposedDropdownMenuDefaults.outlinedTextFieldColors(), modifier = Modifier diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/MasterOfflineBanner.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/MasterOfflineBanner.kt similarity index 93% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/MasterOfflineBanner.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/MasterOfflineBanner.kt index 200611c99555..258baf33d22c 100644 --- a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/MasterOfflineBanner.kt +++ b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/MasterOfflineBanner.kt @@ -14,9 +14,8 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp -import app.aaps.core.ui.R +import app.aaps.core.ui.UiStrings /** * Full-width error banner that explains why a screen's edit controls are disabled: the client's master @@ -34,8 +33,8 @@ import app.aaps.core.ui.R fun MasterOfflineBanner( editingEnabled: Boolean, modifier: Modifier = Modifier, - text: String = if (!LocalMasterControlAllowed.current) stringResource(R.string.master_control_disabled_banner) - else stringResource(R.string.master_offline_banner) + text: String = if (!LocalMasterControlAllowed.current) stringResource(UiStrings.master_control_disabled_banner) + else stringResource(UiStrings.master_offline_banner) ) { if (editingEnabled) return Surface( diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/Modifiers.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/Modifiers.kt similarity index 98% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/Modifiers.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/Modifiers.kt index bf7743390d6f..019e3a69a8cc 100644 --- a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/Modifiers.kt +++ b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/Modifiers.kt @@ -9,7 +9,6 @@ import androidx.compose.foundation.layout.navigationBars import androidx.compose.foundation.layout.union import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.relocation.BringIntoViewRequester -import androidx.compose.foundation.relocation.bringIntoViewRequester import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.remember diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/NumberInputRow.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/NumberInputRow.kt similarity index 86% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/NumberInputRow.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/NumberInputRow.kt index 92fe7c3357c7..b9f9477dd432 100644 --- a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/NumberInputRow.kt +++ b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/NumberInputRow.kt @@ -26,14 +26,14 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.platform.LocalFocusManager -import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.TextRange import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.unit.dp import app.aaps.core.data.format.NumberFormat -import app.aaps.core.ui.R +import app.aaps.core.keys.interfaces.TextRef +import app.aaps.core.ui.UiStrings import kotlin.math.roundToInt /** @@ -47,14 +47,14 @@ import kotlin.math.roundToInt * 0 — 300 ← range, or error message * ``` * - * @param labelResId Resource ID for the display label + * @param labelRef Display label * @param value Current numeric value * @param onValueChange Callback invoked when value changes, receives new value as Double * @param valueRange The range of values the input can represent * @param step Step increment for +/- buttons - * @param unitLabelResId Resource ID for unit label (e.g., R.string.units_min, R.string.units_percent) - * @param unitLabel Resolved unit label string (used when unitLabelResId is 0) - * @param valueFormatResId Resource ID for formatting value with unit (e.g., "%1$.1f U") + * @param unitLabel Unit label shown after the value + * @param asDuration Render the value as "Xh Ym" instead of a plain number + * @param valueFormat Resource ID for formatting value with unit (e.g., "%1$.1f U") * @param formatAsInt If true, value is formatted as Int for stringResource (use with %d format strings) * @param valueFormat Custom NumberFormat (overrides auto-created from decimalPlaces) * @param decimalPlaces Number of decimal places for value display (0 = integer, default). Ignored if valueFormat is set. @@ -69,15 +69,15 @@ import kotlin.math.roundToInt */ @Composable fun NumberInputRow( - labelResId: Int, + labelRef: TextRef?, value: Double, onValueChange: (Double) -> Unit, valueRange: ClosedFloatingPointRange, step: Double, modifier: Modifier = Modifier, - unitLabelResId: Int = 0, - unitLabel: String = "", - valueFormatResId: Int? = null, + unitLabel: TextRef? = null, + asDuration: Boolean = false, + valueFormatRef: TextRef? = null, formatAsInt: Boolean = false, valueFormat: NumberFormat? = null, decimalPlaces: Int = 0, @@ -89,7 +89,7 @@ fun NumberInputRow( NumberFormat.withDecimals(decimalPlaces) } val focusManager = LocalFocusManager.current - val label = if (labelResId != 0) stringResource(labelResId) else "" + val label = labelRef?.let { stringResource(it) } ?: "" // Track whether the field is focused for editing var isFocused by remember { mutableStateOf(false) } var textFieldValue by remember { @@ -111,25 +111,27 @@ fun NumberInputRow( } } + val resolvedUnitLabel = unitLabel?.let { stringResource(it) } ?: "" + // Formatted display text for special cases (duration) val formattedDisplay = formatSliderDisplayValue( value = value, - unitLabelResId = unitLabelResId, - valueFormatResId = valueFormatResId, + unitLabel = unitLabel, + valueFormatRef = valueFormatRef, formatAsInt = formatAsInt, valueFormat = effectiveValueFormat, - unitLabel = unitLabel + asDuration = asDuration ) // Only show formatted display when it differs meaningfully from the raw number val rawDisplay = effectiveValueFormat.format(value) val showFormattedDisplay = formattedDisplay != rawDisplay && - formattedDisplay != "$rawDisplay $unitLabel".trim() + formattedDisplay != "$rawDisplay $resolvedUnitLabel".trim() // Range text val rangeText = "${effectiveValueFormat.format(valueRange.start)} — ${effectiveValueFormat.format(valueRange.endInclusive)}" // Pre-resolve error strings for use in non-composable validateAndCommit - val errorInvalidNumber = stringResource(R.string.invalid_number) + val errorInvalidNumber = stringResource(UiStrings.invalid_number) fun validateAndCommit(text: String) { val cleaned = text.trim().replace(",", ".") @@ -173,11 +175,6 @@ fun NumberInputRow( onValueChange(newValue) } - val resolvedUnitLabel = when { - unitLabelResId != 0 -> stringResource(unitLabelResId) - unitLabel.isNotEmpty() -> unitLabel - else -> "" - } Row( verticalAlignment = Alignment.CenterVertically, @@ -297,4 +294,41 @@ fun NumberInputRow( } } -// --- Previews --- +/** + * Convenience for the many call sites that name their own module's `UiStrings.x`. + * The [TextRef] form above is the real one; this just wraps the id. + */ +@Composable +fun NumberInputRow( + labelResId: Int, + value: Double, + onValueChange: (Double) -> Unit, + valueRange: ClosedFloatingPointRange, + step: Double, + modifier: Modifier = Modifier, + unitLabel: TextRef? = null, + asDuration: Boolean = false, + valueFormatRef: TextRef? = null, + formatAsInt: Boolean = false, + valueFormat: NumberFormat? = null, + decimalPlaces: Int = 0, + enabled: Boolean = true, + compact: Boolean = false, + displayValue: String? = null, +) = NumberInputRow( + labelRef = if (labelResId != 0) TextRef.AndroidRes(labelResId) else null, + value = value, + onValueChange = onValueChange, + valueRange = valueRange, + step = step, + modifier = modifier, + unitLabel = unitLabel, + asDuration = asDuration, + valueFormatRef = valueFormatRef, + formatAsInt = formatAsInt, + valueFormat = valueFormat, + decimalPlaces = decimalPlaces, + enabled = enabled, + compact = compact, + displayValue = displayValue, +) diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/NumberInputRowPreviews.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/NumberInputRowPreviews.kt similarity index 69% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/NumberInputRowPreviews.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/NumberInputRowPreviews.kt index adfe14381c65..b41d4424fce5 100644 --- a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/NumberInputRowPreviews.kt +++ b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/NumberInputRowPreviews.kt @@ -3,14 +3,15 @@ package app.aaps.core.ui.compose import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.ui.tooling.preview.Preview -import app.aaps.core.ui.R -import app.aaps.core.keys.R as KeysR +import app.aaps.core.interfaces.InterfacesStrings +import app.aaps.core.keys.interfaces.TextRef +import app.aaps.core.ui.UiStrings @Preview(showBackground = true) @Composable internal fun NumberInputRowBasicPreview() { MaterialTheme { - NumberInputRow(labelResId = R.string.carbs, value = 20.0, onValueChange = {}, valueRange = 0.0..100.0, step = 1.0) + NumberInputRow(labelRef = InterfacesStrings.carbs, value = 20.0, onValueChange = {}, valueRange = 0.0..100.0, step = 1.0) } } @@ -19,13 +20,13 @@ internal fun NumberInputRowBasicPreview() { internal fun NumberInputRowWithUnitPreview() { MaterialTheme { NumberInputRow( - labelResId = R.string.insulin_label, + labelRef = UiStrings.insulin_label, value = 3.5, onValueChange = {}, valueRange = 0.0..10.0, step = 0.1, decimalPlaces = 1, - unitLabel = "U" + unitLabel = TextRef.Literal("U") ) } } @@ -35,12 +36,12 @@ internal fun NumberInputRowWithUnitPreview() { internal fun NumberInputRowMinutesPreview() { MaterialTheme { NumberInputRow( - labelResId = R.string.duration, + labelRef = UiStrings.duration, value = 130.0, onValueChange = {}, valueRange = 0.0..300.0, step = 10.0, - unitLabelResId = KeysR.string.units_min + unitLabel = UiStrings.units_min ) } } @@ -50,12 +51,12 @@ internal fun NumberInputRowMinutesPreview() { internal fun NumberInputRowPercentPreview() { MaterialTheme { NumberInputRow( - labelResId = R.string.duration, + labelRef = UiStrings.duration, value = 100.0, onValueChange = {}, valueRange = 10.0..200.0, step = 5.0, - unitLabelResId = KeysR.string.units_percent + unitLabel = UiStrings.units_percent ) } } @@ -65,12 +66,12 @@ internal fun NumberInputRowPercentPreview() { internal fun NumberInputRowMinutesDirectPreview() { MaterialTheme { NumberInputRow( - labelResId = R.string.duration, + labelRef = UiStrings.duration, value = 130.0, onValueChange = {}, valueRange = 0.0..300.0, step = 10.0, - unitLabelResId = KeysR.string.units_min + unitLabel = UiStrings.units_min ) } } diff --git a/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/PlatformTheme.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/PlatformTheme.kt new file mode 100644 index 000000000000..5e336a974503 --- /dev/null +++ b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/PlatformTheme.kt @@ -0,0 +1,23 @@ +package app.aaps.core.ui.compose + +import androidx.compose.runtime.Composable + +/** + * Keeps the system bar icons legible against the bar scrims, which use `colorScheme.surface`. + * + * Platform specific because the two platforms do not even agree on what a system bar is: Android + * flips light/dark icon appearance on the window, and this must react without recreating the + * activity. + */ +@Composable +expect fun SystemBarAppearance(isDark: Boolean) + +/** + * Smallest screen width in dp, used to decide whether this is a tablet and scale typography. + * + * Smallest width rather than current width, so the answer does not change when the device is + * rotated. Platform specific because Android answers from the device configuration rather than from + * the size of the window the app happens to occupy. + */ +@Composable +expect fun smallestScreenWidthDp(): Int diff --git a/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/PluginCategoryTitle.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/PluginCategoryTitle.kt new file mode 100644 index 000000000000..fb381d38a084 --- /dev/null +++ b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/PluginCategoryTitle.kt @@ -0,0 +1,24 @@ +package app.aaps.core.ui.compose + +import app.aaps.core.data.plugin.PluginType +import app.aaps.core.keys.interfaces.TextRef +import app.aaps.core.ui.UiStrings + +/** + * Single source of truth for the plugin-category → title string mapping, shared by the Configuration + * screen and the Quick-launch config. Exhaustive `when` (no `else`) so adding a [PluginType] is a + * compile-forced decision here. + */ + +fun pluginCategoryTitle(type: PluginType): TextRef = when (type) { + PluginType.BGSOURCE -> UiStrings.configbuilder_bgsource + PluginType.SMOOTHING -> UiStrings.configbuilder_smoothing + PluginType.CALIBRATION -> UiStrings.configbuilder_calibration + PluginType.PUMP -> UiStrings.configbuilder_pump + PluginType.SENSITIVITY -> UiStrings.configbuilder_sensitivity + PluginType.APS -> UiStrings.configbuilder_aps + PluginType.LOOP -> UiStrings.configbuilder_loop + PluginType.CONSTRAINTS -> UiStrings.constraints + PluginType.SYNC -> UiStrings.configbuilder_sync + PluginType.GENERAL -> UiStrings.configbuilder_general +} diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/PlusMinusEdit.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/PlusMinusEdit.kt similarity index 93% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/PlusMinusEdit.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/PlusMinusEdit.kt index 82d5d6a8f9d5..b5d8898917fb 100644 --- a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/PlusMinusEdit.kt +++ b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/PlusMinusEdit.kt @@ -23,13 +23,13 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.platform.LocalFocusManager -import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.TextRange import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.unit.dp import app.aaps.core.data.format.NumberFormat +import app.aaps.core.keys.interfaces.TextRef import kotlin.math.roundToInt /** @@ -45,8 +45,7 @@ import kotlin.math.roundToInt * @param valueRange Allowed value range; values outside are clamped on commit * @param step Step size for +/- buttons * @param valueFormat Format for the displayed text - * @param unitLabel Resolved unit label (used when [unitLabelResId] is 0) - * @param unitLabelResId Resource ID for the unit label, shown as the field's trailing icon + * @param unitLabel Unit label, shown as the field's trailing icon * @param enabled Whether the stepper is interactive * @param modifier Modifier for the row container */ @@ -57,8 +56,7 @@ fun PlusMinusEdit( valueRange: ClosedFloatingPointRange, step: Double, valueFormat: NumberFormat = NumberFormat.DECIMAL_1, - unitLabel: String = "", - unitLabelResId: Int = 0, + unitLabel: TextRef? = null, enabled: Boolean = true, modifier: Modifier = Modifier ) { @@ -80,11 +78,7 @@ fun PlusMinusEdit( } } - val resolvedUnitLabel = when { - unitLabelResId != 0 -> stringResource(unitLabelResId) - unitLabel.isNotEmpty() -> unitLabel - else -> "" - } + val resolvedUnitLabel = unitLabel?.let { stringResource(it) } ?: "" fun validateAndCommit(text: String) { val cleaned = text.trim().replace(",", ".") diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/ProfileHelperColors.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/ProfileHelperColors.kt similarity index 100% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/ProfileHelperColors.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/ProfileHelperColors.kt diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/QuickAddButtons.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/QuickAddButtons.kt similarity index 100% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/QuickAddButtons.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/QuickAddButtons.kt diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/QuickAddButtonsPreviews.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/QuickAddButtonsPreviews.kt similarity index 100% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/QuickAddButtonsPreviews.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/QuickAddButtonsPreviews.kt diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/ScreenMode.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/ScreenMode.kt similarity index 100% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/ScreenMode.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/ScreenMode.kt diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/SelectableListToolbar.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/SelectableListToolbar.kt similarity index 88% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/SelectableListToolbar.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/SelectableListToolbar.kt index 1e6c1eea2042..31fdc0a40fdc 100644 --- a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/SelectableListToolbar.kt +++ b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/SelectableListToolbar.kt @@ -20,8 +20,8 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue -import app.aaps.core.interfaces.resources.ResourceHelper -import app.aaps.core.ui.R +import app.aaps.core.interfaces.resources.TextResolver +import app.aaps.core.ui.UiStrings /** * Reusable toolbar builder for screens with selectable list items. @@ -49,7 +49,7 @@ fun SelectableListToolbar( onExitRemovingMode: () -> Unit, onNavigateBack: () -> Unit, onDelete: () -> Unit, - rh: ResourceHelper, + rh: TextResolver, title: String = "", showInvalidated: Boolean? = null, onToggleInvalidated: (() -> Unit)? = null, @@ -61,12 +61,12 @@ fun SelectableListToolbar( return if (isRemovingMode) { // Selection mode: show count, close icon, and delete action ToolbarConfig( - title = rh.gs(R.string.count_selected, selectedCount), + title = rh.gs(UiStrings.count_selected, selectedCount), navigationIcon = { IconButton(onClick = onExitRemovingMode) { Icon( imageVector = Icons.Default.Close, - contentDescription = rh.gs(R.string.close) + contentDescription = rh.gs(UiStrings.close) ) } }, @@ -75,7 +75,7 @@ fun SelectableListToolbar( IconButton(onClick = onDelete) { Icon( imageVector = Icons.Default.Delete, - contentDescription = rh.gs(R.string.delete), + contentDescription = rh.gs(UiStrings.delete), tint = MaterialTheme.colorScheme.error ) } @@ -89,7 +89,7 @@ fun SelectableListToolbar( IconButton(onClick = onNavigateBack) { Icon( imageVector = Icons.AutoMirrored.Filled.ArrowBack, - contentDescription = rh.gs(R.string.back) + contentDescription = rh.gs(UiStrings.back) ) } }, @@ -100,9 +100,9 @@ fun SelectableListToolbar( Icon( imageVector = if (showInvalidated) Icons.Default.VisibilityOff else Icons.Default.Visibility, contentDescription = if (showInvalidated) - rh.gs(R.string.hide_invalidated) + rh.gs(UiStrings.hide_invalidated) else - rh.gs(R.string.show_invalidated) + rh.gs(UiStrings.show_invalidated) ) } } @@ -112,7 +112,7 @@ fun SelectableListToolbar( IconButton(onClick = onToggleLoop) { Icon( imageVector = if (showLoop) Icons.Default.VisibilityOff else Icons.Default.Visibility, - contentDescription = rh.gs(R.string.show_hide_records) + contentDescription = rh.gs(UiStrings.show_hide_records) ) } } @@ -127,7 +127,7 @@ fun SelectableListToolbar( IconButton(onClick = onSettings) { Icon( imageVector = Icons.Default.Settings, - contentDescription = rh.gs(R.string.nav_plugin_preferences) + contentDescription = rh.gs(UiStrings.nav_plugin_preferences) ) } } @@ -142,7 +142,7 @@ fun SelectableListToolbar( @Composable private fun MenuDropdown( menuItems: List, - rh: ResourceHelper + rh: TextResolver ) { var showMenu by remember { mutableStateOf(false) } @@ -150,7 +150,7 @@ private fun MenuDropdown( IconButton(onClick = { showMenu = true }) { Icon( imageVector = Icons.Default.MoreVert, - contentDescription = rh.gs(R.string.more_options) + contentDescription = rh.gs(UiStrings.more_options) ) } DropdownMenu( diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/SliderWithButtons.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/SliderWithButtons.kt similarity index 91% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/SliderWithButtons.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/SliderWithButtons.kt index b566ccf71690..629687de5e9b 100644 --- a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/SliderWithButtons.kt +++ b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/SliderWithButtons.kt @@ -30,15 +30,16 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.hapticfeedback.HapticFeedbackType import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.platform.LocalHapticFeedback -import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import app.aaps.core.data.format.NumberFormat +import app.aaps.core.keys.interfaces.TextRef import app.aaps.core.ui.compose.dialogs.ValueInputDialog import kotlinx.coroutines.delay +import kotlin.math.pow import kotlin.math.roundToInt -import app.aaps.core.keys.R as KeysR +import kotlin.math.roundToLong /** * A Slider with +/- buttons on each side for fine-grained value control. @@ -50,11 +51,11 @@ import app.aaps.core.keys.R as KeysR * @param step The step size for +/- buttons (default 0.1) * @param controlPoints Pairs of (position [0-1], value) to create a non linear slider, if null slider is linear * @param showValue Whether to show a clickable value label (default false) - * @param valueFormatResId Resource ID for formatting value with unit (e.g., "%1$.1f U" or "%1$d min") + * @param valueFormat Resource ID for formatting value with unit (e.g., "%1$.1f U" or "%1$d min") * @param formatAsInt If true, value is formatted as Int for stringResource (use with %d format strings) * @param valueFormat Format for the value (used for dialog and fallback) - * @param unitLabel Unit label for dialog input suffix (deprecated, use unitLabelResId) - * @param unitLabelResId Resource ID for unit label. When R.string.units_min, auto-formats as "Xh Ym" + * @param unitLabel Unit label, shown after the value and as the dialog input suffix + * @param asDuration Render the value as "Xh Ym" instead of a plain number * @param dialogLabel Label for the input dialog * @param dialogSummary Summary/description for the input dialog * @param modifier Modifier for the Row container @@ -72,11 +73,11 @@ fun SliderWithButtons( step: Double = 0.1, controlPoints: List>? = null, showValue: Boolean = false, - valueFormatResId: Int? = null, + valueFormatRef: TextRef? = null, formatAsInt: Boolean = false, valueFormat: NumberFormat = NumberFormat.DECIMAL_1, - unitLabel: String = "", - unitLabelResId: Int = 0, + unitLabel: TextRef? = null, + asDuration: Boolean = false, dialogLabel: String? = null, dialogSummary: String? = null, enabled: Boolean = true, @@ -157,22 +158,16 @@ fun SliderWithButtons( val dynamicStepPosUp = (posForCurrentPlusStep - posForCurrent).coerceAtLeast(0.001f) val dynamicStepPosDown = (posForCurrent - posForCurrentMinusStep).coerceAtLeast(0.001f) - // Check if this is minutes input for special formatting - val isMinutesUnit = unitLabelResId == KeysR.string.units_min - val resolvedUnitLabel = when { - unitLabelResId != 0 -> stringResource(unitLabelResId) - unitLabel.isNotEmpty() -> unitLabel - else -> "" - } + val resolvedUnitLabel = unitLabel?.let { stringResource(it) } ?: "" // Use shared formatting function for display text val displayText = if (showValue) formatSliderDisplayValue( value = value, - unitLabelResId = unitLabelResId, - valueFormatResId = valueFormatResId, + unitLabel = unitLabel, + valueFormatRef = valueFormatRef, formatAsInt = formatAsInt, valueFormat = valueFormat, - unitLabel = unitLabel + asDuration = asDuration ) else "" BoxWithConstraints(modifier = modifier) { @@ -243,7 +238,7 @@ fun SliderWithButtons( color = MaterialTheme.colorScheme.primary, textAlign = TextAlign.End, modifier = Modifier - .widthIn(min = if (isMinutesUnit || valueFormatResId != null || resolvedUnitLabel.isNotEmpty()) 70.dp else 40.dp) + .widthIn(min = if (asDuration || valueFormat != null || resolvedUnitLabel.isNotEmpty()) 70.dp else 40.dp) .then(if (enabled) Modifier.clickable { showDialog = true } else Modifier) .padding(start = 4.dp) ) @@ -259,8 +254,8 @@ fun SliderWithButtons( step = step, label = dialogLabel, summary = dialogSummary, - unitLabel = resolvedUnitLabel, - unitLabelResId = unitLabelResId, + unitLabel = unitLabel, + asDuration = asDuration, valueFormat = valueFormat, onValueConfirm = onValueChange, onDismiss = { showDialog = false } @@ -272,8 +267,8 @@ internal fun roundToStep(value: Double, step: Double): Double { val scaled = (value / step).roundToInt() * step // Fix floating point precision errors (e.g., 6.1000000000005 -> 6.1) val decimals = step.toString().substringAfter('.', "").length - val factor = Math.pow(10.0, decimals.toDouble()) - return Math.round(scaled * factor) / factor + val factor = 10.0.pow(decimals.toDouble()) + return (scaled * factor).roundToLong() / factor } /** diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/SliderWithButtonsPreviews.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/SliderWithButtonsPreviews.kt similarity index 93% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/SliderWithButtonsPreviews.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/SliderWithButtonsPreviews.kt index 963505fa1948..ddecc39d0d74 100644 --- a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/SliderWithButtonsPreviews.kt +++ b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/SliderWithButtonsPreviews.kt @@ -5,7 +5,8 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.ui.tooling.preview.Preview import app.aaps.core.data.format.NumberFormat -import app.aaps.core.keys.R as KeysR +import app.aaps.core.keys.interfaces.TextRef +import app.aaps.core.ui.UiStrings @Preview(showBackground = true) @Composable @@ -31,7 +32,7 @@ internal fun SliderWithButtonsValuePreview() { step = 0.1, showValue = true, valueFormat = NumberFormat.DECIMAL_1, - unitLabel = "U" + unitLabel = TextRef.Literal("U") ) } } @@ -47,7 +48,7 @@ internal fun SliderWithButtonsIntPreview() { step = 5.0, showValue = true, valueFormat = NumberFormat.INTEGER, - unitLabelResId = KeysR.string.units_min + unitLabel = UiStrings.units_min ) } } diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/SnackbarColors.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/SnackbarColors.kt similarity index 100% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/SnackbarColors.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/SnackbarColors.kt diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/StateColors.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/StateColors.kt similarity index 100% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/StateColors.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/StateColors.kt diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/StatusLevel.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/StatusLevel.kt similarity index 100% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/StatusLevel.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/StatusLevel.kt diff --git a/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/TextRefResource.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/TextRefResource.kt new file mode 100644 index 000000000000..dca883827c83 --- /dev/null +++ b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/TextRefResource.kt @@ -0,0 +1,30 @@ +package app.aaps.core.ui.compose + +import androidx.compose.runtime.Composable +import app.aaps.core.keys.interfaces.TextRef +import app.aaps.core.keys.interfaces.TextRef.Companion.withArgs + +/** + * Resolves a [TextRef] to text inside a Composable. + * + * Every screen funnels through this one function, which is the point: a module that changes how it + * stores its strings changes only this resolver, and the call sites do not change again. + * + * It is `expect` because finding the text is the one part that cannot be shared. Android looks the + * reference up through `Resources`, so it keeps doing its own locale matching exactly as before. + * Every other target needs its own answer. + */ +@Composable +expect fun stringResource(ref: TextRef): String + +/** + * Same, with format arguments - mirrors `androidx.compose.ui.res.stringResource(id, vararg)`, which + * is what the call sites used before they stopped naming resource ids. + */ +@Composable +fun stringResource(ref: TextRef, vararg formatArgs: Any): String = + stringResource(ref.withArgs(*formatArgs)) + +/** Same, for an optional reference - returns null so callers can keep using `?.let { }`. */ +@Composable +fun stringResourceOrNull(ref: TextRef?): String? = ref?.let { stringResource(it) } diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/TimeRangePicker.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/TimeRangePicker.kt similarity index 96% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/TimeRangePicker.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/TimeRangePicker.kt index 0c9718f270a4..0ab7062517c2 100644 --- a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/TimeRangePicker.kt +++ b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/TimeRangePicker.kt @@ -18,9 +18,8 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp -import app.aaps.core.ui.R +import app.aaps.core.ui.UiStrings import app.aaps.core.ui.compose.dialogs.TimePickerModal /** @@ -75,7 +74,7 @@ fun TimeRangePicker( modifier = Modifier.clickable { showStartPicker = true } ) { Text( - text = stringResource(R.string.from_label), + text = stringResource(UiStrings.from_label), style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant ) @@ -100,7 +99,7 @@ fun TimeRangePicker( modifier = Modifier.clickable { showEndPicker = true } ) { Text( - text = stringResource(R.string.to_label), + text = stringResource(UiStrings.to_label), style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant ) diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/TonalIcon.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/TonalIcon.kt similarity index 100% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/TonalIcon.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/TonalIcon.kt diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/ToolbarConfig.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/ToolbarConfig.kt similarity index 100% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/ToolbarConfig.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/ToolbarConfig.kt diff --git a/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/UnitTypeText.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/UnitTypeText.kt new file mode 100644 index 000000000000..c820cb1d735d --- /dev/null +++ b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/UnitTypeText.kt @@ -0,0 +1,86 @@ +package app.aaps.core.ui.compose + +import app.aaps.core.keys.UnitType +import app.aaps.core.keys.interfaces.TextRef +import app.aaps.core.keys.interfaces.TextRef.Companion.withArgs +import app.aaps.core.ui.UiStrings + +/** + * Maps a [UnitType] to the text that describes it. + * + * This lives in `:core:ui` rather than next to the enum on purpose. [UnitType] itself is a plain + * enum with no Android in it, so it can move to a multiplatform module. The mapping to string + * resources cannot, so it stays on the UI side and the enum travels alone. + */ + +/** + * The unit label on its own - "min", "U", "h". + * Null when the unit has no label, which is the case for NONE and the generic doubles. + */ +fun UnitType.unitLabel(): TextRef? = when (this) { + UnitType.NONE -> null + UnitType.GRAMS -> UiStrings.units_grams + UnitType.MIN -> UiStrings.units_min + UnitType.SEC -> UiStrings.units_sec + UnitType.HOURS, UnitType.HOURS_DOUBLE -> UiStrings.units_hours + UnitType.DAYS -> UiStrings.units_days + UnitType.PERCENT -> UiStrings.units_percent + UnitType.INSULIN, UnitType.INSULIN_INT -> UiStrings.units_insulin + UnitType.INSULIN_RATE -> UiStrings.units_insulin_rate + UnitType.DOUBLE, UnitType.DOUBLE_2, UnitType.DOUBLE_3 -> null // generic doubles carry no unit + UnitType.MGDL -> UiStrings.units_mgdl +} + +/** + * A value and its range, already filled in - "5 min (0 - 30)". + * + * The arguments are taken here rather than returned as a bare template id, because a [TextRef.AndroidRes] + * with no arguments renders the raw `%1$d` template. Taking them makes that mistake impossible. + */ +fun UnitType.rangeText(value: Any, min: Any, max: Any): TextRef? = + rangeFormat()?.withArgs(value, min, max) + +/** + * Format template for a single value, e.g. `%1$d min`. + * + * Still a raw resource id, unlike the two above, because the slider applies it to whatever value + * the user drags to - the arguments are not known here. It never leaves `:core:ui`. + */ +fun UnitType.valueFormat(): TextRef? = when (this) { + UnitType.NONE -> null + UnitType.GRAMS -> UiStrings.units_format_grams + UnitType.MIN -> UiStrings.units_format_min + UnitType.SEC -> UiStrings.units_format_sec + UnitType.HOURS -> UiStrings.units_format_hours + UnitType.HOURS_DOUBLE -> UiStrings.units_format_hours_double + UnitType.DAYS -> UiStrings.units_format_days + UnitType.PERCENT -> UiStrings.units_format_percent + UnitType.INSULIN -> UiStrings.units_format_insulin + UnitType.INSULIN_INT -> UiStrings.units_format_insulin_int + UnitType.INSULIN_RATE -> UiStrings.units_format_insulin_rate + UnitType.DOUBLE -> UiStrings.units_format_double + UnitType.DOUBLE_2 -> UiStrings.units_format_double_2 + UnitType.DOUBLE_3 -> UiStrings.units_format_double_3 + UnitType.MGDL -> UiStrings.units_format_mgdl +} + +private fun UnitType.rangeFormat(): TextRef? = when (this) { + UnitType.NONE -> null + UnitType.GRAMS -> UiStrings.units_format_grams_range + UnitType.MIN -> UiStrings.units_format_min_range + UnitType.SEC -> UiStrings.units_format_sec_range + UnitType.HOURS -> UiStrings.units_format_hours_range + UnitType.HOURS_DOUBLE -> UiStrings.units_format_hours_double_range + UnitType.DAYS -> UiStrings.units_format_days_range + UnitType.PERCENT -> UiStrings.units_format_percent_range + UnitType.INSULIN -> UiStrings.units_format_insulin_range + UnitType.INSULIN_INT -> UiStrings.units_format_insulin_int_range + UnitType.INSULIN_RATE -> UiStrings.units_format_insulin_rate_range + UnitType.DOUBLE -> UiStrings.units_format_double_range + UnitType.DOUBLE_2 -> UiStrings.units_format_double_2_range + UnitType.DOUBLE_3 -> UiStrings.units_format_double_3_range + UnitType.MGDL -> UiStrings.units_format_mgdl_range +} + +/** True when this unit should be rendered as a duration ("1 h 30 min") rather than a plain number. */ +fun UnitType.isDuration(): Boolean = this == UnitType.MIN diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/banner/Banner.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/banner/Banner.kt similarity index 100% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/banner/Banner.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/banner/Banner.kt diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/banner/BannerPreviews.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/banner/BannerPreviews.kt similarity index 100% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/banner/BannerPreviews.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/banner/BannerPreviews.kt diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/dialogs/ConfirmationMessage.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/dialogs/ConfirmationMessage.kt similarity index 100% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/dialogs/ConfirmationMessage.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/dialogs/ConfirmationMessage.kt diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/dialogs/DatePickerModal.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/dialogs/DatePickerModal.kt similarity index 87% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/dialogs/DatePickerModal.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/dialogs/DatePickerModal.kt index 94ebbe772eaa..6d530f496de2 100644 --- a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/dialogs/DatePickerModal.kt +++ b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/dialogs/DatePickerModal.kt @@ -7,8 +7,8 @@ import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.material3.rememberDatePickerState import androidx.compose.runtime.Composable -import androidx.compose.ui.res.stringResource -import app.aaps.core.ui.R +import app.aaps.core.ui.UiStrings +import app.aaps.core.ui.compose.stringResource /** * A modal date picker dialog. @@ -35,12 +35,12 @@ fun DatePickerModal( onDateSelected(datePickerState.selectedDateMillis) onDismiss() }) { - Text(stringResource(R.string.ok)) + Text(stringResource(UiStrings.ok)) } }, dismissButton = { TextButton(onClick = onDismiss) { - Text(stringResource(R.string.cancel)) + Text(stringResource(UiStrings.cancel)) } } ) { diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/dialogs/DatePickerModalPreviews.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/dialogs/DatePickerModalPreviews.kt similarity index 100% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/dialogs/DatePickerModalPreviews.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/dialogs/DatePickerModalPreviews.kt diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/dialogs/ElementConfirmationDialog.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/dialogs/ElementConfirmationDialog.kt similarity index 87% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/dialogs/ElementConfirmationDialog.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/dialogs/ElementConfirmationDialog.kt index e6ea694c32d2..192d8034fc46 100644 --- a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/dialogs/ElementConfirmationDialog.kt +++ b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/dialogs/ElementConfirmationDialog.kt @@ -1,20 +1,20 @@ package app.aaps.core.ui.compose.dialogs import androidx.compose.runtime.Composable -import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.AnnotatedString import app.aaps.core.data.ui.ConfirmationLine import app.aaps.core.interfaces.navigation.ElementType import app.aaps.core.ui.compose.navigation.color import app.aaps.core.ui.compose.navigation.icon -import app.aaps.core.ui.compose.navigation.labelResId +import app.aaps.core.ui.compose.navigation.label +import app.aaps.core.ui.compose.stringResourceOrNull /** * Shared confirmation dialog for the action dialogs (wizard, treatment, insulin, temp-basal, care, …). * * Carries the [elementType]'s visual identity — its label as the title and its themed icon/tint — so each * dialog only supplies the per-action [message] (a colored [AnnotatedString] summary) and the confirm/dismiss - * callbacks, instead of re-inlining `OkCancelDialog(title = …labelResId(), icon = …icon(), iconTint = …color())` + * callbacks, instead of re-inlining `OkCancelDialog(title = …label(), icon = …icon(), iconTint = …color())` * in every screen. */ @Composable @@ -25,7 +25,7 @@ fun ElementConfirmationDialog( onDismiss: () -> Unit ) { OkCancelDialog( - title = stringResource(elementType.labelResId()), + title = (stringResourceOrNull(elementType.label()) ?: ""), message = message, icon = elementType.icon(), iconTint = elementType.color(), @@ -59,7 +59,7 @@ fun ElementConfirmationDialog( onDismiss: () -> Unit ) { OkCancelDialog( - title = stringResource(elementType.labelResId()), + title = (stringResourceOrNull(elementType.label()) ?: ""), message = message, icon = elementType.icon(), iconTint = elementType.color(), diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/dialogs/ErrorDialog.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/dialogs/ErrorDialog.kt similarity index 92% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/dialogs/ErrorDialog.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/dialogs/ErrorDialog.kt index 462bbb74c7d3..bd893626128a 100644 --- a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/dialogs/ErrorDialog.kt +++ b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/dialogs/ErrorDialog.kt @@ -10,12 +10,12 @@ import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier -import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.AnnotatedString -import androidx.compose.ui.text.fromHtml import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.window.DialogProperties -import app.aaps.core.ui.R +import app.aaps.core.ui.UiStrings +import app.aaps.core.ui.compose.htmlToAnnotatedString +import app.aaps.core.ui.compose.stringResource /** * An error/warning dialog with a warning icon, dismiss button, and optional positive button. @@ -54,7 +54,7 @@ fun ErrorDialog( }, text = { Text( - text = AnnotatedString.fromHtml(message.replace("\n", "
")), + text = message.htmlToAnnotatedString(), modifier = Modifier.fillMaxWidth(), textAlign = TextAlign.Center ) @@ -68,7 +68,7 @@ fun ErrorDialog( }, dismissButton = { TextButton(onClick = onDismiss) { - Text(stringResource(R.string.dismiss)) + Text(stringResource(UiStrings.dismiss)) } }, properties = DialogProperties(dismissOnBackPress = true, dismissOnClickOutside = true) @@ -118,7 +118,7 @@ fun ErrorDialog( }, dismissButton = { TextButton(onClick = onDismiss) { - Text(stringResource(R.string.dismiss)) + Text(stringResource(UiStrings.dismiss)) } }, properties = DialogProperties(dismissOnBackPress = true, dismissOnClickOutside = true) diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/dialogs/ErrorDialogPreviews.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/dialogs/ErrorDialogPreviews.kt similarity index 100% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/dialogs/ErrorDialogPreviews.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/dialogs/ErrorDialogPreviews.kt diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/dialogs/GlobalDialogHost.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/dialogs/GlobalDialogHost.kt similarity index 98% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/dialogs/GlobalDialogHost.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/dialogs/GlobalDialogHost.kt index 78db977381c9..0872f048585b 100644 --- a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/dialogs/GlobalDialogHost.kt +++ b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/dialogs/GlobalDialogHost.kt @@ -44,7 +44,7 @@ fun GlobalDialogHost(rxBus: RxBus) { LaunchedEffect(rxBus, lifecycleOwner) { lifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { - rxBus.toFlow(EventShowDialog::class.java).collect { event -> + rxBus.toFlow(EventShowDialog::class).collect { event -> val choice = CompletableDeferred() try { current = event to choice diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/dialogs/GlobalSnackbarHost.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/dialogs/GlobalSnackbarHost.kt similarity index 96% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/dialogs/GlobalSnackbarHost.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/dialogs/GlobalSnackbarHost.kt index 1c172a513333..fb9c8bb5d2e5 100644 --- a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/dialogs/GlobalSnackbarHost.kt +++ b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/dialogs/GlobalSnackbarHost.kt @@ -25,16 +25,16 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector -import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.lifecycle.Lifecycle import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.lifecycle.repeatOnLifecycle import app.aaps.core.interfaces.rx.bus.RxBus import app.aaps.core.interfaces.rx.events.EventShowSnackbar -import app.aaps.core.ui.R +import app.aaps.core.ui.UiStrings import app.aaps.core.ui.compose.AapsTheme import app.aaps.core.ui.compose.SnackbarColors +import app.aaps.core.ui.compose.stringResource /** * Root-level snackbar host that subscribes to [EventShowSnackbar] on [rxBus] @@ -65,7 +65,7 @@ fun GlobalSnackbarHost( // transition, double-surfacing messages. LaunchedEffect(rxBus, lifecycleOwner) { lifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { - rxBus.toFlow(EventShowSnackbar::class.java).collect { event -> + rxBus.toFlow(EventShowSnackbar::class).collect { event -> hostState.showSnackbar( BusSnackbarVisuals(message = event.message, type = event.type) ) @@ -87,7 +87,7 @@ fun GlobalSnackbarHost( dismissAction = { TextButton(onClick = { hostState.currentSnackbarData?.dismiss() }) { Text( - text = stringResource(R.string.dismiss), + text = stringResource(UiStrings.dismiss), color = contentColor ) } diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/dialogs/OkCancelDialog.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/dialogs/OkCancelDialog.kt similarity index 92% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/dialogs/OkCancelDialog.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/dialogs/OkCancelDialog.kt index bf6129c844a7..0a4232360c37 100644 --- a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/dialogs/OkCancelDialog.kt +++ b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/dialogs/OkCancelDialog.kt @@ -15,13 +15,13 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector -import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.AnnotatedString -import androidx.compose.ui.text.fromHtml import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.window.DialogProperties -import app.aaps.core.ui.R +import app.aaps.core.ui.UiStrings +import app.aaps.core.ui.compose.htmlToAnnotatedString +import app.aaps.core.ui.compose.stringResource /** * A confirmation dialog with OK and Cancel buttons. @@ -73,7 +73,7 @@ fun OkCancelDialog( horizontalAlignment = Alignment.CenterHorizontally ) { Text( - text = AnnotatedString.fromHtml(message.replace("\n", "
")), + text = message.htmlToAnnotatedString(), modifier = Modifier.fillMaxWidth(), textAlign = TextAlign.Center ) @@ -91,12 +91,12 @@ fun OkCancelDialog( }, confirmButton = { TextButton(onClick = onConfirm) { - Text(stringResource(R.string.ok)) + Text(stringResource(UiStrings.ok)) } }, dismissButton = { TextButton(onClick = onDismiss) { - Text(stringResource(R.string.cancel)) + Text(stringResource(UiStrings.cancel)) } }, properties = DialogProperties(dismissOnBackPress = true, dismissOnClickOutside = false) @@ -161,12 +161,12 @@ fun OkCancelDialog( }, confirmButton = { TextButton(onClick = onConfirm) { - Text(stringResource(R.string.ok)) + Text(stringResource(UiStrings.ok)) } }, dismissButton = { TextButton(onClick = onDismiss) { - Text(stringResource(R.string.cancel)) + Text(stringResource(UiStrings.cancel)) } }, properties = DialogProperties(dismissOnBackPress = true, dismissOnClickOutside = false) diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/dialogs/OkCancelDialogPreviews.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/dialogs/OkCancelDialogPreviews.kt similarity index 100% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/dialogs/OkCancelDialogPreviews.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/dialogs/OkCancelDialogPreviews.kt diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/dialogs/OkDialog.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/dialogs/OkDialog.kt similarity index 88% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/dialogs/OkDialog.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/dialogs/OkDialog.kt index c7bda532aca0..85ac7cc2394d 100644 --- a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/dialogs/OkDialog.kt +++ b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/dialogs/OkDialog.kt @@ -8,12 +8,12 @@ import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.vector.ImageVector -import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.AnnotatedString -import androidx.compose.ui.text.fromHtml import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.window.DialogProperties -import app.aaps.core.ui.R +import app.aaps.core.ui.UiStrings +import app.aaps.core.ui.compose.htmlToAnnotatedString +import app.aaps.core.ui.compose.stringResource /** * A simple alert dialog with a title, message, and OK button. @@ -43,11 +43,11 @@ fun OkDialog( ) }, text = { - Text(text = AnnotatedString.fromHtml(message.replace("\n", "
"))) + Text(text = message.htmlToAnnotatedString()) }, confirmButton = { TextButton(onClick = onDismiss) { - Text(stringResource(R.string.ok)) + Text(stringResource(UiStrings.ok)) } }, properties = DialogProperties(dismissOnBackPress = true, dismissOnClickOutside = true) @@ -79,7 +79,7 @@ fun OkDialog( }, confirmButton = { TextButton(onClick = onDismiss) { - Text(stringResource(R.string.ok)) + Text(stringResource(UiStrings.ok)) } }, properties = DialogProperties(dismissOnBackPress = true, dismissOnClickOutside = true) diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/dialogs/OkDialogPreviews.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/dialogs/OkDialogPreviews.kt similarity index 100% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/dialogs/OkDialogPreviews.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/dialogs/OkDialogPreviews.kt diff --git a/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/dialogs/PasswordCheckHost.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/dialogs/PasswordCheckHost.kt new file mode 100644 index 000000000000..75ba959289ba --- /dev/null +++ b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/dialogs/PasswordCheckHost.kt @@ -0,0 +1,53 @@ +package app.aaps.core.ui.compose.dialogs + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import app.aaps.core.interfaces.protection.PasswordCheck +import app.aaps.core.interfaces.protection.PasswordRequest +import app.aaps.core.ui.compose.stringResource + +/** + * Draws whatever [passwordCheck] is currently asking for. + * + * Place once, near the root of the UI, inside the app theme. Nothing else needs to know a prompt is + * happening: a caller anywhere asks `passwordCheck.queryPassword(...)` and the dialog appears here. + * + * This replaces an `android.app.Dialog` wrapped around a `ComposeView` with three `setViewTree*Owner` + * calls and a hand written lifecycle owner - all of which existed only to run Compose from + * non-Compose code. Rendering in the composition that is already running needs none of it, and is + * the reason [PasswordCheck] no longer takes a `Context`. + */ +@Composable +fun PasswordCheckHost(passwordCheck: PasswordCheck) { + val request by passwordCheck.request.collectAsStateWithLifecycle() + + when (val current = request) { + null -> Unit + + is PasswordRequest.Query -> + QueryPasswordDialog( + title = stringResource(current.label), + pinInput = current.pinInput, + onConfirm = current.onConfirm, + onCancel = current.onCancel + ) + + is PasswordRequest.Set -> + SetPasswordDialog( + title = stringResource(current.label), + pinInput = current.pinInput, + onConfirm = current.onConfirm, + onCancel = current.onCancel + ) + + is PasswordRequest.QueryAny -> + QueryAnyPasswordDialog( + title = stringResource(current.label), + passwordExplanation = current.explanation?.let { stringResource(it) }, + passwordWarning = current.warning?.let { stringResource(it) }, + onConfirm = current.onConfirm, + onCancel = current.onCancel + ) + } +} diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/dialogs/QueryAnyPasswordDialog.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/dialogs/QueryAnyPasswordDialog.kt similarity index 95% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/dialogs/QueryAnyPasswordDialog.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/dialogs/QueryAnyPasswordDialog.kt index e5ced5088e39..b425536eec89 100644 --- a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/dialogs/QueryAnyPasswordDialog.kt +++ b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/dialogs/QueryAnyPasswordDialog.kt @@ -25,14 +25,14 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.platform.LocalSoftwareKeyboardController -import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.window.DialogProperties -import app.aaps.core.ui.R +import app.aaps.core.ui.UiStrings +import app.aaps.core.ui.compose.stringResource /** * Dialog for querying a free-form password with optional explanation and warning messages. @@ -105,7 +105,7 @@ fun QueryAnyPasswordDialog( OutlinedTextField( value = passwordText, onValueChange = { passwordText = it }, - label = { Text(stringResource(R.string.protection_password_hint)) }, + label = { Text(stringResource(UiStrings.protection_password_hint)) }, isError = errorMessage != null, supportingText = errorMessage?.let { msg -> { Text(msg, color = MaterialTheme.colorScheme.error) } }, visualTransformation = PasswordVisualTransformation(), @@ -136,12 +136,12 @@ fun QueryAnyPasswordDialog( onConfirm(passwordText) } ) { - Text(stringResource(R.string.ok)) + Text(stringResource(UiStrings.ok)) } }, dismissButton = { TextButton(onClick = onCancel) { - Text(stringResource(R.string.cancel)) + Text(stringResource(UiStrings.cancel)) } }, properties = DialogProperties(dismissOnBackPress = false, dismissOnClickOutside = false) diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/dialogs/QueryAnyPasswordDialogPreviews.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/dialogs/QueryAnyPasswordDialogPreviews.kt similarity index 100% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/dialogs/QueryAnyPasswordDialogPreviews.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/dialogs/QueryAnyPasswordDialogPreviews.kt diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/dialogs/QueryPasswordDialog.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/dialogs/QueryPasswordDialog.kt similarity index 92% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/dialogs/QueryPasswordDialog.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/dialogs/QueryPasswordDialog.kt index 97a283f4e607..84b4d2bc072e 100644 --- a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/dialogs/QueryPasswordDialog.kt +++ b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/dialogs/QueryPasswordDialog.kt @@ -20,13 +20,13 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.platform.LocalSoftwareKeyboardController -import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.window.DialogProperties -import app.aaps.core.ui.R +import app.aaps.core.ui.UiStrings +import app.aaps.core.ui.compose.stringResource /** * Dialog for querying an existing password or PIN. @@ -73,7 +73,7 @@ fun QueryPasswordDialog( value = passwordText, onValueChange = { passwordText = it }, label = { - Text(stringResource(if (pinInput) R.string.protection_pin_hint else R.string.protection_password_hint)) + Text(stringResource(if (pinInput) UiStrings.protection_pin_hint else UiStrings.protection_password_hint)) }, visualTransformation = PasswordVisualTransformation(), keyboardOptions = KeyboardOptions( @@ -102,12 +102,12 @@ fun QueryPasswordDialog( onConfirm(passwordText) } ) { - Text(stringResource(R.string.ok)) + Text(stringResource(UiStrings.ok)) } }, dismissButton = { TextButton(onClick = onCancel) { - Text(stringResource(R.string.cancel)) + Text(stringResource(UiStrings.cancel)) } }, properties = DialogProperties(dismissOnBackPress = false, dismissOnClickOutside = false) diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/dialogs/QueryPasswordDialogPreviews.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/dialogs/QueryPasswordDialogPreviews.kt similarity index 100% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/dialogs/QueryPasswordDialogPreviews.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/dialogs/QueryPasswordDialogPreviews.kt diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/dialogs/SetPasswordDialog.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/dialogs/SetPasswordDialog.kt similarity index 90% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/dialogs/SetPasswordDialog.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/dialogs/SetPasswordDialog.kt index f536952172a6..a6724c4a64c2 100644 --- a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/dialogs/SetPasswordDialog.kt +++ b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/dialogs/SetPasswordDialog.kt @@ -21,14 +21,14 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester -import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.window.DialogProperties -import app.aaps.core.ui.R +import app.aaps.core.ui.UiStrings +import app.aaps.core.ui.compose.stringResource /** * Dialog for setting a new password or PIN. @@ -80,7 +80,7 @@ fun SetPasswordDialog( value = password1, onValueChange = { password1 = it }, label = { - Text(stringResource(if (pinInput) R.string.protection_pin_hint else R.string.protection_password_hint)) + Text(stringResource(if (pinInput) UiStrings.protection_pin_hint else UiStrings.protection_password_hint)) }, visualTransformation = PasswordVisualTransformation(), keyboardOptions = KeyboardOptions( @@ -97,7 +97,7 @@ fun SetPasswordDialog( value = password2, onValueChange = { password2 = it }, label = { - Text(stringResource(if (pinInput) R.string.confirm_pin_hint else R.string.confirm_password_hint)) + Text(stringResource(if (pinInput) UiStrings.confirm_pin_hint else UiStrings.confirm_password_hint)) }, visualTransformation = PasswordVisualTransformation(), keyboardOptions = KeyboardOptions( @@ -114,12 +114,12 @@ fun SetPasswordDialog( }, confirmButton = { TextButton(onClick = { onConfirm(password1, password2) }) { - Text(stringResource(R.string.ok)) + Text(stringResource(UiStrings.ok)) } }, dismissButton = { TextButton(onClick = onCancel) { - Text(stringResource(R.string.cancel)) + Text(stringResource(UiStrings.cancel)) } }, properties = DialogProperties(dismissOnBackPress = false, dismissOnClickOutside = false) diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/dialogs/SetPasswordDialogPreviews.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/dialogs/SetPasswordDialogPreviews.kt similarity index 100% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/dialogs/SetPasswordDialogPreviews.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/dialogs/SetPasswordDialogPreviews.kt diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/dialogs/ThreeButtonDialog.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/dialogs/ThreeButtonDialog.kt similarity index 93% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/dialogs/ThreeButtonDialog.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/dialogs/ThreeButtonDialog.kt index c10478902545..ea686e1301b8 100644 --- a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/dialogs/ThreeButtonDialog.kt +++ b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/dialogs/ThreeButtonDialog.kt @@ -18,13 +18,12 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.AnnotatedString -import androidx.compose.ui.text.fromHtml import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.window.DialogProperties -import app.aaps.core.ui.R +import app.aaps.core.ui.UiStrings +import app.aaps.core.ui.compose.htmlToAnnotatedString +import app.aaps.core.ui.compose.stringResource /** * A confirmation dialog with three stacked, full-width actions: primary, secondary, cancel. @@ -47,7 +46,7 @@ import app.aaps.core.ui.R * @param onPrimary Called when primary button is clicked * @param secondaryLabel Label for the alternative action button (e.g., "End") * @param onSecondary Called when secondary button is clicked - * @param cancelLabel Optional override for cancel label; defaults to R.string.cancel + * @param cancelLabel Optional override for cancel label; defaults to UiStrings.cancel * @param onDismiss Called when cancel is clicked or dialog is dismissed * * @see ThreeButtonDialogPreview @@ -66,7 +65,7 @@ fun ThreeButtonDialog( cancelLabel: String? = null, onDismiss: () -> Unit ) { - val resolvedCancel = cancelLabel ?: stringResource(R.string.cancel) + val resolvedCancel = cancelLabel ?: stringResource(UiStrings.cancel) AlertDialog( onDismissRequest = onDismiss, icon = icon?.let { @@ -94,7 +93,7 @@ fun ThreeButtonDialog( horizontalAlignment = Alignment.CenterHorizontally ) { Text( - text = AnnotatedString.fromHtml(message.replace("\n", "
")), + text = message.htmlToAnnotatedString(), modifier = Modifier.fillMaxWidth(), textAlign = TextAlign.Center ) diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/dialogs/ThreeButtonDialogPreviews.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/dialogs/ThreeButtonDialogPreviews.kt similarity index 100% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/dialogs/ThreeButtonDialogPreviews.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/dialogs/ThreeButtonDialogPreviews.kt diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/dialogs/TimePickerModal.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/dialogs/TimePickerModal.kt similarity index 89% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/dialogs/TimePickerModal.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/dialogs/TimePickerModal.kt index 48814c20928e..9704d596f4c7 100644 --- a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/dialogs/TimePickerModal.kt +++ b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/dialogs/TimePickerModal.kt @@ -7,9 +7,9 @@ import androidx.compose.material3.TextButton import androidx.compose.material3.TimePicker import androidx.compose.material3.rememberTimePickerState import androidx.compose.runtime.Composable -import androidx.compose.ui.res.stringResource import androidx.compose.ui.window.DialogProperties -import app.aaps.core.ui.R +import app.aaps.core.ui.UiStrings +import app.aaps.core.ui.compose.stringResource /** * A modal time picker dialog. @@ -45,12 +45,12 @@ fun TimePickerModal( onTimeSelected(timePickerState.hour, timePickerState.minute) onDismiss() }) { - Text(stringResource(R.string.ok)) + Text(stringResource(UiStrings.ok)) } }, dismissButton = { TextButton(onClick = onDismiss) { - Text(stringResource(R.string.cancel)) + Text(stringResource(UiStrings.cancel)) } }, properties = DialogProperties(dismissOnBackPress = true, dismissOnClickOutside = true) diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/dialogs/TimePickerModalPreviews.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/dialogs/TimePickerModalPreviews.kt similarity index 100% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/dialogs/TimePickerModalPreviews.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/dialogs/TimePickerModalPreviews.kt diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/dialogs/UnifiedAuthDialog.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/dialogs/UnifiedAuthDialog.kt similarity index 92% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/dialogs/UnifiedAuthDialog.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/dialogs/UnifiedAuthDialog.kt index 2c225ccf5899..5586e6ee0d5c 100644 --- a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/dialogs/UnifiedAuthDialog.kt +++ b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/dialogs/UnifiedAuthDialog.kt @@ -20,7 +20,6 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.platform.LocalSoftwareKeyboardController -import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.PasswordVisualTransformation @@ -31,7 +30,8 @@ import app.aaps.core.interfaces.protection.AuthorizationResult import app.aaps.core.interfaces.protection.ProtectionCheck import app.aaps.core.interfaces.protection.ProtectionResult import app.aaps.core.interfaces.protection.ProtectionType -import app.aaps.core.ui.R +import app.aaps.core.ui.UiStrings +import app.aaps.core.ui.compose.stringResource /** * Unified authentication dialog that accepts a single credential input and tries it @@ -57,9 +57,9 @@ fun UnifiedAuthDialog( // Pick hint: master password is always accepted, plus any custom credentials val hintRes = when { - hasPin -> R.string.auth_hint_master_or_pin - hasCustomPassword -> R.string.auth_hint_master_or_password - else -> R.string.auth_hint_master_password + hasPin -> UiStrings.auth_hint_master_or_pin + hasCustomPassword -> UiStrings.auth_hint_master_or_password + else -> UiStrings.auth_hint_master_password } var passwordText by remember { mutableStateOf("") } @@ -95,7 +95,7 @@ fun UnifiedAuthDialog( }, title = { Text( - text = stringResource(R.string.biometric_title), + text = stringResource(UiStrings.biometric_title), modifier = Modifier.fillMaxWidth(), textAlign = TextAlign.Center ) @@ -134,12 +134,12 @@ fun UnifiedAuthDialog( tryAuthenticate(passwordText) } ) { - Text(stringResource(R.string.ok)) + Text(stringResource(UiStrings.ok)) } }, dismissButton = { TextButton(onClick = { onResult(AuthorizationResult(null, ProtectionResult.CANCELLED)) }) { - Text(stringResource(R.string.cancel)) + Text(stringResource(UiStrings.cancel)) } }, properties = DialogProperties(dismissOnBackPress = false, dismissOnClickOutside = false) diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/dialogs/ValueInputDialog.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/dialogs/ValueInputDialog.kt similarity index 89% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/dialogs/ValueInputDialog.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/dialogs/ValueInputDialog.kt index ce8a10947091..247ccead11d7 100644 --- a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/dialogs/ValueInputDialog.kt +++ b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/dialogs/ValueInputDialog.kt @@ -19,18 +19,17 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester -import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.TextRange import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.unit.dp -import app.aaps.core.ui.R - import app.aaps.core.data.format.NumberFormat +import app.aaps.core.keys.interfaces.TextRef +import app.aaps.core.ui.UiStrings import app.aaps.core.ui.compose.formatMinutesAsDuration +import app.aaps.core.ui.compose.stringResource import kotlin.math.roundToInt -import app.aaps.core.keys.R as KeysR /** * Dialog for entering a numeric value directly. @@ -41,7 +40,7 @@ import app.aaps.core.keys.R as KeysR * @param label Optional label for the input field * @param summary Optional summary/description text to show below the label * @param unitLabel Optional unit label to show after value - * @param unitLabelResId Resource ID for unit label. When R.string.units_min, shows formatted preview + * @param asDuration Show a "= Xh Ym" preview under the field * @param valueFormat Format for displaying/parsing the value * @param onValueConfirm Called when user confirms with a valid value * @param onDismiss Called when dialog is dismissed @@ -55,8 +54,8 @@ fun ValueInputDialog( step: Double = 0.1, label: String? = null, summary: String? = null, - unitLabel: String = "", - unitLabelResId: Int = 0, + unitLabel: TextRef? = null, + asDuration: Boolean = false, valueFormat: NumberFormat = NumberFormat.DECIMAL_1, onValueConfirm: (Double) -> Unit, onDismiss: () -> Unit @@ -68,34 +67,32 @@ fun ValueInputDialog( } var isError by remember { mutableStateOf(false) } var errorMessage by remember { mutableStateOf("") } - - // Check if this is minutes input for formatted preview - val isMinutesUnit = unitLabelResId == KeysR.string.units_min + val resolvedUnitLabel = unitLabel?.let { stringResource(it) } ?: "" fun validateAndParse(): Double? { val text = textFieldValue.text.replace(",", ".") return try { val parsed = text.toDouble() when { - parsed < valueRange.start -> { + parsed < valueRange.start -> { isError = true errorMessage = "Min: ${valueFormat.format(valueRange.start)}" null } - parsed > valueRange.endInclusive -> { + parsed > valueRange.endInclusive -> { isError = true errorMessage = "Max: ${valueFormat.format(valueRange.endInclusive)}" null } - isMinutesUnit && parsed != parsed.roundToInt().toDouble() -> { + asDuration && parsed != parsed.roundToInt().toDouble() -> { isError = true errorMessage = "Minutes must be whole numbers" null } - else -> { + else -> { isError = false // Accept value as-is (no rounding to step) parsed @@ -116,7 +113,7 @@ fun ValueInputDialog( } // Compute formatted preview for minutes - val formattedPreview: String? = if (isMinutesUnit) { + val formattedPreview: String? = if (asDuration) { val minutes = textFieldValue.text.replace(",", ".").toDoubleOrNull()?.roundToInt() if (minutes != null && minutes >= 60) { "= ${formatMinutesAsDuration(minutes)}" @@ -162,8 +159,8 @@ fun ValueInputDialog( else -> null }, - suffix = if (unitLabel.isNotEmpty()) { - { Text(unitLabel) } + suffix = if (resolvedUnitLabel.isNotEmpty()) { + { Text(resolvedUnitLabel) } } else null, keyboardOptions = KeyboardOptions( keyboardType = KeyboardType.Decimal, @@ -187,12 +184,12 @@ fun ValueInputDialog( }, confirmButton = { TextButton(onClick = { confirm() }) { - Text(stringResource(R.string.ok)) + Text(stringResource(UiStrings.ok)) } }, dismissButton = { TextButton(onClick = onDismiss) { - Text(stringResource(R.string.cancel)) + Text(stringResource(UiStrings.cancel)) } } ) diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/dialogs/ValueInputDialogPreviews.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/dialogs/ValueInputDialogPreviews.kt similarity index 85% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/dialogs/ValueInputDialogPreviews.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/dialogs/ValueInputDialogPreviews.kt index 7d16eb37fc0b..f81a33a7fcf2 100644 --- a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/dialogs/ValueInputDialogPreviews.kt +++ b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/dialogs/ValueInputDialogPreviews.kt @@ -3,6 +3,7 @@ package app.aaps.core.ui.compose.dialogs import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.ui.tooling.preview.Preview +import app.aaps.core.keys.interfaces.TextRef @Preview(showBackground = true) @Composable @@ -13,7 +14,7 @@ internal fun ValueInputDialogPreview() { valueRange = 0.0..10.0, step = 0.5, label = "Insulin", - unitLabel = "U", + unitLabel = TextRef.Literal("U"), onValueConfirm = {}, onDismiss = {} ) diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/dialogs/YesNoCancelDialog.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/dialogs/YesNoCancelDialog.kt similarity index 83% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/dialogs/YesNoCancelDialog.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/dialogs/YesNoCancelDialog.kt index c7e1949b3a41..1a5d957c7a5e 100644 --- a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/dialogs/YesNoCancelDialog.kt +++ b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/dialogs/YesNoCancelDialog.kt @@ -7,12 +7,12 @@ import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier -import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.AnnotatedString -import androidx.compose.ui.text.fromHtml import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.window.DialogProperties -import app.aaps.core.ui.R +import app.aaps.core.ui.UiStrings +import app.aaps.core.ui.compose.htmlToAnnotatedString +import app.aaps.core.ui.compose.stringResource /** * A dialog with Yes, No, and Cancel buttons. @@ -43,20 +43,20 @@ fun YesNoCancelDialog( ) }, text = { - Text(text = AnnotatedString.fromHtml(message.replace("\n", "
"))) + Text(text = message.htmlToAnnotatedString()) }, confirmButton = { TextButton(onClick = onYes) { - Text(stringResource(R.string.yes)) + Text(stringResource(UiStrings.yes)) } }, dismissButton = { Row { TextButton(onClick = onCancel) { - Text(stringResource(R.string.cancel)) + Text(stringResource(UiStrings.cancel)) } TextButton(onClick = onNo) { - Text(stringResource(R.string.no)) + Text(stringResource(UiStrings.no)) } } }, @@ -89,16 +89,16 @@ fun YesNoCancelDialog( }, confirmButton = { TextButton(onClick = onYes) { - Text(stringResource(R.string.yes)) + Text(stringResource(UiStrings.yes)) } }, dismissButton = { Row { TextButton(onClick = onCancel) { - Text(stringResource(R.string.cancel)) + Text(stringResource(UiStrings.cancel)) } TextButton(onClick = onNo) { - Text(stringResource(R.string.no)) + Text(stringResource(UiStrings.no)) } } }, diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/dialogs/YesNoCancelDialogPreviews.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/dialogs/YesNoCancelDialogPreviews.kt similarity index 100% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/dialogs/YesNoCancelDialogPreviews.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/dialogs/YesNoCancelDialogPreviews.kt diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/Careportal.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/Careportal.kt similarity index 100% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/Careportal.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/Careportal.kt diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/CareportalPreviews.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/CareportalPreviews.kt similarity index 100% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/CareportalPreviews.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/CareportalPreviews.kt diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcAaps.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcAaps.kt similarity index 100% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcAaps.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcAaps.kt diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcAapsPreviews.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcAapsPreviews.kt similarity index 100% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcAapsPreviews.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcAapsPreviews.kt diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcAction.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcAction.kt similarity index 100% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcAction.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcAction.kt diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcActionPreviews.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcActionPreviews.kt similarity index 100% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcActionPreviews.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcActionPreviews.kt diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcActivity.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcActivity.kt similarity index 100% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcActivity.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcActivity.kt diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcActivityPreviews.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcActivityPreviews.kt similarity index 100% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcActivityPreviews.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcActivityPreviews.kt diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcAnnouncement.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcAnnouncement.kt similarity index 100% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcAnnouncement.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcAnnouncement.kt diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcAnnouncementPreviews.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcAnnouncementPreviews.kt similarity index 100% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcAnnouncementPreviews.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcAnnouncementPreviews.kt diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcArrowDoubleDown.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcArrowDoubleDown.kt similarity index 100% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcArrowDoubleDown.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcArrowDoubleDown.kt diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcArrowDoubleDownPreviews.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcArrowDoubleDownPreviews.kt similarity index 100% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcArrowDoubleDownPreviews.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcArrowDoubleDownPreviews.kt diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcArrowDoubleUp.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcArrowDoubleUp.kt similarity index 100% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcArrowDoubleUp.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcArrowDoubleUp.kt diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcArrowDoubleUpPreviews.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcArrowDoubleUpPreviews.kt similarity index 100% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcArrowDoubleUpPreviews.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcArrowDoubleUpPreviews.kt diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcArrowFlat.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcArrowFlat.kt similarity index 100% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcArrowFlat.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcArrowFlat.kt diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcArrowFlatPreviews.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcArrowFlatPreviews.kt similarity index 100% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcArrowFlatPreviews.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcArrowFlatPreviews.kt diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcArrowFortyFiveDown.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcArrowFortyFiveDown.kt similarity index 100% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcArrowFortyFiveDown.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcArrowFortyFiveDown.kt diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcArrowFortyFiveDownPreviews.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcArrowFortyFiveDownPreviews.kt similarity index 100% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcArrowFortyFiveDownPreviews.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcArrowFortyFiveDownPreviews.kt diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcArrowFortyFiveUp.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcArrowFortyFiveUp.kt similarity index 100% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcArrowFortyFiveUp.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcArrowFortyFiveUp.kt diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcArrowFortyFiveUpPreviews.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcArrowFortyFiveUpPreviews.kt similarity index 100% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcArrowFortyFiveUpPreviews.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcArrowFortyFiveUpPreviews.kt diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcArrowInvalid.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcArrowInvalid.kt similarity index 100% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcArrowInvalid.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcArrowInvalid.kt diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcArrowInvalidPreviews.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcArrowInvalidPreviews.kt similarity index 100% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcArrowInvalidPreviews.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcArrowInvalidPreviews.kt diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcArrowLeft.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcArrowLeft.kt similarity index 100% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcArrowLeft.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcArrowLeft.kt diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcArrowLeftDown.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcArrowLeftDown.kt similarity index 100% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcArrowLeftDown.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcArrowLeftDown.kt diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcArrowLeftDownPreviews.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcArrowLeftDownPreviews.kt similarity index 100% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcArrowLeftDownPreviews.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcArrowLeftDownPreviews.kt diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcArrowLeftPreviews.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcArrowLeftPreviews.kt similarity index 100% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcArrowLeftPreviews.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcArrowLeftPreviews.kt diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcArrowLeftUp.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcArrowLeftUp.kt similarity index 100% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcArrowLeftUp.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcArrowLeftUp.kt diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcArrowLeftUpPreviews.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcArrowLeftUpPreviews.kt similarity index 100% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcArrowLeftUpPreviews.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcArrowLeftUpPreviews.kt diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcArrowNone.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcArrowNone.kt similarity index 100% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcArrowNone.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcArrowNone.kt diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcArrowNonePreviews.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcArrowNonePreviews.kt similarity index 100% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcArrowNonePreviews.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcArrowNonePreviews.kt diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcArrowSimpleDown.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcArrowSimpleDown.kt similarity index 100% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcArrowSimpleDown.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcArrowSimpleDown.kt diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcArrowSimpleDownPreviews.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcArrowSimpleDownPreviews.kt similarity index 100% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcArrowSimpleDownPreviews.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcArrowSimpleDownPreviews.kt diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcArrowSimpleUp.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcArrowSimpleUp.kt similarity index 100% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcArrowSimpleUp.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcArrowSimpleUp.kt diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcArrowSimpleUpPreviews.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcArrowSimpleUpPreviews.kt similarity index 100% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcArrowSimpleUpPreviews.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcArrowSimpleUpPreviews.kt diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcAs.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcAs.kt similarity index 100% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcAs.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcAs.kt diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcAsAbove.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcAsAbove.kt similarity index 100% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcAsAbove.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcAsAbove.kt diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcAsAbovePreviews.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcAsAbovePreviews.kt similarity index 100% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcAsAbovePreviews.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcAsAbovePreviews.kt diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcAsAboveX.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcAsAboveX.kt similarity index 100% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcAsAboveX.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcAsAboveX.kt diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcAsAboveXPreviews.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcAsAboveXPreviews.kt similarity index 100% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcAsAboveXPreviews.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcAsAboveXPreviews.kt diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcAsBelow.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcAsBelow.kt similarity index 100% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcAsBelow.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcAsBelow.kt diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcAsBelowPreviews.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcAsBelowPreviews.kt similarity index 100% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcAsBelowPreviews.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcAsBelowPreviews.kt diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcAsBelowX.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcAsBelowX.kt similarity index 100% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcAsBelowX.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcAsBelowX.kt diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcAsBelowXPreviews.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcAsBelowXPreviews.kt similarity index 100% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcAsBelowXPreviews.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcAsBelowXPreviews.kt diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcAsPreviews.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcAsPreviews.kt similarity index 100% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcAsPreviews.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcAsPreviews.kt diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcAsX.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcAsX.kt similarity index 100% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcAsX.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcAsX.kt diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcAsXPreviews.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcAsXPreviews.kt similarity index 100% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcAsXPreviews.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcAsXPreviews.kt diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcAutomation.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcAutomation.kt similarity index 100% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcAutomation.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcAutomation.kt diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcAutomationPreviews.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcAutomationPreviews.kt similarity index 100% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcAutomationPreviews.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcAutomationPreviews.kt diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcBgCheck.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcBgCheck.kt similarity index 100% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcBgCheck.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcBgCheck.kt diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcBgCheckPreviews.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcBgCheckPreviews.kt similarity index 100% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcBgCheckPreviews.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcBgCheckPreviews.kt diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcBolus.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcBolus.kt similarity index 100% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcBolus.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcBolus.kt diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcBolusPreviews.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcBolusPreviews.kt similarity index 100% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcBolusPreviews.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcBolusPreviews.kt diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcBread.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcBread.kt similarity index 100% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcBread.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcBread.kt diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcBreadPreviews.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcBreadPreviews.kt similarity index 100% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcBreadPreviews.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcBreadPreviews.kt diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcByoda.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcByoda.kt similarity index 100% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcByoda.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcByoda.kt diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcByodaPreviews.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcByodaPreviews.kt similarity index 100% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcByodaPreviews.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcByodaPreviews.kt diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcCake.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcCake.kt similarity index 100% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcCake.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcCake.kt diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcCakePreviews.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcCakePreviews.kt similarity index 100% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcCakePreviews.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcCakePreviews.kt diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcCalculator.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcCalculator.kt similarity index 100% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcCalculator.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcCalculator.kt diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcCalculatorPreviews.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcCalculatorPreviews.kt similarity index 100% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcCalculatorPreviews.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcCalculatorPreviews.kt diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcCalibration.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcCalibration.kt similarity index 100% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcCalibration.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcCalibration.kt diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcCalibrationPreviews.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcCalibrationPreviews.kt similarity index 100% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcCalibrationPreviews.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcCalibrationPreviews.kt diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcCancelExtendedBolus.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcCancelExtendedBolus.kt similarity index 100% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcCancelExtendedBolus.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcCancelExtendedBolus.kt diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcCancelExtendedBolusPreviews.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcCancelExtendedBolusPreviews.kt similarity index 100% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcCancelExtendedBolusPreviews.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcCancelExtendedBolusPreviews.kt diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcCannulaChange.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcCannulaChange.kt similarity index 100% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcCannulaChange.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcCannulaChange.kt diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcCannulaChangePreviews.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcCannulaChangePreviews.kt similarity index 100% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcCannulaChangePreviews.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcCannulaChangePreviews.kt diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcCarbs.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcCarbs.kt similarity index 100% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcCarbs.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcCarbs.kt diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcCarbsPreviews.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcCarbsPreviews.kt similarity index 100% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcCarbsPreviews.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcCarbsPreviews.kt diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcCgmInsert.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcCgmInsert.kt similarity index 100% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcCgmInsert.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcCgmInsert.kt diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcCgmInsertPreviews.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcCgmInsertPreviews.kt similarity index 100% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcCgmInsertPreviews.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcCgmInsertPreviews.kt diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcClinicalNotes.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcClinicalNotes.kt similarity index 100% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcClinicalNotes.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcClinicalNotes.kt diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcClinicalNotesPreviews.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcClinicalNotesPreviews.kt similarity index 100% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcClinicalNotesPreviews.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcClinicalNotesPreviews.kt diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcCompareProfiles.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcCompareProfiles.kt similarity index 100% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcCompareProfiles.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcCompareProfiles.kt diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcCompareProfilesPreviews.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcCompareProfilesPreviews.kt similarity index 100% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcCompareProfilesPreviews.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcCompareProfilesPreviews.kt diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcDelta.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcDelta.kt similarity index 100% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcDelta.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcDelta.kt diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcDeltaPreviews.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcDeltaPreviews.kt similarity index 100% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcDeltaPreviews.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcDeltaPreviews.kt diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcDiaconn.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcDiaconn.kt similarity index 100% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcDiaconn.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcDiaconn.kt diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcDiaconnPreviews.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcDiaconnPreviews.kt similarity index 100% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcDiaconnPreviews.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcDiaconnPreviews.kt diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcExtendedBolus.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcExtendedBolus.kt similarity index 100% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcExtendedBolus.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcExtendedBolus.kt diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcExtendedBolusPreviews.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcExtendedBolusPreviews.kt similarity index 100% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcExtendedBolusPreviews.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcExtendedBolusPreviews.kt diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcGenericCgm.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcGenericCgm.kt similarity index 100% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcGenericCgm.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcGenericCgm.kt diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcGenericCgmPreviews.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcGenericCgmPreviews.kt similarity index 100% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcGenericCgmPreviews.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcGenericCgmPreviews.kt diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcGenericIcon.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcGenericIcon.kt similarity index 100% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcGenericIcon.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcGenericIcon.kt diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcGenericIconPreviews.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcGenericIconPreviews.kt similarity index 100% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcGenericIconPreviews.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcGenericIconPreviews.kt diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcGoogleDrive.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcGoogleDrive.kt similarity index 100% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcGoogleDrive.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcGoogleDrive.kt diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcGoogleDrivePreviews.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcGoogleDrivePreviews.kt similarity index 100% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcGoogleDrivePreviews.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcGoogleDrivePreviews.kt diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcHistory.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcHistory.kt similarity index 100% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcHistory.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcHistory.kt diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcHistoryPreviews.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcHistoryPreviews.kt similarity index 100% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcHistoryPreviews.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcHistoryPreviews.kt diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcLoopClosed.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcLoopClosed.kt similarity index 100% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcLoopClosed.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcLoopClosed.kt diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcLoopClosedPreviews.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcLoopClosedPreviews.kt similarity index 100% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcLoopClosedPreviews.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcLoopClosedPreviews.kt diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcLoopDisabled.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcLoopDisabled.kt similarity index 100% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcLoopDisabled.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcLoopDisabled.kt diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcLoopDisabledPreviews.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcLoopDisabledPreviews.kt similarity index 100% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcLoopDisabledPreviews.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcLoopDisabledPreviews.kt diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcLoopDisconnected.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcLoopDisconnected.kt similarity index 100% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcLoopDisconnected.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcLoopDisconnected.kt diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcLoopDisconnectedPreviews.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcLoopDisconnectedPreviews.kt similarity index 100% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcLoopDisconnectedPreviews.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcLoopDisconnectedPreviews.kt diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcLoopHidden.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcLoopHidden.kt similarity index 100% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcLoopHidden.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcLoopHidden.kt diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcLoopHiddenPreviews.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcLoopHiddenPreviews.kt similarity index 100% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcLoopHiddenPreviews.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcLoopHiddenPreviews.kt diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcLoopLgs.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcLoopLgs.kt similarity index 100% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcLoopLgs.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcLoopLgs.kt diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcLoopLgsPreviews.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcLoopLgsPreviews.kt similarity index 100% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcLoopLgsPreviews.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcLoopLgsPreviews.kt diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcLoopOpen.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcLoopOpen.kt similarity index 100% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcLoopOpen.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcLoopOpen.kt diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcLoopOpenPreviews.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcLoopOpenPreviews.kt similarity index 100% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcLoopOpenPreviews.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcLoopOpenPreviews.kt diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcLoopPaused.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcLoopPaused.kt similarity index 100% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcLoopPaused.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcLoopPaused.kt diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcLoopPausedDst.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcLoopPausedDst.kt similarity index 100% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcLoopPausedDst.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcLoopPausedDst.kt diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcLoopPausedDstPreviews.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcLoopPausedDstPreviews.kt similarity index 100% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcLoopPausedDstPreviews.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcLoopPausedDstPreviews.kt diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcLoopPausedPreviews.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcLoopPausedPreviews.kt similarity index 100% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcLoopPausedPreviews.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcLoopPausedPreviews.kt diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcLoopPausedPump.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcLoopPausedPump.kt similarity index 100% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcLoopPausedPump.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcLoopPausedPump.kt diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcLoopPausedPumpPreviews.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcLoopPausedPumpPreviews.kt similarity index 100% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcLoopPausedPumpPreviews.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcLoopPausedPumpPreviews.kt diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcLoopReconnect.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcLoopReconnect.kt similarity index 100% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcLoopReconnect.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcLoopReconnect.kt diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcLoopReconnectPreviews.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcLoopReconnectPreviews.kt similarity index 100% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcLoopReconnectPreviews.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcLoopReconnectPreviews.kt diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcLoopSuperBolus.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcLoopSuperBolus.kt similarity index 100% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcLoopSuperBolus.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcLoopSuperBolus.kt diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcLoopSuperBolusPreviews.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcLoopSuperBolusPreviews.kt similarity index 100% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcLoopSuperBolusPreviews.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcLoopSuperBolusPreviews.kt diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcMdi.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcMdi.kt similarity index 100% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcMdi.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcMdi.kt diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcMdiPreviews.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcMdiPreviews.kt similarity index 100% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcMdiPreviews.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcMdiPreviews.kt diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcNoTbr.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcNoTbr.kt similarity index 100% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcNoTbr.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcNoTbr.kt diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcNoTbrPreviews.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcNoTbrPreviews.kt similarity index 100% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcNoTbrPreviews.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcNoTbrPreviews.kt diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcNote.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcNote.kt similarity index 100% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcNote.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcNote.kt diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcNotePreviews.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcNotePreviews.kt similarity index 100% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcNotePreviews.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcNotePreviews.kt diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcPatchPump.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcPatchPump.kt similarity index 100% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcPatchPump.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcPatchPump.kt diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcPatchPumpPreviews.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcPatchPumpPreviews.kt similarity index 100% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcPatchPumpPreviews.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcPatchPumpPreviews.kt diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcPizza.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcPizza.kt similarity index 100% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcPizza.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcPizza.kt diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcPizzaPreviews.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcPizzaPreviews.kt similarity index 100% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcPizzaPreviews.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcPizzaPreviews.kt diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcPluginAutomation.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcPluginAutomation.kt similarity index 100% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcPluginAutomation.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcPluginAutomation.kt diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcPluginAutomationPreviews.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcPluginAutomationPreviews.kt similarity index 100% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcPluginAutomationPreviews.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcPluginAutomationPreviews.kt diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcPluginAutotune.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcPluginAutotune.kt similarity index 100% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcPluginAutotune.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcPluginAutotune.kt diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcPluginAutotunePreviews.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcPluginAutotunePreviews.kt similarity index 100% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcPluginAutotunePreviews.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcPluginAutotunePreviews.kt diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcPluginByoda.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcPluginByoda.kt similarity index 100% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcPluginByoda.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcPluginByoda.kt diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcPluginByodaPreviews.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcPluginByodaPreviews.kt similarity index 100% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcPluginByodaPreviews.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcPluginByodaPreviews.kt diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcPluginCombo.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcPluginCombo.kt similarity index 100% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcPluginCombo.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcPluginCombo.kt diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcPluginComboPreviews.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcPluginComboPreviews.kt similarity index 100% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcPluginComboPreviews.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcPluginComboPreviews.kt diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcPluginConfigBuilder.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcPluginConfigBuilder.kt similarity index 100% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcPluginConfigBuilder.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcPluginConfigBuilder.kt diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcPluginConfigBuilderPreviews.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcPluginConfigBuilderPreviews.kt similarity index 100% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcPluginConfigBuilderPreviews.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcPluginConfigBuilderPreviews.kt diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcPluginDana.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcPluginDana.kt similarity index 100% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcPluginDana.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcPluginDana.kt diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcPluginDanaPreviews.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcPluginDanaPreviews.kt similarity index 100% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcPluginDanaPreviews.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcPluginDanaPreviews.kt diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcPluginDiaconn.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcPluginDiaconn.kt similarity index 100% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcPluginDiaconn.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcPluginDiaconn.kt diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcPluginDiaconnPreviews.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcPluginDiaconnPreviews.kt similarity index 100% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcPluginDiaconnPreviews.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcPluginDiaconnPreviews.kt diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcPluginEopatch.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcPluginEopatch.kt similarity index 100% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcPluginEopatch.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcPluginEopatch.kt diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcPluginEopatchPreviews.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcPluginEopatchPreviews.kt similarity index 100% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcPluginEopatchPreviews.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcPluginEopatchPreviews.kt diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcPluginEquil.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcPluginEquil.kt similarity index 100% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcPluginEquil.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcPluginEquil.kt diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcPluginEquilPreviews.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcPluginEquilPreviews.kt similarity index 100% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcPluginEquilPreviews.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcPluginEquilPreviews.kt diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcPluginEversense.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcPluginEversense.kt similarity index 100% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcPluginEversense.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcPluginEversense.kt diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcPluginEversensePreviews.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcPluginEversensePreviews.kt similarity index 100% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcPluginEversensePreviews.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcPluginEversensePreviews.kt diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcPluginFood.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcPluginFood.kt similarity index 100% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcPluginFood.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcPluginFood.kt diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcPluginFoodPreviews.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcPluginFoodPreviews.kt similarity index 100% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcPluginFoodPreviews.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcPluginFoodPreviews.kt diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcPluginGarmin.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcPluginGarmin.kt similarity index 100% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcPluginGarmin.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcPluginGarmin.kt diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcPluginGarminPreviews.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcPluginGarminPreviews.kt similarity index 100% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcPluginGarminPreviews.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcPluginGarminPreviews.kt diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcPluginGlimp.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcPluginGlimp.kt similarity index 100% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcPluginGlimp.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcPluginGlimp.kt diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcPluginGlimpPreviews.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcPluginGlimpPreviews.kt similarity index 100% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcPluginGlimpPreviews.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcPluginGlimpPreviews.kt diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcPluginGlunovo.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcPluginGlunovo.kt similarity index 100% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcPluginGlunovo.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcPluginGlunovo.kt diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcPluginGlunovoPreviews.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcPluginGlunovoPreviews.kt similarity index 100% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcPluginGlunovoPreviews.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcPluginGlunovoPreviews.kt diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcPluginInsight.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcPluginInsight.kt similarity index 100% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcPluginInsight.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcPluginInsight.kt diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcPluginInsightPreviews.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcPluginInsightPreviews.kt similarity index 100% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcPluginInsightPreviews.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcPluginInsightPreviews.kt diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcPluginInsulin.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcPluginInsulin.kt similarity index 100% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcPluginInsulin.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcPluginInsulin.kt diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcPluginInsulinPreviews.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcPluginInsulinPreviews.kt similarity index 100% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcPluginInsulinPreviews.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcPluginInsulinPreviews.kt diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcPluginIntelligo.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcPluginIntelligo.kt similarity index 100% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcPluginIntelligo.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcPluginIntelligo.kt diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcPluginIntelligoPreviews.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcPluginIntelligoPreviews.kt similarity index 100% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcPluginIntelligoPreviews.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcPluginIntelligoPreviews.kt diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcPluginMaintenance.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcPluginMaintenance.kt similarity index 100% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcPluginMaintenance.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcPluginMaintenance.kt diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcPluginMaintenancePreviews.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcPluginMaintenancePreviews.kt similarity index 100% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcPluginMaintenancePreviews.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcPluginMaintenancePreviews.kt diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcPluginMedtronic.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcPluginMedtronic.kt similarity index 100% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcPluginMedtronic.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcPluginMedtronic.kt diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcPluginMedtronicPreviews.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcPluginMedtronicPreviews.kt similarity index 100% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcPluginMedtronicPreviews.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcPluginMedtronicPreviews.kt diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcPluginMedtrum.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcPluginMedtrum.kt similarity index 100% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcPluginMedtrum.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcPluginMedtrum.kt diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcPluginMedtrumPreviews.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcPluginMedtrumPreviews.kt similarity index 100% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcPluginMedtrumPreviews.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcPluginMedtrumPreviews.kt diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcPluginMm640G.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcPluginMm640G.kt similarity index 100% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcPluginMm640G.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcPluginMm640G.kt diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcPluginMm640GPreviews.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcPluginMm640GPreviews.kt similarity index 100% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcPluginMm640GPreviews.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcPluginMm640GPreviews.kt diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcPluginNsClient.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcPluginNsClient.kt similarity index 100% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcPluginNsClient.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcPluginNsClient.kt diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcPluginNsClientBg.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcPluginNsClientBg.kt similarity index 100% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcPluginNsClientBg.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcPluginNsClientBg.kt diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcPluginNsClientBgPreviews.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcPluginNsClientBgPreviews.kt similarity index 100% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcPluginNsClientBgPreviews.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcPluginNsClientBgPreviews.kt diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcPluginNsClientPreviews.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcPluginNsClientPreviews.kt similarity index 100% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcPluginNsClientPreviews.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcPluginNsClientPreviews.kt diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcPluginObjectives.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcPluginObjectives.kt similarity index 100% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcPluginObjectives.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcPluginObjectives.kt diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcPluginObjectivesPreviews.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcPluginObjectivesPreviews.kt similarity index 100% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcPluginObjectivesPreviews.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcPluginObjectivesPreviews.kt diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcPluginOmnipod.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcPluginOmnipod.kt similarity index 100% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcPluginOmnipod.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcPluginOmnipod.kt diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcPluginOmnipodPreviews.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcPluginOmnipodPreviews.kt similarity index 100% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcPluginOmnipodPreviews.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcPluginOmnipodPreviews.kt diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcPluginOpenAps.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcPluginOpenAps.kt similarity index 100% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcPluginOpenAps.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcPluginOpenAps.kt diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcPluginOpenApsPreviews.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcPluginOpenApsPreviews.kt similarity index 100% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcPluginOpenApsPreviews.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcPluginOpenApsPreviews.kt diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcPluginOpenHumans.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcPluginOpenHumans.kt similarity index 100% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcPluginOpenHumans.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcPluginOpenHumans.kt diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcPluginOpenHumansPreviews.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcPluginOpenHumansPreviews.kt similarity index 100% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcPluginOpenHumansPreviews.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcPluginOpenHumansPreviews.kt diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcPluginPocTech.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcPluginPocTech.kt similarity index 100% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcPluginPocTech.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcPluginPocTech.kt diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcPluginPocTechPreviews.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcPluginPocTechPreviews.kt similarity index 100% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcPluginPocTechPreviews.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcPluginPocTechPreviews.kt diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcPluginRandomBg.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcPluginRandomBg.kt similarity index 100% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcPluginRandomBg.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcPluginRandomBg.kt diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcPluginRandomBgPreviews.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcPluginRandomBgPreviews.kt similarity index 100% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcPluginRandomBgPreviews.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcPluginRandomBgPreviews.kt diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcPluginSms.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcPluginSms.kt similarity index 100% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcPluginSms.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcPluginSms.kt diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcPluginSmsPreviews.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcPluginSmsPreviews.kt similarity index 100% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcPluginSmsPreviews.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcPluginSmsPreviews.kt diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcPluginSyai.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcPluginSyai.kt similarity index 100% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcPluginSyai.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcPluginSyai.kt diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcPluginSyaiPreviews.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcPluginSyaiPreviews.kt similarity index 100% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcPluginSyaiPreviews.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcPluginSyaiPreviews.kt diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcPluginTMobi.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcPluginTMobi.kt similarity index 100% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcPluginTMobi.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcPluginTMobi.kt diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcPluginTMobiPreviews.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcPluginTMobiPreviews.kt similarity index 100% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcPluginTMobiPreviews.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcPluginTMobiPreviews.kt diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcPluginTidepool.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcPluginTidepool.kt similarity index 100% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcPluginTidepool.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcPluginTidepool.kt diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcPluginTidepoolPreviews.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcPluginTidepoolPreviews.kt similarity index 100% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcPluginTidepoolPreviews.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcPluginTidepoolPreviews.kt diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcPluginTizen.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcPluginTizen.kt similarity index 100% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcPluginTizen.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcPluginTizen.kt diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcPluginTizenPreviews.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcPluginTizenPreviews.kt similarity index 100% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcPluginTizenPreviews.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcPluginTizenPreviews.kt diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcPluginTomato.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcPluginTomato.kt similarity index 100% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcPluginTomato.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcPluginTomato.kt diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcPluginTomatoPreviews.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcPluginTomatoPreviews.kt similarity index 100% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcPluginTomatoPreviews.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcPluginTomatoPreviews.kt diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcPluginVirtualPump.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcPluginVirtualPump.kt similarity index 100% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcPluginVirtualPump.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcPluginVirtualPump.kt diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcPluginVirtualPumpPreviews.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcPluginVirtualPumpPreviews.kt similarity index 100% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcPluginVirtualPumpPreviews.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcPluginVirtualPumpPreviews.kt diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcProfile.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcProfile.kt similarity index 100% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcProfile.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcProfile.kt diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcProfilePreviews.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcProfilePreviews.kt similarity index 100% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcProfilePreviews.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcProfilePreviews.kt diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcPumpBattery.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcPumpBattery.kt similarity index 100% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcPumpBattery.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcPumpBattery.kt diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcPumpBatteryPreviews.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcPumpBatteryPreviews.kt similarity index 100% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcPumpBatteryPreviews.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcPumpBatteryPreviews.kt diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcPumpCartridge.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcPumpCartridge.kt similarity index 100% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcPumpCartridge.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcPumpCartridge.kt diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcPumpCartridgePreviews.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcPumpCartridgePreviews.kt similarity index 100% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcPumpCartridgePreviews.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcPumpCartridgePreviews.kt diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcQuestion.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcQuestion.kt similarity index 100% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcQuestion.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcQuestion.kt diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcQuestionPreviews.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcQuestionPreviews.kt similarity index 100% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcQuestionPreviews.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcQuestionPreviews.kt diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcQuickWizard.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcQuickWizard.kt similarity index 100% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcQuickWizard.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcQuickWizard.kt diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcQuickWizardPreviews.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcQuickWizardPreviews.kt similarity index 100% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcQuickWizardPreviews.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcQuickWizardPreviews.kt diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcSettingsOff.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcSettingsOff.kt similarity index 100% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcSettingsOff.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcSettingsOff.kt diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcSettingsOffPreviews.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcSettingsOffPreviews.kt similarity index 100% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcSettingsOffPreviews.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcSettingsOffPreviews.kt diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcSetupWizard.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcSetupWizard.kt similarity index 100% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcSetupWizard.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcSetupWizard.kt diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcSetupWizardPreviews.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcSetupWizardPreviews.kt similarity index 100% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcSetupWizardPreviews.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcSetupWizardPreviews.kt diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcSiteRotation.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcSiteRotation.kt similarity index 100% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcSiteRotation.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcSiteRotation.kt diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcSiteRotationPreviews.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcSiteRotationPreviews.kt similarity index 100% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcSiteRotationPreviews.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcSiteRotationPreviews.kt diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcSmb.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcSmb.kt similarity index 100% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcSmb.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcSmb.kt diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcSmbPreviews.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcSmbPreviews.kt similarity index 100% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcSmbPreviews.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcSmbPreviews.kt diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcStats.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcStats.kt similarity index 100% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcStats.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcStats.kt diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcStatsPreviews.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcStatsPreviews.kt similarity index 100% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcStatsPreviews.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcStatsPreviews.kt diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcTbrCancel.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcTbrCancel.kt similarity index 100% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcTbrCancel.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcTbrCancel.kt diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcTbrCancelPreviews.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcTbrCancelPreviews.kt similarity index 100% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcTbrCancelPreviews.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcTbrCancelPreviews.kt diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcTbrHigh.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcTbrHigh.kt similarity index 100% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcTbrHigh.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcTbrHigh.kt diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcTbrHighPreviews.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcTbrHighPreviews.kt similarity index 100% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcTbrHighPreviews.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcTbrHighPreviews.kt diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcTbrLow.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcTbrLow.kt similarity index 100% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcTbrLow.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcTbrLow.kt diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcTbrLowPreviews.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcTbrLowPreviews.kt similarity index 100% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcTbrLowPreviews.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcTbrLowPreviews.kt diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcTtActivity.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcTtActivity.kt similarity index 100% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcTtActivity.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcTtActivity.kt diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcTtActivityPreviews.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcTtActivityPreviews.kt similarity index 100% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcTtActivityPreviews.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcTtActivityPreviews.kt diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcTtCancel.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcTtCancel.kt similarity index 100% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcTtCancel.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcTtCancel.kt diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcTtCancelPreviews.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcTtCancelPreviews.kt similarity index 100% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcTtCancelPreviews.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcTtCancelPreviews.kt diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcTtEatingSoon.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcTtEatingSoon.kt similarity index 100% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcTtEatingSoon.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcTtEatingSoon.kt diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcTtEatingSoonPreviews.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcTtEatingSoonPreviews.kt similarity index 100% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcTtEatingSoonPreviews.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcTtEatingSoonPreviews.kt diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcTtHigh.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcTtHigh.kt similarity index 100% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcTtHigh.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcTtHigh.kt diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcTtHighPreviews.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcTtHighPreviews.kt similarity index 100% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcTtHighPreviews.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcTtHighPreviews.kt diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcTtHypo.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcTtHypo.kt similarity index 100% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcTtHypo.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcTtHypo.kt diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcTtHypoPreviews.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcTtHypoPreviews.kt similarity index 100% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcTtHypoPreviews.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcTtHypoPreviews.kt diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcTtManual.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcTtManual.kt similarity index 100% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcTtManual.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcTtManual.kt diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcTtManualPreviews.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcTtManualPreviews.kt similarity index 100% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcTtManualPreviews.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcTtManualPreviews.kt diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcUserOptions.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcUserOptions.kt similarity index 100% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcUserOptions.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcUserOptions.kt diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcUserOptionsPreviews.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcUserOptionsPreviews.kt similarity index 100% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcUserOptionsPreviews.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcUserOptionsPreviews.kt diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcXDrip.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcXDrip.kt similarity index 100% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcXDrip.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcXDrip.kt diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcXDripPreviews.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcXDripPreviews.kt similarity index 100% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcXDripPreviews.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/IcXDripPreviews.kt diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/Ns.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/Ns.kt similarity index 100% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/Ns.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/Ns.kt diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/NsPreviews.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/NsPreviews.kt similarity index 100% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/NsPreviews.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/NsPreviews.kt diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/Pump.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/Pump.kt similarity index 100% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/Pump.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/Pump.kt diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/PumpPreviews.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/PumpPreviews.kt similarity index 100% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/PumpPreviews.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/PumpPreviews.kt diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/library/IcChildBack.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/library/IcChildBack.kt similarity index 98% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/library/IcChildBack.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/library/IcChildBack.kt index 10a7e587f7b9..8d3ada4195cd 100644 --- a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/library/IcChildBack.kt +++ b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/library/IcChildBack.kt @@ -1,14 +1,14 @@ package app.aaps.core.ui.compose.icons.library -import android.graphics.Path import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Path import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.StrokeCap import androidx.compose.ui.graphics.StrokeJoin import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.PathParser import androidx.compose.ui.graphics.vector.path import androidx.compose.ui.unit.dp -import androidx.core.graphics.PathParser import app.aaps.core.data.model.TE.Location /** @@ -191,7 +191,7 @@ object ChildBackPaths { ) val zones: List> by lazy { pathData.map { (location, svgData) -> - location to PathParser.createPathFromPathData(svgData) + location to PathParser().parsePathString(svgData).toPath() } } } diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/library/IcChildBackPreviews.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/library/IcChildBackPreviews.kt similarity index 100% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/library/IcChildBackPreviews.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/library/IcChildBackPreviews.kt diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/library/IcChildFront.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/library/IcChildFront.kt similarity index 98% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/library/IcChildFront.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/library/IcChildFront.kt index 5e656705ae28..0cdc539446fe 100644 --- a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/library/IcChildFront.kt +++ b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/library/IcChildFront.kt @@ -1,14 +1,14 @@ package app.aaps.core.ui.compose.icons.library -import android.graphics.Path import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Path import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.StrokeCap import androidx.compose.ui.graphics.StrokeJoin import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.PathParser import androidx.compose.ui.graphics.vector.path import androidx.compose.ui.unit.dp -import androidx.core.graphics.PathParser import app.aaps.core.data.model.TE.Location /** @@ -203,7 +203,7 @@ object ChildFrontPaths { ) val zones: List> by lazy { pathData.map { (location, svgData) -> - location to PathParser.createPathFromPathData(svgData) + location to PathParser().parsePathString(svgData).toPath() } } } diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/library/IcChildFrontPreviews.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/library/IcChildFrontPreviews.kt similarity index 100% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/library/IcChildFrontPreviews.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/library/IcChildFrontPreviews.kt diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/library/IcManBack.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/library/IcManBack.kt similarity index 98% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/library/IcManBack.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/library/IcManBack.kt index 96ce1342b3f1..2cd16b803fc2 100644 --- a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/library/IcManBack.kt +++ b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/library/IcManBack.kt @@ -1,14 +1,14 @@ package app.aaps.core.ui.compose.icons.library -import android.graphics.Path import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Path import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.StrokeCap import androidx.compose.ui.graphics.StrokeJoin import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.PathParser import androidx.compose.ui.graphics.vector.path import androidx.compose.ui.unit.dp -import androidx.core.graphics.PathParser import app.aaps.core.data.model.TE.Location /** @@ -197,7 +197,7 @@ object ManBackPaths { ) val zones: List> by lazy { pathData.map { (location, svgData) -> - location to PathParser.createPathFromPathData(svgData) + location to PathParser().parsePathString(svgData).toPath() } } } diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/library/IcManBackPreviews.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/library/IcManBackPreviews.kt similarity index 100% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/library/IcManBackPreviews.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/library/IcManBackPreviews.kt diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/library/IcManFront.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/library/IcManFront.kt similarity index 99% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/library/IcManFront.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/library/IcManFront.kt index eba4776ad89c..1886bf4c9047 100644 --- a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/library/IcManFront.kt +++ b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/library/IcManFront.kt @@ -1,14 +1,14 @@ package app.aaps.core.ui.compose.icons.library -import android.graphics.Path import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Path import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.StrokeCap import androidx.compose.ui.graphics.StrokeJoin import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.PathParser import androidx.compose.ui.graphics.vector.path import androidx.compose.ui.unit.dp -import androidx.core.graphics.PathParser import app.aaps.core.data.model.TE.Location /** @@ -204,6 +204,6 @@ object ManFrontPaths { Location.FRONT_LEFT_UPPER_CHEST to "M33.046,25.581 a3.989,2.16 -7.0 1,0 -7.978,0 a3.989,2.16 -7.0 1,0 7.978,0z", ) val zones: List> = pathData.map { (location, svgData) -> - location to PathParser.createPathFromPathData(svgData) + location to PathParser().parsePathString(svgData).toPath() } } diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/library/IcManFrontPreviews.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/library/IcManFrontPreviews.kt similarity index 100% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/library/IcManFrontPreviews.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/library/IcManFrontPreviews.kt diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/library/IcWomanBack.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/library/IcWomanBack.kt similarity index 98% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/library/IcWomanBack.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/library/IcWomanBack.kt index 0904d193da1b..fdfc9b46ca32 100644 --- a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/library/IcWomanBack.kt +++ b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/library/IcWomanBack.kt @@ -1,14 +1,14 @@ package app.aaps.core.ui.compose.icons.library -import android.graphics.Path import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Path import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.StrokeCap import androidx.compose.ui.graphics.StrokeJoin import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.PathParser import androidx.compose.ui.graphics.vector.path import androidx.compose.ui.unit.dp -import androidx.core.graphics.PathParser import app.aaps.core.data.model.TE.Location /** @@ -205,7 +205,7 @@ object WomanBackPaths { ) val zones: List> by lazy { pathData.map { (location, svgData) -> - location to PathParser.createPathFromPathData(svgData) + location to PathParser().parsePathString(svgData).toPath() } } } diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/library/IcWomanBackPreviews.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/library/IcWomanBackPreviews.kt similarity index 100% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/library/IcWomanBackPreviews.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/library/IcWomanBackPreviews.kt diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/library/IcWomanFront.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/library/IcWomanFront.kt similarity index 98% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/library/IcWomanFront.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/library/IcWomanFront.kt index 95e153c94eab..93d064672ea5 100644 --- a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/library/IcWomanFront.kt +++ b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/library/IcWomanFront.kt @@ -1,14 +1,14 @@ package app.aaps.core.ui.compose.icons.library -import android.graphics.Path import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Path import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.StrokeCap import androidx.compose.ui.graphics.StrokeJoin import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.PathParser import androidx.compose.ui.graphics.vector.path import androidx.compose.ui.unit.dp -import androidx.core.graphics.PathParser import app.aaps.core.data.model.TE.Location /** @@ -204,7 +204,7 @@ object WomanFrontPaths { ) val zones: List> by lazy { pathData.map { (location, svgData) -> - location to PathParser.createPathFromPathData(svgData) + location to PathParser().parsePathString(svgData).toPath() } } } diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/library/IcWomanFrontPreviews.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/library/IcWomanFrontPreviews.kt similarity index 100% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/library/IcWomanFrontPreviews.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/library/IcWomanFrontPreviews.kt diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/library/unused/IcActivityTreatments.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/library/unused/IcActivityTreatments.kt similarity index 100% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/library/unused/IcActivityTreatments.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/library/unused/IcActivityTreatments.kt diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/library/unused/IcActivityTreatmentsPreviews.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/library/unused/IcActivityTreatmentsPreviews.kt similarity index 100% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/library/unused/IcActivityTreatmentsPreviews.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/library/unused/IcActivityTreatmentsPreviews.kt diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/library/unused/IcArrowCenter.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/library/unused/IcArrowCenter.kt similarity index 100% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/library/unused/IcArrowCenter.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/library/unused/IcArrowCenter.kt diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/library/unused/IcArrowCenterPreviews.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/library/unused/IcArrowCenterPreviews.kt similarity index 100% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/library/unused/IcArrowCenterPreviews.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/library/unused/IcArrowCenterPreviews.kt diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/library/unused/IcArrowFlat.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/library/unused/IcArrowFlat.kt similarity index 100% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/library/unused/IcArrowFlat.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/library/unused/IcArrowFlat.kt diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/library/unused/IcArrowFlatPreviews.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/library/unused/IcArrowFlatPreviews.kt similarity index 100% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/library/unused/IcArrowFlatPreviews.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/library/unused/IcArrowFlatPreviews.kt diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/library/unused/IcPluginAction.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/library/unused/IcPluginAction.kt similarity index 100% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/library/unused/IcPluginAction.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/library/unused/IcPluginAction.kt diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/library/unused/IcPluginActionPreviews.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/library/unused/IcPluginActionPreviews.kt similarity index 100% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/library/unused/IcPluginActionPreviews.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/library/unused/IcPluginActionPreviews.kt diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/library/unused/IcPluginConfigBuilder.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/library/unused/IcPluginConfigBuilder.kt similarity index 100% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/library/unused/IcPluginConfigBuilder.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/library/unused/IcPluginConfigBuilder.kt diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/library/unused/IcPluginConfigBuilderPreviews.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/library/unused/IcPluginConfigBuilderPreviews.kt similarity index 100% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/library/unused/IcPluginConfigBuilderPreviews.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/library/unused/IcPluginConfigBuilderPreviews.kt diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/library/unused/IcPluginOverview.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/library/unused/IcPluginOverview.kt similarity index 100% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/library/unused/IcPluginOverview.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/library/unused/IcPluginOverview.kt diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/library/unused/IcPluginOverviewPreviews.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/library/unused/IcPluginOverviewPreviews.kt similarity index 100% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/library/unused/IcPluginOverviewPreviews.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/icons/library/unused/IcPluginOverviewPreviews.kt diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/insulin/ConcentrationDropDown.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/insulin/ConcentrationDropDown.kt similarity index 93% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/insulin/ConcentrationDropDown.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/insulin/ConcentrationDropDown.kt index d58800b1e629..0a2de34f6e16 100644 --- a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/insulin/ConcentrationDropDown.kt +++ b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/insulin/ConcentrationDropDown.kt @@ -14,9 +14,9 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier -import androidx.compose.ui.res.stringResource import app.aaps.core.interfaces.insulin.ConcentrationType -import app.aaps.core.ui.R +import app.aaps.core.ui.UiStrings +import app.aaps.core.ui.compose.stringResource @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -37,7 +37,7 @@ fun ConcentrationDropdown( onValueChange = {}, readOnly = true, enabled = enabled, - label = { Text(stringResource(R.string.concentration_label)) }, + label = { Text(stringResource(UiStrings.concentration_label)) }, trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) }, modifier = Modifier .fillMaxWidth() diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/insulin/SelectInsulin.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/insulin/SelectInsulin.kt similarity index 95% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/insulin/SelectInsulin.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/insulin/SelectInsulin.kt index 9e3f0b3dddc2..3553b7c29e1b 100644 --- a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/insulin/SelectInsulin.kt +++ b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/insulin/SelectInsulin.kt @@ -20,12 +20,12 @@ import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import app.aaps.core.data.model.ICfg import app.aaps.core.interfaces.insulin.ConcentrationType -import app.aaps.core.ui.R +import app.aaps.core.ui.UiStrings +import app.aaps.core.ui.compose.stringResource /** * @see PreviewCollapsed @@ -77,7 +77,7 @@ fun SelectInsulin( ) { Column(modifier = Modifier.weight(1f)) { Text( - text = stringResource(R.string.current_insulin), + text = stringResource(UiStrings.current_insulin), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant ) @@ -88,7 +88,7 @@ fun SelectInsulin( ) } FilledTonalButton(onClick = { expanded = !expanded }) { - Text(stringResource(R.string.change_insulin)) + Text(stringResource(UiStrings.change_insulin)) } } @@ -138,7 +138,7 @@ fun SelectInsulin( ) if (isActive) { Text( - text = stringResource(R.string.current_insulin), + text = stringResource(UiStrings.current_insulin), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant ) diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/insulin/SelectInsulinPreviews.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/insulin/SelectInsulinPreviews.kt similarity index 100% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/insulin/SelectInsulinPreviews.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/insulin/SelectInsulinPreviews.kt diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/navigation/ElementColors.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/navigation/ElementColors.kt similarity index 100% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/navigation/ElementColors.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/navigation/ElementColors.kt diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/navigation/ElementTypeStyle.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/navigation/ElementTypeStyle.kt similarity index 56% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/navigation/ElementTypeStyle.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/navigation/ElementTypeStyle.kt index 350207b23551..45b3d6549c05 100644 --- a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/navigation/ElementTypeStyle.kt +++ b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/navigation/ElementTypeStyle.kt @@ -9,9 +9,11 @@ import androidx.compose.material.icons.filled.Settings import androidx.compose.runtime.Composable import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector +import app.aaps.core.interfaces.InterfacesStrings import app.aaps.core.interfaces.navigation.ElementCategory import app.aaps.core.interfaces.navigation.ElementType -import app.aaps.core.ui.R +import app.aaps.core.keys.interfaces.TextRef +import app.aaps.core.ui.UiStrings import app.aaps.core.ui.compose.AapsTheme import app.aaps.core.ui.compose.icons.IcActivity import app.aaps.core.ui.compose.icons.IcAnnouncement @@ -185,113 +187,113 @@ fun ElementType.icon(): ImageVector = when (this) { ElementType.EXIT -> Icons.AutoMirrored.Filled.ExitToApp } -fun ElementCategory.labelResId(): Int = when (this) { - ElementCategory.TREATMENT -> R.string.overview_treatment_label - ElementCategory.CGM -> R.string.cgm - ElementCategory.MANAGEMENT -> R.string.manage - ElementCategory.CAREPORTAL -> R.string.careportal - ElementCategory.DEVICE -> R.string.device_maintenance - ElementCategory.BASAL -> R.string.basal +fun ElementCategory.label(): TextRef? = when (this) { + ElementCategory.TREATMENT -> UiStrings.overview_treatment_label + ElementCategory.CGM -> UiStrings.cgm + ElementCategory.MANAGEMENT -> UiStrings.manage + ElementCategory.CAREPORTAL -> UiStrings.careportal + ElementCategory.DEVICE -> UiStrings.device_maintenance + ElementCategory.BASAL -> UiStrings.basal ElementCategory.SYSTEM, ElementCategory.NAVIGATION, - ElementCategory.INTERNAL -> 0 + ElementCategory.INTERNAL -> null } -fun ElementType.labelResId(): Int = when (this) { - ElementType.INSULIN -> R.string.overview_insulin_label - ElementType.CARBS -> R.string.carbs - ElementType.BOLUS_WIZARD -> R.string.boluswizard - ElementType.QUICK_WIZARD -> 0 // dynamic label - ElementType.QUICK_WIZARD_MANAGEMENT -> R.string.quickwizard_managemnt - ElementType.FOOD_MANAGEMENT -> R.string.food_management - ElementType.TREATMENT -> R.string.overview_treatment_label - ElementType.CGM_XDRIP -> R.string.cgm - ElementType.CGM_DEX -> R.string.cgm - ElementType.CALIBRATION -> R.string.calibration - ElementType.INSULIN_MANAGEMENT -> R.string.insulin_management - ElementType.PROFILE_MANAGEMENT -> R.string.profile_management - ElementType.TEMP_TARGET_MANAGEMENT -> R.string.temp_target_management - ElementType.BG_CHECK -> R.string.careportal_bgcheck - ElementType.NOTE -> R.string.careportal_note - ElementType.EXERCISE -> R.string.careportal_exercise - ElementType.QUESTION -> R.string.careportal_question - ElementType.ANNOUNCEMENT -> R.string.careportal_announcement - ElementType.SENSOR_INSERT -> R.string.cgm_sensor_insert - ElementType.BATTERY_CHANGE -> R.string.pump_battery_change - ElementType.CANNULA_CHANGE -> R.string.careportal_pump_site_change - ElementType.FILL -> R.string.prime_fill - ElementType.SITE_ROTATION -> R.string.site_rotation - ElementType.TEMP_BASAL -> R.string.temp_basal - ElementType.EXTENDED_BOLUS -> R.string.extended_bolus - ElementType.AUTOMATION -> 0 // dynamic label - ElementType.AUTOMATION_MANAGEMENT -> R.string.automation - ElementType.PUMP -> R.string.pump - ElementType.SETTINGS -> R.string.settings - ElementType.QUICK_LAUNCH_CONFIG -> R.string.quick_launch_configure - ElementType.TREATMENTS -> R.string.treatments_history - ElementType.STATISTICS -> R.string.statistics - ElementType.TDD_CYCLE_PATTERN -> R.string.tdd_cycle_pattern - ElementType.PROFILE_HELPER -> R.string.nav_profile_helper - ElementType.HISTORY_BROWSER -> R.string.nav_history_browser - ElementType.SETUP_WIZARD -> R.string.nav_setupwizard - ElementType.MAINTENANCE -> R.string.maintenance - ElementType.CONFIGURATION -> R.string.nav_configuration - ElementType.ABOUT -> R.string.nav_about - ElementType.COB -> R.string.cob - ElementType.SENSITIVITY -> R.string.sensitivity - ElementType.SCENE -> 0 // dynamic label - ElementType.SCENE_MANAGEMENT -> R.string.scene_management - ElementType.AUTHORIZED_CLIENTS -> R.string.authorized_clients_manage_label - ElementType.PAIR_WITH_MASTER -> R.string.pair_with_master_manage_label - ElementType.RUNNING_MODE -> R.string.running_mode - ElementType.USER_ENTRY -> R.string.user_entry - ElementType.LOOP -> R.string.loop - ElementType.AAPS -> R.string.aaps - ElementType.EXIT -> R.string.nav_exit +fun ElementType.label(): TextRef? = when (this) { + ElementType.INSULIN -> UiStrings.overview_insulin_label + ElementType.CARBS -> InterfacesStrings.carbs + ElementType.BOLUS_WIZARD -> UiStrings.boluswizard + ElementType.QUICK_WIZARD -> null // dynamic label + ElementType.QUICK_WIZARD_MANAGEMENT -> UiStrings.quickwizard_managemnt + ElementType.FOOD_MANAGEMENT -> UiStrings.food_management + ElementType.TREATMENT -> UiStrings.overview_treatment_label + ElementType.CGM_XDRIP -> UiStrings.cgm + ElementType.CGM_DEX -> UiStrings.cgm + ElementType.CALIBRATION -> UiStrings.calibration + ElementType.INSULIN_MANAGEMENT -> UiStrings.insulin_management + ElementType.PROFILE_MANAGEMENT -> UiStrings.profile_management + ElementType.TEMP_TARGET_MANAGEMENT -> UiStrings.temp_target_management + ElementType.BG_CHECK -> UiStrings.careportal_bgcheck + ElementType.NOTE -> UiStrings.careportal_note + ElementType.EXERCISE -> UiStrings.careportal_exercise + ElementType.QUESTION -> UiStrings.careportal_question + ElementType.ANNOUNCEMENT -> UiStrings.careportal_announcement + ElementType.SENSOR_INSERT -> UiStrings.cgm_sensor_insert + ElementType.BATTERY_CHANGE -> UiStrings.pump_battery_change + ElementType.CANNULA_CHANGE -> UiStrings.careportal_pump_site_change + ElementType.FILL -> UiStrings.prime_fill + ElementType.SITE_ROTATION -> UiStrings.site_rotation + ElementType.TEMP_BASAL -> UiStrings.temp_basal + ElementType.EXTENDED_BOLUS -> UiStrings.extended_bolus + ElementType.AUTOMATION -> null // dynamic label + ElementType.AUTOMATION_MANAGEMENT -> UiStrings.automation + ElementType.PUMP -> UiStrings.pump + ElementType.SETTINGS -> UiStrings.settings + ElementType.QUICK_LAUNCH_CONFIG -> UiStrings.quick_launch_configure + ElementType.TREATMENTS -> UiStrings.treatments_history + ElementType.STATISTICS -> UiStrings.statistics + ElementType.TDD_CYCLE_PATTERN -> UiStrings.tdd_cycle_pattern + ElementType.PROFILE_HELPER -> UiStrings.nav_profile_helper + ElementType.HISTORY_BROWSER -> UiStrings.nav_history_browser + ElementType.SETUP_WIZARD -> UiStrings.nav_setupwizard + ElementType.MAINTENANCE -> UiStrings.maintenance + ElementType.CONFIGURATION -> UiStrings.nav_configuration + ElementType.ABOUT -> UiStrings.nav_about + ElementType.COB -> UiStrings.cob + ElementType.SENSITIVITY -> UiStrings.sensitivity + ElementType.SCENE -> null // dynamic label + ElementType.SCENE_MANAGEMENT -> UiStrings.scene_management + ElementType.AUTHORIZED_CLIENTS -> UiStrings.authorized_clients_manage_label + ElementType.PAIR_WITH_MASTER -> UiStrings.pair_with_master_manage_label + ElementType.RUNNING_MODE -> UiStrings.running_mode + ElementType.USER_ENTRY -> UiStrings.user_entry + ElementType.LOOP -> UiStrings.loop + ElementType.AAPS -> UiStrings.aaps + ElementType.EXIT -> UiStrings.nav_exit } -fun ElementType.descriptionResId(): Int = when (this) { - ElementType.INSULIN -> R.string.treatment_insulin_desc - ElementType.CARBS -> R.string.treatment_carbs_desc - ElementType.BOLUS_WIZARD -> R.string.treatment_calculator_desc - ElementType.TREATMENT -> R.string.treatment_desc - ElementType.INSULIN_MANAGEMENT -> R.string.manage_insulin_desc - ElementType.PROFILE_MANAGEMENT -> R.string.manage_profile_desc - ElementType.TEMP_TARGET_MANAGEMENT -> R.string.manage_temp_target_desc - ElementType.QUICK_WIZARD_MANAGEMENT -> R.string.manage_quickwizard_desc - ElementType.FOOD_MANAGEMENT -> R.string.manage_food_desc - - ElementType.TEMP_BASAL -> R.string.manage_temp_basal_desc - ElementType.EXTENDED_BOLUS -> R.string.manage_extended_bolus_desc - ElementType.SITE_ROTATION -> R.string.manage_site_rotation_desc - ElementType.NOTE -> R.string.treatment_note_desc - ElementType.QUESTION -> R.string.treatment_question_desc +fun ElementType.description(): TextRef? = when (this) { + ElementType.INSULIN -> UiStrings.treatment_insulin_desc + ElementType.CARBS -> UiStrings.treatment_carbs_desc + ElementType.BOLUS_WIZARD -> UiStrings.treatment_calculator_desc + ElementType.TREATMENT -> UiStrings.treatment_desc + ElementType.INSULIN_MANAGEMENT -> UiStrings.manage_insulin_desc + ElementType.PROFILE_MANAGEMENT -> UiStrings.manage_profile_desc + ElementType.TEMP_TARGET_MANAGEMENT -> UiStrings.manage_temp_target_desc + ElementType.QUICK_WIZARD_MANAGEMENT -> UiStrings.manage_quickwizard_desc + ElementType.FOOD_MANAGEMENT -> UiStrings.manage_food_desc + + ElementType.TEMP_BASAL -> UiStrings.manage_temp_basal_desc + ElementType.EXTENDED_BOLUS -> UiStrings.manage_extended_bolus_desc + ElementType.SITE_ROTATION -> UiStrings.manage_site_rotation_desc + ElementType.NOTE -> UiStrings.treatment_note_desc + ElementType.QUESTION -> UiStrings.treatment_question_desc ElementType.CGM_XDRIP, - ElementType.CGM_DEX -> R.string.treatment_cgm_desc - - ElementType.CALIBRATION -> R.string.treatment_calibration_desc - ElementType.BG_CHECK -> R.string.treatment_bg_check_desc - ElementType.EXERCISE -> R.string.treatment_exercise_desc - ElementType.ANNOUNCEMENT -> R.string.treatment_announcement_desc - ElementType.SENSOR_INSERT -> R.string.treatment_sensor_insert_desc - ElementType.BATTERY_CHANGE -> R.string.treatment_battery_change_desc - ElementType.CANNULA_CHANGE -> R.string.treatment_cannula_change_desc - ElementType.FILL -> R.string.treatment_fill_desc - ElementType.TREATMENTS -> R.string.treatments_desc - ElementType.STATISTICS -> R.string.statistics_desc - ElementType.TDD_CYCLE_PATTERN -> R.string.tdd_cycle_pattern_desc - ElementType.PROFILE_HELPER -> R.string.nav_profile_helper_desc - ElementType.HISTORY_BROWSER -> R.string.nav_history_browser_desc - ElementType.SETUP_WIZARD -> R.string.nav_setupwizard_desc - ElementType.MAINTENANCE -> R.string.description_maintenance - ElementType.CONFIGURATION -> R.string.nav_configuration_desc - ElementType.ABOUT -> R.string.nav_about_desc - ElementType.QUICK_LAUNCH_CONFIG -> R.string.quick_launch_configure_desc - ElementType.SCENE -> R.string.scene_desc - ElementType.SCENE_MANAGEMENT -> R.string.scene_management_desc - ElementType.AUTOMATION_MANAGEMENT -> R.string.automation_management_desc - ElementType.AUTHORIZED_CLIENTS -> R.string.authorized_clients_manage_desc - ElementType.PAIR_WITH_MASTER -> R.string.pair_with_master_manage_desc + ElementType.CGM_DEX -> UiStrings.treatment_cgm_desc + + ElementType.CALIBRATION -> UiStrings.treatment_calibration_desc + ElementType.BG_CHECK -> UiStrings.treatment_bg_check_desc + ElementType.EXERCISE -> UiStrings.treatment_exercise_desc + ElementType.ANNOUNCEMENT -> UiStrings.treatment_announcement_desc + ElementType.SENSOR_INSERT -> UiStrings.treatment_sensor_insert_desc + ElementType.BATTERY_CHANGE -> UiStrings.treatment_battery_change_desc + ElementType.CANNULA_CHANGE -> UiStrings.treatment_cannula_change_desc + ElementType.FILL -> UiStrings.treatment_fill_desc + ElementType.TREATMENTS -> UiStrings.treatments_desc + ElementType.STATISTICS -> UiStrings.statistics_desc + ElementType.TDD_CYCLE_PATTERN -> UiStrings.tdd_cycle_pattern_desc + ElementType.PROFILE_HELPER -> UiStrings.nav_profile_helper_desc + ElementType.HISTORY_BROWSER -> UiStrings.nav_history_browser_desc + ElementType.SETUP_WIZARD -> UiStrings.nav_setupwizard_desc + ElementType.MAINTENANCE -> UiStrings.description_maintenance + ElementType.CONFIGURATION -> UiStrings.nav_configuration_desc + ElementType.ABOUT -> UiStrings.nav_about_desc + ElementType.QUICK_LAUNCH_CONFIG -> UiStrings.quick_launch_configure_desc + ElementType.SCENE -> UiStrings.scene_desc + ElementType.SCENE_MANAGEMENT -> UiStrings.scene_management_desc + ElementType.AUTOMATION_MANAGEMENT -> UiStrings.automation_management_desc + ElementType.AUTHORIZED_CLIENTS -> UiStrings.authorized_clients_manage_desc + ElementType.PAIR_WITH_MASTER -> UiStrings.pair_with_master_manage_desc ElementType.QUICK_WIZARD, ElementType.RUNNING_MODE, ElementType.AUTOMATION, @@ -302,5 +304,5 @@ fun ElementType.descriptionResId(): Int = when (this) { ElementType.USER_ENTRY, ElementType.LOOP, ElementType.AAPS, - ElementType.EXIT -> 0 + ElementType.EXIT -> null } diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/navigation/NavigationRequest.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/navigation/NavigationRequest.kt similarity index 100% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/navigation/NavigationRequest.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/navigation/NavigationRequest.kt diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/pickers/HourWheelPicker.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/pickers/HourWheelPicker.kt similarity index 100% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/pickers/HourWheelPicker.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/pickers/HourWheelPicker.kt diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/pickers/WeekDaySelector.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/pickers/WeekDaySelector.kt similarity index 97% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/pickers/WeekDaySelector.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/pickers/WeekDaySelector.kt index ac24cb50c322..fdd377e35714 100644 --- a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/pickers/WeekDaySelector.kt +++ b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/pickers/WeekDaySelector.kt @@ -8,8 +8,8 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier -import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp +import app.aaps.core.ui.compose.stringResource import app.aaps.core.ui.elements.WeekDay /** diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/pickers/WeekDaySelectorPreviews.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/pickers/WeekDaySelectorPreviews.kt similarity index 100% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/pickers/WeekDaySelectorPreviews.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/pickers/WeekDaySelectorPreviews.kt diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/preference/AdaptiveDoublePreference.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/preference/AdaptiveDoublePreference.kt similarity index 73% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/preference/AdaptiveDoublePreference.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/preference/AdaptiveDoublePreference.kt index ae1ebdf206af..818b377d3dc5 100644 --- a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/preference/AdaptiveDoublePreference.kt +++ b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/preference/AdaptiveDoublePreference.kt @@ -10,22 +10,25 @@ import androidx.compose.foundation.layout.padding import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier -import androidx.compose.ui.res.stringResource import app.aaps.core.data.format.NumberFormat import app.aaps.core.keys.decimalPlaces import app.aaps.core.keys.interfaces.DoublePreferenceKey +import app.aaps.core.keys.interfaces.TextRef import app.aaps.core.keys.interfaces.VisibilityContext -import app.aaps.core.keys.rangeResId import app.aaps.core.keys.step -import app.aaps.core.keys.unitLabelResId -import app.aaps.core.keys.valueResId -import app.aaps.core.ui.R +import app.aaps.core.ui.UiStrings import app.aaps.core.ui.compose.LocalPreferences +import app.aaps.core.ui.compose.isDuration +import app.aaps.core.ui.compose.rangeText +import app.aaps.core.ui.compose.stringResource +import app.aaps.core.ui.compose.stringResourceOrNull +import app.aaps.core.ui.compose.unitLabel +import app.aaps.core.ui.compose.valueFormat /** * Composable double preference for use inside card sections. * - * @param titleResId Optional title resource ID. If 0 or not provided, uses doubleKey.titleResId + * @param title Optional title override. If null, uses doubleKey.title * @param visibilityContext Optional context for evaluating runtime visibility/enabled conditions * * @see AdaptiveDoublePreferencePreview @@ -33,15 +36,12 @@ import app.aaps.core.ui.compose.LocalPreferences @Composable fun AdaptiveDoublePreferenceItem( doubleKey: DoublePreferenceKey, - titleResId: Int = 0, + title: TextRef? = null, unit: String = "", visibilityContext: VisibilityContext? = null ) { val preferences = LocalPreferences.current - val effectiveTitleResId = if (titleResId != 0) titleResId else doubleKey.titleResId - - // Skip if no title resource is available - if (effectiveTitleResId == 0) return + val effectiveTitle = title ?: doubleKey.title val visibility = calculatePreferenceVisibility( preferenceKey = doubleKey, @@ -58,17 +58,16 @@ fun AdaptiveDoublePreferenceItem( val unitType = doubleKey.unitType val decimalPlaces = unitType.decimalPlaces() val step = unitType.step() - val valueFormatResId = unitType.valueResId() + val valueFormatRef = unitType.valueFormat() // Get unit label from UnitType (for dialog input suffix) - val unitLabelResId = unitType.unitLabelResId() - val unitLabel = unitLabelResId?.let { stringResource(it) } ?: unit + val unitLabelRef = unitType.unitLabel() ?: unit.takeIf { it.isNotEmpty() }?.let { TextRef.Literal(it) } + val unitLabelText = unitLabelRef?.let { stringResource(it) } ?: "" val valueFormat = NumberFormat.withDecimals(decimalPlaces) // Get summary if available - val summaryResId = doubleKey.summaryResId - val summary = if (summaryResId != null && summaryResId != 0) stringResource(summaryResId) else null + val summary = stringResourceOrNull(doubleKey.summary) // Use slider if min/max range is specified (not default extreme values) // Note: Double.MIN_VALUE is smallest positive value, not most negative @@ -81,7 +80,7 @@ fun AdaptiveDoublePreferenceItem( .padding(theme.padding) ) { TextWithSyncBadge( - text = stringResource(effectiveTitleResId), + text = stringResource(effectiveTitle), key = doubleKey, style = theme.titleTextStyle, // Mirror Preference's disabled styling (the switch row greys the same way) since this @@ -105,25 +104,26 @@ fun AdaptiveDoublePreferenceItem( valueRange = doubleKey.min..doubleKey.max, step = step, showValue = true, - valueFormatResId = valueFormatResId, + valueFormatRef = valueFormatRef, valueFormat = valueFormat, - unitLabel = unitLabel, - dialogLabel = stringResource(effectiveTitleResId), + unitLabel = unitLabelRef, + asDuration = unitType.isDuration(), + dialogLabel = stringResource(effectiveTitle), dialogSummary = summary, enabled = visibility.enabled ) } } else { // For unspecified ranges, use text field with range summary - val rangeFormatResId = unitType.rangeResId() - val summaryText = if (rangeFormatResId != null) { - stringResource(rangeFormatResId, value, doubleKey.min, doubleKey.max) + val rangeRef = unitType.rangeText(value, doubleKey.min, doubleKey.max) + val summaryText = if (rangeRef != null) { + stringResource(rangeRef) } else { - stringResource(R.string.preference_range_summary, valueFormat.format(value), unitLabel, valueFormat.format(doubleKey.min), valueFormat.format(doubleKey.max)) + stringResource(UiStrings.preference_range_summary, valueFormat.format(value), unitLabelText, valueFormat.format(doubleKey.min), valueFormat.format(doubleKey.max)) } TextFieldPreference( state = state, - title = { PreferenceTitleWithSyncBadge(effectiveTitleResId, doubleKey) }, + title = { PreferenceTitleWithSyncBadge(effectiveTitle, doubleKey) }, textToValue = { text -> text.toDoubleOrNull()?.coerceIn(doubleKey.min, doubleKey.max) }, diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/preference/AdaptiveDoublePreferencePreviews.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/preference/AdaptiveDoublePreferencePreviews.kt similarity index 100% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/preference/AdaptiveDoublePreferencePreviews.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/preference/AdaptiveDoublePreferencePreviews.kt diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/preference/AdaptiveIntPreference.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/preference/AdaptiveIntPreference.kt similarity index 72% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/preference/AdaptiveIntPreference.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/preference/AdaptiveIntPreference.kt index 12bfdb2960a3..a06ab572a81f 100644 --- a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/preference/AdaptiveIntPreference.kt +++ b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/preference/AdaptiveIntPreference.kt @@ -10,19 +10,22 @@ import androidx.compose.foundation.layout.padding import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier -import androidx.compose.ui.res.stringResource import app.aaps.core.data.format.NumberFormat import app.aaps.core.keys.interfaces.IntPreferenceKey +import app.aaps.core.keys.interfaces.TextRef import app.aaps.core.keys.interfaces.VisibilityContext -import app.aaps.core.keys.rangeResId -import app.aaps.core.keys.unitLabelResId -import app.aaps.core.keys.valueResId -import app.aaps.core.ui.R +import app.aaps.core.ui.UiStrings +import app.aaps.core.ui.compose.isDuration +import app.aaps.core.ui.compose.rangeText +import app.aaps.core.ui.compose.stringResource +import app.aaps.core.ui.compose.stringResourceOrNull +import app.aaps.core.ui.compose.unitLabel +import app.aaps.core.ui.compose.valueFormat /** * Composable int preference for use inside card sections. * - * @param titleResId Optional title resource ID. If 0 or not provided, uses intKey.titleResId + * @param title Optional title override. If null, uses intKey.title * @param visibilityContext Optional context for evaluating runtime visibility/enabled conditions * * @see AdaptiveIntPreferencePreview @@ -30,14 +33,11 @@ import app.aaps.core.ui.R @Composable fun AdaptiveIntPreferenceItem( intKey: IntPreferenceKey, - titleResId: Int = 0, + title: TextRef? = null, unit: String = "", visibilityContext: VisibilityContext? = null ) { - val effectiveTitleResId = if (titleResId != 0) titleResId else intKey.titleResId - - // Skip if no title resource is available - if (effectiveTitleResId == 0) return + val effectiveTitle = title ?: intKey.title val visibility = calculatePreferenceVisibility( preferenceKey = intKey, @@ -53,15 +53,14 @@ fun AdaptiveIntPreferenceItem( // Get formatting info from UnitType val unitType = intKey.unitType - val valueFormatResId = unitType.valueResId() + val valueFormatRef = unitType.valueFormat() // Get unit label from UnitType (for dialog input suffix) - val unitLabelResId = unitType.unitLabelResId() - val unitLabel = unitLabelResId?.let { stringResource(it) } ?: unit + val unitLabelRef = unitType.unitLabel() ?: unit.takeIf { it.isNotEmpty() }?.let { TextRef.Literal(it) } + val unitLabelText = unitLabelRef?.let { stringResource(it) } ?: "" // Get summary if available - val summaryResId = intKey.summaryResId - val summary = if (summaryResId != null && summaryResId != 0) stringResource(summaryResId) else null + val summary = stringResourceOrNull(intKey.summary) // Use slider if min/max range is specified (not default extreme values) val hasValidRange = intKey.min > Int.MIN_VALUE && intKey.max < Int.MAX_VALUE @@ -73,7 +72,7 @@ fun AdaptiveIntPreferenceItem( .padding(theme.padding) ) { TextWithSyncBadge( - text = stringResource(effectiveTitleResId), + text = stringResource(effectiveTitle), key = intKey, style = theme.titleTextStyle, // Mirror Preference's disabled styling (the switch row greys the same way) since this @@ -97,26 +96,27 @@ fun AdaptiveIntPreferenceItem( valueRange = intKey.min.toDouble()..intKey.max.toDouble(), step = 1.0, showValue = true, - valueFormatResId = valueFormatResId, + valueFormatRef = valueFormatRef, formatAsInt = true, valueFormat = NumberFormat.INTEGER, - unitLabel = unitLabel, - dialogLabel = stringResource(effectiveTitleResId), + unitLabel = unitLabelRef, + asDuration = unitType.isDuration(), + dialogLabel = stringResource(effectiveTitle), dialogSummary = summary, enabled = visibility.enabled ) } } else { // For unspecified ranges, use text field with range summary - val rangeFormatResId = unitType.rangeResId() - val summaryText = if (rangeFormatResId != null) { - stringResource(rangeFormatResId, value, intKey.min, intKey.max) + val rangeRef = unitType.rangeText(value, intKey.min, intKey.max) + val summaryText = if (rangeRef != null) { + stringResource(rangeRef) } else { - stringResource(R.string.preference_range_summary, value.toString(), unitLabel, intKey.min.toString(), intKey.max.toString()) + stringResource(UiStrings.preference_range_summary, value.toString(), unitLabelText, intKey.min.toString(), intKey.max.toString()) } TextFieldPreference( state = state, - title = { PreferenceTitleWithSyncBadge(effectiveTitleResId, intKey) }, + title = { PreferenceTitleWithSyncBadge(effectiveTitle, intKey) }, textToValue = { text -> text.toIntOrNull()?.coerceIn(intKey.min, intKey.max) }, diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/preference/AdaptiveIntPreferencePreviews.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/preference/AdaptiveIntPreferencePreviews.kt similarity index 100% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/preference/AdaptiveIntPreferencePreviews.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/preference/AdaptiveIntPreferencePreviews.kt diff --git a/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/preference/AdaptiveIntentPreference.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/preference/AdaptiveIntentPreference.kt new file mode 100644 index 000000000000..41d639fe5b7f --- /dev/null +++ b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/preference/AdaptiveIntentPreference.kt @@ -0,0 +1,139 @@ +/* + * Adaptive Intent Preferences for Jetpack Compose + */ + +package app.aaps.core.ui.compose.preference + +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.platform.LocalUriHandler +import app.aaps.core.keys.interfaces.IntentPreferenceKey +import app.aaps.core.keys.interfaces.TextRef +import app.aaps.core.keys.interfaces.VisibilityContext +import app.aaps.core.ui.compose.ComposeScreenContent +import app.aaps.core.ui.compose.dialogs.OkCancelDialog +import app.aaps.core.ui.compose.stringResource + +/** + * Composable intent preference for use inside card sections. + * + * @param title Optional title override. If null, uses intentKey.title + * @param summary Optional summary override. If null, uses intentKey.summary + * @param visibilityContext Optional context for evaluating runtime visibility/enabled conditions + */ +@Composable +fun AdaptiveIntentPreferenceItem( + intentKey: IntentPreferenceKey, + title: TextRef? = null, + summary: TextRef? = null, + onClick: () -> Unit, + visibilityContext: VisibilityContext? = null +) { + val effectiveTitle = title ?: intentKey.title + val effectiveSummary = summary ?: intentKey.summary + + val visibility = calculateIntentPreferenceVisibility( + intentKey = intentKey, + visibilityContext = visibilityContext + ) + + if (!visibility.visible) return + + // Show confirmation dialog when confirmationMessage is set on the key + val confirmation = intentKey.confirmationMessage + var showConfirmation by remember { mutableStateOf(false) } + + if (showConfirmation && confirmation != null) { + OkCancelDialog( + title = stringResource(effectiveTitle), + message = stringResource(confirmation), + onConfirm = { + onClick() + showConfirmation = false + }, + onDismiss = { showConfirmation = false } + ) + } + + val effectiveOnClick = if (confirmation != null) { + { showConfirmation = true } + } else { + onClick + } + + Preference( + title = { Text(stringResource(effectiveTitle)) }, + summary = effectiveSummary?.let { { Text(stringResource(it)) } }, + enabled = visibility.enabled, + onClick = if (visibility.enabled) effectiveOnClick else null + ) +} + +/** + * Composable URL preference for use inside card sections. + * + * @param title Optional title override. If null, uses intentKey.title + */ +@Composable +fun AdaptiveUrlPreferenceItem( + intentKey: IntentPreferenceKey, + title: TextRef? = null, + url: String, + visibilityContext: VisibilityContext? = null +) { + val effectiveTitle = title ?: intentKey.title + + val visibility = calculateIntentPreferenceVisibility( + intentKey = intentKey, + visibilityContext = visibilityContext + ) + + if (!visibility.visible) return + + val uriHandler = LocalUriHandler.current + Preference( + title = { Text(stringResource(effectiveTitle)) }, + summary = { Text(url) }, + enabled = visibility.enabled, + onClick = if (visibility.enabled) { + { uriHandler.openUri(url) } + } else null + ) +} + +/** + * Composable preference that navigates to an inline Compose screen. + * Used for IntentPreferenceKey with composeScreen attached via withCompose(). + */ +@Composable +fun AdaptiveComposeScreenPreferenceItem( + intentKey: IntentPreferenceKey, + composeScreen: ComposeScreenContent, + onNavigate: (ComposeScreenContent) -> Unit, + title: TextRef? = null, + summary: TextRef? = null, + visibilityContext: VisibilityContext? = null +) { + val effectiveTitle = title ?: intentKey.title + val effectiveSummary = summary ?: intentKey.summary + + val visibility = calculateIntentPreferenceVisibility( + intentKey = intentKey, + visibilityContext = visibilityContext + ) + + if (!visibility.visible) return + + Preference( + title = { Text(stringResource(effectiveTitle)) }, + summary = effectiveSummary?.let { { Text(stringResource(it)) } }, + enabled = visibility.enabled, + onClick = if (visibility.enabled) { + { onNavigate(composeScreen) } + } else null + ) +} diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/preference/AdaptiveListPreference.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/preference/AdaptiveListPreference.kt similarity index 71% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/preference/AdaptiveListPreference.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/preference/AdaptiveListPreference.kt index 61c5413e3386..599836dd8a92 100644 --- a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/preference/AdaptiveListPreference.kt +++ b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/preference/AdaptiveListPreference.kt @@ -6,16 +6,18 @@ package app.aaps.core.ui.compose.preference import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.AnnotatedString import app.aaps.core.keys.interfaces.IntPreferenceKey import app.aaps.core.keys.interfaces.StringPreferenceKey +import app.aaps.core.keys.interfaces.TextRef import app.aaps.core.keys.interfaces.VisibilityContext +import app.aaps.core.ui.compose.stringResource +import app.aaps.core.ui.compose.stringResourceOrNull /** * Composable list int preference for use inside card sections. * - * @param titleResId Optional title resource ID. If 0 or not provided, uses intKey.titleResId + * @param title Optional title override. If null, uses intKey.title * @param visibilityContext Optional context for evaluating runtime visibility/enabled conditions * * @see AdaptiveListIntPreferencePreview @@ -23,15 +25,12 @@ import app.aaps.core.keys.interfaces.VisibilityContext @Composable fun AdaptiveListIntPreferenceItem( intKey: IntPreferenceKey, - titleResId: Int = 0, + title: TextRef? = null, entries: List, entryValues: List, visibilityContext: VisibilityContext? = null ) { - val effectiveTitleResId = if (titleResId != 0) titleResId else intKey.titleResId - - // Skip if no title resource is available - if (effectiveTitleResId == 0) return + val effectiveTitle = title ?: intKey.title val visibility = calculatePreferenceVisibility( preferenceKey = intKey, @@ -47,13 +46,12 @@ fun AdaptiveListIntPreferenceItem( val currentEntry = entries.getOrElse(currentIndex) { currentValue.toString() } // Get dialog summary from key - val summaryResId = intKey.summaryResId - val dialogSummary = if (summaryResId != null && summaryResId != 0) stringResource(summaryResId) else null + val dialogSummary = stringResourceOrNull(intKey.summary) ListPreference( state = state, values = entryValues, - title = { Text(stringResource(effectiveTitleResId)) }, + title = { Text(stringResource(effectiveTitle)) }, enabled = visibility.enabled, summary = { Text(currentEntry) }, dialogSummary = dialogSummary, @@ -67,20 +65,17 @@ fun AdaptiveListIntPreferenceItem( /** * Composable string list preference for use inside card sections. * - * @param titleResId Optional title resource ID. If 0 or not provided, uses stringKey.titleResId + * @param title Optional title override. If null, uses stringKey.title * @param visibilityContext Optional context for evaluating runtime visibility/enabled conditions */ @Composable fun AdaptiveStringListPreferenceItem( stringKey: StringPreferenceKey, - titleResId: Int = 0, + title: TextRef? = null, entries: Map, visibilityContext: VisibilityContext? = null ) { - val effectiveTitleResId = if (titleResId != 0) titleResId else stringKey.titleResId - - // Skip if no title resource is available - if (effectiveTitleResId == 0) return + val effectiveTitle = title ?: stringKey.title val visibility = calculatePreferenceVisibility( preferenceKey = stringKey, @@ -95,13 +90,12 @@ fun AdaptiveStringListPreferenceItem( val values = entries.keys.toList() // Get dialog summary from key - val summaryResId = stringKey.summaryResId - val dialogSummary = if (summaryResId != null && summaryResId != 0) stringResource(summaryResId) else null + val dialogSummary = stringResourceOrNull(stringKey.summary) ListPreference( state = state, values = values, - title = { PreferenceTitleWithSyncBadge(effectiveTitleResId, stringKey) }, + title = { PreferenceTitleWithSyncBadge(effectiveTitle, stringKey) }, enabled = visibility.enabled, summary = { Text(currentEntry) }, dialogSummary = dialogSummary, diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/preference/AdaptiveListPreferencePreviews.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/preference/AdaptiveListPreferencePreviews.kt similarity index 100% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/preference/AdaptiveListPreferencePreviews.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/preference/AdaptiveListPreferencePreviews.kt diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/preference/AdaptiveMasterPasswordPreference.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/preference/AdaptiveMasterPasswordPreference.kt similarity index 85% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/preference/AdaptiveMasterPasswordPreference.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/preference/AdaptiveMasterPasswordPreference.kt index ea62d8580011..adfe2a980443 100644 --- a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/preference/AdaptiveMasterPasswordPreference.kt +++ b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/preference/AdaptiveMasterPasswordPreference.kt @@ -6,12 +6,12 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue -import androidx.compose.ui.res.stringResource import app.aaps.core.keys.StringKey -import app.aaps.core.ui.R +import app.aaps.core.ui.UiStrings import app.aaps.core.ui.compose.LocalPreferences import app.aaps.core.ui.compose.dialogs.QueryPasswordDialog import app.aaps.core.ui.compose.dialogs.SetPasswordDialog +import app.aaps.core.ui.compose.stringResource /** * Master password preference that requires current password verification before allowing change. @@ -47,9 +47,9 @@ fun AdaptiveMasterPasswordPreferenceItem( val hasPassword = passwordState.isNotEmpty() val summary = if (hasPassword) { - stringResource(R.string.password_set) + stringResource(UiStrings.password_set) } else { - stringResource(R.string.password_not_set) + stringResource(UiStrings.password_not_set) } // Dialog states @@ -58,7 +58,7 @@ fun AdaptiveMasterPasswordPreferenceItem( Preference( title = if (showTitle) { - { Text(stringResource(app.aaps.core.keys.R.string.master_password)) } + { Text(stringResource(StringKey.ProtectionMasterPassword.title)) } } else { { Text(summary) } }, @@ -80,16 +80,16 @@ fun AdaptiveMasterPasswordPreferenceItem( ) // Message strings (resolved here for use in callbacks) - val wrongPasswordMsg = stringResource(R.string.wrongpassword) - val dontMatchMsg = stringResource(R.string.passwords_dont_match) - val passwordSetMsg = stringResource(R.string.password_set) - val passwordClearedMsg = stringResource(R.string.password_cleared) - val notChangedMsg = stringResource(R.string.password_not_changed) + val wrongPasswordMsg = stringResource(UiStrings.wrongpassword) + val dontMatchMsg = stringResource(UiStrings.passwords_dont_match) + val passwordSetMsg = stringResource(UiStrings.password_set) + val passwordClearedMsg = stringResource(UiStrings.password_cleared) + val notChangedMsg = stringResource(UiStrings.password_not_changed) // Query current password dialog if (showQueryDialog) { QueryPasswordDialog( - title = stringResource(R.string.current_master_password), + title = stringResource(UiStrings.current_master_password), pinInput = false, onConfirm = { enteredPassword -> if (checkPassword(enteredPassword, passwordState)) { @@ -106,7 +106,7 @@ fun AdaptiveMasterPasswordPreferenceItem( // Set new password dialog if (showSetDialog) { SetPasswordDialog( - title = stringResource(app.aaps.core.keys.R.string.master_password), + title = stringResource(StringKey.ProtectionMasterPassword.title), pinInput = false, onConfirm = { password1, password2 -> when { diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/preference/AdaptiveMasterPasswordPreferencePreviews.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/preference/AdaptiveMasterPasswordPreferencePreviews.kt similarity index 100% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/preference/AdaptiveMasterPasswordPreferencePreviews.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/preference/AdaptiveMasterPasswordPreferencePreviews.kt diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/preference/AdaptivePasswordPreference.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/preference/AdaptivePasswordPreference.kt similarity index 80% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/preference/AdaptivePasswordPreference.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/preference/AdaptivePasswordPreference.kt index 3d09d0e37d06..3c38b161f9bb 100644 --- a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/preference/AdaptivePasswordPreference.kt +++ b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/preference/AdaptivePasswordPreference.kt @@ -10,13 +10,14 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue -import androidx.compose.ui.res.stringResource import app.aaps.core.keys.interfaces.IntPreferenceKey import app.aaps.core.keys.interfaces.StringPreferenceKey +import app.aaps.core.keys.interfaces.TextRef import app.aaps.core.keys.interfaces.VisibilityContext -import app.aaps.core.ui.R +import app.aaps.core.ui.UiStrings import app.aaps.core.ui.compose.LocalPreferences import app.aaps.core.ui.compose.dialogs.SetPasswordDialog +import app.aaps.core.ui.compose.stringResource /** * Composable password/PIN preference that opens a dialog to set the password. @@ -25,7 +26,7 @@ import app.aaps.core.ui.compose.dialogs.SetPasswordDialog * @param preferences The Preferences instance * @param stringKey The StringPreferenceKey (should have isPassword=true or isPin=true) * @param hashPassword Function to hash the password before storing - * @param titleResId Optional title resource ID. If 0, uses stringKey.titleResId + * @param titleResId Optional title resource ID. If 0, uses stringKey.title * @param visibilityKey Optional IntPreferenceKey that controls visibility * @param visibilityValue The value that visibilityKey must equal for this preference to be visible * @param visibilityContext Optional context for evaluating runtime visibility conditions @@ -37,16 +38,13 @@ fun AdaptivePasswordPreferenceItem( stringKey: StringPreferenceKey, hashPassword: (String) -> String, onShowMessage: (String) -> Unit, - titleResId: Int = 0, + title: TextRef? = null, visibilityKey: IntPreferenceKey? = null, visibilityValue: Int? = null, visibilityContext: VisibilityContext? = null ) { val preferences = LocalPreferences.current - val effectiveTitleResId = if (titleResId != 0) titleResId else stringKey.titleResId - - // Skip if no title resource is available - if (effectiveTitleResId == 0) return + val effectiveTitle = title ?: stringKey.title // Check conditional visibility based on visibilityKey if (visibilityKey != null && visibilityValue != null) { @@ -71,12 +69,12 @@ fun AdaptivePasswordPreferenceItem( val summary = when { hasValue -> "••••••••" - isPin -> stringResource(R.string.pin_not_set) - else -> stringResource(R.string.password_not_set) + isPin -> stringResource(UiStrings.pin_not_set) + else -> stringResource(UiStrings.password_not_set) } Preference( - title = { Text(stringResource(effectiveTitleResId)) }, + title = { Text(stringResource(effectiveTitle)) }, summary = { Text(summary) }, enabled = visibility.enabled, onClick = if (visibility.enabled) { @@ -85,13 +83,13 @@ fun AdaptivePasswordPreferenceItem( ) if (showDialog) { - val dontMatchMsg = stringResource(if (isPin) R.string.pin_dont_match else R.string.passwords_dont_match) - val setMsg = stringResource(if (isPin) R.string.pin_set else R.string.password_set) - val clearedMsg = stringResource(if (isPin) R.string.pin_cleared else R.string.password_cleared) - val notChangedMsg = stringResource(if (isPin) R.string.pin_not_changed else R.string.password_not_changed) + val dontMatchMsg = stringResource(if (isPin) UiStrings.pin_dont_match else UiStrings.passwords_dont_match) + val setMsg = stringResource(if (isPin) UiStrings.pin_set else UiStrings.password_set) + val clearedMsg = stringResource(if (isPin) UiStrings.pin_cleared else UiStrings.password_cleared) + val notChangedMsg = stringResource(if (isPin) UiStrings.pin_not_changed else UiStrings.password_not_changed) SetPasswordDialog( - title = stringResource(effectiveTitleResId), + title = stringResource(effectiveTitle), pinInput = isPin, onConfirm = { password1, password2 -> when { diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/preference/AdaptivePasswordPreferencePreviews.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/preference/AdaptivePasswordPreferencePreviews.kt similarity index 100% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/preference/AdaptivePasswordPreferencePreviews.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/preference/AdaptivePasswordPreferencePreviews.kt diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/preference/AdaptivePreferenceItem.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/preference/AdaptivePreferenceItem.kt similarity index 76% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/preference/AdaptivePreferenceItem.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/preference/AdaptivePreferenceItem.kt index 24d4827ad659..9109b36881df 100644 --- a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/preference/AdaptivePreferenceItem.kt +++ b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/preference/AdaptivePreferenceItem.kt @@ -8,9 +8,6 @@ package app.aaps.core.ui.compose.preference import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.remember -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.res.stringResource - import app.aaps.core.keys.PreferenceType import app.aaps.core.keys.StringKey import app.aaps.core.keys.interfaces.BooleanPreferenceKey @@ -18,23 +15,24 @@ import app.aaps.core.keys.interfaces.DoublePreferenceKey import app.aaps.core.keys.interfaces.IntPreferenceKey import app.aaps.core.keys.interfaces.IntentPreferenceKey import app.aaps.core.keys.interfaces.PreferenceKey -import app.aaps.core.keys.interfaces.VisibilityContext import app.aaps.core.keys.interfaces.StringKeyWithEntriesProvider import app.aaps.core.keys.interfaces.StringPreferenceKey +import app.aaps.core.keys.interfaces.TextRef import app.aaps.core.keys.interfaces.UnitDoublePreferenceKey +import app.aaps.core.keys.interfaces.VisibilityContext import app.aaps.core.ui.compose.ComposeScreenContent +import app.aaps.core.ui.compose.stringResource /** * Renders a preference based on its PreferenceKey type and preferenceType. * Automatically selects the appropriate composable. * * For LIST types, loads entries from resources using entriesResId/entryValuesResId. - * For URL/ACTIVITY types on IntentPreferenceKey, requires additional parameters. + * For URL types on IntentPreferenceKey, requires additional parameters. * * @param key The PreferenceKey to render * @param onIntentClick Optional click handler for IntentPreferenceKey with CLICK type * @param intentUrl Optional URL for IntentPreferenceKey with URL type - * @param intentActivityClass Optional Activity class for IntentPreferenceKey with ACTIVITY type */ @Composable fun AdaptivePreferenceItem( @@ -42,8 +40,7 @@ fun AdaptivePreferenceItem( onShowMessage: (String) -> Unit, visibilityContext: VisibilityContext? = null, onIntentClick: (() -> Unit)? = null, - intentUrl: String? = null, - intentActivityClass: Class<*>? = null + intentUrl: String? = null ) { when (key) { is BooleanPreferenceKey -> { @@ -57,19 +54,8 @@ fun AdaptivePreferenceItem( is IntPreferenceKey -> { when (key.preferenceType) { PreferenceType.LIST -> { - // Check for runtime-resolved entries first (from withEntries) - val resolved = key.resolvedEntries - if (resolved != null) { + if (key.entries.isNotEmpty()) { AdaptiveListIntPreferenceItem( - - intKey = key, - entries = resolved.values.toList(), - entryValues = resolved.keys.toList(), - visibilityContext = visibilityContext - ) - } else if (key.entries.isNotEmpty()) { - AdaptiveListIntPreferenceItem( - intKey = key, entries = key.entries.values.map { stringResource(it) }, entryValues = key.entries.keys.toList(), @@ -106,10 +92,10 @@ fun AdaptivePreferenceItem( } is StringKeyWithEntriesProvider -> { - // Handle context-dependent entries provider - val context = LocalContext.current - val entries = remember(key) { key.entriesProvider(context) } - val emptyMessageResId = key.emptyEntriesMessageResId + // Entries are only known at run time, so the provider runs here and its labels are + // resolved in composable scope. + val entries = remember(key) { key.entriesProvider() }.mapValues { stringResource(it.value) } + val emptyMessage = key.emptyEntriesMessage if (entries.isNotEmpty()) { AdaptiveStringListPreferenceItem( @@ -117,11 +103,11 @@ fun AdaptivePreferenceItem( entries = entries, visibilityContext = visibilityContext ) - } else if (emptyMessageResId != null) { + } else if (emptyMessage != null) { // Show disabled preference with empty message Preference( - title = { Text(stringResource(key.titleResId)) }, - summary = { Text(stringResource(emptyMessageResId)) }, + title = { Text(stringResource(key.title)) }, + summary = { Text(stringResource(emptyMessage)) }, enabled = false ) } @@ -152,15 +138,10 @@ fun AdaptivePreferenceItem( } else { when (key.preferenceType) { PreferenceType.LIST -> { - // Check for runtime-resolved entries first (from withEntries) - val entriesMap = key.resolvedEntries - ?: key.entries.takeIf { it.isNotEmpty() }?.mapValues { (_, resId) -> stringResource(resId) } - - if (entriesMap != null) { + if (key.entries.isNotEmpty()) { AdaptiveStringListPreferenceItem( - stringKey = key, - entries = entriesMap, + entries = key.entries.mapValues { (_, ref) -> stringResource(ref) }, visibilityContext = visibilityContext ) } @@ -193,15 +174,14 @@ fun AdaptivePreferenceItem( } is IntentPreferenceKey -> { - // Priority: 1) runtime click 2) compose screen 3) activity 4) url + // Priority: 1) runtime click 2) compose screen 3) url val resolvedClick = key.onClick ?: onIntentClick val resolvedCompose = key.composeScreen as? ComposeScreenContent val onNavigateToCompose = LocalNavigateToCompose.current - val resolvedActivity = key.runtimeActivityClass ?: intentActivityClass ?: key.activityClass - val resolvedUrl = key.runtimeUrl ?: intentUrl ?: key.urlResId?.let { stringResource(it) } + val resolvedUrl = key.runtimeUrl ?: intentUrl ?: key.urlResId?.let { stringResource(TextRef.AndroidRes(it)) } when { - resolvedClick != null -> { + resolvedClick != null -> { AdaptiveIntentPreferenceItem( intentKey = key, @@ -210,7 +190,7 @@ fun AdaptivePreferenceItem( ) } - resolvedCompose != null && onNavigateToCompose != null -> { + resolvedCompose != null && onNavigateToCompose != null -> { AdaptiveComposeScreenPreferenceItem( intentKey = key, composeScreen = resolvedCompose, @@ -219,16 +199,7 @@ fun AdaptivePreferenceItem( ) } - resolvedActivity != null -> { - AdaptiveDynamicActivityPreferenceItem( - - intentKey = key, - activityClass = resolvedActivity, - visibilityContext = visibilityContext - ) - } - - resolvedUrl != null -> { + resolvedUrl != null -> { AdaptiveUrlPreferenceItem( intentKey = key, diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/preference/AdaptivePreferenceList.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/preference/AdaptivePreferenceList.kt similarity index 99% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/preference/AdaptivePreferenceList.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/preference/AdaptivePreferenceList.kt index be0b5411a4a4..e0941f256135 100644 --- a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/preference/AdaptivePreferenceList.kt +++ b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/preference/AdaptivePreferenceList.kt @@ -6,7 +6,6 @@ package app.aaps.core.ui.compose.preference import androidx.compose.runtime.Composable - import app.aaps.core.keys.interfaces.PreferenceItem import app.aaps.core.keys.interfaces.PreferenceKey import app.aaps.core.keys.interfaces.VisibilityContext diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/preference/AdaptiveStringPreference.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/preference/AdaptiveStringPreference.kt similarity index 76% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/preference/AdaptiveStringPreference.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/preference/AdaptiveStringPreference.kt index aa592380007f..5db909152905 100644 --- a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/preference/AdaptiveStringPreference.kt +++ b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/preference/AdaptiveStringPreference.kt @@ -11,17 +11,19 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.Modifier -import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.input.TextFieldValue import app.aaps.core.keys.interfaces.StringPreferenceKey import app.aaps.core.keys.interfaces.StringValidator +import app.aaps.core.keys.interfaces.TextRef import app.aaps.core.keys.interfaces.VisibilityContext +import app.aaps.core.ui.UiStrings +import app.aaps.core.ui.compose.stringResource /** * Composable string preference for use inside card sections. * - * @param titleResId Optional title resource ID. If 0 or not provided, uses stringKey.titleResId - * @param summaryResId Optional summary resource ID. If null, uses stringKey.summaryResId + * @param title Optional title override. If null, uses stringKey.title + * @param summary Optional summary override. If null, uses stringKey.summary * @param visibilityContext Optional context for evaluating runtime visibility/enabled conditions * * @see AdaptiveStringPreferencePreview @@ -29,16 +31,13 @@ import app.aaps.core.keys.interfaces.VisibilityContext @Composable fun AdaptiveStringPreferenceItem( stringKey: StringPreferenceKey, - titleResId: Int = 0, - summaryResId: Int? = null, + title: TextRef? = null, + summary: TextRef? = null, isPassword: Boolean = false, visibilityContext: VisibilityContext? = null ) { - val effectiveTitleResId = if (titleResId != 0) titleResId else stringKey.titleResId - val effectiveSummaryResId = summaryResId ?: stringKey.summaryResId - - // Skip if no title resource is available - if (effectiveTitleResId == 0) return + val effectiveTitle = title ?: stringKey.title + val effectiveSummary = summary ?: stringKey.summary val visibility = calculatePreferenceVisibility( preferenceKey = stringKey, @@ -53,11 +52,11 @@ fun AdaptiveStringPreferenceItem( val isSecure = isPassword || stringKey.isPassword || stringKey.isPin // Get dialog summary from key - val dialogSummary = if (effectiveSummaryResId != null) stringResource(effectiveSummaryResId) else null + val dialogSummary = if (effectiveSummary != null) stringResource(effectiveSummary) else null TextFieldPreference( state = state, - title = { Text(stringResource(effectiveTitleResId)) }, + title = { Text(stringResource(effectiveTitle)) }, textToValue = { text -> val result = validator.validate(text) if (result.isValid) text else null @@ -69,16 +68,16 @@ fun AdaptiveStringPreferenceItem( } isSecure && value.isEmpty() -> { - val notSetResId = if (stringKey.isPin) app.aaps.core.ui.R.string.pin_not_set else app.aaps.core.ui.R.string.password_not_set - { Text(stringResource(effectiveSummaryResId ?: notSetResId)) } + val notSetResId = if (stringKey.isPin) UiStrings.pin_not_set else UiStrings.password_not_set + { Text(stringResource(effectiveSummary ?: notSetResId)) } } value.isNotEmpty() -> { { Text(value) } } - effectiveSummaryResId != null -> { - { Text(stringResource(effectiveSummaryResId)) } + effectiveSummary != null -> { + { Text(stringResource(effectiveSummary)) } } else -> null diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/preference/AdaptiveStringPreferencePreviews.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/preference/AdaptiveStringPreferencePreviews.kt similarity index 100% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/preference/AdaptiveStringPreferencePreviews.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/preference/AdaptiveStringPreferencePreviews.kt diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/preference/AdaptiveSwitchPreference.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/preference/AdaptiveSwitchPreference.kt similarity index 68% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/preference/AdaptiveSwitchPreference.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/preference/AdaptiveSwitchPreference.kt index df6084899581..21ba1640b9a9 100644 --- a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/preference/AdaptiveSwitchPreference.kt +++ b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/preference/AdaptiveSwitchPreference.kt @@ -10,18 +10,19 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue -import androidx.compose.ui.res.stringResource import app.aaps.core.keys.interfaces.BooleanKeyWithChangeGuard import app.aaps.core.keys.interfaces.BooleanPreferenceKey +import app.aaps.core.keys.interfaces.TextRef import app.aaps.core.keys.interfaces.VisibilityContext -import app.aaps.core.ui.R +import app.aaps.core.ui.UiStrings import app.aaps.core.ui.compose.dialogs.OkDialog +import app.aaps.core.ui.compose.stringResource /** * Composable switch preference for use inside card sections. * - * @param titleResId Optional title resource ID. If 0 or not provided, uses booleanKey.titleResId - * @param summaryResId Optional summary resource ID. If null, uses booleanKey.summaryResId + * @param title Optional title override. If null, uses booleanKey.title + * @param summary Optional summary override. If null, uses booleanKey.summary * @param visibilityContext Optional context for evaluating runtime visibility/enabled conditions * * @see AdaptiveSwitchPreferencePreview @@ -29,17 +30,14 @@ import app.aaps.core.ui.compose.dialogs.OkDialog @Composable fun AdaptiveSwitchPreferenceItem( booleanKey: BooleanPreferenceKey, - titleResId: Int = 0, - summaryResId: Int? = null, - summaryOnResId: Int? = null, - summaryOffResId: Int? = null, + title: TextRef? = null, + summary: TextRef? = null, + summaryOn: TextRef? = null, + summaryOff: TextRef? = null, visibilityContext: VisibilityContext? = null ) { - val effectiveTitleResId = if (titleResId != 0) titleResId else booleanKey.titleResId - val effectiveSummaryResId = summaryResId ?: booleanKey.summaryResId - - // Skip if no title resource is available - if (effectiveTitleResId == 0) return + val effectiveTitle = title ?: booleanKey.title + val effectiveSummary = summary ?: booleanKey.summary val visibility = calculatePreferenceVisibility( preferenceKey = booleanKey, @@ -55,15 +53,15 @@ fun AdaptiveSwitchPreferenceItem( var guardMessage by remember { mutableStateOf(null) } val summary: @Composable (() -> Unit)? = when { - summaryOnResId != null && summaryOffResId != null -> { - { Text(stringResource(if (state.value) summaryOnResId else summaryOffResId)) } + summaryOn != null && summaryOff != null -> { + { Text(stringResource(if (state.value) summaryOn else summaryOff)) } } - effectiveSummaryResId != null -> { - { Text(stringResource(effectiveSummaryResId)) } + effectiveSummary != null -> { + { Text(stringResource(effectiveSummary)) } } - else -> null + else -> null } if (changeGuard != null) { @@ -77,14 +75,14 @@ fun AdaptiveSwitchPreferenceItem( guardMessage = message } }, - title = { PreferenceTitleWithSyncBadge(effectiveTitleResId, booleanKey) }, + title = { PreferenceTitleWithSyncBadge(effectiveTitle, booleanKey) }, summary = summary, enabled = visibility.enabled ) } else { SwitchPreference( state = state, - title = { PreferenceTitleWithSyncBadge(effectiveTitleResId, booleanKey) }, + title = { PreferenceTitleWithSyncBadge(effectiveTitle, booleanKey) }, summary = summary, enabled = visibility.enabled ) @@ -93,7 +91,7 @@ fun AdaptiveSwitchPreferenceItem( // Show guard rejection dialog guardMessage?.let { message -> OkDialog( - title = stringResource(R.string.error), + title = stringResource(UiStrings.error), message = message, onDismiss = { guardMessage = null } ) diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/preference/AdaptiveSwitchPreferencePreviews.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/preference/AdaptiveSwitchPreferencePreviews.kt similarity index 100% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/preference/AdaptiveSwitchPreferencePreviews.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/preference/AdaptiveSwitchPreferencePreviews.kt diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/preference/AdaptiveUnitDoublePreference.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/preference/AdaptiveUnitDoublePreference.kt similarity index 80% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/preference/AdaptiveUnitDoublePreference.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/preference/AdaptiveUnitDoublePreference.kt index 402e0f2499c0..9d68cdd44b77 100644 --- a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/preference/AdaptiveUnitDoublePreference.kt +++ b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/preference/AdaptiveUnitDoublePreference.kt @@ -10,36 +10,34 @@ import androidx.compose.foundation.layout.padding import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier -import androidx.compose.ui.res.stringResource import app.aaps.core.data.format.NumberFormat -import app.aaps.core.keys.interfaces.VisibilityContext +import app.aaps.core.data.format.NumberFormatPlatform +import app.aaps.core.keys.interfaces.TextRef import app.aaps.core.keys.interfaces.UnitDoublePreferenceKey +import app.aaps.core.keys.interfaces.VisibilityContext +import app.aaps.core.ui.UiStrings import app.aaps.core.ui.compose.LocalPreferences import app.aaps.core.ui.compose.LocalProfileUtil -import java.math.BigDecimal -import java.math.RoundingMode +import app.aaps.core.ui.compose.stringResource +import app.aaps.core.ui.compose.stringResourceOrNull import kotlin.math.abs -import app.aaps.core.ui.R as UiR /** * Composable unit double preference for use inside card sections. * Handles glucose unit conversion (mg/dL <-> mmol/L). * - * @param titleResId Optional title resource ID. If 0 or not provided, uses unitKey.titleResId + * @param title Optional title override. If null, uses unitKey.title * @param visibilityContext Optional context for evaluating runtime visibility/enabled conditions */ @Composable fun AdaptiveUnitDoublePreferenceItem( unitKey: UnitDoublePreferenceKey, - titleResId: Int = 0, + title: TextRef? = null, visibilityContext: VisibilityContext? = null ) { val preferences = LocalPreferences.current val profileUtil = LocalProfileUtil.current - val effectiveTitleResId = if (titleResId != 0) titleResId else unitKey.titleResId - - // Skip if no title resource is available - if (effectiveTitleResId == 0) return + val effectiveTitle = title ?: unitKey.title val visibility = calculatePreferenceVisibility( preferenceKey = unitKey, @@ -64,11 +62,10 @@ fun AdaptiveUnitDoublePreferenceItem( val valueFormat = if (isMgdl) NumberFormat.INTEGER else NumberFormat.DECIMAL_1 // Get unit label from resources - short form for slider - val unitLabel = stringResource(if (isMgdl) UiR.string.mgdl else UiR.string.mmol) + val unitLabel = if (isMgdl) UiStrings.mgdl else UiStrings.mmol // Get summary if available - val summaryResId = unitKey.summaryResId - val summary = if (summaryResId != null && summaryResId != 0) stringResource(summaryResId) else null + val summary = stringResourceOrNull(unitKey.summary) // Parse current display value to Double val currentValue = state.displayValue.toDoubleOrNull() ?: minDisplay @@ -79,7 +76,7 @@ fun AdaptiveUnitDoublePreferenceItem( .padding(theme.padding) ) { TextWithSyncBadge( - text = stringResource(effectiveTitleResId), + text = stringResource(effectiveTitle), key = unitKey, style = theme.titleTextStyle, // Mirror Preference's disabled styling (the switch row greys the same way) since this @@ -98,7 +95,7 @@ fun AdaptiveUnitDoublePreferenceItem( onValueChange = { newValue -> if (visibility.enabled) { // Format with appropriate precision and update state - val formatted = BigDecimal(newValue).setScale(decimalPlaces, RoundingMode.HALF_UP).toPlainString() + val formatted = NumberFormat.withDecimalsHalfUp(decimalPlaces).format(newValue, NumberFormatPlatform.SEPARATOR_DOT) state.updateDisplayValue(formatted) } }, @@ -107,7 +104,7 @@ fun AdaptiveUnitDoublePreferenceItem( showValue = true, valueFormat = valueFormat, unitLabel = unitLabel, - dialogLabel = stringResource(effectiveTitleResId), + dialogLabel = stringResource(effectiveTitle), dialogSummary = summary, enabled = visibility.enabled ) diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/preference/BasicPreference.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/preference/BasicPreference.kt similarity index 100% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/preference/BasicPreference.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/preference/BasicPreference.kt diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/preference/ClickablePreferenceCategoryHeader.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/preference/ClickablePreferenceCategoryHeader.kt similarity index 92% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/preference/ClickablePreferenceCategoryHeader.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/preference/ClickablePreferenceCategoryHeader.kt index 251bd2c8a325..e7df7bf46264 100644 --- a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/preference/ClickablePreferenceCategoryHeader.kt +++ b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/preference/ClickablePreferenceCategoryHeader.kt @@ -40,9 +40,11 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.vector.ImageVector -import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp +import app.aaps.core.keys.interfaces.TextRef +import app.aaps.core.ui.UiStrings +import app.aaps.core.ui.compose.stringResource /** * Internal composable for clickable category header with expand/collapse icon. @@ -52,8 +54,8 @@ import androidx.compose.ui.unit.dp */ @Composable internal fun ClickablePreferenceCategoryHeader( - titleResId: Int, - summaryItems: List = emptyList(), + title: TextRef, + summaryItems: List = emptyList(), expanded: Boolean, onToggle: () -> Unit, modifier: Modifier = Modifier, @@ -67,7 +69,7 @@ internal fun ClickablePreferenceCategoryHeader( label = "expandIconRotation" ) - // Build summary text from list of resource IDs — resolve each string in composable context + // Build summary text from the child titles — resolve each one in composable context val resolvedSummaries = summaryItems.map { stringResource(it) } val summaryText = if (resolvedSummaries.isNotEmpty()) { resolvedSummaries.joinToString(", ") @@ -107,7 +109,7 @@ internal fun ClickablePreferenceCategoryHeader( } Column(modifier = Modifier.weight(1f)) { ProvideTextStyle(value = theme.categoryTextStyle) { - Text(text = stringResource(titleResId)) + Text(text = stringResource(title)) } // Show summary when collapsed if (!expanded && summaryText != null) { @@ -124,7 +126,7 @@ internal fun ClickablePreferenceCategoryHeader( if (collapsible) { Icon( imageVector = Icons.Default.ExpandMore, - contentDescription = stringResource(if (expanded) app.aaps.core.ui.R.string.collapse else app.aaps.core.ui.R.string.expand), + contentDescription = stringResource(if (expanded) UiStrings.collapse else UiStrings.expand), modifier = Modifier .size(theme.expandIconSize) .graphicsLayer { rotationZ = rotationAngle.value } diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/preference/CollapsibleCardSectionContent.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/preference/CollapsibleCardSectionContent.kt similarity index 95% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/preference/CollapsibleCardSectionContent.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/preference/CollapsibleCardSectionContent.kt index 7f61b935a980..86c2f6a39cd4 100644 --- a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/preference/CollapsibleCardSectionContent.kt +++ b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/preference/CollapsibleCardSectionContent.kt @@ -29,6 +29,7 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.vector.ImageVector +import app.aaps.core.keys.interfaces.TextRef /** * Composable for a collapsible card section. @@ -39,8 +40,8 @@ import androidx.compose.ui.graphics.vector.ImageVector */ @Composable fun CollapsibleCardSectionContent( - titleResId: Int, - summaryItems: List = emptyList(), + title: TextRef, + summaryItems: List = emptyList(), expanded: Boolean, onToggle: () -> Unit, icon: ImageVector? = null, @@ -59,7 +60,7 @@ fun CollapsibleCardSectionContent( ) { Column { ClickablePreferenceCategoryHeader( - titleResId = titleResId, + title = title, summaryItems = summaryItems, expanded = expanded, onToggle = onToggle, diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/preference/CollapsibleCardSectionContentPreviews.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/preference/CollapsibleCardSectionContentPreviews.kt similarity index 87% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/preference/CollapsibleCardSectionContentPreviews.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/preference/CollapsibleCardSectionContentPreviews.kt index 32aea761d735..59eeccb6d0e5 100644 --- a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/preference/CollapsibleCardSectionContentPreviews.kt +++ b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/preference/CollapsibleCardSectionContentPreviews.kt @@ -6,14 +6,14 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -import app.aaps.core.ui.R +import app.aaps.core.ui.UiStrings @Preview(showBackground = true) @Composable internal fun CollapsibleCardSectionContentPreview() { PreviewTheme { CollapsibleCardSectionContent( - titleResId = R.string.configbuilder_insulin, + title = UiStrings.configbuilder_insulin, expanded = true, onToggle = {} ) { diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/preference/InlinePreferenceItems.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/preference/InlinePreferenceItems.kt similarity index 85% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/preference/InlinePreferenceItems.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/preference/InlinePreferenceItems.kt index 55d17e6c4cc6..e4fb839efc8a 100644 --- a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/preference/InlinePreferenceItems.kt +++ b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/preference/InlinePreferenceItems.kt @@ -14,11 +14,12 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.Role import app.aaps.core.keys.interfaces.StringPreferenceKey import app.aaps.core.keys.interfaces.StringValidator +import app.aaps.core.keys.interfaces.TextRef import app.aaps.core.ui.compose.AapsSpacing +import app.aaps.core.ui.compose.stringResource /** * Inline text field for string preferences — for use in wizards and forms @@ -29,9 +30,9 @@ import app.aaps.core.ui.compose.AapsSpacing @Composable fun InlineStringPreferenceItem( stringKey: StringPreferenceKey, - titleResId: Int = 0 + title: TextRef? = null ) { - val effectiveTitleResId = if (titleResId != 0) titleResId else stringKey.titleResId + val effectiveTitle = title ?: stringKey.title val state = rememberPreferenceStringState(stringKey) val validator = stringKey.validator val text = state.value @@ -46,9 +47,7 @@ fun InlineStringPreferenceItem( onValueChange = { newValue -> state.value = newValue }, - label = if (effectiveTitleResId != 0) { - { Text(stringResource(effectiveTitleResId)) } - } else null, + label = { Text(stringResource(effectiveTitle)) }, isError = !validationResult.isValid, supportingText = if (!validationResult.isValid) { { Text(validationResult.errorMessage ?: "") } @@ -67,19 +66,17 @@ fun InlineStringPreferenceItem( @Composable fun InlineStringListPreferenceItem( stringKey: StringPreferenceKey, - titleResId: Int = 0, + title: TextRef? = null, entries: Map ) { - val effectiveTitleResId = if (titleResId != 0) titleResId else stringKey.titleResId + val effectiveTitle = title ?: stringKey.title val state = rememberPreferenceStringState(stringKey) val selectedValue = state.value - if (effectiveTitleResId != 0) { - Text( - text = stringResource(effectiveTitleResId), - style = MaterialTheme.typography.titleMedium - ) - } + Text( + text = stringResource(effectiveTitle), + style = MaterialTheme.typography.titleMedium + ) Column(modifier = Modifier.selectableGroup()) { entries.forEach { (value, label) -> diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/preference/ListPreference.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/preference/ListPreference.kt similarity index 98% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/preference/ListPreference.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/preference/ListPreference.kt index 56754be887a7..403cf0dbbfb9 100644 --- a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/preference/ListPreference.kt +++ b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/preference/ListPreference.kt @@ -45,10 +45,11 @@ import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.Role import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.unit.dp +import app.aaps.core.ui.UiStrings +import app.aaps.core.ui.compose.stringResource enum class ListPreferenceType { ALERT_DIALOG, @@ -115,7 +116,7 @@ fun ListPreference( title = title, buttons = { TextButton(onClick = { openSelector = false }) { - Text(text = stringResource(android.R.string.cancel)) + Text(text = stringResource(UiStrings.cancel)) } }, ) { diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/preference/ListPreferencePreviews.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/preference/ListPreferencePreviews.kt similarity index 100% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/preference/ListPreferencePreviews.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/preference/ListPreferencePreviews.kt diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/preference/LocalPasswordCheck.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/preference/LocalPasswordCheck.kt similarity index 100% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/preference/LocalPasswordCheck.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/preference/LocalPasswordCheck.kt diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/preference/PaddingValuesExtensions.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/preference/PaddingValuesExtensions.kt similarity index 100% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/preference/PaddingValuesExtensions.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/preference/PaddingValuesExtensions.kt diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/preference/PluginPreferencesScreen.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/preference/PluginPreferencesScreen.kt similarity index 89% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/preference/PluginPreferencesScreen.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/preference/PluginPreferencesScreen.kt index 4d475c040c3d..03b6f03d3ad8 100644 --- a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/preference/PluginPreferencesScreen.kt +++ b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/preference/PluginPreferencesScreen.kt @@ -1,6 +1,5 @@ package app.aaps.core.ui.compose.preference -import androidx.activity.compose.BackHandler import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding @@ -23,14 +22,18 @@ import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.res.stringResource +import androidx.navigationevent.NavigationEventInfo +import androidx.navigationevent.compose.NavigationBackHandler +import androidx.navigationevent.compose.rememberNavigationEventState import app.aaps.core.interfaces.plugin.PluginBase import app.aaps.core.keys.interfaces.VisibilityContext +import app.aaps.core.ui.UiStrings import app.aaps.core.ui.compose.AapsTopAppBar import app.aaps.core.ui.compose.ComposeScreenContent import app.aaps.core.ui.compose.LocalSnackbarHostState import app.aaps.core.ui.compose.MasterOfflineBanner import app.aaps.core.ui.compose.masterEditingEnabled +import app.aaps.core.ui.compose.stringResource import kotlinx.coroutines.launch /** @@ -53,9 +56,13 @@ fun PluginPreferencesScreen( // State for inline Compose screen navigation var composeScreen: ComposeScreenContent? by remember { mutableStateOf(null) } - BackHandler(enabled = composeScreen != null) { - composeScreen = null - } + // Back closes the inline sub-screen instead of leaving the preferences screen. + // The sub-screen is a single level, so the handler carries no navigation info. + NavigationBackHandler( + state = rememberNavigationEventState(NavigationEventInfo.None), + isBackEnabled = composeScreen != null, + onBackCompleted = { composeScreen = null } + ) // If a compose sub-screen is active, render it instead of preferences composeScreen?.let { screen -> @@ -88,7 +95,7 @@ fun PluginPreferencesScreen( IconButton(onClick = onBackClick) { Icon( imageVector = Icons.AutoMirrored.Filled.ArrowBack, - contentDescription = stringResource(app.aaps.core.ui.R.string.back) + contentDescription = stringResource(UiStrings.back) ) } } @@ -103,7 +110,7 @@ fun PluginPreferencesScreen( contentAlignment = Alignment.Center ) { Text( - text = stringResource(app.aaps.core.ui.R.string.no_compose_preferences), + text = stringResource(UiStrings.no_compose_preferences), style = MaterialTheme.typography.bodyMedium ) } @@ -145,7 +152,7 @@ private fun SinglePluginPreferencesRenderer( IconButton(onClick = onBackClick) { Icon( imageVector = Icons.AutoMirrored.Filled.ArrowBack, - contentDescription = stringResource(app.aaps.core.ui.R.string.back) + contentDescription = stringResource(UiStrings.back) ) } } diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/preference/Preference.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/preference/Preference.kt similarity index 100% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/preference/Preference.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/preference/Preference.kt diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/preference/PreferenceAlertDialog.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/preference/PreferenceAlertDialog.kt similarity index 97% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/preference/PreferenceAlertDialog.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/preference/PreferenceAlertDialog.kt index 0fc03f6ba8f1..342677172945 100644 --- a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/preference/PreferenceAlertDialog.kt +++ b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/preference/PreferenceAlertDialog.kt @@ -72,9 +72,11 @@ internal fun PreferenceAlertDialog( title() } } - Box(modifier = Modifier - .fillMaxWidth() - .weight(1f, fill = false)) { content() } + Box( + modifier = Modifier + .fillMaxWidth() + .weight(1f, fill = false) + ) { content() } ProvideContentColorTextStyle( contentColor = MaterialTheme.colorScheme.primary, textStyle = MaterialTheme.typography.labelLarge, diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/preference/PreferenceCategory.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/preference/PreferenceCategory.kt similarity index 100% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/preference/PreferenceCategory.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/preference/PreferenceCategory.kt diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/preference/PreferenceCategoryPreviews.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/preference/PreferenceCategoryPreviews.kt similarity index 100% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/preference/PreferenceCategoryPreviews.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/preference/PreferenceCategoryPreviews.kt diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/preference/PreferenceContentExtensions.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/preference/PreferenceContentExtensions.kt similarity index 98% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/preference/PreferenceContentExtensions.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/preference/PreferenceContentExtensions.kt index af0a9dbdee71..8fe1ad94575e 100644 --- a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/preference/PreferenceContentExtensions.kt +++ b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/preference/PreferenceContentExtensions.kt @@ -58,7 +58,7 @@ fun LazyListScope.addPreferenceSubScreenDef( // Get visibility context from CompositionLocal val visibilityContext = LocalVisibilityContext.current CollapsibleCardSectionContent( - titleResId = def.titleResId, + title = def.title, summaryItems = def.effectiveSummaryItems(), expanded = isExpanded, onToggle = { sectionState?.toggle(sectionKey, SectionLevel.TOP_LEVEL) }, @@ -112,7 +112,7 @@ private fun RenderPreferenceItems( // Header without card (no icon for nested subscreens) ClickablePreferenceCategoryHeader( - titleResId = item.titleResId, + title = item.title, summaryItems = item.effectiveSummaryItems(), expanded = isSubExpanded, onToggle = { sectionState?.toggle(subSectionKey, SectionLevel.SUB_SECTION, parentKey = parentKey) }, diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/preference/PreferencePreviews.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/preference/PreferencePreviews.kt similarity index 100% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/preference/PreferencePreviews.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/preference/PreferencePreviews.kt diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/preference/PreferenceScreenContent.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/preference/PreferenceScreenContent.kt similarity index 79% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/preference/PreferenceScreenContent.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/preference/PreferenceScreenContent.kt index 32bb317032ec..af4851776e44 100644 --- a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/preference/PreferenceScreenContent.kt +++ b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/preference/PreferenceScreenContent.kt @@ -1,9 +1,9 @@ package app.aaps.core.ui.compose.preference -import android.os.Bundle import androidx.compose.runtime.Composable import androidx.compose.runtime.mutableStateMapOf import androidx.compose.runtime.saveable.Saver +import androidx.compose.runtime.saveable.listSaver import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.snapshots.SnapshotStateMap @@ -77,17 +77,24 @@ class PreferenceSectionState( companion object { - val Saver: Saver = Saver( + /** + * Saved as a flat `[key, value, key, value, ...]` list rather than a `Bundle`. + * + * A `Bundle` is Android only, and it bought nothing here: the state is a map of section key + * to expanded flag, and `listSaver` stores `String` and `Boolean` through the same + * SaveableStateRegistry that a Bundle-backed saver used. Same behaviour across + * configuration changes, no Android type in the signature. + */ + val Saver: Saver = listSaver( save = { state -> - Bundle().apply { - state.expandedSections.forEach { (k, v) -> putBoolean(k, v) } - } + state.expandedSections.flatMap { (key, expanded) -> listOf(key, expanded) } }, - restore = { bundle -> + restore = { saved -> PreferenceSectionState( expandedSections = mutableStateMapOf().apply { - bundle.keySet().forEach { key -> - put(key, bundle.getBoolean(key)) + saved.chunked(2).forEach { pair -> + val key = pair.getOrNull(0) as? String ?: return@forEach + put(key, pair.getOrNull(1) as? Boolean ?: false) } } ) diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/preference/PreferenceSheetContent.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/preference/PreferenceSheetContent.kt similarity index 95% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/preference/PreferenceSheetContent.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/preference/PreferenceSheetContent.kt index be8bcaabcb87..0b662f99bfdc 100644 --- a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/preference/PreferenceSheetContent.kt +++ b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/preference/PreferenceSheetContent.kt @@ -32,7 +32,7 @@ fun PreferenceSheetContent( if (groups.isEmpty()) { // Single flat list → one always-expanded card titled by the def itself. CollapsibleCardSectionContent( - titleResId = settingsDef.titleResId, + title = settingsDef.title, expanded = true, onToggle = {}, icon = settingsDef.icon, @@ -47,7 +47,7 @@ fun PreferenceSheetContent( groups.forEach { group -> var expanded by remember(group.key) { mutableStateOf(false) } CollapsibleCardSectionContent( - titleResId = group.titleResId, + title = group.title, expanded = expanded, onToggle = { expanded = !expanded }, icon = group.icon diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/preference/PreferenceSliderWithButtons.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/preference/PreferenceSliderWithButtons.kt similarity index 85% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/preference/PreferenceSliderWithButtons.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/preference/PreferenceSliderWithButtons.kt index b13e48d6a77d..a98d14f04b83 100644 --- a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/preference/PreferenceSliderWithButtons.kt +++ b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/preference/PreferenceSliderWithButtons.kt @@ -14,10 +14,10 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import app.aaps.core.data.format.NumberFormat +import app.aaps.core.keys.interfaces.TextRef import app.aaps.core.ui.compose.SliderWithButtons import app.aaps.core.ui.compose.dialogs.ValueInputDialog import app.aaps.core.ui.compose.formatSliderDisplayValue @@ -39,11 +39,11 @@ fun PreferenceSliderWithButtons( valueRange: ClosedFloatingPointRange, step: Double = 0.1, showValue: Boolean = false, - valueFormatResId: Int? = null, + valueFormatRef: TextRef? = null, formatAsInt: Boolean = false, valueFormat: NumberFormat = NumberFormat.DECIMAL_1, - unitLabel: String = "", - unitLabelResId: Int = 0, + unitLabel: TextRef? = null, + asDuration: Boolean = false, dialogLabel: String? = null, dialogSummary: String? = null, enabled: Boolean = true, @@ -57,11 +57,11 @@ fun PreferenceSliderWithButtons( valueRange = valueRange, step = step, showValue = showValue, - valueFormatResId = valueFormatResId, + valueFormatRef = valueFormatRef, formatAsInt = formatAsInt, valueFormat = valueFormat, unitLabel = unitLabel, - unitLabelResId = unitLabelResId, + asDuration = asDuration, dialogLabel = dialogLabel, dialogSummary = dialogSummary, enabled = enabled, @@ -72,18 +72,13 @@ fun PreferenceSliderWithButtons( var showDialog by remember { mutableStateOf(false) } - val resolvedUnitLabel = when { - unitLabelResId != 0 -> stringResource(unitLabelResId) - unitLabel.isNotEmpty() -> unitLabel - else -> "" - } val displayText = if (showValue) formatSliderDisplayValue( value = value, - unitLabelResId = unitLabelResId, - valueFormatResId = valueFormatResId, + unitLabel = unitLabel, + valueFormatRef = valueFormatRef, formatAsInt = formatAsInt, valueFormat = valueFormat, - unitLabel = unitLabel + asDuration = asDuration ) else "" Row( @@ -111,8 +106,8 @@ fun PreferenceSliderWithButtons( step = step, label = dialogLabel, summary = dialogSummary, - unitLabel = resolvedUnitLabel, - unitLabelResId = unitLabelResId, + unitLabel = unitLabel, + asDuration = asDuration, valueFormat = valueFormat, onValueConfirm = onValueChange, onDismiss = { showDialog = false } diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/preference/PreferenceState.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/preference/PreferenceState.kt similarity index 98% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/preference/PreferenceState.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/preference/PreferenceState.kt index 5cfa1a67d39a..e9a971c34ab1 100644 --- a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/preference/PreferenceState.kt +++ b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/preference/PreferenceState.kt @@ -12,6 +12,8 @@ import androidx.compose.runtime.compositionLocalOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.snapshots.SnapshotStateMap +import app.aaps.core.data.format.NumberFormat +import app.aaps.core.data.format.NumberFormatPlatform import app.aaps.core.interfaces.profile.ProfileUtil import app.aaps.core.keys.interfaces.BooleanNonPreferenceKey import app.aaps.core.keys.interfaces.BooleanPreferenceKey @@ -20,12 +22,12 @@ import app.aaps.core.keys.interfaces.IntNonPreferenceKey import app.aaps.core.keys.interfaces.IntPreferenceKey import app.aaps.core.keys.interfaces.IntentPreferenceKey import app.aaps.core.keys.interfaces.PreferenceKey -import app.aaps.core.keys.interfaces.VisibilityContext import app.aaps.core.keys.interfaces.Preferences import app.aaps.core.keys.interfaces.StringNonPreferenceKey import app.aaps.core.keys.interfaces.StringPreferenceKey import app.aaps.core.keys.interfaces.SyncDirection import app.aaps.core.keys.interfaces.UnitDoublePreferenceKey +import app.aaps.core.keys.interfaces.VisibilityContext import app.aaps.core.ui.compose.LocalConfig import app.aaps.core.ui.compose.LocalMasterReachable import app.aaps.core.ui.compose.LocalPreferences @@ -33,8 +35,6 @@ import app.aaps.core.ui.compose.LocalProfileUtil import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.flow.drop import kotlinx.coroutines.launch -import java.math.BigDecimal -import java.math.RoundingMode /** * Data class holding visibility and enabled state for a preference @@ -556,7 +556,8 @@ fun rememberUnitDoublePreferenceState( val displayValue = profileUtil.valueInCurrentUnitsDetect(storedValue) val isMgdl = displayValue == storedValue || (storedValue > 0 && displayValue / storedValue > 0.9) val precision = if (isMgdl) 0 else 1 - return BigDecimal(displayValue).setScale(precision, RoundingMode.HALF_UP).toPlainString() + // SEPARATOR_DOT, not the locale separator: this string is parsed back as a number when edited. + return NumberFormat.withDecimalsHalfUp(precision).format(displayValue, NumberFormatPlatform.SEPARATOR_DOT) } // Back the display value with the shared state map so it's reactive diff --git a/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/preference/PreferenceSubScreenDef.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/preference/PreferenceSubScreenDef.kt new file mode 100644 index 000000000000..b10988e73224 --- /dev/null +++ b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/preference/PreferenceSubScreenDef.kt @@ -0,0 +1,61 @@ +package app.aaps.core.ui.compose.preference + +import androidx.compose.ui.graphics.vector.ImageVector +import app.aaps.core.keys.interfaces.PreferenceItem +import app.aaps.core.keys.interfaces.PreferenceKey +import app.aaps.core.keys.interfaces.TextRef + +/** + * Lightweight preference subscreen definition. + * Can contain both PreferenceKeys and nested PreferenceSubScreenDefs for hierarchical structure. + * Content is auto-generated from items using AdaptivePreferenceList. + * + * Titles are [TextRef], so the rendering code deals with one form only, the same as it does for + * [PreferenceKey]. A second constructor takes plain resource ids, because ~174 call sites still build + * these with `titleResId = R.string.x`; it wraps them in [TextRef.AndroidRes]. + * + * Which one you get is decided by the argument NAME, and picking the wrong one is a type error rather + * than anything readable: pass a [TextRef] (`title = UiStrings.x`) to `title`, and a resource id + * (`titleResId = R.string.x`) to `titleResId`. Multiplatform call sites want the first, since a + * resource id means nothing off Android. + * + * @param key Unique key for this subscreen + * @param title Screen title + * @param items List of preference items (keys and/or nested subscreens) + * @param summary Optional summary shown in the parent list + * @param icon Optional Compose ImageVector icon shown next to the title + */ +data class PreferenceSubScreenDef( + val key: String, + /** Screen title, in the same form as [PreferenceKey.title]. */ + val title: TextRef, + val items: List = emptyList(), + /** Optional summary, in the same form as [PreferenceKey.summary]. */ + val summary: TextRef? = null, + val icon: ImageVector? = null +) : PreferenceItem { + + /** + * Resource id form, for the many call sites that still name their strings with R.string. + * + * @param titleResId String resource id for the screen title + * @param summaryResId Optional string resource id for the summary shown in the parent list + */ + constructor( + key: String, + titleResId: Int, + items: List = emptyList(), + summaryResId: Int? = null, + icon: ImageVector? = null + ) : this(key, TextRef.AndroidRes(titleResId), items, summaryResId?.let { TextRef.AndroidRes(it) }, icon) + + /** Titles of the contained items, used to build the summary line in the parent list. */ + fun effectiveSummaryItems(): List = + items.mapNotNull { item -> + when (item) { + is PreferenceKey -> item.title + is PreferenceSubScreenDef -> item.title + else -> null + } + } +} diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/preference/PreferenceTheme.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/preference/PreferenceTheme.kt similarity index 100% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/preference/PreferenceTheme.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/preference/PreferenceTheme.kt diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/preference/PreviewUtils.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/preference/PreviewUtils.kt similarity index 98% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/preference/PreviewUtils.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/preference/PreviewUtils.kt index 75180d635785..4e9e8730e589 100644 --- a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/preference/PreviewUtils.kt +++ b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/preference/PreviewUtils.kt @@ -128,8 +128,7 @@ private object PreviewPreferences : Preferences { override fun isUnitDependent(key: String): Boolean = false override fun get(key: String): NonPreferenceKey? = null override fun getIfExists(key: String): NonPreferenceKey? = null - override fun getDependingOn(key: String): List = emptyList() - override fun registerPreferences(clazz: Class) {} + override fun registerPreferences(keys: List) {} override fun allMatchingStrings(key: ComposedKey): List = emptyList() override fun allMatchingInts(key: ComposedKey): List = emptyList() override fun isExportableKey(key: String): Boolean = false diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/preference/ScrollIndicators.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/preference/ScrollIndicators.kt similarity index 100% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/preference/ScrollIndicators.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/preference/ScrollIndicators.kt diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/preference/SwitchPreference.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/preference/SwitchPreference.kt similarity index 100% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/preference/SwitchPreference.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/preference/SwitchPreference.kt diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/preference/SwitchPreferencePreviews.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/preference/SwitchPreferencePreviews.kt similarity index 100% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/preference/SwitchPreferencePreviews.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/preference/SwitchPreferencePreviews.kt diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/preference/SyncBadge.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/preference/SyncBadge.kt similarity index 90% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/preference/SyncBadge.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/preference/SyncBadge.kt index f1366792daa1..0331753b93bf 100644 --- a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/preference/SyncBadge.kt +++ b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/preference/SyncBadge.kt @@ -13,7 +13,6 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color -import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.Placeholder import androidx.compose.ui.text.PlaceholderVerticalAlign import androidx.compose.ui.text.TextStyle @@ -22,8 +21,10 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.em import app.aaps.core.keys.interfaces.NonPreferenceKey import app.aaps.core.keys.interfaces.SyncDirection -import app.aaps.core.ui.R +import app.aaps.core.keys.interfaces.TextRef +import app.aaps.core.ui.UiStrings import app.aaps.core.ui.compose.LocalConfig +import app.aaps.core.ui.compose.stringResource /** * Small badge marking something that two-way syncs with the master ("main phone"). @@ -40,7 +41,7 @@ fun SyncBadge(visible: Boolean, modifier: Modifier = Modifier) { // NOT a tappable refresh/sync control. Icon( imageVector = Icons.Default.PhonelinkRing, - contentDescription = stringResource(R.string.pref_syncs_with_main_phone), + contentDescription = stringResource(UiStrings.pref_syncs_with_main_phone), modifier = modifier.size(14.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f) ) @@ -82,7 +83,7 @@ fun TextWithSyncBadge( ) { Icon( imageVector = Icons.Default.PhonelinkRing, - contentDescription = stringResource(R.string.pref_syncs_with_main_phone), + contentDescription = stringResource(UiStrings.pref_syncs_with_main_phone), modifier = Modifier.fillMaxSize(), tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f) ) @@ -97,5 +98,5 @@ fun TextWithSyncBadge( * on a client for Bidirectional keys. */ @Composable -fun PreferenceTitleWithSyncBadge(titleResId: Int, key: NonPreferenceKey?) = - TextWithSyncBadge(stringResource(titleResId), key) +fun PreferenceTitleWithSyncBadge(title: TextRef, key: NonPreferenceKey?) = + TextWithSyncBadge(stringResource(title), key) diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/preference/TextFieldPreference.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/preference/TextFieldPreference.kt similarity index 97% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/preference/TextFieldPreference.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/preference/TextFieldPreference.kt index 841a074fac77..50b5eb092618 100644 --- a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/preference/TextFieldPreference.kt +++ b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/preference/TextFieldPreference.kt @@ -37,10 +37,11 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester -import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.TextRange import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.unit.dp +import app.aaps.core.ui.UiStrings +import app.aaps.core.ui.compose.stringResource /** * @see TextFieldPreferencePreview @@ -122,9 +123,9 @@ fun TextFieldPreference( title = title, buttons = { TextButton(onClick = { openDialog = false }) { - Text(text = stringResource(android.R.string.cancel)) + Text(text = stringResource(UiStrings.cancel)) } - TextButton(onClick = onOk) { Text(text = stringResource(android.R.string.ok)) } + TextButton(onClick = onOk) { Text(text = stringResource(UiStrings.ok)) } }, ) { val focusRequester = remember { FocusRequester() } diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/preference/TextFieldPreferencePreviews.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/preference/TextFieldPreferencePreviews.kt similarity index 100% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/preference/TextFieldPreferencePreviews.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/preference/TextFieldPreferencePreviews.kt diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/pump/BleScanStep.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/pump/BleScanStep.kt similarity index 93% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/pump/BleScanStep.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/pump/BleScanStep.kt index e6ed985f7023..e32ff43948a2 100644 --- a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/pump/BleScanStep.kt +++ b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/pump/BleScanStep.kt @@ -18,10 +18,10 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import app.aaps.core.interfaces.pump.ble.ScannedDevice -import app.aaps.core.ui.R +import app.aaps.core.ui.UiStrings +import app.aaps.core.ui.compose.stringResource /** * Shared BLE device scan step for pump pairing wizards. @@ -46,8 +46,8 @@ fun BleScanStep( onStopScan: () -> Unit = {}, onCancel: () -> Unit, deviceNameFilter: Regex? = null, - title: String = stringResource(R.string.ble_scan_select_pump), - subtitle: String = stringResource(R.string.ble_scan_scanning) + title: String = stringResource(UiStrings.ble_scan_select_pump), + subtitle: String = stringResource(UiStrings.ble_scan_scanning) ) { DisposableEffect(Unit) { onStartScan() @@ -63,7 +63,7 @@ fun BleScanStep( WizardStepLayout( scrollable = false, secondaryButton = WizardButton( - text = stringResource(app.aaps.core.ui.R.string.cancel), + text = stringResource(UiStrings.cancel), onClick = onCancel ) ) { diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/pump/ProfileGateWizardStep.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/pump/ProfileGateWizardStep.kt similarity index 90% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/pump/ProfileGateWizardStep.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/pump/ProfileGateWizardStep.kt index a2940fad4fbd..1a6806a39530 100644 --- a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/pump/ProfileGateWizardStep.kt +++ b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/pump/ProfileGateWizardStep.kt @@ -13,11 +13,11 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle -import app.aaps.core.ui.R +import app.aaps.core.ui.UiStrings import app.aaps.core.ui.compose.AapsSpacing +import app.aaps.core.ui.compose.stringResource import kotlinx.coroutines.flow.StateFlow /** @@ -52,18 +52,18 @@ fun ProfileGateWizardStep(host: ProfileGateStepHost) { WizardStepLayout( primaryButton = if (hasStore) WizardButton( - text = stringResource(R.string.activate_profile), + text = stringResource(UiStrings.activate_profile), onClick = { host.activateSelectedProfile() }, enabled = selected != null ) else null, secondaryButton = WizardButton( - text = stringResource(R.string.cancel), + text = stringResource(UiStrings.cancel), onClick = { host.cancelGate() } ) ) { if (hasStore) { Text( - text = stringResource(R.string.pump_wizard_profile_gate_pick), + text = stringResource(UiStrings.pump_wizard_profile_gate_pick), style = MaterialTheme.typography.bodyLarge ) Column( @@ -95,7 +95,7 @@ fun ProfileGateWizardStep(host: ProfileGateStepHost) { } } else { Text( - text = stringResource(R.string.pump_wizard_profile_gate_no_store), + text = stringResource(UiStrings.pump_wizard_profile_gate_no_store), style = MaterialTheme.typography.bodyLarge ) } diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/pump/ProfileGateWizardStepPreviews.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/pump/ProfileGateWizardStepPreviews.kt similarity index 100% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/pump/ProfileGateWizardStepPreviews.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/pump/ProfileGateWizardStepPreviews.kt diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/pump/PumpActivityDialog.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/pump/PumpActivityDialog.kt similarity index 92% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/pump/PumpActivityDialog.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/pump/PumpActivityDialog.kt index fa176f28c43f..39064f3edebf 100644 --- a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/pump/PumpActivityDialog.kt +++ b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/pump/PumpActivityDialog.kt @@ -24,15 +24,16 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.DialogProperties import app.aaps.core.interfaces.pump.BolusProgressState -import app.aaps.core.ui.R +import app.aaps.core.ui.UiStrings import app.aaps.core.ui.compose.AapsSpacing +import app.aaps.core.ui.compose.stringResource /** * Shared pump activity dialog showing pump status, queue info, and bolus progress. @@ -45,7 +46,7 @@ import app.aaps.core.ui.compose.AapsSpacing fun PumpActivityDialog( bolusState: BolusProgressState?, pumpStatus: String, - queueStatus: String?, + queueStatus: AnnotatedString?, isModal: Boolean, onStop: () -> Unit, onDismiss: () -> Unit @@ -100,7 +101,7 @@ fun PumpActivityDialog( internal fun PumpActivityCard( bolusState: BolusProgressState?, pumpStatus: String, - queueStatus: String?, + queueStatus: AnnotatedString?, onStop: () -> Unit, onDismiss: () -> Unit ) { @@ -163,7 +164,7 @@ private fun BolusProgressSection( ) { // Title Text( - text = stringResource(R.string.goingtodeliver, state.insulin), + text = stringResource(UiStrings.goingtodeliver, state.insulin), style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold, textAlign = TextAlign.Center, @@ -174,9 +175,10 @@ private fun BolusProgressSection( // Status text — hidden when stalled: a present-tense "Delivering …" line would contradict the // "connection lost / status unknown" message and read as if delivery were still being tracked. - if (state.status.isNotEmpty() && !state.stalled) { + val statusText = stringResource(state.status) + if (statusText.isNotEmpty() && !state.stalled) { Text( - text = state.status, + text = statusText, style = MaterialTheme.typography.bodyMedium, textAlign = TextAlign.Center, modifier = Modifier.fillMaxWidth() @@ -214,7 +216,7 @@ private fun BolusProgressSection( // only hides this dialog — it does NOT stop the pump. state.stalled -> { Text( - text = stringResource(R.string.clientcontrol_bolus_progress_stalled_title), + text = stringResource(UiStrings.clientcontrol_bolus_progress_stalled_title), style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.error, @@ -223,7 +225,7 @@ private fun BolusProgressSection( ) Spacer(modifier = Modifier.height(AapsSpacing.medium)) Text( - text = stringResource(R.string.clientcontrol_bolus_progress_stalled_body), + text = stringResource(UiStrings.clientcontrol_bolus_progress_stalled_body), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, textAlign = TextAlign.Center, @@ -237,7 +239,7 @@ private fun BolusProgressSection( // Neutral/tonal — Dismiss only hides this local view; it is NOT destructive like Stop, // so it must not borrow Stop's error-red affordance. FilledTonalButton(onClick = onDismiss) { - Text(text = stringResource(R.string.dismiss)) + Text(text = stringResource(UiStrings.dismiss)) } } } @@ -257,8 +259,8 @@ private fun BolusProgressSection( ) ) { Text( - text = if (state.stopPressed) stringResource(R.string.stop_pressed) - else stringResource(R.string.stop) + text = if (state.stopPressed) stringResource(UiStrings.stop_pressed) + else stringResource(UiStrings.stop) ) } } diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/pump/PumpActivityDialogPreviews.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/pump/PumpActivityDialogPreviews.kt similarity index 80% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/pump/PumpActivityDialogPreviews.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/pump/PumpActivityDialogPreviews.kt index ed0d3ebb7fab..610e45bc07ce 100644 --- a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/pump/PumpActivityDialogPreviews.kt +++ b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/pump/PumpActivityDialogPreviews.kt @@ -2,9 +2,11 @@ package app.aaps.core.ui.compose.pump import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable +import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.tooling.preview.Preview import app.aaps.core.interfaces.pump.BolusProgressState import app.aaps.core.interfaces.pump.PumpInsulin +import app.aaps.core.keys.interfaces.TextRef @Preview(showBackground = true, widthDp = 360) @Composable @@ -16,8 +18,8 @@ internal fun PreviewBolusInProgress() { isSMB = false, isPriming = false, percent = 45, - status = "Delivering 1.80U", - wearStatus = "Delivering 1.80U", + status = TextRef.Literal("Delivering 1.80U"), + wearStatus = TextRef.Literal("Delivering 1.80U"), delivered = PumpInsulin(1.8), stopPressed = false, stopDeliveryEnabled = true @@ -40,8 +42,8 @@ internal fun PreviewBolusStopPressed() { isSMB = false, isPriming = false, percent = 45, - status = "Delivering 1.80U", - wearStatus = "Delivering 1.80U", + status = TextRef.Literal("Delivering 1.80U"), + wearStatus = TextRef.Literal("Delivering 1.80U"), delivered = PumpInsulin(1.8), stopPressed = true, stopDeliveryEnabled = true @@ -64,8 +66,8 @@ internal fun PreviewBolusCompleted() { isSMB = false, isPriming = false, percent = 100, - status = "Bolus 4.00U delivered successfully", - wearStatus = "Bolus 4.00U delivered successfully", + status = TextRef.Literal("Bolus 4.00U delivered successfully"), + wearStatus = TextRef.Literal("Bolus 4.00U delivered successfully"), delivered = PumpInsulin(4.0), stopPressed = false, stopDeliveryEnabled = true @@ -88,14 +90,14 @@ internal fun PreviewBolusIndeterminate() { isSMB = false, isPriming = false, percent = 0, - status = "", - wearStatus = "", + status = TextRef.Literal(""), + wearStatus = TextRef.Literal(""), delivered = PumpInsulin(0.0), stopPressed = false, stopDeliveryEnabled = false ), pumpStatus = "Connecting for 5s", - queueStatus = "BOLUS 2.50U", + queueStatus = AnnotatedString("BOLUS 2.50U"), onStop = {}, onDismiss = {} ) @@ -112,8 +114,8 @@ internal fun PreviewBolusStalled() { isSMB = false, isPriming = false, percent = 85, - status = "Delivering 1.36U", - wearStatus = "Delivering 1.36U", + status = TextRef.Literal("Delivering 1.36U"), + wearStatus = TextRef.Literal("Delivering 1.36U"), delivered = PumpInsulin(1.36), stopPressed = false, stopDeliveryEnabled = true, @@ -134,7 +136,7 @@ internal fun PreviewPumpStatusOnly() { PumpActivityCard( bolusState = null, pumpStatus = "Handshaking", - queueStatus = "READSTATUS", + queueStatus = AnnotatedString("READSTATUS"), onStop = {}, onDismiss = {} ) diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/pump/PumpActivityFab.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/pump/PumpActivityFab.kt similarity index 100% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/pump/PumpActivityFab.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/pump/PumpActivityFab.kt diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/pump/PumpActivityFabPreviews.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/pump/PumpActivityFabPreviews.kt similarity index 85% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/pump/PumpActivityFabPreviews.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/pump/PumpActivityFabPreviews.kt index 3a5c172ee460..f3574de41fae 100644 --- a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/pump/PumpActivityFabPreviews.kt +++ b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/pump/PumpActivityFabPreviews.kt @@ -5,6 +5,7 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.tooling.preview.Preview import app.aaps.core.interfaces.pump.BolusProgressState import app.aaps.core.interfaces.pump.PumpInsulin +import app.aaps.core.keys.interfaces.TextRef @Preview(showBackground = true) @Composable @@ -29,8 +30,8 @@ internal fun PreviewPumpFabSmbPercent() { isSMB = true, isPriming = false, percent = 42, - status = "Delivering 0.13U", - wearStatus = "Delivering 0.13U", + status = TextRef.Literal("Delivering 0.13U"), + wearStatus = TextRef.Literal("Delivering 0.13U"), delivered = PumpInsulin(0.13), stopPressed = false, stopDeliveryEnabled = true diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/pump/PumpCommunicationStatus.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/pump/PumpCommunicationStatus.kt similarity index 69% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/pump/PumpCommunicationStatus.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/pump/PumpCommunicationStatus.kt index 051357459e44..b31cc443c39e 100644 --- a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/pump/PumpCommunicationStatus.kt +++ b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/pump/PumpCommunicationStatus.kt @@ -1,7 +1,8 @@ package app.aaps.core.ui.compose.pump -import android.content.Context +import androidx.compose.ui.text.AnnotatedString import app.aaps.core.interfaces.queue.CommandQueue +import app.aaps.core.interfaces.resources.TextResolver import app.aaps.core.interfaces.rx.bus.RxBus import app.aaps.core.interfaces.rx.events.EventPumpStatusChanged import app.aaps.core.interfaces.rx.events.EventQueueChanged @@ -12,6 +13,7 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach +import kotlin.time.Clock /** * Shared communication status provider for all pump overview screens. @@ -23,32 +25,32 @@ import kotlinx.coroutines.flow.onEach class PumpCommunicationStatus( rxBus: RxBus, private val commandQueue: CommandQueue, - private val context: Context, + private val rh: TextResolver, scope: CoroutineScope ) { private val _statusBanner = MutableStateFlow(null) val statusBannerFlow: StateFlow = _statusBanner.asStateFlow() - private val _queueStatus = MutableStateFlow(null) - val queueStatusFlow: StateFlow = _queueStatus.asStateFlow() + private val _queueStatus = MutableStateFlow(null) + val queueStatusFlow: StateFlow = _queueStatus.asStateFlow() /** Emits whenever communication status or queue changes. */ val refreshTrigger: MutableStateFlow = MutableStateFlow(0L) init { - rxBus.toFlow(EventPumpStatusChanged::class.java) + rxBus.toFlow(EventPumpStatusChanged::class) .onEach { event -> - val text = event.getStatus(context) + val text = rh.gs(event.getStatus()) _statusBanner.value = if (text.isEmpty()) null else StatusBanner(text = text, level = StatusLevel.UNSPECIFIED) - refreshTrigger.value = System.currentTimeMillis() + refreshTrigger.value = Clock.System.now().toEpochMilliseconds() } .launchIn(scope) - rxBus.toFlow(EventQueueChanged::class.java) + rxBus.toFlow(EventQueueChanged::class) .onEach { - _queueStatus.value = commandQueue.spannedStatus().toString().takeIf { it.isNotEmpty() } - refreshTrigger.value = System.currentTimeMillis() + _queueStatus.value = commandQueue.statusAsAnnotated().takeIf { it.isNotEmpty() } + refreshTrigger.value = Clock.System.now().toEpochMilliseconds() } .launchIn(scope) } @@ -57,5 +59,5 @@ class PumpCommunicationStatus( fun statusBanner(): StatusBanner? = _statusBanner.value /** Returns the current command queue status text. */ - fun queueStatus(): String? = _queueStatus.value + fun queueStatus(): AnnotatedString? = _queueStatus.value } diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/pump/PumpHistoryModels.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/pump/PumpHistoryModels.kt similarity index 100% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/pump/PumpHistoryModels.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/pump/PumpHistoryModels.kt diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/pump/PumpHistoryScreen.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/pump/PumpHistoryScreen.kt similarity index 95% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/pump/PumpHistoryScreen.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/pump/PumpHistoryScreen.kt index 62ecd7247a77..1e3b21ad0dad 100644 --- a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/pump/PumpHistoryScreen.kt +++ b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/pump/PumpHistoryScreen.kt @@ -27,9 +27,9 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp -import app.aaps.core.ui.R +import app.aaps.core.ui.UiStrings +import app.aaps.core.ui.compose.stringResource /** * Shared pump history screen scaffold with type dropdown, reload button, and record list. @@ -69,7 +69,7 @@ fun PumpHistoryScreen( if (state.isLoading) { CircularProgressIndicator(modifier = Modifier.size(18.dp), strokeWidth = 2.dp) } else { - Text(stringResource(R.string.refresh)) + Text(stringResource(UiStrings.refresh)) } } } @@ -92,7 +92,7 @@ fun PumpHistoryScreen( contentAlignment = Alignment.Center ) { Text( - text = stringResource(R.string.no_history_records), + text = stringResource(UiStrings.no_history_records), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant ) diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/pump/PumpOverviewModels.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/pump/PumpOverviewModels.kt similarity index 91% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/pump/PumpOverviewModels.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/pump/PumpOverviewModels.kt index 0569373754d3..b601cef4340c 100644 --- a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/pump/PumpOverviewModels.kt +++ b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/pump/PumpOverviewModels.kt @@ -3,6 +3,7 @@ package app.aaps.core.ui.compose.pump import androidx.compose.runtime.Composable import androidx.compose.runtime.Immutable import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.text.AnnotatedString import app.aaps.core.ui.compose.StatusLevel /** @@ -15,7 +16,7 @@ data class PumpOverviewUiState( val infoRows: List = emptyList(), val primaryActions: List = emptyList(), val managementActions: List = emptyList(), - val queueStatus: String? = null + val queueStatus: AnnotatedString? = null ) /** @@ -41,19 +42,20 @@ data class PumpInfoRow( val value: String, val level: StatusLevel = StatusLevel.UNSPECIFIED, val visible: Boolean = true -): PumpInfoInterface +) : PumpInfoInterface /** * Group for PumpInfoRow. Group items are displayed together, with divider only at end of group (instead of each item) */ data class PumpInfoGroup( var list: MutableList = mutableListOf() -): PumpInfoInterface +) : PumpInfoInterface /** * PumpInfoRow with custom compose content. */ -interface PumpInfoComposable: PumpInfoInterface { +interface PumpInfoComposable : PumpInfoInterface { + fun composableContent(): @Composable () -> Unit fun hasDividerOnEnd(): Boolean = false } diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/pump/PumpOverviewScreen.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/pump/PumpOverviewScreen.kt similarity index 99% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/pump/PumpOverviewScreen.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/pump/PumpOverviewScreen.kt index 73b581c8efd7..22e8f540ee74 100644 --- a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/pump/PumpOverviewScreen.kt +++ b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/pump/PumpOverviewScreen.kt @@ -22,6 +22,7 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp @@ -82,7 +83,7 @@ fun PumpOverviewScreen( // ── Communication status card (banner + queue in one card) ──────────────── @Composable -private fun CommunicationStatusCard(banner: StatusBanner?, queueStatus: String?) { +private fun CommunicationStatusCard(banner: StatusBanner?, queueStatus: AnnotatedString?) { if (banner == null && queueStatus == null) return val (bgColor, fgColor) = when (banner?.level) { diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/pump/PumpOverviewStateBuilder.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/pump/PumpOverviewStateBuilder.kt similarity index 82% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/pump/PumpOverviewStateBuilder.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/pump/PumpOverviewStateBuilder.kt index f79e33c72ca0..3ea2a8dd32a6 100644 --- a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/pump/PumpOverviewStateBuilder.kt +++ b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/pump/PumpOverviewStateBuilder.kt @@ -1,7 +1,7 @@ package app.aaps.core.ui.compose.pump -import app.aaps.core.interfaces.resources.ResourceHelper -import app.aaps.core.ui.R +import app.aaps.core.interfaces.resources.TextResolver +import app.aaps.core.ui.UiStrings /** * Builds the common [PumpInfoRow] items that every pump shares. @@ -12,7 +12,7 @@ import app.aaps.core.ui.R * visibility logic. */ class PumpOverviewStateBuilder( - private val rh: ResourceHelper + private val rh: TextResolver ) { /** @@ -41,7 +41,7 @@ class PumpOverviewStateBuilder( if (lastConnection.isNotEmpty()) { add( PumpInfoRow( - label = rh.gs(R.string.last_connection_label), + label = rh.gs(UiStrings.last_connection_label), value = lastConnection ) ) @@ -51,7 +51,7 @@ class PumpOverviewStateBuilder( lastBolus?.let { add( PumpInfoRow( - label = rh.gs(R.string.last_bolus_label), + label = rh.gs(UiStrings.last_bolus_label), value = it ) ) @@ -61,7 +61,7 @@ class PumpOverviewStateBuilder( baseBasalRate?.let { add( PumpInfoRow( - label = rh.gs(R.string.base_basal_rate_label), + label = rh.gs(UiStrings.base_basal_rate_label), value = it ) ) @@ -70,7 +70,7 @@ class PumpOverviewStateBuilder( // Temp basal add( PumpInfoRow( - label = rh.gs(R.string.tempbasal_label), + label = rh.gs(UiStrings.tempbasal_label), value = tempBasalText, visible = tempBasalText.isNotEmpty() ) @@ -79,7 +79,7 @@ class PumpOverviewStateBuilder( // Extended bolus add( PumpInfoRow( - label = rh.gs(R.string.extended_bolus_label), + label = rh.gs(UiStrings.extended_bolus_label), value = extendedBolusText, visible = extendedBolusText.isNotEmpty() ) @@ -89,7 +89,7 @@ class PumpOverviewStateBuilder( battery?.let { add( PumpInfoRow( - label = rh.gs(R.string.battery_label), + label = rh.gs(UiStrings.battery_label), value = it ) ) @@ -99,7 +99,7 @@ class PumpOverviewStateBuilder( reservoir?.let { add( PumpInfoRow( - label = rh.gs(R.string.reservoir_label), + label = rh.gs(UiStrings.reservoir_label), value = it ) ) @@ -109,7 +109,7 @@ class PumpOverviewStateBuilder( serialNumber?.takeIf { it.isNotEmpty() }?.let { add( PumpInfoRow( - label = rh.gs(R.string.serial_number), + label = rh.gs(UiStrings.serial_number), value = it ) ) diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/pump/StepProgressIndicator.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/pump/StepProgressIndicator.kt similarity index 100% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/pump/StepProgressIndicator.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/pump/StepProgressIndicator.kt index 437d051a5f65..db435502bfc6 100644 --- a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/pump/StepProgressIndicator.kt +++ b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/pump/StepProgressIndicator.kt @@ -10,7 +10,6 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.material3.MaterialTheme -import app.aaps.core.ui.compose.AapsSpacing import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment @@ -19,6 +18,7 @@ import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.drawscope.Stroke import androidx.compose.ui.unit.dp +import app.aaps.core.ui.compose.AapsSpacing @Composable fun StepProgressIndicator( diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/pump/TickerFlow.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/pump/TickerFlow.kt similarity index 100% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/pump/TickerFlow.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/pump/TickerFlow.kt diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/pump/WizardScreen.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/pump/WizardScreen.kt similarity index 90% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/pump/WizardScreen.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/pump/WizardScreen.kt index 4a1c5a55c7c2..c2e43f64a665 100644 --- a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/pump/WizardScreen.kt +++ b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/pump/WizardScreen.kt @@ -1,6 +1,5 @@ package app.aaps.core.ui.compose.pump -import androidx.activity.compose.BackHandler import androidx.compose.animation.AnimatedContent import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut @@ -20,6 +19,9 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier +import androidx.navigationevent.NavigationEventInfo +import androidx.navigationevent.compose.NavigationBackHandler +import androidx.navigationevent.compose.rememberNavigationEventState import app.aaps.core.ui.compose.ToolbarConfig import app.aaps.core.ui.compose.dialogs.OkCancelDialog @@ -84,10 +86,13 @@ fun WizardScreen( } } - // System back button: always confirm before leaving the wizard - BackHandler(enabled = true) { - showCancelDialog = true - } + // System back button: always confirm before leaving the wizard. + // The wizard has no back stack of its own, so the handler carries no navigation info. + NavigationBackHandler( + state = rememberNavigationEventState(NavigationEventInfo.None), + isBackEnabled = true, + onBackCompleted = { showCancelDialog = true } + ) if (showCancelDialog) { OkCancelDialog( diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/pump/WizardStepLayout.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/pump/WizardStepLayout.kt similarity index 100% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/pump/WizardStepLayout.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/pump/WizardStepLayout.kt diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/siteRotation/ArrowExtensions.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/siteRotation/ArrowExtensions.kt similarity index 100% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/siteRotation/ArrowExtensions.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/siteRotation/ArrowExtensions.kt diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/siteRotation/ArrowSelectionDialog.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/siteRotation/ArrowSelectionDialog.kt similarity index 94% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/siteRotation/ArrowSelectionDialog.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/siteRotation/ArrowSelectionDialog.kt index b6405bc7a8c1..cbc711d2ca3c 100644 --- a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/siteRotation/ArrowSelectionDialog.kt +++ b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/siteRotation/ArrowSelectionDialog.kt @@ -11,10 +11,10 @@ import androidx.compose.material3.IconButton import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier -import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import app.aaps.core.data.model.TE -import app.aaps.core.ui.R +import app.aaps.core.ui.UiStrings +import app.aaps.core.ui.compose.stringResource /** * @see ArrowSelectionDialogPreview @@ -26,7 +26,7 @@ fun ArrowSelectionDialog( ) { AlertDialog( onDismissRequest = onDismiss, - title = { Text(stringResource(R.string.select_arrow)) }, + title = { Text(stringResource(UiStrings.select_arrow)) }, text = { Column { Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceEvenly) { diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/siteRotation/ArrowSelectionDialogPreviews.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/siteRotation/ArrowSelectionDialogPreviews.kt similarity index 100% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/siteRotation/ArrowSelectionDialogPreviews.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/siteRotation/ArrowSelectionDialogPreviews.kt diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/siteRotation/BodyType.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/siteRotation/BodyType.kt similarity index 97% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/siteRotation/BodyType.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/siteRotation/BodyType.kt index 3ece1461064e..399d8d06afcb 100644 --- a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/siteRotation/BodyType.kt +++ b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/siteRotation/BodyType.kt @@ -1,6 +1,6 @@ package app.aaps.core.ui.compose.siteRotation -import android.graphics.Path +import androidx.compose.ui.graphics.Path import androidx.compose.ui.graphics.vector.ImageVector import app.aaps.core.data.model.TE import app.aaps.core.ui.compose.icons.library.ChildBack diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/siteRotation/BodyView.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/siteRotation/BodyView.kt similarity index 88% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/siteRotation/BodyView.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/siteRotation/BodyView.kt index d167440a1acf..1f21e24b028a 100644 --- a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/siteRotation/BodyView.kt +++ b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/siteRotation/BodyView.kt @@ -1,7 +1,5 @@ package app.aaps.core.ui.compose.siteRotation -import android.graphics.Path -import android.graphics.Region import androidx.compose.foundation.Canvas import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.layout.BoxWithConstraints @@ -12,14 +10,15 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Rect import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Matrix -import androidx.compose.ui.graphics.asComposePath +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.PathOperation import androidx.compose.ui.graphics.drawscope.Fill import androidx.compose.ui.graphics.drawscope.Stroke import androidx.compose.ui.input.pointer.pointerInput import app.aaps.core.data.model.TE -import androidx.compose.ui.graphics.Path as ComposePath @Composable fun BodyView( @@ -45,7 +44,7 @@ fun BodyView( location != TE.Location.NONE && ( if (editedType == TE.Type.CANNULA_CHANGE) location.pump else (showPumpSites && location.pump) || showCgmSites - ) + ) BoxWithConstraints(modifier = modifier.aspectRatio(aspectRatio)) { val canvasWidth = constraints.maxWidth.toFloat() @@ -87,8 +86,8 @@ fun BodyView( zones.forEach { (location, path) -> if (showLocation(location)) { - val originalComposePath = path.asComposePath() - val transformedPath = ComposePath().apply { + val originalComposePath = path + val transformedPath = Path().apply { addPath(originalComposePath) transform(matrix) } @@ -109,13 +108,20 @@ fun BodyView( } } -// Workaround to manage click, was not able to make composePath.contains( ... ) working +/** + * Whether a tap landed inside a body zone. + * + * The original used `android.graphics.Region`, with a note that `composePath.contains(...)` could + * not be made to work. That note was right, and so is the reason: Compose's `Path` has no point + * test. Intersecting with a tiny rectangle is the portable way to ask the same question, and unlike + * the `Region` version it does not round the tap to whole pixels - `Region` works in integers, so + * the old code truncated the coordinates before testing. + */ fun Path.containsPoint(x: Float, y: Float): Boolean { - val region = Region() - val bounds = android.graphics.RectF() - computeBounds(bounds, true) - region.setPath(this, Region(bounds.left.toInt(), bounds.top.toInt(), bounds.right.toInt(), bounds.bottom.toInt())) - return region.contains(x.toInt(), y.toInt()) + val probe = Path().apply { addRect(Rect(x - 0.5f, y - 0.5f, x + 0.5f, y + 0.5f)) } + val hit = Path() + hit.op(this, probe, PathOperation.Intersect) + return !hit.isEmpty } internal fun computeZoneColors( diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/siteRotation/SiteEntryList.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/siteRotation/SiteEntryList.kt similarity index 97% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/siteRotation/SiteEntryList.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/siteRotation/SiteEntryList.kt index 897fe26b225f..d7c8c0f901b8 100644 --- a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/siteRotation/SiteEntryList.kt +++ b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/siteRotation/SiteEntryList.kt @@ -25,16 +25,16 @@ import androidx.compose.runtime.Immutable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.vector.ImageVector -import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import app.aaps.core.data.model.TE import app.aaps.core.interfaces.utils.DateUtil import app.aaps.core.interfaces.utils.Translator -import app.aaps.core.ui.R +import app.aaps.core.ui.UiStrings import app.aaps.core.ui.compose.AapsSpacing import app.aaps.core.ui.compose.AapsTheme import app.aaps.core.ui.compose.icons.IcCannulaChange import app.aaps.core.ui.compose.icons.IcCgmInsert +import app.aaps.core.ui.compose.stringResource /** * Pre-formatted display data for a site entry row. @@ -162,7 +162,7 @@ private fun SiteEntryRow( ) { Icon( imageVector = Icons.Default.Edit, - contentDescription = stringResource(R.string.edit_site), + contentDescription = stringResource(UiStrings.edit_site), modifier = Modifier.size(20.dp), tint = MaterialTheme.colorScheme.primary ) diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/siteRotation/SiteEntryListPreviews.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/siteRotation/SiteEntryListPreviews.kt similarity index 100% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/siteRotation/SiteEntryListPreviews.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/siteRotation/SiteEntryListPreviews.kt diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/siteRotation/SiteLocationPicker.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/siteRotation/SiteLocationPicker.kt similarity index 93% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/siteRotation/SiteLocationPicker.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/siteRotation/SiteLocationPicker.kt index 7790c6ea16e4..6a16f356c41e 100644 --- a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/siteRotation/SiteLocationPicker.kt +++ b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/siteRotation/SiteLocationPicker.kt @@ -31,14 +31,14 @@ import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import app.aaps.core.data.model.TE -import app.aaps.core.ui.R +import app.aaps.core.ui.UiStrings import app.aaps.core.ui.compose.AapsSpacing import app.aaps.core.ui.compose.LocalDateUtil import app.aaps.core.ui.compose.icons.IcCannulaChange import app.aaps.core.ui.compose.icons.IcCgmInsert +import app.aaps.core.ui.compose.stringResource import kotlinx.coroutines.launch /** @@ -100,9 +100,9 @@ fun SiteLocationPicker( ) { Text( text = if (selectedLocation != TE.Location.NONE) - stringResource(R.string.selected_location, selectedLocationString ?: selectedLocation.text) + stringResource(UiStrings.selected_location, selectedLocationString ?: selectedLocation.text) else - stringResource(R.string.select_location), + stringResource(UiStrings.select_location), style = MaterialTheme.typography.bodyMedium, modifier = Modifier.weight(1f) ) @@ -112,7 +112,7 @@ fun SiteLocationPicker( ) { Icon( imageVector = selectedArrow.directionToComposeIcon(), - contentDescription = stringResource(R.string.select_arrow), + contentDescription = stringResource(UiStrings.select_arrow), modifier = Modifier.size(20.dp), tint = MaterialTheme.colorScheme.onSurface ) @@ -135,7 +135,7 @@ fun SiteLocationPicker( ) { Icon( imageVector = IcCannulaChange, - contentDescription = stringResource(R.string.careportal_pump_site_management), + contentDescription = stringResource(UiStrings.careportal_pump_site_management), modifier = Modifier.size(24.dp) ) } @@ -148,7 +148,7 @@ fun SiteLocationPicker( ) { Icon( imageVector = IcCgmInsert, - contentDescription = stringResource(R.string.careportal_cgm_site_management), + contentDescription = stringResource(UiStrings.careportal_cgm_site_management), modifier = Modifier.size(24.dp) ) } @@ -160,7 +160,7 @@ fun SiteLocationPicker( positionProvider = TooltipDefaults.rememberTooltipPositionProvider(TooltipAnchorPosition.Above), tooltip = { PlainTooltip { - Text(stringResource(R.string.site_filter_info)) + Text(stringResource(UiStrings.site_filter_info)) } }, state = tooltipState @@ -279,7 +279,7 @@ fun SiteLocationPickerWithFilters( ) { Icon( imageVector = IcCannulaChange, - contentDescription = stringResource(R.string.careportal_pump_site_management), + contentDescription = stringResource(UiStrings.careportal_pump_site_management), modifier = Modifier.size(24.dp) ) } @@ -291,7 +291,7 @@ fun SiteLocationPickerWithFilters( ) { Icon( imageVector = IcCgmInsert, - contentDescription = stringResource(R.string.careportal_cgm_site_management), + contentDescription = stringResource(UiStrings.careportal_cgm_site_management), modifier = Modifier.size(24.dp) ) } @@ -303,7 +303,7 @@ fun SiteLocationPickerWithFilters( positionProvider = TooltipDefaults.rememberTooltipPositionProvider(TooltipAnchorPosition.Above), tooltip = { PlainTooltip { - Text(stringResource(R.string.site_filter_info)) + Text(stringResource(UiStrings.site_filter_info)) } }, state = tooltipState diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/siteRotation/SiteLocationPickerPreviews.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/siteRotation/SiteLocationPickerPreviews.kt similarity index 100% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/siteRotation/SiteLocationPickerPreviews.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/siteRotation/SiteLocationPickerPreviews.kt diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/siteRotation/SiteLocationPickerScreen.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/siteRotation/SiteLocationPickerScreen.kt similarity index 94% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/siteRotation/SiteLocationPickerScreen.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/siteRotation/SiteLocationPickerScreen.kt index d5b7b4b4a05c..d16d5aea2913 100644 --- a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/siteRotation/SiteLocationPickerScreen.kt +++ b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/siteRotation/SiteLocationPickerScreen.kt @@ -16,10 +16,10 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier -import androidx.compose.ui.res.stringResource import app.aaps.core.data.model.TE -import app.aaps.core.ui.R +import app.aaps.core.ui.UiStrings import app.aaps.core.ui.compose.AapsTopAppBar +import app.aaps.core.ui.compose.stringResource /** * Full-screen wrapper for [SiteLocationPicker] with a top bar and confirm button. @@ -42,12 +42,12 @@ fun SiteLocationPickerScreen( Scaffold( topBar = { AapsTopAppBar( - title = { Text(stringResource(R.string.site_rotation)) }, + title = { Text(stringResource(UiStrings.site_rotation)) }, navigationIcon = { IconButton(onClick = onClose) { Icon( imageVector = Icons.Filled.Close, - contentDescription = stringResource(R.string.close) + contentDescription = stringResource(UiStrings.close) ) } }, @@ -58,7 +58,7 @@ fun SiteLocationPickerScreen( ) { Icon( imageVector = Icons.Default.Check, - contentDescription = stringResource(R.string.save), + contentDescription = stringResource(UiStrings.save), tint = if (selectedLocation != TE.Location.NONE) MaterialTheme.colorScheme.primary else diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/siteRotation/SiteLocationPickerScreenPreviews.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/siteRotation/SiteLocationPickerScreenPreviews.kt similarity index 100% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/siteRotation/SiteLocationPickerScreenPreviews.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/siteRotation/SiteLocationPickerScreenPreviews.kt diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/siteRotation/SiteLocationSummary.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/siteRotation/SiteLocationSummary.kt similarity index 89% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/siteRotation/SiteLocationSummary.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/siteRotation/SiteLocationSummary.kt index ee51fd8b23eb..e5d72ff8fff5 100644 --- a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/siteRotation/SiteLocationSummary.kt +++ b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/siteRotation/SiteLocationSummary.kt @@ -14,13 +14,13 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import app.aaps.core.data.model.TE -import app.aaps.core.ui.R +import app.aaps.core.ui.UiStrings import app.aaps.core.ui.compose.AapsSpacing import app.aaps.core.ui.compose.icons.IcCannulaChange import app.aaps.core.ui.compose.icons.IcCgmInsert +import app.aaps.core.ui.compose.stringResource /** * Compact summary widget for embedding in Fill/Care dialogs. @@ -65,21 +65,21 @@ fun SiteLocationSummary( if (hasSelection) { Text( - text = stringResource(R.string.selected_location, selectedLocationString), + text = stringResource(UiStrings.selected_location, selectedLocationString), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.primary, modifier = Modifier.weight(1f) ) } else if (lastLocationString != null) { Text( - text = stringResource(R.string.last_site_location, lastLocationString), + text = stringResource(UiStrings.last_site_location, lastLocationString), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.weight(1f) ) } else { Text( - text = stringResource(R.string.select_site_location), + text = stringResource(UiStrings.select_site_location), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.weight(1f) @@ -87,7 +87,7 @@ fun SiteLocationSummary( } OutlinedButton(onClick = onPickSiteClick) { - Text(stringResource(R.string.pick_site)) + Text(stringResource(UiStrings.pick_site)) } } } diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/siteRotation/SiteLocationSummaryPreviews.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/siteRotation/SiteLocationSummaryPreviews.kt similarity index 100% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/siteRotation/SiteLocationSummaryPreviews.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/siteRotation/SiteLocationSummaryPreviews.kt diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/siteRotation/SiteLocationWizardStep.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/siteRotation/SiteLocationWizardStep.kt similarity index 92% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/siteRotation/SiteLocationWizardStep.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/siteRotation/SiteLocationWizardStep.kt index c1a3882637c0..2bf3700ea9e0 100644 --- a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/siteRotation/SiteLocationWizardStep.kt +++ b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/siteRotation/SiteLocationWizardStep.kt @@ -2,12 +2,12 @@ package app.aaps.core.ui.compose.siteRotation import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue -import androidx.compose.ui.res.stringResource import androidx.lifecycle.compose.collectAsStateWithLifecycle import app.aaps.core.data.model.TE -import app.aaps.core.ui.R +import app.aaps.core.ui.UiStrings import app.aaps.core.ui.compose.pump.WizardButton import app.aaps.core.ui.compose.pump.WizardStepLayout +import app.aaps.core.ui.compose.stringResource import kotlinx.coroutines.flow.StateFlow /** @@ -40,12 +40,12 @@ fun SiteLocationWizardStep(host: SiteLocationStepHost) { WizardStepLayout( primaryButton = WizardButton( - text = stringResource(R.string.next), + text = stringResource(UiStrings.next), onClick = { host.completeSiteLocation() }, enabled = siteLocation != TE.Location.NONE ), secondaryButton = WizardButton( - text = stringResource(R.string.skip), + text = stringResource(UiStrings.skip), onClick = { host.updateSiteLocation(TE.Location.NONE) host.updateSiteArrow(TE.Arrow.NONE) diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/siteRotation/SiteLocationWizardStepPreviews.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/siteRotation/SiteLocationWizardStepPreviews.kt similarity index 100% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/siteRotation/SiteLocationWizardStepPreviews.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/siteRotation/SiteLocationWizardStepPreviews.kt diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/siteRotation/ZoomableBodyDiagram.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/siteRotation/ZoomableBodyDiagram.kt similarity index 100% rename from core/ui/src/main/kotlin/app/aaps/core/ui/compose/siteRotation/ZoomableBodyDiagram.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/compose/siteRotation/ZoomableBodyDiagram.kt diff --git a/core/ui/src/commonMain/kotlin/app/aaps/core/ui/elements/WeekDay.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/elements/WeekDay.kt new file mode 100644 index 000000000000..cc78582d15d5 --- /dev/null +++ b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/elements/WeekDay.kt @@ -0,0 +1,87 @@ +package app.aaps.core.ui.elements + +import app.aaps.core.keys.interfaces.TextRef +import app.aaps.core.ui.UiStrings +import kotlinx.datetime.TimeZone +import kotlinx.datetime.toLocalDateTime +import kotlin.time.Instant + +open class WeekDay { + + enum class DayOfWeek { + MONDAY, TUESDAY, WEDNESDAY, THURSDAY, FRIDAY, SATURDAY, SUNDAY; + + fun toCalendarInt(): Int { + return calendarInts[ordinal] + } + + val shortName: TextRef + get() = shortNames[ordinal] + + companion object { + + // The numbers java.util.Calendar uses: SUNDAY is 1 and SATURDAY is 7, so Monday is 2. + // They are written out rather than taken from Calendar because these values are a + // persisted contract - automation triggers store them and `getSelectedDays()` hands + // them to other modules - so they must not change, and they must not need the JVM. + private val calendarInts = intArrayOf(2, 3, 4, 5, 6, 7, 1) + private val shortNames = arrayOf( + UiStrings.weekday_monday_short, + UiStrings.weekday_tuesday_short, + UiStrings.weekday_wednesday_short, + UiStrings.weekday_thursday_short, + UiStrings.weekday_friday_short, + UiStrings.weekday_saturday_short, + UiStrings.weekday_sunday_short + ) + + fun fromCalendarInt(day: Int): DayOfWeek { + for (i in calendarInts.indices) { + if (calendarInts[i] == day) return entries[i] + } + throw IllegalStateException("Invalid day") + } + } + } + + val weekdays = BooleanArray(DayOfWeek.entries.size) + + init { + for (day in DayOfWeek.entries) set(day, false) + } + + fun setAll(value: Boolean) { + for (day in DayOfWeek.entries) set(day, value) + } + + operator fun set(day: DayOfWeek, value: Boolean): WeekDay { + weekdays[day.ordinal] = value + return this + } + + fun isSet(day: DayOfWeek): Boolean = weekdays[day.ordinal] + + /** + * Which weekday a moment falls on, in the device's own time zone - an automation set for Monday + * has to mean the user's Monday, not UTC's. + * + * `kotlinx.datetime.DayOfWeek` runs MONDAY..SUNDAY, the same order as [DayOfWeek] here, so the + * ordinal carries across directly and no ISO-number conversion is involved. + */ + fun isSet(timestamp: Long): Boolean { + val dayOfWeek = Instant.fromEpochMilliseconds(timestamp) + .toLocalDateTime(TimeZone.currentSystemDefault()) + .dayOfWeek + return isSet(DayOfWeek.entries[dayOfWeek.ordinal]) + } + + fun getSelectedDays(): List { + val selectedDays: MutableList = ArrayList() + for (i in weekdays.indices) { + val day = DayOfWeek.entries[i] + val selected = weekdays[i] + if (selected) selectedDays.add(day.toCalendarInt()) + } + return selectedDays + } +} diff --git a/core/objects/src/main/kotlin/app/aaps/core/objects/extensions/CobInfoExtension.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/extensions/CobInfoDisplay.kt similarity index 53% rename from core/objects/src/main/kotlin/app/aaps/core/objects/extensions/CobInfoExtension.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/extensions/CobInfoDisplay.kt index 559c22a9ff38..2d8a4e06606f 100644 --- a/core/objects/src/main/kotlin/app/aaps/core/objects/extensions/CobInfoExtension.kt +++ b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/extensions/CobInfoDisplay.kt @@ -1,9 +1,16 @@ -package app.aaps.core.objects.extensions +package app.aaps.core.ui.extensions import app.aaps.core.data.iob.CobInfo -import app.aaps.core.interfaces.resources.ResourceHelper +import app.aaps.core.interfaces.InterfacesStrings +import app.aaps.core.interfaces.resources.TextResolver import app.aaps.core.interfaces.utils.DecimalFormatter +/** + * Text for a [CobInfo] on screen. + * + * These build user visible text out of this module's strings, so they belong here. They used to sit + * in `:core:objects`, which made a domain module depend on this one only to name a string. + */ fun CobInfo.generateCOBString(decimalFormatter: DecimalFormatter): String { var cobStringResult = "--g" displayCob?.let { displayCob -> @@ -15,9 +22,9 @@ fun CobInfo.generateCOBString(decimalFormatter: DecimalFormatter): String { return cobStringResult } -fun CobInfo.displayText(rh: ResourceHelper, decimalFormatter: DecimalFormatter): String? = +fun CobInfo.displayText(rh: TextResolver, decimalFormatter: DecimalFormatter): String? = displayCob?.let { displayCob -> - var cobText = rh.gs(app.aaps.core.ui.R.string.format_carbs, displayCob.toInt()) + var cobText = rh.gs(InterfacesStrings.format_carbs, displayCob.toInt()) if (futureCarbs > 0) cobText += "(" + decimalFormatter.to0Decimal(futureCarbs) + ")" cobText } diff --git a/core/ui/src/commonMain/kotlin/app/aaps/core/ui/extensions/ExtendedBolusDisplay.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/extensions/ExtendedBolusDisplay.kt new file mode 100644 index 000000000000..836b00486b35 --- /dev/null +++ b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/extensions/ExtendedBolusDisplay.kt @@ -0,0 +1,23 @@ +package app.aaps.core.ui.extensions + +import app.aaps.core.data.model.EB +import app.aaps.core.data.model.getPassedDurationToTimeInMinutes +import app.aaps.core.data.time.T +import app.aaps.core.interfaces.insulin.ConcentrationHelper +import app.aaps.core.interfaces.pump.PumpRate +import app.aaps.core.interfaces.resources.TextResolver +import app.aaps.core.interfaces.utils.DateUtil +import app.aaps.core.ui.UiStrings + +/** + * Text for an extended bolus on screen. The record's own maths stays in `:core:objects` - see + * [TB.toStringFull] for why the split runs where it does. + */ +fun EB.toStringFull(dateUtil: DateUtil, rh: TextResolver): String = + rh.gs(UiStrings.extended_bolus_full, rate, dateUtil.timeString(timestamp), getPassedDurationToTimeInMinutes(dateUtil.now()), T.msecs(duration).mins()) + +fun EB.toStringFull(dateUtil: DateUtil, ch: ConcentrationHelper): String = + "${ch.basalRateString(PumpRate(rate), true)} ${dateUtil.timeString(timestamp)} ${getPassedDurationToTimeInMinutes(dateUtil.now())}/${T.msecs(duration).mins()}" + +fun EB.toStringMedium(dateUtil: DateUtil, rh: TextResolver): String = + rh.gs(UiStrings.extended_bolus_medium, rate, getPassedDurationToTimeInMinutes(dateUtil.now()), T.msecs(duration).mins()) diff --git a/core/ui/src/commonMain/kotlin/app/aaps/core/ui/extensions/TemporaryBasalDisplay.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/extensions/TemporaryBasalDisplay.kt new file mode 100644 index 000000000000..dd88543c3b60 --- /dev/null +++ b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/extensions/TemporaryBasalDisplay.kt @@ -0,0 +1,46 @@ +package app.aaps.core.ui.extensions + +import app.aaps.core.data.model.TB +import app.aaps.core.data.model.durationInMinutes +import app.aaps.core.data.model.getPassedDurationToTimeInMinutes +import app.aaps.core.interfaces.insulin.ConcentrationHelper +import app.aaps.core.interfaces.profile.Profile +import app.aaps.core.interfaces.pump.PumpRate +import app.aaps.core.interfaces.resources.TextResolver +import app.aaps.core.interfaces.utils.DateUtil +import app.aaps.core.ui.UiStrings + +/** + * Text for a temporary basal on screen. + * + * The maths that belongs to the record - `iobCalc`, `convertedToAbsolute`, `convertedToPercent` - + * stays in `:core:objects`. Only the part that formats for a reader lives here, next to the strings + * it uses. What made that possible was moving the two time helpers down to `:core:data`: while they + * sat in `:core:objects`, using them from here would have meant `:core:ui` depending on + * `:core:objects`, and that module still depends on this one. + */ +private fun TB.netExtendedRate(profile: Profile) = rate - profile.getBasal(timestamp) + +fun TB.toStringFull(profile: Profile, dateUtil: DateUtil, rh: TextResolver): String { + val timeAndDuration = "${dateUtil.timeString(timestamp)} ${getPassedDurationToTimeInMinutes(dateUtil.now())}/${durationInMinutes}'" + + return when { + type == TB.Type.FAKE_EXTENDED -> rh.gs(UiStrings.temp_basal_tsf_fake_extended, rate, netExtendedRate(profile), timeAndDuration) + isAbsolute -> rh.gs(UiStrings.temp_basal_tsf_absolute, rate, timeAndDuration) + else -> rh.gs(UiStrings.temp_basal_tsf_percent, rate, timeAndDuration) + } +} + +fun TB.toStringFull(profile: Profile, dateUtil: DateUtil, ch: ConcentrationHelper): String { + val timeAndDuration = "${dateUtil.timeString(timestamp)} ${getPassedDurationToTimeInMinutes(dateUtil.now())}/${durationInMinutes}'" + + return when { + type == TB.Type.FAKE_EXTENDED -> "${ch.basalRateString(PumpRate(rate), true)} (${netExtendedRate(profile)}E) $timeAndDuration" + isAbsolute -> "${ch.basalRateString(PumpRate(rate), true)} $timeAndDuration" + else -> "${ch.basalRateString(PumpRate(rate), false)} $timeAndDuration" + } +} + +fun TB.toStringShort(rh: TextResolver): String = + if (isAbsolute || type == TB.Type.FAKE_EXTENDED) rh.gs(UiStrings.pump_base_basal_rate, rate) + else rh.gs(UiStrings.formatPercent, rate) diff --git a/core/objects/src/main/kotlin/app/aaps/core/objects/extensions/TemporaryTargetExtension.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/extensions/TemporaryTargetDisplay.kt similarity index 63% rename from core/objects/src/main/kotlin/app/aaps/core/objects/extensions/TemporaryTargetExtension.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/extensions/TemporaryTargetDisplay.kt index 80f45a489aba..c8416b5030ba 100644 --- a/core/objects/src/main/kotlin/app/aaps/core/objects/extensions/TemporaryTargetExtension.kt +++ b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/extensions/TemporaryTargetDisplay.kt @@ -1,13 +1,20 @@ -package app.aaps.core.objects.extensions +package app.aaps.core.ui.extensions import app.aaps.core.data.configuration.Constants import app.aaps.core.data.model.GlucoseUnit import app.aaps.core.data.model.TT import app.aaps.core.interfaces.profile.ProfileUtil -import app.aaps.core.interfaces.resources.ResourceHelper +import app.aaps.core.interfaces.resources.TextResolver import app.aaps.core.interfaces.utils.DecimalFormatter +import app.aaps.core.ui.UiStrings import kotlin.time.Duration.Companion.milliseconds +/** + * Text for a [TT] on screen. + * + * The maths that belongs to the model - `TT.target()` - stays in `:core:objects`. Only the part that + * formats for a reader lives here, next to the strings it uses. + */ fun TT.lowValueToUnitsToString(units: GlucoseUnit, decimalFormatter: DecimalFormatter): String = if (units == GlucoseUnit.MGDL) decimalFormatter.to0Decimal(this.lowTarget) else decimalFormatter.to1Decimal(this.lowTarget * Constants.MGDL_TO_MMOLL) @@ -16,10 +23,7 @@ fun TT.highValueToUnitsToString(units: GlucoseUnit, decimalFormatter: DecimalFor if (units == GlucoseUnit.MGDL) decimalFormatter.to0Decimal(this.highTarget) else decimalFormatter.to1Decimal(this.highTarget * Constants.MGDL_TO_MMOLL) -fun TT.target(): Double = - (this.lowTarget + this.highTarget) / 2 - -fun TT.friendlyDescription(units: GlucoseUnit, rh: ResourceHelper, profileUtil: ProfileUtil): String = +fun TT.friendlyDescription(units: GlucoseUnit, rh: TextResolver, profileUtil: ProfileUtil): String = profileUtil.toTargetRangeString(lowTarget, highTarget, GlucoseUnit.MGDL, units) + profileUtil.unitLabel + - "@" + rh.gs(app.aaps.core.ui.R.string.format_mins, duration.milliseconds.inWholeMinutes) + "(" + reason.text + ")" + "@" + rh.gs(UiStrings.format_mins, duration.milliseconds.inWholeMinutes) + "(" + reason.text + ")" diff --git a/core/objects/src/main/kotlin/app/aaps/core/objects/extensions/GlucoseValueExtension.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/extensions/TrendArrowIcon.kt similarity index 56% rename from core/objects/src/main/kotlin/app/aaps/core/objects/extensions/GlucoseValueExtension.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/extensions/TrendArrowIcon.kt index f45a59962664..315073e4c08d 100644 --- a/core/objects/src/main/kotlin/app/aaps/core/objects/extensions/GlucoseValueExtension.kt +++ b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/extensions/TrendArrowIcon.kt @@ -1,12 +1,7 @@ -package app.aaps.core.objects.extensions +package app.aaps.core.ui.extensions import androidx.compose.ui.graphics.vector.ImageVector -import app.aaps.core.data.configuration.Constants -import app.aaps.core.data.iob.InMemoryGlucoseValue -import app.aaps.core.data.model.GV -import app.aaps.core.data.model.GlucoseUnit import app.aaps.core.data.model.TrendArrow -import app.aaps.core.interfaces.utils.DateUtil import app.aaps.core.ui.compose.icons.IcArrowDoubleDown import app.aaps.core.ui.compose.icons.IcArrowDoubleUp import app.aaps.core.ui.compose.icons.IcArrowFlat @@ -15,23 +10,14 @@ import app.aaps.core.ui.compose.icons.IcArrowFortyfiveUp import app.aaps.core.ui.compose.icons.IcArrowInvalid import app.aaps.core.ui.compose.icons.IcArrowSimpleDown import app.aaps.core.ui.compose.icons.IcArrowSimpleUp -import org.json.JSONObject - -fun GV.toJson(isAdd: Boolean, dateUtil: DateUtil): JSONObject = - JSONObject() - .put("device", sourceSensor.text) - .put("date", timestamp) - .put("dateString", dateUtil.toISOString(timestamp)) - .put("isValid", isValid) - .put("sgv", value) - .put("direction", trendArrow.text) - .put("type", "sgv") - .also { if (isAdd && ids.nightscoutId != null) it.put("_id", ids.nightscoutId) } - -fun InMemoryGlucoseValue.valueToUnits(units: GlucoseUnit): Double = - if (units == GlucoseUnit.MGDL) recalculated - else recalculated * Constants.MGDL_TO_MMOLL +/** + * The icon that draws a [TrendArrow]. + * + * Lives here and not next to the model, for the same reason a model does not carry a colour: + * [TrendArrow] is the classification, and picking a picture for it is the job of the layer that + * draws. It used to sit in `:core:objects`, which made a domain module depend on this one. + */ fun TrendArrow.directionToIcon(): ImageVector = when (this) { TrendArrow.TRIPLE_DOWN -> IcArrowInvalid diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/search/SearchableItem.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/search/SearchableItem.kt similarity index 69% rename from core/ui/src/main/kotlin/app/aaps/core/ui/search/SearchableItem.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/search/SearchableItem.kt index 9353f65e9c6a..d4b25b7008ec 100644 --- a/core/ui/src/main/kotlin/app/aaps/core/ui/search/SearchableItem.kt +++ b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/search/SearchableItem.kt @@ -4,9 +4,10 @@ import androidx.compose.ui.graphics.vector.ImageVector import app.aaps.core.interfaces.navigation.ElementType import app.aaps.core.interfaces.plugin.PluginBase import app.aaps.core.keys.interfaces.PreferenceKey -import app.aaps.core.ui.compose.navigation.descriptionResId +import app.aaps.core.keys.interfaces.TextRef +import app.aaps.core.ui.compose.navigation.description import app.aaps.core.ui.compose.navigation.icon -import app.aaps.core.ui.compose.navigation.labelResId +import app.aaps.core.ui.compose.navigation.label import app.aaps.core.ui.compose.preference.PreferenceSubScreenDef /** @@ -21,9 +22,9 @@ sealed class SearchableItem { abstract val key: String /** - * Resource ID for the item's display title. + * The item's display title. */ - abstract val titleResId: Int + abstract val title: TextRef /** * Compose ImageVector icon. @@ -31,9 +32,9 @@ sealed class SearchableItem { open val icon: ImageVector? = null /** - * Optional resource ID for a summary/description. + * Optional summary/description. */ - open val summaryResId: Int? = null + open val summary: TextRef? = null /** * Optional reference to the plugin that owns this item. @@ -57,8 +58,8 @@ sealed class SearchableItem { ) : SearchableItem() { override val key: String = preferenceKey.key - override val titleResId: Int = preferenceKey.titleResId - override val summaryResId: Int? = preferenceKey.summaryResId + override val title: TextRef = preferenceKey.title + override val summary: TextRef? = preferenceKey.summary override val plugin: PluginBase? = ownerPlugin } @@ -75,8 +76,8 @@ sealed class SearchableItem { ) : SearchableItem() { override val key: String = screenDef.key - override val titleResId: Int = screenDef.titleResId - override val summaryResId: Int? = screenDef.summaryResId + override val title: TextRef = screenDef.title + override val summary: TextRef? = screenDef.summary override val plugin: PluginBase? = ownerPlugin override val icon: ImageVector? = screenDef.icon } @@ -92,11 +93,11 @@ sealed class SearchableItem { ) : SearchableItem() { override val key: String = elementType.name - override val titleResId: Int = elementType.labelResId() + override val title: TextRef = elementType.label() ?: TextRef.Literal("") @Deprecated("use icon") override val icon: ImageVector = elementType.icon() - override val summaryResId: Int? = elementType.descriptionResId().takeIf { it != 0 } + override val summary: TextRef? = elementType.description() } /** @@ -109,9 +110,11 @@ sealed class SearchableItem { val pluginRef: PluginBase ) : SearchableItem() { - override val key: String = pluginRef.javaClass.simpleName - override val titleResId: Int = pluginRef.pluginDescription.pluginName - override val summaryResId: Int? = pluginRef.pluginDescription.description.takeIf { it != -1 } + // pluginId is the documented stable identity for a plugin and defaults to the class simple name, + // so this is the same string javaClass.simpleName gave - without tying the file to the JVM. + override val key: String = pluginRef.pluginId + override val title: TextRef = pluginRef.pluginDescription.pluginName ?: TextRef.Literal(pluginRef.pluginId) + override val summary: TextRef? = pluginRef.pluginDescription.description override val plugin: PluginBase = pluginRef } @@ -130,6 +133,13 @@ sealed class SearchableItem { ) : SearchableItem() { override val key: String = url - override val titleResId: Int = 0 // not used — title is dynamic + + /** + * The title comes from the ReadTheDocs API, so it is plain text, not a resource. It used to + * be stored as resource id 0, which the index then turned into an empty string - wiki hits + * could not be found by their own title. + */ + override val title: TextRef = TextRef.Literal(wikiTitle) + override val summary: TextRef? = snippet?.let { TextRef.Literal(it) } } } diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/search/SearchableProvider.kt b/core/ui/src/commonMain/kotlin/app/aaps/core/ui/search/SearchableProvider.kt similarity index 100% rename from core/ui/src/main/kotlin/app/aaps/core/ui/search/SearchableProvider.kt rename to core/ui/src/commonMain/kotlin/app/aaps/core/ui/search/SearchableProvider.kt diff --git a/core/ui/src/iosMain/kotlin/app/aaps/core/ui/compose/FontPadding.ios.kt b/core/ui/src/iosMain/kotlin/app/aaps/core/ui/compose/FontPadding.ios.kt new file mode 100644 index 000000000000..6872a3567e4a --- /dev/null +++ b/core/ui/src/iosMain/kotlin/app/aaps/core/ui/compose/FontPadding.ios.kt @@ -0,0 +1,6 @@ +package app.aaps.core.ui.compose + +import androidx.compose.ui.text.PlatformTextStyle + +/** iOS text layout has no legacy font padding, so there is nothing to switch off. */ +actual fun noFontPaddingPlatformStyle(): PlatformTextStyle? = null diff --git a/core/ui/src/iosMain/kotlin/app/aaps/core/ui/compose/PlatformTheme.ios.kt b/core/ui/src/iosMain/kotlin/app/aaps/core/ui/compose/PlatformTheme.ios.kt new file mode 100644 index 000000000000..07f7bd90b9bb --- /dev/null +++ b/core/ui/src/iosMain/kotlin/app/aaps/core/ui/compose/PlatformTheme.ios.kt @@ -0,0 +1,21 @@ +package app.aaps.core.ui.compose + +import androidx.compose.runtime.Composable +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalWindowInfo +import kotlin.math.min + +/** iOS styles its status bar through the view controller, not from inside the composition. */ +@Composable +actual fun SystemBarAppearance(isDark: Boolean) = Unit + +/** + * Taken from the window rather than the device, which is the closest iOS equivalent. An iPad in + * split view therefore reports the pane it actually has, which is the number a layout wants anyway. + */ +@Composable +actual fun smallestScreenWidthDp(): Int { + val size = LocalWindowInfo.current.containerSize + val density = LocalDensity.current + return with(density) { min(size.width, size.height).toDp().value.toInt() } +} diff --git a/core/ui/src/iosMain/kotlin/app/aaps/core/ui/compose/TextRefResource.ios.kt b/core/ui/src/iosMain/kotlin/app/aaps/core/ui/compose/TextRefResource.ios.kt new file mode 100644 index 000000000000..91c1834346db --- /dev/null +++ b/core/ui/src/iosMain/kotlin/app/aaps/core/ui/compose/TextRefResource.ios.kt @@ -0,0 +1,25 @@ +package app.aaps.core.ui.compose + +import androidx.compose.runtime.Composable +import app.aaps.core.keys.interfaces.TextRef + +/** + * PLACEHOLDER. iOS has no string table yet, so this returns something readable rather than the real + * translation. + * + * It exists because `expect` needs an `actual` on every target, and without an iOS one the module + * would not compile for iOS at all - which is the check that keeps commonMain honest. Nothing on + * iOS runs yet, so nothing shows these strings to anyone. + * + * When iOS gains a string table, this is the only place that has to change: + * - [TextRef.Named] already carries the name from `strings.xml` and the module that owns it, which + * is exactly what a `.strings` lookup needs. That is the form to aim for. + * - [TextRef.AndroidRes] carries a number that means nothing off Android. Files still using it have + * to move to [TextRef.Named] first, so it deliberately has no sensible answer here. + */ +@Composable +actual fun stringResource(ref: TextRef): String = when (ref) { + is TextRef.Literal -> ref.text + is TextRef.Named -> ref.name + is TextRef.AndroidRes -> "?" +} diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/clientcontrol/FailureReasonText.kt b/core/ui/src/main/kotlin/app/aaps/core/ui/clientcontrol/FailureReasonText.kt deleted file mode 100644 index 8eeb079972f3..000000000000 --- a/core/ui/src/main/kotlin/app/aaps/core/ui/clientcontrol/FailureReasonText.kt +++ /dev/null @@ -1,31 +0,0 @@ -package app.aaps.core.ui.clientcontrol - -import androidx.annotation.StringRes -import app.aaps.core.interfaces.clientcontrol.FailureReason -import app.aaps.core.ui.R - -/** - * The single localized-string mapping for a client-control [FailureReason], shared by the phone pending dialog - * (`ClientControlPendingDialog`) and the wear relay error path (`DataHandlerMobile`). Resolve the returned id on - * the SHOWING device (`stringResource` / `ResourceHelper.gs`) so the text is in that device's locale. - */ -@StringRes -fun FailureReason.failTextResId(): Int = when (this) { - FailureReason.NotPaired -> R.string.clientcontrol_fail_not_paired - FailureReason.NotReachable -> R.string.clientcontrol_fail_not_reachable - FailureReason.NoReply -> R.string.clientcontrol_fail_no_reply - FailureReason.Expired -> R.string.clientcontrol_fail_expired - FailureReason.Busy -> R.string.clientcontrol_fail_busy - FailureReason.SendFailed -> R.string.clientcontrol_fail_send_failed - FailureReason.NoActiveProfile -> R.string.clientcontrol_fail_no_active_profile - FailureReason.SceneNotFound -> R.string.clientcontrol_fail_scene_not_found - FailureReason.SceneDisabled -> R.string.clientcontrol_fail_scene_disabled - FailureReason.PartialFailure -> R.string.clientcontrol_fail_partial - FailureReason.ExecutionFailed -> R.string.clientcontrol_fail_execution - FailureReason.ControlDisabled -> R.string.clientcontrol_fail_control_disabled - FailureReason.NoAction -> R.string.no_action_selected - FailureReason.NoPendingBolus -> R.string.clientcontrol_fail_no_pending_bolus - FailureReason.BolusComputeFailed -> R.string.clientcontrol_fail_bolus_compute - FailureReason.Internal -> R.string.clientcontrol_fail_internal - FailureReason.Unknown -> R.string.clientcontrol_fail_unknown -} diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/FormatUtils.kt b/core/ui/src/main/kotlin/app/aaps/core/ui/compose/FormatUtils.kt deleted file mode 100644 index 83ade0f7f6ea..000000000000 --- a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/FormatUtils.kt +++ /dev/null @@ -1,83 +0,0 @@ -package app.aaps.core.ui.compose - -import androidx.compose.runtime.Composable -import androidx.compose.ui.res.stringResource -import app.aaps.core.data.format.NumberFormat -import app.aaps.core.interfaces.resources.ResourceHelper -import app.aaps.core.ui.R -import kotlin.math.abs -import kotlin.math.roundToInt -import app.aaps.core.keys.R as KeysR - -/** - * Formats minutes as duration string: "X h Y min" when >= 60 (omits minutes if zero), "X min" otherwise. - * Composable version using stringResource. - */ -@Composable -fun formatMinutesAsDuration(minutes: Int): String { - val abs = abs(minutes) - val sign = if (minutes < 0) "-" else "" - return if (abs >= 60) { - val hours = abs / 60 - val mins = abs % 60 - sign + if (mins == 0) stringResource(R.string.format_hours_only, hours) - else stringResource(R.string.format_hour_minute, hours, mins) - } else { - stringResource(R.string.format_mins, minutes) - } -} - -/** - * Formats minutes as duration string: "X h Y min" when >= 60 (omits minutes if zero), "X min" otherwise. - * Non-composable version using ResourceHelper. - */ -fun formatMinutesAsDuration(minutes: Int, rh: ResourceHelper): String { - val abs = abs(minutes) - val sign = if (minutes < 0) "-" else "" - return if (abs >= 60) { - val hours = abs / 60 - val mins = abs % 60 - sign + if (mins == 0) rh.gs(R.string.format_hours_only, hours) - else rh.gs(R.string.format_hour_minute, hours, mins) - } else { - rh.gs(R.string.format_mins, minutes) - } -} - -/** - * Formats a slider/input value for display, handling minutes-as-duration, resource format strings, - * unit labels, and plain value formatting. - * - * Priority order: - * 1. Minutes unit (unitLabelResId == units_min) → "X h Y min" or "X min" - * 2. valueFormatResId → stringResource with value (as Int if formatAsInt, else Double) - * 3. unitLabel non-empty → "formatted_value unitLabel" - * 4. Plain → valueFormat.format(value) - */ -@Composable -fun formatSliderDisplayValue( - value: Double, - unitLabelResId: Int = 0, - valueFormatResId: Int? = null, - formatAsInt: Boolean = false, - valueFormat: NumberFormat, - unitLabel: String = "" -): String { - val isMinutesUnit = unitLabelResId == KeysR.string.units_min - val resolvedUnitLabel = when { - unitLabelResId != 0 -> stringResource(unitLabelResId) - unitLabel.isNotEmpty() -> unitLabel - else -> "" - } - return when { - isMinutesUnit -> formatMinutesAsDuration(value.roundToInt()) - - valueFormatResId != null -> { - if (formatAsInt) stringResource(valueFormatResId, value.roundToInt()) - else stringResource(valueFormatResId, value) - } - - resolvedUnitLabel.isNotEmpty() -> "${valueFormat.format(value)} $resolvedUnitLabel" - else -> valueFormat.format(value) - } -} diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/PluginCategoryTitle.kt b/core/ui/src/main/kotlin/app/aaps/core/ui/compose/PluginCategoryTitle.kt deleted file mode 100644 index 77bd136deaaa..000000000000 --- a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/PluginCategoryTitle.kt +++ /dev/null @@ -1,24 +0,0 @@ -package app.aaps.core.ui.compose - -import androidx.annotation.StringRes -import app.aaps.core.data.plugin.PluginType -import app.aaps.core.ui.R - -/** - * Single source of truth for the plugin-category → title string mapping, shared by the Configuration - * screen and the Quick-launch config. Exhaustive `when` (no `else`) so adding a [PluginType] is a - * compile-forced decision here. - */ -@StringRes -fun pluginCategoryTitleRes(type: PluginType): Int = when (type) { - PluginType.BGSOURCE -> R.string.configbuilder_bgsource - PluginType.SMOOTHING -> R.string.configbuilder_smoothing - PluginType.CALIBRATION -> R.string.configbuilder_calibration - PluginType.PUMP -> R.string.configbuilder_pump - PluginType.SENSITIVITY -> R.string.configbuilder_sensitivity - PluginType.APS -> R.string.configbuilder_aps - PluginType.LOOP -> R.string.configbuilder_loop - PluginType.CONSTRAINTS -> R.string.constraints - PluginType.SYNC -> R.string.configbuilder_sync - PluginType.GENERAL -> R.string.configbuilder_general -} diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/preference/AdaptiveIntentPreference.kt b/core/ui/src/main/kotlin/app/aaps/core/ui/compose/preference/AdaptiveIntentPreference.kt deleted file mode 100644 index 5cdea2ee032a..000000000000 --- a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/preference/AdaptiveIntentPreference.kt +++ /dev/null @@ -1,186 +0,0 @@ -/* - * Adaptive Intent Preferences for Jetpack Compose - */ - -package app.aaps.core.ui.compose.preference - -import android.content.Intent -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.platform.LocalUriHandler -import androidx.compose.ui.res.stringResource -import app.aaps.core.keys.interfaces.IntentPreferenceKey -import app.aaps.core.keys.interfaces.VisibilityContext -import app.aaps.core.ui.compose.ComposeScreenContent -import app.aaps.core.ui.compose.dialogs.OkCancelDialog - -/** - * Composable intent preference for use inside card sections. - * - * @param titleResId Optional title resource ID. If 0 or not provided, uses intentKey.titleResId - * @param summaryResId Optional summary resource ID. If null, uses intentKey.summaryResId - * @param visibilityContext Optional context for evaluating runtime visibility/enabled conditions - */ -@Composable -fun AdaptiveIntentPreferenceItem( - intentKey: IntentPreferenceKey, - titleResId: Int = 0, - summaryResId: Int? = null, - onClick: () -> Unit, - visibilityContext: VisibilityContext? = null -) { - val effectiveTitleResId = if (titleResId != 0) titleResId else intentKey.titleResId - val effectiveSummaryResId = summaryResId ?: intentKey.summaryResId - - // Skip if no title resource is available - if (effectiveTitleResId == 0) return - - val visibility = calculateIntentPreferenceVisibility( - intentKey = intentKey, - visibilityContext = visibilityContext - ) - - if (!visibility.visible) return - - // Show confirmation dialog when confirmationMessageResId is set on the key - val confirmationResId = intentKey.confirmationMessageResId - var showConfirmation by remember { mutableStateOf(false) } - - if (showConfirmation && confirmationResId != null) { - OkCancelDialog( - title = stringResource(effectiveTitleResId), - message = stringResource(confirmationResId), - onConfirm = { - onClick() - showConfirmation = false - }, - onDismiss = { showConfirmation = false } - ) - } - - val effectiveOnClick = if (confirmationResId != null) { - { showConfirmation = true } - } else { - onClick - } - - Preference( - title = { Text(stringResource(effectiveTitleResId)) }, - summary = effectiveSummaryResId?.let { { Text(stringResource(it)) } }, - enabled = visibility.enabled, - onClick = if (visibility.enabled) effectiveOnClick else null - ) -} - -/** - * Composable URL preference for use inside card sections. - * - * @param titleResId Optional title resource ID. If 0 or not provided, uses intentKey.titleResId - */ -@Composable -fun AdaptiveUrlPreferenceItem( - intentKey: IntentPreferenceKey, - titleResId: Int = 0, - url: String, - visibilityContext: VisibilityContext? = null -) { - val effectiveTitleResId = if (titleResId != 0) titleResId else intentKey.titleResId - - // Skip if no title resource is available - if (effectiveTitleResId == 0) return - - val visibility = calculateIntentPreferenceVisibility( - intentKey = intentKey, - visibilityContext = visibilityContext - ) - - if (!visibility.visible) return - - val uriHandler = LocalUriHandler.current - Preference( - title = { Text(stringResource(effectiveTitleResId)) }, - summary = { Text(url) }, - enabled = visibility.enabled, - onClick = if (visibility.enabled) { - { uriHandler.openUri(url) } - } else null - ) -} - -/** - * Composable dynamic activity preference for use inside card sections. - * - * @param titleResId Optional title resource ID. If 0 or not provided, uses intentKey.titleResId - * @param summaryResId Optional summary resource ID. If null, uses intentKey.summaryResId - */ -@Composable -fun AdaptiveDynamicActivityPreferenceItem( - intentKey: IntentPreferenceKey, - titleResId: Int = 0, - activityClass: Class<*>, - summaryResId: Int? = null, - visibilityContext: VisibilityContext? = null -) { - val effectiveTitleResId = if (titleResId != 0) titleResId else intentKey.titleResId - val effectiveSummaryResId = summaryResId ?: intentKey.summaryResId - - // Skip if no title resource is available - if (effectiveTitleResId == 0) return - - val visibility = calculateIntentPreferenceVisibility( - intentKey = intentKey, - visibilityContext = visibilityContext - ) - - if (!visibility.visible) return - - val context = LocalContext.current - Preference( - title = { Text(stringResource(effectiveTitleResId)) }, - summary = effectiveSummaryResId?.let { { Text(stringResource(it)) } }, - enabled = visibility.enabled, - onClick = if (visibility.enabled) { - { context.startActivity(Intent(context, activityClass)) } - } else null - ) -} - -/** - * Composable preference that navigates to an inline Compose screen. - * Used for IntentPreferenceKey with composeScreen attached via withCompose(). - */ -@Composable -fun AdaptiveComposeScreenPreferenceItem( - intentKey: IntentPreferenceKey, - composeScreen: ComposeScreenContent, - onNavigate: (ComposeScreenContent) -> Unit, - titleResId: Int = 0, - summaryResId: Int? = null, - visibilityContext: VisibilityContext? = null -) { - val effectiveTitleResId = if (titleResId != 0) titleResId else intentKey.titleResId - val effectiveSummaryResId = summaryResId ?: intentKey.summaryResId - - if (effectiveTitleResId == 0) return - - val visibility = calculateIntentPreferenceVisibility( - intentKey = intentKey, - visibilityContext = visibilityContext - ) - - if (!visibility.visible) return - - Preference( - title = { Text(stringResource(effectiveTitleResId)) }, - summary = effectiveSummaryResId?.let { { Text(stringResource(it)) } }, - enabled = visibility.enabled, - onClick = if (visibility.enabled) { - { onNavigate(composeScreen) } - } else null - ) -} diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/preference/PreferenceSubScreenDef.kt b/core/ui/src/main/kotlin/app/aaps/core/ui/compose/preference/PreferenceSubScreenDef.kt deleted file mode 100644 index d5fae9279a2f..000000000000 --- a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/preference/PreferenceSubScreenDef.kt +++ /dev/null @@ -1,35 +0,0 @@ -package app.aaps.core.ui.compose.preference - -import androidx.compose.ui.graphics.vector.ImageVector -import app.aaps.core.keys.interfaces.PreferenceItem -import app.aaps.core.keys.interfaces.PreferenceKey - -/** - * Lightweight preference subscreen definition. - * Can contain both PreferenceKeys and nested PreferenceSubScreenDefs for hierarchical structure. - * Content is auto-generated from items using AdaptivePreferenceList. - * - * @param key Unique key for this subscreen - * @param titleResId String resource ID for the screen title - * @param items List of preference items (keys and/or nested subscreens) - * @param summaryResId Optional string resource ID for summary shown in parent list - * @param icon Optional Compose ImageVector icon shown next to the title - */ -data class PreferenceSubScreenDef( - val key: String, - val titleResId: Int, - val items: List = emptyList(), - val summaryResId: Int? = null, - val icon: ImageVector? = null -) : PreferenceItem { - - /** Effective summary items - from items' titleResId */ - fun effectiveSummaryItems(): List = - items.mapNotNull { item -> - when (item) { - is PreferenceKey -> item.titleResId.takeIf { it != 0 } - is PreferenceSubScreenDef -> item.titleResId.takeIf { it != 0 } - else -> null - } - } -} diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/elements/WeekDay.kt b/core/ui/src/main/kotlin/app/aaps/core/ui/elements/WeekDay.kt deleted file mode 100644 index 4eb65c088931..000000000000 --- a/core/ui/src/main/kotlin/app/aaps/core/ui/elements/WeekDay.kt +++ /dev/null @@ -1,80 +0,0 @@ -package app.aaps.core.ui.elements - -import androidx.annotation.StringRes -import app.aaps.core.ui.R -import java.util.Calendar -import java.util.Date - -open class WeekDay { - - enum class DayOfWeek { - MONDAY, TUESDAY, WEDNESDAY, THURSDAY, FRIDAY, SATURDAY, SUNDAY; - - fun toCalendarInt(): Int { - return calendarInts[ordinal] - } - - @get:StringRes val shortName: Int - get() = shortNames[ordinal] - - companion object { - - private val calendarInts = intArrayOf( - Calendar.MONDAY, - Calendar.TUESDAY, - Calendar.WEDNESDAY, - Calendar.THURSDAY, - Calendar.FRIDAY, - Calendar.SATURDAY, - Calendar.SUNDAY - ) - private val shortNames = intArrayOf( - R.string.weekday_monday_short, - R.string.weekday_tuesday_short, - R.string.weekday_wednesday_short, - R.string.weekday_thursday_short, - R.string.weekday_friday_short, - R.string.weekday_saturday_short, - R.string.weekday_sunday_short - ) - - fun fromCalendarInt(day: Int): DayOfWeek { - for (i in calendarInts.indices) { - if (calendarInts[i] == day) return entries[i] - } - throw IllegalStateException("Invalid day") - } - } - } - - val weekdays = BooleanArray(DayOfWeek.entries.size) - init { - for (day in DayOfWeek.entries) set(day, false) - } - - fun setAll(value: Boolean) { - for (day in DayOfWeek.entries) set(day, value) - } - - operator fun set(day: DayOfWeek, value: Boolean): WeekDay { - weekdays[day.ordinal] = value - return this - } - - fun isSet(day: DayOfWeek): Boolean = weekdays[day.ordinal] - - fun isSet(timestamp: Long): Boolean { - val scheduledDayOfWeek = Calendar.getInstance().also { it.time = Date(timestamp) } - return isSet(DayOfWeek.fromCalendarInt(scheduledDayOfWeek[Calendar.DAY_OF_WEEK])) - } - - fun getSelectedDays(): List { - val selectedDays: MutableList = ArrayList() - for (i in weekdays.indices) { - val day = DayOfWeek.entries[i] - val selected = weekdays[i] - if (selected) selectedDays.add(day.toCalendarInt()) - } - return selectedDays - } -} diff --git a/core/ui/src/main/res/drawable/ic_eopatch2_128.xml b/core/ui/src/main/res/drawable/ic_eopatch2_128.xml deleted file mode 100644 index 1f3e107e05c6..000000000000 --- a/core/ui/src/main/res/drawable/ic_eopatch2_128.xml +++ /dev/null @@ -1,43 +0,0 @@ - - - - - - - - - - - diff --git a/core/ui/src/main/res/drawable/ic_error_red_48dp.xml b/core/ui/src/main/res/drawable/ic_error_red_48dp.xml deleted file mode 100644 index b3666f2662f1..000000000000 --- a/core/ui/src/main/res/drawable/ic_error_red_48dp.xml +++ /dev/null @@ -1,5 +0,0 @@ - - - diff --git a/core/ui/src/main/res/drawable/ic_medtrum_128.xml b/core/ui/src/main/res/drawable/ic_medtrum_128.xml deleted file mode 100644 index a8e35cd3e02f..000000000000 --- a/core/ui/src/main/res/drawable/ic_medtrum_128.xml +++ /dev/null @@ -1,71 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/core/ui/src/main/res/drawable/splash_logo.xml b/core/ui/src/main/res/drawable/splash_logo.xml deleted file mode 100644 index 8fae059e471f..000000000000 --- a/core/ui/src/main/res/drawable/splash_logo.xml +++ /dev/null @@ -1,86 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/core/utils/build.gradle.kts b/core/utils/build.gradle.kts index b908c8861eb4..e388c3e1766d 100644 --- a/core/utils/build.gradle.kts +++ b/core/utils/build.gradle.kts @@ -1,42 +1,101 @@ import kotlin.math.min plugins { - alias(libs.plugins.android.library) - kotlin("plugin.allopen") - id("android-module-dependencies") - id("all-open-dependencies") - id("test-module-dependencies") - id("jacoco-module-dependencies") + kotlin("multiplatform") + // NOT com.android.library. AGP 9 refuses that plugin together with the multiplatform plugin. + // Same reason as :core:keys and :core:data. + alias(libs.plugins.android.kmp.library) } -android { - namespace = "app.aaps.core.utils" - defaultConfig { - minSdk = min(Versions.minSdk, Versions.wearMinSdk) +// The first module SPLIT rather than converted whole. Most of what lives here is Android glue - +// WorkManager, Bluetooth, Intent/Bundle, Html - and only a handful of files are genuinely portable, +// so androidMain keeps the bulk and commonMain takes the four that carry no platform types. +// +// The `allopen` plugin is deliberately not carried over: it exists to open @OpenForTesting classes +// for mocking and nothing in this module is annotated with it. +kotlin { + android { + namespace = "app.aaps.core.utils" + compileSdk = Versions.compileSdk + minSdk = min(Versions.minSdk, Versions.wearMinSdk) // Compatible with wear module + // Creates the androidHostTest compilation, which also pulls in commonTest. + withHostTest { } + compilerOptions { jvmTarget.set(Versions.jvmTarget) } + // Restated from android-module-dependencies, which a multiplatform module cannot apply. + lint { + checkReleaseBuilds = false + disable += "MissingTranslation" + disable += "ExtraTranslation" + } } -} -dependencies { + jvm { + compilerOptions { jvmTarget.set(Versions.jvmTarget) } + } + + iosArm64() + iosSimulatorArm64() + + sourceSets { + commonMain { + dependencies { + api(libs.kotlinx.datetime) + // JsonLenientRead reads kotlinx documents from common code. The androidMain block + // below keeps its own `api` on the same artifact for the consumers that resolve it + // transitively from there. + api(project.dependencies.platform(libs.kotlinx.serialization.bom)) + api(libs.kotlinx.serialization.json) + } + } + + androidMain { + dependencies { + // Everything below was `api` on the old android library and several of the 24 + // consumer modules resolve these transitively, so they must stay exported. They are + // JVM/Android only, which is exactly why they belong to this source set. + api(libs.net.danlew.android.joda) + api(project.dependencies.platform(libs.kotlinx.serialization.bom)) + api(libs.kotlinx.serialization.json) - api(libs.net.danlew.android.joda) - api(platform(libs.kotlinx.serialization.bom)) - api(libs.kotlinx.serialization.json) + //Firebase + api(project.dependencies.platform(libs.com.google.firebase.bom)) + api(libs.com.google.firebase.analytics) + api(libs.com.google.firebase.crashlytics) - //Firebase - api(platform(libs.com.google.firebase.bom)) - api(libs.com.google.firebase.analytics) - api(libs.com.google.firebase.crashlytics) + //CryptoUtil + api(libs.com.madgag.spongycastle) + api(libs.com.google.crypto.tink) - //CryptoUtil - api(libs.com.madgag.spongycastle) - api(libs.com.google.crypto.tink) + //WorkManager + api(libs.androidx.work.runtime) // DataWorkerStorage - //WorkManager - api(libs.androidx.work.runtime) // DataWorkerStorage + // ProcessLifecycleOwner for DeferredForegroundStart + implementation(libs.androidx.lifecycle.process) - // ProcessLifecycleOwner for DeferredForegroundStart - implementation(libs.androidx.lifecycle.process) + api(libs.com.google.dagger.android) // for javax.inject annotations + api(libs.com.google.dagger.android.support) + } + } - api(libs.com.google.dagger.android) // for javax.inject annotations - api(libs.com.google.dagger.android.support) -} \ No newline at end of file + // Hand written rather than taken from test-module-dependencies, which applies + // com.android.library and so cannot be used here. Same approach as :core:keys. + getByName("androidHostTest") { + dependencies { + implementation(libs.org.junit.jupiter) + implementation(libs.org.junit.jupiter.api) + implementation(libs.com.google.truth) + implementation(libs.org.mockito.kotlin) + // JsonHelperTest needs a REAL org.json. On the Android unit-test classpath the + // platform one is a stub that throws "not mocked"; test-module-dependencies used to + // supply the real thing transitively through jsonassert, and this module can no + // longer apply that plugin. This is AOSP's own implementation, repackaged. + implementation(libs.org.json.android) + runtimeOnly(libs.org.junit.platform.launcher) + } + } + } +} + +tasks.withType { + useJUnitPlatform() +} diff --git a/core/utils/src/test/kotlin/app/aaps/core/utils/JsonHelperTest.kt b/core/utils/src/androidHostTest/kotlin/app/aaps/core/utils/JsonHelperTest.kt similarity index 100% rename from core/utils/src/test/kotlin/app/aaps/core/utils/JsonHelperTest.kt rename to core/utils/src/androidHostTest/kotlin/app/aaps/core/utils/JsonHelperTest.kt diff --git a/core/utils/src/test/kotlin/app/aaps/core/utils/MidnightUtilsTest.kt b/core/utils/src/androidHostTest/kotlin/app/aaps/core/utils/MidnightUtilsTest.kt similarity index 100% rename from core/utils/src/test/kotlin/app/aaps/core/utils/MidnightUtilsTest.kt rename to core/utils/src/androidHostTest/kotlin/app/aaps/core/utils/MidnightUtilsTest.kt diff --git a/core/utils/src/test/kotlin/app/aaps/core/utils/PercentileTest.kt b/core/utils/src/androidHostTest/kotlin/app/aaps/core/utils/PercentileTest.kt similarity index 100% rename from core/utils/src/test/kotlin/app/aaps/core/utils/PercentileTest.kt rename to core/utils/src/androidHostTest/kotlin/app/aaps/core/utils/PercentileTest.kt diff --git a/core/utils/src/test/kotlin/app/aaps/core/utils/receivers/StringUtilsTest.kt b/core/utils/src/androidHostTest/kotlin/app/aaps/core/utils/receivers/StringUtilsTest.kt similarity index 100% rename from core/utils/src/test/kotlin/app/aaps/core/utils/receivers/StringUtilsTest.kt rename to core/utils/src/androidHostTest/kotlin/app/aaps/core/utils/receivers/StringUtilsTest.kt diff --git a/core/utils/src/test/kotlin/app/aaps/pump/common/utils/DateTimeUtilUTest.kt b/core/utils/src/androidHostTest/kotlin/app/aaps/pump/common/utils/DateTimeUtilUTest.kt similarity index 100% rename from core/utils/src/test/kotlin/app/aaps/pump/common/utils/DateTimeUtilUTest.kt rename to core/utils/src/androidHostTest/kotlin/app/aaps/pump/common/utils/DateTimeUtilUTest.kt diff --git a/core/utils/src/main/AndroidManifest.xml b/core/utils/src/androidMain/AndroidManifest.xml similarity index 100% rename from core/utils/src/main/AndroidManifest.xml rename to core/utils/src/androidMain/AndroidManifest.xml diff --git a/core/utils/src/main/kotlin/app/aaps/core/utils/Concurrency.kt b/core/utils/src/androidMain/kotlin/app/aaps/core/utils/Concurrency.kt similarity index 100% rename from core/utils/src/main/kotlin/app/aaps/core/utils/Concurrency.kt rename to core/utils/src/androidMain/kotlin/app/aaps/core/utils/Concurrency.kt diff --git a/core/utils/src/main/kotlin/app/aaps/core/utils/DateTimeUtil.kt b/core/utils/src/androidMain/kotlin/app/aaps/core/utils/DateTimeUtil.kt similarity index 99% rename from core/utils/src/main/kotlin/app/aaps/core/utils/DateTimeUtil.kt rename to core/utils/src/androidMain/kotlin/app/aaps/core/utils/DateTimeUtil.kt index c9a5102216e5..74ee8717fbbc 100644 --- a/core/utils/src/main/kotlin/app/aaps/core/utils/DateTimeUtil.kt +++ b/core/utils/src/androidMain/kotlin/app/aaps/core/utils/DateTimeUtil.kt @@ -1,5 +1,6 @@ package app.aaps.core.utils +import app.aaps.core.utils.DateTimeUtil.toATechDate import org.joda.time.LocalDateTime import org.joda.time.Minutes import org.joda.time.Seconds diff --git a/core/utils/src/main/kotlin/app/aaps/core/utils/DeferredForegroundStart.kt b/core/utils/src/androidMain/kotlin/app/aaps/core/utils/DeferredForegroundStart.kt similarity index 100% rename from core/utils/src/main/kotlin/app/aaps/core/utils/DeferredForegroundStart.kt rename to core/utils/src/androidMain/kotlin/app/aaps/core/utils/DeferredForegroundStart.kt diff --git a/core/utils/src/main/kotlin/app/aaps/core/utils/EspressoTestHelper.kt b/core/utils/src/androidMain/kotlin/app/aaps/core/utils/EspressoTestHelper.kt similarity index 100% rename from core/utils/src/main/kotlin/app/aaps/core/utils/EspressoTestHelper.kt rename to core/utils/src/androidMain/kotlin/app/aaps/core/utils/EspressoTestHelper.kt diff --git a/core/utils/src/main/kotlin/app/aaps/core/utils/JsonHelper.kt b/core/utils/src/androidMain/kotlin/app/aaps/core/utils/JsonHelper.kt similarity index 64% rename from core/utils/src/main/kotlin/app/aaps/core/utils/JsonHelper.kt rename to core/utils/src/androidMain/kotlin/app/aaps/core/utils/JsonHelper.kt index 0abc0797d806..f4d0bbf4a44a 100644 --- a/core/utils/src/main/kotlin/app/aaps/core/utils/JsonHelper.kt +++ b/core/utils/src/androidMain/kotlin/app/aaps/core/utils/JsonHelper.kt @@ -1,12 +1,6 @@ package app.aaps.core.utils -import kotlinx.serialization.json.JsonNull import kotlinx.serialization.json.JsonObject -import kotlinx.serialization.json.JsonPrimitive -import kotlinx.serialization.json.boolean -import kotlinx.serialization.json.double -import kotlinx.serialization.json.int -import kotlinx.serialization.json.long import org.json.JSONException import org.json.JSONObject @@ -194,142 +188,60 @@ object JsonHelper { return result } - fun JsonObject?.safeGetString(fieldName: String): String? { - var result: String? = null - if (this?.contains(fieldName) == true) { - try { - result = (get(fieldName) as JsonPrimitive).content - } catch (_: Exception) { - } - } - return result - } + /* + * The kotlinx twins below delegate to the lenient readers in JsonLenientRead. + * + * They used to call the strict kotlinx accessors (.int, .long, .double, .boolean) inside a + * try/catch that swallowed the failure and returned the default. That looked equivalent to the + * org.json halves above and was not: org.json coerces on read, kotlinx throws. A stored "36" + * (a quoted number, which real Nightscout documents contain) read back as 36 through org.json + * and as the DEFAULT through these, with nothing logged. For a dosing field that is a hazard - + * the same one that made ICfg.fromJsonObject read insulinEndTime as 0, i.e. DIA 0. + * + * The two string overloads had a second, separate bug: `if (get(fieldName) is JsonNull) result = + * defaultValue` was immediately overwritten by an unconditional assignment on the next line, and + * since JsonNull IS a JsonPrimitive whose content is "null", they returned the literal text + * "null" where the org.json halves return the default. + */ - fun JsonObject?.safeGetString(fieldName: String, defaultValue: String): String { - var result = defaultValue - if (this?.contains(fieldName) == true) { - try { - if (get(fieldName) is JsonNull) result = defaultValue - result = (get(fieldName) as JsonPrimitive).content - } catch (_: Exception) { - } - } - return result - } + fun JsonObject?.safeGetString(fieldName: String): String? = + lenientStringOrNull(fieldName) - fun JsonObject?.safeGetStringAllowNull(fieldName: String, defaultValue: String?): String? { - var result = defaultValue - if (this?.contains(fieldName) == true) { - try { - if (get(fieldName) is JsonNull) result = defaultValue - result = (get(fieldName) as JsonPrimitive).content - } catch (_: Exception) { - } - } - return result - } + fun JsonObject?.safeGetString(fieldName: String, defaultValue: String): String = + lenientString(fieldName, defaultValue) - fun JsonObject?.safeGetDouble(fieldName: String): Double { - var result = 0.0 - if (this?.contains(fieldName) == true) { - try { - result = (get(fieldName) as JsonPrimitive).double - } catch (_: Exception) { - } - } - return result - } + fun JsonObject?.safeGetStringAllowNull(fieldName: String, defaultValue: String?): String? = + lenientStringOrNull(fieldName) ?: defaultValue - fun JsonObject?.safeGetDoubleAllowNull(fieldName: String): Double? { - var result: Double? = null - if (this?.contains(fieldName) == true) { - try { - result = (get(fieldName) as JsonPrimitive).double - } catch (_: Exception) { - } - } - return result - } + fun JsonObject?.safeGetDouble(fieldName: String): Double = + lenientDouble(fieldName, 0.0) - fun JsonObject?.safeGetDouble(fieldName: String, defaultValue: Double): Double { - var result = defaultValue - if (this?.contains(fieldName) == true) { - try { - result = (get(fieldName) as JsonPrimitive).double - } catch (_: Exception) { - } - } - return result - } + fun JsonObject?.safeGetDoubleAllowNull(fieldName: String): Double? = + lenientDoubleOrNull(fieldName) + + fun JsonObject?.safeGetDouble(fieldName: String, defaultValue: Double): Double = + lenientDouble(fieldName, defaultValue) fun JsonObject?.safeGetInt(fieldName: String): Int = safeGetInt(fieldName, 0) - fun JsonObject?.safeGetInt(fieldName: String, defaultValue: Int): Int { - var result = defaultValue - if (this?.contains(fieldName) == true) { - try { - result = (get(fieldName) as JsonPrimitive).int - } catch (_: Exception) { - } - } - return result - } + fun JsonObject?.safeGetInt(fieldName: String, defaultValue: Int): Int = + lenientInt(fieldName, defaultValue) - fun JsonObject?.safeGetIntAllowNull(fieldName: String): Int? { - var result: Int? = null - if (this?.contains(fieldName) == true) { - try { - result = (get(fieldName) as JsonPrimitive).int - } catch (_: Exception) { - } - } - return result - } + fun JsonObject?.safeGetIntAllowNull(fieldName: String): Int? = + lenientIntOrNull(fieldName) - fun JsonObject?.safeGetLong(fieldName: String): Long { - var result: Long = 0 - if (this?.contains(fieldName) == true) { - try { - result = (get(fieldName) as JsonPrimitive).long - } catch (_: Exception) { - } - } - return result - } + fun JsonObject?.safeGetLong(fieldName: String): Long = + lenientLong(fieldName, 0) - fun JsonObject?.safeGetLongAllowNull(fieldName: String, defaultValue: Long? = null): Long? { - var result: Long? = defaultValue - if (this?.contains(fieldName) == true) { - try { - result = (get(fieldName) as JsonPrimitive).long - } catch (_: Exception) { - } - } - return result - } + fun JsonObject?.safeGetLongAllowNull(fieldName: String, defaultValue: Long? = null): Long? = + lenientLongOrNull(fieldName) ?: defaultValue - fun JsonObject?.safeGetBoolean(fieldName: String, defaultValue: Boolean = false): Boolean { - var result = defaultValue - if (this?.contains(fieldName) == true) { - try { - result = (get(fieldName) as JsonPrimitive).boolean - } catch (_: Exception) { - } - } - return result - } + fun JsonObject?.safeGetBoolean(fieldName: String, defaultValue: Boolean = false): Boolean = + lenientBoolean(fieldName, defaultValue) - fun JsonObject?.safeGetBooleanAllowNull(fieldName: String, defaultValue: Boolean? = null): Boolean? { - var result = defaultValue - if (this?.contains(fieldName) == true) { - try { - result = (get(fieldName) as JsonPrimitive).boolean - } catch (_: Exception) { - } - } - return result - } + fun JsonObject?.safeGetBooleanAllowNull(fieldName: String, defaultValue: Boolean? = null): Boolean? = + lenientBooleanOrNull(fieldName) ?: defaultValue /** * Simple merge of two JSON objects. diff --git a/core/utils/src/main/kotlin/app/aaps/core/utils/StringUtil.kt b/core/utils/src/androidMain/kotlin/app/aaps/core/utils/StringUtil.kt similarity index 96% rename from core/utils/src/main/kotlin/app/aaps/core/utils/StringUtil.kt rename to core/utils/src/androidMain/kotlin/app/aaps/core/utils/StringUtil.kt index 751b70651dba..b320d3125408 100644 --- a/core/utils/src/main/kotlin/app/aaps/core/utils/StringUtil.kt +++ b/core/utils/src/androidMain/kotlin/app/aaps/core/utils/StringUtil.kt @@ -21,7 +21,7 @@ private fun usPattern(decimals: Int): String = * @param decimals how many digits after the dot, always shown. 0 means no decimals. */ fun Number.formatUS(decimals: Int): String = - // A new DecimalFormat for every call on purpose. DecimalFormat is not thread safe, and the +// A new DecimalFormat for every call on purpose. DecimalFormat is not thread safe, and the // old shared array could give wrong text when two threads formatted at the same time. DecimalFormat(usPattern(decimals), US_SYMBOLS).format(this) diff --git a/core/utils/src/main/kotlin/app/aaps/core/utils/extensions/BluetoothAdapterExtension.kt b/core/utils/src/androidMain/kotlin/app/aaps/core/utils/extensions/BluetoothAdapterExtension.kt similarity index 100% rename from core/utils/src/main/kotlin/app/aaps/core/utils/extensions/BluetoothAdapterExtension.kt rename to core/utils/src/androidMain/kotlin/app/aaps/core/utils/extensions/BluetoothAdapterExtension.kt diff --git a/core/utils/src/main/kotlin/app/aaps/core/utils/extensions/BluetoothDeviceExtension.kt b/core/utils/src/androidMain/kotlin/app/aaps/core/utils/extensions/BluetoothDeviceExtension.kt similarity index 100% rename from core/utils/src/main/kotlin/app/aaps/core/utils/extensions/BluetoothDeviceExtension.kt rename to core/utils/src/androidMain/kotlin/app/aaps/core/utils/extensions/BluetoothDeviceExtension.kt diff --git a/core/utils/src/main/kotlin/app/aaps/core/utils/extensions/IntentExtension.kt b/core/utils/src/androidMain/kotlin/app/aaps/core/utils/extensions/IntentExtension.kt similarity index 100% rename from core/utils/src/main/kotlin/app/aaps/core/utils/extensions/IntentExtension.kt rename to core/utils/src/androidMain/kotlin/app/aaps/core/utils/extensions/IntentExtension.kt diff --git a/core/utils/src/androidMain/kotlin/app/aaps/core/utils/extensions/WorkerDataBuilder.kt b/core/utils/src/androidMain/kotlin/app/aaps/core/utils/extensions/WorkerDataBuilder.kt new file mode 100644 index 000000000000..902705c8932e --- /dev/null +++ b/core/utils/src/androidMain/kotlin/app/aaps/core/utils/extensions/WorkerDataBuilder.kt @@ -0,0 +1,19 @@ +package app.aaps.core.utils.extensions + +import android.os.Bundle +import androidx.work.Data + +fun Data.Builder.copyString(key: String, bundle: Bundle?, defaultValue: String? = ""): Data.Builder = + this.also { putString(key, bundle?.getString(key) ?: defaultValue) } + +fun Data.Builder.copyLong(key: String, bundle: Bundle?, defaultValue: Long = 0): Data.Builder = + this.also { putLong(key, bundle?.getLong(key) ?: defaultValue) } + +fun Data.Builder.copyInt(key: String, bundle: Bundle?, defaultValue: Int = 0): Data.Builder = + this.also { putInt(key, bundle?.getInt(key) ?: defaultValue) } + +fun Data.Builder.copyDouble(key: String, bundle: Bundle?, defaultValue: Double = 0.0): Data.Builder = + this.also { putDouble(key, bundle?.getDouble(key) ?: defaultValue) } + +fun Data.Builder.copyBoolean(key: String, bundle: Bundle?, defaultValue: Boolean = false): Data.Builder = + this.also { putBoolean(key, bundle?.getBoolean(key) ?: defaultValue) } \ No newline at end of file diff --git a/core/utils/src/main/kotlin/app/aaps/core/utils/fabric/InstanceId.kt b/core/utils/src/androidMain/kotlin/app/aaps/core/utils/fabric/InstanceId.kt similarity index 88% rename from core/utils/src/main/kotlin/app/aaps/core/utils/fabric/InstanceId.kt rename to core/utils/src/androidMain/kotlin/app/aaps/core/utils/fabric/InstanceId.kt index 77d0b89bbff5..f219a0f133e7 100644 --- a/core/utils/src/main/kotlin/app/aaps/core/utils/fabric/InstanceId.kt +++ b/core/utils/src/androidMain/kotlin/app/aaps/core/utils/fabric/InstanceId.kt @@ -3,7 +3,8 @@ package app.aaps.core.utils.fabric import com.google.firebase.installations.FirebaseInstallations object InstanceId { - var instanceId : String = "" + + var instanceId: String = "" init { FirebaseInstallations.getInstance().id.addOnCompleteListener { diff --git a/core/utils/src/main/kotlin/app/aaps/core/utils/pump/ByteUtil.kt b/core/utils/src/androidMain/kotlin/app/aaps/core/utils/pump/ByteUtil.kt similarity index 100% rename from core/utils/src/main/kotlin/app/aaps/core/utils/pump/ByteUtil.kt rename to core/utils/src/androidMain/kotlin/app/aaps/core/utils/pump/ByteUtil.kt diff --git a/core/utils/src/main/kotlin/app/aaps/core/utils/pump/ThreadUtil.kt b/core/utils/src/androidMain/kotlin/app/aaps/core/utils/pump/ThreadUtil.kt similarity index 100% rename from core/utils/src/main/kotlin/app/aaps/core/utils/pump/ThreadUtil.kt rename to core/utils/src/androidMain/kotlin/app/aaps/core/utils/pump/ThreadUtil.kt diff --git a/core/utils/src/main/kotlin/app/aaps/core/utils/receivers/BundleLogger.kt b/core/utils/src/androidMain/kotlin/app/aaps/core/utils/receivers/BundleLogger.kt similarity index 100% rename from core/utils/src/main/kotlin/app/aaps/core/utils/receivers/BundleLogger.kt rename to core/utils/src/androidMain/kotlin/app/aaps/core/utils/receivers/BundleLogger.kt diff --git a/core/utils/src/main/kotlin/app/aaps/core/utils/receivers/DataWorkerStorage.kt b/core/utils/src/androidMain/kotlin/app/aaps/core/utils/receivers/DataWorkerStorage.kt similarity index 100% rename from core/utils/src/main/kotlin/app/aaps/core/utils/receivers/DataWorkerStorage.kt rename to core/utils/src/androidMain/kotlin/app/aaps/core/utils/receivers/DataWorkerStorage.kt diff --git a/core/utils/src/main/kotlin/app/aaps/core/utils/receivers/Inbox.kt b/core/utils/src/androidMain/kotlin/app/aaps/core/utils/receivers/Inbox.kt similarity index 100% rename from core/utils/src/main/kotlin/app/aaps/core/utils/receivers/Inbox.kt rename to core/utils/src/androidMain/kotlin/app/aaps/core/utils/receivers/Inbox.kt diff --git a/core/utils/src/main/kotlin/app/aaps/core/utils/worker/WorkExtensions.kt b/core/utils/src/androidMain/kotlin/app/aaps/core/utils/worker/WorkExtensions.kt similarity index 100% rename from core/utils/src/main/kotlin/app/aaps/core/utils/worker/WorkExtensions.kt rename to core/utils/src/androidMain/kotlin/app/aaps/core/utils/worker/WorkExtensions.kt diff --git a/core/utils/src/main/kotlin/app/aaps/core/utils/HexByteArrayConversion.kt b/core/utils/src/commonMain/kotlin/app/aaps/core/utils/HexByteArrayConversion.kt similarity index 61% rename from core/utils/src/main/kotlin/app/aaps/core/utils/HexByteArrayConversion.kt rename to core/utils/src/commonMain/kotlin/app/aaps/core/utils/HexByteArrayConversion.kt index 58e6f6235fad..928b1265394e 100644 --- a/core/utils/src/main/kotlin/app/aaps/core/utils/HexByteArrayConversion.kt +++ b/core/utils/src/commonMain/kotlin/app/aaps/core/utils/HexByteArrayConversion.kt @@ -1,11 +1,11 @@ package app.aaps.core.utils -import java.util.Locale - private val HEX_CHARS = "0123456789abcdef".toCharArray() -fun ByteArray.toHex() : String{ - val result = StringBuffer() +fun ByteArray.toHex(): String { + // StringBuilder, not StringBuffer: StringBuffer is a JVM class and its synchronization bought + // nothing here - the builder never leaves this function. + val result = StringBuilder() forEach { val octet = it.toInt() @@ -22,7 +22,9 @@ fun String.hexStringToByteArray(): ByteArray { val result = ByteArray(length / 2) - val lowerCased = this.lowercase(Locale.getDefault()) + // Locale independent lowercase. The previous `lowercase(Locale.getDefault())` made hex parsing + // depend on the phone's language, which is never what a wire format wants. + val lowerCased = this.lowercase() for (i in indices step 2) { val firstIndex = HEX_CHARS.indexOf(lowerCased[i]) val secondIndex = HEX_CHARS.indexOf(lowerCased[i + 1]) @@ -32,4 +34,4 @@ fun String.hexStringToByteArray(): ByteArray { } return result -} \ No newline at end of file +} diff --git a/core/utils/src/commonMain/kotlin/app/aaps/core/utils/JsonLenientRead.kt b/core/utils/src/commonMain/kotlin/app/aaps/core/utils/JsonLenientRead.kt new file mode 100644 index 000000000000..76a276baf345 --- /dev/null +++ b/core/utils/src/commonMain/kotlin/app/aaps/core/utils/JsonLenientRead.kt @@ -0,0 +1,124 @@ +package app.aaps.core.utils + +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive + +/** + * Lenient readers that copy what Android's `org.json` does, so a document keeps parsing the same way + * after it moves from `JSONObject` to [JsonObject]. + * + * This matters more than it looks. `org.json` coerces on read and kotlinx does not: + * + * | stored value | `org.json` getInt | plain kotlinx `.int` | + * |--------------|-------------------|----------------------| + * | `36` | 36 | 36 | + * | `"36"` | 36 | throws | + * | `36.9` | 36 | throws | + * | `"36.9"` | 36 | throws | + * | `true` | throws | throws | + * + * Quoted numbers are not a theoretical case - real Nightscout documents contain them, and AAPS has + * always read them. A conversion that drops the coercion compiles, passes the current tests, and then + * silently reads a stored value as its default. For a dosing field that is a real hazard: an + * `insulinEndTime` that falls back to 0 means DIA 0, so IOB decays at once and the loop believes there + * is no insulin on board. + * + * The rules below are Android's `JSON.toInteger` / `toLong` / `toDouble` / `toBoolean` / `toString`: + * a number converts through its Java `intValue()`/`longValue()` (so fractions truncate toward zero), + * a string converts through `Double.parseDouble` and is then narrowed, and anything else fails to the + * default. A missing key and an explicit JSON `null` both give the default. + */ + +/** The primitive at [key], or null when absent or explicitly JSON null. */ +private fun JsonObject?.primitiveOrNull(key: String): JsonPrimitive? { + val element: JsonElement = this?.get(key) ?: return null + if (element is JsonNull) return null + return element as? JsonPrimitive +} + +/** + * `true` only for a real quoted string. Numbers and booleans are unquoted, so this separates + * `"36"` from `36` the way `org.json` separates a `String` value from a `Number` value. + */ +private fun JsonPrimitive.isQuoted(): Boolean = isString + +/** Android `JSON.toDouble`: a number as-is, a quoted number through `parseDouble`, else null. */ +private fun JsonPrimitive.asDoubleOrNull(): Double? = content.toDoubleOrNull() + +fun JsonObject?.lenientInt(key: String, defaultValue: Int = 0): Int { + val primitive = primitiveOrNull(key) ?: return defaultValue + // A boolean is not a number for org.json, and "true".toDoubleOrNull() is null anyway. + return primitive.content.toIntOrNull() + ?: primitive.asDoubleOrNull()?.toInt() + ?: defaultValue +} + +fun JsonObject?.lenientLong(key: String, defaultValue: Long = 0L): Long { + val primitive = primitiveOrNull(key) ?: return defaultValue + // Whole digits are read exactly first. Only the fraction/exponent forms go through Double, which + // is what org.json does for a quoted value - and every real epoch-millis value is below 2^53, + // so that path is exact too. + return primitive.content.toLongOrNull() + ?: primitive.asDoubleOrNull()?.toLong() + ?: defaultValue +} + +fun JsonObject?.lenientDouble(key: String, defaultValue: Double = 0.0): Double { + val primitive = primitiveOrNull(key) ?: return defaultValue + return primitive.asDoubleOrNull() ?: defaultValue +} + +/** + * Android `JSON.toBoolean`: a real boolean, or the text `true`/`false` in any case. A number is NOT + * a boolean here - `org.json` throws for `1`, so we fall back to [defaultValue]. + */ +fun JsonObject?.lenientBoolean(key: String, defaultValue: Boolean): Boolean { + val primitive = primitiveOrNull(key) ?: return defaultValue + return when (primitive.content.lowercase()) { + "true" -> true + "false" -> false + else -> defaultValue + } +} + +/** + * Android `getString` coerces any non-null value to its text, so a stored `12` reads back as `"12"`. + * An explicit JSON null gives [defaultValue] rather than the literal text `null` - that matches + * `JsonHelper.safeGetString`, which maps `JSONObject.NULL.toString()` back to the default. + */ +fun JsonObject?.lenientString(key: String, defaultValue: String = ""): String { + val primitive = primitiveOrNull(key) ?: return defaultValue + return primitive.content +} + +/** [lenientString] but null when absent, so callers can tell "missing" from "empty". */ +fun JsonObject?.lenientStringOrNull(key: String): String? = primitiveOrNull(key)?.content + +/* + * Nullable variants, for the callers that must tell "absent or unreadable" from "present and zero". + */ + +fun JsonObject?.lenientIntOrNull(key: String): Int? { + val primitive = primitiveOrNull(key) ?: return null + return primitive.content.toIntOrNull() ?: primitive.asDoubleOrNull()?.toInt() +} + +fun JsonObject?.lenientLongOrNull(key: String): Long? { + val primitive = primitiveOrNull(key) ?: return null + return primitive.content.toLongOrNull() ?: primitive.asDoubleOrNull()?.toLong() +} + +fun JsonObject?.lenientDoubleOrNull(key: String): Double? = + primitiveOrNull(key)?.asDoubleOrNull() + +fun JsonObject?.lenientBooleanOrNull(key: String): Boolean? = + when (primitiveOrNull(key)?.content?.lowercase()) { + "true" -> true + "false" -> false + else -> null + } + +/** True when a real quoted string is stored, for the few places that must not coerce a number. */ +fun JsonObject?.holdsQuotedString(key: String): Boolean = primitiveOrNull(key)?.isQuoted() == true diff --git a/core/utils/src/commonMain/kotlin/app/aaps/core/utils/MidnightUtils.kt b/core/utils/src/commonMain/kotlin/app/aaps/core/utils/MidnightUtils.kt new file mode 100644 index 000000000000..1685adb6c4a8 --- /dev/null +++ b/core/utils/src/commonMain/kotlin/app/aaps/core/utils/MidnightUtils.kt @@ -0,0 +1,61 @@ +package app.aaps.core.utils + +import kotlinx.datetime.TimeZone +import kotlinx.datetime.atStartOfDayIn +import kotlinx.datetime.toLocalDateTime +import kotlin.time.Clock +import kotlin.time.Instant + +/** + * Midnight time conversion + */ +object MidnightUtils { + + /** + * The wall clock offset of the day's first instant. + * + * Normally 00:00, so this is zero and every result is simply the time of day. On a day where the + * clocks jump forward over midnight (Brazil used to do this) local midnight does not exist, and + * `atStartOfDayIn` gives 01:00 instead - the same thing `atStartOfDay(zone)` did before. Keeping + * the subtraction preserves the old "ignoring DST change" behaviour exactly rather than assuming + * the day starts at zero. + */ + private fun secondsFrom(timestamp: Long, tz: TimeZone): Int { + val local = Instant.fromEpochMilliseconds(timestamp).toLocalDateTime(tz) + val startOfDay = local.date.atStartOfDayIn(tz).toLocalDateTime(tz) + return local.time.toSecondOfDay() - startOfDay.time.toSecondOfDay() + } + + /** + * Actual passed seconds from midnight ignoring DST change + * (thus always having 24 hours in a day, not 23 or 25 in days where DST changes) + * + * @return seconds + */ + fun secondsFromMidnight(): Int = + secondsFrom(Clock.System.now().toEpochMilliseconds(), TimeZone.currentSystemDefault()) + + /** + * Passed seconds from midnight for specified time ignoring DST change + * (thus always having 24 hours in a day, not 23 or 25 in days where DST changes) + * + * @param timestamp time + * @return seconds + */ + fun secondsFromMidnight(timestamp: Long): Int = + secondsFrom(timestamp, TimeZone.currentSystemDefault()) + + /** + * Passed milliseconds from midnight for specified time ignoring DST change + * (thus always having 24 hours in a day, not 23 or 25 in days where DST changes) + * + * @param timestamp time + * @return milliseconds + */ + fun milliSecFromMidnight(timestamp: Long): Long { + val tz = TimeZone.currentSystemDefault() + val local = Instant.fromEpochMilliseconds(timestamp).toLocalDateTime(tz) + val startOfDay = local.date.atStartOfDayIn(tz).toLocalDateTime(tz) + return (local.time.toMillisecondOfDay() - startOfDay.time.toMillisecondOfDay()).toLong() + } +} diff --git a/core/utils/src/main/kotlin/app/aaps/core/utils/Percentile.kt b/core/utils/src/commonMain/kotlin/app/aaps/core/utils/Percentile.kt similarity index 100% rename from core/utils/src/main/kotlin/app/aaps/core/utils/Percentile.kt rename to core/utils/src/commonMain/kotlin/app/aaps/core/utils/Percentile.kt diff --git a/core/utils/src/main/kotlin/app/aaps/core/utils/receivers/StringUtils.kt b/core/utils/src/commonMain/kotlin/app/aaps/core/utils/receivers/StringUtils.kt similarity index 100% rename from core/utils/src/main/kotlin/app/aaps/core/utils/receivers/StringUtils.kt rename to core/utils/src/commonMain/kotlin/app/aaps/core/utils/receivers/StringUtils.kt diff --git a/core/utils/src/main/kotlin/app/aaps/core/utils/HtmlHelper.kt b/core/utils/src/main/kotlin/app/aaps/core/utils/HtmlHelper.kt deleted file mode 100644 index 87c8b2884992..000000000000 --- a/core/utils/src/main/kotlin/app/aaps/core/utils/HtmlHelper.kt +++ /dev/null @@ -1,15 +0,0 @@ -package app.aaps.core.utils - -import android.text.Html -import android.text.SpannableStringBuilder -import android.text.Spanned - -object HtmlHelper { - - fun fromHtml(source: String): Spanned = - try { - Html.fromHtml(source, Html.FROM_HTML_MODE_LEGACY) - } catch (_: Exception) { - SpannableStringBuilder("") - } -} \ No newline at end of file diff --git a/core/utils/src/main/kotlin/app/aaps/core/utils/MidnightUtils.kt b/core/utils/src/main/kotlin/app/aaps/core/utils/MidnightUtils.kt deleted file mode 100644 index 49b808f125e8..000000000000 --- a/core/utils/src/main/kotlin/app/aaps/core/utils/MidnightUtils.kt +++ /dev/null @@ -1,56 +0,0 @@ -package app.aaps.core.utils - -import java.time.Duration -import java.time.Instant -import java.time.ZoneId -import java.time.ZonedDateTime - -/** - * Midnight time conversion - */ -object MidnightUtils { - - /** - * Actual passed seconds from midnight ignoring DST change - * (thus always having 24 hours in a day, not 23 or 25 in days where DST changes) - * - * @return seconds - */ - fun secondsFromMidnight(): Int { - val nowZoned = ZonedDateTime.now() - val localTime = nowZoned.toLocalTime() - val midnight = nowZoned.toLocalDate().atStartOfDay(nowZoned.zone).toLocalTime() - val duration = Duration.between(midnight, localTime) - return duration.seconds.toInt() - } - - /** - * Passed seconds from midnight for specified time ignoring DST change - * (thus always having 24 hours in a day, not 23 or 25 in days where DST changes) - * - * @param timestamp time - * @return seconds - */ - fun secondsFromMidnight(timestamp: Long): Int { - val timeZoned = ZonedDateTime.ofInstant(Instant.ofEpochMilli(timestamp), ZoneId.systemDefault()) - val localTime = timeZoned.toLocalTime() - val midnight = timeZoned.toLocalDate().atStartOfDay(timeZoned.zone).toLocalTime() - val duration: Duration = Duration.between(midnight, localTime) - return duration.seconds.toInt() - } - - /** - * Passed milliseconds from midnight for specified time ignoring DST change - * (thus always having 24 hours in a day, not 23 or 25 in days where DST changes) - * - * @param timestamp time - * @return milliseconds - */ - fun milliSecFromMidnight(timestamp: Long): Long { - val timeZoned = ZonedDateTime.ofInstant(Instant.ofEpochMilli(timestamp), ZoneId.systemDefault()) - val localTime = timeZoned.toLocalTime() - val midnight = timeZoned.toLocalDate().atStartOfDay(timeZoned.zone).toLocalTime() - val duration = Duration.between(midnight, localTime) - return duration.toMillis() - } -} \ No newline at end of file diff --git a/core/utils/src/main/kotlin/app/aaps/core/utils/extensions/WorkerDataBuilder.kt b/core/utils/src/main/kotlin/app/aaps/core/utils/extensions/WorkerDataBuilder.kt deleted file mode 100644 index 63765dcc44bc..000000000000 --- a/core/utils/src/main/kotlin/app/aaps/core/utils/extensions/WorkerDataBuilder.kt +++ /dev/null @@ -1,19 +0,0 @@ -package app.aaps.core.utils.extensions - -import android.os.Bundle -import androidx.work.Data - -fun Data.Builder.copyString(key: String, bundle: Bundle?, defaultValue : String? = ""): Data.Builder = - this.also { putString(key, bundle?.getString(key) ?: defaultValue) } - -fun Data.Builder.copyLong(key: String, bundle: Bundle?, defaultValue : Long = 0): Data.Builder = - this.also { putLong(key, bundle?.getLong(key) ?: defaultValue) } - -fun Data.Builder.copyInt(key: String, bundle: Bundle?, defaultValue : Int = 0): Data.Builder = - this.also { putInt(key, bundle?.getInt(key) ?: defaultValue) } - -fun Data.Builder.copyDouble(key: String, bundle: Bundle?, defaultValue : Double = 0.0): Data.Builder = - this.also { putDouble(key, bundle?.getDouble(key) ?: defaultValue) } - -fun Data.Builder.copyBoolean(key: String, bundle: Bundle?, defaultValue : Boolean = false): Data.Builder = - this.also { putBoolean(key, bundle?.getBoolean(key) ?: defaultValue) } \ No newline at end of file diff --git a/database/impl/src/main/kotlin/app/aaps/database/AppRepository.kt b/database/impl/src/main/kotlin/app/aaps/database/AppRepository.kt index 31ae262a88af..6a26d6d3da83 100644 --- a/database/impl/src/main/kotlin/app/aaps/database/AppRepository.kt +++ b/database/impl/src/main/kotlin/app/aaps/database/AppRepository.kt @@ -224,7 +224,7 @@ class AppRepository @Inject internal constructor( val ret = StringBuilder() removed .filter { it.second > 0 } - .forEach { ret.append(it.first + " " + it.second + "
") } + .forEach { ret.appendLine(it.first + " " + it.second) } // VACUUM is intentionally NOT run here. It is memory heavy and crashed (SQLITE_NOMEM) when // it overlapped live DB activity; defragmenting VACUUM now runs only at startup while the // DB is quiescent (see vacuumDatabase / MainApp.vacuumDatabaseIfDue). diff --git a/database/persistence/src/main/kotlin/app/aaps/database/persistence/PersistenceLayerImpl.kt b/database/persistence/src/main/kotlin/app/aaps/database/persistence/PersistenceLayerImpl.kt index 22b1a4862f6a..74c39af6e714 100644 --- a/database/persistence/src/main/kotlin/app/aaps/database/persistence/PersistenceLayerImpl.kt +++ b/database/persistence/src/main/kotlin/app/aaps/database/persistence/PersistenceLayerImpl.kt @@ -179,58 +179,58 @@ class PersistenceLayerImpl @Inject constructor( // Flow-based change observation @Suppress("UNCHECKED_CAST") - override fun observeChanges(type: Class): Flow> { + override fun observeChanges(type: KClass): Flow> { // Map database entity changes to domain types return when (type) { - BS::class.java -> repository.changesOfType() + BS::class -> repository.changesOfType() .map { list -> list.map { it.fromDb() } } - CA::class.java -> repository.changesOfType() + CA::class -> repository.changesOfType() .map { list -> list.map { it.fromDb() } } - BCR::class.java -> repository.changesOfType() + BCR::class -> repository.changesOfType() .map { list -> list.map { it.fromDb() } } - EB::class.java -> repository.changesOfType() + EB::class -> repository.changesOfType() .map { list -> list.map { it.fromDb() } } - TB::class.java -> repository.changesOfType() + TB::class -> repository.changesOfType() .map { list -> list.map { it.fromDb() } } - TT::class.java -> repository.changesOfType() + TT::class -> repository.changesOfType() .map { list -> list.map { it.fromDb() } } - TE::class.java -> repository.changesOfType() + TE::class -> repository.changesOfType() .map { list -> list.map { it.fromDb() } } - PS::class.java -> repository.changesOfType() + PS::class -> repository.changesOfType() .map { list -> list.map { it.fromDb() } } - EPS::class.java -> repository.changesOfType() + EPS::class -> repository.changesOfType() .map { list -> list.map { it.fromDb() } } - GV::class.java -> repository.changesOfType() + GV::class -> repository.changesOfType() .map { list -> list.map { it.fromDb() } } - CAL::class.java -> repository.changesOfType() + CAL::class -> repository.changesOfType() .map { list -> list.map { it.fromDb() } } - UE::class.java -> repository.changesOfType() + UE::class -> repository.changesOfType() .map { list -> list.map { it.fromDb() } } - RM::class.java -> repository.changesOfType() + RM::class -> repository.changesOfType() .map { list -> list.map { it.fromDb() } } - DS::class.java -> repository.changesOfType() + DS::class -> repository.changesOfType() .map { list -> list.map { it.fromDb() } } - HR::class.java -> repository.changesOfType() + HR::class -> repository.changesOfType() .map { list -> list.map { it.fromDb() } } - SC::class.java -> repository.changesOfType() + SC::class -> repository.changesOfType() .map { list -> list.map { it.fromDb() } } - FD::class.java -> repository.changesOfType() + FD::class -> repository.changesOfType() .map { list -> list.map { it.fromDb() } } else -> throw IllegalArgumentException("Unsupported observation type: ${type.simpleName}") diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 841205660386..70945760cfc3 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -11,6 +11,7 @@ junit = "6.1.2" robolectric = "4.16.1" okhttp = "5.4.0" retrofit = "3.0.0" +ktor = "3.3.0" kotlinx-serialization = "1.11.0" kotlinx-coroutines = "1.11.0" work = "2.11.2" @@ -28,13 +29,32 @@ vico = "3.2.3" composeUi = "1.11.4" glance = "1.1.1" +# Compose Multiplatform - spike only, see :spike:cmp. Separate from the androidx Compose above: +# CMP publishes the same androidx.compose.* package names and its Android variants delegate to +# androidx, so the composeBom still decides the Android versions. +# 1.11.1 rather than 1.12.0-beta03 on purpose: the beta's Android side delegates to androidx +# 1.12.0-beta02, which would drag a pre-release androidx into an app pinned to stable 1.11.x. +composeMultiplatform = "1.11.1" +navigationEvent = "1.0.1" +jetbrainsLifecycle = "2.9.6" +# material3 and material-icons-extended are separate, lagging version lines in CMP. +cmpMaterial3 = "1.9.0" +cmpIcons = "1.7.3" + [plugins] android-library = { id = "com.android.library" } +# No version, exactly like android-library above: both plugin ids ship in com.android.tools.build:gradle, +# which is already on the root buildscript classpath. AGP 9 refuses com.android.library together with +# the Kotlin multiplatform plugin, so a multiplatform module with an Android target needs this one. +android-kmp-library = { id = "com.android.kotlin.multiplatform.library" } klint = { id = "org.jlleitschuh.gradle.ktlint", version = "14.2.0" } hilt = { id = "com.google.dagger.hilt.android", version.ref = "dagger" } ksp = { id = "com.google.devtools.ksp", version = "2.3.10" } android-test = { id = "com.android.test", version.ref = "gradlePlugin" } compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } +# Compose Multiplatform framework plugin. It hard-fails unless compose-compiler above is applied in +# the same module - the compiler itself ships with Kotlin, this plugin only adds the framework. +compose-multiplatform = { id = "org.jetbrains.compose", version.ref = "composeMultiplatform" } [libraries] com-android-tools-build = { group = "com.android.tools.build", name = "gradle", version.ref = "gradlePlugin" } @@ -90,6 +110,11 @@ androidx-activity = { group = "androidx.activity", name = "activity-ktx", versio androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version = "1.7.1" } androidx-biometric = { group = "androidx.biometric", name = "biometric", version = "1.1.0" } androidx-browser = { group = "androidx.browser", name = "browser", version = "1.10.0" } +# Declared rather than left to transitive resolution: it used to land on 1.1.0 in some modules and +# 1.5.0 in others, and those are different generations of the same class (the newer one is a Kotlin +# rewrite where size is also a property), so the same code did not compile everywhere. From 1.4.0 it +# is multiplatform, which is what keeps LongSparseArray usable from shared code. +androidx-collection = { group = "androidx.collection", name = "collection", version = "1.6.0" } androidx-constraintlayout = { group = "androidx.constraintlayout", name = "constraintlayout", version = "2.2.2" } androidx-core = { group = "androidx.core", name = "core-ktx", version = "1.19.0" } androidx-datastore = { module = "androidx.datastore:datastore", version.ref = "datastorePreferences" } @@ -129,8 +154,17 @@ com-uber-rxdogtag2-rxdogtag = { group = "com.uber.rxdogtag2", name = "rxdogtag", com-squareup-leakcanary-android = { group = "com.squareup.leakcanary", name = "leakcanary-android", version = "2.14" } com-squareup-okhttp3-okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhttp" } com-squareup-okhttp3-logging-interceptor = { group = "com.squareup.okhttp3", name = "logging-interceptor", version.ref = "okhttp" } +com-squareup-okhttp3-mockwebserver = { group = "com.squareup.okhttp3", name = "mockwebserver3-junit5", version.ref = "okhttp" } + +io-ktor-client-core = { group = "io.ktor", name = "ktor-client-core", version.ref = "ktor" } +io-ktor-client-okhttp = { group = "io.ktor", name = "ktor-client-okhttp", version.ref = "ktor" } +io-ktor-client-content-negotiation = { group = "io.ktor", name = "ktor-client-content-negotiation", version.ref = "ktor" } +io-ktor-serialization-kotlinx-json = { group = "io.ktor", name = "ktor-serialization-kotlinx-json", version.ref = "ktor" } +io-ktor-client-cio = { group = "io.ktor", name = "ktor-client-cio", version.ref = "ktor" } +io-ktor-client-darwin = { group = "io.ktor", name = "ktor-client-darwin", version.ref = "ktor" } com-squareup-retrofit2-retrofit = { group = "com.squareup.retrofit2", name = "retrofit", version.ref = "retrofit" } com-squareup-retrofit2-converter-gson = { group = "com.squareup.retrofit2", name = "converter-gson", version.ref = "retrofit" } +com-squareup-retrofit2-converter-kotlinx-serialization = { group = "com.squareup.retrofit2", name = "converter-kotlinx-serialization", version.ref = "retrofit" } org-apache-commons-lang3 = { group = "org.apache.commons", name = "commons-lang3", version = "3.20.0" } org-mozilla-rhino = { group = "org.mozilla", name = "rhino", version = "1.9.1" } @@ -153,6 +187,12 @@ com-caverock-androidsvg = { group = "com.caverock", name = "androidsvg", version net-danlew-android-joda = { group = "net.danlew", name = "android.joda", version = "2.14.2.1" } joda-time = { group = "joda-time", name = "joda-time", version = "2.14.3" } org-skyscreamer-jsonassert = { group = "org.skyscreamer", name = "jsonassert", version = "1.5.3" } +# Test-only oracle. On Android `org.json` comes from the platform, so a plain JVM test has no real +# implementation to compare against. This is AOSP's own org.json, repackaged for the JVM - NOT +# Crockford's `org.json:json`, which is a different implementation and disagrees on real cases +# (optString of a JSON null gives "" there and "null" on Android). It is what jsonassert already +# pulls in, so Android module tests are measured against this too. Never use it in main source. +org-json-android = { group = "com.vaadin.external.google", name = "android-json", version = "0.0.20131108.vaadin1" } com-eatthepath-java-otp = { group = "com.eatthepath", name = "java-otp", version = "0.4.0" } com-github-bumptech-glide-compose = { group = "com.github.bumptech.glide", name = "compose", version = "1.0.0-beta10" } # do not update to 4.xx com-github-kenglxn-qrgen-android = { group = "com.github.kenglxn.QRGen", name = "android", version = "3.0.1" } @@ -192,3 +232,24 @@ androidx-compose-ui-test-manifest = { group = "androidx.compose.ui", name = "ui- sh-calvin-reorderable = { group = "sh.calvin.reorderable", name = "reorderable", version = "3.1.0" } androidx-glance-appwidget = { group = "androidx.glance", name = "glance-appwidget", version.ref = "glance" } +# Compose Multiplatform - spike only. Explicit coordinates rather than the `compose.xxx` DSL +# accessors, which are deprecated in 1.11.1. +cmp-runtime = { module = "org.jetbrains.compose.runtime:runtime", version.ref = "composeMultiplatform" } +cmp-foundation = { module = "org.jetbrains.compose.foundation:foundation", version.ref = "composeMultiplatform" } +cmp-ui = { module = "org.jetbrains.compose.ui:ui", version.ref = "composeMultiplatform" } +cmp-ui-backhandler = { module = "org.jetbrains.compose.ui:ui-backhandler", version.ref = "composeMultiplatform" } +# Replaces the deprecated `compose.components.uiToolingPreview`. It declares the annotation under the +# androidx package name for every target, so common and Android code share one import. +cmp-ui-tooling-preview = { module = "org.jetbrains.compose.ui:ui-tooling-preview", version.ref = "composeMultiplatform" } +# The JetBrains republish of androidx lifecycle. The plain `androidx.lifecycle` artifacts are +# Android only; these carry the uikit targets, so ViewModel, viewModelScope, `viewModel { }` and +# collectAsStateWithLifecycle can all be used from commonMain. +jetbrains-lifecycle-viewmodel-compose = { module = "org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "jetbrainsLifecycle" } +jetbrains-lifecycle-runtime-compose = { module = "org.jetbrains.androidx.lifecycle:lifecycle-runtime-compose", version.ref = "jetbrainsLifecycle" } +# Replacement for the deprecated `androidx.compose.ui.backhandler.BackHandler`. +# The JetBrains republish, not `androidx.navigationevent:navigationevent-compose` - the plain androidx +# one ships Android/JVM variants only and cannot be used from commonMain. +androidx-navigationevent-compose = { module = "org.jetbrains.androidx.navigationevent:navigationevent-compose", version.ref = "navigationEvent" } +cmp-material3 = { module = "org.jetbrains.compose.material3:material3", version.ref = "cmpMaterial3" } +cmp-material-icons-extended = { module = "org.jetbrains.compose.material:material-icons-extended", version.ref = "cmpIcons" } + diff --git a/implementation/build.gradle.kts b/implementation/build.gradle.kts index 0118259c35a4..66b5d292dbf0 100644 --- a/implementation/build.gradle.kts +++ b/implementation/build.gradle.kts @@ -2,7 +2,6 @@ plugins { alias(libs.plugins.android.library) alias(libs.plugins.ksp) alias(libs.plugins.compose.compiler) - id("kotlin-parcelize") id("android-module-dependencies") id("all-open-dependencies") id("test-module-dependencies") diff --git a/implementation/src/main/kotlin/app/aaps/implementation/alerts/LocalAlertUtilsImpl.kt b/implementation/src/main/kotlin/app/aaps/implementation/alerts/LocalAlertUtilsImpl.kt index 9cbca440f088..cdfa205a2f8d 100644 --- a/implementation/src/main/kotlin/app/aaps/implementation/alerts/LocalAlertUtilsImpl.kt +++ b/implementation/src/main/kotlin/app/aaps/implementation/alerts/LocalAlertUtilsImpl.kt @@ -1,5 +1,6 @@ package app.aaps.implementation.alerts +import app.aaps.core.keys.interfaces.TextRef import app.aaps.core.data.model.TE import app.aaps.core.data.time.T import app.aaps.core.data.ue.Action @@ -11,6 +12,7 @@ import app.aaps.core.interfaces.db.PersistenceLayer import app.aaps.core.interfaces.di.ApplicationScope import app.aaps.core.interfaces.logging.AAPSLogger import app.aaps.core.interfaces.logging.LTag +import app.aaps.core.interfaces.notifications.AlarmSound import app.aaps.core.interfaces.notifications.NotificationId import app.aaps.core.interfaces.notifications.NotificationManager import app.aaps.core.interfaces.plugin.ActivePlugin @@ -49,7 +51,7 @@ class LocalAlertUtilsImpl @Inject constructor( ) : LocalAlertUtils { init { - preferences.registerPreferences(LocalAlertLongKey::class.java) + preferences.registerPreferences(LocalAlertLongKey.entries) } private fun missedReadingsThreshold(): Long { @@ -67,7 +69,7 @@ class LocalAlertUtilsImpl @Inject constructor( if (preferences.get(BooleanKey.AlertPumpUnreachable)) { aapsLogger.debug(LTag.CORE, "Generating pump unreachable alarm. lastConnection: " + dateUtil.dateAndTimeString(lastConnection) + " isStatusOutdated: true") preferences.put(LocalAlertLongKey.NextPumpDisconnectedAlarm, dateUtil.now() + pumpUnreachableThreshold()) - notificationManager.post(NotificationId.PUMP_UNREACHABLE, R.string.pump_unreachable, soundRes = R.raw.alarm) + notificationManager.post(NotificationId.PUMP_UNREACHABLE, TextRef.AndroidRes(R.string.pump_unreachable), sound = AlarmSound.ALARM) if (preferences.get(BooleanKey.NsClientCreateAnnouncementsFromErrors) && config.APS) appScope.launch { persistenceLayer.insertPumpTherapyEventIfNewByTimestamp( @@ -134,7 +136,7 @@ class LocalAlertUtilsImpl @Inject constructor( && preferences.get(LocalAlertLongKey.NextMissedReadingsAlarm) < dateUtil.now() ) { preferences.put(LocalAlertLongKey.NextMissedReadingsAlarm, dateUtil.now() + missedReadingsThreshold()) - notificationManager.post(NotificationId.BG_READINGS_MISSED, R.string.missed_bg_readings, soundRes = R.raw.alarm) + notificationManager.post(NotificationId.BG_READINGS_MISSED, TextRef.AndroidRes(R.string.missed_bg_readings), sound = AlarmSound.ALARM) if (preferences.get(BooleanKey.NsClientCreateAnnouncementsFromErrors) && config.APS) { appScope.launch { persistenceLayer.insertPumpTherapyEventIfNewByTimestamp( diff --git a/implementation/src/main/kotlin/app/aaps/implementation/androidNotification/AlarmNotificationManager.kt b/implementation/src/main/kotlin/app/aaps/implementation/androidNotification/AlarmNotificationManager.kt index 8c1750423dcd..2531e89ece0b 100644 --- a/implementation/src/main/kotlin/app/aaps/implementation/androidNotification/AlarmNotificationManager.kt +++ b/implementation/src/main/kotlin/app/aaps/implementation/androidNotification/AlarmNotificationManager.kt @@ -7,10 +7,12 @@ import android.app.NotificationManager import android.app.PendingIntent import android.content.Context import android.content.Intent +import android.graphics.BitmapFactory import android.media.AudioAttributes import android.net.Uri import android.os.SystemClock -import androidx.annotation.RawRes +import app.aaps.core.interfaces.notifications.AlarmSound +import app.aaps.core.ui.rawRes import androidx.core.app.NotificationCompat import androidx.core.app.TaskStackBuilder import app.aaps.core.interfaces.logging.AAPSLogger @@ -94,18 +96,18 @@ class AlarmNotificationManager @Inject constructor( */ const val SOUND_ID_OFFSET = 100_000 - private val SOUND_NAMES: Map = mapOf( - app.aaps.core.ui.R.raw.alarm to "alarm", - app.aaps.core.ui.R.raw.boluserror to "boluserror", - app.aaps.core.ui.R.raw.error to "error", - app.aaps.core.ui.R.raw.urgentalarm to "urgentalarm" + private val SOUND_NAMES: Map = mapOf( + AlarmSound.ALARM to "alarm", + AlarmSound.BOLUS_ERROR to "boluserror", + AlarmSound.ERROR to "error", + AlarmSound.URGENT_ALARM to "urgentalarm" ) - private val DISPLAY_NAMES: Map = mapOf( - app.aaps.core.ui.R.raw.alarm to "Standard alarm", - app.aaps.core.ui.R.raw.boluserror to "Bolus error", - app.aaps.core.ui.R.raw.error to "General error", - app.aaps.core.ui.R.raw.urgentalarm to "Urgent alarm" + private val DISPLAY_NAMES: Map = mapOf( + AlarmSound.ALARM to "Standard alarm", + AlarmSound.BOLUS_ERROR to "Bolus error", + AlarmSound.ERROR to "General error", + AlarmSound.URGENT_ALARM to "Urgent alarm" ) } @@ -170,8 +172,8 @@ class AlarmNotificationManager @Inject constructor( // tap the notification. When the FSI does auto-launch (lockscreen/idle), ErrorActivity's // looping ramped audio takes over — its volume ramp starts at 0 so the overlap with the // channel one-shot is inaudible. - for ((soundId, displayName) in DISPLAY_NAMES) { - val uri: Uri = Uri.parse("android.resource://${context.packageName}/$soundId") + for ((sound, displayName) in DISPLAY_NAMES) { + val uri: Uri = Uri.parse("android.resource://${context.packageName}/${sound.rawRes}") val alarmAttrs = AudioAttributes.Builder() .setUsage(AudioAttributes.USAGE_ALARM) @@ -179,7 +181,7 @@ class AlarmNotificationManager @Inject constructor( .build() mgr.createNotificationChannel( NotificationChannel( - channelIdForSound(soundId, overrideDnd = true), + channelIdForSound(sound, overrideDnd = true), "$displayName (override DND)", NotificationManager.IMPORTANCE_HIGH ).apply { @@ -197,7 +199,7 @@ class AlarmNotificationManager @Inject constructor( // For medical alarms we always want heads-up visibility. mgr.createNotificationChannel( NotificationChannel( - channelIdForSound(soundId, overrideDnd = false), + channelIdForSound(sound, overrideDnd = false), "$displayName (respects DND)", NotificationManager.IMPORTANCE_HIGH ).apply { @@ -211,14 +213,14 @@ class AlarmNotificationManager @Inject constructor( private fun openAppPendingIntent(): PendingIntent? { val mainActivity = uiInteractionProvider.get().mainActivity return TaskStackBuilder.create(context).run { - addParentStack(mainActivity) - addNextIntent(Intent(context, mainActivity)) + addParentStack(mainActivity.java) + addNextIntent(Intent(context, mainActivity.java)) getPendingIntent(0, PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT) } } - private fun channelIdForSound(@RawRes soundId: Int, overrideDnd: Boolean): String { - val name = SOUND_NAMES[soundId] ?: "error" + private fun channelIdForSound(sound: AlarmSound, overrideDnd: Boolean): String { + val name = SOUND_NAMES[sound] ?: "error" val suffix = if (overrideDnd) "alarm" else "notify" return "aaps_alarm_${name}_$suffix" } @@ -229,7 +231,7 @@ class AlarmNotificationManager @Inject constructor( * launches it full-screen (device idle / lock screen) or shows a heads-up notification. * The activity is responsible for sound playback. */ - fun postFullScreenAlarm(status: String, title: String, @RawRes soundId: Int) { + fun postFullScreenAlarm(status: String, title: String, sound: AlarmSound?) { // Reached only from the background / off-main branches of UiInteraction.runAlarm. // // Screen-wake + full-screen ErrorActivity launch no longer rely on USE_FULL_SCREEN_INTENT @@ -248,8 +250,8 @@ class AlarmNotificationManager @Inject constructor( // double-audio; if ErrorActivity does launch, it re-requests the same owner+sound, which the // player treats as idempotent (no restart glitch). val postedAt = SystemClock.elapsedRealtime() - val intent = Intent(context, uiInteractionProvider.get().errorHelperActivity).apply { - putExtra(AlarmIntent.EXTRA_SOUND_ID, soundId) + val intent = Intent(context, uiInteractionProvider.get().errorHelperActivity.java).apply { + putExtra(AlarmIntent.EXTRA_SOUND, sound?.name) putExtra(AlarmIntent.EXTRA_STATUS, status) putExtra(AlarmIntent.EXTRA_TITLE, title) putExtra(AlarmIntent.EXTRA_POSTED_AT_ELAPSED_REALTIME, postedAt) @@ -263,9 +265,9 @@ class AlarmNotificationManager @Inject constructor( ) val overrideDnd = preferences.get(BooleanKey.AlertOverrideDoNotDisturb) - // Always a sound-bearing channel (matching the requested soundId + DND preference) so the alarm + // Always a sound-bearing channel (matching the requested sound + DND preference) so the alarm // is audible from the notification regardless of whether the FSI activity ever launches. - val channelId = channelIdForSound(soundId, overrideDnd) + val channelId = channelIdForSound(sound ?: AlarmSound.ERROR, overrideDnd) // Mute action so the user can silence the looping alarm straight from the lock-screen // notification when ErrorActivity isn't in the foreground (see AlarmMuteReceiver). @@ -305,7 +307,7 @@ class AlarmNotificationManager @Inject constructor( // Continuous looping/ramping audio so the alarm keeps sounding even if ErrorActivity never // comes to the foreground (deferred past the channel one-shot via postedAt). Stopped by // muteAllAlarms() / ErrorActivity acknowledge, both of which stop OWNER_FULLSCREEN. - alarmSoundPlayer.play(soundId, AlarmSoundPlayer.OWNER_FULLSCREEN, postedAt) + sound?.let { alarmSoundPlayer.play(it, AlarmSoundPlayer.OWNER_FULLSCREEN, postedAt) } // Permission-free screen-wake + activity launch (independent of the notification above, so it // still runs even if POST_NOTIFICATIONS was revoked and mgr.notify threw). @@ -330,7 +332,7 @@ class AlarmNotificationManager @Inject constructor( val triggerAt = System.currentTimeMillis() + SCREEN_WAKE_DELAY_MS val show = PendingIntent.getActivity( context, WAKE_REQUEST_CODE, - Intent(context, uiInteractionProvider.get().mainActivity), + Intent(context, uiInteractionProvider.get().mainActivity.java), PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT ) val wakeOp = PendingIntent.getBroadcast( @@ -362,7 +364,7 @@ class AlarmNotificationManager @Inject constructor( ) { val builder = NotificationCompat.Builder(context, CHANNEL_FULL_SCREEN_SILENT) .setSmallIcon(iconsProvider.getNotificationIcon()) - .setLargeIcon(rh.decodeResource(iconsProvider.getIcon())) + .setLargeIcon(BitmapFactory.decodeResource(context.resources, iconsProvider.getIcon())) .setContentTitle(title) .setContentText(body) .setStyle(NotificationCompat.BigTextStyle().bigText(body)) diff --git a/implementation/src/main/kotlin/app/aaps/implementation/androidNotification/AlarmSoundPlayerImpl.kt b/implementation/src/main/kotlin/app/aaps/implementation/androidNotification/AlarmSoundPlayerImpl.kt index 0c7b367375e9..0a72dfce3621 100644 --- a/implementation/src/main/kotlin/app/aaps/implementation/androidNotification/AlarmSoundPlayerImpl.kt +++ b/implementation/src/main/kotlin/app/aaps/implementation/androidNotification/AlarmSoundPlayerImpl.kt @@ -9,6 +9,8 @@ import android.os.Handler import android.os.Looper import android.os.SystemClock import androidx.annotation.RawRes +import app.aaps.core.interfaces.notifications.AlarmSound +import app.aaps.core.ui.rawRes import app.aaps.core.interfaces.logging.AAPSLogger import app.aaps.core.interfaces.logging.LTag import app.aaps.core.interfaces.notifications.AlarmSoundPlayer @@ -42,31 +44,30 @@ class AlarmSoundPlayerImpl @Inject constructor( // --- main-looper-confined state --- private var player: MediaPlayer? = null - @RawRes private var currentSound: Int = 0 + private var currentSound: AlarmSound? = null private var currentOwner: String? = null private var currentVolumeLevel = 0 // Stable reference so a deferred start can be cancelled by doStop() during the channel-sound guard. private val startRunnable = Runnable { startMediaPlayer() } - override fun play(@RawRes soundRes: Int, ownerTag: String, postedAtElapsedRealtime: Long) { - if (soundRes == 0) return - handler.post { doPlay(soundRes, ownerTag, postedAtElapsedRealtime) } + override fun play(sound: AlarmSound, ownerTag: String, postedAtElapsedRealtime: Long) { + handler.post { doPlay(sound, ownerTag, postedAtElapsedRealtime) } } override fun stop(ownerTag: String) { handler.post { if (currentOwner == ownerTag) doStop() } } - private fun doPlay(@RawRes soundRes: Int, ownerTag: String, postedAtElapsedRealtime: Long) { + private fun doPlay(sound: AlarmSound, ownerTag: String, postedAtElapsedRealtime: Long) { // Idempotent: a re-request of the exact same sound by the same owner while it's already // playing is a no-op. This lets ErrorActivity call play() when it foregrounds on top of an // alarm that AlarmNotificationManager already started (same OWNER_FULLSCREEN) without a // stop/restart audio glitch. - if (player != null && currentOwner == ownerTag && currentSound == soundRes) return + if (player != null && currentOwner == ownerTag && currentSound == sound) return doStop() - currentSound = soundRes + currentSound = sound currentOwner = ownerTag // Only the full-screen path passes postedAt > 0 (it has an accompanying channel one-shot to @@ -74,7 +75,7 @@ class AlarmSoundPlayerImpl @Inject constructor( // probe entirely and start immediately. val deferralMs = if (postedAtElapsedRealtime > 0L) { - val soundDurationMs = probeSoundDurationMs(soundRes) + val soundDurationMs = probeSoundDurationMs(sound.rawRes) (soundDurationMs - (SystemClock.elapsedRealtime() - postedAtElapsedRealtime)).coerceAtLeast(0L) } else 0L @@ -102,8 +103,7 @@ class AlarmSoundPlayerImpl @Inject constructor( } private fun startMediaPlayer() { - val soundRes = currentSound - if (soundRes == 0) return + val soundRes = currentSound?.rawRes ?: return val overrideDnd = preferences.get(BooleanKey.AlertOverrideDoNotDisturb) val audioAttrs = AudioAttributes.Builder() @@ -118,7 +118,7 @@ class AlarmSoundPlayerImpl @Inject constructor( MediaPlayer() } mp.setAudioAttributes(audioAttrs) - val afd = rh.openRawResourceFd(soundRes) ?: run { + val afd = context.resources.openRawResourceFd(soundRes) ?: run { aapsLogger.error(LTag.CORE, "AlarmSoundPlayer: unable to open raw resource $soundRes") mp.release() return diff --git a/implementation/src/main/kotlin/app/aaps/implementation/androidNotification/NotificationHolderImpl.kt b/implementation/src/main/kotlin/app/aaps/implementation/androidNotification/NotificationHolderImpl.kt index ed304d824b7c..5c63d4c137c7 100644 --- a/implementation/src/main/kotlin/app/aaps/implementation/androidNotification/NotificationHolderImpl.kt +++ b/implementation/src/main/kotlin/app/aaps/implementation/androidNotification/NotificationHolderImpl.kt @@ -6,6 +6,7 @@ import android.app.NotificationManager import android.app.PendingIntent import android.content.Context import android.content.Intent +import android.graphics.BitmapFactory import androidx.core.app.NotificationCompat import androidx.core.app.TaskStackBuilder import app.aaps.core.interfaces.notifications.NotificationHolder @@ -32,9 +33,9 @@ class NotificationHolderImpl @Inject constructor( } get() = _notification ?: placeholderNotification() - override fun openAppIntent(context: Context): PendingIntent? = TaskStackBuilder.create(context).run { - addParentStack(uiInteraction.mainActivity) - addNextIntent(Intent(context, uiInteraction.mainActivity)) + override fun openAppIntent(): PendingIntent? = TaskStackBuilder.create(context).run { + addParentStack(uiInteraction.mainActivity.java) + addNextIntent(Intent(context, uiInteraction.mainActivity.java)) getPendingIntent(0, PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT) } @@ -47,9 +48,9 @@ class NotificationHolderImpl @Inject constructor( .setOnlyAlertOnce(true) .setCategory(NotificationCompat.CATEGORY_STATUS) .setSmallIcon(iconsProvider.getNotificationIcon()) - .setLargeIcon(rh.decodeResource(iconsProvider.getIcon())) + .setLargeIcon(BitmapFactory.decodeResource(context.resources, iconsProvider.getIcon())) .setContentTitle(rh.gs(app.aaps.core.ui.R.string.loading)) - .setContentIntent(openAppIntent(context)) + .setContentIntent(openAppIntent()) .build() .also { (context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager).notify(notificationID, it) diff --git a/implementation/src/main/kotlin/app/aaps/implementation/aps/DetermineBasalResult.kt b/implementation/src/main/kotlin/app/aaps/implementation/aps/DetermineBasalResult.kt index cb861fc8a681..73088cf94002 100644 --- a/implementation/src/main/kotlin/app/aaps/implementation/aps/DetermineBasalResult.kt +++ b/implementation/src/main/kotlin/app/aaps/implementation/aps/DetermineBasalResult.kt @@ -1,6 +1,5 @@ package app.aaps.implementation.aps -import android.text.Spanned import app.aaps.core.data.model.GV import app.aaps.core.data.model.SourceSensor import app.aaps.core.data.model.TrendArrow @@ -32,8 +31,9 @@ import app.aaps.core.keys.interfaces.Preferences import app.aaps.core.objects.extensions.convertedToAbsolute import app.aaps.core.objects.extensions.convertedToPercent import app.aaps.core.ui.R -import app.aaps.core.utils.HtmlHelper -import org.json.JSONObject +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.jsonObject import javax.inject.Inject import javax.inject.Provider import kotlin.math.abs @@ -142,35 +142,13 @@ class DetermineBasalResult @Inject constructor( } else rh.gs(R.string.nochangerequested) } - override suspend fun resultAsSpanned(): Spanned = HtmlHelper.fromHtml(resultAsHtmlString()) - override suspend fun resultAsHtmlString(): String { - val pump = activePlugin.activePump - if (isChangeRequested()) { - // rate - var ret: String = - if (rate == 0.0 && duration == 0) rh.gs(R.string.cancel_temp) + "
" - else if (rate == -1.0) rh.gs(R.string.let_temp_basal_run) + "
" - else if (usePercent) rh.gs(R.string.percent_rate_duration_formatted, percent.toDouble(), percent * ch.fromPump(pump.baseBasalRate) / 100.0, duration) - else rh.gs(R.string.rate_percent_duration_formatted, rate, rate / ch.fromPump(pump.baseBasalRate) * 100.0, duration) - - // smb - if (smb != 0.0) ret += "" + "SMB" + ": " + decimalFormatter.toPumpSupportedBolus(smb, activePlugin.activePump.pumpDescription.bolusStep) + "
" - if (isCarbsRequired) { - ret += "$carbsRequiredText
" - } - - // reason - ret += "" + rh.gs(R.string.reason) + ": " + reason.replace("<", "<").replace(">", ">") - return ret - } - return if (isCarbsRequired) carbsRequiredText - else rh.gs(R.string.nochangerequested) - } - override fun newAndClone(): APSResult = apsResultProvider.get().with(result) - override fun json(): JSONObject { + override fun json(): JsonObject { reportNonFiniteResultFields() - return JSONObject(result.serialize()) + // Straight to a tree. This used to serialise to text and parse it back with org.json, once per + // loop cycle. Same default Json either way, so a non-finite Double still throws here - see + // `reportNonFiniteResultFields`, that crash is the signal and must not be swallowed. + return Json.encodeToJsonElement(RT.serializer(), result).jsonObject } /** @@ -182,9 +160,9 @@ class DetermineBasalResult @Inject constructor( * * We deliberately do NOT sanitize/swallow here — the crash is the signal driving the ongoing * DetermineBasal NaN hunt (see `DetermineBasalSMB` minPredBG pin, `OpenAPSSMBPlugin` invalidInputs - * guard). Instead, right before the (still-crashing) serialize, we report exactly which field is + * guard). Instead, right before the (still-crashing) encode, we report exactly which field is * non-finite plus the ISF inputs that feed it, so the next occurrence pinpoints the field and - * algorithm instead of an opaque framework trace. `[result.serialize]` runs unchanged afterwards. + * algorithm instead of an opaque framework trace. The encode runs unchanged afterwards. */ private fun reportNonFiniteResultFields() { val offenders = buildList { diff --git a/implementation/src/main/kotlin/app/aaps/implementation/bolus/WizardBolusExecutorImpl.kt b/implementation/src/main/kotlin/app/aaps/implementation/bolus/WizardBolusExecutorImpl.kt index 80a93ef70d0f..fa0d4a942f8c 100644 --- a/implementation/src/main/kotlin/app/aaps/implementation/bolus/WizardBolusExecutorImpl.kt +++ b/implementation/src/main/kotlin/app/aaps/implementation/bolus/WizardBolusExecutorImpl.kt @@ -27,6 +27,7 @@ import app.aaps.core.interfaces.iob.IobCobCalculator import app.aaps.core.interfaces.logging.AAPSLogger import app.aaps.core.interfaces.logging.LTag import app.aaps.core.interfaces.logging.UserEntryLogger +import app.aaps.core.interfaces.notifications.AlarmSound import app.aaps.core.interfaces.notifications.NotificationId import app.aaps.core.interfaces.notifications.NotificationManager import app.aaps.core.interfaces.plugin.ActivePlugin @@ -50,6 +51,7 @@ import app.aaps.core.objects.wizard.BolusWizard import app.aaps.core.objects.wizard.QuickWizard import app.aaps.core.objects.wizard.QuickWizardEntry import app.aaps.core.ui.R +import app.aaps.core.interfaces.R as InterfacesR import app.aaps.core.ui.compose.formatMinutesAsDuration import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch @@ -688,29 +690,29 @@ class WizardBolusExecutorImpl @Inject constructor( val pumpDescription = activePlugin.activePump.pumpDescription val out = mutableListOf() if (insulin > 0.0) { - out += ConfirmationLine(ConfirmationRole.BOLUS, rh.gs(R.string.confirmation_line, rh.gs(R.string.bolus), decimalFormatter.toPumpSupportedBolusWithUnits(insulin, pumpDescription.bolusStep))) + out += ConfirmationLine(ConfirmationRole.BOLUS, rh.gs(InterfacesR.string.confirmation_line, rh.gs(InterfacesR.string.bolus), decimalFormatter.toPumpSupportedBolusWithUnits(insulin, pumpDescription.bolusStep))) if (recordOnly) { - out += ConfirmationLine(ConfirmationRole.WARNING, rh.gs(R.string.bolus_recorded_only)) + out += ConfirmationLine(ConfirmationRole.WARNING, rh.gs(InterfacesR.string.bolus_recorded_only)) bolus.iCfg?.let { out += ConfirmationLine(ConfirmationRole.NORMAL, rh.gs(R.string.selected_insulin, it.insulinLabel)) } } else if (abs(insulin - bolus.insulin) > pumpDescription.pumpType.determineCorrectBolusStepSize(insulin)) { - out += ConfirmationLine(ConfirmationRole.WARNING, rh.gs(R.string.bolus_constraint_applied_warn, bolus.insulin, insulin)) + out += ConfirmationLine(ConfirmationRole.WARNING, rh.gs(InterfacesR.string.bolus_constraint_applied_warn, bolus.insulin, insulin)) } } if (carbs != 0) { - out += ConfirmationLine(ConfirmationRole.CARBS, rh.gs(R.string.confirmation_line, rh.gs(R.string.carbs), rh.gs(R.string.format_carbs, carbs))) + out += ConfirmationLine(ConfirmationRole.CARBS, rh.gs(InterfacesR.string.confirmation_line, rh.gs(InterfacesR.string.carbs), rh.gs(InterfacesR.string.format_carbs, carbs))) if (!recordOnly && carbs != bolus.carbs) out += ConfirmationLine(ConfirmationRole.WARNING, rh.gs(R.string.constraint_applied)) // Delayed/extended carbs (e.g. wear eCarbs): show the scheduled start time on the general line so every // surface (phone, client, watch) renders it identically — the one piece of info added to the shared path. if (bolus.carbsTimeOffsetMinutes != 0) - out += ConfirmationLine(ConfirmationRole.NORMAL, rh.gs(R.string.confirmation_line, rh.gs(R.string.time), dateUtil.timeString(dateUtil.now() + T.mins(bolus.carbsTimeOffsetMinutes.toLong()).msecs()))) + out += ConfirmationLine(ConfirmationRole.NORMAL, rh.gs(InterfacesR.string.confirmation_line, rh.gs(R.string.time), dateUtil.timeString(dateUtil.now() + T.mins(bolus.carbsTimeOffsetMinutes.toLong()).msecs()))) if (bolus.carbsDurationHours > 0) - out += ConfirmationLine(ConfirmationRole.NORMAL, rh.gs(R.string.confirmation_line, rh.gs(R.string.duration), rh.gs(R.string.value_with_unit, bolus.carbsDurationHours.toString(), rh.gs(app.aaps.core.interfaces.R.string.shorthour)))) + out += ConfirmationLine(ConfirmationRole.NORMAL, rh.gs(InterfacesR.string.confirmation_line, rh.gs(R.string.duration), rh.gs(R.string.value_with_unit, bolus.carbsDurationHours.toString(), rh.gs(app.aaps.core.interfaces.R.string.shorthour)))) } if (bolus.eCarbsGrams > 0) - out += ConfirmationLine(ConfirmationRole.CARBS, rh.gs(R.string.wizard_ecarbs, bolus.eCarbsGrams, bolus.eCarbsDurationHours, bolus.eCarbsDelayMinutes)) + out += ConfirmationLine(ConfirmationRole.CARBS, rh.gs(InterfacesR.string.wizard_ecarbs, bolus.eCarbsGrams, bolus.eCarbsDurationHours, bolus.eCarbsDelayMinutes)) if (bolus.notes.isNotEmpty()) - out += ConfirmationLine(ConfirmationRole.NORMAL, rh.gs(R.string.confirmation_line, rh.gs(R.string.notes_label), bolus.notes)) + out += ConfirmationLine(ConfirmationRole.NORMAL, rh.gs(InterfacesR.string.confirmation_line, rh.gs(R.string.notes_label), bolus.notes)) return out } @@ -723,7 +725,7 @@ class WizardBolusExecutorImpl @Inject constructor( private fun buildTempTargetLines(reasonDisplay: String, lowMgdl: Double, highMgdl: Double, durationMinutes: Int, standalone: Boolean = false): List { val out = mutableListOf() if (durationMinutes == 0) { - out += ConfirmationLine(ConfirmationRole.NORMAL, rh.gs(R.string.confirmation_line, rh.gs(R.string.temporary_target), rh.gs(R.string.cancel))) + out += ConfirmationLine(ConfirmationRole.NORMAL, rh.gs(InterfacesR.string.confirmation_line, rh.gs(R.string.temporary_target), rh.gs(R.string.cancel))) } else { val units = profileFunction.getUnits() val unitLabel = if (units == GlucoseUnit.MMOL) rh.gs(R.string.mmol) else rh.gs(R.string.mgdl) @@ -733,14 +735,14 @@ class WizardBolusExecutorImpl @Inject constructor( val targetLabel = if (standalone) rh.gs(R.string.target_label) else rh.gs(R.string.temporary_target) out += ConfirmationLine( ConfirmationRole.TEMP_TARGET, - rh.gs(R.string.confirmation_line, targetLabel, rh.gs(R.string.value_with_unit, target, unitLabel)) + rh.gs(InterfacesR.string.confirmation_line, targetLabel, rh.gs(R.string.value_with_unit, target, unitLabel)) ) out += ConfirmationLine( ConfirmationRole.NORMAL, - rh.gs(R.string.confirmation_line, rh.gs(R.string.duration), durationText) + rh.gs(InterfacesR.string.confirmation_line, rh.gs(R.string.duration), durationText) ) if (reasonDisplay.isNotEmpty()) - out += ConfirmationLine(ConfirmationRole.NORMAL, rh.gs(R.string.confirmation_line, rh.gs(R.string.reason), reasonDisplay)) + out += ConfirmationLine(ConfirmationRole.NORMAL, rh.gs(InterfacesR.string.confirmation_line, rh.gs(R.string.reason), reasonDisplay)) } return out } @@ -801,12 +803,12 @@ class WizardBolusExecutorImpl @Inject constructor( /** The PS line(s) for any batch (wear / client / phone) — target profile name + percentage + optional time-shift + duration. */ private suspend fun buildPsLine(ps: BatchAction.ProfileSwitch): List { val out = mutableListOf() - out += ConfirmationLine(ConfirmationRole.PRIMARY, rh.gs(R.string.confirmation_line, rh.gs(R.string.profile), ps.profileName ?: profileFunction.getOriginalProfileName())) - out += ConfirmationLine(ConfirmationRole.NORMAL, rh.gs(R.string.confirmation_line, rh.gs(R.string.percentage_label), rh.gs(R.string.format_percent, ps.percentage))) + out += ConfirmationLine(ConfirmationRole.PRIMARY, rh.gs(InterfacesR.string.confirmation_line, rh.gs(R.string.profile), ps.profileName ?: profileFunction.getOriginalProfileName())) + out += ConfirmationLine(ConfirmationRole.NORMAL, rh.gs(InterfacesR.string.confirmation_line, rh.gs(R.string.percentage_label), rh.gs(R.string.format_percent, ps.percentage))) if (ps.timeShiftHours != 0) - out += ConfirmationLine(ConfirmationRole.NORMAL, rh.gs(R.string.confirmation_line, rh.gs(R.string.timeshift_label), rh.gs(R.string.value_with_unit, ps.timeShiftHours.toString(), rh.gs(app.aaps.core.interfaces.R.string.shorthour)))) + out += ConfirmationLine(ConfirmationRole.NORMAL, rh.gs(InterfacesR.string.confirmation_line, rh.gs(R.string.timeshift_label), rh.gs(R.string.value_with_unit, ps.timeShiftHours.toString(), rh.gs(app.aaps.core.interfaces.R.string.shorthour)))) if (ps.durationMinutes > 0) - out += ConfirmationLine(ConfirmationRole.NORMAL, rh.gs(R.string.confirmation_line, rh.gs(R.string.duration), formatMinutesAsDuration(ps.durationMinutes, rh))) + out += ConfirmationLine(ConfirmationRole.NORMAL, rh.gs(InterfacesR.string.confirmation_line, rh.gs(R.string.duration), formatMinutesAsDuration(ps.durationMinutes, rh))) return out } @@ -860,7 +862,7 @@ class WizardBolusExecutorImpl @Inject constructor( val out = mutableListOf() out += ConfirmationLine(rmModeRole(rm.mode), rmModeTitle(rm.mode)) if (rm.durationMinutes > 0) - out += ConfirmationLine(ConfirmationRole.NORMAL, rh.gs(R.string.confirmation_line, rh.gs(R.string.duration), formatMinutesAsDuration(rm.durationMinutes, rh))) + out += ConfirmationLine(ConfirmationRole.NORMAL, rh.gs(InterfacesR.string.confirmation_line, rh.gs(R.string.duration), formatMinutesAsDuration(rm.durationMinutes, rh))) return out } @@ -882,7 +884,7 @@ class WizardBolusExecutorImpl @Inject constructor( RM.Mode.OPEN_LOOP -> rh.gs(R.string.openloop) RM.Mode.DISABLED_LOOP -> rh.gs(R.string.disableloop) RM.Mode.SUSPENDED_BY_USER -> rh.gs(R.string.suspendloop) - RM.Mode.DISCONNECTED_PUMP -> rh.gs(R.string.pump_disconnected) + RM.Mode.DISCONNECTED_PUMP -> rh.gs(InterfacesR.string.pump_disconnected) RM.Mode.RESUME -> if (loop.runningMode() == RM.Mode.DISCONNECTED_PUMP) rh.gs(R.string.pump_reconnect) else rh.gs(R.string.resumeloop) RM.Mode.SUPER_BOLUS, RM.Mode.SUSPENDED_BY_PUMP, RM.Mode.SUSPENDED_BY_DST -> rh.gs(R.string.running_mode) } @@ -904,8 +906,8 @@ class WizardBolusExecutorImpl @Inject constructor( private fun buildTempBasalLine(capped: BatchAction.TempBasal, original: BatchAction.TempBasal?): List { val out = mutableListOf() val rateStr = if (capped.isPercent) rh.gs(R.string.format_percent, capped.rate.toInt()) else rh.gs(R.string.pump_base_basal_rate, capped.rate) - out += ConfirmationLine(ConfirmationRole.PRIMARY, rh.gs(R.string.confirmation_line, rh.gs(R.string.tempbasal_label), rateStr)) - out += ConfirmationLine(ConfirmationRole.NORMAL, rh.gs(R.string.confirmation_line, rh.gs(R.string.duration), rh.gs(R.string.format_mins, capped.durationMinutes))) + out += ConfirmationLine(ConfirmationRole.PRIMARY, rh.gs(InterfacesR.string.confirmation_line, rh.gs(R.string.tempbasal_label), rateStr)) + out += ConfirmationLine(ConfirmationRole.NORMAL, rh.gs(InterfacesR.string.confirmation_line, rh.gs(R.string.duration), rh.gs(R.string.format_mins, capped.durationMinutes))) if (original != null && capped.rate != original.rate) out += ConfirmationLine(ConfirmationRole.WARNING, rh.gs(R.string.constraint_applied)) return out } @@ -920,8 +922,8 @@ class WizardBolusExecutorImpl @Inject constructor( /** The extended-bolus line(s) — insulin + duration, with a cap warning when reduced. */ private fun buildExtendedBolusLine(capped: BatchAction.ExtendedBolus, original: BatchAction.ExtendedBolus?): List { val out = mutableListOf() - out += ConfirmationLine(ConfirmationRole.BOLUS, rh.gs(R.string.format_insulin_units, capped.insulin)) - out += ConfirmationLine(ConfirmationRole.NORMAL, rh.gs(R.string.confirmation_line, rh.gs(R.string.duration), rh.gs(R.string.format_mins, capped.durationMinutes))) + out += ConfirmationLine(ConfirmationRole.BOLUS, rh.gs(InterfacesR.string.format_insulin_units, capped.insulin)) + out += ConfirmationLine(ConfirmationRole.NORMAL, rh.gs(InterfacesR.string.confirmation_line, rh.gs(R.string.duration), rh.gs(R.string.format_mins, capped.durationMinutes))) if (original != null && abs(capped.insulin - original.insulin) > 0.01) out += ConfirmationLine(ConfirmationRole.WARNING, rh.gs(R.string.constraint_applied)) return out } @@ -942,11 +944,11 @@ class WizardBolusExecutorImpl @Inject constructor( /** The single cancel line — "Cancel: Temp basal" / "Cancel: Extended bolus" ([labelRes] = the cancelled action). */ private fun buildCancelLine(labelRes: Int): List = - listOf(ConfirmationLine(ConfirmationRole.PRIMARY, rh.gs(R.string.confirmation_line, rh.gs(R.string.cancel), rh.gs(labelRes)))) + listOf(ConfirmationLine(ConfirmationRole.PRIMARY, rh.gs(InterfacesR.string.confirmation_line, rh.gs(R.string.cancel), rh.gs(labelRes)))) /** The insulin-activate line — "Activate insulin: