Endurain: Refresh tokens periodically

This commit is contained in:
Arjan Schrijver
2026-06-19 23:31:23 +02:00
parent 5dfa20202d
commit 8cd90f5a66
7 changed files with 154 additions and 43 deletions
@@ -70,6 +70,7 @@ import java.util.Locale;
import java.util.Set; import java.util.Set;
import nodomain.freeyourgadget.gadgetbridge.activities.ControlCenterv2; import nodomain.freeyourgadget.gadgetbridge.activities.ControlCenterv2;
import nodomain.freeyourgadget.gadgetbridge.activities.endurain.PeriodicEndurainTokenRefresher;
import nodomain.freeyourgadget.gadgetbridge.database.DBHandler; import nodomain.freeyourgadget.gadgetbridge.database.DBHandler;
import nodomain.freeyourgadget.gadgetbridge.database.DBHelper; import nodomain.freeyourgadget.gadgetbridge.database.DBHelper;
import nodomain.freeyourgadget.gadgetbridge.database.PeriodicDbExporter; import nodomain.freeyourgadget.gadgetbridge.database.PeriodicDbExporter;
@@ -290,6 +291,11 @@ public class GBApplication extends Application {
// Ensure the InternetHelper is bound, so that it works on first usage // Ensure the InternetHelper is bound, so that it works on first usage
InternetHelperSingleton.INSTANCE.ensureInternetHelperBound(); InternetHelperSingleton.INSTANCE.ensureInternetHelperBound();
// Refresh Endurain tokens and schedule periodic refresh
if (!GBEnvironment.env().isTest()) {
PeriodicEndurainTokenRefresher.INSTANCE.scheduleNextExecution(context);
}
} }
private void startNotificationCollectorMonitorService() { private void startNotificationCollectorMonitorService() {
@@ -29,7 +29,6 @@ import nodomain.freeyourgadget.gadgetbridge.util.DateTimeUtils
import nodomain.freeyourgadget.gadgetbridge.util.GB import nodomain.freeyourgadget.gadgetbridge.util.GB
class EndurainPreferencesActivity : AbstractSettingsActivityV2() { class EndurainPreferencesActivity : AbstractSettingsActivityV2() {
override fun newFragment(): PreferenceFragmentCompat = override fun newFragment(): PreferenceFragmentCompat =
EndurainPreferencesFragment() EndurainPreferencesFragment()
@@ -52,7 +51,7 @@ class EndurainPreferencesActivity : AbstractSettingsActivityV2() {
val server = GBApplication.getPrefs().preferences.getString("endurain_server", null) val server = GBApplication.getPrefs().preferences.getString("endurain_server", null)
if (server != null) { if (server != null) {
if (vm.tokenManager.isAccessTokenExpired()) { if (vm.tokenManager.isAccessTokenExpired()) {
vm.performTokenRefresh(server) { vm.tokenManager.performTokenRefresh(server) {
activity?.runOnUiThread { activity?.runOnUiThread {
updateStatus() updateStatus()
updateLogoutPreferenceVisibility() updateLogoutPreferenceVisibility()
@@ -19,10 +19,8 @@ package nodomain.freeyourgadget.gadgetbridge.activities.endurain
import android.app.Application import android.app.Application
import androidx.core.net.toUri import androidx.core.net.toUri
import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.AndroidViewModel
import nodomain.freeyourgadget.gadgetbridge.util.DateTimeUtils
import nodomain.freeyourgadget.gadgetbridge.util.InternetUtils import nodomain.freeyourgadget.gadgetbridge.util.InternetUtils
import org.slf4j.LoggerFactory import org.slf4j.LoggerFactory
import java.util.Date
class EndurainSetupViewModel(application: Application) : AndroidViewModel(application) { class EndurainSetupViewModel(application: Application) : AndroidViewModel(application) {
@@ -105,44 +103,6 @@ class EndurainSetupViewModel(application: Application) : AndroidViewModel(applic
}.start() }.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 * Perform local username/password login
*/ */
@@ -20,8 +20,11 @@ import android.content.Context
import androidx.core.content.edit import androidx.core.content.edit
import androidx.security.crypto.EncryptedSharedPreferences import androidx.security.crypto.EncryptedSharedPreferences
import androidx.security.crypto.MasterKey import androidx.security.crypto.MasterKey
import org.slf4j.Logger
import org.slf4j.LoggerFactory
class EndurainTokenManager(context: Context) { class EndurainTokenManager(context: Context) {
private val LOG: Logger = LoggerFactory.getLogger(EndurainTokenManager::class.java)
private val masterKey = MasterKey.Builder(context) private val masterKey = MasterKey.Builder(context)
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM) .setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
.build() .build()
@@ -64,4 +67,39 @@ class EndurainTokenManager(context: Context) {
fun isRefreshTokenExpired(): Boolean { fun isRefreshTokenExpired(): Boolean {
return (System.currentTimeMillis() / 1000) >= getRefreshTokenExpiresAt() return (System.currentTimeMillis() / 1000) >= getRefreshTokenExpiresAt()
} }
fun performTokenRefresh(
serverUrl: String,
callback: (Boolean) -> Unit
) {
Thread {
try {
val apiClient = EndurainApiClient(serverUrl, this)
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")
saveTokens(
response.access_token,
response.refresh_token!!,
response.expires_in!!
)
callback(true)
}
else -> {
LOG.error("Token refresh failed: ${response.detail}")
callback(false)
}
}
} catch (e: Exception) {
LOG.error("Token refresh error", e)
callback(false)
}
}.start()
}
} }
@@ -0,0 +1,45 @@
/* Copyright (C) 2026 Arjan Schrijver
This file is part of Gadgetbridge.
Gadgetbridge is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published
by the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
Gadgetbridge 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 Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>. */
package nodomain.freeyourgadget.gadgetbridge.activities.endurain
import android.content.Context
import androidx.work.Worker
import androidx.work.WorkerParameters
import nodomain.freeyourgadget.gadgetbridge.GBApplication
import org.slf4j.Logger
import org.slf4j.LoggerFactory
class EndurainTokenRefreshWorker (
context: Context,
params: WorkerParameters
) : Worker(context, params) {
private val LOG: Logger = LoggerFactory.getLogger(EndurainTokenRefreshWorker::class.java)
override fun doWork(): Result {
LOG.info("Running Endurain token refresh worker")
// Refresh tokens if possible
val tokenManager = EndurainTokenManager(applicationContext)
val serverUrl = GBApplication.getPrefs().preferences.getString("endurain_server", null)
if (serverUrl != null && tokenManager.isLoggedIn()) {
tokenManager.performTokenRefresh(serverUrl) {}
}
return Result.success()
}
}
@@ -0,0 +1,63 @@
/* Copyright (C) 2026 Arjan Schrijver
This file is part of Gadgetbridge.
Gadgetbridge is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published
by the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
Gadgetbridge 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 Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>. */
package nodomain.freeyourgadget.gadgetbridge.activities.endurain
import android.content.Context
import androidx.work.ExistingPeriodicWorkPolicy
import androidx.work.PeriodicWorkRequestBuilder
import androidx.work.WorkManager
import org.slf4j.Logger
import org.slf4j.LoggerFactory
import java.util.concurrent.TimeUnit
object PeriodicEndurainTokenRefresher {
private val LOG: Logger = LoggerFactory.getLogger(PeriodicEndurainTokenRefresher::class.java)
const val TAG_CREATED_AT = "createdAt-"
const val WORK_TAG = "EndurainTokenRefreshWorker"
fun scheduleNextExecution(context: Context) {
try {
val tokenManager = EndurainTokenManager(context)
val workManager = WorkManager.getInstance(context)
workManager.cancelAllWorkByTag(WORK_TAG)
if (!tokenManager.isLoggedIn()) {
LOG.info("Not scheduling {}, no valid refresh token available", this::class.java.simpleName)
return
}
// Runs every 3 days
val periodicWork = PeriodicWorkRequestBuilder<EndurainTokenRefreshWorker>(
3, TimeUnit.DAYS
)
.addTag(WORK_TAG)
.addTag("$TAG_CREATED_AT${System.currentTimeMillis()}")
.build()
WorkManager.getInstance(context).apply {
enqueueUniquePeriodicWork(
WORK_TAG,
ExistingPeriodicWorkPolicy.CANCEL_AND_REENQUEUE,
periodicWork
)
}
} catch (e: Exception) {
LOG.error("Failed to schedule next execution for {}", this::class.java.simpleName, e)
}
}
}
@@ -757,7 +757,7 @@ class WorkoutDetailsFragment : Fragment(), MenuProvider {
val endurainVm: EndurainSetupViewModel by viewModels() val endurainVm: EndurainSetupViewModel by viewModels()
val serverUrl = GBApplication.getPrefs().preferences.getString("endurain_server", null) val serverUrl = GBApplication.getPrefs().preferences.getString("endurain_server", null)
val apiClient = EndurainApiClient(serverUrl!!, endurainVm.tokenManager) val apiClient = EndurainApiClient(serverUrl!!, endurainVm.tokenManager)
endurainVm.performTokenRefresh(serverUrl) { endurainVm.tokenManager.performTokenRefresh(serverUrl) {
apiClient.uploadActivity(gpxFile) { success -> apiClient.uploadActivity(gpxFile) { success ->
activity?.runOnUiThread { activity?.runOnUiThread {
if (success) if (success)