Skip to content

Commit c3fe326

Browse files
authored
Merge pull request #385 from samuel-rl/android-zip-file
feat(android): Support ZIP format
2 parents 9f16e37 + 9e3dc46 commit c3fe326

File tree

3 files changed

+216
-7
lines changed

3 files changed

+216
-7
lines changed

react-native-mcu-manager/android/build.gradle

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,4 +51,5 @@ repositories {
5151

5252
dependencies {
5353
implementation "no.nordicsemi.android:mcumgr-ble:2.2.1"
54+
implementation "com.google.code.gson:gson:2.11.0"
5455
}

react-native-mcu-manager/android/src/main/java/uk/co/playerdata/reactnativemcumanager/DeviceUpgrade.kt

Lines changed: 36 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,11 @@ import io.runtime.mcumgr.dfu.FirmwareUpgradeCallback
1111
import io.runtime.mcumgr.dfu.FirmwareUpgradeController
1212
import io.runtime.mcumgr.dfu.mcuboot.FirmwareUpgradeManager
1313
import io.runtime.mcumgr.dfu.mcuboot.FirmwareUpgradeManager.Settings
14+
import io.runtime.mcumgr.dfu.mcuboot.model.ImageSet
1415
import io.runtime.mcumgr.exception.McuMgrException
16+
import io.runtime.mcumgr.image.McuMgrImage
1517
import java.io.IOException
18+
import android.webkit.MimeTypeMap
1619

1720
val UpgradeModes =
1821
mapOf(
@@ -63,6 +66,37 @@ class DeviceUpgrade(
6366
transport.release()
6467
}
6568

69+
private fun uriToByteArray(uri: Uri): ByteArray? {
70+
val inputStream = context.contentResolver.openInputStream(uri) ?: return null
71+
return inputStream.use { it.readBytes() }
72+
}
73+
74+
private fun extractImagesFrom(updateBundleUri: Uri): ImageSet {
75+
val fileExtension = MimeTypeMap.getFileExtensionFromUrl(updateBundleUri.toString())
76+
val mimeType = MimeTypeMap.getSingleton().getMimeTypeFromExtension(fileExtension)
77+
val binData = uriToByteArray(updateBundleUri) ?: throw IOException("Failed to read update file")
78+
79+
if (mimeType == "application/zip") {
80+
return extractImagesFromZipFile(binData)
81+
} else {
82+
return extractImagesFromBinFile(binData)
83+
}
84+
}
85+
86+
private fun extractImagesFromBinFile(binData: ByteArray): ImageSet {
87+
// Check if the BIN file is valid.
88+
McuMgrImage.getHash(binData)
89+
90+
val binaries = ImageSet()
91+
binaries.add(binData)
92+
93+
return binaries
94+
}
95+
96+
private fun extractImagesFromZipFile(zipData: ByteArray): ImageSet {
97+
return ZipPackage(zipData).getBinaries();
98+
}
99+
66100
private fun doUpdate(updateBundleUri: Uri) {
67101
val estimatedSwapTime = updateOptions.estimatedSwapTime * 1000
68102
val modeInt = updateOptions.upgradeMode ?: 1
@@ -71,22 +105,17 @@ class DeviceUpgrade(
71105
val settings = Settings.Builder().setEstimatedSwapTime(estimatedSwapTime).build()
72106

73107
try {
74-
val stream = context.contentResolver.openInputStream(updateBundleUri)
75-
val imageData = ByteArray(stream!!.available())
76-
77-
stream.read(imageData)
108+
val images = extractImagesFrom(updateBundleUri)
78109

79110
dfuManager.setMode(upgradeMode)
80-
dfuManager.start(imageData, settings)
111+
dfuManager.start(images, settings)
81112
} catch (e: IOException) {
82113
e.printStackTrace()
83114
disconnectDevice()
84-
Log.v(this.TAG, "IOException")
85115
withSafePromise { promise -> promise.reject(CodedException(e)) }
86116
} catch (e: McuMgrException) {
87117
e.printStackTrace()
88118
disconnectDevice()
89-
Log.v(this.TAG, "mcu exception")
90119
withSafePromise { promise ->
91120
promise.reject(ReactNativeMcuMgrException.fromMcuMgrException(e))
92121
}
Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,179 @@
1+
package uk.co.playerdata.reactnativemcumanager;
2+
3+
import android.util.Log;
4+
5+
import androidx.annotation.Keep;
6+
import androidx.annotation.NonNull;
7+
8+
import com.google.gson.FieldNamingPolicy;
9+
import com.google.gson.Gson;
10+
import com.google.gson.GsonBuilder;
11+
12+
import java.io.ByteArrayInputStream;
13+
import java.io.ByteArrayOutputStream;
14+
import java.io.File;
15+
import java.io.IOException;
16+
import java.io.InputStreamReader;
17+
import java.util.HashMap;
18+
import java.util.Map;
19+
import java.util.zip.ZipEntry;
20+
import java.util.zip.ZipInputStream;
21+
22+
import io.runtime.mcumgr.dfu.mcuboot.model.ImageSet;
23+
import io.runtime.mcumgr.dfu.mcuboot.model.TargetImage;
24+
import io.runtime.mcumgr.exception.McuMgrException;
25+
26+
public final class ZipPackage {
27+
private static final String MANIFEST = "manifest.json";
28+
29+
@SuppressWarnings({"unused", "MismatchedReadAndWriteOfArray"})
30+
@Keep
31+
private static class Manifest {
32+
private int formatVersion;
33+
private File[] files;
34+
35+
@Keep
36+
private static class File {
37+
/**
38+
* The file type. Expected vales are: "application", "bin", "suit-envelope".
39+
*/
40+
private String type;
41+
/**
42+
* The name of the image file.
43+
*/
44+
private String file;
45+
/**
46+
* The size of the image file in bytes. This is declared size and does not have to
47+
* be equal to the actual file size.
48+
*/
49+
private int size;
50+
/**
51+
* Image index is used for multi-core devices. Index 0 is the main core (app core),
52+
* index 1 is secondary core (net core), etc.
53+
* <p>
54+
* For single-core devices this is not present in the manifest file and defaults to 0.
55+
*/
56+
private int imageIndex = 0;
57+
/**
58+
* The slot number where the image is to be sent. By default images are sent to the
59+
* secondary slot and then swapped to the primary slot after the image is confirmed
60+
* and the device is reset.
61+
* <p>
62+
* However, if the device supports Direct XIP feature it is possible to run an app
63+
* from a secondary slot. The image has to be compiled for this slot. A ZIP package
64+
* can contain images for both slots. Only the one targeting the available one will
65+
* be sent.
66+
* @since NCS v 2.5, nRF Connect Device Manager 1.8.
67+
*/
68+
private int slot = TargetImage.SLOT_SECONDARY;
69+
}
70+
}
71+
72+
private Manifest manifest;
73+
private final Map<String, byte[]> entries = new HashMap<>();
74+
75+
public ZipPackage(@NonNull final byte[] data) throws IOException {
76+
ZipEntry ze;
77+
78+
// Unzip the file and look for the manifest.json.
79+
final ZipInputStream zis = new ZipInputStream(new ByteArrayInputStream(data));
80+
while ((ze = zis.getNextEntry()) != null) {
81+
if (ze.isDirectory())
82+
throw new IOException("Invalid ZIP");
83+
84+
final String name = validateFilename(ze.getName(), ".");
85+
86+
if (name.equals(MANIFEST)) {
87+
final Gson gson = new GsonBuilder()
88+
.setFieldNamingPolicy(FieldNamingPolicy.LOWER_CASE_WITH_UNDERSCORES)
89+
.create();
90+
manifest = gson.fromJson(new InputStreamReader(zis), Manifest.class);
91+
} else if (name.endsWith(".bin") || name.endsWith(".suit")) {
92+
final byte[] content = getData(zis);
93+
entries.put(name, content);
94+
} else {
95+
throw new IOException("Unsupported file found: " + name);
96+
}
97+
}
98+
}
99+
100+
public ImageSet getBinaries() throws IOException, McuMgrException {
101+
final ImageSet binaries = new ImageSet();
102+
103+
// Search for images.
104+
for (final Manifest.File file: manifest.files) {
105+
final String name = file.file;
106+
final byte[] content = entries.get(name);
107+
if (content == null)
108+
throw new IOException("File not found: " + name);
109+
110+
binaries.add(new TargetImage(file.imageIndex, file.slot, content));
111+
}
112+
return binaries;
113+
}
114+
115+
public byte[] getSuitEnvelope() {
116+
// First, search for an entry of type "suit-envelope".
117+
for (final Manifest.File file: manifest.files) {
118+
if (file.type.equals("suit-envelope")) {
119+
return entries.get(file.file);
120+
}
121+
}
122+
// If not found, search for a file with the ".suit" extension.
123+
for (final Manifest.File file: manifest.files) {
124+
if (file.file.endsWith(".suit")) {
125+
return entries.get(file.file);
126+
}
127+
}
128+
// Not found.
129+
return null;
130+
}
131+
132+
public byte[] getResource(@NonNull final String name) {
133+
return entries.get(name);
134+
}
135+
136+
private byte[] getData(@NonNull ZipInputStream zis) throws IOException {
137+
final byte[] buffer = new byte[1024];
138+
139+
// Read file content to byte array
140+
final ByteArrayOutputStream os = new ByteArrayOutputStream();
141+
int count;
142+
while ((count = zis.read(buffer)) != -1) {
143+
os.write(buffer, 0, count);
144+
}
145+
return os.toByteArray();
146+
}
147+
148+
/**
149+
* Validates the path (not the content) of the zip file to prevent path traversal issues.
150+
*
151+
* <p> When unzipping an archive, always validate the compressed files' paths and reject any path
152+
* that has a path traversal (such as ../..). Simply looking for .. characters in the compressed
153+
* file's path may not be enough to prevent path traversal issues. The code validates the name of
154+
* the entry before extracting the entry. If the name is invalid, the entire extraction is aborted.
155+
* <p>
156+
*
157+
* @param filename The path to the file.
158+
* @param intendedDir The intended directory where the zip should be.
159+
* @return The validated path to the file.
160+
* @throws java.io.IOException Thrown in case of path traversal issues.
161+
*/
162+
@SuppressWarnings("SameParameterValue")
163+
private String validateFilename(@NonNull final String filename,
164+
@NonNull final String intendedDir)
165+
throws IOException {
166+
File f = new File(filename);
167+
String canonicalPath = f.getCanonicalPath();
168+
169+
File iD = new File(intendedDir);
170+
String canonicalID = iD.getCanonicalPath();
171+
172+
if (canonicalPath.startsWith(canonicalID)) {
173+
return canonicalPath.substring(1); // remove leading "/"
174+
} else {
175+
throw new IllegalStateException("File is outside extraction target directory.");
176+
}
177+
}
178+
179+
}

0 commit comments

Comments
 (0)