Add an "inactive linked device" megaphone
This commit is contained in:
parent
70ac6f28d0
commit
c26ad8a4e5
@ -1841,6 +1841,10 @@
|
||||
D9B95A9829E8906200D7CB95 /* OWSDeviceTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = D9B95A9729E8906200D7CB95 /* OWSDeviceTest.swift */; };
|
||||
D9B95A9B29E8923B00D7CB95 /* InMemoryDB.swift in Sources */ = {isa = PBXBuildFile; fileRef = D9B95A9929E8918200D7CB95 /* InMemoryDB.swift */; };
|
||||
D9B95A9D29E894A600D7CB95 /* ValidatableModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = D9B95A9C29E894A600D7CB95 /* ValidatableModel.swift */; };
|
||||
D9C0AE652BD7103100FCB05E /* OWSDeviceStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = D9C0AE632BD7103100FCB05E /* OWSDeviceStore.swift */; };
|
||||
D9C0AE662BD7103100FCB05E /* InactiveLinkedDeviceFinder.swift in Sources */ = {isa = PBXBuildFile; fileRef = D9C0AE642BD7103100FCB05E /* InactiveLinkedDeviceFinder.swift */; };
|
||||
D9C0AE672BD7162300FCB05E /* InactiveLinkedDeviceFinderTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = D9C0AE612BD7102500FCB05E /* InactiveLinkedDeviceFinderTest.swift */; };
|
||||
D9C0AE692BD82DBC00FCB05E /* InactiveLinkedDeviceReminderMegaphone.swift in Sources */ = {isa = PBXBuildFile; fileRef = D9C0AE682BD82DBC00FCB05E /* InactiveLinkedDeviceReminderMegaphone.swift */; };
|
||||
D9C2D77E299D750200D79715 /* UsernameEducationManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = D9C2D77D299D750200D79715 /* UsernameEducationManager.swift */; };
|
||||
D9C2D780299EC11400D79715 /* CreateUsernameMegaphone.swift in Sources */ = {isa = PBXBuildFile; fileRef = D9C2D77F299EC11400D79715 /* CreateUsernameMegaphone.swift */; };
|
||||
D9C2D782299EEDDA00D79715 /* UsernameSelectionCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = D9C2D781299EEDDA00D79715 /* UsernameSelectionCoordinator.swift */; };
|
||||
@ -4803,6 +4807,10 @@
|
||||
D9B95A9729E8906200D7CB95 /* OWSDeviceTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OWSDeviceTest.swift; sourceTree = "<group>"; };
|
||||
D9B95A9929E8918200D7CB95 /* InMemoryDB.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InMemoryDB.swift; sourceTree = "<group>"; };
|
||||
D9B95A9C29E894A600D7CB95 /* ValidatableModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ValidatableModel.swift; sourceTree = "<group>"; };
|
||||
D9C0AE612BD7102500FCB05E /* InactiveLinkedDeviceFinderTest.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = InactiveLinkedDeviceFinderTest.swift; sourceTree = "<group>"; };
|
||||
D9C0AE632BD7103100FCB05E /* OWSDeviceStore.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = OWSDeviceStore.swift; sourceTree = "<group>"; };
|
||||
D9C0AE642BD7103100FCB05E /* InactiveLinkedDeviceFinder.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = InactiveLinkedDeviceFinder.swift; sourceTree = "<group>"; };
|
||||
D9C0AE682BD82DBC00FCB05E /* InactiveLinkedDeviceReminderMegaphone.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InactiveLinkedDeviceReminderMegaphone.swift; sourceTree = "<group>"; };
|
||||
D9C2D777299B07D300D79715 /* Usernames+BetterIdentifierChecker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Usernames+BetterIdentifierChecker.swift"; sourceTree = "<group>"; };
|
||||
D9C2D77D299D750200D79715 /* UsernameEducationManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UsernameEducationManager.swift; sourceTree = "<group>"; };
|
||||
D9C2D77F299EC11400D79715 /* CreateUsernameMegaphone.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreateUsernameMegaphone.swift; sourceTree = "<group>"; };
|
||||
@ -8720,6 +8728,7 @@
|
||||
children = (
|
||||
8806EF1A248DBFC100E764C7 /* ContactPermissionReminderMegaphone.swift */,
|
||||
D9C2D77F299EC11400D79715 /* CreateUsernameMegaphone.swift */,
|
||||
D9C0AE682BD82DBC00FCB05E /* InactiveLinkedDeviceReminderMegaphone.swift */,
|
||||
88A505F923DBA1360005C012 /* IntroducingPINs.swift */,
|
||||
8837F74023DA0B0F00772A32 /* MegaphoneView.swift */,
|
||||
8806EF18248DBD7200E764C7 /* NotificationPermissionReminderMegaphone.swift */,
|
||||
@ -9883,6 +9892,7 @@
|
||||
F94261C7289B1B5300460798 /* Devices */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
D9C0AE612BD7102500FCB05E /* InactiveLinkedDeviceFinderTest.swift */,
|
||||
D9708B5B29E4CCCB004306FA /* OWSDeviceManagerTest.swift */,
|
||||
F94261C8289B1B5300460798 /* OWSDeviceProvisionerTest.swift */,
|
||||
D9B95A9729E8906200D7CB95 /* OWSDeviceTest.swift */,
|
||||
@ -10836,11 +10846,13 @@
|
||||
children = (
|
||||
F9C5CA0E289453B100548EEE /* ConversationSync */,
|
||||
6619A1BB2B2A8132004B38FE /* SentMessageTranscriptReceiver */,
|
||||
D9C0AE642BD7103100FCB05E /* InactiveLinkedDeviceFinder.swift */,
|
||||
F9C5CA21289453B100548EEE /* OWSBlockedPhoneNumbersMessage.h */,
|
||||
F9C5CA29289453B100548EEE /* OWSBlockedPhoneNumbersMessage.m */,
|
||||
F9C5CA1A289453B100548EEE /* OWSDevice.swift */,
|
||||
D92AB7D729E3BEE30081CA7D /* OWSDeviceManager.swift */,
|
||||
F9C5CA0B289453B100548EEE /* OWSDeviceProvisioner.swift */,
|
||||
D9C0AE632BD7103100FCB05E /* OWSDeviceStore.swift */,
|
||||
F9C5CA1F289453B100548EEE /* OWSLinkedDeviceReadReceipt.h */,
|
||||
F9C5CA2A289453B100548EEE /* OWSLinkedDeviceReadReceipt.m */,
|
||||
F9C5CA0D289453B100548EEE /* OWSProvisioningCipher.swift */,
|
||||
@ -13165,6 +13177,7 @@
|
||||
B9A0807A2B07D76A000FDB5B /* HomeTabViewController.swift in Sources */,
|
||||
D9E7C8792B9B9072005BD3B9 /* IdentifierIndexedArray.swift in Sources */,
|
||||
3496955C219B605E00DCFE74 /* ImagePickerController.swift in Sources */,
|
||||
D9C0AE692BD82DBC00FCB05E /* InactiveLinkedDeviceReminderMegaphone.swift in Sources */,
|
||||
1700E33F28B856FC0073D949 /* IncomingCallControls.swift in Sources */,
|
||||
E1DDFC202BAB5A97007C67FB /* IncomingReactionsView.swift in Sources */,
|
||||
4CD4E7D523E8CCFE00834B1B /* IndividualCall.swift in Sources */,
|
||||
@ -13893,6 +13906,7 @@
|
||||
F9C5CDB4289453B400548EEE /* HTTPUtils.swift in Sources */,
|
||||
66FC637C29DF8FF200F00DAC /* HydratedMessageBody.swift in Sources */,
|
||||
F9C5CDDF289453B400548EEE /* ImageQuality.swift in Sources */,
|
||||
D9C0AE662BD7103100FCB05E /* InactiveLinkedDeviceFinder.swift in Sources */,
|
||||
505166D72BB37DAE00FF6B4A /* IncomingCallEventSyncMessageManager.swift in Sources */,
|
||||
505166D62BB37DA700FF6B4A /* IncomingCallEventSyncMessageParams.swift in Sources */,
|
||||
D958C6792B9FBD66002F6888 /* IncomingCallLogEventSyncMessageManager.swift in Sources */,
|
||||
@ -14122,6 +14136,7 @@
|
||||
D92AB7D829E3BEE30081CA7D /* OWSDeviceManager.swift in Sources */,
|
||||
F9C5CCEF289453B300548EEE /* OWSDeviceProvisioner.swift in Sources */,
|
||||
F9C5CDBA289453B400548EEE /* OWSDevicesService.swift in Sources */,
|
||||
D9C0AE652BD7103100FCB05E /* OWSDeviceStore.swift in Sources */,
|
||||
F9C5CBFC289453B300548EEE /* OWSDisappearingConfigurationUpdateInfoMessage+SDS.swift in Sources */,
|
||||
F9C5CC02289453B300548EEE /* OWSDisappearingConfigurationUpdateInfoMessage.m in Sources */,
|
||||
F9C5CCC4289453B300548EEE /* OWSDisappearingMessagesConfiguration+SDS.swift in Sources */,
|
||||
@ -14753,6 +14768,7 @@
|
||||
5075C21729CA1EE700A260D2 /* GroupMemberUpdaterTest.swift in Sources */,
|
||||
F9426251289B1B5500460798 /* GroupModelsTest.swift in Sources */,
|
||||
F9426245289B1B5500460798 /* HTMLMetadataTests.swift in Sources */,
|
||||
D9C0AE672BD7162300FCB05E /* InactiveLinkedDeviceFinderTest.swift in Sources */,
|
||||
D958C67D2BA0F3B2002F6888 /* IncomingCallLogEventSyncMessageManagerTest.swift in Sources */,
|
||||
D979CC4C2AD4DECB006AAC49 /* IndividualCallRecordManagerTest.swift in Sources */,
|
||||
F942624D289B1B5500460798 /* InteractionFinderTest.swift in Sources */,
|
||||
|
||||
@ -75,8 +75,12 @@ public class AppEnvironment: NSObject {
|
||||
}
|
||||
|
||||
let db = DependenciesBridge.shared.db
|
||||
let deletedCallRecordCleanupManager = DependenciesBridge.shared.deletedCallRecordCleanupManager
|
||||
let groupCallRecordRingingCleanupManager = GroupCallRecordRingingCleanupManager.fromGlobals()
|
||||
let inactiveLinkedDeviceFinder = DependenciesBridge.shared.inactiveLinkedDeviceFinder
|
||||
let learnMyOwnPniManager = DependenciesBridge.shared.learnMyOwnPniManager
|
||||
let linkedDevicePniKeyManager = DependenciesBridge.shared.linkedDevicePniKeyManager
|
||||
let masterKeySyncManager = DependenciesBridge.shared.masterKeySyncManager
|
||||
let pniHelloWorldManager = DependenciesBridge.shared.pniHelloWorldManager
|
||||
let schedulers = DependenciesBridge.shared.schedulers
|
||||
|
||||
@ -97,16 +101,18 @@ public class AppEnvironment: NSObject {
|
||||
}
|
||||
|
||||
db.asyncWrite { tx in
|
||||
DependenciesBridge.shared.masterKeySyncManager.runStartupJobs(tx: tx)
|
||||
masterKeySyncManager.runStartupJobs(tx: tx)
|
||||
}
|
||||
|
||||
db.asyncWrite { tx in
|
||||
GroupCallRecordRingingCleanupManager.fromGlobals()
|
||||
.cleanupRingingCalls(tx: tx)
|
||||
groupCallRecordRingingCleanupManager.cleanupRingingCalls(tx: tx)
|
||||
}
|
||||
|
||||
DependenciesBridge.shared.deletedCallRecordCleanupManager
|
||||
.startCleanupIfNecessary()
|
||||
Task {
|
||||
await inactiveLinkedDeviceFinder.refreshLinkedDeviceStateIfNecessary()
|
||||
}
|
||||
|
||||
deletedCallRecordCleanupManager.startCleanupIfNecessary()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
12
Signal/Images.xcassets/inactive-linked-device-reminder-megaphone.imageset/Contents.json
vendored
Normal file
12
Signal/Images.xcassets/inactive-linked-device-reminder-megaphone.imageset/Contents.json
vendored
Normal file
@ -0,0 +1,12 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "inactive_linked_device.pdf",
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
BIN
Signal/Images.xcassets/inactive-linked-device-reminder-megaphone.imageset/inactive_linked_device.pdf
vendored
Normal file
BIN
Signal/Images.xcassets/inactive-linked-device-reminder-megaphone.imageset/inactive_linked_device.pdf
vendored
Normal file
Binary file not shown.
@ -131,6 +131,7 @@ class ExperienceUpgradeManager: Dependencies {
|
||||
.pinReminder,
|
||||
.notificationPermissionReminder,
|
||||
.createUsernameReminder,
|
||||
.inactiveLinkedDeviceReminder,
|
||||
.contactPermissionReminder:
|
||||
return true
|
||||
case .remoteMegaphone:
|
||||
@ -177,6 +178,22 @@ class ExperienceUpgradeManager: Dependencies {
|
||||
experienceUpgrade: experienceUpgrade,
|
||||
fromViewController: fromViewController
|
||||
)
|
||||
case .inactiveLinkedDeviceReminder:
|
||||
let inactiveLinkedDevice: InactiveLinkedDevice? = databaseStorage.read { tx in
|
||||
return DependenciesBridge.shared.inactiveLinkedDeviceFinder
|
||||
.findLeastActiveLinkedDevice(tx: tx.asV2Read)
|
||||
}
|
||||
|
||||
guard let inactiveLinkedDevice else {
|
||||
owsFailDebug("Trying to show inactive linked device megaphone, but have no device!")
|
||||
return nil
|
||||
}
|
||||
|
||||
return InactiveLinkedDeviceReminderMegaphone(
|
||||
inactiveLinkedDevice: inactiveLinkedDevice,
|
||||
fromViewController: fromViewController,
|
||||
experienceUpgrade: experienceUpgrade
|
||||
)
|
||||
case .contactPermissionReminder:
|
||||
return ContactPermissionReminderMegaphone(experienceUpgrade: experienceUpgrade, fromViewController: fromViewController)
|
||||
case .remoteMegaphone(let megaphone):
|
||||
|
||||
@ -0,0 +1,83 @@
|
||||
//
|
||||
// Copyright 2024 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
//
|
||||
|
||||
import SignalServiceKit
|
||||
|
||||
final class InactiveLinkedDeviceReminderMegaphone: MegaphoneView {
|
||||
private var inactiveLinkedDeviceFinder: InactiveLinkedDeviceFinder {
|
||||
DependenciesBridge.shared.inactiveLinkedDeviceFinder
|
||||
}
|
||||
|
||||
private let inactiveLinkedDevice: InactiveLinkedDevice
|
||||
|
||||
/// The number of days until the linked device represented by this megaphone
|
||||
/// will expire. Clamps to a floor of one day.
|
||||
private var daysUntilExpiration: Int {
|
||||
let daysUntilExpiration: Int = DateUtil.daysFrom(
|
||||
firstDate: Date(),
|
||||
toSecondDate: inactiveLinkedDevice.expirationDate
|
||||
)
|
||||
|
||||
// If there's less than 1 day till expiration, round up to one day.
|
||||
return max(daysUntilExpiration, 1)
|
||||
}
|
||||
|
||||
init(
|
||||
inactiveLinkedDevice: InactiveLinkedDevice,
|
||||
fromViewController: UIViewController,
|
||||
experienceUpgrade: ExperienceUpgrade
|
||||
) {
|
||||
self.inactiveLinkedDevice = inactiveLinkedDevice
|
||||
|
||||
super.init(experienceUpgrade: experienceUpgrade)
|
||||
|
||||
titleText = OWSLocalizedString(
|
||||
"INACTIVE_LINKED_DEVICE_REMINDER_MEGAPHONE_TITLE",
|
||||
comment: "Title for an in-app megaphone about a user's inactive linked device."
|
||||
)
|
||||
|
||||
let bodyTextFormat = OWSLocalizedString(
|
||||
"INACTIVE_LINKED_DEVICE_REMINDER_MEGAPHONE_BODY_%d",
|
||||
tableName: "PluralAware",
|
||||
comment: "Title for an in-app megaphone about a user's inactive linked device. Embeds {{ %d: the number of days until that device's expiration; %2$@: the name of the device }}."
|
||||
)
|
||||
bodyText = String.localizedStringWithFormat(
|
||||
bodyTextFormat,
|
||||
daysUntilExpiration,
|
||||
inactiveLinkedDevice.displayName
|
||||
)
|
||||
|
||||
imageName = "inactive-linked-device-reminder-megaphone"
|
||||
imageContentMode = .center
|
||||
|
||||
let dontRemindMeButton = Button(title: OWSLocalizedString(
|
||||
"INACTIVE_LINKED_DEVICE_REMINDER_MEGAPHONE_DONT_REMIND_ME_BUTTON",
|
||||
comment: "Title for a button in an in-app megaphone about a user's inactive linked device, indicating the user doesn't want to be reminded."
|
||||
)) {
|
||||
DependenciesBridge.shared.db.asyncWrite(
|
||||
block: { tx in
|
||||
self.inactiveLinkedDeviceFinder.permanentlyDisableFinders(tx: tx)
|
||||
},
|
||||
completionQueue: .main,
|
||||
completion: { [weak self] in
|
||||
self?.dismiss()
|
||||
}
|
||||
)
|
||||
}
|
||||
let gotItButton = snoozeButton(
|
||||
fromViewController: fromViewController,
|
||||
snoozeTitle: OWSLocalizedString(
|
||||
"INACTIVE_LINKED_DEVICE_REMINDER_MEGAPHONE_GOT IT_BUTTON",
|
||||
comment: "Title for a button in an in-app megaphone about a user's inactive linked device, temporarily dismissing the megaphone."
|
||||
)
|
||||
)
|
||||
setButtons(primary: gotItButton, secondary: dontRemindMeButton)
|
||||
}
|
||||
|
||||
@available(*, unavailable, message: "Use other constructor!")
|
||||
required init(coder: NSCoder) {
|
||||
owsFail("Use other constructor!")
|
||||
}
|
||||
}
|
||||
@ -74,7 +74,7 @@ class LinkedDevicesTableViewController: OWSTableViewController2 {
|
||||
device: device,
|
||||
displayName: device.displayName(
|
||||
identityManager: identityManager,
|
||||
transaction: transaction
|
||||
tx: transaction.asV2Read
|
||||
)
|
||||
)
|
||||
}
|
||||
@ -113,7 +113,7 @@ class LinkedDevicesTableViewController: OWSTableViewController2 {
|
||||
private func refreshDevices() {
|
||||
AssertIsOnMainThread()
|
||||
|
||||
OWSDevicesService.refreshDevices()
|
||||
_ = OWSDevicesService.refreshDevices()
|
||||
}
|
||||
|
||||
@objc
|
||||
|
||||
@ -32,6 +32,13 @@ class DebugUIMisc: NSObject, DebugUIPage, Dependencies {
|
||||
}
|
||||
}),
|
||||
|
||||
OWSTableItem(title: "Reenable disabled inactive linked device reminder megaphones", actionBlock: {
|
||||
SDSDatabaseStorage.shared.write { tx in
|
||||
DependenciesBridge.shared.inactiveLinkedDeviceFinder
|
||||
.reenablePermanentlyDisabledFinders(tx: tx.asV2Write)
|
||||
}
|
||||
}),
|
||||
|
||||
OWSTableItem(title: "Re-register", actionBlock: {
|
||||
OWSActionSheets.showConfirmationAlert(
|
||||
title: "Re-register?",
|
||||
|
||||
@ -88,7 +88,7 @@ public class RegistrationCoordinatorTest: XCTestCase {
|
||||
registrationStateChangeManagerMock = MockRegistrationStateChangeManager()
|
||||
sessionManager = RegistrationSessionManagerMock()
|
||||
storageServiceManagerMock = FakeStorageServiceManager()
|
||||
tsAccountManagerMock = MockTSAccountManager(dateProvider: dateProvider)
|
||||
tsAccountManagerMock = MockTSAccountManager()
|
||||
usernameApiClientMock = MockUsernameApiClient()
|
||||
usernameLinkManagerMock = MockUsernameLinkManager()
|
||||
|
||||
|
||||
@ -3595,6 +3595,15 @@
|
||||
/* Call setup status label */
|
||||
"IN_CALL_TERMINATED" = "Call Ended.";
|
||||
|
||||
/* Title for a button in an in-app megaphone about a user's inactive linked device, indicating the user doesn't want to be reminded. */
|
||||
"INACTIVE_LINKED_DEVICE_REMINDER_MEGAPHONE_DONT_REMIND_ME_BUTTON" = "Don't Remind Me";
|
||||
|
||||
/* Title for a button in an in-app megaphone about a user's inactive linked device, temporarily dismissing the megaphone. */
|
||||
"INACTIVE_LINKED_DEVICE_REMINDER_MEGAPHONE_GOT IT_BUTTON" = "Got It";
|
||||
|
||||
/* Title for an in-app megaphone about a user's inactive linked device. */
|
||||
"INACTIVE_LINKED_DEVICE_REMINDER_MEGAPHONE_TITLE" = "Inactive Linked Device";
|
||||
|
||||
/* Label reminding the user that they are in archive mode, and that muted chats remain archived when they receive a new message. */
|
||||
"INBOX_VIEW_ARCHIVE_MODE_MUTED_CHATS_REMINDER" = "Muted chats that are archived will remain archived when a new message arrives.";
|
||||
|
||||
|
||||
@ -733,6 +733,22 @@
|
||||
<string>You can’t share more than %d items.</string>
|
||||
</dict>
|
||||
</dict>
|
||||
<key>INACTIVE_LINKED_DEVICE_REMINDER_MEGAPHONE_BODY_%d</key>
|
||||
<dict>
|
||||
<key>NSStringLocalizedFormatKey</key>
|
||||
<string>%#@text@</string>
|
||||
<key>text</key>
|
||||
<dict>
|
||||
<key>NSStringFormatSpecTypeKey</key>
|
||||
<string>NSStringPluralRuleType</string>
|
||||
<key>NSStringFormatValueTypeKey</key>
|
||||
<string>d</string>
|
||||
<key>one</key>
|
||||
<string>To keep "%2$@" linked, open Signal on this device within one day.</string>
|
||||
<key>other</key>
|
||||
<string>To keep "%2$@" linked, open Signal on this device within %d days.</string>
|
||||
</dict>
|
||||
</dict>
|
||||
<key>MANY_GROUPS_IN_COMMON_%d</key>
|
||||
<dict>
|
||||
<key>NSStringLocalizedFormatKey</key>
|
||||
|
||||
@ -10,11 +10,7 @@ import LibSignalClient
|
||||
|
||||
public class MockTSAccountManager: TSAccountManager {
|
||||
|
||||
public var dateProvider: DateProvider
|
||||
|
||||
public init(dateProvider: @escaping DateProvider = { Date() }) {
|
||||
self.dateProvider = dateProvider
|
||||
}
|
||||
public init() {}
|
||||
|
||||
public func tmp_loadAccountState(tx: DBReadTransaction) {}
|
||||
|
||||
|
||||
@ -59,6 +59,7 @@ public class DependenciesBridge {
|
||||
public let deletedCallRecordCleanupManager: DeletedCallRecordCleanupManager
|
||||
let deletedCallRecordStore: DeletedCallRecordStore
|
||||
public let deviceManager: OWSDeviceManager
|
||||
public let deviceStore: OWSDeviceStore
|
||||
public let disappearingMessagesConfigurationStore: DisappearingMessagesConfigurationStore
|
||||
public let editManager: EditManager
|
||||
public let editMessageStore: EditMessageStore
|
||||
@ -68,6 +69,7 @@ public class DependenciesBridge {
|
||||
public let groupMemberUpdater: GroupMemberUpdater
|
||||
public let groupUpdateInfoMessageInserter: GroupUpdateInfoMessageInserter
|
||||
public let identityManager: OWSIdentityManager
|
||||
public let inactiveLinkedDeviceFinder: InactiveLinkedDeviceFinder
|
||||
let incomingCallEventSyncMessageManager: IncomingCallEventSyncMessageManager
|
||||
let incomingCallLogEventSyncMessageManager: IncomingCallLogEventSyncMessageManager
|
||||
public let incomingPniChangeNumberProcessor: IncomingPniChangeNumberProcessor
|
||||
@ -147,6 +149,7 @@ public class DependenciesBridge {
|
||||
deletedCallRecordCleanupManager: DeletedCallRecordCleanupManager,
|
||||
deletedCallRecordStore: DeletedCallRecordStore,
|
||||
deviceManager: OWSDeviceManager,
|
||||
deviceStore: OWSDeviceStore,
|
||||
disappearingMessagesConfigurationStore: DisappearingMessagesConfigurationStore,
|
||||
editManager: EditManager,
|
||||
editMessageStore: EditMessageStore,
|
||||
@ -156,6 +159,7 @@ public class DependenciesBridge {
|
||||
groupMemberUpdater: GroupMemberUpdater,
|
||||
groupUpdateInfoMessageInserter: GroupUpdateInfoMessageInserter,
|
||||
identityManager: OWSIdentityManager,
|
||||
inactiveLinkedDeviceFinder: InactiveLinkedDeviceFinder,
|
||||
incomingCallEventSyncMessageManager: IncomingCallEventSyncMessageManager,
|
||||
incomingCallLogEventSyncMessageManager: IncomingCallLogEventSyncMessageManager,
|
||||
incomingPniChangeNumberProcessor: IncomingPniChangeNumberProcessor,
|
||||
@ -232,6 +236,7 @@ public class DependenciesBridge {
|
||||
self.deletedCallRecordCleanupManager = deletedCallRecordCleanupManager
|
||||
self.deletedCallRecordStore = deletedCallRecordStore
|
||||
self.deviceManager = deviceManager
|
||||
self.deviceStore = deviceStore
|
||||
self.disappearingMessagesConfigurationStore = disappearingMessagesConfigurationStore
|
||||
self.editManager = editManager
|
||||
self.editMessageStore = editMessageStore
|
||||
@ -241,6 +246,7 @@ public class DependenciesBridge {
|
||||
self.groupMemberUpdater = groupMemberUpdater
|
||||
self.groupUpdateInfoMessageInserter = groupUpdateInfoMessageInserter
|
||||
self.identityManager = identityManager
|
||||
self.inactiveLinkedDeviceFinder = inactiveLinkedDeviceFinder
|
||||
self.incomingCallEventSyncMessageManager = incomingCallEventSyncMessageManager
|
||||
self.incomingCallLogEventSyncMessageManager = incomingCallLogEventSyncMessageManager
|
||||
self.incomingPniChangeNumberProcessor = incomingPniChangeNumberProcessor
|
||||
|
||||
268
SignalServiceKit/Devices/InactiveLinkedDeviceFinder.swift
Normal file
268
SignalServiceKit/Devices/InactiveLinkedDeviceFinder.swift
Normal file
@ -0,0 +1,268 @@
|
||||
//
|
||||
// Copyright 2024 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
//
|
||||
|
||||
import SignalCoreKit
|
||||
|
||||
/// Represents an "inactive" linked device.
|
||||
/// - SeeAlso ``InactiveLinkedDeviceFinder``
|
||||
public struct InactiveLinkedDevice: Equatable {
|
||||
public let displayName: String
|
||||
public let expirationDate: Date
|
||||
|
||||
init(displayName: String, expirationDate: Date) {
|
||||
self.displayName = displayName
|
||||
self.expirationDate = expirationDate
|
||||
}
|
||||
}
|
||||
|
||||
/// Responsible for finding "inactive" linked devices, or those who have not
|
||||
/// come online in a long time and are at risk of expiring (and being unlinked)
|
||||
/// soon.
|
||||
public protocol InactiveLinkedDeviceFinder {
|
||||
/// At most once per day, re-fetch our linked device state.
|
||||
///
|
||||
/// - Note
|
||||
/// This method does nothing if invoked from a linked device.
|
||||
func refreshLinkedDeviceStateIfNecessary() async
|
||||
|
||||
/// Find the user's "least active" linked device, i.e. their linked device
|
||||
/// that was last seen longest ago.
|
||||
///
|
||||
/// - Note
|
||||
/// A linked device's expiration time (when it is unlinked) is a function of
|
||||
/// its "last seen" time. Consequently, the least-active linked device
|
||||
/// returned by this method will also be the next-expiring device.
|
||||
///
|
||||
/// - Note
|
||||
/// This method returns `nil` if the current device is a linked device.
|
||||
func findLeastActiveLinkedDevice(tx: DBReadTransaction) -> InactiveLinkedDevice?
|
||||
|
||||
/// Permanently disables this and any future inactive linked device finders.
|
||||
///
|
||||
/// - Important
|
||||
/// This is irreversible for the life of this app install. Use with care.
|
||||
func permanentlyDisableFinders(tx: DBWriteTransaction)
|
||||
|
||||
#if TESTABLE_BUILD
|
||||
func reenablePermanentlyDisabledFinders(tx: DBWriteTransaction)
|
||||
#endif
|
||||
}
|
||||
|
||||
public extension InactiveLinkedDeviceFinder {
|
||||
/// Whether the user has an "inactive" linked device.
|
||||
func hasInactiveLinkedDevice(tx: DBReadTransaction) -> Bool {
|
||||
return findLeastActiveLinkedDevice(tx: tx) != nil
|
||||
}
|
||||
}
|
||||
|
||||
class InactiveLinkedDeviceFinderImpl: InactiveLinkedDeviceFinder {
|
||||
private enum Constants {
|
||||
/// How long we should wait between device state refreshes.
|
||||
static let intervalForDeviceRefresh: TimeInterval = kDayInterval
|
||||
|
||||
/// How long before a device expires it is considered "inactive".
|
||||
static let intervalBeforeExpirationConsideredInactive = kWeekInterval
|
||||
}
|
||||
|
||||
private enum StoreKeys {
|
||||
static let lastRefreshedDate: String = "lastRefreshedDate"
|
||||
static let isPermanentlyDisabled: String = "isPermanentlyDisabled"
|
||||
}
|
||||
|
||||
private let dateProvider: DateProvider
|
||||
private let db: DB
|
||||
private let deviceNameDecrypter: Shims.OWSDeviceNameDecrypter
|
||||
private let deviceStore: OWSDeviceStore
|
||||
private let devicesService: Shims.OWSDevicesService
|
||||
private let kvStore: KeyValueStore
|
||||
private let remoteConfig: Shims.RemoteConfig
|
||||
private let tsAccountManager: TSAccountManager
|
||||
|
||||
private var intervalForDeviceExpiration: TimeInterval {
|
||||
return remoteConfig.linkedDeviceLifespan()
|
||||
}
|
||||
|
||||
private var intervalForDeviceInactivity: TimeInterval {
|
||||
return max(0, remoteConfig.linkedDeviceLifespan() - Constants.intervalBeforeExpirationConsideredInactive)
|
||||
}
|
||||
|
||||
private let logger = PrefixedLogger(prefix: "InactiveLinkedDeviceFinder")
|
||||
|
||||
init(
|
||||
dateProvider: @escaping DateProvider,
|
||||
db: DB,
|
||||
deviceNameDecrypter: Shims.OWSDeviceNameDecrypter,
|
||||
deviceStore: OWSDeviceStore,
|
||||
devicesService: Shims.OWSDevicesService,
|
||||
kvStoreFactory: KeyValueStoreFactory,
|
||||
remoteConfig: Shims.RemoteConfig,
|
||||
tsAccountManager: TSAccountManager
|
||||
) {
|
||||
self.dateProvider = dateProvider
|
||||
self.db = db
|
||||
self.deviceNameDecrypter = deviceNameDecrypter
|
||||
self.deviceStore = deviceStore
|
||||
self.devicesService = devicesService
|
||||
self.kvStore = kvStoreFactory.keyValueStore(collection: "InactiveLinkedDeviceFinderImpl")
|
||||
self.remoteConfig = remoteConfig
|
||||
self.tsAccountManager = tsAccountManager
|
||||
}
|
||||
|
||||
func refreshLinkedDeviceStateIfNecessary() async {
|
||||
struct SkipRefreshError: Error {}
|
||||
|
||||
do {
|
||||
try await Task {
|
||||
try db.read { tx in
|
||||
if kvStore.hasValue(StoreKeys.isPermanentlyDisabled, transaction: tx) {
|
||||
// Finder is permanently disabled, no need to refresh.
|
||||
throw SkipRefreshError()
|
||||
}
|
||||
|
||||
if !tsAccountManager.registrationState(tx: tx).isRegisteredPrimaryDevice {
|
||||
// Not a registered primary device? No need to refresh.
|
||||
throw SkipRefreshError()
|
||||
}
|
||||
|
||||
if
|
||||
let lastRefreshedDate = kvStore.getDate(StoreKeys.lastRefreshedDate, transaction: tx),
|
||||
lastRefreshedDate.addingTimeInterval(Constants.intervalForDeviceRefresh) > dateProvider()
|
||||
{
|
||||
// Checked less than a day ago, skip.
|
||||
throw SkipRefreshError()
|
||||
}
|
||||
}
|
||||
}.value
|
||||
} catch is SkipRefreshError {
|
||||
return
|
||||
} catch {
|
||||
owsFail("Impossible!")
|
||||
}
|
||||
|
||||
do {
|
||||
try await devicesService.refreshDevices()
|
||||
|
||||
await db.awaitableWrite { tx in
|
||||
self.kvStore.setDate(
|
||||
self.dateProvider(),
|
||||
key: StoreKeys.lastRefreshedDate,
|
||||
transaction: tx
|
||||
)
|
||||
}
|
||||
} catch {
|
||||
logger.warn("Failed to refresh devices!")
|
||||
}
|
||||
}
|
||||
|
||||
func findLeastActiveLinkedDevice(tx: DBReadTransaction) -> InactiveLinkedDevice? {
|
||||
if kvStore.hasValue(StoreKeys.isPermanentlyDisabled, transaction: tx) {
|
||||
// Short-circuit if we've been disabled.
|
||||
return nil
|
||||
}
|
||||
|
||||
if !tsAccountManager.registrationState(tx: tx).isRegisteredPrimaryDevice {
|
||||
// Only report linked devices if we are a primary.
|
||||
return nil
|
||||
}
|
||||
|
||||
let allInactiveLinkedDevices = deviceStore.fetchAll(tx: tx)
|
||||
.filter { !$0.isPrimaryDevice }
|
||||
.filter { device in
|
||||
// Only keep devices whose inactivity date has passed.
|
||||
let inactivityDate = device.lastSeenAt.addingTimeInterval(intervalForDeviceInactivity)
|
||||
return inactivityDate < dateProvider()
|
||||
}
|
||||
|
||||
return allInactiveLinkedDevices
|
||||
.min { lhs, rhs in
|
||||
return lhs.lastSeenAt < rhs.lastSeenAt
|
||||
}
|
||||
.map { device -> InactiveLinkedDevice in
|
||||
return InactiveLinkedDevice(
|
||||
displayName: deviceNameDecrypter.decryptName(
|
||||
device: device, tx: tx
|
||||
),
|
||||
expirationDate: device.lastSeenAt.addingTimeInterval(
|
||||
intervalForDeviceExpiration
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func permanentlyDisableFinders(tx: DBWriteTransaction) {
|
||||
kvStore.setBool(true, key: StoreKeys.isPermanentlyDisabled, transaction: tx)
|
||||
}
|
||||
|
||||
#if TESTABLE_BUILD
|
||||
func reenablePermanentlyDisabledFinders(tx: DBWriteTransaction) {
|
||||
kvStore.removeValue(forKey: StoreKeys.isPermanentlyDisabled, transaction: tx)
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
// MARK: - Shims
|
||||
|
||||
extension InactiveLinkedDeviceFinderImpl {
|
||||
enum Shims {
|
||||
typealias OWSDeviceNameDecrypter = InactiveLinkedDeviceFinderImpl_OWSDeviceNameDecrypter_Shim
|
||||
typealias OWSDevicesService = InactiveLinkedDeviceFinderImpl_OWSDevicesService_Shim
|
||||
typealias RemoteConfig = InactiveLinkedDeviceFinderImpl_RemoteConfig_Shim
|
||||
}
|
||||
|
||||
enum Wrappers {
|
||||
typealias OWSDeviceNameDecrypter = InactiveLinkedDeviceFinderImpl_OWSDeviceNameDecrypter_Wrapper
|
||||
typealias OWSDevicesService = InactiveLinkedDeviceFinderImpl_OWSDevicesService_Wrapper
|
||||
typealias RemoteConfig = InactiveLinkedDeviceFinderImpl_RemoteConfig_Wrapper
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: OWSDeviceNameDecrypter
|
||||
|
||||
protocol InactiveLinkedDeviceFinderImpl_OWSDeviceNameDecrypter_Shim {
|
||||
func decryptName(device: OWSDevice, tx: DBReadTransaction) -> String
|
||||
}
|
||||
|
||||
class InactiveLinkedDeviceFinderImpl_OWSDeviceNameDecrypter_Wrapper: InactiveLinkedDeviceFinderImpl_OWSDeviceNameDecrypter_Shim {
|
||||
private let identityManager: OWSIdentityManager
|
||||
|
||||
init(identityManager: OWSIdentityManager) {
|
||||
self.identityManager = identityManager
|
||||
}
|
||||
|
||||
func decryptName(device: OWSDevice, tx: DBReadTransaction) -> String {
|
||||
return device.displayName(
|
||||
identityManager: identityManager, tx: tx
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: OWSDevicesService
|
||||
|
||||
protocol InactiveLinkedDeviceFinderImpl_OWSDevicesService_Shim {
|
||||
func refreshDevices() async throws
|
||||
}
|
||||
|
||||
class InactiveLinkedDeviceFinderImpl_OWSDevicesService_Wrapper: InactiveLinkedDeviceFinderImpl_OWSDevicesService_Shim {
|
||||
init() {}
|
||||
|
||||
func refreshDevices() async throws {
|
||||
try await OWSDevicesService.refreshDevices().awaitable()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: RemoteConfig
|
||||
|
||||
/// We need to shim around ``RemoteConfig`` because accessing it transitively
|
||||
/// accesses the global ``RemoteConfigManager`` in ``Dependencies``, which is
|
||||
/// not set up when ``InactiveLinkedDeviceFinderImpl`` is initialized.
|
||||
protocol InactiveLinkedDeviceFinderImpl_RemoteConfig_Shim {
|
||||
func linkedDeviceLifespan() -> TimeInterval
|
||||
}
|
||||
|
||||
class InactiveLinkedDeviceFinderImpl_RemoteConfig_Wrapper: InactiveLinkedDeviceFinderImpl_RemoteConfig_Shim {
|
||||
func linkedDeviceLifespan() -> TimeInterval {
|
||||
return RemoteConfig.linkedDeviceLifespan
|
||||
}
|
||||
}
|
||||
@ -85,10 +85,10 @@ public final class OWSDevice: SDSCodableModel, Decodable {
|
||||
public extension OWSDevice {
|
||||
func displayName(
|
||||
identityManager: OWSIdentityManager,
|
||||
transaction: SDSAnyReadTransaction
|
||||
tx: DBReadTransaction
|
||||
) -> String {
|
||||
if let encryptedName = self.encryptedName {
|
||||
if let identityKeyPair = identityManager.identityKeyPair(for: .aci, tx: transaction.asV2Read) {
|
||||
if let identityKeyPair = identityManager.identityKeyPair(for: .aci, tx: tx) {
|
||||
do {
|
||||
return try DeviceNames.decryptDeviceName(
|
||||
base64String: encryptedName,
|
||||
|
||||
14
SignalServiceKit/Devices/OWSDeviceStore.swift
Normal file
14
SignalServiceKit/Devices/OWSDeviceStore.swift
Normal file
@ -0,0 +1,14 @@
|
||||
//
|
||||
// Copyright 2024 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
//
|
||||
|
||||
public protocol OWSDeviceStore {
|
||||
func fetchAll(tx: DBReadTransaction) -> [OWSDevice]
|
||||
}
|
||||
|
||||
class OWSDeviceStoreImpl: OWSDeviceStore {
|
||||
func fetchAll(tx: DBReadTransaction) -> [OWSDevice] {
|
||||
return OWSDevice.anyFetchAll(transaction: SDSDB.shimOnlyBridge(tx))
|
||||
}
|
||||
}
|
||||
@ -261,11 +261,6 @@ public class AppSetup {
|
||||
tsAccountManager: tsAccountManager
|
||||
)
|
||||
|
||||
let deviceManager = OWSDeviceManagerImpl(
|
||||
databaseStorage: db,
|
||||
keyValueStoreFactory: keyValueStoreFactory
|
||||
)
|
||||
|
||||
let linkPreviewManager = LinkPreviewManagerImpl(
|
||||
attachmentManager: tsResourceManager,
|
||||
attachmentStore: tsResourceStore,
|
||||
@ -765,6 +760,22 @@ public class AppSetup {
|
||||
let attachmentCloner = SignalAttachmentClonerImpl()
|
||||
let tsResourceCloner = SignalTSResourceClonerImpl(attachmentCloner: attachmentCloner)
|
||||
|
||||
let deviceManager = OWSDeviceManagerImpl(
|
||||
databaseStorage: db,
|
||||
keyValueStoreFactory: keyValueStoreFactory
|
||||
)
|
||||
let deviceStore = OWSDeviceStoreImpl()
|
||||
let inactiveLinkedDeviceFinder = InactiveLinkedDeviceFinderImpl(
|
||||
dateProvider: dateProvider,
|
||||
db: db,
|
||||
deviceNameDecrypter: InactiveLinkedDeviceFinderImpl.Wrappers.OWSDeviceNameDecrypter(identityManager: identityManager),
|
||||
deviceStore: deviceStore,
|
||||
devicesService: InactiveLinkedDeviceFinderImpl.Wrappers.OWSDevicesService(),
|
||||
kvStoreFactory: keyValueStoreFactory,
|
||||
remoteConfig: InactiveLinkedDeviceFinderImpl.Wrappers.RemoteConfig(),
|
||||
tsAccountManager: tsAccountManager
|
||||
)
|
||||
|
||||
let dependenciesBridge = DependenciesBridge(
|
||||
accountAttributesUpdater: accountAttributesUpdater,
|
||||
appExpiry: appExpiry,
|
||||
@ -787,6 +798,7 @@ public class AppSetup {
|
||||
deletedCallRecordCleanupManager: deletedCallRecordCleanupManager,
|
||||
deletedCallRecordStore: deletedCallRecordStore,
|
||||
deviceManager: deviceManager,
|
||||
deviceStore: deviceStore,
|
||||
disappearingMessagesConfigurationStore: disappearingMessagesConfigurationStore,
|
||||
editManager: editManager,
|
||||
editMessageStore: editMessageStore,
|
||||
@ -796,6 +808,7 @@ public class AppSetup {
|
||||
groupMemberUpdater: groupMemberUpdater,
|
||||
groupUpdateInfoMessageInserter: groupUpdateInfoMessageInserter,
|
||||
identityManager: identityManager,
|
||||
inactiveLinkedDeviceFinder: inactiveLinkedDeviceFinder,
|
||||
incomingCallEventSyncMessageManager: incomingCallEventSyncMessageManager,
|
||||
incomingCallLogEventSyncMessageManager: incomingCallLogEventSyncMessageManager,
|
||||
incomingPniChangeNumberProcessor: incomingPniChangeNumberProcessor,
|
||||
|
||||
@ -108,6 +108,7 @@ extension ExperienceUpgrade {
|
||||
.introducingPins,
|
||||
.notificationPermissionReminder,
|
||||
.createUsernameReminder,
|
||||
.inactiveLinkedDeviceReminder,
|
||||
.pinReminder,
|
||||
.contactPermissionReminder,
|
||||
.unrecognized:
|
||||
|
||||
@ -27,6 +27,10 @@ public enum ExperienceUpgradeManifest: Dependencies {
|
||||
/// over time.
|
||||
case remoteMegaphone(megaphone: RemoteMegaphoneModel)
|
||||
|
||||
/// Prompts the user about any "inactive" linked devices that will expire
|
||||
/// soon.
|
||||
case inactiveLinkedDeviceReminder
|
||||
|
||||
/// Prompts the user to enter their PIN, to help ensure they remember it.
|
||||
///
|
||||
/// Note that this upgrade stores state in external components, rather than
|
||||
@ -98,6 +102,8 @@ extension ExperienceUpgradeManifest {
|
||||
return .notificationPermissionReminder
|
||||
case Self.createUsernameReminder.uniqueId:
|
||||
return .createUsernameReminder
|
||||
case Self.inactiveLinkedDeviceReminder.uniqueId:
|
||||
return .inactiveLinkedDeviceReminder
|
||||
case Self.pinReminder.uniqueId:
|
||||
return .pinReminder
|
||||
case Self.contactPermissionReminder.uniqueId:
|
||||
@ -126,6 +132,7 @@ extension ExperienceUpgradeManifest {
|
||||
.introducingPins,
|
||||
.notificationPermissionReminder,
|
||||
.createUsernameReminder,
|
||||
.inactiveLinkedDeviceReminder,
|
||||
.pinReminder,
|
||||
.contactPermissionReminder
|
||||
]
|
||||
@ -147,6 +154,8 @@ extension ExperienceUpgradeManifest {
|
||||
return "createUsernameReminder"
|
||||
case .remoteMegaphone(let megaphone):
|
||||
return megaphone.id
|
||||
case .inactiveLinkedDeviceReminder:
|
||||
return "inactiveLinkedDeviceReminder"
|
||||
case .pinReminder:
|
||||
return "pinReminder"
|
||||
case .contactPermissionReminder:
|
||||
@ -211,10 +220,12 @@ extension ExperienceUpgradeManifest: ExperienceUpgradeSortable {
|
||||
// Remote megaphone manifests use higher numbers to indicate higher
|
||||
// priority, so we should invert their priority here.
|
||||
return (3, -1 * megaphone.manifest.priority)
|
||||
case .pinReminder:
|
||||
case .inactiveLinkedDeviceReminder:
|
||||
return (4, 0)
|
||||
case .contactPermissionReminder:
|
||||
case .pinReminder:
|
||||
return (5, 0)
|
||||
case .contactPermissionReminder:
|
||||
return (6, 0)
|
||||
case .unrecognized:
|
||||
return (Int.max, Int.max)
|
||||
}
|
||||
@ -236,7 +247,8 @@ extension ExperienceUpgradeManifest {
|
||||
case
|
||||
.introducingPins,
|
||||
.createUsernameReminder,
|
||||
.remoteMegaphone:
|
||||
.remoteMegaphone,
|
||||
.inactiveLinkedDeviceReminder:
|
||||
return false
|
||||
case
|
||||
.notificationPermissionReminder,
|
||||
@ -260,6 +272,7 @@ extension ExperienceUpgradeManifest {
|
||||
case
|
||||
.notificationPermissionReminder,
|
||||
.createUsernameReminder,
|
||||
.inactiveLinkedDeviceReminder,
|
||||
.remoteMegaphone,
|
||||
.contactPermissionReminder:
|
||||
return true
|
||||
@ -276,6 +289,7 @@ extension ExperienceUpgradeManifest {
|
||||
.introducingPins,
|
||||
.notificationPermissionReminder,
|
||||
.createUsernameReminder,
|
||||
.inactiveLinkedDeviceReminder,
|
||||
.pinReminder,
|
||||
.contactPermissionReminder,
|
||||
.unrecognized:
|
||||
@ -297,7 +311,9 @@ extension ExperienceUpgradeManifest {
|
||||
.introducingPins,
|
||||
.pinReminder:
|
||||
return 2 * kDayInterval
|
||||
case .notificationPermissionReminder:
|
||||
case
|
||||
.notificationPermissionReminder,
|
||||
.inactiveLinkedDeviceReminder:
|
||||
return 3 * kDayInterval
|
||||
case .createUsernameReminder:
|
||||
// On snooze, never show again.
|
||||
@ -352,6 +368,7 @@ extension ExperienceUpgradeManifest {
|
||||
.introducingPins,
|
||||
.notificationPermissionReminder,
|
||||
.createUsernameReminder,
|
||||
.inactiveLinkedDeviceReminder,
|
||||
.pinReminder,
|
||||
.contactPermissionReminder:
|
||||
return Int.max
|
||||
@ -369,6 +386,7 @@ extension ExperienceUpgradeManifest {
|
||||
case
|
||||
.notificationPermissionReminder,
|
||||
.createUsernameReminder,
|
||||
.inactiveLinkedDeviceReminder,
|
||||
.contactPermissionReminder:
|
||||
return kDayInterval
|
||||
case .introducingPins:
|
||||
@ -400,6 +418,7 @@ extension ExperienceUpgradeManifest {
|
||||
.introducingPins,
|
||||
.notificationPermissionReminder,
|
||||
.createUsernameReminder,
|
||||
.inactiveLinkedDeviceReminder,
|
||||
.pinReminder,
|
||||
.contactPermissionReminder:
|
||||
return Date.distantFuture
|
||||
@ -416,15 +435,14 @@ extension ExperienceUpgradeManifest {
|
||||
case
|
||||
.introducingPins,
|
||||
.pinReminder,
|
||||
.inactiveLinkedDeviceReminder,
|
||||
.contactPermissionReminder,
|
||||
.unrecognized:
|
||||
return false
|
||||
case
|
||||
.notificationPermissionReminder,
|
||||
.createUsernameReminder:
|
||||
return true
|
||||
case
|
||||
.contactPermissionReminder:
|
||||
return false
|
||||
case
|
||||
.remoteMegaphone:
|
||||
// Controlled by conditional check
|
||||
@ -469,6 +487,8 @@ extension ExperienceUpgradeManifest {
|
||||
return checkPreconditionsForCreateUsernameReminder(transaction: transaction)
|
||||
case .remoteMegaphone(let megaphone):
|
||||
return checkPreconditionsForRemoteMegaphone(megaphone, tx: transaction)
|
||||
case .inactiveLinkedDeviceReminder:
|
||||
return checkPreconditionsForInactiveLinkedDeviceReminder(tx: transaction)
|
||||
case .pinReminder:
|
||||
return checkPreconditionsForPinReminder(transaction: transaction)
|
||||
case .contactPermissionReminder:
|
||||
@ -543,6 +563,10 @@ extension ExperienceUpgradeManifest {
|
||||
return timeIntervalSinceDisabledDiscovery > requiredDelayAfterDisablingDiscovery
|
||||
}
|
||||
|
||||
private static func checkPreconditionsForInactiveLinkedDeviceReminder(tx: SDSAnyReadTransaction) -> Bool {
|
||||
return DependenciesBridge.shared.inactiveLinkedDeviceFinder.hasInactiveLinkedDevice(tx: tx.asV2Read)
|
||||
}
|
||||
|
||||
private static func checkPreconditionsForPinReminder(transaction: SDSAnyReadTransaction) -> Bool {
|
||||
return OWS2FAManager.shared.isDueForV2Reminder(transaction: transaction)
|
||||
}
|
||||
|
||||
@ -19,9 +19,8 @@ open class OWSDevicesService: NSObject {
|
||||
@objc
|
||||
public static let deviceListUpdateModifiedDeviceList = Notification.Name("deviceListUpdateModifiedDeviceList")
|
||||
|
||||
@objc
|
||||
public static func refreshDevices() {
|
||||
firstly {
|
||||
public static func refreshDevices() -> Promise<Void> {
|
||||
return firstly {
|
||||
Self.getDevices()
|
||||
}.done(on: DispatchQueue.global()) { (devices: [OWSDevice]) in
|
||||
let didAddOrRemove = databaseStorage.write { transaction in
|
||||
|
||||
@ -268,6 +268,15 @@ public class RemoteConfig: NSObject {
|
||||
return false
|
||||
}
|
||||
|
||||
/// The time a linked device may be offline before it expires and is
|
||||
/// unlinked.
|
||||
public static var linkedDeviceLifespan: TimeInterval {
|
||||
return interval(
|
||||
.linkedDeviceLifespanInterval,
|
||||
defaultInterval: kMonthInterval
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: UInt values
|
||||
|
||||
private static func getUIntValue(
|
||||
@ -553,6 +562,7 @@ private enum ValueFlag: String, FlagType {
|
||||
case maxNicknameLength = "global.nicknames.max"
|
||||
case maxAttachmentDownloadSizeBytes = "global.attachments.maxBytes"
|
||||
case backgroundRefreshInterval = "ios.backgroundRefreshInterval"
|
||||
case linkedDeviceLifespanInterval = "ios.linkedDeviceLifespanInterval"
|
||||
|
||||
var isSticky: Bool {
|
||||
switch self {
|
||||
@ -576,7 +586,8 @@ private enum ValueFlag: String, FlagType {
|
||||
case .minNicknameLength: fallthrough
|
||||
case .maxNicknameLength: fallthrough
|
||||
case .maxAttachmentDownloadSizeBytes: fallthrough
|
||||
case .backgroundRefreshInterval:
|
||||
case .backgroundRefreshInterval: fallthrough
|
||||
case .linkedDeviceLifespanInterval:
|
||||
return false
|
||||
}
|
||||
}
|
||||
@ -594,7 +605,8 @@ private enum ValueFlag: String, FlagType {
|
||||
case .sepaEnabledRegions: fallthrough
|
||||
case .idealEnabledRegions: fallthrough
|
||||
case .maxGroupCallRingSize: fallthrough
|
||||
case .backgroundRefreshInterval:
|
||||
case .backgroundRefreshInterval: fallthrough
|
||||
case .linkedDeviceLifespanInterval:
|
||||
return true
|
||||
case .clientExpiration: fallthrough
|
||||
case .cdsSyncInterval: fallthrough
|
||||
|
||||
@ -0,0 +1,194 @@
|
||||
//
|
||||
// Copyright 2024 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
//
|
||||
|
||||
import XCTest
|
||||
|
||||
@testable import SignalServiceKit
|
||||
|
||||
final class InactiveLinkedDeviceFinderTest: XCTestCase {
|
||||
private var mockDateProvider: DateProvider = { Date() }
|
||||
private var mockDB: DB!
|
||||
private var mockDeviceNameDecrypter: MockDeviceNameDecrypter!
|
||||
private var mockDeviceStore: MockDeviceStore!
|
||||
private var mockDevicesService: MockDevicesService!
|
||||
private var mockTSAccountManager: MockTSAccountManager!
|
||||
|
||||
private var inactiveLinkedDeviceFinder: InactiveLinkedDeviceFinderImpl!
|
||||
|
||||
private var inactiveLastSeenAt: Date {
|
||||
// The finder will consider anything not seen for (1 month - 1 week) to
|
||||
// be inactive, so we'll go back exactly that far and then go one more
|
||||
// hour back to avoid any boundary-time issues.
|
||||
return Date()
|
||||
.addingTimeInterval(-kMonthInterval)
|
||||
.addingTimeInterval(kWeekInterval)
|
||||
.addingTimeInterval(-kHourInterval)
|
||||
}
|
||||
|
||||
override func setUp() {
|
||||
mockDB = MockDB()
|
||||
mockDeviceNameDecrypter = MockDeviceNameDecrypter()
|
||||
mockDeviceStore = MockDeviceStore()
|
||||
mockDevicesService = MockDevicesService()
|
||||
mockTSAccountManager = MockTSAccountManager()
|
||||
|
||||
inactiveLinkedDeviceFinder = InactiveLinkedDeviceFinderImpl(
|
||||
dateProvider: { self.mockDateProvider() },
|
||||
db: mockDB,
|
||||
deviceNameDecrypter: mockDeviceNameDecrypter,
|
||||
deviceStore: mockDeviceStore,
|
||||
devicesService: mockDevicesService,
|
||||
kvStoreFactory: InMemoryKeyValueStoreFactory(),
|
||||
remoteConfig: MockRemoteConfig(),
|
||||
tsAccountManager: mockTSAccountManager
|
||||
)
|
||||
}
|
||||
|
||||
func testRefreshing() async {
|
||||
// Skip if linked device.
|
||||
mockTSAccountManager.registrationStateMock = { .provisioned }
|
||||
await inactiveLinkedDeviceFinder.refreshLinkedDeviceStateIfNecessary()
|
||||
XCTAssertEqual(mockDevicesService.refreshCount, 0)
|
||||
|
||||
// Make a first attempt, failing to refresh.
|
||||
mockTSAccountManager.registrationStateMock = { .registered }
|
||||
mockDevicesService.shouldFail = true
|
||||
await inactiveLinkedDeviceFinder.refreshLinkedDeviceStateIfNecessary()
|
||||
XCTAssertEqual(mockDevicesService.refreshCount, 1)
|
||||
|
||||
// Make a second attempt, succeeding.
|
||||
mockTSAccountManager.registrationStateMock = { .registered }
|
||||
mockDevicesService.shouldFail = false
|
||||
await inactiveLinkedDeviceFinder.refreshLinkedDeviceStateIfNecessary()
|
||||
XCTAssertEqual(mockDevicesService.refreshCount, 2)
|
||||
|
||||
// A third attempt should do nothing, because we just succeeded.
|
||||
await inactiveLinkedDeviceFinder.refreshLinkedDeviceStateIfNecessary()
|
||||
XCTAssertEqual(mockDevicesService.refreshCount, 2)
|
||||
}
|
||||
|
||||
func testFetching() {
|
||||
func findLeastActive() -> InactiveLinkedDevice? {
|
||||
return mockDB.read { inactiveLinkedDeviceFinder.findLeastActiveLinkedDevice(tx: $0) }
|
||||
}
|
||||
|
||||
let inactiveLastSeenAt = inactiveLastSeenAt
|
||||
|
||||
// Skip if not the primary.
|
||||
mockTSAccountManager.registrationStateMock = { .provisioned }
|
||||
XCTAssertNil(findLeastActive())
|
||||
|
||||
// Nothing if no linked devices.
|
||||
mockTSAccountManager.registrationStateMock = { .provisioned }
|
||||
mockDeviceStore.devices = [.primary()]
|
||||
XCTAssertNil(findLeastActive())
|
||||
|
||||
// Only include inactive devices.
|
||||
mockTSAccountManager.registrationStateMock = { .registered }
|
||||
mockDeviceStore.devices = [
|
||||
.primary(),
|
||||
.fixture(name: "eye pad", lastSeenAt: inactiveLastSeenAt),
|
||||
]
|
||||
XCTAssertEqual(
|
||||
findLeastActive(),
|
||||
InactiveLinkedDevice(
|
||||
displayName: "eye pad",
|
||||
expirationDate: inactiveLastSeenAt.addingTimeInterval(kMonthInterval)
|
||||
)
|
||||
)
|
||||
|
||||
// If multiple inactive devices, pick the "least active" one.
|
||||
mockTSAccountManager.registrationStateMock = { .registered }
|
||||
mockDeviceStore.devices = [
|
||||
.primary(),
|
||||
.fixture(name: "🏖️", lastSeenAt: inactiveLastSeenAt.addingTimeInterval(-kSecondInterval)),
|
||||
.fixture(name: "🦩", lastSeenAt: inactiveLastSeenAt),
|
||||
]
|
||||
XCTAssertEqual(
|
||||
findLeastActive(),
|
||||
InactiveLinkedDevice(
|
||||
displayName: "🏖️",
|
||||
expirationDate: inactiveLastSeenAt.addingTimeInterval(-kSecondInterval).addingTimeInterval(kMonthInterval)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
func testPermanentlyDisabling() async {
|
||||
let inactiveLastSeenAt = inactiveLastSeenAt
|
||||
|
||||
mockTSAccountManager.registrationStateMock = { .registered }
|
||||
mockDeviceStore.devices = [
|
||||
.primary(),
|
||||
.fixture(name: "a sedentary device", lastSeenAt: inactiveLastSeenAt),
|
||||
]
|
||||
|
||||
mockDB.write { inactiveLinkedDeviceFinder.permanentlyDisableFinders(tx: $0) }
|
||||
await inactiveLinkedDeviceFinder.refreshLinkedDeviceStateIfNecessary()
|
||||
XCTAssertEqual(mockDevicesService.refreshCount, 0)
|
||||
XCTAssertFalse(mockDB.read { inactiveLinkedDeviceFinder.hasInactiveLinkedDevice(tx: $0) })
|
||||
|
||||
// Re-enable (only available in tests) and run more tests, to prove the
|
||||
// disabling is why the first battery passed.
|
||||
mockDB.write { inactiveLinkedDeviceFinder.reenablePermanentlyDisabledFinders(tx: $0) }
|
||||
await inactiveLinkedDeviceFinder.refreshLinkedDeviceStateIfNecessary()
|
||||
XCTAssertEqual(mockDevicesService.refreshCount, 1)
|
||||
XCTAssertTrue(mockDB.read { inactiveLinkedDeviceFinder.hasInactiveLinkedDevice(tx: $0) })
|
||||
}
|
||||
}
|
||||
|
||||
private extension OWSDevice {
|
||||
static func primary() -> OWSDevice {
|
||||
return OWSDevice(
|
||||
deviceId: Int(OWSDevice.primaryDeviceId),
|
||||
encryptedName: nil,
|
||||
createdAt: .distantPast,
|
||||
lastSeenAt: Date()
|
||||
)
|
||||
}
|
||||
|
||||
static func fixture(
|
||||
name: String,
|
||||
lastSeenAt: Date
|
||||
) -> OWSDevice {
|
||||
return OWSDevice(
|
||||
deviceId: 24,
|
||||
encryptedName: name,
|
||||
createdAt: .distantPast,
|
||||
lastSeenAt: lastSeenAt
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Mocks
|
||||
|
||||
private class MockDeviceNameDecrypter: InactiveLinkedDeviceFinderImpl.Shims.OWSDeviceNameDecrypter {
|
||||
func decryptName(device: OWSDevice, tx: DBReadTransaction) -> String {
|
||||
return device.encryptedName!
|
||||
}
|
||||
}
|
||||
|
||||
private class MockDeviceStore: OWSDeviceStore {
|
||||
var devices: [OWSDevice] = []
|
||||
|
||||
func fetchAll(tx: DBReadTransaction) -> [OWSDevice] {
|
||||
return devices
|
||||
}
|
||||
}
|
||||
|
||||
private class MockDevicesService: InactiveLinkedDeviceFinderImpl.Shims.OWSDevicesService {
|
||||
var shouldFail: Bool = false
|
||||
var refreshCount: Int = 0
|
||||
|
||||
func refreshDevices() async throws {
|
||||
refreshCount += 1
|
||||
if shouldFail { throw OWSGenericError("") }
|
||||
}
|
||||
}
|
||||
|
||||
private class MockRemoteConfig: InactiveLinkedDeviceFinderImpl.Shims.RemoteConfig {
|
||||
func linkedDeviceLifespan() -> TimeInterval {
|
||||
return kMonthInterval
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user