android: add account api and repository for profile privacy sessions 2fa
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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>,
|
||||
)
|
||||
|
||||
@@ -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",
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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?,
|
||||
)
|
||||
@@ -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>>
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user