From 22fafebd91bcf52d96b7f255e4431380bb8c4fca Mon Sep 17 00:00:00 2001 From: kuhy Date: Fri, 26 Apr 2024 22:35:35 +0200 Subject: [PATCH] Garmin protocol: install AGPS data as firmware --- .../garmin/GarminAgpsInstallHandler.java | 122 ++++++++++++++++++ .../devices/garmin/GarminCoordinator.java | 20 +++ .../GarminVivoActive4SCoordinator.java | 26 ++-- .../service/devices/garmin/GarminSupport.java | 39 ++++++ .../devices/garmin/file/GarminAgpsFile.java | 40 ++++++ .../garmin/http/DataTransferHandler.java | 20 ++- .../devices/garmin/http/EphemerisHandler.java | 34 +++-- .../devices/garmin/http/HttpHandler.java | 16 ++- 8 files changed, 291 insertions(+), 26 deletions(-) create mode 100644 app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/GarminAgpsInstallHandler.java create mode 100644 app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/file/GarminAgpsFile.java diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/GarminAgpsInstallHandler.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/GarminAgpsInstallHandler.java new file mode 100644 index 000000000..26304553f --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/GarminAgpsInstallHandler.java @@ -0,0 +1,122 @@ +package nodomain.freeyourgadget.gadgetbridge.devices.garmin; + +import android.content.Context; +import android.net.Uri; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.BufferedInputStream; +import java.io.IOException; +import java.io.InputStream; + +import nodomain.freeyourgadget.gadgetbridge.R; +import nodomain.freeyourgadget.gadgetbridge.activities.InstallActivity; +import nodomain.freeyourgadget.gadgetbridge.devices.DeviceCoordinator; +import nodomain.freeyourgadget.gadgetbridge.devices.InstallHandler; +import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice; +import nodomain.freeyourgadget.gadgetbridge.model.GenericItem; +import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.file.GarminAgpsFile; +import nodomain.freeyourgadget.gadgetbridge.util.FileUtils; +import nodomain.freeyourgadget.gadgetbridge.util.UriHelper; + +public class GarminAgpsInstallHandler implements InstallHandler { + private static final Logger LOG = LoggerFactory.getLogger(GarminAgpsInstallHandler.class); + + protected final Context mContext; + private GarminAgpsFile file; + + public GarminAgpsInstallHandler(final Uri uri, final Context context) { + this.mContext = context; + + final UriHelper uriHelper; + try { + uriHelper = UriHelper.get(uri, context); + } catch (final IOException e) { + LOG.error("Failed to get uri", e); + return; + } + + try (InputStream in = new BufferedInputStream(uriHelper.openInputStream())) { + final byte[] rawBytes = FileUtils.readAll(in, 1024 * 1024); // 1MB, they're usually ~60KB + final GarminAgpsFile agpsFile = new GarminAgpsFile(rawBytes); + if (agpsFile.isValid()) { + this.file = agpsFile; + } + } catch (final Exception e) { + LOG.error("Failed to read file", e); + } + } + + @Override + public boolean isValid() { + return file != null; + } + + @Override + public void validateInstallation(final InstallActivity installActivity, final GBDevice device) { + if (device.isBusy()) { + installActivity.setInfoText(device.getBusyTask()); + installActivity.setInstallEnabled(false); + return; + } + + final DeviceCoordinator coordinator = device.getDeviceCoordinator(); + if (!(coordinator instanceof GarminCoordinator)) { + LOG.warn("Coordinator is not a GarminCoordinator: {}", coordinator.getClass()); + installActivity.setInfoText(mContext.getString(R.string.fwapp_install_device_not_supported)); + installActivity.setInstallEnabled(false); + return; + } + final GarminCoordinator garminCoordinator = (GarminCoordinator) coordinator; + if (!garminCoordinator.supportsAgpsUpdates()) { + installActivity.setInfoText(mContext.getString(R.string.fwapp_install_device_not_supported)); + installActivity.setInstallEnabled(false); + return; + } + + if (!device.isInitialized()) { + installActivity.setInfoText(mContext.getString(R.string.fwapp_install_device_not_ready)); + installActivity.setInstallEnabled(false); + return; + } + + final GenericItem fwItem = createInstallItem(device); + fwItem.setIcon(coordinator.getDefaultIconResource()); + + if (file == null) { + fwItem.setDetails(mContext.getString(R.string.miband_fwinstaller_incompatible_version)); + installActivity.setInfoText(mContext.getString(R.string.fwinstaller_firmware_not_compatible_to_device)); + installActivity.setInstallEnabled(false); + return; + } + + final StringBuilder builder = new StringBuilder(); + final String agpsBundle = mContext.getString(R.string.kind_agps_bundle); + builder.append(mContext.getString(R.string.fw_upgrade_notice, agpsBundle)); + builder.append("\n\n").append(mContext.getString(R.string.miband_firmware_unknown_warning)); + fwItem.setDetails(mContext.getString(R.string.miband_fwinstaller_untested_version)); + installActivity.setInfoText(builder.toString()); + installActivity.setInstallItem(fwItem); + installActivity.setInstallEnabled(true); + } + + @Override + public void onStartInstall(final GBDevice device) { + } + + public GarminAgpsFile getFile() { + return file; + } + + private GenericItem createInstallItem(final GBDevice device) { + DeviceCoordinator coordinator = device.getDeviceCoordinator(); + final String firmwareName = mContext.getString( + R.string.installhandler_firmware_name, + mContext.getString(coordinator.getDeviceNameResource()), + mContext.getString(R.string.kind_agps_bundle), + "" + ); + return new GenericItem(firmwareName); + } +} diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/GarminCoordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/GarminCoordinator.java index ab4881626..df0016734 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/GarminCoordinator.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/GarminCoordinator.java @@ -1,5 +1,8 @@ package nodomain.freeyourgadget.gadgetbridge.devices.garmin; +import android.content.Context; +import android.net.Uri; + import androidx.annotation.NonNull; import java.util.List; @@ -10,6 +13,7 @@ import nodomain.freeyourgadget.gadgetbridge.R; import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSpecificSettings; import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSpecificSettingsScreen; import nodomain.freeyourgadget.gadgetbridge.devices.AbstractBLEDeviceCoordinator; +import nodomain.freeyourgadget.gadgetbridge.devices.InstallHandler; import nodomain.freeyourgadget.gadgetbridge.entities.DaoSession; import nodomain.freeyourgadget.gadgetbridge.entities.Device; import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice; @@ -92,4 +96,20 @@ public abstract class GarminCoordinator extends AbstractBLEDeviceCoordinator { public boolean supportsUnicodeEmojis() { return true; } + + @Override + public InstallHandler findInstallHandler(final Uri uri, final Context context) { + if (supportsAgpsUpdates()) { + final GarminAgpsInstallHandler agpsInstallHandler = new GarminAgpsInstallHandler(uri, context); + if (agpsInstallHandler.isValid()) { + return agpsInstallHandler; + } + } + + return null; + } + + public boolean supportsAgpsUpdates() { + return false; + } } diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/vivoactive4s/GarminVivoActive4SCoordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/vivoactive4s/GarminVivoActive4SCoordinator.java index ee8e654e9..2765db1f5 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/vivoactive4s/GarminVivoActive4SCoordinator.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/vivoactive4s/GarminVivoActive4SCoordinator.java @@ -6,13 +6,23 @@ import nodomain.freeyourgadget.gadgetbridge.R; import nodomain.freeyourgadget.gadgetbridge.devices.garmin.GarminCoordinator; public class GarminVivoActive4SCoordinator extends GarminCoordinator { - @Override - protected Pattern getSupportedDeviceName() { - return Pattern.compile("vĂ­voactive 4S"); - } + @Override + protected Pattern getSupportedDeviceName() { + return Pattern.compile("vĂ­voactive 4S"); + } - @Override - public int getDeviceNameResource() { - return R.string.devicetype_garmin_vivoactive_4s; - } + @Override + public int getDeviceNameResource() { + return R.string.devicetype_garmin_vivoactive_4s; + } + + @Override + public boolean supportsFlashing() { + return true; + } + + @Override + public boolean supportsAgpsUpdates() { + return true; + } } diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/GarminSupport.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/GarminSupport.java index a4ad3f453..8944984a9 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/GarminSupport.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/GarminSupport.java @@ -3,11 +3,14 @@ package nodomain.freeyourgadget.gadgetbridge.service.devices.garmin; import android.bluetooth.BluetoothGatt; import android.bluetooth.BluetoothGattCharacteristic; import android.location.Location; +import android.net.Uri; +import android.widget.Toast; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import java.io.File; +import java.io.FileOutputStream; import java.io.IOException; import java.text.DecimalFormat; import java.util.ArrayList; @@ -25,6 +28,7 @@ import java.util.UUID; import nodomain.freeyourgadget.gadgetbridge.R; import nodomain.freeyourgadget.gadgetbridge.deviceevents.GBDeviceEvent; +import nodomain.freeyourgadget.gadgetbridge.devices.garmin.GarminAgpsInstallHandler; import nodomain.freeyourgadget.gadgetbridge.devices.garmin.GarminPreferences; import nodomain.freeyourgadget.gadgetbridge.devices.vivomovehr.GarminCapability; import nodomain.freeyourgadget.gadgetbridge.externalevents.gps.GBLocationService; @@ -616,6 +620,25 @@ public class GarminSupport extends AbstractBTLEDeviceSupport implements ICommuni } } + @Override + public void onInstallApp(final Uri uri) { + final GarminAgpsInstallHandler agpsHandler = new GarminAgpsInstallHandler(uri, getContext()); + if (agpsHandler.isValid()) { + try { + // Write the AGPS update to a temporary file in cache, so we can load it when requested + final File agpsFile = getAgpsFile(); + try (FileOutputStream outputStream = new FileOutputStream(agpsFile)) { + outputStream.write(agpsHandler.getFile().getBytes()); + LOG.info("AGPS file successfully written to the cache directory."); + } catch (final IOException e) { + LOG.error("Failed to write AGPS bytes to temporary directory", e); + } + } catch (final Exception e) { + GB.toast(getContext(), "AGPS install error: " + e.getMessage(), Toast.LENGTH_LONG, GB.ERROR, e); + } + } + } + private boolean checkFileExists(String fileName) { File dir; try { @@ -647,4 +670,20 @@ public class GarminSupport extends AbstractBTLEDeviceSupport implements ICommuni ); sendOutgoingMessage(locationUpdatedNotificationRequest); } + + public File getAgpsFile() throws IOException { + return new File(getAgpsCacheDirectory(), "CPE.BIN"); + } + + private File getAgpsCacheDirectory() throws IOException { + final File cacheDir = getContext().getCacheDir(); + final File agpsCacheDir = new File(cacheDir, "garmin-agps"); + if (agpsCacheDir.mkdir()) { + LOG.info("AGPS cache directory for Garmin devices successfully created."); + } else if (!agpsCacheDir.exists() || !agpsCacheDir.isDirectory()) { + throw new IOException("Cannot create/locate AGPS directory for Garmin devices."); + } + return agpsCacheDir; + } + } diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/file/GarminAgpsFile.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/file/GarminAgpsFile.java new file mode 100644 index 000000000..273735e10 --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/file/GarminAgpsFile.java @@ -0,0 +1,40 @@ +package nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.file; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import nodomain.freeyourgadget.gadgetbridge.util.ArrayUtils; + + +public class GarminAgpsFile { + private static final Logger LOG = LoggerFactory.getLogger(GarminAgpsFile.class); + public static final int TAR_MAGIC_BYTES_OFFSET = 257; + public static final byte[] TAR_MAGIC_BYTES = new byte[]{ + 'u', 's', 't', 'a', 'r', '\0' + }; + private final byte[] tarBytes; + + public GarminAgpsFile(final byte[] tarBytes) { + this.tarBytes = tarBytes; + } + + public boolean isValid() { + if (!ArrayUtils.equals(tarBytes, TAR_MAGIC_BYTES, TAR_MAGIC_BYTES_OFFSET)) { + LOG.debug("Is not TAR file!"); + return false; + } + + // TODO Add additional checks. + // Archive usually contains following files: + // CPE_GLO.BIN + // CPE_QZSS.BIN + // CPE_GPS.BIN + // CPE_GAL.BIN + + return true; + } + + public byte[] getBytes() { + return tarBytes.clone(); + } +} diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/http/DataTransferHandler.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/http/DataTransferHandler.java index 1268baa04..dbad3edb6 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/http/DataTransferHandler.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/http/DataTransferHandler.java @@ -5,17 +5,22 @@ import com.google.protobuf.ByteString; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import java.util.ArrayList; import java.util.Arrays; import java.util.HashMap; +import java.util.List; import java.util.Map; +import java.util.Objects; +import java.util.Random; import java.util.TreeMap; +import java.util.concurrent.Callable; import java.util.concurrent.atomic.AtomicInteger; import nodomain.freeyourgadget.gadgetbridge.proto.vivomovehr.GdiDataTransferService; public class DataTransferHandler { private static final Logger LOG = LoggerFactory.getLogger(DataTransferHandler.class); - private static final AtomicInteger idCounter = new AtomicInteger(0); + private static final AtomicInteger idCounter = new AtomicInteger((new Random()).nextInt(Integer.MAX_VALUE / 2)); private static final Map dataById = new HashMap<>(); private static final Map unprocessedChunksByRequestId = new HashMap<>(); @@ -94,6 +99,13 @@ public class DataTransferHandler { data.onDataChunkSuccessfullyReceived(chunkInfo); if (data.isDataSuccessfullySent()) { LOG.info("Data successfully sent to the device (id: {}, size: {})", chunkInfo.dataId, data.data.length); + for (Callable listener : data.onDataSuccessfullySentListeners) { + try { + listener.call(); + } catch (Exception e) { + LOG.error("Data listener failed.", e); + } + } dataById.remove(chunkInfo.dataId); } else { LOG.debug( @@ -103,6 +115,10 @@ public class DataTransferHandler { } } + public static void addOnDataSuccessfullySentListener(final int dataId, final Callable listener) { + Objects.requireNonNull(dataById.get(dataId)).onDataSuccessfullySentListeners.add(listener); + } + private static class ChunkInfo { private final int dataId; private final int start; @@ -120,10 +136,12 @@ public class DataTransferHandler { // Because now we have to store the whole data in RAM. private final byte[] data; private final TreeMap chunksReceivedByDevice; + private final List> onDataSuccessfullySentListeners; private Data(byte[] data) { this.data = data; chunksReceivedByDevice = new TreeMap<>(); + onDataSuccessfullySentListeners = new ArrayList<>(); } private byte[] getDataChunk(final int offset, final int maxChunkSize) { diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/http/EphemerisHandler.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/http/EphemerisHandler.java index 67cf710a1..1b43e87a7 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/http/EphemerisHandler.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/http/EphemerisHandler.java @@ -3,14 +3,15 @@ package nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.http; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import java.io.BufferedInputStream; -import java.io.DataInputStream; import java.io.File; import java.io.FileInputStream; import java.io.IOException; +import java.io.InputStream; import java.util.Map; +import java.util.concurrent.Callable; import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.GarminSupport; +import nodomain.freeyourgadget.gadgetbridge.util.FileUtils; public class EphemerisHandler { private static final Logger LOG = LoggerFactory.getLogger(EphemerisHandler.class); @@ -21,21 +22,30 @@ public class EphemerisHandler { } public byte[] handleEphemerisRequest(final String path, final Map query) { - // TODO Return status code 304 (Not Modified) when we don't have newer data and "if-none-match" is set. try { - final File exportDirectory = deviceSupport.getWritableExportDirectory(); - final File ephemerisDataFile = new File(exportDirectory, "CPE.BIN"); - if (!ephemerisDataFile.exists() || !ephemerisDataFile.isFile()) { - throw new IOException("Cannot locate CPE.BIN file in export/import directory."); + final File agpsFile = deviceSupport.getAgpsFile(); + if (!agpsFile.exists() || !agpsFile.isFile()) { + LOG.info("File with AGPS data does not exist."); + return null; + } + try(InputStream agpsIn = new FileInputStream(agpsFile)) { + final byte[] rawBytes = FileUtils.readAll(agpsIn, 1024 * 1024); // 1MB, they're usually ~60KB + LOG.info("Sending new AGPS data to the device."); + return rawBytes; } - final byte[] bytes = new byte[(int) ephemerisDataFile.length()]; - final BufferedInputStream bis = new BufferedInputStream(new FileInputStream(ephemerisDataFile)); - final DataInputStream dis = new DataInputStream(bis); - dis.readFully(bytes); - return bytes; } catch (IOException e) { LOG.error("Unable to obtain ephemeris data.", e); return null; } } + + public Callable getOnDataSuccessfullySentListener() { + return () -> { + LOG.info("AGPS data successfully sent to the device."); + if (deviceSupport.getAgpsFile().delete()) { + LOG.info("AGPS data was deleted from the cache folder."); + } + return null; + }; + } } diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/http/HttpHandler.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/http/HttpHandler.java index c90f7d599..9d5c0dd97 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/http/HttpHandler.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/http/HttpHandler.java @@ -16,6 +16,7 @@ import java.util.HashMap; import java.util.List; import java.util.Locale; import java.util.Map; +import java.util.concurrent.Callable; import java.util.zip.GZIPOutputStream; import nodomain.freeyourgadget.gadgetbridge.proto.vivomovehr.GdiHttpService; @@ -52,6 +53,7 @@ public class HttpHandler { } public GdiHttpService.HttpService.RawResponse handleRawRequest(final GdiHttpService.HttpService.RawRequest rawRequest) { + // TODO Return status code 304 (Not Modified) when we don't have newer data and "if-none-match" is set. final String urlString = rawRequest.getUrl(); LOG.debug("Got rawRequest: {} - {}", rawRequest.getMethod(), urlString); @@ -74,15 +76,15 @@ public class HttpHandler { } final String json = GSON.toJson(weatherData); LOG.debug("Weather response: {}", json); - return createRawResponse(rawRequest, json.getBytes(StandardCharsets.UTF_8), "application/json"); + return createRawResponse(rawRequest, json.getBytes(StandardCharsets.UTF_8), "application/json", null); } else if (path.startsWith("/ephemeris/")) { LOG.info("Got ephemeris request for {}", path); - byte[] ephemerisData = ephemerisHandler.handleEphemerisRequest(path, query); + final byte[] ephemerisData = ephemerisHandler.handleEphemerisRequest(path, query); if (ephemerisData == null) { return null; } LOG.debug("Successfully obtained ephemeris data (length: {})", ephemerisData.length); - return createRawResponse(rawRequest, ephemerisData, "application/x-tar"); + return createRawResponse(rawRequest, ephemerisData, "application/x-tar", ephemerisHandler.getOnDataSuccessfullySentListener()); } else { LOG.warn("Unhandled path {}", urlString); return null; @@ -92,11 +94,15 @@ public class HttpHandler { private static GdiHttpService.HttpService.RawResponse createRawResponse( final GdiHttpService.HttpService.RawRequest rawRequest, final byte[] data, - final String contentType - ) { + final String contentType, + final Callable onDataSuccessfullySentListener + ) { if (rawRequest.hasUseDataXfer() && rawRequest.getUseDataXfer()) { LOG.debug("Data will be returned using data_xfer"); int id = DataTransferHandler.registerData(data); + if (onDataSuccessfullySentListener != null) { + DataTransferHandler.addOnDataSuccessfullySentListener(id, onDataSuccessfullySentListener); + } return GdiHttpService.HttpService.RawResponse.newBuilder() .setStatus(GdiHttpService.HttpService.Status.OK) .setHttpStatus(200)