From 28fc07d364a0daecee5603a33fc293a31b9aacf6 Mon Sep 17 00:00:00 2001 From: Hafiz Date: Tue, 1 Jul 2025 22:06:48 -0500 Subject: [PATCH 1/3] Handle revoked API token and displays success message - Logs the user out if the API token has been revoked elsewhere (also) - Copy Token - Show success message when password has been changed --- .../habitica/data/implementation/ApiClientImpl.kt | 9 +++++++++ .../fragments/preferences/AccountPreferenceFragment.kt | 4 ++++ .../fragments/preferences/ApiTokenBottomSheetFragment.kt | 6 ++++++ 3 files changed, 19 insertions(+) diff --git a/Habitica/src/main/java/com/habitrpg/android/habitica/data/implementation/ApiClientImpl.kt b/Habitica/src/main/java/com/habitrpg/android/habitica/data/implementation/ApiClientImpl.kt index fa126ae3a..93f11d03e 100644 --- a/Habitica/src/main/java/com/habitrpg/android/habitica/data/implementation/ApiClientImpl.kt +++ b/Habitica/src/main/java/com/habitrpg/android/habitica/data/implementation/ApiClientImpl.kt @@ -1,6 +1,7 @@ package com.habitrpg.android.habitica.data.implementation import android.content.Context +import android.util.Log import com.google.gson.JsonSyntaxException import com.habitrpg.android.habitica.BuildConfig import com.habitrpg.android.habitica.HabiticaBaseApplication @@ -155,6 +156,14 @@ class ApiClientImpl( // Modify cache control for 4xx or 5xx range - effectively "do not cache", preventing caching of 4xx and 5xx responses if (response.code in 400..599) { when (response.code) { + 401 -> { + val path = response.request.url.encodedPath + if (!path.contains("user/auth/update-password")) { + // token has been revoked/rotated elsewhere + HabiticaBaseApplication.logout(context) + } + return@addNetworkInterceptor response + } 404 -> { // The server is returning a 404 error, which means the requested resource was not found. // In this case - we want to actually cache the response, and handle it in the app diff --git a/Habitica/src/main/java/com/habitrpg/android/habitica/ui/fragments/preferences/AccountPreferenceFragment.kt b/Habitica/src/main/java/com/habitrpg/android/habitica/ui/fragments/preferences/AccountPreferenceFragment.kt index 80eddc841..82b954ba2 100644 --- a/Habitica/src/main/java/com/habitrpg/android/habitica/ui/fragments/preferences/AccountPreferenceFragment.kt +++ b/Habitica/src/main/java/com/habitrpg/android/habitica/ui/fragments/preferences/AccountPreferenceFragment.kt @@ -271,6 +271,10 @@ class AccountPreferenceFragment : ) response?.apiToken?.let { viewModel.saveTokens(it, user?.id ?: "") + (activity as? SnackbarActivity)?.showSnackbar( + content = getString(R.string.password_changed), + displayType = HabiticaSnackbar.SnackbarDisplayType.SUCCESS, + ) sheet.dismiss() } } diff --git a/Habitica/src/main/java/com/habitrpg/android/habitica/ui/fragments/preferences/ApiTokenBottomSheetFragment.kt b/Habitica/src/main/java/com/habitrpg/android/habitica/ui/fragments/preferences/ApiTokenBottomSheetFragment.kt index 7b090c557..df8827953 100644 --- a/Habitica/src/main/java/com/habitrpg/android/habitica/ui/fragments/preferences/ApiTokenBottomSheetFragment.kt +++ b/Habitica/src/main/java/com/habitrpg/android/habitica/ui/fragments/preferences/ApiTokenBottomSheetFragment.kt @@ -1,5 +1,7 @@ package com.habitrpg.android.habitica.ui.fragments.preferences +import android.content.ClipData +import android.content.ClipboardManager import android.content.res.Configuration import android.os.Bundle import android.view.LayoutInflater @@ -7,6 +9,7 @@ import android.view.View import android.view.ViewGroup import androidx.compose.ui.platform.ComposeView import androidx.core.content.ContextCompat +import androidx.core.content.ContextCompat.getSystemService import androidx.core.view.WindowInsetsControllerCompat import com.google.android.material.bottomsheet.BottomSheetDialogFragment import com.habitrpg.android.habitica.R @@ -61,6 +64,9 @@ class ApiTokenBottomSheetFragment : BottomSheetDialogFragment() { content = getString(R.string.copied_to_clipboard, copiedToken), displayType = HabiticaSnackbar.SnackbarDisplayType.SUCCESS, ) + val clipboard: ClipboardManager? = + context?.let { getSystemService(it, ClipboardManager::class.java) } + clipboard?.setPrimaryClip(ClipData.newPlainText("API Token", copiedToken)) dismiss() }) } From 8de67c8416d0a8f1d38dcb5fde96f9b6a570a29a Mon Sep 17 00:00:00 2001 From: Hafiz Date: Wed, 2 Jul 2025 11:59:28 -0500 Subject: [PATCH 2/3] Update UserAuthResponse to a data class Refactors UserAuthResponse to a data class for better data handling and immutability. --- .../habitica/models/auth/UserAuthResponse.kt | 21 ++++++++----------- 1 file changed, 9 insertions(+), 12 deletions(-) diff --git a/common/src/main/java/com/habitrpg/common/habitica/models/auth/UserAuthResponse.kt b/common/src/main/java/com/habitrpg/common/habitica/models/auth/UserAuthResponse.kt index dde393ead..dfc92ecaa 100644 --- a/common/src/main/java/com/habitrpg/common/habitica/models/auth/UserAuthResponse.kt +++ b/common/src/main/java/com/habitrpg/common/habitica/models/auth/UserAuthResponse.kt @@ -1,15 +1,12 @@ package com.habitrpg.common.habitica.models.auth -class UserAuthResponse { - // we need apiToken and token, as both are possible returns - var apiToken: String = "" - var token: String - get() { - return apiToken - } - set(value) { - apiToken = value - } - var newUser = false - var id: String = "" +data class UserAuthResponse( + val apiToken: String = "", + val id: String = "", + val newUser: Boolean = false +) { + + val token: String + get() = apiToken } + From 55132e0c57ecf352b9c64ccbe51d240ad10c5238 Mon Sep 17 00:00:00 2001 From: Hafiz Date: Wed, 2 Jul 2025 13:29:27 -0500 Subject: [PATCH 3/3] Handle specific 401 errors by logging out the user Adds logic to check the error message from the API response when a 401 error is received. If the error indicates invalid credentials or missing authentication headers, the user is automatically logged out. --- .../data/implementation/ApiClientImpl.kt | 28 +++++++++++++++++-- 1 file changed, 25 insertions(+), 3 deletions(-) diff --git a/Habitica/src/main/java/com/habitrpg/android/habitica/data/implementation/ApiClientImpl.kt b/Habitica/src/main/java/com/habitrpg/android/habitica/data/implementation/ApiClientImpl.kt index 93f11d03e..a6ca8de8c 100644 --- a/Habitica/src/main/java/com/habitrpg/android/habitica/data/implementation/ApiClientImpl.kt +++ b/Habitica/src/main/java/com/habitrpg/android/habitica/data/implementation/ApiClientImpl.kt @@ -56,6 +56,7 @@ import okhttp3.Cache import okhttp3.OkHttpClient import okhttp3.Request import okhttp3.logging.HttpLoggingInterceptor +import org.json.JSONObject import retrofit2.Converter import retrofit2.HttpException import retrofit2.Retrofit @@ -158,12 +159,33 @@ class ApiClientImpl( when (response.code) { 401 -> { val path = response.request.url.encodedPath - if (!path.contains("user/auth/update-password")) { - // token has been revoked/rotated elsewhere - HabiticaBaseApplication.logout(context) + if (!path.contains("/user/auth/update-password")) { + val bodyStr = try { + response.peekBody(1024).string() + } catch (_: Exception) { + "" + } + + val (errField, msgField) = try { + val obj = JSONObject(bodyStr) + obj.optString("error", "") to obj.optString("message", "") + } catch (_: Exception) { + "" to "" + } + + val shouldLogout = errField.equals("missingAuthHeaders", ignoreCase = true) + || errField.equals("invalidCredentials", ignoreCase = true) + || msgField.contains("invalidCredentials", ignoreCase = true) + || msgField.contains("Missing authentication headers", ignoreCase = true) + || msgField.contains("There is no account that uses those credentials", ignoreCase = true) + + if (shouldLogout) { + HabiticaBaseApplication.logout(context) + } } return@addNetworkInterceptor response } + 404 -> { // The server is returning a 404 error, which means the requested resource was not found. // In this case - we want to actually cache the response, and handle it in the app