Compare commits

..

123 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
Codex
9296695ed5 docs: document push token and firebase notification setup
Some checks failed
Android CI / android (push) Failing after 4m47s
Android Release / release (push) Has started running
CI / test (push) Has been cancelled
2026-03-09 23:12:54 +03:00
Codex
ef28c165e6 web: add firebase push token registration and sync 2026-03-09 23:12:40 +03:00
Codex
b1b54896a7 android: sync FCM token with backend notifications API 2026-03-09 23:12:29 +03:00
Codex
74b086b9c8 backend: add push token API and FCM delivery pipeline 2026-03-09 23:12:19 +03:00
Codex
e82178fcc3 android: add contacts API parity and real contacts screen
Some checks failed
Android CI / android (push) Failing after 4m42s
Android Release / release (push) Failing after 5m38s
CI / test (push) Failing after 2m42s
2026-03-09 22:54:13 +03:00
Codex
b294297dbd android: add global search and message thread API parity
Some checks failed
Android CI / android (push) Failing after 4m59s
Android Release / release (push) Has started running
CI / test (push) Has been cancelled
2026-03-09 22:48:36 +03:00
Codex
7824ab1a55 android: add chat title/profile patch API parity
Some checks failed
Android CI / android (push) Failing after 5m22s
Android Release / release (push) Has started running
CI / test (push) Has been cancelled
2026-03-09 22:40:52 +03:00
Codex
854ba0cbc6 android: compress images before media 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-09 22:35:49 +03:00
Codex
bd1229fe5a android: use saved chat endpoint in chats menu
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 22:31:00 +03:00
Codex
c040ebf059 android: sync pin and archive changes immediately on chat_updated
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 22:28:56 +03:00
Codex
f005b3f222 android: wire chats popup actions to archive pin delete clear and mute APIs
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 22:25:33 +03:00
Codex
77697ff36e android: refine chats multi-select menu labels and state
Some checks failed
Android CI / android (push) Failing after 5m17s
Android Release / release (push) Has started running
CI / test (push) Has been cancelled
2026-03-09 22:19:54 +03:00
Codex
e6a9fe9cca android: show selection check badge on chat avatars
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 22:17:58 +03:00
Codex
9dff805145 android: show selection drag markers only for pinned chats
Some checks failed
Android CI / android (push) Has started running
Android Release / release (push) Has been cancelled
CI / test (push) Has started running
2026-03-09 22:15:04 +03:00
Codex
4f53e3ef99 android: polish fullscreen chats search interactions
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 22:12:51 +03:00
Codex
4a31612df0 android: reset chats search query when leaving fullscreen search
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 22:10:41 +03:00
Codex
c4d1e7f1fb android: persist chats search history and recent in datastore
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 22:07:59 +03:00
Codex
18844ec06a android: redesign chats search as fullscreen telegram-like flow
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 22:03:43 +03:00
Codex
28f7da5f41 web: open reset form immediately for reset links
Some checks failed
Android CI / android (push) Failing after 5m33s
Android Release / release (push) Has started running
CI / test (push) Has been cancelled
2026-03-09 21:55:42 +03:00
Codex
776a7634d2 web: fix reset password token flow and auth interceptor
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 21:52:24 +03:00
Codex
cbd326ee12 android: wire chats popup actions and remove duplicate menu
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 21:46:17 +03:00
Codex
4502fdf9e9 android: add chats menu select and search interaction states
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 21:43:11 +03:00
Codex
2324801f56 android: refine chats list typography badges and time format
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 21:40:04 +03:00
Codex
e717888d8e android: tune chats list visuals closer to telegram
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 21:38:13 +03:00
Codex
6a1961e045 android: fix unread badge reset when chat is read
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 21:34:03 +03:00
Codex
8101cbbffd android: chat list preview cleanup without emoji icons 2026-03-09 21:33:44 +03:00
Codex
0a9297c03d android: show connecting status in chats header via realtime state
Some checks failed
Android CI / android (push) Failing after 5m0s
Android Release / release (push) Has started running
CI / test (push) Has been cancelled
2026-03-09 21:28:03 +03:00
Codex
3b3c740ae0 android: update chats list header and archive flow to match reference
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 21:23:01 +03:00
Codex
b75df4967f android: remove chats bottom gap when tabs bar is hidden
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 21:20:04 +03:00
Codex
6328a74c23 android: align top bar offsets across main pages
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 21:18:15 +03:00
Codex
fdd877b49a android: add top app bars and safe-area pass for main pages
Some checks failed
Android CI / android (push) Has started running
Android Release / release (push) Has been cancelled
CI / test (push) Has started running
2026-03-09 21:16:06 +03:00
Codex
ee52785b1b android: tune global bottom tabs to telegram-like style
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 21:12:59 +03:00
Codex
3af90ec257 android: fix main tabs bar layout and content overlap
Some checks failed
Android CI / android (push) Failing after 5m15s
Android Release / release (push) Failing after 5m18s
CI / test (push) Failing after 2m26s
2026-03-09 20:29:05 +03:00
Codex
d29ad4cfb7 android: unify main tabs shell and hide bottom bar on scroll
Some checks failed
Android CI / android (push) Failing after 4m25s
Android Release / release (push) Failing after 4m49s
CI / test (push) Failing after 2m11s
2026-03-09 20:06:05 +03:00
Codex
448ed3243d android: start material icons migration for chat and list ui
Some checks failed
Android CI / android (push) Failing after 4m7s
Android Release / release (push) Failing after 4m36s
CI / test (push) Has started running
2026-03-09 19:56:27 +03:00
Codex
e65714e45e docs: add ui batch 4 reference map and material icons policy
Some checks failed
Android CI / android (push) Failing after 3m57s
Android Release / release (push) Has started running
CI / test (push) Has been cancelled
2026-03-09 19:50:17 +03:00
Codex
c12ab05946 android: send recorded audio as voice and fix playback replay/duration
Some checks failed
Android CI / android (push) Failing after 4m5s
Android Release / release (push) Failing after 4m1s
CI / test (push) Failing after 2m24s
2026-03-09 16:52:17 +03:00
Codex
fd31e39fce android: add tablet adaptive layouts and fix voice release send
Some checks failed
Android CI / android (push) Has started running
Android Release / release (push) Has been cancelled
CI / test (push) Has been cancelled
2026-03-09 16:49:17 +03:00
Codex
f6851d2af9 android: add voice waveform/speed and circle video playback
Some checks failed
Android CI / android (push) Failing after 4m14s
Android Release / release (push) Has started running
CI / test (push) Has been cancelled
2026-03-09 16:44:25 +03:00
Codex
45918d65cb android: unify chat action sheets and resolve gesture conflicts
Some checks failed
Android CI / android (push) Has started running
Android Release / release (push) Has been cancelled
CI / test (push) Has been cancelled
2026-03-09 16:42:33 +03:00
Codex
af6d8426ba android: fix voice permissions, theme apply, and profile avatar layout
Some checks failed
Android CI / android (push) Failing after 4m7s
Android Release / release (push) Has started running
CI / test (push) Has been cancelled
2026-03-09 16:36:34 +03:00
Codex
881ad99ada android: add global search inline jump theme toggle and accessibility pass
Some checks failed
Android CI / android (push) Failing after 4m9s
Android Release / release (push) Has started running
CI / test (push) Has been cancelled
2026-03-09 16:28:48 +03:00
Codex
862b18e305 android: add group channel invite and admin management baseline
Some checks failed
Android CI / android (push) Failing after 3m54s
Android Release / release (push) Has started running
CI / test (push) Has been cancelled
2026-03-09 16:21:43 +03:00
Codex
47190e354d android: add hold-to-record voice flow with lock cancel and audio focus
Some checks failed
Android CI / android (push) Has started running
Android Release / release (push) Has been cancelled
CI / test (push) Has been cancelled
2026-03-09 16:15:43 +03:00
Codex
69c0b632df web: add complete password reset flow with deep link token handling
Some checks failed
Android CI / android (push) Failing after 3m59s
Android Release / release (push) Has started running
CI / test (push) Failing after 2m15s
2026-03-09 16:09:17 +03:00
132 changed files with 15376 additions and 1392 deletions

View File

@@ -34,6 +34,11 @@ SMTP_USE_TLS=false
SMTP_USE_SSL=false SMTP_USE_SSL=false
SMTP_TIMEOUT_SECONDS=10 SMTP_TIMEOUT_SECONDS=10
SMTP_FROM_EMAIL=no-reply@benyamessenger.local 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/
LOGIN_RATE_LIMIT_PER_MINUTE=10 LOGIN_RATE_LIMIT_PER_MINUTE=10
REGISTER_RATE_LIMIT_PER_MINUTE=5 REGISTER_RATE_LIMIT_PER_MINUTE=5

1
.gitignore vendored
View File

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

View File

@@ -0,0 +1,44 @@
"""add push device tokens table
Revision ID: 0027_push_device_tokens
Revises: 0026_deduplicate_saved_chats
Create Date: 2026-03-10 02:10:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = "0027_push_device_tokens"
down_revision: Union[str, Sequence[str], None] = "0026_deduplicate_saved_chats"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
"push_device_tokens",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("user_id", sa.Integer(), nullable=False),
sa.Column("platform", sa.String(length=16), nullable=False),
sa.Column("token", sa.String(length=512), nullable=False),
sa.Column("device_id", sa.String(length=128), nullable=True),
sa.Column("app_version", sa.String(length=64), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("user_id", "platform", "token", name="uq_push_device_tokens_user_platform_token"),
)
op.create_index(op.f("ix_push_device_tokens_id"), "push_device_tokens", ["id"], unique=False)
op.create_index(op.f("ix_push_device_tokens_platform"), "push_device_tokens", ["platform"], unique=False)
op.create_index(op.f("ix_push_device_tokens_user_id"), "push_device_tokens", ["user_id"], unique=False)
def downgrade() -> None:
op.drop_index(op.f("ix_push_device_tokens_user_id"), table_name="push_device_tokens")
op.drop_index(op.f("ix_push_device_tokens_platform"), table_name="push_device_tokens")
op.drop_index(op.f("ix_push_device_tokens_id"), table_name="push_device_tokens")
op.drop_table("push_device_tokens")

View File

@@ -392,3 +392,634 @@
- Reworked Settings/Profile screens from placeholders to editable account management screens. - Reworked Settings/Profile screens from placeholders to editable account management screens.
- Added avatar upload with center square crop (`1:1`) before upload. - Added avatar upload with center square crop (`1:1`) before upload.
- Upgraded message attachment rendering with in-bubble multi-image gallery and unified attachment context actions (open/copy/close). - Upgraded message attachment rendering with in-bubble multi-image gallery and unified attachment context actions (open/copy/close).
### Step 66 - Voice recording controls + global audio focus
- Added microphone permission (`RECORD_AUDIO`) and in-chat voice recording flow based on press-and-hold gesture.
- Implemented Telegram-like gesture controls for voice button:
- hold to record,
- slide up to lock recording,
- slide left to cancel recording.
- Added minimum voice length validation (`>= 1s`) before sending.
- Integrated voice message sending via existing media upload path (`audio/mp4` attachment).
- Added process-wide audio focus coordinator to enforce single active audio source:
- attachment player pauses when another source starts,
- recording requests focus and stops competing playback.
### Step 67 - Group/channel management baseline in Chat List
- Extended chat API/repository layer with management operations:
- create group/channel,
- discover + join/leave chats,
- invite link create/regenerate,
- members/bans listing and admin actions (add/remove/ban/unban/promote/demote).
- Added domain models for discover/member/ban items and repository mappings.
- Added in-app management panel in `ChatListScreen` (FAB toggle) for:
- creating group/channel,
- joining discovered chats,
- loading chat members/bans by chat id,
- executing admin/member visibility actions from one place.
### Step 68 - Search, inline jump, theme toggle, accessibility pass
- Added global search baseline in chat list:
- users search (`/users/search`),
- messages search (`/messages/search`),
- chat discovery integration (`/chats/discover`).
- Added inline search in chat screen with jump navigation (prev/next) and automatic scroll to matched message.
- Added highlighted message state for active inline search result.
- Added theme switching controls in settings (Light/Dark/System) via `AppCompatDelegate`.
- Added accessibility refinements for key surfaces and controls:
- explicit content descriptions for avatars and tab-like controls,
- voice record button semantic label for TalkBack.
### Step 69 - Bugfix pass: voice recording, theme apply, profile avatar UX
- Fixed voice recording start on Android by switching `VoiceRecorder` to compatible `MediaRecorder()` initialization.
- Fixed microphone permission flow: record action now triggers runtime permission request reliably and auto-starts recording after grant.
- Fixed theme switching application by introducing app-level `MessengerTheme` and switching app manifest base theme to DayNight.
- Fixed profile screen usability after avatar upload:
- enabled vertical scrolling with safe insets/navigation padding,
- constrained avatar preview to a centered circular area instead of full-screen takeover.
### Step 70 - Chat interaction consistency: gestures + sheets/dialogs
- Reworked single-message actions to open in `ModalBottomSheet` (tap action menu) instead of inline action bars.
- Reworked forward target chooser to `ModalBottomSheet` for consistent overlay behavior across chat actions.
- Added destructive action confirmation via `AlertDialog` before delete actions.
- Reduced gesture conflicts by removing attachment-level long-press handlers that collided with message selection gestures.
- Improved voice hold gesture reliability by handling consumed pointer down events (`requireUnconsumed = false`).
### Step 71 - Voice playback waveform/speed + circle video playback
- Added voice-focused audio playback mode with waveform rendering in message bubbles.
- Added playback speed switch for voice messages (`1.0x -> 1.5x -> 2.0x`).
- Added view-only circle video renderer for `video_note` messages with looped playback.
- Kept regular audio/video attachment rendering for non-voice/non-circle media unchanged.
### Step 72 - Adaptive layout baseline (phone/tablet) + voice release fix
- Added tablet-aware max-width layout constraints across major screens (login, verify/reset auth, chats list, chat, profile, settings).
- Kept phone layout unchanged while centering content and limiting line width on larger displays.
- Fixed voice hold-to-send gesture reliability by removing pointer-input restarts during active recording, so release consistently triggers send path.
### Step 73 - Voice message send/playback bugfixes
- Fixed voice media type mapping in message repository: recorded files with `voice_*.m4a` are now sent as message type `voice` (not generic `audio`).
- Fixed audio replay behavior: when playback reaches the end, next play restarts from `0:00`.
- Improved duration display in audio/voice player by adding metadata fallback when `MediaPlayer` duration is not immediately available.
### Step 74 - UI references consolidation (Batch 4)
- Added full Telegram reference mapping checklist (`docs/android-ui-batch-4-checklist.md`) with screenshot-by-screenshot description.
- Added explicit icon policy: no emoji icons in production UI components, Material Icons/vector icons only.
- Updated UI checklist index with Batch 4 entry.
### Step 75 - Material Icons migration (Batch 1 start)
- Replaced symbol/emoji-based UI controls in chat surfaces with Material Icons:
- chat header/menu/search controls (`more`, `up/down`),
- image viewer actions (`close`, `forward`, `delete`),
- multi-select markers (`radio checked/unchecked`, `selected` check),
- attachment/media markers (`movie`, `attach file`).
- Replaced chat list management FAB glyph toggle (`+`/`×`) with Material `Add`/`Close` icons.
- Added `androidx.compose.material:material-icons-extended` dependency for consistent icon usage.
### Step 76 - Shared main tabs shell with scroll-aware visibility
- Moved `Chats / Contacts / Settings / Profile` bottom panel to a shared navigation shell (`AppNavGraph`) so it behaves as global page navigation.
- Added dedicated `Contacts` page route and wired it into main tabs.
- Removed local duplicated bottom panel from chat list screen.
- Implemented scroll-direction behavior for all 4 main pages:
- hide panel on downward scroll,
- show panel on upward scroll / at top.
### Step 77 - Main tabs bar UX/layout fix
- Replaced custom pill-row main bar with compact `NavigationBar` inside rounded container for stable 4-tab layout on small screens.
- Added bottom content paddings for `Chats/Contacts/Settings/Profile` pages so content is not obscured by the floating main bar.
- Raised chats management FAB offset to avoid overlap with the global bottom bar.
### Step 78 - Telegram-like bottom tabs visual tuning
- Tuned shared main bar visual style to better match Telegram references:
- rounded floating container with subtle elevation,
- unified selected/unselected item colors,
- stable 4-item navigation with icons + labels.
- Kept scroll-hide/show behavior and page-level navigation unchanged.
### Step 79 - Main pages app bars + safe-area pass
- Added top app bars for all 4 main pages (`Chats`, `Contacts`, `Settings`, `Profile`) to make them feel like proper standalone sections.
- Moved chats management toggle action into chats app bar.
- Kept safe-area handling and bottom insets consistent with shared floating tabs bar to avoid overlap.
### Step 80 - Top bar offset consistency fix
- Unified top bar alignment across `Chats`, `Contacts`, `Settings`, and `Profile`:
- removed extra outer paddings that shifted headers down/right on some pages,
- separated content padding from top app bar container.
- Result: consistent title baseline and horizontal alignment between main pages.
### Step 81 - Chats bottom gap fix when tabs bar hidden
- Fixed blank gap at the bottom of chats list when global tabs bar auto-hides on scroll.
- Chats screen bottom padding is now dynamic and applied only while tabs bar is visible.
### Step 82 - Chats list header closer to Telegram reference
- Removed `Archived` top tab from chats list UI.
- Added search action in top app bar and unified single search field with leading search icon.
- Kept archive as dedicated row inside chats list; opening archive now happens from that row and back navigation appears in app bar while archive is active.
### Step 83 - Chats header realtime connection status
- Added realtime connection state stream (`Disconnected/Connecting/Reconnecting/Connected`) to `RealtimeManager`.
- Wired websocket lifecycle into that state in `WsRealtimeManager`.
- Bound chats top bar title to realtime state:
- shows `Connecting...` while reconnect/initial connect is in progress,
- shows regular page title once connected.
### Step 84 - Chats list preview icon policy cleanup
- Updated chat last-message preview text to remove emoji prefixes.
- Switched media-type preview prefixes to plain text labels (`Photo`, `Video`, `Voice`, etc.) to match Material-icons-only UI policy.
### Step 85 - Unread counter fix for active/read chats
- Added `ChatDao.markChatRead(chatId)` to clear `unread_count` and `unread_mentions_count` in Room.
- Applied optimistic local unread reset on `markMessageRead(...)` in message repository.
- Fixed realtime unread logic: incoming messages in currently active chat no longer increment unread badge.
### Step 86 - Chats list visual pass toward Telegram reference
- Updated chats list row density: tighter vertical rhythm, larger avatar, stronger title hierarchy, cleaner secondary text.
- Restyled archive as dedicated list row with leading archive icon avatar, subtitle, and unread badge.
- Kept search in top app bar action and changed search field default to collapsed (opens via search icon).
- Returned message-type emoji markers in chat previews:
- `🖼` photo, `🎤` voice, `🎵` audio, `🎥` video, `⭕` circle video, `🔗` links.
### Step 87 - Chats list micro-typography and time formatting
- Refined chat row typography hierarchy to be closer to Telegram density:
- title/body/presence font scale aligned and single-line ellipsis for long values.
- Tightened unread/mention badge sizing and spacing for compact right-side metadata.
- Updated trailing time formatter:
- today: `HH:mm`,
- this week: localized short weekday,
- older: `dd.MM.yy`.
### Step 88 - Chats list interaction states (menu/select/search)
- Added default overflow menu (`⋮`) state in chats header with Telegram-like quick actions UI.
- Added long-press multi-select mode for chat rows with:
- top selection bar (`count`, action icons),
- dedicated overflow menu for selected chats.
- Added dedicated search-mode state in chats screen:
- search field + section chips (`Chats/Channels/Apps/Posts`),
- horizontal recent avatars strip,
- list filtered by active query.
### Step 89 - Chats actions wiring + duplicate menu fix
- Removed duplicated overflow action in chats top bar (single `⋮` remains in default mode).
- Wired selection actions to behavior:
- delete selected -> leave selected chats,
- archive selected -> switch to archived section,
- non-implemented bulk actions now show explicit user feedback.
- Wired default menu actions:
- create group/channel -> open management panel,
- saved -> open saved chat if present,
- unsupported items show clear feedback instead of silent no-op.
### Step 90 - Fullscreen chats search redesign (Telegram-like)
- Reworked chats search mode into a fullscreen flow:
- top rounded search field with inline clear button,
- horizontal category chips (`Chats`, `Channels`, `Apps`, `Posts`),
- dedicated recent avatars row for the active category.
- Added search-mode content states:
- empty query -> `Recent` list block (history-style chat rows),
- non-empty query -> local matches + `Global search` and `Messages` sections.
- Kept search action in chats top bar; while search mode is active, app bar switches to back-navigation + empty title (content drives the page).
### Step 91 - Search history/recent persistence + clear action
- Added `ChatSearchRepository` abstraction and `DataStoreChatSearchRepository` implementation.
- Persisted chats search metadata in `DataStore`:
- recent opened chats list,
- search history list (bounded).
- Wired chats fullscreen search to persisted data:
- green recent avatars strip now reads saved recent chats,
- red `Recent` list now reads saved history with fallback.
- Connected `Очистить` action to real history cleanup in `DataStore`.
- On opening a chat from search results/messages/history, the chat is now stored in recent/history.
### Step 92 - Search filter leak fix on exit
- Fixed chats search state leak: leaving fullscreen search now resets local/global query.
- Main chats list no longer stays filtered by previous search input after returning from search mode.
### Step 93 - Fullscreen search UX polish
- Added system back-handler for search mode with safe query reset.
- Improved fullscreen search result sections:
- `Показать больше / Свернуть` toggle for global users,
- `Показать больше / Свернуть` toggle for message results.
- Added explicit empty-state text when local/global/message search sections all have no results.
### Step 94 - Pinned-only drag markers in selection mode
- Updated chats multi-select row UI: drag markers are now shown only for pinned chats.
- Non-pinned chats no longer render reorder marker in selection mode.
### Step 95 - Selection badge on avatar (Telegram-like)
- Added explicit selection indicator directly on chat avatars in multi-select mode:
- selected chat -> colored circle with check icon,
- unselected chat -> empty outlined circle.
- This matches the reference behavior and makes selected rows easier to scan.
### Step 96 - Selection menu labels and behavior polish
- Updated multi-select top actions/menu to be closer to Telegram reference in wording.
- Added dynamic `Закрепить/Открепить` label in selection overflow based on selected chats pinned state.
- Kept non-supported actions explicit with user feedback (Toast), avoiding silent no-op behavior.
### Step 97 - Chats popup/select actions wired to backend API
- Extended Android chat data layer with missing parity endpoints:
- `archive/unarchive`
- `pin-chat/unpin-chat`
- `clear`
- `delete (for_all=false)`
- `chat notifications get/update`
- Added repository methods and `ViewModel` actions for those operations.
- Replaced chats multi-select UI stubs with real API calls:
- mute/unmute selected chats,
- archive/unarchive selected chats,
- pin/unpin selected chats,
- clear selected chats,
- delete selected chats for current user.
### Step 98 - Realtime sync fix for pin/archive updates
- Improved `chat_updated` handling in realtime flow:
- now refreshes both active and archived chats lists to sync user-scoped flags (`pinned`, `archived`) immediately.
- Added parser fallback for realtime chat events to support payloads with either `chat_id` or `id`.
### Step 99 - Saved chat API parity
- Added Android support for `GET /api/v1/chats/saved`.
- Wired chats overflow `Saved` action to real backend request (instead of local title heuristic).
- Saved chat is now upserted into local Room cache and opened via normal navigation flow.
### Step 100 - Android image compression before upload
- Added pre-upload image compression in Android media pipeline (`NetworkMediaRepository`).
- For non-GIF images:
- decode + resize with max side `1920`,
- re-encode as `image/jpeg` with quality `82`,
- keep original bytes if compression does not reduce payload size.
- Upload request and attachment metadata now use actual prepared payload (`fileName`, `fileType`, `fileSize`), matching web behavior.
### Step 101 - Chat title/profile API parity
- Added Android API integration for:
- `PATCH /api/v1/chats/{chat_id}/title`
- `PATCH /api/v1/chats/{chat_id}/profile`
- Extended `ChatRepository`/`NetworkChatRepository` with `updateChatTitle(...)` and `updateChatProfile(...)`.
- Wired these actions into the existing Chat Management panel:
- edit selected chat title,
- edit selected chat profile fields (title/description).
### Step 102 - Global search + message thread parity
- Added Android data-layer integration for unified backend global search:
- `GET /api/v1/search`
- new `SearchRepository` + `SearchApiService` returning `users/chats/messages`.
- Switched chats fullscreen search flow to use unified backend search instead of composed per-domain calls.
- Extended message data layer with:
- `GET /api/v1/messages/{message_id}/thread`
- `MessageRepository.getMessageThread(...)` for thread/replies usage in upcoming UI steps.
### Step 103 - Contacts API parity + real Contacts screen
- Added Android integration for contacts endpoints:
- `GET /api/v1/users/contacts`
- `POST /api/v1/users/{user_id}/contacts`
- `POST /api/v1/users/contacts/by-email`
- `DELETE /api/v1/users/{user_id}/contacts`
- Extended `AccountRepository` + `NetworkAccountRepository` with contacts methods.
- Replaced placeholder Contacts screen with real stateful flow (`ContactsViewModel`):
- load contacts from backend,
- user search + add contact,
- add contact by email,
- remove contact,
- loading/refresh/error/info states.
### Step 104 - Push token sync (Android + backend)
- Added backend push token lifecycle API and storage:
- `POST /api/v1/notifications/push-token`
- `DELETE /api/v1/notifications/push-token`
- new table `push_device_tokens` (+ Alembic migration `0027_push_device_tokens`).
- Added Android push token sync manager:
- registers FCM token on app start and after auth refresh/login,
- updates backend token on `FirebaseMessagingService.onNewToken`,
- unregisters token on logout.
- Added backend FCM delivery in Celery notification tasks:
- sends to registered user device tokens,
- auto-removes invalid/unregistered tokens,
- safe fallback logs when Firebase is not configured.
### Step 105 - Web Firebase push registration
- Added web-side Firebase Messaging bootstrap (env-driven, no hardcoded secrets):
- fetch web push token and register in backend via `/notifications/push-token`,
- 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 { plugins {
id("com.android.application") id("com.android.application")
id("org.jetbrains.kotlin.android") id("org.jetbrains.kotlin.android")
@@ -9,6 +11,15 @@ plugins {
id("com.google.firebase.crashlytics") 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 { android {
namespace = "ru.daemonlord.messenger" namespace = "ru.daemonlord.messenger"
compileSdk = 35 compileSdk = 35
@@ -21,6 +32,12 @@ android {
versionName = "0.1.0" versionName = "0.1.0"
buildConfigField("String", "API_BASE_URL", "\"https://chat.daemonlord.ru/\"") buildConfigField("String", "API_BASE_URL", "\"https://chat.daemonlord.ru/\"")
buildConfigField("String", "API_VERSION_HEADER", "\"2026-03\"") buildConfigField("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_ACCOUNT_MANAGEMENT", "true")
buildConfigField("boolean", "FEATURE_TWO_FACTOR", "true") buildConfigField("boolean", "FEATURE_TWO_FACTOR", "true")
buildConfigField("boolean", "FEATURE_MEDIA_GALLERY", "true") buildConfigField("boolean", "FEATURE_MEDIA_GALLERY", "true")
@@ -71,6 +88,7 @@ dependencies {
implementation(platform("com.google.firebase:firebase-bom:34.10.0")) implementation(platform("com.google.firebase:firebase-bom:34.10.0"))
implementation("androidx.core:core-ktx:1.15.0") implementation("androidx.core:core-ktx:1.15.0")
implementation("androidx.appcompat:appcompat:1.7.0")
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.8.7") implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.8.7")
implementation("androidx.lifecycle:lifecycle-runtime-compose:2.8.7") implementation("androidx.lifecycle:lifecycle-runtime-compose:2.8.7")
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.8.7") implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.8.7")
@@ -80,11 +98,20 @@ dependencies {
implementation("androidx.compose.ui:ui:1.7.6") implementation("androidx.compose.ui:ui:1.7.6")
implementation("androidx.compose.ui:ui-tooling-preview:1.7.6") implementation("androidx.compose.ui:ui-tooling-preview:1.7.6")
implementation("androidx.compose.material3:material3:1.3.1") implementation("androidx.compose.material3:material3:1.3.1")
implementation("androidx.compose.material:material-icons-extended:1.7.6")
implementation("io.coil-kt:coil:2.7.0") implementation("io.coil-kt:coil:2.7.0")
implementation("io.coil-kt:coil-compose: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-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:1.4.1")
implementation("androidx.media3:media3-datasource-okhttp: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-coroutines-android:1.9.0")
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3") implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3")
@@ -125,3 +152,37 @@ dependencies {
kapt { kapt {
correctErrorTypes = true 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

@@ -3,16 +3,19 @@
<uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" /> <uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.CAMERA" />
<application <application
android:name=".MessengerApplication" android:name=".MessengerApplication"
android:allowBackup="true" android:allowBackup="true"
android:enableOnBackInvokedCallback="true"
android:icon="@android:drawable/sym_def_app_icon" android:icon="@android:drawable/sym_def_app_icon"
android:label="@string/app_name" android:label="@string/app_name"
android:roundIcon="@android:drawable/sym_def_app_icon" android:roundIcon="@android:drawable/sym_def_app_icon"
android:supportsRtl="true" android:supportsRtl="true"
android:usesCleartextTraffic="true" android:usesCleartextTraffic="true"
android:theme="@android:style/Theme.Material.Light.NoActionBar"> android:theme="@style/Theme.Messenger">
<activity <activity
android:name=".MainActivity" android:name=".MainActivity"
android:exported="true"> android:exported="true">
@@ -45,6 +48,15 @@
<action android:name="com.google.firebase.MESSAGING_EVENT" /> <action android:name="com.google.firebase.MESSAGING_EVENT" />
</intent-filter> </intent-filter>
</service> </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> </application>
</manifest> </manifest>

View File

@@ -2,10 +2,8 @@ package ru.daemonlord.messenger
import android.content.Intent import android.content.Intent
import android.os.Bundle import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.enableEdgeToEdge import androidx.activity.enableEdgeToEdge
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
@@ -13,12 +11,31 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.foundation.layout.fillMaxSize 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 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.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.navigation.MessengerNavHost
import ru.daemonlord.messenger.ui.theme.MessengerTheme
import javax.inject.Inject
@AndroidEntryPoint @AndroidEntryPoint
class MainActivity : ComponentActivity() { 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 pendingInviteToken by mutableStateOf<String?>(null)
private var pendingVerifyEmailToken by mutableStateOf<String?>(null) private var pendingVerifyEmailToken by mutableStateOf<String?>(null)
private var pendingResetPasswordToken by mutableStateOf<String?>(null) private var pendingResetPasswordToken by mutableStateOf<String?>(null)
@@ -27,15 +44,35 @@ class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) 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() pendingInviteToken = intent.extractInviteToken()
pendingVerifyEmailToken = intent.extractVerifyEmailToken() pendingVerifyEmailToken = intent.extractVerifyEmailToken()
pendingResetPasswordToken = intent.extractResetPasswordToken() pendingResetPasswordToken = intent.extractResetPasswordToken()
val notificationPayload = intent.extractNotificationOpenPayload() val notificationPayload = intent.extractNotificationOpenPayload()
pendingNotificationChatId = notificationPayload?.first pendingNotificationChatId = notificationPayload?.first
pendingNotificationMessageId = notificationPayload?.second pendingNotificationMessageId = notificationPayload?.second
notificationPayload?.first?.let(notificationDispatcher::clearChatNotifications)
enableEdgeToEdge() enableEdgeToEdge()
setContent { setContent {
MaterialTheme { MessengerTheme {
Surface(modifier = Modifier.fillMaxSize()) { Surface(modifier = Modifier.fillMaxSize()) {
AppRoot( AppRoot(
inviteToken = pendingInviteToken, inviteToken = pendingInviteToken,
@@ -66,6 +103,7 @@ class MainActivity : ComponentActivity() {
if (notificationPayload != null) { if (notificationPayload != null) {
pendingNotificationChatId = notificationPayload.first pendingNotificationChatId = notificationPayload.first
pendingNotificationMessageId = notificationPayload.second pendingNotificationMessageId = notificationPayload.second
notificationDispatcher.clearChatNotifications(notificationPayload.first)
} }
} }
} }

View File

@@ -1,18 +1,27 @@
package ru.daemonlord.messenger package ru.daemonlord.messenger
import android.app.Application import android.app.Application
import android.os.Build
import coil.ImageLoader import coil.ImageLoader
import coil.ImageLoaderFactory import coil.ImageLoaderFactory
import coil.decode.GifDecoder
import coil.decode.ImageDecoderDecoder
import coil.disk.DiskCache import coil.disk.DiskCache
import coil.memory.MemoryCache import coil.memory.MemoryCache
import com.google.firebase.crashlytics.FirebaseCrashlytics import com.google.firebase.crashlytics.FirebaseCrashlytics
import dagger.hilt.android.HiltAndroidApp import dagger.hilt.android.HiltAndroidApp
import ru.daemonlord.messenger.core.notifications.NotificationChannels import ru.daemonlord.messenger.core.notifications.NotificationChannels
import ru.daemonlord.messenger.push.PushTokenSyncManager
import java.io.File import java.io.File
import timber.log.Timber import timber.log.Timber
import javax.inject.Inject
@HiltAndroidApp @HiltAndroidApp
class MessengerApplication : Application(), ImageLoaderFactory { class MessengerApplication : Application(), ImageLoaderFactory {
@Inject
lateinit var pushTokenSyncManager: PushTokenSyncManager
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()
if (BuildConfig.DEBUG) { if (BuildConfig.DEBUG) {
@@ -20,6 +29,7 @@ class MessengerApplication : Application(), ImageLoaderFactory {
} }
FirebaseCrashlytics.getInstance().setCrashlyticsCollectionEnabled(!BuildConfig.DEBUG) FirebaseCrashlytics.getInstance().setCrashlyticsCollectionEnabled(!BuildConfig.DEBUG)
NotificationChannels.ensureCreated(this) NotificationChannels.ensureCreated(this)
pushTokenSyncManager.triggerBestEffortSync()
} }
override fun newImageLoader(): ImageLoader { override fun newImageLoader(): ImageLoader {
@@ -28,6 +38,13 @@ class MessengerApplication : Application(), ImageLoaderFactory {
diskCacheDir.mkdirs() diskCacheDir.mkdirs()
} }
return ImageLoader.Builder(this) return ImageLoader.Builder(this)
.components {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
add(ImageDecoderDecoder.Factory())
} else {
add(GifDecoder.Factory())
}
}
.memoryCache { .memoryCache {
MemoryCache.Builder(this) MemoryCache.Builder(this)
.maxSizePercent(0.25) .maxSizePercent(0.25)

View File

@@ -0,0 +1,20 @@
package ru.daemonlord.messenger.core.audio
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
object AppAudioFocusCoordinator {
private val _activeSourceId = MutableStateFlow<String?>(null)
val activeSourceId: StateFlow<String?> = _activeSourceId.asStateFlow()
fun request(sourceId: String) {
_activeSourceId.value = sourceId
}
fun release(sourceId: String) {
if (_activeSourceId.value == sourceId) {
_activeSourceId.value = null
}
}
}

View File

@@ -15,6 +15,8 @@ import kotlin.math.abs
class NotificationDispatcher @Inject constructor( class NotificationDispatcher @Inject constructor(
@ApplicationContext private val context: Context, @ApplicationContext private val context: Context,
) { ) {
private val chatStates = linkedMapOf<Long, ChatNotificationState>()
fun showChatMessage(payload: ChatNotificationPayload) { fun showChatMessage(payload: ChatNotificationPayload) {
NotificationChannels.ensureCreated(context) NotificationChannels.ensureCreated(context)
val channelId = if (payload.isMention) { val channelId = if (payload.isMention) {
@@ -22,34 +24,126 @@ class NotificationDispatcher @Inject constructor(
} else { } else {
NotificationChannels.CHANNEL_MESSAGES 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) val openIntent = Intent(context, MainActivity::class.java)
.putExtra(NotificationIntentExtras.EXTRA_CHAT_ID, payload.chatId) .putExtra(NotificationIntentExtras.EXTRA_CHAT_ID, payload.chatId)
.putExtra(NotificationIntentExtras.EXTRA_MESSAGE_ID, payload.messageId ?: -1L) .putExtra(NotificationIntentExtras.EXTRA_MESSAGE_ID, payload.messageId ?: -1L)
.addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP) .addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP)
val pendingIntent = PendingIntent.getActivity( val pendingIntent = PendingIntent.getActivity(
context, context,
notificationId(payload.chatId, payload.messageId), chatNotificationId(payload.chatId),
openIntent, openIntent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, 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) val notification = NotificationCompat.Builder(context, channelId)
.setSmallIcon(android.R.drawable.ic_dialog_info) .setSmallIcon(android.R.drawable.ic_dialog_info)
.setContentTitle(payload.title) .setContentTitle(state.title)
.setContentText(payload.body) .setContentText(contentText)
.setStyle(NotificationCompat.BigTextStyle().bigText(payload.body)) .setStyle(inboxStyle)
.setAutoCancel(true) .setAutoCancel(true)
.setContentIntent(pendingIntent) .setContentIntent(pendingIntent)
.setGroup("chat_${payload.chatId}") .setGroup(GROUP_KEY_CHATS)
.setOnlyAlertOnce(false)
.setNumber(state.unreadCount)
.setPriority( .setPriority(
if (payload.isMention) NotificationCompat.PRIORITY_HIGH else NotificationCompat.PRIORITY_DEFAULT if (payload.isMention) NotificationCompat.PRIORITY_HIGH else NotificationCompat.PRIORITY_DEFAULT
) )
.build() .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 { fun clearChatNotifications(chatId: Long) {
val raw = (chatId * 1_000_003L) + (messageId ?: 0L) val manager = NotificationManagerCompat.from(context)
return abs(raw.toInt()) 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() 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? { override suspend fun getTokens(): TokenBundle? {
return observeTokens().first() 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) { override suspend fun saveTokens(tokens: TokenBundle) {
dataStore.edit { preferences -> dataStore.edit { preferences ->
preferences[ACCESS_TOKEN_KEY] = tokens.accessToken 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() { override suspend fun clearTokens() {
dataStore.edit { preferences -> dataStore.edit { preferences ->
preferences.remove(ACCESS_TOKEN_KEY) preferences.remove(ACCESS_TOKEN_KEY)
@@ -40,6 +84,10 @@ class DataStoreTokenRepository @Inject constructor(
} }
} }
override suspend fun clearAllTokens() {
clearTokens()
}
private fun Preferences.toTokenBundleOrNull(): TokenBundle? { private fun Preferences.toTokenBundleOrNull(): TokenBundle? {
val access = this[ACCESS_TOKEN_KEY] val access = this[ACCESS_TOKEN_KEY]
val refresh = this[REFRESH_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 { private companion object {
val ACCESS_TOKEN_KEY = stringPreferencesKey("access_token") val ACCESS_TOKEN_KEY = stringPreferencesKey("access_token")
val REFRESH_TOKEN_KEY = stringPreferencesKey("refresh_token") val REFRESH_TOKEN_KEY = stringPreferencesKey("refresh_token")

View File

@@ -1,9 +1,12 @@
package ru.daemonlord.messenger.core.token package ru.daemonlord.messenger.core.token
import android.content.SharedPreferences import android.content.SharedPreferences
import android.util.Base64
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import org.json.JSONArray
import org.json.JSONObject
import ru.daemonlord.messenger.di.TokenPrefs import ru.daemonlord.messenger.di.TokenPrefs
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
@@ -13,48 +16,277 @@ class EncryptedPrefsTokenRepository @Inject constructor(
@TokenPrefs private val sharedPreferences: SharedPreferences, @TokenPrefs private val sharedPreferences: SharedPreferences,
) : TokenRepository { ) : 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 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 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) { override suspend fun saveTokens(tokens: TokenBundle) {
sharedPreferences.edit() val userId = tokens.accessToken.extractUserIdFromJwt()
.putString(ACCESS_TOKEN_KEY, tokens.accessToken) ?: activeUserIdFlow.value
.putString(REFRESH_TOKEN_KEY, tokens.refreshToken) ?: return
.putLong(SAVED_AT_KEY, tokens.savedAtMillis) val allTokens = readAllTokenEntries().toMutableMap()
.apply() allTokens[userId] = tokens
tokensFlow.value = 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() { 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() sharedPreferences.edit()
.remove(ACCESS_TOKEN_KEY) .remove(ACCESS_TOKEN_KEY)
.remove(REFRESH_TOKEN_KEY) .remove(REFRESH_TOKEN_KEY)
.remove(SAVED_AT_KEY) .remove(SAVED_AT_KEY)
.apply() .apply()
tokensFlow.value = null
} }
private fun readTokens(): TokenBundle? { private fun ensureAccountPlaceholder(userId: Long, lastActiveAt: Long) {
val access = sharedPreferences.getString(ACCESS_TOKEN_KEY, null) val accounts = readAccounts().associateBy { it.userId }.toMutableMap()
val refresh = sharedPreferences.getString(REFRESH_TOKEN_KEY, null) val existing = accounts[userId]
val savedAt = sharedPreferences.getLong(SAVED_AT_KEY, -1L) if (existing == null) {
if (access.isNullOrBlank() || refresh.isNullOrBlank() || savedAt <= 0L) { accounts[userId] = StoredAccount(
return null userId = userId,
} email = null,
return TokenBundle( name = "User #$userId",
accessToken = access, username = null,
refreshToken = refresh, avatarUrl = null,
savedAtMillis = savedAt, lastActiveAt = lastActiveAt,
) )
} else {
accounts[userId] = existing.copy(lastActiveAt = maxOf(existing.lastActiveAt, lastActiveAt))
}
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 { private companion object {
const val ACCESS_TOKEN_KEY = "access_token" const val ACCESS_TOKEN_KEY = "access_token"
const val REFRESH_TOKEN_KEY = "refresh_token" const val REFRESH_TOKEN_KEY = "refresh_token"
const val SAVED_AT_KEY = "tokens_saved_at" 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 { interface TokenRepository {
fun observeTokens(): Flow<TokenBundle?> fun observeTokens(): Flow<TokenBundle?>
fun observeAccounts(): Flow<List<StoredAccount>>
fun observeActiveUserId(): Flow<Long?>
suspend fun getTokens(): TokenBundle? suspend fun getTokens(): TokenBundle?
suspend fun getAccounts(): List<StoredAccount>
suspend fun getActiveUserId(): Long?
suspend fun saveTokens(tokens: TokenBundle) 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 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.LoginRequestDto
import ru.daemonlord.messenger.data.auth.dto.MessageResponseDto import ru.daemonlord.messenger.data.auth.dto.MessageResponseDto
import ru.daemonlord.messenger.data.auth.dto.RegisterRequestDto 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.RequestPasswordResetDto
import ru.daemonlord.messenger.data.auth.dto.ResetPasswordRequestDto import ru.daemonlord.messenger.data.auth.dto.ResetPasswordRequestDto
import ru.daemonlord.messenger.data.auth.dto.RefreshTokenRequestDto import ru.daemonlord.messenger.data.auth.dto.RefreshTokenRequestDto
@@ -51,6 +52,10 @@ interface AuthApiService {
@POST("/api/v1/auth/request-password-reset") @POST("/api/v1/auth/request-password-reset")
suspend fun requestPasswordReset(@Body request: RequestPasswordResetDto): MessageResponseDto 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") @Headers("No-Auth: true")
@POST("/api/v1/auth/reset-password") @POST("/api/v1/auth/reset-password")
suspend fun resetPassword(@Body request: ResetPasswordRequestDto): MessageResponseDto suspend fun resetPassword(@Body request: ResetPasswordRequestDto): MessageResponseDto

View File

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

View File

@@ -3,20 +3,25 @@ package ru.daemonlord.messenger.data.auth.repository
import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import ru.daemonlord.messenger.core.token.TokenBundle 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.core.token.TokenRepository
import ru.daemonlord.messenger.data.auth.api.AuthApiService import ru.daemonlord.messenger.data.auth.api.AuthApiService
import ru.daemonlord.messenger.data.auth.dto.AuthUserDto 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.AuthSessionDto
import ru.daemonlord.messenger.data.auth.dto.LoginRequestDto import ru.daemonlord.messenger.data.auth.dto.LoginRequestDto
import ru.daemonlord.messenger.data.auth.dto.RefreshTokenRequestDto 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.ApiErrorMode
import ru.daemonlord.messenger.data.common.toAppError import ru.daemonlord.messenger.data.common.toAppError
import ru.daemonlord.messenger.di.IoDispatcher 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.AuthUser
import ru.daemonlord.messenger.domain.auth.model.AuthSession import ru.daemonlord.messenger.domain.auth.model.AuthSession
import ru.daemonlord.messenger.domain.auth.repository.AuthRepository import ru.daemonlord.messenger.domain.auth.repository.AuthRepository
import ru.daemonlord.messenger.domain.common.AppError import ru.daemonlord.messenger.domain.common.AppError
import ru.daemonlord.messenger.domain.common.AppResult import ru.daemonlord.messenger.domain.common.AppResult
import ru.daemonlord.messenger.push.PushTokenSyncManager
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
@@ -24,15 +29,60 @@ import javax.inject.Singleton
class NetworkAuthRepository @Inject constructor( class NetworkAuthRepository @Inject constructor(
private val authApiService: AuthApiService, private val authApiService: AuthApiService,
private val tokenRepository: TokenRepository, private val tokenRepository: TokenRepository,
private val pushTokenSyncManager: PushTokenSyncManager,
@IoDispatcher private val ioDispatcher: CoroutineDispatcher, @IoDispatcher private val ioDispatcher: CoroutineDispatcher,
) : AuthRepository { ) : 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 { try {
val tokenResponse = authApiService.login( val tokenResponse = authApiService.login(
request = LoginRequestDto( request = LoginRequestDto(
email = email, email = email,
password = password, password = password,
otpCode = otpCode?.trim()?.ifBlank { null },
recoveryCode = recoveryCode?.trim()?.ifBlank { null },
) )
) )
tokenRepository.saveTokens( tokenRepository.saveTokens(
@@ -42,7 +92,14 @@ class NetworkAuthRepository @Inject constructor(
savedAtMillis = System.currentTimeMillis(), savedAtMillis = System.currentTimeMillis(),
) )
) )
getMe() pushTokenSyncManager.triggerBestEffortSync()
when (val meResult = getMe()) {
is AppResult.Success -> {
tokenRepository.upsertAccount(meResult.data.toStoredAccount())
meResult
}
is AppResult.Error -> meResult
}
} catch (error: Throwable) { } catch (error: Throwable) {
AppResult.Error(error.toAppError(mode = ApiErrorMode.LOGIN)) AppResult.Error(error.toAppError(mode = ApiErrorMode.LOGIN))
} }
@@ -62,6 +119,7 @@ class NetworkAuthRepository @Inject constructor(
savedAtMillis = System.currentTimeMillis(), savedAtMillis = System.currentTimeMillis(),
) )
) )
pushTokenSyncManager.triggerBestEffortSync()
AppResult.Success(Unit) AppResult.Success(Unit)
} catch (error: Throwable) { } catch (error: Throwable) {
tokenRepository.clearTokens() tokenRepository.clearTokens()
@@ -72,6 +130,7 @@ class NetworkAuthRepository @Inject constructor(
override suspend fun getMe(): AppResult<AuthUser> = withContext(ioDispatcher) { override suspend fun getMe(): AppResult<AuthUser> = withContext(ioDispatcher) {
try { try {
val user = authApiService.me().toDomain() val user = authApiService.me().toDomain()
tokenRepository.upsertAccount(user.toStoredAccount())
AppResult.Success(user) AppResult.Success(user)
} catch (error: Throwable) { } catch (error: Throwable) {
AppResult.Error(error.toAppError()) AppResult.Error(error.toAppError())
@@ -88,7 +147,10 @@ class NetworkAuthRepository @Inject constructor(
} }
when (val meResult = getMe()) { when (val meResult = getMe()) {
is AppResult.Success -> meResult is AppResult.Success -> {
pushTokenSyncManager.triggerBestEffortSync()
meResult
}
is AppResult.Error -> { is AppResult.Error -> {
if (meResult.reason is AppError.Unauthorized) { if (meResult.reason is AppError.Unauthorized) {
tokenRepository.clearTokens() tokenRepository.clearTokens()
@@ -125,6 +187,7 @@ class NetworkAuthRepository @Inject constructor(
} }
override suspend fun logout() { override suspend fun logout() {
pushTokenSyncManager.unregisterCurrentTokenOnLogout()
tokenRepository.clearTokens() tokenRepository.clearTokens()
} }
@@ -145,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 { private fun AuthSessionDto.toDomain(): AuthSession {
return AuthSession( return AuthSession(
jti = jti, jti = jti,
@@ -156,4 +230,13 @@ class NetworkAuthRepository @Inject constructor(
) )
} }
private fun CheckEmailStatusDto.toDomain(): AuthEmailStatus {
return AuthEmailStatus(
email = email,
registered = registered,
emailVerified = emailVerified,
twofaEnabled = twofaEnabled,
)
}
} }

View File

@@ -1,13 +1,26 @@
package ru.daemonlord.messenger.data.chat.api package ru.daemonlord.messenger.data.chat.api
import retrofit2.http.Body import retrofit2.http.Body
import retrofit2.http.DELETE
import retrofit2.http.GET import retrofit2.http.GET
import retrofit2.http.PATCH
import retrofit2.http.Path import retrofit2.http.Path
import retrofit2.http.POST import retrofit2.http.POST
import retrofit2.http.PUT
import retrofit2.http.Query import retrofit2.http.Query
import ru.daemonlord.messenger.data.chat.dto.ChatBanDto
import ru.daemonlord.messenger.data.chat.dto.ChatCreateRequestDto
import ru.daemonlord.messenger.data.chat.dto.ChatInviteLinkDto import ru.daemonlord.messenger.data.chat.dto.ChatInviteLinkDto
import ru.daemonlord.messenger.data.chat.dto.ChatJoinByInviteRequestDto import ru.daemonlord.messenger.data.chat.dto.ChatJoinByInviteRequestDto
import ru.daemonlord.messenger.data.chat.dto.ChatMemberAddRequestDto
import ru.daemonlord.messenger.data.chat.dto.ChatMemberDto
import ru.daemonlord.messenger.data.chat.dto.ChatMemberRoleUpdateRequestDto
import ru.daemonlord.messenger.data.chat.dto.ChatNotificationSettingsDto
import ru.daemonlord.messenger.data.chat.dto.ChatNotificationSettingsUpdateDto
import ru.daemonlord.messenger.data.chat.dto.ChatProfileUpdateRequestDto
import ru.daemonlord.messenger.data.chat.dto.ChatReadDto import ru.daemonlord.messenger.data.chat.dto.ChatReadDto
import ru.daemonlord.messenger.data.chat.dto.ChatTitleUpdateRequestDto
import ru.daemonlord.messenger.data.chat.dto.DiscoverChatDto
interface ChatApiService { interface ChatApiService {
@GET("/api/v1/chats") @GET("/api/v1/chats")
@@ -20,6 +33,9 @@ interface ChatApiService {
@Path("chat_id") chatId: Long, @Path("chat_id") chatId: Long,
): ChatReadDto ): ChatReadDto
@GET("/api/v1/chats/saved")
suspend fun getSavedChat(): ChatReadDto
@POST("/api/v1/chats/{chat_id}/invite-link") @POST("/api/v1/chats/{chat_id}/invite-link")
suspend fun createInviteLink( suspend fun createInviteLink(
@Path("chat_id") chatId: Long, @Path("chat_id") chatId: Long,
@@ -29,4 +45,119 @@ interface ChatApiService {
suspend fun joinByInvite( suspend fun joinByInvite(
@Body request: ChatJoinByInviteRequestDto, @Body request: ChatJoinByInviteRequestDto,
): ChatReadDto ): ChatReadDto
@POST("/api/v1/chats")
suspend fun createChat(
@Body request: ChatCreateRequestDto,
): ChatReadDto
@GET("/api/v1/chats/discover")
suspend fun discoverChats(
@Query("query") query: String? = null,
): List<DiscoverChatDto>
@POST("/api/v1/chats/{chat_id}/join")
suspend fun joinChat(
@Path("chat_id") chatId: Long,
): ChatReadDto
@POST("/api/v1/chats/{chat_id}/leave")
suspend fun leaveChat(
@Path("chat_id") chatId: Long,
)
@DELETE("/api/v1/chats/{chat_id}")
suspend fun deleteChat(
@Path("chat_id") chatId: Long,
@Query("for_all") forAll: Boolean = false,
)
@POST("/api/v1/chats/{chat_id}/clear")
suspend fun clearChat(
@Path("chat_id") chatId: Long,
)
@POST("/api/v1/chats/{chat_id}/archive")
suspend fun archiveChat(
@Path("chat_id") chatId: Long,
): ChatReadDto
@POST("/api/v1/chats/{chat_id}/unarchive")
suspend fun unarchiveChat(
@Path("chat_id") chatId: Long,
): ChatReadDto
@POST("/api/v1/chats/{chat_id}/pin-chat")
suspend fun pinChat(
@Path("chat_id") chatId: Long,
): ChatReadDto
@POST("/api/v1/chats/{chat_id}/unpin-chat")
suspend fun unpinChat(
@Path("chat_id") chatId: Long,
): ChatReadDto
@PATCH("/api/v1/chats/{chat_id}/title")
suspend fun updateChatTitle(
@Path("chat_id") chatId: Long,
@Body request: ChatTitleUpdateRequestDto,
): ChatReadDto
@PATCH("/api/v1/chats/{chat_id}/profile")
suspend fun updateChatProfile(
@Path("chat_id") chatId: Long,
@Body request: ChatProfileUpdateRequestDto,
): ChatReadDto
@GET("/api/v1/chats/{chat_id}/notifications")
suspend fun getChatNotifications(
@Path("chat_id") chatId: Long,
): ChatNotificationSettingsDto
@PUT("/api/v1/chats/{chat_id}/notifications")
suspend fun updateChatNotifications(
@Path("chat_id") chatId: Long,
@Body request: ChatNotificationSettingsUpdateDto,
): ChatNotificationSettingsDto
@GET("/api/v1/chats/{chat_id}/members")
suspend fun listMembers(
@Path("chat_id") chatId: Long,
): List<ChatMemberDto>
@GET("/api/v1/chats/{chat_id}/bans")
suspend fun listBans(
@Path("chat_id") chatId: Long,
): List<ChatBanDto>
@POST("/api/v1/chats/{chat_id}/members")
suspend fun addMember(
@Path("chat_id") chatId: Long,
@Body request: ChatMemberAddRequestDto,
): ChatMemberDto
@PATCH("/api/v1/chats/{chat_id}/members/{user_id}/role")
suspend fun updateMemberRole(
@Path("chat_id") chatId: Long,
@Path("user_id") userId: Long,
@Body request: ChatMemberRoleUpdateRequestDto,
): ChatMemberDto
@DELETE("/api/v1/chats/{chat_id}/members/{user_id}")
suspend fun removeMember(
@Path("chat_id") chatId: Long,
@Path("user_id") userId: Long,
)
@POST("/api/v1/chats/{chat_id}/bans/{user_id}")
suspend fun banMember(
@Path("chat_id") chatId: Long,
@Path("user_id") userId: Long,
)
@DELETE("/api/v1/chats/{chat_id}/bans/{user_id}")
suspend fun unbanMember(
@Path("chat_id") chatId: Long,
@Path("user_id") userId: Long,
)
} }

View File

@@ -61,3 +61,87 @@ data class ChatInviteLinkDto(
data class ChatJoinByInviteRequestDto( data class ChatJoinByInviteRequestDto(
val token: String, val token: String,
) )
@Serializable
data class ChatCreateRequestDto(
val type: String,
val title: String? = null,
@SerialName("is_public")
val isPublic: Boolean = false,
val handle: String? = null,
val description: String? = null,
@SerialName("member_ids")
val memberIds: List<Long> = emptyList(),
)
@Serializable
data class DiscoverChatDto(
val id: Long,
val type: String,
@SerialName("display_title")
val displayTitle: String,
val handle: String? = null,
@SerialName("avatar_url")
val avatarUrl: String? = null,
@SerialName("is_member")
val isMember: Boolean = false,
)
@Serializable
data class ChatMemberDto(
@SerialName("user_id")
val userId: Long,
val role: String,
val name: String? = null,
val username: String? = null,
@SerialName("avatar_url")
val avatarUrl: String? = null,
)
@Serializable
data class ChatBanDto(
@SerialName("user_id")
val userId: Long,
@SerialName("banned_at")
val bannedAt: String? = null,
val name: String? = null,
val username: String? = null,
)
@Serializable
data class ChatMemberRoleUpdateRequestDto(
val role: String,
)
@Serializable
data class ChatMemberAddRequestDto(
@SerialName("user_id")
val userId: Long,
)
@Serializable
data class ChatNotificationSettingsDto(
@SerialName("chat_id")
val chatId: Long,
@SerialName("user_id")
val userId: Long,
val muted: Boolean,
)
@Serializable
data class ChatNotificationSettingsUpdateDto(
val muted: Boolean,
)
@Serializable
data class ChatTitleUpdateRequestDto(
val title: String,
)
@Serializable
data class ChatProfileUpdateRequestDto(
val title: String? = null,
val description: String? = null,
@SerialName("avatar_url")
val avatarUrl: String? = null,
)

View File

@@ -87,6 +87,9 @@ interface ChatDao {
@Query("SELECT muted FROM chats WHERE id = :chatId LIMIT 1") @Query("SELECT muted FROM chats WHERE id = :chatId LIMIT 1")
suspend fun isChatMuted(chatId: Long): Boolean? suspend fun isChatMuted(chatId: Long): Boolean?
@Query("UPDATE chats SET muted = :muted WHERE id = :chatId")
suspend fun updateChatMuted(chatId: Long, muted: Boolean)
@Insert(onConflict = OnConflictStrategy.REPLACE) @Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun upsertChats(chats: List<ChatEntity>) suspend fun upsertChats(chats: List<ChatEntity>)
@@ -139,6 +142,16 @@ interface ChatDao {
) )
suspend fun incrementUnread(chatId: Long, incrementBy: Int = 1) suspend fun incrementUnread(chatId: Long, incrementBy: Int = 1)
@Query(
"""
UPDATE chats
SET unread_count = 0,
unread_mentions_count = 0
WHERE id = :chatId
"""
)
suspend fun markChatRead(chatId: Long)
@Transaction @Transaction
suspend fun clearAndReplaceChats( suspend fun clearAndReplaceChats(
archived: Boolean, archived: Boolean,

View File

@@ -0,0 +1,67 @@
package ru.daemonlord.messenger.data.chat.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.map
import ru.daemonlord.messenger.domain.chat.repository.ChatSearchRepository
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class DataStoreChatSearchRepository @Inject constructor(
private val dataStore: DataStore<Preferences>,
) : ChatSearchRepository {
override fun observeHistoryChatIds(): Flow<List<Long>> {
return dataStore.data.map { prefs -> decodeIds(prefs[HISTORY_IDS_KEY]) }
}
override fun observeRecentChatIds(): Flow<List<Long>> {
return dataStore.data.map { prefs -> decodeIds(prefs[RECENT_IDS_KEY]) }
}
override suspend fun addHistoryChat(chatId: Long) {
dataStore.edit { prefs ->
prefs[HISTORY_IDS_KEY] = encodeIds(prepend(chatId, decodeIds(prefs[HISTORY_IDS_KEY])))
}
}
override suspend fun addRecentChat(chatId: Long) {
dataStore.edit { prefs ->
prefs[RECENT_IDS_KEY] = encodeIds(prepend(chatId, decodeIds(prefs[RECENT_IDS_KEY])))
}
}
override suspend fun clearHistory() {
dataStore.edit { prefs ->
prefs.remove(HISTORY_IDS_KEY)
}
}
private fun prepend(chatId: Long, source: List<Long>): List<Long> {
return buildList {
add(chatId)
addAll(source.filterNot { it == chatId })
}.take(MAX_SIZE)
}
private fun decodeIds(raw: String?): List<Long> {
if (raw.isNullOrBlank()) return emptyList()
return raw.split(',')
.mapNotNull { it.trim().toLongOrNull() }
.distinct()
.take(MAX_SIZE)
}
private fun encodeIds(ids: List<Long>): String = ids.joinToString(",")
private companion object {
const val MAX_SIZE = 30
val HISTORY_IDS_KEY = stringPreferencesKey("chat_search_history_ids")
val RECENT_IDS_KEY = stringPreferencesKey("chat_search_recent_ids")
}
}

View File

@@ -9,7 +9,17 @@ import kotlinx.coroutines.withContext
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.channels.awaitClose import kotlinx.coroutines.channels.awaitClose
import ru.daemonlord.messenger.data.chat.api.ChatApiService import ru.daemonlord.messenger.data.chat.api.ChatApiService
import ru.daemonlord.messenger.data.chat.dto.ChatBanDto
import ru.daemonlord.messenger.data.chat.dto.ChatCreateRequestDto
import ru.daemonlord.messenger.data.chat.dto.ChatJoinByInviteRequestDto import ru.daemonlord.messenger.data.chat.dto.ChatJoinByInviteRequestDto
import ru.daemonlord.messenger.data.chat.dto.ChatMemberAddRequestDto
import ru.daemonlord.messenger.data.chat.dto.ChatMemberDto
import ru.daemonlord.messenger.data.chat.dto.ChatMemberRoleUpdateRequestDto
import ru.daemonlord.messenger.data.chat.dto.ChatNotificationSettingsDto
import ru.daemonlord.messenger.data.chat.dto.ChatNotificationSettingsUpdateDto
import ru.daemonlord.messenger.data.chat.dto.ChatProfileUpdateRequestDto
import ru.daemonlord.messenger.data.chat.dto.ChatTitleUpdateRequestDto
import ru.daemonlord.messenger.data.chat.dto.DiscoverChatDto
import ru.daemonlord.messenger.data.chat.local.dao.ChatDao import ru.daemonlord.messenger.data.chat.local.dao.ChatDao
import ru.daemonlord.messenger.data.chat.mapper.toChatEntity import ru.daemonlord.messenger.data.chat.mapper.toChatEntity
import ru.daemonlord.messenger.data.chat.mapper.toDomain import ru.daemonlord.messenger.data.chat.mapper.toDomain
@@ -17,7 +27,11 @@ import ru.daemonlord.messenger.data.chat.mapper.toUserShortEntityOrNull
import ru.daemonlord.messenger.data.common.toAppError import ru.daemonlord.messenger.data.common.toAppError
import ru.daemonlord.messenger.domain.chat.model.ChatInviteLink import ru.daemonlord.messenger.domain.chat.model.ChatInviteLink
import ru.daemonlord.messenger.di.IoDispatcher import ru.daemonlord.messenger.di.IoDispatcher
import ru.daemonlord.messenger.domain.chat.model.ChatBanItem
import ru.daemonlord.messenger.domain.chat.model.ChatItem import ru.daemonlord.messenger.domain.chat.model.ChatItem
import ru.daemonlord.messenger.domain.chat.model.ChatMemberItem
import ru.daemonlord.messenger.domain.chat.model.ChatNotificationSettings
import ru.daemonlord.messenger.domain.chat.model.DiscoverChatItem
import ru.daemonlord.messenger.domain.chat.repository.ChatRepository import ru.daemonlord.messenger.domain.chat.repository.ChatRepository
import ru.daemonlord.messenger.domain.common.AppResult import ru.daemonlord.messenger.domain.common.AppResult
import javax.inject.Inject import javax.inject.Inject
@@ -75,6 +89,18 @@ class NetworkChatRepository @Inject constructor(
} }
} }
override suspend fun getSavedChat(): AppResult<ChatItem> = withContext(ioDispatcher) {
try {
val chat = chatApiService.getSavedChat()
chatDao.upsertUsers(chat.toUserShortEntityOrNull()?.let(::listOf).orEmpty())
val entity = chat.toChatEntity()
chatDao.upsertChats(listOf(entity))
AppResult.Success(entity.toDomain())
} catch (error: Throwable) {
AppResult.Error(error.toAppError())
}
}
override suspend fun createInviteLink(chatId: Long): AppResult<ChatInviteLink> = withContext(ioDispatcher) { override suspend fun createInviteLink(chatId: Long): AppResult<ChatInviteLink> = withContext(ioDispatcher) {
try { try {
AppResult.Success(chatApiService.createInviteLink(chatId = chatId).toDomain()) AppResult.Success(chatApiService.createInviteLink(chatId = chatId).toDomain())
@@ -95,9 +121,304 @@ class NetworkChatRepository @Inject constructor(
} }
} }
override suspend fun createChat(
type: String,
title: String?,
isPublic: Boolean,
handle: String?,
description: String?,
memberIds: List<Long>,
): AppResult<ChatItem> = withContext(ioDispatcher) {
try {
val created = chatApiService.createChat(
request = ChatCreateRequestDto(
type = type,
title = title,
isPublic = isPublic,
handle = handle,
description = description,
memberIds = memberIds,
)
)
chatDao.upsertUsers(created.toUserShortEntityOrNull()?.let(::listOf).orEmpty())
val chatEntity = created.toChatEntity()
chatDao.upsertChats(listOf(chatEntity))
AppResult.Success(chatEntity.toDomain())
} catch (error: Throwable) {
AppResult.Error(error.toAppError())
}
}
override suspend fun discoverChats(query: String?): AppResult<List<DiscoverChatItem>> = withContext(ioDispatcher) {
try {
AppResult.Success(chatApiService.discoverChats(query = query).map { it.toDomain() })
} catch (error: Throwable) {
AppResult.Error(error.toAppError())
}
}
override suspend fun joinChat(chatId: Long): AppResult<ChatItem> = withContext(ioDispatcher) {
try {
val joined = chatApiService.joinChat(chatId = chatId)
chatDao.upsertUsers(joined.toUserShortEntityOrNull()?.let(::listOf).orEmpty())
val entity = joined.toChatEntity()
chatDao.upsertChats(listOf(entity))
AppResult.Success(entity.toDomain())
} catch (error: Throwable) {
AppResult.Error(error.toAppError())
}
}
override suspend fun leaveChat(chatId: Long): AppResult<Unit> = withContext(ioDispatcher) {
try {
chatApiService.leaveChat(chatId = chatId)
chatDao.deleteChat(chatId = chatId)
AppResult.Success(Unit)
} catch (error: Throwable) {
AppResult.Error(error.toAppError())
}
}
override suspend fun archiveChat(chatId: Long): AppResult<ChatItem> = withContext(ioDispatcher) {
try {
val updated = chatApiService.archiveChat(chatId = chatId)
chatDao.upsertUsers(updated.toUserShortEntityOrNull()?.let(::listOf).orEmpty())
val entity = updated.toChatEntity()
chatDao.upsertChats(listOf(entity))
AppResult.Success(entity.toDomain())
} catch (error: Throwable) {
AppResult.Error(error.toAppError())
}
}
override suspend fun unarchiveChat(chatId: Long): AppResult<ChatItem> = withContext(ioDispatcher) {
try {
val updated = chatApiService.unarchiveChat(chatId = chatId)
chatDao.upsertUsers(updated.toUserShortEntityOrNull()?.let(::listOf).orEmpty())
val entity = updated.toChatEntity()
chatDao.upsertChats(listOf(entity))
AppResult.Success(entity.toDomain())
} catch (error: Throwable) {
AppResult.Error(error.toAppError())
}
}
override suspend fun pinChat(chatId: Long): AppResult<ChatItem> = withContext(ioDispatcher) {
try {
val updated = chatApiService.pinChat(chatId = chatId)
chatDao.upsertUsers(updated.toUserShortEntityOrNull()?.let(::listOf).orEmpty())
val entity = updated.toChatEntity()
chatDao.upsertChats(listOf(entity))
AppResult.Success(entity.toDomain())
} catch (error: Throwable) {
AppResult.Error(error.toAppError())
}
}
override suspend fun unpinChat(chatId: Long): AppResult<ChatItem> = withContext(ioDispatcher) {
try {
val updated = chatApiService.unpinChat(chatId = chatId)
chatDao.upsertUsers(updated.toUserShortEntityOrNull()?.let(::listOf).orEmpty())
val entity = updated.toChatEntity()
chatDao.upsertChats(listOf(entity))
AppResult.Success(entity.toDomain())
} catch (error: Throwable) {
AppResult.Error(error.toAppError())
}
}
override suspend fun updateChatTitle(chatId: Long, title: String): AppResult<ChatItem> = withContext(ioDispatcher) {
try {
val updated = chatApiService.updateChatTitle(
chatId = chatId,
request = ChatTitleUpdateRequestDto(title = title),
)
chatDao.upsertUsers(updated.toUserShortEntityOrNull()?.let(::listOf).orEmpty())
val entity = updated.toChatEntity()
chatDao.upsertChats(listOf(entity))
AppResult.Success(entity.toDomain())
} catch (error: Throwable) {
AppResult.Error(error.toAppError())
}
}
override suspend fun updateChatProfile(
chatId: Long,
title: String?,
description: String?,
avatarUrl: String?,
): AppResult<ChatItem> = withContext(ioDispatcher) {
try {
val updated = chatApiService.updateChatProfile(
chatId = chatId,
request = ChatProfileUpdateRequestDto(
title = title,
description = description,
avatarUrl = avatarUrl,
),
)
chatDao.upsertUsers(updated.toUserShortEntityOrNull()?.let(::listOf).orEmpty())
val entity = updated.toChatEntity()
chatDao.upsertChats(listOf(entity))
AppResult.Success(entity.toDomain())
} catch (error: Throwable) {
AppResult.Error(error.toAppError())
}
}
override suspend fun clearChat(chatId: Long): AppResult<Unit> = withContext(ioDispatcher) {
try {
chatApiService.clearChat(chatId = chatId)
AppResult.Success(Unit)
} catch (error: Throwable) {
AppResult.Error(error.toAppError())
}
}
override suspend fun removeChat(chatId: Long, forAll: Boolean): AppResult<Unit> = withContext(ioDispatcher) {
try {
chatApiService.deleteChat(chatId = chatId, forAll = forAll)
chatDao.deleteChat(chatId = chatId)
AppResult.Success(Unit)
} catch (error: Throwable) {
AppResult.Error(error.toAppError())
}
}
override suspend fun getChatNotifications(chatId: Long): AppResult<ChatNotificationSettings> = withContext(ioDispatcher) {
try {
AppResult.Success(chatApiService.getChatNotifications(chatId = chatId).toDomain())
} catch (error: Throwable) {
AppResult.Error(error.toAppError())
}
}
override suspend fun updateChatNotifications(chatId: Long, muted: Boolean): AppResult<ChatNotificationSettings> = withContext(ioDispatcher) {
try {
val settings = chatApiService.updateChatNotifications(
chatId = chatId,
request = ChatNotificationSettingsUpdateDto(muted = muted),
).toDomain()
chatDao.updateChatMuted(chatId = chatId, muted = settings.muted)
AppResult.Success(settings)
} catch (error: Throwable) {
AppResult.Error(error.toAppError())
}
}
override suspend fun listMembers(chatId: Long): AppResult<List<ChatMemberItem>> = withContext(ioDispatcher) {
try {
AppResult.Success(chatApiService.listMembers(chatId = chatId).map { it.toDomain() })
} catch (error: Throwable) {
AppResult.Error(error.toAppError())
}
}
override suspend fun listBans(chatId: Long): AppResult<List<ChatBanItem>> = withContext(ioDispatcher) {
try {
AppResult.Success(chatApiService.listBans(chatId = chatId).map { it.toDomain() })
} catch (error: Throwable) {
AppResult.Error(error.toAppError())
}
}
override suspend fun addMember(chatId: Long, userId: Long): AppResult<ChatMemberItem> = withContext(ioDispatcher) {
try {
AppResult.Success(
chatApiService.addMember(
chatId = chatId,
request = ChatMemberAddRequestDto(userId = userId),
).toDomain()
)
} catch (error: Throwable) {
AppResult.Error(error.toAppError())
}
}
override suspend fun updateMemberRole(chatId: Long, userId: Long, role: String): AppResult<ChatMemberItem> = withContext(ioDispatcher) {
try {
AppResult.Success(
chatApiService.updateMemberRole(
chatId = chatId,
userId = userId,
request = ChatMemberRoleUpdateRequestDto(role = role),
).toDomain()
)
} catch (error: Throwable) {
AppResult.Error(error.toAppError())
}
}
override suspend fun removeMember(chatId: Long, userId: Long): AppResult<Unit> = withContext(ioDispatcher) {
try {
chatApiService.removeMember(chatId = chatId, userId = userId)
AppResult.Success(Unit)
} catch (error: Throwable) {
AppResult.Error(error.toAppError())
}
}
override suspend fun banMember(chatId: Long, userId: Long): AppResult<Unit> = withContext(ioDispatcher) {
try {
chatApiService.banMember(chatId = chatId, userId = userId)
AppResult.Success(Unit)
} catch (error: Throwable) {
AppResult.Error(error.toAppError())
}
}
override suspend fun unbanMember(chatId: Long, userId: Long): AppResult<Unit> = withContext(ioDispatcher) {
try {
chatApiService.unbanMember(chatId = chatId, userId = userId)
AppResult.Success(Unit)
} catch (error: Throwable) {
AppResult.Error(error.toAppError())
}
}
override suspend fun deleteChat(chatId: Long) { override suspend fun deleteChat(chatId: Long) {
withContext(ioDispatcher) { withContext(ioDispatcher) {
chatDao.deleteChat(chatId = chatId) chatDao.deleteChat(chatId = chatId)
} }
} }
private fun DiscoverChatDto.toDomain(): DiscoverChatItem {
return DiscoverChatItem(
id = id,
type = type,
displayTitle = displayTitle,
handle = handle,
avatarUrl = avatarUrl,
isMember = isMember,
)
}
private fun ChatMemberDto.toDomain(): ChatMemberItem {
val fallbackName = username?.takeIf { it.isNotBlank() }?.let { "@$it" } ?: "User #$userId"
return ChatMemberItem(
userId = userId,
role = role,
name = name?.takeIf { it.isNotBlank() } ?: fallbackName,
username = username,
avatarUrl = avatarUrl,
)
}
private fun ChatBanDto.toDomain(): ChatBanItem {
val fallbackName = username?.takeIf { it.isNotBlank() }?.let { "@$it" } ?: "User #$userId"
return ChatBanItem(
userId = userId,
name = name?.takeIf { it.isNotBlank() } ?: fallbackName,
username = username,
bannedAt = bannedAt,
)
}
private fun ChatNotificationSettingsDto.toDomain(): ChatNotificationSettings {
return ChatNotificationSettings(
chatId = chatId,
userId = userId,
muted = muted,
)
}
} }

View File

@@ -3,6 +3,7 @@ package ru.daemonlord.messenger.data.common
import retrofit2.HttpException import retrofit2.HttpException
import ru.daemonlord.messenger.domain.common.AppError import ru.daemonlord.messenger.domain.common.AppError
import java.io.IOException import java.io.IOException
import org.json.JSONObject
enum class ApiErrorMode { enum class ApiErrorMode {
DEFAULT, DEFAULT,
@@ -13,15 +14,24 @@ fun Throwable.toAppError(mode: ApiErrorMode = ApiErrorMode.DEFAULT): AppError {
return when (this) { return when (this) {
is IOException -> AppError.Network is IOException -> AppError.Network
is HttpException -> when (mode) { is HttpException -> when (mode) {
ApiErrorMode.LOGIN -> when (code()) { ApiErrorMode.LOGIN -> {
400, 401, 403 -> AppError.InvalidCredentials val detail = extractErrorDetail()
else -> AppError.Server(message = message()) 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) { ApiErrorMode.DEFAULT -> if (code() == 401 || code() == 403) {
AppError.Unauthorized AppError.Unauthorized
} else { } 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

@@ -1,5 +1,9 @@
package ru.daemonlord.messenger.data.media.repository 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.CoroutineDispatcher
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import okhttp3.MediaType.Companion.toMediaTypeOrNull import okhttp3.MediaType.Companion.toMediaTypeOrNull
@@ -14,7 +18,10 @@ import ru.daemonlord.messenger.di.IoDispatcher
import ru.daemonlord.messenger.di.RefreshClient import ru.daemonlord.messenger.di.RefreshClient
import ru.daemonlord.messenger.domain.common.AppError import ru.daemonlord.messenger.domain.common.AppError
import ru.daemonlord.messenger.domain.common.AppResult import ru.daemonlord.messenger.domain.common.AppResult
import ru.daemonlord.messenger.domain.media.model.UploadedAttachment
import ru.daemonlord.messenger.domain.media.repository.MediaRepository import ru.daemonlord.messenger.domain.media.repository.MediaRepository
import java.io.ByteArrayOutputStream
import kotlin.math.roundToInt
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
@@ -30,17 +37,22 @@ class NetworkMediaRepository @Inject constructor(
fileName: String, fileName: String,
mimeType: String, mimeType: String,
bytes: ByteArray, bytes: ByteArray,
): AppResult<Unit> = withContext(ioDispatcher) { ): AppResult<UploadedAttachment> = withContext(ioDispatcher) {
try { try {
val uploadPayload = prepareUploadPayload(
fileName = fileName,
mimeType = mimeType,
bytes = bytes,
)
val uploadInfo = mediaApiService.requestUploadUrl( val uploadInfo = mediaApiService.requestUploadUrl(
request = UploadUrlRequestDto( request = UploadUrlRequestDto(
fileName = fileName, fileName = uploadPayload.fileName,
fileType = mimeType, fileType = uploadPayload.mimeType,
fileSize = bytes.size.toLong(), fileSize = uploadPayload.bytes.size.toLong(),
) )
) )
val body = bytes.toRequestBody(mimeType.toMediaTypeOrNull()) val body = uploadPayload.bytes.toRequestBody(uploadPayload.mimeType.toMediaTypeOrNull())
val uploadRequestBuilder = Request.Builder() val uploadRequestBuilder = Request.Builder()
.url(uploadInfo.uploadUrl) .url(uploadInfo.uploadUrl)
.put(body) .put(body)
@@ -61,13 +73,120 @@ class NetworkMediaRepository @Inject constructor(
request = AttachmentCreateRequestDto( request = AttachmentCreateRequestDto(
messageId = messageId, messageId = messageId,
fileUrl = uploadInfo.fileUrl, fileUrl = uploadInfo.fileUrl,
fileType = mimeType, fileType = uploadPayload.mimeType,
fileSize = bytes.size.toLong(), fileSize = uploadPayload.bytes.size.toLong(),
)
)
AppResult.Success(
UploadedAttachment(
fileUrl = uploadInfo.fileUrl,
fileType = uploadPayload.mimeType,
fileSize = uploadPayload.bytes.size.toLong(),
) )
) )
AppResult.Success(Unit)
} catch (error: Throwable) { } catch (error: Throwable) {
AppResult.Error(error.toAppError()) AppResult.Error(error.toAppError())
} }
} }
private fun prepareUploadPayload(
fileName: String,
mimeType: String,
bytes: ByteArray,
): UploadPayload {
if (!mimeType.startsWith("image/", ignoreCase = true)) {
return UploadPayload(fileName = fileName, mimeType = mimeType, bytes = bytes)
}
if (mimeType.equals("image/gif", ignoreCase = true)) {
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)
val maxSide = 1920
val width = source.width
val height = source.height
val scale = (maxSide.toFloat() / maxOf(width, height).toFloat()).coerceAtMost(1f)
val targetWidth = (width * scale).roundToInt().coerceAtLeast(1)
val targetHeight = (height * scale).roundToInt().coerceAtLeast(1)
val scaled = if (targetWidth != width || targetHeight != height) {
Bitmap.createScaledBitmap(source, targetWidth, targetHeight, true)
} else {
source
}
val output = ByteArrayOutputStream()
val compressedOk = runCatching {
scaled.compress(Bitmap.CompressFormat.JPEG, 82, output)
}.getOrDefault(false)
if (scaled !== source) {
scaled.recycle()
}
source.recycle()
if (!compressedOk) {
return UploadPayload(fileName = fileName, mimeType = mimeType, bytes = bytes)
}
val compressedBytes = output.toByteArray()
if (compressedBytes.size >= bytes.size) {
return UploadPayload(fileName = fileName, mimeType = mimeType, bytes = bytes)
}
val baseName = fileName.substringBeforeLast('.').ifBlank { "image" }
return UploadPayload(
fileName = "$baseName-web.jpg",
mimeType = "image/jpeg",
bytes = compressedBytes,
)
}
private data class UploadPayload(
val fileName: String,
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

@@ -24,6 +24,18 @@ interface MessageApiService {
@Query("before_id") beforeId: Long? = null, @Query("before_id") beforeId: Long? = null,
): List<MessageReadDto> ): List<MessageReadDto>
@GET("/api/v1/messages/search")
suspend fun searchMessages(
@Query("query") query: String,
@Query("chat_id") chatId: Long? = null,
): List<MessageReadDto>
@GET("/api/v1/messages/{message_id}/thread")
suspend fun getMessageThread(
@Path("message_id") messageId: Long,
@Query("limit") limit: Int = 100,
): List<MessageReadDto>
@POST("/api/v1/messages") @POST("/api/v1/messages")
suspend fun sendMessage( suspend fun sendMessage(
@Body request: MessageCreateRequestDto, @Body request: MessageCreateRequestDto,

View File

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

View File

@@ -10,11 +10,13 @@ import kotlinx.coroutines.withContext
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import ru.daemonlord.messenger.data.chat.local.dao.ChatDao import ru.daemonlord.messenger.data.chat.local.dao.ChatDao
import ru.daemonlord.messenger.data.message.api.MessageApiService import ru.daemonlord.messenger.data.message.api.MessageApiService
import ru.daemonlord.messenger.data.message.dto.MessageReadDto
import ru.daemonlord.messenger.core.token.TokenRepository import ru.daemonlord.messenger.core.token.TokenRepository
import ru.daemonlord.messenger.data.common.toAppError import ru.daemonlord.messenger.data.common.toAppError
import ru.daemonlord.messenger.data.message.local.dao.MessageDao 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.dao.PendingMessageActionDao
import ru.daemonlord.messenger.data.message.local.entity.MessageEntity 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.local.entity.PendingMessageActionEntity
import ru.daemonlord.messenger.data.message.mapper.toDomain import ru.daemonlord.messenger.data.message.mapper.toDomain
import ru.daemonlord.messenger.data.message.mapper.toEntity import ru.daemonlord.messenger.data.message.mapper.toEntity
@@ -71,6 +73,27 @@ class NetworkMessageRepository @Inject constructor(
} }
} }
override suspend fun searchMessages(query: String, chatId: Long?): AppResult<List<MessageItem>> = withContext(ioDispatcher) {
val normalized = query.trim()
if (normalized.isBlank()) return@withContext AppResult.Success(emptyList())
try {
val remote = messageApiService.searchMessages(query = normalized, chatId = chatId)
val mapped = remote.map { dto -> dto.toDomain(currentUserId = currentUserId) }
AppResult.Success(mapped)
} catch (error: Throwable) {
AppResult.Error(error.toAppError())
}
}
override suspend fun getMessageThread(messageId: Long, limit: Int): AppResult<List<MessageItem>> = withContext(ioDispatcher) {
try {
val remote = messageApiService.getMessageThread(messageId = messageId, limit = limit)
AppResult.Success(remote.map { dto -> dto.toDomain(currentUserId = currentUserId) })
} catch (error: Throwable) {
AppResult.Error(error.toAppError())
}
}
override suspend fun syncRecentMessages(chatId: Long): AppResult<Unit> = withContext(ioDispatcher) { override suspend fun syncRecentMessages(chatId: Long): AppResult<Unit> = withContext(ioDispatcher) {
flushPendingActions(chatId = chatId) flushPendingActions(chatId = chatId)
try { try {
@@ -290,7 +313,7 @@ class NetworkMessageRepository @Inject constructor(
caption: String?, caption: String?,
replyToMessageId: Long?, replyToMessageId: Long?,
): AppResult<Unit> = withContext(ioDispatcher) { ): AppResult<Unit> = withContext(ioDispatcher) {
val messageType = mapMimeToMessageType(mimeType) val messageType = mapMimeToMessageType(mimeType = mimeType, fileName = fileName)
val tempId = -System.currentTimeMillis() val tempId = -System.currentTimeMillis()
val tempMessage = MessageEntity( val tempMessage = MessageEntity(
id = tempId, id = tempId,
@@ -338,7 +361,27 @@ class NetworkMessageRepository @Inject constructor(
)) { )) {
is AppResult.Success -> { is AppResult.Success -> {
messageDao.deleteMessage(tempId) 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 -> { is AppResult.Error -> {
@@ -352,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) { override suspend fun markMessageDelivered(chatId: Long, messageId: Long): AppResult<Unit> = withContext(ioDispatcher) {
try { try {
messageApiService.updateMessageStatus( messageApiService.updateMessageStatus(
@@ -368,6 +475,8 @@ class NetworkMessageRepository @Inject constructor(
} }
override suspend fun markMessageRead(chatId: Long, messageId: Long): AppResult<Unit> = withContext(ioDispatcher) { override suspend fun markMessageRead(chatId: Long, messageId: Long): AppResult<Unit> = withContext(ioDispatcher) {
// User already viewed this chat/message in UI, so unread badge should drop immediately.
chatDao.markChatRead(chatId)
try { try {
messageApiService.updateMessageStatus( messageApiService.updateMessageStatus(
request = MessageStatusUpdateRequestDto( request = MessageStatusUpdateRequestDto(
@@ -462,11 +571,20 @@ class NetworkMessageRepository @Inject constructor(
} }
} }
private fun mapMimeToMessageType(mimeType: String): String { private fun mapMimeToMessageType(
mimeType: String,
fileName: String,
): String {
val normalizedMime = mimeType.lowercase()
val normalizedName = fileName.lowercase()
return when { return when {
mimeType.startsWith("image/") -> "image" normalizedName.startsWith("circle_") -> "circle_video"
mimeType.startsWith("video/") -> "video" normalizedMime == "image/gif" || normalizedName.endsWith(".gif") || normalizedName.startsWith("gif_") -> "image"
mimeType.startsWith("audio/") -> "audio" 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" else -> "file"
} }
} }
@@ -563,6 +681,29 @@ class NetworkMessageRepository @Inject constructor(
DELETE, DELETE,
} }
private fun MessageReadDto.toDomain(currentUserId: Long?): MessageItem {
return MessageItem(
id = id,
chatId = chatId,
senderId = senderId,
senderDisplayName = senderDisplayName,
senderUsername = senderUsername,
type = type,
text = text,
createdAt = createdAt,
updatedAt = updatedAt,
isOutgoing = currentUserId != null && currentUserId == senderId,
status = deliveryStatus,
replyToMessageId = replyToMessageId,
replyPreviewText = replyPreviewText,
replyPreviewSenderName = replyPreviewSenderName,
forwardedFromMessageId = forwardedFromMessageId,
forwardedFromDisplayName = forwardedFromDisplayName,
attachmentWaveform = attachmentWaveform,
attachments = emptyList(),
)
}
private fun String.extractUserIdFromJwt(): Long? { private fun String.extractUserIdFromJwt(): Long? {
val payloadPart = split('.').getOrNull(1) ?: return null val payloadPart = split('.').getOrNull(1) ?: return null
val normalized = payloadPart val normalized = payloadPart

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,15 @@
package ru.daemonlord.messenger.data.notifications.api
import retrofit2.http.Body
import retrofit2.http.HTTP
import retrofit2.http.POST
import ru.daemonlord.messenger.data.notifications.dto.PushTokenDeleteRequestDto
import ru.daemonlord.messenger.data.notifications.dto.PushTokenUpsertRequestDto
interface PushTokenApiService {
@POST("/api/v1/notifications/push-token")
suspend fun upsert(@Body request: PushTokenUpsertRequestDto)
@HTTP(method = "DELETE", path = "/api/v1/notifications/push-token", hasBody = true)
suspend fun delete(@Body request: PushTokenDeleteRequestDto)
}

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

@@ -0,0 +1,24 @@
package ru.daemonlord.messenger.data.notifications.dto
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class PushTokenUpsertRequestDto(
@SerialName("platform")
val platform: String,
@SerialName("token")
val token: String,
@SerialName("device_id")
val deviceId: String? = null,
@SerialName("app_version")
val appVersion: String? = null,
)
@Serializable
data class PushTokenDeleteRequestDto(
@SerialName("platform")
val platform: String,
@SerialName("token")
val token: String,
)

View File

@@ -67,12 +67,16 @@ class RealtimeEventParser @Inject constructor(
} }
"chat_updated" -> { "chat_updated" -> {
val chatId = payload["chat_id"].longOrNull() ?: return RealtimeEvent.Ignored val chatId = payload["chat_id"].longOrNull()
?: payload["id"].longOrNull()
?: return RealtimeEvent.Ignored
RealtimeEvent.ChatUpdated(chatId = chatId) RealtimeEvent.ChatUpdated(chatId = chatId)
} }
"chat_deleted" -> { "chat_deleted" -> {
val chatId = payload["chat_id"].longOrNull() ?: return RealtimeEvent.Ignored val chatId = payload["chat_id"].longOrNull()
?: payload["id"].longOrNull()
?: return RealtimeEvent.Ignored
RealtimeEvent.ChatDeleted(chatId = chatId) RealtimeEvent.ChatDeleted(chatId = chatId)
} }
@@ -98,7 +102,12 @@ class RealtimeEventParser @Inject constructor(
"message_read" -> { "message_read" -> {
val chatId = payload["chat_id"].longOrNull() ?: return RealtimeEvent.Ignored val chatId = payload["chat_id"].longOrNull() ?: return RealtimeEvent.Ignored
val messageId = payload["message_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" -> { "typing_start" -> {

View File

@@ -8,6 +8,8 @@ import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
@@ -19,6 +21,7 @@ import ru.daemonlord.messenger.BuildConfig
import ru.daemonlord.messenger.core.token.TokenRepository import ru.daemonlord.messenger.core.token.TokenRepository
import ru.daemonlord.messenger.di.RefreshClient import ru.daemonlord.messenger.di.RefreshClient
import ru.daemonlord.messenger.domain.realtime.RealtimeManager import ru.daemonlord.messenger.domain.realtime.RealtimeManager
import ru.daemonlord.messenger.domain.realtime.model.RealtimeConnectionState
import ru.daemonlord.messenger.domain.realtime.model.RealtimeEvent import ru.daemonlord.messenger.domain.realtime.model.RealtimeEvent
import java.util.concurrent.atomic.AtomicBoolean import java.util.concurrent.atomic.AtomicBoolean
import java.util.concurrent.atomic.AtomicLong import java.util.concurrent.atomic.AtomicLong
@@ -42,16 +45,20 @@ class WsRealtimeManager @Inject constructor(
private var heartbeatJob: Job? = null private var heartbeatJob: Job? = null
override val events: Flow<RealtimeEvent> = eventFlow.asSharedFlow() override val events: Flow<RealtimeEvent> = eventFlow.asSharedFlow()
private val _connectionState = MutableStateFlow(RealtimeConnectionState.Disconnected)
override val connectionState: StateFlow<RealtimeConnectionState> = _connectionState
override fun connect() { override fun connect() {
if (isConnected.get()) return if (isConnected.get()) return
manualDisconnect.set(false) manualDisconnect.set(false)
_connectionState.value = RealtimeConnectionState.Connecting
scope.launch { openSocket() } scope.launch { openSocket() }
} }
override fun disconnect() { override fun disconnect() {
manualDisconnect.set(true) manualDisconnect.set(true)
isConnected.set(false) isConnected.set(false)
_connectionState.value = RealtimeConnectionState.Disconnected
heartbeatJob?.cancel() heartbeatJob?.cancel()
heartbeatJob = null heartbeatJob = null
socket?.close(1000, "Client disconnect") socket?.close(1000, "Client disconnect")
@@ -59,7 +66,10 @@ class WsRealtimeManager @Inject constructor(
} }
private suspend fun openSocket() { private suspend fun openSocket() {
val accessToken = tokenRepository.getTokens()?.accessToken ?: return val accessToken = tokenRepository.getTokens()?.accessToken ?: run {
_connectionState.value = RealtimeConnectionState.Disconnected
return
}
val wsUrl = BuildConfig.API_BASE_URL val wsUrl = BuildConfig.API_BASE_URL
.replace("http://", "ws://") .replace("http://", "ws://")
.replace("https://", "wss://") .replace("https://", "wss://")
@@ -72,6 +82,7 @@ class WsRealtimeManager @Inject constructor(
private fun scheduleReconnect() { private fun scheduleReconnect() {
if (manualDisconnect.get()) return if (manualDisconnect.get()) return
_connectionState.value = RealtimeConnectionState.Reconnecting
scope.launch { scope.launch {
delay(reconnectDelayMs) delay(reconnectDelayMs)
reconnectDelayMs = (reconnectDelayMs * 2).coerceAtMost(MAX_RECONNECT_MS) reconnectDelayMs = (reconnectDelayMs * 2).coerceAtMost(MAX_RECONNECT_MS)
@@ -98,6 +109,7 @@ class WsRealtimeManager @Inject constructor(
private val listener = object : WebSocketListener() { private val listener = object : WebSocketListener() {
override fun onOpen(webSocket: WebSocket, response: Response) { override fun onOpen(webSocket: WebSocket, response: Response) {
isConnected.set(true) isConnected.set(true)
_connectionState.value = RealtimeConnectionState.Connected
reconnectDelayMs = INITIAL_RECONNECT_MS reconnectDelayMs = INITIAL_RECONNECT_MS
startHeartbeat(webSocket) startHeartbeat(webSocket)
} }
@@ -111,18 +123,27 @@ class WsRealtimeManager @Inject constructor(
override fun onClosing(webSocket: WebSocket, code: Int, reason: String) { override fun onClosing(webSocket: WebSocket, code: Int, reason: String) {
isConnected.set(false) isConnected.set(false)
if (!manualDisconnect.get()) {
_connectionState.value = RealtimeConnectionState.Reconnecting
}
heartbeatJob?.cancel() heartbeatJob?.cancel()
webSocket.close(code, reason) webSocket.close(code, reason)
} }
override fun onClosed(webSocket: WebSocket, code: Int, reason: String) { override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {
isConnected.set(false) isConnected.set(false)
if (!manualDisconnect.get()) {
_connectionState.value = RealtimeConnectionState.Reconnecting
}
heartbeatJob?.cancel() heartbeatJob?.cancel()
scheduleReconnect() scheduleReconnect()
} }
override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) { override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {
isConnected.set(false) isConnected.set(false)
if (!manualDisconnect.get()) {
_connectionState.value = RealtimeConnectionState.Reconnecting
}
heartbeatJob?.cancel() heartbeatJob?.cancel()
scheduleReconnect() scheduleReconnect()
} }

View File

@@ -0,0 +1,15 @@
package ru.daemonlord.messenger.data.search.api
import retrofit2.http.GET
import retrofit2.http.Query
import ru.daemonlord.messenger.data.search.dto.GlobalSearchResponseDto
interface SearchApiService {
@GET("/api/v1/search")
suspend fun globalSearch(
@Query("query") query: String,
@Query("users_limit") usersLimit: Int = 10,
@Query("chats_limit") chatsLimit: Int = 10,
@Query("messages_limit") messagesLimit: Int = 10,
): GlobalSearchResponseDto
}

View File

@@ -0,0 +1,13 @@
package ru.daemonlord.messenger.data.search.dto
import kotlinx.serialization.Serializable
import ru.daemonlord.messenger.data.chat.dto.DiscoverChatDto
import ru.daemonlord.messenger.data.message.dto.MessageReadDto
import ru.daemonlord.messenger.data.user.dto.UserSearchDto
@Serializable
data class GlobalSearchResponseDto(
val users: List<UserSearchDto> = emptyList(),
val chats: List<DiscoverChatDto> = emptyList(),
val messages: List<MessageReadDto> = emptyList(),
)

View File

@@ -0,0 +1,93 @@
package ru.daemonlord.messenger.data.search.repository
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.withContext
import ru.daemonlord.messenger.data.common.toAppError
import ru.daemonlord.messenger.data.search.api.SearchApiService
import ru.daemonlord.messenger.di.IoDispatcher
import ru.daemonlord.messenger.domain.account.model.UserSearchItem
import ru.daemonlord.messenger.domain.chat.model.DiscoverChatItem
import ru.daemonlord.messenger.domain.common.AppResult
import ru.daemonlord.messenger.domain.message.model.MessageItem
import ru.daemonlord.messenger.domain.search.model.GlobalSearchResult
import ru.daemonlord.messenger.domain.search.repository.SearchRepository
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class NetworkSearchRepository @Inject constructor(
private val searchApiService: SearchApiService,
@IoDispatcher private val ioDispatcher: CoroutineDispatcher,
) : SearchRepository {
override suspend fun globalSearch(
query: String,
usersLimit: Int,
chatsLimit: Int,
messagesLimit: Int,
): AppResult<GlobalSearchResult> = withContext(ioDispatcher) {
val normalized = query.trim()
if (normalized.isBlank()) return@withContext AppResult.Success(
GlobalSearchResult(
users = emptyList(),
chats = emptyList(),
messages = emptyList(),
)
)
try {
val response = searchApiService.globalSearch(
query = normalized,
usersLimit = usersLimit,
chatsLimit = chatsLimit,
messagesLimit = messagesLimit,
)
AppResult.Success(
GlobalSearchResult(
users = response.users.map { dto ->
UserSearchItem(
id = dto.id,
name = dto.name?.trim().takeUnless { it.isNullOrBlank() }
?: dto.username?.trim().takeUnless { it.isNullOrBlank() }
?: "User #${dto.id}",
username = dto.username,
avatarUrl = dto.avatarUrl,
)
},
chats = response.chats.map { dto ->
DiscoverChatItem(
id = dto.id,
type = dto.type,
displayTitle = dto.displayTitle,
handle = dto.handle,
avatarUrl = dto.avatarUrl,
isMember = dto.isMember,
)
},
messages = response.messages.map { dto ->
MessageItem(
id = dto.id,
chatId = dto.chatId,
senderId = dto.senderId,
senderDisplayName = dto.senderDisplayName,
type = dto.type,
text = dto.text,
createdAt = dto.createdAt,
updatedAt = dto.updatedAt,
isOutgoing = false,
status = dto.deliveryStatus,
replyToMessageId = dto.replyToMessageId,
replyPreviewText = dto.replyPreviewText,
replyPreviewSenderName = dto.replyPreviewSenderName,
forwardedFromMessageId = dto.forwardedFromMessageId,
forwardedFromDisplayName = dto.forwardedFromDisplayName,
attachmentWaveform = dto.attachmentWaveform,
attachments = emptyList(),
)
},
)
)
} catch (error: Throwable) {
AppResult.Error(error.toAppError())
}
}
}

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

@@ -6,7 +6,9 @@ import retrofit2.http.GET
import retrofit2.http.POST import retrofit2.http.POST
import retrofit2.http.PUT import retrofit2.http.PUT
import retrofit2.http.Path import retrofit2.http.Path
import retrofit2.http.Query
import ru.daemonlord.messenger.data.auth.dto.AuthUserDto import ru.daemonlord.messenger.data.auth.dto.AuthUserDto
import ru.daemonlord.messenger.data.user.dto.AddContactByEmailRequestDto
import ru.daemonlord.messenger.data.user.dto.UserProfileUpdateRequestDto import ru.daemonlord.messenger.data.user.dto.UserProfileUpdateRequestDto
import ru.daemonlord.messenger.data.user.dto.UserSearchDto import ru.daemonlord.messenger.data.user.dto.UserSearchDto
@@ -17,9 +19,27 @@ interface UserApiService {
@GET("/api/v1/users/blocked") @GET("/api/v1/users/blocked")
suspend fun listBlockedUsers(): List<UserSearchDto> suspend fun listBlockedUsers(): List<UserSearchDto>
@GET("/api/v1/users/search")
suspend fun searchUsers(
@Query("query") query: String,
@Query("limit") limit: Int = 20,
): List<UserSearchDto>
@POST("/api/v1/users/{user_id}/block") @POST("/api/v1/users/{user_id}/block")
suspend fun blockUser(@Path("user_id") userId: Long) suspend fun blockUser(@Path("user_id") userId: Long)
@DELETE("/api/v1/users/{user_id}/block") @DELETE("/api/v1/users/{user_id}/block")
suspend fun unblockUser(@Path("user_id") userId: Long) suspend fun unblockUser(@Path("user_id") userId: Long)
@GET("/api/v1/users/contacts")
suspend fun listContacts(): List<UserSearchDto>
@POST("/api/v1/users/{user_id}/contacts")
suspend fun addContact(@Path("user_id") userId: Long)
@POST("/api/v1/users/contacts/by-email")
suspend fun addContactByEmail(@Body request: AddContactByEmailRequestDto)
@DELETE("/api/v1/users/{user_id}/contacts")
suspend fun removeContact(@Path("user_id") userId: Long)
} }

View File

@@ -31,3 +31,8 @@ data class UserSearchDto(
@SerialName("avatar_url") @SerialName("avatar_url")
val avatarUrl: String? = null, val avatarUrl: String? = null,
) )
@Serializable
data class AddContactByEmailRequestDto(
val email: String,
)

View File

@@ -10,23 +10,32 @@ import ru.daemonlord.messenger.data.auth.api.AuthApiService
import ru.daemonlord.messenger.data.auth.dto.AuthSessionDto import ru.daemonlord.messenger.data.auth.dto.AuthSessionDto
import ru.daemonlord.messenger.data.auth.dto.AuthUserDto import ru.daemonlord.messenger.data.auth.dto.AuthUserDto
import ru.daemonlord.messenger.data.auth.dto.RequestPasswordResetDto 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.ResetPasswordRequestDto
import ru.daemonlord.messenger.data.auth.dto.TwoFactorCodeRequestDto import ru.daemonlord.messenger.data.auth.dto.TwoFactorCodeRequestDto
import ru.daemonlord.messenger.data.auth.dto.VerifyEmailRequestDto import ru.daemonlord.messenger.data.auth.dto.VerifyEmailRequestDto
import ru.daemonlord.messenger.data.common.toAppError import ru.daemonlord.messenger.data.common.toAppError
import ru.daemonlord.messenger.data.media.api.MediaApiService import ru.daemonlord.messenger.data.media.api.MediaApiService
import ru.daemonlord.messenger.data.media.dto.UploadUrlRequestDto 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.di.RefreshClient
import ru.daemonlord.messenger.data.user.api.UserApiService import ru.daemonlord.messenger.data.user.api.UserApiService
import ru.daemonlord.messenger.data.user.dto.AddContactByEmailRequestDto
import ru.daemonlord.messenger.data.user.dto.UserProfileUpdateRequestDto import ru.daemonlord.messenger.data.user.dto.UserProfileUpdateRequestDto
import ru.daemonlord.messenger.data.user.dto.UserSearchDto import ru.daemonlord.messenger.data.user.dto.UserSearchDto
import ru.daemonlord.messenger.di.IoDispatcher import ru.daemonlord.messenger.di.IoDispatcher
import ru.daemonlord.messenger.domain.account.model.UserSearchItem 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.account.repository.AccountRepository
import ru.daemonlord.messenger.domain.auth.model.AuthSession import ru.daemonlord.messenger.domain.auth.model.AuthSession
import ru.daemonlord.messenger.domain.auth.model.AuthUser import ru.daemonlord.messenger.domain.auth.model.AuthUser
import ru.daemonlord.messenger.domain.common.AppError import ru.daemonlord.messenger.domain.common.AppError
import ru.daemonlord.messenger.domain.common.AppResult 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.Inject
import javax.inject.Singleton import javax.inject.Singleton
@@ -35,6 +44,7 @@ class NetworkAccountRepository @Inject constructor(
private val authApiService: AuthApiService, private val authApiService: AuthApiService,
private val userApiService: UserApiService, private val userApiService: UserApiService,
private val mediaApiService: MediaApiService, private val mediaApiService: MediaApiService,
private val notificationApiService: NotificationApiService,
@RefreshClient private val uploadClient: OkHttpClient, @RefreshClient private val uploadClient: OkHttpClient,
@IoDispatcher private val ioDispatcher: CoroutineDispatcher, @IoDispatcher private val ioDispatcher: CoroutineDispatcher,
) : AccountRepository { ) : AccountRepository {
@@ -130,6 +140,55 @@ class NetworkAccountRepository @Inject constructor(
} }
} }
override suspend fun listContacts(): AppResult<List<UserSearchItem>> = withContext(ioDispatcher) {
try {
AppResult.Success(userApiService.listContacts().map { it.toDomain() })
} catch (error: Throwable) {
AppResult.Error(error.toAppError())
}
}
override suspend fun searchUsers(query: String, limit: Int): AppResult<List<UserSearchItem>> = withContext(ioDispatcher) {
val normalized = query.trim()
if (normalized.isBlank()) return@withContext AppResult.Success(emptyList())
try {
AppResult.Success(userApiService.searchUsers(query = normalized, limit = limit).map { it.toDomain() })
} catch (error: Throwable) {
AppResult.Error(error.toAppError())
}
}
override suspend fun addContact(userId: Long): AppResult<Unit> = withContext(ioDispatcher) {
try {
userApiService.addContact(userId = userId)
AppResult.Success(Unit)
} catch (error: Throwable) {
AppResult.Error(error.toAppError())
}
}
override suspend fun addContactByEmail(email: String): AppResult<Unit> = withContext(ioDispatcher) {
val normalized = email.trim()
if (normalized.isBlank()) return@withContext AppResult.Error(AppError.Server("Email is required"))
try {
userApiService.addContactByEmail(
request = AddContactByEmailRequestDto(email = normalized),
)
AppResult.Success(Unit)
} catch (error: Throwable) {
AppResult.Error(error.toAppError())
}
}
override suspend fun removeContact(userId: Long): AppResult<Unit> = withContext(ioDispatcher) {
try {
userApiService.removeContact(userId = userId)
AppResult.Success(Unit)
} catch (error: Throwable) {
AppResult.Error(error.toAppError())
}
}
override suspend fun blockUser(userId: Long): AppResult<Unit> = withContext(ioDispatcher) { override suspend fun blockUser(userId: Long): AppResult<Unit> = withContext(ioDispatcher) {
try { try {
userApiService.blockUser(userId) userApiService.blockUser(userId)
@@ -156,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) { override suspend fun revokeSession(jti: String): AppResult<Unit> = withContext(ioDispatcher) {
try { try {
authApiService.revokeSession(jti) authApiService.revokeSession(jti)
@@ -192,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) { override suspend fun resetPassword(token: String, password: String): AppResult<String> = withContext(ioDispatcher) {
try { try {
val result = authApiService.resetPassword( val result = authApiService.resetPassword(
@@ -286,4 +362,24 @@ class NetworkAccountRepository @Inject constructor(
avatarUrl = avatarUrl, 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,9 @@ import ru.daemonlord.messenger.data.auth.api.AuthApiService
import ru.daemonlord.messenger.data.chat.api.ChatApiService import ru.daemonlord.messenger.data.chat.api.ChatApiService
import ru.daemonlord.messenger.data.media.api.MediaApiService import ru.daemonlord.messenger.data.media.api.MediaApiService
import ru.daemonlord.messenger.data.message.api.MessageApiService import ru.daemonlord.messenger.data.message.api.MessageApiService
import ru.daemonlord.messenger.data.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 import ru.daemonlord.messenger.data.user.api.UserApiService
import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
@@ -147,4 +150,22 @@ object NetworkModule {
fun provideUserApiService(retrofit: Retrofit): UserApiService { fun provideUserApiService(retrofit: Retrofit): UserApiService {
return retrofit.create(UserApiService::class.java) return retrofit.create(UserApiService::class.java)
} }
@Provides
@Singleton
fun provideSearchApiService(retrofit: Retrofit): SearchApiService {
return retrofit.create(SearchApiService::class.java)
}
@Provides
@Singleton
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

@@ -7,17 +7,25 @@ import dagger.hilt.components.SingletonComponent
import ru.daemonlord.messenger.data.auth.repository.NetworkAuthRepository import ru.daemonlord.messenger.data.auth.repository.NetworkAuthRepository
import ru.daemonlord.messenger.data.auth.repository.DefaultSessionCleanupRepository import ru.daemonlord.messenger.data.auth.repository.DefaultSessionCleanupRepository
import ru.daemonlord.messenger.data.chat.repository.NetworkChatRepository import ru.daemonlord.messenger.data.chat.repository.NetworkChatRepository
import ru.daemonlord.messenger.data.chat.repository.DataStoreChatSearchRepository
import ru.daemonlord.messenger.data.media.repository.NetworkMediaRepository import ru.daemonlord.messenger.data.media.repository.NetworkMediaRepository
import ru.daemonlord.messenger.data.message.repository.NetworkMessageRepository import ru.daemonlord.messenger.data.message.repository.NetworkMessageRepository
import ru.daemonlord.messenger.data.notifications.repository.DataStoreNotificationSettingsRepository import ru.daemonlord.messenger.data.notifications.repository.DataStoreNotificationSettingsRepository
import ru.daemonlord.messenger.data.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.data.user.repository.NetworkAccountRepository
import ru.daemonlord.messenger.domain.account.repository.AccountRepository import ru.daemonlord.messenger.domain.account.repository.AccountRepository
import ru.daemonlord.messenger.domain.auth.repository.AuthRepository import ru.daemonlord.messenger.domain.auth.repository.AuthRepository
import ru.daemonlord.messenger.domain.auth.repository.SessionCleanupRepository import ru.daemonlord.messenger.domain.auth.repository.SessionCleanupRepository
import ru.daemonlord.messenger.domain.chat.repository.ChatRepository import ru.daemonlord.messenger.domain.chat.repository.ChatRepository
import ru.daemonlord.messenger.domain.chat.repository.ChatSearchRepository
import ru.daemonlord.messenger.domain.media.repository.MediaRepository import ru.daemonlord.messenger.domain.media.repository.MediaRepository
import ru.daemonlord.messenger.domain.message.repository.MessageRepository import ru.daemonlord.messenger.domain.message.repository.MessageRepository
import ru.daemonlord.messenger.domain.notifications.repository.NotificationSettingsRepository 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 import javax.inject.Singleton
@Module @Module
@@ -42,6 +50,12 @@ abstract class RepositoryModule {
repository: NetworkChatRepository, repository: NetworkChatRepository,
): ChatRepository ): ChatRepository
@Binds
@Singleton
abstract fun bindChatSearchRepository(
repository: DataStoreChatSearchRepository,
): ChatSearchRepository
@Binds @Binds
@Singleton @Singleton
abstract fun bindMessageRepository( abstract fun bindMessageRepository(
@@ -65,4 +79,22 @@ abstract class RepositoryModule {
abstract fun bindAccountRepository( abstract fun bindAccountRepository(
repository: NetworkAccountRepository, repository: NetworkAccountRepository,
): AccountRepository ): AccountRepository
@Binds
@Singleton
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 package ru.daemonlord.messenger.domain.account.repository
import ru.daemonlord.messenger.domain.account.model.UserSearchItem 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.AuthSession
import ru.daemonlord.messenger.domain.auth.model.AuthUser import ru.daemonlord.messenger.domain.auth.model.AuthUser
import ru.daemonlord.messenger.domain.common.AppResult import ru.daemonlord.messenger.domain.common.AppResult
@@ -25,12 +26,19 @@ interface AccountRepository {
groupInvites: String, groupInvites: String,
): AppResult<AuthUser> ): AppResult<AuthUser>
suspend fun listBlockedUsers(): AppResult<List<UserSearchItem>> suspend fun listBlockedUsers(): AppResult<List<UserSearchItem>>
suspend fun listContacts(): AppResult<List<UserSearchItem>>
suspend fun searchUsers(query: String, limit: Int = 20): AppResult<List<UserSearchItem>>
suspend fun addContact(userId: Long): AppResult<Unit>
suspend fun addContactByEmail(email: String): AppResult<Unit>
suspend fun removeContact(userId: Long): AppResult<Unit>
suspend fun blockUser(userId: Long): AppResult<Unit> suspend fun blockUser(userId: Long): AppResult<Unit>
suspend fun unblockUser(userId: Long): AppResult<Unit> suspend fun unblockUser(userId: Long): AppResult<Unit>
suspend fun listSessions(): AppResult<List<AuthSession>> suspend fun listSessions(): AppResult<List<AuthSession>>
suspend fun listNotifications(limit: Int = 50): AppResult<List<AccountNotification>>
suspend fun revokeSession(jti: String): AppResult<Unit> suspend fun revokeSession(jti: String): AppResult<Unit>
suspend fun revokeAllSessions(): AppResult<Unit> suspend fun revokeAllSessions(): AppResult<Unit>
suspend fun verifyEmail(token: String): AppResult<String> suspend fun verifyEmail(token: String): AppResult<String>
suspend fun resendVerification(email: String): AppResult<String>
suspend fun requestPasswordReset(email: String): AppResult<String> suspend fun requestPasswordReset(email: String): AppResult<String>
suspend fun resetPassword(token: String, password: String): AppResult<String> suspend fun resetPassword(token: String, password: String): AppResult<String>
suspend fun setupTwoFactor(): AppResult<Pair<String, 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.AuthUser
import ru.daemonlord.messenger.domain.auth.model.AuthSession import ru.daemonlord.messenger.domain.auth.model.AuthSession
import ru.daemonlord.messenger.domain.auth.model.AuthEmailStatus
import ru.daemonlord.messenger.domain.common.AppResult import ru.daemonlord.messenger.domain.common.AppResult
interface AuthRepository { 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 refreshTokens(): AppResult<Unit>
suspend fun getMe(): AppResult<AuthUser> suspend fun getMe(): AppResult<AuthUser>
suspend fun restoreSession(): 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( class LoginUseCase @Inject constructor(
private val authRepository: AuthRepository, private val authRepository: AuthRepository,
) { ) {
suspend operator fun invoke(email: String, password: String): AppResult<AuthUser> { suspend operator fun invoke(
return authRepository.login(email = email, password = password) 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.chat.model
data class ChatBanItem(
val userId: Long,
val name: String,
val username: String?,
val bannedAt: String?,
)

View File

@@ -0,0 +1,9 @@
package ru.daemonlord.messenger.domain.chat.model
data class ChatMemberItem(
val userId: Long,
val role: String,
val name: String,
val username: String?,
val avatarUrl: String?,
)

View File

@@ -0,0 +1,8 @@
package ru.daemonlord.messenger.domain.chat.model
data class ChatNotificationSettings(
val chatId: Long,
val userId: Long,
val muted: Boolean,
)

View File

@@ -0,0 +1,10 @@
package ru.daemonlord.messenger.domain.chat.model
data class DiscoverChatItem(
val id: Long,
val type: String,
val displayTitle: String,
val handle: String?,
val avatarUrl: String?,
val isMember: Boolean,
)

View File

@@ -1,8 +1,12 @@
package ru.daemonlord.messenger.domain.chat.repository package ru.daemonlord.messenger.domain.chat.repository
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import ru.daemonlord.messenger.domain.chat.model.ChatBanItem
import ru.daemonlord.messenger.domain.chat.model.ChatInviteLink import ru.daemonlord.messenger.domain.chat.model.ChatInviteLink
import ru.daemonlord.messenger.domain.chat.model.ChatItem import ru.daemonlord.messenger.domain.chat.model.ChatItem
import ru.daemonlord.messenger.domain.chat.model.ChatMemberItem
import ru.daemonlord.messenger.domain.chat.model.ChatNotificationSettings
import ru.daemonlord.messenger.domain.chat.model.DiscoverChatItem
import ru.daemonlord.messenger.domain.common.AppResult import ru.daemonlord.messenger.domain.common.AppResult
interface ChatRepository { interface ChatRepository {
@@ -10,7 +14,41 @@ interface ChatRepository {
fun observeChat(chatId: Long): Flow<ChatItem?> fun observeChat(chatId: Long): Flow<ChatItem?>
suspend fun refreshChats(archived: Boolean): AppResult<Unit> suspend fun refreshChats(archived: Boolean): AppResult<Unit>
suspend fun refreshChat(chatId: Long): AppResult<Unit> suspend fun refreshChat(chatId: Long): AppResult<Unit>
suspend fun getSavedChat(): AppResult<ChatItem>
suspend fun createInviteLink(chatId: Long): AppResult<ChatInviteLink> suspend fun createInviteLink(chatId: Long): AppResult<ChatInviteLink>
suspend fun joinByInvite(token: String): AppResult<ChatItem> suspend fun joinByInvite(token: String): AppResult<ChatItem>
suspend fun createChat(
type: String,
title: String?,
isPublic: Boolean,
handle: String?,
description: String?,
memberIds: List<Long>,
): AppResult<ChatItem>
suspend fun discoverChats(query: String?): AppResult<List<DiscoverChatItem>>
suspend fun joinChat(chatId: Long): AppResult<ChatItem>
suspend fun leaveChat(chatId: Long): AppResult<Unit>
suspend fun archiveChat(chatId: Long): AppResult<ChatItem>
suspend fun unarchiveChat(chatId: Long): AppResult<ChatItem>
suspend fun pinChat(chatId: Long): AppResult<ChatItem>
suspend fun unpinChat(chatId: Long): AppResult<ChatItem>
suspend fun updateChatTitle(chatId: Long, title: String): AppResult<ChatItem>
suspend fun updateChatProfile(
chatId: Long,
title: String? = null,
description: String? = null,
avatarUrl: String? = null,
): AppResult<ChatItem>
suspend fun clearChat(chatId: Long): AppResult<Unit>
suspend fun removeChat(chatId: Long, forAll: Boolean = false): AppResult<Unit>
suspend fun getChatNotifications(chatId: Long): AppResult<ChatNotificationSettings>
suspend fun updateChatNotifications(chatId: Long, muted: Boolean): AppResult<ChatNotificationSettings>
suspend fun listMembers(chatId: Long): AppResult<List<ChatMemberItem>>
suspend fun listBans(chatId: Long): AppResult<List<ChatBanItem>>
suspend fun addMember(chatId: Long, userId: Long): AppResult<ChatMemberItem>
suspend fun updateMemberRole(chatId: Long, userId: Long, role: String): AppResult<ChatMemberItem>
suspend fun removeMember(chatId: Long, userId: Long): AppResult<Unit>
suspend fun banMember(chatId: Long, userId: Long): AppResult<Unit>
suspend fun unbanMember(chatId: Long, userId: Long): AppResult<Unit>
suspend fun deleteChat(chatId: Long) suspend fun deleteChat(chatId: Long)
} }

View File

@@ -0,0 +1,12 @@
package ru.daemonlord.messenger.domain.chat.repository
import kotlinx.coroutines.flow.Flow
interface ChatSearchRepository {
fun observeHistoryChatIds(): Flow<List<Long>>
fun observeRecentChatIds(): Flow<List<Long>>
suspend fun addHistoryChat(chatId: Long)
suspend fun addRecentChat(chatId: Long)
suspend fun clearHistory()
}

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

View File

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

View File

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

View File

@@ -7,6 +7,8 @@ import ru.daemonlord.messenger.domain.message.model.MessageReaction
interface MessageRepository { interface MessageRepository {
fun observeMessages(chatId: Long, limit: Int = 50): Flow<List<MessageItem>> fun observeMessages(chatId: Long, limit: Int = 50): Flow<List<MessageItem>>
suspend fun searchMessages(query: String, chatId: Long? = null): AppResult<List<MessageItem>>
suspend fun getMessageThread(messageId: Long, limit: Int = 100): AppResult<List<MessageItem>>
suspend fun syncRecentMessages(chatId: Long): AppResult<Unit> suspend fun syncRecentMessages(chatId: Long): AppResult<Unit>
suspend fun loadMoreMessages(chatId: Long, beforeMessageId: Long): AppResult<Unit> suspend fun loadMoreMessages(chatId: Long, beforeMessageId: Long): AppResult<Unit>
suspend fun sendTextMessage(chatId: Long, text: String, replyToMessageId: Long? = null): AppResult<Unit> suspend fun sendTextMessage(chatId: Long, text: String, replyToMessageId: Long? = null): AppResult<Unit>
@@ -18,6 +20,11 @@ interface MessageRepository {
caption: String? = null, caption: String? = null,
replyToMessageId: Long? = null, replyToMessageId: Long? = null,
): AppResult<Unit> ): 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 editMessage(messageId: Long, newText: String): AppResult<Unit>
suspend fun deleteMessage(messageId: Long, forAll: Boolean): AppResult<Unit> suspend fun deleteMessage(messageId: Long, forAll: Boolean): AppResult<Unit>
suspend fun markMessageDelivered(chatId: Long, messageId: Long): 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

@@ -1,10 +1,13 @@
package ru.daemonlord.messenger.domain.realtime package ru.daemonlord.messenger.domain.realtime
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.StateFlow
import ru.daemonlord.messenger.domain.realtime.model.RealtimeConnectionState
import ru.daemonlord.messenger.domain.realtime.model.RealtimeEvent import ru.daemonlord.messenger.domain.realtime.model.RealtimeEvent
interface RealtimeManager { interface RealtimeManager {
val events: Flow<RealtimeEvent> val events: Flow<RealtimeEvent>
val connectionState: StateFlow<RealtimeConnectionState>
fun connect() fun connect()
fun disconnect() fun disconnect()
} }

View File

@@ -0,0 +1,8 @@
package ru.daemonlord.messenger.domain.realtime.model
enum class RealtimeConnectionState {
Disconnected,
Connecting,
Reconnecting,
Connected,
}

View File

@@ -52,6 +52,8 @@ sealed interface RealtimeEvent {
data class MessageRead( data class MessageRead(
val chatId: Long, val chatId: Long,
val messageId: Long, val messageId: Long,
val userId: Long?,
val lastReadMessageId: Long?,
) : RealtimeEvent ) : RealtimeEvent
data class TypingStart( 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.ActiveChatTracker
import ru.daemonlord.messenger.core.notifications.ChatNotificationPayload import ru.daemonlord.messenger.core.notifications.ChatNotificationPayload
import ru.daemonlord.messenger.core.notifications.NotificationDispatcher 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.dao.MessageDao
import ru.daemonlord.messenger.data.message.local.entity.MessageEntity import ru.daemonlord.messenger.data.message.local.entity.MessageEntity
import ru.daemonlord.messenger.domain.chat.repository.ChatRepository 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.notifications.usecase.ShouldShowMessageNotificationUseCase
import ru.daemonlord.messenger.domain.realtime.RealtimeManager import ru.daemonlord.messenger.domain.realtime.RealtimeManager
import ru.daemonlord.messenger.domain.realtime.model.RealtimeEvent import ru.daemonlord.messenger.domain.realtime.model.RealtimeEvent
@@ -27,6 +29,8 @@ class HandleRealtimeEventsUseCase @Inject constructor(
private val messageDao: MessageDao, private val messageDao: MessageDao,
private val notificationDispatcher: NotificationDispatcher, private val notificationDispatcher: NotificationDispatcher,
private val activeChatTracker: ActiveChatTracker, private val activeChatTracker: ActiveChatTracker,
private val tokenRepository: TokenRepository,
private val notificationSettingsRepository: NotificationSettingsRepository,
private val shouldShowMessageNotificationUseCase: ShouldShowMessageNotificationUseCase, private val shouldShowMessageNotificationUseCase: ShouldShowMessageNotificationUseCase,
) { ) {
@@ -45,6 +49,7 @@ class HandleRealtimeEventsUseCase @Inject constructor(
} }
is RealtimeEvent.ReceiveMessage -> { is RealtimeEvent.ReceiveMessage -> {
val activeChatId = activeChatTracker.activeChatId.value
messageDao.upsertMessages( messageDao.upsertMessages(
listOf( listOf(
MessageEntity( MessageEntity(
@@ -75,18 +80,41 @@ class HandleRealtimeEventsUseCase @Inject constructor(
lastMessageCreatedAt = event.createdAt, lastMessageCreatedAt = event.createdAt,
updatedSortAt = event.createdAt, updatedSortAt = event.createdAt,
) )
if (activeChatId == event.chatId) {
chatDao.markChatRead(chatId = event.chatId)
} else {
chatDao.incrementUnread(chatId = event.chatId) chatDao.incrementUnread(chatId = event.chatId)
val activeChatId = activeChatTracker.activeChatId.value }
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 muted = chatDao.isChatMuted(event.chatId) == true
val shouldNotify = shouldShowMessageNotificationUseCase( val shouldNotify = shouldShowMessageNotificationUseCase(
chatId = event.chatId, chatId = event.chatId,
isMention = event.isMention, isMention = isMention,
serverMuted = muted, serverMuted = muted,
) )
if (activeChatId != event.chatId && shouldNotify) { if (activeChatId != event.chatId && shouldNotify) {
val title = chatDao.getChatDisplayTitle(event.chatId) ?: "New message" val title = chatDao.getChatDisplayTitle(event.chatId) ?: "New message"
val body = event.text?.takeIf { it.isNotBlank() } val previewEnabled = notificationSettingsRepository.getSettings().previewEnabled
?: when (event.type?.lowercase()) { val body = (if (previewEnabled) {
event.text?.takeIf { it.isNotBlank() }
} else {
null
}) ?: when (event.type?.lowercase()) {
"image" -> "Photo" "image" -> "Photo"
"video" -> "Video" "video" -> "Video"
"audio" -> "Audio" "audio" -> "Audio"
@@ -100,7 +128,7 @@ class HandleRealtimeEventsUseCase @Inject constructor(
messageId = event.messageId, messageId = event.messageId,
title = title, title = title,
body = body, body = body,
isMention = event.isMention, isMention = isMention,
) )
) )
} }
@@ -128,7 +156,8 @@ class HandleRealtimeEventsUseCase @Inject constructor(
} }
is RealtimeEvent.ChatUpdated -> { is RealtimeEvent.ChatUpdated -> {
chatRepository.refreshChat(chatId = event.chatId) chatRepository.refreshChats(archived = false)
chatRepository.refreshChats(archived = true)
} }
is RealtimeEvent.ChatDeleted -> { is RealtimeEvent.ChatDeleted -> {
@@ -163,6 +192,7 @@ class HandleRealtimeEventsUseCase @Inject constructor(
messageId = event.messageId, messageId = event.messageId,
status = "read", status = "read",
) )
chatRepository.refreshChat(chatId = event.chatId)
} }
is RealtimeEvent.TypingStart -> Unit is RealtimeEvent.TypingStart -> Unit

View File

@@ -0,0 +1,11 @@
package ru.daemonlord.messenger.domain.search.model
import ru.daemonlord.messenger.domain.account.model.UserSearchItem
import ru.daemonlord.messenger.domain.chat.model.DiscoverChatItem
import ru.daemonlord.messenger.domain.message.model.MessageItem
data class GlobalSearchResult(
val users: List<UserSearchItem>,
val chats: List<DiscoverChatItem>,
val messages: List<MessageItem>,
)

View File

@@ -0,0 +1,13 @@
package ru.daemonlord.messenger.domain.search.repository
import ru.daemonlord.messenger.domain.common.AppResult
import ru.daemonlord.messenger.domain.search.model.GlobalSearchResult
interface SearchRepository {
suspend fun globalSearch(
query: String,
usersLimit: Int = 10,
chatsLimit: Int = 10,
messagesLimit: Int = 10,
): AppResult<GlobalSearchResult>
}

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.FirebaseMessagingService
import com.google.firebase.messaging.RemoteMessage import com.google.firebase.messaging.RemoteMessage
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import ru.daemonlord.messenger.core.notifications.ActiveChatTracker
import ru.daemonlord.messenger.core.notifications.NotificationDispatcher import ru.daemonlord.messenger.core.notifications.NotificationDispatcher
import javax.inject.Inject import javax.inject.Inject
@@ -13,12 +14,22 @@ class MessengerFirebaseMessagingService : FirebaseMessagingService() {
@Inject @Inject
lateinit var notificationDispatcher: NotificationDispatcher lateinit var notificationDispatcher: NotificationDispatcher
@Inject
lateinit var pushTokenSyncManager: PushTokenSyncManager
@Inject
lateinit var activeChatTracker: ActiveChatTracker
override fun onMessageReceived(message: RemoteMessage) { override fun onMessageReceived(message: RemoteMessage) {
val payload = PushPayloadParser.parse(message) ?: return val payload = PushPayloadParser.parse(message) ?: return
if (activeChatTracker.activeChatId.value == payload.chatId) {
return
}
notificationDispatcher.showChatMessage(payload) notificationDispatcher.showChatMessage(payload)
} }
override fun onNewToken(token: String) { override fun onNewToken(token: String) {
Log.i("MessengerPush", "FCM token refreshed (${token.take(10)}...)") Log.i("MessengerPush", "FCM token refreshed (${token.take(10)}...)")
pushTokenSyncManager.onNewToken(token)
} }
} }

View File

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

View File

@@ -0,0 +1,118 @@
package ru.daemonlord.messenger.push
import android.content.SharedPreferences
import com.google.firebase.messaging.FirebaseMessaging
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import ru.daemonlord.messenger.BuildConfig
import ru.daemonlord.messenger.core.token.TokenRepository
import ru.daemonlord.messenger.data.notifications.api.PushTokenApiService
import ru.daemonlord.messenger.data.notifications.dto.PushTokenDeleteRequestDto
import ru.daemonlord.messenger.data.notifications.dto.PushTokenUpsertRequestDto
import ru.daemonlord.messenger.di.IoDispatcher
import ru.daemonlord.messenger.di.TokenPrefs
import timber.log.Timber
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class PushTokenSyncManager @Inject constructor(
private val tokenRepository: TokenRepository,
private val pushTokenApiService: PushTokenApiService,
@TokenPrefs private val securePrefs: SharedPreferences,
@IoDispatcher ioDispatcher: CoroutineDispatcher,
) {
private val scope = CoroutineScope(SupervisorJob() + ioDispatcher)
fun onNewToken(token: String) {
val cleaned = token.trim()
if (cleaned.isBlank()) {
return
}
securePrefs.edit().putString(KEY_LAST_FCM_TOKEN, cleaned).apply()
scope.launch {
registerTokenIfPossible(cleaned)
}
}
fun triggerBestEffortSync() {
val cached = securePrefs.getString(KEY_LAST_FCM_TOKEN, null)?.trim().orEmpty()
if (cached.isNotBlank()) {
scope.launch {
registerTokenIfPossible(cached)
}
}
FirebaseMessaging.getInstance().token
.addOnSuccessListener { token -> onNewToken(token) }
.addOnFailureListener { error ->
Timber.w(error, "Failed to fetch FCM token for sync")
}
}
suspend fun unregisterCurrentTokenOnLogout() {
val token = securePrefs.getString(KEY_LAST_FCM_TOKEN, null)?.trim().orEmpty()
if (token.isBlank()) {
return
}
val hasTokens = tokenRepository.getTokens() != null
if (!hasTokens) {
return
}
runCatching {
pushTokenApiService.delete(
request = PushTokenDeleteRequestDto(
platform = PLATFORM_ANDROID,
token = token,
)
)
}.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) {
val hasTokens = tokenRepository.getTokens() != null
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(
platform = PLATFORM_ANDROID,
token = token,
deviceId = null,
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")
}
}
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 package ru.daemonlord.messenger.ui.account
import ru.daemonlord.messenger.domain.account.model.UserSearchItem 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.AuthSession
import ru.daemonlord.messenger.domain.auth.model.AuthUser 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( data class AccountUiState(
val isLoading: Boolean = false, val isLoading: Boolean = false,
@@ -14,6 +17,22 @@ data class AccountUiState(
val twoFactorOtpAuthUrl: String? = null, val twoFactorOtpAuthUrl: String? = null,
val recoveryCodes: List<String> = emptyList(), val recoveryCodes: List<String> = emptyList(),
val recoveryCodesRemaining: Int? = null, 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 message: String? = null,
val errorMessage: 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 package ru.daemonlord.messenger.ui.account
import android.content.Context
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope 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 dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch 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.account.repository.AccountRepository
import ru.daemonlord.messenger.domain.chat.repository.ChatRepository
import ru.daemonlord.messenger.domain.common.AppError import ru.daemonlord.messenger.domain.common.AppError
import ru.daemonlord.messenger.domain.common.AppResult 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 import javax.inject.Inject
@HiltViewModel @HiltViewModel
class AccountViewModel @Inject constructor( class AccountViewModel @Inject constructor(
private val accountRepository: AccountRepository, 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() { ) : ViewModel() {
private val _uiState = MutableStateFlow(AccountUiState()) private val _uiState = MutableStateFlow(AccountUiState())
val uiState: StateFlow<AccountUiState> = _uiState.asStateFlow() val uiState: StateFlow<AccountUiState> = _uiState.asStateFlow()
@@ -30,13 +54,38 @@ class AccountViewModel @Inject constructor(
val me = accountRepository.getMe() val me = accountRepository.getMe()
val sessions = accountRepository.listSessions() val sessions = accountRepository.listSessions()
val blocked = accountRepository.listBlockedUsers() 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 -> _uiState.update { state ->
state.copy( state.copy(
isLoading = false, isLoading = false,
profile = (me as? AppResult.Success)?.data ?: state.profile, profile = (me as? AppResult.Success)?.data ?: state.profile,
sessions = (sessions as? AppResult.Success)?.data ?: state.sessions, sessions = (sessions as? AppResult.Success)?.data ?: state.sessions,
blockedUsers = (blocked as? AppResult.Success)?.data ?: state.blockedUsers, 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>() .filterIsInstance<AppResult.Error>()
.firstOrNull() .firstOrNull()
?.reason ?.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( fun updateProfile(
name: String, name: String,
username: String, username: String,
@@ -59,7 +213,7 @@ class AccountViewModel @Inject constructor(
it.copy( it.copy(
isSaving = false, isSaving = false,
profile = result.data, profile = result.data,
message = "Profile updated.", message = context.getString(R.string.account_info_profile_updated),
) )
} }
is AppResult.Error -> _uiState.update { is AppResult.Error -> _uiState.update {
@@ -82,7 +236,7 @@ class AccountViewModel @Inject constructor(
_uiState.update { it.copy(isSaving = true, errorMessage = null, message = null) } _uiState.update { it.copy(isSaving = true, errorMessage = null, message = null) }
when (val result = accountRepository.uploadAvatar(fileName, mimeType, bytes)) { when (val result = accountRepository.uploadAvatar(fileName, mimeType, bytes)) {
is AppResult.Success -> { 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) onUploaded(result.data)
} }
is AppResult.Error -> _uiState.update { is AppResult.Error -> _uiState.update {
@@ -115,7 +269,7 @@ class AccountViewModel @Inject constructor(
it.copy( it.copy(
isSaving = false, isSaving = false,
profile = result.data, profile = result.data,
message = "Privacy settings updated.", message = context.getString(R.string.account_info_privacy_updated),
) )
} }
is AppResult.Error -> _uiState.update { is AppResult.Error -> _uiState.update {
@@ -168,7 +322,7 @@ class AccountViewModel @Inject constructor(
it.copy( it.copy(
twoFactorSecret = result.data.first, twoFactorSecret = result.data.first,
twoFactorOtpAuthUrl = result.data.second, twoFactorOtpAuthUrl = result.data.second,
message = "2FA secret generated. Enter code to enable.", message = context.getString(R.string.account_info_2fa_secret_generated),
errorMessage = null, errorMessage = null,
) )
} }
@@ -217,7 +371,7 @@ class AccountViewModel @Inject constructor(
is AppResult.Success -> _uiState.update { is AppResult.Success -> _uiState.update {
it.copy( it.copy(
recoveryCodes = result.data, recoveryCodes = result.data,
message = "Recovery codes regenerated.", message = context.getString(R.string.account_info_recovery_codes_regenerated),
errorMessage = null, 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) { fun requestPasswordReset(email: String) {
viewModelScope.launch { viewModelScope.launch {
_uiState.update { it.copy(isSaving = true, errorMessage = null, message = null) } _uiState.update { it.copy(isSaving = true, errorMessage = null, message = null) }
@@ -292,11 +466,11 @@ class AccountViewModel @Inject constructor(
private fun AppError.toUiMessage(): String { private fun AppError.toUiMessage(): String {
return when (this) { return when (this) {
AppError.InvalidCredentials -> "Invalid credentials." AppError.InvalidCredentials -> context.getString(R.string.account_error_invalid_credentials)
AppError.Unauthorized -> "Unauthorized." AppError.Unauthorized -> context.getString(R.string.account_error_unauthorized)
AppError.Network -> "Network error." AppError.Network -> context.getString(R.string.error_network)
is AppError.Server -> message ?: "Server error." is AppError.Server -> message ?: context.getString(R.string.error_server)
is AppError.Unknown -> cause?.message ?: "Unknown error." is AppError.Unknown -> cause?.message ?: context.getString(R.string.error_unknown)
} }
} }
} }

View File

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

View File

@@ -1,25 +1,33 @@
package ru.daemonlord.messenger.ui.auth package ru.daemonlord.messenger.ui.auth
import android.content.Context
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch 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.LoginUseCase
import ru.daemonlord.messenger.domain.auth.usecase.LogoutUseCase 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.auth.usecase.RestoreSessionUseCase
import ru.daemonlord.messenger.domain.common.AppError import ru.daemonlord.messenger.domain.common.AppError
import ru.daemonlord.messenger.domain.common.AppResult import ru.daemonlord.messenger.domain.common.AppResult
import ru.daemonlord.messenger.R
import javax.inject.Inject import javax.inject.Inject
@HiltViewModel @HiltViewModel
class AuthViewModel @Inject constructor( class AuthViewModel @Inject constructor(
private val restoreSessionUseCase: RestoreSessionUseCase, private val restoreSessionUseCase: RestoreSessionUseCase,
private val checkEmailStatusUseCase: CheckEmailStatusUseCase,
private val registerUseCase: RegisterUseCase,
private val loginUseCase: LoginUseCase, private val loginUseCase: LoginUseCase,
private val logoutUseCase: LogoutUseCase, private val logoutUseCase: LogoutUseCase,
@ApplicationContext private val context: Context,
) : ViewModel() { ) : ViewModel() {
private val _uiState = MutableStateFlow(AuthUiState()) private val _uiState = MutableStateFlow(AuthUiState())
@@ -30,33 +38,235 @@ class AuthViewModel @Inject constructor(
} }
fun onEmailChanged(value: String) { 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) { fun onPasswordChanged(value: String) {
_uiState.update { it.copy(password = value, errorMessage = null) } _uiState.update { it.copy(password = value, errorMessage = null) }
} }
fun login() { fun onOtpCodeChanged(value: String) {
val state = uiState.value _uiState.update { it.copy(otpCode = value.filter(Char::isDigit).take(8), errorMessage = null) }
if (state.email.isBlank() || state.password.isBlank()) {
_uiState.update { it.copy(errorMessage = "Email and password are required.") }
return
} }
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 { 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)) { when (val result = loginUseCase(state.email.trim(), state.password)) {
is AppResult.Success -> { is AppResult.Success -> {
_uiState.update { _uiState.update {
it.copy( it.copy(
isLoading = false, isLoading = false,
isAuthenticated = true, 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, errorMessage = null,
) )
} }
} }
is AppResult.Error -> { is AppResult.Error -> {
_uiState.update { _uiState.update {
it.copy( it.copy(
@@ -76,16 +286,47 @@ class AuthViewModel @Inject constructor(
runCatching { logoutUseCase() } runCatching { logoutUseCase() }
_uiState.update { _uiState.update {
it.copy( it.copy(
step = AuthStep.EMAIL,
email = "", email = "",
name = "",
username = "",
password = "", password = "",
otpCode = "",
recoveryCode = "",
useRecoveryCode = false,
isLoading = false, isLoading = false,
isAuthenticated = false, isAuthenticated = false,
errorMessage = null, 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() { private fun restoreSession() {
viewModelScope.launch { viewModelScope.launch {
_uiState.update { it.copy(isCheckingSession = true) } _uiState.update { it.copy(isCheckingSession = true) }
@@ -99,7 +340,6 @@ class AuthViewModel @Inject constructor(
) )
} }
} }
is AppResult.Error -> { is AppResult.Error -> {
val keepAuthenticatedOffline = result.reason is AppError.Network val keepAuthenticatedOffline = result.reason is AppError.Network
_uiState.update { _uiState.update {
@@ -116,11 +356,11 @@ class AuthViewModel @Inject constructor(
private fun AppError.toUiMessage(): String { private fun AppError.toUiMessage(): String {
return when (this) { return when (this) {
AppError.InvalidCredentials -> "Invalid email or password." AppError.InvalidCredentials -> context.getString(R.string.auth_error_invalid_credentials)
AppError.Network -> "Network error. Check your connection." AppError.Network -> context.getString(R.string.auth_error_network)
AppError.Unauthorized -> "Session expired. Please sign in again." AppError.Unauthorized -> context.getString(R.string.auth_error_session_expired)
is AppError.Server -> "Server error. Please try again." is AppError.Server -> message ?: context.getString(R.string.auth_error_server)
is AppError.Unknown -> "Unknown error. Please try again." is AppError.Unknown -> context.getString(R.string.auth_error_unknown)
} }
} }
} }

View File

@@ -1,9 +1,11 @@
package ru.daemonlord.messenger.ui.auth package ru.daemonlord.messenger.ui.auth
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.WindowInsets
@@ -17,67 +19,208 @@ import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier 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.text.input.PasswordVisualTransformation
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import ru.daemonlord.messenger.R
@Composable @Composable
fun LoginScreen( fun LoginScreen(
state: AuthUiState, state: AuthUiState,
headerTitle: String = "",
onEmailChanged: (String) -> Unit, onEmailChanged: (String) -> Unit,
onNameChanged: (String) -> Unit,
onUsernameChanged: (String) -> Unit,
onPasswordChanged: (String) -> Unit, onPasswordChanged: (String) -> Unit,
onLoginClick: () -> Unit, onOtpCodeChanged: (String) -> Unit,
onRecoveryCodeChanged: (String) -> Unit,
onToggleRecoveryCodeMode: () -> Unit,
onContinueEmail: () -> Unit,
onSubmitStep: () -> Unit,
onBackToEmail: () -> Unit,
onOpenVerifyEmail: () -> Unit, onOpenVerifyEmail: () -> Unit,
onOpenResetPassword: () -> Unit, onOpenResetPassword: () -> Unit,
) { ) {
Column( val isTabletLayout = LocalConfiguration.current.screenWidthDp >= 840
val isBusy = state.isLoading
val resolvedHeaderTitle = headerTitle.ifBlank { stringResource(id = R.string.auth_header_login) }
Box(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.windowInsetsPadding(WindowInsets.safeDrawing) .windowInsetsPadding(WindowInsets.safeDrawing)
.padding(horizontal = 24.dp), .padding(horizontal = 24.dp),
contentAlignment = Alignment.Center,
) {
Column(
modifier = Modifier
.fillMaxWidth()
.then(if (isTabletLayout) Modifier.widthIn(max = 560.dp) else Modifier),
verticalArrangement = Arrangement.Center, verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
) { ) {
Text( Text(
text = "Messenger Login", text = resolvedHeaderTitle,
style = MaterialTheme.typography.headlineSmall, style = MaterialTheme.typography.headlineSmall,
modifier = Modifier.padding(bottom = 24.dp), modifier = Modifier.padding(bottom = 14.dp),
) )
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( OutlinedTextField(
value = state.email, value = state.email,
onValueChange = onEmailChanged, onValueChange = onEmailChanged,
label = { Text(text = "Email") }, label = { Text(text = stringResource(id = R.string.auth_label_email)) },
singleLine = true, singleLine = true,
enabled = !state.isLoading, enabled = !isBusy,
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 modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(bottom = 16.dp), .padding(bottom = 16.dp),
) )
Button( Button(
onClick = onLoginClick, onClick = onContinueEmail,
enabled = !state.isLoading, enabled = !isBusy,
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
) { ) {
if (state.isLoading) { if (isBusy) {
CircularProgressIndicator( CircularProgressIndicator(strokeWidth = 2.dp, modifier = Modifier.padding(2.dp))
strokeWidth = 2.dp, } else {
modifier = Modifier.padding(2.dp), 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 { } else {
Text(text = "Login") 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)
}
)
}
}
} }
} }
@@ -89,18 +232,28 @@ fun LoginScreen(
modifier = Modifier.padding(top = 12.dp), 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( TextButton(
onClick = onOpenVerifyEmail, onClick = onOpenVerifyEmail,
enabled = !state.isLoading, enabled = !isBusy,
modifier = Modifier.padding(top = 8.dp), modifier = Modifier.padding(top = 8.dp),
) { ) {
Text(text = "Verify email by token") Text(text = stringResource(id = R.string.auth_verify_email_by_token))
} }
TextButton( TextButton(
onClick = onOpenResetPassword, onClick = onOpenResetPassword,
enabled = !state.isLoading, enabled = !isBusy,
) { ) {
Text(text = "Forgot password") Text(text = stringResource(id = R.string.auth_forgot_password))
}
} }
} }
} }

View File

@@ -1,12 +1,14 @@
package ru.daemonlord.messenger.ui.auth.reset package ru.daemonlord.messenger.ui.auth.reset
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawing import androidx.compose.foundation.layout.safeDrawing
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
@@ -18,10 +20,14 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier 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.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import ru.daemonlord.messenger.R
import ru.daemonlord.messenger.ui.account.AccountViewModel import ru.daemonlord.messenger.ui.account.AccountViewModel
@Composable @Composable
@@ -33,18 +39,25 @@ fun ResetPasswordRoute(
val state by viewModel.uiState.collectAsStateWithLifecycle() val state by viewModel.uiState.collectAsStateWithLifecycle()
var email by remember { mutableStateOf("") } var email by remember { mutableStateOf("") }
var password by remember { mutableStateOf("") } var password by remember { mutableStateOf("") }
Column( val isTabletLayout = LocalConfiguration.current.screenWidthDp >= 840
Box(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.windowInsetsPadding(WindowInsets.safeDrawing) .windowInsetsPadding(WindowInsets.safeDrawing)
.padding(16.dp), .padding(16.dp),
contentAlignment = Alignment.TopCenter,
) {
Column(
modifier = Modifier
.fillMaxWidth()
.then(if (isTabletLayout) Modifier.widthIn(max = 620.dp) else Modifier),
verticalArrangement = Arrangement.spacedBy(10.dp), 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( OutlinedTextField(
value = email, value = email,
onValueChange = { email = it }, onValueChange = { email = it },
label = { Text("Email") }, label = { Text(stringResource(id = R.string.auth_label_email)) },
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
) )
Button( Button(
@@ -52,12 +65,12 @@ fun ResetPasswordRoute(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
enabled = !state.isSaving && email.isNotBlank(), enabled = !state.isSaving && email.isNotBlank(),
) { ) {
Text("Send reset link") Text(stringResource(id = R.string.auth_send_reset_link))
} }
OutlinedTextField( OutlinedTextField(
value = password, value = password,
onValueChange = { password = it }, onValueChange = { password = it },
label = { Text("New password") }, label = { Text(stringResource(id = R.string.auth_new_password)) },
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
) )
Button( Button(
@@ -69,7 +82,7 @@ fun ResetPasswordRoute(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
enabled = !state.isSaving && !token.isNullOrBlank() && password.length >= 8, enabled = !state.isSaving && !token.isNullOrBlank() && password.length >= 8,
) { ) {
Text("Reset with token") Text(stringResource(id = R.string.auth_reset_with_token))
} }
if (state.isSaving) { if (state.isSaving) {
CircularProgressIndicator() CircularProgressIndicator()
@@ -81,7 +94,8 @@ fun ResetPasswordRoute(
Text(state.errorMessage!!, color = MaterialTheme.colorScheme.error) Text(state.errorMessage!!, color = MaterialTheme.colorScheme.error)
} }
Button(onClick = onBackToLogin, modifier = Modifier.fillMaxWidth()) { Button(onClick = onBackToLogin, modifier = Modifier.fillMaxWidth()) {
Text("Back to login") Text(stringResource(id = R.string.auth_back_to_login))
}
} }
} }
} }

View File

@@ -1,12 +1,14 @@
package ru.daemonlord.messenger.ui.auth.verify package ru.daemonlord.messenger.ui.auth.verify
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawing import androidx.compose.foundation.layout.safeDrawing
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
@@ -19,10 +21,14 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier 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.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import ru.daemonlord.messenger.R
import ru.daemonlord.messenger.ui.account.AccountViewModel import ru.daemonlord.messenger.ui.account.AccountViewModel
@Composable @Composable
@@ -33,6 +39,7 @@ fun VerifyEmailRoute(
) { ) {
val state by viewModel.uiState.collectAsStateWithLifecycle() val state by viewModel.uiState.collectAsStateWithLifecycle()
var editableToken by remember(token) { mutableStateOf(token.orEmpty()) } var editableToken by remember(token) { mutableStateOf(token.orEmpty()) }
var resendEmail by remember { mutableStateOf(state.profile?.email.orEmpty()) }
LaunchedEffect(token) { LaunchedEffect(token) {
if (!token.isNullOrBlank()) { if (!token.isNullOrBlank()) {
@@ -40,18 +47,25 @@ fun VerifyEmailRoute(
} }
} }
Column( val isTabletLayout = LocalConfiguration.current.screenWidthDp >= 840
Box(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.windowInsetsPadding(WindowInsets.safeDrawing) .windowInsetsPadding(WindowInsets.safeDrawing)
.padding(16.dp), .padding(16.dp),
contentAlignment = Alignment.TopCenter,
) {
Column(
modifier = Modifier
.fillMaxWidth()
.then(if (isTabletLayout) Modifier.widthIn(max = 620.dp) else Modifier),
verticalArrangement = Arrangement.spacedBy(10.dp), 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( OutlinedTextField(
value = editableToken, value = editableToken,
onValueChange = { editableToken = it }, onValueChange = { editableToken = it },
label = { Text("Verification token") }, label = { Text(stringResource(id = R.string.auth_verification_token)) },
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
) )
Button( Button(
@@ -59,7 +73,21 @@ fun VerifyEmailRoute(
enabled = !state.isSaving && editableToken.isNotBlank(), enabled = !state.isSaving && editableToken.isNotBlank(),
modifier = Modifier.fillMaxWidth(), 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) { if (state.isSaving) {
CircularProgressIndicator() CircularProgressIndicator()
@@ -71,7 +99,8 @@ fun VerifyEmailRoute(
Text(state.errorMessage!!, color = MaterialTheme.colorScheme.error) Text(state.errorMessage!!, color = MaterialTheme.colorScheme.error)
} }
Button(onClick = onBackToLogin, modifier = Modifier.fillMaxWidth()) { 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 package ru.daemonlord.messenger.ui.chat
import android.content.Context
import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
@@ -14,6 +16,10 @@ import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import ru.daemonlord.messenger.core.notifications.ActiveChatTracker 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.ObserveChatUseCase
import ru.daemonlord.messenger.domain.chat.usecase.ObserveChatsUseCase import ru.daemonlord.messenger.domain.chat.usecase.ObserveChatsUseCase
import ru.daemonlord.messenger.domain.common.AppError 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.MarkMessageDeliveredUseCase
import ru.daemonlord.messenger.domain.message.usecase.MarkMessageReadUseCase import ru.daemonlord.messenger.domain.message.usecase.MarkMessageReadUseCase
import ru.daemonlord.messenger.domain.message.usecase.ObserveMessagesUseCase 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.SendMediaMessageUseCase
import ru.daemonlord.messenger.domain.message.usecase.SendTextMessageUseCase import ru.daemonlord.messenger.domain.message.usecase.SendTextMessageUseCase
import ru.daemonlord.messenger.domain.message.usecase.SyncRecentMessagesUseCase import ru.daemonlord.messenger.domain.message.usecase.SyncRecentMessagesUseCase
@@ -45,6 +52,7 @@ class ChatViewModel @Inject constructor(
private val syncRecentMessagesUseCase: SyncRecentMessagesUseCase, private val syncRecentMessagesUseCase: SyncRecentMessagesUseCase,
private val loadMoreMessagesUseCase: LoadMoreMessagesUseCase, private val loadMoreMessagesUseCase: LoadMoreMessagesUseCase,
private val sendTextMessageUseCase: SendTextMessageUseCase, private val sendTextMessageUseCase: SendTextMessageUseCase,
private val sendImageUrlMessageUseCase: SendImageUrlMessageUseCase,
private val sendMediaMessageUseCase: SendMediaMessageUseCase, private val sendMediaMessageUseCase: SendMediaMessageUseCase,
private val editMessageUseCase: EditMessageUseCase, private val editMessageUseCase: EditMessageUseCase,
private val deleteMessageUseCase: DeleteMessageUseCase, private val deleteMessageUseCase: DeleteMessageUseCase,
@@ -54,10 +62,14 @@ class ChatViewModel @Inject constructor(
private val forwardMessageBulkUseCase: ForwardMessageBulkUseCase, private val forwardMessageBulkUseCase: ForwardMessageBulkUseCase,
private val listMessageReactionsUseCase: ListMessageReactionsUseCase, private val listMessageReactionsUseCase: ListMessageReactionsUseCase,
private val toggleMessageReactionUseCase: ToggleMessageReactionUseCase, private val toggleMessageReactionUseCase: ToggleMessageReactionUseCase,
private val chatRepository: ChatRepository,
private val observeChatUseCase: ObserveChatUseCase, private val observeChatUseCase: ObserveChatUseCase,
private val observeChatsUseCase: ObserveChatsUseCase, private val observeChatsUseCase: ObserveChatsUseCase,
private val handleRealtimeEventsUseCase: HandleRealtimeEventsUseCase, private val handleRealtimeEventsUseCase: HandleRealtimeEventsUseCase,
private val activeChatTracker: ActiveChatTracker, private val activeChatTracker: ActiveChatTracker,
private val notificationDispatcher: NotificationDispatcher,
private val tokenRepository: TokenRepository,
@ApplicationContext private val context: Context,
) : ViewModel() { ) : ViewModel() {
private val chatId: Long = checkNotNull(savedStateHandle["chatId"]) private val chatId: Long = checkNotNull(savedStateHandle["chatId"])
@@ -66,9 +78,12 @@ class ChatViewModel @Inject constructor(
private val visibleMessagesLimit = MutableStateFlow(MESSAGES_PAGE_SIZE) private val visibleMessagesLimit = MutableStateFlow(MESSAGES_PAGE_SIZE)
private var lastDeliveredMessageId: Long? = null private var lastDeliveredMessageId: Long? = null
private var lastReadMessageId: Long? = null private var lastReadMessageId: Long? = null
private val reactionsRequestedMessageIds = mutableSetOf<Long>()
private var membersLoadKey: String? = null
init { init {
activeChatTracker.setActiveChat(chatId) activeChatTracker.setActiveChat(chatId)
notificationDispatcher.clearChatNotifications(chatId)
handleRealtimeEventsUseCase.start() handleRealtimeEventsUseCase.start()
observeChatPermissions() observeChatPermissions()
observeMessages() observeMessages()
@@ -79,6 +94,127 @@ class ChatViewModel @Inject constructor(
_uiState.update { it.copy(inputText = value) } _uiState.update { it.copy(inputText = value) }
} }
fun onInlineSearchChanged(query: String) {
_uiState.update { state ->
val normalized = query.trim().lowercase()
if (normalized.isBlank()) {
state.copy(
inlineSearchQuery = query,
inlineSearchMatches = emptyList(),
highlightedMessageId = null,
)
} else {
val matches = state.messages
.filter { (it.text ?: "").lowercase().contains(normalized) }
.map { it.id }
state.copy(
inlineSearchQuery = query,
inlineSearchMatches = matches,
highlightedMessageId = matches.firstOrNull(),
)
}
}
}
fun jumpInlineSearch(next: Boolean) {
_uiState.update { state ->
val matches = state.inlineSearchMatches
if (matches.isEmpty()) return@update state
val current = state.highlightedMessageId
val index = matches.indexOf(current).takeIf { it >= 0 } ?: 0
val nextIndex = if (next) {
(index + 1) % matches.size
} else {
if (index == 0) matches.lastIndex else index - 1
}
state.copy(highlightedMessageId = matches[nextIndex])
}
}
fun onVoiceRecordStarted() {
_uiState.update {
it.copy(
isRecordingVoice = true,
isVoiceLocked = false,
voiceRecordingDurationMs = 0L,
voiceRecordingHint = context.getString(R.string.chat_voice_hint_slide),
errorMessage = null,
)
}
}
fun onVoiceRecordTick(durationMs: Long) {
_uiState.update {
if (!it.isRecordingVoice) it else it.copy(voiceRecordingDurationMs = durationMs)
}
}
fun onVoiceRecordLocked() {
_uiState.update {
if (!it.isRecordingVoice) it else {
it.copy(
isVoiceLocked = true,
voiceRecordingHint = context.getString(R.string.chat_voice_hint_locked),
)
}
}
}
fun onVoiceRecordCancelled() {
_uiState.update {
it.copy(
isRecordingVoice = false,
isVoiceLocked = false,
voiceRecordingDurationMs = 0L,
voiceRecordingHint = null,
)
}
}
fun onVoiceRecordFinish(
fileName: String,
mimeType: String,
bytes: ByteArray,
durationMs: Long,
) {
if (durationMs < MIN_VOICE_DURATION_MS) {
_uiState.update {
it.copy(
isRecordingVoice = false,
isVoiceLocked = false,
voiceRecordingDurationMs = 0L,
voiceRecordingHint = null,
errorMessage = context.getString(R.string.chat_error_voice_too_short),
)
}
return
}
_uiState.update {
it.copy(
isRecordingVoice = false,
isVoiceLocked = false,
voiceRecordingDurationMs = 0L,
voiceRecordingHint = null,
)
}
onMediaPicked(
fileName = fileName,
mimeType = mimeType,
bytes = bytes,
)
}
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?) { fun onSelectMessage(message: MessageItem?) {
if (message == null) { if (message == null) {
onClearSelection() onClearSelection()
@@ -194,7 +330,7 @@ class ChatViewModel @Inject constructor(
val actionState = uiState.value.actionState val actionState = uiState.value.actionState
if (actionState.mode == MessageSelectionMode.MULTI) { if (actionState.mode == MessageSelectionMode.MULTI) {
if (forAll) { 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 return
} }
val selectedIds = actionState.selectedMessageIds.toList().sorted() val selectedIds = actionState.selectedMessageIds.toList().sorted()
@@ -222,7 +358,7 @@ class ChatViewModel @Inject constructor(
} }
val selected = getFocusedSelectedMessage() ?: return val selected = getFocusedSelectedMessage() ?: return
if (forAll && !canDeleteForAll(selected)) { 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 return
} }
viewModelScope.launch { viewModelScope.launch {
@@ -319,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() { fun onSendClick() {
val text = uiState.value.inputText.trim() val text = uiState.value.inputText.trim()
if (text.isBlank()) return if (text.isBlank()) return
@@ -330,7 +564,7 @@ class ChatViewModel @Inject constructor(
_uiState.update { _uiState.update {
it.copy( it.copy(
isSending = false, isSending = false,
errorMessage = "This message can no longer be edited.", errorMessage = context.getString(R.string.chat_error_edit_expired),
) )
} }
return@launch return@launch
@@ -342,7 +576,8 @@ class ChatViewModel @Inject constructor(
_uiState.update { _uiState.update {
it.copy( it.copy(
isSending = false, 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 return@launch
@@ -416,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() { fun loadMore() {
val oldest = uiState.value.messages.firstOrNull() ?: return val oldest = uiState.value.messages.firstOrNull() ?: return
viewModelScope.launch { viewModelScope.launch {
@@ -438,15 +704,32 @@ class ChatViewModel @Inject constructor(
visibleMessagesLimit visibleMessagesLimit
.flatMapLatest { limit -> observeMessagesUseCase(chatId = chatId, limit = limit) } .flatMapLatest { limit -> observeMessagesUseCase(chatId = chatId, limit = limit) }
.collectLatest { messages -> .collectLatest { messages ->
val sortedMessages = messages.sortedBy { msg -> msg.id }
_uiState.update { _uiState.update {
val pinnedId = it.pinnedMessageId val pinnedId = it.pinnedMessageId
val normalized = it.inlineSearchQuery.trim().lowercase()
val inlineMatches = if (normalized.isBlank()) {
emptyList()
} else {
sortedMessages
.filter { msg -> (msg.text ?: "").lowercase().contains(normalized) }
.map { msg -> msg.id }
}
val highlighted = if (inlineMatches.contains(it.highlightedMessageId)) {
it.highlightedMessageId
} else {
inlineMatches.firstOrNull()
}
it.copy( it.copy(
isLoading = false, isLoading = false,
messages = messages.sortedBy { msg -> msg.id }, messages = sortedMessages,
pinnedMessage = pinnedId?.let { id -> messages.firstOrNull { msg -> msg.id == id } }, pinnedMessage = pinnedId?.let { id -> sortedMessages.firstOrNull { msg -> msg.id == id } },
inlineSearchMatches = inlineMatches,
highlightedMessageId = highlighted,
) )
} }
acknowledgeLatestIncoming(messages) preloadReactions(sortedMessages)
acknowledgeLatestMessages(sortedMessages)
} }
} }
} }
@@ -463,14 +746,18 @@ class ChatViewModel @Inject constructor(
val restriction = if (canSend) { val restriction = if (canSend) {
null null
} else { } 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 { val chatSubtitle = when {
chat.type.equals("private", ignoreCase = true) && chat.counterpartIsOnline == true -> "online" chat.type.equals("private", ignoreCase = true) && chat.counterpartIsOnline == true ->
chat.type.equals("private", ignoreCase = true) && !chat.counterpartLastSeenAt.isNullOrBlank() -> "last seen recently" context.getString(R.string.chat_status_online)
chat.type.equals("group", ignoreCase = true) -> "group" chat.type.equals("private", ignoreCase = true) && !chat.counterpartLastSeenAt.isNullOrBlank() ->
chat.type.equals("channel", ignoreCase = true) -> "channel" 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 -> "" else -> ""
} }
_uiState.update { _uiState.update {
@@ -478,15 +765,56 @@ class ChatViewModel @Inject constructor(
it.messages.firstOrNull { message -> message.id == pinnedId } it.messages.firstOrNull { message -> message.id == pinnedId }
} }
it.copy( it.copy(
chatType = chat.type,
chatRole = role,
chatMuted = chat.muted,
chatTitle = chatTitle, chatTitle = chatTitle,
chatSubtitle = chatSubtitle, chatSubtitle = chatSubtitle,
chatAvatarUrl = chat.avatarUrl ?: chat.counterpartAvatarUrl, chatAvatarUrl = chat.avatarUrl ?: chat.counterpartAvatarUrl,
chatUnreadCount = chat.unreadCount.coerceAtLeast(0),
canManageMembers = role == "owner" || role == "admin",
canSendMessages = canSend, canSendMessages = canSend,
sendRestrictionText = restriction, sendRestrictionText = restriction,
pinnedMessageId = chat.pinnedMessageId, pinnedMessageId = chat.pinnedMessageId,
pinnedMessage = pinnedMessage, 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,
)
} }
} }
} }
@@ -494,11 +822,13 @@ class ChatViewModel @Inject constructor(
private fun refresh() { private fun refresh() {
viewModelScope.launch { viewModelScope.launch {
_uiState.update { it.copy(isLoading = true, errorMessage = null) } _uiState.update { it.copy(isLoading = true, errorMessage = null) }
val selfUserId = tokenRepository.getActiveUserId()
when (val result = syncRecentMessagesUseCase(chatId = chatId)) { 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 { is AppResult.Error -> _uiState.update {
it.copy( it.copy(
isLoading = false, isLoading = false,
selfUserId = selfUserId,
errorMessage = result.reason.toUiMessage(), errorMessage = result.reason.toUiMessage(),
) )
} }
@@ -507,6 +837,7 @@ class ChatViewModel @Inject constructor(
} }
private fun loadReactions(messageId: Long) { private fun loadReactions(messageId: Long) {
if (messageId <= 0L) return
viewModelScope.launch { viewModelScope.launch {
when (val result = listMessageReactionsUseCase(messageId = messageId)) { when (val result = listMessageReactionsUseCase(messageId = messageId)) {
is AppResult.Success -> { is AppResult.Success -> {
@@ -519,24 +850,38 @@ class ChatViewModel @Inject constructor(
} }
} }
private fun acknowledgeLatestIncoming(messages: List<MessageItem>) { private fun preloadReactions(messages: List<MessageItem>) {
val latestIncoming = messages val toRequest = messages
.asReversed() .asReversed()
.firstOrNull { !it.isOutgoing } .map { it.id }
?: return .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 lastDeliveredMessageId = latestIncoming.id
viewModelScope.launch { viewModelScope.launch {
markMessageDeliveredUseCase(chatId = chatId, messageId = latestIncoming.id) 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 { fun canEdit(message: MessageItem): Boolean {
@@ -588,23 +933,62 @@ class ChatViewModel @Inject constructor(
return uiState.value.messages.firstOrNull { it.id == messageId } 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 { private fun AppError.toUiMessage(): String {
return when (this) { return when (this) {
AppError.Network -> "Network error." AppError.Network -> context.getString(R.string.error_network)
AppError.Unauthorized -> "Session expired." AppError.Unauthorized -> context.getString(R.string.error_session_expired)
AppError.InvalidCredentials -> "Authorization error." AppError.InvalidCredentials -> context.getString(R.string.error_authorization)
is AppError.Server -> "Server error." is AppError.Server -> context.getString(R.string.error_server)
is AppError.Unknown -> "Unknown error." is AppError.Unknown -> context.getString(R.string.error_unknown)
} }
} }
override fun onCleared() { override fun onCleared() {
activeChatTracker.clearActiveChat(chatId) activeChatTracker.clearActiveChat(chatId)
handleRealtimeEventsUseCase.stop()
super.onCleared() super.onCleared()
} }
private companion object { private companion object {
const val MESSAGES_PAGE_SIZE = 50 const val MESSAGES_PAGE_SIZE = 50
const val MIN_VOICE_DURATION_MS = 1_000L
} }
} }

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.MessageItem
import ru.daemonlord.messenger.domain.message.model.MessageReaction 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( data class MessageUiState(
val chatId: Long = 0L, val chatId: Long = 0L,
val selfUserId: Long? = null,
val isLoading: Boolean = true, val isLoading: Boolean = true,
val isLoadingMore: Boolean = false, val isLoadingMore: Boolean = false,
val isSending: Boolean = false, val isSending: Boolean = false,
@@ -13,6 +16,13 @@ data class MessageUiState(
val chatTitle: String = "", val chatTitle: String = "",
val chatSubtitle: String = "", val chatSubtitle: String = "",
val chatAvatarUrl: String? = null, 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 messages: List<MessageItem> = emptyList(),
val pinnedMessageId: Long? = null, val pinnedMessageId: Long? = null,
val pinnedMessage: MessageItem? = null, val pinnedMessage: MessageItem? = null,
@@ -28,7 +38,15 @@ data class MessageUiState(
val isForwarding: Boolean = false, val isForwarding: Boolean = false,
val canSendMessages: Boolean = true, val canSendMessages: Boolean = true,
val sendRestrictionText: String? = null, val sendRestrictionText: String? = null,
val isRecordingVoice: Boolean = false,
val isVoiceLocked: Boolean = false,
val voiceRecordingDurationMs: Long = 0L,
val voiceRecordingHint: String? = null,
val inlineSearchQuery: String = "",
val inlineSearchMatches: List<Long> = emptyList(),
val highlightedMessageId: Long? = null,
val actionState: MessageActionState = MessageActionState(), val actionState: MessageActionState = MessageActionState(),
val chatDeletedNonce: Long = 0L,
) )
data class ForwardTargetUiModel( data class ForwardTargetUiModel(

View File

@@ -0,0 +1,74 @@
package ru.daemonlord.messenger.ui.chat.voice
import android.content.Context
import android.media.MediaRecorder
import java.io.File
import java.util.UUID
class VoiceRecorder(private val context: Context) {
private var recorder: MediaRecorder? = null
private var outputFile: File? = null
private var startedAtMillis: Long = 0L
fun start(): Boolean {
return runCatching {
val file = File(context.cacheDir, "voice_${UUID.randomUUID()}.m4a")
val mediaRecorder = MediaRecorder().apply {
setAudioSource(MediaRecorder.AudioSource.MIC)
setOutputFormat(MediaRecorder.OutputFormat.MPEG_4)
setAudioEncoder(MediaRecorder.AudioEncoder.AAC)
setAudioChannels(1)
setAudioEncodingBitRate(64_000)
setAudioSamplingRate(44_100)
setOutputFile(file.absolutePath)
prepare()
start()
}
recorder = mediaRecorder
outputFile = file
startedAtMillis = System.currentTimeMillis()
true
}.getOrElse {
releaseInternal(deleteFile = true)
false
}
}
fun elapsedMillis(nowMillis: Long = System.currentTimeMillis()): Long {
if (startedAtMillis <= 0L) return 0L
return (nowMillis - startedAtMillis).coerceAtLeast(0L)
}
fun stopAndReadBytes(): ByteArray? {
val file = outputFile ?: return null
val success = runCatching {
recorder?.stop()
true
}.getOrDefault(false)
releaseInternal(deleteFile = false)
if (!success || !file.exists()) {
file.delete()
return null
}
return runCatching {
val bytes = file.readBytes()
file.delete()
bytes
}.getOrNull()
}
fun cancel() {
releaseInternal(deleteFile = true)
}
private fun releaseInternal(deleteFile: Boolean) {
runCatching { recorder?.reset() }
runCatching { recorder?.release() }
recorder = null
startedAtMillis = 0L
if (deleteFile) {
outputFile?.delete()
}
outputFile = null
}
}

View File

@@ -1,6 +1,11 @@
package ru.daemonlord.messenger.ui.chats package ru.daemonlord.messenger.ui.chats
import ru.daemonlord.messenger.domain.chat.model.ChatItem import ru.daemonlord.messenger.domain.chat.model.ChatItem
import ru.daemonlord.messenger.domain.chat.model.ChatBanItem
import ru.daemonlord.messenger.domain.chat.model.ChatMemberItem
import ru.daemonlord.messenger.domain.chat.model.DiscoverChatItem
import ru.daemonlord.messenger.domain.account.model.UserSearchItem
import ru.daemonlord.messenger.domain.message.model.MessageItem
data class ChatListUiState( data class ChatListUiState(
val selectedTab: ChatTab = ChatTab.ALL, val selectedTab: ChatTab = ChatTab.ALL,
@@ -8,10 +13,23 @@ data class ChatListUiState(
val searchQuery: String = "", val searchQuery: String = "",
val isLoading: Boolean = true, val isLoading: Boolean = true,
val isRefreshing: Boolean = false, val isRefreshing: Boolean = false,
val isConnecting: Boolean = false,
val errorMessage: String? = null, val errorMessage: String? = null,
val chats: List<ChatItem> = emptyList(), val chats: List<ChatItem> = emptyList(),
val searchHistoryChats: List<ChatItem> = emptyList(),
val searchRecentChats: List<ChatItem> = emptyList(),
val archivedChatsCount: Int = 0, val archivedChatsCount: Int = 0,
val archivedUnreadCount: Int = 0, val archivedUnreadCount: Int = 0,
val isJoiningInvite: Boolean = false, val isJoiningInvite: Boolean = false,
val pendingOpenChatId: Long? = null, val pendingOpenChatId: Long? = null,
val discoverChats: List<DiscoverChatItem> = emptyList(),
val globalChats: List<DiscoverChatItem> = emptyList(),
val selectedManageChatId: Long? = null,
val members: List<ChatMemberItem> = emptyList(),
val bans: List<ChatBanItem> = emptyList(),
val globalUsers: List<UserSearchItem> = emptyList(),
val globalMessages: List<MessageItem> = emptyList(),
val globalSearchQuery: String = "",
val isManagementLoading: Boolean = false,
val managementMessage: String? = null,
) )

View File

@@ -1,8 +1,12 @@
package ru.daemonlord.messenger.ui.chats package ru.daemonlord.messenger.ui.chats
import android.content.Context
import androidx.appcompat.app.AppCompatDelegate
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
@@ -13,31 +17,50 @@ import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import ru.daemonlord.messenger.domain.chat.model.ChatItem import ru.daemonlord.messenger.domain.chat.model.ChatItem
import ru.daemonlord.messenger.domain.chat.repository.ChatRepository
import ru.daemonlord.messenger.domain.chat.repository.ChatSearchRepository
import ru.daemonlord.messenger.domain.chat.usecase.JoinByInviteUseCase import ru.daemonlord.messenger.domain.chat.usecase.JoinByInviteUseCase
import ru.daemonlord.messenger.domain.chat.usecase.ObserveChatsUseCase import ru.daemonlord.messenger.domain.chat.usecase.ObserveChatsUseCase
import ru.daemonlord.messenger.domain.chat.usecase.RefreshChatsUseCase import ru.daemonlord.messenger.domain.chat.usecase.RefreshChatsUseCase
import ru.daemonlord.messenger.domain.common.AppError import ru.daemonlord.messenger.domain.common.AppError
import ru.daemonlord.messenger.domain.common.AppResult 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.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 import javax.inject.Inject
@HiltViewModel @HiltViewModel
@OptIn(ExperimentalCoroutinesApi::class)
class ChatListViewModel @Inject constructor( class ChatListViewModel @Inject constructor(
private val observeChatsUseCase: ObserveChatsUseCase, private val observeChatsUseCase: ObserveChatsUseCase,
private val refreshChatsUseCase: RefreshChatsUseCase, private val refreshChatsUseCase: RefreshChatsUseCase,
private val joinByInviteUseCase: JoinByInviteUseCase, private val joinByInviteUseCase: JoinByInviteUseCase,
private val handleRealtimeEventsUseCase: HandleRealtimeEventsUseCase, private val handleRealtimeEventsUseCase: HandleRealtimeEventsUseCase,
private val realtimeManager: RealtimeManager,
private val chatRepository: ChatRepository,
private val chatSearchRepository: ChatSearchRepository,
private val searchRepository: SearchRepository,
private val themeRepository: ThemeRepository,
@ApplicationContext private val context: Context,
) : ViewModel() { ) : ViewModel() {
private val selectedTab = MutableStateFlow(ChatTab.ALL) private val selectedTab = MutableStateFlow(ChatTab.ALL)
private val selectedFilter = MutableStateFlow(ChatListFilter.ALL) private val selectedFilter = MutableStateFlow(ChatListFilter.ALL)
private val searchQuery = MutableStateFlow("") private val searchQuery = MutableStateFlow("")
private val searchHistoryIds = MutableStateFlow<List<Long>>(emptyList())
private val searchRecentIds = MutableStateFlow<List<Long>>(emptyList())
private var lastHandledInviteToken: String? = null private var lastHandledInviteToken: String? = null
private val _uiState = MutableStateFlow(ChatListUiState()) private val _uiState = MutableStateFlow(ChatListUiState())
val uiState: StateFlow<ChatListUiState> = _uiState.asStateFlow() val uiState: StateFlow<ChatListUiState> = _uiState.asStateFlow()
init { init {
handleRealtimeEventsUseCase.start() handleRealtimeEventsUseCase.start()
observeSearchStore()
observeConnectionState()
observeChatStream() observeChatStream()
} }
@@ -101,6 +124,381 @@ class ChatListViewModel @Inject constructor(
_uiState.update { it.copy(pendingOpenChatId = null) } _uiState.update { it.copy(pendingOpenChatId = null) }
} }
fun onGlobalSearchChanged(value: String) {
_uiState.update { it.copy(globalSearchQuery = value) }
val normalized = value.trim()
if (normalized.length < 2) {
_uiState.update { it.copy(globalUsers = emptyList(), globalChats = emptyList(), globalMessages = emptyList()) }
return
}
viewModelScope.launch {
when (val result = searchRepository.globalSearch(query = normalized, usersLimit = 10, chatsLimit = 10, messagesLimit = 20)) {
is AppResult.Success -> _uiState.update {
it.copy(
globalUsers = result.data.users,
globalChats = result.data.chats,
globalMessages = result.data.messages,
)
}
is AppResult.Error -> _uiState.update {
it.copy(
globalUsers = emptyList(),
globalChats = emptyList(),
globalMessages = emptyList(),
errorMessage = result.reason.toUiMessage(),
)
}
}
}
}
fun onSearchResultOpened(chatId: Long) {
viewModelScope.launch {
chatSearchRepository.addHistoryChat(chatId)
chatSearchRepository.addRecentChat(chatId)
}
}
fun clearSearchHistory() {
viewModelScope.launch {
chatSearchRepository.clearHistory()
}
}
fun openSavedChat() {
viewModelScope.launch {
when (val result = chatRepository.getSavedChat()) {
is AppResult.Success -> {
_uiState.update { it.copy(pendingOpenChatId = result.data.id) }
}
is AppResult.Error -> _uiState.update {
it.copy(errorMessage = result.reason.toUiMessage())
}
}
}
}
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) {
loadMembersAndBans(chatId)
}
}
fun discoverChats(query: String?) {
viewModelScope.launch {
_uiState.update { it.copy(isManagementLoading = true, errorMessage = null) }
when (val result = chatRepository.discoverChats(query = query?.trim()?.ifBlank { null })) {
is AppResult.Success -> _uiState.update {
it.copy(
isManagementLoading = false,
discoverChats = result.data,
)
}
is AppResult.Error -> _uiState.update {
it.copy(
isManagementLoading = false,
errorMessage = result.reason.toUiMessage(),
)
}
}
}
}
fun createGroup(title: String, memberIds: List<Long>) {
createChatInternal(
type = "group",
title = title,
isPublic = false,
handle = null,
description = null,
memberIds = memberIds,
successMessageResId = R.string.chat_list_info_group_created,
)
}
fun createChannel(title: String, handle: String, description: String?) {
createChatInternal(
type = "channel",
title = title,
isPublic = true,
handle = handle,
description = description,
memberIds = emptyList(),
successMessageResId = R.string.chat_list_info_channel_created,
)
}
fun joinChat(chatId: Long) {
viewModelScope.launch {
when (val result = chatRepository.joinChat(chatId = chatId)) {
is AppResult.Success -> {
_uiState.update {
it.copy(
managementMessage = context.getString(R.string.chat_list_info_joined_chat),
pendingOpenChatId = result.data.id,
)
}
refreshCurrentTab(forceRefresh = true)
}
is AppResult.Error -> _uiState.update { it.copy(errorMessage = result.reason.toUiMessage()) }
}
}
}
fun leaveChat(chatId: Long) {
viewModelScope.launch {
when (val result = chatRepository.leaveChat(chatId = chatId)) {
is AppResult.Success -> {
_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()) }
}
}
}
fun archiveChat(chatId: Long) {
viewModelScope.launch {
when (val result = chatRepository.archiveChat(chatId = chatId)) {
is AppResult.Success -> {
_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()) }
}
}
}
fun unarchiveChat(chatId: Long) {
viewModelScope.launch {
when (val result = chatRepository.unarchiveChat(chatId = chatId)) {
is AppResult.Success -> {
_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()) }
}
}
}
fun pinChat(chatId: Long) {
viewModelScope.launch {
when (val result = chatRepository.pinChat(chatId = chatId)) {
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()) }
}
}
}
fun unpinChat(chatId: Long) {
viewModelScope.launch {
when (val result = chatRepository.unpinChat(chatId = chatId)) {
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()) }
}
}
}
fun clearChat(chatId: Long) {
viewModelScope.launch {
when (val result = chatRepository.clearChat(chatId = chatId)) {
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()) }
}
}
}
fun updateChatTitle(chatId: Long, title: String) {
val normalized = title.trim()
if (normalized.isBlank()) {
_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 = context.getString(R.string.chat_list_info_title_updated)) }
is AppResult.Error -> _uiState.update { it.copy(errorMessage = result.reason.toUiMessage()) }
}
}
}
fun updateChatProfile(chatId: Long, title: String?, description: String?) {
val normalizedTitle = title?.trim()?.ifBlank { null }
val normalizedDescription = description?.trim()?.ifBlank { null }
if (normalizedTitle == null && normalizedDescription == null) {
_uiState.update { it.copy(errorMessage = context.getString(R.string.chat_list_error_title_or_description_required)) }
return
}
viewModelScope.launch {
when (
val result = chatRepository.updateChatProfile(
chatId = chatId,
title = normalizedTitle,
description = normalizedDescription,
avatarUrl = null,
)
) {
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()) }
}
}
}
fun deleteChatForMe(chatId: Long) {
viewModelScope.launch {
when (val result = chatRepository.removeChat(chatId = chatId, forAll = false)) {
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()) }
}
}
}
fun toggleChatMute(chatId: Long) {
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(
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()) }
}
}
is AppResult.Error -> _uiState.update { it.copy(errorMessage = current.reason.toUiMessage()) }
}
}
}
fun createInvite(chatId: Long) {
viewModelScope.launch {
when (val result = chatRepository.createInviteLink(chatId = chatId)) {
is AppResult.Success -> _uiState.update {
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()) }
}
}
}
fun addMember(chatId: Long, userId: Long) {
viewModelScope.launch {
when (val result = chatRepository.addMember(chatId = chatId, userId = userId)) {
is AppResult.Success -> {
_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()) }
}
}
}
fun updateMemberRole(chatId: Long, userId: Long, role: String) {
viewModelScope.launch {
when (val result = chatRepository.updateMemberRole(chatId = chatId, userId = userId, role = role)) {
is AppResult.Success -> {
_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()) }
}
}
}
fun removeMember(chatId: Long, userId: Long) {
viewModelScope.launch {
when (val result = chatRepository.removeMember(chatId = chatId, userId = userId)) {
is AppResult.Success -> {
_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()) }
}
}
}
fun banMember(chatId: Long, userId: Long) {
viewModelScope.launch {
when (val result = chatRepository.banMember(chatId = chatId, userId = userId)) {
is AppResult.Success -> {
_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()) }
}
}
}
fun unbanMember(chatId: Long, userId: Long) {
viewModelScope.launch {
when (val result = chatRepository.unbanMember(chatId = chatId, userId = userId)) {
is AppResult.Success -> {
_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()) }
}
}
}
private fun observeChatStream() { private fun observeChatStream() {
viewModelScope.launch { viewModelScope.launch {
val archiveStatsFlow = observeChatsUseCase(archived = true) val archiveStatsFlow = observeChatsUseCase(archived = true)
@@ -117,24 +515,126 @@ class ChatListViewModel @Inject constructor(
.combine(selectedFilter) { (chats, query), filter -> .combine(selectedFilter) { (chats, query), filter ->
chats.filterByQueryAndType(query = query, filter = filter) chats.filterByQueryAndType(query = query, filter = filter)
} }
.combine(searchHistoryIds) { filtered, historyIds ->
filtered to historyIds
}
.combine(searchRecentIds) { (filtered, historyIds), recentIds ->
Triple(filtered, historyIds, recentIds)
}
.combine(archiveStatsFlow) { filtered, stats -> .combine(archiveStatsFlow) { filtered, stats ->
filtered to stats filtered to stats
} }
.collectLatest { (filtered, stats) -> .collectLatest { (filteredWithSearch, stats) ->
val filtered = filteredWithSearch.first
val historyIds = filteredWithSearch.second
val recentIds = filteredWithSearch.third
val byId = filtered.associateBy { it.id }
val historyChats = historyIds.mapNotNull(byId::get)
val recentChats = recentIds.mapNotNull(byId::get)
_uiState.update { _uiState.update {
it.copy( it.copy(
isLoading = false, isLoading = false,
isRefreshing = false, isRefreshing = false,
errorMessage = null, errorMessage = null,
chats = filtered, chats = filtered,
searchHistoryChats = historyChats,
searchRecentChats = recentChats,
archivedChatsCount = stats.first, archivedChatsCount = stats.first,
archivedUnreadCount = stats.second, archivedUnreadCount = stats.second,
managementMessage = null,
) )
} }
} }
} }
} }
private fun observeConnectionState() {
viewModelScope.launch {
realtimeManager.connectionState.collectLatest { state ->
_uiState.update {
it.copy(
isConnecting = state == RealtimeConnectionState.Connecting ||
state == RealtimeConnectionState.Reconnecting,
)
}
}
}
}
private fun observeSearchStore() {
viewModelScope.launch {
chatSearchRepository.observeHistoryChatIds().collectLatest { ids ->
searchHistoryIds.value = ids
}
}
viewModelScope.launch {
chatSearchRepository.observeRecentChatIds().collectLatest { ids ->
searchRecentIds.value = ids
}
}
}
private fun createChatInternal(
type: String,
title: String,
isPublic: Boolean,
handle: String?,
description: String?,
memberIds: List<Long>,
successMessageResId: Int,
) {
val normalizedTitle = title.trim()
if (normalizedTitle.isBlank()) {
_uiState.update { it.copy(errorMessage = context.getString(R.string.chat_list_error_title_required)) }
return
}
viewModelScope.launch {
when (
val result = chatRepository.createChat(
type = type,
title = normalizedTitle,
isPublic = isPublic,
handle = handle?.trim()?.ifBlank { null },
description = description?.trim()?.ifBlank { null },
memberIds = memberIds,
)
) {
is AppResult.Success -> {
_uiState.update {
it.copy(
managementMessage = context.getString(successMessageResId),
pendingOpenChatId = result.data.id,
)
}
refreshCurrentTab(forceRefresh = true)
}
is AppResult.Error -> _uiState.update {
it.copy(errorMessage = result.reason.toUiMessage())
}
}
}
}
private fun loadMembersAndBans(chatId: Long) {
viewModelScope.launch {
_uiState.update { it.copy(isManagementLoading = true, errorMessage = null) }
val membersResult = chatRepository.listMembers(chatId = chatId)
val bansResult = chatRepository.listBans(chatId = chatId)
_uiState.update {
it.copy(
isManagementLoading = false,
members = (membersResult as? AppResult.Success)?.data ?: emptyList(),
bans = (bansResult as? AppResult.Success)?.data ?: emptyList(),
errorMessage = listOf(membersResult, bansResult)
.filterIsInstance<AppResult.Error>()
.firstOrNull()
?.reason
?.toUiMessage(),
)
}
}
}
private fun refreshCurrentTab(forceRefresh: Boolean = false) { private fun refreshCurrentTab(forceRefresh: Boolean = false) {
viewModelScope.launch { viewModelScope.launch {
_uiState.update { _uiState.update {
@@ -174,16 +674,15 @@ class ChatListViewModel @Inject constructor(
private fun AppError.toUiMessage(): String { private fun AppError.toUiMessage(): String {
return when (this) { return when (this) {
AppError.Network -> "Network error while syncing chats." AppError.Network -> context.getString(R.string.chat_list_error_network_sync)
AppError.Unauthorized -> "Session expired. Please log in again." AppError.Unauthorized -> context.getString(R.string.chat_list_error_session_expired)
AppError.InvalidCredentials -> "Authorization failed." AppError.InvalidCredentials -> context.getString(R.string.chat_list_error_authorization_failed)
is AppError.Server -> "Server error while loading chats." is AppError.Server -> context.getString(R.string.chat_list_error_server_loading)
is AppError.Unknown -> "Unknown error while loading chats." is AppError.Unknown -> context.getString(R.string.chat_list_error_unknown_loading)
} }
} }
override fun onCleared() { override fun onCleared() {
handleRealtimeEventsUseCase.stop()
super.onCleared() super.onCleared()
} }
} }

View File

@@ -0,0 +1,257 @@
package ru.daemonlord.messenger.ui.contacts
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.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawing
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.pulltorefresh.PullToRefreshBox
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
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(
onMainBarVisibilityChanged: (Boolean) -> Unit,
viewModel: ContactsViewModel = hiltViewModel(),
) {
val state by viewModel.uiState.collectAsStateWithLifecycle()
ContactsScreen(
state = state,
onMainBarVisibilityChanged = onMainBarVisibilityChanged,
onQueryChanged = viewModel::onQueryChanged,
onAddByEmailChanged = viewModel::onAddByEmailChanged,
onRefresh = viewModel::onRefresh,
onAddContact = viewModel::addContact,
onAddContactByEmail = viewModel::addContactByEmail,
onRemoveContact = viewModel::removeContact,
)
}
@Composable
@OptIn(ExperimentalMaterial3Api::class)
private fun ContactsScreen(
state: ContactsUiState,
onMainBarVisibilityChanged: (Boolean) -> Unit,
onQueryChanged: (String) -> Unit,
onAddByEmailChanged: (String) -> Unit,
onRefresh: () -> Unit,
onAddContact: (Long) -> Unit,
onAddContactByEmail: () -> Unit,
onRemoveContact: (Long) -> Unit,
) {
val listState = rememberLazyListState()
val isTabletLayout = LocalConfiguration.current.screenWidthDp >= 840
LaunchedEffect(Unit) {
onMainBarVisibilityChanged(true)
}
LaunchedEffect(listState) {
var prevIndex = 0
var prevOffset = 0
snapshotFlow { listState.firstVisibleItemIndex to listState.firstVisibleItemScrollOffset }
.collectLatest { (index, offset) ->
val movedDown = index > prevIndex || (index == prevIndex && offset > prevOffset)
val movedUp = index < prevIndex || (index == prevIndex && offset < prevOffset)
when {
index == 0 && offset == 0 -> onMainBarVisibilityChanged(true)
movedDown -> onMainBarVisibilityChanged(false)
movedUp -> onMainBarVisibilityChanged(true)
}
prevIndex = index
prevOffset = offset
}
}
Box(
modifier = Modifier
.fillMaxSize()
.windowInsetsPadding(WindowInsets.safeDrawing),
contentAlignment = Alignment.TopCenter,
) {
Column(
modifier = Modifier
.fillMaxWidth()
.then(if (isTabletLayout) Modifier.widthIn(max = 820.dp) else Modifier)
.padding(bottom = 96.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
TopAppBar(
title = { Text(stringResource(id = R.string.contacts_title)) },
)
PullToRefreshBox(
isRefreshing = state.isRefreshing,
onRefresh = onRefresh,
modifier = Modifier.fillMaxSize(),
) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(horizontal = 16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
OutlinedTextField(
value = state.query,
onValueChange = onQueryChanged,
modifier = Modifier.fillMaxWidth(),
singleLine = true,
label = { Text(stringResource(id = R.string.contacts_search_label)) },
)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
OutlinedTextField(
value = state.addByEmail,
onValueChange = onAddByEmailChanged,
modifier = Modifier.weight(1f),
singleLine = true,
label = { Text(stringResource(id = R.string.contacts_add_by_email_label)) },
)
Button(onClick = onAddContactByEmail) {
Text(stringResource(id = R.string.common_create))
}
}
state.errorMessage?.let {
Text(
text = it,
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodyMedium,
)
}
state.infoMessage?.let {
Text(
text = it,
color = MaterialTheme.colorScheme.primary,
style = MaterialTheme.typography.bodyMedium,
)
}
if (state.isLoading) {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center,
) {
CircularProgressIndicator()
}
} else {
LazyColumn(
modifier = Modifier.fillMaxSize(),
state = listState,
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
if (state.isSearchingUsers || state.query.trim().length >= 2) {
item(key = "search_header") {
Text(
text = stringResource(id = R.string.contacts_search_results),
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold,
)
}
items(state.searchResults, key = { "search_${it.id}" }) { user ->
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = user.name,
fontWeight = FontWeight.SemiBold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Text(
text = user.username?.let { "@$it" } ?: "id: ${user.id}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
OutlinedButton(onClick = { onAddContact(user.id) }) {
Text(stringResource(id = R.string.common_create))
}
}
}
}
item(key = "contacts_header") {
Text(
text = stringResource(id = R.string.contacts_my_contacts),
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold,
modifier = Modifier.padding(top = 4.dp),
)
}
items(state.contacts, key = { "contact_${it.id}" }) { contact ->
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = contact.name,
fontWeight = FontWeight.SemiBold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Text(
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(stringResource(id = R.string.contacts_remove))
}
}
}
if (state.contacts.isEmpty()) {
item(key = "empty_contacts") {
Text(
text = stringResource(id = R.string.contacts_empty),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
}
}
}
}
}

View File

@@ -0,0 +1,15 @@
package ru.daemonlord.messenger.ui.contacts
import ru.daemonlord.messenger.domain.account.model.UserSearchItem
data class ContactsUiState(
val isLoading: Boolean = true,
val isRefreshing: Boolean = false,
val isSearchingUsers: Boolean = false,
val query: String = "",
val addByEmail: String = "",
val contacts: List<UserSearchItem> = emptyList(),
val searchResults: List<UserSearchItem> = emptyList(),
val errorMessage: String? = null,
val infoMessage: String? = null,
)

View File

@@ -0,0 +1,162 @@
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
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
import javax.inject.Inject
@HiltViewModel
class ContactsViewModel @Inject constructor(
private val accountRepository: AccountRepository,
@ApplicationContext private val context: Context,
) : ViewModel() {
private val _uiState = MutableStateFlow(ContactsUiState())
val uiState: StateFlow<ContactsUiState> = _uiState.asStateFlow()
private var searchJob: Job? = null
init {
loadContacts()
}
fun onQueryChanged(value: String) {
_uiState.update {
it.copy(
query = value,
errorMessage = null,
infoMessage = null,
)
}
val normalized = value.trim()
if (normalized.length < 2) {
searchJob?.cancel()
_uiState.update { it.copy(searchResults = emptyList(), isSearchingUsers = false) }
return
}
searchJob?.cancel()
searchJob = viewModelScope.launch {
_uiState.update { it.copy(isSearchingUsers = true) }
delay(250)
when (val result = accountRepository.searchUsers(query = normalized, limit = 20)) {
is AppResult.Success -> _uiState.update {
it.copy(
isSearchingUsers = false,
searchResults = result.data,
)
}
is AppResult.Error -> _uiState.update {
it.copy(
isSearchingUsers = false,
searchResults = emptyList(),
errorMessage = result.reason.toUiMessage(),
)
}
}
}
}
fun onAddByEmailChanged(value: String) {
_uiState.update { it.copy(addByEmail = value) }
}
fun onRefresh() {
loadContacts(forceRefresh = true)
}
fun addContact(userId: Long) {
viewModelScope.launch {
when (val result = accountRepository.addContact(userId = userId)) {
is AppResult.Success -> {
_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()) }
}
}
}
fun addContactByEmail() {
val email = uiState.value.addByEmail.trim()
if (email.isBlank()) {
_uiState.update { it.copy(errorMessage = context.getString(R.string.contacts_error_email_required)) }
return
}
viewModelScope.launch {
when (val result = accountRepository.addContactByEmail(email = email)) {
is AppResult.Success -> {
_uiState.update {
it.copy(
addByEmail = "",
infoMessage = context.getString(R.string.contacts_info_added_by_email),
)
}
loadContacts(forceRefresh = true)
}
is AppResult.Error -> _uiState.update { it.copy(errorMessage = result.reason.toUiMessage()) }
}
}
}
fun removeContact(userId: Long) {
viewModelScope.launch {
when (val result = accountRepository.removeContact(userId = userId)) {
is AppResult.Success -> {
_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()) }
}
}
}
private fun loadContacts(forceRefresh: Boolean = false) {
viewModelScope.launch {
_uiState.update {
it.copy(
isLoading = it.contacts.isEmpty(),
isRefreshing = forceRefresh,
errorMessage = null,
)
}
when (val result = accountRepository.listContacts()) {
is AppResult.Success -> _uiState.update {
it.copy(
isLoading = false,
isRefreshing = false,
contacts = result.data,
)
}
is AppResult.Error -> _uiState.update {
it.copy(
isLoading = false,
isRefreshing = false,
errorMessage = result.reason.toUiMessage(),
)
}
}
}
}
private fun AppError.toUiMessage(): String {
return when (this) {
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

@@ -6,44 +6,78 @@ import android.os.Build
import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.safeDrawing import androidx.compose.foundation.layout.safeDrawing
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.Chat
import androidx.compose.material.icons.filled.Contacts
import androidx.compose.material.icons.filled.Person
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material3.Icon
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.NavigationBar
import androidx.compose.material3.NavigationBarItem
import androidx.compose.material3.NavigationBarItemDefaults
import androidx.compose.material3.Surface
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
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.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import androidx.navigation.NavType import androidx.navigation.NavType
import androidx.navigation.NavGraph.Companion.findStartDestination import androidx.navigation.NavGraph.Companion.findStartDestination
import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.navigation.navArgument import androidx.navigation.navArgument
import androidx.navigation.NavHostController import androidx.navigation.NavHostController
import androidx.navigation.compose.NavHost import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable import androidx.navigation.compose.composable
import androidx.navigation.compose.navigation import androidx.navigation.compose.navigation
import androidx.navigation.compose.rememberNavController import androidx.navigation.compose.rememberNavController
import ru.daemonlord.messenger.R
import ru.daemonlord.messenger.ui.auth.AuthViewModel import ru.daemonlord.messenger.ui.auth.AuthViewModel
import ru.daemonlord.messenger.ui.auth.LoginScreen import ru.daemonlord.messenger.ui.auth.LoginScreen
import ru.daemonlord.messenger.ui.auth.reset.ResetPasswordRoute import ru.daemonlord.messenger.ui.auth.reset.ResetPasswordRoute
import ru.daemonlord.messenger.ui.auth.verify.VerifyEmailRoute import ru.daemonlord.messenger.ui.auth.verify.VerifyEmailRoute
import ru.daemonlord.messenger.ui.chat.ChatRoute import ru.daemonlord.messenger.ui.chat.ChatRoute
import ru.daemonlord.messenger.ui.chats.ChatListRoute import ru.daemonlord.messenger.ui.chats.ChatListRoute
import ru.daemonlord.messenger.ui.contacts.ContactsRoute
import ru.daemonlord.messenger.ui.profile.ProfileRoute import ru.daemonlord.messenger.ui.profile.ProfileRoute
import ru.daemonlord.messenger.ui.settings.SettingsRoute import ru.daemonlord.messenger.ui.settings.SettingsRoute
private object Routes { private object Routes {
const val Startup = "startup"
const val AuthGraph = "auth_graph" const val AuthGraph = "auth_graph"
const val Login = "login" const val Login = "login"
const val AddAccountLogin = "add_account_login"
const val VerifyEmail = "verify_email" const val VerifyEmail = "verify_email"
const val ResetPassword = "reset_password" const val ResetPassword = "reset_password"
const val Chats = "chats" const val Chats = "chats"
const val Contacts = "contacts"
const val Settings = "settings" const val Settings = "settings"
const val Profile = "profile" const val Profile = "profile"
const val Chat = "chat" const val Chat = "chat"
@@ -68,6 +102,13 @@ fun MessengerNavHost(
val notificationPermissionLauncher = rememberLauncherForActivityResult( val notificationPermissionLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.RequestPermission(), contract = ActivityResultContracts.RequestPermission(),
) {} ) {}
var isMainBarVisible by remember { mutableStateOf(true) }
val backStackEntry by navController.currentBackStackEntryAsState()
val currentRoute = backStackEntry?.destination?.route
val mainTabRoutes = remember {
setOf(Routes.Chats, Routes.Contacts, Routes.Settings, Routes.Profile)
}
val showMainBar = currentRoute in mainTabRoutes
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) return@LaunchedEffect if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) return@LaunchedEffect
@@ -127,27 +168,79 @@ fun MessengerNavHost(
} }
} }
Box(modifier = Modifier.fillMaxSize()) {
NavHost( NavHost(
navController = navController, navController = navController,
startDestination = Routes.AuthGraph, startDestination = Routes.Startup,
) { ) {
composable(route = Routes.Startup) {
SessionCheckingScreen()
}
navigation( navigation(
route = Routes.AuthGraph, route = Routes.AuthGraph,
startDestination = Routes.Login, startDestination = Routes.Login,
) { ) {
composable(route = Routes.Login) { composable(route = Routes.Login) {
if (uiState.isCheckingSession) {
SessionCheckingScreen()
} else {
LoginScreen( LoginScreen(
state = uiState, state = uiState,
headerTitle = context.getString(R.string.auth_header_login),
onEmailChanged = viewModel::onEmailChanged, onEmailChanged = viewModel::onEmailChanged,
onNameChanged = viewModel::onNameChanged,
onUsernameChanged = viewModel::onUsernameChanged,
onPasswordChanged = viewModel::onPasswordChanged, onPasswordChanged = viewModel::onPasswordChanged,
onLoginClick = viewModel::login, onOtpCodeChanged = viewModel::onOtpCodeChanged,
onRecoveryCodeChanged = viewModel::onRecoveryCodeChanged,
onToggleRecoveryCodeMode = viewModel::toggleRecoveryCodeMode,
onContinueEmail = viewModel::continueWithEmail,
onSubmitStep = viewModel::submitAuthStep,
onBackToEmail = viewModel::backToEmailStep,
onOpenVerifyEmail = { navController.navigate(Routes.VerifyEmail) }, onOpenVerifyEmail = { navController.navigate(Routes.VerifyEmail) },
onOpenResetPassword = { navController.navigate(Routes.ResetPassword) }, 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) },
)
} }
} }
@@ -187,26 +280,40 @@ fun MessengerNavHost(
ChatListRoute( ChatListRoute(
inviteToken = inviteToken, inviteToken = inviteToken,
onInviteTokenConsumed = onInviteTokenConsumed, onInviteTokenConsumed = onInviteTokenConsumed,
onOpenSettings = { navController.navigate(Routes.Settings) },
onOpenProfile = { navController.navigate(Routes.Profile) },
onOpenChat = { chatId -> onOpenChat = { chatId ->
navController.navigate("${Routes.Chat}/$chatId") navController.navigate("${Routes.Chat}/$chatId")
} },
isMainBarVisible = isMainBarVisible,
onMainBarVisibilityChanged = { isMainBarVisible = it },
) )
} }
composable(route = Routes.Contacts) {
ContactsRoute(onMainBarVisibilityChanged = { isMainBarVisible = it })
}
composable(route = Routes.Settings) { composable(route = Routes.Settings) {
SettingsRoute( SettingsRoute(
onBackToChats = { navController.popBackStack() },
onOpenProfile = { navController.navigate(Routes.Profile) }, 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, onLogout = viewModel::logout,
onMainBarVisibilityChanged = { isMainBarVisible = it },
) )
} }
composable(route = Routes.Profile) { composable(route = Routes.Profile) {
ProfileRoute( ProfileRoute(
onBackToChats = { navController.popBackStack() }, onMainBarVisibilityChanged = { isMainBarVisible = it },
onOpenSettings = { navController.navigate(Routes.Settings) },
) )
} }
@@ -221,6 +328,118 @@ fun MessengerNavHost(
) )
} }
} }
AnimatedVisibility(
visible = showMainBar && isMainBarVisible,
enter = slideInVertically(initialOffsetY = { it / 2 }) + fadeIn(),
exit = slideOutVertically(targetOffsetY = { it / 2 }) + fadeOut(),
modifier = Modifier
.align(Alignment.BottomCenter)
.navigationBarsPadding()
.padding(bottom = 12.dp),
) {
MainBottomBar(
currentRoute = currentRoute,
onNavigate = { route ->
if (currentRoute == route) return@MainBottomBar
navController.navigate(route) {
popUpTo(navController.graph.findStartDestination().id) {
saveState = true
}
launchSingleTop = true
restoreState = true
}
},
)
}
}
}
@Composable
private fun MainBottomBar(
currentRoute: String?,
onNavigate: (String) -> Unit,
) {
Surface(
modifier = Modifier
.padding(horizontal = 12.dp)
.fillMaxWidth(),
shape = RoundedCornerShape(28.dp),
color = MaterialTheme.colorScheme.surface.copy(alpha = 0.88f),
tonalElevation = 2.dp,
shadowElevation = 8.dp,
) {
NavigationBar(
containerColor = Color.Transparent,
tonalElevation = 0.dp,
modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp),
) {
NavigationBarItem(
selected = currentRoute == Routes.Chats,
onClick = { onNavigate(Routes.Chats) },
icon = { Icon(Icons.AutoMirrored.Filled.Chat, contentDescription = stringResource(id = R.string.nav_chats)) },
label = {
androidx.compose.material3.Text(stringResource(id = R.string.nav_chats), maxLines = 1, overflow = TextOverflow.Ellipsis)
},
alwaysShowLabel = true,
colors = NavigationBarItemDefaults.colors(
selectedIconColor = MaterialTheme.colorScheme.primary,
selectedTextColor = MaterialTheme.colorScheme.primary,
indicatorColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.18f),
unselectedIconColor = MaterialTheme.colorScheme.onSurfaceVariant,
unselectedTextColor = MaterialTheme.colorScheme.onSurfaceVariant,
),
)
NavigationBarItem(
selected = currentRoute == Routes.Contacts,
onClick = { onNavigate(Routes.Contacts) },
icon = { Icon(Icons.Filled.Contacts, contentDescription = stringResource(id = R.string.nav_contacts)) },
label = {
androidx.compose.material3.Text(stringResource(id = R.string.nav_contacts), maxLines = 1, overflow = TextOverflow.Ellipsis)
},
alwaysShowLabel = true,
colors = NavigationBarItemDefaults.colors(
selectedIconColor = MaterialTheme.colorScheme.primary,
selectedTextColor = MaterialTheme.colorScheme.primary,
indicatorColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.18f),
unselectedIconColor = MaterialTheme.colorScheme.onSurfaceVariant,
unselectedTextColor = MaterialTheme.colorScheme.onSurfaceVariant,
),
)
NavigationBarItem(
selected = currentRoute == Routes.Settings,
onClick = { onNavigate(Routes.Settings) },
icon = { Icon(Icons.Filled.Settings, contentDescription = stringResource(id = R.string.nav_settings)) },
label = {
androidx.compose.material3.Text(stringResource(id = R.string.nav_settings), maxLines = 1, overflow = TextOverflow.Ellipsis)
},
alwaysShowLabel = true,
colors = NavigationBarItemDefaults.colors(
selectedIconColor = MaterialTheme.colorScheme.primary,
selectedTextColor = MaterialTheme.colorScheme.primary,
indicatorColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.18f),
unselectedIconColor = MaterialTheme.colorScheme.onSurfaceVariant,
unselectedTextColor = MaterialTheme.colorScheme.onSurfaceVariant,
),
)
NavigationBarItem(
selected = currentRoute == Routes.Profile,
onClick = { onNavigate(Routes.Profile) },
icon = { Icon(Icons.Filled.Person, contentDescription = stringResource(id = R.string.nav_profile)) },
label = {
androidx.compose.material3.Text(stringResource(id = R.string.nav_profile), maxLines = 1, overflow = TextOverflow.Ellipsis)
},
alwaysShowLabel = true,
colors = NavigationBarItemDefaults.colors(
selectedIconColor = MaterialTheme.colorScheme.primary,
selectedTextColor = MaterialTheme.colorScheme.primary,
indicatorColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.18f),
unselectedIconColor = MaterialTheme.colorScheme.onSurfaceVariant,
unselectedTextColor = MaterialTheme.colorScheme.onSurfaceVariant,
),
)
}
}
} }
@Composable @Composable

View File

@@ -8,53 +8,90 @@ import android.os.Build
import android.provider.MediaStore import android.provider.MediaStore
import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.gestures.detectTransformGestures
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawing import androidx.compose.foundation.layout.safeDrawing
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.layout.windowInsetsPadding 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.Button
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.OutlinedButton import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier 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.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.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import coil.compose.AsyncImage import coil.compose.AsyncImage
import kotlinx.coroutines.flow.collectLatest
import ru.daemonlord.messenger.R
import ru.daemonlord.messenger.ui.account.AccountViewModel import ru.daemonlord.messenger.ui.account.AccountViewModel
import java.io.ByteArrayOutputStream import java.io.ByteArrayOutputStream
import kotlin.math.max
import androidx.compose.ui.res.stringResource
@Composable @Composable
fun ProfileRoute( fun ProfileRoute(
onBackToChats: () -> Unit, onMainBarVisibilityChanged: (Boolean) -> Unit,
onOpenSettings: () -> Unit,
viewModel: AccountViewModel = hiltViewModel(), viewModel: AccountViewModel = hiltViewModel(),
) { ) {
ProfileScreen( ProfileScreen(
onBackToChats = onBackToChats, onMainBarVisibilityChanged = onMainBarVisibilityChanged,
onOpenSettings = onOpenSettings,
viewModel = viewModel, viewModel = viewModel,
) )
} }
@Composable @Composable
fun ProfileScreen( fun ProfileScreen(
onBackToChats: () -> Unit, onMainBarVisibilityChanged: (Boolean) -> Unit,
onOpenSettings: () -> Unit,
viewModel: AccountViewModel, viewModel: AccountViewModel,
) { ) {
val state by viewModel.uiState.collectAsStateWithLifecycle() val state by viewModel.uiState.collectAsStateWithLifecycle()
@@ -63,78 +100,214 @@ fun ProfileScreen(
var username by remember(profile?.username) { mutableStateOf(profile?.username.orEmpty()) } var username by remember(profile?.username) { mutableStateOf(profile?.username.orEmpty()) }
var bio by remember(profile?.bio) { mutableStateOf(profile?.bio.orEmpty()) } var bio by remember(profile?.bio) { mutableStateOf(profile?.bio.orEmpty()) }
var avatarUrl by remember(profile?.avatarUrl) { mutableStateOf(profile?.avatarUrl.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) { LaunchedEffect(Unit) {
viewModel.refresh() viewModel.refresh()
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
}
} }
val context = androidx.compose.ui.platform.LocalContext.current val context = LocalContext.current
val isTabletLayout = LocalConfiguration.current.screenWidthDp >= 840
val pickAvatarLauncher = rememberLauncherForActivityResult( val pickAvatarLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.GetContent(), contract = ActivityResultContracts.GetContent(),
) { uri -> ) { uri ->
val bytes = uri?.toSquareJpeg(context) ?: return@rememberLauncherForActivityResult pendingAvatarBitmap = uri?.toBitmap(context)
viewModel.uploadAvatar( }
fileName = "avatar.jpg",
mimeType = "image/jpeg", Box(
bytes = bytes, modifier = Modifier
) { uploadedUrl -> .fillMaxSize()
avatarUrl = uploadedUrl .background(MaterialTheme.colorScheme.background)
.windowInsetsPadding(WindowInsets.safeDrawing),
contentAlignment = Alignment.TopCenter,
) {
Column(
modifier = Modifier
.fillMaxWidth()
.then(if (isTabletLayout) Modifier.widthIn(max = 720.dp) else Modifier)
.verticalScroll(scrollState)
.padding(bottom = 96.dp),
) {
if (!editMode) {
Box(
modifier = Modifier
.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( Column(
modifier = Modifier modifier = Modifier.padding(horizontal = 16.dp),
.fillMaxSize()
.windowInsetsPadding(WindowInsets.safeDrawing)
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp), verticalArrangement = Arrangement.spacedBy(12.dp),
) { ) {
Text( if (!editMode) {
text = "Profile", Surface(
style = MaterialTheme.typography.headlineSmall, color = MaterialTheme.colorScheme.surfaceContainer,
) shape = RoundedCornerShape(22.dp),
if (!avatarUrl.isBlank()) { modifier = Modifier
AsyncImage( .fillMaxWidth()
model = avatarUrl, .offset(y = (-22).dp),
contentDescription = "Avatar",
modifier = Modifier.fillMaxWidth(),
)
}
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically,
) { ) {
Button(onClick = { pickAvatarLauncher.launch("image/*") }, enabled = !state.isSaving) { Column(
Text("Upload avatar") 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 (state.isSaving) {
CircularProgressIndicator(modifier = Modifier.padding(4.dp))
} }
} }
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( OutlinedTextField(
value = name, value = name,
onValueChange = { name = it }, onValueChange = { name = it },
label = { Text("Name") }, label = { Text(stringResource(id = R.string.auth_label_name)) },
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
) )
OutlinedTextField( OutlinedTextField(
value = username, value = username,
onValueChange = { username = it }, onValueChange = { username = it },
label = { Text("Username") }, label = { Text(stringResource(id = R.string.auth_label_username)) },
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
) )
OutlinedTextField( OutlinedTextField(
value = bio, value = bio,
onValueChange = { bio = it }, onValueChange = { bio = it },
label = { Text("Bio") }, label = { Text(stringResource(id = R.string.profile_bio)) },
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
) )
OutlinedTextField( OutlinedTextField(
value = avatarUrl, value = avatarUrl,
onValueChange = { avatarUrl = it }, onValueChange = { avatarUrl = it },
label = { Text("Avatar URL") }, label = { Text(stringResource(id = R.string.profile_avatar_url)) },
modifier = Modifier.fillMaxWidth(), 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( Button(
onClick = { onClick = {
viewModel.updateProfile( viewModel.updateProfile(
@@ -143,43 +316,202 @@ fun ProfileScreen(
bio = bio.ifBlank { null }, bio = bio.ifBlank { null },
avatarUrl = avatarUrl.ifBlank { null }, avatarUrl = avatarUrl.ifBlank { null },
) )
editMode = false
}, },
enabled = !state.isSaving && name.isNotBlank() && username.isNotBlank(), enabled = !state.isSaving && name.isNotBlank() && username.isNotBlank(),
modifier = Modifier.fillMaxWidth(), modifier = Modifier.weight(1f),
) { ) {
Text("Save profile") Text(stringResource(id = R.string.common_save))
} }
if (!state.message.isNullOrBlank()) {
Text(
text = state.message!!,
color = MaterialTheme.colorScheme.primary,
style = MaterialTheme.typography.bodySmall,
)
} }
if (!state.errorMessage.isNullOrBlank()) { if (state.isSaving) {
Text( CircularProgressIndicator(modifier = Modifier.size(22.dp), strokeWidth = 2.dp)
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")
} }
} }
} }
private fun Uri.toSquareJpeg(context: Context): ByteArray? { if (!state.message.isNullOrBlank()) {
val bitmap = runCatching { 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),
)
}
}
}
}
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
},
)
}
}
@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(),
) {
Icon(icon, contentDescription = null)
Text(label, modifier = Modifier.padding(start = 6.dp))
}
}
}
@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) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
val src = ImageDecoder.createSource(context.contentResolver, this) val src = ImageDecoder.createSource(context.contentResolver, this)
ImageDecoder.decodeBitmap(src) ImageDecoder.decodeBitmap(src)
@@ -187,18 +519,5 @@ private fun Uri.toSquareJpeg(context: Context): ByteArray? {
@Suppress("DEPRECATION") @Suppress("DEPRECATION")
MediaStore.Images.Media.getBitmap(context.contentResolver, this) MediaStore.Images.Media.getBitmap(context.contentResolver, this)
} }
}.getOrNull() ?: return null }.getOrNull()
val square = bitmap.centerCropSquare()
val output = ByteArrayOutputStream()
val compressed = square.compress(Bitmap.CompressFormat.JPEG, 92, output)
if (!compressed) return null
return output.toByteArray()
}
private fun Bitmap.centerCropSquare(): Bitmap {
val side = minOf(width, height)
val left = (width - side) / 2
val top = (height - side) / 2
return Bitmap.createBitmap(this, left, top, side, side)
} }

View File

@@ -1,7 +1,11 @@
package ru.daemonlord.messenger.ui.settings 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.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.WindowInsets
@@ -9,274 +13,640 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawing import androidx.compose.foundation.layout.safeDrawing
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.rememberScrollState 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.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.Button
import androidx.compose.material3.CircularProgressIndicator 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.MaterialTheme
import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField 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.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.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.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import coil.compose.AsyncImage
import 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 import ru.daemonlord.messenger.ui.account.AccountViewModel
private enum class SettingsFolder {
Account,
Chat,
Privacy,
Notifications,
Data,
Folders,
Devices,
Power,
Language,
}
@Composable @Composable
fun SettingsRoute( fun SettingsRoute(
onBackToChats: () -> Unit,
onOpenProfile: () -> Unit, onOpenProfile: () -> Unit,
onAddAccount: () -> Unit,
onSwitchAccount: () -> Unit,
onLogout: () -> Unit, onLogout: () -> Unit,
onMainBarVisibilityChanged: (Boolean) -> Unit,
viewModel: AccountViewModel = hiltViewModel(), viewModel: AccountViewModel = hiltViewModel(),
) { ) {
SettingsScreen( SettingsScreen(
onBackToChats = onBackToChats,
onOpenProfile = onOpenProfile, onOpenProfile = onOpenProfile,
onAddAccount = onAddAccount,
onSwitchAccount = onSwitchAccount,
onLogout = onLogout, onLogout = onLogout,
onMainBarVisibilityChanged = onMainBarVisibilityChanged,
viewModel = viewModel, viewModel = viewModel,
) )
} }
@Composable @Composable
fun SettingsScreen( fun SettingsScreen(
onBackToChats: () -> Unit,
onOpenProfile: () -> Unit, onOpenProfile: () -> Unit,
onAddAccount: () -> Unit,
onSwitchAccount: () -> Unit,
onLogout: () -> Unit, onLogout: () -> Unit,
onMainBarVisibilityChanged: (Boolean) -> Unit,
viewModel: AccountViewModel, viewModel: AccountViewModel,
) { ) {
val state by viewModel.uiState.collectAsStateWithLifecycle() val state by viewModel.uiState.collectAsStateWithLifecycle()
val profile = state.profile val profile = state.profile
var privacyPm by remember(profile?.privacyPrivateMessages) { mutableStateOf(profile?.privacyPrivateMessages ?: "everyone") } val isTablet = LocalConfiguration.current.screenWidthDp >= 840
var privacyLastSeen by remember(profile?.privacyLastSeen) { mutableStateOf(profile?.privacyLastSeen ?: "everyone") } var folder by rememberSaveable { mutableStateOf<SettingsFolder?>(null) }
var privacyAvatar by remember(profile?.privacyAvatar) { mutableStateOf(profile?.privacyAvatar ?: "everyone") }
var privacyGroupInvites by remember(profile?.privacyGroupInvites) { mutableStateOf(profile?.privacyGroupInvites ?: "everyone") }
var twoFactorCode by remember { mutableStateOf("") }
var recoveryRegenerateCode by remember { mutableStateOf("") }
var blockUserIdInput by remember { mutableStateOf("") }
val scrollState = rememberScrollState()
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
viewModel.refresh() viewModel.refresh()
viewModel.refreshRecoveryStatus() onMainBarVisibilityChanged(true)
} }
Box(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.background)
.windowInsetsPadding(WindowInsets.safeDrawing),
contentAlignment = Alignment.TopCenter,
) {
Column(
modifier = Modifier
.fillMaxWidth()
.then(if (isTablet) Modifier.widthIn(max = 720.dp) else Modifier),
) {
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,
)
}
}
}
}
@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( Column(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.windowInsetsPadding(WindowInsets.safeDrawing) .verticalScroll(scroll)
.verticalScroll(scrollState) .padding(horizontal = 16.dp, vertical = 12.dp)
.padding(16.dp), .padding(bottom = 96.dp),
verticalArrangement = Arrangement.spacedBy(12.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(
text = "Settings", text = subtitle,
style = MaterialTheme.typography.headlineSmall, style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
) )
if (state.isLoading) { }
CircularProgressIndicator() }
} }
Text("Sessions", style = MaterialTheme.typography.titleMedium) @Composable
if (state.sessions.isEmpty()) { private fun languageLabel(language: AppLanguage): String = when (language) {
Text("No active sessions", color = MaterialTheme.colorScheme.onSurfaceVariant) AppLanguage.SYSTEM -> stringResource(id = R.string.language_system)
} else { AppLanguage.RUSSIAN -> stringResource(id = R.string.language_russian)
state.sessions.forEach { session -> AppLanguage.ENGLISH -> stringResource(id = R.string.language_english)
Row( }
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically, @Composable
horizontalArrangement = Arrangement.SpaceBetween, 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)) { Column(modifier = Modifier.weight(1f)) {
Text(session.userAgent ?: "Unknown device", style = MaterialTheme.typography.bodyMedium) Text(account.title, maxLines = 1, overflow = TextOverflow.Ellipsis)
Text(session.ipAddress ?: "Unknown IP", style = MaterialTheme.typography.bodySmall) Text(account.subtitle, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis)
} }
OutlinedButton( if (account.isActive) {
onClick = { viewModel.revokeSession(session.jti) }, Text(stringResource(id = R.string.settings_active), color = MaterialTheme.colorScheme.primary)
enabled = !state.isSaving && session.current != true,
) {
Text("Revoke")
}
}
}
}
OutlinedButton(
onClick = viewModel::revokeAllSessions,
enabled = !state.isSaving,
modifier = Modifier.fillMaxWidth(),
) {
Text("Revoke all sessions")
}
Text("Privacy", style = MaterialTheme.typography.titleMedium)
OutlinedTextField(
value = privacyPm,
onValueChange = { privacyPm = it },
label = { Text("PM privacy (everyone/contacts/nobody)") },
modifier = Modifier.fillMaxWidth(),
)
OutlinedTextField(
value = privacyLastSeen,
onValueChange = { privacyLastSeen = it },
label = { Text("Last seen privacy") },
modifier = Modifier.fillMaxWidth(),
)
OutlinedTextField(
value = privacyAvatar,
onValueChange = { privacyAvatar = it },
label = { Text("Avatar privacy") },
modifier = Modifier.fillMaxWidth(),
)
OutlinedTextField(
value = privacyGroupInvites,
onValueChange = { privacyGroupInvites = it },
label = { Text("Group invites privacy") },
modifier = Modifier.fillMaxWidth(),
)
Button(
onClick = {
viewModel.updatePrivacy(
privateMessages = privacyPm,
lastSeen = privacyLastSeen,
avatar = privacyAvatar,
groupInvites = privacyGroupInvites,
)
},
enabled = !state.isSaving,
modifier = Modifier.fillMaxWidth(),
) {
Text("Save privacy")
}
Text("Blocked users", style = MaterialTheme.typography.titleMedium)
OutlinedTextField(
value = blockUserIdInput,
onValueChange = { blockUserIdInput = it.filter { ch -> ch.isDigit() } },
label = { Text("User ID to block") },
modifier = Modifier.fillMaxWidth(),
)
OutlinedButton(
onClick = {
blockUserIdInput.toLongOrNull()?.let { viewModel.blockUser(it) }
blockUserIdInput = ""
},
enabled = !state.isSaving && blockUserIdInput.isNotBlank(),
modifier = Modifier.fillMaxWidth(),
) {
Text("Block user by ID")
}
if (state.blockedUsers.isEmpty()) {
Text("Blocked list is empty", color = MaterialTheme.colorScheme.onSurfaceVariant)
} else { } else {
state.blockedUsers.forEach { user -> OutlinedButton(onClick = { viewModel.switchStoredAccount(account.userId) { if (it) onSwitchAccount() } }) { Text(stringResource(id = R.string.settings_switch)) }
Row( }
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(), modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) { ) {
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)) { Column(modifier = Modifier.weight(1f)) {
Text(user.name) Text(s.userAgent ?: stringResource(id = R.string.settings_unknown_device))
if (!user.username.isNullOrBlank()) { Text(s.ipAddress ?: stringResource(id = R.string.settings_unknown_ip), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
Text("@${user.username}", style = MaterialTheme.typography.bodySmall) }
} OutlinedButton(onClick = { viewModel.revokeSession(s.jti) }, enabled = !state.isSaving && s.current != true) { Text(stringResource(id = R.string.settings_revoke)) }
}
OutlinedButton(onClick = { viewModel.unblockUser(user.id) }) {
Text("Unblock")
} }
} }
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))
} }
} }
Text("2FA", style = MaterialTheme.typography.titleMedium) 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)) { Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedButton(onClick = viewModel::setupTwoFactor, enabled = !state.isSaving) { Button(onClick = { viewModel.enableTwoFactor(twoFactorCode) }, enabled = twoFactorCode.isNotBlank() && !state.isSaving) { Text(stringResource(id = R.string.settings_enable)) }
Text("Setup") OutlinedButton(onClick = { viewModel.disableTwoFactor(twoFactorCode) }, enabled = twoFactorCode.isNotBlank() && !state.isSaving) { Text(stringResource(id = R.string.settings_disable)) }
} }
OutlinedButton(onClick = viewModel::refreshRecoveryStatus, enabled = !state.isSaving) { OutlinedTextField(value = recoveryCode, onValueChange = { recoveryCode = it }, label = { Text(stringResource(id = R.string.settings_recovery_regen_code)) }, modifier = Modifier.fillMaxWidth(), singleLine = true)
Text("Refresh status") OutlinedButton(onClick = { viewModel.regenerateRecoveryCodes(recoveryCode) }, enabled = recoveryCode.isNotBlank() && !state.isSaving, modifier = Modifier.fillMaxWidth()) {
Text(stringResource(id = R.string.settings_regenerate_recovery_codes))
} }
} }
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()) { @Composable
Text(state.message!!, color = MaterialTheme.colorScheme.primary) private fun PlaceholderFolder(text: String) {
SettingsCard { Text(text) }
} }
if (!state.errorMessage.isNullOrBlank()) {
Text(state.errorMessage!!, color = MaterialTheme.colorScheme.error)
}
Spacer(modifier = Modifier.padding(top = 4.dp))
OutlinedButton( @Composable
onClick = onOpenProfile, private fun ProfileHeader(name: String, email: String, username: String, avatarUrl: String?, onOpenProfile: () -> Unit) {
modifier = Modifier.fillMaxWidth(), Column(modifier = Modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(8.dp)) {
) { if (!avatarUrl.isNullOrBlank()) {
Text("Open profile") 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() ?: "?")
} }
Button(
onClick = onLogout,
modifier = Modifier.fillMaxWidth(),
) {
Text("Logout")
} }
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( Row(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth().clip(RoundedCornerShape(14.dp)).clickable(onClick = onClick).padding(horizontal = 8.dp, vertical = 10.dp),
horizontalArrangement = Arrangement.End,
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp),
) { ) {
OutlinedButton(onClick = onBackToChats) { Box(
Text("Back to chats") 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

@@ -0,0 +1,39 @@
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()
@Composable
fun MessengerTheme(content: @Composable () -> Unit) {
val darkTheme = when (AppCompatDelegate.getDefaultNightMode()) {
AppCompatDelegate.MODE_NIGHT_YES -> true
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 = 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"?> <?xml version="1.0" encoding="utf-8"?>
<resources> <resources>
<string name="app_name">Benya Messenger</string> <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> </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>

Some files were not shown because too many files have changed in this diff Show More