Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 21 additions & 5 deletions iosApp/project.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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: |
Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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<Preferences> =
createDefaultPreferenceFlow()
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ fun SharedDrawerContent(
tutorialRunning: Boolean,
recordingEnabled: Boolean,
newReleaseDialog: MutableState<Boolean>?,
exitApp: () -> Unit = {},
exitApp: (() -> Unit)? = null,
) {
val running = remember(tutorialRunning) { mutableStateOf(tutorialRunning) }

Expand Down Expand Up @@ -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),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ fun SharedHomeScreen(
rateSoundscape: () -> Unit,
contactSupport: () -> Unit,
shareRecording: () -> Unit,
exitApp: () -> Unit = {},
exitApp: (() -> Unit)? = null,
toggleTutorial: () -> Unit,
tutorialRunning: Boolean,
recordingEnabled: Boolean,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
@@ -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<Preferences>
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -103,7 +104,7 @@ fun AccessibilityOnboardingScreen(
)
Spacer(modifier = Modifier.height(spacing.large))

ProvidePreferenceLocals {
ProvidePreferenceLocals(flow = rememberSoundscapePreferenceFlow()) {
SwitchPreference(
state = showMap,
title = {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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<Preferences> =
remember { createSoundscapePreferenceFlow(NSUserDefaults.standardUserDefaults) }

private fun createSoundscapePreferenceFlow(
userDefaults: NSUserDefaults,
): MutableStateFlow<Preferences> {
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<String, Any>)
?: 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<String, Any>).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<String>).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<Any?>
if (list.all { it is NSString }) {
@Suppress("UNCHECKED_CAST")
(list as List<NSString>).mapTo(mutableSetOf()) { it as String }
} else {
null
}
}
else -> null
}
}