// // Copyright 2021 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only // import Foundation public protocol AppVersion { var hardwareInfoString: String { get } var iosVersionString: String { get } /// The version of the app when it was first launched. If this is the first /// launch, this will match `currentAppVersion`. var firstAppVersion: String { get } /// The version of the app the last time it was launched. `nil` if the app /// hasn't been launched. var lastAppVersion: String? { get } /// Internally, we use a version format with 4 dotted values to uniquely /// identify builds. The first three values are the the release version, the /// fourth value is the last value from the build version. /// /// For example, `3.4.5.6`. var currentAppVersion: String { get } /// A user-visible "pretty" version number. /// /// Never sort or compare using this version number. var prettyAppVersion: String { get } var lastCompletedLaunchAppVersion: String? { get } var lastCompletedLaunchMainAppVersion: String? { get } var lastCompletedLaunchSAEAppVersion: String? { get } var lastCompletedLaunchNSEAppVersion: String? { get } var firstMainAppLaunchDateAfterUpdate: Date? { get } var buildDate: Date { get } func mainAppLaunchDidComplete() func saeLaunchDidComplete() func nseLaunchDidComplete() } public struct AppVersionNumber: Equatable, Comparable { public var rawValue: String public init(_ rawValue: String) { self.rawValue = rawValue } public static func < (lhs: Self, rhs: Self) -> Bool { return lhs.rawValue.compare(rhs.rawValue, options: .numeric) == .orderedAscending } } public class AppVersionImpl: AppVersion { private let firstVersionKey = "kNSUserDefaults_FirstAppVersion" private let lastVersionKey = "kNSUserDefaults_LastVersion" private let lastCompletedLaunchVersionKey = "kNSUserDefaults_LastCompletedLaunchAppVersion" private let lastCompletedMainAppLaunchVersionKey = "kNSUserDefaults_LastCompletedLaunchAppVersion_MainApp" private let lastCompletedSAELaunchVersionKey = "kNSUserDefaults_LastCompletedLaunchAppVersion_SAE" private let lastCompletedNSELaunchVersionKey = "kNSUserDefaults_LastCompletedLaunchAppVersion_NSE" private let firstMainAppLaunchDateAfterUpdateKey = "FirstMainAppLaunchDateAfterUpdate" public static let shared: AppVersion = { let result = AppVersionImpl( bundle: Bundle.main, userDefaults: CurrentAppContext().appUserDefaults() ) result.save() result.startupLogging() return result }() // MARK: - Properties public var hardwareInfoString: String { let marketingString = UIDevice.current.model let machineString = String(sysctlKey: "hw.machine") ?? "nil" let modelString = String(sysctlKey: "hw.model") ?? "nil" return "\(marketingString) (\(machineString); \(modelString))" } public var iosVersionString: String { let majorMinor = UIDevice.current.systemVersion let buildNumber = String(sysctlKey: "kern.osversion") ?? "nil" return "\(majorMinor) (\(buildNumber))" } private let userDefaults: UserDefaults /// The version of the app when it was first launched. If this is the first /// launch, this will match `currentAppVersion`. public var firstAppVersion: String { return userDefaults.string(forKey: firstVersionKey) ?? currentAppVersion } /// The version of the app the last time it was launched. `nil` if the app /// hasn't been launched. public var lastAppVersion: String? { userDefaults.string(forKey: lastVersionKey) } /// Internally, we use a version format with 4 dotted values to uniquely /// identify builds. The first three values are the the release version, the /// fourth value is the last value from the build version. /// /// For example, `3.4.5.6`. public let currentAppVersion: String public let prettyAppVersion: String public var lastCompletedLaunchAppVersion: String? { return userDefaults.string(forKey: lastCompletedLaunchVersionKey) } public private(set) var lastCompletedLaunchMainAppVersion: String? { get { userDefaults.string(forKey: lastCompletedMainAppLaunchVersionKey) } set { let didChange = lastCompletedLaunchMainAppVersion != newValue userDefaults.setOrRemove(newValue, forKey: lastCompletedLaunchVersionKey) userDefaults.setOrRemove(newValue, forKey: lastCompletedMainAppLaunchVersionKey) if didChange { userDefaults.set(Date(), forKey: firstMainAppLaunchDateAfterUpdateKey) } } } public private(set) var lastCompletedLaunchSAEAppVersion: String? { get { userDefaults.string(forKey: lastCompletedSAELaunchVersionKey) } set { userDefaults.setOrRemove(newValue, forKey: lastCompletedLaunchVersionKey) userDefaults.setOrRemove(newValue, forKey: lastCompletedSAELaunchVersionKey) } } public private(set) var lastCompletedLaunchNSEAppVersion: String? { get { userDefaults.string(forKey: lastCompletedNSELaunchVersionKey) } set { userDefaults.setOrRemove(newValue, forKey: lastCompletedLaunchVersionKey) userDefaults.setOrRemove(newValue, forKey: lastCompletedNSELaunchVersionKey) } } public var firstMainAppLaunchDateAfterUpdate: Date? { return userDefaults.object(forKey: firstMainAppLaunchDateAfterUpdateKey) as? Date } public let buildDate: Date // MARK: - Setup private init(bundle: Bundle, userDefaults: UserDefaults) { let marketingVersion = bundle.string(forInfoDictionaryKey: "CFBundleShortVersionString") var marketingVersionComponents = marketingVersion.components(separatedBy: ".") while marketingVersionComponents.count < 3 { marketingVersionComponents.append("0") } let buildNumber = bundle.string(forInfoDictionaryKey: "CFBundleVersion") self.currentAppVersion = "\(marketingVersionComponents.joined(separator: ".")).\(buildNumber)" self.prettyAppVersion = "\(marketingVersion) (\(buildNumber))" if let rawBuildDetails = bundle.app.object(forInfoDictionaryKey: "BuildDetails"), let buildDetails = rawBuildDetails as? [String: Any], let buildTimestamp = buildDetails["Timestamp"] as? TimeInterval { self.buildDate = Date(timeIntervalSince1970: buildTimestamp) } else { #if !TESTABLE_BUILD owsFailBeta("Expected a build date to be defined. Assuming build date is right now") #endif self.buildDate = Date() } self.userDefaults = userDefaults } private func save() { if userDefaults.string(forKey: firstVersionKey) == nil { userDefaults.set(currentAppVersion, forKey: firstVersionKey) } userDefaults.set(currentAppVersion, forKey: lastVersionKey) } private func formatForLogging(_ versionNumber: String?) -> String { if let versionNumber { // The long version string looks like an IPv4 address. To prevent the log // scrubber from scrubbing it, we replace `.` with `_`. return versionNumber.replacingOccurrences(of: ".", with: "_") } else { return "none" } } private func startupLogging() { Logger.info("firstAppVersion: \(formatForLogging(firstAppVersion))") Logger.info("lastAppVersion: \(formatForLogging(lastAppVersion))") Logger.info("currentAppVersion: \(formatForLogging(currentAppVersion))") Logger.info("lastCompletedLaunchAppVersion: \(formatForLogging(lastCompletedLaunchAppVersion))") Logger.info("lastCompletedLaunchMainAppVersion: \(formatForLogging(lastCompletedLaunchMainAppVersion))") Logger.info("lastCompletedLaunchSAEAppVersion: \(formatForLogging(lastCompletedLaunchSAEAppVersion))") Logger.info("lastCompletedLaunchNSEAppVersion: \(formatForLogging(lastCompletedLaunchNSEAppVersion))") let databaseCorruptionState = DatabaseCorruptionState(userDefaults: userDefaults) Logger.info("Database corruption state: \(databaseCorruptionState)") Logger.info("iOS Version: \(iosVersionString)") let locale = Locale.current Logger.info("Locale Identifier: \(locale.identifier)") if let countryCode = (locale as NSLocale).countryCode { Logger.info("Country Code: \(countryCode)") } if let languageCode = locale.languageCode { Logger.info("Language Code: \(languageCode)") } Logger.info("Device Model: \(hardwareInfoString)") if let buildDetails = Bundle.main.object(forInfoDictionaryKey: "BuildDetails") as? [String: Any] { if let signalCommit = buildDetails["SignalCommit"] as? String { Logger.info("Signal Commit: \(signalCommit)") } if let xcodeVersion = buildDetails["XCodeVersion"] as? String { Logger.info("Build XCode Version: \(xcodeVersion)") } if let buildTime = buildDetails["DateTime"] as? String { Logger.info("Build Date/Time: \(buildTime)") } } } // MARK: - Events public func mainAppLaunchDidComplete() { Logger.info("") lastCompletedLaunchMainAppVersion = currentAppVersion } public func saeLaunchDidComplete() { Logger.info("") lastCompletedLaunchSAEAppVersion = currentAppVersion } public func nseLaunchDidComplete() { Logger.info("") lastCompletedLaunchNSEAppVersion = currentAppVersion } } // MARK: - Objective-C interop @objc(AppVersion) @objcMembers public class AppVersionForObjC: NSObject { public static var shared: AppVersionForObjC { .init(AppVersionImpl.shared) } private var appVersion: AppVersion public var lastCompletedLaunchAppVersion: String? { appVersion.lastCompletedLaunchAppVersion } public var lastCompletedLaunchMainAppVersion: String? { appVersion.lastCompletedLaunchMainAppVersion } public var currentAppVersion: String { appVersion.currentAppVersion } private init(_ appVersion: AppVersion) { self.appVersion = appVersion } public func mainAppLaunchDidComplete() { appVersion.mainAppLaunchDidComplete() } public func saeLaunchDidComplete() { appVersion.saeLaunchDidComplete() } public func nseLaunchDidComplete() { appVersion.nseLaunchDidComplete() } } // MARK: - Helpers fileprivate extension Bundle { func string(forInfoDictionaryKey key: String) -> String { guard let result = object(forInfoDictionaryKey: key) as? String else { owsFail("Couldn't fetch string from \(key)") } if result.isEmpty { owsFail("String is unexpectedly empty") } return result } } fileprivate extension UserDefaults { func setOrRemove(_ str: String?, forKey key: String) { if let str { set(str, forKey: key) } else { removeObject(forKey: key) } } } // MARK: - Mock #if TESTABLE_BUILD public class MockAppVerion: AppVersion { public init() {} public var hardwareInfoString: String = "" public var iosVersionString: String = "16.0" public var firstAppVersion: String = "1.0" public var lastAppVersion: String? = "1.0" public var currentAppVersion: String = "1.0.0.0" public var prettyAppVersion: String = "1.0 (0)" public var lastCompletedLaunchAppVersion: String? public var lastCompletedLaunchMainAppVersion: String? public var lastCompletedLaunchSAEAppVersion: String? public var lastCompletedLaunchNSEAppVersion: String? public var firstMainAppLaunchDateAfterUpdate: Date? public var buildDate: Date = Date() public func mainAppLaunchDidComplete() {} public func saeLaunchDidComplete() {} public func nseLaunchDidComplete() {} } #endif