Compare commits

...

399 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
Codex
4939754de8 android: stabilize DI graph and production api config
Some checks are pending
CI / test (push) Has started running
2026-03-08 23:02:16 +03:00
Codex
9d842c1d88 android: add chat/realtime tests and update android checklist 2026-03-08 22:34:41 +03:00
Codex
2dfad1a624 android: add chat list compose screen and chat placeholder navigation 2026-03-08 22:32:15 +03:00
Codex
21aa11c342 android: add websocket realtime manager and room event handling 2026-03-08 22:29:38 +03:00
Codex
d006998867 android: add chats api and cache-first repository sync 2026-03-08 22:27:50 +03:00
Codex
f838fe1d5d android: add room schema and chat list domain models 2026-03-08 22:26:08 +03:00
Codex
390dcb8b2d android: add unit tests for token store and auth login mapping 2026-03-08 22:22:04 +03:00
Codex
54b0d4eb8c android: add auth UI flow and auth-to-chats navigation 2026-03-08 22:21:51 +03:00
Codex
0ff838baf7 android: add auth network core, token store, and DI wiring 2026-03-08 22:21:24 +03:00
Codex
acdb83e04e android: 2026-03-08 23:48:24 +03:00
c86c8cf344 android: bootstrap phase-0 compose project skeleton
Some checks failed
CI / test (push) Failing after 2m4s
2026-03-08 23:00:43 +03:00
3c855d78a6 docs: add android checklist and prioritized roadmap
Some checks are pending
CI / test (push) Has started running
2026-03-08 22:59:33 +03:00
bf7b4fa3c0 web: allow editing group and channel descriptions in chat info
Some checks failed
CI / test (push) Failing after 2m11s
2026-03-08 22:56:07 +03:00
0bc7760eee web: hide participants list in group and channel info for members
Some checks failed
CI / test (push) Failing after 2m10s
2026-03-08 22:53:30 +03:00
f3f593c8c9 docs: mark remaining checklist modules as done for current web scope
Some checks failed
CI / test (push) Has been cancelled
2026-03-08 22:52:28 +03:00
d7513d7caf web: add notification sound toggle and complete notifications module
Some checks failed
CI / test (push) Has been cancelled
2026-03-08 22:51:39 +03:00
7889c7a958 web: add sticker search and close formatting/media checklist items
Some checks are pending
CI / test (push) Has started running
2026-03-08 22:50:36 +03:00
c18ed3db81 web: add lightweight inline link preview cards in messages
Some checks failed
CI / test (push) Failing after 2m9s
2026-03-08 22:48:03 +03:00
8fcd2156c6 web: use last-seen-recently fallback in private chat status
Some checks are pending
CI / test (push) Has started running
2026-03-08 22:47:16 +03:00
9f8bcb5724 web: handle notification deep links after auth
Some checks are pending
CI / test (push) Has started running
2026-03-08 22:46:23 +03:00
f8b377904e web: add inline block and unblock actions in contacts panel
Some checks are pending
CI / test (push) Has started running
2026-03-08 22:44:12 +03:00
ff4aa48a34 web: add markdown formatting keyboard shortcuts in composer
Some checks are pending
CI / test (push) Has started running
2026-03-08 22:43:09 +03:00
10eb82c82d web: add jump-to-message navigation from thread panel
Some checks failed
CI / test (push) Has been cancelled
2026-03-08 22:42:24 +03:00
16f3d91c3b web: refresh full chat info panel after moderation actions
Some checks are pending
CI / test (push) Has started running
2026-03-08 22:40:56 +03:00
f9c8ba5c52 feat(web): add empty-state hint for banned users filter
Some checks are pending
CI / test (push) Has started running
2026-03-08 22:39:27 +03:00
2dc04f565f feat(web): add add-member empty-state hint in chat info
Some checks are pending
CI / test (push) Has started running
2026-03-08 22:38:51 +03:00
15c7b7ac43 feat(web): exclude banned users from add-member search
Some checks are pending
CI / test (push) Has started running
2026-03-08 22:37:45 +03:00
794dcece29 fix(web): prevent invalid owner leave action in group/channel info
Some checks are pending
CI / test (push) Has started running
2026-03-08 22:37:06 +03:00
d971e0ac0f feat(web): add delete-for-all action for groups in chat info
Some checks failed
CI / test (push) Has been cancelled
2026-03-08 22:36:26 +03:00
ec3dbddad6 fix(chats): resolve saved chat detail 500 by importing ChatMemberRead
Some checks failed
CI / test (push) Failing after 2m15s
2026-03-08 22:32:28 +03:00
92f60972de fix(web): stop invite auto-join retry spam on failures
Some checks failed
CI / test (push) Failing after 2m18s
2026-03-08 22:28:33 +03:00
751f8c9067 feat(web): unify attachment open behavior in context menus
Some checks failed
CI / test (push) Failing after 2m19s
2026-03-08 22:24:28 +03:00
4697193243 feat(web): normalize moderation filters for @username input
Some checks failed
CI / test (push) Failing after 2m13s
2026-03-08 21:46:48 +03:00
8da090778e feat(web): add role-based channel actions in chat info
Some checks failed
CI / test (push) Has been cancelled
2026-03-08 21:46:12 +03:00
3416d44afa fix(web): harden chat info profile loading for partial failures
Some checks are pending
CI / test (push) Has started running
2026-03-08 21:45:23 +03:00
97dd543d30 feat(web): use blob download flow in chat info attachment menu
Some checks failed
CI / test (push) Has been cancelled
2026-03-08 21:44:43 +03:00
8189c0c933 docs(realtime): clarify circle_video is mobile-only sender type
Some checks are pending
CI / test (push) Has started running
2026-03-08 21:43:37 +03:00
00df092096 feat(web): add explicit member actions button in chat info
Some checks are pending
CI / test (push) Has started running
2026-03-08 21:43:08 +03:00
c742d785e3 feat(web): show ban actor and timestamp in chat info
Some checks are pending
CI / test (push) Has started running
2026-03-08 21:42:13 +03:00
4b95f84f6e fix(web): avoid nested buttons in banned users list
Some checks failed
CI / test (push) Has been cancelled
2026-03-08 21:41:30 +03:00
de8037d73c feat(web): add inline unban action in chat info bans list
Some checks are pending
CI / test (push) Has started running
2026-03-08 21:40:53 +03:00
e233cab993 refactor(web): limit composer realtime events to typing and voice
Some checks are pending
CI / test (push) Has started running
2026-03-08 21:40:03 +03:00
cf967026f4 feat(web): add avatars to add-member search results
Some checks are pending
CI / test (push) Has started running
2026-03-08 21:39:20 +03:00
9f94084e3f feat(web): show avatars in chat info moderation lists
Some checks are pending
CI / test (push) Has started running
2026-03-08 21:38:25 +03:00
119b423632 feat(web): remove circle video compose flow from web client
Some checks failed
CI / test (push) Has been cancelled
2026-03-08 21:35:58 +03:00
f3a00155d3 docs(status): mark focus shifted beyond p1
Some checks failed
CI / test (push) Has been cancelled
2026-03-08 21:33:36 +03:00
3506231295 perf(web): reduce member profile roundtrips in chat info
Some checks are pending
CI / test (push) Has started running
2026-03-08 21:33:11 +03:00
cb37e735b0 feat(web): add chat info sync polling fallback
Some checks are pending
CI / test (push) Has started running
2026-03-08 21:31:58 +03:00
4555a8454c feat(web): improve chat moderation panel ux for members and bans
Some checks are pending
CI / test (push) Has started running
2026-03-08 21:31:07 +03:00
775236b483 feat(web): add banned users section in chat info moderation
Some checks failed
CI / test (push) Failing after 2m12s
2026-03-08 21:26:10 +03:00
2f6aa86cc9 test(channels): cover invite-link permissions for member and admin
Some checks failed
CI / test (push) Failing after 2m19s
2026-03-08 21:23:25 +03:00
6e24c559aa feat(groups): include member profile fields in chat members API
Some checks are pending
CI / test (push) Has started running
2026-03-08 21:22:53 +03:00
90320ffd5d feat(moderation): add chat bans list endpoint with admin access checks
Some checks are pending
CI / test (push) Has started running
2026-03-08 21:21:43 +03:00
5909503012 feat(p0): complete account security privacy and sync hardening
Some checks failed
CI / test (push) Failing after 2m10s
2026-03-08 21:19:12 +03:00
6b724e260f fix(migration): merge duplicate saved chats per user
Some checks failed
CI / test (push) Failing after 2m5s
2026-03-08 21:15:48 +03:00
926413534b fix(chats): prevent duplicate saved messages entries in chat list
Some checks failed
CI / test (push) Failing after 1m57s
2026-03-08 21:13:40 +03:00
af3c5bd79e fix(auth-web): handle verify-email token links and show auth feedback
Some checks failed
CI / test (push) Failing after 1m56s
2026-03-08 21:11:06 +03:00
727df4c7f8 test(privacy): cover avatar everyone visibility in user search
Some checks failed
CI / test (push) Failing after 1m48s
2026-03-08 21:08:39 +03:00
b6ffff8015 fix(sync): publish chat updates for mute archive and pin mutations
Some checks are pending
CI / test (push) Has started running
2026-03-08 21:08:07 +03:00
a7965aa882 test(account): cover resend verification and password reset login flow
Some checks failed
CI / test (push) Has been cancelled
2026-03-08 21:07:17 +03:00
d6378ab346 test(privacy): extend avatar and presence matrix coverage
Some checks failed
CI / test (push) Failing after 1m32s
2026-03-08 21:04:27 +03:00
eb27371f0d feat(settings): harden 2fa recovery code UX with warning copy and download
Some checks failed
CI / test (push) Has been cancelled
2026-03-08 21:02:49 +03:00
c222c93628 test(auth): cover normalized 2fa recovery codes and status decrement
Some checks are pending
CI / test (push) Has started running
2026-03-08 21:01:26 +03:00
84613228aa feat(auth): support 2fa recovery code login in web auth panel
Some checks are pending
CI / test (push) Has started running
2026-03-08 21:00:10 +03:00
fb0e4dabba fix(chat-list): show separate pin and mute indicators without replacing avatar
Some checks failed
CI / test (push) Failing after 1m38s
2026-03-08 20:52:54 +03:00
f12f9e590c test(media): cover upload-url acceptance for mp4/m4a audio
Some checks failed
CI / test (push) Failing after 1m33s
2026-03-08 20:51:00 +03:00
21c8f57169 fix(media): allow mp4/m4a audio uploads for voice recordings
Some checks failed
CI / test (push) Failing after 1m31s
2026-03-08 20:48:36 +03:00
e59c60094f fix(voice): improve duration detection for new waveform playback
Some checks failed
CI / test (push) Has been cancelled
2026-03-08 20:46:57 +03:00
8092cb53c5 fix(media): exclude stickers and gifs from media gallery set
Some checks failed
CI / test (push) Has been cancelled
2026-03-08 20:45:16 +03:00
11d108f0a6 fix(media): keep sticker and gif clicks out of photo viewer
Some checks failed
CI / test (push) Has been cancelled
2026-03-08 20:44:33 +03:00
f0582bf4ab fix(composer): guard websocket and recorder race on chat switch
Some checks failed
CI / test (push) Failing after 1m32s
2026-03-08 20:42:19 +03:00
20f31cd15e fix(notifications): sync mute state in chat store immediately
Some checks failed
CI / test (push) Has been cancelled
2026-03-08 20:40:44 +03:00
418c9e6044 feat(notifications): honor chat mute in web realtime alerts
Some checks failed
CI / test (push) Failing after 1m30s
2026-03-08 20:37:54 +03:00
6c039ae94f fix(contacts-ui): show specific add-by-email errors
Some checks are pending
CI / test (push) Has started running
2026-03-08 20:36:38 +03:00
42596fba16 feat(status): improve last-seen labels in web private chats
Some checks are pending
CI / test (push) Has started running
2026-03-08 20:35:57 +03:00
25b6f470d5 fix(settings): show sessions load errors explicitly
Some checks are pending
CI / test (push) Has started running
2026-03-08 20:34:36 +03:00
586d3acc16 feat(settings): harden privacy and sessions actions UX
Some checks are pending
CI / test (push) Has started running
2026-03-08 20:33:16 +03:00
4122882b7e feat(privacy): support nobody option for group invites
Some checks are pending
CI / test (push) Has started running
2026-03-08 20:32:29 +03:00
362098b954 test(chats): ensure saved chat delete clears history only
Some checks failed
CI / test (push) Failing after 1m28s
2026-03-08 20:27:33 +03:00
f57e254bcc test(messages): cover 7-day edit window enforcement
Some checks failed
CI / test (push) Has been cancelled
2026-03-08 20:26:21 +03:00
f6c686a343 fix(web): keep chat context menu actions clickable
Some checks are pending
CI / test (push) Has started running
2026-03-08 20:25:22 +03:00
f746e31616 test(contacts): cover blocked relation for add-by-email
Some checks failed
CI / test (push) Failing after 1m18s
2026-03-08 20:23:50 +03:00
a900713a48 test(contacts): cover add-by-email success and not-found
Some checks failed
CI / test (push) Failing after 1m19s
2026-03-08 20:22:06 +03:00
1337a7c10e test(privacy): cover everyone group-invite policy
Some checks are pending
CI / test (push) Has started running
2026-03-08 20:21:41 +03:00
4cd374e33e test(privacy): cover everyone private-message policy
Some checks failed
CI / test (push) Has been cancelled
2026-03-08 20:21:13 +03:00
aaae5b313e test(privacy): enforce nobody group-invite policy
Some checks are pending
CI / test (push) Has started running
2026-03-08 20:20:47 +03:00
6fbb98cf2f test(invites): return 404 for invalid join token
Some checks are pending
CI / test (push) Has started running
2026-03-08 20:19:53 +03:00
bbb97292d2 docs(api): add invite-link permission rules
Some checks failed
CI / test (push) Has been cancelled
2026-03-08 20:19:30 +03:00
58e85d0a64 test(invites): cover join-by-token and invite-link permissions
Some checks are pending
CI / test (push) Has started running
2026-03-08 20:19:16 +03:00
90c2bdcd96 docs(api): document owner-only chat role update rules
Some checks are pending
CI / test (push) Has started running
2026-03-08 20:18:18 +03:00
ee43d13ba4 test(roles): enforce owner-only member role management
Some checks are pending
CI / test (push) Has started running
2026-03-08 20:17:30 +03:00
58c80460fa docs(api): clarify message delete semantics and channel constraints
Some checks failed
CI / test (push) Failing after 1m6s
2026-03-08 20:15:46 +03:00
80bda6e537 test(channels): enforce delete-for-all permissions on messages
Some checks are pending
CI / test (push) Has started running
2026-03-08 20:15:25 +03:00
60e5225c80 fix(ui): prevent chat-info attachment menu clicks from closing panel
Some checks are pending
CI / test (push) Has started running
2026-03-08 20:14:24 +03:00
7453e1ec06 feat(realtime): emit recording_video activity in circle-video flow
Some checks failed
CI / test (push) Has been cancelled
2026-03-08 20:13:43 +03:00
1d2610a796 docs(checklist): note session revoke test coverage
Some checks failed
CI / test (push) Failing after 1m2s
2026-03-08 20:10:42 +03:00
ace8c79051 test(auth): cover single-session revoke behavior
Some checks are pending
CI / test (push) Has started running
2026-03-08 20:10:30 +03:00
190b7b9d71 docs(checklist): mark typing module partial until video recorder emit is wired
Some checks failed
CI / test (push) Has been cancelled
2026-03-08 20:09:48 +03:00
9f03aafd18 test(privacy): enforce nobody private message policy
Some checks are pending
CI / test (push) Has started running
2026-03-08 20:09:14 +03:00
9ffcf7b3ef perf(realtime): debounce typing start/stop events
Some checks failed
CI / test (push) Failing after 55s
2026-03-08 20:06:04 +03:00
c5b90bc91c fix(typing): stop indicator on blur and chat switch
Some checks are pending
CI / test (push) Has started running
2026-03-08 20:05:23 +03:00
1a3a54cfb9 test(moderation): enforce group profile edit permissions by role
Some checks are pending
CI / test (push) Has started running
2026-03-08 20:04:55 +03:00
57b687a036 test(channels): validate admin global delete permissions
Some checks failed
CI / test (push) Failing after 51s
2026-03-08 20:03:15 +03:00
d6cd0e719c fix(realtime): flush activity state during forced disconnect cleanup
Some checks are pending
CI / test (push) Has started running
2026-03-08 20:02:46 +03:00
724bd24b4f docs(api): add full realtime websocket protocol section
Some checks are pending
CI / test (push) Has started running
2026-03-08 20:02:07 +03:00
9bc695ca58 test(privacy): verify contacts-only avatar and presence visibility
Some checks are pending
CI / test (push) Has started running
2026-03-08 20:01:36 +03:00
f369083b6a fix(realtime-ui): auto-expire stale typing/recording indicators
Some checks are pending
CI / test (push) Has started running
2026-03-08 20:00:59 +03:00
6930e73b9f test(channels): enforce member read-only posting permissions
All checks were successful
CI / test (push) Successful in 50s
2026-03-08 19:58:10 +03:00
f03fcb2bb7 test(privacy): cover hidden avatar and last-seen in private chat list
Some checks are pending
CI / test (push) Has started running
2026-03-08 19:57:42 +03:00
84ac0c0e60 fix(websocket): force logout on revoked session close codes
Some checks failed
CI / test (push) Has been cancelled
2026-03-08 19:57:11 +03:00
65c20faecd fix(realtime): clear typing and recording indicators on disconnect
All checks were successful
CI / test (push) Successful in 46s
2026-03-08 19:55:32 +03:00
1d250f0420 test(realtime): cover recording activity event schema
All checks were successful
CI / test (push) Successful in 46s
2026-03-08 19:54:19 +03:00
ac82e25d16 feat(realtime): add voice/video recording activity events
Some checks are pending
CI / test (push) Has started running
2026-03-08 19:53:48 +03:00
1ef0cdf29d test(channel): forbid member delete with for_all
All checks were successful
CI / test (push) Successful in 42s
2026-03-08 19:45:37 +03:00
101f39771e fix(channel): member delete acts as leave; add coverage and docs
All checks were successful
CI / test (push) Successful in 42s
2026-03-08 19:44:42 +03:00
744ded914d realtime: emit and handle chat_deleted for full chat removals
All checks were successful
CI / test (push) Successful in 38s
2026-03-08 19:41:49 +03:00
a896568c53 realtime(chats): update subscriptions on delete/leave chat actions
All checks were successful
CI / test (push) Successful in 41s
2026-03-08 19:40:03 +03:00
8965dc93fd web(avatar-crop): smooth zoom via transform scale with stable cover sizing
All checks were successful
CI / test (push) Successful in 46s
2026-03-08 19:36:44 +03:00
702679c99d web(avatar-crop): fix narrow-image centering and add circular tg-like mask
All checks were successful
CI / test (push) Successful in 39s
2026-03-08 19:33:24 +03:00
958a85be91 web(settings): center no-avatar placeholder text inside circle
All checks were successful
CI / test (push) Successful in 45s
2026-03-08 19:30:41 +03:00
a1436ca27f web(composer): center glyphs inside round action buttons
All checks were successful
CI / test (push) Successful in 50s
2026-03-08 19:28:38 +03:00
67752b9f47 web(mobile): compact composer under 390px and fix stale title draft in chat info
All checks were successful
CI / test (push) Successful in 41s
2026-03-08 19:20:04 +03:00
cb59f1063e web(mobile): tighten composer controls and solid settings drawer background
All checks were successful
CI / test (push) Successful in 37s
2026-03-08 19:17:58 +03:00
fb812c9a39 auth(2fa): add one-time recovery codes with regenerate/status APIs
All checks were successful
CI / test (push) Successful in 40s
2026-03-08 19:16:15 +03:00
f91a6493ff web(mobile): fix composer layout overflow on narrow screens
All checks were successful
CI / test (push) Successful in 34s
2026-03-08 19:08:55 +03:00
d069ff1121 auth(2fa): block setup after enable to avoid secret reissue
All checks were successful
CI / test (push) Successful in 43s
2026-03-08 19:07:20 +03:00
af1ce20640 tests(privacy): cover group-invite and avatar visibility policies
All checks were successful
CI / test (push) Successful in 31s
2026-03-08 19:05:43 +03:00
1c9855b34c auth: force disconnect realtime on revoke-all sessions
All checks were successful
CI / test (push) Successful in 26s
2026-03-08 19:04:23 +03:00
7e38123d4a docs(checklist): mark forwarding module as done
All checks were successful
CI / test (push) Successful in 24s
2026-03-08 18:57:09 +03:00
8830192642 web(realtime): refresh chat info panel on chat updates
Some checks are pending
CI / test (push) Has started running
2026-03-08 18:56:54 +03:00
661f8acf63 web(group-ui): show sender avatars on incoming clusters
All checks were successful
CI / test (push) Successful in 28s
2026-03-08 18:54:55 +03:00
0db741cb8e voice: harden recorder capture with mime fallback and chunked start
All checks were successful
CI / test (push) Successful in 26s
2026-03-08 18:52:02 +03:00
4d9b64973d voice: add global playback speed control for audio and voice
All checks were successful
CI / test (push) Successful in 25s
2026-03-08 18:51:12 +03:00
f186f12bde ui: show sender names in group bubbles with stable colors
All checks were successful
CI / test (push) Successful in 26s
2026-03-08 18:49:20 +03:00
db700bcbcd moderation: add chat bans for groups/channels with web actions
All checks were successful
CI / test (push) Successful in 26s
2026-03-08 14:29:21 +03:00
76cc5e0f12 privacy/security: add PM privacy levels and improve session visibility
All checks were successful
CI / test (push) Successful in 24s
2026-03-08 14:26:19 +03:00
528778238b web: add 500x500 avatar cropper for profile and chat uploads
All checks were successful
CI / test (push) Successful in 28s
2026-03-08 14:17:19 +03:00
07e970e81f p2: add quote and code-block text formatting
All checks were successful
CI / test (push) Successful in 20s
2026-03-08 14:12:12 +03:00
33e467d2a5 p1: add forward without author option
All checks were successful
CI / test (push) Successful in 21s
2026-03-08 14:11:04 +03:00
5ae5821c20 web: fix chat context menu click handling
All checks were successful
CI / test (push) Successful in 22s
2026-03-08 14:09:24 +03:00
539ba70294 p1: prioritize mention browser notifications
All checks were successful
CI / test (push) Successful in 21s
2026-03-08 14:06:02 +03:00
f670305073 p0: hide invalid delete action for channel members
All checks were successful
CI / test (push) Successful in 27s
2026-03-08 14:05:10 +03:00
9b3b404993 p0: harden realtime reconciliation and revoke-all token invalidation
All checks were successful
CI / test (push) Successful in 23s
2026-03-08 14:04:11 +03:00
a9106b7fa3 web: add giphy provider for gif search
All checks were successful
CI / test (push) Successful in 21s
2026-03-08 13:57:03 +03:00
b6175352d0 web: disable hardcoded tenor gifs and add configured fallback
All checks were successful
CI / test (push) Successful in 21s
2026-03-08 13:55:24 +03:00
bc9d943d11 chats: add chat avatars and profile view-only modal
All checks were successful
CI / test (push) Successful in 23s
2026-03-08 13:53:29 +03:00
f7413bc626 web: add avatar file upload in profile editors
All checks were successful
CI / test (push) Successful in 28s
2026-03-08 13:45:47 +03:00
688cf0dd39 feat(web): add Tenor-backed GIF search in composer
All checks were successful
CI / test (push) Successful in 22s
2026-03-08 13:41:35 +03:00
5d69d53301 feat(threads): support nested replies in thread API and panel
All checks were successful
CI / test (push) Successful in 31s
2026-03-08 13:40:42 +03:00
88ff11c130 feat(web): add favorites for sticker and GIF pickers
All checks were successful
CI / test (push) Successful in 21s
2026-03-08 13:38:55 +03:00
c6e8b779b0 feat(threads): add basic message thread API and web thread panel
All checks were successful
CI / test (push) Successful in 21s
2026-03-08 13:37:53 +03:00
cf1a77ae76 feat(web): add notifications history block in settings
All checks were successful
CI / test (push) Successful in 23s
2026-03-08 13:33:50 +03:00
10b11b065f feat(web): add built-in sticker and GIF picker
All checks were successful
CI / test (push) Successful in 21s
2026-03-08 13:32:26 +03:00
c214cc8fd8 feat(privacy): enforce avatar/presence visibility and invite restrictions 2026-03-08 13:32:20 +03:00
eb0852e64d fix(web): keep editable text stable while typing
All checks were successful
CI / test (push) Successful in 21s
2026-03-08 13:25:28 +03:00
704781e359 feat(web): add message edit flow in context menu and composer
All checks were successful
CI / test (push) Successful in 24s
2026-03-08 13:22:57 +03:00
041f7ac171 feat(messages): limit message edit window to 7 days
All checks were successful
CI / test (push) Successful in 25s
2026-03-08 13:20:51 +03:00
a32ef745c1 docs: add current core checklist implementation status
All checks were successful
CI / test (push) Successful in 22s
2026-03-08 13:19:25 +03:00
18596e6dab fix(web): enforce channel read-only and admin delete rules 2026-03-08 13:18:52 +03:00
13b5f5b855 feat(realtime): sync message edits and deletes instantly 2026-03-08 13:17:09 +03:00
eda84d4d82 feat(web): redesign full-screen media viewer UX
All checks were successful
CI / test (push) Successful in 27s
2026-03-08 13:14:18 +03:00
10d4e0386a fix(web): refresh attachments when message list updates
All checks were successful
CI / test (push) Successful in 20s
2026-03-08 13:11:11 +03:00
072677b9ad feat(web): improve album layout and captions for multi-attachments
All checks were successful
CI / test (push) Successful in 31s
2026-03-08 13:07:53 +03:00
d2dd9aa01b feat(chat): add in-message attachments gallery and multi-file send
All checks were successful
CI / test (push) Successful in 19s
2026-03-08 13:06:00 +03:00
65d8a9379b feat(web): implement robust inline message formatting parser
All checks were successful
CI / test (push) Successful in 25s
2026-03-08 13:00:11 +03:00
58208787e7 feat(web): refresh audio card UI and enforce outside-click menu close
All checks were successful
CI / test (push) Successful in 24s
2026-03-08 12:55:55 +03:00
82322c4d42 fix(realtime,ui): sync message deletes and channel delete/leave behavior
All checks were successful
CI / test (push) Successful in 23s
2026-03-08 12:52:31 +03:00
613edbecfe fix(web): keep delivery status monotonic after reconnect
All checks were successful
CI / test (push) Successful in 20s
2026-03-08 12:48:41 +03:00
dcc0f2abbc fix(web): reconcile unread mention counters in realtime
All checks were successful
CI / test (push) Successful in 20s
2026-03-08 12:46:52 +03:00
2af4588688 feat: improve voice recording UX and realtime state reconciliation
All checks were successful
CI / test (push) Successful in 20s
2026-03-08 12:44:15 +03:00
d7160af908 ui: move audio volume control to top player bar
All checks were successful
CI / test (push) Successful in 29s
2026-03-08 12:42:07 +03:00
30169a3a27 feat: add waveform voice messages end-to-end
All checks were successful
CI / test (push) Successful in 23s
2026-03-08 12:40:49 +03:00
3b82b5e558 fix: restore light theme text and menu icon contrast
All checks were successful
CI / test (push) Successful in 24s
2026-03-08 12:30:04 +03:00
8689283e99 fix: persist message delivery status across server restarts
All checks were successful
CI / test (push) Successful in 21s
2026-03-08 12:27:54 +03:00
831047447b fix: improve light theme contrast and remove dark artifacts
All checks were successful
CI / test (push) Successful in 25s
2026-03-08 12:25:49 +03:00
0594b890c3 feat: mentions badge in chat list and muted-mention delivery
All checks were successful
CI / test (push) Successful in 21s
2026-03-08 12:23:39 +03:00
fc7a9cc3a6 test+web: fix test suite and remove redundant privacy checkbox
All checks were successful
CI / test (push) Successful in 25s
2026-03-08 12:16:21 +03:00
79baadb522 feat(auth,privacy,web): step-by-step login, privacy settings persistence, TOTP QR, and API docs
Some checks failed
CI / test (push) Failing after 22s
2026-03-08 12:09:53 +03:00
1546ae7381 web: move contacts to dedicated burger-menu screen
Some checks failed
CI / test (push) Failing after 24s
2026-03-08 11:57:01 +03:00
f6fecf57c7 fix(web): move Contacts access to burger menu only
Some checks failed
CI / test (push) Failing after 25s
- remove Contacts from top chat tabs

- keep contacts screen reachable via burger menu
2026-03-08 11:52:11 +03:00
cbd1b008bb feat(contacts): switch contacts UX to email-first flow
Some checks failed
CI / test (push) Failing after 18s
- expose email in user search/contact responses

- add add-contact-by-email API endpoint

- show email in contacts/search and add contact form by email in Contacts tab
2026-03-08 11:51:02 +03:00
897defc39d fix(audio,sessions): unify audio playback state and improve session discovery
Some checks failed
CI / test (push) Failing after 18s
- move voice/audio players to single global audio engine with shared volume

- stop/reset previous track when switching to another media

- keep playback alive across chat switches via global audio element

- list refresh sessions by redis scan fallback when user session set is missing
2026-03-08 11:48:13 +03:00
27d3340a37 feat(auth): add TOTP 2FA setup and login verification
Some checks failed
CI / test (push) Failing after 21s
- add user twofa fields and migration

- add 2FA setup/enable/disable endpoints

- enforce OTP on login when 2FA enabled

- add web login OTP field and settings UI
2026-03-08 11:43:51 +03:00
e685a38be6 feat(auth): add active sessions management
Some checks failed
CI / test (push) Failing after 33s
- store refresh session metadata in redis (ip/user-agent/created_at)

- add auth APIs: list sessions, revoke one, revoke all

- add web privacy UI for active sessions
2026-03-08 11:41:03 +03:00
da73b79ee7 feat(contacts): add contacts module with backend APIs and web tab
Some checks failed
CI / test (push) Failing after 18s
- add user_contacts table and migration

- expose /users/contacts list/add/remove endpoints

- add Contacts tab in chat list with add/remove actions
2026-03-08 11:38:11 +03:00
39b61ec94b feat(web): split chat list into pinned and regular sections
Some checks failed
CI / test (push) Failing after 19s
- render explicit Pinned and Chats blocks

- keep pin context actions unchanged
2026-03-08 11:35:31 +03:00
8fcfd60ff5 feat(web): improve archive chats UX
Some checks failed
CI / test (push) Failing after 18s
- keep archived list synced while browsing chats

- add archived entry in All tab with unread count-like badge

- show loading/empty states in Archived tab
2026-03-08 11:34:45 +03:00
4fe89ce89a feat(web): service-worker notifications and composer/scroll UX fixes
Some checks failed
CI / test (push) Failing after 21s
- register notifications service worker and handle click-to-open chat/message

- route realtime notifications through service worker with fallback

- support ?chat=&message= deep-link navigation in chats page

- enforce 1s minimum voice message length

- lift scroll-to-bottom button to avoid overlap with composer action
2026-03-08 11:33:58 +03:00
68ba97bb90 fix(web): unify mic/send button and restore scroll-down
Some checks failed
CI / test (push) Failing after 20s
- show one action button in composer: mic when empty, send when text exists

- add floating scroll-to-bottom button in message list

- exclude non-text/media messages from Chat Info links list to avoid duplicates
2026-03-08 11:27:16 +03:00
14610b5699 feat(web): inline chat search and global audio bar
Some checks failed
CI / test (push) Failing after 20s
- replace modal message search with header inline search controls

- add global top audio bar linked to active inline audio player

- improve chat info header variants and light theme readability
2026-03-08 11:21:57 +03:00
03bf197949 fix(web): stabilize realtime chat list synchronization
Some checks failed
CI / test (push) Failing after 17s
- throttle chat list reload on realtime events

- refresh chat previews after incoming messages

- keep active chat messages in sync on chat_updated
2026-03-08 11:10:55 +03:00
c58678ee09 feat(web): refine media gallery UX in chat info
Some checks failed
CI / test (push) Failing after 18s
- add per-tab counters and sticky media tabs

- normalize media ordering by newest first

- improve links tab readability with short host/path preview
2026-03-08 11:10:25 +03:00
48f521e551 feat(web): upgrade settings UX to telegram-like structure
Some checks failed
CI / test (push) Failing after 18s
- redesign settings main/general/notifications/privacy screens

- add status subtitles and structured rows

- improve privacy and notifications sections
2026-03-08 11:09:36 +03:00
0e44988634 fix(web): notification media preview and theme switching
Some checks failed
CI / test (push) Failing after 19s
- show media labels instead of raw URLs in browser notifications

- support notification icon preview for image messages

- implement effective light/dark/system theme application

- apply appearance prefs on app startup
2026-03-08 11:07:30 +03:00
663df37d92 fix(web): media preview labels and burger menu interactions
Some checks failed
CI / test (push) Failing after 19s
- show emoji+media type in chat list preview

- prevent burger menu from closing immediately via event propagation
2026-03-08 11:03:16 +03:00
99e7c70901 feat: realtime sync, settings UX and chat list improvements
Some checks failed
CI / test (push) Failing after 21s
- add chat_updated realtime event and dynamic chat subscriptions

- auto-join invite links in web app

- implement Telegram-like settings panel (general/notifications/privacy)

- add browser notification preferences and keyboard send mode

- improve chat list with last message preview/time and online badge

- rework chat members UI with context actions and role crowns
2026-03-08 10:59:44 +03:00
a4fa72df30 fix(web): always show media actions in context menu for media messages
Some checks failed
CI / test (push) Failing after 20s
2026-03-08 10:40:57 +03:00
72c3b10ba5 feat(web): fullscreen media preview/viewer and fix media context menu
Some checks failed
CI / test (push) Failing after 26s
2026-03-08 10:38:46 +03:00
a77516cfea feat(web): sprint1 ui core with global toasts and improved chat layout
Some checks failed
CI / test (push) Failing after 19s
2026-03-08 10:35:21 +03:00
1119cc65b8 fix(web): chat sidebar layout, media context actions, and scrollable chat info
Some checks failed
CI / test (push) Failing after 21s
2026-03-08 10:30:38 +03:00
6a96a99775 feat(web): improve message UX, voice gestures, and attachments
Some checks failed
CI / test (push) Failing after 21s
2026-03-08 10:20:52 +03:00
52c41b6958 feat(web): send on Enter and newline on Shift+Enter
Some checks failed
CI / test (push) Failing after 25s
2026-03-08 09:59:29 +03:00
f01bbda14e feat(invites): add group/channel invite links and join by token 2026-03-08 09:58:55 +03:00
cc70394960 feat(web): add safe rich text formatting for message rendering 2026-03-08 09:56:37 +03:00
7c4a5f990d feat(messages): support forwarding to multiple chats 2026-03-08 09:55:39 +03:00
8cdcd9531d feat(chats): add per-user pinned chats and pinned sorting 2026-03-08 09:54:43 +03:00
fdf973eeab feat(chats): add per-user chat archive support 2026-03-08 09:53:28 +03:00
76f008d635 feat(reactions): add message reactions API and web quick reactions 2026-03-08 09:51:18 +03:00
6adb8c24d7 fix(migrations): shorten 0012 revision id for alembic_version varchar(32)
Some checks failed
CI / test (push) Failing after 20s
2026-03-08 09:44:16 +03:00
310 changed files with 41227 additions and 803 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

@@ -1,6 +1,6 @@
"""add allow_private_messages setting
Revision ID: 0012_user_private_message_privacy
Revision ID: 0012_user_pm_privacy
Revises: 0011_chat_public_id
Create Date: 2026-03-08 16:00:00.000000
"""
@@ -11,7 +11,7 @@ from alembic import op
import sqlalchemy as sa
revision: str = "0012_user_private_message_privacy"
revision: str = "0012_user_pm_privacy"
down_revision: Union[str, Sequence[str], None] = "0011_chat_public_id"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None

View File

@@ -0,0 +1,45 @@
"""add message reactions
Revision ID: 0013_msg_reactions
Revises: 0012_user_pm_privacy
Create Date: 2026-03-08 18:40:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = "0013_msg_reactions"
down_revision: Union[str, Sequence[str], None] = "0012_user_pm_privacy"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
"message_reactions",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("message_id", sa.Integer(), nullable=False),
sa.Column("user_id", sa.Integer(), nullable=False),
sa.Column("emoji", sa.String(length=16), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.ForeignKeyConstraint(["message_id"], ["messages.id"], name=op.f("fk_message_reactions_message_id_messages"), ondelete="CASCADE"),
sa.ForeignKeyConstraint(["user_id"], ["users.id"], name=op.f("fk_message_reactions_user_id_users"), ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id", name=op.f("pk_message_reactions")),
sa.UniqueConstraint("message_id", "user_id", name="uq_message_reactions_message_user"),
)
op.create_index(op.f("ix_message_reactions_id"), "message_reactions", ["id"], unique=False)
op.create_index(op.f("ix_message_reactions_message_id"), "message_reactions", ["message_id"], unique=False)
op.create_index(op.f("ix_message_reactions_user_id"), "message_reactions", ["user_id"], unique=False)
op.create_index(op.f("ix_message_reactions_emoji"), "message_reactions", ["emoji"], unique=False)
def downgrade() -> None:
op.drop_index(op.f("ix_message_reactions_emoji"), table_name="message_reactions")
op.drop_index(op.f("ix_message_reactions_user_id"), table_name="message_reactions")
op.drop_index(op.f("ix_message_reactions_message_id"), table_name="message_reactions")
op.drop_index(op.f("ix_message_reactions_id"), table_name="message_reactions")
op.drop_table("message_reactions")

View File

@@ -0,0 +1,43 @@
"""add chat user settings for archive
Revision ID: 0014_chat_user_set
Revises: 0013_msg_reactions
Create Date: 2026-03-08 19:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = "0014_chat_user_set"
down_revision: Union[str, Sequence[str], None] = "0013_msg_reactions"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
"chat_user_settings",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("chat_id", sa.Integer(), nullable=False),
sa.Column("user_id", sa.Integer(), nullable=False),
sa.Column("archived", sa.Boolean(), nullable=False, server_default=sa.text("false")),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.ForeignKeyConstraint(["chat_id"], ["chats.id"], name=op.f("fk_chat_user_settings_chat_id_chats"), ondelete="CASCADE"),
sa.ForeignKeyConstraint(["user_id"], ["users.id"], name=op.f("fk_chat_user_settings_user_id_users"), ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id", name=op.f("pk_chat_user_settings")),
sa.UniqueConstraint("chat_id", "user_id", name="uq_chat_user_settings_chat_user"),
)
op.create_index(op.f("ix_chat_user_settings_id"), "chat_user_settings", ["id"], unique=False)
op.create_index(op.f("ix_chat_user_settings_chat_id"), "chat_user_settings", ["chat_id"], unique=False)
op.create_index(op.f("ix_chat_user_settings_user_id"), "chat_user_settings", ["user_id"], unique=False)
def downgrade() -> None:
op.drop_index(op.f("ix_chat_user_settings_user_id"), table_name="chat_user_settings")
op.drop_index(op.f("ix_chat_user_settings_chat_id"), table_name="chat_user_settings")
op.drop_index(op.f("ix_chat_user_settings_id"), table_name="chat_user_settings")
op.drop_table("chat_user_settings")

View File

@@ -0,0 +1,28 @@
"""add chat pin fields to chat user settings
Revision ID: 0015_chat_pin_set
Revises: 0014_chat_user_set
Create Date: 2026-03-08 19:20:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = "0015_chat_pin_set"
down_revision: Union[str, Sequence[str], None] = "0014_chat_user_set"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column("chat_user_settings", sa.Column("pinned", sa.Boolean(), nullable=False, server_default=sa.text("false")))
op.add_column("chat_user_settings", sa.Column("pinned_at", sa.DateTime(timezone=True), nullable=True))
def downgrade() -> None:
op.drop_column("chat_user_settings", "pinned_at")
op.drop_column("chat_user_settings", "pinned")

View File

@@ -0,0 +1,46 @@
"""add chat invite links
Revision ID: 0016_chat_invites
Revises: 0015_chat_pin_set
Create Date: 2026-03-08 19:45:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = "0016_chat_invites"
down_revision: Union[str, Sequence[str], None] = "0015_chat_pin_set"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
"chat_invite_links",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("chat_id", sa.Integer(), nullable=False),
sa.Column("creator_user_id", sa.Integer(), nullable=False),
sa.Column("token", sa.String(length=64), nullable=False),
sa.Column("is_active", sa.Boolean(), nullable=False, server_default=sa.text("true")),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.ForeignKeyConstraint(["chat_id"], ["chats.id"], name=op.f("fk_chat_invite_links_chat_id_chats"), ondelete="CASCADE"),
sa.ForeignKeyConstraint(["creator_user_id"], ["users.id"], name=op.f("fk_chat_invite_links_creator_user_id_users"), ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id", name=op.f("pk_chat_invite_links")),
sa.UniqueConstraint("token", name="uq_chat_invite_links_token"),
)
op.create_index(op.f("ix_chat_invite_links_id"), "chat_invite_links", ["id"], unique=False)
op.create_index(op.f("ix_chat_invite_links_chat_id"), "chat_invite_links", ["chat_id"], unique=False)
op.create_index(op.f("ix_chat_invite_links_creator_user_id"), "chat_invite_links", ["creator_user_id"], unique=False)
op.create_index(op.f("ix_chat_invite_links_token"), "chat_invite_links", ["token"], unique=False)
def downgrade() -> None:
op.drop_index(op.f("ix_chat_invite_links_token"), table_name="chat_invite_links")
op.drop_index(op.f("ix_chat_invite_links_creator_user_id"), table_name="chat_invite_links")
op.drop_index(op.f("ix_chat_invite_links_chat_id"), table_name="chat_invite_links")
op.drop_index(op.f("ix_chat_invite_links_id"), table_name="chat_invite_links")
op.drop_table("chat_invite_links")

View File

@@ -0,0 +1,42 @@
"""add user contacts table
Revision ID: 0017_user_contacts
Revises: 0016_chat_invites
Create Date: 2026-03-08 23:10:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = "0017_user_contacts"
down_revision: Union[str, Sequence[str], None] = "0016_chat_invites"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
"user_contacts",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("user_id", sa.Integer(), nullable=False),
sa.Column("contact_user_id", sa.Integer(), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.ForeignKeyConstraint(["user_id"], ["users.id"], name=op.f("fk_user_contacts_user_id_users"), ondelete="CASCADE"),
sa.ForeignKeyConstraint(["contact_user_id"], ["users.id"], name=op.f("fk_user_contacts_contact_user_id_users"), ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id", name=op.f("pk_user_contacts")),
sa.UniqueConstraint("user_id", "contact_user_id", name="uq_user_contacts_pair"),
)
op.create_index(op.f("ix_user_contacts_id"), "user_contacts", ["id"], unique=False)
op.create_index(op.f("ix_user_contacts_user_id"), "user_contacts", ["user_id"], unique=False)
op.create_index(op.f("ix_user_contacts_contact_user_id"), "user_contacts", ["contact_user_id"], unique=False)
def downgrade() -> None:
op.drop_index(op.f("ix_user_contacts_contact_user_id"), table_name="user_contacts")
op.drop_index(op.f("ix_user_contacts_user_id"), table_name="user_contacts")
op.drop_index(op.f("ix_user_contacts_id"), table_name="user_contacts")
op.drop_table("user_contacts")

View File

@@ -0,0 +1,28 @@
"""add user twofa fields
Revision ID: 0018_user_twofa
Revises: 0017_user_contacts
Create Date: 2026-03-08 23:35:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = "0018_user_twofa"
down_revision: Union[str, Sequence[str], None] = "0017_user_contacts"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column("users", sa.Column("twofa_enabled", sa.Boolean(), nullable=False, server_default=sa.text("false")))
op.add_column("users", sa.Column("twofa_secret", sa.String(length=64), nullable=True))
def downgrade() -> None:
op.drop_column("users", "twofa_secret")
op.drop_column("users", "twofa_enabled")

View File

@@ -0,0 +1,39 @@
"""add user privacy fields
Revision ID: 0019_user_privacy_fields
Revises: 0018_user_twofa
Create Date: 2026-03-08 23:59:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = "0019_user_privacy_fields"
down_revision: Union[str, Sequence[str], None] = "0018_user_twofa"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column(
"users",
sa.Column("privacy_last_seen", sa.String(length=16), nullable=False, server_default=sa.text("'everyone'")),
)
op.add_column(
"users",
sa.Column("privacy_avatar", sa.String(length=16), nullable=False, server_default=sa.text("'everyone'")),
)
op.add_column(
"users",
sa.Column("privacy_group_invites", sa.String(length=16), nullable=False, server_default=sa.text("'everyone'")),
)
def downgrade() -> None:
op.drop_column("users", "privacy_group_invites")
op.drop_column("users", "privacy_avatar")
op.drop_column("users", "privacy_last_seen")

View File

@@ -0,0 +1,27 @@
"""add waveform data to attachments
Revision ID: 0020_attachment_waveform_data
Revises: 0019_user_privacy_fields
Create Date: 2026-03-08
"""
from collections.abc import Sequence
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = "0020_attachment_waveform_data"
down_revision: str | None = "0019_user_privacy_fields"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.add_column("attachments", sa.Column("waveform_data", sa.Text(), nullable=True))
def downgrade() -> None:
op.drop_column("attachments", "waveform_data")

View File

@@ -0,0 +1,26 @@
"""add avatar url for chats
Revision ID: 0021_chat_avatar_url
Revises: 0020_attachment_waveform_data
Create Date: 2026-03-08
"""
from collections.abc import Sequence
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = "0021_chat_avatar_url"
down_revision: str | None = "0020_attachment_waveform_data"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.add_column("chats", sa.Column("avatar_url", sa.String(length=512), nullable=True))
def downgrade() -> None:
op.drop_column("chats", "avatar_url")

View File

@@ -0,0 +1,26 @@
"""add access token revoke marker for users
Revision ID: 0022_user_access_revoked_before
Revises: 0021_chat_avatar_url
Create Date: 2026-03-08
"""
from collections.abc import Sequence
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = "0022_user_access_revoked_before"
down_revision: str | None = "0021_chat_avatar_url"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.add_column("users", sa.Column("access_revoked_before", sa.DateTime(timezone=True), nullable=True))
def downgrade() -> None:
op.drop_column("users", "access_revoked_before")

View File

@@ -0,0 +1,38 @@
"""add privacy private messages level
Revision ID: 0023_privacy_pm_level
Revises: 0022_user_access_revoked_before
Create Date: 2026-03-09 00:40:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = "0023_privacy_pm_level"
down_revision: Union[str, Sequence[str], None] = "0022_user_access_revoked_before"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column(
"users",
sa.Column("privacy_private_messages", sa.String(length=16), nullable=False, server_default=sa.text("'everyone'")),
)
op.execute(
sa.text(
"UPDATE users "
"SET privacy_private_messages = CASE "
"WHEN allow_private_messages IS TRUE THEN 'everyone' "
"ELSE 'nobody' "
"END"
)
)
def downgrade() -> None:
op.drop_column("users", "privacy_private_messages")

View File

@@ -0,0 +1,46 @@
"""add chat bans for moderation
Revision ID: 0024_chat_bans
Revises: 0023_privacy_pm_level
Create Date: 2026-03-09 01:10:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = "0024_chat_bans"
down_revision: Union[str, Sequence[str], None] = "0023_privacy_pm_level"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
"chat_bans",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("chat_id", sa.Integer(), nullable=False),
sa.Column("user_id", sa.Integer(), nullable=False),
sa.Column("banned_by_user_id", sa.Integer(), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.ForeignKeyConstraint(["chat_id"], ["chats.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["banned_by_user_id"], ["users.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("chat_id", "user_id", name="uq_chat_bans_chat_user"),
)
op.create_index("ix_chat_bans_id", "chat_bans", ["id"], unique=False)
op.create_index("ix_chat_bans_chat_id", "chat_bans", ["chat_id"], unique=False)
op.create_index("ix_chat_bans_user_id", "chat_bans", ["user_id"], unique=False)
op.create_index("ix_chat_bans_banned_by_user_id", "chat_bans", ["banned_by_user_id"], unique=False)
def downgrade() -> None:
op.drop_index("ix_chat_bans_banned_by_user_id", table_name="chat_bans")
op.drop_index("ix_chat_bans_user_id", table_name="chat_bans")
op.drop_index("ix_chat_bans_chat_id", table_name="chat_bans")
op.drop_index("ix_chat_bans_id", table_name="chat_bans")
op.drop_table("chat_bans")

View File

@@ -0,0 +1,26 @@
"""add recovery codes storage for 2fa
Revision ID: 0025_user_twofa_recovery_codes
Revises: 0024_chat_bans
Create Date: 2026-03-09 16:55:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = "0025_user_twofa_recovery_codes"
down_revision: Union[str, Sequence[str], None] = "0024_chat_bans"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column("users", sa.Column("twofa_recovery_codes_hashes", sa.String(length=4096), nullable=True))
def downgrade() -> None:
op.drop_column("users", "twofa_recovery_codes_hashes")

View File

@@ -0,0 +1,216 @@
"""deduplicate saved chats per user
Revision ID: 0026_deduplicate_saved_chats
Revises: 0025_user_twofa_recovery_codes
Create Date: 2026-03-10 00:25:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = "0026_deduplicate_saved_chats"
down_revision: Union[str, Sequence[str], None] = "0025_user_twofa_recovery_codes"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
bind = op.get_bind()
duplicate_user_ids = [
int(row[0])
for row in bind.execute(
sa.text(
"""
SELECT cm.user_id
FROM chat_members cm
JOIN chats c ON c.id = cm.chat_id
WHERE c.is_saved IS TRUE
GROUP BY cm.user_id
HAVING COUNT(*) > 1
"""
)
).fetchall()
]
for user_id in duplicate_user_ids:
saved_chat_ids = [
int(row[0])
for row in bind.execute(
sa.text(
"""
SELECT c.id
FROM chats c
JOIN chat_members cm ON cm.chat_id = c.id
WHERE c.is_saved IS TRUE
AND cm.user_id = :user_id
ORDER BY c.id ASC
"""
),
{"user_id": user_id},
).fetchall()
]
if len(saved_chat_ids) <= 1:
continue
keep_chat_id = saved_chat_ids[0]
duplicate_chat_ids = saved_chat_ids[1:]
for duplicate_chat_id in duplicate_chat_ids:
bind.execute(
sa.text(
"""
UPDATE chats keep
SET pinned_message_id = COALESCE(
keep.pinned_message_id,
(SELECT pinned_message_id FROM chats WHERE id = :dup_chat_id)
)
WHERE keep.id = :keep_chat_id
"""
),
{"keep_chat_id": keep_chat_id, "dup_chat_id": duplicate_chat_id},
)
bind.execute(
sa.text(
"""
UPDATE messages
SET chat_id = :keep_chat_id
WHERE chat_id = :dup_chat_id
"""
),
{"keep_chat_id": keep_chat_id, "dup_chat_id": duplicate_chat_id},
)
bind.execute(
sa.text(
"""
INSERT INTO message_receipts (chat_id, user_id, last_delivered_message_id, last_read_message_id, updated_at)
SELECT :keep_chat_id, mr.user_id, mr.last_delivered_message_id, mr.last_read_message_id, mr.updated_at
FROM message_receipts mr
WHERE mr.chat_id = :dup_chat_id
ON CONFLICT (chat_id, user_id) DO UPDATE
SET last_delivered_message_id = GREATEST(
COALESCE(message_receipts.last_delivered_message_id, 0),
COALESCE(EXCLUDED.last_delivered_message_id, 0)
),
last_read_message_id = GREATEST(
COALESCE(message_receipts.last_read_message_id, 0),
COALESCE(EXCLUDED.last_read_message_id, 0)
),
updated_at = GREATEST(message_receipts.updated_at, EXCLUDED.updated_at)
"""
),
{"keep_chat_id": keep_chat_id, "dup_chat_id": duplicate_chat_id},
)
bind.execute(
sa.text("DELETE FROM message_receipts WHERE chat_id = :dup_chat_id"),
{"dup_chat_id": duplicate_chat_id},
)
bind.execute(
sa.text(
"""
INSERT INTO chat_notification_settings (chat_id, user_id, muted, updated_at)
SELECT :keep_chat_id, cns.user_id, cns.muted, cns.updated_at
FROM chat_notification_settings cns
WHERE cns.chat_id = :dup_chat_id
ON CONFLICT (chat_id, user_id) DO UPDATE
SET muted = chat_notification_settings.muted OR EXCLUDED.muted,
updated_at = GREATEST(chat_notification_settings.updated_at, EXCLUDED.updated_at)
"""
),
{"keep_chat_id": keep_chat_id, "dup_chat_id": duplicate_chat_id},
)
bind.execute(
sa.text("DELETE FROM chat_notification_settings WHERE chat_id = :dup_chat_id"),
{"dup_chat_id": duplicate_chat_id},
)
bind.execute(
sa.text(
"""
INSERT INTO chat_user_settings (chat_id, user_id, archived, pinned, pinned_at, updated_at)
SELECT :keep_chat_id, cus.user_id, cus.archived, cus.pinned, cus.pinned_at, cus.updated_at
FROM chat_user_settings cus
WHERE cus.chat_id = :dup_chat_id
ON CONFLICT (chat_id, user_id) DO UPDATE
SET archived = chat_user_settings.archived OR EXCLUDED.archived,
pinned = chat_user_settings.pinned OR EXCLUDED.pinned,
pinned_at = CASE
WHEN chat_user_settings.pinned_at IS NULL THEN EXCLUDED.pinned_at
WHEN EXCLUDED.pinned_at IS NULL THEN chat_user_settings.pinned_at
ELSE GREATEST(chat_user_settings.pinned_at, EXCLUDED.pinned_at)
END,
updated_at = GREATEST(chat_user_settings.updated_at, EXCLUDED.updated_at)
"""
),
{"keep_chat_id": keep_chat_id, "dup_chat_id": duplicate_chat_id},
)
bind.execute(
sa.text("DELETE FROM chat_user_settings WHERE chat_id = :dup_chat_id"),
{"dup_chat_id": duplicate_chat_id},
)
bind.execute(
sa.text(
"""
INSERT INTO message_idempotency_keys (chat_id, sender_id, client_message_id, message_id, created_at)
SELECT :keep_chat_id, mik.sender_id, mik.client_message_id, mik.message_id, mik.created_at
FROM message_idempotency_keys mik
WHERE mik.chat_id = :dup_chat_id
ON CONFLICT (chat_id, sender_id, client_message_id) DO NOTHING
"""
),
{"keep_chat_id": keep_chat_id, "dup_chat_id": duplicate_chat_id},
)
bind.execute(
sa.text("DELETE FROM message_idempotency_keys WHERE chat_id = :dup_chat_id"),
{"dup_chat_id": duplicate_chat_id},
)
bind.execute(
sa.text(
"""
INSERT INTO chat_members (chat_id, user_id, role, joined_at)
SELECT :keep_chat_id, cm.user_id, cm.role, cm.joined_at
FROM chat_members cm
WHERE cm.chat_id = :dup_chat_id
ON CONFLICT (chat_id, user_id) DO UPDATE
SET role = CASE
WHEN chat_members.role = 'OWNER' OR EXCLUDED.role = 'OWNER' THEN 'OWNER'::chatmemberrole
WHEN chat_members.role = 'ADMIN' OR EXCLUDED.role = 'ADMIN' THEN 'ADMIN'::chatmemberrole
ELSE 'MEMBER'::chatmemberrole
END,
joined_at = LEAST(chat_members.joined_at, EXCLUDED.joined_at)
"""
),
{"keep_chat_id": keep_chat_id, "dup_chat_id": duplicate_chat_id},
)
bind.execute(
sa.text("DELETE FROM chat_members WHERE chat_id = :dup_chat_id"),
{"dup_chat_id": duplicate_chat_id},
)
bind.execute(
sa.text("DELETE FROM chat_bans WHERE chat_id = :dup_chat_id"),
{"dup_chat_id": duplicate_chat_id},
)
bind.execute(
sa.text("DELETE FROM chat_invite_links WHERE chat_id = :dup_chat_id"),
{"dup_chat_id": duplicate_chat_id},
)
bind.execute(
sa.text("DELETE FROM chats WHERE id = :dup_chat_id"),
{"dup_chat_id": duplicate_chat_id},
)
def downgrade() -> None:
# data-cleanup migration; no reversible schema changes
pass

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")

1025
android/CHANGELOG.md Normal file

File diff suppressed because it is too large Load Diff

13
android/README.md Normal file
View File

@@ -0,0 +1,13 @@
# Android App (Phase 0)
Минимальный каркас Android-клиента на Kotlin + Jetpack Compose.
## Что уже есть
- Gradle multi-module root (`:app`)
- Compose `MainActivity`
- Базовые зависимости для дальнейшей реализации
## Следующий шаг
1. Добавить network layer (Retrofit/OkHttp + auth interceptor).
2. Внедрить DI и feature-модули.
3. Поднять auth flow (email-first) и chat list.

View File

@@ -0,0 +1,188 @@
import java.util.Properties
plugins {
id("com.android.application")
id("org.jetbrains.kotlin.android")
id("org.jetbrains.kotlin.plugin.compose")
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
defaultConfig {
applicationId = "ru.daemonlord.messenger"
minSdk = 26
targetSdk = 35
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 {
useSupportLibrary = true
}
}
buildTypes {
release {
isMinifyEnabled = false
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = "17"
}
buildFeatures {
compose = true
buildConfig = true
}
composeOptions {
kotlinCompilerExtensionVersion = "1.5.15"
}
packaging {
resources {
excludes += "/META-INF/{AL2.0,LGPL2.1}"
}
}
}
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")
implementation("androidx.activity:activity-compose:1.10.1")
implementation("androidx.navigation:navigation-compose:2.8.5")
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")
implementation("com.squareup.retrofit2:retrofit:2.11.0")
implementation("com.jakewharton.retrofit:retrofit2-kotlinx-serialization-converter:1.0.0")
implementation("com.squareup.okhttp3:okhttp:4.12.0")
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")
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")
testImplementation("androidx.datastore:datastore-preferences-core:1.1.1")
testImplementation("androidx.room:room-testing:2.6.1")
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")
}
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")

1
android/app/proguard-rules.pro vendored Normal file
View File

@@ -0,0 +1 @@
# App-specific ProGuard rules.

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

@@ -0,0 +1,62 @@
<?xml version="1.0" encoding="utf-8"?>
<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="@style/Theme.Messenger">
<activity
android:name=".MainActivity"
android:exported="true">
<intent-filter>
<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

@@ -0,0 +1,171 @@
package ru.daemonlord.messenger
import android.content.Intent
import android.os.Bundle
import androidx.activity.enableEdgeToEdge
import androidx.activity.compose.setContent
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 : 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 {
MessengerTheme {
Surface(modifier = Modifier.fillMaxSize()) {
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(
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

@@ -0,0 +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(), 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,37 @@
package ru.daemonlord.messenger.core.network
import kotlinx.coroutines.runBlocking
import okhttp3.Interceptor
import okhttp3.Response
import ru.daemonlord.messenger.core.token.TokenRepository
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class AuthHeaderInterceptor @Inject constructor(
private val tokenRepository: TokenRepository,
) : Interceptor {
override fun intercept(chain: Interceptor.Chain): Response {
val originalRequest = chain.request()
val noAuthHeader = originalRequest.header(NO_AUTH_HEADER)
if (noAuthHeader == "true") {
val requestWithoutMarker = originalRequest.newBuilder()
.removeHeader(NO_AUTH_HEADER)
.build()
return chain.proceed(requestWithoutMarker)
}
val accessToken = runBlocking { tokenRepository.getTokens()?.accessToken }
val requestBuilder = originalRequest.newBuilder()
if (!accessToken.isNullOrBlank()) {
requestBuilder.header("Authorization", "Bearer $accessToken")
}
return chain.proceed(requestBuilder.build())
}
private companion object {
const val NO_AUTH_HEADER = "No-Auth"
}
}

View File

@@ -0,0 +1,81 @@
package ru.daemonlord.messenger.core.network
import kotlinx.coroutines.runBlocking
import okhttp3.Authenticator
import okhttp3.Request
import okhttp3.Response
import okhttp3.Route
import ru.daemonlord.messenger.core.token.TokenBundle
import ru.daemonlord.messenger.core.token.TokenRepository
import ru.daemonlord.messenger.data.auth.api.AuthApiService
import ru.daemonlord.messenger.data.auth.dto.RefreshTokenRequestDto
import ru.daemonlord.messenger.di.RefreshAuthApi
import java.io.IOException
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class TokenRefreshAuthenticator @Inject constructor(
private val tokenRepository: TokenRepository,
@RefreshAuthApi
private val refreshAuthApiService: AuthApiService,
) : Authenticator {
override fun authenticate(route: Route?, response: Response): Request? {
if (responseCount(response) >= MAX_RETRIES) {
return null
}
val marker = response.request.header(NO_AUTH_HEADER)
if (marker == "true") {
return null
}
val refreshedAccessToken = synchronized(this) {
runBlocking {
val currentTokens = tokenRepository.getTokens() ?: return@runBlocking null
tryRefreshTokens(currentTokens)
}
} ?: return null
return response.request.newBuilder()
.header("Authorization", "Bearer $refreshedAccessToken")
.build()
}
private suspend fun tryRefreshTokens(tokens: TokenBundle): String? {
return try {
val refreshed = refreshAuthApiService.refresh(
request = RefreshTokenRequestDto(refreshToken = tokens.refreshToken)
)
tokenRepository.saveTokens(
TokenBundle(
accessToken = refreshed.accessToken,
refreshToken = refreshed.refreshToken,
savedAtMillis = System.currentTimeMillis(),
)
)
refreshed.accessToken
} catch (_: IOException) {
null
} catch (_: Exception) {
tokenRepository.clearTokens()
null
}
}
private fun responseCount(response: Response): Int {
var current: Response? = response
var count = 1
while (current?.priorResponse != null) {
count++
current = current.priorResponse
}
return count
}
private companion object {
const val MAX_RETRIES = 2
const val NO_AUTH_HEADER = "No-Auth"
}
}

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

@@ -0,0 +1,138 @@
package ru.daemonlord.messenger.core.token
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.longPreferencesKey
import androidx.datastore.preferences.core.stringPreferencesKey
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class DataStoreTokenRepository @Inject constructor(
private val dataStore: DataStore<Preferences>,
) : TokenRepository {
override fun observeTokens(): Flow<TokenBundle?> = dataStore.data.map { preferences ->
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
preferences[REFRESH_TOKEN_KEY] = tokens.refreshToken
preferences[SAVED_AT_KEY] = tokens.savedAtMillis
}
}
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)
preferences.remove(REFRESH_TOKEN_KEY)
preferences.remove(SAVED_AT_KEY)
}
}
override suspend fun clearAllTokens() {
clearTokens()
}
private fun Preferences.toTokenBundleOrNull(): TokenBundle? {
val access = this[ACCESS_TOKEN_KEY]
val refresh = this[REFRESH_TOKEN_KEY]
val savedAt = this[SAVED_AT_KEY]
if (access.isNullOrBlank() || refresh.isNullOrBlank() || savedAt == null) {
return null
}
return TokenBundle(
accessToken = access,
refreshToken = refresh,
savedAtMillis = savedAt,
)
}
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")
val SAVED_AT_KEY = longPreferencesKey("tokens_saved_at")
}
}

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

@@ -0,0 +1,7 @@
package ru.daemonlord.messenger.core.token
data class TokenBundle(
val accessToken: String,
val refreshToken: String,
val savedAtMillis: Long,
)

View File

@@ -0,0 +1,18 @@
package ru.daemonlord.messenger.core.token
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

@@ -0,0 +1,86 @@
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
@Headers("No-Auth: true")
@POST("/api/v1/auth/refresh")
suspend fun refresh(@Body request: RefreshTokenRequestDto): TokenResponseDto
@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

@@ -0,0 +1,139 @@
package ru.daemonlord.messenger.data.auth.dto
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@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
data class RefreshTokenRequestDto(
@SerialName("refresh_token")
val refreshToken: String,
)
@Serializable
data class TokenResponseDto(
@SerialName("access_token")
val accessToken: String,
@SerialName("refresh_token")
val refreshToken: String,
@SerialName("token_type")
val tokenType: String,
)
@Serializable
data class AuthUserDto(
val id: Long,
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

@@ -0,0 +1,242 @@
package ru.daemonlord.messenger.data.auth.repository
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.withContext
import ru.daemonlord.messenger.core.token.TokenBundle
import ru.daemonlord.messenger.core.token.StoredAccount
import ru.daemonlord.messenger.core.token.TokenRepository
import ru.daemonlord.messenger.data.auth.api.AuthApiService
import ru.daemonlord.messenger.data.auth.dto.AuthUserDto
import ru.daemonlord.messenger.data.auth.dto.CheckEmailStatusDto
import ru.daemonlord.messenger.data.auth.dto.AuthSessionDto
import ru.daemonlord.messenger.data.auth.dto.LoginRequestDto
import ru.daemonlord.messenger.data.auth.dto.RefreshTokenRequestDto
import ru.daemonlord.messenger.data.auth.dto.RegisterRequestDto
import ru.daemonlord.messenger.data.common.ApiErrorMode
import ru.daemonlord.messenger.data.common.toAppError
import ru.daemonlord.messenger.di.IoDispatcher
import ru.daemonlord.messenger.domain.auth.model.AuthEmailStatus
import ru.daemonlord.messenger.domain.auth.model.AuthUser
import ru.daemonlord.messenger.domain.auth.model.AuthSession
import ru.daemonlord.messenger.domain.auth.repository.AuthRepository
import ru.daemonlord.messenger.domain.common.AppError
import ru.daemonlord.messenger.domain.common.AppResult
import ru.daemonlord.messenger.push.PushTokenSyncManager
import javax.inject.Inject
import javax.inject.Singleton
@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 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(
TokenBundle(
accessToken = tokenResponse.accessToken,
refreshToken = tokenResponse.refreshToken,
savedAtMillis = System.currentTimeMillis(),
)
)
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(mode = ApiErrorMode.LOGIN))
}
}
override suspend fun refreshTokens(): AppResult<Unit> = withContext(ioDispatcher) {
val tokens = tokenRepository.getTokens()
?: return@withContext AppResult.Error(AppError.Unauthorized)
try {
val refreshed = authApiService.refresh(
request = RefreshTokenRequestDto(refreshToken = tokens.refreshToken)
)
tokenRepository.saveTokens(
TokenBundle(
accessToken = refreshed.accessToken,
refreshToken = refreshed.refreshToken,
savedAtMillis = System.currentTimeMillis(),
)
)
pushTokenSyncManager.triggerBestEffortSync()
AppResult.Success(Unit)
} catch (error: Throwable) {
tokenRepository.clearTokens()
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())
}
}
override suspend fun restoreSession(): AppResult<AuthUser> = withContext(ioDispatcher) {
val tokens = tokenRepository.getTokens()
?: return@withContext AppResult.Error(AppError.Unauthorized)
if (tokens.accessToken.isBlank() || tokens.refreshToken.isBlank()) {
tokenRepository.clearTokens()
return@withContext AppResult.Error(AppError.Unauthorized)
}
when (val meResult = getMe()) {
is AppResult.Success -> {
pushTokenSyncManager.triggerBestEffortSync()
meResult
}
is AppResult.Error -> {
if (meResult.reason is AppError.Unauthorized) {
tokenRepository.clearTokens()
}
meResult
}
}
}
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()
}
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 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

@@ -0,0 +1,163 @@
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")
suspend fun getChats(
@Query("archived") archived: Boolean = false,
): List<ChatReadDto>
@GET("/api/v1/chats/{chat_id}")
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

@@ -0,0 +1,147 @@
package ru.daemonlord.messenger.data.chat.dto
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class ChatReadDto(
val id: Long,
@SerialName("public_id")
val publicId: String,
val type: String,
val title: String? = null,
@SerialName("display_title")
val displayTitle: String,
val handle: String? = null,
@SerialName("avatar_url")
val avatarUrl: String? = null,
val archived: Boolean = false,
val pinned: Boolean = false,
val muted: Boolean = false,
@SerialName("unread_count")
val unreadCount: Int = 0,
@SerialName("unread_mentions_count")
val unreadMentionsCount: Int = 0,
@SerialName("counterpart_user_id")
val counterpartUserId: Long? = null,
@SerialName("counterpart_name")
val counterpartName: String? = null,
@SerialName("counterpart_username")
val counterpartUsername: String? = null,
@SerialName("counterpart_avatar_url")
val counterpartAvatarUrl: String? = null,
@SerialName("counterpart_is_online")
val counterpartIsOnline: Boolean? = null,
@SerialName("counterpart_last_seen_at")
val counterpartLastSeenAt: String? = null,
@SerialName("last_message_text")
val lastMessageText: String? = null,
@SerialName("last_message_type")
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

@@ -0,0 +1,165 @@
package ru.daemonlord.messenger.data.chat.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.chat.local.entity.ChatEntity
import ru.daemonlord.messenger.data.chat.local.entity.UserShortEntity
import ru.daemonlord.messenger.data.chat.local.model.ChatListLocalModel
@Dao
interface ChatDao {
@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.archived = :archived
ORDER BY c.pinned DESC, c.updated_sort_at DESC, c.id DESC
"""
)
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>)
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun upsertUsers(users: List<UserShortEntity>)
@Query("DELETE FROM chats WHERE archived = :archived")
suspend fun deleteChatsByArchived(archived: Boolean)
@Query("DELETE FROM chats WHERE id = :chatId")
suspend fun deleteChat(chatId: Long)
@Query(
"""
UPDATE chats
SET counterpart_is_online = :isOnline,
counterpart_last_seen_at = :lastSeenAt
WHERE id = :chatId
"""
)
suspend fun updatePresence(chatId: Long, isOnline: Boolean, lastSeenAt: String?)
@Query(
"""
UPDATE chats
SET last_message_text = :lastMessageText,
last_message_type = :lastMessageType,
last_message_created_at = :lastMessageCreatedAt,
updated_sort_at = :updatedSortAt
WHERE id = :chatId
"""
)
suspend fun updateLastMessage(
chatId: Long,
lastMessageText: String?,
lastMessageType: String?,
lastMessageCreatedAt: String?,
updatedSortAt: String?,
)
@Query(
"""
UPDATE chats
SET unread_count = CASE
WHEN :incrementBy > 0 THEN unread_count + :incrementBy
ELSE unread_count
END
WHERE id = :chatId
"""
)
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,
chats: List<ChatEntity>,
users: List<UserShortEntity>,
) {
upsertUsers(users)
deleteChatsByArchived(archived = archived)
upsertChats(chats)
}
}

View File

@@ -0,0 +1,29 @@
package ru.daemonlord.messenger.data.chat.local.db
import androidx.room.Database
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 = 9,
exportSchema = false,
)
abstract class MessengerDatabase : RoomDatabase() {
abstract fun chatDao(): ChatDao
abstract fun messageDao(): MessageDao
abstract fun pendingMessageActionDao(): PendingMessageActionDao
}

View File

@@ -0,0 +1,65 @@
package ru.daemonlord.messenger.data.chat.local.entity
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.Index
import androidx.room.PrimaryKey
@Entity(
tableName = "chats",
indices = [
Index(value = ["archived", "pinned", "updated_sort_at"]),
Index(value = ["archived", "last_message_created_at"]),
],
)
data class ChatEntity(
@PrimaryKey
@ColumnInfo(name = "id")
val id: Long,
@ColumnInfo(name = "public_id")
val publicId: String,
@ColumnInfo(name = "type")
val type: String,
@ColumnInfo(name = "title")
val title: String?,
@ColumnInfo(name = "display_title")
val displayTitle: String,
@ColumnInfo(name = "handle")
val handle: String?,
@ColumnInfo(name = "avatar_url")
val avatarUrl: String?,
@ColumnInfo(name = "archived")
val archived: Boolean,
@ColumnInfo(name = "pinned")
val pinned: Boolean,
@ColumnInfo(name = "muted")
val muted: Boolean,
@ColumnInfo(name = "unread_count")
val unreadCount: Int,
@ColumnInfo(name = "unread_mentions_count")
val unreadMentionsCount: Int,
@ColumnInfo(name = "counterpart_user_id")
val counterpartUserId: Long?,
@ColumnInfo(name = "counterpart_name")
val counterpartName: String?,
@ColumnInfo(name = "counterpart_username")
val counterpartUsername: String?,
@ColumnInfo(name = "counterpart_avatar_url")
val counterpartAvatarUrl: String?,
@ColumnInfo(name = "counterpart_is_online")
val counterpartIsOnline: Boolean?,
@ColumnInfo(name = "counterpart_last_seen_at")
val counterpartLastSeenAt: String?,
@ColumnInfo(name = "last_message_text")
val lastMessageText: String?,
@ColumnInfo(name = "last_message_type")
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

@@ -0,0 +1,20 @@
package ru.daemonlord.messenger.data.chat.local.entity
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.PrimaryKey
@Entity(
tableName = "users_short",
)
data class UserShortEntity(
@PrimaryKey
@ColumnInfo(name = "id")
val id: Long,
@ColumnInfo(name = "display_name")
val displayName: String,
@ColumnInfo(name = "username")
val username: String?,
@ColumnInfo(name = "avatar_url")
val avatarUrl: String?,
)

View File

@@ -0,0 +1,52 @@
package ru.daemonlord.messenger.data.chat.local.model
import androidx.room.ColumnInfo
data class ChatListLocalModel(
@ColumnInfo(name = "id")
val id: Long,
@ColumnInfo(name = "public_id")
val publicId: String,
@ColumnInfo(name = "type")
val type: String,
@ColumnInfo(name = "title")
val title: String?,
@ColumnInfo(name = "display_title")
val displayTitle: String,
@ColumnInfo(name = "handle")
val handle: String?,
@ColumnInfo(name = "avatar_url")
val avatarUrl: String?,
@ColumnInfo(name = "archived")
val archived: Boolean,
@ColumnInfo(name = "pinned")
val pinned: Boolean,
@ColumnInfo(name = "muted")
val muted: Boolean,
@ColumnInfo(name = "unread_count")
val unreadCount: Int,
@ColumnInfo(name = "unread_mentions_count")
val unreadMentionsCount: Int,
@ColumnInfo(name = "counterpart_name")
val counterpartName: String?,
@ColumnInfo(name = "counterpart_username")
val counterpartUsername: String?,
@ColumnInfo(name = "counterpart_avatar_url")
val counterpartAvatarUrl: String?,
@ColumnInfo(name = "counterpart_is_online")
val counterpartIsOnline: Boolean?,
@ColumnInfo(name = "counterpart_last_seen_at")
val counterpartLastSeenAt: String?,
@ColumnInfo(name = "last_message_text")
val lastMessageText: String?,
@ColumnInfo(name = "last_message_type")
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

@@ -0,0 +1,61 @@
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 {
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,
)
}
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

@@ -0,0 +1,55 @@
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(
id = id,
publicId = publicId,
type = type,
title = title,
displayTitle = displayTitle,
handle = handle,
avatarUrl = avatarUrl,
archived = archived,
pinned = pinned,
muted = muted,
unreadCount = unreadCount,
unreadMentionsCount = unreadMentionsCount,
counterpartUserId = counterpartUserId,
counterpartName = counterpartName,
counterpartUsername = counterpartUsername,
counterpartAvatarUrl = counterpartAvatarUrl,
counterpartIsOnline = counterpartIsOnline,
counterpartLastSeenAt = counterpartLastSeenAt,
lastMessageText = lastMessageText,
lastMessageType = lastMessageType,
lastMessageCreatedAt = lastMessageCreatedAt,
pinnedMessageId = pinnedMessageId,
myRole = myRole,
updatedSortAt = lastMessageCreatedAt ?: createdAt,
)
}
fun ChatReadDto.toUserShortEntityOrNull(): UserShortEntity? {
val userId = counterpartUserId ?: return null
val displayName = counterpartName ?: counterpartUsername ?: return null
return UserShortEntity(
id = userId,
displayName = displayName,
username = counterpartUsername,
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

@@ -0,0 +1,424 @@
package ru.daemonlord.messenger.data.chat.repository
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 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.AppResult
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class NetworkChatRepository @Inject constructor(
private val chatApiService: ChatApiService,
private val chatDao: ChatDao,
@IoDispatcher private val ioDispatcher: CoroutineDispatcher,
) : ChatRepository {
override fun observeChats(archived: Boolean): Flow<List<ChatItem>> {
return channelFlow {
val dbCollection = launch {
chatDao.observeChats(archived = archived).collect { rows ->
send(rows.map { it.toDomain() })
}
}
launch(ioDispatcher) {
refreshChats(archived = archived)
}
awaitClose { dbCollection.cancel() }
}
}
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)
val chatEntities = chats.map { it.toChatEntity() }
val userEntities = chats.mapNotNull { it.toUserShortEntityOrNull() }
chatDao.clearAndReplaceChats(
archived = archived,
chats = chatEntities,
users = userEntities,
)
AppResult.Success(Unit)
} catch (error: Throwable) {
AppResult.Error(error.toAppError())
}
}
override suspend fun refreshChat(chatId: Long): AppResult<Unit> = withContext(ioDispatcher) {
try {
val chat = chatApiService.getChatById(chatId = chatId)
chatDao.upsertUsers(chat.toUserShortEntityOrNull()?.let(::listOf).orEmpty())
chatDao.upsertChats(listOf(chat.toChatEntity()))
AppResult.Success(Unit)
} catch (error: Throwable) {
AppResult.Error(error.toAppError())
}
}
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 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

@@ -0,0 +1,145 @@
package ru.daemonlord.messenger.data.realtime
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.contentOrNull
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import ru.daemonlord.messenger.domain.realtime.model.RealtimeEvent
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class RealtimeEventParser @Inject constructor(
private val json: Json,
) {
fun parse(raw: String): RealtimeEvent {
val root = runCatching { json.parseToJsonElement(raw).jsonObject }.getOrNull()
?: return RealtimeEvent.Ignored
val event = root["event"].stringOrNull() ?: return RealtimeEvent.Ignored
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(),
)
}
"message_deleted" -> {
val chatId = payload["chat_id"].longOrNull() ?: return RealtimeEvent.Ignored
RealtimeEvent.MessageDeleted(
chatId = chatId,
messageId = payload["message_id"].longOrNull(),
)
}
"chat_updated" -> {
val chatId = payload["chat_id"].longOrNull()
?: payload["id"].longOrNull()
?: return RealtimeEvent.Ignored
RealtimeEvent.ChatUpdated(chatId = chatId)
}
"chat_deleted" -> {
val chatId = payload["chat_id"].longOrNull()
?: payload["id"].longOrNull()
?: return RealtimeEvent.Ignored
RealtimeEvent.ChatDeleted(chatId = chatId)
}
"user_online" -> {
val chatId = payload["chat_id"].longOrNull() ?: return RealtimeEvent.Ignored
RealtimeEvent.UserOnline(chatId = chatId)
}
"user_offline" -> {
val chatId = payload["chat_id"].longOrNull() ?: return RealtimeEvent.Ignored
RealtimeEvent.UserOffline(
chatId = chatId,
lastSeenAt = payload["last_seen_at"].stringOrNull(),
)
}
"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
}
}
private fun JsonElement?.stringOrNull(): String? {
return this?.jsonPrimitive?.contentOrNull
}
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

@@ -0,0 +1,164 @@
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
import okhttp3.Request
import okhttp3.Response
import okhttp3.WebSocket
import okhttp3.WebSocketListener
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
@Singleton
class WsRealtimeManager @Inject constructor(
@RefreshClient private val okHttpClient: OkHttpClient,
private val tokenRepository: TokenRepository,
private val parser: RealtimeEventParser,
) : RealtimeManager {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private val eventFlow = MutableSharedFlow<RealtimeEvent>(extraBufferCapacity = 64)
private var socket: WebSocket? = null
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 ?: run {
_connectionState.value = RealtimeConnectionState.Disconnected
return
}
val wsUrl = BuildConfig.API_BASE_URL
.replace("http://", "ws://")
.replace("https://", "wss://")
.trimEnd('/') + "/api/v1/realtime/ws?token=$accessToken"
val request = Request.Builder()
.url(wsUrl)
.build()
socket = okHttpClient.newWebSocket(request, listener)
}
private fun scheduleReconnect() {
if (manualDisconnect.get()) return
_connectionState.value = RealtimeConnectionState.Reconnecting
scope.launch {
delay(reconnectDelayMs)
reconnectDelayMs = (reconnectDelayMs * 2).coerceAtMost(MAX_RECONNECT_MS)
openSocket()
}
}
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()
}
}
@Suppress("unused")
fun shutdown() {
disconnect()
scope.cancel()
}
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

@@ -0,0 +1,45 @@
package ru.daemonlord.messenger.di
import android.content.Context
import androidx.room.Room
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.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
@InstallIn(SingletonComponent::class)
object DatabaseModule {
@Provides
@Singleton
fun provideDatabase(
@ApplicationContext context: Context,
): MessengerDatabase {
return Room.databaseBuilder(
context,
MessengerDatabase::class.java,
"messenger.db",
).fallbackToDestructiveMigration()
.build()
}
@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

@@ -0,0 +1,171 @@
package ru.daemonlord.messenger.di
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers
import kotlinx.serialization.json.Json
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
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
@Module
@InstallIn(SingletonComponent::class)
object NetworkModule {
@Provides
@Singleton
fun provideJson(): Json {
return Json {
ignoreUnknownKeys = true
explicitNulls = false
isLenient = true
}
}
@Provides
@Singleton
fun provideHttpLoggingInterceptor(): HttpLoggingInterceptor {
return HttpLoggingInterceptor().apply {
level = if (BuildConfig.DEBUG) {
HttpLoggingInterceptor.Level.BODY
} else {
HttpLoggingInterceptor.Level.NONE
}
}
}
@Provides
@Singleton
@RefreshClient
fun provideRefreshClient(
loggingInterceptor: HttpLoggingInterceptor,
): OkHttpClient {
return OkHttpClient.Builder()
.addInterceptor(loggingInterceptor)
.connectTimeout(20, TimeUnit.SECONDS)
.readTimeout(20, TimeUnit.SECONDS)
.writeTimeout(20, TimeUnit.SECONDS)
.build()
}
@Provides
@Singleton
@RefreshAuthApi
fun provideRefreshApiService(
@RefreshClient refreshClient: OkHttpClient,
json: Json,
): AuthApiService {
val contentType = "application/json".toMediaType()
return Retrofit.Builder()
.baseUrl(BuildConfig.API_BASE_URL)
.addConverterFactory(json.asConverterFactory(contentType))
.client(refreshClient)
.build()
.create(AuthApiService::class.java)
}
@Provides
@Singleton
@IoDispatcher
fun provideIoDispatcher(): CoroutineDispatcher = Dispatchers.IO
@Provides
@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)
.readTimeout(20, TimeUnit.SECONDS)
.writeTimeout(20, TimeUnit.SECONDS)
.build()
}
@Provides
@Singleton
fun provideRetrofit(
client: OkHttpClient,
json: Json,
): Retrofit {
val contentType = "application/json".toMediaType()
return Retrofit.Builder()
.baseUrl(BuildConfig.API_BASE_URL)
.addConverterFactory(json.asConverterFactory(contentType))
.client(client)
.build()
}
@Provides
@Singleton
fun provideAuthApiService(retrofit: Retrofit): AuthApiService {
return retrofit.create(AuthApiService::class.java)
}
@Provides
@Singleton
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

@@ -0,0 +1,19 @@
package ru.daemonlord.messenger.di
import javax.inject.Qualifier
@Qualifier
@Retention(AnnotationRetention.BINARY)
annotation class RefreshClient
@Qualifier
@Retention(AnnotationRetention.BINARY)
annotation class RefreshAuthApi
@Qualifier
@Retention(AnnotationRetention.BINARY)
annotation class IoDispatcher
@Qualifier
@Retention(AnnotationRetention.BINARY)
annotation class TokenPrefs

View File

@@ -0,0 +1,20 @@
package ru.daemonlord.messenger.di
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import ru.daemonlord.messenger.data.realtime.WsRealtimeManager
import ru.daemonlord.messenger.domain.realtime.RealtimeManager
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
abstract class RealtimeModule {
@Binds
@Singleton
abstract fun bindRealtimeManager(
manager: WsRealtimeManager,
): RealtimeManager
}

View File

@@ -0,0 +1,100 @@
package ru.daemonlord.messenger.di
import dagger.Binds
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
@InstallIn(SingletonComponent::class)
abstract class RepositoryModule {
@Binds
@Singleton
abstract fun bindAuthRepository(
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

@@ -0,0 +1,57 @@
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.EncryptedPrefsTokenRepository
import ru.daemonlord.messenger.core.token.TokenRepository
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
object StorageModule {
@Provides
@Singleton
fun providePreferenceDataStore(
@ApplicationContext context: Context,
): DataStore<Preferences> {
return PreferenceDataStoreFactory.create(
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: 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,
)

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