Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(android): Support ZIP format #385

Open
wants to merge 8 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions react-native-mcu-manager/android/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -51,4 +51,5 @@ repositories {

dependencies {
implementation "no.nordicsemi.android:mcumgr-ble:2.2.0"
implementation 'com.google.code.gson:gson:2.11.0'
}
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,11 @@ import io.runtime.mcumgr.dfu.FirmwareUpgradeCallback
import io.runtime.mcumgr.dfu.FirmwareUpgradeController
import io.runtime.mcumgr.dfu.mcuboot.FirmwareUpgradeManager
import io.runtime.mcumgr.dfu.mcuboot.FirmwareUpgradeManager.Settings
import io.runtime.mcumgr.dfu.mcuboot.model.ImageSet
import io.runtime.mcumgr.exception.McuMgrException
import io.runtime.mcumgr.image.McuMgrImage
import java.io.IOException
import android.webkit.MimeTypeMap

val UpgradeModes =
mapOf(
Expand Down Expand Up @@ -63,6 +66,37 @@ class DeviceUpgrade(
transport.release()
}

private fun uriToByteArray(uri: Uri): ByteArray? {
val inputStream = context.contentResolver.openInputStream(uri) ?: return null
return inputStream.use { it.readBytes() }
}

private fun extractImagesFrom(updateBundleUri: Uri): ImageSet {
val fileExtension = MimeTypeMap.getFileExtensionFromUrl(updateBundleUri.toString())
val mimeType = MimeTypeMap.getSingleton().getMimeTypeFromExtension(fileExtension)
val binData = uriToByteArray(updateBundleUri) ?: throw IOException("Failed to read update file")

if (mimeType == "application/zip") {
return extractImagesFromZipFile(binData)
} else {
return extractImagesFromBinFile(binData)
}
}

private fun extractImagesFromBinFile(binData: ByteArray): ImageSet {
// Check if the BIN file is valid.
McuMgrImage.getHash(binData)

val binaries = ImageSet()
binaries.add(binData)

return binaries
}

private fun extractImagesFromZipFile(zipData: ByteArray): ImageSet {
return ZipPackage(zipData).getBinaries();
}

private fun doUpdate(updateBundleUri: Uri) {
val estimatedSwapTime = updateOptions.estimatedSwapTime * 1000
val modeInt = updateOptions.upgradeMode ?: 1
Expand All @@ -71,22 +105,17 @@ class DeviceUpgrade(
val settings = Settings.Builder().setEstimatedSwapTime(estimatedSwapTime).build()

try {
val stream = context.contentResolver.openInputStream(updateBundleUri)
val imageData = ByteArray(stream!!.available())

stream.read(imageData)
val images = extractImagesFrom(updateBundleUri)

dfuManager.setMode(upgradeMode)
dfuManager.start(imageData, settings)
dfuManager.start(images, settings)
} catch (e: IOException) {
e.printStackTrace()
disconnectDevice()
Log.v(this.TAG, "IOException")
withSafePromise { promise -> promise.reject(CodedException(e)) }
} catch (e: McuMgrException) {
e.printStackTrace()
disconnectDevice()
Log.v(this.TAG, "mcu exception")
withSafePromise { promise ->
promise.reject(ReactNativeMcuMgrException.fromMcuMgrException(e))
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
package uk.co.playerdata.reactnativemcumanager;

import android.util.Log;

import androidx.annotation.Keep;
import androidx.annotation.NonNull;

import com.google.gson.FieldNamingPolicy;
import com.google.gson.Gson;
import com.google.gson.GsonBuilder;

import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.IOException;
import java.io.InputStreamReader;
import java.util.HashMap;
import java.util.Map;
import java.util.zip.ZipEntry;
import java.util.zip.ZipInputStream;

import io.runtime.mcumgr.dfu.mcuboot.model.ImageSet;
import io.runtime.mcumgr.dfu.mcuboot.model.TargetImage;
import io.runtime.mcumgr.exception.McuMgrException;

public final class ZipPackage {
private static final String MANIFEST = "manifest.json";

@SuppressWarnings({"unused", "MismatchedReadAndWriteOfArray"})
@Keep
private static class Manifest {
private int formatVersion;
private File[] files;

@Keep
private static class File {
/**
* The file type. Expected vales are: "application", "bin", "suit-envelope".
*/
private String type;
/**
* The name of the image file.
*/
private String file;
/**
* The size of the image file in bytes. This is declared size and does not have to
* be equal to the actual file size.
*/
private int size;
/**
* Image index is used for multi-core devices. Index 0 is the main core (app core),
* index 1 is secondary core (net core), etc.
* <p>
* For single-core devices this is not present in the manifest file and defaults to 0.
*/
private int imageIndex = 0;
/**
* The slot number where the image is to be sent. By default images are sent to the
* secondary slot and then swapped to the primary slot after the image is confirmed
* and the device is reset.
* <p>
* However, if the device supports Direct XIP feature it is possible to run an app
* from a secondary slot. The image has to be compiled for this slot. A ZIP package
* can contain images for both slots. Only the one targeting the available one will
* be sent.
* @since NCS v 2.5, nRF Connect Device Manager 1.8.
*/
private int slot = TargetImage.SLOT_SECONDARY;
}
}

private Manifest manifest;
private final Map<String, byte[]> entries = new HashMap<>();

public ZipPackage(@NonNull final byte[] data) throws IOException {
ZipEntry ze;

// Unzip the file and look for the manifest.json.
final ZipInputStream zis = new ZipInputStream(new ByteArrayInputStream(data));
while ((ze = zis.getNextEntry()) != null) {
if (ze.isDirectory())
throw new IOException("Invalid ZIP");

final String name = validateFilename(ze.getName(), ".");

if (name.equals(MANIFEST)) {
final Gson gson = new GsonBuilder()
.setFieldNamingPolicy(FieldNamingPolicy.LOWER_CASE_WITH_UNDERSCORES)
.create();
manifest = gson.fromJson(new InputStreamReader(zis), Manifest.class);
} else if (name.endsWith(".bin") || name.endsWith(".suit")) {
final byte[] content = getData(zis);
entries.put(name, content);
} else {
Log.v("KT", "les");
}
}
}

public ImageSet getBinaries() throws IOException, McuMgrException {
final ImageSet binaries = new ImageSet();

// Search for images.
for (final Manifest.File file: manifest.files) {
final String name = file.file;
final byte[] content = entries.get(name);
if (content == null)
throw new IOException("File not found: " + name);

binaries.add(new TargetImage(file.imageIndex, file.slot, content));
}
return binaries;
}

public byte[] getSuitEnvelope() {
// First, search for an entry of type "suit-envelope".
for (final Manifest.File file: manifest.files) {
if (file.type.equals("suit-envelope")) {
return entries.get(file.file);
}
}
// If not found, search for a file with the ".suit" extension.
for (final Manifest.File file: manifest.files) {
if (file.file.endsWith(".suit")) {
return entries.get(file.file);
}
}
// Not found.
return null;
}

public byte[] getResource(@NonNull final String name) {
return entries.get(name);
}

private byte[] getData(@NonNull ZipInputStream zis) throws IOException {
final byte[] buffer = new byte[1024];

// Read file content to byte array
final ByteArrayOutputStream os = new ByteArrayOutputStream();
int count;
while ((count = zis.read(buffer)) != -1) {
os.write(buffer, 0, count);
}
return os.toByteArray();
}

/**
* Validates the path (not the content) of the zip file to prevent path traversal issues.
*
* <p> When unzipping an archive, always validate the compressed files' paths and reject any path
* that has a path traversal (such as ../..). Simply looking for .. characters in the compressed
* file's path may not be enough to prevent path traversal issues. The code validates the name of
* the entry before extracting the entry. If the name is invalid, the entire extraction is aborted.
* <p>
*
* @param filename The path to the file.
* @param intendedDir The intended directory where the zip should be.
* @return The validated path to the file.
* @throws java.io.IOException Thrown in case of path traversal issues.
*/
@SuppressWarnings("SameParameterValue")
private String validateFilename(@NonNull final String filename,
@NonNull final String intendedDir)
throws IOException {
File f = new File(filename);
String canonicalPath = f.getCanonicalPath();

File iD = new File(intendedDir);
String canonicalID = iD.getCanonicalPath();

if (canonicalPath.startsWith(canonicalID)) {
return canonicalPath.substring(1); // remove leading "/"
} else {
throw new IllegalStateException("File is outside extraction target directory.");
}
}

}
Loading