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
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ MVVM layered architecture following [Android Modern App Architecture](https://de
- **Dependency injection**: Hilt with `@InstallIn(SingletonComponent::class)`. Repositories are bound via `@Binds` in `DataModule`.
- **Navigation routes**: Defined as extension functions on `NavGraphBuilder` (e.g., `shopListView()`, `navigateToShopDetail()`). Routes use type-safe navigation.
- **Proto DataStore**: User preferences are persisted via Protocol Buffers (lite).
- **Session steps**: steps that remove the calling screen (sign-in bootstrap, permission refresh, sign-out after an email change) go through `SessionLifecycle` (`data/login/`), which runs them in the `@ApplicationScope` and waits for them, so the screen's `viewModelScope` being cancelled does not cut them short.
- **Errors in ViewModels**: a failed load or save shows the error and stops the spinner through `ErrorShowingUiState` (`ui/common/ErrorShowingUiState.kt`): the screen's UI state implements `showingError(message)` once, and flows end with `.catchShowingError(_uiState)` (or `_uiState.showError(exception)` in a `try`/`catch`). Rethrow `CancellationException` before catching `Exception`.

## Error Handling (CodedError System)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,14 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.nativeapptemplate.nativeapptemplatefree.MainActivityUiState.Loading
import com.nativeapptemplate.nativeapptemplatefree.MainActivityUiState.Success
import com.nativeapptemplate.nativeapptemplatefree.common.errors.ApiException
import com.nativeapptemplate.nativeapptemplatefree.common.coroutines.runCatchingCancellable
import com.nativeapptemplate.nativeapptemplatefree.data.login.AppLanguageSyncer
import com.nativeapptemplate.nativeapptemplatefree.data.login.LoginRepository
import com.nativeapptemplate.nativeapptemplatefree.data.login.SessionLifecycle
import com.nativeapptemplate.nativeapptemplatefree.model.UserData
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
Expand All @@ -23,6 +23,7 @@ import javax.inject.Inject
class MainActivityViewModel @Inject constructor(
private val loginRepository: LoginRepository,
private val appLanguageSyncer: AppLanguageSyncer,
private val sessionLifecycle: SessionLifecycle,
) : ViewModel() {

val uiState: StateFlow<MainActivityUiState> = loginRepository.userData.map {
Expand All @@ -48,27 +49,10 @@ class MainActivityViewModel @Inject constructor(

fun updatePermissions() {
viewModelScope.launch {
val isLoggedIn = loginRepository.isLoggedIn().first()
if (!loginRepository.isLoggedIn().first()) return@launch

if (isLoggedIn) {
loginRepository.getPermissions()
.catch { exception ->
Log.e("MainActivityViewModel", "Failed to update permissions", exception)

// Only a rejected session means the user is signed out. Being offline or a server error
// must not wipe the session; the cached permissions stay in use until the next resume.
if ((exception as? ApiException)?.isUnauthorized != true) return@catch

loginRepository.logout()
.catch { logoutException ->
Log.e("MainActivityViewModel", "Logout error", logoutException)
}
.collect { }
}
.collect { permissions ->
loginRepository.setPermissions(permissions)
}
}
runCatchingCancellable { sessionLifecycle.refreshPermissions() }
.onFailure { Log.e("MainActivityViewModel", "Failed to update permissions", it) }
}
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
package com.nativeapptemplate.nativeapptemplatefree.common.coroutines

import kotlin.coroutines.cancellation.CancellationException

/**
* Like [runCatching], but rethrows [CancellationException] so a cancelled coroutine stops instead
* of treating its cancellation as an ordinary failure and carrying on.
*/
inline fun <T> runCatchingCancellable(block: () -> T): Result<T> = try {
Result.success(block())
} catch (cancellation: CancellationException) {
throw cancellation
} catch (throwable: Throwable) {
Result.failure(throwable)
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,9 @@ import com.nativeapptemplate.nativeapptemplatefree.datastore.NativeAppTemplatePr
import com.nativeapptemplate.nativeapptemplatefree.model.*
import com.nativeapptemplate.nativeapptemplatefree.network.Dispatcher
import com.nativeapptemplate.nativeapptemplatefree.network.NativeAppTemplateDispatchers
import com.nativeapptemplate.nativeapptemplatefree.network.emitApiResponse
import com.nativeapptemplate.nativeapptemplatefree.network.apiAction
import com.nativeapptemplate.nativeapptemplatefree.network.apiRequest
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOn
import javax.inject.Inject

class ItemTagRepositoryImpl @Inject constructor(
Expand All @@ -20,67 +18,60 @@ class ItemTagRepositoryImpl @Inject constructor(
override fun getItemTags(
shopId: String,
page: Int?,
) = flow {
val response = api.getItemTags(
mtcPreferencesDataSource.userData.first().accountId,
) = apiRequest(ioDispatcher) {
api.getItemTags(
mtcPreferencesDataSource.currentAccountId(),
shopId,
page,
)
emitApiResponse(response)
}.flowOn(ioDispatcher)
}

override fun getItemTag(
id: String,
) = flow {
val response = api.getItemTag(
mtcPreferencesDataSource.userData.first().accountId,
) = apiRequest(ioDispatcher) {
api.getItemTag(
mtcPreferencesDataSource.currentAccountId(),
id,
)
emitApiResponse(response)
}.flowOn(ioDispatcher)
}

override fun createItemTag(
shopId: String,
itemTagBody: ItemTagBody,
) = flow {
val response = api.createItemTag(
mtcPreferencesDataSource.userData.first().accountId,
) = apiRequest(ioDispatcher) {
api.createItemTag(
mtcPreferencesDataSource.currentAccountId(),
shopId,
itemTagBody,
)
emitApiResponse(response)
}.flowOn(ioDispatcher)
}

override fun updateItemTag(
id: String,
itemTagBody: ItemTagBody,
) = flow {
val response = api.updateItemTag(
mtcPreferencesDataSource.userData.first().accountId,
) = apiRequest(ioDispatcher) {
api.updateItemTag(
mtcPreferencesDataSource.currentAccountId(),
id,
itemTagBody,
)
emitApiResponse(response)
}.flowOn(ioDispatcher)
}

override fun deleteItemTag(
id: String,
) = flow {
val response = api.deleteItemTag(mtcPreferencesDataSource.userData.first().accountId, id)
emitApiResponse<Status, Boolean>(response) { true }
}.flowOn(ioDispatcher)
) = apiAction(ioDispatcher) {
api.deleteItemTag(mtcPreferencesDataSource.currentAccountId(), id)
}

override fun completeItemTag(
id: String,
) = flow {
val response = api.completeItemTag(mtcPreferencesDataSource.userData.first().accountId, id)
emitApiResponse(response)
}.flowOn(ioDispatcher)
) = apiRequest(ioDispatcher) {
api.completeItemTag(mtcPreferencesDataSource.currentAccountId(), id)
}

override fun idleItemTag(
id: String,
) = flow {
val response = api.idleItemTag(mtcPreferencesDataSource.userData.first().accountId, id)
emitApiResponse(response)
}.flowOn(ioDispatcher)
) = apiRequest(ioDispatcher) {
api.idleItemTag(mtcPreferencesDataSource.currentAccountId(), id)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,8 @@ import com.nativeapptemplate.nativeapptemplatefree.datastore.NativeAppTemplatePr
import com.nativeapptemplate.nativeapptemplatefree.model.*
import com.nativeapptemplate.nativeapptemplatefree.network.Dispatcher
import com.nativeapptemplate.nativeapptemplatefree.network.NativeAppTemplateDispatchers
import com.nativeapptemplate.nativeapptemplatefree.network.emitApiResponse
import com.nativeapptemplate.nativeapptemplatefree.network.apiAction
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOn
import javax.inject.Inject

class AccountPasswordRepositoryImpl @Inject constructor(
Expand All @@ -18,11 +15,10 @@ class AccountPasswordRepositoryImpl @Inject constructor(
) : AccountPasswordRepository {
override fun updateAccountPassword(
updatePasswordBody: UpdatePasswordBody,
) = flow {
val response = api.updateAccountPassword(
natPreferencesDataSource.userData.first().accountId,
) = apiAction(ioDispatcher) {
api.updateAccountPassword(
natPreferencesDataSource.currentAccountId(),
updatePasswordBody,
)
emitApiResponse<Status, Boolean>(response) { true }
}.flowOn(ioDispatcher)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,14 +8,14 @@ import com.nativeapptemplate.nativeapptemplatefree.model.LoggedInShopkeeper
import com.nativeapptemplate.nativeapptemplatefree.model.Login
import com.nativeapptemplate.nativeapptemplatefree.network.Dispatcher
import com.nativeapptemplate.nativeapptemplatefree.network.NativeAppTemplateDispatchers
import com.nativeapptemplate.nativeapptemplatefree.network.emitApiResponse
import com.nativeapptemplate.nativeapptemplatefree.network.apiAction
import com.nativeapptemplate.nativeapptemplatefree.network.apiRequest
import com.nativeapptemplate.nativeapptemplatefree.network.throwApiError
import com.skydoves.sandwich.message
import com.skydoves.sandwich.suspendOnFailure
import com.skydoves.sandwich.suspendOnSuccess
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
Expand All @@ -33,10 +33,8 @@ class LoginRepositoryImpl @Inject constructor(

override fun login(
login: Login,
) = flow {
val response = api.login(login)
emitApiResponse(response)
}.map { it.requireCompleteSession() }
) = apiRequest(ioDispatcher) { api.login(login) }
.map { it.requireCompleteSession() }
.flowOn(ioDispatcher)

override val userData: Flow<UserData> =
Expand All @@ -54,26 +52,23 @@ class LoginRepositoryImpl @Inject constructor(
}
}.flowOn(ioDispatcher)

override fun getPermissions() = flow {
val response = api.getPermissions(
userData.first().accountId,
override fun getPermissions() = apiRequest(ioDispatcher) {
api.getPermissions(
natPreferencesDataSource.currentAccountId(),
)
emitApiResponse(response)
}.flowOn(ioDispatcher)
}

override fun updateConfirmedPrivacyVersion() = flow {
val response = api.updateConfirmedPrivacyVersion(
natPreferencesDataSource.userData.first().accountId,
override fun updateConfirmedPrivacyVersion() = apiAction(ioDispatcher) {
api.updateConfirmedPrivacyVersion(
natPreferencesDataSource.currentAccountId(),
)
emitApiResponse<Status, Boolean>(response) { true }
}.flowOn(ioDispatcher)
}

override fun updateConfirmedTermsVersion() = flow {
val response = api.updateConfirmedTermsVersion(
natPreferencesDataSource.userData.first().accountId,
override fun updateConfirmedTermsVersion() = apiAction(ioDispatcher) {
api.updateConfirmedTermsVersion(
natPreferencesDataSource.currentAccountId(),
)
emitApiResponse<Status, Boolean>(response) { true }
}.flowOn(ioDispatcher)
}

override suspend fun setAccountId(accountId: String) {
natPreferencesDataSource.setAccountId(accountId)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
package com.nativeapptemplate.nativeapptemplatefree.data.login

import android.util.Log
import com.nativeapptemplate.nativeapptemplatefree.common.errors.ApiException
import com.nativeapptemplate.nativeapptemplatefree.di.modules.ApplicationScope
import com.nativeapptemplate.nativeapptemplatefree.model.LoggedInShopkeeper
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.async
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import javax.inject.Inject
import javax.inject.Singleton

private const val TAG = "SessionLifecycle"

/**
* Starts, refreshes and ends the signed-in session.
*
* Each step runs in the application scope and the caller waits for it: the screens that call these
* are removed by the very change they make (storing a session leaves the sign-in screen, clearing
* it leaves Edit Profile), which cancels their viewModelScope while the step still waits on the
* server. Cancelling the caller only stops the wait, not the step.
*/
@Singleton
class SessionLifecycle @Inject constructor(
private val loginRepository: LoginRepository,
private val appLanguageSyncer: AppLanguageSyncer,
@param:ApplicationScope private val scope: CoroutineScope,
) {
/**
* Replaces any previous session with [loggedInShopkeeper], then brings it up to date: the
* shopkeeper's locale in the background, and the permissions, which this waits for.
*
* Throws what [refreshPermissions] throws.
*/
suspend fun start(loggedInShopkeeper: LoggedInShopkeeper) = inApplicationScope {
loginRepository.clearSession()
loginRepository.setShopkeeper(loggedInShopkeeper)
// The account may have been created, or last used, in another language.
scope.launch { appLanguageSyncer.sync() }
fetchPermissions()
}

/**
* Fetches and stores the permissions. On failure the cached permissions stay in use and the
* failure is thrown; only a rejected session (401) signs the shopkeeper out first, as being
* offline or a server error must not wipe the session.
*/
suspend fun refreshPermissions() = inApplicationScope { fetchPermissions() }

/**
* Signs out on the server, then locally with [notice] (see [LoginRepository.logout]). The session
* is cleared even when the request fails; that failure is then thrown.
*/
suspend fun signOut(notice: SignOutNotice? = null) = inApplicationScope {
loginRepository.logout(notice).collect { }
}

private suspend fun fetchPermissions() {
val permissions = loginRepository.getPermissions()
.catch { exception ->
if ((exception as? ApiException)?.isUnauthorized == true) {
loginRepository.logout()
.catch { logoutException -> Log.e(TAG, "Logout error", logoutException) }
.collect { }
}
throw exception
}
.first()
loginRepository.setPermissions(permissions)
}

private suspend fun <T> inApplicationScope(block: suspend CoroutineScope.() -> T): T = scope.async(block = block).await()
}
Loading
Loading