Add missed-call badging to the Calls Tab
This commit is contained in:
parent
38f0d7ee34
commit
a301e40ecc
@ -28,6 +28,7 @@ disabled_rules:
|
||||
- type_body_length
|
||||
- unneeded_synthesized_initializer
|
||||
- unused_closure_parameter
|
||||
- unused_optional_binding
|
||||
opt_in_rules:
|
||||
- attributes
|
||||
- comma_inheritance
|
||||
|
||||
@ -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 = "<group>"; };
|
||||
D979CC5A2AD61699006AAC49 /* GroupCallRecordManager.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GroupCallRecordManager.swift; sourceTree = "<group>"; };
|
||||
D979CC5C2AD616A4006AAC49 /* GroupCallRecordManagerTest.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GroupCallRecordManagerTest.swift; sourceTree = "<group>"; };
|
||||
D979DA152B8D1FDD000EEAB8 /* BadgeCountFetcher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BadgeCountFetcher.swift; sourceTree = "<group>"; };
|
||||
D98300B12936E6C70018FDC2 /* SubscriptionManager+DonationConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SubscriptionManager+DonationConfiguration.swift"; sourceTree = "<group>"; };
|
||||
D985D86329B91C400087C90C /* ChangePhoneNumberPniManagerTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChangePhoneNumberPniManagerTest.swift; sourceTree = "<group>"; };
|
||||
D985D86529B949D20087C90C /* ChangePhoneNumberPniManager+Shims.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ChangePhoneNumberPniManager+Shims.swift"; sourceTree = "<group>"; };
|
||||
@ -4391,6 +4395,8 @@
|
||||
D9C42C2E2B6C60600086B142 /* CallRecordDeleteManagerTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CallRecordDeleteManagerTest.swift; sourceTree = "<group>"; };
|
||||
D9C42C302B6C60FE0086B142 /* MockDeletedCallRecordCleanupManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockDeletedCallRecordCleanupManager.swift; sourceTree = "<group>"; };
|
||||
D9C42C322B6C66320086B142 /* MockCallRecordDeleteManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockCallRecordDeleteManager.swift; sourceTree = "<group>"; };
|
||||
D9C544282B8578B50036F274 /* CallRecord+CallStatus.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CallRecord+CallStatus.swift"; sourceTree = "<group>"; };
|
||||
D9C5442A2B8578F30036F274 /* CallRecordMissedCallManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CallRecordMissedCallManager.swift; sourceTree = "<group>"; };
|
||||
D9C5442C2B865B060036F274 /* CallsListViewController+Strings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CallsListViewController+Strings.swift"; sourceTree = "<group>"; };
|
||||
D9C7CEB328EB8495001E87B6 /* ExperienceUpgrade.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExperienceUpgrade.swift; sourceTree = "<group>"; };
|
||||
D9C7CECA28EBC09C001E87B6 /* ExperienceUpgradeManifest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExperienceUpgradeManifest.swift; sourceTree = "<group>"; };
|
||||
@ -6978,14 +6984,6 @@
|
||||
path = Badge;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
50EF8DD02A1E96EA00A00935 /* Badge */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
50EF8DCD2A1BEBAE00A00935 /* BadgeManagerTest.swift */,
|
||||
);
|
||||
path = Badge;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
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 = "<group>";
|
||||
};
|
||||
D979DA122B8D1B3E000EEAB8 /* Badge */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
50EF8DCD2A1BEBAE00A00935 /* BadgeManagerTest.swift */,
|
||||
);
|
||||
path = Badge;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
D979DA142B8D1FCF000EEAB8 /* Badge */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
D979DA152B8D1FDD000EEAB8 /* BadgeCountFetcher.swift */,
|
||||
);
|
||||
path = Badge;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
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 */,
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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<BadgeObserver>]()
|
||||
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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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!
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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) }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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)")
|
||||
}
|
||||
|
||||
31
SignalServiceKit/Badge/BadgeCountFetcher.swift
Normal file
31
SignalServiceKit/Badge/BadgeCountFetcher.swift
Normal file
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
175
SignalServiceKit/Calls/CallRecord/CallRecord+CallStatus.swift
Normal file
175
SignalServiceKit/Calls/CallRecord/CallRecord+CallStatus.swift
Normal file
@ -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)
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -252,7 +252,7 @@ public class GroupCallRecordManagerImpl: GroupCallRecordManager {
|
||||
return
|
||||
}
|
||||
|
||||
callRecordStore.updateRecordStatus(
|
||||
callRecordStore.updateCallAndUnreadStatus(
|
||||
callRecord: existingCallRecord,
|
||||
newCallStatus: .group(newGroupCallStatus),
|
||||
tx: tx
|
||||
|
||||
@ -215,7 +215,7 @@ public class IndividualCallRecordManagerImpl: IndividualCallRecordManager {
|
||||
return
|
||||
}
|
||||
|
||||
callRecordStore.updateRecordStatus(
|
||||
callRecordStore.updateCallAndUnreadStatus(
|
||||
callRecord: existingCallRecord,
|
||||
newCallStatus: .individual(newIndividualCallStatus),
|
||||
tx: tx
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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"
|
||||
)
|
||||
;
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user