diff --git a/Flipcash/Core/AppDelegate.swift b/Flipcash/Core/AppDelegate.swift index e733640cf..182616a7f 100644 --- a/Flipcash/Core/AppDelegate.swift +++ b/Flipcash/Core/AppDelegate.swift @@ -134,6 +134,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate { case .background: logger.info("scenePhase → background") sessionContainer?.session.didEnterBackground() + sessionContainer?.conversationController.handleBackground() container.preferences.appDidEnterBackground() sessionContainer?.pushController.clearBadgeCount() shutDownForBackground() diff --git a/Flipcash/Core/Controllers/ConversationController.swift b/Flipcash/Core/Controllers/ConversationController.swift index cd73bdbd3..53ee0c450 100644 --- a/Flipcash/Core/Controllers/ConversationController.swift +++ b/Flipcash/Core/Controllers/ConversationController.swift @@ -265,6 +265,9 @@ final class ConversationController { @ObservationIgnored private var startTask: Task? /// The feed load in flight, joined by every caller that arrives before it finishes. @ObservationIgnored private var feedLoadTask: Task? + /// Whether the app was backgrounded while `feedLoadTask` was in flight. Its requests went out + /// before suspension, so it answers with what the server held then. + @ObservationIgnored private var feedLoadInterrupted = false /// How long a reconcile waits for the slowest feed, and for unread counts, before applying what landed. @ObservationIgnored private let reconcileTiming: FeedReconcileTiming /// The transcript fetches waiting to run, shared by every feed load. @@ -627,7 +630,25 @@ final class ConversationController { func handleForeground() { reloadFromDatabase() catchUpOpenChat() - Task { await loadFeed() } + // Joining a load that was in flight across a suspension would leave the feed as it was + // before the app went away, so wait that one out and fetch again. + let interrupted = feedLoadInterrupted ? feedLoadTask : nil + feedLoadInterrupted = false + Task { + if let interrupted { + await interrupted.value + if feedLoadTask == interrupted { + feedLoadTask = nil + } + } + await loadFeed() + } + } + + /// Background hook (`AppDelegate` `.background`): marks a feed load still in flight as stale, so + /// the next `handleForeground()` fetches again instead of joining it. + func handleBackground() { + feedLoadInterrupted = feedLoadTask != nil } /// Re-reads every known conversation's transcript and feed preview straight from disk, with no @@ -781,6 +802,7 @@ final class ConversationController { startTask = nil feedLoadTask?.cancel() feedLoadTask = nil + feedLoadInterrupted = false if let extensionStoreWriteToken { ChatStoreWriteNotification.stopObserving(extensionStoreWriteToken) self.extensionStoreWriteToken = nil @@ -879,7 +901,10 @@ final class ConversationController { } feedLoadTask = task await task.value - feedLoadTask = nil + // A foreground that outwaited an interrupted load may already have started the next one. + if feedLoadTask == task { + feedLoadTask = nil + } } private func loadFeeds() async -> [Conversation] { diff --git a/FlipcashTests/ConversationControllerTests.swift b/FlipcashTests/ConversationControllerTests.swift index 669f3fc94..e25d6e0d5 100644 --- a/FlipcashTests/ConversationControllerTests.swift +++ b/FlipcashTests/ConversationControllerTests.swift @@ -1016,6 +1016,34 @@ struct ConversationControllerTests { controller.stop() } + @Test("a foreground after the app was backgrounded mid-load fetches the feed again instead of joining the stale load") + func foregroundAfterBackgroundMidLoadRefetchesFeed() async throws { + let mock = MockConversations() + mock.feed = [Conversation(id: ConversationID.test(1), members: [], lastMessage: nil, lastActivity: Date(timeIntervalSince1970: 100))] + mock.messages = [ConversationMessage(id: MessageID(value: 1), senderID: nil, content: .text("one"), date: Date(timeIntervalSince1970: 10), unreadSeq: 1, eventSequence: 1)] + mock.feedDelay = .milliseconds(300) + let controller = makeController(mock) + controller.visibleConversationID = nil + + controller.start() + // start()'s feed requests are out, holding the feed as it was when they were sent. + try await waitUntil { mock.dmFeedCalls > 0 } + controller.handleBackground() + + mock.feed = [Conversation(id: ConversationID.test(1), members: [], lastMessage: nil, lastActivity: Date(timeIntervalSince1970: 200), latestEventSequence: 2)] + mock.deltaHead = 2 + mock.deltaBatches = [MockConversations.DeltaBatch( + messages: [ConversationMessage(id: MessageID(value: 2), senderID: nil, content: .text("missed while backgrounded"), date: Date(timeIntervalSince1970: 20), unreadSeq: 2, eventSequence: 2)], + checkpoint: 2 + )] + + controller.handleForeground() + + try await waitUntil { controller.messages(for: ConversationID.test(1)).map(\.id.value) == [1, 2] } + #expect(mock.deltaAfterSequences == [1]) + controller.stop() + } + @Test("foreground re-fetches via GetDelta even when the extension already persisted the message to the shared store") func foregroundRefetchesViaGetDeltaEvenWhenExtensionAlreadyPersistedMessage() async throws { let (database, _) = try Database.makeTemp() diff --git a/FlipcashTests/TestSupport/MockConversations.swift b/FlipcashTests/TestSupport/MockConversations.swift index fa1e3ec67..cb73629e9 100644 --- a/FlipcashTests/TestSupport/MockConversations.swift +++ b/FlipcashTests/TestSupport/MockConversations.swift @@ -288,6 +288,8 @@ final class MockConversations: ConversationFetching, ConversationMembership, Con func getDmChatFeed(owner: KeyPair, type: ConversationType) async throws -> [Conversation] { lock.withLock { _dmFeedCalls += 1 } + // Read on arrival: like the server, the answer reflects the state when the request was made. + let feed = feed if feedDelay > .zero { try? await Task.sleep(for: feedDelay) } return feed }