/* Copyright (C) 2024 Martin.JM 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.devices.huawei; import android.os.Handler; import android.os.Looper; import android.widget.Toast; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import java.io.IOException; import java.nio.ByteBuffer; import java.util.ArrayList; import java.util.Arrays; import java.util.Locale; import nodomain.freeyourgadget.gadgetbridge.devices.huawei.HuaweiPacket; import nodomain.freeyourgadget.gadgetbridge.devices.huawei.packets.FileDownloadService0A; import nodomain.freeyourgadget.gadgetbridge.devices.huawei.packets.FileDownloadService2C; import nodomain.freeyourgadget.gadgetbridge.service.devices.huawei.requests.GetFileBlockRequest; import nodomain.freeyourgadget.gadgetbridge.service.devices.huawei.requests.GetFileDownloadCompleteRequest; import nodomain.freeyourgadget.gadgetbridge.service.devices.huawei.requests.GetFileDownloadInitRequest; import nodomain.freeyourgadget.gadgetbridge.service.devices.huawei.requests.GetFileInfoRequest; import nodomain.freeyourgadget.gadgetbridge.service.devices.huawei.requests.GetFileParametersRequest; import nodomain.freeyourgadget.gadgetbridge.service.devices.huawei.requests.Request; import nodomain.freeyourgadget.gadgetbridge.util.GB; public class HuaweiFileDownloadManager { private static final Logger LOG = LoggerFactory.getLogger(HuaweiFileDownloadManager.class); public static class HuaweiFileDownloadException extends Exception { @Nullable public final FileRequest fileRequest; HuaweiFileDownloadException(@Nullable FileRequest fileRequest, String message) { super(message); this.fileRequest = fileRequest; } HuaweiFileDownloadException(@Nullable FileRequest fileRequest, String message, Exception e) { super(message, e); this.fileRequest = fileRequest; } } public static class HuaweiFileDownloadSendException extends HuaweiFileDownloadException { HuaweiFileDownloadSendException(@Nullable FileRequest fileRequest, Request request, Exception e) { super(fileRequest, "Error sending request " + request.getName(), e); } } public static class HuaweiFileDownloadRequestException extends HuaweiFileDownloadException { HuaweiFileDownloadRequestException(@Nullable FileRequest fileRequest, Class requestClass, Exception e) { super(fileRequest, "Error in request of class " + requestClass, e); } } public static class HuaweiFileDownloadTimeoutException extends HuaweiFileDownloadException { HuaweiFileDownloadTimeoutException(@Nullable FileRequest fileRequest) { super(fileRequest, "Timeout was hit!"); } } public static class HuaweiFileDownloadFileMismatchException extends HuaweiFileDownloadException { HuaweiFileDownloadFileMismatchException(@NonNull FileRequest fileRequest, String filename) { super(fileRequest, "Data for wrong file received. Expected name " + fileRequest.filename + ", got name " + filename); } HuaweiFileDownloadFileMismatchException(@NonNull FileRequest fileRequest, FileType fileType) { super(fileRequest, "Data for wrong file received. Expected type " + fileRequest.fileType + ", got type " + fileType); } HuaweiFileDownloadFileMismatchException(@NonNull FileRequest fileRequest, String[] filenames) { super(fileRequest, "File " + fileRequest.filename + " cannot be downloaded using this method. Only the following files are supported: " + Arrays.toString(filenames)); } HuaweiFileDownloadFileMismatchException(@NonNull FileRequest fileRequest, int number, boolean newSync) { super( fileRequest, "Data for wrong file received. Expected " + (newSync ? "id " + fileRequest.fileId + ", got id " + number : "packet number " + (fileRequest.lastPacketNumber + 1) + ", got " + number) ); } } public enum FileType { DEBUG, SLEEP_STATE, SLEEP_DATA, GPS, UNKNOWN // Never for input! } public static class FileDownloadCallback { public void downloadComplete(FileRequest fileRequest) { } public void downloadException(HuaweiFileDownloadException e) { if (e.fileRequest != null) LOG.error("Error downloading file: {}{}", e.fileRequest.getFilename(), e.fileRequest.isNewSync() ? " (newsync)" : "", e); else LOG.error("Error in file download", e); } } public static class FileRequest { // Inputs private final String filename; private final FileType fileType; private final boolean newSync; FileDownloadCallback fileDownloadCallback = null; // Sleep type only - for 2C GPS they are set to zero private int startTime = 0; private int endTime = 0; // GPS type only private short workoutId; private Long databaseId; private FileRequest(String filename, FileType fileType, boolean newSync, int startTime, int endTime, FileDownloadCallback fileDownloadCallback) { this.filename = filename; this.fileType = fileType; this.newSync = newSync; this.fileDownloadCallback = fileDownloadCallback; this.startTime = startTime; this.endTime = endTime; } public static FileRequest sleepStateFileRequest(boolean supportsTruSleepNewSync, int startTime, int endTime, FileDownloadCallback fileDownloadCallback) { return new FileRequest("sleep_state.bin", FileType.SLEEP_STATE, supportsTruSleepNewSync, startTime, endTime, fileDownloadCallback); } public static FileRequest sleepDataFileRequest(boolean supportsTruSleepNewSync, int startTime, int endTime, FileDownloadCallback fileDownloadCallback) { return new FileRequest("sleep_data.bin", FileType.SLEEP_DATA, supportsTruSleepNewSync, startTime, endTime, fileDownloadCallback); } private FileRequest(String filename, FileType fileType, boolean newSync, FileDownloadCallback fileDownloadCallback) { this.filename = filename; this.fileType = fileType; this.newSync = newSync; this.fileDownloadCallback = fileDownloadCallback; } public static FileRequest debugFileRequest(String filename, FileDownloadCallback fileDownloadCallback) { return new FileRequest(filename, FileType.DEBUG, false, fileDownloadCallback); } private FileRequest(@Nullable String filename, FileType fileType, boolean newSync, short workoutId, Long databaseId, FileDownloadCallback fileDownloadCallback) { this.filename = filename; this.fileType = fileType; this.newSync = newSync; this.fileDownloadCallback = fileDownloadCallback; this.workoutId = workoutId; this.databaseId = databaseId; } public static FileRequest workoutGpsFileRequest(boolean newSync, short workoutId, Long databaseId, FileDownloadCallback fileDownloadCallback) { if (newSync) return new FileRequest(String.format(Locale.getDefault(), "%d_gps.bin", workoutId), FileType.GPS, true, workoutId, databaseId, fileDownloadCallback); else return new FileRequest(null, FileType.GPS, false, workoutId, databaseId, fileDownloadCallback); } // Retrieved private int fileSize; private int maxBlockSize; private int timeout; // TODO: unit? private ByteBuffer buffer; private int startOfBlockOffset; private int currentBlockSize; // Old sync only private String[] filenames; private byte lastPacketNumber; // New sync only private byte fileId; private boolean noEncrypt; public byte getFileId() { return fileId; } public String getFilename() { return filename; } public FileType getFileType() { return fileType; } public byte[] getData() { if (buffer == null) return new byte[] {}; return buffer.array(); } public int getCurrentOffset() { return buffer.position(); } public boolean isNewSync() { return newSync; } public int getStartTime() { return startTime; } public int getEndTime() { return endTime; } public short getWorkoutId() { return workoutId; } public Long getDatabaseId() { return databaseId; } public int getCurrentBlockSize() { return currentBlockSize; } public boolean isNoEncrypt() { return noEncrypt; } } /** * Actually receives the file data */ private static class FileDataReceiver extends Request { public boolean newSync; public byte[] data; // Packet number for old sync // fileId for new sync public byte number; public FileDataReceiver(HuaweiSupportProvider supportProvider) { super(supportProvider); } @Override public boolean handleResponse(HuaweiPacket response) { if ( (response.serviceId == FileDownloadService0A.id && response.commandId == FileDownloadService0A.BlockResponse.id) || (response.serviceId == FileDownloadService2C.id && response.commandId == FileDownloadService2C.BlockResponse.id) ) { receivedPacket = response; return true; } return false; } @Override public boolean autoRemoveFromResponseHandler() { // This needs to be removed manually return false; } @Override protected void processResponse() throws ResponseParseException { if (this.receivedPacket instanceof FileDownloadService0A.BlockResponse) { FileDownloadService0A.BlockResponse response = (FileDownloadService0A.BlockResponse) this.receivedPacket; this.newSync = false; this.number = response.number; this.data = response.data; if (response.e instanceof HuaweiPacket.CryptoException) { LOG.warn("Data could be decoded as TLV, but not decrypted.", response.e); } } else if (this.receivedPacket instanceof FileDownloadService2C.BlockResponse) { this.newSync = true; this.number = ((FileDownloadService2C.BlockResponse) this.receivedPacket).fileId; this.data = ((FileDownloadService2C.BlockResponse) this.receivedPacket).data; } else { throw new ResponseTypeMismatchException( this.receivedPacket, FileDownloadService0A.BlockResponse.class, FileDownloadService2C.BlockResponse.class ); } } } private final HuaweiSupportProvider supportProvider; private final Handler handler; private final Runnable timeout; // Cannot be final as we need the device to be connected before creating this private FileDataReceiver fileDataReceiver; // For old sync we cannot download multiple files at the same time, for new sync we don't want // to, so we limit that. We also do not allow old and new sync at the same time. // Note that old and new sync are already split by the serviceID and commandID that are used for // all the requests. // Note that the timeout is not ready for concurrent downloads private boolean isBusy; private final ArrayList fileRequests; private FileRequest currentFileRequest; // If the GB interface needs to be updated at the end of the queue of downloads private boolean needSync = false; public HuaweiFileDownloadManager(HuaweiSupportProvider supportProvider) { this.supportProvider = supportProvider; handler = new Handler(Looper.getMainLooper()); isBusy = false; fileRequests = new ArrayList<>(); timeout = () -> { this.currentFileRequest.fileDownloadCallback.downloadException(new HuaweiFileDownloadTimeoutException(currentFileRequest)); reset(); }; } public void addToQueue(FileRequest fileRequest, boolean needSync) { synchronized (supportProvider) { fileRequests.add(fileRequest); if (needSync) this.needSync = true; } startDownload(); } private boolean arrayContains(String[] haystack, String needle) { return Arrays.stream(haystack).anyMatch(s -> s.equals(needle)); } private void initFileDataReceiver() { if (fileDataReceiver == null) { // We can only init fileDataReceiver if the device is already connected fileDataReceiver = new FileDataReceiver(supportProvider); fileDataReceiver.setFinalizeReq(new Request.RequestCallback() { @Override public void call() { // Reset timeout handler.removeCallbacks(HuaweiFileDownloadManager.this.timeout); handler.postDelayed(HuaweiFileDownloadManager.this.timeout, currentFileRequest.timeout * 1000L); // Handle data handleFileData(fileDataReceiver.newSync, fileDataReceiver.number, fileDataReceiver.data); } @Override public void handleException(Request.ResponseParseException e) { currentFileRequest.fileDownloadCallback.downloadException(new HuaweiFileDownloadRequestException(null, this.getClass(), e)); } }); } } public void startDownload() { initFileDataReceiver(); // Make sure the fileDataReceiver is ready synchronized (this.supportProvider) { if (this.isBusy) { LOG.debug("A new download is started while a previous is in progress."); return; // Already downloading, this file will come eventually } if (this.fileRequests.isEmpty()) { // No more files to download supportProvider.downloadQueueEmpty(this.needSync); // Don't need sync after this anymore this.needSync = false; return; } this.isBusy = true; } this.currentFileRequest = this.fileRequests.remove(0); GetFileDownloadInitRequest getFileDownloadInitRequest = new GetFileDownloadInitRequest(supportProvider, currentFileRequest); getFileDownloadInitRequest.setFinalizeReq(new Request.RequestCallback() { @Override public void call(Request request) { // For multi-download, match to file instead of assuming current GetFileDownloadInitRequest r = (GetFileDownloadInitRequest) request; if (r.newSync) { if (!currentFileRequest.filename.equals(r.filename)) { currentFileRequest.fileDownloadCallback.downloadException(new HuaweiFileDownloadFileMismatchException( currentFileRequest, r.filename )); reset(); return; } if (currentFileRequest.fileType != r.fileType) { currentFileRequest.fileDownloadCallback.downloadException(new HuaweiFileDownloadFileMismatchException( currentFileRequest, r.fileType )); reset(); return; } currentFileRequest.fileId = r.fileId; currentFileRequest.fileSize = r.fileSize; if (r.fileSize == 0) { // Nothing to download, go to end fileComplete(); return; } getFileInfo(); } else { if (currentFileRequest.fileType == FileType.GPS) { if (r.filenames.length == 0) { reset(); return; } for (String filename : r.filenames) { // We only download the gps file itself right now if (!filename.contains("_gps.bin")) continue; // Add download request with the filename at the start of the queue FileRequest fileRequest = new FileRequest( filename, FileType.GPS, currentFileRequest.newSync, currentFileRequest.workoutId, currentFileRequest.databaseId, currentFileRequest.fileDownloadCallback ); synchronized (supportProvider) { fileRequests.add(0, fileRequest); } } currentFileRequest = fileRequests.remove(0); // Replace with the file to download } else { if (!arrayContains(r.filenames, currentFileRequest.filename)) { currentFileRequest.fileDownloadCallback.downloadException(new HuaweiFileDownloadFileMismatchException( currentFileRequest, r.filenames )); reset(); return; } } currentFileRequest.filenames = r.filenames; getDownloadParameters(); } } @Override public void handleException(Request.ResponseParseException e) { currentFileRequest.fileDownloadCallback.downloadException(new HuaweiFileDownloadRequestException(currentFileRequest, this.getClass(), e)); } }); try { getFileDownloadInitRequest.doPerform(); } catch (IOException e) { currentFileRequest.fileDownloadCallback.downloadException(new HuaweiFileDownloadSendException(currentFileRequest, getFileDownloadInitRequest, e)); reset(); } } private void getDownloadParameters() { // Old sync only, can never be multiple at the same time // Assuming currentRequest is the correct one the entire time // Which may no longer be the case when we implement multi-download for new sync GetFileParametersRequest getFileParametersRequest = new GetFileParametersRequest(supportProvider, currentFileRequest.fileType == FileType.SLEEP_STATE || currentFileRequest.fileType == FileType.SLEEP_DATA ); getFileParametersRequest.setFinalizeReq(new Request.RequestCallback() { @Override public void call() { currentFileRequest.maxBlockSize = getFileParametersRequest.getMaxBlockSize(); currentFileRequest.timeout = getFileParametersRequest.getTimeout(); getFileInfo(); } @Override public void handleException(Request.ResponseParseException e) { currentFileRequest.fileDownloadCallback.downloadException(new HuaweiFileDownloadRequestException(null, this.getClass(), e)); reset(); } }); try { getFileParametersRequest.doPerform(); } catch (IOException e) { currentFileRequest.fileDownloadCallback.downloadException(new HuaweiFileDownloadSendException(currentFileRequest, getFileParametersRequest, e)); reset(); } } private void getFileInfo() { GetFileInfoRequest getFileInfoRequest = new GetFileInfoRequest(supportProvider, currentFileRequest); getFileInfoRequest.setFinalizeReq(new Request.RequestCallback() { @Override public void call(Request request) { GetFileInfoRequest r = (GetFileInfoRequest) request; if (r.newSync) { if (currentFileRequest.fileId != r.fileId) { currentFileRequest.fileDownloadCallback.downloadException(new HuaweiFileDownloadFileMismatchException(currentFileRequest, r.fileId, true)); reset(); return; } currentFileRequest.timeout = r.timeout; currentFileRequest.maxBlockSize = r.maxBlockSize; currentFileRequest.noEncrypt = r.noEncrypt; } else { // currentFileRequest MUST BE correct here currentFileRequest.fileSize = r.fileLength; if (currentFileRequest.fileSize == 0) { // Nothing to download, go to complete fileComplete(); return; } } downloadNextFileBlock(); } @Override public void handleException(Request.ResponseParseException e) { currentFileRequest.fileDownloadCallback.downloadException(new HuaweiFileDownloadRequestException(null, this.getClass(), e)); reset(); } }); try { getFileInfoRequest.doPerform(); } catch (IOException e) { currentFileRequest.fileDownloadCallback.downloadException(new HuaweiFileDownloadSendException(currentFileRequest, getFileInfoRequest, e)); reset(); } } private void downloadNextFileBlock() { if (currentFileRequest.buffer == null) // New file currentFileRequest.buffer = ByteBuffer.allocate(currentFileRequest.fileSize); currentFileRequest.lastPacketNumber = -1; // Counts per block currentFileRequest.startOfBlockOffset = currentFileRequest.buffer.position(); currentFileRequest.currentBlockSize = Math.min( currentFileRequest.fileSize - currentFileRequest.buffer.position(), // Remaining file size currentFileRequest.maxBlockSize // Max we can ask for ); // Start listening for file data this.supportProvider.addInProgressRequest(fileDataReceiver); handler.removeCallbacks(this.timeout); handler.postDelayed(HuaweiFileDownloadManager.this.timeout, currentFileRequest.timeout * 1000L); GetFileBlockRequest getFileBlockRequest = new GetFileBlockRequest(supportProvider, currentFileRequest); getFileBlockRequest.setFinalizeReq(new Request.RequestCallback() { @Override public void call() { // Reset timeout handler.removeCallbacks(HuaweiFileDownloadManager.this.timeout); handler.postDelayed(HuaweiFileDownloadManager.this.timeout, currentFileRequest.timeout * 1000L); } }); try { getFileBlockRequest.doPerform(); } catch (IOException e) { currentFileRequest.fileDownloadCallback.downloadException(new HuaweiFileDownloadSendException(currentFileRequest, getFileBlockRequest, e)); reset(); } } private void handleFileData(boolean newSync, byte number, byte[] data) { if (newSync && currentFileRequest.fileId != number) { currentFileRequest.fileDownloadCallback.downloadException(new HuaweiFileDownloadFileMismatchException(currentFileRequest, number, true)); reset(); return; } if (!newSync) { if (currentFileRequest.lastPacketNumber != number - 1) { currentFileRequest.fileDownloadCallback.downloadException(new HuaweiFileDownloadFileMismatchException(currentFileRequest, number, false)); reset(); return; } currentFileRequest.lastPacketNumber = number; } currentFileRequest.buffer.put(data); if (currentFileRequest.buffer.position() >= currentFileRequest.fileSize) { // File complete LOG.info("Download complete for file {}", currentFileRequest.filename); if (currentFileRequest.buffer.position() != currentFileRequest.fileSize) { GB.toast("Downloaded file is larger than expected", Toast.LENGTH_SHORT, GB.ERROR); LOG.error("Downloaded file is larger than expected: {}", currentFileRequest.filename); } fileComplete(); } else if (currentFileRequest.buffer.position() - currentFileRequest.startOfBlockOffset >= currentFileRequest.maxBlockSize) { // Block complete, need to request a new file block downloadNextFileBlock(); } // Else we're expecting more data to arrive automatically } private void fileComplete() { // Stop timeout from hitting this.handler.removeCallbacks(this.timeout); // File complete request GetFileDownloadCompleteRequest getFileDownloadCompleteRequest = new GetFileDownloadCompleteRequest(supportProvider, currentFileRequest); try { getFileDownloadCompleteRequest.doPerform(); } catch (IOException e) { currentFileRequest.fileDownloadCallback.downloadException(new HuaweiFileDownloadSendException(currentFileRequest, getFileDownloadCompleteRequest, e)); reset(); } // Handle file data try { currentFileRequest.fileDownloadCallback.downloadComplete(currentFileRequest); } catch (Exception e) { LOG.error("Download complete callback exception.", e); LOG.warn("File contents: {}", GB.hexdump(currentFileRequest.getData())); GB.toast("Workout GPX file could not be parsed.",Toast.LENGTH_SHORT, GB.ERROR, e); } if (!this.currentFileRequest.newSync && !this.fileRequests.isEmpty() && !this.fileRequests.get(0).newSync) { // Old sync can potentially take a shortcut if (arrayContains(this.currentFileRequest.filenames, this.fileRequests.get(0).filename)) { // Shortcut to next download // - No init // - No getting parameters // Just copy over the info and go directly to GetFileInfo FileRequest nextRequest = this.fileRequests.remove(0); nextRequest.filenames = this.currentFileRequest.filenames; nextRequest.maxBlockSize = this.currentFileRequest.maxBlockSize; nextRequest.timeout = this.currentFileRequest.timeout; this.currentFileRequest = nextRequest; getFileInfo(); return; } } reset(); } private void reset() { // Stop listening for file data, if we were doing that this.supportProvider.removeInProgressRequests(this.fileDataReceiver); // Reset current request this.currentFileRequest = null; // Unset busy, otherwise the next download will never start synchronized (this.supportProvider) { this.isBusy = false; } // Try to download next file startDownload(); } public void dispose() { // Stop timeout from hitting, nothing else to really do this.handler.removeCallbacks(this.timeout); } }