From 201f3efad5e1da40bf995b7bd9cce2e71ab299e3 Mon Sep 17 00:00:00 2001 From: Dave Craig Date: Mon, 24 Aug 2026 16:07:48 +0100 Subject: [PATCH 1/4] Fix iOS Release build crashes exposed by Firebase Three issues that all surfaced when Firebase Analytics + Crashlytics started running on iOS: - compose-preference's iOS backend assumed exclusive ownership of the app's NSUserDefaults persistent domain, throwing IllegalArgumentException on Settings composition when Firebase Crashlytics's cached remote settings (nested NSDictionary) sat in the same domain, and wiping that cache on every user preference change via setPersistentDomain. Introduce a Soundscape-scoped preferences flow (expect/actual: Android delegates to the library default; iOS filters foreign value types on read and merges them back on write) and pass it into ProvidePreferenceLocals. - UnhandledExceptionLogger's NSLog("%@", ...) crashed on objc_opt_respondsToSelector because Kotlin/Native's NSLog vararg binding cannot reliably bridge Kotlin String to NSString for %@ formatters, turning every uncaught coroutine exception into an app termination. Switch to println so exceptions actually surface in Console.app. - Crashlytics dSYM upload script assumed the standard DerivedData layout where BUILD_DIR sits under DerivedData. Add a `find` fallback for custom Xcode build locations, skip the phase entirely on Debug (Firebase is gated off there), and move DEBUG_INFORMATION_FORMAT: dwarf-with-dsym to Release-only so Debug builds are not slowed generating unused dSYMs. Co-Authored-By: Claude Opus 4.7 (1M context) --- iosApp/project.yml | 26 ++++- .../SoundscapePreferenceFlow.android.kt | 10 ++ .../home/settings/SharedSettingsScreen.kt | 2 +- .../home/settings/SoundscapePreferenceFlow.kt | 23 ++++ .../accessibility/AccessibilityScreen.kt | 3 +- .../soundscape/UnhandledExceptionLogger.kt | 11 +- .../settings/SoundscapePreferenceFlow.ios.kt | 108 ++++++++++++++++++ 7 files changed, 174 insertions(+), 9 deletions(-) create mode 100644 shared/src/androidMain/kotlin/org/scottishtecharmy/soundscape/screens/home/settings/SoundscapePreferenceFlow.android.kt create mode 100644 shared/src/commonMain/kotlin/org/scottishtecharmy/soundscape/screens/home/settings/SoundscapePreferenceFlow.kt create mode 100644 shared/src/iosMain/kotlin/org/scottishtecharmy/soundscape/screens/home/settings/SoundscapePreferenceFlow.ios.kt diff --git a/iosApp/project.yml b/iosApp/project.yml index 9f6358bcc..fe513d342 100644 --- a/iosApp/project.yml +++ b/iosApp/project.yml @@ -144,8 +144,6 @@ targets: - "-framework" - "Shared" CODE_SIGN_ENTITLEMENTS: iosApp/iosApp.entitlements - # Full DWARF with dSYM so Crashlytics can symbolicate crash reports. - DEBUG_INFORMATION_FORMAT: dwarf-with-dsym configs: # CI archives with manual signing so xcodebuild uses the # distribution cert + profile imported by setup-ios-signing rather @@ -159,6 +157,10 @@ targets: CODE_SIGN_STYLE: Manual CODE_SIGN_IDENTITY: "Apple Distribution" PROVISIONING_PROFILE_SPECIFIER: "$(PROFILE_NAME_APP)" + # dSYMs are only needed for Crashlytics symbolication on Release. + # Firebase is gated off on Debug, so producing dSYMs there would + # just slow the build. + DEBUG_INFORMATION_FORMAT: dwarf-with-dsym preBuildScripts: - name: "Build Kotlin Framework" script: | @@ -168,11 +170,25 @@ targets: postBuildScripts: # Crashlytics needs dSYMs uploaded so crash reports symbolicate. The # Firebase SDK ships a `run` script inside its SPM checkout that handles - # this. Uses BUILD_DIR to locate the SourcePackages checkouts directory - # (SPM unpacks packages under DerivedData/…/SourcePackages/checkouts). + # this. Skipped on Debug because Firebase is gated off there anyway + # (FirebaseAnalyticsBridge.swift). The standard path formula assumes + # BUILD_DIR sits under DerivedData; falls back to a `find` under the + # module cache's parent for custom Xcode build locations. - name: "Crashlytics: upload dSYMs" script: | - "${BUILD_DIR%/Build/*}/SourcePackages/checkouts/firebase-ios-sdk/Crashlytics/run" + if [ "$CONFIGURATION" = "Debug" ]; then + exit 0 + fi + RUN_SCRIPT="${BUILD_DIR%/Build/*}/SourcePackages/checkouts/firebase-ios-sdk/Crashlytics/run" + if [ ! -x "$RUN_SCRIPT" ]; then + DERIVED_ROOT=$(dirname "$MODULE_CACHE_DIR") + RUN_SCRIPT=$(find "$DERIVED_ROOT" -maxdepth 6 -type f -path "*/firebase-ios-sdk/Crashlytics/run" -print -quit) + fi + if [ -z "$RUN_SCRIPT" ] || [ ! -x "$RUN_SCRIPT" ]; then + echo "error: could not locate firebase-ios-sdk Crashlytics/run script" + exit 1 + fi + "$RUN_SCRIPT" inputFiles: - ${DWARF_DSYM_FOLDER_PATH}/${DWARF_DSYM_FILE_NAME}/Contents/Resources/DWARF/${TARGET_NAME} - $(SRCROOT)/iosApp/GoogleService-Info.plist diff --git a/shared/src/androidMain/kotlin/org/scottishtecharmy/soundscape/screens/home/settings/SoundscapePreferenceFlow.android.kt b/shared/src/androidMain/kotlin/org/scottishtecharmy/soundscape/screens/home/settings/SoundscapePreferenceFlow.android.kt new file mode 100644 index 000000000..af937a809 --- /dev/null +++ b/shared/src/androidMain/kotlin/org/scottishtecharmy/soundscape/screens/home/settings/SoundscapePreferenceFlow.android.kt @@ -0,0 +1,10 @@ +package org.scottishtecharmy.soundscape.screens.home.settings + +import androidx.compose.runtime.Composable +import kotlinx.coroutines.flow.MutableStateFlow +import me.zhanghai.compose.preference.Preferences +import me.zhanghai.compose.preference.createDefaultPreferenceFlow + +@Composable +internal actual fun rememberSoundscapePreferenceFlow(): MutableStateFlow = + createDefaultPreferenceFlow() diff --git a/shared/src/commonMain/kotlin/org/scottishtecharmy/soundscape/screens/home/settings/SharedSettingsScreen.kt b/shared/src/commonMain/kotlin/org/scottishtecharmy/soundscape/screens/home/settings/SharedSettingsScreen.kt index f42105e59..061e1d682 100644 --- a/shared/src/commonMain/kotlin/org/scottishtecharmy/soundscape/screens/home/settings/SharedSettingsScreen.kt +++ b/shared/src/commonMain/kotlin/org/scottishtecharmy/soundscape/screens/home/settings/SharedSettingsScreen.kt @@ -213,7 +213,7 @@ fun SharedSettingsScreen( ) val geocoderValues = listOf("Auto", "Offline") - ProvidePreferenceLocals { + ProvidePreferenceLocals(flow = rememberSoundscapePreferenceFlow()) { // Track allowCallouts reactively for enabling/disabling child settings val allowCallouts by rememberPreferenceState( PreferenceKeys.ALLOW_CALLOUTS, diff --git a/shared/src/commonMain/kotlin/org/scottishtecharmy/soundscape/screens/home/settings/SoundscapePreferenceFlow.kt b/shared/src/commonMain/kotlin/org/scottishtecharmy/soundscape/screens/home/settings/SoundscapePreferenceFlow.kt new file mode 100644 index 000000000..bae93e585 --- /dev/null +++ b/shared/src/commonMain/kotlin/org/scottishtecharmy/soundscape/screens/home/settings/SoundscapePreferenceFlow.kt @@ -0,0 +1,23 @@ +package org.scottishtecharmy.soundscape.screens.home.settings + +import androidx.compose.runtime.Composable +import kotlinx.coroutines.flow.MutableStateFlow +import me.zhanghai.compose.preference.Preferences + +/** + * Preferences flow for [me.zhanghai.compose.preference.ProvidePreferenceLocals]. + * + * On Android the default `createDefaultPreferenceFlow()` is fine — it uses + * an app-scoped SharedPreferences file that other SDKs do not touch. + * + * On iOS the library's default assumes exclusive ownership of the app's + * NSUserDefaults persistent domain. Firebase Crashlytics writes its cached + * remote settings (a nested NSDictionary) into that same domain, which + * makes the library's read path throw `IllegalArgumentException` on the + * next Settings composition and its write path silently wipe Firebase's + * cache on every user preference change. The iOS actual replaces both + * sides with a version that skips foreign value types on read and merges + * them back on write. + */ +@Composable +internal expect fun rememberSoundscapePreferenceFlow(): MutableStateFlow diff --git a/shared/src/commonMain/kotlin/org/scottishtecharmy/soundscape/screens/onboarding/accessibility/AccessibilityScreen.kt b/shared/src/commonMain/kotlin/org/scottishtecharmy/soundscape/screens/onboarding/accessibility/AccessibilityScreen.kt index 00f99ca1e..a39fc07f2 100644 --- a/shared/src/commonMain/kotlin/org/scottishtecharmy/soundscape/screens/onboarding/accessibility/AccessibilityScreen.kt +++ b/shared/src/commonMain/kotlin/org/scottishtecharmy/soundscape/screens/onboarding/accessibility/AccessibilityScreen.kt @@ -36,6 +36,7 @@ import org.scottishtecharmy.soundscape.resources.accessibility_screen_reader_ena import org.scottishtecharmy.soundscape.resources.accessibility_title import org.scottishtecharmy.soundscape.resources.settings_show_map import org.scottishtecharmy.soundscape.resources.ui_continue +import org.scottishtecharmy.soundscape.screens.home.settings.rememberSoundscapePreferenceFlow import org.scottishtecharmy.soundscape.screens.onboarding.component.BoxWithGradientBackground import org.scottishtecharmy.soundscape.ui.theme.smallPadding import org.scottishtecharmy.soundscape.ui.theme.spacing @@ -103,7 +104,7 @@ fun AccessibilityOnboardingScreen( ) Spacer(modifier = Modifier.height(spacing.large)) - ProvidePreferenceLocals { + ProvidePreferenceLocals(flow = rememberSoundscapePreferenceFlow()) { SwitchPreference( state = showMap, title = { diff --git a/shared/src/iosMain/kotlin/org/scottishtecharmy/soundscape/UnhandledExceptionLogger.kt b/shared/src/iosMain/kotlin/org/scottishtecharmy/soundscape/UnhandledExceptionLogger.kt index db7750f13..8fb1a0da8 100644 --- a/shared/src/iosMain/kotlin/org/scottishtecharmy/soundscape/UnhandledExceptionLogger.kt +++ b/shared/src/iosMain/kotlin/org/scottishtecharmy/soundscape/UnhandledExceptionLogger.kt @@ -2,12 +2,19 @@ package org.scottishtecharmy.soundscape import kotlin.experimental.ExperimentalNativeApi import kotlin.native.setUnhandledExceptionHook -import platform.Foundation.NSLog private val install: Boolean by lazy { @OptIn(ExperimentalNativeApi::class) setUnhandledExceptionHook { throwable -> - NSLog("Soundscape uncaught: %@\n%@", throwable.toString(), throwable.stackTraceToString()) + // Use println (stderr) rather than NSLog. Kotlin/Native's NSLog + // binding takes vararg Any? and cannot reliably bridge Kotlin + // strings to NSString* for %@ formatters — the ObjC format machinery + // then calls objc_opt_respondsToSelector on a bad pointer and + // crashes the app before the exception ever surfaces. println + // writes to stderr, which iOS captures in Console.app / device + // logs just as visibly. + println("Soundscape uncaught: $throwable") + println(throwable.stackTraceToString()) } true } diff --git a/shared/src/iosMain/kotlin/org/scottishtecharmy/soundscape/screens/home/settings/SoundscapePreferenceFlow.ios.kt b/shared/src/iosMain/kotlin/org/scottishtecharmy/soundscape/screens/home/settings/SoundscapePreferenceFlow.ios.kt new file mode 100644 index 000000000..04c693c24 --- /dev/null +++ b/shared/src/iosMain/kotlin/org/scottishtecharmy/soundscape/screens/home/settings/SoundscapePreferenceFlow.ios.kt @@ -0,0 +1,108 @@ +package org.scottishtecharmy.soundscape.screens.home.settings + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import kotlinx.cinterop.ExperimentalForeignApi +import kotlinx.cinterop.toKString +import kotlinx.coroutines.DelicateCoroutinesApi +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.drop +import kotlinx.coroutines.launch +import me.zhanghai.compose.preference.MapPreferences +import me.zhanghai.compose.preference.Preferences +import platform.Foundation.NSArray +import platform.Foundation.NSBundle +import platform.Foundation.NSNumber +import platform.Foundation.NSString +import platform.Foundation.NSUserDefaults + +@Composable +internal actual fun rememberSoundscapePreferenceFlow(): MutableStateFlow = + remember { createSoundscapePreferenceFlow(NSUserDefaults.standardUserDefaults) } + +private fun createSoundscapePreferenceFlow( + userDefaults: NSUserDefaults, +): MutableStateFlow { + val flow = MutableStateFlow(userDefaults.readSoundscapePreferences()) + @OptIn(DelicateCoroutinesApi::class) + GlobalScope.launch(Dispatchers.Main.immediate) { + flow.drop(1).collect { userDefaults.writeSoundscapePreferences(it) } + } + return flow +} + +private fun NSUserDefaults.readSoundscapePreferences(): Preferences { + val bundleId = NSBundle.mainBundle.bundleIdentifier ?: return MapPreferences(emptyMap()) + @Suppress("UNCHECKED_CAST") + val dictionary = + (persistentDomainForName(bundleId) as? Map) + ?: return MapPreferences(emptyMap()) + return MapPreferences( + buildMap { + for ((key, value) in dictionary) { + val converted = value.toPreferenceValueOrNull() ?: continue + put(key, converted) + } + }, + ) +} + +private fun NSUserDefaults.writeSoundscapePreferences(preferences: Preferences) { + val bundleId = NSBundle.mainBundle.bundleIdentifier ?: return + // setPersistentDomain replaces the entire domain, so re-read it now and + // keep any foreign keys (Firebase Crashlytics's cached remote settings + // dictionary, etc.) that we cannot represent in a Preferences map. + @Suppress("UNCHECKED_CAST") + val foreign = + ((persistentDomainForName(bundleId) as? Map).orEmpty()) + .filterValues { it.toPreferenceValueOrNull() == null } + val converted = + preferences.asMap().mapValues { (_, mapValue) -> + @Suppress("CAST_NEVER_SUCCEEDS") + when (mapValue) { + is Boolean -> mapValue as NSNumber + is Int -> mapValue as NSNumber + is Float -> mapValue as NSNumber + is String -> mapValue as NSString + is Set<*> -> + @Suppress("UNCHECKED_CAST") + (mapValue as Set).map { it as NSString } as NSArray + else -> throw IllegalArgumentException("Unsupported type for value $mapValue") + } + } + setPersistentDomain(converted + foreign, bundleId) +} + +// Values stored in NSUserDefaults by third-party SDKs (nested NSDictionary +// from Firebase Crashlytics's settings cache, NSNumber with an objCType +// the compose-preference API does not model, arrays of non-string +// elements, etc.) are skipped by returning null. The read path drops +// them from the exposed Preferences; the write path preserves them so +// the SDK's cache is not lost. +private fun Any.toPreferenceValueOrNull(): Any? { + @Suppress("CAST_NEVER_SUCCEEDS") + return when (this) { + is NSNumber -> + @OptIn(ExperimentalForeignApi::class) + when (objCType?.toKString()) { + "c", "C", "B" -> boolValue + "i", "I", "s", "S", "l", "L", "q", "Q" -> intValue + "f", "d" -> floatValue + else -> null + } + is NSString -> this as String + is NSArray -> { + @Suppress("UNCHECKED_CAST") + val list = this as List + if (list.all { it is NSString }) { + @Suppress("UNCHECKED_CAST") + (list as List).mapTo(mutableSetOf()) { it as String } + } else { + null + } + } + else -> null + } +} From 25adb5ffb0f65c02a2635db81ce0e5cea241c253 Mon Sep 17 00:00:00 2001 From: Dave Craig Date: Mon, 24 Aug 2026 16:15:12 +0100 Subject: [PATCH 2/4] Hide "Exit Soundscape" menu item on iOS MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Apple's HIG forbids apps from quitting themselves, and iOS never wired an onExitApp callback — so the drawer item silently did nothing when tapped. Make the callback nullable and skip rendering the item when it is null; Android still passes a lambda so its behavior is unchanged. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../org/scottishtecharmy/soundscape/App.kt | 7 ++++++- .../screens/home/home/SharedDrawerContent.kt | 16 +++++++++------- .../screens/home/home/SharedHomeScreen.kt | 2 +- 3 files changed, 16 insertions(+), 9 deletions(-) diff --git a/shared/src/commonMain/kotlin/org/scottishtecharmy/soundscape/App.kt b/shared/src/commonMain/kotlin/org/scottishtecharmy/soundscape/App.kt index dc009b9f9..58ff97b56 100644 --- a/shared/src/commonMain/kotlin/org/scottishtecharmy/soundscape/App.kt +++ b/shared/src/commonMain/kotlin/org/scottishtecharmy/soundscape/App.kt @@ -118,7 +118,12 @@ data class AppCallbacks( val onBeaconPreviewStart: ((String) -> Unit)? = null, val onBeaconPreviewUpdate: ((String) -> Unit)? = null, val onBeaconPreviewStop: ((Boolean, String?) -> Unit)? = null, - val onExitApp: () -> Unit = {}, + /** + * Fully exits the app (stops the foreground service and finishes the + * activity). Left null on iOS, where Apple's HIG forbids apps quitting + * themselves; the drawer hides the "Exit Soundscape" item when null. + */ + val onExitApp: (() -> Unit)? = null, ) data class AppFlows( diff --git a/shared/src/commonMain/kotlin/org/scottishtecharmy/soundscape/screens/home/home/SharedDrawerContent.kt b/shared/src/commonMain/kotlin/org/scottishtecharmy/soundscape/screens/home/home/SharedDrawerContent.kt index b6387d627..6e90be41f 100644 --- a/shared/src/commonMain/kotlin/org/scottishtecharmy/soundscape/screens/home/home/SharedDrawerContent.kt +++ b/shared/src/commonMain/kotlin/org/scottishtecharmy/soundscape/screens/home/home/SharedDrawerContent.kt @@ -64,7 +64,7 @@ fun SharedDrawerContent( tutorialRunning: Boolean, recordingEnabled: Boolean, newReleaseDialog: MutableState?, - exitApp: () -> Unit = {}, + exitApp: (() -> Unit)? = null, ) { val running = remember(tutorialRunning) { mutableStateOf(tutorialRunning) } @@ -113,12 +113,14 @@ fun SharedDrawerContent( .padding(innerPadding) .verticalScroll(rememberScrollState()), ) { - DrawerMenuItem( - onClick = { exitApp() }, - label = stringResource(Res.string.menu_exit_app), - icon = Icons.AutoMirrored.Rounded.ExitToApp, - modifier = Modifier.testTag("menuExitApp"), - ) + if (exitApp != null) { + DrawerMenuItem( + onClick = { exitApp() }, + label = stringResource(Res.string.menu_exit_app), + icon = Icons.AutoMirrored.Rounded.ExitToApp, + modifier = Modifier.testTag("menuExitApp"), + ) + } DrawerMenuItem( onClick = { onNavigate(SharedRoutes.SETTINGS) }, label = stringResource(Res.string.settings_screen_title), diff --git a/shared/src/commonMain/kotlin/org/scottishtecharmy/soundscape/screens/home/home/SharedHomeScreen.kt b/shared/src/commonMain/kotlin/org/scottishtecharmy/soundscape/screens/home/home/SharedHomeScreen.kt index 586cdb3d9..9db1e9c5b 100644 --- a/shared/src/commonMain/kotlin/org/scottishtecharmy/soundscape/screens/home/home/SharedHomeScreen.kt +++ b/shared/src/commonMain/kotlin/org/scottishtecharmy/soundscape/screens/home/home/SharedHomeScreen.kt @@ -60,7 +60,7 @@ fun SharedHomeScreen( rateSoundscape: () -> Unit, contactSupport: () -> Unit, shareRecording: () -> Unit, - exitApp: () -> Unit = {}, + exitApp: (() -> Unit)? = null, toggleTutorial: () -> Unit, tutorialRunning: Boolean, recordingEnabled: Boolean, From 013effb81e75a57ca3772091342e31c7dfc28e8e Mon Sep 17 00:00:00 2001 From: Dave Craig Date: Mon, 24 Aug 2026 16:27:56 +0100 Subject: [PATCH 3/4] Restore Photon "name" so road-only search results show the road Search results for roads (e.g. "Craigdhu Road, Milngavie") were rendering as just the city ("Milngavie"). Photon returns highway hits with the road in the "name" property and no "street" property, so AddressFormatter only sees the city. The line that copied "name" into nameLocal had been accidentally commented out (along with two debug printlns) during the iOS Geocoder work, so the fallback dropped the road entirely. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../scottishtecharmy/soundscape/utils/FeatureLocationExt.kt | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/shared/src/commonMain/kotlin/org/scottishtecharmy/soundscape/utils/FeatureLocationExt.kt b/shared/src/commonMain/kotlin/org/scottishtecharmy/soundscape/utils/FeatureLocationExt.kt index 4888931d6..827d4cc48 100644 --- a/shared/src/commonMain/kotlin/org/scottishtecharmy/soundscape/utils/FeatureLocationExt.kt +++ b/shared/src/commonMain/kotlin/org/scottishtecharmy/soundscape/utils/FeatureLocationExt.kt @@ -48,7 +48,6 @@ fun LocationDescription.process() { val mvt = (feature as? MvtFeature) var nameLocal: String? = null - println("$featureName") feature.properties?.let { properties -> properties.forEach { (key, value) -> when (key) { @@ -85,7 +84,7 @@ fun LocationDescription.process() { "postcode", "country", "state" -> {} } } - //nameLocal = properties["name"] as String? + nameLocal = properties["name"] as? String mvt?.housenumber?.let { jsonFields["house_number"] = it address = true From a8ee8a7a1a6360746062b67773a99ce21c5da914 Mon Sep 17 00:00:00 2001 From: Dave Craig Date: Mon, 24 Aug 2026 16:48:43 +0100 Subject: [PATCH 4/4] Add analytics to IosGeocoder --- .../org/scottishtecharmy/soundscape/IosSoundscapeService.kt | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/shared/src/iosMain/kotlin/org/scottishtecharmy/soundscape/IosSoundscapeService.kt b/shared/src/iosMain/kotlin/org/scottishtecharmy/soundscape/IosSoundscapeService.kt index 66c28bfd5..dbe9ac1bd 100644 --- a/shared/src/iosMain/kotlin/org/scottishtecharmy/soundscape/IosSoundscapeService.kt +++ b/shared/src/iosMain/kotlin/org/scottishtecharmy/soundscape/IosSoundscapeService.kt @@ -351,7 +351,9 @@ class IosSoundscapeService : GeoEngineListener, MediaControllableService, Servic offlineExtractPath = documentsPath, hasNetwork = { networkUtils.hasNetwork() }, photonSearch = photonSearch, - platformGeocoder = IosGeocoder(), + platformGeocoder = IosGeocoder( + analyticsLogger = { name -> analytics.logEvent(name, null) } + ), streetPreviewEnabled = streetPreviewEnabled, ) geoEngineStarted = true