diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/endurain/EndurainApiClient.kt b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/endurain/EndurainApiClient.kt index 79428856d9..5dcc7c7d72 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/endurain/EndurainApiClient.kt +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/endurain/EndurainApiClient.kt @@ -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,11 +58,21 @@ class EndurainApiClient( /** * Build headers with authentication tokens */ - private fun buildHeaders(): Map { + private fun buildHeaders(auth: AuthType): MutableMap { val headers = mutableMapOf("X-Client-Type" to "mobile") - tokenManager.getAccessToken()?.let { token -> - headers["Authorization"] = "Bearer $token" + 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) diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/endurain/EndurainPreferencesActivity.kt b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/endurain/EndurainPreferencesActivity.kt index b767fcfe81..3f8f163d61 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/endurain/EndurainPreferencesActivity.kt +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/endurain/EndurainPreferencesActivity.kt @@ -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("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" } diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/endurain/EndurainSetupViewModel.kt b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/endurain/EndurainSetupViewModel.kt index 35d812141e..8b6fd65e7c 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/endurain/EndurainSetupViewModel.kt +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/endurain/EndurainSetupViewModel.kt @@ -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 */ diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/endurain/EndurainTokenManager.kt b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/endurain/EndurainTokenManager.kt index 97d4c54f71..b2aa64b533 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/endurain/EndurainTokenManager.kt +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/endurain/EndurainTokenManager.kt @@ -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() {