mirror of
https://codeberg.org/Freeyourgadget/Gadgetbridge.git
synced 2026-07-31 07:44:24 +02:00
Endurain: Support SSO when logging into Endurain
This commit is contained in:
@@ -262,6 +262,7 @@ dependencies {
|
|||||||
implementation libs.androidx.work.runtime.ktx
|
implementation libs.androidx.work.runtime.ktx
|
||||||
implementation libs.androidx.lifecycle.process
|
implementation libs.androidx.lifecycle.process
|
||||||
implementation libs.androidx.security.crypto
|
implementation libs.androidx.security.crypto
|
||||||
|
implementation libs.androidx.browser
|
||||||
|
|
||||||
implementation libs.material
|
implementation libs.material
|
||||||
implementation libs.flexbox
|
implementation libs.flexbox
|
||||||
|
|||||||
@@ -1124,6 +1124,23 @@
|
|||||||
</intent-filter>
|
</intent-filter>
|
||||||
</activity>
|
</activity>
|
||||||
|
|
||||||
|
<activity
|
||||||
|
android:name=".activities.endurain.EndurainOAuthCallbackActivity"
|
||||||
|
android:exported="true"
|
||||||
|
android:launchMode="singleTop">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.VIEW" />
|
||||||
|
<category android:name="android.intent.category.DEFAULT" />
|
||||||
|
<category android:name="android.intent.category.BROWSABLE" />
|
||||||
|
|
||||||
|
<!-- Deep link scheme for OAuth callback -->
|
||||||
|
<data
|
||||||
|
android:scheme="${appAuthRedirectScheme}"
|
||||||
|
android:host="endurain"
|
||||||
|
android:pathPrefix="/oauth/callback" />
|
||||||
|
</intent-filter>
|
||||||
|
</activity>
|
||||||
|
|
||||||
<!-- For supported versions through Android 13, create an activity to show the rationale
|
<!-- For supported versions through Android 13, create an activity to show the rationale
|
||||||
of Health Connect permissions once users click the privacy policy link. -->
|
of Health Connect permissions once users click the privacy policy link. -->
|
||||||
<activity
|
<activity
|
||||||
|
|||||||
+31
@@ -37,6 +37,7 @@ data class LoginResponse(
|
|||||||
val access_token: String? = null,
|
val access_token: String? = null,
|
||||||
val refresh_token: String? = null,
|
val refresh_token: String? = null,
|
||||||
val expires_in: Int? = null,
|
val expires_in: Int? = null,
|
||||||
|
val refresh_token_expires_in: Int? = null,
|
||||||
val token_type: String? = null,
|
val token_type: String? = null,
|
||||||
val mfa_required: Boolean? = null,
|
val mfa_required: Boolean? = null,
|
||||||
val username: String? = null,
|
val username: String? = null,
|
||||||
@@ -52,6 +53,12 @@ data class TokenExchangeRequest(
|
|||||||
val code_verifier: String
|
val code_verifier: String
|
||||||
)
|
)
|
||||||
|
|
||||||
|
data class IdentityProvider(
|
||||||
|
val id: String,
|
||||||
|
val name: String,
|
||||||
|
val slug: String
|
||||||
|
)
|
||||||
|
|
||||||
class EndurainApiClient(
|
class EndurainApiClient(
|
||||||
private val baseUrl: String,
|
private val baseUrl: String,
|
||||||
private val tokenManager: EndurainTokenManager
|
private val tokenManager: EndurainTokenManager
|
||||||
@@ -200,6 +207,28 @@ class EndurainApiClient(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get list of available identity providers
|
||||||
|
*/
|
||||||
|
fun getIdentityProviders(): List<IdentityProvider>? {
|
||||||
|
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<List<IdentityProvider>>() {}.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)
|
* Exchange OAuth session for tokens (PKCE flow)
|
||||||
*/
|
*/
|
||||||
@@ -220,6 +249,8 @@ class EndurainApiClient(
|
|||||||
body = body
|
body = body
|
||||||
)
|
)
|
||||||
|
|
||||||
|
LOG.debug("OAuth token result: $responseText")
|
||||||
|
|
||||||
return if (responseText != null) {
|
return if (responseText != null) {
|
||||||
gson.fromJson(responseText, LoginResponse::class.java)
|
gson.fromJson(responseText, LoginResponse::class.java)
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
+84
@@ -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 <https://www.gnu.org/licenses/>. */
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
+6
-5
@@ -109,11 +109,11 @@ class EndurainPreferencesActivity : AbstractSettingsActivityV2() {
|
|||||||
vm.logout { success ->
|
vm.logout { success ->
|
||||||
activity?.runOnUiThread {
|
activity?.runOnUiThread {
|
||||||
if (success) {
|
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()
|
updateStatus()
|
||||||
updateLogoutPreferenceVisibility()
|
updateLogoutPreferenceVisibility()
|
||||||
} else {
|
} 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 server = GBApplication.getPrefs().preferences.getString("endurain_server", null)
|
||||||
val tokenExpiresAt = DateTimeUtils.parseTimeStamp(vm.tokenManager.getRefreshTokenExpiresAt())
|
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) {
|
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) {
|
if (vm.serverVersion != null) {
|
||||||
summaryText += "\nServer version: ${vm.serverVersion}"
|
summaryText += getString(R.string.endurain_server_version, vm.serverVersion)
|
||||||
}
|
}
|
||||||
statusPref?.summary = summaryText
|
statusPref?.summary = summaryText
|
||||||
}
|
}
|
||||||
|
|||||||
+135
-19
@@ -16,11 +16,17 @@
|
|||||||
along with this program. If not, see <https://www.gnu.org/licenses/>. */
|
along with this program. If not, see <https://www.gnu.org/licenses/>. */
|
||||||
package nodomain.freeyourgadget.gadgetbridge.activities.endurain
|
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.os.Bundle
|
||||||
import android.view.LayoutInflater
|
import android.view.LayoutInflater
|
||||||
import android.view.View
|
import android.view.View
|
||||||
import android.view.ViewGroup
|
import android.view.ViewGroup
|
||||||
import android.widget.Toast
|
import android.widget.Toast
|
||||||
|
import androidx.browser.customtabs.CustomTabsIntent
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
import androidx.core.content.edit
|
import androidx.core.content.edit
|
||||||
import androidx.core.net.toUri
|
import androidx.core.net.toUri
|
||||||
import androidx.fragment.app.viewModels
|
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.GBApplication
|
||||||
import nodomain.freeyourgadget.gadgetbridge.R
|
import nodomain.freeyourgadget.gadgetbridge.R
|
||||||
import nodomain.freeyourgadget.gadgetbridge.util.GB
|
import nodomain.freeyourgadget.gadgetbridge.util.GB
|
||||||
|
import org.slf4j.LoggerFactory
|
||||||
|
|
||||||
class EndurainSetupBottomSheet : BottomSheetDialogFragment() {
|
class EndurainSetupBottomSheet : BottomSheetDialogFragment() {
|
||||||
|
|
||||||
|
private val LOG = LoggerFactory.getLogger(EndurainSetupBottomSheet::class.java)
|
||||||
private val prefs get() = GBApplication.getPrefs().preferences
|
private val prefs get() = GBApplication.getPrefs().preferences
|
||||||
private val vm: EndurainSetupViewModel by viewModels()
|
private val vm: EndurainSetupViewModel by viewModels()
|
||||||
|
|
||||||
@@ -52,6 +60,24 @@ class EndurainSetupBottomSheet : BottomSheetDialogFragment() {
|
|||||||
private lateinit var progress: View
|
private lateinit var progress: View
|
||||||
private lateinit var next: MaterialButton
|
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(
|
override fun onCreateView(
|
||||||
inflater: LayoutInflater,
|
inflater: LayoutInflater,
|
||||||
container: ViewGroup?,
|
container: ViewGroup?,
|
||||||
@@ -63,26 +89,31 @@ class EndurainSetupBottomSheet : BottomSheetDialogFragment() {
|
|||||||
)
|
)
|
||||||
|
|
||||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||||
|
// Initialize views
|
||||||
serverLayout = view.findViewById(R.id.server_layout)
|
serverLayout = view.findViewById(R.id.server_layout)
|
||||||
serverInput = view.findViewById(R.id.server_input)
|
serverInput = view.findViewById(R.id.server_input)
|
||||||
|
|
||||||
loginTypeGroup = view.findViewById(R.id.login_type_group)
|
loginTypeGroup = view.findViewById(R.id.login_type_group)
|
||||||
localButton = view.findViewById(R.id.local_login_button)
|
localButton = view.findViewById(R.id.local_login_button)
|
||||||
ssoButton = view.findViewById(R.id.sso_login_button)
|
ssoButton = view.findViewById(R.id.sso_login_button)
|
||||||
|
|
||||||
userLayout = view.findViewById(R.id.user_layout)
|
userLayout = view.findViewById(R.id.user_layout)
|
||||||
passLayout = view.findViewById(R.id.password_layout)
|
passLayout = view.findViewById(R.id.password_layout)
|
||||||
userInput = view.findViewById(R.id.user_input)
|
userInput = view.findViewById(R.id.user_input)
|
||||||
passInput = view.findViewById(R.id.password_input)
|
passInput = view.findViewById(R.id.password_input)
|
||||||
|
|
||||||
mfaLayout = view.findViewById(R.id.mfa_layout)
|
mfaLayout = view.findViewById(R.id.mfa_layout)
|
||||||
mfaInput = view.findViewById(R.id.mfa_input)
|
mfaInput = view.findViewById(R.id.mfa_input)
|
||||||
|
|
||||||
progress = view.findViewById(R.id.progress)
|
progress = view.findViewById(R.id.progress)
|
||||||
next = view.findViewById(R.id.next_button)
|
next = view.findViewById(R.id.next_button)
|
||||||
|
|
||||||
serverInput.setText(prefs.getString("endurain_server", ""))
|
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 {
|
next.setOnClickListener {
|
||||||
when (vm.step) {
|
when (vm.step) {
|
||||||
EndurainSetupViewModel.Step.SERVER -> handleServerStep()
|
EndurainSetupViewModel.Step.SERVER -> handleServerStep()
|
||||||
@@ -102,10 +133,95 @@ class EndurainSetupBottomSheet : BottomSheetDialogFragment() {
|
|||||||
mfaLayout.visibility = View.GONE
|
mfaLayout.visibility = View.GONE
|
||||||
}
|
}
|
||||||
R.id.sso_login_button -> {
|
R.id.sso_login_button -> {
|
||||||
vm.step = EndurainSetupViewModel.Step.SSO_LOGIN
|
vm.step = EndurainSetupViewModel.Step.SSO_PROVIDERS
|
||||||
// TODO: Launch SSO flow
|
startSsoFlow()
|
||||||
GB.toast("SSO not yet implemented", Toast.LENGTH_SHORT, GB.INFO)
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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()
|
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() {
|
private fun handleServerStep() {
|
||||||
val uri = serverInput.text.toString().toUri()
|
val uri = serverInput.text.toString().toUri()
|
||||||
if (uri.scheme == null || uri.host == null) {
|
if (uri.scheme == null || uri.host == null) {
|
||||||
serverLayout.error = "Invalid server URL"
|
serverLayout.error = getString(R.string.endurain_invalid_server_url)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
serverLayout.error = null
|
serverLayout.error = null
|
||||||
@@ -127,7 +243,7 @@ class EndurainSetupBottomSheet : BottomSheetDialogFragment() {
|
|||||||
activity?.runOnUiThread {
|
activity?.runOnUiThread {
|
||||||
showProgress(false)
|
showProgress(false)
|
||||||
if (!ok) {
|
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
|
return@runOnUiThread
|
||||||
}
|
}
|
||||||
vm.step = EndurainSetupViewModel.Step.LOGIN_TYPE
|
vm.step = EndurainSetupViewModel.Step.LOGIN_TYPE
|
||||||
@@ -151,14 +267,14 @@ class EndurainSetupBottomSheet : BottomSheetDialogFragment() {
|
|||||||
|
|
||||||
var hasError = false
|
var hasError = false
|
||||||
if (user.isBlank()) {
|
if (user.isBlank()) {
|
||||||
userLayout.error = "Required"
|
userLayout.error = getString(R.string.required)
|
||||||
hasError = true
|
hasError = true
|
||||||
} else {
|
} else {
|
||||||
userLayout.error = null
|
userLayout.error = null
|
||||||
}
|
}
|
||||||
|
|
||||||
if (pass.isBlank()) {
|
if (pass.isBlank()) {
|
||||||
passLayout.error = "Required"
|
passLayout.error = getString(R.string.required)
|
||||||
hasError = true
|
hasError = true
|
||||||
} else {
|
} else {
|
||||||
passLayout.error = null
|
passLayout.error = null
|
||||||
@@ -176,11 +292,11 @@ class EndurainSetupBottomSheet : BottomSheetDialogFragment() {
|
|||||||
userLayout.visibility = View.GONE
|
userLayout.visibility = View.GONE
|
||||||
passLayout.visibility = View.GONE
|
passLayout.visibility = View.GONE
|
||||||
mfaLayout.visibility = View.VISIBLE
|
mfaLayout.visibility = View.VISIBLE
|
||||||
next.text = "Verify MFA"
|
next.text = getString(R.string.endurain_verify_mfa)
|
||||||
GB.toast("Enter your MFA code", Toast.LENGTH_SHORT, GB.INFO)
|
GB.toast(getString(R.string.endurain_enter_your_mfa_code), Toast.LENGTH_SHORT, GB.INFO)
|
||||||
}
|
}
|
||||||
success -> {
|
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
|
// Send result to parent fragment
|
||||||
parentFragmentManager.setFragmentResult(
|
parentFragmentManager.setFragmentResult(
|
||||||
"endurain_login_result",
|
"endurain_login_result",
|
||||||
@@ -189,7 +305,7 @@ class EndurainSetupBottomSheet : BottomSheetDialogFragment() {
|
|||||||
dismiss()
|
dismiss()
|
||||||
}
|
}
|
||||||
else -> {
|
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() {
|
private fun handleMfaStep() {
|
||||||
val mfaCode = mfaInput.text.toString()
|
val mfaCode = mfaInput.text.toString()
|
||||||
if (mfaCode.isBlank()) {
|
if (mfaCode.isBlank()) {
|
||||||
mfaLayout.error = "Required"
|
mfaLayout.error = getString(R.string.required)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
mfaLayout.error = null
|
mfaLayout.error = null
|
||||||
@@ -209,7 +325,7 @@ class EndurainSetupBottomSheet : BottomSheetDialogFragment() {
|
|||||||
activity?.runOnUiThread {
|
activity?.runOnUiThread {
|
||||||
showProgress(false)
|
showProgress(false)
|
||||||
if (success) {
|
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
|
// Send result to parent fragment
|
||||||
parentFragmentManager.setFragmentResult(
|
parentFragmentManager.setFragmentResult(
|
||||||
"endurain_login_result",
|
"endurain_login_result",
|
||||||
@@ -217,8 +333,8 @@ class EndurainSetupBottomSheet : BottomSheetDialogFragment() {
|
|||||||
)
|
)
|
||||||
dismiss()
|
dismiss()
|
||||||
} else {
|
} else {
|
||||||
mfaLayout.error = "Invalid MFA code"
|
mfaLayout.error = getString(R.string.endurain_invalid_mfa_code)
|
||||||
GB.toast("MFA verification failed", Toast.LENGTH_SHORT, GB.INFO)
|
GB.toast(getString(R.string.endurain_mfa_verification_failed), Toast.LENGTH_SHORT, GB.INFO)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+108
-3
@@ -17,8 +17,12 @@
|
|||||||
package nodomain.freeyourgadget.gadgetbridge.activities.endurain
|
package nodomain.freeyourgadget.gadgetbridge.activities.endurain
|
||||||
|
|
||||||
import android.app.Application
|
import android.app.Application
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.core.content.edit
|
||||||
import androidx.core.net.toUri
|
import androidx.core.net.toUri
|
||||||
import androidx.lifecycle.AndroidViewModel
|
import androidx.lifecycle.AndroidViewModel
|
||||||
|
import nodomain.freeyourgadget.gadgetbridge.BuildConfig
|
||||||
|
import nodomain.freeyourgadget.gadgetbridge.GBApplication
|
||||||
import nodomain.freeyourgadget.gadgetbridge.util.InternetUtils
|
import nodomain.freeyourgadget.gadgetbridge.util.InternetUtils
|
||||||
import org.slf4j.LoggerFactory
|
import org.slf4j.LoggerFactory
|
||||||
|
|
||||||
@@ -26,13 +30,14 @@ class EndurainSetupViewModel(application: Application) : AndroidViewModel(applic
|
|||||||
|
|
||||||
private val LOG = LoggerFactory.getLogger(EndurainSetupViewModel::class.java)
|
private val LOG = LoggerFactory.getLogger(EndurainSetupViewModel::class.java)
|
||||||
private lateinit var apiClient: EndurainApiClient
|
private lateinit var apiClient: EndurainApiClient
|
||||||
|
private val pkceHelper = PkceHelper()
|
||||||
|
|
||||||
enum class Step {
|
enum class Step {
|
||||||
SERVER,
|
SERVER,
|
||||||
LOGIN_TYPE,
|
LOGIN_TYPE,
|
||||||
LOCAL_LOGIN,
|
LOCAL_LOGIN,
|
||||||
MFA_VERIFY,
|
MFA_VERIFY,
|
||||||
SSO_LOGIN
|
SSO_PROVIDERS
|
||||||
}
|
}
|
||||||
|
|
||||||
val tokenManager = EndurainTokenManager(application)
|
val tokenManager = EndurainTokenManager(application)
|
||||||
@@ -41,6 +46,7 @@ class EndurainSetupViewModel(application: Application) : AndroidViewModel(applic
|
|||||||
var localLoginEnabled = false
|
var localLoginEnabled = false
|
||||||
var ssoEnabled = false
|
var ssoEnabled = false
|
||||||
var pendingMfaUsername: String? = null
|
var pendingMfaUsername: String? = null
|
||||||
|
var availableProviders: List<IdentityProvider> = emptyList()
|
||||||
var serverVersion: String? = null
|
var serverVersion: String? = null
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -103,6 +109,101 @@ class EndurainSetupViewModel(application: Application) : AndroidViewModel(applic
|
|||||||
}.start()
|
}.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<GBApplication>().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<GBApplication>()
|
||||||
|
.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
|
* Perform local username/password login
|
||||||
*/
|
*/
|
||||||
@@ -133,7 +234,8 @@ class EndurainSetupViewModel(application: Application) : AndroidViewModel(applic
|
|||||||
tokenManager.saveTokens(
|
tokenManager.saveTokens(
|
||||||
response.access_token,
|
response.access_token,
|
||||||
response.refresh_token!!,
|
response.refresh_token!!,
|
||||||
response.expires_in!!
|
response.expires_in!!,
|
||||||
|
response.refresh_token_expires_in!!
|
||||||
)
|
)
|
||||||
callback(true)
|
callback(true)
|
||||||
}
|
}
|
||||||
@@ -164,12 +266,15 @@ class EndurainSetupViewModel(application: Application) : AndroidViewModel(applic
|
|||||||
|
|
||||||
val response = apiClient.verifyMfa(username, mfaCode)
|
val response = apiClient.verifyMfa(username, mfaCode)
|
||||||
|
|
||||||
|
LOG.debug("MFA verification response: {}", response)
|
||||||
|
|
||||||
if (response?.access_token != null) {
|
if (response?.access_token != null) {
|
||||||
LOG.info("MFA verification successful")
|
LOG.info("MFA verification successful")
|
||||||
tokenManager.saveTokens(
|
tokenManager.saveTokens(
|
||||||
response.access_token,
|
response.access_token,
|
||||||
response.refresh_token!!,
|
response.refresh_token!!,
|
||||||
response.expires_in!!
|
response.expires_in!!,
|
||||||
|
response.refresh_token_expires_in!!
|
||||||
)
|
)
|
||||||
pendingMfaUsername = null
|
pendingMfaUsername = null
|
||||||
callback(true)
|
callback(true)
|
||||||
|
|||||||
+41
@@ -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 <https://www.gnu.org/licenses/>. */
|
||||||
|
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<IdentityProvider>,
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
+8
-7
@@ -37,15 +37,15 @@ class EndurainTokenManager(context: Context) {
|
|||||||
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
|
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 {
|
sharedPreferences.edit {
|
||||||
putString("access_token", accessToken)
|
putString("access_token", accessToken)
|
||||||
.putString("refresh_token", refreshToken)
|
.putString("refresh_token", refreshToken)
|
||||||
.putInt("access_token_expires_at", accessTokenExpiresAt)
|
.putInt("access_token_expires_at", accessTokenExpiryTs)
|
||||||
.putInt("refresh_token_expires_at",
|
.putInt("refresh_token_expires_at", refreshTokenExpiryTs
|
||||||
((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
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -87,7 +87,8 @@ class EndurainTokenManager(context: Context) {
|
|||||||
saveTokens(
|
saveTokens(
|
||||||
response.access_token,
|
response.access_token,
|
||||||
response.refresh_token!!,
|
response.refresh_token!!,
|
||||||
response.expires_in!!
|
response.expires_in!!,
|
||||||
|
response.refresh_token_expires_in!!
|
||||||
)
|
)
|
||||||
callback(true)
|
callback(true)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4938,4 +4938,26 @@
|
|||||||
<string name="media_source_audio_card">Audio card</string>
|
<string name="media_source_audio_card">Audio card</string>
|
||||||
<string name="media_source_aux">AUX</string>
|
<string name="media_source_aux">AUX</string>
|
||||||
<string name="sinilink_prompt_tone">Prompt tone</string>
|
<string name="sinilink_prompt_tone">Prompt tone</string>
|
||||||
|
<string name="required">Required</string>
|
||||||
|
<string name="endurain_sso_login_failed">SSO login failed</string>
|
||||||
|
<string name="endurain_no_sso_providers_available">No SSO providers available</string>
|
||||||
|
<string name="endurain_failed_to_open_browser">Failed to open browser: %1$s</string>
|
||||||
|
<string name="endurain_sso_login_successful">SSO login successful</string>
|
||||||
|
<string name="endurain_invalid_server_url">Invalid server URL</string>
|
||||||
|
<string name="endurain_failed_to_connect_to_server">Failed to connect to server</string>
|
||||||
|
<string name="endurain_verify_mfa">Verify MFA</string>
|
||||||
|
<string name="endurain_enter_your_mfa_code">Enter your MFA code</string>
|
||||||
|
<string name="endurain_login_successful">Login successful</string>
|
||||||
|
<string name="endurain_login_failed">Login failed</string>
|
||||||
|
<string name="endurain_mfa_verification_successful">MFA verification successful</string>
|
||||||
|
<string name="endurain_invalid_mfa_code">Invalid MFA code</string>
|
||||||
|
<string name="endurain_mfa_verification_failed">MFA verification failed</string>
|
||||||
|
<string name="endurain_select_identity_provider">Select Identity Provider</string>
|
||||||
|
<string name="endurain_invalid_oauth_callback">Invalid OAuth callback</string>
|
||||||
|
<string name="endurain_oauth_completing_login">Completing login…</string>
|
||||||
|
<string name="endurain_not_logged_in_integration_disabled">Not logged in, integration is disabled</string>
|
||||||
|
<string name="endurain_logged_in_refresh_token">Logged in to %1$s\nRefresh token expires: %2$s</string>
|
||||||
|
<string name="endurain_server_version">\nServer version: %1$s</string>
|
||||||
|
<string name="endurain_logout_failed">Logout failed</string>
|
||||||
|
<string name="endurain_logged_out_successfully">Logged out successfully</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ searchpreference = "2.7.3"
|
|||||||
slf4j = "2.0.18"
|
slf4j = "2.0.18"
|
||||||
solarpositioning = "0.1.10"
|
solarpositioning = "0.1.10"
|
||||||
security-crypto = "1.1.0"
|
security-crypto = "1.1.0"
|
||||||
|
browser = "1.10.0"
|
||||||
|
|
||||||
[libraries]
|
[libraries]
|
||||||
ahocorasick = { group = "org.ahocorasick", name = "ahocorasick", version.ref = "ahocorasick" }
|
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" }
|
slf4j-api = { group = "org.slf4j", name = "slf4j-api", version.ref = "slf4j" }
|
||||||
solarpositioning = { group = "net.e175.klaus", name = "solarpositioning", version.ref = "solarpositioning" }
|
solarpositioning = { group = "net.e175.klaus", name = "solarpositioning", version.ref = "solarpositioning" }
|
||||||
androidx-security-crypto = { group = "androidx.security", name = "security-crypto", version.ref = "security-crypto" }
|
androidx-security-crypto = { group = "androidx.security", name = "security-crypto", version.ref = "security-crypto" }
|
||||||
|
androidx-browser = { group = "androidx.browser", name = "browser", version.ref = "browser" }
|
||||||
|
|
||||||
[bundles]
|
[bundles]
|
||||||
android-emojify = [
|
android-emojify = [
|
||||||
|
|||||||
Reference in New Issue
Block a user