Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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 `<plurals>` (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).
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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()
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -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 : ApiException> 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"
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
Original file line number Diff line number Diff line change
@@ -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)
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
}
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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.
Expand All @@ -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()
}

/**
Expand Down
2 changes: 2 additions & 0 deletions app/src/main/res/values-ja/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -188,4 +188,6 @@
<plurals name="characters_minimum">
<item quantity="other">%1$d文字以上で入力してください。</item>
</plurals>
<string name="error_communication_failed">サーバーとの通信中に問題が発生しました。しばらくしてからもう一度お試しください。</string>
<string name="error_unexpected">予期しないエラーが発生しました。</string>
</resources>
2 changes: 2 additions & 0 deletions app/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -185,4 +185,6 @@
<item quantity="one">%1$d character minimum.</item>
<item quantity="other">%1$d characters minimum.</item>
</plurals>
<string name="error_communication_failed">Something went wrong while communicating with the server. Please try again later.</string>
<string name="error_unexpected">An unexpected error occurred.</string>
</resources>
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand Down
Loading
Loading