From 91d712c7023fa175d24bee71987a95d5ad36537d Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 9 Mar 2026 16:05:14 +0300 Subject: [PATCH] android: add account api and repository for profile privacy sessions 2fa --- .../messenger/data/auth/api/AuthApiService.kt | 46 +++ .../messenger/data/auth/dto/AuthDtos.kt | 77 +++++ .../auth/repository/NetworkAuthRepository.kt | 6 + .../messenger/data/user/api/UserApiService.kt | 25 ++ .../messenger/data/user/dto/UserDtos.kt | 33 ++ .../repository/NetworkAccountRepository.kt | 289 +++++++++++++++++ .../messenger/di/RepositoryModule.kt | 8 + .../domain/account/model/UserSearchItem.kt | 8 + .../account/repository/AccountRepository.kt | 41 +++ .../messenger/domain/auth/model/AuthUser.kt | 6 + .../messenger/ui/account/AccountUiState.kt | 19 ++ .../messenger/ui/account/AccountViewModel.kt | 302 ++++++++++++++++++ 12 files changed, 860 insertions(+) create mode 100644 android/app/src/main/java/ru/daemonlord/messenger/data/user/api/UserApiService.kt create mode 100644 android/app/src/main/java/ru/daemonlord/messenger/data/user/dto/UserDtos.kt create mode 100644 android/app/src/main/java/ru/daemonlord/messenger/data/user/repository/NetworkAccountRepository.kt create mode 100644 android/app/src/main/java/ru/daemonlord/messenger/domain/account/model/UserSearchItem.kt create mode 100644 android/app/src/main/java/ru/daemonlord/messenger/domain/account/repository/AccountRepository.kt create mode 100644 android/app/src/main/java/ru/daemonlord/messenger/ui/account/AccountUiState.kt create mode 100644 android/app/src/main/java/ru/daemonlord/messenger/ui/account/AccountViewModel.kt diff --git a/android/app/src/main/java/ru/daemonlord/messenger/data/auth/api/AuthApiService.kt b/android/app/src/main/java/ru/daemonlord/messenger/data/auth/api/AuthApiService.kt index 8c339a8..26576d4 100644 --- a/android/app/src/main/java/ru/daemonlord/messenger/data/auth/api/AuthApiService.kt +++ b/android/app/src/main/java/ru/daemonlord/messenger/data/auth/api/AuthApiService.kt @@ -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 @@ -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 } diff --git a/android/app/src/main/java/ru/daemonlord/messenger/data/auth/dto/AuthDtos.kt b/android/app/src/main/java/ru/daemonlord/messenger/data/auth/dto/AuthDtos.kt index 08d7b43..142a083 100644 --- a/android/app/src/main/java/ru/daemonlord/messenger/data/auth/dto/AuthDtos.kt +++ b/android/app/src/main/java/ru/daemonlord/messenger/data/auth/dto/AuthDtos.kt @@ -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, +) diff --git a/android/app/src/main/java/ru/daemonlord/messenger/data/auth/repository/NetworkAuthRepository.kt b/android/app/src/main/java/ru/daemonlord/messenger/data/auth/repository/NetworkAuthRepository.kt index 9251662..30bc270 100644 --- a/android/app/src/main/java/ru/daemonlord/messenger/data/auth/repository/NetworkAuthRepository.kt +++ b/android/app/src/main/java/ru/daemonlord/messenger/data/auth/repository/NetworkAuthRepository.kt @@ -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", ) } diff --git a/android/app/src/main/java/ru/daemonlord/messenger/data/user/api/UserApiService.kt b/android/app/src/main/java/ru/daemonlord/messenger/data/user/api/UserApiService.kt new file mode 100644 index 0000000..5930bf2 --- /dev/null +++ b/android/app/src/main/java/ru/daemonlord/messenger/data/user/api/UserApiService.kt @@ -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 + + @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) +} diff --git a/android/app/src/main/java/ru/daemonlord/messenger/data/user/dto/UserDtos.kt b/android/app/src/main/java/ru/daemonlord/messenger/data/user/dto/UserDtos.kt new file mode 100644 index 0000000..dc7b11d --- /dev/null +++ b/android/app/src/main/java/ru/daemonlord/messenger/data/user/dto/UserDtos.kt @@ -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, +) diff --git a/android/app/src/main/java/ru/daemonlord/messenger/data/user/repository/NetworkAccountRepository.kt b/android/app/src/main/java/ru/daemonlord/messenger/data/user/repository/NetworkAccountRepository.kt new file mode 100644 index 0000000..fab4814 --- /dev/null +++ b/android/app/src/main/java/ru/daemonlord/messenger/data/user/repository/NetworkAccountRepository.kt @@ -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 = 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 = 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 = 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 = 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> = withContext(ioDispatcher) { + try { + AppResult.Success(userApiService.listBlockedUsers().map { it.toDomain() }) + } catch (error: Throwable) { + AppResult.Error(error.toAppError()) + } + } + + override suspend fun blockUser(userId: Long): AppResult = withContext(ioDispatcher) { + try { + userApiService.blockUser(userId) + AppResult.Success(Unit) + } catch (error: Throwable) { + AppResult.Error(error.toAppError()) + } + } + + override suspend fun unblockUser(userId: Long): AppResult = withContext(ioDispatcher) { + try { + userApiService.unblockUser(userId) + AppResult.Success(Unit) + } catch (error: Throwable) { + AppResult.Error(error.toAppError()) + } + } + + override suspend fun listSessions(): AppResult> = withContext(ioDispatcher) { + try { + AppResult.Success(authApiService.sessions().map { it.toDomain() }) + } catch (error: Throwable) { + AppResult.Error(error.toAppError()) + } + } + + override suspend fun revokeSession(jti: String): AppResult = withContext(ioDispatcher) { + try { + authApiService.revokeSession(jti) + AppResult.Success(Unit) + } catch (error: Throwable) { + AppResult.Error(error.toAppError()) + } + } + + override suspend fun revokeAllSessions(): AppResult = withContext(ioDispatcher) { + try { + authApiService.revokeAllSessions() + AppResult.Success(Unit) + } catch (error: Throwable) { + AppResult.Error(error.toAppError()) + } + } + + override suspend fun verifyEmail(token: String): AppResult = 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 = 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 = 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> = 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 = 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 = 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 = withContext(ioDispatcher) { + try { + AppResult.Success(authApiService.twoFactorRecoveryStatus().remainingCodes) + } catch (error: Throwable) { + AppResult.Error(error.toAppError()) + } + } + + override suspend fun regenerateTwoFactorRecoveryCodes(code: String): AppResult> = 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, + ) + } +} diff --git a/android/app/src/main/java/ru/daemonlord/messenger/di/RepositoryModule.kt b/android/app/src/main/java/ru/daemonlord/messenger/di/RepositoryModule.kt index bb8924b..da03c17 100644 --- a/android/app/src/main/java/ru/daemonlord/messenger/di/RepositoryModule.kt +++ b/android/app/src/main/java/ru/daemonlord/messenger/di/RepositoryModule.kt @@ -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 } diff --git a/android/app/src/main/java/ru/daemonlord/messenger/domain/account/model/UserSearchItem.kt b/android/app/src/main/java/ru/daemonlord/messenger/domain/account/model/UserSearchItem.kt new file mode 100644 index 0000000..0d87009 --- /dev/null +++ b/android/app/src/main/java/ru/daemonlord/messenger/domain/account/model/UserSearchItem.kt @@ -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?, +) diff --git a/android/app/src/main/java/ru/daemonlord/messenger/domain/account/repository/AccountRepository.kt b/android/app/src/main/java/ru/daemonlord/messenger/domain/account/repository/AccountRepository.kt new file mode 100644 index 0000000..2c86a5c --- /dev/null +++ b/android/app/src/main/java/ru/daemonlord/messenger/domain/account/repository/AccountRepository.kt @@ -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 + suspend fun updateProfile( + name: String, + username: String, + bio: String?, + avatarUrl: String?, + ): AppResult + suspend fun uploadAvatar( + fileName: String, + mimeType: String, + bytes: ByteArray, + ): AppResult + suspend fun updatePrivacy( + privateMessages: String, + lastSeen: String, + avatar: String, + groupInvites: String, + ): AppResult + suspend fun listBlockedUsers(): AppResult> + suspend fun blockUser(userId: Long): AppResult + suspend fun unblockUser(userId: Long): AppResult + suspend fun listSessions(): AppResult> + suspend fun revokeSession(jti: String): AppResult + suspend fun revokeAllSessions(): AppResult + suspend fun verifyEmail(token: String): AppResult + suspend fun requestPasswordReset(email: String): AppResult + suspend fun resetPassword(token: String, password: String): AppResult + suspend fun setupTwoFactor(): AppResult> + suspend fun enableTwoFactor(code: String): AppResult + suspend fun disableTwoFactor(code: String): AppResult + suspend fun twoFactorRecoveryStatus(): AppResult + suspend fun regenerateTwoFactorRecoveryCodes(code: String): AppResult> +} diff --git a/android/app/src/main/java/ru/daemonlord/messenger/domain/auth/model/AuthUser.kt b/android/app/src/main/java/ru/daemonlord/messenger/domain/auth/model/AuthUser.kt index 7cb49b7..85b408e 100644 --- a/android/app/src/main/java/ru/daemonlord/messenger/domain/auth/model/AuthUser.kt +++ b/android/app/src/main/java/ru/daemonlord/messenger/domain/auth/model/AuthUser.kt @@ -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, ) diff --git a/android/app/src/main/java/ru/daemonlord/messenger/ui/account/AccountUiState.kt b/android/app/src/main/java/ru/daemonlord/messenger/ui/account/AccountUiState.kt new file mode 100644 index 0000000..ec2ea35 --- /dev/null +++ b/android/app/src/main/java/ru/daemonlord/messenger/ui/account/AccountUiState.kt @@ -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 = emptyList(), + val blockedUsers: List = emptyList(), + val twoFactorSecret: String? = null, + val twoFactorOtpAuthUrl: String? = null, + val recoveryCodes: List = emptyList(), + val recoveryCodesRemaining: Int? = null, + val message: String? = null, + val errorMessage: String? = null, +) diff --git a/android/app/src/main/java/ru/daemonlord/messenger/ui/account/AccountViewModel.kt b/android/app/src/main/java/ru/daemonlord/messenger/ui/account/AccountViewModel.kt new file mode 100644 index 0000000..04e8c74 --- /dev/null +++ b/android/app/src/main/java/ru/daemonlord/messenger/ui/account/AccountViewModel.kt @@ -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 = _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() + .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." + } + } +}