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

View File

@@ -28,6 +28,14 @@
android:host="chat.daemonlord.ru" android:host="chat.daemonlord.ru"
android:pathPrefix="/join" android:pathPrefix="/join"
android:scheme="https" /> 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> </intent-filter>
</activity> </activity>
<service <service

View File

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

View File

@@ -5,14 +5,20 @@ import coil.ImageLoader
import coil.ImageLoaderFactory import coil.ImageLoaderFactory
import coil.disk.DiskCache import coil.disk.DiskCache
import coil.memory.MemoryCache import coil.memory.MemoryCache
import com.google.firebase.crashlytics.FirebaseCrashlytics
import dagger.hilt.android.HiltAndroidApp import dagger.hilt.android.HiltAndroidApp
import ru.daemonlord.messenger.core.notifications.NotificationChannels import ru.daemonlord.messenger.core.notifications.NotificationChannels
import java.io.File import java.io.File
import timber.log.Timber
@HiltAndroidApp @HiltAndroidApp
class MessengerApplication : Application(), ImageLoaderFactory { class MessengerApplication : Application(), ImageLoaderFactory {
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()
if (BuildConfig.DEBUG) {
Timber.plant(Timber.DebugTree())
}
FirebaseCrashlytics.getInstance().setCrashlyticsCollectionEnabled(!BuildConfig.DEBUG)
NotificationChannels.ensureCreated(this) 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.AuthUserDto
import ru.daemonlord.messenger.data.auth.dto.AuthSessionDto 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.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.RefreshTokenRequestDto
import ru.daemonlord.messenger.data.auth.dto.TokenResponseDto 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.Headers
import retrofit2.http.Body import retrofit2.http.Body
import retrofit2.http.GET import retrofit2.http.GET
import retrofit2.http.POST import retrofit2.http.POST
import retrofit2.http.DELETE import retrofit2.http.DELETE
import retrofit2.http.Path import retrofit2.http.Path
import retrofit2.http.Query
interface AuthApiService { 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") @Headers("No-Auth: true")
@POST("/api/v1/auth/login") @POST("/api/v1/auth/login")
suspend fun login(@Body request: LoginRequestDto): TokenResponseDto suspend fun login(@Body request: LoginRequestDto): TokenResponseDto
@@ -24,6 +43,18 @@ interface AuthApiService {
@GET("/api/v1/auth/me") @GET("/api/v1/auth/me")
suspend fun me(): AuthUserDto 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") @GET("/api/v1/auth/sessions")
suspend fun sessions(): List<AuthSessionDto> suspend fun sessions(): List<AuthSessionDto>
@@ -32,4 +63,19 @@ interface AuthApiService {
@DELETE("/api/v1/auth/sessions") @DELETE("/api/v1/auth/sessions")
suspend fun revokeAllSessions() 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( data class LoginRequestDto(
val email: String, val email: String,
val password: String, val password: String,
@SerialName("otp_code")
val otpCode: String? = null,
@SerialName("recovery_code")
val recoveryCode: String? = null,
) )
@Serializable @Serializable
@@ -31,10 +35,21 @@ data class AuthUserDto(
val email: String, val email: String,
val name: String, val name: String,
val username: String, val username: String,
val bio: String? = null,
@SerialName("avatar_url") @SerialName("avatar_url")
val avatarUrl: String? = null, val avatarUrl: String? = null,
@SerialName("email_verified") @SerialName("email_verified")
val emailVerified: Boolean, 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 @Serializable
@@ -55,3 +70,65 @@ data class AuthSessionDto(
data class ErrorResponseDto( data class ErrorResponseDto(
val detail: String? = null, 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, email = email,
name = name, name = name,
username = username, username = username,
bio = bio,
avatarUrl = avatarUrl, avatarUrl = avatarUrl,
emailVerified = emailVerified, 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 retrofit2.Retrofit
import ru.daemonlord.messenger.BuildConfig import ru.daemonlord.messenger.BuildConfig
import ru.daemonlord.messenger.core.network.AuthHeaderInterceptor 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.core.network.TokenRefreshAuthenticator
import ru.daemonlord.messenger.data.auth.api.AuthApiService import ru.daemonlord.messenger.data.auth.api.AuthApiService
import ru.daemonlord.messenger.data.chat.api.ChatApiService import ru.daemonlord.messenger.data.chat.api.ChatApiService
import ru.daemonlord.messenger.data.media.api.MediaApiService import ru.daemonlord.messenger.data.media.api.MediaApiService
import ru.daemonlord.messenger.data.message.api.MessageApiService 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 com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
import javax.inject.Singleton import javax.inject.Singleton
@@ -87,11 +89,13 @@ object NetworkModule {
@Singleton @Singleton
fun provideApiClient( fun provideApiClient(
loggingInterceptor: HttpLoggingInterceptor, loggingInterceptor: HttpLoggingInterceptor,
apiVersionInterceptor: ApiVersionInterceptor,
authHeaderInterceptor: AuthHeaderInterceptor, authHeaderInterceptor: AuthHeaderInterceptor,
tokenRefreshAuthenticator: TokenRefreshAuthenticator, tokenRefreshAuthenticator: TokenRefreshAuthenticator,
): OkHttpClient { ): OkHttpClient {
return OkHttpClient.Builder() return OkHttpClient.Builder()
.addInterceptor(loggingInterceptor) .addInterceptor(loggingInterceptor)
.addInterceptor(apiVersionInterceptor)
.addInterceptor(authHeaderInterceptor) .addInterceptor(authHeaderInterceptor)
.authenticator(tokenRefreshAuthenticator) .authenticator(tokenRefreshAuthenticator)
.connectTimeout(20, TimeUnit.SECONDS) .connectTimeout(20, TimeUnit.SECONDS)
@@ -137,4 +141,10 @@ object NetworkModule {
fun provideMediaApiService(retrofit: Retrofit): MediaApiService { fun provideMediaApiService(retrofit: Retrofit): MediaApiService {
return retrofit.create(MediaApiService::class.java) 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.media.repository.NetworkMediaRepository
import ru.daemonlord.messenger.data.message.repository.NetworkMessageRepository import ru.daemonlord.messenger.data.message.repository.NetworkMessageRepository
import ru.daemonlord.messenger.data.notifications.repository.DataStoreNotificationSettingsRepository 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.AuthRepository
import ru.daemonlord.messenger.domain.auth.repository.SessionCleanupRepository import ru.daemonlord.messenger.domain.auth.repository.SessionCleanupRepository
import ru.daemonlord.messenger.domain.chat.repository.ChatRepository import ru.daemonlord.messenger.domain.chat.repository.ChatRepository
@@ -57,4 +59,10 @@ abstract class RepositoryModule {
abstract fun bindNotificationSettingsRepository( abstract fun bindNotificationSettingsRepository(
repository: DataStoreNotificationSettingsRepository, repository: DataStoreNotificationSettingsRepository,
): NotificationSettingsRepository ): 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 email: String,
val name: String, val name: String,
val username: String, val username: String,
val bio: String?,
val avatarUrl: String?, val avatarUrl: String?,
val emailVerified: Boolean, 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, onEmailChanged: (String) -> Unit,
onPasswordChanged: (String) -> Unit, onPasswordChanged: (String) -> Unit,
onLoginClick: () -> Unit, onLoginClick: () -> Unit,
onOpenVerifyEmail: () -> Unit,
onOpenResetPassword: () -> Unit,
) { ) {
Column( Column(
modifier = Modifier modifier = Modifier
@@ -86,14 +88,19 @@ fun LoginScreen(
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.padding(top = 12.dp), modifier = Modifier.padding(top = 12.dp),
) )
} else { }
TextButton( TextButton(
onClick = {}, onClick = onOpenVerifyEmail,
enabled = false, enabled = !state.isLoading,
modifier = Modifier.padding(top = 8.dp), modifier = Modifier.padding(top = 8.dp),
) { ) {
Text(text = "Use your existing backend account") 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.unit.dp
import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.safeDrawing import androidx.compose.foundation.layout.safeDrawing
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import coil.compose.AsyncImage import coil.compose.AsyncImage
@@ -624,6 +626,8 @@ private fun MessageBubble(
onClick: () -> Unit, onClick: () -> Unit,
onLongPress: () -> Unit, onLongPress: () -> Unit,
) { ) {
val clipboard = LocalClipboardManager.current
var contextAttachmentUrl by remember { mutableStateOf<String?>(null) }
val isOutgoing = message.isOutgoing val isOutgoing = message.isOutgoing
val bubbleShape = if (isOutgoing) { val bubbleShape = if (isOutgoing) {
RoundedCornerShape(topStart = 16.dp, topEnd = 16.dp, bottomStart = 16.dp, bottomEnd = 6.dp) RoundedCornerShape(topStart = 16.dp, topEnd = 16.dp, bottomStart = 16.dp, bottomEnd = 6.dp)
@@ -733,41 +737,120 @@ private fun MessageBubble(
} }
if (message.attachments.isNotEmpty()) { if (message.attachments.isNotEmpty()) {
Spacer(modifier = Modifier.height(4.dp)) Spacer(modifier = Modifier.height(4.dp))
val showAsFileList = message.attachments.size > 1 val imageAttachments = message.attachments.filter { it.fileType.lowercase().startsWith("image/") }
message.attachments.forEach { attachment -> val nonImageAttachments = message.attachments.filterNot { it.fileType.lowercase().startsWith("image/") }
val fileType = attachment.fileType.lowercase()
when { if (imageAttachments.isNotEmpty()) {
fileType.startsWith("image/") -> { if (imageAttachments.size == 1) {
AsyncImage( val single = imageAttachments.first()
model = attachment.fileUrl, AsyncImage(
contentDescription = "Image", model = single.fileUrl,
modifier = Modifier contentDescription = "Image",
.fillMaxWidth() modifier = Modifier
.height(160.dp) .fillMaxWidth()
.clickable { onAttachmentImageClick(attachment.fileUrl) }, .height(180.dp)
contentScale = ContentScale.Crop, .combinedClickable(
) onClick = { onAttachmentImageClick(single.fileUrl) },
} onLongClick = { contextAttachmentUrl = single.fileUrl },
fileType.startsWith("video/") -> { ),
VideoAttachmentCard( contentScale = ContentScale.Crop,
url = attachment.fileUrl, )
fileType = attachment.fileType, } else {
) imageAttachments.chunked(2).forEach { rowItems ->
} Row(
fileType.startsWith("audio/") -> { modifier = Modifier.fillMaxWidth(),
AudioAttachmentPlayer(url = attachment.fileUrl) horizontalArrangement = Arrangement.spacedBy(4.dp),
} ) {
else -> { rowItems.forEach { image ->
FileAttachmentRow( AsyncImage(
fileUrl = attachment.fileUrl, model = image.fileUrl,
fileType = attachment.fileType, contentDescription = "Image",
fileSize = attachment.fileSize, modifier = Modifier
compact = showAsFileList, .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)) 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( Row(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),

View File

@@ -31,6 +31,8 @@ import androidx.navigation.compose.navigation
import androidx.navigation.compose.rememberNavController import androidx.navigation.compose.rememberNavController
import ru.daemonlord.messenger.ui.auth.AuthViewModel import ru.daemonlord.messenger.ui.auth.AuthViewModel
import ru.daemonlord.messenger.ui.auth.LoginScreen 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.chat.ChatRoute
import ru.daemonlord.messenger.ui.chats.ChatListRoute import ru.daemonlord.messenger.ui.chats.ChatListRoute
import ru.daemonlord.messenger.ui.profile.ProfileRoute import ru.daemonlord.messenger.ui.profile.ProfileRoute
@@ -39,6 +41,8 @@ import ru.daemonlord.messenger.ui.settings.SettingsRoute
private object Routes { private object Routes {
const val AuthGraph = "auth_graph" const val AuthGraph = "auth_graph"
const val Login = "login" const val Login = "login"
const val VerifyEmail = "verify_email"
const val ResetPassword = "reset_password"
const val Chats = "chats" const val Chats = "chats"
const val Settings = "settings" const val Settings = "settings"
const val Profile = "profile" const val Profile = "profile"
@@ -51,6 +55,10 @@ fun MessengerNavHost(
viewModel: AuthViewModel = hiltViewModel(), viewModel: AuthViewModel = hiltViewModel(),
inviteToken: String? = null, inviteToken: String? = null,
onInviteTokenConsumed: () -> Unit = {}, onInviteTokenConsumed: () -> Unit = {},
verifyEmailToken: String? = null,
onVerifyEmailTokenConsumed: () -> Unit = {},
resetPasswordToken: String? = null,
onResetPasswordTokenConsumed: () -> Unit = {},
notificationChatId: Long? = null, notificationChatId: Long? = null,
notificationMessageId: Long? = null, notificationMessageId: Long? = null,
onNotificationConsumed: () -> Unit = {}, 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) { if (uiState.isCheckingSession) {
return@LaunchedEffect 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 (uiState.isAuthenticated) {
if (notificationChatId != null) { if (notificationChatId != null) {
navController.navigate("${Routes.Chat}/$notificationChatId") { navController.navigate("${Routes.Chat}/$notificationChatId") {
@@ -120,11 +144,45 @@ fun MessengerNavHost(
onEmailChanged = viewModel::onEmailChanged, onEmailChanged = viewModel::onEmailChanged,
onPasswordChanged = viewModel::onPasswordChanged, onPasswordChanged = viewModel::onPasswordChanged,
onLoginClick = viewModel::login, 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) { composable(route = Routes.Chats) {
ChatListRoute( ChatListRoute(
inviteToken = inviteToken, inviteToken = inviteToken,

View File

@@ -1,28 +1,53 @@
package ru.daemonlord.messenger.ui.profile 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.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawing import androidx.compose.foundation.layout.safeDrawing
import androidx.compose.foundation.layout.windowInsetsPadding 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.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable 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.Modifier
import androidx.compose.ui.unit.dp 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 @Composable
fun ProfileRoute( fun ProfileRoute(
onBackToChats: () -> Unit, onBackToChats: () -> Unit,
onOpenSettings: () -> Unit, onOpenSettings: () -> Unit,
viewModel: AccountViewModel = hiltViewModel(),
) { ) {
ProfileScreen( ProfileScreen(
onBackToChats = onBackToChats, onBackToChats = onBackToChats,
onOpenSettings = onOpenSettings, onOpenSettings = onOpenSettings,
viewModel = viewModel,
) )
} }
@@ -30,7 +55,33 @@ fun ProfileRoute(
fun ProfileScreen( fun ProfileScreen(
onBackToChats: () -> Unit, onBackToChats: () -> Unit,
onOpenSettings: () -> 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( Column(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
@@ -42,11 +93,76 @@ fun ProfileScreen(
text = "Profile", text = "Profile",
style = MaterialTheme.typography.headlineSmall, style = MaterialTheme.typography.headlineSmall,
) )
Text( if (!avatarUrl.isBlank()) {
text = "Profile editing screen placeholder. Telegram-like account editor will be implemented in a dedicated step.", AsyncImage(
style = MaterialTheme.typography.bodyMedium, model = avatarUrl,
color = MaterialTheme.colorScheme.onSurfaceVariant, 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( OutlinedButton(
onClick = onOpenSettings, onClick = onOpenSettings,
modifier = Modifier.fillMaxWidth(), 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.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawing import androidx.compose.foundation.layout.safeDrawing
import androidx.compose.foundation.layout.windowInsetsPadding 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.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable 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.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp 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 @Composable
fun SettingsRoute( fun SettingsRoute(
onBackToChats: () -> Unit, onBackToChats: () -> Unit,
onOpenProfile: () -> Unit, onOpenProfile: () -> Unit,
onLogout: () -> Unit, onLogout: () -> Unit,
viewModel: AccountViewModel = hiltViewModel(),
) { ) {
SettingsScreen( SettingsScreen(
onBackToChats = onBackToChats, onBackToChats = onBackToChats,
onOpenProfile = onOpenProfile, onOpenProfile = onOpenProfile,
onLogout = onLogout, onLogout = onLogout,
viewModel = viewModel,
) )
} }
@@ -36,11 +51,29 @@ fun SettingsScreen(
onBackToChats: () -> Unit, onBackToChats: () -> Unit,
onOpenProfile: () -> Unit, onOpenProfile: () -> Unit,
onLogout: () -> 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( Column(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.windowInsetsPadding(WindowInsets.safeDrawing) .windowInsetsPadding(WindowInsets.safeDrawing)
.verticalScroll(scrollState)
.padding(16.dp), .padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp), verticalArrangement = Arrangement.spacedBy(12.dp),
) { ) {
@@ -48,11 +81,182 @@ fun SettingsScreen(
text = "Settings", text = "Settings",
style = MaterialTheme.typography.headlineSmall, style = MaterialTheme.typography.headlineSmall,
) )
Text( if (state.isLoading) {
text = "Core account actions are here. More Telegram-like settings will be added in next iterations.", CircularProgressIndicator()
style = MaterialTheme.typography.bodyMedium, }
color = MaterialTheme.colorScheme.onSurfaceVariant,
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( OutlinedButton(
onClick = onOpenProfile, onClick = onOpenProfile,
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
@@ -76,4 +280,3 @@ fun SettingsScreen(
} }
} }
} }

View File

@@ -1,9 +1,12 @@
plugins { plugins {
id("com.android.application") version "8.7.2" apply false 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.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("org.jetbrains.kotlin.plugin.compose") version "2.0.21" apply false
id("com.google.dagger.hilt.android") version "2.52" 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.plugin.serialization") version "2.0.21" apply false
id("org.jetbrains.kotlin.kapt") 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.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" rootProject.name = "MessengerAndroid"
include(":app") include(":app")
include(":core:common")

View File

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