Skip to content

KMP migration - #5067

Open
MilosKozak wants to merge 136 commits into
devfrom
kmp
Open

KMP migration#5067
MilosKozak wants to merge 136 commits into
devfrom
kmp

Conversation

@MilosKozak

Copy link
Copy Markdown
Contributor

No description provided.

Event.toString() used commons-lang3 ReflectionToStringBuilder, which was the only
thing keeping the base class on Android.

The 23 events that carry constructor parameters become data classes, so they
print their own values. The 41 that carry none keep the base toString, which is
now just the class name - the same thing reflection produced for a class with no
fields. commons-lang3 is gone from :core:interfaces.

Nothing depended on event identity, so the change from identity to content
equality is safe: no distinctUntilChanged on a bus stream, no event in a Set or
Map, no event compared with ==. The dedup key documented on EventShowSnackbar is
not actually implemented in the host, so it does not rely on equality either.

None of the 23 was open or subclassed, so all could take data.

EventMobileToWearWatchface holds a ByteArray, so its generated toString prints the
reference rather than the bytes reflection used to dump. For a watchface payload
that is an improvement.

RxBus is now one step from commonMain: Class<T> in toFlow is all that is left.
Class<T> in toFlow was the last thing keeping RxBus on Android. KClass is
multiplatform and isInstance works the same, so the signature becomes
toFlow(eventType: KClass<T>) and 208 call sites drop one token: ::class.java
becomes ::class.

Three places pass the type in a variable rather than a literal and change with
it: RxHelper in androidTest, SWEventListener, and the two test fakes of RxBus.
RxHelper's maps and its waitFor and resetState take KClass now too, so the file is
consistent, and its callers plus SWDefinition follow.

With that and Event moving in the previous commit, the fixpoint jumps from 156
files in commonMain to 204, leaving 56. Six rounds again: 31 files fail on their
own imports, then 11, 10, 3 and 1. Sensitivity, IobCobCalculator, AutosensDataStore
and the rest of that chain came across.

What is left is genuinely platform bound or waits on ResourceHelper: SP, Storage,
FileListProvider, the Dagger qualifiers, PasswordCheck, BlePreCheck,
RfcommTransport, Sms, AapsSchedulers, and the plugin and pump interfaces that name
ResourceHelper in a signature.

Reified would read better than KClass at the call site. It can come later on its
own - it is an inline extension, so it changes the shape of every call rather than
one token.
Same change as RxBus, and it finishes something that was already half done here:
observeAnyChange has always returned Flow<Set<KClass<*>>>, and a reified
observeChanges() extension already existed, it just unwrapped to Class internally.

89 call sites drop .java. The when in PersistenceLayerImpl matches on KClass now,
the test stubs match any<KClass<*>> instead of any<Class<*>>, and one list of
types in OverviewDataCacheImpl needed converting by hand because it is built with
listOf rather than passed inline.

KClass.simpleName is nullable where Class.simpleName is not, which the default
argument of awaitDbChange in androidTest relied on.

This moves PersistenceLayer to commonMain and nothing else: everything that
references it is held up by something else, mostly ResourceHelper. 205 files in
commonMain now, 55 left.
remove, putBoolean, putDouble, putLong, putInt and putString taking a @stringres
id existed on SP.Editor and nowhere else did anything call them, except
SPImplTest. The test drives them through a `someResource` variable, so a search
for edit blocks containing R.string found nothing and reported them as dead. They
are dead in production; the test assertions go with them.

SP itself stays in androidMain with its own resource id overloads, which wear
uses. That is where it belongs: Preferences in :core:keys is the platform neutral
API and is already in commonMain without referencing SP, and SP is the Android
SharedPreferences layer that the phone's PreferencesImpl is built on. SP is not a
blocker either - nothing in :core:interfaces references it.
Same change as DateUtil, for the files that named ResourceHelper only to pass it
along. Callers are untouched, because a ResourceHelper is a TextResolver.

InsulinType was already halfway there: its labels are TextRefs, so rh.gs(label)
was a TextResolver call already and only the parameter type had to change. It
moves to commonMain and takes InsulinManager with it.

Profile names it in five signatures and never calls it. The work is in
ProfileSealed, which does: 17 rh.gs(R.string.x) become
rh.gs(TextRef.AndroidRes(R.string.x)), including two multi line calls and one
that picks the id with an if. PumpSync has three of the same in its own default
methods.

Profile and PumpSync still cannot move: Profile names Pump, and PumpSync names
Profile. Pump itself has no platform import and no ResourceHelper, so what holds
that group is worth a separate look.

ProfileSealedTest stubbed rh.gs with a raw id and now stubs the TextRef, which is
what turned "mmol/L/U" into null.

207 files in commonMain, 53 left.
ImageVector was never blocking PluginDescription. Compose Multiplatform is already
a commonMain dependency of this module, so it resolves there, and moving the file
proves it. I had tested that earlier and then repeated the wrong reason twice.

What actually held this group:

- Pump waited on PumpProfile, which waited on Profile, which is the TextResolver
  change from the previous commit. Nothing platform bound anywhere in the chain.
- PumpSync named R.string directly, so wrapping the ids in TextRef.AndroidRes did
  not help - AndroidRes still needs the R class. Its three strings exist in the
  generated common table, so they become InterfacesStrings.temp_basal_* , which is
  TextRef.Named and needs no R. This is the pattern InsulinType already used.

PluginDescription still cannot move, but not for the reason I gave: it takes a
(PluginBase) -> Any provider, and PluginBase stays behind for two small JVM things
- an org.jetbrains @testonly annotation, and pluginId reading javaClass.simpleName.

pluginId is deliberately left alone. Its KDoc says it matches the legacy
RunningConfiguration encoding so the active-plugin sync keeps dual-writing the
same value, and this::class.simpleName is nullable where javaClass.simpleName is
not. Changing a persisted identity needs a decision, not a sweep.

211 files in commonMain, 49 left.
javaClass.simpleName becomes this::class.simpleName, which is the same string: a
plugin is always a named class, as you said. It is not defaulted - simpleName is
only null for an anonymous class, and syncing an empty plugin id would be worse
than failing, since the value is persisted and mirrored to the client.

The org.jetbrains @testonly annotation goes too. It is JVM only and carries no
runtime behaviour; the KDoc above the function already says it is for tests.

That removes both JVM references from PluginBase. It still does not move, and I
tried: making its rh a TextResolver breaks 30 plugin files that call
rh.gs(R.string.x) through the inherited property. That is churn in plugin and
pump code which is not going multiplatform, so it waits for the ResourceHelper
migration rather than being forced now. PluginDescription is behind PluginBase
either way - it takes a (PluginBase) -> Any provider.
pluginName, shortName and description were @stringres ids with -1 meaning unset.
They are TextRef? now, so a plugin names itself with something that carries no
Android resource id, and "unset" is null rather than a sentinel.

64 files pass TextRef.AndroidRes(R.string.x) at the builder. That is fine from a
plain Android module: TextRef lives in :core:keys commonMain, and a KMP module
publishes an ordinary Android artifact, so pump drivers and plugins use it with no
KMP plugin of their own - :pump:danar already had 43 uses before this.

PreferenceSubScreenDef gains a TextRef primary constructor and keeps the resource
id one as a secondary, so its 285 call sites are untouched while the three BG
source plugins can pass a plugin's own name straight through. It already derived
`title: TextRef` from the id internally, so this only exposes what it was doing.

Readers lose their sentinel checks: SearchIndexBuilder compares against null,
SearchableItem and QuickLaunchResolver pass the ref along instead of rewrapping
it, and ConfigBuilderImpl uses PluginBase.name where it wanted the localized form.

PluginBase keeps rh: ResourceHelper. Narrowing it to TextResolver is what would
let PluginBase and PluginDescription reach commonMain, but it breaks plugins that
hand the inherited rh to other ResourceHelper-typed APIs - VirtualPumpPlugin does
this five times - so that belongs with the ResourceHelper migration, not here.
PluginBase held rh: ResourceHelper, but it only ever calls gs(TextRef), for
name, nameShort and description. ResourceHelper adds the Android resource id
overloads, which mean something only where Android resources exist, so that one
field kept PluginBase, PluginDescription and ActivePlugin in androidMain.

rh is now TextResolver, the platform neutral half. Plugins that use the
inherited rh with Android resource ids narrow it back with
"override val rh: ResourceHelper" - 31 one line changes. The alternative was to
wrap every call as rh.gs(TextRef.AndroidRes(R.string.x)), which measured 366
call sites and would also mix the TextRef migration of individual plugins into
a change that is about platform neutrality. That migration stays a separate job.
Plugins behave exactly as before.

Other platform specific parts of these types:

- ActivePlugin.getSpecificPluginsListByInterface and CommandQueue.isCustomCommand*
  took Class<*>. They take KClass now, so call sites pass X::class.
- ActivePlugin.collectMissingPermissions and collectAllPermissions need a Context
  and the Android permission model. They moved to a new PluginPermissions
  interface in androidMain; PluginStore implements both.
- UiInteraction.mainActivity and errorHelperActivity are KClass, so the nine
  Intent call sites add .java.
- Callback implemented java.lang.Runnable. Nothing ever handed a Callback to
  something that wanted a Runnable, so it declares its own run(). This also fixed
  two "cannot infer type parameter" errors in Command, where ?.run() resolved to
  Kotlin's scope function instead.

Moved to commonMain: ActivePlugin, PluginBase, PluginBaseWithPreferences,
PluginDescription, APS, Sensitivity, IobCobCalculator, PumpWithConcentration,
Callback, Command, CommandQueue, EffectiveProfile, UiInteraction.
:core:interfaces goes from 211/49 to 224/37 commonMain/androidMain.

Dead code found on the way:

- CommandQueueImplementation.removeAllCustomCommands tested "command is
  CustomCommand" on a Command from the queue, so it never matched. It was
  unreachable anyway: the line above returns early whenever a command of the
  same type is already queued.
- OmnipodDashPumpPlugin built a bolus progress string and threw it away.

Omnipod Eros keeps rh.gq for its pod alert plural. Plurals have no TextRef form
yet, so that plugin still needs the Android ResourceHelper.
Same four plugin recipe as :core:interfaces - kotlin("multiplatform") plus the
AGP multiplatform library, the Compose compiler and Compose Multiplatform.
src/main becomes src/androidMain and src/test becomes src/androidHostTest.

166 of 438 files reach commonMain, 0 before. 131 of them are icon definitions,
moved untouched: CMP reuses the androidx package names, so not one import
changed. The rest came from compiling for iosArm64 and moving back whatever
failed, three rounds to a fixpoint.

What stays on Android, and why:

- 174 *Previews.kt files. androidx.compose.ui.tooling.preview.Preview is Android
  only and CMP keeps its own in a separate artifact. Splitting each icon from its
  preview harness costs nothing, because androidMain sees commonMain.
- ~95 files that name an Android resource id. R appears 75 times in the root
  blocker list, ResourceHelper.gs 30 more. UiStrings is already generated for
  this module, so the way out is TextRef.Named, the same conversion :core:keys
  and PluginDescription went through. Not started here.
- 16 files that use a real Android API - Context, LocalContext, fromHtml,
  FragmentActivity, window and requestedOrientation.

Restated by hand, because android-module-dependencies, test-module-dependencies,
compose-test-module-dependencies and jacoco-module-dependencies all apply
com.android.library and a multiplatform module cannot:

- lint MissingTranslation / ExtraTranslation off, or the incomplete locale files
  would fail a release build for the first time here.
- withHostTest { isIncludeAndroidResources = true; isReturnDefaultValues = true }.
  This one is load bearing: without it 93 of the 115 Robolectric Compose tests
  fail, because createComposeRule() has no merged resource table and no manifest
  holding the activity it launches.
- The JaCoCo isIncludeNoLocationClasses flag. Robolectric rewrites bytecode in its
  own classloader, so without it the agent records nothing for the Compose screens
  the tests drive. Coverage data for this module goes from 191 KB to 655 KB with
  it back. The jacoco plugin itself is applied to every project by the root build
  file, and jacocoAllDebugReport already knows how to read a multiplatform module,
  so the aggregate report keeps working.

androidx-compose-ui-tooling moves from debugImplementation to implementation: the
AGP multiplatform library target has no build types, so there is no debug only
configuration. R8 in the app module drops it from a release build.

Note for anyone verifying locally: testFullDebugUnitTest does not exist for a
multiplatform module. Use testAndroidHostTest, as CircleCI already does.

Also in here, because the KMP move rewrote every path in this module and the two
cannot be separated into buildable commits: display formatting leaves
:core:objects.

TrendArrow.directionToIcon(), CobInfo.generateCOBString()/displayText() and the
TT display helpers now live in :core:ui, next to the strings and icons they use.
A domain module was reaching into the UI module to pick a picture for an enum and
to name a format string. TT.target() stays behind, because it is arithmetic.

This does not remove the :core:objects -> :core:ui dependency. TB.toStringFull and
EB.toStringFull call domain helpers used across plugins/aps, implementation and
pump/*, so moving them would need :core:ui -> :core:objects, which cycles while
ProfileSealed, BolusWizard and RunningModeGuard still name core.ui strings. Those
three have to be cleaned first.
Two halves. They cannot be split into buildable commits, because the source-set
sweep rewrote the path of nearly every file the first half edits.

## HTML

Only <b> and <br> appear anywhere - 36 and 4 across every English string
resource, nothing else - so a 60 line htmlToAnnotatedString() in commonMain
replaces AnnotatedString.fromHtml, which is Android only and was what kept the
dialogs out of commonMain. 11 unit tests cover it, including the cases the
platform parser gets wrong: an unknown tag and a bare < are kept rather than
swallowed, and an unbalanced <b> still terminates.

The rule applied to every other site: markup is built where the text is
generated, not parsed back out before it is shown.

- AutomationRuntime.executionLog is List<AnnotatedString>. It built
  "<b>${event.title}:</b>" per entry, so making the screen render plain text
  would have shown a literal <b> - the log panel is the one place formatting had
  to move to the generator rather than simply disappear.
- MaintenanceDialogs, NSClientComposeContent and InsulinManagementScreen build
  their bold headers with buildAnnotatedString and use the AnnotatedString
  overload the dialogs already had.
- AppRepository.cleanupDatabase joins with a newline instead of <br>.
- NSDeviceStatusHandler wrote "<b>key:</b> value<br>" and the overview stripped
  every tag straight back out before displaying it. It now writes plain lines.

Nothing AAPS uploads contains markup - the Nightscout pump.extended object is
built from versions, dates and rates - so no outbound formatting was lost.
Inbound is different: pump.extended read FROM Nightscout is written by whatever
uploaded it, so the flattening the overview used to do is kept, moved to the
point the foreign data arrives.

Deleted as dead: HtmlHelper (its only callers were two Insight sites, and the
Insight alert strings contain no markup at all), EventTidepoolStatus
.toPreparedHtml, and PumpEnactResult.toHtml with its tests - no production caller
for any of them. Also an unclosed <b> in an Omnipod Dash notification, which was
rendering literally because notification text is drawn by a plain Text.

## commonMain

166 -> 348 of 442 files.

- stringResource(TextRef) is expect/actual. The Android actual is the previous
  code unchanged; iOS gets a documented placeholder, because expect needs an
  actual on every target and that is what keeps the iOS compile honest. Every
  screen funnels through this, so nothing else could move until it did.
- 174 @Preview files use org.jetbrains.compose ... .Preview. It accepts
  showBackground, name and widthDp, which is every parameter used here - verified
  by compiling for iOS, with a bogus parameter as a negative control. 148 moved;
  the other 26 preview components that are still Android bound.
- 49 dead `import app.aaps.core.ui.R` lines. Files already converted to UiStrings
  kept the import. Invisible in an Android module, a hard error in commonMain.
- SearchableItem used javaClass.simpleName; pluginId is the documented stable
  identity and defaults to exactly that string. No plugin overrides it.
- includeFontPadding is an Android text-layout quirk, now a one line expect.

24 root blockers remain, 44 more files wait only on those. Six need
ResourceHelper narrowed to TextResolver, two carry @stringres, two still call the
platform stringResource; the rest are Activity, Context, Locale, permissions or
java.math.BigDecimal and want expect/actual rather than migration.
426 of 444 files, up from 348. Three of the 18 left are .android.kt actuals, so
15 are real Android code.

## What moved, and why it was stuck

ProfileUtil was the cheapest win in the whole exercise: the interface imports
nothing but :core:data, so it was simply in the wrong source set. Moving it also
cleared the ProfileUtil error out of AapsTheme, PreferenceState and
TemporaryTargetDisplay.

AapsTheme looked like the hard one and was not. Three lines of 317 were Android:
the system-bar icon SideEffect, LocalConfiguration for tablet detection, and one
includeFontPadding. Two small expects - SystemBarAppearance and
smallestScreenWidthDp - and the theme itself moved, pulling 26 files with it
because most of the UI sits under it.

BackHandler needed no BOM bump. androidx compose-ui has no backhandler package at
any version we resolve (checked 1.10.0, 1.11.2, 1.11.4). It is a separate CMP
artifact, org.jetbrains.compose.ui:ui-backhandler, which ships its own Android
variant. It is still experimental, hence the OptIn at the two call sites.

Six files narrowed from ResourceHelper to TextResolver, four more carried dead
Android imports - @stringres on files already returning TextRef, and
androidx.compose.ui.res on files already passing one.

## Rounding

AdaptiveUnitDoublePreference and PreferenceState used
BigDecimal(v).setScale(n, HALF_UP). The multiplatform NumberFormat is half-even,
so switching to it would have quietly changed a displayed glucose value.

The two modes can only disagree on an exact tie. At one decimal - mmol/L - a tie
would have to be (2k+1)/20, and the factor of 5 means no Double ever lands there,
so they cannot disagree at all. At zero decimals - mg/dL - a tie is k + 0.5, which
IS exactly representable, and there half-even gives 4.5 -> 4. Half-even is
banker's rounding: it cancels bias when many values are summed, which is not what
a single number on a preference screen does.

So half-up stays, and NumberFormat learned the mode instead: a NumberRounding
enum, a rounding field defaulting to HALF_EVEN so nothing else changes, wired
through all three actuals (DecimalFormat.roundingMode, NSNumberFormatterRoundHalfUp,
and the mingw tie branch). SEPARATOR_DOT at the call sites, because toPlainString
was always dot-separated and the text is parsed back when edited.

## The regression the emulator caught

Naming the shared builders' strings with UiStrings broke them. ResourceHelper
lives in :core:interfaces and cannot see UiStringIds - :core:ui depends on it, not
the reverse - so a `ui` name read outside a Composable fell back to printing its
own name, and the overview showed "format_carbs" instead of "58 g".

The KDoc on keysIdOf had called this exactly: "If a non-Compose caller ever needs
a `ui` name, this is the place that has to learn about it - at that point a
registry is probably better than a third branch."

So: TextRefIdRegistry, consulted for any owner keysIdOf does not know directly,
with ResourceHelperImpl registering "ui" in its init - that class is downstream of
every string-owning module and is built before anything can ask it for text.
Verified on emulator: the row reads "58 g", and Treatments and Manage carry no
stray names either.

Four pump/source tests needed both gs overloads stubbed, not one swapped for the
other: the shared builders call gs(TextRef) while the plugins' own code still
calls gs(Int).

Not verified by hand: the rounding change. It only differs on exact .5 ties, which
is the behaviour deliberately preserved, but it is a glucose display.
Loop, ConfigBuilder, ConstraintsChecker, PluginConstraints, ProfileFunction,
TemporaryBasalStorage, CalculationSignals and CalculationWorkflow import nothing
from Android, Java, RxJava or Dagger. They were in androidMain only because that
is where the module started, so moving them is a no-op for every caller.

Found by listing androidMain files with no platform import at all - the same
check that turned up ProfileUtil. Five other candidates from that list did NOT
move and stay put: Maintenance, Prefs and PrefsFile need ExportResult and
PrefsMetadataKey, SmsCommunicator needs Sms, and MidnightTime uses synchronized.

233 commonMain / 29 androidMain.
:core:objects goes from 5 files needing :core:ui down to 3. The cycle that made
this awkward is gone: the formatting now lives in :core:ui, and :core:ui still
has zero references to :core:objects.

The blocker was never the formatting itself, it was three helpers it called -
TB.getPassedDurationToTimeInMinutes, EB.getPassedDurationToTimeInMinutes and
TB.durationInMinutes. They are used across plugins/aps, implementation and the
androidTest adapters, so they could not simply travel with the formatting, and
while they sat in :core:objects nothing in :core:ui could call them.

They read nothing but the record's own timestamp and duration, so they moved DOWN
to :core:data instead, next to the models. With them there, TB.toStringFull /
toStringShort and EB.toStringFull / toStringMedium moved up to :core:ui with
netExtendedRate, and take TextResolver rather than ResourceHelper.

durationInMinutes stays Long. T.mins() returns Long and AutotuneIob assigns it
straight to a val, so narrowing it to Int would have changed a caller's type.

TizenPluginTest needed the gs(TextRef, vararg) overload stubbed as well as
gs(TextRef). Mockito does not run default interface methods, so the vararg form
answered null, toStringFull produced null and the bundle key vanished - the test
failed on a missing key rather than on wrong text, which is a slow way to find out.

Still holding :core:objects to :core:ui: ProfileSealed, BolusWizard and
RunningModeGuard. Those are domain classes choosing display strings, so they need
the strings relocated or the text moved out - not a file move.
…m :core:ui

:core:objects no longer depends on :core:ui at all. That edge was backwards - a
domain module reaching into the UI module - and it is what blocked :core:objects,
and through it :core:graph and the 282-file :ui, from becoming multiplatform.

## The strings

ProfileSealed, BolusWizard and RunningModeGuard between them name 33 strings that
lived in :core:ui: the value+unit templates (format_carbs, format_insulin_units,
confirmation_line, mins), the profile validation messages
(value_out_of_hard_limits, basalprofilenotaligned, profile_*) and the wizard's
confirmation text.

They moved to :core:interfaces, which every one of these modules already depends
on and which already owns a strings.xml and an InterfacesStrings generator. No
name collided.

795 elements moved - 33 names across the 24 locales that actually had a
translation for them. The total element count across both modules is unchanged at
21000, and none of the 33 is left anywhere in :core:ui. The move was line-exact
rather than XML-rewritten, so the `comment=` translator notes and every byte of
the translated text survive untouched.

## The call sites

138 files. Four different shapes had to be handled, and only the first was
obvious:

- app.aaps.core.ui.R.string.X, fully qualified
- UiStrings.X, the generated TextRef form
- a bare R.string.X, from `import app.aaps.core.ui.R`
- an aliased CoreUiR/UiR/CoreR.string.X

For a file whose R import pointed only at moved strings the import was swapped.
Where a file mixes moved and unmoved names - TranslatorImpl uses 4 moved out of
205 - it gained `import app.aaps.core.interfaces.R as InterfacesR` and only the
moved references were rewritten.

## What this does not fix

The three domain classes still choose display strings; they just choose them from
a module below rather than above. Making BolusWizard return structured data
instead of built text is still the right change, and is now a refactor that can be
done on its own rather than a prerequisite for the module graph.
exportSharedPreferences(FragmentActivity) had no caller anywhere. Its only use
was ImportExportPrefsImpl's own private exportSharedPreferencesLegacy, which was
in turn the sole route into exportToBoth, exportToLocal, doExportToLocal,
exportToCloud and doExportToCloud - all FragmentActivity shaped, all unreachable.
234 lines.

exportSharedPreferencesNonInteractive is a different method and stays: Automation
calls it through ActionSettingsExport.

That FragmentActivity was the only Android type in the ImportExportPrefs
interface, so removing it took the whole maintenance package to commonMain:
ImportExportPrefs, Prefs, PrefsFile, PrefsMetadataKey and Maintenance.

One thing was left in the way. PrefsMetadataKey.formatForDisplay took a Context,
and used it for nothing but context.getString. Rather than swap Context for a
TextResolver parameter, it now returns a TextRef and the caller resolves it with
stringResource - the layer that draws decides the language, and no resolver has
to be threaded through the Composable.

:core:interfaces 238 commonMain / 24 androidMain.

Worth remembering: this is the second file in this migration that looked blocked
on an Android type and was really blocked on dead code - the 49 unused
`import app.aaps.core.ui.R` lines in :core:ui were the first. Check whether the
code runs at all before designing an expect/actual for it.
The whole module, charts included. Nothing left in androidMain but the manifest.

## Vico ships Apple targets

This was the open question and the answer is yes. com.patrykandpatrick.vico:compose
3.2.3 publishes iOS artifacts, so the charts are shared rather than reimplemented.
Verified by resolving it from a commonMain source set and compiling for iosArm64 -
the first probe even failed usefully, with "overload resolution ambiguity" on
rememberCartesianChart, which only happens once the symbol has been found.

Vico is the only third-party UI library in commonMain.

## What blocked the flip

A multiplatform module cannot consume a FLAVOURED Android library: :core:objects
carries the five product flavours from android-module-dependencies, so Gradle
could not pick a variant for a consumer that asks for none.

Only one file needed it - InsulinGraphCompose calls BS.iobCalc - and that function
reads nothing but :core:data types, because the work is done by
ICfg.iobCalcForTreatment which already lives there. So it moved down to
:core:data, the same fix as the duration helpers, and the dependency went away
instead of being worked around.

:core:objects keeps its own TB.iobCalc and EB.iobCalc; those need Profile and
EffectiveProfile. Files using both now import both, which Kotlin resolves by
receiver.

## Strings

:core:graph owns no strings, it borrowed them from :core:ui and :core:interfaces.
Converted to UiStrings / InterfacesStrings with the module's own
stringResource(TextRef), previews to the CMP annotation, ResourceHelper to
TextResolver.

One regex mangled a fully qualified app.aaps.core.interfaces.R.string.shorthour
into app.aaps.core.interfaces.UiStrings.shorthour, because the bare-R rule matched
inside the qualified name. The compiler caught it; a rule that rewrites `R.string`
has to run after the qualified forms, not before.
The blocker for :core:objects was never org.json, it was Dagger. Dagger emits
Java, and the AGP multiplatform library target has NO Java compilation step -
:core:objects:tasks --all lists no *JavaWithJavac task at all, and
build/classes/ holds only kotlin/. So KSP generated CoreModule's
hilt_aggregated_deps and CoreModule_SmsManagerFactory correctly, they were never
compiled, and the app failed with MissingBinding for SmsManager.

That is not fixable with a Gradle setting. The fix is to stop generating.

Five classes - CryptoUtil, RunningModeGuard, QuickWizard, QuickWizardEntry and
BolusWizard - lose @Inject/@singleton and become plain constructors. A new
CoreObjectsModule in :implementation constructs them with @provides, which keeps
the graph identical: same scopes, same instances. This is the trade already made
for BolusProgressData, and its comment says why - javax.inject cannot be used
from code meant to reach commonMain.

javax.inject.Provider is JVM only too, so QuickWizard and QuickWizardEntry take
`() -> T` instead. Dagger still supplies them from the Android side, as a lambda
over Provider.get(). Same pattern as Command.pumpEnactResultProvider.

CoreModule itself moved rather than converted: a @module inside a multiplatform
module would be generated and silently dropped. Its two @ContributesAndroidInjector
entries were dead - nothing member-injects BolusWizard or QuickWizardEntry - so
only the SmsManager @provides came across.

:core:objects now runs no annotation processor: the ksp plugin and all three
Dagger processors are gone from its build file, along with kotlin-parcelize, which
was applied to a module containing no @parcelize at all.

This does not flip the module. org.json still holds ~10 files and has to be dealt
with separately. What it removes is the reason the flip could not work.
:app is the only module that can never become multiplatform - it is the Android
application itself. Wiring put there never has to move again, whereas
:implementation carries no such guarantee.

It also lands among its peers: app/aaps/di/ already holds AppModule,
PluginsListModule and ReceiversModule, and :app already depends on :core:objects.
Hilt aggregates @Installin(SingletonComponent) modules in :app regardless of which
module declares them, so this changes where the source lives and nothing about the
graph.

Worth recording the counter-argument, because it may matter later: :implementation
is probably safe too. It is the ANDROID implementation of :core:interfaces - 42 of
its 116 files import android/androidx directly, including 20 of the 51 *Impl
classes - so iOS would get its own implementation module beside it rather than
sharing this one. But that is a judgement about the future and :app needs no
judgement.

Verified on a cold-booted emulator (-no-snapshot-load): clean start, no
MissingBinding, and the bolus wizard calculates 20 g -> 3.10 U at 10.5 mmol/L,
which exercises BolusWizard through the hand-written provider including the
correction path. No ANR on a quiet machine - the onStartJob ANRs seen earlier were
the build host at load 6.68, not the app.
:core:objects is now a Kotlin Multiplatform module. 17 of 29 files are in
commonMain. The DI lift to :app in the previous commit removed the Dagger
codegen that blocked the flip, and two more files moved once their org.json
boundary was split out:

- BlockExtension and InsulinExtension keep only the kotlinx logic and move to
  commonMain. Their org.json entry points go to new androidMain files
  BlockJsonAdapters and InsulinJsonAdapters. These were already written as thin
  adapters, so nothing else changed.
- ProfileSealed and RunningModeGuard ask for strings by InterfacesStrings
  instead of R.string, and RunningModeGuard takes the common TextResolver
  instead of ResourceHelper.
- System.currentTimeMillis becomes Clock.System, and javaClass.simpleName
  becomes ::class.simpleName.

Still Android only: CryptoUtil (javax.crypto), LoggingWorker (WorkManager), the
two json adapter files, and 8 files that need org.json.

Two Compose deprecations are also cleared:

- compose.components.uiToolingPreview is replaced by
  org.jetbrains.compose.ui:ui-tooling-preview. The new artifact declares the
  annotation under the androidx package name, so all 177 preview files share one
  import with the Android only modules.
- androidx.compose.ui.backhandler.BackHandler is replaced by
  NavigationBackHandler. Note the coordinate: androidx.navigationevent has no
  iOS targets, so this uses the JetBrains republish
  org.jetbrains.androidx.navigationevent:navigationevent-compose.

Two test stubs asked for strings by id while the code moved to TextRef.
SmsCommunicatorPluginTest was a real catch: the guard stopped rejecting and a
bolus went through where the test expected "Pump suspended".

Verified on emulator: profile viewer shows "7.7 g/U" and "8.2 mmol/L/U", the
constraint log keeps its "[Safety]" source tag, and 17 captured screens contain
no unresolved string keys.
The kotlinx halves of our JSON helpers were not equal to the org.json halves
they sit next to. org.json coerces on read and kotlinx throws, so a stored "36"
(a quoted number, which real Nightscout documents contain) read back as 36
through org.json and as the DEFAULT through kotlinx, with nothing logged.

New JsonLenientRead in :core:utils commonMain copies Android's JSON.toInteger /
toLong / toDouble / toBoolean / toString rules, and both sides now share it.

Fixed:

- ICfg.fromJsonObject read insulinEndTime with longOrNull, which answers null for
  1.8E7 and 18000000.5 and fell back to 0. insulinEndTime 0 means DIA 0, so IOB
  decays at once and the loop believes there is no insulin on board. It is
  reachable from InsulinImpl and from BatchActionMapping (client control sync).
- All twelve kotlinx JsonHelper twins had the same coercion gap. The two string
  overloads also had a dead guard: the JsonNull check was overwritten by the next
  line, and because JsonNull is a JsonPrimitive holding "null" they returned that
  text instead of the default.
- SceneSerializer used map with a throwing getString("id"), so ONE damaged scene
  reached the outer catch and returned an empty list, wiping the whole scene
  catalogue. Now the bad entry is skipped, like an unknown action type already
  was.
- QuickWizard.addOrUpdate passed a stale position to JSONArray.put(index, value),
  which pads the list with nulls and makes the readers throw at the next app
  start. remove() had no bounds check and still saved, pushing an unchanged list
  through the sync channel.
- IobTotal.json and determineBasalJson shared one swallowing try, so a NaN iob
  skipped every later put and returned {}, losing time as well.
- QuickWizardEntry left its lateinit storage unassigned on a parse failure, so it
  failed later as UninitializedPropertyAccessException far from the cause.

Removed as dead code:

- The IobTotal.copy() extension. IobTotal is a data class whose fields are all
  constructor parameters, so the generated member copy() wins at every call site
  and nothing imports the extension.
- QuickWizardEntry.usePercentage(), the DEFAULT/CUSTOM constants and the seeded
  "usePercentage": "default" template entry. Its only writer was deleted in
  ccf0fe3 (2023-10-14), so the DEFAULT branch has been unreachable for over
  two years on master as well. IntKey.OverviewBolusPercentage stays - wear still
  uses it.

Split so far: GlucoseValueExtension keeps its logic in commonMain as
toJsonObject, and toJson stays in androidMain as a reparse delegate. Going back
through the text is what keeps the bytes identical, because org.json renders a
whole numbered double as an integer and kotlinx does not.
:core:objects is now 25 files in commonMain and 8 in androidMain. org.json no
longer blocks anything: of the 8 left, 6 are the deliberate boundary adapters
that exist so the ~199 org.json files elsewhere keep compiling, and 2 are real
platform code (CryptoUtil, LoggingWorker).

Four independent files converted, each keeping its logic in commonMain on
kotlinx types and leaving a thin org.json delegate behind:

- IobTotalExtension: plus/round/combine are pure and moved as is; json and
  determineBasalJson became kotlinx builders. The NaN guard is restated for
  kotlinx, which has the opposite failure mode - org.json refuses a non finite
  double, kotlinx accepts it and writes the bare token NaN, which is not valid
  JSON and would turn an uploaded device status into null.
- JSONObjectExt: a clean split, its kotlinx twins already existed. Its commonMain
  store() had the same coercion bug as JsonHelper (strict raw.int / raw.double),
  so it now reads leniently too. Only tests call it today.
- SceneSerializer: its API is String based, so the whole file moved and no
  adapter was needed.
- ProfileSwitchExtension: the units rule is preserved exactly - absent or
  explicitly null falls back to defaultUnits and only a still missing value
  rejects the profile. That rejection matters because GlucoseUnit.fromText never
  throws, it answers MGDL, so a null slipping through would read an mmol/L
  profile as mg/dL and put every target, ISF and correction out by 18x.

QuickWizardEntry, QuickWizard and BolusWizard had to land together because they
depend on each other. The entry now holds plain data instead of a live
JSONObject, and all 23 accessors kept their names so every read call site is
unchanged. Removing the JSONObject removed the aliasing three things relied on:

- setGuidsForOldEntries assigned a guid and never saved. It only persisted
  because the entry WAS the element inside the stored array, so the guid rode
  along on some later unrelated save. If none happened, a different UUID was
  generated on every app start and nothing could resolve a legacy entry by guid
  across restarts. It saves now.
- markAsUsed writes back explicitly through addOrUpdate.
- QuickWizard parses into a list and skips an unreadable element, where a single
  damaged entry used to be a ClassCastException at construction, i.e. the app did
  not start.

The editor's 46 storage.put calls became three typed copy() blocks. Clone now
explicitly keeps its own guid and resets lastUsed instead of relying on which
keys the old code happened to copy.

Last blockers were small: kotlin.concurrent.Volatile, kotlin.uuid.Uuid, and in
BolusWizard the 22 R.string refs to InterfacesStrings plus ResourceHelper to
TextResolver and Calendar to Clock. The bolus calculator is common code now.

Verified on emulator: a created preset persists all 23 keys with the right
defaults (validTo 86340, useBG 0, useCOB 1, percentage 100), clone produces a
second entry with a DIFFERENT guid and lastUsed reset, and both survive a force
stop and restart.
InsulinJsonAdapters is deleted. ICfg.toJson and ICfg.Companion.fromJson had no
references left anywhere but their own declarations - ProfileSwitchExtension was
the last user and moved to fromJsonObject. Checked the neighbours rather than
assuming: determineBasalJson is used by IobTotalTest and IobTotal.json by
LoopPlugin, so IobTotalJsonAdapters stays.

pureProfileFromJson gains a String entry point in commonMain. Four callers held
the profile as text - out of the database or off the Nightscout wire - and built
an org.json document only to hand it straight over. That hop is gone, and two
Nightscout sync files no longer touch org.json at all.

New PureProfileFromJsonParityTest pins the three entry points (String, kotlinx
JsonObject, org.json adapter) against each other, so the remaining callers can be
moved without re-arguing that the profile they read is identical. It also pins
the rules a conversion is most likely to drop quietly:

- a profile without units is REJECTED. This is the one that matters:
  GlucoseUnit.fromText never throws, it answers MGDL, so if a missing unit
  stopped rejecting, an mmol/L profile would be read as mg/dL and every target,
  ISF and correction would be out by 18x.
- values quoted as strings are still read as numbers, which real Nightscout
  documents rely on.
- a missing schedule, and text that is not JSON at all, give an invalid profile
  rather than an exception.
- an unknown timezone falls back to UTC.

The org.json call sites inside core:objects' own tests are left alone on purpose
- they are the only coverage the adapter has.
pureProfileFromJson now has no production caller left on its org.json overload -
only tests use it, and they are the adapter's only coverage.

Build-then-parse removed. DefaultProfile and DefaultProfileDPV assembled a JSON
document only to parse it straight back into a PureProfile, so every value made a
round trip through text. They build the blocks directly now and neither file
contains a single org.json reference. This works because blockFromJson derived
each Block's DURATION from consecutive timeAsSeconds fields, so the helpers that
emitted {time, value, timeAsSeconds} arrays become functions returning
List<Block> with the same arithmetic. Three fields went with the document -
dia, carbs_hr and delay were written but never read back, because none is part of
a PureProfile.

ATProfile.data() and AutotunePlugin.saveLastRun had the same wasted hop:
toPureNsJson already answers a kotlinx document and they rendered it to text just
to reparse it as org.json. Both stay on kotlinx now. AutotunePlugin.loadLastRun
uses the lenient kotlinx readers, so a document written by an older build - where
a whole double was stored as a bare integer - still reads back the same.

ProfileStoreObject is kotlinx throughout. Its own comment said it was waiting for
JsonHelper and pureProfileFromJson to speak kotlinx; both do, so two text round
trips are gone - with() no longer re-serialises the incoming document and
getData() no longer re-parses it on every call.

Found by review of this change:

- ATProfile.data() lost a fail-safe. org.json refused NaN and Infinity outright,
  so a non finite tuned value aborted the document and the method answered null.
  kotlinx writes the bare token NaN and the lenient reader parses it back, which
  would put a NaN into a dosing profile. It now rejects the profile explicitly.
- ProfileStoreObject.getStore() stopped logging a malformed `store`. The old code
  threw out of getJSONObject and logged; answering null silently would hide a
  broken document.
- The getData KDoc still described the old copy-on-read behaviour, and the
  getDefaultProfileName comment stated the old optString rule wrongly - it
  answered the literal text "null" for an explicit null, not "".

Tests: new PureProfileFromJsonParityTest pins the three entry points against each
other, including that a profile without units is REJECTED - GlucoseUnit.fromText
never throws, it answers MGDL, so losing that rejection would read an mmol/L
profile as mg/dL. DefaultProfileTest gains block-boundary assertions, because the
existing midnight-only checks cannot see a duration error, and a case for age 0,
which the old `age > 18` branch never covered.

Verified on emulator: the profile store loads after a force stop and the viewer is
unchanged (24.48 U, Fiasp, IC 7.7 g/U, ISF 8.2 mmol/L/U), and the profile helper
recomputes. Autotune is disabled there with no stored last run, so loadLastRun has
no runtime coverage.
@sonarqubecloud

Copy link
Copy Markdown

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant