diff --git a/Code.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Code.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 11431824b..4ed943956 100644 --- a/Code.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Code.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "bfbaca6df065c0441a76cea8f5de36ddcc5deb1584f25cf135fc57d082acd454", + "originHash" : "4a4f991f35e10ddca66ba10d214729d965d97db3e9a2bc168bb6c28231895a53", "pins" : [ { "identity" : "abseil-cpp-binary", @@ -87,8 +87,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/code-payments/flipcash2-client-protocol", "state" : { - "revision" : "27e3f09a82f6fbd41c03026ee738f943f4ed465e", - "version" : "0.4.0" + "revision" : "524cfbee86388ee0b13078d6159e4d2961fe130a", + "version" : "0.5.0" } }, { diff --git a/Flipcash/Core/Controllers/ContactSyncController.swift b/Flipcash/Core/Controllers/ContactSyncController.swift index 43eab2c01..4607c68ae 100644 --- a/Flipcash/Core/Controllers/ContactSyncController.swift +++ b/Flipcash/Core/Controllers/ContactSyncController.swift @@ -51,7 +51,7 @@ final class ContactSyncController { /// Set once, during the user's first contact scan, to the number of the /// user's contacts the server matched. Gated on the durable `contactsConnected` /// flag (UserDefaults, not the DB) so it fires once per device and never - /// re-fires after a `SQLiteVersion` rebuild, later syncs, or screen opens. + /// re-fires after a `schemaVersion` rebuild, later syncs, or screen opens. var onFlipcashMatchCount: Int? nonisolated private var ownerKeyPair: KeyPair { @@ -276,7 +276,7 @@ final class ContactSyncController { await resolveDirectory() // Fire the one-time "already on Flipcash" dialog on the first connect. - // The flag lives in UserDefaults, not the DB, so a `SQLiteVersion` + // The flag lives in UserDefaults, not the DB, so a `schemaVersion` // rebuild never re-fires it for an existing user. await MainActor.run { guard UserDefaults.contactsConnected != true else { return } @@ -546,7 +546,7 @@ extension ContactSyncController: DMContactNaming { extension UserDefaults { /// `true` once this device has completed its first contact connect. Gates the /// one-time "already on Flipcash" dialog; persisted here rather than in the - /// per-account SQLite store so a `SQLiteVersion` rebuild doesn't re-fire it. + /// per-account SQLite store so a `schemaVersion` rebuild doesn't re-fire it. @Defaults(.contactsConnected) static var contactsConnected: Bool? } diff --git a/Flipcash/Core/Controllers/Onramp/CoinbaseOrderEmail.swift b/Flipcash/Core/Controllers/Onramp/CoinbaseOrderEmail.swift index 52690752b..f806534dc 100644 --- a/Flipcash/Core/Controllers/Onramp/CoinbaseOrderEmail.swift +++ b/Flipcash/Core/Controllers/Onramp/CoinbaseOrderEmail.swift @@ -12,7 +12,7 @@ import FlipcashCore /// /// The email flow writes the fallback when `requireCoinbaseEmailVerification` /// is off; logout clears it. It lives in UserDefaults rather than SQLite -/// because the server never sees it — a `SQLiteVersion` rebuild (which +/// because the server never sees it — a `schemaVersion` rebuild (which /// restores only server data) would lose it. enum CoinbaseOrderEmail { diff --git a/Flipcash/Core/Session/SessionAuthenticator.swift b/Flipcash/Core/Session/SessionAuthenticator.swift index 5aa9af056..6710ac1d5 100644 --- a/Flipcash/Core/Session/SessionAuthenticator.swift +++ b/Flipcash/Core/Session/SessionAuthenticator.swift @@ -331,7 +331,7 @@ final class SessionAuthenticator { // the user version is outdated, we'll rebuild the // database during sync. let userVersion = (try? Database.userVersion(files: files)) ?? 0 - let currentVersion = try InfoPlist.value(for: "SQLiteVersion").integer() + let currentVersion = Database.schemaVersion if currentVersion > userVersion { try Database.deleteStore(files: files) logger.error("Outdated user version, deleted database.") diff --git a/Flipcash/Supporting Files/Info.plist b/Flipcash/Supporting Files/Info.plist index e5d12f210..1bcdc4734 100644 --- a/Flipcash/Supporting Files/Info.plist +++ b/Flipcash/Supporting Files/Info.plist @@ -22,8 +22,6 @@ INSendMessageIntent com.flipcash.app.openChat - SQLiteVersion - 35 UIApplicationSceneManifest UIApplicationSupportsMultipleScenes diff --git a/FlipcashAPI/Package.swift b/FlipcashAPI/Package.swift index 9babd2a7a..075cfa234 100644 --- a/FlipcashAPI/Package.swift +++ b/FlipcashAPI/Package.swift @@ -35,7 +35,7 @@ let contractDependencies: [Package.Dependency] = protoLocalRoot.map { root in ] } ?? [ .package(url: "https://github.com/code-payments/ocp-client-protocol", exact: "0.3.0"), - .package(url: "https://github.com/code-payments/flipcash2-client-protocol", exact: "0.4.0"), + .package(url: "https://github.com/code-payments/flipcash2-client-protocol", exact: "0.5.0"), ] let package = Package( diff --git a/FlipcashCore/Sources/FlipcashCore/Push/NotificationPayload.swift b/FlipcashCore/Sources/FlipcashCore/Push/NotificationPayload.swift index 88b52d222..aaf97793e 100644 --- a/FlipcashCore/Sources/FlipcashCore/Push/NotificationPayload.swift +++ b/FlipcashCore/Sources/FlipcashCore/Push/NotificationPayload.swift @@ -45,4 +45,19 @@ public enum NotificationPayload { } return ConversationType(payload.chatMetadata.type) } + + /// The message a CHAT push carries inline, or `nil` when the push isn't a chat message, carries + /// no chat metadata, predates the server embedding the message, or carries content this client + /// can't represent. + /// + /// The embedded message is the only part of a push that needs no network to become store rows. + /// It carries the same `eventSequence` the transcript fetch would return for it, so it merges + /// with a fetched message rather than competing with one. + public static func chatMessage(_ userInfo: [AnyHashable: Any]) -> ConversationMessage? { + guard let payload = decode(userInfo), payload.category == .chat, payload.hasChatMetadata else { + return nil + } + guard payload.chatMetadata.hasMessage else { return nil } + return ConversationMessage(payload.chatMetadata.message) + } } diff --git a/FlipcashCore/Sources/FlipcashStore/Database.swift b/FlipcashCore/Sources/FlipcashStore/Database.swift index b95f6268f..de86c3409 100644 --- a/FlipcashCore/Sources/FlipcashStore/Database.swift +++ b/FlipcashCore/Sources/FlipcashStore/Database.swift @@ -183,6 +183,18 @@ nonisolated open class Database: @unchecked Sendable { // MARK: - Versioning - + /// The schema version this build writes. + /// + /// A launch that finds a lower version recorded beside the store deletes the store and rebuilds + /// it from sync, which is the project's substitute for schema migrations. Bump this whenever a + /// table definition in `Schema.swift` changes. + /// + /// This used to be the `SQLiteVersion` key in the app's `Info.plist`. It moved into code because + /// the notification service extension needs the same number to decide whether the store on disk + /// is one it understands, and an extension cannot read the app's `Info.plist` — separate bundles. + /// Both targets link this module, so they cannot disagree. + public static let schemaVersion = 35 + /// Removes the store and the write-ahead log files beside it. /// /// The version file is deliberately left alone: the caller deletes the store because the diff --git a/FlipcashCore/Sources/FlipcashStore/ExtensionStore.swift b/FlipcashCore/Sources/FlipcashStore/ExtensionStore.swift new file mode 100644 index 000000000..beca61fc3 --- /dev/null +++ b/FlipcashCore/Sources/FlipcashStore/ExtensionStore.swift @@ -0,0 +1,105 @@ +// +// ExtensionStore.swift +// FlipcashStore +// +// Copyright © 2026 Code Inc. All rights reserved. +// + +import Foundation +import FlipcashCore +import SQLite +// For `SQLITE_BUSY`. SQLite.swift re-exports the connection API but not the result codes. +import SQLite3 + +nonisolated private let logger = Logger(label: "flipcash.database.extension") + +/// A bounded open-write-close cycle over the shared store, for callers that are not the app. +/// +/// The app opens the store once at login and keeps it open. An extension cannot: it is woken for a +/// few seconds and then suspended, and a process suspended while holding a lock on App Group storage +/// is the case iOS terminates with `0xdead10cc`. So every write from outside the app opens the store, +/// writes, checkpoints, and closes within one call. +/// +/// The two guards in front of that cycle matter more than the cycle itself, and neither is a +/// nicety — see ``perform(owner:location:fileManager:schemaVersion:_:)``. +nonisolated public enum ExtensionStore { + + public enum Outcome: Equatable { + /// The body ran and the store was checkpointed and closed. + case wrote + /// No store at the shared location. Nothing was created. + case noStore + /// The version recorded beside the store is not the one this build writes. + case versionMismatch(recorded: Int?) + /// Another process held the write lock for longer than the busy timeout. + case busy + case failed(String) + } + + /// Opens the owner's store, runs `body` against it, then checkpoints and closes. + /// + /// Returns rather than throws. Every outcome here is one the caller answers by doing nothing — + /// a notification extension has no way to surface a store problem to the user, and no reason to + /// fail delivery over one. + /// + /// **This never creates a store.** `Database.init` would happily create one, and an empty store + /// appearing at the shared path before the app has migrated is data loss, not an inconvenience: + /// `StoreMigration` reads an existing destination as a finished migration and deletes the legacy + /// store, so the user's history would go with it. The existence check is what prevents that. + /// + /// **It also never writes into a schema it does not match.** A recorded version lower than + /// ``Database/schemaVersion`` means the app has not yet rebuilt the store for this build, and a + /// higher one means a newer build wrote it and the user has since downgraded. Rebuilding belongs + /// to the app, so both cases no-op. + @discardableResult + public static func perform( + owner: PublicKey, + location: StoreLocation = .resolved(), + fileManager: FileManager = .default, + schemaVersion: Int = Database.schemaVersion, + _ body: (Database) throws -> Void + ) -> Outcome { + let files = location.files(owner: owner) + + guard fileManager.fileExists(atPath: files.database.path) else { + return .noStore + } + + let recorded = try? Database.userVersion(files: files) + guard recorded == schemaVersion else { + return .versionMismatch(recorded: recorded) + } + + do { + let database = try Database(url: files.database) + // Checkpoints and drops both connections. Runs even when `body` throws: a store left + // open is the thing this type exists to avoid. + defer { try? database.close() } + try body(database) + return .wrote + + } catch let error as SQLite.Result where error.isBusy { + // The app holds the write lock. Giving up is correct — whatever this write was carrying, + // the app is in a better position to fetch it than the extension is to wait for it. + return .busy + + } catch { + logger.error("Extension store write failed", metadata: ["error": "\(error)"]) + return .failed("\(error)") + } + } +} + +extension SQLite.Result { + + /// Whether this is `SQLITE_BUSY`, under either the primary or an extended result code. + /// + /// Extended codes carry the primary code in their low byte, so one mask covers both shapes. + var isBusy: Bool { + let code: Int32 = switch self { + case .error(_, let code, _): code + case .extendedError(_, let extendedCode, _): extendedCode + } + return code & 0xFF == SQLITE_BUSY + } +} diff --git a/FlipcashCore/Tests/FlipcashCoreTests/ProfileTests.swift b/FlipcashCore/Tests/FlipcashCoreTests/ProfileTests.swift index 5b00469d0..1893b71a5 100644 --- a/FlipcashCore/Tests/FlipcashCoreTests/ProfileTests.swift +++ b/FlipcashCore/Tests/FlipcashCoreTests/ProfileTests.swift @@ -64,7 +64,7 @@ struct ProfileTests { /// Profiles persist as a JSON blob in a single-row table, so adding /// `profilePicture` is only safe if rows written before it still decode. - /// This is the whole reason the change ships without a `SQLiteVersion` bump. + /// This is the whole reason the change ships without a `schemaVersion` bump. @Test("A row persisted before profile pictures still decodes") func decodesProfilePersistedBeforeProfilePictures() throws { let legacy = Data(#"{"displayName":"Ted Livingston","email":"ted@example.com"}"#.utf8) @@ -181,7 +181,7 @@ struct ProfileTests { /// Profiles persist as a JSON blob, so `username` is optional and rows /// written before it still decode — which is why this ships without a - /// `SQLiteVersion` bump. + /// `schemaVersion` bump. @Test("A row persisted before usernames still decodes") func decodesProfilePersistedBeforeUsernames() throws { let legacy = Data(#"{"displayName":"Ted Livingston","email":"ted@example.com"}"#.utf8) diff --git a/FlipcashCore/Tests/FlipcashCoreTests/Push/NotificationPayloadTests.swift b/FlipcashCore/Tests/FlipcashCoreTests/Push/NotificationPayloadTests.swift index 314ccf394..55ebd06a2 100644 --- a/FlipcashCore/Tests/FlipcashCoreTests/Push/NotificationPayloadTests.swift +++ b/FlipcashCore/Tests/FlipcashCoreTests/Push/NotificationPayloadTests.swift @@ -165,4 +165,84 @@ struct NotificationPayloadTests { let userInfo = [NotificationPayload.userInfoKey: try Self.base64(for: payload)] #expect(NotificationPayload.chatType(userInfo) == nil) } + + // MARK: - chatMessage - + + private static func chatPush( + category: Flipcash_Push_V1_Payload.Category = .chat, + message: Flipcash_Messaging_V1_Message? + ) throws -> [String: String] { + let payload = Flipcash_Push_V1_Payload.with { + $0.category = category + $0.chatMetadata = .with { + $0.type = .contactDm + if let message { $0.message = message } + } + } + return [NotificationPayload.userInfoKey: try Self.base64(for: payload)] + } + + @Test("chatMessage maps the message embedded in the push") + func chatMessageFromMetadata() throws { + let senderUUID = UUID() + let embedded = Flipcash_Messaging_V1_Message.with { + $0.messageID = .with { $0.value = 42 } + $0.senderID = .with { $0.value = senderUUID.data } + $0.content = [.with { $0.text = .with { $0.text = "see you there" } }] + $0.ts = .init(date: Date(timeIntervalSince1970: 1_700_000_000)) + $0.eventSequence = 9 + $0.unreadSeq = 4 + } + + let message = try #require(NotificationPayload.chatMessage(try Self.chatPush(message: embedded))) + #expect(message.id == MessageID(value: 42)) + #expect(message.senderID == senderUUID) + #expect(message.content == .text("see you there")) + #expect(message.date == Date(timeIntervalSince1970: 1_700_000_000)) + #expect(message.unreadSeq == 4) + } + + /// The whole reason the embedded message can merge with a fetched one instead of duplicating it. + @Test("chatMessage preserves the event sequence the transcript fetch would return") + func chatMessageCarriesEventSequence() throws { + let embedded = Flipcash_Messaging_V1_Message.with { + $0.messageID = .with { $0.value = 42 } + $0.content = [.with { $0.text = .with { $0.text = "hi" } }] + $0.eventSequence = 9 + } + + let message = try #require(NotificationPayload.chatMessage(try Self.chatPush(message: embedded))) + #expect(message.eventSequence == 9) + } + + /// A server that predates the embedded message, which is every server until 0.5.0 ships. + @Test("chatMessage is nil when the push carries no embedded message") + func chatMessageNilWhenAbsent() throws { + #expect(NotificationPayload.chatMessage(try Self.chatPush(message: nil)) == nil) + } + + @Test("chatMessage is nil for a non-chat category even when a message is embedded") + func chatMessageNilForNonChatCategory() throws { + let embedded = Flipcash_Messaging_V1_Message.with { + $0.messageID = .with { $0.value = 42 } + $0.content = [.with { $0.text = .with { $0.text = "hi" } }] + } + #expect(NotificationPayload.chatMessage(try Self.chatPush(category: .default, message: embedded)) == nil) + } + + /// `ConversationMessage.init?` rejects content this client can't draw. The accessor has to pass + /// that nil through rather than substituting an empty message. + @Test("chatMessage is nil for embedded content the client cannot represent") + func chatMessageNilForUnrepresentableContent() throws { + let embedded = Flipcash_Messaging_V1_Message.with { + $0.messageID = .with { $0.value = 42 } + $0.content = [.with { $0.system = .with { _ in } }] + } + #expect(NotificationPayload.chatMessage(try Self.chatPush(message: embedded)) == nil) + } + + @Test("chatMessage is nil when no payload is present") + func chatMessageNilWhenNoPayload() { + #expect(NotificationPayload.chatMessage([:]) == nil) + } } diff --git a/FlipcashTests/ContactSyncControllerTests.swift b/FlipcashTests/ContactSyncControllerTests.swift index 0ba419d99..f09781f6c 100644 --- a/FlipcashTests/ContactSyncControllerTests.swift +++ b/FlipcashTests/ContactSyncControllerTests.swift @@ -779,7 +779,7 @@ struct ContactSyncControllerTests { #expect(controller.onFlipcashMatchCount == nil) } - @Test("A SQLiteVersion rebuild does not re-fire the first-connect dialog") + @Test("A schemaVersion rebuild does not re-fire the first-connect dialog") func schemaRebuild_doesNotReSignal() async throws { UserDefaults.contactsConnected = nil let contacts = [Self.aliceContact, Self.bobContact] @@ -791,7 +791,7 @@ struct ContactSyncControllerTests { try await firstController.performSync(contacts: contacts) #expect(firstController.onFlipcashMatchCount == 1) - // A SQLiteVersion bump deletes and rebuilds the DB, so the stored + // A schemaVersion bump deletes and rebuilds the DB, so the stored // checksum is gone and this sync takes the first-scan (full upload) // path again — but the durable flag survives, so it must stay silent. let rebuiltMock = MockContactSync() diff --git a/FlipcashTests/ExtensionStoreTests.swift b/FlipcashTests/ExtensionStoreTests.swift new file mode 100644 index 000000000..ff1df30ae --- /dev/null +++ b/FlipcashTests/ExtensionStoreTests.swift @@ -0,0 +1,267 @@ +// +// ExtensionStoreTests.swift +// FlipcashTests +// + +import Foundation +import Testing +import SQLite +import FlipcashCore +import FlipcashStore +@testable import Flipcash + +@Suite("Extension store writes") +struct ExtensionStoreTests { + + private let owner = try! PublicKey(Data(repeating: 9, count: 32)) + + /// A location standing in for the App Group container. Both directories are the same here: + /// these tests are about what happens once the store has arrived, not about the move. + private func makeLocation() throws -> StoreLocation { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("extension-store-\(UUID().uuidString)") + let group = root.appendingPathComponent("group") + let support = root.appendingPathComponent("support") + try FileManager.default.createDirectory(at: group, withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: support, withIntermediateDirectories: true) + return StoreLocation(directory: group, legacyDirectory: support, isShared: true) + } + + /// Creates the store the way a logged-in app leaves it: tables built, version recorded. + private func seedStore(at location: StoreLocation, version: Int = Database.schemaVersion) throws { + let files = location.files(owner: owner) + let database = try Database(url: files.database) + try database.close() + try Database.setUserVersion(version: version, files: files) + } + + private func message(id: UInt64, text: String, sequence: UInt64 = 1) -> ConversationMessage { + ConversationMessage( + id: MessageID(value: id), + senderID: UUID(), + content: .text(text), + date: Date(timeIntervalSince1970: TimeInterval(id)), + unreadSeq: id, + eventSequence: sequence + ) + } + + private func exists(_ url: URL) -> Bool { + FileManager.default.fileExists(atPath: url.path) + } + + // MARK: - The guards - + + @Test("no store at the shared location is a no-op, and creates nothing") + func missingStoreCreatesNothing() throws { + let location = try makeLocation() + let files = location.files(owner: owner) + + let outcome = ExtensionStore.perform(owner: owner, location: location) { _ in + Issue.record("the body must not run without a store") + } + + #expect(outcome == .noStore) + // The point of the guard. An empty store here reads to `StoreMigration` as a finished + // migration, which would make it delete the legacy store the user's history is still in. + #expect(!exists(files.database)) + #expect(!exists(files.wal)) + #expect(!exists(files.version)) + } + + @Test("a store with no recorded version is left alone") + func missingVersionFileIsSkipped() throws { + let location = try makeLocation() + let files = location.files(owner: owner) + let database = try Database(url: files.database) + try database.close() + + let outcome = ExtensionStore.perform(owner: owner, location: location) { _ in + Issue.record("the body must not run without a recorded version") + } + + #expect(outcome == .versionMismatch(recorded: nil)) + } + + @Test("a store recorded at an older schema is left for the app to rebuild") + func olderSchemaIsSkipped() throws { + let location = try makeLocation() + try seedStore(at: location, version: Database.schemaVersion - 1) + + let outcome = ExtensionStore.perform(owner: owner, location: location) { _ in + Issue.record("the body must not run against a stale schema") + } + + #expect(outcome == .versionMismatch(recorded: Database.schemaVersion - 1)) + } + + @Test("a store recorded at a newer schema is left alone too") + func newerSchemaIsSkipped() throws { + // A user who installed a newer build and then downgraded. This build's `Schema` is the + // older one, so writing through it could hit a column that no longer means what it did. + let location = try makeLocation() + try seedStore(at: location, version: Database.schemaVersion + 1) + + let outcome = ExtensionStore.perform(owner: owner, location: location) { _ in + Issue.record("the body must not run against a newer schema") + } + + #expect(outcome == .versionMismatch(recorded: Database.schemaVersion + 1)) + } + + // MARK: - Writing - + + @Test("messages written by the extension are there for the app to read") + func writesAreVisibleToTheApp() throws { + let location = try makeLocation() + try seedStore(at: location) + let conversationID = ConversationID.test(3) + + let outcome = ExtensionStore.perform(owner: owner, location: location) { database in + try database.persistMessages( + [message(id: 1, text: "first"), message(id: 2, text: "second")], + cursor: 0, + conversationID: conversationID + ) + } + #expect(outcome == .wrote) + + // Reopened the way the app opens it at login, which is the only read that matters. + let app = try Database(url: location.files(owner: owner).database) + let stored = try app.messagesWindow(conversationID: conversationID, limit: 10) + #expect(stored.count == 2) + #expect(stored.map(\.id.value).sorted() == [1, 2]) + try app.close() + } + + @Test("the catch-up cursor does not move") + func cursorIsNotAdvanced() throws { + let location = try makeLocation() + try seedStore(at: location) + let conversationID = ConversationID.test(4) + + #expect( + ExtensionStore.perform(owner: owner, location: location) { database in + try database.persistMessages([message(id: 9, text: "preview")], cursor: 0, conversationID: conversationID) + } == .wrote + ) + + // The extension fetched a bounded preview, not a delta. A cursor advanced to it would tell + // the app it has everything up to that point and make the next sync skip the gap. + let app = try Database(url: location.files(owner: owner).database) + #expect(try app.catchupCursor(conversationID: conversationID) == 0) + try app.close() + } + + @Test("writing the same preview twice changes nothing") + func repeatedWritesMerge() throws { + let location = try makeLocation() + try seedStore(at: location) + let conversationID = ConversationID.test(5) + let batch = [message(id: 1, text: "once"), message(id: 2, text: "twice")] + + for _ in 0..<3 { + #expect( + ExtensionStore.perform(owner: owner, location: location) { database in + try database.persistMessages(batch, cursor: 0, conversationID: conversationID) + } == .wrote + ) + } + + let app = try Database(url: location.files(owner: owner).database) + #expect(try app.messagesWindow(conversationID: conversationID, limit: 10).count == 2) + try app.close() + } + + @Test("a stale re-delivery does not overwrite a newer stored message") + func staleDeliveryLoses() throws { + let location = try makeLocation() + try seedStore(at: location) + let conversationID = ConversationID.test(6) + + _ = ExtensionStore.perform(owner: owner, location: location) { database in + try database.persistMessages([message(id: 1, text: "edited", sequence: 5)], cursor: 0, conversationID: conversationID) + } + _ = ExtensionStore.perform(owner: owner, location: location) { database in + try database.persistMessages([message(id: 1, text: "original", sequence: 2)], cursor: 0, conversationID: conversationID) + } + + let app = try Database(url: location.files(owner: owner).database) + let stored = try app.messagesWindow(conversationID: conversationID, limit: 10) + #expect(stored.count == 1) + if case .text(let text) = stored.first?.content { + #expect(text == "edited") + } else { + Issue.record("expected a text message") + } + try app.close() + } + + // MARK: - Closing - + + @Test("the cycle leaves no write-ahead log behind") + func storeIsCheckpointedAndClosed() throws { + let location = try makeLocation() + try seedStore(at: location) + let files = location.files(owner: owner) + + #expect( + ExtensionStore.perform(owner: owner, location: location) { database in + try database.persistMessages([message(id: 1, text: "flushed")], cursor: 0, conversationID: .test(7)) + } == .wrote + ) + + // A truncating checkpoint ran and both connections were dropped, so anything left is empty. + // A log with bytes in it would mean the extension went away still holding the store open — + // the shape that gets a process killed with `0xdead10cc`. + let walSize = (try? FileManager.default.attributesOfItem(atPath: files.wal.path)[.size] as? Int) ?? 0 + #expect(walSize == 0) + } + + @Test("a throwing body still closes the store, and commits nothing") + func failureStillCloses() throws { + let location = try makeLocation() + try seedStore(at: location) + let conversationID = ConversationID.test(8) + + struct Boom: Error {} + let outcome = ExtensionStore.perform(owner: owner, location: location) { database in + try database.persistMessages([message(id: 1, text: "rolled back")], cursor: 0, conversationID: conversationID) + throw Boom() + } + + guard case .failed = outcome else { + Issue.record("expected a failure, got \(outcome)") + return + } + + // `persistMessages` committed its own transaction before the throw, so the row is there. + // What matters is that the store reopens at all, which it cannot if the cycle leaked a + // connection or left the file locked. + let app = try Database(url: location.files(owner: owner).database) + #expect(try app.messagesWindow(conversationID: conversationID, limit: 10).count == 1) + try app.close() + } + + // MARK: - Contention - + + @Test("another process holding the write lock ends the cycle instead of waiting") + func busyLockGivesUp() throws { + let location = try makeLocation() + try seedStore(at: location) + let files = location.files(owner: owner) + + // Stands in for the app mid-transaction. `BEGIN EXCLUSIVE` takes the write lock and holds + // it for as long as this connection is alive. + let holder = try Connection(files.database.path) + try holder.run("PRAGMA journal_mode = WAL;") + try holder.run("BEGIN EXCLUSIVE;") + defer { try? holder.run("ROLLBACK;") } + + let outcome = ExtensionStore.perform(owner: owner, location: location) { database in + try database.persistMessages([message(id: 1, text: "contended")], cursor: 0, conversationID: .test(9)) + } + + #expect(outcome == .busy) + } +} diff --git a/FlipcashTests/StoreMigrationContainerTests.swift b/FlipcashTests/StoreMigrationContainerTests.swift new file mode 100644 index 000000000..6f39cbda6 --- /dev/null +++ b/FlipcashTests/StoreMigrationContainerTests.swift @@ -0,0 +1,162 @@ +// +// StoreMigrationContainerTests.swift +// FlipcashTests +// + +import Foundation +import Testing +import SQLite +import FlipcashCore +import FlipcashStore +@testable import Flipcash + +/// The migration against the containers the app actually uses, rather than two temporary +/// directories standing in for them. +/// +/// `StoreMigrationTests` covers the logic — interruption, sweeping, version adoption — with injected +/// paths. What it cannot cover is the part of a real upgrade that runs before any of that logic: +/// resolving the App Group container. If the entitlement is not active at runtime, +/// `StoreLocation.resolved` falls back to Application Support, both sides name the same file, the +/// migration correctly reports `.notNeeded`, and the extension silently never sees the store. A test +/// that builds its own `StoreLocation` cannot see that failure. +/// +/// Every file here is named from a random owner key. Store paths are owner-scoped +/// (`flipcash-.sqlite`), so nothing here can name a real account's store even though it sits +/// in the real directories, and each test removes what it wrote. +@Suite("Store migration, real containers", .serialized) +struct StoreMigrationContainerTests { + + /// A key no account holds, so these file names cannot collide with a real store. + private func makeOwner() throws -> PublicKey { + var bytes = Data(count: 32) + for index in bytes.indices { + bytes[index] = UInt8.random(in: .min ... .max) + } + return try PublicKey(bytes) + } + + /// The shape a shipped build leaves in Application Support: a WAL-mode store with one row, and + /// the schema version recorded beside it under the name that build writes. + /// + /// Scoped so the connection closes before the migration runs. A live connection would keep the + /// `-shm` on disk and the checkpoint would have company. + private func seedLegacyStore(at files: StoreLocation.Files, value: String) throws { + let connection = try Connection(files.database.path) + try connection.run("PRAGMA journal_mode = WAL;") + try connection.run("CREATE TABLE probe (id INTEGER PRIMARY KEY, value TEXT);") + try connection.run("INSERT INTO probe (value) VALUES (?);", value) + try Database.setUserVersion(version: 35, files: files) + } + + private func exists(_ url: URL) -> Bool { + FileManager.default.fileExists(atPath: url.path) + } + + private func remove(_ groups: StoreLocation.Files...) { + for group in groups { + for url in [group.database, group.wal, group.shm, group.version] { + try? FileManager.default.removeItem(at: url) + } + } + } + + @Test("the App Group container resolves, so the store lands outside the app's own container") + func containerResolves() { + let location = StoreLocation.resolved() + + // The app's private container root, two levels above Application Support. Anything under it + // is visible to this process only, which is the arrangement the move exists to end. The + // container's own path is not checked for the group identifier: the simulator spells it out, + // a device names the directory by UUID instead. + let privateContainer = location.legacyDirectory + .deletingLastPathComponent() + .deletingLastPathComponent() + + #expect(location.isShared) + #expect(location.directory != location.legacyDirectory) + #expect(!location.directory.path.hasPrefix(privateContainer.path)) + } + + @Test("a store in the real Application Support directory moves into the real App Group container") + func upgradeMovesTheStore() throws { + let owner = try makeOwner() + let location = StoreLocation.resolved() + let legacy = location.legacyFiles(owner: owner) + let current = location.files(owner: owner) + defer { remove(legacy, current) } + + try FileManager.default.createDirectory( + at: location.legacyDirectory, + withIntermediateDirectories: true + ) + try seedLegacyStore(at: legacy, value: "survived-the-upgrade") + + let outcome = StoreMigration.migrateIfNeeded(owner: owner, location: location) + + #expect(outcome == .migrated) + #expect(exists(current.database)) + #expect(!exists(legacy.database)) + #expect(!exists(legacy.version)) + + // The recorded version has to arrive with the store. 35 is what the shipped build wrote from + // its `SQLiteVersion` Info.plist key and what this build reads from `Database.schemaVersion`; + // if the number did not travel, the launch that just migrated reads 0, decides the schema is + // stale, and deletes the store it moved. + let recorded = (try? Database.userVersion(files: current)) ?? 0 + #expect(recorded == 35) + #expect(Database.schemaVersion <= recorded) + + // Reached through `Database`, which is how the app reads it — and a migrated store arrives as + // a lone `.sqlite` with no `-shm`, the case that needs the writer opened before the reader. + let database = try Database(url: current.database) + defer { try? database.close() } + let value = try database.reader.scalar("SELECT value FROM probe LIMIT 1;") as? String + #expect(value == "survived-the-upgrade") + } + + @Test("the extension opens the store the app migrated, at the path it resolves for itself") + func extensionReachesTheMigratedStore() throws { + let owner = try makeOwner() + let location = StoreLocation.resolved() + let legacy = location.legacyFiles(owner: owner) + defer { remove(legacy, location.files(owner: owner)) } + + try FileManager.default.createDirectory( + at: location.legacyDirectory, + withIntermediateDirectories: true + ) + try seedLegacyStore(at: legacy, value: "written-by-the-app") + #expect(StoreMigration.migrateIfNeeded(owner: owner, location: location) == .migrated) + + // No location passed: `ExtensionStore` resolves the container itself, the way the notification + // service extension does. That it finds this store is the whole point of the move. + var read: String? + let outcome = ExtensionStore.perform(owner: owner) { database in + read = try database.reader.scalar("SELECT value FROM probe LIMIT 1;") as? String + try database.writer.run("INSERT INTO probe (value) VALUES (?);", "written-by-the-extension") + } + + #expect(outcome == .wrote) + #expect(read == "written-by-the-app") + + // And back the other way: what the extension wrote is there for the app's next read. + let database = try Database(url: location.files(owner: owner).database) + defer { try? database.close() } + let count = try database.reader.scalar("SELECT count(*) FROM probe;") as? Int64 + #expect(count == 2) + } + + @Test("a push before the first migrated launch does not leave an empty store behind") + func extensionCreatesNothing() throws { + let owner = try makeOwner() + let current = StoreLocation.resolved().files(owner: owner) + defer { remove(current) } + + let outcome = ExtensionStore.perform(owner: owner) { _ in + Issue.record("The body must not run when there is no store at the shared path.") + } + + #expect(outcome == .noStore) + #expect(!exists(current.database)) + } +} diff --git a/NotificationService/NotificationService.swift b/NotificationService/NotificationService.swift index c503598d6..b2b2e00bb 100644 --- a/NotificationService/NotificationService.swift +++ b/NotificationService/NotificationService.swift @@ -9,6 +9,7 @@ import Contacts import Intents import FlipcashCore import FlipcashAPI +import FlipcashStore /// Rewrites contact pushes to use the user's local contact name, and renders /// "Sent You Cash" pushes as communication notifications carrying the sender's @@ -168,15 +169,23 @@ final class NotificationService: UNNotificationServiceExtension { // `UNNotificationContent`. Keeping the `Task` out of this `self`-isolated method is what lets // the region checker prove the closure crosses no isolation boundary with a non-`Sendable`. delivery.arm(handler: contentHandler, content: finalContent) - Self.startPrefetch(into: delivery, for: conversationID) + Self.startPrefetch( + into: delivery, + for: conversationID, + embedded: NotificationPayload.chatMessage(request.content.userInfo) + ) } /// Spawns the transcript prefetch and registers it on `delivery`. `nonisolated static` and taking /// only `Sendable` arguments, so the spawned `Task` captures nothing isolated to a `self` — which /// is what keeps the region checker satisfied and the hand-off thread-agnostic. - private nonisolated static func startPrefetch(into delivery: DeliveryBox, for conversationID: ConversationID) { + private nonisolated static func startPrefetch( + into delivery: DeliveryBox, + for conversationID: ConversationID, + embedded: ConversationMessage? + ) { let task = Task { - await cachePreview(for: conversationID, deliver: { delivery.deliver() }) + await cachePreview(for: conversationID, embedded: embedded, deliver: { delivery.deliver() }) } delivery.setPrefetchTask(task) } @@ -289,7 +298,11 @@ final class NotificationService: UNNotificationServiceExtension { /// connection. Calls `deliver` once the transcript is cached (or the fetch can't proceed) so the /// banner isn't gated on the slower branding round-trip. Best-effort: any failure just leaves the /// content extension to fetch live. - private static func cachePreview(for conversationID: ConversationID, deliver: @Sendable () -> Void) async { + private static func cachePreview( + for conversationID: ConversationID, + embedded: ConversationMessage?, + deliver: @Sendable () -> Void + ) async { guard let account = OwnerKeyStore.loadOwnerAccount() else { return deliver() } do { let client = try ChatNotificationClient() @@ -299,7 +312,12 @@ final class NotificationService: UNNotificationServiceExtension { limit: NotificationPreviewCache.previewLimit, retryingEmpty: true ) - guard !messages.isEmpty else { return deliver() } + guard !messages.isEmpty else { + // The fetch came back empty but the push still carried a message. Write that one + // rather than nothing. + await persist(merge(fetched: [], embedded: embedded), for: conversationID, account: account) + return deliver() + } func items(_ branding: [PublicKey: MintBrandingInfo]) -> [ChatItem] { ChatItem.preview( from: messages, @@ -312,6 +330,12 @@ final class NotificationService: UNNotificationServiceExtension { // round-trip, then enrich the cache with token names + icons best-effort — the bubble // renders fine without branding if it's slow or the extension is suspended first. NotificationPreviewCache.write(items([:]), for: conversationID) + + // Before `deliver()`, not after. The banner pays for the write — a few milliseconds plus + // roughly 100 ms of checkpoint — but after delivery there is nothing keeping the process + // alive, and being suspended mid-write while holding the App Group store's lock is the + // case iOS kills with `0xdead10cc`. + await persist(merge(fetched: messages, embedded: embedded), for: conversationID, account: account) deliver() let branding = (try? await client.resolveMintBranding(in: messages)) ?? [:] if !branding.isEmpty { @@ -319,9 +343,116 @@ final class NotificationService: UNNotificationServiceExtension { } } catch { // Best-effort prefetch — a transport failure: the content extension falls back to a live - // fetch on open. + // fetch on open. The store write does not fall back, because the embedded message needs + // no transport: an offline device still lands the message the push carried. ExtensionReporting.capture(error, reason: "Notification preview prefetch failed") + await persist(merge(fetched: [], embedded: embedded), for: conversationID, account: account) deliver() } } + + /// The messages to write, preferring the fetched copy of any message the push also embedded. + /// + /// The two sources overlap by exactly one message in the ordinary case — the push embeds what it + /// is notifying about, and the fetch returns that as its newest. They agree on `eventSequence`, + /// so writing both would converge anyway; deduplicating by ID keeps the write to one row per + /// message and keeps the fetched copy, which is the one the server rendered most recently. + private static func merge( + fetched: [ConversationMessage], + embedded: ConversationMessage? + ) -> [ConversationMessage] { + guard let embedded else { return fetched } + guard !fetched.contains(where: { $0.id == embedded.id }) else { return fetched } + return fetched + [embedded] + } + + /// Writes the messages this push produced into the shared store, so the app has them on next + /// launch instead of fetching them after the user opens the chat. + /// + /// The side-car cache is still written above and still owns the content extension's expand. This + /// is a second destination, not a replacement: the two have different readers, different + /// lifetimes, and the side-car does not need the store's schema to be current. + /// + /// `cursor: 0` on purpose. Advancing the catch-up cursor would tell the app it has everything up + /// to this point, and the extension fetched a bounded preview rather than a delta — the app would + /// skip the gap on its next sync. Messages merge on `eventSequence`, so writing the same preview + /// twice, or writing one the app already has, changes nothing. + /// + /// No conversation row is synthesized. A conversation the app has never seen stays absent from + /// the feed until sync introduces it; these rows are a warm transcript for a chat the user + /// already has, not a way to invent one. + private static func persist( + _ messages: [ConversationMessage], + for conversationID: ConversationID, + account: UserAccount + ) async { + // Nothing to write: no fetch and no embedded message. Opening the store to write zero rows + // would still cost a checkpoint. + guard !messages.isEmpty else { return } + + let owner = account.keyAccount.ownerPublicKey + + await withCheckedContinuation { (continuation: CheckedContinuation) in + let resume = OneShot { continuation.resume() } + + // The assertion is the mitigation: it asks the system to hold off suspension while the + // store is open. `performExpiringActivity` runs the block on its own queue and calls it a + // second time, on another thread, when the assertion is being revoked — hence `OneShot`, + // since resuming a continuation twice traps. + ProcessInfo.processInfo.performExpiringActivity(withReason: "flipcash.notification.store-write") { expired in + guard !expired else { + // Either the write already finished (and resumed), or it is mid-transaction, + // where interrupting it is worse than letting it commit. Just unblock the caller. + resume.fire() + return + } + + let outcome = ExtensionStore.perform(owner: owner) { database in + try database.persistMessages(messages, cursor: 0, conversationID: conversationID) + } + + switch outcome { + case .wrote, .noStore, .busy: + // All three are ordinary. `noStore` is a user who has not finished login on a + // build that owns the shared store; `busy` is the app holding the write lock, + // which means the app is running and will fetch this itself. + ExtensionReporting.breadcrumb("store write: \(outcome)") + case .versionMismatch(let recorded): + // The app rebuilds the store on its next launch. Worth a breadcrumb because a + // mismatch that persists means preload is silently off for this user. + ExtensionReporting.breadcrumb("store write skipped, schema \(recorded.map(String.init) ?? "none") != \(Database.schemaVersion)") + case .failed(let description): + ExtensionReporting.capture( + StoreWriteError.failed(description), + reason: "Notification store write failed" + ) + } + + resume.fire() + } + } + } + + private enum StoreWriteError: Error { + case failed(String) + } + + /// Runs its closure at most once, whichever thread gets there first. + private final class OneShot: @unchecked Sendable { + private let lock = NSLock() + private var action: (() -> Void)? + + init(_ action: @escaping () -> Void) { + self.action = action + } + + func fire() { + let captured = lock.withLock { () -> (() -> Void)? in + defer { action = nil } + return action + } + captured?() + } + } + }