Compare commits
5 Commits
ef5f866bd0
...
f708854bb2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f708854bb2 | ||
|
|
5368515112 | ||
|
|
9ad8372d45 | ||
|
|
91d712c702 | ||
|
|
65e74cffdb |
@@ -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).
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -2,17 +2,36 @@ package ru.daemonlord.messenger.data.auth.api
|
||||
|
||||
import ru.daemonlord.messenger.data.auth.dto.AuthUserDto
|
||||
import ru.daemonlord.messenger.data.auth.dto.AuthSessionDto
|
||||
import ru.daemonlord.messenger.data.auth.dto.CheckEmailStatusDto
|
||||
import ru.daemonlord.messenger.data.auth.dto.LoginRequestDto
|
||||
import ru.daemonlord.messenger.data.auth.dto.MessageResponseDto
|
||||
import ru.daemonlord.messenger.data.auth.dto.RegisterRequestDto
|
||||
import ru.daemonlord.messenger.data.auth.dto.RequestPasswordResetDto
|
||||
import ru.daemonlord.messenger.data.auth.dto.ResetPasswordRequestDto
|
||||
import ru.daemonlord.messenger.data.auth.dto.RefreshTokenRequestDto
|
||||
import ru.daemonlord.messenger.data.auth.dto.TokenResponseDto
|
||||
import ru.daemonlord.messenger.data.auth.dto.TwoFactorCodeRequestDto
|
||||
import ru.daemonlord.messenger.data.auth.dto.TwoFactorRecoveryCodesDto
|
||||
import ru.daemonlord.messenger.data.auth.dto.TwoFactorRecoveryStatusDto
|
||||
import ru.daemonlord.messenger.data.auth.dto.TwoFactorSetupDto
|
||||
import ru.daemonlord.messenger.data.auth.dto.VerifyEmailRequestDto
|
||||
import retrofit2.http.Headers
|
||||
import retrofit2.http.Body
|
||||
import retrofit2.http.GET
|
||||
import retrofit2.http.POST
|
||||
import retrofit2.http.DELETE
|
||||
import retrofit2.http.Path
|
||||
import retrofit2.http.Query
|
||||
|
||||
interface AuthApiService {
|
||||
@Headers("No-Auth: true")
|
||||
@GET("/api/v1/auth/check-email")
|
||||
suspend fun checkEmailStatus(@Query("email") email: String): CheckEmailStatusDto
|
||||
|
||||
@Headers("No-Auth: true")
|
||||
@POST("/api/v1/auth/register")
|
||||
suspend fun register(@Body request: RegisterRequestDto): MessageResponseDto
|
||||
|
||||
@Headers("No-Auth: true")
|
||||
@POST("/api/v1/auth/login")
|
||||
suspend fun login(@Body request: LoginRequestDto): TokenResponseDto
|
||||
@@ -24,6 +43,18 @@ interface AuthApiService {
|
||||
@GET("/api/v1/auth/me")
|
||||
suspend fun me(): AuthUserDto
|
||||
|
||||
@Headers("No-Auth: true")
|
||||
@POST("/api/v1/auth/verify-email")
|
||||
suspend fun verifyEmail(@Body request: VerifyEmailRequestDto): MessageResponseDto
|
||||
|
||||
@Headers("No-Auth: true")
|
||||
@POST("/api/v1/auth/request-password-reset")
|
||||
suspend fun requestPasswordReset(@Body request: RequestPasswordResetDto): MessageResponseDto
|
||||
|
||||
@Headers("No-Auth: true")
|
||||
@POST("/api/v1/auth/reset-password")
|
||||
suspend fun resetPassword(@Body request: ResetPasswordRequestDto): MessageResponseDto
|
||||
|
||||
@GET("/api/v1/auth/sessions")
|
||||
suspend fun sessions(): List<AuthSessionDto>
|
||||
|
||||
@@ -32,4 +63,19 @@ interface AuthApiService {
|
||||
|
||||
@DELETE("/api/v1/auth/sessions")
|
||||
suspend fun revokeAllSessions()
|
||||
|
||||
@POST("/api/v1/auth/2fa/setup")
|
||||
suspend fun setupTwoFactor(): TwoFactorSetupDto
|
||||
|
||||
@POST("/api/v1/auth/2fa/enable")
|
||||
suspend fun enableTwoFactor(@Body request: TwoFactorCodeRequestDto): MessageResponseDto
|
||||
|
||||
@POST("/api/v1/auth/2fa/disable")
|
||||
suspend fun disableTwoFactor(@Body request: TwoFactorCodeRequestDto): MessageResponseDto
|
||||
|
||||
@GET("/api/v1/auth/2fa/recovery-codes/status")
|
||||
suspend fun twoFactorRecoveryStatus(): TwoFactorRecoveryStatusDto
|
||||
|
||||
@POST("/api/v1/auth/2fa/recovery-codes/regenerate")
|
||||
suspend fun regenerateTwoFactorRecoveryCodes(@Body request: TwoFactorCodeRequestDto): TwoFactorRecoveryCodesDto
|
||||
}
|
||||
|
||||
@@ -7,6 +7,10 @@ import kotlinx.serialization.Serializable
|
||||
data class LoginRequestDto(
|
||||
val email: String,
|
||||
val password: String,
|
||||
@SerialName("otp_code")
|
||||
val otpCode: String? = null,
|
||||
@SerialName("recovery_code")
|
||||
val recoveryCode: String? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
@@ -31,10 +35,21 @@ data class AuthUserDto(
|
||||
val email: String,
|
||||
val name: String,
|
||||
val username: String,
|
||||
val bio: String? = null,
|
||||
@SerialName("avatar_url")
|
||||
val avatarUrl: String? = null,
|
||||
@SerialName("email_verified")
|
||||
val emailVerified: Boolean,
|
||||
@SerialName("twofa_enabled")
|
||||
val twofaEnabled: Boolean = false,
|
||||
@SerialName("privacy_private_messages")
|
||||
val privacyPrivateMessages: String? = null,
|
||||
@SerialName("privacy_last_seen")
|
||||
val privacyLastSeen: String? = null,
|
||||
@SerialName("privacy_avatar")
|
||||
val privacyAvatar: String? = null,
|
||||
@SerialName("privacy_group_invites")
|
||||
val privacyGroupInvites: String? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
@@ -55,3 +70,65 @@ data class AuthSessionDto(
|
||||
data class ErrorResponseDto(
|
||||
val detail: String? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class MessageResponseDto(
|
||||
val message: String,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class VerifyEmailRequestDto(
|
||||
val token: String,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class RequestPasswordResetDto(
|
||||
val email: String,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ResetPasswordRequestDto(
|
||||
val token: String,
|
||||
val password: String,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class CheckEmailStatusDto(
|
||||
val email: String,
|
||||
val registered: Boolean,
|
||||
@SerialName("email_verified")
|
||||
val emailVerified: Boolean,
|
||||
@SerialName("twofa_enabled")
|
||||
val twofaEnabled: Boolean,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class RegisterRequestDto(
|
||||
val email: String,
|
||||
val name: String,
|
||||
val username: String,
|
||||
val password: String,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class TwoFactorSetupDto(
|
||||
val secret: String,
|
||||
@SerialName("otpauth_url")
|
||||
val otpauthUrl: String,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class TwoFactorCodeRequestDto(
|
||||
val code: String,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class TwoFactorRecoveryStatusDto(
|
||||
@SerialName("remaining_codes")
|
||||
val remainingCodes: Int,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class TwoFactorRecoveryCodesDto(
|
||||
val codes: List<String>,
|
||||
)
|
||||
|
||||
@@ -134,8 +134,14 @@ class NetworkAuthRepository @Inject constructor(
|
||||
email = email,
|
||||
name = name,
|
||||
username = username,
|
||||
bio = bio,
|
||||
avatarUrl = avatarUrl,
|
||||
emailVerified = emailVerified,
|
||||
twofaEnabled = twofaEnabled,
|
||||
privacyPrivateMessages = privacyPrivateMessages ?: "everyone",
|
||||
privacyLastSeen = privacyLastSeen ?: "everyone",
|
||||
privacyAvatar = privacyAvatar ?: "everyone",
|
||||
privacyGroupInvites = privacyGroupInvites ?: "everyone",
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
package ru.daemonlord.messenger.data.user.api
|
||||
|
||||
import retrofit2.http.Body
|
||||
import retrofit2.http.DELETE
|
||||
import retrofit2.http.GET
|
||||
import retrofit2.http.POST
|
||||
import retrofit2.http.PUT
|
||||
import retrofit2.http.Path
|
||||
import ru.daemonlord.messenger.data.auth.dto.AuthUserDto
|
||||
import ru.daemonlord.messenger.data.user.dto.UserProfileUpdateRequestDto
|
||||
import ru.daemonlord.messenger.data.user.dto.UserSearchDto
|
||||
|
||||
interface UserApiService {
|
||||
@PUT("/api/v1/users/profile")
|
||||
suspend fun updateProfile(@Body request: UserProfileUpdateRequestDto): AuthUserDto
|
||||
|
||||
@GET("/api/v1/users/blocked")
|
||||
suspend fun listBlockedUsers(): List<UserSearchDto>
|
||||
|
||||
@POST("/api/v1/users/{user_id}/block")
|
||||
suspend fun blockUser(@Path("user_id") userId: Long)
|
||||
|
||||
@DELETE("/api/v1/users/{user_id}/block")
|
||||
suspend fun unblockUser(@Path("user_id") userId: Long)
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package ru.daemonlord.messenger.data.user.dto
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class UserProfileUpdateRequestDto(
|
||||
val name: String? = null,
|
||||
val username: String? = null,
|
||||
val bio: String? = null,
|
||||
@SerialName("avatar_url")
|
||||
val avatarUrl: String? = null,
|
||||
@SerialName("allow_private_messages")
|
||||
val allowPrivateMessages: Boolean? = null,
|
||||
@SerialName("privacy_private_messages")
|
||||
val privacyPrivateMessages: String? = null,
|
||||
@SerialName("privacy_last_seen")
|
||||
val privacyLastSeen: String? = null,
|
||||
@SerialName("privacy_avatar")
|
||||
val privacyAvatar: String? = null,
|
||||
@SerialName("privacy_group_invites")
|
||||
val privacyGroupInvites: String? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class UserSearchDto(
|
||||
val id: Long,
|
||||
val email: String? = null,
|
||||
val name: String? = null,
|
||||
val username: String? = null,
|
||||
@SerialName("avatar_url")
|
||||
val avatarUrl: String? = null,
|
||||
)
|
||||
@@ -0,0 +1,289 @@
|
||||
package ru.daemonlord.messenger.data.user.repository
|
||||
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.withContext
|
||||
import okhttp3.MediaType.Companion.toMediaTypeOrNull
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import ru.daemonlord.messenger.data.auth.api.AuthApiService
|
||||
import ru.daemonlord.messenger.data.auth.dto.AuthSessionDto
|
||||
import ru.daemonlord.messenger.data.auth.dto.AuthUserDto
|
||||
import ru.daemonlord.messenger.data.auth.dto.RequestPasswordResetDto
|
||||
import ru.daemonlord.messenger.data.auth.dto.ResetPasswordRequestDto
|
||||
import ru.daemonlord.messenger.data.auth.dto.TwoFactorCodeRequestDto
|
||||
import ru.daemonlord.messenger.data.auth.dto.VerifyEmailRequestDto
|
||||
import ru.daemonlord.messenger.data.common.toAppError
|
||||
import ru.daemonlord.messenger.data.media.api.MediaApiService
|
||||
import ru.daemonlord.messenger.data.media.dto.UploadUrlRequestDto
|
||||
import ru.daemonlord.messenger.di.RefreshClient
|
||||
import ru.daemonlord.messenger.data.user.api.UserApiService
|
||||
import ru.daemonlord.messenger.data.user.dto.UserProfileUpdateRequestDto
|
||||
import ru.daemonlord.messenger.data.user.dto.UserSearchDto
|
||||
import ru.daemonlord.messenger.di.IoDispatcher
|
||||
import ru.daemonlord.messenger.domain.account.model.UserSearchItem
|
||||
import ru.daemonlord.messenger.domain.account.repository.AccountRepository
|
||||
import ru.daemonlord.messenger.domain.auth.model.AuthSession
|
||||
import ru.daemonlord.messenger.domain.auth.model.AuthUser
|
||||
import ru.daemonlord.messenger.domain.common.AppError
|
||||
import ru.daemonlord.messenger.domain.common.AppResult
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
class NetworkAccountRepository @Inject constructor(
|
||||
private val authApiService: AuthApiService,
|
||||
private val userApiService: UserApiService,
|
||||
private val mediaApiService: MediaApiService,
|
||||
@RefreshClient private val uploadClient: OkHttpClient,
|
||||
@IoDispatcher private val ioDispatcher: CoroutineDispatcher,
|
||||
) : AccountRepository {
|
||||
|
||||
override suspend fun getMe(): AppResult<AuthUser> = withContext(ioDispatcher) {
|
||||
try {
|
||||
AppResult.Success(authApiService.me().toDomain())
|
||||
} catch (error: Throwable) {
|
||||
AppResult.Error(error.toAppError())
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun updateProfile(
|
||||
name: String,
|
||||
username: String,
|
||||
bio: String?,
|
||||
avatarUrl: String?,
|
||||
): AppResult<AuthUser> = withContext(ioDispatcher) {
|
||||
try {
|
||||
val updated = userApiService.updateProfile(
|
||||
request = UserProfileUpdateRequestDto(
|
||||
name = name.trim().ifBlank { null },
|
||||
username = username.trim().ifBlank { null },
|
||||
bio = bio?.trim(),
|
||||
avatarUrl = avatarUrl?.trim(),
|
||||
)
|
||||
)
|
||||
AppResult.Success(updated.toDomain())
|
||||
} catch (error: Throwable) {
|
||||
AppResult.Error(error.toAppError())
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun uploadAvatar(
|
||||
fileName: String,
|
||||
mimeType: String,
|
||||
bytes: ByteArray,
|
||||
): AppResult<String> = withContext(ioDispatcher) {
|
||||
try {
|
||||
val uploadInfo = mediaApiService.requestUploadUrl(
|
||||
UploadUrlRequestDto(
|
||||
fileName = fileName,
|
||||
fileType = mimeType,
|
||||
fileSize = bytes.size.toLong(),
|
||||
)
|
||||
)
|
||||
val body = bytes.toRequestBody(mimeType.toMediaTypeOrNull())
|
||||
val uploadRequestBuilder = Request.Builder()
|
||||
.url(uploadInfo.uploadUrl)
|
||||
.put(body)
|
||||
uploadInfo.requiredHeaders.forEach { (key, value) ->
|
||||
uploadRequestBuilder.header(key, value)
|
||||
}
|
||||
uploadClient.newCall(uploadRequestBuilder.build()).execute().use { response ->
|
||||
if (!response.isSuccessful) {
|
||||
return@withContext AppResult.Error(AppError.Server("Upload failed: HTTP ${response.code}"))
|
||||
}
|
||||
}
|
||||
AppResult.Success(uploadInfo.fileUrl)
|
||||
} catch (error: Throwable) {
|
||||
AppResult.Error(error.toAppError())
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun updatePrivacy(
|
||||
privateMessages: String,
|
||||
lastSeen: String,
|
||||
avatar: String,
|
||||
groupInvites: String,
|
||||
): AppResult<AuthUser> = withContext(ioDispatcher) {
|
||||
try {
|
||||
val allowPrivateMessages = privateMessages != "nobody"
|
||||
val updated = userApiService.updateProfile(
|
||||
request = UserProfileUpdateRequestDto(
|
||||
allowPrivateMessages = allowPrivateMessages,
|
||||
privacyPrivateMessages = privateMessages,
|
||||
privacyLastSeen = lastSeen,
|
||||
privacyAvatar = avatar,
|
||||
privacyGroupInvites = groupInvites,
|
||||
)
|
||||
)
|
||||
AppResult.Success(updated.toDomain())
|
||||
} catch (error: Throwable) {
|
||||
AppResult.Error(error.toAppError())
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun listBlockedUsers(): AppResult<List<UserSearchItem>> = withContext(ioDispatcher) {
|
||||
try {
|
||||
AppResult.Success(userApiService.listBlockedUsers().map { it.toDomain() })
|
||||
} catch (error: Throwable) {
|
||||
AppResult.Error(error.toAppError())
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun blockUser(userId: Long): AppResult<Unit> = withContext(ioDispatcher) {
|
||||
try {
|
||||
userApiService.blockUser(userId)
|
||||
AppResult.Success(Unit)
|
||||
} catch (error: Throwable) {
|
||||
AppResult.Error(error.toAppError())
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun unblockUser(userId: Long): AppResult<Unit> = withContext(ioDispatcher) {
|
||||
try {
|
||||
userApiService.unblockUser(userId)
|
||||
AppResult.Success(Unit)
|
||||
} catch (error: Throwable) {
|
||||
AppResult.Error(error.toAppError())
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun listSessions(): AppResult<List<AuthSession>> = withContext(ioDispatcher) {
|
||||
try {
|
||||
AppResult.Success(authApiService.sessions().map { it.toDomain() })
|
||||
} catch (error: Throwable) {
|
||||
AppResult.Error(error.toAppError())
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun revokeSession(jti: String): AppResult<Unit> = withContext(ioDispatcher) {
|
||||
try {
|
||||
authApiService.revokeSession(jti)
|
||||
AppResult.Success(Unit)
|
||||
} catch (error: Throwable) {
|
||||
AppResult.Error(error.toAppError())
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun revokeAllSessions(): AppResult<Unit> = withContext(ioDispatcher) {
|
||||
try {
|
||||
authApiService.revokeAllSessions()
|
||||
AppResult.Success(Unit)
|
||||
} catch (error: Throwable) {
|
||||
AppResult.Error(error.toAppError())
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun verifyEmail(token: String): AppResult<String> = withContext(ioDispatcher) {
|
||||
try {
|
||||
val result = authApiService.verifyEmail(VerifyEmailRequestDto(token))
|
||||
AppResult.Success(result.message)
|
||||
} catch (error: Throwable) {
|
||||
AppResult.Error(error.toAppError())
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun requestPasswordReset(email: String): AppResult<String> = withContext(ioDispatcher) {
|
||||
try {
|
||||
val result = authApiService.requestPasswordReset(RequestPasswordResetDto(email = email.trim()))
|
||||
AppResult.Success(result.message)
|
||||
} catch (error: Throwable) {
|
||||
AppResult.Error(error.toAppError())
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun resetPassword(token: String, password: String): AppResult<String> = withContext(ioDispatcher) {
|
||||
try {
|
||||
val result = authApiService.resetPassword(
|
||||
ResetPasswordRequestDto(
|
||||
token = token.trim(),
|
||||
password = password,
|
||||
)
|
||||
)
|
||||
AppResult.Success(result.message)
|
||||
} catch (error: Throwable) {
|
||||
AppResult.Error(error.toAppError())
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun setupTwoFactor(): AppResult<Pair<String, String>> = withContext(ioDispatcher) {
|
||||
try {
|
||||
val response = authApiService.setupTwoFactor()
|
||||
AppResult.Success(response.secret to response.otpauthUrl)
|
||||
} catch (error: Throwable) {
|
||||
AppResult.Error(error.toAppError())
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun enableTwoFactor(code: String): AppResult<String> = withContext(ioDispatcher) {
|
||||
try {
|
||||
val response = authApiService.enableTwoFactor(TwoFactorCodeRequestDto(code.trim()))
|
||||
AppResult.Success(response.message)
|
||||
} catch (error: Throwable) {
|
||||
AppResult.Error(error.toAppError())
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun disableTwoFactor(code: String): AppResult<String> = withContext(ioDispatcher) {
|
||||
try {
|
||||
val response = authApiService.disableTwoFactor(TwoFactorCodeRequestDto(code.trim()))
|
||||
AppResult.Success(response.message)
|
||||
} catch (error: Throwable) {
|
||||
AppResult.Error(error.toAppError())
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun twoFactorRecoveryStatus(): AppResult<Int> = withContext(ioDispatcher) {
|
||||
try {
|
||||
AppResult.Success(authApiService.twoFactorRecoveryStatus().remainingCodes)
|
||||
} catch (error: Throwable) {
|
||||
AppResult.Error(error.toAppError())
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun regenerateTwoFactorRecoveryCodes(code: String): AppResult<List<String>> = withContext(ioDispatcher) {
|
||||
try {
|
||||
val response = authApiService.regenerateTwoFactorRecoveryCodes(TwoFactorCodeRequestDto(code.trim()))
|
||||
AppResult.Success(response.codes)
|
||||
} catch (error: Throwable) {
|
||||
AppResult.Error(error.toAppError())
|
||||
}
|
||||
}
|
||||
|
||||
private fun AuthUserDto.toDomain(): AuthUser {
|
||||
return AuthUser(
|
||||
id = id,
|
||||
email = email,
|
||||
name = name,
|
||||
username = username,
|
||||
bio = bio,
|
||||
avatarUrl = avatarUrl,
|
||||
emailVerified = emailVerified,
|
||||
twofaEnabled = twofaEnabled,
|
||||
privacyPrivateMessages = privacyPrivateMessages ?: "everyone",
|
||||
privacyLastSeen = privacyLastSeen ?: "everyone",
|
||||
privacyAvatar = privacyAvatar ?: "everyone",
|
||||
privacyGroupInvites = privacyGroupInvites ?: "everyone",
|
||||
)
|
||||
}
|
||||
|
||||
private fun AuthSessionDto.toDomain(): AuthSession {
|
||||
return AuthSession(
|
||||
jti = jti,
|
||||
createdAt = createdAt,
|
||||
ipAddress = ipAddress,
|
||||
userAgent = userAgent,
|
||||
current = current,
|
||||
tokenType = tokenType,
|
||||
)
|
||||
}
|
||||
|
||||
private fun UserSearchDto.toDomain(): UserSearchItem {
|
||||
return UserSearchItem(
|
||||
id = id,
|
||||
name = name?.trim().takeUnless { it.isNullOrBlank() } ?: username?.trim().takeUnless { it.isNullOrBlank() } ?: "User #$id",
|
||||
username = username,
|
||||
avatarUrl = avatarUrl,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,8 @@ import ru.daemonlord.messenger.data.chat.repository.NetworkChatRepository
|
||||
import ru.daemonlord.messenger.data.media.repository.NetworkMediaRepository
|
||||
import ru.daemonlord.messenger.data.message.repository.NetworkMessageRepository
|
||||
import ru.daemonlord.messenger.data.notifications.repository.DataStoreNotificationSettingsRepository
|
||||
import ru.daemonlord.messenger.data.user.repository.NetworkAccountRepository
|
||||
import ru.daemonlord.messenger.domain.account.repository.AccountRepository
|
||||
import ru.daemonlord.messenger.domain.auth.repository.AuthRepository
|
||||
import ru.daemonlord.messenger.domain.auth.repository.SessionCleanupRepository
|
||||
import ru.daemonlord.messenger.domain.chat.repository.ChatRepository
|
||||
@@ -57,4 +59,10 @@ abstract class RepositoryModule {
|
||||
abstract fun bindNotificationSettingsRepository(
|
||||
repository: DataStoreNotificationSettingsRepository,
|
||||
): NotificationSettingsRepository
|
||||
|
||||
@Binds
|
||||
@Singleton
|
||||
abstract fun bindAccountRepository(
|
||||
repository: NetworkAccountRepository,
|
||||
): AccountRepository
|
||||
}
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
package ru.daemonlord.messenger.domain.account.model
|
||||
|
||||
data class UserSearchItem(
|
||||
val id: Long,
|
||||
val name: String,
|
||||
val username: String?,
|
||||
val avatarUrl: String?,
|
||||
)
|
||||
@@ -0,0 +1,41 @@
|
||||
package ru.daemonlord.messenger.domain.account.repository
|
||||
|
||||
import ru.daemonlord.messenger.domain.account.model.UserSearchItem
|
||||
import ru.daemonlord.messenger.domain.auth.model.AuthSession
|
||||
import ru.daemonlord.messenger.domain.auth.model.AuthUser
|
||||
import ru.daemonlord.messenger.domain.common.AppResult
|
||||
|
||||
interface AccountRepository {
|
||||
suspend fun getMe(): AppResult<AuthUser>
|
||||
suspend fun updateProfile(
|
||||
name: String,
|
||||
username: String,
|
||||
bio: String?,
|
||||
avatarUrl: String?,
|
||||
): AppResult<AuthUser>
|
||||
suspend fun uploadAvatar(
|
||||
fileName: String,
|
||||
mimeType: String,
|
||||
bytes: ByteArray,
|
||||
): AppResult<String>
|
||||
suspend fun updatePrivacy(
|
||||
privateMessages: String,
|
||||
lastSeen: String,
|
||||
avatar: String,
|
||||
groupInvites: String,
|
||||
): AppResult<AuthUser>
|
||||
suspend fun listBlockedUsers(): AppResult<List<UserSearchItem>>
|
||||
suspend fun blockUser(userId: Long): AppResult<Unit>
|
||||
suspend fun unblockUser(userId: Long): AppResult<Unit>
|
||||
suspend fun listSessions(): AppResult<List<AuthSession>>
|
||||
suspend fun revokeSession(jti: String): AppResult<Unit>
|
||||
suspend fun revokeAllSessions(): AppResult<Unit>
|
||||
suspend fun verifyEmail(token: String): AppResult<String>
|
||||
suspend fun requestPasswordReset(email: String): AppResult<String>
|
||||
suspend fun resetPassword(token: String, password: String): AppResult<String>
|
||||
suspend fun setupTwoFactor(): AppResult<Pair<String, String>>
|
||||
suspend fun enableTwoFactor(code: String): AppResult<String>
|
||||
suspend fun disableTwoFactor(code: String): AppResult<String>
|
||||
suspend fun twoFactorRecoveryStatus(): AppResult<Int>
|
||||
suspend fun regenerateTwoFactorRecoveryCodes(code: String): AppResult<List<String>>
|
||||
}
|
||||
@@ -5,6 +5,12 @@ data class AuthUser(
|
||||
val email: String,
|
||||
val name: String,
|
||||
val username: String,
|
||||
val bio: String?,
|
||||
val avatarUrl: String?,
|
||||
val emailVerified: Boolean,
|
||||
val twofaEnabled: Boolean,
|
||||
val privacyPrivateMessages: String,
|
||||
val privacyLastSeen: String,
|
||||
val privacyAvatar: String,
|
||||
val privacyGroupInvites: String,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
package ru.daemonlord.messenger.ui.account
|
||||
|
||||
import ru.daemonlord.messenger.domain.account.model.UserSearchItem
|
||||
import ru.daemonlord.messenger.domain.auth.model.AuthSession
|
||||
import ru.daemonlord.messenger.domain.auth.model.AuthUser
|
||||
|
||||
data class AccountUiState(
|
||||
val isLoading: Boolean = false,
|
||||
val isSaving: Boolean = false,
|
||||
val profile: AuthUser? = null,
|
||||
val sessions: List<AuthSession> = emptyList(),
|
||||
val blockedUsers: List<UserSearchItem> = emptyList(),
|
||||
val twoFactorSecret: String? = null,
|
||||
val twoFactorOtpAuthUrl: String? = null,
|
||||
val recoveryCodes: List<String> = emptyList(),
|
||||
val recoveryCodesRemaining: Int? = null,
|
||||
val message: String? = null,
|
||||
val errorMessage: String? = null,
|
||||
)
|
||||
@@ -0,0 +1,302 @@
|
||||
package ru.daemonlord.messenger.ui.account
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import ru.daemonlord.messenger.domain.account.repository.AccountRepository
|
||||
import ru.daemonlord.messenger.domain.common.AppError
|
||||
import ru.daemonlord.messenger.domain.common.AppResult
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltViewModel
|
||||
class AccountViewModel @Inject constructor(
|
||||
private val accountRepository: AccountRepository,
|
||||
) : ViewModel() {
|
||||
private val _uiState = MutableStateFlow(AccountUiState())
|
||||
val uiState: StateFlow<AccountUiState> = _uiState.asStateFlow()
|
||||
|
||||
init {
|
||||
refresh()
|
||||
}
|
||||
|
||||
fun refresh() {
|
||||
viewModelScope.launch {
|
||||
_uiState.update { it.copy(isLoading = true, errorMessage = null, message = null) }
|
||||
val me = accountRepository.getMe()
|
||||
val sessions = accountRepository.listSessions()
|
||||
val blocked = accountRepository.listBlockedUsers()
|
||||
_uiState.update { state ->
|
||||
state.copy(
|
||||
isLoading = false,
|
||||
profile = (me as? AppResult.Success)?.data ?: state.profile,
|
||||
sessions = (sessions as? AppResult.Success)?.data ?: state.sessions,
|
||||
blockedUsers = (blocked as? AppResult.Success)?.data ?: state.blockedUsers,
|
||||
errorMessage = listOf(me, sessions, blocked)
|
||||
.filterIsInstance<AppResult.Error>()
|
||||
.firstOrNull()
|
||||
?.reason
|
||||
?.toUiMessage(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun updateProfile(
|
||||
name: String,
|
||||
username: String,
|
||||
bio: String?,
|
||||
avatarUrl: String?,
|
||||
) {
|
||||
viewModelScope.launch {
|
||||
_uiState.update { it.copy(isSaving = true, errorMessage = null, message = null) }
|
||||
when (val result = accountRepository.updateProfile(name, username, bio, avatarUrl)) {
|
||||
is AppResult.Success -> _uiState.update {
|
||||
it.copy(
|
||||
isSaving = false,
|
||||
profile = result.data,
|
||||
message = "Profile updated.",
|
||||
)
|
||||
}
|
||||
is AppResult.Error -> _uiState.update {
|
||||
it.copy(
|
||||
isSaving = false,
|
||||
errorMessage = result.reason.toUiMessage(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun uploadAvatar(
|
||||
fileName: String,
|
||||
mimeType: String,
|
||||
bytes: ByteArray,
|
||||
onUploaded: (String) -> Unit,
|
||||
) {
|
||||
viewModelScope.launch {
|
||||
_uiState.update { it.copy(isSaving = true, errorMessage = null, message = null) }
|
||||
when (val result = accountRepository.uploadAvatar(fileName, mimeType, bytes)) {
|
||||
is AppResult.Success -> {
|
||||
_uiState.update { it.copy(isSaving = false, message = "Avatar uploaded.") }
|
||||
onUploaded(result.data)
|
||||
}
|
||||
is AppResult.Error -> _uiState.update {
|
||||
it.copy(
|
||||
isSaving = false,
|
||||
errorMessage = result.reason.toUiMessage(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun updatePrivacy(
|
||||
privateMessages: String,
|
||||
lastSeen: String,
|
||||
avatar: String,
|
||||
groupInvites: String,
|
||||
) {
|
||||
viewModelScope.launch {
|
||||
_uiState.update { it.copy(isSaving = true, errorMessage = null, message = null) }
|
||||
when (
|
||||
val result = accountRepository.updatePrivacy(
|
||||
privateMessages = privateMessages,
|
||||
lastSeen = lastSeen,
|
||||
avatar = avatar,
|
||||
groupInvites = groupInvites,
|
||||
)
|
||||
) {
|
||||
is AppResult.Success -> _uiState.update {
|
||||
it.copy(
|
||||
isSaving = false,
|
||||
profile = result.data,
|
||||
message = "Privacy settings updated.",
|
||||
)
|
||||
}
|
||||
is AppResult.Error -> _uiState.update {
|
||||
it.copy(isSaving = false, errorMessage = result.reason.toUiMessage())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun blockUser(userId: Long) {
|
||||
viewModelScope.launch {
|
||||
when (val result = accountRepository.blockUser(userId)) {
|
||||
is AppResult.Success -> refresh()
|
||||
is AppResult.Error -> _uiState.update { it.copy(errorMessage = result.reason.toUiMessage()) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun unblockUser(userId: Long) {
|
||||
viewModelScope.launch {
|
||||
when (val result = accountRepository.unblockUser(userId)) {
|
||||
is AppResult.Success -> refresh()
|
||||
is AppResult.Error -> _uiState.update { it.copy(errorMessage = result.reason.toUiMessage()) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun revokeSession(jti: String) {
|
||||
viewModelScope.launch {
|
||||
when (val result = accountRepository.revokeSession(jti)) {
|
||||
is AppResult.Success -> refresh()
|
||||
is AppResult.Error -> _uiState.update { it.copy(errorMessage = result.reason.toUiMessage()) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun revokeAllSessions() {
|
||||
viewModelScope.launch {
|
||||
when (val result = accountRepository.revokeAllSessions()) {
|
||||
is AppResult.Success -> refresh()
|
||||
is AppResult.Error -> _uiState.update { it.copy(errorMessage = result.reason.toUiMessage()) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun setupTwoFactor() {
|
||||
viewModelScope.launch {
|
||||
when (val result = accountRepository.setupTwoFactor()) {
|
||||
is AppResult.Success -> _uiState.update {
|
||||
it.copy(
|
||||
twoFactorSecret = result.data.first,
|
||||
twoFactorOtpAuthUrl = result.data.second,
|
||||
message = "2FA secret generated. Enter code to enable.",
|
||||
errorMessage = null,
|
||||
)
|
||||
}
|
||||
is AppResult.Error -> _uiState.update { it.copy(errorMessage = result.reason.toUiMessage()) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun enableTwoFactor(code: String) {
|
||||
viewModelScope.launch {
|
||||
when (val result = accountRepository.enableTwoFactor(code)) {
|
||||
is AppResult.Success -> {
|
||||
_uiState.update { it.copy(message = result.data, errorMessage = null) }
|
||||
refreshRecoveryStatus()
|
||||
refresh()
|
||||
}
|
||||
is AppResult.Error -> _uiState.update { it.copy(errorMessage = result.reason.toUiMessage()) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun disableTwoFactor(code: String) {
|
||||
viewModelScope.launch {
|
||||
when (val result = accountRepository.disableTwoFactor(code)) {
|
||||
is AppResult.Success -> {
|
||||
_uiState.update { it.copy(message = result.data, errorMessage = null) }
|
||||
refresh()
|
||||
}
|
||||
is AppResult.Error -> _uiState.update { it.copy(errorMessage = result.reason.toUiMessage()) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun refreshRecoveryStatus() {
|
||||
viewModelScope.launch {
|
||||
when (val result = accountRepository.twoFactorRecoveryStatus()) {
|
||||
is AppResult.Success -> _uiState.update { it.copy(recoveryCodesRemaining = result.data) }
|
||||
is AppResult.Error -> _uiState.update { it.copy(errorMessage = result.reason.toUiMessage()) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun regenerateRecoveryCodes(code: String) {
|
||||
viewModelScope.launch {
|
||||
when (val result = accountRepository.regenerateTwoFactorRecoveryCodes(code)) {
|
||||
is AppResult.Success -> _uiState.update {
|
||||
it.copy(
|
||||
recoveryCodes = result.data,
|
||||
message = "Recovery codes regenerated.",
|
||||
errorMessage = null,
|
||||
)
|
||||
}
|
||||
is AppResult.Error -> _uiState.update { it.copy(errorMessage = result.reason.toUiMessage()) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun verifyEmail(token: String) {
|
||||
viewModelScope.launch {
|
||||
_uiState.update { it.copy(isSaving = true, errorMessage = null, message = null) }
|
||||
when (val result = accountRepository.verifyEmail(token)) {
|
||||
is AppResult.Success -> _uiState.update {
|
||||
it.copy(
|
||||
isSaving = false,
|
||||
message = result.data,
|
||||
)
|
||||
}
|
||||
is AppResult.Error -> _uiState.update {
|
||||
it.copy(
|
||||
isSaving = false,
|
||||
errorMessage = result.reason.toUiMessage(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun requestPasswordReset(email: String) {
|
||||
viewModelScope.launch {
|
||||
_uiState.update { it.copy(isSaving = true, errorMessage = null, message = null) }
|
||||
when (val result = accountRepository.requestPasswordReset(email)) {
|
||||
is AppResult.Success -> _uiState.update {
|
||||
it.copy(
|
||||
isSaving = false,
|
||||
message = result.data,
|
||||
)
|
||||
}
|
||||
is AppResult.Error -> _uiState.update {
|
||||
it.copy(
|
||||
isSaving = false,
|
||||
errorMessage = result.reason.toUiMessage(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun resetPassword(token: String, password: String) {
|
||||
viewModelScope.launch {
|
||||
_uiState.update { it.copy(isSaving = true, errorMessage = null, message = null) }
|
||||
when (val result = accountRepository.resetPassword(token, password)) {
|
||||
is AppResult.Success -> _uiState.update {
|
||||
it.copy(
|
||||
isSaving = false,
|
||||
message = result.data,
|
||||
)
|
||||
}
|
||||
is AppResult.Error -> _uiState.update {
|
||||
it.copy(
|
||||
isSaving = false,
|
||||
errorMessage = result.reason.toUiMessage(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun clearMessage() {
|
||||
_uiState.update { it.copy(message = null, errorMessage = null) }
|
||||
}
|
||||
|
||||
private fun AppError.toUiMessage(): String {
|
||||
return when (this) {
|
||||
AppError.InvalidCredentials -> "Invalid credentials."
|
||||
AppError.Unauthorized -> "Unauthorized."
|
||||
AppError.Network -> "Network error."
|
||||
is AppError.Server -> message ?: "Server error."
|
||||
is AppError.Unknown -> cause?.message ?: "Unknown error."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
7
android/core/common/build.gradle.kts
Normal file
7
android/core/common/build.gradle.kts
Normal file
@@ -0,0 +1,7 @@
|
||||
plugins {
|
||||
id("org.jetbrains.kotlin.jvm")
|
||||
}
|
||||
|
||||
kotlin {
|
||||
jvmToolchain(17)
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package ru.daemonlord.messenger.core.feature
|
||||
|
||||
data class FeatureFlags(
|
||||
val accountManagementEnabled: Boolean,
|
||||
val twoFactorEnabled: Boolean,
|
||||
val mediaGalleryEnabled: Boolean,
|
||||
)
|
||||
@@ -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)
|
||||
}
|
||||
@@ -16,3 +16,4 @@ dependencyResolutionManagement {
|
||||
|
||||
rootProject.name = "MessengerAndroid"
|
||||
include(":app")
|
||||
include(":core:common")
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user