Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
177 changes: 176 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -163,7 +163,182 @@ StableID.configure(idGenerator: MyCustomIDGenerator())

## 📚 Examples

_Coming soon_
<details>
<summary><b>Example 1: Basic Setup with RevenueCat</b></summary>

Configure StableID and use it to configure RevenueCat with a consistent user identifier:

```swift
import StableID
import RevenueCat

class AppDelegate: UIApplicationDelegate {
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {

// Configure StableID first
if StableID.hasStoredID {
StableID.configure()

// Configure RevenueCat with StableID
Purchases.configure(withAPIKey: "your_api_key", appUserID: StableID.id)
} else {
Task {
// Try to fetch AppTransactionID, fallback to generated ID if it fails
if let id = try? await StableID.fetchAppTransactionID() {
StableID.configure(id: id)
} else {
StableID.configure()
}

// Configure RevenueCat after StableID is ready
Purchases.configure(withAPIKey: "your_api_key", appUserID: StableID.id)
}
}

return true
}
}
```

</details>

<details>
<summary><b>Example 2: Handling ID Changes with RevenueCat</b></summary>

Use the delegate pattern to update RevenueCat when the StableID changes (e.g., from another device via iCloud):

```swift
import StableID
import RevenueCat

class AppCoordinator: StableIDDelegate {
init() {
// Set up StableID delegate
StableID.set(delegate: self)
}

func willChangeID(currentID: String, candidateID: String) -> String? {
// Optional: validate or modify the candidate ID
return nil
}

func didChangeID(newID: String) {
// Update RevenueCat with the new ID
Purchases.shared.logIn(newID) { customerInfo, created, error in
if let error = error {
print("Error updating RevenueCat user: \(error)")
} else {
print("Successfully updated RevenueCat user to: \(newID)")
}
}
}
}
```

</details>

<details>
<summary><b>Example 3: User Login Flow</b></summary>

Handle the case where a user logs into your app with their own account:

```swift
import StableID
import RevenueCat

func userDidLogin(userID: String) {
// Update StableID to use the user's account ID
StableID.identify(id: userID)

// Update RevenueCat to match
Purchases.shared.logIn(userID) { customerInfo, created, error in
if let error = error {
print("Error logging in to RevenueCat: \(error)")
} else {
print("Successfully logged in to RevenueCat")
}
}
}

func userDidLogout() {
// Generate a new anonymous ID
StableID.generateNewID()

// Switch RevenueCat to the new anonymous ID
Purchases.shared.logIn(StableID.id) { customerInfo, created, error in
if let error = error {
print("Error switching to anonymous ID: \(error)")
} else {
print("Switched to anonymous ID: \(StableID.id)")
}
}
}
```

</details>

<details>
<summary><b>Example 4: SwiftUI App with Async Configuration</b></summary>

For SwiftUI apps, configure StableID and RevenueCat in your App struct:

```swift
import SwiftUI
import StableID
import RevenueCat

@main
struct MyApp: App {
init() {
// Configure StableID with .preferStored policy
Task {
do {
let id = try await StableID.fetchAppTransactionID()
StableID.configure(id: id, policy: .preferStored)

// Configure RevenueCat after StableID is ready
Purchases.configure(withAPIKey: "your_api_key", appUserID: StableID.id)
} catch {
print("Error configuring StableID: \(error)")
// Fallback to generated ID
StableID.configure()
Purchases.configure(withAPIKey: "your_api_key", appUserID: StableID.id)
}
}
}

var body: some Scene {
WindowGroup {
ContentView()
}
}
}
```

</details>

<details>
<summary><b>Example 5: Custom ID Generator for Testing</b></summary>

Use a custom ID generator for testing or specific formatting requirements:

```swift
import StableID

struct TestIDGenerator: IDGenerator {
func generateID() -> String {
return "test-user-\(UUID().uuidString.prefix(8))"
}
}

#if DEBUG
StableID.configure(idGenerator: TestIDGenerator())
#else
StableID.configure()
#endif
```

</details>

## 📙 License

Expand Down
12 changes: 11 additions & 1 deletion Sources/StableID/Delegate/Delegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,19 @@ import Foundation

public protocol StableIDDelegate {
/// Called when StableID is about to change the identified user ID.
/// Return `nil` to prevent the change.
///
/// Use this to validate or modify the candidate ID before it's set.
///
/// - Parameters:
/// - currentID: The current user ID
/// - candidateID: The proposed new user ID
/// - Returns: An adjusted ID to use instead, or `nil` to use the candidate ID as-is
func willChangeID(currentID: String, candidateID: String) -> String?

/// Called after StableID changes the identified user ID.
///
/// Use this to sync the new ID with other services like RevenueCat.
///
/// - Parameter newID: The new user ID that was set
func didChangeID(newID: String)
}
126 changes: 123 additions & 3 deletions Sources/StableID/StableID.swift
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,30 @@ public class StableID {
private static var remoteStore = NSUbiquitousKeyValueStore.default
private static var localStore = UserDefaults(suiteName: Constants.StableID_Key_DefaultsSuiteName)

/// Configures StableID with an optional user identifier.
///
/// This method initializes the StableID system and must be called before accessing any other StableID methods.
/// It can only be called once per app session - subsequent calls will be ignored.
///
/// - Parameters:
/// - id: An optional identifier to use. If nil, will use a stored ID (from iCloud or local storage) or generate a new one.
/// - idGenerator: The generator to use for creating new IDs. Defaults to `StandardGenerator()` which produces UUIDs.
/// - policy: Controls how the provided ID is handled. Defaults to `.forceUpdate`.
/// - `.forceUpdate`: Always uses the provided ID and updates storage
/// - `.preferStored`: Uses stored ID if available, otherwise uses the provided ID
///
/// Example usage:
/// ```swift
/// // Basic configuration with auto-generated ID
/// StableID.configure()
///
/// // With AppTransactionID
/// if let id = try? await StableID.fetchAppTransactionID() {
/// StableID.configure(id: id, policy: .preferStored)
/// } else {
/// StableID.configure()
/// }
/// ```
public static func configure(id: String? = nil, idGenerator: IDGenerator = StandardGenerator(), policy: IDPolicy = .forceUpdate) {
guard isConfigured == false else {
Self.logger.log(type: .error, message: "StableID has already been configured! Call `identify` to change the identifier.")
Expand Down Expand Up @@ -135,12 +159,12 @@ public class StableID {
private func didChangeExternally(_ notification: Notification) {
if let newId = Self.remoteStore.string(forKey: Constants.StableID_Key_Identifier) {
if newId != id {
Self.logger.log(type: .info, message: "Detected new StableID: \(newId)")
Self.logger.log(type: .info, message: "Detected new StableID in iCloud: \(newId)")

self.setIdentity(value: newId)
} else {
// the identifier was updated remotely, but it's the same identifier
Self.logger.log(type: .info, message: "No change to StableID.")
Self.logger.log(type: .info, message: "StableID was updated in iCloud, but it's the same identifier that's already configured.")
}

} else {
Expand All @@ -156,22 +180,118 @@ public class StableID {

/// Public methods
extension StableID {
/// Returns whether StableID has been configured.
///
/// Check this property before calling `configure()` to avoid double-configuration errors.
///
/// Example usage:
/// ```swift
/// if !StableID.isConfigured {
/// StableID.configure()
/// }
/// ```
public static var isConfigured: Bool { instance != nil }

/// The current stable identifier.
///
/// This property returns the active user ID. It persists across app launches and syncs via iCloud.
///
/// Example usage:
/// ```swift
/// let userID = StableID.id
/// Purchases.configure(withAPIKey: "key", appUserID: userID)
/// ```
///
/// - Warning: Accessing this property before calling `configure()` will result in a fatal error.
public static var id: String { return Self.shared.id }

/// Changes the current identifier to a new value.
///
/// Use this method to update the user's identifier, for example when a user logs in with their account.
/// The new ID will be persisted to both local storage and iCloud, and any configured delegates will be notified.
///
/// - Parameter id: The new identifier to use.
///
/// Example usage:
/// ```swift
/// // When user logs in
/// StableID.identify(id: "user-account-123")
///
/// // Update RevenueCat
/// Purchases.shared.logIn("user-account-123") { _, _, _ in }
/// ```
///
/// - Warning: Calling this method before `configure()` will result in a fatal error.
public static func identify(id: String) {
Self.shared.setIdentity(value: id)
}

/// Generates and sets a new random identifier.
///
/// This method creates a new ID using the configured ID generator and updates both local and iCloud storage.
/// Use this when you need to create a new anonymous user, for example after a user logs out.
///
/// Example usage:
/// ```swift
/// // When user logs out
/// StableID.generateNewID()
///
/// // Update RevenueCat with new anonymous ID
/// Purchases.shared.logOut { _ in
/// Purchases.shared.logIn(StableID.id) { _, _, _ in }
/// }
/// ```
///
/// - Warning: Calling this method before `configure()` will result in a fatal error.
public static func generateNewID() {
Self.shared.generateID()
}

/// Sets a delegate to receive notifications when the ID changes.
///
/// The delegate receives callbacks before and after ID changes, allowing you to validate
/// or respond to ID updates from any source (manual, iCloud sync, or generation).
///
/// - Parameter delegate: An object conforming to `StableIDDelegate`.
///
/// Example usage:
/// ```swift
/// class MyDelegate: StableIDDelegate {
/// func willChangeID(currentID: String, candidateID: String) -> String? {
/// // Optional: validate or modify the candidate ID
/// return nil
/// }
///
/// func didChangeID(newID: String) {
/// // Sync with your backend or RevenueCat
/// Purchases.shared.logIn(newID) { _, _, _ in }
/// }
/// }
///
/// StableID.set(delegate: MyDelegate())
/// ```
///
/// - Warning: Calling this method before `configure()` will result in a fatal error.
public static func set(delegate: any StableIDDelegate) {
Self.shared.delegate = delegate
}


/// Returns whether an ID is stored in iCloud or local storage.
///
/// Use this property to determine if you need to fetch a new ID (like AppTransactionID)
/// or if you can use the existing stored value.
///
/// Example usage:
/// ```swift
/// if StableID.hasStoredID {
/// StableID.configure()
/// } else {
/// // Fetch AppTransactionID only if needed
/// if let id = try? await StableID.fetchAppTransactionID() {
/// StableID.configure(id: id)
/// }
/// }
/// ```
public static var hasStoredID: Bool { fetchStoredID() != nil }

/// Resets the StableID instance. For testing purposes only.
Expand Down