Endurain: Functional local login implementation

This commit is contained in:
Arjan Schrijver
2026-06-19 23:31:23 +02:00
parent f92d8c8ebb
commit 38e02f6e78
9 changed files with 758 additions and 118 deletions
+1 -3
View File
@@ -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'
@@ -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 <https://www.gnu.org/licenses/>. */
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<String, String> {
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
}
}
}
@@ -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<Preference>("pref_key_log_out")?.isVisible = false
private fun wireLogoutPreference() {
findPreference<Preference>("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<Preference>("pref_key_log_out")?.isVisible = vm.isLoggedIn()
findPreference<Preference>("pref_key_log_in")?.isVisible = !vm.isLoggedIn()
}
private fun updateStatus() {
findPreference<Preference>("pref_key_status")?.summary =
"Not logged in, integration is disabled"
val statusPref = findPreference<Preference>("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"
}
}
}
}
@@ -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<TextInputLayout>(R.id.server_layout)
val serverInput = view.findViewById<TextInputEditText>(R.id.server_input)
serverLayout = view.findViewById(R.id.server_layout)
serverInput = view.findViewById(R.id.server_input)
val loginTypeGroup =
view.findViewById<MaterialButtonToggleGroup>(R.id.login_type_group)
val localButton = view.findViewById<MaterialButton>(R.id.local_login_button)
val ssoButton = view.findViewById<MaterialButton>(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<TextInputLayout>(R.id.user_layout)
val passLayout = view.findViewById<TextInputLayout>(R.id.password_layout)
val userInput = view.findViewById<TextInputEditText>(R.id.user_input)
val passInput = view.findViewById<TextInputEditText>(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<View>(R.id.progress)
val next = view.findViewById<MaterialButton>(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
}
}
@@ -16,54 +16,180 @@
along with this program. If not, see <https://www.gnu.org/licenses/>. */
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()
}
}
@@ -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 <https://www.gnu.org/licenses/>. */
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() }
}
}
@@ -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 <https://www.gnu.org/licenses/>. */
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
)
}
}
@@ -80,6 +80,22 @@
android:inputType="textPassword" />
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/mfa_layout"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:visibility="gone"
app:errorEnabled="true">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/mfa_input"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="MFA Code"
android:inputType="number"
android:maxLength="6" />
</com.google.android.material.textfield.TextInputLayout>
<ProgressBar
android:id="@+id/progress"
android:layout_width="wrap_content"