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
1 change: 1 addition & 0 deletions Flipcash/Core/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
29 changes: 27 additions & 2 deletions Flipcash/Core/Controllers/ConversationController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -265,6 +265,9 @@ final class ConversationController {
@ObservationIgnored private var startTask: Task<Void, Never>?
/// The feed load in flight, joined by every caller that arrives before it finishes.
@ObservationIgnored private var feedLoadTask: Task<Void, Never>?
/// 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.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -781,6 +802,7 @@ final class ConversationController {
startTask = nil
feedLoadTask?.cancel()
feedLoadTask = nil
feedLoadInterrupted = false
if let extensionStoreWriteToken {
ChatStoreWriteNotification.stopObserving(extensionStoreWriteToken)
self.extensionStoreWriteToken = nil
Expand Down Expand Up @@ -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] {
Expand Down
28 changes: 28 additions & 0 deletions FlipcashTests/ConversationControllerTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
2 changes: 2 additions & 0 deletions FlipcashTests/TestSupport/MockConversations.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
Loading