crossmate

A collaborative crossword app for iOS
Log | Files | Refs | LICENSE

commit 24c45b27add4882006c862fe81c0661dc55fd25e
parent 6c25968e297c75234550735019ae7699851fab3a
Author: Michael Camilleri <[email protected]>
Date:   Wed, 10 Jun 2026 17:30:35 +0900

Extract the invite/ping manager from AppServices

This commit moves the friend-zone traffic into a composed
InviteCoordinator, the fourth extraction from AppServices: outbound game
invites (inviteFriend), inbound Ping handling (claim/dedup, staleness
GC, local notification presentation, friendship-bootstrap dispatch), the
durable InviteEntity rows behind the library's 'Invited' section,
accept/decline and friend blocking.

Code is moved verbatim with method names unchanged. AppServices keeps the
SyncEngine callback wiring and forwards into the coordinator; the badge
refresh is injected as a closure since pending invites count toward the
app-icon badge. The private Array.partitioned(by:) helper moves along
with its only caller, and InviteAcceptanceError now lives on the
coordinator.

Co-Authored-By: Claude Fable 5 <[email protected]>

Diffstat:
MCrossmate.xcodeproj/project.pbxproj | 4++++
MCrossmate/CrossmateApp.swift | 10+++++-----
MCrossmate/Services/AppServices.swift | 608+++----------------------------------------------------------------------------
ACrossmate/Services/InviteCoordinator.swift | 637+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
MTests/Unit/PuzzleNotificationTextTests.swift | 4++--
MTests/Unit/Sync/FriendModelTests.swift | 4++--
6 files changed, 672 insertions(+), 595 deletions(-)

diff --git a/Crossmate.xcodeproj/project.pbxproj b/Crossmate.xcodeproj/project.pbxproj @@ -54,6 +54,7 @@ 50C02D37A41D55CFA5D307E2 /* NYTPuzzleUpgraderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B34489D0864DF76AF436E391 /* NYTPuzzleUpgraderTests.swift */; }; 51E6F7F2FC52C2AA87B9DB45 /* PeerPresenceGraceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 08E8592B1CB1336E63498706 /* PeerPresenceGraceTests.swift */; }; 54464FDFB8C71B0D3B4B61A2 /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74FEFF257CDDD3EF0E77CBF7 /* SettingsView.swift */; }; + 59230713D85AE6895852B06A /* InviteCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 10064D171DB7C48D3DE1E769 /* InviteCoordinator.swift */; }; 5992AD4A06D7C6440825E9C6 /* GameArchiver.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1B7539E0AD285C5A3AC3DDA2 /* GameArchiver.swift */; }; 5ECF5B80D08E5E999A540782 /* SessionMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0EB332831AB173ACF6BFEC59 /* SessionMonitor.swift */; }; 5EFCD28B3B682DCCF38068D6 /* AnnouncementCenter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1D3ECD0DE71BE567BCEE15F6 /* AnnouncementCenter.swift */; }; @@ -205,6 +206,7 @@ 0E230B327585E1E3A2921C92 /* GameStoreCompletionLockTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GameStoreCompletionLockTests.swift; sourceTree = "<group>"; }; 0EB332831AB173ACF6BFEC59 /* SessionMonitor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionMonitor.swift; sourceTree = "<group>"; }; 0FD9A43789D0ED123F7A99B0 /* CheckResult.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CheckResult.swift; sourceTree = "<group>"; }; + 10064D171DB7C48D3DE1E769 /* InviteCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InviteCoordinator.swift; sourceTree = "<group>"; }; 11BF168D5C1CD85DAE5CAF9E /* PlayerSelectionPublisher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerSelectionPublisher.swift; sourceTree = "<group>"; }; 122BC1863D12DE06388D5DA7 /* GameStoreMergedAuthorCellsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GameStoreMergedAuthorCellsTests.swift; sourceTree = "<group>"; }; 14B05C19BD4705876B3DF0EC /* GridStateMerger.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GridStateMerger.swift; sourceTree = "<group>"; }; @@ -608,6 +610,7 @@ 4DB0580C9B7C778F34BE6AC2 /* EngagementLifecycle.swift */, 462CE0FD356F6137C9BFD30F /* ImportService.swift */, 6BDD06460A76D4AF31077732 /* InputMonitor.swift */, + 10064D171DB7C48D3DE1E769 /* InviteCoordinator.swift */, 33878A29B09A6154C7A63C82 /* KeychainHelper.swift */, A253416F4FEA271A80B22A73 /* NYTAuthService.swift */, B0938B0ACB40772EE522D77C /* NYTPuzzleFetcher.swift */, @@ -856,6 +859,7 @@ 4A89595E3F6AB50E1D9E6BA8 /* ImportService.swift in Sources */, 8478F0BC0CA624C78DC0A3B5 /* ImportedBrowseView.swift in Sources */, 1A19D13D9B820E276C60819E /* InputMonitor.swift in Sources */, + 59230713D85AE6895852B06A /* InviteCoordinator.swift in Sources */, 9502840161DB88BB6BB409D5 /* Journal.swift in Sources */, B5F78A55C9BCCD24E44D865F /* JournalReplay.swift in Sources */, F77177F48728ECEACD3B28B3 /* KeyboardView.swift in Sources */, diff --git a/Crossmate/CrossmateApp.swift b/Crossmate/CrossmateApp.swift @@ -43,16 +43,16 @@ struct CrossmateApp: App { } }) .environment(\.inviteFriend, { gameID, friendAuthorID in - try await services.inviteFriend(gameID: gameID, friendAuthorID: friendAuthorID) + try await services.invites.inviteFriend(gameID: gameID, friendAuthorID: friendAuthorID) }) .environment(\.acceptInvite, { shareURL, pingRecordName in - try await services.acceptInvite(shareURL: shareURL, pingRecordName: pingRecordName) + try await services.invites.acceptInvite(shareURL: shareURL, pingRecordName: pingRecordName) }) .environment(\.declineInvite, { gameID in - try await services.declineInvite(gameID: gameID) + try await services.invites.declineInvite(gameID: gameID) }) .environment(\.blockFriend, { friendAuthorID in - await services.blockFriend(authorID: friendAuthorID) + await services.invites.blockFriend(authorID: friendAuthorID) }) .environment(\.sendResignPings, { gameID in await services.sessions.sendCompletionPings(gameID: gameID, resigned: true) @@ -624,7 +624,7 @@ private struct PuzzleDisplayView: View { if !didAcceptInvite { didAcceptInvite = true do { - try await services.acceptPendingInvite(gameID: gameID) + try await services.invites.acceptPendingInvite(gameID: gameID) joinDeadline = Date() .addingTimeInterval(Self.inviteJoinTimeout) } catch { diff --git a/Crossmate/Services/AppServices.swift b/Crossmate/Services/AppServices.swift @@ -6,17 +6,6 @@ import UserNotifications @MainActor final class AppServices { - enum InviteAcceptanceError: LocalizedError { - case unavailable - - var errorDescription: String? { - switch self { - case .unavailable: - return "This invite is no longer available. Ask the sender to invite you again." - } - } - } - enum ReadCursorPublishMode { case activeLease case currentTime @@ -107,6 +96,24 @@ final class AppServices { await self?.publishAccountSeenPush(gameID: gameID, readAt: readAt) } ) + /// Friend-zone traffic — outbound invites, inbound ping handling, durable + /// invite rows, friendship bootstrap, blocking; see `InviteCoordinator`. + /// Lazy so the badge refresh can capture `self`. + private(set) lazy var invites = InviteCoordinator( + persistence: persistence, + identity: identity, + preferences: preferences, + syncMonitor: syncMonitor, + eventLog: eventLog, + syncEngine: syncEngine, + announcements: announcements, + shareController: shareController, + friendController: friendController, + cloudService: cloudService, + refreshAppBadge: { [weak self] in + await self?.badge.refreshAppBadge() + } + ) let preferences: PlayerPreferences @@ -137,9 +144,6 @@ final class AppServices { /// after the first cold-launch game-list freshen, not on every foreground, /// manual refresh, or remote-triggered refresh. private var shouldRunColdLaunchArchiveReconcile = true - private var claimedPingRecordNames: Set<String> = [] - private var claimedPingRecordNameOrder: [String] = [] - private let claimedPingRecordNameCap = 200 /// Wall-clock timestamp of the last successful game-list freshen per /// scope, used to suppress redundant polls when no inbound push has /// arrived since. Pushes own freshness; the freshen (zone discovery + @@ -440,7 +444,7 @@ final class AppServices { // Player record (their identity) — fires once per new collaborator, // not on moves and not on their later name / cursor updates. await syncEngine.setOnRemotePlayersUpdated { [weak self] gameIDs in - await self?.reconcileFriendships(forGameIDs: gameIDs) + await self?.invites.reconcileFriendships(forGameIDs: gameIDs) // A newly-arrived shared game means a new address slot to mint and // a token to register under it (so this device can receive the // game's pushes without opening it first). @@ -574,7 +578,7 @@ final class AppServices { await syncEngine.setOnPings { [weak self] pings in guard let self else { return } - await self.presentPings(pings) + await self.invites.presentPings(pings) } await syncEngine.setOnAccountChange { [weak self] in @@ -624,7 +628,7 @@ final class AppServices { // by side. `applyInvitePings` GCs the same row, but only when a // ping is next fetched. do { - try self.removePendingInvite(forGameID: gameID) + try self.invites.removePendingInvite(forGameID: gameID) // The pending invite (if any) is gone; drop it from the badge. await self.badge.refreshAppBadge() } catch { @@ -649,7 +653,7 @@ final class AppServices { // reached us, and clear any durable invite row backed by that Ping. await syncEngine.setOnPingDeleted { [weak self] pings in guard let self else { return } - try? self.removePendingInvites(forPingRecordNames: Set(pings.map { $0.recordName })) + try? self.invites.removePendingInvites(forPingRecordNames: Set(pings.map { $0.recordName })) await self.badge.refreshAppBadge() for gameID in Set(pings.map { $0.gameID }) { await self.badge.dismissDeliveredNotifications( @@ -1738,558 +1742,6 @@ final class AppServices { return result } - /// Re-invites an existing friend to a game: adds them as a participant on - /// the game's `CKShare` and writes an `.invite` Ping into the friend zone. - /// Surfaced to the UI via the `\.inviteFriend` environment closure. - func inviteFriend(gameID: UUID, friendAuthorID: String) async throws { - guard let localAuthorID = identity.currentID, !localAuthorID.isEmpty else { - throw FriendController.FriendError.friendNotFound - } - let ctx = persistence.viewContext - let req = NSFetchRequest<GameEntity>(entityName: "GameEntity") - req.predicate = NSPredicate(format: "id == %@", gameID as CVarArg) - req.fetchLimit = 1 - let title = (try? ctx.fetch(req).first)?.title ?? "" - - let url = try await shareController.addFriendParticipant( - toGameID: gameID, - userRecordName: friendAuthorID - ) - try await friendController.sendInvite( - toFriendAuthorID: friendAuthorID, - gameID: gameID, - gameTitle: title, - inviterAuthorID: localAuthorID, - inviterName: preferences.name, - gameShareURL: url - ) - } - - /// For each collaborative game with newly-known remote authors, asks the - /// `FriendController` to bootstrap a friendship. `establishIfOwner` is a - /// no-op for the non-owner and for already-established pairs (a defensive - /// backstop — the caller already fires this only on a Player record's - /// first sighting, so it runs about once per new collaborator). - private func reconcileFriendships(forGameIDs gameIDs: Set<UUID>) async { - guard preferences.isICloudSyncEnabled, - let localAuthorID = identity.currentID, - !localAuthorID.isEmpty - else { return } - - let ctx = persistence.container.newBackgroundContext() - let candidates: [(gameID: UUID, remoteAuthorID: String, remoteDisplayName: String?)] = ctx.performAndWait { - var result: [(UUID, String, String?)] = [] - for gameID in gameIDs { - let gReq = NSFetchRequest<GameEntity>(entityName: "GameEntity") - gReq.predicate = NSPredicate(format: "id == %@", gameID as CVarArg) - gReq.fetchLimit = 1 - guard let game = try? ctx.fetch(gReq).first else { continue } - // Only collaborative games carry other authors. - guard game.databaseScope == 1 || game.ckShareRecordName != nil else { continue } - - // Identity comes only from Player records — this feature is - // deliberately uninterested in Moves (the bootstrap trigger is - // the first sighting of a remote Player record). - var playersByAuthorID: [String: String?] = [:] - let pReq = NSFetchRequest<PlayerEntity>(entityName: "PlayerEntity") - pReq.predicate = NSPredicate(format: "game == %@", game) - for p in (try? ctx.fetch(pReq)) ?? [] { - guard let authorID = p.authorID else { continue } - playersByAuthorID[authorID] = p.name - } - playersByAuthorID.removeValue(forKey: localAuthorID) - playersByAuthorID.removeValue(forKey: CKCurrentUserDefaultName) - playersByAuthorID.removeValue(forKey: "") - for (authorID, name) in playersByAuthorID { - result.append((gameID, authorID, name)) - } - } - return result - } - - for (gameID, remoteAuthorID, remoteDisplayName) in candidates { - await friendController.establishIfOwner( - localAuthorID: localAuthorID, - remoteAuthorID: remoteAuthorID, - localDisplayName: preferences.name, - remoteDisplayName: remoteDisplayName, - viaGameID: gameID - ) - } - } - - /// Upserts a durable `InviteEntity` for each inbound `.invite` Ping so the - /// Game List's "Invited" section survives the Ping being GC'd. Skips - /// self-authored invites, invites not directed to this author, invites - /// from blocked friends, games already joined, and any `pingRecordName` - /// already seen (a declined row is a tombstone that prevents - /// resurrection). - private func applyInvitePings(_ pings: [Ping]) { - let invites = pings.filter { - $0.kind == .invite && - $0.authorID != identity.currentID && - $0.addressee == identity.currentID - } - guard !invites.isEmpty else { return } - - let ctx = persistence.container.newBackgroundContext() - ctx.performAndWait { - for ping in invites { - guard let payload = FriendZone.InvitePayload.decode(ping.payload) else { continue } - - let dupReq = NSFetchRequest<InviteEntity>(entityName: "InviteEntity") - dupReq.predicate = NSPredicate(format: "pingRecordName == %@", ping.recordName) - dupReq.fetchLimit = 1 - if ((try? ctx.count(for: dupReq)) ?? 0) > 0 { continue } - - let invite = InviteEntity(context: ctx) - invite.gameID = ping.gameID - invite.gameTitle = ping.puzzleTitle - invite.inviterAuthorID = ping.authorID - invite.inviterName = ping.playerName - invite.shareURL = payload.gameShareURL - invite.pingRecordName = ping.recordName - invite.status = "pending" - invite.createdAt = Date() - } - - // GC: a pending invite whose game now exists locally was joined by - // some other path (a link, or accepted on another device), so the - // "Invited" row is stale — drop it. - let pendingReq = NSFetchRequest<InviteEntity>(entityName: "InviteEntity") - pendingReq.predicate = NSPredicate(format: "status == %@", "pending") - for invite in (try? ctx.fetch(pendingReq)) ?? [] { - guard let gid = invite.gameID else { continue } - let gReq = NSFetchRequest<GameEntity>(entityName: "GameEntity") - gReq.predicate = NSPredicate(format: "id == %@", gid as CVarArg) - gReq.fetchLimit = 1 - if ((try? ctx.count(for: gReq)) ?? 0) > 0 { ctx.delete(invite) } - } - - if ctx.hasChanges { - do { - try ctx.save() - } catch { - Task { @MainActor [weak self] in - self?.eventLog.note("AppServices: applyInvitePings save failed — \(error)", level: "error") - } - } - } - } - } - - /// Drops the pending invite row(s) for `gameID`. Called when the game's - /// shared zone appears locally (joined here or on a sibling device): the - /// "Invited" row is now redundant. `applyInvitePings` runs the same - /// garbage-collection over every pending invite, but only when a ping is - /// fetched — hooking zone arrival closes the window where a just-synced - /// game and its stale invite show side by side in the library. - func removePendingInvite(forGameID gameID: UUID) throws { - let ctx = persistence.viewContext - let req = NSFetchRequest<InviteEntity>(entityName: "InviteEntity") - req.predicate = NSPredicate( - format: "gameID == %@ AND status == %@", gameID as CVarArg, "pending" - ) - let invites = try ctx.fetch(req) - guard !invites.isEmpty else { return } - for invite in invites { ctx.delete(invite) } - if ctx.hasChanges { - try ctx.save() - } - } - - /// Drops pending invite rows whose source Ping was consumed elsewhere on - /// this account. Matching by record name avoids conflating invite Pings - /// with other Ping kinds for the same game. - func removePendingInvites(forPingRecordNames recordNames: Set<String>) throws { - guard !recordNames.isEmpty else { return } - let ctx = persistence.viewContext - let req = NSFetchRequest<InviteEntity>(entityName: "InviteEntity") - req.predicate = NSPredicate( - format: "pingRecordName IN %@ AND status == %@", - Array(recordNames), - "pending" - ) - let invites = try ctx.fetch(req) - guard !invites.isEmpty else { return } - for invite in invites { ctx.delete(invite) } - if ctx.hasChanges { - try ctx.save() - } - } - - /// Accepts a pending game invite: fetches the share metadata, joins via - /// the existing share-accept path, then drops the local `InviteEntity` - /// (the game now represents it). If CloudKit says the share URL no longer - /// exists, the durable invite row is stale, so it is removed as well. - /// Surfaced via `\.acceptInvite`. - func acceptInvite(shareURL: String, pingRecordName: String) async throws { - guard let url = URL(string: shareURL) else { - throw FriendController.FriendError.missingShareURLInPayload - } - do { - try await cloudService.acceptShare(url: url) - } catch let error as CKError where error.code == .unknownItem { - // Stale share: the row needs to go away too, but the next - // `applyInvitePings` will GC it if this cleanup itself fails. - // The user-visible signal here is `.unavailable`, so don't let a - // cleanup error clobber it — log and continue. - do { - try await deleteInviteAndPing(pingRecordName: pingRecordName) - syncMonitor.note("accept invite: removed stale invite \(pingRecordName)") - } catch { - syncMonitor.note("accept invite: stale-invite cleanup failed for \(pingRecordName) — \(error)") - } - throw InviteAcceptanceError.unavailable - } - try await deleteInviteAndPing(pingRecordName: pingRecordName) - } - - /// Accepts the pending invite for `gameID`, if one is still recorded - /// locally. The puzzle-display join path calls this when the user reached - /// a not-yet-joined shared game by tapping its `.invite` notification: - /// that tap only navigates, so the CKShare must still be accepted here. - /// The durable `InviteEntity` (written when the `.invite` Ping arrived) - /// carries the share URL. Throws `InviteAcceptanceError.unavailable` when - /// no such invite exists — the same error the stale-share case surfaces. - func acceptPendingInvite(gameID: UUID) async throws { - let ctx = persistence.viewContext - let req = NSFetchRequest<InviteEntity>(entityName: "InviteEntity") - req.predicate = NSPredicate(format: "gameID == %@", gameID as CVarArg) - req.sortDescriptors = [ - NSSortDescriptor(keyPath: \InviteEntity.createdAt, ascending: false) - ] - req.fetchLimit = 1 - guard let invite = (try? ctx.fetch(req))?.first, - let shareURL = invite.shareURL, - let pingRecordName = invite.pingRecordName - else { - throw InviteAcceptanceError.unavailable - } - try await acceptInvite(shareURL: shareURL, pingRecordName: pingRecordName) - } - - private func deleteInviteAndPing(pingRecordName: String) async throws { - let ctx = persistence.viewContext - let req = NSFetchRequest<InviteEntity>(entityName: "InviteEntity") - req.predicate = NSPredicate(format: "pingRecordName == %@", pingRecordName) - for invite in try ctx.fetch(req) { - if let inviterAuthorID = invite.inviterAuthorID { - await friendController.deleteInvitePing( - fromFriendAuthorID: inviterAuthorID, - recordName: pingRecordName - ) - } - ctx.delete(invite) - } - if ctx.hasChanges { - try ctx.save() - await badge.refreshAppBadge() - } - } - - /// Declines a pending game invite: marks the durable `InviteEntity` rows for - /// `gameID` as a `"declined"` tombstone (which prevents the invite from - /// resurrecting locally if CloudKit deletion is delayed), consumes the - /// source invite Ping so sibling devices clear their rows, and refreshes - /// the badge. Surfaced via `\.declineInvite`; the AppServices entry point - /// keeps invite mutation and the badge refresh in one place, mirroring - /// `acceptInvite`. - func declineInvite(gameID: UUID) async throws { - let ctx = persistence.viewContext - let req = NSFetchRequest<InviteEntity>(entityName: "InviteEntity") - req.predicate = NSPredicate( - format: "gameID == %@ AND status == %@", gameID as CVarArg, "pending" - ) - let invites = try ctx.fetch(req) - guard !invites.isEmpty else { return } - let pingsToDelete = invites.compactMap { invite -> (String, String)? in - guard let inviterAuthorID = invite.inviterAuthorID, - let pingRecordName = invite.pingRecordName - else { return nil } - return (inviterAuthorID, pingRecordName) - } - for invite in invites { - invite.status = "declined" - } - if ctx.hasChanges { - try ctx.save() - await badge.refreshAppBadge() - } - for (inviterAuthorID, pingRecordName) in pingsToDelete { - await friendController.deleteInvitePing( - fromFriendAuthorID: inviterAuthorID, - recordName: pingRecordName - ) - } - } - - /// Blocks a collaborator: marks the friendship blocked and tears down the - /// friend zone, leaves every game they currently share with us, and drops - /// their pending invites. Games we *own* that they joined are untouched. - /// Surfaced via `\.blockFriend`. - func blockFriend(authorID: String) async { - do { - try await friendController.blockAndTeardown(friendAuthorID: authorID) - } catch { - announcements.post(Announcement( - id: "block-friend-error-\(authorID)", - scope: .global, - severity: .error, - title: "Blocking Failed", - body: error.localizedDescription, - dismissal: .manual - )) - return - } - - let ctx = persistence.container.newBackgroundContext() - let gameIDsToLeave: [UUID] = ctx.performAndWait { - let req = NSFetchRequest<GameEntity>(entityName: "GameEntity") - req.predicate = NSPredicate(format: "databaseScope == 1") - var ids: [UUID] = [] - for game in (try? ctx.fetch(req)) ?? [] { - guard let gid = game.id else { continue } - var authors = Set<String>() - if let owner = game.ckZoneOwnerName { authors.insert(owner) } - let mReq = NSFetchRequest<MovesEntity>(entityName: "MovesEntity") - mReq.predicate = NSPredicate(format: "game == %@", game) - for m in (try? ctx.fetch(mReq)) ?? [] { - if let a = m.authorID { authors.insert(a) } - } - let pReq = NSFetchRequest<PlayerEntity>(entityName: "PlayerEntity") - pReq.predicate = NSPredicate(format: "game == %@", game) - for p in (try? ctx.fetch(pReq)) ?? [] { - if let a = p.authorID { authors.insert(a) } - } - if authors.contains(authorID) { ids.append(gid) } - } - return ids - } - for gid in gameIDsToLeave { - try? await shareController.leaveShare(gameID: gid) - } - - // Drop their pending invites. Future inbound `.invite` Pings from a - // blocked sender are caught by `consumeStaleInvites`, which deletes - // the Ping so it doesn't re-fire across cold starts. - let vctx = persistence.viewContext - let iReq = NSFetchRequest<InviteEntity>(entityName: "InviteEntity") - iReq.predicate = NSPredicate(format: "inviterAuthorID == %@", authorID) - for invite in (try? vctx.fetch(iReq)) ?? [] { vctx.delete(invite) } - if vctx.hasChanges { - try? vctx.save() - await badge.refreshAppBadge() - } - } - - /// Deletes `.invite` Pings that are no longer actionable on this device — - /// the game is already in the local library (joined here or on a sibling), - /// or the inviter is blocked — and returns the surviving pings. Running - /// this upstream of both `applyInvitePings` and the notification loop - /// keeps the staleness rule in one place; without it, an orphaned invite - /// Ping re-fires a notification on every cold start because the in-memory - /// dedup caches reset. - private func consumeStaleInvites(_ pings: [Ping]) async -> [Ping] { - let candidates = pings.filter { - $0.kind == .invite && - $0.authorID != identity.currentID && - $0.addressee == identity.currentID - } - guard !candidates.isEmpty else { return pings } - - let ctx = persistence.container.newBackgroundContext() - let staleNames: Set<String> = ctx.performAndWait { - Self.staleInviteRecordNames( - among: candidates, - in: ctx, - currentAuthorID: identity.currentID - ) - } - guard !staleNames.isEmpty else { return pings } - - for ping in candidates where staleNames.contains(ping.recordName) { - await friendController.deleteInvitePing( - fromFriendAuthorID: ping.authorID, - recordName: ping.recordName - ) - syncMonitor.note( - "ping(invite): consumed stale invite \(ping.recordName) for \(ping.gameID.uuidString)" - ) - } - return pings.filter { !staleNames.contains($0.recordName) } - } - - static func staleInviteRecordNames( - among pings: [Ping], - in ctx: NSManagedObjectContext, - currentAuthorID: String? - ) -> Set<String> { - var names: Set<String> = [] - for ping in pings where ping.kind == .invite && - ping.authorID != currentAuthorID && - ping.addressee == currentAuthorID { - guard FriendZone.InvitePayload.decode(ping.payload) != nil else { - names.insert(ping.recordName) - continue - } - - let blockedReq = NSFetchRequest<FriendEntity>(entityName: "FriendEntity") - blockedReq.predicate = NSPredicate( - format: "authorID == %@ AND isBlocked == YES", ping.authorID - ) - blockedReq.fetchLimit = 1 - if ((try? ctx.count(for: blockedReq)) ?? 0) > 0 { - names.insert(ping.recordName) - continue - } - - let inviteReq = NSFetchRequest<InviteEntity>(entityName: "InviteEntity") - inviteReq.predicate = NSPredicate(format: "pingRecordName == %@", ping.recordName) - inviteReq.fetchLimit = 1 - if let invite = try? ctx.fetch(inviteReq).first, - invite.status != "pending" { - names.insert(ping.recordName) - continue - } - - let gameReq = NSFetchRequest<GameEntity>(entityName: "GameEntity") - gameReq.predicate = NSPredicate(format: "id == %@", ping.gameID as CVarArg) - gameReq.fetchLimit = 1 - if ((try? ctx.count(for: gameReq)) ?? 0) > 0 { - names.insert(ping.recordName) - } - } - return names - } - - private func presentPings(_ pings: [Ping]) async { - let claimed = claimPingsForHandling(pings) - guard !claimed.isEmpty else { return } - let pings = await consumeStaleInvites(claimed) - guard !pings.isEmpty else { return } - applyInvitePings(pings) - // Reflect any newly-stored pending invite in the app-icon badge now — - // before the notification-authorization guard — so the badge updates - // even when the banner is suppressed or unauthorized. - await badge.refreshAppBadge() - // `.friend` is the friendship-bootstrap handshake. `.join` and `.hail` - // are legacy live-notification/bootstrap kinds; APNs and Game-record - // engagement creds own those jobs now. System pings do not require - // notification authorization. - let (systemPings, playerFacingPings) = pings.partitioned { - $0.kind == .friend || $0.kind == .join || $0.kind == .hail - } - for ping in systemPings where ping.kind == .friend { - await friendController.applyFriendPing(ping) - } - guard !playerFacingPings.isEmpty else { return } - guard await canPresentNotifications() else { - syncMonitor.note("ping: local notification skipped — authorization not granted") - return - } - - let center = UNUserNotificationCenter.current() - for ping in playerFacingPings { - if ping.kind == .invite, ping.addressee != identity.currentID { - continue - } - // A directed ping (`addressee` set) targets one player by - // authorID. Ignore one addressed to someone else — another user's - // device receives and consumes it. nil ⇒ broadcast, which is now - // legacy and ignored for `.invite`. - if let addressee = ping.addressee, addressee != identity.currentID { - continue - } - if ping.authorID == identity.currentID { - syncMonitor.note("ping(\(ping.kind.rawValue)): skipped self-authored record \(ping.recordName)") - continue - } - // A directed ping addressed to us is consumed by this account: - // once handled — shown, suppressed, or a duplicate — delete it so - // it stops re-notifying and the deletion withdraws any copy our - // sibling devices showed. Broadcast pings are left as-is. - let consume = ping.addressee != nil && ping.kind != .invite - func consumeIfDirected() async { - guard consume else { return } - await syncEngine.deletePing(recordName: ping.recordName, gameID: ping.gameID) - } - if NotificationState.isSuppressed(gameID: ping.gameID) { - syncMonitor.note("ping(\(ping.kind.rawValue)): suppressed — puzzle is active for \(ping.gameID.uuidString)") - await consumeIfDirected() - continue - } - - let content = UNMutableNotificationContent() - content.title = "Crossmate" - content.body = Self.bodyText(for: ping) - content.sound = .default - content.userInfo = [ - "gameID": ping.gameID.uuidString, - "pingKind": ping.kind.rawValue - ] - - let request = UNNotificationRequest( - identifier: "ping-\(ping.gameID.uuidString)-\(UUID().uuidString)", - content: content, - trigger: nil - ) - do { - try await center.add(request) - syncMonitor.note("ping(\(ping.kind.rawValue)): queued local notification for \(ping.gameID.uuidString)") - await consumeIfDirected() - } catch { - syncMonitor.note("ping(\(ping.kind.rawValue)): local notification failed — \(error.localizedDescription)") - } - } - } - - private func claimPingsForHandling(_ pings: [Ping]) -> [Ping] { - var unclaimed: [Ping] = [] - for ping in pings { - guard claimedPingRecordNames.insert(ping.recordName).inserted else { - syncMonitor.note("ping(\(ping.kind.rawValue)): already-handled record \(ping.recordName)") - continue - } - claimedPingRecordNameOrder.append(ping.recordName) - unclaimed.append(ping) - } - if claimedPingRecordNameOrder.count > claimedPingRecordNameCap { - let overflow = claimedPingRecordNameOrder.count - claimedPingRecordNameCap - for recordName in claimedPingRecordNameOrder.prefix(overflow) { - claimedPingRecordNames.remove(recordName) - } - claimedPingRecordNameOrder.removeFirst(overflow) - } - return unclaimed - } - - private func canPresentNotifications() async -> Bool { - let center = UNUserNotificationCenter.current() - let settings = await center.notificationSettings() - switch settings.authorizationStatus { - case .authorized, .provisional, .ephemeral: - return true - default: - return false - } - } - - nonisolated static func bodyText(for ping: Ping) -> String { - let puzzleSuffix = ping.puzzleTitle.isEmpty ? "the puzzle" : "the puzzle '\(ping.puzzleTitle)'" - switch ping.kind { - case .invite: - let player = ping.playerName.isEmpty ? "A player" : ping.playerName - return "\(player) invited you to \(puzzleSuffix)" - case .friend, .join, .hail: - // System-only kinds handled by the friendship-bootstrap / - // engagement paths; never presented as a notification. If this - // text surfaces in a log or alert, `presentPings` dispatch has - // broken. - return "system-only ping should not be presented" - } - } - /// Parses the silent-push payload into a short, human-readable summary /// (database scope, notification type, subscription ID, pruned flag). /// Used by the diagnostics log to confirm whether shared-DB pushes are @@ -2733,19 +2185,3 @@ final class AppServices { } } - -private extension Array { - /// Splits the collection into `(matched, rejected)` in one pass. - func partitioned(by predicate: (Element) -> Bool) -> ([Element], [Element]) { - var matched: [Element] = [] - var rejected: [Element] = [] - for element in self { - if predicate(element) { - matched.append(element) - } else { - rejected.append(element) - } - } - return (matched, rejected) - } -} diff --git a/Crossmate/Services/InviteCoordinator.swift b/Crossmate/Services/InviteCoordinator.swift @@ -0,0 +1,637 @@ +import CloudKit +import CoreData +import Foundation +import UserNotifications + +/// Owns the friend-zone traffic that used to live in `AppServices`: outbound +/// game invites, inbound `Ping` handling (claim/dedup, staleness GC, local +/// notification presentation, friendship-bootstrap dispatch), the durable +/// `InviteEntity` rows behind the library's "Invited" section, and friend +/// blocking. `AppServices` composes one instance and forwards the +/// `SyncEngine` ping callbacks into it; the accept/decline/block entry +/// points are surfaced to the UI through environment closures. +@MainActor +final class InviteCoordinator { + enum InviteAcceptanceError: LocalizedError { + case unavailable + + var errorDescription: String? { + switch self { + case .unavailable: + return "This invite is no longer available. Ask the sender to invite you again." + } + } + } + + private let persistence: PersistenceController + private let identity: AuthorIdentity + private let preferences: PlayerPreferences + private let syncMonitor: SyncMonitor + private let eventLog: EventLog + private let syncEngine: SyncEngine + private let announcements: AnnouncementCenter + private let shareController: ShareController + private let friendController: FriendController + private let cloudService: CloudService + /// Refreshes the app-icon badge — `BadgeCoordinator.refreshAppBadge` — + /// whenever invite rows change (pending invites count toward the badge). + private let refreshAppBadge: () async -> Void + + private var claimedPingRecordNames: Set<String> = [] + private var claimedPingRecordNameOrder: [String] = [] + private let claimedPingRecordNameCap = 200 + + init( + persistence: PersistenceController, + identity: AuthorIdentity, + preferences: PlayerPreferences, + syncMonitor: SyncMonitor, + eventLog: EventLog, + syncEngine: SyncEngine, + announcements: AnnouncementCenter, + shareController: ShareController, + friendController: FriendController, + cloudService: CloudService, + refreshAppBadge: @escaping () async -> Void + ) { + self.persistence = persistence + self.identity = identity + self.preferences = preferences + self.syncMonitor = syncMonitor + self.eventLog = eventLog + self.syncEngine = syncEngine + self.announcements = announcements + self.shareController = shareController + self.friendController = friendController + self.cloudService = cloudService + self.refreshAppBadge = refreshAppBadge + } + + /// Re-invites an existing friend to a game: adds them as a participant on + /// the game's `CKShare` and writes an `.invite` Ping into the friend zone. + /// Surfaced to the UI via the `\.inviteFriend` environment closure. + func inviteFriend(gameID: UUID, friendAuthorID: String) async throws { + guard let localAuthorID = identity.currentID, !localAuthorID.isEmpty else { + throw FriendController.FriendError.friendNotFound + } + let ctx = persistence.viewContext + let req = NSFetchRequest<GameEntity>(entityName: "GameEntity") + req.predicate = NSPredicate(format: "id == %@", gameID as CVarArg) + req.fetchLimit = 1 + let title = (try? ctx.fetch(req).first)?.title ?? "" + + let url = try await shareController.addFriendParticipant( + toGameID: gameID, + userRecordName: friendAuthorID + ) + try await friendController.sendInvite( + toFriendAuthorID: friendAuthorID, + gameID: gameID, + gameTitle: title, + inviterAuthorID: localAuthorID, + inviterName: preferences.name, + gameShareURL: url + ) + } + + /// For each collaborative game with newly-known remote authors, asks the + /// `FriendController` to bootstrap a friendship. `establishIfOwner` is a + /// no-op for the non-owner and for already-established pairs (a defensive + /// backstop — the caller already fires this only on a Player record's + /// first sighting, so it runs about once per new collaborator). + func reconcileFriendships(forGameIDs gameIDs: Set<UUID>) async { + guard preferences.isICloudSyncEnabled, + let localAuthorID = identity.currentID, + !localAuthorID.isEmpty + else { return } + + let ctx = persistence.container.newBackgroundContext() + let candidates: [(gameID: UUID, remoteAuthorID: String, remoteDisplayName: String?)] = ctx.performAndWait { + var result: [(UUID, String, String?)] = [] + for gameID in gameIDs { + let gReq = NSFetchRequest<GameEntity>(entityName: "GameEntity") + gReq.predicate = NSPredicate(format: "id == %@", gameID as CVarArg) + gReq.fetchLimit = 1 + guard let game = try? ctx.fetch(gReq).first else { continue } + // Only collaborative games carry other authors. + guard game.databaseScope == 1 || game.ckShareRecordName != nil else { continue } + + // Identity comes only from Player records — this feature is + // deliberately uninterested in Moves (the bootstrap trigger is + // the first sighting of a remote Player record). + var playersByAuthorID: [String: String?] = [:] + let pReq = NSFetchRequest<PlayerEntity>(entityName: "PlayerEntity") + pReq.predicate = NSPredicate(format: "game == %@", game) + for p in (try? ctx.fetch(pReq)) ?? [] { + guard let authorID = p.authorID else { continue } + playersByAuthorID[authorID] = p.name + } + playersByAuthorID.removeValue(forKey: localAuthorID) + playersByAuthorID.removeValue(forKey: CKCurrentUserDefaultName) + playersByAuthorID.removeValue(forKey: "") + for (authorID, name) in playersByAuthorID { + result.append((gameID, authorID, name)) + } + } + return result + } + + for (gameID, remoteAuthorID, remoteDisplayName) in candidates { + await friendController.establishIfOwner( + localAuthorID: localAuthorID, + remoteAuthorID: remoteAuthorID, + localDisplayName: preferences.name, + remoteDisplayName: remoteDisplayName, + viaGameID: gameID + ) + } + } + + /// Upserts a durable `InviteEntity` for each inbound `.invite` Ping so the + /// Game List's "Invited" section survives the Ping being GC'd. Skips + /// self-authored invites, invites not directed to this author, invites + /// from blocked friends, games already joined, and any `pingRecordName` + /// already seen (a declined row is a tombstone that prevents + /// resurrection). + private func applyInvitePings(_ pings: [Ping]) { + let invites = pings.filter { + $0.kind == .invite && + $0.authorID != identity.currentID && + $0.addressee == identity.currentID + } + guard !invites.isEmpty else { return } + + let ctx = persistence.container.newBackgroundContext() + ctx.performAndWait { + for ping in invites { + guard let payload = FriendZone.InvitePayload.decode(ping.payload) else { continue } + + let dupReq = NSFetchRequest<InviteEntity>(entityName: "InviteEntity") + dupReq.predicate = NSPredicate(format: "pingRecordName == %@", ping.recordName) + dupReq.fetchLimit = 1 + if ((try? ctx.count(for: dupReq)) ?? 0) > 0 { continue } + + let invite = InviteEntity(context: ctx) + invite.gameID = ping.gameID + invite.gameTitle = ping.puzzleTitle + invite.inviterAuthorID = ping.authorID + invite.inviterName = ping.playerName + invite.shareURL = payload.gameShareURL + invite.pingRecordName = ping.recordName + invite.status = "pending" + invite.createdAt = Date() + } + + // GC: a pending invite whose game now exists locally was joined by + // some other path (a link, or accepted on another device), so the + // "Invited" row is stale — drop it. + let pendingReq = NSFetchRequest<InviteEntity>(entityName: "InviteEntity") + pendingReq.predicate = NSPredicate(format: "status == %@", "pending") + for invite in (try? ctx.fetch(pendingReq)) ?? [] { + guard let gid = invite.gameID else { continue } + let gReq = NSFetchRequest<GameEntity>(entityName: "GameEntity") + gReq.predicate = NSPredicate(format: "id == %@", gid as CVarArg) + gReq.fetchLimit = 1 + if ((try? ctx.count(for: gReq)) ?? 0) > 0 { ctx.delete(invite) } + } + + if ctx.hasChanges { + do { + try ctx.save() + } catch { + Task { @MainActor [weak self] in + self?.eventLog.note("InviteCoordinator: applyInvitePings save failed — \(error)", level: "error") + } + } + } + } + } + + /// Drops the pending invite row(s) for `gameID`. Called when the game's + /// shared zone appears locally (joined here or on a sibling device): the + /// "Invited" row is now redundant. `applyInvitePings` runs the same + /// garbage-collection over every pending invite, but only when a ping is + /// fetched — hooking zone arrival closes the window where a just-synced + /// game and its stale invite show side by side in the library. + func removePendingInvite(forGameID gameID: UUID) throws { + let ctx = persistence.viewContext + let req = NSFetchRequest<InviteEntity>(entityName: "InviteEntity") + req.predicate = NSPredicate( + format: "gameID == %@ AND status == %@", gameID as CVarArg, "pending" + ) + let invites = try ctx.fetch(req) + guard !invites.isEmpty else { return } + for invite in invites { ctx.delete(invite) } + if ctx.hasChanges { + try ctx.save() + } + } + + /// Drops pending invite rows whose source Ping was consumed elsewhere on + /// this account. Matching by record name avoids conflating invite Pings + /// with other Ping kinds for the same game. + func removePendingInvites(forPingRecordNames recordNames: Set<String>) throws { + guard !recordNames.isEmpty else { return } + let ctx = persistence.viewContext + let req = NSFetchRequest<InviteEntity>(entityName: "InviteEntity") + req.predicate = NSPredicate( + format: "pingRecordName IN %@ AND status == %@", + Array(recordNames), + "pending" + ) + let invites = try ctx.fetch(req) + guard !invites.isEmpty else { return } + for invite in invites { ctx.delete(invite) } + if ctx.hasChanges { + try ctx.save() + } + } + + /// Accepts a pending game invite: fetches the share metadata, joins via + /// the existing share-accept path, then drops the local `InviteEntity` + /// (the game now represents it). If CloudKit says the share URL no longer + /// exists, the durable invite row is stale, so it is removed as well. + /// Surfaced via `\.acceptInvite`. + func acceptInvite(shareURL: String, pingRecordName: String) async throws { + guard let url = URL(string: shareURL) else { + throw FriendController.FriendError.missingShareURLInPayload + } + do { + try await cloudService.acceptShare(url: url) + } catch let error as CKError where error.code == .unknownItem { + // Stale share: the row needs to go away too, but the next + // `applyInvitePings` will GC it if this cleanup itself fails. + // The user-visible signal here is `.unavailable`, so don't let a + // cleanup error clobber it — log and continue. + do { + try await deleteInviteAndPing(pingRecordName: pingRecordName) + syncMonitor.note("accept invite: removed stale invite \(pingRecordName)") + } catch { + syncMonitor.note("accept invite: stale-invite cleanup failed for \(pingRecordName) — \(error)") + } + throw InviteAcceptanceError.unavailable + } + try await deleteInviteAndPing(pingRecordName: pingRecordName) + } + + /// Accepts the pending invite for `gameID`, if one is still recorded + /// locally. The puzzle-display join path calls this when the user reached + /// a not-yet-joined shared game by tapping its `.invite` notification: + /// that tap only navigates, so the CKShare must still be accepted here. + /// The durable `InviteEntity` (written when the `.invite` Ping arrived) + /// carries the share URL. Throws `InviteAcceptanceError.unavailable` when + /// no such invite exists — the same error the stale-share case surfaces. + func acceptPendingInvite(gameID: UUID) async throws { + let ctx = persistence.viewContext + let req = NSFetchRequest<InviteEntity>(entityName: "InviteEntity") + req.predicate = NSPredicate(format: "gameID == %@", gameID as CVarArg) + req.sortDescriptors = [ + NSSortDescriptor(keyPath: \InviteEntity.createdAt, ascending: false) + ] + req.fetchLimit = 1 + guard let invite = (try? ctx.fetch(req))?.first, + let shareURL = invite.shareURL, + let pingRecordName = invite.pingRecordName + else { + throw InviteAcceptanceError.unavailable + } + try await acceptInvite(shareURL: shareURL, pingRecordName: pingRecordName) + } + + private func deleteInviteAndPing(pingRecordName: String) async throws { + let ctx = persistence.viewContext + let req = NSFetchRequest<InviteEntity>(entityName: "InviteEntity") + req.predicate = NSPredicate(format: "pingRecordName == %@", pingRecordName) + for invite in try ctx.fetch(req) { + if let inviterAuthorID = invite.inviterAuthorID { + await friendController.deleteInvitePing( + fromFriendAuthorID: inviterAuthorID, + recordName: pingRecordName + ) + } + ctx.delete(invite) + } + if ctx.hasChanges { + try ctx.save() + await refreshAppBadge() + } + } + + /// Declines a pending game invite: marks the durable `InviteEntity` rows for + /// `gameID` as a `"declined"` tombstone (which prevents the invite from + /// resurrecting locally if CloudKit deletion is delayed), consumes the + /// source invite Ping so sibling devices clear their rows, and refreshes + /// the badge. Surfaced via `\.declineInvite`; the AppServices entry point + /// keeps invite mutation and the badge refresh in one place, mirroring + /// `acceptInvite`. + func declineInvite(gameID: UUID) async throws { + let ctx = persistence.viewContext + let req = NSFetchRequest<InviteEntity>(entityName: "InviteEntity") + req.predicate = NSPredicate( + format: "gameID == %@ AND status == %@", gameID as CVarArg, "pending" + ) + let invites = try ctx.fetch(req) + guard !invites.isEmpty else { return } + let pingsToDelete = invites.compactMap { invite -> (String, String)? in + guard let inviterAuthorID = invite.inviterAuthorID, + let pingRecordName = invite.pingRecordName + else { return nil } + return (inviterAuthorID, pingRecordName) + } + for invite in invites { + invite.status = "declined" + } + if ctx.hasChanges { + try ctx.save() + await refreshAppBadge() + } + for (inviterAuthorID, pingRecordName) in pingsToDelete { + await friendController.deleteInvitePing( + fromFriendAuthorID: inviterAuthorID, + recordName: pingRecordName + ) + } + } + + /// Blocks a collaborator: marks the friendship blocked and tears down the + /// friend zone, leaves every game they currently share with us, and drops + /// their pending invites. Games we *own* that they joined are untouched. + /// Surfaced via `\.blockFriend`. + func blockFriend(authorID: String) async { + do { + try await friendController.blockAndTeardown(friendAuthorID: authorID) + } catch { + announcements.post(Announcement( + id: "block-friend-error-\(authorID)", + scope: .global, + severity: .error, + title: "Blocking Failed", + body: error.localizedDescription, + dismissal: .manual + )) + return + } + + let ctx = persistence.container.newBackgroundContext() + let gameIDsToLeave: [UUID] = ctx.performAndWait { + let req = NSFetchRequest<GameEntity>(entityName: "GameEntity") + req.predicate = NSPredicate(format: "databaseScope == 1") + var ids: [UUID] = [] + for game in (try? ctx.fetch(req)) ?? [] { + guard let gid = game.id else { continue } + var authors = Set<String>() + if let owner = game.ckZoneOwnerName { authors.insert(owner) } + let mReq = NSFetchRequest<MovesEntity>(entityName: "MovesEntity") + mReq.predicate = NSPredicate(format: "game == %@", game) + for m in (try? ctx.fetch(mReq)) ?? [] { + if let a = m.authorID { authors.insert(a) } + } + let pReq = NSFetchRequest<PlayerEntity>(entityName: "PlayerEntity") + pReq.predicate = NSPredicate(format: "game == %@", game) + for p in (try? ctx.fetch(pReq)) ?? [] { + if let a = p.authorID { authors.insert(a) } + } + if authors.contains(authorID) { ids.append(gid) } + } + return ids + } + for gid in gameIDsToLeave { + try? await shareController.leaveShare(gameID: gid) + } + + // Drop their pending invites. Future inbound `.invite` Pings from a + // blocked sender are caught by `consumeStaleInvites`, which deletes + // the Ping so it doesn't re-fire across cold starts. + let vctx = persistence.viewContext + let iReq = NSFetchRequest<InviteEntity>(entityName: "InviteEntity") + iReq.predicate = NSPredicate(format: "inviterAuthorID == %@", authorID) + for invite in (try? vctx.fetch(iReq)) ?? [] { vctx.delete(invite) } + if vctx.hasChanges { + try? vctx.save() + await refreshAppBadge() + } + } + + /// Deletes `.invite` Pings that are no longer actionable on this device — + /// the game is already in the local library (joined here or on a sibling), + /// or the inviter is blocked — and returns the surviving pings. Running + /// this upstream of both `applyInvitePings` and the notification loop + /// keeps the staleness rule in one place; without it, an orphaned invite + /// Ping re-fires a notification on every cold start because the in-memory + /// dedup caches reset. + private func consumeStaleInvites(_ pings: [Ping]) async -> [Ping] { + let candidates = pings.filter { + $0.kind == .invite && + $0.authorID != identity.currentID && + $0.addressee == identity.currentID + } + guard !candidates.isEmpty else { return pings } + + let ctx = persistence.container.newBackgroundContext() + let staleNames: Set<String> = ctx.performAndWait { + Self.staleInviteRecordNames( + among: candidates, + in: ctx, + currentAuthorID: identity.currentID + ) + } + guard !staleNames.isEmpty else { return pings } + + for ping in candidates where staleNames.contains(ping.recordName) { + await friendController.deleteInvitePing( + fromFriendAuthorID: ping.authorID, + recordName: ping.recordName + ) + syncMonitor.note( + "ping(invite): consumed stale invite \(ping.recordName) for \(ping.gameID.uuidString)" + ) + } + return pings.filter { !staleNames.contains($0.recordName) } + } + + static func staleInviteRecordNames( + among pings: [Ping], + in ctx: NSManagedObjectContext, + currentAuthorID: String? + ) -> Set<String> { + var names: Set<String> = [] + for ping in pings where ping.kind == .invite && + ping.authorID != currentAuthorID && + ping.addressee == currentAuthorID { + guard FriendZone.InvitePayload.decode(ping.payload) != nil else { + names.insert(ping.recordName) + continue + } + + let blockedReq = NSFetchRequest<FriendEntity>(entityName: "FriendEntity") + blockedReq.predicate = NSPredicate( + format: "authorID == %@ AND isBlocked == YES", ping.authorID + ) + blockedReq.fetchLimit = 1 + if ((try? ctx.count(for: blockedReq)) ?? 0) > 0 { + names.insert(ping.recordName) + continue + } + + let inviteReq = NSFetchRequest<InviteEntity>(entityName: "InviteEntity") + inviteReq.predicate = NSPredicate(format: "pingRecordName == %@", ping.recordName) + inviteReq.fetchLimit = 1 + if let invite = try? ctx.fetch(inviteReq).first, + invite.status != "pending" { + names.insert(ping.recordName) + continue + } + + let gameReq = NSFetchRequest<GameEntity>(entityName: "GameEntity") + gameReq.predicate = NSPredicate(format: "id == %@", ping.gameID as CVarArg) + gameReq.fetchLimit = 1 + if ((try? ctx.count(for: gameReq)) ?? 0) > 0 { + names.insert(ping.recordName) + } + } + return names + } + + func presentPings(_ pings: [Ping]) async { + let claimed = claimPingsForHandling(pings) + guard !claimed.isEmpty else { return } + let pings = await consumeStaleInvites(claimed) + guard !pings.isEmpty else { return } + applyInvitePings(pings) + // Reflect any newly-stored pending invite in the app-icon badge now — + // before the notification-authorization guard — so the badge updates + // even when the banner is suppressed or unauthorized. + await refreshAppBadge() + // `.friend` is the friendship-bootstrap handshake. `.join` and `.hail` + // are legacy live-notification/bootstrap kinds; APNs and Game-record + // engagement creds own those jobs now. System pings do not require + // notification authorization. + let (systemPings, playerFacingPings) = pings.partitioned { + $0.kind == .friend || $0.kind == .join || $0.kind == .hail + } + for ping in systemPings where ping.kind == .friend { + await friendController.applyFriendPing(ping) + } + guard !playerFacingPings.isEmpty else { return } + guard await canPresentNotifications() else { + syncMonitor.note("ping: local notification skipped — authorization not granted") + return + } + + let center = UNUserNotificationCenter.current() + for ping in playerFacingPings { + if ping.kind == .invite, ping.addressee != identity.currentID { + continue + } + // A directed ping (`addressee` set) targets one player by + // authorID. Ignore one addressed to someone else — another user's + // device receives and consumes it. nil ⇒ broadcast, which is now + // legacy and ignored for `.invite`. + if let addressee = ping.addressee, addressee != identity.currentID { + continue + } + if ping.authorID == identity.currentID { + syncMonitor.note("ping(\(ping.kind.rawValue)): skipped self-authored record \(ping.recordName)") + continue + } + // A directed ping addressed to us is consumed by this account: + // once handled — shown, suppressed, or a duplicate — delete it so + // it stops re-notifying and the deletion withdraws any copy our + // sibling devices showed. Broadcast pings are left as-is. + let consume = ping.addressee != nil && ping.kind != .invite + func consumeIfDirected() async { + guard consume else { return } + await syncEngine.deletePing(recordName: ping.recordName, gameID: ping.gameID) + } + if NotificationState.isSuppressed(gameID: ping.gameID) { + syncMonitor.note("ping(\(ping.kind.rawValue)): suppressed — puzzle is active for \(ping.gameID.uuidString)") + await consumeIfDirected() + continue + } + + let content = UNMutableNotificationContent() + content.title = "Crossmate" + content.body = Self.bodyText(for: ping) + content.sound = .default + content.userInfo = [ + "gameID": ping.gameID.uuidString, + "pingKind": ping.kind.rawValue + ] + + let request = UNNotificationRequest( + identifier: "ping-\(ping.gameID.uuidString)-\(UUID().uuidString)", + content: content, + trigger: nil + ) + do { + try await center.add(request) + syncMonitor.note("ping(\(ping.kind.rawValue)): queued local notification for \(ping.gameID.uuidString)") + await consumeIfDirected() + } catch { + syncMonitor.note("ping(\(ping.kind.rawValue)): local notification failed — \(error.localizedDescription)") + } + } + } + + private func claimPingsForHandling(_ pings: [Ping]) -> [Ping] { + var unclaimed: [Ping] = [] + for ping in pings { + guard claimedPingRecordNames.insert(ping.recordName).inserted else { + syncMonitor.note("ping(\(ping.kind.rawValue)): already-handled record \(ping.recordName)") + continue + } + claimedPingRecordNameOrder.append(ping.recordName) + unclaimed.append(ping) + } + if claimedPingRecordNameOrder.count > claimedPingRecordNameCap { + let overflow = claimedPingRecordNameOrder.count - claimedPingRecordNameCap + for recordName in claimedPingRecordNameOrder.prefix(overflow) { + claimedPingRecordNames.remove(recordName) + } + claimedPingRecordNameOrder.removeFirst(overflow) + } + return unclaimed + } + + private func canPresentNotifications() async -> Bool { + let center = UNUserNotificationCenter.current() + let settings = await center.notificationSettings() + switch settings.authorizationStatus { + case .authorized, .provisional, .ephemeral: + return true + default: + return false + } + } + + nonisolated static func bodyText(for ping: Ping) -> String { + let puzzleSuffix = ping.puzzleTitle.isEmpty ? "the puzzle" : "the puzzle '\(ping.puzzleTitle)'" + switch ping.kind { + case .invite: + let player = ping.playerName.isEmpty ? "A player" : ping.playerName + return "\(player) invited you to \(puzzleSuffix)" + case .friend, .join, .hail: + // System-only kinds handled by the friendship-bootstrap / + // engagement paths; never presented as a notification. If this + // text surfaces in a log or alert, `presentPings` dispatch has + // broken. + return "system-only ping should not be presented" + } + } +} + +private extension Array { + /// Splits the collection into `(matched, rejected)` in one pass. + func partitioned(by predicate: (Element) -> Bool) -> ([Element], [Element]) { + var matched: [Element] = [] + var rejected: [Element] = [] + for element in self { + if predicate(element) { + matched.append(element) + } else { + rejected.append(element) + } + } + return (matched, rejected) + } +} diff --git a/Tests/Unit/PuzzleNotificationTextTests.swift b/Tests/Unit/PuzzleNotificationTextTests.swift @@ -29,7 +29,7 @@ struct PuzzleNotificationTextTests { addressee: nil ) - #expect(AppServices.bodyText(for: ping) == "system-only ping should not be presented") + #expect(InviteCoordinator.bodyText(for: ping) == "system-only ping should not be presented") } @Test("pauseBody combines fills and clears counts when both are non-zero") @@ -148,6 +148,6 @@ struct PuzzleNotificationTextTests { addressee: "bob" ) - #expect(AppServices.bodyText(for: ping) == "Alice invited you to the puzzle 'Saturday Puzzle – 1 January 2001'") + #expect(InviteCoordinator.bodyText(for: ping) == "Alice invited you to the puzzle 'Saturday Puzzle – 1 January 2001'") } } diff --git a/Tests/Unit/Sync/FriendModelTests.swift b/Tests/Unit/Sync/FriendModelTests.swift @@ -108,7 +108,7 @@ struct FriendModelTests { addressee: "_me" ) - let stale = AppServices.staleInviteRecordNames( + let stale = InviteCoordinator.staleInviteRecordNames( among: [ping], in: ctx, currentAuthorID: "_me" @@ -133,7 +133,7 @@ struct FriendModelTests { addressee: "_me" ) - let stale = AppServices.staleInviteRecordNames( + let stale = InviteCoordinator.staleInviteRecordNames( among: [ping], in: ctx, currentAuthorID: "_me"