Endurain: Support uploading workout GPX file

This commit is contained in:
Arjan Schrijver
2026-06-19 23:31:23 +02:00
parent efdc8b9121
commit 692df2e3aa
4 changed files with 161 additions and 0 deletions
@@ -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()
}
}
@@ -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)
@@ -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<String, String> = 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.
*/
@@ -50,6 +50,12 @@
android:title="@string/activity_detail_share_gps_label"
app:showAsAction="never" />
<item
android:id="@+id/activity_action_upload_to_endurain"
android:icon="@drawable/ic_file_upload"
android:title="Upload to Endurain"
app:showAsAction="never" />
<item
android:id="@+id/activity_action_dev_tools"
android:icon="@drawable/ic_developer_mode"