From 88a9c81dccc376bc166758ab7389777f8d310846 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Rebelo?= Date: Fri, 17 May 2024 17:24:50 +0100 Subject: [PATCH] Garmin: Implement etag for agps requests --- .../devices/garmin/agps/AgpsHandler.java | 60 +++++++-- .../garmin/http/GarminHttpRequest.java | 64 +++++++++ .../garmin/http/GarminHttpResponse.java | 41 ++++++ .../devices/garmin/http/HttpHandler.java | 121 +++++++----------- .../devices/garmin/http/WeatherHandler.java | 37 +++++- 5 files changed, 225 insertions(+), 98 deletions(-) create mode 100644 app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/http/GarminHttpRequest.java create mode 100644 app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/http/GarminHttpResponse.java diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/agps/AgpsHandler.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/agps/AgpsHandler.java index e9375711f..06f1ef23a 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/agps/AgpsHandler.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/agps/AgpsHandler.java @@ -11,14 +11,18 @@ import java.io.InputStream; import java.util.ArrayList; import java.util.Collections; import java.util.List; -import java.util.Map; +import java.util.Locale; import java.util.Objects; import java.util.concurrent.Callable; import nodomain.freeyourgadget.gadgetbridge.deviceevents.GBDeviceEventUpdatePreferences; import nodomain.freeyourgadget.gadgetbridge.devices.garmin.GarminPreferences; import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.GarminSupport; +import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.http.GarminHttpRequest; +import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.http.GarminHttpResponse; +import nodomain.freeyourgadget.gadgetbridge.util.CheckSums; import nodomain.freeyourgadget.gadgetbridge.util.FileUtils; +import nodomain.freeyourgadget.gadgetbridge.util.GB; import nodomain.freeyourgadget.gadgetbridge.util.Prefs; public class AgpsHandler { @@ -30,13 +34,13 @@ public class AgpsHandler { this.deviceSupport = deviceSupport; } - public byte[] handleAgpsRequest(final String url, final String path, final Map query) { - saveKnownUrl(url); + public GarminHttpResponse handleAgpsRequest(final GarminHttpRequest request) { + saveKnownUrl(request.getUrl()); try { - final DocumentFile agpsFile = deviceSupport.getAgpsFile(url); + final DocumentFile agpsFile = deviceSupport.getAgpsFile(request.getUrl()); if (agpsFile == null) { - LOG.warn("File with AGPS data for {} does not exist.", url); + LOG.warn("File with AGPS data for {} does not exist.", request.getUrl()); return null; } try (InputStream agpsIn = deviceSupport.getContext().getContentResolver().openInputStream(agpsFile.getUri())) { @@ -45,18 +49,35 @@ public class AgpsHandler { return null; } - // Run some sanity checks on known agps file formats + final GarminHttpResponse response = new GarminHttpResponse(); + final byte[] rawBytes = FileUtils.readAll(agpsIn, 1024 * 1024); // 1MB, they're usually ~60KB + final String fileHash = GB.hexdump(CheckSums.md5(rawBytes)).toLowerCase(Locale.ROOT); + final String etag = "\"" + fileHash + "\""; + response.getHeaders().put("etag", etag); + + if (request.getHeaders().containsKey("if-none-match")) { + // Check checksum + final String ifNoneMatch = request.getHeaders().get("if-none-match"); + LOG.debug("agps request hash = {}, file hash = {}", ifNoneMatch, etag); + + if (etag.equals(ifNoneMatch)) { + response.setStatus(304); + return response; + } + } + + // Run some sanity checks on known agps file formats final GarminAgpsFile garminAgpsFile = new GarminAgpsFile(rawBytes); - if (query.containsKey(QUERY_CONSTELLATIONS)) { - final String[] requestedConstellations = Objects.requireNonNull(query.get(QUERY_CONSTELLATIONS)).split(","); + if (request.getQuery().containsKey(QUERY_CONSTELLATIONS)) { + final String[] requestedConstellations = Objects.requireNonNull(request.getQuery().get(QUERY_CONSTELLATIONS)).split(","); if (!garminAgpsFile.isValidTar(requestedConstellations)) { - reportError(url); + reportError(request.getUrl()); return null; } - } else if (path.contains(("/rxnetworks/"))) { + } else if (request.getPath().contains(("/rxnetworks/"))) { if (!garminAgpsFile.isValidRxNetworks()) { - reportError(url); + reportError(request.getUrl()); return null; } } else { @@ -64,12 +85,23 @@ public class AgpsHandler { return null; } - LOG.info("Sending new AGPS data to the device from {}", agpsFile.getUri()); - return rawBytes; + LOG.info("Sending new AGPS data (length: {}) to the device from {}", rawBytes.length, agpsFile.getUri()); + + if (request.getHeaders().containsKey("accept")) { + response.getHeaders().put("Content-Type", request.getHeaders().get("accept")); + } else { + response.getHeaders().put("Content-Type", "application/octet-stream"); + } + + response.setStatus(200); + response.setBody(rawBytes); + response.setOnDataSuccessfullySentListener(getOnDataSuccessfullySentListener(request.getUrl())); + + return response; } } catch (final IOException e) { LOG.error("Unable to obtain AGPS data", e); - reportError(url); + reportError(request.getUrl()); return null; } } diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/http/GarminHttpRequest.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/http/GarminHttpRequest.java new file mode 100644 index 000000000..cdab877b4 --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/http/GarminHttpRequest.java @@ -0,0 +1,64 @@ +package nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.http; + +import java.net.MalformedURLException; +import java.net.URL; +import java.util.HashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; + +import nodomain.freeyourgadget.gadgetbridge.proto.garmin.GdiHttpService; +import nodomain.freeyourgadget.gadgetbridge.util.HttpUtils; + +public class GarminHttpRequest { + private final GdiHttpService.HttpService.RawRequest rawRequest; + + private final String url; + private final String path; + private final Map query; + private final Map headers; + + public GarminHttpRequest(final GdiHttpService.HttpService.RawRequest rawRequest) { + this.rawRequest = rawRequest; + + final URL netUrl; + try { + netUrl = new URL(rawRequest.getUrl()); + } catch (final MalformedURLException e) { + throw new IllegalArgumentException("Failed to parse url", e); + } + + this.url = rawRequest.getUrl(); + this.path = netUrl.getPath(); + this.query = HttpUtils.urlQueryParameters(netUrl); + this.headers = headersToMap(rawRequest.getHeaderList()); + } + + public GdiHttpService.HttpService.RawRequest getRawRequest() { + return rawRequest; + } + + public String getUrl() { + return url; + } + + public String getPath() { + return path; + } + + public Map getQuery() { + return query; + } + + public Map getHeaders() { + return headers; + } + + private static Map headersToMap(final List headers) { + final Map ret = new HashMap<>(); + for (final GdiHttpService.HttpService.Header header : headers) { + ret.put(header.getKey().toLowerCase(Locale.ROOT), header.getValue()); + } + return ret; + } +} diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/http/GarminHttpResponse.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/http/GarminHttpResponse.java new file mode 100644 index 000000000..9cbadaa68 --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/http/GarminHttpResponse.java @@ -0,0 +1,41 @@ +package nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.http; + +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.concurrent.Callable; + +public class GarminHttpResponse { + private int status = 200; + private final Map headers = new LinkedHashMap<>(); + private byte[] body = new byte[0]; + + private Callable onDataSuccessfullySentListener; + + public int getStatus() { + return status; + } + + public void setStatus(final int status) { + this.status = status; + } + + public Map getHeaders() { + return headers; + } + + public byte[] getBody() { + return body; + } + + public void setBody(final byte[] body) { + this.body = body; + } + + public Callable getOnDataSuccessfullySentListener() { + return onDataSuccessfullySentListener; + } + + public void setOnDataSuccessfullySentListener(final Callable onDataSuccessfullySentListener) { + this.onDataSuccessfullySentListener = onDataSuccessfullySentListener; + } +} 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 a205be10a..7838329aa 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 @@ -1,36 +1,23 @@ package nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.http; -import com.google.gson.Gson; -import com.google.gson.GsonBuilder; import com.google.protobuf.ByteString; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import java.io.ByteArrayOutputStream; -import java.net.MalformedURLException; -import java.net.URL; -import java.nio.charset.StandardCharsets; import java.util.ArrayList; -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.garmin.GdiHttpService; import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.GarminSupport; import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.agps.AgpsHandler; -import nodomain.freeyourgadget.gadgetbridge.util.HttpUtils; public class HttpHandler { private static final Logger LOG = LoggerFactory.getLogger(HttpHandler.class); - private static final Gson GSON = new GsonBuilder() - //.serializeNulls() - .create(); - private final AgpsHandler agpsHandler; public HttpHandler(GarminSupport deviceSupport) { @@ -54,74 +41,67 @@ 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); + LOG.debug("Got rawRequest: {} - {}", rawRequest.getMethod(), rawRequest.getUrl()); - final URL url; - try { - url = new URL(urlString); - } catch (final MalformedURLException e) { - LOG.error("Failed to parse url", e); - return null; - } + final GarminHttpRequest request = new GarminHttpRequest(rawRequest); - final String path = url.getPath(); - final Map query = HttpUtils.urlQueryParameters(url); - final Map requestHeaders = headersToMap(rawRequest.getHeaderList()); - - if (path.startsWith("/weather/")) { - LOG.info("Got weather request for {}", path); - final Object weatherData = WeatherHandler.handleWeatherRequest(path, query); - if (weatherData == null) { - return null; - } - final String json = GSON.toJson(weatherData); - LOG.debug("Weather response: {}", json); - return createRawResponse(rawRequest, json.getBytes(StandardCharsets.UTF_8), "application/json", null); - } else if (path.startsWith("/ephemeris/")) { - LOG.info("Got AGPS request for {}", path); - final byte[] agpsData = agpsHandler.handleAgpsRequest(urlString, path, query); - if (agpsData == null) { - return null; - } - LOG.debug("Successfully obtained AGPS data (length: {})", agpsData.length); - final String contentType = requestHeaders.containsKey("accept") ? requestHeaders.get("accept") : "application/octet-stream"; - return createRawResponse(rawRequest, agpsData, contentType, agpsHandler.getOnDataSuccessfullySentListener(urlString)); + final GarminHttpResponse response; + if (request.getPath().startsWith("/weather/")) { + LOG.info("Got weather request for {}", request.getPath()); + response = WeatherHandler.handleWeatherRequest(request); + } else if (request.getPath().startsWith("/ephemeris/")) { + LOG.info("Got AGPS request for {}", request.getPath()); + response = agpsHandler.handleAgpsRequest(request); } else { - LOG.warn("Unhandled path {}", urlString); + LOG.warn("Unhandled path {}", request.getPath()); + response = null; + } + + if (response == null) { return null; } + + LOG.debug("Http response status={}", response.getStatus()); + + return createRawResponse(request, response); } private static GdiHttpService.HttpService.RawResponse createRawResponse( - final GdiHttpService.HttpService.RawRequest rawRequest, - final byte[] data, - final String contentType, - final Callable onDataSuccessfullySentListener - ) { - if (rawRequest.hasUseDataXfer() && rawRequest.getUseDataXfer()) { + final GarminHttpRequest request, + final GarminHttpResponse response + ) { + final List responseHeaders = new ArrayList<>(); + for (final Map.Entry h : response.getHeaders().entrySet()) { + responseHeaders.add( + GdiHttpService.HttpService.Header.newBuilder() + .setKey(h.getKey()) + .setValue(h.getValue()) + .build() + ); + } + + if (response.getStatus() == 200 && request.getRawRequest().hasUseDataXfer() && request.getRawRequest().getUseDataXfer()) { LOG.debug("Data will be returned using data_xfer"); - int id = DataTransferHandler.registerData(data); - if (onDataSuccessfullySentListener != null) { - DataTransferHandler.addOnDataSuccessfullySentListener(id, onDataSuccessfullySentListener); + int id = DataTransferHandler.registerData(response.getBody()); + if (response.getOnDataSuccessfullySentListener() != null) { + DataTransferHandler.addOnDataSuccessfullySentListener(id, response.getOnDataSuccessfullySentListener()); } return GdiHttpService.HttpService.RawResponse.newBuilder() .setStatus(GdiHttpService.HttpService.Status.OK) - .setHttpStatus(200) + .setHttpStatus(response.getStatus()) + .addAllHeader(responseHeaders) .setXferData( GdiHttpService.HttpService.DataTransferItem.newBuilder() .setId(id) - .setSize(data.length) + .setSize(response.getBody().length) .build() ) .build(); } - final Map requestHeaders = headersToMap(rawRequest.getHeaderList()); - final List responseHeaders = new ArrayList<>(); final byte[] responseBody; - if ("gzip".equals(requestHeaders.get("accept-encoding"))) { + if ("gzip".equals(request.getHeaders().get("accept-encoding"))) { + LOG.debug("Compressing response"); responseHeaders.add( GdiHttpService.HttpService.Header.newBuilder() .setKey("Content-Encoding") @@ -131,7 +111,7 @@ public class HttpHandler { final ByteArrayOutputStream baos = new ByteArrayOutputStream(); try (GZIPOutputStream gzos = new GZIPOutputStream(baos)) { - gzos.write(data); + gzos.write(response.getBody()); gzos.finish(); gzos.flush(); responseBody = baos.toByteArray(); @@ -140,29 +120,14 @@ public class HttpHandler { return null; } } else { - responseBody = data; + responseBody = response.getBody(); } - responseHeaders.add( - GdiHttpService.HttpService.Header.newBuilder() - .setKey("Content-Type") - .setValue(contentType) - .build() - ); - return GdiHttpService.HttpService.RawResponse.newBuilder() .setStatus(GdiHttpService.HttpService.Status.OK) - .setHttpStatus(200) + .setHttpStatus(response.getStatus()) .setBody(ByteString.copyFrom(responseBody)) .addAllHeader(responseHeaders) .build(); } - - private static Map headersToMap(final List headers) { - final Map ret = new HashMap<>(); - for (final GdiHttpService.HttpService.Header header : headers) { - ret.put(header.getKey().toLowerCase(Locale.ROOT), header.getValue()); - } - return ret; - } } diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/http/WeatherHandler.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/http/WeatherHandler.java index d5909be00..a55a9011f 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/http/WeatherHandler.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/http/WeatherHandler.java @@ -2,12 +2,16 @@ package nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.http; import android.location.Location; +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; + import net.e175.klaus.solarpositioning.DeltaT; import net.e175.klaus.solarpositioning.SPA; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import java.nio.charset.StandardCharsets; import java.util.ArrayList; import java.util.Calendar; import java.util.Date; @@ -23,8 +27,15 @@ import nodomain.freeyourgadget.gadgetbridge.service.devices.pebble.webview.Curre public class WeatherHandler { private static final Logger LOG = LoggerFactory.getLogger(WeatherHandler.class); + private static final Gson GSON = new GsonBuilder() + //.serializeNulls() + .create(); + // These get requested on connection at most every 5 minutes - public static Object handleWeatherRequest(final String path, final Map query) { + public static GarminHttpResponse handleWeatherRequest(final GarminHttpRequest request) { + final String path = request.getPath(); + final Map query = request.getQuery(); + final WeatherSpec weatherSpec = Weather.getInstance().getWeatherSpec(); if (weatherSpec == null) { @@ -32,6 +43,7 @@ public class WeatherHandler { return null; } + final Object weatherData; switch (path) { case "/weather/v2/forecast/day": { final int lat = getQueryNum(query, "lat", 0); @@ -46,7 +58,8 @@ public class WeatherHandler { date.add(Calendar.DAY_OF_MONTH, 1); ret.add(new WeatherForecastDay(date, weatherSpec.forecasts.get(i))); } - return ret; + weatherData = ret; + break; } case "/weather/v2/forecast/hour": { final int lat = getQueryNum(query, "lat", 0); @@ -60,7 +73,8 @@ public class WeatherHandler { for (int i = 0; i < Math.min(duration, weatherSpec.hourly.size()); i++) { ret.add(new WeatherForecastHour(weatherSpec.hourly.get(i))); } - return ret; + weatherData = ret; + break; } case "/weather/v2/current": { final int lat = getQueryNum(query, "lat", 0); @@ -68,13 +82,24 @@ public class WeatherHandler { final String tempUnit = getQueryString(query, "tempUnit", "CELSIUS"); final String speedUnit = getQueryString(query, "speedUnit", "METERS_PER_SECOND"); final String provider = getQueryString(query, "provider", "dci"); - return new WeatherForecastCurrent(weatherSpec); + weatherData = new WeatherForecastCurrent(weatherSpec); + break; } + default: + LOG.warn("Unknown weather path {}", path); + final GarminHttpResponse response = new GarminHttpResponse(); + response.setStatus(404); + return response; } - LOG.warn("Unknown weather path {}", path); + final String json = GSON.toJson(weatherData); + LOG.debug("Weather response: {}", json); - return null; + final GarminHttpResponse response = new GarminHttpResponse(); + response.setStatus(200); + response.setBody(json.getBytes(StandardCharsets.UTF_8)); + response.getHeaders().put("Content-Type", "application/json"); + return response; } private static int getQueryNum(final Map query, final String key, final int defaultValue) {