Endurain: Support refreshing auth token

This commit is contained in:
Arjan Schrijver
2026-06-19 23:31:23 +02:00
parent 38e02f6e78
commit efdc8b9121
4 changed files with 109 additions and 53 deletions
@@ -22,11 +22,17 @@ import com.google.gson.Gson
import nodomain.freeyourgadget.gadgetbridge.util.InternetUtils
import org.slf4j.LoggerFactory
enum class AuthType {
NONE,
AUTH_TOKEN,
REFRESH_TOKEN
}
data class LoginResponse(
val session_id: String? = null,
val access_token: String? = null,
val refresh_token: String? = null,
val expires_in: Long? = null,
val expires_in: Int? = null,
val token_type: String? = null,
val mfa_required: Boolean? = null,
val username: String? = null,
@@ -38,10 +44,6 @@ data class MfaVerifyRequest(
val mfa_code: String
)
data class TokenRefreshRequest(
val refresh_token: String
)
data class TokenExchangeRequest(
val code_verifier: String
)
@@ -56,12 +58,22 @@ class EndurainApiClient(
/**
* Build headers with authentication tokens
*/
private fun buildHeaders(): Map<String, String> {
private fun buildHeaders(auth: AuthType): MutableMap<String, String> {
val headers = mutableMapOf("X-Client-Type" to "mobile")
when (auth) {
AuthType.AUTH_TOKEN -> {
tokenManager.getAccessToken()?.let { token ->
headers["Authorization"] = "Bearer $token"
}
}
AuthType.REFRESH_TOKEN -> {
tokenManager.getRefreshToken()?.let { token ->
headers["Authorization"] = "Bearer $token"
}
}
else -> {}
}
return headers
}
@@ -76,15 +88,14 @@ class EndurainApiClient(
// Form-encoded body
val body = "username=${Uri.encode(username)}&password=${Uri.encode(password)}"
val headers = mapOf("X-Client-Type" to "mobile")
val headers = buildHeaders(AuthType.NONE)
headers["Content-Type"] = "application/x-www-form-urlencoded"
val responseText = InternetUtils.doStringRequest(
uri = uri,
method = "POST",
requestHeaders = headers,
body = body,
bodyContentType = "application/x-www-form-urlencoded",
allowInsecure = false
body = body
)
return if (responseText != null) {
@@ -109,15 +120,14 @@ class EndurainApiClient(
val request = MfaVerifyRequest(username, mfaCode)
val body = gson.toJson(request)
val headers = mapOf("X-Client-Type" to "mobile")
val headers = buildHeaders(AuthType.NONE)
headers["Content-Type"] = "application/json"
val responseText = InternetUtils.doStringRequest(
uri = uri,
method = "POST",
requestHeaders = headers,
body = body,
bodyContentType = "application/json",
allowInsecure = false
body = body
)
return if (responseText != null) {
@@ -137,26 +147,16 @@ class EndurainApiClient(
*/
fun refreshToken(): LoginResponse? {
try {
val refreshToken = tokenManager.getRefreshToken()
if (refreshToken == null) {
LOG.error("No refresh token available")
return null
}
val uri = "$baseUrl/api/v1/auth/refresh".toUri()
val request = TokenRefreshRequest(refreshToken)
val body = gson.toJson(request)
val headers = buildHeaders()
val headers = buildHeaders(AuthType.REFRESH_TOKEN)
headers["Content-Type"] = "application/json"
val responseText = InternetUtils.doStringRequest(
uri = uri,
method = "POST",
requestHeaders = headers,
body = body,
bodyContentType = "application/json",
allowInsecure = false
body = "{}"
)
return if (responseText != null) {
@@ -178,15 +178,14 @@ class EndurainApiClient(
try {
val uri = "$baseUrl/api/v1/auth/logout".toUri()
val headers = buildHeaders()
val headers = buildHeaders(AuthType.AUTH_TOKEN)
headers["Content-Type"] = "application/json"
InternetUtils.doStringRequest(
uri = uri,
method = "POST",
requestHeaders = headers,
body = null,
bodyContentType = "application/json",
allowInsecure = false
body = "{}"
)
tokenManager.clearTokens()
@@ -207,15 +206,14 @@ class EndurainApiClient(
val request = TokenExchangeRequest(codeVerifier)
val body = gson.toJson(request)
val headers = mapOf("X-Client-Type" to "mobile")
val headers = buildHeaders(AuthType.NONE)
headers["Content-Type"] = "application/json"
val responseText = InternetUtils.doStringRequest(
uri = uri,
method = "POST",
requestHeaders = headers,
body = body,
bodyContentType = "application/json",
allowInsecure = false
body = body
)
return if (responseText != null) {
@@ -241,15 +239,14 @@ class EndurainApiClient(
try {
val uri = "$baseUrl$endpoint".toUri()
val headers = buildHeaders()
val headers = buildHeaders(AuthType.AUTH_TOKEN)
headers["Content-Type"] = "application/json"
return InternetUtils.doStringRequest(
uri = uri,
method = method,
requestHeaders = headers,
body = body,
bodyContentType = "application/json",
allowInsecure = false
body = body
)
} catch (e: Exception) {
LOG.error("Authenticated request error", e)
@@ -42,7 +42,20 @@ class EndurainPreferencesActivity : AbstractSettingsActivityV2() {
wireLoginPreference()
wireLogoutPreference()
updateStatus()
updateLogoutPreferenceVisibility()
setupLoginResultListener()
// Refresh auth token
val vm: EndurainSetupViewModel by viewModels()
val server = GBApplication.getPrefs().preferences.getString("endurain_server", null)
if (server != null) {
vm.performTokenRefresh(server) {
activity?.runOnUiThread {
updateStatus()
updateLogoutPreferenceVisibility()
}
}
}
}
private fun setupLoginResultListener() {
@@ -106,9 +119,10 @@ class EndurainPreferencesActivity : AbstractSettingsActivityV2() {
private fun updateStatus() {
val statusPref = findPreference<Preference>("pref_key_status")
val server = GBApplication.getPrefs().preferences.getString("endurain_server", null)
val tokenExpiresAt = vm.getTokenExpiresAt()
if (vm.isLoggedIn() && server != null) {
statusPref?.summary = "Logged in to $server"
statusPref?.summary = "Logged in to $server\nAuth token expires: $tokenExpiresAt"
} else {
statusPref?.summary = "Not logged in, integration is disabled"
}
@@ -19,8 +19,10 @@ package nodomain.freeyourgadget.gadgetbridge.activities.endurain
import android.app.Application
import androidx.core.net.toUri
import androidx.lifecycle.AndroidViewModel
import nodomain.freeyourgadget.gadgetbridge.util.DateTimeUtils
import nodomain.freeyourgadget.gadgetbridge.util.InternetUtils
import org.slf4j.LoggerFactory
import java.util.Date
class EndurainSetupViewModel(application: Application) : AndroidViewModel(application) {
@@ -50,11 +52,7 @@ class EndurainSetupViewModel(application: Application) : AndroidViewModel(applic
try {
// Fetch server settings from the public endpoint
val settingsUri = "$serverUrl/api/v1/public/server_settings".toUri()
val settingsResponse = InternetUtils.doJsonRequest(
uri = settingsUri,
method = "GET",
allowInsecure = false
)
val settingsResponse = InternetUtils.doJsonRequest(settingsUri)
if (settingsResponse != null) {
// Parse server settings to determine available authentication methods
@@ -87,6 +85,44 @@ class EndurainSetupViewModel(application: Application) : AndroidViewModel(applic
}.start()
}
/**
* Perform token refresh
*/
fun performTokenRefresh(
serverUrl: String,
callback: (Boolean) -> Unit
) {
Thread {
try {
apiClient = EndurainApiClient(serverUrl, tokenManager)
val response = apiClient.refreshToken()
when {
response == null -> {
LOG.error("Token refresh failed: null response")
callback(false)
}
response.access_token != null -> {
LOG.info("Token refresh successful")
tokenManager.saveTokens(
response.access_token,
response.refresh_token!!,
response.expires_in!!
)
callback(true)
}
else -> {
LOG.error("Token refresh failed: ${response.message}")
callback(false)
}
}
} catch (e: Exception) {
LOG.error("Token refresh error", e)
callback(false)
}
}.start()
}
/**
* Perform local username/password login
*/
@@ -116,7 +152,8 @@ class EndurainSetupViewModel(application: Application) : AndroidViewModel(applic
LOG.info("Login successful")
tokenManager.saveTokens(
response.access_token,
response.refresh_token!!
response.refresh_token!!,
response.expires_in!!
)
callback(true)
}
@@ -151,7 +188,8 @@ class EndurainSetupViewModel(application: Application) : AndroidViewModel(applic
LOG.info("MFA verification successful")
tokenManager.saveTokens(
response.access_token,
response.refresh_token!!
response.refresh_token!!,
response.expires_in!!
)
pendingMfaUsername = null
callback(true)
@@ -173,6 +211,13 @@ class EndurainSetupViewModel(application: Application) : AndroidViewModel(applic
return tokenManager.getAccessToken() != null && !tokenManager.isTokenExpired()
}
/**
* Get token expiry date
*/
fun getTokenExpiresAt(): Date {
return DateTimeUtils.parseTimeStamp(tokenManager.getAccessTokenExpiresAt())
}
/**
* Logout and clear tokens
*/
@@ -34,19 +34,19 @@ class EndurainTokenManager(context: Context) {
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
)
fun saveTokens(accessToken: String, refreshToken: String) {
fun saveTokens(accessToken: String, refreshToken: String, accessTokenExpiresAt: Int) {
sharedPreferences.edit {
putString("access_token", accessToken)
.putString("refresh_token", refreshToken)
.putLong("expires_at", System.currentTimeMillis() + (15 * 60 * 1000))
.putInt("expires_at", accessTokenExpiresAt)
}
}
fun getAccessToken(): String? = sharedPreferences.getString("access_token", null)
fun getRefreshToken(): String? = sharedPreferences.getString("refresh_token", null)
fun getAccessTokenExpiresAt(): Int = sharedPreferences.getInt("expires_at", 0)
fun isTokenExpired(): Boolean {
val expiresAt = sharedPreferences.getLong("expires_at", 0)
return System.currentTimeMillis() >= expiresAt
return (System.currentTimeMillis() / 1000) >= getAccessTokenExpiresAt()
}
fun clearTokens() {