From 45d717cbb69cd4bd7724c64b9ef29939de0f9e2c Mon Sep 17 00:00:00 2001
From: Dany Mestas <94061157+DanyPM@users.noreply.github.com>
Date: Tue, 7 Jul 2026 12:41:40 +0200
Subject: [PATCH] Add Endurain sync backend
---
src/app/build.gradle | 1 +
.../sync/core/model/EndurainViewModel.kt | 67 +++
.../sync/core/service/BackendRegistry.kt | 1 +
.../sync/core/service/EndurainService.kt | 290 +++++++++++++
.../openscale/sync/core/sync/EndurainSync.kt | 383 ++++++++++++++++++
.../sync/core/sync/EndurainTokenManager.kt | 93 +++++
src/app/src/main/res/drawable/ic_endurain.xml | 13 +
src/app/src/main/res/values-de/strings.xml | 17 +
src/app/src/main/res/values/strings.xml | 17 +
.../sync/core/sync/EndurainSyncTest.kt | 115 ++++++
src/gradle/libs.versions.toml | 2 +
11 files changed, 999 insertions(+)
create mode 100644 src/app/src/main/java/com/health/openscale/sync/core/model/EndurainViewModel.kt
create mode 100644 src/app/src/main/java/com/health/openscale/sync/core/service/EndurainService.kt
create mode 100644 src/app/src/main/java/com/health/openscale/sync/core/sync/EndurainSync.kt
create mode 100644 src/app/src/main/java/com/health/openscale/sync/core/sync/EndurainTokenManager.kt
create mode 100644 src/app/src/main/res/drawable/ic_endurain.xml
create mode 100644 src/app/src/test/java/com/health/openscale/sync/core/sync/EndurainSyncTest.kt
diff --git a/src/app/build.gradle b/src/app/build.gradle
index 9a09d07..4fcf706 100644
--- a/src/app/build.gradle
+++ b/src/app/build.gradle
@@ -155,6 +155,7 @@ dependencies {
implementation libs.okhttp
implementation libs.retrofit
implementation libs.retrofit.converter.gson
+ implementation libs.androidx.security.crypto
testImplementation libs.junit
testImplementation libs.robolectric
diff --git a/src/app/src/main/java/com/health/openscale/sync/core/model/EndurainViewModel.kt b/src/app/src/main/java/com/health/openscale/sync/core/model/EndurainViewModel.kt
new file mode 100644
index 0000000..ca69772
--- /dev/null
+++ b/src/app/src/main/java/com/health/openscale/sync/core/model/EndurainViewModel.kt
@@ -0,0 +1,67 @@
+/*
+ * Copyright (C) 2026 Dany Mestas
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program 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 General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see
+ *
+ */
+package com.health.openscale.sync.core.model
+
+import android.content.SharedPreferences
+import androidx.core.content.edit
+import androidx.lifecycle.LiveData
+import androidx.lifecycle.MutableLiveData
+import com.health.openscale.sync.R
+
+/**
+ * Persisted, non-secret settings for the Endurain backend: the server origin and the last-used
+ * username (kept only to prefill the form and label the login status). The password is never
+ * stored; OAuth tokens live encrypted in [com.health.openscale.sync.core.sync.EndurainTokenManager].
+ */
+class EndurainViewModel(private val sharedPreferences: SharedPreferences) : ViewModelInterface(sharedPreferences) {
+ companion object {
+ const val SERVER = "endurain_server"
+ const val USERNAME = "endurain_username"
+ }
+
+ private val _endurainServer = MutableLiveData(sharedPreferences.getString(SERVER, ""))
+ private val _endurainUsername = MutableLiveData(sharedPreferences.getString(USERNAME, ""))
+
+ // Bumped whenever tokens change (login / logout) so Compose recomposes the login status section.
+ private val _loginStateVersion = MutableLiveData(0)
+
+ override fun getName(): String {
+ return "Endurain"
+ }
+
+ override fun getIcon(): Int {
+ return R.drawable.ic_endurain
+ }
+
+ val endurainServer: LiveData = _endurainServer
+ fun setEndurainServer(value: String) {
+ this._endurainServer.value = value
+ sharedPreferences.edit { putString(SERVER, value) }
+ }
+
+ val endurainUsername: LiveData = _endurainUsername
+ fun setEndurainUsername(value: String) {
+ this._endurainUsername.value = value
+ sharedPreferences.edit { putString(USERNAME, value) }
+ }
+
+ val loginStateVersion: LiveData = _loginStateVersion
+ fun notifyLoginStateChanged() {
+ _loginStateVersion.value = (_loginStateVersion.value ?: 0) + 1
+ }
+}
diff --git a/src/app/src/main/java/com/health/openscale/sync/core/service/BackendRegistry.kt b/src/app/src/main/java/com/health/openscale/sync/core/service/BackendRegistry.kt
index cce5659..7bb6cbb 100644
--- a/src/app/src/main/java/com/health/openscale/sync/core/service/BackendRegistry.kt
+++ b/src/app/src/main/java/com/health/openscale/sync/core/service/BackendRegistry.kt
@@ -15,6 +15,7 @@ object BackendRegistry {
HealthConnectService(context, prefs),
MQTTService(context, prefs),
WgerService(context, prefs),
+ EndurainService(context, prefs),
InfluxDbService(context, prefs),
WebhookService(context, prefs)
)
diff --git a/src/app/src/main/java/com/health/openscale/sync/core/service/EndurainService.kt b/src/app/src/main/java/com/health/openscale/sync/core/service/EndurainService.kt
new file mode 100644
index 0000000..d85802c
--- /dev/null
+++ b/src/app/src/main/java/com/health/openscale/sync/core/service/EndurainService.kt
@@ -0,0 +1,290 @@
+/*
+ * Copyright (C) 2026 Dany Mestas
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program 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 General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see
+ *
+ */
+package com.health.openscale.sync.core.service
+
+import android.content.Context
+import android.content.SharedPreferences
+import androidx.activity.ComponentActivity
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.text.KeyboardOptions
+import androidx.compose.material3.Button
+import androidx.compose.material3.OutlinedButton
+import androidx.compose.material3.OutlinedTextField
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.livedata.observeAsState
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.input.KeyboardType
+import androidx.lifecycle.lifecycleScope
+import com.health.openscale.sync.R
+import com.health.openscale.sync.core.datatypes.OpenScaleMeasurement
+import com.health.openscale.sync.core.model.EndurainViewModel
+import com.health.openscale.sync.core.model.ViewModelInterface
+import com.health.openscale.sync.core.sync.EndurainSync
+import com.health.openscale.sync.core.sync.EndurainTokenManager
+import com.health.openscale.sync.gui.components.LocalSnackbar
+import com.health.openscale.sync.gui.components.SecretOutlinedTextField
+import com.health.openscale.sync.gui.components.UserScopeSection
+import kotlinx.coroutines.launch
+import java.text.DateFormat
+import java.util.Date
+
+/**
+ * Export-only sync backend for Endurain (self-hosted FastAPI fitness server). Auth is OAuth2
+ * username/password login → short-lived JWT access token + rotating refresh token; the tokens are
+ * stored encrypted (no password kept) and refreshed proactively in [connect] and reactively on 401
+ * inside [EndurainSync]. Body weight + composition is written to the `health_weight` table via
+ * POST (upsert-by-date). MFA is supported; SSO/PKCE is out of scope.
+ */
+class EndurainService(
+ private val context: Context,
+ sharedPreferences: SharedPreferences
+) : ServiceInterface(context) {
+ private val viewModel: EndurainViewModel = EndurainViewModel(sharedPreferences)
+ private val tokenManager = EndurainTokenManager(context)
+ private var endurainSync: EndurainSync? = null
+
+ override fun viewModel(): ViewModelInterface = viewModel
+
+ // Export only: Endurain has a readable weight API but inbound import is out of scope here.
+ override val supportsInbound: Boolean get() = false
+
+ // Stable retry-queue key (independent of the display name).
+ override val retryQueueKey: String get() = "endurain"
+
+ private fun buildSync(): EndurainSync =
+ EndurainSync(viewModel.endurainServer.value.orEmpty(), tokenManager).also { endurainSync = it }
+
+ override suspend fun connect() {
+ if (!viewModel.syncEnabled.value) return
+
+ val server = viewModel.endurainServer.value.orEmpty().trim()
+ if (server.isEmpty()) {
+ viewModel.setConnectAvailable(false)
+ viewModel.setAllPermissionsGranted(false)
+ return
+ }
+
+ val sync = buildSync()
+
+ if (!tokenManager.isLoggedIn()) {
+ viewModel.setConnectAvailable(false)
+ viewModel.setAllPermissionsGranted(false)
+ setErrorMessage(context.getString(R.string.endurain_not_logged_in))
+ return
+ }
+
+ // Proactive refresh: mint a fresh access token if the current one has expired (15-min TTL).
+ if (!sync.ensureFreshAccessToken()) {
+ viewModel.setConnectAvailable(false)
+ viewModel.setAllPermissionsGranted(false)
+ setErrorMessage(context.getString(R.string.endurain_session_expired))
+ return
+ }
+
+ when (val res = sync.testConnection()) {
+ is SyncResult.Success -> {
+ viewModel.setAllPermissionsGranted(true)
+ viewModel.setConnectAvailable(true)
+ clearErrorMessage()
+ setInfoMessage(context.getString(R.string.endurain_successful_connected_text))
+ }
+ is SyncResult.Failure -> {
+ viewModel.setConnectAvailable(false)
+ viewModel.setAllPermissionsGranted(false)
+ setErrorMessage(res)
+ }
+ }
+ }
+
+ private suspend fun withSync(block: suspend (EndurainSync) -> SyncResult): SyncResult {
+ val sync = endurainSync
+ return if (sync != null && viewModel.connectAvailable.value && viewModel.allPermissionsGranted.value)
+ block(sync)
+ else SyncResult.Failure(SyncResult.ErrorType.PERMISSION_DENIED)
+ }
+
+ // POST is upsert-by-date on Endurain, so insert and update are the same wire call.
+ override suspend fun insert(measurement: OpenScaleMeasurement): SyncResult =
+ withSync { it.upsert(measurement) }
+
+ override suspend fun update(measurement: OpenScaleMeasurement): SyncResult =
+ withSync { it.upsert(measurement) }
+
+ override suspend fun delete(userId: Int, date: Date): SyncResult =
+ withSync { it.delete(date) }
+
+ override suspend fun clear(userId: Int): SyncResult =
+ withSync { it.clear() }
+
+ @Composable
+ override fun ComposeSettings(activity: ComponentActivity) {
+ val showMessage = LocalSnackbar.current
+
+ DetailScaffold(
+ activity = activity,
+ testConnecting = false,
+ onTest = {
+ activity.lifecycleScope.launch {
+ connect()
+ if (viewModel.errorMessage.value.isNullOrEmpty())
+ showMessage(context.getString(R.string.service_connection_successful))
+ }
+ }
+ ) {
+ // Single-user backend: pick which openScale user this destination receives.
+ val osUsers = remember { openScaleDataService.getUsers() }
+ UserScopeSection(
+ isMultiUser = isMultiUser,
+ users = osUsers,
+ selectedUserId = viewModel.selectedUserId.value,
+ onUserSelected = { viewModel.setSelectedUserId(it.id) },
+ enabled = viewModel.syncEnabled.value
+ )
+
+ val enabled = viewModel.syncEnabled.value
+
+ val serverState by viewModel.endurainServer.observeAsState("")
+ OutlinedTextField(
+ enabled = enabled,
+ modifier = Modifier.fillMaxWidth(),
+ value = serverState,
+ onValueChange = { viewModel.setEndurainServer(it) },
+ label = { Text(stringResource(id = R.string.endurain_server_name_title)) },
+ placeholder = { Text(stringResource(id = R.string.endurain_server_name_hint)) },
+ singleLine = true,
+ keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri)
+ )
+
+ // Login status (recomposes when tokens change via loginStateVersion).
+ val loginVersion by viewModel.loginStateVersion.observeAsState(0)
+ val usernameState by viewModel.endurainUsername.observeAsState("")
+ val loggedIn = remember(loginVersion) { tokenManager.isLoggedIn() }
+ val refreshExpiresAt = remember(loginVersion) { tokenManager.getRefreshTokenExpiresAt() }
+
+ if (loggedIn) {
+ Text(stringResource(id = R.string.endurain_logged_in_status, usernameState))
+ if (refreshExpiresAt > 0L) {
+ val expText = DateFormat.getDateTimeInstance().format(Date(refreshExpiresAt * 1000))
+ Text(stringResource(id = R.string.endurain_refresh_expires, expText))
+ }
+ OutlinedButton(
+ enabled = enabled,
+ modifier = Modifier.fillMaxWidth(),
+ onClick = {
+ tokenManager.clear()
+ viewModel.notifyLoginStateChanged()
+ viewModel.setConnectAvailable(false)
+ viewModel.setAllPermissionsGranted(false)
+ clearErrorMessage()
+ }
+ ) { Text(stringResource(id = R.string.endurain_logout_button)) }
+ } else {
+ Text(stringResource(id = R.string.endurain_not_logged_in))
+
+ var username by remember { mutableStateOf(usernameState) }
+ var password by remember { mutableStateOf("") }
+ var mfaRequired by remember { mutableStateOf(false) }
+ var mfaCode by remember { mutableStateOf("") }
+ var busy by remember { mutableStateOf(false) }
+
+ OutlinedTextField(
+ enabled = enabled && !busy,
+ modifier = Modifier.fillMaxWidth(),
+ value = username,
+ onValueChange = { username = it },
+ label = { Text(stringResource(id = R.string.endurain_username_title)) },
+ singleLine = true
+ )
+ SecretOutlinedTextField(
+ value = password,
+ onValueChange = { password = it },
+ label = stringResource(id = R.string.endurain_password_title),
+ enabled = enabled && !busy,
+ modifier = Modifier.fillMaxWidth()
+ )
+
+ if (mfaRequired) {
+ OutlinedTextField(
+ enabled = enabled && !busy,
+ modifier = Modifier.fillMaxWidth(),
+ value = mfaCode,
+ onValueChange = { mfaCode = it },
+ label = { Text(stringResource(id = R.string.endurain_mfa_code_title)) },
+ singleLine = true,
+ keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number)
+ )
+ Button(
+ enabled = enabled && !busy,
+ modifier = Modifier.fillMaxWidth(),
+ onClick = {
+ busy = true
+ activity.lifecycleScope.launch {
+ val sync = buildSync()
+ when (val r = sync.verifyMfa(username, mfaCode)) {
+ is EndurainSync.LoginResult.Success -> {
+ viewModel.setEndurainUsername(username)
+ viewModel.notifyLoginStateChanged()
+ mfaRequired = false
+ connect()
+ showMessage(context.getString(R.string.endurain_login_success))
+ }
+ is EndurainSync.LoginResult.MfaRequired -> { /* stay on MFA step */ }
+ is EndurainSync.LoginResult.Failure ->
+ setErrorMessage(context.getString(R.string.endurain_login_failed) + " (" + r.message + ")")
+ }
+ busy = false
+ }
+ }
+ ) { Text(stringResource(id = R.string.endurain_verify_button)) }
+ } else {
+ Button(
+ enabled = enabled && !busy,
+ modifier = Modifier.fillMaxWidth(),
+ onClick = {
+ busy = true
+ activity.lifecycleScope.launch {
+ val sync = buildSync()
+ when (val r = sync.login(username, password)) {
+ is EndurainSync.LoginResult.Success -> {
+ viewModel.setEndurainUsername(username)
+ viewModel.notifyLoginStateChanged()
+ connect()
+ showMessage(context.getString(R.string.endurain_login_success))
+ }
+ is EndurainSync.LoginResult.MfaRequired -> {
+ viewModel.setEndurainUsername(username)
+ mfaRequired = true
+ }
+ is EndurainSync.LoginResult.Failure ->
+ setErrorMessage(context.getString(R.string.endurain_login_failed) + " (" + r.message + ")")
+ }
+ busy = false
+ }
+ }
+ ) { Text(stringResource(id = R.string.endurain_login_button)) }
+ }
+ }
+ }
+ }
+}
diff --git a/src/app/src/main/java/com/health/openscale/sync/core/sync/EndurainSync.kt b/src/app/src/main/java/com/health/openscale/sync/core/sync/EndurainSync.kt
new file mode 100644
index 0000000..454cca3
--- /dev/null
+++ b/src/app/src/main/java/com/health/openscale/sync/core/sync/EndurainSync.kt
@@ -0,0 +1,383 @@
+/*
+ * Copyright (C) 2026 Dany Mestas
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program 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 General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see
+ *
+ */
+package com.health.openscale.sync.core.sync
+
+import com.google.gson.annotations.SerializedName
+import com.health.openscale.sync.core.datatypes.OpenScaleMeasurement
+import com.health.openscale.sync.core.service.SyncResult
+import okhttp3.OkHttpClient
+import retrofit2.Response
+import retrofit2.Retrofit
+import retrofit2.converter.gson.GsonConverterFactory
+import retrofit2.http.Body
+import retrofit2.http.DELETE
+import retrofit2.http.Field
+import retrofit2.http.FormUrlEncoded
+import retrofit2.http.GET
+import retrofit2.http.Header
+import retrofit2.http.POST
+import retrofit2.http.Path
+import retrofit2.http.Query
+import java.text.SimpleDateFormat
+import java.util.Date
+import java.util.Locale
+import java.util.TimeZone
+
+/**
+ * Wire layer for Endurain's health-weight REST API (export only).
+ *
+ * Auth model (clean-room from Endurain's own server spec, NOT copied from the AGPL Gadgetbridge
+ * client): OAuth2 username/password login → short-lived JWT access token + rotating refresh token.
+ * Every request carries `X-Client-Type: mobile`. Non-auth requests carry
+ * `Authorization: Bearer `, injected by the interceptor from the [tokenManager] on each call
+ * (so a token refreshed mid-batch is picked up automatically). Token refresh sends the refresh token
+ * in its own Authorization header. On a 401 we refresh once and retry (reactive path).
+ *
+ * @param serverOrigin bare origin the user entered (e.g. https://endurain.example.com); the
+ * `/api/v1/` prefix is appended here.
+ */
+class EndurainSync(
+ serverOrigin: String,
+ private val tokenManager: EndurainTokenManager
+) : SyncInterface() {
+
+ // Locale.US: API wire format, must stay ASCII regardless of device locale. Endurain's `date`
+ // column is a calendar date (YYYY-MM-DD), formatted in the device's local time zone.
+ private val dateOnly = SimpleDateFormat("yyyy-MM-dd", Locale.US).apply { timeZone = TimeZone.getDefault() }
+
+ private val api: EndurainApi
+
+ init {
+ val baseUrl = serverOrigin.trim().trimEnd('/') + "/api/v1/"
+ val client = OkHttpClient.Builder()
+ .addInterceptor { chain ->
+ val original = chain.request()
+ val builder = original.newBuilder().header("X-Client-Type", "mobile")
+ // Auth endpoints authenticate themselves (login = none, refresh = refresh token in
+ // its own header) — never overwrite them with the access token.
+ val isAuthEndpoint = original.url.encodedPath.contains("/auth/")
+ if (!isAuthEndpoint && original.header("Authorization") == null) {
+ tokenManager.getAccessToken()?.let { builder.header("Authorization", "Bearer $it") }
+ }
+ chain.proceed(builder.build())
+ }
+ .build()
+ api = Retrofit.Builder()
+ .client(client)
+ .baseUrl(baseUrl)
+ .addConverterFactory(GsonConverterFactory.create())
+ .build()
+ .create(EndurainApi::class.java)
+ }
+
+ // --- Auth ---------------------------------------------------------------------------
+
+ /** Outcome of a login / MFA-verify attempt. */
+ sealed class LoginResult {
+ /** Tokens obtained and persisted. */
+ object Success : LoginResult()
+ /** Server requires a second factor; call [verifyMfa] with the code for this username. */
+ data class MfaRequired(val username: String) : LoginResult()
+ data class Failure(val message: String) : LoginResult()
+ }
+
+ suspend fun login(username: String, password: String): LoginResult = try {
+ handleTokenResponse(api.login(username, password), username)
+ } catch (e: Exception) {
+ LoginResult.Failure(e.message ?: "unknown error")
+ }
+
+ suspend fun verifyMfa(username: String, code: String): LoginResult = try {
+ handleTokenResponse(api.verifyMfa(MfaRequest(username, code)), username)
+ } catch (e: Exception) {
+ LoginResult.Failure(e.message ?: "unknown error")
+ }
+
+ private fun handleTokenResponse(resp: Response, username: String): LoginResult {
+ val body = resp.body()
+ return when {
+ !resp.isSuccessful -> LoginResult.Failure("HTTP ${resp.code()}: ${resp.errorBody()?.string()?.take(200)}")
+ body == null -> LoginResult.Failure("empty response")
+ body.mfa_required == true -> LoginResult.MfaRequired(username)
+ body.access_token != null && body.refresh_token != null -> {
+ tokenManager.saveTokens(
+ body.access_token, body.refresh_token,
+ body.expires_in ?: 0L, body.refresh_token_expires_in ?: 0L
+ )
+ LoginResult.Success
+ }
+ else -> LoginResult.Failure(body.detail ?: "no tokens returned")
+ }
+ }
+
+ /** Exchange the refresh token for a fresh access/refresh pair. Returns true on success. */
+ private suspend fun refreshAccessToken(): Boolean {
+ val refresh = tokenManager.getRefreshToken() ?: return false
+ if (tokenManager.isRefreshTokenExpired()) return false
+ return try {
+ val resp = api.refresh("Bearer $refresh", emptyMap())
+ val body = resp.body()
+ if (resp.isSuccessful && body?.access_token != null && body.refresh_token != null) {
+ tokenManager.saveTokens(
+ body.access_token, body.refresh_token,
+ body.expires_in ?: 0L, body.refresh_token_expires_in ?: 0L
+ )
+ true
+ } else false
+ } catch (e: Exception) {
+ false
+ }
+ }
+
+ /**
+ * Proactive refresh used by the service before a sync run: if the access token is expired (or
+ * missing) and a valid refresh token exists, mint a new pair. Returns true if a usable access
+ * token is available afterwards.
+ */
+ suspend fun ensureFreshAccessToken(): Boolean {
+ if (!tokenManager.isAccessTokenExpired() && tokenManager.getAccessToken() != null) return true
+ return refreshAccessToken()
+ }
+
+ /** Run an authenticated call; on 401, refresh once and retry. */
+ private suspend fun withAuthRetry(call: suspend () -> Response): Response {
+ val resp = call()
+ return if (resp.code() == 401 && refreshAccessToken()) call() else resp
+ }
+
+ // --- Connectivity / permission check ------------------------------------------------
+
+ /** GET a single page to verify the server, token and health:read scope. */
+ suspend fun testConnection(): SyncResult = try {
+ val resp = withAuthRetry { api.getWeight(1, 1) }
+ when {
+ resp.isSuccessful -> SyncResult.Success(Unit)
+ resp.code() == 401 || resp.code() == 403 ->
+ SyncResult.Failure(SyncResult.ErrorType.PERMISSION_DENIED, "HTTP ${resp.code()}")
+ else -> SyncResult.Failure(SyncResult.ErrorType.API_ERROR, "HTTP ${resp.code()}: ${resp.errorBody()?.string()?.take(200)}")
+ }
+ } catch (e: Exception) {
+ SyncResult.Failure(SyncResult.ErrorType.UNKNOWN_ERROR, null, e)
+ }
+
+ // --- Export -------------------------------------------------------------------------
+
+ /** Insert/update are the same operation: POST is upsert-by-date on Endurain. */
+ suspend fun upsert(measurement: OpenScaleMeasurement): SyncResult = try {
+ val resp = withAuthRetry { api.postWeight(buildWeightRequest(measurement, dateOnly)) }
+ if (resp.isSuccessful) SyncResult.Success(Unit)
+ else SyncResult.Failure(
+ SyncResult.ErrorType.API_ERROR,
+ "endurain ${dateOnly.format(measurement.date)} upsert error HTTP ${resp.code()}: ${resp.errorBody()?.string()?.take(200)}"
+ )
+ } catch (e: Exception) {
+ SyncResult.Failure(SyncResult.ErrorType.UNKNOWN_ERROR, null, e)
+ }
+
+ suspend fun delete(date: Date): SyncResult = try {
+ val id = findIdByDate(dateOnly.format(date))
+ if (id == null) {
+ // Already absent → deletion is a no-op success (keeps reconcile idempotent).
+ SyncResult.Success(Unit)
+ } else {
+ val resp = withAuthRetry { api.deleteWeight(id) }
+ if (resp.isSuccessful) SyncResult.Success(Unit)
+ else SyncResult.Failure(SyncResult.ErrorType.API_ERROR, "endurain delete error HTTP ${resp.code()}")
+ }
+ } catch (e: Exception) {
+ SyncResult.Failure(SyncResult.ErrorType.UNKNOWN_ERROR, null, e)
+ }
+
+ /** Delete every weight record of the authenticated user (records are token-scoped server-side). */
+ suspend fun clear(): SyncResult {
+ return try {
+ // Always fetch the first page: each pass deletes it, so the next records shift up.
+ // Bounded by CLEAR_MAX_PAGES as a backstop against a server that never drains.
+ var page = 0
+ while (page < CLEAR_MAX_PAGES) {
+ page++
+ val resp = withAuthRetry { api.getWeight(1, PAGE_SIZE) }
+ if (!resp.isSuccessful) {
+ return SyncResult.Failure(SyncResult.ErrorType.API_ERROR, "endurain clear list error HTTP ${resp.code()}")
+ }
+ val records = resp.body()?.records.orEmpty()
+ if (records.isEmpty()) return SyncResult.Success(Unit)
+ for (r in records) {
+ val del = withAuthRetry { api.deleteWeight(r.id) }
+ if (!del.isSuccessful) {
+ return SyncResult.Failure(SyncResult.ErrorType.API_ERROR, "endurain clear delete error HTTP ${del.code()}")
+ }
+ }
+ }
+ SyncResult.Failure(SyncResult.ErrorType.API_ERROR, "endurain clear exceeded page limit")
+ } catch (e: Exception) {
+ SyncResult.Failure(SyncResult.ErrorType.UNKNOWN_ERROR, null, e)
+ }
+ }
+
+ /** Resolve a record id by its calendar date by paging the list (Endurain has no date query). */
+ private suspend fun findIdByDate(dateStr: String): Int? {
+ var page = 1
+ while (page <= CLEAR_MAX_PAGES) {
+ val resp = withAuthRetry { api.getWeight(page, PAGE_SIZE) }
+ if (!resp.isSuccessful) return null
+ val records = resp.body()?.records.orEmpty()
+ records.firstOrNull { it.date == dateStr }?.let { return it.id }
+ if (records.size < PAGE_SIZE) return null
+ page++
+ }
+ return null
+ }
+
+ // --- Retrofit contract + DTOs -------------------------------------------------------
+
+ interface EndurainApi {
+ @FormUrlEncoded
+ @POST("auth/login")
+ suspend fun login(
+ @Field("username") username: String,
+ @Field("password") password: String
+ ): Response
+
+ @POST("auth/mfa/verify")
+ suspend fun verifyMfa(@Body body: MfaRequest): Response
+
+ @POST("auth/refresh")
+ suspend fun refresh(
+ @Header("Authorization") authorization: String,
+ @Body body: Map
+ ): Response
+
+ @POST("health/weight")
+ suspend fun postWeight(@Body body: WeightRequest): Response
+
+ @GET("health/weight")
+ suspend fun getWeight(
+ @Query("page_number") pageNumber: Int?,
+ @Query("num_records") numRecords: Int?
+ ): Response
+
+ @DELETE("health/weight/{id}")
+ suspend fun deleteWeight(@Path("id") id: Int): Response
+ }
+
+ data class TokenResponse(
+ @SerializedName("access_token") val access_token: String? = null,
+ @SerializedName("refresh_token") val refresh_token: String? = null,
+ @SerializedName("expires_in") val expires_in: Long? = null,
+ @SerializedName("refresh_token_expires_in") val refresh_token_expires_in: Long? = null,
+ @SerializedName("token_type") val token_type: String? = null,
+ @SerializedName("mfa_required") val mfa_required: Boolean? = null,
+ @SerializedName("detail") val detail: String? = null
+ )
+
+ data class MfaRequest(
+ @SerializedName("username") val username: String,
+ @SerializedName("mfa_code") val mfa_code: String
+ )
+
+ // Nullable fields are omitted by Gson (nulls are not serialized by default), so only present
+ // metrics are sent — matching Endurain's forbid-extra / optional-field schema.
+ data class WeightRequest(
+ @SerializedName("date") val date: String,
+ @SerializedName("weight") val weight: Float? = null,
+ @SerializedName("body_fat") val bodyFat: Float? = null,
+ @SerializedName("body_water") val bodyWater: Float? = null,
+ @SerializedName("muscle_mass") val muscleMass: Float? = null,
+ @SerializedName("bone_mass") val boneMass: Float? = null,
+ @SerializedName("visceral_fat") val visceralFat: Float? = null
+ )
+
+ data class WeightRead(
+ @SerializedName("id") val id: Int = 0,
+ @SerializedName("date") val date: String? = null
+ )
+
+ data class WeightListResponse(
+ @SerializedName("total") val total: Int? = null,
+ @SerializedName("records") val records: List? = null
+ )
+
+ companion object {
+ private const val PAGE_SIZE = 100
+ private const val CLEAR_MAX_PAGES = 1000
+
+ /**
+ * Maps an openScale measurement to Endurain's weight body. Reads the self-describing generic
+ * [OpenScaleMeasurement.values] (unit-aware) rather than the sync-app convenience fields,
+ * which are all normalised to %. Endurain wants weight/masses in kg and fat/water in %. Only
+ * present values are set; null fields are omitted by Gson (Endurain's schema forbids unknown
+ * keys but accepts missing optional ones). `bmi` is left for the server to auto-calculate;
+ * `source`, `physique_rating`, `metabolic_age` are omitted (openScale has no source / the
+ * server enum rejects a non-garmin source). Pure/static so it is unit-testable without a
+ * network client.
+ */
+ fun buildWeightRequest(m: OpenScaleMeasurement, dateOnly: SimpleDateFormat): WeightRequest {
+ val byKey = m.values.associateBy { it.key }
+
+ // Fallback for a very old openScale that emits no generic values: use the convenience
+ // fields (fat/water are %; muscle/bone are % → convert to kg via weight).
+ if (byKey.isEmpty()) {
+ val w = m.weight.takeIf { it > 0f }
+ return WeightRequest(
+ date = dateOnly.format(m.date),
+ weight = w,
+ bodyFat = m.body_fat.takeIf { it > 0f },
+ bodyWater = m.water.takeIf { it > 0f },
+ muscleMass = if (w != null) m.muscle.takeIf { it > 0f }?.let { w * it / 100f } else null,
+ boneMass = if (w != null) m.bone.takeIf { it > 0f }?.let { w * it / 100f } else null,
+ visceralFat = null
+ )
+ }
+
+ val weight = byKey["WEIGHT"]?.value?.takeIf { it > 0f }
+
+ // A composition value as a percentage of body weight.
+ fun asPercent(key: String): Float? {
+ val v = byKey[key] ?: return null
+ val value = v.value ?: return null
+ return when {
+ v.unit == "%" -> value
+ weight != null -> value / weight * 100f
+ else -> null
+ }
+ }
+
+ // A composition value as an absolute mass in kg.
+ fun asKg(key: String): Float? {
+ val v = byKey[key] ?: return null
+ val value = v.value ?: return null
+ return when {
+ v.unit == "kg" -> value
+ weight != null -> weight * value / 100f
+ else -> null
+ }
+ }
+
+ return WeightRequest(
+ date = dateOnly.format(m.date),
+ weight = weight,
+ bodyFat = asPercent("BODY_FAT"),
+ bodyWater = asPercent("WATER"),
+ muscleMass = asKg("MUSCLE"),
+ boneMass = asKg("BONE"),
+ visceralFat = byKey["VISCERAL_FAT"]?.value // unitless rating, passed through
+ )
+ }
+ }
+}
diff --git a/src/app/src/main/java/com/health/openscale/sync/core/sync/EndurainTokenManager.kt b/src/app/src/main/java/com/health/openscale/sync/core/sync/EndurainTokenManager.kt
new file mode 100644
index 0000000..5484976
--- /dev/null
+++ b/src/app/src/main/java/com/health/openscale/sync/core/sync/EndurainTokenManager.kt
@@ -0,0 +1,93 @@
+/*
+ * Copyright (C) 2026 Dany Mestas
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program 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 General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see
+ *
+ */
+package com.health.openscale.sync.core.sync
+
+import android.content.Context
+import android.content.SharedPreferences
+import androidx.core.content.edit
+import androidx.security.crypto.EncryptedSharedPreferences
+import androidx.security.crypto.MasterKey
+import timber.log.Timber
+
+/**
+ * Encrypted at-rest storage for the Endurain OAuth tokens. Endurain issues a short-lived access
+ * token (~15 min) plus a longer-lived rotating refresh token (~7 days). We never store the user's
+ * password — only the tokens — matching Gadgetbridge's model. Tokens are held in an
+ * [EncryptedSharedPreferences] (AES256) file separate from the plain app prefs.
+ *
+ * Expiries are stored as ABSOLUTE epoch-seconds, computed from the `expires_in` /
+ * `refresh_token_expires_in` (relative seconds) the server returns at login/refresh.
+ */
+class EndurainTokenManager(context: Context) {
+ private val prefs: SharedPreferences = run {
+ val masterKey = MasterKey.Builder(context)
+ .setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
+ .build()
+ EncryptedSharedPreferences.create(
+ context,
+ PREFS_FILE,
+ masterKey,
+ EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
+ EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
+ )
+ }
+
+ /**
+ * Persist a freshly minted token pair. [expiresIn] / [refreshExpiresIn] are the relative
+ * lifetimes in seconds from the Endurain token response; they are converted to absolute
+ * epoch-second deadlines here.
+ */
+ fun saveTokens(accessToken: String, refreshToken: String, expiresIn: Long, refreshExpiresIn: Long) {
+ val now = System.currentTimeMillis() / 1000
+ prefs.edit {
+ putString(KEY_ACCESS, accessToken)
+ putString(KEY_REFRESH, refreshToken)
+ putLong(KEY_ACCESS_EXP, now + expiresIn)
+ putLong(KEY_REFRESH_EXP, now + refreshExpiresIn)
+ }
+ }
+
+ fun getAccessToken(): String? = prefs.getString(KEY_ACCESS, null)
+ fun getRefreshToken(): String? = prefs.getString(KEY_REFRESH, null)
+
+ fun getRefreshTokenExpiresAt(): Long = prefs.getLong(KEY_REFRESH_EXP, 0L)
+
+ // A small clock-skew margin so we refresh slightly early rather than racing the server's cutoff.
+ fun isAccessTokenExpired(): Boolean =
+ (System.currentTimeMillis() / 1000) >= (prefs.getLong(KEY_ACCESS_EXP, 0L) - EXPIRY_SKEW_SECONDS)
+
+ fun isRefreshTokenExpired(): Boolean =
+ (System.currentTimeMillis() / 1000) >= prefs.getLong(KEY_REFRESH_EXP, 0L)
+
+ /** Logged in = we still hold a refresh token that has not yet expired. */
+ fun isLoggedIn(): Boolean = getRefreshToken() != null && !isRefreshTokenExpired()
+
+ fun clear() {
+ prefs.edit { clear() }
+ Timber.d("Endurain tokens cleared")
+ }
+
+ companion object {
+ private const val PREFS_FILE = "endurain_tokens"
+ private const val KEY_ACCESS = "access_token"
+ private const val KEY_REFRESH = "refresh_token"
+ private const val KEY_ACCESS_EXP = "access_token_expires_at"
+ private const val KEY_REFRESH_EXP = "refresh_token_expires_at"
+ private const val EXPIRY_SKEW_SECONDS = 30L
+ }
+}
diff --git a/src/app/src/main/res/drawable/ic_endurain.xml b/src/app/src/main/res/drawable/ic_endurain.xml
new file mode 100644
index 0000000..aef00db
--- /dev/null
+++ b/src/app/src/main/res/drawable/ic_endurain.xml
@@ -0,0 +1,13 @@
+
+
+
diff --git a/src/app/src/main/res/values-de/strings.xml b/src/app/src/main/res/values-de/strings.xml
index 9f1e428..ae23210 100644
--- a/src/app/src/main/res/values-de/strings.xml
+++ b/src/app/src/main/res/values-de/strings.xml
@@ -94,6 +94,23 @@
z.B. https://wger.de/api/v2/
Wger API-Token
+ Erfolgreich mit Endurain verbunden.
+ Verbindung zu Endurain fehlgeschlagen.
+ Server-URL
+ z.B. https://endurain.example.com
+ Benutzername
+ Passwort
+ Zwei-Faktor-Code
+ Anmelden
+ Code bestätigen
+ Abmelden
+ Bei Endurain angemeldet
+ Endurain-Anmeldung fehlgeschlagen
+ Angemeldet als %1$s
+ Sitzung läuft ab: %1$s
+ Nicht angemeldet — bitte anmelden
+ Sitzung abgelaufen — bitte erneut anmelden
+
Diese openScale-sync-Version benötigt ein neueres openScale. Bitte aktualisiere openScale, um weiter zu synchronisieren.
openScale %1$s auf diesem Gerät ist zu alt zum Synchronisieren. Bitte aktualisiere openScale auf die neueste Version.
Synchronisierte Benutzer
diff --git a/src/app/src/main/res/values/strings.xml b/src/app/src/main/res/values/strings.xml
index 70968d6..59d70fa 100644
--- a/src/app/src/main/res/values/strings.xml
+++ b/src/app/src/main/res/values/strings.xml
@@ -93,6 +93,23 @@
e.g. https://wger.de/api/v2/
Wger Api token
+ Successfully connected to Endurain
+ Cannot connect to Endurain
+ Server URL
+ e.g. https://endurain.example.com
+ Username
+ Password
+ Two-factor code
+ Log in
+ Verify code
+ Log out
+ Logged in to Endurain
+ Endurain login failed
+ Logged in as %1$s
+ Session expires: %1$s
+ Not logged in — please log in
+ Session expired — please log in again
+
This openScale-sync version requires a newer openScale. Please update openScale to continue synchronizing.
openScale %1$s on this device is too old to synchronize. Please update openScale to the latest version.
Synced users
diff --git a/src/app/src/test/java/com/health/openscale/sync/core/sync/EndurainSyncTest.kt b/src/app/src/test/java/com/health/openscale/sync/core/sync/EndurainSyncTest.kt
new file mode 100644
index 0000000..92c924a
--- /dev/null
+++ b/src/app/src/test/java/com/health/openscale/sync/core/sync/EndurainSyncTest.kt
@@ -0,0 +1,115 @@
+/*
+ * Copyright (C) 2026 Dany Mestas
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program 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 General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see
+ *
+ */
+package com.health.openscale.sync.core.sync
+
+import com.google.gson.Gson
+import com.health.openscale.sync.core.datatypes.OpenScaleMeasurement
+import com.health.openscale.sync.core.datatypes.OpenScaleMeasurementValue
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertNull
+import org.junit.Assert.assertTrue
+import org.junit.Test
+import java.text.SimpleDateFormat
+import java.util.Date
+import java.util.Locale
+import java.util.TimeZone
+
+/**
+ * Covers [EndurainSync.buildWeightRequest] — the openScale-values → Endurain-weight-body mapping.
+ * Pure logic, no network. Verifies kg conversion for masses, %-passthrough, null omission, that no
+ * forbidden keys (bmi/source) are emitted, and that numbers are serialized as JSON numbers.
+ */
+class EndurainSyncTest {
+
+ // UTC so the formatted calendar date is deterministic regardless of the test host's zone.
+ private val dateOnly = SimpleDateFormat("yyyy-MM-dd", Locale.US).apply { timeZone = TimeZone.getTimeZone("UTC") }
+ private val gson = Gson()
+
+ private fun mv(key: String, unit: String, value: Float, typeId: Int = 0) =
+ OpenScaleMeasurementValue(typeId, key, key, unit, false, value)
+
+ private fun measurement(values: List) =
+ OpenScaleMeasurement.fromValues(1, 1, Date(0L), "alice", values)
+
+ @Test
+ fun fullMapping_massesInKg_percentagesPassThrough() {
+ val m = measurement(listOf(
+ mv("WEIGHT", "kg", 80f),
+ mv("BODY_FAT", "%", 20f),
+ mv("WATER", "%", 55f),
+ mv("MUSCLE", "%", 40f), // % → kg via weight
+ mv("BONE", "kg", 3.2f), // already kg → direct
+ mv("VISCERAL_FAT", "", 9f)
+ ))
+
+ val req = EndurainSync.buildWeightRequest(m, dateOnly)
+
+ assertEquals("1970-01-01", req.date)
+ assertEquals(80f, req.weight!!, 0.0001f)
+ assertEquals(20f, req.bodyFat!!, 0.0001f)
+ assertEquals(55f, req.bodyWater!!, 0.0001f)
+ assertEquals(32f, req.muscleMass!!, 0.0001f) // 80 * 40 / 100
+ assertEquals(3.2f, req.boneMass!!, 0.0001f)
+ assertEquals(9f, req.visceralFat!!, 0.0001f)
+ }
+
+ @Test
+ fun muscleAlreadyKg_passesThrough_boneAsPercentConverted() {
+ val m = measurement(listOf(
+ mv("WEIGHT", "kg", 100f),
+ mv("MUSCLE", "kg", 45f), // already kg → direct
+ mv("BONE", "%", 3f) // % → kg via weight
+ ))
+
+ val req = EndurainSync.buildWeightRequest(m, dateOnly)
+
+ assertEquals(45f, req.muscleMass!!, 0.0001f)
+ assertEquals(3f, req.boneMass!!, 0.0001f) // 100 * 3 / 100
+ }
+
+ @Test
+ fun absentMetrics_areNull() {
+ val m = measurement(listOf(mv("WEIGHT", "kg", 70f)))
+ val req = EndurainSync.buildWeightRequest(m, dateOnly)
+
+ assertEquals(70f, req.weight!!, 0.0001f)
+ assertNull(req.bodyFat)
+ assertNull(req.bodyWater)
+ assertNull(req.muscleMass)
+ assertNull(req.boneMass)
+ assertNull(req.visceralFat)
+ }
+
+ @Test
+ fun json_omitsNulls_hasNoForbiddenKeys_usesNumbers() {
+ val m = measurement(listOf(mv("WEIGHT", "kg", 70f)))
+ val json = gson.toJson(EndurainSync.buildWeightRequest(m, dateOnly))
+
+ // present + numeric (not a quoted string)
+ assertTrue(json.contains("\"weight\":70"))
+ assertFalse(json.contains("\"weight\":\""))
+ // absent optionals omitted (Gson drops nulls)
+ assertFalse(json.contains("body_fat"))
+ assertFalse(json.contains("muscle_mass"))
+ assertFalse(json.contains("visceral_fat"))
+ // never sent: server auto-calcs bmi; source enum rejects non-garmin
+ assertFalse(json.contains("bmi"))
+ assertFalse(json.contains("source"))
+ }
+}
diff --git a/src/gradle/libs.versions.toml b/src/gradle/libs.versions.toml
index e135941..0558437 100644
--- a/src/gradle/libs.versions.toml
+++ b/src/gradle/libs.versions.toml
@@ -22,6 +22,7 @@ hivemqMqttClient = "1.3.15"
gson = "2.14.0"
okhttp = "5.4.0"
retrofit = "3.0.0"
+securityCrypto = "1.1.0-alpha06"
junit = "4.13.2"
robolectric = "4.16.1"
coroutinesTest = "1.11.0"
@@ -61,6 +62,7 @@ gson = { group = "com.google.code.gson", name = "gson", version.ref = "gson" }
okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhttp" }
retrofit = { group = "com.squareup.retrofit2", name = "retrofit", version.ref = "retrofit" }
retrofit-converter-gson = { group = "com.squareup.retrofit2", name = "converter-gson", version.ref = "retrofit" }
+androidx-security-crypto = { group = "androidx.security", name = "security-crypto", version.ref = "securityCrypto" }
[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }