From b1caa990781a9878fe722daa5c849daca5890d09 Mon Sep 17 00:00:00 2001 From: dadachi Date: Sun, 25 Oct 2026 08:21:07 +0900 Subject: [PATCH 1/7] Do not read the session as plain proto when it does not decrypt UserPreferencesSerializer fell back to parsing bytes the key did not accept as an unencrypted UserPreferences, a migration from before encryption that an app built from the template never needs. It let a plain file put on the device, or ciphertext that happens to parse once the Keystore key is gone, stand in for a signed-in shopkeeper. Such bytes are a CorruptionException now, and DataStoreModule's ReplaceFileCorruptionHandler starts over signed out. The test that pinned the fallback checks the opposite. Ported from Orange Baton (template-feedback 115, 118). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018iXcEetCio5kwB6Fhf2EJi --- .../datastore/UserPreferencesSerializer.kt | 20 +++++------ .../UserPreferencesSerializerTest.kt | 36 +++++++++++++------ 2 files changed, 35 insertions(+), 21 deletions(-) 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/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) From 82f0e06412bfb55ad2c3df0fbee0c8534efb7228 Mon Sep 17 00:00:00 2001 From: dadachi Date: Sun, 25 Oct 2026 08:21:28 +0900 Subject: [PATCH 2/7] Name the preferences source nativeAppTemplatePreferencesDataSource everywhere The repositories and their tests called NativeAppTemplatePreferencesDataSource natPreferencesDataSource, and ItemTagRepositoryImpl mtcPreferencesDataSource, left from MyTurnTag Creator. One name now, the paid edition's, which the rename in nativeapptemplate-agent also covers. Found while building Orange Baton from the template (template-feedback 119). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018iXcEetCio5kwB6Fhf2EJi --- .../data/item_tag/ItemTagRepositoryImpl.kt | 16 +++---- .../login/AccountPasswordRepositoryImpl.kt | 4 +- .../data/login/LoginRepositoryImpl.kt | 46 +++++++++---------- .../data/shop/ShopRepositoryImpl.kt | 12 ++--- .../network/AuthInterceptor.kt | 8 ++-- .../data/login/LoginRepositoryImplTest.kt | 2 +- 6 files changed, 44 insertions(+), 44 deletions(-) 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/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/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(), ) From 3ebfd475521c358246773172c06d9a74724977db Mon Sep 17 00:00:00 2001 From: dadachi Date: Sun, 25 Oct 2026 08:22:39 +0900 Subject: [PATCH 3/7] Let notices of something done go by themselves The notices shown after a shop or the account was deleted were SnackbarDuration.Indefinite and stayed until dismissed, covering the add button. They are Short now (storedNoticeDuration); the reconfirmation of a new email address, which asks the shopkeeper to act, stays until dismissed (StoredNoticeTest). Ported from Orange Baton (template-feedback 123). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018iXcEetCio5kwB6Fhf2EJi --- .../ui/app_root/NativeAppTemplateApp.kt | 2 +- .../ui/app_root/StoredNotice.kt | 13 +++++++++++++ .../ui/app_root/StoredNoticeTest.kt | 11 +++++++++++ 3 files changed, 25 insertions(+), 1 deletion(-) 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/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)) + } } From a9db2161c66d508788406b806d3159f6407678f3 Mon Sep 17 00:00:00 2001 From: dadachi Date: Sun, 25 Oct 2026 08:23:05 +0900 Subject: [PATCH 4/7] Warn about roles sharing a color in Theme.kt; state the FABs' colors A Surface, Card or Dialog without colors takes its text from contentColorFor, which matches errorContainer before surface: a palette that gives errorContainer the surface's color turned every plain surface's text red in Orange Baton. Theme.kt says to give the on* colors one color too, and ContentColorTest checks that the surfaces and the background get onSurface / onBackground in both schemes. The FABs state primary / onPrimary, as SubmitFab does, instead of the default primaryContainer. Found while building Orange Baton from the template (template-feedback 120). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018iXcEetCio5kwB6Fhf2EJi --- .../designsystem/theme/Theme.kt | 5 +++ .../item_tag_list/ItemTagListView.kt | 4 ++ .../ui/shops/ShopListView.kt | 4 ++ .../designsystem/theme/ContentColorTest.kt | 37 +++++++++++++++++++ 4 files changed, 50 insertions(+) create mode 100644 app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/designsystem/theme/ContentColorTest.kt 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/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/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)) + } + } +} From 04a279fa989bb0abc6156a7eede2289d1bb21967 Mon Sep 17 00:00:00 2001 From: dadachi Date: Sun, 25 Oct 2026 08:23:19 +0900 Subject: [PATCH 5/7] Say in the delete-account confirmation that the shops go too Deleting a shopkeeper deletes their organization with every shop and item tag. The dialog only asked "Are you sure?"; it says so now. Found while building Orange Baton from the template (template-feedback 13). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018iXcEetCio5kwB6Fhf2EJi --- .../nativeapptemplatefree/ui/settings/ShopkeeperEditView.kt | 4 ++++ app/src/main/res/values-ja/strings.xml | 1 + app/src/main/res/values/strings.xml | 1 + 3 files changed, 6 insertions(+) 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/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 From aca8dbba6a1fa1ab9f0a6a2e539863b0a25d70b4 Mon Sep 17 00:00:00 2001 From: dadachi Date: Sun, 25 Oct 2026 08:29:25 +0900 Subject: [PATCH 6/7] Add the D1 changes to the CHANGELOG Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018iXcEetCio5kwB6Fhf2EJi --- CHANGELOG.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index b818048..e6227f3 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 +- 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 +- The delete-account confirmation says that the shops and item tags are deleted with the account. Found while building Orange Baton from the template - 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 +- 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 - 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 From 2b78bb897e77df3fad4e87118798cb95da5e0517 Mon Sep 17 00:00:00 2001 From: dadachi Date: Sun, 25 Oct 2026 08:29:38 +0900 Subject: [PATCH 7/7] Add the PR number to the CHANGELOG entries Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018iXcEetCio5kwB6Fhf2EJi --- CHANGELOG.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e6227f3..86c67f8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,9 +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 -- 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 -- The delete-account confirmation says that the shops and item tags are deleted with the account. Found while building Orange Baton from the template +- 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"` @@ -26,8 +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 -- 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 +- 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