diff --git a/AGENTS.md b/AGENTS.md
index 0dd3553..39861ce 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -57,6 +57,8 @@ All errors should use the `CodedError` interface. Error codes use the `NATIVEAPP
- New error types must implement `CodedError`
- Use `codedDescription` (not `message` or `localizedMessage`) in all user-facing error messages — this prepends `[NATIVEAPPTEMPLATE-XXXX]` for `CodedError` types
+- `errorDescription` is what the user sees, in the app language: the server's message as is (`2001`, and `2002` with devise_token_auth's message), "Something went wrong while communicating with the server. Please try again later." (`error_communication_failed`) for no response or an error status without a message, and "An unexpected error occurred." (`error_unexpected`) for internal errors (a response the app could not process, `AppError.Unexpected`). Errors made without a Context get these strings from `ErrorMessages` (installed in `Application.onCreate`)
+- Developer details go to `Throwable.message` and the log, with the code, where the error is made: `ApiException.logged()` (`adb logcat -s ApiError`)
## Testing
@@ -102,5 +104,6 @@ The UI is in English (`values/strings.xml`) and Japanese (`values-ja/strings.xml
- Use the terms of the API's `config/locales/ja.yml`: Shop = 店舗, Item Tag = アイテムタグ, idled / completed = 未完了 / 完了. Reuse the paid edition's Japanese for strings both editions share, so they read the same. Polite です/ます for sentences, no 。 on buttons and labels, full-width 「」():.
- nativeapptemplate-agent renames 店舗 and アイテムタグ as plain substrings, so write them in full: never ショップ / 店 / お店, never タグ or アイテム alone. `StringResourcesTranslationTest` fails otherwise. The Free edition has no organizations, so no string may mention 組織. `app_name` is `translatable="false"` so the English rename covers the product name.
- Don't build sentences from fragments: Japanese word order differs. Use one string with `%1$s` arguments; for links inside a sentence use `linkedFormat()` (`ui/common/LinkedFormat.kt`). Counts use `` (Japanese needs only `other`).
-- Keep untranslated: product names, URLs, `NATIVEAPPTEMPLATE-XXXX` codes and `CodedError` descriptions, time zone names, logs, the API's error messages (the server localizes them), and what the user typed.
+- User-facing error text is localized (string resources, as on iOS); developer details (raw messages, HTTP statuses) go to the log, never on screen. See Error Handling.
+- Keep untranslated: product names, URLs, `NATIVEAPPTEMPLATE-XXXX` codes, time zone names, logs, the API's error messages (the server localizes them), and what the user typed.
- There is no `localeFilters`/`resourceConfigurations` in Gradle; adding one without `ja` would strip the Japanese (`JapaneseResourcesPackagedTest` would fail).
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 9863bbe..d16060a 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -22,6 +22,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
- `HardcodedUiStringsTest` scans each file as a whole, so it also catches a literal on the line after `Text(`, and `updateMessage("...")` / `?: "..."` fallbacks
- Move agent instructions from `CLAUDE.md` to `AGENTS.md` so Claude Code, Codex, and other agents share one file. `CLAUDE.md` now only imports it (`@AGENTS.md`)
+### Fixed
+- Error messages are in the app language and say what the user can do, with the same wording as NativeAppTemplate-Free-iOS; the error codes are unchanged. A request that got no answer (no network, a timeout) or an error status without a message (e.g. a 502 page) shows `[NATIVEAPPTEMPLATE-2002]` "Something went wrong while communicating with the server. Please try again later." (サーバーとの通信中に問題が発生しました。しばらくしてからもう一度お試しください。) instead of the English system text (e.g. "Processing error: Unable to resolve host ..." or "Processing error: HTTP 502"). A response the app could not process (`2002`) and `1001` show "An unexpected error occurred." (予期しないエラーが発生しました。). The server's own messages are shown as is, without the developer suffix " [Status: 422]" (`2001`) or the "Processing error: " prefix (sign-in's "Invalid login credentials"). The details are logged with the code instead (`adb logcat -s ApiError`)
+
## [3.2.6] - 2026-05-24
### Fixed
diff --git a/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/NativeAppTemplateApplication.kt b/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/NativeAppTemplateApplication.kt
index e006ae9..78fbe2e 100644
--- a/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/NativeAppTemplateApplication.kt
+++ b/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/NativeAppTemplateApplication.kt
@@ -1,6 +1,7 @@
package com.nativeapptemplate.nativeapptemplatefree
import android.app.Application
+import com.nativeapptemplate.nativeapptemplatefree.common.errors.ErrorMessages
import com.nativeapptemplate.nativeapptemplatefree.utils.ProfileVerifierLogger
import dagger.hilt.android.HiltAndroidApp
import javax.inject.Inject
@@ -11,6 +12,8 @@ class NativeAppTemplateApplication : Application() {
lateinit var profileVerifierLogger: ProfileVerifierLogger
override fun onCreate() {
+ // Before anything that may make an error (super.onCreate() runs Hilt's injection).
+ ErrorMessages.install(this)
super.onCreate()
profileVerifierLogger()
}
diff --git a/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/common/errors/ApiException.kt b/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/common/errors/ApiException.kt
index 1295eeb..eb2cfa4 100644
--- a/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/common/errors/ApiException.kt
+++ b/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/common/errors/ApiException.kt
@@ -1,5 +1,7 @@
package com.nativeapptemplate.nativeapptemplatefree.common.errors
+import android.util.Log
+
/**
* @property httpStatusCode The HTTP status of the failed response, or null when the request
* never got a response (e.g. no network) or the status is unknown.
@@ -20,15 +22,50 @@ sealed class ApiException(
httpStatusCode: Int? = null,
) : ApiException("$apiMessage [Status: $code]", httpStatusCode = httpStatusCode) {
override val errorCode: String = "NATIVEAPPTEMPLATE-2001"
- override val errorDescription: String = "$apiMessage [Status: $code]"
+
+ /** The API's message as is (the server localizes it); the status is only in [message], for the log. */
+ override val errorDescription: String = apiMessage
+ }
+
+ /** What the user sees for an [UnprocessableError]. */
+ sealed interface UserMessage {
+ /** The server's own message (devise_token_auth's), already localized: shown as is. */
+ data class Server(val text: String) : UserMessage
+
+ /** No usable answer: no network, a timeout, or an error status without a message. */
+ data object CommunicationFailure : UserMessage
+
+ /** A response the app could not process: a bug, nothing the user can act on. */
+ data object Unexpected : UserMessage
}
+ /**
+ * No response, an error response that could not be deserialized, or a response the app could not
+ * process. [rawMessage] is the developer detail, in [message] for the log; the user sees [userMessage].
+ */
class UnprocessableError(
val rawMessage: String,
cause: Throwable? = null,
httpStatusCode: Int? = null,
+ val userMessage: UserMessage = UserMessage.Unexpected,
) : ApiException("Not processable error($rawMessage).", cause, httpStatusCode) {
override val errorCode: String = "NATIVEAPPTEMPLATE-2002"
- override val errorDescription: String = "Processing error: $rawMessage"
+ override val errorDescription: String
+ get() = when (userMessage) {
+ is UserMessage.Server -> userMessage.text
+ UserMessage.CommunicationFailure -> ErrorMessages.communicationFailed
+ UserMessage.Unexpected -> ErrorMessages.unexpected
+ }
}
}
+
+/**
+ * Logs this error's code and developer detail (the raw message, the HTTP status), which the user
+ * never sees (AGENTS.md, Error Handling). Call it where the error is made. Returns this error.
+ */
+fun E.logged(): E {
+ Log.w(API_ERROR_LOG_TAG, "[$errorCode] $message (HTTP ${httpStatusCode ?: "no response"})", cause)
+ return this
+}
+
+private const val API_ERROR_LOG_TAG = "ApiError"
diff --git a/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/common/errors/AppError.kt b/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/common/errors/AppError.kt
index ed2f5ff..0bd5820 100644
--- a/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/common/errors/AppError.kt
+++ b/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/common/errors/AppError.kt
@@ -2,11 +2,14 @@ package com.nativeapptemplate.nativeapptemplatefree.common.errors
sealed class AppError(
override val errorCode: String,
- override val errorDescription: String,
-) : Exception(errorDescription), CodedError {
+ message: String,
+) : Exception(message), CodedError {
+ /** The user sees the generic [ErrorMessages.unexpected]; [detail] is only in [message], for the log. */
class Unexpected(detail: String? = null) : AppError(
errorCode = "NATIVEAPPTEMPLATE-1001",
- errorDescription = "Unexpected error" + if (detail != null) ": $detail" else "",
- )
+ message = "Unexpected error" + if (detail != null) ": $detail" else "",
+ ) {
+ override val errorDescription: String get() = ErrorMessages.unexpected
+ }
}
diff --git a/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/common/errors/ErrorMessages.kt b/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/common/errors/ErrorMessages.kt
new file mode 100644
index 0000000..4029f70
--- /dev/null
+++ b/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/common/errors/ErrorMessages.kt
@@ -0,0 +1,33 @@
+package com.nativeapptemplate.nativeapptemplatefree.common.errors
+
+import android.content.Context
+import androidx.annotation.StringRes
+import com.nativeapptemplate.nativeapptemplatefree.R
+
+/**
+ * The user-facing messages of [CodedError]s that are not the server's own: string resources in the
+ * app language (values/ and values-ja/), worded as on iOS.
+ *
+ * Most errors are made where there is no Context (the network layer), so the application installs
+ * its Context here once, first thing in `Application.onCreate`. A message is looked up when it is
+ * read, so it follows the app language. Developer details (raw messages, HTTP statuses) are never
+ * part of them: they are logged where the error is made, and kept in the exception's message.
+ */
+object ErrorMessages {
+ @Volatile
+ private var context: Context? = null
+
+ fun install(context: Context) {
+ this.context = context.applicationContext
+ }
+
+ /** No usable answer from the server: no network, a timeout, or an error status without a message. */
+ val communicationFailed: String get() = string(R.string.error_communication_failed)
+
+ /** An internal error (a response the app could not process, a bug): nothing the user can act on. */
+ val unexpected: String get() = string(R.string.error_unexpected)
+
+ private fun string(@StringRes id: Int): String = checkNotNull(context) {
+ "ErrorMessages.install was not called (Application.onCreate)"
+ }.getString(id)
+}
diff --git a/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/data/login/LoginRepositoryImpl.kt b/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/data/login/LoginRepositoryImpl.kt
index 8dba178..418db39 100644
--- a/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/data/login/LoginRepositoryImpl.kt
+++ b/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/data/login/LoginRepositoryImpl.kt
@@ -2,6 +2,7 @@ package com.nativeapptemplate.nativeapptemplatefree.data.login
import androidx.annotation.VisibleForTesting
import com.nativeapptemplate.nativeapptemplatefree.common.errors.ApiException
+import com.nativeapptemplate.nativeapptemplatefree.common.errors.logged
import com.nativeapptemplate.nativeapptemplatefree.datastore.NativeAppTemplatePreferencesDataSource
import com.nativeapptemplate.nativeapptemplatefree.model.*
import com.nativeapptemplate.nativeapptemplatefree.model.LoggedInShopkeeper
@@ -148,7 +149,7 @@ private fun LoggedInShopkeeper.requireCompleteSession(): LoggedInShopkeeper {
.map { (name, _) -> name }
if (missing.isNotEmpty()) {
- throw ApiException.UnprocessableError(rawMessage = "Incomplete sign-in response: missing ${missing.joinToString()}")
+ throw ApiException.UnprocessableError(rawMessage = "Incomplete sign-in response: missing ${missing.joinToString()}").logged()
}
return this
}
diff --git a/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/network/ApiResponseExtensions.kt b/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/network/ApiResponseExtensions.kt
index e3ff6ad..4ed7265 100644
--- a/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/network/ApiResponseExtensions.kt
+++ b/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/network/ApiResponseExtensions.kt
@@ -1,6 +1,7 @@
package com.nativeapptemplate.nativeapptemplatefree.network
import com.nativeapptemplate.nativeapptemplatefree.common.errors.ApiException
+import com.nativeapptemplate.nativeapptemplatefree.common.errors.logged
import com.nativeapptemplate.nativeapptemplatefree.model.NativeAppTemplateApiError
import com.skydoves.sandwich.ApiResponse
import com.skydoves.sandwich.message
@@ -88,7 +89,7 @@ internal fun throwApiErrorFor(
errorMessage: String,
): Nothing {
val httpError = response as? ApiResponse.Failure.Error
- ?: throw ApiException.UnprocessableError(rawMessage = errorMessage)
+ ?: throw noResponseError(response, errorMessage)
val httpStatusCode = httpError.statusCode.code
// The body can be read only once.
@@ -112,13 +113,34 @@ internal fun throwApiErrorFor(
code = nativeAppTemplateApiError.code,
apiMessage = nativeAppTemplateApiError.message,
httpStatusCode = httpStatusCode,
- )
+ ).logged()
}
+ // devise_token_auth's message is the server's, already localized; without one, the user can only retry.
+ val deviseMessage = errorBody?.let(::deviseErrorMessage)
throw ApiException.UnprocessableError(
- rawMessage = errorBody?.let(::deviseErrorMessage) ?: "HTTP $httpStatusCode",
+ rawMessage = deviseMessage ?: "HTTP $httpStatusCode",
httpStatusCode = httpStatusCode,
- )
+ userMessage = deviseMessage?.let { ApiException.UserMessage.Server(it) }
+ ?: ApiException.UserMessage.CommunicationFailure,
+ ).logged()
+}
+
+/**
+ * No HTTP response. An [IOException] (no network, a timeout) is a communication failure; anything
+ * else (e.g. a body the converter could not decode) is a response the app could not process.
+ */
+private fun noResponseError(response: ApiResponse<*>, errorMessage: String): ApiException.UnprocessableError {
+ val cause = (response as? ApiResponse.Failure.Exception)?.throwable
+ return ApiException.UnprocessableError(
+ rawMessage = errorMessage,
+ cause = cause,
+ userMessage = if (cause is IOException) {
+ ApiException.UserMessage.CommunicationFailure
+ } else {
+ ApiException.UserMessage.Unexpected
+ },
+ ).logged()
}
/**
diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml
index b9522da..a96fa88 100644
--- a/app/src/main/res/values-ja/strings.xml
+++ b/app/src/main/res/values-ja/strings.xml
@@ -188,4 +188,6 @@
- %1$d文字以上で入力してください。
+ サーバーとの通信中に問題が発生しました。しばらくしてからもう一度お試しください。
+ 予期しないエラーが発生しました。
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 62980b4..d266638 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -185,4 +185,6 @@
- %1$d character minimum.
- %1$d characters minimum.
+ Something went wrong while communicating with the server. Please try again later.
+ An unexpected error occurred.
diff --git a/app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/common/errors/CodedErrorTest.kt b/app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/common/errors/CodedErrorTest.kt
index cc9bc69..4813c2a 100644
--- a/app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/common/errors/CodedErrorTest.kt
+++ b/app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/common/errors/CodedErrorTest.kt
@@ -2,7 +2,12 @@ package com.nativeapptemplate.nativeapptemplatefree.common.errors
import org.junit.Assert.assertEquals
import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.RobolectricTestRunner
+import org.robolectric.annotation.Config
+/** Robolectric for the string resources of the messages (values/ and values-ja/). */
+@RunWith(RobolectricTestRunner::class)
class CodedErrorTest {
@Test
@@ -11,10 +16,13 @@ class CodedErrorTest {
assertEquals("NATIVEAPPTEMPLATE-2001", error.errorCode)
}
+ // The user sees the API's error_message as is (the server localizes it); the HTTP status is a
+ // developer detail, kept in the exception's message for the log (AGENTS.md, Error Handling).
@Test
- fun apiError_formattedDescription_includesCodeAndMessage() {
+ fun apiError_formattedDescription_isTheServerMessageWithoutTheStatus() {
val error = ApiException.ApiError(code = 422, apiMessage = "Validation failed")
- assertEquals("[NATIVEAPPTEMPLATE-2001] Validation failed [Status: 422]", error.formattedDescription)
+ assertEquals("[NATIVEAPPTEMPLATE-2001] Validation failed", error.formattedDescription)
+ assertEquals("Validation failed [Status: 422]", error.message)
}
@Test
@@ -23,29 +31,80 @@ class CodedErrorTest {
assertEquals("NATIVEAPPTEMPLATE-2002", error.errorCode)
}
+ // A response the app could not process is a bug, not something the user can act on: the generic
+ // values/strings.xml error_unexpected, "An unexpected error occurred." (the iOS wording). The raw
+ // detail stays in the exception's message, for the log.
@Test
- fun unprocessableError_formattedDescription_includesCodeAndMessage() {
- val error = ApiException.UnprocessableError(rawMessage = "timeout")
- assertEquals("[NATIVEAPPTEMPLATE-2002] Processing error: timeout", error.formattedDescription)
+ fun unprocessableError_byDefault_showsTheUnexpectedErrorMessage() {
+ val error = ApiException.UnprocessableError(rawMessage = "Incomplete account data")
+ assertEquals("[NATIVEAPPTEMPLATE-2002] An unexpected error occurred.", error.formattedDescription)
+ assertEquals("Not processable error(Incomplete account data).", error.message)
+ }
+
+ // No usable answer from the server: values/strings.xml error_communication_failed.
+ @Test
+ fun unprocessableError_communicationFailure_showsTheCommunicationMessage() {
+ val error = ApiException.UnprocessableError(
+ rawMessage = "Unable to resolve host",
+ userMessage = ApiException.UserMessage.CommunicationFailure,
+ )
+ assertEquals(
+ "[NATIVEAPPTEMPLATE-2002] Something went wrong while communicating with the server. Please try again later.",
+ error.formattedDescription,
+ )
+ }
+
+ // values-ja/strings.xml error_communication_failed, the wording shared with iOS.
+ @Test
+ @Config(qualifiers = "ja")
+ fun unprocessableError_communicationFailure_isInJapanese() {
+ val error = ApiException.UnprocessableError(
+ rawMessage = "Unable to resolve host",
+ userMessage = ApiException.UserMessage.CommunicationFailure,
+ )
+ assertEquals(
+ "[NATIVEAPPTEMPLATE-2002] サーバーとの通信中に問題が発生しました。しばらくしてからもう一度お試しください。",
+ error.formattedDescription,
+ )
+ }
+
+ // The server's own message (devise_token_auth's), already localized, shown as is.
+ @Test
+ fun unprocessableError_serverMessage_showsItAsIs() {
+ val error = ApiException.UnprocessableError(
+ rawMessage = "Invalid login credentials. Please try again.",
+ httpStatusCode = 401,
+ userMessage = ApiException.UserMessage.Server("Invalid login credentials. Please try again."),
+ )
+ assertEquals("[NATIVEAPPTEMPLATE-2002] Invalid login credentials. Please try again.", error.formattedDescription)
}
@Test
fun appError_unexpected_hasCorrectCode() {
val error = AppError.Unexpected()
assertEquals("NATIVEAPPTEMPLATE-1001", error.errorCode)
- assertEquals("[NATIVEAPPTEMPLATE-1001] Unexpected error", error.formattedDescription)
+ assertEquals("[NATIVEAPPTEMPLATE-1001] An unexpected error occurred.", error.formattedDescription)
}
+ // The detail is for developers: in the exception's message (the log), not on screen.
@Test
- fun appError_unexpected_withDetail_includesDetail() {
+ fun appError_unexpected_withDetail_keepsTheDetailOutOfTheDescription() {
val error = AppError.Unexpected(detail = "null pointer")
- assertEquals("[NATIVEAPPTEMPLATE-1001] Unexpected error: null pointer", error.formattedDescription)
+ assertEquals("[NATIVEAPPTEMPLATE-1001] An unexpected error occurred.", error.formattedDescription)
+ assertEquals("Unexpected error: null pointer", error.message)
+ }
+
+ // values-ja/strings.xml error_unexpected, the wording shared with iOS.
+ @Test
+ @Config(qualifiers = "ja")
+ fun appError_unexpected_isInJapanese() {
+ assertEquals("[NATIVEAPPTEMPLATE-1001] 予期しないエラーが発生しました。", AppError.Unexpected().formattedDescription)
}
@Test
fun codedDescription_forCodedError_returnsFormattedDescription() {
val error: Throwable = ApiException.ApiError(code = 500, apiMessage = "Server error")
- assertEquals("[NATIVEAPPTEMPLATE-2001] Server error [Status: 500]", error.codedDescription)
+ assertEquals("[NATIVEAPPTEMPLATE-2001] Server error", error.codedDescription)
}
@Test
diff --git a/app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/data/RepositoryRequestsTest.kt b/app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/data/RepositoryRequestsTest.kt
index 50fb871..2a25f42 100644
--- a/app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/data/RepositoryRequestsTest.kt
+++ b/app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/data/RepositoryRequestsTest.kt
@@ -41,6 +41,8 @@ import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.RobolectricTestRunner
import retrofit2.Retrofit
import java.net.InetAddress
import java.net.InetSocketAddress
@@ -55,6 +57,8 @@ import kotlin.test.assertFailsWith
* `GET {account_id}/api/v1/shopkeeper/shops/{shop_id}/item_tags`); the account id is the one saved
* at sign-in (UserPreferences.accountId).
*/
+// Robolectric for android.util.Log: API errors are logged where they are made.
+@RunWith(RobolectricTestRunner::class)
class RepositoryRequestsTest {
private class Received(val method: String, val pathAndQuery: String, val headers: Map, val body: String)
@@ -361,8 +365,9 @@ class RepositoryRequestsTest {
assertEquals("Name has already been taken", exception.apiMessage)
assertEquals(422, exception.httpStatusCode)
- // ApiException.ApiError: "[NATIVEAPPTEMPLATE-2001] " + "$apiMessage [Status: $code]".
- assertEquals("[NATIVEAPPTEMPLATE-2001] Name has already been taken [Status: 422]", exception.formattedDescription)
+ // ApiException.ApiError: the user sees "[NATIVEAPPTEMPLATE-2001] $apiMessage"; the status is
+ // only in the message, for the log.
+ assertEquals("[NATIVEAPPTEMPLATE-2001] Name has already been taken", exception.formattedDescription)
}
@Test
diff --git a/app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/data/login/LoginRepositoryImplTest.kt b/app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/data/login/LoginRepositoryImplTest.kt
index 64cf3c7..5f8f44d 100644
--- a/app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/data/login/LoginRepositoryImplTest.kt
+++ b/app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/data/login/LoginRepositoryImplTest.kt
@@ -3,6 +3,7 @@ package com.nativeapptemplate.nativeapptemplatefree.data.login
import androidx.datastore.core.DataStore
import com.nativeapptemplate.nativeapptemplatefree.UserPreferences
import com.nativeapptemplate.nativeapptemplatefree.common.errors.ApiException
+import com.nativeapptemplate.nativeapptemplatefree.common.errors.codedDescription
import com.nativeapptemplate.nativeapptemplatefree.datastore.NativeAppTemplatePreferencesDataSource
import com.nativeapptemplate.nativeapptemplatefree.datastoreTest.InMemoryDataStore
import com.nativeapptemplate.nativeapptemplatefree.model.Attributes
@@ -20,9 +21,13 @@ import okhttp3.MediaType.Companion.toMediaType
import okhttp3.ResponseBody.Companion.toResponseBody
import org.junit.Assert.assertEquals
import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.RobolectricTestRunner
import retrofit2.Response
import kotlin.test.assertFailsWith
+/** Robolectric for android.util.Log (errors are logged where they are made) and the string resources. */
+@RunWith(RobolectricTestRunner::class)
class LoginRepositoryImplTest {
/** Every value written to the store, in order: one entry per DataStore write. */
@@ -62,9 +67,13 @@ class LoginRepositoryImplTest {
fun login_withoutAToken_failsTheFlowWithACodedError() = runTest {
// Thrown inside the flow so the ViewModel's .catch shows it, instead of crashing when the
// missing value is later dereferenced while saving the session.
- assertFailsWith {
+ val exception = assertFailsWith {
repository(sessionWith(token = null)).login(Login(email = "john@example.com", password = "password")).first()
}
+ // A response missing what the app needs is an internal error: values/strings.xml error_unexpected.
+ // What is missing is a developer detail, in the message for the log.
+ assertEquals("[NATIVEAPPTEMPLATE-2002] An unexpected error occurred.", exception.codedDescription)
+ assertEquals("Not processable error(Incomplete sign-in response: missing token).", exception.message)
}
@Test
diff --git a/app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/network/ApiResponseExtensionsTest.kt b/app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/network/ApiResponseExtensionsTest.kt
index 4ce4f02..e468ee1 100644
--- a/app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/network/ApiResponseExtensionsTest.kt
+++ b/app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/network/ApiResponseExtensionsTest.kt
@@ -1,6 +1,7 @@
package com.nativeapptemplate.nativeapptemplatefree.network
import com.nativeapptemplate.nativeapptemplatefree.common.errors.ApiException
+import com.nativeapptemplate.nativeapptemplatefree.common.errors.codedDescription
import com.skydoves.sandwich.ApiResponse
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flow
@@ -12,10 +13,14 @@ import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.RobolectricTestRunner
import retrofit2.Response
import kotlin.test.assertFailsWith
import kotlin.test.assertIs
+/** Robolectric for the string resources of the messages and for android.util.Log. */
+@RunWith(RobolectricTestRunner::class)
class ApiResponseExtensionsTest {
@Test
@@ -186,7 +191,7 @@ class ApiResponseExtensionsTest {
@Test
fun throwApiError_apiErrorBody_takesTheCodeFromTheBodyAndTheStatusFromTheResponse() {
- // The two can differ; ApiError.code (shown as "[Status: code]") is the body's.
+ // The two can differ; ApiError.code (logged as "[Status: code]") is the body's.
val response = httpError(403, """{"code":1001,"error_message":"Shop limit reached"}""")
val exception = assertFailsWith {
@@ -195,7 +200,74 @@ class ApiResponseExtensionsTest {
assertEquals(1001, exception.code)
assertEquals(403, exception.httpStatusCode)
- // ApiException.ApiError: "[NATIVEAPPTEMPLATE-2001] " + "$apiMessage [Status: $code]".
- assertEquals("[NATIVEAPPTEMPLATE-2001] Shop limit reached [Status: 1001]", exception.formattedDescription)
+ // ApiException.ApiError: the user sees "[NATIVEAPPTEMPLATE-2001] $apiMessage"; the status is in
+ // the message, for the log.
+ assertEquals("[NATIVEAPPTEMPLATE-2001] Shop limit reached", exception.formattedDescription)
+ assertEquals("Shop limit reached [Status: 1001]", exception.message)
+ }
+
+ // What the user sees (codedDescription). The generic messages are values/strings.xml's
+ // error_communication_failed and error_unexpected, the wording shared with iOS.
+
+ private val communicationFailed =
+ "[NATIVEAPPTEMPLATE-2002] Something went wrong while communicating with the server. Please try again later."
+
+ @Test
+ fun noResponse_showsTheCommunicationMessage() {
+ // No network or a timeout: Sandwich wraps OkHttp's IOException.
+ val response: ApiResponse = ApiResponse.Failure.Exception(java.net.UnknownHostException("Unable to resolve host"))
+
+ val exception = assertFailsWith {
+ throwApiError(response, "Unable to resolve host")
+ }
+
+ assertEquals(communicationFailed, exception.codedDescription)
+ // The detail stays in the exception's message, for the log.
+ assertEquals("Not processable error(Unable to resolve host).", exception.message)
+ }
+
+ @Test
+ fun errorStatusWithoutAMessage_showsTheCommunicationMessage() = runTest {
+ val response = httpError(502, "Bad Gateway")
+
+ val exception = assertFailsWith {
+ flow { emitApiResponse(response) }.first()
+ }
+
+ assertEquals(communicationFailed, exception.codedDescription)
+ }
+
+ @Test
+ fun responseTheAppCannotProcess_showsTheUnexpectedErrorMessage() {
+ // Not an IOException: e.g. a success body the converter could not decode.
+ val response: ApiResponse = ApiResponse.Failure.Exception(IllegalArgumentException("Unexpected JSON token"))
+
+ val exception = assertFailsWith {
+ throwApiError(response, "Unexpected JSON token")
+ }
+
+ assertEquals("[NATIVEAPPTEMPLATE-2002] An unexpected error occurred.", exception.codedDescription)
+ }
+
+ @Test
+ fun deviseErrorBody_showsTheServerMessageAsIs() = runTest {
+ val response = httpError(401, """{"success":false,"errors":["Invalid login credentials. Please try again."]}""")
+
+ val exception = assertFailsWith {
+ flow { emitApiResponse(response) }.first()
+ }
+
+ assertEquals("[NATIVEAPPTEMPLATE-2002] Invalid login credentials. Please try again.", exception.codedDescription)
+ }
+
+ @Test
+ fun apiErrorBody_showsTheServerMessageWithoutTheStatus() {
+ val response = httpError(500, """{"code":500,"error_message":"Something broke on our side."}""")
+
+ val exception = assertFailsWith {
+ throwApiError(response, "server error")
+ }
+
+ assertEquals("[NATIVEAPPTEMPLATE-2001] Something broke on our side.", exception.codedDescription)
}
}
diff --git a/app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/testing/util/TestErrors.kt b/app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/testing/util/TestErrors.kt
index f81a0cf..d744111 100644
--- a/app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/testing/util/TestErrors.kt
+++ b/app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/testing/util/TestErrors.kt
@@ -7,7 +7,7 @@ val testApiError = ApiException.ApiError(code = 422, apiMessage = "Name has alre
/**
* What a screen must show for [testApiError]: CodedError.codedDescription is "[] "
- * (AGENTS.md, Error Handling), and a failed request is NATIVEAPPTEMPLATE-2001 with the description
- * " [Status: ]" (ApiException.ApiError).
+ * (AGENTS.md, Error Handling). A failed request is NATIVEAPPTEMPLATE-2001 whose description is the API's
+ * message as is (the server localizes it); the HTTP status goes to the log, not the screen.
*/
-const val TEST_API_ERROR_MESSAGE = "[NATIVEAPPTEMPLATE-2001] Name has already been taken [Status: 422]"
+const val TEST_API_ERROR_MESSAGE = "[NATIVEAPPTEMPLATE-2001] Name has already been taken"
diff --git a/app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/app_root/SignInEmailAndPasswordViewModelTest.kt b/app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/app_root/SignInEmailAndPasswordViewModelTest.kt
index 9525104..b390a8c 100644
--- a/app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/app_root/SignInEmailAndPasswordViewModelTest.kt
+++ b/app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/app_root/SignInEmailAndPasswordViewModelTest.kt
@@ -106,15 +106,23 @@ class SignInEmailAndPasswordViewModelTest {
viewModel.updateEmail(testInputLoggedInShopkeeper.getEmail()!!)
viewModel.updatePassword(testInputPassword)
loginRepository.sendLoggedInShopkeeper(testInputLoggedInShopkeeper)
- val error = ApiException.UnprocessableError(rawMessage = "Bad Gateway", httpStatusCode = 502)
+ // What throwApiError makes of a 502 without the API's error body.
+ val error = ApiException.UnprocessableError(
+ rawMessage = "HTTP 502",
+ httpStatusCode = 502,
+ userMessage = ApiException.UserMessage.CommunicationFailure,
+ )
loginRepository.failPermissions(error)
viewModel.login()
assertFalse(viewModel.uiState.value.isLoading)
// The user-facing message is the coded description (AGENTS.md: CodedError system):
- // ApiException.UnprocessableError = "[NATIVEAPPTEMPLATE-2002] Processing error: $rawMessage".
- assertEquals("[NATIVEAPPTEMPLATE-2002] Processing error: Bad Gateway", viewModel.uiState.value.message)
+ // values/strings.xml error_communication_failed, after the code (AGENTS.md, Error Handling).
+ assertEquals(
+ "[NATIVEAPPTEMPLATE-2002] Something went wrong while communicating with the server. Please try again later.",
+ viewModel.uiState.value.message,
+ )
// A server error is not a rejected session; permissions are fetched again on the next resume.
assertEquals(0, loginRepository.logoutCallCount)
}
@@ -126,15 +134,19 @@ class SignInEmailAndPasswordViewModelTest {
viewModel.updatePassword(testInputPassword)
// What throwApiError makes of devise_token_auth's 401 {"errors": ["Invalid login credentials. ..."]}.
loginRepository.failLogin(
- ApiException.UnprocessableError(rawMessage = "Invalid login credentials. Please try again.", httpStatusCode = 401),
+ ApiException.UnprocessableError(
+ rawMessage = "Invalid login credentials. Please try again.",
+ httpStatusCode = 401,
+ userMessage = ApiException.UserMessage.Server("Invalid login credentials. Please try again."),
+ ),
)
viewModel.login()
val uiStateValue = viewModel.uiState.value
assertFalse(uiStateValue.isLoading)
- // ApiException.UnprocessableError = "[NATIVEAPPTEMPLATE-2002] Processing error: $rawMessage".
- assertEquals("[NATIVEAPPTEMPLATE-2002] Processing error: Invalid login credentials. Please try again.", uiStateValue.message)
+ // The server's message as is, after the code (AGENTS.md, Error Handling).
+ assertEquals("[NATIVEAPPTEMPLATE-2002] Invalid login credentials. Please try again.", uiStateValue.message)
// The previous session (if any) is left alone, and a rejected sign-in is not a sign-out.
assertEquals(0, loginRepository.clearSessionCallCount)
assertEquals(0, loginRepository.logoutCallCount)
diff --git a/app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/settings/ShopkeeperEditViewModelTest.kt b/app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/settings/ShopkeeperEditViewModelTest.kt
index eedef4a..48780ee 100644
--- a/app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/settings/ShopkeeperEditViewModelTest.kt
+++ b/app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/settings/ShopkeeperEditViewModelTest.kt
@@ -277,7 +277,11 @@ class ShopkeeperEditViewModelTest {
),
)
viewModel.reload()
- val error = ApiException.UnprocessableError(rawMessage = "Unable to resolve host")
+ // What throwApiError makes of a request without a response (no network).
+ val error = ApiException.UnprocessableError(
+ rawMessage = "Unable to resolve host",
+ userMessage = ApiException.UserMessage.CommunicationFailure,
+ )
signUpRepository.failDeleteAccount(error)
viewModel.deleteShopkeeper()
@@ -286,8 +290,11 @@ class ShopkeeperEditViewModelTest {
assertEquals(0, loginRepository.clearSessionCallCount)
assertFalse(viewModel.uiState.value.isLoading)
assertFalse(viewModel.uiState.value.isDeleted)
- // ApiException.UnprocessableError = "[NATIVEAPPTEMPLATE-2002] Processing error: $rawMessage".
- assertEquals("[NATIVEAPPTEMPLATE-2002] Processing error: Unable to resolve host", viewModel.uiState.value.message)
+ // values/strings.xml error_communication_failed, after the code (AGENTS.md, Error Handling).
+ assertEquals(
+ "[NATIVEAPPTEMPLATE-2002] Something went wrong while communicating with the server. Please try again later.",
+ viewModel.uiState.value.message,
+ )
}
@Test