Skip to content
Open
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
7 changes: 7 additions & 0 deletions ios/EthosProtocol/Sources/Services/APIClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,13 @@ public final class APIClient {
try await get(path: "/vaults")
}

/// How long ago the cached `/vaults` response was written, or nil if nothing is cached yet.
/// Lets VaultStore surface "data as of X ago" when a `listVaults()` call was served from
/// the offline cache. Uses the same cache key `execute(_:)` saves under (the request URL).
func vaultsCacheAge() -> TimeInterval? {
OfflineCache.shared.age(for: request(path: "/vaults").url?.absoluteString ?? "")
}

func getVault(id: String) async throws -> Vault {
try await get(path: "/vaults/\(id)")
}
Expand Down
29 changes: 25 additions & 4 deletions ios/EthosProtocol/Sources/Services/BackgroundRefreshService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -20,10 +20,18 @@ final class BackgroundRefreshService {
// Injected dependency for testing; defaults to APIClient.shared.listVaults()
var vaultListProvider: VaultListProvider = { try await APIClient.shared.listVaults() }

// Tracks whether scheduleAppRefresh was called (for testing)
private(set) var scheduleAppRefreshCallCount = 0
// Injected dependency for testing; defaults to the shared check-in retry queue.
var checkInSync: CheckInSyncService = .shared

private init() {}
// Tracks whether scheduleAppRefresh was called (for testing). `internal` (not
// `private(set)`): HandleRefreshTests resets this between runs via `@testable import`,
// which — like `private init()` — a `private` setter would block from another file.
var scheduleAppRefreshCallCount = 0

// `internal` (not `private`): lets tests construct a BackgroundRefreshService via
// `@testable import` instead of only exercising the shared singleton, mirroring
// APIClient's testable init.
init() {}

func registerBackgroundTask() {
BGTaskScheduler.shared.register(forTaskWithIdentifier: Self.taskIdentifier, using: nil) { [weak self] task in
Expand All @@ -43,10 +51,23 @@ final class BackgroundRefreshService {

// Register the expiration handler before kicking off the async work so there's no
// window where the system could expire the task before we're able to cancel it.
// `BackgroundRefreshTask` isn't class-constrained, so the compiler won't assign through
// the immutable `task` parameter directly even though every real conformer
// (BGAppRefreshTask, MockBackgroundRefreshTask) is a class — shadow it with a local
// `var` for this one synchronous mutation. The `Task { ... }` closure below captures
// the original `task` (never mutated) instead, so it doesn't need to capture a `var`
// across a concurrency boundary.
var mutableTask = task
var refreshTask: Task<Void, Never>?
task.expirationHandler = { refreshTask?.cancel() }
mutableTask.expirationHandler = { refreshTask?.cancel() }

refreshTask = Task {
// Piggyback the offline check-in retry queue on this already-registered,
// already-scheduled BGAppRefreshTask rather than registering a second background
// task identifier — see issue #28. Best-effort: a flush failure shouldn't fail the
// TTL refresh this task also performs.
await checkInSync.flush()

do {
let vaults = try await vaultListProvider()
for vault in vaults where vault.status == .active {
Expand Down
63 changes: 63 additions & 0 deletions ios/EthosProtocol/Sources/Services/CheckInQueue.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import Foundation

/// A check-in attempted while offline, waiting to be retried once connectivity returns.
struct PendingCheckIn: Codable, Equatable {
let vaultID: String
let queuedAt: Date
}

/// Durable, file-backed queue for check-ins attempted while offline, so a missed connectivity
/// window doesn't silently drop a dead-man's-switch check-in. Mirrors Android's
/// PendingCheckInDao (android/.../services/CheckInQueue.kt) using a flat JSON file instead of
/// Room, since this app has no other on-device database to extend.
final class CheckInQueue {
static let shared = CheckInQueue()

private let fileURL: URL
private let queue = DispatchQueue(label: "com.ethosprotocol.checkinqueue")

// `internal` (not `private`): lets tests construct an isolated instance pointed at a
// scratch directory via `@testable import`, instead of mutating the real `.shared` queue.
init(directory: URL = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0]) {
try? FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
fileURL = directory.appendingPathComponent("pending_checkins.json")
}

/// Vaults with a check-in currently queued for retry, oldest first.
var pending: [PendingCheckIn] {
queue.sync { readAll() }
}

var count: Int { pending.count }

/// Queues `vaultID` for retry, replacing any existing queued entry for the same vault
/// rather than duplicating it.
func enqueue(vaultID: String) {
queue.sync {
var all = readAll().filter { $0.vaultID != vaultID }
all.append(PendingCheckIn(vaultID: vaultID, queuedAt: Date()))
write(all)
}
}

func remove(vaultID: String) {
queue.sync {
write(readAll().filter { $0.vaultID != vaultID })
}
}

func removeAll() {
queue.sync { write([]) }
}

private func readAll() -> [PendingCheckIn] {
guard let data = try? Data(contentsOf: fileURL),
let decoded = try? JSONDecoder().decode([PendingCheckIn].self, from: data) else { return [] }
return decoded
}

private func write(_ items: [PendingCheckIn]) {
guard let data = try? JSONEncoder().encode(items) else { return }
try? data.write(to: fileURL)
}
}
53 changes: 53 additions & 0 deletions ios/EthosProtocol/Sources/Services/CheckInSyncService.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import Foundation

/// Retries every queued check-in. Mirrors Android's CheckInSyncWorker
/// (android/.../services/CheckInSyncWorker.kt): a check-in is a dead-man's-switch signal, so
/// only errors the server has definitively rejected as invalid (the vault no longer exists)
/// drop the queued item — everything else (offline, timeout, server error, expired auth) is
/// left queued for the next retry rather than risking a vault being released even though the
/// user did check in.
final class CheckInSyncService {
static let shared = CheckInSyncService()

var queue: CheckInQueue = .shared

// Injected for testing; defaults to a real check-in call.
var checkInProvider: (String) async throws -> Void = { try await APIClient.shared.checkIn(vaultID: $0) }

// `internal` (not `private`): lets tests construct an isolated instance with its own
// queue/provider via `@testable import`, instead of mutating the real `.shared` service.
init() {}

/// Attempts every queued check-in, dropping only the non-retryable failures, and leaves
/// the "check-in queued" notification up to date with what's left afterward.
@discardableResult
func flush() async -> Bool {
let items = queue.pending
// Nothing queued means nothing to do — in particular, skip touching
// NotificationService entirely so a routine flush with an empty queue (e.g. every
// hourly BGAppRefreshTask tick) doesn't needlessly re-post/cancel a notification.
guard !items.isEmpty else { return true }

var allSucceeded = true
for item in items {
do {
try await checkInProvider(item.vaultID)
queue.remove(vaultID: item.vaultID)
} catch APIError.notFound {
// The server has told us unambiguously this check-in can never succeed
// (the vault no longer exists) — retrying it is pointless.
queue.remove(vaultID: item.vaultID)
} catch {
allSucceeded = false
}
}

let remaining = queue.count
if remaining > 0 {
NotificationService.shared.showQueuedCheckIn(count: remaining)
} else {
NotificationService.shared.cancelQueuedCheckIn()
}
return allSucceeded
}
}
30 changes: 30 additions & 0 deletions ios/EthosProtocol/Sources/Services/NotificationService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,36 @@ final class NotificationService: NSObject, UNUserNotificationCenterDelegate {
}
}

// MARK: - Offline Check-In Queue Indicator

private static let queuedCheckInIdentifier = "checkin-queued"

/// Surfaces a persistent local notification while check-ins are queued for retry, mirroring
/// Android's NotificationHelper.showQueuedCheckIn. iOS has no true "ongoing" notification
/// (UNNotificationRequest has no non-dismissable/foreground-service equivalent to Android's
/// setOngoing(true)), so this re-posts the same identifier every time the queue changes;
/// cancelQueuedCheckIn() removes it once the queue drains. VaultStore's queuedCheckInCount
/// mirrors this in-app for while the app is foregrounded.
func showQueuedCheckIn(count: Int) {
let center = UNUserNotificationCenter.current()
let content = UNMutableNotificationContent()
content.title = "Check-in queued"
content.body = count == 1
? "1 check-in will be submitted when back online"
: "\(count) check-ins will be submitted when back online"

let trigger = UNTimeIntervalNotificationTrigger(timeInterval: 1, repeats: false)
let request = UNNotificationRequest(identifier: Self.queuedCheckInIdentifier, content: content, trigger: trigger)
center.removePendingNotificationRequests(withIdentifiers: [Self.queuedCheckInIdentifier])
center.add(request)
}

func cancelQueuedCheckIn() {
let center = UNUserNotificationCenter.current()
center.removePendingNotificationRequests(withIdentifiers: [Self.queuedCheckInIdentifier])
center.removeDeliveredNotifications(withIdentifiers: [Self.queuedCheckInIdentifier])
}

// MARK: - UNUserNotificationCenterDelegate

func userNotificationCenter(_ center: UNUserNotificationCenter,
Expand Down
127 changes: 115 additions & 12 deletions ios/EthosProtocol/Sources/Services/OfflineSupport.swift
Original file line number Diff line number Diff line change
@@ -1,43 +1,146 @@
import Network
import Foundation

/// Abstracts NWPathMonitor so NetworkMonitor's cold-start behavior can be exercised in tests
/// without a real network stack. NWPath itself has no public initializer, so tests can't
/// construct one to fake `pathUpdateHandler` callbacks — this narrows the surface to the two
/// things NetworkMonitor actually needs down to plain, fakeable types.
protocol NetworkPathProvider {
var isCurrentlySatisfied: Bool { get }
func startMonitoring(_ handler: @escaping (Bool) -> Void)
}

struct NWPathMonitorProvider: NetworkPathProvider {
private let monitor = NWPathMonitor()

var isCurrentlySatisfied: Bool { monitor.currentPath.status == .satisfied }

func startMonitoring(_ handler: @escaping (Bool) -> Void) {
monitor.pathUpdateHandler = { path in handler(path.status == .satisfied) }
monitor.start(queue: DispatchQueue(label: "NetworkMonitor"))
}
}

final class NetworkMonitor {
static let shared = NetworkMonitor()
private let monitor = NWPathMonitor()
private(set) var isConnected = true

private init() {
monitor.pathUpdateHandler = { [weak self] path in
self?.isConnected = path.status == .satisfied
// `private(set)` so production code can only read this; tests observe it via a fresh
// instance constructed with a mock provider instead of mutating `.shared`.
private(set) var isConnected: Bool

// `internal` (not `private`): lets tests construct a NetworkMonitor with a mock
// NetworkPathProvider via `@testable import`, mirroring APIClient's testable init.
init(provider: NetworkPathProvider = NWPathMonitorProvider()) {
// NWPathMonitor.currentPath reflects the system's last-known path synchronously,
// even before `start(queue:)` attaches pathUpdateHandler — reading it here instead of
// defaulting to `true` closes the cold-start window where a request made before the
// first async path update would otherwise assume connectivity and attempt (and time
// out on) a real network call while actually offline.
isConnected = provider.isCurrentlySatisfied
provider.startMonitoring { [weak self] satisfied in
self?.isConnected = satisfied
}
monitor.start(queue: DispatchQueue(label: "NetworkMonitor"))
}
}

/// Simple disk-based cache for offline reads.
/// Simple disk-based cache for offline reads. Entries are timestamped so callers can surface
/// staleness ("as of 3 days ago") and so entries older than `maxAge` can be treated as absent.
/// Bounded to `maxBytes` total via LRU eviction (least-recently-*loaded* entry evicted first).
final class OfflineCache {
static let shared = OfflineCache()
private let dir: URL

/// Entries older than this are treated as absent by `load(for:)`/`age(for:)`. `nil`
/// (the default) disables expiry enforcement — staleness is still tracked and can be
/// surfaced in the UI even when it isn't used to refuse serving the entry.
var maxAge: TimeInterval?

/// Total on-disk size cap across all cached entries. Exceeding this on `save(_:for:)`
/// evicts the least-recently-loaded entries first until back under the cap.
var maxBytes: Int = 20 * 1_024 * 1_024 // 20 MB

private init() {
dir = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask)[0]
.appendingPathComponent("EthosProtocolOfflineCache", isDirectory: true)
try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
}

func save(_ data: Data, for key: String) {
let file = dir.appendingPathComponent(key.sha256Hex)
let file = dataFile(for: key)
try? data.write(to: file)
try? Date().timeIntervalSince1970.description.data(using: .utf8)?.write(to: metaFile(for: key))
enforceSizeCap()
}

func load(for key: String) -> Data? {
let file = dir.appendingPathComponent(key.sha256Hex)
return try? Data(contentsOf: file)
guard !isExpired(key) else { return nil }
let file = dataFile(for: key)
guard let data = try? Data(contentsOf: file) else { return nil }
// Bump the entry's mtime so it's treated as recently used for LRU eviction, without
// touching the separate `.meta` timestamp `age(for:)` reports — a cache hit shouldn't
// reset how stale the underlying data actually is.
try? FileManager.default.setAttributes([.modificationDate: Date()], ofItemAtPath: file.path)
return data
}

func delete(for key: String) {
let file = dir.appendingPathComponent(key.sha256Hex)
try? FileManager.default.removeItem(at: file)
try? FileManager.default.removeItem(at: dataFile(for: key))
try? FileManager.default.removeItem(at: metaFile(for: key))
}

/// Timestamp the entry for `key` was cached, or nil if no entry exists.
func cachedAt(for key: String) -> Date? {
guard let raw = try? String(contentsOf: metaFile(for: key), encoding: .utf8),
let interval = TimeInterval(raw) else { return nil }
return Date(timeIntervalSince1970: interval)
}

/// How long ago the entry for `key` was cached, or nil if no entry exists.
func age(for key: String) -> TimeInterval? {
cachedAt(for: key).map { Date().timeIntervalSince($0) }
}

/// Removes every cached entry. Called on sign-out so a subsequent user on the same
/// device can't be served the previous user's cached vault data while offline.
func clearAll() {
try? FileManager.default.removeItem(at: dir)
try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
}

private func isExpired(_ key: String) -> Bool {
guard let maxAge, let cachedAt = cachedAt(for: key) else { return false }
return Date().timeIntervalSince(cachedAt) > maxAge
}

private func dataFile(for key: String) -> URL {
dir.appendingPathComponent(key.sha256Hex)
}

private func metaFile(for key: String) -> URL {
dataFile(for: key).appendingPathExtension("meta")
}

private func enforceSizeCap() {
guard let contents = try? FileManager.default.contentsOfDirectory(
at: dir, includingPropertiesForKeys: [.fileSizeKey, .contentModificationDateKey]) else { return }

let entries = contents
.filter { $0.pathExtension != "meta" }
.compactMap { url -> (url: URL, size: Int, modified: Date)? in
guard let values = try? url.resourceValues(forKeys: [.fileSizeKey, .contentModificationDateKey]),
let size = values.fileSize, let modified = values.contentModificationDate else { return nil }
return (url, size, modified)
}

var totalSize = entries.reduce(0) { $0 + $1.size }
guard totalSize > maxBytes else { return }

for entry in entries.sorted(by: { $0.modified < $1.modified }) {
guard totalSize > maxBytes else { break }
try? FileManager.default.removeItem(at: entry.url)
try? FileManager.default.removeItem(at: entry.url.appendingPathExtension("meta"))
totalSize -= entry.size
}
}
}

Expand Down
Loading