From b0c0eda4f3ff08a4484c5e9be621e6af3033c05a Mon Sep 17 00:00:00 2001 From: Arjan Schrijver Date: Tue, 25 Nov 2025 10:48:10 +0100 Subject: [PATCH] Centralize internet requests, removing Volley in the process --- app/build.gradle | 1 - app/src/main/assets/licenses/licenses.json | 6 +- .../activities/ExternalPebbleJSActivity.java | 4 - .../appmanager/RebbleAppStoreActivity.kt | 167 ++------------ .../banglejs/AppsManagementActivity.java | 13 +- .../devices/banglejs/BangleJSCoordinator.java | 1 - .../banglejs/BangleJSDeviceSupport.java | 159 +------------- .../util/InternetHelperSingleton.kt | 99 ++++----- .../gadgetbridge/util/InternetUtils.kt | 206 ++++++++++++++++++ .../gadgetbridge/util/VolleyUtils.java | 71 ------ .../gadgetbridge/webview/GBChromeClient.java | 15 +- .../gadgetbridge/webview/GBWebClient.java | 2 +- 12 files changed, 292 insertions(+), 452 deletions(-) create mode 100644 app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/InternetUtils.kt delete mode 100644 app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/VolleyUtils.java diff --git a/app/build.gradle b/app/build.gradle index 91290837ca..58b43ac8dd 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -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' diff --git a/app/src/main/assets/licenses/licenses.json b/app/src/main/assets/licenses/licenses.json index ac55db0261..2c7c728cec 100644 --- a/app/src/main/assets/licenses/licenses.json +++ b/app/src/main/assets/licenses/licenses.json @@ -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" }, { diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/ExternalPebbleJSActivity.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/ExternalPebbleJSActivity.java index d7246bc8ae..93892d2933 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/ExternalPebbleJSActivity.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/ExternalPebbleJSActivity.java @@ -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 diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/appmanager/RebbleAppStoreActivity.kt b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/appmanager/RebbleAppStoreActivity.kt index c352ffc267..9ce41c4354 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/appmanager/RebbleAppStoreActivity.kt +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/appmanager/RebbleAppStoreActivity.kt @@ -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()) } } diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/banglejs/AppsManagementActivity.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/banglejs/AppsManagementActivity.java index f638ceafea..d15cc2912f 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/banglejs/AppsManagementActivity.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/banglejs/AppsManagementActivity.java @@ -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()); diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/banglejs/BangleJSCoordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/banglejs/BangleJSCoordinator.java index eda2f1000d..791e35fb30 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/banglejs/BangleJSCoordinator.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/banglejs/BangleJSCoordinator.java @@ -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 { diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/banglejs/BangleJSDeviceSupport.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/banglejs/BangleJSDeviceSupport.java index 69e5e1af23..5c7590a9ea 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/banglejs/BangleJSDeviceSupport.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/banglejs/BangleJSDeviceSupport.java @@ -19,6 +19,7 @@ along with this program. If not, see . */ 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 _headers = null; + Map headers = null; if (json.has("headers")) { JSONObject h = json.getJSONObject("headers"); - _headers = new HashMap(); + headers = new HashMap(); Iterator 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 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() { - @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 getHeaders() throws AuthFailureError { - // clone the data from super.getHeaders() so we can write to it - Map 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); } /** diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/InternetHelperSingleton.kt b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/InternetHelperSingleton.kt index d18bdfca34..9e08a7b08a 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/InternetHelperSingleton.kt +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/InternetHelperSingleton.kt @@ -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() + 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 } } \ No newline at end of file diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/InternetUtils.kt b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/InternetUtils.kt new file mode 100644 index 0000000000..132d634217 --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/InternetUtils.kt @@ -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 . */ +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 = 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, + 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 { + val result = LinkedHashMap() + 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( + object : X509TrustManager { + override fun checkClientTrusted(chain: Array?, authType: String?) {} + override fun checkServerTrusted(chain: Array?, authType: String?) {} + override fun getAcceptedIssuers(): Array = 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 + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/VolleyUtils.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/VolleyUtils.java deleted file mode 100644 index c7a3c88c18..0000000000 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/VolleyUtils.java +++ /dev/null @@ -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 . */ -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[]{}; - } - } -} diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/webview/GBChromeClient.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/webview/GBChromeClient.java index 9ccd3958dd..9075e35a95 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/webview/GBChromeClient.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/webview/GBChromeClient.java @@ -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()); } - - -} +} \ No newline at end of file diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/webview/GBWebClient.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/webview/GBWebClient.java index e15283f1c6..ca7c531f21 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/webview/GBWebClient.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/webview/GBWebClient.java @@ -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));