Skip to content

iOS improvements and bug fixes - #1038

Merged
davecraig merged 7 commits into
Scottish-Tech-Army:mainfrom
davecraig:main
Aug 25, 2026
Merged

iOS improvements and bug fixes#1038
davecraig merged 7 commits into
Scottish-Tech-Army:mainfrom
davecraig:main

Conversation

@davecraig

Copy link
Copy Markdown
Contributor

No description provided.

davecraig and others added 7 commits August 25, 2026 10:37
Two callers were reaching for the same AVAudioSession at launch:
SplashCoordinator.playSplashAudio() hard-coded .playback + .mixWithOthers
and setActive(true), while IosAudioEngine.ensureEngineStarted() ran its
own setCategory/setActive with options driven by the user's MIX_AUDIO
preference (default false). Whichever landed second, the loser's
AVAudioPlayer could stop delivering audioPlayerDidFinishPlaying. Because
SplashCoordinator only dismisses when both didFinishAudio and the 1.5s
timer have fired, a missed callback wedged the splash indefinitely; iOS
watchdog-killed the app ~20% of launches and reported it as a crash on
the next launch.

Make IosAudioEngine the single owner of the session: expose
configureAudioSession() and have the Swift splash call it before playing,
then stop deactivating the session on dismiss (the engine is about to
use it). Both paths now use identical category/options and there is one
setCategory/setActive call site.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
installUnhandledExceptionLogger() was called from inside the Compose
entry point (MainViewController), which meant Kotlin/Native had no
setUnhandledExceptionHook installed during LegacyMigrator, FirebaseBootstrap,
and IosSoundscapeService construction. Any coroutine failure in that window
terminated the process with nothing written to stderr.

Make the installer public and move the call to iOSApp.init() as the first
line, so the hook is active before any Swift-invoked Kotlin code runs.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Opening the search popup raises the IME, which used to hide the top and
bottom bars on the home screen. With no top bar, the collapsed search
bar shifted up under the transparent iOS status bar and stayed visible
there (the popup does not paint over the status-bar strip). Dismissing
the popup produced the reverse glitch: `expanded` cleared immediately,
but the IME took its own animation to close, briefly hiding the bars
again.

Hold a `searchOwnsIme` flag true from the moment the popup opens the IME
until the IME has fully closed. Keep the top/bottom bars in place for
the whole search session, so the underlying layout never moves and
nothing bleeds behind the status bar.

Once the top bar stays visible on Android too, the search Popup would
open below it (Popup defaults to its anchor position). Pin the Popup
with a PopupPositionProvider that always returns (0, 0) so it covers
the whole window on both platforms.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The home-screen callout buttons (my location, what's around me, ahead
of me, nearby markers) queued audio on iOS instead of cancelling
in-flight callouts the way Android does. Port Android's startCallout
pattern - a coroutine job that cancels its predecessor and clears the
audio queue before speaking - and match Android's ClearQueue semantics
in IosAudioEngine so earcons queued behind an interrupted callout are
dropped instead of playing out. Run the callout coroutine on
Dispatchers.Main so its AVAudioEngine mutations don't race with the
TTS render callback, and guard the render callback so a late-arriving
render for a cancelled sound doesn't attach a zombie node.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The four home-screen callout methods were passing addModeEarcon=false
to speakCalloutCommon, so iOS never played the enter/exit beeps that
frame each callout on Android. myLocation now plays the enter earcon
immediately (feedback while the reverse geocoder runs) and the exit
earcon after the callout; whatsAroundMe/aheadOfMe/nearbyMarkers now
pass addModeEarcon=true. Callouts with no positioned strings or a
null result skip the earcons so a nothing-to-say press stays silent
instead of just beeping.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Tapping the search bar for the first time while a callout was speaking
caused TTS to crackle and gap out, because iOS's first soft-keyboard
instantiation stalls the main thread for hundreds of milliseconds and
the audio pipeline (TTS render completion, DiscretePlayer completion,
startCallout coroutine) was hopping through the main queue between
utterances. Introduce a serial GCD queue owned by IosAudioEngine and
expose it as a CoroutineDispatcher, then route the two render-callback
dispatches and startCallout onto that queue instead of Dispatchers.Main.
AVAudioEngine mutations stay serialized (so the `[_nodes containsObject:
node]` crash the previous commits fixed doesn't return), and callout
audio is now decoupled from any main-thread stall.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Legacy iOS animated whichever "hear my surroundings" button was speaking
and stopped animating when the callout finished or was cancelled. Port
that: MediaControllableService gains an activeCalloutFlow<TourButton?>,
each service's startCallout publishes/clears the source around its body
(with a compareAndSet in finally so a superseding callout keeps its
value), HomeViewModel folds the flow into HomeState, and the home
bottom bar flips the active button to the theme primary colour with a
pulsing icon scale until the flow returns to null.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@davecraig
davecraig merged commit 0790ee3 into Scottish-Tech-Army:main Aug 25, 2026
2 checks passed
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