git.iko.soy/parseable-android
on master help sign in
Fix login flow: case-insensitive URL scheme, proper 401 handling, bypass checkAuth (#6)

Three bugs prevented reliable login: 1. URL scheme detection was case-sensitive. Android keyboards can auto-capitalize input (e.g. "Https://"), causing the code to prepend a second scheme ("https://Https://server.com") and fail validation. Fixed with lowercase comparison. 2. The /api/v1/about call during login went through checkAuth(), which on 401 cleared the API client config and fired the global auth-error handler in MainActivity. This caused confusing "Session expired" navigation during the initial login attempt. Added verifyServer() to ParseableRepository that bypasses checkAuth for use during login. 3. Wrong credentials showed "Server responded but doesn't appear to be Parseable" because the /api/v1/liveness endpoint is public (no auth) so it always succeeded, and the 401 from /about was treated as a generic server error. Now 401 from /about shows "Invalid credentials." https://claude.ai/code/session_01M7KNBL1KVKVFkMf5hSPCg8 Co-authored-by: Claude <noreply@anthropic.com>

commit63ee88db3dd2a2c9f8820e791214142bd5021c03 authoriko <mail@iko.soy> dateSun 15 Feb 2026, 15:25 (8 months ago) parent7cd2159
3 files changed+82−12
app/src/main/java/com/parseable/android/data/repository/ParseableRepository.kt+14 app/src/main/java/com/parseable/android/ui/screens/login/LoginViewModel.kt+12−8 app/src/test/java/com/parseable/android/LoginViewModelTest.kt+56−4
app/src/main/java/com/parseable/android/data/repository/ParseableRepository.kt+14
@@ -66,6 +66,20 @@ class ParseableRepository @Inject constructor(
6666
6767 suspend fun testConnection(): ApiResult<String> = apiClient.checkLiveness()
6868
69+ /**
70+ * Verify this is a Parseable server and credentials are valid.
71+ * Does NOT trigger [checkAuth] so the global auth-error handler won't fire during login.
72+ */
73+ suspend fun verifyServer(): ApiResult<AboutInfo> {
74+ return when (val result = apiClient.getAbout()) {
75+ is ApiResult.Success -> {
76+ aboutCache = CacheEntry(result.data)
77+ result
78+ }
79+ is ApiResult.Error -> result
80+ }
81+ }
82+
6983 suspend fun getAbout(): ApiResult<AboutInfo> {
7084 aboutCache?.let { if (it.isValid(ABOUT_TTL_MS)) return ApiResult.Success(it.data) }
7185 return checkAuth(apiClient.getAbout()).also { result ->
app/src/main/java/com/parseable/android/ui/screens/login/LoginViewModel.kt+12−8
@@ -120,7 +120,8 @@ class LoginViewModel @Inject constructor(
120120 _state.update { it.copy(isLoading = true, error = null) }
121121
122122 var url = current.serverUrl.trim()
123− if (!url.startsWith("http://") && !url.startsWith("https://")) {
123+ val urlLower = url.lowercase()
124+ if (!urlLower.startsWith("http://") && !urlLower.startsWith("https://")) {
124125 url = if (current.allowInsecure) "http://$url" else "https://$url"
125126 }
126127
@@ -144,25 +145,28 @@ class LoginViewModel @Inject constructor(
144145 serverUrl = url,
145146 username = current.username.trim(),
146147 password = password,
147− useTls = url.startsWith("https"),
148+ useTls = url.lowercase().startsWith("https"),
148149 )
149150
150151 repository.configure(config)
151152
152153 when (val result = repository.testConnection()) {
153154 is ApiResult.Success -> {
154− // Verify this is actually a Parseable server by fetching /about
155− when (val aboutResult = repository.getAbout()) {
155+ // Verify this is actually a Parseable server and credentials are valid.
156+ // Uses verifyServer() to avoid triggering the global auth-error handler.
157+ when (val aboutResult = repository.verifyServer()) {
156158 is ApiResult.Success -> {
157159 settingsRepository.saveServerConfig(config)
158160 _state.update { it.copy(isLoading = false, loginSuccess = true, password = "") }
159161 }
160162 is ApiResult.Error -> {
163+ val errorMsg = if (aboutResult.isUnauthorized) {
164+ "Invalid credentials. Check your username and password."
165+ } else {
166+ "Server responded but doesn't appear to be Parseable: ${aboutResult.userMessage}"
167+ }
161168 _state.update {
162− it.copy(
163− isLoading = false,
164− error = "Server responded but doesn't appear to be Parseable: ${aboutResult.userMessage}",
165− )
169+ it.copy(isLoading = false, error = errorMsg)
166170 }
167171 }
168172 }
app/src/test/java/com/parseable/android/LoginViewModelTest.kt+56−4
@@ -64,7 +64,7 @@ class LoginViewModelTest {
6464 @Test
6565 fun `onLogin succeeds with valid credentials`() = runTest {
6666 coEvery { repository.testConnection() } returns ApiResult.Success("ok")
67− coEvery { repository.getAbout() } returns ApiResult.Success(AboutInfo(version = "1.0"))
67+ coEvery { repository.verifyServer() } returns ApiResult.Success(AboutInfo(version = "1.0"))
6868
6969 viewModel.onServerUrlChange("https://test.parseable.com")
7070 viewModel.onUsernameChange("admin")
@@ -95,7 +95,7 @@ class LoginViewModelTest {
9595 @Test
9696 fun `onLogin fails when about check fails`() = runTest {
9797 coEvery { repository.testConnection() } returns ApiResult.Success("ok")
98− coEvery { repository.getAbout() } returns ApiResult.Error("not parseable", 404)
98+ coEvery { repository.verifyServer() } returns ApiResult.Error("not parseable", 404)
9999
100100 viewModel.onServerUrlChange("https://test.com")
101101 viewModel.onUsernameChange("admin")
@@ -108,6 +108,23 @@ class LoginViewModelTest {
108108 assertTrue(state.error!!.contains("doesn't appear to be Parseable"))
109109 }
110110
111+ @Test
112+ fun `onLogin shows invalid credentials on 401`() = runTest {
113+ coEvery { repository.testConnection() } returns ApiResult.Success("ok")
114+ coEvery { repository.verifyServer() } returns ApiResult.Error("Unauthorized", 401)
115+
116+ viewModel.onServerUrlChange("https://test.parseable.com")
117+ viewModel.onUsernameChange("admin")
118+ viewModel.onPasswordChange("wrongpass")
119+ viewModel.onLogin()
120+
121+ val state = viewModel.state.value
122+ assertFalse(state.loginSuccess)
123+ assertNotNull(state.error)
124+ assertTrue(state.error!!.contains("Invalid credentials"))
125+ assertFalse(state.error!!.contains("doesn't appear to be Parseable"))
126+ }
127+
111128 @Test
112129 fun `onServerUrlChange clears error`() {
113130 viewModel.onLogin() // triggers error
@@ -147,7 +164,7 @@ class LoginViewModelTest {
147164 @Test
148165 fun `onLogin prepends https when not insecure`() = runTest {
149166 coEvery { repository.testConnection() } returns ApiResult.Success("ok")
150− coEvery { repository.getAbout() } returns ApiResult.Success(AboutInfo())
167+ coEvery { repository.verifyServer() } returns ApiResult.Success(AboutInfo())
151168
152169 viewModel.onServerUrlChange("test.parseable.com")
153170 viewModel.onUsernameChange("admin")
@@ -162,7 +179,7 @@ class LoginViewModelTest {
162179 @Test
163180 fun `onLogin prepends http when insecure`() = runTest {
164181 coEvery { repository.testConnection() } returns ApiResult.Success("ok")
165− coEvery { repository.getAbout() } returns ApiResult.Success(AboutInfo())
182+ coEvery { repository.verifyServer() } returns ApiResult.Success(AboutInfo())
166183
167184 viewModel.onServerUrlChange("test.parseable.com")
168185 viewModel.onAllowInsecureChange(true)
@@ -174,4 +191,39 @@ class LoginViewModelTest {
174191 repository.configure(match { it.serverUrl == "http://test.parseable.com" })
175192 }
176193 }
194+
195+ @Test
196+ fun `onLogin handles uppercase URL scheme`() = runTest {
197+ coEvery { repository.testConnection() } returns ApiResult.Success("ok")
198+ coEvery { repository.verifyServer() } returns ApiResult.Success(AboutInfo())
199+
200+ viewModel.onServerUrlChange("HTTPS://test.parseable.com")
201+ viewModel.onUsernameChange("admin")
202+ viewModel.onPasswordChange("pass")
203+ viewModel.onLogin()
204+
205+ coVerify {
206+ repository.configure(match {
207+ it.serverUrl == "HTTPS://test.parseable.com" && it.useTls
208+ })
209+ }
210+ }
211+
212+ @Test
213+ fun `onLogin handles mixed case URL scheme`() = runTest {
214+ coEvery { repository.testConnection() } returns ApiResult.Success("ok")
215+ coEvery { repository.verifyServer() } returns ApiResult.Success(AboutInfo())
216+
217+ viewModel.onServerUrlChange("Http://test.parseable.com")
218+ viewModel.onAllowInsecureChange(true)
219+ viewModel.onUsernameChange("admin")
220+ viewModel.onPasswordChange("pass")
221+ viewModel.onLogin()
222+
223+ coVerify {
224+ repository.configure(match {
225+ it.serverUrl == "Http://test.parseable.com" && !it.useTls
226+ })
227+ }
228+ }
177229}