Skip to content

Commit d42f795

Browse files
refactor: add loop protection for OTA redeployment and update error handling
1 parent bdfe6e0 commit d42f795

6 files changed

Lines changed: 76 additions & 11 deletions

File tree

android/src/main/java/com/mendix/mendixnative/react/ota/NativeOtaModule.kt

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ const val OTA_ZIP_FILE_MISSING = "OTA_ZIP_FILE_MISSING"
1919
const val OTA_UNZIP_DIR_EXISTS = "OTA_UNZIP_DIR_EXISTS"
2020
const val OTA_DEPLOYMENT_FAILED = "OTA_DEPLOYMENT_FAILED"
2121
const val OTA_DOWNLOAD_FAILED = "OTA_DOWNLOAD_FAILED"
22+
const val OTA_ALREADY_DEPLOYED = "OTA_ALREADY_DEPLOYED"
2223

2324
const val MANIFEST_OTA_DEPLOYMENT_ID_KEY = "otaDeploymentID"
2425
const val MANIFEST_RELATIVE_BUNDLE_PATH_KEY = "relativeBundlePath"
@@ -141,6 +142,30 @@ class NativeOtaModule(
141142

142143
Log.i(TAG, "Deploying ota with id: $otaDeploymentID")
143144

145+
// Loop protection: refuse to redeploy a deployment that is already the active one.
146+
// Redeploying and reloading into an already-active deployment restarts the app into
147+
// the same bundle, causing an infinite download -> deploy -> reload loop. This happens
148+
// when the served OTA bundle's embedded deploymentID never matches the server-advertised
149+
// deploymentID (e.g. a bundle served as an OTA update but built for a different deployment).
150+
// Rejecting here makes the JS OTA flow skip the reload, so the app keeps running the
151+
// currently deployed bundle instead of looping.
152+
if (oldManifest != null &&
153+
oldManifest.otaDeploymentID == otaDeploymentID &&
154+
File(
155+
resolveAbsolutePathRelativeToOtaDir(
156+
reactApplicationContext,
157+
oldManifest.relativeBundlePath
158+
)
159+
).exists()
160+
) {
161+
zipFile.delete()
162+
return reject(
163+
promise,
164+
OTA_ALREADY_DEPLOYED,
165+
"Deployment $otaDeploymentID is already active. Skipping redeploy to prevent a reload loop."
166+
)
167+
}
168+
144169
if (!zipFile.exists()) {
145170
return reject(promise, OTA_ZIP_FILE_MISSING, "OTA package does not exist")
146171
}

ios/Modules/JSBundleFileProvider/OtaJSBundleFileProvider.swift

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -54,10 +54,6 @@ extension OtaJSBundleFileProvider: JSBundleFileProviderProtocol {
5454
return nil
5555
}
5656

57-
guard let encodedPath = bundlePath.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) else {
58-
return nil
59-
}
60-
61-
return URL(string: encodedPath)
57+
return URL(fileURLWithPath: bundlePath)
6258
}
6359
}

ios/Modules/MxConfiguration/MxConfigProxy.swift

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,12 +8,12 @@ public class MxConfigProxy: NSObject {
88
public var filesDirectoryName: String
99
public var warningsFilter: String
1010
public var otaManifestPath: String
11-
public var isDeveloperApp: NSNumber
11+
public var isDeveloperApp: Bool
1212
public var nativeDependencies: [String: Any]
1313
public var nativeBinaryVersion: NSNumber
1414
public var appSessionId: String?
1515

16-
init(runtimeUrl: String, appName: String?, databaseName: String, filesDirectoryName: String, warningsFilter: String, otaManifestPath: String, isDeveloperApp: NSNumber, nativeDependencies: [String : Any], nativeBinaryVersion: NSNumber, appSessionId: String?) {
16+
init(runtimeUrl: String, appName: String?, databaseName: String, filesDirectoryName: String, warningsFilter: String, otaManifestPath: String, isDeveloperApp: Bool, nativeDependencies: [String : Any], nativeBinaryVersion: NSNumber, appSessionId: String?) {
1717
self.runtimeUrl = runtimeUrl
1818
self.appName = appName
1919
self.databaseName = databaseName
@@ -45,7 +45,7 @@ public class MxConfigProxy: NSObject {
4545
filesDirectoryName: MxConfiguration.filesDirectoryName,
4646
warningsFilter: MxConfiguration.warningsFilter.stringValue,
4747
otaManifestPath: OtaHelpers.getOtaManifestFilepath(),
48-
isDeveloperApp: NSNumber(booleanLiteral: MxConfiguration.isDeveloperApp),
48+
isDeveloperApp: MxConfiguration.isDeveloperApp,
4949
nativeDependencies: OtaHelpers.getNativeDependencies(),
5050
nativeBinaryVersion: NSNumber(integerLiteral: MxConfiguration.nativeBinaryVersion),
5151
appSessionId: MxConfiguration.appSessionId

ios/Modules/NativeDownloadHandler/NativeDownloadHandler.swift

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,22 @@ extension NativeDownloadHandler: URLSessionDownloadDelegate {
5858
func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didFinishDownloadingTo location: URL) {
5959
let fileManager = FileManager.default
6060

61+
// Validate the HTTP status code. URLSession's download task writes the response body
62+
// to `location` even for error responses (e.g. 404/500), so without this check an
63+
// HTML/JSON error body would be saved as the "downloaded" file and later fail to unzip
64+
// with a misleading error. Fail explicitly with the status code instead.
65+
if let httpResponse = downloadTask.response as? HTTPURLResponse,
66+
!(200...299).contains(httpResponse.statusCode) {
67+
let error = NSError(
68+
domain: NSURLErrorDomain,
69+
code: httpResponse.statusCode,
70+
userInfo: [NSLocalizedDescriptionKey: "Download failed with HTTP status \(httpResponse.statusCode)."]
71+
)
72+
NSLog("%@", formatMessage("Download failed with HTTP status \(httpResponse.statusCode)"))
73+
failCallback?(error)
74+
return
75+
}
76+
6177
// Check MIME type if specified
6278
if let expectedMimeType = mimeType,
6379
let responseMimeType = downloadTask.response?.mimeType,

ios/Modules/NativeOtaModule/NativeOtaModule.swift

Lines changed: 30 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,25 @@ public class NativeOtaModule: NSObject {
145145

146146
let oldManifest = OtaHelpers.readManifestAsDictionary()
147147

148+
// Loop protection: refuse to redeploy a deployment that is already the active one.
149+
// Redeploying and reloading into an already-active deployment restarts the app into
150+
// the same bundle, causing an infinite download -> deploy -> reload loop. This happens
151+
// when the served OTA bundle's embedded deploymentID never matches the server-advertised
152+
// deploymentID (e.g. a bundle served as an OTA update but built for a different deployment).
153+
// Rejecting here makes the JS OTA flow skip the reload, so the app keeps running the
154+
// currently deployed bundle instead of looping.
155+
if let oldManifest = oldManifest,
156+
let deployedID = oldManifest[MANIFEST_OTA_DEPLOYMENT_ID_KEY] as? String,
157+
deployedID == otaDeploymentID,
158+
let deployedBundlePath = oldManifest[MANIFEST_RELATIVE_BUNDLE_PATH_KEY] as? String,
159+
FileManager.default.fileExists(atPath: OtaHelpers.resolveAbsolutePathRelativeToOtaDir("/\(deployedBundlePath)")) {
160+
let message = "[OTA] Deployment \(otaDeploymentID) is already active. Skipping redeploy to prevent a reload loop."
161+
NSLog("%@", message)
162+
removeZipFile(zipPath)
163+
promise.reject(OTA_ALREADY_DEPLOYED, message, nil)
164+
return
165+
}
166+
148167
let fileExists = FileManager.default.fileExists(atPath: zipPath)
149168
if !fileExists {
150169
let errorMessage = "[OTA] OTA package does not exist."
@@ -161,9 +180,17 @@ public class NativeOtaModule: NSObject {
161180

162181
let unzipped = SSZipArchive.unzipFile(atPath: zipPath, toDestination: unzipDir, overwrite: false, password: nil, progressHandler: nil)
163182
if !unzipped {
164-
NSLog("[OTA] Unzipping OTA failed")
165-
removeZipFile(unzipDir)
166-
promise.reject(OTA_DEPLOYMENT_FAILED, "OTA deployment failed.", nil)
183+
// Diagnostic: the "OTA package" is not a valid zip. This most often means the
184+
// server returned a non-zip response (e.g. an HTML/JSON error body) that was
185+
// saved as the download. Log the size and leading bytes so the real cause is visible.
186+
let fileSize = (try? FileManager.default.attributesOfItem(atPath: zipPath)[.size] as? Int) ?? nil
187+
var preview = ""
188+
if let handle = FileManager.default.contents(atPath: zipPath) {
189+
preview = String(data: handle.prefix(64), encoding: .utf8) ?? handle.prefix(4).map { String(format: "%02x", $0) }.joined()
190+
}
191+
NSLog("[OTA] Unzipping OTA failed. Downloaded file size: \(fileSize ?? -1) bytes. Leading bytes: \(preview)")
192+
removeZipFile(zipPath)
193+
promise.reject(OTA_DEPLOYMENT_FAILED, "OTA deployment failed: downloaded package is not a valid zip.", nil)
167194
return
168195
}
169196

ios/Modules/NativeOtaModule/OtaConstants.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ let OTA_UNZIP_DIR_EXISTS = "OTA_UNZIP_DIR_EXISTS"
1212
let OTA_DEPLOYMENT_FAILED = "OTA_DEPLOYMENT_FAILED"
1313
let INVALID_DOWNLOAD_CONFIG = "INVALID_DOWNLOAD_CONFIG"
1414
let OTA_DOWNLOAD_FAILED = "OTA_DOWNLOAD_FAILED"
15+
let OTA_ALREADY_DEPLOYED = "OTA_ALREADY_DEPLOYED"
1516

1617
// MARK: - Download Config Keys
1718
let DOWNLOAD_CONFIG_URL_KEY = "url"

0 commit comments

Comments
 (0)