diff --git a/Examples/AblyPush/AblyPushExample.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/Examples/AblyPush/AblyPushExample.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 000000000..919434a62 --- /dev/null +++ b/Examples/AblyPush/AblyPushExample.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/Examples/LocalDeviceStorageBugTest/.gitignore b/Examples/LocalDeviceStorageBugTest/.gitignore new file mode 100644 index 000000000..dfa5e2e6b --- /dev/null +++ b/Examples/LocalDeviceStorageBugTest/.gitignore @@ -0,0 +1 @@ +/LocalDeviceStorageBugTest/Secrets.swift diff --git a/Examples/LocalDeviceStorageBugTest/LocalDeviceStorageBugTest.xcodeproj/project.pbxproj b/Examples/LocalDeviceStorageBugTest/LocalDeviceStorageBugTest.xcodeproj/project.pbxproj new file mode 100644 index 000000000..5b0dee066 --- /dev/null +++ b/Examples/LocalDeviceStorageBugTest/LocalDeviceStorageBugTest.xcodeproj/project.pbxproj @@ -0,0 +1,379 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 77; + objects = { + +/* Begin PBXBuildFile section */ + 2157A51E2F90250E00C526F5 /* Ably in Frameworks */ = {isa = PBXBuildFile; productRef = 2157A51D2F90250E00C526F5 /* Ably */; }; +/* End PBXBuildFile section */ + +/* Begin PBXFileReference section */ + 2157A50E2F90238800C526F5 /* LocalDeviceStorageBugTest.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = LocalDeviceStorageBugTest.app; sourceTree = BUILT_PRODUCTS_DIR; }; +/* End PBXFileReference section */ + +/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ + 21B9EEBB2F902D060021591A /* Exceptions for "LocalDeviceStorageBugTest" folder in "LocalDeviceStorageBugTest" target */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + Info.plist, + ); + target = 2157A50D2F90238800C526F5 /* LocalDeviceStorageBugTest */; + }; +/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */ + +/* Begin PBXFileSystemSynchronizedRootGroup section */ + 2157A5102F90238800C526F5 /* LocalDeviceStorageBugTest */ = { + isa = PBXFileSystemSynchronizedRootGroup; + exceptions = ( + 21B9EEBB2F902D060021591A /* Exceptions for "LocalDeviceStorageBugTest" folder in "LocalDeviceStorageBugTest" target */, + ); + path = LocalDeviceStorageBugTest; + sourceTree = ""; + }; +/* End PBXFileSystemSynchronizedRootGroup section */ + +/* Begin PBXFrameworksBuildPhase section */ + 2157A50B2F90238800C526F5 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 2157A51E2F90250E00C526F5 /* Ably in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 2157A5052F90238800C526F5 = { + isa = PBXGroup; + children = ( + 2157A5102F90238800C526F5 /* LocalDeviceStorageBugTest */, + 2157A50F2F90238800C526F5 /* Products */, + ); + sourceTree = ""; + }; + 2157A50F2F90238800C526F5 /* Products */ = { + isa = PBXGroup; + children = ( + 2157A50E2F90238800C526F5 /* LocalDeviceStorageBugTest.app */, + ); + name = Products; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 2157A50D2F90238800C526F5 /* LocalDeviceStorageBugTest */ = { + isa = PBXNativeTarget; + buildConfigurationList = 2157A5192F90238900C526F5 /* Build configuration list for PBXNativeTarget "LocalDeviceStorageBugTest" */; + buildPhases = ( + 2157A50A2F90238800C526F5 /* Sources */, + 2157A50B2F90238800C526F5 /* Frameworks */, + 2157A50C2F90238800C526F5 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + fileSystemSynchronizedGroups = ( + 2157A5102F90238800C526F5 /* LocalDeviceStorageBugTest */, + ); + name = LocalDeviceStorageBugTest; + packageProductDependencies = ( + 2157A51D2F90250E00C526F5 /* Ably */, + ); + productName = LocalDeviceStorageBugTest; + productReference = 2157A50E2F90238800C526F5 /* LocalDeviceStorageBugTest.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 2157A5062F90238800C526F5 /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = 1; + LastSwiftUpdateCheck = 2630; + LastUpgradeCheck = 2630; + TargetAttributes = { + 2157A50D2F90238800C526F5 = { + CreatedOnToolsVersion = 26.3; + }; + }; + }; + buildConfigurationList = 2157A5092F90238800C526F5 /* Build configuration list for PBXProject "LocalDeviceStorageBugTest" */; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 2157A5052F90238800C526F5; + minimizedProjectReferenceProxies = 1; + packageReferences = ( + 2157A51C2F90250E00C526F5 /* XCLocalSwiftPackageReference "../../../ably-cocoa" */, + ); + preferredProjectObjectVersion = 77; + productRefGroup = 2157A50F2F90238800C526F5 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 2157A50D2F90238800C526F5 /* LocalDeviceStorageBugTest */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 2157A50C2F90238800C526F5 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 2157A50A2F90238800C526F5 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin XCBuildConfiguration section */ + 2157A5172F90238900C526F5 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + DEVELOPMENT_TEAM = XXY98AVDR6; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 26.2; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = iphoneos; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + }; + name = Debug; + }; + 2157A5182F90238900C526F5 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + DEVELOPMENT_TEAM = XXY98AVDR6; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 26.2; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MTL_ENABLE_DEBUG_INFO = NO; + MTL_FAST_MATH = YES; + SDKROOT = iphoneos; + SWIFT_COMPILATION_MODE = wholemodule; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; + 2157A51A2F90238900C526F5 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_ENTITLEMENTS = LocalDeviceStorageBugTest/LocalDeviceStorageBugTest.entitlements; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = XXY98AVDR6; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = LocalDeviceStorageBugTest/Info.plist; + INFOPLIST_KEY_NSVoIPUsageDescription = "This app uses VoIP to deterministically test what happens when the app is launched before first unlock."; + INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UILaunchScreen_Generation = YES; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.ably.LocalDeviceStorageBugTest; + PRODUCT_NAME = "$(TARGET_NAME)"; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + 2157A51B2F90238900C526F5 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_ENTITLEMENTS = LocalDeviceStorageBugTest/LocalDeviceStorageBugTest.entitlements; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = XXY98AVDR6; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = LocalDeviceStorageBugTest/Info.plist; + INFOPLIST_KEY_NSVoIPUsageDescription = "This app uses VoIP to deterministically test what happens when the app is launched before first unlock."; + INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UILaunchScreen_Generation = YES; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.ably.LocalDeviceStorageBugTest; + PRODUCT_NAME = "$(TARGET_NAME)"; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 2157A5092F90238800C526F5 /* Build configuration list for PBXProject "LocalDeviceStorageBugTest" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 2157A5172F90238900C526F5 /* Debug */, + 2157A5182F90238900C526F5 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 2157A5192F90238900C526F5 /* Build configuration list for PBXNativeTarget "LocalDeviceStorageBugTest" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 2157A51A2F90238900C526F5 /* Debug */, + 2157A51B2F90238900C526F5 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + +/* Begin XCLocalSwiftPackageReference section */ + 2157A51C2F90250E00C526F5 /* XCLocalSwiftPackageReference "../../../ably-cocoa" */ = { + isa = XCLocalSwiftPackageReference; + relativePath = "../../../ably-cocoa"; + }; +/* End XCLocalSwiftPackageReference section */ + +/* Begin XCSwiftPackageProductDependency section */ + 2157A51D2F90250E00C526F5 /* Ably */ = { + isa = XCSwiftPackageProductDependency; + productName = Ably; + }; +/* End XCSwiftPackageProductDependency section */ + }; + rootObject = 2157A5062F90238800C526F5 /* Project object */; +} diff --git a/Examples/LocalDeviceStorageBugTest/LocalDeviceStorageBugTest.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/Examples/LocalDeviceStorageBugTest/LocalDeviceStorageBugTest.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 000000000..919434a62 --- /dev/null +++ b/Examples/LocalDeviceStorageBugTest/LocalDeviceStorageBugTest.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/Examples/LocalDeviceStorageBugTest/LocalDeviceStorageBugTest.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Examples/LocalDeviceStorageBugTest/LocalDeviceStorageBugTest.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved new file mode 100644 index 000000000..398e2d4ce --- /dev/null +++ b/Examples/LocalDeviceStorageBugTest/LocalDeviceStorageBugTest.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -0,0 +1,33 @@ +{ + "originHash" : "87e36dc35272852784b73789d77ac08b840b1887b534b591946036e7f579575b", + "pins" : [ + { + "identity" : "ably-cocoa-plugin-support", + "kind" : "remoteSourceControl", + "location" : "https://github.com/ably/ably-cocoa-plugin-support.git", + "state" : { + "revision" : "a290b8942086ffb6e21e4805d9319143669d9414", + "version" : "2.0.0" + } + }, + { + "identity" : "delta-codec-cocoa", + "kind" : "remoteSourceControl", + "location" : "https://github.com/ably/delta-codec-cocoa", + "state" : { + "revision" : "d53eec08f9443c6160d941327a6f9d8bbb93cea2", + "version" : "1.3.5" + } + }, + { + "identity" : "msgpack-objective-c", + "kind" : "remoteSourceControl", + "location" : "https://github.com/rvi/msgpack-objective-C", + "state" : { + "revision" : "3e36b48e04ecd756cb927bd5f5b9bf6d45e475f9", + "version" : "0.4.0" + } + } + ], + "version" : 3 +} diff --git a/Examples/LocalDeviceStorageBugTest/LocalDeviceStorageBugTest/ARTRealtimeChannel+Event.swift b/Examples/LocalDeviceStorageBugTest/LocalDeviceStorageBugTest/ARTRealtimeChannel+Event.swift new file mode 100644 index 000000000..b626444bb --- /dev/null +++ b/Examples/LocalDeviceStorageBugTest/LocalDeviceStorageBugTest/ARTRealtimeChannel+Event.swift @@ -0,0 +1,9 @@ +import Ably + +extension ARTRealtimeChannel { + /// Publishes an ``Event`` to this channel, using the event's ``Event/name`` + /// as the Ably message name and its JSON-encoded representation as the data. + func publish(_ event: Event) { + publish(event.name, data: event.toAblyData()) + } +} diff --git a/Examples/LocalDeviceStorageBugTest/LocalDeviceStorageBugTest/AppSettings.swift b/Examples/LocalDeviceStorageBugTest/LocalDeviceStorageBugTest/AppSettings.swift new file mode 100644 index 000000000..b0e5fee01 --- /dev/null +++ b/Examples/LocalDeviceStorageBugTest/LocalDeviceStorageBugTest/AppSettings.swift @@ -0,0 +1,41 @@ +import Foundation + +/// Persistent settings for the app, stored in a file with no data protection +/// so that they are readable even when the device is locked (i.e. before first +/// unlock). +struct AppSettings: Codable { + /// Whether to automatically call `push.activate()` on app launch. + var autoActivatePush: Bool = false + + /// Whether to automatically subscribe to the push channel on app launch + /// (after activation completes, if auto-activate is also enabled). + var autoSubscribeToPushChannel: Bool = false +} + +/// Reads and writes ``AppSettings`` to a JSON file with +/// `FileProtectionType.none`. +class AppSettingsStore { + static let shared = AppSettingsStore() + + private let fileURL: URL + + private init() { + let documentsDir = FileManager.default.urls( + for: .documentDirectory, + in: .userDomainMask + ).first! + fileURL = documentsDir.appendingPathComponent("settings.json") + } + + func load() -> AppSettings { + guard let data = try? Data(contentsOf: fileURL) else { + return AppSettings() + } + return (try? JSONDecoder().decode(AppSettings.self, from: data)) ?? AppSettings() + } + + func save(_ settings: AppSettings) { + let data = try! JSONEncoder().encode(settings) + try! data.write(to: fileURL, options: .noFileProtection) + } +} diff --git a/Examples/LocalDeviceStorageBugTest/LocalDeviceStorageBugTest/Assets.xcassets/AccentColor.colorset/Contents.json b/Examples/LocalDeviceStorageBugTest/LocalDeviceStorageBugTest/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 000000000..eb8789700 --- /dev/null +++ b/Examples/LocalDeviceStorageBugTest/LocalDeviceStorageBugTest/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,11 @@ +{ + "colors" : [ + { + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Examples/LocalDeviceStorageBugTest/LocalDeviceStorageBugTest/Assets.xcassets/AppIcon.appiconset/Contents.json b/Examples/LocalDeviceStorageBugTest/LocalDeviceStorageBugTest/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 000000000..230588010 --- /dev/null +++ b/Examples/LocalDeviceStorageBugTest/LocalDeviceStorageBugTest/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,35 @@ +{ + "images" : [ + { + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "tinted" + } + ], + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Examples/LocalDeviceStorageBugTest/LocalDeviceStorageBugTest/Assets.xcassets/Contents.json b/Examples/LocalDeviceStorageBugTest/LocalDeviceStorageBugTest/Assets.xcassets/Contents.json new file mode 100644 index 000000000..73c00596a --- /dev/null +++ b/Examples/LocalDeviceStorageBugTest/LocalDeviceStorageBugTest/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Examples/LocalDeviceStorageBugTest/LocalDeviceStorageBugTest/ContentView.swift b/Examples/LocalDeviceStorageBugTest/LocalDeviceStorageBugTest/ContentView.swift new file mode 100644 index 000000000..623836de6 --- /dev/null +++ b/Examples/LocalDeviceStorageBugTest/LocalDeviceStorageBugTest/ContentView.swift @@ -0,0 +1,327 @@ +// +// ContentView.swift +// LocalDeviceStorageBugTest +// +// Created by Lawrence Forooghian on 15/04/2026. +// + +import SwiftUI +import Ably +import Ably.Private +import PushKit +import CallKit + +private let eventsChannelName = "LocalDeviceStorageBugTest-events" +private let pushChannelName = "push-test" + +/// Custom log handler that publishes log messages to the events channel via `eventLoggingAbly`. +nonisolated class EventLoggingLogHandler: ARTLog { + private let eventsChannel: ARTRealtimeChannel + + init(eventsChannel: ARTRealtimeChannel) { + self.eventsChannel = eventsChannel + // Claude's speculation: with `import Ably.Private` in this file, + // `ARTLog -init` (which is `[self initCapturingOutput:true]`) + // reaches a Swift-synthesised trap body for the unoverridden + // `init(capturingOutput:)` on this subclass, firing a fatal + // error. Calling the 3-arg terminal designated initialiser + // directly avoids the self-dispatch and sidesteps the trap. + super.init(capturingOutput: true, historyLines: 100) + } + + override func log(_ message: String, with level: ARTLogLevel) { + let levelString: String + switch level { + case .verbose: levelString = "verbose" + case .debug: levelString = "debug" + case .info: levelString = "info" + case .warn: levelString = "warn" + case .error: levelString = "error" + case .none: levelString = "none" + @unknown default: levelString = "unknown" + } + + eventsChannel.publish(.ablyLog(.init(level: levelString, message: message))) + } +} + +/// Handles PushKit VoIP token registration and CallKit integration. +class PushHandler: NSObject, PKPushRegistryDelegate, CXProviderDelegate { + private let eventsChannel: ARTRealtimeChannel + private let pushRegistry: PKPushRegistry + private let callProvider: CXProvider + + init(eventsChannel: ARTRealtimeChannel) { + self.eventsChannel = eventsChannel + + let providerConfig = CXProviderConfiguration() + providerConfig.supportsVideo = false + self.callProvider = CXProvider(configuration: providerConfig) + + self.pushRegistry = PKPushRegistry(queue: nil) + + super.init() + + self.callProvider.setDelegate(self, queue: nil) + self.pushRegistry.delegate = self + self.pushRegistry.desiredPushTypes = [.voIP] + } + + // MARK: - PKPushRegistryDelegate + + func pushRegistry(_ registry: PKPushRegistry, didUpdate pushCredentials: PKPushCredentials, for type: PKPushType) { + let token = pushCredentials.token.map { String(format: "%02x", $0) }.joined() + eventsChannel.publish(.voipTokenUpdated(.init(token: token))) + } + + func pushRegistry(_ registry: PKPushRegistry, didReceiveIncomingPushWith payload: PKPushPayload, for type: PKPushType, completion: @escaping () -> Void) { + let payloadJSON = String( + data: try! JSONSerialization.data(withJSONObject: payload.dictionaryPayload), + encoding: .utf8 + )! + eventsChannel.publish(.voipPushReceived(.init(payloadJSON: payloadJSON))) + + // Must report a call to CallKit when receiving a VoIP push, or iOS will terminate the app. + let update = CXCallUpdate() + update.remoteHandle = CXHandle(type: .generic, value: "LocalDeviceStorageBugTest") + update.hasVideo = false + + callProvider.reportNewIncomingCall(with: UUID(), update: update) { error in + if let error { + print("Failed to report incoming call: \(error)") + } + completion() + } + } + + func pushRegistry(_ registry: PKPushRegistry, didInvalidatePushTokenFor type: PKPushType) { + eventsChannel.publish(.voipTokenInvalidated) + } + + // MARK: - CXProviderDelegate + + func providerDidReset(_ provider: CXProvider) {} + + func provider(_ provider: CXProvider, perform action: CXAnswerCallAction) { + action.fulfill() + } + + func provider(_ provider: CXProvider, perform action: CXEndCallAction) { + action.fulfill() + } +} + +/// Receives push activation/deactivation results from the SDK. +class PushActivationHandler: NSObject, ARTPushRegistererDelegate { + var onActivate: (@MainActor (ARTErrorInfo?) -> Void)? + + func didActivateAblyPush(_ error: ARTErrorInfo?) { + MainActor.assumeIsolated { + onActivate?(error) + } + } + + func didDeactivateAblyPush(_ error: ARTErrorInfo?) {} +} + +struct ContentView: View { + @State private var eventLoggingAbly: ARTRealtime? + @State private var eventsChannel: ARTRealtimeChannel? + @State private var mainAbly: ARTRealtime? + @State private var pushHandler: PushHandler? + @State private var pushActivationHandler: PushActivationHandler? + + @State private var settings = AppSettingsStore.shared.load() + @State private var activateResult: Result? + @State private var subscribeResult: Result? + + var body: some View { + VStack(spacing: 16) { + Text("LocalDeviceStorageBugTest") + .font(.headline) + + Divider() + + Button("Activate Push") { + activatePush(reason: .userTappedButton) + } + .disabled(mainAbly == nil) + + resultView(activateResult, successText: "Activated") + + Divider() + + Button("Subscribe to Push Channel") { + subscribeToPushChannel(reason: .userTappedButton) + } + .disabled(mainAbly == nil) + + resultView(subscribeResult, successText: "Subscribed to \(pushChannelName)") + + Divider() + + VStack(alignment: .leading, spacing: 8) { + Text("Settings") + .font(.headline) + + Toggle("Auto-activate push on launch", isOn: $settings.autoActivatePush) + .onChange(of: settings.autoActivatePush) { saveSettings() } + + Toggle("Auto-subscribe to push channel on launch", isOn: $settings.autoSubscribeToPushChannel) + .onChange(of: settings.autoSubscribeToPushChannel) { saveSettings() } + } + } + .padding() + .task { + setUp() + } + } + + private func activatePush(reason: ActionReason, then completion: (() -> Void)? = nil) { + let attemptID = UUID().uuidString + eventsChannel?.publish(.pushActivateAttempt(.init( + id: attemptID, + reason: reason + ))) + + pushActivationHandler?.onActivate = { error in + eventsChannel?.publish(.pushActivateResult(.init( + attemptID: attemptID, + error: error.map { CodableErrorInfo($0) }, + localDevice: CodableLocalDevice(mainAbly!.device), + userDefaultsContents: userDefaultsContentsSanitisedJSON() + ))) + if let error { + activateResult = .failure(error) + } else { + activateResult = .success(()) + completion?() + } + } + + mainAbly?.push.activate() + } + + private func subscribeToPushChannel(reason: ActionReason) { + let attemptID = UUID().uuidString + eventsChannel?.publish(.pushSubscribeAttempt(.init( + id: attemptID, + reason: reason, + channelName: pushChannelName + ))) + + mainAbly?.channels.get(pushChannelName).push.subscribeDevice { error in + eventsChannel?.publish(.pushSubscribeResult(.init( + attemptID: attemptID, + channelName: pushChannelName, + error: error.map { CodableErrorInfo($0) } + ))) + if let error { + subscribeResult = .failure(error) + } else { + subscribeResult = .success(()) + } + } + } + + private func resultView(_ result: Result?, successText: String) -> some View { + Group { + switch result { + case nil: + EmptyView() + case .success: + Text(successText) + .foregroundStyle(.green) + case .failure(let error): + Text("Error: \(error.message)") + .foregroundStyle(.red) + } + } + .font(.caption) + } + + private func saveSettings() { + AppSettingsStore.shared.save(settings) + } + + private func setUp() { + // Set up event logging Ably instance (Realtime, to preserve message ordering) + let clientId = "appInstallation-\(appInstallationID)" + + let eventLoggingOptions = ARTClientOptions(key: Secrets.ablyAPIKey) + eventLoggingOptions.clientId = clientId + // This client exists only to publish diagnostic events. Disable + // local-device functionality so that the first access to + // `rest.device_nosync` doesn't bind the shared `ARTLocalDevice` + // to this client's storage/logger and steal the main client's + // device-storage activity out of its logs. + eventLoggingOptions.testOptions.disableLocalDevice = true + let eventLogging = ARTRealtime(options: eventLoggingOptions) + let eventsChannel = eventLogging.channels.get(eventsChannelName) + self.eventLoggingAbly = eventLogging + self.eventsChannel = eventsChannel + + // Publish the app launched event before setting up anything else + eventsChannel.publish(.appLaunched(.init( + protectedDataAvailable: UIApplication.shared.isProtectedDataAvailable, + userDefaultsFileProtection: userDefaultsFileProtection(), + userDefaultsContents: userDefaultsContentsSanitisedJSON() + ))) + + // Set up PushKit VoIP registration and CallKit handler + self.pushHandler = PushHandler(eventsChannel: eventsChannel) + + // Set up push activation delegate + self.pushActivationHandler = PushActivationHandler() + + // Set up main Ably instance with custom log handler that publishes to the events channel. + // Use the appInstallationID as the clientId so that multiple device registrations from + // the same installation can be correlated (which is the failure mode we're investigating). + let mainOptions = ARTClientOptions(key: Secrets.ablyAPIKey) + mainOptions.clientId = clientId + mainOptions.logHandler = EventLoggingLogHandler(eventsChannel: eventsChannel) + mainOptions.logLevel = .verbose + mainOptions.pushRegistererDelegate = pushActivationHandler + // Include the actual persisted values in the storage-access log + // lines (rather than the default `(retracted)` placeholder). + // We're investigating what's being read/written, so we need + // visibility. This is a debug build of a test app; the values + // never leave this client's logs. + mainOptions.testOptions.logLocalDeviceStorageValues = true + let main = ARTRealtime(options: mainOptions) + self.mainAbly = main + mainAblyInstance = main + + // Observe subsequent protected data availability changes + NotificationCenter.default.addObserver( + forName: UIApplication.protectedDataDidBecomeAvailableNotification, + object: nil, + queue: .main + ) { _ in + self.eventsChannel?.publish(.protectedDataAvailability(.init( + isAvailable: true + ))) + } + + NotificationCenter.default.addObserver( + forName: UIApplication.protectedDataWillBecomeUnavailableNotification, + object: nil, + queue: .main + ) { _ in + self.eventsChannel?.publish(.protectedDataAvailability(.init( + isAvailable: false + ))) + } + + // Perform automatic actions based on settings + if settings.autoActivatePush { + activatePush(reason: .appLaunch) { + if settings.autoSubscribeToPushChannel { + subscribeToPushChannel(reason: .appLaunch) + } + } + } else if settings.autoSubscribeToPushChannel { + subscribeToPushChannel(reason: .appLaunch) + } + } +} diff --git a/Examples/LocalDeviceStorageBugTest/LocalDeviceStorageBugTest/Event.swift b/Examples/LocalDeviceStorageBugTest/LocalDeviceStorageBugTest/Event.swift new file mode 100644 index 000000000..23891e56e --- /dev/null +++ b/Examples/LocalDeviceStorageBugTest/LocalDeviceStorageBugTest/Event.swift @@ -0,0 +1,416 @@ +import Foundation +import Ably + +/// A unique identifier for this app installation, persisted across launches +/// but not across reinstallations. Stored in a file with no data protection. +let appInstallationID: String = { + let fileURL = FileManager.default + .urls(for: .documentDirectory, in: .userDomainMask).first! + .appendingPathComponent("installation-id.txt") + if let existing = try? String(contentsOf: fileURL, encoding: .utf8), !existing.isEmpty { + return existing + } + let id = UUID().uuidString + try! id.write(to: fileURL, atomically: true, encoding: .utf8) + try! FileManager.default.setAttributes( + [.protectionKey: FileProtectionType.none], + ofItemAtPath: fileURL.path + ) + return id +}() + +/// A unique identifier for this app launch, included in every event payload. +let appLaunchID = UUID().uuidString + +/// A `Codable` representation of `ARTErrorInfo`. +final class CodableErrorInfo: Codable { + /// The Ably error code. + var code: Int + + /// HTTP status code corresponding to this error, where applicable. + var statusCode: Int + + /// Additional message information. + var message: String + + /// The reason why the error occurred, where available. + var reason: String? + + /// A URL for additional help on the error code, where available. + var href: String? + + /// The request ID, if the failing request had one. + var requestId: String? + + /// Information pertaining to what caused the error, where available. + var cause: CodableErrorInfo? + + init(_ errorInfo: ARTErrorInfo) { + self.code = errorInfo.code + self.statusCode = Int(errorInfo.statusCode) + self.message = errorInfo.message + self.reason = errorInfo.reason + self.href = errorInfo.href + self.requestId = errorInfo.requestId + self.cause = errorInfo.cause.map { CodableErrorInfo($0) } + } + + private enum CodingKeys: String, CodingKey { + case code, statusCode, message, reason, href, requestId, cause + } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(code, forKey: .code) + try container.encode(statusCode, forKey: .statusCode) + try container.encode(message, forKey: .message) + try container.encodeIfPresent(reason, forKey: .reason) + try container.encodeIfPresent(href, forKey: .href) + try container.encodeIfPresent(requestId, forKey: .requestId) + try container.encodeIfPresent(cause, forKey: .cause) + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + code = try container.decode(Int.self, forKey: .code) + statusCode = try container.decode(Int.self, forKey: .statusCode) + message = try container.decode(String.self, forKey: .message) + reason = try container.decodeIfPresent(String.self, forKey: .reason) + href = try container.decodeIfPresent(String.self, forKey: .href) + requestId = try container.decodeIfPresent(String.self, forKey: .requestId) + cause = try container.decodeIfPresent(CodableErrorInfo.self, forKey: .cause) + } +} + +/// A `Codable` representation of `ARTLocalDevice`. +struct CodableLocalDevice: Codable { + /// The unique device ID. + var id: String + + /// The client ID the device is connected to Ably with. + var clientId: String? + + /// The platform (e.g. "ios"). + var platform: String + + /// The form factor (e.g. "phone", "tablet"). + var formFactor: String + + /// Key-value metadata for the device. + var metadata: [String: String] + + /// The push registration details. + var push: CodablePushDetails + + /// The device identity token details, if available. + var identityTokenDetails: CodableIdentityTokenDetails? + + /// The device secret, if available. + var secret: String? + + init(_ device: ARTLocalDevice) { + self.id = device.id as String + self.clientId = device.clientId + self.platform = device.platform + self.formFactor = device.formFactor + self.metadata = device.metadata + self.push = CodablePushDetails(device.push) + self.identityTokenDetails = device.identityTokenDetails.map { CodableIdentityTokenDetails($0) } + self.secret = device.secret as String? + } +} + +/// A `Codable` representation of `ARTDevicePushDetails`. +struct CodablePushDetails: Codable { + /// The push transport and address. + var recipient: [String: String] + + /// The current state of the push registration. + var state: String? + + /// The most recent error when the state is Failing or Failed. + var errorReason: CodableErrorInfo? + + init(_ details: ARTDevicePushDetails) { + self.recipient = (details.recipient as NSDictionary as? [String: String]) ?? [:] + self.state = details.state + self.errorReason = details.errorReason.map { CodableErrorInfo($0) } + } +} + +/// A `Codable` representation of `ARTDeviceIdentityTokenDetails`. +struct CodableIdentityTokenDetails: Codable { + /// The token string. + var token: String + + /// When the token was issued. + var issued: Date + + /// When the token expires. + var expires: Date + + /// The capability JSON string. + var capability: String + + /// The client ID assigned to the token, if any. + var clientId: String? + + init(_ details: ARTDeviceIdentityTokenDetails) { + self.token = details.token + self.issued = details.issued + self.expires = details.expires + self.capability = details.capability + self.clientId = details.clientId + } +} + +/// The reason an action was performed (e.g. push activation, channel subscription). +enum ActionReason: String, Codable { + /// The user tapped a button in the UI. + case userTappedButton + + /// The action was triggered automatically on app launch, based on settings. + case appLaunch +} + +/// An event emitted by the app, published to the events channel for external observation. +enum Event: Codable { + /// The app has launched. Published before any other event. + case appLaunched(AppLaunched) + + /// A log message emitted by `mainAbly`'s custom log handler. + case ablyLog(Log) + + /// PushKit provided a new VoIP device token. + case voipTokenUpdated(VoIPToken) + + /// A VoIP push notification was received from PushKit. + case voipPushReceived(VoIPPush) + + /// PushKit invalidated the VoIP device token. + case voipTokenInvalidated + + /// A call to `push.activate()` was initiated. + case pushActivateAttempt(PushActivateAttempt) + + /// A call to `push.activate()` completed. + case pushActivateResult(PushActivateResult) + + /// A call to `push.subscribeDevice` was initiated. + case pushSubscribeAttempt(PushSubscribeAttempt) + + /// A call to `push.subscribeDevice` completed. + case pushSubscribeResult(PushSubscribeResult) + + /// The availability of protected data changed after launch. + case protectedDataAvailability(ProtectedDataAvailability) + + // MARK: - Payload types + + /// A log message from the SDK. + struct Log: Codable { + /// The log level (e.g. "verbose", "debug", "info", "warn", "error"). + var level: String + + /// The log message text. + var message: String + } + + /// A VoIP device token update. + struct VoIPToken: Codable { + /// The hex-encoded device token for VoIP pushes. + var token: String + } + + /// A received VoIP push notification. + struct VoIPPush: Codable { + /// The push notification payload, serialised as a JSON string + /// (since the raw `[AnyHashable: Any]` from PushKit is not `Codable`). + var payloadJSON: String + } + + struct PushActivateAttempt: Codable { + /// Unique identifier for this attempt. + var id: String + + /// Why the activation was performed. + var reason: ActionReason + } + + struct PushActivateResult: Codable { + /// The identifier of the attempt this result corresponds to. + var attemptID: String + + /// The error, or `nil` on success. + var error: CodableErrorInfo? + + /// The state of the local device at the time the result was received. + /// Useful for detecting whether device details (e.g. ID, secret) have + /// changed as a result of the SDK being unable to load persisted data. + var localDevice: CodableLocalDevice + + /// The contents of `UserDefaults.standard` after activation completed. + /// Compared with the dump in `appLaunched`, this shows whether the SDK + /// wrote new values during activation even if the file was previously + /// unavailable. + var userDefaultsContents: String + } + + struct PushSubscribeAttempt: Codable { + /// Unique identifier for this attempt. + var id: String + + /// Why the subscription was performed. + var reason: ActionReason + + /// The channel being subscribed to. + var channelName: String + } + + struct PushSubscribeResult: Codable { + /// The identifier of the attempt this result corresponds to. + var attemptID: String + + /// The channel that was subscribed to. + var channelName: String + + /// The error, or `nil` on success. + var error: CodableErrorInfo? + } + + struct AppLaunched: Codable { + /// Whether protected data was available at launch time. + var protectedDataAvailable: Bool + + /// The file protection level of the UserDefaults plist, or an error + /// string if it could not be read (e.g. the file does not yet exist on + /// a fresh install before any defaults have been written). The plist + /// path (`Library/Preferences/.plist`) is an implementation + /// detail of `NSUserDefaults` and not guaranteed by Apple. This is + /// where the SDK persists device details via `ARTLocalDeviceStorage`. + var userDefaultsFileProtection: String + + /// The entire contents of `UserDefaults.standard`, serialised as a + /// JSON string internally but inlined as a dictionary in the Ably + /// message payload by `toAblyData()`. Useful for seeing exactly what + /// the SDK has persisted (or failed to persist) at launch time. + var userDefaultsContents: String + } + + struct ProtectedDataAvailability: Codable { + /// Whether protected data is now available. + var isAvailable: Bool + } + + // MARK: - Codable + + private enum CodingKeys: String, CodingKey { + case appInstallationID + case appLaunchID + case appLaunched + case ablyLog + case voipTokenUpdated + case voipPushReceived + case voipTokenInvalidated + case pushActivateAttempt + case pushActivateResult + case pushSubscribeAttempt + case pushSubscribeResult + case protectedDataAvailability + } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(appInstallationID, forKey: .appInstallationID) + try container.encode(appLaunchID, forKey: .appLaunchID) + switch self { + case .appLaunched(let launched): + try container.encode(launched, forKey: .appLaunched) + case .ablyLog(let log): + try container.encode(log, forKey: .ablyLog) + case .voipTokenUpdated(let token): + try container.encode(token, forKey: .voipTokenUpdated) + case .voipPushReceived(let push): + try container.encode(push, forKey: .voipPushReceived) + case .voipTokenInvalidated: + try container.encodeNil(forKey: .voipTokenInvalidated) + case .pushActivateAttempt(let attempt): + try container.encode(attempt, forKey: .pushActivateAttempt) + case .pushActivateResult(let result): + try container.encode(result, forKey: .pushActivateResult) + case .pushSubscribeAttempt(let attempt): + try container.encode(attempt, forKey: .pushSubscribeAttempt) + case .pushSubscribeResult(let result): + try container.encode(result, forKey: .pushSubscribeResult) + case .protectedDataAvailability(let availability): + try container.encode(availability, forKey: .protectedDataAvailability) + } + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + if let launched = try container.decodeIfPresent(AppLaunched.self, forKey: .appLaunched) { + self = .appLaunched(launched) + } else if let log = try container.decodeIfPresent(Log.self, forKey: .ablyLog) { + self = .ablyLog(log) + } else if let token = try container.decodeIfPresent(VoIPToken.self, forKey: .voipTokenUpdated) { + self = .voipTokenUpdated(token) + } else if let push = try container.decodeIfPresent(VoIPPush.self, forKey: .voipPushReceived) { + self = .voipPushReceived(push) + } else if container.contains(.voipTokenInvalidated) { + self = .voipTokenInvalidated + } else if let attempt = try container.decodeIfPresent(PushActivateAttempt.self, forKey: .pushActivateAttempt) { + self = .pushActivateAttempt(attempt) + } else if let result = try container.decodeIfPresent(PushActivateResult.self, forKey: .pushActivateResult) { + self = .pushActivateResult(result) + } else if let attempt = try container.decodeIfPresent(PushSubscribeAttempt.self, forKey: .pushSubscribeAttempt) { + self = .pushSubscribeAttempt(attempt) + } else if let result = try container.decodeIfPresent(PushSubscribeResult.self, forKey: .pushSubscribeResult) { + self = .pushSubscribeResult(result) + } else if let availability = try container.decodeIfPresent(ProtectedDataAvailability.self, forKey: .protectedDataAvailability) { + self = .protectedDataAvailability(availability) + } else { + throw DecodingError.dataCorrupted( + .init(codingPath: decoder.codingPath, debugDescription: "No matching event case") + ) + } + } + + // MARK: - Ably message helpers + + /// The Ably message event name for this event. + var name: String { + switch self { + case .appLaunched: "appLaunched" + case .ablyLog: "ablyLog" + case .voipTokenUpdated: "voipTokenUpdated" + case .voipPushReceived: "voipPushReceived" + case .voipTokenInvalidated: "voipTokenInvalidated" + case .pushActivateAttempt: "pushActivateAttempt" + case .pushActivateResult: "pushActivateResult" + case .pushSubscribeAttempt: "pushSubscribeAttempt" + case .pushSubscribeResult: "pushSubscribeResult" + case .protectedDataAvailability: "protectedDataAvailability" + } + } + + /// Encodes this event as a JSON-compatible dictionary for use as Ably + /// message data. + func toAblyData() -> Any { + let jsonData = try! JSONEncoder().encode(self) + var dict = try! JSONSerialization.jsonObject(with: jsonData) as! [String: Any] + + // Inline JSON strings that should be dictionaries in the payload. + for key in ["appLaunched", "pushActivateResult"] { + if var nested = dict[key] as? [String: Any], + let contentsString = nested["userDefaultsContents"] as? String, + let contentsData = contentsString.data(using: .utf8), + let contentsParsed = try? JSONSerialization.jsonObject(with: contentsData) { + nested["userDefaultsContents"] = contentsParsed + dict[key] = nested + } + } + + return dict + } +} diff --git a/Examples/LocalDeviceStorageBugTest/LocalDeviceStorageBugTest/Info.plist b/Examples/LocalDeviceStorageBugTest/LocalDeviceStorageBugTest/Info.plist new file mode 100644 index 000000000..ad67d777a --- /dev/null +++ b/Examples/LocalDeviceStorageBugTest/LocalDeviceStorageBugTest/Info.plist @@ -0,0 +1,10 @@ + + + + + UIBackgroundModes + + voip + + + diff --git a/Examples/LocalDeviceStorageBugTest/LocalDeviceStorageBugTest/LocalDeviceStorageBugTest.entitlements b/Examples/LocalDeviceStorageBugTest/LocalDeviceStorageBugTest/LocalDeviceStorageBugTest.entitlements new file mode 100644 index 000000000..903def2af --- /dev/null +++ b/Examples/LocalDeviceStorageBugTest/LocalDeviceStorageBugTest/LocalDeviceStorageBugTest.entitlements @@ -0,0 +1,8 @@ + + + + + aps-environment + development + + diff --git a/Examples/LocalDeviceStorageBugTest/LocalDeviceStorageBugTest/LocalDeviceStorageBugTestApp.swift b/Examples/LocalDeviceStorageBugTest/LocalDeviceStorageBugTest/LocalDeviceStorageBugTestApp.swift new file mode 100644 index 000000000..db2decae8 --- /dev/null +++ b/Examples/LocalDeviceStorageBugTest/LocalDeviceStorageBugTest/LocalDeviceStorageBugTestApp.swift @@ -0,0 +1,35 @@ +// +// LocalDeviceStorageBugTestApp.swift +// LocalDeviceStorageBugTest +// +// Created by Lawrence Forooghian on 15/04/2026. +// + +import SwiftUI +import Ably + +/// Shared reference to `mainAbly`, so the app delegate can forward APNs tokens. +var mainAblyInstance: ARTRealtime? + +class AppDelegate: NSObject, UIApplicationDelegate { + func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) { + guard let mainAbly = mainAblyInstance else { return } + ARTPush.didRegisterForRemoteNotifications(withDeviceToken: deviceToken, realtime: mainAbly) + } + + func application(_ application: UIApplication, didFailToRegisterForRemoteNotificationsWithError error: any Error) { + guard let mainAbly = mainAblyInstance else { return } + ARTPush.didFailToRegisterForRemoteNotificationsWithError(error, realtime: mainAbly) + } +} + +@main +struct LocalDeviceStorageBugTestApp: App { + @UIApplicationDelegateAdaptor private var appDelegate: AppDelegate + + var body: some Scene { + WindowGroup { + ContentView() + } + } +} diff --git a/Examples/LocalDeviceStorageBugTest/LocalDeviceStorageBugTest/UserDefaultsFileProtection.swift b/Examples/LocalDeviceStorageBugTest/LocalDeviceStorageBugTest/UserDefaultsFileProtection.swift new file mode 100644 index 000000000..7535fb5ad --- /dev/null +++ b/Examples/LocalDeviceStorageBugTest/LocalDeviceStorageBugTest/UserDefaultsFileProtection.swift @@ -0,0 +1,58 @@ +import Foundation + +/// Returns the file protection level of the UserDefaults plist file. +/// +/// The plist path (`Library/Preferences/.plist`) is an +/// implementation detail of `NSUserDefaults` and not guaranteed by Apple. +/// The file may not exist on a fresh install before any defaults have been +/// written; in that case this returns an error string. +func userDefaultsFileProtection() -> String { + let bundleId = Bundle.main.bundleIdentifier ?? "unknown" + let prefsDir = FileManager.default + .urls(for: .libraryDirectory, in: .userDomainMask).first! + .appendingPathComponent("Preferences") + let plistURL = prefsDir.appendingPathComponent("\(bundleId).plist") + + do { + let attrs = try FileManager.default.attributesOfItem(atPath: plistURL.path) + if let protection = attrs[.protectionKey] as? FileProtectionType { + return protection.rawValue + } + return "unknown (attribute not present)" + } catch { + return "error: \(error.localizedDescription)" + } +} + +/// Returns the entire contents of `UserDefaults.standard` as a JSON string. +/// +/// Values that are not directly JSON-serialisable (e.g. `Data`) are +/// converted to a string representation. +func userDefaultsContentsSanitisedJSON() -> String { + let dict = UserDefaults.standard.dictionaryRepresentation() + let sanitised = dict.mapValues { makeJSONSafe($0) } + guard let data = try? JSONSerialization.data(withJSONObject: sanitised, options: [.sortedKeys]), + let json = String(data: data, encoding: .utf8) else { + return "{}" + } + return json +} + +private func makeJSONSafe(_ value: Any) -> Any { + switch value { + case let data as Data: + return "" + case let date as Date: + return date.description + case let array as [Any]: + return array.map { makeJSONSafe($0) } + case let dict as [String: Any]: + return dict.mapValues { makeJSONSafe($0) } + case is String, is Bool, is Int, is Double, is Float: + return value + case let number as NSNumber: + return number + default: + return String(describing: value) + } +} diff --git a/Examples/LocalDeviceStorageBugTest/README.md b/Examples/LocalDeviceStorageBugTest/README.md new file mode 100644 index 000000000..92675df5d --- /dev/null +++ b/Examples/LocalDeviceStorageBugTest/README.md @@ -0,0 +1,86 @@ +# LocalDeviceStorageBugTest + +A minimal iOS app for investigating push registration failures in ably-cocoa. + +## How it works + +The app creates two Ably client instances: + +- **`mainAbly`** (`ARTRealtime`) — the client under test. Its `clientId` is set to `appInstallation-` so that multiple device registrations from the same installation are easy to identify in the Ably dashboard — this is the failure mode under investigation (device ID gets unnecessarily recreated). +- **`eventLoggingAbly`** (`ARTRealtime`) — used solely to log events emitted by the app, by publishing them to the `LocalDeviceStorageBugTest-events` channel. Uses a Realtime connection to preserve message ordering. + +### VoIP push + +The app registers for VoIP pushes via PushKit. This gives us a way to deterministically launch the app before first unlock, which is important for reproducing the bug under investigation. A minimal CallKit handler is included to satisfy the iOS requirement that VoIP pushes must report an incoming call. + +### UI actions + +The app provides two buttons: + +- **Activate Push** — calls `push.activate()` on `mainAbly`, registering the device with Ably for push notifications. +- **Subscribe to Push Channel** — subscribes the device to push notifications on the `push-test` channel. + +Each shows its result (success or error) in the UI. + +### Settings + +A settings section provides toggles to automatically perform these actions on app launch: + +- **Auto-activate push on launch** +- **Auto-subscribe to push channel on launch** + +When both are enabled, the app activates first and then subscribes after activation succeeds. Settings are stored in a JSON file with `FileProtectionType.none`, so they are readable even when the device is locked (before first unlock). This is important because the app can be launched by a VoIP push before the user has unlocked the device. + +### Events + +All events published to the channel are defined by the `Event` enum in `Event.swift`. Every event payload includes: + +- **`appInstallationID`** — a UUID generated on first launch and persisted in an unprotected file. Stable across launches but reset on reinstallation. +- **`appLaunchID`** — a UUID generated fresh each launch. + +Current events: + +- **`appLaunched`** — published before any other event. Includes `protectedDataAvailable` (whether the device was unlocked at launch time), `userDefaultsFileProtection` (the file protection level of the `NSUserDefaults` plist, where `ARTLocalDeviceStorage` persists device details), and `userDefaultsContents` (a full dump of `UserDefaults.standard` so we can see exactly what the SDK has or hasn't persisted). +- **`ablyLog`** — a log message from the SDK (level and message text), captured via a custom `ARTLog` handler on `mainAbly`. +- **`voipTokenUpdated`** — PushKit provided a new VoIP device token. +- **`voipPushReceived`** — a VoIP push notification was received. +- **`voipTokenInvalidated`** — PushKit invalidated the VoIP device token. +- **`pushActivateAttempt`** / **`pushActivateResult`** — a call to `push.activate()` and its outcome. Linked by an attempt ID. The result includes a snapshot of the `ARTLocalDevice` and a dump of `UserDefaults.standard`, so that changes to device details (e.g. ID, secret) and persisted data can be detected by comparing with the `appLaunched` dump — this is useful for identifying cases where the SDK was unable to load persisted data (e.g. when launched before first unlock) and whether it wrote new values during activation. +- **`pushSubscribeAttempt`** / **`pushSubscribeResult`** — a call to `push.subscribeDevice` and its outcome. Linked by an attempt ID. +- **`protectedDataAvailability`** — published when protected data availability changes after launch (device locked/unlocked). + +Attempt events include a `reason` (`userTappedButton` or `appLaunch`). Result events include the full `ARTErrorInfo` on failure. + +## Setup + +1. Copy `Secrets.example.swift` to `LocalDeviceStorageBugTest/Secrets.swift` and insert your Ably API key. (`Secrets.swift` is gitignored.) +2. Enable message persistence on the events channel so that `send-voip-push.sh` (and anything else reading history) can find events beyond the default 2-minute retention: + ```sh + ably apps channel-rules create --name "LocalDeviceStorageBugTest-events" --persisted + ``` +3. Enable push on the push-test channel so that `push.subscribeDevice` works: + ```sh + ably apps channel-rules create --name "push-test" --push-enabled + ``` +4. Open `LocalDeviceStorageBugTest.xcodeproj` in Xcode. +4. Build and run on a physical device (PushKit does not deliver tokens on the simulator). + +To observe the events being published, subscribe to the `LocalDeviceStorageBugTest-events` channel (e.g. `ably channels subscribe LocalDeviceStorageBugTest-events`). + +## Sending a VoIP push + +The `send-voip-push.sh` script fetches the latest VoIP device token from the events channel and sends a push notification to APNs: + +```sh +APNS_AUTH_KEY_PATH=~/path/to/AuthKey.p8 \ +APNS_AUTH_KEY_ID=XXXXXXXXXX \ +APNS_TEAM_ID=XXXXXXXXXX \ +./send-voip-push.sh +``` + +This uses the sandbox APNs endpoint by default. Set `APNS_HOST=api.push.apple.com` for production. + +## Known reproduction issues + +- **iPad**: Sending a VoIP push to an iPad does not appear to show the incoming call screen or launch the app before first unlock. The same flow works on an iPhone. The reason is not yet known. +- **Slow launch before first unlock**: When the app is launched by a VoIP push before first unlock, it can take 10–15 seconds for the call screen to appear. This appears to be normal iOS behaviour. Rarely, it doesn't appear at all; sending the push again seems to work. diff --git a/Examples/LocalDeviceStorageBugTest/Secrets.example.swift b/Examples/LocalDeviceStorageBugTest/Secrets.example.swift new file mode 100644 index 000000000..8448fb479 --- /dev/null +++ b/Examples/LocalDeviceStorageBugTest/Secrets.example.swift @@ -0,0 +1,6 @@ +// Copy this file to Secrets.swift and insert your Ably API key. +// Secrets.swift is gitignored and will not be checked in. + +enum Secrets { + static let ablyAPIKey = "" +} diff --git a/Examples/LocalDeviceStorageBugTest/send-voip-push.sh b/Examples/LocalDeviceStorageBugTest/send-voip-push.sh new file mode 100755 index 000000000..a4d651417 --- /dev/null +++ b/Examples/LocalDeviceStorageBugTest/send-voip-push.sh @@ -0,0 +1,109 @@ +#!/bin/bash +set -euo pipefail + +# Fetches the latest VoIP device token from the Ably events channel, +# then sends a VoIP push notification to that device via APNs. +# +# Required environment variables: +# APNS_AUTH_KEY_PATH - path to your .p8 APNs auth key file +# APNS_AUTH_KEY_ID - the key ID (10-character string from App Store Connect) +# APNS_TEAM_ID - your Apple Developer team ID +# +# Optional: +# APNS_HOST - APNs host (default: api.sandbox.push.apple.com) + +CHANNEL_NAME="LocalDeviceStorageBugTest-events" +BUNDLE_ID="com.ably.LocalDeviceStorageBugTest" +APNS_TOPIC="${BUNDLE_ID}.voip" + +AUTH_KEY_PATH="${APNS_AUTH_KEY_PATH:?Set APNS_AUTH_KEY_PATH to your .p8 file}" +AUTH_KEY_ID="${APNS_AUTH_KEY_ID:?Set APNS_AUTH_KEY_ID to your key ID}" +TEAM_ID="${APNS_TEAM_ID:?Set APNS_TEAM_ID to your team ID}" +APNS_HOST="${APNS_HOST:-api.sandbox.push.apple.com}" + +# Step 1: Fetch the latest voipTokenUpdated event from channel history +echo "Fetching latest voipTokenUpdated from ${CHANNEL_NAME}..." + +TOKEN_EVENT=$(ably channels history "$CHANNEL_NAME" --json --limit 1000 \ + | jq -r '[.messages[] | select(.name == "voipTokenUpdated")] | first') + +if [ -z "$TOKEN_EVENT" ] || [ "$TOKEN_EVENT" = "null" ]; then + echo "Error: no voipTokenUpdated found in channel history" >&2 + exit 1 +fi + +TOKEN=$(echo "$TOKEN_EVENT" | jq -r '.data.voipTokenUpdated.token') +INSTALLATION_ID=$(echo "$TOKEN_EVENT" | jq -r '.data.appInstallationID') +LAUNCH_ID=$(echo "$TOKEN_EVENT" | jq -r '.data.appLaunchID') + +echo "Installation ID: ${INSTALLATION_ID}" +echo "Launch ID: ${LAUNCH_ID}" +echo "VoIP token: ${TOKEN}" + +# Step 2: Generate an APNs JWT +JWT=$(python3 -c " +import json, time, base64, subprocess, sys + +def base64url(data): + if isinstance(data, str): + data = data.encode() + return base64.urlsafe_b64encode(data).rstrip(b'=').decode() + +def der_to_raw(der): + \"\"\"Convert DER-encoded ECDSA signature to the raw r||s format that JWT requires.\"\"\" + assert der[0] == 0x30 + pos = 2 + assert der[pos] == 0x02 + r_len = der[pos + 1] + r = der[pos + 2 : pos + 2 + r_len] + pos += 2 + r_len + assert der[pos] == 0x02 + s_len = der[pos + 1] + s = der[pos + 2 : pos + 2 + s_len] + # Each component must be exactly 32 bytes for ES256 + r = r[-32:].rjust(32, b'\x00') + s = s[-32:].rjust(32, b'\x00') + return r + s + +key_id = sys.argv[1] +team_id = sys.argv[2] +key_path = sys.argv[3] + +header = base64url(json.dumps({'alg': 'ES256', 'kid': key_id})) +payload = base64url(json.dumps({'iss': team_id, 'iat': int(time.time())})) +signing_input = f'{header}.{payload}'.encode() + +result = subprocess.run( + ['openssl', 'dgst', '-sha256', '-sign', key_path], + input=signing_input, + capture_output=True, + check=True, +) +signature = base64url(der_to_raw(result.stdout)) + +print(f'{header}.{payload}.{signature}') +" "$AUTH_KEY_ID" "$TEAM_ID" "$AUTH_KEY_PATH") + +# Step 3: Send the VoIP push via APNs +echo "Sending VoIP push to ${APNS_HOST}..." + +RESPONSE=$(curl --silent --show-error \ + --http2 \ + --header "authorization: bearer ${JWT}" \ + --header "apns-topic: ${APNS_TOPIC}" \ + --header "apns-push-type: voip" \ + --header "apns-priority: 10" \ + --data '{"aps":{}}' \ + --write-out "\n%{http_code}" \ + "https://${APNS_HOST}/3/device/${TOKEN}") + +HTTP_BODY=$(echo "$RESPONSE" | sed '$d') +HTTP_STATUS=$(echo "$RESPONSE" | tail -1) + +if [ "$HTTP_STATUS" = "200" ]; then + echo "Push sent successfully." +else + echo "APNs returned HTTP ${HTTP_STATUS}:" >&2 + echo "$HTTP_BODY" >&2 + exit 1 +fi diff --git a/push-registration-failure-investigation.md b/push-registration-failure-investigation.md new file mode 100644 index 000000000..099a836b7 --- /dev/null +++ b/push-registration-failure-investigation.md @@ -0,0 +1,375 @@ +# Push registration failure investigation + +2026-03-20 + +## Customer report + +A customer using ably-cocoa 1.2.35 on iOS reported: + +- Error when subscribing to a channel: "Token deviceId does not match requested device operation" (error code 40100) +- Multiple active device push registrations for the same user/device in the Ably dashboard +- Three device registrations with different UUIDs but the same `clientId`, two sharing identical APNS tokens + +## What the server error means + +The error is raised by the realtime server when a request to `/push/deviceRegistrations/:deviceId` includes a device identity token whose embedded `deviceId` doesn't match the `:deviceId` in the URL path. It's a security check: a device token should only authorise operations on the device it was issued for. + +## Root cause + +The device ID and device identity token can get out of sync because they are stored in different places with different availability characteristics: + +| Field | Storage | Availability | +|---|---|---| +| Device ID | `NSUserDefaults` | Always | +| Device identity token | `NSUserDefaults` | Always | +| Device secret | Keychain (`kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly`) | Not until first unlock after reboot | + +When the app launches in the background before the user has unlocked the device after a reboot (e.g. woken by a push notification), the Keychain is inaccessible. The device loading code in `ARTLocalDevice.m` (`deviceWithStorage:logger:`) then: + +1. Loads `deviceId` from `NSUserDefaults` — succeeds +2. Loads `deviceSecret` from Keychain — fails (`errSecInteractionNotAllowed`), returns nil +3. Since `deviceSecret` is nil, generates a **new** device ID and secret pair (`generateAndPersistPairOfDeviceIdAndSecret`) +4. Loads the **old** identity token from `NSUserDefaults` — it's still there, tied to the old device ID + +The device is now in an inconsistent state: new ID, new secret, old identity token. When the activation state machine tries to sync or update the registration, it sends a request with the new device ID in the URL but authenticates with the old identity token (which contains the old device ID embedded in it). The server rejects this with error 40100. + +Each time this happens, it can also create a new orphaned registration on the server, explaining the multiple registrations the customer sees. + +### Full inventory of persisted data in ably-cocoa + +This is a comprehensive list of everything ably-cocoa persists. All persistence goes through `ARTLocalDeviceStorage`, which provides two storage mechanisms: `objectForKey:`/`setObject:forKey:` (backed by NSUserDefaults) and `secretForDevice:`/`setSecret:forDevice:` (backed by the Keychain, keyed by device ID). Everything persisted is push-related: + +| Key | Storage | What it is | Consequence of loss | +|---|---|---|---| +| `ARTDeviceId` | NSUserDefaults | Device UUID | If lost without also losing the secret and token, causes the mismatch bug. If lost alongside everything else, device re-registers on next `activate()`. | +| `ARTDeviceSecret` | Keychain (`kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly`), keyed by device ID | Device secret for authenticating push operations | Unavailable before first unlock. Loss triggers id/secret regeneration in current code, which is the root cause of this bug. | +| `ARTDeviceIdentityToken` | NSUserDefaults | Token returned by server after registration, used for authenticating push operations | Stored under a fixed key (not keyed by device ID), so survives id regeneration — this is the stale-token problem. If lost on its own, the device would need to re-register to get a new one. | +| `ARTClientId` | NSUserDefaults | Client identity associated with the device | If lost, falls back to the identity token's client ID (existing code at `ARTLocalDevice.m:90-93`). Low severity. | +| `ARTAPNSDeviceToken-default` | NSUserDefaults | APNS device token (default) | Recoverable from the platform — iOS provides a new one via `registerForRemoteNotifications`. Loss triggers a `GotPushDeviceDetails` event and a PATCH to update the server. Note: Apple's guidance is to ["never cache device tokens in local storage"](https://developer.apple.com/documentation/usernotifications/registering_your_app_with_apns), but this is better interpreted as "don't trust cached tokens without re-validating" — RSH8i requires validation against the platform on each launch. See [specification#25](https://github.com/ably/specification/issues/25). | +| `ARTAPNSDeviceToken-location` | NSUserDefaults | APNS device token (location) | Same as above. | +| `ARTPushActivationCurrentState` | NSUserDefaults | Persisted state machine state (archived object) | Defaults to `NotActivated` if lost. Device re-syncs or re-registers on next `activate()`. Causes unnecessary REST calls but is self-correcting. | +| `ARTPushActivationPendingEvents` | NSUserDefaults | Queued state machine events (archived array) | Defaults to empty array if lost. Pending events are dropped — may cause missed transitions but state machine recovers on next `activate()`. | + +**Storage mechanisms and their failure modes:** + +- **NSUserDefaults**: Backed by a plist file. Availability depends on the app's data protection class — the default is `NSFileProtectionCompleteUntilFirstUserAuthentication`, which has a similar availability window to the Keychain's `kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly`. Can also fail due to plist corruption. In practice, much more reliable than the Keychain, but not guaranteed always-available. +- **Keychain** (`kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly`): Not available until the user unlocks the device after a reboot. This is the primary cause of the bug. Read failures return nil without distinguishing "not found" from "inaccessible." Write failures are also possible when inaccessible. +- **`NSFileProtectionNone`** (proposed): Always available, no encryption at rest. Would eliminate the availability issue entirely but stores secrets in plain text on disk. + +### Historical context + +The Keychain accessibility attribute was changed from `kSecAttrAccessibleAlwaysThisDeviceOnly` (always available, but deprecated since iOS 12) to `kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly` in commit `a9e4b24` (Aug 2021). This was labelled "Catalyst kSecAttr warning fix" — it fixed a deprecation warning but introduced the before-first-unlock failure window. + +## Existing issues + +- [#1109 — "Push: new device secret created for preexisting ID"](https://github.com/ably/ably-cocoa/issues/1109) (March 2021) — the original report of this class of problem. PR [#1187](https://github.com/ably/ably-cocoa/pull/1187) (merged Oct 2021) partially fixed it by ensuring the secret is only regenerated alongside the device ID. But the root cause (Keychain inaccessibility, and the identity token not being cleared) was not addressed. +- [#1257 — "Respect RSH8j"](https://github.com/ably/ably-cocoa/issues/1257) (Dec 2021) — filed after investigating the above. Notes that when the ID is regenerated, the identity token may no longer match, and the state machine should transition to `NotActivated`. A WIP branch `RSH8j-transition-to-NotActivated-after-failing-to-load-LocalDevice` exists with commits from Jan–Feb 2022 but was never completed. +- [#1256 — "Respecting RSH8b"](https://github.com/ably/ably-cocoa/issues/1256) (Dec 2021) — about generating ID/secret lazily on activation rather than eagerly at device fetch time, per the spec. Would require making `id` nullable (breaking change). +- [#966 — "Non-persistent push state machine states conflict with RSH3"](https://github.com/ably/ably-cocoa/issues/966) (Jan 2020) — related issue about non-persistent state machine states. + +## Specification analysis + +The relevant spec point is RSH8j (now moved to RSH8a1 as part of this investigation): + +> If the `LocalDevice` `id` or `deviceSecret` attributes are not able to be loaded then those LocalDevice details must be discarded and the ActivationStateMachine machine should transition to the `NotActivated` state. + +### Problems with the spec as written + +1. **RSH8a1 doesn't say to clear the identity token.** "Those LocalDevice details" refers to `id` and `deviceSecret` only. But if the identity token survives, the `NotActivated` + `CalledActivate` path hits RSH3a2a (which checks for an existing identity token) before RSH3a2b (which would generate new credentials). So RSH3a2a tries to sync using the stale identity token with the new device ID, which fails. + +2. **RSH8a1 directly mutates the state machine state.** Every other RSH8 point that interacts with the state machine does so by sending events. RSH8a1 is the only one that bypasses the event-driven model and sets the state directly. This is a breakage of the state machine abstraction. + +3. **RSH8a1 doesn't distinguish between load contexts.** RSH8a says the `LocalDevice` is initialised "when first required, either as a result of a call to `RestClient#device` or `RealtimeClient#device`, or as a result of an operation involving the Activation State Machine." If the load failure happens during an explicit `client.device` fetch, a side-effect transition to `NotActivated` is reasonable. If it happens during state machine event handling, silently resetting the state from underneath the event handler is problematic. + +4. **Multiple spec points overlap in describing when id/deviceSecret are generated.** RSH8a (loading), RSH8b (generation, delegating to RSH3a2b), RSH8a1 (load failure), and RSH3a2b itself all touch on the same lifecycle. The RSH8k2 note acknowledges that implementations don't even agree on whether generation is eager or lazy. + +### How the three SDKs differ + +| Behaviour | ably-cocoa | ably-js | ably-java | +|---|---|---|---| +| id/secret generation | Eager (at device fetch) | Eager (at device fetch) | Lazy (on `CalledActivate`, per RSH3a2b) | +| RSH8a1 implementation | Regenerates id/secret but doesn't clear identity token or reset state machine | No implementation; assumes if id loads, secret loads too | No implementation observed | +| Storage for secret | Keychain (different availability from id) | Same storage as id | Same storage as id (`SharedPreferences`) | + +## Spec assumptions about storage + +There are two things to note about how the spec relates to storage: + +**The spec assumes LocalDevice is a single atomic blob.** It doesn't anticipate implementations splitting storage across mechanisms with different availability characteristics. ably-cocoa does this (Keychain for secret, NSUserDefaults for everything else), and this is the root cause of the bug. Our proposed RSH8a2 would acknowledge that implementations may split storage and define the atomicity requirement, plus RSH8a2a for ably-cocoa's specific legacy situation. However, we have not yet proposed a concrete mechanism for how ably-cocoa would achieve atomicity going forward — that is the "move to always-available storage" work described in the "Storage availability" section below. + +**The spec's failure recovery (RSH3h1) is a safety net, not a routine code path.** RSH3h1 handles load failures by discarding everything and starting in `NotActivated`. This works correctly in isolation, but the consequences of it firing routinely are not addressed: orphaned registrations accumulate on the server, push channel subscriptions are lost, and there is no mechanism for cleaning up. Paddy's [comment on #1109](https://github.com/ably/ably-cocoa/issues/1109#issuecomment-934163390) — "we need to use a persistence mechanism for the device registration and secret that is always available" — suggests this was never intended to be a routine occurrence. If ably-cocoa ships the spec changes without also fixing the storage to be always-available, the recovery behaviour would be correct but would fire too frequently, with accumulating side effects. + +### What should be stored atomically? + +The proposed RSH8a2 specifies atomicity for the (`id`, `deviceSecret`, `deviceIdentityToken`) tuple. But it's worth considering whether the spec's model actually implies a larger atomic unit. + +The state machine state carries assumptions about which LocalDevice properties exist — e.g. `WaitingForNewPushDeviceDetails` assumes id, secret, and token are all present. Our proposed RSH3h says "the persisted activation state is only valid if the LocalDevice details it depends on are available." This means the state machine state and the LocalDevice data are logically one unit: if one changes without the other, the assumptions are violated. + +Looking at the full set of persisted items: + +- **(`id`, `deviceSecret`, `deviceIdentityToken`)**: critical. Out-of-sync state causes the 40100 error and the broken error loop that prompted this investigation. RSH8a2 proposes atomicity for this tuple. +- **`clientId`**: has a fallback (loaded from the identity token if missing, `ARTLocalDevice.m:90-93`). If out of sync with the rest, the worst case is RSH3a2a1 detecting a mismatch and firing `SyncRegistrationFailed` with error 61002, which the state machine handles. +- **APNS tokens**: issued by Apple for the physical device, not tied to the Ably device id. Always valid for the physical device regardless of which Ably device id is in use. Re-validated against the platform on each launch (RSH8i). Can't meaningfully be "out of sync" with the device id. +- **State machine state and pending events**: if lost, defaults to `NotActivated` with an empty queue. Self-corrects on next `activate()` — causes an unnecessary re-sync or re-registration but not a broken state. + +So the (`id`, `deviceSecret`, `deviceIdentityToken`) tuple is the only group where atomicity is critical for correctness. The other items either self-correct or have fallbacks. However, the state machine state is logically part of the same unit — it just happens that losing it is recoverable. + +In ably-java and ably-js, everything is stored in the same mechanism (SharedPreferences / localStorage), so atomicity is effectively achieved by accident. If ably-cocoa is redesigning its storage, it may be simplest to store the entire set as one blob rather than reasoning about which subsets need atomicity. This would also avoid future bugs if new persisted fields are added that have dependencies we haven't anticipated. + +## Proposed spec changes + +We are drafting spec changes (on the `2026-03-20-investigating-ably-cocoa-push-registration-failures` branch of the specification repo) that address the issues above. The key changes are: + +- **RSH3h**: Require the state machine to load and verify `LocalDevice` details at init time, before processing any events. If the load fails, discard everything and start in `NotActivated`. +- **RSH8a2**: Require the (`id`, `deviceSecret`, `deviceIdentityToken`) tuple to be persisted and loaded atomically. If an implementation splits storage (e.g. Keychain for secrets), it must provide a mechanism to detect when the loaded tuple doesn't match what was persisted. +- **RSH8a2a**: For legacy data without an atomicity mechanism, check invariants (id and secret both present or both absent, token only if id and secret present). +- **RSH3i** (`ValidatingRegistration` state): For legacy data that passes the invariant check but can't be locally verified (all three fields present but token may belong to a different id), validate against the server before accepting the data. + +### Understanding RSH3a2a — the existing "validation" mechanism + +RSH3a2a fires in `NotActivated` when `CalledActivate` is received and the device has a `deviceIdentityToken`. It does a PUT to `/push/deviceRegistrations/:deviceId` with the full device details, authenticating with the token. The spec describes this as "performs a validation of the local DeviceDetails." If the PUT succeeds (`RegistrationSynced`), the device is confirmed as registered. If it fails (`SyncRegistrationFailed`), the state machine goes to `AfterRegistrationSyncFailed`. + +**What is it actually validating?** Not the token specifically — it's syncing the device's current state with the server ("am I still registered, and here are my current details"). The token is just the authentication mechanism for this sync. The token's validity is tested as a side effect of authenticating the request. + +**When would you be in `NotActivated` with a token?** We traced all the paths into `NotActivated`: +- RSH3g2c (after deregistration): RSH3g2a clears all device details including the token +- RSH3b2b (`CalledDeactivate` in `WaitingForPushDeviceDetails`): no token at this stage +- RSH3b4b (`GettingPushDeviceDetailsFailed`): no token at this stage +- RSH3c3b (`GettingDeviceRegistrationFailed`): no token at this stage + +None of these paths leave a token in place. So RSH3a2a doesn't appear to be reachable through normal state machine transitions. It seems to exist only for the case where the persisted state is `NotActivated` but the device data (including a token) somehow survived — exactly the kind of state/data inconsistency our RSH3h is designed to catch. It's a pre-existing attempt to handle this edge case, but without an explicit acknowledgement of why you'd be in that state. + +**Could we hook into it for our recovery?** If we kept the token and started in `NotActivated`, RSH3a2a would fire automatically on the next `CalledActivate`. If the token is valid, the PUT succeeds and we're done — the existing mechanism handles it. The problem is when the token is invalid: the PUT fails with 401, `SyncRegistrationFailed` fires, the state goes to `AfterRegistrationSyncFailed`, and from there the next `CalledActivate` does the same as RSH3a2a again (RSH3f1a) (RSH3f1a) — the same loop we identified at the start of this investigation. RSH3a2a's failure path has no special handling for 401; it just loops. + +A possible approach: modify `AfterRegistrationSyncFailed` (or `WaitingForRegistrationSync`) to detect that the sync failed with a 401, discard the token, and fall through to RSH3a2b onwards (fresh registration with existing id/secret). This would fix the loop for all cases — not just legacy migration — meaning any device that ends up with a mismatched token would self-recover through the existing state machine. However, this would be a change to the general sync failure handling, not scoped to the legacy migration case. We have not yet explored this option in the spec changes. + +### The legacy validation problem + +In ably-cocoa, the `deviceSecret` is stored in the Keychain keyed by device ID, so if we can load a secret for a given id, we know they match. The `deviceIdentityToken` is stored in `NSUserDefaults` under a fixed key, not keyed by device id. So we can have all three fields present and loadable, but the token may have been issued for a previous device id that was since overwritten. + +We know the secret is correct for the current device id because it is stored in the Keychain keyed by device id — if we load a secret for id=C, it must be the secret that was generated alongside id=C. The token, by contrast, is stored in `NSUserDefaults` under a fixed key (`ARTDeviceIdentityToken`), not keyed by device id. It is also opaque to the client — we cannot inspect it to see which device id it was issued for. So while we can trust the (id, secret) binding locally, we cannot verify the (id, token) binding without a server round-trip. + +This is a narrow case: legacy data, no atomicity mechanism, all three fields present and loadable, but we can't locally verify the token belongs to the current id. Once the atomicity mechanism (RSH8a2) is in place, this state is never entered again. + +### Options for the server validation (RSH3i) + +**Option 1: Validate the token with a non-mutating GET.** Make a GET request to `/push/deviceRegistrations/:deviceId` authenticated with the `X-Ably-DeviceToken` header. If it succeeds (200), the token matches the device id and the tuple is consistent — persist with the atomicity mechanism and proceed. If it fails (40100), the token is stale — discard everything and transition to `NotActivated`. On the next `activate()`, the device goes through the clean registration path with a new id and secret. + +- Pros: simple, non-mutating, doesn't need the custom `registerCallback` +- Cons: if the token is stale, we discard the (id, secret) pair even though they may be valid on the server — this orphans the server-side registration and forces a full re-registration + +**Option 2: Recreate the token using the secret.** Make a PATCH request to `/push/deviceRegistrations/:deviceId` authenticated with the `X-Ably-DeviceSecret` header. The server validates the (id, secret) pair, and the response includes a fresh `deviceIdentityToken`. If it succeeds, persist the fresh tuple with the atomicity mechanism and proceed. If it fails, the (id, secret) pair isn't registered — discard everything and transition to `NotActivated`. + +- Pros: preserves the existing registration if the (id, secret) is valid; no orphaned registrations +- Cons: mutating (though in practice a PATCH with no body changes nothing); the spec provides a custom `registerCallback` that users can substitute for registration HTTP calls, but the callback has no way to distinguish between the different types of registration request (POST/PUT/PATCH) — it's unclear whether calling it here would do the right thing, and we'd need to decide whether to use it or bypass it + +**Option 3: Use the secret for all subsequent authentication and abandon the token.** Instead of validating or recreating the token, just use the device secret for all requests. + +- Pros: simple, no server round-trip needed +- Cons: unknown consequences — the token exists for a reason (the spec calls it `deviceIdentityToken` / docs call it `updateToken`); unclear what functionality would be lost by not having it; would diverge from the spec's authentication model + +### How much do we care about preserving the registration? + +A device that reaches the legacy validation path (RSH8a2a2) has legacy data without an atomicity mechanism, all three fields present, but a token that may belong to a different device id. There are two scenarios: either the token is actually valid (the device was never affected by the Keychain bug — e.g. it was never launched in the background before first unlock), or the token is stale (the device has been through one or more cycles of id/secret regeneration). In the latter case, the server may already have multiple orphaned registrations, and the cost of one more is low. In the former case, the registration is still valid and preserving it would avoid an unnecessary re-registration. + +Both directions below preserve the device id (and any push channel subscriptions tied to it), since on rejection both end up in the same normal registration flow (RSH3a2b onwards). The `registerCallback` is already used for POST, PUT, and PATCH cases without distinction (RSH3b3a, RSH3d3a, RSH3a2a2), so it works in both directions too. + +### Direction A: Validate the token, then re-register if invalid + +This is option 1 above, implemented as two new state machine states: +- `ValidatingDeviceIdentityToken` (RSH3i): on `CalledActivate`, makes a non-mutating GET to check the token +- `WaitingForDeviceIdentityTokenValidation` (RSH3j): handles the result — if valid, persist with atomicity; if rejected (401), discard token and re-register via RSH3a2b onwards; if other error, report and let the user retry + +This is currently written into the spec on the `2026-03-20-investigating-ably-cocoa-push-registration-failures` branch (commits `faa0fb8` through `25e0fd6`). + +The spec currently discards all `LocalDevice` details on rejection and re-registers fresh (RSH3j2a/RSH3j2b). There is a TODO in RSH3j2a to investigate whether it's possible instead to preserve the (id, secret) pair and regenerate only the token, since the secret is known to be valid for the current id. One possible approach: use a PATCH authenticated with `X-Ably-DeviceSecret` (like RSH3d3b), which the server accepts (confirmed in `rest_push.ts:322-324`) and which returns a fresh `deviceIdentityToken` (confirmed in `rest_push.ts:1814-1825`). This would preserve the existing registration. If the PATCH fails (e.g. device not registered), fall back to fresh registration. Having a dedicated state makes this kind of special-casing possible, whereas direction B would need to rely on the POST's undocumented upsert behaviour. + +Pros: +- Only re-registers when the token is actually invalid +- Clear separation of validation and registration +- The dedicated state provides a path to preserve the registration via PATCH in the future (see TODO in RSH3j2a) + +Cons: +- Adds two new states and three new events for a one-time migration path + +### Direction B: Skip validation, just re-register + +Not yet written into the spec. + +Instead of validating the token against the server, simply discard it and start in `NotActivated`. On the next `CalledActivate`: +1. RSH3a2a doesn't apply (no token) +2. RSH3a2b: device already has id/secret → skip generation +3. RSH3a2c onwards: normal registration flow +4. RSH3b3b: POST to `/push/deviceRegistrations` — the server treats this as an upsert (confirmed by reading the realtime server code: `upsertDeviceRegistration` at `rest_push.ts:1768` handles both new and existing registrations via `postDeviceRegistration`) +5. Server returns a fresh `deviceIdentityToken` + +Pros: +- No new states or events — the existing state machine handles everything + +Cons: +- Every device upgrading from legacy data makes a POST on first activation, even if the token was actually valid (one-time cost) +- Relies on the server treating POST as an upsert, which is current behaviour but not explicitly part of the spec + +### Direction C: Hook into the existing RSH3a2a validation mechanism + +Not yet written into the spec. + +In the RSH8a2a2 case (legacy data, all three present, no atomicity), instead of discarding the token, keep it and start in `NotActivated`. On `CalledActivate`, RSH3a2a fires automatically — it does a PUT to sync the device details with the server, authenticating with the token. If the token is valid, the PUT succeeds and we're done with no additional mechanism needed. + +The problem is the failure path: if the token is invalid (401), RSH3a2a fires `SyncRegistrationFailed`, which leads to `AfterRegistrationSyncFailed`, which on the next `CalledActivate` does the same as RSH3a2a again (RSH3f1a) — the same loop we identified at the start. To make this work, we'd need to modify the sync failure handling: if the sync fails with a 401, discard the token and fall through to RSH3a2b onwards (fresh registration with existing id/secret). + +This change would not be scoped to the legacy migration case — it would fix the loop for *all* cases where a device ends up with a mismatched token, making it a general improvement to the state machine's resilience. However, it requires a better understanding of what RSH3a2a is for and whether modifying its failure path would have unintended consequences (see "Understanding RSH3a2a" above). + +Pros: +- No new states or events for the migration case — uses the existing validation mechanism +- Fixes the token-mismatch loop for all cases, not just legacy migration +- If the token is valid, no unnecessary re-registration or server round-trip beyond what RSH3a2a already does + +Cons: +- Requires modifying the general sync failure handling (AfterRegistrationSyncFailed / WaitingForRegistrationSync), which affects all devices, not just those migrating from legacy data +- RSH3a2a's purpose and reachability are not fully understood (see above) — we need more clarity before changing its failure path +- By the time we're in `AfterRegistrationSyncFailed` with a 401, the context of why we got here is lost. We can't distinguish "401 because we kept a stale token during legacy migration" from some other 401 during a sync. This means we can't produce a helpful log message about legacy recovery, and if there's a case where discarding the token on 401 is the wrong response, we've applied it too broadly. + +### Current recommendation + +We have not yet reached a decision. Direction A is fully specced and works but adds complexity for a one-time migration. Direction B is simpler but relies on undocumented server behaviour. Direction C is the most elegant and fixes the problem generally, but requires more investigation into RSH3a2a and the consequences of modifying the sync failure path. + +## Storage availability + +The spec changes above address the immediate problem (detecting and recovering from inconsistent data), but there is a deeper issue: the Keychain can be temporarily unavailable (before first unlock after reboot), and even NSUserDefaults availability depends on the app's data protection class (the default is `NSFileProtectionCompleteUntilFirstUserAuthentication`, which has the same availability window as the Keychain's `kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly`). The only guaranteed-always-available storage on iOS is a file with `NSFileProtectionNone`, which provides no encryption at rest. + +This means even with our spec changes, if the app launches before first unlock, the SDK could end up in a regeneration loop: RSH3h1 discards and resets to `NotActivated`, `activate()` generates new id/secret, the write fails because storage is unavailable, next launch discards again, and so on until the user unlocks. + +### Proposed approach for ably-cocoa + +**Default storage: always-available, no encryption.** Store the (`id`, `deviceSecret`, `deviceIdentityToken`) tuple in a file with `NSFileProtectionNone` (or equivalent always-available mechanism). This means: +- `client.device` works synchronously as it does today +- `activate()` works immediately regardless of device lock state +- No availability issues, no regeneration loop +- This is what ably-java and ably-js effectively do today (SharedPreferences / localStorage) + +The trade-off is that the device secret is stored unencrypted on disk. The blast radius of a compromised device secret is limited to push operations for that specific device (updating/deleting the registration, subscribing to push channels). It does not grant access to the Ably API key or to publish/subscribe on channels. On a jailbroken/rooted device where the secret could be extracted, the attacker likely has access to far more than just the device secret. + +**Pluggable secure storage (optional).** For customers who require encrypted credential storage (e.g. due to regulatory requirements, as raised in [ably-java#593](https://github.com/ably/ably-java/issues/593)), provide a pluggable storage interface. The user supplies their own storage implementation (e.g. backed by the Keychain) which: +- Exposes an async loading API that can indicate "not yet available" +- Provides a mechanism for subscribing to availability events (on iOS, this maps to `UIApplication.isProtectedDataAvailable` and `UIProtectedDataDidBecomeAvailable`) + +If secure storage is used, `client.device` would need a new async variant, and the state machine (RSH3h) would defer loading rather than discarding when storage is temporarily unavailable. This is a larger piece of work and can be done separately from the immediate fix. + +### Migrating existing data + +The migration from Keychain to always-available storage is closely related to the RSH8a2a legacy data handling: + +1. On first launch with the new SDK, check the new always-available storage — empty (nothing migrated yet). +2. Fall back to loading from old locations (NSUserDefaults for id/token, Keychain for secret). +3. If everything loads successfully, write to the new storage with the atomicity mechanism (RSH8a2), then proceed normally. Subsequent launches use the new storage and this migration code never runs again. +4. If the load is partial (e.g. Keychain unavailable before first unlock), the RSH8a2a invariant check catches it (id present, secret absent → violation → RSH3h1 → discard → `NotActivated`). +5. If all three load but the token might be stale, RSH8a2a2 triggers the `ValidatingDeviceIdentityToken` flow (RSH3i). + +The problem case is: the user upgrades the app, and the first launch with the new SDK happens before first unlock. The Keychain is unavailable, so the old secret can't be read, so migration can't complete. The device loses its existing push registration and has to re-activate after unlock. + +We considered alternatives to avoid this: + +1. **Wait for the Keychain to become available before migrating.** This avoids losing the registration, but `client.device` can't return a complete device until migration is done — there's no way to satisfy the non-nullable `id` contract while waiting. Building an async mechanism just for a one-time migration is disproportionate. + +2. **Store a "migration pending" flag and keep trying on each launch.** Don't discard anything until migration succeeds; don't process events until then. Similar problem: `client.device` can't return a complete device during the pending period. + +Both alternatives run into the same fundamental issue: if the data isn't available yet, `client.device` can't work synchronously. This is the same problem that the pluggable secure storage would face, and solving it requires a larger change (async device loading) that is out of scope for the immediate fix. + +Accepting the one-time sacrifice is pragmatic: +- It only affects users who upgrade the app AND whose first launch with the new SDK is before first unlock — a narrow window. +- The outcome (losing the registration, re-registering on next `activate()`) is the same as what's already happening today with the bug, except today it loops indefinitely rather than recovering cleanly. +- After migration (successful or not), the new always-available storage prevents this class of problem from recurring. + +### Impact on the spec + +The spec changes we've drafted (RSH3h, RSH8a2, etc.) are compatible with both approaches. RSH8a2 says the tuple must be persisted and loaded atomically but doesn't prescribe a storage mechanism. The choice between always-available storage and pluggable secure storage is an implementation decision for each SDK. A future spec enhancement could add guidance for the "wait for availability" pattern if multiple SDKs need it. + +### Recommended order of work + +Steps 1 and 2 are intertwined and should be treated as a single piece of work. The new storage (step 1) needs the migration and integrity checking logic (step 2), because migrating from old storage is where the RSH8a2a invariant checks and RSH3i token validation are needed. And the spec changes (step 2) rely on always-available storage (step 1) to avoid the regeneration loop where the SDK repeatedly discards and regenerates credentials because the Keychain is inaccessible. + +1. **Move to always-available storage + implement the spec changes** — store the (`id`, `deviceSecret`, `deviceIdentityToken`) tuple in a file with `NSFileProtectionNone` (or equivalent), with the RSH8a2 atomicity mechanism. Implement RSH3h (load and verify at state machine init), RSH8a2a (legacy data invariant checks and token validation), and RSH3i (`ValidatingDeviceIdentityToken` state). This fixes the root cause and handles migration from old Keychain/NSUserDefaults storage in one release. +2. **Pluggable secure storage** — future enhancement for customers who require encrypted credential storage, with async device loading. Separate piece of work. + +### Privacy manifest + +The current privacy manifest (`Source/PrivacyInfo.xcprivacy`) declares: +- `NSPrivacyCollectedDataTypeDeviceID` (linked, tracking, for app functionality) +- `NSPrivacyAccessedAPICategoryUserDefaults` with reason `CA92.1` + +If we move to a custom file with `NSFileProtectionNone`, the UserDefaults declaration is still needed (for other data and during migration). Custom file I/O does not fall under Apple's "required reason APIs", so no additional privacy manifest declarations are needed for the new storage mechanism. + +### Customer expectations around Keychain storage + +Keychain storage of the device secret is not documented anywhere as a feature or guarantee — it is not mentioned in the README, public headers, or any public documentation. The only references are in the CHANGELOG as internal implementation changes (removing the SAMKeychain dependency, replacing `SecKeychainItemDelete`). + +No customer should reasonably be depending on the device secret being stored in the Keychain. The change should be mentioned in release notes for transparency, but it does not constitute a breaking change in any documented sense. + +For customers who do require encrypted credential storage (as raised in [ably-java#593](https://github.com/ably/ably-java/issues/593)), the pluggable secure storage (step 2 above) would provide an explicit, supported mechanism for this — rather than the current situation where it's an undocumented implementation detail. + +## Testing plan + +> **Note**: The testing plans below are outdated and should be updated to refer to the test app in `Examples/LocalDeviceStorageBugTest/` once we understand the issue better. See that app's [README](Examples/LocalDeviceStorageBugTest/README.md) for its current capabilities. + +### Test app + +Build a small test app that uses ably-cocoa, registers for push, and displays on screen: +- Device ID +- Device secret (truncated) +- Whether a device identity token is present +- State machine state +- Push channel subscription status + +The app should include a debug mechanism (e.g. a button) to manually tamper with the persisted state, so we can create specific scenarios without needing to trigger the actual Keychain failure. + +### Creating the stale token state directly + +This is the easiest way to reproduce the customer's bug. The app should have a debug button that: + +1. Records the current token in NSUserDefaults (token=A, issued for id=A) +2. Generates a new id=B and secret=B +3. Writes id=B to NSUserDefaults +4. Writes secret=B to Keychain keyed by B +5. Leaves token=A in NSUserDefaults untouched + +On relaunch, the app has id=B, secret=B (valid pair), token=A (stale) — the exact state the customer is in. This can be used to test whichever recovery direction we implement (A, B, or C). + +### Reproducing the actual Keychain failure + +This requires a physical device and reproduces the full bug sequence: + +1. Install the test app with the current (pre-fix) SDK version +2. Activate push, confirm registration works +3. Reboot the device +4. Send a silent push notification (`content-available: 1`) to the device before unlocking — this wakes the app in the background while the Keychain is still locked. The APNS token is given by iOS independently of the Keychain, so the device should still be reachable for push even after reboot. +5. The app's device loading code runs: Keychain is inaccessible, id/secret are regenerated, token survives +6. Unlock the device, launch the app +7. Observe: the device now has a new id, new secret, stale token — the 40100 error should be reproducible + +This confirms the root cause. After implementing the fix, repeat this sequence and verify recovery. + +### Reproducing the migration + +1. Install the test app with the old SDK version, activate push, confirm registration works +2. Update the app to the new SDK version (with always-available storage and migration logic) +3. Launch normally → verify data migrated to new storage, push still works, same device ID +4. Repeat but with the stale token state (created via debug button before upgrading) → verify the chosen recovery direction handles it + +### Reproducing the migration with Keychain unavailable + +1. Install with old SDK, activate push +2. Update to new SDK +3. Reboot the device +4. Trigger a background launch before unlocking (silent push) +5. The migration attempts to read from old storage — Keychain is inaccessible, so the secret can't be migrated +6. RSH3h1 / RSH8a2a1 invariant check should fire: id present, secret absent → discard, `NotActivated` +7. Unlock, launch normally → verify the device re-registers cleanly on next `activate()` + +### Things to verify in all cases + +- After recovery, `activate()` succeeds and push notifications work +- After recovery, push channel subscriptions can be created +- No error loop — the 40100 error does not recur +- After successful migration, subsequent launches do not re-migrate (new storage is used) +- The state machine state is consistent with the device data at all times diff --git a/push-state-machine-persistence-archaeology.md b/push-state-machine-persistence-archaeology.md new file mode 100644 index 000000000..fe64cf6d0 --- /dev/null +++ b/push-state-machine-persistence-archaeology.md @@ -0,0 +1,65 @@ +# Push state machine persistence archaeology + +Background research for potential future work on push state machine persistence. This is out of scope for the current investigation but captured here for reference. + +## Which states are persistent in each SDK? + +| State | ably-cocoa | ably-java | ably-js | +|---|---|---|---| +| `NotActivated` | Persistent | Persistent | Persistent | +| `WaitingForPushDeviceDetails` | Persistent | Persistent | Not persistent | +| `WaitingForDeviceRegistration` | Not persistent | Not persistent | Not persistent | +| `WaitingForNewPushDeviceDetails` | Persistent | Persistent | Persistent | +| `WaitingForRegistrationSync` | Not persistent | Not persistent | Not persistent | +| `AfterRegistrationSyncFailed` | Persistent | Persistent | Not persistent | +| `WaitingForDeregistration` | Not persistent | Not persistent | Not persistent | + +ably-js is the most aggressive — it only persists `NotActivated` and `WaitingForNewPushDeviceDetails`. + +The spec (RSH3) does not prescribe which states should be persistent. It says "some kind of on-disk storage to which the state machine's state must be persisted" without distinguishing. + +## Was the split there from the start? + +**ably-cocoa**: Yes. The `ARTPushActivationPersistentState` base class was introduced in the very first push implementation (commit `ce1ce28d`, Feb 2017). The same four persistent / three non-persistent split has been there from the beginning. + +**ably-java**: Yes. The `PersistentState` abstract class was present from the initial push activation state machine implementation (commit `1f88b8b4`, Dec 2018, by Paddy Byers). Same split as ably-cocoa. + +**ably-js**: Yes. The `isPersistentState()` function was introduced in the initial push activation plugin (commit `24893a9f`, May 2024, by Owen Pearson, merged in PR #1775). However, ably-js made a different choice about which states to persist — only `NotActivated` and `WaitingForNewPushDeviceDetails`. + +## What's the motivation for non-persistent states? + +The non-persistent states (`WaitingForDeviceRegistration`, `WaitingForRegistrationSync`, `WaitingForDeregistration`) all represent "waiting for an HTTP response." If the app is killed, the response is never coming. + +The intent is to revert to the previous stable state and re-trigger the operation on restart. This is described in [ably-java#546](https://github.com/ably/ably-java/issues/546), which is referenced by [ably-cocoa#966](https://github.com/ably/ably-cocoa/issues/966). + +Additionally, in ably-java, there's a practical serialisation reason: events with constructor parameters (like `GettingDeviceRegistrationFailed` which carries an error) can't be reconstructed from a persisted string name. Events and states that carry data are not persisted (see ably-java `EventTest.java` lines 45-52). + +## The ably-cocoa#966 problem + +The flaw in the non-persistent approach: reverting to the previous state doesn't always re-trigger the operation. For example, if the state was `WaitingForPushDeviceDetails` (persistent) and `GotPushDeviceDetails` triggered a POST, the state moves to `WaitingForDeviceRegistration` (not persistent). If the app is killed, the state reverts to `WaitingForPushDeviceDetails`. But the APNS token is already persisted, so the SDK doesn't re-emit `GotPushDeviceDetails` (it thinks the token hasn't changed), and the state machine gets stuck. + +ably-cocoa has a workaround for this specific case in `ARTPushActivationStateMachine.m` (lines 70-76): on init, if the state is `WaitingForPushDeviceDetails` and the device already has an APNS token, it re-emits `GotPushDeviceDetails`. + +ably-js doesn't appear to have any special handling for this case. Since it only persists `NotActivated` and `WaitingForNewPushDeviceDetails`, any interruption during the registration flow reverts to `NotActivated` and the user needs to call `activate()` again. + +## The attempt to make WaitingForDeviceRegistration persistent (ably-cocoa) + +**Commit `0e921865`** (Sep 24, 2021, by Marat Al) on branch `fix/966-non-persistent-state` changed `WaitingForDeviceRegistration` to persistent and added logic to re-submit the device registration request when the state machine starts up in this state. + +This branch was **never merged to main**. It appears to have been an experimental attempt to properly fix #966 by making the "waiting" state persistent and re-submitting the in-flight request on restart. + +A later branch (`min-ios14-swift-mig`, commit `5708d9a8`, Aug 2024, by Umair Hussain) also touched this but as part of a broader Swift migration refactor. That branch was also **never merged to main**. + +So the current state on main is that `WaitingForDeviceRegistration` remains non-persistent, and the #966 workaround (re-emitting `GotPushDeviceDetails` on init) is still in place. + +## How ably-js handles things differently + +ably-js only persists two states, making a simpler but less resilient choice. It doesn't attempt to recover from interrupted operations — if the app is killed during registration, it reverts to `NotActivated` and the user must call `activate()` again. This avoids the #966 class of bugs entirely (no stuck states) at the cost of occasionally requiring re-registration. + +## Repos examined + +| Repo | HEAD at time of examination | +|---|---| +| ably-cocoa | `745e7b7a` | +| ably-java | `da4c60f0` | +| ably-js | `17be43e1` |