Skip to content
Open

wip #1668

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
16 changes: 16 additions & 0 deletions iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,9 @@
3C14E3A12AFAE461006ED053 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = 3C14E3A02AFAE461006ED053 /* PrivacyInfo.xcprivacy */; };
3C14E3A42AFAE54C006ED053 /* OneSignalSwiftInterface.swift in Sources */ = {isa = PBXBuildFile; fileRef = DEC08AFF2947D4E900C81DA3 /* OneSignalSwiftInterface.swift */; };
3C19C6322E919F0C00D6731E /* OSRequestLiveActivityClicked.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3C19C6312E919F0C00D6731E /* OSRequestLiveActivityClicked.swift */; };
3C23A21B2FCE0A52001D32E3 /* OneSignalIdentifiersFallbackTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3C23A21A2FCE0A52001D32E3 /* OneSignalIdentifiersFallbackTests.swift */; };
3C23A21D2FCE0A83001D32E3 /* OSModelStoreRefreshTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3C23A21C2FCE0A83001D32E3 /* OSModelStoreRefreshTests.swift */; };
3C23A21F2FCE0AA1001D32E3 /* OSResilientStorageTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3C23A21E2FCE0AA1001D32E3 /* OSResilientStorageTests.swift */; };
3C24B0EC2BD09D7A0052E771 /* OneSignalCoreObjCTests.m in Sources */ = {isa = PBXBuildFile; fileRef = 3C24B0EB2BD09D7A0052E771 /* OneSignalCoreObjCTests.m */; };
3C277D7E2BD76E0000857606 /* OSIdentityModelRepo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3C277D7D2BD76E0000857606 /* OSIdentityModelRepo.swift */; };
3C2C7DC8288F3C020020F9AE /* OSSubscriptionModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3C2C7DC7288F3C020020F9AE /* OSSubscriptionModel.swift */; };
Expand Down Expand Up @@ -180,6 +183,7 @@
3CC890352C5BF9A7002CB4CC /* UserConcurrencyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3CC890342C5BF9A7002CB4CC /* UserConcurrencyTests.swift */; };
3CC9A6342AFA1FDE008F68FD /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = 3CC9A6332AFA1FDD008F68FD /* PrivacyInfo.xcprivacy */; };
3CC9A6362AFA26E7008F68FD /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = 3CC9A6352AFA26E7008F68FD /* PrivacyInfo.xcprivacy */; };
3CCC48042FCD619400D77E94 /* OSResilientStorage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3CCC48032FCD619400D77E94 /* OSResilientStorage.swift */; };
3CCF44BE299B17290021964D /* OneSignalWrapper.h in Headers */ = {isa = PBXBuildFile; fileRef = 3CCF44BC299B17290021964D /* OneSignalWrapper.h */; settings = {ATTRIBUTES = (Public, ); }; };
3CCF44BF299B17290021964D /* OneSignalWrapper.m in Sources */ = {isa = PBXBuildFile; fileRef = 3CCF44BD299B17290021964D /* OneSignalWrapper.m */; };
3CDE664C2BFC2A56006DA114 /* OneSignalUserObjcTests.m in Sources */ = {isa = PBXBuildFile; fileRef = 3CDE664B2BFC2A56006DA114 /* OneSignalUserObjcTests.m */; };
Expand Down Expand Up @@ -1332,6 +1336,9 @@
3C14E39E2AFAE39B006ED053 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; lastKnownFileType = text.xml; path = PrivacyInfo.xcprivacy; sourceTree = "<group>"; };
3C14E3A02AFAE461006ED053 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; lastKnownFileType = text.xml; path = PrivacyInfo.xcprivacy; sourceTree = "<group>"; };
3C19C6312E919F0C00D6731E /* OSRequestLiveActivityClicked.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSRequestLiveActivityClicked.swift; sourceTree = "<group>"; };
3C23A21A2FCE0A52001D32E3 /* OneSignalIdentifiersFallbackTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OneSignalIdentifiersFallbackTests.swift; sourceTree = "<group>"; };
3C23A21C2FCE0A83001D32E3 /* OSModelStoreRefreshTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSModelStoreRefreshTests.swift; sourceTree = "<group>"; };
3C23A21E2FCE0AA1001D32E3 /* OSResilientStorageTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSResilientStorageTests.swift; sourceTree = "<group>"; };
3C24B0EA2BD09D790052E771 /* OneSignalCoreTests-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "OneSignalCoreTests-Bridging-Header.h"; sourceTree = "<group>"; };
3C24B0EB2BD09D7A0052E771 /* OneSignalCoreObjCTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = OneSignalCoreObjCTests.m; sourceTree = "<group>"; };
3C277D7D2BD76E0000857606 /* OSIdentityModelRepo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSIdentityModelRepo.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -1416,6 +1423,7 @@
3CC890342C5BF9A7002CB4CC /* UserConcurrencyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserConcurrencyTests.swift; sourceTree = "<group>"; };
3CC9A6332AFA1FDD008F68FD /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; lastKnownFileType = text.xml; path = PrivacyInfo.xcprivacy; sourceTree = "<group>"; };
3CC9A6352AFA26E7008F68FD /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; lastKnownFileType = text.xml; path = PrivacyInfo.xcprivacy; sourceTree = "<group>"; };
3CCC48032FCD619400D77E94 /* OSResilientStorage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSResilientStorage.swift; sourceTree = "<group>"; };
3CCF44BC299B17290021964D /* OneSignalWrapper.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = OneSignalWrapper.h; sourceTree = "<group>"; };
3CCF44BD299B17290021964D /* OneSignalWrapper.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = OneSignalWrapper.m; sourceTree = "<group>"; };
3CDE664A2BFC2A55006DA114 /* OneSignalUserTests-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "OneSignalUserTests-Bridging-Header.h"; sourceTree = "<group>"; };
Expand Down Expand Up @@ -2231,6 +2239,7 @@
3C115163289A259500565C41 /* OneSignalOSCore.h */,
3C115188289ADEA300565C41 /* OSModelStore.swift */,
3C5C6FFB2FCB8DED00102E2C /* OneSignalIdentifiers.swift */,
3CCC48032FCD619400D77E94 /* OSResilientStorage.swift */,
3C115186289ADE7700565C41 /* OSModelStoreListener.swift */,
3C115184289ADE4F00565C41 /* OSModel.swift */,
3CF1A5622C669EA40056B3AA /* OSNewRecordsState.swift */,
Expand Down Expand Up @@ -2513,6 +2522,9 @@
isa = PBXGroup;
children = (
5BC1DE672C90C23E00CA8807 /* OSConsistencyManagerTests.swift */,
3C23A21A2FCE0A52001D32E3 /* OneSignalIdentifiersFallbackTests.swift */,
3C23A21E2FCE0AA1001D32E3 /* OSResilientStorageTests.swift */,
3C23A21C2FCE0A83001D32E3 /* OSModelStoreRefreshTests.swift */,
);
path = OneSignalOSCoreTests;
sourceTree = "<group>";
Expand Down Expand Up @@ -4381,6 +4393,7 @@
5BC1DE5C2C90B7E600CA8807 /* OSConsistencyManager.swift in Sources */,
3C5C6FFC2FCB8DED00102E2C /* OneSignalIdentifiers.swift in Sources */,
3C115189289ADEA300565C41 /* OSModelStore.swift in Sources */,
3CCC48042FCD619400D77E94 /* OSResilientStorage.swift in Sources */,
3C115185289ADE4F00565C41 /* OSModel.swift in Sources */,
3CF1A5632C669EA40056B3AA /* OSNewRecordsState.swift in Sources */,
3C448BA22936B474002F96BC /* OSBackgroundTaskManager.swift in Sources */,
Expand Down Expand Up @@ -4527,6 +4540,9 @@
buildActionMask = 2147483647;
files = (
5B053FC32CAE0843002F30C4 /* OSConsistencyManagerTests.swift in Sources */,
3C23A21F2FCE0AA1001D32E3 /* OSResilientStorageTests.swift in Sources */,
3C23A21D2FCE0A83001D32E3 /* OSModelStoreRefreshTests.swift in Sources */,
3C23A21B2FCE0A52001D32E3 /* OneSignalIdentifiersFallbackTests.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,15 @@
@implementation OneSignalReceiveReceiptsController

- (BOOL)isReceiveReceiptsEnabled {
return [OneSignalUserDefaults.initShared getSavedBoolForKey:OSUD_RECEIVE_RECEIPTS_ENABLED defaultValue:NO];
BOOL enabled = [OneSignalUserDefaults.initShared getSavedBoolForKey:OSUD_RECEIVE_RECEIPTS_ENABLED defaultValue:NO];
if (enabled) {
return YES;
}
// UserDefaults can return NO for two reasons: the flag is genuinely off, or the shared
// UserDefaults file isn't readable right now (NSE running while device is locked under
// NSFileProtectionCompleteUntilFirstUserAuthentication). Fall back to the unencrypted cache.
NSString *cached = [OSResilientStorage stringForKey:OSResilientStorage.keyReceiveReceiptsEnabled];
return [cached isEqualToString:@"1"];
Comment on lines +38 to +46
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 🟡 Minor consistency window in isReceiveReceiptsEnabled (OneSignalReceiveReceiptsController.m:38-46): when the server flips IOS_RECEIVE_RECEIPTS_ENABLE from YES→NO in downloadIOSParamsWithAppId, the UserDefaults write is synchronous but OSResilientStorage.setString is queue.async — between those two writes an NSE reading the flag sees UD=NO, falls back to the cache, reads stale "1", and returns YES. The window is microseconds-to-milliseconds and the worst-case impact is one extra receive_receipt request the server is free to ignore. Easy mitigation if you want it tight: write OSResilientStorage before UserDefaults at OneSignal.m:631-637 so the race direction is safe, or make this specific setString synchronous.

Extended reasoning...

The race. The new fallback in isReceiveReceiptsEnabled treats a UD value of NO as ambiguous and consults OSResilientStorage as a tiebreaker — this is the right call for the locked-NSE case where cfprefsd returns nil under NSFileProtectionCompleteUntilFirstUserAuthentication. But UD can also legitimately be NO because the server just disabled the flag. The two storage layers are not updated atomically:

// OneSignal.m:631-637 (server response handler)
[OneSignalUserDefaults.initShared saveBoolForKey:OSUD_RECEIVE_RECEIPTS_ENABLED withValue:enabled]; // synchronous
[OSResilientStorage setString:enabled ? @"1" : @"0" forKey:...];                                   // queue.async

OneSignalUserDefaults.saveBoolForKey is -setBool:forKey: + synchronize — synchronous from the caller's view, and cross-process visible after cfprefsd propagation. OSResilientStorage.setString (OSResilientStorage.swift:132-142) hops to a serial queue, then does a Data(contentsOf:) → mutate → atomic data.write cycle.

Step-by-step proof of a stale YES.

  1. Initial state on disk: UD says OSUD_RECEIVE_RECEIPTS_ENABLED = YES, file says receive_receipts_enabled = "1". NSE returns YES correctly.
  2. Server flips the flag to disabled. Main app receives downloadIOSParamsWithAppId response.
  3. T₀: Main app calls saveBoolForKey:withValue:NO. UD now says NO; cfprefsd propagates this to the NSE within microseconds.
  4. T₀ + ε: Main app calls [OSResilientStorage setString:@"0" ...]. The work is enqueued on the serial queue but not yet executed — the file on disk still says "1".
  5. T₀ + ε + δ: An NSE wakes for a push delivery (this is the realistic timing — NSEs fire on push arrival, not on a schedule).
  6. NSE's isReceiveReceiptsEnabled reads UD → gets NO (enabled = NO, falls through the if (enabled) return YES early-out).
  7. NSE reads OSResilientStorage.stringForKey:keyReceiveReceiptsEnabled → file still says "1" → returns YES.
  8. NSE proceeds to send report_received, which the server has just disabled.

Why existing code doesn't prevent it. The whole point of the fallback added at OneSignalReceiveReceiptsController.m:38-46 is to not trust UD's NO. So the fallback unconditionally consults the file, with no way to distinguish "UD was unreadable" from "UD was explicitly set to NO." OSResilientStorage.setString's queue.async ensures the file write isn't synchronous with the UD write that just preceded it — there is no barrier between the two stores.

Why this is a nit, not a blocker. The refutation captures it well and I agree:

  • The window is bounded by one serial dispatch + one file write, roughly microseconds to a few ms. Cross-process cfprefsd propagation widens it slightly but it's still a narrow window.
  • It requires the rare YES→NO server flip and an NSE waking inside that window.
  • The impact is at most one extra report_received request per occurrence — server-idempotent, no data corruption, no user-visible effect, no security implication. The server is the authoritative source and will reject the receipt if it cares.
  • The PR fixes the dominant locked-NSE false-negative case, which is frequent and deterministic; this is a small, asymmetric trade-off in the opposite direction.

Cheap mitigation if you want a clean story. Reverse the write order in downloadIOSParamsWithAppId so the cache is updated before UD — then the race direction becomes "UD still says YES, file already says 0" → the early-return at line 40 still hits and the fallback isn't consulted, so the stale read can't manifest. Or, only for this one key, dispatch the OSResilientStorage write via queue.sync. Either is a one-line change and neither affects the locked-NSE fix.

Not a merge blocker.

}

- (void)sendReceiveReceiptWithNotificationId:(NSString *)notificationId {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ class OSRequestLiveActivityClicked: OneSignalRequest, OSLiveActivityRequest {
return false
}

guard let subscriptionId = OneSignalUserManagerImpl.sharedInstance.pushSubscriptionId else {
guard let subscriptionId = OneSignalIdentifiers.subscriptionId else {
OneSignalLog.onesignalLog(.LL_DEBUG, message: "Cannot generate the OSRequestLiveActivityClicked due to null subscription ID.")
return false
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ class OSRequestLiveActivityReceiveReceipts: OneSignalRequest, OSLiveActivityRequ
return false
}

guard let subscriptionId = OneSignalUserManagerImpl.sharedInstance.pushSubscriptionId else {
guard let subscriptionId = OneSignalIdentifiers.subscriptionId else {
OneSignalLog.onesignalLog(.LL_DEBUG, message: "Cannot generate the OSRequestLiveActivityReceiveReceipts due to null subscription ID.")
return false
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ class OSRequestRemoveStartToken: OneSignalRequest, OSLiveActivityRequest, OSLive
return false
}

guard let subscriptionId = OneSignalUserManagerImpl.sharedInstance.pushSubscriptionId else {
guard let subscriptionId = OneSignalIdentifiers.subscriptionId else {
OneSignalLog.onesignalLog(.LL_DEBUG, message: "Cannot generate the remove start token request due to null subscription ID.")
return false
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ class OSRequestRemoveUpdateToken: OneSignalRequest, OSLiveActivityRequest, OSLiv
return false
}

guard let subscriptionId = OneSignalUserManagerImpl.sharedInstance.pushSubscriptionId else {
guard let subscriptionId = OneSignalIdentifiers.subscriptionId else {
OneSignalLog.onesignalLog(.LL_DEBUG, message: "Cannot generate the remove update token request due to null subscription ID.")
return false
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ class OSRequestSetStartToken: OneSignalRequest, OSLiveActivityRequest, OSLiveAct
return false
}

guard let subscriptionId = OneSignalUserManagerImpl.sharedInstance.pushSubscriptionId else {
guard let subscriptionId = OneSignalIdentifiers.subscriptionId else {
OneSignalLog.onesignalLog(.LL_DEBUG, message: "Cannot generate the set start token request due to null subscription ID.")
return false
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ class OSRequestSetUpdateToken: OneSignalRequest, OSLiveActivityRequest, OSLiveAc
return false
}

guard let subscriptionId = OneSignalUserManagerImpl.sharedInstance.pushSubscriptionId else {
guard let subscriptionId = OneSignalIdentifiers.subscriptionId else {
OneSignalLog.onesignalLog(.LL_DEBUG, message: "Cannot generate the set update token request due to null subscription ID.")
return false
}
Expand Down
23 changes: 23 additions & 0 deletions iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSModelStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,29 @@ open class OSModelStore<TModel: OSModel>: NSObject {
}
}

/// Re-read this store's backing UserDefaults entry and hydrate `models` from disk.
/// No-op when `models` is already non-empty — we never clobber in-memory state.
///
/// Motivation: model stores load their `models` dict once in `init()` from shared UserDefaults.
/// If `init()` runs while protected data is unavailable (iOS app prewarm, NSE before first
/// unlock), that read returns nil and the dict stays empty for the lifetime of the singleton —
/// it is never re-read. After protected data becomes available, callers can call `refresh()`
/// so the store reflects what's actually on disk. Does not fire listener events.
public func refresh() {
lock.withLock {
guard models.isEmpty else { return }
guard let stored = OneSignalUserDefaults.initShared().getSavedCodeableData(forKey: self.storeKey, defaultValue: [:]) as? [String: TModel],
!stored.isEmpty else {
return
}
OneSignalLog.onesignalLog(.LL_DEBUG, message: "OSModelStore[\(self.storeKey)] refresh hydrated \(stored.count) model(s) from UserDefaults")
self.models = stored
for model in stored.values {
model.changeNotifier.subscribe(self)
}
}
}

public func add(id: String, model: TModel, hydrating: Bool) {
// TODO: Check if we are adding the same model? Do we replace?
// For example, calling addEmail multiple times with the same email
Expand Down
164 changes: 164 additions & 0 deletions iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSResilientStorage.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
/*
Modified MIT License

Copyright 2026 OneSignal

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

1. The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.

2. All copies of substantial portions of the Software may only be used in connection
with services provided by OneSignal.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
*/

import Foundation
import OneSignalCore

/// File-backed mirror of OneSignal SDK identifiers, written with `NSFileProtectionNone`
/// so it's readable before first unlock, when shared `UserDefaults` reads silently return nil.
///
/// Stored in the App Group container, so it's shared across any targets (main app, NSE, etc.)
/// configured with the same App Group entitlement. Opaque identifiers only: no PII or credentials.
@objc(OSResilientStorage)
public final class OSResilientStorage: NSObject {

// MARK: - Public key constants

@objc public static let keyAppId = "app_id"
@objc public static let keySubscriptionId = "subscription_id"
/// Needed because the NSE reads this flag from shared UserDefaults while the device may be locked
/// and the read silently returns the default (NO). Stored as "1" / "0".
@objc public static let keyReceiveReceiptsEnabled = "receive_receipts_enabled"
/// Set to `"1"` once `OneSignalUserManagerImpl.start()` has completed on this device at least once.
/// Used by the main app's protected-data seed to distinguish "fresh install" from
/// "prior session exists but UserDefaults isn't readable yet (iOS prewarm before first
/// unlock)". Cleared on app-id change so a new app's first launch behaves like a fresh install.
@objc public static let keyDidStart = "did_start"

// MARK: - Internal

private static let fileName = "onesignal_identity.json"

/// Serial queue used to serialize all file reads/writes.
private static let queue = DispatchQueue(label: "com.onesignal.resilient-storage")

/// Resolve a writable container URL. App Group container is preferred so
/// the NSE can read the same file. Falls back to the app's private
/// Application Support directory when no App Group is entitled.
private static func fileURL() -> URL? {
let fm = FileManager.default

let groupName = OneSignalUserDefaults.appGroupName()
if let container = fm.containerURL(forSecurityApplicationGroupIdentifier: groupName) {
return container.appendingPathComponent(fileName)
}

do {
let support = try fm.url(
for: .applicationSupportDirectory,
in: .userDomainMask,
appropriateFor: nil,
create: true
)
return support.appendingPathComponent(fileName)
} catch {
OneSignalLog.onesignalLog(.LL_ERROR, message: "OSResilientStorage could not resolve a container URL: \(error)")
return nil
}
}

/// Reads the cache file. Caller is responsible for queue-serialization.
/// Returns an empty dict if the file is missing or unreadable.
private static func loadUnsafe() -> [String: String] {
guard let url = fileURL() else { return [:] }
guard FileManager.default.fileExists(atPath: url.path) else { return [:] }

do {
let data = try Data(contentsOf: url)
if data.isEmpty { return [:] }
let object = try JSONSerialization.jsonObject(with: data, options: [])
return (object as? [String: String]) ?? [:]
} catch {
OneSignalLog.onesignalLog(.LL_WARN, message: "OSResilientStorage could not read file: \(error)")
return [:]
}
}

/// Writes the cache file atomically with `.none` file protection.
/// Caller is responsible for queue-serialization.
private static func writeUnsafe(_ contents: [String: String]) {
guard let url = fileURL() else { return }

do {
let data = try JSONSerialization.data(withJSONObject: contents, options: [])
try data.write(to: url, options: [.atomic, .noFileProtection])

// Explicitly re-apply protection class. The atomic write performs a rename which
// has been observed to reset attributes on some iOS versions.
try FileManager.default.setAttributes(
[.protectionKey: FileProtectionType.none],
ofItemAtPath: url.path
)
} catch {
OneSignalLog.onesignalLog(.LL_ERROR, message: "OSResilientStorage write failed: \(error)")
}
}

// MARK: - Public API

/// Returns the full current contents of the cache. Empty dict if absent.
@objc public static func snapshot() -> [String: String] {
return queue.sync { loadUnsafe() }
}

/// Reads a single value. Returns nil when missing or unreadable.
@objc public static func string(forKey key: String) -> String? {
let dict = snapshot()
guard let value = dict[key], !value.isEmpty else { return nil }
return value
}

/// Atomically updates a single value. Passing nil or an empty string removes the key.
@objc public static func setString(_ value: String?, forKey key: String) {
queue.async {
var current = loadUnsafe()
if let value = value, !value.isEmpty {
current[key] = value
} else {
current.removeValue(forKey: key)
}
writeUnsafe(current)
}
}

/// Atomically updates multiple values, preserving keys not in `values`.
/// An empty-string value removes the corresponding key.
@objc public static func setStrings(_ values: [String: String]) {
guard !values.isEmpty else { return }
queue.async {
var current = loadUnsafe()
for (key, value) in values {
if value.isEmpty {
current.removeValue(forKey: key)
} else {
current[key] = value
}
}
writeUnsafe(current)
}
}
}
Loading
Loading