Skip to content

Commit c9dc134

Browse files
committed
add OSResilientStorage file-backed identifier mirror
NSFileProtectionNone JSON file in the App Group container that mirrors opaque identifiers so they're readable before first unlock, when cfprefsd-backed UserDefaults returns nil.
1 parent 79001c2 commit c9dc134

1 file changed

Lines changed: 163 additions & 0 deletions

File tree

Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
1+
/*
2+
Modified MIT License
3+
4+
Copyright 2026 OneSignal
5+
6+
Permission is hereby granted, free of charge, to any person obtaining a copy
7+
of this software and associated documentation files (the "Software"), to deal
8+
in the Software without restriction, including without limitation the rights
9+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
10+
copies of the Software, and to permit persons to whom the Software is
11+
furnished to do so, subject to the following conditions:
12+
13+
1. The above copyright notice and this permission notice shall be included in
14+
all copies or substantial portions of the Software.
15+
16+
2. All copies of substantial portions of the Software may only be used in connection
17+
with services provided by OneSignal.
18+
19+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
20+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
21+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
22+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
23+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
24+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
25+
THE SOFTWARE.
26+
*/
27+
28+
import Foundation
29+
import OneSignalCore
30+
31+
/// File-backed mirror of OneSignal SDK identifiers, written with `NSFileProtectionNone`
32+
/// so it's readable before first unlock, when shared `UserDefaults` reads silently return nil.
33+
///
34+
/// Stored in the App Group container, so it's shared across any targets (main app, NSE, etc.)
35+
/// configured with the same App Group entitlement. Opaque identifiers only: no PII or credentials.
36+
@objc(OSResilientStorage)
37+
public final class OSResilientStorage: NSObject {
38+
39+
// MARK: - Public key constants
40+
41+
@objc public static let keyAppId = "app_id"
42+
@objc public static let keySubscriptionId = "subscription_id"
43+
/// Needed because the NSE reads this flag from shared UserDefaults while the device may be locked
44+
/// and the read silently returns the default (NO). Stored as "1" / "0".
45+
@objc public static let keyReceiveReceiptsEnabled = "receive_receipts_enabled"
46+
/// Set to `"1"` once `OneSignalUserManagerImpl.start()` has completed at least once on this app
47+
/// install; cleared on app-id change. Read at startup to distinguish a true fresh install from
48+
/// a prior session whose UserDefaults isn't readable yet (ie: during iOS prewarm before first unlock).
49+
@objc public static let keyHasPriorSession = "has_prior_session"
50+
51+
// MARK: - Internal
52+
53+
private static let fileName = "onesignal_identity.json"
54+
55+
/// Serial queue used to serialize all file reads/writes.
56+
private static let queue = DispatchQueue(label: "com.onesignal.resilient-storage")
57+
58+
/// Resolve a writable container URL. App Group container is preferred so
59+
/// the NSE can read the same file. Falls back to the app's private
60+
/// Application Support directory when no App Group is entitled.
61+
private static func fileURL() -> URL? {
62+
let fm = FileManager.default
63+
64+
let groupName = OneSignalUserDefaults.appGroupName()
65+
if let container = fm.containerURL(forSecurityApplicationGroupIdentifier: groupName) {
66+
return container.appendingPathComponent(fileName)
67+
}
68+
69+
do {
70+
let support = try fm.url(
71+
for: .applicationSupportDirectory,
72+
in: .userDomainMask,
73+
appropriateFor: nil,
74+
create: true
75+
)
76+
return support.appendingPathComponent(fileName)
77+
} catch {
78+
OneSignalLog.onesignalLog(.LL_ERROR, message: "OSResilientStorage could not resolve a container URL: \(error)")
79+
return nil
80+
}
81+
}
82+
83+
/// Reads the cache file. Caller is responsible for queue-serialization.
84+
/// Returns an empty dict if the file is missing or unreadable.
85+
private static func loadUnsafe() -> [String: String] {
86+
guard let url = fileURL() else { return [:] }
87+
guard FileManager.default.fileExists(atPath: url.path) else { return [:] }
88+
89+
do {
90+
let data = try Data(contentsOf: url)
91+
if data.isEmpty { return [:] }
92+
let object = try JSONSerialization.jsonObject(with: data, options: [])
93+
return (object as? [String: String]) ?? [:]
94+
} catch {
95+
OneSignalLog.onesignalLog(.LL_WARN, message: "OSResilientStorage could not read file: \(error)")
96+
return [:]
97+
}
98+
}
99+
100+
/// Writes the cache file atomically with `.none` file protection.
101+
/// Caller is responsible for queue-serialization.
102+
private static func writeUnsafe(_ contents: [String: String]) {
103+
guard let url = fileURL() else { return }
104+
105+
do {
106+
let data = try JSONSerialization.data(withJSONObject: contents, options: [])
107+
try data.write(to: url, options: [.atomic, .noFileProtection])
108+
109+
// Explicitly re-apply protection class. The atomic write performs a rename which
110+
// has been observed to reset attributes on some iOS versions.
111+
try FileManager.default.setAttributes(
112+
[.protectionKey: FileProtectionType.none],
113+
ofItemAtPath: url.path
114+
)
115+
} catch {
116+
OneSignalLog.onesignalLog(.LL_ERROR, message: "OSResilientStorage write failed: \(error)")
117+
}
118+
}
119+
120+
// MARK: - Public API
121+
122+
/// Returns the full current contents of the cache. Empty dict if absent.
123+
@objc public static func snapshot() -> [String: String] {
124+
return queue.sync { loadUnsafe() }
125+
}
126+
127+
/// Reads a single value. Returns nil when missing or unreadable.
128+
@objc public static func string(forKey key: String) -> String? {
129+
let dict = snapshot()
130+
guard let value = dict[key], !value.isEmpty else { return nil }
131+
return value
132+
}
133+
134+
/// Atomically updates a single value. Passing nil or an empty string removes the key.
135+
@objc public static func setString(_ value: String?, forKey key: String) {
136+
queue.async {
137+
var current = loadUnsafe()
138+
if let value = value, !value.isEmpty {
139+
current[key] = value
140+
} else {
141+
current.removeValue(forKey: key)
142+
}
143+
writeUnsafe(current)
144+
}
145+
}
146+
147+
/// Atomically updates multiple values, preserving keys not in `values`.
148+
/// An empty-string value removes the corresponding key.
149+
@objc public static func setStrings(_ values: [String: String]) {
150+
guard !values.isEmpty else { return }
151+
queue.async {
152+
var current = loadUnsafe()
153+
for (key, value) in values {
154+
if value.isEmpty {
155+
current.removeValue(forKey: key)
156+
} else {
157+
current[key] = value
158+
}
159+
}
160+
writeUnsafe(current)
161+
}
162+
}
163+
}

0 commit comments

Comments
 (0)