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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,13 +16,18 @@ 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"`
- `HardcodedUiStringsTest` scans each file as a whole, so it also catches a literal on the line after `Text(`, and `updateMessage("...")` / `?: "..."` fallbacks
- Move agent instructions from `CLAUDE.md` to `AGENTS.md` so Claude Code, Codex, and other agents share one file. `CLAUDE.md` now only imports it (`@AGENTS.md`)

### Fixed
- 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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -20,7 +20,7 @@ class ItemTagRepositoryImpl @Inject constructor(
page: Int?,
) = apiRequest(ioDispatcher) {
api.getItemTags(
mtcPreferencesDataSource.currentAccountId(),
nativeAppTemplatePreferencesDataSource.currentAccountId(),
shopId,
page,
)
Expand All @@ -30,7 +30,7 @@ class ItemTagRepositoryImpl @Inject constructor(
id: String,
) = apiRequest(ioDispatcher) {
api.getItemTag(
mtcPreferencesDataSource.currentAccountId(),
nativeAppTemplatePreferencesDataSource.currentAccountId(),
id,
)
}
Expand All @@ -40,7 +40,7 @@ class ItemTagRepositoryImpl @Inject constructor(
itemTagBody: ItemTagBody,
) = apiRequest(ioDispatcher) {
api.createItemTag(
mtcPreferencesDataSource.currentAccountId(),
nativeAppTemplatePreferencesDataSource.currentAccountId(),
shopId,
itemTagBody,
)
Expand All @@ -51,7 +51,7 @@ class ItemTagRepositoryImpl @Inject constructor(
itemTagBody: ItemTagBody,
) = apiRequest(ioDispatcher) {
api.updateItemTag(
mtcPreferencesDataSource.currentAccountId(),
nativeAppTemplatePreferencesDataSource.currentAccountId(),
id,
itemTagBody,
)
Expand All @@ -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)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,15 +9,15 @@ 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 {
override fun updateAccountPassword(
updatePasswordBody: UpdatePasswordBody,
) = apiAction(ioDispatcher) {
api.updateAccountPassword(
natPreferencesDataSource.currentAccountId(),
nativeAppTemplatePreferencesDataSource.currentAccountId(),
updatePasswordBody,
)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {

Expand All @@ -39,7 +39,7 @@ class LoginRepositoryImpl @Inject constructor(
.flowOn(ioDispatcher)

override val userData: Flow<UserData> =
natPreferencesDataSource.userData
nativeAppTemplatePreferencesDataSource.userData

override fun logout(notice: SignOutNotice?) = flow {
val response = api.logout()
Expand All @@ -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<Boolean> = natPreferencesDataSource.isLoggedIn()
override fun isLoggedIn(): Flow<Boolean> = nativeAppTemplatePreferencesDataSource.isLoggedIn()

override fun shouldUpdateApp(): Flow<Boolean> = natPreferencesDataSource.shouldUpdateApp()
override fun shouldUpdateApp(): Flow<Boolean> = nativeAppTemplatePreferencesDataSource.shouldUpdateApp()

override fun shouldUpdatePrivacy(): Flow<Boolean> = natPreferencesDataSource.shouldUpdatePrivacy()
override fun shouldUpdatePrivacy(): Flow<Boolean> = nativeAppTemplatePreferencesDataSource.shouldUpdatePrivacy()

override fun shouldUpdateTerms(): Flow<Boolean> = natPreferencesDataSource.shouldUpdateTerms()
override fun shouldUpdateTerms(): Flow<Boolean> = nativeAppTemplatePreferencesDataSource.shouldUpdateTerms()

override fun isEmailUpdated(): Flow<Boolean> = natPreferencesDataSource.isEmailUpdated()
override fun isEmailUpdated(): Flow<Boolean> = nativeAppTemplatePreferencesDataSource.isEmailUpdated()

override fun isMyAccountDeleted(): Flow<Boolean> = natPreferencesDataSource.isMyAccountDeleted()
override fun isMyAccountDeleted(): Flow<Boolean> = nativeAppTemplatePreferencesDataSource.isMyAccountDeleted()

override fun isShopDeleted(): Flow<Boolean> = natPreferencesDataSource.isShopDeleted()
override fun isShopDeleted(): Flow<Boolean> = nativeAppTemplatePreferencesDataSource.isShopDeleted()

override fun didShowTapShopBelowTip(): Flow<Boolean> = natPreferencesDataSource.didShowTapShopBelowTip()
override fun didShowTapShopBelowTip(): Flow<Boolean> = nativeAppTemplatePreferencesDataSource.didShowTapShopBelowTip()
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,22 +10,22 @@ 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(),
)
}

override fun getShop(
id: String,
) = apiRequest(ioDispatcher) {
api.getShop(
natPreferencesDataSource.currentAccountId(),
nativeAppTemplatePreferencesDataSource.currentAccountId(),
id,
)
}
Expand All @@ -34,7 +34,7 @@ class ShopRepositoryImpl @Inject constructor(
shopBody: ShopBody,
) = apiRequest(ioDispatcher) {
api.createShop(
natPreferencesDataSource.currentAccountId(),
nativeAppTemplatePreferencesDataSource.currentAccountId(),
shopBody,
)
}
Expand All @@ -44,7 +44,7 @@ class ShopRepositoryImpl @Inject constructor(
shopUpdateBody: ShopUpdateBody,
) = apiRequest(ioDispatcher) {
api.updateShop(
natPreferencesDataSource.currentAccountId(),
nativeAppTemplatePreferencesDataSource.currentAccountId(),
id,
shopUpdateBody,
)
Expand All @@ -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)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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)
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ fun NativeAppTemplateApp(appState: NativeAppTemplateAppState) {
snackbarHostState.showSnackbar(
message = resources.getString(messageId),
actionLabel = dismissLabel,
duration = Indefinite,
duration = storedNoticeDuration(messageId),
)
}

Expand Down
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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
}
Original file line number Diff line number Diff line change
Expand Up @@ -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()
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
},
Expand Down
Loading
Loading