From 29baac4c6892ea17e2c1c7653ff1373af5f34096 Mon Sep 17 00:00:00 2001 From: Alsey Coleman Miller Date: Thu, 23 Jul 2026 23:38:57 -0400 Subject: [PATCH 1/5] Add AppStorage with a pluggable backing store --- .../AndroidSwiftUICore/AppStorage.swift | 117 ++++++++++++++++++ 1 file changed, 117 insertions(+) create mode 100644 AndroidSwiftUICore/Sources/AndroidSwiftUICore/AppStorage.swift diff --git a/AndroidSwiftUICore/Sources/AndroidSwiftUICore/AppStorage.swift b/AndroidSwiftUICore/Sources/AndroidSwiftUICore/AppStorage.swift new file mode 100644 index 0000000..96b7759 --- /dev/null +++ b/AndroidSwiftUICore/Sources/AndroidSwiftUICore/AppStorage.swift @@ -0,0 +1,117 @@ +// +// AppStorage.swift +// AndroidSwiftUICore +// +// Values that outlive the process. The core owns no platform APIs, so the +// backing store is a protocol the host installs — a JSON file under the app's +// files directory on Android, an in-memory store in tests. Reads and writes +// go through the same `StateBox` machinery `@State` uses, so a change marks the +// view dirty exactly like any other state write. +// + +import Foundation + +/// Where `@AppStorage` values are kept between launches. +public protocol AppStorageBackend: AnyObject { + func value(forKey key: String) -> Any? + func set(_ value: Any?, forKey key: String) +} + +/// The process-wide store. Defaults to memory so the core works — and tests — +/// without a host; `ViewHost` installs a persistent one on Android. +public enum AppStorageStore { + + nonisolated(unsafe) public static var backend: AppStorageBackend = InMemoryAppStorage() + + internal static func read(_ key: String, as type: Value.Type) -> Value? { + backend.value(forKey: key) as? Value + } + + internal static func write(_ value: Any?, _ key: String) { + backend.set(value, forKey: key) + } +} + +public final class InMemoryAppStorage: AppStorageBackend { + private var values: [String: Any] = [:] + public init() {} + public func value(forKey key: String) -> Any? { values[key] } + public func set(_ value: Any?, forKey key: String) { values[key] = value } +} + +/// A JSON file on disk. Small by design: `@AppStorage` is for preferences, and +/// rewriting the whole file per write keeps it consistent without a database. +public final class FileAppStorage: AppStorageBackend { + + private let url: URL + private var values: [String: Any] + + public init(directory: String, name: String = "app-storage.json") { + self.url = URL(fileURLWithPath: directory).appendingPathComponent(name) + if let data = try? Data(contentsOf: url), + let decoded = try? JSONSerialization.jsonObject(with: data) as? [String: Any] { + self.values = decoded + } else { + self.values = [:] + } + } + + public func value(forKey key: String) -> Any? { values[key] } + + public func set(_ value: Any?, forKey key: String) { + if let value { values[key] = value } else { values.removeValue(forKey: key) } + guard let data = try? JSONSerialization.data(withJSONObject: values) else { return } + try? data.write(to: url, options: .atomic) + } +} + +// MARK: - The wrapper + +/// A value persisted under `key`, readable and writable like `@State`. +@propertyWrapper +public struct AppStorage: DynamicProperty { + + internal let box: StateBox + internal let key: String + + private init(key: String, defaultValue: Value) { + self.key = key + // seed from the store so the first read already reflects what was saved + self.box = StateBox(AppStorageStore.read(key, as: Value.self) ?? defaultValue) + } + + public var wrappedValue: Value { + get { box.value } + nonmutating set { + box.value = newValue + AppStorageStore.write(newValue, key) + } + } + + public var projectedValue: Binding { + let key = self.key + let box = self.box + return Binding( + get: { box.value }, + set: { box.value = $0; AppStorageStore.write($0, key) } + ) + } +} + +// Only the types a preferences store can round-trip through JSON. +public extension AppStorage where Value == Bool { + init(wrappedValue: Value, _ key: String) { self.init(key: key, defaultValue: wrappedValue) } +} +public extension AppStorage where Value == Int { + init(wrappedValue: Value, _ key: String) { self.init(key: key, defaultValue: wrappedValue) } +} +public extension AppStorage where Value == Double { + init(wrappedValue: Value, _ key: String) { self.init(key: key, defaultValue: wrappedValue) } +} +public extension AppStorage where Value == String { + init(wrappedValue: Value, _ key: String) { self.init(key: key, defaultValue: wrappedValue) } +} + +extension AppStorage: _StatePropertyReflectable { + public var _box: AnyObject { box } +} From 8c966b417b132e5c573de7317d647006978ce48e Mon Sep 17 00:00:00 2001 From: Alsey Coleman Miller Date: Thu, 23 Jul 2026 23:38:58 -0400 Subject: [PATCH 2/5] Persist AppStorage in the app files directory --- Sources/AndroidSwiftUI/MainActivity.swift | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/Sources/AndroidSwiftUI/MainActivity.swift b/Sources/AndroidSwiftUI/MainActivity.swift index 1c4ddb4..7c8e27b 100644 --- a/Sources/AndroidSwiftUI/MainActivity.swift +++ b/Sources/AndroidSwiftUI/MainActivity.swift @@ -25,7 +25,16 @@ extension MainActivity { public func onCreateSwift(_ savedInstanceState: BaseBundle?) { log("\(self).\(#function)") MainActivity.shared = self - + + // Point @AppStorage at a file in the app's private storage before any + // view is built, so the first evaluation already reads saved values. + // The path comes through the existing Context binding — no new bridge. + if let directory = (self as AndroidContent.Context).getFilesDir()?.getAbsolutePath() { + AppStorageStore.backend = FileAppStorage(directory: directory) + } else { + log("MainActivity: no files directory; @AppStorage stays in memory") + } + // start app AndroidSwiftUIMain() From 38cf1e8baf49644ba7e3d50d72c2691ac4e413ef Mon Sep 17 00:00:00 2001 From: Alsey Coleman Miller Date: Thu, 23 Jul 2026 23:38:58 -0400 Subject: [PATCH 3/5] Test AppStorage persistence and types --- .../ControlTests.swift | 84 +++++++++++++++++++ 1 file changed, 84 insertions(+) diff --git a/AndroidSwiftUICore/Tests/AndroidSwiftUICoreTests/ControlTests.swift b/AndroidSwiftUICore/Tests/AndroidSwiftUICoreTests/ControlTests.swift index b4abbfb..356d6c5 100644 --- a/AndroidSwiftUICore/Tests/AndroidSwiftUICoreTests/ControlTests.swift +++ b/AndroidSwiftUICore/Tests/AndroidSwiftUICoreTests/ControlTests.swift @@ -376,3 +376,87 @@ private func focusCallback(_ node: RenderNode) -> Int64? { case .int(let id)? = mod.args["onChange"] else { return nil } return Int64(id) } + +@Suite("AppStorage") +struct AppStorageTests { + + @Test("A stored value survives a fresh wrapper, as it would a relaunch") + func persistsAcrossWrappers() { + AppStorageStore.backend = InMemoryAppStorage() + // first "launch": default, then the user changes it + struct First: View { + @AppStorage("volume") var volume = 5 + var body: some View { Text("\(volume)") } + } + let first = First() + #expect(first.volume == 5) + first.volume = 9 + + // a fresh wrapper reads what was written, not the default + struct Second: View { + @AppStorage("volume") var volume = 5 + var body: some View { Text("\(volume)") } + } + #expect(Second().volume == 9) + } + + @Test("Each supported type round-trips under its own key") + func supportedTypes() { + AppStorageStore.backend = InMemoryAppStorage() + struct Screen: View { + @AppStorage("on") var on = false + @AppStorage("count") var count = 0 + @AppStorage("ratio") var ratio = 0.0 + @AppStorage("name") var name = "" + var body: some View { Text(name) } + } + let screen = Screen() + screen.on = true + screen.count = 42 + screen.ratio = 0.75 + screen.name = "Coleman" + #expect(AppStorageStore.read("on", as: Bool.self) == true) + #expect(AppStorageStore.read("count", as: Int.self) == 42) + #expect(AppStorageStore.read("ratio", as: Double.self) == 0.75) + #expect(AppStorageStore.read("name", as: String.self) == "Coleman") + // keys stay independent + #expect(Screen().count == 42) + #expect(Screen().name == "Coleman") + } + + @Test("The projected binding writes through to the store") + func bindingWritesThrough() { + AppStorageStore.backend = InMemoryAppStorage() + struct Screen: View { + @AppStorage("nickname") var nickname = "" + var body: some View { TextField("Name", text: $nickname) } + } + let host = ViewHost(Screen()) + let node = host.evaluate() + guard case .int(let id)? = node.props["onChange"] else { + Issue.record("missing field callback"); return + } + host.callbacks.invokeString(Int64(id), "typed in") + #expect(AppStorageStore.read("nickname", as: String.self) == "typed in") + } + + @Test("A file-backed store reloads what a previous instance wrote") + func fileBackedRoundTrip() throws { + let directory = NSTemporaryDirectory() + "appstorage-test-\(UInt32.random(in: 0..<100_000))" + try FileManager.default.createDirectory(atPath: directory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(atPath: directory) } + + let first = FileAppStorage(directory: directory) + first.set(7, forKey: "launches") + first.set("dark", forKey: "theme") + + // a second instance is what the next launch sees + let second = FileAppStorage(directory: directory) + #expect(second.value(forKey: "launches") as? Int == 7) + #expect(second.value(forKey: "theme") as? String == "dark") + + // and removal sticks + second.set(nil, forKey: "theme") + #expect(FileAppStorage(directory: directory).value(forKey: "theme") == nil) + } +} From 6e63f21e1a8b848936945b2d515682bb90380f28 Mon Sep 17 00:00:00 2001 From: Alsey Coleman Miller Date: Thu, 23 Jul 2026 23:38:58 -0400 Subject: [PATCH 4/5] Add an app storage playground --- .../Sources/AppStoragePlaygrounds.swift | 44 +++++++++++++++++++ 1 file changed, 44 insertions(+) create mode 100644 Demo/App.swiftpm/Sources/AppStoragePlaygrounds.swift diff --git a/Demo/App.swiftpm/Sources/AppStoragePlaygrounds.swift b/Demo/App.swiftpm/Sources/AppStoragePlaygrounds.swift new file mode 100644 index 0000000..cbeff48 --- /dev/null +++ b/Demo/App.swiftpm/Sources/AppStoragePlaygrounds.swift @@ -0,0 +1,44 @@ +#if canImport(AndroidSwiftUI) +import AndroidSwiftUI +#else +import SwiftUI +#endif + +struct AppStoragePlayground: View { + + @AppStorage("demo.launches") private var launches = 0 + @AppStorage("demo.nickname") private var nickname = "" + @AppStorage("demo.notify") private var notify = false + @AppStorage("demo.volume") private var volume = 0.5 + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 0) { + Example("Survives a relaunch") { + VStack(alignment: .leading, spacing: 8) { + Text("Counter: \(launches)") + Text("Kill and reopen the app — it keeps its value.") + Button("Increment") { launches += 1 } + Button("Reset") { launches = 0 } + } + } + Example("String") { + VStack(alignment: .leading, spacing: 8) { + TextField("Nickname", text: $nickname) + Text("Stored: \(nickname)") + } + } + Example("Bool") { + Toggle("Notifications", isOn: $notify) + } + Example("Double") { + VStack(alignment: .leading, spacing: 8) { + Slider(value: $volume, in: 0...1) + Text("Volume: \(Int(volume * 100))%") + } + } + } + } + .navigationTitle("AppStorage") + } +} From 81bc940348a43bba10eace04b1aea33edaa6afc4 Mon Sep 17 00:00:00 2001 From: Alsey Coleman Miller Date: Thu, 23 Jul 2026 23:38:58 -0400 Subject: [PATCH 5/5] List the app storage playground in the catalog --- Demo/App.swiftpm/Sources/Catalog.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/Demo/App.swiftpm/Sources/Catalog.swift b/Demo/App.swiftpm/Sources/Catalog.swift index 9efe9d1..010f52f 100644 --- a/Demo/App.swiftpm/Sources/Catalog.swift +++ b/Demo/App.swiftpm/Sources/Catalog.swift @@ -64,6 +64,7 @@ struct CatalogEntry: Identifiable { CatalogEntry(id: "accessibility", title: "Accessibility", screen: AnyCatalogScreen(AccessibilityPlayground())), CatalogEntry(id: "state", title: "State", screen: AnyCatalogScreen(StatePlayground())), CatalogEntry(id: "preference", title: "Preferences", screen: AnyCatalogScreen(PreferencePlayground())), + CatalogEntry(id: "appstorage", title: "AppStorage", screen: AnyCatalogScreen(AppStoragePlayground())), CatalogEntry(id: "environment", title: "Environment", screen: AnyCatalogScreen(EnvironmentPlayground())), CatalogEntry(id: "observable", title: "Observable", screen: AnyCatalogScreen(ObservablePlayground())), CatalogEntry(id: "bindable", title: "Bindable", screen: AnyCatalogScreen(BindablePlayground())),