Skip to content

WIP: AAPS v4 - #4903

Draft
MilosKozak wants to merge 3616 commits into
masterfrom
dev
Draft

MilosKozak wants to merge 3616 commits into
masterfrom
dev

Conversation

@MilosKozak

Copy link
Copy Markdown
Contributor

No description provided.

@MilosKozak
MilosKozak marked this pull request as draft June 16, 2026 16:31
@codecov

codecov Bot commented Jun 18, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 66.51635% with 297 lines in your changes missing coverage. Please review.
✅ Project coverage is 60.68%. Comparing base (936ed3c) to head (c6da36f).
⚠️ Report is 2 commits behind head on master.

Files with missing lines Patch % Lines
app/src/main/kotlin/app/aaps/MainApp.kt 0.00% 77 Missing ⚠️
...in/kotlin/app/aaps/appshell/StartupFailedScreen.kt 0.00% 34 Missing ⚠️
...p/aaps/settingsstore/SettingsUnreadableActivity.kt 0.00% 32 Missing ⚠️
...ps/settingsstore/SettingsUnreadableNotification.kt 0.00% 19 Missing ⚠️
...kotlin/app/aaps/core/objects/wizard/BolusWizard.kt 20.00% 16 Missing ⚠️
...pp/src/main/kotlin/app/aaps/ComposeMainActivity.kt 38.88% 7 Missing and 4 partials ⚠️
...kotlin/app/aaps/appshell/navigation/AppNavGraph.kt 52.17% 11 Missing ⚠️
.../aaps/persistentNotification/DummyServiceHelper.kt 43.75% 8 Missing and 1 partial ⚠️
...tlin/app/aaps/settingsstore/KeystoreStoreCipher.kt 82.22% 6 Missing and 2 partials ⚠️
.../kotlin/app/aaps/core/nssdk/NSAndroidClientImpl.kt 76.66% 2 Missing and 5 partials ⚠️
... and 32 more
Additional details and impacted files
@@              Coverage Diff              @@
##             master    #4903       +/-   ##
=============================================
+ Coverage     34.49%   60.68%   +26.19%     
- Complexity     9670    23732    +14062     
=============================================
  Files          2416     3633     +1217     
  Lines        126587   198990    +72403     
  Branches      16412    25444     +9032     
=============================================
+ Hits          43661   120764    +77103     
+ Misses        79691    68957    -10734     
- Partials       3235     9269     +6034     
Flag Coverage Δ
instrumented 27.75% <32.80%> (?)
unit 54.48% <59.41%> (?)

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@MilosKozak MilosKozak changed the title AAPS v4 WIP: AAPS v4 Jul 23, 2026
@sonarqubecloud

sonarqubecloud Bot commented Sep 8, 2026

Copy link
Copy Markdown

❌ The last analysis has failed.

See analysis details on SonarQube Cloud

@MilosKozak
MilosKozak force-pushed the dev branch 2 times, most recently from 6b48aaa to f3e9c5d Compare September 16, 2026 13:14
…s-codec-commons-codec-1.23.0

Bump commons-codec:commons-codec from 1.22.1 to 1.23.0
…e, so the widget no longer goes empty

0c1a45c draws the widgets in the app process with compose(), on Dispatchers.Default. To make a
frame, Glance copies the widget tree while the composition may still be building it. With many
threads both ran at the same time: the first frame sometimes held a half built tree (and once the
copy threw a ConcurrentModificationException). compose() sent only that first frame and closed the
session, so the real frame was lost and the widget stayed empty until a later update. Changing the
opacity drew it again, which is why that brought it back.

The session now runs on Dispatchers.Default.limitedParallelism(1), like Glance's own worker runs it
on the main thread. Every frame goes to the launcher, and the session is closed only after one
second without a new frame. That also gives Glance time to save which root layout it gave to the
widget shape; when that save was lost, two shapes could get the same root layout.
…frame, without time labels and closer together (replaces #5293)
# Conflicts:
#	ui/src/commonMain/kotlin/app/aaps/ui/compose/overview/graphs/GraphsSection.kt
BG graph pop-up: press and hold for more data
# Conflicts:
#	pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/command/CarelevoActivationExecutor.kt
#	pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/command/CarelevoActivationExecutorTest.kt
…in, not refused

Since 58833e6 the phone wizard goes through WizardBolusExecutorImpl.prepareWizard(), which
refused a dose above the max bolus ("Insulin constraint violation! Cannot deliver X U"). That rule
came from the watch. In v3 the phone wizard capped the dose to the limit and showed "Bolus
constraint applied: X U to Y U".

prepareWizard now caps on every surface (phone, AAPSClient, watch): the confirmation shows the
capped dose and the warning. The capped dose is also the base the watch adds its +/- steps to, so
the total the watch shows is the dose that is given. QuickWizard still refuses: it is a one-tap
preset. When the limit leaves no insulin and there are no carbs, the wizard still refuses.
Overview: Add edit button to header of scenes bottom sheet
Two follow-ups to #5251.

Nothing to draw: zeroFloorNiceRange(0.0, 0.0) returns 0..1, because niceScale
widens a zero-width range by 1 so that it always has a usable tick step. The
scale was therefore positive for a window with no insulin on board at all, and
a flat line was drawn on the zero anchor - on the BG graph that is the low mark,
the bottom edge of the in-range belt, where it reads as a second target line.
Catch the all-zero case before the nice-ification and return a zero scale. A
window where activity is constant but not zero is a real curve and still draws.

Room for the IOB overlay: the windowed nice range of the primary layer moves
into its own value, primaryDataNiceRange, which no longer depends on
hasBasalLayer. The IOB+basal axis is built from it, and so is the activity
overlay, which needs the room the axis leaves on each side of zero whether or
not basal is drawn. Reading that room from the IOB+basal axis made the overlay
vanish with no other symptom whenever that axis was absent. In that case the
real axis is the zero-aligned dual-axis range, which always contains this nice
range, so the curve is sized a little conservatively and still sits in frame
with its zero on the axis zero.
…overview again, and the widget works (#5301)

A scene without a duration stores its temporary target and therapy event with
duration Long.MAX_VALUE until the scene ends. In Kotlin, timestamp + duration
then wraps to a negative number, so the entry looked like it ended long ago:

- the BG graph drew the profile target instead of the temp target
- the treatment belt drew no bar for the event
- the home screen widget and Android Auto failed on every update, because
  untilString made an infinite Duration and infinite minus infinite throws
- the temp target editor copied Long.MAX_VALUE as the duration, so activating
  from it started a target that never ends and no scene closes
- lists showed a wrong end time and a huge duration

Dosing was not affected: the database query does not overflow.

endOf() gives the end of an entry and stops at Long.MAX_VALUE instead of
wrapping. TT.end uses it, and TT.hasEnd / TE.hasEnd tell a scene entry without
an end. Text that shows a remaining time or a duration now leaves it out for
such an entry, like the overview chip already did. timeFrameString returns
"∞" for an infinite Duration instead of throwing.

Also removed remainingTimeMs from the temp target screen: it was never shown.
…r_anchor

Profile editor: the hour wheel opens over the time chip that was tapped
…so a 723 is no longer shown as suspended by a record dated 2060 (#1398)
…es forward (#449)

When the clock goes forward, the local hour from 02:00 to 03:00 does not exist.
The pump clock was read with Joda DateTime(y, m, d, h, m, s) in the phone time
zone, which throws for such a time. The reply then counted as not received,
the pump time was 0, readPumpStatus reset the pump and lastConnection became
0, so "pump unreachable" came at once, without the set limit.

- Pumps with local time (Dana RS): right after the change the pump clock still
  shows the missing hour. It is read as the next hour, which is the real time,
  the same rule the history already used (dateTimeSecFromBuff). One helper,
  DanaRSPacket.localTimeToMillis, for the pump clock and the history.
- Pumps with UTC (Dana-i): the clock is read as UTC, the way AAPS sets it. The
  old code read it as local time and added the offset of now. That threw when
  the UTC time fell in the local gap (04:00 to 05:00 in central Europe), was an
  hour off in the hour after the change, and was 30 minutes off in zones with
  half hours, because the offset was cut to whole hours.
- A date without a time (shipping date, daily total) starts at the first time
  that exists, for zones that change the clock at midnight.

Outside these cases the values are the same as before.
The prepare pass wrote endTime = toTime into the cached TimeRange and threw away
the horizon the post phase had put there. The IOB and activity series are built
in that same pass, so they stopped at the next full hour while the axis went on
to the newest prediction. Regressed in 426178f, which dropped the max() that
8903cdb had put there on purpose.

Two phases write endTime and only one of them knows the horizon, so give them one
rule: the post phase owns endTime, the prepare pass may widen it but never narrow
it. keptEndTime() carries that rule, with an upper guard for a clock or time zone
change that moves toTime back by hours.

The post phase now sets endTime to the newest prediction instead of the window
share clamped to two hours. The axis is already max(newest point, endTime), so
this widens nothing - it only makes endTime agree with the edge the series are
measured against. The two hour clamp stays where it belongs, deciding how much
history to fetch.

BGI does not change: bgiPrediction is gated on autosensData, which is null for any
future time, so it has always been empty.
… gets its own answer, so the fill step no longer times out, and CI waits longer for a slow shard
The method has had this guard since 36040b7 and no test covered it, although
something visible depends on it: the future part of the BGI series on the overview
is built only where this method answers, so it is empty. That is not obvious from
reading PrepareGraphDataRunner alone, see #5304.

The test asserts the past case as well, or a null that only came from "no bucketed
data at all" would look like a pass. It builds its times from the real clock,
because the method reads Clock.System.now() itself instead of the clock it is
given, so the test fixture time would never reach the guard.
MilosKozak and others added 30 commits October 26, 2026 20:03
The age was built in two forms, and the row showed the long one
("3 days 4 hours") wherever it fit. So the same row read differently on a
phone and on a tablet, and on a narrow screen the long words pushed the
row's own name out of its place. There is one form now, the short one, on
every screen size.

While there is no full day yet, the days part is left out: a sensor
inserted this morning reads "4h", not "0d4h". It has its own format
string, status_age_short_hours, so nothing is cut off a string in code.

LongOrShort was only used here and is removed with it.
"Bluetooth Low Energy not supported or device not paired" was shown where
the code already knew which of the two it was, and in one place where it
was neither.

Medtrum sent it whenever connect() was refused. That happens when a
Bluetooth permission is missing, which the transport already reports on
its own; when there is no Bluetooth adapter, which now says "Bluetooth
Low Energy not supported"; and when the service is already connecting,
which is normal and is now only a log line. Medtrum never pairs, it looks
for the serial number, so "device not paired" could not be true there at
all. The loop calls connect() over and over, so the third case was the
one users saw, turned into a notification when no screen was open.

Dana RS showed it in the error banner when the pump has an address but is
not bonded. That is only about pairing, and BLE support is not tested
there, so it now says "Please pair your pump with your phone!", the same
string the notification from BLEComm uses.

The combined string has no callers left and is removed.
The client's COB calculation takes the ISF for carbs from the master's
device status. The first calculation after a start can run before
Nightscout has delivered one, and then every bucket uses the profile
ISF: with dynamic ISF on the master that is about twice the value, so
carbs decay at half speed and COB stays too high until something else
resets the calculation. The device status handler now says once when
the master's value has arrived, and the calculation starts over on it.
The master's ISF for carbs is the average dynamic ISF of the 24 hours
before each bucket, so it moves through the day. The client used the
newest value for every bucket, and a full calculation after a restart
absorbed the early buckets slower than the master whenever the ISF had
risen since: COB 19 g where the master had 14 g. The client stores
every APS result it gets with a device status; the calculation now
reads the carbs ISF of those results, time and value only, and takes
the newest one at or before each bucket. Before the first stored
result the newest value still serves.
On top of the two commits before this one, for issue #5404.

The reset on EventMasterCarbsIsfArrived ran at every client start. The client
keeps that value only in memory, so it is always absent when the calculation at
start runs, and the event always came. But the calculation now reads the
master's ISF for carbs of each bucket's own time from the stored results, so a
client that has results stored is right from the first run and the reset only
calculated the same numbers again, about four seconds of a full recalculation
per start.

The calculation says instead whether it had to use the profile ISF, which
happens when the client has stored nothing for the calculated time - a new
install, or after a database reset. Only then does the arriving value change
anything, and only then does the calculation start over. The flag is only set,
never cleared by a later run, because a run that does find stored values does
not make the buckets already in the cache right; it is cleared with the cache.

Also:

- Before the first stored result the oldest stored one is used instead of the
  master's newest value. The master's value of a few hours ago is much nearer to
  what it had then, and the newest value is what made COB too high in the first
  place.
- A note in ProfileSealed.getIsfMgdlForCarbs that it answers a client with the
  newest value and ignores the timestamp, so the calculation must not ask there
  for a past bucket.
- The missing newline at the end of APSResultDao.kt.
The bar showed the length that was set and never changed it, while the
countdown notification showed the time from the start until the ring, which
did grow with every +5 min. The same timer showed two different numbers.

extend() now moves the end and the whole wait together, so both read the
same, and the text says "Total wait: 25 min" instead of "Set: 25 min":
after +5 min it is no longer the length that was set. The end itself does
not change: 5 minutes on top of the end while the timer runs, 5 minutes
from now once it has rung.

On the alarm screen "Mute" and "OK" did not say what they do. They are now
"Silence alarm" (the screen stays open) and "Silence and close". This is
shown for every alarm, not only the eat reminder.
The timer rang with AlarmSound.ALARM, the same sound as a BG alarm, so a
reminder to eat sounded like something was wrong. It now has its own
sound: a small steel dome struck twice, synthesized rather than sampled,
so no licence is attached to it.

The file sits in :core:ui/res/raw with the other four. The Xcode build
script copies them into the iOS bundle by name, so it and the bundled
count in ShellInfo are updated too - without that the sound would simply
be missing on iOS and the alarm would stay silent there.
…he wizard

The timer the phone starts after a bolus never reached the watch: nothing was
sent, and its notifications are ongoing ones, which Wear OS does not bridge.

Now the phone sends the timer's start and end with every status, also from a
client's copy. The watch draws a dashed line at the end in its BG graph, on
the graph screen and the tile, with the minutes left at it. The watch sets its
own alarm at that time and rings by itself, so it rings with the phone out of
reach too: a vibrating notification with Silence and +5 min, and a screen with
the same two buttons over it. Both go to the phone, which stops or moves the
timer; a client relays to its master. The next status closes the screen.

The watch wizard gets a carb time page behind a new watch setting, off by
default, next to the meal type one: minutes until the carbs are eaten, with a
"Set alarm" bell that is on by default once the time is positive. The phone
starts the timer after the bolus and moves the meal type's eCarbs with the
meal, as its own wizard does.

The custom watchface graph and the V4 face do not show the timer yet.
…r a save, basal rows named for screen readers (#5401, #5402)
…ation

Ending a scene asked for two confirmations: the banner, and a notification
card next to it saying the same thing. Only the banner cleared both, so
anyone who reached for the card first - the natural thing when it sits in
the Android shade - was left with the banner still waiting.

A plain end and a follow-up that started now post nothing at all: the
banner of the ended scene, and the banner of the scene that started, say
it already. A follow-up that did NOT start is the one case with something
to add, so its reason goes on the same banner ("Follow-up did not start:
target scene was deleted") instead of into a card of its own. Only a chain
that partly failed still posts, because there the new scene's banner looks
healthy while it is half applied.

The reason rides on the active scene state, so it survives a restart and
reaches a client with the running configuration, as the rest of the scene
state does. Both places that posted the success card are covered: the
expiry runner and stopActiveSceneAndChain, which the End-Scene dialog and
a client's scene.stop(triggerChain) share.

The three ids that nothing posts any more stay in NotificationId: their
ordinals are the live system-notification ids, so removing them would
renumber every id after them.
…ge fits

The ring screen is gone: Android does not start an activity from an alarm
in the background, so it only showed up behind the notification's "Open app".
The vibrating notification with Silence and +5 min is the ring now.

The carb time page says "Carb time [min]" on two lines and keeps the bell row
on one line, so the - and + buttons sit still from "Now" to "+60".

The face's graph complication writes the minutes left at the timer's line too.
Between the start and the ring the watch only showed the line in the graph,
so the timer could not be stopped from the watch before it rang. While it
runs, a silent notification now sits in the stream: "Time to eat, Rings at
19:25", with Stop and +5 min. It follows every new end time, goes away when
the timer ends, and times out by itself at the ring, when the ring's own
notification takes over.
…ed elsewhere

Silence from the watch, or Stop from a client or from the notification,
ended the timer and muted the sound, but the alarm screen on the phone stayed
open with nothing left to offer. It now closes when the timer is gone. The
phone's own "Silence alarm" keeps it open, as for every alarm.

Also on the watch wizard's request page: the carb time and the percentage
share one line when both are there, since a third line under the check fell
off the screen. And a note for translators that the carb time label must
stay short.
…tion and silences the ring

Wear OS lets the user swipe an ongoing notification away. For the running timer that notification is
the only Stop and +5 min on the watch, so it comes back at once: its delete intent posts it again while
the timer runs. Our own cancel does not fire the delete intent, so Stop, the ring and a timer ended on
the phone still clear it.

A swipe on the ringing notification silences the timer, as on a clock's alarm. Before, the phone kept
ringing with nothing left on the watch to stop it.

On the phone, the alarm screen now also closes when the timer is moved +5 min from the watch or a
client, not only when it is stopped there. The alarm has rung when that screen opens, so a timer that
has not rung is one that was extended.
A watch vibrates once per notification, while the phone rings until it is answered. So the ring is
posted again every 30 s until 5 minutes after the end, with the same alarm intent: Silence, +5 min and
a swipe on the watch, and a timer stopped or moved on the phone, cancel or replace that alarm and the
repeats with it. The window for a valid alarm grows from 2 minutes around the end to 2 minutes before
and 7 minutes after, so the repeats pass and an old timer is still ignored.
…t left behind

Adding a client sometimes made two Nightscout tokens with the same name,
and the second one was the only one the client got.

Nightscout answers the create without the token: it builds the token from
the subject id while it reloads its own list, and GET
/authorization/subjects then serves that list from memory, never from the
database. The list is reloaded at start and by a create, a change or a
delete that the same Nightscout process handled - there is no timer. So
the create can come back fine while the read right after it knows nothing
about the new token, and the list stays that way until the next write. In
the log from the report the create was answered 200, the read 12 ms later
did not list it, the check two minutes later still did not, and the second
try then made the second token.

Reading again cannot mend that, because nothing refreshes the list without
a write. Writing again does, so a new token that is not listed is now
deleted again and made once more: at most two tries, each a create, up to
three short reads, and a delete when it was never listed. The delete is
the part that matters - without it the first token stays behind, invisible
to the next try, which is how the two tokens of the same name appeared.

If even the delete fails the token really is in Nightscout and only the
user can clear it, so the dialog names it instead of showing a QR code for
a token the client would not be able to use. The plain failure text says
that nothing was left behind, because now that is true. Every branch logs
what it did, so the next report of this says which one was taken.

Worth fixing in Nightscout too, later: return the token from the create,
or serve the subject list from the database. Old Nightscout versions stay
as they are, so AAPS has to deal with this either way.
…ugh the system, Android 12 follows the phone
…art, and one warning per batch instead of a stack trace per document (#5414)
…newest calculation wins, and no bell for a past carb time (#5415)
…client loses nothing, and a client shows the master's answer once (#5416)
…raphs below BG fill once the time range is known (#5418)

This branch has not been deployed

No deployments
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.

6 participants