mirror of
https://codeberg.org/Freeyourgadget/Gadgetbridge.git
synced 2026-07-31 07:44:24 +02:00
Endurain: Functional local login implementation
This commit is contained in:
+1
-3
@@ -261,6 +261,7 @@ dependencies {
|
|||||||
implementation libs.androidx.viewpager2
|
implementation libs.androidx.viewpager2
|
||||||
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.material
|
implementation libs.material
|
||||||
implementation libs.flexbox
|
implementation libs.flexbox
|
||||||
@@ -290,9 +291,6 @@ dependencies {
|
|||||||
implementation libs.androidsvg
|
implementation libs.androidsvg
|
||||||
implementation libs.jsoup
|
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
|
// 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
|
// It's included in the org.bouncycastle.shaded package, to fix conflicts with roboelectric
|
||||||
//implementation 'org.bouncycastle:bcpkix-jdk18on:1.76'
|
//implementation 'org.bouncycastle:bcpkix-jdk18on:1.76'
|
||||||
|
|||||||
+259
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+57
-5
@@ -17,6 +17,8 @@
|
|||||||
package nodomain.freeyourgadget.gadgetbridge.activities.endurain
|
package nodomain.freeyourgadget.gadgetbridge.activities.endurain
|
||||||
|
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
|
import android.widget.Toast
|
||||||
|
import androidx.fragment.app.viewModels
|
||||||
import androidx.preference.Preference
|
import androidx.preference.Preference
|
||||||
import androidx.preference.PreferenceFragmentCompat
|
import androidx.preference.PreferenceFragmentCompat
|
||||||
import nodomain.freeyourgadget.gadgetbridge.GBApplication
|
import nodomain.freeyourgadget.gadgetbridge.GBApplication
|
||||||
@@ -31,13 +33,35 @@ class EndurainPreferencesActivity : AbstractSettingsActivityV2() {
|
|||||||
|
|
||||||
class EndurainPreferencesFragment : AbstractPreferenceFragment() {
|
class EndurainPreferencesFragment : AbstractPreferenceFragment() {
|
||||||
|
|
||||||
|
private val vm: EndurainSetupViewModel by viewModels()
|
||||||
|
|
||||||
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
|
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
|
||||||
setPreferencesFromResource(R.xml.endurain_preferences, rootKey)
|
setPreferencesFromResource(R.xml.endurain_preferences, rootKey)
|
||||||
|
|
||||||
updateNetworkWarning()
|
updateNetworkWarning()
|
||||||
wireLoginPreference()
|
wireLoginPreference()
|
||||||
hideLogoffPreference()
|
wireLogoutPreference()
|
||||||
updateStatus()
|
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() {
|
private fun updateNetworkWarning() {
|
||||||
@@ -53,13 +77,41 @@ class EndurainPreferencesActivity : AbstractSettingsActivityV2() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun hideLogoffPreference() {
|
private fun wireLogoutPreference() {
|
||||||
findPreference<Preference>("pref_key_log_out")?.isVisible = false
|
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() {
|
private fun updateStatus() {
|
||||||
findPreference<Preference>("pref_key_status")?.summary =
|
val statusPref = findPreference<Preference>("pref_key_status")
|
||||||
"Not logged in, integration is disabled"
|
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"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+155
-70
@@ -20,6 +20,7 @@ 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 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
|
||||||
@@ -30,12 +31,27 @@ import com.google.android.material.textfield.TextInputEditText
|
|||||||
import com.google.android.material.textfield.TextInputLayout
|
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
|
||||||
|
|
||||||
class EndurainSetupBottomSheet : BottomSheetDialogFragment() {
|
class EndurainSetupBottomSheet : BottomSheetDialogFragment() {
|
||||||
|
|
||||||
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()
|
||||||
|
|
||||||
|
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(
|
override fun onCreateView(
|
||||||
inflater: LayoutInflater,
|
inflater: LayoutInflater,
|
||||||
container: ViewGroup?,
|
container: ViewGroup?,
|
||||||
@@ -47,82 +63,31 @@ class EndurainSetupBottomSheet : BottomSheetDialogFragment() {
|
|||||||
)
|
)
|
||||||
|
|
||||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||||
val serverLayout = view.findViewById<TextInputLayout>(R.id.server_layout)
|
serverLayout = view.findViewById(R.id.server_layout)
|
||||||
val serverInput = view.findViewById<TextInputEditText>(R.id.server_input)
|
serverInput = view.findViewById(R.id.server_input)
|
||||||
|
|
||||||
val loginTypeGroup =
|
loginTypeGroup = view.findViewById(R.id.login_type_group)
|
||||||
view.findViewById<MaterialButtonToggleGroup>(R.id.login_type_group)
|
localButton = view.findViewById(R.id.local_login_button)
|
||||||
val localButton = view.findViewById<MaterialButton>(R.id.local_login_button)
|
ssoButton = view.findViewById(R.id.sso_login_button)
|
||||||
val ssoButton = view.findViewById<MaterialButton>(R.id.sso_login_button)
|
|
||||||
|
|
||||||
val userLayout = view.findViewById<TextInputLayout>(R.id.user_layout)
|
userLayout = view.findViewById(R.id.user_layout)
|
||||||
val passLayout = view.findViewById<TextInputLayout>(R.id.password_layout)
|
passLayout = view.findViewById(R.id.password_layout)
|
||||||
val userInput = view.findViewById<TextInputEditText>(R.id.user_input)
|
userInput = view.findViewById(R.id.user_input)
|
||||||
val passInput = view.findViewById<TextInputEditText>(R.id.password_input)
|
passInput = view.findViewById(R.id.password_input)
|
||||||
|
|
||||||
val progress = view.findViewById<View>(R.id.progress)
|
mfaLayout = view.findViewById(R.id.mfa_layout)
|
||||||
val next = view.findViewById<MaterialButton>(R.id.next_button)
|
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", ""))
|
serverInput.setText(prefs.getString("endurain_server", ""))
|
||||||
|
|
||||||
fun showProgress(show: Boolean) {
|
|
||||||
progress.visibility = if (show) View.VISIBLE else View.GONE
|
|
||||||
next.isEnabled = !show
|
|
||||||
}
|
|
||||||
|
|
||||||
next.setOnClickListener {
|
next.setOnClickListener {
|
||||||
when (vm.step) {
|
when (vm.step) {
|
||||||
EndurainSetupViewModel.Step.SERVER -> {
|
EndurainSetupViewModel.Step.SERVER -> handleServerStep()
|
||||||
val uri = serverInput.text.toString().toUri()
|
EndurainSetupViewModel.Step.LOCAL_LOGIN -> handleLocalLoginStep()
|
||||||
if (uri.scheme == null || uri.host == null) {
|
EndurainSetupViewModel.Step.MFA_VERIFY -> handleMfaStep()
|
||||||
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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
else -> {}
|
else -> {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -134,13 +99,133 @@ class EndurainSetupBottomSheet : BottomSheetDialogFragment() {
|
|||||||
vm.step = EndurainSetupViewModel.Step.LOCAL_LOGIN
|
vm.step = EndurainSetupViewModel.Step.LOCAL_LOGIN
|
||||||
userLayout.visibility = View.VISIBLE
|
userLayout.visibility = View.VISIBLE
|
||||||
passLayout.visibility = View.VISIBLE
|
passLayout.visibility = View.VISIBLE
|
||||||
|
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_LOGIN
|
||||||
// TODO launch SSO flow
|
// TODO: Launch SSO flow
|
||||||
|
GB.toast("SSO not yet implemented", Toast.LENGTH_SHORT, GB.INFO)
|
||||||
dismiss()
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
+166
-40
@@ -16,54 +16,180 @@
|
|||||||
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.app.Application
|
||||||
import androidx.core.net.toUri
|
import androidx.core.net.toUri
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.AndroidViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import kotlinx.coroutines.withContext
|
|
||||||
import nodomain.freeyourgadget.gadgetbridge.util.InternetUtils
|
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
|
enum class Step {
|
||||||
var server: String = ""
|
SERVER,
|
||||||
var localLoginEnabled: Boolean = false
|
LOGIN_TYPE,
|
||||||
var ssoEnabled: Boolean = false
|
LOCAL_LOGIN,
|
||||||
|
MFA_VERIFY,
|
||||||
fun fetchServerCapabilities(
|
SSO_LOGIN
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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(
|
fun performLocalLogin(
|
||||||
server: String,
|
serverUrl: String,
|
||||||
user: String,
|
username: String,
|
||||||
pass: String,
|
password: String,
|
||||||
onResult: (Boolean) -> Unit
|
callback: (Boolean) -> Unit
|
||||||
) {
|
) {
|
||||||
viewModelScope.launch {
|
Thread {
|
||||||
val success = withContext(Dispatchers.IO) {
|
try {
|
||||||
// TODO real API call
|
apiClient = EndurainApiClient(serverUrl, tokenManager)
|
||||||
user.isNotBlank() && pass.isNotBlank()
|
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
+55
@@ -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() }
|
||||||
|
}
|
||||||
|
}
|
||||||
+47
@@ -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" />
|
android:inputType="textPassword" />
|
||||||
</com.google.android.material.textfield.TextInputLayout>
|
</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
|
<ProgressBar
|
||||||
android:id="@+id/progress"
|
android:id="@+id/progress"
|
||||||
android:layout_width="wrap_content"
|
android:layout_width="wrap_content"
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ robolectric = "4.16.1"
|
|||||||
searchpreference = "2.7.3"
|
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"
|
||||||
|
|
||||||
[libraries]
|
[libraries]
|
||||||
ahocorasick = { group = "org.ahocorasick", name = "ahocorasick", version.ref = "ahocorasick" }
|
ahocorasick = { group = "org.ahocorasick", name = "ahocorasick", version.ref = "ahocorasick" }
|
||||||
@@ -112,6 +113,7 @@ robolectric = { group = "org.robolectric", name = "robolectric", version.ref = "
|
|||||||
searchpreference = { group = "com.github.ByteHamster", name = "SearchPreference", version.ref = "searchpreference" }
|
searchpreference = { group = "com.github.ByteHamster", name = "SearchPreference", version.ref = "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" }
|
||||||
|
|
||||||
[bundles]
|
[bundles]
|
||||||
android-emojify = [
|
android-emojify = [
|
||||||
|
|||||||
Reference in New Issue
Block a user