diff --git a/app/build.gradle b/app/build.gradle
index 16c45fc0a0..e810198cd8 100644
--- a/app/build.gradle
+++ b/app/build.gradle
@@ -261,6 +261,7 @@ dependencies {
implementation libs.androidx.viewpager2
implementation libs.androidx.work.runtime.ktx
implementation libs.androidx.lifecycle.process
+ implementation libs.androidx.security.crypto
implementation libs.material
implementation libs.flexbox
@@ -290,9 +291,6 @@ dependencies {
implementation libs.androidsvg
implementation libs.jsoup
- // AppAuth is used for OAuth/PKCE/OIDC functionality, like required by the Endurain integration
- implementation 'net.openid:appauth:0.11.1'
-
// Bouncy Castle is included directly in GB, to avoid pulling the entire dependency
// It's included in the org.bouncycastle.shaded package, to fix conflicts with roboelectric
//implementation 'org.bouncycastle:bcpkix-jdk18on:1.76'
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
new file mode 100644
index 0000000000..79428856d9
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/endurain/EndurainApiClient.kt
@@ -0,0 +1,259 @@
+/* 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.net.Uri
+import androidx.core.net.toUri
+import com.google.gson.Gson
+import nodomain.freeyourgadget.gadgetbridge.util.InternetUtils
+import org.slf4j.LoggerFactory
+
+data class LoginResponse(
+ val session_id: String? = null,
+ val access_token: String? = null,
+ val refresh_token: String? = null,
+ val expires_in: Long? = null,
+ val token_type: String? = null,
+ val mfa_required: Boolean? = null,
+ val username: String? = null,
+ val detail: String? = null
+)
+
+data class MfaVerifyRequest(
+ val username: String,
+ val mfa_code: String
+)
+
+data class TokenRefreshRequest(
+ val refresh_token: String
+)
+
+data class TokenExchangeRequest(
+ val code_verifier: String
+)
+
+class EndurainApiClient(
+ private val baseUrl: String,
+ private val tokenManager: EndurainTokenManager
+) {
+ private val gson = Gson()
+ private val LOG = LoggerFactory.getLogger(EndurainApiClient::class.java)
+
+ /**
+ * Build headers with authentication tokens
+ */
+ private fun buildHeaders(): Map {
+ val headers = mutableMapOf("X-Client-Type" to "mobile")
+
+ tokenManager.getAccessToken()?.let { token ->
+ headers["Authorization"] = "Bearer $token"
+ }
+
+ return headers
+ }
+
+ /**
+ * Username/Password Login
+ */
+ fun login(username: String, password: String): LoginResponse? {
+ try {
+ val uri = "$baseUrl/api/v1/auth/login".toUri()
+
+ // Form-encoded body
+ val body = "username=${Uri.encode(username)}&password=${Uri.encode(password)}"
+
+ val headers = mapOf("X-Client-Type" to "mobile")
+
+ val responseText = InternetUtils.doStringRequest(
+ uri = uri,
+ method = "POST",
+ requestHeaders = headers,
+ body = body,
+ bodyContentType = "application/x-www-form-urlencoded",
+ allowInsecure = false
+ )
+
+ return if (responseText != null) {
+ gson.fromJson(responseText, LoginResponse::class.java)
+ } else {
+ LOG.error("Login failed: empty response")
+ null
+ }
+ } catch (e: Exception) {
+ LOG.error("Login error", e)
+ return null
+ }
+ }
+
+ /**
+ * MFA Verification
+ */
+ fun verifyMfa(username: String, mfaCode: String): LoginResponse? {
+ try {
+ val uri = "$baseUrl/api/v1/auth/mfa/verify".toUri()
+
+ val request = MfaVerifyRequest(username, mfaCode)
+ val body = gson.toJson(request)
+
+ val headers = mapOf("X-Client-Type" to "mobile")
+
+ val responseText = InternetUtils.doStringRequest(
+ uri = uri,
+ method = "POST",
+ requestHeaders = headers,
+ body = body,
+ bodyContentType = "application/json",
+ allowInsecure = false
+ )
+
+ return if (responseText != null) {
+ gson.fromJson(responseText, LoginResponse::class.java)
+ } else {
+ LOG.error("MFA verification failed: empty response")
+ null
+ }
+ } catch (e: Exception) {
+ LOG.error("MFA verification error", e)
+ return null
+ }
+ }
+
+ /**
+ * Token Refresh
+ */
+ 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 responseText = InternetUtils.doStringRequest(
+ uri = uri,
+ method = "POST",
+ requestHeaders = headers,
+ body = body,
+ bodyContentType = "application/json",
+ allowInsecure = false
+ )
+
+ return if (responseText != null) {
+ gson.fromJson(responseText, LoginResponse::class.java)
+ } else {
+ LOG.error("Token refresh failed: empty response")
+ null
+ }
+ } catch (e: Exception) {
+ LOG.error("Token refresh error", e)
+ return null
+ }
+ }
+
+ /**
+ * Logout
+ */
+ fun logout(): Boolean {
+ try {
+ val uri = "$baseUrl/api/v1/auth/logout".toUri()
+
+ val headers = buildHeaders()
+
+ InternetUtils.doStringRequest(
+ uri = uri,
+ method = "POST",
+ requestHeaders = headers,
+ body = null,
+ bodyContentType = "application/json",
+ allowInsecure = false
+ )
+
+ tokenManager.clearTokens()
+ return true
+ } catch (e: Exception) {
+ LOG.error("Logout error", e)
+ return false
+ }
+ }
+
+ /**
+ * Exchange OAuth session for tokens (PKCE flow)
+ */
+ fun exchangeOAuthSession(sessionId: String, codeVerifier: String): LoginResponse? {
+ try {
+ val uri = "$baseUrl/api/v1/public/idp/session/$sessionId/tokens".toUri()
+
+ val request = TokenExchangeRequest(codeVerifier)
+ val body = gson.toJson(request)
+
+ val headers = mapOf("X-Client-Type" to "mobile")
+
+ val responseText = InternetUtils.doStringRequest(
+ uri = uri,
+ method = "POST",
+ requestHeaders = headers,
+ body = body,
+ bodyContentType = "application/json",
+ allowInsecure = false
+ )
+
+ return if (responseText != null) {
+ gson.fromJson(responseText, LoginResponse::class.java)
+ } else {
+ LOG.error("OAuth token exchange failed: empty response")
+ null
+ }
+ } catch (e: Exception) {
+ LOG.error("OAuth token exchange error", e)
+ return null
+ }
+ }
+
+ /**
+ * Generic authenticated API request
+ */
+ fun doAuthenticatedRequest(
+ endpoint: String,
+ method: String = "GET",
+ body: String? = null
+ ): String? {
+ try {
+ val uri = "$baseUrl$endpoint".toUri()
+
+ val headers = buildHeaders()
+
+ return InternetUtils.doStringRequest(
+ uri = uri,
+ method = method,
+ requestHeaders = headers,
+ body = body,
+ bodyContentType = "application/json",
+ allowInsecure = false
+ )
+ } catch (e: Exception) {
+ LOG.error("Authenticated request error", e)
+ return null
+ }
+ }
+}
\ 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 2162142fdc..b767fcfe81 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
@@ -17,6 +17,8 @@
package nodomain.freeyourgadget.gadgetbridge.activities.endurain
import android.os.Bundle
+import android.widget.Toast
+import androidx.fragment.app.viewModels
import androidx.preference.Preference
import androidx.preference.PreferenceFragmentCompat
import nodomain.freeyourgadget.gadgetbridge.GBApplication
@@ -31,13 +33,35 @@ class EndurainPreferencesActivity : AbstractSettingsActivityV2() {
class EndurainPreferencesFragment : AbstractPreferenceFragment() {
+ private val vm: EndurainSetupViewModel by viewModels()
+
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
setPreferencesFromResource(R.xml.endurain_preferences, rootKey)
updateNetworkWarning()
wireLoginPreference()
- hideLogoffPreference()
+ wireLogoutPreference()
updateStatus()
+ setupLoginResultListener()
+ }
+
+ private fun setupLoginResultListener() {
+ parentFragmentManager.setFragmentResultListener(
+ "endurain_login_result",
+ this
+ ) { _, bundle ->
+ val success = bundle.getBoolean("success", false)
+ if (success) {
+ updateStatus()
+ updateLogoutPreferenceVisibility()
+ }
+ }
+ }
+
+ override fun onResume() {
+ super.onResume()
+ updateStatus()
+ updateLogoutPreferenceVisibility()
}
private fun updateNetworkWarning() {
@@ -53,13 +77,41 @@ class EndurainPreferencesActivity : AbstractSettingsActivityV2() {
}
}
- private fun hideLogoffPreference() {
- findPreference("pref_key_log_out")?.isVisible = false
+ private fun wireLogoutPreference() {
+ findPreference("pref_key_log_out")?.setOnPreferenceClickListener {
+ performLogout()
+ true
+ }
+ }
+
+ private fun performLogout() {
+ vm.logout { success ->
+ activity?.runOnUiThread {
+ if (success) {
+ Toast.makeText(requireContext(), "Logged out successfully", Toast.LENGTH_SHORT).show()
+ updateStatus()
+ updateLogoutPreferenceVisibility()
+ } else {
+ Toast.makeText(requireContext(), "Logout failed", Toast.LENGTH_SHORT).show()
+ }
+ }
+ }
+ }
+
+ private fun updateLogoutPreferenceVisibility() {
+ findPreference("pref_key_log_out")?.isVisible = vm.isLoggedIn()
+ findPreference("pref_key_log_in")?.isVisible = !vm.isLoggedIn()
}
private fun updateStatus() {
- findPreference("pref_key_status")?.summary =
- "Not logged in, integration is disabled"
+ val statusPref = findPreference("pref_key_status")
+ val server = GBApplication.getPrefs().preferences.getString("endurain_server", null)
+
+ if (vm.isLoggedIn() && server != null) {
+ statusPref?.summary = "Logged in to $server"
+ } else {
+ statusPref?.summary = "Not logged in, integration is disabled"
+ }
}
}
}
\ No newline at end of file
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 9c66d34708..022c1b6d9d 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
@@ -20,6 +20,7 @@ import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
+import android.widget.Toast
import androidx.core.content.edit
import androidx.core.net.toUri
import androidx.fragment.app.viewModels
@@ -30,12 +31,27 @@ import com.google.android.material.textfield.TextInputEditText
import com.google.android.material.textfield.TextInputLayout
import nodomain.freeyourgadget.gadgetbridge.GBApplication
import nodomain.freeyourgadget.gadgetbridge.R
+import nodomain.freeyourgadget.gadgetbridge.util.GB
class EndurainSetupBottomSheet : BottomSheetDialogFragment() {
private val prefs get() = GBApplication.getPrefs().preferences
private val vm: EndurainSetupViewModel by viewModels()
+ private lateinit var serverLayout: TextInputLayout
+ private lateinit var serverInput: TextInputEditText
+ private lateinit var loginTypeGroup: MaterialButtonToggleGroup
+ private lateinit var localButton: MaterialButton
+ private lateinit var ssoButton: MaterialButton
+ private lateinit var userLayout: TextInputLayout
+ private lateinit var passLayout: TextInputLayout
+ private lateinit var userInput: TextInputEditText
+ private lateinit var passInput: TextInputEditText
+ private lateinit var mfaLayout: TextInputLayout
+ private lateinit var mfaInput: TextInputEditText
+ private lateinit var progress: View
+ private lateinit var next: MaterialButton
+
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
@@ -47,82 +63,31 @@ class EndurainSetupBottomSheet : BottomSheetDialogFragment() {
)
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
- val serverLayout = view.findViewById(R.id.server_layout)
- val serverInput = view.findViewById(R.id.server_input)
+ serverLayout = view.findViewById(R.id.server_layout)
+ serverInput = view.findViewById(R.id.server_input)
- val loginTypeGroup =
- view.findViewById(R.id.login_type_group)
- val localButton = view.findViewById(R.id.local_login_button)
- val ssoButton = view.findViewById(R.id.sso_login_button)
+ loginTypeGroup = view.findViewById(R.id.login_type_group)
+ localButton = view.findViewById(R.id.local_login_button)
+ ssoButton = view.findViewById(R.id.sso_login_button)
- val userLayout = view.findViewById(R.id.user_layout)
- val passLayout = view.findViewById(R.id.password_layout)
- val userInput = view.findViewById(R.id.user_input)
- val passInput = view.findViewById(R.id.password_input)
+ 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)
- val progress = view.findViewById(R.id.progress)
- val next = view.findViewById(R.id.next_button)
+ 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", ""))
- fun showProgress(show: Boolean) {
- progress.visibility = if (show) View.VISIBLE else View.GONE
- next.isEnabled = !show
- }
-
next.setOnClickListener {
when (vm.step) {
- EndurainSetupViewModel.Step.SERVER -> {
- val uri = serverInput.text.toString().toUri()
- if (uri.scheme == null || uri.host == null) {
- serverLayout.error = "Invalid server URL"
- return@setOnClickListener
- }
- serverLayout.error = null
- val server = "${uri.scheme}://${uri.host}"
- vm.server = server
- prefs.edit { putString("endurain_server", server) }
-
- showProgress(true)
- vm.fetchServerCapabilities(server) { ok ->
- showProgress(false)
- if (!ok) return@fetchServerCapabilities
- vm.step = EndurainSetupViewModel.Step.LOGIN_TYPE
- loginTypeGroup.visibility = View.VISIBLE
- localButton.visibility =
- if (vm.localLoginEnabled) View.VISIBLE else View.GONE
- ssoButton.visibility =
- if (vm.ssoEnabled) View.VISIBLE else View.GONE
- }
- }
-
- EndurainSetupViewModel.Step.LOCAL_LOGIN -> {
- val user = userInput.text.toString()
- val pass = passInput.text.toString()
- if (user.isBlank()) {
- userLayout.error = "Required"
- return@setOnClickListener
- }
- if (pass.isBlank()) {
- passLayout.error = "Required"
- return@setOnClickListener
- }
- userLayout.error = null
- passLayout.error = null
-
- showProgress(true)
- vm.performLocalLogin(vm.server, user, pass) { success ->
- showProgress(false)
- if (success) {
- prefs.edit {
- putString("endurain_user", user)
- putString("endurain_password", pass)
- }
- dismiss()
- }
- }
- }
-
+ EndurainSetupViewModel.Step.SERVER -> handleServerStep()
+ EndurainSetupViewModel.Step.LOCAL_LOGIN -> handleLocalLoginStep()
+ EndurainSetupViewModel.Step.MFA_VERIFY -> handleMfaStep()
else -> {}
}
}
@@ -134,13 +99,133 @@ class EndurainSetupBottomSheet : BottomSheetDialogFragment() {
vm.step = EndurainSetupViewModel.Step.LOCAL_LOGIN
userLayout.visibility = View.VISIBLE
passLayout.visibility = View.VISIBLE
+ mfaLayout.visibility = View.GONE
}
R.id.sso_login_button -> {
vm.step = EndurainSetupViewModel.Step.SSO_LOGIN
- // TODO launch SSO flow
+ // TODO: Launch SSO flow
+ GB.toast("SSO not yet implemented", Toast.LENGTH_SHORT, GB.INFO)
dismiss()
}
}
}
}
-}
+
+ private fun handleServerStep() {
+ val uri = serverInput.text.toString().toUri()
+ if (uri.scheme == null || uri.host == null) {
+ serverLayout.error = "Invalid server URL"
+ return
+ }
+ serverLayout.error = null
+ val server = "${uri.scheme}://${uri.host}${if (uri.port > 0) ":${uri.port}" else ""}"
+ vm.server = server
+ prefs.edit { putString("endurain_server", server) }
+
+ showProgress(true)
+ vm.fetchServerCapabilities(server) { ok ->
+ activity?.runOnUiThread {
+ showProgress(false)
+ if (!ok) {
+ GB.toast("Failed to connect to server", Toast.LENGTH_SHORT, GB.INFO)
+ return@runOnUiThread
+ }
+ vm.step = EndurainSetupViewModel.Step.LOGIN_TYPE
+ loginTypeGroup.visibility = View.VISIBLE
+ localButton.visibility = if (vm.localLoginEnabled) View.VISIBLE else View.GONE
+ ssoButton.visibility = if (vm.ssoEnabled) View.VISIBLE else View.GONE
+
+ // Auto-select if only one option available
+ if (vm.localLoginEnabled && !vm.ssoEnabled) {
+ loginTypeGroup.check(R.id.local_login_button)
+ } else if (vm.ssoEnabled && !vm.localLoginEnabled) {
+ loginTypeGroup.check(R.id.sso_login_button)
+ }
+ }
+ }
+ }
+
+ private fun handleLocalLoginStep() {
+ val user = userInput.text.toString()
+ val pass = passInput.text.toString()
+
+ var hasError = false
+ if (user.isBlank()) {
+ userLayout.error = "Required"
+ hasError = true
+ } else {
+ userLayout.error = null
+ }
+
+ if (pass.isBlank()) {
+ passLayout.error = "Required"
+ hasError = true
+ } else {
+ passLayout.error = null
+ }
+
+ if (hasError) return
+
+ showProgress(true)
+ vm.performLocalLogin(vm.server, user, pass) { success ->
+ activity?.runOnUiThread {
+ showProgress(false)
+ when {
+ vm.step == EndurainSetupViewModel.Step.MFA_VERIFY -> {
+ // MFA required - show MFA input
+ 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)
+ }
+ success -> {
+ GB.toast("Login successful", Toast.LENGTH_SHORT, GB.INFO)
+ // Send result to parent fragment
+ parentFragmentManager.setFragmentResult(
+ "endurain_login_result",
+ Bundle().apply { putBoolean("success", true) }
+ )
+ dismiss()
+ }
+ else -> {
+ GB.toast("Login failed", Toast.LENGTH_SHORT, GB.INFO)
+ }
+ }
+ }
+ }
+ }
+
+ private fun handleMfaStep() {
+ val mfaCode = mfaInput.text.toString()
+ if (mfaCode.isBlank()) {
+ mfaLayout.error = "Required"
+ return
+ }
+ mfaLayout.error = null
+
+ showProgress(true)
+ vm.verifyMfa(mfaCode) { success ->
+ activity?.runOnUiThread {
+ showProgress(false)
+ if (success) {
+ GB.toast("MFA verification successful", Toast.LENGTH_SHORT, GB.INFO)
+ // Send result to parent fragment
+ parentFragmentManager.setFragmentResult(
+ "endurain_login_result",
+ Bundle().apply { putBoolean("success", true) }
+ )
+ dismiss()
+ } else {
+ mfaLayout.error = "Invalid MFA code"
+ GB.toast("MFA verification failed", Toast.LENGTH_SHORT, GB.INFO)
+ }
+ }
+ }
+ }
+
+ private fun showProgress(show: Boolean) {
+ progress.visibility = if (show) View.VISIBLE else View.GONE
+ next.isEnabled = !show
+ }
+}
\ No newline at end of file
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 8d986f1656..35d812141e 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
@@ -16,54 +16,180 @@
along with this program. If not, see . */
package nodomain.freeyourgadget.gadgetbridge.activities.endurain
+import android.app.Application
import androidx.core.net.toUri
-import androidx.lifecycle.ViewModel
-import androidx.lifecycle.viewModelScope
-import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.launch
-import kotlinx.coroutines.withContext
+import androidx.lifecycle.AndroidViewModel
import nodomain.freeyourgadget.gadgetbridge.util.InternetUtils
-import org.json.JSONObject
+import org.slf4j.LoggerFactory
-class EndurainSetupViewModel : ViewModel() {
+class EndurainSetupViewModel(application: Application) : AndroidViewModel(application) {
- enum class Step { SERVER, LOGIN_TYPE, LOCAL_LOGIN, SSO_LOGIN, DONE }
+ private val LOG = LoggerFactory.getLogger(EndurainSetupViewModel::class.java)
+ private val tokenManager = EndurainTokenManager(application)
+ private lateinit var apiClient: EndurainApiClient
- var step: Step = Step.SERVER
- var server: String = ""
- var localLoginEnabled: Boolean = false
- var ssoEnabled: Boolean = false
-
- fun fetchServerCapabilities(
- server: String,
- onResult: (Boolean) -> Unit
- ) {
- viewModelScope.launch {
- val ok = withContext(Dispatchers.IO) {
- val uri = "$server/api/v1/public/server_settings"
- val json: JSONObject? =
- InternetUtils.doJsonRequest(uri.toUri())
- if (json == null) return@withContext false
- localLoginEnabled = json.optBoolean("local_login_enabled")
- ssoEnabled = json.optBoolean("sso_enabled")
- true
- }
- onResult(ok)
- }
+ enum class Step {
+ SERVER,
+ LOGIN_TYPE,
+ LOCAL_LOGIN,
+ MFA_VERIFY,
+ SSO_LOGIN
}
+ var step = Step.SERVER
+ var server = ""
+ var localLoginEnabled = false
+ var ssoEnabled = false
+ var pendingMfaUsername: String? = null
+
+ /**
+ * Fetch server capabilities to determine available login methods
+ */
+ fun fetchServerCapabilities(serverUrl: String, callback: (Boolean) -> Unit) {
+ Thread {
+ 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
+ )
+
+ if (settingsResponse != null) {
+ // Parse server settings to determine available authentication methods
+ localLoginEnabled = settingsResponse.optBoolean("local_login_enabled", true)
+ ssoEnabled = settingsResponse.optBoolean("sso_enabled", false)
+
+ LOG.info("Server capabilities - Local login: $localLoginEnabled, SSO: $ssoEnabled")
+
+ // Validate that at least one auth method is available
+ if (!localLoginEnabled && !ssoEnabled) {
+ LOG.warn("Server has no authentication methods enabled, defaulting to local login")
+ localLoginEnabled = true
+ }
+
+ callback(true)
+ } else {
+ LOG.error("Failed to fetch server settings")
+ // Default to local login on failure
+ localLoginEnabled = true
+ ssoEnabled = false
+ callback(false)
+ }
+ } catch (e: Exception) {
+ LOG.error("Error fetching server capabilities", e)
+ // Default to local login on error
+ localLoginEnabled = true
+ ssoEnabled = false
+ callback(false)
+ }
+ }.start()
+ }
+
+ /**
+ * Perform local username/password login
+ */
fun performLocalLogin(
- server: String,
- user: String,
- pass: String,
- onResult: (Boolean) -> Unit
+ serverUrl: String,
+ username: String,
+ password: String,
+ callback: (Boolean) -> Unit
) {
- viewModelScope.launch {
- val success = withContext(Dispatchers.IO) {
- // TODO real API call
- user.isNotBlank() && pass.isNotBlank()
+ Thread {
+ try {
+ apiClient = EndurainApiClient(serverUrl, tokenManager)
+ val response = apiClient.login(username, password)
+
+ when {
+ response == null -> {
+ LOG.error("Login failed: null response")
+ callback(false)
+ }
+ response.mfa_required == true -> {
+ LOG.info("MFA required for user: ${response.username}")
+ pendingMfaUsername = response.username ?: username
+ step = Step.MFA_VERIFY
+ callback(true) // Return true to indicate MFA step is needed
+ }
+ response.access_token != null -> {
+ LOG.info("Login successful")
+ tokenManager.saveTokens(
+ response.access_token,
+ response.refresh_token!!
+ )
+ callback(true)
+ }
+ else -> {
+ LOG.error("Login failed: ${response.detail}")
+ callback(false)
+ }
+ }
+ } catch (e: Exception) {
+ LOG.error("Login error", e)
+ callback(false)
}
- onResult(success)
- }
+ }.start()
}
-}
+
+ /**
+ * Verify MFA code
+ */
+ fun verifyMfa(mfaCode: String, callback: (Boolean) -> Unit) {
+ Thread {
+ try {
+ val username = pendingMfaUsername
+ if (username == null) {
+ LOG.error("No pending MFA username")
+ callback(false)
+ return@Thread
+ }
+
+ val response = apiClient.verifyMfa(username, mfaCode)
+
+ if (response?.access_token != null) {
+ LOG.info("MFA verification successful")
+ tokenManager.saveTokens(
+ response.access_token,
+ response.refresh_token!!
+ )
+ pendingMfaUsername = null
+ callback(true)
+ } else {
+ LOG.error("MFA verification failed")
+ callback(false)
+ }
+ } catch (e: Exception) {
+ LOG.error("MFA verification error", e)
+ callback(false)
+ }
+ }.start()
+ }
+
+ /**
+ * Check if user is currently logged in
+ */
+ fun isLoggedIn(): Boolean {
+ return tokenManager.getAccessToken() != null && !tokenManager.isTokenExpired()
+ }
+
+ /**
+ * Logout and clear tokens
+ */
+ fun logout(callback: (Boolean) -> Unit) {
+ Thread {
+ try {
+ if (::apiClient.isInitialized) {
+ apiClient.logout()
+ } else {
+ tokenManager.clearTokens()
+ }
+ callback(true)
+ } catch (e: Exception) {
+ LOG.error("Logout error", e)
+ tokenManager.clearTokens() // Clear tokens anyway
+ callback(true)
+ }
+ }.start()
+ }
+}
\ 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
new file mode 100644
index 0000000000..97d4c54f71
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/endurain/EndurainTokenManager.kt
@@ -0,0 +1,55 @@
+/* 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.Context
+import androidx.core.content.edit
+import androidx.security.crypto.EncryptedSharedPreferences
+import androidx.security.crypto.MasterKey
+
+class EndurainTokenManager(context: Context) {
+ private val masterKey = MasterKey.Builder(context)
+ .setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
+ .build()
+
+ private val sharedPreferences = EncryptedSharedPreferences.create(
+ context,
+ "endurain_tokens",
+ masterKey,
+ EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
+ EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
+ )
+
+ fun saveTokens(accessToken: String, refreshToken: String) {
+ sharedPreferences.edit {
+ putString("access_token", accessToken)
+ .putString("refresh_token", refreshToken)
+ .putLong("expires_at", System.currentTimeMillis() + (15 * 60 * 1000))
+ }
+ }
+
+ fun getAccessToken(): String? = sharedPreferences.getString("access_token", null)
+ fun getRefreshToken(): String? = sharedPreferences.getString("refresh_token", null)
+ fun isTokenExpired(): Boolean {
+ val expiresAt = sharedPreferences.getLong("expires_at", 0)
+ return System.currentTimeMillis() >= expiresAt
+ }
+
+ fun clearTokens() {
+ sharedPreferences.edit { clear() }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/endurain/PkceHelper.kt b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/endurain/PkceHelper.kt
new file mode 100644
index 0000000000..9d2cf6d4c1
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/endurain/PkceHelper.kt
@@ -0,0 +1,47 @@
+/* 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.util.Base64
+import java.security.MessageDigest
+import java.security.SecureRandom
+
+class PkceHelper {
+ /**
+ * Generate cryptographically random code verifier (43-128 chars)
+ */
+ fun generateCodeVerifier(): String {
+ val bytes = ByteArray(32)
+ SecureRandom().nextBytes(bytes)
+ return Base64.encodeToString(
+ bytes,
+ Base64.URL_SAFE or Base64.NO_WRAP or Base64.NO_PADDING
+ )
+ }
+
+ /**
+ * Generate SHA256 code challenge from verifier
+ */
+ fun generateCodeChallenge(verifier: String): String {
+ val digest = MessageDigest.getInstance("SHA-256")
+ val hash = digest.digest(verifier.toByteArray())
+ return Base64.encodeToString(
+ hash,
+ Base64.URL_SAFE or Base64.NO_WRAP or Base64.NO_PADDING
+ )
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/res/layout/endurain_bottomsheet_setup_wizard.xml b/app/src/main/res/layout/endurain_bottomsheet_setup_wizard.xml
index 76b8358f7a..f6621947eb 100644
--- a/app/src/main/res/layout/endurain_bottomsheet_setup_wizard.xml
+++ b/app/src/main/res/layout/endurain_bottomsheet_setup_wizard.xml
@@ -80,6 +80,22 @@
android:inputType="textPassword" />
+
+
+
+
+