@capgo/cordova-updater 8.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) hide show
  1. package/LICENSE +373 -0
  2. package/README.md +82 -0
  3. package/dist/esm/definitions.d.ts +2684 -0
  4. package/dist/esm/definitions.js +109 -0
  5. package/dist/esm/definitions.js.map +1 -0
  6. package/dist/esm/exec.d.ts +4 -0
  7. package/dist/esm/exec.js +25 -0
  8. package/dist/esm/exec.js.map +1 -0
  9. package/dist/esm/history.d.ts +1 -0
  10. package/dist/esm/history.js +283 -0
  11. package/dist/esm/history.js.map +1 -0
  12. package/dist/esm/index.d.ts +57 -0
  13. package/dist/esm/index.js +68 -0
  14. package/dist/esm/index.js.map +1 -0
  15. package/dist/esm/plugin.d.ts +57 -0
  16. package/dist/esm/plugin.js +15 -0
  17. package/dist/esm/plugin.js.map +1 -0
  18. package/dist/plugin.cjs.js +217 -0
  19. package/dist/plugin.cjs.js.map +1 -0
  20. package/dist/plugin.js +222 -0
  21. package/dist/plugin.js.map +1 -0
  22. package/package.json +96 -0
  23. package/plugin.xml +207 -0
  24. package/src/android/CordovaUpdaterPlugin.java +5662 -0
  25. package/src/android/UpdaterPathHandler.java +99 -0
  26. package/src/android/app/capgo/cordova/updater/AndroidAppExitReporter.java +92 -0
  27. package/src/android/app/capgo/cordova/updater/AppLifecycleObserver.java +115 -0
  28. package/src/android/app/capgo/cordova/updater/BundleInfo.java +246 -0
  29. package/src/android/app/capgo/cordova/updater/BundleStatus.java +45 -0
  30. package/src/android/app/capgo/cordova/updater/Callback.java +13 -0
  31. package/src/android/app/capgo/cordova/updater/CapgoUpdater.java +3404 -0
  32. package/src/android/app/capgo/cordova/updater/CordovaUpdaterConfig.java +56 -0
  33. package/src/android/app/capgo/cordova/updater/CryptoCipher.java +462 -0
  34. package/src/android/app/capgo/cordova/updater/DataManager.java +44 -0
  35. package/src/android/app/capgo/cordova/updater/DelayCondition.java +56 -0
  36. package/src/android/app/capgo/cordova/updater/DelayUntilNext.java +14 -0
  37. package/src/android/app/capgo/cordova/updater/DelayUpdateUtils.java +307 -0
  38. package/src/android/app/capgo/cordova/updater/DeviceIdHelper.java +230 -0
  39. package/src/android/app/capgo/cordova/updater/DownloadService.java +1219 -0
  40. package/src/android/app/capgo/cordova/updater/DownloadWorkerManager.java +260 -0
  41. package/src/android/app/capgo/cordova/updater/InternalUtils.java +54 -0
  42. package/src/android/app/capgo/cordova/updater/Logger.java +345 -0
  43. package/src/android/app/capgo/cordova/updater/ShakeDetector.java +72 -0
  44. package/src/android/app/capgo/cordova/updater/ShakeMenu.java +39 -0
  45. package/src/android/app/capgo/cordova/updater/ThreeFingerPinchDetector.java +323 -0
  46. package/src/android/app/capgo/cordova/updater/compat/JSArray.java +9 -0
  47. package/src/android/app/capgo/cordova/updater/compat/JSObject.java +83 -0
  48. package/src/android/app/capgo/cordova/updater/compat/PluginCall.java +165 -0
  49. package/src/android/capgo-cordova-updater.gradle +19 -0
  50. package/src/android/capgo-updater.xml +3 -0
  51. package/src/android/test/app/capgo/cordova/updater/CapacitorUpdaterUnitTest.java.skip +3039 -0
  52. package/src/android/test/app/capgo/cordova/updater/DelayUpdateUtilsTest.java +177 -0
  53. package/src/android/test/app/capgo/cordova/updater/NativeContractTest.java +105 -0
  54. package/src/ios/AES.swift +216 -0
  55. package/src/ios/AppHealthTracker.swift +82 -0
  56. package/src/ios/BundleInfo.swift +185 -0
  57. package/src/ios/BundleStatus.swift +127 -0
  58. package/src/ios/CapgoRawRsa.swift +36 -0
  59. package/src/ios/CapgoUpdater.swift +3544 -0
  60. package/src/ios/CordovaCompat.swift +11 -0
  61. package/src/ios/CordovaPluginCall.swift +190 -0
  62. package/src/ios/CordovaUpdaterCommands.swift +270 -0
  63. package/src/ios/CordovaUpdaterPlugin.swift +4706 -0
  64. package/src/ios/CryptoCipher.swift +326 -0
  65. package/src/ios/DelayCondition.swift +74 -0
  66. package/src/ios/DelayUntilNext.swift +30 -0
  67. package/src/ios/DelayUpdateUtils.swift +257 -0
  68. package/src/ios/DeviceIdHelper.swift +120 -0
  69. package/src/ios/Info.plist +28 -0
  70. package/src/ios/InternalUtils.swift +471 -0
  71. package/src/ios/Logger.swift +283 -0
  72. package/src/ios/RSA.swift +96 -0
  73. package/src/ios/ShakeMenu.swift +52 -0
  74. package/src/ios/UpdaterSchemeHandler.swift +81 -0
  75. package/src/ios/UserDefaultsExtension.swift +46 -0
  76. package/src/ios/WebViewStatsReporter.swift +303 -0
@@ -0,0 +1,326 @@
1
+ /*
2
+ * This Source Code Form is subject to the terms of the Mozilla Public
3
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
4
+ * file, You can obtain one at https://mozilla.org/MPL/2.0/.
5
+ */
6
+
7
+ import Foundation
8
+ import CryptoKit
9
+
10
+ public struct CryptoCipher {
11
+ private static var logger: Logger!
12
+
13
+ public static func setLogger(_ logger: Logger) {
14
+ self.logger = logger
15
+ }
16
+
17
+ private static func hexStringToData(_ hex: String) -> Data? {
18
+ var data = Data()
19
+ var hexIterator = hex.makeIterator()
20
+ while let char1 = hexIterator.next(), let char2 = hexIterator.next() {
21
+ guard let byte = UInt8(String([char1, char2]), radix: 16) else {
22
+ return nil
23
+ }
24
+ data.append(byte)
25
+ }
26
+ return data
27
+ }
28
+
29
+ private static func isHexString(_ str: String) -> Bool {
30
+ let hexCharacterSet = CharacterSet(charactersIn: "0123456789abcdefABCDEF")
31
+ return str.unicodeScalars.allSatisfy { hexCharacterSet.contains($0) }
32
+ }
33
+
34
+ public static func decryptChecksum(checksum: String, publicKey: String) throws -> String {
35
+ if publicKey.isEmpty {
36
+ logger.info("No encryption set (public key) ignored")
37
+ return checksum
38
+ }
39
+ do {
40
+ // Determine if input is hex or base64 encoded
41
+ // Hex strings only contain 0-9 and a-f, while base64 contains other characters
42
+ let checksumBytes: Data
43
+ let detectedFormat: String
44
+ if isHexString(checksum) {
45
+ // Hex encoded (new format from CLI for plugin versions >= 5.30.0, 6.30.0, 7.30.0)
46
+ guard let hexData = hexStringToData(checksum) else {
47
+ logger.error("Cannot decode checksum as hex")
48
+ logger.debug("Checksum value: \(checksum)")
49
+ throw CustomError.cannotDecode
50
+ }
51
+ checksumBytes = hexData
52
+ detectedFormat = "hex"
53
+ } else {
54
+ // TODO: remove backwards compatibility
55
+ // Base64 encoded (old format for backwards compatibility)
56
+ guard let base64Data = Data(base64Encoded: checksum) else {
57
+ logger.error("Cannot decode checksum as base64")
58
+ logger.debug("Checksum value: \(checksum)")
59
+ throw CustomError.cannotDecode
60
+ }
61
+ checksumBytes = base64Data
62
+ detectedFormat = "base64"
63
+ }
64
+ // swiftlint:disable:next line_length
65
+ logger.debug("Received checksum format: \(detectedFormat) (length: \(checksum.count) chars, \(checksumBytes.count) bytes)")
66
+
67
+ if checksumBytes.isEmpty {
68
+ logger.error("Decoded checksum is empty")
69
+ throw CustomError.cannotDecode
70
+ }
71
+
72
+ // RSA-2048 encrypted data must be exactly 256 bytes
73
+ // If the checksum is not 256 bytes, the bundle was not encrypted properly
74
+ if checksumBytes.count != 256 {
75
+ // swiftlint:disable:next line_length
76
+ logger.error("Checksum is not RSA encrypted (size: \(checksumBytes.count) bytes, expected 256 for RSA-2048). Bundle must be uploaded with encryption when public key is configured.")
77
+ throw CustomError.cannotDecode
78
+ }
79
+
80
+ guard let rsaPublicKey = RSAPublicKey.load(rsaPublicKey: publicKey) else {
81
+ logger.error("The public key is not a valid RSA Public key")
82
+ throw CustomError.cannotDecode
83
+ }
84
+
85
+ guard let decryptedChecksum = rsaPublicKey.decrypt(data: checksumBytes) else {
86
+ logger.error("decryptChecksum fail")
87
+ throw NSError(domain: "Failed to decrypt session key data", code: 2, userInfo: nil)
88
+ }
89
+
90
+ // Return as hex string to match calcChecksum output format
91
+ let result = decryptedChecksum.map { String(format: "%02x", $0) }.joined()
92
+
93
+ // Detect checksum algorithm based on length
94
+ let detectedAlgorithm: String
95
+ if decryptedChecksum.count == 32 {
96
+ detectedAlgorithm = "SHA-256"
97
+ } else if decryptedChecksum.count == 4 {
98
+ detectedAlgorithm = "CRC32 (deprecated)"
99
+ logger.error("CRC32 checksum detected - deprecated algorithm")
100
+ } else {
101
+ detectedAlgorithm = "unknown (\(decryptedChecksum.count) bytes)"
102
+ logger.error("Unknown checksum algorithm detected")
103
+ logger.debug("Byte count: \(decryptedChecksum.count), Expected: 32 (SHA-256)")
104
+ }
105
+ // swiftlint:disable:next line_length
106
+ logger.debug("Decrypted checksum: \(detectedAlgorithm) hex format (length: \(result.count) chars, \(decryptedChecksum.count) bytes)")
107
+ return result
108
+ } catch {
109
+ logger.error("Checksum decryption failed")
110
+ logger.debug("Error: \(error.localizedDescription)")
111
+ throw CustomError.cannotDecode
112
+ }
113
+ }
114
+
115
+ /// Detect checksum algorithm based on hex string length.
116
+ /// SHA-256 = 64 hex chars (32 bytes)
117
+ /// CRC32 = 8 hex chars (4 bytes)
118
+ public static func detectChecksumAlgorithm(_ hexChecksum: String) -> String {
119
+ if hexChecksum.isEmpty {
120
+ return "empty"
121
+ }
122
+ let len = hexChecksum.count
123
+ if len == 64 {
124
+ return "SHA-256"
125
+ } else if len == 8 {
126
+ return "CRC32 (deprecated)"
127
+ } else {
128
+ return "unknown (\(len) hex chars)"
129
+ }
130
+ }
131
+
132
+ /// Log checksum info and warn if deprecated algorithm detected.
133
+ public static func logChecksumInfo(label: String, hexChecksum: String) {
134
+ let algorithm = detectChecksumAlgorithm(hexChecksum)
135
+ logger.debug("\(label): \(algorithm) hex format (length: \(hexChecksum.count) chars)")
136
+ if algorithm.contains("CRC32") {
137
+ logger.error("CRC32 checksum detected - deprecated algorithm")
138
+ } else if algorithm.contains("unknown") {
139
+ logger.error("Unknown checksum algorithm detected")
140
+ logger.debug("Char count: \(hexChecksum.count), Expected: 64 (SHA-256)")
141
+ }
142
+ }
143
+
144
+ /// 256 KiB: one size for checksum, copy, and decode.
145
+ /// 64 workers * 256 KiB = 16 MiB for one buffer; AES/Brotli hold two (~32 MiB).
146
+ static let ioBufferBytesValue = 256 * 1024
147
+
148
+ static func ioBufferBytes() -> Int {
149
+ return ioBufferBytesValue
150
+ }
151
+
152
+ static func checksumBufferBytes() -> Int {
153
+ return ioBufferBytesValue
154
+ }
155
+
156
+ static func copyBufferBytes() -> Int {
157
+ return ioBufferBytesValue
158
+ }
159
+
160
+ public static func calcChecksum(filePath: URL) -> String {
161
+ let bufferSize = checksumBufferBytes()
162
+ var sha256 = SHA256()
163
+
164
+ do {
165
+ let fileHandle: FileHandle
166
+ do {
167
+ fileHandle = try FileHandle(forReadingFrom: filePath)
168
+ } catch {
169
+ logger.error("Cannot open file for checksum calculation")
170
+ logger.debug("Path: \(filePath.path), Error: \(error)")
171
+ return ""
172
+ }
173
+
174
+ defer {
175
+ do {
176
+ try fileHandle.close()
177
+ } catch {
178
+ logger.error("Error closing file during checksum")
179
+ logger.debug("Error: \(error)")
180
+ }
181
+ }
182
+
183
+ while autoreleasepool(invoking: {
184
+ let fileData: Data
185
+ do {
186
+ fileData = try fileHandle.read(upToCount: bufferSize) ?? Data()
187
+ } catch {
188
+ logger.error("Error reading file during checksum")
189
+ logger.debug("Error: \(error)")
190
+ return false
191
+ }
192
+
193
+ if fileData.count > 0 {
194
+ sha256.update(data: fileData)
195
+ return true // Continue
196
+ } else {
197
+ return false // End of file
198
+ }
199
+ }) {}
200
+
201
+ return hexString(from: sha256)
202
+ } catch {
203
+ logger.error("Cannot calculate checksum")
204
+ logger.debug("Path: \(filePath.path), Error: \(error)")
205
+ return ""
206
+ }
207
+ }
208
+
209
+ final class RunningChecksum {
210
+ private var sha256 = SHA256()
211
+
212
+ func update(_ data: Data) {
213
+ guard !data.isEmpty else {
214
+ return
215
+ }
216
+ sha256.update(data: data)
217
+ }
218
+
219
+ func hex() -> String {
220
+ CryptoCipher.hexString(from: sha256)
221
+ }
222
+ }
223
+
224
+ static func hexString(from sha256: SHA256) -> String {
225
+ var copy = sha256
226
+ return copy.finalize().compactMap { String(format: "%02x", $0) }.joined()
227
+ }
228
+
229
+ static func shortPathKey(_ fileName: String) -> String {
230
+ var sha256 = SHA256()
231
+ sha256.update(data: Data(fileName.utf8))
232
+ return String(hexString(from: sha256).prefix(16))
233
+ }
234
+
235
+ public static func decryptFile(filePath: URL, publicKey: String, sessionKey: String, version: String) throws {
236
+ if publicKey.isEmpty || sessionKey.isEmpty || sessionKey.components(separatedBy: ":").count != 2 {
237
+ logger.info("Encryption not set, no public key or session, ignored")
238
+ return
239
+ }
240
+
241
+ if !publicKey.hasPrefix("-----BEGIN RSA PUBLIC KEY-----") {
242
+ logger.error("The public key is not a valid RSA Public key")
243
+ return
244
+ }
245
+
246
+ do {
247
+ guard let rsaPublicKey = RSAPublicKey.load(rsaPublicKey: publicKey) else {
248
+ logger.error("The public key is not a valid RSA Public key")
249
+ throw CustomError.cannotDecode
250
+ }
251
+
252
+ let sessionKeyComponents = sessionKey.components(separatedBy: ":")
253
+ let ivBase64 = sessionKeyComponents[0]
254
+ let encryptedKeyBase64 = sessionKeyComponents[1]
255
+
256
+ guard let ivData = Data(base64Encoded: ivBase64) else {
257
+ logger.error("Cannot decode sessionKey IV")
258
+ logger.debug("IV value: \(ivBase64)")
259
+ throw CustomError.cannotDecode
260
+ }
261
+
262
+ if ivData.count != 16 {
263
+ logger.error("IV data has invalid length")
264
+ logger.debug("Length: \(ivData.count), Expected: 16")
265
+ throw CustomError.cannotDecode
266
+ }
267
+
268
+ guard let sessionKeyDataEncrypted = Data(base64Encoded: encryptedKeyBase64) else {
269
+ logger.error("Cannot decode sessionKey data")
270
+ logger.debug("Key value: \(encryptedKeyBase64)")
271
+ throw NSError(domain: "Invalid session key data", code: 1, userInfo: nil)
272
+ }
273
+
274
+ guard let sessionKeyDataDecrypted = rsaPublicKey.decrypt(data: sessionKeyDataEncrypted) else {
275
+ logger.error("Failed to decrypt session key data")
276
+ throw NSError(domain: "Failed to decrypt session key data", code: 2, userInfo: nil)
277
+ }
278
+
279
+ if sessionKeyDataDecrypted.count != 16 {
280
+ logger.error("Decrypted session key has invalid length")
281
+ logger.debug("Length: \(sessionKeyDataDecrypted.count), Expected: 16")
282
+ throw NSError(domain: "Invalid decrypted session key", code: 5, userInfo: nil)
283
+ }
284
+
285
+ let aesPrivateKey = AES128Key(iv: ivData, aes128Key: sessionKeyDataDecrypted, logger: logger)
286
+
287
+ let encryptedSize = (try FileManager.default.attributesOfItem(atPath: filePath.path)[.size] as? NSNumber)?.uint64Value ?? 0
288
+ if encryptedSize == 0 {
289
+ logger.error("Encrypted file data is empty")
290
+ throw NSError(domain: "Empty encrypted data", code: 6, userInfo: nil)
291
+ }
292
+
293
+ try aesPrivateKey.decrypt(from: filePath, to: filePath)
294
+ let decryptedSize = (try FileManager.default.attributesOfItem(atPath: filePath.path)[.size] as? NSNumber)?.uint64Value ?? 0
295
+ if decryptedSize == 0 {
296
+ logger.error("Decrypted data is empty")
297
+ throw NSError(domain: "Empty decrypted data", code: 7, userInfo: nil)
298
+ }
299
+
300
+ } catch {
301
+ logger.error("File decryption failed")
302
+ throw CustomError.cannotDecode
303
+ }
304
+ }
305
+
306
+ /// Get first 20 characters of the public key for identification
307
+ /// Returns 20-character string or empty string if key is invalid/empty
308
+ /// The first 12 chars are always "MIIBCgKCAQEA" for RSA 2048-bit keys,
309
+ /// so the unique part starts at character 13
310
+ public static func calcKeyId(publicKey: String) -> String {
311
+ if publicKey.isEmpty {
312
+ return ""
313
+ }
314
+
315
+ // Remove PEM headers and whitespace to get the raw key data
316
+ let cleanedKey = publicKey
317
+ .replacingOccurrences(of: "-----BEGIN RSA PUBLIC KEY-----", with: "")
318
+ .replacingOccurrences(of: "-----END RSA PUBLIC KEY-----", with: "")
319
+ .replacingOccurrences(of: "\n", with: "")
320
+ .replacingOccurrences(of: "\r", with: "")
321
+ .replacingOccurrences(of: " ", with: "")
322
+
323
+ // Return first 20 characters of the base64-encoded key
324
+ return String(cleanedKey.prefix(20))
325
+ }
326
+ }
@@ -0,0 +1,74 @@
1
+ /*
2
+ * This Source Code Form is subject to the terms of the Mozilla Public
3
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
4
+ * file, You can obtain one at https://mozilla.org/MPL/2.0/.
5
+ */
6
+
7
+ //
8
+ // DelayCondition.swift
9
+ // Plugin
10
+ //
11
+ // Created by Luca Peruzzo on 12/09/22.
12
+ // Copyright © 2022 Capgo. All rights reserved.
13
+ //
14
+
15
+ import Foundation
16
+
17
+ private func delayUntilNextValue(value: String) -> DelayUntilNext {
18
+ switch value {
19
+ case "background": return .background
20
+ case "kill": return .kill
21
+ case "nativeVersion": return .nativeVersion
22
+ case "date": return .date
23
+ default:
24
+ return .background
25
+ }
26
+ }
27
+
28
+ @objc public class DelayCondition: NSObject, Decodable, Encodable {
29
+ private let kind: DelayUntilNext
30
+ private let value: String?
31
+
32
+ convenience init(kind: String, value: String?) {
33
+ self.init(kind: delayUntilNextValue(value: kind), value: value)
34
+ }
35
+
36
+ init(kind: DelayUntilNext, value: String?) {
37
+ self.kind = kind
38
+ self.value = value
39
+ }
40
+
41
+ public required init(from decoder: Decoder) throws {
42
+ let values: KeyedDecodingContainer<DelayCondition.CodingKeys> = try decoder.container(keyedBy: CodingKeys.self)
43
+ kind = try values.decode(DelayUntilNext.self, forKey: .kind)
44
+ value = try values.decode(String.self, forKey: .value)
45
+ }
46
+
47
+ enum CodingKeys: String, CodingKey {
48
+ case kind, value
49
+ }
50
+
51
+ public func getKind() -> String {
52
+ return self.kind.description
53
+ }
54
+
55
+ public func getValue() -> String? {
56
+ return self.value
57
+ }
58
+
59
+ public func toJSON() -> [String: String] {
60
+ return [
61
+ "kind": self.getKind(),
62
+ "value": self.getValue() ?? ""
63
+ ]
64
+ }
65
+
66
+ public static func == (lhs: DelayCondition, rhs: DelayCondition) -> Bool {
67
+ return lhs.getKind() == rhs.getKind() && lhs.getValue() == rhs.getValue()
68
+ }
69
+
70
+ public func toString() -> String {
71
+ return "{ \"kind\": \"\(self.getKind())\", \"value\": \"\(self.getValue() ?? "")\"}"
72
+ }
73
+
74
+ }
@@ -0,0 +1,30 @@
1
+ /*
2
+ * This Source Code Form is subject to the terms of the Mozilla Public
3
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
4
+ * file, You can obtain one at https://mozilla.org/MPL/2.0/.
5
+ */
6
+
7
+ //
8
+ // DelayUntilNext.swift
9
+ // Plugin
10
+ //
11
+ // Created by Luca Peruzzo on 12/09/22.
12
+ // Copyright © 2022 Capgo. All rights reserved.
13
+ //
14
+
15
+ import Foundation
16
+ enum DelayUntilNext: Decodable, Encodable, CustomStringConvertible {
17
+ case background
18
+ case kill
19
+ case nativeVersion
20
+ case date
21
+
22
+ var description: String {
23
+ switch self {
24
+ case .background: return "background"
25
+ case .kill: return "kill"
26
+ case .nativeVersion: return "nativeVersion"
27
+ case .date: return "date"
28
+ }
29
+ }
30
+ }
@@ -0,0 +1,257 @@
1
+ /*
2
+ * This Source Code Form is subject to the terms of the Mozilla Public
3
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
4
+ * file, You can obtain one at https://mozilla.org/MPL/2.0/.
5
+ */
6
+
7
+ //
8
+ // DelayUpdateUtils.swift
9
+ // Plugin
10
+ //
11
+ // Created by Auto-generated based on Android implementation
12
+ // Copyright © 2024 Capgo. All rights reserved.
13
+ //
14
+
15
+ import Foundation
16
+ import Version
17
+
18
+ public class DelayUpdateUtils {
19
+
20
+ // swiftlint:disable identifier_name
21
+ static let DELAY_CONDITION_PREFERENCES = "DELAY_CONDITION_PREFERENCES_CAPGO"
22
+ static let BACKGROUND_TIMESTAMP_KEY = "BACKGROUND_TIMESTAMP_KEY_CAPGO"
23
+ // swiftlint:enable identifier_name
24
+ private let logger: Logger
25
+
26
+ private let currentVersionNative: Version
27
+
28
+ public enum CancelDelaySource {
29
+ case killed
30
+ case background
31
+ case foreground
32
+
33
+ var description: String {
34
+ switch self {
35
+ case .killed: return "KILLED"
36
+ case .background: return "BACKGROUND"
37
+ case .foreground: return "FOREGROUND"
38
+ }
39
+ }
40
+ }
41
+
42
+ public init(currentVersionNative: Version, logger: Logger) {
43
+ self.currentVersionNative = currentVersionNative
44
+ self.logger = logger
45
+ }
46
+
47
+ public func checkCancelDelay(source: CancelDelaySource) {
48
+ let delayUpdatePreferences = UserDefaults.standard.string(
49
+ forKey: DelayUpdateUtils.DELAY_CONDITION_PREFERENCES) ?? "[]"
50
+ let delayConditionList: [DelayCondition] = fromJsonArr(json: delayUpdatePreferences).compactMap { obj in
51
+ guard let kind = obj.value(forKey: "kind") as? String else { return nil }
52
+ let value: String? = obj.value(forKey: "value") as? String
53
+ return DelayCondition(kind: kind, value: value)
54
+ }
55
+
56
+ var delayConditionListToKeep: [DelayCondition] = []
57
+ var index = 0
58
+
59
+ for condition in delayConditionList {
60
+ let kind = condition.getKind()
61
+ let value = condition.getValue()
62
+
63
+ switch kind {
64
+ case "background":
65
+ if source == .foreground {
66
+ let backgroundedAt = getBackgroundTimestamp()
67
+ let now = Int64(Date().timeIntervalSince1970 * 1000) // Convert to milliseconds
68
+ let delta = max(0, now - backgroundedAt)
69
+
70
+ var longValue: Int64 = 0
71
+ if let value = value, !value.isEmpty {
72
+ longValue = Int64(value) ?? 0
73
+ }
74
+
75
+ if delta > longValue {
76
+ // swiftlint:disable:next line_length
77
+ logger.info("Background condition (value: \(value ?? "")) deleted at index \(index). Delta: \(delta), longValue: \(longValue)")
78
+ } else {
79
+ delayConditionListToKeep.append(condition)
80
+ // swiftlint:disable:next line_length
81
+ logger.info("Background delay (value: \(value ?? "")) condition kept at index \(index) (source: \(source.description))")
82
+ }
83
+ } else {
84
+ delayConditionListToKeep.append(condition)
85
+ // swiftlint:disable:next line_length
86
+ logger.info("Background delay (value: \(value ?? "")) condition kept at index \(index) (source: \(source.description))")
87
+ }
88
+
89
+ case "kill":
90
+ if source == .killed {
91
+ logger.info("Kill delay (value: \(value ?? "")) removed at index \(index) after app kill")
92
+ } else {
93
+ delayConditionListToKeep.append(condition)
94
+ // swiftlint:disable:next line_length
95
+ logger.info("Kill delay (value: \(value ?? "")) condition kept at index \(index) (source: \(source.description))")
96
+ }
97
+
98
+ case "date":
99
+ if let value = value, !value.isEmpty {
100
+ if let date = parseDateCondition(value) {
101
+ if Date() > date {
102
+ // swiftlint:disable:next line_length
103
+ logger.info("Date delay (value: \(value)) condition removed due to expired date at index \(index)")
104
+ } else {
105
+ delayConditionListToKeep.append(condition)
106
+ logger.info("Date delay (value: \(value)) kept at index \(index)")
107
+ }
108
+ } else {
109
+ // swiftlint:disable:next line_length
110
+ logger.error("Date delay (value: \(value)) condition removed due to parsing issue at index \(index)")
111
+ }
112
+ } else {
113
+ // swiftlint:disable:next line_length
114
+ logger.error("Date delay (value: \(value ?? "")) condition removed due to empty value at index \(index)")
115
+ }
116
+
117
+ case "nativeVersion":
118
+ if let value = value, !value.isEmpty {
119
+ do {
120
+ let versionLimit = try Version(value)
121
+ if currentVersionNative >= versionLimit {
122
+ // swiftlint:disable:next line_length
123
+ logger.info("Native version delay (value: \(value)) condition removed due to above limit at index \(index)")
124
+ } else {
125
+ delayConditionListToKeep.append(condition)
126
+ logger.info("Native version delay (value: \(value)) kept at index \(index)")
127
+ }
128
+ } catch {
129
+ // swiftlint:disable:next line_length
130
+ logger.error("Native version delay (value: \(value)) condition removed due to parsing issue at index \(index): \(error)")
131
+ }
132
+ } else {
133
+ // swiftlint:disable:next line_length
134
+ logger.error("Native version delay (value: \(value ?? "")) condition removed due to empty value at index \(index)")
135
+ }
136
+
137
+ default:
138
+ logger.error("Unknown delay condition kind: \(kind) at index \(index)")
139
+ }
140
+
141
+ index += 1
142
+ }
143
+
144
+ if !delayConditionListToKeep.isEmpty {
145
+ let json = toJson(object: delayConditionListToKeep.map { $0.toJSON() })
146
+ _ = setMultiDelay(delayConditions: json)
147
+ } else {
148
+ // Clear all delay conditions if none are left to keep
149
+ _ = cancelDelay(source: "checkCancelDelay")
150
+ }
151
+ }
152
+
153
+ public func setMultiDelay(delayConditions: String) -> Bool {
154
+ do {
155
+ UserDefaults.standard.set(delayConditions, forKey: DelayUpdateUtils.DELAY_CONDITION_PREFERENCES)
156
+ UserDefaults.standard.synchronize()
157
+ logger.info("Delay update saved")
158
+ return true
159
+ } catch {
160
+ logger.error("Failed to delay update, [Error calling 'setMultiDelay()']: \(error)")
161
+ return false
162
+ }
163
+ }
164
+
165
+ public func setBackgroundTimestamp(_ backgroundTimestamp: Int64) {
166
+ do {
167
+ UserDefaults.standard.set(backgroundTimestamp, forKey: DelayUpdateUtils.BACKGROUND_TIMESTAMP_KEY)
168
+ UserDefaults.standard.synchronize()
169
+ logger.info("Background timestamp saved")
170
+ } catch {
171
+ logger.error("Failed to save background timestamp, [Error calling 'setBackgroundTimestamp()']: \(error)")
172
+ }
173
+ }
174
+
175
+ public func unsetBackgroundTimestamp() {
176
+ do {
177
+ UserDefaults.standard.removeObject(forKey: DelayUpdateUtils.BACKGROUND_TIMESTAMP_KEY)
178
+ UserDefaults.standard.synchronize()
179
+ logger.info("Background timestamp removed")
180
+ } catch {
181
+ // swiftlint:disable:next line_length
182
+ logger.error("Failed to remove background timestamp, [Error calling 'unsetBackgroundTimestamp()']: \(error)")
183
+ }
184
+ }
185
+
186
+ private func getBackgroundTimestamp() -> Int64 {
187
+ do {
188
+ let key = DelayUpdateUtils.BACKGROUND_TIMESTAMP_KEY
189
+ let timestamp = UserDefaults.standard.object(forKey: key) as? Int64 ?? 0
190
+ return timestamp
191
+ } catch {
192
+ logger.error("Failed to get background timestamp, [Error calling 'getBackgroundTimestamp()']: \(error)")
193
+ return 0
194
+ }
195
+ }
196
+
197
+ public func cancelDelay(source: String) -> Bool {
198
+ do {
199
+ UserDefaults.standard.removeObject(forKey: DelayUpdateUtils.DELAY_CONDITION_PREFERENCES)
200
+ UserDefaults.standard.synchronize()
201
+ logger.info("All delays canceled from \(source)")
202
+ return true
203
+ } catch {
204
+ logger.error("Failed to cancel update delay: \(error)")
205
+ return false
206
+ }
207
+ }
208
+
209
+ // MARK: - Helper methods
210
+
211
+ private func parseDateCondition(_ value: String) -> Date? {
212
+ let withFractionalSeconds = ISO8601DateFormatter()
213
+ withFractionalSeconds.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
214
+ if let date = withFractionalSeconds.date(from: value) {
215
+ return date
216
+ }
217
+
218
+ let withoutFractionalSeconds = ISO8601DateFormatter()
219
+ withoutFractionalSeconds.formatOptions = [.withInternetDateTime]
220
+ if let date = withoutFractionalSeconds.date(from: value) {
221
+ return date
222
+ }
223
+
224
+ // Legacy fallback for strings without timezone.
225
+ for format in ["yyyy-MM-dd'T'HH:mm:ss.SSS", "yyyy-MM-dd'T'HH:mm:ss"] {
226
+ let formatter = DateFormatter()
227
+ formatter.locale = Locale(identifier: "en_US_POSIX")
228
+ formatter.calendar = Calendar(identifier: .gregorian)
229
+ formatter.timeZone = .current
230
+ formatter.isLenient = false
231
+ formatter.dateFormat = format
232
+ if let date = formatter.date(from: value) {
233
+ return date
234
+ }
235
+ }
236
+
237
+ return nil
238
+ }
239
+
240
+ private func toJson(object: Any) -> String {
241
+ guard let data = try? JSONSerialization.data(withJSONObject: object, options: []) else {
242
+ return ""
243
+ }
244
+ return String(data: data, encoding: String.Encoding.utf8) ?? ""
245
+ }
246
+
247
+ private func fromJsonArr(json: String) -> [NSObject] {
248
+ guard let jsonData = json.data(using: .utf8) else {
249
+ return []
250
+ }
251
+ let object = try? JSONSerialization.jsonObject(
252
+ with: jsonData,
253
+ options: .mutableContainers
254
+ ) as? [NSObject]
255
+ return object ?? []
256
+ }
257
+ }