mirror of
https://codeberg.org/Freeyourgadget/Gadgetbridge.git
synced 2026-07-31 07:44:24 +02:00
Wanderer: Add basic integration and support GPX uploading
This commit is contained in:
+32
-31
@@ -26,13 +26,13 @@ import org.json.JSONObject
|
||||
import org.slf4j.LoggerFactory
|
||||
import java.io.File
|
||||
|
||||
enum class AuthType {
|
||||
enum class EndurainAuthType {
|
||||
NONE,
|
||||
AUTH_TOKEN,
|
||||
REFRESH_TOKEN
|
||||
}
|
||||
|
||||
data class LoginResponse(
|
||||
data class EndurainLoginResponse(
|
||||
val session_id: String? = null,
|
||||
val access_token: String? = null,
|
||||
val refresh_token: String? = null,
|
||||
@@ -44,16 +44,16 @@ data class LoginResponse(
|
||||
val detail: String? = null
|
||||
)
|
||||
|
||||
data class MfaVerifyRequest(
|
||||
data class EndurainMfaVerifyRequest(
|
||||
val username: String,
|
||||
val mfa_code: String
|
||||
)
|
||||
|
||||
data class TokenExchangeRequest(
|
||||
data class EndurainTokenExchangeRequest(
|
||||
val code_verifier: String
|
||||
)
|
||||
|
||||
data class IdentityProvider(
|
||||
data class EndurainIdentityProvider(
|
||||
val id: String,
|
||||
val name: String,
|
||||
val slug: String
|
||||
@@ -69,16 +69,16 @@ class EndurainApiClient(
|
||||
/**
|
||||
* Build headers with authentication tokens
|
||||
*/
|
||||
private fun buildHeaders(auth: AuthType): MutableMap<String, String> {
|
||||
private fun buildHeaders(auth: EndurainAuthType): MutableMap<String, String> {
|
||||
val headers = mutableMapOf("X-Client-Type" to "mobile")
|
||||
|
||||
when (auth) {
|
||||
AuthType.AUTH_TOKEN -> {
|
||||
EndurainAuthType.AUTH_TOKEN -> {
|
||||
tokenManager.getAccessToken()?.let { token ->
|
||||
headers["Authorization"] = "Bearer $token"
|
||||
}
|
||||
}
|
||||
AuthType.REFRESH_TOKEN -> {
|
||||
EndurainAuthType.REFRESH_TOKEN -> {
|
||||
tokenManager.getRefreshToken()?.let { token ->
|
||||
headers["Authorization"] = "Bearer $token"
|
||||
}
|
||||
@@ -92,14 +92,14 @@ class EndurainApiClient(
|
||||
/**
|
||||
* Username/Password Login
|
||||
*/
|
||||
fun login(username: String, password: String): LoginResponse? {
|
||||
fun login(username: String, password: String): EndurainLoginResponse? {
|
||||
try {
|
||||
val uri = "$baseUrl/api/v1/auth/login".toUri()
|
||||
|
||||
// Form-encoded body
|
||||
val body = "username=${Uri.encode(username)}&password=${Uri.encode(password)}"
|
||||
|
||||
val headers = buildHeaders(AuthType.NONE)
|
||||
val headers = buildHeaders(EndurainAuthType.NONE)
|
||||
headers["Content-Type"] = "application/x-www-form-urlencoded"
|
||||
|
||||
val responseText = InternetUtils.doStringRequest(
|
||||
@@ -110,7 +110,7 @@ class EndurainApiClient(
|
||||
)
|
||||
|
||||
return if (responseText != null) {
|
||||
gson.fromJson(responseText, LoginResponse::class.java)
|
||||
gson.fromJson(responseText, EndurainLoginResponse::class.java)
|
||||
} else {
|
||||
LOG.error("Login failed: empty response")
|
||||
null
|
||||
@@ -124,14 +124,14 @@ class EndurainApiClient(
|
||||
/**
|
||||
* MFA Verification
|
||||
*/
|
||||
fun verifyMfa(username: String, mfaCode: String): LoginResponse? {
|
||||
fun verifyMfa(username: String, mfaCode: String): EndurainLoginResponse? {
|
||||
try {
|
||||
val uri = "$baseUrl/api/v1/auth/mfa/verify".toUri()
|
||||
|
||||
val request = MfaVerifyRequest(username, mfaCode)
|
||||
val request = EndurainMfaVerifyRequest(username, mfaCode)
|
||||
val body = gson.toJson(request)
|
||||
|
||||
val headers = buildHeaders(AuthType.NONE)
|
||||
val headers = buildHeaders(EndurainAuthType.NONE)
|
||||
headers["Content-Type"] = "application/json"
|
||||
|
||||
val responseText = InternetUtils.doStringRequest(
|
||||
@@ -142,7 +142,7 @@ class EndurainApiClient(
|
||||
)
|
||||
|
||||
return if (responseText != null) {
|
||||
gson.fromJson(responseText, LoginResponse::class.java)
|
||||
gson.fromJson(responseText, EndurainLoginResponse::class.java)
|
||||
} else {
|
||||
LOG.error("MFA verification failed: empty response")
|
||||
null
|
||||
@@ -156,11 +156,11 @@ class EndurainApiClient(
|
||||
/**
|
||||
* Token Refresh
|
||||
*/
|
||||
fun refreshToken(): LoginResponse? {
|
||||
fun refreshToken(): EndurainLoginResponse? {
|
||||
try {
|
||||
val uri = "$baseUrl/api/v1/auth/refresh".toUri()
|
||||
|
||||
val headers = buildHeaders(AuthType.REFRESH_TOKEN)
|
||||
val headers = buildHeaders(EndurainAuthType.REFRESH_TOKEN)
|
||||
headers["Content-Type"] = "application/json"
|
||||
|
||||
val responseText = InternetUtils.doStringRequest(
|
||||
@@ -171,7 +171,7 @@ class EndurainApiClient(
|
||||
)
|
||||
|
||||
return if (responseText != null) {
|
||||
gson.fromJson(responseText, LoginResponse::class.java)
|
||||
gson.fromJson(responseText, EndurainLoginResponse::class.java)
|
||||
} else {
|
||||
LOG.error("Token refresh failed: empty response")
|
||||
null
|
||||
@@ -189,7 +189,7 @@ class EndurainApiClient(
|
||||
try {
|
||||
val uri = "$baseUrl/api/v1/auth/logout".toUri()
|
||||
|
||||
val headers = buildHeaders(AuthType.AUTH_TOKEN)
|
||||
val headers = buildHeaders(EndurainAuthType.AUTH_TOKEN)
|
||||
headers["Content-Type"] = "application/json"
|
||||
|
||||
InternetUtils.doStringRequest(
|
||||
@@ -210,11 +210,11 @@ class EndurainApiClient(
|
||||
/**
|
||||
* Get list of available identity providers
|
||||
*/
|
||||
fun getIdentityProviders(): List<IdentityProvider>? {
|
||||
fun getIdentityProviders(): List<EndurainIdentityProvider>? {
|
||||
try {
|
||||
val uri = "$baseUrl/api/v1/public/idp".toUri()
|
||||
|
||||
val headers = buildHeaders(AuthType.NONE)
|
||||
val headers = buildHeaders(EndurainAuthType.NONE)
|
||||
headers["Content-Type"] = "application/json"
|
||||
|
||||
val responseText = InternetUtils.doStringRequest(
|
||||
@@ -223,7 +223,7 @@ class EndurainApiClient(
|
||||
)
|
||||
|
||||
return if (responseText != null) {
|
||||
val type = object : com.google.gson.reflect.TypeToken<List<IdentityProvider>>() {}.type
|
||||
val type = object : com.google.gson.reflect.TypeToken<List<EndurainIdentityProvider>>() {}.type
|
||||
gson.fromJson(responseText, type)
|
||||
} else {
|
||||
LOG.error("Failed to fetch identity providers")
|
||||
@@ -238,14 +238,14 @@ class EndurainApiClient(
|
||||
/**
|
||||
* Exchange OAuth session for tokens (PKCE flow)
|
||||
*/
|
||||
fun exchangeOAuthSession(sessionId: String, codeVerifier: String): LoginResponse? {
|
||||
fun exchangeOAuthSession(sessionId: String, codeVerifier: String): EndurainLoginResponse? {
|
||||
try {
|
||||
val uri = "$baseUrl/api/v1/public/idp/session/$sessionId/tokens".toUri()
|
||||
|
||||
val request = TokenExchangeRequest(codeVerifier)
|
||||
val request = EndurainTokenExchangeRequest(codeVerifier)
|
||||
val body = gson.toJson(request)
|
||||
|
||||
val headers = buildHeaders(AuthType.NONE)
|
||||
val headers = buildHeaders(EndurainAuthType.NONE)
|
||||
headers["Content-Type"] = "application/json"
|
||||
|
||||
val responseText = InternetUtils.doStringRequest(
|
||||
@@ -258,7 +258,7 @@ class EndurainApiClient(
|
||||
LOG.debug("OAuth token result: $responseText")
|
||||
|
||||
return if (responseText != null) {
|
||||
gson.fromJson(responseText, LoginResponse::class.java)
|
||||
gson.fromJson(responseText, EndurainLoginResponse::class.java)
|
||||
} else {
|
||||
LOG.error("OAuth token exchange failed: empty response")
|
||||
null
|
||||
@@ -280,7 +280,7 @@ class EndurainApiClient(
|
||||
try {
|
||||
val uri = "$baseUrl$endpoint".toUri()
|
||||
|
||||
val headers = buildHeaders(AuthType.AUTH_TOKEN)
|
||||
val headers = buildHeaders(EndurainAuthType.AUTH_TOKEN)
|
||||
headers["Content-Type"] = "application/json"
|
||||
|
||||
return InternetUtils.doStringRequest(
|
||||
@@ -302,14 +302,15 @@ class EndurainApiClient(
|
||||
Thread {
|
||||
try {
|
||||
val uri = "$baseUrl/api/v1/activities/create/upload".toUri()
|
||||
val headers = buildHeaders(AuthType.AUTH_TOKEN)
|
||||
val headers = buildHeaders(EndurainAuthType.AUTH_TOKEN)
|
||||
|
||||
InternetUtils.uploadBinaryFile(
|
||||
uri = uri,
|
||||
file = file,
|
||||
requestHeaders = headers
|
||||
) { success, responseText ->
|
||||
) { success, statusCode, responseText ->
|
||||
if (success && responseText != null) {
|
||||
LOG.debug("Response $statusCode from Endurain: $responseText")
|
||||
val jsonArray = JSONArray(responseText)
|
||||
val firstObject = jsonArray.getJSONObject(0)
|
||||
val id = firstObject.getInt("id")
|
||||
@@ -332,7 +333,7 @@ class EndurainApiClient(
|
||||
fun editActivity(id: Int, activityKind: ActivityKind, name: String): Boolean {
|
||||
try {
|
||||
val uri = "$baseUrl/api/v1/activities/edit".toUri()
|
||||
val headers = buildHeaders(AuthType.AUTH_TOKEN)
|
||||
val headers = buildHeaders(EndurainAuthType.AUTH_TOKEN)
|
||||
headers["Content-Type"] = "application/json"
|
||||
|
||||
var activityType = 10 // Generic workout
|
||||
@@ -367,7 +368,7 @@ class EndurainApiClient(
|
||||
try {
|
||||
val uri = "$baseUrl/api/v1/about".toUri()
|
||||
|
||||
val headers = buildHeaders(AuthType.NONE)
|
||||
val headers = buildHeaders(EndurainAuthType.NONE)
|
||||
|
||||
val result = InternetUtils.doJsonRequest(
|
||||
uri = uri,
|
||||
|
||||
+1
-1
@@ -175,7 +175,7 @@ class EndurainSetupBottomSheet : BottomSheetDialogFragment() {
|
||||
}.show()
|
||||
}
|
||||
|
||||
private fun launchSsoLogin(provider: IdentityProvider) {
|
||||
private fun launchSsoLogin(provider: EndurainIdentityProvider) {
|
||||
val ssoUrl = vm.generateSsoUrl(provider.slug)
|
||||
|
||||
LOG.info("Launching secure browser for SSO URL: $ssoUrl")
|
||||
|
||||
+13
-13
@@ -40,14 +40,14 @@ class EndurainSetupViewModel(application: Application) : AndroidViewModel(applic
|
||||
SSO_PROVIDERS
|
||||
}
|
||||
|
||||
val tokenManager = EndurainTokenManager(application)
|
||||
val endurainTokenManager = EndurainTokenManager(application)
|
||||
var step = Step.SERVER
|
||||
var server = ""
|
||||
var localLoginEnabled = false
|
||||
var ssoEnabled = false
|
||||
var pendingMfaUsername: String? = null
|
||||
var availableProviders: List<IdentityProvider> = emptyList()
|
||||
var serverVersion: String? = null
|
||||
var availableProviders: List<EndurainIdentityProvider> = emptyList()
|
||||
var endurainServerVersion: String? = null
|
||||
|
||||
/**
|
||||
* Fetch server version
|
||||
@@ -58,8 +58,8 @@ class EndurainSetupViewModel(application: Application) : AndroidViewModel(applic
|
||||
) {
|
||||
Thread {
|
||||
try {
|
||||
apiClient = EndurainApiClient(serverUrl, tokenManager)
|
||||
serverVersion = apiClient.fetchVersion()
|
||||
apiClient = EndurainApiClient(serverUrl, endurainTokenManager)
|
||||
endurainServerVersion = apiClient.fetchVersion()
|
||||
callback(true)
|
||||
} catch (e: Exception) {
|
||||
LOG.error("Fetching server version error", e)
|
||||
@@ -119,7 +119,7 @@ class EndurainSetupViewModel(application: Application) : AndroidViewModel(applic
|
||||
fun fetchSsoProviders(callback: (Boolean) -> Unit) {
|
||||
Thread {
|
||||
try {
|
||||
apiClient = EndurainApiClient(server, tokenManager)
|
||||
apiClient = EndurainApiClient(server, endurainTokenManager)
|
||||
val providers = apiClient.getIdentityProviders()
|
||||
|
||||
if (providers != null && providers.isNotEmpty()) {
|
||||
@@ -183,14 +183,14 @@ class EndurainSetupViewModel(application: Application) : AndroidViewModel(applic
|
||||
prefs.edit { remove("code_verifier") }
|
||||
|
||||
if (!::apiClient.isInitialized) {
|
||||
apiClient = EndurainApiClient(server, tokenManager)
|
||||
apiClient = EndurainApiClient(server, endurainTokenManager)
|
||||
}
|
||||
|
||||
val response = apiClient.exchangeOAuthSession(sessionId, verifier)
|
||||
|
||||
if (response?.access_token != null) {
|
||||
LOG.info("SSO login successful")
|
||||
tokenManager.saveTokens(
|
||||
endurainTokenManager.saveTokens(
|
||||
response.access_token,
|
||||
response.refresh_token!!,
|
||||
response.expires_in!!,
|
||||
@@ -219,7 +219,7 @@ class EndurainSetupViewModel(application: Application) : AndroidViewModel(applic
|
||||
) {
|
||||
Thread {
|
||||
try {
|
||||
apiClient = EndurainApiClient(serverUrl, tokenManager)
|
||||
apiClient = EndurainApiClient(serverUrl, endurainTokenManager)
|
||||
val response = apiClient.login(username, password)
|
||||
|
||||
when {
|
||||
@@ -235,7 +235,7 @@ class EndurainSetupViewModel(application: Application) : AndroidViewModel(applic
|
||||
}
|
||||
response.access_token != null -> {
|
||||
LOG.info("Login successful")
|
||||
tokenManager.saveTokens(
|
||||
endurainTokenManager.saveTokens(
|
||||
response.access_token,
|
||||
response.refresh_token!!,
|
||||
response.expires_in!!,
|
||||
@@ -274,7 +274,7 @@ class EndurainSetupViewModel(application: Application) : AndroidViewModel(applic
|
||||
|
||||
if (response?.access_token != null) {
|
||||
LOG.info("MFA verification successful")
|
||||
tokenManager.saveTokens(
|
||||
endurainTokenManager.saveTokens(
|
||||
response.access_token,
|
||||
response.refresh_token!!,
|
||||
response.expires_in!!,
|
||||
@@ -302,12 +302,12 @@ class EndurainSetupViewModel(application: Application) : AndroidViewModel(applic
|
||||
if (::apiClient.isInitialized) {
|
||||
apiClient.logout()
|
||||
} else {
|
||||
tokenManager.clearTokens()
|
||||
endurainTokenManager.clearTokens()
|
||||
}
|
||||
callback(true)
|
||||
} catch (e: Exception) {
|
||||
LOG.error("Logout error", e)
|
||||
tokenManager.clearTokens() // Clear tokens anyway
|
||||
endurainTokenManager.clearTokens() // Clear tokens anyway
|
||||
callback(true)
|
||||
}
|
||||
}.start()
|
||||
|
||||
+2
-2
@@ -22,8 +22,8 @@ import nodomain.freeyourgadget.gadgetbridge.R
|
||||
|
||||
class EndurainSsoProviderDialog(
|
||||
private val context: Context,
|
||||
private val providers: List<IdentityProvider>,
|
||||
private val onProviderSelected: (IdentityProvider) -> Unit
|
||||
private val providers: List<EndurainIdentityProvider>,
|
||||
private val onProviderSelected: (EndurainIdentityProvider) -> Unit
|
||||
) {
|
||||
|
||||
fun show() {
|
||||
|
||||
+57
-22
@@ -40,8 +40,8 @@ class OnlineFitnessTrackersPreferencesActivity : AbstractSettingsActivityV2() {
|
||||
setPreferencesFromResource(R.xml.online_fitness_trackers_preferences, rootKey)
|
||||
|
||||
updateNetworkWarning()
|
||||
wireLoginPreference()
|
||||
wireLogoutPreference()
|
||||
wireLoginPreferences()
|
||||
wireLogoutPreferences()
|
||||
updateStatus()
|
||||
updateLogoutPreferenceVisibility()
|
||||
setupLoginResultListener()
|
||||
@@ -50,8 +50,8 @@ class OnlineFitnessTrackersPreferencesActivity : AbstractSettingsActivityV2() {
|
||||
val vm: EndurainSetupViewModel by viewModels()
|
||||
val server = GBApplication.getPrefs().preferences.getString("endurain_server", null)
|
||||
if (server != null) {
|
||||
if (vm.tokenManager.isAccessTokenExpired()) {
|
||||
vm.tokenManager.performTokenRefresh(server) {
|
||||
if (vm.endurainTokenManager.isAccessTokenExpired()) {
|
||||
vm.endurainTokenManager.performTokenRefresh(server) {
|
||||
activity?.runOnUiThread {
|
||||
updateStatus()
|
||||
updateLogoutPreferenceVisibility()
|
||||
@@ -77,6 +77,16 @@ class OnlineFitnessTrackersPreferencesActivity : AbstractSettingsActivityV2() {
|
||||
updateLogoutPreferenceVisibility()
|
||||
}
|
||||
}
|
||||
parentFragmentManager.setFragmentResultListener(
|
||||
"wanderer_login_result",
|
||||
this
|
||||
) { _, bundle ->
|
||||
val success = bundle.getBoolean("success", false)
|
||||
if (success) {
|
||||
updateStatus()
|
||||
updateLogoutPreferenceVisibility()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
@@ -90,22 +100,21 @@ class OnlineFitnessTrackersPreferencesActivity : AbstractSettingsActivityV2() {
|
||||
!GBApplication.hasInternetAccess()
|
||||
}
|
||||
|
||||
private fun wireLoginPreference() {
|
||||
findPreference<Preference>("pref_key_log_in")?.setOnPreferenceClickListener {
|
||||
private fun wireLoginPreferences() {
|
||||
findPreference<Preference>("pref_key_endurain_log_in")?.setOnPreferenceClickListener {
|
||||
EndurainSetupBottomSheet()
|
||||
.show(parentFragmentManager, "endurain_setup")
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
private fun wireLogoutPreference() {
|
||||
findPreference<Preference>("pref_key_log_out")?.setOnPreferenceClickListener {
|
||||
performLogout()
|
||||
findPreference<Preference>("pref_key_wanderer_log_in")?.setOnPreferenceClickListener {
|
||||
WandererSetupBottomSheet()
|
||||
.show(parentFragmentManager, "wanderer_setup")
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
private fun performLogout() {
|
||||
private fun wireLogoutPreferences() {
|
||||
findPreference<Preference>("pref_key_endurain_log_out")?.setOnPreferenceClickListener {
|
||||
vm.logout { success ->
|
||||
activity?.runOnUiThread {
|
||||
if (success) {
|
||||
@@ -117,27 +126,53 @@ class OnlineFitnessTrackersPreferencesActivity : AbstractSettingsActivityV2() {
|
||||
}
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
findPreference<Preference>("pref_key_wanderer_log_out")?.setOnPreferenceClickListener {
|
||||
WandererTokenManager(requireContext()).clearTokens()
|
||||
activity?.runOnUiThread {
|
||||
GB.toast(getString(R.string.endurain_logged_out_successfully), Toast.LENGTH_SHORT, GB.INFO)
|
||||
updateStatus()
|
||||
updateLogoutPreferenceVisibility()
|
||||
}
|
||||
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateLogoutPreferenceVisibility() {
|
||||
findPreference<Preference>("pref_key_log_out")?.isVisible = vm.tokenManager.isLoggedIn()
|
||||
findPreference<Preference>("pref_key_log_in")?.isVisible = !vm.tokenManager.isLoggedIn()
|
||||
findPreference<Preference>("pref_key_endurain_log_out")?.isVisible = vm.endurainTokenManager.isLoggedIn()
|
||||
findPreference<Preference>("pref_key_endurain_log_in")?.isVisible = !vm.endurainTokenManager.isLoggedIn()
|
||||
findPreference<Preference>("pref_key_wanderer_log_out")?.isVisible = WandererTokenManager(requireContext()).isLoggedIn()
|
||||
findPreference<Preference>("pref_key_wanderer_log_in")?.isVisible = !WandererTokenManager(requireContext()).isLoggedIn()
|
||||
}
|
||||
|
||||
private fun updateStatus() {
|
||||
val statusPref = findPreference<Preference>("pref_key_status")
|
||||
val server = GBApplication.getPrefs().preferences.getString("endurain_server", null)
|
||||
val tokenExpiresAt = DateTimeUtils.parseTimeStamp(vm.tokenManager.getRefreshTokenExpiresAt())
|
||||
val endurainStatusPref = findPreference<Preference>("pref_key_endurain_status")
|
||||
val endurainServer = GBApplication.getPrefs().preferences.getString("endurain_server", null)
|
||||
val endurainTokenExpiresAt = DateTimeUtils.parseTimeStamp(vm.endurainTokenManager.getRefreshTokenExpiresAt())
|
||||
val wandererStatusPref = findPreference<Preference>("pref_key_wanderer_status")
|
||||
val wandererServer = GBApplication.getPrefs().preferences.getString("wanderer_server", null)
|
||||
val wandererAPITokenAvailable = WandererTokenManager(requireContext()).isLoggedIn()
|
||||
|
||||
// Update Endurain preferences
|
||||
var summaryText = getString(R.string.endurain_not_logged_in_integration_disabled)
|
||||
if (vm.tokenManager.isLoggedIn() && server != null) {
|
||||
if (vm.endurainTokenManager.isLoggedIn() && endurainServer != null) {
|
||||
summaryText =
|
||||
getString(R.string.endurain_logged_in_refresh_token, server, tokenExpiresAt)
|
||||
getString(R.string.endurain_logged_in_refresh_token, endurainServer, endurainTokenExpiresAt)
|
||||
}
|
||||
if (vm.serverVersion != null) {
|
||||
summaryText += getString(R.string.endurain_server_version, vm.serverVersion)
|
||||
if (vm.endurainServerVersion != null) {
|
||||
summaryText += getString(R.string.endurain_server_version, vm.endurainServerVersion)
|
||||
}
|
||||
statusPref?.summary = summaryText
|
||||
endurainStatusPref?.summary = summaryText
|
||||
|
||||
// Update Wanderer preferences
|
||||
summaryText = getString(R.string.endurain_not_logged_in_integration_disabled)
|
||||
if (wandererAPITokenAvailable) {
|
||||
summaryText =
|
||||
getString(R.string.wanderer_logged_in).format(wandererServer)
|
||||
}
|
||||
wandererStatusPref?.summary = summaryText
|
||||
}
|
||||
}
|
||||
}
|
||||
+81
@@ -0,0 +1,81 @@
|
||||
/* 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 androidx.core.net.toUri
|
||||
import nodomain.freeyourgadget.gadgetbridge.util.InternetUtils
|
||||
import org.json.JSONObject
|
||||
import org.slf4j.LoggerFactory
|
||||
import java.io.File
|
||||
|
||||
class WandererApiClient(
|
||||
private val baseUrl: String,
|
||||
private val tokenManager: WandererTokenManager
|
||||
) {
|
||||
private val LOG = LoggerFactory.getLogger(WandererApiClient::class.java)
|
||||
|
||||
/**
|
||||
* Build headers with authentication tokens
|
||||
*/
|
||||
private fun buildHeaders(): MutableMap<String, String> {
|
||||
val headers: MutableMap<String, String> = mutableMapOf()
|
||||
|
||||
tokenManager.getAPIToken()?.let { token ->
|
||||
headers["Authorization"] = "Bearer $token"
|
||||
}
|
||||
|
||||
return headers
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload activity file (GPX)
|
||||
*/
|
||||
fun uploadActivity(file: File, callback: (String?, String?) -> Unit) {
|
||||
Thread {
|
||||
try {
|
||||
val uri = "$baseUrl/api/v1/trail/upload".toUri()
|
||||
val headers = buildHeaders()
|
||||
|
||||
InternetUtils.uploadBinaryFile(
|
||||
uri = uri,
|
||||
file = file,
|
||||
requestHeaders = headers,
|
||||
method = "PUT"
|
||||
) { success, statusCode, responseText ->
|
||||
if (success && statusCode != null && statusCode >= 200 && statusCode < 300 && responseText != null) {
|
||||
LOG.debug("Response $statusCode from Wanderer: $responseText")
|
||||
val jsonObject = JSONObject(responseText)
|
||||
callback(jsonObject.getString("id"), null)
|
||||
} else {
|
||||
if (responseText != null) {
|
||||
val jsonObject = JSONObject(responseText)
|
||||
val message = jsonObject.getString("message")
|
||||
LOG.error("Activity upload failed: $message")
|
||||
callback(null, message)
|
||||
} else {
|
||||
LOG.error("Activity upload failed")
|
||||
callback(null, null)
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
LOG.error("Activity upload error", e)
|
||||
callback(null, null)
|
||||
}
|
||||
}.start()
|
||||
}
|
||||
}
|
||||
+76
@@ -0,0 +1,76 @@
|
||||
/* 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.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.Button
|
||||
import android.widget.EditText
|
||||
import android.widget.Toast
|
||||
import androidx.core.content.edit
|
||||
import com.google.android.material.bottomsheet.BottomSheetDialogFragment
|
||||
import nodomain.freeyourgadget.gadgetbridge.GBApplication
|
||||
import nodomain.freeyourgadget.gadgetbridge.R
|
||||
import nodomain.freeyourgadget.gadgetbridge.util.GB
|
||||
import org.slf4j.LoggerFactory
|
||||
|
||||
class WandererSetupBottomSheet : BottomSheetDialogFragment() {
|
||||
private val LOG = LoggerFactory.getLogger(WandererSetupBottomSheet::class.java)
|
||||
private val prefs get() = GBApplication.getPrefs().preferences
|
||||
|
||||
override fun onCreateView(
|
||||
inflater: LayoutInflater,
|
||||
container: ViewGroup?,
|
||||
savedInstanceState: Bundle?
|
||||
): View = inflater.inflate(
|
||||
R.layout.wanderer_bottomsheet_setup_wizard,
|
||||
container,
|
||||
false
|
||||
)
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
|
||||
val serverNameInput = view.findViewById<EditText>(R.id.wanderer_server_name)
|
||||
val apiTokenInput = view.findViewById<EditText>(R.id.wanderer_api_token)
|
||||
val saveButton = view.findViewById<Button>(R.id.save_button)
|
||||
|
||||
serverNameInput.setText(prefs.getString("wanderer_server", ""))
|
||||
|
||||
saveButton.setOnClickListener {
|
||||
val serverName = serverNameInput.text.toString().trim()
|
||||
val apiToken = apiTokenInput.text.toString().trim()
|
||||
|
||||
if (serverName.isNotEmpty() && apiToken.startsWith("wanderer_key_")) {
|
||||
LOG.info("Saving Wanderer server ({}) and API token", serverName)
|
||||
prefs.edit { putString("wanderer_server", serverName) }
|
||||
WandererTokenManager(requireContext()).saveToken(apiToken)
|
||||
parentFragmentManager.setFragmentResult(
|
||||
"wanderer_login_result",
|
||||
Bundle().apply { putBoolean("success", true) }
|
||||
)
|
||||
dismiss()
|
||||
} else if (serverName.isNotEmpty() && apiToken.isNotEmpty()) {
|
||||
GB.toast(getString(R.string.wanderer_setup_api_token_error), Toast.LENGTH_SHORT, GB.WARN)
|
||||
} else {
|
||||
GB.toast(getString(R.string.wanderer_setup_missing_information), Toast.LENGTH_SHORT, GB.WARN)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+52
@@ -0,0 +1,52 @@
|
||||
/* 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 WandererTokenManager(context: Context) {
|
||||
private val masterKey = MasterKey.Builder(context)
|
||||
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
|
||||
.build()
|
||||
|
||||
private val tokenPreferences = EncryptedSharedPreferences.create(
|
||||
context,
|
||||
"wanderer_tokens",
|
||||
masterKey,
|
||||
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
|
||||
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
|
||||
)
|
||||
|
||||
fun saveToken(apiToken: String) {
|
||||
tokenPreferences.edit {
|
||||
putString("api_token", apiToken)
|
||||
}
|
||||
}
|
||||
|
||||
fun clearTokens() {
|
||||
tokenPreferences.edit { clear() }
|
||||
}
|
||||
|
||||
fun getAPIToken(): String? = tokenPreferences.getString("api_token", null)
|
||||
|
||||
fun isLoggedIn(): Boolean {
|
||||
return getAPIToken() != null && getAPIToken()?.startsWith("wanderer_key_") == true
|
||||
}
|
||||
}
|
||||
+62
-3
@@ -61,6 +61,8 @@ import nodomain.freeyourgadget.gadgetbridge.activities.ActivitySummariesChartFra
|
||||
import nodomain.freeyourgadget.gadgetbridge.activities.charts.DurationXLabelFormatter
|
||||
import nodomain.freeyourgadget.gadgetbridge.activities.endurain.EndurainApiClient
|
||||
import nodomain.freeyourgadget.gadgetbridge.activities.endurain.EndurainSetupViewModel
|
||||
import nodomain.freeyourgadget.gadgetbridge.activities.endurain.WandererApiClient
|
||||
import nodomain.freeyourgadget.gadgetbridge.activities.endurain.WandererTokenManager
|
||||
import nodomain.freeyourgadget.gadgetbridge.activities.fit.FitViewerActivity
|
||||
import nodomain.freeyourgadget.gadgetbridge.activities.workouts.charts.ChartDataRepository
|
||||
import nodomain.freeyourgadget.gadgetbridge.activities.workouts.charts.DefaultWorkoutCharts
|
||||
@@ -551,6 +553,11 @@ class WorkoutDetailsFragment : Fragment(), MenuProvider {
|
||||
true
|
||||
}
|
||||
|
||||
R.id.activity_action_upload_to_wanderer -> {
|
||||
uploadToWanderer()
|
||||
true
|
||||
}
|
||||
|
||||
R.id.activity_action_dev_inspect_file -> {
|
||||
val intent = Intent(requireContext(), FitViewerActivity::class.java).apply {
|
||||
putExtra(FitViewerActivity.EXTRA_PATH, File(workout.summary.rawDetailsPath).absolutePath)
|
||||
@@ -638,7 +645,8 @@ class WorkoutDetailsFragment : Fragment(), MenuProvider {
|
||||
|
||||
val endurainVm: EndurainSetupViewModel by viewModels()
|
||||
val server = GBApplication.getPrefs().preferences.getString("endurain_server", null)
|
||||
overflowMenu?.findItem(R.id.activity_action_upload_to_endurain)?.isVisible = hasGpx && server != null && endurainVm.tokenManager.isLoggedIn()
|
||||
overflowMenu?.findItem(R.id.activity_action_upload_to_endurain)?.isVisible = hasGpx && server != null && endurainVm.endurainTokenManager.isLoggedIn()
|
||||
overflowMenu?.findItem(R.id.activity_action_upload_to_wanderer)?.isVisible = hasGpx && server != null && WandererTokenManager(requireContext()).isLoggedIn()
|
||||
}
|
||||
|
||||
private fun takeSharedScreenshot() {
|
||||
@@ -765,8 +773,8 @@ class WorkoutDetailsFragment : Fragment(), MenuProvider {
|
||||
try {
|
||||
val endurainVm: EndurainSetupViewModel by viewModels()
|
||||
val serverUrl = GBApplication.getPrefs().preferences.getString("endurain_server", null)
|
||||
val apiClient = EndurainApiClient(serverUrl!!, endurainVm.tokenManager)
|
||||
endurainVm.tokenManager.performTokenRefresh(serverUrl) {
|
||||
val apiClient = EndurainApiClient(serverUrl!!, endurainVm.endurainTokenManager)
|
||||
endurainVm.endurainTokenManager.performTokenRefresh(serverUrl) {
|
||||
LOG.info("Uploading workout '{}' (type {}) to Endurain", workoutName, activityKind)
|
||||
apiClient.uploadActivity(activityFile) { newId ->
|
||||
if (newId != null) {
|
||||
@@ -799,6 +807,57 @@ class WorkoutDetailsFragment : Fragment(), MenuProvider {
|
||||
}
|
||||
}
|
||||
|
||||
private fun uploadToWanderer() {
|
||||
val workout = currentWorkout ?: return
|
||||
val workoutName = currentWorkout!!.summary.name
|
||||
val activityKind = ActivityKind.fromCode(currentWorkout!!.summary.activityKind)
|
||||
val activityTrackProvider = gbDevice.deviceCoordinator.getActivityTrackProvider(gbDevice, requireContext())
|
||||
|
||||
val activityFile = if (workout.summary.rawDetailsPath?.endsWith(".fit") == true) {
|
||||
FileUtils.tryFixPath(workout.summary.rawDetailsPath)
|
||||
} else {
|
||||
ActivitySummaryUtils.getShareableGpxFile(activityTrackProvider, workout.summary)
|
||||
}
|
||||
|
||||
if (activityFile == null) {
|
||||
GB.toast(getString(R.string.no_activity_track_in_activity_toast), Toast.LENGTH_LONG, GB.INFO)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
val serverUrl = GBApplication.getPrefs().preferences.getString("wanderer_server", null)
|
||||
val apiClient = WandererApiClient(serverUrl!!, WandererTokenManager(requireContext()))
|
||||
apiClient.uploadActivity(activityFile) { newId, message ->
|
||||
if (newId != null && message == null) {
|
||||
LOG.info("Uploaded GPX to Wanderer, ID $newId")
|
||||
// TODO: Update activity type on the server
|
||||
//apiClient.editActivity(newId, activityKind, workoutName)
|
||||
}
|
||||
activity?.runOnUiThread {
|
||||
if (newId != null && message == null)
|
||||
GB.toast(
|
||||
getString(R.string.wanderer_toast_successfully_uploaded),
|
||||
Toast.LENGTH_LONG,
|
||||
GB.INFO
|
||||
)
|
||||
else
|
||||
GB.toast(
|
||||
getString(R.string.wanderer_toast_upload_error, message),
|
||||
Toast.LENGTH_LONG,
|
||||
GB.INFO
|
||||
)
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
GB.toast(
|
||||
getString(R.string.wanderer_unable_to_upload_gpx_file_toast, e.localizedMessage),
|
||||
Toast.LENGTH_LONG,
|
||||
GB.ERROR,
|
||||
e
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun shareRawSummary(workout: Workout) {
|
||||
if (workout.summary.rawSummaryData == null) {
|
||||
GB.toast(requireContext(), "No raw summary in this activity", Toast.LENGTH_LONG, GB.WARN)
|
||||
|
||||
@@ -154,13 +154,14 @@ class InternetUtils {
|
||||
uri: Uri,
|
||||
file: File,
|
||||
requestHeaders: Map<String, String> = emptyMap(),
|
||||
method: String = "POST",
|
||||
allowInsecure: Boolean = false,
|
||||
onComplete: (success: Boolean, response: String?) -> Unit
|
||||
onComplete: (success: Boolean, statusCode: Int?, response: String?) -> Unit
|
||||
) {
|
||||
try {
|
||||
if (!file.exists() || !file.canRead()) {
|
||||
LOG.error("File does not exist or cannot be read: ${file.path}")
|
||||
onComplete(false, null)
|
||||
onComplete(false, null, null)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -179,7 +180,7 @@ class InternetUtils {
|
||||
val response = if (GBApplication.hasDirectInternetAccess()) {
|
||||
directBinaryRequest(
|
||||
uri = uri,
|
||||
method = "POST",
|
||||
method = method,
|
||||
requestHeaders = headers,
|
||||
body = multipartBodyBytes,
|
||||
allowInsecure = allowInsecure
|
||||
@@ -187,7 +188,7 @@ class InternetUtils {
|
||||
} else {
|
||||
InternetHelperSingleton.send(
|
||||
uri,
|
||||
HttpRequest.Method.POST,
|
||||
HttpRequest.Method.valueOf(method),
|
||||
headers,
|
||||
multipartBodyBytes,
|
||||
allowInsecure
|
||||
@@ -195,10 +196,10 @@ class InternetUtils {
|
||||
}
|
||||
|
||||
val responseText = response?.data?.bufferedReader()?.use { it.readText() }
|
||||
onComplete(response != null, responseText)
|
||||
onComplete(response != null, response?.statusCode, responseText)
|
||||
} catch (e: Exception) {
|
||||
LOG.error("Uploading $uri failed: ", e)
|
||||
onComplete(false, null)
|
||||
onComplete(false, null, null)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -20,12 +20,14 @@
|
||||
android:id="@+id/server_layout"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:hint="@string/endurain_setup_server">
|
||||
android:hint="@string/endurain_setup_server"
|
||||
app:placeholderText="https://endurain.example.com">
|
||||
|
||||
<com.google.android.material.textfield.TextInputEditText
|
||||
android:id="@+id/server_input"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
app:placeholderText="https://endurain.example.com"
|
||||
android:inputType="textUri" />
|
||||
</com.google.android.material.textfield.TextInputLayout>
|
||||
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
android:orientation="vertical"
|
||||
android:padding="16dp">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/title"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Wanderer setup"
|
||||
android:textAppearance="?attr/textAppearanceHeadlineSmall" />
|
||||
|
||||
<com.google.android.material.textfield.TextInputLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:hint="Server hostname"
|
||||
app:placeholderText="https://wanderer.example.com"
|
||||
android:layout_marginBottom="12dp">
|
||||
|
||||
<com.google.android.material.textfield.TextInputEditText
|
||||
android:id="@+id/wanderer_server_name"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:inputType="textUri"/>
|
||||
</com.google.android.material.textfield.TextInputLayout>
|
||||
|
||||
<com.google.android.material.textfield.TextInputLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:hint="API token"
|
||||
android:layout_marginBottom="24dp">
|
||||
|
||||
<com.google.android.material.textfield.TextInputEditText
|
||||
android:id="@+id/wanderer_api_token"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:inputType="textPassword"/>
|
||||
</com.google.android.material.textfield.TextInputLayout>
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:gravity="end"
|
||||
android:orientation="horizontal"
|
||||
android:paddingTop="12dp">
|
||||
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/save_button"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/save" />
|
||||
</LinearLayout>
|
||||
|
||||
</LinearLayout>
|
||||
@@ -56,6 +56,12 @@
|
||||
android:title="@string/activity_detail_upload_to_endurain"
|
||||
app:showAsAction="never" />
|
||||
|
||||
<item
|
||||
android:id="@+id/activity_action_upload_to_wanderer"
|
||||
android:icon="@drawable/ic_file_upload"
|
||||
android:title="Upload GPX to Wanderer"
|
||||
app:showAsAction="never" />
|
||||
|
||||
<item
|
||||
android:id="@+id/activity_action_dev_tools"
|
||||
android:icon="@drawable/ic_developer_mode"
|
||||
|
||||
@@ -4983,4 +4983,14 @@
|
||||
<string name="endurain_setup_mfa_code">MFA code</string>
|
||||
<string name="endurain_setup_next">Next</string>
|
||||
<string name="activity_detail_upload_to_endurain">Upload to Endurain</string>
|
||||
<string name="wanderer_logged_in">Logged in to %s</string>
|
||||
<string name="wanderer_setup_api_token_error">API token should start with wanderer_key_</string>
|
||||
<string name="wanderer_setup_missing_information">Please fill both fields</string>
|
||||
<string name="wanderer_toast_successfully_uploaded">Successfully uploaded to Wanderer</string>
|
||||
<string name="wanderer_toast_upload_error">Wanderer upload error: %1$s</string>
|
||||
<string name="wanderer_unable_to_upload_gpx_file_toast">Unable to upload GPX file to Wanderer: %1$s</string>
|
||||
<string name="pref_wanderer_status_title">Wanderer status</string>
|
||||
<string name="pref_wanderer_enter_api_token_title">Enter Wanderer API token</string>
|
||||
<string name="pref_wanderer_enter_api_token_summary">Tap here to enter the Wanderer server and API token</string>
|
||||
<string name="pref_wanderer_log_out_title">Log out of Wanderer</string>
|
||||
</resources>
|
||||
|
||||
@@ -10,18 +10,36 @@
|
||||
android:title="Endurain">
|
||||
<Preference
|
||||
android:icon="@drawable/ic_info"
|
||||
android:key="pref_key_status"
|
||||
android:key="pref_key_endurain_status"
|
||||
android:title="@string/pref_info_endurain_status_title"
|
||||
android:summary="Logged in on endurain.example.com" />
|
||||
<Preference
|
||||
android:icon="@drawable/ic_language"
|
||||
android:key="pref_key_log_in"
|
||||
android:key="pref_key_endurain_log_in"
|
||||
android:title="@string/pref_endurain_log_in_title"
|
||||
android:summary="@string/pref_endurain_log_in_summary" />
|
||||
<Preference
|
||||
android:icon="@drawable/ic_language"
|
||||
android:key="pref_key_log_out"
|
||||
android:key="pref_key_endurain_log_out"
|
||||
android:title="@string/pref_endurain_log_out_title"
|
||||
android:summary="@string/pref_endurain_log_out_summary" />
|
||||
</PreferenceCategory>
|
||||
<PreferenceCategory
|
||||
android:title="Wanderer">
|
||||
<Preference
|
||||
android:icon="@drawable/ic_info"
|
||||
android:key="pref_key_wanderer_status"
|
||||
android:title="@string/pref_wanderer_status_title"
|
||||
android:summary="Logged in on wanderer.example.com" />
|
||||
<Preference
|
||||
android:icon="@drawable/ic_language"
|
||||
android:key="pref_key_wanderer_log_in"
|
||||
android:title="@string/pref_wanderer_enter_api_token_title"
|
||||
android:summary="@string/pref_wanderer_enter_api_token_summary" />
|
||||
<Preference
|
||||
android:icon="@drawable/ic_language"
|
||||
android:key="pref_key_wanderer_log_out"
|
||||
android:title="@string/pref_wanderer_log_out_title"
|
||||
android:summary="@string/pref_endurain_log_out_summary" />
|
||||
</PreferenceCategory>
|
||||
</PreferenceScreen>
|
||||
Reference in New Issue
Block a user