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" /> + + + + +