From c26ad8a4e56077d643fccacaa33930c112edfeb3 Mon Sep 17 00:00:00 2001 From: Sasha Weiss Date: Wed, 24 Apr 2024 11:28:33 -0700 Subject: [PATCH] Add an "inactive linked device" megaphone --- Signal.xcodeproj/project.pbxproj | 16 ++ Signal/AppLaunch/AppEnvironment.swift | 16 +- .../Contents.json | 12 + .../inactive_linked_device.pdf | Bin 0 -> 6044 bytes .../Megaphones/ExperienceUpgradeManager.swift | 17 ++ ...nactiveLinkedDeviceReminderMegaphone.swift | 83 ++++++ .../LinkedDevicesTableViewController.swift | 4 +- .../ViewControllers/DebugUI/DebugUIMisc.swift | 7 + .../RegistrationCoordinatorTest.swift | 2 +- .../translations/en.lproj/Localizable.strings | 9 + .../en.lproj/PluralAware.stringsdict | 16 ++ .../MockTSAccountManager.swift | 6 +- .../Dependencies/DependenciesBridge.swift | 6 + .../Devices/InactiveLinkedDeviceFinder.swift | 268 ++++++++++++++++++ SignalServiceKit/Devices/OWSDevice.swift | 4 +- SignalServiceKit/Devices/OWSDeviceStore.swift | 14 + SignalServiceKit/Environment/AppSetup.swift | 23 +- .../Megaphones/ExperienceUpgrade.swift | 1 + .../ExperienceUpgradeManifest.swift | 38 ++- .../Network/API/OWSDevicesService.swift | 5 +- .../Util/RemoteConfigManager.swift | 16 +- .../InactiveLinkedDeviceFinderTest.swift | 194 +++++++++++++ 22 files changed, 725 insertions(+), 32 deletions(-) create mode 100644 Signal/Images.xcassets/inactive-linked-device-reminder-megaphone.imageset/Contents.json create mode 100644 Signal/Images.xcassets/inactive-linked-device-reminder-megaphone.imageset/inactive_linked_device.pdf create mode 100644 Signal/Megaphones/UserInterface/InactiveLinkedDeviceReminderMegaphone.swift create mode 100644 SignalServiceKit/Devices/InactiveLinkedDeviceFinder.swift create mode 100644 SignalServiceKit/Devices/OWSDeviceStore.swift create mode 100644 SignalServiceKit/tests/Devices/InactiveLinkedDeviceFinderTest.swift diff --git a/Signal.xcodeproj/project.pbxproj b/Signal.xcodeproj/project.pbxproj index 2d1f1901fb..2dfa0e5a14 100644 --- a/Signal.xcodeproj/project.pbxproj +++ b/Signal.xcodeproj/project.pbxproj @@ -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 = ""; }; D9B95A9929E8918200D7CB95 /* InMemoryDB.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InMemoryDB.swift; sourceTree = ""; }; D9B95A9C29E894A600D7CB95 /* ValidatableModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ValidatableModel.swift; sourceTree = ""; }; + D9C0AE612BD7102500FCB05E /* InactiveLinkedDeviceFinderTest.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = InactiveLinkedDeviceFinderTest.swift; sourceTree = ""; }; + D9C0AE632BD7103100FCB05E /* OWSDeviceStore.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = OWSDeviceStore.swift; sourceTree = ""; }; + D9C0AE642BD7103100FCB05E /* InactiveLinkedDeviceFinder.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = InactiveLinkedDeviceFinder.swift; sourceTree = ""; }; + D9C0AE682BD82DBC00FCB05E /* InactiveLinkedDeviceReminderMegaphone.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InactiveLinkedDeviceReminderMegaphone.swift; sourceTree = ""; }; D9C2D777299B07D300D79715 /* Usernames+BetterIdentifierChecker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Usernames+BetterIdentifierChecker.swift"; sourceTree = ""; }; D9C2D77D299D750200D79715 /* UsernameEducationManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UsernameEducationManager.swift; sourceTree = ""; }; D9C2D77F299EC11400D79715 /* CreateUsernameMegaphone.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreateUsernameMegaphone.swift; sourceTree = ""; }; @@ -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 */, diff --git a/Signal/AppLaunch/AppEnvironment.swift b/Signal/AppLaunch/AppEnvironment.swift index b5f944609f..b23bc0389c 100644 --- a/Signal/AppLaunch/AppEnvironment.swift +++ b/Signal/AppLaunch/AppEnvironment.swift @@ -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() } } } diff --git a/Signal/Images.xcassets/inactive-linked-device-reminder-megaphone.imageset/Contents.json b/Signal/Images.xcassets/inactive-linked-device-reminder-megaphone.imageset/Contents.json new file mode 100644 index 0000000000..43559f5330 --- /dev/null +++ b/Signal/Images.xcassets/inactive-linked-device-reminder-megaphone.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "inactive_linked_device.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Signal/Images.xcassets/inactive-linked-device-reminder-megaphone.imageset/inactive_linked_device.pdf b/Signal/Images.xcassets/inactive-linked-device-reminder-megaphone.imageset/inactive_linked_device.pdf new file mode 100644 index 0000000000000000000000000000000000000000..d1b57a092a3c854c3a196328d67f3ee848a98415 GIT binary patch literal 6044 zcmbtY52)Q$9gml?_CrdWl`gH*pT%C={k`+=cYf!mVr=g@8P4|h-eAfcbi40vd*j`E z-|l@|tBJ!Tn?|O{oXFZ(2!*Jv7-yZYZc7-u?m9NIVrH({ZP(|TY-FstbGp%-xN#*O z;Wx5s+C9VH89W|qHab&H|71RyrGHp^+y0~J{+$PhCil#5 zI)3P-M;6cj`Pho@UitCY9=K;;>pKfiF8P$$I{cT%Uwig9FMjjY^w6~j_e~vm=b>9( zNw(d6WcBjLuKoAz3-;gJeDdkLet+`N@eTW5xa!op-_{p>;fj05*WG#1#jo#q@74Rh zbYHEz;Va|m_lB1LdTjM&i=KM^^vj3qAC=obwBpqLM|yAG@sTIreW}%Y>theMW{-}X zII;4B_WOq)JpRWMyDqw7@dw(c)@18%Jazc(H@eTgyO2LTboX0Je^P(rm!J9C?md_M zefR6{H7@zr)k8b?o&M41j{bA;w+`L8Y<|HfhPOSNG}}|&&p5eICX!<6Vp5@s(2ZK~4CZf_r;)Gt=o#&ej{vGCf8Gb6h46TNmDnb2jjfPY2@(t0Wk~ z%cGNt`_t$p+Y^!U^2c~}sL_d@nd+Ia)v3`+cv{#z;~cLVn^{rdpn0yeX1zu`$tzMx zr&a_OspN%F1+!^haBE-_$rE%6SR+wI#WA2Wi57iEdGx+k6{RFG5k~qnl7P}HU`c0Q z=@z7*UcJYHJ^^baLHB^pBxGa&NedD}2+40m4x^_t$<}1^7n63f?)>9Nku2k;eTDx1s?=#Abvzy#;;_QMel2R z9>fGJ=!5tXWmX9XO9Ex38{+4sPl3Az|bO5cq>9{Ir5k@GPMA);uyZ-B@&3CQnu7C-R%gb`+M>W@OpVT} zE>?=RF19dQNvK!a>-$Pax@G8+c2MNW61*DZWn)m%PsN@B>M~CWjLppb(8T&+<=Nu8n!NNJd z$y2u1WR_x<;|KplC@dC*v^fgDJXltfG@f8n|CIGM)7=1M&;)__0+Bh{$$qJRQ~&LrM2R8&AC2au!)iEN*Pu?|yj zfNMmlQzflNNA<5bJ*f<%yd!Kd00ft1QnE5J2l%fnG!D>#Gxh)wM zS_yTf0L7a^K+B=$_o;xQ7&IbR(bEeF9g>Y>%;Af!44tt;X6}10$HHHZ5^O>=O2`8eWp7beZjun_ zRv^PMJ>8%Us72GqT*9tF-l`RSN^BLO=Mv`pr40U|jT7h`mU9EOB9Qm#P)L0t)~yER ztAM;VmN}n!(CclWp+vc8T^P)0Y)4X}+EQbT!rrGV?XdEc3spcGSkFw6OC3?cF0KlJ zDeqYgq*djq^B?zNgAU$2k4b`3@>v(x7j`7~QJ$l|{6~4{VidCgp%$n^1hkB=|F{n* z*5#_cycEPdV5AOs5=tV$l0Z@oc6%w5>p>lY&=4!Zgq7*izjPps4#b}jVi4CCDeUTt zC_+DR!^NU>l!$s%@D{cXuF)OBXbBN%;8pJ{V(e0~@?W1X7IhL(JtA@<9tcrI2K!ZC zei7t{H1d39)c=Ta-2Q^bWymY_$h1fZwWtuG;HoG@0gT$#IqufnwO0SWROi;x6&OC& z;%n4)yEE5g8#nn2(e+=S#f|ECeM@y^dUJDXy1ufri)}<_@aogSkj>##ahhoMIy<_J zW{YM4WJQ6LE3|_DG<+H0~QdTA!=WbhZq>x~tpVoNy+1(zoCJ&rFu7i~;vQfu8Sv ztX)Cy`k4@xhO;1bHdS#Z6-oq79AKa641O4d3_hvPfN~yQoa@%7XPVu_U6-yNgCm#r xI<4j~TRJ^8+;`{5M5i;yoU6*vj7&~{qv`A}FUafNxzZCE?!l5JOKw=Z>VE;t5#;~? literal 0 HcmV?d00001 diff --git a/Signal/Megaphones/ExperienceUpgradeManager.swift b/Signal/Megaphones/ExperienceUpgradeManager.swift index 61d7fdb891..918dacda86 100644 --- a/Signal/Megaphones/ExperienceUpgradeManager.swift +++ b/Signal/Megaphones/ExperienceUpgradeManager.swift @@ -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): diff --git a/Signal/Megaphones/UserInterface/InactiveLinkedDeviceReminderMegaphone.swift b/Signal/Megaphones/UserInterface/InactiveLinkedDeviceReminderMegaphone.swift new file mode 100644 index 0000000000..a704fed4c1 --- /dev/null +++ b/Signal/Megaphones/UserInterface/InactiveLinkedDeviceReminderMegaphone.swift @@ -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!") + } +} diff --git a/Signal/src/ViewControllers/AppSettings/Linked Devices/LinkedDevicesTableViewController.swift b/Signal/src/ViewControllers/AppSettings/Linked Devices/LinkedDevicesTableViewController.swift index c1ad852276..b86c37ffaa 100644 --- a/Signal/src/ViewControllers/AppSettings/Linked Devices/LinkedDevicesTableViewController.swift +++ b/Signal/src/ViewControllers/AppSettings/Linked Devices/LinkedDevicesTableViewController.swift @@ -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 diff --git a/Signal/src/ViewControllers/DebugUI/DebugUIMisc.swift b/Signal/src/ViewControllers/DebugUI/DebugUIMisc.swift index b9383afa52..cc683fd079 100644 --- a/Signal/src/ViewControllers/DebugUI/DebugUIMisc.swift +++ b/Signal/src/ViewControllers/DebugUI/DebugUIMisc.swift @@ -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?", diff --git a/Signal/test/Registration/RegistrationCoordinatorTest.swift b/Signal/test/Registration/RegistrationCoordinatorTest.swift index 4eb0adc950..e65b101bdb 100644 --- a/Signal/test/Registration/RegistrationCoordinatorTest.swift +++ b/Signal/test/Registration/RegistrationCoordinatorTest.swift @@ -88,7 +88,7 @@ public class RegistrationCoordinatorTest: XCTestCase { registrationStateChangeManagerMock = MockRegistrationStateChangeManager() sessionManager = RegistrationSessionManagerMock() storageServiceManagerMock = FakeStorageServiceManager() - tsAccountManagerMock = MockTSAccountManager(dateProvider: dateProvider) + tsAccountManagerMock = MockTSAccountManager() usernameApiClientMock = MockUsernameApiClient() usernameLinkManagerMock = MockUsernameLinkManager() diff --git a/Signal/translations/en.lproj/Localizable.strings b/Signal/translations/en.lproj/Localizable.strings index ccbdb8564c..1ee78c0aea 100644 --- a/Signal/translations/en.lproj/Localizable.strings +++ b/Signal/translations/en.lproj/Localizable.strings @@ -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."; diff --git a/Signal/translations/en.lproj/PluralAware.stringsdict b/Signal/translations/en.lproj/PluralAware.stringsdict index 33f0ee83e1..603cad0831 100644 --- a/Signal/translations/en.lproj/PluralAware.stringsdict +++ b/Signal/translations/en.lproj/PluralAware.stringsdict @@ -733,6 +733,22 @@ You can’t share more than %d items. + INACTIVE_LINKED_DEVICE_REMINDER_MEGAPHONE_BODY_%d + + NSStringLocalizedFormatKey + %#@text@ + text + + NSStringFormatSpecTypeKey + NSStringPluralRuleType + NSStringFormatValueTypeKey + d + one + To keep "%2$@" linked, open Signal on this device within one day. + other + To keep "%2$@" linked, open Signal on this device within %d days. + + MANY_GROUPS_IN_COMMON_%d NSStringLocalizedFormatKey diff --git a/SignalServiceKit/Account/TSAccountManager/MockTSAccountManager.swift b/SignalServiceKit/Account/TSAccountManager/MockTSAccountManager.swift index b4ce141760..f5f64292c4 100644 --- a/SignalServiceKit/Account/TSAccountManager/MockTSAccountManager.swift +++ b/SignalServiceKit/Account/TSAccountManager/MockTSAccountManager.swift @@ -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) {} diff --git a/SignalServiceKit/Dependencies/DependenciesBridge.swift b/SignalServiceKit/Dependencies/DependenciesBridge.swift index dfc058944c..71d0840a8f 100644 --- a/SignalServiceKit/Dependencies/DependenciesBridge.swift +++ b/SignalServiceKit/Dependencies/DependenciesBridge.swift @@ -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 diff --git a/SignalServiceKit/Devices/InactiveLinkedDeviceFinder.swift b/SignalServiceKit/Devices/InactiveLinkedDeviceFinder.swift new file mode 100644 index 0000000000..43a3858f76 --- /dev/null +++ b/SignalServiceKit/Devices/InactiveLinkedDeviceFinder.swift @@ -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 + } +} diff --git a/SignalServiceKit/Devices/OWSDevice.swift b/SignalServiceKit/Devices/OWSDevice.swift index 4fcc3a915b..ab1c426708 100644 --- a/SignalServiceKit/Devices/OWSDevice.swift +++ b/SignalServiceKit/Devices/OWSDevice.swift @@ -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, diff --git a/SignalServiceKit/Devices/OWSDeviceStore.swift b/SignalServiceKit/Devices/OWSDeviceStore.swift new file mode 100644 index 0000000000..9c1d1314fa --- /dev/null +++ b/SignalServiceKit/Devices/OWSDeviceStore.swift @@ -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)) + } +} diff --git a/SignalServiceKit/Environment/AppSetup.swift b/SignalServiceKit/Environment/AppSetup.swift index 20479332b7..084642eb34 100644 --- a/SignalServiceKit/Environment/AppSetup.swift +++ b/SignalServiceKit/Environment/AppSetup.swift @@ -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, diff --git a/SignalServiceKit/Megaphones/ExperienceUpgrade.swift b/SignalServiceKit/Megaphones/ExperienceUpgrade.swift index a61df36bb2..8c5d34474e 100644 --- a/SignalServiceKit/Megaphones/ExperienceUpgrade.swift +++ b/SignalServiceKit/Megaphones/ExperienceUpgrade.swift @@ -108,6 +108,7 @@ extension ExperienceUpgrade { .introducingPins, .notificationPermissionReminder, .createUsernameReminder, + .inactiveLinkedDeviceReminder, .pinReminder, .contactPermissionReminder, .unrecognized: diff --git a/SignalServiceKit/Megaphones/ExperienceUpgradeManifest.swift b/SignalServiceKit/Megaphones/ExperienceUpgradeManifest.swift index 469b7ee163..6ce56dbe3e 100644 --- a/SignalServiceKit/Megaphones/ExperienceUpgradeManifest.swift +++ b/SignalServiceKit/Megaphones/ExperienceUpgradeManifest.swift @@ -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) } diff --git a/SignalServiceKit/Network/API/OWSDevicesService.swift b/SignalServiceKit/Network/API/OWSDevicesService.swift index bbbafa7b68..873e5ca871 100644 --- a/SignalServiceKit/Network/API/OWSDevicesService.swift +++ b/SignalServiceKit/Network/API/OWSDevicesService.swift @@ -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 { + return firstly { Self.getDevices() }.done(on: DispatchQueue.global()) { (devices: [OWSDevice]) in let didAddOrRemove = databaseStorage.write { transaction in diff --git a/SignalServiceKit/Util/RemoteConfigManager.swift b/SignalServiceKit/Util/RemoteConfigManager.swift index 6c90bd3570..5a9733394a 100644 --- a/SignalServiceKit/Util/RemoteConfigManager.swift +++ b/SignalServiceKit/Util/RemoteConfigManager.swift @@ -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 diff --git a/SignalServiceKit/tests/Devices/InactiveLinkedDeviceFinderTest.swift b/SignalServiceKit/tests/Devices/InactiveLinkedDeviceFinderTest.swift new file mode 100644 index 0000000000..d5ccd66f5f --- /dev/null +++ b/SignalServiceKit/tests/Devices/InactiveLinkedDeviceFinderTest.swift @@ -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 + } +}