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

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 3 additions & 3 deletions Flipcash/Core/Controllers/ContactSyncController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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 }
Expand Down Expand Up @@ -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?
}
2 changes: 1 addition & 1 deletion Flipcash/Core/Controllers/Onramp/CoinbaseOrderEmail.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {

Expand Down
2 changes: 1 addition & 1 deletion Flipcash/Core/Session/SessionAuthenticator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.")
Expand Down
2 changes: 0 additions & 2 deletions Flipcash/Supporting Files/Info.plist
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,6 @@
<string>INSendMessageIntent</string>
<string>com.flipcash.app.openChat</string>
</array>
<key>SQLiteVersion</key>
<integer>35</integer>
<key>UIApplicationSceneManifest</key>
<dict>
<key>UIApplicationSupportsMultipleScenes</key>
Expand Down
2 changes: 1 addition & 1 deletion FlipcashAPI/Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
15 changes: 15 additions & 0 deletions FlipcashCore/Sources/FlipcashCore/Push/NotificationPayload.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
12 changes: 12 additions & 0 deletions FlipcashCore/Sources/FlipcashStore/Database.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
105 changes: 105 additions & 0 deletions FlipcashCore/Sources/FlipcashStore/ExtensionStore.swift
Original file line number Diff line number Diff line change
@@ -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
}
}
4 changes: 2 additions & 2 deletions FlipcashCore/Tests/FlipcashCoreTests/ProfileTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
4 changes: 2 additions & 2 deletions FlipcashTests/ContactSyncControllerTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand All @@ -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()
Expand Down
Loading