Compare commits

...

78 Commits

Author SHA1 Message Date
b40dea18f1 Telegram-like composer: voice/circle toggle and unified attach actions
Some checks failed
Android CI / android (push) Failing after 5m23s
Android Release / release (push) Failing after 6m10s
CI / test (push) Failing after 3m11s
2026-03-11 23:41:47 +03:00
28cb80fbb8 Reduce ChatScreen parameter footprint to avoid verifier crash
Some checks failed
Android CI / android (push) Failing after 6m25s
Android Release / release (push) Failing after 6m3s
CI / test (push) Failing after 3m25s
2026-03-11 23:10:56 +03:00
9af7597f8b Split chat overlays to fix ART VerifyError
Some checks failed
Android CI / android (push) Failing after 5m57s
Android Release / release (push) Has started running
CI / test (push) Has been cancelled
2026-03-11 23:00:19 +03:00
e6f1727800 Fix circle video playback and recorder compatibility
Some checks failed
Android CI / android (push) Failing after 6m47s
Android Release / release (push) Failing after 6m1s
CI / test (push) Has started running
2026-03-11 22:46:34 +03:00
cf53123724 android: add in-chat circle recorder with live camera preview
Some checks failed
Android CI / android (push) Failing after 34s
Android Release / release (push) Failing after 36s
CI / test (push) Failing after 3m38s
2026-03-11 22:32:39 +03:00
2fa006747d android: add circle recording and in-app camera capture
Some checks failed
Android CI / android (push) Has started running
CI / test (push) Has been cancelled
Android Release / release (push) Failing after 37s
2026-03-11 22:19:10 +03:00
4032b55b0b Localize remaining SettingsScreen meta strings
Some checks failed
Android CI / android (push) Failing after 12m44s
CI / test (push) Has been cancelled
Android Release / release (push) Failing after 7m12s
2026-03-11 21:52:48 +03:00
a1163be30b Localize AppNavGraph auth headers
Some checks failed
Android CI / android (push) Has been cancelled
Android Release / release (push) Has been cancelled
CI / test (push) Has been cancelled
2026-03-11 21:48:38 +03:00
d649cf1cb4 Localize key ChatScreen labels and media badges
Some checks failed
Android CI / android (push) Has started running
CI / test (push) Has been cancelled
Android Release / release (push) Has started running
2026-03-11 21:45:48 +03:00
e5e4fd653e Localize ProfileScreen labels and actions
Some checks failed
Android CI / android (push) Failing after 6m10s
Android Release / release (push) Failing after 6m47s
CI / test (push) Failing after 3m4s
2026-03-11 21:03:55 +03:00
f88d9a2a36 Localize chat list management messages
Some checks failed
Android CI / android (push) Failing after 6m28s
Android Release / release (push) Has started running
CI / test (push) Has been cancelled
2026-03-11 20:52:27 +03:00
27f2ad8001 Localize AccountViewModel status and error messages
Some checks failed
Android CI / android (push) Failing after 5m31s
Android Release / release (push) Has started running
CI / test (push) Has been cancelled
2026-03-11 20:46:36 +03:00
d54dc9fe8b Localize AuthViewModel validation and error messages
Some checks failed
Android CI / android (push) Has started running
Android Release / release (push) Has been cancelled
CI / test (push) Has been cancelled
2026-03-11 20:42:33 +03:00
6e9e580b3f Localize chat day labels and gif errors
Some checks failed
Android CI / android (push) Failing after 5m57s
Android Release / release (push) Failing after 5m13s
CI / test (push) Failing after 2m56s
2026-03-11 06:41:23 +03:00
43c3fd0169 Localize ChatViewModel runtime messages
Some checks failed
Android CI / android (push) Failing after 4m52s
Android Release / release (push) Has started running
CI / test (push) Has been cancelled
2026-03-11 06:35:24 +03:00
3f9aa83110 Localize contacts screen and contact errors/messages (EN/RU)
Some checks failed
Android CI / android (push) Failing after 5m28s
Android Release / release (push) Failing after 5m34s
CI / test (push) Failing after 2m37s
2026-03-11 06:16:37 +03:00
2ffc4cce09 Chat video messages: add thumbnail preview card with play overlay
Some checks failed
Android CI / android (push) Has been cancelled
Android Release / release (push) Has been cancelled
CI / test (push) Has been cancelled
2026-03-11 06:12:36 +03:00
e591a3fa8d Localize auth screens (login, verify email, reset password) EN/RU
Some checks failed
Android CI / android (push) Has started running
Android Release / release (push) Has been cancelled
CI / test (push) Has been cancelled
2026-03-11 06:07:55 +03:00
e0728ac067 Settings localization: remove hardcoded Russian subtitle literal
Some checks failed
Android CI / android (push) Has been cancelled
Android Release / release (push) Has been cancelled
CI / test (push) Has been cancelled
2026-03-11 06:03:11 +03:00
c5c1db98ad Localize chat member action dialogs and chat info labels (EN/RU)
Some checks failed
Android CI / android (push) Has started running
Android Release / release (push) Has been cancelled
CI / test (push) Has been cancelled
2026-03-11 06:00:54 +03:00
92c4cba1b0 Localize chat list popups and selection menu strings (EN/RU)
Some checks failed
Android CI / android (push) Has started running
Android Release / release (push) Has been cancelled
CI / test (push) Has been cancelled
2026-03-11 05:56:51 +03:00
60d898bf21 Localization base: add EN/RU chat keys and wire chat info/member labels
Some checks failed
Android CI / android (push) Has started running
Android Release / release (push) Has been cancelled
CI / test (push) Has been cancelled
2026-03-11 05:52:42 +03:00
732b21a4e3 Chat mute UX: dynamic channel toggle, top-bar indicator, no success toast
Some checks failed
Android CI / android (push) Failing after 5m21s
Android Release / release (push) Has started running
CI / test (push) Has been cancelled
2026-03-11 05:45:46 +03:00
10676e34ad Enforce owner/admin hierarchy for member management
Some checks failed
Android CI / android (push) Failing after 4m44s
Android Release / release (push) Failing after 4m50s
CI / test (push) Has been cancelled
2026-03-11 05:35:23 +03:00
3bc540e46d android: prevent self member actions and add member action confirmations
Some checks failed
Android CI / android (push) Failing after 4m57s
Android Release / release (push) Failing after 5m5s
CI / test (push) Failing after 3m8s
2026-03-11 05:17:57 +03:00
0510a2717a android: fix chat theme toggle and add member management in chat info
Some checks failed
Android CI / android (push) Failing after 5m24s
Android Release / release (push) Has been cancelled
CI / test (push) Has been cancelled
2026-03-11 05:12:17 +03:00
cdb45abb21 android: persist language settings and realtime/ui sync updates
Some checks failed
Android CI / android (push) Failing after 5m7s
Android Release / release (push) Failing after 5m5s
CI / test (push) Failing after 2m49s
2026-03-11 04:52:03 +03:00
cd7fb878b3 android: remove wallet menu and continue chat/settings localization
Some checks failed
Android CI / android (push) Has started running
Android Release / release (push) Has been cancelled
CI / test (push) Has been cancelled
2026-03-11 04:49:48 +03:00
a4fd60919e Android chat UX: gif/url media parity, waveform seekbar, unread auto-scroll
Some checks failed
Android CI / android (push) Failing after 6m42s
Android Release / release (push) Failing after 6m12s
CI / test (push) Failing after 2m53s
2026-03-10 22:04:08 +03:00
Codex
3c9b97e102 fix(android): enforce single active player in chat timeline
Some checks failed
Android CI / android (push) Failing after 6m15s
Android Release / release (push) Failing after 6m4s
CI / test (push) Failing after 3m3s
2026-03-10 21:10:05 +03:00
Codex
f8ed889170 fix(android): enforce single active voice player in chat info tab
Some checks failed
Android CI / android (push) Has started running
Android Release / release (push) Has been cancelled
CI / test (push) Has been cancelled
2026-03-10 21:07:53 +03:00
Codex
3844875d36 fix(android): skip reactions for temp messages and fallback gif upload
Some checks failed
Android CI / android (push) Has started running
Android Release / release (push) Has been cancelled
CI / test (push) Has been cancelled
2026-03-10 21:06:07 +03:00
Codex
27fba86915 fix(android): make top audio strip controls functional
Some checks failed
Android CI / android (push) Has started running
Android Release / release (push) Has been cancelled
CI / test (push) Has been cancelled
2026-03-10 21:03:03 +03:00
Codex
58b554731d fix(android): render gif attachments reliably after send
Some checks failed
Android CI / android (push) Failing after 5m21s
Android Release / release (push) Has been cancelled
CI / test (push) Has been cancelled
2026-03-10 20:55:31 +03:00
Codex
2a72437d28 feat(android): add inline voice mini-player in chat info tab
Some checks failed
Android CI / android (push) Failing after 5m23s
Android Release / release (push) Has started running
CI / test (push) Has been cancelled
2026-03-10 20:49:15 +03:00
Codex
8522e32aea feat(android): make chat info entries clickable and open from header
Some checks failed
Android CI / android (push) Has started running
Android Release / release (push) Has been cancelled
CI / test (push) Has been cancelled
2026-03-10 20:47:14 +03:00
Codex
e3fdccdeaa fix(android): send gifs/stickers as image and add giphy search
Some checks failed
Android CI / android (push) Failing after 5m47s
Android Release / release (push) Has started running
CI / test (push) Has been cancelled
2026-03-10 20:41:04 +03:00
Codex
23d636be7e fix(android): preload message reactions on chat open
Some checks failed
Android CI / android (push) Failing after 5m51s
Android Release / release (push) Has started running
CI / test (push) Has been cancelled
2026-03-10 20:33:50 +03:00
Codex
842a9d2093 fix(android): make chat day separator keys unique
Some checks failed
Android CI / android (push) Has started running
Android Release / release (push) Has been cancelled
CI / test (push) Has been cancelled
2026-03-10 20:30:10 +03:00
Codex
63c0cd098e android: refine multi-select ui to telegram-like layout
Some checks failed
Android CI / android (push) Failing after 5m11s
Android Release / release (push) Has started running
CI / test (push) Has been cancelled
2026-03-10 20:21:10 +03:00
Codex
fbe4db02ca android: remove legacy single-message action bar
Some checks failed
Android CI / android (push) Has started running
Android Release / release (push) Has been cancelled
CI / test (push) Has been cancelled
2026-03-10 20:18:11 +03:00
Codex
7f1b0e09c5 android: clear message selection when context sheet is dismissed
Some checks failed
Android CI / android (push) Failing after 5m7s
Android Release / release (push) Failing after 5m31s
CI / test (push) Failing after 2m49s
2026-03-10 09:15:11 +03:00
Codex
f7b9753c2e android: fix AppCompat theme crash on launch
Some checks failed
Android CI / android (push) Has been cancelled
Android Release / release (push) Has been cancelled
CI / test (push) Has been cancelled
2026-03-10 09:12:14 +03:00
Codex
e4ea18242a android: fix missing Hilt GeneratedInjector in asm transform
Some checks failed
Android CI / android (push) Failing after 5m19s
Android Release / release (push) Has been cancelled
CI / test (push) Has been cancelled
2026-03-10 09:06:24 +03:00
Codex
0208fbc5cc android: add seek/pause controls for video and audio players
Some checks failed
Android CI / android (push) Failing after 5m15s
Android Release / release (push) Has been cancelled
CI / test (push) Has been cancelled
2026-03-10 08:58:15 +03:00
Codex
22ee59fd74 android: fix voice recording composer overlap
Some checks failed
Android CI / android (push) Has started running
Android Release / release (push) Has been cancelled
CI / test (push) Has been cancelled
2026-03-10 08:54:34 +03:00
Codex
f7ef10b011 android: align channel chat UI with telegram-style feed
Some checks failed
Android CI / android (push) Has started running
Android Release / release (push) Has been cancelled
CI / test (push) Has been cancelled
2026-03-10 08:50:38 +03:00
Codex
78934a5f28 Android chat UX: video viewer, emoji/gif/sticker picker, day separators
Some checks failed
Android CI / android (push) Failing after 5m21s
Android Release / release (push) Failing after 5m16s
CI / test (push) Has been cancelled
2026-03-10 08:38:54 +03:00
Codex
0beb52e438 Android parity: formatting, notifications inbox, resend verification, push sync
Some checks failed
Android CI / android (push) Failing after 4m55s
Android Release / release (push) Has started running
CI / test (push) Has been cancelled
2026-03-10 08:29:36 +03:00
Codex
10e188b615 docs: add android text-formatting parity gap
Some checks failed
Android CI / android (push) Failing after 5m8s
Android Release / release (push) Failing after 6m12s
CI / test (push) Failing after 2m51s
2026-03-10 08:00:39 +03:00
Codex
47365bba57 android: make top audio strip playback-driven and dismissible
Some checks failed
Android CI / android (push) Failing after 5m3s
Android Release / release (push) Failing after 5m9s
CI / test (push) Failing after 3m0s
2026-03-10 01:51:51 +03:00
Codex
55af1f78b6 android: micro-polish chat bubbles and composer visuals
Some checks failed
Android CI / android (push) Has been cancelled
Android Release / release (push) Has been cancelled
CI / test (push) Has been cancelled
2026-03-10 01:48:07 +03:00
Codex
7781cf83e4 android: refine chat header and top pinned/audio strips
Some checks failed
Android CI / android (push) Failing after 5m15s
Android Release / release (push) Has been cancelled
CI / test (push) Has been cancelled
2026-03-10 01:42:17 +03:00
Codex
5a0bb9ff08 android: fix inline search close and polish message action sheet
Some checks failed
Android CI / android (push) Has started running
Android Release / release (push) Has been cancelled
CI / test (push) Has been cancelled
2026-03-10 01:38:46 +03:00
Codex
90c25c5eb8 android: polish chat info tabs and media grid layout
Some checks failed
Android CI / android (push) Has started running
Android Release / release (push) Has been cancelled
CI / test (push) Has been cancelled
2026-03-10 01:32:19 +03:00
Codex
2ed0e1f041 android: add chat menu and info tabs shell
Some checks failed
Android CI / android (push) Has started running
CI / test (push) Has been cancelled
Android Release / release (push) Failing after 5m12s
2026-03-10 01:28:39 +03:00
Codex
580a6683e3 android: align chat message actions with telegram-style selection
Some checks failed
Android CI / android (push) Failing after 5m2s
Android Release / release (push) Has started running
CI / test (push) Has been cancelled
2026-03-10 01:22:08 +03:00
Codex
4aa4946e82 android: refine chat message bubbles and media blocks
Some checks failed
Android CI / android (push) Has been cancelled
Android Release / release (push) Has been cancelled
CI / test (push) Has been cancelled
2026-03-10 01:16:39 +03:00
Codex
895c132eb2 android: refresh chat screen header and composer baseline
Some checks failed
Android CI / android (push) Failing after 5m3s
Android Release / release (push) Has started running
CI / test (push) Has been cancelled
2026-03-10 01:10:51 +03:00
Codex
1099efc8c0 android: group push notifications by chat
Some checks failed
Android CI / android (push) Failing after 5m15s
Android Release / release (push) Failing after 4m53s
CI / test (push) Failing after 2m56s
2026-03-10 00:43:20 +03:00
Codex
e21a54e2bf web: group notifications per chat thread
Some checks failed
Android CI / android (push) Has started running
Android Release / release (push) Has been cancelled
CI / test (push) Has been cancelled
2026-03-10 00:39:58 +03:00
Codex
148870de14 web: guard invalid VAPID key during push subscription
Some checks failed
Android CI / android (push) Has started running
Android Release / release (push) Has been cancelled
CI / test (push) Has been cancelled
2026-03-10 00:35:06 +03:00
Codex
158126555c android: remove back-to-chats from settings folders
Some checks failed
Android CI / android (push) Has started running
Android Release / release (push) Has been cancelled
CI / test (push) Has been cancelled
2026-03-10 00:33:10 +03:00
Codex
eae6a2a90f android: clean up profile screen layout and actions
Some checks failed
Android CI / android (push) Has started running
Android Release / release (push) Has been cancelled
CI / test (push) Has been cancelled
2026-03-10 00:31:14 +03:00
Codex
bb1f59d1f4 android settings: split menu into telegram-like folder pages
Some checks failed
Android CI / android (push) Has started running
Android Release / release (push) Has been cancelled
CI / test (push) Has been cancelled
2026-03-10 00:27:47 +03:00
Codex
4bab551f0e android accounts: force chats/realtime resync on account switch
Some checks failed
Android CI / android (push) Failing after 5m15s
Android Release / release (push) Failing after 4m20s
CI / test (push) Has started running
2026-03-10 00:17:13 +03:00
Codex
c609a7d72d android auth: add step-based email/register/2fa flow and startup route
Some checks failed
Android CI / android (push) Has been cancelled
Android Release / release (push) Has been cancelled
CI / test (push) Has been cancelled
2026-03-10 00:12:42 +03:00
Codex
09a77bd4d7 android: switch privacy settings to dropdowns and simplify settings sections
Some checks failed
Android CI / android (push) Failing after 5m16s
Android Release / release (push) Has started running
CI / test (push) Has started running
2026-03-10 00:02:40 +03:00
Codex
0bd7e1cd21 android: fix profile crash by replacing negative padding with offset
Some checks failed
Android CI / android (push) Has started running
Android Release / release (push) Has been cancelled
CI / test (push) Has been cancelled
2026-03-09 23:57:37 +03:00
Codex
15f9836224 android: fix MainActivity crash by applying theme after Hilt injection
Some checks failed
Android CI / android (push) Has started running
Android Release / release (push) Has been cancelled
CI / test (push) Has been cancelled
2026-03-09 23:56:02 +03:00
Codex
cdf7859668 android: align settings/profile with app theme and add real settings controls
Some checks failed
Android CI / android (push) Has started running
Android Release / release (push) Has been cancelled
CI / test (push) Has been cancelled
2026-03-09 23:54:47 +03:00
Codex
daddbfd2a0 android: add multi-account switching foundation in settings
Some checks failed
Android CI / android (push) Failing after 4m43s
Android Release / release (push) Has started running
CI / test (push) Has been cancelled
2026-03-09 23:44:53 +03:00
Codex
19471ac736 android: redesign settings and profile screens to telegram-like layout
Some checks failed
Android CI / android (push) Failing after 4m55s
Android Release / release (push) Has started running
CI / test (push) Has been cancelled
2026-03-09 23:37:48 +03:00
Codex
15e80262e0 android: keep read ack strictly bounded by visible incoming messages
Some checks failed
Android CI / android (push) Failing after 4m58s
Android Release / release (push) Has started running
CI / test (push) Has been cancelled
2026-03-09 23:32:22 +03:00
Codex
5921215718 android: mark messages read when visible and sync unread across devices
Some checks failed
Android CI / android (push) Has started running
Android Release / release (push) Has been cancelled
CI / test (push) Has been cancelled
2026-03-09 23:29:35 +03:00
Codex
d54eb400c7 android: fix unread ack to use latest visible message
Some checks failed
Android CI / android (push) Has started running
Android Release / release (push) Has been cancelled
CI / test (push) Has been cancelled
2026-03-09 23:25:20 +03:00
Codex
28b549e53e chore: ignore local secrets directory
Some checks failed
Android CI / android (push) Has been cancelled
Android Release / release (push) Has been cancelled
CI / test (push) Has been cancelled
2026-03-09 23:21:37 +03:00
Codex
e44e8d1355 infra: wire firebase credentials into docker backend and worker
Some checks failed
Android CI / android (push) Has been cancelled
Android Release / release (push) Has been cancelled
CI / test (push) Has been cancelled
2026-03-09 23:19:06 +03:00
79 changed files with 9101 additions and 1382 deletions

View File

@@ -35,6 +35,7 @@ SMTP_USE_SSL=false
SMTP_TIMEOUT_SECONDS=10
SMTP_FROM_EMAIL=no-reply@benyamessenger.local
FIREBASE_ENABLED=false
FIREBASE_CREDENTIALS_HOST_PATH=./secrets/firebase-service-account.json
FIREBASE_CREDENTIALS_PATH=
FIREBASE_CREDENTIALS_JSON=
FIREBASE_WEBPUSH_LINK=https://chat.daemonlord.ru/

1
.gitignore vendored
View File

@@ -8,3 +8,4 @@ test.db
web/node_modules
web/dist
web/tsconfig.tsbuildinfo
secrets/

View File

@@ -700,3 +700,326 @@
- unregister token on logout,
- handle foreground push payload via existing notification service worker.
- Added required env keys to `web/.env.example` and backend Firebase env keys to root `.env.example`.
### Step 106 - Unread counter stabilization in Chat screen
- Fixed read acknowledgement strategy in `ChatViewModel`:
- read status is now acknowledged by the latest visible message id in chat (not only latest incoming),
- delivery status still uses latest incoming message.
- This removes cases where unread badge reappears after chat list refresh because the previous read ack used an outdated incoming id.
### Step 107 - Read-on-visible + cross-device unread sync
- Implemented read acknowledgement from actual visible messages in `ChatScreen`:
- tracks visible `LazyColumn` rows and sends read up to max visible incoming message id.
- unread now drops as messages appear on screen while scrolling.
- Improved cross-device sync (web <-> android):
- `message_read` realtime event now parses `user_id` and `last_read_message_id`.
- on `message_read`, Android refreshes chat snapshot from backend to keep unread counters aligned across devices.
### Step 108 - Strict read boundary by visible incoming only
- Removed fallback read-pointer advancement in `ChatViewModel.acknowledgeLatestMessages(...)` that previously moved `lastReadMessageId` by latest loaded message id.
- Read pointer is now advanced only via `onVisibleIncomingMessageId(...)` from visible incoming rows in `ChatScreen`.
- This prevents read acknowledgements from overshooting beyond what user actually saw during refresh/recompose scenarios.
### Step 109 - Telegram-like Settings/Profile visual refresh
- Redesigned `SettingsScreen` to Telegram-inspired dark card layout:
- profile header card with avatar/name/email/username,
- grouped settings rows with material icons,
- appearance controls (Light/Dark/System),
- quick security/help sections and preserved logout/back actions.
- Redesigned `ProfileScreen` to Telegram-inspired structure:
- gradient hero header with centered avatar, status, and action buttons,
- primary profile info card,
- tab-like section (`Posts/Archived/Gifts`) with placeholder content,
- inline edit card (name/username/bio/avatar URL) with existing save/upload behavior preserved.
### Step 110 - Multi-account foundation (switch active account)
- Extended `TokenRepository` to support account list and active-account switching:
- observe/list stored accounts,
- get active account id,
- switch/remove account,
- clear all tokens.
- Reworked `EncryptedPrefsTokenRepository` storage model:
- stores tokens per `userId` and account metadata list in encrypted prefs,
- migrates legacy single-account keys on first run,
- preserves active account pointer.
- `NetworkAuthRepository` now upserts account metadata after auth/me calls.
- Added `Settings` UI account section:
- shows saved accounts,
- allows switch/remove,
- triggers auth recheck + chats reload on switch.
### Step 111 - Real Settings + persistent theme + add-account UX
- Implemented persistent app theme storage via DataStore (`ThemeRepository`) and applied theme mode on app start in `MainActivity`.
- Reworked `SettingsScreen` to contain only working settings and actions:
- multi-account list (`Switch`/`Remove`) + `Add account` dialog with email/password sign-in,
- appearance (`Light`/`Dark`/`System`) wired to persisted theme,
- notifications (`global` + `preview`) wired to `NotificationSettingsRepository`,
- privacy update, blocked users management, sessions revoke/revoke-all, 2FA controls.
- Updated `ProfileScreen` to follow current app theme colors instead of forced dark palette.
### Step 112 - Settings cleanup (privacy dropdowns + removed extra blocks)
- Replaced free-text privacy inputs with dropdown selectors (`everyone`, `contacts`, `nobody`) for:
- private messages,
- last seen,
- avatar visibility,
- group invites.
- Removed direct `block by user id` controls from Settings UI as requested.
- Removed extra bottom Settings actions (`Profile` row and `Back to chats` button) and kept categorized section layout.
### Step 113 - Auth flow redesign (email -> password/register -> 2FA) + startup no-flicker
- Added step-based auth domain/use-cases for:
- `GET /api/v1/auth/check-email`
- `POST /api/v1/auth/register`
- login with optional `otp_code` / `recovery_code`.
- Updated Android login UI to multi-step flow:
- step 1: email input,
- step 2: password for existing account or register form (`name`, `username`, `password`) for new account,
- step 3: 2FA OTP/recovery code when backend requires it.
- Improved login error mapping for 2FA-required responses, so app switches to OTP step instead of generic invalid-password message.
- Removed auth screen flash on startup:
- introduced dedicated `startup` route with session-check loader,
- delayed auth/chats navigation until session check is finished.
- Added safe fallback in `MainActivity` theme bootstrap to prevent crash if `ThemeRepository` injection is unexpectedly unavailable during startup.
### Step 114 - Multi-account switch sync fix (chats + realtime)
- Fixed account switch flow to fully rebind app data context:
- restart realtime socket on new active account token,
- force refresh chats for both `archived=false` and `archived=true` right after switch.
- Fixed navigation behavior on account switch to avoid noisy `popBackStack ... not found` and stale restored stack state.
### Step 115 - Settings UI restructured into Telegram-like folders
- Reworked Settings into a menu-first screen with Telegram-style grouped rows.
- Added per-item folder pages (subscreens) for:
- Account
- Chat settings
- Privacy
- Notifications
- Devices
- Data/Chat folders/Power/Language placeholders
- Kept theme logic intact and moved appearance controls into `Chat settings` folder.
### Step 116 - Profile cleanup (remove non-working extras)
- Removed non-functional profile tabs and placeholder blocks:
- `Posts`
- `Archived`
- `Gifts`
- Removed `Settings` hero button from profile header.
- Removed bottom `Back to chats` button from profile screen.
- Simplified profile layout so the editable profile form is the primary secondary section toggled by `Edit`.
- Updated `ProfileRoute` navigation contract to match the simplified screen API.
### Step 117 - Settings folders cleanup (remove back button action)
- Removed `Back to chats` button from all Settings folder pages.
- Simplified Settings navigation contract by removing unused `onBackToChats` parameter from:
- `SettingsRoute`
- `SettingsScreen`
- `SettingsFolderView`
- Updated `AppNavGraph` Settings destination call-site accordingly.
### Step 118 - Android push notifications grouped by chat
- Reworked `NotificationDispatcher` to aggregate incoming messages into one notification per chat:
- stable notification id per `chatId`,
- per-chat unread counter,
- multi-line inbox preview of recent messages.
- Added app-level summary notification that groups all active chat notifications.
- Added deduplication guard for repeated push deliveries of the same `messageId`.
- Added notification cleanup on chat open:
- when push-open intent targets a chat in `MainActivity`,
- when `ChatViewModel` enters a chat directly from app UI.
### Step 119 - Chat screen visual baseline (Telegram-like start)
- Reworked chat top bar:
- icon back button instead of text button,
- cleaner title/subtitle styling,
- dedicated search icon in top bar (inline search is now collapsible).
- Updated pinned message strip:
- cleaner card styling,
- close icon action instead of full text button.
- Updated composer baseline:
- icon-based emoji/attach/send/mic controls,
- cleaner container styling closer to Telegram-like bottom bar.
### Step 120 - Message bubble layout pass (Telegram-like geometry)
- Reworked `MessageBubble` structure and density:
- cleaner outgoing/incoming bubble geometry,
- improved max width and alignment behavior,
- tighter paddings and spacing for mobile density.
- Redesigned forwarded/reply blocks:
- compact forwarded caption styling,
- reply block with accent stripe and nested preview text.
- Improved message meta line:
- cleaner time + status line placement and contrast.
- Refined reactions and attachments rendering inside bubbles:
- chip-like reaction containers,
- rounded image/file/media surfaces closer to Telegram-like visual rhythm.
### Step 121 - Chat selection and message action UX cleanup
- Added Telegram-like multi-select top bar in chat:
- close selection,
- selected counter,
- quick forward/delete actions.
- Simplified tap action menu flow for single message:
- richer reaction row (`❤️ 👍 👎 🔥 🥰 👏 😁`),
- reply/edit/forward/delete actions kept in one sheet.
- Removed duplicate/conflicting selection controls between top and bottom action rows.
### Step 122 - Chat 3-dot menu + chat info media tabs shell
- Added chat header `3-dot` popup menu with Telegram-like actions:
- `Chat info`
- `Search`
- `Notifications`
- `Change wallpaper`
- `Clear history`
- Added `Chat info` bottom sheet with tabbed sections:
- `Media`
- `Files`
- `Links`
- `Voice`
- Implemented local tab content from current loaded chat messages/attachments to provide immediate media/files/links/voice overview.
### Step 123 - Chat info visual pass (Telegram-like density)
- Updated `Chat info` tabs to pill-style horizontal chips with tighter Telegram-like spacing.
- Improved tab content rendering:
- `Media` now uses a 3-column thumbnail grid.
- `Files / Links / Voice` use denser card rows with icon+meta layout.
- `Voice` rows now show a dedicated play affordance.
- Refined menu order in chat `3-dot` popup and kept actions consistent with current no-calls scope.
### Step 124 - Inline search close fix + message menu visual pass
- Fixed inline chat search UX:
- added explicit close button in the search row,
- closing search now also clears active query/filter without re-entering chat.
- Added automatic inline-search collapse when entering multi-select mode.
- Reworked message tap action sheet to Telegram-like list actions with icons and clearer destructive `Delete` row styling.
### Step 125 - Chat header/top strips visual refinement
- Refined chat header density and typography to be closer to Telegram-like proportions.
- Updated pinned strip visual:
- accent vertical marker,
- tighter spacing,
- cleaner title/content hierarchy.
- Added top mini audio strip under pinned area:
- shows latest audio/voice context from loaded chat,
- includes play affordance, speed badge, and dismiss action.
### Step 126 - Message bubble/composer micro-polish
- Updated message bubble sizing and density:
- reduced bubble width for cleaner conversation rhythm,
- tighter vertical spacing,
- text style adjusted for better readability.
- Refined bottom composer visuals:
- switched to Telegram-like rounded input container look,
- emoji/attach/send buttons now use circular tinted surfaces,
- text input moved to filled style with hidden indicator lines.
### Step 127 - Top audio strip behavior fix (playback-driven)
- Reworked top audio strip logic to be playback-driven instead of always-on:
- strip appears only when user starts audio/voice playback,
- strip switches to the currently playing file,
- strip auto-hides when playback stops.
- Added close (`X`) behavior that hides the strip and force-stops the currently playing source.
### Step 128 - Parity docs update: text formatting gap
- Synced Android parity documentation with web-core status:
- added explicit `Text formatting parity` gap to `docs/android-checklist.md`,
- added dedicated Android gap block in `docs/backend-web-android-parity.md` for formatting parity.
- Marked formatting parity as part of highest-priority Android parity block.
### Step 129 - Parity block (1/3/4/5/6): formatting, notifications inbox, resend verification, push sync
- Completed Android text formatting parity in chat:
- composer toolbar actions for `bold/italic/underline/strikethrough`,
- spoiler, inline code, code block, quote, link insertion,
- message bubble rich renderer for web-style markdown tokens and clickable links.
- Added server notifications inbox flow in account/settings:
- API wiring for `GET /api/v1/notifications`,
- domain mapping and recent-notifications UI section.
- Added resend verification support on Android:
- API wiring for `POST /api/v1/auth/resend-verification`,
- Verify Email screen action for resending link by email.
- Hardened push token lifecycle sync:
- token registration dedupe by `(userId, token)`,
- marker cleanup on logout,
- best-effort re-sync after account switch.
- Notification delivery polish (foundation):
- foreground notification body now respects preview setting and falls back to media-type labels when previews are disabled.
- Verified with successful `:app:compileDebugKotlin` and `:app:assembleDebug`.
### Step 130 - Chat UX pack: video viewer, emoji/GIF/sticker picker, day separators
- Added chat timeline day separators with Telegram-like chips:
- `Сегодня`, `Вчера`, or localized date labels.
- Added fullscreen video viewer:
- video attachments now open in a fullscreen overlay with close action.
- Added composer media picker sheet:
- tabs: `Эмодзи`, `GIF`, `Стикеры`,
- emoji insertion at cursor,
- remote GIF/sticker selection with download+send flow.
- Extended media type mapping in message send pipeline:
- GIFs now sent as `gif`,
- sticker-like payloads sent as `sticker` (filename/mime detection).
- Added missing fallback resource theme declarations (`themes.xml`) to stabilize clean debug assembly on local builds.
### Step 131 - Channel chat Telegram-like visual alignment
- Added channel-aware chat rendering path:
- `MessageUiState` now carries `chatType` from `ChatViewModel`,
- channel timeline bubbles are rendered as wider post-like cards (left-aligned feed style).
- Refined channel message status presentation:
- post cards now show cleaner timestamp-only footer instead of direct-message style checks.
- Added dedicated read-only channel bottom bar (for non owner/admin):
- compact Telegram-like controls with search + centered `Включить звук` action + notifications icon.
- Kept existing full composer for roles allowed to post in channels (owner/admin).
### Step 132 - Voice recording composer overlap fix
- Fixed composer overlap during voice recording:
- recording status/hint is now rendered in a dedicated top block inside composer,
- formatting toolbar is hidden while recording is active.
- Prevented controls collision for locked-recording actions:
- `Cancel/Send` now render on a separate row in locked state.
### Step 133 - Video/audio player controls upgrade
- Upgraded fullscreen video viewer controls:
- play/pause button,
- seek slider (scrubbing),
- current time / total duration labels.
- Upgraded attachment audio player behavior (voice + audio):
- added seek slider for manual rewind/fast-forward,
- unified speed toggle for both `voice` and `audio` playback.
### Step 134 - Hilt startup crash fix (`MessengerApplication_GeneratedInjector`)
- Fixed startup crash:
- `NoClassDefFoundError: MessengerApplication_GeneratedInjector`.
- Root cause observed in build pipeline:
- `MessengerApplication_GeneratedInjector.class` existed after `javac`,
- but was missing in `transformDebugClassesWithAsm/dirs` before dexing.
- Added Gradle backfill task for `debug/release` variants:
- copies `*Application_GeneratedInjector.class` from `intermediates/javac/.../classes`
into `intermediates/classes/.../transform...ClassesWithAsm/dirs` if missing,
- wired task as dependency of `dexBuilder<Variant>`.
### Step 135 - AppCompat launch crash fix (theme mismatch)
- Fixed `MainActivity` startup crash:
- `IllegalStateException: You need to use a Theme.AppCompat theme`.
- Root cause:
- `Theme.AppCompat.DayNight.NoActionBar` was accidentally overridden in app resources
with non-AppCompat parent (`Theme.DeviceDefault.NoActionBar`).
- Fix applied:
- introduced dedicated app theme `Theme.Messenger` with parent `Theme.AppCompat.DayNight.NoActionBar`,
- switched `AndroidManifest.xml` application theme to `@style/Theme.Messenger`.
### Step 136 - Message context menu dismiss selection fix
- Fixed chat bug after closing message context menu by tapping outside:
- selection state now clears on `ModalBottomSheet` dismiss,
- prevents stale single-selection action bar from appearing after menu close.
### Step 137 - Telegram-like message actions cleanup
- Removed legacy single-selection bottom action bar (`Close/Delete/Del for all/Edit`) in chat.
- Message actions are now driven by Telegram-like context UI:
- tap -> context sheet actions,
- long-press -> selection mode flow.
### Step 138 - Multi-select UX closer to Telegram
- Refined selection top bar:
- removed extra overflow/load action from selection mode,
- kept focused actions only: close, selected count, forward, delete.
- In `MULTI` selection mode, composer is now replaced with a compact bottom action row:
- `Reply` (enabled for single selected message),
- `Forward`.

View File

@@ -1,3 +1,5 @@
import java.util.Properties
plugins {
id("com.android.application")
id("org.jetbrains.kotlin.android")
@@ -9,6 +11,15 @@ plugins {
id("com.google.firebase.crashlytics")
}
val localProperties = Properties().apply {
val file = rootProject.file("local.properties")
if (file.exists()) {
file.inputStream().use { load(it) }
}
}
fun String.escapeForBuildConfig(): String = replace("\\", "\\\\").replace("\"", "\\\"")
android {
namespace = "ru.daemonlord.messenger"
compileSdk = 35
@@ -21,6 +32,12 @@ android {
versionName = "0.1.0"
buildConfigField("String", "API_BASE_URL", "\"https://chat.daemonlord.ru/\"")
buildConfigField("String", "API_VERSION_HEADER", "\"2026-03\"")
val giphyApiKey = (
localProperties.getProperty("GIPHY_API_KEY")
?: System.getenv("GIPHY_API_KEY")
?: ""
).trim()
buildConfigField("String", "GIPHY_API_KEY", "\"${giphyApiKey.escapeForBuildConfig()}\"")
buildConfigField("boolean", "FEATURE_ACCOUNT_MANAGEMENT", "true")
buildConfigField("boolean", "FEATURE_TWO_FACTOR", "true")
buildConfigField("boolean", "FEATURE_MEDIA_GALLERY", "true")
@@ -84,9 +101,17 @@ dependencies {
implementation("androidx.compose.material:material-icons-extended:1.7.6")
implementation("io.coil-kt:coil:2.7.0")
implementation("io.coil-kt:coil-compose:2.7.0")
implementation("io.coil-kt:coil-gif:2.7.0")
implementation("io.coil-kt:coil-video:2.7.0")
implementation("androidx.media3:media3-exoplayer:1.4.1")
implementation("androidx.media3:media3-ui:1.4.1")
implementation("androidx.media3:media3-datasource:1.4.1")
implementation("androidx.media3:media3-datasource-okhttp:1.4.1")
implementation("androidx.camera:camera-core:1.4.2")
implementation("androidx.camera:camera-camera2:1.4.2")
implementation("androidx.camera:camera-lifecycle:1.4.2")
implementation("androidx.camera:camera-video:1.4.2")
implementation("androidx.camera:camera-view:1.4.2")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0")
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3")
@@ -127,3 +152,37 @@ dependencies {
kapt {
correctErrorTypes = true
}
fun registerHiltInjectorBackfillTask(variantName: String) {
val cap = variantName.replaceFirstChar { if (it.isLowerCase()) it.titlecase() else it.toString() }
val taskName = "backfill${cap}HiltApplicationInjector"
val dexBuilderTaskName = "dexBuilder$cap"
val compileJavaTaskName = "compile${cap}JavaWithJavac"
tasks.register(taskName) {
dependsOn(compileJavaTaskName)
doLast {
val javacOutput = file("$buildDir/intermediates/javac/$variantName/$compileJavaTaskName/classes")
val asmOutput = file("$buildDir/intermediates/classes/$variantName/transform${cap}ClassesWithAsm/dirs")
if (!javacOutput.exists() || !asmOutput.exists()) return@doLast
fileTree(javacOutput) {
include("**/*Application_GeneratedInjector.class")
}.forEach { source ->
val relativePath = source.relativeTo(javacOutput).path
val target = file("${asmOutput.path}/$relativePath")
if (!target.exists()) {
target.parentFile?.mkdirs()
source.copyTo(target, overwrite = true)
}
}
}
}
tasks.matching { it.name == dexBuilderTaskName }.configureEach {
dependsOn(taskName)
}
}
registerHiltInjectorBackfillTask("debug")
registerHiltInjectorBackfillTask("release")

View File

@@ -4,16 +4,18 @@
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.CAMERA" />
<application
android:name=".MessengerApplication"
android:allowBackup="true"
android:enableOnBackInvokedCallback="true"
android:icon="@android:drawable/sym_def_app_icon"
android:label="@string/app_name"
android:roundIcon="@android:drawable/sym_def_app_icon"
android:supportsRtl="true"
android:usesCleartextTraffic="true"
android:theme="@style/Theme.AppCompat.DayNight.NoActionBar">
android:theme="@style/Theme.Messenger">
<activity
android:name=".MainActivity"
android:exported="true">
@@ -46,6 +48,15 @@
<action android:name="com.google.firebase.MESSAGING_EVENT" />
</intent-filter>
</service>
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths" />
</provider>
</application>
</manifest>

View File

@@ -12,13 +12,30 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.foundation.layout.fillMaxSize
import androidx.appcompat.app.AppCompatActivity
import androidx.appcompat.app.AppCompatDelegate
import androidx.core.os.LocaleListCompat
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.runBlocking
import ru.daemonlord.messenger.core.notifications.NotificationDispatcher
import ru.daemonlord.messenger.core.notifications.NotificationIntentExtras
import ru.daemonlord.messenger.domain.settings.repository.LanguageRepository
import ru.daemonlord.messenger.domain.settings.model.AppThemeMode
import ru.daemonlord.messenger.domain.settings.repository.ThemeRepository
import ru.daemonlord.messenger.ui.navigation.MessengerNavHost
import ru.daemonlord.messenger.ui.theme.MessengerTheme
import javax.inject.Inject
@AndroidEntryPoint
class MainActivity : AppCompatActivity() {
@Inject
lateinit var themeRepository: ThemeRepository
@Inject
lateinit var languageRepository: LanguageRepository
@Inject
lateinit var notificationDispatcher: NotificationDispatcher
private var pendingInviteToken by mutableStateOf<String?>(null)
private var pendingVerifyEmailToken by mutableStateOf<String?>(null)
private var pendingResetPasswordToken by mutableStateOf<String?>(null)
@@ -27,12 +44,32 @@ class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val savedThemeMode = if (this::themeRepository.isInitialized) {
runBlocking { themeRepository.getThemeMode() }
} else {
AppThemeMode.SYSTEM
}
AppCompatDelegate.setDefaultNightMode(
when (savedThemeMode) {
AppThemeMode.LIGHT -> AppCompatDelegate.MODE_NIGHT_NO
AppThemeMode.DARK -> AppCompatDelegate.MODE_NIGHT_YES
AppThemeMode.SYSTEM -> AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM
}
)
val savedLanguageTag = if (this::languageRepository.isInitialized) {
runBlocking { languageRepository.getLanguage().tag }
} else {
null
}
val locales = savedLanguageTag?.let { LocaleListCompat.forLanguageTags(it) } ?: LocaleListCompat.getEmptyLocaleList()
AppCompatDelegate.setApplicationLocales(locales)
pendingInviteToken = intent.extractInviteToken()
pendingVerifyEmailToken = intent.extractVerifyEmailToken()
pendingResetPasswordToken = intent.extractResetPasswordToken()
val notificationPayload = intent.extractNotificationOpenPayload()
pendingNotificationChatId = notificationPayload?.first
pendingNotificationMessageId = notificationPayload?.second
notificationPayload?.first?.let(notificationDispatcher::clearChatNotifications)
enableEdgeToEdge()
setContent {
MessengerTheme {
@@ -66,6 +103,7 @@ class MainActivity : AppCompatActivity() {
if (notificationPayload != null) {
pendingNotificationChatId = notificationPayload.first
pendingNotificationMessageId = notificationPayload.second
notificationDispatcher.clearChatNotifications(notificationPayload.first)
}
}
}

View File

@@ -1,8 +1,11 @@
package ru.daemonlord.messenger
import android.app.Application
import android.os.Build
import coil.ImageLoader
import coil.ImageLoaderFactory
import coil.decode.GifDecoder
import coil.decode.ImageDecoderDecoder
import coil.disk.DiskCache
import coil.memory.MemoryCache
import com.google.firebase.crashlytics.FirebaseCrashlytics
@@ -35,6 +38,13 @@ class MessengerApplication : Application(), ImageLoaderFactory {
diskCacheDir.mkdirs()
}
return ImageLoader.Builder(this)
.components {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
add(ImageDecoderDecoder.Factory())
} else {
add(GifDecoder.Factory())
}
}
.memoryCache {
MemoryCache.Builder(this)
.maxSizePercent(0.25)

View File

@@ -15,6 +15,8 @@ import kotlin.math.abs
class NotificationDispatcher @Inject constructor(
@ApplicationContext private val context: Context,
) {
private val chatStates = linkedMapOf<Long, ChatNotificationState>()
fun showChatMessage(payload: ChatNotificationPayload) {
NotificationChannels.ensureCreated(context)
val channelId = if (payload.isMention) {
@@ -22,34 +24,126 @@ class NotificationDispatcher @Inject constructor(
} else {
NotificationChannels.CHANNEL_MESSAGES
}
val state = synchronized(chatStates) {
val existing = chatStates[payload.chatId]
if (existing != null && payload.messageId != null && existing.lastMessageId == payload.messageId) {
return
}
val updated = (existing ?: ChatNotificationState(title = payload.title))
.copy(title = payload.title)
.appendMessage(payload.body, payload.messageId)
chatStates[payload.chatId] = updated
updated
}
val openIntent = Intent(context, MainActivity::class.java)
.putExtra(NotificationIntentExtras.EXTRA_CHAT_ID, payload.chatId)
.putExtra(NotificationIntentExtras.EXTRA_MESSAGE_ID, payload.messageId ?: -1L)
.addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP)
val pendingIntent = PendingIntent.getActivity(
context,
notificationId(payload.chatId, payload.messageId),
chatNotificationId(payload.chatId),
openIntent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
val contentText = when {
state.unreadCount <= 1 -> state.lines.firstOrNull() ?: payload.body
else -> "${state.unreadCount} new messages"
}
val inboxStyle = NotificationCompat.InboxStyle()
.setBigContentTitle(state.title)
.setSummaryText("${state.unreadCount} messages")
state.lines.reversed().forEach { inboxStyle.addLine(it) }
val notification = NotificationCompat.Builder(context, channelId)
.setSmallIcon(android.R.drawable.ic_dialog_info)
.setContentTitle(payload.title)
.setContentText(payload.body)
.setStyle(NotificationCompat.BigTextStyle().bigText(payload.body))
.setContentTitle(state.title)
.setContentText(contentText)
.setStyle(inboxStyle)
.setAutoCancel(true)
.setContentIntent(pendingIntent)
.setGroup("chat_${payload.chatId}")
.setGroup(GROUP_KEY_CHATS)
.setOnlyAlertOnce(false)
.setNumber(state.unreadCount)
.setPriority(
if (payload.isMention) NotificationCompat.PRIORITY_HIGH else NotificationCompat.PRIORITY_DEFAULT
)
.build()
NotificationManagerCompat.from(context).notify(notificationId(payload.chatId, payload.messageId), notification)
val manager = NotificationManagerCompat.from(context)
manager.notify(chatNotificationId(payload.chatId), notification)
showSummaryNotification(manager)
}
private fun notificationId(chatId: Long, messageId: Long?): Int {
val raw = (chatId * 1_000_003L) + (messageId ?: 0L)
return abs(raw.toInt())
fun clearChatNotifications(chatId: Long) {
val manager = NotificationManagerCompat.from(context)
synchronized(chatStates) {
chatStates.remove(chatId)
}
manager.cancel(chatNotificationId(chatId))
showSummaryNotification(manager)
}
private fun showSummaryNotification(manager: NotificationManagerCompat) {
val snapshot = synchronized(chatStates) { chatStates.values.toList() }
if (snapshot.isEmpty()) {
manager.cancel(SUMMARY_NOTIFICATION_ID)
return
}
val totalUnread = snapshot.sumOf { it.unreadCount }
val inboxStyle = NotificationCompat.InboxStyle()
.setBigContentTitle("Benya Messenger")
.setSummaryText("$totalUnread messages")
snapshot.take(6).forEach { state ->
val preview = state.lines.firstOrNull().orEmpty()
val line = if (preview.isBlank()) {
"${state.title} (${state.unreadCount})"
} else {
"${state.title}: $preview (${state.unreadCount})"
}
inboxStyle.addLine(line)
}
val summary = NotificationCompat.Builder(context, NotificationChannels.CHANNEL_MESSAGES)
.setSmallIcon(android.R.drawable.ic_dialog_info)
.setContentTitle("Benya Messenger")
.setContentText("$totalUnread new messages in ${snapshot.size} chats")
.setStyle(inboxStyle)
.setGroup(GROUP_KEY_CHATS)
.setGroupSummary(true)
.setAutoCancel(true)
.build()
manager.notify(SUMMARY_NOTIFICATION_ID, summary)
}
private fun chatNotificationId(chatId: Long): Int {
return abs((chatId * 1_000_003L).toInt())
}
private data class ChatNotificationState(
val title: String,
val unreadCount: Int = 0,
val lines: List<String> = emptyList(),
val lastMessageId: Long? = null,
) {
fun appendMessage(body: String, messageId: Long?): ChatNotificationState {
val normalized = body.trim().ifBlank { "New message" }
val updatedLines = buildList {
add(normalized)
lines.forEach { add(it) }
}.distinct().take(MAX_LINES)
return copy(
unreadCount = unreadCount + 1,
lines = updatedLines,
lastMessageId = messageId ?: lastMessageId,
)
}
}
private companion object {
private const val GROUP_KEY_CHATS = "messenger_chats_group"
private const val SUMMARY_NOTIFICATION_ID = 0x4D53_4752 // "MSGR"
private const val MAX_LINES = 5
}
}

View File

@@ -20,10 +20,40 @@ class DataStoreTokenRepository @Inject constructor(
preferences.toTokenBundleOrNull()
}
override fun observeAccounts(): Flow<List<StoredAccount>> {
return observeTokens().map { tokens ->
if (tokens == null) emptyList() else {
val userId = tokens.accessToken.extractUserIdFromJwt() ?: return@map emptyList()
listOf(
StoredAccount(
userId = userId,
email = null,
name = "User #$userId",
username = null,
avatarUrl = null,
lastActiveAt = tokens.savedAtMillis,
)
)
}
}
}
override fun observeActiveUserId(): Flow<Long?> {
return observeTokens().map { it?.accessToken?.extractUserIdFromJwt() }
}
override suspend fun getTokens(): TokenBundle? {
return observeTokens().first()
}
override suspend fun getAccounts(): List<StoredAccount> {
return observeAccounts().first()
}
override suspend fun getActiveUserId(): Long? {
return observeActiveUserId().first()
}
override suspend fun saveTokens(tokens: TokenBundle) {
dataStore.edit { preferences ->
preferences[ACCESS_TOKEN_KEY] = tokens.accessToken
@@ -32,6 +62,20 @@ class DataStoreTokenRepository @Inject constructor(
}
}
override suspend fun upsertAccount(account: StoredAccount) {
// DataStoreTokenRepository is not used in production DI currently.
}
override suspend fun switchAccount(userId: Long): Boolean {
return getActiveUserId() == userId
}
override suspend fun removeAccount(userId: Long) {
if (getActiveUserId() == userId) {
clearTokens()
}
}
override suspend fun clearTokens() {
dataStore.edit { preferences ->
preferences.remove(ACCESS_TOKEN_KEY)
@@ -40,6 +84,10 @@ class DataStoreTokenRepository @Inject constructor(
}
}
override suspend fun clearAllTokens() {
clearTokens()
}
private fun Preferences.toTokenBundleOrNull(): TokenBundle? {
val access = this[ACCESS_TOKEN_KEY]
val refresh = this[REFRESH_TOKEN_KEY]
@@ -56,6 +104,32 @@ class DataStoreTokenRepository @Inject constructor(
)
}
private fun String.extractUserIdFromJwt(): Long? {
val payload = split('.').getOrNull(1) ?: return null
val normalized = payload
.replace('-', '+')
.replace('_', '/')
.let { source ->
when (source.length % 4) {
0 -> source
2 -> source + "=="
3 -> source + "="
else -> return null
}
}
return runCatching {
val json = String(java.util.Base64.getDecoder().decode(normalized), Charsets.UTF_8)
val marker = "\"sub\":\""
val start = json.indexOf(marker)
if (start < 0) null
else {
val valueStart = start + marker.length
val valueEnd = json.indexOf('"', valueStart)
if (valueEnd <= valueStart) null else json.substring(valueStart, valueEnd).toLongOrNull()
}
}.getOrNull()
}
private companion object {
val ACCESS_TOKEN_KEY = stringPreferencesKey("access_token")
val REFRESH_TOKEN_KEY = stringPreferencesKey("refresh_token")

View File

@@ -1,9 +1,12 @@
package ru.daemonlord.messenger.core.token
import android.content.SharedPreferences
import android.util.Base64
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import org.json.JSONArray
import org.json.JSONObject
import ru.daemonlord.messenger.di.TokenPrefs
import javax.inject.Inject
import javax.inject.Singleton
@@ -13,48 +16,277 @@ class EncryptedPrefsTokenRepository @Inject constructor(
@TokenPrefs private val sharedPreferences: SharedPreferences,
) : TokenRepository {
private val tokensFlow = MutableStateFlow(readTokens())
private val tokensFlow = MutableStateFlow<TokenBundle?>(null)
private val accountsFlow = MutableStateFlow<List<StoredAccount>>(emptyList())
private val activeUserIdFlow = MutableStateFlow<Long?>(null)
init {
migrateLegacyIfNeeded()
refreshFlows()
}
override fun observeTokens(): Flow<TokenBundle?> = tokensFlow.asStateFlow()
override fun observeAccounts(): Flow<List<StoredAccount>> = accountsFlow.asStateFlow()
override fun observeActiveUserId(): Flow<Long?> = activeUserIdFlow.asStateFlow()
override suspend fun getTokens(): TokenBundle? = tokensFlow.value
override suspend fun getAccounts(): List<StoredAccount> = accountsFlow.value
override suspend fun getActiveUserId(): Long? = activeUserIdFlow.value
override suspend fun saveTokens(tokens: TokenBundle) {
sharedPreferences.edit()
.putString(ACCESS_TOKEN_KEY, tokens.accessToken)
.putString(REFRESH_TOKEN_KEY, tokens.refreshToken)
.putLong(SAVED_AT_KEY, tokens.savedAtMillis)
.apply()
tokensFlow.value = tokens
val userId = tokens.accessToken.extractUserIdFromJwt()
?: activeUserIdFlow.value
?: return
val allTokens = readAllTokenEntries().toMutableMap()
allTokens[userId] = tokens
writeAllTokenEntries(allTokens)
writeActiveUserId(userId)
ensureAccountPlaceholder(userId = userId, lastActiveAt = tokens.savedAtMillis)
refreshFlows()
}
override suspend fun upsertAccount(account: StoredAccount) {
val accounts = readAccounts().associateBy { it.userId }.toMutableMap()
val existing = accounts[account.userId]
accounts[account.userId] = account.copy(
lastActiveAt = maxOf(existing?.lastActiveAt ?: 0L, account.lastActiveAt),
)
writeAccounts(accounts.values.toList())
refreshFlows()
}
override suspend fun switchAccount(userId: Long): Boolean {
val allTokens = readAllTokenEntries()
if (!allTokens.containsKey(userId)) {
return false
}
writeActiveUserId(userId)
refreshFlows()
return true
}
override suspend fun removeAccount(userId: Long) {
val allTokens = readAllTokenEntries().toMutableMap()
allTokens.remove(userId)
writeAllTokenEntries(allTokens)
val accounts = readAccounts().filterNot { it.userId == userId }
writeAccounts(accounts)
val active = readActiveUserId()
if (active == userId) {
val nextUserId = allTokens.entries
.maxByOrNull { it.value.savedAtMillis }
?.key
writeActiveUserId(nextUserId)
}
refreshFlows()
}
override suspend fun clearTokens() {
val active = readActiveUserId() ?: return
removeAccount(active)
}
override suspend fun clearAllTokens() {
sharedPreferences.edit()
.remove(TOKENS_JSON_KEY)
.remove(ACCOUNTS_JSON_KEY)
.remove(ACTIVE_USER_ID_KEY)
.remove(ACCESS_TOKEN_KEY)
.remove(REFRESH_TOKEN_KEY)
.remove(SAVED_AT_KEY)
.apply()
refreshFlows()
}
private fun refreshFlows() {
val activeUserId = readActiveUserId()
activeUserIdFlow.value = activeUserId
tokensFlow.value = activeUserId?.let { readAllTokenEntries()[it] }
accountsFlow.value = readAccounts().sortedByDescending { it.lastActiveAt }
}
private fun migrateLegacyIfNeeded() {
val hasModernStorage = sharedPreferences.contains(TOKENS_JSON_KEY)
if (hasModernStorage) return
val legacyAccess = sharedPreferences.getString(ACCESS_TOKEN_KEY, null)
val legacyRefresh = sharedPreferences.getString(REFRESH_TOKEN_KEY, null)
val legacySavedAt = sharedPreferences.getLong(SAVED_AT_KEY, -1L)
if (legacyAccess.isNullOrBlank() || legacyRefresh.isNullOrBlank() || legacySavedAt <= 0L) {
return
}
val userId = legacyAccess.extractUserIdFromJwt() ?: return
val token = TokenBundle(
accessToken = legacyAccess,
refreshToken = legacyRefresh,
savedAtMillis = legacySavedAt,
)
writeAllTokenEntries(mapOf(userId to token))
writeActiveUserId(userId)
writeAccounts(
listOf(
StoredAccount(
userId = userId,
email = null,
name = "User #$userId",
username = null,
avatarUrl = null,
lastActiveAt = legacySavedAt,
)
)
)
sharedPreferences.edit()
.remove(ACCESS_TOKEN_KEY)
.remove(REFRESH_TOKEN_KEY)
.remove(SAVED_AT_KEY)
.apply()
tokensFlow.value = null
}
private fun readTokens(): TokenBundle? {
val access = sharedPreferences.getString(ACCESS_TOKEN_KEY, null)
val refresh = sharedPreferences.getString(REFRESH_TOKEN_KEY, null)
val savedAt = sharedPreferences.getLong(SAVED_AT_KEY, -1L)
if (access.isNullOrBlank() || refresh.isNullOrBlank() || savedAt <= 0L) {
return null
private fun ensureAccountPlaceholder(userId: Long, lastActiveAt: Long) {
val accounts = readAccounts().associateBy { it.userId }.toMutableMap()
val existing = accounts[userId]
if (existing == null) {
accounts[userId] = StoredAccount(
userId = userId,
email = null,
name = "User #$userId",
username = null,
avatarUrl = null,
lastActiveAt = lastActiveAt,
)
} else {
accounts[userId] = existing.copy(lastActiveAt = maxOf(existing.lastActiveAt, lastActiveAt))
}
return TokenBundle(
accessToken = access,
refreshToken = refresh,
savedAtMillis = savedAt,
)
writeAccounts(accounts.values.toList())
}
private fun readActiveUserId(): Long? {
val value = sharedPreferences.getLong(ACTIVE_USER_ID_KEY, -1L)
return value.takeIf { it > 0L }
}
private fun writeActiveUserId(userId: Long?) {
sharedPreferences.edit().apply {
if (userId == null) {
remove(ACTIVE_USER_ID_KEY)
} else {
putLong(ACTIVE_USER_ID_KEY, userId)
}
}.apply()
}
private fun readAllTokenEntries(): Map<Long, TokenBundle> {
val raw = sharedPreferences.getString(TOKENS_JSON_KEY, null).orEmpty()
if (raw.isBlank()) return emptyMap()
return runCatching {
val root = JSONObject(raw)
root.keys().asSequence().mapNotNull { key ->
val userId = key.toLongOrNull() ?: return@mapNotNull null
val node = root.optJSONObject(key) ?: return@mapNotNull null
val access = node.optString("access", "")
val refresh = node.optString("refresh", "")
val savedAt = node.optLong("savedAt", -1L)
if (access.isBlank() || refresh.isBlank() || savedAt <= 0L) {
null
} else {
userId to TokenBundle(access, refresh, savedAt)
}
}.toMap()
}.getOrDefault(emptyMap())
}
private fun writeAllTokenEntries(tokens: Map<Long, TokenBundle>) {
val root = JSONObject()
tokens.forEach { (userId, token) ->
root.put(
userId.toString(),
JSONObject().apply {
put("access", token.accessToken)
put("refresh", token.refreshToken)
put("savedAt", token.savedAtMillis)
}
)
}
sharedPreferences.edit().putString(TOKENS_JSON_KEY, root.toString()).apply()
}
private fun readAccounts(): List<StoredAccount> {
val raw = sharedPreferences.getString(ACCOUNTS_JSON_KEY, null).orEmpty()
if (raw.isBlank()) return emptyList()
return runCatching {
val array = JSONArray(raw)
buildList {
for (index in 0 until array.length()) {
val node = array.optJSONObject(index) ?: continue
val userId = node.optLong("userId", -1L)
if (userId <= 0L) continue
add(
StoredAccount(
userId = userId,
email = node.optString("email", "").ifBlank { null },
name = node.optString("name", "User #$userId").ifBlank { "User #$userId" },
username = node.optString("username", "").ifBlank { null },
avatarUrl = node.optString("avatarUrl", "").ifBlank { null },
lastActiveAt = node.optLong("lastActiveAt", 0L),
)
)
}
}
}.getOrDefault(emptyList())
}
private fun writeAccounts(accounts: List<StoredAccount>) {
val array = JSONArray()
accounts.forEach { account ->
array.put(
JSONObject().apply {
put("userId", account.userId)
put("email", account.email.orEmpty())
put("name", account.name)
put("username", account.username.orEmpty())
put("avatarUrl", account.avatarUrl.orEmpty())
put("lastActiveAt", account.lastActiveAt)
}
)
}
sharedPreferences.edit().putString(ACCOUNTS_JSON_KEY, array.toString()).apply()
}
private fun String.extractUserIdFromJwt(): Long? {
val parts = split('.')
if (parts.size < 2) return null
val payload = parts[1]
val normalized = payload
.replace('-', '+')
.replace('_', '/')
.let { source ->
when (source.length % 4) {
0 -> source
2 -> source + "=="
3 -> source + "="
else -> return null
}
}
return runCatching {
val payloadJson = String(Base64.decode(normalized, Base64.DEFAULT), Charsets.UTF_8)
JSONObject(payloadJson).optString("sub").toLongOrNull()
}.getOrNull()
}
private companion object {
const val ACCESS_TOKEN_KEY = "access_token"
const val REFRESH_TOKEN_KEY = "refresh_token"
const val SAVED_AT_KEY = "tokens_saved_at"
const val TOKENS_JSON_KEY = "tokens_json"
const val ACCOUNTS_JSON_KEY = "accounts_json"
const val ACTIVE_USER_ID_KEY = "active_user_id"
}
}

View File

@@ -0,0 +1,11 @@
package ru.daemonlord.messenger.core.token
data class StoredAccount(
val userId: Long,
val email: String?,
val name: String,
val username: String?,
val avatarUrl: String?,
val lastActiveAt: Long,
)

View File

@@ -4,7 +4,15 @@ import kotlinx.coroutines.flow.Flow
interface TokenRepository {
fun observeTokens(): Flow<TokenBundle?>
fun observeAccounts(): Flow<List<StoredAccount>>
fun observeActiveUserId(): Flow<Long?>
suspend fun getTokens(): TokenBundle?
suspend fun getAccounts(): List<StoredAccount>
suspend fun getActiveUserId(): Long?
suspend fun saveTokens(tokens: TokenBundle)
suspend fun upsertAccount(account: StoredAccount)
suspend fun switchAccount(userId: Long): Boolean
suspend fun removeAccount(userId: Long)
suspend fun clearTokens()
suspend fun clearAllTokens()
}

View File

@@ -6,6 +6,7 @@ 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.ResendVerificationRequestDto
import ru.daemonlord.messenger.data.auth.dto.RequestPasswordResetDto
import ru.daemonlord.messenger.data.auth.dto.ResetPasswordRequestDto
import ru.daemonlord.messenger.data.auth.dto.RefreshTokenRequestDto
@@ -51,6 +52,10 @@ interface AuthApiService {
@POST("/api/v1/auth/request-password-reset")
suspend fun requestPasswordReset(@Body request: RequestPasswordResetDto): MessageResponseDto
@Headers("No-Auth: true")
@POST("/api/v1/auth/resend-verification")
suspend fun resendVerification(@Body request: ResendVerificationRequestDto): MessageResponseDto
@Headers("No-Auth: true")
@POST("/api/v1/auth/reset-password")
suspend fun resetPassword(@Body request: ResetPasswordRequestDto): MessageResponseDto

View File

@@ -86,6 +86,11 @@ data class RequestPasswordResetDto(
val email: String,
)
@Serializable
data class ResendVerificationRequestDto(
val email: String,
)
@Serializable
data class ResetPasswordRequestDto(
val token: String,

View File

@@ -3,15 +3,19 @@ package ru.daemonlord.messenger.data.auth.repository
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.withContext
import ru.daemonlord.messenger.core.token.TokenBundle
import ru.daemonlord.messenger.core.token.StoredAccount
import ru.daemonlord.messenger.core.token.TokenRepository
import ru.daemonlord.messenger.data.auth.api.AuthApiService
import ru.daemonlord.messenger.data.auth.dto.AuthUserDto
import ru.daemonlord.messenger.data.auth.dto.CheckEmailStatusDto
import ru.daemonlord.messenger.data.auth.dto.AuthSessionDto
import ru.daemonlord.messenger.data.auth.dto.LoginRequestDto
import ru.daemonlord.messenger.data.auth.dto.RefreshTokenRequestDto
import ru.daemonlord.messenger.data.auth.dto.RegisterRequestDto
import ru.daemonlord.messenger.data.common.ApiErrorMode
import ru.daemonlord.messenger.data.common.toAppError
import ru.daemonlord.messenger.di.IoDispatcher
import ru.daemonlord.messenger.domain.auth.model.AuthEmailStatus
import ru.daemonlord.messenger.domain.auth.model.AuthUser
import ru.daemonlord.messenger.domain.auth.model.AuthSession
import ru.daemonlord.messenger.domain.auth.repository.AuthRepository
@@ -29,12 +33,56 @@ class NetworkAuthRepository @Inject constructor(
@IoDispatcher private val ioDispatcher: CoroutineDispatcher,
) : AuthRepository {
override suspend fun login(email: String, password: String): AppResult<AuthUser> = withContext(ioDispatcher) {
override suspend fun checkEmailStatus(email: String): AppResult<AuthEmailStatus> = withContext(ioDispatcher) {
val normalized = email.trim().lowercase()
if (normalized.isBlank()) return@withContext AppResult.Error(AppError.Server("Email is required"))
try {
AppResult.Success(authApiService.checkEmailStatus(normalized).toDomain())
} catch (error: Throwable) {
AppResult.Error(error.toAppError())
}
}
override suspend fun register(
email: String,
name: String,
username: String,
password: String,
): AppResult<Unit> = withContext(ioDispatcher) {
val normalizedEmail = email.trim().lowercase()
val normalizedName = name.trim()
val normalizedUsername = username.trim().removePrefix("@")
if (normalizedEmail.isBlank() || normalizedName.isBlank() || normalizedUsername.isBlank() || password.isBlank()) {
return@withContext AppResult.Error(AppError.Server("Email, name, username and password are required"))
}
try {
authApiService.register(
request = RegisterRequestDto(
email = normalizedEmail,
name = normalizedName,
username = normalizedUsername,
password = password,
)
)
AppResult.Success(Unit)
} catch (error: Throwable) {
AppResult.Error(error.toAppError())
}
}
override suspend fun login(
email: String,
password: String,
otpCode: String?,
recoveryCode: String?,
): AppResult<AuthUser> = withContext(ioDispatcher) {
try {
val tokenResponse = authApiService.login(
request = LoginRequestDto(
email = email,
password = password,
otpCode = otpCode?.trim()?.ifBlank { null },
recoveryCode = recoveryCode?.trim()?.ifBlank { null },
)
)
tokenRepository.saveTokens(
@@ -45,7 +93,13 @@ class NetworkAuthRepository @Inject constructor(
)
)
pushTokenSyncManager.triggerBestEffortSync()
getMe()
when (val meResult = getMe()) {
is AppResult.Success -> {
tokenRepository.upsertAccount(meResult.data.toStoredAccount())
meResult
}
is AppResult.Error -> meResult
}
} catch (error: Throwable) {
AppResult.Error(error.toAppError(mode = ApiErrorMode.LOGIN))
}
@@ -76,6 +130,7 @@ class NetworkAuthRepository @Inject constructor(
override suspend fun getMe(): AppResult<AuthUser> = withContext(ioDispatcher) {
try {
val user = authApiService.me().toDomain()
tokenRepository.upsertAccount(user.toStoredAccount())
AppResult.Success(user)
} catch (error: Throwable) {
AppResult.Error(error.toAppError())
@@ -92,7 +147,10 @@ class NetworkAuthRepository @Inject constructor(
}
when (val meResult = getMe()) {
is AppResult.Success -> meResult
is AppResult.Success -> {
pushTokenSyncManager.triggerBestEffortSync()
meResult
}
is AppResult.Error -> {
if (meResult.reason is AppError.Unauthorized) {
tokenRepository.clearTokens()
@@ -150,6 +208,17 @@ class NetworkAuthRepository @Inject constructor(
)
}
private fun AuthUser.toStoredAccount(): StoredAccount {
return StoredAccount(
userId = id,
email = email,
name = name,
username = username,
avatarUrl = avatarUrl,
lastActiveAt = System.currentTimeMillis(),
)
}
private fun AuthSessionDto.toDomain(): AuthSession {
return AuthSession(
jti = jti,
@@ -161,4 +230,13 @@ class NetworkAuthRepository @Inject constructor(
)
}
private fun CheckEmailStatusDto.toDomain(): AuthEmailStatus {
return AuthEmailStatus(
email = email,
registered = registered,
emailVerified = emailVerified,
twofaEnabled = twofaEnabled,
)
}
}

View File

@@ -3,6 +3,7 @@ package ru.daemonlord.messenger.data.common
import retrofit2.HttpException
import ru.daemonlord.messenger.domain.common.AppError
import java.io.IOException
import org.json.JSONObject
enum class ApiErrorMode {
DEFAULT,
@@ -13,15 +14,24 @@ fun Throwable.toAppError(mode: ApiErrorMode = ApiErrorMode.DEFAULT): AppError {
return when (this) {
is IOException -> AppError.Network
is HttpException -> when (mode) {
ApiErrorMode.LOGIN -> when (code()) {
400, 401, 403 -> AppError.InvalidCredentials
else -> AppError.Server(message = message())
ApiErrorMode.LOGIN -> {
val detail = extractErrorDetail()
when (code()) {
400, 401, 403 -> {
if (detail?.contains("2fa code required", ignoreCase = true) == true) {
AppError.Server(message = detail)
} else {
AppError.InvalidCredentials
}
}
else -> AppError.Server(message = detail ?: message())
}
}
ApiErrorMode.DEFAULT -> if (code() == 401 || code() == 403) {
AppError.Unauthorized
} else {
AppError.Server(message = message())
AppError.Server(message = extractErrorDetail() ?: message())
}
}
@@ -29,3 +39,11 @@ fun Throwable.toAppError(mode: ApiErrorMode = ApiErrorMode.DEFAULT): AppError {
}
}
private fun HttpException.extractErrorDetail(): String? {
return runCatching {
val body = response()?.errorBody()?.string()?.trim().orEmpty()
if (body.isBlank()) return@runCatching null
val json = JSONObject(body)
json.optString("detail").takeIf { it.isNotBlank() }
}.getOrNull()
}

View File

@@ -2,6 +2,8 @@ package ru.daemonlord.messenger.data.media.repository
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.graphics.Canvas
import android.graphics.Movie
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.withContext
import okhttp3.MediaType.Companion.toMediaTypeOrNull
@@ -16,6 +18,7 @@ import ru.daemonlord.messenger.di.IoDispatcher
import ru.daemonlord.messenger.di.RefreshClient
import ru.daemonlord.messenger.domain.common.AppError
import ru.daemonlord.messenger.domain.common.AppResult
import ru.daemonlord.messenger.domain.media.model.UploadedAttachment
import ru.daemonlord.messenger.domain.media.repository.MediaRepository
import java.io.ByteArrayOutputStream
import kotlin.math.roundToInt
@@ -34,7 +37,7 @@ class NetworkMediaRepository @Inject constructor(
fileName: String,
mimeType: String,
bytes: ByteArray,
): AppResult<Unit> = withContext(ioDispatcher) {
): AppResult<UploadedAttachment> = withContext(ioDispatcher) {
try {
val uploadPayload = prepareUploadPayload(
fileName = fileName,
@@ -74,7 +77,13 @@ class NetworkMediaRepository @Inject constructor(
fileSize = uploadPayload.bytes.size.toLong(),
)
)
AppResult.Success(Unit)
AppResult.Success(
UploadedAttachment(
fileUrl = uploadInfo.fileUrl,
fileType = uploadPayload.mimeType,
fileSize = uploadPayload.bytes.size.toLong(),
)
)
} catch (error: Throwable) {
AppResult.Error(error.toAppError())
}
@@ -89,7 +98,26 @@ class NetworkMediaRepository @Inject constructor(
return UploadPayload(fileName = fileName, mimeType = mimeType, bytes = bytes)
}
if (mimeType.equals("image/gif", ignoreCase = true)) {
return UploadPayload(fileName = fileName, mimeType = mimeType, bytes = bytes)
val still = gifToPngPayload(fileName = fileName, bytes = bytes)
if (still != null) return still
val bitmapFallback = runCatching { BitmapFactory.decodeByteArray(bytes, 0, bytes.size) }.getOrNull()
if (bitmapFallback != null) {
val output = ByteArrayOutputStream()
val compressed = bitmapFallback.compress(Bitmap.CompressFormat.PNG, 100, output)
bitmapFallback.recycle()
if (compressed) {
val pngBytes = output.toByteArray()
if (pngBytes.isNotEmpty()) {
val baseName = fileName.substringBeforeLast('.').ifBlank { "gif" }
return UploadPayload(
fileName = "${baseName}-still.png",
mimeType = "image/png",
bytes = pngBytes,
)
}
}
}
return UploadPayload(fileName = fileName, mimeType = "application/octet-stream", bytes = bytes)
}
val source = runCatching { BitmapFactory.decodeByteArray(bytes, 0, bytes.size) }.getOrNull()
?: return UploadPayload(fileName = fileName, mimeType = mimeType, bytes = bytes)
@@ -133,4 +161,32 @@ class NetworkMediaRepository @Inject constructor(
val mimeType: String,
val bytes: ByteArray,
)
private fun gifToPngPayload(
fileName: String,
bytes: ByteArray,
): UploadPayload? {
val movie = runCatching { Movie.decodeByteArray(bytes, 0, bytes.size) }.getOrNull() ?: return null
val width = movie.width().coerceAtLeast(1)
val height = movie.height().coerceAtLeast(1)
val bitmap = runCatching { Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888) }.getOrNull() ?: return null
return try {
val canvas = Canvas(bitmap)
movie.setTime(0)
movie.draw(canvas, 0f, 0f)
val output = ByteArrayOutputStream()
val compressed = bitmap.compress(Bitmap.CompressFormat.PNG, 100, output)
if (!compressed) return null
val pngBytes = output.toByteArray()
if (pngBytes.isEmpty()) return null
val baseName = fileName.substringBeforeLast('.').ifBlank { "gif" }
UploadPayload(
fileName = "${baseName}-still.png",
mimeType = "image/png",
bytes = pngBytes,
)
} finally {
bitmap.recycle()
}
}
}

View File

@@ -48,6 +48,7 @@ fun MessageLocalModel.toDomain(currentUserId: Long?): MessageItem {
chatId = message.chatId,
senderId = message.senderId,
senderDisplayName = message.senderDisplayName,
senderUsername = message.senderUsername,
type = message.type,
text = message.text,
createdAt = message.createdAt,

View File

@@ -16,6 +16,7 @@ import ru.daemonlord.messenger.data.common.toAppError
import ru.daemonlord.messenger.data.message.local.dao.MessageDao
import ru.daemonlord.messenger.data.message.local.dao.PendingMessageActionDao
import ru.daemonlord.messenger.data.message.local.entity.MessageEntity
import ru.daemonlord.messenger.data.message.local.entity.MessageAttachmentEntity
import ru.daemonlord.messenger.data.message.local.entity.PendingMessageActionEntity
import ru.daemonlord.messenger.data.message.mapper.toDomain
import ru.daemonlord.messenger.data.message.mapper.toEntity
@@ -360,7 +361,27 @@ class NetworkMessageRepository @Inject constructor(
)) {
is AppResult.Success -> {
messageDao.deleteMessage(tempId)
syncRecentMessages(chatId = chatId)
messageDao.upsertMessages(listOf(created.toEntity()))
messageDao.upsertAttachments(
listOf(
MessageAttachmentEntity(
id = -System.currentTimeMillis(),
messageId = created.id,
fileUrl = mediaResult.data.fileUrl,
fileType = mediaResult.data.fileType,
fileSize = mediaResult.data.fileSize,
waveformPointsJson = null,
)
)
)
chatDao.updateLastMessage(
chatId = chatId,
lastMessageText = created.text,
lastMessageType = created.type,
lastMessageCreatedAt = created.createdAt,
updatedSortAt = created.createdAt,
)
AppResult.Success(Unit)
}
is AppResult.Error -> {
@@ -374,6 +395,70 @@ class NetworkMessageRepository @Inject constructor(
}
}
override suspend fun sendImageUrlMessage(
chatId: Long,
imageUrl: String,
replyToMessageId: Long?,
): AppResult<Unit> = withContext(ioDispatcher) {
val normalizedUrl = imageUrl.trim()
if (normalizedUrl.isBlank()) {
return@withContext AppResult.Error(AppError.Server("Image URL is empty"))
}
val tempId = -System.currentTimeMillis()
val now = java.time.Instant.now().toString()
val tempMessage = MessageEntity(
id = tempId,
chatId = chatId,
senderId = currentUserId ?: 0L,
senderDisplayName = null,
senderUsername = null,
senderAvatarUrl = null,
replyToMessageId = replyToMessageId,
replyPreviewText = null,
replyPreviewSenderName = null,
forwardedFromMessageId = null,
forwardedFromDisplayName = null,
type = "image",
text = normalizedUrl,
status = "pending",
attachmentWaveformJson = null,
createdAt = now,
updatedAt = null,
)
messageDao.upsertMessages(listOf(tempMessage))
chatDao.updateLastMessage(
chatId = chatId,
lastMessageText = normalizedUrl,
lastMessageType = "image",
lastMessageCreatedAt = now,
updatedSortAt = now,
)
try {
val sent = messageApiService.sendMessage(
request = MessageCreateRequestDto(
chatId = chatId,
type = "image",
text = normalizedUrl,
clientMessageId = UUID.randomUUID().toString(),
replyToMessageId = replyToMessageId,
)
)
messageDao.deleteMessage(tempId)
messageDao.upsertMessages(listOf(sent.toEntity()))
chatDao.updateLastMessage(
chatId = chatId,
lastMessageText = sent.text,
lastMessageType = sent.type,
lastMessageCreatedAt = sent.createdAt,
updatedSortAt = sent.createdAt,
)
AppResult.Success(Unit)
} catch (error: Throwable) {
messageDao.deleteMessage(tempId)
AppResult.Error(error.toAppError())
}
}
override suspend fun markMessageDelivered(chatId: Long, messageId: Long): AppResult<Unit> = withContext(ioDispatcher) {
try {
messageApiService.updateMessageStatus(
@@ -490,11 +575,16 @@ class NetworkMessageRepository @Inject constructor(
mimeType: String,
fileName: String,
): String {
val normalizedMime = mimeType.lowercase()
val normalizedName = fileName.lowercase()
return when {
mimeType.startsWith("image/") -> "image"
mimeType.startsWith("video/") -> "video"
mimeType.startsWith("audio/") && fileName.startsWith("voice_", ignoreCase = true) -> "voice"
mimeType.startsWith("audio/") -> "audio"
normalizedName.startsWith("circle_") -> "circle_video"
normalizedMime == "image/gif" || normalizedName.endsWith(".gif") || normalizedName.startsWith("gif_") -> "image"
normalizedName.startsWith("sticker_") || normalizedMime == "image/webp" -> "image"
normalizedMime.startsWith("image/") -> "image"
normalizedMime.startsWith("video/") -> "video"
normalizedMime.startsWith("audio/") && normalizedName.startsWith("voice_") -> "voice"
normalizedMime.startsWith("audio/") -> "audio"
else -> "file"
}
}
@@ -597,6 +687,7 @@ class NetworkMessageRepository @Inject constructor(
chatId = chatId,
senderId = senderId,
senderDisplayName = senderDisplayName,
senderUsername = senderUsername,
type = type,
text = text,
createdAt = createdAt,

View File

@@ -0,0 +1,10 @@
package ru.daemonlord.messenger.data.notifications.api
import retrofit2.http.GET
import retrofit2.http.Query
import ru.daemonlord.messenger.data.notifications.dto.NotificationReadDto
interface NotificationApiService {
@GET("/api/v1/notifications")
suspend fun list(@Query("limit") limit: Int = 50): List<NotificationReadDto>
}

View File

@@ -0,0 +1,16 @@
package ru.daemonlord.messenger.data.notifications.dto
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class NotificationReadDto(
val id: Long,
@SerialName("user_id")
val userId: Long,
@SerialName("event_type")
val eventType: String,
val payload: String,
@SerialName("created_at")
val createdAt: String,
)

View File

@@ -102,7 +102,12 @@ class RealtimeEventParser @Inject constructor(
"message_read" -> {
val chatId = payload["chat_id"].longOrNull() ?: return RealtimeEvent.Ignored
val messageId = payload["message_id"].longOrNull() ?: return RealtimeEvent.Ignored
RealtimeEvent.MessageRead(chatId = chatId, messageId = messageId)
RealtimeEvent.MessageRead(
chatId = chatId,
messageId = messageId,
userId = payload["user_id"].longOrNull(),
lastReadMessageId = payload["last_read_message_id"].longOrNull(),
)
}
"typing_start" -> {

View File

@@ -0,0 +1,44 @@
package ru.daemonlord.messenger.data.settings.repository
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey
import javax.inject.Inject
import javax.inject.Singleton
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import ru.daemonlord.messenger.domain.settings.model.AppLanguage
import ru.daemonlord.messenger.domain.settings.repository.LanguageRepository
@Singleton
class DataStoreLanguageRepository @Inject constructor(
private val dataStore: DataStore<Preferences>,
) : LanguageRepository {
override fun observeLanguage(): Flow<AppLanguage> {
return dataStore.data.map { prefs ->
AppLanguage.fromTag(prefs[LANGUAGE_TAG_KEY])
}
}
override suspend fun getLanguage(): AppLanguage {
return observeLanguage().first()
}
override suspend fun setLanguage(language: AppLanguage) {
dataStore.edit { prefs ->
if (language.tag == null) {
prefs.remove(LANGUAGE_TAG_KEY)
} else {
prefs[LANGUAGE_TAG_KEY] = language.tag
}
}
}
private companion object {
val LANGUAGE_TAG_KEY = stringPreferencesKey("app_language_tag")
}
}

View File

@@ -0,0 +1,42 @@
package ru.daemonlord.messenger.data.settings.repository
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import ru.daemonlord.messenger.domain.settings.model.AppThemeMode
import ru.daemonlord.messenger.domain.settings.repository.ThemeRepository
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class DataStoreThemeRepository @Inject constructor(
private val dataStore: DataStore<Preferences>,
) : ThemeRepository {
override fun observeThemeMode(): Flow<AppThemeMode> {
return dataStore.data.map { prefs ->
prefs[THEME_MODE_KEY]
?.let { raw -> runCatching { AppThemeMode.valueOf(raw) }.getOrNull() }
?: AppThemeMode.SYSTEM
}
}
override suspend fun getThemeMode(): AppThemeMode {
return observeThemeMode().first()
}
override suspend fun setThemeMode(mode: AppThemeMode) {
dataStore.edit { prefs ->
prefs[THEME_MODE_KEY] = mode.name
}
}
private companion object {
val THEME_MODE_KEY = stringPreferencesKey("app_theme_mode")
}
}

View File

@@ -10,12 +10,15 @@ 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.ResendVerificationRequestDto
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.data.notifications.api.NotificationApiService
import ru.daemonlord.messenger.data.notifications.dto.NotificationReadDto
import ru.daemonlord.messenger.di.RefreshClient
import ru.daemonlord.messenger.data.user.api.UserApiService
import ru.daemonlord.messenger.data.user.dto.AddContactByEmailRequestDto
@@ -23,11 +26,16 @@ 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.model.AccountNotification
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 kotlinx.serialization.json.Json
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import kotlinx.serialization.json.contentOrNull
import javax.inject.Inject
import javax.inject.Singleton
@@ -36,6 +44,7 @@ class NetworkAccountRepository @Inject constructor(
private val authApiService: AuthApiService,
private val userApiService: UserApiService,
private val mediaApiService: MediaApiService,
private val notificationApiService: NotificationApiService,
@RefreshClient private val uploadClient: OkHttpClient,
@IoDispatcher private val ioDispatcher: CoroutineDispatcher,
) : AccountRepository {
@@ -206,6 +215,14 @@ class NetworkAccountRepository @Inject constructor(
}
}
override suspend fun listNotifications(limit: Int): AppResult<List<AccountNotification>> = withContext(ioDispatcher) {
try {
AppResult.Success(notificationApiService.list(limit = limit).map { it.toDomain() })
} catch (error: Throwable) {
AppResult.Error(error.toAppError())
}
}
override suspend fun revokeSession(jti: String): AppResult<Unit> = withContext(ioDispatcher) {
try {
authApiService.revokeSession(jti)
@@ -242,6 +259,15 @@ class NetworkAccountRepository @Inject constructor(
}
}
override suspend fun resendVerification(email: String): AppResult<String> = withContext(ioDispatcher) {
try {
val result = authApiService.resendVerification(ResendVerificationRequestDto(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(
@@ -336,4 +362,24 @@ class NetworkAccountRepository @Inject constructor(
avatarUrl = avatarUrl,
)
}
private fun NotificationReadDto.toDomain(): AccountNotification {
val payloadObject = runCatching {
Json.parseToJsonElement(payload).jsonObject
}.getOrNull()
val chatId = payloadObject?.get("chat_id")?.jsonPrimitive?.contentOrNull?.toLongOrNull()
val messageId = payloadObject?.get("message_id")?.jsonPrimitive?.contentOrNull?.toLongOrNull()
val text = payloadObject?.get("text")?.jsonPrimitive?.contentOrNull
?: payloadObject?.get("body")?.jsonPrimitive?.contentOrNull
?: payloadObject?.get("title")?.jsonPrimitive?.contentOrNull
return AccountNotification(
id = id,
eventType = eventType,
createdAt = createdAt,
payloadRaw = payload,
chatId = chatId,
messageId = messageId,
text = text,
)
}
}

View File

@@ -19,6 +19,7 @@ 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.notifications.api.NotificationApiService
import ru.daemonlord.messenger.data.notifications.api.PushTokenApiService
import ru.daemonlord.messenger.data.search.api.SearchApiService
import ru.daemonlord.messenger.data.user.api.UserApiService
@@ -161,4 +162,10 @@ object NetworkModule {
fun providePushTokenApiService(retrofit: Retrofit): PushTokenApiService {
return retrofit.create(PushTokenApiService::class.java)
}
@Provides
@Singleton
fun provideNotificationApiService(retrofit: Retrofit): NotificationApiService {
return retrofit.create(NotificationApiService::class.java)
}
}

View File

@@ -12,6 +12,8 @@ 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.search.repository.NetworkSearchRepository
import ru.daemonlord.messenger.data.settings.repository.DataStoreLanguageRepository
import ru.daemonlord.messenger.data.settings.repository.DataStoreThemeRepository
import ru.daemonlord.messenger.data.user.repository.NetworkAccountRepository
import ru.daemonlord.messenger.domain.account.repository.AccountRepository
import ru.daemonlord.messenger.domain.auth.repository.AuthRepository
@@ -22,6 +24,8 @@ import ru.daemonlord.messenger.domain.media.repository.MediaRepository
import ru.daemonlord.messenger.domain.message.repository.MessageRepository
import ru.daemonlord.messenger.domain.notifications.repository.NotificationSettingsRepository
import ru.daemonlord.messenger.domain.search.repository.SearchRepository
import ru.daemonlord.messenger.domain.settings.repository.LanguageRepository
import ru.daemonlord.messenger.domain.settings.repository.ThemeRepository
import javax.inject.Singleton
@Module
@@ -81,4 +85,16 @@ abstract class RepositoryModule {
abstract fun bindSearchRepository(
repository: NetworkSearchRepository,
): SearchRepository
@Binds
@Singleton
abstract fun bindThemeRepository(
repository: DataStoreThemeRepository,
): ThemeRepository
@Binds
@Singleton
abstract fun bindLanguageRepository(
repository: DataStoreLanguageRepository,
): LanguageRepository
}

View File

@@ -0,0 +1,11 @@
package ru.daemonlord.messenger.domain.account.model
data class AccountNotification(
val id: Long,
val eventType: String,
val createdAt: String,
val payloadRaw: String,
val chatId: Long? = null,
val messageId: Long? = null,
val text: String? = null,
)

View File

@@ -1,6 +1,7 @@
package ru.daemonlord.messenger.domain.account.repository
import ru.daemonlord.messenger.domain.account.model.UserSearchItem
import ru.daemonlord.messenger.domain.account.model.AccountNotification
import ru.daemonlord.messenger.domain.auth.model.AuthSession
import ru.daemonlord.messenger.domain.auth.model.AuthUser
import ru.daemonlord.messenger.domain.common.AppResult
@@ -33,9 +34,11 @@ interface AccountRepository {
suspend fun blockUser(userId: Long): AppResult<Unit>
suspend fun unblockUser(userId: Long): AppResult<Unit>
suspend fun listSessions(): AppResult<List<AuthSession>>
suspend fun listNotifications(limit: Int = 50): AppResult<List<AccountNotification>>
suspend fun revokeSession(jti: String): AppResult<Unit>
suspend fun revokeAllSessions(): AppResult<Unit>
suspend fun verifyEmail(token: String): AppResult<String>
suspend fun resendVerification(email: 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>>

View File

@@ -0,0 +1,9 @@
package ru.daemonlord.messenger.domain.auth.model
data class AuthEmailStatus(
val email: String,
val registered: Boolean,
val emailVerified: Boolean,
val twofaEnabled: Boolean,
)

View File

@@ -2,10 +2,18 @@ package ru.daemonlord.messenger.domain.auth.repository
import ru.daemonlord.messenger.domain.auth.model.AuthUser
import ru.daemonlord.messenger.domain.auth.model.AuthSession
import ru.daemonlord.messenger.domain.auth.model.AuthEmailStatus
import ru.daemonlord.messenger.domain.common.AppResult
interface AuthRepository {
suspend fun login(email: String, password: String): AppResult<AuthUser>
suspend fun checkEmailStatus(email: String): AppResult<AuthEmailStatus>
suspend fun register(email: String, name: String, username: String, password: String): AppResult<Unit>
suspend fun login(
email: String,
password: String,
otpCode: String? = null,
recoveryCode: String? = null,
): AppResult<AuthUser>
suspend fun refreshTokens(): AppResult<Unit>
suspend fun getMe(): AppResult<AuthUser>
suspend fun restoreSession(): AppResult<AuthUser>

View File

@@ -0,0 +1,15 @@
package ru.daemonlord.messenger.domain.auth.usecase
import ru.daemonlord.messenger.domain.auth.model.AuthEmailStatus
import ru.daemonlord.messenger.domain.auth.repository.AuthRepository
import ru.daemonlord.messenger.domain.common.AppResult
import javax.inject.Inject
class CheckEmailStatusUseCase @Inject constructor(
private val authRepository: AuthRepository,
) {
suspend operator fun invoke(email: String): AppResult<AuthEmailStatus> {
return authRepository.checkEmailStatus(email)
}
}

View File

@@ -8,7 +8,17 @@ import javax.inject.Inject
class LoginUseCase @Inject constructor(
private val authRepository: AuthRepository,
) {
suspend operator fun invoke(email: String, password: String): AppResult<AuthUser> {
return authRepository.login(email = email, password = password)
suspend operator fun invoke(
email: String,
password: String,
otpCode: String? = null,
recoveryCode: String? = null,
): AppResult<AuthUser> {
return authRepository.login(
email = email,
password = password,
otpCode = otpCode,
recoveryCode = recoveryCode,
)
}
}

View File

@@ -0,0 +1,24 @@
package ru.daemonlord.messenger.domain.auth.usecase
import ru.daemonlord.messenger.domain.auth.repository.AuthRepository
import ru.daemonlord.messenger.domain.common.AppResult
import javax.inject.Inject
class RegisterUseCase @Inject constructor(
private val authRepository: AuthRepository,
) {
suspend operator fun invoke(
email: String,
name: String,
username: String,
password: String,
): AppResult<Unit> {
return authRepository.register(
email = email,
name = name,
username = username,
password = password,
)
}
}

View File

@@ -0,0 +1,8 @@
package ru.daemonlord.messenger.domain.media.model
data class UploadedAttachment(
val fileUrl: String,
val fileType: String,
val fileSize: Long,
)

View File

@@ -1,6 +1,7 @@
package ru.daemonlord.messenger.domain.media.repository
import ru.daemonlord.messenger.domain.common.AppResult
import ru.daemonlord.messenger.domain.media.model.UploadedAttachment
interface MediaRepository {
suspend fun uploadAndAttach(
@@ -8,5 +9,5 @@ interface MediaRepository {
fileName: String,
mimeType: String,
bytes: ByteArray,
): AppResult<Unit>
): AppResult<UploadedAttachment>
}

View File

@@ -1,6 +1,7 @@
package ru.daemonlord.messenger.domain.media.usecase
import ru.daemonlord.messenger.domain.common.AppResult
import ru.daemonlord.messenger.domain.media.model.UploadedAttachment
import ru.daemonlord.messenger.domain.media.repository.MediaRepository
import javax.inject.Inject
@@ -12,7 +13,7 @@ class UploadAndAttachMediaUseCase @Inject constructor(
fileName: String,
mimeType: String,
bytes: ByteArray,
): AppResult<Unit> {
): AppResult<UploadedAttachment> {
return mediaRepository.uploadAndAttach(
messageId = messageId,
fileName = fileName,

View File

@@ -5,6 +5,7 @@ data class MessageItem(
val chatId: Long,
val senderId: Long,
val senderDisplayName: String?,
val senderUsername: String? = null,
val type: String,
val text: String?,
val createdAt: String,

View File

@@ -20,6 +20,11 @@ interface MessageRepository {
caption: String? = null,
replyToMessageId: Long? = null,
): AppResult<Unit>
suspend fun sendImageUrlMessage(
chatId: Long,
imageUrl: String,
replyToMessageId: Long? = null,
): AppResult<Unit>
suspend fun editMessage(messageId: Long, newText: String): AppResult<Unit>
suspend fun deleteMessage(messageId: Long, forAll: Boolean): AppResult<Unit>
suspend fun markMessageDelivered(chatId: Long, messageId: Long): AppResult<Unit>

View File

@@ -0,0 +1,21 @@
package ru.daemonlord.messenger.domain.message.usecase
import ru.daemonlord.messenger.domain.common.AppResult
import ru.daemonlord.messenger.domain.message.repository.MessageRepository
import javax.inject.Inject
class SendImageUrlMessageUseCase @Inject constructor(
private val messageRepository: MessageRepository,
) {
suspend operator fun invoke(
chatId: Long,
imageUrl: String,
replyToMessageId: Long? = null,
): AppResult<Unit> {
return messageRepository.sendImageUrlMessage(
chatId = chatId,
imageUrl = imageUrl,
replyToMessageId = replyToMessageId,
)
}
}

View File

@@ -52,6 +52,8 @@ sealed interface RealtimeEvent {
data class MessageRead(
val chatId: Long,
val messageId: Long,
val userId: Long?,
val lastReadMessageId: Long?,
) : RealtimeEvent
data class TypingStart(

View File

@@ -10,9 +10,11 @@ import ru.daemonlord.messenger.data.chat.local.dao.ChatDao
import ru.daemonlord.messenger.core.notifications.ActiveChatTracker
import ru.daemonlord.messenger.core.notifications.ChatNotificationPayload
import ru.daemonlord.messenger.core.notifications.NotificationDispatcher
import ru.daemonlord.messenger.core.token.TokenRepository
import ru.daemonlord.messenger.data.message.local.dao.MessageDao
import ru.daemonlord.messenger.data.message.local.entity.MessageEntity
import ru.daemonlord.messenger.domain.chat.repository.ChatRepository
import ru.daemonlord.messenger.domain.notifications.repository.NotificationSettingsRepository
import ru.daemonlord.messenger.domain.notifications.usecase.ShouldShowMessageNotificationUseCase
import ru.daemonlord.messenger.domain.realtime.RealtimeManager
import ru.daemonlord.messenger.domain.realtime.model.RealtimeEvent
@@ -27,6 +29,8 @@ class HandleRealtimeEventsUseCase @Inject constructor(
private val messageDao: MessageDao,
private val notificationDispatcher: NotificationDispatcher,
private val activeChatTracker: ActiveChatTracker,
private val tokenRepository: TokenRepository,
private val notificationSettingsRepository: NotificationSettingsRepository,
private val shouldShowMessageNotificationUseCase: ShouldShowMessageNotificationUseCase,
) {
@@ -81,16 +85,36 @@ class HandleRealtimeEventsUseCase @Inject constructor(
} else {
chatDao.incrementUnread(chatId = event.chatId)
}
val activeUserId = tokenRepository.getActiveUserId()
val myUsername = activeUserId?.let { userId ->
tokenRepository.getAccounts()
.firstOrNull { it.userId == userId }
?.username
?.trim()
?.removePrefix("@")
?.lowercase()
}
val isMentionByText = if (myUsername.isNullOrBlank()) {
false
} else {
Regex("(^|\\W)@${Regex.escape(myUsername)}(\\W|$)", RegexOption.IGNORE_CASE)
.containsMatchIn(event.text.orEmpty())
}
val isMention = event.isMention || isMentionByText
val muted = chatDao.isChatMuted(event.chatId) == true
val shouldNotify = shouldShowMessageNotificationUseCase(
chatId = event.chatId,
isMention = event.isMention,
isMention = isMention,
serverMuted = muted,
)
if (activeChatId != event.chatId && shouldNotify) {
val title = chatDao.getChatDisplayTitle(event.chatId) ?: "New message"
val body = event.text?.takeIf { it.isNotBlank() }
?: when (event.type?.lowercase()) {
val previewEnabled = notificationSettingsRepository.getSettings().previewEnabled
val body = (if (previewEnabled) {
event.text?.takeIf { it.isNotBlank() }
} else {
null
}) ?: when (event.type?.lowercase()) {
"image" -> "Photo"
"video" -> "Video"
"audio" -> "Audio"
@@ -104,7 +128,7 @@ class HandleRealtimeEventsUseCase @Inject constructor(
messageId = event.messageId,
title = title,
body = body,
isMention = event.isMention,
isMention = isMention,
)
)
}
@@ -168,6 +192,7 @@ class HandleRealtimeEventsUseCase @Inject constructor(
messageId = event.messageId,
status = "read",
)
chatRepository.refreshChat(chatId = event.chatId)
}
is RealtimeEvent.TypingStart -> Unit

View File

@@ -0,0 +1,15 @@
package ru.daemonlord.messenger.domain.settings.model
enum class AppLanguage(val tag: String?) {
SYSTEM(null),
RUSSIAN("ru"),
ENGLISH("en");
companion object {
fun fromTag(tag: String?): AppLanguage {
if (tag.isNullOrBlank()) return SYSTEM
return entries.firstOrNull { it.tag.equals(tag, ignoreCase = true) } ?: SYSTEM
}
}
}

View File

@@ -0,0 +1,8 @@
package ru.daemonlord.messenger.domain.settings.model
enum class AppThemeMode {
LIGHT,
DARK,
SYSTEM,
}

View File

@@ -0,0 +1,11 @@
package ru.daemonlord.messenger.domain.settings.repository
import kotlinx.coroutines.flow.Flow
import ru.daemonlord.messenger.domain.settings.model.AppLanguage
interface LanguageRepository {
fun observeLanguage(): Flow<AppLanguage>
suspend fun getLanguage(): AppLanguage
suspend fun setLanguage(language: AppLanguage)
}

View File

@@ -0,0 +1,11 @@
package ru.daemonlord.messenger.domain.settings.repository
import kotlinx.coroutines.flow.Flow
import ru.daemonlord.messenger.domain.settings.model.AppThemeMode
interface ThemeRepository {
fun observeThemeMode(): Flow<AppThemeMode>
suspend fun getThemeMode(): AppThemeMode
suspend fun setThemeMode(mode: AppThemeMode)
}

View File

@@ -4,6 +4,7 @@ import android.util.Log
import com.google.firebase.messaging.FirebaseMessagingService
import com.google.firebase.messaging.RemoteMessage
import dagger.hilt.android.AndroidEntryPoint
import ru.daemonlord.messenger.core.notifications.ActiveChatTracker
import ru.daemonlord.messenger.core.notifications.NotificationDispatcher
import javax.inject.Inject
@@ -16,8 +17,14 @@ class MessengerFirebaseMessagingService : FirebaseMessagingService() {
@Inject
lateinit var pushTokenSyncManager: PushTokenSyncManager
@Inject
lateinit var activeChatTracker: ActiveChatTracker
override fun onMessageReceived(message: RemoteMessage) {
val payload = PushPayloadParser.parse(message) ?: return
if (activeChatTracker.activeChatId.value == payload.chatId) {
return
}
notificationDispatcher.showChatMessage(payload)
}

View File

@@ -29,7 +29,8 @@ object PushPayloadParser {
?: data["text"]
?: "Open chat"
val isMention = data["is_mention"]?.equals("true", ignoreCase = true) == true ||
data["mention"]?.equals("true", ignoreCase = true) == true
data["mention"]?.equals("true", ignoreCase = true) == true ||
data["type"]?.equals("mention", ignoreCase = true) == true
return ChatNotificationPayload(
chatId = chatId,

View File

@@ -71,6 +71,10 @@ class PushTokenSyncManager @Inject constructor(
}.onFailure { error ->
Timber.w(error, "Failed to unregister push token on logout")
}
securePrefs.edit()
.remove(KEY_LAST_SYNCED_TOKEN)
.remove(KEY_LAST_SYNCED_USER_ID)
.apply()
}
private suspend fun registerTokenIfPossible(token: String) {
@@ -78,6 +82,15 @@ class PushTokenSyncManager @Inject constructor(
if (!hasTokens) {
return
}
val activeUserId = tokenRepository.getActiveUserId()
if (activeUserId == null) {
return
}
val lastSyncedToken = securePrefs.getString(KEY_LAST_SYNCED_TOKEN, null)?.trim()
val lastSyncedUserId = securePrefs.getLong(KEY_LAST_SYNCED_USER_ID, -1L).takeIf { it > 0L }
if (lastSyncedToken == token && lastSyncedUserId == activeUserId) {
return
}
runCatching {
pushTokenApiService.upsert(
request = PushTokenUpsertRequestDto(
@@ -87,6 +100,10 @@ class PushTokenSyncManager @Inject constructor(
appVersion = BuildConfig.VERSION_NAME,
)
)
securePrefs.edit()
.putString(KEY_LAST_SYNCED_TOKEN, token)
.putLong(KEY_LAST_SYNCED_USER_ID, activeUserId)
.apply()
}.onFailure { error ->
Timber.w(error, "Failed to sync push token")
}
@@ -94,6 +111,8 @@ class PushTokenSyncManager @Inject constructor(
private companion object {
const val KEY_LAST_FCM_TOKEN = "last_fcm_token"
const val KEY_LAST_SYNCED_TOKEN = "last_synced_push_token"
const val KEY_LAST_SYNCED_USER_ID = "last_synced_push_user_id"
const val PLATFORM_ANDROID = "android"
}
}

View File

@@ -1,8 +1,11 @@
package ru.daemonlord.messenger.ui.account
import ru.daemonlord.messenger.domain.account.model.UserSearchItem
import ru.daemonlord.messenger.domain.account.model.AccountNotification
import ru.daemonlord.messenger.domain.auth.model.AuthSession
import ru.daemonlord.messenger.domain.auth.model.AuthUser
import ru.daemonlord.messenger.domain.settings.model.AppLanguage
import ru.daemonlord.messenger.domain.settings.model.AppThemeMode
data class AccountUiState(
val isLoading: Boolean = false,
@@ -14,6 +17,22 @@ data class AccountUiState(
val twoFactorOtpAuthUrl: String? = null,
val recoveryCodes: List<String> = emptyList(),
val recoveryCodesRemaining: Int? = null,
val activeUserId: Long? = null,
val storedAccounts: List<StoredAccountUi> = emptyList(),
val appThemeMode: AppThemeMode = AppThemeMode.SYSTEM,
val appLanguage: AppLanguage = AppLanguage.SYSTEM,
val notificationsEnabled: Boolean = true,
val notificationsPreviewEnabled: Boolean = true,
val notificationsHistory: List<AccountNotification> = emptyList(),
val isAddingAccount: Boolean = false,
val message: String? = null,
val errorMessage: String? = null,
)
data class StoredAccountUi(
val userId: Long,
val title: String,
val subtitle: String,
val avatarUrl: String?,
val isActive: Boolean,
)

View File

@@ -1,21 +1,45 @@
package ru.daemonlord.messenger.ui.account
import android.content.Context
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.appcompat.app.AppCompatDelegate
import androidx.core.os.LocaleListCompat
import dagger.hilt.android.qualifiers.ApplicationContext
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.core.token.TokenRepository
import ru.daemonlord.messenger.push.PushTokenSyncManager
import ru.daemonlord.messenger.domain.auth.usecase.LoginUseCase
import ru.daemonlord.messenger.domain.account.repository.AccountRepository
import ru.daemonlord.messenger.domain.chat.repository.ChatRepository
import ru.daemonlord.messenger.domain.common.AppError
import ru.daemonlord.messenger.domain.common.AppResult
import ru.daemonlord.messenger.domain.notifications.repository.NotificationSettingsRepository
import ru.daemonlord.messenger.domain.realtime.RealtimeManager
import ru.daemonlord.messenger.domain.settings.model.AppLanguage
import ru.daemonlord.messenger.domain.settings.model.AppThemeMode
import ru.daemonlord.messenger.domain.settings.repository.LanguageRepository
import ru.daemonlord.messenger.domain.settings.repository.ThemeRepository
import ru.daemonlord.messenger.R
import javax.inject.Inject
@HiltViewModel
class AccountViewModel @Inject constructor(
private val accountRepository: AccountRepository,
private val tokenRepository: TokenRepository,
private val loginUseCase: LoginUseCase,
private val chatRepository: ChatRepository,
private val realtimeManager: RealtimeManager,
private val notificationSettingsRepository: NotificationSettingsRepository,
private val languageRepository: LanguageRepository,
private val themeRepository: ThemeRepository,
private val pushTokenSyncManager: PushTokenSyncManager,
@ApplicationContext private val context: Context,
) : ViewModel() {
private val _uiState = MutableStateFlow(AccountUiState())
val uiState: StateFlow<AccountUiState> = _uiState.asStateFlow()
@@ -30,13 +54,38 @@ class AccountViewModel @Inject constructor(
val me = accountRepository.getMe()
val sessions = accountRepository.listSessions()
val blocked = accountRepository.listBlockedUsers()
val notifications = accountRepository.listNotifications(limit = 50)
val activeUserId = tokenRepository.getActiveUserId()
val storedAccounts = tokenRepository.getAccounts()
val notificationSettings = notificationSettingsRepository.getSettings()
val appThemeMode = themeRepository.getThemeMode()
val appLanguage = languageRepository.getLanguage()
_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)
notificationsHistory = (notifications as? AppResult.Success)?.data ?: state.notificationsHistory,
activeUserId = activeUserId,
storedAccounts = storedAccounts.map { account ->
val fallbackUser = context.getString(R.string.account_user_fallback, account.userId)
StoredAccountUi(
userId = account.userId,
title = account.name.ifBlank { fallbackUser },
subtitle = listOfNotNull(
account.username?.takeIf { it.isNotBlank() }?.let { "@$it" },
account.email?.takeIf { it.isNotBlank() },
).joinToString("").ifBlank { fallbackUser },
avatarUrl = account.avatarUrl,
isActive = activeUserId == account.userId,
)
},
notificationsEnabled = notificationSettings.globalEnabled,
notificationsPreviewEnabled = notificationSettings.previewEnabled,
appThemeMode = appThemeMode,
appLanguage = appLanguage,
errorMessage = listOf(me, sessions, blocked, notifications)
.filterIsInstance<AppResult.Error>()
.firstOrNull()
?.reason
@@ -46,6 +95,111 @@ class AccountViewModel @Inject constructor(
}
}
fun setThemeMode(mode: AppThemeMode) {
viewModelScope.launch {
themeRepository.setThemeMode(mode)
AppCompatDelegate.setDefaultNightMode(
when (mode) {
AppThemeMode.LIGHT -> AppCompatDelegate.MODE_NIGHT_NO
AppThemeMode.DARK -> AppCompatDelegate.MODE_NIGHT_YES
AppThemeMode.SYSTEM -> AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM
}
)
_uiState.update { it.copy(appThemeMode = mode) }
}
}
fun setLanguage(language: AppLanguage) {
viewModelScope.launch {
languageRepository.setLanguage(language)
val locales = language.tag?.let { LocaleListCompat.forLanguageTags(it) }
?: LocaleListCompat.getEmptyLocaleList()
AppCompatDelegate.setApplicationLocales(locales)
_uiState.update { it.copy(appLanguage = language) }
}
}
fun setGlobalNotificationsEnabled(enabled: Boolean) {
viewModelScope.launch {
notificationSettingsRepository.setGlobalEnabled(enabled)
_uiState.update { it.copy(notificationsEnabled = enabled) }
}
}
fun setNotificationPreviewEnabled(enabled: Boolean) {
viewModelScope.launch {
notificationSettingsRepository.setPreviewEnabled(enabled)
_uiState.update { it.copy(notificationsPreviewEnabled = enabled) }
}
}
fun addAccount(email: String, password: String, onDone: (Boolean) -> Unit = {}) {
val normalizedEmail = email.trim()
if (normalizedEmail.isBlank() || password.isBlank()) {
_uiState.update { it.copy(errorMessage = context.getString(R.string.account_error_email_password_required)) }
onDone(false)
return
}
viewModelScope.launch {
_uiState.update { it.copy(isAddingAccount = true, errorMessage = null, message = null) }
when (val result = loginUseCase(normalizedEmail, password)) {
is AppResult.Success -> {
_uiState.update {
it.copy(
isAddingAccount = false,
message = context.getString(R.string.account_info_added),
)
}
refresh()
onDone(true)
}
is AppResult.Error -> {
_uiState.update {
it.copy(
isAddingAccount = false,
errorMessage = result.reason.toUiMessage(),
)
}
onDone(false)
}
}
}
}
fun switchStoredAccount(userId: Long, onSwitched: (Boolean) -> Unit = {}) {
viewModelScope.launch {
val switched = tokenRepository.switchAccount(userId)
if (!switched) {
_uiState.update { it.copy(errorMessage = context.getString(R.string.account_error_no_saved_session)) }
onSwitched(false)
return@launch
}
// Force data/context switch to the newly active account.
realtimeManager.disconnect()
realtimeManager.connect()
pushTokenSyncManager.triggerBestEffortSync()
val allResult = chatRepository.refreshChats(archived = false)
val archivedResult = chatRepository.refreshChats(archived = true)
refresh()
val syncFailed = allResult is AppResult.Error && archivedResult is AppResult.Error
if (syncFailed) {
_uiState.update {
it.copy(
errorMessage = context.getString(R.string.account_error_switch_sync_failed),
)
}
}
onSwitched(true)
}
}
fun removeStoredAccount(userId: Long) {
viewModelScope.launch {
tokenRepository.removeAccount(userId)
refresh()
}
}
fun updateProfile(
name: String,
username: String,
@@ -59,7 +213,7 @@ class AccountViewModel @Inject constructor(
it.copy(
isSaving = false,
profile = result.data,
message = "Profile updated.",
message = context.getString(R.string.account_info_profile_updated),
)
}
is AppResult.Error -> _uiState.update {
@@ -82,7 +236,7 @@ class AccountViewModel @Inject constructor(
_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.") }
_uiState.update { it.copy(isSaving = false, message = context.getString(R.string.account_info_avatar_uploaded)) }
onUploaded(result.data)
}
is AppResult.Error -> _uiState.update {
@@ -115,7 +269,7 @@ class AccountViewModel @Inject constructor(
it.copy(
isSaving = false,
profile = result.data,
message = "Privacy settings updated.",
message = context.getString(R.string.account_info_privacy_updated),
)
}
is AppResult.Error -> _uiState.update {
@@ -168,7 +322,7 @@ class AccountViewModel @Inject constructor(
it.copy(
twoFactorSecret = result.data.first,
twoFactorOtpAuthUrl = result.data.second,
message = "2FA secret generated. Enter code to enable.",
message = context.getString(R.string.account_info_2fa_secret_generated),
errorMessage = null,
)
}
@@ -217,7 +371,7 @@ class AccountViewModel @Inject constructor(
is AppResult.Success -> _uiState.update {
it.copy(
recoveryCodes = result.data,
message = "Recovery codes regenerated.",
message = context.getString(R.string.account_info_recovery_codes_regenerated),
errorMessage = null,
)
}
@@ -246,6 +400,26 @@ class AccountViewModel @Inject constructor(
}
}
fun resendVerification(email: String) {
viewModelScope.launch {
_uiState.update { it.copy(isSaving = true, errorMessage = null, message = null) }
when (val result = accountRepository.resendVerification(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 requestPasswordReset(email: String) {
viewModelScope.launch {
_uiState.update { it.copy(isSaving = true, errorMessage = null, message = null) }
@@ -292,11 +466,11 @@ class AccountViewModel @Inject constructor(
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."
AppError.InvalidCredentials -> context.getString(R.string.account_error_invalid_credentials)
AppError.Unauthorized -> context.getString(R.string.account_error_unauthorized)
AppError.Network -> context.getString(R.string.error_network)
is AppError.Server -> message ?: context.getString(R.string.error_server)
is AppError.Unknown -> cause?.message ?: context.getString(R.string.error_unknown)
}
}
}

View File

@@ -1,10 +1,25 @@
package ru.daemonlord.messenger.ui.auth
enum class AuthStep {
EMAIL,
PASSWORD,
REGISTER,
OTP,
}
data class AuthUiState(
val step: AuthStep = AuthStep.EMAIL,
val email: String = "",
val name: String = "",
val username: String = "",
val password: String = "",
val otpCode: String = "",
val recoveryCode: String = "",
val useRecoveryCode: Boolean = false,
val isCheckingSession: Boolean = true,
val isLoading: Boolean = false,
val isAuthenticated: Boolean = false,
val authCompletedNonce: Long = 0L,
val successMessage: String? = null,
val errorMessage: String? = null,
)

View File

@@ -1,25 +1,33 @@
package ru.daemonlord.messenger.ui.auth
import android.content.Context
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.qualifiers.ApplicationContext
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.auth.usecase.CheckEmailStatusUseCase
import ru.daemonlord.messenger.domain.auth.usecase.LoginUseCase
import ru.daemonlord.messenger.domain.auth.usecase.LogoutUseCase
import ru.daemonlord.messenger.domain.auth.usecase.RegisterUseCase
import ru.daemonlord.messenger.domain.auth.usecase.RestoreSessionUseCase
import ru.daemonlord.messenger.domain.common.AppError
import ru.daemonlord.messenger.domain.common.AppResult
import ru.daemonlord.messenger.R
import javax.inject.Inject
@HiltViewModel
class AuthViewModel @Inject constructor(
private val restoreSessionUseCase: RestoreSessionUseCase,
private val checkEmailStatusUseCase: CheckEmailStatusUseCase,
private val registerUseCase: RegisterUseCase,
private val loginUseCase: LoginUseCase,
private val logoutUseCase: LogoutUseCase,
@ApplicationContext private val context: Context,
) : ViewModel() {
private val _uiState = MutableStateFlow(AuthUiState())
@@ -30,33 +38,235 @@ class AuthViewModel @Inject constructor(
}
fun onEmailChanged(value: String) {
_uiState.update { it.copy(email = value, errorMessage = null) }
_uiState.update {
it.copy(
email = value,
errorMessage = null,
successMessage = null,
)
}
}
fun onNameChanged(value: String) {
_uiState.update { it.copy(name = value, errorMessage = null) }
}
fun onUsernameChanged(value: String) {
_uiState.update { it.copy(username = value.replace("@", ""), errorMessage = null) }
}
fun onPasswordChanged(value: String) {
_uiState.update { it.copy(password = value, errorMessage = null) }
}
fun login() {
val state = uiState.value
if (state.email.isBlank() || state.password.isBlank()) {
_uiState.update { it.copy(errorMessage = "Email and password are required.") }
fun onOtpCodeChanged(value: String) {
_uiState.update { it.copy(otpCode = value.filter(Char::isDigit).take(8), errorMessage = null) }
}
fun onRecoveryCodeChanged(value: String) {
_uiState.update {
it.copy(
recoveryCode = value.uppercase().filter { ch -> ch.isLetterOrDigit() || ch == '-' }.take(32),
errorMessage = null,
)
}
}
fun toggleRecoveryCodeMode() {
_uiState.update {
it.copy(
useRecoveryCode = !it.useRecoveryCode,
errorMessage = null,
successMessage = null,
)
}
}
fun backToEmailStep() {
_uiState.update {
it.copy(
step = AuthStep.EMAIL,
password = "",
otpCode = "",
recoveryCode = "",
useRecoveryCode = false,
errorMessage = null,
successMessage = null,
)
}
}
fun continueWithEmail() {
val email = uiState.value.email.trim().lowercase()
if (email.isBlank()) {
_uiState.update { it.copy(errorMessage = context.getString(R.string.auth_error_enter_email)) }
return
}
viewModelScope.launch {
_uiState.update { it.copy(isLoading = true, errorMessage = null) }
_uiState.update {
it.copy(
isLoading = true,
errorMessage = null,
successMessage = null,
)
}
when (val result = checkEmailStatusUseCase(email)) {
is AppResult.Success -> {
_uiState.update {
it.copy(
email = result.data.email,
isLoading = false,
step = if (result.data.registered) AuthStep.PASSWORD else AuthStep.REGISTER,
errorMessage = null,
successMessage = if (result.data.registered) {
null
} else {
context.getString(R.string.auth_info_email_not_registered)
},
)
}
}
is AppResult.Error -> {
_uiState.update {
it.copy(
isLoading = false,
errorMessage = result.reason.toUiMessage(),
successMessage = null,
)
}
}
}
}
}
fun submitAuthStep() {
when (uiState.value.step) {
AuthStep.EMAIL -> continueWithEmail()
AuthStep.REGISTER -> register()
AuthStep.PASSWORD -> loginWithoutOtp()
AuthStep.OTP -> loginWithOtp()
}
}
private fun register() {
val state = uiState.value
if (state.name.isBlank() || state.username.isBlank() || state.password.isBlank()) {
_uiState.update { it.copy(errorMessage = context.getString(R.string.auth_error_register_fields_required)) }
return
}
viewModelScope.launch {
_uiState.update { it.copy(isLoading = true, errorMessage = null, successMessage = null) }
when (
val result = registerUseCase(
email = state.email.trim().lowercase(),
name = state.name.trim(),
username = state.username.trim().removePrefix("@"),
password = state.password,
)
) {
is AppResult.Success -> {
_uiState.update {
it.copy(
isLoading = false,
step = AuthStep.PASSWORD,
successMessage = context.getString(R.string.auth_info_account_created),
errorMessage = null,
)
}
}
is AppResult.Error -> {
_uiState.update {
it.copy(
isLoading = false,
errorMessage = result.reason.toUiMessage(),
successMessage = null,
)
}
}
}
}
}
private fun loginWithoutOtp() {
val state = uiState.value
if (state.password.isBlank()) {
_uiState.update { it.copy(errorMessage = context.getString(R.string.auth_error_password_required)) }
return
}
viewModelScope.launch {
_uiState.update { it.copy(isLoading = true, errorMessage = null, successMessage = null) }
when (val result = loginUseCase(state.email.trim(), state.password)) {
is AppResult.Success -> {
_uiState.update {
it.copy(
isLoading = false,
isAuthenticated = true,
authCompletedNonce = System.currentTimeMillis(),
errorMessage = null,
successMessage = null,
)
}
}
is AppResult.Error -> {
val isOtpRequired = (result.reason as? AppError.Server)
?.message
?.contains("2fa code required", ignoreCase = true) == true
if (isOtpRequired) {
_uiState.update {
it.copy(
isLoading = false,
step = AuthStep.OTP,
errorMessage = null,
successMessage = context.getString(R.string.auth_info_enter_2fa_or_recovery),
)
}
} else {
_uiState.update {
it.copy(
isLoading = false,
isAuthenticated = false,
errorMessage = result.reason.toUiMessage(),
successMessage = null,
)
}
}
}
}
}
}
private fun loginWithOtp() {
val state = uiState.value
val otpCode = state.otpCode.trim().ifBlank { null }
val recoveryCode = state.recoveryCode.trim().ifBlank { null }
if (!state.useRecoveryCode && otpCode.isNullOrBlank()) {
_uiState.update { it.copy(errorMessage = context.getString(R.string.auth_error_enter_2fa_code)) }
return
}
if (state.useRecoveryCode && recoveryCode.isNullOrBlank()) {
_uiState.update { it.copy(errorMessage = context.getString(R.string.auth_error_enter_recovery_code)) }
return
}
viewModelScope.launch {
_uiState.update { it.copy(isLoading = true, errorMessage = null, successMessage = null) }
when (
val result = loginUseCase(
email = state.email.trim(),
password = state.password,
otpCode = if (state.useRecoveryCode) null else otpCode,
recoveryCode = if (state.useRecoveryCode) recoveryCode else null,
)
) {
is AppResult.Success -> {
_uiState.update {
it.copy(
isLoading = false,
isAuthenticated = true,
authCompletedNonce = System.currentTimeMillis(),
errorMessage = null,
)
}
}
is AppResult.Error -> {
_uiState.update {
it.copy(
@@ -76,16 +286,47 @@ class AuthViewModel @Inject constructor(
runCatching { logoutUseCase() }
_uiState.update {
it.copy(
step = AuthStep.EMAIL,
email = "",
name = "",
username = "",
password = "",
otpCode = "",
recoveryCode = "",
useRecoveryCode = false,
isLoading = false,
isAuthenticated = false,
errorMessage = null,
successMessage = null,
)
}
}
}
fun recheckSession() {
restoreSession()
}
fun startAddAccountFlow() {
_uiState.update {
it.copy(
step = AuthStep.EMAIL,
email = "",
name = "",
username = "",
password = "",
otpCode = "",
recoveryCode = "",
useRecoveryCode = false,
isCheckingSession = false,
isLoading = false,
isAuthenticated = false,
successMessage = null,
errorMessage = null,
)
}
}
private fun restoreSession() {
viewModelScope.launch {
_uiState.update { it.copy(isCheckingSession = true) }
@@ -99,7 +340,6 @@ class AuthViewModel @Inject constructor(
)
}
}
is AppResult.Error -> {
val keepAuthenticatedOffline = result.reason is AppError.Network
_uiState.update {
@@ -116,11 +356,11 @@ class AuthViewModel @Inject constructor(
private fun AppError.toUiMessage(): String {
return when (this) {
AppError.InvalidCredentials -> "Invalid email or password."
AppError.Network -> "Network error. Check your connection."
AppError.Unauthorized -> "Session expired. Please sign in again."
is AppError.Server -> "Server error. Please try again."
is AppError.Unknown -> "Unknown error. Please try again."
AppError.InvalidCredentials -> context.getString(R.string.auth_error_invalid_credentials)
AppError.Network -> context.getString(R.string.auth_error_network)
AppError.Unauthorized -> context.getString(R.string.auth_error_session_expired)
is AppError.Server -> message ?: context.getString(R.string.auth_error_server)
is AppError.Unknown -> context.getString(R.string.auth_error_unknown)
}
}
}

View File

@@ -20,19 +20,32 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.unit.dp
import ru.daemonlord.messenger.R
@Composable
fun LoginScreen(
state: AuthUiState,
headerTitle: String = "",
onEmailChanged: (String) -> Unit,
onNameChanged: (String) -> Unit,
onUsernameChanged: (String) -> Unit,
onPasswordChanged: (String) -> Unit,
onLoginClick: () -> Unit,
onOtpCodeChanged: (String) -> Unit,
onRecoveryCodeChanged: (String) -> Unit,
onToggleRecoveryCodeMode: () -> Unit,
onContinueEmail: () -> Unit,
onSubmitStep: () -> Unit,
onBackToEmail: () -> Unit,
onOpenVerifyEmail: () -> Unit,
onOpenResetPassword: () -> Unit,
) {
val isTabletLayout = LocalConfiguration.current.screenWidthDp >= 840
val isBusy = state.isLoading
val resolvedHeaderTitle = headerTitle.ifBlank { stringResource(id = R.string.auth_header_login) }
Box(
modifier = Modifier
.fillMaxSize()
@@ -47,71 +60,200 @@ fun LoginScreen(
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(
text = "Messenger Login",
style = MaterialTheme.typography.headlineSmall,
modifier = Modifier.padding(bottom = 24.dp),
)
OutlinedTextField(
value = state.email,
onValueChange = onEmailChanged,
label = { Text(text = "Email") },
singleLine = true,
enabled = !state.isLoading,
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 12.dp),
)
OutlinedTextField(
value = state.password,
onValueChange = onPasswordChanged,
label = { Text(text = "Password") },
visualTransformation = PasswordVisualTransformation(),
singleLine = true,
enabled = !state.isLoading,
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 16.dp),
)
Button(
onClick = onLoginClick,
enabled = !state.isLoading,
modifier = Modifier.fillMaxWidth(),
) {
if (state.isLoading) {
CircularProgressIndicator(
strokeWidth = 2.dp,
modifier = Modifier.padding(2.dp),
)
} else {
Text(text = "Login")
}
}
if (!state.errorMessage.isNullOrBlank()) {
Text(
text = state.errorMessage,
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.padding(top = 12.dp),
text = resolvedHeaderTitle,
style = MaterialTheme.typography.headlineSmall,
modifier = Modifier.padding(bottom = 14.dp),
)
}
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")
}
val subtitle = when (state.step) {
AuthStep.EMAIL -> stringResource(id = R.string.auth_subtitle_enter_email)
AuthStep.PASSWORD -> stringResource(id = R.string.auth_subtitle_enter_password, state.email)
AuthStep.REGISTER -> stringResource(id = R.string.auth_subtitle_create_account, state.email)
AuthStep.OTP -> stringResource(id = R.string.auth_subtitle_2fa_enabled)
}
Text(
text = subtitle,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(bottom = 18.dp),
)
when (state.step) {
AuthStep.EMAIL -> {
OutlinedTextField(
value = state.email,
onValueChange = onEmailChanged,
label = { Text(text = stringResource(id = R.string.auth_label_email)) },
singleLine = true,
enabled = !isBusy,
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 16.dp),
)
Button(
onClick = onContinueEmail,
enabled = !isBusy,
modifier = Modifier.fillMaxWidth(),
) {
if (isBusy) {
CircularProgressIndicator(strokeWidth = 2.dp, modifier = Modifier.padding(2.dp))
} else {
Text(stringResource(id = R.string.auth_continue))
}
}
}
AuthStep.PASSWORD, AuthStep.REGISTER, AuthStep.OTP -> {
OutlinedTextField(
value = state.email,
onValueChange = {},
readOnly = true,
label = { Text(text = stringResource(id = R.string.auth_label_email)) },
singleLine = true,
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 8.dp),
)
TextButton(
onClick = onBackToEmail,
enabled = !isBusy,
modifier = Modifier
.align(Alignment.Start)
.padding(bottom = 6.dp),
) {
Text(stringResource(id = R.string.auth_change_email))
}
if (state.step == AuthStep.REGISTER) {
OutlinedTextField(
value = state.name,
onValueChange = onNameChanged,
label = { Text(text = stringResource(id = R.string.auth_label_name)) },
singleLine = true,
enabled = !isBusy,
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 10.dp),
)
OutlinedTextField(
value = state.username,
onValueChange = onUsernameChanged,
label = { Text(text = stringResource(id = R.string.auth_label_username)) },
singleLine = true,
enabled = !isBusy,
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 10.dp),
)
}
if (state.step != AuthStep.OTP) {
OutlinedTextField(
value = state.password,
onValueChange = onPasswordChanged,
label = { Text(text = stringResource(id = R.string.auth_label_password)) },
visualTransformation = PasswordVisualTransformation(),
singleLine = true,
enabled = !isBusy,
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 10.dp),
)
}
if (state.step == AuthStep.OTP) {
TextButton(
onClick = onToggleRecoveryCodeMode,
enabled = !isBusy,
modifier = Modifier
.align(Alignment.Start)
.padding(bottom = 4.dp),
) {
Text(
if (state.useRecoveryCode) {
stringResource(id = R.string.auth_use_otp_code)
} else {
stringResource(id = R.string.auth_use_recovery_code)
},
)
}
if (state.useRecoveryCode) {
OutlinedTextField(
value = state.recoveryCode,
onValueChange = onRecoveryCodeChanged,
label = { Text(text = stringResource(id = R.string.auth_label_recovery_code)) },
singleLine = true,
enabled = !isBusy,
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 10.dp),
)
} else {
OutlinedTextField(
value = state.otpCode,
onValueChange = onOtpCodeChanged,
label = { Text(text = stringResource(id = R.string.auth_label_2fa_code)) },
singleLine = true,
enabled = !isBusy,
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 10.dp),
)
}
}
Button(
onClick = onSubmitStep,
enabled = !isBusy,
modifier = Modifier.fillMaxWidth(),
) {
if (isBusy) {
CircularProgressIndicator(strokeWidth = 2.dp, modifier = Modifier.padding(2.dp))
} else {
Text(
when (state.step) {
AuthStep.PASSWORD -> stringResource(id = R.string.auth_sign_in)
AuthStep.REGISTER -> stringResource(id = R.string.auth_create_account)
AuthStep.OTP -> stringResource(id = R.string.auth_confirm_2fa)
AuthStep.EMAIL -> stringResource(id = R.string.auth_continue)
}
)
}
}
}
}
if (!state.errorMessage.isNullOrBlank()) {
Text(
text = state.errorMessage,
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.padding(top = 12.dp),
)
}
if (!state.successMessage.isNullOrBlank()) {
Text(
text = state.successMessage,
color = MaterialTheme.colorScheme.primary,
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.padding(top = 12.dp),
)
}
TextButton(
onClick = onOpenVerifyEmail,
enabled = !isBusy,
modifier = Modifier.padding(top = 8.dp),
) {
Text(text = stringResource(id = R.string.auth_verify_email_by_token))
}
TextButton(
onClick = onOpenResetPassword,
enabled = !isBusy,
) {
Text(text = stringResource(id = R.string.auth_forgot_password))
}
}
}
}

View File

@@ -23,9 +23,11 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import ru.daemonlord.messenger.R
import ru.daemonlord.messenger.ui.account.AccountViewModel
@Composable
@@ -51,11 +53,11 @@ fun ResetPasswordRoute(
.then(if (isTabletLayout) Modifier.widthIn(max = 620.dp) else Modifier),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
Text("Password reset", style = MaterialTheme.typography.headlineSmall)
Text(stringResource(id = R.string.auth_password_reset_title), style = MaterialTheme.typography.headlineSmall)
OutlinedTextField(
value = email,
onValueChange = { email = it },
label = { Text("Email") },
label = { Text(stringResource(id = R.string.auth_label_email)) },
modifier = Modifier.fillMaxWidth(),
)
Button(
@@ -63,12 +65,12 @@ fun ResetPasswordRoute(
modifier = Modifier.fillMaxWidth(),
enabled = !state.isSaving && email.isNotBlank(),
) {
Text("Send reset link")
Text(stringResource(id = R.string.auth_send_reset_link))
}
OutlinedTextField(
value = password,
onValueChange = { password = it },
label = { Text("New password") },
label = { Text(stringResource(id = R.string.auth_new_password)) },
modifier = Modifier.fillMaxWidth(),
)
Button(
@@ -80,7 +82,7 @@ fun ResetPasswordRoute(
modifier = Modifier.fillMaxWidth(),
enabled = !state.isSaving && !token.isNullOrBlank() && password.length >= 8,
) {
Text("Reset with token")
Text(stringResource(id = R.string.auth_reset_with_token))
}
if (state.isSaving) {
CircularProgressIndicator()
@@ -92,7 +94,7 @@ fun ResetPasswordRoute(
Text(state.errorMessage!!, color = MaterialTheme.colorScheme.error)
}
Button(onClick = onBackToLogin, modifier = Modifier.fillMaxWidth()) {
Text("Back to login")
Text(stringResource(id = R.string.auth_back_to_login))
}
}
}

View File

@@ -24,9 +24,11 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import ru.daemonlord.messenger.R
import ru.daemonlord.messenger.ui.account.AccountViewModel
@Composable
@@ -37,6 +39,7 @@ fun VerifyEmailRoute(
) {
val state by viewModel.uiState.collectAsStateWithLifecycle()
var editableToken by remember(token) { mutableStateOf(token.orEmpty()) }
var resendEmail by remember { mutableStateOf(state.profile?.email.orEmpty()) }
LaunchedEffect(token) {
if (!token.isNullOrBlank()) {
@@ -58,11 +61,11 @@ fun VerifyEmailRoute(
.then(if (isTabletLayout) Modifier.widthIn(max = 620.dp) else Modifier),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
Text("Verify email", style = MaterialTheme.typography.headlineSmall)
Text(stringResource(id = R.string.auth_verify_email_title), style = MaterialTheme.typography.headlineSmall)
OutlinedTextField(
value = editableToken,
onValueChange = { editableToken = it },
label = { Text("Verification token") },
label = { Text(stringResource(id = R.string.auth_verification_token)) },
modifier = Modifier.fillMaxWidth(),
)
Button(
@@ -70,7 +73,21 @@ fun VerifyEmailRoute(
enabled = !state.isSaving && editableToken.isNotBlank(),
modifier = Modifier.fillMaxWidth(),
) {
Text("Verify")
Text(stringResource(id = R.string.auth_verify))
}
OutlinedTextField(
value = resendEmail,
onValueChange = { resendEmail = it },
label = { Text(stringResource(id = R.string.auth_email_for_resend)) },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
)
Button(
onClick = { viewModel.resendVerification(resendEmail) },
enabled = !state.isSaving && resendEmail.isNotBlank(),
modifier = Modifier.fillMaxWidth(),
) {
Text(stringResource(id = R.string.auth_resend_verification_link))
}
if (state.isSaving) {
CircularProgressIndicator()
@@ -82,7 +99,7 @@ fun VerifyEmailRoute(
Text(state.errorMessage!!, color = MaterialTheme.colorScheme.error)
}
Button(onClick = onBackToLogin, modifier = Modifier.fillMaxWidth()) {
Text("Back to login")
Text(stringResource(id = R.string.auth_back_to_login))
}
}
}

View File

@@ -1,8 +1,10 @@
package ru.daemonlord.messenger.ui.chat
import android.content.Context
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow
@@ -14,6 +16,10 @@ import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import ru.daemonlord.messenger.core.notifications.ActiveChatTracker
import ru.daemonlord.messenger.core.notifications.NotificationDispatcher
import ru.daemonlord.messenger.core.token.TokenRepository
import ru.daemonlord.messenger.R
import ru.daemonlord.messenger.domain.chat.repository.ChatRepository
import ru.daemonlord.messenger.domain.chat.usecase.ObserveChatUseCase
import ru.daemonlord.messenger.domain.chat.usecase.ObserveChatsUseCase
import ru.daemonlord.messenger.domain.common.AppError
@@ -28,6 +34,7 @@ import ru.daemonlord.messenger.domain.message.usecase.LoadMoreMessagesUseCase
import ru.daemonlord.messenger.domain.message.usecase.MarkMessageDeliveredUseCase
import ru.daemonlord.messenger.domain.message.usecase.MarkMessageReadUseCase
import ru.daemonlord.messenger.domain.message.usecase.ObserveMessagesUseCase
import ru.daemonlord.messenger.domain.message.usecase.SendImageUrlMessageUseCase
import ru.daemonlord.messenger.domain.message.usecase.SendMediaMessageUseCase
import ru.daemonlord.messenger.domain.message.usecase.SendTextMessageUseCase
import ru.daemonlord.messenger.domain.message.usecase.SyncRecentMessagesUseCase
@@ -45,6 +52,7 @@ class ChatViewModel @Inject constructor(
private val syncRecentMessagesUseCase: SyncRecentMessagesUseCase,
private val loadMoreMessagesUseCase: LoadMoreMessagesUseCase,
private val sendTextMessageUseCase: SendTextMessageUseCase,
private val sendImageUrlMessageUseCase: SendImageUrlMessageUseCase,
private val sendMediaMessageUseCase: SendMediaMessageUseCase,
private val editMessageUseCase: EditMessageUseCase,
private val deleteMessageUseCase: DeleteMessageUseCase,
@@ -54,10 +62,14 @@ class ChatViewModel @Inject constructor(
private val forwardMessageBulkUseCase: ForwardMessageBulkUseCase,
private val listMessageReactionsUseCase: ListMessageReactionsUseCase,
private val toggleMessageReactionUseCase: ToggleMessageReactionUseCase,
private val chatRepository: ChatRepository,
private val observeChatUseCase: ObserveChatUseCase,
private val observeChatsUseCase: ObserveChatsUseCase,
private val handleRealtimeEventsUseCase: HandleRealtimeEventsUseCase,
private val activeChatTracker: ActiveChatTracker,
private val notificationDispatcher: NotificationDispatcher,
private val tokenRepository: TokenRepository,
@ApplicationContext private val context: Context,
) : ViewModel() {
private val chatId: Long = checkNotNull(savedStateHandle["chatId"])
@@ -66,9 +78,12 @@ class ChatViewModel @Inject constructor(
private val visibleMessagesLimit = MutableStateFlow(MESSAGES_PAGE_SIZE)
private var lastDeliveredMessageId: Long? = null
private var lastReadMessageId: Long? = null
private val reactionsRequestedMessageIds = mutableSetOf<Long>()
private var membersLoadKey: String? = null
init {
activeChatTracker.setActiveChat(chatId)
notificationDispatcher.clearChatNotifications(chatId)
handleRealtimeEventsUseCase.start()
observeChatPermissions()
observeMessages()
@@ -122,7 +137,7 @@ class ChatViewModel @Inject constructor(
isRecordingVoice = true,
isVoiceLocked = false,
voiceRecordingDurationMs = 0L,
voiceRecordingHint = "Slide up to lock, slide left to cancel",
voiceRecordingHint = context.getString(R.string.chat_voice_hint_slide),
errorMessage = null,
)
}
@@ -139,7 +154,7 @@ class ChatViewModel @Inject constructor(
if (!it.isRecordingVoice) it else {
it.copy(
isVoiceLocked = true,
voiceRecordingHint = "Recording locked",
voiceRecordingHint = context.getString(R.string.chat_voice_hint_locked),
)
}
}
@@ -169,7 +184,7 @@ class ChatViewModel @Inject constructor(
isVoiceLocked = false,
voiceRecordingDurationMs = 0L,
voiceRecordingHint = null,
errorMessage = "Voice message is too short.",
errorMessage = context.getString(R.string.chat_error_voice_too_short),
)
}
return
@@ -189,6 +204,17 @@ class ChatViewModel @Inject constructor(
)
}
fun onVisibleIncomingMessageId(messageId: Long?) {
val visibleIncomingId = messageId ?: return
if ((lastReadMessageId ?: 0L) >= visibleIncomingId) {
return
}
lastReadMessageId = visibleIncomingId
viewModelScope.launch {
markMessageReadUseCase(chatId = chatId, messageId = visibleIncomingId)
}
}
fun onSelectMessage(message: MessageItem?) {
if (message == null) {
onClearSelection()
@@ -304,7 +330,7 @@ class ChatViewModel @Inject constructor(
val actionState = uiState.value.actionState
if (actionState.mode == MessageSelectionMode.MULTI) {
if (forAll) {
_uiState.update { it.copy(errorMessage = "Delete for all is available only for single message selection.") }
_uiState.update { it.copy(errorMessage = context.getString(R.string.chat_error_delete_for_all_single)) }
return
}
val selectedIds = actionState.selectedMessageIds.toList().sorted()
@@ -332,7 +358,7 @@ class ChatViewModel @Inject constructor(
}
val selected = getFocusedSelectedMessage() ?: return
if (forAll && !canDeleteForAll(selected)) {
_uiState.update { it.copy(errorMessage = "Delete for all is available only for your own messages.") }
_uiState.update { it.copy(errorMessage = context.getString(R.string.chat_error_delete_for_all_own)) }
return
}
viewModelScope.launch {
@@ -429,6 +455,104 @@ class ChatViewModel @Inject constructor(
}
}
fun onToggleChatNotifications() {
viewModelScope.launch {
when (val current = chatRepository.getChatNotifications(chatId = chatId)) {
is AppResult.Success -> {
when (val updated = chatRepository.updateChatNotifications(chatId = chatId, muted = !current.data.muted)) {
is AppResult.Success -> _uiState.update { it.copy(chatMuted = updated.data.muted) }
is AppResult.Error -> _uiState.update { it.copy(errorMessage = updated.reason.toUiMessage()) }
}
}
is AppResult.Error -> _uiState.update { it.copy(errorMessage = current.reason.toUiMessage()) }
}
}
}
fun onClearHistory() {
viewModelScope.launch {
when (val result = chatRepository.clearChat(chatId = chatId)) {
is AppResult.Success -> _uiState.update {
it.copy(
selectedMessage = null,
selectedCanEdit = false,
selectedCanDeleteForAll = false,
actionState = it.actionState.clearSelection(),
errorMessage = context.getString(R.string.chat_info_history_cleared),
)
}
is AppResult.Error -> _uiState.update { it.copy(errorMessage = result.reason.toUiMessage()) }
}
}
}
fun onDeleteOrLeaveChat() {
viewModelScope.launch {
val type = uiState.value.chatType.lowercase()
val result = when (type) {
"group", "channel" -> chatRepository.leaveChat(chatId = chatId)
else -> chatRepository.removeChat(chatId = chatId, forAll = false)
}
when (result) {
is AppResult.Success -> _uiState.update {
it.copy(
selectedMessage = null,
selectedCanEdit = false,
selectedCanDeleteForAll = false,
actionState = it.actionState.clearSelection(),
errorMessage = null,
chatDeletedNonce = it.chatDeletedNonce + 1L,
)
}
is AppResult.Error -> _uiState.update { it.copy(errorMessage = result.reason.toUiMessage()) }
}
}
}
fun promoteMember(userId: Long) {
if (!ensureCanManageTarget(userId = userId, action = "promote")) return
updateMemberRole(userId = userId, role = "admin")
}
fun demoteMember(userId: Long) {
if (!ensureCanManageTarget(userId = userId, action = "demote")) return
updateMemberRole(userId = userId, role = "member")
}
fun transferOwnership(userId: Long) {
if (!ensureCanManageTarget(userId = userId, action = "transfer_ownership", ownerOnly = true)) return
updateMemberRole(userId = userId, role = "owner")
}
fun kickMember(userId: Long) {
if (!ensureCanManageTarget(userId = userId, action = "kick")) return
viewModelScope.launch {
when (val result = chatRepository.removeMember(chatId = chatId, userId = userId)) {
is AppResult.Success -> refreshMembersAndBans()
is AppResult.Error -> _uiState.update { it.copy(errorMessage = result.reason.toUiMessage()) }
}
}
}
fun banMember(userId: Long) {
if (!ensureCanManageTarget(userId = userId, action = "ban")) return
viewModelScope.launch {
when (val result = chatRepository.banMember(chatId = chatId, userId = userId)) {
is AppResult.Success -> refreshMembersAndBans()
is AppResult.Error -> _uiState.update { it.copy(errorMessage = result.reason.toUiMessage()) }
}
}
}
fun unbanMember(userId: Long) {
viewModelScope.launch {
when (val result = chatRepository.unbanMember(chatId = chatId, userId = userId)) {
is AppResult.Success -> refreshMembersAndBans()
is AppResult.Error -> _uiState.update { it.copy(errorMessage = result.reason.toUiMessage()) }
}
}
}
fun onSendClick() {
val text = uiState.value.inputText.trim()
if (text.isBlank()) return
@@ -440,7 +564,7 @@ class ChatViewModel @Inject constructor(
_uiState.update {
it.copy(
isSending = false,
errorMessage = "This message can no longer be edited.",
errorMessage = context.getString(R.string.chat_error_edit_expired),
)
}
return@launch
@@ -452,7 +576,8 @@ class ChatViewModel @Inject constructor(
_uiState.update {
it.copy(
isSending = false,
errorMessage = uiState.value.sendRestrictionText ?: "Sending is restricted in this chat.",
errorMessage = uiState.value.sendRestrictionText
?: context.getString(R.string.chat_error_send_restricted),
)
}
return@launch
@@ -526,6 +651,37 @@ class ChatViewModel @Inject constructor(
}
}
fun onSendPresetMediaUrl(url: String) {
val normalizedUrl = url.trim()
if (normalizedUrl.isBlank()) return
viewModelScope.launch {
_uiState.update { it.copy(isUploadingMedia = true, errorMessage = null) }
when (
val result = sendImageUrlMessageUseCase(
chatId = chatId,
imageUrl = normalizedUrl,
replyToMessageId = uiState.value.replyToMessage?.id,
)
) {
is AppResult.Success -> _uiState.update {
it.copy(
isUploadingMedia = false,
inputText = "",
replyToMessage = null,
editingMessage = null,
)
}
is AppResult.Error -> _uiState.update {
it.copy(
isUploadingMedia = false,
errorMessage = result.reason.toUiMessage(),
)
}
}
}
}
fun loadMore() {
val oldest = uiState.value.messages.firstOrNull() ?: return
viewModelScope.launch {
@@ -548,13 +704,14 @@ class ChatViewModel @Inject constructor(
visibleMessagesLimit
.flatMapLatest { limit -> observeMessagesUseCase(chatId = chatId, limit = limit) }
.collectLatest { messages ->
val sortedMessages = messages.sortedBy { msg -> msg.id }
_uiState.update {
val pinnedId = it.pinnedMessageId
val normalized = it.inlineSearchQuery.trim().lowercase()
val inlineMatches = if (normalized.isBlank()) {
emptyList()
} else {
messages
sortedMessages
.filter { msg -> (msg.text ?: "").lowercase().contains(normalized) }
.map { msg -> msg.id }
}
@@ -565,13 +722,14 @@ class ChatViewModel @Inject constructor(
}
it.copy(
isLoading = false,
messages = messages.sortedBy { msg -> msg.id },
pinnedMessage = pinnedId?.let { id -> messages.firstOrNull { msg -> msg.id == id } },
messages = sortedMessages,
pinnedMessage = pinnedId?.let { id -> sortedMessages.firstOrNull { msg -> msg.id == id } },
inlineSearchMatches = inlineMatches,
highlightedMessageId = highlighted,
)
}
acknowledgeLatestIncoming(messages)
preloadReactions(sortedMessages)
acknowledgeLatestMessages(sortedMessages)
}
}
}
@@ -588,14 +746,18 @@ class ChatViewModel @Inject constructor(
val restriction = if (canSend) {
null
} else {
"Only channel owner/admin can send messages."
context.getString(R.string.chat_restriction_owner_admin)
}
val chatTitle = chat.displayTitle.ifBlank {
context.getString(R.string.chat_title_fallback, chatId)
}
val chatTitle = chat.displayTitle.ifBlank { "Chat #$chatId" }
val chatSubtitle = when {
chat.type.equals("private", ignoreCase = true) && chat.counterpartIsOnline == true -> "online"
chat.type.equals("private", ignoreCase = true) && !chat.counterpartLastSeenAt.isNullOrBlank() -> "last seen recently"
chat.type.equals("group", ignoreCase = true) -> "group"
chat.type.equals("channel", ignoreCase = true) -> "channel"
chat.type.equals("private", ignoreCase = true) && chat.counterpartIsOnline == true ->
context.getString(R.string.chat_status_online)
chat.type.equals("private", ignoreCase = true) && !chat.counterpartLastSeenAt.isNullOrBlank() ->
context.getString(R.string.chat_status_last_seen_recently)
chat.type.equals("group", ignoreCase = true) -> context.getString(R.string.chat_type_group)
chat.type.equals("channel", ignoreCase = true) -> context.getString(R.string.chat_type_channel)
else -> ""
}
_uiState.update {
@@ -603,15 +765,56 @@ class ChatViewModel @Inject constructor(
it.messages.firstOrNull { message -> message.id == pinnedId }
}
it.copy(
chatType = chat.type,
chatRole = role,
chatMuted = chat.muted,
chatTitle = chatTitle,
chatSubtitle = chatSubtitle,
chatAvatarUrl = chat.avatarUrl ?: chat.counterpartAvatarUrl,
chatUnreadCount = chat.unreadCount.coerceAtLeast(0),
canManageMembers = role == "owner" || role == "admin",
canSendMessages = canSend,
sendRestrictionText = restriction,
pinnedMessageId = chat.pinnedMessageId,
pinnedMessage = pinnedMessage,
)
}
val shouldLoadMembers = chat.type.equals("group", ignoreCase = true) ||
(chat.type.equals("channel", ignoreCase = true) && (role == "owner" || role == "admin"))
val nextLoadKey = "${chat.id}:${chat.type.lowercase()}:${role ?: "none"}"
if (shouldLoadMembers && membersLoadKey != nextLoadKey) {
membersLoadKey = nextLoadKey
refreshMembersAndBans()
}
}
}
}
private fun updateMemberRole(userId: Long, role: String) {
viewModelScope.launch {
when (val result = chatRepository.updateMemberRole(chatId = chatId, userId = userId, role = role)) {
is AppResult.Success -> refreshMembersAndBans()
is AppResult.Error -> _uiState.update { it.copy(errorMessage = result.reason.toUiMessage()) }
}
}
}
private fun refreshMembersAndBans() {
viewModelScope.launch {
val membersResult = chatRepository.listMembers(chatId = chatId)
val bansResult = chatRepository.listBans(chatId = chatId)
_uiState.update {
it.copy(
chatMembers = (membersResult as? AppResult.Success)?.data ?: it.chatMembers,
chatBans = (bansResult as? AppResult.Success)?.data ?: it.chatBans,
errorMessage = listOf(membersResult, bansResult)
.filterIsInstance<AppResult.Error>()
.firstOrNull()
?.reason
?.toUiMessage()
?: it.errorMessage,
)
}
}
}
@@ -619,11 +822,13 @@ class ChatViewModel @Inject constructor(
private fun refresh() {
viewModelScope.launch {
_uiState.update { it.copy(isLoading = true, errorMessage = null) }
val selfUserId = tokenRepository.getActiveUserId()
when (val result = syncRecentMessagesUseCase(chatId = chatId)) {
is AppResult.Success -> _uiState.update { it.copy(isLoading = false) }
is AppResult.Success -> _uiState.update { it.copy(isLoading = false, selfUserId = selfUserId) }
is AppResult.Error -> _uiState.update {
it.copy(
isLoading = false,
selfUserId = selfUserId,
errorMessage = result.reason.toUiMessage(),
)
}
@@ -632,6 +837,7 @@ class ChatViewModel @Inject constructor(
}
private fun loadReactions(messageId: Long) {
if (messageId <= 0L) return
viewModelScope.launch {
when (val result = listMessageReactionsUseCase(messageId = messageId)) {
is AppResult.Success -> {
@@ -644,24 +850,38 @@ class ChatViewModel @Inject constructor(
}
}
private fun acknowledgeLatestIncoming(messages: List<MessageItem>) {
val latestIncoming = messages
private fun preloadReactions(messages: List<MessageItem>) {
val toRequest = messages
.asReversed()
.firstOrNull { !it.isOutgoing }
?: return
.map { it.id }
.filter { it > 0L }
.filter { reactionsRequestedMessageIds.add(it) }
if (lastDeliveredMessageId != latestIncoming.id) {
if (toRequest.isEmpty()) return
toRequest.forEach { messageId ->
viewModelScope.launch {
when (val result = listMessageReactionsUseCase(messageId = messageId)) {
is AppResult.Success -> {
_uiState.update {
it.copy(reactionByMessageId = it.reactionByMessageId + (messageId to result.data))
}
}
is AppResult.Error -> Unit
}
}
}
}
private fun acknowledgeLatestMessages(messages: List<MessageItem>) {
val latestIncoming = messages.asReversed().firstOrNull { !it.isOutgoing }
if (latestIncoming != null && lastDeliveredMessageId != latestIncoming.id) {
lastDeliveredMessageId = latestIncoming.id
viewModelScope.launch {
markMessageDeliveredUseCase(chatId = chatId, messageId = latestIncoming.id)
}
}
if (lastReadMessageId != latestIncoming.id) {
lastReadMessageId = latestIncoming.id
viewModelScope.launch {
markMessageReadUseCase(chatId = chatId, messageId = latestIncoming.id)
}
}
}
fun canEdit(message: MessageItem): Boolean {
@@ -713,19 +933,57 @@ class ChatViewModel @Inject constructor(
return uiState.value.messages.firstOrNull { it.id == messageId }
}
private fun ensureCanManageTarget(
userId: Long,
action: String,
ownerOnly: Boolean = false,
): Boolean {
val state = uiState.value
val selfId = state.selfUserId
if (selfId != null && userId == selfId) {
_uiState.update { it.copy(errorMessage = context.getString(R.string.chat_error_action_self)) }
return false
}
val actorRole = state.chatRole?.lowercase()
if (actorRole != "owner" && actorRole != "admin") {
_uiState.update { it.copy(errorMessage = context.getString(R.string.chat_error_permissions)) }
return false
}
if (ownerOnly && actorRole != "owner") {
_uiState.update { it.copy(errorMessage = context.getString(R.string.chat_error_owner_only)) }
return false
}
val targetRole = state.chatMembers.firstOrNull { it.userId == userId }?.role?.lowercase()
if (targetRole == "owner") {
_uiState.update { it.copy(errorMessage = context.getString(R.string.chat_error_manage_owner)) }
return false
}
if (actorRole == "admin" && (targetRole == "admin" || targetRole == "owner")) {
_uiState.update { it.copy(errorMessage = context.getString(R.string.chat_error_admin_manage_admin_owner)) }
return false
}
if (action == "transfer_ownership" && targetRole == "owner") {
_uiState.update { it.copy(errorMessage = context.getString(R.string.chat_error_transfer_choose_another)) }
return false
}
return true
}
private fun AppError.toUiMessage(): String {
return when (this) {
AppError.Network -> "Network error."
AppError.Unauthorized -> "Session expired."
AppError.InvalidCredentials -> "Authorization error."
is AppError.Server -> "Server error."
is AppError.Unknown -> "Unknown error."
AppError.Network -> context.getString(R.string.error_network)
AppError.Unauthorized -> context.getString(R.string.error_session_expired)
AppError.InvalidCredentials -> context.getString(R.string.error_authorization)
is AppError.Server -> context.getString(R.string.error_server)
is AppError.Unknown -> context.getString(R.string.error_unknown)
}
}
override fun onCleared() {
activeChatTracker.clearActiveChat(chatId)
handleRealtimeEventsUseCase.stop()
super.onCleared()
}

View File

@@ -0,0 +1,232 @@
package ru.daemonlord.messenger.ui.chat
import androidx.compose.foundation.text.ClickableText
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.TextRange
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.text.style.TextDecoration
private data class InlineToken(
val marker: String,
val style: SpanStyle,
)
fun applyInlineFormatting(
value: TextFieldValue,
prefix: String,
suffix: String = prefix,
placeholder: String = "text",
): TextFieldValue {
val start = value.selection.min
val end = value.selection.max
val selected = value.text.substring(start, end)
val middle = if (selected.isNotBlank()) selected else placeholder
val replacement = "$prefix$middle$suffix"
val nextText = value.text.replaceRange(start, end, replacement)
val selection = if (selected.isNotBlank()) {
TextRange(start + replacement.length)
} else {
TextRange(start + prefix.length, start + prefix.length + middle.length)
}
return value.copy(text = nextText, selection = selection)
}
fun applyQuoteFormatting(value: TextFieldValue): TextFieldValue {
val start = value.selection.min
val end = value.selection.max
val selected = value.text.substring(start, end).ifBlank { "quote" }
val quoted = selected
.split('\n')
.joinToString("\n") { line -> "> $line" }
val nextText = value.text.replaceRange(start, end, quoted)
return value.copy(text = nextText, selection = TextRange(start + quoted.length))
}
fun applyLinkFormatting(value: TextFieldValue, url: String = "https://"): TextFieldValue {
val start = value.selection.min
val end = value.selection.max
val selected = value.text.substring(start, end).trim().ifBlank { "text" }
val replacement = "[$selected]($url)"
val nextText = value.text.replaceRange(start, end, replacement)
return value.copy(
text = nextText,
selection = TextRange(start + selected.length + 3, start + selected.length + 3 + url.length),
)
}
@Composable
fun FormattedMessageText(
text: String,
style: TextStyle,
color: Color,
modifier: Modifier = Modifier,
) {
val uriHandler = LocalUriHandler.current
val annotated = buildFormattedMessage(text = text, baseColor = color)
ClickableText(
text = annotated,
style = style,
modifier = modifier,
onClick = { offset ->
annotated
.getStringAnnotations(tag = "url", start = offset, end = offset)
.firstOrNull()
?.item
?.let { link ->
runCatching { uriHandler.openUri(link) }
}
},
)
}
private fun buildFormattedMessage(text: String, baseColor: Color): AnnotatedString {
val codeStyle = SpanStyle(
fontFamily = FontFamily.Monospace,
background = Color(0x332A2A2A),
)
val spoilerStyle = SpanStyle(
color = Color.Transparent,
background = baseColor.copy(alpha = 0.45f),
)
val linkStyle = SpanStyle(
color = Color(0xFF8AB4F8),
textDecoration = TextDecoration.Underline,
)
val quotePrefixStyle = SpanStyle(
color = baseColor.copy(alpha = 0.72f),
fontWeight = FontWeight.SemiBold,
)
val tokens = listOf(
InlineToken("||", spoilerStyle),
InlineToken("**", SpanStyle(fontWeight = FontWeight.Bold)),
InlineToken("__", SpanStyle(textDecoration = TextDecoration.Underline)),
InlineToken("~~", SpanStyle(textDecoration = TextDecoration.LineThrough)),
InlineToken("*", SpanStyle(fontStyle = FontStyle.Italic)),
)
return buildAnnotatedString {
val parts = text.split("```")
parts.forEachIndexed { index, part ->
val isCodeBlock = index % 2 == 1
if (isCodeBlock) {
val cleanCode = part.trim('\n')
val blockStart = length
append(cleanCode)
addStyle(codeStyle, blockStart, length)
if (index != parts.lastIndex) append('\n')
return@forEachIndexed
}
val lines = part.split('\n')
lines.forEachIndexed { lineIndex, line ->
if (line.startsWith("> ")) {
val prefixStart = length
append("")
addStyle(quotePrefixStyle, prefixStart, length)
appendInlineFormatted(
source = line.removePrefix("> "),
tokens = tokens,
codeStyle = codeStyle,
linkStyle = linkStyle,
)
} else {
appendInlineFormatted(
source = line,
tokens = tokens,
codeStyle = codeStyle,
linkStyle = linkStyle,
)
}
if (lineIndex != lines.lastIndex) append('\n')
}
}
}
}
private fun AnnotatedString.Builder.appendInlineFormatted(
source: String,
tokens: List<InlineToken>,
codeStyle: SpanStyle,
linkStyle: SpanStyle,
) {
var i = 0
while (i < source.length) {
val linkMatch = Regex("""^\[([^\]]+)]\(([^)]+)\)""").find(source.substring(i))
if (linkMatch != null) {
val label = linkMatch.groupValues[1]
val href = linkMatch.groupValues[2].trim()
if (href.startsWith("http://", ignoreCase = true) || href.startsWith("https://", ignoreCase = true)) {
val start = length
appendInlineFormatted(
source = label,
tokens = tokens,
codeStyle = codeStyle,
linkStyle = linkStyle,
)
addStyle(linkStyle, start, length)
addStringAnnotation(tag = "url", annotation = href, start = start, end = length)
i += linkMatch.value.length
continue
}
}
val bareUrl = Regex("""^https?://[^\s<>()]+""", RegexOption.IGNORE_CASE).find(source.substring(i))
if (bareUrl != null) {
val raw = bareUrl.value
val trimmed = raw.trimEnd(',', ')', '.', ';', '!', '?')
val trailing = raw.removePrefix(trimmed)
val start = length
append(trimmed)
addStyle(linkStyle, start, length)
addStringAnnotation(tag = "url", annotation = trimmed, start = start, end = length)
append(trailing)
i += raw.length
continue
}
if (source.startsWith("`", i)) {
val end = source.indexOf('`', startIndex = i + 1)
if (end > i + 1) {
val codeStart = length
append(source.substring(i + 1, end))
addStyle(codeStyle, codeStart, length)
i = end + 1
continue
}
}
var matched = false
for (token in tokens) {
if (!source.startsWith(token.marker, i)) continue
val end = source.indexOf(token.marker, startIndex = i + token.marker.length)
if (end <= i + token.marker.length) continue
val inner = source.substring(i + token.marker.length, end)
if (inner.isBlank()) continue
val rangeStart = length
appendInlineFormatted(
source = inner,
tokens = tokens,
codeStyle = codeStyle,
linkStyle = linkStyle,
)
addStyle(token.style, rangeStart, length)
i = end + token.marker.length
matched = true
break
}
if (matched) continue
append(source[i])
i += 1
}
}

View File

@@ -2,9 +2,12 @@ package ru.daemonlord.messenger.ui.chat
import ru.daemonlord.messenger.domain.message.model.MessageItem
import ru.daemonlord.messenger.domain.message.model.MessageReaction
import ru.daemonlord.messenger.domain.chat.model.ChatMemberItem
import ru.daemonlord.messenger.domain.chat.model.ChatBanItem
data class MessageUiState(
val chatId: Long = 0L,
val selfUserId: Long? = null,
val isLoading: Boolean = true,
val isLoadingMore: Boolean = false,
val isSending: Boolean = false,
@@ -13,6 +16,13 @@ data class MessageUiState(
val chatTitle: String = "",
val chatSubtitle: String = "",
val chatAvatarUrl: String? = null,
val chatType: String = "",
val chatRole: String? = null,
val chatMuted: Boolean = false,
val chatUnreadCount: Int = 0,
val chatMembers: List<ChatMemberItem> = emptyList(),
val chatBans: List<ChatBanItem> = emptyList(),
val canManageMembers: Boolean = false,
val messages: List<MessageItem> = emptyList(),
val pinnedMessageId: Long? = null,
val pinnedMessage: MessageItem? = null,
@@ -36,6 +46,7 @@ data class MessageUiState(
val inlineSearchMatches: List<Long> = emptyList(),
val highlightedMessageId: Long? = null,
val actionState: MessageActionState = MessageActionState(),
val chatDeletedNonce: Long = 0L,
)
data class ForwardTargetUiModel(

View File

@@ -30,9 +30,11 @@ import androidx.compose.material3.AssistChip
import androidx.compose.material3.AssistChipDefaults
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Checkbox
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
@@ -40,6 +42,7 @@ import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.OutlinedTextFieldDefaults
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.pulltorefresh.PullToRefreshBox
import androidx.compose.runtime.Composable
@@ -56,7 +59,9 @@ import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.luminance
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
@@ -68,9 +73,11 @@ import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.DoneAll
import androidx.compose.material.icons.filled.Groups
import androidx.compose.material.icons.filled.Inventory2
import androidx.compose.material.icons.filled.FolderOpen
import androidx.compose.material.icons.filled.LightMode
import androidx.compose.material.icons.filled.BookmarkBorder
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.filled.NotificationsOff
import androidx.compose.material.icons.filled.PushPin
@@ -79,6 +86,8 @@ import androidx.compose.material.icons.filled.DragHandle
import androidx.compose.material.icons.filled.Clear
import kotlinx.coroutines.flow.collectLatest
import coil.compose.AsyncImage
import ru.daemonlord.messenger.R
import ru.daemonlord.messenger.domain.settings.model.AppThemeMode
import ru.daemonlord.messenger.domain.chat.model.ChatItem
import java.time.Instant
import java.time.LocalDate
@@ -134,6 +143,7 @@ fun ChatListRoute(
onUpdateChatProfile = viewModel::updateChatProfile,
onClearChat = viewModel::clearChat,
onDeleteChat = viewModel::deleteChatForMe,
onDeleteChatForAll = viewModel::deleteChatForAll,
onToggleChatMute = viewModel::toggleChatMute,
onSelectManageChat = viewModel::onManagementChatSelected,
onCreateInvite = viewModel::createInvite,
@@ -142,6 +152,7 @@ fun ChatListRoute(
onRemoveMember = viewModel::removeMember,
onBanMember = viewModel::banMember,
onUnbanMember = viewModel::unbanMember,
onToggleDayNightMode = viewModel::toggleDayNightMode,
)
}
@@ -173,6 +184,7 @@ fun ChatListScreen(
onUpdateChatProfile: (Long, String?, String?) -> Unit,
onClearChat: (Long) -> Unit,
onDeleteChat: (Long) -> Unit,
onDeleteChatForAll: (Long) -> Unit,
onToggleChatMute: (Long) -> Unit,
onSelectManageChat: (Long?) -> Unit,
onCreateInvite: (Long) -> Unit,
@@ -181,6 +193,7 @@ fun ChatListScreen(
onRemoveMember: (Long, Long) -> Unit,
onBanMember: (Long, Long) -> Unit,
onUnbanMember: (Long, Long) -> Unit,
onToggleDayNightMode: ((AppThemeMode) -> Unit) -> Unit,
) {
val context = LocalContext.current
var managementExpanded by remember { mutableStateOf(false) }
@@ -200,6 +213,13 @@ fun ChatListScreen(
var selectedManageChatIdText by remember { mutableStateOf("") }
var manageUserIdText by remember { mutableStateOf("") }
var manageRoleText by remember { mutableStateOf("member") }
var showCreateGroupDialog by remember { mutableStateOf(false) }
var showCreateChannelDialog by remember { mutableStateOf(false) }
var quickCreateGroupTitle by remember { mutableStateOf("") }
var quickCreateChannelTitle by remember { mutableStateOf("") }
var quickCreateChannelHandle by remember { mutableStateOf("") }
var showDeleteChatsDialog by remember { mutableStateOf(false) }
var deleteSelectedForAll by remember { mutableStateOf(false) }
val isTabletLayout = LocalConfiguration.current.screenWidthDp >= 840
val listState = rememberLazyListState()
val selectedChats = remember(state.chats, selectedChatIds) {
@@ -282,9 +302,9 @@ fun ChatListScreen(
when {
selectedChatIds.isNotEmpty() -> selectedChatIds.size.toString()
isSearchMode -> ""
state.isConnecting -> "Connecting..."
state.selectedTab == ChatTab.ARCHIVED -> "Archived"
else -> "Chats"
state.isConnecting -> stringResource(id = R.string.chats_connecting)
state.selectedTab == ChatTab.ARCHIVED -> stringResource(id = R.string.chats_archived)
else -> stringResource(id = R.string.nav_chats)
}
)
},
@@ -307,23 +327,22 @@ fun ChatListScreen(
}) {
Icon(
imageVector = Icons.Filled.FolderOpen,
contentDescription = "Архивировать",
contentDescription = stringResource(id = R.string.chats_contentdesc_archive_selected),
)
}
IconButton(onClick = {
selectedChatIds.forEach { chatId -> onDeleteChat(chatId) }
selectedChatIds = emptySet()
showDeleteChatsDialog = true
}) {
Icon(
imageVector = Icons.Filled.Delete,
contentDescription = "Delete selected",
contentDescription = stringResource(id = R.string.chats_contentdesc_delete_selected),
)
}
Box {
IconButton(onClick = { showSelectionMenu = true }) {
Icon(
imageVector = Icons.Filled.MoreVert,
contentDescription = "Меню выбранного",
contentDescription = stringResource(id = R.string.chats_contentdesc_selection_menu),
)
}
DropdownMenu(
@@ -331,7 +350,15 @@ fun ChatListScreen(
onDismissRequest = { showSelectionMenu = false },
) {
DropdownMenuItem(
text = { Text(if (allSelectedPinned) "Открепить" else "Закрепить") },
text = {
Text(
if (allSelectedPinned) {
stringResource(id = R.string.chats_selection_unpin)
} else {
stringResource(id = R.string.chats_selection_pin)
},
)
},
leadingIcon = { Icon(Icons.Filled.PushPin, contentDescription = null) },
onClick = {
showSelectionMenu = false
@@ -342,23 +369,23 @@ fun ChatListScreen(
},
)
DropdownMenuItem(
text = { Text("Добавить в папку") },
text = { Text(stringResource(id = R.string.chats_selection_add_to_folder)) },
leadingIcon = { Icon(Icons.Filled.FolderOpen, contentDescription = null) },
onClick = {
showSelectionMenu = false
Toast.makeText(context, "Папки чатов будут добавлены позже.", Toast.LENGTH_SHORT).show()
Toast.makeText(context, context.getString(R.string.chats_toast_folders_coming_soon), Toast.LENGTH_SHORT).show()
},
)
DropdownMenuItem(
text = { Text("Пометить непрочитанным") },
text = { Text(stringResource(id = R.string.chats_selection_mark_unread)) },
leadingIcon = { Icon(Icons.Filled.DoneAll, contentDescription = null) },
onClick = {
showSelectionMenu = false
Toast.makeText(context, "Отметка непрочитанным будет добавлена позже.", Toast.LENGTH_SHORT).show()
Toast.makeText(context, context.getString(R.string.chats_toast_mark_unread_coming_soon), Toast.LENGTH_SHORT).show()
},
)
DropdownMenuItem(
text = { Text("Удалить из кэша") },
text = { Text(stringResource(id = R.string.chats_selection_clear_cache)) },
leadingIcon = { Icon(Icons.Filled.Delete, contentDescription = null) },
onClick = {
showSelectionMenu = false
@@ -394,45 +421,44 @@ fun ChatListScreen(
onDismissRequest = { showDefaultMenu = false },
) {
DropdownMenuItem(
text = { Text("Day mode") },
text = {
Text(
if (MaterialTheme.colorScheme.background.luminance() < 0.5f) {
stringResource(id = R.string.menu_day_mode)
} else {
stringResource(id = R.string.menu_night_mode)
}
)
},
leadingIcon = { Icon(Icons.Filled.LightMode, contentDescription = null) },
onClick = {
showDefaultMenu = false
Toast.makeText(context, "Theme switch in Settings.", Toast.LENGTH_SHORT).show()
onToggleDayNightMode { nextMode ->
val toastRes = when (nextMode) {
AppThemeMode.LIGHT -> R.string.toast_day_mode_enabled
AppThemeMode.DARK -> R.string.toast_night_mode_enabled
AppThemeMode.SYSTEM -> R.string.toast_day_mode_enabled
}
Toast.makeText(context, context.getString(toastRes), Toast.LENGTH_SHORT).show()
}
},
)
DropdownMenuItem(
text = { Text("Create group") },
leadingIcon = { Icon(Icons.Filled.FolderOpen, contentDescription = null) },
text = { Text(stringResource(id = R.string.menu_create_group)) },
leadingIcon = { Icon(Icons.Filled.Groups, contentDescription = null) },
onClick = {
showDefaultMenu = false
managementExpanded = true
showCreateGroupDialog = true
},
)
DropdownMenuItem(
text = { Text("Create channel") },
leadingIcon = { Icon(Icons.Filled.FolderOpen, contentDescription = null) },
onClick = {
showDefaultMenu = false
managementExpanded = true
},
)
DropdownMenuItem(
text = { Text("Saved") },
leadingIcon = { Icon(Icons.Filled.Inventory2, contentDescription = null) },
text = { Text(stringResource(id = R.string.menu_saved)) },
leadingIcon = { Icon(Icons.Filled.BookmarkBorder, contentDescription = null) },
onClick = {
showDefaultMenu = false
onOpenSaved()
},
)
DropdownMenuItem(
text = { Text("Proxy") },
leadingIcon = { Icon(Icons.Filled.NotificationsOff, contentDescription = null) },
onClick = {
showDefaultMenu = false
Toast.makeText(context, "Proxy settings will be added next.", Toast.LENGTH_SHORT).show()
},
)
}
}
}
@@ -447,22 +473,22 @@ fun ChatListScreen(
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
FilterChip(
label = "All",
label = stringResource(id = R.string.filter_all),
selected = state.selectedFilter == ChatListFilter.ALL,
onClick = { onFilterSelected(ChatListFilter.ALL) },
)
FilterChip(
label = "People",
label = stringResource(id = R.string.filter_people),
selected = state.selectedFilter == ChatListFilter.PEOPLE,
onClick = { onFilterSelected(ChatListFilter.PEOPLE) },
)
FilterChip(
label = "Groups",
label = stringResource(id = R.string.filter_groups),
selected = state.selectedFilter == ChatListFilter.GROUPS,
onClick = { onFilterSelected(ChatListFilter.GROUPS) },
)
FilterChip(
label = "Channels",
label = stringResource(id = R.string.filter_channels),
selected = state.selectedFilter == ChatListFilter.CHANNELS,
onClick = { onFilterSelected(ChatListFilter.CHANNELS) },
)
@@ -475,7 +501,7 @@ fun ChatListScreen(
) {
when {
state.isLoading -> {
CenterState(text = "Loading chats...", loading = true)
CenterState(text = stringResource(id = R.string.chats_loading), loading = true)
}
!state.errorMessage.isNullOrBlank() -> {
@@ -483,7 +509,7 @@ fun ChatListScreen(
}
state.chats.isEmpty() -> {
CenterState(text = "No chats found", loading = false)
CenterState(text = stringResource(id = R.string.chats_not_found), loading = false)
}
else -> {
@@ -759,6 +785,120 @@ fun ChatListScreen(
}
}
}
if (showCreateGroupDialog) {
AlertDialog(
onDismissRequest = { showCreateGroupDialog = false },
title = { Text(stringResource(id = R.string.chats_dialog_create_group_title)) },
text = {
OutlinedTextField(
value = quickCreateGroupTitle,
onValueChange = { quickCreateGroupTitle = it },
label = { Text(stringResource(id = R.string.chats_dialog_group_title_label)) },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
},
confirmButton = {
TextButton(
onClick = {
val title = quickCreateGroupTitle.trim()
if (title.isNotBlank()) {
onCreateGroup(title, emptyList())
showCreateGroupDialog = false
quickCreateGroupTitle = ""
}
},
) { Text(stringResource(id = R.string.common_create)) }
},
dismissButton = {
TextButton(onClick = { showCreateGroupDialog = false }) { Text(stringResource(id = R.string.common_cancel)) }
},
)
}
if (showCreateChannelDialog) {
AlertDialog(
onDismissRequest = { showCreateChannelDialog = false },
title = { Text(stringResource(id = R.string.chats_dialog_create_channel_title)) },
text = {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedTextField(
value = quickCreateChannelTitle,
onValueChange = { quickCreateChannelTitle = it },
label = { Text(stringResource(id = R.string.chats_dialog_channel_title_label)) },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
OutlinedTextField(
value = quickCreateChannelHandle,
onValueChange = { quickCreateChannelHandle = it },
label = { Text(stringResource(id = R.string.chats_dialog_channel_handle_label)) },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
}
},
confirmButton = {
TextButton(
onClick = {
val title = quickCreateChannelTitle.trim()
val handle = quickCreateChannelHandle.trim()
if (title.isNotBlank() && handle.isNotBlank()) {
onCreateChannel(title, handle, null)
showCreateChannelDialog = false
quickCreateChannelTitle = ""
quickCreateChannelHandle = ""
}
},
) { Text(stringResource(id = R.string.common_create)) }
},
dismissButton = {
TextButton(onClick = { showCreateChannelDialog = false }) { Text(stringResource(id = R.string.common_cancel)) }
},
)
}
if (showDeleteChatsDialog) {
AlertDialog(
onDismissRequest = { showDeleteChatsDialog = false },
title = { Text(stringResource(id = R.string.chats_dialog_delete_selected_title)) },
text = {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text(stringResource(id = R.string.chats_dialog_delete_selected_body))
Row(
modifier = Modifier
.fillMaxWidth()
.clickable { deleteSelectedForAll = !deleteSelectedForAll },
verticalAlignment = Alignment.CenterVertically,
) {
Checkbox(
checked = deleteSelectedForAll,
onCheckedChange = { deleteSelectedForAll = it },
)
Text(stringResource(id = R.string.chats_dialog_delete_for_all))
}
}
},
confirmButton = {
TextButton(
onClick = {
selectedChatIds.forEach { chatId ->
if (deleteSelectedForAll) onDeleteChatForAll(chatId) else onDeleteChat(chatId)
}
selectedChatIds = emptySet()
deleteSelectedForAll = false
showDeleteChatsDialog = false
},
) { Text(stringResource(id = R.string.common_delete)) }
},
dismissButton = {
TextButton(
onClick = {
showDeleteChatsDialog = false
deleteSelectedForAll = false
},
) { Text(stringResource(id = R.string.common_cancel)) }
},
)
}
}
@Composable
@@ -1505,6 +1645,10 @@ private fun CenterState(
private fun ChatItem.previewText(): String {
val raw = lastMessageText.orEmpty().trim()
val isGifImage = lastMessageType == "image" && isGifLikeUrl(raw)
val isStickerImage = lastMessageType == "image" && isStickerLikeUrl(raw)
if (isGifImage) return "🖼 GIF"
if (isStickerImage) return "🖼 Sticker"
val prefix = when (lastMessageType) {
"image" -> "🖼"
"video" -> "🎥"
@@ -1537,3 +1681,15 @@ private fun ChatItem.previewText(): String {
else -> "Media"
}
}
private fun isGifLikeUrl(url: String): Boolean {
if (url.isBlank()) return false
val normalized = url.lowercase()
return normalized.contains(".gif") || normalized.contains("giphy.com")
}
private fun isStickerLikeUrl(url: String): Boolean {
if (url.isBlank()) return false
val normalized = url.lowercase()
return normalized.contains("twemoji") || normalized.endsWith(".webp")
}

View File

@@ -1,7 +1,10 @@
package ru.daemonlord.messenger.ui.chats
import android.content.Context
import androidx.appcompat.app.AppCompatDelegate
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow
@@ -24,7 +27,10 @@ import ru.daemonlord.messenger.domain.common.AppResult
import ru.daemonlord.messenger.domain.realtime.RealtimeManager
import ru.daemonlord.messenger.domain.realtime.model.RealtimeConnectionState
import ru.daemonlord.messenger.domain.realtime.usecase.HandleRealtimeEventsUseCase
import ru.daemonlord.messenger.domain.settings.model.AppThemeMode
import ru.daemonlord.messenger.domain.settings.repository.ThemeRepository
import ru.daemonlord.messenger.domain.search.repository.SearchRepository
import ru.daemonlord.messenger.R
import javax.inject.Inject
@HiltViewModel
@@ -38,6 +44,8 @@ class ChatListViewModel @Inject constructor(
private val chatRepository: ChatRepository,
private val chatSearchRepository: ChatSearchRepository,
private val searchRepository: SearchRepository,
private val themeRepository: ThemeRepository,
@ApplicationContext private val context: Context,
) : ViewModel() {
private val selectedTab = MutableStateFlow(ChatTab.ALL)
@@ -170,6 +178,29 @@ class ChatListViewModel @Inject constructor(
}
}
fun toggleDayNightMode(onResult: (AppThemeMode) -> Unit = {}) {
viewModelScope.launch {
val current = themeRepository.getThemeMode()
val next = when (current) {
AppThemeMode.DARK -> AppThemeMode.LIGHT
AppThemeMode.LIGHT -> AppThemeMode.DARK
AppThemeMode.SYSTEM -> {
val isNight = AppCompatDelegate.getDefaultNightMode() == AppCompatDelegate.MODE_NIGHT_YES
if (isNight) AppThemeMode.LIGHT else AppThemeMode.DARK
}
}
themeRepository.setThemeMode(next)
AppCompatDelegate.setDefaultNightMode(
when (next) {
AppThemeMode.LIGHT -> AppCompatDelegate.MODE_NIGHT_NO
AppThemeMode.DARK -> AppCompatDelegate.MODE_NIGHT_YES
AppThemeMode.SYSTEM -> AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM
}
)
onResult(next)
}
}
fun onManagementChatSelected(chatId: Long?) {
_uiState.update { it.copy(selectedManageChatId = chatId) }
if (chatId != null) {
@@ -205,7 +236,7 @@ class ChatListViewModel @Inject constructor(
handle = null,
description = null,
memberIds = memberIds,
successMessage = "Group created.",
successMessageResId = R.string.chat_list_info_group_created,
)
}
@@ -217,7 +248,7 @@ class ChatListViewModel @Inject constructor(
handle = handle,
description = description,
memberIds = emptyList(),
successMessage = "Channel created.",
successMessageResId = R.string.chat_list_info_channel_created,
)
}
@@ -227,7 +258,7 @@ class ChatListViewModel @Inject constructor(
is AppResult.Success -> {
_uiState.update {
it.copy(
managementMessage = "Joined chat.",
managementMessage = context.getString(R.string.chat_list_info_joined_chat),
pendingOpenChatId = result.data.id,
)
}
@@ -242,7 +273,7 @@ class ChatListViewModel @Inject constructor(
viewModelScope.launch {
when (val result = chatRepository.leaveChat(chatId = chatId)) {
is AppResult.Success -> {
_uiState.update { it.copy(managementMessage = "Left chat.") }
_uiState.update { it.copy(managementMessage = context.getString(R.string.chat_list_info_left_chat)) }
refreshCurrentTab(forceRefresh = true)
}
is AppResult.Error -> _uiState.update { it.copy(errorMessage = result.reason.toUiMessage()) }
@@ -254,7 +285,7 @@ class ChatListViewModel @Inject constructor(
viewModelScope.launch {
when (val result = chatRepository.archiveChat(chatId = chatId)) {
is AppResult.Success -> {
_uiState.update { it.copy(managementMessage = "Чат архивирован.") }
_uiState.update { it.copy(managementMessage = context.getString(R.string.chat_list_info_archived)) }
refreshCurrentTab(forceRefresh = true)
}
is AppResult.Error -> _uiState.update { it.copy(errorMessage = result.reason.toUiMessage()) }
@@ -266,7 +297,7 @@ class ChatListViewModel @Inject constructor(
viewModelScope.launch {
when (val result = chatRepository.unarchiveChat(chatId = chatId)) {
is AppResult.Success -> {
_uiState.update { it.copy(managementMessage = "Чат возвращен из архива.") }
_uiState.update { it.copy(managementMessage = context.getString(R.string.chat_list_info_unarchived)) }
refreshCurrentTab(forceRefresh = true)
}
is AppResult.Error -> _uiState.update { it.copy(errorMessage = result.reason.toUiMessage()) }
@@ -277,7 +308,7 @@ class ChatListViewModel @Inject constructor(
fun pinChat(chatId: Long) {
viewModelScope.launch {
when (val result = chatRepository.pinChat(chatId = chatId)) {
is AppResult.Success -> _uiState.update { it.copy(managementMessage = "Чат закреплен.") }
is AppResult.Success -> _uiState.update { it.copy(managementMessage = context.getString(R.string.chat_list_info_pinned)) }
is AppResult.Error -> _uiState.update { it.copy(errorMessage = result.reason.toUiMessage()) }
}
}
@@ -286,7 +317,7 @@ class ChatListViewModel @Inject constructor(
fun unpinChat(chatId: Long) {
viewModelScope.launch {
when (val result = chatRepository.unpinChat(chatId = chatId)) {
is AppResult.Success -> _uiState.update { it.copy(managementMessage = "Чат откреплен.") }
is AppResult.Success -> _uiState.update { it.copy(managementMessage = context.getString(R.string.chat_list_info_unpinned)) }
is AppResult.Error -> _uiState.update { it.copy(errorMessage = result.reason.toUiMessage()) }
}
}
@@ -295,7 +326,7 @@ class ChatListViewModel @Inject constructor(
fun clearChat(chatId: Long) {
viewModelScope.launch {
when (val result = chatRepository.clearChat(chatId = chatId)) {
is AppResult.Success -> _uiState.update { it.copy(managementMessage = "История чата очищена.") }
is AppResult.Success -> _uiState.update { it.copy(managementMessage = context.getString(R.string.chat_list_info_history_cleared)) }
is AppResult.Error -> _uiState.update { it.copy(errorMessage = result.reason.toUiMessage()) }
}
}
@@ -304,12 +335,12 @@ class ChatListViewModel @Inject constructor(
fun updateChatTitle(chatId: Long, title: String) {
val normalized = title.trim()
if (normalized.isBlank()) {
_uiState.update { it.copy(errorMessage = "Title is required.") }
_uiState.update { it.copy(errorMessage = context.getString(R.string.chat_list_error_title_required)) }
return
}
viewModelScope.launch {
when (val result = chatRepository.updateChatTitle(chatId = chatId, title = normalized)) {
is AppResult.Success -> _uiState.update { it.copy(managementMessage = "Title updated.") }
is AppResult.Success -> _uiState.update { it.copy(managementMessage = context.getString(R.string.chat_list_info_title_updated)) }
is AppResult.Error -> _uiState.update { it.copy(errorMessage = result.reason.toUiMessage()) }
}
}
@@ -319,7 +350,7 @@ class ChatListViewModel @Inject constructor(
val normalizedTitle = title?.trim()?.ifBlank { null }
val normalizedDescription = description?.trim()?.ifBlank { null }
if (normalizedTitle == null && normalizedDescription == null) {
_uiState.update { it.copy(errorMessage = "Provide title or description.") }
_uiState.update { it.copy(errorMessage = context.getString(R.string.chat_list_error_title_or_description_required)) }
return
}
viewModelScope.launch {
@@ -331,7 +362,7 @@ class ChatListViewModel @Inject constructor(
avatarUrl = null,
)
) {
is AppResult.Success -> _uiState.update { it.copy(managementMessage = "Profile updated.") }
is AppResult.Success -> _uiState.update { it.copy(managementMessage = context.getString(R.string.chat_list_info_profile_updated)) }
is AppResult.Error -> _uiState.update { it.copy(errorMessage = result.reason.toUiMessage()) }
}
}
@@ -340,7 +371,16 @@ class ChatListViewModel @Inject constructor(
fun deleteChatForMe(chatId: Long) {
viewModelScope.launch {
when (val result = chatRepository.removeChat(chatId = chatId, forAll = false)) {
is AppResult.Success -> _uiState.update { it.copy(managementMessage = "Чат удален.") }
is AppResult.Success -> _uiState.update { it.copy(managementMessage = context.getString(R.string.chat_list_info_deleted_for_me)) }
is AppResult.Error -> _uiState.update { it.copy(errorMessage = result.reason.toUiMessage()) }
}
}
}
fun deleteChatForAll(chatId: Long) {
viewModelScope.launch {
when (val result = chatRepository.removeChat(chatId = chatId, forAll = true)) {
is AppResult.Success -> _uiState.update { it.copy(managementMessage = context.getString(R.string.chat_list_info_deleted_for_all)) }
is AppResult.Error -> _uiState.update { it.copy(errorMessage = result.reason.toUiMessage()) }
}
}
@@ -353,7 +393,11 @@ class ChatListViewModel @Inject constructor(
when (val updated = chatRepository.updateChatNotifications(chatId = chatId, muted = !current.data.muted)) {
is AppResult.Success -> _uiState.update {
it.copy(
managementMessage = if (updated.data.muted) "Уведомления выключены." else "Уведомления включены.",
managementMessage = if (updated.data.muted) {
context.getString(R.string.chat_list_info_notifications_disabled)
} else {
context.getString(R.string.chat_list_info_notifications_enabled)
},
)
}
is AppResult.Error -> _uiState.update { it.copy(errorMessage = updated.reason.toUiMessage()) }
@@ -368,7 +412,12 @@ class ChatListViewModel @Inject constructor(
viewModelScope.launch {
when (val result = chatRepository.createInviteLink(chatId = chatId)) {
is AppResult.Success -> _uiState.update {
it.copy(managementMessage = "Invite: ${result.data.inviteUrl}")
it.copy(
managementMessage = context.getString(
R.string.chat_list_info_invite_created,
result.data.inviteUrl,
),
)
}
is AppResult.Error -> _uiState.update { it.copy(errorMessage = result.reason.toUiMessage()) }
}
@@ -379,7 +428,14 @@ class ChatListViewModel @Inject constructor(
viewModelScope.launch {
when (val result = chatRepository.addMember(chatId = chatId, userId = userId)) {
is AppResult.Success -> {
_uiState.update { it.copy(managementMessage = "Added ${result.data.name}") }
_uiState.update {
it.copy(
managementMessage = context.getString(
R.string.chat_list_info_member_added,
result.data.name,
),
)
}
loadMembersAndBans(chatId)
}
is AppResult.Error -> _uiState.update { it.copy(errorMessage = result.reason.toUiMessage()) }
@@ -391,7 +447,15 @@ class ChatListViewModel @Inject constructor(
viewModelScope.launch {
when (val result = chatRepository.updateMemberRole(chatId = chatId, userId = userId, role = role)) {
is AppResult.Success -> {
_uiState.update { it.copy(managementMessage = "Role updated: ${result.data.name} -> ${result.data.role}") }
_uiState.update {
it.copy(
managementMessage = context.getString(
R.string.chat_list_info_member_role_updated,
result.data.name,
result.data.role,
),
)
}
loadMembersAndBans(chatId)
}
is AppResult.Error -> _uiState.update { it.copy(errorMessage = result.reason.toUiMessage()) }
@@ -403,7 +467,7 @@ class ChatListViewModel @Inject constructor(
viewModelScope.launch {
when (val result = chatRepository.removeMember(chatId = chatId, userId = userId)) {
is AppResult.Success -> {
_uiState.update { it.copy(managementMessage = "Member removed.") }
_uiState.update { it.copy(managementMessage = context.getString(R.string.chat_list_info_member_removed)) }
loadMembersAndBans(chatId)
}
is AppResult.Error -> _uiState.update { it.copy(errorMessage = result.reason.toUiMessage()) }
@@ -415,7 +479,7 @@ class ChatListViewModel @Inject constructor(
viewModelScope.launch {
when (val result = chatRepository.banMember(chatId = chatId, userId = userId)) {
is AppResult.Success -> {
_uiState.update { it.copy(managementMessage = "Member banned.") }
_uiState.update { it.copy(managementMessage = context.getString(R.string.chat_list_info_member_banned)) }
loadMembersAndBans(chatId)
}
is AppResult.Error -> _uiState.update { it.copy(errorMessage = result.reason.toUiMessage()) }
@@ -427,7 +491,7 @@ class ChatListViewModel @Inject constructor(
viewModelScope.launch {
when (val result = chatRepository.unbanMember(chatId = chatId, userId = userId)) {
is AppResult.Success -> {
_uiState.update { it.copy(managementMessage = "Member unbanned.") }
_uiState.update { it.copy(managementMessage = context.getString(R.string.chat_list_info_member_unbanned)) }
loadMembersAndBans(chatId)
}
is AppResult.Error -> _uiState.update { it.copy(errorMessage = result.reason.toUiMessage()) }
@@ -517,11 +581,11 @@ class ChatListViewModel @Inject constructor(
handle: String?,
description: String?,
memberIds: List<Long>,
successMessage: String,
successMessageResId: Int,
) {
val normalizedTitle = title.trim()
if (normalizedTitle.isBlank()) {
_uiState.update { it.copy(errorMessage = "Title is required.") }
_uiState.update { it.copy(errorMessage = context.getString(R.string.chat_list_error_title_required)) }
return
}
viewModelScope.launch {
@@ -538,7 +602,7 @@ class ChatListViewModel @Inject constructor(
is AppResult.Success -> {
_uiState.update {
it.copy(
managementMessage = successMessage,
managementMessage = context.getString(successMessageResId),
pendingOpenChatId = result.data.id,
)
}
@@ -610,16 +674,15 @@ class ChatListViewModel @Inject constructor(
private fun AppError.toUiMessage(): String {
return when (this) {
AppError.Network -> "Network error while syncing chats."
AppError.Unauthorized -> "Session expired. Please log in again."
AppError.InvalidCredentials -> "Authorization failed."
is AppError.Server -> "Server error while loading chats."
is AppError.Unknown -> "Unknown error while loading chats."
AppError.Network -> context.getString(R.string.chat_list_error_network_sync)
AppError.Unauthorized -> context.getString(R.string.chat_list_error_session_expired)
AppError.InvalidCredentials -> context.getString(R.string.chat_list_error_authorization_failed)
is AppError.Server -> context.getString(R.string.chat_list_error_server_loading)
is AppError.Unknown -> context.getString(R.string.chat_list_error_unknown_loading)
}
}
override fun onCleared() {
handleRealtimeEventsUseCase.stop()
super.onCleared()
}
}

View File

@@ -30,12 +30,14 @@ import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import kotlinx.coroutines.flow.collectLatest
import ru.daemonlord.messenger.R
@Composable
fun ContactsRoute(
@@ -104,7 +106,7 @@ private fun ContactsScreen(
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
TopAppBar(
title = { Text("Contacts") },
title = { Text(stringResource(id = R.string.contacts_title)) },
)
PullToRefreshBox(
isRefreshing = state.isRefreshing,
@@ -122,7 +124,7 @@ private fun ContactsScreen(
onValueChange = onQueryChanged,
modifier = Modifier.fillMaxWidth(),
singleLine = true,
label = { Text("Search contacts/users") },
label = { Text(stringResource(id = R.string.contacts_search_label)) },
)
Row(
modifier = Modifier.fillMaxWidth(),
@@ -134,10 +136,10 @@ private fun ContactsScreen(
onValueChange = onAddByEmailChanged,
modifier = Modifier.weight(1f),
singleLine = true,
label = { Text("Add by email") },
label = { Text(stringResource(id = R.string.contacts_add_by_email_label)) },
)
Button(onClick = onAddContactByEmail) {
Text("Add")
Text(stringResource(id = R.string.common_create))
}
}
@@ -172,7 +174,7 @@ private fun ContactsScreen(
if (state.isSearchingUsers || state.query.trim().length >= 2) {
item(key = "search_header") {
Text(
text = "Search results",
text = stringResource(id = R.string.contacts_search_results),
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold,
)
@@ -197,7 +199,7 @@ private fun ContactsScreen(
)
}
OutlinedButton(onClick = { onAddContact(user.id) }) {
Text("Add")
Text(stringResource(id = R.string.common_create))
}
}
}
@@ -205,7 +207,7 @@ private fun ContactsScreen(
item(key = "contacts_header") {
Text(
text = "My contacts",
text = stringResource(id = R.string.contacts_my_contacts),
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold,
modifier = Modifier.padding(top = 4.dp),
@@ -226,13 +228,13 @@ private fun ContactsScreen(
overflow = TextOverflow.Ellipsis,
)
Text(
text = contact.username?.let { "@$it" } ?: "last seen recently",
text = contact.username?.let { "@$it" } ?: stringResource(id = R.string.contacts_last_seen_recently),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
OutlinedButton(onClick = { onRemoveContact(contact.id) }) {
Text("Remove")
Text(stringResource(id = R.string.contacts_remove))
}
}
}
@@ -240,7 +242,7 @@ private fun ContactsScreen(
if (state.contacts.isEmpty()) {
item(key = "empty_contacts") {
Text(
text = "No contacts yet.",
text = stringResource(id = R.string.contacts_empty),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)

View File

@@ -2,7 +2,9 @@ package ru.daemonlord.messenger.ui.contacts
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.android.lifecycle.HiltViewModel
import android.content.Context
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
@@ -10,6 +12,7 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import ru.daemonlord.messenger.R
import ru.daemonlord.messenger.domain.account.repository.AccountRepository
import ru.daemonlord.messenger.domain.common.AppError
import ru.daemonlord.messenger.domain.common.AppResult
@@ -18,6 +21,7 @@ import javax.inject.Inject
@HiltViewModel
class ContactsViewModel @Inject constructor(
private val accountRepository: AccountRepository,
@ApplicationContext private val context: Context,
) : ViewModel() {
private val _uiState = MutableStateFlow(ContactsUiState())
@@ -76,7 +80,7 @@ class ContactsViewModel @Inject constructor(
viewModelScope.launch {
when (val result = accountRepository.addContact(userId = userId)) {
is AppResult.Success -> {
_uiState.update { it.copy(infoMessage = "Contact added.") }
_uiState.update { it.copy(infoMessage = context.getString(R.string.contacts_info_added)) }
loadContacts(forceRefresh = true)
}
is AppResult.Error -> _uiState.update { it.copy(errorMessage = result.reason.toUiMessage()) }
@@ -87,7 +91,7 @@ class ContactsViewModel @Inject constructor(
fun addContactByEmail() {
val email = uiState.value.addByEmail.trim()
if (email.isBlank()) {
_uiState.update { it.copy(errorMessage = "Email is required.") }
_uiState.update { it.copy(errorMessage = context.getString(R.string.contacts_error_email_required)) }
return
}
viewModelScope.launch {
@@ -96,7 +100,7 @@ class ContactsViewModel @Inject constructor(
_uiState.update {
it.copy(
addByEmail = "",
infoMessage = "Contact added by email.",
infoMessage = context.getString(R.string.contacts_info_added_by_email),
)
}
loadContacts(forceRefresh = true)
@@ -110,7 +114,7 @@ class ContactsViewModel @Inject constructor(
viewModelScope.launch {
when (val result = accountRepository.removeContact(userId = userId)) {
is AppResult.Success -> {
_uiState.update { it.copy(infoMessage = "Contact removed.") }
_uiState.update { it.copy(infoMessage = context.getString(R.string.contacts_info_removed)) }
loadContacts(forceRefresh = true)
}
is AppResult.Error -> _uiState.update { it.copy(errorMessage = result.reason.toUiMessage()) }
@@ -148,11 +152,11 @@ class ContactsViewModel @Inject constructor(
private fun AppError.toUiMessage(): String {
return when (this) {
AppError.Network -> "Network error."
AppError.Unauthorized -> "Session expired."
AppError.InvalidCredentials -> "Authorization error."
is AppError.Server -> this.message ?: "Server error."
is AppError.Unknown -> this.cause?.message ?: "Unknown error."
AppError.Network -> context.getString(R.string.error_network)
AppError.Unauthorized -> context.getString(R.string.error_session_expired)
AppError.InvalidCredentials -> context.getString(R.string.error_authorization)
is AppError.Server -> this.message ?: context.getString(R.string.error_server)
is AppError.Unknown -> this.cause?.message ?: context.getString(R.string.error_unknown)
}
}
}

View File

@@ -41,6 +41,7 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.core.content.ContextCompat
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@@ -57,6 +58,7 @@ import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.navigation
import androidx.navigation.compose.rememberNavController
import ru.daemonlord.messenger.R
import ru.daemonlord.messenger.ui.auth.AuthViewModel
import ru.daemonlord.messenger.ui.auth.LoginScreen
import ru.daemonlord.messenger.ui.auth.reset.ResetPasswordRoute
@@ -68,8 +70,10 @@ import ru.daemonlord.messenger.ui.profile.ProfileRoute
import ru.daemonlord.messenger.ui.settings.SettingsRoute
private object Routes {
const val Startup = "startup"
const val AuthGraph = "auth_graph"
const val Login = "login"
const val AddAccountLogin = "add_account_login"
const val VerifyEmail = "verify_email"
const val ResetPassword = "reset_password"
const val Chats = "chats"
@@ -167,25 +171,76 @@ fun MessengerNavHost(
Box(modifier = Modifier.fillMaxSize()) {
NavHost(
navController = navController,
startDestination = Routes.AuthGraph,
startDestination = Routes.Startup,
) {
composable(route = Routes.Startup) {
SessionCheckingScreen()
}
navigation(
route = Routes.AuthGraph,
startDestination = Routes.Login,
) {
composable(route = Routes.Login) {
if (uiState.isCheckingSession) {
SessionCheckingScreen()
} else {
LoginScreen(
state = uiState,
onEmailChanged = viewModel::onEmailChanged,
onPasswordChanged = viewModel::onPasswordChanged,
onLoginClick = viewModel::login,
onOpenVerifyEmail = { navController.navigate(Routes.VerifyEmail) },
onOpenResetPassword = { navController.navigate(Routes.ResetPassword) },
)
LoginScreen(
state = uiState,
headerTitle = context.getString(R.string.auth_header_login),
onEmailChanged = viewModel::onEmailChanged,
onNameChanged = viewModel::onNameChanged,
onUsernameChanged = viewModel::onUsernameChanged,
onPasswordChanged = viewModel::onPasswordChanged,
onOtpCodeChanged = viewModel::onOtpCodeChanged,
onRecoveryCodeChanged = viewModel::onRecoveryCodeChanged,
onToggleRecoveryCodeMode = viewModel::toggleRecoveryCodeMode,
onContinueEmail = viewModel::continueWithEmail,
onSubmitStep = viewModel::submitAuthStep,
onBackToEmail = viewModel::backToEmailStep,
onOpenVerifyEmail = { navController.navigate(Routes.VerifyEmail) },
onOpenResetPassword = { navController.navigate(Routes.ResetPassword) },
)
}
composable(route = Routes.AddAccountLogin) { entry ->
val addAccountViewModel: AuthViewModel = hiltViewModel(entry)
val addAccountState by addAccountViewModel.uiState.collectAsState()
val lastCompletedNonce = remember { mutableStateOf(0L) }
LaunchedEffect(Unit) {
addAccountViewModel.startAddAccountFlow()
}
LaunchedEffect(addAccountState.authCompletedNonce) {
val nonce = addAccountState.authCompletedNonce
if (nonce == 0L || nonce == lastCompletedNonce.value) return@LaunchedEffect
lastCompletedNonce.value = nonce
viewModel.recheckSession()
navController.navigate(Routes.Chats) {
popUpTo(navController.graph.findStartDestination().id) {
saveState = false
}
launchSingleTop = true
restoreState = false
}
}
LoginScreen(
state = addAccountState,
headerTitle = context.getString(R.string.settings_add_account),
onEmailChanged = addAccountViewModel::onEmailChanged,
onNameChanged = addAccountViewModel::onNameChanged,
onUsernameChanged = addAccountViewModel::onUsernameChanged,
onPasswordChanged = addAccountViewModel::onPasswordChanged,
onOtpCodeChanged = addAccountViewModel::onOtpCodeChanged,
onRecoveryCodeChanged = addAccountViewModel::onRecoveryCodeChanged,
onToggleRecoveryCodeMode = addAccountViewModel::toggleRecoveryCodeMode,
onContinueEmail = addAccountViewModel::continueWithEmail,
onSubmitStep = addAccountViewModel::submitAuthStep,
onBackToEmail = {
if (addAccountState.step == ru.daemonlord.messenger.ui.auth.AuthStep.EMAIL) {
navController.popBackStack()
} else {
addAccountViewModel.backToEmailStep()
}
},
onOpenVerifyEmail = { navController.navigate(Routes.VerifyEmail) },
onOpenResetPassword = { navController.navigate(Routes.ResetPassword) },
)
}
}
@@ -239,8 +294,18 @@ fun MessengerNavHost(
composable(route = Routes.Settings) {
SettingsRoute(
onBackToChats = { navController.navigate(Routes.Chats) },
onOpenProfile = { navController.navigate(Routes.Profile) },
onAddAccount = { navController.navigate(Routes.AddAccountLogin) },
onSwitchAccount = {
viewModel.recheckSession()
navController.navigate(Routes.Chats) {
popUpTo(navController.graph.findStartDestination().id) {
saveState = false
}
launchSingleTop = true
restoreState = false
}
},
onLogout = viewModel::logout,
onMainBarVisibilityChanged = { isMainBarVisible = it },
)
@@ -248,8 +313,6 @@ fun MessengerNavHost(
composable(route = Routes.Profile) {
ProfileRoute(
onBackToChats = { navController.navigate(Routes.Chats) },
onOpenSettings = { navController.navigate(Routes.Settings) },
onMainBarVisibilityChanged = { isMainBarVisible = it },
)
}
@@ -314,9 +377,9 @@ private fun MainBottomBar(
NavigationBarItem(
selected = currentRoute == Routes.Chats,
onClick = { onNavigate(Routes.Chats) },
icon = { Icon(Icons.AutoMirrored.Filled.Chat, contentDescription = "Chats") },
icon = { Icon(Icons.AutoMirrored.Filled.Chat, contentDescription = stringResource(id = R.string.nav_chats)) },
label = {
androidx.compose.material3.Text("Chats", maxLines = 1, overflow = TextOverflow.Ellipsis)
androidx.compose.material3.Text(stringResource(id = R.string.nav_chats), maxLines = 1, overflow = TextOverflow.Ellipsis)
},
alwaysShowLabel = true,
colors = NavigationBarItemDefaults.colors(
@@ -330,9 +393,9 @@ private fun MainBottomBar(
NavigationBarItem(
selected = currentRoute == Routes.Contacts,
onClick = { onNavigate(Routes.Contacts) },
icon = { Icon(Icons.Filled.Contacts, contentDescription = "Contacts") },
icon = { Icon(Icons.Filled.Contacts, contentDescription = stringResource(id = R.string.nav_contacts)) },
label = {
androidx.compose.material3.Text("Contacts", maxLines = 1, overflow = TextOverflow.Ellipsis)
androidx.compose.material3.Text(stringResource(id = R.string.nav_contacts), maxLines = 1, overflow = TextOverflow.Ellipsis)
},
alwaysShowLabel = true,
colors = NavigationBarItemDefaults.colors(
@@ -346,9 +409,9 @@ private fun MainBottomBar(
NavigationBarItem(
selected = currentRoute == Routes.Settings,
onClick = { onNavigate(Routes.Settings) },
icon = { Icon(Icons.Filled.Settings, contentDescription = "Settings") },
icon = { Icon(Icons.Filled.Settings, contentDescription = stringResource(id = R.string.nav_settings)) },
label = {
androidx.compose.material3.Text("Settings", maxLines = 1, overflow = TextOverflow.Ellipsis)
androidx.compose.material3.Text(stringResource(id = R.string.nav_settings), maxLines = 1, overflow = TextOverflow.Ellipsis)
},
alwaysShowLabel = true,
colors = NavigationBarItemDefaults.colors(
@@ -362,9 +425,9 @@ private fun MainBottomBar(
NavigationBarItem(
selected = currentRoute == Routes.Profile,
onClick = { onNavigate(Routes.Profile) },
icon = { Icon(Icons.Filled.Person, contentDescription = "Profile") },
icon = { Icon(Icons.Filled.Person, contentDescription = stringResource(id = R.string.nav_profile)) },
label = {
androidx.compose.material3.Text("Profile", maxLines = 1, overflow = TextOverflow.Ellipsis)
androidx.compose.material3.Text(stringResource(id = R.string.nav_profile), maxLines = 1, overflow = TextOverflow.Ellipsis)
},
alwaysShowLabel = true,
colors = NavigationBarItemDefaults.colors(

View File

@@ -8,32 +8,40 @@ import android.os.Build
import android.provider.MediaStore
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.gestures.detectTransformGestures
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
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.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawing
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.AddAPhoto
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
@@ -41,38 +49,48 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.unit.dp
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.window.Dialog
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import kotlinx.coroutines.flow.collectLatest
import coil.compose.AsyncImage
import kotlinx.coroutines.flow.collectLatest
import ru.daemonlord.messenger.R
import ru.daemonlord.messenger.ui.account.AccountViewModel
import java.io.ByteArrayOutputStream
import kotlin.math.max
import androidx.compose.ui.res.stringResource
@Composable
fun ProfileRoute(
onBackToChats: () -> Unit,
onOpenSettings: () -> Unit,
onMainBarVisibilityChanged: (Boolean) -> Unit,
viewModel: AccountViewModel = hiltViewModel(),
) {
ProfileScreen(
onBackToChats = onBackToChats,
onOpenSettings = onOpenSettings,
onMainBarVisibilityChanged = onMainBarVisibilityChanged,
viewModel = viewModel,
)
}
@Composable
@OptIn(ExperimentalMaterial3Api::class)
fun ProfileScreen(
onBackToChats: () -> Unit,
onOpenSettings: () -> Unit,
onMainBarVisibilityChanged: (Boolean) -> Unit,
viewModel: AccountViewModel,
) {
@@ -82,6 +100,8 @@ fun ProfileScreen(
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()) }
var editMode by remember { mutableStateOf(false) }
var pendingAvatarBitmap by remember { mutableStateOf<Bitmap?>(null) }
val scrollState = rememberScrollState()
LaunchedEffect(Unit) {
@@ -101,147 +121,397 @@ fun ProfileScreen(
}
}
val context = androidx.compose.ui.platform.LocalContext.current
val context = LocalContext.current
val isTabletLayout = LocalConfiguration.current.screenWidthDp >= 840
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
}
pendingAvatarBitmap = uri?.toBitmap(context)
}
Box(
modifier = Modifier
.fillMaxSize()
.windowInsetsPadding(WindowInsets.safeDrawing)
.navigationBarsPadding(),
.background(MaterialTheme.colorScheme.background)
.windowInsetsPadding(WindowInsets.safeDrawing),
contentAlignment = Alignment.TopCenter,
) {
Column(
modifier = Modifier
.fillMaxWidth()
.then(if (isTabletLayout) Modifier.widthIn(max = 720.dp) else Modifier)
.padding(bottom = 96.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
TopAppBar(
title = { Text("Profile") },
)
Column(
modifier = Modifier
.fillMaxWidth()
.verticalScroll(scrollState)
.padding(start = 16.dp, end = 16.dp, bottom = 16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
.padding(bottom = 96.dp),
) {
if (!avatarUrl.isBlank()) {
Box(
modifier = Modifier.fillMaxWidth(),
contentAlignment = Alignment.Center,
) {
AsyncImage(
model = avatarUrl,
contentDescription = "Avatar",
if (!editMode) {
Box(
modifier = Modifier
.size(180.dp)
.aspectRatio(1f)
.clip(CircleShape)
.border(
width = 1.dp,
color = MaterialTheme.colorScheme.outlineVariant,
shape = CircleShape,
.fillMaxWidth()
.height(305.dp)
.background(
Brush.verticalGradient(
colors = listOf(
MaterialTheme.colorScheme.primaryContainer,
MaterialTheme.colorScheme.secondaryContainer,
MaterialTheme.colorScheme.tertiaryContainer,
),
),
),
)
) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(horizontal = 16.dp, vertical = 14.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
Spacer(modifier = Modifier.height(6.dp))
if (avatarUrl.isNotBlank()) {
AsyncImage(
model = avatarUrl,
contentDescription = stringResource(id = R.string.profile_avatar_content_description),
modifier = Modifier
.size(108.dp)
.clip(CircleShape)
.border(1.dp, MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.5f), CircleShape),
)
} else {
Box(
modifier = Modifier
.size(108.dp)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.2f)),
contentAlignment = Alignment.Center,
) {
Text(
text = name.firstOrNull()?.uppercase() ?: "?",
style = MaterialTheme.typography.headlineMedium,
color = MaterialTheme.colorScheme.onPrimaryContainer,
)
}
}
Text(
text = if (name.isBlank()) stringResource(id = R.string.profile_user_fallback) else name,
style = MaterialTheme.typography.headlineSmall,
color = MaterialTheme.colorScheme.onPrimaryContainer,
fontWeight = FontWeight.SemiBold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Text(
stringResource(id = R.string.chat_status_online),
color = MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.8f),
style = MaterialTheme.typography.bodyLarge,
)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
HeroActionButton(
label = stringResource(id = R.string.profile_choose_photo),
icon = Icons.Filled.AddAPhoto,
modifier = Modifier.weight(1f),
) {
pickAvatarLauncher.launch("image/*")
}
HeroActionButton(
label = stringResource(id = R.string.profile_edit),
icon = Icons.Filled.Edit,
modifier = Modifier.weight(1f),
) {
editMode = true
}
}
}
}
}
Column(
modifier = Modifier.padding(horizontal = 16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
if (!editMode) {
Surface(
color = MaterialTheme.colorScheme.surfaceContainer,
shape = RoundedCornerShape(22.dp),
modifier = Modifier
.fillMaxWidth()
.offset(y = (-22).dp),
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(14.dp),
) {
val notSet = stringResource(id = R.string.profile_not_set)
ProfileInfoRow(stringResource(id = R.string.auth_label_email), profile?.email.orEmpty())
ProfileInfoRow(stringResource(id = R.string.profile_bio), bio.ifBlank { notSet })
ProfileInfoRow(
stringResource(id = R.string.auth_label_username),
if (username.isBlank()) notSet else "@$username",
)
ProfileInfoRow(stringResource(id = R.string.auth_label_name), name.ifBlank { notSet })
}
}
}
if (editMode) {
Surface(
color = MaterialTheme.colorScheme.surfaceContainer,
shape = RoundedCornerShape(22.dp),
modifier = Modifier
.fillMaxWidth()
.padding(top = 12.dp),
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
Text(stringResource(id = R.string.profile_edit_profile), style = MaterialTheme.typography.titleMedium)
HeroActionButton(
label = stringResource(id = R.string.profile_choose_photo),
icon = Icons.Filled.AddAPhoto,
modifier = Modifier.fillMaxWidth(),
) {
pickAvatarLauncher.launch("image/*")
}
OutlinedTextField(
value = name,
onValueChange = { name = it },
label = { Text(stringResource(id = R.string.auth_label_name)) },
modifier = Modifier.fillMaxWidth(),
)
OutlinedTextField(
value = username,
onValueChange = { username = it },
label = { Text(stringResource(id = R.string.auth_label_username)) },
modifier = Modifier.fillMaxWidth(),
)
OutlinedTextField(
value = bio,
onValueChange = { bio = it },
label = { Text(stringResource(id = R.string.profile_bio)) },
modifier = Modifier.fillMaxWidth(),
)
OutlinedTextField(
value = avatarUrl,
onValueChange = { avatarUrl = it },
label = { Text(stringResource(id = R.string.profile_avatar_url)) },
modifier = Modifier.fillMaxWidth(),
)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
TextButton(onClick = { editMode = false }, modifier = Modifier.weight(1f)) {
Text(stringResource(id = R.string.common_cancel))
}
Button(
onClick = {
viewModel.updateProfile(
name = name,
username = username,
bio = bio.ifBlank { null },
avatarUrl = avatarUrl.ifBlank { null },
)
editMode = false
},
enabled = !state.isSaving && name.isNotBlank() && username.isNotBlank(),
modifier = Modifier.weight(1f),
) {
Text(stringResource(id = R.string.common_save))
}
}
if (state.isSaving) {
CircularProgressIndicator(modifier = Modifier.size(22.dp), strokeWidth = 2.dp)
}
}
}
}
if (!state.message.isNullOrBlank()) {
Text(
text = state.message.orEmpty(),
color = MaterialTheme.colorScheme.primary,
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.padding(horizontal = 4.dp),
)
}
if (!state.errorMessage.isNullOrBlank()) {
Text(
text = state.errorMessage.orEmpty(),
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.padding(horizontal = 4.dp),
)
}
}
}
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 },
)
}
pendingAvatarBitmap?.let { bitmap ->
AvatarCropDialog(
bitmap = bitmap,
onDismiss = { pendingAvatarBitmap = null },
onConfirm = { croppedBytes ->
viewModel.uploadAvatar(
fileName = "avatar.jpg",
mimeType = "image/jpeg",
bytes = croppedBytes,
) { uploadedUrl ->
avatarUrl = uploadedUrl
}
pendingAvatarBitmap = null
},
enabled = !state.isSaving && name.isNotBlank() && username.isNotBlank(),
)
}
}
@Composable
private fun HeroActionButton(
label: String,
icon: androidx.compose.ui.graphics.vector.ImageVector,
modifier: Modifier = Modifier,
onClick: () -> Unit,
) {
Surface(
color = MaterialTheme.colorScheme.surfaceContainerHighest.copy(alpha = 0.8f),
shape = RoundedCornerShape(16.dp),
modifier = modifier,
) {
Button(
onClick = onClick,
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(),
) {
Text("Open settings")
}
OutlinedButton(
onClick = onBackToChats,
modifier = Modifier.fillMaxWidth(),
) {
Text("Back to chats")
}
}
Icon(icon, contentDescription = null)
Text(label, modifier = Modifier.padding(start = 6.dp))
}
}
}
private fun Uri.toSquareJpeg(context: Context): ByteArray? {
val bitmap = runCatching {
@Composable
private fun ProfileInfoRow(label: String, value: String) {
Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
Text(text = value, style = MaterialTheme.typography.titleLarge)
Text(text = label, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
@Composable
private fun AvatarCropDialog(
bitmap: Bitmap,
onDismiss: () -> Unit,
onConfirm: (ByteArray) -> Unit,
) {
var scale by remember(bitmap) { mutableStateOf(1f) }
var offset by remember(bitmap) { mutableStateOf(Offset.Zero) }
var viewportSize by remember { mutableStateOf(IntSize.Zero) }
fun clampOffset(raw: Offset, currentScale: Float, viewportPx: Float): Offset {
val baseScale = max(viewportPx / bitmap.width.toFloat(), viewportPx / bitmap.height.toFloat())
val displayedWidth = bitmap.width * baseScale * currentScale
val displayedHeight = bitmap.height * baseScale * currentScale
val maxOffsetX = max(0f, (displayedWidth - viewportPx) / 2f)
val maxOffsetY = max(0f, (displayedHeight - viewportPx) / 2f)
return Offset(
x = raw.x.coerceIn(-maxOffsetX, maxOffsetX),
y = raw.y.coerceIn(-maxOffsetY, maxOffsetY),
)
}
Dialog(onDismissRequest = onDismiss) {
Surface(
shape = RoundedCornerShape(18.dp),
color = MaterialTheme.colorScheme.surface,
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
Text(stringResource(id = R.string.profile_crop_avatar), style = MaterialTheme.typography.titleMedium)
Box(
modifier = Modifier
.fillMaxWidth()
.aspectRatio(1f)
.clip(RoundedCornerShape(16.dp))
.background(MaterialTheme.colorScheme.surfaceContainerHighest)
.clipToBounds()
.onSizeChanged { viewportSize = it }
.pointerInput(bitmap, viewportSize, scale, offset) {
detectTransformGestures { _, pan, zoom, _ ->
val viewport = viewportSize.width.toFloat().coerceAtLeast(1f)
val newScale = (scale * zoom).coerceIn(1f, 4f)
scale = newScale
offset = clampOffset(offset + pan, newScale, viewport)
}
},
) {
androidx.compose.foundation.Image(
bitmap = bitmap.asImageBitmap(),
contentDescription = stringResource(id = R.string.profile_avatar_crop_preview),
contentScale = ContentScale.Crop,
modifier = Modifier
.fillMaxSize()
.graphicsLayer {
scaleX = scale
scaleY = scale
translationX = offset.x
translationY = offset.y
},
)
}
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
TextButton(
onClick = onDismiss,
modifier = Modifier.weight(1f),
) {
Text(stringResource(id = R.string.common_cancel))
}
Button(
onClick = {
val viewportPx = viewportSize.width.toFloat()
if (viewportPx <= 1f) return@Button
val baseScale = max(viewportPx / bitmap.width.toFloat(), viewportPx / bitmap.height.toFloat())
val fullScale = baseScale * scale
val centerX = viewportPx / 2f + offset.x
val centerY = viewportPx / 2f + offset.y
val left = ((0f - centerX) / fullScale + bitmap.width / 2f)
val top = ((0f - centerY) / fullScale + bitmap.height / 2f)
val side = (viewportPx / fullScale).coerceAtMost(minOf(bitmap.width, bitmap.height).toFloat())
val safeLeft = left.coerceIn(0f, bitmap.width - side)
val safeTop = top.coerceIn(0f, bitmap.height - side)
val cropBitmap = Bitmap.createBitmap(
bitmap,
safeLeft.toInt(),
safeTop.toInt(),
side.toInt().coerceAtLeast(1),
side.toInt().coerceAtLeast(1),
)
val output = ByteArrayOutputStream()
val ok = cropBitmap.compress(Bitmap.CompressFormat.JPEG, 92, output)
if (ok) onConfirm(output.toByteArray())
},
modifier = Modifier.weight(1f),
) {
Text(stringResource(id = R.string.profile_use_crop))
}
}
Text(
text = stringResource(id = R.string.profile_crop_hint),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
private fun Uri.toBitmap(context: Context): Bitmap? {
return runCatching {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
val src = ImageDecoder.createSource(context.contentResolver, this)
ImageDecoder.decodeBitmap(src)
@@ -249,18 +519,5 @@ private fun Uri.toSquareJpeg(context: Context): ByteArray? {
@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)
}.getOrNull()
}

View File

@@ -1,8 +1,11 @@
package ru.daemonlord.messenger.ui.settings
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
@@ -10,47 +13,96 @@ 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.size
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
import androidx.compose.material.icons.filled.AccountCircle
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.BatterySaver
import androidx.compose.material.icons.filled.Chat
import androidx.compose.material.icons.filled.DeleteOutline
import androidx.compose.material.icons.filled.Devices
import androidx.compose.material.icons.filled.Folder
import androidx.compose.material.icons.filled.Language
import androidx.compose.material.icons.filled.Lock
import androidx.compose.material.icons.filled.Notifications
import androidx.compose.material.icons.filled.Security
import androidx.compose.material.icons.filled.Storage
import androidx.compose.material.icons.filled.Visibility
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExposedDropdownMenuBox
import androidx.compose.material3.ExposedDropdownMenuDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.RadioButton
import androidx.compose.material3.Surface
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.material3.TextButton
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.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.appcompat.app.AppCompatDelegate
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import kotlinx.coroutines.flow.collectLatest
import coil.compose.AsyncImage
import ru.daemonlord.messenger.R
import ru.daemonlord.messenger.domain.settings.model.AppLanguage
import ru.daemonlord.messenger.domain.settings.model.AppThemeMode
import ru.daemonlord.messenger.ui.account.AccountUiState
import ru.daemonlord.messenger.ui.account.AccountViewModel
private enum class SettingsFolder {
Account,
Chat,
Privacy,
Notifications,
Data,
Folders,
Devices,
Power,
Language,
}
@Composable
fun SettingsRoute(
onBackToChats: () -> Unit,
onOpenProfile: () -> Unit,
onAddAccount: () -> Unit,
onSwitchAccount: () -> Unit,
onLogout: () -> Unit,
onMainBarVisibilityChanged: (Boolean) -> Unit,
viewModel: AccountViewModel = hiltViewModel(),
) {
SettingsScreen(
onBackToChats = onBackToChats,
onOpenProfile = onOpenProfile,
onAddAccount = onAddAccount,
onSwitchAccount = onSwitchAccount,
onLogout = onLogout,
onMainBarVisibilityChanged = onMainBarVisibilityChanged,
viewModel = viewModel,
@@ -58,295 +110,543 @@ fun SettingsRoute(
}
@Composable
@OptIn(ExperimentalMaterial3Api::class)
fun SettingsScreen(
onBackToChats: () -> Unit,
onOpenProfile: () -> Unit,
onAddAccount: () -> Unit,
onSwitchAccount: () -> Unit,
onLogout: () -> Unit,
onMainBarVisibilityChanged: (Boolean) -> 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("") }
var nightMode by remember { mutableIntStateOf(AppCompatDelegate.getDefaultNightMode()) }
val scrollState = rememberScrollState()
val isTabletLayout = LocalConfiguration.current.screenWidthDp >= 840
val isTablet = LocalConfiguration.current.screenWidthDp >= 840
var folder by rememberSaveable { mutableStateOf<SettingsFolder?>(null) }
LaunchedEffect(Unit) {
viewModel.refresh()
viewModel.refreshRecoveryStatus()
onMainBarVisibilityChanged(true)
}
LaunchedEffect(scrollState) {
var prevOffset = 0
snapshotFlow { scrollState.value }
.collectLatest { offset ->
when {
offset == 0 -> onMainBarVisibilityChanged(true)
offset > prevOffset -> onMainBarVisibilityChanged(false)
offset < prevOffset -> onMainBarVisibilityChanged(true)
}
prevOffset = offset
}
}
Box(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.background)
.windowInsetsPadding(WindowInsets.safeDrawing),
contentAlignment = Alignment.TopCenter,
) {
Column(
modifier = Modifier
.fillMaxWidth()
.then(if (isTabletLayout) Modifier.widthIn(max = 720.dp) else Modifier)
.padding(bottom = 96.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
.then(if (isTablet) Modifier.widthIn(max = 720.dp) else Modifier),
) {
TopAppBar(
title = { Text("Settings") },
)
Column(
modifier = Modifier
.fillMaxWidth()
.verticalScroll(scrollState)
.padding(start = 16.dp, end = 16.dp, bottom = 16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
Text("Appearance", style = MaterialTheme.typography.titleMedium)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedButton(
onClick = {
AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_NO)
nightMode = AppCompatDelegate.MODE_NIGHT_NO
},
) { Text("Light") }
OutlinedButton(
onClick = {
AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_YES)
nightMode = AppCompatDelegate.MODE_NIGHT_YES
},
) { Text("Dark") }
OutlinedButton(
onClick = {
AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM)
nightMode = AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM
},
) { Text("System") }
}
Text(
text = when (nightMode) {
AppCompatDelegate.MODE_NIGHT_YES -> "Current theme: Dark"
AppCompatDelegate.MODE_NIGHT_NO -> "Current theme: Light"
else -> "Current theme: System"
},
style = MaterialTheme.typography.bodySmall,
)
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,
if (folder == null) {
SettingsHome(
state = state,
name = profile?.name.orEmpty(),
email = profile?.email.orEmpty(),
username = profile?.username.orEmpty(),
avatarUrl = profile?.avatarUrl,
onOpenProfile = onOpenProfile,
onOpenFolder = { folder = it },
)
} else {
SettingsFolderView(
state = state,
folder = folder ?: SettingsFolder.Account,
onBack = { folder = null },
onAddAccount = onAddAccount,
onSwitchAccount = onSwitchAccount,
onLogout = onLogout,
onOpenProfile = onOpenProfile,
viewModel = viewModel,
)
},
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(),
) {
Text("Open profile")
}
Button(
onClick = onLogout,
modifier = Modifier.fillMaxWidth(),
) {
Text("Logout")
}
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.End,
verticalAlignment = Alignment.CenterVertically,
) {
OutlinedButton(onClick = onBackToChats) {
Text("Back to chats")
}
}
}
}
}
}
@Composable
private fun SettingsHome(
state: AccountUiState,
name: String,
email: String,
username: String,
avatarUrl: String?,
onOpenProfile: () -> Unit,
onOpenFolder: (SettingsFolder) -> Unit,
) {
val scroll = rememberScrollState()
val active = state.storedAccounts.firstOrNull { it.isActive }
Column(
modifier = Modifier
.fillMaxSize()
.verticalScroll(scroll)
.padding(horizontal = 16.dp, vertical = 12.dp)
.padding(bottom = 96.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
ProfileHeader(name.ifBlank { stringResource(id = R.string.settings_user_fallback) }, email, username, avatarUrl, onOpenProfile)
SettingsCard {
Text(stringResource(id = R.string.settings_accounts_header), color = MaterialTheme.colorScheme.primary, style = MaterialTheme.typography.labelMedium)
SettingsShortcut(
title = active?.title ?: stringResource(id = R.string.settings_no_active_account),
subtitle = active?.subtitle ?: stringResource(id = R.string.settings_add_account),
onClick = { onOpenFolder(SettingsFolder.Account) },
)
}
SettingsCard {
SettingsRow(Icons.Filled.AccountCircle, stringResource(id = R.string.settings_folder_account), stringResource(id = R.string.settings_account_subtitle)) { onOpenFolder(SettingsFolder.Account) }
SettingsRow(Icons.Filled.Chat, stringResource(id = R.string.settings_folder_chat), stringResource(id = R.string.settings_chat_subtitle)) { onOpenFolder(SettingsFolder.Chat) }
SettingsRow(Icons.Filled.Lock, stringResource(id = R.string.settings_folder_privacy), stringResource(id = R.string.settings_privacy_subtitle)) { onOpenFolder(SettingsFolder.Privacy) }
SettingsRow(Icons.Filled.Notifications, stringResource(id = R.string.settings_folder_notifications), stringResource(id = R.string.settings_notifications_subtitle)) { onOpenFolder(SettingsFolder.Notifications) }
SettingsRow(Icons.Filled.Storage, stringResource(id = R.string.settings_folder_data), stringResource(id = R.string.settings_data_subtitle)) { onOpenFolder(SettingsFolder.Data) }
SettingsRow(Icons.Filled.Folder, stringResource(id = R.string.settings_folder_folders), stringResource(id = R.string.settings_folders_subtitle)) { onOpenFolder(SettingsFolder.Folders) }
SettingsRow(Icons.Filled.Devices, stringResource(id = R.string.settings_folder_devices), stringResource(id = R.string.settings_devices_subtitle)) { onOpenFolder(SettingsFolder.Devices) }
SettingsRow(Icons.Filled.BatterySaver, stringResource(id = R.string.settings_folder_power), stringResource(id = R.string.settings_power_subtitle)) { onOpenFolder(SettingsFolder.Power) }
SettingsRow(Icons.Filled.Language, stringResource(id = R.string.settings_folder_language), languageLabel(state.appLanguage), divider = false) { onOpenFolder(SettingsFolder.Language) }
}
}
}
@Composable
private fun SettingsFolderView(
state: AccountUiState,
folder: SettingsFolder,
onBack: () -> Unit,
onAddAccount: () -> Unit,
onSwitchAccount: () -> Unit,
onLogout: () -> Unit,
onOpenProfile: () -> Unit,
viewModel: AccountViewModel,
) {
val scroll = rememberScrollState()
Column(
modifier = Modifier
.fillMaxSize()
.verticalScroll(scroll)
.padding(horizontal = 16.dp, vertical = 12.dp)
.padding(bottom = 96.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) {
IconButton(onClick = onBack) { Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null) }
Text(folderTitle(folder), style = MaterialTheme.typography.headlineSmall)
Spacer(modifier = Modifier.weight(1f))
if (state.isLoading || state.isSaving) {
CircularProgressIndicator(modifier = Modifier.size(20.dp), strokeWidth = 2.dp)
}
}
when (folder) {
SettingsFolder.Account -> AccountFolder(state, onAddAccount, onSwitchAccount, onOpenProfile, onLogout, viewModel)
SettingsFolder.Chat -> ChatFolder(state, viewModel)
SettingsFolder.Privacy -> PrivacyFolder(state, viewModel)
SettingsFolder.Notifications -> NotificationsFolder(state, viewModel)
SettingsFolder.Devices -> DevicesFolder(state, viewModel)
SettingsFolder.Data -> PlaceholderFolder(stringResource(id = R.string.settings_placeholder_data))
SettingsFolder.Folders -> PlaceholderFolder(stringResource(id = R.string.settings_placeholder_folders))
SettingsFolder.Power -> PlaceholderFolder(stringResource(id = R.string.settings_placeholder_power))
SettingsFolder.Language -> LanguageFolder(state, viewModel)
}
if (!state.message.isNullOrBlank()) Text(state.message.orEmpty(), color = MaterialTheme.colorScheme.primary)
if (!state.errorMessage.isNullOrBlank()) Text(state.errorMessage.orEmpty(), color = MaterialTheme.colorScheme.error)
}
}
@Composable
private fun LanguageFolder(state: AccountUiState, viewModel: AccountViewModel) {
SettingsCard {
Text(stringResource(id = R.string.settings_language_title), style = MaterialTheme.typography.titleSmall)
LanguageOptionRow(
title = stringResource(id = R.string.language_system),
subtitle = stringResource(id = R.string.settings_language_system_subtitle),
selected = state.appLanguage == AppLanguage.SYSTEM,
onClick = { viewModel.setLanguage(AppLanguage.SYSTEM) },
)
LanguageOptionRow(
title = stringResource(id = R.string.language_russian),
subtitle = stringResource(id = R.string.language_russian),
selected = state.appLanguage == AppLanguage.RUSSIAN,
onClick = { viewModel.setLanguage(AppLanguage.RUSSIAN) },
)
LanguageOptionRow(
title = stringResource(id = R.string.language_english),
subtitle = stringResource(id = R.string.settings_language_english_subtitle),
selected = state.appLanguage == AppLanguage.ENGLISH,
onClick = { viewModel.setLanguage(AppLanguage.ENGLISH) },
)
}
}
@Composable
private fun LanguageOptionRow(
title: String,
subtitle: String,
selected: Boolean,
onClick: () -> Unit,
) {
Row(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(12.dp))
.clickable(onClick = onClick)
.padding(horizontal = 4.dp, vertical = 2.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
RadioButton(selected = selected, onClick = onClick)
Column(modifier = Modifier.weight(1f)) {
Text(text = title, style = MaterialTheme.typography.bodyLarge)
Text(
text = subtitle,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
@Composable
private fun languageLabel(language: AppLanguage): String = when (language) {
AppLanguage.SYSTEM -> stringResource(id = R.string.language_system)
AppLanguage.RUSSIAN -> stringResource(id = R.string.language_russian)
AppLanguage.ENGLISH -> stringResource(id = R.string.language_english)
}
@Composable
private fun folderTitle(folder: SettingsFolder): String = when (folder) {
SettingsFolder.Account -> stringResource(id = R.string.settings_folder_account)
SettingsFolder.Chat -> stringResource(id = R.string.settings_folder_chat)
SettingsFolder.Privacy -> stringResource(id = R.string.settings_folder_privacy)
SettingsFolder.Notifications -> stringResource(id = R.string.settings_folder_notifications)
SettingsFolder.Data -> stringResource(id = R.string.settings_folder_data)
SettingsFolder.Folders -> stringResource(id = R.string.settings_folder_folders)
SettingsFolder.Devices -> stringResource(id = R.string.settings_folder_devices)
SettingsFolder.Power -> stringResource(id = R.string.settings_folder_power)
SettingsFolder.Language -> stringResource(id = R.string.settings_folder_language)
}
@Composable
private fun AccountFolder(
state: AccountUiState,
onAddAccount: () -> Unit,
onSwitchAccount: () -> Unit,
onOpenProfile: () -> Unit,
onLogout: () -> Unit,
viewModel: AccountViewModel,
) {
SettingsCard {
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically) {
Text(stringResource(id = R.string.settings_accounts), style = MaterialTheme.typography.titleSmall)
OutlinedButton(onClick = onAddAccount) {
Icon(Icons.Filled.Add, contentDescription = null)
Text(stringResource(id = R.string.settings_add_account), modifier = Modifier.padding(start = 6.dp))
}
}
state.storedAccounts.forEach { account ->
Row(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(14.dp))
.background(MaterialTheme.colorScheme.surfaceContainerHighest)
.padding(10.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Box(
modifier = Modifier
.size(30.dp)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.primaryContainer),
contentAlignment = Alignment.Center,
) {
Text(
text = account.title.firstOrNull()?.uppercase() ?: "?",
style = MaterialTheme.typography.labelMedium,
maxLines = 1,
)
}
Column(modifier = Modifier.weight(1f)) {
Text(account.title, maxLines = 1, overflow = TextOverflow.Ellipsis)
Text(account.subtitle, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis)
}
if (account.isActive) {
Text(stringResource(id = R.string.settings_active), color = MaterialTheme.colorScheme.primary)
} else {
OutlinedButton(onClick = { viewModel.switchStoredAccount(account.userId) { if (it) onSwitchAccount() } }) { Text(stringResource(id = R.string.settings_switch)) }
}
OutlinedButton(onClick = { viewModel.removeStoredAccount(account.userId) }) { Icon(Icons.Filled.DeleteOutline, contentDescription = null) }
}
}
}
SettingsCard {
OutlinedButton(onClick = onOpenProfile, modifier = Modifier.fillMaxWidth()) { Text(stringResource(id = R.string.settings_open_profile)) }
Button(onClick = onLogout, modifier = Modifier.fillMaxWidth()) { Text(stringResource(id = R.string.settings_logout)) }
}
}
@Composable
private fun ChatFolder(state: AccountUiState, viewModel: AccountViewModel) {
SettingsCard {
Text(stringResource(id = R.string.settings_appearance), style = MaterialTheme.typography.titleSmall)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
ThemeButton(stringResource(id = R.string.theme_light), state.appThemeMode == AppThemeMode.LIGHT) { viewModel.setThemeMode(AppThemeMode.LIGHT) }
ThemeButton(stringResource(id = R.string.theme_dark), state.appThemeMode == AppThemeMode.DARK) { viewModel.setThemeMode(AppThemeMode.DARK) }
ThemeButton(stringResource(id = R.string.theme_system), state.appThemeMode == AppThemeMode.SYSTEM) { viewModel.setThemeMode(AppThemeMode.SYSTEM) }
}
}
}
@Composable
private fun NotificationsFolder(state: AccountUiState, viewModel: AccountViewModel) {
SettingsCard {
SettingsToggle(Icons.Filled.Notifications, stringResource(id = R.string.settings_enable_notifications), state.notificationsEnabled, viewModel::setGlobalNotificationsEnabled)
SettingsToggle(Icons.Filled.Visibility, stringResource(id = R.string.settings_show_preview), state.notificationsPreviewEnabled, viewModel::setNotificationPreviewEnabled)
OutlinedButton(
onClick = viewModel::refresh,
enabled = !state.isLoading && !state.isSaving,
modifier = Modifier.fillMaxWidth(),
) {
Text(stringResource(id = R.string.settings_refresh_notifications))
}
}
SettingsCard {
Text(stringResource(id = R.string.settings_recent_notifications), style = MaterialTheme.typography.titleSmall)
if (state.notificationsHistory.isEmpty()) {
Text(stringResource(id = R.string.settings_no_notifications_yet), color = MaterialTheme.colorScheme.onSurfaceVariant)
} else {
state.notificationsHistory.take(20).forEach { notification ->
Column(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(12.dp))
.background(MaterialTheme.colorScheme.surfaceContainerHighest)
.padding(horizontal = 10.dp, vertical = 8.dp),
verticalArrangement = Arrangement.spacedBy(2.dp),
) {
Text(
text = notification.eventType,
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.primary,
)
Text(
text = notification.text ?: notification.payloadRaw,
style = MaterialTheme.typography.bodySmall,
maxLines = 3,
overflow = TextOverflow.Ellipsis,
)
Text(
text = stringResource(
id = R.string.settings_notification_meta,
notification.chatId?.toString() ?: "-",
notification.messageId?.toString() ?: "-",
notification.createdAt,
),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
}
}
}
@Composable
private fun PrivacyFolder(state: AccountUiState, viewModel: AccountViewModel) {
var pm by remember(state.profile?.privacyPrivateMessages) { mutableStateOf(state.profile?.privacyPrivateMessages ?: "everyone") }
var lastSeen by remember(state.profile?.privacyLastSeen) { mutableStateOf(state.profile?.privacyLastSeen ?: "everyone") }
var avatar by remember(state.profile?.privacyAvatar) { mutableStateOf(state.profile?.privacyAvatar ?: "everyone") }
var invites by remember(state.profile?.privacyGroupInvites) { mutableStateOf(state.profile?.privacyGroupInvites ?: "everyone") }
SettingsCard {
PrivacyDropdown(stringResource(id = R.string.privacy_private_messages), pm) { pm = it }
PrivacyDropdown(stringResource(id = R.string.privacy_last_seen), lastSeen) { lastSeen = it }
PrivacyDropdown(stringResource(id = R.string.privacy_avatar), avatar) { avatar = it }
PrivacyDropdown(stringResource(id = R.string.privacy_group_invites), invites) { invites = it }
Button(onClick = { viewModel.updatePrivacy(pm, lastSeen, avatar, invites) }, enabled = !state.isSaving, modifier = Modifier.fillMaxWidth()) {
Icon(Icons.Filled.Lock, contentDescription = null)
Text(stringResource(id = R.string.privacy_save), modifier = Modifier.padding(start = 6.dp))
}
}
}
@Composable
private fun DevicesFolder(state: AccountUiState, viewModel: AccountViewModel) {
var twoFactorCode by remember { mutableStateOf("") }
var recoveryCode by remember { mutableStateOf("") }
SettingsCard {
Text(stringResource(id = R.string.settings_sessions_security), style = MaterialTheme.typography.titleSmall)
state.sessions.forEach { s ->
Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp)) {
Icon(Icons.Filled.Devices, contentDescription = null)
Column(modifier = Modifier.weight(1f)) {
Text(s.userAgent ?: stringResource(id = R.string.settings_unknown_device))
Text(s.ipAddress ?: stringResource(id = R.string.settings_unknown_ip), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
OutlinedButton(onClick = { viewModel.revokeSession(s.jti) }, enabled = !state.isSaving && s.current != true) { Text(stringResource(id = R.string.settings_revoke)) }
}
}
OutlinedButton(onClick = viewModel::revokeAllSessions, enabled = !state.isSaving, modifier = Modifier.fillMaxWidth()) {
Icon(Icons.Filled.Security, contentDescription = null)
Text(stringResource(id = R.string.settings_revoke_all), modifier = Modifier.padding(start = 6.dp))
}
}
SettingsCard {
OutlinedTextField(value = twoFactorCode, onValueChange = { twoFactorCode = it }, label = { Text(stringResource(id = R.string.settings_2fa_code)) }, modifier = Modifier.fillMaxWidth(), singleLine = true)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Button(onClick = { viewModel.enableTwoFactor(twoFactorCode) }, enabled = twoFactorCode.isNotBlank() && !state.isSaving) { Text(stringResource(id = R.string.settings_enable)) }
OutlinedButton(onClick = { viewModel.disableTwoFactor(twoFactorCode) }, enabled = twoFactorCode.isNotBlank() && !state.isSaving) { Text(stringResource(id = R.string.settings_disable)) }
}
OutlinedTextField(value = recoveryCode, onValueChange = { recoveryCode = it }, label = { Text(stringResource(id = R.string.settings_recovery_regen_code)) }, modifier = Modifier.fillMaxWidth(), singleLine = true)
OutlinedButton(onClick = { viewModel.regenerateRecoveryCodes(recoveryCode) }, enabled = recoveryCode.isNotBlank() && !state.isSaving, modifier = Modifier.fillMaxWidth()) {
Text(stringResource(id = R.string.settings_regenerate_recovery_codes))
}
}
}
@Composable
private fun PlaceholderFolder(text: String) {
SettingsCard { Text(text) }
}
@Composable
private fun ProfileHeader(name: String, email: String, username: String, avatarUrl: String?, onOpenProfile: () -> Unit) {
Column(modifier = Modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(8.dp)) {
if (!avatarUrl.isNullOrBlank()) {
AsyncImage(model = avatarUrl, contentDescription = null, modifier = Modifier.size(84.dp).clip(CircleShape))
} else {
Box(modifier = Modifier.size(84.dp).clip(CircleShape).background(MaterialTheme.colorScheme.primaryContainer), contentAlignment = Alignment.Center) {
Text(name.firstOrNull()?.uppercase() ?: "?")
}
}
Text(name, style = MaterialTheme.typography.headlineSmall)
Text(listOfNotNull(email.takeIf { it.isNotBlank() }, username.takeIf { it.isNotBlank() }?.let { "@$it" }).joinToString(""), color = MaterialTheme.colorScheme.onSurfaceVariant)
TextButton(onClick = onOpenProfile) { Text(stringResource(id = R.string.settings_open_profile)) }
}
}
@Composable
private fun SettingsCard(content: @Composable ColumnScope.() -> Unit) {
Surface(color = MaterialTheme.colorScheme.surfaceContainer, shape = RoundedCornerShape(22.dp), modifier = Modifier.fillMaxWidth()) {
Column(modifier = Modifier.fillMaxWidth().padding(horizontal = 14.dp, vertical = 12.dp), verticalArrangement = Arrangement.spacedBy(8.dp), content = content)
}
}
@Composable
private fun SettingsShortcut(title: String, subtitle: String, onClick: () -> Unit) {
Row(
modifier = Modifier.fillMaxWidth().clip(RoundedCornerShape(14.dp)).clickable(onClick = onClick).padding(horizontal = 8.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp),
) {
Box(
modifier = Modifier
.size(30.dp)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.primaryContainer),
contentAlignment = Alignment.Center,
) {
Text(
text = title.firstOrNull()?.uppercase() ?: stringResource(id = R.string.settings_fallback_avatar_letter),
style = MaterialTheme.typography.labelMedium,
maxLines = 1,
)
}
Column(modifier = Modifier.weight(1f)) {
Text(
text = title,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Text(subtitle, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis)
}
Icon(Icons.AutoMirrored.Filled.KeyboardArrowRight, contentDescription = null)
}
}
@Composable
private fun SettingsRow(icon: ImageVector, title: String, subtitle: String, divider: Boolean = true, onClick: () -> Unit) {
Column(modifier = Modifier.fillMaxWidth().clickable(onClick = onClick).padding(vertical = 4.dp)) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
modifier = Modifier.fillMaxWidth().padding(horizontal = 4.dp, vertical = 6.dp),
) {
Box(modifier = Modifier.size(34.dp).clip(CircleShape).background(MaterialTheme.colorScheme.primaryContainer), contentAlignment = Alignment.Center) {
Icon(icon, contentDescription = null, tint = MaterialTheme.colorScheme.primary, modifier = Modifier.size(20.dp))
}
Column(modifier = Modifier.weight(1f)) {
Text(title)
Text(subtitle, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
Icon(Icons.AutoMirrored.Filled.KeyboardArrowRight, contentDescription = null)
}
if (divider) {
Spacer(modifier = Modifier.fillMaxWidth().padding(start = 50.dp).size(width = 1.dp, height = 0.5.dp).background(MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.7f)))
}
}
}
@Composable
private fun SettingsToggle(icon: ImageVector, title: String, checked: Boolean, onCheckedChange: (Boolean) -> Unit) {
Row(
modifier = Modifier.fillMaxWidth().clip(RoundedCornerShape(14.dp)).background(MaterialTheme.colorScheme.surfaceContainerHighest).padding(horizontal = 10.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
Icon(icon, contentDescription = null, tint = MaterialTheme.colorScheme.primary)
Text(title, modifier = Modifier.weight(1f))
Switch(checked = checked, onCheckedChange = onCheckedChange)
}
}
@Composable
private fun ThemeButton(text: String, selected: Boolean, onClick: () -> Unit) {
if (selected) Button(onClick = onClick) { Text(text) } else OutlinedButton(onClick = onClick) { Text(text) }
}
@Composable
@OptIn(ExperimentalMaterial3Api::class)
private fun PrivacyDropdown(label: String, value: String, onChange: (String) -> Unit) {
val options = listOf("everyone", "contacts", "nobody")
var expanded by remember { mutableStateOf(false) }
ExposedDropdownMenuBox(expanded = expanded, onExpandedChange = { expanded = !expanded }) {
OutlinedTextField(
value = value,
onValueChange = {},
readOnly = true,
label = { Text(label) },
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) },
modifier = Modifier.menuAnchor().fillMaxWidth(),
singleLine = true,
)
DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
options.forEach { option ->
DropdownMenuItem(
text = { Text(privacyOptionLabel(option)) },
onClick = { onChange(option); expanded = false },
)
}
}
}
}
@Composable
private fun privacyOptionLabel(value: String): String = when (value.lowercase()) {
"everyone" -> stringResource(id = R.string.privacy_everyone)
"contacts" -> stringResource(id = R.string.privacy_contacts)
"nobody" -> stringResource(id = R.string.privacy_nobody)
else -> value
}

View File

@@ -1,11 +1,16 @@
package ru.daemonlord.messenger.ui.theme
import android.app.Activity
import androidx.appcompat.app.AppCompatDelegate
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.SideEffect
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.platform.LocalView
import androidx.core.view.WindowCompat
private val LightColors = lightColorScheme()
private val DarkColors = darkColorScheme()
@@ -17,8 +22,18 @@ fun MessengerTheme(content: @Composable () -> Unit) {
AppCompatDelegate.MODE_NIGHT_NO -> false
else -> isSystemInDarkTheme()
}
val colorScheme = if (darkTheme) DarkColors else LightColors
val view = LocalView.current
SideEffect {
val window = (view.context as? Activity)?.window ?: return@SideEffect
window.statusBarColor = colorScheme.surface.toArgb()
window.navigationBarColor = colorScheme.surface.toArgb()
val controller = WindowCompat.getInsetsController(window, view)
controller.isAppearanceLightStatusBars = !darkTheme
controller.isAppearanceLightNavigationBars = !darkTheme
}
MaterialTheme(
colorScheme = if (darkTheme) DarkColors else LightColors,
colorScheme = colorScheme,
content = content,
)
}

View File

@@ -0,0 +1,335 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">Benya Messenger</string>
<string name="nav_chats">Чаты</string>
<string name="nav_contacts">Контакты</string>
<string name="nav_settings">Настройки</string>
<string name="nav_profile">Профиль</string>
<string name="chats_connecting">Подключение...</string>
<string name="chats_archived">Архив</string>
<string name="chats_loading">Загрузка чатов...</string>
<string name="chats_not_found">Чаты не найдены</string>
<string name="chats_contentdesc_archive_selected">Архивировать выбранное</string>
<string name="chats_contentdesc_delete_selected">Удалить выбранное</string>
<string name="chats_contentdesc_selection_menu">Меню выбора</string>
<string name="chats_selection_pin">Закрепить</string>
<string name="chats_selection_unpin">Открепить</string>
<string name="chats_selection_add_to_folder">Добавить в папку</string>
<string name="chats_selection_mark_unread">Пометить непрочитанным</string>
<string name="chats_selection_clear_cache">Удалить из кэша</string>
<string name="chats_toast_folders_coming_soon">Папки чатов будут добавлены позже.</string>
<string name="chats_toast_mark_unread_coming_soon">Отметка непрочитанным будет добавлена позже.</string>
<string name="chats_dialog_create_group_title">Создать группу</string>
<string name="chats_dialog_group_title_label">Название группы</string>
<string name="chats_dialog_create_channel_title">Создать канал</string>
<string name="chats_dialog_channel_title_label">Название канала</string>
<string name="chats_dialog_channel_handle_label">Хэндл</string>
<string name="chats_dialog_delete_selected_title">Удалить выбранные чаты</string>
<string name="chats_dialog_delete_selected_body">Вы уверены, что хотите удалить выбранные чаты?</string>
<string name="chats_dialog_delete_for_all">Удалить для всех (где доступно)</string>
<string name="filter_all">Все</string>
<string name="filter_people">Люди</string>
<string name="filter_groups">Группы</string>
<string name="filter_channels">Каналы</string>
<string name="menu_day_mode">Дневной режим</string>
<string name="menu_night_mode">Ночной режим</string>
<string name="menu_create_group">Создать группу</string>
<string name="menu_saved">Избранное</string>
<string name="chat_list_info_group_created">Группа создана.</string>
<string name="chat_list_info_channel_created">Канал создан.</string>
<string name="chat_list_info_joined_chat">Вы вступили в чат.</string>
<string name="chat_list_info_left_chat">Вы вышли из чата.</string>
<string name="chat_list_info_archived">Чат архивирован.</string>
<string name="chat_list_info_unarchived">Чат возвращен из архива.</string>
<string name="chat_list_info_pinned">Чат закреплен.</string>
<string name="chat_list_info_unpinned">Чат откреплен.</string>
<string name="chat_list_info_history_cleared">История чата очищена.</string>
<string name="chat_list_info_title_updated">Название обновлено.</string>
<string name="chat_list_info_profile_updated">Профиль обновлен.</string>
<string name="chat_list_info_deleted_for_me">Чат удален.</string>
<string name="chat_list_info_deleted_for_all">Чат удален для всех.</string>
<string name="chat_list_info_notifications_disabled">Уведомления выключены.</string>
<string name="chat_list_info_notifications_enabled">Уведомления включены.</string>
<string name="chat_list_info_invite_created">Приглашение: %1$s</string>
<string name="chat_list_info_member_added">Добавлен %1$s</string>
<string name="chat_list_info_member_role_updated">Роль обновлена: %1$s -&gt; %2$s</string>
<string name="chat_list_info_member_removed">Участник удален.</string>
<string name="chat_list_info_member_banned">Участник заблокирован.</string>
<string name="chat_list_info_member_unbanned">Участник разблокирован.</string>
<string name="chat_list_error_title_required">Требуется название.</string>
<string name="chat_list_error_title_or_description_required">Укажите название или описание.</string>
<string name="chat_list_error_network_sync">Ошибка сети при синхронизации чатов.</string>
<string name="chat_list_error_session_expired">Сессия истекла. Войдите снова.</string>
<string name="chat_list_error_authorization_failed">Ошибка авторизации.</string>
<string name="chat_list_error_server_loading">Ошибка сервера при загрузке чатов.</string>
<string name="chat_list_error_unknown_loading">Неизвестная ошибка при загрузке чатов.</string>
<string name="toast_day_mode_enabled">Включен дневной режим.</string>
<string name="toast_night_mode_enabled">Включен ночной режим.</string>
<string name="common_cancel">Отмена</string>
<string name="common_confirm">Подтвердить</string>
<string name="common_close">Закрыть</string>
<string name="common_delete">Удалить</string>
<string name="common_create">Создать</string>
<string name="common_send">Отправить</string>
<string name="common_save">Сохранить</string>
<string name="common_unknown_user">Неизвестный пользователь</string>
<string name="profile_avatar_content_description">Аватар</string>
<string name="profile_user_fallback">Пользователь</string>
<string name="profile_choose_photo">Выбрать фото</string>
<string name="profile_edit">Редактировать</string>
<string name="profile_bio">О себе</string>
<string name="profile_not_set">Не указано</string>
<string name="profile_edit_profile">Редактировать профиль</string>
<string name="profile_avatar_url">URL аватара</string>
<string name="profile_crop_avatar">Обрезать аватар</string>
<string name="profile_avatar_crop_preview">Предпросмотр обрезки аватара</string>
<string name="profile_use_crop">Использовать</string>
<string name="profile_crop_hint">Используйте два пальца для масштабирования и перемещения.</string>
<string name="chat_menu_notifications">Уведомления</string>
<string name="chat_menu_search">Поиск</string>
<string name="chat_menu_change_wallpaper">Изменить обои</string>
<string name="chat_menu_clear_history">Очистить историю</string>
<string name="chat_wallpaper_coming_soon">Смена обоев будет добавлена позже.</string>
<string name="chat_delete_dialog">Удалить диалог</string>
<string name="chat_leave_delete">Выйти и удалить чат</string>
<string name="chat_leave_chat">Выйти из чата</string>
<string name="chat_leave">Выйти</string>
<string name="chat_search_in_chat">Поиск по чату</string>
<string name="chat_search_prev">Назад</string>
<string name="chat_search_next">Далее</string>
<string name="chat_search_gifs">Поиск GIF</string>
<string name="chat_search_matches">Совпадений: %1$d</string>
<string name="chat_pinned_message">Закрепленное сообщение</string>
<string name="chat_message_placeholder">Сообщение</string>
<string name="chat_action_reply">Ответить</string>
<string name="chat_action_edit">Изменить</string>
<string name="chat_action_forward">Переслать</string>
<string name="chat_action_delete">Удалить</string>
<string name="chat_delete_message_title">Удалить сообщение</string>
<string name="chat_forward_one">Переслать сообщение #%1$d</string>
<string name="chat_forward_many">Переслать %1$d сообщений</string>
<string name="chat_no_available_chats">Нет доступных чатов</string>
<string name="chat_forwarding">Пересылка...</string>
<string name="chat_editing_message">Редактирование сообщения #%1$d</string>
<string name="chat_reply_to">Ответ</string>
<string name="chat_delete_message_for_everyone">Удалить выбранное сообщение для всех?</string>
<string name="chat_delete_message_for_me">Удалить выбранные сообщения у вас?</string>
<string name="chat_clear_history_confirm">Удалить все сообщения в этом чате? Это действие нельзя отменить.</string>
<string name="chat_clear">Очистить</string>
<string name="chat_delete_dialog_confirm">Удалить диалог у вас? История будет очищена.</string>
<string name="chat_leave_delete_confirm">Выйти из чата и убрать его из списка?</string>
<string name="chat_enable_sound">Включить звук</string>
<string name="chat_disable_sound">Выключить звук</string>
<string name="chat_circle_video">Видеокружок</string>
<string name="chat_open_item_failed">Не удалось открыть элемент</string>
<string name="chat_status_online">в сети</string>
<string name="chat_status_last_seen_recently">был(а) недавно</string>
<string name="chat_type_group">группа</string>
<string name="chat_type_channel">канал</string>
<string name="chat_info_tab_media">Медиа</string>
<string name="chat_info_tab_files">Файлы</string>
<string name="chat_info_tab_links">Ссылки</string>
<string name="chat_info_tab_voice">Голосовые</string>
<string name="chat_info_tab_members">Участники</string>
<string name="chat_info_empty">Пока нет: %1$s</string>
<string name="chat_info_no_members_data">Нет данных об участниках</string>
<string name="chat_members_header">Участники (%1$d)</string>
<string name="chat_banned_header">Заблокированные (%1$d)</string>
<string name="chat_member_action_promote">Повысить</string>
<string name="chat_member_action_demote">Понизить</string>
<string name="chat_member_action_transfer_owner">Передать owner</string>
<string name="chat_member_action_ban">Забанить</string>
<string name="chat_member_action_kick">Кикнуть</string>
<string name="chat_member_action_unban">Разбанить</string>
<string name="chat_media_badge_video">Видео</string>
<string name="chat_attachment_title">Вложение</string>
<string name="chat_attachment_gallery_file">Галерея / Файл</string>
<string name="chat_attachment_take_photo">Сделать фото</string>
<string name="chat_attachment_take_video">Снять видео</string>
<string name="chat_media_badge_gif">GIF</string>
<string name="chat_media_badge_sticker">Стикер</string>
<string name="chat_picker_tab_emoji">Эмодзи</string>
<string name="chat_picker_tab_gif">GIF</string>
<string name="chat_picker_tab_stickers">Стикеры</string>
<string name="chat_playback_subtitle_voice">Голосовое сообщение • %1$s</string>
<string name="chat_playback_subtitle_audio">Аудио • %1$s</string>
<string name="chat_user_fallback_with_id">Пользователь #%1$d</string>
<string name="chat_member_role_with_id">%1$s • id %2$d</string>
<string name="chat_member_id">id %1$d</string>
<string name="chat_member_dialog_demote_title">Понизить админа</string>
<string name="chat_member_dialog_demote_body">Понизить %1$s до участника?</string>
<string name="chat_member_dialog_transfer_title">Передача owner</string>
<string name="chat_member_dialog_transfer_body">Передать owner пользователю %1$s?</string>
<string name="chat_member_dialog_ban_title">Блокировка участника</string>
<string name="chat_member_dialog_ban_body">Забанить %1$s?</string>
<string name="chat_member_dialog_kick_title">Исключить участника</string>
<string name="chat_member_dialog_kick_body">Исключить %1$s из чата?</string>
<string name="chat_error_voice_too_short">Голосовое сообщение слишком короткое.</string>
<string name="chat_error_delete_for_all_single">Удаление для всех доступно только при выборе одного сообщения.</string>
<string name="chat_error_delete_for_all_own">Удаление для всех доступно только для ваших сообщений.</string>
<string name="chat_info_history_cleared">История чата очищена.</string>
<string name="chat_error_edit_expired">Это сообщение уже нельзя редактировать.</string>
<string name="chat_error_send_restricted">Отправка сообщений в этом чате ограничена.</string>
<string name="chat_restriction_owner_admin">Только owner/admin канала может отправлять сообщения.</string>
<string name="chat_error_action_self">Это действие нельзя применить к себе.</string>
<string name="chat_error_permissions">Недостаточно прав.</string>
<string name="chat_error_owner_only">Только owner может выполнить это действие.</string>
<string name="chat_error_manage_owner">Нельзя управлять аккаунтом owner.</string>
<string name="chat_error_admin_manage_admin_owner">Админ не может управлять админами и owner.</string>
<string name="chat_error_transfer_choose_another">Выберите другого участника для передачи owner.</string>
<string name="chat_voice_hint_slide">Проведите вверх, чтобы закрепить, и влево, чтобы отменить</string>
<string name="chat_voice_hint_locked">Запись закреплена</string>
<string name="chat_title_fallback">Чат #%1$d</string>
<string name="chat_day_today">Сегодня</string>
<string name="chat_day_yesterday">Вчера</string>
<string name="chat_voice_recording_duration">Голос %1$s</string>
<string name="chat_unknown_user">Неизвестный пользователь</string>
<string name="chat_media_placeholder">[медиа]</string>
<string name="chat_forwarded_from">Переслано от %1$s</string>
<string name="chat_error_giphy_api_key_missing">Укажите GIPHY_API_KEY в local.properties</string>
<string name="chat_error_no_gifs_found">GIF не найдены</string>
<string name="settings_user_fallback">Пользователь</string>
<string name="settings_accounts_header">АККАУНТЫ</string>
<string name="settings_no_active_account">Нет активного аккаунта</string>
<string name="settings_add_account">Добавить аккаунт</string>
<string name="settings_folder_account">Аккаунт</string>
<string name="settings_folder_chat">Настройки чатов</string>
<string name="settings_folder_privacy">Конфиденциальность</string>
<string name="settings_folder_notifications">Уведомления</string>
<string name="settings_folder_data">Данные и память</string>
<string name="settings_folder_folders">Папки с чатами</string>
<string name="settings_folder_devices">Устройства</string>
<string name="settings_folder_power">Энергосбережение</string>
<string name="settings_folder_language">Язык</string>
<string name="settings_account_subtitle">Номер, имя пользователя, «О себе»</string>
<string name="settings_chat_subtitle">Обои, ночной режим, анимации</string>
<string name="settings_privacy_subtitle">Время захода, устройства, ключи доступа</string>
<string name="settings_notifications_subtitle">Звуки, звонки, счетчик сообщений</string>
<string name="settings_data_subtitle">Настройки загрузки медиафайлов</string>
<string name="settings_folders_subtitle">Сортировка чатов по папкам</string>
<string name="settings_devices_subtitle">Управление активными сессиями</string>
<string name="settings_power_subtitle">Экономия энергии при низком заряде</string>
<string name="settings_placeholder_data">Этот раздел будет расширен на следующем шаге.</string>
<string name="settings_placeholder_folders">Управление папками чатов будет добавлено на следующей итерации.</string>
<string name="settings_placeholder_power">Настройки энергосбережения будут добавлены отдельным этапом.</string>
<string name="settings_language_title">Язык приложения</string>
<string name="settings_language_system_subtitle">Использовать язык устройства</string>
<string name="settings_language_english_subtitle">Английский</string>
<string name="language_system">Системный</string>
<string name="language_russian">Русский</string>
<string name="language_english">Английский</string>
<string name="settings_accounts">Аккаунты</string>
<string name="settings_active">Активный</string>
<string name="settings_switch">Переключить</string>
<string name="settings_open_profile">Открыть профиль</string>
<string name="settings_logout">Выйти</string>
<string name="settings_appearance">Оформление</string>
<string name="theme_light">Светлая</string>
<string name="theme_dark">Темная</string>
<string name="theme_system">Системная</string>
<string name="settings_enable_notifications">Включить уведомления</string>
<string name="settings_show_preview">Показывать превью сообщений</string>
<string name="settings_refresh_notifications">Обновить историю уведомлений</string>
<string name="settings_recent_notifications">Последние уведомления</string>
<string name="settings_no_notifications_yet">Пока нет серверных уведомлений.</string>
<string name="settings_notification_meta">chat=%1$s • msg=%2$s • %3$s</string>
<string name="settings_fallback_avatar_letter">А</string>
<string name="privacy_private_messages">Личные сообщения</string>
<string name="privacy_last_seen">Время захода</string>
<string name="privacy_avatar">Аватар</string>
<string name="privacy_group_invites">Приглашения в группы</string>
<string name="privacy_save">Сохранить приватность</string>
<string name="settings_sessions_security">Сессии и безопасность</string>
<string name="settings_unknown_device">Неизвестное устройство</string>
<string name="settings_unknown_ip">Неизвестный IP</string>
<string name="settings_revoke">Отозвать</string>
<string name="settings_revoke_all">Отозвать все сессии</string>
<string name="settings_2fa_code">Код 2FA</string>
<string name="settings_enable">Включить</string>
<string name="settings_disable">Выключить</string>
<string name="settings_recovery_regen_code">Код для регенерации recovery</string>
<string name="settings_regenerate_recovery_codes">Сгенерировать recovery-коды заново</string>
<string name="privacy_everyone">все</string>
<string name="privacy_contacts">контакты</string>
<string name="privacy_nobody">никто</string>
<string name="auth_header_login">Вход в Messenger</string>
<string name="auth_subtitle_enter_email">Введите email для продолжения</string>
<string name="auth_subtitle_enter_password">Введите пароль для %1$s</string>
<string name="auth_subtitle_create_account">Создайте аккаунт для %1$s</string>
<string name="auth_subtitle_2fa_enabled">Включена двухфакторная аутентификация</string>
<string name="auth_label_email">Email</string>
<string name="auth_label_name">Имя</string>
<string name="auth_label_username">Имя пользователя</string>
<string name="auth_label_password">Пароль</string>
<string name="auth_label_recovery_code">Код восстановления</string>
<string name="auth_label_2fa_code">Код 2FA</string>
<string name="auth_continue">Продолжить</string>
<string name="auth_change_email">Изменить email</string>
<string name="auth_use_otp_code">Использовать OTP-код</string>
<string name="auth_use_recovery_code">Использовать код восстановления</string>
<string name="auth_sign_in">Войти</string>
<string name="auth_create_account">Создать аккаунт</string>
<string name="auth_confirm_2fa">Подтвердить 2FA</string>
<string name="auth_verify_email_by_token">Подтвердить email по токену</string>
<string name="auth_forgot_password">Забыли пароль</string>
<string name="auth_verify_email_title">Подтверждение email</string>
<string name="auth_verification_token">Токен подтверждения</string>
<string name="auth_verify">Подтвердить</string>
<string name="auth_email_for_resend">Email для повторной отправки</string>
<string name="auth_resend_verification_link">Отправить ссылку повторно</string>
<string name="auth_back_to_login">Назад ко входу</string>
<string name="auth_password_reset_title">Сброс пароля</string>
<string name="auth_send_reset_link">Отправить ссылку сброса</string>
<string name="auth_new_password">Новый пароль</string>
<string name="auth_reset_with_token">Сбросить по токену</string>
<string name="auth_error_enter_email">Введите email.</string>
<string name="auth_info_email_not_registered">Этот email не зарегистрирован. Завершите регистрацию.</string>
<string name="auth_error_register_fields_required">Нужны имя, имя пользователя и пароль.</string>
<string name="auth_info_account_created">Аккаунт создан. Используйте пароль для входа.</string>
<string name="auth_error_password_required">Требуется пароль.</string>
<string name="auth_info_enter_2fa_or_recovery">Введите код 2FA или код восстановления.</string>
<string name="auth_error_enter_2fa_code">Введите код 2FA.</string>
<string name="auth_error_enter_recovery_code">Введите код восстановления.</string>
<string name="auth_error_invalid_credentials">Неверный email или пароль.</string>
<string name="auth_error_network">Ошибка сети. Проверьте подключение.</string>
<string name="auth_error_session_expired">Сессия истекла. Войдите снова.</string>
<string name="auth_error_server">Ошибка сервера. Попробуйте снова.</string>
<string name="auth_error_unknown">Неизвестная ошибка. Попробуйте снова.</string>
<string name="contacts_title">Контакты</string>
<string name="contacts_search_label">Поиск контактов/пользователей</string>
<string name="contacts_add_by_email_label">Добавить по email</string>
<string name="contacts_search_results">Результаты поиска</string>
<string name="contacts_my_contacts">Мои контакты</string>
<string name="contacts_last_seen_recently">был(а) недавно</string>
<string name="contacts_remove">Удалить</string>
<string name="contacts_empty">Контактов пока нет.</string>
<string name="contacts_info_added">Контакт добавлен.</string>
<string name="contacts_info_added_by_email">Контакт добавлен по email.</string>
<string name="contacts_info_removed">Контакт удален.</string>
<string name="contacts_error_email_required">Требуется email.</string>
<string name="error_network">Ошибка сети.</string>
<string name="error_session_expired">Сессия истекла.</string>
<string name="error_authorization">Ошибка авторизации.</string>
<string name="error_server">Ошибка сервера.</string>
<string name="error_unknown">Неизвестная ошибка.</string>
<string name="account_user_fallback">Пользователь #%1$d</string>
<string name="account_error_email_password_required">Нужны email и пароль.</string>
<string name="account_info_added">Аккаунт добавлен.</string>
<string name="account_error_no_saved_session">Для этого аккаунта нет сохраненной сессии.</string>
<string name="account_error_switch_sync_failed">Аккаунт переключен, но синхронизация чатов не удалась. Потяните вниз для обновления.</string>
<string name="account_info_profile_updated">Профиль обновлен.</string>
<string name="account_info_avatar_uploaded">Аватар загружен.</string>
<string name="account_info_privacy_updated">Настройки приватности обновлены.</string>
<string name="account_info_2fa_secret_generated">Секрет 2FA сгенерирован. Введите код для включения.</string>
<string name="account_info_recovery_codes_regenerated">Коды восстановления перегенерированы.</string>
<string name="account_error_invalid_credentials">Неверные учетные данные.</string>
<string name="account_error_unauthorized">Не авторизовано.</string>
</resources>

View File

@@ -1,4 +1,335 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">Benya Messenger</string>
<string name="nav_chats">Chats</string>
<string name="nav_contacts">Contacts</string>
<string name="nav_settings">Settings</string>
<string name="nav_profile">Profile</string>
<string name="chats_connecting">Connecting...</string>
<string name="chats_archived">Archived</string>
<string name="chats_loading">Loading chats...</string>
<string name="filter_all">All</string>
<string name="filter_people">People</string>
<string name="filter_groups">Groups</string>
<string name="filter_channels">Channels</string>
<string name="menu_day_mode">Day mode</string>
<string name="menu_night_mode">Night mode</string>
<string name="menu_create_group">Create group</string>
<string name="menu_saved">Saved</string>
<string name="chat_list_info_group_created">Group created.</string>
<string name="chat_list_info_channel_created">Channel created.</string>
<string name="chat_list_info_joined_chat">Joined chat.</string>
<string name="chat_list_info_left_chat">Left chat.</string>
<string name="chat_list_info_archived">Chat archived.</string>
<string name="chat_list_info_unarchived">Chat restored from archive.</string>
<string name="chat_list_info_pinned">Chat pinned.</string>
<string name="chat_list_info_unpinned">Chat unpinned.</string>
<string name="chat_list_info_history_cleared">Chat history cleared.</string>
<string name="chat_list_info_title_updated">Title updated.</string>
<string name="chat_list_info_profile_updated">Profile updated.</string>
<string name="chat_list_info_deleted_for_me">Chat deleted.</string>
<string name="chat_list_info_deleted_for_all">Chat deleted for everyone.</string>
<string name="chat_list_info_notifications_disabled">Notifications disabled.</string>
<string name="chat_list_info_notifications_enabled">Notifications enabled.</string>
<string name="chat_list_info_invite_created">Invite: %1$s</string>
<string name="chat_list_info_member_added">Added %1$s</string>
<string name="chat_list_info_member_role_updated">Role updated: %1$s -&gt; %2$s</string>
<string name="chat_list_info_member_removed">Member removed.</string>
<string name="chat_list_info_member_banned">Member banned.</string>
<string name="chat_list_info_member_unbanned">Member unbanned.</string>
<string name="chat_list_error_title_required">Title is required.</string>
<string name="chat_list_error_title_or_description_required">Provide title or description.</string>
<string name="chat_list_error_network_sync">Network error while syncing chats.</string>
<string name="chat_list_error_session_expired">Session expired. Please log in again.</string>
<string name="chat_list_error_authorization_failed">Authorization failed.</string>
<string name="chat_list_error_server_loading">Server error while loading chats.</string>
<string name="chat_list_error_unknown_loading">Unknown error while loading chats.</string>
<string name="toast_day_mode_enabled">Day mode enabled.</string>
<string name="toast_night_mode_enabled">Night mode enabled.</string>
<string name="chats_not_found">No chats found</string>
<string name="chats_contentdesc_archive_selected">Archive selected</string>
<string name="chats_contentdesc_delete_selected">Delete selected</string>
<string name="chats_contentdesc_selection_menu">Selection menu</string>
<string name="chats_selection_pin">Pin</string>
<string name="chats_selection_unpin">Unpin</string>
<string name="chats_selection_add_to_folder">Add to folder</string>
<string name="chats_selection_mark_unread">Mark as unread</string>
<string name="chats_selection_clear_cache">Clear cache</string>
<string name="chats_toast_folders_coming_soon">Chat folders will be added later.</string>
<string name="chats_toast_mark_unread_coming_soon">Mark as unread will be added later.</string>
<string name="chats_dialog_create_group_title">Create group</string>
<string name="chats_dialog_group_title_label">Group title</string>
<string name="chats_dialog_create_channel_title">Create channel</string>
<string name="chats_dialog_channel_title_label">Channel title</string>
<string name="chats_dialog_channel_handle_label">Handle</string>
<string name="chats_dialog_delete_selected_title">Delete selected chats</string>
<string name="chats_dialog_delete_selected_body">Are you sure you want to delete selected chats?</string>
<string name="chats_dialog_delete_for_all">Delete for all (where allowed)</string>
<string name="common_cancel">Cancel</string>
<string name="common_confirm">Confirm</string>
<string name="common_close">Close</string>
<string name="common_delete">Delete</string>
<string name="common_create">Create</string>
<string name="common_send">Send</string>
<string name="common_save">Save</string>
<string name="common_unknown_user">Unknown user</string>
<string name="profile_avatar_content_description">Avatar</string>
<string name="profile_user_fallback">User</string>
<string name="profile_choose_photo">Choose photo</string>
<string name="profile_edit">Edit</string>
<string name="profile_bio">Bio</string>
<string name="profile_not_set">Not set</string>
<string name="profile_edit_profile">Edit profile</string>
<string name="profile_avatar_url">Avatar URL</string>
<string name="profile_crop_avatar">Crop avatar</string>
<string name="profile_avatar_crop_preview">Avatar crop preview</string>
<string name="profile_use_crop">Use</string>
<string name="profile_crop_hint">Use two fingers to zoom and move.</string>
<string name="chat_menu_notifications">Notifications</string>
<string name="chat_menu_search">Search</string>
<string name="chat_menu_change_wallpaper">Change wallpaper</string>
<string name="chat_menu_clear_history">Clear history</string>
<string name="chat_wallpaper_coming_soon">Wallpaper change will be added later.</string>
<string name="chat_delete_dialog">Delete dialog</string>
<string name="chat_leave_delete">Leave and delete chat</string>
<string name="chat_leave_chat">Leave chat</string>
<string name="chat_leave">Leave</string>
<string name="chat_search_in_chat">Search in chat</string>
<string name="chat_search_prev">Prev</string>
<string name="chat_search_next">Next</string>
<string name="chat_search_gifs">Search GIFs</string>
<string name="chat_search_matches">Matches: %1$d</string>
<string name="chat_pinned_message">Pinned message</string>
<string name="chat_message_placeholder">Message</string>
<string name="chat_action_reply">Reply</string>
<string name="chat_action_edit">Edit</string>
<string name="chat_action_forward">Forward</string>
<string name="chat_action_delete">Delete</string>
<string name="chat_delete_message_title">Delete message</string>
<string name="chat_forward_one">Forward message #%1$d</string>
<string name="chat_forward_many">Forward %1$d messages</string>
<string name="chat_no_available_chats">No available chats</string>
<string name="chat_forwarding">Forwarding...</string>
<string name="chat_editing_message">Editing message #%1$d</string>
<string name="chat_reply_to">Reply to</string>
<string name="chat_delete_message_for_everyone">Delete selected message for everyone?</string>
<string name="chat_delete_message_for_me">Delete selected message(s) for you?</string>
<string name="chat_clear_history_confirm">Delete all messages in this chat? This action cannot be undone.</string>
<string name="chat_clear">Clear</string>
<string name="chat_delete_dialog_confirm">Delete dialog for you? History will be cleared.</string>
<string name="chat_leave_delete_confirm">Leave the chat and remove it from your list?</string>
<string name="chat_enable_sound">Enable sound</string>
<string name="chat_disable_sound">Disable sound</string>
<string name="chat_circle_video">Circle video</string>
<string name="chat_open_item_failed">Unable to open item</string>
<string name="chat_status_online">online</string>
<string name="chat_status_last_seen_recently">last seen recently</string>
<string name="chat_type_group">group</string>
<string name="chat_type_channel">channel</string>
<string name="chat_info_tab_media">Media</string>
<string name="chat_info_tab_files">Files</string>
<string name="chat_info_tab_links">Links</string>
<string name="chat_info_tab_voice">Voice</string>
<string name="chat_info_tab_members">Members</string>
<string name="chat_info_empty">No %1$s yet</string>
<string name="chat_info_no_members_data">No members data</string>
<string name="chat_members_header">Members (%1$d)</string>
<string name="chat_banned_header">Banned (%1$d)</string>
<string name="chat_member_action_promote">Promote</string>
<string name="chat_member_action_demote">Demote</string>
<string name="chat_member_action_transfer_owner">Transfer owner</string>
<string name="chat_member_action_ban">Ban</string>
<string name="chat_member_action_kick">Kick</string>
<string name="chat_member_action_unban">Unban</string>
<string name="chat_media_badge_video">Video</string>
<string name="chat_attachment_title">Attachment</string>
<string name="chat_attachment_gallery_file">Gallery / File</string>
<string name="chat_attachment_take_photo">Take photo</string>
<string name="chat_attachment_take_video">Record video</string>
<string name="chat_media_badge_gif">GIF</string>
<string name="chat_media_badge_sticker">Sticker</string>
<string name="chat_picker_tab_emoji">Emoji</string>
<string name="chat_picker_tab_gif">GIF</string>
<string name="chat_picker_tab_stickers">Stickers</string>
<string name="chat_playback_subtitle_voice">Voice message • %1$s</string>
<string name="chat_playback_subtitle_audio">Audio • %1$s</string>
<string name="chat_user_fallback_with_id">User #%1$d</string>
<string name="chat_member_role_with_id">%1$s • id %2$d</string>
<string name="chat_member_id">id %1$d</string>
<string name="chat_member_dialog_demote_title">Demote admin</string>
<string name="chat_member_dialog_demote_body">Demote %1$s to member?</string>
<string name="chat_member_dialog_transfer_title">Transfer ownership</string>
<string name="chat_member_dialog_transfer_body">Transfer ownership to %1$s?</string>
<string name="chat_member_dialog_ban_title">Ban member</string>
<string name="chat_member_dialog_ban_body">Ban %1$s?</string>
<string name="chat_member_dialog_kick_title">Kick member</string>
<string name="chat_member_dialog_kick_body">Kick %1$s from chat?</string>
<string name="chat_error_voice_too_short">Voice message is too short.</string>
<string name="chat_error_delete_for_all_single">Delete for all is available only for single message selection.</string>
<string name="chat_error_delete_for_all_own">Delete for all is available only for your own messages.</string>
<string name="chat_info_history_cleared">Chat history cleared.</string>
<string name="chat_error_edit_expired">This message can no longer be edited.</string>
<string name="chat_error_send_restricted">Sending is restricted in this chat.</string>
<string name="chat_restriction_owner_admin">Only channel owner/admin can send messages.</string>
<string name="chat_error_action_self">You cannot apply this action to yourself.</string>
<string name="chat_error_permissions">You don\'t have enough permissions.</string>
<string name="chat_error_owner_only">Only owner can perform this action.</string>
<string name="chat_error_manage_owner">You cannot manage owner account.</string>
<string name="chat_error_admin_manage_admin_owner">Admin cannot manage admins or owner.</string>
<string name="chat_error_transfer_choose_another">Choose another member for ownership transfer.</string>
<string name="chat_voice_hint_slide">Slide up to lock, slide left to cancel</string>
<string name="chat_voice_hint_locked">Recording locked</string>
<string name="chat_title_fallback">Chat #%1$d</string>
<string name="chat_day_today">Today</string>
<string name="chat_day_yesterday">Yesterday</string>
<string name="chat_voice_recording_duration">Voice %1$s</string>
<string name="chat_unknown_user">Unknown user</string>
<string name="chat_media_placeholder">[media]</string>
<string name="chat_forwarded_from">Forwarded from %1$s</string>
<string name="chat_error_giphy_api_key_missing">Set GIPHY_API_KEY in local.properties</string>
<string name="chat_error_no_gifs_found">No GIFs found</string>
<string name="settings_user_fallback">User</string>
<string name="settings_accounts_header">ACCOUNTS</string>
<string name="settings_no_active_account">No active account</string>
<string name="settings_add_account">Add account</string>
<string name="settings_folder_account">Account</string>
<string name="settings_folder_chat">Chat settings</string>
<string name="settings_folder_privacy">Privacy</string>
<string name="settings_folder_notifications">Notifications</string>
<string name="settings_folder_data">Data and storage</string>
<string name="settings_folder_folders">Chat folders</string>
<string name="settings_folder_devices">Devices</string>
<string name="settings_folder_power">Power saving</string>
<string name="settings_folder_language">Language</string>
<string name="settings_account_subtitle">Phone number, username, bio</string>
<string name="settings_chat_subtitle">Wallpaper, night mode, animations</string>
<string name="settings_privacy_subtitle">Last seen, devices, passkeys</string>
<string name="settings_notifications_subtitle">Sounds, message counter</string>
<string name="settings_data_subtitle">Media download settings</string>
<string name="settings_folders_subtitle">Sort chats by folders</string>
<string name="settings_devices_subtitle">Manage active sessions</string>
<string name="settings_power_subtitle">Save power on low battery</string>
<string name="settings_placeholder_data">This section will be expanded in the next step.</string>
<string name="settings_placeholder_folders">Chat folders management will be added in next iteration.</string>
<string name="settings_placeholder_power">Power saving settings will be added in a separate step.</string>
<string name="settings_language_title">App language</string>
<string name="settings_language_system_subtitle">Use device language</string>
<string name="settings_language_english_subtitle">English</string>
<string name="language_system">System</string>
<string name="language_russian">Russian</string>
<string name="language_english">English</string>
<string name="settings_accounts">Accounts</string>
<string name="settings_active">Active</string>
<string name="settings_switch">Switch</string>
<string name="settings_open_profile">Open profile</string>
<string name="settings_logout">Logout</string>
<string name="settings_appearance">Appearance</string>
<string name="theme_light">Light</string>
<string name="theme_dark">Dark</string>
<string name="theme_system">System</string>
<string name="settings_enable_notifications">Enable notifications</string>
<string name="settings_show_preview">Show message preview</string>
<string name="settings_refresh_notifications">Refresh notification history</string>
<string name="settings_recent_notifications">Recent notifications</string>
<string name="settings_no_notifications_yet">No server notifications yet.</string>
<string name="settings_notification_meta">chat=%1$s • msg=%2$s • %3$s</string>
<string name="settings_fallback_avatar_letter">A</string>
<string name="privacy_private_messages">Private messages</string>
<string name="privacy_last_seen">Last seen</string>
<string name="privacy_avatar">Avatar</string>
<string name="privacy_group_invites">Group invites</string>
<string name="privacy_save">Save privacy</string>
<string name="settings_sessions_security">Sessions &amp; Security</string>
<string name="settings_unknown_device">Unknown device</string>
<string name="settings_unknown_ip">Unknown IP</string>
<string name="settings_revoke">Revoke</string>
<string name="settings_revoke_all">Revoke all sessions</string>
<string name="settings_2fa_code">2FA code</string>
<string name="settings_enable">Enable</string>
<string name="settings_disable">Disable</string>
<string name="settings_recovery_regen_code">Code for recovery regeneration</string>
<string name="settings_regenerate_recovery_codes">Regenerate recovery codes</string>
<string name="privacy_everyone">everyone</string>
<string name="privacy_contacts">contacts</string>
<string name="privacy_nobody">nobody</string>
<string name="auth_header_login">Messenger Login</string>
<string name="auth_subtitle_enter_email">Enter your email to continue</string>
<string name="auth_subtitle_enter_password">Enter password for %1$s</string>
<string name="auth_subtitle_create_account">Create account for %1$s</string>
<string name="auth_subtitle_2fa_enabled">Two-factor authentication is enabled</string>
<string name="auth_label_email">Email</string>
<string name="auth_label_name">Name</string>
<string name="auth_label_username">Username</string>
<string name="auth_label_password">Password</string>
<string name="auth_label_recovery_code">Recovery code</string>
<string name="auth_label_2fa_code">2FA code</string>
<string name="auth_continue">Continue</string>
<string name="auth_change_email">Change email</string>
<string name="auth_use_otp_code">Use OTP code</string>
<string name="auth_use_recovery_code">Use recovery code</string>
<string name="auth_sign_in">Sign in</string>
<string name="auth_create_account">Create account</string>
<string name="auth_confirm_2fa">Confirm 2FA</string>
<string name="auth_verify_email_by_token">Verify email by token</string>
<string name="auth_forgot_password">Forgot password</string>
<string name="auth_verify_email_title">Verify email</string>
<string name="auth_verification_token">Verification token</string>
<string name="auth_verify">Verify</string>
<string name="auth_email_for_resend">Email for resend</string>
<string name="auth_resend_verification_link">Resend verification link</string>
<string name="auth_back_to_login">Back to login</string>
<string name="auth_password_reset_title">Password reset</string>
<string name="auth_send_reset_link">Send reset link</string>
<string name="auth_new_password">New password</string>
<string name="auth_reset_with_token">Reset with token</string>
<string name="auth_error_enter_email">Enter email.</string>
<string name="auth_info_email_not_registered">This email is not registered. Complete sign up.</string>
<string name="auth_error_register_fields_required">Name, username and password are required.</string>
<string name="auth_info_account_created">Account created. Use password to sign in.</string>
<string name="auth_error_password_required">Password is required.</string>
<string name="auth_info_enter_2fa_or_recovery">Enter 2FA code or recovery code.</string>
<string name="auth_error_enter_2fa_code">Enter 2FA code.</string>
<string name="auth_error_enter_recovery_code">Enter recovery code.</string>
<string name="auth_error_invalid_credentials">Invalid email or password.</string>
<string name="auth_error_network">Network error. Check your connection.</string>
<string name="auth_error_session_expired">Session expired. Please sign in again.</string>
<string name="auth_error_server">Server error. Please try again.</string>
<string name="auth_error_unknown">Unknown error. Please try again.</string>
<string name="contacts_title">Contacts</string>
<string name="contacts_search_label">Search contacts/users</string>
<string name="contacts_add_by_email_label">Add by email</string>
<string name="contacts_search_results">Search results</string>
<string name="contacts_my_contacts">My contacts</string>
<string name="contacts_last_seen_recently">last seen recently</string>
<string name="contacts_remove">Remove</string>
<string name="contacts_empty">No contacts yet.</string>
<string name="contacts_info_added">Contact added.</string>
<string name="contacts_info_added_by_email">Contact added by email.</string>
<string name="contacts_info_removed">Contact removed.</string>
<string name="contacts_error_email_required">Email is required.</string>
<string name="error_network">Network error.</string>
<string name="error_session_expired">Session expired.</string>
<string name="error_authorization">Authorization error.</string>
<string name="error_server">Server error.</string>
<string name="error_unknown">Unknown error.</string>
<string name="account_user_fallback">User #%1$d</string>
<string name="account_error_email_password_required">Email and password are required.</string>
<string name="account_info_added">Account added.</string>
<string name="account_error_no_saved_session">No saved session for this account.</string>
<string name="account_error_switch_sync_failed">Account switched, but chats sync failed. Pull to refresh.</string>
<string name="account_info_profile_updated">Profile updated.</string>
<string name="account_info_avatar_uploaded">Avatar uploaded.</string>
<string name="account_info_privacy_updated">Privacy settings updated.</string>
<string name="account_info_2fa_secret_generated">2FA secret generated. Enter code to enable.</string>
<string name="account_info_recovery_codes_regenerated">Recovery codes regenerated.</string>
<string name="account_error_invalid_credentials">Invalid credentials.</string>
<string name="account_error_unauthorized">Unauthorized.</string>
</resources>

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="Theme.Messenger" parent="Theme.AppCompat.DayNight.NoActionBar" />
<integer name="google_play_services_version">12451000</integer>
</resources>

View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<paths xmlns:android="http://schemas.android.com/apk/res/android">
<cache-path
name="captures"
path="captures/" />
</paths>

View File

@@ -0,0 +1,157 @@
package ru.daemonlord.messenger.integration
import androidx.room.Room
import androidx.test.core.app.ApplicationProvider
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.runTest
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import ru.daemonlord.messenger.core.notifications.ActiveChatTracker
import ru.daemonlord.messenger.core.notifications.NotificationDispatcher
import ru.daemonlord.messenger.data.chat.local.db.MessengerDatabase
import ru.daemonlord.messenger.data.chat.local.entity.ChatEntity
import ru.daemonlord.messenger.domain.chat.model.ChatInviteLink
import ru.daemonlord.messenger.domain.chat.model.ChatItem
import ru.daemonlord.messenger.domain.chat.repository.ChatRepository
import ru.daemonlord.messenger.domain.common.AppResult
import ru.daemonlord.messenger.domain.notifications.model.ChatNotificationOverride
import ru.daemonlord.messenger.domain.notifications.model.NotificationSettings
import ru.daemonlord.messenger.domain.notifications.repository.NotificationSettingsRepository
import ru.daemonlord.messenger.domain.notifications.usecase.ShouldShowMessageNotificationUseCase
import ru.daemonlord.messenger.domain.realtime.RealtimeManager
import ru.daemonlord.messenger.domain.realtime.model.RealtimeEvent
import ru.daemonlord.messenger.domain.realtime.usecase.HandleRealtimeEventsUseCase
import java.time.Instant
@RunWith(RobolectricTestRunner::class)
@OptIn(ExperimentalCoroutinesApi::class)
class RealtimePipelineIntegrationTest {
private lateinit var db: MessengerDatabase
@Before
fun setUp() {
db = Room.inMemoryDatabaseBuilder(
ApplicationProvider.getApplicationContext(),
MessengerDatabase::class.java,
).allowMainThreadQueries().build()
}
@After
fun tearDown() {
db.close()
}
@Test
fun receiveMessageEvent_updatesRoomState() = runTest {
db.chatDao().upsertChats(
listOf(
ChatEntity(
id = 77L,
publicId = "chat-77",
type = "private",
title = null,
displayTitle = "Integration chat",
handle = null,
avatarUrl = null,
archived = false,
pinned = false,
muted = false,
unreadCount = 0,
unreadMentionsCount = 0,
counterpartUserId = null,
counterpartName = null,
counterpartUsername = null,
counterpartAvatarUrl = null,
counterpartIsOnline = false,
counterpartLastSeenAt = null,
lastMessageText = null,
lastMessageType = null,
lastMessageCreatedAt = null,
pinnedMessageId = null,
myRole = "member",
updatedSortAt = Instant.now().toString(),
)
)
)
val realtimeManager = FakeRealtimeManager()
val useCase = HandleRealtimeEventsUseCase(
realtimeManager = realtimeManager,
chatRepository = NoOpChatRepository(),
chatDao = db.chatDao(),
messageDao = db.messageDao(),
notificationDispatcher = NotificationDispatcher(ApplicationProvider.getApplicationContext()),
activeChatTracker = ActiveChatTracker(),
shouldShowMessageNotificationUseCase = ShouldShowMessageNotificationUseCase(
notificationSettingsRepository = AllowAllNotificationSettingsRepository(),
),
)
useCase.start()
realtimeManager.emit(
RealtimeEvent.ReceiveMessage(
chatId = 77L,
messageId = 9001L,
senderId = 5L,
replyToMessageId = null,
text = "integration hello",
type = "text",
createdAt = Instant.now().toString(),
isMention = false,
)
)
val chat = db.chatDao().observeChatById(77L).first()
assertEquals(1, chat?.unreadCount)
assertEquals("integration hello", chat?.lastMessageText)
useCase.stop()
}
private class FakeRealtimeManager : RealtimeManager {
private val stream = MutableSharedFlow<RealtimeEvent>(extraBufferCapacity = 8)
override val events: Flow<RealtimeEvent> = stream
override fun connect() = Unit
override fun disconnect() = Unit
fun emit(event: RealtimeEvent) {
stream.tryEmit(event)
}
}
private class NoOpChatRepository : ChatRepository {
override fun observeChats(archived: Boolean): Flow<List<ChatItem>> = kotlinx.coroutines.flow.flowOf(emptyList())
override fun observeChat(chatId: Long): Flow<ChatItem?> = kotlinx.coroutines.flow.flowOf(null)
override suspend fun refreshChats(archived: Boolean): AppResult<Unit> = AppResult.Success(Unit)
override suspend fun refreshChat(chatId: Long): AppResult<Unit> = AppResult.Success(Unit)
override suspend fun createInviteLink(chatId: Long): AppResult<ChatInviteLink> {
return AppResult.Error(ru.daemonlord.messenger.domain.common.AppError.Unknown(null))
}
override suspend fun joinByInvite(token: String): AppResult<ChatItem> {
return AppResult.Error(ru.daemonlord.messenger.domain.common.AppError.Unknown(null))
}
override suspend fun deleteChat(chatId: Long) = Unit
}
private class AllowAllNotificationSettingsRepository : NotificationSettingsRepository {
override fun observeSettings(): Flow<NotificationSettings> = kotlinx.coroutines.flow.flowOf(NotificationSettings())
override suspend fun getSettings(): NotificationSettings = NotificationSettings()
override suspend fun setGlobalEnabled(enabled: Boolean) = Unit
override suspend fun setPreviewEnabled(enabled: Boolean) = Unit
override fun observeChatOverride(chatId: Long): Flow<ChatNotificationOverride> {
return kotlinx.coroutines.flow.flowOf(ChatNotificationOverride.DEFAULT)
}
override suspend fun getChatOverride(chatId: Long): ChatNotificationOverride = ChatNotificationOverride.DEFAULT
override suspend fun setChatOverride(chatId: Long, mode: ChatNotificationOverride) = Unit
override suspend fun clearChatOverride(chatId: Long) = Unit
override suspend fun clearChatOverrides() = Unit
}
}

View File

@@ -30,6 +30,9 @@ x-app-env: &app-env
SMTP_USE_SSL: ${SMTP_USE_SSL:-false}
SMTP_TIMEOUT_SECONDS: ${SMTP_TIMEOUT_SECONDS:-10}
SMTP_FROM_EMAIL: ${SMTP_FROM_EMAIL:-no-reply@benyamessenger.local}
FIREBASE_ENABLED: ${FIREBASE_ENABLED:-true}
FIREBASE_CREDENTIALS_PATH: ${FIREBASE_CREDENTIALS_PATH:-/run/secrets/firebase-service-account.json}
FIREBASE_WEBPUSH_LINK: ${FIREBASE_WEBPUSH_LINK:-https://chat.daemonlord.ru/}
LOGIN_RATE_LIMIT_PER_MINUTE: ${LOGIN_RATE_LIMIT_PER_MINUTE:-10}
REGISTER_RATE_LIMIT_PER_MINUTE: ${REGISTER_RATE_LIMIT_PER_MINUTE:-5}
RESET_RATE_LIMIT_PER_MINUTE: ${RESET_RATE_LIMIT_PER_MINUTE:-5}
@@ -113,6 +116,8 @@ services:
RUN_MIGRATIONS_ON_STARTUP: ${RUN_MIGRATIONS_ON_STARTUP:-true}
ports:
- "${BACKEND_PORT:-8000}:8000"
volumes:
- ${FIREBASE_CREDENTIALS_HOST_PATH:-./secrets/firebase-service-account.json}:${FIREBASE_CREDENTIALS_PATH:-/run/secrets/firebase-service-account.json}:ro
healthcheck:
test: ["CMD-SHELL", "python -c \"import urllib.request; urllib.request.urlopen('http://localhost:8000/health/ready').read()\""]
interval: 10s
@@ -134,6 +139,8 @@ services:
<<: *app-env
AUTO_CREATE_TABLES: false
RUN_MIGRATIONS_ON_STARTUP: false
volumes:
- ${FIREBASE_CREDENTIALS_HOST_PATH:-./secrets/firebase-service-account.json}:${FIREBASE_CREDENTIALS_PATH:-/run/secrets/firebase-service-account.json}:ro
mailpit:
image: axllent/mailpit:latest

View File

@@ -56,11 +56,17 @@
- [x] Forward в 1+ чатов
- [x] Reactions
- [x] Delivery/read states
- [x] Text formatting parity with web:
- bold / italic / underline / strikethrough
- spoiler / monospace / code block / links
- composer toolbar behavior (mobile-first)
## 8. Медиа и вложения
- [x] Upload image/video/file/audio
- [x] Upload/send GIF and sticker attachments
- [x] Галерея в сообщении (multi media)
- [x] Media viewer (zoom/swipe/download)
- [x] Fullscreen video viewer from chat bubbles
- [x] Единое контекстное меню для медиа
- [x] Voice playback waveform + speed
- [x] Audio player UI (не как voice)
@@ -81,6 +87,10 @@
- [x] Admin actions: add/remove/ban/unban/promote/demote
- [x] Ограничения канала: писать только owner/admin
- [x] Member visibility rules (скрытие списков/действий)
- [x] Channel chat visual pass (Telegram-like):
- post-style bubbles in channel timeline,
- read-only bottom bar for non-admin members (`Включить звук` style),
- cleaner channel feed density and spacing.
## 11. Поиск
- [x] Глобальный поиск: users/chats/messages
@@ -94,6 +104,8 @@
- [x] Deep links: open chat/message
- [x] Mention override для muted чатов
- [x] DataStore настройки уведомлений (global + per-chat override)
- [x] Server notifications inbox in settings (`GET /notifications`)
- [x] Push-token lifecycle sync (`POST/DELETE /notifications/push-token`) with dedupe per user/token
## 13. UI/UX и темы
- [x] Светлая/темная тема (читаемая)
@@ -101,6 +113,7 @@
- [x] Контекстные меню без конфликтов жестов
- [x] Bottom sheets/dialog behavior consistency
- [x] Accessibility (TalkBack, dynamic type)
- [x] Day separators in chat timeline (Сегодня/Вчера/дата)
## 14. Безопасность
- [x] Secure token storage (EncryptedSharedPrefs/Keystore)

View File

@@ -18,20 +18,21 @@ Backend покрывает web-функционал почти полность
## 2) Web endpoints not yet fully used on Android
- `GET /api/v1/messages/{message_id}/thread` (data layer wired, UI thread screen/jump usage pending)
- `GET /api/v1/notifications`
- `POST /api/v1/notifications/push-token`
- `DELETE /api/v1/notifications/push-token`
- `POST /api/v1/auth/resend-verification`
## 2.1) Web feature parity gaps not yet covered on Android
- Notification delivery polish is still partial:
- chat-level grouping/snooze parity with web prefs (full)
- richer per-chat override UX alignment in Android settings
## 3) Practical status
- Backend readiness vs Web: `high`
- Android parity vs Web (feature-level): `~82-87%`
- Android parity vs Web (feature-level): `~87-91%`
## 4) Highest-priority Android parity step
Завершить следующий parity-блок:
- `GET /api/v1/messages/{message_id}/thread` (UI usage)
- notifications API + UI inbox flow
- notifications delivery polish (channels/grouping/snooze/per-chat overrides parity with web prefs)
- notification delivery polish (channels/grouping/snooze/per-chat overrides parity with web prefs)

View File

@@ -9,7 +9,7 @@ let foregroundListenerAttached = false;
export async function ensureWebPushRegistration(): Promise<void> {
const config = getFirebaseConfig();
const vapidKey = import.meta.env.VITE_FIREBASE_VAPID_KEY?.trim();
const vapidKey = normalizeVapidKey(import.meta.env.VITE_FIREBASE_VAPID_KEY);
if (!config || !vapidKey) {
return;
}
@@ -33,10 +33,21 @@ export async function ensureWebPushRegistration(): Promise<void> {
const registration = await navigator.serviceWorker.ready;
const app = getApps()[0] ?? initializeApp(config);
const messaging = getMessaging(app);
const token = await getToken(messaging, {
vapidKey,
serviceWorkerRegistration: registration,
});
let token: string | null = null;
try {
token = await getToken(messaging, {
vapidKey,
serviceWorkerRegistration: registration,
});
} catch (error) {
if (error instanceof DOMException && error.name === "InvalidAccessError") {
console.error(
"[web-push] Invalid VAPID key format. Check VITE_FIREBASE_VAPID_KEY in web env.",
);
return;
}
throw error;
}
if (!token) {
return;
}
@@ -107,3 +118,34 @@ function getFirebaseConfig():
appId,
};
}
function normalizeVapidKey(raw: string | undefined): string | null {
if (!raw) {
return null;
}
let key = raw.trim();
if (!key) {
return null;
}
// Accept accidental JSON payloads copied from docs/panels.
if (key.startsWith("{") && key.endsWith("}")) {
try {
const parsed = JSON.parse(key) as { vapidKey?: string; publicKey?: string; key?: string };
key = (parsed.vapidKey ?? parsed.publicKey ?? parsed.key ?? "").trim();
} catch {
return null;
}
}
// Strip wrapping quotes and whitespace/newlines.
key = key.replace(/^['"]|['"]$/g, "").replace(/\s+/g, "");
// Convert classic base64 chars to URL-safe format expected by PushManager/Firebase.
key = key.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, "");
if (!/^[A-Za-z0-9\-_]+$/.test(key)) {
return null;
}
return key.length >= 80 ? key : null;
}

View File

@@ -26,15 +26,34 @@ export async function showNotificationViaServiceWorker(params: {
if (!registration) {
return false;
}
const tag = `chat-${params.chatId}`;
const active = await registration.getNotifications({ tag });
const prevData = active[0]?.data as
| {
unreadCount?: number;
previews?: string[];
}
| undefined;
const unreadCount = Math.max(0, Number(prevData?.unreadCount ?? 0)) + 1;
const previews = [params.body, ...(prevData?.previews ?? [])]
.filter((item) => item && item.trim().length > 0)
.slice(0, 3);
const extraCount = Math.max(0, unreadCount - previews.length);
const groupedBody =
previews.length <= 1
? previews[0] ?? params.body
: `${previews.join("\n")}${extraCount > 0 ? `\n+${extraCount} more` : ""}`;
const url = `/?chat=${params.chatId}&message=${params.messageId}`;
await registration.showNotification(params.title, {
body: params.body,
body: groupedBody,
icon: params.image,
tag: `chat-${params.chatId}`,
tag,
data: {
chatId: params.chatId,
messageId: params.messageId,
url,
unreadCount,
previews,
},
});
return true;

View File

@@ -1 +1 @@
{"root":["./src/main.tsx","./src/vite-env.d.ts","./src/api/auth.ts","./src/api/chats.ts","./src/api/http.ts","./src/api/notifications.ts","./src/api/search.ts","./src/api/users.ts","./src/app/app.tsx","./src/chat/types.ts","./src/components/apperrorboundary.tsx","./src/components/authpanel.tsx","./src/components/chatinfopanel.tsx","./src/components/chatlist.tsx","./src/components/messagecomposer.tsx","./src/components/messagelist.tsx","./src/components/newchatpanel.tsx","./src/components/settingspanel.tsx","./src/components/toastviewport.tsx","./src/hooks/userealtime.ts","./src/pages/authpage.tsx","./src/pages/chatspage.tsx","./src/store/audioplayerstore.ts","./src/store/authstore.ts","./src/store/chatstore.ts","./src/store/uistore.ts","./src/utils/format.ts","./src/utils/formatmessage.tsx","./src/utils/preferences.ts","./src/utils/webnotifications.ts","./src/utils/ws.ts"],"version":"5.9.2"}
{"root":["./src/main.tsx","./src/vite-env.d.ts","./src/api/auth.ts","./src/api/chats.ts","./src/api/http.ts","./src/api/notifications.ts","./src/api/search.ts","./src/api/users.ts","./src/app/app.tsx","./src/chat/types.ts","./src/components/apperrorboundary.tsx","./src/components/authpanel.tsx","./src/components/avatarcropmodal.tsx","./src/components/chatinfopanel.tsx","./src/components/chatlist.tsx","./src/components/mediaviewer.tsx","./src/components/messagecomposer.tsx","./src/components/messagelist.tsx","./src/components/newchatpanel.tsx","./src/components/settingspanel.tsx","./src/components/toastviewport.tsx","./src/hooks/userealtime.ts","./src/pages/authpage.tsx","./src/pages/chatspage.tsx","./src/store/audioplayerstore.ts","./src/store/authstore.ts","./src/store/chatstore.ts","./src/store/uistore.ts","./src/utils/firebasepush.ts","./src/utils/format.ts","./src/utils/formatmessage.tsx","./src/utils/preferences.ts","./src/utils/webnotifications.ts","./src/utils/ws.ts"],"version":"5.9.2"}