diff --git a/Sources/SkipUI/SkipUI/Properties/AppStorage.swift b/Sources/SkipUI/SkipUI/Properties/AppStorage.swift index 6add6fb1..0602d298 100644 --- a/Sources/SkipUI/SkipUI/Properties/AppStorage.swift +++ b/Sources/SkipUI/SkipUI/Properties/AppStorage.swift @@ -63,13 +63,7 @@ public final class AppStorage: StateTracker { // Create our Compose-trackable backing state and keep it in sync with the store. Note that we have to seed the store with a value // for the key in order for our listener to work let store = self.currentStore - let object = store.object(forKey: key) - let value: Value? - if let object, let deserializer { - value = deserializer(object) - } else { - value = object as? Value - } + let value = storedValue(from: store.object(forKey: key), store: store) if let value { _wrappedValue = value } else if let serializer { @@ -82,14 +76,7 @@ public final class AppStorage: StateTracker { // Caution: The preference manager does not currently store a strong reference to the listener. You must store a strong reference to the listener, or it will be susceptible to garbage collection. We recommend you keep a reference to the listener in the instance data of an object that will exist as long as you need the listener. // https://developer.android.com/reference/android/content/SharedPreferences.html#registerOnSharedPreferenceChangeListener(android.content.SharedPreferences.OnSharedPreferenceChangeListener) self.listener = store.registerOnSharedPreferenceChangeListener(key: key) { - let object = store.object(forKey: key) - let value: Value? - if let object, let deserializer { - value = deserializer(object) - } else { - value = object as? Value - } - if let value { + if let value = storedValue(from: store.object(forKey: key), store: store) { _wrappedValue = value _wrappedValueState?.value = value } @@ -97,6 +84,41 @@ public final class AppStorage: StateTracker { #endif } + #if SKIP + /// Read the stored `object` as our `Value`, coercing between primitive types the way `UserDefaults` does on iOS. + /// + /// `object as? Value` is a no-op under Kotlin's type erasure: it neither coerces (so `@AppStorage` would not + /// round-trip a value stored under one primitive type and read as another, unlike SwiftUI on iOS) nor rejects a + /// mismatched type (so a `String` stored value would flow into a `Double`-typed property and crash later with a + /// `ClassCastException` — see https://github.com/skiptools/skip-ui/issues/317). Instead we dispatch on the concrete + /// runtime type of the current wrapped value and use the matching typed `UserDefaults` accessor, mirroring the + /// coercion that the bridged `AppStorageSupport` performs. + private func storedValue(from object: Any?, store: UserDefaults) -> Value? { + guard let object else { + return nil + } + if let deserializer { + return deserializer(object) + } + switch _wrappedValue { + case is Bool: + return ((object as? Bool) ?? store.bool(forKey: key)) as? Value + case is Int: + return ((object as? Int) ?? store.integer(forKey: key)) as? Value + case is Double: + return ((object as? Double) ?? store.double(forKey: key)) as? Value + case is String: + return ((object as? String) ?? store.string(forKey: key)) as? Value + case is URL: + return ((object as? URL) ?? store.url(forKey: key)) as? Value + case is Data: + return ((object as? Data) ?? store.data(forKey: key)) as? Value + default: + return object as? Value + } + } + #endif + /// The current active store private var currentStore: UserDefaults { #if SKIP diff --git a/Tests/SkipUITests/AppStorageTests.swift b/Tests/SkipUITests/AppStorageTests.swift new file mode 100644 index 00000000..5bf94feb --- /dev/null +++ b/Tests/SkipUITests/AppStorageTests.swift @@ -0,0 +1,49 @@ +// Copyright 2026 Skip +// SPDX-License-Identifier: MPL-2.0 +import Foundation +import SwiftUI +import XCTest +#if SKIP +import SkipModel +#endif + +final class AppStorageTests: XCTestCase { + + /// A value stored under one primitive type and read through an `@AppStorage` of another + /// must coerce (as `UserDefaults` does on iOS) rather than let the mismatched type through, + /// which crashed a later consumer with a `ClassCastException` (issue #317). + func testAppStorageCoercesStringToDouble() throws { + #if !SKIP + throw XCTSkip("@AppStorage type coercion is Android-only") + #else + let defaults = UserDefaults.standard + let key = "SkipUITests.appStorageStringToDouble" + defaults.removeObject(forKey: key) + + defaults.set("1.0", forKey: key) // stored as a String + let storage = AppStorage(wrappedValue: 0.0, key) // read as a Double + storage.trackState() + XCTAssertEqual(storage.wrappedValue, 1.0) + + defaults.removeObject(forKey: key) + #endif + } + + /// An integer stored value read through a `Double`-typed `@AppStorage` should coerce as well. + func testAppStorageCoercesIntToDouble() throws { + #if !SKIP + throw XCTSkip("@AppStorage type coercion is Android-only") + #else + let defaults = UserDefaults.standard + let key = "SkipUITests.appStorageIntToDouble" + defaults.removeObject(forKey: key) + + defaults.set(3, forKey: key) // stored as an Int + let storage = AppStorage(wrappedValue: 0.0, key) // read as a Double + storage.trackState() + XCTAssertEqual(storage.wrappedValue, 3.0) + + defaults.removeObject(forKey: key) + #endif + } +}