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

This commit is contained in:
Codex
2026-03-09 16:05:52 +03:00
parent 5368515112
commit f708854bb2
3 changed files with 144 additions and 47 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

@@ -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

@@ -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