Add missed-call badging to the Calls Tab

This commit is contained in:
Sasha Weiss 2024-02-27 14:55:15 -08:00 committed by GitHub
parent 38f0d7ee34
commit a301e40ecc
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
25 changed files with 772 additions and 213 deletions

View File

@ -28,6 +28,7 @@ disabled_rules:
- type_body_length
- unneeded_synthesized_initializer
- unused_closure_parameter
- unused_optional_binding
opt_in_rules:
- attributes
- comma_inheritance

View File

@ -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 */,

View File

@ -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 {

View File

@ -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)
}
}

View File

@ -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

View File

@ -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

View File

@ -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!

View File

@ -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)
}
}

View File

@ -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) }
)
}
}

View File

@ -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 {

View File

@ -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)")
}

View 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
)
}
}

View 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)
}
}

View File

@ -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
}

View File

@ -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
}
}
}
}

View File

@ -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,

View File

@ -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 {

View File

@ -252,7 +252,7 @@ public class GroupCallRecordManagerImpl: GroupCallRecordManager {
return
}
callRecordStore.updateRecordStatus(
callRecordStore.updateCallAndUnreadStatus(
callRecord: existingCallRecord,
newCallStatus: .group(newGroupCallStatus),
tx: tx

View File

@ -215,7 +215,7 @@ public class IndividualCallRecordManagerImpl: IndividualCallRecordManager {
return
}
callRecordStore.updateRecordStatus(
callRecordStore.updateCallAndUnreadStatus(
callRecord: existingCallRecord,
newCallStatus: .individual(newIndividualCallStatus),
tx: tx

View File

@ -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,

View File

@ -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

View File

@ -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"
)
;

View File

@ -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
}

View File

@ -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(

View File

@ -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
),
]