react-native-instantpay-code-push 1.1.8 → 1.2.0

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.
@@ -0,0 +1,13 @@
1
+ //
2
+ // NotificationExtension.swift
3
+ // InstantpayCodePush
4
+ //
5
+ // Created by Dhananjay kumar on 09/02/26.
6
+ //
7
+
8
+ import Foundation
9
+
10
+ extension Notification.Name {
11
+ static let downloadProgressUpdate = Notification.Name("IpayCodePushDownloadProgressUpdate")
12
+ static let downloadDidFinish = Notification.Name("IpayCodePushDownloadDidFinish")
13
+ }
@@ -0,0 +1,358 @@
1
+ //
2
+ // SignatureVerifier.swift
3
+ // InstantpayCodePush
4
+ //
5
+ // Created by Dhananjay kumar on 06/02/26.
6
+ //
7
+
8
+ import Foundation
9
+ import Security
10
+
11
+ /// Prefix for signed file hash format.
12
+ private let SIGNED_HASH_PREFIX = "sig:"
13
+
14
+
15
+ /// Error types for signature verification failures.
16
+ public enum SignatureVerificationError: Error, CustomNSError {
17
+ case publicKeyNotConfigured
18
+ case invalidPublicKeyFormat
19
+ case missingFileHash
20
+ case invalidSignatureFormat
21
+ case signatureVerificationFailed
22
+ case fileHashMismatch
23
+ case fileReadFailed
24
+ case unsignedNotAllowed
25
+ case securityFrameworkError(OSStatus)
26
+
27
+ // CustomNSError protocol implementation
28
+ public static var errorDomain: String {
29
+ return "IpayCodePush.SignatureVerificationError"
30
+ }
31
+
32
+ public var errorCode: Int {
33
+ return 0
34
+ }
35
+
36
+ public var errorCodeString: String {
37
+ switch self {
38
+ case .publicKeyNotConfigured: return "PUBLIC_KEY_NOT_CONFIGURED"
39
+ case .invalidPublicKeyFormat: return "INVALID_PUBLIC_KEY_FORMAT"
40
+ case .missingFileHash: return "MISSING_FILE_HASH"
41
+ case .invalidSignatureFormat: return "INVALID_SIGNATURE_FORMAT"
42
+ case .signatureVerificationFailed: return "SIGNATURE_VERIFICATION_FAILED"
43
+ case .fileHashMismatch: return "FILE_HASH_MISMATCH"
44
+ case .fileReadFailed: return "FILE_READ_FAILED"
45
+ case .unsignedNotAllowed: return "UNSIGNED_NOT_ALLOWED"
46
+ case .securityFrameworkError: return "SECURITY_FRAMEWORK_ERROR"
47
+ }
48
+ }
49
+
50
+ public var errorUserInfo: [String: Any] {
51
+ var userInfo: [String: Any] = [:]
52
+
53
+ switch self {
54
+ case .publicKeyNotConfigured:
55
+ userInfo[NSLocalizedDescriptionKey] = "Public key not configured for signature verification"
56
+ userInfo[NSLocalizedRecoverySuggestionErrorKey] = "Add IPAY_CODE_PUSH_PUBLIC_KEY to Info.plist with your RSA public key"
57
+
58
+ case .invalidPublicKeyFormat:
59
+ userInfo[NSLocalizedDescriptionKey] = "Public key format is invalid"
60
+ userInfo[NSLocalizedRecoverySuggestionErrorKey] = "Ensure the public key is in PEM format (BEGIN PUBLIC KEY)"
61
+
62
+ case .missingFileHash:
63
+ userInfo[NSLocalizedDescriptionKey] = "File hash is missing or empty"
64
+ userInfo[NSLocalizedRecoverySuggestionErrorKey] = "Ensure the bundle update includes a valid file hash"
65
+
66
+ case .invalidSignatureFormat:
67
+ userInfo[NSLocalizedDescriptionKey] = "Signature format is invalid or corrupted"
68
+ userInfo[NSLocalizedRecoverySuggestionErrorKey] = "The signature data is malformed or cannot be decoded. Bundle may be corrupted"
69
+
70
+ case .signatureVerificationFailed:
71
+ userInfo[NSLocalizedDescriptionKey] = "Bundle signature verification failed"
72
+ userInfo[NSLocalizedRecoverySuggestionErrorKey] = "The bundle may be corrupted or tampered with. Rejecting update for security"
73
+
74
+ case .fileHashMismatch:
75
+ userInfo[NSLocalizedDescriptionKey] = "File hash verification failed"
76
+ userInfo[NSLocalizedRecoverySuggestionErrorKey] = "The bundle file hash does not match the expected value. File may be corrupted"
77
+
78
+ case .fileReadFailed:
79
+ userInfo[NSLocalizedDescriptionKey] = "Failed to read file for verification"
80
+ userInfo[NSLocalizedRecoverySuggestionErrorKey] = "Could not read file for hash verification"
81
+
82
+ case .unsignedNotAllowed:
83
+ userInfo[NSLocalizedDescriptionKey] = "Unsigned bundle not allowed when signing is enabled"
84
+ userInfo[NSLocalizedRecoverySuggestionErrorKey] = "Public key is configured but bundle is not signed. Rejecting update"
85
+
86
+ case .securityFrameworkError(let status):
87
+ userInfo[NSLocalizedDescriptionKey] = "Security framework error during verification (OSStatus: \(status))"
88
+ userInfo[NSLocalizedRecoverySuggestionErrorKey] = "Check public key format and signature data"
89
+ }
90
+
91
+ return userInfo
92
+ }
93
+ }
94
+
95
+ /**
96
+ * Service for verifying bundle integrity through hash or RSA-SHA256 signature verification.
97
+ * Uses iOS Security framework for cryptographic operations.
98
+ *
99
+ * fileHash format:
100
+ * - Signed: `sig:<base64_signature>` - Verify signature (implicitly verifies hash)
101
+ * - Unsigned: `<hex_hash>` - Verify SHA256 hash only
102
+ *
103
+ * Security rules:
104
+ * - null/empty fileHash → REJECT
105
+ * - sig:... + public key configured → verify signature → Install/REJECT
106
+ * - sig:... + public key NOT configured → REJECT (can't verify)
107
+ * - <hash> + public key configured → REJECT (unsigned not allowed)
108
+ * - <hash> + public key NOT configured → verify hash → Install/REJECT
109
+ */
110
+ public class SignatureVerifier {
111
+
112
+ public static let CLASS_TAG: String = "*SignatureVerifier"
113
+
114
+ /**
115
+ * Reads public key from Info.plist configuration.
116
+ * @return Public key PEM string or nil if not configured
117
+ */
118
+ private static func getPublicKeyFromConfig() -> String? {
119
+ guard let publicKeyPEM = Bundle.main.object(forInfoDictionaryKey: "IPAY_CODE_PUSH_PUBLIC_KEY") as? String else {
120
+ IpayCodePushHelper.logPrint(classTag: CLASS_TAG, log: "IPAY_CODE_PUSH_PUBLIC_KEY not found in Info.plist")
121
+ return nil
122
+ }
123
+ return publicKeyPEM
124
+ }
125
+
126
+ /**
127
+ * Checks if signing is enabled (public key is configured).
128
+ * @return true if public key is configured
129
+ */
130
+ public static func isSigningEnabled() -> Bool {
131
+ return getPublicKeyFromConfig() != nil
132
+ }
133
+
134
+ /**
135
+ * Checks if fileHash is in signed format (starts with "sig:").
136
+ * @param fileHash The file hash string to check
137
+ * @return true if signed format
138
+ */
139
+ public static func isSignedFormat(_ fileHash: String?) -> Bool {
140
+ guard let hash = fileHash else { return false }
141
+ return hash.hasPrefix(SIGNED_HASH_PREFIX)
142
+ }
143
+
144
+ /**
145
+ * Extracts signature from signed format fileHash.
146
+ * @param fileHash The signed file hash (sig:<signature>)
147
+ * @return Base64-encoded signature or nil if not signed format
148
+ */
149
+ public static func extractSignature(_ fileHash: String?) -> String? {
150
+ guard let hash = fileHash, isSignedFormat(hash) else { return nil }
151
+ return String(hash.dropFirst(SIGNED_HASH_PREFIX.count))
152
+ }
153
+
154
+ /**
155
+ * Verifies bundle integrity based on fileHash format.
156
+ * Determines verification mode by checking for "sig:" prefix.
157
+ *
158
+ * @param fileURL URL of the bundle file to verify
159
+ * @param fileHash Combined hash string (sig:<signature> or <hex_hash>)
160
+ * @return Result indicating verification success or failure with error
161
+ */
162
+ public static func verifyBundle(fileURL: URL, fileHash: String?) -> Result<Void, SignatureVerificationError> {
163
+ let signingEnabled = isSigningEnabled()
164
+
165
+ // Rule: null/empty fileHash → REJECT
166
+ guard let hash = fileHash, !hash.isEmpty else {
167
+ IpayCodePushHelper.logPrint(classTag: CLASS_TAG, log: "[SignatureVerifier] fileHash is null or empty. Rejecting update.")
168
+ return .failure(.missingFileHash)
169
+ }
170
+
171
+ if isSignedFormat(hash) {
172
+ // Signed format: sig:<signature>
173
+ guard let signature = extractSignature(hash) else {
174
+ IpayCodePushHelper.logPrint(classTag: CLASS_TAG, log: "[SignatureVerifier] Failed to extract signature from fileHash")
175
+ return .failure(.invalidSignatureFormat)
176
+ }
177
+
178
+ // Rule: sig:... + public key NOT configured → REJECT
179
+ guard signingEnabled else {
180
+ IpayCodePushHelper.logPrint(classTag: CLASS_TAG, log: "[SignatureVerifier] Signed bundle but public key not configured. Cannot verify.")
181
+ return .failure(.publicKeyNotConfigured)
182
+ }
183
+
184
+ // Rule: sig:... + public key configured → verify signature
185
+ return verifySignature(fileURL: fileURL, signatureBase64: signature)
186
+ } else {
187
+ // Unsigned format: <hex_hash>
188
+
189
+ // Rule: <hash> + public key configured → REJECT
190
+ if signingEnabled {
191
+ IpayCodePushHelper.logPrint(classTag: CLASS_TAG, log: "[SignatureVerifier] Unsigned bundle not allowed when signing is enabled. Rejecting.")
192
+ return .failure(.unsignedNotAllowed)
193
+ }
194
+
195
+ // Rule: <hash> + public key NOT configured → verify hash
196
+ return verifyHash(fileURL: fileURL, expectedHash: hash)
197
+ }
198
+ }
199
+
200
+ /**
201
+ * Verifies SHA256 hash of a file.
202
+ * @param fileURL URL of the file to verify
203
+ * @param expectedHash Expected SHA256 hash (hex string)
204
+ * @return Result indicating verification success or failure
205
+ */
206
+ public static func verifyHash(fileURL: URL, expectedHash: String) -> Result<Void, SignatureVerificationError> {
207
+ IpayCodePushHelper.logPrint(classTag: CLASS_TAG, log: "Verifying hash for file: \(fileURL.lastPathComponent)")
208
+
209
+ guard HashUtils.verifyHash(fileURL: fileURL, expectedHash: expectedHash) else {
210
+ IpayCodePushHelper.logPrint(classTag: CLASS_TAG, log: "Hash mismatch!")
211
+ return .failure(.fileHashMismatch)
212
+ }
213
+
214
+ IpayCodePushHelper.logPrint(classTag: CLASS_TAG, log: "✅ Hash verified successfully")
215
+ return .success(())
216
+ }
217
+
218
+ /**
219
+ * Verifies RSA-SHA256 signature of a file.
220
+ * Calculates the file hash internally and verifies the signature.
221
+ *
222
+ * @param fileURL URL of the file to verify
223
+ * @param signatureBase64 Base64-encoded RSA-SHA256 signature
224
+ * @return Result indicating verification success or failure with error
225
+ */
226
+ public static func verifySignature(fileURL: URL, signatureBase64: String) -> Result<Void, SignatureVerificationError> {
227
+
228
+ IpayCodePushHelper.logPrint(classTag: CLASS_TAG, log: "Verifying signature for file: \(fileURL.lastPathComponent)")
229
+
230
+ // Get public key from config
231
+ guard let publicKeyPEM = getPublicKeyFromConfig() else {
232
+ IpayCodePushHelper.logPrint(classTag: CLASS_TAG, log: "Cannot verify signature: public key not configured in Info.plist")
233
+ return .failure(.publicKeyNotConfigured)
234
+ }
235
+
236
+ // Convert PEM to SecKey
237
+ let publicKeyResult = createPublicKey(from: publicKeyPEM)
238
+ guard case .success(let publicKey) = publicKeyResult else {
239
+ if case .failure(let error) = publicKeyResult {
240
+ return .failure(error)
241
+ }
242
+ return .failure(.invalidPublicKeyFormat)
243
+ }
244
+
245
+ // Calculate file hash
246
+ guard let fileHashHex = HashUtils.calculateSHA256(fileURL: fileURL) else {
247
+ IpayCodePushHelper.logPrint(classTag: CLASS_TAG, log: "Failed to calculate file hash")
248
+ return .failure(.fileReadFailed)
249
+ }
250
+
251
+ IpayCodePushHelper.logPrint(classTag: CLASS_TAG, log: "Calculated file hash: \(fileHashHex)")
252
+
253
+ // Decode signature from base64
254
+ guard let signatureData = Data(base64Encoded: signatureBase64) else {
255
+ IpayCodePushHelper.logPrint(classTag: CLASS_TAG, log: "Failed to decode signature from base64")
256
+ return .failure(.invalidSignatureFormat)
257
+ }
258
+
259
+ // Convert hex fileHash to data
260
+ guard let fileHashData = dataFromHexString(fileHashHex) else {
261
+ IpayCodePushHelper.logPrint(classTag: CLASS_TAG, log: "Failed to convert fileHash from hex")
262
+ return .failure(.invalidSignatureFormat)
263
+ }
264
+
265
+ // Verify signature
266
+ let algorithm: SecKeyAlgorithm = .rsaSignatureMessagePKCS1v15SHA256
267
+
268
+ guard SecKeyIsAlgorithmSupported(publicKey, .verify, algorithm) else {
269
+ IpayCodePushHelper.logPrint(classTag: CLASS_TAG, log: "RSA-SHA256 algorithm not supported")
270
+ return .failure(.securityFrameworkError(-1))
271
+ }
272
+
273
+ var error: Unmanaged<CFError>?
274
+ let verified = SecKeyVerifySignature(
275
+ publicKey,
276
+ algorithm,
277
+ fileHashData as CFData,
278
+ signatureData as CFData,
279
+ &error
280
+ )
281
+
282
+ if let err = error?.takeRetainedValue() {
283
+ IpayCodePushHelper.logPrint(classTag: CLASS_TAG, log: "Verification failed: \(err)")
284
+ return .failure(.signatureVerificationFailed)
285
+ }
286
+
287
+ if verified {
288
+ IpayCodePushHelper.logPrint(classTag: CLASS_TAG, log: "✅ Signature verified successfully")
289
+ return .success(())
290
+ } else {
291
+ IpayCodePushHelper.logPrint(classTag: CLASS_TAG, log: "❌ Signature verification failed")
292
+ return .failure(.signatureVerificationFailed)
293
+ }
294
+ }
295
+
296
+ /**
297
+ * Converts PEM-formatted public key to SecKey.
298
+ * @param publicKeyPEM Public key in PEM format
299
+ * @return SecKey or error
300
+ */
301
+ private static func createPublicKey(from publicKeyPEM: String) -> Result<SecKey, SignatureVerificationError> {
302
+ // Remove PEM headers/footers and whitespace
303
+ var keyString = publicKeyPEM
304
+ .replacingOccurrences(of: "-----BEGIN PUBLIC KEY-----", with: "")
305
+ .replacingOccurrences(of: "-----END PUBLIC KEY-----", with: "")
306
+ .replacingOccurrences(of: "\\n", with: "")
307
+ .replacingOccurrences(of: "\n", with: "")
308
+ .replacingOccurrences(of: " ", with: "")
309
+
310
+ // Decode base64
311
+ guard let keyData = Data(base64Encoded: keyString) else {
312
+ IpayCodePushHelper.logPrint(classTag: CLASS_TAG, log: "Failed to decode public key from base64")
313
+ return .failure(.invalidPublicKeyFormat)
314
+ }
315
+
316
+ // SecKeyCreateWithData auto-detects key size from SPKI-formatted key data.
317
+ // This supports any valid RSA key size (2048, 3072, 4096-bit, etc.)
318
+ let attributes: [String: Any] = [
319
+ kSecAttrKeyType as String: kSecAttrKeyTypeRSA,
320
+ kSecAttrKeyClass as String: kSecAttrKeyClassPublic
321
+ ]
322
+
323
+ var error: Unmanaged<CFError>?
324
+ guard let secKey = SecKeyCreateWithData(keyData as CFData, attributes as CFDictionary, &error) else {
325
+ if let err = error?.takeRetainedValue() {
326
+ IpayCodePushHelper.logPrint(classTag: CLASS_TAG, log: "SecKeyCreateWithData failed: \(err)")
327
+ }
328
+ return .failure(.invalidPublicKeyFormat)
329
+ }
330
+
331
+ return .success(secKey)
332
+ }
333
+
334
+ /**
335
+ * Converts hex string to Data.
336
+ * @param hexString Hex-encoded string
337
+ * @return Data or nil if invalid format
338
+ */
339
+ private static func dataFromHexString(_ hexString: String) -> Data? {
340
+ var data = Data(capacity: hexString.count / 2)
341
+
342
+ var index = hexString.startIndex
343
+ while index < hexString.endIndex {
344
+ let nextIndex = hexString.index(index, offsetBy: 2)
345
+ guard nextIndex <= hexString.endIndex else { return nil }
346
+
347
+ let byteString = hexString[index..<nextIndex]
348
+ guard let byte = UInt8(byteString, radix: 16) else { return nil }
349
+
350
+ data.append(byte)
351
+ index = nextIndex
352
+ }
353
+
354
+ return data
355
+ }
356
+
357
+
358
+ }
@@ -0,0 +1,251 @@
1
+ //
2
+ // URLSessionDownloadService.swift
3
+ // InstantpayCodePush
4
+ //
5
+ // Created by Dhananjay kumar on 09/02/26.
6
+ //
7
+
8
+ import Foundation
9
+ #if !os(macOS)
10
+ import UIKit
11
+ #endif
12
+
13
+ protocol DownloadService {
14
+ /**
15
+ * Downloads a file from a URL.
16
+ * @param url The URL to download from
17
+ * @param destination The local path to save to
18
+ * @param fileSizeHandler Optional callback called when file size is known
19
+ * @param progressHandler Callback for download progress updates
20
+ * @param completion Callback with downloaded file URL or error
21
+ * @return The download task (optional)
22
+ */
23
+ func downloadFile(from url: URL, to destination: String, fileSizeHandler: ((Int64) -> Void)?, progressHandler: @escaping (Double) -> Void, completion: @escaping (Result<URL, Error>) -> Void) -> URLSessionDownloadTask?
24
+ }
25
+
26
+ enum DownloadError: Error {
27
+ case incompleteDownload(expected: Int64, actual: Int64)
28
+ case invalidContentLength
29
+ }
30
+
31
+ // Task state for persistence and recovery
32
+ struct TaskState: Codable {
33
+ let taskIdentifier: Int
34
+ let destination: String
35
+ let bundleId: String
36
+ let startedAt: TimeInterval
37
+ }
38
+
39
+ class URLSessionDownloadService: NSObject, DownloadService {
40
+
41
+ private var session: URLSession!
42
+ private var backgroundSession: URLSession!
43
+ private var progressHandlers: [URLSessionTask: (Double) -> Void] = [:]
44
+ private var completionHandlers: [URLSessionTask: (Result<URL, Error>) -> Void] = [:]
45
+ private var destinations: [URLSessionTask: String] = [:]
46
+ private var fileSizeHandlers: [URLSessionTask: (Int64) -> Void] = [:]
47
+ private var taskStates: [Int: TaskState] = [:]
48
+
49
+ override init() {
50
+ super.init()
51
+
52
+ // Foreground session (existing behavior)
53
+ let defaultConfig = URLSessionConfiguration.default
54
+ session = URLSession(configuration: defaultConfig, delegate: self, delegateQueue: nil)
55
+
56
+ // Background session for persistent downloads
57
+ let backgroundConfig = URLSessionConfiguration.background(
58
+ withIdentifier: "com.ipaycodepush.background.download"
59
+ )
60
+ backgroundConfig.isDiscretionary = false
61
+ backgroundConfig.sessionSendsLaunchEvents = true
62
+ backgroundSession = URLSession(configuration: backgroundConfig, delegate: self, delegateQueue: nil)
63
+
64
+ // Load persisted task states
65
+ taskStates = loadTaskStates()
66
+ }
67
+
68
+ // MARK: - State Persistence
69
+
70
+ private var stateFileURL: URL {
71
+ let documentsPath = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0]
72
+ return documentsPath.appendingPathComponent("download-state.json")
73
+ }
74
+
75
+ private func saveTaskState(_ state: TaskState) {
76
+ taskStates[state.taskIdentifier] = state
77
+
78
+ if let data = try? JSONEncoder().encode(taskStates) {
79
+ try? data.write(to: stateFileURL)
80
+ }
81
+ }
82
+
83
+ private func loadTaskStates() -> [Int: TaskState] {
84
+ guard let data = try? Data(contentsOf: stateFileURL),
85
+ let states = try? JSONDecoder().decode([Int: TaskState].self, from: data) else {
86
+ return [:]
87
+ }
88
+ return states
89
+ }
90
+
91
+ private func removeTaskState(_ taskIdentifier: Int) {
92
+ taskStates.removeValue(forKey: taskIdentifier)
93
+
94
+ if let data = try? JSONEncoder().encode(taskStates) {
95
+ try? data.write(to: stateFileURL)
96
+ }
97
+ }
98
+
99
+ func downloadFile(from url: URL, to destination: String, fileSizeHandler: ((Int64) -> Void)?, progressHandler: @escaping (Double) -> Void, completion: @escaping (Result<URL, Error>) -> Void) -> URLSessionDownloadTask? {
100
+ // Determine if we should use background session
101
+ #if !os(macOS)
102
+ let appState = UIApplication.shared.applicationState
103
+ let useBackgroundSession = (appState == .background || appState == .inactive)
104
+ #else
105
+ let useBackgroundSession = false
106
+ #endif
107
+
108
+ let selectedSession = useBackgroundSession ? backgroundSession : session
109
+ let task = selectedSession?.downloadTask(with: url)
110
+
111
+ guard let task = task else {
112
+ return nil
113
+ }
114
+
115
+ progressHandlers[task] = progressHandler
116
+ completionHandlers[task] = completion
117
+ destinations[task] = destination
118
+ if let handler = fileSizeHandler {
119
+ fileSizeHandlers[task] = handler
120
+ }
121
+
122
+ // Extract bundleId from destination path (e.g., "bundle-store/{bundleId}/bundle.zip")
123
+ let bundleId = (destination as NSString).pathComponents
124
+ .dropFirst()
125
+ .first(where: { $0 != "bundle-store" }) ?? "unknown"
126
+
127
+ // Save task metadata for background recovery
128
+ let taskState = TaskState(
129
+ taskIdentifier: task.taskIdentifier,
130
+ destination: destination,
131
+ bundleId: bundleId,
132
+ startedAt: Date().timeIntervalSince1970
133
+ )
134
+ saveTaskState(taskState)
135
+
136
+ task.resume()
137
+ return task
138
+ }
139
+ }
140
+
141
+ extension URLSessionDownloadService: URLSessionDownloadDelegate {
142
+ func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didFinishDownloadingTo location: URL) {
143
+ let completion = completionHandlers[downloadTask]
144
+ let destination = destinations[downloadTask]
145
+
146
+ defer {
147
+ progressHandlers.removeValue(forKey: downloadTask)
148
+ completionHandlers.removeValue(forKey: downloadTask)
149
+ destinations.removeValue(forKey: downloadTask)
150
+ fileSizeHandlers.removeValue(forKey: downloadTask)
151
+ removeTaskState(downloadTask.taskIdentifier)
152
+
153
+ // 다운로드 완료 알림
154
+ NotificationCenter.default.post(name: .downloadDidFinish, object: downloadTask)
155
+ }
156
+
157
+ guard let destination = destination else {
158
+ completion?(.failure(NSError(domain: "IpayCodePushError", code: 1, userInfo: [NSLocalizedDescriptionKey: "Destination path not found"])))
159
+ return
160
+ }
161
+
162
+ // Verify file size
163
+ let expectedSize = downloadTask.response?.expectedContentLength ?? -1
164
+ let actualSize: Int64?
165
+ do {
166
+ let attributes = try FileManager.default.attributesOfItem(atPath: location.path)
167
+ actualSize = attributes[.size] as? Int64
168
+ } catch {
169
+ IpayCodePushHelper.logPrint(classTag: "DownloadService", log: "Failed to get file attributes: \(error.localizedDescription)")
170
+ actualSize = nil
171
+ }
172
+
173
+ if expectedSize > 0, let actualSize = actualSize, actualSize != expectedSize {
174
+ IpayCodePushHelper.logPrint(classTag: "DownloadService", log: "Download incomplete: \(actualSize) / \(expectedSize) bytes")
175
+ // Delete incomplete file
176
+ try? FileManager.default.removeItem(at: location)
177
+ completion?(.failure(DownloadError.incompleteDownload(expected: expectedSize, actual: actualSize)))
178
+ return
179
+ }
180
+
181
+ do {
182
+ let destinationURL = URL(fileURLWithPath: destination)
183
+
184
+ // Delete existing file if needed
185
+ if FileManager.default.fileExists(atPath: destination) {
186
+ try FileManager.default.removeItem(at: destinationURL)
187
+ }
188
+
189
+ try FileManager.default.copyItem(at: location, to: destinationURL)
190
+
191
+ IpayCodePushHelper.logPrint(classTag: "DownloadService", log: "Download completed successfully: \(actualSize ?? 0) bytes")
192
+
193
+ completion?(.success(destinationURL))
194
+ } catch {
195
+
196
+ IpayCodePushHelper.logPrint(classTag: "DownloadService", log: "Failed to copy downloaded file: \(error.localizedDescription)")
197
+
198
+ completion?(.failure(error))
199
+ }
200
+ }
201
+
202
+ func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) {
203
+ let completion = completionHandlers[task]
204
+ defer {
205
+ progressHandlers.removeValue(forKey: task)
206
+ completionHandlers.removeValue(forKey: task)
207
+ destinations.removeValue(forKey: task)
208
+ fileSizeHandlers.removeValue(forKey: task)
209
+ removeTaskState(task.taskIdentifier)
210
+
211
+ NotificationCenter.default.post(name: .downloadDidFinish, object: task)
212
+ }
213
+
214
+ if let error = error {
215
+ completion?(.failure(error))
216
+ }
217
+ }
218
+
219
+ func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didWriteData bytesWritten: Int64, totalBytesWritten: Int64, totalBytesExpectedToWrite: Int64) {
220
+ let progressHandler = progressHandlers[downloadTask]
221
+
222
+ // Call file size handler on first callback when size is known
223
+ if totalBytesWritten == bytesWritten && bytesWritten > 0 {
224
+ if let fileSizeHandler = fileSizeHandlers[downloadTask] {
225
+ if totalBytesExpectedToWrite > 0 {
226
+ fileSizeHandler(totalBytesExpectedToWrite)
227
+ } else {
228
+
229
+ IpayCodePushHelper.logPrint(classTag: "DownloadService", log: "Content-Length not available, proceeding without disk space check")
230
+ }
231
+ fileSizeHandlers.removeValue(forKey: downloadTask)
232
+ }
233
+ }
234
+
235
+ if totalBytesExpectedToWrite > 0 {
236
+ let progress = Double(totalBytesWritten) / Double(totalBytesExpectedToWrite)
237
+ progressHandler?(progress)
238
+
239
+ let progressInfo: [String: Any] = [
240
+ "progress": progress,
241
+ "totalBytesReceived": totalBytesWritten,
242
+ "totalBytesExpected": totalBytesExpectedToWrite
243
+ ]
244
+ NotificationCenter.default.post(name: .downloadProgressUpdate, object: downloadTask, userInfo: progressInfo)
245
+ } else {
246
+ progressHandler?(0)
247
+
248
+ NotificationCenter.default.post(name: .downloadProgressUpdate, object: downloadTask, userInfo: ["progress": 0.0, "totalBytesReceived": 0, "totalBytesExpected": 0])
249
+ }
250
+ }
251
+ }