Wanderer: Add basic integration and support GPX uploading

This commit is contained in:
Arjan Schrijver
2026-06-19 23:31:23 +02:00
parent cc529b40e2
commit 17c7137f12
15 changed files with 488 additions and 90 deletions
@@ -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,
@@ -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")
@@ -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()
@@ -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() {
@@ -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,54 +100,79 @@ 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() {
vm.logout { success ->
activity?.runOnUiThread {
if (success) {
GB.toast(getString(R.string.endurain_logged_out_successfully), Toast.LENGTH_SHORT, GB.INFO)
updateStatus()
updateLogoutPreferenceVisibility()
} else {
GB.toast(getString(R.string.endurain_logout_failed), Toast.LENGTH_SHORT, GB.WARN)
private fun wireLogoutPreferences() {
findPreference<Preference>("pref_key_endurain_log_out")?.setOnPreferenceClickListener {
vm.logout { success ->
activity?.runOnUiThread {
if (success) {
GB.toast(getString(R.string.endurain_logged_out_successfully), Toast.LENGTH_SHORT, GB.INFO)
updateStatus()
updateLogoutPreferenceVisibility()
} else {
GB.toast(getString(R.string.endurain_logout_failed), Toast.LENGTH_SHORT, GB.WARN)
}
}
}
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
}
}
}
@@ -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()
}
}
@@ -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)
}
}
}
}
@@ -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
}
}
@@ -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"
+10
View File
@@ -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>