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/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, 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/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 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 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 + } +}