From a301e40ecc1e1e2f0fe5ea41d82be95f9d957402 Mon Sep 17 00:00:00 2001 From: Sasha Weiss Date: Tue, 27 Feb 2024 14:55:15 -0800 Subject: [PATCH] Add missed-call badging to the Calls Tab --- .swiftlint.yml | 1 + Signal.xcodeproj/project.pbxproj | 46 +++-- Signal/CallsTab/CallRecordLoader.swift | 13 -- Signal/src/Badge/AppIconBadgeUpdater.swift | 4 +- Signal/src/Badge/BadgeManager.swift | 36 ++-- ...GroupCallRecordRingingCleanupManager.swift | 2 +- .../Calls/CallsListViewController.swift | 41 ++++ .../HomeView/HomeTabBarController.swift | 9 +- Signal/test/Badge/BadgeManagerTest.swift | 22 ++- .../test/CallsTab/CallRecordLoaderTest.swift | 4 + SignalNSE/NotificationService.swift | 11 +- .../Badge/BadgeCountFetcher.swift | 31 ++++ .../CallRecord/CallRecord+CallStatus.swift | 175 ++++++++++++++++++ .../Calls/CallRecord/CallRecord.swift | 163 +++------------- .../CallRecordMissedCallManager.swift | 96 ++++++++++ .../Calls/CallRecord/CallRecordQuerier.swift | 51 +++++ .../Calls/CallRecord/CallRecordStore.swift | 53 +++++- .../CallRecord/GroupCallRecordManager.swift | 2 +- .../IndividualCallRecordManager.swift | 2 +- .../Dependencies/DependenciesBridge.swift | 11 ++ .../CallRecord/MockCallRecordStore.swift | 7 +- SignalServiceKit/Resources/schema.sql | 9 + .../Storage/Database/GRDBSchemaMigrator.swift | 33 +++- .../CallRecord/CallRecordQuerierTest.swift | 44 ++++- .../CallRecord/CallRecordStoreTest.swift | 119 +++++++++++- 25 files changed, 772 insertions(+), 213 deletions(-) create mode 100644 SignalServiceKit/Badge/BadgeCountFetcher.swift create mode 100644 SignalServiceKit/Calls/CallRecord/CallRecord+CallStatus.swift create mode 100644 SignalServiceKit/Calls/CallRecord/CallRecordMissedCallManager.swift diff --git a/.swiftlint.yml b/.swiftlint.yml index 36e3220e95..fdd512d4d1 100644 --- a/.swiftlint.yml +++ b/.swiftlint.yml @@ -28,6 +28,7 @@ disabled_rules: - type_body_length - unneeded_synthesized_initializer - unused_closure_parameter +- unused_optional_binding opt_in_rules: - attributes - comma_inheritance diff --git a/Signal.xcodeproj/project.pbxproj b/Signal.xcodeproj/project.pbxproj index d931c2e72c..94c3773655 100644 --- a/Signal.xcodeproj/project.pbxproj +++ b/Signal.xcodeproj/project.pbxproj @@ -756,8 +756,6 @@ 50E642C929E4E9CD00566D5D /* SSKEnvironment.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50E642C829E4E9CD00566D5D /* SSKEnvironment.swift */; }; 50EF8DCA2A1885C000A00935 /* AppIconBadgeUpdater.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50EF8DC92A1885C000A00935 /* AppIconBadgeUpdater.swift */; }; 50EF8DCC2A189B3000A00935 /* ConversationViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50EF8DCB2A189B3000A00935 /* ConversationViewModel.swift */; }; - 50EF8DCF2A1E871400A00935 /* BadgeManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50EF8DC42A1860EF00A00935 /* BadgeManager.swift */; }; - 50EF8DD12A1E9B3800A00935 /* BadgeManagerTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50EF8DCD2A1BEBAE00A00935 /* BadgeManagerTest.swift */; }; 50EF8DD32A1EC6B100A00935 /* OWSDisappearingMessagesConfigurationTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50EF8DD22A1EC6B100A00935 /* OWSDisappearingMessagesConfigurationTest.swift */; }; 50EF8DD52A1FE55D00A00935 /* SignalAccountMergeObserver.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50EF8DD42A1FE55D00A00935 /* SignalAccountMergeObserver.swift */; }; 50F75E312AD9F18F0032530F /* RecipientDatabaseTableTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50F75E302AD9F18F0032530F /* RecipientDatabaseTableTest.swift */; }; @@ -1550,6 +1548,9 @@ D979CC592AD61641006AAC49 /* GroupCallInteractionFinder.swift in Sources */ = {isa = PBXBuildFile; fileRef = D979CC582AD61641006AAC49 /* GroupCallInteractionFinder.swift */; }; D979CC5B2AD61699006AAC49 /* GroupCallRecordManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = D979CC5A2AD61699006AAC49 /* GroupCallRecordManager.swift */; }; D979CC5E2AD618EA006AAC49 /* GroupCallRecordManagerTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = D979CC5C2AD616A4006AAC49 /* GroupCallRecordManagerTest.swift */; }; + D979DA112B8D1B06000EEAB8 /* BadgeManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50EF8DC42A1860EF00A00935 /* BadgeManager.swift */; }; + D979DA132B8D1B65000EEAB8 /* BadgeManagerTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50EF8DCD2A1BEBAE00A00935 /* BadgeManagerTest.swift */; }; + D979DA162B8D1FDD000EEAB8 /* BadgeCountFetcher.swift in Sources */ = {isa = PBXBuildFile; fileRef = D979DA152B8D1FDD000EEAB8 /* BadgeCountFetcher.swift */; }; D98300B22936E6C70018FDC2 /* SubscriptionManager+DonationConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = D98300B12936E6C70018FDC2 /* SubscriptionManager+DonationConfiguration.swift */; }; D985D86629B949D20087C90C /* ChangePhoneNumberPniManager+Shims.swift in Sources */ = {isa = PBXBuildFile; fileRef = D985D86529B949D20087C90C /* ChangePhoneNumberPniManager+Shims.swift */; }; D985D86829B94EC60087C90C /* ChangePhoneNumberPniManagerTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = D985D86329B91C400087C90C /* ChangePhoneNumberPniManagerTest.swift */; }; @@ -1606,6 +1607,8 @@ D9C42C2F2B6C60600086B142 /* CallRecordDeleteManagerTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = D9C42C2E2B6C60600086B142 /* CallRecordDeleteManagerTest.swift */; }; D9C42C312B6C60FE0086B142 /* MockDeletedCallRecordCleanupManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = D9C42C302B6C60FE0086B142 /* MockDeletedCallRecordCleanupManager.swift */; }; D9C42C332B6C66320086B142 /* MockCallRecordDeleteManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = D9C42C322B6C66320086B142 /* MockCallRecordDeleteManager.swift */; }; + D9C544292B8578B50036F274 /* CallRecord+CallStatus.swift in Sources */ = {isa = PBXBuildFile; fileRef = D9C544282B8578B50036F274 /* CallRecord+CallStatus.swift */; }; + D9C5442B2B8578F30036F274 /* CallRecordMissedCallManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = D9C5442A2B8578F30036F274 /* CallRecordMissedCallManager.swift */; }; D9C5442D2B865B060036F274 /* CallsListViewController+Strings.swift in Sources */ = {isa = PBXBuildFile; fileRef = D9C5442C2B865B060036F274 /* CallsListViewController+Strings.swift */; }; D9C7CEB428EB8495001E87B6 /* ExperienceUpgrade.swift in Sources */ = {isa = PBXBuildFile; fileRef = D9C7CEB328EB8495001E87B6 /* ExperienceUpgrade.swift */; }; D9C7CECB28EBC09C001E87B6 /* ExperienceUpgradeManifest.swift in Sources */ = {isa = PBXBuildFile; fileRef = D9C7CECA28EBC09C001E87B6 /* ExperienceUpgradeManifest.swift */; }; @@ -4336,6 +4339,7 @@ D979CC582AD61641006AAC49 /* GroupCallInteractionFinder.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GroupCallInteractionFinder.swift; sourceTree = ""; }; D979CC5A2AD61699006AAC49 /* GroupCallRecordManager.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GroupCallRecordManager.swift; sourceTree = ""; }; D979CC5C2AD616A4006AAC49 /* GroupCallRecordManagerTest.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GroupCallRecordManagerTest.swift; sourceTree = ""; }; + D979DA152B8D1FDD000EEAB8 /* BadgeCountFetcher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BadgeCountFetcher.swift; sourceTree = ""; }; D98300B12936E6C70018FDC2 /* SubscriptionManager+DonationConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SubscriptionManager+DonationConfiguration.swift"; sourceTree = ""; }; D985D86329B91C400087C90C /* ChangePhoneNumberPniManagerTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChangePhoneNumberPniManagerTest.swift; sourceTree = ""; }; D985D86529B949D20087C90C /* ChangePhoneNumberPniManager+Shims.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ChangePhoneNumberPniManager+Shims.swift"; sourceTree = ""; }; @@ -4391,6 +4395,8 @@ D9C42C2E2B6C60600086B142 /* CallRecordDeleteManagerTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CallRecordDeleteManagerTest.swift; sourceTree = ""; }; D9C42C302B6C60FE0086B142 /* MockDeletedCallRecordCleanupManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockDeletedCallRecordCleanupManager.swift; sourceTree = ""; }; D9C42C322B6C66320086B142 /* MockCallRecordDeleteManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockCallRecordDeleteManager.swift; sourceTree = ""; }; + D9C544282B8578B50036F274 /* CallRecord+CallStatus.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CallRecord+CallStatus.swift"; sourceTree = ""; }; + D9C5442A2B8578F30036F274 /* CallRecordMissedCallManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CallRecordMissedCallManager.swift; sourceTree = ""; }; D9C5442C2B865B060036F274 /* CallsListViewController+Strings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CallsListViewController+Strings.swift"; sourceTree = ""; }; D9C7CEB328EB8495001E87B6 /* ExperienceUpgrade.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExperienceUpgrade.swift; sourceTree = ""; }; D9C7CECA28EBC09C001E87B6 /* ExperienceUpgradeManifest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExperienceUpgradeManifest.swift; sourceTree = ""; }; @@ -6978,14 +6984,6 @@ path = Badge; sourceTree = ""; }; - 50EF8DD02A1E96EA00A00935 /* Badge */ = { - isa = PBXGroup; - children = ( - 50EF8DCD2A1BEBAE00A00935 /* BadgeManagerTest.swift */, - ); - path = Badge; - sourceTree = ""; - }; 6600F352298C8FBB00B1EDB7 /* Registration */ = { isa = PBXGroup; children = ( @@ -8388,7 +8386,7 @@ children = ( 34C6B0A41FA0E46F00D35993 /* Assets */, 1704690725D4C2DA000793D8 /* attachments */, - 50EF8DD02A1E96EA00A00935 /* Badge */, + D979DA122B8D1B3E000EEAB8 /* Badge */, D931080C2B338D00006A034E /* CallsTab */, B660F6751C29867F00687D6E /* contact */, 50B6BCB22AEC58190010FB3B /* Contacts */, @@ -8728,12 +8726,14 @@ D9277AAA2AC3885300A72E73 /* CallRecord */ = { isa = PBXGroup; children = ( + D9C544282B8578B50036F274 /* CallRecord+CallStatus.swift */, D99A2A842AAB9AB9003388D1 /* CallRecord.swift */, D9DB37EE2B7180DD007B16C8 /* CallRecordAssociatedInteraction.swift */, D9CA8AB62B6AE77200787167 /* CallRecordDeleteManager.swift */, D979CC202AD3933B006AAC49 /* CallRecordIncomingSyncMessageManager.swift */, D979CC252AD3933B006AAC49 /* CallRecordIncomingSyncMessageParams.swift */, D941863B2ACE252D002FE2D3 /* CallRecordLogger.swift */, + D9C5442A2B8578F30036F274 /* CallRecordMissedCallManager.swift */, D979CC222AD3933B006AAC49 /* CallRecordOutgoingSyncMessageManager.swift */, D93108032B30F7E3006A034E /* CallRecordQuerier.swift */, D95A79A72AB125D80013DB00 /* CallRecordStore.swift */, @@ -8843,6 +8843,22 @@ path = CallRecord; sourceTree = ""; }; + D979DA122B8D1B3E000EEAB8 /* Badge */ = { + isa = PBXGroup; + children = ( + 50EF8DCD2A1BEBAE00A00935 /* BadgeManagerTest.swift */, + ); + path = Badge; + sourceTree = ""; + }; + D979DA142B8D1FCF000EEAB8 /* Badge */ = { + isa = PBXGroup; + children = ( + D979DA152B8D1FDD000EEAB8 /* BadgeCountFetcher.swift */, + ); + path = Badge; + sourceTree = ""; + }; D985D86229B91C2B0087C90C /* ChangePhoneNumber */ = { isa = PBXGroup; children = ( @@ -9535,6 +9551,7 @@ isa = PBXGroup; children = ( F9C5C9BA289453B100548EEE /* Account */, + D979DA142B8D1FCF000EEAB8 /* Badge */, F945FE482984795A00C835C7 /* Calls */, D9C2D78529A80BE700D79715 /* ChangePhoneNumber */, F9C5C9CC289453B100548EEE /* Contacts */, @@ -12319,7 +12336,7 @@ F9F4DE2A283FDFDA001909ED /* BadgeGiftingConfirmationViewController.swift in Sources */, F95427E6286E042200314EDA /* BadgeGiftingThanksSheet.swift in Sources */, F02564D8274EDF4600D7B48A /* BadgeIssueSheet.swift in Sources */, - 50EF8DCF2A1E871400A00935 /* BadgeManager.swift in Sources */, + D979DA112B8D1B06000EEAB8 /* BadgeManager.swift in Sources */, 8880179427430DDB00346E9A /* BadgeThanksSheet.swift in Sources */, D99554752AF5AFE90001E15C /* BadgeThanksSheetPresenter.swift in Sources */, B9DB91702AF46B9A0051A3FD /* BankTransferMandateViewController.swift in Sources */, @@ -12932,7 +12949,7 @@ 50B6BCB42AEC58250010FB3B /* AuthorMergeHelperBuilderTest.swift in Sources */, F9A335CA282F0BF700B5F5FA /* BadgeGiftingChooseBadgeViewControllerStateTest.swift in Sources */, F97A2EEA282578C000610669 /* BadgeIssueSheetStateTest.swift in Sources */, - 50EF8DD12A1E9B3800A00935 /* BadgeManagerTest.swift in Sources */, + D979DA132B8D1B65000EEAB8 /* BadgeManagerTest.swift in Sources */, 34F1072226D045290053EF4D /* BatchUpdateTest.swift in Sources */, D9317FD52A4BB15D00075A92 /* BitmapsImageCenteredDeadzoneTest.swift in Sources */, D99ABC742A3D0BE10034CD3B /* BitmapsImageParsingTest.swift in Sources */, @@ -13029,6 +13046,7 @@ 665C0D6C2AE0776700539A37 /* Backup.pb.swift in Sources */, 665C0D6E2AE07A8A00539A37 /* BackupProto.swift in Sources */, F9C5CE3A289453B400548EEE /* BadgeAssets.swift in Sources */, + D979DA162B8D1FDD000EEAB8 /* BadgeCountFetcher.swift in Sources */, F9C5CE37289453B400548EEE /* BadgeStore.swift in Sources */, F9C5CD58289453B300548EEE /* BaseModel.m in Sources */, 6600F369298DA57200B1EDB7 /* BaseOWSURLSessionMock.swift in Sources */, @@ -13041,6 +13059,7 @@ F9C5CE39289453B400548EEE /* BulkProfileFetch.swift in Sources */, E7D7C93F28B580AC003F043B /* Bundle+OWS.swift in Sources */, F9C5CD32289453B300548EEE /* CallKitIdStore.m in Sources */, + D9C544292B8578B50036F274 /* CallRecord+CallStatus.swift in Sources */, D99A2A852AAB9AB9003388D1 /* CallRecord.swift in Sources */, D9DB37EF2B7180DD007B16C8 /* CallRecordAssociatedInteraction.swift in Sources */, D9DB37F92B72A770007B16C8 /* CallRecordDeleteAllJobQueue.swift in Sources */, @@ -13049,6 +13068,7 @@ D979CC272AD3933B006AAC49 /* CallRecordIncomingSyncMessageManager.swift in Sources */, D979CC2C2AD3933B006AAC49 /* CallRecordIncomingSyncMessageParams.swift in Sources */, D941863C2ACE252D002FE2D3 /* CallRecordLogger.swift in Sources */, + D9C5442B2B8578F30036F274 /* CallRecordMissedCallManager.swift in Sources */, D979CC292AD3933B006AAC49 /* CallRecordOutgoingSyncMessageManager.swift in Sources */, D93108042B30F7E3006A034E /* CallRecordQuerier.swift in Sources */, D95A79A82AB125D80013DB00 /* CallRecordStore.swift in Sources */, diff --git a/Signal/CallsTab/CallRecordLoader.swift b/Signal/CallsTab/CallRecordLoader.swift index dc56eaccdb..e5fb4a5b0a 100644 --- a/Signal/CallsTab/CallRecordLoader.swift +++ b/Signal/CallsTab/CallRecordLoader.swift @@ -150,19 +150,6 @@ class CallRecordLoader { } } -extension CallRecord.CallStatus { - static var missedCalls: [CallRecord.CallStatus] { - return [ - .individual(.incomingMissed), - .group(.ringingMissed) - ] - } - - var isMissedCall: Bool { - return Self.missedCalls.contains(self) - } -} - // MARK: - private extension CallRecordLoader { diff --git a/Signal/src/Badge/AppIconBadgeUpdater.swift b/Signal/src/Badge/AppIconBadgeUpdater.swift index 50db4b968c..3696a9e104 100644 --- a/Signal/src/Badge/AppIconBadgeUpdater.swift +++ b/Signal/src/Badge/AppIconBadgeUpdater.swift @@ -19,7 +19,7 @@ class AppIconBadgeUpdater { } extension AppIconBadgeUpdater: BadgeObserver { - func didUpdateBadgeValue(_ badgeManager: BadgeManager, badgeValue: UInt) { - UIApplication.shared.applicationIconBadgeNumber = Int(badgeValue) + func didUpdateBadgeCount(_ badgeManager: BadgeManager, badgeCount: BadgeCount) { + UIApplication.shared.applicationIconBadgeNumber = Int(badgeCount.unreadTotalCount) } } diff --git a/Signal/src/Badge/BadgeManager.swift b/Signal/src/Badge/BadgeManager.swift index cab6b2c1d8..b00dd4c736 100644 --- a/Signal/src/Badge/BadgeManager.swift +++ b/Signal/src/Badge/BadgeManager.swift @@ -3,27 +3,31 @@ // SPDX-License-Identifier: AGPL-3.0-only // -import Foundation import SignalCoreKit import SignalServiceKit public protocol BadgeObserver { - func didUpdateBadgeValue(_ badgeManager: BadgeManager, badgeValue: UInt) + func didUpdateBadgeCount( + _ badgeManager: BadgeManager, + badgeCount: BadgeCount + ) } public class BadgeManager { + public typealias FetchBadgeCountBlock = () -> BadgeCount + private let mainScheduler: Scheduler private let serialScheduler: Scheduler - private let fetchBadgeValueBlock: () -> UInt + private let fetchBadgeCountBlock: FetchBadgeCountBlock public init( mainScheduler: Scheduler, serialScheduler: Scheduler, - fetchBadgeValue: @escaping () -> UInt + fetchBadgeCountBlock: @escaping FetchBadgeCountBlock ) { self.mainScheduler = mainScheduler self.serialScheduler = serialScheduler - self.fetchBadgeValueBlock = fetchBadgeValue + self.fetchBadgeCountBlock = fetchBadgeCountBlock } public convenience init( @@ -34,9 +38,10 @@ public class BadgeManager { self.init( mainScheduler: mainScheduler, serialScheduler: serialScheduler, - fetchBadgeValue: { - databaseStorage.read { tx in - InteractionFinder.unreadCountInAllThreads(transaction: tx) + fetchBadgeCountBlock: { + return databaseStorage.read { tx -> BadgeCount in + return DependenciesBridge.shared.badgeCountFetcher + .fetchBadgeCount(tx: tx.asV2Read) } } ) @@ -45,7 +50,7 @@ public class BadgeManager { private var observers = [Weak]() private var shouldFetch: Bool = true private var isFetching: Bool = false - private(set) var mostRecentBadgeValue: UInt? + private(set) var mostRecentBadgeCount: BadgeCount? private func fetchBadgeValueIfNeeded() { guard shouldFetch, !observers.isEmpty, !isFetching else { @@ -55,17 +60,17 @@ public class BadgeManager { shouldFetch = false let backgroundTask = OWSBackgroundTask(label: #function) serialScheduler.async { - let badgeValue = self.fetchBadgeValueBlock() + let badgeCount = self.fetchBadgeCountBlock() self.mainScheduler.async { self.isFetching = false self.observers.removeAll(where: { $0.value == nil }) if self.observers.isEmpty { // If there are no observers, we're going to stop fetching badge values for // a while, so don't keep around a value that's potentially outdated. - self.mostRecentBadgeValue = nil + self.mostRecentBadgeCount = nil } else { - self.mostRecentBadgeValue = badgeValue - self.observers.forEach { $0.value?.didUpdateBadgeValue(self, badgeValue: badgeValue) } + self.mostRecentBadgeCount = badgeCount + self.observers.forEach { $0.value?.didUpdateBadgeCount(self, badgeCount: badgeCount) } self.fetchBadgeValueIfNeeded() } backgroundTask.end() @@ -90,8 +95,8 @@ public class BadgeManager { /// value to the new observer, even if it's slightly out of date. public func addObserver(_ observer: BadgeObserver) { AssertIsOnMainThread() - if let mostRecentBadgeValue { - observer.didUpdateBadgeValue(self, badgeValue: mostRecentBadgeValue) + if let mostRecentBadgeCount { + observer.didUpdateBadgeCount(self, badgeCount: mostRecentBadgeCount) } observers.append(Weak(value: observer)) fetchBadgeValueIfNeeded() @@ -107,6 +112,7 @@ extension BadgeManager: DatabaseChangeDelegate { let badgeMightBeDifferent = ( databaseChanges.didUpdateInteractions || databaseChanges.didUpdateModel(collection: String(describing: ThreadAssociatedData.self)) + || databaseChanges.didUpdateModel(collection: String(describing: CallRecord.self)) ) guard badgeMightBeDifferent else { return diff --git a/Signal/src/Calls/Group/GroupCallRecordRingingCleanupManager.swift b/Signal/src/Calls/Group/GroupCallRecordRingingCleanupManager.swift index 0c4eb2e070..99b19ea88b 100644 --- a/Signal/src/Calls/Group/GroupCallRecordRingingCleanupManager.swift +++ b/Signal/src/Calls/Group/GroupCallRecordRingingCleanupManager.swift @@ -95,7 +95,7 @@ class GroupCallRecordRingingCleanupManager { let callRecordsToPeek = ringingCallRecords.prefix(Constants.maxRingingCallsToPeek) for ringingCallRecord in ringingCallRecords { - _ = callRecordStore.updateRecordStatus( + callRecordStore.updateCallAndUnreadStatus( callRecord: ringingCallRecord, newCallStatus: .group(.ringingMissed), tx: tx diff --git a/Signal/src/ViewControllers/HomeView/Calls/CallsListViewController.swift b/Signal/src/ViewControllers/HomeView/Calls/CallsListViewController.swift index a9fa6f7a70..c465d35018 100644 --- a/Signal/src/ViewControllers/HomeView/Calls/CallsListViewController.swift +++ b/Signal/src/ViewControllers/HomeView/Calls/CallsListViewController.swift @@ -33,8 +33,10 @@ class CallsListViewController: OWSViewController, HomeTabViewController, CallSer // MARK: - Dependencies private struct Dependencies { + let badgeManager: BadgeManager let callRecordDeleteManager: CallRecordDeleteManager let callRecordDeleteAllJobQueue: CallRecordDeleteAllJobQueue + let callRecordMissedCallManager: CallRecordMissedCallManager let callRecordQuerier: CallRecordQuerier let callRecordStore: CallRecordStore let callService: CallService @@ -46,8 +48,10 @@ class CallsListViewController: OWSViewController, HomeTabViewController, CallSer } private lazy var deps: Dependencies = Dependencies( + badgeManager: AppEnvironment.shared.badgeManager, callRecordDeleteManager: DependenciesBridge.shared.callRecordDeleteManager, callRecordDeleteAllJobQueue: SSKEnvironment.shared.callRecordDeleteAllJobQueueRef, + callRecordMissedCallManager: DependenciesBridge.shared.callRecordMissedCallManager, callRecordQuerier: DependenciesBridge.shared.callRecordQuerier, callRecordStore: DependenciesBridge.shared.callRecordStore, callService: NSObject.callService, @@ -122,6 +126,7 @@ class CallsListViewController: OWSViewController, HomeTabViewController, CallSer override func viewWillAppear(_ animated: Bool) { updateDisplayedDateForAllCallCells() + clearMissedCallsIfNecessary() } override func themeDidChange() { @@ -544,6 +549,42 @@ class CallsListViewController: OWSViewController, HomeTabViewController, CallSer reloadRows(forIdentifiers: callViewModelIdsToReload) } + // MARK: - Clear missed calls + + /// A serial queue for clearing the missed-call badge. + private let clearMissedCallQueue = DispatchQueue(label: "org.signal.calls-list-clear-missed") + + /// Asynchronously clears any missed-call badges, avoiding write + /// transactions if possible. + /// + /// - Important + /// The asynchronous work enqueued by this method is executed serially, such + /// that multiple calls to this method will not race. + private func clearMissedCallsIfNecessary() { + clearMissedCallQueue.async { + let unreadMissedCallCount = self.deps.db.read { tx in + self.deps.callRecordMissedCallManager.countUnreadMissedCalls( + tx: tx.asV2Read + ) + } + + /// We expect that the only unread calls to mark as read will be + /// missed calls, so if there's no unread missed calls no need to + /// open a write transaction. + guard unreadMissedCallCount > 0 else { return } + + self.deps.db.write { tx in + self.deps.callRecordMissedCallManager.markUnreadCallsAsRead( + tx: tx.asV2Write + ) + + tx.addAsyncCompletionOnMain { + self.deps.badgeManager.invalidateBadgeValue() + } + } + } + } + // MARK: - Call loading private var _calls: LoadedCalls! diff --git a/Signal/src/ViewControllers/HomeView/HomeTabBarController.swift b/Signal/src/ViewControllers/HomeView/HomeTabBarController.swift index 28d9084efb..dd792d4bca 100644 --- a/Signal/src/ViewControllers/HomeView/HomeTabBarController.swift +++ b/Signal/src/ViewControllers/HomeView/HomeTabBarController.swift @@ -193,8 +193,13 @@ class HomeTabBarController: UITabBarController { } extension HomeTabBarController: BadgeObserver { - func didUpdateBadgeValue(_ badgeManager: BadgeManager, badgeValue: UInt) { - chatListTabBarItem.badgeValue = badgeValue > 0 ? "\(badgeValue)" : nil + func didUpdateBadgeCount(_ badgeManager: BadgeManager, badgeCount: BadgeCount) { + func stringify(_ badgeValue: UInt) -> String? { + return badgeValue > 0 ? "\(badgeValue)" : nil + } + + chatListTabBarItem.badgeValue = stringify(badgeCount.unreadChatCount) + callsListTabBarItem.badgeValue = stringify(badgeCount.unreadCallsCount) } } diff --git a/Signal/test/Badge/BadgeManagerTest.swift b/Signal/test/Badge/BadgeManagerTest.swift index 077f47217d..198643f5f8 100644 --- a/Signal/test/Badge/BadgeManagerTest.swift +++ b/Signal/test/Badge/BadgeManagerTest.swift @@ -11,8 +11,8 @@ import XCTest class BadgeManagerTest: XCTestCase { private class MockBadgeObserver: BadgeObserver { var badgeValues = [UInt]() - func didUpdateBadgeValue(_ badgeManager: BadgeManager, badgeValue: UInt) { - badgeValues.append(badgeValue) + func didUpdateBadgeCount(_ badgeManager: BadgeManager, badgeCount: BadgeCount) { + badgeValues.append(badgeCount.unreadChatCount) } } @@ -22,7 +22,7 @@ class BadgeManagerTest: XCTestCase { let badgeManager = BadgeManager( mainScheduler: scheduler, serialScheduler: scheduler, - fetchBadgeValue: { + fetchIntBadgeValue: { fetchCount += 1 return fetchCount } @@ -53,7 +53,7 @@ class BadgeManagerTest: XCTestCase { let badgeManager = BadgeManager( mainScheduler: mainScheduler, serialScheduler: serialScheduler, - fetchBadgeValue: { + fetchIntBadgeValue: { fetchCount += 1 return fetchCount } @@ -76,3 +76,17 @@ class BadgeManagerTest: XCTestCase { XCTAssertEqual(fetchCount, 3) } } + +private extension BadgeManager { + convenience init( + mainScheduler: Scheduler, + serialScheduler: Scheduler, + fetchIntBadgeValue: @escaping () -> UInt + ) { + self.init( + mainScheduler: mainScheduler, + serialScheduler: serialScheduler, + fetchBadgeCountBlock: { BadgeCount(unreadChatCount: fetchIntBadgeValue(), unreadCallsCount: 0) } + ) + } +} diff --git a/Signal/test/CallsTab/CallRecordLoaderTest.swift b/Signal/test/CallsTab/CallRecordLoaderTest.swift index 3479f19d7f..71cc5d8756 100644 --- a/Signal/test/CallsTab/CallRecordLoaderTest.swift +++ b/Signal/test/CallsTab/CallRecordLoaderTest.swift @@ -267,6 +267,10 @@ private class MockCallRecordQuerier: CallRecordQuerier { func fetchCursor(threadRowId: Int64, callStatus: CallRecord.CallStatus, ordering: FetchOrdering, tx: DBReadTransaction) -> CallRecordCursor? { return Cursor(applyOrdering(mockCallRecords.filter { $0.callStatus == callStatus && $0.threadRowId == threadRowId }, ordering: ordering)) } + + func fetchCursorForUnread(callStatus: CallRecord.CallStatus, ordering: FetchOrdering, tx: DBReadTransaction) -> CallRecordCursor? { + return Cursor(applyOrdering(mockCallRecords.filter { $0.callStatus == callStatus && $0.unreadStatus == .unread }, ordering: ordering)) + } } private extension Array { diff --git a/SignalNSE/NotificationService.swift b/SignalNSE/NotificationService.swift index 997e890a27..fbca3e1f8e 100644 --- a/SignalNSE/NotificationService.swift +++ b/SignalNSE/NotificationService.swift @@ -69,7 +69,7 @@ class NotificationService: UNNotificationServiceExtension { // MARK: - // This method is thread-safe. - func completeSilently(timeHasExpired: Bool = false, badgeValue: UInt? = nil, logger: NSELogger) { + func completeSilently(timeHasExpired: Bool = false, badgeCount: BadgeCount? = nil, logger: NSELogger) { defer { logger.flush() } guard let contentHandler = contentHandler.swap(nil) else { @@ -79,7 +79,7 @@ class NotificationService: UNNotificationServiceExtension { Self.nseDidComplete() let content = UNMutableNotificationContent() - content.badge = badgeValue.map { NSNumber(value: $0) } + content.badge = badgeCount.map { NSNumber(value: $0.unreadTotalCount) } if timeHasExpired { contentHandler(content) @@ -236,10 +236,11 @@ class NotificationService: UNNotificationServiceExtension { SignalProxy.stopRelayServer() globalEnvironment.processingMessageCounter.decrementOrZero() // If we're completing normally, try to update the badge on the app icon. - let badgeValue = Self.databaseStorage.read { tx in - InteractionFinder.unreadCountInAllThreads(transaction: tx) + let badgeCount: BadgeCount = Self.databaseStorage.read { tx in + return DependenciesBridge.shared.badgeCountFetcher + .fetchBadgeCount(tx: tx.asV2Read) } - self?.completeSilently(badgeValue: badgeValue, logger: logger) + self?.completeSilently(badgeCount: badgeCount, logger: logger) }.catch(on: DispatchQueue.global()) { error in logger.error("Error: \(error)") } diff --git a/SignalServiceKit/Badge/BadgeCountFetcher.swift b/SignalServiceKit/Badge/BadgeCountFetcher.swift new file mode 100644 index 0000000000..7667fa0cd0 --- /dev/null +++ b/SignalServiceKit/Badge/BadgeCountFetcher.swift @@ -0,0 +1,31 @@ +// +// Copyright 2024 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only +// + +public struct BadgeCount { + public let unreadChatCount: UInt + public let unreadCallsCount: UInt + + public var unreadTotalCount: UInt { + unreadChatCount + unreadCallsCount + } +} + +public protocol BadgeCountFetcher { + func fetchBadgeCount(tx: DBReadTransaction) -> BadgeCount +} + +class BadgeCountFetcherImpl: BadgeCountFetcher { + public func fetchBadgeCount(tx: DBReadTransaction) -> BadgeCount { + let sdsTx = SDSDB.shimOnlyBridge(tx) + + let unreadInteractionCount = InteractionFinder.unreadCountInAllThreads(transaction: sdsTx) + let unreadMissedCallCount = DependenciesBridge.shared.callRecordMissedCallManager.countUnreadMissedCalls(tx: tx) + + return BadgeCount( + unreadChatCount: unreadInteractionCount, + unreadCallsCount: unreadMissedCallCount + ) + } +} diff --git a/SignalServiceKit/Calls/CallRecord/CallRecord+CallStatus.swift b/SignalServiceKit/Calls/CallRecord/CallRecord+CallStatus.swift new file mode 100644 index 0000000000..bbf31fc8d2 --- /dev/null +++ b/SignalServiceKit/Calls/CallRecord/CallRecord+CallStatus.swift @@ -0,0 +1,175 @@ +// +// Copyright 2024 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only +// + +extension CallRecord { + public enum CallStatus: Codable, Equatable { + case individual(IndividualCallStatus) + case group(GroupCallStatus) + + /// Represents the states that an individual (1:1) call may be in. + /// + /// - Important + /// The raw values of the cases of this enum must not overlap with those + /// for ``GroupCallStatus``, or en/decoding becomes ambiguous. + public enum IndividualCallStatus: Int, CaseIterable { + /// This is a call for which no action has yet been taken. + /// + /// For example, this call may have been accepted on a linked + /// device, but we haven't yet received the corresponding sync + /// message. Records with this status can be used to bridge between + /// an incoming sync mesage and other state, such as the + /// corresponding interaction. + /// + /// Records with this status should eventually be updated to another + /// status. If they aren't, the call should be treated as missed. + case pending = 0 + + /// This call was accepted. + /// + /// For an incoming call, indicates we accepted the ring. For an + /// outgoing call, indicates the receiver accepted the ring. + case accepted = 1 + + /// This call was not accepted. + /// + /// For an incoming call, indicates we actively declined the ring. + /// For an outgoing call, indicates the receiver did not accept. + case notAccepted = 2 + + /// This was an incoming call that we missed. + /// + /// An incoming missed call is contrasted with an actively-declined + /// one, which would fall under ``notAccepted`` above. + /// + /// - Note + /// Calls declined as "busy" use this case. + case incomingMissed = 3 + } + + /// Represents the states that a group call may be in. + /// + /// - Important + /// The raw values of the cases of this enum must not overlap with those + /// for ``IndividualCallStatus``, or en/decoding becomes ambiguous. + public enum GroupCallStatus: Int, CaseIterable { + /// This is a call that was started without ringing, which we have + /// learned about but are not involved with. + case generic = 4 + + /// This is a call that was started without ringing, which we have + /// joined. + case joined = 5 + + /// This call involves ringing which is actively occuring. No action + /// has yet been taken on the ring, and it has not expired. + /// + /// - Note + /// We do not track the state of outgoing group rings and instead + /// record them as accepted when we start the ring. Consequently, + /// only incoming rings should be in this state. + case ringing = 9 + + /// This call involved ringing, and the ring was accepted. + /// + /// - Note + /// We do not track the state of outgoing group rings and instead + /// record them as accepted when we start the ring. All outgoing + /// group rings will therefore end up in this state. + case ringingAccepted = 6 + + /// This call involved ringing, and the ring was declined. + /// + /// For an incoming call, indicates we actively declined the ring. + /// + /// - Note + /// We do not track the state of outgoing group rings and instead + /// record them as accepted when we start the ring. Consequently, + /// only incoming rings should be in this state. + case ringingDeclined = 7 + + /// This call involved ringing, and no action was taken on the ring + /// before it expired. + /// + /// A missed call is contrasted with an actively-declined one, which + /// would fall under ``ringingNotAccepted`` above. + /// + /// - Note + /// Calls declined as "busy" use this case. + /// + /// - Note + /// We do not track the state of outgoing group rings and instead + /// record them as accepted when we start the ring. Consequently, + /// only incoming rings should be in this state. + case ringingMissed = 8 + } + + // MARK: Codable + + var intValue: Int { + switch self { + case .individual(let individualCallStatus): return individualCallStatus.rawValue + case .group(let groupCallStatus): return groupCallStatus.rawValue + } + } + + private init?(intValue: Int) { + if let individualCallStatus = IndividualCallStatus(rawValue: intValue) { + self = .individual(individualCallStatus) + } else if let groupCallStatus = GroupCallStatus(rawValue: intValue) { + self = .group(groupCallStatus) + } else { + owsFailDebug("Unexpected int value: \(intValue)") + return nil + } + } + + public init(from decoder: Decoder) throws { + let singleValueContainer = try decoder.singleValueContainer() + let intValue = try singleValueContainer.decode(Int.self) + + guard let selfValue = CallStatus(intValue: intValue) else { + throw DecodingError.dataCorruptedError( + in: singleValueContainer, + debugDescription: "\(type(of: self)) contained unexpected int value: \(intValue)" + ) + } + + self = selfValue + } + + public func encode(to encoder: Encoder) throws { + var singleValueContainer = encoder.singleValueContainer() + try singleValueContainer.encode(intValue) + } + } +} + +// MARK: - All cases + +public extension CallRecord.CallStatus { + static var allCases: [CallRecord.CallStatus] { + let allIndividualCases: [CallRecord.CallStatus] = IndividualCallStatus.allCases + .map { .individual($0) } + let allGroupCases: [CallRecord.CallStatus] = GroupCallStatus.allCases + .map { .group($0) } + + return allIndividualCases + allGroupCases + } +} + +// MARK: - Missed calls + +public extension CallRecord.CallStatus { + static var missedCalls: [CallRecord.CallStatus] { + return [ + .individual(.incomingMissed), + .group(.ringingMissed) + ] + } + + var isMissedCall: Bool { + return Self.missedCalls.contains(self) + } +} diff --git a/SignalServiceKit/Calls/CallRecord/CallRecord.swift b/SignalServiceKit/Calls/CallRecord/CallRecord.swift index 91e493038a..16c2d0d7c1 100644 --- a/SignalServiceKit/Calls/CallRecord/CallRecord.swift +++ b/SignalServiceKit/Calls/CallRecord/CallRecord.swift @@ -25,6 +25,7 @@ public final class CallRecord: Codable, PersistableRecord, FetchableRecord { case callStatus = "status" case _groupCallRingerAci = "groupCallRingerAci" case callBeganTimestamp = "timestamp" + case unreadStatus = "unreadStatus" } /// This record's SQLite row ID, if it represents a record that has already @@ -58,6 +59,17 @@ public final class CallRecord: Codable, PersistableRecord, FetchableRecord { public internal(set) var callDirection: CallDirection public internal(set) var callStatus: CallStatus + /// The "unread" status of this call, which is used for app icon and Calls + /// Tab badging. + /// + /// - Note + /// Only missed calls should ever be in an unread state. All other calls + /// should have already been marked as read. + /// + /// - SeeAlso: ``CallRecord/CallStatus/isMissedCall`` + /// - SeeAlso: ``CallRecordStore/updateCallAndUnreadStatus(callRecord:newCallStatus:tx:)`` + public internal(set) var unreadStatus: CallUnreadStatus + /// If this record represents a group ring, returns the user that initiated /// the ring. /// @@ -124,6 +136,11 @@ public final class CallRecord: Codable, PersistableRecord, FetchableRecord { /// as for display. public internal(set) var callBeganTimestamp: UInt64 + /// Creates a ``CallRecord`` with the given parameters. + /// + /// - Note + /// The ``unreadStatus`` for this call record is automatically derived from + /// its given call status. public init( callId: UInt64, interactionRowId: Int64, @@ -140,6 +157,7 @@ public final class CallRecord: Codable, PersistableRecord, FetchableRecord { self.callType = callType self.callDirection = callDirection self.callStatus = callStatus + self.unreadStatus = CallUnreadStatus(callStatus: callStatus) self.callBeganTimestamp = callBeganTimestamp if let groupCallRingerAci, isGroupRing { @@ -168,145 +186,17 @@ extension CallRecord { case outgoing = 1 } - public enum CallStatus: Codable, Equatable { - case individual(IndividualCallStatus) - case group(GroupCallStatus) + public enum CallUnreadStatus: Int, Codable { + case read = 0 + case unread = 1 - /// Represents the states that an individual (1:1) call may be in. - /// - /// - Important - /// The raw values of the cases of this enum must not overlap with those - /// for ``GroupCallStatus``, or en/decoding becomes ambiguous. - public enum IndividualCallStatus: Int, CaseIterable { - /// This is a call for which no action has yet been taken. - /// - /// For example, this call may have been accepted on a linked - /// device, but we haven't yet received the corresponding sync - /// message. Records with this status can be used to bridge between - /// an incoming sync mesage and other state, such as the - /// corresponding interaction. - /// - /// Records with this status should eventually be updated to another - /// status. If they aren't, the call should be treated as missed. - case pending = 0 - - /// This call was accepted. - /// - /// For an incoming call, indicates we accepted the ring. For an - /// outgoing call, indicates the receiver accepted the ring. - case accepted = 1 - - /// This call was not accepted. - /// - /// For an incoming call, indicates we actively declined the ring. - /// For an outgoing call, indicates the receiver did not accept. - case notAccepted = 2 - - /// This was an incoming call that we missed. - /// - /// An incoming missed call is contrasted with an actively-declined - /// one, which would fall under ``notAccepted`` above. - /// - /// - Note - /// Calls declined as "busy" use this case. - case incomingMissed = 3 - } - - /// Represents the states that a group call may be in. - /// - /// - Important - /// The raw values of the cases of this enum must not overlap with those - /// for ``IndividualCallStatus``, or en/decoding becomes ambiguous. - public enum GroupCallStatus: Int, CaseIterable { - /// This is a call that was started without ringing, which we have - /// learned about but are not involved with. - case generic = 4 - - /// This is a call that was started without ringing, which we have - /// joined. - case joined = 5 - - /// This call involves ringing which is actively occuring. No action - /// has yet been taken on the ring, and it has not expired. - /// - /// - Note - /// We do not track the state of outgoing group rings and instead - /// record them as accepted when we start the ring. Consequently, - /// only incoming rings should be in this state. - case ringing = 9 - - /// This call involved ringing, and the ring was accepted. - /// - /// - Note - /// We do not track the state of outgoing group rings and instead - /// record them as accepted when we start the ring. All outgoing - /// group rings will therefore end up in this state. - case ringingAccepted = 6 - - /// This call involved ringing, and the ring was declined. - /// - /// For an incoming call, indicates we actively declined the ring. - /// - /// - Note - /// We do not track the state of outgoing group rings and instead - /// record them as accepted when we start the ring. Consequently, - /// only incoming rings should be in this state. - case ringingDeclined = 7 - - /// This call involved ringing, and no action was taken on the ring - /// before it expired. - /// - /// A missed call is contrasted with an actively-declined one, which - /// would fall under ``ringingNotAccepted`` above. - /// - /// - Note - /// Calls declined as "busy" use this case. - /// - /// - Note - /// We do not track the state of outgoing group rings and instead - /// record them as accepted when we start the ring. Consequently, - /// only incoming rings should be in this state. - case ringingMissed = 8 - } - - // MARK: Codable - - var intValue: Int { - switch self { - case .individual(let individualCallStatus): return individualCallStatus.rawValue - case .group(let groupCallStatus): return groupCallStatus.rawValue - } - } - - private init?(intValue: Int) { - if let individualCallStatus = IndividualCallStatus(rawValue: intValue) { - self = .individual(individualCallStatus) - } else if let groupCallStatus = GroupCallStatus(rawValue: intValue) { - self = .group(groupCallStatus) + init(callStatus: CallStatus) { + if callStatus.isMissedCall { + self = .unread } else { - owsFailDebug("Unexpected int value: \(intValue)") - return nil + self = .read } } - - public init(from decoder: Decoder) throws { - let singleValueContainer = try decoder.singleValueContainer() - let intValue = try singleValueContainer.decode(Int.self) - - guard let selfValue = CallStatus(intValue: intValue) else { - throw DecodingError.dataCorruptedError( - in: singleValueContainer, - debugDescription: "\(type(of: self)) contained unexpected int value: \(intValue)" - ) - } - - self = selfValue - } - - public func encode(to encoder: Encoder) throws { - var singleValueContainer = encoder.singleValueContainer() - try singleValueContainer.encode(intValue) - } } } @@ -326,7 +216,8 @@ extension CallRecord { callDirection == other.callDirection, callStatus == other.callStatus, groupCallRingerAci == other.groupCallRingerAci, - callBeganTimestamp == other.callBeganTimestamp + callBeganTimestamp == other.callBeganTimestamp, + unreadStatus == other.unreadStatus { return true } diff --git a/SignalServiceKit/Calls/CallRecord/CallRecordMissedCallManager.swift b/SignalServiceKit/Calls/CallRecord/CallRecordMissedCallManager.swift new file mode 100644 index 0000000000..7b39952cba --- /dev/null +++ b/SignalServiceKit/Calls/CallRecord/CallRecordMissedCallManager.swift @@ -0,0 +1,96 @@ +// +// Copyright 2024 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only +// + +import SignalCoreKit + +public protocol CallRecordMissedCallManager { + /// The number of unread missed calls. + func countUnreadMissedCalls(tx: DBReadTransaction) -> UInt + + /// Marks all unread calls as read. + func markUnreadCallsAsRead(tx: DBWriteTransaction) +} + +class CallRecordMissedCallManagerImpl: CallRecordMissedCallManager { + private let callRecordQuerier: CallRecordQuerier + private let callRecordStore: CallRecordStore + + init( + callRecordQuerier: CallRecordQuerier, + callRecordStore: CallRecordStore + ) { + self.callRecordQuerier = callRecordQuerier + self.callRecordStore = callRecordStore + } + + // MARK: - + + func countUnreadMissedCalls(tx: DBReadTransaction) -> UInt { + guard FeatureFlags.shouldShowCallsTab else { return 0 } + + return _countUnreadMissedCalls(tx: tx) + } + + func markUnreadCallsAsRead(tx: DBWriteTransaction) { + guard FeatureFlags.shouldShowCallsTab else { return } + + _markUnreadCallsAsRead(tx: tx) + } + + // MARK: - + + private func _countUnreadMissedCalls(tx: DBReadTransaction) -> UInt { + var unreadMissedCallCount: UInt = 0 + + for missedCallStatus in CallRecord.CallStatus.missedCalls { + guard let unreadMissedCallCursor = callRecordQuerier.fetchCursorForUnread( + callStatus: missedCallStatus, + ordering: .descending, + tx: tx + ) else { continue } + + do { + while let _ = try unreadMissedCallCursor.next() { + unreadMissedCallCount += 1 + } + } catch { + owsFailDebug("Unexpectedly failed to iterate CallRecord cursor!") + continue + } + } + + return unreadMissedCallCount + } + + private func _markUnreadCallsAsRead(tx: DBWriteTransaction) { + for callStatus in CallRecord.CallStatus.allCases { + guard let unreadCallCursor = callRecordQuerier.fetchCursorForUnread( + callStatus: callStatus, + ordering: .descending, + tx: tx + ) else { continue } + + do { + var unreadCallCount = 0 + + while let unreadCallRecord = try unreadCallCursor.next() { + unreadCallCount += 1 + + callRecordStore.markAsRead( + callRecord: unreadCallRecord, tx: tx + ) + } + + owsAssertDebug( + unreadCallCount == 0 || callStatus.isMissedCall, + "Unexpectedly had \(unreadCallCount) unread calls that were not missed!" + ) + } catch { + owsFailDebug("Unexpectedly failed to iterate CallRecord cursor!") + continue + } + } + } +} diff --git a/SignalServiceKit/Calls/CallRecord/CallRecordQuerier.swift b/SignalServiceKit/Calls/CallRecord/CallRecordQuerier.swift index f7b473d764..330e522b07 100644 --- a/SignalServiceKit/Calls/CallRecord/CallRecordQuerier.swift +++ b/SignalServiceKit/Calls/CallRecord/CallRecordQuerier.swift @@ -113,6 +113,28 @@ public protocol CallRecordQuerier { ordering: FetchOrdering, tx: DBReadTransaction ) -> CallRecordCursor? + + /// Returns a cursor over all ``CallRecord``s with the given call status + /// whose ``CallRecord/unreadStatus`` is `.unread`. + /// + /// - Note + /// In practice, all unread calls should have a "missed call" status. + /// - SeeAlso: ``CallRecord/CallStatus/missedCalls`` + /// - SeeAlso: ``CallRecord/unreadStatus`` + /// - SeeAlso: ``CallRecordStore/updateCallAndUnreadStatus(callRecord:newCallStatus:tx:)`` + /// + /// - Note + /// The cursor should be ordered by ``CallRecord/callBeganTimestamp``, + /// according to the given ordering. + /// + /// - Note + /// The implementation of this method in ``CallRecordQuerierImpl`` relies on + /// the index `index_call_record_on_callStatus_and_unreadStatus_and_timestamp`. + func fetchCursorForUnread( + callStatus: CallRecord.CallStatus, + ordering: FetchOrdering, + tx: DBReadTransaction + ) -> CallRecordCursor? } // MARK: - @@ -248,6 +270,35 @@ class CallRecordQuerierImpl: CallRecordQuerier { // MARK: - + func fetchCursorForUnread( + callStatus: CallRecord.CallStatus, + ordering: FetchOrdering, + tx: DBReadTransaction + ) -> CallRecordCursor? { + return fetchCursorForUnread( + callStatus: callStatus, + ordering: ordering, + db: SDSDB.shimOnlyBridge(tx).database + ) + } + + func fetchCursorForUnread( + callStatus: CallRecord.CallStatus, + ordering: FetchOrdering, + db: Database + ) -> CallRecordCursor? { + return fetchCursor( + columnArgs: [ + ColumnArg(.callStatus, callStatus.intValue), + ColumnArg(.unreadStatus, CallRecord.CallUnreadStatus.unread.rawValue) + ], + ordering: ordering, + db: db + ) + } + + // MARK: - + fileprivate func fetchCursor( columnArgs: [ColumnArg], ordering: FetchOrdering, diff --git a/SignalServiceKit/Calls/CallRecord/CallRecordStore.swift b/SignalServiceKit/Calls/CallRecord/CallRecordStore.swift index 9c2c0817a1..c6a9161e27 100644 --- a/SignalServiceKit/Calls/CallRecord/CallRecordStore.swift +++ b/SignalServiceKit/Calls/CallRecord/CallRecordStore.swift @@ -41,15 +41,40 @@ public protocol CallRecordStore { /// Posts a `.deleted` ``CallRecordStoreNotification``. func delete(callRecords: [CallRecord], tx: DBWriteTransaction) - /// Update the status of the given call record. + /// Update the call status and unread status of the given call record. + /// + /// - Note + /// In practice, call records are created in a "read" state, and only if a + /// call's status changes to a "missed call" status is the call considered + /// "unread". An unread (missed) call can later be marked as read without + /// changing its status. + /// + /// An edge-case exception to this is if a call is created with a "missed" + /// status, in which case it will be unread. At the time of writing this + /// shouldn't normally happen, since the call should have been created with + /// a ringing status before later becoming missed. + /// + /// - SeeAlso: ``markAsRead(callRecord:tx:)`` + /// /// - Important /// Posts a `.statusUpdated` ``CallRecordStoreNotification``. - func updateRecordStatus( + func updateCallAndUnreadStatus( callRecord: CallRecord, newCallStatus: CallRecord.CallStatus, tx: DBWriteTransaction ) + /// Updates the unread status of the given call record to `.read`. + /// + /// - Note + /// In practice, only missed calls are ever in an "unread" state. This API + /// can then be used to mark them as "read". + /// - SeeAlso: ``updateCallAndUnreadStatus(callRecord:newCallStatus:tx:)`` + func markAsRead( + callRecord: CallRecord, + tx: DBWriteTransaction + ) + /// Update the direction of the given call record. func updateDirection( callRecord: CallRecord, @@ -137,12 +162,12 @@ class CallRecordStoreImpl: CallRecordStore { ) } - func updateRecordStatus( + func updateCallAndUnreadStatus( callRecord: CallRecord, newCallStatus: CallRecord.CallStatus, tx: DBWriteTransaction ) { - updateRecordStatus( + updateCallAndUnreadStatus( callRecord: callRecord, newCallStatus: newCallStatus, db: SDSDB.shimOnlyBridge(tx).database @@ -156,6 +181,13 @@ class CallRecordStoreImpl: CallRecordStore { ) } + func markAsRead(callRecord: CallRecord, tx: DBWriteTransaction) { + markAsRead( + callRecord: callRecord, + db: SDSDB.shimOnlyBridge(tx).database + ) + } + func updateDirection( callRecord: CallRecord, newCallDirection: CallRecord.CallDirection, @@ -254,16 +286,25 @@ class CallRecordStoreImpl: CallRecordStore { } } - func updateRecordStatus( + func updateCallAndUnreadStatus( callRecord: CallRecord, newCallStatus: CallRecord.CallStatus, db: Database ) { let logger = CallRecordLogger.shared.suffixed(with: " \(callRecord.callStatus) -> \(newCallStatus)") - logger.info("Updating existing call record.") callRecord.callStatus = newCallStatus + callRecord.unreadStatus = CallRecord.CallUnreadStatus(callStatus: newCallStatus) + do { + try callRecord.update(db) + } catch let error { + owsFailBeta("Failed to update call record: \(error)") + } + } + + func markAsRead(callRecord: CallRecord, db: Database) { + callRecord.unreadStatus = .read do { try callRecord.update(db) } catch let error { diff --git a/SignalServiceKit/Calls/CallRecord/GroupCallRecordManager.swift b/SignalServiceKit/Calls/CallRecord/GroupCallRecordManager.swift index da93f53f66..df46e060d8 100644 --- a/SignalServiceKit/Calls/CallRecord/GroupCallRecordManager.swift +++ b/SignalServiceKit/Calls/CallRecord/GroupCallRecordManager.swift @@ -252,7 +252,7 @@ public class GroupCallRecordManagerImpl: GroupCallRecordManager { return } - callRecordStore.updateRecordStatus( + callRecordStore.updateCallAndUnreadStatus( callRecord: existingCallRecord, newCallStatus: .group(newGroupCallStatus), tx: tx diff --git a/SignalServiceKit/Calls/CallRecord/IndividualCallRecordManager.swift b/SignalServiceKit/Calls/CallRecord/IndividualCallRecordManager.swift index 25fd21d974..bc723119cf 100644 --- a/SignalServiceKit/Calls/CallRecord/IndividualCallRecordManager.swift +++ b/SignalServiceKit/Calls/CallRecord/IndividualCallRecordManager.swift @@ -215,7 +215,7 @@ public class IndividualCallRecordManagerImpl: IndividualCallRecordManager { return } - callRecordStore.updateRecordStatus( + callRecordStore.updateCallAndUnreadStatus( callRecord: existingCallRecord, newCallStatus: .individual(newIndividualCallStatus), tx: tx diff --git a/SignalServiceKit/Dependencies/DependenciesBridge.swift b/SignalServiceKit/Dependencies/DependenciesBridge.swift index cecc1216a6..05f3bd2eaf 100644 --- a/SignalServiceKit/Dependencies/DependenciesBridge.swift +++ b/SignalServiceKit/Dependencies/DependenciesBridge.swift @@ -43,10 +43,14 @@ public class DependenciesBridge { public let appExpiry: AppExpiry public let authorMergeHelper: AuthorMergeHelper + public let badgeCountFetcher: BadgeCountFetcher + let deletedCallRecordStore: DeletedCallRecordStore public let deletedCallRecordCleanupManager: DeletedCallRecordCleanupManager + public let callRecordStore: CallRecordStore public let callRecordDeleteManager: CallRecordDeleteManager + public let callRecordMissedCallManager: CallRecordMissedCallManager public let callRecordQuerier: CallRecordQuerier public let groupCallRecordManager: GroupCallRecordManager @@ -298,6 +302,8 @@ public class DependenciesBridge { schedulers: schedulers ) + self.badgeCountFetcher = BadgeCountFetcherImpl() + self.recipientDatabaseTable = recipientDatabaseTable self.recipientFetcher = recipientFetcher self.recipientIdFinder = recipientIdFinder @@ -454,6 +460,11 @@ public class DependenciesBridge { ) self.callRecordQuerier = CallRecordQuerierImpl() + self.callRecordMissedCallManager = CallRecordMissedCallManagerImpl( + callRecordQuerier: self.callRecordQuerier, + callRecordStore: self.callRecordStore + ) + self.groupCallRecordManager = GroupCallRecordManagerImpl( callRecordStore: self.callRecordStore, interactionStore: interactionStore, diff --git a/SignalServiceKit/Mocks/CallRecord/MockCallRecordStore.swift b/SignalServiceKit/Mocks/CallRecord/MockCallRecordStore.swift index ef1b535c81..c6b76b28a1 100644 --- a/SignalServiceKit/Mocks/CallRecord/MockCallRecordStore.swift +++ b/SignalServiceKit/Mocks/CallRecord/MockCallRecordStore.swift @@ -38,10 +38,15 @@ class MockCallRecordStore: CallRecordStore { } var askedToUpdateRecordStatusTo: CallRecord.CallStatus? - func updateRecordStatus(callRecord: CallRecord, newCallStatus: CallRecord.CallStatus, tx: DBWriteTransaction) { + func updateCallAndUnreadStatus(callRecord: CallRecord, newCallStatus: CallRecord.CallStatus, tx: DBWriteTransaction) { askedToUpdateRecordStatusTo = newCallStatus } + var askedToMarkAsRead: Bool = false + func markAsRead(callRecord: CallRecord, tx: DBWriteTransaction) { + askedToMarkAsRead = true + } + var askedToUpdateRecordDirectionTo: CallRecord.CallDirection? func updateDirection(callRecord: CallRecord, newCallDirection: CallRecord.CallDirection, tx: DBWriteTransaction) { askedToUpdateRecordDirectionTo = newCallDirection diff --git a/SignalServiceKit/Resources/schema.sql b/SignalServiceKit/Resources/schema.sql index 67d1510c47..e9216b01f9 100644 --- a/SignalServiceKit/Resources/schema.sql +++ b/SignalServiceKit/Resources/schema.sql @@ -1491,6 +1491,7 @@ CREATE ,"status" INTEGER NOT NULL ,"timestamp" INTEGER NOT NULL ,"groupCallRingerAci" BLOB + ,"unreadStatus" INTEGER NOT NULL DEFAULT 0 ) ; @@ -1554,3 +1555,11 @@ CREATE ON "DeletedCallRecord"("deletedAtTimestamp" ) ; + +CREATE + INDEX "index_call_record_on_callStatus_and_unreadStatus_and_timestamp" + ON "CallRecord"("status" + ,"unreadStatus" + ,"timestamp" +) +; diff --git a/SignalServiceKit/src/Storage/Database/GRDBSchemaMigrator.swift b/SignalServiceKit/src/Storage/Database/GRDBSchemaMigrator.swift index b0328ef8bd..fbeefb9aa5 100644 --- a/SignalServiceKit/src/Storage/Database/GRDBSchemaMigrator.swift +++ b/SignalServiceKit/src/Storage/Database/GRDBSchemaMigrator.swift @@ -249,6 +249,7 @@ public class GRDBSchemaMigrator: NSObject { case addPhoneNumberSharingAndDiscoverability case removeRedundantPhoneNumbers2 case scheduleFullIntersection + case addUnreadToCallRecord // NOTE: Every time we add a migration id, consider // incrementing grdbSchemaVersionLatest. @@ -308,7 +309,7 @@ public class GRDBSchemaMigrator: NSObject { } public static let grdbSchemaVersionDefault: UInt = 0 - public static let grdbSchemaVersionLatest: UInt = 66 + public static let grdbSchemaVersionLatest: UInt = 67 // An optimization for new users, we have the first migration import the latest schema // and mark any other migrations as "already run". @@ -2628,6 +2629,36 @@ public class GRDBSchemaMigrator: NSObject { return .success(()) } + migrator.registerMigration(.addUnreadToCallRecord) { tx in + /// Annoyingly, we need to provide a DEFAULT value in this migration + /// to cover all existing rows. I'd prefer that we make the column + /// not have a default value that applies going forward, since all + /// records inserted after this migration runs will provide a value + /// for this column. + /// + /// However, SQLite doesn't know that, and consequently won't allow + /// us to create a NOT NULL column without a default – even if we + /// were to run a separate SQL statement after creating the column + /// to populate it for existing rows. + try tx.database.alter(table: "CallRecord") { table in + table.add(column: "unreadStatus", .integer) + .notNull() + .defaults(to: CallRecord.CallUnreadStatus.read.rawValue) + } + + try tx.database.create( + index: "index_call_record_on_callStatus_and_unreadStatus_and_timestamp", + on: "CallRecord", + columns: [ + "status", + "unreadStatus", + "timestamp", + ] + ) + + return .success(()) + } + // MARK: - Schema Migration Insertion Point } diff --git a/SignalServiceKit/tests/Calls/CallRecord/CallRecordQuerierTest.swift b/SignalServiceKit/tests/Calls/CallRecord/CallRecordQuerierTest.swift index c13545c7ac..0dbc63d28c 100644 --- a/SignalServiceKit/tests/Calls/CallRecord/CallRecordQuerierTest.swift +++ b/SignalServiceKit/tests/Calls/CallRecord/CallRecordQuerierTest.swift @@ -40,6 +40,7 @@ final class CallRecordQuerierTest: XCTestCase { /// The row ID of the thread these call records are associated with. private func insertCallRecordsForThread( callStatuses: [CallRecord.CallStatus], + unreadStatus: CallRecord.CallUnreadStatus? = nil, knownThreadRowId: Int64? = nil ) -> Int64 { return inMemoryDB.write { tx -> Int64 in @@ -66,7 +67,7 @@ final class CallRecordQuerierTest: XCTestCase { } }() - try! CallRecord( + let callRecord = CallRecord( callId: .maxRandom, interactionRowId: interactionRowId, threadRowId: threadRowId, @@ -74,7 +75,13 @@ final class CallRecordQuerierTest: XCTestCase { callDirection: .incoming, callStatus: callStatus, callBeganTimestamp: runningCallBeganTimestampForInsertedCallRecords - ).insert(db) + ) + + if let unreadStatus { + callRecord.unreadStatus = unreadStatus + } + + try! callRecord.insert(db) runningCallBeganTimestampForInsertedCallRecords += 1 } @@ -294,6 +301,39 @@ final class CallRecordQuerierTest: XCTestCase { } } + func testFetchUnreadByCallStatus() { + _ = insertCallRecordsForThread(callStatuses: [.group(.ringingMissed)], unreadStatus: .unread) + _ = insertCallRecordsForThread(callStatuses: [.group(.ringingMissed)], unreadStatus: .read) + _ = insertCallRecordsForThread(callStatuses: [.group(.ringingMissed)], unreadStatus: .unread) + _ = insertCallRecordsForThread(callStatuses: [.group(.ringingMissed)], unreadStatus: .read) + _ = insertCallRecordsForThread(callStatuses: [.individual(.incomingMissed)], unreadStatus: .unread) + _ = insertCallRecordsForThread(callStatuses: [.individual(.incomingMissed)], unreadStatus: .read) + + func testCase( + _ callRecords: [CallRecord], + count: Int, + sortDirection: SortDirection + ) { + assertExplanation(contains: "index_call_record_on_callStatus_and_unreadStatus_and_timestamp") + XCTAssertEqual(callRecords.count, count) + XCTAssertTrue(callRecords.allSatisfy { $0.callStatus == .group(.ringingMissed) }) + XCTAssertTrue(callRecords.allSatisfy { $0.unreadStatus == .unread }) + XCTAssertTrue(callRecords.isSortedByTimestamp(sortDirection)) + } + + inMemoryDB.read { tx in + testCase( + try! callRecordQuerier.fetchCursorForUnread( + callStatus: .group(.ringingMissed), + ordering: .descending, + db: tx.database + )!.drain(), + count: 2, + sortDirection: .descending + ) + } + } + /// Asserts that the latest fetch explanation in the call record querier /// contains the given string. private func assertExplanation( diff --git a/SignalServiceKit/tests/Calls/CallRecord/CallRecordStoreTest.swift b/SignalServiceKit/tests/Calls/CallRecord/CallRecordStoreTest.swift index a641975ae6..04f6e18381 100644 --- a/SignalServiceKit/tests/Calls/CallRecord/CallRecordStoreTest.swift +++ b/SignalServiceKit/tests/Calls/CallRecord/CallRecordStoreTest.swift @@ -177,7 +177,7 @@ final class CallRecordStoreTest: XCTestCase { } } - // MARK: - updateRecordStatus + // MARK: - updateCallStatus func testUpdateRecordStatus() { let callRecord = makeCallRecord(callStatus: .group(.generic)) @@ -187,7 +187,7 @@ final class CallRecordStoreTest: XCTestCase { } inMemoryDB.write { tx in - callRecordStore.updateRecordStatus( + callRecordStore.updateCallAndUnreadStatus( callRecord: callRecord, newCallStatus: .group(.joined), db: InMemoryDB.shimOnlyBridge(tx).db @@ -206,6 +206,84 @@ final class CallRecordStoreTest: XCTestCase { XCTAssertTrue(callRecord.matches(fetched)) } + func testUpdateRecordStatusAndUnread() { + /// Some of these are not updates that can happen in production; for + /// example, a missed individual call cannot move into a pending state. + /// + /// However, for the purposes of ``CallRecordStore`` we're just + /// interested in testing that it does a dumb update. + let testCases: [( + beforeCallStatus: CallRecord.CallStatus, + beforeUnreadStatus: CallRecord.CallUnreadStatus, + afterCallStatus: CallRecord.CallStatus, + afterUnreadStatus: CallRecord.CallUnreadStatus + )] = [ + (.individual(.pending), .read, .individual(.incomingMissed), .unread), + (.individual(.incomingMissed), .unread, .individual(.pending), .read), + (.individual(.incomingMissed), .unread, .individual(.accepted), .read), + (.individual(.incomingMissed), .unread, .individual(.notAccepted), .read), + + (.group(.generic), .read, .group(.ringingMissed), .unread), + (.group(.ringingMissed), .unread, .group(.generic), .read), + (.group(.ringingMissed), .unread, .group(.joined), .read), + (.group(.ringingMissed), .unread, .group(.ringing), .read), + (.group(.ringingMissed), .unread, .group(.ringingAccepted), .read), + (.group(.ringingMissed), .unread, .group(.ringingDeclined), .read), + ] + XCTAssertEqual( + testCases.count, + CallRecord.CallStatus.allCases.count + ) + + for (beforeCallStatus, beforeUnreadStatus, afterCallStatus, afterUnreadStatus) in testCases { + let callRecord = makeCallRecord(callStatus: beforeCallStatus) + XCTAssertEqual(callRecord.unreadStatus, beforeUnreadStatus) + + inMemoryDB.write { tx in + callRecordStore.insert(callRecord: callRecord, db: InMemoryDB.shimOnlyBridge(tx).db) + + callRecordStore.updateCallAndUnreadStatus( + callRecord: callRecord, + newCallStatus: afterCallStatus, + db: InMemoryDB.shimOnlyBridge(tx).db + ) + XCTAssertEqual(callRecord.callStatus, afterCallStatus) + XCTAssertEqual(callRecord.unreadStatus, afterUnreadStatus) + + let fetched = callRecordStore.fetch( + callId: callRecord.callId, + threadRowId: callRecord.threadRowId, + db: InMemoryDB.shimOnlyBridge(tx).db + ).unwrapped + XCTAssertTrue(callRecord.matches(fetched)) + } + } + } + + // MARK: - markAsRead + + func testMarkAsRead() { + let unreadCallRecord = makeCallRecord(callStatus: .group(.ringingMissed)) + XCTAssertEqual(unreadCallRecord.unreadStatus, .unread) + + inMemoryDB.write { tx in + let db = InMemoryDB.shimOnlyBridge(tx).db + callRecordStore.insert(callRecord: unreadCallRecord, db: db) + callRecordStore.markAsRead(callRecord: unreadCallRecord, db: db) + XCTAssertEqual(unreadCallRecord.unreadStatus, .read) + } + + let fetched = inMemoryDB.read { tx in + callRecordStore.fetch( + callId: unreadCallRecord.callId, + threadRowId: unreadCallRecord.threadRowId, + db: InMemoryDB.shimOnlyBridge(tx).db + ).unwrapped + } + + XCTAssert(fetched.matches(unreadCallRecord)) + } + // MARK: - updateWithMergedThread func testUpdateWithMergedThread() { @@ -307,6 +385,7 @@ final class CallRecordStoreTest: XCTestCase { let (interaction1, thread1) = insertThreadAndInteraction() let (interaction2, thread2) = insertThreadAndInteraction() let (interaction3, thread3) = insertThreadAndInteraction() + let (interaction4, thread4) = insertThreadAndInteraction() try inMemoryDB.write { tx in try InMemoryDB.shimOnlyBridge(tx).db.execute(sql: """ @@ -321,9 +400,10 @@ final class CallRecordStoreTest: XCTestCase { try inMemoryDB.write { tx in try InMemoryDB.shimOnlyBridge(tx).db.execute(sql: """ INSERT INTO "CallRecord" - ( "id", "callId", "interactionRowId", "threadRowId", "type", "direction", "status", "timestamp", "groupCallRingerAci" ) + ( "id", "callId", "interactionRowId", "threadRowId", "type", "direction", "status", "timestamp", "groupCallRingerAci", "unreadStatus" ) VALUES - ( 3, 12345, \(interaction3), \(thread3), 2, 0, 8, 1701300001, X'c2459e888a6a474b80fd51a79923fd50' ); + ( 3, 12345, \(interaction3), \(thread3), 2, 0, 8, 1701300001, X'c2459e888a6a474b80fd51a79923fd50', 0 ), + ( 4, 123456, \(interaction4), \(thread4), 2, 0, 8, 1701300002, X'227a8eefe8dd45f2a18c3276dc2da653', 1 ); """) } @@ -348,16 +428,35 @@ final class CallRecordStoreTest: XCTestCase { callStatus: .group(.ringingAccepted), callBeganTimestamp: 1701300000 ), + { + /// A call record's unread status is set during init, based on + /// its call status. This fixture, however, represents a missed + /// ring that was later marked as read – so we'll manually + /// overwrite the unread property. + let fixture: CallRecord = .fixture( + id: 3, + callId: 12345, + interactionRowId: interaction3, + threadRowId: thread3, + callType: .groupCall, + callDirection: .incoming, + callStatus: .group(.ringingMissed), + groupCallRingerAci: Aci.constantForTesting("C2459E88-8A6A-474B-80FD-51A79923FD50"), + callBeganTimestamp: 1701300001 + ) + fixture.unreadStatus = .read + return fixture + }(), .fixture( - id: 3, - callId: 12345, - interactionRowId: interaction3, - threadRowId: thread3, + id: 4, + callId: 123456, + interactionRowId: interaction4, + threadRowId: thread4, callType: .groupCall, callDirection: .incoming, callStatus: .group(.ringingMissed), - groupCallRingerAci: Aci.constantForTesting("C2459E88-8A6A-474B-80FD-51A79923FD50"), - callBeganTimestamp: 1701300001 + groupCallRingerAci: Aci.constantForTesting("227A8EEF-E8DD-45F2-A18C-3276DC2DA653"), + callBeganTimestamp: 1701300002 ), ]