From bb5c6e3ef1b234c123688b2af7d0200dd8cf742c Mon Sep 17 00:00:00 2001 From: "Vincenzo S." Date: Fri, 16 Oct 2026 19:23:00 +0200 Subject: [PATCH] feat: localize the drop-in screens, shipping English and Italian Every end-user string of the board, the feedback form, My requests and the conversation screens now resolves from the library's string resources: values/strings.xml (English, the default) and a complete values-it/strings.xml. The screens follow the host app's locale, per-app language included, and fall back to English. - Every resource name is prefixed feedbackthread_: library resources merge into the host app, where an unprefixed name would be overridden by one of the app's own. - FeedbackThreadText (internal) carries copy produced outside composition - catch blocks, the status label, enum titles - as a resource reference or verbatim text, resolved in composition. - The board's empty filter states are one whole sentence per filter instead of a lowercased status interpolated into "No ... requests", and the vote button's description is a proper plural ("Vote, 1 vote"). Deliberately not localized: - Wire strings. feedbackThreadRequestStage() and its status match strings, serialized names, URL paths, headers and the persisted voter key are untouched, byte for byte. - Public API. FeedbackThreadFeedbackKind.title stays an English String beside a new internal localizedTitle. Exception messages stay English for integrators; the end-user copy the SDK writes (unreachable, unreadable response or settings, the HTTP-status fallback) is tagged internally and the screens show it localized. The two values FeedbackThreadConversations writes to FeedbackThreadConversationState .error become internal constants the host shows localized. A binary-compatibility-validator dump is identical before and after. - InvalidConfiguration messages, messages the API writes, and statuses the SDK doesn't recognize, which stay verbatim. Tests: a localization suite that discovers every values-*/strings.xml and checks prefix, completeness, placeholders, plurals and apostrophes; status labels asserted against the resources (English unchanged, exact Italian); error tagging and its display mapping; the conversation error mapping. The wire-mapping and section-bucketing tests are unchanged. The unit test task declares src/main/res as an input, so a translation-only edit doesn't leave it up to date. --- CHANGELOG.md | 10 + README.md | 18 +- feedbackthread/build.gradle.kts | 8 + .../feedbackthread/sdk/FeedbackThreadBoard.kt | 92 +++++--- .../sdk/FeedbackThreadClient.kt | 59 ++++- .../sdk/FeedbackThreadConversationHost.kt | 43 ++-- .../sdk/FeedbackThreadConversations.kt | 7 +- .../sdk/FeedbackThreadFeedbackScreen.kt | 35 +-- .../sdk/FeedbackThreadMyRequestsScreen.kt | 42 ++-- .../sdk/FeedbackThreadRequestStatus.kt | 19 +- .../feedbackthread/sdk/FeedbackThreadText.kt | 47 ++++ .../src/main/res/values-it/strings.xml | 218 ++++++++++++++++++ .../src/main/res/values/strings.xml | 218 ++++++++++++++++++ .../sdk/FeedbackThreadClientTest.kt | 151 ++++++++++++ .../sdk/FeedbackThreadLocalizationTest.kt | 138 +++++++++++ .../sdk/FeedbackThreadRequestStatusTest.kt | 40 +++- .../sdk/FeedbackThreadStringResources.kt | 115 +++++++++ .../sdk/FeedbackThreadTextTest.kt | 96 ++++++++ 18 files changed, 1243 insertions(+), 113 deletions(-) create mode 100644 feedbackthread/src/main/java/com/feedbackthread/sdk/FeedbackThreadText.kt create mode 100644 feedbackthread/src/main/res/values-it/strings.xml create mode 100644 feedbackthread/src/main/res/values/strings.xml create mode 100644 feedbackthread/src/test/java/com/feedbackthread/sdk/FeedbackThreadLocalizationTest.kt create mode 100644 feedbackthread/src/test/java/com/feedbackthread/sdk/FeedbackThreadStringResources.kt create mode 100644 feedbackthread/src/test/java/com/feedbackthread/sdk/FeedbackThreadTextTest.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index 8ba284d..b162179 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,15 @@ # Changelog +## Unreleased + +The drop-in Compose screens are localized, and ship in English and Italian. + +- Every end-user string of the board, the feedback form, My requests and the conversation screens is now a string resource of the library: `values/strings.xml` (English, the default) and a complete `values-it/strings.xml`. The screens follow the host app's locale, per-app language included, and fall back to English for any other language. Every name is prefixed `feedbackthread_`, so it can't collide with the host app's own resources. Adding a language is one new `values-xx/strings.xml`; a unit test fails on a string left untranslated, or on a translation whose placeholders drift from the English. +- **No public API change.** `FeedbackThreadFeedbackKind.title` still returns its English `String`; the screens use an internal localized accessor beside it. Exception messages stay English and unchanged, for the integrators who read and log them: the copy the SDK itself writes for end users (service unreachable, unreadable response, the HTTP-status fallback) is tagged internally, and the drop-in screens show it localized. `FeedbackThreadConversationState.error` keeps its English values; `FeedbackThreadConversationHost` shows them localized. +- Shown as written, on purpose: request titles and descriptions, error messages written by the API, statuses the SDK doesn't recognize, and `FeedbackThreadException.InvalidConfiguration` messages, which address the integrator. +- Wire values are deliberately untouched: the request statuses matched from the API (`"Submitted"`, `"In review"`, `"Ready to release"`, …), serialized names, URL paths, headers and the persisted anonymous voter-identity key stay exactly as they were, even where a label reads the same. +- Two English changes. The board's empty filter states are one whole sentence per filter instead of a lowercased status interpolated into `"No … requests"`, which no other language can inflect: "No requests in review" and "No requests in progress" replace "No in review requests" and "No in progress requests" (the other filters read as before). And the vote button's screen-reader description is a proper plural: "Vote, 1 vote" instead of "Vote, 1 votes". + ## 0.5.0 — 2026-09-15 - Add secure guest conversations and Compose reply/comment screens with foreground live updates, pagination, read markers, follow/mute and removal. diff --git a/README.md b/README.md index 0fc33de..d601149 100644 --- a/README.md +++ b/README.md @@ -138,6 +138,20 @@ The client exposes all three calls directly if you're building custom UI: `myReq The Compose screens are optional. `FeedbackThreadClient` exposes `submit(...)`, `requests(...)`, and `setVote(...)` for custom UI, with configurable connect/read timeouts and base-URL validation. +## Localization + +The drop-in screens ship in **English and Italian** and follow your app's language automatically — the device's, or the per-app language your users pick — with nothing to configure. A language the SDK doesn't carry falls back to English. + +All SDK-authored user-facing text lives in the library's string resources, `feedbackthread/src/main/res/values/strings.xml` (English) and `values-it/strings.xml`. Adding a language is a pull request with one new `values-xx/strings.xml` and nothing else; a unit test fails if any string is left untranslated or if a translation's placeholders don't match the English ones. Every name is prefixed `feedbackthread_`, so the SDK's strings never collide with your app's. + +Some text stays as it is on purpose: + +- **Server-supplied text** — request titles and descriptions, error messages from the API, and any request status the SDK doesn't recognize are shown exactly as received. +- **Configuration errors** — `FeedbackThreadException.InvalidConfiguration` messages are addressed to you while you wire the SDK up, not to your users. +- **Exception messages** — the messages the SDK writes on `FeedbackThreadException` stay English for your logs; the drop-in screens show their own localized copy of them. + +`FeedbackThreadFeedbackKind.title` and `FeedbackThreadConversationState.error` remain plain English `String`s, unchanged, for custom UI that already renders them. + ## Development ```sh @@ -236,5 +250,5 @@ while retaining that old object. Never reuse its client after logout. Public comments default off. Disabling them hides existing discussion without deleting it. Private replies remain enabled. Image uploads are deferred and have -no SDK or server implementation in this change. New Compose conversation copy is -English, matching the current Android SDK; localization remains a release check. +no SDK or server implementation in this change. The conversation screens are +localized with the rest of the drop-in UI (see [Localization](#localization)). diff --git a/feedbackthread/build.gradle.kts b/feedbackthread/build.gradle.kts index dae9617..b0be00b 100644 --- a/feedbackthread/build.gradle.kts +++ b/feedbackthread/build.gradle.kts @@ -26,6 +26,14 @@ android { buildFeatures { compose = true } + + testOptions { + unitTests.all { + // The localization tests read src/main/res directly; without this + // input, an edited translation would leave the test task up to date. + it.inputs.dir("src/main/res") + } + } } kotlin { diff --git a/feedbackthread/src/main/java/com/feedbackthread/sdk/FeedbackThreadBoard.kt b/feedbackthread/src/main/java/com/feedbackthread/sdk/FeedbackThreadBoard.kt index 41f4d6b..067a320 100644 --- a/feedbackthread/src/main/java/com/feedbackthread/sdk/FeedbackThreadBoard.kt +++ b/feedbackthread/src/main/java/com/feedbackthread/sdk/FeedbackThreadBoard.kt @@ -2,6 +2,7 @@ package com.feedbackthread.sdk import android.content.Context import androidx.activity.compose.BackHandler +import androidx.annotation.StringRes import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement @@ -50,6 +51,8 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview @@ -64,15 +67,18 @@ import kotlinx.coroutines.launch private sealed interface RequestLoadPhase { data object Loading : RequestLoadPhase data object Loaded : RequestLoadPhase - data class Failed(val message: String) : RequestLoadPhase + data class Failed(val message: FeedbackThreadText) : RequestLoadPhase } -private enum class RequestFilter(val title: String) { - ALL("All"), - IN_REVIEW("In review"), - PLANNED("Planned"), - IN_PROGRESS("In progress"), - COMPLETED("Completed"); +// internal (not private) so its copy is unit-testable. The stage filters +// share the status labels; each filter has its own whole empty-state +// sentence rather than a status name interpolated into one. +internal enum class RequestFilter(@StringRes val title: Int, @StringRes val emptyTitle: Int) { + ALL(R.string.feedbackthread_filter_all, R.string.feedbackthread_board_empty_filter_all), + IN_REVIEW(R.string.feedbackthread_status_in_review, R.string.feedbackthread_board_empty_filter_in_review), + PLANNED(R.string.feedbackthread_status_planned, R.string.feedbackthread_board_empty_filter_planned), + IN_PROGRESS(R.string.feedbackthread_status_in_progress, R.string.feedbackthread_board_empty_filter_in_progress), + COMPLETED(R.string.feedbackthread_status_completed, R.string.feedbackthread_board_empty_filter_completed); fun includes(status: String): Boolean = when (this) { // Unknown statuses are included under "All" rather than dropped, so a @@ -130,7 +136,7 @@ public fun FeedbackThreadBoard( } catch (error: CancellationException) { throw error } catch (error: Exception) { - phase = RequestLoadPhase.Failed(error.message ?: "Feature requests could not be loaded.") + phase = RequestLoadPhase.Failed(error.feedbackThreadDisplayText(R.string.feedbackthread_board_load_failed_fallback)) } } } @@ -194,7 +200,7 @@ public fun FeedbackThreadBoard( throw error } catch (error: Exception) { phase = RequestLoadPhase.Failed( - error.message ?: "Your vote could not be saved.", + error.feedbackThreadDisplayText(R.string.feedbackthread_board_vote_failed_fallback), ) } finally { votingIds = votingIds - request.id @@ -207,12 +213,20 @@ public fun FeedbackThreadBoard( modifier = modifier.fillMaxSize(), topBar = { TopAppBar( - title = { Text(if (selectedRequest == null) "Feature requests" else "Request") }, + title = { + Text( + if (selectedRequest == null) { + stringResource(R.string.feedbackthread_board_title) + } else { + stringResource(R.string.feedbackthread_board_detail_title) + }, + ) + }, navigationIcon = { if (selectedRequest == null) { - TextButton(onClick = onDismiss) { Text("Done") } + TextButton(onClick = onDismiss) { Text(stringResource(R.string.feedbackthread_done)) } } else { - TextButton(onClick = { selectedRequestId = null }) { Text("Back") } + TextButton(onClick = { selectedRequestId = null }) { Text(stringResource(R.string.feedbackthread_back)) } } }, actions = { @@ -225,7 +239,7 @@ public fun FeedbackThreadBoard( } }, ) { - Icon(Icons.Default.Person, contentDescription = "My requests") + Icon(Icons.Default.Person, contentDescription = stringResource(R.string.feedbackthread_my_requests_title)) } } } @@ -244,7 +258,7 @@ public fun FeedbackThreadBoard( .navigationBarsPadding() .padding(horizontal = 16.dp, vertical = 12.dp), ) { - Text("Suggest a feature") + Text(stringResource(R.string.feedbackthread_board_suggest_feature)) } } } @@ -254,14 +268,14 @@ public fun FeedbackThreadBoard( FeatureRequestDetail( request = selectedRequest, isVoting = selectedRequest.id in votingIds, - errorMessage = (phase as? RequestLoadPhase.Failed)?.message, + errorMessage = (phase as? RequestLoadPhase.Failed)?.message?.resolve(), onVote = { toggleVote(selectedRequest) }, modifier = Modifier.padding(contentPadding), ) } else when { phase is RequestLoadPhase.Loading && requests.isEmpty() -> { RequestMessage( - title = "Loading requests…", + title = stringResource(R.string.feedbackthread_board_loading), showProgress = true, modifier = Modifier.padding(contentPadding), ) @@ -269,9 +283,9 @@ public fun FeedbackThreadBoard( phase is RequestLoadPhase.Failed && requests.isEmpty() -> { RequestMessage( - title = "Couldn’t load requests", - message = (phase as RequestLoadPhase.Failed).message, - actionTitle = "Try again", + title = stringResource(R.string.feedbackthread_board_load_failed_title), + message = (phase as RequestLoadPhase.Failed).message.resolve(), + actionTitle = stringResource(R.string.feedbackthread_try_again), onAction = { scope.launch { loadRequests() } }, modifier = Modifier.padding(contentPadding), ) @@ -279,9 +293,9 @@ public fun FeedbackThreadBoard( visibleRequests.isEmpty() -> { RequestMessage( - title = "No approved requests yet", - message = "Share an idea and it will appear after review.", - actionTitle = "Add request", + title = stringResource(R.string.feedbackthread_board_empty_title), + message = stringResource(R.string.feedbackthread_board_empty_message), + actionTitle = stringResource(R.string.feedbackthread_board_add_request), onAction = onAddRequest, modifier = Modifier.padding(contentPadding), ) @@ -306,14 +320,20 @@ public fun FeedbackThreadBoard( selected = selectedFilter == filter, onClick = { selectedFilter = filter }, label = { - Text("${filter.title} (${visibleRequests.count { filter.includes(it.status) }})") + Text( + stringResource( + R.string.feedbackthread_filter_chip, + stringResource(filter.title), + visibleRequests.count { filter.includes(it.status) }, + ), + ) }, ) } } if (phase is RequestLoadPhase.Failed) { Text( - text = (phase as RequestLoadPhase.Failed).message, + text = (phase as RequestLoadPhase.Failed).message.resolve(), modifier = Modifier.padding(horizontal = 20.dp, vertical = 10.dp), color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall, @@ -321,8 +341,8 @@ public fun FeedbackThreadBoard( } if (filteredRequests.isEmpty()) { RequestMessage( - title = "No ${selectedFilter.title.lowercase()} requests", - message = "Choose another status to see more requests.", + title = stringResource(selectedFilter.emptyTitle), + message = stringResource(R.string.feedbackthread_board_empty_filter_message), modifier = Modifier.weight(1f), ) } else { @@ -402,7 +422,7 @@ private fun FeatureRequestRow( @Composable private fun BugKindBadge() { Text( - text = "Bug", + text = stringResource(FeedbackThreadFeedbackKind.BUG.localizedTitle), style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.error, modifier = Modifier @@ -431,7 +451,7 @@ private fun FeatureRequestDetail( val conversations = LocalFeedbackThreadConversations.current val conversationState = conversations?.state?.collectAsState()?.value if (conversationState?.publicCommentsEnabled == true) { - TextButton(onClick = { conversations.open(FeedbackThreadConversationRoute(request.id, "public")) }) { Text("Comments") } + TextButton(onClick = { conversations.open(FeedbackThreadConversationRoute(request.id, "public")) }) { Text(stringResource(R.string.feedbackthread_comments)) } } Text( text = request.title, @@ -494,6 +514,12 @@ private fun FeatureRequestVoteButton( isVoting: Boolean, onVote: () -> Unit, ) { + // Resolved here, in composition: the semantics block below runs outside it. + val voteDescription = if (isVoted) { + pluralStringResource(R.plurals.feedbackthread_remove_vote_description, votes, votes) + } else { + pluralStringResource(R.plurals.feedbackthread_vote_description, votes, votes) + } TextButton( onClick = onVote, enabled = !isVoting, @@ -501,11 +527,7 @@ private fun FeatureRequestVoteButton( .widthIn(min = 64.dp) .heightIn(min = 56.dp) .semantics { - contentDescription = if (isVoted) { - "Remove vote, $votes votes" - } else { - "Vote, $votes votes" - } + contentDescription = voteDescription }, ) { if (isVoting) { @@ -528,7 +550,7 @@ private fun FeatureRequestStatusBadge(status: String) { is FeedbackThreadRequestStage.Unknown -> MaterialTheme.colorScheme.onSurfaceVariant } Text( - text = status.feedbackThreadRequestLabel(), + text = status.feedbackThreadRequestLabel().resolve(), modifier = Modifier .background(statusColor.copy(alpha = 0.12f), RoundedCornerShape(999.dp)) .padding(horizontal = 8.dp, vertical = 3.dp), @@ -541,7 +563,7 @@ private fun FeatureRequestStatusBadge(status: String) { private fun ShippedInVersionBadge(version: String) { val shippedColor = Color(0xFF2E7D32) Text( - text = "✓ Shipped in $version", + text = stringResource(R.string.feedbackthread_shipped_in, version), modifier = Modifier .background(shippedColor.copy(alpha = 0.12f), RoundedCornerShape(999.dp)) .padding(horizontal = 8.dp, vertical = 3.dp), diff --git a/feedbackthread/src/main/java/com/feedbackthread/sdk/FeedbackThreadClient.kt b/feedbackthread/src/main/java/com/feedbackthread/sdk/FeedbackThreadClient.kt index ec275bd..fa23d8a 100644 --- a/feedbackthread/src/main/java/com/feedbackthread/sdk/FeedbackThreadClient.kt +++ b/feedbackthread/src/main/java/com/feedbackthread/sdk/FeedbackThreadClient.kt @@ -1,5 +1,6 @@ package com.feedbackthread.sdk +import androidx.annotation.StringRes import java.io.IOException import java.net.HttpURLConnection import java.net.URI @@ -33,6 +34,19 @@ public enum class FeedbackThreadFeedbackKind(public val title: String) { REVIEW("Review"), ; + /** + * [title] as one of the SDK's string resources, for its own screens. + * [title] itself stays the plain English String, so host apps that + * already render it keep the exact value they get today. + */ + @get:StringRes + internal val localizedTitle: Int + get() = when (this) { + BUG -> R.string.feedbackthread_kind_bug + REQUEST -> R.string.feedbackthread_kind_request + REVIEW -> R.string.feedbackthread_kind_review + } + public companion object { /** * The kinds an end user can pick in the drop-in feedback screen. @@ -206,6 +220,16 @@ public data class FeedbackThreadConfiguration( } public sealed class FeedbackThreadException(message: String, cause: Throwable? = null) : Exception(message, cause) { + /** + * The end-user copy of this error, set where the SDK itself wrote the + * message (see [withUserMessage]), so the drop-in screens can show it in + * the user's language. [message] stays the English text integrators read + * and log. Null for an integrator-facing error or a message the API wrote. + * Transient because exceptions are Serializable and the tag is display-only. + */ + @Transient + internal var userMessage: FeedbackThreadText.Resource? = null + public class InvalidConfiguration internal constructor(message: String) : FeedbackThreadException(message) public class InvalidResponse internal constructor(message: String, cause: Throwable? = null) : @@ -217,6 +241,10 @@ public sealed class FeedbackThreadException(message: String, cause: Throwable? = ) : FeedbackThreadException(message) } +/** Tags an SDK-authored error with its end-user copy; the message is unchanged. */ +internal fun T.withUserMessage(@StringRes id: Int, vararg args: Any): T = + apply { userMessage = FeedbackThreadText.Resource(id, args.toList()) } + public class FeedbackThreadClient private constructor( private val handlers: FeedbackThreadHandlers, ) { @@ -397,6 +425,7 @@ private class FeedbackThreadHTTPTransport( json.decodeFromString(body) } catch (error: SerializationException) { throw FeedbackThreadException.InvalidResponse("FeedbackThread returned unreadable conversation settings.", error) + .withUserMessage(R.string.feedbackthread_error_unreadable_conversation_settings) } } finally { connection.disconnect() @@ -439,21 +468,20 @@ private class FeedbackThreadHTTPTransport( val responseBody = stream?.bufferedReader(StandardCharsets.UTF_8)?.use { it.readText() }.orEmpty() if (statusCode !in 200..299) { - val message = runCatching { - json.decodeFromString(responseBody).error.message - }.getOrNull() ?: "FeedbackThread returned HTTP $statusCode." - throw FeedbackThreadException.Server(statusCode, message) + throw serverError(statusCode, responseBody) } try { json.decodeFromString(responseBody).feedback } catch (error: SerializationException) { throw FeedbackThreadException.InvalidResponse("FeedbackThread returned an unreadable response.", error) + .withUserMessage(R.string.feedbackthread_error_unreadable_response) } } catch (error: FeedbackThreadException) { throw error } catch (error: IOException) { throw FeedbackThreadException.InvalidResponse("Could not reach FeedbackThread.", error) + .withUserMessage(R.string.feedbackthread_error_unreachable) } finally { connection.disconnect() } @@ -472,6 +500,7 @@ private class FeedbackThreadHTTPTransport( json.decodeFromString(responseBody).requests } catch (error: SerializationException) { throw FeedbackThreadException.InvalidResponse("FeedbackThread returned an unreadable response.", error) + .withUserMessage(R.string.feedbackthread_error_unreadable_response) } } finally { connection.disconnect() @@ -507,6 +536,7 @@ private class FeedbackThreadHTTPTransport( json.decodeFromString(responseBody) } catch (error: SerializationException) { throw FeedbackThreadException.InvalidResponse("FeedbackThread returned an unreadable response.", error) + .withUserMessage(R.string.feedbackthread_error_unreadable_response) } } finally { connection.disconnect() @@ -526,6 +556,7 @@ private class FeedbackThreadHTTPTransport( json.decodeFromString(responseBody).requests } catch (error: SerializationException) { throw FeedbackThreadException.InvalidResponse("FeedbackThread returned an unreadable response.", error) + .withUserMessage(R.string.feedbackthread_error_unreadable_response) } } finally { connection.disconnect() @@ -545,6 +576,7 @@ private class FeedbackThreadHTTPTransport( json.decodeFromString(responseBody) } catch (error: SerializationException) { throw FeedbackThreadException.InvalidResponse("FeedbackThread returned an unreadable response.", error) + .withUserMessage(R.string.feedbackthread_error_unreadable_response) } } finally { connection.disconnect() @@ -574,6 +606,7 @@ private class FeedbackThreadHTTPTransport( json.decodeFromString(responseBody).unreadCount } catch (error: SerializationException) { throw FeedbackThreadException.InvalidResponse("FeedbackThread returned an unreadable response.", error) + .withUserMessage(R.string.feedbackthread_error_unreadable_response) } } finally { connection.disconnect() @@ -593,15 +626,25 @@ private class FeedbackThreadHTTPTransport( val stream = if (statusCode in 200..299) connection.inputStream else connection.errorStream val responseBody = stream?.bufferedReader(StandardCharsets.UTF_8)?.use { it.readText() }.orEmpty() if (statusCode !in 200..299) { - val message = runCatching { - json.decodeFromString(responseBody).error.message - }.getOrNull() ?: "FeedbackThread returned HTTP $statusCode." - throw FeedbackThreadException.Server(statusCode, message) + throw serverError(statusCode, responseBody) } configuration.customerSessionProvider?.invoke() // Discard responses after logout. return responseBody } + /** + * The API's own error message, passed through as written; when the body + * carries none, the SDK's HTTP-status fallback, tagged for the screens. + */ + private fun serverError(statusCode: Int, responseBody: String): FeedbackThreadException.Server { + val message = runCatching { + json.decodeFromString(responseBody).error.message + }.getOrNull() + ?: return FeedbackThreadException.Server(statusCode, "FeedbackThread returned HTTP $statusCode.") + .withUserMessage(R.string.feedbackthread_error_http_status, statusCode) + return FeedbackThreadException.Server(statusCode, message) + } + private fun normalizedUserId(value: String?): String? = configuration.customerSessionProvider?.invoke()?.externalUserId ?: value?.trim()?.takeIf { it.isNotEmpty() } private fun endpointURL(path: String = "feedback"): URL { diff --git a/feedbackthread/src/main/java/com/feedbackthread/sdk/FeedbackThreadConversationHost.kt b/feedbackthread/src/main/java/com/feedbackthread/sdk/FeedbackThreadConversationHost.kt index 0fb8bff..e6431ec 100644 --- a/feedbackthread/src/main/java/com/feedbackthread/sdk/FeedbackThreadConversationHost.kt +++ b/feedbackthread/src/main/java/com/feedbackthread/sdk/FeedbackThreadConversationHost.kt @@ -7,6 +7,8 @@ import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.ui.Modifier +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.DialogProperties @@ -20,6 +22,9 @@ import kotlinx.coroutines.launch internal val LocalFeedbackThreadConversations = staticCompositionLocalOf { null } +/** The state's error as shown: the two that [FeedbackThreadConversations] writes localized, any other value as written. */ +internal fun conversationErrorText(error: String): FeedbackThreadText = when(error) { CONVERSATIONS_REFRESH_FAILED -> FeedbackThreadText.Resource(R.string.feedbackthread_conversation_refresh_failed); CONVERSATIONS_CONNECT_FAILED -> FeedbackThreadText.Resource(R.string.feedbackthread_conversation_connect_failed); else -> FeedbackThreadText.Verbatim(error) } + /** Keep at app root and pass the supplied secure client to all FeedbackThread screens. */ @Composable public fun FeedbackThreadConversationHost( @@ -39,12 +44,12 @@ public fun FeedbackThreadConversationHost( if (state.ready) content(conversations.client) else Column(Modifier.padding(24.dp)) { CircularProgressIndicator() - Text(state.error ?: "Connecting to your feedback…") + Text(state.error?.let { conversationErrorText(it).resolve() } ?: stringResource(R.string.feedbackthread_conversation_connecting)) } } state.inbox.firstOrNull { it.unreadCount > 0 }?.let { unread -> TextButton(onClick = { conversations.open(FeedbackThreadConversationRoute(unread.feedbackId,unread.audience)) }, modifier=Modifier.fillMaxWidth()) { - Text("New message (${state.unreadCount})") + Text(pluralStringResource(R.plurals.feedbackthread_conversation_new_messages,state.unreadCount,state.unreadCount)) } } } @@ -83,7 +88,7 @@ private fun ConversationContent(conversations: FeedbackThreadConversations,route var draft by remember { mutableStateOf("") } var pending by remember { mutableStateOf?>(null) } var busy by remember { mutableStateOf(false) } - var error by remember { mutableStateOf(null) } + var error by remember { mutableStateOf(null) } // A string resource, resolved below. var revision by remember { mutableStateOf(0) } var remove by remember { mutableStateOf(null) } val messages=(earlier+(history?.messages ?: emptyList())).associateBy { it.id }.values.sortedBy { it.seq } @@ -96,7 +101,7 @@ private fun ConversationContent(conversations: FeedbackThreadConversations,route cursor?.let { next -> val page=conversations.history(route,next); older.addAll(page.messages); cursor=page.nextBefore } } history=value; earlier=older; before=cursor; error=null - } catch(e: CancellationException) { throw e } catch(e: Exception) { history=null; earlier=emptyList(); pages=1; error="Could not load this conversation." } + } catch(e: CancellationException) { throw e } catch(e: Exception) { history=null; earlier=emptyList(); pages=1; error=R.string.feedbackthread_conversation_load_failed } } LaunchedEffect(messages) { lifecycle.repeatOnLifecycle(Lifecycle.State.RESUMED) { @@ -111,39 +116,39 @@ private fun ConversationContent(conversations: FeedbackThreadConversations,route busy=true; error=null scope.launch { try { work(); revision++; try { conversations.refresh() } catch(e: CancellationException) { throw e } catch(_: Exception) {} } - catch(e: CancellationException) { throw e } catch(_: Exception) { error="Could not complete this action. Try again." } + catch(e: CancellationException) { throw e } catch(_: Exception) { error=R.string.feedbackthread_conversation_action_failed } finally { busy=false } } } val disabled=route.audience=="public" && state.ready && !state.publicCommentsEnabled - Scaffold(modifier=modifier.fillMaxSize().imePadding(),topBar={ TopAppBar(title={Text(if(route.audience=="private") "Replies" else "Comments")},navigationIcon={TextButton(onClick=onDismiss){Text("Back")}}) },bottomBar={ + Scaffold(modifier=modifier.fillMaxSize().imePadding(),topBar={ TopAppBar(title={Text(stringResource(if(route.audience=="private") R.string.feedbackthread_replies else R.string.feedbackthread_comments))},navigationIcon={TextButton(onClick=onDismiss){Text(stringResource(R.string.feedbackthread_back))}}) },bottomBar={ if(!disabled) Column(Modifier.fillMaxWidth().navigationBarsPadding().padding(12.dp)) { - OutlinedTextField(value=draft,onValueChange={ if(it.length<=4000) draft=it },label={Text(if(route.audience=="private") "Write a private reply" else "Write a public comment")},enabled=!busy,modifier=Modifier.fillMaxWidth(),maxLines=5) + OutlinedTextField(value=draft,onValueChange={ if(it.length<=4000) draft=it },label={Text(stringResource(if(route.audience=="private") R.string.feedbackthread_conversation_private_placeholder else R.string.feedbackthread_conversation_public_placeholder))},enabled=!busy,modifier=Modifier.fillMaxWidth(),maxLines=5) Button(enabled=!busy && history!=null && draft.isNotBlank(),onClick={ val text=draft.trim(); if(pending?.first!=text) pending=text to UUID.randomUUID().toString() val id=pending!!.second action { conversations.send(route,text,id); draft=""; pending=null } - },modifier=Modifier.fillMaxWidth()) { Text(if(busy) "Sending…" else if(route.audience=="private") "Send reply" else "Post comment") } + },modifier=Modifier.fillMaxWidth()) { Text(stringResource(if(busy) R.string.feedbackthread_conversation_sending else if(route.audience=="private") R.string.feedbackthread_conversation_send_reply else R.string.feedbackthread_conversation_post_comment)) } } }) { padding -> - if(disabled) Text("Public comments are disabled for this app.",Modifier.padding(padding).padding(20.dp)) + if(disabled) Text(stringResource(R.string.feedbackthread_conversation_public_disabled),Modifier.padding(padding).padding(20.dp)) else LazyColumn(state=list,modifier=Modifier.padding(padding).fillMaxSize(),contentPadding=PaddingValues(16.dp),verticalArrangement=Arrangement.spacedBy(12.dp)) { item { - Text(if(route.audience=="private") "Only you and the app developer can see these replies." else "Everyone using this app can see these comments.") - error?.let { Text(it,color=MaterialTheme.colorScheme.error); TextButton(onClick={revision++}){Text("Try again")} } - history?.let { value -> TextButton(enabled=!busy,onClick={action {conversations.follow(route,!value.following)}}){Text(if(value.following) "Mute notifications" else "Notify me")} } - if(before!=null) TextButton(enabled=!busy,onClick={val cursor=before; action {val value=conversations.history(route,cursor); pages++; earlier=value.messages+earlier; before=value.nextBefore}}){Text("Load earlier messages")} - if(messages.isEmpty()) Text(if(history==null) "Loading…" else "No messages yet.") + Text(stringResource(if(route.audience=="private") R.string.feedbackthread_conversation_private_notice else R.string.feedbackthread_conversation_public_notice)) + error?.let { Text(stringResource(it),color=MaterialTheme.colorScheme.error); TextButton(onClick={revision++}){Text(stringResource(R.string.feedbackthread_try_again))} } + history?.let { value -> TextButton(enabled=!busy,onClick={action {conversations.follow(route,!value.following)}}){Text(stringResource(if(value.following) R.string.feedbackthread_conversation_mute else R.string.feedbackthread_conversation_follow))} } + if(before!=null) TextButton(enabled=!busy,onClick={val cursor=before; action {val value=conversations.history(route,cursor); pages++; earlier=value.messages+earlier; before=value.nextBefore}}){Text(stringResource(R.string.feedbackthread_conversation_load_earlier))} + if(messages.isEmpty()) Text(stringResource(if(history==null) R.string.feedbackthread_conversation_loading else R.string.feedbackthread_conversation_empty)) } items(messages,key={it.id}) { message -> Card(Modifier.fillMaxWidth()) { Column(Modifier.padding(12.dp),verticalArrangement=Arrangement.spacedBy(8.dp)) { - Text(if(message.mine) "You" else if(message.authorRole=="developer") "Developer" else "App user",style=MaterialTheme.typography.titleSmall) - Text(if(message.deletedAt!=null) "Message removed" else message.body) - if(message.mine) Text(if((history?.otherReadSeq ?: 0)>=message.seq) "Read" else "Posted",style=MaterialTheme.typography.labelSmall) - if(message.mine && message.deletedAt==null) TextButton(enabled=!busy,onClick={remove=message}){Text("Remove message")} + Text(stringResource(if(message.mine) R.string.feedbackthread_conversation_author_you else if(message.authorRole=="developer") R.string.feedbackthread_conversation_author_developer else R.string.feedbackthread_conversation_author_app_user),style=MaterialTheme.typography.titleSmall) + Text(if(message.deletedAt!=null) stringResource(R.string.feedbackthread_conversation_message_removed) else message.body) + if(message.mine) Text(stringResource(if((history?.otherReadSeq ?: 0)>=message.seq) R.string.feedbackthread_conversation_read else R.string.feedbackthread_conversation_posted),style=MaterialTheme.typography.labelSmall) + if(message.mine && message.deletedAt==null) TextButton(enabled=!busy,onClick={remove=message}){Text(stringResource(R.string.feedbackthread_conversation_remove_message))} } } } } } - remove?.let { message -> AlertDialog(onDismissRequest={remove=null},title={Text("Remove message?")},text={Text("The conversation will show that this message was removed.")},confirmButton={TextButton(onClick={remove=null; action {conversations.remove(route,message.id)}}){Text("Remove")}},dismissButton={TextButton(onClick={remove=null}){Text("Cancel")}}) } + remove?.let { message -> AlertDialog(onDismissRequest={remove=null},title={Text(stringResource(R.string.feedbackthread_conversation_remove_title))},text={Text(stringResource(R.string.feedbackthread_conversation_remove_body))},confirmButton={TextButton(onClick={remove=null; action {conversations.remove(route,message.id)}}){Text(stringResource(R.string.feedbackthread_conversation_remove_confirm))}},dismissButton={TextButton(onClick={remove=null}){Text(stringResource(R.string.feedbackthread_cancel))}}) } } diff --git a/feedbackthread/src/main/java/com/feedbackthread/sdk/FeedbackThreadConversations.kt b/feedbackthread/src/main/java/com/feedbackthread/sdk/FeedbackThreadConversations.kt index 605a29f..c556d73 100644 --- a/feedbackthread/src/main/java/com/feedbackthread/sdk/FeedbackThreadConversations.kt +++ b/feedbackthread/src/main/java/com/feedbackthread/sdk/FeedbackThreadConversations.kt @@ -40,6 +40,9 @@ import okhttp3.WebSocketListener @Serializable public data class FeedbackThreadConversationHistory(val thread: FeedbackThreadConversationThread, val messages: List, val unreadCount: Int, val following: Boolean, val hasMore: Boolean, val nextBefore: Long? = null, val otherReadSeq: Long? = null) @Serializable public data class FeedbackThreadConversationSummary(val id: String, val feedbackId: String, val audience: String, val title: String, val preview: String, val status: String, val unreadCount: Int, val updatedAt: String) @Serializable public data class FeedbackThreadConversationInbox(val conversations: List, val unreadCount: Int) +/** The errors [FeedbackThreadConversations] writes to [FeedbackThreadConversationState.error]; the Compose host shows these two localized. */ +internal const val CONVERSATIONS_REFRESH_FAILED = "Could not refresh conversations." +internal const val CONVERSATIONS_CONNECT_FAILED = "Could not connect to conversations." public data class FeedbackThreadConversationState(val inbox: List = emptyList(), val unreadCount: Int = 0, val publicCommentsEnabled: Boolean = false, val ready: Boolean = false, val revision: Long = 0, val route: FeedbackThreadConversationRoute? = null, val error: String? = null) /** Back with encrypted platform storage; never use plain preferences for guest tokens. */ @@ -163,9 +166,9 @@ public class FeedbackThreadConversations( override fun onClosed(webSocket: WebSocket,code: Int,reason: String) { ended.complete(Unit) } }); liveSocket=socket val heartbeat=launch { while(isActive) { delay(20_000); socket.send("ping") } } - val updates=launch { for(signal in signals) { try { refresh() } catch(e: CancellationException) { throw e } catch(e: Exception) { mutableState.value=mutableState.value.copy(error="Could not refresh conversations.") } } } + val updates=launch { for(signal in signals) { try { refresh() } catch(e: CancellationException) { throw e } catch(e: Exception) { mutableState.value=mutableState.value.copy(error=CONVERSATIONS_REFRESH_FAILED) } } } try { ended.await(); failures=0 } finally { heartbeat.cancel(); updates.cancel(); signals.close(); socket.cancel() } - } catch(e: CancellationException) { throw e } catch(e: Exception) { if(!closed) mutableState.value=mutableState.value.copy(error="Could not connect to conversations.") } + } catch(e: CancellationException) { throw e } catch(e: Exception) { if(!closed) mutableState.value=mutableState.value.copy(error=CONVERSATIONS_CONNECT_FAILED) } delay(minOf(30_000L,1000L shl minOf(failures++,5))) } } finally { liveSocket?.cancel(); http.dispatcher.executorService.shutdown(); http.connectionPool.evictAll() } diff --git a/feedbackthread/src/main/java/com/feedbackthread/sdk/FeedbackThreadFeedbackScreen.kt b/feedbackthread/src/main/java/com/feedbackthread/sdk/FeedbackThreadFeedbackScreen.kt index 2eae3fe..fdd908d 100644 --- a/feedbackthread/src/main/java/com/feedbackthread/sdk/FeedbackThreadFeedbackScreen.kt +++ b/feedbackthread/src/main/java/com/feedbackthread/sdk/FeedbackThreadFeedbackScreen.kt @@ -29,6 +29,7 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.KeyboardCapitalization import androidx.compose.ui.text.input.KeyboardType @@ -42,7 +43,7 @@ private sealed interface SubmissionPhase { data object Editing : SubmissionPhase data object Submitting : SubmissionPhase data object Sent : SubmissionPhase - data class Failed(val message: String) : SubmissionPhase + data class Failed(val message: FeedbackThreadText) : SubmissionPhase } /** @@ -79,10 +80,10 @@ public fun FeedbackThreadFeedbackScreen( modifier = modifier.fillMaxSize(), topBar = { TopAppBar( - title = { Text("Feedback") }, + title = { Text(stringResource(R.string.feedbackthread_form_title)) }, navigationIcon = { TextButton(onClick = onDismiss) { - Text("Cancel") + Text(stringResource(R.string.feedbackthread_cancel)) } }, ) @@ -98,21 +99,25 @@ public fun FeedbackThreadFeedbackScreen( verticalArrangement = Arrangement.Center, ) { Text( - if (kind == FeedbackThreadFeedbackKind.REQUEST) "Request submitted for review" else "Feedback sent", + if (kind == FeedbackThreadFeedbackKind.REQUEST) { + stringResource(R.string.feedbackthread_form_sent_request_title) + } else { + stringResource(R.string.feedbackthread_form_sent_feedback_title) + }, style = MaterialTheme.typography.headlineSmall, ) Spacer(Modifier.height(8.dp)) Text( if (kind == FeedbackThreadFeedbackKind.REQUEST) { - "It will appear in the feature list after it is approved." + stringResource(R.string.feedbackthread_form_sent_request_message) } else { - "Thank you for helping us improve the app." + stringResource(R.string.feedbackthread_form_sent_feedback_message) }, color = MaterialTheme.colorScheme.onSurfaceVariant, ) Spacer(Modifier.height(24.dp)) Button(onClick = onDismiss) { - Text("Done") + Text(stringResource(R.string.feedbackthread_done)) } } } else { @@ -124,7 +129,7 @@ public fun FeedbackThreadFeedbackScreen( .padding(horizontal = 20.dp, vertical = 16.dp), verticalArrangement = Arrangement.spacedBy(16.dp), ) { - Text("What would you like to share?", style = MaterialTheme.typography.titleMedium) + Text(stringResource(R.string.feedbackthread_form_prompt), style = MaterialTheme.typography.titleMedium) Row( modifier = Modifier.fillMaxWidth(), @@ -137,7 +142,7 @@ public fun FeedbackThreadFeedbackScreen( kind = option resubmissionKey.contentChanged() }, - label = { Text(option.title) }, + label = { Text(stringResource(option.localizedTitle)) }, ) } } @@ -149,7 +154,7 @@ public fun FeedbackThreadFeedbackScreen( resubmissionKey.contentChanged() }, modifier = Modifier.fillMaxWidth(), - label = { Text("Short title") }, + label = { Text(stringResource(R.string.feedbackthread_form_title_label)) }, singleLine = true, keyboardOptions = KeyboardOptions( capitalization = KeyboardCapitalization.Sentences, @@ -165,7 +170,7 @@ public fun FeedbackThreadFeedbackScreen( resubmissionKey.contentChanged() }, modifier = Modifier.fillMaxWidth(), - label = { Text("Details") }, + label = { Text(stringResource(R.string.feedbackthread_form_details_label)) }, minLines = 5, maxLines = 10, keyboardOptions = KeyboardOptions( @@ -176,7 +181,7 @@ public fun FeedbackThreadFeedbackScreen( if (phase is SubmissionPhase.Failed) { Text( - text = (phase as SubmissionPhase.Failed).message, + text = (phase as SubmissionPhase.Failed).message.resolve(), color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall, ) @@ -208,7 +213,9 @@ public fun FeedbackThreadFeedbackScreen( } catch (error: CancellationException) { throw error } catch (error: Exception) { - phase = SubmissionPhase.Failed(error.message ?: "Feedback could not be sent.") + phase = SubmissionPhase.Failed( + error.feedbackThreadDisplayText(R.string.feedbackthread_form_send_failed_fallback), + ) } } }, @@ -221,7 +228,7 @@ public fun FeedbackThreadFeedbackScreen( strokeWidth = 2.dp, ) } else { - Text("Send feedback") + Text(stringResource(R.string.feedbackthread_form_send)) } } } diff --git a/feedbackthread/src/main/java/com/feedbackthread/sdk/FeedbackThreadMyRequestsScreen.kt b/feedbackthread/src/main/java/com/feedbackthread/sdk/FeedbackThreadMyRequestsScreen.kt index 0b1b270..f808a73 100644 --- a/feedbackthread/src/main/java/com/feedbackthread/sdk/FeedbackThreadMyRequestsScreen.kt +++ b/feedbackthread/src/main/java/com/feedbackthread/sdk/FeedbackThreadMyRequestsScreen.kt @@ -1,5 +1,6 @@ package com.feedbackthread.sdk +import androidx.annotation.StringRes import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column @@ -31,6 +32,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp @@ -40,7 +42,7 @@ import kotlinx.coroutines.launch private sealed interface MyRequestsLoadPhase { data object Loading : MyRequestsLoadPhase data object Loaded : MyRequestsLoadPhase - data class Failed(val message: String) : MyRequestsLoadPhase + data class Failed(val message: FeedbackThreadText) : MyRequestsLoadPhase } /** @@ -97,7 +99,9 @@ public fun FeedbackThreadMyRequestsScreen( } catch (error: CancellationException) { throw error } catch (error: Exception) { - phase = MyRequestsLoadPhase.Failed(error.message ?: "Your requests could not be loaded.") + phase = MyRequestsLoadPhase.Failed( + error.feedbackThreadDisplayText(R.string.feedbackthread_my_requests_load_failed_fallback), + ) } } @@ -122,15 +126,15 @@ public fun FeedbackThreadMyRequestsScreen( modifier = modifier.fillMaxSize(), topBar = { TopAppBar( - title = { Text("My requests") }, - navigationIcon = { TextButton(onClick = onDismiss) { Text("Done") } }, + title = { Text(stringResource(R.string.feedbackthread_my_requests_title)) }, + navigationIcon = { TextButton(onClick = onDismiss) { Text(stringResource(R.string.feedbackthread_done)) } }, ) }, ) { contentPadding -> when { phase is MyRequestsLoadPhase.Loading && myRequests.isEmpty() -> { MyRequestsMessage( - title = "Loading your requests…", + title = stringResource(R.string.feedbackthread_my_requests_loading), showProgress = true, modifier = Modifier.padding(contentPadding), ) @@ -138,9 +142,9 @@ public fun FeedbackThreadMyRequestsScreen( phase is MyRequestsLoadPhase.Failed && myRequests.isEmpty() -> { MyRequestsMessage( - title = "Couldn’t load your requests", - message = (phase as MyRequestsLoadPhase.Failed).message, - actionTitle = "Try again", + title = stringResource(R.string.feedbackthread_my_requests_load_failed_title), + message = (phase as MyRequestsLoadPhase.Failed).message.resolve(), + actionTitle = stringResource(R.string.feedbackthread_try_again), onAction = { scope.launch { load() } }, modifier = Modifier.padding(contentPadding), ) @@ -148,18 +152,18 @@ public fun FeedbackThreadMyRequestsScreen( myRequests.isEmpty() -> { MyRequestsMessage( - title = "No requests yet", - message = "Anything you submit shows up here, including while it's waiting for review.", + title = stringResource(R.string.feedbackthread_my_requests_empty_title), + message = stringResource(R.string.feedbackthread_my_requests_empty_message), modifier = Modifier.padding(contentPadding), ) } else -> { LazyColumn(modifier = Modifier.padding(contentPadding)) { - myRequestsSection("Waiting for review", pendingReview) - myRequestsSection("In progress", inProgress) - myRequestsSection("Shipped", shipped) - myRequestsSection("Closed", closed) + myRequestsSection(R.string.feedbackthread_my_requests_section_waiting_for_review, pendingReview) + myRequestsSection(R.string.feedbackthread_my_requests_section_in_progress, inProgress) + myRequestsSection(R.string.feedbackthread_my_requests_section_shipped, shipped) + myRequestsSection(R.string.feedbackthread_my_requests_section_closed, closed) } } } @@ -167,13 +171,13 @@ public fun FeedbackThreadMyRequestsScreen( } private fun LazyListScope.myRequestsSection( - title: String, + @StringRes title: Int, entries: List, ) { if (entries.isEmpty()) return item { Text( - text = title, + text = stringResource(title), modifier = Modifier.padding(horizontal = 20.dp, vertical = 10.dp), style = MaterialTheme.typography.labelLarge, fontWeight = FontWeight.SemiBold, @@ -196,7 +200,7 @@ private fun MyRequestRow(request: FeedbackThreadMyRequest) { ) { val conversations = LocalFeedbackThreadConversations.current if (request.conversationAvailable && conversations != null) { - TextButton(onClick = { conversations.open(FeedbackThreadConversationRoute(request.id, "private")) }) { Text("Replies") } + TextButton(onClick = { conversations.open(FeedbackThreadConversationRoute(request.id, "private")) }) { Text(stringResource(R.string.feedbackthread_replies)) } } Text( text = request.title, @@ -225,7 +229,7 @@ private fun MyRequestStatusBadge(status: String) { is FeedbackThreadRequestStage.Unknown -> MaterialTheme.colorScheme.onSurfaceVariant } Text( - text = status.feedbackThreadRequestLabel(), + text = status.feedbackThreadRequestLabel().resolve(), modifier = Modifier .background(statusColor.copy(alpha = 0.12f), RoundedCornerShape(999.dp)) .padding(horizontal = 8.dp, vertical = 3.dp), @@ -238,7 +242,7 @@ private fun MyRequestStatusBadge(status: String) { private fun ShippedInVersionBadge(version: String) { val shippedColor = Color(0xFF2E7D32) Text( - text = "✓ Shipped in $version", + text = stringResource(R.string.feedbackthread_shipped_in, version), modifier = Modifier .background(shippedColor.copy(alpha = 0.12f), RoundedCornerShape(999.dp)) .padding(horizontal = 8.dp, vertical = 3.dp), diff --git a/feedbackthread/src/main/java/com/feedbackthread/sdk/FeedbackThreadRequestStatus.kt b/feedbackthread/src/main/java/com/feedbackthread/sdk/FeedbackThreadRequestStatus.kt index 163d725..930daac 100644 --- a/feedbackthread/src/main/java/com/feedbackthread/sdk/FeedbackThreadRequestStatus.kt +++ b/feedbackthread/src/main/java/com/feedbackthread/sdk/FeedbackThreadRequestStatus.kt @@ -39,16 +39,17 @@ internal fun String.feedbackThreadRequestStage(): FeedbackThreadRequestStage = w /** * A user-facing label for the status: the known stage's display name, or the - * raw status sensibly capitalized when the stage isn't recognized. + * raw status sensibly capitalized when the stage isn't recognized - shown + * verbatim, since it is the server's value rather than SDK copy. */ -internal fun String.feedbackThreadRequestLabel(): String = when (val stage = feedbackThreadRequestStage()) { - FeedbackThreadRequestStage.PendingReview -> "Waiting for review" - FeedbackThreadRequestStage.InReview -> "In review" - FeedbackThreadRequestStage.Planned -> "Planned" - FeedbackThreadRequestStage.InProgress -> "In progress" - FeedbackThreadRequestStage.Completed -> "Completed" - FeedbackThreadRequestStage.Rejected -> "Rejected" - is FeedbackThreadRequestStage.Unknown -> stage.rawValue.sensiblyCapitalized() +internal fun String.feedbackThreadRequestLabel(): FeedbackThreadText = when (val stage = feedbackThreadRequestStage()) { + FeedbackThreadRequestStage.PendingReview -> FeedbackThreadText.Resource(R.string.feedbackthread_status_waiting_for_review) + FeedbackThreadRequestStage.InReview -> FeedbackThreadText.Resource(R.string.feedbackthread_status_in_review) + FeedbackThreadRequestStage.Planned -> FeedbackThreadText.Resource(R.string.feedbackthread_status_planned) + FeedbackThreadRequestStage.InProgress -> FeedbackThreadText.Resource(R.string.feedbackthread_status_in_progress) + FeedbackThreadRequestStage.Completed -> FeedbackThreadText.Resource(R.string.feedbackthread_status_completed) + FeedbackThreadRequestStage.Rejected -> FeedbackThreadText.Resource(R.string.feedbackthread_status_rejected) + is FeedbackThreadRequestStage.Unknown -> FeedbackThreadText.Verbatim(stage.rawValue.sensiblyCapitalized()) } /** diff --git a/feedbackthread/src/main/java/com/feedbackthread/sdk/FeedbackThreadText.kt b/feedbackthread/src/main/java/com/feedbackthread/sdk/FeedbackThreadText.kt new file mode 100644 index 0000000..30ce611 --- /dev/null +++ b/feedbackthread/src/main/java/com/feedbackthread/sdk/FeedbackThreadText.kt @@ -0,0 +1,47 @@ +package com.feedbackthread.sdk + +import androidx.annotation.StringRes +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.stringResource + +/** + * Text a drop-in screen shows, carried as a value until composition resolves + * it in the host app's current language. Needed wherever text is produced + * outside composition - a catch block in a launched coroutine, a status + * label - and may be either one of the SDK's own strings or text that must + * be shown exactly as it came. + * + * Text that is always one of the SDK's own strings travels as its + * `@StringRes` id instead. + */ +internal sealed interface FeedbackThreadText { + /** One of the SDK's string resources, with its format arguments. */ + data class Resource(@StringRes val id: Int, val args: List = emptyList()) : FeedbackThreadText + + /** + * Shown as given, never looked up: a message the API wrote, a status + * this SDK doesn't recognize, an error addressed to the integrator. + */ + data class Verbatim(val value: String) : FeedbackThreadText +} + +@Composable +internal fun FeedbackThreadText.resolve(): String = when (this) { + // Without arguments the string is not run through a formatter at all. + is FeedbackThreadText.Resource -> if (args.isEmpty()) stringResource(id) else stringResource(id, *args.toTypedArray()) + is FeedbackThreadText.Verbatim -> value +} + +/** + * What a drop-in screen shows for an error it caught: the end-user copy the + * SDK authored for it ([FeedbackThreadException.userMessage]), in the user's + * language; otherwise the error's own message, verbatim - an + * [FeedbackThreadException.InvalidConfiguration] addresses the integrator + * and stays English on purpose, and an untagged + * [FeedbackThreadException.Server] message is the API's own - or [fallback] + * when the error has no message at all. + */ +internal fun Throwable.feedbackThreadDisplayText(@StringRes fallback: Int): FeedbackThreadText = + (this as? FeedbackThreadException)?.userMessage + ?: message?.let { FeedbackThreadText.Verbatim(it) } + ?: FeedbackThreadText.Resource(fallback) diff --git a/feedbackthread/src/main/res/values-it/strings.xml b/feedbackthread/src/main/res/values-it/strings.xml new file mode 100644 index 0000000..e9f7013 --- /dev/null +++ b/feedbackthread/src/main/res/values-it/strings.xml @@ -0,0 +1,218 @@ + + + + + + + Fine + + Indietro + + Annulla + + Riprova + + ✓ Rilasciata nella versione %1$s + + Risposte + + Commenti + + + + + In attesa di revisione + + In revisione + + Pianificata + + In corso + + Completata + + Rifiutata + + + + + Richieste di funzionalità + + Richiesta + + Suggerisci una funzionalità + + Caricamento delle richieste… + + Impossibile caricare le richieste + + Non è stato possibile caricare le richieste di funzionalità. + + Non è stato possibile salvare il tuo voto. + + Ancora nessuna richiesta approvata + + Condividi un’idea: comparirà dopo la revisione. + + Aggiungi una richiesta + + Tutte + + %1$s (%2$d) + + Nessuna richiesta + + Nessuna richiesta in revisione + + Nessuna richiesta pianificata + + Nessuna richiesta in corso + + Nessuna richiesta completata + + Scegli un altro stato per vedere altre richieste. + + + Vota, %1$d voto + Vota, %1$d voti + + + + Rimuovi il voto, %1$d voto + Rimuovi il voto, %1$d voti + + + + + + Feedback + + Cosa vuoi condividere? + + Titolo breve + + Dettagli + + Invia feedback + + Non è stato possibile inviare il feedback. + + Richiesta inviata per la revisione + + Comparirà nell’elenco delle funzionalità dopo l’approvazione. + + Feedback inviato + + Grazie per averci aiutato a migliorare l’app. + + Richiesta + + Bug + + Recensione + + + + + Le mie richieste + + Caricamento delle tue richieste… + + Impossibile caricare le tue richieste + + Non è stato possibile caricare le tue richieste. + + Non hai ancora inviato richieste + + Tutto ciò che invii compare qui, anche mentre è in attesa di revisione. + + In attesa di revisione + + In corso + + Rilasciate + + Chiuse + + + + + Connessione al tuo feedback… + + + Nuovo messaggio (%1$d) + Nuovi messaggi (%1$d) + + + Impossibile aggiornare le conversazioni. + + Impossibile connettersi alle conversazioni. + + Impossibile caricare questa conversazione. + + Impossibile completare l’azione. Riprova. + + Scrivi una risposta privata + + Scrivi un commento pubblico + + Invio in corso… + + Invia risposta + + Pubblica commento + + I commenti pubblici sono disattivati per questa app. + + Solo tu e lo sviluppatore dell’app potete vedere queste risposte. + + Chiunque usi questa app può vedere questi commenti. + + Disattiva le notifiche + + Avvisami + + Carica i messaggi precedenti + + Caricamento… + + Ancora nessun messaggio. + + Tu + + Sviluppatore + + Utente dell’app + + Messaggio rimosso + + Letto + + Inviato + + Rimuovi messaggio + + Rimuovere il messaggio? + + Nella conversazione resterà indicato che il messaggio è stato rimosso. + + Rimuovi + + + + + Impossibile raggiungere FeedbackThread. + + FeedbackThread ha restituito una risposta illeggibile. + + FeedbackThread ha restituito impostazioni delle conversazioni illeggibili. + + FeedbackThread ha restituito il codice HTTP %1$d. + diff --git a/feedbackthread/src/main/res/values/strings.xml b/feedbackthread/src/main/res/values/strings.xml new file mode 100644 index 0000000..5f0f690 --- /dev/null +++ b/feedbackthread/src/main/res/values/strings.xml @@ -0,0 +1,218 @@ + + + + + + + Done + + Back + + Cancel + + Try again + + ✓ Shipped in %1$s + + Replies + + Comments + + + + + Waiting for review + + In review + + Planned + + In progress + + Completed + + Rejected + + + + + Feature requests + + Request + + Suggest a feature + + Loading requests… + + Couldn’t load requests + + Feature requests could not be loaded. + + Your vote could not be saved. + + No approved requests yet + + Share an idea and it will appear after review. + + Add request + + All + + %1$s (%2$d) + + No requests + + No requests in review + + No planned requests + + No requests in progress + + No completed requests + + Choose another status to see more requests. + + + Vote, %1$d vote + Vote, %1$d votes + + + + Remove vote, %1$d vote + Remove vote, %1$d votes + + + + + + Feedback + + What would you like to share? + + Short title + + Details + + Send feedback + + Feedback could not be sent. + + Request submitted for review + + It will appear in the feature list after it is approved. + + Feedback sent + + Thank you for helping us improve the app. + + Request + + Bug + + Review + + + + + My requests + + Loading your requests… + + Couldn’t load your requests + + Your requests could not be loaded. + + No requests yet + + Anything you submit shows up here, including while it\'s waiting for review. + + Waiting for review + + In progress + + Shipped + + Closed + + + + + Connecting to your feedback… + + + New message (%1$d) + New message (%1$d) + + + Could not refresh conversations. + + Could not connect to conversations. + + Could not load this conversation. + + Could not complete this action. Try again. + + Write a private reply + + Write a public comment + + Sending… + + Send reply + + Post comment + + Public comments are disabled for this app. + + Only you and the app developer can see these replies. + + Everyone using this app can see these comments. + + Mute notifications + + Notify me + + Load earlier messages + + Loading… + + No messages yet. + + You + + Developer + + App user + + Message removed + + Read + + Posted + + Remove message + + Remove message? + + The conversation will show that this message was removed. + + Remove + + + + + Could not reach FeedbackThread. + + FeedbackThread returned an unreadable response. + + FeedbackThread returned unreadable conversation settings. + + FeedbackThread returned HTTP %1$d. + diff --git a/feedbackthread/src/test/java/com/feedbackthread/sdk/FeedbackThreadClientTest.kt b/feedbackthread/src/test/java/com/feedbackthread/sdk/FeedbackThreadClientTest.kt index 43c6f3f..90c91ef 100644 --- a/feedbackthread/src/test/java/com/feedbackthread/sdk/FeedbackThreadClientTest.kt +++ b/feedbackthread/src/test/java/com/feedbackthread/sdk/FeedbackThreadClientTest.kt @@ -1,9 +1,13 @@ package com.feedbackthread.sdk +import com.feedbackthread.sdk.FeedbackThreadStringResources.english +import com.feedbackthread.sdk.FeedbackThreadStringResources.italian import java.io.ByteArrayInputStream import java.io.ByteArrayOutputStream +import java.io.IOException import java.io.InputStream import java.io.OutputStream +import java.net.ConnectException import java.net.HttpURLConnection import java.net.URL import java.nio.charset.StandardCharsets @@ -451,6 +455,134 @@ public class FeedbackThreadClientTest { } } + // Error copy: the exception message integrators read stays English and + // unchanged; the drop-in screens show the copy the SDK authored in the + // user's language, and anything else as written. + + @Test + public fun tagsAnUnreadableResponseFromEveryCallWithItsEndUserCopy(): Unit = runBlocking { + val client = FeedbackThreadClient( + configuration = FeedbackThreadConfiguration("https://example.com", "project-key", "android"), + connectionFactory = { url -> FakeHttpURLConnection(url, 200, "{}") }, + ) + val calls = listOf Unit>( + { client.submit(FeedbackThreadFeedbackSubmission(FeedbackThreadFeedbackKind.BUG, "Crash", "It crashed.")) }, + { client.requests("user-123") }, + { client.setVote("FDBK-request", true, "user-123") }, + { client.myRequests("user-123") }, + { client.myUpdates("user-123") }, + { client.acknowledgeUpdates(listOf("FDBK-shipped"), "user-123") }, + ) + + for (call in calls) { + val error = expectFailure(FeedbackThreadException.InvalidResponse::class.java) { call() } + assertEquals("FeedbackThread returned an unreadable response.", error.message) + val text = error.feedbackThreadDisplayText(R.string.feedbackthread_board_load_failed_fallback) + assertEquals(FeedbackThreadText.Resource(R.string.feedbackthread_error_unreadable_response), text) + assertEquals(error.message, english.render(text)) + assertEquals("FeedbackThread ha restituito una risposta illeggibile.", italian.render(text)) + } + } + + @Test + public fun tagsUnreadableConversationSettingsWithTheirEndUserCopy(): Unit = runBlocking { + val client = FeedbackThreadClient( + configuration = FeedbackThreadConfiguration(projectKey = "project-key"), + connectionFactory = { url -> FakeHttpURLConnection(url, 200, """{"privateRepliesEnabled":true}""") }, + ) + + val error = expectFailure(FeedbackThreadException.InvalidResponse::class.java) { client.conversationSettings() } + + assertEquals("FeedbackThread returned unreadable conversation settings.", error.message) + val text = error.feedbackThreadDisplayText(R.string.feedbackthread_board_load_failed_fallback) + assertEquals(FeedbackThreadText.Resource(R.string.feedbackthread_error_unreadable_conversation_settings), text) + assertEquals(error.message, english.render(text)) + } + + @Test + public fun tagsTheHttpStatusFallbackWithItsEndUserCopyAndCode(): Unit = runBlocking { + val client = FeedbackThreadClient( + configuration = FeedbackThreadConfiguration("https://example.com", "project-key", "android"), + connectionFactory = { url -> FakeHttpURLConnection(url, 502, "Bad gateway") }, + ) + // submit reads its own response; every other call shares one reader. + val calls = listOf Unit>( + { client.submit(FeedbackThreadFeedbackSubmission(FeedbackThreadFeedbackKind.BUG, "Crash", "It crashed.")) }, + { client.requests("user-123") }, + ) + + for (call in calls) { + val error = expectFailure(FeedbackThreadException.Server::class.java) { call() } + assertEquals(502, error.statusCode) + assertEquals("FeedbackThread returned HTTP 502.", error.message) + val text = error.feedbackThreadDisplayText(R.string.feedbackthread_board_load_failed_fallback) + assertEquals(FeedbackThreadText.Resource(R.string.feedbackthread_error_http_status, listOf(502)), text) + assertEquals(error.message, english.render(text)) + assertEquals("FeedbackThread ha restituito il codice HTTP 502.", italian.render(text)) + } + } + + @Test + public fun showsAServerProvidedMessageVerbatim(): Unit = runBlocking { + val client = FeedbackThreadClient( + configuration = FeedbackThreadConfiguration("https://example.com", "project-key", "android"), + connectionFactory = { url -> + FakeHttpURLConnection(url, 404, """{"error":{"code":"not_found","message":"Project was not found."}}""") + }, + ) + val calls = listOf Unit>( + { client.submit(FeedbackThreadFeedbackSubmission(FeedbackThreadFeedbackKind.BUG, "Crash", "It crashed.")) }, + { client.requests("user-123") }, + ) + + for (call in calls) { + val error = expectFailure(FeedbackThreadException.Server::class.java) { call() } + assertEquals("Project was not found.", error.message) + assertNull(error.userMessage) + assertEquals( + FeedbackThreadText.Verbatim("Project was not found."), + error.feedbackThreadDisplayText(R.string.feedbackthread_board_load_failed_fallback), + ) + } + } + + @Test + public fun tagsAnUnreachableServiceOnSubmitWithItsEndUserCopy(): Unit = runBlocking { + val client = FeedbackThreadClient( + configuration = FeedbackThreadConfiguration("https://example.com", "project-key", "android"), + connectionFactory = { url -> UnreachableHttpURLConnection(url) }, + ) + + val error = expectFailure(FeedbackThreadException.InvalidResponse::class.java) { + client.submit(FeedbackThreadFeedbackSubmission(FeedbackThreadFeedbackKind.BUG, "Crash", "It crashed.")) + } + + assertEquals("Could not reach FeedbackThread.", error.message) + assertTrue(error.cause is IOException) + val text = error.feedbackThreadDisplayText(R.string.feedbackthread_form_send_failed_fallback) + assertEquals(FeedbackThreadText.Resource(R.string.feedbackthread_error_unreachable), text) + assertEquals(error.message, english.render(text)) + assertEquals("Impossibile raggiungere FeedbackThread.", italian.render(text)) + } + + @Test + public fun showsAnInvalidConfigurationMessageVerbatimInEnglish(): Unit = runBlocking { + val client = FeedbackThreadClient( + FeedbackThreadConfiguration("https://example.com", " ", "android"), + ) + + val error = expectFailure(FeedbackThreadException.InvalidConfiguration::class.java) { + client.submit(FeedbackThreadFeedbackSubmission(FeedbackThreadFeedbackKind.BUG, "Crash", "It crashed.")) + } + + assertEquals("A FeedbackThread project key is required.", error.message) + assertNull(error.userMessage) + assertEquals( + FeedbackThreadText.Verbatim("A FeedbackThread project key is required."), + error.feedbackThreadDisplayText(R.string.feedbackthread_form_send_failed_fallback), + ) + } + @Test public fun submitsThroughLiveStagingWhenConfigured(): Unit = runBlocking { val baseURL = System.getenv("FEEDBACKTHREAD_LIVE_BASE_URL") @@ -479,6 +611,16 @@ public class FeedbackThreadClientTest { assertTrue(requests.isNotEmpty()) } + private suspend fun expectFailure(type: Class, call: suspend () -> Unit): T { + try { + call() + } catch (error: Throwable) { + if (type.isInstance(error)) return type.cast(error)!! + throw error + } + throw AssertionError("Expected ${type.simpleName}") + } + private companion object { val successResponse: String = """ { @@ -585,3 +727,12 @@ private class FakeHttpURLConnection( override fun getErrorStream(): InputStream? = if (statusCode in 200..299) null else ByteArrayInputStream(responseBytes) } + +/** A connection whose server can't be reached: every network step fails with an IOException. */ +private class UnreachableHttpURLConnection(url: URL) : HttpURLConnection(url) { + override fun connect(): Unit = throw ConnectException("Connection refused") + override fun disconnect() = Unit + override fun usingProxy(): Boolean = false + override fun getOutputStream(): OutputStream = throw ConnectException("Connection refused") + override fun getResponseCode(): Int = throw ConnectException("Connection refused") +} diff --git a/feedbackthread/src/test/java/com/feedbackthread/sdk/FeedbackThreadLocalizationTest.kt b/feedbackthread/src/test/java/com/feedbackthread/sdk/FeedbackThreadLocalizationTest.kt new file mode 100644 index 0000000..11e832d --- /dev/null +++ b/feedbackthread/src/test/java/com/feedbackthread/sdk/FeedbackThreadLocalizationTest.kt @@ -0,0 +1,138 @@ +package com.feedbackthread.sdk + +import com.feedbackthread.sdk.FeedbackThreadStringResources.english +import com.feedbackthread.sdk.FeedbackThreadStringResources.italian +import com.feedbackthread.sdk.FeedbackThreadStringResources.pluralIds +import com.feedbackthread.sdk.FeedbackThreadStringResources.stringIds +import com.feedbackthread.sdk.FeedbackThreadStringResources.translations +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * Integrity of the shipped string resources: English (`values/`) is the + * default, and every translation must be complete. The translations are found + * on disk ([translations]: each `values-*` directory with a strings.xml), so + * adding a language means adding its file and nothing else. + */ +public class FeedbackThreadLocalizationTest { + private val files = mapOf("en" to english) + translations + + @Test + public fun italianIsAmongTheTranslations() { + // Discovery checks whatever is there: without this, a lost values-it would pass. + assertTrue("translations found: ${translations.keys}", "it" in translations) + } + + @Test + public fun everyResourceNameCarriesTheSdkPrefix() { + // Library resources merge into the host app: an unprefixed name would + // be overridden by one of the app's own. + for ((language, file) in files) { + for (name in file.strings.keys + file.plurals.keys) { + assertTrue("$language: $name lacks the feedbackthread_ prefix", name.startsWith("feedbackthread_")) + } + } + } + + @Test + public fun everyTranslationHasEveryEnglishResourceAndNothingElse() { + for ((language, file) in translations) { + assertEquals("$language strings", english.strings.keys, file.strings.keys) + assertEquals("$language plurals", english.plurals.keys, file.plurals.keys) + } + } + + @Test + public fun noValueIsEmpty() { + for ((language, file) in files) { + file.strings.forEach { (name, value) -> assertTrue("$language: $name is empty", value.isNotBlank()) } + file.plurals.forEach { (name, items) -> + items.forEach { (quantity, value) -> assertTrue("$language: $name/$quantity is empty", value.isNotBlank()) } + } + } + } + + @Test + public fun everyPlaceholderIsPositional() { + for ((language, file) in files) { + val values = file.strings.entries.map { it.key to it.value } + + file.plurals.flatMap { (name, items) -> items.map { (quantity, value) -> "$name/$quantity" to value } } + for ((name, value) in values) { + for (placeholder in placeholders(value)) { + assertTrue("$language: $name uses $placeholder", placeholder.matches(Regex("%\\d+\\$.+"))) + } + } + } + } + + @Test + public fun translationsKeepTheEnglishPlaceholders() { + for ((language, file) in translations) { + for ((name, value) in file.strings) { + assertEquals("$language: $name", placeholders(english.strings.getValue(name)), placeholders(value)) + } + for ((name, items) in file.plurals) { + val englishItems = english.plurals.getValue(name) + for ((quantity, value) in items) { + // A quantity English lacks (e.g. "many") is compared with English "other". + val englishValue = englishItems[quantity] ?: englishItems.getValue("other") + assertEquals("$language: $name/$quantity", placeholders(englishValue), placeholders(value)) + } + } + } + } + + @Test + public fun everyPluralHasTheQuantitiesItsLanguageNeeds() { + for ((language, file) in files) { + file.plurals.forEach { (name, items) -> assertTrue("$language: $name lacks other", "other" in items) } + } + for ((name, items) in english.plurals) { + assertTrue("en: $name lacks one", "one" in items) + } + for ((name, items) in italian.plurals) { + assertTrue("it: $name lacks one", "one" in items) + } + } + + @Test + public fun everyGeneratedResourceIsInEveryFile() { + assertTrue(stringIds.isNotEmpty()) + assertTrue(pluralIds.isNotEmpty()) + for ((language, file) in files) { + stringIds.keys.forEach { assertTrue("$language lacks string $it", it in file.strings) } + pluralIds.keys.forEach { assertTrue("$language lacks plurals $it", it in file.plurals) } + } + } + + @Test + public fun generatedIdsAreDistinctSoMappingTestsCanTellResourcesApart() { + assertEquals(stringIds.size, stringIds.values.toSet().size) + assertTrue(stringIds.values.none { it == 0 }) + } + + @Test + public fun apostrophesAreEscapedEverywhereAndTypographicInItalian() { + // aapt's rule, for every file: a straight apostrophe needs its backslash. + for ((language, file) in files) { + val raws = file.rawStrings.entries.map { it.key to it.value } + + file.rawPlurals.flatMap { (name, items) -> items.map { (quantity, raw) -> "$name/$quantity" to raw } } + for ((name, raw) in raws) { + assertTrue("$language: $name has an unescaped apostrophe", !Regex("(? assertTrue("it: $name uses a straight apostrophe", '\'' !in value) } + italian.plurals.forEach { (name, items) -> + items.values.forEach { assertTrue("it: $name uses a straight apostrophe", '\'' !in it) } + } + } + + private fun placeholders(value: String): List = + Regex("%(\\d+\\$)?[-#+ 0,(]*\\d*(\\.\\d+)?[a-zA-Z%]").findAll(value) + .map { it.value } + .filter { it != "%%" } + .sorted() + .toList() +} diff --git a/feedbackthread/src/test/java/com/feedbackthread/sdk/FeedbackThreadRequestStatusTest.kt b/feedbackthread/src/test/java/com/feedbackthread/sdk/FeedbackThreadRequestStatusTest.kt index 249bf24..9cdebbb 100644 --- a/feedbackthread/src/test/java/com/feedbackthread/sdk/FeedbackThreadRequestStatusTest.kt +++ b/feedbackthread/src/test/java/com/feedbackthread/sdk/FeedbackThreadRequestStatusTest.kt @@ -1,5 +1,7 @@ package com.feedbackthread.sdk +import com.feedbackthread.sdk.FeedbackThreadStringResources.english +import com.feedbackthread.sdk.FeedbackThreadStringResources.italian import org.junit.Assert.assertEquals import org.junit.Test @@ -16,14 +18,42 @@ public class FeedbackThreadRequestStatusTest { assertEquals(FeedbackThreadRequestStage.Rejected, "Rejected".feedbackThreadRequestStage()) } + @Test + public fun labelsEveryKnownStatusWithItsResourceAndUnchangedEnglish() { + // Wire status -> the label resource it maps to, and the English label + // the SDK showed before it was localized: English must not move. + val labels = listOf( + Triple("Submitted", R.string.feedbackthread_status_waiting_for_review, "Waiting for review"), + Triple("Under review", R.string.feedbackthread_status_in_review, "In review"), + Triple("In review", R.string.feedbackthread_status_in_review, "In review"), + Triple("Planned", R.string.feedbackthread_status_planned, "Planned"), + Triple("In progress", R.string.feedbackthread_status_in_progress, "In progress"), + Triple("Ready to release", R.string.feedbackthread_status_in_progress, "In progress"), + Triple("Released", R.string.feedbackthread_status_completed, "Completed"), + Triple("Rejected", R.string.feedbackthread_status_rejected, "Rejected"), + ) + + for ((status, resource, englishLabel) in labels) { + val label = status.feedbackThreadRequestLabel() + assertEquals(status, FeedbackThreadText.Resource(resource), label) + assertEquals(status, englishLabel, english.render(label)) + } + } + @Test public fun labelsPendingReviewHonestlyWithoutImplyingModerationHappened() { - assertEquals("Waiting for review", "Submitted".feedbackThreadRequestLabel()) + val label = "Submitted".feedbackThreadRequestLabel() + + assertEquals("Waiting for review", english.render(label)) + assertEquals("In attesa di revisione", italian.render(label)) } @Test public fun labelsRejectedPlainly() { - assertEquals("Rejected", "Rejected".feedbackThreadRequestLabel()) + val label = "Rejected".feedbackThreadRequestLabel() + + assertEquals("Rejected", english.render(label)) + assertEquals("Rifiutata", italian.render(label)) } @Test @@ -31,13 +61,13 @@ public class FeedbackThreadRequestStatusTest { val status = "archived" assertEquals(FeedbackThreadRequestStage.Unknown("archived"), status.feedbackThreadRequestStage()) - assertEquals("Archived", status.feedbackThreadRequestLabel()) + assertEquals(FeedbackThreadText.Verbatim("Archived"), status.feedbackThreadRequestLabel()) } @Test public fun sensiblyCapitalizesAnUnrecognizedStatusWithSeparators() { - assertEquals("On Hold", "on_hold".feedbackThreadRequestLabel()) - assertEquals("Needs Triage", "NEEDS-TRIAGE".feedbackThreadRequestLabel()) + assertEquals(FeedbackThreadText.Verbatim("On Hold"), "on_hold".feedbackThreadRequestLabel()) + assertEquals(FeedbackThreadText.Verbatim("Needs Triage"), "NEEDS-TRIAGE".feedbackThreadRequestLabel()) } @Test diff --git a/feedbackthread/src/test/java/com/feedbackthread/sdk/FeedbackThreadStringResources.kt b/feedbackthread/src/test/java/com/feedbackthread/sdk/FeedbackThreadStringResources.kt new file mode 100644 index 0000000..2f4ee20 --- /dev/null +++ b/feedbackthread/src/test/java/com/feedbackthread/sdk/FeedbackThreadStringResources.kt @@ -0,0 +1,115 @@ +package com.feedbackthread.sdk + +import java.io.File +import java.util.Locale +import javax.xml.parsers.DocumentBuilderFactory +import org.w3c.dom.Element + +/** + * The SDK's string resources read straight from `src/main/res`, so plain JVM + * tests can check the copy that ships - and what a [FeedbackThreadText] + * reads as in English and in Italian - without Robolectric. + */ +internal object FeedbackThreadStringResources { + /** + * Gradle runs unit tests from the module directory; an IDE may run them + * from the repository root. + */ + val resDirectory: File = listOf(File("src/main/res"), File("feedbackthread/src/main/res")) + .firstOrNull { File(it, "values/strings.xml").isFile } + ?: error("src/main/res not found from ${File("").absolutePath}") + + val english = FeedbackThreadStringFile(File(resDirectory, "values/strings.xml"), Locale.ENGLISH) + + /** + * Every translation, by resource qualifier (`it`, `pt-rBR`): each + * `values-*` directory that holds a strings.xml. Found on disk rather than + * listed, so a new language is checked as soon as its file exists. + */ + val translations: Map = resDirectory.listFiles().orEmpty() + .filter { it.name.startsWith("values-") && File(it, "strings.xml").isFile } + .sortedBy { it.name } + .associate { directory -> + val qualifier = directory.name.removePrefix("values-") + qualifier to FeedbackThreadStringFile(File(directory, "strings.xml"), locale(qualifier)) + } + + /** The Italian file, for tests that check exact Italian copy. */ + val italian: FeedbackThreadStringFile get() = translations.getValue("it") + + /** The fields of the module's generated `R.string`: resource name to id. */ + val stringIds: Map = R.string::class.java.fields.associate { it.name to it.getInt(null) } + + /** The fields of the module's generated `R.plurals`: resource name to id. */ + val pluralIds: Map = R.plurals::class.java.fields.associate { it.name to it.getInt(null) } + + fun stringName(id: Int): String = + stringIds.entries.singleOrNull { it.value == id }?.key ?: error("No R.string field has the id $id") + + /** The locale a `values-*` qualifier names: `it`, `pt-rBR`, or BCP 47 `b+sr+Latn`. */ + private fun locale(qualifier: String): Locale = Locale.forLanguageTag( + if (qualifier.startsWith("b+")) qualifier.removePrefix("b+").replace('+', '-') else qualifier.replace("-r", "-"), + ) +} + +internal class FeedbackThreadStringFile(file: File, private val locale: Locale) { + /** Each ``'s value as Android resolves it (escapes undone). */ + val strings: Map + + /** Each ``'s text exactly as written in the file. */ + val rawStrings: Map + + /** Each ``'s items, quantity to value (escapes undone). */ + val plurals: Map> + + /** Each ``'s items, quantity to text exactly as written in the file. */ + val rawPlurals: Map> + + init { + val root = DocumentBuilderFactory.newInstance().newDocumentBuilder().parse(file).documentElement + val children = (0 until root.childNodes.length).map { root.childNodes.item(it) }.filterIsInstance() + rawStrings = children.filter { it.tagName == "string" }.associate { it.getAttribute("name") to it.textContent } + strings = rawStrings.mapValues { unescaped(it.value) } + rawPlurals = children.filter { it.tagName == "plurals" }.associate { element -> + val items = element.getElementsByTagName("item") + element.getAttribute("name") to (0 until items.length).map { items.item(it) as Element } + .associate { it.getAttribute("quantity") to it.textContent } + } + plurals = rawPlurals.mapValues { (_, items) -> items.mapValues { unescaped(it.value) } } + } + + /** What [text] reads as in this file's language, formatted the way `getString(id, args)` formats it. */ + fun render(text: FeedbackThreadText): String = when (text) { + is FeedbackThreadText.Verbatim -> text.value + is FeedbackThreadText.Resource -> { + val value = strings.getValue(FeedbackThreadStringResources.stringName(text.id)) + if (text.args.isEmpty()) value else String.format(locale, value, *text.args.toTypedArray()) + } + } + + /** Undoes the backslash escapes of Android string resources (`\'`, `\"`, `\n`, `\t`, `\uXXXX`). */ + private fun unescaped(raw: String): String { + val value = StringBuilder() + var index = 0 + val text = raw.trim() + while (index < text.length) { + val character = text[index] + if (character == '\\' && index + 1 < text.length) { + when (val escaped = text[index + 1]) { + 'n' -> value.append('\n') + 't' -> value.append('\t') + 'u' -> { + value.append(text.substring(index + 2, index + 6).toInt(16).toChar()) + index += 4 + } + else -> value.append(escaped) + } + index += 2 + } else { + value.append(character) + index += 1 + } + } + return value.toString() + } +} diff --git a/feedbackthread/src/test/java/com/feedbackthread/sdk/FeedbackThreadTextTest.kt b/feedbackthread/src/test/java/com/feedbackthread/sdk/FeedbackThreadTextTest.kt new file mode 100644 index 0000000..f6dfbe1 --- /dev/null +++ b/feedbackthread/src/test/java/com/feedbackthread/sdk/FeedbackThreadTextTest.kt @@ -0,0 +1,96 @@ +package com.feedbackthread.sdk + +import com.feedbackthread.sdk.FeedbackThreadStringResources.english +import com.feedbackthread.sdk.FeedbackThreadStringResources.italian +import com.feedbackthread.sdk.FeedbackThreadStringResources.stringName +import java.io.IOException +import org.junit.Assert.assertEquals +import org.junit.Test + +public class FeedbackThreadTextTest { + @Test + public fun showsAnyOtherErrorsOwnMessageVerbatim() { + assertEquals( + FeedbackThreadText.Verbatim("Unable to resolve host"), + IOException("Unable to resolve host").feedbackThreadDisplayText(R.string.feedbackthread_board_load_failed_fallback), + ) + } + + @Test + public fun fallsBackToTheScreensOwnCopyForAnErrorWithoutAMessage() { + val text = IllegalStateException().feedbackThreadDisplayText(R.string.feedbackthread_my_requests_load_failed_fallback) + + assertEquals(FeedbackThreadText.Resource(R.string.feedbackthread_my_requests_load_failed_fallback), text) + assertEquals("Your requests could not be loaded.", english.render(text)) + assertEquals("Non è stato possibile caricare le tue richieste.", italian.render(text)) + } + + @Test + public fun localizesExactlyTheTwoErrorsTheConversationManagerWrites() { + val refresh = conversationErrorText(CONVERSATIONS_REFRESH_FAILED) + val connect = conversationErrorText(CONVERSATIONS_CONNECT_FAILED) + + assertEquals(FeedbackThreadText.Resource(R.string.feedbackthread_conversation_refresh_failed), refresh) + assertEquals(FeedbackThreadText.Resource(R.string.feedbackthread_conversation_connect_failed), connect) + // The state's public, English value is unchanged. + assertEquals("Could not refresh conversations.", CONVERSATIONS_REFRESH_FAILED) + assertEquals("Could not connect to conversations.", CONVERSATIONS_CONNECT_FAILED) + assertEquals(CONVERSATIONS_REFRESH_FAILED, english.render(refresh)) + assertEquals(CONVERSATIONS_CONNECT_FAILED, english.render(connect)) + assertEquals("Impossibile aggiornare le conversazioni.", italian.render(refresh)) + assertEquals("Impossibile connettersi alle conversazioni.", italian.render(connect)) + } + + @Test + public fun showsAnyOtherConversationErrorVerbatim() { + for (error in listOf("Conversation request failed (500).", "could not refresh conversations.", "")) { + assertEquals(FeedbackThreadText.Verbatim(error), conversationErrorText(error)) + } + } + + @Test + public fun titlesEveryBoardFilterAndItsEmptyState() { + val copy = RequestFilter.entries.associateWith { filter -> + english.strings.getValue(stringName(filter.title)) to english.strings.getValue(stringName(filter.emptyTitle)) + } + + assertEquals( + mapOf( + RequestFilter.ALL to ("All" to "No requests"), + RequestFilter.IN_REVIEW to ("In review" to "No requests in review"), + RequestFilter.PLANNED to ("Planned" to "No planned requests"), + RequestFilter.IN_PROGRESS to ("In progress" to "No requests in progress"), + RequestFilter.COMPLETED to ("Completed" to "No completed requests"), + ), + copy, + ) + assertEquals("Tutte", italian.strings.getValue(stringName(RequestFilter.ALL.title))) + assertEquals("Nessuna richiesta in revisione", italian.strings.getValue(stringName(RequestFilter.IN_REVIEW.emptyTitle))) + } + + @Test + public fun stageFiltersShareTheStatusLabels() { + val stageFilters = mapOf( + RequestFilter.IN_REVIEW to "In review", + RequestFilter.PLANNED to "Planned", + RequestFilter.IN_PROGRESS to "In progress", + RequestFilter.COMPLETED to "Released", + ) + + for ((filter, status) in stageFilters) { + assertEquals(filter.name, status.feedbackThreadRequestLabel(), FeedbackThreadText.Resource(filter.title)) + } + } + + @Test + public fun localizesEveryFeedbackKindWhileItsPublicTitleStaysEnglish() { + for (kind in FeedbackThreadFeedbackKind.entries) { + assertEquals(kind.name, kind.title, english.strings.getValue(stringName(kind.localizedTitle))) + } + assertEquals("Bug", FeedbackThreadFeedbackKind.BUG.title) + assertEquals("Request", FeedbackThreadFeedbackKind.REQUEST.title) + assertEquals("Review", FeedbackThreadFeedbackKind.REVIEW.title) + assertEquals("Richiesta", italian.strings.getValue(stringName(FeedbackThreadFeedbackKind.REQUEST.localizedTitle))) + assertEquals("Recensione", italian.strings.getValue(stringName(FeedbackThreadFeedbackKind.REVIEW.localizedTitle))) + } +}