From 692df2e3aafc79b97e164007cc095eb8ffb72e96 Mon Sep 17 00:00:00 2001 From: Arjan Schrijver Date: Mon, 19 Jan 2026 17:00:44 +0100 Subject: [PATCH] Endurain: Support uploading workout GPX file --- .../activities/endurain/EndurainApiClient.kt | 29 +++++++ .../workouts/WorkoutDetailsFragment.kt | 48 ++++++++++++ .../gadgetbridge/util/InternetUtils.kt | 78 +++++++++++++++++++ .../menu/activity_take_screenshot_menu.xml | 6 ++ 4 files changed, 161 insertions(+) diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/endurain/EndurainApiClient.kt b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/endurain/EndurainApiClient.kt index 5dcc7c7d72..51991939d2 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/endurain/EndurainApiClient.kt +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/endurain/EndurainApiClient.kt @@ -21,6 +21,7 @@ import androidx.core.net.toUri import com.google.gson.Gson import nodomain.freeyourgadget.gadgetbridge.util.InternetUtils import org.slf4j.LoggerFactory +import java.io.File enum class AuthType { NONE, @@ -253,4 +254,32 @@ class EndurainApiClient( return null } } + + /** + * Upload activity file (GPX) + */ + fun uploadActivity(file: File, callback: (Boolean) -> Unit) { + Thread { + try { + val uri = "$baseUrl/api/v1/activities/create/upload".toUri() + val headers = buildHeaders(AuthType.AUTH_TOKEN) + + InternetUtils.uploadBinaryFile( + uri = uri, + file = file, + requestHeaders = headers + ) { success, responseText -> + if (success && responseText != null) { + callback(true) + } else { + LOG.error("Activity upload failed") + callback(false) + } + } + } catch (e: Exception) { + LOG.error("Activity upload error", e) + callback(false) + } + }.start() + } } \ No newline at end of file diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/workouts/WorkoutDetailsFragment.kt b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/workouts/WorkoutDetailsFragment.kt index 74ff4ff5cf..e37de015a5 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/workouts/WorkoutDetailsFragment.kt +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/workouts/WorkoutDetailsFragment.kt @@ -41,6 +41,7 @@ import androidx.core.content.FileProvider import androidx.core.graphics.createBitmap import androidx.core.view.MenuProvider import androidx.fragment.app.Fragment +import androidx.fragment.app.viewModels import androidx.lifecycle.Lifecycle import androidx.lifecycle.lifecycleScope import com.github.mikephil.charting.charts.BarLineChartBase @@ -58,6 +59,9 @@ import nodomain.freeyourgadget.gadgetbridge.GBApplication import nodomain.freeyourgadget.gadgetbridge.R import nodomain.freeyourgadget.gadgetbridge.activities.ActivitySummariesChartFragment import nodomain.freeyourgadget.gadgetbridge.activities.charts.DurationXLabelFormatter +import nodomain.freeyourgadget.gadgetbridge.activities.endurain.EndurainApiClient +import nodomain.freeyourgadget.gadgetbridge.activities.endurain.EndurainSetupViewModel +import nodomain.freeyourgadget.gadgetbridge.activities.endurain.EndurainTokenManager import nodomain.freeyourgadget.gadgetbridge.activities.fit.FitViewerActivity import nodomain.freeyourgadget.gadgetbridge.activities.workouts.charts.ChartDataRepository import nodomain.freeyourgadget.gadgetbridge.activities.workouts.charts.DefaultWorkoutCharts @@ -543,6 +547,11 @@ class WorkoutDetailsFragment : Fragment(), MenuProvider { true } + R.id.activity_action_upload_to_endurain -> { + uploadToEndurain() + true + } + R.id.activity_action_dev_inspect_file -> { val intent = Intent(requireContext(), FitViewerActivity::class.java).apply { putExtra(FitViewerActivity.EXTRA_PATH, File(workout.summary.rawDetailsPath).absolutePath) @@ -627,6 +636,16 @@ class WorkoutDetailsFragment : Fragment(), MenuProvider { val devToolsSubMenu = devToolsMenu?.subMenu devToolsMenu?.isVisible = devToolsSubMenu != null && devToolsSubMenu.hasVisibleItems() } + + val endurainVm: EndurainSetupViewModel by viewModels() + val server = GBApplication.getPrefs().preferences.getString("endurain_server", null) + if (server != null) { + endurainVm.performTokenRefresh(server) { + activity?.runOnUiThread { + overflowMenu?.findItem(R.id.activity_action_upload_to_endurain)?.isVisible = hasGpx && endurainVm.isLoggedIn() + } + } + } } private fun takeSharedScreenshot() { @@ -732,6 +751,35 @@ class WorkoutDetailsFragment : Fragment(), MenuProvider { } } + private fun uploadToEndurain() { + val workout = currentWorkout ?: return + val gpxFile = ActivitySummaryUtils.getGpxFile(workout.summary) + + if (gpxFile == null) { + GB.toast("No GPX track in this activity", Toast.LENGTH_LONG, GB.INFO) + return + } + + try { + val tokenManager = EndurainTokenManager(requireContext()) + val serverUrl = GBApplication.getPrefs().preferences.getString("endurain_server", null) + val apiClient = EndurainApiClient(serverUrl!!, tokenManager) + apiClient.uploadActivity(gpxFile) { success -> + if (success) + GB.toast("Successfully uploaded to Endurain", Toast.LENGTH_SHORT, GB.INFO) + else + GB.toast("Error while uploading to Endurain", Toast.LENGTH_SHORT, GB.INFO) + } + } catch (e: Exception) { + GB.toast( + "Unable to upload GPX file to Endurain: ${e.localizedMessage}", + Toast.LENGTH_LONG, + GB.ERROR, + e + ) + } + } + private fun shareRawSummary(workout: Workout) { if (workout.summary.rawSummaryData == null) { GB.toast(requireContext(), "No raw summary in this activity", Toast.LENGTH_LONG, GB.WARN) diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/InternetUtils.kt b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/InternetUtils.kt index ccd8e9b632..3c7c893203 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/InternetUtils.kt +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/InternetUtils.kt @@ -32,6 +32,7 @@ import org.json.JSONObject import org.slf4j.Logger import org.slf4j.LoggerFactory import java.io.ByteArrayInputStream +import java.io.ByteArrayOutputStream import java.io.File import java.io.FileOutputStream import java.security.SecureRandom @@ -149,6 +150,83 @@ class InternetUtils { } } + fun uploadBinaryFile( + uri: Uri, + file: File, + requestHeaders: Map = emptyMap(), + allowInsecure: Boolean = false, + onComplete: (success: Boolean, response: String?) -> Unit + ) { + try { + if (!file.exists() || !file.canRead()) { + LOG.error("File does not exist or cannot be read: ${file.path}") + onComplete(false, null) + return + } + + val fileName = file.name + val mimeType = when (fileName.substringAfterLast('.', "").lowercase()) { + "gpx" -> "application/gpx+xml" + else -> "application/octet-stream" + } + + val boundary = "----GadgetbridgeFormBoundary${System.currentTimeMillis()}" + val multipartBody = buildMultipartBody(file, fileName, mimeType, boundary) + + val headers = requestHeaders.toMutableMap() + headers["Content-Type"] = "multipart/form-data; boundary=$boundary" + + val response = if (GBApplication.hasDirectInternetAccess()) { + directRequest( + uri = uri, + method = "POST", + requestHeaders = headers, + body = multipartBody, + allowInsecure = allowInsecure + ) + } else { + InternetHelperSingleton.send( + uri, + HttpRequest.Method.POST, + headers, + multipartBody.toByteArray(), + allowInsecure + ) + } + + val responseText = response?.data?.bufferedReader()?.use { it.readText() } + onComplete(response != null, responseText) + } catch (e: Exception) { + LOG.error("Uploading $uri failed: ", e) + onComplete(false, null) + } + } + + private fun buildMultipartBody( + file: File, + fileName: String, + mimeType: String, + boundary: String + ): String { + val fileBytes = file.readBytes() + val output = ByteArrayOutputStream() + val writer = output.bufferedWriter() + + writer.write("--$boundary\r\n") + writer.write("Content-Disposition: form-data; name=\"file\"; filename=\"$fileName\"\r\n") + writer.write("Content-Type: $mimeType\r\n") + writer.write("\r\n") + writer.flush() + + output.write(fileBytes) + + writer.write("\r\n") + writer.write("--$boundary--\r\n") + writer.flush() + + return output.toString("UTF-8") + } + /** * Direct HTTP request using OkHttp. */ diff --git a/app/src/main/res/menu/activity_take_screenshot_menu.xml b/app/src/main/res/menu/activity_take_screenshot_menu.xml index 3cec000de9..b4e6a9326c 100644 --- a/app/src/main/res/menu/activity_take_screenshot_menu.xml +++ b/app/src/main/res/menu/activity_take_screenshot_menu.xml @@ -50,6 +50,12 @@ android:title="@string/activity_detail_share_gps_label" app:showAsAction="never" /> + +