From 9c93d11229536616e7d9bbd0f724a402b1c456c4 Mon Sep 17 00:00:00 2001 From: William Pettersson Date: Fri, 30 Jun 2023 14:27:55 +0100 Subject: [PATCH] Resource uploading via BLEFS - wip This is a commit of partial progress, this shouldn't even compile --- .../pinetime/PineTimeInstallHandler.java | 61 +- .../btle/ResourceUploadProgressListener.java | 35 ++ .../profiles/adablefs/AdaBleFsAction.java | 18 + .../profiles/adablefs/AdaBleFsProfile.java | 550 ++++++++++++++++++ .../devices/pinetime/PineTimeJFSupport.java | 145 ++++- .../util/RemoteFileSystemCache.java | 103 ++++ 6 files changed, 892 insertions(+), 20 deletions(-) create mode 100644 app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/btle/ResourceUploadProgressListener.java create mode 100644 app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/btle/profiles/adablefs/AdaBleFsAction.java create mode 100644 app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/btle/profiles/adablefs/AdaBleFsProfile.java create mode 100644 app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/RemoteFileSystemCache.java diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/pinetime/PineTimeInstallHandler.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/pinetime/PineTimeInstallHandler.java index 63475275b5..e1a4e99e2f 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/pinetime/PineTimeInstallHandler.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/pinetime/PineTimeInstallHandler.java @@ -17,6 +17,8 @@ package nodomain.freeyourgadget.gadgetbridge.devices.pinetime; import android.app.Activity; +import static java.nio.charset.StandardCharsets.UTF_8; + import android.content.Context; import android.net.Uri; @@ -49,27 +51,41 @@ public class PineTimeInstallHandler implements InstallHandler { private final Context context; + public enum InfiniTimeUpdateType { + UNKNOWN, + DFU, + RESOURCES + } + public InfiniTimeUpdateType updateType; + private InfiniTimeDFUPackage dfuPackageManifest; public PineTimeInstallHandler(Uri uri, Context context) { this.context = context; + updateType = InfiniTimeUpdateType.UNKNOWN; UriHelper uriHelper; try { uriHelper = UriHelper.get(uri, this.context); - GBZipFile dfuPackage = new GBZipFile(uriHelper.openInputStream()); - String manifest = new String(dfuPackage.getFileFromZip("manifest.json")); + GBZipFile zipPackage = new GBZipFile(uriHelper.openInputStream()); + if (zipPackage.fileExists("manifest.json")) { + updateType = InfiniTimeUpdateType.DFU; + String manifest = new String(zipPackage.getFileFromZip("manifest.json")); - if (!manifest.trim().isEmpty()) { - dfuPackageManifest = new Gson().fromJson(manifest.trim(), InfiniTimeDFUPackage.class); + if (!manifest.trim().isEmpty()) { + dfuPackageManifest = new Gson().fromJson(manifest.trim(), InfiniTimeDFUPackage.class); + } + } else if (zipPackage.fileExists("resources.json")) { + updateType = InfiniTimeUpdateType.RESOURCES; + } else { + LOG.error("Unable to determine update type, no manifest.json or resources.json file found."); } - } catch (ZipFileException e) { - LOG.error("Unable to read manifest file.", e); + LOG.error("Unable to read the zip file.", e); } catch (FileNotFoundException e) { - LOG.error("The DFU file was not found.", e); + LOG.error("The update file was not found.", e); } catch (IOException e) { LOG.error("General IO error occurred.", e); } catch (Exception e) { @@ -104,7 +120,11 @@ public class PineTimeInstallHandler implements InstallHandler { GenericItem installItem = new GenericItem(); installItem.setIcon(R.drawable.ic_firmware); - installItem.setName("PineTime firmware"); + if (updateType == InfiniTimeUpdateType.DFU) { + installItem.setName("PineTime firmware"); + } else { + installItem.setName("PineTime resources"); + } installItem.setDetails(getVersion()); installActivity.setInfoText(context.getString(R.string.firmware_install_warning, "(unknown)")); @@ -124,19 +144,28 @@ public class PineTimeInstallHandler implements InstallHandler { @Override public boolean isValid() { - return dfuPackageManifest != null && - dfuPackageManifest.manifest != null && - dfuPackageManifest.manifest.application != null && - dfuPackageManifest.manifest.application.bin_file != null; + if (updateType == InfiniTimeUpdateType.DFU) { + return dfuPackageManifest != null && + dfuPackageManifest.manifest != null && + dfuPackageManifest.manifest.application != null && + dfuPackageManifest.manifest.application.bin_file != null; + } else if (updateType == InfiniTimeUpdateType.RESOURCES) { + return true; // TODO What counts as valid for a resource update? + } else { // updateType == UNKNOWN + return false; + } } // TODO: obtain version information from manifest file instead private String getVersion() { - String binFileName = dfuPackageManifest.manifest.application.bin_file; - Matcher regexMatcher = binNameVersionPattern.matcher(binFileName); + if (updateType == InfiniTimeUpdateType.DFU) { + String binFileName = dfuPackageManifest.manifest.application.bin_file; + Matcher regexMatcher = binNameVersionPattern.matcher(binFileName); - if (regexMatcher.matches()) - return regexMatcher.group(1); + if (regexMatcher.matches()) + return regexMatcher.group(1); + } + // TODO Get version of a resources.package return "(Unknown version)"; } } diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/btle/ResourceUploadProgressListener.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/btle/ResourceUploadProgressListener.java new file mode 100644 index 0000000000..8e4cf30131 --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/btle/ResourceUploadProgressListener.java @@ -0,0 +1,35 @@ +/* Copyright (C) 2016-2022 Andreas Shimokawa, Carsten Pfeiffer, JF, Sebastian + Kranz, Taavi Eomäe + + This file is part of Gadgetbridge. + + Gadgetbridge is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published + by the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + Gadgetbridge is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . */ +package nodomain.freeyourgadget.gadgetbridge.service.btle; + +public class ResourceUploadProgressListener { + public void onDeviceConnecting(final String mac) { } + public void onDeviceConnected(final String mac) { } + public void onUploadStarting(final String mac) { } + public void onDeviceDisconnecting(final String mac) { } + public void onDeviceDisconnected(final String mac) { } + public void onUploadCompleted(final String mac) { } + public void onUploadAborted(final String mac) { } + public void onError(final String mac, int error, int errorType, final String message) { } + public void onProgressChanged(final String mac, + int percent, + float speed, + float averageSpeed, + int segment, + int totalSegments) { } +} diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/btle/profiles/adablefs/AdaBleFsAction.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/btle/profiles/adablefs/AdaBleFsAction.java new file mode 100644 index 0000000000..7972ca6640 --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/btle/profiles/adablefs/AdaBleFsAction.java @@ -0,0 +1,18 @@ +package nodomain.freeyourgadget.gadgetbridge.service.btle.profiles.adablefs; + +class AdaBleFsAction { + public enum Method { + UPLOAD, + DELETE, + }; + public String filenameorpath; + public String secondFilenameorpath; + public Method method; + public byte[] data; + + public AdaBleFsAction(AdaBleFsAction.Method method, String filenameorpath, byte[] data) { + this.filenameorpath = filenameorpath; + this.method = method; + this.data = data; + } +} diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/btle/profiles/adablefs/AdaBleFsProfile.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/btle/profiles/adablefs/AdaBleFsProfile.java new file mode 100644 index 0000000000..1060d88a86 --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/btle/profiles/adablefs/AdaBleFsProfile.java @@ -0,0 +1,550 @@ +/* Copyright (C) 2016-2021 Andreas Shimokawa, Carsten Pfeiffer, João + Paulo Barraca, JohnnySun + + This file is part of Gadgetbridge. + + Gadgetbridge is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published + by the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + Gadgetbridge is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . */ +package nodomain.freeyourgadget.gadgetbridge.service.btle.profiles.adablefs; + +import android.bluetooth.BluetoothGatt; +import android.bluetooth.BluetoothGattCharacteristic; +import android.content.Context; +import android.net.Uri; + +import org.json.JSONArray; +import org.json.JSONObject; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.FileNotFoundException; +import java.io.IOException; +import java.io.UnsupportedEncodingException; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashMap; +import java.util.LinkedList; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.Vector; + +import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice; +import nodomain.freeyourgadget.gadgetbridge.service.btle.AbstractBTLEDeviceSupport; +import nodomain.freeyourgadget.gadgetbridge.service.btle.BtLEQueue; +import nodomain.freeyourgadget.gadgetbridge.service.btle.GattCharacteristic; +import nodomain.freeyourgadget.gadgetbridge.service.btle.TransactionBuilder; +import nodomain.freeyourgadget.gadgetbridge.service.btle.profiles.AbstractBleProfile; +import nodomain.freeyourgadget.gadgetbridge.util.RemoteFileSystemCache; +import nodomain.freeyourgadget.gadgetbridge.util.UriHelper; +import nodomain.freeyourgadget.gadgetbridge.util.ZipFile; +import nodomain.freeyourgadget.gadgetbridge.util.ZipFileException; + +public class AdaBleFsProfile extends AbstractBleProfile { + final byte PADDING_BYTE = 0x00; + final byte REQUEST_CONTINUED = 0x01; + final byte REQUEST_WRITE_FILE_START = 0x20; + final byte RESPONSE_WRITE_FILE = 0x21; + final byte REQUEST_WRITE_FILE_DATA = 0x22; + final byte REQUEST_DELETE_FILE = 0x30; + final byte RESPONSE_DELETE_FILE = 0x31; + final byte REQUEST_MAKE_DIRECTORY = 0x40; + final byte RESPONSE_MAKE_DIRECTORY = 0x41; + final byte REQUEST_LIST_DIRECTORY = 0x50; + final byte RESPONSE_LIST_DIRECTORY = 0x51; + final byte REQUEST_MOVE_FILE_DIRECTORY = 0x60; + final byte RESPONSE_MOVE_FILE_DIRECTORY = 0x61; + + + final byte STATUS_OK = 0x01; + + private enum TriState {TRUE, FALSE, UNKNOWN}; + + + static final UUID UUID_SERVICE_FS = UUID.fromString("0000febb-0000-1000-8000-00805f9b34fb"); + static final UUID UUID_CHARACTERISTIC_FS_VERSION = UUID.fromString("adaf0100-4669-6c65-5472-616e73666572"); + static final public UUID UUID_CHARACTERISTIC_FS_TRANSFER = UUID.fromString("adaf0200-4669-6c65-5472-616e73666572"); + + private BtLEQueue btleQueue; + private GBDevice device; + + private LinkedList adaBleFsQueue; + int bytesOfFileWritten; + int locationToWriteTo; + int chunkSize; + + private RemoteFileSystemCache remoteFs; + + List listingDirectory; + + private static final Logger LOG = LoggerFactory.getLogger(AdaBleFsProfile.class); + + public AdaBleFsProfile(T support) { + super(support); + adaBleFsQueue = new LinkedList<>(); + chunkSize = 20; // Default MTU for android, I believe? + remoteFs = new RemoteFileSystemCache(); + } + + public void loadResources(Uri uri, Context context, BtLEQueue queue) { + this.btleQueue = queue; + // Unzip + try { + UriHelper uriHelper = UriHelper.get(uri, context); + ZipFile zipPackage = new ZipFile(uriHelper.openInputStream()); + + JSONObject resources_manifest = new JSONObject(new String(zipPackage.getFileFromZip(("resources.json")))); + JSONArray resources = resources_manifest.getJSONArray("resources"); + for(int completed = 0; completed < resources.length(); completed++) { + JSONObject fileItem = resources.getJSONObject(completed); + adaBleFsQueue.add( + new AdaBleFsAction( + AdaBleFsAction.Method.UPLOAD, + fileItem.getString("path"), + zipPackage.getFileFromZip(fileItem.getString("filename")) + ) + ); + } + // TODO Get version, and proper mechanism to compare versions + // for each obsolete in obsolete_files + // if obsolete["since"] < this_version ; delete obsolete["path"] + } catch (ZipFileException e) { + LOG.error("Unable to read the zip file.", e); + } catch (FileNotFoundException e) { + LOG.error("The update file was not found.", e); + } catch (IOException e) { + LOG.error("General IO error occurred.", e); + } catch (Exception e) { + LOG.error("Unknown error occurred.", e); + } + this.startNextAdaFsAction(); + } + + private void startNextAdaFsAction() { + if (adaBleFsQueue.size() == 0) { + return; + } + final AdaBleFsAction nextAction = adaBleFsQueue.getFirst(); + switch (nextAction.method) { + case UPLOAD: + uploadFileStart(); + break; + case DELETE: + deleteFile(); + break; + } + } + + + public void enableNotify(TransactionBuilder builder, boolean enable) { + builder.notify(getCharacteristic(UUID_CHARACTERISTIC_FS_TRANSFER), enable); + } + public boolean onCharacteristicRead(BluetoothGatt gatt, BluetoothGattCharacteristic characteristic, int status) { + if (status == BluetoothGatt.GATT_SUCCESS) { + UUID charUuid = characteristic.getUuid(); + if (charUuid.equals(UUID_CHARACTERISTIC_FS_TRANSFER)) { + handleNextStatus(gatt, characteristic); + return true; + } else { + LOG.info("Unexpected onCharacteristicRead: " + GattCharacteristic.toString(characteristic)); + } + } else { + LOG.warn("error reading from characteristic:" + GattCharacteristic.toString(characteristic)); + } + return false; + } + + private void handleNextStatus(BluetoothGatt gatt, BluetoothGattCharacteristic characteristic) { + // Determine what we are waiting on + final byte[] returned = characteristic.getValue(); + // if doing file upload + boolean handled = false; + switch (returned[0]) { + case RESPONSE_WRITE_FILE: + handled = checkContinueFileUpload(returned); + break; + case RESPONSE_DELETE_FILE: + handled = checkDeleteFile(returned); + break; + case RESPONSE_MAKE_DIRECTORY: + handled = checkMakeDirectory(returned); + break; + case RESPONSE_LIST_DIRECTORY: + // TODO We just throw away the results for now, not sure what to do with them + handled = checkListDirectory(returned); + break; + case RESPONSE_MOVE_FILE_DIRECTORY: + handled = checkMove(returned); + break; + } + // Read bytes + // Check for OK + // queue next event + return; + } + + private void checkStatus(byte status) { + // 0x00 is Error + // 0x01 / STATUS_OK is OK + // 0x05 is error modifying read-only FS + // all others are errors too + if (status != STATUS_OK) { + // Raise an exception? + } + + } + + private boolean checkMove(byte[] returned) { + byte status = returned[1]; + + return true; + } + + private boolean checkListDirectory(byte[] returned) { + /** + * Check that a list directory command worked okay + * + * @return true if everything is okay + */ + // Note that we get one response per entry in the directory. + byte status = returned[1]; + checkStatus(status); + int length = returned[2] + (returned[3] >> 8); + int entryNum = returned[4] + (returned[5] >> 8) + (returned[6] >> 16) + (returned[7] >> 24); + int totalEntries = returned[8] + (returned[9] >> 8) + (returned[10] >> 16) + (returned[11] >> 24); + // returned[12:15] = flags, but only bit 0 matters + int flags = returned[12] + (returned[13] >> 8) + (returned[14] >> 16) + (returned[15] >> 24); + boolean isDirectory = (flags & 1) == 1; + long timeStamp = returned[16] + (returned[17] >> 8) + (returned[18] >> 16) + (returned[19] >> 24) + + (returned[20] >> 32) + (returned[21] >> 40) + (returned[22] >> 48) + (returned[23] >> 56); + int fileSize = returned[24] + (returned[25] >> 8) + (returned[26] >> 16) + (returned[27] >> 24); + // Rest is the path, relative to the path we requested + byte[] stringBytes = new byte[length+1]; + for(int counter = 0; counter < length; counter++) { + stringBytes[counter] = returned[28+counter]; + } + Map relativeRoot = directoryTree; + for(String path: listingDirectory) { + relativeRoot = (Map) relativeRoot.get(path); + } + try { + String path = new String(stringBytes, "UTF-8"); + Map here = directoryTree; // TODO Paths returned are relative to where we requested paths from so we need to alter this + // TODO Remove first / ? + // Split path on / + String soFar = ""; + final String[] paths = path.split("/"); + for(int counter = 0; counter < paths.length; counter++) { + final String dir = paths[counter]; + soFar = soFar + "/" + dir; + if (here.containsKey(dir)) { + Object entry = here.get(dir); + if (counter < (paths.length-1) || isDirectory) { + // If not at last entry in paths, or if the response says this is a directory + if (entry instanceof HashMap) { + // All good, continue; + } else { + // our map doesn't list directory, but this is? + LOG.warn("GadgetBridge cache thinks " + soFar + " is a file, but device thinks it's a directory."); + here.put(dir, new HashMap()); + } + } else { + // We are at the last string in paths, and response says this isn't a file + if (entry instanceof String) { + // All good, continue + } else { + LOG.warn("GadgetBridge cache thinks " + soFar + " is a directory, but device thinks it's a file."); + here.put(dir, dir); + } + } + } else { + // Our cache doesn't know of soFar + if (counter < (paths.length-1) || isDirectory) { + here.put(dir, new HashMap()); + } else { + here.put(dir, dir); + } + } + } + } catch (UnsupportedEncodingException e) { + // Pretty sure this branch will never happen + } + if (entryNum == totalEntries) { + relativeRoot.put(".", "."); + } + return entryNum == totalEntries; + } + + private boolean checkMakeDirectory(byte[] returned) { + /** + * Check that a make directory command worked okay + * + * @return true if everything is okay + */ + byte status = returned[1]; + checkStatus(status); + // returned[2:7] are padding + // returned[8:15] are a unix timestamp of the directory + return true; + } + + private boolean checkDeleteFile(byte[] returned) { + /** + * Check that a delete command worked okay + * + * @return true if everything is okay + */ + byte status = returned[1]; + checkStatus(status); + // Not sure what to do here + return true; + } + + private boolean checkContinueFileUpload(byte[] returned) { + /** + * Check that a file upload command has completed + * + * @return true if everything is okay + */ + byte status = returned[1]; + checkStatus(status); + // returned[2:3] are padding + locationToWriteTo = returned[4] + (returned[5] << 8) + (returned[6] << 16) + (returned[7] << 24); + // returned[8:15] is uint64 encoding unix timestamp + int sizeLeft = returned[16] + (returned[17] <<8) + (returned[18] << 16) + (returned[19] << 24); + if (sizeLeft > 0) { + uploadNextFileChunk(); + } + return (sizeLeft == 0); + } + + @Override + public boolean onCharacteristicChanged(BluetoothGatt gatt, BluetoothGattCharacteristic characteristic) { + return onCharacteristicRead(gatt, characteristic, BluetoothGatt.GATT_SUCCESS); + } + + private void uploadNextFileChunk() { + final AdaBleFsAction nextAction = adaBleFsQueue.getFirst(); + final int toSendSize = Math.min(chunkSize, nextAction.data.length - bytesOfFileWritten); + ArrayList command = new ArrayList<>(); + command.add(REQUEST_WRITE_FILE_DATA); + command.add(REQUEST_CONTINUED); + command.add(PADDING_BYTE); + command.add(PADDING_BYTE); + command.add((byte) (locationToWriteTo & 0xFF)); // 32-bit offset + command.add((byte) ((locationToWriteTo >> 8) & 0xFF)); + command.add((byte) ((locationToWriteTo >> 16) & 0xFF)); + command.add((byte) ((locationToWriteTo >> 24) & 0xFF)); + command.add((byte) (toSendSize & 0xFF)); // File size as 32-bit + command.add((byte) ((toSendSize >> 8) & 0xFF)); + command.add((byte) ((toSendSize >> 16) & 0xFF)); + command.add((byte) ((toSendSize >> 24) & 0xFF)); + for(int counter = bytesOfFileWritten; counter < toSendSize; counter++) { + command.add(nextAction.data[counter]); + } + byte[] bytes = new byte[command.size()]; + for(int i = 0; i < command.size(); i++) { + bytes[i] = command.get(i); + } + TransactionBuilder builder = new TransactionBuilder("Upload file chunk"); + builder.write(getCharacteristic(UUID_CHARACTERISTIC_FS_TRANSFER), bytes); + builder.read(getCharacteristic(UUID_CHARACTERISTIC_FS_TRANSFER)); + builder.queue(getQueue()); + } + + private void uploadFileInitialise() { + final AdaBleFsAction nextAction = adaBleFsQueue.getFirst(); + // Check if nextAction.filenameorpath directory exists + // get directory + // check directory exists, including parents + // remove file if it already exists + // then uploadFileStart() + } + + private TriState directoryExists(String[] directoryList) { + Map here = directoryTree; + String[] soFar; + String totalPath = "/"; + for(String directory: directoryList) { + if (here.containsKey(directory)) { + Object entry = here.get(directory); + if (entry instanceof HashMap) { + here = (Map) entry; + totalPath += directory + "/"; + } else { + // Not a HashMap, must be a string then and so this is a file, not a directory + return TriState.FALSE; + } + } else if (here.containsKey(".")) { + // We have listed this directory, and the requested path does not exist + return TriState.FALSE; + } + // Request directory listing for here + totalPath += directory; + requestListDirectory(totalPath); + return TriState.UNKNOWN; + } + return TriState.TRUE; + } + + private void uploadFileStart() { + bytesOfFileWritten = 0; + // TODO Should this be the time we upload, or should it somehow be the timestamp of the + // resources.zip archive file + long unixTime = System.currentTimeMillis() / 1000L; + final AdaBleFsAction nextAction = adaBleFsQueue.getFirst(); + Vector command = new Vector<>(); + command.add(REQUEST_WRITE_FILE_START); + command.add(PADDING_BYTE); + command.add((byte) (nextAction.filenameorpath.length() & 0xFF)); // 16-bit path length + command.add((byte) ((nextAction.filenameorpath.length() >> 8) & 0xFF)); + command.add((byte) 0x00); // 32-bit offset is 0 for file start + command.add((byte) 0x00); + command.add((byte) 0x00); + command.add((byte) 0x00); + command.add((byte) (unixTime & 0xFF)); // Timestamp as 64-bit + command.add((byte) ((unixTime >> 8) & 0xFF)); + command.add((byte) ((unixTime >> 16) & 0xFF)); + command.add((byte) ((unixTime >> 24) & 0xFF)); + command.add((byte) ((unixTime >> 32) & 0xFF)); + command.add((byte) ((unixTime >> 40) & 0xFF)); + command.add((byte) ((unixTime >> 48) & 0xFF)); + command.add((byte) ((unixTime >> 56) & 0xFF)); + command.add((byte) (nextAction.data.length & 0xFF)); // File size as 32-bit + command.add((byte) ((nextAction.data.length >> 8) & 0xFF)); + command.add((byte) ((nextAction.data.length >> 16) & 0xFF)); + command.add((byte) ((nextAction.data.length >> 24) & 0xFF)); + // Is there a better way to construct a bytes[] ? + byte[] bytes = new byte[command.size()]; + for(int i = 0; i < command.size(); i++) { + bytes[i] = command.get(i); + } + TransactionBuilder builder = new TransactionBuilder("Upload file start"); + builder.write(getCharacteristic(UUID_CHARACTERISTIC_FS_TRANSFER), bytes); + builder.read(getCharacteristic(UUID_CHARACTERISTIC_FS_TRANSFER)); + builder.queue(getQueue()); + return; + } + + private void requestListDirectory(String path) { + Vector command = new Vector(); + command.add(REQUEST_LIST_DIRECTORY); + command.add(PADDING_BYTE); + command.add((byte) (path.length() & 0xFF)); // 16-bit path length + command.add((byte) ((path.length() >> 8) & 0xFF)); + for(int count = 0; count < path.length(); count++) { + command.add((byte) path.charAt(count)); + } + // send command, wait for response + // Is there a better way to construct a bytes[] ? + byte[] bytes = new byte[command.size()]; + for(int i = 0; i < command.size(); i++) { + bytes[i] = command.get(i); + } + TransactionBuilder builder = new TransactionBuilder("List directory"); + builder.write(getCharacteristic(UUID_CHARACTERISTIC_FS_TRANSFER), bytes); + builder.read(getCharacteristic(UUID_CHARACTERISTIC_FS_TRANSFER)); + builder.queue(getQueue()); + listingDirectory = Arrays.asList(path.split("/")); + return; + } + private void deleteFile() { + final AdaBleFsAction nextAction = adaBleFsQueue.getFirst(); + Vector command = new Vector(); + command.add(REQUEST_DELETE_FILE); + command.add(PADDING_BYTE); + command.add((byte) (nextAction.filenameorpath.length() & 0xFF)); // 16-bit path length + command.add((byte) ((nextAction.filenameorpath.length() >> 8) & 0xFF)); + for(int count = 0; count < nextAction.filenameorpath.length(); count++) { + command.add((byte) nextAction.filenameorpath.charAt(count)); + } + // send command, wait for response + // Is there a better way to construct a bytes[] ? + byte[] bytes = new byte[command.size()]; + for(int i = 0; i < command.size(); i++) { + bytes[i] = command.get(i); + } + TransactionBuilder builder = new TransactionBuilder("Delete file or directory"); + builder.write(getCharacteristic(UUID_CHARACTERISTIC_FS_TRANSFER), bytes); + builder.read(getCharacteristic(UUID_CHARACTERISTIC_FS_TRANSFER)); + builder.queue(getQueue()); + return; + } + + private void makeDirectory() { + final AdaBleFsAction nextAction = adaBleFsQueue.getFirst(); + long unixTime = System.currentTimeMillis() / 1000L; + Vector command = new Vector(); + command.add(REQUEST_MAKE_DIRECTORY); + command.add(PADDING_BYTE); + command.add((byte) (nextAction.filenameorpath.length() & 0xFF)); // 16-bit path length + command.add((byte) ((nextAction.filenameorpath.length() >> 8) & 0xFF)); + command.add(PADDING_BYTE); + command.add(PADDING_BYTE); + command.add(PADDING_BYTE); + command.add(PADDING_BYTE); + command.add((byte) (unixTime & 0xFF)); // Timestamp as 64-bit + command.add((byte) ((unixTime >> 8) & 0xFF)); + command.add((byte) ((unixTime >> 16) & 0xFF)); + command.add((byte) ((unixTime >> 24) & 0xFF)); + command.add((byte) ((unixTime >> 32) & 0xFF)); + command.add((byte) ((unixTime >> 40) & 0xFF)); + command.add((byte) ((unixTime >> 48) & 0xFF)); + command.add((byte) ((unixTime >> 56) & 0xFF)); + for(int count = 0; count < nextAction.filenameorpath.length(); count++) { + command.add((byte) nextAction.filenameorpath.charAt(count)); + } + // send command, wait for response + // Is there a better way to construct a bytes[] ? + byte[] bytes = new byte[command.size()]; + for(int i = 0; i < command.size(); i++) { + bytes[i] = command.get(i); + } + TransactionBuilder builder = new TransactionBuilder("Create directory"); + builder.write(getCharacteristic(UUID_CHARACTERISTIC_FS_TRANSFER), bytes); + builder.read(getCharacteristic(UUID_CHARACTERISTIC_FS_TRANSFER)); + builder.queue(getQueue()); + return; + } + + private void moveFileOrDirectory() { + final AdaBleFsAction nextAction = adaBleFsQueue.getFirst(); + long unixTime = System.currentTimeMillis() / 1000L; + Vector command = new Vector(); + command.add(REQUEST_MOVE_FILE_DIRECTORY); + command.add(PADDING_BYTE); + command.add((byte) (nextAction.filenameorpath.length() & 0xFF)); // 16-bit path length + command.add((byte) ((nextAction.filenameorpath.length() >> 8) & 0xFF)); + command.add((byte) (nextAction.secondFilenameorpath.length() & 0xFF)); // 16-bit path length + command.add((byte) ((nextAction.secondFilenameorpath.length() >> 8) & 0xFF)); + for(int count = 0; count < nextAction.filenameorpath.length(); count++) { + command.add((byte) nextAction.filenameorpath.charAt(count)); + } + command.add(PADDING_BYTE); + for(int count = 0; count < nextAction.secondFilenameorpath.length(); count++) { + command.add((byte) nextAction.secondFilenameorpath.charAt(count)); + } + // send command, wait for response + // Is there a better way to construct a bytes[] ? + byte[] bytes = new byte[command.size()]; + for(int i = 0; i < command.size(); i++) { + bytes[i] = command.get(i); + } + TransactionBuilder builder = new TransactionBuilder("Move file or directory"); + builder.write(getCharacteristic(UUID_CHARACTERISTIC_FS_TRANSFER), bytes); + builder.read(getCharacteristic(UUID_CHARACTERISTIC_FS_TRANSFER)); + builder.queue(getQueue()); + return; + } + +} diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/pinetime/PineTimeJFSupport.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/pinetime/PineTimeJFSupport.java index e4171885f1..d85b21bff6 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/pinetime/PineTimeJFSupport.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/pinetime/PineTimeJFSupport.java @@ -62,7 +62,6 @@ import no.nordicsemi.android.dfu.DfuServiceInitiator; import no.nordicsemi.android.dfu.DfuServiceListenerHelper; import nodomain.freeyourgadget.gadgetbridge.GBApplication; import nodomain.freeyourgadget.gadgetbridge.R; -import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSettingsPreferenceConst; import nodomain.freeyourgadget.gadgetbridge.database.DBHandler; import nodomain.freeyourgadget.gadgetbridge.database.DBHelper; import nodomain.freeyourgadget.gadgetbridge.deviceevents.GBDeviceEventBatteryInfo; @@ -92,6 +91,10 @@ import nodomain.freeyourgadget.gadgetbridge.model.WeatherSpec; import nodomain.freeyourgadget.gadgetbridge.model.WorldClock; import nodomain.freeyourgadget.gadgetbridge.model.weather.Weather; import nodomain.freeyourgadget.gadgetbridge.service.btle.AbstractBTLESingleDeviceSupport; +import nodomain.freeyourgadget.gadgetbridge.service.btle.AbstractBTLEDeviceSupport; +import nodomain.freeyourgadget.gadgetbridge.service.btle.AbstractBTLEDeviceSupport; +import nodomain.freeyourgadget.gadgetbridge.service.btle.profiles.adablefs.AdaBleFsProfile; +import nodomain.freeyourgadget.gadgetbridge.service.btle.ResourceUploadProgressListener; import nodomain.freeyourgadget.gadgetbridge.service.btle.BLETypeConversions; import nodomain.freeyourgadget.gadgetbridge.service.btle.GattCharacteristic; import nodomain.freeyourgadget.gadgetbridge.service.btle.GattService; @@ -113,6 +116,8 @@ public class PineTimeJFSupport extends AbstractBTLESingleDeviceSupport implement private final DeviceInfoProfile deviceInfoProfile; private final BatteryInfoProfile batteryInfoProfile; + private final AdaBleFsProfile adaBleFsProfile; + private final int MaxNotificationLength = 100; private final int CutNotificationTitleMinAt = 25; private int firmwareVersionMajor = 0; @@ -250,6 +255,101 @@ public class PineTimeJFSupport extends AbstractBTLESingleDeviceSupport implement percent, speed, averageSpeed, segment, totalSegments)); } }; + private final ResourceUploadProgressListener resourceUploadProgressListener = new ResourceUploadProgressListener() { + private final LocalBroadcastManager manager = LocalBroadcastManager.getInstance(getContext()); + + /** + * Sets the progress bar to indeterminate or not, also makes it visible + * + * @param indeterminate if indeterminate + */ + public void setIndeterminate(boolean indeterminate) { + manager.sendBroadcast(new Intent(GB.ACTION_SET_PROGRESS_BAR).putExtra(GB.PROGRESS_BAR_INDETERMINATE, indeterminate)); + } + + /** + * Sets the status text and logs it + */ + public void setProgress(int progress) { + manager.sendBroadcast(new Intent(GB.ACTION_SET_PROGRESS_BAR).putExtra(GB.PROGRESS_BAR_PROGRESS, progress)); + } + + /** + * Sets the text that describes progress + * + * @param progressText text to display + */ + public void setProgressText(String progressText) { + manager.sendBroadcast(new Intent(GB.ACTION_SET_PROGRESS_TEXT).putExtra(GB.DISPLAY_MESSAGE_MESSAGE, progressText)); + } + + @Override + public void onDeviceConnecting(final String mac) { + this.setIndeterminate(true); + this.setProgressText(getContext().getString(R.string.devicestatus_connecting)); + } + + @Override + public void onDeviceConnected(final String mac) { + this.setIndeterminate(true); + this.setProgressText(getContext().getString(R.string.devicestatus_connected)); + } + + @Override + public void onUploadStarting(final String mac) { + this.setIndeterminate(true); + this.setProgressText(getContext().getString(R.string.devicestatus_upload_starting)); + } + + @Override + public void onDeviceDisconnecting(final String mac) { + this.setProgressText(getContext().getString(R.string.devicestatus_disconnecting)); + } + + @Override + public void onDeviceDisconnected(final String mac) { + this.setIndeterminate(true); + this.setProgressText(getContext().getString(R.string.devicestatus_disconnected)); + } + + @Override + public void onUploadCompleted(final String mac) { + this.setProgressText(getContext().getString(R.string.devicestatus_upload_completed)); + this.setIndeterminate(false); + this.setProgress(100); + + handler = null; + controller = null; + gbDevice.unsetBusyTask(); + // TODO: Request reconnection + } + + @Override + public void onUploadAborted(final String mac) { + this.setProgressText(getContext().getString(R.string.devicestatus_upload_aborted)); + gbDevice.unsetBusyTask(); + } + + @Override + public void onError(final String mac, int error, int errorType, final String message) { + this.setProgressText(getContext().getString(R.string.devicestatus_upload_failed)); + gbDevice.unsetBusyTask(); + } + + @Override + public void onProgressChanged(final String mac, + int percent, + float speed, + float averageSpeed, + int segment, + int totalSegments) { + this.setProgress(percent); + this.setIndeterminate(false); + this.setProgressText(String.format(Locale.ENGLISH, + getContext().getString(R.string.firmware_update_progress), + percent, speed, averageSpeed, segment, totalSegments)); + } + }; public PineTimeJFSupport() { super(LOG); @@ -287,6 +387,10 @@ public class PineTimeJFSupport extends AbstractBTLESingleDeviceSupport implement batteryInfoProfile = new BatteryInfoProfile<>(this); batteryInfoProfile.addListener(mListener); addSupportedProfile(batteryInfoProfile); + + adaBleFsProfile = new AdaBleFsProfile<>(this); + addSupportedProfile(adaBleFsProfile); + addSupportedService(AdaBleFsProfile.UUID_CHARACTERISTIC_FS_TRANSFER); } private void handleBatteryInfo(nodomain.freeyourgadget.gadgetbridge.service.btle.profiles.battery.BatteryInfo info) { @@ -444,9 +548,42 @@ public class PineTimeJFSupport extends AbstractBTLESingleDeviceSupport implement @Override public void onInstallApp(Uri uri, @NonNull final Bundle options) { - try { - handler = new PineTimeInstallHandler(uri, getContext()); + handler = new PineTimeInstallHandler(uri, getContext()); + if (!handler.isValid()) { + LocalBroadcastManager.getInstance(getContext()).sendBroadcast(new Intent(GB.ACTION_SET_PROGRESS_TEXT) + .putExtra(GB.DISPLAY_MESSAGE_MESSAGE, getContext().getString(R.string.fwinstaller_firmware_not_compatible_to_device))); + return; + } + if (handler.updateType == PineTimeInstallHandler.InfiniTimeUpdateType.DFU) { + installDfu(uri); + } else if (handler.updateType == PineTimeInstallHandler.InfiniTimeUpdateType.RESOURCES) { + installResource(uri); + } + } + public void installResource(Uri uri) { + try { + gbDevice.setBusyTask("resources upgrade"); + adaBleFsProfile.loadResources(uri, getContext(), getQueue()); + + LocalBroadcastManager.getInstance(getContext()).sendBroadcast(new Intent(GB.ACTION_SET_PROGRESS_BAR) + .putExtra(GB.PROGRESS_BAR_INDETERMINATE, true) + ); + LocalBroadcastManager.getInstance(getContext()).sendBroadcast(new Intent(GB.ACTION_SET_PROGRESS_TEXT) + .putExtra(GB.DISPLAY_MESSAGE_MESSAGE, getContext().getString(R.string.devicestatus_upload_starting)) + ); + } catch (Exception ex) { + GB.toast(getContext(), getContext().getString(R.string.updatefirmwareoperation_write_failed) + ":" + ex.getMessage(), Toast.LENGTH_LONG, GB.ERROR, ex); + if (gbDevice.isBusy() && gbDevice.getBusyTask().equals("resources upgrade")) { + gbDevice.unsetBusyTask(); + } + } + + } + + public void installDfu(Uri uri) { + handler = new PineTimeInstallHandler(uri, getContext()); + try { if (handler.isValid()) { gbDevice.setBusyTask(R.string.updating_firmware, getContext()); DfuServiceInitiator starter = new DfuServiceInitiator(getDevice().getAddress()) @@ -470,7 +607,7 @@ public class PineTimeJFSupport extends AbstractBTLESingleDeviceSupport implement } else { LocalBroadcastManager.getInstance(getContext()).sendBroadcast(new Intent(GB.ACTION_SET_PROGRESS_TEXT) .putExtra(GB.DISPLAY_MESSAGE_MESSAGE, getContext().getString(R.string.fwinstaller_firmware_not_compatible_to_device))); - } + } } catch (Exception ex) { GB.toast(getContext(), getContext().getString(R.string.updatefirmwareoperation_write_failed) + ":" + ex.getLocalizedMessage(), Toast.LENGTH_LONG, GB.ERROR, ex); if (gbDevice.isBusy() && gbDevice.getBusyTask().equals("firmware upgrade")) { diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/RemoteFileSystemCache.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/RemoteFileSystemCache.java new file mode 100644 index 0000000000..b17318b745 --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/RemoteFileSystemCache.java @@ -0,0 +1,103 @@ +/* Copyright (C) 2016-2021 Andreas Shimokawa, Carsten Pfeiffer, João + Paulo Barraca, JohnnySun + + This file is part of Gadgetbridge. + + Gadgetbridge is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published + by the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + Gadgetbridge is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . */ + +package nodomain.freeyourgadget.gadgetbridge.util; + +import java.util.ArrayList; +import java.util.List; + +public class RemoteFileSystemCache { + + private Directory root; + + public RemoteFileSystemCache() { + root = new Directory("/"); + } + + public void addFile(String full_path) { + root.addFile(full_path.substring(1)); // skip first slash + } + + public void addDirectory(String full_path) { + root.addDirectory(full_path.substring(1)); // skip first slash + } + public boolean hasFile(String full_path) { + return root.hasFile(full_path); + } + private class Directory { + String name; + List files; + List directories; + + Directory(String name) { + files = new ArrayList<>(); + directories = new ArrayList<>(); + this.name = name; + } + + void addFile(String filename) { + int indexOfSlash = filename.indexOf('/'); + if (indexOfSlash > 0) { + Directory nextDirectory = getDirectory(filename.substring(0, indexOfSlash)).get(); + nextDirectory.addFile(filename.substring(indexOfSlash + 1)); + } else { + files.add(filename); + } + } + + void addDirectory(String name) { + int indexOfSlash = name.indexOf('/'); + if (indexOfSlash > 0) { + Directory nextDirectory = getDirectory(name.substring(0, indexOfSlash)).get(); + nextDirectory.addFile(name.substring(indexOfSlash + 1)); + } else { + directories.add(new Directory(name)); + } + } + + boolean hasFile(String full_path) { + // Remove leading slash, it may be left if we are root + String path; + if (full_path.charAt(0) == '/') { + path = full_path.substring(1); + } else { + path = full_path; + } + int indexOfSlash = path.indexOf('/'); + if (indexOfSlash >= 0) { + Optional nextDirectory = getDirectory(path.substring(0, indexOfSlash)); + if (nextDirectory.isPresent()) { + String remains = path.substring(indexOfSlash + 1); // Skip past slash + return nextDirectory.get().hasFile(remains); + } + return false; + } else { + return files.contains(path); + } + } + + private Optional getDirectory(String name) { + for(Directory directory: directories) { + if (directory.name == name) { + return Optional.of(directory); + } + } + return null; + } + } +}