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" }