mirror of
https://codeberg.org/Freeyourgadget/Gadgetbridge.git
synced 2026-07-31 07:44:24 +02:00
This also resolves several TODO comments in code that that previously hard-coded it to never be night, primarily in Pebble code because that's what I have for testing. In WeatherMapper, map the day and night icon variants for all condition codes, even if the reference doesn't show a difference between the two icons. Map condition 511 "freezing rain" to use icon 13 "snow" instead of icon 09 "shower rain" to match the API reference documentation.
439 lines
18 KiB
Java
439 lines
18 KiB
Java
/* Copyright (C) 2017-2024 Andreas Shimokawa, Carsten Pfeiffer, Daniele
|
|
Gobbetti, Pavel Elagin
|
|
|
|
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 <https://www.gnu.org/licenses/>. */
|
|
package nodomain.freeyourgadget.gadgetbridge.webview;
|
|
|
|
import android.content.Intent;
|
|
import android.graphics.Bitmap;
|
|
import android.net.Uri;
|
|
import android.os.RemoteException;
|
|
import android.webkit.WebResourceRequest;
|
|
import android.webkit.WebResourceResponse;
|
|
import android.webkit.WebView;
|
|
import android.webkit.WebViewClient;
|
|
|
|
import net.e175.klaus.solarpositioning.DeltaT;
|
|
import net.e175.klaus.solarpositioning.SPA;
|
|
import net.e175.klaus.solarpositioning.SunriseTransitSet;
|
|
|
|
import org.apache.commons.lang3.StringUtils;
|
|
import org.jetbrains.annotations.NotNull;
|
|
import org.json.JSONArray;
|
|
import org.json.JSONException;
|
|
import org.json.JSONObject;
|
|
import org.slf4j.Logger;
|
|
import org.slf4j.LoggerFactory;
|
|
|
|
import java.io.ByteArrayInputStream;
|
|
import java.nio.charset.StandardCharsets;
|
|
import java.time.LocalDate;
|
|
import java.time.ZonedDateTime;
|
|
import java.util.ArrayList;
|
|
import java.util.HashMap;
|
|
import java.util.Iterator;
|
|
import java.util.List;
|
|
import java.util.Map;
|
|
|
|
import nodomain.freeyourgadget.gadgetbridge.GBApplication;
|
|
import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
|
|
import nodomain.freeyourgadget.gadgetbridge.internet.InternetFirewall;
|
|
import nodomain.freeyourgadget.gadgetbridge.internet.InternetRequestType;
|
|
import nodomain.freeyourgadget.gadgetbridge.model.WeatherSpec;
|
|
import nodomain.freeyourgadget.gadgetbridge.model.weather.Weather;
|
|
import nodomain.freeyourgadget.gadgetbridge.model.weather.WeatherMapper;
|
|
import nodomain.freeyourgadget.gadgetbridge.util.GBPrefs;
|
|
import nodomain.freeyourgadget.gadgetbridge.util.InternetHelperSingleton;
|
|
import nodomain.freeyourgadget.internethelper.aidl.http.HttpRequest;
|
|
|
|
public class GBWebClient extends WebViewClient {
|
|
private static final Logger LOG = LoggerFactory.getLogger(GBWebClient.class);
|
|
|
|
private final InternetFirewall firewall;
|
|
|
|
private final String[] LocallySupportedDomains = new String[]{
|
|
"openweathermap.org", //for weather :)
|
|
"rawgit.com", //for trekvolle
|
|
};
|
|
|
|
private final Map<String, List<Entry>> postData = new HashMap<>();
|
|
private record Entry(long timestamp, String body) {}
|
|
|
|
public GBWebClient(final InternetRequestType type, @NotNull final GBDevice device) {
|
|
super();
|
|
this.firewall = new InternetFirewall(type, device);
|
|
}
|
|
|
|
/**
|
|
* Stores HTTP POST body strings for later retrieval in mimicReply()
|
|
*/
|
|
public synchronized void storePostBody(String url, String body) {
|
|
long now = System.currentTimeMillis();
|
|
|
|
// Cleanup expired entries (more than a minute old)
|
|
Iterator<Map.Entry<String, List<Entry>>> mapIt = postData.entrySet().iterator();
|
|
while (mapIt.hasNext()) {
|
|
Map.Entry<String, List<Entry>> mapEntry = mapIt.next();
|
|
List<Entry> list = mapEntry.getValue();
|
|
list.removeIf(e -> now - e.timestamp > 60*1000);
|
|
if (list.isEmpty()) {
|
|
mapIt.remove();
|
|
}
|
|
}
|
|
|
|
// Add new entry
|
|
postData
|
|
.computeIfAbsent(url, k -> new ArrayList<>())
|
|
.add(new Entry(now, body));
|
|
}
|
|
|
|
/**
|
|
* Returns the earliest known HTTP POST body for a certain URL
|
|
*/
|
|
private synchronized String getFirstPostForUrl(String url) {
|
|
List<Entry> list = postData.get(url);
|
|
if (list == null || list.isEmpty()) {
|
|
return null;
|
|
}
|
|
|
|
Entry first = list.remove(0);
|
|
if (list.isEmpty()) {
|
|
postData.remove(url);
|
|
}
|
|
|
|
return first.body;
|
|
}
|
|
|
|
@Override
|
|
public WebResourceResponse shouldInterceptRequest(WebView view, WebResourceRequest request) {
|
|
Uri parsedUri = request.getUrl();
|
|
LOG.debug("WEBVIEW shouldInterceptRequest URL: {} (method {})", parsedUri.toString(), request.getMethod());
|
|
WebResourceResponse mimickedReply = mimicReply(parsedUri, request.getMethod(), request.getRequestHeaders());
|
|
if (mimickedReply != null)
|
|
return mimickedReply;
|
|
return super.shouldInterceptRequest(view, request);
|
|
}
|
|
|
|
@Override
|
|
public WebResourceResponse shouldInterceptRequest(WebView view, String url) {
|
|
LOG.debug("WEBVIEW shouldInterceptRequest URL (legacy): {}", url);
|
|
Uri parsedUri = Uri.parse(url);
|
|
WebResourceResponse mimickedReply = mimicReply(parsedUri, "GET", new HashMap<>());
|
|
if (mimickedReply != null)
|
|
return mimickedReply;
|
|
return super.shouldInterceptRequest(view, url);
|
|
}
|
|
|
|
private WebResourceResponse mimicReply(Uri requestedUri, String method, Map<String, String> requestHeaders) {
|
|
GBPrefs prefs = GBApplication.getPrefs();
|
|
boolean locallySupported = StringUtils.indexOfAny(requestedUri.getHost(), LocallySupportedDomains) != -1;
|
|
boolean urlIsAllowed = locallySupported;
|
|
boolean matchFound = false;
|
|
|
|
// Allow full access to internet when available
|
|
boolean directInternetAccess = GBApplication.hasDirectInternetAccess();
|
|
if (directInternetAccess && !locallySupported) {
|
|
return null;
|
|
}
|
|
|
|
// Handle local schemes locally
|
|
if (requestedUri.toString().startsWith("file://") || requestedUri.toString().startsWith("gadgetbridge://")) {
|
|
return null;
|
|
}
|
|
|
|
// Handle predefined groups
|
|
urlIsAllowed = firewall.isAllowed(requestedUri);
|
|
|
|
// Handle OpenWeatherMap locally
|
|
boolean forceLocal = false;
|
|
if (locallySupported && prefs.getBoolean("pref_key_internethelper_force_local", true)) {
|
|
urlIsAllowed = true;
|
|
forceLocal = true;
|
|
}
|
|
|
|
// Handle request
|
|
if (requestedUri.getHost() != null && urlIsAllowed) {
|
|
if (!forceLocal && !directInternetAccess && InternetHelperSingleton.INSTANCE.ensureInternetHelperBound()) {
|
|
LOG.debug("WEBVIEW forwarding request to the internet helper");
|
|
try {
|
|
HttpRequest.Method requestMethod = HttpRequest.Method.valueOf(method);
|
|
String body = null;
|
|
if (requestMethod == HttpRequest.Method.POST) {
|
|
body = getFirstPostForUrl(requestedUri.toString());
|
|
LOG.debug("WEBVIEW POSTing with body: {}", body);
|
|
}
|
|
WebResourceResponse wrr = InternetHelperSingleton.INSTANCE.send(requestedUri, requestMethod, requestHeaders, body != null ? body.getBytes(StandardCharsets.UTF_8) : null, false);
|
|
if (wrr != null && wrr.getStatusCode() < 400)
|
|
return wrr;
|
|
else
|
|
return null;
|
|
} catch (RemoteException | InterruptedException e) {
|
|
LOG.warn("Error downloading data from {}", requestedUri, e);
|
|
}
|
|
} else {
|
|
if (StringUtils.endsWith(requestedUri.getHost(), "openweathermap.org")){
|
|
LOG.debug("WEBVIEW request to openweathermap.org detected of type: {} params: {}", requestedUri.getPath(), requestedUri.getQuery());
|
|
return mimicOpenWeatherMapResponse(requestedUri.getPath(), requestedUri.getQueryParameter("units"));
|
|
} else if (StringUtils.endsWith(requestedUri.getHost(), "rawgit.com")) {
|
|
LOG.debug("WEBVIEW request to rawgit.com detected of type: {} params: {}", requestedUri.getPath(), requestedUri.getQuery());
|
|
return mimicRawGitResponse(requestedUri.getPath());
|
|
} else {
|
|
LOG.debug("WEBVIEW request to allowed domain detected but not intercepted: {}", requestedUri);
|
|
}
|
|
}
|
|
} else {
|
|
LOG.debug("WEBVIEW request not intercepted: {}", requestedUri);
|
|
}
|
|
return null;
|
|
}
|
|
|
|
@Override
|
|
public boolean shouldOverrideUrlLoading(WebView view, String url) {
|
|
Uri parsedUri = Uri.parse(url);
|
|
|
|
if (parsedUri.getScheme().startsWith("http")) {
|
|
if (GBApplication.hasInternetAccess()) {
|
|
view.loadUrl(url);
|
|
} else {
|
|
Intent i = new Intent(Intent.ACTION_VIEW, Uri.parse(url));
|
|
i.setFlags(Intent.FLAG_ACTIVITY_CLEAR_TASK | Intent.FLAG_ACTIVITY_NEW_TASK);
|
|
GBApplication.getContext().startActivity(i);
|
|
}
|
|
} else if (parsedUri.getScheme().startsWith("gadgetbridge")) {
|
|
url = url.replaceFirst("^gadgetbridge://.*json=", "file:///android_asset/app_config/configure.html?config=true&json=");
|
|
view.loadUrl(url);
|
|
} else if (parsedUri.getScheme().startsWith("pebblejs")) {
|
|
url = url.replaceFirst("^pebblejs://close#", "file:///android_asset/app_config/configure.html?config=true&json=");
|
|
view.loadUrl(url);
|
|
} else if (parsedUri.getScheme().equals("data")) { //clay
|
|
view.loadUrl(url);
|
|
} else {
|
|
LOG.debug("WEBVIEW Ignoring unhandled scheme: {}", parsedUri.getScheme());
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
@Override
|
|
public boolean shouldOverrideUrlLoading(WebView view, WebResourceRequest request) {
|
|
return shouldOverrideUrlLoading(view, request.getUrl().toString());
|
|
}
|
|
|
|
@Override
|
|
public void onPageStarted(WebView view, String url, Bitmap favicon) {
|
|
injectPostInterceptor(view);
|
|
}
|
|
|
|
@Override
|
|
public void onPageFinished(WebView view, String url) {
|
|
injectPostInterceptor(view);
|
|
}
|
|
|
|
private void injectPostInterceptor(WebView view) {
|
|
// Inject JavaScript for capturing POST body data, which we need when forwarding
|
|
// requests to the internet helper
|
|
view.evaluateJavascript(
|
|
"""
|
|
(function () {
|
|
if (window.__p) return; window.__p = 1;
|
|
const sb = b => b instanceof FormData
|
|
? [...b].map(e => e.map(encodeURIComponent).join('=')).join('&')
|
|
: b ? b.toString() : null;
|
|
|
|
const send = (u, b) => GBReqInt.onPostBody(new URL(u, location.href).toString(), sb(b));
|
|
|
|
const xo = XMLHttpRequest.prototype.open;
|
|
const xs = XMLHttpRequest.prototype.send;
|
|
XMLHttpRequest.prototype.open = function (m, u) {
|
|
this._m = m; this._u = u; return xo.apply(this, arguments);
|
|
};
|
|
XMLHttpRequest.prototype.send = function (b) {
|
|
if (this._m === 'POST') send(this._u, b);
|
|
return xs.apply(this, arguments);
|
|
};
|
|
|
|
const f = window.fetch;
|
|
window.fetch = function (i, o = {}) {
|
|
if ((o.method || 'GET') === 'POST')
|
|
send(typeof i === 'string' ? i : i.url, o.body);
|
|
return f.apply(this, arguments);
|
|
};
|
|
|
|
document.addEventListener('submit', e => {
|
|
const f = e.target;
|
|
if (f.method && f.method.toUpperCase() === 'POST')
|
|
send(f.action || location.href, new FormData(f));
|
|
}, true);
|
|
})();
|
|
""",
|
|
null
|
|
);
|
|
}
|
|
|
|
private WebResourceResponse mimicRawGitResponse(String path) {
|
|
if("/aHcVolle/TrekVolle/master/online.html".equals(path)) { //TrekVolle online check
|
|
Map<String, String> headers = new HashMap<>();
|
|
headers.put("Access-Control-Allow-Origin", "*");
|
|
return new WebResourceResponse("text/html", "utf-8", 200, "OK",
|
|
headers,
|
|
new ByteArrayInputStream("1".getBytes())
|
|
);
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
private WebResourceResponse mimicOpenWeatherMapResponse(String type, String units) {
|
|
|
|
if (Weather.getWeatherSpecs().isEmpty()) {
|
|
LOG.warn("WEBVIEW - WeatherSpecs is empty, cannot update weather");
|
|
return null;
|
|
}
|
|
|
|
CurrentPosition currentPosition = new CurrentPosition();
|
|
WeatherSpec current = Weather.getWeatherSpec();
|
|
|
|
try {
|
|
JSONObject resp = createReconstructedOWMWeatherReply(current);
|
|
if ("/data/2.5/weather".equals(type) && resp != null) {
|
|
JSONObject main = resp.getJSONObject("main");
|
|
|
|
convertTemps(main, units); //caller might want different units
|
|
JSONObject wind = resp.getJSONObject("wind");
|
|
convertSpeeds(wind, units);
|
|
|
|
resp.put("cod", 200);
|
|
resp.put("coord", coordObject(currentPosition));
|
|
resp.put("sys", sysObject(currentPosition));
|
|
// } else if ("/data/2.5/forecast".equals(type) && Weather.getWeather2().reconstructedOWMForecast != null) { //this is wrong, as we only have daily data. Unfortunately it looks like daily forecasts cannot be reconstructed
|
|
// resp = new JSONObject(Weather.getWeather2().reconstructedOWMForecast.toString());
|
|
//
|
|
// JSONObject city = resp.getJSONObject("city");
|
|
// city.put("coord", coordObject(currentPosition));
|
|
//
|
|
// JSONArray list = resp.getJSONArray("list");
|
|
// for (int i = 0, size = list.length(); i < size; i++) {
|
|
// JSONObject item = list.getJSONObject(i);
|
|
// JSONObject main = item.getJSONObject("main");
|
|
// convertTemps(main, units); //caller might want different units
|
|
// }
|
|
//
|
|
// resp.put("cod", 200);
|
|
} else {
|
|
LOG.warn("WEBVIEW - cannot mimick request of type {} (unsupported or lack of data)", type);
|
|
return null;
|
|
}
|
|
|
|
LOG.info("WEBVIEW - mimic openweather response {}", resp.toString());
|
|
Map<String, String> headers = new HashMap<>();
|
|
headers.put("Access-Control-Allow-Origin", "*");
|
|
|
|
return new WebResourceResponse("application/json", "utf-8", 200, "OK",
|
|
headers,
|
|
new ByteArrayInputStream(resp.toString().getBytes())
|
|
);
|
|
} catch (JSONException e) {
|
|
LOG.warn("Error building the JSON weather message.", e);
|
|
}
|
|
|
|
return null;
|
|
|
|
}
|
|
|
|
public JSONObject createReconstructedOWMWeatherReply(WeatherSpec weatherSpec) {
|
|
if (weatherSpec == null) {
|
|
return null;
|
|
}
|
|
JSONObject reconstructedOWMWeather = new JSONObject();
|
|
JSONArray weather = new JSONArray();
|
|
JSONObject condition = new JSONObject();
|
|
JSONObject main = new JSONObject();
|
|
JSONObject wind = new JSONObject();
|
|
|
|
try {
|
|
condition.put("id", weatherSpec.getCurrentConditionCode());
|
|
condition.put("main", weatherSpec.getCurrentCondition());
|
|
condition.put("description", weatherSpec.getCurrentCondition());
|
|
condition.put("icon", WeatherMapper.mapToOpenWeatherMapIcon(weatherSpec.getCurrentConditionCode(),weatherSpec.isNight()));
|
|
weather.put(condition);
|
|
|
|
|
|
main.put("temp", weatherSpec.getCurrentTemp());
|
|
main.put("humidity", weatherSpec.getCurrentHumidity());
|
|
main.put("temp_min", weatherSpec.getTodayMinTemp());
|
|
main.put("temp_max", weatherSpec.getTodayMaxTemp());
|
|
|
|
wind.put("speed", (weatherSpec.getWindSpeed() / 3.6f)); //meter per second
|
|
wind.put("deg", weatherSpec.getWindDirection());
|
|
|
|
reconstructedOWMWeather.put("weather", weather);
|
|
reconstructedOWMWeather.put("main", main);
|
|
reconstructedOWMWeather.put("name", weatherSpec.getLocation());
|
|
reconstructedOWMWeather.put("wind", wind);
|
|
|
|
} catch (JSONException e) {
|
|
LOG.error("Error while reconstructing OWM weather reply");
|
|
return null;
|
|
}
|
|
LOG.debug("Weather JSON for WEBVIEW: {}", reconstructedOWMWeather);
|
|
return reconstructedOWMWeather;
|
|
}
|
|
|
|
|
|
private static JSONObject sysObject(CurrentPosition currentPosition) throws JSONException {
|
|
final SunriseTransitSet sunriseTransitSet = SPA.calculateSunriseTransitSet(
|
|
ZonedDateTime.now(),
|
|
currentPosition.getLatitude(),
|
|
currentPosition.getLongitude(),
|
|
DeltaT.estimate(LocalDate.now())
|
|
);
|
|
|
|
JSONObject sys = new JSONObject();
|
|
sys.put("country", "World");
|
|
sys.put("sunrise", sunriseTransitSet.getSunrise().toInstant().getEpochSecond());
|
|
sys.put("sunset", sunriseTransitSet.getSunset().toInstant().getEpochSecond());
|
|
|
|
return sys;
|
|
}
|
|
|
|
private static void convertSpeeds(JSONObject wind, String units) throws JSONException {
|
|
if ("metric".equals(units)) {
|
|
wind.put("speed", (wind.getDouble("speed") * 3.6f) );
|
|
} else if ("imperial".equals(units)) { //it's 2018... this is so sad
|
|
wind.put("speed", (wind.getDouble("speed") * 2.237f) );
|
|
}
|
|
}
|
|
|
|
private static void convertTemps(JSONObject main, String units) throws JSONException {
|
|
if ("metric".equals(units)) {
|
|
main.put("temp", (int) main.get("temp") - 273);
|
|
main.put("temp_min", (int) main.get("temp_min") - 273);
|
|
main.put("temp_max", (int) main.get("temp_max") - 273);
|
|
} else if ("imperial".equals(units)) { //it's 2017... this is so sad
|
|
main.put("temp", ((int) (main.get("temp")) - 273.15f) * 1.8f + 32); // lgtm [java/integer-multiplication-cast-to-long]
|
|
main.put("temp_min", ((int) (main.get("temp_min")) - 273.15f) * 1.8f + 32); // lgtm [java/integer-multiplication-cast-to-long]
|
|
main.put("temp_max", ((int) (main.get("temp_max")) - 273.15f) * 1.8f + 32); // lgtm [java/integer-multiplication-cast-to-long]
|
|
}
|
|
}
|
|
|
|
private static JSONObject coordObject(CurrentPosition currentPosition) throws JSONException {
|
|
JSONObject coord = new JSONObject();
|
|
coord.put("lat", currentPosition.getLatitude());
|
|
coord.put("lon", currentPosition.getLongitude());
|
|
return coord;
|
|
}
|
|
}
|