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
117 changes: 117 additions & 0 deletions AndroidSwiftUICore/Sources/AndroidSwiftUICore/AppStorage.swift
Original file line number Diff line number Diff line change
@@ -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<Value>(_ 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<Value>: DynamicProperty {

internal let box: StateBox<Value>
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<Value> {
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 }
}
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
44 changes: 44 additions & 0 deletions Demo/App.swiftpm/Sources/AppStoragePlaygrounds.swift
Original file line number Diff line number Diff line change
@@ -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")
}
}
1 change: 1 addition & 0 deletions Demo/App.swiftpm/Sources/Catalog.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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())),
Expand Down
11 changes: 10 additions & 1 deletion Sources/AndroidSwiftUI/MainActivity.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down
Loading