diff --git a/CHANGELOG.md b/CHANGELOG.md index b818048..86c67f8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). - `StringResourcesTranslationTest` fails when a translatable string or plural is missing from either language, its format arguments differ, or a Japanese string says ショップ / 店, shortens アイテムタグ, or mentions 組織 (the Free edition has no organizations); `JapaneseResourcesPackagedTest` checks the Japanese resources are packaged ### Changed +- The repositories call their preferences source `nativeAppTemplatePreferencesDataSource` (was `natPreferencesDataSource`, and `mtcPreferencesDataSource` in `ItemTagRepositoryImpl`). Found while building Orange Baton from the template (#92) +- Theme.kt says to give roles that share a color the same on* color too (`contentColorFor` matches `errorContainer` before `surface`; `ContentColorTest`), and the FABs state `primary` / `onPrimary` like `SubmitFab`. Found while building Orange Baton from the template (#92) +- The delete-account confirmation says that the shops and item tags are deleted with the account. Found while building Orange Baton from the template (#92) - Text that was hardcoded in Kotlin now comes from strings.xml: the minimum password length hint (a plural) and the alert dialog's default "OK" - The agreement and "Please accept updated ..." sentences are single format strings with the links as arguments (`linkedFormat()`), so a translation can put the links where its grammar needs them - `app_name` is `translatable="false"` @@ -23,6 +26,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). - 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 +- A session file that the key does not decrypt is no longer read as unencrypted proto: `UserPreferencesSerializer` throws `CorruptionException` and the app starts over signed out, so a plain file put on the device cannot sign it in. Found while building Orange Baton from the template (#92) +- Notices of something done shown after a restart (shop or account deleted) go by themselves (`SnackbarDuration.Short`) instead of staying over the add button; the new email's reconfirmation still stays. Found while building Orange Baton from the template (#92) - Comments say "the agent" instead of the agent's name (renamed with the product) and drop "a"/"an" before domain words, which can't follow the first sound of the renamed word; test fixtures say "This is shop.". `StringResourcesTranslationTest` now fails on "a" or "an" before a domain word or the product name in an English string - 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`) - An error snackbar no longer stays after the screen recovers (e.g. the offline `[NATIVEAPPTEMPLATE-2002]` error still showing after the network is back and Reload succeeded). Every screen's load (Reload, pull to refresh, resume) and every sign-in attempt now clears the previous attempt's error when it starts, which closes its snackbar; an error of the new attempt still shows. Ported from MyTurnTagCreator-Android #139 diff --git a/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/data/item_tag/ItemTagRepositoryImpl.kt b/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/data/item_tag/ItemTagRepositoryImpl.kt index 3dade16..279e2d2 100644 --- a/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/data/item_tag/ItemTagRepositoryImpl.kt +++ b/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/data/item_tag/ItemTagRepositoryImpl.kt @@ -10,7 +10,7 @@ import kotlinx.coroutines.CoroutineDispatcher import javax.inject.Inject class ItemTagRepositoryImpl @Inject constructor( - private val mtcPreferencesDataSource: NativeAppTemplatePreferencesDataSource, + private val nativeAppTemplatePreferencesDataSource: NativeAppTemplatePreferencesDataSource, private val api: ItemTagApi, @param:Dispatcher(NativeAppTemplateDispatchers.IO) private val ioDispatcher: CoroutineDispatcher, ) : ItemTagRepository { @@ -20,7 +20,7 @@ class ItemTagRepositoryImpl @Inject constructor( page: Int?, ) = apiRequest(ioDispatcher) { api.getItemTags( - mtcPreferencesDataSource.currentAccountId(), + nativeAppTemplatePreferencesDataSource.currentAccountId(), shopId, page, ) @@ -30,7 +30,7 @@ class ItemTagRepositoryImpl @Inject constructor( id: String, ) = apiRequest(ioDispatcher) { api.getItemTag( - mtcPreferencesDataSource.currentAccountId(), + nativeAppTemplatePreferencesDataSource.currentAccountId(), id, ) } @@ -40,7 +40,7 @@ class ItemTagRepositoryImpl @Inject constructor( itemTagBody: ItemTagBody, ) = apiRequest(ioDispatcher) { api.createItemTag( - mtcPreferencesDataSource.currentAccountId(), + nativeAppTemplatePreferencesDataSource.currentAccountId(), shopId, itemTagBody, ) @@ -51,7 +51,7 @@ class ItemTagRepositoryImpl @Inject constructor( itemTagBody: ItemTagBody, ) = apiRequest(ioDispatcher) { api.updateItemTag( - mtcPreferencesDataSource.currentAccountId(), + nativeAppTemplatePreferencesDataSource.currentAccountId(), id, itemTagBody, ) @@ -60,18 +60,18 @@ class ItemTagRepositoryImpl @Inject constructor( override fun deleteItemTag( id: String, ) = apiAction(ioDispatcher) { - api.deleteItemTag(mtcPreferencesDataSource.currentAccountId(), id) + api.deleteItemTag(nativeAppTemplatePreferencesDataSource.currentAccountId(), id) } override fun completeItemTag( id: String, ) = apiRequest(ioDispatcher) { - api.completeItemTag(mtcPreferencesDataSource.currentAccountId(), id) + api.completeItemTag(nativeAppTemplatePreferencesDataSource.currentAccountId(), id) } override fun idleItemTag( id: String, ) = apiRequest(ioDispatcher) { - api.idleItemTag(mtcPreferencesDataSource.currentAccountId(), id) + api.idleItemTag(nativeAppTemplatePreferencesDataSource.currentAccountId(), id) } } diff --git a/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/data/login/AccountPasswordRepositoryImpl.kt b/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/data/login/AccountPasswordRepositoryImpl.kt index 2670fa6..c5c014d 100644 --- a/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/data/login/AccountPasswordRepositoryImpl.kt +++ b/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/data/login/AccountPasswordRepositoryImpl.kt @@ -9,7 +9,7 @@ import kotlinx.coroutines.CoroutineDispatcher import javax.inject.Inject class AccountPasswordRepositoryImpl @Inject constructor( - private val natPreferencesDataSource: NativeAppTemplatePreferencesDataSource, + private val nativeAppTemplatePreferencesDataSource: NativeAppTemplatePreferencesDataSource, private val api: AccountPasswordApi, @param:Dispatcher(NativeAppTemplateDispatchers.IO) private val ioDispatcher: CoroutineDispatcher, ) : AccountPasswordRepository { @@ -17,7 +17,7 @@ class AccountPasswordRepositoryImpl @Inject constructor( updatePasswordBody: UpdatePasswordBody, ) = apiAction(ioDispatcher) { api.updateAccountPassword( - natPreferencesDataSource.currentAccountId(), + nativeAppTemplatePreferencesDataSource.currentAccountId(), updatePasswordBody, ) } diff --git a/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/data/login/LoginRepositoryImpl.kt b/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/data/login/LoginRepositoryImpl.kt index 418db39..7f8f1f7 100644 --- a/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/data/login/LoginRepositoryImpl.kt +++ b/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/data/login/LoginRepositoryImpl.kt @@ -28,7 +28,7 @@ import javax.inject.Inject @VisibleForTesting class LoginRepositoryImpl @Inject constructor( private val api: LoginApi, - private val natPreferencesDataSource: NativeAppTemplatePreferencesDataSource, + private val nativeAppTemplatePreferencesDataSource: NativeAppTemplatePreferencesDataSource, @param:Dispatcher(NativeAppTemplateDispatchers.IO) private val ioDispatcher: CoroutineDispatcher, ) : LoginRepository { @@ -39,7 +39,7 @@ class LoginRepositoryImpl @Inject constructor( .flowOn(ioDispatcher) override val userData: Flow = - natPreferencesDataSource.userData + nativeAppTemplatePreferencesDataSource.userData override fun logout(notice: SignOutNotice?) = flow { val response = api.logout() @@ -55,80 +55,80 @@ class LoginRepositoryImpl @Inject constructor( override fun getPermissions() = apiRequest(ioDispatcher) { api.getPermissions( - natPreferencesDataSource.currentAccountId(), + nativeAppTemplatePreferencesDataSource.currentAccountId(), ) } override fun updateConfirmedPrivacyVersion() = apiAction(ioDispatcher) { api.updateConfirmedPrivacyVersion( - natPreferencesDataSource.currentAccountId(), + nativeAppTemplatePreferencesDataSource.currentAccountId(), ) } override fun updateConfirmedTermsVersion() = apiAction(ioDispatcher) { api.updateConfirmedTermsVersion( - natPreferencesDataSource.currentAccountId(), + nativeAppTemplatePreferencesDataSource.currentAccountId(), ) } override suspend fun setAccountId(accountId: String) { - natPreferencesDataSource.setAccountId(accountId) + nativeAppTemplatePreferencesDataSource.setAccountId(accountId) } override suspend fun setShopkeeper(loggedInShopkeeper: LoggedInShopkeeper) { - natPreferencesDataSource.setShopkeeper(loggedInShopkeeper) + nativeAppTemplatePreferencesDataSource.setShopkeeper(loggedInShopkeeper) } override suspend fun setShopkeeperForUpdate(loggedInShopkeeper: LoggedInShopkeeper) { - natPreferencesDataSource.setShopkeeperForUpdate(loggedInShopkeeper) + nativeAppTemplatePreferencesDataSource.setShopkeeperForUpdate(loggedInShopkeeper) } override suspend fun setPermissions(permissions: Permissions) { - natPreferencesDataSource.setPermissions(permissions) + nativeAppTemplatePreferencesDataSource.setPermissions(permissions) } override suspend fun setDarkThemeConfig(darkThemeConfig: DarkThemeConfig) { - natPreferencesDataSource.setDarkThemeConfig(darkThemeConfig) + nativeAppTemplatePreferencesDataSource.setDarkThemeConfig(darkThemeConfig) } override suspend fun setDidShowTapShopBelowTip(didShowTapShopBelowTip: Boolean) { - natPreferencesDataSource.setDidShowTapShopBelowTip(didShowTapShopBelowTip) + nativeAppTemplatePreferencesDataSource.setDidShowTapShopBelowTip(didShowTapShopBelowTip) } override suspend fun setIsEmailUpdated(isEmailUpdated: Boolean) { - natPreferencesDataSource.setIsEmailUpdated(isEmailUpdated) + nativeAppTemplatePreferencesDataSource.setIsEmailUpdated(isEmailUpdated) } override suspend fun setIsMyAccountDeleted(isMyAccountDeleted: Boolean) { - natPreferencesDataSource.setIsMyAccountDeleted(isMyAccountDeleted) + nativeAppTemplatePreferencesDataSource.setIsMyAccountDeleted(isMyAccountDeleted) } override suspend fun setIsShopDeleted(isShopDeleted: Boolean) { - natPreferencesDataSource.setIsShopDeleted(isShopDeleted) + nativeAppTemplatePreferencesDataSource.setIsShopDeleted(isShopDeleted) } override suspend fun clearSession(notice: SignOutNotice?) { - natPreferencesDataSource.clearSession( + nativeAppTemplatePreferencesDataSource.clearSession( isEmailUpdated = notice == SignOutNotice.EMAIL_UPDATED, isMyAccountDeleted = notice == SignOutNotice.ACCOUNT_DELETED, ) } - override fun isLoggedIn(): Flow = natPreferencesDataSource.isLoggedIn() + override fun isLoggedIn(): Flow = nativeAppTemplatePreferencesDataSource.isLoggedIn() - override fun shouldUpdateApp(): Flow = natPreferencesDataSource.shouldUpdateApp() + override fun shouldUpdateApp(): Flow = nativeAppTemplatePreferencesDataSource.shouldUpdateApp() - override fun shouldUpdatePrivacy(): Flow = natPreferencesDataSource.shouldUpdatePrivacy() + override fun shouldUpdatePrivacy(): Flow = nativeAppTemplatePreferencesDataSource.shouldUpdatePrivacy() - override fun shouldUpdateTerms(): Flow = natPreferencesDataSource.shouldUpdateTerms() + override fun shouldUpdateTerms(): Flow = nativeAppTemplatePreferencesDataSource.shouldUpdateTerms() - override fun isEmailUpdated(): Flow = natPreferencesDataSource.isEmailUpdated() + override fun isEmailUpdated(): Flow = nativeAppTemplatePreferencesDataSource.isEmailUpdated() - override fun isMyAccountDeleted(): Flow = natPreferencesDataSource.isMyAccountDeleted() + override fun isMyAccountDeleted(): Flow = nativeAppTemplatePreferencesDataSource.isMyAccountDeleted() - override fun isShopDeleted(): Flow = natPreferencesDataSource.isShopDeleted() + override fun isShopDeleted(): Flow = nativeAppTemplatePreferencesDataSource.isShopDeleted() - override fun didShowTapShopBelowTip(): Flow = natPreferencesDataSource.didShowTapShopBelowTip() + override fun didShowTapShopBelowTip(): Flow = nativeAppTemplatePreferencesDataSource.didShowTapShopBelowTip() } /** diff --git a/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/data/shop/ShopRepositoryImpl.kt b/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/data/shop/ShopRepositoryImpl.kt index 1e36bb9..23a25ab 100644 --- a/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/data/shop/ShopRepositoryImpl.kt +++ b/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/data/shop/ShopRepositoryImpl.kt @@ -10,14 +10,14 @@ import kotlinx.coroutines.CoroutineDispatcher import javax.inject.Inject class ShopRepositoryImpl @Inject constructor( - private val natPreferencesDataSource: NativeAppTemplatePreferencesDataSource, + private val nativeAppTemplatePreferencesDataSource: NativeAppTemplatePreferencesDataSource, private val api: ShopApi, @param:Dispatcher(NativeAppTemplateDispatchers.IO) private val ioDispatcher: CoroutineDispatcher, ) : ShopRepository { override fun getShops() = apiRequest(ioDispatcher) { api.getShops( - natPreferencesDataSource.currentAccountId(), + nativeAppTemplatePreferencesDataSource.currentAccountId(), ) } @@ -25,7 +25,7 @@ class ShopRepositoryImpl @Inject constructor( id: String, ) = apiRequest(ioDispatcher) { api.getShop( - natPreferencesDataSource.currentAccountId(), + nativeAppTemplatePreferencesDataSource.currentAccountId(), id, ) } @@ -34,7 +34,7 @@ class ShopRepositoryImpl @Inject constructor( shopBody: ShopBody, ) = apiRequest(ioDispatcher) { api.createShop( - natPreferencesDataSource.currentAccountId(), + nativeAppTemplatePreferencesDataSource.currentAccountId(), shopBody, ) } @@ -44,7 +44,7 @@ class ShopRepositoryImpl @Inject constructor( shopUpdateBody: ShopUpdateBody, ) = apiRequest(ioDispatcher) { api.updateShop( - natPreferencesDataSource.currentAccountId(), + nativeAppTemplatePreferencesDataSource.currentAccountId(), id, shopUpdateBody, ) @@ -53,6 +53,6 @@ class ShopRepositoryImpl @Inject constructor( override fun deleteShop( id: String, ) = apiAction(ioDispatcher) { - api.deleteShop(natPreferencesDataSource.currentAccountId(), id) + api.deleteShop(nativeAppTemplatePreferencesDataSource.currentAccountId(), id) } } diff --git a/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/datastore/UserPreferencesSerializer.kt b/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/datastore/UserPreferencesSerializer.kt index c2dfd09..93a8876 100644 --- a/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/datastore/UserPreferencesSerializer.kt +++ b/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/datastore/UserPreferencesSerializer.kt @@ -29,8 +29,7 @@ import javax.inject.Inject /** * An [androidx.datastore.core.Serializer] for the [UserPreferences] proto. * - * Encrypts data at rest using Tink AEAD. On read, falls back to parsing - * unencrypted proto for migration from the previous unencrypted format. + * Encrypts data at rest using Tink AEAD. */ class UserPreferencesSerializer @Inject constructor( private val aead: Aead, @@ -43,14 +42,15 @@ class UserPreferencesSerializer @Inject constructor( return try { val decrypted = aead.decrypt(bytes, null) UserPreferences.parseFrom(decrypted) - } catch (_: GeneralSecurityException) { - // Fallback: try parsing as unencrypted proto (migration from legacy format). - // The data will be re-encrypted on the next write. - try { - UserPreferences.parseFrom(bytes) - } catch (e: InvalidProtocolBufferException) { - throw CorruptionException("Cannot read proto.", e) - } + } catch (e: GeneralSecurityException) { + // Only what this app encrypted is a session. The file has never been written unencrypted, so + // bytes the key does not accept are corruption (DataStoreModule then starts over signed out), + // never a fallback to reading them as plain proto: that would let an unencrypted file put on + // the device, or ciphertext that happens to parse once the Keystore key is gone, stand in for + // the signed-in shopkeeper. + throw CorruptionException("Cannot decrypt proto.", e) + } catch (e: InvalidProtocolBufferException) { + throw CorruptionException("Cannot read proto.", e) } } diff --git a/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/designsystem/theme/Theme.kt b/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/designsystem/theme/Theme.kt index 3631089..6db640d 100644 --- a/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/designsystem/theme/Theme.kt +++ b/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/designsystem/theme/Theme.kt @@ -9,6 +9,11 @@ import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.dp +// When you give several roles one color (your palette's surface also as errorContainer, say), give +// their on* colors one color too. A Surface, Card or Dialog without colors takes its text from +// contentColorFor, which matches errorContainer before surface: with onErrorContainer left red, the +// text of every plain surface turns red (ContentColorTest). FABs state their colors for the same +// reason: the default primaryContainer, made the screen's color, would hide them. val LightDefaultColorScheme = lightColorScheme( primary = Blue40, onPrimary = Color.White, diff --git a/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/network/AuthInterceptor.kt b/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/network/AuthInterceptor.kt index 00eaea6..39eeceb 100644 --- a/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/network/AuthInterceptor.kt +++ b/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/network/AuthInterceptor.kt @@ -20,15 +20,15 @@ import kotlin.coroutines.cancellation.CancellationException */ @Singleton class AuthInterceptor internal constructor( - private val natPreferencesDataSource: NativeAppTemplatePreferencesDataSource, + private val nativeAppTemplatePreferencesDataSource: NativeAppTemplatePreferencesDataSource, private val apiHost: String, ) : Interceptor { @Inject - constructor(natPreferencesDataSource: NativeAppTemplatePreferencesDataSource) : - this(natPreferencesDataSource, BuildConfig.DOMAIN) + constructor(nativeAppTemplatePreferencesDataSource: NativeAppTemplatePreferencesDataSource) : + this(nativeAppTemplatePreferencesDataSource, BuildConfig.DOMAIN) private suspend fun requestHelper(): RequestHelper { - val userData = natPreferencesDataSource.userData.first() + val userData = nativeAppTemplatePreferencesDataSource.userData.first() return RequestHelper( apiAuthToken = userData.token, diff --git a/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/app_root/NativeAppTemplateApp.kt b/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/app_root/NativeAppTemplateApp.kt index d39e622..efd8f15 100644 --- a/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/app_root/NativeAppTemplateApp.kt +++ b/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/app_root/NativeAppTemplateApp.kt @@ -76,7 +76,7 @@ fun NativeAppTemplateApp(appState: NativeAppTemplateAppState) { snackbarHostState.showSnackbar( message = resources.getString(messageId), actionLabel = dismissLabel, - duration = Indefinite, + duration = storedNoticeDuration(messageId), ) } diff --git a/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/app_root/StoredNotice.kt b/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/app_root/StoredNotice.kt index 49913c1..b105192 100644 --- a/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/app_root/StoredNotice.kt +++ b/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/app_root/StoredNotice.kt @@ -1,5 +1,8 @@ package com.nativeapptemplate.nativeapptemplatefree.ui.app_root +import androidx.annotation.StringRes +import androidx.compose.material3.SnackbarDuration +import com.nativeapptemplate.nativeapptemplatefree.R import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.filter @@ -56,3 +59,13 @@ internal suspend fun showNoticeStoredForLaunch( } } } + +/** + * How long a stored notice stays. A confirmation of something done (shop or account deleted) goes + * by itself: kept until dismissed, it covered the add button. The new address's confirmation mail + * asks the shopkeeper to act, so that one stays until dismissed. + */ +internal fun storedNoticeDuration(@StringRes messageId: Int): SnackbarDuration = when (messageId) { + R.string.reconfirm_description -> SnackbarDuration.Indefinite + else -> SnackbarDuration.Short +} diff --git a/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/settings/ShopkeeperEditView.kt b/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/settings/ShopkeeperEditView.kt index 503ad84..3b2e4b5 100644 --- a/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/settings/ShopkeeperEditView.kt +++ b/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/settings/ShopkeeperEditView.kt @@ -362,6 +362,10 @@ fun DeleteShopkeeperAlertDialog( title = { Text(dialogTitle) }, + // What goes with the account: the API deletes the shopkeeper's organization with its shops + text = { + Text(stringResource(R.string.delete_my_account_description)) + }, onDismissRequest = { onDismissRequest() }, diff --git a/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/shop_settings/item_tag_list/ItemTagListView.kt b/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/shop_settings/item_tag_list/ItemTagListView.kt index f713fe7..ff7b627 100644 --- a/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/shop_settings/item_tag_list/ItemTagListView.kt +++ b/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/shop_settings/item_tag_list/ItemTagListView.kt @@ -166,6 +166,10 @@ private fun ItemTagListContentView( }, floatingActionButton = { FloatingActionButton( + // Colors stated, not primaryContainer: a theme that gives primaryContainer the + // screen's color would hide the button. The same as SubmitFab (primary on onPrimary) + containerColor = MaterialTheme.colorScheme.primary, + contentColor = MaterialTheme.colorScheme.onPrimary, onClick = { onAddItemTagClick(viewModel.shopId) }, diff --git a/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/shops/ShopListView.kt b/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/shops/ShopListView.kt index 437b2cf..29cf424 100644 --- a/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/shops/ShopListView.kt +++ b/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/shops/ShopListView.kt @@ -136,6 +136,10 @@ private fun ShopListContentView( floatingActionButton = { if (leftInShopSlots > 0) { FloatingActionButton( + // Colors stated, not primaryContainer: a theme that gives primaryContainer the + // screen's color would hide the button. The same as SubmitFab (primary on onPrimary) + containerColor = MaterialTheme.colorScheme.primary, + contentColor = MaterialTheme.colorScheme.onPrimary, onClick = { onAddShopClick() }, diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml index fa34128..58a7607 100644 --- a/app/src/main/res/values-ja/strings.xml +++ b/app/src/main/res/values-ja/strings.xml @@ -146,6 +146,7 @@ 利用規約 プロフィールを編集 アカウントを削除 + アカウントと一緒に、店舗とアイテムタグもすべて削除されます。元に戻せません。 パスワードを変更 現在のパスワード 新しいパスワード diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index d266638..b9bd742 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -142,6 +142,7 @@ Terms of Use Edit Profile Delete My Account + Your shops and item tags are deleted with your account. This cannot be undone. Update Password Current Password New Password diff --git a/app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/data/login/LoginRepositoryImplTest.kt b/app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/data/login/LoginRepositoryImplTest.kt index 5f8f44d..a03dff6 100644 --- a/app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/data/login/LoginRepositoryImplTest.kt +++ b/app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/data/login/LoginRepositoryImplTest.kt @@ -52,7 +52,7 @@ class LoginRepositoryImplTest { override suspend fun updateConfirmedPrivacyVersion(accountId: String): ApiResponse = error("not used") override suspend fun updateConfirmedTermsVersion(accountId: String): ApiResponse = error("not used") }, - natPreferencesDataSource = preferences, + nativeAppTemplatePreferencesDataSource = preferences, ioDispatcher = UnconfinedTestDispatcher(), ) diff --git a/app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/datastore/UserPreferencesSerializerTest.kt b/app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/datastore/UserPreferencesSerializerTest.kt index 1de572c..ce6cfe0 100644 --- a/app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/datastore/UserPreferencesSerializerTest.kt +++ b/app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/datastore/UserPreferencesSerializerTest.kt @@ -53,23 +53,37 @@ class UserPreferencesSerializerTest { ) } + // Security: a file the key does not accept is never read as plain proto. Otherwise a plain + // UserPreferences written to the device (or ciphertext that happens to parse once the Keystore + // key is gone) would sign the app in as whoever it names. Corruption instead: DataStoreModule's + // ReplaceFileCorruptionHandler then starts over signed out. @Test - fun readingUnencryptedProto_migratesSuccessfully() = runTest { - val expectedUserPreferences = userPreferences { + fun readingUnencryptedProto_isCorruption_notASession() = runTest { + val plantedSession = userPreferences { isLoggedIn = true + id = "shopkeeper-1" + token = "stolen-token" } + val plainBytes = ByteArrayOutputStream().also { plantedSession.writeTo(it) }.toByteArray() + // The planted bytes do parse as the session on their own; only the serializer must refuse them. + kotlin.test.assertEquals(plantedSession, com.nativeapptemplate.nativeapptemplatefree.UserPreferences.parseFrom(plainBytes)) - // Write unencrypted proto (legacy format) - val outputStream = ByteArrayOutputStream() - expectedUserPreferences.writeTo(outputStream) + val exception = kotlin.test.assertFailsWith { + userPreferencesSerializer.readFrom(ByteArrayInputStream(plainBytes)) + } + kotlin.test.assertIs(exception.cause) + } - val inputStream = ByteArrayInputStream(outputStream.toByteArray()) - val actualUserPreferences = userPreferencesSerializer.readFrom(inputStream) + // The same bytes under another key (e.g. the Keystore key was invalidated and a new one made). + @Test + fun readingDataEncryptedWithAnotherKey_isCorruption() = runTest { + val otherAead = KeysetHandle.generateNew(KeyTemplates.get("AES256_GCM")).getPrimitive(RegistryConfiguration.get(), Aead::class.java) + val outputStream = ByteArrayOutputStream() + UserPreferencesSerializer(otherAead).writeTo(userPreferences { isLoggedIn = true }, outputStream) - kotlin.test.assertEquals( - expectedUserPreferences, - actualUserPreferences, - ) + kotlin.test.assertFailsWith { + userPreferencesSerializer.readFrom(ByteArrayInputStream(outputStream.toByteArray())) + } } @Test(expected = CorruptionException::class) diff --git a/app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/designsystem/theme/ContentColorTest.kt b/app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/designsystem/theme/ContentColorTest.kt new file mode 100644 index 0000000..0f56e24 --- /dev/null +++ b/app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/designsystem/theme/ContentColorTest.kt @@ -0,0 +1,37 @@ +package com.nativeapptemplate.nativeapptemplatefree.designsystem.theme + +import androidx.compose.material3.ColorScheme +import androidx.compose.material3.contentColorFor +import androidx.compose.ui.graphics.Color +import org.junit.Assert.assertEquals +import org.junit.Test + +/** + * The text color Material picks for a surface (`Surface`, `Card`, `Dialog` and the sheets call + * `contentColorFor` on their container). `contentColorFor` matches `errorContainer` (and the other + * containers) before `surface`, so a theme that gives `errorContainer` the surface's color turns the + * text of every plain surface into `onErrorContainer`, the error red (found in Orange Baton, whose + * palette did exactly that). Text on the surfaces and the background is onSurface / onBackground. + */ +class ContentColorTest { + @Test + fun surfaces_getOnSurface() { + assertSurfacesGet(LightDefaultColorScheme) + assertSurfacesGet(DarkDefaultColorScheme) + } + + private fun assertSurfacesGet(scheme: ColorScheme) { + val surfaces: Map> = mapOf( + "surface" to (scheme.surface to scheme.onSurface), + "surfaceContainerLow" to (scheme.surfaceContainerLow to scheme.onSurface), + "surfaceContainer" to (scheme.surfaceContainer to scheme.onSurface), + "surfaceContainerHigh" to (scheme.surfaceContainerHigh to scheme.onSurface), + "surfaceContainerHighest" to (scheme.surfaceContainerHighest to scheme.onSurface), + "background" to (scheme.background to scheme.onBackground), + ) + for ((name, colors) in surfaces) { + val (container, text) = colors + assertEquals(name, text, scheme.contentColorFor(container)) + } + } +} diff --git a/app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/app_root/StoredNoticeTest.kt b/app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/app_root/StoredNoticeTest.kt index 5f9ab47..5d4f04d 100644 --- a/app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/app_root/StoredNoticeTest.kt +++ b/app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/app_root/StoredNoticeTest.kt @@ -1,5 +1,7 @@ package com.nativeapptemplate.nativeapptemplatefree.ui.app_root +import androidx.compose.material3.SnackbarDuration +import com.nativeapptemplate.nativeapptemplatefree.R import com.nativeapptemplate.nativeapptemplatefree.UserPreferences import com.nativeapptemplate.nativeapptemplatefree.datastore.NativeAppTemplatePreferencesDataSource import com.nativeapptemplate.nativeapptemplatefree.datastoreTest.InMemoryDataStore @@ -126,4 +128,13 @@ class StoredNoticeTest { assertEquals(0, shopDeletedClosedCount) app.cancel() } + + @Test + fun doneNotices_goByThemselves_andTheReconfirmationStays() { + // A notice of something done stayed until dismissed and covered the add button: it goes by + // itself. The new address's confirmation mail asks the shopkeeper to act, so it stays. + assertEquals(SnackbarDuration.Short, storedNoticeDuration(R.string.message_shop_deleted)) + assertEquals(SnackbarDuration.Short, storedNoticeDuration(R.string.message_shopkeeper_deleted)) + assertEquals(SnackbarDuration.Indefinite, storedNoticeDuration(R.string.reconfirm_description)) + } }