TM-SGNL-iOS/SignalServiceKit/Devices/InactiveLinkedDeviceFinder.swift
TeleMessage developers dde0620daf initial commit
2025-05-03 12:28:28 -07:00

236 lines
8.2 KiB
Swift

//
// Copyright 2024 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//
/// 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: any DB
private let deviceNameDecrypter: Shims.OWSDeviceNameDecrypter
private let deviceService: OWSDeviceService
private let deviceStore: OWSDeviceStore
private let kvStore: KeyValueStore
private let remoteConfigProvider: any RemoteConfigProvider
private let tsAccountManager: TSAccountManager
private var intervalForDeviceExpiration: TimeInterval {
return remoteConfigProvider.currentConfig().messageQueueTime
}
private var intervalForDeviceInactivity: TimeInterval {
return max(0, remoteConfigProvider.currentConfig().messageQueueTime - Constants.intervalBeforeExpirationConsideredInactive)
}
private let logger = PrefixedLogger(prefix: "InactiveLinkedDeviceFinder")
init(
dateProvider: @escaping DateProvider,
db: any DB,
deviceNameDecrypter: Shims.OWSDeviceNameDecrypter,
deviceService: OWSDeviceService,
deviceStore: OWSDeviceStore,
remoteConfigProvider: any RemoteConfigProvider,
tsAccountManager: TSAccountManager
) {
self.dateProvider = dateProvider
self.db = db
self.deviceNameDecrypter = deviceNameDecrypter
self.deviceService = deviceService
self.deviceStore = deviceStore
self.kvStore = KeyValueStore(collection: "InactiveLinkedDeviceFinderImpl")
self.remoteConfigProvider = remoteConfigProvider
self.tsAccountManager = tsAccountManager
}
func refreshLinkedDeviceStateIfNecessary() async {
struct SkipRefreshError: Error {}
let shouldSkip = db.read { tx -> Bool in
if kvStore.hasValue(StoreKeys.isPermanentlyDisabled, transaction: tx) {
// Finder is permanently disabled, no need to refresh.
return true
}
if !tsAccountManager.registrationState(tx: tx).isRegisteredPrimaryDevice {
// Only refresh state on primaries.
return true
}
if
let lastRefreshedDate = kvStore.getDate(StoreKeys.lastRefreshedDate, transaction: tx),
lastRefreshedDate.addingTimeInterval(Constants.intervalForDeviceRefresh) > dateProvider()
{
// Checked less than a day ago, skip.
return true
}
return false
}
if shouldSkip {
return
}
do {
_ = try await deviceService.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.lastRefreshedDate, transaction: tx) {
/// Short-circuit if we've never refreshed device state. Otherwise,
/// we'll be querying stale data from who knows when.
return nil
}
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
}
enum Wrappers {
typealias OWSDeviceNameDecrypter = InactiveLinkedDeviceFinderImpl_OWSDeviceNameDecrypter_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
)
}
}