diff --git a/app/build.gradle b/app/build.gradle
index e810198cd8..6856ae348c 100644
--- a/app/build.gradle
+++ b/app/build.gradle
@@ -262,6 +262,7 @@ dependencies {
implementation libs.androidx.work.runtime.ktx
implementation libs.androidx.lifecycle.process
implementation libs.androidx.security.crypto
+ implementation libs.androidx.browser
implementation libs.material
implementation libs.flexbox
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index bf910edcb3..eb3dbb2073 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -1124,6 +1124,23 @@
+
+
+
+
+
+
+
+
+
+
+
? {
+ try {
+ val uri = "$baseUrl/api/v1/public/idp".toUri()
+
+ val responseText = InternetUtils.doStringRequest(uri = uri)
+
+ return if (responseText != null) {
+ val type = object : com.google.gson.reflect.TypeToken>() {}.type
+ gson.fromJson(responseText, type)
+ } else {
+ LOG.error("Failed to fetch identity providers")
+ null
+ }
+ } catch (e: Exception) {
+ LOG.error("Error fetching identity providers", e)
+ return null
+ }
+ }
+
/**
* Exchange OAuth session for tokens (PKCE flow)
*/
@@ -220,6 +249,8 @@ class EndurainApiClient(
body = body
)
+ LOG.debug("OAuth token result: $responseText")
+
return if (responseText != null) {
gson.fromJson(responseText, LoginResponse::class.java)
} else {
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/endurain/EndurainOAuthCallbackActivity.kt b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/endurain/EndurainOAuthCallbackActivity.kt
new file mode 100644
index 0000000000..082c014594
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/endurain/EndurainOAuthCallbackActivity.kt
@@ -0,0 +1,84 @@
+/* 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 . */
+package nodomain.freeyourgadget.gadgetbridge.activities.endurain
+
+import android.content.Intent
+import android.net.Uri
+import android.os.Bundle
+import android.widget.Toast
+import nodomain.freeyourgadget.gadgetbridge.R
+import nodomain.freeyourgadget.gadgetbridge.activities.AbstractGBActivity
+import org.slf4j.LoggerFactory
+
+/**
+ * Handles OAuth callback deep links from the browser
+ */
+class EndurainOAuthCallbackActivity : AbstractGBActivity() {
+
+ private val LOG = LoggerFactory.getLogger(EndurainOAuthCallbackActivity::class.java)
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+
+ // Bring us back to front to dismiss the custom tab
+ val dismissIntent = Intent(applicationContext, EndurainPreferencesActivity::class.java).apply {
+ flags = Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP
+ }
+ startActivity(dismissIntent)
+
+ // Handle OAuth callback intent
+ val uri = intent.data
+ if (uri != null) {
+ LOG.info("OAuth callback received: $uri")
+ handleOAuthCallback(uri)
+ } else {
+ LOG.error("No URI in OAuth callback")
+ Toast.makeText(this,
+ getString(R.string.endurain_invalid_oauth_callback), Toast.LENGTH_SHORT).show()
+ finish()
+ }
+ }
+
+ private fun handleOAuthCallback(uri: Uri) {
+ // Expected format: nodomain.freeyourgadget.gadgetbridge://endurain/oauth/callback?session_id={uuid}
+
+ val sessionId = uri.getQueryParameter("session_id")
+
+ if (sessionId != null) {
+ LOG.info("SSO successful, session_id: $sessionId")
+
+ // Broadcast the session ID so the setup bottom sheet can handle it
+ val resultIntent = Intent("nodomain.freeyourgadget.gadgetbridge.ENDURAIN_SSO_CALLBACK")
+ resultIntent.putExtra("session_id", sessionId)
+ resultIntent.putExtra("success", true)
+ sendBroadcast(resultIntent)
+
+ Toast.makeText(this,
+ getString(R.string.endurain_oauth_completing_login), Toast.LENGTH_SHORT).show()
+ } else {
+ LOG.error("SSO failed or missing session_id")
+ Toast.makeText(this, getString(R.string.endurain_sso_login_failed), Toast.LENGTH_SHORT).show()
+
+ // Broadcast failure
+ val resultIntent = Intent("nodomain.freeyourgadget.gadgetbridge.ENDURAIN_SSO_CALLBACK")
+ resultIntent.putExtra("success", false)
+ sendBroadcast(resultIntent)
+ }
+
+ finish()
+ }
+}
\ No newline at end of file
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 25ca7ed7a9..ae750d35ba 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
@@ -109,11 +109,11 @@ class EndurainPreferencesActivity : AbstractSettingsActivityV2() {
vm.logout { success ->
activity?.runOnUiThread {
if (success) {
- GB.toast("Logged out successfully", Toast.LENGTH_SHORT, GB.INFO)
+ GB.toast(getString(R.string.endurain_logged_out_successfully), Toast.LENGTH_SHORT, GB.INFO)
updateStatus()
updateLogoutPreferenceVisibility()
} else {
- GB.toast("Logout failed", Toast.LENGTH_SHORT, GB.WARN)
+ GB.toast(getString(R.string.endurain_logout_failed), Toast.LENGTH_SHORT, GB.WARN)
}
}
}
@@ -129,12 +129,13 @@ class EndurainPreferencesActivity : AbstractSettingsActivityV2() {
val server = GBApplication.getPrefs().preferences.getString("endurain_server", null)
val tokenExpiresAt = DateTimeUtils.parseTimeStamp(vm.tokenManager.getRefreshTokenExpiresAt())
- var summaryText = "Not logged in, integration is disabled"
+ var summaryText = getString(R.string.endurain_not_logged_in_integration_disabled)
if (vm.tokenManager.isLoggedIn() && server != null) {
- summaryText = "Logged in to $server\nRefresh token expires: $tokenExpiresAt"
+ summaryText =
+ getString(R.string.endurain_logged_in_refresh_token, server, tokenExpiresAt)
}
if (vm.serverVersion != null) {
- summaryText += "\nServer version: ${vm.serverVersion}"
+ summaryText += getString(R.string.endurain_server_version, vm.serverVersion)
}
statusPref?.summary = summaryText
}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/endurain/EndurainSetupBottomSheet.kt b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/endurain/EndurainSetupBottomSheet.kt
index 022c1b6d9d..a138d65da8 100644
--- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/endurain/EndurainSetupBottomSheet.kt
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/endurain/EndurainSetupBottomSheet.kt
@@ -16,11 +16,17 @@
along with this program. If not, see . */
package nodomain.freeyourgadget.gadgetbridge.activities.endurain
+import android.content.BroadcastReceiver
+import android.content.Context
+import android.content.Intent
+import android.content.IntentFilter
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.Toast
+import androidx.browser.customtabs.CustomTabsIntent
+import androidx.core.content.ContextCompat
import androidx.core.content.edit
import androidx.core.net.toUri
import androidx.fragment.app.viewModels
@@ -32,9 +38,11 @@ import com.google.android.material.textfield.TextInputLayout
import nodomain.freeyourgadget.gadgetbridge.GBApplication
import nodomain.freeyourgadget.gadgetbridge.R
import nodomain.freeyourgadget.gadgetbridge.util.GB
+import org.slf4j.LoggerFactory
class EndurainSetupBottomSheet : BottomSheetDialogFragment() {
+ private val LOG = LoggerFactory.getLogger(EndurainSetupBottomSheet::class.java)
private val prefs get() = GBApplication.getPrefs().preferences
private val vm: EndurainSetupViewModel by viewModels()
@@ -52,6 +60,24 @@ class EndurainSetupBottomSheet : BottomSheetDialogFragment() {
private lateinit var progress: View
private lateinit var next: MaterialButton
+ // Broadcast receiver for SSO callback
+ private val ssoCallbackReceiver = object : BroadcastReceiver() {
+ override fun onReceive(context: Context?, intent: Intent?) {
+ val sessionId = intent?.getStringExtra("session_id")
+ val success = intent?.getBooleanExtra("success", true) ?: true
+
+ if (sessionId != null && success) {
+ handleSsoCallback(sessionId)
+ } else {
+ activity?.runOnUiThread {
+ showProgress(false)
+ Toast.makeText(requireContext(),
+ getString(R.string.endurain_sso_login_failed), Toast.LENGTH_SHORT).show()
+ }
+ }
+ }
+ }
+
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
@@ -63,26 +89,31 @@ class EndurainSetupBottomSheet : BottomSheetDialogFragment() {
)
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+ // Initialize views
serverLayout = view.findViewById(R.id.server_layout)
serverInput = view.findViewById(R.id.server_input)
-
loginTypeGroup = view.findViewById(R.id.login_type_group)
localButton = view.findViewById(R.id.local_login_button)
ssoButton = view.findViewById(R.id.sso_login_button)
-
userLayout = view.findViewById(R.id.user_layout)
passLayout = view.findViewById(R.id.password_layout)
userInput = view.findViewById(R.id.user_input)
passInput = view.findViewById(R.id.password_input)
-
mfaLayout = view.findViewById(R.id.mfa_layout)
mfaInput = view.findViewById(R.id.mfa_input)
-
progress = view.findViewById(R.id.progress)
next = view.findViewById(R.id.next_button)
serverInput.setText(prefs.getString("endurain_server", ""))
+ // Register broadcast receiver for SSO callbacks
+ ContextCompat.registerReceiver(
+ requireContext(),
+ ssoCallbackReceiver,
+ IntentFilter("nodomain.freeyourgadget.gadgetbridge.ENDURAIN_SSO_CALLBACK"),
+ ContextCompat.RECEIVER_EXPORTED
+ )
+
next.setOnClickListener {
when (vm.step) {
EndurainSetupViewModel.Step.SERVER -> handleServerStep()
@@ -102,10 +133,95 @@ class EndurainSetupBottomSheet : BottomSheetDialogFragment() {
mfaLayout.visibility = View.GONE
}
R.id.sso_login_button -> {
- vm.step = EndurainSetupViewModel.Step.SSO_LOGIN
- // TODO: Launch SSO flow
- GB.toast("SSO not yet implemented", Toast.LENGTH_SHORT, GB.INFO)
+ vm.step = EndurainSetupViewModel.Step.SSO_PROVIDERS
+ startSsoFlow()
+ }
+ }
+ }
+ }
+
+ override fun onDestroyView() {
+ super.onDestroyView()
+ try {
+ requireContext().unregisterReceiver(ssoCallbackReceiver)
+ } catch (e: Exception) {
+ // Already unregistered
+ }
+ }
+
+ private fun startSsoFlow() {
+ showProgress(true)
+ vm.fetchSsoProviders { success ->
+ activity?.runOnUiThread {
+ showProgress(false)
+ if (success && vm.availableProviders.size == 1) {
+ launchSsoLogin(vm.availableProviders[0])
+ } else if (success && vm.availableProviders.isNotEmpty()) {
+ showProviderSelection()
+ } else {
+ Toast.makeText(
+ requireContext(),
+ getString(R.string.endurain_no_sso_providers_available),
+ Toast.LENGTH_SHORT
+ ).show()
+ }
+ }
+ }
+ }
+
+ private fun showProviderSelection() {
+ EndurainSsoProviderDialog(requireContext(), vm.availableProviders) { provider ->
+ launchSsoLogin(provider)
+ }.show()
+ }
+
+ private fun launchSsoLogin(provider: IdentityProvider) {
+ val ssoUrl = vm.generateSsoUrl(provider.slug)
+
+ LOG.info("Launching secure browser for SSO URL: $ssoUrl")
+
+ // Launch Custom Tab (secure browser)
+ val customTabsIntent = CustomTabsIntent.Builder()
+ .setShowTitle(true)
+ .setUrlBarHidingEnabled(false)
+ .build()
+
+ showProgress(true)
+
+ try {
+ customTabsIntent.launchUrl(requireContext(), ssoUrl.toUri())
+ } catch (e: Exception) {
+ showProgress(false)
+ Toast.makeText(
+ requireContext(),
+ getString(R.string.endurain_failed_to_open_browser, e.message),
+ Toast.LENGTH_SHORT
+ ).show()
+ }
+ }
+
+ private fun handleSsoCallback(sessionId: String) {
+ showProgress(true)
+ vm.exchangeSsoSession(sessionId) { success ->
+ activity?.runOnUiThread {
+ showProgress(false)
+ if (success) {
+ Toast.makeText(
+ requireContext(),
+ getString(R.string.endurain_sso_login_successful),
+ Toast.LENGTH_SHORT
+ ).show()
+ parentFragmentManager.setFragmentResult(
+ "endurain_login_result",
+ Bundle().apply { putBoolean("success", true) }
+ )
dismiss()
+ } else {
+ Toast.makeText(
+ requireContext(),
+ getString(R.string.endurain_sso_login_failed),
+ Toast.LENGTH_SHORT
+ ).show()
}
}
}
@@ -114,7 +230,7 @@ class EndurainSetupBottomSheet : BottomSheetDialogFragment() {
private fun handleServerStep() {
val uri = serverInput.text.toString().toUri()
if (uri.scheme == null || uri.host == null) {
- serverLayout.error = "Invalid server URL"
+ serverLayout.error = getString(R.string.endurain_invalid_server_url)
return
}
serverLayout.error = null
@@ -127,7 +243,7 @@ class EndurainSetupBottomSheet : BottomSheetDialogFragment() {
activity?.runOnUiThread {
showProgress(false)
if (!ok) {
- GB.toast("Failed to connect to server", Toast.LENGTH_SHORT, GB.INFO)
+ GB.toast(getString(R.string.endurain_failed_to_connect_to_server), Toast.LENGTH_SHORT, GB.INFO)
return@runOnUiThread
}
vm.step = EndurainSetupViewModel.Step.LOGIN_TYPE
@@ -151,14 +267,14 @@ class EndurainSetupBottomSheet : BottomSheetDialogFragment() {
var hasError = false
if (user.isBlank()) {
- userLayout.error = "Required"
+ userLayout.error = getString(R.string.required)
hasError = true
} else {
userLayout.error = null
}
if (pass.isBlank()) {
- passLayout.error = "Required"
+ passLayout.error = getString(R.string.required)
hasError = true
} else {
passLayout.error = null
@@ -176,11 +292,11 @@ class EndurainSetupBottomSheet : BottomSheetDialogFragment() {
userLayout.visibility = View.GONE
passLayout.visibility = View.GONE
mfaLayout.visibility = View.VISIBLE
- next.text = "Verify MFA"
- GB.toast("Enter your MFA code", Toast.LENGTH_SHORT, GB.INFO)
+ next.text = getString(R.string.endurain_verify_mfa)
+ GB.toast(getString(R.string.endurain_enter_your_mfa_code), Toast.LENGTH_SHORT, GB.INFO)
}
success -> {
- GB.toast("Login successful", Toast.LENGTH_SHORT, GB.INFO)
+ GB.toast(getString(R.string.endurain_login_successful), Toast.LENGTH_SHORT, GB.INFO)
// Send result to parent fragment
parentFragmentManager.setFragmentResult(
"endurain_login_result",
@@ -189,7 +305,7 @@ class EndurainSetupBottomSheet : BottomSheetDialogFragment() {
dismiss()
}
else -> {
- GB.toast("Login failed", Toast.LENGTH_SHORT, GB.INFO)
+ GB.toast(getString(R.string.endurain_login_failed), Toast.LENGTH_SHORT, GB.INFO)
}
}
}
@@ -199,7 +315,7 @@ class EndurainSetupBottomSheet : BottomSheetDialogFragment() {
private fun handleMfaStep() {
val mfaCode = mfaInput.text.toString()
if (mfaCode.isBlank()) {
- mfaLayout.error = "Required"
+ mfaLayout.error = getString(R.string.required)
return
}
mfaLayout.error = null
@@ -209,7 +325,7 @@ class EndurainSetupBottomSheet : BottomSheetDialogFragment() {
activity?.runOnUiThread {
showProgress(false)
if (success) {
- GB.toast("MFA verification successful", Toast.LENGTH_SHORT, GB.INFO)
+ GB.toast(getString(R.string.endurain_mfa_verification_successful), Toast.LENGTH_SHORT, GB.INFO)
// Send result to parent fragment
parentFragmentManager.setFragmentResult(
"endurain_login_result",
@@ -217,8 +333,8 @@ class EndurainSetupBottomSheet : BottomSheetDialogFragment() {
)
dismiss()
} else {
- mfaLayout.error = "Invalid MFA code"
- GB.toast("MFA verification failed", Toast.LENGTH_SHORT, GB.INFO)
+ mfaLayout.error = getString(R.string.endurain_invalid_mfa_code)
+ GB.toast(getString(R.string.endurain_mfa_verification_failed), Toast.LENGTH_SHORT, GB.INFO)
}
}
}
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 f8b119c3e9..a068acb4f9 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
@@ -17,8 +17,12 @@
package nodomain.freeyourgadget.gadgetbridge.activities.endurain
import android.app.Application
+import android.content.Context
+import androidx.core.content.edit
import androidx.core.net.toUri
import androidx.lifecycle.AndroidViewModel
+import nodomain.freeyourgadget.gadgetbridge.BuildConfig
+import nodomain.freeyourgadget.gadgetbridge.GBApplication
import nodomain.freeyourgadget.gadgetbridge.util.InternetUtils
import org.slf4j.LoggerFactory
@@ -26,13 +30,14 @@ class EndurainSetupViewModel(application: Application) : AndroidViewModel(applic
private val LOG = LoggerFactory.getLogger(EndurainSetupViewModel::class.java)
private lateinit var apiClient: EndurainApiClient
+ private val pkceHelper = PkceHelper()
enum class Step {
SERVER,
LOGIN_TYPE,
LOCAL_LOGIN,
MFA_VERIFY,
- SSO_LOGIN
+ SSO_PROVIDERS
}
val tokenManager = EndurainTokenManager(application)
@@ -41,6 +46,7 @@ class EndurainSetupViewModel(application: Application) : AndroidViewModel(applic
var localLoginEnabled = false
var ssoEnabled = false
var pendingMfaUsername: String? = null
+ var availableProviders: List = emptyList()
var serverVersion: String? = null
/**
@@ -103,6 +109,101 @@ class EndurainSetupViewModel(application: Application) : AndroidViewModel(applic
}.start()
}
+ /**
+ * Fetch available SSO providers
+ */
+ fun fetchSsoProviders(callback: (Boolean) -> Unit) {
+ Thread {
+ try {
+ apiClient = EndurainApiClient(server, tokenManager)
+ val providers = apiClient.getIdentityProviders()
+
+ if (providers != null && providers.isNotEmpty()) {
+ availableProviders = providers
+ callback(true)
+ } else {
+ LOG.error("No SSO providers available")
+ callback(false)
+ }
+ } catch (e: Exception) {
+ LOG.error("Error fetching SSO providers", e)
+ callback(false)
+ }
+ }.start()
+ }
+
+ /**
+ * Generate SSO URL with PKCE for Custom Tabs
+ * Returns the URL and also stores the verifier for later
+ */
+ fun generateSsoUrl(idpSlug: String): String {
+ val codeVerifier = pkceHelper.generateCodeVerifier()
+ val codeChallenge = pkceHelper.generateCodeChallenge(codeVerifier)
+
+ LOG.debug("Initiating PKCE with verifier=$codeVerifier and challenge=$codeChallenge")
+
+ // Store verifier in shared preferences for the callback activity
+ getApplication().getSharedPreferences("endurain_oauth_temp", Context.MODE_PRIVATE)
+ .edit {
+ putString("code_verifier", codeVerifier)
+ }
+
+ // Build OAuth URL with custom redirect URI for deep linking
+ return "$server/api/v1/public/idp/login/$idpSlug".toUri()
+ .buildUpon()
+ .appendQueryParameter("code_challenge", codeChallenge)
+ .appendQueryParameter("code_challenge_method", "S256")
+ .appendQueryParameter("redirect", BuildConfig.APPLICATION_ID + "://endurain/oauth/callback")
+ .build()
+ .toString()
+ }
+
+ /**
+ * Exchange OAuth session for tokens
+ */
+ fun exchangeSsoSession(sessionId: String, callback: (Boolean) -> Unit) {
+ Thread {
+ try {
+ // Retrieve the stored code verifier
+ val prefs = getApplication()
+ .getSharedPreferences("endurain_oauth_temp", Context.MODE_PRIVATE)
+
+ val verifier = prefs.getString("code_verifier", null)
+ if (verifier == null) {
+ LOG.error("No pending code verifier")
+ callback(false)
+ return@Thread
+ }
+
+ // Clear the verifier
+ prefs.edit { remove("code_verifier") }
+
+ if (!::apiClient.isInitialized) {
+ apiClient = EndurainApiClient(server, tokenManager)
+ }
+
+ val response = apiClient.exchangeOAuthSession(sessionId, verifier)
+
+ if (response?.access_token != null) {
+ LOG.info("SSO login successful")
+ tokenManager.saveTokens(
+ response.access_token,
+ response.refresh_token!!,
+ response.expires_in!!,
+ response.refresh_token_expires_in!!
+ )
+ callback(true)
+ } else {
+ LOG.error("SSO login failed")
+ callback(false)
+ }
+ } catch (e: Exception) {
+ LOG.error("SSO session exchange error", e)
+ callback(false)
+ }
+ }.start()
+ }
+
/**
* Perform local username/password login
*/
@@ -133,7 +234,8 @@ class EndurainSetupViewModel(application: Application) : AndroidViewModel(applic
tokenManager.saveTokens(
response.access_token,
response.refresh_token!!,
- response.expires_in!!
+ response.expires_in!!,
+ response.refresh_token_expires_in!!
)
callback(true)
}
@@ -164,12 +266,15 @@ class EndurainSetupViewModel(application: Application) : AndroidViewModel(applic
val response = apiClient.verifyMfa(username, mfaCode)
+ LOG.debug("MFA verification response: {}", response)
+
if (response?.access_token != null) {
LOG.info("MFA verification successful")
tokenManager.saveTokens(
response.access_token,
response.refresh_token!!,
- response.expires_in!!
+ response.expires_in!!,
+ response.refresh_token_expires_in!!
)
pendingMfaUsername = null
callback(true)
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/endurain/EndurainSsoProviderDialog.kt b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/endurain/EndurainSsoProviderDialog.kt
new file mode 100644
index 0000000000..2d81477e07
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/endurain/EndurainSsoProviderDialog.kt
@@ -0,0 +1,41 @@
+/* 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 . */
+package nodomain.freeyourgadget.gadgetbridge.activities.endurain
+
+import android.app.AlertDialog
+import android.content.Context
+import nodomain.freeyourgadget.gadgetbridge.R
+
+class EndurainSsoProviderDialog(
+ private val context: Context,
+ private val providers: List,
+ private val onProviderSelected: (IdentityProvider) -> Unit
+) {
+
+ fun show() {
+ val providerNames = providers.map { it.name }.toTypedArray()
+
+ AlertDialog.Builder(context)
+ .setTitle(context.getString(R.string.endurain_select_identity_provider))
+ .setItems(providerNames) { dialog, which ->
+ onProviderSelected(providers[which])
+ dialog.dismiss()
+ }
+ .setNegativeButton(context.getString(R.string.cancel), null)
+ .show()
+ }
+}
\ No newline at end of file
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 308500d179..70e404b01f 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
@@ -37,15 +37,15 @@ class EndurainTokenManager(context: Context) {
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
)
- fun saveTokens(accessToken: String, refreshToken: String, accessTokenExpiresAt: Int) {
+ fun saveTokens(accessToken: String, refreshToken: String, accessTokenExpiry: Int, refreshTokenExpiry: Int) {
+ val accessTokenExpiryTs = if (accessTokenExpiry < 1000000000) ((System.currentTimeMillis() / 1000) + accessTokenExpiry).toInt() else accessTokenExpiry
+ val refreshTokenExpiryTs = if (refreshTokenExpiry < 1000000000) ((System.currentTimeMillis() / 1000) + refreshTokenExpiry).toInt() else refreshTokenExpiry
sharedPreferences.edit {
putString("access_token", accessToken)
.putString("refresh_token", refreshToken)
- .putInt("access_token_expires_at", accessTokenExpiresAt)
- .putInt("refresh_token_expires_at",
- ((System.currentTimeMillis() / 1000) + (7 * 24 * 60 * 60)).toInt()
- ) // FIXME: 7 days is the Endurain default for refresh token expiry
- // https://github.com/endurain-project/endurain/issues/514
+ .putInt("access_token_expires_at", accessTokenExpiryTs)
+ .putInt("refresh_token_expires_at", refreshTokenExpiryTs
+ )
}
}
@@ -87,7 +87,8 @@ class EndurainTokenManager(context: Context) {
saveTokens(
response.access_token,
response.refresh_token!!,
- response.expires_in!!
+ response.expires_in!!,
+ response.refresh_token_expires_in!!
)
callback(true)
}
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 589f908d6b..81fa53dd5c 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -4938,4 +4938,26 @@
Audio card
AUX
Prompt tone
+ Required
+ SSO login failed
+ No SSO providers available
+ Failed to open browser: %1$s
+ SSO login successful
+ Invalid server URL
+ Failed to connect to server
+ Verify MFA
+ Enter your MFA code
+ Login successful
+ Login failed
+ MFA verification successful
+ Invalid MFA code
+ MFA verification failed
+ Select Identity Provider
+ Invalid OAuth callback
+ Completing login…
+ Not logged in, integration is disabled
+ Logged in to %1$s\nRefresh token expires: %2$s
+ \nServer version: %1$s
+ Logout failed
+ Logged out successfully
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index b43223714d..7875e3a4f9 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -51,6 +51,7 @@ searchpreference = "2.7.3"
slf4j = "2.0.18"
solarpositioning = "0.1.10"
security-crypto = "1.1.0"
+browser = "1.10.0"
[libraries]
ahocorasick = { group = "org.ahocorasick", name = "ahocorasick", version.ref = "ahocorasick" }
@@ -114,6 +115,7 @@ searchpreference = { group = "com.github.ByteHamster", name = "SearchPreference"
slf4j-api = { group = "org.slf4j", name = "slf4j-api", version.ref = "slf4j" }
solarpositioning = { group = "net.e175.klaus", name = "solarpositioning", version.ref = "solarpositioning" }
androidx-security-crypto = { group = "androidx.security", name = "security-crypto", version.ref = "security-crypto" }
+androidx-browser = { group = "androidx.browser", name = "browser", version.ref = "browser" }
[bundles]
android-emojify = [