Centralize internet requests, removing Volley in the process

This commit is contained in:
Arjan Schrijver
2025-12-25 14:45:08 +01:00
committed by Arjan Schrijver
parent 3b1eda31a6
commit b0c0eda4f3
12 changed files with 292 additions and 452 deletions
-1
View File
@@ -268,7 +268,6 @@ dependencies {
implementation 'com.github.wax911.android-emojify:emojify:1.9.7'
implementation 'com.github.wax911.android-emojify:gson:1.9.7'
implementation 'com.google.protobuf:protobuf-javalite:4.33.2'
implementation 'com.android.volley:volley:1.2.1'
implementation 'com.squareup.okhttp3:okhttp:5.2.1'
implementation 'org.msgpack:msgpack-core:0.9.10'
implementation 'com.github.ByteHamster:SearchPreference:2.7.3'
+3 -3
View File
@@ -63,10 +63,10 @@
"path": "licenses/desugar_jdk_libs.txt"
},
{
"name": "volley",
"owner": "Google",
"name": "okhttp",
"owner": "Square, Inc.",
"type": "Apache 2.0",
"url": "https://github.com/google/volley",
"url": "https://github.com/square/okhttp",
"path": "licenses/apache-2.0.txt"
},
{
@@ -58,10 +58,6 @@ public class ExternalPebbleJSActivity extends AbstractGBActivity {
private static final Logger LOG = LoggerFactory.getLogger(ExternalPebbleJSActivity.class);
private Uri confUri;
/**
* When bgjs is enabled, this field refers to the WebViewSingleton,
* otherwise it refers to the legacy webview from the activity_legacy_external_pebble_js layout
*/
private WebView myWebView;
@Override
@@ -23,41 +23,24 @@ import android.net.Uri
import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.os.ParcelFileDescriptor
import android.webkit.PermissionRequest
import android.webkit.WebResourceRequest
import android.webkit.WebView
import android.widget.Toast
import androidx.core.net.toUri
import com.android.volley.Request
import com.android.volley.toolbox.JsonObjectRequest
import com.android.volley.toolbox.Volley
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import nodomain.freeyourgadget.gadgetbridge.GBApplication
import nodomain.freeyourgadget.gadgetbridge.R
import nodomain.freeyourgadget.gadgetbridge.activities.AbstractGBActivity
import nodomain.freeyourgadget.gadgetbridge.devices.InstallHandler
import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice
import nodomain.freeyourgadget.gadgetbridge.model.DeviceService
import nodomain.freeyourgadget.gadgetbridge.util.GB
import nodomain.freeyourgadget.gadgetbridge.util.InternetHelperSingleton
import nodomain.freeyourgadget.gadgetbridge.util.InternetUtils
import nodomain.freeyourgadget.gadgetbridge.webview.GBChromeClient
import nodomain.freeyourgadget.gadgetbridge.webview.GBWebClient
import nodomain.freeyourgadget.internethelper.aidl.http.HttpGetRequest
import nodomain.freeyourgadget.internethelper.aidl.http.HttpHeaders
import nodomain.freeyourgadget.internethelper.aidl.http.HttpResponse
import nodomain.freeyourgadget.internethelper.aidl.http.IHttpCallback
import okhttp3.OkHttpClient
import org.json.JSONObject
import org.slf4j.Logger
import org.slf4j.LoggerFactory
import java.io.File
import java.io.FileOutputStream
import java.io.InputStreamReader
import java.io.OutputStream
import java.nio.charset.StandardCharsets
class RebbleAppStoreActivity : AbstractGBActivity() {
val LOG: Logger = LoggerFactory.getLogger(RebbleAppStoreActivity::class.java)
@@ -99,148 +82,24 @@ class RebbleAppStoreActivity : AbstractGBActivity() {
}
private fun downloadInstallWatchapp(url: Uri) {
if (GBApplication.hasDirectInternetAccess()) {
downloadBinaryFile(url) { file ->
installFile(file)
}
} else {
val httpHeaders = HttpHeaders()
val httpGetRequest = HttpGetRequest(url.toString(), httpHeaders)
InternetHelperSingleton.getHttpService()
?.get(httpGetRequest, object : IHttpCallback.Stub() {
override fun onResponse(response: HttpResponse) {
val contentType = response.headers["content-type"]?.split(";")?.get(0)
if (!contentType.equals("application/octet-stream") && !contentType.equals("application/zip")) {
GB.toast(
getString(
R.string.rebble_appstore_download_failed_wrong_content_type,
contentType
),
Toast.LENGTH_LONG, GB.ERROR
)
LOG.error("Received content-type $contentType but expected application/octet-stream or application/zip")
return
}
val inputStream = ParcelFileDescriptor.AutoCloseInputStream(response.body)
val filename = url.lastPathSegment ?: "downloaded_file.pbw"
val cacheDir = applicationContext.externalCacheDir ?: return
val cacheFile = File(cacheDir, filename)
val outputStream: OutputStream = FileOutputStream(cacheFile)
val buffer = ByteArray(1024)
var len: Int
while (inputStream.read(buffer).also { len = it } > 0) {
outputStream.write(buffer, 0, len)
}
outputStream.flush()
outputStream.close()
installFile(cacheFile)
}
val filename = url.lastPathSegment ?: "downloaded_file.bin"
val cacheDir = applicationContext.externalCacheDir
val targetFile = File(cacheDir, filename)
override fun onException(message: String?) {
GB.toast(
getString(R.string.rebble_appstore_download_failed, message),
Toast.LENGTH_LONG,
GB.ERROR
)
LOG.error("Failed downloading file: $message")
}
})
InternetUtils.downloadBinaryFile(url, targetFile) { file ->
installFile(file)
}
}
private fun downloadInstallWatchappById(storeId: String) {
val appUrl = "https://appstore-api.rebble.io/api/v1/apps/id/$storeId"
if (GBApplication.hasDirectInternetAccess()) {
val requestQueue = Volley.newRequestQueue(this)
val jsonObjectRequest = JsonObjectRequest(
Request.Method.GET, appUrl, null,
{ response ->
val dataArray = response.getJSONArray("data")
val firstAppObject = dataArray.getJSONObject(0)
val latestRelease = firstAppObject.getJSONObject("latest_release")
val pbwFile = latestRelease.getString("pbw_file")
downloadInstallWatchapp(pbwFile.toUri())
},
{ error ->
GB.toast(
getString(
R.string.rebble_appstore_fetching_download_file_failed,
error
), Toast.LENGTH_LONG, GB.ERROR
)
LOG.error("Failed fetching download file URL", error)
}
)
requestQueue.add(jsonObjectRequest)
} else {
val httpHeaders = HttpHeaders()
val httpGetRequest = HttpGetRequest(appUrl, httpHeaders)
InternetHelperSingleton.getHttpService()
?.get(httpGetRequest, object : IHttpCallback.Stub() {
override fun onResponse(response: HttpResponse) {
val contentType = response.headers["content-type"]?.split(";")?.get(0)
if (!contentType.equals("application/json")) {
GB.toast(
getString(
R.string.rebble_appstore_fetch_app_info_failed_content_type,
contentType
),
Toast.LENGTH_LONG, GB.ERROR
)
LOG.error("Received content-type $contentType but expected application/json")
return
}
val inputStream = ParcelFileDescriptor.AutoCloseInputStream(response.body)
val responseBody =
InputStreamReader(inputStream, StandardCharsets.UTF_8).readText()
val jsonObject = JSONObject(responseBody)
val dataArray = jsonObject.getJSONArray("data")
val firstAppObject = dataArray.getJSONObject(0)
val latestRelease = firstAppObject.getJSONObject("latest_release")
val pbwFile = latestRelease.getString("pbw_file")
downloadInstallWatchapp(pbwFile.toUri())
}
override fun onException(message: String?) {
GB.toast(
getString(
R.string.rebble_appstore_fetching_download_file_failed,
message
), Toast.LENGTH_LONG, GB.ERROR
)
LOG.error("Error downloading file: $message")
}
})
}
}
fun downloadBinaryFile(url: Uri, onComplete: (File) -> Unit) {
CoroutineScope(Dispatchers.IO).launch {
try {
val client = OkHttpClient()
val request = okhttp3.Request.Builder().url(url.toString()).build()
val response = client.newCall(request).execute()
if (!response.isSuccessful) {
LOG.error("Downloading $url failed: ${response.code}")
return@launch
}
val filename = url.lastPathSegment ?: "downloaded_file.pbw"
val cacheDir = applicationContext.externalCacheDir
val cacheFile = File(cacheDir, filename)
val outputStream = FileOutputStream(cacheFile)
val inputStream = response.body.byteStream()
inputStream.copyTo(outputStream)
outputStream.flush()
outputStream.close()
inputStream.close()
onComplete(cacheFile)
} catch (e: Exception) {
LOG.error("Downloading $url failed: ", e)
}
val response: JSONObject? = InternetUtils.doRequest(appUrl.toUri())
if (response != null) {
val dataArray = response.getJSONArray("data")
val firstAppObject = dataArray.getJSONObject(0)
val latestRelease = firstAppObject.getJSONObject("latest_release")
val pbwFile = latestRelease.getString("pbw_file")
downloadInstallWatchapp(pbwFile.toUri())
}
}
@@ -32,11 +32,8 @@ import android.webkit.DownloadListener;
import android.webkit.JavascriptInterface;
import android.webkit.PermissionRequest;
import android.webkit.ValueCallback;
import android.webkit.WebChromeClient;
import android.webkit.WebResourceRequest;
import android.webkit.WebSettings;
import android.webkit.WebView;
import android.webkit.WebViewClient;
import android.widget.Toast;
import androidx.localbroadcastmanager.content.LocalBroadcastManager;
@@ -55,6 +52,7 @@ import nodomain.freeyourgadget.gadgetbridge.devices.DeviceCoordinator;
import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
import nodomain.freeyourgadget.gadgetbridge.service.devices.banglejs.BangleJSDeviceSupport;
import nodomain.freeyourgadget.gadgetbridge.util.Prefs;
import nodomain.freeyourgadget.gadgetbridge.webview.GBChromeClient;
import nodomain.freeyourgadget.gadgetbridge.webview.GBWebClient;
public class AppsManagementActivity extends AbstractGBActivity {
@@ -195,7 +193,6 @@ public class AppsManagementActivity extends AbstractGBActivity {
private void initViews() {
//https://stackoverflow.com/questions/4325639/android-calling-javascript-functions-in-webview
webView = findViewById(R.id.webview);
webView.setWebViewClient(new WebViewClient());
WebSettings settings = webView.getSettings();
settings.setJavaScriptEnabled(true);
settings.setDatabaseEnabled(true);
@@ -212,12 +209,6 @@ public class AppsManagementActivity extends AbstractGBActivity {
public void onPageFinished(WebView view, String weburl){
//webView.loadUrl("javascript:showToast('WebView in Espruino')");
}
@Override
public boolean shouldOverrideUrlLoading(WebView vw, WebResourceRequest request) {
Intent intent = new Intent(Intent.ACTION_VIEW, request.getUrl());
vw.getContext().startActivity(intent);
return true;
}
@Override
public void onReceivedError(WebView view, int errorCode, String description, String failingUrl) {
@@ -237,7 +228,7 @@ public class AppsManagementActivity extends AbstractGBActivity {
}, 1000);
webView.setWebChromeClient(new WebChromeClient() {
webView.setWebChromeClient(new GBChromeClient() {
@Override
public void onPermissionRequest(final PermissionRequest request) {
request.grant(request.getResources());
@@ -55,7 +55,6 @@ import nodomain.freeyourgadget.gadgetbridge.model.ActivitySummaryParser;
import nodomain.freeyourgadget.gadgetbridge.model.BatteryConfig;
import nodomain.freeyourgadget.gadgetbridge.service.DeviceSupport;
import nodomain.freeyourgadget.gadgetbridge.service.devices.banglejs.BangleJSDeviceSupport;
import nodomain.freeyourgadget.gadgetbridge.util.InternetHelperSingleton;
public class BangleJSCoordinator extends AbstractBLEDeviceCoordinator {
@@ -19,6 +19,7 @@
along with this program. If not, see <https://www.gnu.org/licenses/>. */
package nodomain.freeyourgadget.gadgetbridge.service.devices.banglejs;
import static java.util.Collections.emptyMap;
import static nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSettingsPreferenceConst.PREF_ALLOW_HIGH_MTU;
import static nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSettingsPreferenceConst.PREF_BANGLEJS_TEXT_BITMAP;
import static nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSettingsPreferenceConst.PREF_BANGLEJS_TEXT_BITMAP_SIZE;
@@ -58,21 +59,9 @@ import androidx.core.content.ContextCompat;
import androidx.core.text.HtmlCompat;
import androidx.localbroadcastmanager.content.LocalBroadcastManager;
import com.android.volley.AuthFailureError;
import com.android.volley.DefaultRetryPolicy;
import com.android.volley.Request;
import com.android.volley.RequestQueue;
import com.android.volley.Response;
import com.android.volley.VolleyError;
import com.android.volley.toolbox.StringRequest;
import com.android.volley.toolbox.Volley;
import org.json.JSONArray;
import org.json.JSONException;
import org.json.JSONObject;
import org.jsoup.Jsoup;
import org.jsoup.nodes.Document;
import org.jsoup.select.Elements;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -151,11 +140,10 @@ import nodomain.freeyourgadget.gadgetbridge.util.EmojiConverter;
import nodomain.freeyourgadget.gadgetbridge.util.FileUtils;
import nodomain.freeyourgadget.gadgetbridge.util.GB;
import nodomain.freeyourgadget.gadgetbridge.util.GBPrefs;
import nodomain.freeyourgadget.gadgetbridge.util.InternetHelperSingleton;
import nodomain.freeyourgadget.gadgetbridge.util.InternetUtils;
import nodomain.freeyourgadget.gadgetbridge.util.LimitedQueue;
import nodomain.freeyourgadget.gadgetbridge.util.MediaManager;
import nodomain.freeyourgadget.gadgetbridge.util.Prefs;
import nodomain.freeyourgadget.gadgetbridge.util.VolleyUtils;
public class BangleJSDeviceSupport extends AbstractBTLESingleDeviceSupport {
private static final Logger LOG = LoggerFactory.getLogger(BangleJSDeviceSupport.class);
@@ -189,10 +177,6 @@ public class BangleJSDeviceSupport extends AbstractBTLESingleDeviceSupport {
// this stores the globalUartReceiver (for uart.tx intents)
private BroadcastReceiver globalUartReceiver = null;
// used to make HTTP requests and handle responses
private RequestQueue requestQueue = null;
private RequestQueue insecureRequestQueue = null;
/// Maximum amount of characters to store in receiveHistory
public static final int MAX_RECEIVE_HISTORY_CHARS = 100000;
/// Used to avoid spamming logs with ACTION_DEVICE_CHANGED messages
@@ -226,7 +210,6 @@ public class BangleJSDeviceSupport extends AbstractBTLESingleDeviceSupport {
super.dispose();
stopGlobalUartReceiver();
stopLocationUpdate();
stopRequestQueue();
handler.removeCallbacksAndMessages(null);
}
}
@@ -246,38 +229,6 @@ public class BangleJSDeviceSupport extends AbstractBTLESingleDeviceSupport {
gpsUpdateSetup = false;
}
private void stopRequestQueue() {
if (requestQueue != null) {
requestQueue.stop();
}
if (insecureRequestQueue != null) {
insecureRequestQueue.stop();
}
}
private RequestQueue getRequestQueue(final boolean insecure) {
if (insecure) {
if (insecureRequestQueue == null) {
try {
insecureRequestQueue = Volley.newRequestQueue(
getContext(),
VolleyUtils.createInsecureHurlStack()
);
} catch (final Exception e) {
LOG.error("Failed to initialized insecure request queue", e);
// fallback to secure one
return getRequestQueue(false);
}
}
return insecureRequestQueue;
} else {
if (requestQueue == null) {
requestQueue = Volley.newRequestQueue(getContext());
}
return requestQueue;
}
}
private void addReceiveHistory(String s) {
receiveHistory += s;
if (receiveHistory.length() > MAX_RECEIVE_HISTORY_CHARS)
@@ -924,123 +875,37 @@ public class BangleJSDeviceSupport extends AbstractBTLESingleDeviceSupport {
}
Prefs devicePrefs = new Prefs(GBApplication.getDeviceSpecificSharedPrefs(gbDevice.getAddress()));
if (! devicePrefs.getBoolean(PREF_DEVICE_INTERNET_ACCESS, false)) {
if (!devicePrefs.getBoolean(PREF_DEVICE_INTERNET_ACCESS, false)) {
uartTxJSONError("http", "Internet access not enabled for this watch", id);
return;
}
String url = json.getString("url");
final boolean insecure = json.optBoolean("insecure", false);
int method = Request.Method.GET;
if (json.has("method")) {
String m = json.getString("method").toLowerCase(Locale.US);
if (m.equals("get")) method = Request.Method.GET;
else if (m.equals("post")) method = Request.Method.POST;
else if (m.equals("head")) method = Request.Method.HEAD;
else if (m.equals("put")) method = Request.Method.PUT;
else if (m.equals("patch")) method = Request.Method.PATCH;
else if (m.equals("delete")) method = Request.Method.DELETE;
else uartTxJSONError("http", "Unknown HTTP method "+m,id);
}
String method = json.getString("method").toUpperCase(Locale.US);
byte[] _body = null;
String body = null;
if (json.has("body"))
_body = json.getString("body").getBytes();
final byte[] body = _body;
body = json.getString("body");
Map<String,String> _headers = null;
Map<String,String> headers = null;
if (json.has("headers")) {
JSONObject h = json.getJSONObject("headers");
_headers = new HashMap<String,String>();
headers = new HashMap<String,String>();
Iterator<String> iter = h.keys();
while (iter.hasNext()) {
String key = iter.next();
try {
String value = h.getString(key);
_headers.put(key, value);
headers.put(key, value);
} catch (JSONException e) {
}
}
}
final Map<String,String> headers = _headers;
if (headers == null) headers = emptyMap();
String _xmlPath = "";
String _xmlReturn = "";
try {
_xmlPath = json.getString("xpath");
_xmlReturn = json.getString("return");
} catch (JSONException e) {
}
final String xmlPath = _xmlPath;
final String xmlReturn = _xmlReturn;
// Request a string response from the provided URL.
StringRequest stringRequest = new StringRequest(method, url,
new Response.Listener<String>() {
@Override
public void onResponse(String response) {
JSONObject o = new JSONObject();
if (xmlPath.length() != 0) {
try {
Document doc = Jsoup.parse(response);
Elements result = doc.selectXpath(xmlPath);
if (xmlReturn.equals("array")) {
response = null; // don't add it below
JSONArray arr = new JSONArray();
for (int i = 0; i < result.size(); i++)
arr.put(result.get(i).text());
o.put("resp", arr);
} else { // else return only first!
response = "";
if (!result.isEmpty())
response = result.get(0).text();
}
} catch (Exception error) {
uartTxJSONError("http", error.toString(), id);
return;
}
}
try {
o.put("t", "http");
if( id!=null)
o.put("id", id);
if (response!=null)
o.put("resp", response);
} catch (JSONException e) {
GB.toast(getContext(), "HTTP: " + e.getLocalizedMessage(), Toast.LENGTH_LONG, GB.ERROR, e);
}
uartTxJSON("http", o);
}
}, new Response.ErrorListener() {
@Override
public void onErrorResponse(VolleyError error) {
uartTxJSONError("http", error.toString(), id);
}
}) {
@Override
public byte[] getBody() throws AuthFailureError {
if (body == null) return super.getBody();
return body;
}
@Override
public Map<String, String> getHeaders() throws AuthFailureError {
// clone the data from super.getHeaders() so we can write to it
Map<String, String> h = new HashMap<>(super.getHeaders());
if (headers != null) {
for (String key : headers.keySet()) {
String value = headers.get(key);
h.put(key, value);
}
}
return h;
}
};
if (json.has("timeout")) {
int timeout = json.getInt("timeout");
stringRequest.setRetryPolicy(new DefaultRetryPolicy(timeout, 0, DefaultRetryPolicy.DEFAULT_BACKOFF_MULT));
}
RequestQueue queue = getRequestQueue(insecure);
queue.add(stringRequest);
JSONObject o = InternetUtils.Companion.doRequest(Uri.parse(url), method, headers, body, "application/json", insecure);
uartTxJSON("http", o);
}
/**
@@ -43,12 +43,6 @@ object InternetHelperSingleton {
private var internetHelperBound = false
private var internetHelper: IHttpService? = null
fun getHttpService(): IHttpService? {
ensureInternetHelperBound()
return internetHelper
}
//Internet helper outgoing connection
private val internetHelperConnection: ServiceConnection = object : ServiceConnection {
override fun onServiceConnected(className: ComponentName?, service: IBinder?) {
LOG.info("internet helper service bound")
@@ -63,8 +57,9 @@ object InternetHelperSingleton {
}
}
fun isInternetHelperBound(): Boolean {
return internetHelperBound
fun getHttpService(): IHttpService? {
ensureInternetHelperBound()
return internetHelper
}
fun ensureInternetHelperBound(): Boolean {
@@ -92,64 +87,66 @@ object InternetHelperSingleton {
@Throws(RemoteException::class, InterruptedException::class)
fun send(webRequest: Uri): WebResourceResponse? {
val httpHeaders = HttpHeaders()
val httpGetRequest = HttpGetRequest(webRequest.toString(), httpHeaders)
val latch = CountDownLatch(1)
val internetResponseCapsule = Capsule<WebResourceResponse?>()
var result: WebResourceResponse? = null
val request = HttpGetRequest(webRequest.toString(), HttpHeaders())
LOG.debug("Forwarding GET request to {} to internet helper app", webRequest)
try {
internetHelper?.get(httpGetRequest, object : IHttpCallback.Stub() {
@Throws(RemoteException::class)
internetHelper?.get(request, object : IHttpCallback.Stub() {
override fun onResponse(response: HttpResponse) {
// Extract headers
val contentType = response.headers["content-type"]?.split(";")?.get(0) ?: "text/html"
val contentEncoding = response.headers["content-encoding"] ?: "UTF-8"
try {
val headers = response.headers.toMap()
val contentType = response.headers["content-type"]
?.substringBefore(";")
?.trim()
?: "text/html"
val encoding = response.headers["content-encoding"] ?: "UTF-8"
val inputStream = ParcelFileDescriptor.AutoCloseInputStream(response.body)
// Retrieve original payload as InputStream
val inputStream = ParcelFileDescriptor.AutoCloseInputStream(response.body)
result = if (contentType.equals("text/html", ignoreCase = true)) {
val rawHtml = inputStream.bufferedReader(Charset.forName(encoding)).use { it.readText() }
val cleanedHtml = Jsoup.parse(rawHtml).html()
WebResourceResponse(
contentType,
encoding,
response.status,
"OK",
headers,
cleanedHtml.byteInputStream()
)
} else {
WebResourceResponse(
contentType,
encoding,
response.status,
"OK",
headers,
inputStream
)
}
// Clean up and fix received malformed HTML
if (contentType.equals("text/html", ignoreCase = true)) {
val rawHtml = inputStream.bufferedReader(Charset.forName(contentEncoding)).use { it.readText() }
val cleanedHtml = Jsoup.parse(rawHtml).html()
val internetResponse = WebResourceResponse(
contentType,
contentEncoding,
response.status,
"OK",
response.headers.toMap(),
cleanedHtml.byteInputStream()
)
internetResponseCapsule.set(internetResponse)
} else {
// If not text/html, return original response
val internetResponse = WebResourceResponse(
contentType,
contentEncoding,
response.status,
"OK",
response.headers.toMap(),
inputStream
)
internetResponseCapsule.set(internetResponse)
} catch (e: Exception) {
LOG.error("Error processing HTTP response", e)
} finally {
latch.countDown()
}
latch.countDown()
}
@Throws(RemoteException::class)
override fun onException(message: String?) {
LOG.error("Error during GET request: $message")
result = null
latch.countDown()
}
})
} catch (e: RemoteException) {
LOG.error("Error during GET request", e)
}
try {
latch.await()
} catch (e: InterruptedException) {
LOG.error("Error during GET request", e)
LOG.error("Error initiating GET request", e)
latch.countDown()
return null
}
return internetResponseCapsule.get()
latch.await()
return result
}
}
@@ -0,0 +1,206 @@
/* Copyright (C) 2025 Arjan Schrijver
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.util
import android.net.Uri
import android.webkit.WebResourceResponse
import nodomain.freeyourgadget.gadgetbridge.GBApplication
import okhttp3.Headers
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody
import okhttp3.RequestBody.Companion.toRequestBody
import org.json.JSONObject
import org.slf4j.Logger
import org.slf4j.LoggerFactory
import java.io.ByteArrayInputStream
import java.io.File
import java.io.FileOutputStream
import java.security.SecureRandom
import java.security.cert.X509Certificate
import javax.net.ssl.SSLContext
import javax.net.ssl.TrustManager
import javax.net.ssl.X509TrustManager
class InternetUtils {
companion object {
private val LOG: Logger = LoggerFactory.getLogger(InternetUtils::class.java)
private val defaultClient = OkHttpClient()
/**
* Performs an HTTP request to the given URI, optionally allowing insecure connections.
*/
fun doRequest(
uri: Uri,
method: String = "GET",
requestHeaders: Map<String, String> = emptyMap(),
body: String? = null,
bodyContentType: String = "text/plain",
insecure: Boolean = false
): JSONObject? {
val response: WebResourceResponse? = if (GBApplication.hasDirectInternetAccess()) {
directRequest(uri, method, requestHeaders, body, bodyContentType, insecure)
} else {
InternetHelperSingleton.send(uri)
}
if (response == null) return null
// Convert response InputStream to String
val text = response.data.bufferedReader().use { it.readText() }
return JSONObject(text)
}
fun downloadBinaryFile(
uri: Uri,
targetFile: File,
onComplete: (File) -> Unit
) {
try {
val response: WebResourceResponse? = if (GBApplication.hasDirectInternetAccess()) {
directRequest(
uri = uri,
method = "GET",
requestHeaders = emptyMap(),
body = null,
bodyContentType = "application/octet-stream",
insecure = false
)
} else {
InternetHelperSingleton.send(uri)
}
response?.data?.use { input ->
FileOutputStream(targetFile).use { output ->
input.copyTo(output)
}
}
if (response != null)
onComplete(targetFile)
} catch (e: Exception) {
LOG.error("Downloading $uri failed: ", e)
}
}
/**
* Direct HTTP request using OkHttp.
*/
private fun directRequest(
uri: Uri,
method: String,
requestHeaders: Map<String, String>,
body: String?,
bodyContentType: String,
insecure: Boolean
): WebResourceResponse {
val client = if (insecure) createInsecureClient() else defaultClient
val builder = Request.Builder().url(uri.toString())
// Apply request headers
for ((key, value) in requestHeaders) {
builder.addHeader(key, value)
}
// Convert body string to RequestBody if allowed
val requestBody: RequestBody? =
if (body != null && method.uppercase() !in listOf("GET", "HEAD")) {
body.toRequestBody(bodyContentType.toMediaType())
} else null
// Configure HTTP method
when (method.uppercase()) {
"GET" -> builder.get()
"HEAD" -> builder.head()
"DELETE" -> if (requestBody != null) builder.delete(requestBody) else builder.delete()
else -> builder.method(method.uppercase(), requestBody)
}
// Execute request
client.newCall(builder.build()).execute().use { response ->
val statusCode = response.code
val message = if (!response.message.isEmpty()) response.message else "OK"
val headers = response.headers.toMap()
val contentType = response.header("content-type") ?: "application/octet-stream"
val encoding = response.header("content-encoding") ?: "UTF-8"
// HEAD: empty body
if (method.equals("HEAD", true)) {
return WebResourceResponse(
contentType,
encoding,
statusCode,
message,
headers,
ByteArrayInputStream(ByteArray(0))
)
}
return WebResourceResponse(
contentType,
encoding,
statusCode,
message,
headers,
ByteArrayInputStream(response.body.bytes())
)
}
}
/**
* Converts OkHttp Headers to a Map suitable for WebResourceResponse.
*/
private fun Headers.toMap(): Map<String, String> {
val result = LinkedHashMap<String, String>()
for (name in this.names()) {
result[name] = this.values(name).joinToString(",")
}
return result
}
/**
* Creates an OkHttpClient that accepts all SSL certificates (insecure).
*/
private fun createInsecureClient(): OkHttpClient {
return try {
val trustAllCerts = arrayOf<TrustManager>(
object : X509TrustManager {
override fun checkClientTrusted(chain: Array<out X509Certificate>?, authType: String?) {}
override fun checkServerTrusted(chain: Array<out X509Certificate>?, authType: String?) {}
override fun getAcceptedIssuers(): Array<X509Certificate> = arrayOf()
}
)
val sslContext = SSLContext.getInstance("TLS")
sslContext.init(null, trustAllCerts, SecureRandom())
val sslSocketFactory = sslContext.socketFactory
OkHttpClient.Builder()
.sslSocketFactory(sslSocketFactory, trustAllCerts[0] as X509TrustManager)
.hostnameVerifier { _, _ -> true }
.build()
} catch (e: Exception) {
LOG.error("Failed to create insecure OkHttp client", e)
defaultClient
}
}
}
}
@@ -1,71 +0,0 @@
/* Copyright (C) 2025 José Rebelo
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.util;
import android.annotation.SuppressLint;
import com.android.volley.toolbox.HurlStack;
import java.io.IOException;
import java.net.HttpURLConnection;
import java.net.URL;
import java.security.SecureRandom;
import java.security.cert.X509Certificate;
import javax.net.ssl.HttpsURLConnection;
import javax.net.ssl.SSLContext;
import javax.net.ssl.TrustManager;
import javax.net.ssl.X509TrustManager;
public class VolleyUtils {
public static HurlStack createInsecureHurlStack() throws Exception {
// Trust all certificates
final TrustManager[] trustAllCerts = new TrustManager[]{new InsecureTrustManager()};
final SSLContext sslContext = SSLContext.getInstance("TLS");
sslContext.init(null, trustAllCerts, new SecureRandom());
return new HurlStack(null, sslContext.getSocketFactory()) {
@Override
protected HttpURLConnection createConnection(final URL url) throws IOException {
final HttpURLConnection httpURLConnection = super.createConnection(url);
if (httpURLConnection instanceof HttpsURLConnection httpsURLConnection) {
// Trust all hostnames
httpsURLConnection.setHostnameVerifier((hostname, session) -> true);
}
return httpURLConnection;
}
};
}
@SuppressLint("CustomX509TrustManager")
public static class InsecureTrustManager implements X509TrustManager {
@SuppressLint("TrustAllX509TrustManager")
@Override
public void checkClientTrusted(final X509Certificate[] chain, final String authType) {
}
@SuppressLint("TrustAllX509TrustManager")
@Override
public void checkServerTrusted(final X509Certificate[] chain, final String authType) {
}
@Override
public X509Certificate[] getAcceptedIssuers() {
return new X509Certificate[]{};
}
}
}
@@ -18,28 +18,27 @@ package nodomain.freeyourgadget.gadgetbridge.webview;
import android.webkit.ConsoleMessage;
import android.webkit.WebChromeClient;
import android.widget.Toast;
import nodomain.freeyourgadget.gadgetbridge.util.GB;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
public class GBChromeClient extends WebChromeClient {
private static final Logger LOG = LoggerFactory.getLogger(GBChromeClient.class);
@Override
public boolean onConsoleMessage(ConsoleMessage consoleMessage) {
if (ConsoleMessage.MessageLevel.ERROR.equals(consoleMessage.messageLevel())) {
GB.toast(formatConsoleMessage(consoleMessage), Toast.LENGTH_LONG, GB.ERROR);
//TODO: show error page
LOG.error(formatConsoleMessage(consoleMessage));
//TODO: show small error indication to user, allowing them to view the error(s)
}
return super.onConsoleMessage(consoleMessage);
}
private static String formatConsoleMessage(ConsoleMessage message) {
String sourceId = message.sourceId();
if (sourceId == null || sourceId.length() == 0) {
if (sourceId == null || sourceId.isEmpty()) {
sourceId = "unknown";
}
return String.format("%s (at %s: %d)", message.message(), sourceId, message.lineNumber());
}
}
}
@@ -164,7 +164,7 @@ public class GBWebClient extends WebViewClient {
Uri parsedUri = Uri.parse(url);
if (parsedUri.getScheme().startsWith("http")) {
if (InternetHelperSingleton.INSTANCE.ensureInternetHelperBound()) {
if (GBApplication.hasInternetAccess()) {
view.loadUrl(url);
} else {
Intent i = new Intent(Intent.ACTION_VIEW, Uri.parse(url));