Tier 5 - Code Refactoring: - iOS: Split NodeAppModel.swift (988→493 LOC) into focused modules - NodeCommandHandlers.swift: command routing (372 LOC) - NodeGatewaySync.swift: gateway sync, branding (153 LOC) - Android: Split NodeRuntime.kt (1268→756 LOC, 40% reduction) - NodeCommandHandlers.kt: command routing (326 LOC) - NodeGatewaySync.kt: gateway sync, A2UI helpers (294 LOC) Tier 6 - App Store Preparation: - iOS: Add PrivacyInfo.xcprivacy (iOS 17+ privacy manifest) - Android: Add ProGuard rules and enable minification for release builds Infrastructure (from earlier tiers): - iOS: Add OfflineMessageQueue for disconnected message queueing - iOS: Add PushManager for VoIP push foundation - iOS: Add TTSVoiceSettingsView for voice selection - Android: Add ClawdbotMessagingService for FCM push - Android: Add SyncWorker for background chat sync - Both: Deep linking support (clawdbot:// URL scheme) - Both: Empty states and loading indicators in settings Testing: - iOS: Add OfflineMessageQueueTests (8 tests) - Android: Add NodeGatewaySyncTest (11 tests) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
93 lines
3.0 KiB
Swift
93 lines
3.0 KiB
Swift
import Foundation
|
|
import OSLog
|
|
|
|
/// Queues chat messages when the gateway is disconnected and delivers them on reconnect.
|
|
@MainActor
|
|
@Observable
|
|
final class OfflineMessageQueue {
|
|
struct PendingMessage: Codable, Identifiable, Sendable {
|
|
let id: UUID
|
|
let text: String
|
|
let sessionKey: String
|
|
let thinking: String
|
|
let createdAt: Date
|
|
var retryCount: Int
|
|
}
|
|
|
|
private let logger = Logger(subsystem: "com.clawdbot", category: "offline-queue")
|
|
private let fileURL: URL
|
|
private let maxRetries = 3
|
|
|
|
private(set) var pending: [PendingMessage] = []
|
|
|
|
init() {
|
|
let docs = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0]
|
|
self.fileURL = docs.appendingPathComponent("offline-queue.json")
|
|
self.load()
|
|
}
|
|
|
|
var count: Int { pending.count }
|
|
var isEmpty: Bool { pending.isEmpty }
|
|
|
|
/// Add a message to the queue when gateway is disconnected.
|
|
func enqueue(text: String, sessionKey: String, thinking: String = "off") {
|
|
let msg = PendingMessage(
|
|
id: UUID(),
|
|
text: text,
|
|
sessionKey: sessionKey,
|
|
thinking: thinking,
|
|
createdAt: Date(),
|
|
retryCount: 0
|
|
)
|
|
pending.append(msg)
|
|
save()
|
|
logger.info("Queued message for session \(sessionKey, privacy: .public): \(text.prefix(50), privacy: .public)...")
|
|
}
|
|
|
|
/// Remove a message after successful delivery.
|
|
func dequeue(_ id: UUID) {
|
|
pending.removeAll { $0.id == id }
|
|
save()
|
|
logger.info("Dequeued message \(id.uuidString, privacy: .public)")
|
|
}
|
|
|
|
/// Mark a message as failed. Removes it if max retries exceeded.
|
|
func markFailed(_ id: UUID) {
|
|
guard let idx = pending.firstIndex(where: { $0.id == id }) else { return }
|
|
pending[idx].retryCount += 1
|
|
if pending[idx].retryCount >= maxRetries {
|
|
logger.warning("Message \(id.uuidString, privacy: .public) exceeded max retries, removing")
|
|
pending.remove(at: idx)
|
|
}
|
|
save()
|
|
}
|
|
|
|
/// Clear all pending messages.
|
|
func clear() {
|
|
pending.removeAll()
|
|
save()
|
|
logger.info("Cleared all pending messages")
|
|
}
|
|
|
|
private func load() {
|
|
guard FileManager.default.fileExists(atPath: fileURL.path) else { return }
|
|
do {
|
|
let data = try Data(contentsOf: fileURL)
|
|
let decoded = try JSONDecoder().decode([PendingMessage].self, from: data)
|
|
pending = decoded
|
|
logger.info("Loaded \(decoded.count) pending messages from disk")
|
|
} catch {
|
|
logger.error("Failed to load offline queue: \(error.localizedDescription)")
|
|
}
|
|
}
|
|
|
|
private func save() {
|
|
do {
|
|
let data = try JSONEncoder().encode(pending)
|
|
try data.write(to: fileURL, options: .atomic)
|
|
} catch {
|
|
logger.error("Failed to save offline queue: \(error.localizedDescription)")
|
|
}
|
|
}
|
|
}
|