android: add account api and repository for profile privacy sessions 2fa

This commit is contained in:
Codex
2026-03-09 16:05:14 +03:00
parent 65e74cffdb
commit 91d712c702
12 changed files with 860 additions and 0 deletions

View File

@@ -2,17 +2,36 @@ package ru.daemonlord.messenger.data.auth.api
import ru.daemonlord.messenger.data.auth.dto.AuthUserDto
import ru.daemonlord.messenger.data.auth.dto.AuthSessionDto
import ru.daemonlord.messenger.data.auth.dto.CheckEmailStatusDto
import ru.daemonlord.messenger.data.auth.dto.LoginRequestDto
import ru.daemonlord.messenger.data.auth.dto.MessageResponseDto
import ru.daemonlord.messenger.data.auth.dto.RegisterRequestDto
import ru.daemonlord.messenger.data.auth.dto.RequestPasswordResetDto
import ru.daemonlord.messenger.data.auth.dto.ResetPasswordRequestDto
import ru.daemonlord.messenger.data.auth.dto.RefreshTokenRequestDto
import ru.daemonlord.messenger.data.auth.dto.TokenResponseDto
import ru.daemonlord.messenger.data.auth.dto.TwoFactorCodeRequestDto
import ru.daemonlord.messenger.data.auth.dto.TwoFactorRecoveryCodesDto
import ru.daemonlord.messenger.data.auth.dto.TwoFactorRecoveryStatusDto
import ru.daemonlord.messenger.data.auth.dto.TwoFactorSetupDto
import ru.daemonlord.messenger.data.auth.dto.VerifyEmailRequestDto
import retrofit2.http.Headers
import retrofit2.http.Body
import retrofit2.http.GET
import retrofit2.http.POST
import retrofit2.http.DELETE
import retrofit2.http.Path
import retrofit2.http.Query
interface AuthApiService {
@Headers("No-Auth: true")
@GET("/api/v1/auth/check-email")
suspend fun checkEmailStatus(@Query("email") email: String): CheckEmailStatusDto
@Headers("No-Auth: true")
@POST("/api/v1/auth/register")
suspend fun register(@Body request: RegisterRequestDto): MessageResponseDto
@Headers("No-Auth: true")
@POST("/api/v1/auth/login")
suspend fun login(@Body request: LoginRequestDto): TokenResponseDto
@@ -24,6 +43,18 @@ interface AuthApiService {
@GET("/api/v1/auth/me")
suspend fun me(): AuthUserDto
@Headers("No-Auth: true")
@POST("/api/v1/auth/verify-email")
suspend fun verifyEmail(@Body request: VerifyEmailRequestDto): MessageResponseDto
@Headers("No-Auth: true")
@POST("/api/v1/auth/request-password-reset")
suspend fun requestPasswordReset(@Body request: RequestPasswordResetDto): MessageResponseDto
@Headers("No-Auth: true")
@POST("/api/v1/auth/reset-password")
suspend fun resetPassword(@Body request: ResetPasswordRequestDto): MessageResponseDto
@GET("/api/v1/auth/sessions")
suspend fun sessions(): List<AuthSessionDto>
@@ -32,4 +63,19 @@ interface AuthApiService {
@DELETE("/api/v1/auth/sessions")
suspend fun revokeAllSessions()
@POST("/api/v1/auth/2fa/setup")
suspend fun setupTwoFactor(): TwoFactorSetupDto
@POST("/api/v1/auth/2fa/enable")
suspend fun enableTwoFactor(@Body request: TwoFactorCodeRequestDto): MessageResponseDto
@POST("/api/v1/auth/2fa/disable")
suspend fun disableTwoFactor(@Body request: TwoFactorCodeRequestDto): MessageResponseDto
@GET("/api/v1/auth/2fa/recovery-codes/status")
suspend fun twoFactorRecoveryStatus(): TwoFactorRecoveryStatusDto
@POST("/api/v1/auth/2fa/recovery-codes/regenerate")
suspend fun regenerateTwoFactorRecoveryCodes(@Body request: TwoFactorCodeRequestDto): TwoFactorRecoveryCodesDto
}

View File

@@ -7,6 +7,10 @@ import kotlinx.serialization.Serializable
data class LoginRequestDto(
val email: String,
val password: String,
@SerialName("otp_code")
val otpCode: String? = null,
@SerialName("recovery_code")
val recoveryCode: String? = null,
)
@Serializable
@@ -31,10 +35,21 @@ data class AuthUserDto(
val email: String,
val name: String,
val username: String,
val bio: String? = null,
@SerialName("avatar_url")
val avatarUrl: String? = null,
@SerialName("email_verified")
val emailVerified: Boolean,
@SerialName("twofa_enabled")
val twofaEnabled: Boolean = false,
@SerialName("privacy_private_messages")
val privacyPrivateMessages: String? = null,
@SerialName("privacy_last_seen")
val privacyLastSeen: String? = null,
@SerialName("privacy_avatar")
val privacyAvatar: String? = null,
@SerialName("privacy_group_invites")
val privacyGroupInvites: String? = null,
)
@Serializable
@@ -55,3 +70,65 @@ data class AuthSessionDto(
data class ErrorResponseDto(
val detail: String? = null,
)
@Serializable
data class MessageResponseDto(
val message: String,
)
@Serializable
data class VerifyEmailRequestDto(
val token: String,
)
@Serializable
data class RequestPasswordResetDto(
val email: String,
)
@Serializable
data class ResetPasswordRequestDto(
val token: String,
val password: String,
)
@Serializable
data class CheckEmailStatusDto(
val email: String,
val registered: Boolean,
@SerialName("email_verified")
val emailVerified: Boolean,
@SerialName("twofa_enabled")
val twofaEnabled: Boolean,
)
@Serializable
data class RegisterRequestDto(
val email: String,
val name: String,
val username: String,
val password: String,
)
@Serializable
data class TwoFactorSetupDto(
val secret: String,
@SerialName("otpauth_url")
val otpauthUrl: String,
)
@Serializable
data class TwoFactorCodeRequestDto(
val code: String,
)
@Serializable
data class TwoFactorRecoveryStatusDto(
@SerialName("remaining_codes")
val remainingCodes: Int,
)
@Serializable
data class TwoFactorRecoveryCodesDto(
val codes: List<String>,
)

View File

@@ -134,8 +134,14 @@ class NetworkAuthRepository @Inject constructor(
email = email,
name = name,
username = username,
bio = bio,
avatarUrl = avatarUrl,
emailVerified = emailVerified,
twofaEnabled = twofaEnabled,
privacyPrivateMessages = privacyPrivateMessages ?: "everyone",
privacyLastSeen = privacyLastSeen ?: "everyone",
privacyAvatar = privacyAvatar ?: "everyone",
privacyGroupInvites = privacyGroupInvites ?: "everyone",
)
}

View File

@@ -0,0 +1,25 @@
package ru.daemonlord.messenger.data.user.api
import retrofit2.http.Body
import retrofit2.http.DELETE
import retrofit2.http.GET
import retrofit2.http.POST
import retrofit2.http.PUT
import retrofit2.http.Path
import ru.daemonlord.messenger.data.auth.dto.AuthUserDto
import ru.daemonlord.messenger.data.user.dto.UserProfileUpdateRequestDto
import ru.daemonlord.messenger.data.user.dto.UserSearchDto
interface UserApiService {
@PUT("/api/v1/users/profile")
suspend fun updateProfile(@Body request: UserProfileUpdateRequestDto): AuthUserDto
@GET("/api/v1/users/blocked")
suspend fun listBlockedUsers(): List<UserSearchDto>
@POST("/api/v1/users/{user_id}/block")
suspend fun blockUser(@Path("user_id") userId: Long)
@DELETE("/api/v1/users/{user_id}/block")
suspend fun unblockUser(@Path("user_id") userId: Long)
}

View File

@@ -0,0 +1,33 @@
package ru.daemonlord.messenger.data.user.dto
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class UserProfileUpdateRequestDto(
val name: String? = null,
val username: String? = null,
val bio: String? = null,
@SerialName("avatar_url")
val avatarUrl: String? = null,
@SerialName("allow_private_messages")
val allowPrivateMessages: Boolean? = null,
@SerialName("privacy_private_messages")
val privacyPrivateMessages: String? = null,
@SerialName("privacy_last_seen")
val privacyLastSeen: String? = null,
@SerialName("privacy_avatar")
val privacyAvatar: String? = null,
@SerialName("privacy_group_invites")
val privacyGroupInvites: String? = null,
)
@Serializable
data class UserSearchDto(
val id: Long,
val email: String? = null,
val name: String? = null,
val username: String? = null,
@SerialName("avatar_url")
val avatarUrl: String? = null,
)

View File

@@ -0,0 +1,289 @@
package ru.daemonlord.messenger.data.user.repository
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.withContext
import okhttp3.MediaType.Companion.toMediaTypeOrNull
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import ru.daemonlord.messenger.data.auth.api.AuthApiService
import ru.daemonlord.messenger.data.auth.dto.AuthSessionDto
import ru.daemonlord.messenger.data.auth.dto.AuthUserDto
import ru.daemonlord.messenger.data.auth.dto.RequestPasswordResetDto
import ru.daemonlord.messenger.data.auth.dto.ResetPasswordRequestDto
import ru.daemonlord.messenger.data.auth.dto.TwoFactorCodeRequestDto
import ru.daemonlord.messenger.data.auth.dto.VerifyEmailRequestDto
import ru.daemonlord.messenger.data.common.toAppError
import ru.daemonlord.messenger.data.media.api.MediaApiService
import ru.daemonlord.messenger.data.media.dto.UploadUrlRequestDto
import ru.daemonlord.messenger.di.RefreshClient
import ru.daemonlord.messenger.data.user.api.UserApiService
import ru.daemonlord.messenger.data.user.dto.UserProfileUpdateRequestDto
import ru.daemonlord.messenger.data.user.dto.UserSearchDto
import ru.daemonlord.messenger.di.IoDispatcher
import ru.daemonlord.messenger.domain.account.model.UserSearchItem
import ru.daemonlord.messenger.domain.account.repository.AccountRepository
import ru.daemonlord.messenger.domain.auth.model.AuthSession
import ru.daemonlord.messenger.domain.auth.model.AuthUser
import ru.daemonlord.messenger.domain.common.AppError
import ru.daemonlord.messenger.domain.common.AppResult
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class NetworkAccountRepository @Inject constructor(
private val authApiService: AuthApiService,
private val userApiService: UserApiService,
private val mediaApiService: MediaApiService,
@RefreshClient private val uploadClient: OkHttpClient,
@IoDispatcher private val ioDispatcher: CoroutineDispatcher,
) : AccountRepository {
override suspend fun getMe(): AppResult<AuthUser> = withContext(ioDispatcher) {
try {
AppResult.Success(authApiService.me().toDomain())
} catch (error: Throwable) {
AppResult.Error(error.toAppError())
}
}
override suspend fun updateProfile(
name: String,
username: String,
bio: String?,
avatarUrl: String?,
): AppResult<AuthUser> = withContext(ioDispatcher) {
try {
val updated = userApiService.updateProfile(
request = UserProfileUpdateRequestDto(
name = name.trim().ifBlank { null },
username = username.trim().ifBlank { null },
bio = bio?.trim(),
avatarUrl = avatarUrl?.trim(),
)
)
AppResult.Success(updated.toDomain())
} catch (error: Throwable) {
AppResult.Error(error.toAppError())
}
}
override suspend fun uploadAvatar(
fileName: String,
mimeType: String,
bytes: ByteArray,
): AppResult<String> = withContext(ioDispatcher) {
try {
val uploadInfo = mediaApiService.requestUploadUrl(
UploadUrlRequestDto(
fileName = fileName,
fileType = mimeType,
fileSize = bytes.size.toLong(),
)
)
val body = bytes.toRequestBody(mimeType.toMediaTypeOrNull())
val uploadRequestBuilder = Request.Builder()
.url(uploadInfo.uploadUrl)
.put(body)
uploadInfo.requiredHeaders.forEach { (key, value) ->
uploadRequestBuilder.header(key, value)
}
uploadClient.newCall(uploadRequestBuilder.build()).execute().use { response ->
if (!response.isSuccessful) {
return@withContext AppResult.Error(AppError.Server("Upload failed: HTTP ${response.code}"))
}
}
AppResult.Success(uploadInfo.fileUrl)
} catch (error: Throwable) {
AppResult.Error(error.toAppError())
}
}
override suspend fun updatePrivacy(
privateMessages: String,
lastSeen: String,
avatar: String,
groupInvites: String,
): AppResult<AuthUser> = withContext(ioDispatcher) {
try {
val allowPrivateMessages = privateMessages != "nobody"
val updated = userApiService.updateProfile(
request = UserProfileUpdateRequestDto(
allowPrivateMessages = allowPrivateMessages,
privacyPrivateMessages = privateMessages,
privacyLastSeen = lastSeen,
privacyAvatar = avatar,
privacyGroupInvites = groupInvites,
)
)
AppResult.Success(updated.toDomain())
} catch (error: Throwable) {
AppResult.Error(error.toAppError())
}
}
override suspend fun listBlockedUsers(): AppResult<List<UserSearchItem>> = withContext(ioDispatcher) {
try {
AppResult.Success(userApiService.listBlockedUsers().map { it.toDomain() })
} catch (error: Throwable) {
AppResult.Error(error.toAppError())
}
}
override suspend fun blockUser(userId: Long): AppResult<Unit> = withContext(ioDispatcher) {
try {
userApiService.blockUser(userId)
AppResult.Success(Unit)
} catch (error: Throwable) {
AppResult.Error(error.toAppError())
}
}
override suspend fun unblockUser(userId: Long): AppResult<Unit> = withContext(ioDispatcher) {
try {
userApiService.unblockUser(userId)
AppResult.Success(Unit)
} catch (error: Throwable) {
AppResult.Error(error.toAppError())
}
}
override suspend fun listSessions(): AppResult<List<AuthSession>> = withContext(ioDispatcher) {
try {
AppResult.Success(authApiService.sessions().map { it.toDomain() })
} catch (error: Throwable) {
AppResult.Error(error.toAppError())
}
}
override suspend fun revokeSession(jti: String): AppResult<Unit> = withContext(ioDispatcher) {
try {
authApiService.revokeSession(jti)
AppResult.Success(Unit)
} catch (error: Throwable) {
AppResult.Error(error.toAppError())
}
}
override suspend fun revokeAllSessions(): AppResult<Unit> = withContext(ioDispatcher) {
try {
authApiService.revokeAllSessions()
AppResult.Success(Unit)
} catch (error: Throwable) {
AppResult.Error(error.toAppError())
}
}
override suspend fun verifyEmail(token: String): AppResult<String> = withContext(ioDispatcher) {
try {
val result = authApiService.verifyEmail(VerifyEmailRequestDto(token))
AppResult.Success(result.message)
} catch (error: Throwable) {
AppResult.Error(error.toAppError())
}
}
override suspend fun requestPasswordReset(email: String): AppResult<String> = withContext(ioDispatcher) {
try {
val result = authApiService.requestPasswordReset(RequestPasswordResetDto(email = email.trim()))
AppResult.Success(result.message)
} catch (error: Throwable) {
AppResult.Error(error.toAppError())
}
}
override suspend fun resetPassword(token: String, password: String): AppResult<String> = withContext(ioDispatcher) {
try {
val result = authApiService.resetPassword(
ResetPasswordRequestDto(
token = token.trim(),
password = password,
)
)
AppResult.Success(result.message)
} catch (error: Throwable) {
AppResult.Error(error.toAppError())
}
}
override suspend fun setupTwoFactor(): AppResult<Pair<String, String>> = withContext(ioDispatcher) {
try {
val response = authApiService.setupTwoFactor()
AppResult.Success(response.secret to response.otpauthUrl)
} catch (error: Throwable) {
AppResult.Error(error.toAppError())
}
}
override suspend fun enableTwoFactor(code: String): AppResult<String> = withContext(ioDispatcher) {
try {
val response = authApiService.enableTwoFactor(TwoFactorCodeRequestDto(code.trim()))
AppResult.Success(response.message)
} catch (error: Throwable) {
AppResult.Error(error.toAppError())
}
}
override suspend fun disableTwoFactor(code: String): AppResult<String> = withContext(ioDispatcher) {
try {
val response = authApiService.disableTwoFactor(TwoFactorCodeRequestDto(code.trim()))
AppResult.Success(response.message)
} catch (error: Throwable) {
AppResult.Error(error.toAppError())
}
}
override suspend fun twoFactorRecoveryStatus(): AppResult<Int> = withContext(ioDispatcher) {
try {
AppResult.Success(authApiService.twoFactorRecoveryStatus().remainingCodes)
} catch (error: Throwable) {
AppResult.Error(error.toAppError())
}
}
override suspend fun regenerateTwoFactorRecoveryCodes(code: String): AppResult<List<String>> = withContext(ioDispatcher) {
try {
val response = authApiService.regenerateTwoFactorRecoveryCodes(TwoFactorCodeRequestDto(code.trim()))
AppResult.Success(response.codes)
} catch (error: Throwable) {
AppResult.Error(error.toAppError())
}
}
private fun AuthUserDto.toDomain(): AuthUser {
return AuthUser(
id = id,
email = email,
name = name,
username = username,
bio = bio,
avatarUrl = avatarUrl,
emailVerified = emailVerified,
twofaEnabled = twofaEnabled,
privacyPrivateMessages = privacyPrivateMessages ?: "everyone",
privacyLastSeen = privacyLastSeen ?: "everyone",
privacyAvatar = privacyAvatar ?: "everyone",
privacyGroupInvites = privacyGroupInvites ?: "everyone",
)
}
private fun AuthSessionDto.toDomain(): AuthSession {
return AuthSession(
jti = jti,
createdAt = createdAt,
ipAddress = ipAddress,
userAgent = userAgent,
current = current,
tokenType = tokenType,
)
}
private fun UserSearchDto.toDomain(): UserSearchItem {
return UserSearchItem(
id = id,
name = name?.trim().takeUnless { it.isNullOrBlank() } ?: username?.trim().takeUnless { it.isNullOrBlank() } ?: "User #$id",
username = username,
avatarUrl = avatarUrl,
)
}
}

View File

@@ -10,6 +10,8 @@ import ru.daemonlord.messenger.data.chat.repository.NetworkChatRepository
import ru.daemonlord.messenger.data.media.repository.NetworkMediaRepository
import ru.daemonlord.messenger.data.message.repository.NetworkMessageRepository
import ru.daemonlord.messenger.data.notifications.repository.DataStoreNotificationSettingsRepository
import ru.daemonlord.messenger.data.user.repository.NetworkAccountRepository
import ru.daemonlord.messenger.domain.account.repository.AccountRepository
import ru.daemonlord.messenger.domain.auth.repository.AuthRepository
import ru.daemonlord.messenger.domain.auth.repository.SessionCleanupRepository
import ru.daemonlord.messenger.domain.chat.repository.ChatRepository
@@ -57,4 +59,10 @@ abstract class RepositoryModule {
abstract fun bindNotificationSettingsRepository(
repository: DataStoreNotificationSettingsRepository,
): NotificationSettingsRepository
@Binds
@Singleton
abstract fun bindAccountRepository(
repository: NetworkAccountRepository,
): AccountRepository
}

View File

@@ -0,0 +1,8 @@
package ru.daemonlord.messenger.domain.account.model
data class UserSearchItem(
val id: Long,
val name: String,
val username: String?,
val avatarUrl: String?,
)

View File

@@ -0,0 +1,41 @@
package ru.daemonlord.messenger.domain.account.repository
import ru.daemonlord.messenger.domain.account.model.UserSearchItem
import ru.daemonlord.messenger.domain.auth.model.AuthSession
import ru.daemonlord.messenger.domain.auth.model.AuthUser
import ru.daemonlord.messenger.domain.common.AppResult
interface AccountRepository {
suspend fun getMe(): AppResult<AuthUser>
suspend fun updateProfile(
name: String,
username: String,
bio: String?,
avatarUrl: String?,
): AppResult<AuthUser>
suspend fun uploadAvatar(
fileName: String,
mimeType: String,
bytes: ByteArray,
): AppResult<String>
suspend fun updatePrivacy(
privateMessages: String,
lastSeen: String,
avatar: String,
groupInvites: String,
): AppResult<AuthUser>
suspend fun listBlockedUsers(): AppResult<List<UserSearchItem>>
suspend fun blockUser(userId: Long): AppResult<Unit>
suspend fun unblockUser(userId: Long): AppResult<Unit>
suspend fun listSessions(): AppResult<List<AuthSession>>
suspend fun revokeSession(jti: String): AppResult<Unit>
suspend fun revokeAllSessions(): AppResult<Unit>
suspend fun verifyEmail(token: String): AppResult<String>
suspend fun requestPasswordReset(email: String): AppResult<String>
suspend fun resetPassword(token: String, password: String): AppResult<String>
suspend fun setupTwoFactor(): AppResult<Pair<String, String>>
suspend fun enableTwoFactor(code: String): AppResult<String>
suspend fun disableTwoFactor(code: String): AppResult<String>
suspend fun twoFactorRecoveryStatus(): AppResult<Int>
suspend fun regenerateTwoFactorRecoveryCodes(code: String): AppResult<List<String>>
}

View File

@@ -5,6 +5,12 @@ data class AuthUser(
val email: String,
val name: String,
val username: String,
val bio: String?,
val avatarUrl: String?,
val emailVerified: Boolean,
val twofaEnabled: Boolean,
val privacyPrivateMessages: String,
val privacyLastSeen: String,
val privacyAvatar: String,
val privacyGroupInvites: String,
)

View File

@@ -0,0 +1,19 @@
package ru.daemonlord.messenger.ui.account
import ru.daemonlord.messenger.domain.account.model.UserSearchItem
import ru.daemonlord.messenger.domain.auth.model.AuthSession
import ru.daemonlord.messenger.domain.auth.model.AuthUser
data class AccountUiState(
val isLoading: Boolean = false,
val isSaving: Boolean = false,
val profile: AuthUser? = null,
val sessions: List<AuthSession> = emptyList(),
val blockedUsers: List<UserSearchItem> = emptyList(),
val twoFactorSecret: String? = null,
val twoFactorOtpAuthUrl: String? = null,
val recoveryCodes: List<String> = emptyList(),
val recoveryCodesRemaining: Int? = null,
val message: String? = null,
val errorMessage: String? = null,
)

View File

@@ -0,0 +1,302 @@
package ru.daemonlord.messenger.ui.account
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import ru.daemonlord.messenger.domain.account.repository.AccountRepository
import ru.daemonlord.messenger.domain.common.AppError
import ru.daemonlord.messenger.domain.common.AppResult
import javax.inject.Inject
@HiltViewModel
class AccountViewModel @Inject constructor(
private val accountRepository: AccountRepository,
) : ViewModel() {
private val _uiState = MutableStateFlow(AccountUiState())
val uiState: StateFlow<AccountUiState> = _uiState.asStateFlow()
init {
refresh()
}
fun refresh() {
viewModelScope.launch {
_uiState.update { it.copy(isLoading = true, errorMessage = null, message = null) }
val me = accountRepository.getMe()
val sessions = accountRepository.listSessions()
val blocked = accountRepository.listBlockedUsers()
_uiState.update { state ->
state.copy(
isLoading = false,
profile = (me as? AppResult.Success)?.data ?: state.profile,
sessions = (sessions as? AppResult.Success)?.data ?: state.sessions,
blockedUsers = (blocked as? AppResult.Success)?.data ?: state.blockedUsers,
errorMessage = listOf(me, sessions, blocked)
.filterIsInstance<AppResult.Error>()
.firstOrNull()
?.reason
?.toUiMessage(),
)
}
}
}
fun updateProfile(
name: String,
username: String,
bio: String?,
avatarUrl: String?,
) {
viewModelScope.launch {
_uiState.update { it.copy(isSaving = true, errorMessage = null, message = null) }
when (val result = accountRepository.updateProfile(name, username, bio, avatarUrl)) {
is AppResult.Success -> _uiState.update {
it.copy(
isSaving = false,
profile = result.data,
message = "Profile updated.",
)
}
is AppResult.Error -> _uiState.update {
it.copy(
isSaving = false,
errorMessage = result.reason.toUiMessage(),
)
}
}
}
}
fun uploadAvatar(
fileName: String,
mimeType: String,
bytes: ByteArray,
onUploaded: (String) -> Unit,
) {
viewModelScope.launch {
_uiState.update { it.copy(isSaving = true, errorMessage = null, message = null) }
when (val result = accountRepository.uploadAvatar(fileName, mimeType, bytes)) {
is AppResult.Success -> {
_uiState.update { it.copy(isSaving = false, message = "Avatar uploaded.") }
onUploaded(result.data)
}
is AppResult.Error -> _uiState.update {
it.copy(
isSaving = false,
errorMessage = result.reason.toUiMessage(),
)
}
}
}
}
fun updatePrivacy(
privateMessages: String,
lastSeen: String,
avatar: String,
groupInvites: String,
) {
viewModelScope.launch {
_uiState.update { it.copy(isSaving = true, errorMessage = null, message = null) }
when (
val result = accountRepository.updatePrivacy(
privateMessages = privateMessages,
lastSeen = lastSeen,
avatar = avatar,
groupInvites = groupInvites,
)
) {
is AppResult.Success -> _uiState.update {
it.copy(
isSaving = false,
profile = result.data,
message = "Privacy settings updated.",
)
}
is AppResult.Error -> _uiState.update {
it.copy(isSaving = false, errorMessage = result.reason.toUiMessage())
}
}
}
}
fun blockUser(userId: Long) {
viewModelScope.launch {
when (val result = accountRepository.blockUser(userId)) {
is AppResult.Success -> refresh()
is AppResult.Error -> _uiState.update { it.copy(errorMessage = result.reason.toUiMessage()) }
}
}
}
fun unblockUser(userId: Long) {
viewModelScope.launch {
when (val result = accountRepository.unblockUser(userId)) {
is AppResult.Success -> refresh()
is AppResult.Error -> _uiState.update { it.copy(errorMessage = result.reason.toUiMessage()) }
}
}
}
fun revokeSession(jti: String) {
viewModelScope.launch {
when (val result = accountRepository.revokeSession(jti)) {
is AppResult.Success -> refresh()
is AppResult.Error -> _uiState.update { it.copy(errorMessage = result.reason.toUiMessage()) }
}
}
}
fun revokeAllSessions() {
viewModelScope.launch {
when (val result = accountRepository.revokeAllSessions()) {
is AppResult.Success -> refresh()
is AppResult.Error -> _uiState.update { it.copy(errorMessage = result.reason.toUiMessage()) }
}
}
}
fun setupTwoFactor() {
viewModelScope.launch {
when (val result = accountRepository.setupTwoFactor()) {
is AppResult.Success -> _uiState.update {
it.copy(
twoFactorSecret = result.data.first,
twoFactorOtpAuthUrl = result.data.second,
message = "2FA secret generated. Enter code to enable.",
errorMessage = null,
)
}
is AppResult.Error -> _uiState.update { it.copy(errorMessage = result.reason.toUiMessage()) }
}
}
}
fun enableTwoFactor(code: String) {
viewModelScope.launch {
when (val result = accountRepository.enableTwoFactor(code)) {
is AppResult.Success -> {
_uiState.update { it.copy(message = result.data, errorMessage = null) }
refreshRecoveryStatus()
refresh()
}
is AppResult.Error -> _uiState.update { it.copy(errorMessage = result.reason.toUiMessage()) }
}
}
}
fun disableTwoFactor(code: String) {
viewModelScope.launch {
when (val result = accountRepository.disableTwoFactor(code)) {
is AppResult.Success -> {
_uiState.update { it.copy(message = result.data, errorMessage = null) }
refresh()
}
is AppResult.Error -> _uiState.update { it.copy(errorMessage = result.reason.toUiMessage()) }
}
}
}
fun refreshRecoveryStatus() {
viewModelScope.launch {
when (val result = accountRepository.twoFactorRecoveryStatus()) {
is AppResult.Success -> _uiState.update { it.copy(recoveryCodesRemaining = result.data) }
is AppResult.Error -> _uiState.update { it.copy(errorMessage = result.reason.toUiMessage()) }
}
}
}
fun regenerateRecoveryCodes(code: String) {
viewModelScope.launch {
when (val result = accountRepository.regenerateTwoFactorRecoveryCodes(code)) {
is AppResult.Success -> _uiState.update {
it.copy(
recoveryCodes = result.data,
message = "Recovery codes regenerated.",
errorMessage = null,
)
}
is AppResult.Error -> _uiState.update { it.copy(errorMessage = result.reason.toUiMessage()) }
}
}
}
fun verifyEmail(token: String) {
viewModelScope.launch {
_uiState.update { it.copy(isSaving = true, errorMessage = null, message = null) }
when (val result = accountRepository.verifyEmail(token)) {
is AppResult.Success -> _uiState.update {
it.copy(
isSaving = false,
message = result.data,
)
}
is AppResult.Error -> _uiState.update {
it.copy(
isSaving = false,
errorMessage = result.reason.toUiMessage(),
)
}
}
}
}
fun requestPasswordReset(email: String) {
viewModelScope.launch {
_uiState.update { it.copy(isSaving = true, errorMessage = null, message = null) }
when (val result = accountRepository.requestPasswordReset(email)) {
is AppResult.Success -> _uiState.update {
it.copy(
isSaving = false,
message = result.data,
)
}
is AppResult.Error -> _uiState.update {
it.copy(
isSaving = false,
errorMessage = result.reason.toUiMessage(),
)
}
}
}
}
fun resetPassword(token: String, password: String) {
viewModelScope.launch {
_uiState.update { it.copy(isSaving = true, errorMessage = null, message = null) }
when (val result = accountRepository.resetPassword(token, password)) {
is AppResult.Success -> _uiState.update {
it.copy(
isSaving = false,
message = result.data,
)
}
is AppResult.Error -> _uiState.update {
it.copy(
isSaving = false,
errorMessage = result.reason.toUiMessage(),
)
}
}
}
}
fun clearMessage() {
_uiState.update { it.copy(message = null, errorMessage = null) }
}
private fun AppError.toUiMessage(): String {
return when (this) {
AppError.InvalidCredentials -> "Invalid credentials."
AppError.Unauthorized -> "Unauthorized."
AppError.Network -> "Network error."
is AppError.Server -> message ?: "Server error."
is AppError.Unknown -> cause?.message ?: "Unknown error."
}
}
}