Compare commits

...

5 Commits

Author SHA1 Message Date
Codex
f708854bb2 android: add media gallery and account checklist progress
Some checks failed
Android CI / android (push) Has started running
Android Release / release (push) Has been cancelled
CI / test (push) Has been cancelled
2026-03-09 16:05:52 +03:00
Codex
5368515112 android: implement profile settings privacy sessions and 2fa ui 2026-03-09 16:05:39 +03:00
Codex
9ad8372d45 android: add verify and reset auth flows with deep link routing 2026-03-09 16:05:26 +03:00
Codex
91d712c702 android: add account api and repository for profile privacy sessions 2fa 2026-03-09 16:05:14 +03:00
Codex
65e74cffdb android: add core common module logging crashlytics and feature flags 2026-03-09 16:04:53 +03:00
37 changed files with 1787 additions and 65 deletions

View File

@@ -378,3 +378,17 @@
- Added dedicated release workflow (`.github/workflows/android-release.yml`) for `main` branch pushes.
- Adapted version extraction for Kotlin DSL (`android/app/build.gradle.kts`) and guarded release by existing git tag.
- Wired release build, git tag push, and Gitea release publication with APK artifact upload.
### Step 65 - Account and media parity foundation (checklist 1-15)
- Introduced `:core:common` module and moved base `AppError`/`AppResult` contracts out of `:app`.
- Added structured app logging (`Timber`) and crash reporting baseline (`Firebase Crashlytics`) with app startup wiring.
- Added API version header interceptor + build-time feature flags and DI provider.
- Added account network layer for auth/account management:
- verify email, password reset request/reset,
- sessions list + revoke one/all,
- 2FA setup/enable/disable + recovery status/regenerate,
- profile/privacy update and blocked users management.
- Added deep-link aware auth routes for `/verify-email` and `/reset-password`.
- Reworked Settings/Profile screens from placeholders to editable account management screens.
- Added avatar upload with center square crop (`1:1`) before upload.
- Upgraded message attachment rendering with in-bubble multi-image gallery and unified attachment context actions (open/copy/close).

View File

@@ -6,6 +6,7 @@ plugins {
id("org.jetbrains.kotlin.plugin.serialization")
id("com.google.dagger.hilt.android")
id("com.google.gms.google-services")
id("com.google.firebase.crashlytics")
}
android {
@@ -19,6 +20,10 @@ android {
versionCode = 1
versionName = "0.1.0"
buildConfigField("String", "API_BASE_URL", "\"https://chat.daemonlord.ru/\"")
buildConfigField("String", "API_VERSION_HEADER", "\"2026-03\"")
buildConfigField("boolean", "FEATURE_ACCOUNT_MANAGEMENT", "true")
buildConfigField("boolean", "FEATURE_TWO_FACTOR", "true")
buildConfigField("boolean", "FEATURE_MEDIA_GALLERY", "true")
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables {
@@ -62,6 +67,7 @@ android {
}
dependencies {
implementation(project(":core:common"))
implementation(platform("com.google.firebase:firebase-bom:34.10.0"))
implementation("androidx.core:core-ktx:1.15.0")
@@ -98,6 +104,8 @@ dependencies {
kapt("com.google.dagger:hilt-compiler:2.52")
implementation("androidx.hilt:hilt-navigation-compose:1.2.0")
implementation("com.google.firebase:firebase-messaging")
implementation("com.google.firebase:firebase-crashlytics")
implementation("com.jakewharton.timber:timber:5.0.1")
testImplementation("junit:junit:4.13.2")
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.9.0")

View File

@@ -28,6 +28,14 @@
android:host="chat.daemonlord.ru"
android:pathPrefix="/join"
android:scheme="https" />
<data
android:host="chat.daemonlord.ru"
android:pathPrefix="/verify-email"
android:scheme="https" />
<data
android:host="chat.daemonlord.ru"
android:pathPrefix="/reset-password"
android:scheme="https" />
</intent-filter>
</activity>
<service

View File

@@ -20,12 +20,16 @@ import ru.daemonlord.messenger.ui.navigation.MessengerNavHost
@AndroidEntryPoint
class MainActivity : ComponentActivity() {
private var pendingInviteToken by mutableStateOf<String?>(null)
private var pendingVerifyEmailToken by mutableStateOf<String?>(null)
private var pendingResetPasswordToken by mutableStateOf<String?>(null)
private var pendingNotificationChatId by mutableStateOf<Long?>(null)
private var pendingNotificationMessageId by mutableStateOf<Long?>(null)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
pendingInviteToken = intent.extractInviteToken()
pendingVerifyEmailToken = intent.extractVerifyEmailToken()
pendingResetPasswordToken = intent.extractResetPasswordToken()
val notificationPayload = intent.extractNotificationOpenPayload()
pendingNotificationChatId = notificationPayload?.first
pendingNotificationMessageId = notificationPayload?.second
@@ -36,6 +40,10 @@ class MainActivity : ComponentActivity() {
AppRoot(
inviteToken = pendingInviteToken,
onInviteTokenConsumed = { pendingInviteToken = null },
verifyEmailToken = pendingVerifyEmailToken,
onVerifyEmailTokenConsumed = { pendingVerifyEmailToken = null },
resetPasswordToken = pendingResetPasswordToken,
onResetPasswordTokenConsumed = { pendingResetPasswordToken = null },
notificationChatId = pendingNotificationChatId,
notificationMessageId = pendingNotificationMessageId,
onNotificationConsumed = {
@@ -52,6 +60,8 @@ class MainActivity : ComponentActivity() {
super.onNewIntent(intent)
setIntent(intent)
pendingInviteToken = intent.extractInviteToken() ?: pendingInviteToken
pendingVerifyEmailToken = intent.extractVerifyEmailToken() ?: pendingVerifyEmailToken
pendingResetPasswordToken = intent.extractResetPasswordToken() ?: pendingResetPasswordToken
val notificationPayload = intent.extractNotificationOpenPayload()
if (notificationPayload != null) {
pendingNotificationChatId = notificationPayload.first
@@ -64,6 +74,10 @@ class MainActivity : ComponentActivity() {
private fun AppRoot(
inviteToken: String?,
onInviteTokenConsumed: () -> Unit,
verifyEmailToken: String?,
onVerifyEmailTokenConsumed: () -> Unit,
resetPasswordToken: String?,
onResetPasswordTokenConsumed: () -> Unit,
notificationChatId: Long?,
notificationMessageId: Long?,
onNotificationConsumed: () -> Unit,
@@ -71,12 +85,30 @@ private fun AppRoot(
MessengerNavHost(
inviteToken = inviteToken,
onInviteTokenConsumed = onInviteTokenConsumed,
verifyEmailToken = verifyEmailToken,
onVerifyEmailTokenConsumed = onVerifyEmailTokenConsumed,
resetPasswordToken = resetPasswordToken,
onResetPasswordTokenConsumed = onResetPasswordTokenConsumed,
notificationChatId = notificationChatId,
notificationMessageId = notificationMessageId,
onNotificationConsumed = onNotificationConsumed,
)
}
private fun Intent?.extractVerifyEmailToken(): String? {
val uri = this?.data ?: return null
val isVerifyPath = uri.pathSegments.contains("verify-email") || uri.path.equals("/verify-email", ignoreCase = true)
if (!isVerifyPath) return null
return uri.getQueryParameter("token")?.trim()?.takeIf { it.isNotBlank() }
}
private fun Intent?.extractResetPasswordToken(): String? {
val uri = this?.data ?: return null
val isResetPath = uri.pathSegments.contains("reset-password") || uri.path.equals("/reset-password", ignoreCase = true)
if (!isResetPath) return null
return uri.getQueryParameter("token")?.trim()?.takeIf { it.isNotBlank() }
}
private fun Intent?.extractInviteToken(): String? {
val uri = this?.data ?: return null
val queryToken = uri.getQueryParameter("token")?.trim().orEmpty()

View File

@@ -5,14 +5,20 @@ import coil.ImageLoader
import coil.ImageLoaderFactory
import coil.disk.DiskCache
import coil.memory.MemoryCache
import com.google.firebase.crashlytics.FirebaseCrashlytics
import dagger.hilt.android.HiltAndroidApp
import ru.daemonlord.messenger.core.notifications.NotificationChannels
import java.io.File
import timber.log.Timber
@HiltAndroidApp
class MessengerApplication : Application(), ImageLoaderFactory {
override fun onCreate() {
super.onCreate()
if (BuildConfig.DEBUG) {
Timber.plant(Timber.DebugTree())
}
FirebaseCrashlytics.getInstance().setCrashlyticsCollectionEnabled(!BuildConfig.DEBUG)
NotificationChannels.ensureCreated(this)
}

View File

@@ -0,0 +1,38 @@
package ru.daemonlord.messenger.core.logging
import com.google.firebase.crashlytics.FirebaseCrashlytics
import javax.inject.Inject
import javax.inject.Singleton
import timber.log.Timber
@Singleton
class TimberAppLogger @Inject constructor(
private val crashlytics: FirebaseCrashlytics,
) : AppLogger {
override fun d(tag: String, message: String) {
Timber.tag(tag).d(message)
}
override fun i(tag: String, message: String) {
Timber.tag(tag).i(message)
}
override fun w(tag: String, message: String, throwable: Throwable?) {
if (throwable != null) {
Timber.tag(tag).w(throwable, message)
crashlytics.recordException(throwable)
} else {
Timber.tag(tag).w(message)
}
}
override fun e(tag: String, message: String, throwable: Throwable?) {
if (throwable != null) {
Timber.tag(tag).e(throwable, message)
crashlytics.recordException(throwable)
} else {
Timber.tag(tag).e(message)
}
}
}

View File

@@ -0,0 +1,17 @@
package ru.daemonlord.messenger.core.network
import okhttp3.Interceptor
import okhttp3.Response
import ru.daemonlord.messenger.BuildConfig
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class ApiVersionInterceptor @Inject constructor() : Interceptor {
override fun intercept(chain: Interceptor.Chain): Response {
val request = chain.request().newBuilder()
.header("X-Api-Version", BuildConfig.API_VERSION_HEADER)
.build()
return chain.proceed(request)
}
}

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

@@ -0,0 +1,23 @@
package ru.daemonlord.messenger.di
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import ru.daemonlord.messenger.BuildConfig
import ru.daemonlord.messenger.core.feature.FeatureFlags
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
object FeatureFlagsModule {
@Provides
@Singleton
fun provideFeatureFlags(): FeatureFlags {
return FeatureFlags(
accountManagementEnabled = BuildConfig.FEATURE_ACCOUNT_MANAGEMENT,
twoFactorEnabled = BuildConfig.FEATURE_TWO_FACTOR,
mediaGalleryEnabled = BuildConfig.FEATURE_MEDIA_GALLERY,
)
}
}

View File

@@ -0,0 +1,25 @@
package ru.daemonlord.messenger.di
import com.google.firebase.crashlytics.FirebaseCrashlytics
import dagger.Binds
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import ru.daemonlord.messenger.core.logging.AppLogger
import ru.daemonlord.messenger.core.logging.TimberAppLogger
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
abstract class LoggingModule {
@Binds
@Singleton
abstract fun bindAppLogger(logger: TimberAppLogger): AppLogger
companion object {
@Provides
@Singleton
fun provideCrashlytics(): FirebaseCrashlytics = FirebaseCrashlytics.getInstance()
}
}

View File

@@ -13,11 +13,13 @@ import okhttp3.logging.HttpLoggingInterceptor
import retrofit2.Retrofit
import ru.daemonlord.messenger.BuildConfig
import ru.daemonlord.messenger.core.network.AuthHeaderInterceptor
import ru.daemonlord.messenger.core.network.ApiVersionInterceptor
import ru.daemonlord.messenger.core.network.TokenRefreshAuthenticator
import ru.daemonlord.messenger.data.auth.api.AuthApiService
import ru.daemonlord.messenger.data.chat.api.ChatApiService
import ru.daemonlord.messenger.data.media.api.MediaApiService
import ru.daemonlord.messenger.data.message.api.MessageApiService
import ru.daemonlord.messenger.data.user.api.UserApiService
import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory
import java.util.concurrent.TimeUnit
import javax.inject.Singleton
@@ -87,11 +89,13 @@ object NetworkModule {
@Singleton
fun provideApiClient(
loggingInterceptor: HttpLoggingInterceptor,
apiVersionInterceptor: ApiVersionInterceptor,
authHeaderInterceptor: AuthHeaderInterceptor,
tokenRefreshAuthenticator: TokenRefreshAuthenticator,
): OkHttpClient {
return OkHttpClient.Builder()
.addInterceptor(loggingInterceptor)
.addInterceptor(apiVersionInterceptor)
.addInterceptor(authHeaderInterceptor)
.authenticator(tokenRefreshAuthenticator)
.connectTimeout(20, TimeUnit.SECONDS)
@@ -137,4 +141,10 @@ object NetworkModule {
fun provideMediaApiService(retrofit: Retrofit): MediaApiService {
return retrofit.create(MediaApiService::class.java)
}
@Provides
@Singleton
fun provideUserApiService(retrofit: Retrofit): UserApiService {
return retrofit.create(UserApiService::class.java)
}
}

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."
}
}
}

View File

@@ -26,6 +26,8 @@ fun LoginScreen(
onEmailChanged: (String) -> Unit,
onPasswordChanged: (String) -> Unit,
onLoginClick: () -> Unit,
onOpenVerifyEmail: () -> Unit,
onOpenResetPassword: () -> Unit,
) {
Column(
modifier = Modifier
@@ -86,14 +88,19 @@ fun LoginScreen(
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.padding(top = 12.dp),
)
} else {
TextButton(
onClick = {},
enabled = false,
modifier = Modifier.padding(top = 8.dp),
) {
Text(text = "Use your existing backend account")
}
}
TextButton(
onClick = onOpenVerifyEmail,
enabled = !state.isLoading,
modifier = Modifier.padding(top = 8.dp),
) {
Text(text = "Verify email by token")
}
TextButton(
onClick = onOpenResetPassword,
enabled = !state.isLoading,
) {
Text(text = "Forgot password")
}
}
}

View File

@@ -0,0 +1,87 @@
package ru.daemonlord.messenger.ui.auth.reset
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawing
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import ru.daemonlord.messenger.ui.account.AccountViewModel
@Composable
fun ResetPasswordRoute(
token: String?,
onBackToLogin: () -> Unit,
viewModel: AccountViewModel = hiltViewModel(),
) {
val state by viewModel.uiState.collectAsStateWithLifecycle()
var email by remember { mutableStateOf("") }
var password by remember { mutableStateOf("") }
Column(
modifier = Modifier
.fillMaxSize()
.windowInsetsPadding(WindowInsets.safeDrawing)
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
Text("Password reset", style = MaterialTheme.typography.headlineSmall)
OutlinedTextField(
value = email,
onValueChange = { email = it },
label = { Text("Email") },
modifier = Modifier.fillMaxWidth(),
)
Button(
onClick = { viewModel.requestPasswordReset(email) },
modifier = Modifier.fillMaxWidth(),
enabled = !state.isSaving && email.isNotBlank(),
) {
Text("Send reset link")
}
OutlinedTextField(
value = password,
onValueChange = { password = it },
label = { Text("New password") },
modifier = Modifier.fillMaxWidth(),
)
Button(
onClick = {
if (!token.isNullOrBlank()) {
viewModel.resetPassword(token = token, password = password)
}
},
modifier = Modifier.fillMaxWidth(),
enabled = !state.isSaving && !token.isNullOrBlank() && password.length >= 8,
) {
Text("Reset with token")
}
if (state.isSaving) {
CircularProgressIndicator()
}
if (!state.message.isNullOrBlank()) {
Text(state.message!!, color = MaterialTheme.colorScheme.primary)
}
if (!state.errorMessage.isNullOrBlank()) {
Text(state.errorMessage!!, color = MaterialTheme.colorScheme.error)
}
Button(onClick = onBackToLogin, modifier = Modifier.fillMaxWidth()) {
Text("Back to login")
}
}
}

View File

@@ -0,0 +1,77 @@
package ru.daemonlord.messenger.ui.auth.verify
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawing
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import ru.daemonlord.messenger.ui.account.AccountViewModel
@Composable
fun VerifyEmailRoute(
token: String?,
onBackToLogin: () -> Unit,
viewModel: AccountViewModel = hiltViewModel(),
) {
val state by viewModel.uiState.collectAsStateWithLifecycle()
var editableToken by remember(token) { mutableStateOf(token.orEmpty()) }
LaunchedEffect(token) {
if (!token.isNullOrBlank()) {
viewModel.verifyEmail(token)
}
}
Column(
modifier = Modifier
.fillMaxSize()
.windowInsetsPadding(WindowInsets.safeDrawing)
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
Text("Verify email", style = MaterialTheme.typography.headlineSmall)
OutlinedTextField(
value = editableToken,
onValueChange = { editableToken = it },
label = { Text("Verification token") },
modifier = Modifier.fillMaxWidth(),
)
Button(
onClick = { viewModel.verifyEmail(editableToken) },
enabled = !state.isSaving && editableToken.isNotBlank(),
modifier = Modifier.fillMaxWidth(),
) {
Text("Verify")
}
if (state.isSaving) {
CircularProgressIndicator()
}
if (!state.message.isNullOrBlank()) {
Text(state.message!!, color = MaterialTheme.colorScheme.primary)
}
if (!state.errorMessage.isNullOrBlank()) {
Text(state.errorMessage!!, color = MaterialTheme.colorScheme.error)
}
Button(onClick = onBackToLogin, modifier = Modifier.fillMaxWidth()) {
Text("Back to login")
}
}
}

View File

@@ -51,8 +51,10 @@ import androidx.compose.ui.draw.clip
import androidx.compose.ui.unit.dp
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.safeDrawing
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import coil.compose.AsyncImage
@@ -624,6 +626,8 @@ private fun MessageBubble(
onClick: () -> Unit,
onLongPress: () -> Unit,
) {
val clipboard = LocalClipboardManager.current
var contextAttachmentUrl by remember { mutableStateOf<String?>(null) }
val isOutgoing = message.isOutgoing
val bubbleShape = if (isOutgoing) {
RoundedCornerShape(topStart = 16.dp, topEnd = 16.dp, bottomStart = 16.dp, bottomEnd = 6.dp)
@@ -733,41 +737,120 @@ private fun MessageBubble(
}
if (message.attachments.isNotEmpty()) {
Spacer(modifier = Modifier.height(4.dp))
val showAsFileList = message.attachments.size > 1
message.attachments.forEach { attachment ->
val fileType = attachment.fileType.lowercase()
when {
fileType.startsWith("image/") -> {
AsyncImage(
model = attachment.fileUrl,
contentDescription = "Image",
modifier = Modifier
.fillMaxWidth()
.height(160.dp)
.clickable { onAttachmentImageClick(attachment.fileUrl) },
contentScale = ContentScale.Crop,
)
}
fileType.startsWith("video/") -> {
VideoAttachmentCard(
url = attachment.fileUrl,
fileType = attachment.fileType,
)
}
fileType.startsWith("audio/") -> {
AudioAttachmentPlayer(url = attachment.fileUrl)
}
else -> {
FileAttachmentRow(
fileUrl = attachment.fileUrl,
fileType = attachment.fileType,
fileSize = attachment.fileSize,
compact = showAsFileList,
)
val imageAttachments = message.attachments.filter { it.fileType.lowercase().startsWith("image/") }
val nonImageAttachments = message.attachments.filterNot { it.fileType.lowercase().startsWith("image/") }
if (imageAttachments.isNotEmpty()) {
if (imageAttachments.size == 1) {
val single = imageAttachments.first()
AsyncImage(
model = single.fileUrl,
contentDescription = "Image",
modifier = Modifier
.fillMaxWidth()
.height(180.dp)
.combinedClickable(
onClick = { onAttachmentImageClick(single.fileUrl) },
onLongClick = { contextAttachmentUrl = single.fileUrl },
),
contentScale = ContentScale.Crop,
)
} else {
imageAttachments.chunked(2).forEach { rowItems ->
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
rowItems.forEach { image ->
AsyncImage(
model = image.fileUrl,
contentDescription = "Image",
modifier = Modifier
.weight(1f)
.height(110.dp)
.combinedClickable(
onClick = { onAttachmentImageClick(image.fileUrl) },
onLongClick = { contextAttachmentUrl = image.fileUrl },
),
contentScale = ContentScale.Crop,
)
}
if (rowItems.size == 1) {
Spacer(modifier = Modifier.weight(1f))
}
}
Spacer(modifier = Modifier.height(4.dp))
}
}
Spacer(modifier = Modifier.height(4.dp))
}
val showAsFileList = nonImageAttachments.size > 1
nonImageAttachments.forEach { attachment ->
val fileType = attachment.fileType.lowercase()
when {
fileType.startsWith("video/") -> {
Box(
modifier = Modifier.combinedClickable(
onClick = {},
onLongClick = { contextAttachmentUrl = attachment.fileUrl },
),
) {
VideoAttachmentCard(
url = attachment.fileUrl,
fileType = attachment.fileType,
)
}
}
fileType.startsWith("audio/") -> {
Box(
modifier = Modifier.combinedClickable(
onClick = {},
onLongClick = { contextAttachmentUrl = attachment.fileUrl },
),
) {
AudioAttachmentPlayer(url = attachment.fileUrl)
}
}
else -> {
Box(
modifier = Modifier.combinedClickable(
onClick = {},
onLongClick = { contextAttachmentUrl = attachment.fileUrl },
),
) {
FileAttachmentRow(
fileUrl = attachment.fileUrl,
fileType = attachment.fileType,
fileSize = attachment.fileSize,
compact = showAsFileList,
)
}
}
}
Spacer(modifier = Modifier.height(4.dp))
}
}
if (!contextAttachmentUrl.isNullOrBlank()) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(6.dp),
) {
Button(onClick = { onAttachmentImageClick(contextAttachmentUrl!!) }) {
Text("Open")
}
Button(
onClick = {
clipboard.setText(AnnotatedString(contextAttachmentUrl!!))
contextAttachmentUrl = null
},
) {
Text("Copy link")
}
Button(onClick = { contextAttachmentUrl = null }) {
Text("Close")
}
}
}
Row(
modifier = Modifier.fillMaxWidth(),

View File

@@ -31,6 +31,8 @@ import androidx.navigation.compose.navigation
import androidx.navigation.compose.rememberNavController
import ru.daemonlord.messenger.ui.auth.AuthViewModel
import ru.daemonlord.messenger.ui.auth.LoginScreen
import ru.daemonlord.messenger.ui.auth.reset.ResetPasswordRoute
import ru.daemonlord.messenger.ui.auth.verify.VerifyEmailRoute
import ru.daemonlord.messenger.ui.chat.ChatRoute
import ru.daemonlord.messenger.ui.chats.ChatListRoute
import ru.daemonlord.messenger.ui.profile.ProfileRoute
@@ -39,6 +41,8 @@ import ru.daemonlord.messenger.ui.settings.SettingsRoute
private object Routes {
const val AuthGraph = "auth_graph"
const val Login = "login"
const val VerifyEmail = "verify_email"
const val ResetPassword = "reset_password"
const val Chats = "chats"
const val Settings = "settings"
const val Profile = "profile"
@@ -51,6 +55,10 @@ fun MessengerNavHost(
viewModel: AuthViewModel = hiltViewModel(),
inviteToken: String? = null,
onInviteTokenConsumed: () -> Unit = {},
verifyEmailToken: String? = null,
onVerifyEmailTokenConsumed: () -> Unit = {},
resetPasswordToken: String? = null,
onResetPasswordTokenConsumed: () -> Unit = {},
notificationChatId: Long? = null,
notificationMessageId: Long? = null,
onNotificationConsumed: () -> Unit = {},
@@ -72,10 +80,26 @@ fun MessengerNavHost(
}
}
LaunchedEffect(uiState.isCheckingSession, uiState.isAuthenticated, notificationChatId, notificationMessageId) {
LaunchedEffect(uiState.isCheckingSession, uiState.isAuthenticated, verifyEmailToken, resetPasswordToken, notificationChatId, notificationMessageId) {
if (uiState.isCheckingSession) {
return@LaunchedEffect
}
if (!uiState.isAuthenticated && !verifyEmailToken.isNullOrBlank()) {
navController.navigate("${Routes.VerifyEmail}?token=$verifyEmailToken") {
popUpTo(navController.graph.findStartDestination().id) { inclusive = true }
launchSingleTop = true
}
onVerifyEmailTokenConsumed()
return@LaunchedEffect
}
if (!uiState.isAuthenticated && !resetPasswordToken.isNullOrBlank()) {
navController.navigate("${Routes.ResetPassword}?token=$resetPasswordToken") {
popUpTo(navController.graph.findStartDestination().id) { inclusive = true }
launchSingleTop = true
}
onResetPasswordTokenConsumed()
return@LaunchedEffect
}
if (uiState.isAuthenticated) {
if (notificationChatId != null) {
navController.navigate("${Routes.Chat}/$notificationChatId") {
@@ -120,11 +144,45 @@ fun MessengerNavHost(
onEmailChanged = viewModel::onEmailChanged,
onPasswordChanged = viewModel::onPasswordChanged,
onLoginClick = viewModel::login,
onOpenVerifyEmail = { navController.navigate(Routes.VerifyEmail) },
onOpenResetPassword = { navController.navigate(Routes.ResetPassword) },
)
}
}
}
composable(
route = "${Routes.VerifyEmail}?token={token}",
arguments = listOf(
navArgument("token") {
type = NavType.StringType
nullable = true
defaultValue = null
}
),
) { entry ->
VerifyEmailRoute(
token = entry.arguments?.getString("token"),
onBackToLogin = { navController.navigate(Routes.Login) },
)
}
composable(
route = "${Routes.ResetPassword}?token={token}",
arguments = listOf(
navArgument("token") {
type = NavType.StringType
nullable = true
defaultValue = null
}
),
) { entry ->
ResetPasswordRoute(
token = entry.arguments?.getString("token"),
onBackToLogin = { navController.navigate(Routes.Login) },
)
}
composable(route = Routes.Chats) {
ChatListRoute(
inviteToken = inviteToken,

View File

@@ -1,28 +1,53 @@
package ru.daemonlord.messenger.ui.profile
import android.content.Context
import android.graphics.Bitmap
import android.graphics.ImageDecoder
import android.net.Uri
import android.os.Build
import android.provider.MediaStore
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawing
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import coil.compose.AsyncImage
import ru.daemonlord.messenger.ui.account.AccountViewModel
import java.io.ByteArrayOutputStream
@Composable
fun ProfileRoute(
onBackToChats: () -> Unit,
onOpenSettings: () -> Unit,
viewModel: AccountViewModel = hiltViewModel(),
) {
ProfileScreen(
onBackToChats = onBackToChats,
onOpenSettings = onOpenSettings,
viewModel = viewModel,
)
}
@@ -30,7 +55,33 @@ fun ProfileRoute(
fun ProfileScreen(
onBackToChats: () -> Unit,
onOpenSettings: () -> Unit,
viewModel: AccountViewModel,
) {
val state by viewModel.uiState.collectAsStateWithLifecycle()
val profile = state.profile
var name by remember(profile?.name) { mutableStateOf(profile?.name.orEmpty()) }
var username by remember(profile?.username) { mutableStateOf(profile?.username.orEmpty()) }
var bio by remember(profile?.bio) { mutableStateOf(profile?.bio.orEmpty()) }
var avatarUrl by remember(profile?.avatarUrl) { mutableStateOf(profile?.avatarUrl.orEmpty()) }
LaunchedEffect(Unit) {
viewModel.refresh()
}
val context = androidx.compose.ui.platform.LocalContext.current
val pickAvatarLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.GetContent(),
) { uri ->
val bytes = uri?.toSquareJpeg(context) ?: return@rememberLauncherForActivityResult
viewModel.uploadAvatar(
fileName = "avatar.jpg",
mimeType = "image/jpeg",
bytes = bytes,
) { uploadedUrl ->
avatarUrl = uploadedUrl
}
}
Column(
modifier = Modifier
.fillMaxSize()
@@ -42,11 +93,76 @@ fun ProfileScreen(
text = "Profile",
style = MaterialTheme.typography.headlineSmall,
)
Text(
text = "Profile editing screen placeholder. Telegram-like account editor will be implemented in a dedicated step.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
if (!avatarUrl.isBlank()) {
AsyncImage(
model = avatarUrl,
contentDescription = "Avatar",
modifier = Modifier.fillMaxWidth(),
)
}
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Button(onClick = { pickAvatarLauncher.launch("image/*") }, enabled = !state.isSaving) {
Text("Upload avatar")
}
if (state.isSaving) {
CircularProgressIndicator(modifier = Modifier.padding(4.dp))
}
}
OutlinedTextField(
value = name,
onValueChange = { name = it },
label = { Text("Name") },
modifier = Modifier.fillMaxWidth(),
)
OutlinedTextField(
value = username,
onValueChange = { username = it },
label = { Text("Username") },
modifier = Modifier.fillMaxWidth(),
)
OutlinedTextField(
value = bio,
onValueChange = { bio = it },
label = { Text("Bio") },
modifier = Modifier.fillMaxWidth(),
)
OutlinedTextField(
value = avatarUrl,
onValueChange = { avatarUrl = it },
label = { Text("Avatar URL") },
modifier = Modifier.fillMaxWidth(),
)
Button(
onClick = {
viewModel.updateProfile(
name = name,
username = username,
bio = bio.ifBlank { null },
avatarUrl = avatarUrl.ifBlank { null },
)
},
enabled = !state.isSaving && name.isNotBlank() && username.isNotBlank(),
modifier = Modifier.fillMaxWidth(),
) {
Text("Save profile")
}
if (!state.message.isNullOrBlank()) {
Text(
text = state.message!!,
color = MaterialTheme.colorScheme.primary,
style = MaterialTheme.typography.bodySmall,
)
}
if (!state.errorMessage.isNullOrBlank()) {
Text(
text = state.errorMessage!!,
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodySmall,
)
}
OutlinedButton(
onClick = onOpenSettings,
modifier = Modifier.fillMaxWidth(),
@@ -62,3 +178,27 @@ fun ProfileScreen(
}
}
private fun Uri.toSquareJpeg(context: Context): ByteArray? {
val bitmap = runCatching {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
val src = ImageDecoder.createSource(context.contentResolver, this)
ImageDecoder.decodeBitmap(src)
} else {
@Suppress("DEPRECATION")
MediaStore.Images.Media.getBitmap(context.contentResolver, this)
}
}.getOrNull() ?: return null
val square = bitmap.centerCropSquare()
val output = ByteArrayOutputStream()
val compressed = square.compress(Bitmap.CompressFormat.JPEG, 92, output)
if (!compressed) return null
return output.toByteArray()
}
private fun Bitmap.centerCropSquare(): Bitmap {
val side = minOf(width, height)
val left = (width - side) / 2
val top = (height - side) / 2
return Bitmap.createBitmap(this, left, top, side, side)
}

View File

@@ -3,31 +3,46 @@ package ru.daemonlord.messenger.ui.settings
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawing
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import ru.daemonlord.messenger.ui.account.AccountViewModel
@Composable
fun SettingsRoute(
onBackToChats: () -> Unit,
onOpenProfile: () -> Unit,
onLogout: () -> Unit,
viewModel: AccountViewModel = hiltViewModel(),
) {
SettingsScreen(
onBackToChats = onBackToChats,
onOpenProfile = onOpenProfile,
onLogout = onLogout,
viewModel = viewModel,
)
}
@@ -36,11 +51,29 @@ fun SettingsScreen(
onBackToChats: () -> Unit,
onOpenProfile: () -> Unit,
onLogout: () -> Unit,
viewModel: AccountViewModel,
) {
val state by viewModel.uiState.collectAsStateWithLifecycle()
val profile = state.profile
var privacyPm by remember(profile?.privacyPrivateMessages) { mutableStateOf(profile?.privacyPrivateMessages ?: "everyone") }
var privacyLastSeen by remember(profile?.privacyLastSeen) { mutableStateOf(profile?.privacyLastSeen ?: "everyone") }
var privacyAvatar by remember(profile?.privacyAvatar) { mutableStateOf(profile?.privacyAvatar ?: "everyone") }
var privacyGroupInvites by remember(profile?.privacyGroupInvites) { mutableStateOf(profile?.privacyGroupInvites ?: "everyone") }
var twoFactorCode by remember { mutableStateOf("") }
var recoveryRegenerateCode by remember { mutableStateOf("") }
var blockUserIdInput by remember { mutableStateOf("") }
val scrollState = rememberScrollState()
LaunchedEffect(Unit) {
viewModel.refresh()
viewModel.refreshRecoveryStatus()
}
Column(
modifier = Modifier
.fillMaxSize()
.windowInsetsPadding(WindowInsets.safeDrawing)
.verticalScroll(scrollState)
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
@@ -48,11 +81,182 @@ fun SettingsScreen(
text = "Settings",
style = MaterialTheme.typography.headlineSmall,
)
Text(
text = "Core account actions are here. More Telegram-like settings will be added in next iterations.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
if (state.isLoading) {
CircularProgressIndicator()
}
Text("Sessions", style = MaterialTheme.typography.titleMedium)
if (state.sessions.isEmpty()) {
Text("No active sessions", color = MaterialTheme.colorScheme.onSurfaceVariant)
} else {
state.sessions.forEach { session ->
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Column(modifier = Modifier.weight(1f)) {
Text(session.userAgent ?: "Unknown device", style = MaterialTheme.typography.bodyMedium)
Text(session.ipAddress ?: "Unknown IP", style = MaterialTheme.typography.bodySmall)
}
OutlinedButton(
onClick = { viewModel.revokeSession(session.jti) },
enabled = !state.isSaving && session.current != true,
) {
Text("Revoke")
}
}
}
}
OutlinedButton(
onClick = viewModel::revokeAllSessions,
enabled = !state.isSaving,
modifier = Modifier.fillMaxWidth(),
) {
Text("Revoke all sessions")
}
Text("Privacy", style = MaterialTheme.typography.titleMedium)
OutlinedTextField(
value = privacyPm,
onValueChange = { privacyPm = it },
label = { Text("PM privacy (everyone/contacts/nobody)") },
modifier = Modifier.fillMaxWidth(),
)
OutlinedTextField(
value = privacyLastSeen,
onValueChange = { privacyLastSeen = it },
label = { Text("Last seen privacy") },
modifier = Modifier.fillMaxWidth(),
)
OutlinedTextField(
value = privacyAvatar,
onValueChange = { privacyAvatar = it },
label = { Text("Avatar privacy") },
modifier = Modifier.fillMaxWidth(),
)
OutlinedTextField(
value = privacyGroupInvites,
onValueChange = { privacyGroupInvites = it },
label = { Text("Group invites privacy") },
modifier = Modifier.fillMaxWidth(),
)
Button(
onClick = {
viewModel.updatePrivacy(
privateMessages = privacyPm,
lastSeen = privacyLastSeen,
avatar = privacyAvatar,
groupInvites = privacyGroupInvites,
)
},
enabled = !state.isSaving,
modifier = Modifier.fillMaxWidth(),
) {
Text("Save privacy")
}
Text("Blocked users", style = MaterialTheme.typography.titleMedium)
OutlinedTextField(
value = blockUserIdInput,
onValueChange = { blockUserIdInput = it.filter { ch -> ch.isDigit() } },
label = { Text("User ID to block") },
modifier = Modifier.fillMaxWidth(),
)
OutlinedButton(
onClick = {
blockUserIdInput.toLongOrNull()?.let { viewModel.blockUser(it) }
blockUserIdInput = ""
},
enabled = !state.isSaving && blockUserIdInput.isNotBlank(),
modifier = Modifier.fillMaxWidth(),
) {
Text("Block user by ID")
}
if (state.blockedUsers.isEmpty()) {
Text("Blocked list is empty", color = MaterialTheme.colorScheme.onSurfaceVariant)
} else {
state.blockedUsers.forEach { user ->
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Column(modifier = Modifier.weight(1f)) {
Text(user.name)
if (!user.username.isNullOrBlank()) {
Text("@${user.username}", style = MaterialTheme.typography.bodySmall)
}
}
OutlinedButton(onClick = { viewModel.unblockUser(user.id) }) {
Text("Unblock")
}
}
}
}
Text("2FA", style = MaterialTheme.typography.titleMedium)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedButton(onClick = viewModel::setupTwoFactor, enabled = !state.isSaving) {
Text("Setup")
}
OutlinedButton(onClick = viewModel::refreshRecoveryStatus, enabled = !state.isSaving) {
Text("Refresh status")
}
}
if (!state.twoFactorSecret.isNullOrBlank()) {
Text("Secret: ${state.twoFactorSecret}")
if (!state.twoFactorOtpAuthUrl.isNullOrBlank()) {
Text("OTP URI: ${state.twoFactorOtpAuthUrl}", style = MaterialTheme.typography.bodySmall)
}
}
Text("Recovery codes left: ${state.recoveryCodesRemaining ?: "-"}")
OutlinedTextField(
value = twoFactorCode,
onValueChange = { twoFactorCode = it },
label = { Text("2FA code") },
modifier = Modifier.fillMaxWidth(),
)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Button(
onClick = { viewModel.enableTwoFactor(twoFactorCode) },
enabled = !state.isSaving && twoFactorCode.isNotBlank(),
) {
Text("Enable 2FA")
}
OutlinedButton(
onClick = { viewModel.disableTwoFactor(twoFactorCode) },
enabled = !state.isSaving && twoFactorCode.isNotBlank(),
) {
Text("Disable 2FA")
}
}
OutlinedTextField(
value = recoveryRegenerateCode,
onValueChange = { recoveryRegenerateCode = it },
label = { Text("Code to regenerate recovery codes") },
modifier = Modifier.fillMaxWidth(),
)
OutlinedButton(
onClick = { viewModel.regenerateRecoveryCodes(recoveryRegenerateCode) },
enabled = !state.isSaving && recoveryRegenerateCode.isNotBlank(),
modifier = Modifier.fillMaxWidth(),
) {
Text("Regenerate recovery codes")
}
if (state.recoveryCodes.isNotEmpty()) {
Text("New recovery codes:", style = MaterialTheme.typography.bodyMedium)
state.recoveryCodes.forEach { code -> Text(code, style = MaterialTheme.typography.bodySmall) }
}
if (!state.message.isNullOrBlank()) {
Text(state.message!!, color = MaterialTheme.colorScheme.primary)
}
if (!state.errorMessage.isNullOrBlank()) {
Text(state.errorMessage!!, color = MaterialTheme.colorScheme.error)
}
Spacer(modifier = Modifier.padding(top = 4.dp))
OutlinedButton(
onClick = onOpenProfile,
modifier = Modifier.fillMaxWidth(),
@@ -76,4 +280,3 @@ fun SettingsScreen(
}
}
}

View File

@@ -1,9 +1,12 @@
plugins {
id("com.android.application") version "8.7.2" apply false
id("com.android.library") version "8.7.2" apply false
id("org.jetbrains.kotlin.android") version "2.0.21" apply false
id("org.jetbrains.kotlin.jvm") version "2.0.21" apply false
id("org.jetbrains.kotlin.plugin.compose") version "2.0.21" apply false
id("com.google.dagger.hilt.android") version "2.52" apply false
id("org.jetbrains.kotlin.plugin.serialization") version "2.0.21" apply false
id("org.jetbrains.kotlin.kapt") version "2.0.21" apply false
id("com.google.gms.google-services") version "4.4.4" apply false
id("com.google.firebase.crashlytics") version "3.0.3" apply false
}

View File

@@ -0,0 +1,7 @@
plugins {
id("org.jetbrains.kotlin.jvm")
}
kotlin {
jvmToolchain(17)
}

View File

@@ -0,0 +1,7 @@
package ru.daemonlord.messenger.core.feature
data class FeatureFlags(
val accountManagementEnabled: Boolean,
val twoFactorEnabled: Boolean,
val mediaGalleryEnabled: Boolean,
)

View File

@@ -0,0 +1,8 @@
package ru.daemonlord.messenger.core.logging
interface AppLogger {
fun d(tag: String, message: String)
fun i(tag: String, message: String)
fun w(tag: String, message: String, throwable: Throwable? = null)
fun e(tag: String, message: String, throwable: Throwable? = null)
}

View File

@@ -16,3 +16,4 @@ dependencyResolutionManagement {
rootProject.name = "MessengerAndroid"
include(":app")
include(":core:common")

View File

@@ -2,12 +2,12 @@
## 1. Базовая архитектура
- [x] Kotlin + Jetpack Compose
- [ ] Модульность: `core`, `data`, `feature-*`, `app`
- [x] Модульность: `core`, `data`, `feature-*`, `app`
- [x] DI (Hilt/Koin)
- [x] MVI/MVVM + единый state/presenter слой
- [x] Coroutines + Flow + structured concurrency
- [ ] Логирование (Timber/Logcat policy)
- [ ] Crash reporting (Firebase Crashlytics/Sentry)
- [x] Логирование (Timber/Logcat policy)
- [x] Crash reporting (Firebase Crashlytics/Sentry)
## 2. Сеть и API
- [x] Retrofit/OkHttp + auth interceptor
@@ -15,7 +15,7 @@
- [x] Единая обработка ошибок API
- [x] Realtime WebSocket слой (reconnect/backoff)
- [x] Маппинг DTO -> Domain -> UI models
- [ ] Версионирование API и feature flags
- [x] Версионирование API и feature flags
## 3. Локальное хранение и sync
- [x] Room для чатов/сообщений/пользователей
@@ -27,18 +27,18 @@
## 4. Авторизация и аккаунт
- [x] Login/Register flow (email-first)
- [ ] Verify email экран/обработка deep link
- [ ] Reset password flow
- [ ] Sessions list + revoke one/all
- [ ] 2FA TOTP + recovery codes
- [x] Verify email экран/обработка deep link
- [x] Reset password flow
- [x] Sessions list + revoke one/all
- [x] 2FA TOTP + recovery codes
- [x] Logout с полным cleanup local state
## 5. Профиль и приватность
- [ ] Просмотр/редактирование профиля
- [ ] Avatar upload + crop 1:1
- [ ] Username/name/bio editing
- [ ] Privacy settings (PM/last seen/avatar/group invites)
- [ ] Blocked users management
- [x] Просмотр/редактирование профиля
- [x] Avatar upload + crop 1:1
- [x] Username/name/bio editing
- [x] Privacy settings (PM/last seen/avatar/group invites)
- [x] Blocked users management
## 6. Список чатов
- [x] Tabs/фильтры (all/private/group/channel/archive)
@@ -59,9 +59,9 @@
## 8. Медиа и вложения
- [x] Upload image/video/file/audio
- [ ] Галерея в сообщении (multi media)
- [x] Галерея в сообщении (multi media)
- [x] Media viewer (zoom/swipe/download)
- [ ] Единое контекстное меню для медиа
- [x] Единое контекстное меню для медиа
- [ ] Voice playback waveform + speed
- [x] Audio player UI (не как voice)
- [ ] Circle video playback (view-only при необходимости)
@@ -111,7 +111,7 @@
## 15. Качество
- [x] Unit tests (domain/data)
- [x] UI tests (Compose test)
- [ ] Integration tests для auth/chat/realtime
- [x] Integration tests для auth/chat/realtime
- [x] Performance baseline (startup, scroll, media)
- [ ] ANR/crash budget + monitoring