Add an "inactive linked device" megaphone

This commit is contained in:
Sasha Weiss 2024-04-24 11:28:33 -07:00 committed by GitHub
parent 70ac6f28d0
commit c26ad8a4e5
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
22 changed files with 725 additions and 32 deletions

View File

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

View File

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

View File

@ -0,0 +1,12 @@
{
"images" : [
{
"filename" : "inactive_linked_device.pdf",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

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

View File

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

View File

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

View File

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

View File

@ -88,7 +88,7 @@ public class RegistrationCoordinatorTest: XCTestCase {
registrationStateChangeManagerMock = MockRegistrationStateChangeManager()
sessionManager = RegistrationSessionManagerMock()
storageServiceManagerMock = FakeStorageServiceManager()
tsAccountManagerMock = MockTSAccountManager(dateProvider: dateProvider)
tsAccountManagerMock = MockTSAccountManager()
usernameApiClientMock = MockUsernameApiClient()
usernameLinkManagerMock = MockUsernameLinkManager()

View File

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

View File

@ -733,6 +733,22 @@
<string>You cant 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>

View File

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

View File

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

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

View File

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

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

View File

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

View File

@ -108,6 +108,7 @@ extension ExperienceUpgrade {
.introducingPins,
.notificationPermissionReminder,
.createUsernameReminder,
.inactiveLinkedDeviceReminder,
.pinReminder,
.contactPermissionReminder,
.unrecognized:

View File

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

View File

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

View File

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

View File

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