diff --git a/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/data/login/LoginRepository.kt b/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/data/login/LoginRepository.kt index b771210..e96cf49 100644 --- a/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/data/login/LoginRepository.kt +++ b/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/data/login/LoginRepository.kt @@ -38,7 +38,11 @@ interface LoginRepository { suspend fun setIsShopDeleted(isShopDeleted: Boolean) - suspend fun clearUserPreferences() + /** + * Forgets the signed-in shopkeeper (session, account, permissions, one-shot flags) and keeps the + * device settings (dark theme, tips already shown). + */ + suspend fun clearSession() fun isLoggedIn(): Flow 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 7c0c474..73334f4 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 @@ -46,10 +46,10 @@ class LoginRepositoryImpl @Inject constructor( val response = api.logout() response.suspendOnSuccess { - clearUserPreferences() + clearSession() emit(true) }.suspendOnFailure { - clearUserPreferences() + clearSession() throwApiError(response, message()) } }.flowOn(ioDispatcher) @@ -111,8 +111,8 @@ class LoginRepositoryImpl @Inject constructor( natPreferencesDataSource.setIsShopDeleted(isShopDeleted) } - override suspend fun clearUserPreferences() { - natPreferencesDataSource.clearUserPreferences() + override suspend fun clearSession() { + natPreferencesDataSource.clearSession() } override fun isLoggedIn(): Flow = natPreferencesDataSource.isLoggedIn() diff --git a/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/datastore/NativeAppTemplatePreferencesDataSource.kt b/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/datastore/NativeAppTemplatePreferencesDataSource.kt index 7050997..a3caadc 100644 --- a/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/datastore/NativeAppTemplatePreferencesDataSource.kt +++ b/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/datastore/NativeAppTemplatePreferencesDataSource.kt @@ -162,9 +162,19 @@ class NativeAppTemplatePreferencesDataSource @Inject constructor( } } - suspend fun clearUserPreferences() { - updatePreferences("Failed to clear user preferences") { - it.toBuilder().clear().build() + /** + * Resets everything to its default except the device settings, which belong to whoever uses this + * device rather than to the signed-in shopkeeper: the dark theme and the "tap shop below" tip + * (an iOS TipKit tip, also per device). Everything else is the shopkeeper's (session, account, + * permissions and limits, locale) or a one-shot flag about their session, and must not reach + * whoever signs in next. + */ + suspend fun clearSession() { + updatePreferences("Failed to clear the session") { + UserPreferences.newBuilder() + .setDarkThemeConfig(it.darkThemeConfig) + .setDidShowTapShopBelowTip(it.didShowTapShopBelowTip) + .build() } } diff --git a/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/app_root/SignInEmailAndPasswordViewModel.kt b/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/app_root/SignInEmailAndPasswordViewModel.kt index 20e92b9..1cdd85c 100644 --- a/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/app_root/SignInEmailAndPasswordViewModel.kt +++ b/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/app_root/SignInEmailAndPasswordViewModel.kt @@ -59,7 +59,7 @@ class SignInEmailAndPasswordViewModel @Inject constructor( .catchShowingError(_uiState) .collect { loggedInShopkeeper -> _uiState.update { it.copy(loggedInShopkeeper = loggedInShopkeeper) } - loginRepository.clearUserPreferences() + loginRepository.clearSession() loginRepository.setShopkeeper(loggedInShopkeeper) // The account may have been created, or last used, in another language. viewModelScope.launch { appLanguageSyncer.sync() } diff --git a/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/settings/ShopkeeperEditViewModel.kt b/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/settings/ShopkeeperEditViewModel.kt index d2fcc32..70614d0 100644 --- a/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/settings/ShopkeeperEditViewModel.kt +++ b/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/settings/ShopkeeperEditViewModel.kt @@ -179,7 +179,7 @@ class ShopkeeperEditViewModel @Inject constructor( isDeleted = true, ) } - loginRepository.clearUserPreferences() + loginRepository.clearSession() loginRepository.setIsMyAccountDeleted(true) } } diff --git a/app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/data/RepositoryRequestsTest.kt b/app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/data/RepositoryRequestsTest.kt index 01be473..50fb871 100644 --- a/app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/data/RepositoryRequestsTest.kt +++ b/app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/data/RepositoryRequestsTest.kt @@ -254,7 +254,7 @@ class RepositoryRequestsTest { loginRepository.logout().first() assertEquals("DELETE /shopkeeper_auth/sign_out", received.single().let { "${it.method} ${it.pathAndQuery}" }) - // clearUserPreferences resets UserPreferences to its defaults. + // clearSession forgets the session (and keeps only the device settings). val userData = dataSource.userData.first() assertFalse(userData.isLoggedIn) assertEquals("", userData.token) @@ -378,7 +378,7 @@ class RepositoryRequestsTest { @Test fun aSignedOutRequest_carriesNoTokenAndNoAccountSegment() = runBlocking { - dataSource.clearUserPreferences() + dataSource.clearSession() respond(200, """{"data":[]}""") shopRepository.getShops().first() 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 45e021f..b7138fb 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 @@ -5,6 +5,7 @@ import com.nativeapptemplate.nativeapptemplatefree.common.errors.ApiException import com.nativeapptemplate.nativeapptemplatefree.datastore.NativeAppTemplatePreferencesDataSource import com.nativeapptemplate.nativeapptemplatefree.datastoreTest.InMemoryDataStore import com.nativeapptemplate.nativeapptemplatefree.model.Attributes +import com.nativeapptemplate.nativeapptemplatefree.model.DarkThemeConfig import com.nativeapptemplate.nativeapptemplatefree.model.Data import com.nativeapptemplate.nativeapptemplatefree.model.LoggedInShopkeeper import com.nativeapptemplate.nativeapptemplatefree.model.Login @@ -23,6 +24,10 @@ import kotlin.test.assertFailsWith class LoginRepositoryImplTest { + private val preferences = NativeAppTemplatePreferencesDataSource( + InMemoryDataStore(UserPreferences.getDefaultInstance()), + ) + private fun repository( loginResponse: LoggedInShopkeeper = sessionWith(), logoutResponse: ApiResponse = ApiResponse.Success(Status()), @@ -34,9 +39,7 @@ class LoginRepositoryImplTest { override suspend fun updateConfirmedPrivacyVersion(accountId: String): ApiResponse = error("not used") override suspend fun updateConfirmedTermsVersion(accountId: String): ApiResponse = error("not used") }, - natPreferencesDataSource = NativeAppTemplatePreferencesDataSource( - InMemoryDataStore(UserPreferences.getDefaultInstance()), - ), + natPreferencesDataSource = preferences, ioDispatcher = UnconfinedTestDispatcher(), ) @@ -63,6 +66,18 @@ class LoginRepositoryImplTest { } } + @Test + fun logout_keepsTheDarkTheme() = runTest { + // A device setting: the next shopkeeper on this device keeps the theme chosen here. + preferences.setDarkThemeConfig(DarkThemeConfig.DARK) + preferences.setShopkeeper(sessionWith()) + + repository().logout().first() + + assertEquals(false, preferences.userData.first().isLoggedIn) + assertEquals(DarkThemeConfig.DARK, preferences.userData.first().darkThemeConfig) + } + @Test fun logout_whenTheServerRejectsIt_reportsTheServerMessage() = runTest { val response = ApiResponse.Failure.Error( diff --git a/app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/datastore/NativeAppTemplatePreferencesDataSourceTest.kt b/app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/datastore/NativeAppTemplatePreferencesDataSourceTest.kt index 1f8eeb0..ec2fbb8 100644 --- a/app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/datastore/NativeAppTemplatePreferencesDataSourceTest.kt +++ b/app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/datastore/NativeAppTemplatePreferencesDataSourceTest.kt @@ -7,6 +7,7 @@ import com.nativeapptemplate.nativeapptemplatefree.di.modules.networkJson import com.nativeapptemplate.nativeapptemplatefree.model.Attributes import com.nativeapptemplate.nativeapptemplatefree.model.DarkThemeConfig import com.nativeapptemplate.nativeapptemplatefree.model.Data +import com.nativeapptemplate.nativeapptemplatefree.model.Locales import com.nativeapptemplate.nativeapptemplatefree.model.LoggedInShopkeeper import com.nativeapptemplate.nativeapptemplatefree.model.Meta import com.nativeapptemplate.nativeapptemplatefree.model.Permissions @@ -202,29 +203,57 @@ class NativeAppTemplatePreferencesDataSourceTest { } } - // Signing out (LoginRepositoryImpl.logout) clears the whole UserPreferences: the session, and also - // the device-level settings stored next to it (dark mode, the shop tip). Current behavior. @Test - fun clearUserPreferences_resetsEverythingToTheDefaults() = testScope.runTest { - subject.setShopkeeper(testInputLoggedInShopkeeper) + fun clearSession_forgetsEverythingAboutTheShopkeeper() = testScope.runTest { + // Whoever signs in next on this device must see nothing of this shopkeeper. + subject.setShopkeeper(LoggedInShopkeeper(datum = testInputLoggedInShopkeeperData.copy(attributes = testInputLoggedInShopkeeperData.attributes!!.copy(locale = "ja")))) subject.setPermissions(Permissions(meta = Meta(androidAppVersion = BuildConfig.VERSION_CODE + 1, shouldUpdatePrivacy = true, shouldUpdateTerms = true, shopLimitCount = 99))) - subject.setDarkThemeConfig(DarkThemeConfig.DARK) - subject.setDidShowTapShopBelowTip(true) + subject.setIsEmailUpdated(true) + subject.setIsMyAccountDeleted(true) subject.setIsShopDeleted(true) - subject.clearUserPreferences() + subject.clearSession() + // Expected: the proto3 defaults ("" / 0 / false), as on a fresh install. val userData = subject.userData.first() assertFalse(userData.isLoggedIn) - assertEquals("", userData.token) + assertEquals("", userData.id) assertEquals("", userData.accountId) + assertEquals("", userData.personalAccountId) + assertEquals("", userData.accountOwnerId) + assertEquals("", userData.accountName) + assertEquals("", userData.email) + assertEquals("", userData.name) + assertEquals("", userData.timeZone) + // An empty stored locale reads as Locales.DEFAULT. + assertEquals(Locales.DEFAULT, userData.locale) + assertEquals("", userData.token) + assertEquals("", userData.client) + assertEquals("", userData.uid) + assertEquals("", userData.expiry) + assertEquals(0, userData.androidAppVersion) + assertEquals(0, userData.shopLimitCount) + assertFalse(userData.shouldUpdatePrivacy) + assertFalse(userData.shouldUpdateTerms) assertFalse(subject.shouldUpdateApp().first()) - assertFalse(subject.shouldUpdatePrivacy().first()) - assertEquals(DarkThemeConfig.FOLLOW_SYSTEM, userData.darkThemeConfig) - assertFalse(subject.didShowTapShopBelowTip().first()) + assertFalse(userData.isEmailUpdated) + assertFalse(userData.isMyAccountDeleted) assertFalse(subject.isShopDeleted().first()) } + @Test + fun clearSession_keepsTheDeviceSettings() = testScope.runTest { + // Chosen on this device, not for the account: signing in or out must not reset them. + subject.setDarkThemeConfig(DarkThemeConfig.DARK) + subject.setDidShowTapShopBelowTip(true) + subject.setShopkeeper(testInputLoggedInShopkeeper) + + subject.clearSession() + + assertEquals(DarkThemeConfig.DARK, subject.userData.first().darkThemeConfig) + assertTrue(subject.didShowTapShopBelowTip().first()) + } + @Test fun flags_readBackWhatWasSet() = testScope.runTest { subject.setIsEmailUpdated(true) diff --git a/app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/datastore/NativeAppTemplatePreferencesDataSourceWriteFailureTest.kt b/app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/datastore/NativeAppTemplatePreferencesDataSourceWriteFailureTest.kt index 1bbc7d0..6bbdf32 100644 --- a/app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/datastore/NativeAppTemplatePreferencesDataSourceWriteFailureTest.kt +++ b/app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/datastore/NativeAppTemplatePreferencesDataSourceWriteFailureTest.kt @@ -56,6 +56,6 @@ class NativeAppTemplatePreferencesDataSourceWriteFailureTest { @Test fun clearing_whenTheWriteFails_isLoggedAndRethrown() = runTest { - assertLoggedAndRethrown("Failed to clear user preferences") { subject.clearUserPreferences() } + assertLoggedAndRethrown("Failed to clear the session") { subject.clearSession() } } } diff --git a/app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/demo/login/DemoLoginRepository.kt b/app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/demo/login/DemoLoginRepository.kt index 52c5aa4..2f80f27 100644 --- a/app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/demo/login/DemoLoginRepository.kt +++ b/app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/demo/login/DemoLoginRepository.kt @@ -79,7 +79,7 @@ class DemoLoginRepository @Inject constructor( override suspend fun setIsShopDeleted(isShopDeleted: Boolean) { } - override suspend fun clearUserPreferences() { + override suspend fun clearSession() { } override fun isLoggedIn(): Flow = MutableStateFlow(true) diff --git a/app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/testing/repository/TestLoginRepository.kt b/app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/testing/repository/TestLoginRepository.kt index 37e9be5..256781d 100644 --- a/app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/testing/repository/TestLoginRepository.kt +++ b/app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/testing/repository/TestLoginRepository.kt @@ -131,12 +131,12 @@ class TestLoginRepository : LoginRepository { override suspend fun setIsShopDeleted(isShopDeleted: Boolean) { } - /** How many times [clearUserPreferences] was called. */ - var clearUserPreferencesCallCount = 0 + /** How many times [clearSession] was called. */ + var clearSessionCallCount = 0 private set - override suspend fun clearUserPreferences() { - clearUserPreferencesCallCount++ + override suspend fun clearSession() { + clearSessionCallCount++ } override fun isLoggedIn(): Flow = isLoggedInReturnFlow diff --git a/app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/app_root/NativeAppTemplateAppStateTest.kt b/app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/app_root/NativeAppTemplateAppStateTest.kt index 40e5273..2ccb390 100644 --- a/app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/app_root/NativeAppTemplateAppStateTest.kt +++ b/app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/app_root/NativeAppTemplateAppStateTest.kt @@ -77,7 +77,7 @@ class NativeAppTemplateAppStateTest { dataSource.setPermissions(Permissions(meta = Meta(androidAppVersion = BuildConfig.VERSION_CODE + 1))) assertEquals(AuthDestination.NEED_APP_UPDATES, appState.authDestination.value) - dataSource.clearUserPreferences() + dataSource.clearSession() assertEquals(AuthDestination.ONBOARDING, appState.authDestination.value) } diff --git a/app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/app_root/SignInEmailAndPasswordViewModelTest.kt b/app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/app_root/SignInEmailAndPasswordViewModelTest.kt index 7a3efa8..01f66fb 100644 --- a/app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/app_root/SignInEmailAndPasswordViewModelTest.kt +++ b/app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/app_root/SignInEmailAndPasswordViewModelTest.kt @@ -134,7 +134,7 @@ class SignInEmailAndPasswordViewModelTest { // ApiException.UnprocessableError = "[NATIVEAPPTEMPLATE-2002] Processing error: $rawMessage". assertEquals("[NATIVEAPPTEMPLATE-2002] Processing error: Invalid login credentials. Please try again.", uiStateValue.message) // The previous session (if any) is left alone, and a rejected sign-in is not a sign-out. - assertEquals(0, loginRepository.clearUserPreferencesCallCount) + assertEquals(0, loginRepository.clearSessionCallCount) assertEquals(0, loginRepository.logoutCallCount) } @@ -148,7 +148,7 @@ class SignInEmailAndPasswordViewModelTest { viewModel.login() - assertEquals(1, loginRepository.clearUserPreferencesCallCount) + assertEquals(1, loginRepository.clearSessionCallCount) val userData = loginRepository.userData.first() assertTrue(userData.isLoggedIn) assertEquals(LOGGED_IN_SHOPKEEPER_ACCOUNT_ID, userData.accountId) diff --git a/app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/settings/ShopkeeperEditViewModelTest.kt b/app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/settings/ShopkeeperEditViewModelTest.kt index 1fbd314..f631e03 100644 --- a/app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/settings/ShopkeeperEditViewModelTest.kt +++ b/app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/settings/ShopkeeperEditViewModelTest.kt @@ -196,7 +196,7 @@ class ShopkeeperEditViewModelTest { viewModel.deleteShopkeeper() // The account still exists on the server, so the session must survive. - assertEquals(0, loginRepository.clearUserPreferencesCallCount) + assertEquals(0, loginRepository.clearSessionCallCount) assertFalse(viewModel.uiState.value.isLoading) assertFalse(viewModel.uiState.value.isDeleted) // ApiException.UnprocessableError = "[NATIVEAPPTEMPLATE-2002] Processing error: $rawMessage".