chore(android-terminal): dev cleartext + readable HTTP error messages
Some checks failed
CI / ruff (push) Successful in 16s
CI / pytest (push) Failing after 2h32m6s
CI / validate (push) Successful in 31s
CI / dependency-scanning (push) Successful in 32s
CI / docs (push) Has been skipped
CI / deploy (push) Has been skipped

Two small dev-quality fixes shaken out by manual testing:

- Add a debug-only network_security_config.xml that whitelists 10.0.2.2,
  localhost and 127.0.0.1 for cleartext HTTP. Without this, the dev
  emulator can't reach the Python dev server because targetSdk 35
  forbids cleartext HTTP by default. Lives under app/src/debug/ so it
  ships only in debug APKs — release builds keep the platform default
  (no cleartext at all).

- TerminalViewModel.runAction now extracts the JSON {message: ...} field
  from HttpException response bodies instead of just showing "HTTP 400".
  Cashiers (and developers) now see "Staff PIN is required for this
  operation" / "Daily stamp limit of N reached" inline in the failed
  action sheet, surfacing the same business-error text the server
  already returns.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-06 23:17:44 +02:00
parent c1bb225228
commit c158d920d2
3 changed files with 50 additions and 1 deletions

View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<!-- Debug-only manifest overlay. Merged onto the main manifest by AGP for
debug builds; release builds skip this and keep the platform default
(no cleartext HTTP). -->
<application
android:networkSecurityConfig="@xml/network_security_config"
tools:replace="android:networkSecurityConfig" />
</manifest>

View File

@@ -0,0 +1,17 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Debug-only: allow cleartext HTTP to the dev server. The release build
(in app/src/main/AndroidManifest.xml) does NOT include this resource and
keeps the platform default which forbids cleartext entirely.
10.0.2.2 → host machine from a standard Android emulator
localhost → loopback within the emulator itself (rarely useful)
Add your LAN IP here too if you test against a hardware tablet on wifi.
-->
<network-security-config>
<domain-config cleartextTrafficPermitted="true">
<domain includeSubdomains="false">10.0.2.2</domain>
<domain includeSubdomains="false">localhost</domain>
<domain includeSubdomains="false">127.0.0.1</domain>
</domain-config>
</network-security-config>

View File

@@ -25,6 +25,7 @@ import lu.rewardflow.terminal.data.model.TransactionItem
import lu.rewardflow.terminal.data.network.NetworkMonitor import lu.rewardflow.terminal.data.network.NetworkMonitor
import lu.rewardflow.terminal.data.repository.CategoryRepository import lu.rewardflow.terminal.data.repository.CategoryRepository
import lu.rewardflow.terminal.data.repository.DeviceConfigRepository import lu.rewardflow.terminal.data.repository.DeviceConfigRepository
import retrofit2.HttpException
import javax.inject.Inject import javax.inject.Inject
/** /**
@@ -294,13 +295,34 @@ class TerminalViewModel @Inject constructor(
_state.value = _state.value.copy( _state.value = _state.value.copy(
actionInProgress = false, actionInProgress = false,
actionResult = ActionResult.Failure( actionResult = ActionResult.Failure(
result.exceptionOrNull()?.message ?: "Operation failed" readableErrorMessage(result.exceptionOrNull())
), ),
) )
} }
} }
} }
/** Extract a human-readable message from a Retrofit/HttpException.
*
* Retrofit's default `message` is the HTTP status text ("Bad Request"),
* which hides the JSON ``{message, error_code, details}`` body the
* backend returns. Read the body and surface its message instead so
* cashiers see "Staff PIN is required" or "Card not found" rather
* than the generic 400. */
private fun readableErrorMessage(t: Throwable?): String {
if (t is HttpException) {
val body = runCatching { t.response()?.errorBody()?.string() }.getOrNull()
if (!body.isNullOrBlank()) {
// Cheap JSON peek — avoid pulling in a full adapter for one field.
val match = "\"message\"\\s*:\\s*\"([^\"]+)\"".toRegex().find(body)
if (match != null) return match.groupValues[1]
return body.take(200)
}
return "HTTP ${t.code()}"
}
return t?.message ?: "Operation failed"
}
fun refreshCurrentCustomer() { fun refreshCurrentCustomer() {
val current = _state.value.customer ?: return val current = _state.value.customer ?: return
viewModelScope.launch { viewModelScope.launch {