Compare commits
128 Commits
ef5f866bd0
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| b40dea18f1 | |||
| 28cb80fbb8 | |||
| 9af7597f8b | |||
| e6f1727800 | |||
| cf53123724 | |||
| 2fa006747d | |||
| 4032b55b0b | |||
| a1163be30b | |||
| d649cf1cb4 | |||
| e5e4fd653e | |||
| f88d9a2a36 | |||
| 27f2ad8001 | |||
| d54dc9fe8b | |||
| 6e9e580b3f | |||
| 43c3fd0169 | |||
| 3f9aa83110 | |||
| 2ffc4cce09 | |||
| e591a3fa8d | |||
| e0728ac067 | |||
| c5c1db98ad | |||
| 92c4cba1b0 | |||
| 60d898bf21 | |||
| 732b21a4e3 | |||
| 10676e34ad | |||
| 3bc540e46d | |||
| 0510a2717a | |||
| cdb45abb21 | |||
| cd7fb878b3 | |||
| a4fd60919e | |||
|
|
3c9b97e102 | ||
|
|
f8ed889170 | ||
|
|
3844875d36 | ||
|
|
27fba86915 | ||
|
|
58b554731d | ||
|
|
2a72437d28 | ||
|
|
8522e32aea | ||
|
|
e3fdccdeaa | ||
|
|
23d636be7e | ||
|
|
842a9d2093 | ||
|
|
63c0cd098e | ||
|
|
fbe4db02ca | ||
|
|
7f1b0e09c5 | ||
|
|
f7b9753c2e | ||
|
|
e4ea18242a | ||
|
|
0208fbc5cc | ||
|
|
22ee59fd74 | ||
|
|
f7ef10b011 | ||
|
|
78934a5f28 | ||
|
|
0beb52e438 | ||
|
|
10e188b615 | ||
|
|
47365bba57 | ||
|
|
55af1f78b6 | ||
|
|
7781cf83e4 | ||
|
|
5a0bb9ff08 | ||
|
|
90c25c5eb8 | ||
|
|
2ed0e1f041 | ||
|
|
580a6683e3 | ||
|
|
4aa4946e82 | ||
|
|
895c132eb2 | ||
|
|
1099efc8c0 | ||
|
|
e21a54e2bf | ||
|
|
148870de14 | ||
|
|
158126555c | ||
|
|
eae6a2a90f | ||
|
|
bb1f59d1f4 | ||
|
|
4bab551f0e | ||
|
|
c609a7d72d | ||
|
|
09a77bd4d7 | ||
|
|
0bd7e1cd21 | ||
|
|
15f9836224 | ||
|
|
cdf7859668 | ||
|
|
daddbfd2a0 | ||
|
|
19471ac736 | ||
|
|
15e80262e0 | ||
|
|
5921215718 | ||
|
|
d54eb400c7 | ||
|
|
28b549e53e | ||
|
|
e44e8d1355 | ||
|
|
9296695ed5 | ||
|
|
ef28c165e6 | ||
|
|
b1b54896a7 | ||
|
|
74b086b9c8 | ||
|
|
e82178fcc3 | ||
|
|
b294297dbd | ||
|
|
7824ab1a55 | ||
|
|
854ba0cbc6 | ||
|
|
bd1229fe5a | ||
|
|
c040ebf059 | ||
|
|
f005b3f222 | ||
|
|
77697ff36e | ||
|
|
e6a9fe9cca | ||
|
|
9dff805145 | ||
|
|
4f53e3ef99 | ||
|
|
4a31612df0 | ||
|
|
c4d1e7f1fb | ||
|
|
18844ec06a | ||
|
|
28f7da5f41 | ||
|
|
776a7634d2 | ||
|
|
cbd326ee12 | ||
|
|
4502fdf9e9 | ||
|
|
2324801f56 | ||
|
|
e717888d8e | ||
|
|
6a1961e045 | ||
|
|
8101cbbffd | ||
|
|
0a9297c03d | ||
|
|
3b3c740ae0 | ||
|
|
b75df4967f | ||
|
|
6328a74c23 | ||
|
|
fdd877b49a | ||
|
|
ee52785b1b | ||
|
|
3af90ec257 | ||
|
|
d29ad4cfb7 | ||
|
|
448ed3243d | ||
|
|
e65714e45e | ||
|
|
c12ab05946 | ||
|
|
fd31e39fce | ||
|
|
f6851d2af9 | ||
|
|
45918d65cb | ||
|
|
af6d8426ba | ||
|
|
881ad99ada | ||
|
|
862b18e305 | ||
|
|
47190e354d | ||
|
|
69c0b632df | ||
|
|
f708854bb2 | ||
|
|
5368515112 | ||
|
|
9ad8372d45 | ||
|
|
91d712c702 | ||
|
|
65e74cffdb |
@@ -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
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -8,3 +8,4 @@ test.db
|
||||
web/node_modules
|
||||
web/dist
|
||||
web/tsconfig.tsbuildinfo
|
||||
secrets/
|
||||
|
||||
44
alembic/versions/0027_push_device_tokens.py
Normal file
44
alembic/versions/0027_push_device_tokens.py
Normal 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")
|
||||
@@ -378,3 +378,648 @@
|
||||
- Added dedicated release workflow (`.github/workflows/android-release.yml`) for `main` branch pushes.
|
||||
- Adapted version extraction for Kotlin DSL (`android/app/build.gradle.kts`) and guarded release by existing git tag.
|
||||
- Wired release build, git tag push, and Gitea release publication with APK artifact upload.
|
||||
|
||||
### Step 65 - Account and media parity foundation (checklist 1-15)
|
||||
- Introduced `:core:common` module and moved base `AppError`/`AppResult` contracts out of `:app`.
|
||||
- Added structured app logging (`Timber`) and crash reporting baseline (`Firebase Crashlytics`) with app startup wiring.
|
||||
- Added API version header interceptor + build-time feature flags and DI provider.
|
||||
- Added account network layer for auth/account management:
|
||||
- verify email, password reset request/reset,
|
||||
- sessions list + revoke one/all,
|
||||
- 2FA setup/enable/disable + recovery status/regenerate,
|
||||
- profile/privacy update and blocked users management.
|
||||
- Added deep-link aware auth routes for `/verify-email` and `/reset-password`.
|
||||
- Reworked Settings/Profile screens from placeholders to editable account management screens.
|
||||
- Added avatar upload with center square crop (`1:1`) before upload.
|
||||
- Upgraded message attachment rendering with in-bubble multi-image gallery and unified attachment context actions (open/copy/close).
|
||||
|
||||
### Step 66 - Voice recording controls + global audio focus
|
||||
- Added microphone permission (`RECORD_AUDIO`) and in-chat voice recording flow based on press-and-hold gesture.
|
||||
- Implemented Telegram-like gesture controls for voice button:
|
||||
- hold to record,
|
||||
- slide up to lock recording,
|
||||
- slide left to cancel recording.
|
||||
- Added minimum voice length validation (`>= 1s`) before sending.
|
||||
- Integrated voice message sending via existing media upload path (`audio/mp4` attachment).
|
||||
- Added process-wide audio focus coordinator to enforce single active audio source:
|
||||
- attachment player pauses when another source starts,
|
||||
- recording requests focus and stops competing playback.
|
||||
|
||||
### Step 67 - Group/channel management baseline in Chat List
|
||||
- Extended chat API/repository layer with management operations:
|
||||
- create group/channel,
|
||||
- discover + join/leave chats,
|
||||
- invite link create/regenerate,
|
||||
- members/bans listing and admin actions (add/remove/ban/unban/promote/demote).
|
||||
- Added domain models for discover/member/ban items and repository mappings.
|
||||
- Added in-app management panel in `ChatListScreen` (FAB toggle) for:
|
||||
- creating group/channel,
|
||||
- joining discovered chats,
|
||||
- loading chat members/bans by chat id,
|
||||
- executing admin/member visibility actions from one place.
|
||||
|
||||
### Step 68 - Search, inline jump, theme toggle, accessibility pass
|
||||
- Added global search baseline in chat list:
|
||||
- users search (`/users/search`),
|
||||
- messages search (`/messages/search`),
|
||||
- chat discovery integration (`/chats/discover`).
|
||||
- Added inline search in chat screen with jump navigation (prev/next) and automatic scroll to matched message.
|
||||
- Added highlighted message state for active inline search result.
|
||||
- Added theme switching controls in settings (Light/Dark/System) via `AppCompatDelegate`.
|
||||
- Added accessibility refinements for key surfaces and controls:
|
||||
- explicit content descriptions for avatars and tab-like controls,
|
||||
- voice record button semantic label for TalkBack.
|
||||
|
||||
### Step 69 - Bugfix pass: voice recording, theme apply, profile avatar UX
|
||||
- Fixed voice recording start on Android by switching `VoiceRecorder` to compatible `MediaRecorder()` initialization.
|
||||
- Fixed microphone permission flow: record action now triggers runtime permission request reliably and auto-starts recording after grant.
|
||||
- Fixed theme switching application by introducing app-level `MessengerTheme` and switching app manifest base theme to DayNight.
|
||||
- Fixed profile screen usability after avatar upload:
|
||||
- enabled vertical scrolling with safe insets/navigation padding,
|
||||
- constrained avatar preview to a centered circular area instead of full-screen takeover.
|
||||
|
||||
### Step 70 - Chat interaction consistency: gestures + sheets/dialogs
|
||||
- Reworked single-message actions to open in `ModalBottomSheet` (tap action menu) instead of inline action bars.
|
||||
- Reworked forward target chooser to `ModalBottomSheet` for consistent overlay behavior across chat actions.
|
||||
- Added destructive action confirmation via `AlertDialog` before delete actions.
|
||||
- Reduced gesture conflicts by removing attachment-level long-press handlers that collided with message selection gestures.
|
||||
- Improved voice hold gesture reliability by handling consumed pointer down events (`requireUnconsumed = false`).
|
||||
|
||||
### Step 71 - Voice playback waveform/speed + circle video playback
|
||||
- Added voice-focused audio playback mode with waveform rendering in message bubbles.
|
||||
- Added playback speed switch for voice messages (`1.0x -> 1.5x -> 2.0x`).
|
||||
- Added view-only circle video renderer for `video_note` messages with looped playback.
|
||||
- Kept regular audio/video attachment rendering for non-voice/non-circle media unchanged.
|
||||
|
||||
### Step 72 - Adaptive layout baseline (phone/tablet) + voice release fix
|
||||
- Added tablet-aware max-width layout constraints across major screens (login, verify/reset auth, chats list, chat, profile, settings).
|
||||
- Kept phone layout unchanged while centering content and limiting line width on larger displays.
|
||||
- Fixed voice hold-to-send gesture reliability by removing pointer-input restarts during active recording, so release consistently triggers send path.
|
||||
|
||||
### Step 73 - Voice message send/playback bugfixes
|
||||
- Fixed voice media type mapping in message repository: recorded files with `voice_*.m4a` are now sent as message type `voice` (not generic `audio`).
|
||||
- Fixed audio replay behavior: when playback reaches the end, next play restarts from `0:00`.
|
||||
- Improved duration display in audio/voice player by adding metadata fallback when `MediaPlayer` duration is not immediately available.
|
||||
|
||||
### Step 74 - UI references consolidation (Batch 4)
|
||||
- Added full Telegram reference mapping checklist (`docs/android-ui-batch-4-checklist.md`) with screenshot-by-screenshot description.
|
||||
- Added explicit icon policy: no emoji icons in production UI components, Material Icons/vector icons only.
|
||||
- Updated UI checklist index with Batch 4 entry.
|
||||
|
||||
### Step 75 - Material Icons migration (Batch 1 start)
|
||||
- Replaced symbol/emoji-based UI controls in chat surfaces with Material Icons:
|
||||
- chat header/menu/search controls (`more`, `up/down`),
|
||||
- image viewer actions (`close`, `forward`, `delete`),
|
||||
- multi-select markers (`radio checked/unchecked`, `selected` check),
|
||||
- attachment/media markers (`movie`, `attach file`).
|
||||
- Replaced chat list management FAB glyph toggle (`+`/`×`) with Material `Add`/`Close` icons.
|
||||
- Added `androidx.compose.material:material-icons-extended` dependency for consistent icon usage.
|
||||
|
||||
### Step 76 - Shared main tabs shell with scroll-aware visibility
|
||||
- Moved `Chats / Contacts / Settings / Profile` bottom panel to a shared navigation shell (`AppNavGraph`) so it behaves as global page navigation.
|
||||
- Added dedicated `Contacts` page route and wired it into main tabs.
|
||||
- Removed local duplicated bottom panel from chat list screen.
|
||||
- Implemented scroll-direction behavior for all 4 main pages:
|
||||
- hide panel on downward scroll,
|
||||
- show panel on upward scroll / at top.
|
||||
|
||||
### Step 77 - Main tabs bar UX/layout fix
|
||||
- Replaced custom pill-row main bar with compact `NavigationBar` inside rounded container for stable 4-tab layout on small screens.
|
||||
- Added bottom content paddings for `Chats/Contacts/Settings/Profile` pages so content is not obscured by the floating main bar.
|
||||
- Raised chats management FAB offset to avoid overlap with the global bottom bar.
|
||||
|
||||
### Step 78 - Telegram-like bottom tabs visual tuning
|
||||
- Tuned shared main bar visual style to better match Telegram references:
|
||||
- rounded floating container with subtle elevation,
|
||||
- unified selected/unselected item colors,
|
||||
- stable 4-item navigation with icons + labels.
|
||||
- Kept scroll-hide/show behavior and page-level navigation unchanged.
|
||||
|
||||
### Step 79 - Main pages app bars + safe-area pass
|
||||
- Added top app bars for all 4 main pages (`Chats`, `Contacts`, `Settings`, `Profile`) to make them feel like proper standalone sections.
|
||||
- Moved chats management toggle action into chats app bar.
|
||||
- Kept safe-area handling and bottom insets consistent with shared floating tabs bar to avoid overlap.
|
||||
|
||||
### Step 80 - Top bar offset consistency fix
|
||||
- Unified top bar alignment across `Chats`, `Contacts`, `Settings`, and `Profile`:
|
||||
- removed extra outer paddings that shifted headers down/right on some pages,
|
||||
- separated content padding from top app bar container.
|
||||
- Result: consistent title baseline and horizontal alignment between main pages.
|
||||
|
||||
### Step 81 - Chats bottom gap fix when tabs bar hidden
|
||||
- Fixed blank gap at the bottom of chats list when global tabs bar auto-hides on scroll.
|
||||
- Chats screen bottom padding is now dynamic and applied only while tabs bar is visible.
|
||||
|
||||
### Step 82 - Chats list header closer to Telegram reference
|
||||
- Removed `Archived` top tab from chats list UI.
|
||||
- Added search action in top app bar and unified single search field with leading search icon.
|
||||
- Kept archive as dedicated row inside chats list; opening archive now happens from that row and back navigation appears in app bar while archive is active.
|
||||
|
||||
### Step 83 - Chats header realtime connection status
|
||||
- Added realtime connection state stream (`Disconnected/Connecting/Reconnecting/Connected`) to `RealtimeManager`.
|
||||
- Wired websocket lifecycle into that state in `WsRealtimeManager`.
|
||||
- Bound chats top bar title to realtime state:
|
||||
- shows `Connecting...` while reconnect/initial connect is in progress,
|
||||
- shows regular page title once connected.
|
||||
|
||||
### Step 84 - Chats list preview icon policy cleanup
|
||||
- Updated chat last-message preview text to remove emoji prefixes.
|
||||
- Switched media-type preview prefixes to plain text labels (`Photo`, `Video`, `Voice`, etc.) to match Material-icons-only UI policy.
|
||||
|
||||
### Step 85 - Unread counter fix for active/read chats
|
||||
- Added `ChatDao.markChatRead(chatId)` to clear `unread_count` and `unread_mentions_count` in Room.
|
||||
- Applied optimistic local unread reset on `markMessageRead(...)` in message repository.
|
||||
- Fixed realtime unread logic: incoming messages in currently active chat no longer increment unread badge.
|
||||
|
||||
### Step 86 - Chats list visual pass toward Telegram reference
|
||||
- Updated chats list row density: tighter vertical rhythm, larger avatar, stronger title hierarchy, cleaner secondary text.
|
||||
- Restyled archive as dedicated list row with leading archive icon avatar, subtitle, and unread badge.
|
||||
- Kept search in top app bar action and changed search field default to collapsed (opens via search icon).
|
||||
- Returned message-type emoji markers in chat previews:
|
||||
- `🖼` photo, `🎤` voice, `🎵` audio, `🎥` video, `⭕` circle video, `🔗` links.
|
||||
|
||||
### Step 87 - Chats list micro-typography and time formatting
|
||||
- Refined chat row typography hierarchy to be closer to Telegram density:
|
||||
- title/body/presence font scale aligned and single-line ellipsis for long values.
|
||||
- Tightened unread/mention badge sizing and spacing for compact right-side metadata.
|
||||
- Updated trailing time formatter:
|
||||
- today: `HH:mm`,
|
||||
- this week: localized short weekday,
|
||||
- older: `dd.MM.yy`.
|
||||
|
||||
### Step 88 - Chats list interaction states (menu/select/search)
|
||||
- Added default overflow menu (`⋮`) state in chats header with Telegram-like quick actions UI.
|
||||
- Added long-press multi-select mode for chat rows with:
|
||||
- top selection bar (`count`, action icons),
|
||||
- dedicated overflow menu for selected chats.
|
||||
- Added dedicated search-mode state in chats screen:
|
||||
- search field + section chips (`Chats/Channels/Apps/Posts`),
|
||||
- horizontal recent avatars strip,
|
||||
- list filtered by active query.
|
||||
|
||||
### Step 89 - Chats actions wiring + duplicate menu fix
|
||||
- Removed duplicated overflow action in chats top bar (single `⋮` remains in default mode).
|
||||
- Wired selection actions to behavior:
|
||||
- delete selected -> leave selected chats,
|
||||
- archive selected -> switch to archived section,
|
||||
- non-implemented bulk actions now show explicit user feedback.
|
||||
- Wired default menu actions:
|
||||
- create group/channel -> open management panel,
|
||||
- saved -> open saved chat if present,
|
||||
- unsupported items show clear feedback instead of silent no-op.
|
||||
|
||||
### Step 90 - Fullscreen chats search redesign (Telegram-like)
|
||||
- Reworked chats search mode into a fullscreen flow:
|
||||
- top rounded search field with inline clear button,
|
||||
- horizontal category chips (`Chats`, `Channels`, `Apps`, `Posts`),
|
||||
- dedicated recent avatars row for the active category.
|
||||
- Added search-mode content states:
|
||||
- empty query -> `Recent` list block (history-style chat rows),
|
||||
- non-empty query -> local matches + `Global search` and `Messages` sections.
|
||||
- Kept search action in chats top bar; while search mode is active, app bar switches to back-navigation + empty title (content drives the page).
|
||||
|
||||
### Step 91 - Search history/recent persistence + clear action
|
||||
- Added `ChatSearchRepository` abstraction and `DataStoreChatSearchRepository` implementation.
|
||||
- Persisted chats search metadata in `DataStore`:
|
||||
- recent opened chats list,
|
||||
- search history list (bounded).
|
||||
- Wired chats fullscreen search to persisted data:
|
||||
- green recent avatars strip now reads saved recent chats,
|
||||
- red `Recent` list now reads saved history with fallback.
|
||||
- Connected `Очистить` action to real history cleanup in `DataStore`.
|
||||
- On opening a chat from search results/messages/history, the chat is now stored in recent/history.
|
||||
|
||||
### Step 92 - Search filter leak fix on exit
|
||||
- Fixed chats search state leak: leaving fullscreen search now resets local/global query.
|
||||
- Main chats list no longer stays filtered by previous search input after returning from search mode.
|
||||
|
||||
### Step 93 - Fullscreen search UX polish
|
||||
- Added system back-handler for search mode with safe query reset.
|
||||
- Improved fullscreen search result sections:
|
||||
- `Показать больше / Свернуть` toggle for global users,
|
||||
- `Показать больше / Свернуть` toggle for message results.
|
||||
- Added explicit empty-state text when local/global/message search sections all have no results.
|
||||
|
||||
### Step 94 - Pinned-only drag markers in selection mode
|
||||
- Updated chats multi-select row UI: drag markers are now shown only for pinned chats.
|
||||
- Non-pinned chats no longer render reorder marker in selection mode.
|
||||
|
||||
### Step 95 - Selection badge on avatar (Telegram-like)
|
||||
- Added explicit selection indicator directly on chat avatars in multi-select mode:
|
||||
- selected chat -> colored circle with check icon,
|
||||
- unselected chat -> empty outlined circle.
|
||||
- This matches the reference behavior and makes selected rows easier to scan.
|
||||
|
||||
### Step 96 - Selection menu labels and behavior polish
|
||||
- Updated multi-select top actions/menu to be closer to Telegram reference in wording.
|
||||
- Added dynamic `Закрепить/Открепить` label in selection overflow based on selected chats pinned state.
|
||||
- Kept non-supported actions explicit with user feedback (Toast), avoiding silent no-op behavior.
|
||||
|
||||
### Step 97 - Chats popup/select actions wired to backend API
|
||||
- Extended Android chat data layer with missing parity endpoints:
|
||||
- `archive/unarchive`
|
||||
- `pin-chat/unpin-chat`
|
||||
- `clear`
|
||||
- `delete (for_all=false)`
|
||||
- `chat notifications get/update`
|
||||
- Added repository methods and `ViewModel` actions for those operations.
|
||||
- Replaced chats multi-select UI stubs with real API calls:
|
||||
- mute/unmute selected chats,
|
||||
- archive/unarchive selected chats,
|
||||
- pin/unpin selected chats,
|
||||
- clear selected chats,
|
||||
- delete selected chats for current user.
|
||||
|
||||
### Step 98 - Realtime sync fix for pin/archive updates
|
||||
- Improved `chat_updated` handling in realtime flow:
|
||||
- now refreshes both active and archived chats lists to sync user-scoped flags (`pinned`, `archived`) immediately.
|
||||
- Added parser fallback for realtime chat events to support payloads with either `chat_id` or `id`.
|
||||
|
||||
### Step 99 - Saved chat API parity
|
||||
- Added Android support for `GET /api/v1/chats/saved`.
|
||||
- Wired chats overflow `Saved` action to real backend request (instead of local title heuristic).
|
||||
- Saved chat is now upserted into local Room cache and opened via normal navigation flow.
|
||||
|
||||
### Step 100 - Android image compression before upload
|
||||
- Added pre-upload image compression in Android media pipeline (`NetworkMediaRepository`).
|
||||
- For non-GIF images:
|
||||
- decode + resize with max side `1920`,
|
||||
- re-encode as `image/jpeg` with quality `82`,
|
||||
- keep original bytes if compression does not reduce payload size.
|
||||
- Upload request and attachment metadata now use actual prepared payload (`fileName`, `fileType`, `fileSize`), matching web behavior.
|
||||
|
||||
### Step 101 - Chat title/profile API parity
|
||||
- Added Android API integration for:
|
||||
- `PATCH /api/v1/chats/{chat_id}/title`
|
||||
- `PATCH /api/v1/chats/{chat_id}/profile`
|
||||
- Extended `ChatRepository`/`NetworkChatRepository` with `updateChatTitle(...)` and `updateChatProfile(...)`.
|
||||
- Wired these actions into the existing Chat Management panel:
|
||||
- edit selected chat title,
|
||||
- edit selected chat profile fields (title/description).
|
||||
|
||||
### Step 102 - Global search + message thread parity
|
||||
- Added Android data-layer integration for unified backend global search:
|
||||
- `GET /api/v1/search`
|
||||
- new `SearchRepository` + `SearchApiService` returning `users/chats/messages`.
|
||||
- Switched chats fullscreen search flow to use unified backend search instead of composed per-domain calls.
|
||||
- Extended message data layer with:
|
||||
- `GET /api/v1/messages/{message_id}/thread`
|
||||
- `MessageRepository.getMessageThread(...)` for thread/replies usage in upcoming UI steps.
|
||||
|
||||
### Step 103 - Contacts API parity + real Contacts screen
|
||||
- Added Android integration for contacts endpoints:
|
||||
- `GET /api/v1/users/contacts`
|
||||
- `POST /api/v1/users/{user_id}/contacts`
|
||||
- `POST /api/v1/users/contacts/by-email`
|
||||
- `DELETE /api/v1/users/{user_id}/contacts`
|
||||
- Extended `AccountRepository` + `NetworkAccountRepository` with contacts methods.
|
||||
- Replaced placeholder Contacts screen with real stateful flow (`ContactsViewModel`):
|
||||
- load contacts from backend,
|
||||
- user search + add contact,
|
||||
- add contact by email,
|
||||
- remove contact,
|
||||
- loading/refresh/error/info states.
|
||||
|
||||
### Step 104 - Push token sync (Android + backend)
|
||||
- Added backend push token lifecycle API and storage:
|
||||
- `POST /api/v1/notifications/push-token`
|
||||
- `DELETE /api/v1/notifications/push-token`
|
||||
- new table `push_device_tokens` (+ Alembic migration `0027_push_device_tokens`).
|
||||
- Added Android push token sync manager:
|
||||
- registers FCM token on app start and after auth refresh/login,
|
||||
- updates backend token on `FirebaseMessagingService.onNewToken`,
|
||||
- unregisters token on logout.
|
||||
- Added backend FCM delivery in Celery notification tasks:
|
||||
- sends to registered user device tokens,
|
||||
- auto-removes invalid/unregistered tokens,
|
||||
- safe fallback logs when Firebase is not configured.
|
||||
|
||||
### Step 105 - Web Firebase push registration
|
||||
- Added web-side Firebase Messaging bootstrap (env-driven, no hardcoded secrets):
|
||||
- fetch web push token and register in backend via `/notifications/push-token`,
|
||||
- unregister token on logout,
|
||||
- handle foreground push payload via existing notification service worker.
|
||||
- Added required env keys to `web/.env.example` and backend Firebase env keys to root `.env.example`.
|
||||
|
||||
### Step 106 - Unread counter stabilization in Chat screen
|
||||
- Fixed read acknowledgement strategy in `ChatViewModel`:
|
||||
- read status is now acknowledged by the latest visible message id in chat (not only latest incoming),
|
||||
- delivery status still uses latest incoming message.
|
||||
- This removes cases where unread badge reappears after chat list refresh because the previous read ack used an outdated incoming id.
|
||||
|
||||
### Step 107 - Read-on-visible + cross-device unread sync
|
||||
- Implemented read acknowledgement from actual visible messages in `ChatScreen`:
|
||||
- tracks visible `LazyColumn` rows and sends read up to max visible incoming message id.
|
||||
- unread now drops as messages appear on screen while scrolling.
|
||||
- Improved cross-device sync (web <-> android):
|
||||
- `message_read` realtime event now parses `user_id` and `last_read_message_id`.
|
||||
- on `message_read`, Android refreshes chat snapshot from backend to keep unread counters aligned across devices.
|
||||
|
||||
### Step 108 - Strict read boundary by visible incoming only
|
||||
- Removed fallback read-pointer advancement in `ChatViewModel.acknowledgeLatestMessages(...)` that previously moved `lastReadMessageId` by latest loaded message id.
|
||||
- Read pointer is now advanced only via `onVisibleIncomingMessageId(...)` from visible incoming rows in `ChatScreen`.
|
||||
- This prevents read acknowledgements from overshooting beyond what user actually saw during refresh/recompose scenarios.
|
||||
|
||||
### Step 109 - Telegram-like Settings/Profile visual refresh
|
||||
- Redesigned `SettingsScreen` to Telegram-inspired dark card layout:
|
||||
- profile header card with avatar/name/email/username,
|
||||
- grouped settings rows with material icons,
|
||||
- appearance controls (Light/Dark/System),
|
||||
- quick security/help sections and preserved logout/back actions.
|
||||
- Redesigned `ProfileScreen` to Telegram-inspired structure:
|
||||
- gradient hero header with centered avatar, status, and action buttons,
|
||||
- primary profile info card,
|
||||
- tab-like section (`Posts/Archived/Gifts`) with placeholder content,
|
||||
- inline edit card (name/username/bio/avatar URL) with existing save/upload behavior preserved.
|
||||
|
||||
### Step 110 - Multi-account foundation (switch active account)
|
||||
- Extended `TokenRepository` to support account list and active-account switching:
|
||||
- observe/list stored accounts,
|
||||
- get active account id,
|
||||
- switch/remove account,
|
||||
- clear all tokens.
|
||||
- Reworked `EncryptedPrefsTokenRepository` storage model:
|
||||
- stores tokens per `userId` and account metadata list in encrypted prefs,
|
||||
- migrates legacy single-account keys on first run,
|
||||
- preserves active account pointer.
|
||||
- `NetworkAuthRepository` now upserts account metadata after auth/me calls.
|
||||
- Added `Settings` UI account section:
|
||||
- shows saved accounts,
|
||||
- allows switch/remove,
|
||||
- triggers auth recheck + chats reload on switch.
|
||||
|
||||
### Step 111 - Real Settings + persistent theme + add-account UX
|
||||
- Implemented persistent app theme storage via DataStore (`ThemeRepository`) and applied theme mode on app start in `MainActivity`.
|
||||
- Reworked `SettingsScreen` to contain only working settings and actions:
|
||||
- multi-account list (`Switch`/`Remove`) + `Add account` dialog with email/password sign-in,
|
||||
- appearance (`Light`/`Dark`/`System`) wired to persisted theme,
|
||||
- notifications (`global` + `preview`) wired to `NotificationSettingsRepository`,
|
||||
- privacy update, blocked users management, sessions revoke/revoke-all, 2FA controls.
|
||||
- Updated `ProfileScreen` to follow current app theme colors instead of forced dark palette.
|
||||
|
||||
### Step 112 - Settings cleanup (privacy dropdowns + removed extra blocks)
|
||||
- Replaced free-text privacy inputs with dropdown selectors (`everyone`, `contacts`, `nobody`) for:
|
||||
- private messages,
|
||||
- last seen,
|
||||
- avatar visibility,
|
||||
- group invites.
|
||||
- Removed direct `block by user id` controls from Settings UI as requested.
|
||||
- Removed extra bottom Settings actions (`Profile` row and `Back to chats` button) and kept categorized section layout.
|
||||
|
||||
### Step 113 - Auth flow redesign (email -> password/register -> 2FA) + startup no-flicker
|
||||
- Added step-based auth domain/use-cases for:
|
||||
- `GET /api/v1/auth/check-email`
|
||||
- `POST /api/v1/auth/register`
|
||||
- login with optional `otp_code` / `recovery_code`.
|
||||
- Updated Android login UI to multi-step flow:
|
||||
- step 1: email input,
|
||||
- step 2: password for existing account or register form (`name`, `username`, `password`) for new account,
|
||||
- step 3: 2FA OTP/recovery code when backend requires it.
|
||||
- Improved login error mapping for 2FA-required responses, so app switches to OTP step instead of generic invalid-password message.
|
||||
- Removed auth screen flash on startup:
|
||||
- introduced dedicated `startup` route with session-check loader,
|
||||
- delayed auth/chats navigation until session check is finished.
|
||||
- Added safe fallback in `MainActivity` theme bootstrap to prevent crash if `ThemeRepository` injection is unexpectedly unavailable during startup.
|
||||
|
||||
### Step 114 - Multi-account switch sync fix (chats + realtime)
|
||||
- Fixed account switch flow to fully rebind app data context:
|
||||
- restart realtime socket on new active account token,
|
||||
- force refresh chats for both `archived=false` and `archived=true` right after switch.
|
||||
- Fixed navigation behavior on account switch to avoid noisy `popBackStack ... not found` and stale restored stack state.
|
||||
|
||||
### Step 115 - Settings UI restructured into Telegram-like folders
|
||||
- Reworked Settings into a menu-first screen with Telegram-style grouped rows.
|
||||
- Added per-item folder pages (subscreens) for:
|
||||
- Account
|
||||
- Chat settings
|
||||
- Privacy
|
||||
- Notifications
|
||||
- Devices
|
||||
- Data/Chat folders/Power/Language placeholders
|
||||
- Kept theme logic intact and moved appearance controls into `Chat settings` folder.
|
||||
|
||||
### Step 116 - Profile cleanup (remove non-working extras)
|
||||
- Removed non-functional profile tabs and placeholder blocks:
|
||||
- `Posts`
|
||||
- `Archived`
|
||||
- `Gifts`
|
||||
- Removed `Settings` hero button from profile header.
|
||||
- Removed bottom `Back to chats` button from profile screen.
|
||||
- Simplified profile layout so the editable profile form is the primary secondary section toggled by `Edit`.
|
||||
- Updated `ProfileRoute` navigation contract to match the simplified screen API.
|
||||
|
||||
### Step 117 - Settings folders cleanup (remove back button action)
|
||||
- Removed `Back to chats` button from all Settings folder pages.
|
||||
- Simplified Settings navigation contract by removing unused `onBackToChats` parameter from:
|
||||
- `SettingsRoute`
|
||||
- `SettingsScreen`
|
||||
- `SettingsFolderView`
|
||||
- Updated `AppNavGraph` Settings destination call-site accordingly.
|
||||
|
||||
### Step 118 - Android push notifications grouped by chat
|
||||
- Reworked `NotificationDispatcher` to aggregate incoming messages into one notification per chat:
|
||||
- stable notification id per `chatId`,
|
||||
- per-chat unread counter,
|
||||
- multi-line inbox preview of recent messages.
|
||||
- Added app-level summary notification that groups all active chat notifications.
|
||||
- Added deduplication guard for repeated push deliveries of the same `messageId`.
|
||||
- Added notification cleanup on chat open:
|
||||
- when push-open intent targets a chat in `MainActivity`,
|
||||
- when `ChatViewModel` enters a chat directly from app UI.
|
||||
|
||||
### Step 119 - Chat screen visual baseline (Telegram-like start)
|
||||
- Reworked chat top bar:
|
||||
- icon back button instead of text button,
|
||||
- cleaner title/subtitle styling,
|
||||
- dedicated search icon in top bar (inline search is now collapsible).
|
||||
- Updated pinned message strip:
|
||||
- cleaner card styling,
|
||||
- close icon action instead of full text button.
|
||||
- Updated composer baseline:
|
||||
- icon-based emoji/attach/send/mic controls,
|
||||
- cleaner container styling closer to Telegram-like bottom bar.
|
||||
|
||||
### Step 120 - Message bubble layout pass (Telegram-like geometry)
|
||||
- Reworked `MessageBubble` structure and density:
|
||||
- cleaner outgoing/incoming bubble geometry,
|
||||
- improved max width and alignment behavior,
|
||||
- tighter paddings and spacing for mobile density.
|
||||
- Redesigned forwarded/reply blocks:
|
||||
- compact forwarded caption styling,
|
||||
- reply block with accent stripe and nested preview text.
|
||||
- Improved message meta line:
|
||||
- cleaner time + status line placement and contrast.
|
||||
- Refined reactions and attachments rendering inside bubbles:
|
||||
- chip-like reaction containers,
|
||||
- rounded image/file/media surfaces closer to Telegram-like visual rhythm.
|
||||
|
||||
### Step 121 - Chat selection and message action UX cleanup
|
||||
- Added Telegram-like multi-select top bar in chat:
|
||||
- close selection,
|
||||
- selected counter,
|
||||
- quick forward/delete actions.
|
||||
- Simplified tap action menu flow for single message:
|
||||
- richer reaction row (`❤️ 👍 👎 🔥 🥰 👏 😁`),
|
||||
- reply/edit/forward/delete actions kept in one sheet.
|
||||
- Removed duplicate/conflicting selection controls between top and bottom action rows.
|
||||
|
||||
### Step 122 - Chat 3-dot menu + chat info media tabs shell
|
||||
- Added chat header `3-dot` popup menu with Telegram-like actions:
|
||||
- `Chat info`
|
||||
- `Search`
|
||||
- `Notifications`
|
||||
- `Change wallpaper`
|
||||
- `Clear history`
|
||||
- Added `Chat info` bottom sheet with tabbed sections:
|
||||
- `Media`
|
||||
- `Files`
|
||||
- `Links`
|
||||
- `Voice`
|
||||
- Implemented local tab content from current loaded chat messages/attachments to provide immediate media/files/links/voice overview.
|
||||
|
||||
### Step 123 - Chat info visual pass (Telegram-like density)
|
||||
- Updated `Chat info` tabs to pill-style horizontal chips with tighter Telegram-like spacing.
|
||||
- Improved tab content rendering:
|
||||
- `Media` now uses a 3-column thumbnail grid.
|
||||
- `Files / Links / Voice` use denser card rows with icon+meta layout.
|
||||
- `Voice` rows now show a dedicated play affordance.
|
||||
- Refined menu order in chat `3-dot` popup and kept actions consistent with current no-calls scope.
|
||||
|
||||
### Step 124 - Inline search close fix + message menu visual pass
|
||||
- Fixed inline chat search UX:
|
||||
- added explicit close button in the search row,
|
||||
- closing search now also clears active query/filter without re-entering chat.
|
||||
- Added automatic inline-search collapse when entering multi-select mode.
|
||||
- Reworked message tap action sheet to Telegram-like list actions with icons and clearer destructive `Delete` row styling.
|
||||
|
||||
### Step 125 - Chat header/top strips visual refinement
|
||||
- Refined chat header density and typography to be closer to Telegram-like proportions.
|
||||
- Updated pinned strip visual:
|
||||
- accent vertical marker,
|
||||
- tighter spacing,
|
||||
- cleaner title/content hierarchy.
|
||||
- Added top mini audio strip under pinned area:
|
||||
- shows latest audio/voice context from loaded chat,
|
||||
- includes play affordance, speed badge, and dismiss action.
|
||||
|
||||
### Step 126 - Message bubble/composer micro-polish
|
||||
- Updated message bubble sizing and density:
|
||||
- reduced bubble width for cleaner conversation rhythm,
|
||||
- tighter vertical spacing,
|
||||
- text style adjusted for better readability.
|
||||
- Refined bottom composer visuals:
|
||||
- switched to Telegram-like rounded input container look,
|
||||
- emoji/attach/send buttons now use circular tinted surfaces,
|
||||
- text input moved to filled style with hidden indicator lines.
|
||||
|
||||
### Step 127 - Top audio strip behavior fix (playback-driven)
|
||||
- Reworked top audio strip logic to be playback-driven instead of always-on:
|
||||
- strip appears only when user starts audio/voice playback,
|
||||
- strip switches to the currently playing file,
|
||||
- strip auto-hides when playback stops.
|
||||
- Added close (`X`) behavior that hides the strip and force-stops the currently playing source.
|
||||
|
||||
### Step 128 - Parity docs update: text formatting gap
|
||||
- Synced Android parity documentation with web-core status:
|
||||
- added explicit `Text formatting parity` gap to `docs/android-checklist.md`,
|
||||
- added dedicated Android gap block in `docs/backend-web-android-parity.md` for formatting parity.
|
||||
- Marked formatting parity as part of highest-priority Android parity block.
|
||||
|
||||
### Step 129 - Parity block (1/3/4/5/6): formatting, notifications inbox, resend verification, push sync
|
||||
- Completed Android text formatting parity in chat:
|
||||
- composer toolbar actions for `bold/italic/underline/strikethrough`,
|
||||
- spoiler, inline code, code block, quote, link insertion,
|
||||
- message bubble rich renderer for web-style markdown tokens and clickable links.
|
||||
- Added server notifications inbox flow in account/settings:
|
||||
- API wiring for `GET /api/v1/notifications`,
|
||||
- domain mapping and recent-notifications UI section.
|
||||
- Added resend verification support on Android:
|
||||
- API wiring for `POST /api/v1/auth/resend-verification`,
|
||||
- Verify Email screen action for resending link by email.
|
||||
- Hardened push token lifecycle sync:
|
||||
- token registration dedupe by `(userId, token)`,
|
||||
- marker cleanup on logout,
|
||||
- best-effort re-sync after account switch.
|
||||
- Notification delivery polish (foundation):
|
||||
- foreground notification body now respects preview setting and falls back to media-type labels when previews are disabled.
|
||||
- Verified with successful `:app:compileDebugKotlin` and `:app:assembleDebug`.
|
||||
|
||||
### Step 130 - Chat UX pack: video viewer, emoji/GIF/sticker picker, day separators
|
||||
- Added chat timeline day separators with Telegram-like chips:
|
||||
- `Сегодня`, `Вчера`, or localized date labels.
|
||||
- Added fullscreen video viewer:
|
||||
- video attachments now open in a fullscreen overlay with close action.
|
||||
- Added composer media picker sheet:
|
||||
- tabs: `Эмодзи`, `GIF`, `Стикеры`,
|
||||
- emoji insertion at cursor,
|
||||
- remote GIF/sticker selection with download+send flow.
|
||||
- Extended media type mapping in message send pipeline:
|
||||
- GIFs now sent as `gif`,
|
||||
- sticker-like payloads sent as `sticker` (filename/mime detection).
|
||||
- Added missing fallback resource theme declarations (`themes.xml`) to stabilize clean debug assembly on local builds.
|
||||
|
||||
### Step 131 - Channel chat Telegram-like visual alignment
|
||||
- Added channel-aware chat rendering path:
|
||||
- `MessageUiState` now carries `chatType` from `ChatViewModel`,
|
||||
- channel timeline bubbles are rendered as wider post-like cards (left-aligned feed style).
|
||||
- Refined channel message status presentation:
|
||||
- post cards now show cleaner timestamp-only footer instead of direct-message style checks.
|
||||
- Added dedicated read-only channel bottom bar (for non owner/admin):
|
||||
- compact Telegram-like controls with search + centered `Включить звук` action + notifications icon.
|
||||
- Kept existing full composer for roles allowed to post in channels (owner/admin).
|
||||
|
||||
### Step 132 - Voice recording composer overlap fix
|
||||
- Fixed composer overlap during voice recording:
|
||||
- recording status/hint is now rendered in a dedicated top block inside composer,
|
||||
- formatting toolbar is hidden while recording is active.
|
||||
- Prevented controls collision for locked-recording actions:
|
||||
- `Cancel/Send` now render on a separate row in locked state.
|
||||
|
||||
### Step 133 - Video/audio player controls upgrade
|
||||
- Upgraded fullscreen video viewer controls:
|
||||
- play/pause button,
|
||||
- seek slider (scrubbing),
|
||||
- current time / total duration labels.
|
||||
- Upgraded attachment audio player behavior (voice + audio):
|
||||
- added seek slider for manual rewind/fast-forward,
|
||||
- unified speed toggle for both `voice` and `audio` playback.
|
||||
|
||||
### Step 134 - Hilt startup crash fix (`MessengerApplication_GeneratedInjector`)
|
||||
- Fixed startup crash:
|
||||
- `NoClassDefFoundError: MessengerApplication_GeneratedInjector`.
|
||||
- Root cause observed in build pipeline:
|
||||
- `MessengerApplication_GeneratedInjector.class` existed after `javac`,
|
||||
- but was missing in `transformDebugClassesWithAsm/dirs` before dexing.
|
||||
- Added Gradle backfill task for `debug/release` variants:
|
||||
- copies `*Application_GeneratedInjector.class` from `intermediates/javac/.../classes`
|
||||
into `intermediates/classes/.../transform...ClassesWithAsm/dirs` if missing,
|
||||
- wired task as dependency of `dexBuilder<Variant>`.
|
||||
|
||||
### Step 135 - AppCompat launch crash fix (theme mismatch)
|
||||
- Fixed `MainActivity` startup crash:
|
||||
- `IllegalStateException: You need to use a Theme.AppCompat theme`.
|
||||
- Root cause:
|
||||
- `Theme.AppCompat.DayNight.NoActionBar` was accidentally overridden in app resources
|
||||
with non-AppCompat parent (`Theme.DeviceDefault.NoActionBar`).
|
||||
- Fix applied:
|
||||
- introduced dedicated app theme `Theme.Messenger` with parent `Theme.AppCompat.DayNight.NoActionBar`,
|
||||
- switched `AndroidManifest.xml` application theme to `@style/Theme.Messenger`.
|
||||
|
||||
### Step 136 - Message context menu dismiss selection fix
|
||||
- Fixed chat bug after closing message context menu by tapping outside:
|
||||
- selection state now clears on `ModalBottomSheet` dismiss,
|
||||
- prevents stale single-selection action bar from appearing after menu close.
|
||||
|
||||
### Step 137 - Telegram-like message actions cleanup
|
||||
- Removed legacy single-selection bottom action bar (`Close/Delete/Del for all/Edit`) in chat.
|
||||
- Message actions are now driven by Telegram-like context UI:
|
||||
- tap -> context sheet actions,
|
||||
- long-press -> selection mode flow.
|
||||
|
||||
### Step 138 - Multi-select UX closer to Telegram
|
||||
- Refined selection top bar:
|
||||
- removed extra overflow/load action from selection mode,
|
||||
- kept focused actions only: close, selected count, forward, delete.
|
||||
- In `MULTI` selection mode, composer is now replaced with a compact bottom action row:
|
||||
- `Reply` (enabled for single selected message),
|
||||
- `Forward`.
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import java.util.Properties
|
||||
|
||||
plugins {
|
||||
id("com.android.application")
|
||||
id("org.jetbrains.kotlin.android")
|
||||
@@ -6,8 +8,18 @@ plugins {
|
||||
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
|
||||
@@ -19,6 +31,16 @@ android {
|
||||
versionCode = 1
|
||||
versionName = "0.1.0"
|
||||
buildConfigField("String", "API_BASE_URL", "\"https://chat.daemonlord.ru/\"")
|
||||
buildConfigField("String", "API_VERSION_HEADER", "\"2026-03\"")
|
||||
val giphyApiKey = (
|
||||
localProperties.getProperty("GIPHY_API_KEY")
|
||||
?: System.getenv("GIPHY_API_KEY")
|
||||
?: ""
|
||||
).trim()
|
||||
buildConfigField("String", "GIPHY_API_KEY", "\"${giphyApiKey.escapeForBuildConfig()}\"")
|
||||
buildConfigField("boolean", "FEATURE_ACCOUNT_MANAGEMENT", "true")
|
||||
buildConfigField("boolean", "FEATURE_TWO_FACTOR", "true")
|
||||
buildConfigField("boolean", "FEATURE_MEDIA_GALLERY", "true")
|
||||
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
vectorDrawables {
|
||||
@@ -62,9 +84,11 @@ android {
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(project(":core:common"))
|
||||
implementation(platform("com.google.firebase:firebase-bom:34.10.0"))
|
||||
|
||||
implementation("androidx.core:core-ktx:1.15.0")
|
||||
implementation("androidx.appcompat:appcompat:1.7.0")
|
||||
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.8.7")
|
||||
implementation("androidx.lifecycle:lifecycle-runtime-compose:2.8.7")
|
||||
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.8.7")
|
||||
@@ -74,11 +98,20 @@ dependencies {
|
||||
implementation("androidx.compose.ui:ui:1.7.6")
|
||||
implementation("androidx.compose.ui:ui-tooling-preview:1.7.6")
|
||||
implementation("androidx.compose.material3:material3:1.3.1")
|
||||
implementation("androidx.compose.material:material-icons-extended:1.7.6")
|
||||
implementation("io.coil-kt:coil:2.7.0")
|
||||
implementation("io.coil-kt:coil-compose:2.7.0")
|
||||
implementation("io.coil-kt:coil-gif:2.7.0")
|
||||
implementation("io.coil-kt:coil-video:2.7.0")
|
||||
implementation("androidx.media3:media3-exoplayer:1.4.1")
|
||||
implementation("androidx.media3:media3-ui:1.4.1")
|
||||
implementation("androidx.media3:media3-datasource:1.4.1")
|
||||
implementation("androidx.media3:media3-datasource-okhttp:1.4.1")
|
||||
implementation("androidx.camera:camera-core:1.4.2")
|
||||
implementation("androidx.camera:camera-camera2:1.4.2")
|
||||
implementation("androidx.camera:camera-lifecycle:1.4.2")
|
||||
implementation("androidx.camera:camera-video:1.4.2")
|
||||
implementation("androidx.camera:camera-view:1.4.2")
|
||||
|
||||
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0")
|
||||
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3")
|
||||
@@ -98,6 +131,8 @@ dependencies {
|
||||
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")
|
||||
@@ -117,3 +152,37 @@ dependencies {
|
||||
kapt {
|
||||
correctErrorTypes = true
|
||||
}
|
||||
|
||||
fun registerHiltInjectorBackfillTask(variantName: String) {
|
||||
val cap = variantName.replaceFirstChar { if (it.isLowerCase()) it.titlecase() else it.toString() }
|
||||
val taskName = "backfill${cap}HiltApplicationInjector"
|
||||
val dexBuilderTaskName = "dexBuilder$cap"
|
||||
val compileJavaTaskName = "compile${cap}JavaWithJavac"
|
||||
|
||||
tasks.register(taskName) {
|
||||
dependsOn(compileJavaTaskName)
|
||||
doLast {
|
||||
val javacOutput = file("$buildDir/intermediates/javac/$variantName/$compileJavaTaskName/classes")
|
||||
val asmOutput = file("$buildDir/intermediates/classes/$variantName/transform${cap}ClassesWithAsm/dirs")
|
||||
if (!javacOutput.exists() || !asmOutput.exists()) return@doLast
|
||||
|
||||
fileTree(javacOutput) {
|
||||
include("**/*Application_GeneratedInjector.class")
|
||||
}.forEach { source ->
|
||||
val relativePath = source.relativeTo(javacOutput).path
|
||||
val target = file("${asmOutput.path}/$relativePath")
|
||||
if (!target.exists()) {
|
||||
target.parentFile?.mkdirs()
|
||||
source.copyTo(target, overwrite = true)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
tasks.matching { it.name == dexBuilderTaskName }.configureEach {
|
||||
dependsOn(taskName)
|
||||
}
|
||||
}
|
||||
|
||||
registerHiltInjectorBackfillTask("debug")
|
||||
registerHiltInjectorBackfillTask("release")
|
||||
|
||||
@@ -3,16 +3,19 @@
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||
<uses-permission android:name="android.permission.CAMERA" />
|
||||
|
||||
<application
|
||||
android:name=".MessengerApplication"
|
||||
android:allowBackup="true"
|
||||
android:enableOnBackInvokedCallback="true"
|
||||
android:icon="@android:drawable/sym_def_app_icon"
|
||||
android:label="@string/app_name"
|
||||
android:roundIcon="@android:drawable/sym_def_app_icon"
|
||||
android:supportsRtl="true"
|
||||
android:usesCleartextTraffic="true"
|
||||
android:theme="@android:style/Theme.Material.Light.NoActionBar">
|
||||
android:theme="@style/Theme.Messenger">
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true">
|
||||
@@ -28,6 +31,14 @@
|
||||
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
|
||||
@@ -37,6 +48,15 @@
|
||||
<action android:name="com.google.firebase.MESSAGING_EVENT" />
|
||||
</intent-filter>
|
||||
</service>
|
||||
<provider
|
||||
android:name="androidx.core.content.FileProvider"
|
||||
android:authorities="${applicationId}.fileprovider"
|
||||
android:exported="false"
|
||||
android:grantUriPermissions="true">
|
||||
<meta-data
|
||||
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||
android:resource="@xml/file_paths" />
|
||||
</provider>
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
|
||||
@@ -2,10 +2,8 @@ package ru.daemonlord.messenger
|
||||
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
@@ -13,29 +11,76 @@ import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.appcompat.app.AppCompatDelegate
|
||||
import androidx.core.os.LocaleListCompat
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import ru.daemonlord.messenger.core.notifications.NotificationDispatcher
|
||||
import ru.daemonlord.messenger.core.notifications.NotificationIntentExtras
|
||||
import ru.daemonlord.messenger.domain.settings.repository.LanguageRepository
|
||||
import ru.daemonlord.messenger.domain.settings.model.AppThemeMode
|
||||
import ru.daemonlord.messenger.domain.settings.repository.ThemeRepository
|
||||
import ru.daemonlord.messenger.ui.navigation.MessengerNavHost
|
||||
import ru.daemonlord.messenger.ui.theme.MessengerTheme
|
||||
import javax.inject.Inject
|
||||
|
||||
@AndroidEntryPoint
|
||||
class MainActivity : ComponentActivity() {
|
||||
class MainActivity : AppCompatActivity() {
|
||||
@Inject
|
||||
lateinit var themeRepository: ThemeRepository
|
||||
|
||||
@Inject
|
||||
lateinit var languageRepository: LanguageRepository
|
||||
|
||||
@Inject
|
||||
lateinit var notificationDispatcher: NotificationDispatcher
|
||||
|
||||
private var pendingInviteToken by mutableStateOf<String?>(null)
|
||||
private var pendingVerifyEmailToken by mutableStateOf<String?>(null)
|
||||
private var pendingResetPasswordToken by mutableStateOf<String?>(null)
|
||||
private var pendingNotificationChatId by mutableStateOf<Long?>(null)
|
||||
private var pendingNotificationMessageId by mutableStateOf<Long?>(null)
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
val savedThemeMode = if (this::themeRepository.isInitialized) {
|
||||
runBlocking { themeRepository.getThemeMode() }
|
||||
} else {
|
||||
AppThemeMode.SYSTEM
|
||||
}
|
||||
AppCompatDelegate.setDefaultNightMode(
|
||||
when (savedThemeMode) {
|
||||
AppThemeMode.LIGHT -> AppCompatDelegate.MODE_NIGHT_NO
|
||||
AppThemeMode.DARK -> AppCompatDelegate.MODE_NIGHT_YES
|
||||
AppThemeMode.SYSTEM -> AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM
|
||||
}
|
||||
)
|
||||
val savedLanguageTag = if (this::languageRepository.isInitialized) {
|
||||
runBlocking { languageRepository.getLanguage().tag }
|
||||
} else {
|
||||
null
|
||||
}
|
||||
val locales = savedLanguageTag?.let { LocaleListCompat.forLanguageTags(it) } ?: LocaleListCompat.getEmptyLocaleList()
|
||||
AppCompatDelegate.setApplicationLocales(locales)
|
||||
pendingInviteToken = intent.extractInviteToken()
|
||||
pendingVerifyEmailToken = intent.extractVerifyEmailToken()
|
||||
pendingResetPasswordToken = intent.extractResetPasswordToken()
|
||||
val notificationPayload = intent.extractNotificationOpenPayload()
|
||||
pendingNotificationChatId = notificationPayload?.first
|
||||
pendingNotificationMessageId = notificationPayload?.second
|
||||
notificationPayload?.first?.let(notificationDispatcher::clearChatNotifications)
|
||||
enableEdgeToEdge()
|
||||
setContent {
|
||||
MaterialTheme {
|
||||
MessengerTheme {
|
||||
Surface(modifier = Modifier.fillMaxSize()) {
|
||||
AppRoot(
|
||||
inviteToken = pendingInviteToken,
|
||||
onInviteTokenConsumed = { pendingInviteToken = null },
|
||||
verifyEmailToken = pendingVerifyEmailToken,
|
||||
onVerifyEmailTokenConsumed = { pendingVerifyEmailToken = null },
|
||||
resetPasswordToken = pendingResetPasswordToken,
|
||||
onResetPasswordTokenConsumed = { pendingResetPasswordToken = null },
|
||||
notificationChatId = pendingNotificationChatId,
|
||||
notificationMessageId = pendingNotificationMessageId,
|
||||
onNotificationConsumed = {
|
||||
@@ -52,10 +97,13 @@ class MainActivity : ComponentActivity() {
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -64,6 +112,10 @@ class MainActivity : ComponentActivity() {
|
||||
private fun AppRoot(
|
||||
inviteToken: String?,
|
||||
onInviteTokenConsumed: () -> Unit,
|
||||
verifyEmailToken: String?,
|
||||
onVerifyEmailTokenConsumed: () -> Unit,
|
||||
resetPasswordToken: String?,
|
||||
onResetPasswordTokenConsumed: () -> Unit,
|
||||
notificationChatId: Long?,
|
||||
notificationMessageId: Long?,
|
||||
onNotificationConsumed: () -> Unit,
|
||||
@@ -71,12 +123,30 @@ private fun AppRoot(
|
||||
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()
|
||||
|
||||
@@ -1,19 +1,35 @@
|
||||
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 {
|
||||
@@ -22,6 +38,13 @@ class MessengerApplication : Application(), ImageLoaderFactory {
|
||||
diskCacheDir.mkdirs()
|
||||
}
|
||||
return ImageLoader.Builder(this)
|
||||
.components {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
|
||||
add(ImageDecoderDecoder.Factory())
|
||||
} else {
|
||||
add(GifDecoder.Factory())
|
||||
}
|
||||
}
|
||||
.memoryCache {
|
||||
MemoryCache.Builder(this)
|
||||
.maxSizePercent(0.25)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,8 @@ import kotlin.math.abs
|
||||
class NotificationDispatcher @Inject constructor(
|
||||
@ApplicationContext private val context: Context,
|
||||
) {
|
||||
private val chatStates = linkedMapOf<Long, ChatNotificationState>()
|
||||
|
||||
fun showChatMessage(payload: ChatNotificationPayload) {
|
||||
NotificationChannels.ensureCreated(context)
|
||||
val channelId = if (payload.isMention) {
|
||||
@@ -22,34 +24,126 @@ class NotificationDispatcher @Inject constructor(
|
||||
} else {
|
||||
NotificationChannels.CHANNEL_MESSAGES
|
||||
}
|
||||
|
||||
val state = synchronized(chatStates) {
|
||||
val existing = chatStates[payload.chatId]
|
||||
if (existing != null && payload.messageId != null && existing.lastMessageId == payload.messageId) {
|
||||
return
|
||||
}
|
||||
val updated = (existing ?: ChatNotificationState(title = payload.title))
|
||||
.copy(title = payload.title)
|
||||
.appendMessage(payload.body, payload.messageId)
|
||||
chatStates[payload.chatId] = updated
|
||||
updated
|
||||
}
|
||||
|
||||
val openIntent = Intent(context, MainActivity::class.java)
|
||||
.putExtra(NotificationIntentExtras.EXTRA_CHAT_ID, payload.chatId)
|
||||
.putExtra(NotificationIntentExtras.EXTRA_MESSAGE_ID, payload.messageId ?: -1L)
|
||||
.addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP)
|
||||
val pendingIntent = PendingIntent.getActivity(
|
||||
context,
|
||||
notificationId(payload.chatId, payload.messageId),
|
||||
chatNotificationId(payload.chatId),
|
||||
openIntent,
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
||||
)
|
||||
|
||||
val contentText = when {
|
||||
state.unreadCount <= 1 -> state.lines.firstOrNull() ?: payload.body
|
||||
else -> "${state.unreadCount} new messages"
|
||||
}
|
||||
val inboxStyle = NotificationCompat.InboxStyle()
|
||||
.setBigContentTitle(state.title)
|
||||
.setSummaryText("${state.unreadCount} messages")
|
||||
state.lines.reversed().forEach { inboxStyle.addLine(it) }
|
||||
|
||||
val notification = NotificationCompat.Builder(context, channelId)
|
||||
.setSmallIcon(android.R.drawable.ic_dialog_info)
|
||||
.setContentTitle(payload.title)
|
||||
.setContentText(payload.body)
|
||||
.setStyle(NotificationCompat.BigTextStyle().bigText(payload.body))
|
||||
.setContentTitle(state.title)
|
||||
.setContentText(contentText)
|
||||
.setStyle(inboxStyle)
|
||||
.setAutoCancel(true)
|
||||
.setContentIntent(pendingIntent)
|
||||
.setGroup("chat_${payload.chatId}")
|
||||
.setGroup(GROUP_KEY_CHATS)
|
||||
.setOnlyAlertOnce(false)
|
||||
.setNumber(state.unreadCount)
|
||||
.setPriority(
|
||||
if (payload.isMention) NotificationCompat.PRIORITY_HIGH else NotificationCompat.PRIORITY_DEFAULT
|
||||
)
|
||||
.build()
|
||||
|
||||
NotificationManagerCompat.from(context).notify(notificationId(payload.chatId, payload.messageId), notification)
|
||||
val manager = NotificationManagerCompat.from(context)
|
||||
manager.notify(chatNotificationId(payload.chatId), notification)
|
||||
showSummaryNotification(manager)
|
||||
}
|
||||
|
||||
private fun notificationId(chatId: Long, messageId: Long?): Int {
|
||||
val raw = (chatId * 1_000_003L) + (messageId ?: 0L)
|
||||
return abs(raw.toInt())
|
||||
fun clearChatNotifications(chatId: Long) {
|
||||
val manager = NotificationManagerCompat.from(context)
|
||||
synchronized(chatStates) {
|
||||
chatStates.remove(chatId)
|
||||
}
|
||||
manager.cancel(chatNotificationId(chatId))
|
||||
showSummaryNotification(manager)
|
||||
}
|
||||
|
||||
private fun showSummaryNotification(manager: NotificationManagerCompat) {
|
||||
val snapshot = synchronized(chatStates) { chatStates.values.toList() }
|
||||
if (snapshot.isEmpty()) {
|
||||
manager.cancel(SUMMARY_NOTIFICATION_ID)
|
||||
return
|
||||
}
|
||||
val totalUnread = snapshot.sumOf { it.unreadCount }
|
||||
val inboxStyle = NotificationCompat.InboxStyle()
|
||||
.setBigContentTitle("Benya Messenger")
|
||||
.setSummaryText("$totalUnread messages")
|
||||
snapshot.take(6).forEach { state ->
|
||||
val preview = state.lines.firstOrNull().orEmpty()
|
||||
val line = if (preview.isBlank()) {
|
||||
"${state.title} (${state.unreadCount})"
|
||||
} else {
|
||||
"${state.title}: $preview (${state.unreadCount})"
|
||||
}
|
||||
inboxStyle.addLine(line)
|
||||
}
|
||||
val summary = NotificationCompat.Builder(context, NotificationChannels.CHANNEL_MESSAGES)
|
||||
.setSmallIcon(android.R.drawable.ic_dialog_info)
|
||||
.setContentTitle("Benya Messenger")
|
||||
.setContentText("$totalUnread new messages in ${snapshot.size} chats")
|
||||
.setStyle(inboxStyle)
|
||||
.setGroup(GROUP_KEY_CHATS)
|
||||
.setGroupSummary(true)
|
||||
.setAutoCancel(true)
|
||||
.build()
|
||||
manager.notify(SUMMARY_NOTIFICATION_ID, summary)
|
||||
}
|
||||
|
||||
private fun chatNotificationId(chatId: Long): Int {
|
||||
return abs((chatId * 1_000_003L).toInt())
|
||||
}
|
||||
|
||||
private data class ChatNotificationState(
|
||||
val title: String,
|
||||
val unreadCount: Int = 0,
|
||||
val lines: List<String> = emptyList(),
|
||||
val lastMessageId: Long? = null,
|
||||
) {
|
||||
fun appendMessage(body: String, messageId: Long?): ChatNotificationState {
|
||||
val normalized = body.trim().ifBlank { "New message" }
|
||||
val updatedLines = buildList {
|
||||
add(normalized)
|
||||
lines.forEach { add(it) }
|
||||
}.distinct().take(MAX_LINES)
|
||||
return copy(
|
||||
unreadCount = unreadCount + 1,
|
||||
lines = updatedLines,
|
||||
lastMessageId = messageId ?: lastMessageId,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
private const val GROUP_KEY_CHATS = "messenger_chats_group"
|
||||
private const val SUMMARY_NOTIFICATION_ID = 0x4D53_4752 // "MSGR"
|
||||
private const val MAX_LINES = 5
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,10 +20,40 @@ class DataStoreTokenRepository @Inject constructor(
|
||||
preferences.toTokenBundleOrNull()
|
||||
}
|
||||
|
||||
override fun observeAccounts(): Flow<List<StoredAccount>> {
|
||||
return observeTokens().map { tokens ->
|
||||
if (tokens == null) emptyList() else {
|
||||
val userId = tokens.accessToken.extractUserIdFromJwt() ?: return@map emptyList()
|
||||
listOf(
|
||||
StoredAccount(
|
||||
userId = userId,
|
||||
email = null,
|
||||
name = "User #$userId",
|
||||
username = null,
|
||||
avatarUrl = null,
|
||||
lastActiveAt = tokens.savedAtMillis,
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun observeActiveUserId(): Flow<Long?> {
|
||||
return observeTokens().map { it?.accessToken?.extractUserIdFromJwt() }
|
||||
}
|
||||
|
||||
override suspend fun getTokens(): TokenBundle? {
|
||||
return observeTokens().first()
|
||||
}
|
||||
|
||||
override suspend fun getAccounts(): List<StoredAccount> {
|
||||
return observeAccounts().first()
|
||||
}
|
||||
|
||||
override suspend fun getActiveUserId(): Long? {
|
||||
return observeActiveUserId().first()
|
||||
}
|
||||
|
||||
override suspend fun saveTokens(tokens: TokenBundle) {
|
||||
dataStore.edit { preferences ->
|
||||
preferences[ACCESS_TOKEN_KEY] = tokens.accessToken
|
||||
@@ -32,6 +62,20 @@ class DataStoreTokenRepository @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun upsertAccount(account: StoredAccount) {
|
||||
// DataStoreTokenRepository is not used in production DI currently.
|
||||
}
|
||||
|
||||
override suspend fun switchAccount(userId: Long): Boolean {
|
||||
return getActiveUserId() == userId
|
||||
}
|
||||
|
||||
override suspend fun removeAccount(userId: Long) {
|
||||
if (getActiveUserId() == userId) {
|
||||
clearTokens()
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun clearTokens() {
|
||||
dataStore.edit { preferences ->
|
||||
preferences.remove(ACCESS_TOKEN_KEY)
|
||||
@@ -40,6 +84,10 @@ class DataStoreTokenRepository @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun clearAllTokens() {
|
||||
clearTokens()
|
||||
}
|
||||
|
||||
private fun Preferences.toTokenBundleOrNull(): TokenBundle? {
|
||||
val access = this[ACCESS_TOKEN_KEY]
|
||||
val refresh = this[REFRESH_TOKEN_KEY]
|
||||
@@ -56,6 +104,32 @@ class DataStoreTokenRepository @Inject constructor(
|
||||
)
|
||||
}
|
||||
|
||||
private fun String.extractUserIdFromJwt(): Long? {
|
||||
val payload = split('.').getOrNull(1) ?: return null
|
||||
val normalized = payload
|
||||
.replace('-', '+')
|
||||
.replace('_', '/')
|
||||
.let { source ->
|
||||
when (source.length % 4) {
|
||||
0 -> source
|
||||
2 -> source + "=="
|
||||
3 -> source + "="
|
||||
else -> return null
|
||||
}
|
||||
}
|
||||
return runCatching {
|
||||
val json = String(java.util.Base64.getDecoder().decode(normalized), Charsets.UTF_8)
|
||||
val marker = "\"sub\":\""
|
||||
val start = json.indexOf(marker)
|
||||
if (start < 0) null
|
||||
else {
|
||||
val valueStart = start + marker.length
|
||||
val valueEnd = json.indexOf('"', valueStart)
|
||||
if (valueEnd <= valueStart) null else json.substring(valueStart, valueEnd).toLongOrNull()
|
||||
}
|
||||
}.getOrNull()
|
||||
}
|
||||
|
||||
private companion object {
|
||||
val ACCESS_TOKEN_KEY = stringPreferencesKey("access_token")
|
||||
val REFRESH_TOKEN_KEY = stringPreferencesKey("refresh_token")
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
package ru.daemonlord.messenger.core.token
|
||||
|
||||
import android.content.SharedPreferences
|
||||
import android.util.Base64
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import ru.daemonlord.messenger.di.TokenPrefs
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
@@ -13,48 +16,277 @@ class EncryptedPrefsTokenRepository @Inject constructor(
|
||||
@TokenPrefs private val sharedPreferences: SharedPreferences,
|
||||
) : TokenRepository {
|
||||
|
||||
private val tokensFlow = MutableStateFlow(readTokens())
|
||||
private val tokensFlow = MutableStateFlow<TokenBundle?>(null)
|
||||
private val accountsFlow = MutableStateFlow<List<StoredAccount>>(emptyList())
|
||||
private val activeUserIdFlow = MutableStateFlow<Long?>(null)
|
||||
|
||||
init {
|
||||
migrateLegacyIfNeeded()
|
||||
refreshFlows()
|
||||
}
|
||||
|
||||
override fun observeTokens(): Flow<TokenBundle?> = tokensFlow.asStateFlow()
|
||||
|
||||
override fun observeAccounts(): Flow<List<StoredAccount>> = accountsFlow.asStateFlow()
|
||||
|
||||
override fun observeActiveUserId(): Flow<Long?> = activeUserIdFlow.asStateFlow()
|
||||
|
||||
override suspend fun getTokens(): TokenBundle? = tokensFlow.value
|
||||
|
||||
override suspend fun getAccounts(): List<StoredAccount> = accountsFlow.value
|
||||
|
||||
override suspend fun getActiveUserId(): Long? = activeUserIdFlow.value
|
||||
|
||||
override suspend fun saveTokens(tokens: TokenBundle) {
|
||||
sharedPreferences.edit()
|
||||
.putString(ACCESS_TOKEN_KEY, tokens.accessToken)
|
||||
.putString(REFRESH_TOKEN_KEY, tokens.refreshToken)
|
||||
.putLong(SAVED_AT_KEY, tokens.savedAtMillis)
|
||||
.apply()
|
||||
tokensFlow.value = tokens
|
||||
val userId = tokens.accessToken.extractUserIdFromJwt()
|
||||
?: activeUserIdFlow.value
|
||||
?: return
|
||||
val allTokens = readAllTokenEntries().toMutableMap()
|
||||
allTokens[userId] = tokens
|
||||
writeAllTokenEntries(allTokens)
|
||||
writeActiveUserId(userId)
|
||||
ensureAccountPlaceholder(userId = userId, lastActiveAt = tokens.savedAtMillis)
|
||||
refreshFlows()
|
||||
}
|
||||
|
||||
override suspend fun upsertAccount(account: StoredAccount) {
|
||||
val accounts = readAccounts().associateBy { it.userId }.toMutableMap()
|
||||
val existing = accounts[account.userId]
|
||||
accounts[account.userId] = account.copy(
|
||||
lastActiveAt = maxOf(existing?.lastActiveAt ?: 0L, account.lastActiveAt),
|
||||
)
|
||||
writeAccounts(accounts.values.toList())
|
||||
refreshFlows()
|
||||
}
|
||||
|
||||
override suspend fun switchAccount(userId: Long): Boolean {
|
||||
val allTokens = readAllTokenEntries()
|
||||
if (!allTokens.containsKey(userId)) {
|
||||
return false
|
||||
}
|
||||
writeActiveUserId(userId)
|
||||
refreshFlows()
|
||||
return true
|
||||
}
|
||||
|
||||
override suspend fun removeAccount(userId: Long) {
|
||||
val allTokens = readAllTokenEntries().toMutableMap()
|
||||
allTokens.remove(userId)
|
||||
writeAllTokenEntries(allTokens)
|
||||
|
||||
val accounts = readAccounts().filterNot { it.userId == userId }
|
||||
writeAccounts(accounts)
|
||||
|
||||
val active = readActiveUserId()
|
||||
if (active == userId) {
|
||||
val nextUserId = allTokens.entries
|
||||
.maxByOrNull { it.value.savedAtMillis }
|
||||
?.key
|
||||
writeActiveUserId(nextUserId)
|
||||
}
|
||||
refreshFlows()
|
||||
}
|
||||
|
||||
override suspend fun clearTokens() {
|
||||
val active = readActiveUserId() ?: return
|
||||
removeAccount(active)
|
||||
}
|
||||
|
||||
override suspend fun clearAllTokens() {
|
||||
sharedPreferences.edit()
|
||||
.remove(TOKENS_JSON_KEY)
|
||||
.remove(ACCOUNTS_JSON_KEY)
|
||||
.remove(ACTIVE_USER_ID_KEY)
|
||||
.remove(ACCESS_TOKEN_KEY)
|
||||
.remove(REFRESH_TOKEN_KEY)
|
||||
.remove(SAVED_AT_KEY)
|
||||
.apply()
|
||||
refreshFlows()
|
||||
}
|
||||
|
||||
private fun refreshFlows() {
|
||||
val activeUserId = readActiveUserId()
|
||||
activeUserIdFlow.value = activeUserId
|
||||
tokensFlow.value = activeUserId?.let { readAllTokenEntries()[it] }
|
||||
accountsFlow.value = readAccounts().sortedByDescending { it.lastActiveAt }
|
||||
}
|
||||
|
||||
private fun migrateLegacyIfNeeded() {
|
||||
val hasModernStorage = sharedPreferences.contains(TOKENS_JSON_KEY)
|
||||
if (hasModernStorage) return
|
||||
|
||||
val legacyAccess = sharedPreferences.getString(ACCESS_TOKEN_KEY, null)
|
||||
val legacyRefresh = sharedPreferences.getString(REFRESH_TOKEN_KEY, null)
|
||||
val legacySavedAt = sharedPreferences.getLong(SAVED_AT_KEY, -1L)
|
||||
if (legacyAccess.isNullOrBlank() || legacyRefresh.isNullOrBlank() || legacySavedAt <= 0L) {
|
||||
return
|
||||
}
|
||||
|
||||
val userId = legacyAccess.extractUserIdFromJwt() ?: return
|
||||
val token = TokenBundle(
|
||||
accessToken = legacyAccess,
|
||||
refreshToken = legacyRefresh,
|
||||
savedAtMillis = legacySavedAt,
|
||||
)
|
||||
writeAllTokenEntries(mapOf(userId to token))
|
||||
writeActiveUserId(userId)
|
||||
writeAccounts(
|
||||
listOf(
|
||||
StoredAccount(
|
||||
userId = userId,
|
||||
email = null,
|
||||
name = "User #$userId",
|
||||
username = null,
|
||||
avatarUrl = null,
|
||||
lastActiveAt = legacySavedAt,
|
||||
)
|
||||
)
|
||||
)
|
||||
sharedPreferences.edit()
|
||||
.remove(ACCESS_TOKEN_KEY)
|
||||
.remove(REFRESH_TOKEN_KEY)
|
||||
.remove(SAVED_AT_KEY)
|
||||
.apply()
|
||||
tokensFlow.value = null
|
||||
}
|
||||
|
||||
private fun readTokens(): TokenBundle? {
|
||||
val access = sharedPreferences.getString(ACCESS_TOKEN_KEY, null)
|
||||
val refresh = sharedPreferences.getString(REFRESH_TOKEN_KEY, null)
|
||||
val savedAt = sharedPreferences.getLong(SAVED_AT_KEY, -1L)
|
||||
if (access.isNullOrBlank() || refresh.isNullOrBlank() || savedAt <= 0L) {
|
||||
return null
|
||||
private fun ensureAccountPlaceholder(userId: Long, lastActiveAt: Long) {
|
||||
val accounts = readAccounts().associateBy { it.userId }.toMutableMap()
|
||||
val existing = accounts[userId]
|
||||
if (existing == null) {
|
||||
accounts[userId] = StoredAccount(
|
||||
userId = userId,
|
||||
email = null,
|
||||
name = "User #$userId",
|
||||
username = null,
|
||||
avatarUrl = null,
|
||||
lastActiveAt = lastActiveAt,
|
||||
)
|
||||
} else {
|
||||
accounts[userId] = existing.copy(lastActiveAt = maxOf(existing.lastActiveAt, lastActiveAt))
|
||||
}
|
||||
return TokenBundle(
|
||||
accessToken = access,
|
||||
refreshToken = refresh,
|
||||
savedAtMillis = savedAt,
|
||||
)
|
||||
writeAccounts(accounts.values.toList())
|
||||
}
|
||||
|
||||
private fun readActiveUserId(): Long? {
|
||||
val value = sharedPreferences.getLong(ACTIVE_USER_ID_KEY, -1L)
|
||||
return value.takeIf { it > 0L }
|
||||
}
|
||||
|
||||
private fun writeActiveUserId(userId: Long?) {
|
||||
sharedPreferences.edit().apply {
|
||||
if (userId == null) {
|
||||
remove(ACTIVE_USER_ID_KEY)
|
||||
} else {
|
||||
putLong(ACTIVE_USER_ID_KEY, userId)
|
||||
}
|
||||
}.apply()
|
||||
}
|
||||
|
||||
private fun readAllTokenEntries(): Map<Long, TokenBundle> {
|
||||
val raw = sharedPreferences.getString(TOKENS_JSON_KEY, null).orEmpty()
|
||||
if (raw.isBlank()) return emptyMap()
|
||||
return runCatching {
|
||||
val root = JSONObject(raw)
|
||||
root.keys().asSequence().mapNotNull { key ->
|
||||
val userId = key.toLongOrNull() ?: return@mapNotNull null
|
||||
val node = root.optJSONObject(key) ?: return@mapNotNull null
|
||||
val access = node.optString("access", "")
|
||||
val refresh = node.optString("refresh", "")
|
||||
val savedAt = node.optLong("savedAt", -1L)
|
||||
if (access.isBlank() || refresh.isBlank() || savedAt <= 0L) {
|
||||
null
|
||||
} else {
|
||||
userId to TokenBundle(access, refresh, savedAt)
|
||||
}
|
||||
}.toMap()
|
||||
}.getOrDefault(emptyMap())
|
||||
}
|
||||
|
||||
private fun writeAllTokenEntries(tokens: Map<Long, TokenBundle>) {
|
||||
val root = JSONObject()
|
||||
tokens.forEach { (userId, token) ->
|
||||
root.put(
|
||||
userId.toString(),
|
||||
JSONObject().apply {
|
||||
put("access", token.accessToken)
|
||||
put("refresh", token.refreshToken)
|
||||
put("savedAt", token.savedAtMillis)
|
||||
}
|
||||
)
|
||||
}
|
||||
sharedPreferences.edit().putString(TOKENS_JSON_KEY, root.toString()).apply()
|
||||
}
|
||||
|
||||
private fun readAccounts(): List<StoredAccount> {
|
||||
val raw = sharedPreferences.getString(ACCOUNTS_JSON_KEY, null).orEmpty()
|
||||
if (raw.isBlank()) return emptyList()
|
||||
return runCatching {
|
||||
val array = JSONArray(raw)
|
||||
buildList {
|
||||
for (index in 0 until array.length()) {
|
||||
val node = array.optJSONObject(index) ?: continue
|
||||
val userId = node.optLong("userId", -1L)
|
||||
if (userId <= 0L) continue
|
||||
add(
|
||||
StoredAccount(
|
||||
userId = userId,
|
||||
email = node.optString("email", "").ifBlank { null },
|
||||
name = node.optString("name", "User #$userId").ifBlank { "User #$userId" },
|
||||
username = node.optString("username", "").ifBlank { null },
|
||||
avatarUrl = node.optString("avatarUrl", "").ifBlank { null },
|
||||
lastActiveAt = node.optLong("lastActiveAt", 0L),
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}.getOrDefault(emptyList())
|
||||
}
|
||||
|
||||
private fun writeAccounts(accounts: List<StoredAccount>) {
|
||||
val array = JSONArray()
|
||||
accounts.forEach { account ->
|
||||
array.put(
|
||||
JSONObject().apply {
|
||||
put("userId", account.userId)
|
||||
put("email", account.email.orEmpty())
|
||||
put("name", account.name)
|
||||
put("username", account.username.orEmpty())
|
||||
put("avatarUrl", account.avatarUrl.orEmpty())
|
||||
put("lastActiveAt", account.lastActiveAt)
|
||||
}
|
||||
)
|
||||
}
|
||||
sharedPreferences.edit().putString(ACCOUNTS_JSON_KEY, array.toString()).apply()
|
||||
}
|
||||
|
||||
private fun String.extractUserIdFromJwt(): Long? {
|
||||
val parts = split('.')
|
||||
if (parts.size < 2) return null
|
||||
val payload = parts[1]
|
||||
val normalized = payload
|
||||
.replace('-', '+')
|
||||
.replace('_', '/')
|
||||
.let { source ->
|
||||
when (source.length % 4) {
|
||||
0 -> source
|
||||
2 -> source + "=="
|
||||
3 -> source + "="
|
||||
else -> return null
|
||||
}
|
||||
}
|
||||
return runCatching {
|
||||
val payloadJson = String(Base64.decode(normalized, Base64.DEFAULT), Charsets.UTF_8)
|
||||
JSONObject(payloadJson).optString("sub").toLongOrNull()
|
||||
}.getOrNull()
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val ACCESS_TOKEN_KEY = "access_token"
|
||||
const val REFRESH_TOKEN_KEY = "refresh_token"
|
||||
const val SAVED_AT_KEY = "tokens_saved_at"
|
||||
|
||||
const val TOKENS_JSON_KEY = "tokens_json"
|
||||
const val ACCOUNTS_JSON_KEY = "accounts_json"
|
||||
const val ACTIVE_USER_ID_KEY = "active_user_id"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -4,7 +4,15 @@ import kotlinx.coroutines.flow.Flow
|
||||
|
||||
interface TokenRepository {
|
||||
fun observeTokens(): Flow<TokenBundle?>
|
||||
fun observeAccounts(): Flow<List<StoredAccount>>
|
||||
fun observeActiveUserId(): Flow<Long?>
|
||||
suspend fun getTokens(): TokenBundle?
|
||||
suspend fun getAccounts(): List<StoredAccount>
|
||||
suspend fun getActiveUserId(): Long?
|
||||
suspend fun saveTokens(tokens: TokenBundle)
|
||||
suspend fun upsertAccount(account: StoredAccount)
|
||||
suspend fun switchAccount(userId: Long): Boolean
|
||||
suspend fun removeAccount(userId: Long)
|
||||
suspend fun clearTokens()
|
||||
suspend fun clearAllTokens()
|
||||
}
|
||||
|
||||
@@ -2,17 +2,37 @@ 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
|
||||
@@ -24,6 +44,22 @@ interface AuthApiService {
|
||||
@GET("/api/v1/auth/me")
|
||||
suspend fun me(): AuthUserDto
|
||||
|
||||
@Headers("No-Auth: true")
|
||||
@POST("/api/v1/auth/verify-email")
|
||||
suspend fun verifyEmail(@Body request: VerifyEmailRequestDto): MessageResponseDto
|
||||
|
||||
@Headers("No-Auth: true")
|
||||
@POST("/api/v1/auth/request-password-reset")
|
||||
suspend fun requestPasswordReset(@Body request: RequestPasswordResetDto): MessageResponseDto
|
||||
|
||||
@Headers("No-Auth: true")
|
||||
@POST("/api/v1/auth/resend-verification")
|
||||
suspend fun resendVerification(@Body request: ResendVerificationRequestDto): MessageResponseDto
|
||||
|
||||
@Headers("No-Auth: true")
|
||||
@POST("/api/v1/auth/reset-password")
|
||||
suspend fun resetPassword(@Body request: ResetPasswordRequestDto): MessageResponseDto
|
||||
|
||||
@GET("/api/v1/auth/sessions")
|
||||
suspend fun sessions(): List<AuthSessionDto>
|
||||
|
||||
@@ -32,4 +68,19 @@ interface AuthApiService {
|
||||
|
||||
@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
|
||||
}
|
||||
|
||||
@@ -7,6 +7,10 @@ import kotlinx.serialization.Serializable
|
||||
data class LoginRequestDto(
|
||||
val email: String,
|
||||
val password: String,
|
||||
@SerialName("otp_code")
|
||||
val otpCode: String? = null,
|
||||
@SerialName("recovery_code")
|
||||
val recoveryCode: String? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
@@ -31,10 +35,21 @@ data class AuthUserDto(
|
||||
val email: String,
|
||||
val name: String,
|
||||
val username: String,
|
||||
val bio: String? = null,
|
||||
@SerialName("avatar_url")
|
||||
val avatarUrl: String? = null,
|
||||
@SerialName("email_verified")
|
||||
val emailVerified: Boolean,
|
||||
@SerialName("twofa_enabled")
|
||||
val twofaEnabled: Boolean = false,
|
||||
@SerialName("privacy_private_messages")
|
||||
val privacyPrivateMessages: String? = null,
|
||||
@SerialName("privacy_last_seen")
|
||||
val privacyLastSeen: String? = null,
|
||||
@SerialName("privacy_avatar")
|
||||
val privacyAvatar: String? = null,
|
||||
@SerialName("privacy_group_invites")
|
||||
val privacyGroupInvites: String? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
@@ -55,3 +70,70 @@ data class AuthSessionDto(
|
||||
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>,
|
||||
)
|
||||
|
||||
@@ -3,20 +3,25 @@ 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
|
||||
|
||||
@@ -24,15 +29,60 @@ import javax.inject.Singleton
|
||||
class NetworkAuthRepository @Inject constructor(
|
||||
private val authApiService: AuthApiService,
|
||||
private val tokenRepository: TokenRepository,
|
||||
private val pushTokenSyncManager: PushTokenSyncManager,
|
||||
@IoDispatcher private val ioDispatcher: CoroutineDispatcher,
|
||||
) : AuthRepository {
|
||||
|
||||
override suspend fun login(email: String, password: String): AppResult<AuthUser> = withContext(ioDispatcher) {
|
||||
override suspend fun checkEmailStatus(email: String): AppResult<AuthEmailStatus> = withContext(ioDispatcher) {
|
||||
val normalized = email.trim().lowercase()
|
||||
if (normalized.isBlank()) return@withContext AppResult.Error(AppError.Server("Email is required"))
|
||||
try {
|
||||
AppResult.Success(authApiService.checkEmailStatus(normalized).toDomain())
|
||||
} catch (error: Throwable) {
|
||||
AppResult.Error(error.toAppError())
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun register(
|
||||
email: String,
|
||||
name: String,
|
||||
username: String,
|
||||
password: String,
|
||||
): AppResult<Unit> = withContext(ioDispatcher) {
|
||||
val normalizedEmail = email.trim().lowercase()
|
||||
val normalizedName = name.trim()
|
||||
val normalizedUsername = username.trim().removePrefix("@")
|
||||
if (normalizedEmail.isBlank() || normalizedName.isBlank() || normalizedUsername.isBlank() || password.isBlank()) {
|
||||
return@withContext AppResult.Error(AppError.Server("Email, name, username and password are required"))
|
||||
}
|
||||
try {
|
||||
authApiService.register(
|
||||
request = RegisterRequestDto(
|
||||
email = normalizedEmail,
|
||||
name = normalizedName,
|
||||
username = normalizedUsername,
|
||||
password = password,
|
||||
)
|
||||
)
|
||||
AppResult.Success(Unit)
|
||||
} catch (error: Throwable) {
|
||||
AppResult.Error(error.toAppError())
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun login(
|
||||
email: String,
|
||||
password: String,
|
||||
otpCode: String?,
|
||||
recoveryCode: String?,
|
||||
): AppResult<AuthUser> = withContext(ioDispatcher) {
|
||||
try {
|
||||
val tokenResponse = authApiService.login(
|
||||
request = LoginRequestDto(
|
||||
email = email,
|
||||
password = password,
|
||||
otpCode = otpCode?.trim()?.ifBlank { null },
|
||||
recoveryCode = recoveryCode?.trim()?.ifBlank { null },
|
||||
)
|
||||
)
|
||||
tokenRepository.saveTokens(
|
||||
@@ -42,7 +92,14 @@ class NetworkAuthRepository @Inject constructor(
|
||||
savedAtMillis = System.currentTimeMillis(),
|
||||
)
|
||||
)
|
||||
getMe()
|
||||
pushTokenSyncManager.triggerBestEffortSync()
|
||||
when (val meResult = getMe()) {
|
||||
is AppResult.Success -> {
|
||||
tokenRepository.upsertAccount(meResult.data.toStoredAccount())
|
||||
meResult
|
||||
}
|
||||
is AppResult.Error -> meResult
|
||||
}
|
||||
} catch (error: Throwable) {
|
||||
AppResult.Error(error.toAppError(mode = ApiErrorMode.LOGIN))
|
||||
}
|
||||
@@ -62,6 +119,7 @@ class NetworkAuthRepository @Inject constructor(
|
||||
savedAtMillis = System.currentTimeMillis(),
|
||||
)
|
||||
)
|
||||
pushTokenSyncManager.triggerBestEffortSync()
|
||||
AppResult.Success(Unit)
|
||||
} catch (error: Throwable) {
|
||||
tokenRepository.clearTokens()
|
||||
@@ -72,6 +130,7 @@ class NetworkAuthRepository @Inject constructor(
|
||||
override suspend fun getMe(): AppResult<AuthUser> = withContext(ioDispatcher) {
|
||||
try {
|
||||
val user = authApiService.me().toDomain()
|
||||
tokenRepository.upsertAccount(user.toStoredAccount())
|
||||
AppResult.Success(user)
|
||||
} catch (error: Throwable) {
|
||||
AppResult.Error(error.toAppError())
|
||||
@@ -88,7 +147,10 @@ class NetworkAuthRepository @Inject constructor(
|
||||
}
|
||||
|
||||
when (val meResult = getMe()) {
|
||||
is AppResult.Success -> meResult
|
||||
is AppResult.Success -> {
|
||||
pushTokenSyncManager.triggerBestEffortSync()
|
||||
meResult
|
||||
}
|
||||
is AppResult.Error -> {
|
||||
if (meResult.reason is AppError.Unauthorized) {
|
||||
tokenRepository.clearTokens()
|
||||
@@ -125,6 +187,7 @@ class NetworkAuthRepository @Inject constructor(
|
||||
}
|
||||
|
||||
override suspend fun logout() {
|
||||
pushTokenSyncManager.unregisterCurrentTokenOnLogout()
|
||||
tokenRepository.clearTokens()
|
||||
}
|
||||
|
||||
@@ -134,8 +197,25 @@ class NetworkAuthRepository @Inject constructor(
|
||||
email = email,
|
||||
name = name,
|
||||
username = username,
|
||||
bio = bio,
|
||||
avatarUrl = avatarUrl,
|
||||
emailVerified = emailVerified,
|
||||
twofaEnabled = twofaEnabled,
|
||||
privacyPrivateMessages = privacyPrivateMessages ?: "everyone",
|
||||
privacyLastSeen = privacyLastSeen ?: "everyone",
|
||||
privacyAvatar = privacyAvatar ?: "everyone",
|
||||
privacyGroupInvites = privacyGroupInvites ?: "everyone",
|
||||
)
|
||||
}
|
||||
|
||||
private fun AuthUser.toStoredAccount(): StoredAccount {
|
||||
return StoredAccount(
|
||||
userId = id,
|
||||
email = email,
|
||||
name = name,
|
||||
username = username,
|
||||
avatarUrl = avatarUrl,
|
||||
lastActiveAt = System.currentTimeMillis(),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -150,4 +230,13 @@ class NetworkAuthRepository @Inject constructor(
|
||||
)
|
||||
}
|
||||
|
||||
private fun CheckEmailStatusDto.toDomain(): AuthEmailStatus {
|
||||
return AuthEmailStatus(
|
||||
email = email,
|
||||
registered = registered,
|
||||
emailVerified = emailVerified,
|
||||
twofaEnabled = twofaEnabled,
|
||||
)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -1,13 +1,26 @@
|
||||
package ru.daemonlord.messenger.data.chat.api
|
||||
|
||||
import retrofit2.http.Body
|
||||
import retrofit2.http.DELETE
|
||||
import retrofit2.http.GET
|
||||
import retrofit2.http.PATCH
|
||||
import retrofit2.http.Path
|
||||
import retrofit2.http.POST
|
||||
import retrofit2.http.PUT
|
||||
import retrofit2.http.Query
|
||||
import ru.daemonlord.messenger.data.chat.dto.ChatBanDto
|
||||
import ru.daemonlord.messenger.data.chat.dto.ChatCreateRequestDto
|
||||
import ru.daemonlord.messenger.data.chat.dto.ChatInviteLinkDto
|
||||
import ru.daemonlord.messenger.data.chat.dto.ChatJoinByInviteRequestDto
|
||||
import ru.daemonlord.messenger.data.chat.dto.ChatMemberAddRequestDto
|
||||
import ru.daemonlord.messenger.data.chat.dto.ChatMemberDto
|
||||
import ru.daemonlord.messenger.data.chat.dto.ChatMemberRoleUpdateRequestDto
|
||||
import ru.daemonlord.messenger.data.chat.dto.ChatNotificationSettingsDto
|
||||
import ru.daemonlord.messenger.data.chat.dto.ChatNotificationSettingsUpdateDto
|
||||
import ru.daemonlord.messenger.data.chat.dto.ChatProfileUpdateRequestDto
|
||||
import ru.daemonlord.messenger.data.chat.dto.ChatReadDto
|
||||
import ru.daemonlord.messenger.data.chat.dto.ChatTitleUpdateRequestDto
|
||||
import ru.daemonlord.messenger.data.chat.dto.DiscoverChatDto
|
||||
|
||||
interface ChatApiService {
|
||||
@GET("/api/v1/chats")
|
||||
@@ -20,6 +33,9 @@ interface ChatApiService {
|
||||
@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,
|
||||
@@ -29,4 +45,119 @@ interface ChatApiService {
|
||||
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,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -61,3 +61,87 @@ data class ChatInviteLinkDto(
|
||||
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,
|
||||
)
|
||||
|
||||
@@ -87,6 +87,9 @@ interface ChatDao {
|
||||
@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>)
|
||||
|
||||
@@ -139,6 +142,16 @@ interface ChatDao {
|
||||
)
|
||||
suspend fun incrementUnread(chatId: Long, incrementBy: Int = 1)
|
||||
|
||||
@Query(
|
||||
"""
|
||||
UPDATE chats
|
||||
SET unread_count = 0,
|
||||
unread_mentions_count = 0
|
||||
WHERE id = :chatId
|
||||
"""
|
||||
)
|
||||
suspend fun markChatRead(chatId: Long)
|
||||
|
||||
@Transaction
|
||||
suspend fun clearAndReplaceChats(
|
||||
archived: Boolean,
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,17 @@ 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
|
||||
@@ -17,7 +27,11 @@ 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
|
||||
@@ -75,6 +89,18 @@ class NetworkChatRepository @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun getSavedChat(): AppResult<ChatItem> = withContext(ioDispatcher) {
|
||||
try {
|
||||
val chat = chatApiService.getSavedChat()
|
||||
chatDao.upsertUsers(chat.toUserShortEntityOrNull()?.let(::listOf).orEmpty())
|
||||
val entity = chat.toChatEntity()
|
||||
chatDao.upsertChats(listOf(entity))
|
||||
AppResult.Success(entity.toDomain())
|
||||
} catch (error: Throwable) {
|
||||
AppResult.Error(error.toAppError())
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun createInviteLink(chatId: Long): AppResult<ChatInviteLink> = withContext(ioDispatcher) {
|
||||
try {
|
||||
AppResult.Success(chatApiService.createInviteLink(chatId = chatId).toDomain())
|
||||
@@ -95,9 +121,304 @@ class NetworkChatRepository @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun createChat(
|
||||
type: String,
|
||||
title: String?,
|
||||
isPublic: Boolean,
|
||||
handle: String?,
|
||||
description: String?,
|
||||
memberIds: List<Long>,
|
||||
): AppResult<ChatItem> = withContext(ioDispatcher) {
|
||||
try {
|
||||
val created = chatApiService.createChat(
|
||||
request = ChatCreateRequestDto(
|
||||
type = type,
|
||||
title = title,
|
||||
isPublic = isPublic,
|
||||
handle = handle,
|
||||
description = description,
|
||||
memberIds = memberIds,
|
||||
)
|
||||
)
|
||||
chatDao.upsertUsers(created.toUserShortEntityOrNull()?.let(::listOf).orEmpty())
|
||||
val chatEntity = created.toChatEntity()
|
||||
chatDao.upsertChats(listOf(chatEntity))
|
||||
AppResult.Success(chatEntity.toDomain())
|
||||
} catch (error: Throwable) {
|
||||
AppResult.Error(error.toAppError())
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun discoverChats(query: String?): AppResult<List<DiscoverChatItem>> = withContext(ioDispatcher) {
|
||||
try {
|
||||
AppResult.Success(chatApiService.discoverChats(query = query).map { it.toDomain() })
|
||||
} catch (error: Throwable) {
|
||||
AppResult.Error(error.toAppError())
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun joinChat(chatId: Long): AppResult<ChatItem> = withContext(ioDispatcher) {
|
||||
try {
|
||||
val joined = chatApiService.joinChat(chatId = chatId)
|
||||
chatDao.upsertUsers(joined.toUserShortEntityOrNull()?.let(::listOf).orEmpty())
|
||||
val entity = joined.toChatEntity()
|
||||
chatDao.upsertChats(listOf(entity))
|
||||
AppResult.Success(entity.toDomain())
|
||||
} catch (error: Throwable) {
|
||||
AppResult.Error(error.toAppError())
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun leaveChat(chatId: Long): AppResult<Unit> = withContext(ioDispatcher) {
|
||||
try {
|
||||
chatApiService.leaveChat(chatId = chatId)
|
||||
chatDao.deleteChat(chatId = chatId)
|
||||
AppResult.Success(Unit)
|
||||
} catch (error: Throwable) {
|
||||
AppResult.Error(error.toAppError())
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun archiveChat(chatId: Long): AppResult<ChatItem> = withContext(ioDispatcher) {
|
||||
try {
|
||||
val updated = chatApiService.archiveChat(chatId = chatId)
|
||||
chatDao.upsertUsers(updated.toUserShortEntityOrNull()?.let(::listOf).orEmpty())
|
||||
val entity = updated.toChatEntity()
|
||||
chatDao.upsertChats(listOf(entity))
|
||||
AppResult.Success(entity.toDomain())
|
||||
} catch (error: Throwable) {
|
||||
AppResult.Error(error.toAppError())
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun unarchiveChat(chatId: Long): AppResult<ChatItem> = withContext(ioDispatcher) {
|
||||
try {
|
||||
val updated = chatApiService.unarchiveChat(chatId = chatId)
|
||||
chatDao.upsertUsers(updated.toUserShortEntityOrNull()?.let(::listOf).orEmpty())
|
||||
val entity = updated.toChatEntity()
|
||||
chatDao.upsertChats(listOf(entity))
|
||||
AppResult.Success(entity.toDomain())
|
||||
} catch (error: Throwable) {
|
||||
AppResult.Error(error.toAppError())
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun pinChat(chatId: Long): AppResult<ChatItem> = withContext(ioDispatcher) {
|
||||
try {
|
||||
val updated = chatApiService.pinChat(chatId = chatId)
|
||||
chatDao.upsertUsers(updated.toUserShortEntityOrNull()?.let(::listOf).orEmpty())
|
||||
val entity = updated.toChatEntity()
|
||||
chatDao.upsertChats(listOf(entity))
|
||||
AppResult.Success(entity.toDomain())
|
||||
} catch (error: Throwable) {
|
||||
AppResult.Error(error.toAppError())
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun unpinChat(chatId: Long): AppResult<ChatItem> = withContext(ioDispatcher) {
|
||||
try {
|
||||
val updated = chatApiService.unpinChat(chatId = chatId)
|
||||
chatDao.upsertUsers(updated.toUserShortEntityOrNull()?.let(::listOf).orEmpty())
|
||||
val entity = updated.toChatEntity()
|
||||
chatDao.upsertChats(listOf(entity))
|
||||
AppResult.Success(entity.toDomain())
|
||||
} catch (error: Throwable) {
|
||||
AppResult.Error(error.toAppError())
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun updateChatTitle(chatId: Long, title: String): AppResult<ChatItem> = withContext(ioDispatcher) {
|
||||
try {
|
||||
val updated = chatApiService.updateChatTitle(
|
||||
chatId = chatId,
|
||||
request = ChatTitleUpdateRequestDto(title = title),
|
||||
)
|
||||
chatDao.upsertUsers(updated.toUserShortEntityOrNull()?.let(::listOf).orEmpty())
|
||||
val entity = updated.toChatEntity()
|
||||
chatDao.upsertChats(listOf(entity))
|
||||
AppResult.Success(entity.toDomain())
|
||||
} catch (error: Throwable) {
|
||||
AppResult.Error(error.toAppError())
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun updateChatProfile(
|
||||
chatId: Long,
|
||||
title: String?,
|
||||
description: String?,
|
||||
avatarUrl: String?,
|
||||
): AppResult<ChatItem> = withContext(ioDispatcher) {
|
||||
try {
|
||||
val updated = chatApiService.updateChatProfile(
|
||||
chatId = chatId,
|
||||
request = ChatProfileUpdateRequestDto(
|
||||
title = title,
|
||||
description = description,
|
||||
avatarUrl = avatarUrl,
|
||||
),
|
||||
)
|
||||
chatDao.upsertUsers(updated.toUserShortEntityOrNull()?.let(::listOf).orEmpty())
|
||||
val entity = updated.toChatEntity()
|
||||
chatDao.upsertChats(listOf(entity))
|
||||
AppResult.Success(entity.toDomain())
|
||||
} catch (error: Throwable) {
|
||||
AppResult.Error(error.toAppError())
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun clearChat(chatId: Long): AppResult<Unit> = withContext(ioDispatcher) {
|
||||
try {
|
||||
chatApiService.clearChat(chatId = chatId)
|
||||
AppResult.Success(Unit)
|
||||
} catch (error: Throwable) {
|
||||
AppResult.Error(error.toAppError())
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun removeChat(chatId: Long, forAll: Boolean): AppResult<Unit> = withContext(ioDispatcher) {
|
||||
try {
|
||||
chatApiService.deleteChat(chatId = chatId, forAll = forAll)
|
||||
chatDao.deleteChat(chatId = chatId)
|
||||
AppResult.Success(Unit)
|
||||
} catch (error: Throwable) {
|
||||
AppResult.Error(error.toAppError())
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun getChatNotifications(chatId: Long): AppResult<ChatNotificationSettings> = withContext(ioDispatcher) {
|
||||
try {
|
||||
AppResult.Success(chatApiService.getChatNotifications(chatId = chatId).toDomain())
|
||||
} catch (error: Throwable) {
|
||||
AppResult.Error(error.toAppError())
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun updateChatNotifications(chatId: Long, muted: Boolean): AppResult<ChatNotificationSettings> = withContext(ioDispatcher) {
|
||||
try {
|
||||
val settings = chatApiService.updateChatNotifications(
|
||||
chatId = chatId,
|
||||
request = ChatNotificationSettingsUpdateDto(muted = muted),
|
||||
).toDomain()
|
||||
chatDao.updateChatMuted(chatId = chatId, muted = settings.muted)
|
||||
AppResult.Success(settings)
|
||||
} catch (error: Throwable) {
|
||||
AppResult.Error(error.toAppError())
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun listMembers(chatId: Long): AppResult<List<ChatMemberItem>> = withContext(ioDispatcher) {
|
||||
try {
|
||||
AppResult.Success(chatApiService.listMembers(chatId = chatId).map { it.toDomain() })
|
||||
} catch (error: Throwable) {
|
||||
AppResult.Error(error.toAppError())
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun listBans(chatId: Long): AppResult<List<ChatBanItem>> = withContext(ioDispatcher) {
|
||||
try {
|
||||
AppResult.Success(chatApiService.listBans(chatId = chatId).map { it.toDomain() })
|
||||
} catch (error: Throwable) {
|
||||
AppResult.Error(error.toAppError())
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun addMember(chatId: Long, userId: Long): AppResult<ChatMemberItem> = withContext(ioDispatcher) {
|
||||
try {
|
||||
AppResult.Success(
|
||||
chatApiService.addMember(
|
||||
chatId = chatId,
|
||||
request = ChatMemberAddRequestDto(userId = userId),
|
||||
).toDomain()
|
||||
)
|
||||
} catch (error: Throwable) {
|
||||
AppResult.Error(error.toAppError())
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun updateMemberRole(chatId: Long, userId: Long, role: String): AppResult<ChatMemberItem> = withContext(ioDispatcher) {
|
||||
try {
|
||||
AppResult.Success(
|
||||
chatApiService.updateMemberRole(
|
||||
chatId = chatId,
|
||||
userId = userId,
|
||||
request = ChatMemberRoleUpdateRequestDto(role = role),
|
||||
).toDomain()
|
||||
)
|
||||
} catch (error: Throwable) {
|
||||
AppResult.Error(error.toAppError())
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun removeMember(chatId: Long, userId: Long): AppResult<Unit> = withContext(ioDispatcher) {
|
||||
try {
|
||||
chatApiService.removeMember(chatId = chatId, userId = userId)
|
||||
AppResult.Success(Unit)
|
||||
} catch (error: Throwable) {
|
||||
AppResult.Error(error.toAppError())
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun banMember(chatId: Long, userId: Long): AppResult<Unit> = withContext(ioDispatcher) {
|
||||
try {
|
||||
chatApiService.banMember(chatId = chatId, userId = userId)
|
||||
AppResult.Success(Unit)
|
||||
} catch (error: Throwable) {
|
||||
AppResult.Error(error.toAppError())
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun unbanMember(chatId: Long, userId: Long): AppResult<Unit> = withContext(ioDispatcher) {
|
||||
try {
|
||||
chatApiService.unbanMember(chatId = chatId, userId = userId)
|
||||
AppResult.Success(Unit)
|
||||
} catch (error: Throwable) {
|
||||
AppResult.Error(error.toAppError())
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun deleteChat(chatId: Long) {
|
||||
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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package ru.daemonlord.messenger.data.common
|
||||
import retrofit2.HttpException
|
||||
import ru.daemonlord.messenger.domain.common.AppError
|
||||
import java.io.IOException
|
||||
import org.json.JSONObject
|
||||
|
||||
enum class ApiErrorMode {
|
||||
DEFAULT,
|
||||
@@ -13,15 +14,24 @@ fun Throwable.toAppError(mode: ApiErrorMode = ApiErrorMode.DEFAULT): AppError {
|
||||
return when (this) {
|
||||
is IOException -> AppError.Network
|
||||
is HttpException -> when (mode) {
|
||||
ApiErrorMode.LOGIN -> when (code()) {
|
||||
400, 401, 403 -> AppError.InvalidCredentials
|
||||
else -> AppError.Server(message = message())
|
||||
ApiErrorMode.LOGIN -> {
|
||||
val detail = extractErrorDetail()
|
||||
when (code()) {
|
||||
400, 401, 403 -> {
|
||||
if (detail?.contains("2fa code required", ignoreCase = true) == true) {
|
||||
AppError.Server(message = detail)
|
||||
} else {
|
||||
AppError.InvalidCredentials
|
||||
}
|
||||
}
|
||||
else -> AppError.Server(message = detail ?: message())
|
||||
}
|
||||
}
|
||||
|
||||
ApiErrorMode.DEFAULT -> if (code() == 401 || code() == 403) {
|
||||
AppError.Unauthorized
|
||||
} else {
|
||||
AppError.Server(message = message())
|
||||
AppError.Server(message = extractErrorDetail() ?: message())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,3 +39,11 @@ fun Throwable.toAppError(mode: ApiErrorMode = ApiErrorMode.DEFAULT): AppError {
|
||||
}
|
||||
}
|
||||
|
||||
private fun HttpException.extractErrorDetail(): String? {
|
||||
return runCatching {
|
||||
val body = response()?.errorBody()?.string()?.trim().orEmpty()
|
||||
if (body.isBlank()) return@runCatching null
|
||||
val json = JSONObject(body)
|
||||
json.optString("detail").takeIf { it.isNotBlank() }
|
||||
}.getOrNull()
|
||||
}
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
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
|
||||
@@ -14,7 +18,10 @@ 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
|
||||
|
||||
@@ -30,17 +37,22 @@ class NetworkMediaRepository @Inject constructor(
|
||||
fileName: String,
|
||||
mimeType: String,
|
||||
bytes: ByteArray,
|
||||
): AppResult<Unit> = withContext(ioDispatcher) {
|
||||
): AppResult<UploadedAttachment> = withContext(ioDispatcher) {
|
||||
try {
|
||||
val uploadPayload = prepareUploadPayload(
|
||||
fileName = fileName,
|
||||
mimeType = mimeType,
|
||||
bytes = bytes,
|
||||
)
|
||||
val uploadInfo = mediaApiService.requestUploadUrl(
|
||||
request = UploadUrlRequestDto(
|
||||
fileName = fileName,
|
||||
fileType = mimeType,
|
||||
fileSize = bytes.size.toLong(),
|
||||
fileName = uploadPayload.fileName,
|
||||
fileType = uploadPayload.mimeType,
|
||||
fileSize = uploadPayload.bytes.size.toLong(),
|
||||
)
|
||||
)
|
||||
|
||||
val body = bytes.toRequestBody(mimeType.toMediaTypeOrNull())
|
||||
val body = uploadPayload.bytes.toRequestBody(uploadPayload.mimeType.toMediaTypeOrNull())
|
||||
val uploadRequestBuilder = Request.Builder()
|
||||
.url(uploadInfo.uploadUrl)
|
||||
.put(body)
|
||||
@@ -61,13 +73,120 @@ class NetworkMediaRepository @Inject constructor(
|
||||
request = AttachmentCreateRequestDto(
|
||||
messageId = messageId,
|
||||
fileUrl = uploadInfo.fileUrl,
|
||||
fileType = mimeType,
|
||||
fileSize = bytes.size.toLong(),
|
||||
fileType = uploadPayload.mimeType,
|
||||
fileSize = uploadPayload.bytes.size.toLong(),
|
||||
)
|
||||
)
|
||||
AppResult.Success(
|
||||
UploadedAttachment(
|
||||
fileUrl = uploadInfo.fileUrl,
|
||||
fileType = uploadPayload.mimeType,
|
||||
fileSize = uploadPayload.bytes.size.toLong(),
|
||||
)
|
||||
)
|
||||
AppResult.Success(Unit)
|
||||
} catch (error: Throwable) {
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,6 +24,18 @@ interface MessageApiService {
|
||||
@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,
|
||||
|
||||
@@ -48,6 +48,7 @@ fun MessageLocalModel.toDomain(currentUserId: Long?): MessageItem {
|
||||
chatId = message.chatId,
|
||||
senderId = message.senderId,
|
||||
senderDisplayName = message.senderDisplayName,
|
||||
senderUsername = message.senderUsername,
|
||||
type = message.type,
|
||||
text = message.text,
|
||||
createdAt = message.createdAt,
|
||||
|
||||
@@ -10,11 +10,13 @@ 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
|
||||
@@ -71,6 +73,27 @@ class NetworkMessageRepository @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun searchMessages(query: String, chatId: Long?): AppResult<List<MessageItem>> = withContext(ioDispatcher) {
|
||||
val normalized = query.trim()
|
||||
if (normalized.isBlank()) return@withContext AppResult.Success(emptyList())
|
||||
try {
|
||||
val remote = messageApiService.searchMessages(query = normalized, chatId = chatId)
|
||||
val mapped = remote.map { dto -> dto.toDomain(currentUserId = currentUserId) }
|
||||
AppResult.Success(mapped)
|
||||
} catch (error: Throwable) {
|
||||
AppResult.Error(error.toAppError())
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun getMessageThread(messageId: Long, limit: Int): AppResult<List<MessageItem>> = withContext(ioDispatcher) {
|
||||
try {
|
||||
val remote = messageApiService.getMessageThread(messageId = messageId, limit = limit)
|
||||
AppResult.Success(remote.map { dto -> dto.toDomain(currentUserId = currentUserId) })
|
||||
} catch (error: Throwable) {
|
||||
AppResult.Error(error.toAppError())
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun syncRecentMessages(chatId: Long): AppResult<Unit> = withContext(ioDispatcher) {
|
||||
flushPendingActions(chatId = chatId)
|
||||
try {
|
||||
@@ -290,7 +313,7 @@ class NetworkMessageRepository @Inject constructor(
|
||||
caption: String?,
|
||||
replyToMessageId: Long?,
|
||||
): AppResult<Unit> = withContext(ioDispatcher) {
|
||||
val messageType = mapMimeToMessageType(mimeType)
|
||||
val messageType = mapMimeToMessageType(mimeType = mimeType, fileName = fileName)
|
||||
val tempId = -System.currentTimeMillis()
|
||||
val tempMessage = MessageEntity(
|
||||
id = tempId,
|
||||
@@ -338,7 +361,27 @@ class NetworkMessageRepository @Inject constructor(
|
||||
)) {
|
||||
is AppResult.Success -> {
|
||||
messageDao.deleteMessage(tempId)
|
||||
syncRecentMessages(chatId = chatId)
|
||||
messageDao.upsertMessages(listOf(created.toEntity()))
|
||||
messageDao.upsertAttachments(
|
||||
listOf(
|
||||
MessageAttachmentEntity(
|
||||
id = -System.currentTimeMillis(),
|
||||
messageId = created.id,
|
||||
fileUrl = mediaResult.data.fileUrl,
|
||||
fileType = mediaResult.data.fileType,
|
||||
fileSize = mediaResult.data.fileSize,
|
||||
waveformPointsJson = null,
|
||||
)
|
||||
)
|
||||
)
|
||||
chatDao.updateLastMessage(
|
||||
chatId = chatId,
|
||||
lastMessageText = created.text,
|
||||
lastMessageType = created.type,
|
||||
lastMessageCreatedAt = created.createdAt,
|
||||
updatedSortAt = created.createdAt,
|
||||
)
|
||||
AppResult.Success(Unit)
|
||||
}
|
||||
|
||||
is AppResult.Error -> {
|
||||
@@ -352,6 +395,70 @@ class NetworkMessageRepository @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun sendImageUrlMessage(
|
||||
chatId: Long,
|
||||
imageUrl: String,
|
||||
replyToMessageId: Long?,
|
||||
): AppResult<Unit> = withContext(ioDispatcher) {
|
||||
val normalizedUrl = imageUrl.trim()
|
||||
if (normalizedUrl.isBlank()) {
|
||||
return@withContext AppResult.Error(AppError.Server("Image URL is empty"))
|
||||
}
|
||||
val tempId = -System.currentTimeMillis()
|
||||
val now = java.time.Instant.now().toString()
|
||||
val tempMessage = MessageEntity(
|
||||
id = tempId,
|
||||
chatId = chatId,
|
||||
senderId = currentUserId ?: 0L,
|
||||
senderDisplayName = null,
|
||||
senderUsername = null,
|
||||
senderAvatarUrl = null,
|
||||
replyToMessageId = replyToMessageId,
|
||||
replyPreviewText = null,
|
||||
replyPreviewSenderName = null,
|
||||
forwardedFromMessageId = null,
|
||||
forwardedFromDisplayName = null,
|
||||
type = "image",
|
||||
text = normalizedUrl,
|
||||
status = "pending",
|
||||
attachmentWaveformJson = null,
|
||||
createdAt = now,
|
||||
updatedAt = null,
|
||||
)
|
||||
messageDao.upsertMessages(listOf(tempMessage))
|
||||
chatDao.updateLastMessage(
|
||||
chatId = chatId,
|
||||
lastMessageText = normalizedUrl,
|
||||
lastMessageType = "image",
|
||||
lastMessageCreatedAt = now,
|
||||
updatedSortAt = now,
|
||||
)
|
||||
try {
|
||||
val sent = messageApiService.sendMessage(
|
||||
request = MessageCreateRequestDto(
|
||||
chatId = chatId,
|
||||
type = "image",
|
||||
text = normalizedUrl,
|
||||
clientMessageId = UUID.randomUUID().toString(),
|
||||
replyToMessageId = replyToMessageId,
|
||||
)
|
||||
)
|
||||
messageDao.deleteMessage(tempId)
|
||||
messageDao.upsertMessages(listOf(sent.toEntity()))
|
||||
chatDao.updateLastMessage(
|
||||
chatId = chatId,
|
||||
lastMessageText = sent.text,
|
||||
lastMessageType = sent.type,
|
||||
lastMessageCreatedAt = sent.createdAt,
|
||||
updatedSortAt = sent.createdAt,
|
||||
)
|
||||
AppResult.Success(Unit)
|
||||
} catch (error: Throwable) {
|
||||
messageDao.deleteMessage(tempId)
|
||||
AppResult.Error(error.toAppError())
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun markMessageDelivered(chatId: Long, messageId: Long): AppResult<Unit> = withContext(ioDispatcher) {
|
||||
try {
|
||||
messageApiService.updateMessageStatus(
|
||||
@@ -368,6 +475,8 @@ class NetworkMessageRepository @Inject constructor(
|
||||
}
|
||||
|
||||
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(
|
||||
@@ -462,11 +571,20 @@ class NetworkMessageRepository @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
private fun mapMimeToMessageType(mimeType: String): String {
|
||||
private fun mapMimeToMessageType(
|
||||
mimeType: String,
|
||||
fileName: String,
|
||||
): String {
|
||||
val normalizedMime = mimeType.lowercase()
|
||||
val normalizedName = fileName.lowercase()
|
||||
return when {
|
||||
mimeType.startsWith("image/") -> "image"
|
||||
mimeType.startsWith("video/") -> "video"
|
||||
mimeType.startsWith("audio/") -> "audio"
|
||||
normalizedName.startsWith("circle_") -> "circle_video"
|
||||
normalizedMime == "image/gif" || normalizedName.endsWith(".gif") || normalizedName.startsWith("gif_") -> "image"
|
||||
normalizedName.startsWith("sticker_") || normalizedMime == "image/webp" -> "image"
|
||||
normalizedMime.startsWith("image/") -> "image"
|
||||
normalizedMime.startsWith("video/") -> "video"
|
||||
normalizedMime.startsWith("audio/") && normalizedName.startsWith("voice_") -> "voice"
|
||||
normalizedMime.startsWith("audio/") -> "audio"
|
||||
else -> "file"
|
||||
}
|
||||
}
|
||||
@@ -563,6 +681,29 @@ class NetworkMessageRepository @Inject constructor(
|
||||
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
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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,
|
||||
)
|
||||
@@ -67,12 +67,16 @@ class RealtimeEventParser @Inject constructor(
|
||||
}
|
||||
|
||||
"chat_updated" -> {
|
||||
val chatId = payload["chat_id"].longOrNull() ?: return RealtimeEvent.Ignored
|
||||
val chatId = payload["chat_id"].longOrNull()
|
||||
?: payload["id"].longOrNull()
|
||||
?: return RealtimeEvent.Ignored
|
||||
RealtimeEvent.ChatUpdated(chatId = chatId)
|
||||
}
|
||||
|
||||
"chat_deleted" -> {
|
||||
val chatId = payload["chat_id"].longOrNull() ?: return RealtimeEvent.Ignored
|
||||
val chatId = payload["chat_id"].longOrNull()
|
||||
?: payload["id"].longOrNull()
|
||||
?: return RealtimeEvent.Ignored
|
||||
RealtimeEvent.ChatDeleted(chatId = chatId)
|
||||
}
|
||||
|
||||
@@ -98,7 +102,12 @@ class RealtimeEventParser @Inject constructor(
|
||||
"message_read" -> {
|
||||
val chatId = payload["chat_id"].longOrNull() ?: return RealtimeEvent.Ignored
|
||||
val messageId = payload["message_id"].longOrNull() ?: return RealtimeEvent.Ignored
|
||||
RealtimeEvent.MessageRead(chatId = chatId, messageId = messageId)
|
||||
RealtimeEvent.MessageRead(
|
||||
chatId = chatId,
|
||||
messageId = messageId,
|
||||
userId = payload["user_id"].longOrNull(),
|
||||
lastReadMessageId = payload["last_read_message_id"].longOrNull(),
|
||||
)
|
||||
}
|
||||
|
||||
"typing_start" -> {
|
||||
|
||||
@@ -8,6 +8,8 @@ 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
|
||||
@@ -19,6 +21,7 @@ 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
|
||||
@@ -42,16 +45,20 @@ class WsRealtimeManager @Inject constructor(
|
||||
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")
|
||||
@@ -59,7 +66,10 @@ class WsRealtimeManager @Inject constructor(
|
||||
}
|
||||
|
||||
private suspend fun openSocket() {
|
||||
val accessToken = tokenRepository.getTokens()?.accessToken ?: return
|
||||
val accessToken = tokenRepository.getTokens()?.accessToken ?: run {
|
||||
_connectionState.value = RealtimeConnectionState.Disconnected
|
||||
return
|
||||
}
|
||||
val wsUrl = BuildConfig.API_BASE_URL
|
||||
.replace("http://", "ws://")
|
||||
.replace("https://", "wss://")
|
||||
@@ -72,6 +82,7 @@ class WsRealtimeManager @Inject constructor(
|
||||
|
||||
private fun scheduleReconnect() {
|
||||
if (manualDisconnect.get()) return
|
||||
_connectionState.value = RealtimeConnectionState.Reconnecting
|
||||
scope.launch {
|
||||
delay(reconnectDelayMs)
|
||||
reconnectDelayMs = (reconnectDelayMs * 2).coerceAtMost(MAX_RECONNECT_MS)
|
||||
@@ -98,6 +109,7 @@ class WsRealtimeManager @Inject constructor(
|
||||
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)
|
||||
}
|
||||
@@ -111,18 +123,27 @@ class WsRealtimeManager @Inject constructor(
|
||||
|
||||
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()
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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(),
|
||||
)
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -13,11 +13,16 @@ import okhttp3.logging.HttpLoggingInterceptor
|
||||
import retrofit2.Retrofit
|
||||
import ru.daemonlord.messenger.BuildConfig
|
||||
import ru.daemonlord.messenger.core.network.AuthHeaderInterceptor
|
||||
import ru.daemonlord.messenger.core.network.ApiVersionInterceptor
|
||||
import ru.daemonlord.messenger.core.network.TokenRefreshAuthenticator
|
||||
import ru.daemonlord.messenger.data.auth.api.AuthApiService
|
||||
import ru.daemonlord.messenger.data.chat.api.ChatApiService
|
||||
import ru.daemonlord.messenger.data.media.api.MediaApiService
|
||||
import ru.daemonlord.messenger.data.message.api.MessageApiService
|
||||
import ru.daemonlord.messenger.data.notifications.api.NotificationApiService
|
||||
import ru.daemonlord.messenger.data.notifications.api.PushTokenApiService
|
||||
import ru.daemonlord.messenger.data.search.api.SearchApiService
|
||||
import ru.daemonlord.messenger.data.user.api.UserApiService
|
||||
import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory
|
||||
import java.util.concurrent.TimeUnit
|
||||
import javax.inject.Singleton
|
||||
@@ -87,11 +92,13 @@ object NetworkModule {
|
||||
@Singleton
|
||||
fun provideApiClient(
|
||||
loggingInterceptor: HttpLoggingInterceptor,
|
||||
apiVersionInterceptor: ApiVersionInterceptor,
|
||||
authHeaderInterceptor: AuthHeaderInterceptor,
|
||||
tokenRefreshAuthenticator: TokenRefreshAuthenticator,
|
||||
): OkHttpClient {
|
||||
return OkHttpClient.Builder()
|
||||
.addInterceptor(loggingInterceptor)
|
||||
.addInterceptor(apiVersionInterceptor)
|
||||
.addInterceptor(authHeaderInterceptor)
|
||||
.authenticator(tokenRefreshAuthenticator)
|
||||
.connectTimeout(20, TimeUnit.SECONDS)
|
||||
@@ -137,4 +144,28 @@ object NetworkModule {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,15 +7,25 @@ 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
|
||||
@@ -40,6 +50,12 @@ abstract class RepositoryModule {
|
||||
repository: NetworkChatRepository,
|
||||
): ChatRepository
|
||||
|
||||
@Binds
|
||||
@Singleton
|
||||
abstract fun bindChatSearchRepository(
|
||||
repository: DataStoreChatSearchRepository,
|
||||
): ChatSearchRepository
|
||||
|
||||
@Binds
|
||||
@Singleton
|
||||
abstract fun bindMessageRepository(
|
||||
@@ -57,4 +73,28 @@ abstract class RepositoryModule {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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?,
|
||||
)
|
||||
@@ -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>>
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -5,6 +5,12 @@ data class AuthUser(
|
||||
val email: String,
|
||||
val name: String,
|
||||
val username: String,
|
||||
val bio: String?,
|
||||
val avatarUrl: String?,
|
||||
val emailVerified: Boolean,
|
||||
val twofaEnabled: Boolean,
|
||||
val privacyPrivateMessages: String,
|
||||
val privacyLastSeen: String,
|
||||
val privacyAvatar: String,
|
||||
val privacyGroupInvites: String,
|
||||
)
|
||||
|
||||
@@ -2,10 +2,18 @@ package ru.daemonlord.messenger.domain.auth.repository
|
||||
|
||||
import ru.daemonlord.messenger.domain.auth.model.AuthUser
|
||||
import ru.daemonlord.messenger.domain.auth.model.AuthSession
|
||||
import ru.daemonlord.messenger.domain.auth.model.AuthEmailStatus
|
||||
import ru.daemonlord.messenger.domain.common.AppResult
|
||||
|
||||
interface AuthRepository {
|
||||
suspend fun login(email: String, password: String): AppResult<AuthUser>
|
||||
suspend fun checkEmailStatus(email: String): AppResult<AuthEmailStatus>
|
||||
suspend fun register(email: String, name: String, username: String, password: String): AppResult<Unit>
|
||||
suspend fun login(
|
||||
email: String,
|
||||
password: String,
|
||||
otpCode: String? = null,
|
||||
recoveryCode: String? = null,
|
||||
): AppResult<AuthUser>
|
||||
suspend fun refreshTokens(): AppResult<Unit>
|
||||
suspend fun getMe(): AppResult<AuthUser>
|
||||
suspend fun restoreSession(): AppResult<AuthUser>
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
package ru.daemonlord.messenger.domain.auth.usecase
|
||||
|
||||
import ru.daemonlord.messenger.domain.auth.model.AuthEmailStatus
|
||||
import ru.daemonlord.messenger.domain.auth.repository.AuthRepository
|
||||
import ru.daemonlord.messenger.domain.common.AppResult
|
||||
import javax.inject.Inject
|
||||
|
||||
class CheckEmailStatusUseCase @Inject constructor(
|
||||
private val authRepository: AuthRepository,
|
||||
) {
|
||||
suspend operator fun invoke(email: String): AppResult<AuthEmailStatus> {
|
||||
return authRepository.checkEmailStatus(email)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,7 +8,17 @@ import javax.inject.Inject
|
||||
class LoginUseCase @Inject constructor(
|
||||
private val authRepository: AuthRepository,
|
||||
) {
|
||||
suspend operator fun invoke(email: String, password: String): AppResult<AuthUser> {
|
||||
return authRepository.login(email = email, password = password)
|
||||
suspend operator fun invoke(
|
||||
email: String,
|
||||
password: String,
|
||||
otpCode: String? = null,
|
||||
recoveryCode: String? = null,
|
||||
): AppResult<AuthUser> {
|
||||
return authRepository.login(
|
||||
email = email,
|
||||
password = password,
|
||||
otpCode = otpCode,
|
||||
recoveryCode = recoveryCode,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
package ru.daemonlord.messenger.domain.auth.usecase
|
||||
|
||||
import ru.daemonlord.messenger.domain.auth.repository.AuthRepository
|
||||
import ru.daemonlord.messenger.domain.common.AppResult
|
||||
import javax.inject.Inject
|
||||
|
||||
class RegisterUseCase @Inject constructor(
|
||||
private val authRepository: AuthRepository,
|
||||
) {
|
||||
suspend operator fun invoke(
|
||||
email: String,
|
||||
name: String,
|
||||
username: String,
|
||||
password: String,
|
||||
): AppResult<Unit> {
|
||||
return authRepository.register(
|
||||
email = email,
|
||||
name = name,
|
||||
username = username,
|
||||
password = password,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
package ru.daemonlord.messenger.domain.chat.model
|
||||
|
||||
data class ChatBanItem(
|
||||
val userId: Long,
|
||||
val name: String,
|
||||
val username: String?,
|
||||
val bannedAt: String?,
|
||||
)
|
||||
@@ -0,0 +1,9 @@
|
||||
package ru.daemonlord.messenger.domain.chat.model
|
||||
|
||||
data class ChatMemberItem(
|
||||
val userId: Long,
|
||||
val role: String,
|
||||
val name: String,
|
||||
val username: String?,
|
||||
val avatarUrl: String?,
|
||||
)
|
||||
@@ -0,0 +1,8 @@
|
||||
package ru.daemonlord.messenger.domain.chat.model
|
||||
|
||||
data class ChatNotificationSettings(
|
||||
val chatId: Long,
|
||||
val userId: Long,
|
||||
val muted: Boolean,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
package ru.daemonlord.messenger.domain.chat.model
|
||||
|
||||
data class DiscoverChatItem(
|
||||
val id: Long,
|
||||
val type: String,
|
||||
val displayTitle: String,
|
||||
val handle: String?,
|
||||
val avatarUrl: String?,
|
||||
val isMember: Boolean,
|
||||
)
|
||||
@@ -1,8 +1,12 @@
|
||||
package ru.daemonlord.messenger.domain.chat.repository
|
||||
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import ru.daemonlord.messenger.domain.chat.model.ChatBanItem
|
||||
import ru.daemonlord.messenger.domain.chat.model.ChatInviteLink
|
||||
import ru.daemonlord.messenger.domain.chat.model.ChatItem
|
||||
import ru.daemonlord.messenger.domain.chat.model.ChatMemberItem
|
||||
import ru.daemonlord.messenger.domain.chat.model.ChatNotificationSettings
|
||||
import ru.daemonlord.messenger.domain.chat.model.DiscoverChatItem
|
||||
import ru.daemonlord.messenger.domain.common.AppResult
|
||||
|
||||
interface ChatRepository {
|
||||
@@ -10,7 +14,41 @@ interface ChatRepository {
|
||||
fun observeChat(chatId: Long): Flow<ChatItem?>
|
||||
suspend fun refreshChats(archived: Boolean): AppResult<Unit>
|
||||
suspend fun refreshChat(chatId: Long): AppResult<Unit>
|
||||
suspend fun getSavedChat(): AppResult<ChatItem>
|
||||
suspend fun createInviteLink(chatId: Long): AppResult<ChatInviteLink>
|
||||
suspend fun joinByInvite(token: String): AppResult<ChatItem>
|
||||
suspend fun createChat(
|
||||
type: String,
|
||||
title: String?,
|
||||
isPublic: Boolean,
|
||||
handle: String?,
|
||||
description: String?,
|
||||
memberIds: List<Long>,
|
||||
): AppResult<ChatItem>
|
||||
suspend fun discoverChats(query: String?): AppResult<List<DiscoverChatItem>>
|
||||
suspend fun joinChat(chatId: Long): AppResult<ChatItem>
|
||||
suspend fun leaveChat(chatId: Long): AppResult<Unit>
|
||||
suspend fun archiveChat(chatId: Long): AppResult<ChatItem>
|
||||
suspend fun unarchiveChat(chatId: Long): AppResult<ChatItem>
|
||||
suspend fun pinChat(chatId: Long): AppResult<ChatItem>
|
||||
suspend fun unpinChat(chatId: Long): AppResult<ChatItem>
|
||||
suspend fun updateChatTitle(chatId: Long, title: String): AppResult<ChatItem>
|
||||
suspend fun updateChatProfile(
|
||||
chatId: Long,
|
||||
title: String? = null,
|
||||
description: String? = null,
|
||||
avatarUrl: String? = null,
|
||||
): AppResult<ChatItem>
|
||||
suspend fun clearChat(chatId: Long): AppResult<Unit>
|
||||
suspend fun removeChat(chatId: Long, forAll: Boolean = false): AppResult<Unit>
|
||||
suspend fun getChatNotifications(chatId: Long): AppResult<ChatNotificationSettings>
|
||||
suspend fun updateChatNotifications(chatId: Long, muted: Boolean): AppResult<ChatNotificationSettings>
|
||||
suspend fun listMembers(chatId: Long): AppResult<List<ChatMemberItem>>
|
||||
suspend fun listBans(chatId: Long): AppResult<List<ChatBanItem>>
|
||||
suspend fun addMember(chatId: Long, userId: Long): AppResult<ChatMemberItem>
|
||||
suspend fun updateMemberRole(chatId: Long, userId: Long, role: String): AppResult<ChatMemberItem>
|
||||
suspend fun removeMember(chatId: Long, userId: Long): AppResult<Unit>
|
||||
suspend fun banMember(chatId: Long, userId: Long): AppResult<Unit>
|
||||
suspend fun unbanMember(chatId: Long, userId: Long): AppResult<Unit>
|
||||
suspend fun deleteChat(chatId: Long)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
package ru.daemonlord.messenger.domain.chat.repository
|
||||
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
interface ChatSearchRepository {
|
||||
fun observeHistoryChatIds(): Flow<List<Long>>
|
||||
fun observeRecentChatIds(): Flow<List<Long>>
|
||||
suspend fun addHistoryChat(chatId: Long)
|
||||
suspend fun addRecentChat(chatId: Long)
|
||||
suspend fun clearHistory()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
package ru.daemonlord.messenger.domain.media.model
|
||||
|
||||
data class UploadedAttachment(
|
||||
val fileUrl: String,
|
||||
val fileType: String,
|
||||
val fileSize: Long,
|
||||
)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package ru.daemonlord.messenger.domain.media.repository
|
||||
|
||||
import ru.daemonlord.messenger.domain.common.AppResult
|
||||
import ru.daemonlord.messenger.domain.media.model.UploadedAttachment
|
||||
|
||||
interface MediaRepository {
|
||||
suspend fun uploadAndAttach(
|
||||
@@ -8,5 +9,5 @@ interface MediaRepository {
|
||||
fileName: String,
|
||||
mimeType: String,
|
||||
bytes: ByteArray,
|
||||
): AppResult<Unit>
|
||||
): AppResult<UploadedAttachment>
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package ru.daemonlord.messenger.domain.media.usecase
|
||||
|
||||
import ru.daemonlord.messenger.domain.common.AppResult
|
||||
import ru.daemonlord.messenger.domain.media.model.UploadedAttachment
|
||||
import ru.daemonlord.messenger.domain.media.repository.MediaRepository
|
||||
import javax.inject.Inject
|
||||
|
||||
@@ -12,7 +13,7 @@ class UploadAndAttachMediaUseCase @Inject constructor(
|
||||
fileName: String,
|
||||
mimeType: String,
|
||||
bytes: ByteArray,
|
||||
): AppResult<Unit> {
|
||||
): AppResult<UploadedAttachment> {
|
||||
return mediaRepository.uploadAndAttach(
|
||||
messageId = messageId,
|
||||
fileName = fileName,
|
||||
|
||||
@@ -5,6 +5,7 @@ data class MessageItem(
|
||||
val chatId: Long,
|
||||
val senderId: Long,
|
||||
val senderDisplayName: String?,
|
||||
val senderUsername: String? = null,
|
||||
val type: String,
|
||||
val text: String?,
|
||||
val createdAt: String,
|
||||
|
||||
@@ -7,6 +7,8 @@ import ru.daemonlord.messenger.domain.message.model.MessageReaction
|
||||
|
||||
interface MessageRepository {
|
||||
fun observeMessages(chatId: Long, limit: Int = 50): Flow<List<MessageItem>>
|
||||
suspend fun searchMessages(query: String, chatId: Long? = null): AppResult<List<MessageItem>>
|
||||
suspend fun getMessageThread(messageId: Long, limit: Int = 100): AppResult<List<MessageItem>>
|
||||
suspend fun syncRecentMessages(chatId: Long): AppResult<Unit>
|
||||
suspend fun loadMoreMessages(chatId: Long, beforeMessageId: Long): AppResult<Unit>
|
||||
suspend fun sendTextMessage(chatId: Long, text: String, replyToMessageId: Long? = null): AppResult<Unit>
|
||||
@@ -18,6 +20,11 @@ interface MessageRepository {
|
||||
caption: String? = null,
|
||||
replyToMessageId: Long? = null,
|
||||
): AppResult<Unit>
|
||||
suspend fun sendImageUrlMessage(
|
||||
chatId: Long,
|
||||
imageUrl: String,
|
||||
replyToMessageId: Long? = null,
|
||||
): AppResult<Unit>
|
||||
suspend fun editMessage(messageId: Long, newText: String): AppResult<Unit>
|
||||
suspend fun deleteMessage(messageId: Long, forAll: Boolean): AppResult<Unit>
|
||||
suspend fun markMessageDelivered(chatId: Long, messageId: Long): AppResult<Unit>
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
package ru.daemonlord.messenger.domain.message.usecase
|
||||
|
||||
import ru.daemonlord.messenger.domain.common.AppResult
|
||||
import ru.daemonlord.messenger.domain.message.repository.MessageRepository
|
||||
import javax.inject.Inject
|
||||
|
||||
class SendImageUrlMessageUseCase @Inject constructor(
|
||||
private val messageRepository: MessageRepository,
|
||||
) {
|
||||
suspend operator fun invoke(
|
||||
chatId: Long,
|
||||
imageUrl: String,
|
||||
replyToMessageId: Long? = null,
|
||||
): AppResult<Unit> {
|
||||
return messageRepository.sendImageUrlMessage(
|
||||
chatId = chatId,
|
||||
imageUrl = imageUrl,
|
||||
replyToMessageId = replyToMessageId,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,13 @@
|
||||
package ru.daemonlord.messenger.domain.realtime
|
||||
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import ru.daemonlord.messenger.domain.realtime.model.RealtimeConnectionState
|
||||
import ru.daemonlord.messenger.domain.realtime.model.RealtimeEvent
|
||||
|
||||
interface RealtimeManager {
|
||||
val events: Flow<RealtimeEvent>
|
||||
val connectionState: StateFlow<RealtimeConnectionState>
|
||||
fun connect()
|
||||
fun disconnect()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
package ru.daemonlord.messenger.domain.realtime.model
|
||||
|
||||
enum class RealtimeConnectionState {
|
||||
Disconnected,
|
||||
Connecting,
|
||||
Reconnecting,
|
||||
Connected,
|
||||
}
|
||||
@@ -52,6 +52,8 @@ sealed interface RealtimeEvent {
|
||||
data class MessageRead(
|
||||
val chatId: Long,
|
||||
val messageId: Long,
|
||||
val userId: Long?,
|
||||
val lastReadMessageId: Long?,
|
||||
) : RealtimeEvent
|
||||
|
||||
data class TypingStart(
|
||||
|
||||
@@ -10,9 +10,11 @@ import ru.daemonlord.messenger.data.chat.local.dao.ChatDao
|
||||
import ru.daemonlord.messenger.core.notifications.ActiveChatTracker
|
||||
import ru.daemonlord.messenger.core.notifications.ChatNotificationPayload
|
||||
import ru.daemonlord.messenger.core.notifications.NotificationDispatcher
|
||||
import ru.daemonlord.messenger.core.token.TokenRepository
|
||||
import ru.daemonlord.messenger.data.message.local.dao.MessageDao
|
||||
import ru.daemonlord.messenger.data.message.local.entity.MessageEntity
|
||||
import ru.daemonlord.messenger.domain.chat.repository.ChatRepository
|
||||
import ru.daemonlord.messenger.domain.notifications.repository.NotificationSettingsRepository
|
||||
import ru.daemonlord.messenger.domain.notifications.usecase.ShouldShowMessageNotificationUseCase
|
||||
import ru.daemonlord.messenger.domain.realtime.RealtimeManager
|
||||
import ru.daemonlord.messenger.domain.realtime.model.RealtimeEvent
|
||||
@@ -27,6 +29,8 @@ class HandleRealtimeEventsUseCase @Inject constructor(
|
||||
private val messageDao: MessageDao,
|
||||
private val notificationDispatcher: NotificationDispatcher,
|
||||
private val activeChatTracker: ActiveChatTracker,
|
||||
private val tokenRepository: TokenRepository,
|
||||
private val notificationSettingsRepository: NotificationSettingsRepository,
|
||||
private val shouldShowMessageNotificationUseCase: ShouldShowMessageNotificationUseCase,
|
||||
) {
|
||||
|
||||
@@ -45,6 +49,7 @@ class HandleRealtimeEventsUseCase @Inject constructor(
|
||||
}
|
||||
|
||||
is RealtimeEvent.ReceiveMessage -> {
|
||||
val activeChatId = activeChatTracker.activeChatId.value
|
||||
messageDao.upsertMessages(
|
||||
listOf(
|
||||
MessageEntity(
|
||||
@@ -75,18 +80,41 @@ class HandleRealtimeEventsUseCase @Inject constructor(
|
||||
lastMessageCreatedAt = event.createdAt,
|
||||
updatedSortAt = event.createdAt,
|
||||
)
|
||||
chatDao.incrementUnread(chatId = event.chatId)
|
||||
val activeChatId = activeChatTracker.activeChatId.value
|
||||
if (activeChatId == event.chatId) {
|
||||
chatDao.markChatRead(chatId = event.chatId)
|
||||
} else {
|
||||
chatDao.incrementUnread(chatId = event.chatId)
|
||||
}
|
||||
val activeUserId = tokenRepository.getActiveUserId()
|
||||
val myUsername = activeUserId?.let { userId ->
|
||||
tokenRepository.getAccounts()
|
||||
.firstOrNull { it.userId == userId }
|
||||
?.username
|
||||
?.trim()
|
||||
?.removePrefix("@")
|
||||
?.lowercase()
|
||||
}
|
||||
val isMentionByText = if (myUsername.isNullOrBlank()) {
|
||||
false
|
||||
} else {
|
||||
Regex("(^|\\W)@${Regex.escape(myUsername)}(\\W|$)", RegexOption.IGNORE_CASE)
|
||||
.containsMatchIn(event.text.orEmpty())
|
||||
}
|
||||
val isMention = event.isMention || isMentionByText
|
||||
val muted = chatDao.isChatMuted(event.chatId) == true
|
||||
val shouldNotify = shouldShowMessageNotificationUseCase(
|
||||
chatId = event.chatId,
|
||||
isMention = event.isMention,
|
||||
isMention = isMention,
|
||||
serverMuted = muted,
|
||||
)
|
||||
if (activeChatId != event.chatId && shouldNotify) {
|
||||
val title = chatDao.getChatDisplayTitle(event.chatId) ?: "New message"
|
||||
val body = event.text?.takeIf { it.isNotBlank() }
|
||||
?: when (event.type?.lowercase()) {
|
||||
val previewEnabled = notificationSettingsRepository.getSettings().previewEnabled
|
||||
val body = (if (previewEnabled) {
|
||||
event.text?.takeIf { it.isNotBlank() }
|
||||
} else {
|
||||
null
|
||||
}) ?: when (event.type?.lowercase()) {
|
||||
"image" -> "Photo"
|
||||
"video" -> "Video"
|
||||
"audio" -> "Audio"
|
||||
@@ -100,7 +128,7 @@ class HandleRealtimeEventsUseCase @Inject constructor(
|
||||
messageId = event.messageId,
|
||||
title = title,
|
||||
body = body,
|
||||
isMention = event.isMention,
|
||||
isMention = isMention,
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -128,7 +156,8 @@ class HandleRealtimeEventsUseCase @Inject constructor(
|
||||
}
|
||||
|
||||
is RealtimeEvent.ChatUpdated -> {
|
||||
chatRepository.refreshChat(chatId = event.chatId)
|
||||
chatRepository.refreshChats(archived = false)
|
||||
chatRepository.refreshChats(archived = true)
|
||||
}
|
||||
|
||||
is RealtimeEvent.ChatDeleted -> {
|
||||
@@ -163,6 +192,7 @@ class HandleRealtimeEventsUseCase @Inject constructor(
|
||||
messageId = event.messageId,
|
||||
status = "read",
|
||||
)
|
||||
chatRepository.refreshChat(chatId = event.chatId)
|
||||
}
|
||||
|
||||
is RealtimeEvent.TypingStart -> Unit
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
package ru.daemonlord.messenger.domain.search.model
|
||||
|
||||
import ru.daemonlord.messenger.domain.account.model.UserSearchItem
|
||||
import ru.daemonlord.messenger.domain.chat.model.DiscoverChatItem
|
||||
import ru.daemonlord.messenger.domain.message.model.MessageItem
|
||||
|
||||
data class GlobalSearchResult(
|
||||
val users: List<UserSearchItem>,
|
||||
val chats: List<DiscoverChatItem>,
|
||||
val messages: List<MessageItem>,
|
||||
)
|
||||
@@ -0,0 +1,13 @@
|
||||
package ru.daemonlord.messenger.domain.search.repository
|
||||
|
||||
import ru.daemonlord.messenger.domain.common.AppResult
|
||||
import ru.daemonlord.messenger.domain.search.model.GlobalSearchResult
|
||||
|
||||
interface SearchRepository {
|
||||
suspend fun globalSearch(
|
||||
query: String,
|
||||
usersLimit: Int = 10,
|
||||
chatsLimit: Int = 10,
|
||||
messagesLimit: Int = 10,
|
||||
): AppResult<GlobalSearchResult>
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package ru.daemonlord.messenger.domain.settings.model
|
||||
|
||||
enum class AppLanguage(val tag: String?) {
|
||||
SYSTEM(null),
|
||||
RUSSIAN("ru"),
|
||||
ENGLISH("en");
|
||||
|
||||
companion object {
|
||||
fun fromTag(tag: String?): AppLanguage {
|
||||
if (tag.isNullOrBlank()) return SYSTEM
|
||||
return entries.firstOrNull { it.tag.equals(tag, ignoreCase = true) } ?: SYSTEM
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
package ru.daemonlord.messenger.domain.settings.model
|
||||
|
||||
enum class AppThemeMode {
|
||||
LIGHT,
|
||||
DARK,
|
||||
SYSTEM,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
package ru.daemonlord.messenger.domain.settings.repository
|
||||
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import ru.daemonlord.messenger.domain.settings.model.AppLanguage
|
||||
|
||||
interface LanguageRepository {
|
||||
fun observeLanguage(): Flow<AppLanguage>
|
||||
suspend fun getLanguage(): AppLanguage
|
||||
suspend fun setLanguage(language: AppLanguage)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
package ru.daemonlord.messenger.domain.settings.repository
|
||||
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import ru.daemonlord.messenger.domain.settings.model.AppThemeMode
|
||||
|
||||
interface ThemeRepository {
|
||||
fun observeThemeMode(): Flow<AppThemeMode>
|
||||
suspend fun getThemeMode(): AppThemeMode
|
||||
suspend fun setThemeMode(mode: AppThemeMode)
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import android.util.Log
|
||||
import com.google.firebase.messaging.FirebaseMessagingService
|
||||
import com.google.firebase.messaging.RemoteMessage
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import ru.daemonlord.messenger.core.notifications.ActiveChatTracker
|
||||
import ru.daemonlord.messenger.core.notifications.NotificationDispatcher
|
||||
import javax.inject.Inject
|
||||
|
||||
@@ -13,12 +14,22 @@ class MessengerFirebaseMessagingService : FirebaseMessagingService() {
|
||||
@Inject
|
||||
lateinit var notificationDispatcher: NotificationDispatcher
|
||||
|
||||
@Inject
|
||||
lateinit var pushTokenSyncManager: PushTokenSyncManager
|
||||
|
||||
@Inject
|
||||
lateinit var activeChatTracker: ActiveChatTracker
|
||||
|
||||
override fun onMessageReceived(message: RemoteMessage) {
|
||||
val payload = PushPayloadParser.parse(message) ?: return
|
||||
if (activeChatTracker.activeChatId.value == payload.chatId) {
|
||||
return
|
||||
}
|
||||
notificationDispatcher.showChatMessage(payload)
|
||||
}
|
||||
|
||||
override fun onNewToken(token: String) {
|
||||
Log.i("MessengerPush", "FCM token refreshed (${token.take(10)}...)")
|
||||
pushTokenSyncManager.onNewToken(token)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,7 +29,8 @@ object PushPayloadParser {
|
||||
?: data["text"]
|
||||
?: "Open chat"
|
||||
val isMention = data["is_mention"]?.equals("true", ignoreCase = true) == true ||
|
||||
data["mention"]?.equals("true", ignoreCase = true) == true
|
||||
data["mention"]?.equals("true", ignoreCase = true) == true ||
|
||||
data["type"]?.equals("mention", ignoreCase = true) == true
|
||||
|
||||
return ChatNotificationPayload(
|
||||
chatId = chatId,
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
package ru.daemonlord.messenger.push
|
||||
|
||||
import android.content.SharedPreferences
|
||||
import com.google.firebase.messaging.FirebaseMessaging
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.launch
|
||||
import ru.daemonlord.messenger.BuildConfig
|
||||
import ru.daemonlord.messenger.core.token.TokenRepository
|
||||
import ru.daemonlord.messenger.data.notifications.api.PushTokenApiService
|
||||
import ru.daemonlord.messenger.data.notifications.dto.PushTokenDeleteRequestDto
|
||||
import ru.daemonlord.messenger.data.notifications.dto.PushTokenUpsertRequestDto
|
||||
import ru.daemonlord.messenger.di.IoDispatcher
|
||||
import ru.daemonlord.messenger.di.TokenPrefs
|
||||
import timber.log.Timber
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
class PushTokenSyncManager @Inject constructor(
|
||||
private val tokenRepository: TokenRepository,
|
||||
private val pushTokenApiService: PushTokenApiService,
|
||||
@TokenPrefs private val securePrefs: SharedPreferences,
|
||||
@IoDispatcher ioDispatcher: CoroutineDispatcher,
|
||||
) {
|
||||
|
||||
private val scope = CoroutineScope(SupervisorJob() + ioDispatcher)
|
||||
|
||||
fun onNewToken(token: String) {
|
||||
val cleaned = token.trim()
|
||||
if (cleaned.isBlank()) {
|
||||
return
|
||||
}
|
||||
securePrefs.edit().putString(KEY_LAST_FCM_TOKEN, cleaned).apply()
|
||||
scope.launch {
|
||||
registerTokenIfPossible(cleaned)
|
||||
}
|
||||
}
|
||||
|
||||
fun triggerBestEffortSync() {
|
||||
val cached = securePrefs.getString(KEY_LAST_FCM_TOKEN, null)?.trim().orEmpty()
|
||||
if (cached.isNotBlank()) {
|
||||
scope.launch {
|
||||
registerTokenIfPossible(cached)
|
||||
}
|
||||
}
|
||||
FirebaseMessaging.getInstance().token
|
||||
.addOnSuccessListener { token -> onNewToken(token) }
|
||||
.addOnFailureListener { error ->
|
||||
Timber.w(error, "Failed to fetch FCM token for sync")
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun unregisterCurrentTokenOnLogout() {
|
||||
val token = securePrefs.getString(KEY_LAST_FCM_TOKEN, null)?.trim().orEmpty()
|
||||
if (token.isBlank()) {
|
||||
return
|
||||
}
|
||||
val hasTokens = tokenRepository.getTokens() != null
|
||||
if (!hasTokens) {
|
||||
return
|
||||
}
|
||||
runCatching {
|
||||
pushTokenApiService.delete(
|
||||
request = PushTokenDeleteRequestDto(
|
||||
platform = PLATFORM_ANDROID,
|
||||
token = token,
|
||||
)
|
||||
)
|
||||
}.onFailure { error ->
|
||||
Timber.w(error, "Failed to unregister push token on logout")
|
||||
}
|
||||
securePrefs.edit()
|
||||
.remove(KEY_LAST_SYNCED_TOKEN)
|
||||
.remove(KEY_LAST_SYNCED_USER_ID)
|
||||
.apply()
|
||||
}
|
||||
|
||||
private suspend fun registerTokenIfPossible(token: String) {
|
||||
val hasTokens = tokenRepository.getTokens() != null
|
||||
if (!hasTokens) {
|
||||
return
|
||||
}
|
||||
val activeUserId = tokenRepository.getActiveUserId()
|
||||
if (activeUserId == null) {
|
||||
return
|
||||
}
|
||||
val lastSyncedToken = securePrefs.getString(KEY_LAST_SYNCED_TOKEN, null)?.trim()
|
||||
val lastSyncedUserId = securePrefs.getLong(KEY_LAST_SYNCED_USER_ID, -1L).takeIf { it > 0L }
|
||||
if (lastSyncedToken == token && lastSyncedUserId == activeUserId) {
|
||||
return
|
||||
}
|
||||
runCatching {
|
||||
pushTokenApiService.upsert(
|
||||
request = PushTokenUpsertRequestDto(
|
||||
platform = PLATFORM_ANDROID,
|
||||
token = token,
|
||||
deviceId = null,
|
||||
appVersion = BuildConfig.VERSION_NAME,
|
||||
)
|
||||
)
|
||||
securePrefs.edit()
|
||||
.putString(KEY_LAST_SYNCED_TOKEN, token)
|
||||
.putLong(KEY_LAST_SYNCED_USER_ID, activeUserId)
|
||||
.apply()
|
||||
}.onFailure { error ->
|
||||
Timber.w(error, "Failed to sync push token")
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val KEY_LAST_FCM_TOKEN = "last_fcm_token"
|
||||
const val KEY_LAST_SYNCED_TOKEN = "last_synced_push_token"
|
||||
const val KEY_LAST_SYNCED_USER_ID = "last_synced_push_user_id"
|
||||
const val PLATFORM_ANDROID = "android"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package ru.daemonlord.messenger.ui.account
|
||||
|
||||
import ru.daemonlord.messenger.domain.account.model.UserSearchItem
|
||||
import ru.daemonlord.messenger.domain.account.model.AccountNotification
|
||||
import ru.daemonlord.messenger.domain.auth.model.AuthSession
|
||||
import ru.daemonlord.messenger.domain.auth.model.AuthUser
|
||||
import ru.daemonlord.messenger.domain.settings.model.AppLanguage
|
||||
import ru.daemonlord.messenger.domain.settings.model.AppThemeMode
|
||||
|
||||
data class AccountUiState(
|
||||
val isLoading: Boolean = false,
|
||||
val isSaving: Boolean = false,
|
||||
val profile: AuthUser? = null,
|
||||
val sessions: List<AuthSession> = emptyList(),
|
||||
val blockedUsers: List<UserSearchItem> = emptyList(),
|
||||
val twoFactorSecret: String? = null,
|
||||
val twoFactorOtpAuthUrl: String? = null,
|
||||
val recoveryCodes: List<String> = emptyList(),
|
||||
val recoveryCodesRemaining: Int? = null,
|
||||
val activeUserId: Long? = null,
|
||||
val storedAccounts: List<StoredAccountUi> = emptyList(),
|
||||
val appThemeMode: AppThemeMode = AppThemeMode.SYSTEM,
|
||||
val appLanguage: AppLanguage = AppLanguage.SYSTEM,
|
||||
val notificationsEnabled: Boolean = true,
|
||||
val notificationsPreviewEnabled: Boolean = true,
|
||||
val notificationsHistory: List<AccountNotification> = emptyList(),
|
||||
val isAddingAccount: Boolean = false,
|
||||
val message: String? = null,
|
||||
val errorMessage: String? = null,
|
||||
)
|
||||
|
||||
data class StoredAccountUi(
|
||||
val userId: Long,
|
||||
val title: String,
|
||||
val subtitle: String,
|
||||
val avatarUrl: String?,
|
||||
val isActive: Boolean,
|
||||
)
|
||||
@@ -0,0 +1,476 @@
|
||||
package ru.daemonlord.messenger.ui.account
|
||||
|
||||
import android.content.Context
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import androidx.appcompat.app.AppCompatDelegate
|
||||
import androidx.core.os.LocaleListCompat
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import ru.daemonlord.messenger.core.token.TokenRepository
|
||||
import ru.daemonlord.messenger.push.PushTokenSyncManager
|
||||
import ru.daemonlord.messenger.domain.auth.usecase.LoginUseCase
|
||||
import ru.daemonlord.messenger.domain.account.repository.AccountRepository
|
||||
import ru.daemonlord.messenger.domain.chat.repository.ChatRepository
|
||||
import ru.daemonlord.messenger.domain.common.AppError
|
||||
import ru.daemonlord.messenger.domain.common.AppResult
|
||||
import ru.daemonlord.messenger.domain.notifications.repository.NotificationSettingsRepository
|
||||
import ru.daemonlord.messenger.domain.realtime.RealtimeManager
|
||||
import ru.daemonlord.messenger.domain.settings.model.AppLanguage
|
||||
import ru.daemonlord.messenger.domain.settings.model.AppThemeMode
|
||||
import ru.daemonlord.messenger.domain.settings.repository.LanguageRepository
|
||||
import ru.daemonlord.messenger.domain.settings.repository.ThemeRepository
|
||||
import ru.daemonlord.messenger.R
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltViewModel
|
||||
class AccountViewModel @Inject constructor(
|
||||
private val accountRepository: AccountRepository,
|
||||
private val tokenRepository: TokenRepository,
|
||||
private val loginUseCase: LoginUseCase,
|
||||
private val chatRepository: ChatRepository,
|
||||
private val realtimeManager: RealtimeManager,
|
||||
private val notificationSettingsRepository: NotificationSettingsRepository,
|
||||
private val languageRepository: LanguageRepository,
|
||||
private val themeRepository: ThemeRepository,
|
||||
private val pushTokenSyncManager: PushTokenSyncManager,
|
||||
@ApplicationContext private val context: Context,
|
||||
) : ViewModel() {
|
||||
private val _uiState = MutableStateFlow(AccountUiState())
|
||||
val uiState: StateFlow<AccountUiState> = _uiState.asStateFlow()
|
||||
|
||||
init {
|
||||
refresh()
|
||||
}
|
||||
|
||||
fun refresh() {
|
||||
viewModelScope.launch {
|
||||
_uiState.update { it.copy(isLoading = true, errorMessage = null, message = null) }
|
||||
val me = accountRepository.getMe()
|
||||
val sessions = accountRepository.listSessions()
|
||||
val blocked = accountRepository.listBlockedUsers()
|
||||
val notifications = accountRepository.listNotifications(limit = 50)
|
||||
val activeUserId = tokenRepository.getActiveUserId()
|
||||
val storedAccounts = tokenRepository.getAccounts()
|
||||
val notificationSettings = notificationSettingsRepository.getSettings()
|
||||
val appThemeMode = themeRepository.getThemeMode()
|
||||
val appLanguage = languageRepository.getLanguage()
|
||||
_uiState.update { state ->
|
||||
state.copy(
|
||||
isLoading = false,
|
||||
profile = (me as? AppResult.Success)?.data ?: state.profile,
|
||||
sessions = (sessions as? AppResult.Success)?.data ?: state.sessions,
|
||||
blockedUsers = (blocked as? AppResult.Success)?.data ?: state.blockedUsers,
|
||||
notificationsHistory = (notifications as? AppResult.Success)?.data ?: state.notificationsHistory,
|
||||
activeUserId = activeUserId,
|
||||
storedAccounts = storedAccounts.map { account ->
|
||||
val fallbackUser = context.getString(R.string.account_user_fallback, account.userId)
|
||||
StoredAccountUi(
|
||||
userId = account.userId,
|
||||
title = account.name.ifBlank { fallbackUser },
|
||||
subtitle = listOfNotNull(
|
||||
account.username?.takeIf { it.isNotBlank() }?.let { "@$it" },
|
||||
account.email?.takeIf { it.isNotBlank() },
|
||||
).joinToString(" • ").ifBlank { fallbackUser },
|
||||
avatarUrl = account.avatarUrl,
|
||||
isActive = activeUserId == account.userId,
|
||||
)
|
||||
},
|
||||
notificationsEnabled = notificationSettings.globalEnabled,
|
||||
notificationsPreviewEnabled = notificationSettings.previewEnabled,
|
||||
appThemeMode = appThemeMode,
|
||||
appLanguage = appLanguage,
|
||||
errorMessage = listOf(me, sessions, blocked, notifications)
|
||||
.filterIsInstance<AppResult.Error>()
|
||||
.firstOrNull()
|
||||
?.reason
|
||||
?.toUiMessage(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun setThemeMode(mode: AppThemeMode) {
|
||||
viewModelScope.launch {
|
||||
themeRepository.setThemeMode(mode)
|
||||
AppCompatDelegate.setDefaultNightMode(
|
||||
when (mode) {
|
||||
AppThemeMode.LIGHT -> AppCompatDelegate.MODE_NIGHT_NO
|
||||
AppThemeMode.DARK -> AppCompatDelegate.MODE_NIGHT_YES
|
||||
AppThemeMode.SYSTEM -> AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM
|
||||
}
|
||||
)
|
||||
_uiState.update { it.copy(appThemeMode = mode) }
|
||||
}
|
||||
}
|
||||
|
||||
fun setLanguage(language: AppLanguage) {
|
||||
viewModelScope.launch {
|
||||
languageRepository.setLanguage(language)
|
||||
val locales = language.tag?.let { LocaleListCompat.forLanguageTags(it) }
|
||||
?: LocaleListCompat.getEmptyLocaleList()
|
||||
AppCompatDelegate.setApplicationLocales(locales)
|
||||
_uiState.update { it.copy(appLanguage = language) }
|
||||
}
|
||||
}
|
||||
|
||||
fun setGlobalNotificationsEnabled(enabled: Boolean) {
|
||||
viewModelScope.launch {
|
||||
notificationSettingsRepository.setGlobalEnabled(enabled)
|
||||
_uiState.update { it.copy(notificationsEnabled = enabled) }
|
||||
}
|
||||
}
|
||||
|
||||
fun setNotificationPreviewEnabled(enabled: Boolean) {
|
||||
viewModelScope.launch {
|
||||
notificationSettingsRepository.setPreviewEnabled(enabled)
|
||||
_uiState.update { it.copy(notificationsPreviewEnabled = enabled) }
|
||||
}
|
||||
}
|
||||
|
||||
fun addAccount(email: String, password: String, onDone: (Boolean) -> Unit = {}) {
|
||||
val normalizedEmail = email.trim()
|
||||
if (normalizedEmail.isBlank() || password.isBlank()) {
|
||||
_uiState.update { it.copy(errorMessage = context.getString(R.string.account_error_email_password_required)) }
|
||||
onDone(false)
|
||||
return
|
||||
}
|
||||
viewModelScope.launch {
|
||||
_uiState.update { it.copy(isAddingAccount = true, errorMessage = null, message = null) }
|
||||
when (val result = loginUseCase(normalizedEmail, password)) {
|
||||
is AppResult.Success -> {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
isAddingAccount = false,
|
||||
message = context.getString(R.string.account_info_added),
|
||||
)
|
||||
}
|
||||
refresh()
|
||||
onDone(true)
|
||||
}
|
||||
is AppResult.Error -> {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
isAddingAccount = false,
|
||||
errorMessage = result.reason.toUiMessage(),
|
||||
)
|
||||
}
|
||||
onDone(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun switchStoredAccount(userId: Long, onSwitched: (Boolean) -> Unit = {}) {
|
||||
viewModelScope.launch {
|
||||
val switched = tokenRepository.switchAccount(userId)
|
||||
if (!switched) {
|
||||
_uiState.update { it.copy(errorMessage = context.getString(R.string.account_error_no_saved_session)) }
|
||||
onSwitched(false)
|
||||
return@launch
|
||||
}
|
||||
// Force data/context switch to the newly active account.
|
||||
realtimeManager.disconnect()
|
||||
realtimeManager.connect()
|
||||
pushTokenSyncManager.triggerBestEffortSync()
|
||||
val allResult = chatRepository.refreshChats(archived = false)
|
||||
val archivedResult = chatRepository.refreshChats(archived = true)
|
||||
refresh()
|
||||
val syncFailed = allResult is AppResult.Error && archivedResult is AppResult.Error
|
||||
if (syncFailed) {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
errorMessage = context.getString(R.string.account_error_switch_sync_failed),
|
||||
)
|
||||
}
|
||||
}
|
||||
onSwitched(true)
|
||||
}
|
||||
}
|
||||
|
||||
fun removeStoredAccount(userId: Long) {
|
||||
viewModelScope.launch {
|
||||
tokenRepository.removeAccount(userId)
|
||||
refresh()
|
||||
}
|
||||
}
|
||||
|
||||
fun updateProfile(
|
||||
name: String,
|
||||
username: String,
|
||||
bio: String?,
|
||||
avatarUrl: String?,
|
||||
) {
|
||||
viewModelScope.launch {
|
||||
_uiState.update { it.copy(isSaving = true, errorMessage = null, message = null) }
|
||||
when (val result = accountRepository.updateProfile(name, username, bio, avatarUrl)) {
|
||||
is AppResult.Success -> _uiState.update {
|
||||
it.copy(
|
||||
isSaving = false,
|
||||
profile = result.data,
|
||||
message = context.getString(R.string.account_info_profile_updated),
|
||||
)
|
||||
}
|
||||
is AppResult.Error -> _uiState.update {
|
||||
it.copy(
|
||||
isSaving = false,
|
||||
errorMessage = result.reason.toUiMessage(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun uploadAvatar(
|
||||
fileName: String,
|
||||
mimeType: String,
|
||||
bytes: ByteArray,
|
||||
onUploaded: (String) -> Unit,
|
||||
) {
|
||||
viewModelScope.launch {
|
||||
_uiState.update { it.copy(isSaving = true, errorMessage = null, message = null) }
|
||||
when (val result = accountRepository.uploadAvatar(fileName, mimeType, bytes)) {
|
||||
is AppResult.Success -> {
|
||||
_uiState.update { it.copy(isSaving = false, message = context.getString(R.string.account_info_avatar_uploaded)) }
|
||||
onUploaded(result.data)
|
||||
}
|
||||
is AppResult.Error -> _uiState.update {
|
||||
it.copy(
|
||||
isSaving = false,
|
||||
errorMessage = result.reason.toUiMessage(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun updatePrivacy(
|
||||
privateMessages: String,
|
||||
lastSeen: String,
|
||||
avatar: String,
|
||||
groupInvites: String,
|
||||
) {
|
||||
viewModelScope.launch {
|
||||
_uiState.update { it.copy(isSaving = true, errorMessage = null, message = null) }
|
||||
when (
|
||||
val result = accountRepository.updatePrivacy(
|
||||
privateMessages = privateMessages,
|
||||
lastSeen = lastSeen,
|
||||
avatar = avatar,
|
||||
groupInvites = groupInvites,
|
||||
)
|
||||
) {
|
||||
is AppResult.Success -> _uiState.update {
|
||||
it.copy(
|
||||
isSaving = false,
|
||||
profile = result.data,
|
||||
message = context.getString(R.string.account_info_privacy_updated),
|
||||
)
|
||||
}
|
||||
is AppResult.Error -> _uiState.update {
|
||||
it.copy(isSaving = false, errorMessage = result.reason.toUiMessage())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun blockUser(userId: Long) {
|
||||
viewModelScope.launch {
|
||||
when (val result = accountRepository.blockUser(userId)) {
|
||||
is AppResult.Success -> refresh()
|
||||
is AppResult.Error -> _uiState.update { it.copy(errorMessage = result.reason.toUiMessage()) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun unblockUser(userId: Long) {
|
||||
viewModelScope.launch {
|
||||
when (val result = accountRepository.unblockUser(userId)) {
|
||||
is AppResult.Success -> refresh()
|
||||
is AppResult.Error -> _uiState.update { it.copy(errorMessage = result.reason.toUiMessage()) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun revokeSession(jti: String) {
|
||||
viewModelScope.launch {
|
||||
when (val result = accountRepository.revokeSession(jti)) {
|
||||
is AppResult.Success -> refresh()
|
||||
is AppResult.Error -> _uiState.update { it.copy(errorMessage = result.reason.toUiMessage()) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun revokeAllSessions() {
|
||||
viewModelScope.launch {
|
||||
when (val result = accountRepository.revokeAllSessions()) {
|
||||
is AppResult.Success -> refresh()
|
||||
is AppResult.Error -> _uiState.update { it.copy(errorMessage = result.reason.toUiMessage()) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun setupTwoFactor() {
|
||||
viewModelScope.launch {
|
||||
when (val result = accountRepository.setupTwoFactor()) {
|
||||
is AppResult.Success -> _uiState.update {
|
||||
it.copy(
|
||||
twoFactorSecret = result.data.first,
|
||||
twoFactorOtpAuthUrl = result.data.second,
|
||||
message = context.getString(R.string.account_info_2fa_secret_generated),
|
||||
errorMessage = null,
|
||||
)
|
||||
}
|
||||
is AppResult.Error -> _uiState.update { it.copy(errorMessage = result.reason.toUiMessage()) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun enableTwoFactor(code: String) {
|
||||
viewModelScope.launch {
|
||||
when (val result = accountRepository.enableTwoFactor(code)) {
|
||||
is AppResult.Success -> {
|
||||
_uiState.update { it.copy(message = result.data, errorMessage = null) }
|
||||
refreshRecoveryStatus()
|
||||
refresh()
|
||||
}
|
||||
is AppResult.Error -> _uiState.update { it.copy(errorMessage = result.reason.toUiMessage()) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun disableTwoFactor(code: String) {
|
||||
viewModelScope.launch {
|
||||
when (val result = accountRepository.disableTwoFactor(code)) {
|
||||
is AppResult.Success -> {
|
||||
_uiState.update { it.copy(message = result.data, errorMessage = null) }
|
||||
refresh()
|
||||
}
|
||||
is AppResult.Error -> _uiState.update { it.copy(errorMessage = result.reason.toUiMessage()) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun refreshRecoveryStatus() {
|
||||
viewModelScope.launch {
|
||||
when (val result = accountRepository.twoFactorRecoveryStatus()) {
|
||||
is AppResult.Success -> _uiState.update { it.copy(recoveryCodesRemaining = result.data) }
|
||||
is AppResult.Error -> _uiState.update { it.copy(errorMessage = result.reason.toUiMessage()) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun regenerateRecoveryCodes(code: String) {
|
||||
viewModelScope.launch {
|
||||
when (val result = accountRepository.regenerateTwoFactorRecoveryCodes(code)) {
|
||||
is AppResult.Success -> _uiState.update {
|
||||
it.copy(
|
||||
recoveryCodes = result.data,
|
||||
message = context.getString(R.string.account_info_recovery_codes_regenerated),
|
||||
errorMessage = null,
|
||||
)
|
||||
}
|
||||
is AppResult.Error -> _uiState.update { it.copy(errorMessage = result.reason.toUiMessage()) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun verifyEmail(token: String) {
|
||||
viewModelScope.launch {
|
||||
_uiState.update { it.copy(isSaving = true, errorMessage = null, message = null) }
|
||||
when (val result = accountRepository.verifyEmail(token)) {
|
||||
is AppResult.Success -> _uiState.update {
|
||||
it.copy(
|
||||
isSaving = false,
|
||||
message = result.data,
|
||||
)
|
||||
}
|
||||
is AppResult.Error -> _uiState.update {
|
||||
it.copy(
|
||||
isSaving = false,
|
||||
errorMessage = result.reason.toUiMessage(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun resendVerification(email: String) {
|
||||
viewModelScope.launch {
|
||||
_uiState.update { it.copy(isSaving = true, errorMessage = null, message = null) }
|
||||
when (val result = accountRepository.resendVerification(email)) {
|
||||
is AppResult.Success -> _uiState.update {
|
||||
it.copy(
|
||||
isSaving = false,
|
||||
message = result.data,
|
||||
)
|
||||
}
|
||||
is AppResult.Error -> _uiState.update {
|
||||
it.copy(
|
||||
isSaving = false,
|
||||
errorMessage = result.reason.toUiMessage(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun requestPasswordReset(email: String) {
|
||||
viewModelScope.launch {
|
||||
_uiState.update { it.copy(isSaving = true, errorMessage = null, message = null) }
|
||||
when (val result = accountRepository.requestPasswordReset(email)) {
|
||||
is AppResult.Success -> _uiState.update {
|
||||
it.copy(
|
||||
isSaving = false,
|
||||
message = result.data,
|
||||
)
|
||||
}
|
||||
is AppResult.Error -> _uiState.update {
|
||||
it.copy(
|
||||
isSaving = false,
|
||||
errorMessage = result.reason.toUiMessage(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun resetPassword(token: String, password: String) {
|
||||
viewModelScope.launch {
|
||||
_uiState.update { it.copy(isSaving = true, errorMessage = null, message = null) }
|
||||
when (val result = accountRepository.resetPassword(token, password)) {
|
||||
is AppResult.Success -> _uiState.update {
|
||||
it.copy(
|
||||
isSaving = false,
|
||||
message = result.data,
|
||||
)
|
||||
}
|
||||
is AppResult.Error -> _uiState.update {
|
||||
it.copy(
|
||||
isSaving = false,
|
||||
errorMessage = result.reason.toUiMessage(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun clearMessage() {
|
||||
_uiState.update { it.copy(message = null, errorMessage = null) }
|
||||
}
|
||||
|
||||
private fun AppError.toUiMessage(): String {
|
||||
return when (this) {
|
||||
AppError.InvalidCredentials -> context.getString(R.string.account_error_invalid_credentials)
|
||||
AppError.Unauthorized -> context.getString(R.string.account_error_unauthorized)
|
||||
AppError.Network -> context.getString(R.string.error_network)
|
||||
is AppError.Server -> message ?: context.getString(R.string.error_server)
|
||||
is AppError.Unknown -> cause?.message ?: context.getString(R.string.error_unknown)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,25 @@
|
||||
package ru.daemonlord.messenger.ui.auth
|
||||
|
||||
enum class AuthStep {
|
||||
EMAIL,
|
||||
PASSWORD,
|
||||
REGISTER,
|
||||
OTP,
|
||||
}
|
||||
|
||||
data class AuthUiState(
|
||||
val step: AuthStep = AuthStep.EMAIL,
|
||||
val email: String = "",
|
||||
val name: String = "",
|
||||
val username: String = "",
|
||||
val password: String = "",
|
||||
val otpCode: String = "",
|
||||
val recoveryCode: String = "",
|
||||
val useRecoveryCode: Boolean = false,
|
||||
val isCheckingSession: Boolean = true,
|
||||
val isLoading: Boolean = false,
|
||||
val isAuthenticated: Boolean = false,
|
||||
val authCompletedNonce: Long = 0L,
|
||||
val successMessage: String? = null,
|
||||
val errorMessage: String? = null,
|
||||
)
|
||||
|
||||
@@ -1,25 +1,33 @@
|
||||
package ru.daemonlord.messenger.ui.auth
|
||||
|
||||
import android.content.Context
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import ru.daemonlord.messenger.domain.auth.usecase.CheckEmailStatusUseCase
|
||||
import ru.daemonlord.messenger.domain.auth.usecase.LoginUseCase
|
||||
import ru.daemonlord.messenger.domain.auth.usecase.LogoutUseCase
|
||||
import ru.daemonlord.messenger.domain.auth.usecase.RegisterUseCase
|
||||
import ru.daemonlord.messenger.domain.auth.usecase.RestoreSessionUseCase
|
||||
import ru.daemonlord.messenger.domain.common.AppError
|
||||
import ru.daemonlord.messenger.domain.common.AppResult
|
||||
import ru.daemonlord.messenger.R
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltViewModel
|
||||
class AuthViewModel @Inject constructor(
|
||||
private val restoreSessionUseCase: RestoreSessionUseCase,
|
||||
private val checkEmailStatusUseCase: CheckEmailStatusUseCase,
|
||||
private val registerUseCase: RegisterUseCase,
|
||||
private val loginUseCase: LoginUseCase,
|
||||
private val logoutUseCase: LogoutUseCase,
|
||||
@ApplicationContext private val context: Context,
|
||||
) : ViewModel() {
|
||||
|
||||
private val _uiState = MutableStateFlow(AuthUiState())
|
||||
@@ -30,33 +38,235 @@ class AuthViewModel @Inject constructor(
|
||||
}
|
||||
|
||||
fun onEmailChanged(value: String) {
|
||||
_uiState.update { it.copy(email = value, errorMessage = null) }
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
email = value,
|
||||
errorMessage = null,
|
||||
successMessage = null,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun onNameChanged(value: String) {
|
||||
_uiState.update { it.copy(name = value, errorMessage = null) }
|
||||
}
|
||||
|
||||
fun onUsernameChanged(value: String) {
|
||||
_uiState.update { it.copy(username = value.replace("@", ""), errorMessage = null) }
|
||||
}
|
||||
|
||||
fun onPasswordChanged(value: String) {
|
||||
_uiState.update { it.copy(password = value, errorMessage = null) }
|
||||
}
|
||||
|
||||
fun login() {
|
||||
val state = uiState.value
|
||||
if (state.email.isBlank() || state.password.isBlank()) {
|
||||
_uiState.update { it.copy(errorMessage = "Email and password are required.") }
|
||||
fun onOtpCodeChanged(value: String) {
|
||||
_uiState.update { it.copy(otpCode = value.filter(Char::isDigit).take(8), errorMessage = null) }
|
||||
}
|
||||
|
||||
fun onRecoveryCodeChanged(value: String) {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
recoveryCode = value.uppercase().filter { ch -> ch.isLetterOrDigit() || ch == '-' }.take(32),
|
||||
errorMessage = null,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun toggleRecoveryCodeMode() {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
useRecoveryCode = !it.useRecoveryCode,
|
||||
errorMessage = null,
|
||||
successMessage = null,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun backToEmailStep() {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
step = AuthStep.EMAIL,
|
||||
password = "",
|
||||
otpCode = "",
|
||||
recoveryCode = "",
|
||||
useRecoveryCode = false,
|
||||
errorMessage = null,
|
||||
successMessage = null,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun continueWithEmail() {
|
||||
val email = uiState.value.email.trim().lowercase()
|
||||
if (email.isBlank()) {
|
||||
_uiState.update { it.copy(errorMessage = context.getString(R.string.auth_error_enter_email)) }
|
||||
return
|
||||
}
|
||||
|
||||
viewModelScope.launch {
|
||||
_uiState.update { it.copy(isLoading = true, errorMessage = null) }
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
isLoading = true,
|
||||
errorMessage = null,
|
||||
successMessage = null,
|
||||
)
|
||||
}
|
||||
when (val result = checkEmailStatusUseCase(email)) {
|
||||
is AppResult.Success -> {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
email = result.data.email,
|
||||
isLoading = false,
|
||||
step = if (result.data.registered) AuthStep.PASSWORD else AuthStep.REGISTER,
|
||||
errorMessage = null,
|
||||
successMessage = if (result.data.registered) {
|
||||
null
|
||||
} else {
|
||||
context.getString(R.string.auth_info_email_not_registered)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
is AppResult.Error -> {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
isLoading = false,
|
||||
errorMessage = result.reason.toUiMessage(),
|
||||
successMessage = null,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun submitAuthStep() {
|
||||
when (uiState.value.step) {
|
||||
AuthStep.EMAIL -> continueWithEmail()
|
||||
AuthStep.REGISTER -> register()
|
||||
AuthStep.PASSWORD -> loginWithoutOtp()
|
||||
AuthStep.OTP -> loginWithOtp()
|
||||
}
|
||||
}
|
||||
|
||||
private fun register() {
|
||||
val state = uiState.value
|
||||
if (state.name.isBlank() || state.username.isBlank() || state.password.isBlank()) {
|
||||
_uiState.update { it.copy(errorMessage = context.getString(R.string.auth_error_register_fields_required)) }
|
||||
return
|
||||
}
|
||||
viewModelScope.launch {
|
||||
_uiState.update { it.copy(isLoading = true, errorMessage = null, successMessage = null) }
|
||||
when (
|
||||
val result = registerUseCase(
|
||||
email = state.email.trim().lowercase(),
|
||||
name = state.name.trim(),
|
||||
username = state.username.trim().removePrefix("@"),
|
||||
password = state.password,
|
||||
)
|
||||
) {
|
||||
is AppResult.Success -> {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
isLoading = false,
|
||||
step = AuthStep.PASSWORD,
|
||||
successMessage = context.getString(R.string.auth_info_account_created),
|
||||
errorMessage = null,
|
||||
)
|
||||
}
|
||||
}
|
||||
is AppResult.Error -> {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
isLoading = false,
|
||||
errorMessage = result.reason.toUiMessage(),
|
||||
successMessage = null,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun loginWithoutOtp() {
|
||||
val state = uiState.value
|
||||
if (state.password.isBlank()) {
|
||||
_uiState.update { it.copy(errorMessage = context.getString(R.string.auth_error_password_required)) }
|
||||
return
|
||||
}
|
||||
viewModelScope.launch {
|
||||
_uiState.update { it.copy(isLoading = true, errorMessage = null, successMessage = null) }
|
||||
when (val result = loginUseCase(state.email.trim(), state.password)) {
|
||||
is AppResult.Success -> {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
isLoading = false,
|
||||
isAuthenticated = true,
|
||||
authCompletedNonce = System.currentTimeMillis(),
|
||||
errorMessage = null,
|
||||
successMessage = null,
|
||||
)
|
||||
}
|
||||
}
|
||||
is AppResult.Error -> {
|
||||
val isOtpRequired = (result.reason as? AppError.Server)
|
||||
?.message
|
||||
?.contains("2fa code required", ignoreCase = true) == true
|
||||
if (isOtpRequired) {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
isLoading = false,
|
||||
step = AuthStep.OTP,
|
||||
errorMessage = null,
|
||||
successMessage = context.getString(R.string.auth_info_enter_2fa_or_recovery),
|
||||
)
|
||||
}
|
||||
} else {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
isLoading = false,
|
||||
isAuthenticated = false,
|
||||
errorMessage = result.reason.toUiMessage(),
|
||||
successMessage = null,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun loginWithOtp() {
|
||||
val state = uiState.value
|
||||
val otpCode = state.otpCode.trim().ifBlank { null }
|
||||
val recoveryCode = state.recoveryCode.trim().ifBlank { null }
|
||||
if (!state.useRecoveryCode && otpCode.isNullOrBlank()) {
|
||||
_uiState.update { it.copy(errorMessage = context.getString(R.string.auth_error_enter_2fa_code)) }
|
||||
return
|
||||
}
|
||||
if (state.useRecoveryCode && recoveryCode.isNullOrBlank()) {
|
||||
_uiState.update { it.copy(errorMessage = context.getString(R.string.auth_error_enter_recovery_code)) }
|
||||
return
|
||||
}
|
||||
viewModelScope.launch {
|
||||
_uiState.update { it.copy(isLoading = true, errorMessage = null, successMessage = null) }
|
||||
when (
|
||||
val result = loginUseCase(
|
||||
email = state.email.trim(),
|
||||
password = state.password,
|
||||
otpCode = if (state.useRecoveryCode) null else otpCode,
|
||||
recoveryCode = if (state.useRecoveryCode) recoveryCode else null,
|
||||
)
|
||||
) {
|
||||
is AppResult.Success -> {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
isLoading = false,
|
||||
isAuthenticated = true,
|
||||
authCompletedNonce = System.currentTimeMillis(),
|
||||
errorMessage = null,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
is AppResult.Error -> {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
@@ -76,16 +286,47 @@ class AuthViewModel @Inject constructor(
|
||||
runCatching { logoutUseCase() }
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
step = AuthStep.EMAIL,
|
||||
email = "",
|
||||
name = "",
|
||||
username = "",
|
||||
password = "",
|
||||
otpCode = "",
|
||||
recoveryCode = "",
|
||||
useRecoveryCode = false,
|
||||
isLoading = false,
|
||||
isAuthenticated = false,
|
||||
errorMessage = null,
|
||||
successMessage = null,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun recheckSession() {
|
||||
restoreSession()
|
||||
}
|
||||
|
||||
fun startAddAccountFlow() {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
step = AuthStep.EMAIL,
|
||||
email = "",
|
||||
name = "",
|
||||
username = "",
|
||||
password = "",
|
||||
otpCode = "",
|
||||
recoveryCode = "",
|
||||
useRecoveryCode = false,
|
||||
isCheckingSession = false,
|
||||
isLoading = false,
|
||||
isAuthenticated = false,
|
||||
successMessage = null,
|
||||
errorMessage = null,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun restoreSession() {
|
||||
viewModelScope.launch {
|
||||
_uiState.update { it.copy(isCheckingSession = true) }
|
||||
@@ -99,7 +340,6 @@ class AuthViewModel @Inject constructor(
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
is AppResult.Error -> {
|
||||
val keepAuthenticatedOffline = result.reason is AppError.Network
|
||||
_uiState.update {
|
||||
@@ -116,11 +356,11 @@ class AuthViewModel @Inject constructor(
|
||||
|
||||
private fun AppError.toUiMessage(): String {
|
||||
return when (this) {
|
||||
AppError.InvalidCredentials -> "Invalid email or password."
|
||||
AppError.Network -> "Network error. Check your connection."
|
||||
AppError.Unauthorized -> "Session expired. Please sign in again."
|
||||
is AppError.Server -> "Server error. Please try again."
|
||||
is AppError.Unknown -> "Unknown error. Please try again."
|
||||
AppError.InvalidCredentials -> context.getString(R.string.auth_error_invalid_credentials)
|
||||
AppError.Network -> context.getString(R.string.auth_error_network)
|
||||
AppError.Unauthorized -> context.getString(R.string.auth_error_session_expired)
|
||||
is AppError.Server -> message ?: context.getString(R.string.auth_error_server)
|
||||
is AppError.Unknown -> context.getString(R.string.auth_error_unknown)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
package ru.daemonlord.messenger.ui.auth
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.layout.windowInsetsPadding
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
@@ -17,82 +19,240 @@ import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||
import androidx.compose.ui.unit.dp
|
||||
import ru.daemonlord.messenger.R
|
||||
|
||||
@Composable
|
||||
fun LoginScreen(
|
||||
state: AuthUiState,
|
||||
headerTitle: String = "",
|
||||
onEmailChanged: (String) -> Unit,
|
||||
onNameChanged: (String) -> Unit,
|
||||
onUsernameChanged: (String) -> Unit,
|
||||
onPasswordChanged: (String) -> Unit,
|
||||
onLoginClick: () -> Unit,
|
||||
onOtpCodeChanged: (String) -> Unit,
|
||||
onRecoveryCodeChanged: (String) -> Unit,
|
||||
onToggleRecoveryCodeMode: () -> Unit,
|
||||
onContinueEmail: () -> Unit,
|
||||
onSubmitStep: () -> Unit,
|
||||
onBackToEmail: () -> Unit,
|
||||
onOpenVerifyEmail: () -> Unit,
|
||||
onOpenResetPassword: () -> Unit,
|
||||
) {
|
||||
Column(
|
||||
val isTabletLayout = LocalConfiguration.current.screenWidthDp >= 840
|
||||
val isBusy = state.isLoading
|
||||
val resolvedHeaderTitle = headerTitle.ifBlank { stringResource(id = R.string.auth_header_login) }
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.windowInsetsPadding(WindowInsets.safeDrawing)
|
||||
.padding(horizontal = 24.dp),
|
||||
verticalArrangement = Arrangement.Center,
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(
|
||||
text = "Messenger Login",
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
modifier = Modifier.padding(bottom = 24.dp),
|
||||
)
|
||||
|
||||
OutlinedTextField(
|
||||
value = state.email,
|
||||
onValueChange = onEmailChanged,
|
||||
label = { Text(text = "Email") },
|
||||
singleLine = true,
|
||||
enabled = !state.isLoading,
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(bottom = 12.dp),
|
||||
)
|
||||
|
||||
OutlinedTextField(
|
||||
value = state.password,
|
||||
onValueChange = onPasswordChanged,
|
||||
label = { Text(text = "Password") },
|
||||
visualTransformation = PasswordVisualTransformation(),
|
||||
singleLine = true,
|
||||
enabled = !state.isLoading,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(bottom = 16.dp),
|
||||
)
|
||||
|
||||
Button(
|
||||
onClick = onLoginClick,
|
||||
enabled = !state.isLoading,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
.then(if (isTabletLayout) Modifier.widthIn(max = 560.dp) else Modifier),
|
||||
verticalArrangement = Arrangement.Center,
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
if (state.isLoading) {
|
||||
CircularProgressIndicator(
|
||||
strokeWidth = 2.dp,
|
||||
modifier = Modifier.padding(2.dp),
|
||||
)
|
||||
} else {
|
||||
Text(text = "Login")
|
||||
}
|
||||
}
|
||||
|
||||
if (!state.errorMessage.isNullOrBlank()) {
|
||||
Text(
|
||||
text = state.errorMessage,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
modifier = Modifier.padding(top = 12.dp),
|
||||
text = resolvedHeaderTitle,
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
modifier = Modifier.padding(bottom = 14.dp),
|
||||
)
|
||||
} else {
|
||||
|
||||
val subtitle = when (state.step) {
|
||||
AuthStep.EMAIL -> stringResource(id = R.string.auth_subtitle_enter_email)
|
||||
AuthStep.PASSWORD -> stringResource(id = R.string.auth_subtitle_enter_password, state.email)
|
||||
AuthStep.REGISTER -> stringResource(id = R.string.auth_subtitle_create_account, state.email)
|
||||
AuthStep.OTP -> stringResource(id = R.string.auth_subtitle_2fa_enabled)
|
||||
}
|
||||
Text(
|
||||
text = subtitle,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(bottom = 18.dp),
|
||||
)
|
||||
|
||||
when (state.step) {
|
||||
AuthStep.EMAIL -> {
|
||||
OutlinedTextField(
|
||||
value = state.email,
|
||||
onValueChange = onEmailChanged,
|
||||
label = { Text(text = stringResource(id = R.string.auth_label_email)) },
|
||||
singleLine = true,
|
||||
enabled = !isBusy,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(bottom = 16.dp),
|
||||
)
|
||||
Button(
|
||||
onClick = onContinueEmail,
|
||||
enabled = !isBusy,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
if (isBusy) {
|
||||
CircularProgressIndicator(strokeWidth = 2.dp, modifier = Modifier.padding(2.dp))
|
||||
} else {
|
||||
Text(stringResource(id = R.string.auth_continue))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
AuthStep.PASSWORD, AuthStep.REGISTER, AuthStep.OTP -> {
|
||||
OutlinedTextField(
|
||||
value = state.email,
|
||||
onValueChange = {},
|
||||
readOnly = true,
|
||||
label = { Text(text = stringResource(id = R.string.auth_label_email)) },
|
||||
singleLine = true,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(bottom = 8.dp),
|
||||
)
|
||||
TextButton(
|
||||
onClick = onBackToEmail,
|
||||
enabled = !isBusy,
|
||||
modifier = Modifier
|
||||
.align(Alignment.Start)
|
||||
.padding(bottom = 6.dp),
|
||||
) {
|
||||
Text(stringResource(id = R.string.auth_change_email))
|
||||
}
|
||||
|
||||
if (state.step == AuthStep.REGISTER) {
|
||||
OutlinedTextField(
|
||||
value = state.name,
|
||||
onValueChange = onNameChanged,
|
||||
label = { Text(text = stringResource(id = R.string.auth_label_name)) },
|
||||
singleLine = true,
|
||||
enabled = !isBusy,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(bottom = 10.dp),
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = state.username,
|
||||
onValueChange = onUsernameChanged,
|
||||
label = { Text(text = stringResource(id = R.string.auth_label_username)) },
|
||||
singleLine = true,
|
||||
enabled = !isBusy,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(bottom = 10.dp),
|
||||
)
|
||||
}
|
||||
|
||||
if (state.step != AuthStep.OTP) {
|
||||
OutlinedTextField(
|
||||
value = state.password,
|
||||
onValueChange = onPasswordChanged,
|
||||
label = { Text(text = stringResource(id = R.string.auth_label_password)) },
|
||||
visualTransformation = PasswordVisualTransformation(),
|
||||
singleLine = true,
|
||||
enabled = !isBusy,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(bottom = 10.dp),
|
||||
)
|
||||
}
|
||||
|
||||
if (state.step == AuthStep.OTP) {
|
||||
TextButton(
|
||||
onClick = onToggleRecoveryCodeMode,
|
||||
enabled = !isBusy,
|
||||
modifier = Modifier
|
||||
.align(Alignment.Start)
|
||||
.padding(bottom = 4.dp),
|
||||
) {
|
||||
Text(
|
||||
if (state.useRecoveryCode) {
|
||||
stringResource(id = R.string.auth_use_otp_code)
|
||||
} else {
|
||||
stringResource(id = R.string.auth_use_recovery_code)
|
||||
},
|
||||
)
|
||||
}
|
||||
if (state.useRecoveryCode) {
|
||||
OutlinedTextField(
|
||||
value = state.recoveryCode,
|
||||
onValueChange = onRecoveryCodeChanged,
|
||||
label = { Text(text = stringResource(id = R.string.auth_label_recovery_code)) },
|
||||
singleLine = true,
|
||||
enabled = !isBusy,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(bottom = 10.dp),
|
||||
)
|
||||
} else {
|
||||
OutlinedTextField(
|
||||
value = state.otpCode,
|
||||
onValueChange = onOtpCodeChanged,
|
||||
label = { Text(text = stringResource(id = R.string.auth_label_2fa_code)) },
|
||||
singleLine = true,
|
||||
enabled = !isBusy,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(bottom = 10.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Button(
|
||||
onClick = onSubmitStep,
|
||||
enabled = !isBusy,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
if (isBusy) {
|
||||
CircularProgressIndicator(strokeWidth = 2.dp, modifier = Modifier.padding(2.dp))
|
||||
} else {
|
||||
Text(
|
||||
when (state.step) {
|
||||
AuthStep.PASSWORD -> stringResource(id = R.string.auth_sign_in)
|
||||
AuthStep.REGISTER -> stringResource(id = R.string.auth_create_account)
|
||||
AuthStep.OTP -> stringResource(id = R.string.auth_confirm_2fa)
|
||||
AuthStep.EMAIL -> stringResource(id = R.string.auth_continue)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!state.errorMessage.isNullOrBlank()) {
|
||||
Text(
|
||||
text = state.errorMessage,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
modifier = Modifier.padding(top = 12.dp),
|
||||
)
|
||||
}
|
||||
if (!state.successMessage.isNullOrBlank()) {
|
||||
Text(
|
||||
text = state.successMessage,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
modifier = Modifier.padding(top = 12.dp),
|
||||
)
|
||||
}
|
||||
|
||||
TextButton(
|
||||
onClick = {},
|
||||
enabled = false,
|
||||
onClick = onOpenVerifyEmail,
|
||||
enabled = !isBusy,
|
||||
modifier = Modifier.padding(top = 8.dp),
|
||||
) {
|
||||
Text(text = "Use your existing backend account")
|
||||
Text(text = stringResource(id = R.string.auth_verify_email_by_token))
|
||||
}
|
||||
TextButton(
|
||||
onClick = onOpenResetPassword,
|
||||
enabled = !isBusy,
|
||||
) {
|
||||
Text(text = stringResource(id = R.string.auth_forgot_password))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
package ru.daemonlord.messenger.ui.auth.reset
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.safeDrawing
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.layout.windowInsetsPadding
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import ru.daemonlord.messenger.R
|
||||
import ru.daemonlord.messenger.ui.account.AccountViewModel
|
||||
|
||||
@Composable
|
||||
fun ResetPasswordRoute(
|
||||
token: String?,
|
||||
onBackToLogin: () -> Unit,
|
||||
viewModel: AccountViewModel = hiltViewModel(),
|
||||
) {
|
||||
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
var email by remember { mutableStateOf("") }
|
||||
var password by remember { mutableStateOf("") }
|
||||
val isTabletLayout = LocalConfiguration.current.screenWidthDp >= 840
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.windowInsetsPadding(WindowInsets.safeDrawing)
|
||||
.padding(16.dp),
|
||||
contentAlignment = Alignment.TopCenter,
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.then(if (isTabletLayout) Modifier.widthIn(max = 620.dp) else Modifier),
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
Text(stringResource(id = R.string.auth_password_reset_title), style = MaterialTheme.typography.headlineSmall)
|
||||
OutlinedTextField(
|
||||
value = email,
|
||||
onValueChange = { email = it },
|
||||
label = { Text(stringResource(id = R.string.auth_label_email)) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
Button(
|
||||
onClick = { viewModel.requestPasswordReset(email) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
enabled = !state.isSaving && email.isNotBlank(),
|
||||
) {
|
||||
Text(stringResource(id = R.string.auth_send_reset_link))
|
||||
}
|
||||
OutlinedTextField(
|
||||
value = password,
|
||||
onValueChange = { password = it },
|
||||
label = { Text(stringResource(id = R.string.auth_new_password)) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
Button(
|
||||
onClick = {
|
||||
if (!token.isNullOrBlank()) {
|
||||
viewModel.resetPassword(token = token, password = password)
|
||||
}
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
enabled = !state.isSaving && !token.isNullOrBlank() && password.length >= 8,
|
||||
) {
|
||||
Text(stringResource(id = R.string.auth_reset_with_token))
|
||||
}
|
||||
if (state.isSaving) {
|
||||
CircularProgressIndicator()
|
||||
}
|
||||
if (!state.message.isNullOrBlank()) {
|
||||
Text(state.message!!, color = MaterialTheme.colorScheme.primary)
|
||||
}
|
||||
if (!state.errorMessage.isNullOrBlank()) {
|
||||
Text(state.errorMessage!!, color = MaterialTheme.colorScheme.error)
|
||||
}
|
||||
Button(onClick = onBackToLogin, modifier = Modifier.fillMaxWidth()) {
|
||||
Text(stringResource(id = R.string.auth_back_to_login))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
package ru.daemonlord.messenger.ui.auth.verify
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.safeDrawing
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.layout.windowInsetsPadding
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import ru.daemonlord.messenger.R
|
||||
import ru.daemonlord.messenger.ui.account.AccountViewModel
|
||||
|
||||
@Composable
|
||||
fun VerifyEmailRoute(
|
||||
token: String?,
|
||||
onBackToLogin: () -> Unit,
|
||||
viewModel: AccountViewModel = hiltViewModel(),
|
||||
) {
|
||||
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
var editableToken by remember(token) { mutableStateOf(token.orEmpty()) }
|
||||
var resendEmail by remember { mutableStateOf(state.profile?.email.orEmpty()) }
|
||||
|
||||
LaunchedEffect(token) {
|
||||
if (!token.isNullOrBlank()) {
|
||||
viewModel.verifyEmail(token)
|
||||
}
|
||||
}
|
||||
|
||||
val isTabletLayout = LocalConfiguration.current.screenWidthDp >= 840
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.windowInsetsPadding(WindowInsets.safeDrawing)
|
||||
.padding(16.dp),
|
||||
contentAlignment = Alignment.TopCenter,
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.then(if (isTabletLayout) Modifier.widthIn(max = 620.dp) else Modifier),
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
Text(stringResource(id = R.string.auth_verify_email_title), style = MaterialTheme.typography.headlineSmall)
|
||||
OutlinedTextField(
|
||||
value = editableToken,
|
||||
onValueChange = { editableToken = it },
|
||||
label = { Text(stringResource(id = R.string.auth_verification_token)) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
Button(
|
||||
onClick = { viewModel.verifyEmail(editableToken) },
|
||||
enabled = !state.isSaving && editableToken.isNotBlank(),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Text(stringResource(id = R.string.auth_verify))
|
||||
}
|
||||
OutlinedTextField(
|
||||
value = resendEmail,
|
||||
onValueChange = { resendEmail = it },
|
||||
label = { Text(stringResource(id = R.string.auth_email_for_resend)) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true,
|
||||
)
|
||||
Button(
|
||||
onClick = { viewModel.resendVerification(resendEmail) },
|
||||
enabled = !state.isSaving && resendEmail.isNotBlank(),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Text(stringResource(id = R.string.auth_resend_verification_link))
|
||||
}
|
||||
if (state.isSaving) {
|
||||
CircularProgressIndicator()
|
||||
}
|
||||
if (!state.message.isNullOrBlank()) {
|
||||
Text(state.message!!, color = MaterialTheme.colorScheme.primary)
|
||||
}
|
||||
if (!state.errorMessage.isNullOrBlank()) {
|
||||
Text(state.errorMessage!!, color = MaterialTheme.colorScheme.error)
|
||||
}
|
||||
Button(onClick = onBackToLogin, modifier = Modifier.fillMaxWidth()) {
|
||||
Text(stringResource(id = R.string.auth_back_to_login))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,8 +1,10 @@
|
||||
package ru.daemonlord.messenger.ui.chat
|
||||
|
||||
import android.content.Context
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
@@ -14,6 +16,10 @@ import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import ru.daemonlord.messenger.core.notifications.ActiveChatTracker
|
||||
import ru.daemonlord.messenger.core.notifications.NotificationDispatcher
|
||||
import ru.daemonlord.messenger.core.token.TokenRepository
|
||||
import ru.daemonlord.messenger.R
|
||||
import ru.daemonlord.messenger.domain.chat.repository.ChatRepository
|
||||
import ru.daemonlord.messenger.domain.chat.usecase.ObserveChatUseCase
|
||||
import ru.daemonlord.messenger.domain.chat.usecase.ObserveChatsUseCase
|
||||
import ru.daemonlord.messenger.domain.common.AppError
|
||||
@@ -28,6 +34,7 @@ import ru.daemonlord.messenger.domain.message.usecase.LoadMoreMessagesUseCase
|
||||
import ru.daemonlord.messenger.domain.message.usecase.MarkMessageDeliveredUseCase
|
||||
import ru.daemonlord.messenger.domain.message.usecase.MarkMessageReadUseCase
|
||||
import ru.daemonlord.messenger.domain.message.usecase.ObserveMessagesUseCase
|
||||
import ru.daemonlord.messenger.domain.message.usecase.SendImageUrlMessageUseCase
|
||||
import ru.daemonlord.messenger.domain.message.usecase.SendMediaMessageUseCase
|
||||
import ru.daemonlord.messenger.domain.message.usecase.SendTextMessageUseCase
|
||||
import ru.daemonlord.messenger.domain.message.usecase.SyncRecentMessagesUseCase
|
||||
@@ -45,6 +52,7 @@ class ChatViewModel @Inject constructor(
|
||||
private val syncRecentMessagesUseCase: SyncRecentMessagesUseCase,
|
||||
private val loadMoreMessagesUseCase: LoadMoreMessagesUseCase,
|
||||
private val sendTextMessageUseCase: SendTextMessageUseCase,
|
||||
private val sendImageUrlMessageUseCase: SendImageUrlMessageUseCase,
|
||||
private val sendMediaMessageUseCase: SendMediaMessageUseCase,
|
||||
private val editMessageUseCase: EditMessageUseCase,
|
||||
private val deleteMessageUseCase: DeleteMessageUseCase,
|
||||
@@ -54,10 +62,14 @@ class ChatViewModel @Inject constructor(
|
||||
private val forwardMessageBulkUseCase: ForwardMessageBulkUseCase,
|
||||
private val listMessageReactionsUseCase: ListMessageReactionsUseCase,
|
||||
private val toggleMessageReactionUseCase: ToggleMessageReactionUseCase,
|
||||
private val chatRepository: ChatRepository,
|
||||
private val observeChatUseCase: ObserveChatUseCase,
|
||||
private val observeChatsUseCase: ObserveChatsUseCase,
|
||||
private val handleRealtimeEventsUseCase: HandleRealtimeEventsUseCase,
|
||||
private val activeChatTracker: ActiveChatTracker,
|
||||
private val notificationDispatcher: NotificationDispatcher,
|
||||
private val tokenRepository: TokenRepository,
|
||||
@ApplicationContext private val context: Context,
|
||||
) : ViewModel() {
|
||||
|
||||
private val chatId: Long = checkNotNull(savedStateHandle["chatId"])
|
||||
@@ -66,9 +78,12 @@ class ChatViewModel @Inject constructor(
|
||||
private val visibleMessagesLimit = MutableStateFlow(MESSAGES_PAGE_SIZE)
|
||||
private var lastDeliveredMessageId: Long? = null
|
||||
private var lastReadMessageId: Long? = null
|
||||
private val reactionsRequestedMessageIds = mutableSetOf<Long>()
|
||||
private var membersLoadKey: String? = null
|
||||
|
||||
init {
|
||||
activeChatTracker.setActiveChat(chatId)
|
||||
notificationDispatcher.clearChatNotifications(chatId)
|
||||
handleRealtimeEventsUseCase.start()
|
||||
observeChatPermissions()
|
||||
observeMessages()
|
||||
@@ -79,6 +94,127 @@ class ChatViewModel @Inject constructor(
|
||||
_uiState.update { it.copy(inputText = value) }
|
||||
}
|
||||
|
||||
fun onInlineSearchChanged(query: String) {
|
||||
_uiState.update { state ->
|
||||
val normalized = query.trim().lowercase()
|
||||
if (normalized.isBlank()) {
|
||||
state.copy(
|
||||
inlineSearchQuery = query,
|
||||
inlineSearchMatches = emptyList(),
|
||||
highlightedMessageId = null,
|
||||
)
|
||||
} else {
|
||||
val matches = state.messages
|
||||
.filter { (it.text ?: "").lowercase().contains(normalized) }
|
||||
.map { it.id }
|
||||
state.copy(
|
||||
inlineSearchQuery = query,
|
||||
inlineSearchMatches = matches,
|
||||
highlightedMessageId = matches.firstOrNull(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun jumpInlineSearch(next: Boolean) {
|
||||
_uiState.update { state ->
|
||||
val matches = state.inlineSearchMatches
|
||||
if (matches.isEmpty()) return@update state
|
||||
val current = state.highlightedMessageId
|
||||
val index = matches.indexOf(current).takeIf { it >= 0 } ?: 0
|
||||
val nextIndex = if (next) {
|
||||
(index + 1) % matches.size
|
||||
} else {
|
||||
if (index == 0) matches.lastIndex else index - 1
|
||||
}
|
||||
state.copy(highlightedMessageId = matches[nextIndex])
|
||||
}
|
||||
}
|
||||
|
||||
fun onVoiceRecordStarted() {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
isRecordingVoice = true,
|
||||
isVoiceLocked = false,
|
||||
voiceRecordingDurationMs = 0L,
|
||||
voiceRecordingHint = context.getString(R.string.chat_voice_hint_slide),
|
||||
errorMessage = null,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun onVoiceRecordTick(durationMs: Long) {
|
||||
_uiState.update {
|
||||
if (!it.isRecordingVoice) it else it.copy(voiceRecordingDurationMs = durationMs)
|
||||
}
|
||||
}
|
||||
|
||||
fun onVoiceRecordLocked() {
|
||||
_uiState.update {
|
||||
if (!it.isRecordingVoice) it else {
|
||||
it.copy(
|
||||
isVoiceLocked = true,
|
||||
voiceRecordingHint = context.getString(R.string.chat_voice_hint_locked),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun onVoiceRecordCancelled() {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
isRecordingVoice = false,
|
||||
isVoiceLocked = false,
|
||||
voiceRecordingDurationMs = 0L,
|
||||
voiceRecordingHint = null,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun onVoiceRecordFinish(
|
||||
fileName: String,
|
||||
mimeType: String,
|
||||
bytes: ByteArray,
|
||||
durationMs: Long,
|
||||
) {
|
||||
if (durationMs < MIN_VOICE_DURATION_MS) {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
isRecordingVoice = false,
|
||||
isVoiceLocked = false,
|
||||
voiceRecordingDurationMs = 0L,
|
||||
voiceRecordingHint = null,
|
||||
errorMessage = context.getString(R.string.chat_error_voice_too_short),
|
||||
)
|
||||
}
|
||||
return
|
||||
}
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
isRecordingVoice = false,
|
||||
isVoiceLocked = false,
|
||||
voiceRecordingDurationMs = 0L,
|
||||
voiceRecordingHint = null,
|
||||
)
|
||||
}
|
||||
onMediaPicked(
|
||||
fileName = fileName,
|
||||
mimeType = mimeType,
|
||||
bytes = bytes,
|
||||
)
|
||||
}
|
||||
|
||||
fun onVisibleIncomingMessageId(messageId: Long?) {
|
||||
val visibleIncomingId = messageId ?: return
|
||||
if ((lastReadMessageId ?: 0L) >= visibleIncomingId) {
|
||||
return
|
||||
}
|
||||
lastReadMessageId = visibleIncomingId
|
||||
viewModelScope.launch {
|
||||
markMessageReadUseCase(chatId = chatId, messageId = visibleIncomingId)
|
||||
}
|
||||
}
|
||||
|
||||
fun onSelectMessage(message: MessageItem?) {
|
||||
if (message == null) {
|
||||
onClearSelection()
|
||||
@@ -194,7 +330,7 @@ class ChatViewModel @Inject constructor(
|
||||
val actionState = uiState.value.actionState
|
||||
if (actionState.mode == MessageSelectionMode.MULTI) {
|
||||
if (forAll) {
|
||||
_uiState.update { it.copy(errorMessage = "Delete for all is available only for single message selection.") }
|
||||
_uiState.update { it.copy(errorMessage = context.getString(R.string.chat_error_delete_for_all_single)) }
|
||||
return
|
||||
}
|
||||
val selectedIds = actionState.selectedMessageIds.toList().sorted()
|
||||
@@ -222,7 +358,7 @@ class ChatViewModel @Inject constructor(
|
||||
}
|
||||
val selected = getFocusedSelectedMessage() ?: return
|
||||
if (forAll && !canDeleteForAll(selected)) {
|
||||
_uiState.update { it.copy(errorMessage = "Delete for all is available only for your own messages.") }
|
||||
_uiState.update { it.copy(errorMessage = context.getString(R.string.chat_error_delete_for_all_own)) }
|
||||
return
|
||||
}
|
||||
viewModelScope.launch {
|
||||
@@ -319,6 +455,104 @@ class ChatViewModel @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
fun onToggleChatNotifications() {
|
||||
viewModelScope.launch {
|
||||
when (val current = chatRepository.getChatNotifications(chatId = chatId)) {
|
||||
is AppResult.Success -> {
|
||||
when (val updated = chatRepository.updateChatNotifications(chatId = chatId, muted = !current.data.muted)) {
|
||||
is AppResult.Success -> _uiState.update { it.copy(chatMuted = updated.data.muted) }
|
||||
is AppResult.Error -> _uiState.update { it.copy(errorMessage = updated.reason.toUiMessage()) }
|
||||
}
|
||||
}
|
||||
is AppResult.Error -> _uiState.update { it.copy(errorMessage = current.reason.toUiMessage()) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun onClearHistory() {
|
||||
viewModelScope.launch {
|
||||
when (val result = chatRepository.clearChat(chatId = chatId)) {
|
||||
is AppResult.Success -> _uiState.update {
|
||||
it.copy(
|
||||
selectedMessage = null,
|
||||
selectedCanEdit = false,
|
||||
selectedCanDeleteForAll = false,
|
||||
actionState = it.actionState.clearSelection(),
|
||||
errorMessage = context.getString(R.string.chat_info_history_cleared),
|
||||
)
|
||||
}
|
||||
is AppResult.Error -> _uiState.update { it.copy(errorMessage = result.reason.toUiMessage()) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun onDeleteOrLeaveChat() {
|
||||
viewModelScope.launch {
|
||||
val type = uiState.value.chatType.lowercase()
|
||||
val result = when (type) {
|
||||
"group", "channel" -> chatRepository.leaveChat(chatId = chatId)
|
||||
else -> chatRepository.removeChat(chatId = chatId, forAll = false)
|
||||
}
|
||||
when (result) {
|
||||
is AppResult.Success -> _uiState.update {
|
||||
it.copy(
|
||||
selectedMessage = null,
|
||||
selectedCanEdit = false,
|
||||
selectedCanDeleteForAll = false,
|
||||
actionState = it.actionState.clearSelection(),
|
||||
errorMessage = null,
|
||||
chatDeletedNonce = it.chatDeletedNonce + 1L,
|
||||
)
|
||||
}
|
||||
is AppResult.Error -> _uiState.update { it.copy(errorMessage = result.reason.toUiMessage()) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun promoteMember(userId: Long) {
|
||||
if (!ensureCanManageTarget(userId = userId, action = "promote")) return
|
||||
updateMemberRole(userId = userId, role = "admin")
|
||||
}
|
||||
|
||||
fun demoteMember(userId: Long) {
|
||||
if (!ensureCanManageTarget(userId = userId, action = "demote")) return
|
||||
updateMemberRole(userId = userId, role = "member")
|
||||
}
|
||||
|
||||
fun transferOwnership(userId: Long) {
|
||||
if (!ensureCanManageTarget(userId = userId, action = "transfer_ownership", ownerOnly = true)) return
|
||||
updateMemberRole(userId = userId, role = "owner")
|
||||
}
|
||||
|
||||
fun kickMember(userId: Long) {
|
||||
if (!ensureCanManageTarget(userId = userId, action = "kick")) return
|
||||
viewModelScope.launch {
|
||||
when (val result = chatRepository.removeMember(chatId = chatId, userId = userId)) {
|
||||
is AppResult.Success -> refreshMembersAndBans()
|
||||
is AppResult.Error -> _uiState.update { it.copy(errorMessage = result.reason.toUiMessage()) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun banMember(userId: Long) {
|
||||
if (!ensureCanManageTarget(userId = userId, action = "ban")) return
|
||||
viewModelScope.launch {
|
||||
when (val result = chatRepository.banMember(chatId = chatId, userId = userId)) {
|
||||
is AppResult.Success -> refreshMembersAndBans()
|
||||
is AppResult.Error -> _uiState.update { it.copy(errorMessage = result.reason.toUiMessage()) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun unbanMember(userId: Long) {
|
||||
viewModelScope.launch {
|
||||
when (val result = chatRepository.unbanMember(chatId = chatId, userId = userId)) {
|
||||
is AppResult.Success -> refreshMembersAndBans()
|
||||
is AppResult.Error -> _uiState.update { it.copy(errorMessage = result.reason.toUiMessage()) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun onSendClick() {
|
||||
val text = uiState.value.inputText.trim()
|
||||
if (text.isBlank()) return
|
||||
@@ -330,7 +564,7 @@ class ChatViewModel @Inject constructor(
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
isSending = false,
|
||||
errorMessage = "This message can no longer be edited.",
|
||||
errorMessage = context.getString(R.string.chat_error_edit_expired),
|
||||
)
|
||||
}
|
||||
return@launch
|
||||
@@ -342,7 +576,8 @@ class ChatViewModel @Inject constructor(
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
isSending = false,
|
||||
errorMessage = uiState.value.sendRestrictionText ?: "Sending is restricted in this chat.",
|
||||
errorMessage = uiState.value.sendRestrictionText
|
||||
?: context.getString(R.string.chat_error_send_restricted),
|
||||
)
|
||||
}
|
||||
return@launch
|
||||
@@ -416,6 +651,37 @@ class ChatViewModel @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
fun onSendPresetMediaUrl(url: String) {
|
||||
val normalizedUrl = url.trim()
|
||||
if (normalizedUrl.isBlank()) return
|
||||
viewModelScope.launch {
|
||||
_uiState.update { it.copy(isUploadingMedia = true, errorMessage = null) }
|
||||
when (
|
||||
val result = sendImageUrlMessageUseCase(
|
||||
chatId = chatId,
|
||||
imageUrl = normalizedUrl,
|
||||
replyToMessageId = uiState.value.replyToMessage?.id,
|
||||
)
|
||||
) {
|
||||
is AppResult.Success -> _uiState.update {
|
||||
it.copy(
|
||||
isUploadingMedia = false,
|
||||
inputText = "",
|
||||
replyToMessage = null,
|
||||
editingMessage = null,
|
||||
)
|
||||
}
|
||||
|
||||
is AppResult.Error -> _uiState.update {
|
||||
it.copy(
|
||||
isUploadingMedia = false,
|
||||
errorMessage = result.reason.toUiMessage(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun loadMore() {
|
||||
val oldest = uiState.value.messages.firstOrNull() ?: return
|
||||
viewModelScope.launch {
|
||||
@@ -438,15 +704,32 @@ class ChatViewModel @Inject constructor(
|
||||
visibleMessagesLimit
|
||||
.flatMapLatest { limit -> observeMessagesUseCase(chatId = chatId, limit = limit) }
|
||||
.collectLatest { messages ->
|
||||
val sortedMessages = messages.sortedBy { msg -> msg.id }
|
||||
_uiState.update {
|
||||
val pinnedId = it.pinnedMessageId
|
||||
val normalized = it.inlineSearchQuery.trim().lowercase()
|
||||
val inlineMatches = if (normalized.isBlank()) {
|
||||
emptyList()
|
||||
} else {
|
||||
sortedMessages
|
||||
.filter { msg -> (msg.text ?: "").lowercase().contains(normalized) }
|
||||
.map { msg -> msg.id }
|
||||
}
|
||||
val highlighted = if (inlineMatches.contains(it.highlightedMessageId)) {
|
||||
it.highlightedMessageId
|
||||
} else {
|
||||
inlineMatches.firstOrNull()
|
||||
}
|
||||
it.copy(
|
||||
isLoading = false,
|
||||
messages = messages.sortedBy { msg -> msg.id },
|
||||
pinnedMessage = pinnedId?.let { id -> messages.firstOrNull { msg -> msg.id == id } },
|
||||
messages = sortedMessages,
|
||||
pinnedMessage = pinnedId?.let { id -> sortedMessages.firstOrNull { msg -> msg.id == id } },
|
||||
inlineSearchMatches = inlineMatches,
|
||||
highlightedMessageId = highlighted,
|
||||
)
|
||||
}
|
||||
acknowledgeLatestIncoming(messages)
|
||||
preloadReactions(sortedMessages)
|
||||
acknowledgeLatestMessages(sortedMessages)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -463,14 +746,18 @@ class ChatViewModel @Inject constructor(
|
||||
val restriction = if (canSend) {
|
||||
null
|
||||
} else {
|
||||
"Only channel owner/admin can send messages."
|
||||
context.getString(R.string.chat_restriction_owner_admin)
|
||||
}
|
||||
val chatTitle = chat.displayTitle.ifBlank {
|
||||
context.getString(R.string.chat_title_fallback, chatId)
|
||||
}
|
||||
val chatTitle = chat.displayTitle.ifBlank { "Chat #$chatId" }
|
||||
val chatSubtitle = when {
|
||||
chat.type.equals("private", ignoreCase = true) && chat.counterpartIsOnline == true -> "online"
|
||||
chat.type.equals("private", ignoreCase = true) && !chat.counterpartLastSeenAt.isNullOrBlank() -> "last seen recently"
|
||||
chat.type.equals("group", ignoreCase = true) -> "group"
|
||||
chat.type.equals("channel", ignoreCase = true) -> "channel"
|
||||
chat.type.equals("private", ignoreCase = true) && chat.counterpartIsOnline == true ->
|
||||
context.getString(R.string.chat_status_online)
|
||||
chat.type.equals("private", ignoreCase = true) && !chat.counterpartLastSeenAt.isNullOrBlank() ->
|
||||
context.getString(R.string.chat_status_last_seen_recently)
|
||||
chat.type.equals("group", ignoreCase = true) -> context.getString(R.string.chat_type_group)
|
||||
chat.type.equals("channel", ignoreCase = true) -> context.getString(R.string.chat_type_channel)
|
||||
else -> ""
|
||||
}
|
||||
_uiState.update {
|
||||
@@ -478,15 +765,56 @@ class ChatViewModel @Inject constructor(
|
||||
it.messages.firstOrNull { message -> message.id == pinnedId }
|
||||
}
|
||||
it.copy(
|
||||
chatType = chat.type,
|
||||
chatRole = role,
|
||||
chatMuted = chat.muted,
|
||||
chatTitle = chatTitle,
|
||||
chatSubtitle = chatSubtitle,
|
||||
chatAvatarUrl = chat.avatarUrl ?: chat.counterpartAvatarUrl,
|
||||
chatUnreadCount = chat.unreadCount.coerceAtLeast(0),
|
||||
canManageMembers = role == "owner" || role == "admin",
|
||||
canSendMessages = canSend,
|
||||
sendRestrictionText = restriction,
|
||||
pinnedMessageId = chat.pinnedMessageId,
|
||||
pinnedMessage = pinnedMessage,
|
||||
)
|
||||
}
|
||||
|
||||
val shouldLoadMembers = chat.type.equals("group", ignoreCase = true) ||
|
||||
(chat.type.equals("channel", ignoreCase = true) && (role == "owner" || role == "admin"))
|
||||
val nextLoadKey = "${chat.id}:${chat.type.lowercase()}:${role ?: "none"}"
|
||||
if (shouldLoadMembers && membersLoadKey != nextLoadKey) {
|
||||
membersLoadKey = nextLoadKey
|
||||
refreshMembersAndBans()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateMemberRole(userId: Long, role: String) {
|
||||
viewModelScope.launch {
|
||||
when (val result = chatRepository.updateMemberRole(chatId = chatId, userId = userId, role = role)) {
|
||||
is AppResult.Success -> refreshMembersAndBans()
|
||||
is AppResult.Error -> _uiState.update { it.copy(errorMessage = result.reason.toUiMessage()) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun refreshMembersAndBans() {
|
||||
viewModelScope.launch {
|
||||
val membersResult = chatRepository.listMembers(chatId = chatId)
|
||||
val bansResult = chatRepository.listBans(chatId = chatId)
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
chatMembers = (membersResult as? AppResult.Success)?.data ?: it.chatMembers,
|
||||
chatBans = (bansResult as? AppResult.Success)?.data ?: it.chatBans,
|
||||
errorMessage = listOf(membersResult, bansResult)
|
||||
.filterIsInstance<AppResult.Error>()
|
||||
.firstOrNull()
|
||||
?.reason
|
||||
?.toUiMessage()
|
||||
?: it.errorMessage,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -494,11 +822,13 @@ class ChatViewModel @Inject constructor(
|
||||
private fun refresh() {
|
||||
viewModelScope.launch {
|
||||
_uiState.update { it.copy(isLoading = true, errorMessage = null) }
|
||||
val selfUserId = tokenRepository.getActiveUserId()
|
||||
when (val result = syncRecentMessagesUseCase(chatId = chatId)) {
|
||||
is AppResult.Success -> _uiState.update { it.copy(isLoading = false) }
|
||||
is AppResult.Success -> _uiState.update { it.copy(isLoading = false, selfUserId = selfUserId) }
|
||||
is AppResult.Error -> _uiState.update {
|
||||
it.copy(
|
||||
isLoading = false,
|
||||
selfUserId = selfUserId,
|
||||
errorMessage = result.reason.toUiMessage(),
|
||||
)
|
||||
}
|
||||
@@ -507,6 +837,7 @@ class ChatViewModel @Inject constructor(
|
||||
}
|
||||
|
||||
private fun loadReactions(messageId: Long) {
|
||||
if (messageId <= 0L) return
|
||||
viewModelScope.launch {
|
||||
when (val result = listMessageReactionsUseCase(messageId = messageId)) {
|
||||
is AppResult.Success -> {
|
||||
@@ -519,24 +850,38 @@ class ChatViewModel @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
private fun acknowledgeLatestIncoming(messages: List<MessageItem>) {
|
||||
val latestIncoming = messages
|
||||
private fun preloadReactions(messages: List<MessageItem>) {
|
||||
val toRequest = messages
|
||||
.asReversed()
|
||||
.firstOrNull { !it.isOutgoing }
|
||||
?: return
|
||||
.map { it.id }
|
||||
.filter { it > 0L }
|
||||
.filter { reactionsRequestedMessageIds.add(it) }
|
||||
|
||||
if (lastDeliveredMessageId != latestIncoming.id) {
|
||||
if (toRequest.isEmpty()) return
|
||||
|
||||
toRequest.forEach { messageId ->
|
||||
viewModelScope.launch {
|
||||
when (val result = listMessageReactionsUseCase(messageId = messageId)) {
|
||||
is AppResult.Success -> {
|
||||
_uiState.update {
|
||||
it.copy(reactionByMessageId = it.reactionByMessageId + (messageId to result.data))
|
||||
}
|
||||
}
|
||||
is AppResult.Error -> Unit
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun acknowledgeLatestMessages(messages: List<MessageItem>) {
|
||||
val latestIncoming = messages.asReversed().firstOrNull { !it.isOutgoing }
|
||||
|
||||
if (latestIncoming != null && lastDeliveredMessageId != latestIncoming.id) {
|
||||
lastDeliveredMessageId = latestIncoming.id
|
||||
viewModelScope.launch {
|
||||
markMessageDeliveredUseCase(chatId = chatId, messageId = latestIncoming.id)
|
||||
}
|
||||
}
|
||||
if (lastReadMessageId != latestIncoming.id) {
|
||||
lastReadMessageId = latestIncoming.id
|
||||
viewModelScope.launch {
|
||||
markMessageReadUseCase(chatId = chatId, messageId = latestIncoming.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun canEdit(message: MessageItem): Boolean {
|
||||
@@ -588,23 +933,62 @@ class ChatViewModel @Inject constructor(
|
||||
return uiState.value.messages.firstOrNull { it.id == messageId }
|
||||
}
|
||||
|
||||
private fun ensureCanManageTarget(
|
||||
userId: Long,
|
||||
action: String,
|
||||
ownerOnly: Boolean = false,
|
||||
): Boolean {
|
||||
val state = uiState.value
|
||||
val selfId = state.selfUserId
|
||||
if (selfId != null && userId == selfId) {
|
||||
_uiState.update { it.copy(errorMessage = context.getString(R.string.chat_error_action_self)) }
|
||||
return false
|
||||
}
|
||||
|
||||
val actorRole = state.chatRole?.lowercase()
|
||||
if (actorRole != "owner" && actorRole != "admin") {
|
||||
_uiState.update { it.copy(errorMessage = context.getString(R.string.chat_error_permissions)) }
|
||||
return false
|
||||
}
|
||||
if (ownerOnly && actorRole != "owner") {
|
||||
_uiState.update { it.copy(errorMessage = context.getString(R.string.chat_error_owner_only)) }
|
||||
return false
|
||||
}
|
||||
|
||||
val targetRole = state.chatMembers.firstOrNull { it.userId == userId }?.role?.lowercase()
|
||||
if (targetRole == "owner") {
|
||||
_uiState.update { it.copy(errorMessage = context.getString(R.string.chat_error_manage_owner)) }
|
||||
return false
|
||||
}
|
||||
if (actorRole == "admin" && (targetRole == "admin" || targetRole == "owner")) {
|
||||
_uiState.update { it.copy(errorMessage = context.getString(R.string.chat_error_admin_manage_admin_owner)) }
|
||||
return false
|
||||
}
|
||||
|
||||
if (action == "transfer_ownership" && targetRole == "owner") {
|
||||
_uiState.update { it.copy(errorMessage = context.getString(R.string.chat_error_transfer_choose_another)) }
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
private fun AppError.toUiMessage(): String {
|
||||
return when (this) {
|
||||
AppError.Network -> "Network error."
|
||||
AppError.Unauthorized -> "Session expired."
|
||||
AppError.InvalidCredentials -> "Authorization error."
|
||||
is AppError.Server -> "Server error."
|
||||
is AppError.Unknown -> "Unknown error."
|
||||
AppError.Network -> context.getString(R.string.error_network)
|
||||
AppError.Unauthorized -> context.getString(R.string.error_session_expired)
|
||||
AppError.InvalidCredentials -> context.getString(R.string.error_authorization)
|
||||
is AppError.Server -> context.getString(R.string.error_server)
|
||||
is AppError.Unknown -> context.getString(R.string.error_unknown)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCleared() {
|
||||
activeChatTracker.clearActiveChat(chatId)
|
||||
handleRealtimeEventsUseCase.stop()
|
||||
super.onCleared()
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val MESSAGES_PAGE_SIZE = 50
|
||||
const val MIN_VOICE_DURATION_MS = 1_000L
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,232 @@
|
||||
package ru.daemonlord.messenger.ui.chat
|
||||
|
||||
import androidx.compose.foundation.text.ClickableText
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalUriHandler
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.SpanStyle
|
||||
import androidx.compose.ui.text.TextRange
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.buildAnnotatedString
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontStyle
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.TextFieldValue
|
||||
import androidx.compose.ui.text.style.TextDecoration
|
||||
|
||||
private data class InlineToken(
|
||||
val marker: String,
|
||||
val style: SpanStyle,
|
||||
)
|
||||
|
||||
fun applyInlineFormatting(
|
||||
value: TextFieldValue,
|
||||
prefix: String,
|
||||
suffix: String = prefix,
|
||||
placeholder: String = "text",
|
||||
): TextFieldValue {
|
||||
val start = value.selection.min
|
||||
val end = value.selection.max
|
||||
val selected = value.text.substring(start, end)
|
||||
val middle = if (selected.isNotBlank()) selected else placeholder
|
||||
val replacement = "$prefix$middle$suffix"
|
||||
val nextText = value.text.replaceRange(start, end, replacement)
|
||||
val selection = if (selected.isNotBlank()) {
|
||||
TextRange(start + replacement.length)
|
||||
} else {
|
||||
TextRange(start + prefix.length, start + prefix.length + middle.length)
|
||||
}
|
||||
return value.copy(text = nextText, selection = selection)
|
||||
}
|
||||
|
||||
fun applyQuoteFormatting(value: TextFieldValue): TextFieldValue {
|
||||
val start = value.selection.min
|
||||
val end = value.selection.max
|
||||
val selected = value.text.substring(start, end).ifBlank { "quote" }
|
||||
val quoted = selected
|
||||
.split('\n')
|
||||
.joinToString("\n") { line -> "> $line" }
|
||||
val nextText = value.text.replaceRange(start, end, quoted)
|
||||
return value.copy(text = nextText, selection = TextRange(start + quoted.length))
|
||||
}
|
||||
|
||||
fun applyLinkFormatting(value: TextFieldValue, url: String = "https://"): TextFieldValue {
|
||||
val start = value.selection.min
|
||||
val end = value.selection.max
|
||||
val selected = value.text.substring(start, end).trim().ifBlank { "text" }
|
||||
val replacement = "[$selected]($url)"
|
||||
val nextText = value.text.replaceRange(start, end, replacement)
|
||||
return value.copy(
|
||||
text = nextText,
|
||||
selection = TextRange(start + selected.length + 3, start + selected.length + 3 + url.length),
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun FormattedMessageText(
|
||||
text: String,
|
||||
style: TextStyle,
|
||||
color: Color,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val uriHandler = LocalUriHandler.current
|
||||
val annotated = buildFormattedMessage(text = text, baseColor = color)
|
||||
ClickableText(
|
||||
text = annotated,
|
||||
style = style,
|
||||
modifier = modifier,
|
||||
onClick = { offset ->
|
||||
annotated
|
||||
.getStringAnnotations(tag = "url", start = offset, end = offset)
|
||||
.firstOrNull()
|
||||
?.item
|
||||
?.let { link ->
|
||||
runCatching { uriHandler.openUri(link) }
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
private fun buildFormattedMessage(text: String, baseColor: Color): AnnotatedString {
|
||||
val codeStyle = SpanStyle(
|
||||
fontFamily = FontFamily.Monospace,
|
||||
background = Color(0x332A2A2A),
|
||||
)
|
||||
val spoilerStyle = SpanStyle(
|
||||
color = Color.Transparent,
|
||||
background = baseColor.copy(alpha = 0.45f),
|
||||
)
|
||||
val linkStyle = SpanStyle(
|
||||
color = Color(0xFF8AB4F8),
|
||||
textDecoration = TextDecoration.Underline,
|
||||
)
|
||||
val quotePrefixStyle = SpanStyle(
|
||||
color = baseColor.copy(alpha = 0.72f),
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
val tokens = listOf(
|
||||
InlineToken("||", spoilerStyle),
|
||||
InlineToken("**", SpanStyle(fontWeight = FontWeight.Bold)),
|
||||
InlineToken("__", SpanStyle(textDecoration = TextDecoration.Underline)),
|
||||
InlineToken("~~", SpanStyle(textDecoration = TextDecoration.LineThrough)),
|
||||
InlineToken("*", SpanStyle(fontStyle = FontStyle.Italic)),
|
||||
)
|
||||
|
||||
return buildAnnotatedString {
|
||||
val parts = text.split("```")
|
||||
parts.forEachIndexed { index, part ->
|
||||
val isCodeBlock = index % 2 == 1
|
||||
if (isCodeBlock) {
|
||||
val cleanCode = part.trim('\n')
|
||||
val blockStart = length
|
||||
append(cleanCode)
|
||||
addStyle(codeStyle, blockStart, length)
|
||||
if (index != parts.lastIndex) append('\n')
|
||||
return@forEachIndexed
|
||||
}
|
||||
|
||||
val lines = part.split('\n')
|
||||
lines.forEachIndexed { lineIndex, line ->
|
||||
if (line.startsWith("> ")) {
|
||||
val prefixStart = length
|
||||
append("▍ ")
|
||||
addStyle(quotePrefixStyle, prefixStart, length)
|
||||
appendInlineFormatted(
|
||||
source = line.removePrefix("> "),
|
||||
tokens = tokens,
|
||||
codeStyle = codeStyle,
|
||||
linkStyle = linkStyle,
|
||||
)
|
||||
} else {
|
||||
appendInlineFormatted(
|
||||
source = line,
|
||||
tokens = tokens,
|
||||
codeStyle = codeStyle,
|
||||
linkStyle = linkStyle,
|
||||
)
|
||||
}
|
||||
if (lineIndex != lines.lastIndex) append('\n')
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun AnnotatedString.Builder.appendInlineFormatted(
|
||||
source: String,
|
||||
tokens: List<InlineToken>,
|
||||
codeStyle: SpanStyle,
|
||||
linkStyle: SpanStyle,
|
||||
) {
|
||||
var i = 0
|
||||
while (i < source.length) {
|
||||
val linkMatch = Regex("""^\[([^\]]+)]\(([^)]+)\)""").find(source.substring(i))
|
||||
if (linkMatch != null) {
|
||||
val label = linkMatch.groupValues[1]
|
||||
val href = linkMatch.groupValues[2].trim()
|
||||
if (href.startsWith("http://", ignoreCase = true) || href.startsWith("https://", ignoreCase = true)) {
|
||||
val start = length
|
||||
appendInlineFormatted(
|
||||
source = label,
|
||||
tokens = tokens,
|
||||
codeStyle = codeStyle,
|
||||
linkStyle = linkStyle,
|
||||
)
|
||||
addStyle(linkStyle, start, length)
|
||||
addStringAnnotation(tag = "url", annotation = href, start = start, end = length)
|
||||
i += linkMatch.value.length
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
val bareUrl = Regex("""^https?://[^\s<>()]+""", RegexOption.IGNORE_CASE).find(source.substring(i))
|
||||
if (bareUrl != null) {
|
||||
val raw = bareUrl.value
|
||||
val trimmed = raw.trimEnd(',', ')', '.', ';', '!', '?')
|
||||
val trailing = raw.removePrefix(trimmed)
|
||||
val start = length
|
||||
append(trimmed)
|
||||
addStyle(linkStyle, start, length)
|
||||
addStringAnnotation(tag = "url", annotation = trimmed, start = start, end = length)
|
||||
append(trailing)
|
||||
i += raw.length
|
||||
continue
|
||||
}
|
||||
|
||||
if (source.startsWith("`", i)) {
|
||||
val end = source.indexOf('`', startIndex = i + 1)
|
||||
if (end > i + 1) {
|
||||
val codeStart = length
|
||||
append(source.substring(i + 1, end))
|
||||
addStyle(codeStyle, codeStart, length)
|
||||
i = end + 1
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
var matched = false
|
||||
for (token in tokens) {
|
||||
if (!source.startsWith(token.marker, i)) continue
|
||||
val end = source.indexOf(token.marker, startIndex = i + token.marker.length)
|
||||
if (end <= i + token.marker.length) continue
|
||||
val inner = source.substring(i + token.marker.length, end)
|
||||
if (inner.isBlank()) continue
|
||||
val rangeStart = length
|
||||
appendInlineFormatted(
|
||||
source = inner,
|
||||
tokens = tokens,
|
||||
codeStyle = codeStyle,
|
||||
linkStyle = linkStyle,
|
||||
)
|
||||
addStyle(token.style, rangeStart, length)
|
||||
i = end + token.marker.length
|
||||
matched = true
|
||||
break
|
||||
}
|
||||
if (matched) continue
|
||||
|
||||
append(source[i])
|
||||
i += 1
|
||||
}
|
||||
}
|
||||
@@ -2,9 +2,12 @@ package ru.daemonlord.messenger.ui.chat
|
||||
|
||||
import ru.daemonlord.messenger.domain.message.model.MessageItem
|
||||
import ru.daemonlord.messenger.domain.message.model.MessageReaction
|
||||
import ru.daemonlord.messenger.domain.chat.model.ChatMemberItem
|
||||
import ru.daemonlord.messenger.domain.chat.model.ChatBanItem
|
||||
|
||||
data class MessageUiState(
|
||||
val chatId: Long = 0L,
|
||||
val selfUserId: Long? = null,
|
||||
val isLoading: Boolean = true,
|
||||
val isLoadingMore: Boolean = false,
|
||||
val isSending: Boolean = false,
|
||||
@@ -13,6 +16,13 @@ data class MessageUiState(
|
||||
val chatTitle: String = "",
|
||||
val chatSubtitle: String = "",
|
||||
val chatAvatarUrl: String? = null,
|
||||
val chatType: String = "",
|
||||
val chatRole: String? = null,
|
||||
val chatMuted: Boolean = false,
|
||||
val chatUnreadCount: Int = 0,
|
||||
val chatMembers: List<ChatMemberItem> = emptyList(),
|
||||
val chatBans: List<ChatBanItem> = emptyList(),
|
||||
val canManageMembers: Boolean = false,
|
||||
val messages: List<MessageItem> = emptyList(),
|
||||
val pinnedMessageId: Long? = null,
|
||||
val pinnedMessage: MessageItem? = null,
|
||||
@@ -28,7 +38,15 @@ data class MessageUiState(
|
||||
val isForwarding: Boolean = false,
|
||||
val canSendMessages: Boolean = true,
|
||||
val sendRestrictionText: String? = null,
|
||||
val isRecordingVoice: Boolean = false,
|
||||
val isVoiceLocked: Boolean = false,
|
||||
val voiceRecordingDurationMs: Long = 0L,
|
||||
val voiceRecordingHint: String? = null,
|
||||
val inlineSearchQuery: String = "",
|
||||
val inlineSearchMatches: List<Long> = emptyList(),
|
||||
val highlightedMessageId: Long? = null,
|
||||
val actionState: MessageActionState = MessageActionState(),
|
||||
val chatDeletedNonce: Long = 0L,
|
||||
)
|
||||
|
||||
data class ForwardTargetUiModel(
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
package ru.daemonlord.messenger.ui.chat.voice
|
||||
|
||||
import android.content.Context
|
||||
import android.media.MediaRecorder
|
||||
import java.io.File
|
||||
import java.util.UUID
|
||||
|
||||
class VoiceRecorder(private val context: Context) {
|
||||
private var recorder: MediaRecorder? = null
|
||||
private var outputFile: File? = null
|
||||
private var startedAtMillis: Long = 0L
|
||||
|
||||
fun start(): Boolean {
|
||||
return runCatching {
|
||||
val file = File(context.cacheDir, "voice_${UUID.randomUUID()}.m4a")
|
||||
val mediaRecorder = MediaRecorder().apply {
|
||||
setAudioSource(MediaRecorder.AudioSource.MIC)
|
||||
setOutputFormat(MediaRecorder.OutputFormat.MPEG_4)
|
||||
setAudioEncoder(MediaRecorder.AudioEncoder.AAC)
|
||||
setAudioChannels(1)
|
||||
setAudioEncodingBitRate(64_000)
|
||||
setAudioSamplingRate(44_100)
|
||||
setOutputFile(file.absolutePath)
|
||||
prepare()
|
||||
start()
|
||||
}
|
||||
recorder = mediaRecorder
|
||||
outputFile = file
|
||||
startedAtMillis = System.currentTimeMillis()
|
||||
true
|
||||
}.getOrElse {
|
||||
releaseInternal(deleteFile = true)
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
fun elapsedMillis(nowMillis: Long = System.currentTimeMillis()): Long {
|
||||
if (startedAtMillis <= 0L) return 0L
|
||||
return (nowMillis - startedAtMillis).coerceAtLeast(0L)
|
||||
}
|
||||
|
||||
fun stopAndReadBytes(): ByteArray? {
|
||||
val file = outputFile ?: return null
|
||||
val success = runCatching {
|
||||
recorder?.stop()
|
||||
true
|
||||
}.getOrDefault(false)
|
||||
releaseInternal(deleteFile = false)
|
||||
if (!success || !file.exists()) {
|
||||
file.delete()
|
||||
return null
|
||||
}
|
||||
return runCatching {
|
||||
val bytes = file.readBytes()
|
||||
file.delete()
|
||||
bytes
|
||||
}.getOrNull()
|
||||
}
|
||||
|
||||
fun cancel() {
|
||||
releaseInternal(deleteFile = true)
|
||||
}
|
||||
|
||||
private fun releaseInternal(deleteFile: Boolean) {
|
||||
runCatching { recorder?.reset() }
|
||||
runCatching { recorder?.release() }
|
||||
recorder = null
|
||||
startedAtMillis = 0L
|
||||
if (deleteFile) {
|
||||
outputFile?.delete()
|
||||
}
|
||||
outputFile = null
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,11 @@
|
||||
package ru.daemonlord.messenger.ui.chats
|
||||
|
||||
import ru.daemonlord.messenger.domain.chat.model.ChatItem
|
||||
import ru.daemonlord.messenger.domain.chat.model.ChatBanItem
|
||||
import ru.daemonlord.messenger.domain.chat.model.ChatMemberItem
|
||||
import ru.daemonlord.messenger.domain.chat.model.DiscoverChatItem
|
||||
import ru.daemonlord.messenger.domain.account.model.UserSearchItem
|
||||
import ru.daemonlord.messenger.domain.message.model.MessageItem
|
||||
|
||||
data class ChatListUiState(
|
||||
val selectedTab: ChatTab = ChatTab.ALL,
|
||||
@@ -8,10 +13,23 @@ data class ChatListUiState(
|
||||
val searchQuery: String = "",
|
||||
val isLoading: Boolean = true,
|
||||
val isRefreshing: Boolean = false,
|
||||
val isConnecting: Boolean = false,
|
||||
val errorMessage: String? = null,
|
||||
val chats: List<ChatItem> = emptyList(),
|
||||
val searchHistoryChats: List<ChatItem> = emptyList(),
|
||||
val searchRecentChats: List<ChatItem> = emptyList(),
|
||||
val archivedChatsCount: Int = 0,
|
||||
val archivedUnreadCount: Int = 0,
|
||||
val isJoiningInvite: Boolean = false,
|
||||
val pendingOpenChatId: Long? = null,
|
||||
val discoverChats: List<DiscoverChatItem> = emptyList(),
|
||||
val globalChats: List<DiscoverChatItem> = emptyList(),
|
||||
val selectedManageChatId: Long? = null,
|
||||
val members: List<ChatMemberItem> = emptyList(),
|
||||
val bans: List<ChatBanItem> = emptyList(),
|
||||
val globalUsers: List<UserSearchItem> = emptyList(),
|
||||
val globalMessages: List<MessageItem> = emptyList(),
|
||||
val globalSearchQuery: String = "",
|
||||
val isManagementLoading: Boolean = false,
|
||||
val managementMessage: String? = null,
|
||||
)
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
package ru.daemonlord.messenger.ui.chats
|
||||
|
||||
import android.content.Context
|
||||
import androidx.appcompat.app.AppCompatDelegate
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
@@ -13,31 +17,50 @@ import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import ru.daemonlord.messenger.domain.chat.model.ChatItem
|
||||
import ru.daemonlord.messenger.domain.chat.repository.ChatRepository
|
||||
import ru.daemonlord.messenger.domain.chat.repository.ChatSearchRepository
|
||||
import ru.daemonlord.messenger.domain.chat.usecase.JoinByInviteUseCase
|
||||
import ru.daemonlord.messenger.domain.chat.usecase.ObserveChatsUseCase
|
||||
import ru.daemonlord.messenger.domain.chat.usecase.RefreshChatsUseCase
|
||||
import ru.daemonlord.messenger.domain.common.AppError
|
||||
import ru.daemonlord.messenger.domain.common.AppResult
|
||||
import ru.daemonlord.messenger.domain.realtime.RealtimeManager
|
||||
import ru.daemonlord.messenger.domain.realtime.model.RealtimeConnectionState
|
||||
import ru.daemonlord.messenger.domain.realtime.usecase.HandleRealtimeEventsUseCase
|
||||
import ru.daemonlord.messenger.domain.settings.model.AppThemeMode
|
||||
import ru.daemonlord.messenger.domain.settings.repository.ThemeRepository
|
||||
import ru.daemonlord.messenger.domain.search.repository.SearchRepository
|
||||
import ru.daemonlord.messenger.R
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltViewModel
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class ChatListViewModel @Inject constructor(
|
||||
private val observeChatsUseCase: ObserveChatsUseCase,
|
||||
private val refreshChatsUseCase: RefreshChatsUseCase,
|
||||
private val joinByInviteUseCase: JoinByInviteUseCase,
|
||||
private val handleRealtimeEventsUseCase: HandleRealtimeEventsUseCase,
|
||||
private val realtimeManager: RealtimeManager,
|
||||
private val chatRepository: ChatRepository,
|
||||
private val chatSearchRepository: ChatSearchRepository,
|
||||
private val searchRepository: SearchRepository,
|
||||
private val themeRepository: ThemeRepository,
|
||||
@ApplicationContext private val context: Context,
|
||||
) : ViewModel() {
|
||||
|
||||
private val selectedTab = MutableStateFlow(ChatTab.ALL)
|
||||
private val selectedFilter = MutableStateFlow(ChatListFilter.ALL)
|
||||
private val searchQuery = MutableStateFlow("")
|
||||
private val searchHistoryIds = MutableStateFlow<List<Long>>(emptyList())
|
||||
private val searchRecentIds = MutableStateFlow<List<Long>>(emptyList())
|
||||
private var lastHandledInviteToken: String? = null
|
||||
private val _uiState = MutableStateFlow(ChatListUiState())
|
||||
val uiState: StateFlow<ChatListUiState> = _uiState.asStateFlow()
|
||||
|
||||
init {
|
||||
handleRealtimeEventsUseCase.start()
|
||||
observeSearchStore()
|
||||
observeConnectionState()
|
||||
observeChatStream()
|
||||
}
|
||||
|
||||
@@ -101,6 +124,381 @@ class ChatListViewModel @Inject constructor(
|
||||
_uiState.update { it.copy(pendingOpenChatId = null) }
|
||||
}
|
||||
|
||||
fun onGlobalSearchChanged(value: String) {
|
||||
_uiState.update { it.copy(globalSearchQuery = value) }
|
||||
val normalized = value.trim()
|
||||
if (normalized.length < 2) {
|
||||
_uiState.update { it.copy(globalUsers = emptyList(), globalChats = emptyList(), globalMessages = emptyList()) }
|
||||
return
|
||||
}
|
||||
viewModelScope.launch {
|
||||
when (val result = searchRepository.globalSearch(query = normalized, usersLimit = 10, chatsLimit = 10, messagesLimit = 20)) {
|
||||
is AppResult.Success -> _uiState.update {
|
||||
it.copy(
|
||||
globalUsers = result.data.users,
|
||||
globalChats = result.data.chats,
|
||||
globalMessages = result.data.messages,
|
||||
)
|
||||
}
|
||||
is AppResult.Error -> _uiState.update {
|
||||
it.copy(
|
||||
globalUsers = emptyList(),
|
||||
globalChats = emptyList(),
|
||||
globalMessages = emptyList(),
|
||||
errorMessage = result.reason.toUiMessage(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun onSearchResultOpened(chatId: Long) {
|
||||
viewModelScope.launch {
|
||||
chatSearchRepository.addHistoryChat(chatId)
|
||||
chatSearchRepository.addRecentChat(chatId)
|
||||
}
|
||||
}
|
||||
|
||||
fun clearSearchHistory() {
|
||||
viewModelScope.launch {
|
||||
chatSearchRepository.clearHistory()
|
||||
}
|
||||
}
|
||||
|
||||
fun openSavedChat() {
|
||||
viewModelScope.launch {
|
||||
when (val result = chatRepository.getSavedChat()) {
|
||||
is AppResult.Success -> {
|
||||
_uiState.update { it.copy(pendingOpenChatId = result.data.id) }
|
||||
}
|
||||
is AppResult.Error -> _uiState.update {
|
||||
it.copy(errorMessage = result.reason.toUiMessage())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun toggleDayNightMode(onResult: (AppThemeMode) -> Unit = {}) {
|
||||
viewModelScope.launch {
|
||||
val current = themeRepository.getThemeMode()
|
||||
val next = when (current) {
|
||||
AppThemeMode.DARK -> AppThemeMode.LIGHT
|
||||
AppThemeMode.LIGHT -> AppThemeMode.DARK
|
||||
AppThemeMode.SYSTEM -> {
|
||||
val isNight = AppCompatDelegate.getDefaultNightMode() == AppCompatDelegate.MODE_NIGHT_YES
|
||||
if (isNight) AppThemeMode.LIGHT else AppThemeMode.DARK
|
||||
}
|
||||
}
|
||||
themeRepository.setThemeMode(next)
|
||||
AppCompatDelegate.setDefaultNightMode(
|
||||
when (next) {
|
||||
AppThemeMode.LIGHT -> AppCompatDelegate.MODE_NIGHT_NO
|
||||
AppThemeMode.DARK -> AppCompatDelegate.MODE_NIGHT_YES
|
||||
AppThemeMode.SYSTEM -> AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM
|
||||
}
|
||||
)
|
||||
onResult(next)
|
||||
}
|
||||
}
|
||||
|
||||
fun onManagementChatSelected(chatId: Long?) {
|
||||
_uiState.update { it.copy(selectedManageChatId = chatId) }
|
||||
if (chatId != null) {
|
||||
loadMembersAndBans(chatId)
|
||||
}
|
||||
}
|
||||
|
||||
fun discoverChats(query: String?) {
|
||||
viewModelScope.launch {
|
||||
_uiState.update { it.copy(isManagementLoading = true, errorMessage = null) }
|
||||
when (val result = chatRepository.discoverChats(query = query?.trim()?.ifBlank { null })) {
|
||||
is AppResult.Success -> _uiState.update {
|
||||
it.copy(
|
||||
isManagementLoading = false,
|
||||
discoverChats = result.data,
|
||||
)
|
||||
}
|
||||
is AppResult.Error -> _uiState.update {
|
||||
it.copy(
|
||||
isManagementLoading = false,
|
||||
errorMessage = result.reason.toUiMessage(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun createGroup(title: String, memberIds: List<Long>) {
|
||||
createChatInternal(
|
||||
type = "group",
|
||||
title = title,
|
||||
isPublic = false,
|
||||
handle = null,
|
||||
description = null,
|
||||
memberIds = memberIds,
|
||||
successMessageResId = R.string.chat_list_info_group_created,
|
||||
)
|
||||
}
|
||||
|
||||
fun createChannel(title: String, handle: String, description: String?) {
|
||||
createChatInternal(
|
||||
type = "channel",
|
||||
title = title,
|
||||
isPublic = true,
|
||||
handle = handle,
|
||||
description = description,
|
||||
memberIds = emptyList(),
|
||||
successMessageResId = R.string.chat_list_info_channel_created,
|
||||
)
|
||||
}
|
||||
|
||||
fun joinChat(chatId: Long) {
|
||||
viewModelScope.launch {
|
||||
when (val result = chatRepository.joinChat(chatId = chatId)) {
|
||||
is AppResult.Success -> {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
managementMessage = context.getString(R.string.chat_list_info_joined_chat),
|
||||
pendingOpenChatId = result.data.id,
|
||||
)
|
||||
}
|
||||
refreshCurrentTab(forceRefresh = true)
|
||||
}
|
||||
is AppResult.Error -> _uiState.update { it.copy(errorMessage = result.reason.toUiMessage()) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun leaveChat(chatId: Long) {
|
||||
viewModelScope.launch {
|
||||
when (val result = chatRepository.leaveChat(chatId = chatId)) {
|
||||
is AppResult.Success -> {
|
||||
_uiState.update { it.copy(managementMessage = context.getString(R.string.chat_list_info_left_chat)) }
|
||||
refreshCurrentTab(forceRefresh = true)
|
||||
}
|
||||
is AppResult.Error -> _uiState.update { it.copy(errorMessage = result.reason.toUiMessage()) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun archiveChat(chatId: Long) {
|
||||
viewModelScope.launch {
|
||||
when (val result = chatRepository.archiveChat(chatId = chatId)) {
|
||||
is AppResult.Success -> {
|
||||
_uiState.update { it.copy(managementMessage = context.getString(R.string.chat_list_info_archived)) }
|
||||
refreshCurrentTab(forceRefresh = true)
|
||||
}
|
||||
is AppResult.Error -> _uiState.update { it.copy(errorMessage = result.reason.toUiMessage()) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun unarchiveChat(chatId: Long) {
|
||||
viewModelScope.launch {
|
||||
when (val result = chatRepository.unarchiveChat(chatId = chatId)) {
|
||||
is AppResult.Success -> {
|
||||
_uiState.update { it.copy(managementMessage = context.getString(R.string.chat_list_info_unarchived)) }
|
||||
refreshCurrentTab(forceRefresh = true)
|
||||
}
|
||||
is AppResult.Error -> _uiState.update { it.copy(errorMessage = result.reason.toUiMessage()) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun pinChat(chatId: Long) {
|
||||
viewModelScope.launch {
|
||||
when (val result = chatRepository.pinChat(chatId = chatId)) {
|
||||
is AppResult.Success -> _uiState.update { it.copy(managementMessage = context.getString(R.string.chat_list_info_pinned)) }
|
||||
is AppResult.Error -> _uiState.update { it.copy(errorMessage = result.reason.toUiMessage()) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun unpinChat(chatId: Long) {
|
||||
viewModelScope.launch {
|
||||
when (val result = chatRepository.unpinChat(chatId = chatId)) {
|
||||
is AppResult.Success -> _uiState.update { it.copy(managementMessage = context.getString(R.string.chat_list_info_unpinned)) }
|
||||
is AppResult.Error -> _uiState.update { it.copy(errorMessage = result.reason.toUiMessage()) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun clearChat(chatId: Long) {
|
||||
viewModelScope.launch {
|
||||
when (val result = chatRepository.clearChat(chatId = chatId)) {
|
||||
is AppResult.Success -> _uiState.update { it.copy(managementMessage = context.getString(R.string.chat_list_info_history_cleared)) }
|
||||
is AppResult.Error -> _uiState.update { it.copy(errorMessage = result.reason.toUiMessage()) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun updateChatTitle(chatId: Long, title: String) {
|
||||
val normalized = title.trim()
|
||||
if (normalized.isBlank()) {
|
||||
_uiState.update { it.copy(errorMessage = context.getString(R.string.chat_list_error_title_required)) }
|
||||
return
|
||||
}
|
||||
viewModelScope.launch {
|
||||
when (val result = chatRepository.updateChatTitle(chatId = chatId, title = normalized)) {
|
||||
is AppResult.Success -> _uiState.update { it.copy(managementMessage = context.getString(R.string.chat_list_info_title_updated)) }
|
||||
is AppResult.Error -> _uiState.update { it.copy(errorMessage = result.reason.toUiMessage()) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun updateChatProfile(chatId: Long, title: String?, description: String?) {
|
||||
val normalizedTitle = title?.trim()?.ifBlank { null }
|
||||
val normalizedDescription = description?.trim()?.ifBlank { null }
|
||||
if (normalizedTitle == null && normalizedDescription == null) {
|
||||
_uiState.update { it.copy(errorMessage = context.getString(R.string.chat_list_error_title_or_description_required)) }
|
||||
return
|
||||
}
|
||||
viewModelScope.launch {
|
||||
when (
|
||||
val result = chatRepository.updateChatProfile(
|
||||
chatId = chatId,
|
||||
title = normalizedTitle,
|
||||
description = normalizedDescription,
|
||||
avatarUrl = null,
|
||||
)
|
||||
) {
|
||||
is AppResult.Success -> _uiState.update { it.copy(managementMessage = context.getString(R.string.chat_list_info_profile_updated)) }
|
||||
is AppResult.Error -> _uiState.update { it.copy(errorMessage = result.reason.toUiMessage()) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun deleteChatForMe(chatId: Long) {
|
||||
viewModelScope.launch {
|
||||
when (val result = chatRepository.removeChat(chatId = chatId, forAll = false)) {
|
||||
is AppResult.Success -> _uiState.update { it.copy(managementMessage = context.getString(R.string.chat_list_info_deleted_for_me)) }
|
||||
is AppResult.Error -> _uiState.update { it.copy(errorMessage = result.reason.toUiMessage()) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun deleteChatForAll(chatId: Long) {
|
||||
viewModelScope.launch {
|
||||
when (val result = chatRepository.removeChat(chatId = chatId, forAll = true)) {
|
||||
is AppResult.Success -> _uiState.update { it.copy(managementMessage = context.getString(R.string.chat_list_info_deleted_for_all)) }
|
||||
is AppResult.Error -> _uiState.update { it.copy(errorMessage = result.reason.toUiMessage()) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun toggleChatMute(chatId: Long) {
|
||||
viewModelScope.launch {
|
||||
when (val current = chatRepository.getChatNotifications(chatId = chatId)) {
|
||||
is AppResult.Success -> {
|
||||
when (val updated = chatRepository.updateChatNotifications(chatId = chatId, muted = !current.data.muted)) {
|
||||
is AppResult.Success -> _uiState.update {
|
||||
it.copy(
|
||||
managementMessage = if (updated.data.muted) {
|
||||
context.getString(R.string.chat_list_info_notifications_disabled)
|
||||
} else {
|
||||
context.getString(R.string.chat_list_info_notifications_enabled)
|
||||
},
|
||||
)
|
||||
}
|
||||
is AppResult.Error -> _uiState.update { it.copy(errorMessage = updated.reason.toUiMessage()) }
|
||||
}
|
||||
}
|
||||
is AppResult.Error -> _uiState.update { it.copy(errorMessage = current.reason.toUiMessage()) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun createInvite(chatId: Long) {
|
||||
viewModelScope.launch {
|
||||
when (val result = chatRepository.createInviteLink(chatId = chatId)) {
|
||||
is AppResult.Success -> _uiState.update {
|
||||
it.copy(
|
||||
managementMessage = context.getString(
|
||||
R.string.chat_list_info_invite_created,
|
||||
result.data.inviteUrl,
|
||||
),
|
||||
)
|
||||
}
|
||||
is AppResult.Error -> _uiState.update { it.copy(errorMessage = result.reason.toUiMessage()) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun addMember(chatId: Long, userId: Long) {
|
||||
viewModelScope.launch {
|
||||
when (val result = chatRepository.addMember(chatId = chatId, userId = userId)) {
|
||||
is AppResult.Success -> {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
managementMessage = context.getString(
|
||||
R.string.chat_list_info_member_added,
|
||||
result.data.name,
|
||||
),
|
||||
)
|
||||
}
|
||||
loadMembersAndBans(chatId)
|
||||
}
|
||||
is AppResult.Error -> _uiState.update { it.copy(errorMessage = result.reason.toUiMessage()) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun updateMemberRole(chatId: Long, userId: Long, role: String) {
|
||||
viewModelScope.launch {
|
||||
when (val result = chatRepository.updateMemberRole(chatId = chatId, userId = userId, role = role)) {
|
||||
is AppResult.Success -> {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
managementMessage = context.getString(
|
||||
R.string.chat_list_info_member_role_updated,
|
||||
result.data.name,
|
||||
result.data.role,
|
||||
),
|
||||
)
|
||||
}
|
||||
loadMembersAndBans(chatId)
|
||||
}
|
||||
is AppResult.Error -> _uiState.update { it.copy(errorMessage = result.reason.toUiMessage()) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun removeMember(chatId: Long, userId: Long) {
|
||||
viewModelScope.launch {
|
||||
when (val result = chatRepository.removeMember(chatId = chatId, userId = userId)) {
|
||||
is AppResult.Success -> {
|
||||
_uiState.update { it.copy(managementMessage = context.getString(R.string.chat_list_info_member_removed)) }
|
||||
loadMembersAndBans(chatId)
|
||||
}
|
||||
is AppResult.Error -> _uiState.update { it.copy(errorMessage = result.reason.toUiMessage()) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun banMember(chatId: Long, userId: Long) {
|
||||
viewModelScope.launch {
|
||||
when (val result = chatRepository.banMember(chatId = chatId, userId = userId)) {
|
||||
is AppResult.Success -> {
|
||||
_uiState.update { it.copy(managementMessage = context.getString(R.string.chat_list_info_member_banned)) }
|
||||
loadMembersAndBans(chatId)
|
||||
}
|
||||
is AppResult.Error -> _uiState.update { it.copy(errorMessage = result.reason.toUiMessage()) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun unbanMember(chatId: Long, userId: Long) {
|
||||
viewModelScope.launch {
|
||||
when (val result = chatRepository.unbanMember(chatId = chatId, userId = userId)) {
|
||||
is AppResult.Success -> {
|
||||
_uiState.update { it.copy(managementMessage = context.getString(R.string.chat_list_info_member_unbanned)) }
|
||||
loadMembersAndBans(chatId)
|
||||
}
|
||||
is AppResult.Error -> _uiState.update { it.copy(errorMessage = result.reason.toUiMessage()) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun observeChatStream() {
|
||||
viewModelScope.launch {
|
||||
val archiveStatsFlow = observeChatsUseCase(archived = true)
|
||||
@@ -117,24 +515,126 @@ class ChatListViewModel @Inject constructor(
|
||||
.combine(selectedFilter) { (chats, query), filter ->
|
||||
chats.filterByQueryAndType(query = query, filter = filter)
|
||||
}
|
||||
.combine(searchHistoryIds) { filtered, historyIds ->
|
||||
filtered to historyIds
|
||||
}
|
||||
.combine(searchRecentIds) { (filtered, historyIds), recentIds ->
|
||||
Triple(filtered, historyIds, recentIds)
|
||||
}
|
||||
.combine(archiveStatsFlow) { filtered, stats ->
|
||||
filtered to stats
|
||||
}
|
||||
.collectLatest { (filtered, stats) ->
|
||||
.collectLatest { (filteredWithSearch, stats) ->
|
||||
val filtered = filteredWithSearch.first
|
||||
val historyIds = filteredWithSearch.second
|
||||
val recentIds = filteredWithSearch.third
|
||||
val byId = filtered.associateBy { it.id }
|
||||
val historyChats = historyIds.mapNotNull(byId::get)
|
||||
val recentChats = recentIds.mapNotNull(byId::get)
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
isLoading = false,
|
||||
isRefreshing = false,
|
||||
errorMessage = null,
|
||||
chats = filtered,
|
||||
searchHistoryChats = historyChats,
|
||||
searchRecentChats = recentChats,
|
||||
archivedChatsCount = stats.first,
|
||||
archivedUnreadCount = stats.second,
|
||||
managementMessage = null,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun observeConnectionState() {
|
||||
viewModelScope.launch {
|
||||
realtimeManager.connectionState.collectLatest { state ->
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
isConnecting = state == RealtimeConnectionState.Connecting ||
|
||||
state == RealtimeConnectionState.Reconnecting,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun observeSearchStore() {
|
||||
viewModelScope.launch {
|
||||
chatSearchRepository.observeHistoryChatIds().collectLatest { ids ->
|
||||
searchHistoryIds.value = ids
|
||||
}
|
||||
}
|
||||
viewModelScope.launch {
|
||||
chatSearchRepository.observeRecentChatIds().collectLatest { ids ->
|
||||
searchRecentIds.value = ids
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun createChatInternal(
|
||||
type: String,
|
||||
title: String,
|
||||
isPublic: Boolean,
|
||||
handle: String?,
|
||||
description: String?,
|
||||
memberIds: List<Long>,
|
||||
successMessageResId: Int,
|
||||
) {
|
||||
val normalizedTitle = title.trim()
|
||||
if (normalizedTitle.isBlank()) {
|
||||
_uiState.update { it.copy(errorMessage = context.getString(R.string.chat_list_error_title_required)) }
|
||||
return
|
||||
}
|
||||
viewModelScope.launch {
|
||||
when (
|
||||
val result = chatRepository.createChat(
|
||||
type = type,
|
||||
title = normalizedTitle,
|
||||
isPublic = isPublic,
|
||||
handle = handle?.trim()?.ifBlank { null },
|
||||
description = description?.trim()?.ifBlank { null },
|
||||
memberIds = memberIds,
|
||||
)
|
||||
) {
|
||||
is AppResult.Success -> {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
managementMessage = context.getString(successMessageResId),
|
||||
pendingOpenChatId = result.data.id,
|
||||
)
|
||||
}
|
||||
refreshCurrentTab(forceRefresh = true)
|
||||
}
|
||||
is AppResult.Error -> _uiState.update {
|
||||
it.copy(errorMessage = result.reason.toUiMessage())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun loadMembersAndBans(chatId: Long) {
|
||||
viewModelScope.launch {
|
||||
_uiState.update { it.copy(isManagementLoading = true, errorMessage = null) }
|
||||
val membersResult = chatRepository.listMembers(chatId = chatId)
|
||||
val bansResult = chatRepository.listBans(chatId = chatId)
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
isManagementLoading = false,
|
||||
members = (membersResult as? AppResult.Success)?.data ?: emptyList(),
|
||||
bans = (bansResult as? AppResult.Success)?.data ?: emptyList(),
|
||||
errorMessage = listOf(membersResult, bansResult)
|
||||
.filterIsInstance<AppResult.Error>()
|
||||
.firstOrNull()
|
||||
?.reason
|
||||
?.toUiMessage(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun refreshCurrentTab(forceRefresh: Boolean = false) {
|
||||
viewModelScope.launch {
|
||||
_uiState.update {
|
||||
@@ -174,16 +674,15 @@ class ChatListViewModel @Inject constructor(
|
||||
|
||||
private fun AppError.toUiMessage(): String {
|
||||
return when (this) {
|
||||
AppError.Network -> "Network error while syncing chats."
|
||||
AppError.Unauthorized -> "Session expired. Please log in again."
|
||||
AppError.InvalidCredentials -> "Authorization failed."
|
||||
is AppError.Server -> "Server error while loading chats."
|
||||
is AppError.Unknown -> "Unknown error while loading chats."
|
||||
AppError.Network -> context.getString(R.string.chat_list_error_network_sync)
|
||||
AppError.Unauthorized -> context.getString(R.string.chat_list_error_session_expired)
|
||||
AppError.InvalidCredentials -> context.getString(R.string.chat_list_error_authorization_failed)
|
||||
is AppError.Server -> context.getString(R.string.chat_list_error_server_loading)
|
||||
is AppError.Unknown -> context.getString(R.string.chat_list_error_unknown_loading)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCleared() {
|
||||
handleRealtimeEventsUseCase.stop()
|
||||
super.onCleared()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,257 @@
|
||||
package ru.daemonlord.messenger.ui.contacts
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.safeDrawing
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.layout.windowInsetsPadding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.pulltorefresh.PullToRefreshBox
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.snapshotFlow
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import ru.daemonlord.messenger.R
|
||||
|
||||
@Composable
|
||||
fun ContactsRoute(
|
||||
onMainBarVisibilityChanged: (Boolean) -> Unit,
|
||||
viewModel: ContactsViewModel = hiltViewModel(),
|
||||
) {
|
||||
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
ContactsScreen(
|
||||
state = state,
|
||||
onMainBarVisibilityChanged = onMainBarVisibilityChanged,
|
||||
onQueryChanged = viewModel::onQueryChanged,
|
||||
onAddByEmailChanged = viewModel::onAddByEmailChanged,
|
||||
onRefresh = viewModel::onRefresh,
|
||||
onAddContact = viewModel::addContact,
|
||||
onAddContactByEmail = viewModel::addContactByEmail,
|
||||
onRemoveContact = viewModel::removeContact,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
private fun ContactsScreen(
|
||||
state: ContactsUiState,
|
||||
onMainBarVisibilityChanged: (Boolean) -> Unit,
|
||||
onQueryChanged: (String) -> Unit,
|
||||
onAddByEmailChanged: (String) -> Unit,
|
||||
onRefresh: () -> Unit,
|
||||
onAddContact: (Long) -> Unit,
|
||||
onAddContactByEmail: () -> Unit,
|
||||
onRemoveContact: (Long) -> Unit,
|
||||
) {
|
||||
val listState = rememberLazyListState()
|
||||
val isTabletLayout = LocalConfiguration.current.screenWidthDp >= 840
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
onMainBarVisibilityChanged(true)
|
||||
}
|
||||
LaunchedEffect(listState) {
|
||||
var prevIndex = 0
|
||||
var prevOffset = 0
|
||||
snapshotFlow { listState.firstVisibleItemIndex to listState.firstVisibleItemScrollOffset }
|
||||
.collectLatest { (index, offset) ->
|
||||
val movedDown = index > prevIndex || (index == prevIndex && offset > prevOffset)
|
||||
val movedUp = index < prevIndex || (index == prevIndex && offset < prevOffset)
|
||||
when {
|
||||
index == 0 && offset == 0 -> onMainBarVisibilityChanged(true)
|
||||
movedDown -> onMainBarVisibilityChanged(false)
|
||||
movedUp -> onMainBarVisibilityChanged(true)
|
||||
}
|
||||
prevIndex = index
|
||||
prevOffset = offset
|
||||
}
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.windowInsetsPadding(WindowInsets.safeDrawing),
|
||||
contentAlignment = Alignment.TopCenter,
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.then(if (isTabletLayout) Modifier.widthIn(max = 820.dp) else Modifier)
|
||||
.padding(bottom = 96.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
TopAppBar(
|
||||
title = { Text(stringResource(id = R.string.contacts_title)) },
|
||||
)
|
||||
PullToRefreshBox(
|
||||
isRefreshing = state.isRefreshing,
|
||||
onRefresh = onRefresh,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(horizontal = 16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = state.query,
|
||||
onValueChange = onQueryChanged,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true,
|
||||
label = { Text(stringResource(id = R.string.contacts_search_label)) },
|
||||
)
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = state.addByEmail,
|
||||
onValueChange = onAddByEmailChanged,
|
||||
modifier = Modifier.weight(1f),
|
||||
singleLine = true,
|
||||
label = { Text(stringResource(id = R.string.contacts_add_by_email_label)) },
|
||||
)
|
||||
Button(onClick = onAddContactByEmail) {
|
||||
Text(stringResource(id = R.string.common_create))
|
||||
}
|
||||
}
|
||||
|
||||
state.errorMessage?.let {
|
||||
Text(
|
||||
text = it,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
}
|
||||
state.infoMessage?.let {
|
||||
Text(
|
||||
text = it,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
}
|
||||
|
||||
if (state.isLoading) {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
CircularProgressIndicator()
|
||||
}
|
||||
} else {
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
state = listState,
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
if (state.isSearchingUsers || state.query.trim().length >= 2) {
|
||||
item(key = "search_header") {
|
||||
Text(
|
||||
text = stringResource(id = R.string.contacts_search_results),
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
}
|
||||
items(state.searchResults, key = { "search_${it.id}" }) { user ->
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = user.name,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Text(
|
||||
text = user.username?.let { "@$it" } ?: "id: ${user.id}",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
OutlinedButton(onClick = { onAddContact(user.id) }) {
|
||||
Text(stringResource(id = R.string.common_create))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
item(key = "contacts_header") {
|
||||
Text(
|
||||
text = stringResource(id = R.string.contacts_my_contacts),
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
modifier = Modifier.padding(top = 4.dp),
|
||||
)
|
||||
}
|
||||
|
||||
items(state.contacts, key = { "contact_${it.id}" }) { contact ->
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = contact.name,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Text(
|
||||
text = contact.username?.let { "@$it" } ?: stringResource(id = R.string.contacts_last_seen_recently),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
OutlinedButton(onClick = { onRemoveContact(contact.id) }) {
|
||||
Text(stringResource(id = R.string.contacts_remove))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (state.contacts.isEmpty()) {
|
||||
item(key = "empty_contacts") {
|
||||
Text(
|
||||
text = stringResource(id = R.string.contacts_empty),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package ru.daemonlord.messenger.ui.contacts
|
||||
|
||||
import ru.daemonlord.messenger.domain.account.model.UserSearchItem
|
||||
|
||||
data class ContactsUiState(
|
||||
val isLoading: Boolean = true,
|
||||
val isRefreshing: Boolean = false,
|
||||
val isSearchingUsers: Boolean = false,
|
||||
val query: String = "",
|
||||
val addByEmail: String = "",
|
||||
val contacts: List<UserSearchItem> = emptyList(),
|
||||
val searchResults: List<UserSearchItem> = emptyList(),
|
||||
val errorMessage: String? = null,
|
||||
val infoMessage: String? = null,
|
||||
)
|
||||
@@ -0,0 +1,162 @@
|
||||
package ru.daemonlord.messenger.ui.contacts
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import android.content.Context
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import ru.daemonlord.messenger.R
|
||||
import ru.daemonlord.messenger.domain.account.repository.AccountRepository
|
||||
import ru.daemonlord.messenger.domain.common.AppError
|
||||
import ru.daemonlord.messenger.domain.common.AppResult
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltViewModel
|
||||
class ContactsViewModel @Inject constructor(
|
||||
private val accountRepository: AccountRepository,
|
||||
@ApplicationContext private val context: Context,
|
||||
) : ViewModel() {
|
||||
|
||||
private val _uiState = MutableStateFlow(ContactsUiState())
|
||||
val uiState: StateFlow<ContactsUiState> = _uiState.asStateFlow()
|
||||
private var searchJob: Job? = null
|
||||
|
||||
init {
|
||||
loadContacts()
|
||||
}
|
||||
|
||||
fun onQueryChanged(value: String) {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
query = value,
|
||||
errorMessage = null,
|
||||
infoMessage = null,
|
||||
)
|
||||
}
|
||||
val normalized = value.trim()
|
||||
if (normalized.length < 2) {
|
||||
searchJob?.cancel()
|
||||
_uiState.update { it.copy(searchResults = emptyList(), isSearchingUsers = false) }
|
||||
return
|
||||
}
|
||||
searchJob?.cancel()
|
||||
searchJob = viewModelScope.launch {
|
||||
_uiState.update { it.copy(isSearchingUsers = true) }
|
||||
delay(250)
|
||||
when (val result = accountRepository.searchUsers(query = normalized, limit = 20)) {
|
||||
is AppResult.Success -> _uiState.update {
|
||||
it.copy(
|
||||
isSearchingUsers = false,
|
||||
searchResults = result.data,
|
||||
)
|
||||
}
|
||||
is AppResult.Error -> _uiState.update {
|
||||
it.copy(
|
||||
isSearchingUsers = false,
|
||||
searchResults = emptyList(),
|
||||
errorMessage = result.reason.toUiMessage(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun onAddByEmailChanged(value: String) {
|
||||
_uiState.update { it.copy(addByEmail = value) }
|
||||
}
|
||||
|
||||
fun onRefresh() {
|
||||
loadContacts(forceRefresh = true)
|
||||
}
|
||||
|
||||
fun addContact(userId: Long) {
|
||||
viewModelScope.launch {
|
||||
when (val result = accountRepository.addContact(userId = userId)) {
|
||||
is AppResult.Success -> {
|
||||
_uiState.update { it.copy(infoMessage = context.getString(R.string.contacts_info_added)) }
|
||||
loadContacts(forceRefresh = true)
|
||||
}
|
||||
is AppResult.Error -> _uiState.update { it.copy(errorMessage = result.reason.toUiMessage()) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun addContactByEmail() {
|
||||
val email = uiState.value.addByEmail.trim()
|
||||
if (email.isBlank()) {
|
||||
_uiState.update { it.copy(errorMessage = context.getString(R.string.contacts_error_email_required)) }
|
||||
return
|
||||
}
|
||||
viewModelScope.launch {
|
||||
when (val result = accountRepository.addContactByEmail(email = email)) {
|
||||
is AppResult.Success -> {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
addByEmail = "",
|
||||
infoMessage = context.getString(R.string.contacts_info_added_by_email),
|
||||
)
|
||||
}
|
||||
loadContacts(forceRefresh = true)
|
||||
}
|
||||
is AppResult.Error -> _uiState.update { it.copy(errorMessage = result.reason.toUiMessage()) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun removeContact(userId: Long) {
|
||||
viewModelScope.launch {
|
||||
when (val result = accountRepository.removeContact(userId = userId)) {
|
||||
is AppResult.Success -> {
|
||||
_uiState.update { it.copy(infoMessage = context.getString(R.string.contacts_info_removed)) }
|
||||
loadContacts(forceRefresh = true)
|
||||
}
|
||||
is AppResult.Error -> _uiState.update { it.copy(errorMessage = result.reason.toUiMessage()) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun loadContacts(forceRefresh: Boolean = false) {
|
||||
viewModelScope.launch {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
isLoading = it.contacts.isEmpty(),
|
||||
isRefreshing = forceRefresh,
|
||||
errorMessage = null,
|
||||
)
|
||||
}
|
||||
when (val result = accountRepository.listContacts()) {
|
||||
is AppResult.Success -> _uiState.update {
|
||||
it.copy(
|
||||
isLoading = false,
|
||||
isRefreshing = false,
|
||||
contacts = result.data,
|
||||
)
|
||||
}
|
||||
is AppResult.Error -> _uiState.update {
|
||||
it.copy(
|
||||
isLoading = false,
|
||||
isRefreshing = false,
|
||||
errorMessage = result.reason.toUiMessage(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun AppError.toUiMessage(): String {
|
||||
return when (this) {
|
||||
AppError.Network -> context.getString(R.string.error_network)
|
||||
AppError.Unauthorized -> context.getString(R.string.error_session_expired)
|
||||
AppError.InvalidCredentials -> context.getString(R.string.error_authorization)
|
||||
is AppError.Server -> this.message ?: context.getString(R.string.error_server)
|
||||
is AppError.Unknown -> this.cause?.message ?: context.getString(R.string.error_unknown)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,40 +6,78 @@ import android.os.Build
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.navigationBarsPadding
|
||||
import androidx.compose.foundation.layout.safeDrawing
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.windowInsetsPadding
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.Chat
|
||||
import androidx.compose.material.icons.filled.Contacts
|
||||
import androidx.compose.material.icons.filled.Person
|
||||
import androidx.compose.material.icons.filled.Settings
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.NavigationBar
|
||||
import androidx.compose.material3.NavigationBarItem
|
||||
import androidx.compose.material3.NavigationBarItemDefaults
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.slideInVertically
|
||||
import androidx.compose.animation.slideOutVertically
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.navigation.NavType
|
||||
import androidx.navigation.NavGraph.Companion.findStartDestination
|
||||
import androidx.navigation.compose.currentBackStackEntryAsState
|
||||
import androidx.navigation.navArgument
|
||||
import androidx.navigation.NavHostController
|
||||
import androidx.navigation.compose.NavHost
|
||||
import androidx.navigation.compose.composable
|
||||
import androidx.navigation.compose.navigation
|
||||
import androidx.navigation.compose.rememberNavController
|
||||
import ru.daemonlord.messenger.R
|
||||
import ru.daemonlord.messenger.ui.auth.AuthViewModel
|
||||
import ru.daemonlord.messenger.ui.auth.LoginScreen
|
||||
import ru.daemonlord.messenger.ui.auth.reset.ResetPasswordRoute
|
||||
import ru.daemonlord.messenger.ui.auth.verify.VerifyEmailRoute
|
||||
import ru.daemonlord.messenger.ui.chat.ChatRoute
|
||||
import ru.daemonlord.messenger.ui.chats.ChatListRoute
|
||||
import ru.daemonlord.messenger.ui.contacts.ContactsRoute
|
||||
import ru.daemonlord.messenger.ui.profile.ProfileRoute
|
||||
import ru.daemonlord.messenger.ui.settings.SettingsRoute
|
||||
|
||||
private object Routes {
|
||||
const val Startup = "startup"
|
||||
const val AuthGraph = "auth_graph"
|
||||
const val Login = "login"
|
||||
const val AddAccountLogin = "add_account_login"
|
||||
const val VerifyEmail = "verify_email"
|
||||
const val ResetPassword = "reset_password"
|
||||
const val Chats = "chats"
|
||||
const val Contacts = "contacts"
|
||||
const val Settings = "settings"
|
||||
const val Profile = "profile"
|
||||
const val Chat = "chat"
|
||||
@@ -51,6 +89,10 @@ fun MessengerNavHost(
|
||||
viewModel: AuthViewModel = hiltViewModel(),
|
||||
inviteToken: String? = null,
|
||||
onInviteTokenConsumed: () -> Unit = {},
|
||||
verifyEmailToken: String? = null,
|
||||
onVerifyEmailTokenConsumed: () -> Unit = {},
|
||||
resetPasswordToken: String? = null,
|
||||
onResetPasswordTokenConsumed: () -> Unit = {},
|
||||
notificationChatId: Long? = null,
|
||||
notificationMessageId: Long? = null,
|
||||
onNotificationConsumed: () -> Unit = {},
|
||||
@@ -60,6 +102,13 @@ fun MessengerNavHost(
|
||||
val notificationPermissionLauncher = rememberLauncherForActivityResult(
|
||||
contract = ActivityResultContracts.RequestPermission(),
|
||||
) {}
|
||||
var isMainBarVisible by remember { mutableStateOf(true) }
|
||||
val backStackEntry by navController.currentBackStackEntryAsState()
|
||||
val currentRoute = backStackEntry?.destination?.route
|
||||
val mainTabRoutes = remember {
|
||||
setOf(Routes.Chats, Routes.Contacts, Routes.Settings, Routes.Profile)
|
||||
}
|
||||
val showMainBar = currentRoute in mainTabRoutes
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) return@LaunchedEffect
|
||||
@@ -72,10 +121,26 @@ fun MessengerNavHost(
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(uiState.isCheckingSession, uiState.isAuthenticated, notificationChatId, notificationMessageId) {
|
||||
LaunchedEffect(uiState.isCheckingSession, uiState.isAuthenticated, verifyEmailToken, resetPasswordToken, notificationChatId, notificationMessageId) {
|
||||
if (uiState.isCheckingSession) {
|
||||
return@LaunchedEffect
|
||||
}
|
||||
if (!uiState.isAuthenticated && !verifyEmailToken.isNullOrBlank()) {
|
||||
navController.navigate("${Routes.VerifyEmail}?token=$verifyEmailToken") {
|
||||
popUpTo(navController.graph.findStartDestination().id) { inclusive = true }
|
||||
launchSingleTop = true
|
||||
}
|
||||
onVerifyEmailTokenConsumed()
|
||||
return@LaunchedEffect
|
||||
}
|
||||
if (!uiState.isAuthenticated && !resetPasswordToken.isNullOrBlank()) {
|
||||
navController.navigate("${Routes.ResetPassword}?token=$resetPasswordToken") {
|
||||
popUpTo(navController.graph.findStartDestination().id) { inclusive = true }
|
||||
launchSingleTop = true
|
||||
}
|
||||
onResetPasswordTokenConsumed()
|
||||
return@LaunchedEffect
|
||||
}
|
||||
if (uiState.isAuthenticated) {
|
||||
if (notificationChatId != null) {
|
||||
navController.navigate("${Routes.Chat}/$notificationChatId") {
|
||||
@@ -103,63 +168,275 @@ fun MessengerNavHost(
|
||||
}
|
||||
}
|
||||
|
||||
NavHost(
|
||||
navController = navController,
|
||||
startDestination = Routes.AuthGraph,
|
||||
) {
|
||||
navigation(
|
||||
route = Routes.AuthGraph,
|
||||
startDestination = Routes.Login,
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
NavHost(
|
||||
navController = navController,
|
||||
startDestination = Routes.Startup,
|
||||
) {
|
||||
composable(route = Routes.Login) {
|
||||
if (uiState.isCheckingSession) {
|
||||
SessionCheckingScreen()
|
||||
} else {
|
||||
composable(route = Routes.Startup) {
|
||||
SessionCheckingScreen()
|
||||
}
|
||||
|
||||
navigation(
|
||||
route = Routes.AuthGraph,
|
||||
startDestination = Routes.Login,
|
||||
) {
|
||||
composable(route = Routes.Login) {
|
||||
LoginScreen(
|
||||
state = uiState,
|
||||
headerTitle = context.getString(R.string.auth_header_login),
|
||||
onEmailChanged = viewModel::onEmailChanged,
|
||||
onNameChanged = viewModel::onNameChanged,
|
||||
onUsernameChanged = viewModel::onUsernameChanged,
|
||||
onPasswordChanged = viewModel::onPasswordChanged,
|
||||
onLoginClick = viewModel::login,
|
||||
onOtpCodeChanged = viewModel::onOtpCodeChanged,
|
||||
onRecoveryCodeChanged = viewModel::onRecoveryCodeChanged,
|
||||
onToggleRecoveryCodeMode = viewModel::toggleRecoveryCodeMode,
|
||||
onContinueEmail = viewModel::continueWithEmail,
|
||||
onSubmitStep = viewModel::submitAuthStep,
|
||||
onBackToEmail = viewModel::backToEmailStep,
|
||||
onOpenVerifyEmail = { navController.navigate(Routes.VerifyEmail) },
|
||||
onOpenResetPassword = { navController.navigate(Routes.ResetPassword) },
|
||||
)
|
||||
}
|
||||
composable(route = Routes.AddAccountLogin) { entry ->
|
||||
val addAccountViewModel: AuthViewModel = hiltViewModel(entry)
|
||||
val addAccountState by addAccountViewModel.uiState.collectAsState()
|
||||
val lastCompletedNonce = remember { mutableStateOf(0L) }
|
||||
LaunchedEffect(Unit) {
|
||||
addAccountViewModel.startAddAccountFlow()
|
||||
}
|
||||
LaunchedEffect(addAccountState.authCompletedNonce) {
|
||||
val nonce = addAccountState.authCompletedNonce
|
||||
if (nonce == 0L || nonce == lastCompletedNonce.value) return@LaunchedEffect
|
||||
lastCompletedNonce.value = nonce
|
||||
viewModel.recheckSession()
|
||||
navController.navigate(Routes.Chats) {
|
||||
popUpTo(navController.graph.findStartDestination().id) {
|
||||
saveState = false
|
||||
}
|
||||
launchSingleTop = true
|
||||
restoreState = false
|
||||
}
|
||||
}
|
||||
LoginScreen(
|
||||
state = addAccountState,
|
||||
headerTitle = context.getString(R.string.settings_add_account),
|
||||
onEmailChanged = addAccountViewModel::onEmailChanged,
|
||||
onNameChanged = addAccountViewModel::onNameChanged,
|
||||
onUsernameChanged = addAccountViewModel::onUsernameChanged,
|
||||
onPasswordChanged = addAccountViewModel::onPasswordChanged,
|
||||
onOtpCodeChanged = addAccountViewModel::onOtpCodeChanged,
|
||||
onRecoveryCodeChanged = addAccountViewModel::onRecoveryCodeChanged,
|
||||
onToggleRecoveryCodeMode = addAccountViewModel::toggleRecoveryCodeMode,
|
||||
onContinueEmail = addAccountViewModel::continueWithEmail,
|
||||
onSubmitStep = addAccountViewModel::submitAuthStep,
|
||||
onBackToEmail = {
|
||||
if (addAccountState.step == ru.daemonlord.messenger.ui.auth.AuthStep.EMAIL) {
|
||||
navController.popBackStack()
|
||||
} else {
|
||||
addAccountViewModel.backToEmailStep()
|
||||
}
|
||||
},
|
||||
onOpenVerifyEmail = { navController.navigate(Routes.VerifyEmail) },
|
||||
onOpenResetPassword = { navController.navigate(Routes.ResetPassword) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
composable(
|
||||
route = "${Routes.VerifyEmail}?token={token}",
|
||||
arguments = listOf(
|
||||
navArgument("token") {
|
||||
type = NavType.StringType
|
||||
nullable = true
|
||||
defaultValue = null
|
||||
}
|
||||
),
|
||||
) { entry ->
|
||||
VerifyEmailRoute(
|
||||
token = entry.arguments?.getString("token"),
|
||||
onBackToLogin = { navController.navigate(Routes.Login) },
|
||||
)
|
||||
}
|
||||
|
||||
composable(
|
||||
route = "${Routes.ResetPassword}?token={token}",
|
||||
arguments = listOf(
|
||||
navArgument("token") {
|
||||
type = NavType.StringType
|
||||
nullable = true
|
||||
defaultValue = null
|
||||
}
|
||||
),
|
||||
) { entry ->
|
||||
ResetPasswordRoute(
|
||||
token = entry.arguments?.getString("token"),
|
||||
onBackToLogin = { navController.navigate(Routes.Login) },
|
||||
)
|
||||
}
|
||||
|
||||
composable(route = Routes.Chats) {
|
||||
ChatListRoute(
|
||||
inviteToken = inviteToken,
|
||||
onInviteTokenConsumed = onInviteTokenConsumed,
|
||||
onOpenChat = { chatId ->
|
||||
navController.navigate("${Routes.Chat}/$chatId")
|
||||
},
|
||||
isMainBarVisible = isMainBarVisible,
|
||||
onMainBarVisibilityChanged = { isMainBarVisible = it },
|
||||
)
|
||||
}
|
||||
|
||||
composable(route = Routes.Contacts) {
|
||||
ContactsRoute(onMainBarVisibilityChanged = { isMainBarVisible = it })
|
||||
}
|
||||
|
||||
composable(route = Routes.Settings) {
|
||||
SettingsRoute(
|
||||
onOpenProfile = { navController.navigate(Routes.Profile) },
|
||||
onAddAccount = { navController.navigate(Routes.AddAccountLogin) },
|
||||
onSwitchAccount = {
|
||||
viewModel.recheckSession()
|
||||
navController.navigate(Routes.Chats) {
|
||||
popUpTo(navController.graph.findStartDestination().id) {
|
||||
saveState = false
|
||||
}
|
||||
launchSingleTop = true
|
||||
restoreState = false
|
||||
}
|
||||
},
|
||||
onLogout = viewModel::logout,
|
||||
onMainBarVisibilityChanged = { isMainBarVisible = it },
|
||||
)
|
||||
}
|
||||
|
||||
composable(route = Routes.Profile) {
|
||||
ProfileRoute(
|
||||
onMainBarVisibilityChanged = { isMainBarVisible = it },
|
||||
)
|
||||
}
|
||||
|
||||
composable(
|
||||
route = "${Routes.Chat}/{chatId}",
|
||||
arguments = listOf(
|
||||
navArgument("chatId") { type = NavType.LongType }
|
||||
),
|
||||
) { backStackEntry ->
|
||||
ChatRoute(
|
||||
onBack = { navController.popBackStack() },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
composable(route = Routes.Chats) {
|
||||
ChatListRoute(
|
||||
inviteToken = inviteToken,
|
||||
onInviteTokenConsumed = onInviteTokenConsumed,
|
||||
onOpenSettings = { navController.navigate(Routes.Settings) },
|
||||
onOpenProfile = { navController.navigate(Routes.Profile) },
|
||||
onOpenChat = { chatId ->
|
||||
navController.navigate("${Routes.Chat}/$chatId")
|
||||
}
|
||||
AnimatedVisibility(
|
||||
visible = showMainBar && isMainBarVisible,
|
||||
enter = slideInVertically(initialOffsetY = { it / 2 }) + fadeIn(),
|
||||
exit = slideOutVertically(targetOffsetY = { it / 2 }) + fadeOut(),
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomCenter)
|
||||
.navigationBarsPadding()
|
||||
.padding(bottom = 12.dp),
|
||||
) {
|
||||
MainBottomBar(
|
||||
currentRoute = currentRoute,
|
||||
onNavigate = { route ->
|
||||
if (currentRoute == route) return@MainBottomBar
|
||||
navController.navigate(route) {
|
||||
popUpTo(navController.graph.findStartDestination().id) {
|
||||
saveState = true
|
||||
}
|
||||
launchSingleTop = true
|
||||
restoreState = true
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
composable(route = Routes.Settings) {
|
||||
SettingsRoute(
|
||||
onBackToChats = { navController.popBackStack() },
|
||||
onOpenProfile = { navController.navigate(Routes.Profile) },
|
||||
onLogout = viewModel::logout,
|
||||
@Composable
|
||||
private fun MainBottomBar(
|
||||
currentRoute: String?,
|
||||
onNavigate: (String) -> Unit,
|
||||
) {
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 12.dp)
|
||||
.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(28.dp),
|
||||
color = MaterialTheme.colorScheme.surface.copy(alpha = 0.88f),
|
||||
tonalElevation = 2.dp,
|
||||
shadowElevation = 8.dp,
|
||||
) {
|
||||
NavigationBar(
|
||||
containerColor = Color.Transparent,
|
||||
tonalElevation = 0.dp,
|
||||
modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp),
|
||||
) {
|
||||
NavigationBarItem(
|
||||
selected = currentRoute == Routes.Chats,
|
||||
onClick = { onNavigate(Routes.Chats) },
|
||||
icon = { Icon(Icons.AutoMirrored.Filled.Chat, contentDescription = stringResource(id = R.string.nav_chats)) },
|
||||
label = {
|
||||
androidx.compose.material3.Text(stringResource(id = R.string.nav_chats), maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
},
|
||||
alwaysShowLabel = true,
|
||||
colors = NavigationBarItemDefaults.colors(
|
||||
selectedIconColor = MaterialTheme.colorScheme.primary,
|
||||
selectedTextColor = MaterialTheme.colorScheme.primary,
|
||||
indicatorColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.18f),
|
||||
unselectedIconColor = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
unselectedTextColor = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
composable(route = Routes.Profile) {
|
||||
ProfileRoute(
|
||||
onBackToChats = { navController.popBackStack() },
|
||||
onOpenSettings = { navController.navigate(Routes.Settings) },
|
||||
NavigationBarItem(
|
||||
selected = currentRoute == Routes.Contacts,
|
||||
onClick = { onNavigate(Routes.Contacts) },
|
||||
icon = { Icon(Icons.Filled.Contacts, contentDescription = stringResource(id = R.string.nav_contacts)) },
|
||||
label = {
|
||||
androidx.compose.material3.Text(stringResource(id = R.string.nav_contacts), maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
},
|
||||
alwaysShowLabel = true,
|
||||
colors = NavigationBarItemDefaults.colors(
|
||||
selectedIconColor = MaterialTheme.colorScheme.primary,
|
||||
selectedTextColor = MaterialTheme.colorScheme.primary,
|
||||
indicatorColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.18f),
|
||||
unselectedIconColor = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
unselectedTextColor = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
composable(
|
||||
route = "${Routes.Chat}/{chatId}",
|
||||
arguments = listOf(
|
||||
navArgument("chatId") { type = NavType.LongType }
|
||||
),
|
||||
) { backStackEntry ->
|
||||
ChatRoute(
|
||||
onBack = { navController.popBackStack() },
|
||||
NavigationBarItem(
|
||||
selected = currentRoute == Routes.Settings,
|
||||
onClick = { onNavigate(Routes.Settings) },
|
||||
icon = { Icon(Icons.Filled.Settings, contentDescription = stringResource(id = R.string.nav_settings)) },
|
||||
label = {
|
||||
androidx.compose.material3.Text(stringResource(id = R.string.nav_settings), maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
},
|
||||
alwaysShowLabel = true,
|
||||
colors = NavigationBarItemDefaults.colors(
|
||||
selectedIconColor = MaterialTheme.colorScheme.primary,
|
||||
selectedTextColor = MaterialTheme.colorScheme.primary,
|
||||
indicatorColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.18f),
|
||||
unselectedIconColor = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
unselectedTextColor = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
)
|
||||
NavigationBarItem(
|
||||
selected = currentRoute == Routes.Profile,
|
||||
onClick = { onNavigate(Routes.Profile) },
|
||||
icon = { Icon(Icons.Filled.Person, contentDescription = stringResource(id = R.string.nav_profile)) },
|
||||
label = {
|
||||
androidx.compose.material3.Text(stringResource(id = R.string.nav_profile), maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
},
|
||||
alwaysShowLabel = true,
|
||||
colors = NavigationBarItemDefaults.colors(
|
||||
selectedIconColor = MaterialTheme.colorScheme.primary,
|
||||
selectedTextColor = MaterialTheme.colorScheme.primary,
|
||||
indicatorColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.18f),
|
||||
unselectedIconColor = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
unselectedTextColor = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user