Compare commits

...

190 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
Codex
f708854bb2 android: add media gallery and account checklist progress
Some checks failed
Android CI / android (push) Has started running
Android Release / release (push) Has been cancelled
CI / test (push) Has been cancelled
2026-03-09 16:05:52 +03:00
Codex
5368515112 android: implement profile settings privacy sessions and 2fa ui 2026-03-09 16:05:39 +03:00
Codex
9ad8372d45 android: add verify and reset auth flows with deep link routing 2026-03-09 16:05:26 +03:00
Codex
91d712c702 android: add account api and repository for profile privacy sessions 2fa 2026-03-09 16:05:14 +03:00
Codex
65e74cffdb android: add core common module logging crashlytics and feature flags 2026-03-09 16:04:53 +03:00
Codex
ef5f866bd0 ci: add android release workflow for gitea
Some checks failed
Android CI / android (push) Failing after 3m38s
Android Release / release (push) Failing after 3m34s
CI / test (push) Failing after 2m25s
2026-03-09 15:44:31 +03:00
Codex
8246fe6cae ci: add android workflow for build test lint and detekt
Some checks failed
Android CI / android (push) Failing after 4m9s
CI / test (push) Has started running
2026-03-09 15:39:59 +03:00
Codex
b5cd371f8b android: add compose ui tests for auth and chat list states
Some checks failed
CI / test (push) Failing after 2m14s
2026-03-09 15:37:10 +03:00
Codex
ffa2205a30 android: add coil and media3 cache foundation for media
Some checks are pending
CI / test (push) Has started running
2026-03-09 15:35:28 +03:00
Codex
43b772a394 android: add deferred offline queue for send edit delete actions
Some checks failed
CI / test (push) Failing after 2m21s
2026-03-09 15:32:05 +03:00
Codex
3eb68cedad android: keep session authenticated on startup when offline
Some checks failed
CI / test (push) Failing after 2m7s
2026-03-09 15:27:58 +03:00
Codex
89755394f7 android: add offline-first chat history reading and cache fallback
Some checks are pending
CI / test (push) Has started running
2026-03-09 15:26:11 +03:00
Codex
16c21d1bb7 android: unify api error mapping across data repositories
Some checks failed
CI / test (push) Failing after 2m13s
2026-03-09 15:22:27 +03:00
Codex
375f5756d3 android: remove duplicate forward action in multi-select
Some checks failed
CI / test (push) Failing after 2m16s
2026-03-09 15:19:01 +03:00
Codex
0adcc97f0f android: split tap context menu and long-press multi-select in chat
Some checks are pending
CI / test (push) Has started running
2026-03-09 15:17:43 +03:00
Codex
c80ff650b2 android: secure token storage with keystore-backed encrypted prefs
Some checks failed
CI / test (push) Has been cancelled
2026-03-09 15:15:28 +03:00
Codex
7fcdc28015 android: add settings and profile shells and move logout action
Some checks failed
CI / test (push) Has been cancelled
2026-03-09 15:13:18 +03:00
Codex
6afde15a2c android: fix kotlin dsl quoting in settings gradle
Some checks are pending
CI / test (push) Has started running
2026-03-09 15:11:13 +03:00
Codex
d7dfda1d31 android: add logout flow with full local session cleanup
Some checks are pending
CI / test (push) Has started running
2026-03-09 15:09:10 +03:00
Codex
33514265e3 android: add datastore notification settings and gating usecase
Some checks failed
CI / test (push) Failing after 2m6s
2026-03-09 15:02:56 +03:00
Codex
dfa67c34c9 android: configure google services plugin and firebase bom
Some checks failed
CI / test (push) Failing after 2m11s
2026-03-09 14:55:41 +03:00
Codex
670fcd721d android: add mention override for muted chat notifications
Some checks failed
CI / test (push) Failing after 2m18s
2026-03-09 14:52:28 +03:00
Codex
98492f869d android: add realtime foreground local notifications with active chat gating
Some checks failed
CI / test (push) Failing after 2m12s
2026-03-09 14:48:17 +03:00
Codex
e8574252ca android: add notifications foundation with fcm channels and deep links
Some checks failed
CI / test (push) Failing after 2m10s
2026-03-09 14:44:28 +03:00
Codex
d09300311f android: upgrade fullscreen media viewer header and gallery
Some checks failed
CI / test (push) Failing after 2m14s
2026-03-09 14:38:27 +03:00
Codex
c947d96748 android: improve media and file attachment bubble rendering
Some checks are pending
CI / test (push) Has started running
2026-03-09 14:37:00 +03:00
Codex
542af1d4c1 android: refine message bubble shapes and parity checklist
Some checks failed
CI / test (push) Failing after 2m11s
2026-03-09 14:30:56 +03:00
Codex
1d37f8eb0b android: add floating bottom nav shell to chat list
Some checks failed
CI / test (push) Has been cancelled
2026-03-09 14:29:35 +03:00
Codex
ce585f62d2 android: improve chat list row parity with avatar time and fab
Some checks are pending
CI / test (push) Has started running
2026-03-09 14:28:16 +03:00
Codex
a5a940b749 android: add long-press reaction and context action menu
Some checks are pending
CI / test (push) Has started running
2026-03-09 14:26:47 +03:00
Codex
5c3535ef8f android: switch invite join to app-link deep links
Some checks failed
CI / test (push) Failing after 2m9s
2026-03-09 14:24:22 +03:00
Codex
e2a87ffb2e android: show message time instead of message id in bubbles
Some checks failed
CI / test (push) Failing after 2m10s
2026-03-09 14:19:14 +03:00
Codex
c835dfda15 android: remove call action from chat header
Some checks are pending
CI / test (push) Has started running
2026-03-09 14:18:19 +03:00
Codex
93a9f70669 android: include pinned message id in chat domain model
Some checks failed
CI / test (push) Has been cancelled
2026-03-09 14:13:45 +03:00
Codex
fed8d22428 android: add mapper fallback test and checklist updates
Some checks failed
CI / test (push) Failing after 2m9s
2026-03-09 14:12:57 +03:00
Codex
f159108b75 android: add wallpaper-aware chat background and overlays
Some checks are pending
CI / test (push) Has started running
2026-03-09 14:11:38 +03:00
Codex
dfd4a00490 android: add chat list chips and archive top-row state
Some checks are pending
CI / test (push) Has started running
2026-03-09 14:10:14 +03:00
Codex
6c9501e624 android: add multi-select top and bottom action bars
Some checks failed
CI / test (push) Failing after 2m7s
2026-03-09 14:07:20 +03:00
Codex
7381d611cc android: restyle chat composer to telegram-like container
Some checks are pending
CI / test (push) Has started running
2026-03-09 14:05:45 +03:00
Codex
db048b9f12 android: restructure chat top app bar with header metadata
Some checks are pending
CI / test (push) Has started running
2026-03-09 14:03:48 +03:00
Codex
651d53f3df android: add pinned message bar support in chat
Some checks are pending
CI / test (push) Has started running
2026-03-09 14:02:03 +03:00
Codex
ade92e4a86 android: add reply and forwarded blocks to chat bubbles
Some checks failed
CI / test (push) Failing after 2m9s
2026-03-09 13:57:41 +03:00
Codex
98e8ac8dfb android: add reply-forward preview data foundation
Some checks are pending
CI / test (push) Has started running
2026-03-09 13:56:27 +03:00
Codex
071165c55b android: apply delete action to full multi-selection
Some checks failed
CI / test (push) Failing after 2m10s
2026-03-09 13:50:39 +03:00
Codex
876d64d345 android: enable multi-select forward execution in chat
Some checks failed
CI / test (push) Has been cancelled
2026-03-09 13:49:42 +03:00
Codex
9e764574bc web: fix reply sender fallback naming
Some checks failed
CI / test (push) Failing after 2m16s
2026-03-09 13:45:00 +03:00
Codex
7cf6be6515 web: add multi-message forward from selection
Some checks failed
CI / test (push) Has been cancelled
2026-03-09 13:42:58 +03:00
Codex
e992f1e26d android: add message action state machine core
Some checks failed
CI / test (push) Has been cancelled
2026-03-09 13:40:46 +03:00
Codex
d8916d6738 android: add bulk forward core foundation for multi-select
Some checks failed
CI / test (push) Failing after 2m10s
2026-03-09 13:30:54 +03:00
Codex
02ec6c95e9 docs: add telegram ui checklists for batches 1 and 3
Some checks failed
CI / test (push) Failing after 2m5s
2026-03-09 13:28:11 +03:00
Codex
fbe684799a docs: add android ui checklist for telegram reference batch 2
Some checks failed
CI / test (push) Has been cancelled
2026-03-09 13:26:38 +03:00
Codex
a05b2ea929 android: fix system insets for status and nav bars
Some checks failed
CI / test (push) Failing after 2m3s
2026-03-09 13:17:53 +03:00
Codex
81597f8f44 android: expand quality coverage and smoke baseline docs
Some checks failed
CI / test (push) Failing after 2m19s
2026-03-09 13:05:38 +03:00
Codex
bd6a8a43ed android: add auth sessions hardening APIs and tests
Some checks are pending
CI / test (push) Has started running
2026-03-09 13:04:12 +03:00
Codex
08815bac7b android: harden realtime heartbeat and reconnect reconcile
Some checks failed
CI / test (push) Failing after 2m10s
2026-03-09 13:01:06 +03:00
Codex
e91884e14a android: add minimum invite link join flow
Some checks failed
CI / test (push) Has been cancelled
2026-03-09 12:59:33 +03:00
Codex
37396f4da5 android: add channel role-based send permissions
Some checks failed
CI / test (push) Failing after 2m18s
2026-03-09 12:56:21 +03:00
Codex
5760a0cb3f android: add chat media attachment rendering and viewer
Some checks failed
CI / test (push) Failing after 2m6s
2026-03-09 12:53:08 +03:00
Codex
946b85a18f android: complete message core with forward reactions and read states
Some checks failed
CI / test (push) Failing after 2m10s
2026-03-09 12:48:51 +03:00
Codex
3dd320193c android: add media repository tests and checklist updates
Some checks failed
CI / test (push) Failing after 2m3s
2026-03-09 02:24:36 +03:00
Codex
8d13eb104e android: add media upload repository and chat attachment send flow
Some checks are pending
CI / test (push) Has started running
2026-03-09 02:22:48 +03:00
Codex
ad2e0ede42 web: fix auth session races, ws token drift, and unread clear behavior
Some checks failed
CI / test (push) Failing after 2m20s
2026-03-09 02:17:14 +03:00
Codex
4fa657ff7a android: add message core tests and update checklist docs
Some checks failed
CI / test (push) Failing after 2m14s
2026-03-09 02:13:20 +03:00
Codex
545b45c5db android: implement message screen ui with compose actions
Some checks failed
CI / test (push) Failing after 2m8s
2026-03-09 02:10:52 +03:00
Codex
c63f063726 android: extend realtime pipeline for message stream updates
Some checks failed
CI / test (push) Failing after 2m16s
2026-03-09 02:08:13 +03:00
Codex
5a0add4d5c android: add message api contracts and repository usecases
Some checks are pending
CI / test (push) Has started running
2026-03-08 23:06:30 +03:00
Codex
5ad89fc05b android: add message room schema and core domain models
Some checks failed
CI / test (push) Failing after 2m6s
2026-03-08 23:03:34 +03:00
232 changed files with 23518 additions and 448 deletions

View File

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

45
.github/workflows/android-ci.yml vendored Normal file
View File

@@ -0,0 +1,45 @@
name: Android CI
on:
push:
branches:
- main
pull_request:
branches:
- main
jobs:
android:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: https://git.daemonlord.ru/actions/checkout@v4
with:
fetch-depth: 0
- name: Set up JDK 17
uses: https://git.daemonlord.ru/actions/setup-java@v4
with:
distribution: temurin
java-version: 17
- name: Set up Android SDK
uses: https://git.daemonlord.ru/actions/setup-android@v3
- name: Make Gradlew executable
run: chmod +x ./android/gradlew
- name: Build + Unit tests + Lint
working-directory: android
run: ./gradlew --no-daemon clean testDebugUnitTest lintDebug assembleDebug assembleDebugAndroidTest
- name: Detekt (if configured)
working-directory: android
run: |
if ./gradlew -q tasks --all | grep -q "detekt"; then
./gradlew --no-daemon detekt
else
echo "Detekt task is not configured, skipping."
fi

94
.github/workflows/android-release.yml vendored Normal file
View File

@@ -0,0 +1,94 @@
name: Android Release
on:
push:
branches:
- main
permissions:
contents: write
jobs:
release:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: https://git.daemonlord.ru/actions/checkout@v4
with:
fetch-depth: 0
tags: true
- name: Set up JDK 17
uses: https://git.daemonlord.ru/actions/setup-java@v4
with:
distribution: temurin
java-version: 17
- name: Extract versionName from Kotlin DSL
id: extract_version
run: |
VERSION=$(grep -oP 'versionName\s*=\s*"[^"]+"' android/app/build.gradle.kts | head -n1 | cut -d'"' -f2 | tr -d '\r\n')
if [ -z "$VERSION" ]; then
echo "Failed to detect versionName in android/app/build.gradle.kts"
exit 1
fi
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
echo "Detected version: $VERSION"
- name: Stop if version already released
id: version_check
run: |
VERSION="${{ steps.extract_version.outputs.version }}"
if git show-ref --tags --quiet --verify "refs/tags/$VERSION"; then
echo "Version $VERSION already released, stopping job."
echo "continue=false" >> "$GITHUB_OUTPUT"
else
echo "Version $VERSION is new, continuing..."
echo "continue=true" >> "$GITHUB_OUTPUT"
fi
- name: Decode keystore
if: steps.version_check.outputs.continue == 'true'
run: |
echo "${{ secrets.KEYSTORE_BASE64 }}" | base64 -d > android/app/release.keystore
- name: Set up Android SDK
if: steps.version_check.outputs.continue == 'true'
uses: https://git.daemonlord.ru/actions/setup-android@v3
- name: Make Gradlew executable
if: steps.version_check.outputs.continue == 'true'
run: chmod +x ./android/gradlew
- name: Build release APK
if: steps.version_check.outputs.continue == 'true'
working-directory: android
env:
KEYSTORE_PASSWORD: ${{ secrets.KEYSTORE_PASSWORD }}
KEY_ALIAS: ${{ secrets.KEY_ALIAS }}
KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }}
run: ./gradlew --no-daemon assembleRelease
- name: Create git tag
if: steps.version_check.outputs.continue == 'true'
run: |
VERSION="${{ steps.extract_version.outputs.version }}"
git config user.name "android-release-bot"
git config user.email "android-release-bot@daemonlord.ru"
git tag "$VERSION"
git push origin "$VERSION"
- name: Create Gitea release
if: steps.version_check.outputs.continue == 'true'
uses: https://git.daemonlord.ru/actions/gitea-release-action@v1
with:
server_url: https://git.daemonlord.ru
repository: ${{ gitea.repository }}
token: ${{ secrets.API_TOKEN }}
tag_name: ${{ steps.extract_version.outputs.version }}
name: Release ${{ steps.extract_version.outputs.version }}
body: |
Android release ${{ steps.extract_version.outputs.version }}
files: |
android/app/build/outputs/apk/release/*.apk

1
.gitignore vendored
View File

@@ -8,3 +8,4 @@ test.db
web/node_modules
web/dist
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

@@ -64,3 +64,962 @@
- Fixed Hilt dependency cycle by separating refresh `AuthApiService` with a dedicated qualifier.
- Added `CoroutineDispatcher` DI provider and qualifier for repositories.
- Fixed Material3 experimental API opt-in and removed deprecated `StateFlow.distinctUntilChanged()` usage.
### Step 11 - Sprint A / 1) Message Room + models
- Added message domain model (`MessageItem`) for chat screen rendering.
- Added Room entities `messages` and `message_attachments` with chat-history indexes.
- Added `MessageDao` with observe/pagination/upsert/delete APIs.
- Updated `MessengerDatabase` schema to include message tables and DAO.
- Added Hilt DI provider for `MessageDao`.
### Step 12 - Sprint A / 2) Message API + repository
- Added message REST API client for history/send/edit/delete endpoints.
- Added message DTOs and mappers (`MessageReadDto -> MessageEntity -> MessageItem`).
- Added `MessageRepository` contracts/use-cases for observe/sync/pagination/send/edit/delete.
- Implemented `NetworkMessageRepository` with cache-first observation and optimistic text send.
- Wired message API and repository into Hilt modules.
### Step 13 - Sprint A / 3) Message realtime integration
- Extended realtime event model/parser with message-focused events (`message_delivered`, `message_read`, `typing_start`, `typing_stop`) and richer message payload mapping.
- Updated unified realtime handler to write `receive_message`, `message_updated`, `message_deleted` into `messages` Room state.
- Added delivery/read status updates in Room for message status events.
- Kept chat list sync updates in the same manager/use-case pipeline for consistency.
### Step 14 - Sprint A / 4) Message UI core
- Replaced chat placeholder with a real message screen route + ViewModel.
- Added message list rendering with Telegram-like bubble alignment and status hints.
- Added input composer with send flow, reply/edit modes, and inline action cancellation.
- Added long-press actions (`reply`, `edit`, `delete`) for baseline message operations.
- Added manual "load older" pagination trigger and chat back navigation integration.
### Step 15 - Sprint A / 5) Message tests and docs
- Added unit tests for `NetworkMessageRepository` sync/send flows.
- Added DAO test for message scoped replace behavior in Room.
- Expanded realtime parser tests with rich `receive_message` mapping coverage.
- Updated `docs/android-checklist.md` for completed message-core items.
### Step 16 - Sprint B / 1-2) Media data layer + chat integration
- Added media API/DTO layer for upload URL and attachment creation.
- Added `MediaRepository` + `UploadAndAttachMediaUseCase` and network implementation with presigned PUT upload.
- Extended `MessageRepository` with media send flow (`sendMediaMessage`) and optimistic local update behavior.
- Wired media API/repository through Hilt modules.
- Integrated file picking and media sending into Android `ChatScreen`/`ChatViewModel` with upload state handling.
### Step 17 - Sprint B / media tests
- Added `NetworkMediaRepositoryTest` for successful upload+attach flow.
- Added error-path coverage for failed presigned upload handling.
## 2026-03-09
### Step 18 - Sprint P0 / 1) Message core completion
- Extended message API/data contracts with `messages/status`, `forward`, and reaction endpoints.
- Added message domain support for forwarded message metadata and attachment waveform payload.
- Implemented repository operations for delivery/read acknowledgements, forward, and reactions.
- Updated Chat ViewModel/UI with forward flow, reaction toggle, and edit/delete-for-all edge-case guards.
- Added automatic delivered/read acknowledgement for latest incoming message in active chat.
- Fixed outgoing message detection by resolving current user id from JWT `sub` claim.
### Step 19 - Sprint P0 / 2) Media UX after send
- Added media endpoint mapping for chat attachments (`GET /api/v1/media/chats/{chat_id}/attachments`).
- Extended Room message observation to include attachment relations via `MessageLocalModel`.
- Synced and persisted message attachments during message refresh/pagination and after media send.
- Extended message domain model with attachment list payload.
- Added message attachment rendering in Chat UI: inline image preview, minimal image viewer overlay, and basic audio play/pause control.
### Step 20 - Sprint P0 / 3) Roles/permissions baseline
- Extended chat data/domain models with `my_role` and added `observeChatById` stream in Room/repository.
- Added `ObserveChatUseCase` to expose per-chat permission state to message screen.
- Implemented channel send restrictions in `ChatViewModel`: sending/attach disabled for `member` role in `channel` chats.
- Added composer-level restriction hint in Chat UI to explain blocked actions.
### Step 21 - Sprint P0 / 4) Invite join flow (minimum)
- Added chat API contracts for invite actions: `POST /api/v1/chats/{chat_id}/invite-link` and `POST /api/v1/chats/join-by-invite`.
- Added domain model/use-cases for invite-link creation and join-by-invite.
- Extended chat repository with invite operations and local chat upsert on successful join.
- Added minimal Chat List UI flow for join-by-invite token input with loading/error handling and auto-open of joined chat.
### Step 22 - Sprint P0 / 5) Realtime stability and reconcile
- Added heartbeat in WebSocket manager (`ping` interval + `pong` timeout detection) with forced reconnect on stale link.
- Improved socket lifecycle hygiene by cancelling heartbeat on close/failure/disconnect paths.
- Added `connect` event mapping and centralized reconcile trigger in realtime handler.
- On realtime reconnect, chat repository now refreshes `all` and `archived` snapshots to reduce stale state after transient disconnects.
### Step 23 - Sprint P0 / 6) Auth hardening foundation
- Extended auth API/repository contracts with sessions management endpoints:
- `GET /api/v1/auth/sessions`
- `DELETE /api/v1/auth/sessions/{jti}`
- `DELETE /api/v1/auth/sessions`
- Added domain model and use-cases for listing/revoking sessions.
- Added unit coverage for session DTO -> domain mapping in `NetworkAuthRepositoryTest`.
### Step 24 - Sprint P0 / 7) Quality pass
- Added realtime parser unit coverage for `connect` event mapping.
- Extended message DAO tests with attachment relation verification.
- Added Android smoke and baseline document (`docs/android-smoke.md`) with test matrix and performance targets.
- Updated Android checklist quality section with initial performance baseline completion.
### Step 25 - UI safe insets fix
- Enabled edge-to-edge mode in `MainActivity` via `enableEdgeToEdge()`.
- Added safe area insets handling (`WindowInsets.safeDrawing`) for login, chat list, session-check and chat screens.
- Added bottom composer protection in chat screen with `navigationBarsPadding()` and `imePadding()`.
- Fixed UI overlap with status bar and navigation bar on modern Android devices.
### Step 26 - Core base / bulk forward foundation
- Added message API/data contracts for bulk forward (`POST /api/v1/messages/{message_id}/forward-bulk`).
- Extended `MessageRepository` with `forwardMessageBulk(...)`.
- Implemented bulk-forward flow in `NetworkMessageRepository` with Room/chat last-message updates.
- Added `ForwardMessageBulkUseCase` for future multi-select message actions.
- Updated message repository unit test fakes to cover new API surface.
### Step 27 - Core base / message action state machine
- Added reusable `MessageActionState` reducer with explicit selection modes (`NONE`, `SINGLE`, `MULTI`).
- Added action-intent contract for message operations (reply/edit/forward/delete/reaction/clear).
- Integrated `ChatViewModel` with reducer-backed selection logic while preserving current UI behavior.
- Added base ViewModel handlers for entering/toggling multi-select mode (`onEnterMultiSelect`, `onToggleMessageMultiSelection`, `onClearSelection`).
- Added unit tests for reducer transitions and available intents (`MessageActionStateTest`).
### Step 28 - Core base / Android multi-forward execution
- Switched chat forward state from single-message payload to `forwardingMessageIds` set.
- Extended `ChatViewModel` forward flow: multi-select now forwards multiple source messages in one action.
- Wired `ForwardMessageBulkUseCase` for multi-message forwarding (sequential safe execution with error short-circuit).
- Updated chat action bar and forward sheet labels for multi-selection count.
### Step 29 - Core base / multi-select delete execution
- Fixed multi-select delete behavior in `ChatViewModel`: `Delete` now applies to all selected messages, not only focused one.
- Added explicit guard for `Delete for all` in multi-select mode (single-message only).
### Step 30 - Core base / reply-forward preview data foundation
- Extended message DTO/Room/domain models with optional preview metadata:
- `replyPreviewText`, `replyPreviewSenderName`
- `forwardedFromDisplayName`
- sender profile fields from API payload (`senderDisplayName`, `senderUsername`, `senderAvatarUrl`)
- Added Room self-relation in `MessageLocalModel` to resolve reply preview fallback from referenced message.
- Updated message mappers and repository/realtime temporary entity creation for new model fields.
- Bumped Room schema version to `7`.
### Step 31 - Chat UI / reply-forward bubble blocks
- Added inline forwarded header rendering in message bubbles with display-name fallback.
- Added inline reply preview block in message bubbles (author + snippet) based on new preview fields/fallbacks.
- Updated Telegram UI batch-2 checklist items for reply-preview and forwarded header.
### Step 32 - Chat UI / pinned message bar
- Added `pinned_message_id` support in chat DTO/local/domain models and DAO selects.
- Extended `ChatViewModel` state with pinned message id + resolved pinned message object.
- Rendered pinned message bar under chat app bar with hide action.
- Updated Telegram UI batch-2 checklist item for pinned message block.
### Step 33 - Chat UI / top app bar restructuring
- Extended chat UI state with resolved chat header fields (`chatTitle`, `chatSubtitle`, `chatAvatarUrl`).
- Updated chat top app bar layout to Telegram-like structure: back, avatar, title, status, call action, menu action.
- Kept load-more behavior accessible via menu placeholder action button.
- Updated Telegram UI batch-2 checklist item for chat top app bar.
### Step 34 - Chat UI / composer restyling
- Reworked chat composer into rounded Telegram-like container with emoji slot, text input, attach button, and send/voice state button.
- Preserved send/upload state guards and existing insets handling (`navigationBarsPadding` + `imePadding`).
- Updated Telegram UI batch-2 checklist composer-related items.
### Step 35 - Chat UI / multi-select bars and overlays
- Split message selection UX into dedicated top selection bar (count/close/delete/edit/reactions) and bottom action bar (reply/forward).
- Enhanced selected bubble visual state with explicit selected marker text.
- Updated Telegram UI batch-2 checklist items for multi-select mode.
### Step 36 - Chat list / advanced states baseline
- Added chat-list local type filters (`All`, `People`, `Groups`, `Channels`) with new `ChatListFilter` UI state.
- Added archive statistics stream in `ChatListViewModel` and special archive top-row entry in `All` tab.
- Extended list preview formatting with media-type markers and retained unread/mention/pinned indicators.
- Updated Telegram UI checklists for chat-list advanced states (batch 2 and batch 3).
### Step 37 - Chat UI / wallpaper-aware readability
- Added gradient wallpaper-like chat background layer in `ChatScreen`.
- Kept pinned/composer/action surfaces on semi-transparent containers to preserve readability over wallpaper.
- Updated Telegram UI checklist items for wallpaper and overlay readability.
### Step 38 - Quality/docs / mapper fallback coverage
- Added `MessageMappersTest` to verify reply preview fallback resolution from Room self-relation (`reply_to_message_id`).
- Updated Android master checklist for completed chat list tabs/filters coverage.
### Step 39 - Android scope / remove calls UI
- Removed chat top-bar `Call` action from Android `ChatScreen`.
- Updated Android UI checklist wording to reflect chat header without calls support.
### Step 40 - Invite deep link flow (app links)
- Added Android App Links intent filter for `https://chat.daemonlord.ru/join...`.
- Added invite token extraction from incoming intents (`query token` and `/join/{token}` path formats).
- Wired deep link token into `MessengerNavHost -> ChatListRoute -> ChatListViewModel` auto-join flow.
- Removed manual `Invite token` input row from chat list screen.
### Step 41 - Chat UI / long-press action menu
- Added long-press message action card in `ChatScreen` with quick reactions.
- Added context actions from long-press: reply, edit, forward, delete, select, close.
- Added placeholder disabled pin action in the menu to keep action set consistent with Telegram-like flow.
- Updated Telegram UI batch-2 checklist items for long-press reactions and context menu.
### Step 42 - Chat list / row and FAB parity pass
- Updated chat list rows with avatar rendering, trailing message time, and richer right-side metadata layout.
- Kept unread/mention/pinned/muted indicators while aligning row structure closer to Telegram list pattern.
- Added floating compose FAB placeholder at bottom-right in chat list screen.
- Updated Telegram UI batch-2 checklist chat-list parity items.
### Step 43 - Chat list / floating bottom navigation shell
- Added floating rounded bottom navigation container on chat list screen.
- Added active tab visual state (Chats selected) with pill styling.
- Updated Telegram UI checklists for bottom-nav shell parity (batch 1 and batch 2).
### Step 44 - Chat UI / bubble density pass
- Updated message bubble shapes for incoming/outgoing messages to denser rounded Telegram-like contours.
- Kept bottom-right time + delivery state rendering in bubble footer after time formatting update.
- Updated Telegram UI batch-2 checklist item for message bubble parity.
### Step 45 - Chat UI / media bubble improvements
- Added richer video attachment card rendering in message bubbles.
- Added file-list style attachment rows (icon + filename + type/size metadata).
- Upgraded non-voice audio attachment player with play/pause, progress bar, and current/total duration labels.
- Updated Telegram UI batch-2 checklist media-bubble items.
### Step 46 - Media viewer / header and gallery navigation
- Upgraded chat image viewer to use global image gallery state (`index / total`) instead of a single URL.
- Added fullscreen viewer header with close, index, share placeholder, and delete placeholder actions.
- Added image navigation controls (`Prev`/`Next`) for gallery traversal.
- Updated Telegram UI batch-2 checklist for fullscreen media header support.
### Step 47 - Notifications foundation (FCM + channels + deep links)
- Added Firebase Messaging dependency and Android manifest wiring for `POST_NOTIFICATIONS`.
- Added notification channels (`messages`, `mentions`, `system`) with startup initialization in `MessengerApplication`.
- Added push service (`MessengerFirebaseMessagingService`) + payload parser + notification dispatcher.
- Added notification tap deep-link handling to open target chat from `MainActivity` via nav host.
- Added runtime notification permission request flow (Android 13+) in `MessengerNavHost`.
- Added parser unit test (`PushPayloadParserTest`).
### Step 48 - Foreground local notifications from realtime
- Added `ActiveChatTracker` to suppress local notifications for currently opened chat.
- Wired realtime receive-message handling to trigger local notification via `NotificationDispatcher` when chat is not active.
- Added chat title lookup helper in `ChatDao` for notification titles.
- Added explicit realtime stop in `ChatViewModel.onCleared()` to avoid stale collectors.
### Step 49 - Mention override for muted chats
- Extended realtime receive-message model/parsing with `isMention` flag support.
- Added muted-chat guard in realtime notification flow: muted chats stay silent unless message is a mention.
- Routed mention notifications to mentions channel/priority via `NotificationDispatcher`.
- Added parser unit test for mention-flag mapping.
### Step 50 - Notification settings storage (DataStore)
- Added domain notification settings models/repository contracts (global + per-chat override).
- Added `DataStoreNotificationSettingsRepository` with persistence for global flags and per-chat override mode.
- Added `ShouldShowMessageNotificationUseCase` and wired realtime notifications through it.
- Added unit tests for DataStore notification settings repository and notification visibility use case.
### Step 51 - Logout with full local cleanup
- Added `LogoutUseCase` with centralized sign-out flow: disconnect realtime, clear active chat, clear auth session, and clear local cached data.
- Added `SessionCleanupRepository` + `DefaultSessionCleanupRepository` to wipe Room tables and clear per-chat notification overrides.
- Added logout action in chat list UI and wired it to `AuthViewModel`, with automatic navigation back to login via auth state.
- Added unit tests for logout use case orchestration and notification override cleanup.
### Step 52 - Settings/Profile shell and logout relocation
- Added dedicated `Settings` and `Profile` routes/screens with mobile-safe insets and placeholder content.
- Removed direct logout action from chat list and moved logout action to `Settings`.
- Wired bottom navigation pills in chats to open `Settings` and `Profile`.
### Step 53 - Secure token storage (Keystore-backed)
- Added `EncryptedPrefsTokenRepository` backed by `EncryptedSharedPreferences` and Android `MasterKey` (Keystore).
- Switched DI token binding from DataStore token repository to encrypted shared preferences repository.
- Kept DataStore for non-token app settings and renamed preferences file to `messenger_preferences.preferences_pb`.
### Step 54 - Message interactions: tap menu vs long-press select
- Updated chat message gesture behavior to match Telegram pattern:
- tap opens contextual message menu with reactions/actions,
- long-press enters multi-select mode directly.
- Hid single-selection action bars while contextual menu is visible to avoid mixed UX states.
- Improved multi-select visual affordance with per-message selection indicator circles.
### Step 55 - Chat multi-select action cleanup
- Removed duplicate forward action in multi-select mode (`Forward selected`), leaving a single clear forward action button.
### Step 56 - Unified API error handling
- Added shared API error mapper (`ApiErrorMapper`) with mode-aware mapping (`DEFAULT`, `LOGIN`).
- Switched auth/chat/message/media repositories to a single `Throwable -> AppError` mapping source.
- Kept login-specific invalid-credentials mapping while standardizing unauthorized/server/network handling for other API calls.
### Step 57 - Offline-first message history reading
- Added paged local history reading path by introducing configurable message observe limit (`observeMessages(chatId, limit)`).
- Updated chat screen loading strategy to expand local Room-backed history first when loading older messages.
- Added network-failure fallback in message sync/load-more: if network is unavailable but local cache exists, chat remains readable without blocking error.
### Step 58 - Keep authenticated session when offline at app start
- Updated auth restore flow in `AuthViewModel`: network errors during session restore no longer force logout when local tokens exist.
- App now opens authenticated flow in offline mode instead of redirecting to login.
### Step 59 - Deferred message action queue (send/edit/delete)
- Added Room-backed pending action queue (`pending_message_actions`) for message operations that fail due to network issues.
- Implemented enqueue + optimistic behavior for `sendText`, `editMessage`, and `deleteMessage` on network failures.
- Added automatic pending-action flush on chat sync/load-more and before new message operations.
- Kept non-network server failures as immediate errors (no queueing), while allowing offline continuation.
### Step 60 - Media cache foundation (Coil + Exo cache)
- Added global Coil image loader cache policy in `MessengerApplication` (memory + disk cache).
- Added Media3 `SimpleCache` singleton module for media stream/file caching foundation.
- Added Media3/Coil core dependencies and configured cache sizes for mobile usage.
### Step 61 - Compose UI tests baseline
- Added instrumented Compose UI tests for login and chat list states.
- Added Android test dependencies for Compose test runner (`ui-test-junit4`) and test infra.
- Covered key visual states: auth error rendering, chat list loading state, and empty state.
### Step 62 - Android CI pipeline
- Added dedicated Android CI workflow for `main` branch and PRs.
- CI now runs Android build, unit tests, lint, and androidTest assemble.
- Added optional detekt execution step (auto-skipped when detekt task is not configured).
### Step 63 - Integration tests for auth/chat/realtime
- Kept repository-level integration coverage for auth/chat data flows (MockWebServer + in-memory storage).
- Added `RealtimePipelineIntegrationTest` to validate realtime event handling pipeline (`receive_message` -> Room state update).
- Consolidated quality checklist integration test coverage for auth/chat/realtime.
### Step 64 - Android release workflow
- Added dedicated release workflow (`.github/workflows/android-release.yml`) for `main` branch pushes.
- Adapted version extraction for Kotlin DSL (`android/app/build.gradle.kts`) and guarded release by existing git tag.
- Wired release build, git tag push, and Gitea release publication with APK artifact upload.
### Step 65 - Account and media parity foundation (checklist 1-15)
- Introduced `:core:common` module and moved base `AppError`/`AppResult` contracts out of `:app`.
- Added structured app logging (`Timber`) and crash reporting baseline (`Firebase Crashlytics`) with app startup wiring.
- Added API version header interceptor + build-time feature flags and DI provider.
- Added account network layer for auth/account management:
- verify email, password reset request/reset,
- sessions list + revoke one/all,
- 2FA setup/enable/disable + recovery status/regenerate,
- profile/privacy update and blocked users management.
- Added deep-link aware auth routes for `/verify-email` and `/reset-password`.
- Reworked Settings/Profile screens from placeholders to editable account management screens.
- Added avatar upload with center square crop (`1:1`) before upload.
- Upgraded message attachment rendering with in-bubble multi-image gallery and unified attachment context actions (open/copy/close).
### 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 {
id("com.android.application")
id("org.jetbrains.kotlin.android")
@@ -5,8 +7,19 @@ plugins {
id("org.jetbrains.kotlin.kapt")
id("org.jetbrains.kotlin.plugin.serialization")
id("com.google.dagger.hilt.android")
id("com.google.gms.google-services")
id("com.google.firebase.crashlytics")
}
val localProperties = Properties().apply {
val file = rootProject.file("local.properties")
if (file.exists()) {
file.inputStream().use { load(it) }
}
}
fun String.escapeForBuildConfig(): String = replace("\\", "\\\\").replace("\"", "\\\"")
android {
namespace = "ru.daemonlord.messenger"
compileSdk = 35
@@ -18,6 +31,16 @@ android {
versionCode = 1
versionName = "0.1.0"
buildConfigField("String", "API_BASE_URL", "\"https://chat.daemonlord.ru/\"")
buildConfigField("String", "API_VERSION_HEADER", "\"2026-03\"")
val giphyApiKey = (
localProperties.getProperty("GIPHY_API_KEY")
?: System.getenv("GIPHY_API_KEY")
?: ""
).trim()
buildConfigField("String", "GIPHY_API_KEY", "\"${giphyApiKey.escapeForBuildConfig()}\"")
buildConfigField("boolean", "FEATURE_ACCOUNT_MANAGEMENT", "true")
buildConfigField("boolean", "FEATURE_TWO_FACTOR", "true")
buildConfigField("boolean", "FEATURE_MEDIA_GALLERY", "true")
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables {
@@ -61,7 +84,11 @@ android {
}
dependencies {
implementation(project(":core:common"))
implementation(platform("com.google.firebase:firebase-bom:34.10.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-compose:2.8.7")
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.8.7")
@@ -71,6 +98,20 @@ dependencies {
implementation("androidx.compose.ui:ui:1.7.6")
implementation("androidx.compose.ui:ui-tooling-preview:1.7.6")
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-compose:2.7.0")
implementation("io.coil-kt:coil-gif:2.7.0")
implementation("io.coil-kt:coil-video:2.7.0")
implementation("androidx.media3:media3-exoplayer:1.4.1")
implementation("androidx.media3:media3-ui:1.4.1")
implementation("androidx.media3:media3-datasource:1.4.1")
implementation("androidx.media3:media3-datasource-okhttp:1.4.1")
implementation("androidx.camera:camera-core:1.4.2")
implementation("androidx.camera:camera-camera2:1.4.2")
implementation("androidx.camera:camera-lifecycle:1.4.2")
implementation("androidx.camera:camera-video:1.4.2")
implementation("androidx.camera:camera-view:1.4.2")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0")
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3")
@@ -81,6 +122,7 @@ dependencies {
implementation("com.squareup.okhttp3:logging-interceptor:4.12.0")
implementation("androidx.datastore:datastore-preferences:1.1.1")
implementation("androidx.security:security-crypto:1.1.0-alpha06")
implementation("androidx.room:room-runtime:2.6.1")
implementation("androidx.room:room-ktx:2.6.1")
kapt("androidx.room:room-compiler:2.6.1")
@@ -88,6 +130,9 @@ dependencies {
implementation("com.google.dagger:hilt-android:2.52")
kapt("com.google.dagger:hilt-compiler:2.52")
implementation("androidx.hilt:hilt-navigation-compose:1.2.0")
implementation("com.google.firebase:firebase-messaging")
implementation("com.google.firebase:firebase-crashlytics")
implementation("com.jakewharton.timber:timber:5.0.1")
testImplementation("junit:junit:4.13.2")
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.9.0")
@@ -96,6 +141,9 @@ dependencies {
testImplementation("androidx.test:core:1.6.1")
testImplementation("org.robolectric:robolectric:4.13")
testImplementation("com.squareup.okhttp3:mockwebserver:4.12.0")
androidTestImplementation("androidx.compose.ui:ui-test-junit4:1.7.6")
androidTestImplementation("androidx.test.ext:junit:1.2.1")
androidTestImplementation("androidx.test.espresso:espresso-core:3.6.1")
debugImplementation("androidx.compose.ui:ui-tooling:1.7.6")
debugImplementation("androidx.compose.ui:ui-test-manifest:1.7.6")
@@ -104,3 +152,37 @@ dependencies {
kapt {
correctErrorTypes = true
}
fun registerHiltInjectorBackfillTask(variantName: String) {
val cap = variantName.replaceFirstChar { if (it.isLowerCase()) it.titlecase() else it.toString() }
val taskName = "backfill${cap}HiltApplicationInjector"
val dexBuilderTaskName = "dexBuilder$cap"
val compileJavaTaskName = "compile${cap}JavaWithJavac"
tasks.register(taskName) {
dependsOn(compileJavaTaskName)
doLast {
val javacOutput = file("$buildDir/intermediates/javac/$variantName/$compileJavaTaskName/classes")
val asmOutput = file("$buildDir/intermediates/classes/$variantName/transform${cap}ClassesWithAsm/dirs")
if (!javacOutput.exists() || !asmOutput.exists()) return@doLast
fileTree(javacOutput) {
include("**/*Application_GeneratedInjector.class")
}.forEach { source ->
val relativePath = source.relativeTo(javacOutput).path
val target = file("${asmOutput.path}/$relativePath")
if (!target.exists()) {
target.parentFile?.mkdirs()
source.copyTo(target, overwrite = true)
}
}
}
}
tasks.matching { it.name == dexBuilderTaskName }.configureEach {
dependsOn(taskName)
}
}
registerHiltInjectorBackfillTask("debug")
registerHiltInjectorBackfillTask("release")

View File

@@ -0,0 +1,35 @@
package ru.daemonlord.messenger.ui.auth
import androidx.activity.ComponentActivity
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onNodeWithText
import org.junit.Rule
import org.junit.Test
class LoginScreenTest {
@get:Rule
val composeRule = createAndroidComposeRule<ComponentActivity>()
@Test
fun loginScreen_showsErrorMessage() {
composeRule.setContent {
LoginScreen(
state = AuthUiState(
email = "demo@daemonlord.ru",
password = "123456",
errorMessage = "Invalid email or password.",
),
onEmailChanged = {},
onPasswordChanged = {},
onLoginClick = {},
)
}
composeRule.onNodeWithText("Messenger Login").assertIsDisplayed()
composeRule.onNodeWithText("Invalid email or password.").assertIsDisplayed()
composeRule.onNodeWithText("Login").assertIsDisplayed()
}
}

View File

@@ -0,0 +1,51 @@
package ru.daemonlord.messenger.ui.chats
import androidx.activity.ComponentActivity
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onNodeWithText
import org.junit.Rule
import org.junit.Test
class ChatListScreenTest {
@get:Rule
val composeRule = createAndroidComposeRule<ComponentActivity>()
@Test
fun chatListScreen_loadingState_showsLoadingText() {
composeRule.setContent {
ChatListScreen(
state = ChatListUiState(isLoading = true),
onTabSelected = {},
onFilterSelected = {},
onSearchChanged = {},
onRefresh = {},
onOpenSettings = {},
onOpenProfile = {},
onOpenChat = {},
)
}
composeRule.onNodeWithText("Loading chats...").assertIsDisplayed()
}
@Test
fun chatListScreen_emptyState_showsPlaceholder() {
composeRule.setContent {
ChatListScreen(
state = ChatListUiState(isLoading = false, chats = emptyList()),
onTabSelected = {},
onFilterSelected = {},
onSearchChanged = {},
onRefresh = {},
onOpenSettings = {},
onOpenProfile = {},
onOpenChat = {},
)
}
composeRule.onNodeWithText("No chats found").assertIsDisplayed()
}
}

View File

@@ -2,16 +2,20 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.CAMERA" />
<application
android:name=".MessengerApplication"
android:allowBackup="true"
android:enableOnBackInvokedCallback="true"
android:icon="@android:drawable/sym_def_app_icon"
android:label="@string/app_name"
android:roundIcon="@android:drawable/sym_def_app_icon"
android:supportsRtl="true"
android:usesCleartextTraffic="true"
android:theme="@android:style/Theme.Material.Light.NoActionBar">
android:theme="@style/Theme.Messenger">
<activity
android:name=".MainActivity"
android:exported="true">
@@ -19,7 +23,40 @@
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<intent-filter android:autoVerify="true">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data
android:host="chat.daemonlord.ru"
android:pathPrefix="/join"
android:scheme="https" />
<data
android:host="chat.daemonlord.ru"
android:pathPrefix="/verify-email"
android:scheme="https" />
<data
android:host="chat.daemonlord.ru"
android:pathPrefix="/reset-password"
android:scheme="https" />
</intent-filter>
</activity>
<service
android:name=".push.MessengerFirebaseMessagingService"
android:exported="false">
<intent-filter>
<action android:name="com.google.firebase.MESSAGING_EVENT" />
</intent-filter>
</service>
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths" />
</provider>
</application>
</manifest>

View File

@@ -1,31 +1,171 @@
package ru.daemonlord.messenger
import android.content.Intent
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.enableEdgeToEdge
import androidx.activity.compose.setContent
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.foundation.layout.fillMaxSize
import androidx.appcompat.app.AppCompatActivity
import androidx.appcompat.app.AppCompatDelegate
import androidx.core.os.LocaleListCompat
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.runBlocking
import ru.daemonlord.messenger.core.notifications.NotificationDispatcher
import ru.daemonlord.messenger.core.notifications.NotificationIntentExtras
import ru.daemonlord.messenger.domain.settings.repository.LanguageRepository
import ru.daemonlord.messenger.domain.settings.model.AppThemeMode
import ru.daemonlord.messenger.domain.settings.repository.ThemeRepository
import ru.daemonlord.messenger.ui.navigation.MessengerNavHost
import ru.daemonlord.messenger.ui.theme.MessengerTheme
import javax.inject.Inject
@AndroidEntryPoint
class MainActivity : 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 pendingVerifyEmailToken by mutableStateOf<String?>(null)
private var pendingResetPasswordToken by mutableStateOf<String?>(null)
private var pendingNotificationChatId by mutableStateOf<Long?>(null)
private var pendingNotificationMessageId by mutableStateOf<Long?>(null)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val savedThemeMode = if (this::themeRepository.isInitialized) {
runBlocking { themeRepository.getThemeMode() }
} else {
AppThemeMode.SYSTEM
}
AppCompatDelegate.setDefaultNightMode(
when (savedThemeMode) {
AppThemeMode.LIGHT -> AppCompatDelegate.MODE_NIGHT_NO
AppThemeMode.DARK -> AppCompatDelegate.MODE_NIGHT_YES
AppThemeMode.SYSTEM -> AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM
}
)
val savedLanguageTag = if (this::languageRepository.isInitialized) {
runBlocking { languageRepository.getLanguage().tag }
} else {
null
}
val locales = savedLanguageTag?.let { LocaleListCompat.forLanguageTags(it) } ?: LocaleListCompat.getEmptyLocaleList()
AppCompatDelegate.setApplicationLocales(locales)
pendingInviteToken = intent.extractInviteToken()
pendingVerifyEmailToken = intent.extractVerifyEmailToken()
pendingResetPasswordToken = intent.extractResetPasswordToken()
val notificationPayload = intent.extractNotificationOpenPayload()
pendingNotificationChatId = notificationPayload?.first
pendingNotificationMessageId = notificationPayload?.second
notificationPayload?.first?.let(notificationDispatcher::clearChatNotifications)
enableEdgeToEdge()
setContent {
MaterialTheme {
MessengerTheme {
Surface(modifier = Modifier.fillMaxSize()) {
AppRoot()
AppRoot(
inviteToken = pendingInviteToken,
onInviteTokenConsumed = { pendingInviteToken = null },
verifyEmailToken = pendingVerifyEmailToken,
onVerifyEmailTokenConsumed = { pendingVerifyEmailToken = null },
resetPasswordToken = pendingResetPasswordToken,
onResetPasswordTokenConsumed = { pendingResetPasswordToken = null },
notificationChatId = pendingNotificationChatId,
notificationMessageId = pendingNotificationMessageId,
onNotificationConsumed = {
pendingNotificationChatId = null
pendingNotificationMessageId = null
},
)
}
}
}
}
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
setIntent(intent)
pendingInviteToken = intent.extractInviteToken() ?: pendingInviteToken
pendingVerifyEmailToken = intent.extractVerifyEmailToken() ?: pendingVerifyEmailToken
pendingResetPasswordToken = intent.extractResetPasswordToken() ?: pendingResetPasswordToken
val notificationPayload = intent.extractNotificationOpenPayload()
if (notificationPayload != null) {
pendingNotificationChatId = notificationPayload.first
pendingNotificationMessageId = notificationPayload.second
notificationDispatcher.clearChatNotifications(notificationPayload.first)
}
}
}
@Composable
private fun AppRoot() {
MessengerNavHost()
private fun AppRoot(
inviteToken: String?,
onInviteTokenConsumed: () -> Unit,
verifyEmailToken: String?,
onVerifyEmailTokenConsumed: () -> Unit,
resetPasswordToken: String?,
onResetPasswordTokenConsumed: () -> Unit,
notificationChatId: Long?,
notificationMessageId: Long?,
onNotificationConsumed: () -> Unit,
) {
MessengerNavHost(
inviteToken = inviteToken,
onInviteTokenConsumed = onInviteTokenConsumed,
verifyEmailToken = verifyEmailToken,
onVerifyEmailTokenConsumed = onVerifyEmailTokenConsumed,
resetPasswordToken = resetPasswordToken,
onResetPasswordTokenConsumed = onResetPasswordTokenConsumed,
notificationChatId = notificationChatId,
notificationMessageId = notificationMessageId,
onNotificationConsumed = onNotificationConsumed,
)
}
private fun Intent?.extractVerifyEmailToken(): String? {
val uri = this?.data ?: return null
val isVerifyPath = uri.pathSegments.contains("verify-email") || uri.path.equals("/verify-email", ignoreCase = true)
if (!isVerifyPath) return null
return uri.getQueryParameter("token")?.trim()?.takeIf { it.isNotBlank() }
}
private fun Intent?.extractResetPasswordToken(): String? {
val uri = this?.data ?: return null
val isResetPath = uri.pathSegments.contains("reset-password") || uri.path.equals("/reset-password", ignoreCase = true)
if (!isResetPath) return null
return uri.getQueryParameter("token")?.trim()?.takeIf { it.isNotBlank() }
}
private fun Intent?.extractInviteToken(): String? {
val uri = this?.data ?: return null
val queryToken = uri.getQueryParameter("token")?.trim().orEmpty()
if (queryToken.isNotBlank()) return queryToken
val segments = uri.pathSegments
val joinIndex = segments.indexOf("join")
if (joinIndex >= 0 && joinIndex + 1 < segments.size) {
val token = segments[joinIndex + 1].trim()
if (token.isNotBlank()) return token
}
return null
}
private fun Intent?.extractNotificationOpenPayload(): Pair<Long, Long?>? {
val source = this ?: return null
val chatId = source.getLongExtra(NotificationIntentExtras.EXTRA_CHAT_ID, -1L)
if (chatId <= 0L) return null
val rawMessageId = source.getLongExtra(NotificationIntentExtras.EXTRA_MESSAGE_ID, -1L)
val messageId = rawMessageId.takeIf { it > 0L }
return chatId to messageId
}

View File

@@ -1,7 +1,62 @@
package ru.daemonlord.messenger
import android.app.Application
import android.os.Build
import coil.ImageLoader
import coil.ImageLoaderFactory
import coil.decode.GifDecoder
import coil.decode.ImageDecoderDecoder
import coil.disk.DiskCache
import coil.memory.MemoryCache
import com.google.firebase.crashlytics.FirebaseCrashlytics
import dagger.hilt.android.HiltAndroidApp
import ru.daemonlord.messenger.core.notifications.NotificationChannels
import ru.daemonlord.messenger.push.PushTokenSyncManager
import java.io.File
import timber.log.Timber
import javax.inject.Inject
@HiltAndroidApp
class MessengerApplication : Application()
class MessengerApplication : Application(), ImageLoaderFactory {
@Inject
lateinit var pushTokenSyncManager: PushTokenSyncManager
override fun onCreate() {
super.onCreate()
if (BuildConfig.DEBUG) {
Timber.plant(Timber.DebugTree())
}
FirebaseCrashlytics.getInstance().setCrashlyticsCollectionEnabled(!BuildConfig.DEBUG)
NotificationChannels.ensureCreated(this)
pushTokenSyncManager.triggerBestEffortSync()
}
override fun newImageLoader(): ImageLoader {
val diskCacheDir = File(cacheDir, "coil_images")
if (!diskCacheDir.exists()) {
diskCacheDir.mkdirs()
}
return ImageLoader.Builder(this)
.components {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
add(ImageDecoderDecoder.Factory())
} else {
add(GifDecoder.Factory())
}
}
.memoryCache {
MemoryCache.Builder(this)
.maxSizePercent(0.25)
.build()
}
.diskCache {
DiskCache.Builder()
.directory(diskCacheDir)
.maxSizeBytes(250L * 1024L * 1024L)
.build()
}
.respectCacheHeaders(false)
.build()
}
}

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

@@ -0,0 +1,38 @@
package ru.daemonlord.messenger.core.logging
import com.google.firebase.crashlytics.FirebaseCrashlytics
import javax.inject.Inject
import javax.inject.Singleton
import timber.log.Timber
@Singleton
class TimberAppLogger @Inject constructor(
private val crashlytics: FirebaseCrashlytics,
) : AppLogger {
override fun d(tag: String, message: String) {
Timber.tag(tag).d(message)
}
override fun i(tag: String, message: String) {
Timber.tag(tag).i(message)
}
override fun w(tag: String, message: String, throwable: Throwable?) {
if (throwable != null) {
Timber.tag(tag).w(throwable, message)
crashlytics.recordException(throwable)
} else {
Timber.tag(tag).w(message)
}
}
override fun e(tag: String, message: String, throwable: Throwable?) {
if (throwable != null) {
Timber.tag(tag).e(throwable, message)
crashlytics.recordException(throwable)
} else {
Timber.tag(tag).e(message)
}
}
}

View File

@@ -0,0 +1,17 @@
package ru.daemonlord.messenger.core.network
import okhttp3.Interceptor
import okhttp3.Response
import ru.daemonlord.messenger.BuildConfig
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class ApiVersionInterceptor @Inject constructor() : Interceptor {
override fun intercept(chain: Interceptor.Chain): Response {
val request = chain.request().newBuilder()
.header("X-Api-Version", BuildConfig.API_VERSION_HEADER)
.build()
return chain.proceed(request)
}
}

View File

@@ -0,0 +1,27 @@
package ru.daemonlord.messenger.core.notifications
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class ActiveChatTracker @Inject constructor() {
private val _activeChatId = MutableStateFlow<Long?>(null)
val activeChatId: StateFlow<Long?> = _activeChatId.asStateFlow()
fun setActiveChat(chatId: Long) {
_activeChatId.value = chatId
}
fun clearActiveChat(chatId: Long) {
if (_activeChatId.value == chatId) {
_activeChatId.value = null
}
}
fun clear() {
_activeChatId.value = null
}
}

View File

@@ -0,0 +1,41 @@
package ru.daemonlord.messenger.core.notifications
import android.app.NotificationChannel
import android.app.NotificationManager
import android.content.Context
import android.os.Build
object NotificationChannels {
const val CHANNEL_MESSAGES = "messages"
const val CHANNEL_MENTIONS = "mentions"
const val CHANNEL_SYSTEM = "system"
fun ensureCreated(context: Context) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
val channels = listOf(
NotificationChannel(
CHANNEL_MESSAGES,
"Messages",
NotificationManager.IMPORTANCE_DEFAULT,
).apply {
description = "New chat messages"
},
NotificationChannel(
CHANNEL_MENTIONS,
"Mentions",
NotificationManager.IMPORTANCE_HIGH,
).apply {
description = "Mentions in chats"
},
NotificationChannel(
CHANNEL_SYSTEM,
"System",
NotificationManager.IMPORTANCE_LOW,
).apply {
description = "Service and system updates"
},
)
manager.createNotificationChannels(channels)
}
}

View File

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

View File

@@ -0,0 +1,14 @@
package ru.daemonlord.messenger.core.notifications
data class ChatNotificationPayload(
val chatId: Long,
val messageId: Long?,
val title: String,
val body: String,
val isMention: Boolean = false,
)
object NotificationIntentExtras {
const val EXTRA_CHAT_ID = "extra_chat_id"
const val EXTRA_MESSAGE_ID = "extra_message_id"
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,15 +1,38 @@
package ru.daemonlord.messenger.data.auth.api
import ru.daemonlord.messenger.data.auth.dto.AuthUserDto
import ru.daemonlord.messenger.data.auth.dto.AuthSessionDto
import ru.daemonlord.messenger.data.auth.dto.CheckEmailStatusDto
import ru.daemonlord.messenger.data.auth.dto.LoginRequestDto
import ru.daemonlord.messenger.data.auth.dto.MessageResponseDto
import ru.daemonlord.messenger.data.auth.dto.RegisterRequestDto
import ru.daemonlord.messenger.data.auth.dto.ResendVerificationRequestDto
import ru.daemonlord.messenger.data.auth.dto.RequestPasswordResetDto
import ru.daemonlord.messenger.data.auth.dto.ResetPasswordRequestDto
import ru.daemonlord.messenger.data.auth.dto.RefreshTokenRequestDto
import ru.daemonlord.messenger.data.auth.dto.TokenResponseDto
import ru.daemonlord.messenger.data.auth.dto.TwoFactorCodeRequestDto
import ru.daemonlord.messenger.data.auth.dto.TwoFactorRecoveryCodesDto
import ru.daemonlord.messenger.data.auth.dto.TwoFactorRecoveryStatusDto
import ru.daemonlord.messenger.data.auth.dto.TwoFactorSetupDto
import ru.daemonlord.messenger.data.auth.dto.VerifyEmailRequestDto
import retrofit2.http.Headers
import retrofit2.http.Body
import retrofit2.http.GET
import retrofit2.http.POST
import retrofit2.http.DELETE
import retrofit2.http.Path
import retrofit2.http.Query
interface AuthApiService {
@Headers("No-Auth: true")
@GET("/api/v1/auth/check-email")
suspend fun checkEmailStatus(@Query("email") email: String): CheckEmailStatusDto
@Headers("No-Auth: true")
@POST("/api/v1/auth/register")
suspend fun register(@Body request: RegisterRequestDto): MessageResponseDto
@Headers("No-Auth: true")
@POST("/api/v1/auth/login")
suspend fun login(@Body request: LoginRequestDto): TokenResponseDto
@@ -20,4 +43,44 @@ interface AuthApiService {
@GET("/api/v1/auth/me")
suspend fun me(): AuthUserDto
@Headers("No-Auth: true")
@POST("/api/v1/auth/verify-email")
suspend fun verifyEmail(@Body request: VerifyEmailRequestDto): MessageResponseDto
@Headers("No-Auth: true")
@POST("/api/v1/auth/request-password-reset")
suspend fun requestPasswordReset(@Body request: RequestPasswordResetDto): MessageResponseDto
@Headers("No-Auth: true")
@POST("/api/v1/auth/resend-verification")
suspend fun resendVerification(@Body request: ResendVerificationRequestDto): MessageResponseDto
@Headers("No-Auth: true")
@POST("/api/v1/auth/reset-password")
suspend fun resetPassword(@Body request: ResetPasswordRequestDto): MessageResponseDto
@GET("/api/v1/auth/sessions")
suspend fun sessions(): List<AuthSessionDto>
@DELETE("/api/v1/auth/sessions/{jti}")
suspend fun revokeSession(@Path("jti") jti: String)
@DELETE("/api/v1/auth/sessions")
suspend fun revokeAllSessions()
@POST("/api/v1/auth/2fa/setup")
suspend fun setupTwoFactor(): TwoFactorSetupDto
@POST("/api/v1/auth/2fa/enable")
suspend fun enableTwoFactor(@Body request: TwoFactorCodeRequestDto): MessageResponseDto
@POST("/api/v1/auth/2fa/disable")
suspend fun disableTwoFactor(@Body request: TwoFactorCodeRequestDto): MessageResponseDto
@GET("/api/v1/auth/2fa/recovery-codes/status")
suspend fun twoFactorRecoveryStatus(): TwoFactorRecoveryStatusDto
@POST("/api/v1/auth/2fa/recovery-codes/regenerate")
suspend fun regenerateTwoFactorRecoveryCodes(@Body request: TwoFactorCodeRequestDto): TwoFactorRecoveryCodesDto
}

View File

@@ -7,6 +7,10 @@ import kotlinx.serialization.Serializable
data class LoginRequestDto(
val email: String,
val password: String,
@SerialName("otp_code")
val otpCode: String? = null,
@SerialName("recovery_code")
val recoveryCode: String? = null,
)
@Serializable
@@ -31,13 +35,105 @@ data class AuthUserDto(
val email: String,
val name: String,
val username: String,
val bio: String? = null,
@SerialName("avatar_url")
val avatarUrl: String? = null,
@SerialName("email_verified")
val emailVerified: Boolean,
@SerialName("twofa_enabled")
val twofaEnabled: Boolean = false,
@SerialName("privacy_private_messages")
val privacyPrivateMessages: String? = null,
@SerialName("privacy_last_seen")
val privacyLastSeen: String? = null,
@SerialName("privacy_avatar")
val privacyAvatar: String? = null,
@SerialName("privacy_group_invites")
val privacyGroupInvites: String? = null,
)
@Serializable
data class AuthSessionDto(
val jti: String,
@SerialName("created_at")
val createdAt: String,
@SerialName("ip_address")
val ipAddress: String? = null,
@SerialName("user_agent")
val userAgent: String? = null,
val current: Boolean? = null,
@SerialName("token_type")
val tokenType: String? = null,
)
@Serializable
data class ErrorResponseDto(
val detail: String? = null,
)
@Serializable
data class MessageResponseDto(
val message: String,
)
@Serializable
data class VerifyEmailRequestDto(
val token: String,
)
@Serializable
data class RequestPasswordResetDto(
val email: String,
)
@Serializable
data class ResendVerificationRequestDto(
val email: String,
)
@Serializable
data class ResetPasswordRequestDto(
val token: String,
val password: String,
)
@Serializable
data class CheckEmailStatusDto(
val email: String,
val registered: Boolean,
@SerialName("email_verified")
val emailVerified: Boolean,
@SerialName("twofa_enabled")
val twofaEnabled: Boolean,
)
@Serializable
data class RegisterRequestDto(
val email: String,
val name: String,
val username: String,
val password: String,
)
@Serializable
data class TwoFactorSetupDto(
val secret: String,
@SerialName("otpauth_url")
val otpauthUrl: String,
)
@Serializable
data class TwoFactorCodeRequestDto(
val code: String,
)
@Serializable
data class TwoFactorRecoveryStatusDto(
@SerialName("remaining_codes")
val remainingCodes: Int,
)
@Serializable
data class TwoFactorRecoveryCodesDto(
val codes: List<String>,
)

View File

@@ -0,0 +1,24 @@
package ru.daemonlord.messenger.data.auth.repository
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.withContext
import ru.daemonlord.messenger.data.chat.local.db.MessengerDatabase
import ru.daemonlord.messenger.di.IoDispatcher
import ru.daemonlord.messenger.domain.auth.repository.SessionCleanupRepository
import ru.daemonlord.messenger.domain.notifications.repository.NotificationSettingsRepository
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class DefaultSessionCleanupRepository @Inject constructor(
private val database: MessengerDatabase,
private val notificationSettingsRepository: NotificationSettingsRepository,
@IoDispatcher private val ioDispatcher: CoroutineDispatcher,
) : SessionCleanupRepository {
override suspend fun clearLocalSessionData() = withContext(ioDispatcher) {
database.clearAllTables()
notificationSettingsRepository.clearChatOverrides()
}
}

View File

@@ -2,19 +2,26 @@ package ru.daemonlord.messenger.data.auth.repository
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.withContext
import retrofit2.HttpException
import ru.daemonlord.messenger.core.token.TokenBundle
import ru.daemonlord.messenger.core.token.StoredAccount
import ru.daemonlord.messenger.core.token.TokenRepository
import ru.daemonlord.messenger.data.auth.api.AuthApiService
import ru.daemonlord.messenger.data.auth.dto.AuthUserDto
import ru.daemonlord.messenger.data.auth.dto.CheckEmailStatusDto
import ru.daemonlord.messenger.data.auth.dto.AuthSessionDto
import ru.daemonlord.messenger.data.auth.dto.LoginRequestDto
import ru.daemonlord.messenger.data.auth.dto.RefreshTokenRequestDto
import ru.daemonlord.messenger.data.auth.dto.RegisterRequestDto
import ru.daemonlord.messenger.data.common.ApiErrorMode
import ru.daemonlord.messenger.data.common.toAppError
import ru.daemonlord.messenger.di.IoDispatcher
import ru.daemonlord.messenger.domain.auth.model.AuthEmailStatus
import ru.daemonlord.messenger.domain.auth.model.AuthUser
import ru.daemonlord.messenger.domain.auth.model.AuthSession
import ru.daemonlord.messenger.domain.auth.repository.AuthRepository
import ru.daemonlord.messenger.domain.common.AppError
import ru.daemonlord.messenger.domain.common.AppResult
import java.io.IOException
import ru.daemonlord.messenger.push.PushTokenSyncManager
import javax.inject.Inject
import javax.inject.Singleton
@@ -22,15 +29,60 @@ import javax.inject.Singleton
class NetworkAuthRepository @Inject constructor(
private val authApiService: AuthApiService,
private val tokenRepository: TokenRepository,
private val pushTokenSyncManager: PushTokenSyncManager,
@IoDispatcher private val ioDispatcher: CoroutineDispatcher,
) : AuthRepository {
override suspend fun login(email: String, password: String): AppResult<AuthUser> = withContext(ioDispatcher) {
override suspend fun checkEmailStatus(email: String): AppResult<AuthEmailStatus> = withContext(ioDispatcher) {
val normalized = email.trim().lowercase()
if (normalized.isBlank()) return@withContext AppResult.Error(AppError.Server("Email is required"))
try {
AppResult.Success(authApiService.checkEmailStatus(normalized).toDomain())
} catch (error: Throwable) {
AppResult.Error(error.toAppError())
}
}
override suspend fun register(
email: String,
name: String,
username: String,
password: String,
): AppResult<Unit> = withContext(ioDispatcher) {
val normalizedEmail = email.trim().lowercase()
val normalizedName = name.trim()
val normalizedUsername = username.trim().removePrefix("@")
if (normalizedEmail.isBlank() || normalizedName.isBlank() || normalizedUsername.isBlank() || password.isBlank()) {
return@withContext AppResult.Error(AppError.Server("Email, name, username and password are required"))
}
try {
authApiService.register(
request = RegisterRequestDto(
email = normalizedEmail,
name = normalizedName,
username = normalizedUsername,
password = password,
)
)
AppResult.Success(Unit)
} catch (error: Throwable) {
AppResult.Error(error.toAppError())
}
}
override suspend fun login(
email: String,
password: String,
otpCode: String?,
recoveryCode: String?,
): AppResult<AuthUser> = withContext(ioDispatcher) {
try {
val tokenResponse = authApiService.login(
request = LoginRequestDto(
email = email,
password = password,
otpCode = otpCode?.trim()?.ifBlank { null },
recoveryCode = recoveryCode?.trim()?.ifBlank { null },
)
)
tokenRepository.saveTokens(
@@ -40,9 +92,16 @@ class NetworkAuthRepository @Inject constructor(
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) {
AppResult.Error(error.toAppError(forLogin = true))
AppResult.Error(error.toAppError(mode = ApiErrorMode.LOGIN))
}
}
@@ -60,19 +119,21 @@ class NetworkAuthRepository @Inject constructor(
savedAtMillis = System.currentTimeMillis(),
)
)
pushTokenSyncManager.triggerBestEffortSync()
AppResult.Success(Unit)
} catch (error: Throwable) {
tokenRepository.clearTokens()
AppResult.Error(error.toAppError(forLogin = false))
AppResult.Error(error.toAppError())
}
}
override suspend fun getMe(): AppResult<AuthUser> = withContext(ioDispatcher) {
try {
val user = authApiService.me().toDomain()
tokenRepository.upsertAccount(user.toStoredAccount())
AppResult.Success(user)
} catch (error: Throwable) {
AppResult.Error(error.toAppError(forLogin = false))
AppResult.Error(error.toAppError())
}
}
@@ -86,7 +147,10 @@ class NetworkAuthRepository @Inject constructor(
}
when (val meResult = getMe()) {
is AppResult.Success -> meResult
is AppResult.Success -> {
pushTokenSyncManager.triggerBestEffortSync()
meResult
}
is AppResult.Error -> {
if (meResult.reason is AppError.Unauthorized) {
tokenRepository.clearTokens()
@@ -96,7 +160,34 @@ class NetworkAuthRepository @Inject constructor(
}
}
override suspend fun listSessions(): AppResult<List<AuthSession>> = withContext(ioDispatcher) {
try {
AppResult.Success(authApiService.sessions().map { it.toDomain() })
} catch (error: Throwable) {
AppResult.Error(error.toAppError())
}
}
override suspend fun revokeSession(jti: String): AppResult<Unit> = withContext(ioDispatcher) {
try {
authApiService.revokeSession(jti = jti)
AppResult.Success(Unit)
} catch (error: Throwable) {
AppResult.Error(error.toAppError())
}
}
override suspend fun revokeAllSessions(): AppResult<Unit> = withContext(ioDispatcher) {
try {
authApiService.revokeAllSessions()
AppResult.Success(Unit)
} catch (error: Throwable) {
AppResult.Error(error.toAppError())
}
}
override suspend fun logout() {
pushTokenSyncManager.unregisterCurrentTokenOnLogout()
tokenRepository.clearTokens()
}
@@ -106,20 +197,46 @@ class NetworkAuthRepository @Inject constructor(
email = email,
name = name,
username = username,
bio = bio,
avatarUrl = avatarUrl,
emailVerified = emailVerified,
twofaEnabled = twofaEnabled,
privacyPrivateMessages = privacyPrivateMessages ?: "everyone",
privacyLastSeen = privacyLastSeen ?: "everyone",
privacyAvatar = privacyAvatar ?: "everyone",
privacyGroupInvites = privacyGroupInvites ?: "everyone",
)
}
private fun Throwable.toAppError(forLogin: Boolean): AppError {
return when (this) {
is IOException -> AppError.Network
is HttpException -> when (code()) {
400 -> if (forLogin) AppError.InvalidCredentials else AppError.Server(message = message())
401, 403 -> if (forLogin) AppError.InvalidCredentials else AppError.Unauthorized
else -> AppError.Server(message = message())
}
else -> AppError.Unknown(cause = this)
}
private fun AuthUser.toStoredAccount(): StoredAccount {
return StoredAccount(
userId = id,
email = email,
name = name,
username = username,
avatarUrl = avatarUrl,
lastActiveAt = System.currentTimeMillis(),
)
}
private fun AuthSessionDto.toDomain(): AuthSession {
return AuthSession(
jti = jti,
createdAt = createdAt,
ipAddress = ipAddress,
userAgent = userAgent,
current = current,
tokenType = tokenType,
)
}
private fun CheckEmailStatusDto.toDomain(): AuthEmailStatus {
return AuthEmailStatus(
email = email,
registered = registered,
emailVerified = emailVerified,
twofaEnabled = twofaEnabled,
)
}
}

View File

@@ -1,9 +1,26 @@
package ru.daemonlord.messenger.data.chat.api
import retrofit2.http.Body
import retrofit2.http.DELETE
import retrofit2.http.GET
import retrofit2.http.PATCH
import retrofit2.http.Path
import retrofit2.http.POST
import retrofit2.http.PUT
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.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.ChatTitleUpdateRequestDto
import ru.daemonlord.messenger.data.chat.dto.DiscoverChatDto
interface ChatApiService {
@GET("/api/v1/chats")
@@ -15,4 +32,132 @@ interface ChatApiService {
suspend fun getChatById(
@Path("chat_id") chatId: Long,
): ChatReadDto
@GET("/api/v1/chats/saved")
suspend fun getSavedChat(): ChatReadDto
@POST("/api/v1/chats/{chat_id}/invite-link")
suspend fun createInviteLink(
@Path("chat_id") chatId: Long,
): ChatInviteLinkDto
@POST("/api/v1/chats/join-by-invite")
suspend fun joinByInvite(
@Body request: ChatJoinByInviteRequestDto,
): 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

@@ -40,6 +40,108 @@ data class ChatReadDto(
val lastMessageType: String? = null,
@SerialName("last_message_created_at")
val lastMessageCreatedAt: String? = null,
@SerialName("pinned_message_id")
val pinnedMessageId: Long? = null,
@SerialName("my_role")
val myRole: String? = null,
@SerialName("created_at")
val createdAt: String? = null,
)
@Serializable
data class ChatInviteLinkDto(
@SerialName("chat_id")
val chatId: Long,
val token: String,
@SerialName("invite_url")
val inviteUrl: String,
)
@Serializable
data class ChatJoinByInviteRequestDto(
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

@@ -36,6 +36,8 @@ interface ChatDao {
c.last_message_text,
c.last_message_type,
c.last_message_created_at,
c.pinned_message_id,
c.my_role,
c.updated_sort_at
FROM chats c
LEFT JOIN users_short u ON c.counterpart_user_id = u.id
@@ -45,6 +47,49 @@ interface ChatDao {
)
fun observeChats(archived: Boolean): Flow<List<ChatListLocalModel>>
@Query(
"""
SELECT
c.id,
c.public_id,
c.type,
c.title,
COALESCE(c.display_title, u.display_name) AS display_title,
c.handle,
COALESCE(c.avatar_url, u.avatar_url) AS avatar_url,
c.archived,
c.pinned,
c.muted,
c.unread_count,
c.unread_mentions_count,
COALESCE(c.counterpart_name, u.display_name) AS counterpart_name,
COALESCE(c.counterpart_username, u.username) AS counterpart_username,
COALESCE(c.counterpart_avatar_url, u.avatar_url) AS counterpart_avatar_url,
c.counterpart_is_online,
c.counterpart_last_seen_at,
c.last_message_text,
c.last_message_type,
c.last_message_created_at,
c.pinned_message_id,
c.my_role,
c.updated_sort_at
FROM chats c
LEFT JOIN users_short u ON c.counterpart_user_id = u.id
WHERE c.id = :chatId
LIMIT 1
"""
)
fun observeChatById(chatId: Long): Flow<ChatListLocalModel?>
@Query("SELECT display_title FROM chats WHERE id = :chatId LIMIT 1")
suspend fun getChatDisplayTitle(chatId: Long): String?
@Query("SELECT muted FROM chats WHERE id = :chatId LIMIT 1")
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)
suspend fun upsertChats(chats: List<ChatEntity>)
@@ -97,6 +142,16 @@ interface ChatDao {
)
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
suspend fun clearAndReplaceChats(
archived: Boolean,

View File

@@ -5,15 +5,25 @@ import androidx.room.RoomDatabase
import ru.daemonlord.messenger.data.chat.local.dao.ChatDao
import ru.daemonlord.messenger.data.chat.local.entity.ChatEntity
import ru.daemonlord.messenger.data.chat.local.entity.UserShortEntity
import ru.daemonlord.messenger.data.message.local.dao.MessageDao
import ru.daemonlord.messenger.data.message.local.dao.PendingMessageActionDao
import ru.daemonlord.messenger.data.message.local.entity.MessageAttachmentEntity
import ru.daemonlord.messenger.data.message.local.entity.MessageEntity
import ru.daemonlord.messenger.data.message.local.entity.PendingMessageActionEntity
@Database(
entities = [
ChatEntity::class,
UserShortEntity::class,
MessageEntity::class,
MessageAttachmentEntity::class,
PendingMessageActionEntity::class,
],
version = 1,
version = 9,
exportSchema = false,
)
abstract class MessengerDatabase : RoomDatabase() {
abstract fun chatDao(): ChatDao
abstract fun messageDao(): MessageDao
abstract fun pendingMessageActionDao(): PendingMessageActionDao
}

View File

@@ -56,6 +56,10 @@ data class ChatEntity(
val lastMessageType: String?,
@ColumnInfo(name = "last_message_created_at")
val lastMessageCreatedAt: String?,
@ColumnInfo(name = "pinned_message_id")
val pinnedMessageId: Long?,
@ColumnInfo(name = "my_role")
val myRole: String?,
@ColumnInfo(name = "updated_sort_at")
val updatedSortAt: String?,
)

View File

@@ -43,6 +43,10 @@ data class ChatListLocalModel(
val lastMessageType: String?,
@ColumnInfo(name = "last_message_created_at")
val lastMessageCreatedAt: String?,
@ColumnInfo(name = "pinned_message_id")
val pinnedMessageId: Long?,
@ColumnInfo(name = "my_role")
val myRole: String?,
@ColumnInfo(name = "updated_sort_at")
val updatedSortAt: String?,
)

View File

@@ -1,6 +1,7 @@
package ru.daemonlord.messenger.data.chat.mapper
import ru.daemonlord.messenger.data.chat.local.model.ChatListLocalModel
import ru.daemonlord.messenger.data.chat.local.entity.ChatEntity
import ru.daemonlord.messenger.domain.chat.model.ChatItem
fun ChatListLocalModel.toDomain(): ChatItem {
@@ -25,6 +26,36 @@ fun ChatListLocalModel.toDomain(): ChatItem {
lastMessageText = lastMessageText,
lastMessageType = lastMessageType,
lastMessageCreatedAt = lastMessageCreatedAt,
pinnedMessageId = pinnedMessageId,
myRole = myRole,
updatedSortAt = updatedSortAt,
)
}
fun ChatEntity.toDomain(): ChatItem {
return ChatItem(
id = id,
publicId = publicId,
type = type,
title = title,
displayTitle = displayTitle,
handle = handle,
avatarUrl = avatarUrl,
archived = archived,
pinned = pinned,
muted = muted,
unreadCount = unreadCount,
unreadMentionsCount = unreadMentionsCount,
counterpartUsername = counterpartUsername,
counterpartName = counterpartName,
counterpartAvatarUrl = counterpartAvatarUrl,
counterpartIsOnline = counterpartIsOnline,
counterpartLastSeenAt = counterpartLastSeenAt,
lastMessageText = lastMessageText,
lastMessageType = lastMessageType,
lastMessageCreatedAt = lastMessageCreatedAt,
pinnedMessageId = pinnedMessageId,
myRole = myRole,
updatedSortAt = updatedSortAt,
)
}

View File

@@ -1,8 +1,10 @@
package ru.daemonlord.messenger.data.chat.mapper
import ru.daemonlord.messenger.data.chat.dto.ChatReadDto
import ru.daemonlord.messenger.data.chat.dto.ChatInviteLinkDto
import ru.daemonlord.messenger.data.chat.local.entity.ChatEntity
import ru.daemonlord.messenger.data.chat.local.entity.UserShortEntity
import ru.daemonlord.messenger.domain.chat.model.ChatInviteLink
fun ChatReadDto.toChatEntity(): ChatEntity {
return ChatEntity(
@@ -27,6 +29,8 @@ fun ChatReadDto.toChatEntity(): ChatEntity {
lastMessageText = lastMessageText,
lastMessageType = lastMessageType,
lastMessageCreatedAt = lastMessageCreatedAt,
pinnedMessageId = pinnedMessageId,
myRole = myRole,
updatedSortAt = lastMessageCreatedAt ?: createdAt,
)
}
@@ -41,3 +45,11 @@ fun ChatReadDto.toUserShortEntityOrNull(): UserShortEntity? {
avatarUrl = counterpartAvatarUrl,
)
}
fun ChatInviteLinkDto.toDomain(): ChatInviteLink {
return ChatInviteLink(
chatId = chatId,
token = token,
inviteUrl = inviteUrl,
)
}

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

@@ -4,21 +4,36 @@ import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.channelFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.withContext
import kotlinx.coroutines.launch
import kotlinx.coroutines.channels.awaitClose
import retrofit2.HttpException
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.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.mapper.toChatEntity
import ru.daemonlord.messenger.data.chat.mapper.toDomain
import ru.daemonlord.messenger.data.chat.mapper.toUserShortEntityOrNull
import ru.daemonlord.messenger.data.common.toAppError
import ru.daemonlord.messenger.domain.chat.model.ChatInviteLink
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.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.common.AppError
import ru.daemonlord.messenger.domain.common.AppResult
import java.io.IOException
import javax.inject.Inject
import javax.inject.Singleton
@@ -43,6 +58,10 @@ class NetworkChatRepository @Inject constructor(
}
}
override fun observeChat(chatId: Long): Flow<ChatItem?> {
return chatDao.observeChatById(chatId = chatId).map { it?.toDomain() }
}
override suspend fun refreshChats(archived: Boolean): AppResult<Unit> = withContext(ioDispatcher) {
try {
val chats = chatApiService.getChats(archived = archived)
@@ -70,21 +89,336 @@ 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) {
try {
AppResult.Success(chatApiService.createInviteLink(chatId = chatId).toDomain())
} catch (error: Throwable) {
AppResult.Error(error.toAppError())
}
}
override suspend fun joinByInvite(token: String): AppResult<ChatItem> = withContext(ioDispatcher) {
try {
val joined = chatApiService.joinByInvite(request = ChatJoinByInviteRequestDto(token = token))
chatDao.upsertUsers(joined.toUserShortEntityOrNull()?.let(::listOf).orEmpty())
val chatEntity = joined.toChatEntity()
chatDao.upsertChats(listOf(chatEntity))
AppResult.Success(chatEntity.toDomain())
} catch (error: Throwable) {
AppResult.Error(error.toAppError())
}
}
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) {
withContext(ioDispatcher) {
chatDao.deleteChat(chatId = chatId)
}
}
private fun Throwable.toAppError(): AppError {
return when (this) {
is IOException -> AppError.Network
is HttpException -> if (code() == 401 || code() == 403) {
AppError.Unauthorized
} else {
AppError.Server(message = message())
}
else -> AppError.Unknown(cause = this)
}
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

@@ -0,0 +1,49 @@
package ru.daemonlord.messenger.data.common
import retrofit2.HttpException
import ru.daemonlord.messenger.domain.common.AppError
import java.io.IOException
import org.json.JSONObject
enum class ApiErrorMode {
DEFAULT,
LOGIN,
}
fun Throwable.toAppError(mode: ApiErrorMode = ApiErrorMode.DEFAULT): AppError {
return when (this) {
is IOException -> AppError.Network
is HttpException -> when (mode) {
ApiErrorMode.LOGIN -> {
val detail = extractErrorDetail()
when (code()) {
400, 401, 403 -> {
if (detail?.contains("2fa code required", ignoreCase = true) == true) {
AppError.Server(message = detail)
} else {
AppError.InvalidCredentials
}
}
else -> AppError.Server(message = detail ?: message())
}
}
ApiErrorMode.DEFAULT -> if (code() == 401 || code() == 403) {
AppError.Unauthorized
} else {
AppError.Server(message = extractErrorDetail() ?: message())
}
}
else -> AppError.Unknown(cause = this)
}
}
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

@@ -0,0 +1,30 @@
package ru.daemonlord.messenger.data.media.api
import retrofit2.http.Body
import retrofit2.http.GET
import retrofit2.http.Path
import retrofit2.http.POST
import retrofit2.http.Query
import ru.daemonlord.messenger.data.media.dto.AttachmentCreateRequestDto
import ru.daemonlord.messenger.data.media.dto.ChatAttachmentReadDto
import ru.daemonlord.messenger.data.media.dto.UploadUrlRequestDto
import ru.daemonlord.messenger.data.media.dto.UploadUrlResponseDto
interface MediaApiService {
@POST("/api/v1/media/upload-url")
suspend fun requestUploadUrl(
@Body request: UploadUrlRequestDto,
): UploadUrlResponseDto
@POST("/api/v1/media/attachments")
suspend fun createAttachment(
@Body request: AttachmentCreateRequestDto,
)
@GET("/api/v1/media/chats/{chat_id}/attachments")
suspend fun getChatAttachments(
@Path("chat_id") chatId: Long,
@Query("limit") limit: Int = 400,
@Query("before_id") beforeId: Long? = null,
): List<ChatAttachmentReadDto>
}

View File

@@ -0,0 +1,61 @@
package ru.daemonlord.messenger.data.media.dto
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class UploadUrlRequestDto(
@SerialName("file_name")
val fileName: String,
@SerialName("file_type")
val fileType: String,
@SerialName("file_size")
val fileSize: Long,
)
@Serializable
data class UploadUrlResponseDto(
@SerialName("upload_url")
val uploadUrl: String,
@SerialName("file_url")
val fileUrl: String,
@SerialName("object_key")
val objectKey: String,
@SerialName("expires_in")
val expiresIn: Int,
@SerialName("required_headers")
val requiredHeaders: Map<String, String> = emptyMap(),
)
@Serializable
data class AttachmentCreateRequestDto(
@SerialName("message_id")
val messageId: Long,
@SerialName("file_url")
val fileUrl: String,
@SerialName("file_type")
val fileType: String,
@SerialName("file_size")
val fileSize: Long,
)
@Serializable
data class ChatAttachmentReadDto(
val id: Long,
@SerialName("message_id")
val messageId: Long,
@SerialName("sender_id")
val senderId: Long,
@SerialName("message_type")
val messageType: String,
@SerialName("message_created_at")
val messageCreatedAt: String,
@SerialName("file_url")
val fileUrl: String,
@SerialName("file_type")
val fileType: String,
@SerialName("file_size")
val fileSize: Long,
@SerialName("waveform_points")
val waveformPoints: List<Int>? = null,
)

View File

@@ -0,0 +1,192 @@
package ru.daemonlord.messenger.data.media.repository
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.graphics.Canvas
import android.graphics.Movie
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.withContext
import okhttp3.MediaType.Companion.toMediaTypeOrNull
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import ru.daemonlord.messenger.data.common.toAppError
import ru.daemonlord.messenger.data.media.api.MediaApiService
import ru.daemonlord.messenger.data.media.dto.AttachmentCreateRequestDto
import ru.daemonlord.messenger.data.media.dto.UploadUrlRequestDto
import ru.daemonlord.messenger.di.IoDispatcher
import ru.daemonlord.messenger.di.RefreshClient
import ru.daemonlord.messenger.domain.common.AppError
import ru.daemonlord.messenger.domain.common.AppResult
import ru.daemonlord.messenger.domain.media.model.UploadedAttachment
import ru.daemonlord.messenger.domain.media.repository.MediaRepository
import java.io.ByteArrayOutputStream
import kotlin.math.roundToInt
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class NetworkMediaRepository @Inject constructor(
private val mediaApiService: MediaApiService,
@RefreshClient private val uploadClient: OkHttpClient,
@IoDispatcher private val ioDispatcher: CoroutineDispatcher,
) : MediaRepository {
override suspend fun uploadAndAttach(
messageId: Long,
fileName: String,
mimeType: String,
bytes: ByteArray,
): AppResult<UploadedAttachment> = withContext(ioDispatcher) {
try {
val uploadPayload = prepareUploadPayload(
fileName = fileName,
mimeType = mimeType,
bytes = bytes,
)
val uploadInfo = mediaApiService.requestUploadUrl(
request = UploadUrlRequestDto(
fileName = uploadPayload.fileName,
fileType = uploadPayload.mimeType,
fileSize = uploadPayload.bytes.size.toLong(),
)
)
val body = uploadPayload.bytes.toRequestBody(uploadPayload.mimeType.toMediaTypeOrNull())
val uploadRequestBuilder = Request.Builder()
.url(uploadInfo.uploadUrl)
.put(body)
uploadInfo.requiredHeaders.forEach { (key, value) ->
uploadRequestBuilder.header(key, value)
}
uploadClient.newCall(uploadRequestBuilder.build()).execute().use { response ->
if (!response.isSuccessful) {
return@withContext AppResult.Error(
AppError.Server(message = "Upload failed: HTTP ${response.code}")
)
}
}
mediaApiService.createAttachment(
request = AttachmentCreateRequestDto(
messageId = messageId,
fileUrl = uploadInfo.fileUrl,
fileType = uploadPayload.mimeType,
fileSize = uploadPayload.bytes.size.toLong(),
)
)
AppResult.Success(
UploadedAttachment(
fileUrl = uploadInfo.fileUrl,
fileType = uploadPayload.mimeType,
fileSize = uploadPayload.bytes.size.toLong(),
)
)
} catch (error: Throwable) {
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

@@ -0,0 +1,83 @@
package ru.daemonlord.messenger.data.message.api
import retrofit2.http.Body
import retrofit2.http.DELETE
import retrofit2.http.GET
import retrofit2.http.POST
import retrofit2.http.PUT
import retrofit2.http.Path
import retrofit2.http.Query
import ru.daemonlord.messenger.data.message.dto.MessageCreateRequestDto
import ru.daemonlord.messenger.data.message.dto.MessageForwardRequestDto
import ru.daemonlord.messenger.data.message.dto.MessageForwardBulkRequestDto
import ru.daemonlord.messenger.data.message.dto.MessageReadDto
import ru.daemonlord.messenger.data.message.dto.MessageReactionDto
import ru.daemonlord.messenger.data.message.dto.MessageReactionToggleRequestDto
import ru.daemonlord.messenger.data.message.dto.MessageStatusUpdateRequestDto
import ru.daemonlord.messenger.data.message.dto.MessageUpdateRequestDto
interface MessageApiService {
@GET("/api/v1/messages/{chat_id}")
suspend fun getMessages(
@Path("chat_id") chatId: Long,
@Query("limit") limit: Int = 50,
@Query("before_id") beforeId: Long? = null,
): 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")
suspend fun sendMessage(
@Body request: MessageCreateRequestDto,
): MessageReadDto
@PUT("/api/v1/messages/{message_id}")
suspend fun editMessage(
@Path("message_id") messageId: Long,
@Body request: MessageUpdateRequestDto,
): MessageReadDto
@DELETE("/api/v1/messages/{message_id}")
suspend fun deleteMessage(
@Path("message_id") messageId: Long,
@Query("for_all") forAll: Boolean = false,
)
@POST("/api/v1/messages/status")
suspend fun updateMessageStatus(
@Body request: MessageStatusUpdateRequestDto,
)
@POST("/api/v1/messages/{message_id}/forward")
suspend fun forwardMessage(
@Path("message_id") messageId: Long,
@Body request: MessageForwardRequestDto,
): MessageReadDto
@POST("/api/v1/messages/{message_id}/forward-bulk")
suspend fun forwardMessageBulk(
@Path("message_id") messageId: Long,
@Body request: MessageForwardBulkRequestDto,
): List<MessageReadDto>
@GET("/api/v1/messages/{message_id}/reactions")
suspend fun listReactions(
@Path("message_id") messageId: Long,
): List<MessageReactionDto>
@POST("/api/v1/messages/{message_id}/reactions/toggle")
suspend fun toggleReaction(
@Path("message_id") messageId: Long,
@Body request: MessageReactionToggleRequestDto,
): List<MessageReactionDto>
}

View File

@@ -0,0 +1,93 @@
package ru.daemonlord.messenger.data.message.dto
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class MessageReadDto(
val id: Long,
@SerialName("chat_id")
val chatId: Long,
@SerialName("sender_id")
val senderId: Long,
@SerialName("sender_display_name")
val senderDisplayName: String? = null,
@SerialName("sender_username")
val senderUsername: String? = null,
@SerialName("sender_avatar_url")
val senderAvatarUrl: String? = null,
@SerialName("reply_to_message_id")
val replyToMessageId: Long? = null,
@SerialName("reply_preview_text")
val replyPreviewText: String? = null,
@SerialName("reply_preview_sender_name")
val replyPreviewSenderName: String? = null,
@SerialName("forwarded_from_message_id")
val forwardedFromMessageId: Long? = null,
@SerialName("forwarded_from_display_name")
val forwardedFromDisplayName: String? = null,
val type: String,
val text: String? = null,
@SerialName("delivery_status")
val deliveryStatus: String? = null,
@SerialName("attachment_waveform")
val attachmentWaveform: List<Int>? = null,
@SerialName("created_at")
val createdAt: String,
@SerialName("updated_at")
val updatedAt: String? = null,
)
@Serializable
data class MessageCreateRequestDto(
@SerialName("chat_id")
val chatId: Long,
val type: String,
val text: String? = null,
@SerialName("client_message_id")
val clientMessageId: String,
@SerialName("reply_to_message_id")
val replyToMessageId: Long? = null,
)
@Serializable
data class MessageUpdateRequestDto(
val text: String,
)
@Serializable
data class MessageStatusUpdateRequestDto(
@SerialName("chat_id")
val chatId: Long,
@SerialName("message_id")
val messageId: Long,
val status: String,
)
@Serializable
data class MessageForwardRequestDto(
@SerialName("target_chat_id")
val targetChatId: Long,
@SerialName("include_author")
val includeAuthor: Boolean = true,
)
@Serializable
data class MessageForwardBulkRequestDto(
@SerialName("target_chat_ids")
val targetChatIds: List<Long>,
@SerialName("include_author")
val includeAuthor: Boolean = true,
)
@Serializable
data class MessageReactionDto(
val emoji: String,
val count: Int,
val reacted: Boolean = false,
)
@Serializable
data class MessageReactionToggleRequestDto(
val emoji: String,
)

View File

@@ -0,0 +1,105 @@
package ru.daemonlord.messenger.data.message.local.dao
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import androidx.room.Transaction
import kotlinx.coroutines.flow.Flow
import ru.daemonlord.messenger.data.message.local.entity.MessageAttachmentEntity
import ru.daemonlord.messenger.data.message.local.entity.MessageEntity
import ru.daemonlord.messenger.data.message.local.model.MessageLocalModel
@Dao
interface MessageDao {
@Query(
"""
SELECT * FROM messages
WHERE chat_id = :chatId
ORDER BY created_at DESC, id DESC
LIMIT :limit
"""
)
@Transaction
fun observeRecentMessages(
chatId: Long,
limit: Int = 50,
): Flow<List<MessageLocalModel>>
@Query("SELECT COUNT(*) FROM messages WHERE chat_id = :chatId")
suspend fun countMessages(chatId: Long): Int
@Query("SELECT chat_id FROM messages WHERE id = :messageId LIMIT 1")
suspend fun getChatIdByMessageId(messageId: Long): Long?
@Query(
"""
SELECT * FROM messages
WHERE chat_id = :chatId
AND id < :beforeMessageId
ORDER BY created_at DESC, id DESC
LIMIT :limit
"""
)
suspend fun getMessagesPage(
chatId: Long,
beforeMessageId: Long,
limit: Int = 50,
): List<MessageEntity>
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun upsertMessages(messages: List<MessageEntity>)
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun upsertAttachments(attachments: List<MessageAttachmentEntity>)
@Query("DELETE FROM messages WHERE chat_id = :chatId")
suspend fun clearChatMessages(chatId: Long)
@Query(
"""
DELETE FROM message_attachments
WHERE message_id IN (
SELECT id FROM messages WHERE chat_id = :chatId
)
"""
)
suspend fun clearChatAttachments(chatId: Long)
@Query("DELETE FROM messages WHERE id = :messageId")
suspend fun deleteMessage(messageId: Long)
@Query(
"""
UPDATE messages
SET text = :text,
updated_at = :updatedAt
WHERE id = :messageId
"""
)
suspend fun updateMessageText(messageId: Long, text: String?, updatedAt: String?)
@Query(
"""
UPDATE messages
SET status = :status
WHERE id = :messageId
"""
)
suspend fun updateMessageStatus(messageId: Long, status: String?)
@Transaction
suspend fun clearAndReplaceMessages(
chatId: Long,
messages: List<MessageEntity>,
attachments: List<MessageAttachmentEntity>,
) {
clearChatAttachments(chatId = chatId)
clearChatMessages(chatId = chatId)
upsertMessages(messages)
if (attachments.isNotEmpty()) {
upsertAttachments(attachments)
}
}
}

View File

@@ -0,0 +1,36 @@
package ru.daemonlord.messenger.data.message.local.dao
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import ru.daemonlord.messenger.data.message.local.entity.PendingMessageActionEntity
@Dao
interface PendingMessageActionDao {
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun enqueue(action: PendingMessageActionEntity): Long
@Query(
"""
SELECT * FROM pending_message_actions
ORDER BY created_at ASC, id ASC
LIMIT :limit
"""
)
suspend fun listPending(limit: Int = 50): List<PendingMessageActionEntity>
@Query("DELETE FROM pending_message_actions WHERE id = :id")
suspend fun deleteById(id: Long)
@Query(
"""
UPDATE pending_message_actions
SET attempts = attempts + 1
WHERE id = :id
"""
)
suspend fun incrementAttempts(id: Long)
}

View File

@@ -0,0 +1,28 @@
package ru.daemonlord.messenger.data.message.local.entity
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.Index
import androidx.room.PrimaryKey
@Entity(
tableName = "message_attachments",
indices = [
Index(value = ["message_id"]),
],
)
data class MessageAttachmentEntity(
@PrimaryKey
@ColumnInfo(name = "id")
val id: Long,
@ColumnInfo(name = "message_id")
val messageId: Long,
@ColumnInfo(name = "file_url")
val fileUrl: String,
@ColumnInfo(name = "file_type")
val fileType: String,
@ColumnInfo(name = "file_size")
val fileSize: Long,
@ColumnInfo(name = "waveform_points_json")
val waveformPointsJson: String?,
)

View File

@@ -0,0 +1,52 @@
package ru.daemonlord.messenger.data.message.local.entity
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.Index
import androidx.room.PrimaryKey
@Entity(
tableName = "messages",
indices = [
Index(value = ["chat_id", "created_at"]),
Index(value = ["chat_id", "id"]),
Index(value = ["chat_id", "updated_at"]),
],
)
data class MessageEntity(
@PrimaryKey
@ColumnInfo(name = "id")
val id: Long,
@ColumnInfo(name = "chat_id")
val chatId: Long,
@ColumnInfo(name = "sender_id")
val senderId: Long,
@ColumnInfo(name = "sender_display_name")
val senderDisplayName: String?,
@ColumnInfo(name = "sender_username")
val senderUsername: String?,
@ColumnInfo(name = "sender_avatar_url")
val senderAvatarUrl: String?,
@ColumnInfo(name = "reply_to_message_id")
val replyToMessageId: Long?,
@ColumnInfo(name = "reply_preview_text")
val replyPreviewText: String?,
@ColumnInfo(name = "reply_preview_sender_name")
val replyPreviewSenderName: String?,
@ColumnInfo(name = "forwarded_from_message_id")
val forwardedFromMessageId: Long?,
@ColumnInfo(name = "forwarded_from_display_name")
val forwardedFromDisplayName: String?,
@ColumnInfo(name = "type")
val type: String,
@ColumnInfo(name = "text")
val text: String?,
@ColumnInfo(name = "status")
val status: String?,
@ColumnInfo(name = "attachment_waveform_json")
val attachmentWaveformJson: String?,
@ColumnInfo(name = "created_at")
val createdAt: String,
@ColumnInfo(name = "updated_at")
val updatedAt: String?,
)

View File

@@ -0,0 +1,35 @@
package ru.daemonlord.messenger.data.message.local.entity
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.Index
import androidx.room.PrimaryKey
@Entity(
tableName = "pending_message_actions",
indices = [Index(value = ["created_at"]), Index(value = ["chat_id"])],
)
data class PendingMessageActionEntity(
@PrimaryKey(autoGenerate = true)
@ColumnInfo(name = "id")
val id: Long = 0L,
@ColumnInfo(name = "type")
val type: String,
@ColumnInfo(name = "chat_id")
val chatId: Long,
@ColumnInfo(name = "message_id")
val messageId: Long?,
@ColumnInfo(name = "local_message_id")
val localMessageId: Long?,
@ColumnInfo(name = "text")
val text: String?,
@ColumnInfo(name = "reply_to_message_id")
val replyToMessageId: Long?,
@ColumnInfo(name = "for_all")
val forAll: Boolean?,
@ColumnInfo(name = "attempts")
val attempts: Int,
@ColumnInfo(name = "created_at")
val createdAt: String,
)

View File

@@ -0,0 +1,21 @@
package ru.daemonlord.messenger.data.message.local.model
import androidx.room.Embedded
import androidx.room.Relation
import ru.daemonlord.messenger.data.message.local.entity.MessageAttachmentEntity
import ru.daemonlord.messenger.data.message.local.entity.MessageEntity
data class MessageLocalModel(
@Embedded
val message: MessageEntity,
@Relation(
parentColumn = "id",
entityColumn = "message_id",
)
val attachments: List<MessageAttachmentEntity>,
@Relation(
parentColumn = "reply_to_message_id",
entityColumn = "id",
)
val replyToMessage: MessageEntity?,
)

View File

@@ -0,0 +1,101 @@
package ru.daemonlord.messenger.data.message.mapper
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import ru.daemonlord.messenger.data.message.dto.MessageReadDto
import ru.daemonlord.messenger.data.message.dto.MessageReactionDto
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.model.MessageLocalModel
import ru.daemonlord.messenger.domain.message.model.MessageAttachment
import ru.daemonlord.messenger.domain.message.model.MessageReaction
import ru.daemonlord.messenger.domain.message.model.MessageItem
private val mapperJson = Json { ignoreUnknownKeys = true }
fun MessageReadDto.toEntity(): MessageEntity {
return MessageEntity(
id = id,
chatId = chatId,
senderId = senderId,
senderDisplayName = senderDisplayName,
senderUsername = senderUsername,
senderAvatarUrl = senderAvatarUrl,
replyToMessageId = replyToMessageId,
replyPreviewText = replyPreviewText,
replyPreviewSenderName = replyPreviewSenderName,
forwardedFromMessageId = forwardedFromMessageId,
forwardedFromDisplayName = forwardedFromDisplayName,
type = type,
text = text,
status = deliveryStatus,
attachmentWaveformJson = attachmentWaveform?.let { mapperJson.encodeToString(it) },
createdAt = createdAt,
updatedAt = updatedAt,
)
}
fun MessageLocalModel.toDomain(currentUserId: Long?): MessageItem {
val resolvedReplyPreviewText = message.replyPreviewText
?: replyToMessage?.text
?: replyToMessage?.type?.let { "[$it]" }
val resolvedReplyPreviewSenderName = message.replyPreviewSenderName
?: replyToMessage?.senderDisplayName
?: replyToMessage?.senderUsername?.takeIf { it.isNotBlank() }?.let { "@$it" }
?: replyToMessage?.senderId?.let { "User #$it" }
return MessageItem(
id = message.id,
chatId = message.chatId,
senderId = message.senderId,
senderDisplayName = message.senderDisplayName,
senderUsername = message.senderUsername,
type = message.type,
text = message.text,
createdAt = message.createdAt,
updatedAt = message.updatedAt,
isOutgoing = currentUserId != null && currentUserId == message.senderId,
status = message.status,
replyToMessageId = message.replyToMessageId,
replyPreviewText = resolvedReplyPreviewText,
replyPreviewSenderName = resolvedReplyPreviewSenderName,
forwardedFromMessageId = message.forwardedFromMessageId,
forwardedFromDisplayName = message.forwardedFromDisplayName,
attachmentWaveform = message.attachmentWaveformJson.toWaveformOrNull(),
attachments = attachments.map { it.toDomain() },
)
}
fun MessageReactionDto.toDomain(): MessageReaction {
return MessageReaction(
emoji = emoji,
count = count,
reacted = reacted,
)
}
fun ru.daemonlord.messenger.data.media.dto.ChatAttachmentReadDto.toEntity(): MessageAttachmentEntity {
return MessageAttachmentEntity(
id = id,
messageId = messageId,
fileUrl = fileUrl,
fileType = fileType,
fileSize = fileSize,
waveformPointsJson = waveformPoints?.let { mapperJson.encodeToString(it) },
)
}
fun MessageAttachmentEntity.toDomain(): MessageAttachment {
return MessageAttachment(
id = id,
messageId = messageId,
fileUrl = fileUrl,
fileType = fileType,
fileSize = fileSize,
waveformPoints = waveformPointsJson.toWaveformOrNull(),
)
}
private fun String?.toWaveformOrNull(): List<Int>? {
if (this.isNullOrBlank()) return null
return runCatching { mapperJson.decodeFromString<List<Int>>(this) }.getOrNull()
}

View File

@@ -0,0 +1,726 @@
package ru.daemonlord.messenger.data.message.repository
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.withContext
import kotlinx.coroutines.launch
import ru.daemonlord.messenger.data.chat.local.dao.ChatDao
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.data.common.toAppError
import ru.daemonlord.messenger.data.message.local.dao.MessageDao
import ru.daemonlord.messenger.data.message.local.dao.PendingMessageActionDao
import ru.daemonlord.messenger.data.message.local.entity.MessageEntity
import ru.daemonlord.messenger.data.message.local.entity.MessageAttachmentEntity
import ru.daemonlord.messenger.data.message.local.entity.PendingMessageActionEntity
import ru.daemonlord.messenger.data.message.mapper.toDomain
import ru.daemonlord.messenger.data.message.mapper.toEntity
import ru.daemonlord.messenger.data.media.api.MediaApiService
import ru.daemonlord.messenger.di.IoDispatcher
import ru.daemonlord.messenger.domain.common.AppError
import ru.daemonlord.messenger.domain.common.AppResult
import ru.daemonlord.messenger.domain.media.repository.MediaRepository
import ru.daemonlord.messenger.domain.message.model.MessageItem
import ru.daemonlord.messenger.domain.message.model.MessageReaction
import ru.daemonlord.messenger.domain.message.repository.MessageRepository
import ru.daemonlord.messenger.data.message.dto.MessageCreateRequestDto
import ru.daemonlord.messenger.data.message.dto.MessageForwardRequestDto
import ru.daemonlord.messenger.data.message.dto.MessageForwardBulkRequestDto
import ru.daemonlord.messenger.data.message.dto.MessageReactionToggleRequestDto
import ru.daemonlord.messenger.data.message.dto.MessageStatusUpdateRequestDto
import ru.daemonlord.messenger.data.message.dto.MessageUpdateRequestDto
import java.util.Base64
import java.util.UUID
import javax.inject.Inject
import javax.inject.Singleton
import kotlinx.coroutines.flow.map
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
@Singleton
class NetworkMessageRepository @Inject constructor(
private val messageApiService: MessageApiService,
private val messageDao: MessageDao,
private val pendingMessageActionDao: PendingMessageActionDao,
private val chatDao: ChatDao,
private val mediaRepository: MediaRepository,
private val mediaApiService: MediaApiService,
tokenRepository: TokenRepository,
@IoDispatcher private val ioDispatcher: CoroutineDispatcher,
) : MessageRepository {
@Volatile
private var currentUserId: Long? = null
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
init {
scope.launch {
tokenRepository.observeTokens().collectLatest { tokens ->
currentUserId = tokens?.accessToken?.extractUserIdFromJwt()
}
}
}
override fun observeMessages(chatId: Long, limit: Int): Flow<List<MessageItem>> {
return messageDao.observeRecentMessages(chatId = chatId, limit = limit).map { entities ->
entities.map { it.toDomain(currentUserId = currentUserId) }
}
}
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) {
flushPendingActions(chatId = chatId)
try {
val remoteMessages = messageApiService.getMessages(chatId = chatId)
val messageEntities = remoteMessages.map { it.toEntity() }
val attachments = mediaApiService.getChatAttachments(chatId = chatId, limit = 400)
.map { it.toEntity() }
messageDao.clearAndReplaceMessages(
chatId = chatId,
messages = messageEntities,
attachments = attachments,
)
AppResult.Success(Unit)
} catch (error: Throwable) {
val mapped = error.toAppError()
val hasCached = messageDao.countMessages(chatId = chatId) > 0
if (mapped is AppError.Network && hasCached) {
AppResult.Success(Unit)
} else {
AppResult.Error(mapped)
}
}
}
override suspend fun loadMoreMessages(chatId: Long, beforeMessageId: Long): AppResult<Unit> = withContext(ioDispatcher) {
flushPendingActions(chatId = chatId)
try {
val page = messageApiService.getMessages(
chatId = chatId,
beforeId = beforeMessageId,
)
if (page.isNotEmpty()) {
messageDao.upsertMessages(page.map { it.toEntity() })
val pageMessageIds = page.map { it.id }.toSet()
val attachments = mediaApiService.getChatAttachments(chatId = chatId, limit = 400)
.filter { it.messageId in pageMessageIds }
.map { it.toEntity() }
if (attachments.isNotEmpty()) {
messageDao.upsertAttachments(attachments)
}
}
AppResult.Success(Unit)
} catch (error: Throwable) {
val mapped = error.toAppError()
val hasOlderLocal = messageDao.getMessagesPage(
chatId = chatId,
beforeMessageId = beforeMessageId,
limit = 1,
).isNotEmpty()
if (mapped is AppError.Network && hasOlderLocal) {
AppResult.Success(Unit)
} else {
AppResult.Error(mapped)
}
}
}
override suspend fun sendTextMessage(
chatId: Long,
text: String,
replyToMessageId: Long?,
): AppResult<Unit> = withContext(ioDispatcher) {
flushPendingActions(chatId = chatId)
val tempId = -System.currentTimeMillis()
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 = "text",
text = text,
status = "pending",
attachmentWaveformJson = null,
createdAt = java.time.Instant.now().toString(),
updatedAt = null,
)
messageDao.upsertMessages(listOf(tempMessage))
chatDao.updateLastMessage(
chatId = chatId,
lastMessageText = text,
lastMessageType = "text",
lastMessageCreatedAt = tempMessage.createdAt,
updatedSortAt = tempMessage.createdAt,
)
try {
val sent = messageApiService.sendMessage(
request = MessageCreateRequestDto(
chatId = chatId,
type = "text",
text = text,
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) {
val mapped = error.toAppError()
if (mapped is AppError.Network) {
pendingMessageActionDao.enqueue(
PendingMessageActionEntity(
type = PendingActionType.SEND_TEXT.name,
chatId = chatId,
messageId = null,
localMessageId = tempId,
text = text,
replyToMessageId = replyToMessageId,
forAll = null,
attempts = 0,
createdAt = java.time.Instant.now().toString(),
)
)
AppResult.Success(Unit)
} else {
messageDao.deleteMessage(tempId)
AppResult.Error(mapped)
}
}
}
override suspend fun editMessage(messageId: Long, newText: String): AppResult<Unit> = withContext(ioDispatcher) {
val chatId = messageDao.getChatIdByMessageId(messageId) ?: 0L
if (chatId > 0L) {
flushPendingActions(chatId = chatId)
}
messageDao.updateMessageText(
messageId = messageId,
text = newText,
updatedAt = java.time.Instant.now().toString(),
)
try {
val updated = messageApiService.editMessage(
messageId = messageId,
request = MessageUpdateRequestDto(text = newText),
)
messageDao.upsertMessages(listOf(updated.toEntity()))
AppResult.Success(Unit)
} catch (error: Throwable) {
val mapped = error.toAppError()
if (mapped is AppError.Network) {
pendingMessageActionDao.enqueue(
PendingMessageActionEntity(
type = PendingActionType.EDIT.name,
chatId = chatId,
messageId = messageId,
localMessageId = null,
text = newText,
replyToMessageId = null,
forAll = null,
attempts = 0,
createdAt = java.time.Instant.now().toString(),
)
)
AppResult.Success(Unit)
} else {
AppResult.Error(mapped)
}
}
}
override suspend fun deleteMessage(messageId: Long, forAll: Boolean): AppResult<Unit> = withContext(ioDispatcher) {
val chatId = messageDao.getChatIdByMessageId(messageId) ?: 0L
if (chatId > 0L) {
flushPendingActions(chatId = chatId)
}
try {
messageApiService.deleteMessage(
messageId = messageId,
forAll = forAll,
)
messageDao.deleteMessage(messageId)
AppResult.Success(Unit)
} catch (error: Throwable) {
val mapped = error.toAppError()
if (mapped is AppError.Network) {
messageDao.deleteMessage(messageId)
pendingMessageActionDao.enqueue(
PendingMessageActionEntity(
type = PendingActionType.DELETE.name,
chatId = chatId,
messageId = messageId,
localMessageId = null,
text = null,
replyToMessageId = null,
forAll = forAll,
attempts = 0,
createdAt = java.time.Instant.now().toString(),
)
)
AppResult.Success(Unit)
} else {
AppResult.Error(mapped)
}
}
}
override suspend fun sendMediaMessage(
chatId: Long,
fileName: String,
mimeType: String,
bytes: ByteArray,
caption: String?,
replyToMessageId: Long?,
): AppResult<Unit> = withContext(ioDispatcher) {
val messageType = mapMimeToMessageType(mimeType = mimeType, fileName = fileName)
val tempId = -System.currentTimeMillis()
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 = messageType,
text = caption,
status = "pending",
attachmentWaveformJson = null,
createdAt = java.time.Instant.now().toString(),
updatedAt = null,
)
messageDao.upsertMessages(listOf(tempMessage))
chatDao.updateLastMessage(
chatId = chatId,
lastMessageText = caption,
lastMessageType = messageType,
lastMessageCreatedAt = tempMessage.createdAt,
updatedSortAt = tempMessage.createdAt,
)
try {
val created = messageApiService.sendMessage(
request = MessageCreateRequestDto(
chatId = chatId,
type = messageType,
text = caption,
clientMessageId = UUID.randomUUID().toString(),
replyToMessageId = replyToMessageId,
)
)
when (val mediaResult = mediaRepository.uploadAndAttach(
messageId = created.id,
fileName = fileName,
mimeType = mimeType,
bytes = bytes,
)) {
is AppResult.Success -> {
messageDao.deleteMessage(tempId)
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 -> {
messageDao.deleteMessage(tempId)
AppResult.Error(mediaResult.reason)
}
}
} catch (error: Throwable) {
messageDao.deleteMessage(tempId)
AppResult.Error(error.toAppError())
}
}
override suspend fun sendImageUrlMessage(
chatId: Long,
imageUrl: String,
replyToMessageId: Long?,
): AppResult<Unit> = withContext(ioDispatcher) {
val normalizedUrl = imageUrl.trim()
if (normalizedUrl.isBlank()) {
return@withContext AppResult.Error(AppError.Server("Image URL is empty"))
}
val tempId = -System.currentTimeMillis()
val now = java.time.Instant.now().toString()
val tempMessage = MessageEntity(
id = tempId,
chatId = chatId,
senderId = currentUserId ?: 0L,
senderDisplayName = null,
senderUsername = null,
senderAvatarUrl = null,
replyToMessageId = replyToMessageId,
replyPreviewText = null,
replyPreviewSenderName = null,
forwardedFromMessageId = null,
forwardedFromDisplayName = null,
type = "image",
text = normalizedUrl,
status = "pending",
attachmentWaveformJson = null,
createdAt = now,
updatedAt = null,
)
messageDao.upsertMessages(listOf(tempMessage))
chatDao.updateLastMessage(
chatId = chatId,
lastMessageText = normalizedUrl,
lastMessageType = "image",
lastMessageCreatedAt = now,
updatedSortAt = now,
)
try {
val sent = messageApiService.sendMessage(
request = MessageCreateRequestDto(
chatId = chatId,
type = "image",
text = normalizedUrl,
clientMessageId = UUID.randomUUID().toString(),
replyToMessageId = replyToMessageId,
)
)
messageDao.deleteMessage(tempId)
messageDao.upsertMessages(listOf(sent.toEntity()))
chatDao.updateLastMessage(
chatId = chatId,
lastMessageText = sent.text,
lastMessageType = sent.type,
lastMessageCreatedAt = sent.createdAt,
updatedSortAt = sent.createdAt,
)
AppResult.Success(Unit)
} catch (error: Throwable) {
messageDao.deleteMessage(tempId)
AppResult.Error(error.toAppError())
}
}
override suspend fun markMessageDelivered(chatId: Long, messageId: Long): AppResult<Unit> = withContext(ioDispatcher) {
try {
messageApiService.updateMessageStatus(
request = MessageStatusUpdateRequestDto(
chatId = chatId,
messageId = messageId,
status = "message_delivered",
)
)
AppResult.Success(Unit)
} catch (error: Throwable) {
AppResult.Error(error.toAppError())
}
}
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 {
messageApiService.updateMessageStatus(
request = MessageStatusUpdateRequestDto(
chatId = chatId,
messageId = messageId,
status = "message_read",
)
)
AppResult.Success(Unit)
} catch (error: Throwable) {
AppResult.Error(error.toAppError())
}
}
override suspend fun forwardMessage(
messageId: Long,
targetChatId: Long,
includeAuthor: Boolean,
): AppResult<Unit> = withContext(ioDispatcher) {
try {
val forwarded = messageApiService.forwardMessage(
messageId = messageId,
request = MessageForwardRequestDto(
targetChatId = targetChatId,
includeAuthor = includeAuthor,
),
)
messageDao.upsertMessages(listOf(forwarded.toEntity()))
chatDao.updateLastMessage(
chatId = targetChatId,
lastMessageText = forwarded.text,
lastMessageType = forwarded.type,
lastMessageCreatedAt = forwarded.createdAt,
updatedSortAt = forwarded.createdAt,
)
AppResult.Success(Unit)
} catch (error: Throwable) {
AppResult.Error(error.toAppError())
}
}
override suspend fun forwardMessageBulk(
messageId: Long,
targetChatIds: List<Long>,
includeAuthor: Boolean,
): AppResult<Unit> = withContext(ioDispatcher) {
if (targetChatIds.isEmpty()) return@withContext AppResult.Success(Unit)
try {
val forwarded = messageApiService.forwardMessageBulk(
messageId = messageId,
request = MessageForwardBulkRequestDto(
targetChatIds = targetChatIds,
includeAuthor = includeAuthor,
),
)
if (forwarded.isNotEmpty()) {
messageDao.upsertMessages(forwarded.map { it.toEntity() })
forwarded.forEach { message ->
chatDao.updateLastMessage(
chatId = message.chatId,
lastMessageText = message.text,
lastMessageType = message.type,
lastMessageCreatedAt = message.createdAt,
updatedSortAt = message.createdAt,
)
}
}
AppResult.Success(Unit)
} catch (error: Throwable) {
AppResult.Error(error.toAppError())
}
}
override suspend fun listReactions(messageId: Long): AppResult<List<MessageReaction>> = withContext(ioDispatcher) {
try {
val reactions = messageApiService.listReactions(messageId = messageId).map { it.toDomain() }
AppResult.Success(reactions)
} catch (error: Throwable) {
AppResult.Error(error.toAppError())
}
}
override suspend fun toggleReaction(messageId: Long, emoji: String): AppResult<List<MessageReaction>> = withContext(ioDispatcher) {
try {
val reactions = messageApiService.toggleReaction(
messageId = messageId,
request = MessageReactionToggleRequestDto(emoji = emoji),
).map { it.toDomain() }
AppResult.Success(reactions)
} catch (error: Throwable) {
AppResult.Error(error.toAppError())
}
}
private fun mapMimeToMessageType(
mimeType: String,
fileName: String,
): String {
val normalizedMime = mimeType.lowercase()
val normalizedName = fileName.lowercase()
return when {
normalizedName.startsWith("circle_") -> "circle_video"
normalizedMime == "image/gif" || normalizedName.endsWith(".gif") || normalizedName.startsWith("gif_") -> "image"
normalizedName.startsWith("sticker_") || normalizedMime == "image/webp" -> "image"
normalizedMime.startsWith("image/") -> "image"
normalizedMime.startsWith("video/") -> "video"
normalizedMime.startsWith("audio/") && normalizedName.startsWith("voice_") -> "voice"
normalizedMime.startsWith("audio/") -> "audio"
else -> "file"
}
}
private suspend fun flushPendingActions(chatId: Long) {
val pending = pendingMessageActionDao.listPending(limit = 100)
for (action in pending) {
if (action.chatId != chatId && action.chatId != 0L) continue
when (val result = performPendingAction(action)) {
is AppResult.Success -> pendingMessageActionDao.deleteById(action.id)
is AppResult.Error -> {
pendingMessageActionDao.incrementAttempts(action.id)
if (result.reason is AppError.Network) {
return
} else {
pendingMessageActionDao.deleteById(action.id)
}
}
}
}
}
private suspend fun performPendingAction(action: PendingMessageActionEntity): AppResult<Unit> {
val type = runCatching { PendingActionType.valueOf(action.type) }.getOrNull()
?: return AppResult.Success(Unit)
return when (type) {
PendingActionType.SEND_TEXT -> {
val text = action.text ?: return AppResult.Success(Unit)
val chatId = action.chatId
runCatching {
messageApiService.sendMessage(
request = MessageCreateRequestDto(
chatId = chatId,
type = "text",
text = text,
clientMessageId = UUID.randomUUID().toString(),
replyToMessageId = action.replyToMessageId,
)
)
}.fold(
onSuccess = { sent ->
action.localMessageId?.let { messageDao.deleteMessage(it) }
messageDao.upsertMessages(listOf(sent.toEntity()))
chatDao.updateLastMessage(
chatId = chatId,
lastMessageText = sent.text,
lastMessageType = sent.type,
lastMessageCreatedAt = sent.createdAt,
updatedSortAt = sent.createdAt,
)
AppResult.Success(Unit)
},
onFailure = { error -> AppResult.Error(error.toAppError()) }
)
}
PendingActionType.EDIT -> {
val messageId = action.messageId ?: return AppResult.Success(Unit)
val text = action.text ?: return AppResult.Success(Unit)
runCatching {
messageApiService.editMessage(
messageId = messageId,
request = MessageUpdateRequestDto(text = text),
)
}.fold(
onSuccess = { updated ->
messageDao.upsertMessages(listOf(updated.toEntity()))
AppResult.Success(Unit)
},
onFailure = { error -> AppResult.Error(error.toAppError()) }
)
}
PendingActionType.DELETE -> {
val messageId = action.messageId ?: return AppResult.Success(Unit)
runCatching {
messageApiService.deleteMessage(
messageId = messageId,
forAll = action.forAll == true,
)
}.fold(
onSuccess = {
AppResult.Success(Unit)
},
onFailure = { error -> AppResult.Error(error.toAppError()) }
)
}
}
}
private enum class PendingActionType {
SEND_TEXT,
EDIT,
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? {
val payloadPart = split('.').getOrNull(1) ?: return null
val normalized = payloadPart
.replace('-', '+')
.replace('_', '/')
.let { part ->
when (part.length % 4) {
2 -> "$part=="
3 -> "$part="
else -> part
}
}
val payloadJson = runCatching {
String(Base64.getDecoder().decode(normalized), Charsets.UTF_8)
}.getOrNull() ?: return null
return runCatching {
Json.parseToJsonElement(payloadJson).jsonObject["sub"]?.jsonPrimitive?.content?.toLongOrNull()
}.getOrNull()
}
}

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

@@ -0,0 +1,90 @@
package ru.daemonlord.messenger.data.notifications.repository
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.booleanPreferencesKey
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.notifications.model.ChatNotificationOverride
import ru.daemonlord.messenger.domain.notifications.model.NotificationSettings
import ru.daemonlord.messenger.domain.notifications.repository.NotificationSettingsRepository
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class DataStoreNotificationSettingsRepository @Inject constructor(
private val dataStore: DataStore<Preferences>,
) : NotificationSettingsRepository {
override fun observeSettings(): Flow<NotificationSettings> {
return dataStore.data.map { preferences ->
NotificationSettings(
globalEnabled = preferences[GLOBAL_ENABLED_KEY] ?: true,
previewEnabled = preferences[PREVIEW_ENABLED_KEY] ?: true,
)
}
}
override suspend fun getSettings(): NotificationSettings {
return observeSettings().first()
}
override suspend fun setGlobalEnabled(enabled: Boolean) {
dataStore.edit { preferences ->
preferences[GLOBAL_ENABLED_KEY] = enabled
}
}
override suspend fun setPreviewEnabled(enabled: Boolean) {
dataStore.edit { preferences ->
preferences[PREVIEW_ENABLED_KEY] = enabled
}
}
override fun observeChatOverride(chatId: Long): Flow<ChatNotificationOverride> {
return dataStore.data.map { preferences ->
preferences.chatOverride(chatId)
}
}
override suspend fun getChatOverride(chatId: Long): ChatNotificationOverride {
return observeChatOverride(chatId).first()
}
override suspend fun setChatOverride(chatId: Long, mode: ChatNotificationOverride) {
dataStore.edit { preferences ->
preferences[chatOverrideKey(chatId)] = mode.name
}
}
override suspend fun clearChatOverride(chatId: Long) {
dataStore.edit { preferences ->
preferences.remove(chatOverrideKey(chatId))
}
}
override suspend fun clearChatOverrides() {
dataStore.edit { preferences ->
val keysToRemove = preferences.asMap().keys
.filter { key -> key.name.startsWith(CHAT_OVERRIDE_PREFIX) }
keysToRemove.forEach { key -> preferences.remove(key) }
}
}
private fun Preferences.chatOverride(chatId: Long): ChatNotificationOverride {
return this[chatOverrideKey(chatId)]
?.let { runCatching { ChatNotificationOverride.valueOf(it) }.getOrNull() }
?: ChatNotificationOverride.DEFAULT
}
private fun chatOverrideKey(chatId: Long) = stringPreferencesKey("$CHAT_OVERRIDE_PREFIX$chatId")
private companion object {
const val CHAT_OVERRIDE_PREFIX = "notification_chat_override_"
val GLOBAL_ENABLED_KEY = booleanPreferencesKey("notification_global_enabled")
val PREVIEW_ENABLED_KEY = booleanPreferencesKey("notification_preview_enabled")
}
}

View File

@@ -22,22 +22,36 @@ class RealtimeEventParser @Inject constructor(
val payload = root["payload"]?.jsonObject ?: JsonObject(emptyMap())
return when (event) {
"connect" -> RealtimeEvent.Connected
"receive_message" -> {
val chatId = payload["chat_id"].longOrNull() ?: return RealtimeEvent.Ignored
val messageObject = payload["message"]?.jsonObject
val messageId = messageObject?.get("id").longOrNull() ?: return RealtimeEvent.Ignored
val senderId = messageObject?.get("sender_id").longOrNull() ?: 0L
RealtimeEvent.ReceiveMessage(
chatId = chatId,
messageId = messageId,
senderId = senderId,
replyToMessageId = messageObject?.get("reply_to_message_id").longOrNull(),
text = messageObject?.get("text").stringOrNull(),
type = messageObject?.get("type").stringOrNull(),
createdAt = messageObject?.get("created_at").stringOrNull(),
isMention = messageObject?.get("is_mention").boolOrNull()
?: payload["is_mention"].boolOrNull()
?: messageObject?.get("mentions_me").boolOrNull()
?: payload["mentions_me"].boolOrNull()
?: false,
)
}
"message_updated" -> {
val chatId = payload["chat_id"].longOrNull() ?: return RealtimeEvent.Ignored
val messageObject = payload["message"]?.jsonObject
val messageId = messageObject?.get("id").longOrNull() ?: return RealtimeEvent.Ignored
RealtimeEvent.MessageUpdated(
chatId = chatId,
messageId = messageId,
text = messageObject?.get("text").stringOrNull(),
type = messageObject?.get("type").stringOrNull(),
updatedAt = messageObject?.get("updated_at").stringOrNull(),
@@ -53,12 +67,16 @@ class RealtimeEventParser @Inject constructor(
}
"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)
}
"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)
}
@@ -75,6 +93,35 @@ class RealtimeEventParser @Inject constructor(
)
}
"message_delivered" -> {
val chatId = payload["chat_id"].longOrNull() ?: return RealtimeEvent.Ignored
val messageId = payload["message_id"].longOrNull() ?: return RealtimeEvent.Ignored
RealtimeEvent.MessageDelivered(chatId = chatId, messageId = messageId)
}
"message_read" -> {
val chatId = payload["chat_id"].longOrNull() ?: return RealtimeEvent.Ignored
val messageId = payload["message_id"].longOrNull() ?: return RealtimeEvent.Ignored
RealtimeEvent.MessageRead(
chatId = chatId,
messageId = messageId,
userId = payload["user_id"].longOrNull(),
lastReadMessageId = payload["last_read_message_id"].longOrNull(),
)
}
"typing_start" -> {
val chatId = payload["chat_id"].longOrNull() ?: return RealtimeEvent.Ignored
RealtimeEvent.TypingStart(chatId = chatId, userId = payload["user_id"].longOrNull())
}
"typing_stop" -> {
val chatId = payload["chat_id"].longOrNull() ?: return RealtimeEvent.Ignored
RealtimeEvent.TypingStop(chatId = chatId, userId = payload["user_id"].longOrNull())
}
"pong" -> RealtimeEvent.Ignored
else -> RealtimeEvent.Ignored
}
}
@@ -86,4 +133,13 @@ class RealtimeEventParser @Inject constructor(
private fun JsonElement?.longOrNull(): Long? {
return this?.jsonPrimitive?.contentOrNull?.toLongOrNull()
}
private fun JsonElement?.boolOrNull(): Boolean? {
val raw = this?.jsonPrimitive?.contentOrNull?.trim()?.lowercase() ?: return null
return when (raw) {
"true", "1" -> true
"false", "0" -> false
else -> null
}
}
}

View File

@@ -2,11 +2,14 @@ package ru.daemonlord.messenger.data.realtime
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.launch
import okhttp3.OkHttpClient
@@ -18,8 +21,10 @@ import ru.daemonlord.messenger.BuildConfig
import ru.daemonlord.messenger.core.token.TokenRepository
import ru.daemonlord.messenger.di.RefreshClient
import ru.daemonlord.messenger.domain.realtime.RealtimeManager
import ru.daemonlord.messenger.domain.realtime.model.RealtimeConnectionState
import ru.daemonlord.messenger.domain.realtime.model.RealtimeEvent
import java.util.concurrent.atomic.AtomicBoolean
import java.util.concurrent.atomic.AtomicLong
import javax.inject.Inject
import javax.inject.Singleton
@@ -36,24 +41,35 @@ class WsRealtimeManager @Inject constructor(
private val isConnected = AtomicBoolean(false)
private val manualDisconnect = AtomicBoolean(false)
private var reconnectDelayMs: Long = INITIAL_RECONNECT_MS
private val lastPongAtMs = AtomicLong(0L)
private var heartbeatJob: Job? = null
override val events: Flow<RealtimeEvent> = eventFlow.asSharedFlow()
private val _connectionState = MutableStateFlow(RealtimeConnectionState.Disconnected)
override val connectionState: StateFlow<RealtimeConnectionState> = _connectionState
override fun connect() {
if (isConnected.get()) return
manualDisconnect.set(false)
_connectionState.value = RealtimeConnectionState.Connecting
scope.launch { openSocket() }
}
override fun disconnect() {
manualDisconnect.set(true)
isConnected.set(false)
_connectionState.value = RealtimeConnectionState.Disconnected
heartbeatJob?.cancel()
heartbeatJob = null
socket?.close(1000, "Client disconnect")
socket = null
}
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
.replace("http://", "ws://")
.replace("https://", "wss://")
@@ -66,6 +82,7 @@ class WsRealtimeManager @Inject constructor(
private fun scheduleReconnect() {
if (manualDisconnect.get()) return
_connectionState.value = RealtimeConnectionState.Reconnecting
scope.launch {
delay(reconnectDelayMs)
reconnectDelayMs = (reconnectDelayMs * 2).coerceAtMost(MAX_RECONNECT_MS)
@@ -73,28 +90,61 @@ class WsRealtimeManager @Inject constructor(
}
}
private fun startHeartbeat(webSocket: WebSocket) {
heartbeatJob?.cancel()
lastPongAtMs.set(System.currentTimeMillis())
heartbeatJob = scope.launch {
while (isConnected.get() && !manualDisconnect.get()) {
val now = System.currentTimeMillis()
if (now - lastPongAtMs.get() > PONG_TIMEOUT_MS) {
webSocket.close(1001, "Heartbeat timeout")
break
}
webSocket.send("""{"event":"ping","payload":{}}""")
delay(PING_INTERVAL_MS)
}
}
}
private val listener = object : WebSocketListener() {
override fun onOpen(webSocket: WebSocket, response: Response) {
isConnected.set(true)
_connectionState.value = RealtimeConnectionState.Connected
reconnectDelayMs = INITIAL_RECONNECT_MS
startHeartbeat(webSocket)
}
override fun onMessage(webSocket: WebSocket, text: String) {
if (text.contains("\"event\":\"pong\"")) {
lastPongAtMs.set(System.currentTimeMillis())
}
eventFlow.tryEmit(parser.parse(text))
}
override fun onClosing(webSocket: WebSocket, code: Int, reason: String) {
isConnected.set(false)
if (!manualDisconnect.get()) {
_connectionState.value = RealtimeConnectionState.Reconnecting
}
heartbeatJob?.cancel()
webSocket.close(code, reason)
}
override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {
isConnected.set(false)
if (!manualDisconnect.get()) {
_connectionState.value = RealtimeConnectionState.Reconnecting
}
heartbeatJob?.cancel()
scheduleReconnect()
}
override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {
isConnected.set(false)
if (!manualDisconnect.get()) {
_connectionState.value = RealtimeConnectionState.Reconnecting
}
heartbeatJob?.cancel()
scheduleReconnect()
}
}
@@ -108,5 +158,7 @@ class WsRealtimeManager @Inject constructor(
private companion object {
const val INITIAL_RECONNECT_MS = 1_000L
const val MAX_RECONNECT_MS = 30_000L
const val PING_INTERVAL_MS = 25_000L
const val PONG_TIMEOUT_MS = 65_000L
}
}

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

@@ -0,0 +1,45 @@
package ru.daemonlord.messenger.data.user.api
import retrofit2.http.Body
import retrofit2.http.DELETE
import retrofit2.http.GET
import retrofit2.http.POST
import retrofit2.http.PUT
import retrofit2.http.Path
import retrofit2.http.Query
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.UserSearchDto
interface UserApiService {
@PUT("/api/v1/users/profile")
suspend fun updateProfile(@Body request: UserProfileUpdateRequestDto): AuthUserDto
@GET("/api/v1/users/blocked")
suspend fun listBlockedUsers(): List<UserSearchDto>
@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")
suspend fun blockUser(@Path("user_id") userId: Long)
@DELETE("/api/v1/users/{user_id}/block")
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

@@ -0,0 +1,38 @@
package ru.daemonlord.messenger.data.user.dto
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class UserProfileUpdateRequestDto(
val name: String? = null,
val username: String? = null,
val bio: String? = null,
@SerialName("avatar_url")
val avatarUrl: String? = null,
@SerialName("allow_private_messages")
val allowPrivateMessages: Boolean? = null,
@SerialName("privacy_private_messages")
val privacyPrivateMessages: String? = null,
@SerialName("privacy_last_seen")
val privacyLastSeen: String? = null,
@SerialName("privacy_avatar")
val privacyAvatar: String? = null,
@SerialName("privacy_group_invites")
val privacyGroupInvites: String? = null,
)
@Serializable
data class UserSearchDto(
val id: Long,
val email: String? = null,
val name: String? = null,
val username: String? = null,
@SerialName("avatar_url")
val avatarUrl: String? = null,
)
@Serializable
data class AddContactByEmailRequestDto(
val email: String,
)

View File

@@ -0,0 +1,385 @@
package ru.daemonlord.messenger.data.user.repository
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.withContext
import okhttp3.MediaType.Companion.toMediaTypeOrNull
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import ru.daemonlord.messenger.data.auth.api.AuthApiService
import ru.daemonlord.messenger.data.auth.dto.AuthSessionDto
import ru.daemonlord.messenger.data.auth.dto.AuthUserDto
import ru.daemonlord.messenger.data.auth.dto.RequestPasswordResetDto
import ru.daemonlord.messenger.data.auth.dto.ResendVerificationRequestDto
import ru.daemonlord.messenger.data.auth.dto.ResetPasswordRequestDto
import ru.daemonlord.messenger.data.auth.dto.TwoFactorCodeRequestDto
import ru.daemonlord.messenger.data.auth.dto.VerifyEmailRequestDto
import ru.daemonlord.messenger.data.common.toAppError
import ru.daemonlord.messenger.data.media.api.MediaApiService
import ru.daemonlord.messenger.data.media.dto.UploadUrlRequestDto
import ru.daemonlord.messenger.data.notifications.api.NotificationApiService
import ru.daemonlord.messenger.data.notifications.dto.NotificationReadDto
import ru.daemonlord.messenger.di.RefreshClient
import ru.daemonlord.messenger.data.user.api.UserApiService
import ru.daemonlord.messenger.data.user.dto.AddContactByEmailRequestDto
import ru.daemonlord.messenger.data.user.dto.UserProfileUpdateRequestDto
import ru.daemonlord.messenger.data.user.dto.UserSearchDto
import ru.daemonlord.messenger.di.IoDispatcher
import ru.daemonlord.messenger.domain.account.model.UserSearchItem
import ru.daemonlord.messenger.domain.account.model.AccountNotification
import ru.daemonlord.messenger.domain.account.repository.AccountRepository
import ru.daemonlord.messenger.domain.auth.model.AuthSession
import ru.daemonlord.messenger.domain.auth.model.AuthUser
import ru.daemonlord.messenger.domain.common.AppError
import ru.daemonlord.messenger.domain.common.AppResult
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import kotlinx.serialization.json.contentOrNull
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class NetworkAccountRepository @Inject constructor(
private val authApiService: AuthApiService,
private val userApiService: UserApiService,
private val mediaApiService: MediaApiService,
private val notificationApiService: NotificationApiService,
@RefreshClient private val uploadClient: OkHttpClient,
@IoDispatcher private val ioDispatcher: CoroutineDispatcher,
) : AccountRepository {
override suspend fun getMe(): AppResult<AuthUser> = withContext(ioDispatcher) {
try {
AppResult.Success(authApiService.me().toDomain())
} catch (error: Throwable) {
AppResult.Error(error.toAppError())
}
}
override suspend fun updateProfile(
name: String,
username: String,
bio: String?,
avatarUrl: String?,
): AppResult<AuthUser> = withContext(ioDispatcher) {
try {
val updated = userApiService.updateProfile(
request = UserProfileUpdateRequestDto(
name = name.trim().ifBlank { null },
username = username.trim().ifBlank { null },
bio = bio?.trim(),
avatarUrl = avatarUrl?.trim(),
)
)
AppResult.Success(updated.toDomain())
} catch (error: Throwable) {
AppResult.Error(error.toAppError())
}
}
override suspend fun uploadAvatar(
fileName: String,
mimeType: String,
bytes: ByteArray,
): AppResult<String> = withContext(ioDispatcher) {
try {
val uploadInfo = mediaApiService.requestUploadUrl(
UploadUrlRequestDto(
fileName = fileName,
fileType = mimeType,
fileSize = bytes.size.toLong(),
)
)
val body = bytes.toRequestBody(mimeType.toMediaTypeOrNull())
val uploadRequestBuilder = Request.Builder()
.url(uploadInfo.uploadUrl)
.put(body)
uploadInfo.requiredHeaders.forEach { (key, value) ->
uploadRequestBuilder.header(key, value)
}
uploadClient.newCall(uploadRequestBuilder.build()).execute().use { response ->
if (!response.isSuccessful) {
return@withContext AppResult.Error(AppError.Server("Upload failed: HTTP ${response.code}"))
}
}
AppResult.Success(uploadInfo.fileUrl)
} catch (error: Throwable) {
AppResult.Error(error.toAppError())
}
}
override suspend fun updatePrivacy(
privateMessages: String,
lastSeen: String,
avatar: String,
groupInvites: String,
): AppResult<AuthUser> = withContext(ioDispatcher) {
try {
val allowPrivateMessages = privateMessages != "nobody"
val updated = userApiService.updateProfile(
request = UserProfileUpdateRequestDto(
allowPrivateMessages = allowPrivateMessages,
privacyPrivateMessages = privateMessages,
privacyLastSeen = lastSeen,
privacyAvatar = avatar,
privacyGroupInvites = groupInvites,
)
)
AppResult.Success(updated.toDomain())
} catch (error: Throwable) {
AppResult.Error(error.toAppError())
}
}
override suspend fun listBlockedUsers(): AppResult<List<UserSearchItem>> = withContext(ioDispatcher) {
try {
AppResult.Success(userApiService.listBlockedUsers().map { it.toDomain() })
} catch (error: Throwable) {
AppResult.Error(error.toAppError())
}
}
override suspend fun 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) {
try {
userApiService.blockUser(userId)
AppResult.Success(Unit)
} catch (error: Throwable) {
AppResult.Error(error.toAppError())
}
}
override suspend fun unblockUser(userId: Long): AppResult<Unit> = withContext(ioDispatcher) {
try {
userApiService.unblockUser(userId)
AppResult.Success(Unit)
} catch (error: Throwable) {
AppResult.Error(error.toAppError())
}
}
override suspend fun listSessions(): AppResult<List<AuthSession>> = withContext(ioDispatcher) {
try {
AppResult.Success(authApiService.sessions().map { it.toDomain() })
} catch (error: Throwable) {
AppResult.Error(error.toAppError())
}
}
override suspend fun listNotifications(limit: Int): AppResult<List<AccountNotification>> = withContext(ioDispatcher) {
try {
AppResult.Success(notificationApiService.list(limit = limit).map { it.toDomain() })
} catch (error: Throwable) {
AppResult.Error(error.toAppError())
}
}
override suspend fun revokeSession(jti: String): AppResult<Unit> = withContext(ioDispatcher) {
try {
authApiService.revokeSession(jti)
AppResult.Success(Unit)
} catch (error: Throwable) {
AppResult.Error(error.toAppError())
}
}
override suspend fun revokeAllSessions(): AppResult<Unit> = withContext(ioDispatcher) {
try {
authApiService.revokeAllSessions()
AppResult.Success(Unit)
} catch (error: Throwable) {
AppResult.Error(error.toAppError())
}
}
override suspend fun verifyEmail(token: String): AppResult<String> = withContext(ioDispatcher) {
try {
val result = authApiService.verifyEmail(VerifyEmailRequestDto(token))
AppResult.Success(result.message)
} catch (error: Throwable) {
AppResult.Error(error.toAppError())
}
}
override suspend fun requestPasswordReset(email: String): AppResult<String> = withContext(ioDispatcher) {
try {
val result = authApiService.requestPasswordReset(RequestPasswordResetDto(email = email.trim()))
AppResult.Success(result.message)
} catch (error: Throwable) {
AppResult.Error(error.toAppError())
}
}
override suspend fun resendVerification(email: String): AppResult<String> = withContext(ioDispatcher) {
try {
val result = authApiService.resendVerification(ResendVerificationRequestDto(email = email.trim()))
AppResult.Success(result.message)
} catch (error: Throwable) {
AppResult.Error(error.toAppError())
}
}
override suspend fun resetPassword(token: String, password: String): AppResult<String> = withContext(ioDispatcher) {
try {
val result = authApiService.resetPassword(
ResetPasswordRequestDto(
token = token.trim(),
password = password,
)
)
AppResult.Success(result.message)
} catch (error: Throwable) {
AppResult.Error(error.toAppError())
}
}
override suspend fun setupTwoFactor(): AppResult<Pair<String, String>> = withContext(ioDispatcher) {
try {
val response = authApiService.setupTwoFactor()
AppResult.Success(response.secret to response.otpauthUrl)
} catch (error: Throwable) {
AppResult.Error(error.toAppError())
}
}
override suspend fun enableTwoFactor(code: String): AppResult<String> = withContext(ioDispatcher) {
try {
val response = authApiService.enableTwoFactor(TwoFactorCodeRequestDto(code.trim()))
AppResult.Success(response.message)
} catch (error: Throwable) {
AppResult.Error(error.toAppError())
}
}
override suspend fun disableTwoFactor(code: String): AppResult<String> = withContext(ioDispatcher) {
try {
val response = authApiService.disableTwoFactor(TwoFactorCodeRequestDto(code.trim()))
AppResult.Success(response.message)
} catch (error: Throwable) {
AppResult.Error(error.toAppError())
}
}
override suspend fun twoFactorRecoveryStatus(): AppResult<Int> = withContext(ioDispatcher) {
try {
AppResult.Success(authApiService.twoFactorRecoveryStatus().remainingCodes)
} catch (error: Throwable) {
AppResult.Error(error.toAppError())
}
}
override suspend fun regenerateTwoFactorRecoveryCodes(code: String): AppResult<List<String>> = withContext(ioDispatcher) {
try {
val response = authApiService.regenerateTwoFactorRecoveryCodes(TwoFactorCodeRequestDto(code.trim()))
AppResult.Success(response.codes)
} catch (error: Throwable) {
AppResult.Error(error.toAppError())
}
}
private fun AuthUserDto.toDomain(): AuthUser {
return AuthUser(
id = id,
email = email,
name = name,
username = username,
bio = bio,
avatarUrl = avatarUrl,
emailVerified = emailVerified,
twofaEnabled = twofaEnabled,
privacyPrivateMessages = privacyPrivateMessages ?: "everyone",
privacyLastSeen = privacyLastSeen ?: "everyone",
privacyAvatar = privacyAvatar ?: "everyone",
privacyGroupInvites = privacyGroupInvites ?: "everyone",
)
}
private fun AuthSessionDto.toDomain(): AuthSession {
return AuthSession(
jti = jti,
createdAt = createdAt,
ipAddress = ipAddress,
userAgent = userAgent,
current = current,
tokenType = tokenType,
)
}
private fun UserSearchDto.toDomain(): UserSearchItem {
return UserSearchItem(
id = id,
name = name?.trim().takeUnless { it.isNullOrBlank() } ?: username?.trim().takeUnless { it.isNullOrBlank() } ?: "User #$id",
username = username,
avatarUrl = avatarUrl,
)
}
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

@@ -9,6 +9,8 @@ import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import ru.daemonlord.messenger.data.chat.local.dao.ChatDao
import ru.daemonlord.messenger.data.chat.local.db.MessengerDatabase
import ru.daemonlord.messenger.data.message.local.dao.MessageDao
import ru.daemonlord.messenger.data.message.local.dao.PendingMessageActionDao
import javax.inject.Singleton
@Module
@@ -31,4 +33,13 @@ object DatabaseModule {
@Provides
@Singleton
fun provideChatDao(database: MessengerDatabase): ChatDao = database.chatDao()
@Provides
@Singleton
fun provideMessageDao(database: MessengerDatabase): MessageDao = database.messageDao()
@Provides
@Singleton
fun providePendingMessageActionDao(database: MessengerDatabase): PendingMessageActionDao =
database.pendingMessageActionDao()
}

View File

@@ -0,0 +1,23 @@
package ru.daemonlord.messenger.di
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import ru.daemonlord.messenger.BuildConfig
import ru.daemonlord.messenger.core.feature.FeatureFlags
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
object FeatureFlagsModule {
@Provides
@Singleton
fun provideFeatureFlags(): FeatureFlags {
return FeatureFlags(
accountManagementEnabled = BuildConfig.FEATURE_ACCOUNT_MANAGEMENT,
twoFactorEnabled = BuildConfig.FEATURE_TWO_FACTOR,
mediaGalleryEnabled = BuildConfig.FEATURE_MEDIA_GALLERY,
)
}
}

View File

@@ -0,0 +1,25 @@
package ru.daemonlord.messenger.di
import com.google.firebase.crashlytics.FirebaseCrashlytics
import dagger.Binds
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import ru.daemonlord.messenger.core.logging.AppLogger
import ru.daemonlord.messenger.core.logging.TimberAppLogger
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
abstract class LoggingModule {
@Binds
@Singleton
abstract fun bindAppLogger(logger: TimberAppLogger): AppLogger
companion object {
@Provides
@Singleton
fun provideCrashlytics(): FirebaseCrashlytics = FirebaseCrashlytics.getInstance()
}
}

View File

@@ -0,0 +1,37 @@
package ru.daemonlord.messenger.di
import android.content.Context
import androidx.media3.database.StandaloneDatabaseProvider
import androidx.media3.datasource.cache.Cache
import androidx.media3.datasource.cache.LeastRecentlyUsedCacheEvictor
import androidx.media3.datasource.cache.SimpleCache
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import java.io.File
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
object MediaCacheModule {
@Provides
@Singleton
fun provideMediaCache(
@ApplicationContext context: Context,
): Cache {
val cacheDir = File(context.cacheDir, "exo_media_cache")
if (!cacheDir.exists()) {
cacheDir.mkdirs()
}
val maxBytes = 200L * 1024L * 1024L
return SimpleCache(
cacheDir,
LeastRecentlyUsedCacheEvictor(maxBytes),
StandaloneDatabaseProvider(context),
)
}
}

View File

@@ -13,9 +13,16 @@ import okhttp3.logging.HttpLoggingInterceptor
import retrofit2.Retrofit
import ru.daemonlord.messenger.BuildConfig
import ru.daemonlord.messenger.core.network.AuthHeaderInterceptor
import ru.daemonlord.messenger.core.network.ApiVersionInterceptor
import ru.daemonlord.messenger.core.network.TokenRefreshAuthenticator
import ru.daemonlord.messenger.data.auth.api.AuthApiService
import ru.daemonlord.messenger.data.chat.api.ChatApiService
import ru.daemonlord.messenger.data.media.api.MediaApiService
import ru.daemonlord.messenger.data.message.api.MessageApiService
import ru.daemonlord.messenger.data.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 com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory
import java.util.concurrent.TimeUnit
import javax.inject.Singleton
@@ -85,11 +92,13 @@ object NetworkModule {
@Singleton
fun provideApiClient(
loggingInterceptor: HttpLoggingInterceptor,
apiVersionInterceptor: ApiVersionInterceptor,
authHeaderInterceptor: AuthHeaderInterceptor,
tokenRefreshAuthenticator: TokenRefreshAuthenticator,
): OkHttpClient {
return OkHttpClient.Builder()
.addInterceptor(loggingInterceptor)
.addInterceptor(apiVersionInterceptor)
.addInterceptor(authHeaderInterceptor)
.authenticator(tokenRefreshAuthenticator)
.connectTimeout(20, TimeUnit.SECONDS)
@@ -123,4 +132,40 @@ object NetworkModule {
fun provideChatApiService(retrofit: Retrofit): ChatApiService {
return retrofit.create(ChatApiService::class.java)
}
@Provides
@Singleton
fun provideMessageApiService(retrofit: Retrofit): MessageApiService {
return retrofit.create(MessageApiService::class.java)
}
@Provides
@Singleton
fun provideMediaApiService(retrofit: Retrofit): MediaApiService {
return retrofit.create(MediaApiService::class.java)
}
@Provides
@Singleton
fun provideUserApiService(retrofit: Retrofit): UserApiService {
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

@@ -13,3 +13,7 @@ annotation class RefreshAuthApi
@Qualifier
@Retention(AnnotationRetention.BINARY)
annotation class IoDispatcher
@Qualifier
@Retention(AnnotationRetention.BINARY)
annotation class TokenPrefs

View File

@@ -5,9 +5,27 @@ import dagger.Module
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import ru.daemonlord.messenger.data.auth.repository.NetworkAuthRepository
import ru.daemonlord.messenger.data.auth.repository.DefaultSessionCleanupRepository
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.message.repository.NetworkMessageRepository
import ru.daemonlord.messenger.data.notifications.repository.DataStoreNotificationSettingsRepository
import ru.daemonlord.messenger.data.search.repository.NetworkSearchRepository
import ru.daemonlord.messenger.data.settings.repository.DataStoreLanguageRepository
import ru.daemonlord.messenger.data.settings.repository.DataStoreThemeRepository
import ru.daemonlord.messenger.data.user.repository.NetworkAccountRepository
import ru.daemonlord.messenger.domain.account.repository.AccountRepository
import ru.daemonlord.messenger.domain.auth.repository.AuthRepository
import ru.daemonlord.messenger.domain.auth.repository.SessionCleanupRepository
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.message.repository.MessageRepository
import ru.daemonlord.messenger.domain.notifications.repository.NotificationSettingsRepository
import ru.daemonlord.messenger.domain.search.repository.SearchRepository
import ru.daemonlord.messenger.domain.settings.repository.LanguageRepository
import ru.daemonlord.messenger.domain.settings.repository.ThemeRepository
import javax.inject.Singleton
@Module
@@ -20,9 +38,63 @@ abstract class RepositoryModule {
repository: NetworkAuthRepository,
): AuthRepository
@Binds
@Singleton
abstract fun bindSessionCleanupRepository(
repository: DefaultSessionCleanupRepository,
): SessionCleanupRepository
@Binds
@Singleton
abstract fun bindChatRepository(
repository: NetworkChatRepository,
): ChatRepository
@Binds
@Singleton
abstract fun bindChatSearchRepository(
repository: DataStoreChatSearchRepository,
): ChatSearchRepository
@Binds
@Singleton
abstract fun bindMessageRepository(
repository: NetworkMessageRepository,
): MessageRepository
@Binds
@Singleton
abstract fun bindMediaRepository(
repository: NetworkMediaRepository,
): MediaRepository
@Binds
@Singleton
abstract fun bindNotificationSettingsRepository(
repository: DataStoreNotificationSettingsRepository,
): NotificationSettingsRepository
@Binds
@Singleton
abstract fun bindAccountRepository(
repository: NetworkAccountRepository,
): 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

@@ -1,16 +1,19 @@
package ru.daemonlord.messenger.di
import android.content.Context
import android.content.SharedPreferences
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.PreferenceDataStoreFactory
import androidx.datastore.preferences.preferencesDataStoreFile
import androidx.security.crypto.EncryptedSharedPreferences
import androidx.security.crypto.MasterKey
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import ru.daemonlord.messenger.core.token.DataStoreTokenRepository
import ru.daemonlord.messenger.core.token.EncryptedPrefsTokenRepository
import ru.daemonlord.messenger.core.token.TokenRepository
import javax.inject.Singleton
@@ -24,13 +27,31 @@ object StorageModule {
@ApplicationContext context: Context,
): DataStore<Preferences> {
return PreferenceDataStoreFactory.create(
produceFile = { context.preferencesDataStoreFile("messenger_tokens.preferences_pb") }
produceFile = { context.preferencesDataStoreFile("messenger_preferences.preferences_pb") }
)
}
@Provides
@Singleton
@TokenPrefs
fun provideTokenSharedPreferences(
@ApplicationContext context: Context,
): SharedPreferences {
val masterKey = MasterKey.Builder(context)
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
.build()
return EncryptedSharedPreferences.create(
context,
"messenger_secure_tokens",
masterKey,
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,
)
}
@Provides
@Singleton
fun provideTokenRepository(
repository: DataStoreTokenRepository,
repository: EncryptedPrefsTokenRepository,
): TokenRepository = repository
}

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

@@ -0,0 +1,8 @@
package ru.daemonlord.messenger.domain.account.model
data class UserSearchItem(
val id: Long,
val name: String,
val username: String?,
val avatarUrl: String?,
)

View File

@@ -0,0 +1,49 @@
package ru.daemonlord.messenger.domain.account.repository
import ru.daemonlord.messenger.domain.account.model.UserSearchItem
import ru.daemonlord.messenger.domain.account.model.AccountNotification
import ru.daemonlord.messenger.domain.auth.model.AuthSession
import ru.daemonlord.messenger.domain.auth.model.AuthUser
import ru.daemonlord.messenger.domain.common.AppResult
interface AccountRepository {
suspend fun getMe(): AppResult<AuthUser>
suspend fun updateProfile(
name: String,
username: String,
bio: String?,
avatarUrl: String?,
): AppResult<AuthUser>
suspend fun uploadAvatar(
fileName: String,
mimeType: String,
bytes: ByteArray,
): AppResult<String>
suspend fun updatePrivacy(
privateMessages: String,
lastSeen: String,
avatar: String,
groupInvites: String,
): AppResult<AuthUser>
suspend fun listBlockedUsers(): AppResult<List<UserSearchItem>>
suspend fun 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 unblockUser(userId: Long): AppResult<Unit>
suspend fun listSessions(): AppResult<List<AuthSession>>
suspend fun listNotifications(limit: Int = 50): AppResult<List<AccountNotification>>
suspend fun revokeSession(jti: String): AppResult<Unit>
suspend fun revokeAllSessions(): AppResult<Unit>
suspend fun verifyEmail(token: String): AppResult<String>
suspend fun resendVerification(email: String): AppResult<String>
suspend fun requestPasswordReset(email: String): AppResult<String>
suspend fun resetPassword(token: String, password: String): AppResult<String>
suspend fun setupTwoFactor(): AppResult<Pair<String, String>>
suspend fun enableTwoFactor(code: String): AppResult<String>
suspend fun disableTwoFactor(code: String): AppResult<String>
suspend fun twoFactorRecoveryStatus(): AppResult<Int>
suspend fun regenerateTwoFactorRecoveryCodes(code: String): AppResult<List<String>>
}

View File

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

@@ -0,0 +1,10 @@
package ru.daemonlord.messenger.domain.auth.model
data class AuthSession(
val jti: String,
val createdAt: String,
val ipAddress: String?,
val userAgent: String?,
val current: Boolean?,
val tokenType: String?,
)

View File

@@ -5,6 +5,12 @@ data class AuthUser(
val email: String,
val name: String,
val username: String,
val bio: String?,
val avatarUrl: String?,
val emailVerified: Boolean,
val twofaEnabled: Boolean,
val privacyPrivateMessages: String,
val privacyLastSeen: String,
val privacyAvatar: String,
val privacyGroupInvites: String,
)

View File

@@ -1,12 +1,24 @@
package ru.daemonlord.messenger.domain.auth.repository
import ru.daemonlord.messenger.domain.auth.model.AuthUser
import ru.daemonlord.messenger.domain.auth.model.AuthSession
import ru.daemonlord.messenger.domain.auth.model.AuthEmailStatus
import ru.daemonlord.messenger.domain.common.AppResult
interface AuthRepository {
suspend fun login(email: String, password: String): AppResult<AuthUser>
suspend fun checkEmailStatus(email: String): AppResult<AuthEmailStatus>
suspend fun register(email: String, name: String, username: String, password: String): AppResult<Unit>
suspend fun login(
email: String,
password: String,
otpCode: String? = null,
recoveryCode: String? = null,
): AppResult<AuthUser>
suspend fun refreshTokens(): AppResult<Unit>
suspend fun getMe(): AppResult<AuthUser>
suspend fun restoreSession(): AppResult<AuthUser>
suspend fun listSessions(): AppResult<List<AuthSession>>
suspend fun revokeSession(jti: String): AppResult<Unit>
suspend fun revokeAllSessions(): AppResult<Unit>
suspend fun logout()
}

View File

@@ -0,0 +1,6 @@
package ru.daemonlord.messenger.domain.auth.repository
interface SessionCleanupRepository {
suspend fun clearLocalSessionData()
}

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

@@ -0,0 +1,14 @@
package ru.daemonlord.messenger.domain.auth.usecase
import ru.daemonlord.messenger.domain.auth.model.AuthSession
import ru.daemonlord.messenger.domain.auth.repository.AuthRepository
import ru.daemonlord.messenger.domain.common.AppResult
import javax.inject.Inject
class ListSessionsUseCase @Inject constructor(
private val repository: AuthRepository,
) {
suspend operator fun invoke(): AppResult<List<AuthSession>> {
return repository.listSessions()
}
}

View File

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

View File

@@ -0,0 +1,22 @@
package ru.daemonlord.messenger.domain.auth.usecase
import ru.daemonlord.messenger.core.notifications.ActiveChatTracker
import ru.daemonlord.messenger.domain.auth.repository.AuthRepository
import ru.daemonlord.messenger.domain.auth.repository.SessionCleanupRepository
import ru.daemonlord.messenger.domain.realtime.RealtimeManager
import javax.inject.Inject
class LogoutUseCase @Inject constructor(
private val authRepository: AuthRepository,
private val sessionCleanupRepository: SessionCleanupRepository,
private val realtimeManager: RealtimeManager,
private val activeChatTracker: ActiveChatTracker,
) {
suspend operator fun invoke() {
realtimeManager.disconnect()
activeChatTracker.clear()
authRepository.logout()
sessionCleanupRepository.clearLocalSessionData()
}
}

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,13 @@
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 RevokeAllSessionsUseCase @Inject constructor(
private val repository: AuthRepository,
) {
suspend operator fun invoke(): AppResult<Unit> {
return repository.revokeAllSessions()
}
}

View File

@@ -0,0 +1,13 @@
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 RevokeSessionUseCase @Inject constructor(
private val repository: AuthRepository,
) {
suspend operator fun invoke(jti: String): AppResult<Unit> {
return repository.revokeSession(jti = jti)
}
}

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,7 @@
package ru.daemonlord.messenger.domain.chat.model
data class ChatInviteLink(
val chatId: Long,
val token: String,
val inviteUrl: String,
)

View File

@@ -21,5 +21,7 @@ data class ChatItem(
val lastMessageText: String?,
val lastMessageType: String?,
val lastMessageCreatedAt: String?,
val pinnedMessageId: Long?,
val myRole: String?,
val updatedSortAt: 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,12 +1,54 @@
package ru.daemonlord.messenger.domain.chat.repository
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.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
interface ChatRepository {
fun observeChats(archived: Boolean): Flow<List<ChatItem>>
fun observeChat(chatId: Long): Flow<ChatItem?>
suspend fun refreshChats(archived: Boolean): AppResult<Unit>
suspend fun refreshChat(chatId: Long): AppResult<Unit>
suspend fun getSavedChat(): AppResult<ChatItem>
suspend fun createInviteLink(chatId: Long): AppResult<ChatInviteLink>
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)
}

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,14 @@
package ru.daemonlord.messenger.domain.chat.usecase
import ru.daemonlord.messenger.domain.chat.model.ChatInviteLink
import ru.daemonlord.messenger.domain.chat.repository.ChatRepository
import ru.daemonlord.messenger.domain.common.AppResult
import javax.inject.Inject
class CreateInviteLinkUseCase @Inject constructor(
private val repository: ChatRepository,
) {
suspend operator fun invoke(chatId: Long): AppResult<ChatInviteLink> {
return repository.createInviteLink(chatId = chatId)
}
}

View File

@@ -0,0 +1,14 @@
package ru.daemonlord.messenger.domain.chat.usecase
import ru.daemonlord.messenger.domain.chat.model.ChatItem
import ru.daemonlord.messenger.domain.chat.repository.ChatRepository
import ru.daemonlord.messenger.domain.common.AppResult
import javax.inject.Inject
class JoinByInviteUseCase @Inject constructor(
private val repository: ChatRepository,
) {
suspend operator fun invoke(token: String): AppResult<ChatItem> {
return repository.joinByInvite(token = token)
}
}

View File

@@ -0,0 +1,14 @@
package ru.daemonlord.messenger.domain.chat.usecase
import kotlinx.coroutines.flow.Flow
import ru.daemonlord.messenger.domain.chat.model.ChatItem
import ru.daemonlord.messenger.domain.chat.repository.ChatRepository
import javax.inject.Inject
class ObserveChatUseCase @Inject constructor(
private val repository: ChatRepository,
) {
operator fun invoke(chatId: Long): Flow<ChatItem?> {
return repository.observeChat(chatId = chatId)
}
}

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