Skip to content

Commit

Permalink
VIT-5855: Improve SDK reset behaviour
Browse files Browse the repository at this point in the history
  • Loading branch information
andersio committed Feb 23, 2024
1 parent a14d9ce commit b3b4918
Show file tree
Hide file tree
Showing 3 changed files with 113 additions and 28 deletions.
74 changes: 70 additions & 4 deletions Sources/VitalCore/Core/Client/VitalClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,9 @@ public enum Environment: Equatable, Hashable, Codable, CustomStringConvertible {
let core_secureStorageKey: String = "core_secureStorageKey"
let user_secureStorageKey: String = "user_secureStorageKey"

@_spi(VitalSDKInternals)
public let health_secureStorageKey: String = "health_secureStorageKey"

@objc public class VitalClient: NSObject {
public static let sdkVersion = "0.11.0"

Expand All @@ -180,6 +183,7 @@ let user_secureStorageKey: String = "user_secureStorageKey"
private static var client: VitalClient?
private static let clientInitLock = NSLock()
private static let automaticConfigurationLock = NSLock()
private var cancellables: Set<AnyCancellable> = []

public static var shared: VitalClient {
let sharedClient = sharedNoAutoConfig
Expand All @@ -196,6 +200,7 @@ let user_secureStorageKey: String = "user_secureStorageKey"
guard let value = client else {
let newClient = VitalClient()
Self.client = newClient
Self.bind(newClient, jwtAuth: VitalJWTAuth.live)
return newClient
}

Expand Down Expand Up @@ -314,8 +319,21 @@ let user_secureStorageKey: String = "user_secureStorageKey"
}

public static var statusDidChange: AnyPublisher<Void, Never> {
Publishers.Merge(shared.statusDidChange, shared.jwtAuth.statusDidChange)
.eraseToAnyPublisher()
Publishers.Merge(
shared.statusDidChange,
shared.jwtAuth.statusDidChange
.compactMap { status -> Void? in
switch status {
case .userNoLongerValid:
return ()
case .signedIn, .signingOut, .update:
// cleanUp() and setConfiguration() triggers a status change as they wrap up.
// No need to duplicate the notification.
return nil
}
}
)
.eraseToAnyPublisher()
}

public static var statuses: AsyncStream<VitalClient.Status> {
Expand Down Expand Up @@ -389,6 +407,28 @@ let user_secureStorageKey: String = "user_secureStorageKey"
}
}

private static func bind(_ client: VitalClient, jwtAuth: VitalJWTAuth) {
// When JWT detects that a user has been deleted, automatically reset the SDK.
jwtAuth.statusDidChange
.filter { $0 == .userNoLongerValid }
.sink { _ in
Task {
await client.cleanUp()
}
}
.store(in: &client.cancellables)

// Asynchronously log Core SDK status changes
// NOTE: This must start async. Otherwise, `VitalClient.statuses` will access
// `VitalClient.shared` while the initialization lock is still held by the caller of
// `bind()`.
Task {
for await status in type(of: client).statuses {
VitalLogger.core.debug("status: \(status, privacy: .public)")
}
}
}

init(
secureStorage: VitalSecureStorage = .init(keychain: .live),
configuration: ProtectedBox<VitalCoreConfiguration> = .init(),
Expand Down Expand Up @@ -551,7 +591,8 @@ let user_secureStorageKey: String = "user_secureStorageKey"

self.secureStorage.clean(key: core_secureStorageKey)
self.secureStorage.clean(key: user_secureStorageKey)

self.secureStorage.clean(key: health_secureStorageKey)

self.apiKeyModeUserId.clean()
self.configuration.clean()

Expand Down Expand Up @@ -606,7 +647,7 @@ public extension VitalClient {
case userJwt
}

struct Status: OptionSet {
struct Status: OptionSet, CustomStringConvertible {
/// The SDK has been configured, either through `VitalClient.Type.configure` for the first time,
/// or through `VitalClient.Type.automaticConfiguration()` where the last auto-saved
/// configuration has been restored.
Expand Down Expand Up @@ -634,6 +675,31 @@ public extension VitalClient {

public let rawValue: Int

public var description: String {
var texts: [String] = []
if self.contains(.configured) {
texts.append("configured")
}
if self.contains(.signedIn) {
texts.append("signedIn")
}
if self.contains(.useApiKey) {
texts.append("useApiKey")
}
if self.contains(.useSignInToken) {
texts.append("useSignInToken")
}
if self.contains(.pendingReauthentication) {
texts.append("pendingReauthentication")
}

if texts.isEmpty {
return "<not configured>"
} else {
return texts.joined(separator: ",")
}
}

public init(rawValue: Int) {
self.rawValue = rawValue
}
Expand Down
37 changes: 21 additions & 16 deletions Sources/VitalCore/JWT/VitalJWTAuth.swift
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,13 @@ internal struct VitalJWTAuthUserContext {

internal struct VitalJWTAuthNeedsRefresh: Error {}

internal enum VitalJWTAuthChangeReason {
case signingOut
case signedIn
case userNoLongerValid
case update
}

internal actor VitalJWTAuth {
internal static let live = VitalJWTAuth()
private static let keychainKey = "vital_jwt_auth"
Expand All @@ -45,7 +52,7 @@ internal actor VitalJWTAuth {
private var cachedRecord: VitalJWTAuthRecord? = nil

// Moments where it would materially affect `VitalClient.Type.status`.
internal nonisolated let statusDidChange = PassthroughSubject<Void, Never>()
internal nonisolated let statusDidChange = PassthroughSubject<VitalJWTAuthChangeReason, Never>()

init(
storage: VitalSecureStorage = VitalSecureStorage(keychain: .live)
Expand Down Expand Up @@ -99,7 +106,7 @@ internal actor VitalJWTAuth {
expiry: Date().addingTimeInterval(Double(exchangeResponse.expiresIn) ?? 3600)
)

try setRecord(record)
try setRecord(record, reason: .signedIn)

VitalLogger.core.info("sign-in success; expiresIn = \(exchangeResponse.expiresIn, privacy: .public)")

Expand All @@ -114,7 +121,7 @@ internal actor VitalJWTAuth {
}

func signOut() async throws {
try setRecord(nil)
try setRecord(nil, reason: .signingOut)
}

func userContext() throws -> VitalJWTAuthUserContext {
Expand Down Expand Up @@ -194,23 +201,23 @@ internal actor VitalJWTAuth {
newRecord.accessToken = refreshResponse.idToken
newRecord.expiry = Date().addingTimeInterval(Double(refreshResponse.expiresIn) ?? 3600)

try setRecord(newRecord)
try setRecord(newRecord, reason: .update)
VitalLogger.core.info("refresh success; expiresIn = \(refreshResponse.expiresIn, privacy: .public)")

default:
if
(400...499).contains(httpResponse.statusCode),
let response = try? JSONDecoder().decode(FirebaseTokenRefreshError.self, from: data)
let response = try? JSONDecoder().decode(FirebaseTokenRefreshErrorResponse.self, from: data)
{
if response.isInvalidUser {
try setRecord(nil)
if response.error.isInvalidUser {
try setRecord(nil, reason: .userNoLongerValid)
throw VitalJWTAuthError.invalidUser
}

if response.needsReauthentication {
if response.error.needsReauthentication {
var record = record
record.pendingReauthentication = true
try setRecord(record)
try setRecord(record, reason: .update)

throw VitalJWTAuthError.needsReauthentication
}
Expand All @@ -237,22 +244,20 @@ internal actor VitalJWTAuth {
return record
} catch is DecodingError {
VitalLogger.core.error("auto signout: failed to decode keychain auth record")
try? setRecord(nil)
try? setRecord(nil, reason: .userNoLongerValid)
return nil
}
}

private func setRecord(_ record: VitalJWTAuthRecord?) throws {
defer {
self.cachedRecord = record
statusDidChange.send(())
}

private func setRecord(_ record: VitalJWTAuthRecord?, reason: VitalJWTAuthChangeReason) throws {
if let record = record {
try storage.set(value: record, key: Self.keychainKey)
} else {
storage.clean(key: Self.keychainKey)
}

self.cachedRecord = record
statusDidChange.send(reason)
}
}

Expand Down
30 changes: 22 additions & 8 deletions Sources/VitalHealthKit/HealthKit/VitalHealthKitClient.swift
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import HealthKit
import Combine
import os.log
import VitalCore
@_spi(VitalSDKInternals) import VitalCore
import UIKit

public enum PermissionOutcome: Equatable {
Expand All @@ -10,8 +10,6 @@ public enum PermissionOutcome: Equatable {
case healthKitNotAvailable
}

let health_secureStorageKey: String = "health_secureStorageKey"

@objc public class VitalHealthKitClient: NSObject {
public enum Status {
case failedSyncing(VitalResource, Error?)
Expand All @@ -26,6 +24,7 @@ let health_secureStorageKey: String = "health_secureStorageKey"
guard let value = client else {
let newClient = VitalHealthKitClient()
Self.client = newClient
Self.bind(newClient, core: VitalClient.shared)
return newClient
}

Expand All @@ -46,6 +45,10 @@ let health_secureStorageKey: String = "health_secureStorageKey"

private let backgroundDeliveryEnabled: ProtectedBox<Bool> = .init(value: false)
let configuration: ProtectedBox<Configuration>

private var isAutoSyncConfigured: Bool {
backgroundDeliveryEnabled.value ?? false
}

public var status: AnyPublisher<Status, Never> {
return _status.eraseToAnyPublisher()
Expand All @@ -68,6 +71,16 @@ let health_secureStorageKey: String = "health_secureStorageKey"

super.init()
}

private static func bind(_ client: VitalHealthKitClient, core: VitalClient) {
Task {
for await status in type(of: core).statuses {
if !status.contains(.signedIn) && client.isAutoSyncConfigured {
await client.resetAutoSync()
}
}
}
}

/// Only use this method if you are working from Objc.
/// Please use the async/await configure method when working from Swift.
Expand Down Expand Up @@ -472,14 +485,15 @@ extension VitalHealthKitClient {
}

public func cleanUp() async {
await store.disableBackgroundDelivery()
await VitalClient.shared.cleanUp()
}

private func resetAutoSync() async {
backgroundDeliveryTask?.task.cancel()
backgroundDeliveryTask = nil

backgroundDeliveryEnabled.set(value: false)

await VitalClient.shared.cleanUp()
self.secureStorage.clean(key: health_secureStorageKey)

await store.disableBackgroundDelivery()
}

public enum SyncPayload {
Expand Down

0 comments on commit b3b4918

Please sign in to comment.