Compare commits
190 Commits
4939754de8
...
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 | ||
|
|
ef5f866bd0 | ||
|
|
8246fe6cae | ||
|
|
b5cd371f8b | ||
|
|
ffa2205a30 | ||
|
|
43b772a394 | ||
|
|
3eb68cedad | ||
|
|
89755394f7 | ||
|
|
16c21d1bb7 | ||
|
|
375f5756d3 | ||
|
|
0adcc97f0f | ||
|
|
c80ff650b2 | ||
|
|
7fcdc28015 | ||
|
|
6afde15a2c | ||
|
|
d7dfda1d31 | ||
|
|
33514265e3 | ||
|
|
dfa67c34c9 | ||
|
|
670fcd721d | ||
|
|
98492f869d | ||
|
|
e8574252ca | ||
|
|
d09300311f | ||
|
|
c947d96748 | ||
|
|
542af1d4c1 | ||
|
|
1d37f8eb0b | ||
|
|
ce585f62d2 | ||
|
|
a5a940b749 | ||
|
|
5c3535ef8f | ||
|
|
e2a87ffb2e | ||
|
|
c835dfda15 | ||
|
|
93a9f70669 | ||
|
|
fed8d22428 | ||
|
|
f159108b75 | ||
|
|
dfd4a00490 | ||
|
|
6c9501e624 | ||
|
|
7381d611cc | ||
|
|
db048b9f12 | ||
|
|
651d53f3df | ||
|
|
ade92e4a86 | ||
|
|
98e8ac8dfb | ||
|
|
071165c55b | ||
|
|
876d64d345 | ||
|
|
9e764574bc | ||
|
|
7cf6be6515 | ||
|
|
e992f1e26d | ||
|
|
d8916d6738 | ||
|
|
02ec6c95e9 | ||
|
|
fbe684799a | ||
|
|
a05b2ea929 | ||
|
|
81597f8f44 | ||
|
|
bd6a8a43ed | ||
|
|
08815bac7b | ||
|
|
e91884e14a | ||
|
|
37396f4da5 | ||
|
|
5760a0cb3f | ||
|
|
946b85a18f | ||
|
|
3dd320193c | ||
|
|
8d13eb104e | ||
|
|
ad2e0ede42 | ||
|
|
4fa657ff7a | ||
|
|
545b45c5db | ||
|
|
c63f063726 | ||
|
|
5a0add4d5c | ||
|
|
5ad89fc05b |
@@ -34,6 +34,11 @@ SMTP_USE_TLS=false
|
|||||||
SMTP_USE_SSL=false
|
SMTP_USE_SSL=false
|
||||||
SMTP_TIMEOUT_SECONDS=10
|
SMTP_TIMEOUT_SECONDS=10
|
||||||
SMTP_FROM_EMAIL=no-reply@benyamessenger.local
|
SMTP_FROM_EMAIL=no-reply@benyamessenger.local
|
||||||
|
FIREBASE_ENABLED=false
|
||||||
|
FIREBASE_CREDENTIALS_HOST_PATH=./secrets/firebase-service-account.json
|
||||||
|
FIREBASE_CREDENTIALS_PATH=
|
||||||
|
FIREBASE_CREDENTIALS_JSON=
|
||||||
|
FIREBASE_WEBPUSH_LINK=https://chat.daemonlord.ru/
|
||||||
|
|
||||||
LOGIN_RATE_LIMIT_PER_MINUTE=10
|
LOGIN_RATE_LIMIT_PER_MINUTE=10
|
||||||
REGISTER_RATE_LIMIT_PER_MINUTE=5
|
REGISTER_RATE_LIMIT_PER_MINUTE=5
|
||||||
|
|||||||
45
.github/workflows/android-ci.yml
vendored
Normal file
45
.github/workflows/android-ci.yml
vendored
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
name: Android CI
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
pull_request:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
android:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: https://git.daemonlord.ru/actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Set up JDK 17
|
||||||
|
uses: https://git.daemonlord.ru/actions/setup-java@v4
|
||||||
|
with:
|
||||||
|
distribution: temurin
|
||||||
|
java-version: 17
|
||||||
|
|
||||||
|
- name: Set up Android SDK
|
||||||
|
uses: https://git.daemonlord.ru/actions/setup-android@v3
|
||||||
|
|
||||||
|
- name: Make Gradlew executable
|
||||||
|
run: chmod +x ./android/gradlew
|
||||||
|
|
||||||
|
- name: Build + Unit tests + Lint
|
||||||
|
working-directory: android
|
||||||
|
run: ./gradlew --no-daemon clean testDebugUnitTest lintDebug assembleDebug assembleDebugAndroidTest
|
||||||
|
|
||||||
|
- name: Detekt (if configured)
|
||||||
|
working-directory: android
|
||||||
|
run: |
|
||||||
|
if ./gradlew -q tasks --all | grep -q "detekt"; then
|
||||||
|
./gradlew --no-daemon detekt
|
||||||
|
else
|
||||||
|
echo "Detekt task is not configured, skipping."
|
||||||
|
fi
|
||||||
|
|
||||||
94
.github/workflows/android-release.yml
vendored
Normal file
94
.github/workflows/android-release.yml
vendored
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
name: Android Release
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
release:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: https://git.daemonlord.ru/actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
tags: true
|
||||||
|
|
||||||
|
- name: Set up JDK 17
|
||||||
|
uses: https://git.daemonlord.ru/actions/setup-java@v4
|
||||||
|
with:
|
||||||
|
distribution: temurin
|
||||||
|
java-version: 17
|
||||||
|
|
||||||
|
- name: Extract versionName from Kotlin DSL
|
||||||
|
id: extract_version
|
||||||
|
run: |
|
||||||
|
VERSION=$(grep -oP 'versionName\s*=\s*"[^"]+"' android/app/build.gradle.kts | head -n1 | cut -d'"' -f2 | tr -d '\r\n')
|
||||||
|
if [ -z "$VERSION" ]; then
|
||||||
|
echo "Failed to detect versionName in android/app/build.gradle.kts"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "Detected version: $VERSION"
|
||||||
|
|
||||||
|
- name: Stop if version already released
|
||||||
|
id: version_check
|
||||||
|
run: |
|
||||||
|
VERSION="${{ steps.extract_version.outputs.version }}"
|
||||||
|
if git show-ref --tags --quiet --verify "refs/tags/$VERSION"; then
|
||||||
|
echo "Version $VERSION already released, stopping job."
|
||||||
|
echo "continue=false" >> "$GITHUB_OUTPUT"
|
||||||
|
else
|
||||||
|
echo "Version $VERSION is new, continuing..."
|
||||||
|
echo "continue=true" >> "$GITHUB_OUTPUT"
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Decode keystore
|
||||||
|
if: steps.version_check.outputs.continue == 'true'
|
||||||
|
run: |
|
||||||
|
echo "${{ secrets.KEYSTORE_BASE64 }}" | base64 -d > android/app/release.keystore
|
||||||
|
|
||||||
|
- name: Set up Android SDK
|
||||||
|
if: steps.version_check.outputs.continue == 'true'
|
||||||
|
uses: https://git.daemonlord.ru/actions/setup-android@v3
|
||||||
|
|
||||||
|
- name: Make Gradlew executable
|
||||||
|
if: steps.version_check.outputs.continue == 'true'
|
||||||
|
run: chmod +x ./android/gradlew
|
||||||
|
|
||||||
|
- name: Build release APK
|
||||||
|
if: steps.version_check.outputs.continue == 'true'
|
||||||
|
working-directory: android
|
||||||
|
env:
|
||||||
|
KEYSTORE_PASSWORD: ${{ secrets.KEYSTORE_PASSWORD }}
|
||||||
|
KEY_ALIAS: ${{ secrets.KEY_ALIAS }}
|
||||||
|
KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }}
|
||||||
|
run: ./gradlew --no-daemon assembleRelease
|
||||||
|
|
||||||
|
- name: Create git tag
|
||||||
|
if: steps.version_check.outputs.continue == 'true'
|
||||||
|
run: |
|
||||||
|
VERSION="${{ steps.extract_version.outputs.version }}"
|
||||||
|
git config user.name "android-release-bot"
|
||||||
|
git config user.email "android-release-bot@daemonlord.ru"
|
||||||
|
git tag "$VERSION"
|
||||||
|
git push origin "$VERSION"
|
||||||
|
|
||||||
|
- name: Create Gitea release
|
||||||
|
if: steps.version_check.outputs.continue == 'true'
|
||||||
|
uses: https://git.daemonlord.ru/actions/gitea-release-action@v1
|
||||||
|
with:
|
||||||
|
server_url: https://git.daemonlord.ru
|
||||||
|
repository: ${{ gitea.repository }}
|
||||||
|
token: ${{ secrets.API_TOKEN }}
|
||||||
|
tag_name: ${{ steps.extract_version.outputs.version }}
|
||||||
|
name: Release ${{ steps.extract_version.outputs.version }}
|
||||||
|
body: |
|
||||||
|
Android release ${{ steps.extract_version.outputs.version }}
|
||||||
|
files: |
|
||||||
|
android/app/build/outputs/apk/release/*.apk
|
||||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -8,3 +8,4 @@ test.db
|
|||||||
web/node_modules
|
web/node_modules
|
||||||
web/dist
|
web/dist
|
||||||
web/tsconfig.tsbuildinfo
|
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")
|
||||||
@@ -64,3 +64,962 @@
|
|||||||
- Fixed Hilt dependency cycle by separating refresh `AuthApiService` with a dedicated qualifier.
|
- Fixed Hilt dependency cycle by separating refresh `AuthApiService` with a dedicated qualifier.
|
||||||
- Added `CoroutineDispatcher` DI provider and qualifier for repositories.
|
- Added `CoroutineDispatcher` DI provider and qualifier for repositories.
|
||||||
- Fixed Material3 experimental API opt-in and removed deprecated `StateFlow.distinctUntilChanged()` usage.
|
- Fixed Material3 experimental API opt-in and removed deprecated `StateFlow.distinctUntilChanged()` usage.
|
||||||
|
|
||||||
|
### Step 11 - Sprint A / 1) Message Room + models
|
||||||
|
- Added message domain model (`MessageItem`) for chat screen rendering.
|
||||||
|
- Added Room entities `messages` and `message_attachments` with chat-history indexes.
|
||||||
|
- Added `MessageDao` with observe/pagination/upsert/delete APIs.
|
||||||
|
- Updated `MessengerDatabase` schema to include message tables and DAO.
|
||||||
|
- Added Hilt DI provider for `MessageDao`.
|
||||||
|
|
||||||
|
### Step 12 - Sprint A / 2) Message API + repository
|
||||||
|
- Added message REST API client for history/send/edit/delete endpoints.
|
||||||
|
- Added message DTOs and mappers (`MessageReadDto -> MessageEntity -> MessageItem`).
|
||||||
|
- Added `MessageRepository` contracts/use-cases for observe/sync/pagination/send/edit/delete.
|
||||||
|
- Implemented `NetworkMessageRepository` with cache-first observation and optimistic text send.
|
||||||
|
- Wired message API and repository into Hilt modules.
|
||||||
|
|
||||||
|
### Step 13 - Sprint A / 3) Message realtime integration
|
||||||
|
- Extended realtime event model/parser with message-focused events (`message_delivered`, `message_read`, `typing_start`, `typing_stop`) and richer message payload mapping.
|
||||||
|
- Updated unified realtime handler to write `receive_message`, `message_updated`, `message_deleted` into `messages` Room state.
|
||||||
|
- Added delivery/read status updates in Room for message status events.
|
||||||
|
- Kept chat list sync updates in the same manager/use-case pipeline for consistency.
|
||||||
|
|
||||||
|
### Step 14 - Sprint A / 4) Message UI core
|
||||||
|
- Replaced chat placeholder with a real message screen route + ViewModel.
|
||||||
|
- Added message list rendering with Telegram-like bubble alignment and status hints.
|
||||||
|
- Added input composer with send flow, reply/edit modes, and inline action cancellation.
|
||||||
|
- Added long-press actions (`reply`, `edit`, `delete`) for baseline message operations.
|
||||||
|
- Added manual "load older" pagination trigger and chat back navigation integration.
|
||||||
|
|
||||||
|
### Step 15 - Sprint A / 5) Message tests and docs
|
||||||
|
- Added unit tests for `NetworkMessageRepository` sync/send flows.
|
||||||
|
- Added DAO test for message scoped replace behavior in Room.
|
||||||
|
- Expanded realtime parser tests with rich `receive_message` mapping coverage.
|
||||||
|
- Updated `docs/android-checklist.md` for completed message-core items.
|
||||||
|
|
||||||
|
### Step 16 - Sprint B / 1-2) Media data layer + chat integration
|
||||||
|
- Added media API/DTO layer for upload URL and attachment creation.
|
||||||
|
- Added `MediaRepository` + `UploadAndAttachMediaUseCase` and network implementation with presigned PUT upload.
|
||||||
|
- Extended `MessageRepository` with media send flow (`sendMediaMessage`) and optimistic local update behavior.
|
||||||
|
- Wired media API/repository through Hilt modules.
|
||||||
|
- Integrated file picking and media sending into Android `ChatScreen`/`ChatViewModel` with upload state handling.
|
||||||
|
|
||||||
|
### Step 17 - Sprint B / media tests
|
||||||
|
- Added `NetworkMediaRepositoryTest` for successful upload+attach flow.
|
||||||
|
- Added error-path coverage for failed presigned upload handling.
|
||||||
|
|
||||||
|
## 2026-03-09
|
||||||
|
### Step 18 - Sprint P0 / 1) Message core completion
|
||||||
|
- Extended message API/data contracts with `messages/status`, `forward`, and reaction endpoints.
|
||||||
|
- Added message domain support for forwarded message metadata and attachment waveform payload.
|
||||||
|
- Implemented repository operations for delivery/read acknowledgements, forward, and reactions.
|
||||||
|
- Updated Chat ViewModel/UI with forward flow, reaction toggle, and edit/delete-for-all edge-case guards.
|
||||||
|
- Added automatic delivered/read acknowledgement for latest incoming message in active chat.
|
||||||
|
- Fixed outgoing message detection by resolving current user id from JWT `sub` claim.
|
||||||
|
|
||||||
|
### Step 19 - Sprint P0 / 2) Media UX after send
|
||||||
|
- Added media endpoint mapping for chat attachments (`GET /api/v1/media/chats/{chat_id}/attachments`).
|
||||||
|
- Extended Room message observation to include attachment relations via `MessageLocalModel`.
|
||||||
|
- Synced and persisted message attachments during message refresh/pagination and after media send.
|
||||||
|
- Extended message domain model with attachment list payload.
|
||||||
|
- Added message attachment rendering in Chat UI: inline image preview, minimal image viewer overlay, and basic audio play/pause control.
|
||||||
|
|
||||||
|
### Step 20 - Sprint P0 / 3) Roles/permissions baseline
|
||||||
|
- Extended chat data/domain models with `my_role` and added `observeChatById` stream in Room/repository.
|
||||||
|
- Added `ObserveChatUseCase` to expose per-chat permission state to message screen.
|
||||||
|
- Implemented channel send restrictions in `ChatViewModel`: sending/attach disabled for `member` role in `channel` chats.
|
||||||
|
- Added composer-level restriction hint in Chat UI to explain blocked actions.
|
||||||
|
|
||||||
|
### Step 21 - Sprint P0 / 4) Invite join flow (minimum)
|
||||||
|
- Added chat API contracts for invite actions: `POST /api/v1/chats/{chat_id}/invite-link` and `POST /api/v1/chats/join-by-invite`.
|
||||||
|
- Added domain model/use-cases for invite-link creation and join-by-invite.
|
||||||
|
- Extended chat repository with invite operations and local chat upsert on successful join.
|
||||||
|
- Added minimal Chat List UI flow for join-by-invite token input with loading/error handling and auto-open of joined chat.
|
||||||
|
|
||||||
|
### Step 22 - Sprint P0 / 5) Realtime stability and reconcile
|
||||||
|
- Added heartbeat in WebSocket manager (`ping` interval + `pong` timeout detection) with forced reconnect on stale link.
|
||||||
|
- Improved socket lifecycle hygiene by cancelling heartbeat on close/failure/disconnect paths.
|
||||||
|
- Added `connect` event mapping and centralized reconcile trigger in realtime handler.
|
||||||
|
- On realtime reconnect, chat repository now refreshes `all` and `archived` snapshots to reduce stale state after transient disconnects.
|
||||||
|
|
||||||
|
### Step 23 - Sprint P0 / 6) Auth hardening foundation
|
||||||
|
- Extended auth API/repository contracts with sessions management endpoints:
|
||||||
|
- `GET /api/v1/auth/sessions`
|
||||||
|
- `DELETE /api/v1/auth/sessions/{jti}`
|
||||||
|
- `DELETE /api/v1/auth/sessions`
|
||||||
|
- Added domain model and use-cases for listing/revoking sessions.
|
||||||
|
- Added unit coverage for session DTO -> domain mapping in `NetworkAuthRepositoryTest`.
|
||||||
|
|
||||||
|
### Step 24 - Sprint P0 / 7) Quality pass
|
||||||
|
- Added realtime parser unit coverage for `connect` event mapping.
|
||||||
|
- Extended message DAO tests with attachment relation verification.
|
||||||
|
- Added Android smoke and baseline document (`docs/android-smoke.md`) with test matrix and performance targets.
|
||||||
|
- Updated Android checklist quality section with initial performance baseline completion.
|
||||||
|
|
||||||
|
### Step 25 - UI safe insets fix
|
||||||
|
- Enabled edge-to-edge mode in `MainActivity` via `enableEdgeToEdge()`.
|
||||||
|
- Added safe area insets handling (`WindowInsets.safeDrawing`) for login, chat list, session-check and chat screens.
|
||||||
|
- Added bottom composer protection in chat screen with `navigationBarsPadding()` and `imePadding()`.
|
||||||
|
- Fixed UI overlap with status bar and navigation bar on modern Android devices.
|
||||||
|
|
||||||
|
### Step 26 - Core base / bulk forward foundation
|
||||||
|
- Added message API/data contracts for bulk forward (`POST /api/v1/messages/{message_id}/forward-bulk`).
|
||||||
|
- Extended `MessageRepository` with `forwardMessageBulk(...)`.
|
||||||
|
- Implemented bulk-forward flow in `NetworkMessageRepository` with Room/chat last-message updates.
|
||||||
|
- Added `ForwardMessageBulkUseCase` for future multi-select message actions.
|
||||||
|
- Updated message repository unit test fakes to cover new API surface.
|
||||||
|
|
||||||
|
### Step 27 - Core base / message action state machine
|
||||||
|
- Added reusable `MessageActionState` reducer with explicit selection modes (`NONE`, `SINGLE`, `MULTI`).
|
||||||
|
- Added action-intent contract for message operations (reply/edit/forward/delete/reaction/clear).
|
||||||
|
- Integrated `ChatViewModel` with reducer-backed selection logic while preserving current UI behavior.
|
||||||
|
- Added base ViewModel handlers for entering/toggling multi-select mode (`onEnterMultiSelect`, `onToggleMessageMultiSelection`, `onClearSelection`).
|
||||||
|
- Added unit tests for reducer transitions and available intents (`MessageActionStateTest`).
|
||||||
|
|
||||||
|
### Step 28 - Core base / Android multi-forward execution
|
||||||
|
- Switched chat forward state from single-message payload to `forwardingMessageIds` set.
|
||||||
|
- Extended `ChatViewModel` forward flow: multi-select now forwards multiple source messages in one action.
|
||||||
|
- Wired `ForwardMessageBulkUseCase` for multi-message forwarding (sequential safe execution with error short-circuit).
|
||||||
|
- Updated chat action bar and forward sheet labels for multi-selection count.
|
||||||
|
|
||||||
|
### Step 29 - Core base / multi-select delete execution
|
||||||
|
- Fixed multi-select delete behavior in `ChatViewModel`: `Delete` now applies to all selected messages, not only focused one.
|
||||||
|
- Added explicit guard for `Delete for all` in multi-select mode (single-message only).
|
||||||
|
|
||||||
|
### Step 30 - Core base / reply-forward preview data foundation
|
||||||
|
- Extended message DTO/Room/domain models with optional preview metadata:
|
||||||
|
- `replyPreviewText`, `replyPreviewSenderName`
|
||||||
|
- `forwardedFromDisplayName`
|
||||||
|
- sender profile fields from API payload (`senderDisplayName`, `senderUsername`, `senderAvatarUrl`)
|
||||||
|
- Added Room self-relation in `MessageLocalModel` to resolve reply preview fallback from referenced message.
|
||||||
|
- Updated message mappers and repository/realtime temporary entity creation for new model fields.
|
||||||
|
- Bumped Room schema version to `7`.
|
||||||
|
|
||||||
|
### Step 31 - Chat UI / reply-forward bubble blocks
|
||||||
|
- Added inline forwarded header rendering in message bubbles with display-name fallback.
|
||||||
|
- Added inline reply preview block in message bubbles (author + snippet) based on new preview fields/fallbacks.
|
||||||
|
- Updated Telegram UI batch-2 checklist items for reply-preview and forwarded header.
|
||||||
|
|
||||||
|
### Step 32 - Chat UI / pinned message bar
|
||||||
|
- Added `pinned_message_id` support in chat DTO/local/domain models and DAO selects.
|
||||||
|
- Extended `ChatViewModel` state with pinned message id + resolved pinned message object.
|
||||||
|
- Rendered pinned message bar under chat app bar with hide action.
|
||||||
|
- Updated Telegram UI batch-2 checklist item for pinned message block.
|
||||||
|
|
||||||
|
### Step 33 - Chat UI / top app bar restructuring
|
||||||
|
- Extended chat UI state with resolved chat header fields (`chatTitle`, `chatSubtitle`, `chatAvatarUrl`).
|
||||||
|
- Updated chat top app bar layout to Telegram-like structure: back, avatar, title, status, call action, menu action.
|
||||||
|
- Kept load-more behavior accessible via menu placeholder action button.
|
||||||
|
- Updated Telegram UI batch-2 checklist item for chat top app bar.
|
||||||
|
|
||||||
|
### Step 34 - Chat UI / composer restyling
|
||||||
|
- Reworked chat composer into rounded Telegram-like container with emoji slot, text input, attach button, and send/voice state button.
|
||||||
|
- Preserved send/upload state guards and existing insets handling (`navigationBarsPadding` + `imePadding`).
|
||||||
|
- Updated Telegram UI batch-2 checklist composer-related items.
|
||||||
|
|
||||||
|
### Step 35 - Chat UI / multi-select bars and overlays
|
||||||
|
- Split message selection UX into dedicated top selection bar (count/close/delete/edit/reactions) and bottom action bar (reply/forward).
|
||||||
|
- Enhanced selected bubble visual state with explicit selected marker text.
|
||||||
|
- Updated Telegram UI batch-2 checklist items for multi-select mode.
|
||||||
|
|
||||||
|
### Step 36 - Chat list / advanced states baseline
|
||||||
|
- Added chat-list local type filters (`All`, `People`, `Groups`, `Channels`) with new `ChatListFilter` UI state.
|
||||||
|
- Added archive statistics stream in `ChatListViewModel` and special archive top-row entry in `All` tab.
|
||||||
|
- Extended list preview formatting with media-type markers and retained unread/mention/pinned indicators.
|
||||||
|
- Updated Telegram UI checklists for chat-list advanced states (batch 2 and batch 3).
|
||||||
|
|
||||||
|
### Step 37 - Chat UI / wallpaper-aware readability
|
||||||
|
- Added gradient wallpaper-like chat background layer in `ChatScreen`.
|
||||||
|
- Kept pinned/composer/action surfaces on semi-transparent containers to preserve readability over wallpaper.
|
||||||
|
- Updated Telegram UI checklist items for wallpaper and overlay readability.
|
||||||
|
|
||||||
|
### Step 38 - Quality/docs / mapper fallback coverage
|
||||||
|
- Added `MessageMappersTest` to verify reply preview fallback resolution from Room self-relation (`reply_to_message_id`).
|
||||||
|
- Updated Android master checklist for completed chat list tabs/filters coverage.
|
||||||
|
|
||||||
|
### Step 39 - Android scope / remove calls UI
|
||||||
|
- Removed chat top-bar `Call` action from Android `ChatScreen`.
|
||||||
|
- Updated Android UI checklist wording to reflect chat header without calls support.
|
||||||
|
|
||||||
|
### Step 40 - Invite deep link flow (app links)
|
||||||
|
- Added Android App Links intent filter for `https://chat.daemonlord.ru/join...`.
|
||||||
|
- Added invite token extraction from incoming intents (`query token` and `/join/{token}` path formats).
|
||||||
|
- Wired deep link token into `MessengerNavHost -> ChatListRoute -> ChatListViewModel` auto-join flow.
|
||||||
|
- Removed manual `Invite token` input row from chat list screen.
|
||||||
|
|
||||||
|
### Step 41 - Chat UI / long-press action menu
|
||||||
|
- Added long-press message action card in `ChatScreen` with quick reactions.
|
||||||
|
- Added context actions from long-press: reply, edit, forward, delete, select, close.
|
||||||
|
- Added placeholder disabled pin action in the menu to keep action set consistent with Telegram-like flow.
|
||||||
|
- Updated Telegram UI batch-2 checklist items for long-press reactions and context menu.
|
||||||
|
|
||||||
|
### Step 42 - Chat list / row and FAB parity pass
|
||||||
|
- Updated chat list rows with avatar rendering, trailing message time, and richer right-side metadata layout.
|
||||||
|
- Kept unread/mention/pinned/muted indicators while aligning row structure closer to Telegram list pattern.
|
||||||
|
- Added floating compose FAB placeholder at bottom-right in chat list screen.
|
||||||
|
- Updated Telegram UI batch-2 checklist chat-list parity items.
|
||||||
|
|
||||||
|
### Step 43 - Chat list / floating bottom navigation shell
|
||||||
|
- Added floating rounded bottom navigation container on chat list screen.
|
||||||
|
- Added active tab visual state (Chats selected) with pill styling.
|
||||||
|
- Updated Telegram UI checklists for bottom-nav shell parity (batch 1 and batch 2).
|
||||||
|
|
||||||
|
### Step 44 - Chat UI / bubble density pass
|
||||||
|
- Updated message bubble shapes for incoming/outgoing messages to denser rounded Telegram-like contours.
|
||||||
|
- Kept bottom-right time + delivery state rendering in bubble footer after time formatting update.
|
||||||
|
- Updated Telegram UI batch-2 checklist item for message bubble parity.
|
||||||
|
|
||||||
|
### Step 45 - Chat UI / media bubble improvements
|
||||||
|
- Added richer video attachment card rendering in message bubbles.
|
||||||
|
- Added file-list style attachment rows (icon + filename + type/size metadata).
|
||||||
|
- Upgraded non-voice audio attachment player with play/pause, progress bar, and current/total duration labels.
|
||||||
|
- Updated Telegram UI batch-2 checklist media-bubble items.
|
||||||
|
|
||||||
|
### Step 46 - Media viewer / header and gallery navigation
|
||||||
|
- Upgraded chat image viewer to use global image gallery state (`index / total`) instead of a single URL.
|
||||||
|
- Added fullscreen viewer header with close, index, share placeholder, and delete placeholder actions.
|
||||||
|
- Added image navigation controls (`Prev`/`Next`) for gallery traversal.
|
||||||
|
- Updated Telegram UI batch-2 checklist for fullscreen media header support.
|
||||||
|
|
||||||
|
### Step 47 - Notifications foundation (FCM + channels + deep links)
|
||||||
|
- Added Firebase Messaging dependency and Android manifest wiring for `POST_NOTIFICATIONS`.
|
||||||
|
- Added notification channels (`messages`, `mentions`, `system`) with startup initialization in `MessengerApplication`.
|
||||||
|
- Added push service (`MessengerFirebaseMessagingService`) + payload parser + notification dispatcher.
|
||||||
|
- Added notification tap deep-link handling to open target chat from `MainActivity` via nav host.
|
||||||
|
- Added runtime notification permission request flow (Android 13+) in `MessengerNavHost`.
|
||||||
|
- Added parser unit test (`PushPayloadParserTest`).
|
||||||
|
|
||||||
|
### Step 48 - Foreground local notifications from realtime
|
||||||
|
- Added `ActiveChatTracker` to suppress local notifications for currently opened chat.
|
||||||
|
- Wired realtime receive-message handling to trigger local notification via `NotificationDispatcher` when chat is not active.
|
||||||
|
- Added chat title lookup helper in `ChatDao` for notification titles.
|
||||||
|
- Added explicit realtime stop in `ChatViewModel.onCleared()` to avoid stale collectors.
|
||||||
|
|
||||||
|
### Step 49 - Mention override for muted chats
|
||||||
|
- Extended realtime receive-message model/parsing with `isMention` flag support.
|
||||||
|
- Added muted-chat guard in realtime notification flow: muted chats stay silent unless message is a mention.
|
||||||
|
- Routed mention notifications to mentions channel/priority via `NotificationDispatcher`.
|
||||||
|
- Added parser unit test for mention-flag mapping.
|
||||||
|
|
||||||
|
### Step 50 - Notification settings storage (DataStore)
|
||||||
|
- Added domain notification settings models/repository contracts (global + per-chat override).
|
||||||
|
- Added `DataStoreNotificationSettingsRepository` with persistence for global flags and per-chat override mode.
|
||||||
|
- Added `ShouldShowMessageNotificationUseCase` and wired realtime notifications through it.
|
||||||
|
- Added unit tests for DataStore notification settings repository and notification visibility use case.
|
||||||
|
|
||||||
|
### Step 51 - Logout with full local cleanup
|
||||||
|
- Added `LogoutUseCase` with centralized sign-out flow: disconnect realtime, clear active chat, clear auth session, and clear local cached data.
|
||||||
|
- Added `SessionCleanupRepository` + `DefaultSessionCleanupRepository` to wipe Room tables and clear per-chat notification overrides.
|
||||||
|
- Added logout action in chat list UI and wired it to `AuthViewModel`, with automatic navigation back to login via auth state.
|
||||||
|
- Added unit tests for logout use case orchestration and notification override cleanup.
|
||||||
|
|
||||||
|
### Step 52 - Settings/Profile shell and logout relocation
|
||||||
|
- Added dedicated `Settings` and `Profile` routes/screens with mobile-safe insets and placeholder content.
|
||||||
|
- Removed direct logout action from chat list and moved logout action to `Settings`.
|
||||||
|
- Wired bottom navigation pills in chats to open `Settings` and `Profile`.
|
||||||
|
|
||||||
|
### Step 53 - Secure token storage (Keystore-backed)
|
||||||
|
- Added `EncryptedPrefsTokenRepository` backed by `EncryptedSharedPreferences` and Android `MasterKey` (Keystore).
|
||||||
|
- Switched DI token binding from DataStore token repository to encrypted shared preferences repository.
|
||||||
|
- Kept DataStore for non-token app settings and renamed preferences file to `messenger_preferences.preferences_pb`.
|
||||||
|
|
||||||
|
### Step 54 - Message interactions: tap menu vs long-press select
|
||||||
|
- Updated chat message gesture behavior to match Telegram pattern:
|
||||||
|
- tap opens contextual message menu with reactions/actions,
|
||||||
|
- long-press enters multi-select mode directly.
|
||||||
|
- Hid single-selection action bars while contextual menu is visible to avoid mixed UX states.
|
||||||
|
- Improved multi-select visual affordance with per-message selection indicator circles.
|
||||||
|
|
||||||
|
### Step 55 - Chat multi-select action cleanup
|
||||||
|
- Removed duplicate forward action in multi-select mode (`Forward selected`), leaving a single clear forward action button.
|
||||||
|
|
||||||
|
### Step 56 - Unified API error handling
|
||||||
|
- Added shared API error mapper (`ApiErrorMapper`) with mode-aware mapping (`DEFAULT`, `LOGIN`).
|
||||||
|
- Switched auth/chat/message/media repositories to a single `Throwable -> AppError` mapping source.
|
||||||
|
- Kept login-specific invalid-credentials mapping while standardizing unauthorized/server/network handling for other API calls.
|
||||||
|
|
||||||
|
### Step 57 - Offline-first message history reading
|
||||||
|
- Added paged local history reading path by introducing configurable message observe limit (`observeMessages(chatId, limit)`).
|
||||||
|
- Updated chat screen loading strategy to expand local Room-backed history first when loading older messages.
|
||||||
|
- Added network-failure fallback in message sync/load-more: if network is unavailable but local cache exists, chat remains readable without blocking error.
|
||||||
|
|
||||||
|
### Step 58 - Keep authenticated session when offline at app start
|
||||||
|
- Updated auth restore flow in `AuthViewModel`: network errors during session restore no longer force logout when local tokens exist.
|
||||||
|
- App now opens authenticated flow in offline mode instead of redirecting to login.
|
||||||
|
|
||||||
|
### Step 59 - Deferred message action queue (send/edit/delete)
|
||||||
|
- Added Room-backed pending action queue (`pending_message_actions`) for message operations that fail due to network issues.
|
||||||
|
- Implemented enqueue + optimistic behavior for `sendText`, `editMessage`, and `deleteMessage` on network failures.
|
||||||
|
- Added automatic pending-action flush on chat sync/load-more and before new message operations.
|
||||||
|
- Kept non-network server failures as immediate errors (no queueing), while allowing offline continuation.
|
||||||
|
|
||||||
|
### Step 60 - Media cache foundation (Coil + Exo cache)
|
||||||
|
- Added global Coil image loader cache policy in `MessengerApplication` (memory + disk cache).
|
||||||
|
- Added Media3 `SimpleCache` singleton module for media stream/file caching foundation.
|
||||||
|
- Added Media3/Coil core dependencies and configured cache sizes for mobile usage.
|
||||||
|
|
||||||
|
### Step 61 - Compose UI tests baseline
|
||||||
|
- Added instrumented Compose UI tests for login and chat list states.
|
||||||
|
- Added Android test dependencies for Compose test runner (`ui-test-junit4`) and test infra.
|
||||||
|
- Covered key visual states: auth error rendering, chat list loading state, and empty state.
|
||||||
|
|
||||||
|
### Step 62 - Android CI pipeline
|
||||||
|
- Added dedicated Android CI workflow for `main` branch and PRs.
|
||||||
|
- CI now runs Android build, unit tests, lint, and androidTest assemble.
|
||||||
|
- Added optional detekt execution step (auto-skipped when detekt task is not configured).
|
||||||
|
|
||||||
|
### Step 63 - Integration tests for auth/chat/realtime
|
||||||
|
- Kept repository-level integration coverage for auth/chat data flows (MockWebServer + in-memory storage).
|
||||||
|
- Added `RealtimePipelineIntegrationTest` to validate realtime event handling pipeline (`receive_message` -> Room state update).
|
||||||
|
- Consolidated quality checklist integration test coverage for auth/chat/realtime.
|
||||||
|
|
||||||
|
### Step 64 - Android release workflow
|
||||||
|
- Added dedicated release workflow (`.github/workflows/android-release.yml`) for `main` branch pushes.
|
||||||
|
- Adapted version extraction for Kotlin DSL (`android/app/build.gradle.kts`) and guarded release by existing git tag.
|
||||||
|
- Wired release build, git tag push, and Gitea release publication with APK artifact upload.
|
||||||
|
|
||||||
|
### Step 65 - Account and media parity foundation (checklist 1-15)
|
||||||
|
- Introduced `:core:common` module and moved base `AppError`/`AppResult` contracts out of `:app`.
|
||||||
|
- Added structured app logging (`Timber`) and crash reporting baseline (`Firebase Crashlytics`) with app startup wiring.
|
||||||
|
- Added API version header interceptor + build-time feature flags and DI provider.
|
||||||
|
- Added account network layer for auth/account management:
|
||||||
|
- verify email, password reset request/reset,
|
||||||
|
- sessions list + revoke one/all,
|
||||||
|
- 2FA setup/enable/disable + recovery status/regenerate,
|
||||||
|
- profile/privacy update and blocked users management.
|
||||||
|
- Added deep-link aware auth routes for `/verify-email` and `/reset-password`.
|
||||||
|
- Reworked Settings/Profile screens from placeholders to editable account management screens.
|
||||||
|
- Added avatar upload with center square crop (`1:1`) before upload.
|
||||||
|
- Upgraded message attachment rendering with in-bubble multi-image gallery and unified attachment context actions (open/copy/close).
|
||||||
|
|
||||||
|
### Step 66 - Voice recording controls + global audio focus
|
||||||
|
- Added microphone permission (`RECORD_AUDIO`) and in-chat voice recording flow based on press-and-hold gesture.
|
||||||
|
- Implemented Telegram-like gesture controls for voice button:
|
||||||
|
- hold to record,
|
||||||
|
- slide up to lock recording,
|
||||||
|
- slide left to cancel recording.
|
||||||
|
- Added minimum voice length validation (`>= 1s`) before sending.
|
||||||
|
- Integrated voice message sending via existing media upload path (`audio/mp4` attachment).
|
||||||
|
- Added process-wide audio focus coordinator to enforce single active audio source:
|
||||||
|
- attachment player pauses when another source starts,
|
||||||
|
- recording requests focus and stops competing playback.
|
||||||
|
|
||||||
|
### Step 67 - Group/channel management baseline in Chat List
|
||||||
|
- Extended chat API/repository layer with management operations:
|
||||||
|
- create group/channel,
|
||||||
|
- discover + join/leave chats,
|
||||||
|
- invite link create/regenerate,
|
||||||
|
- members/bans listing and admin actions (add/remove/ban/unban/promote/demote).
|
||||||
|
- Added domain models for discover/member/ban items and repository mappings.
|
||||||
|
- Added in-app management panel in `ChatListScreen` (FAB toggle) for:
|
||||||
|
- creating group/channel,
|
||||||
|
- joining discovered chats,
|
||||||
|
- loading chat members/bans by chat id,
|
||||||
|
- executing admin/member visibility actions from one place.
|
||||||
|
|
||||||
|
### Step 68 - Search, inline jump, theme toggle, accessibility pass
|
||||||
|
- Added global search baseline in chat list:
|
||||||
|
- users search (`/users/search`),
|
||||||
|
- messages search (`/messages/search`),
|
||||||
|
- chat discovery integration (`/chats/discover`).
|
||||||
|
- Added inline search in chat screen with jump navigation (prev/next) and automatic scroll to matched message.
|
||||||
|
- Added highlighted message state for active inline search result.
|
||||||
|
- Added theme switching controls in settings (Light/Dark/System) via `AppCompatDelegate`.
|
||||||
|
- Added accessibility refinements for key surfaces and controls:
|
||||||
|
- explicit content descriptions for avatars and tab-like controls,
|
||||||
|
- voice record button semantic label for TalkBack.
|
||||||
|
|
||||||
|
### Step 69 - Bugfix pass: voice recording, theme apply, profile avatar UX
|
||||||
|
- Fixed voice recording start on Android by switching `VoiceRecorder` to compatible `MediaRecorder()` initialization.
|
||||||
|
- Fixed microphone permission flow: record action now triggers runtime permission request reliably and auto-starts recording after grant.
|
||||||
|
- Fixed theme switching application by introducing app-level `MessengerTheme` and switching app manifest base theme to DayNight.
|
||||||
|
- Fixed profile screen usability after avatar upload:
|
||||||
|
- enabled vertical scrolling with safe insets/navigation padding,
|
||||||
|
- constrained avatar preview to a centered circular area instead of full-screen takeover.
|
||||||
|
|
||||||
|
### Step 70 - Chat interaction consistency: gestures + sheets/dialogs
|
||||||
|
- Reworked single-message actions to open in `ModalBottomSheet` (tap action menu) instead of inline action bars.
|
||||||
|
- Reworked forward target chooser to `ModalBottomSheet` for consistent overlay behavior across chat actions.
|
||||||
|
- Added destructive action confirmation via `AlertDialog` before delete actions.
|
||||||
|
- Reduced gesture conflicts by removing attachment-level long-press handlers that collided with message selection gestures.
|
||||||
|
- Improved voice hold gesture reliability by handling consumed pointer down events (`requireUnconsumed = false`).
|
||||||
|
|
||||||
|
### Step 71 - Voice playback waveform/speed + circle video playback
|
||||||
|
- Added voice-focused audio playback mode with waveform rendering in message bubbles.
|
||||||
|
- Added playback speed switch for voice messages (`1.0x -> 1.5x -> 2.0x`).
|
||||||
|
- Added view-only circle video renderer for `video_note` messages with looped playback.
|
||||||
|
- Kept regular audio/video attachment rendering for non-voice/non-circle media unchanged.
|
||||||
|
|
||||||
|
### Step 72 - Adaptive layout baseline (phone/tablet) + voice release fix
|
||||||
|
- Added tablet-aware max-width layout constraints across major screens (login, verify/reset auth, chats list, chat, profile, settings).
|
||||||
|
- Kept phone layout unchanged while centering content and limiting line width on larger displays.
|
||||||
|
- Fixed voice hold-to-send gesture reliability by removing pointer-input restarts during active recording, so release consistently triggers send path.
|
||||||
|
|
||||||
|
### Step 73 - Voice message send/playback bugfixes
|
||||||
|
- Fixed voice media type mapping in message repository: recorded files with `voice_*.m4a` are now sent as message type `voice` (not generic `audio`).
|
||||||
|
- Fixed audio replay behavior: when playback reaches the end, next play restarts from `0:00`.
|
||||||
|
- Improved duration display in audio/voice player by adding metadata fallback when `MediaPlayer` duration is not immediately available.
|
||||||
|
|
||||||
|
### Step 74 - UI references consolidation (Batch 4)
|
||||||
|
- Added full Telegram reference mapping checklist (`docs/android-ui-batch-4-checklist.md`) with screenshot-by-screenshot description.
|
||||||
|
- Added explicit icon policy: no emoji icons in production UI components, Material Icons/vector icons only.
|
||||||
|
- Updated UI checklist index with Batch 4 entry.
|
||||||
|
|
||||||
|
### Step 75 - Material Icons migration (Batch 1 start)
|
||||||
|
- Replaced symbol/emoji-based UI controls in chat surfaces with Material Icons:
|
||||||
|
- chat header/menu/search controls (`more`, `up/down`),
|
||||||
|
- image viewer actions (`close`, `forward`, `delete`),
|
||||||
|
- multi-select markers (`radio checked/unchecked`, `selected` check),
|
||||||
|
- attachment/media markers (`movie`, `attach file`).
|
||||||
|
- Replaced chat list management FAB glyph toggle (`+`/`×`) with Material `Add`/`Close` icons.
|
||||||
|
- Added `androidx.compose.material:material-icons-extended` dependency for consistent icon usage.
|
||||||
|
|
||||||
|
### Step 76 - Shared main tabs shell with scroll-aware visibility
|
||||||
|
- Moved `Chats / Contacts / Settings / Profile` bottom panel to a shared navigation shell (`AppNavGraph`) so it behaves as global page navigation.
|
||||||
|
- Added dedicated `Contacts` page route and wired it into main tabs.
|
||||||
|
- Removed local duplicated bottom panel from chat list screen.
|
||||||
|
- Implemented scroll-direction behavior for all 4 main pages:
|
||||||
|
- hide panel on downward scroll,
|
||||||
|
- show panel on upward scroll / at top.
|
||||||
|
|
||||||
|
### Step 77 - Main tabs bar UX/layout fix
|
||||||
|
- Replaced custom pill-row main bar with compact `NavigationBar` inside rounded container for stable 4-tab layout on small screens.
|
||||||
|
- Added bottom content paddings for `Chats/Contacts/Settings/Profile` pages so content is not obscured by the floating main bar.
|
||||||
|
- Raised chats management FAB offset to avoid overlap with the global bottom bar.
|
||||||
|
|
||||||
|
### Step 78 - Telegram-like bottom tabs visual tuning
|
||||||
|
- Tuned shared main bar visual style to better match Telegram references:
|
||||||
|
- rounded floating container with subtle elevation,
|
||||||
|
- unified selected/unselected item colors,
|
||||||
|
- stable 4-item navigation with icons + labels.
|
||||||
|
- Kept scroll-hide/show behavior and page-level navigation unchanged.
|
||||||
|
|
||||||
|
### Step 79 - Main pages app bars + safe-area pass
|
||||||
|
- Added top app bars for all 4 main pages (`Chats`, `Contacts`, `Settings`, `Profile`) to make them feel like proper standalone sections.
|
||||||
|
- Moved chats management toggle action into chats app bar.
|
||||||
|
- Kept safe-area handling and bottom insets consistent with shared floating tabs bar to avoid overlap.
|
||||||
|
|
||||||
|
### Step 80 - Top bar offset consistency fix
|
||||||
|
- Unified top bar alignment across `Chats`, `Contacts`, `Settings`, and `Profile`:
|
||||||
|
- removed extra outer paddings that shifted headers down/right on some pages,
|
||||||
|
- separated content padding from top app bar container.
|
||||||
|
- Result: consistent title baseline and horizontal alignment between main pages.
|
||||||
|
|
||||||
|
### Step 81 - Chats bottom gap fix when tabs bar hidden
|
||||||
|
- Fixed blank gap at the bottom of chats list when global tabs bar auto-hides on scroll.
|
||||||
|
- Chats screen bottom padding is now dynamic and applied only while tabs bar is visible.
|
||||||
|
|
||||||
|
### Step 82 - Chats list header closer to Telegram reference
|
||||||
|
- Removed `Archived` top tab from chats list UI.
|
||||||
|
- Added search action in top app bar and unified single search field with leading search icon.
|
||||||
|
- Kept archive as dedicated row inside chats list; opening archive now happens from that row and back navigation appears in app bar while archive is active.
|
||||||
|
|
||||||
|
### Step 83 - Chats header realtime connection status
|
||||||
|
- Added realtime connection state stream (`Disconnected/Connecting/Reconnecting/Connected`) to `RealtimeManager`.
|
||||||
|
- Wired websocket lifecycle into that state in `WsRealtimeManager`.
|
||||||
|
- Bound chats top bar title to realtime state:
|
||||||
|
- shows `Connecting...` while reconnect/initial connect is in progress,
|
||||||
|
- shows regular page title once connected.
|
||||||
|
|
||||||
|
### Step 84 - Chats list preview icon policy cleanup
|
||||||
|
- Updated chat last-message preview text to remove emoji prefixes.
|
||||||
|
- Switched media-type preview prefixes to plain text labels (`Photo`, `Video`, `Voice`, etc.) to match Material-icons-only UI policy.
|
||||||
|
|
||||||
|
### Step 85 - Unread counter fix for active/read chats
|
||||||
|
- Added `ChatDao.markChatRead(chatId)` to clear `unread_count` and `unread_mentions_count` in Room.
|
||||||
|
- Applied optimistic local unread reset on `markMessageRead(...)` in message repository.
|
||||||
|
- Fixed realtime unread logic: incoming messages in currently active chat no longer increment unread badge.
|
||||||
|
|
||||||
|
### Step 86 - Chats list visual pass toward Telegram reference
|
||||||
|
- Updated chats list row density: tighter vertical rhythm, larger avatar, stronger title hierarchy, cleaner secondary text.
|
||||||
|
- Restyled archive as dedicated list row with leading archive icon avatar, subtitle, and unread badge.
|
||||||
|
- Kept search in top app bar action and changed search field default to collapsed (opens via search icon).
|
||||||
|
- Returned message-type emoji markers in chat previews:
|
||||||
|
- `🖼` photo, `🎤` voice, `🎵` audio, `🎥` video, `⭕` circle video, `🔗` links.
|
||||||
|
|
||||||
|
### Step 87 - Chats list micro-typography and time formatting
|
||||||
|
- Refined chat row typography hierarchy to be closer to Telegram density:
|
||||||
|
- title/body/presence font scale aligned and single-line ellipsis for long values.
|
||||||
|
- Tightened unread/mention badge sizing and spacing for compact right-side metadata.
|
||||||
|
- Updated trailing time formatter:
|
||||||
|
- today: `HH:mm`,
|
||||||
|
- this week: localized short weekday,
|
||||||
|
- older: `dd.MM.yy`.
|
||||||
|
|
||||||
|
### Step 88 - Chats list interaction states (menu/select/search)
|
||||||
|
- Added default overflow menu (`⋮`) state in chats header with Telegram-like quick actions UI.
|
||||||
|
- Added long-press multi-select mode for chat rows with:
|
||||||
|
- top selection bar (`count`, action icons),
|
||||||
|
- dedicated overflow menu for selected chats.
|
||||||
|
- Added dedicated search-mode state in chats screen:
|
||||||
|
- search field + section chips (`Chats/Channels/Apps/Posts`),
|
||||||
|
- horizontal recent avatars strip,
|
||||||
|
- list filtered by active query.
|
||||||
|
|
||||||
|
### Step 89 - Chats actions wiring + duplicate menu fix
|
||||||
|
- Removed duplicated overflow action in chats top bar (single `⋮` remains in default mode).
|
||||||
|
- Wired selection actions to behavior:
|
||||||
|
- delete selected -> leave selected chats,
|
||||||
|
- archive selected -> switch to archived section,
|
||||||
|
- non-implemented bulk actions now show explicit user feedback.
|
||||||
|
- Wired default menu actions:
|
||||||
|
- create group/channel -> open management panel,
|
||||||
|
- saved -> open saved chat if present,
|
||||||
|
- unsupported items show clear feedback instead of silent no-op.
|
||||||
|
|
||||||
|
### Step 90 - Fullscreen chats search redesign (Telegram-like)
|
||||||
|
- Reworked chats search mode into a fullscreen flow:
|
||||||
|
- top rounded search field with inline clear button,
|
||||||
|
- horizontal category chips (`Chats`, `Channels`, `Apps`, `Posts`),
|
||||||
|
- dedicated recent avatars row for the active category.
|
||||||
|
- Added search-mode content states:
|
||||||
|
- empty query -> `Recent` list block (history-style chat rows),
|
||||||
|
- non-empty query -> local matches + `Global search` and `Messages` sections.
|
||||||
|
- Kept search action in chats top bar; while search mode is active, app bar switches to back-navigation + empty title (content drives the page).
|
||||||
|
|
||||||
|
### Step 91 - Search history/recent persistence + clear action
|
||||||
|
- Added `ChatSearchRepository` abstraction and `DataStoreChatSearchRepository` implementation.
|
||||||
|
- Persisted chats search metadata in `DataStore`:
|
||||||
|
- recent opened chats list,
|
||||||
|
- search history list (bounded).
|
||||||
|
- Wired chats fullscreen search to persisted data:
|
||||||
|
- green recent avatars strip now reads saved recent chats,
|
||||||
|
- red `Recent` list now reads saved history with fallback.
|
||||||
|
- Connected `Очистить` action to real history cleanup in `DataStore`.
|
||||||
|
- On opening a chat from search results/messages/history, the chat is now stored in recent/history.
|
||||||
|
|
||||||
|
### Step 92 - Search filter leak fix on exit
|
||||||
|
- Fixed chats search state leak: leaving fullscreen search now resets local/global query.
|
||||||
|
- Main chats list no longer stays filtered by previous search input after returning from search mode.
|
||||||
|
|
||||||
|
### Step 93 - Fullscreen search UX polish
|
||||||
|
- Added system back-handler for search mode with safe query reset.
|
||||||
|
- Improved fullscreen search result sections:
|
||||||
|
- `Показать больше / Свернуть` toggle for global users,
|
||||||
|
- `Показать больше / Свернуть` toggle for message results.
|
||||||
|
- Added explicit empty-state text when local/global/message search sections all have no results.
|
||||||
|
|
||||||
|
### Step 94 - Pinned-only drag markers in selection mode
|
||||||
|
- Updated chats multi-select row UI: drag markers are now shown only for pinned chats.
|
||||||
|
- Non-pinned chats no longer render reorder marker in selection mode.
|
||||||
|
|
||||||
|
### Step 95 - Selection badge on avatar (Telegram-like)
|
||||||
|
- Added explicit selection indicator directly on chat avatars in multi-select mode:
|
||||||
|
- selected chat -> colored circle with check icon,
|
||||||
|
- unselected chat -> empty outlined circle.
|
||||||
|
- This matches the reference behavior and makes selected rows easier to scan.
|
||||||
|
|
||||||
|
### Step 96 - Selection menu labels and behavior polish
|
||||||
|
- Updated multi-select top actions/menu to be closer to Telegram reference in wording.
|
||||||
|
- Added dynamic `Закрепить/Открепить` label in selection overflow based on selected chats pinned state.
|
||||||
|
- Kept non-supported actions explicit with user feedback (Toast), avoiding silent no-op behavior.
|
||||||
|
|
||||||
|
### Step 97 - Chats popup/select actions wired to backend API
|
||||||
|
- Extended Android chat data layer with missing parity endpoints:
|
||||||
|
- `archive/unarchive`
|
||||||
|
- `pin-chat/unpin-chat`
|
||||||
|
- `clear`
|
||||||
|
- `delete (for_all=false)`
|
||||||
|
- `chat notifications get/update`
|
||||||
|
- Added repository methods and `ViewModel` actions for those operations.
|
||||||
|
- Replaced chats multi-select UI stubs with real API calls:
|
||||||
|
- mute/unmute selected chats,
|
||||||
|
- archive/unarchive selected chats,
|
||||||
|
- pin/unpin selected chats,
|
||||||
|
- clear selected chats,
|
||||||
|
- delete selected chats for current user.
|
||||||
|
|
||||||
|
### Step 98 - Realtime sync fix for pin/archive updates
|
||||||
|
- Improved `chat_updated` handling in realtime flow:
|
||||||
|
- now refreshes both active and archived chats lists to sync user-scoped flags (`pinned`, `archived`) immediately.
|
||||||
|
- Added parser fallback for realtime chat events to support payloads with either `chat_id` or `id`.
|
||||||
|
|
||||||
|
### Step 99 - Saved chat API parity
|
||||||
|
- Added Android support for `GET /api/v1/chats/saved`.
|
||||||
|
- Wired chats overflow `Saved` action to real backend request (instead of local title heuristic).
|
||||||
|
- Saved chat is now upserted into local Room cache and opened via normal navigation flow.
|
||||||
|
|
||||||
|
### Step 100 - Android image compression before upload
|
||||||
|
- Added pre-upload image compression in Android media pipeline (`NetworkMediaRepository`).
|
||||||
|
- For non-GIF images:
|
||||||
|
- decode + resize with max side `1920`,
|
||||||
|
- re-encode as `image/jpeg` with quality `82`,
|
||||||
|
- keep original bytes if compression does not reduce payload size.
|
||||||
|
- Upload request and attachment metadata now use actual prepared payload (`fileName`, `fileType`, `fileSize`), matching web behavior.
|
||||||
|
|
||||||
|
### Step 101 - Chat title/profile API parity
|
||||||
|
- Added Android API integration for:
|
||||||
|
- `PATCH /api/v1/chats/{chat_id}/title`
|
||||||
|
- `PATCH /api/v1/chats/{chat_id}/profile`
|
||||||
|
- Extended `ChatRepository`/`NetworkChatRepository` with `updateChatTitle(...)` and `updateChatProfile(...)`.
|
||||||
|
- Wired these actions into the existing Chat Management panel:
|
||||||
|
- edit selected chat title,
|
||||||
|
- edit selected chat profile fields (title/description).
|
||||||
|
|
||||||
|
### Step 102 - Global search + message thread parity
|
||||||
|
- Added Android data-layer integration for unified backend global search:
|
||||||
|
- `GET /api/v1/search`
|
||||||
|
- new `SearchRepository` + `SearchApiService` returning `users/chats/messages`.
|
||||||
|
- Switched chats fullscreen search flow to use unified backend search instead of composed per-domain calls.
|
||||||
|
- Extended message data layer with:
|
||||||
|
- `GET /api/v1/messages/{message_id}/thread`
|
||||||
|
- `MessageRepository.getMessageThread(...)` for thread/replies usage in upcoming UI steps.
|
||||||
|
|
||||||
|
### Step 103 - Contacts API parity + real Contacts screen
|
||||||
|
- Added Android integration for contacts endpoints:
|
||||||
|
- `GET /api/v1/users/contacts`
|
||||||
|
- `POST /api/v1/users/{user_id}/contacts`
|
||||||
|
- `POST /api/v1/users/contacts/by-email`
|
||||||
|
- `DELETE /api/v1/users/{user_id}/contacts`
|
||||||
|
- Extended `AccountRepository` + `NetworkAccountRepository` with contacts methods.
|
||||||
|
- Replaced placeholder Contacts screen with real stateful flow (`ContactsViewModel`):
|
||||||
|
- load contacts from backend,
|
||||||
|
- user search + add contact,
|
||||||
|
- add contact by email,
|
||||||
|
- remove contact,
|
||||||
|
- loading/refresh/error/info states.
|
||||||
|
|
||||||
|
### Step 104 - Push token sync (Android + backend)
|
||||||
|
- Added backend push token lifecycle API and storage:
|
||||||
|
- `POST /api/v1/notifications/push-token`
|
||||||
|
- `DELETE /api/v1/notifications/push-token`
|
||||||
|
- new table `push_device_tokens` (+ Alembic migration `0027_push_device_tokens`).
|
||||||
|
- Added Android push token sync manager:
|
||||||
|
- registers FCM token on app start and after auth refresh/login,
|
||||||
|
- updates backend token on `FirebaseMessagingService.onNewToken`,
|
||||||
|
- unregisters token on logout.
|
||||||
|
- Added backend FCM delivery in Celery notification tasks:
|
||||||
|
- sends to registered user device tokens,
|
||||||
|
- auto-removes invalid/unregistered tokens,
|
||||||
|
- safe fallback logs when Firebase is not configured.
|
||||||
|
|
||||||
|
### Step 105 - Web Firebase push registration
|
||||||
|
- Added web-side Firebase Messaging bootstrap (env-driven, no hardcoded secrets):
|
||||||
|
- fetch web push token and register in backend via `/notifications/push-token`,
|
||||||
|
- unregister token on logout,
|
||||||
|
- handle foreground push payload via existing notification service worker.
|
||||||
|
- Added required env keys to `web/.env.example` and backend Firebase env keys to root `.env.example`.
|
||||||
|
|
||||||
|
### Step 106 - Unread counter stabilization in Chat screen
|
||||||
|
- Fixed read acknowledgement strategy in `ChatViewModel`:
|
||||||
|
- read status is now acknowledged by the latest visible message id in chat (not only latest incoming),
|
||||||
|
- delivery status still uses latest incoming message.
|
||||||
|
- This removes cases where unread badge reappears after chat list refresh because the previous read ack used an outdated incoming id.
|
||||||
|
|
||||||
|
### Step 107 - Read-on-visible + cross-device unread sync
|
||||||
|
- Implemented read acknowledgement from actual visible messages in `ChatScreen`:
|
||||||
|
- tracks visible `LazyColumn` rows and sends read up to max visible incoming message id.
|
||||||
|
- unread now drops as messages appear on screen while scrolling.
|
||||||
|
- Improved cross-device sync (web <-> android):
|
||||||
|
- `message_read` realtime event now parses `user_id` and `last_read_message_id`.
|
||||||
|
- on `message_read`, Android refreshes chat snapshot from backend to keep unread counters aligned across devices.
|
||||||
|
|
||||||
|
### Step 108 - Strict read boundary by visible incoming only
|
||||||
|
- Removed fallback read-pointer advancement in `ChatViewModel.acknowledgeLatestMessages(...)` that previously moved `lastReadMessageId` by latest loaded message id.
|
||||||
|
- Read pointer is now advanced only via `onVisibleIncomingMessageId(...)` from visible incoming rows in `ChatScreen`.
|
||||||
|
- This prevents read acknowledgements from overshooting beyond what user actually saw during refresh/recompose scenarios.
|
||||||
|
|
||||||
|
### Step 109 - Telegram-like Settings/Profile visual refresh
|
||||||
|
- Redesigned `SettingsScreen` to Telegram-inspired dark card layout:
|
||||||
|
- profile header card with avatar/name/email/username,
|
||||||
|
- grouped settings rows with material icons,
|
||||||
|
- appearance controls (Light/Dark/System),
|
||||||
|
- quick security/help sections and preserved logout/back actions.
|
||||||
|
- Redesigned `ProfileScreen` to Telegram-inspired structure:
|
||||||
|
- gradient hero header with centered avatar, status, and action buttons,
|
||||||
|
- primary profile info card,
|
||||||
|
- tab-like section (`Posts/Archived/Gifts`) with placeholder content,
|
||||||
|
- inline edit card (name/username/bio/avatar URL) with existing save/upload behavior preserved.
|
||||||
|
|
||||||
|
### Step 110 - Multi-account foundation (switch active account)
|
||||||
|
- Extended `TokenRepository` to support account list and active-account switching:
|
||||||
|
- observe/list stored accounts,
|
||||||
|
- get active account id,
|
||||||
|
- switch/remove account,
|
||||||
|
- clear all tokens.
|
||||||
|
- Reworked `EncryptedPrefsTokenRepository` storage model:
|
||||||
|
- stores tokens per `userId` and account metadata list in encrypted prefs,
|
||||||
|
- migrates legacy single-account keys on first run,
|
||||||
|
- preserves active account pointer.
|
||||||
|
- `NetworkAuthRepository` now upserts account metadata after auth/me calls.
|
||||||
|
- Added `Settings` UI account section:
|
||||||
|
- shows saved accounts,
|
||||||
|
- allows switch/remove,
|
||||||
|
- triggers auth recheck + chats reload on switch.
|
||||||
|
|
||||||
|
### Step 111 - Real Settings + persistent theme + add-account UX
|
||||||
|
- Implemented persistent app theme storage via DataStore (`ThemeRepository`) and applied theme mode on app start in `MainActivity`.
|
||||||
|
- Reworked `SettingsScreen` to contain only working settings and actions:
|
||||||
|
- multi-account list (`Switch`/`Remove`) + `Add account` dialog with email/password sign-in,
|
||||||
|
- appearance (`Light`/`Dark`/`System`) wired to persisted theme,
|
||||||
|
- notifications (`global` + `preview`) wired to `NotificationSettingsRepository`,
|
||||||
|
- privacy update, blocked users management, sessions revoke/revoke-all, 2FA controls.
|
||||||
|
- Updated `ProfileScreen` to follow current app theme colors instead of forced dark palette.
|
||||||
|
|
||||||
|
### Step 112 - Settings cleanup (privacy dropdowns + removed extra blocks)
|
||||||
|
- Replaced free-text privacy inputs with dropdown selectors (`everyone`, `contacts`, `nobody`) for:
|
||||||
|
- private messages,
|
||||||
|
- last seen,
|
||||||
|
- avatar visibility,
|
||||||
|
- group invites.
|
||||||
|
- Removed direct `block by user id` controls from Settings UI as requested.
|
||||||
|
- Removed extra bottom Settings actions (`Profile` row and `Back to chats` button) and kept categorized section layout.
|
||||||
|
|
||||||
|
### Step 113 - Auth flow redesign (email -> password/register -> 2FA) + startup no-flicker
|
||||||
|
- Added step-based auth domain/use-cases for:
|
||||||
|
- `GET /api/v1/auth/check-email`
|
||||||
|
- `POST /api/v1/auth/register`
|
||||||
|
- login with optional `otp_code` / `recovery_code`.
|
||||||
|
- Updated Android login UI to multi-step flow:
|
||||||
|
- step 1: email input,
|
||||||
|
- step 2: password for existing account or register form (`name`, `username`, `password`) for new account,
|
||||||
|
- step 3: 2FA OTP/recovery code when backend requires it.
|
||||||
|
- Improved login error mapping for 2FA-required responses, so app switches to OTP step instead of generic invalid-password message.
|
||||||
|
- Removed auth screen flash on startup:
|
||||||
|
- introduced dedicated `startup` route with session-check loader,
|
||||||
|
- delayed auth/chats navigation until session check is finished.
|
||||||
|
- Added safe fallback in `MainActivity` theme bootstrap to prevent crash if `ThemeRepository` injection is unexpectedly unavailable during startup.
|
||||||
|
|
||||||
|
### Step 114 - Multi-account switch sync fix (chats + realtime)
|
||||||
|
- Fixed account switch flow to fully rebind app data context:
|
||||||
|
- restart realtime socket on new active account token,
|
||||||
|
- force refresh chats for both `archived=false` and `archived=true` right after switch.
|
||||||
|
- Fixed navigation behavior on account switch to avoid noisy `popBackStack ... not found` and stale restored stack state.
|
||||||
|
|
||||||
|
### Step 115 - Settings UI restructured into Telegram-like folders
|
||||||
|
- Reworked Settings into a menu-first screen with Telegram-style grouped rows.
|
||||||
|
- Added per-item folder pages (subscreens) for:
|
||||||
|
- Account
|
||||||
|
- Chat settings
|
||||||
|
- Privacy
|
||||||
|
- Notifications
|
||||||
|
- Devices
|
||||||
|
- Data/Chat folders/Power/Language placeholders
|
||||||
|
- Kept theme logic intact and moved appearance controls into `Chat settings` folder.
|
||||||
|
|
||||||
|
### Step 116 - Profile cleanup (remove non-working extras)
|
||||||
|
- Removed non-functional profile tabs and placeholder blocks:
|
||||||
|
- `Posts`
|
||||||
|
- `Archived`
|
||||||
|
- `Gifts`
|
||||||
|
- Removed `Settings` hero button from profile header.
|
||||||
|
- Removed bottom `Back to chats` button from profile screen.
|
||||||
|
- Simplified profile layout so the editable profile form is the primary secondary section toggled by `Edit`.
|
||||||
|
- Updated `ProfileRoute` navigation contract to match the simplified screen API.
|
||||||
|
|
||||||
|
### Step 117 - Settings folders cleanup (remove back button action)
|
||||||
|
- Removed `Back to chats` button from all Settings folder pages.
|
||||||
|
- Simplified Settings navigation contract by removing unused `onBackToChats` parameter from:
|
||||||
|
- `SettingsRoute`
|
||||||
|
- `SettingsScreen`
|
||||||
|
- `SettingsFolderView`
|
||||||
|
- Updated `AppNavGraph` Settings destination call-site accordingly.
|
||||||
|
|
||||||
|
### Step 118 - Android push notifications grouped by chat
|
||||||
|
- Reworked `NotificationDispatcher` to aggregate incoming messages into one notification per chat:
|
||||||
|
- stable notification id per `chatId`,
|
||||||
|
- per-chat unread counter,
|
||||||
|
- multi-line inbox preview of recent messages.
|
||||||
|
- Added app-level summary notification that groups all active chat notifications.
|
||||||
|
- Added deduplication guard for repeated push deliveries of the same `messageId`.
|
||||||
|
- Added notification cleanup on chat open:
|
||||||
|
- when push-open intent targets a chat in `MainActivity`,
|
||||||
|
- when `ChatViewModel` enters a chat directly from app UI.
|
||||||
|
|
||||||
|
### Step 119 - Chat screen visual baseline (Telegram-like start)
|
||||||
|
- Reworked chat top bar:
|
||||||
|
- icon back button instead of text button,
|
||||||
|
- cleaner title/subtitle styling,
|
||||||
|
- dedicated search icon in top bar (inline search is now collapsible).
|
||||||
|
- Updated pinned message strip:
|
||||||
|
- cleaner card styling,
|
||||||
|
- close icon action instead of full text button.
|
||||||
|
- Updated composer baseline:
|
||||||
|
- icon-based emoji/attach/send/mic controls,
|
||||||
|
- cleaner container styling closer to Telegram-like bottom bar.
|
||||||
|
|
||||||
|
### Step 120 - Message bubble layout pass (Telegram-like geometry)
|
||||||
|
- Reworked `MessageBubble` structure and density:
|
||||||
|
- cleaner outgoing/incoming bubble geometry,
|
||||||
|
- improved max width and alignment behavior,
|
||||||
|
- tighter paddings and spacing for mobile density.
|
||||||
|
- Redesigned forwarded/reply blocks:
|
||||||
|
- compact forwarded caption styling,
|
||||||
|
- reply block with accent stripe and nested preview text.
|
||||||
|
- Improved message meta line:
|
||||||
|
- cleaner time + status line placement and contrast.
|
||||||
|
- Refined reactions and attachments rendering inside bubbles:
|
||||||
|
- chip-like reaction containers,
|
||||||
|
- rounded image/file/media surfaces closer to Telegram-like visual rhythm.
|
||||||
|
|
||||||
|
### Step 121 - Chat selection and message action UX cleanup
|
||||||
|
- Added Telegram-like multi-select top bar in chat:
|
||||||
|
- close selection,
|
||||||
|
- selected counter,
|
||||||
|
- quick forward/delete actions.
|
||||||
|
- Simplified tap action menu flow for single message:
|
||||||
|
- richer reaction row (`❤️ 👍 👎 🔥 🥰 👏 😁`),
|
||||||
|
- reply/edit/forward/delete actions kept in one sheet.
|
||||||
|
- Removed duplicate/conflicting selection controls between top and bottom action rows.
|
||||||
|
|
||||||
|
### Step 122 - Chat 3-dot menu + chat info media tabs shell
|
||||||
|
- Added chat header `3-dot` popup menu with Telegram-like actions:
|
||||||
|
- `Chat info`
|
||||||
|
- `Search`
|
||||||
|
- `Notifications`
|
||||||
|
- `Change wallpaper`
|
||||||
|
- `Clear history`
|
||||||
|
- Added `Chat info` bottom sheet with tabbed sections:
|
||||||
|
- `Media`
|
||||||
|
- `Files`
|
||||||
|
- `Links`
|
||||||
|
- `Voice`
|
||||||
|
- Implemented local tab content from current loaded chat messages/attachments to provide immediate media/files/links/voice overview.
|
||||||
|
|
||||||
|
### Step 123 - Chat info visual pass (Telegram-like density)
|
||||||
|
- Updated `Chat info` tabs to pill-style horizontal chips with tighter Telegram-like spacing.
|
||||||
|
- Improved tab content rendering:
|
||||||
|
- `Media` now uses a 3-column thumbnail grid.
|
||||||
|
- `Files / Links / Voice` use denser card rows with icon+meta layout.
|
||||||
|
- `Voice` rows now show a dedicated play affordance.
|
||||||
|
- Refined menu order in chat `3-dot` popup and kept actions consistent with current no-calls scope.
|
||||||
|
|
||||||
|
### Step 124 - Inline search close fix + message menu visual pass
|
||||||
|
- Fixed inline chat search UX:
|
||||||
|
- added explicit close button in the search row,
|
||||||
|
- closing search now also clears active query/filter without re-entering chat.
|
||||||
|
- Added automatic inline-search collapse when entering multi-select mode.
|
||||||
|
- Reworked message tap action sheet to Telegram-like list actions with icons and clearer destructive `Delete` row styling.
|
||||||
|
|
||||||
|
### Step 125 - Chat header/top strips visual refinement
|
||||||
|
- Refined chat header density and typography to be closer to Telegram-like proportions.
|
||||||
|
- Updated pinned strip visual:
|
||||||
|
- accent vertical marker,
|
||||||
|
- tighter spacing,
|
||||||
|
- cleaner title/content hierarchy.
|
||||||
|
- Added top mini audio strip under pinned area:
|
||||||
|
- shows latest audio/voice context from loaded chat,
|
||||||
|
- includes play affordance, speed badge, and dismiss action.
|
||||||
|
|
||||||
|
### Step 126 - Message bubble/composer micro-polish
|
||||||
|
- Updated message bubble sizing and density:
|
||||||
|
- reduced bubble width for cleaner conversation rhythm,
|
||||||
|
- tighter vertical spacing,
|
||||||
|
- text style adjusted for better readability.
|
||||||
|
- Refined bottom composer visuals:
|
||||||
|
- switched to Telegram-like rounded input container look,
|
||||||
|
- emoji/attach/send buttons now use circular tinted surfaces,
|
||||||
|
- text input moved to filled style with hidden indicator lines.
|
||||||
|
|
||||||
|
### Step 127 - Top audio strip behavior fix (playback-driven)
|
||||||
|
- Reworked top audio strip logic to be playback-driven instead of always-on:
|
||||||
|
- strip appears only when user starts audio/voice playback,
|
||||||
|
- strip switches to the currently playing file,
|
||||||
|
- strip auto-hides when playback stops.
|
||||||
|
- Added close (`X`) behavior that hides the strip and force-stops the currently playing source.
|
||||||
|
|
||||||
|
### Step 128 - Parity docs update: text formatting gap
|
||||||
|
- Synced Android parity documentation with web-core status:
|
||||||
|
- added explicit `Text formatting parity` gap to `docs/android-checklist.md`,
|
||||||
|
- added dedicated Android gap block in `docs/backend-web-android-parity.md` for formatting parity.
|
||||||
|
- Marked formatting parity as part of highest-priority Android parity block.
|
||||||
|
|
||||||
|
### Step 129 - Parity block (1/3/4/5/6): formatting, notifications inbox, resend verification, push sync
|
||||||
|
- Completed Android text formatting parity in chat:
|
||||||
|
- composer toolbar actions for `bold/italic/underline/strikethrough`,
|
||||||
|
- spoiler, inline code, code block, quote, link insertion,
|
||||||
|
- message bubble rich renderer for web-style markdown tokens and clickable links.
|
||||||
|
- Added server notifications inbox flow in account/settings:
|
||||||
|
- API wiring for `GET /api/v1/notifications`,
|
||||||
|
- domain mapping and recent-notifications UI section.
|
||||||
|
- Added resend verification support on Android:
|
||||||
|
- API wiring for `POST /api/v1/auth/resend-verification`,
|
||||||
|
- Verify Email screen action for resending link by email.
|
||||||
|
- Hardened push token lifecycle sync:
|
||||||
|
- token registration dedupe by `(userId, token)`,
|
||||||
|
- marker cleanup on logout,
|
||||||
|
- best-effort re-sync after account switch.
|
||||||
|
- Notification delivery polish (foundation):
|
||||||
|
- foreground notification body now respects preview setting and falls back to media-type labels when previews are disabled.
|
||||||
|
- Verified with successful `:app:compileDebugKotlin` and `:app:assembleDebug`.
|
||||||
|
|
||||||
|
### Step 130 - Chat UX pack: video viewer, emoji/GIF/sticker picker, day separators
|
||||||
|
- Added chat timeline day separators with Telegram-like chips:
|
||||||
|
- `Сегодня`, `Вчера`, or localized date labels.
|
||||||
|
- Added fullscreen video viewer:
|
||||||
|
- video attachments now open in a fullscreen overlay with close action.
|
||||||
|
- Added composer media picker sheet:
|
||||||
|
- tabs: `Эмодзи`, `GIF`, `Стикеры`,
|
||||||
|
- emoji insertion at cursor,
|
||||||
|
- remote GIF/sticker selection with download+send flow.
|
||||||
|
- Extended media type mapping in message send pipeline:
|
||||||
|
- GIFs now sent as `gif`,
|
||||||
|
- sticker-like payloads sent as `sticker` (filename/mime detection).
|
||||||
|
- Added missing fallback resource theme declarations (`themes.xml`) to stabilize clean debug assembly on local builds.
|
||||||
|
|
||||||
|
### Step 131 - Channel chat Telegram-like visual alignment
|
||||||
|
- Added channel-aware chat rendering path:
|
||||||
|
- `MessageUiState` now carries `chatType` from `ChatViewModel`,
|
||||||
|
- channel timeline bubbles are rendered as wider post-like cards (left-aligned feed style).
|
||||||
|
- Refined channel message status presentation:
|
||||||
|
- post cards now show cleaner timestamp-only footer instead of direct-message style checks.
|
||||||
|
- Added dedicated read-only channel bottom bar (for non owner/admin):
|
||||||
|
- compact Telegram-like controls with search + centered `Включить звук` action + notifications icon.
|
||||||
|
- Kept existing full composer for roles allowed to post in channels (owner/admin).
|
||||||
|
|
||||||
|
### Step 132 - Voice recording composer overlap fix
|
||||||
|
- Fixed composer overlap during voice recording:
|
||||||
|
- recording status/hint is now rendered in a dedicated top block inside composer,
|
||||||
|
- formatting toolbar is hidden while recording is active.
|
||||||
|
- Prevented controls collision for locked-recording actions:
|
||||||
|
- `Cancel/Send` now render on a separate row in locked state.
|
||||||
|
|
||||||
|
### Step 133 - Video/audio player controls upgrade
|
||||||
|
- Upgraded fullscreen video viewer controls:
|
||||||
|
- play/pause button,
|
||||||
|
- seek slider (scrubbing),
|
||||||
|
- current time / total duration labels.
|
||||||
|
- Upgraded attachment audio player behavior (voice + audio):
|
||||||
|
- added seek slider for manual rewind/fast-forward,
|
||||||
|
- unified speed toggle for both `voice` and `audio` playback.
|
||||||
|
|
||||||
|
### Step 134 - Hilt startup crash fix (`MessengerApplication_GeneratedInjector`)
|
||||||
|
- Fixed startup crash:
|
||||||
|
- `NoClassDefFoundError: MessengerApplication_GeneratedInjector`.
|
||||||
|
- Root cause observed in build pipeline:
|
||||||
|
- `MessengerApplication_GeneratedInjector.class` existed after `javac`,
|
||||||
|
- but was missing in `transformDebugClassesWithAsm/dirs` before dexing.
|
||||||
|
- Added Gradle backfill task for `debug/release` variants:
|
||||||
|
- copies `*Application_GeneratedInjector.class` from `intermediates/javac/.../classes`
|
||||||
|
into `intermediates/classes/.../transform...ClassesWithAsm/dirs` if missing,
|
||||||
|
- wired task as dependency of `dexBuilder<Variant>`.
|
||||||
|
|
||||||
|
### Step 135 - AppCompat launch crash fix (theme mismatch)
|
||||||
|
- Fixed `MainActivity` startup crash:
|
||||||
|
- `IllegalStateException: You need to use a Theme.AppCompat theme`.
|
||||||
|
- Root cause:
|
||||||
|
- `Theme.AppCompat.DayNight.NoActionBar` was accidentally overridden in app resources
|
||||||
|
with non-AppCompat parent (`Theme.DeviceDefault.NoActionBar`).
|
||||||
|
- Fix applied:
|
||||||
|
- introduced dedicated app theme `Theme.Messenger` with parent `Theme.AppCompat.DayNight.NoActionBar`,
|
||||||
|
- switched `AndroidManifest.xml` application theme to `@style/Theme.Messenger`.
|
||||||
|
|
||||||
|
### Step 136 - Message context menu dismiss selection fix
|
||||||
|
- Fixed chat bug after closing message context menu by tapping outside:
|
||||||
|
- selection state now clears on `ModalBottomSheet` dismiss,
|
||||||
|
- prevents stale single-selection action bar from appearing after menu close.
|
||||||
|
|
||||||
|
### Step 137 - Telegram-like message actions cleanup
|
||||||
|
- Removed legacy single-selection bottom action bar (`Close/Delete/Del for all/Edit`) in chat.
|
||||||
|
- Message actions are now driven by Telegram-like context UI:
|
||||||
|
- tap -> context sheet actions,
|
||||||
|
- long-press -> selection mode flow.
|
||||||
|
|
||||||
|
### Step 138 - Multi-select UX closer to Telegram
|
||||||
|
- Refined selection top bar:
|
||||||
|
- removed extra overflow/load action from selection mode,
|
||||||
|
- kept focused actions only: close, selected count, forward, delete.
|
||||||
|
- In `MULTI` selection mode, composer is now replaced with a compact bottom action row:
|
||||||
|
- `Reply` (enabled for single selected message),
|
||||||
|
- `Forward`.
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import java.util.Properties
|
||||||
|
|
||||||
plugins {
|
plugins {
|
||||||
id("com.android.application")
|
id("com.android.application")
|
||||||
id("org.jetbrains.kotlin.android")
|
id("org.jetbrains.kotlin.android")
|
||||||
@@ -5,8 +7,19 @@ plugins {
|
|||||||
id("org.jetbrains.kotlin.kapt")
|
id("org.jetbrains.kotlin.kapt")
|
||||||
id("org.jetbrains.kotlin.plugin.serialization")
|
id("org.jetbrains.kotlin.plugin.serialization")
|
||||||
id("com.google.dagger.hilt.android")
|
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 {
|
android {
|
||||||
namespace = "ru.daemonlord.messenger"
|
namespace = "ru.daemonlord.messenger"
|
||||||
compileSdk = 35
|
compileSdk = 35
|
||||||
@@ -18,6 +31,16 @@ android {
|
|||||||
versionCode = 1
|
versionCode = 1
|
||||||
versionName = "0.1.0"
|
versionName = "0.1.0"
|
||||||
buildConfigField("String", "API_BASE_URL", "\"https://chat.daemonlord.ru/\"")
|
buildConfigField("String", "API_BASE_URL", "\"https://chat.daemonlord.ru/\"")
|
||||||
|
buildConfigField("String", "API_VERSION_HEADER", "\"2026-03\"")
|
||||||
|
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"
|
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||||
vectorDrawables {
|
vectorDrawables {
|
||||||
@@ -61,7 +84,11 @@ android {
|
|||||||
}
|
}
|
||||||
|
|
||||||
dependencies {
|
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.core:core-ktx:1.15.0")
|
||||||
|
implementation("androidx.appcompat:appcompat:1.7.0")
|
||||||
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.8.7")
|
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.8.7")
|
||||||
implementation("androidx.lifecycle:lifecycle-runtime-compose:2.8.7")
|
implementation("androidx.lifecycle:lifecycle-runtime-compose:2.8.7")
|
||||||
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.8.7")
|
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.8.7")
|
||||||
@@ -71,6 +98,20 @@ dependencies {
|
|||||||
implementation("androidx.compose.ui:ui:1.7.6")
|
implementation("androidx.compose.ui:ui:1.7.6")
|
||||||
implementation("androidx.compose.ui:ui-tooling-preview:1.7.6")
|
implementation("androidx.compose.ui:ui-tooling-preview:1.7.6")
|
||||||
implementation("androidx.compose.material3:material3:1.3.1")
|
implementation("androidx.compose.material3:material3:1.3.1")
|
||||||
|
implementation("androidx.compose.material:material-icons-extended:1.7.6")
|
||||||
|
implementation("io.coil-kt:coil:2.7.0")
|
||||||
|
implementation("io.coil-kt:coil-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-coroutines-android:1.9.0")
|
||||||
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3")
|
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3")
|
||||||
@@ -81,6 +122,7 @@ dependencies {
|
|||||||
implementation("com.squareup.okhttp3:logging-interceptor:4.12.0")
|
implementation("com.squareup.okhttp3:logging-interceptor:4.12.0")
|
||||||
|
|
||||||
implementation("androidx.datastore:datastore-preferences:1.1.1")
|
implementation("androidx.datastore:datastore-preferences:1.1.1")
|
||||||
|
implementation("androidx.security:security-crypto:1.1.0-alpha06")
|
||||||
implementation("androidx.room:room-runtime:2.6.1")
|
implementation("androidx.room:room-runtime:2.6.1")
|
||||||
implementation("androidx.room:room-ktx:2.6.1")
|
implementation("androidx.room:room-ktx:2.6.1")
|
||||||
kapt("androidx.room:room-compiler:2.6.1")
|
kapt("androidx.room:room-compiler:2.6.1")
|
||||||
@@ -88,6 +130,9 @@ dependencies {
|
|||||||
implementation("com.google.dagger:hilt-android:2.52")
|
implementation("com.google.dagger:hilt-android:2.52")
|
||||||
kapt("com.google.dagger:hilt-compiler:2.52")
|
kapt("com.google.dagger:hilt-compiler:2.52")
|
||||||
implementation("androidx.hilt:hilt-navigation-compose:1.2.0")
|
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("junit:junit:4.13.2")
|
||||||
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.9.0")
|
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.9.0")
|
||||||
@@ -96,6 +141,9 @@ dependencies {
|
|||||||
testImplementation("androidx.test:core:1.6.1")
|
testImplementation("androidx.test:core:1.6.1")
|
||||||
testImplementation("org.robolectric:robolectric:4.13")
|
testImplementation("org.robolectric:robolectric:4.13")
|
||||||
testImplementation("com.squareup.okhttp3:mockwebserver:4.12.0")
|
testImplementation("com.squareup.okhttp3:mockwebserver:4.12.0")
|
||||||
|
androidTestImplementation("androidx.compose.ui:ui-test-junit4:1.7.6")
|
||||||
|
androidTestImplementation("androidx.test.ext:junit:1.2.1")
|
||||||
|
androidTestImplementation("androidx.test.espresso:espresso-core:3.6.1")
|
||||||
|
|
||||||
debugImplementation("androidx.compose.ui:ui-tooling:1.7.6")
|
debugImplementation("androidx.compose.ui:ui-tooling:1.7.6")
|
||||||
debugImplementation("androidx.compose.ui:ui-test-manifest:1.7.6")
|
debugImplementation("androidx.compose.ui:ui-test-manifest:1.7.6")
|
||||||
@@ -104,3 +152,37 @@ dependencies {
|
|||||||
kapt {
|
kapt {
|
||||||
correctErrorTypes = true
|
correctErrorTypes = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun registerHiltInjectorBackfillTask(variantName: String) {
|
||||||
|
val cap = variantName.replaceFirstChar { if (it.isLowerCase()) it.titlecase() else it.toString() }
|
||||||
|
val taskName = "backfill${cap}HiltApplicationInjector"
|
||||||
|
val dexBuilderTaskName = "dexBuilder$cap"
|
||||||
|
val compileJavaTaskName = "compile${cap}JavaWithJavac"
|
||||||
|
|
||||||
|
tasks.register(taskName) {
|
||||||
|
dependsOn(compileJavaTaskName)
|
||||||
|
doLast {
|
||||||
|
val javacOutput = file("$buildDir/intermediates/javac/$variantName/$compileJavaTaskName/classes")
|
||||||
|
val asmOutput = file("$buildDir/intermediates/classes/$variantName/transform${cap}ClassesWithAsm/dirs")
|
||||||
|
if (!javacOutput.exists() || !asmOutput.exists()) return@doLast
|
||||||
|
|
||||||
|
fileTree(javacOutput) {
|
||||||
|
include("**/*Application_GeneratedInjector.class")
|
||||||
|
}.forEach { source ->
|
||||||
|
val relativePath = source.relativeTo(javacOutput).path
|
||||||
|
val target = file("${asmOutput.path}/$relativePath")
|
||||||
|
if (!target.exists()) {
|
||||||
|
target.parentFile?.mkdirs()
|
||||||
|
source.copyTo(target, overwrite = true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tasks.matching { it.name == dexBuilderTaskName }.configureEach {
|
||||||
|
dependsOn(taskName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
registerHiltInjectorBackfillTask("debug")
|
||||||
|
registerHiltInjectorBackfillTask("release")
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
package ru.daemonlord.messenger.ui.auth
|
||||||
|
|
||||||
|
import androidx.activity.ComponentActivity
|
||||||
|
import androidx.compose.ui.test.assertIsDisplayed
|
||||||
|
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||||
|
import androidx.compose.ui.test.onNodeWithText
|
||||||
|
import org.junit.Rule
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class LoginScreenTest {
|
||||||
|
|
||||||
|
@get:Rule
|
||||||
|
val composeRule = createAndroidComposeRule<ComponentActivity>()
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun loginScreen_showsErrorMessage() {
|
||||||
|
composeRule.setContent {
|
||||||
|
LoginScreen(
|
||||||
|
state = AuthUiState(
|
||||||
|
email = "demo@daemonlord.ru",
|
||||||
|
password = "123456",
|
||||||
|
errorMessage = "Invalid email or password.",
|
||||||
|
),
|
||||||
|
onEmailChanged = {},
|
||||||
|
onPasswordChanged = {},
|
||||||
|
onLoginClick = {},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
composeRule.onNodeWithText("Messenger Login").assertIsDisplayed()
|
||||||
|
composeRule.onNodeWithText("Invalid email or password.").assertIsDisplayed()
|
||||||
|
composeRule.onNodeWithText("Login").assertIsDisplayed()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
package ru.daemonlord.messenger.ui.chats
|
||||||
|
|
||||||
|
import androidx.activity.ComponentActivity
|
||||||
|
import androidx.compose.ui.test.assertIsDisplayed
|
||||||
|
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||||
|
import androidx.compose.ui.test.onNodeWithText
|
||||||
|
import org.junit.Rule
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class ChatListScreenTest {
|
||||||
|
|
||||||
|
@get:Rule
|
||||||
|
val composeRule = createAndroidComposeRule<ComponentActivity>()
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun chatListScreen_loadingState_showsLoadingText() {
|
||||||
|
composeRule.setContent {
|
||||||
|
ChatListScreen(
|
||||||
|
state = ChatListUiState(isLoading = true),
|
||||||
|
onTabSelected = {},
|
||||||
|
onFilterSelected = {},
|
||||||
|
onSearchChanged = {},
|
||||||
|
onRefresh = {},
|
||||||
|
onOpenSettings = {},
|
||||||
|
onOpenProfile = {},
|
||||||
|
onOpenChat = {},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
composeRule.onNodeWithText("Loading chats...").assertIsDisplayed()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun chatListScreen_emptyState_showsPlaceholder() {
|
||||||
|
composeRule.setContent {
|
||||||
|
ChatListScreen(
|
||||||
|
state = ChatListUiState(isLoading = false, chats = emptyList()),
|
||||||
|
onTabSelected = {},
|
||||||
|
onFilterSelected = {},
|
||||||
|
onSearchChanged = {},
|
||||||
|
onRefresh = {},
|
||||||
|
onOpenSettings = {},
|
||||||
|
onOpenProfile = {},
|
||||||
|
onOpenChat = {},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
composeRule.onNodeWithText("No chats found").assertIsDisplayed()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -2,16 +2,20 @@
|
|||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
|
||||||
<uses-permission android:name="android.permission.INTERNET" />
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||||
|
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||||
|
<uses-permission android:name="android.permission.CAMERA" />
|
||||||
|
|
||||||
<application
|
<application
|
||||||
android:name=".MessengerApplication"
|
android:name=".MessengerApplication"
|
||||||
android:allowBackup="true"
|
android:allowBackup="true"
|
||||||
|
android:enableOnBackInvokedCallback="true"
|
||||||
android:icon="@android:drawable/sym_def_app_icon"
|
android:icon="@android:drawable/sym_def_app_icon"
|
||||||
android:label="@string/app_name"
|
android:label="@string/app_name"
|
||||||
android:roundIcon="@android:drawable/sym_def_app_icon"
|
android:roundIcon="@android:drawable/sym_def_app_icon"
|
||||||
android:supportsRtl="true"
|
android:supportsRtl="true"
|
||||||
android:usesCleartextTraffic="true"
|
android:usesCleartextTraffic="true"
|
||||||
android:theme="@android:style/Theme.Material.Light.NoActionBar">
|
android:theme="@style/Theme.Messenger">
|
||||||
<activity
|
<activity
|
||||||
android:name=".MainActivity"
|
android:name=".MainActivity"
|
||||||
android:exported="true">
|
android:exported="true">
|
||||||
@@ -19,7 +23,40 @@
|
|||||||
<action android:name="android.intent.action.MAIN" />
|
<action android:name="android.intent.action.MAIN" />
|
||||||
<category android:name="android.intent.category.LAUNCHER" />
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
|
<intent-filter android:autoVerify="true">
|
||||||
|
<action android:name="android.intent.action.VIEW" />
|
||||||
|
<category android:name="android.intent.category.DEFAULT" />
|
||||||
|
<category android:name="android.intent.category.BROWSABLE" />
|
||||||
|
<data
|
||||||
|
android:host="chat.daemonlord.ru"
|
||||||
|
android:pathPrefix="/join"
|
||||||
|
android:scheme="https" />
|
||||||
|
<data
|
||||||
|
android:host="chat.daemonlord.ru"
|
||||||
|
android:pathPrefix="/verify-email"
|
||||||
|
android:scheme="https" />
|
||||||
|
<data
|
||||||
|
android:host="chat.daemonlord.ru"
|
||||||
|
android:pathPrefix="/reset-password"
|
||||||
|
android:scheme="https" />
|
||||||
|
</intent-filter>
|
||||||
</activity>
|
</activity>
|
||||||
|
<service
|
||||||
|
android:name=".push.MessengerFirebaseMessagingService"
|
||||||
|
android:exported="false">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="com.google.firebase.MESSAGING_EVENT" />
|
||||||
|
</intent-filter>
|
||||||
|
</service>
|
||||||
|
<provider
|
||||||
|
android:name="androidx.core.content.FileProvider"
|
||||||
|
android:authorities="${applicationId}.fileprovider"
|
||||||
|
android:exported="false"
|
||||||
|
android:grantUriPermissions="true">
|
||||||
|
<meta-data
|
||||||
|
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||||
|
android:resource="@xml/file_paths" />
|
||||||
|
</provider>
|
||||||
</application>
|
</application>
|
||||||
|
|
||||||
</manifest>
|
</manifest>
|
||||||
|
|||||||
@@ -1,31 +1,171 @@
|
|||||||
package ru.daemonlord.messenger
|
package ru.daemonlord.messenger
|
||||||
|
|
||||||
|
import android.content.Intent
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import androidx.activity.ComponentActivity
|
import androidx.activity.enableEdgeToEdge
|
||||||
import androidx.activity.compose.setContent
|
import androidx.activity.compose.setContent
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.Surface
|
import androidx.compose.material3.Surface
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.appcompat.app.AppCompatActivity
|
||||||
|
import androidx.appcompat.app.AppCompatDelegate
|
||||||
|
import androidx.core.os.LocaleListCompat
|
||||||
import dagger.hilt.android.AndroidEntryPoint
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
|
import ru.daemonlord.messenger.core.notifications.NotificationDispatcher
|
||||||
|
import ru.daemonlord.messenger.core.notifications.NotificationIntentExtras
|
||||||
|
import ru.daemonlord.messenger.domain.settings.repository.LanguageRepository
|
||||||
|
import ru.daemonlord.messenger.domain.settings.model.AppThemeMode
|
||||||
|
import ru.daemonlord.messenger.domain.settings.repository.ThemeRepository
|
||||||
import ru.daemonlord.messenger.ui.navigation.MessengerNavHost
|
import ru.daemonlord.messenger.ui.navigation.MessengerNavHost
|
||||||
|
import ru.daemonlord.messenger.ui.theme.MessengerTheme
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
@AndroidEntryPoint
|
@AndroidEntryPoint
|
||||||
class MainActivity : ComponentActivity() {
|
class MainActivity : AppCompatActivity() {
|
||||||
|
@Inject
|
||||||
|
lateinit var themeRepository: ThemeRepository
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
lateinit var languageRepository: LanguageRepository
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
lateinit var notificationDispatcher: NotificationDispatcher
|
||||||
|
|
||||||
|
private var pendingInviteToken by mutableStateOf<String?>(null)
|
||||||
|
private var 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?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
|
val savedThemeMode = if (this::themeRepository.isInitialized) {
|
||||||
|
runBlocking { themeRepository.getThemeMode() }
|
||||||
|
} else {
|
||||||
|
AppThemeMode.SYSTEM
|
||||||
|
}
|
||||||
|
AppCompatDelegate.setDefaultNightMode(
|
||||||
|
when (savedThemeMode) {
|
||||||
|
AppThemeMode.LIGHT -> AppCompatDelegate.MODE_NIGHT_NO
|
||||||
|
AppThemeMode.DARK -> AppCompatDelegate.MODE_NIGHT_YES
|
||||||
|
AppThemeMode.SYSTEM -> AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM
|
||||||
|
}
|
||||||
|
)
|
||||||
|
val savedLanguageTag = if (this::languageRepository.isInitialized) {
|
||||||
|
runBlocking { languageRepository.getLanguage().tag }
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
val locales = savedLanguageTag?.let { LocaleListCompat.forLanguageTags(it) } ?: LocaleListCompat.getEmptyLocaleList()
|
||||||
|
AppCompatDelegate.setApplicationLocales(locales)
|
||||||
|
pendingInviteToken = intent.extractInviteToken()
|
||||||
|
pendingVerifyEmailToken = intent.extractVerifyEmailToken()
|
||||||
|
pendingResetPasswordToken = intent.extractResetPasswordToken()
|
||||||
|
val notificationPayload = intent.extractNotificationOpenPayload()
|
||||||
|
pendingNotificationChatId = notificationPayload?.first
|
||||||
|
pendingNotificationMessageId = notificationPayload?.second
|
||||||
|
notificationPayload?.first?.let(notificationDispatcher::clearChatNotifications)
|
||||||
|
enableEdgeToEdge()
|
||||||
setContent {
|
setContent {
|
||||||
MaterialTheme {
|
MessengerTheme {
|
||||||
Surface(modifier = Modifier.fillMaxSize()) {
|
Surface(modifier = Modifier.fillMaxSize()) {
|
||||||
AppRoot()
|
AppRoot(
|
||||||
|
inviteToken = pendingInviteToken,
|
||||||
|
onInviteTokenConsumed = { pendingInviteToken = null },
|
||||||
|
verifyEmailToken = pendingVerifyEmailToken,
|
||||||
|
onVerifyEmailTokenConsumed = { pendingVerifyEmailToken = null },
|
||||||
|
resetPasswordToken = pendingResetPasswordToken,
|
||||||
|
onResetPasswordTokenConsumed = { pendingResetPasswordToken = null },
|
||||||
|
notificationChatId = pendingNotificationChatId,
|
||||||
|
notificationMessageId = pendingNotificationMessageId,
|
||||||
|
onNotificationConsumed = {
|
||||||
|
pendingNotificationChatId = null
|
||||||
|
pendingNotificationMessageId = null
|
||||||
|
},
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun onNewIntent(intent: Intent) {
|
||||||
|
super.onNewIntent(intent)
|
||||||
|
setIntent(intent)
|
||||||
|
pendingInviteToken = intent.extractInviteToken() ?: pendingInviteToken
|
||||||
|
pendingVerifyEmailToken = intent.extractVerifyEmailToken() ?: pendingVerifyEmailToken
|
||||||
|
pendingResetPasswordToken = intent.extractResetPasswordToken() ?: pendingResetPasswordToken
|
||||||
|
val notificationPayload = intent.extractNotificationOpenPayload()
|
||||||
|
if (notificationPayload != null) {
|
||||||
|
pendingNotificationChatId = notificationPayload.first
|
||||||
|
pendingNotificationMessageId = notificationPayload.second
|
||||||
|
notificationDispatcher.clearChatNotifications(notificationPayload.first)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun AppRoot() {
|
private fun AppRoot(
|
||||||
MessengerNavHost()
|
inviteToken: String?,
|
||||||
|
onInviteTokenConsumed: () -> Unit,
|
||||||
|
verifyEmailToken: String?,
|
||||||
|
onVerifyEmailTokenConsumed: () -> Unit,
|
||||||
|
resetPasswordToken: String?,
|
||||||
|
onResetPasswordTokenConsumed: () -> Unit,
|
||||||
|
notificationChatId: Long?,
|
||||||
|
notificationMessageId: Long?,
|
||||||
|
onNotificationConsumed: () -> Unit,
|
||||||
|
) {
|
||||||
|
MessengerNavHost(
|
||||||
|
inviteToken = inviteToken,
|
||||||
|
onInviteTokenConsumed = onInviteTokenConsumed,
|
||||||
|
verifyEmailToken = verifyEmailToken,
|
||||||
|
onVerifyEmailTokenConsumed = onVerifyEmailTokenConsumed,
|
||||||
|
resetPasswordToken = resetPasswordToken,
|
||||||
|
onResetPasswordTokenConsumed = onResetPasswordTokenConsumed,
|
||||||
|
notificationChatId = notificationChatId,
|
||||||
|
notificationMessageId = notificationMessageId,
|
||||||
|
onNotificationConsumed = onNotificationConsumed,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun Intent?.extractVerifyEmailToken(): String? {
|
||||||
|
val uri = this?.data ?: return null
|
||||||
|
val isVerifyPath = uri.pathSegments.contains("verify-email") || uri.path.equals("/verify-email", ignoreCase = true)
|
||||||
|
if (!isVerifyPath) return null
|
||||||
|
return uri.getQueryParameter("token")?.trim()?.takeIf { it.isNotBlank() }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun Intent?.extractResetPasswordToken(): String? {
|
||||||
|
val uri = this?.data ?: return null
|
||||||
|
val isResetPath = uri.pathSegments.contains("reset-password") || uri.path.equals("/reset-password", ignoreCase = true)
|
||||||
|
if (!isResetPath) return null
|
||||||
|
return uri.getQueryParameter("token")?.trim()?.takeIf { it.isNotBlank() }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun Intent?.extractInviteToken(): String? {
|
||||||
|
val uri = this?.data ?: return null
|
||||||
|
val queryToken = uri.getQueryParameter("token")?.trim().orEmpty()
|
||||||
|
if (queryToken.isNotBlank()) return queryToken
|
||||||
|
|
||||||
|
val segments = uri.pathSegments
|
||||||
|
val joinIndex = segments.indexOf("join")
|
||||||
|
if (joinIndex >= 0 && joinIndex + 1 < segments.size) {
|
||||||
|
val token = segments[joinIndex + 1].trim()
|
||||||
|
if (token.isNotBlank()) return token
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun Intent?.extractNotificationOpenPayload(): Pair<Long, Long?>? {
|
||||||
|
val source = this ?: return null
|
||||||
|
val chatId = source.getLongExtra(NotificationIntentExtras.EXTRA_CHAT_ID, -1L)
|
||||||
|
if (chatId <= 0L) return null
|
||||||
|
val rawMessageId = source.getLongExtra(NotificationIntentExtras.EXTRA_MESSAGE_ID, -1L)
|
||||||
|
val messageId = rawMessageId.takeIf { it > 0L }
|
||||||
|
return chatId to messageId
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,62 @@
|
|||||||
package ru.daemonlord.messenger
|
package ru.daemonlord.messenger
|
||||||
|
|
||||||
import android.app.Application
|
import android.app.Application
|
||||||
|
import android.os.Build
|
||||||
|
import coil.ImageLoader
|
||||||
|
import coil.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 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
|
@HiltAndroidApp
|
||||||
class MessengerApplication : Application()
|
class MessengerApplication : Application(), ImageLoaderFactory {
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
lateinit var pushTokenSyncManager: PushTokenSyncManager
|
||||||
|
|
||||||
|
override fun onCreate() {
|
||||||
|
super.onCreate()
|
||||||
|
if (BuildConfig.DEBUG) {
|
||||||
|
Timber.plant(Timber.DebugTree())
|
||||||
|
}
|
||||||
|
FirebaseCrashlytics.getInstance().setCrashlyticsCollectionEnabled(!BuildConfig.DEBUG)
|
||||||
|
NotificationChannels.ensureCreated(this)
|
||||||
|
pushTokenSyncManager.triggerBestEffortSync()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun newImageLoader(): ImageLoader {
|
||||||
|
val diskCacheDir = File(cacheDir, "coil_images")
|
||||||
|
if (!diskCacheDir.exists()) {
|
||||||
|
diskCacheDir.mkdirs()
|
||||||
|
}
|
||||||
|
return ImageLoader.Builder(this)
|
||||||
|
.components {
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
|
||||||
|
add(ImageDecoderDecoder.Factory())
|
||||||
|
} else {
|
||||||
|
add(GifDecoder.Factory())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.memoryCache {
|
||||||
|
MemoryCache.Builder(this)
|
||||||
|
.maxSizePercent(0.25)
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
.diskCache {
|
||||||
|
DiskCache.Builder()
|
||||||
|
.directory(diskCacheDir)
|
||||||
|
.maxSizeBytes(250L * 1024L * 1024L)
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
.respectCacheHeaders(false)
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
package ru.daemonlord.messenger.core.notifications
|
||||||
|
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
@Singleton
|
||||||
|
class ActiveChatTracker @Inject constructor() {
|
||||||
|
private val _activeChatId = MutableStateFlow<Long?>(null)
|
||||||
|
val activeChatId: StateFlow<Long?> = _activeChatId.asStateFlow()
|
||||||
|
|
||||||
|
fun setActiveChat(chatId: Long) {
|
||||||
|
_activeChatId.value = chatId
|
||||||
|
}
|
||||||
|
|
||||||
|
fun clearActiveChat(chatId: Long) {
|
||||||
|
if (_activeChatId.value == chatId) {
|
||||||
|
_activeChatId.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun clear() {
|
||||||
|
_activeChatId.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
package ru.daemonlord.messenger.core.notifications
|
||||||
|
|
||||||
|
import android.app.NotificationChannel
|
||||||
|
import android.app.NotificationManager
|
||||||
|
import android.content.Context
|
||||||
|
import android.os.Build
|
||||||
|
|
||||||
|
object NotificationChannels {
|
||||||
|
const val CHANNEL_MESSAGES = "messages"
|
||||||
|
const val CHANNEL_MENTIONS = "mentions"
|
||||||
|
const val CHANNEL_SYSTEM = "system"
|
||||||
|
|
||||||
|
fun ensureCreated(context: Context) {
|
||||||
|
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
|
||||||
|
val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||||
|
val channels = listOf(
|
||||||
|
NotificationChannel(
|
||||||
|
CHANNEL_MESSAGES,
|
||||||
|
"Messages",
|
||||||
|
NotificationManager.IMPORTANCE_DEFAULT,
|
||||||
|
).apply {
|
||||||
|
description = "New chat messages"
|
||||||
|
},
|
||||||
|
NotificationChannel(
|
||||||
|
CHANNEL_MENTIONS,
|
||||||
|
"Mentions",
|
||||||
|
NotificationManager.IMPORTANCE_HIGH,
|
||||||
|
).apply {
|
||||||
|
description = "Mentions in chats"
|
||||||
|
},
|
||||||
|
NotificationChannel(
|
||||||
|
CHANNEL_SYSTEM,
|
||||||
|
"System",
|
||||||
|
NotificationManager.IMPORTANCE_LOW,
|
||||||
|
).apply {
|
||||||
|
description = "Service and system updates"
|
||||||
|
},
|
||||||
|
)
|
||||||
|
manager.createNotificationChannels(channels)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
package ru.daemonlord.messenger.core.notifications
|
||||||
|
|
||||||
|
import android.app.PendingIntent
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import androidx.core.app.NotificationCompat
|
||||||
|
import androidx.core.app.NotificationManagerCompat
|
||||||
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
|
import ru.daemonlord.messenger.MainActivity
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
import kotlin.math.abs
|
||||||
|
|
||||||
|
@Singleton
|
||||||
|
class NotificationDispatcher @Inject constructor(
|
||||||
|
@ApplicationContext private val context: Context,
|
||||||
|
) {
|
||||||
|
private val chatStates = linkedMapOf<Long, ChatNotificationState>()
|
||||||
|
|
||||||
|
fun showChatMessage(payload: ChatNotificationPayload) {
|
||||||
|
NotificationChannels.ensureCreated(context)
|
||||||
|
val channelId = if (payload.isMention) {
|
||||||
|
NotificationChannels.CHANNEL_MENTIONS
|
||||||
|
} else {
|
||||||
|
NotificationChannels.CHANNEL_MESSAGES
|
||||||
|
}
|
||||||
|
|
||||||
|
val state = synchronized(chatStates) {
|
||||||
|
val existing = chatStates[payload.chatId]
|
||||||
|
if (existing != null && payload.messageId != null && existing.lastMessageId == payload.messageId) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val updated = (existing ?: ChatNotificationState(title = payload.title))
|
||||||
|
.copy(title = payload.title)
|
||||||
|
.appendMessage(payload.body, payload.messageId)
|
||||||
|
chatStates[payload.chatId] = updated
|
||||||
|
updated
|
||||||
|
}
|
||||||
|
|
||||||
|
val openIntent = Intent(context, MainActivity::class.java)
|
||||||
|
.putExtra(NotificationIntentExtras.EXTRA_CHAT_ID, payload.chatId)
|
||||||
|
.putExtra(NotificationIntentExtras.EXTRA_MESSAGE_ID, payload.messageId ?: -1L)
|
||||||
|
.addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP)
|
||||||
|
val pendingIntent = PendingIntent.getActivity(
|
||||||
|
context,
|
||||||
|
chatNotificationId(payload.chatId),
|
||||||
|
openIntent,
|
||||||
|
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
||||||
|
)
|
||||||
|
|
||||||
|
val contentText = when {
|
||||||
|
state.unreadCount <= 1 -> state.lines.firstOrNull() ?: payload.body
|
||||||
|
else -> "${state.unreadCount} new messages"
|
||||||
|
}
|
||||||
|
val inboxStyle = NotificationCompat.InboxStyle()
|
||||||
|
.setBigContentTitle(state.title)
|
||||||
|
.setSummaryText("${state.unreadCount} messages")
|
||||||
|
state.lines.reversed().forEach { inboxStyle.addLine(it) }
|
||||||
|
|
||||||
|
val notification = NotificationCompat.Builder(context, channelId)
|
||||||
|
.setSmallIcon(android.R.drawable.ic_dialog_info)
|
||||||
|
.setContentTitle(state.title)
|
||||||
|
.setContentText(contentText)
|
||||||
|
.setStyle(inboxStyle)
|
||||||
|
.setAutoCancel(true)
|
||||||
|
.setContentIntent(pendingIntent)
|
||||||
|
.setGroup(GROUP_KEY_CHATS)
|
||||||
|
.setOnlyAlertOnce(false)
|
||||||
|
.setNumber(state.unreadCount)
|
||||||
|
.setPriority(
|
||||||
|
if (payload.isMention) NotificationCompat.PRIORITY_HIGH else NotificationCompat.PRIORITY_DEFAULT
|
||||||
|
)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
val manager = NotificationManagerCompat.from(context)
|
||||||
|
manager.notify(chatNotificationId(payload.chatId), notification)
|
||||||
|
showSummaryNotification(manager)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun clearChatNotifications(chatId: Long) {
|
||||||
|
val manager = NotificationManagerCompat.from(context)
|
||||||
|
synchronized(chatStates) {
|
||||||
|
chatStates.remove(chatId)
|
||||||
|
}
|
||||||
|
manager.cancel(chatNotificationId(chatId))
|
||||||
|
showSummaryNotification(manager)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun showSummaryNotification(manager: NotificationManagerCompat) {
|
||||||
|
val snapshot = synchronized(chatStates) { chatStates.values.toList() }
|
||||||
|
if (snapshot.isEmpty()) {
|
||||||
|
manager.cancel(SUMMARY_NOTIFICATION_ID)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val totalUnread = snapshot.sumOf { it.unreadCount }
|
||||||
|
val inboxStyle = NotificationCompat.InboxStyle()
|
||||||
|
.setBigContentTitle("Benya Messenger")
|
||||||
|
.setSummaryText("$totalUnread messages")
|
||||||
|
snapshot.take(6).forEach { state ->
|
||||||
|
val preview = state.lines.firstOrNull().orEmpty()
|
||||||
|
val line = if (preview.isBlank()) {
|
||||||
|
"${state.title} (${state.unreadCount})"
|
||||||
|
} else {
|
||||||
|
"${state.title}: $preview (${state.unreadCount})"
|
||||||
|
}
|
||||||
|
inboxStyle.addLine(line)
|
||||||
|
}
|
||||||
|
val summary = NotificationCompat.Builder(context, NotificationChannels.CHANNEL_MESSAGES)
|
||||||
|
.setSmallIcon(android.R.drawable.ic_dialog_info)
|
||||||
|
.setContentTitle("Benya Messenger")
|
||||||
|
.setContentText("$totalUnread new messages in ${snapshot.size} chats")
|
||||||
|
.setStyle(inboxStyle)
|
||||||
|
.setGroup(GROUP_KEY_CHATS)
|
||||||
|
.setGroupSummary(true)
|
||||||
|
.setAutoCancel(true)
|
||||||
|
.build()
|
||||||
|
manager.notify(SUMMARY_NOTIFICATION_ID, summary)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun chatNotificationId(chatId: Long): Int {
|
||||||
|
return abs((chatId * 1_000_003L).toInt())
|
||||||
|
}
|
||||||
|
|
||||||
|
private data class ChatNotificationState(
|
||||||
|
val title: String,
|
||||||
|
val unreadCount: Int = 0,
|
||||||
|
val lines: List<String> = emptyList(),
|
||||||
|
val lastMessageId: Long? = null,
|
||||||
|
) {
|
||||||
|
fun appendMessage(body: String, messageId: Long?): ChatNotificationState {
|
||||||
|
val normalized = body.trim().ifBlank { "New message" }
|
||||||
|
val updatedLines = buildList {
|
||||||
|
add(normalized)
|
||||||
|
lines.forEach { add(it) }
|
||||||
|
}.distinct().take(MAX_LINES)
|
||||||
|
return copy(
|
||||||
|
unreadCount = unreadCount + 1,
|
||||||
|
lines = updatedLines,
|
||||||
|
lastMessageId = messageId ?: lastMessageId,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
private const val GROUP_KEY_CHATS = "messenger_chats_group"
|
||||||
|
private const val SUMMARY_NOTIFICATION_ID = 0x4D53_4752 // "MSGR"
|
||||||
|
private const val MAX_LINES = 5
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
package ru.daemonlord.messenger.core.notifications
|
||||||
|
|
||||||
|
data class ChatNotificationPayload(
|
||||||
|
val chatId: Long,
|
||||||
|
val messageId: Long?,
|
||||||
|
val title: String,
|
||||||
|
val body: String,
|
||||||
|
val isMention: Boolean = false,
|
||||||
|
)
|
||||||
|
|
||||||
|
object NotificationIntentExtras {
|
||||||
|
const val EXTRA_CHAT_ID = "extra_chat_id"
|
||||||
|
const val EXTRA_MESSAGE_ID = "extra_message_id"
|
||||||
|
}
|
||||||
@@ -20,10 +20,40 @@ class DataStoreTokenRepository @Inject constructor(
|
|||||||
preferences.toTokenBundleOrNull()
|
preferences.toTokenBundleOrNull()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun observeAccounts(): Flow<List<StoredAccount>> {
|
||||||
|
return observeTokens().map { tokens ->
|
||||||
|
if (tokens == null) emptyList() else {
|
||||||
|
val userId = tokens.accessToken.extractUserIdFromJwt() ?: return@map emptyList()
|
||||||
|
listOf(
|
||||||
|
StoredAccount(
|
||||||
|
userId = userId,
|
||||||
|
email = null,
|
||||||
|
name = "User #$userId",
|
||||||
|
username = null,
|
||||||
|
avatarUrl = null,
|
||||||
|
lastActiveAt = tokens.savedAtMillis,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun observeActiveUserId(): Flow<Long?> {
|
||||||
|
return observeTokens().map { it?.accessToken?.extractUserIdFromJwt() }
|
||||||
|
}
|
||||||
|
|
||||||
override suspend fun getTokens(): TokenBundle? {
|
override suspend fun getTokens(): TokenBundle? {
|
||||||
return observeTokens().first()
|
return observeTokens().first()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override suspend fun getAccounts(): List<StoredAccount> {
|
||||||
|
return observeAccounts().first()
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun getActiveUserId(): Long? {
|
||||||
|
return observeActiveUserId().first()
|
||||||
|
}
|
||||||
|
|
||||||
override suspend fun saveTokens(tokens: TokenBundle) {
|
override suspend fun saveTokens(tokens: TokenBundle) {
|
||||||
dataStore.edit { preferences ->
|
dataStore.edit { preferences ->
|
||||||
preferences[ACCESS_TOKEN_KEY] = tokens.accessToken
|
preferences[ACCESS_TOKEN_KEY] = tokens.accessToken
|
||||||
@@ -32,6 +62,20 @@ class DataStoreTokenRepository @Inject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override suspend fun upsertAccount(account: StoredAccount) {
|
||||||
|
// DataStoreTokenRepository is not used in production DI currently.
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun switchAccount(userId: Long): Boolean {
|
||||||
|
return getActiveUserId() == userId
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun removeAccount(userId: Long) {
|
||||||
|
if (getActiveUserId() == userId) {
|
||||||
|
clearTokens()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
override suspend fun clearTokens() {
|
override suspend fun clearTokens() {
|
||||||
dataStore.edit { preferences ->
|
dataStore.edit { preferences ->
|
||||||
preferences.remove(ACCESS_TOKEN_KEY)
|
preferences.remove(ACCESS_TOKEN_KEY)
|
||||||
@@ -40,6 +84,10 @@ class DataStoreTokenRepository @Inject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override suspend fun clearAllTokens() {
|
||||||
|
clearTokens()
|
||||||
|
}
|
||||||
|
|
||||||
private fun Preferences.toTokenBundleOrNull(): TokenBundle? {
|
private fun Preferences.toTokenBundleOrNull(): TokenBundle? {
|
||||||
val access = this[ACCESS_TOKEN_KEY]
|
val access = this[ACCESS_TOKEN_KEY]
|
||||||
val refresh = this[REFRESH_TOKEN_KEY]
|
val refresh = this[REFRESH_TOKEN_KEY]
|
||||||
@@ -56,6 +104,32 @@ class DataStoreTokenRepository @Inject constructor(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun String.extractUserIdFromJwt(): Long? {
|
||||||
|
val payload = split('.').getOrNull(1) ?: return null
|
||||||
|
val normalized = payload
|
||||||
|
.replace('-', '+')
|
||||||
|
.replace('_', '/')
|
||||||
|
.let { source ->
|
||||||
|
when (source.length % 4) {
|
||||||
|
0 -> source
|
||||||
|
2 -> source + "=="
|
||||||
|
3 -> source + "="
|
||||||
|
else -> return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return runCatching {
|
||||||
|
val json = String(java.util.Base64.getDecoder().decode(normalized), Charsets.UTF_8)
|
||||||
|
val marker = "\"sub\":\""
|
||||||
|
val start = json.indexOf(marker)
|
||||||
|
if (start < 0) null
|
||||||
|
else {
|
||||||
|
val valueStart = start + marker.length
|
||||||
|
val valueEnd = json.indexOf('"', valueStart)
|
||||||
|
if (valueEnd <= valueStart) null else json.substring(valueStart, valueEnd).toLongOrNull()
|
||||||
|
}
|
||||||
|
}.getOrNull()
|
||||||
|
}
|
||||||
|
|
||||||
private companion object {
|
private companion object {
|
||||||
val ACCESS_TOKEN_KEY = stringPreferencesKey("access_token")
|
val ACCESS_TOKEN_KEY = stringPreferencesKey("access_token")
|
||||||
val REFRESH_TOKEN_KEY = stringPreferencesKey("refresh_token")
|
val REFRESH_TOKEN_KEY = stringPreferencesKey("refresh_token")
|
||||||
|
|||||||
@@ -0,0 +1,292 @@
|
|||||||
|
package ru.daemonlord.messenger.core.token
|
||||||
|
|
||||||
|
import android.content.SharedPreferences
|
||||||
|
import android.util.Base64
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import org.json.JSONArray
|
||||||
|
import org.json.JSONObject
|
||||||
|
import ru.daemonlord.messenger.di.TokenPrefs
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
@Singleton
|
||||||
|
class EncryptedPrefsTokenRepository @Inject constructor(
|
||||||
|
@TokenPrefs private val sharedPreferences: SharedPreferences,
|
||||||
|
) : TokenRepository {
|
||||||
|
|
||||||
|
private val tokensFlow = MutableStateFlow<TokenBundle?>(null)
|
||||||
|
private val accountsFlow = MutableStateFlow<List<StoredAccount>>(emptyList())
|
||||||
|
private val activeUserIdFlow = MutableStateFlow<Long?>(null)
|
||||||
|
|
||||||
|
init {
|
||||||
|
migrateLegacyIfNeeded()
|
||||||
|
refreshFlows()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun observeTokens(): Flow<TokenBundle?> = tokensFlow.asStateFlow()
|
||||||
|
|
||||||
|
override fun observeAccounts(): Flow<List<StoredAccount>> = accountsFlow.asStateFlow()
|
||||||
|
|
||||||
|
override fun observeActiveUserId(): Flow<Long?> = activeUserIdFlow.asStateFlow()
|
||||||
|
|
||||||
|
override suspend fun getTokens(): TokenBundle? = tokensFlow.value
|
||||||
|
|
||||||
|
override suspend fun getAccounts(): List<StoredAccount> = accountsFlow.value
|
||||||
|
|
||||||
|
override suspend fun getActiveUserId(): Long? = activeUserIdFlow.value
|
||||||
|
|
||||||
|
override suspend fun saveTokens(tokens: TokenBundle) {
|
||||||
|
val userId = tokens.accessToken.extractUserIdFromJwt()
|
||||||
|
?: activeUserIdFlow.value
|
||||||
|
?: return
|
||||||
|
val allTokens = readAllTokenEntries().toMutableMap()
|
||||||
|
allTokens[userId] = tokens
|
||||||
|
writeAllTokenEntries(allTokens)
|
||||||
|
writeActiveUserId(userId)
|
||||||
|
ensureAccountPlaceholder(userId = userId, lastActiveAt = tokens.savedAtMillis)
|
||||||
|
refreshFlows()
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun upsertAccount(account: StoredAccount) {
|
||||||
|
val accounts = readAccounts().associateBy { it.userId }.toMutableMap()
|
||||||
|
val existing = accounts[account.userId]
|
||||||
|
accounts[account.userId] = account.copy(
|
||||||
|
lastActiveAt = maxOf(existing?.lastActiveAt ?: 0L, account.lastActiveAt),
|
||||||
|
)
|
||||||
|
writeAccounts(accounts.values.toList())
|
||||||
|
refreshFlows()
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun switchAccount(userId: Long): Boolean {
|
||||||
|
val allTokens = readAllTokenEntries()
|
||||||
|
if (!allTokens.containsKey(userId)) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
writeActiveUserId(userId)
|
||||||
|
refreshFlows()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun removeAccount(userId: Long) {
|
||||||
|
val allTokens = readAllTokenEntries().toMutableMap()
|
||||||
|
allTokens.remove(userId)
|
||||||
|
writeAllTokenEntries(allTokens)
|
||||||
|
|
||||||
|
val accounts = readAccounts().filterNot { it.userId == userId }
|
||||||
|
writeAccounts(accounts)
|
||||||
|
|
||||||
|
val active = readActiveUserId()
|
||||||
|
if (active == userId) {
|
||||||
|
val nextUserId = allTokens.entries
|
||||||
|
.maxByOrNull { it.value.savedAtMillis }
|
||||||
|
?.key
|
||||||
|
writeActiveUserId(nextUserId)
|
||||||
|
}
|
||||||
|
refreshFlows()
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun clearTokens() {
|
||||||
|
val active = readActiveUserId() ?: return
|
||||||
|
removeAccount(active)
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun clearAllTokens() {
|
||||||
|
sharedPreferences.edit()
|
||||||
|
.remove(TOKENS_JSON_KEY)
|
||||||
|
.remove(ACCOUNTS_JSON_KEY)
|
||||||
|
.remove(ACTIVE_USER_ID_KEY)
|
||||||
|
.remove(ACCESS_TOKEN_KEY)
|
||||||
|
.remove(REFRESH_TOKEN_KEY)
|
||||||
|
.remove(SAVED_AT_KEY)
|
||||||
|
.apply()
|
||||||
|
refreshFlows()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun refreshFlows() {
|
||||||
|
val activeUserId = readActiveUserId()
|
||||||
|
activeUserIdFlow.value = activeUserId
|
||||||
|
tokensFlow.value = activeUserId?.let { readAllTokenEntries()[it] }
|
||||||
|
accountsFlow.value = readAccounts().sortedByDescending { it.lastActiveAt }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun migrateLegacyIfNeeded() {
|
||||||
|
val hasModernStorage = sharedPreferences.contains(TOKENS_JSON_KEY)
|
||||||
|
if (hasModernStorage) return
|
||||||
|
|
||||||
|
val legacyAccess = sharedPreferences.getString(ACCESS_TOKEN_KEY, null)
|
||||||
|
val legacyRefresh = sharedPreferences.getString(REFRESH_TOKEN_KEY, null)
|
||||||
|
val legacySavedAt = sharedPreferences.getLong(SAVED_AT_KEY, -1L)
|
||||||
|
if (legacyAccess.isNullOrBlank() || legacyRefresh.isNullOrBlank() || legacySavedAt <= 0L) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val userId = legacyAccess.extractUserIdFromJwt() ?: return
|
||||||
|
val token = TokenBundle(
|
||||||
|
accessToken = legacyAccess,
|
||||||
|
refreshToken = legacyRefresh,
|
||||||
|
savedAtMillis = legacySavedAt,
|
||||||
|
)
|
||||||
|
writeAllTokenEntries(mapOf(userId to token))
|
||||||
|
writeActiveUserId(userId)
|
||||||
|
writeAccounts(
|
||||||
|
listOf(
|
||||||
|
StoredAccount(
|
||||||
|
userId = userId,
|
||||||
|
email = null,
|
||||||
|
name = "User #$userId",
|
||||||
|
username = null,
|
||||||
|
avatarUrl = null,
|
||||||
|
lastActiveAt = legacySavedAt,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
sharedPreferences.edit()
|
||||||
|
.remove(ACCESS_TOKEN_KEY)
|
||||||
|
.remove(REFRESH_TOKEN_KEY)
|
||||||
|
.remove(SAVED_AT_KEY)
|
||||||
|
.apply()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun ensureAccountPlaceholder(userId: Long, lastActiveAt: Long) {
|
||||||
|
val accounts = readAccounts().associateBy { it.userId }.toMutableMap()
|
||||||
|
val existing = accounts[userId]
|
||||||
|
if (existing == null) {
|
||||||
|
accounts[userId] = StoredAccount(
|
||||||
|
userId = userId,
|
||||||
|
email = null,
|
||||||
|
name = "User #$userId",
|
||||||
|
username = null,
|
||||||
|
avatarUrl = null,
|
||||||
|
lastActiveAt = lastActiveAt,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
accounts[userId] = existing.copy(lastActiveAt = maxOf(existing.lastActiveAt, lastActiveAt))
|
||||||
|
}
|
||||||
|
writeAccounts(accounts.values.toList())
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun readActiveUserId(): Long? {
|
||||||
|
val value = sharedPreferences.getLong(ACTIVE_USER_ID_KEY, -1L)
|
||||||
|
return value.takeIf { it > 0L }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun writeActiveUserId(userId: Long?) {
|
||||||
|
sharedPreferences.edit().apply {
|
||||||
|
if (userId == null) {
|
||||||
|
remove(ACTIVE_USER_ID_KEY)
|
||||||
|
} else {
|
||||||
|
putLong(ACTIVE_USER_ID_KEY, userId)
|
||||||
|
}
|
||||||
|
}.apply()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun readAllTokenEntries(): Map<Long, TokenBundle> {
|
||||||
|
val raw = sharedPreferences.getString(TOKENS_JSON_KEY, null).orEmpty()
|
||||||
|
if (raw.isBlank()) return emptyMap()
|
||||||
|
return runCatching {
|
||||||
|
val root = JSONObject(raw)
|
||||||
|
root.keys().asSequence().mapNotNull { key ->
|
||||||
|
val userId = key.toLongOrNull() ?: return@mapNotNull null
|
||||||
|
val node = root.optJSONObject(key) ?: return@mapNotNull null
|
||||||
|
val access = node.optString("access", "")
|
||||||
|
val refresh = node.optString("refresh", "")
|
||||||
|
val savedAt = node.optLong("savedAt", -1L)
|
||||||
|
if (access.isBlank() || refresh.isBlank() || savedAt <= 0L) {
|
||||||
|
null
|
||||||
|
} else {
|
||||||
|
userId to TokenBundle(access, refresh, savedAt)
|
||||||
|
}
|
||||||
|
}.toMap()
|
||||||
|
}.getOrDefault(emptyMap())
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun writeAllTokenEntries(tokens: Map<Long, TokenBundle>) {
|
||||||
|
val root = JSONObject()
|
||||||
|
tokens.forEach { (userId, token) ->
|
||||||
|
root.put(
|
||||||
|
userId.toString(),
|
||||||
|
JSONObject().apply {
|
||||||
|
put("access", token.accessToken)
|
||||||
|
put("refresh", token.refreshToken)
|
||||||
|
put("savedAt", token.savedAtMillis)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
sharedPreferences.edit().putString(TOKENS_JSON_KEY, root.toString()).apply()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun readAccounts(): List<StoredAccount> {
|
||||||
|
val raw = sharedPreferences.getString(ACCOUNTS_JSON_KEY, null).orEmpty()
|
||||||
|
if (raw.isBlank()) return emptyList()
|
||||||
|
return runCatching {
|
||||||
|
val array = JSONArray(raw)
|
||||||
|
buildList {
|
||||||
|
for (index in 0 until array.length()) {
|
||||||
|
val node = array.optJSONObject(index) ?: continue
|
||||||
|
val userId = node.optLong("userId", -1L)
|
||||||
|
if (userId <= 0L) continue
|
||||||
|
add(
|
||||||
|
StoredAccount(
|
||||||
|
userId = userId,
|
||||||
|
email = node.optString("email", "").ifBlank { null },
|
||||||
|
name = node.optString("name", "User #$userId").ifBlank { "User #$userId" },
|
||||||
|
username = node.optString("username", "").ifBlank { null },
|
||||||
|
avatarUrl = node.optString("avatarUrl", "").ifBlank { null },
|
||||||
|
lastActiveAt = node.optLong("lastActiveAt", 0L),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}.getOrDefault(emptyList())
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun writeAccounts(accounts: List<StoredAccount>) {
|
||||||
|
val array = JSONArray()
|
||||||
|
accounts.forEach { account ->
|
||||||
|
array.put(
|
||||||
|
JSONObject().apply {
|
||||||
|
put("userId", account.userId)
|
||||||
|
put("email", account.email.orEmpty())
|
||||||
|
put("name", account.name)
|
||||||
|
put("username", account.username.orEmpty())
|
||||||
|
put("avatarUrl", account.avatarUrl.orEmpty())
|
||||||
|
put("lastActiveAt", account.lastActiveAt)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
sharedPreferences.edit().putString(ACCOUNTS_JSON_KEY, array.toString()).apply()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun String.extractUserIdFromJwt(): Long? {
|
||||||
|
val parts = split('.')
|
||||||
|
if (parts.size < 2) return null
|
||||||
|
val payload = parts[1]
|
||||||
|
val normalized = payload
|
||||||
|
.replace('-', '+')
|
||||||
|
.replace('_', '/')
|
||||||
|
.let { source ->
|
||||||
|
when (source.length % 4) {
|
||||||
|
0 -> source
|
||||||
|
2 -> source + "=="
|
||||||
|
3 -> source + "="
|
||||||
|
else -> return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return runCatching {
|
||||||
|
val payloadJson = String(Base64.decode(normalized, Base64.DEFAULT), Charsets.UTF_8)
|
||||||
|
JSONObject(payloadJson).optString("sub").toLongOrNull()
|
||||||
|
}.getOrNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val ACCESS_TOKEN_KEY = "access_token"
|
||||||
|
const val REFRESH_TOKEN_KEY = "refresh_token"
|
||||||
|
const val SAVED_AT_KEY = "tokens_saved_at"
|
||||||
|
|
||||||
|
const val TOKENS_JSON_KEY = "tokens_json"
|
||||||
|
const val ACCOUNTS_JSON_KEY = "accounts_json"
|
||||||
|
const val ACTIVE_USER_ID_KEY = "active_user_id"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 {
|
interface TokenRepository {
|
||||||
fun observeTokens(): Flow<TokenBundle?>
|
fun observeTokens(): Flow<TokenBundle?>
|
||||||
|
fun observeAccounts(): Flow<List<StoredAccount>>
|
||||||
|
fun observeActiveUserId(): Flow<Long?>
|
||||||
suspend fun getTokens(): TokenBundle?
|
suspend fun getTokens(): TokenBundle?
|
||||||
|
suspend fun getAccounts(): List<StoredAccount>
|
||||||
|
suspend fun getActiveUserId(): Long?
|
||||||
suspend fun saveTokens(tokens: TokenBundle)
|
suspend fun saveTokens(tokens: TokenBundle)
|
||||||
|
suspend fun upsertAccount(account: StoredAccount)
|
||||||
|
suspend fun switchAccount(userId: Long): Boolean
|
||||||
|
suspend fun removeAccount(userId: Long)
|
||||||
suspend fun clearTokens()
|
suspend fun clearTokens()
|
||||||
|
suspend fun clearAllTokens()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,15 +1,38 @@
|
|||||||
package ru.daemonlord.messenger.data.auth.api
|
package ru.daemonlord.messenger.data.auth.api
|
||||||
|
|
||||||
import ru.daemonlord.messenger.data.auth.dto.AuthUserDto
|
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.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.RefreshTokenRequestDto
|
||||||
import ru.daemonlord.messenger.data.auth.dto.TokenResponseDto
|
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.Headers
|
||||||
import retrofit2.http.Body
|
import retrofit2.http.Body
|
||||||
import retrofit2.http.GET
|
import retrofit2.http.GET
|
||||||
import retrofit2.http.POST
|
import retrofit2.http.POST
|
||||||
|
import retrofit2.http.DELETE
|
||||||
|
import retrofit2.http.Path
|
||||||
|
import retrofit2.http.Query
|
||||||
|
|
||||||
interface AuthApiService {
|
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")
|
@Headers("No-Auth: true")
|
||||||
@POST("/api/v1/auth/login")
|
@POST("/api/v1/auth/login")
|
||||||
suspend fun login(@Body request: LoginRequestDto): TokenResponseDto
|
suspend fun login(@Body request: LoginRequestDto): TokenResponseDto
|
||||||
@@ -20,4 +43,44 @@ interface AuthApiService {
|
|||||||
|
|
||||||
@GET("/api/v1/auth/me")
|
@GET("/api/v1/auth/me")
|
||||||
suspend fun me(): AuthUserDto
|
suspend fun me(): AuthUserDto
|
||||||
|
|
||||||
|
@Headers("No-Auth: true")
|
||||||
|
@POST("/api/v1/auth/verify-email")
|
||||||
|
suspend fun verifyEmail(@Body request: VerifyEmailRequestDto): MessageResponseDto
|
||||||
|
|
||||||
|
@Headers("No-Auth: true")
|
||||||
|
@POST("/api/v1/auth/request-password-reset")
|
||||||
|
suspend fun requestPasswordReset(@Body request: RequestPasswordResetDto): MessageResponseDto
|
||||||
|
|
||||||
|
@Headers("No-Auth: true")
|
||||||
|
@POST("/api/v1/auth/resend-verification")
|
||||||
|
suspend fun resendVerification(@Body request: ResendVerificationRequestDto): MessageResponseDto
|
||||||
|
|
||||||
|
@Headers("No-Auth: true")
|
||||||
|
@POST("/api/v1/auth/reset-password")
|
||||||
|
suspend fun resetPassword(@Body request: ResetPasswordRequestDto): MessageResponseDto
|
||||||
|
|
||||||
|
@GET("/api/v1/auth/sessions")
|
||||||
|
suspend fun sessions(): List<AuthSessionDto>
|
||||||
|
|
||||||
|
@DELETE("/api/v1/auth/sessions/{jti}")
|
||||||
|
suspend fun revokeSession(@Path("jti") jti: String)
|
||||||
|
|
||||||
|
@DELETE("/api/v1/auth/sessions")
|
||||||
|
suspend fun revokeAllSessions()
|
||||||
|
|
||||||
|
@POST("/api/v1/auth/2fa/setup")
|
||||||
|
suspend fun setupTwoFactor(): TwoFactorSetupDto
|
||||||
|
|
||||||
|
@POST("/api/v1/auth/2fa/enable")
|
||||||
|
suspend fun enableTwoFactor(@Body request: TwoFactorCodeRequestDto): MessageResponseDto
|
||||||
|
|
||||||
|
@POST("/api/v1/auth/2fa/disable")
|
||||||
|
suspend fun disableTwoFactor(@Body request: TwoFactorCodeRequestDto): MessageResponseDto
|
||||||
|
|
||||||
|
@GET("/api/v1/auth/2fa/recovery-codes/status")
|
||||||
|
suspend fun twoFactorRecoveryStatus(): TwoFactorRecoveryStatusDto
|
||||||
|
|
||||||
|
@POST("/api/v1/auth/2fa/recovery-codes/regenerate")
|
||||||
|
suspend fun regenerateTwoFactorRecoveryCodes(@Body request: TwoFactorCodeRequestDto): TwoFactorRecoveryCodesDto
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,10 @@ import kotlinx.serialization.Serializable
|
|||||||
data class LoginRequestDto(
|
data class LoginRequestDto(
|
||||||
val email: String,
|
val email: String,
|
||||||
val password: String,
|
val password: String,
|
||||||
|
@SerialName("otp_code")
|
||||||
|
val otpCode: String? = null,
|
||||||
|
@SerialName("recovery_code")
|
||||||
|
val recoveryCode: String? = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
@@ -31,13 +35,105 @@ data class AuthUserDto(
|
|||||||
val email: String,
|
val email: String,
|
||||||
val name: String,
|
val name: String,
|
||||||
val username: String,
|
val username: String,
|
||||||
|
val bio: String? = null,
|
||||||
@SerialName("avatar_url")
|
@SerialName("avatar_url")
|
||||||
val avatarUrl: String? = null,
|
val avatarUrl: String? = null,
|
||||||
@SerialName("email_verified")
|
@SerialName("email_verified")
|
||||||
val emailVerified: Boolean,
|
val emailVerified: Boolean,
|
||||||
|
@SerialName("twofa_enabled")
|
||||||
|
val twofaEnabled: Boolean = false,
|
||||||
|
@SerialName("privacy_private_messages")
|
||||||
|
val privacyPrivateMessages: String? = null,
|
||||||
|
@SerialName("privacy_last_seen")
|
||||||
|
val privacyLastSeen: String? = null,
|
||||||
|
@SerialName("privacy_avatar")
|
||||||
|
val privacyAvatar: String? = null,
|
||||||
|
@SerialName("privacy_group_invites")
|
||||||
|
val privacyGroupInvites: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class AuthSessionDto(
|
||||||
|
val jti: String,
|
||||||
|
@SerialName("created_at")
|
||||||
|
val createdAt: String,
|
||||||
|
@SerialName("ip_address")
|
||||||
|
val ipAddress: String? = null,
|
||||||
|
@SerialName("user_agent")
|
||||||
|
val userAgent: String? = null,
|
||||||
|
val current: Boolean? = null,
|
||||||
|
@SerialName("token_type")
|
||||||
|
val tokenType: String? = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
data class ErrorResponseDto(
|
data class ErrorResponseDto(
|
||||||
val detail: String? = null,
|
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>,
|
||||||
|
)
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
package ru.daemonlord.messenger.data.auth.repository
|
||||||
|
|
||||||
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import ru.daemonlord.messenger.data.chat.local.db.MessengerDatabase
|
||||||
|
import ru.daemonlord.messenger.di.IoDispatcher
|
||||||
|
import ru.daemonlord.messenger.domain.auth.repository.SessionCleanupRepository
|
||||||
|
import ru.daemonlord.messenger.domain.notifications.repository.NotificationSettingsRepository
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
@Singleton
|
||||||
|
class DefaultSessionCleanupRepository @Inject constructor(
|
||||||
|
private val database: MessengerDatabase,
|
||||||
|
private val notificationSettingsRepository: NotificationSettingsRepository,
|
||||||
|
@IoDispatcher private val ioDispatcher: CoroutineDispatcher,
|
||||||
|
) : SessionCleanupRepository {
|
||||||
|
|
||||||
|
override suspend fun clearLocalSessionData() = withContext(ioDispatcher) {
|
||||||
|
database.clearAllTables()
|
||||||
|
notificationSettingsRepository.clearChatOverrides()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -2,19 +2,26 @@ package ru.daemonlord.messenger.data.auth.repository
|
|||||||
|
|
||||||
import kotlinx.coroutines.CoroutineDispatcher
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import retrofit2.HttpException
|
|
||||||
import ru.daemonlord.messenger.core.token.TokenBundle
|
import ru.daemonlord.messenger.core.token.TokenBundle
|
||||||
|
import ru.daemonlord.messenger.core.token.StoredAccount
|
||||||
import ru.daemonlord.messenger.core.token.TokenRepository
|
import ru.daemonlord.messenger.core.token.TokenRepository
|
||||||
import ru.daemonlord.messenger.data.auth.api.AuthApiService
|
import ru.daemonlord.messenger.data.auth.api.AuthApiService
|
||||||
import ru.daemonlord.messenger.data.auth.dto.AuthUserDto
|
import ru.daemonlord.messenger.data.auth.dto.AuthUserDto
|
||||||
|
import ru.daemonlord.messenger.data.auth.dto.CheckEmailStatusDto
|
||||||
|
import ru.daemonlord.messenger.data.auth.dto.AuthSessionDto
|
||||||
import ru.daemonlord.messenger.data.auth.dto.LoginRequestDto
|
import ru.daemonlord.messenger.data.auth.dto.LoginRequestDto
|
||||||
import ru.daemonlord.messenger.data.auth.dto.RefreshTokenRequestDto
|
import ru.daemonlord.messenger.data.auth.dto.RefreshTokenRequestDto
|
||||||
|
import ru.daemonlord.messenger.data.auth.dto.RegisterRequestDto
|
||||||
|
import ru.daemonlord.messenger.data.common.ApiErrorMode
|
||||||
|
import ru.daemonlord.messenger.data.common.toAppError
|
||||||
import ru.daemonlord.messenger.di.IoDispatcher
|
import ru.daemonlord.messenger.di.IoDispatcher
|
||||||
|
import ru.daemonlord.messenger.domain.auth.model.AuthEmailStatus
|
||||||
import ru.daemonlord.messenger.domain.auth.model.AuthUser
|
import ru.daemonlord.messenger.domain.auth.model.AuthUser
|
||||||
|
import ru.daemonlord.messenger.domain.auth.model.AuthSession
|
||||||
import ru.daemonlord.messenger.domain.auth.repository.AuthRepository
|
import ru.daemonlord.messenger.domain.auth.repository.AuthRepository
|
||||||
import ru.daemonlord.messenger.domain.common.AppError
|
import ru.daemonlord.messenger.domain.common.AppError
|
||||||
import ru.daemonlord.messenger.domain.common.AppResult
|
import ru.daemonlord.messenger.domain.common.AppResult
|
||||||
import java.io.IOException
|
import ru.daemonlord.messenger.push.PushTokenSyncManager
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
|
|
||||||
@@ -22,15 +29,60 @@ import javax.inject.Singleton
|
|||||||
class NetworkAuthRepository @Inject constructor(
|
class NetworkAuthRepository @Inject constructor(
|
||||||
private val authApiService: AuthApiService,
|
private val authApiService: AuthApiService,
|
||||||
private val tokenRepository: TokenRepository,
|
private val tokenRepository: TokenRepository,
|
||||||
|
private val pushTokenSyncManager: PushTokenSyncManager,
|
||||||
@IoDispatcher private val ioDispatcher: CoroutineDispatcher,
|
@IoDispatcher private val ioDispatcher: CoroutineDispatcher,
|
||||||
) : AuthRepository {
|
) : AuthRepository {
|
||||||
|
|
||||||
override suspend fun login(email: String, password: String): AppResult<AuthUser> = withContext(ioDispatcher) {
|
override suspend fun checkEmailStatus(email: String): AppResult<AuthEmailStatus> = withContext(ioDispatcher) {
|
||||||
|
val normalized = email.trim().lowercase()
|
||||||
|
if (normalized.isBlank()) return@withContext AppResult.Error(AppError.Server("Email is required"))
|
||||||
|
try {
|
||||||
|
AppResult.Success(authApiService.checkEmailStatus(normalized).toDomain())
|
||||||
|
} catch (error: Throwable) {
|
||||||
|
AppResult.Error(error.toAppError())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun register(
|
||||||
|
email: String,
|
||||||
|
name: String,
|
||||||
|
username: String,
|
||||||
|
password: String,
|
||||||
|
): AppResult<Unit> = withContext(ioDispatcher) {
|
||||||
|
val normalizedEmail = email.trim().lowercase()
|
||||||
|
val normalizedName = name.trim()
|
||||||
|
val normalizedUsername = username.trim().removePrefix("@")
|
||||||
|
if (normalizedEmail.isBlank() || normalizedName.isBlank() || normalizedUsername.isBlank() || password.isBlank()) {
|
||||||
|
return@withContext AppResult.Error(AppError.Server("Email, name, username and password are required"))
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
authApiService.register(
|
||||||
|
request = RegisterRequestDto(
|
||||||
|
email = normalizedEmail,
|
||||||
|
name = normalizedName,
|
||||||
|
username = normalizedUsername,
|
||||||
|
password = password,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
AppResult.Success(Unit)
|
||||||
|
} catch (error: Throwable) {
|
||||||
|
AppResult.Error(error.toAppError())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun login(
|
||||||
|
email: String,
|
||||||
|
password: String,
|
||||||
|
otpCode: String?,
|
||||||
|
recoveryCode: String?,
|
||||||
|
): AppResult<AuthUser> = withContext(ioDispatcher) {
|
||||||
try {
|
try {
|
||||||
val tokenResponse = authApiService.login(
|
val tokenResponse = authApiService.login(
|
||||||
request = LoginRequestDto(
|
request = LoginRequestDto(
|
||||||
email = email,
|
email = email,
|
||||||
password = password,
|
password = password,
|
||||||
|
otpCode = otpCode?.trim()?.ifBlank { null },
|
||||||
|
recoveryCode = recoveryCode?.trim()?.ifBlank { null },
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
tokenRepository.saveTokens(
|
tokenRepository.saveTokens(
|
||||||
@@ -40,9 +92,16 @@ class NetworkAuthRepository @Inject constructor(
|
|||||||
savedAtMillis = System.currentTimeMillis(),
|
savedAtMillis = System.currentTimeMillis(),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
getMe()
|
pushTokenSyncManager.triggerBestEffortSync()
|
||||||
|
when (val meResult = getMe()) {
|
||||||
|
is AppResult.Success -> {
|
||||||
|
tokenRepository.upsertAccount(meResult.data.toStoredAccount())
|
||||||
|
meResult
|
||||||
|
}
|
||||||
|
is AppResult.Error -> meResult
|
||||||
|
}
|
||||||
} catch (error: Throwable) {
|
} catch (error: Throwable) {
|
||||||
AppResult.Error(error.toAppError(forLogin = true))
|
AppResult.Error(error.toAppError(mode = ApiErrorMode.LOGIN))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -60,19 +119,21 @@ class NetworkAuthRepository @Inject constructor(
|
|||||||
savedAtMillis = System.currentTimeMillis(),
|
savedAtMillis = System.currentTimeMillis(),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
pushTokenSyncManager.triggerBestEffortSync()
|
||||||
AppResult.Success(Unit)
|
AppResult.Success(Unit)
|
||||||
} catch (error: Throwable) {
|
} catch (error: Throwable) {
|
||||||
tokenRepository.clearTokens()
|
tokenRepository.clearTokens()
|
||||||
AppResult.Error(error.toAppError(forLogin = false))
|
AppResult.Error(error.toAppError())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun getMe(): AppResult<AuthUser> = withContext(ioDispatcher) {
|
override suspend fun getMe(): AppResult<AuthUser> = withContext(ioDispatcher) {
|
||||||
try {
|
try {
|
||||||
val user = authApiService.me().toDomain()
|
val user = authApiService.me().toDomain()
|
||||||
|
tokenRepository.upsertAccount(user.toStoredAccount())
|
||||||
AppResult.Success(user)
|
AppResult.Success(user)
|
||||||
} catch (error: Throwable) {
|
} catch (error: Throwable) {
|
||||||
AppResult.Error(error.toAppError(forLogin = false))
|
AppResult.Error(error.toAppError())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -86,7 +147,10 @@ class NetworkAuthRepository @Inject constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
when (val meResult = getMe()) {
|
when (val meResult = getMe()) {
|
||||||
is AppResult.Success -> meResult
|
is AppResult.Success -> {
|
||||||
|
pushTokenSyncManager.triggerBestEffortSync()
|
||||||
|
meResult
|
||||||
|
}
|
||||||
is AppResult.Error -> {
|
is AppResult.Error -> {
|
||||||
if (meResult.reason is AppError.Unauthorized) {
|
if (meResult.reason is AppError.Unauthorized) {
|
||||||
tokenRepository.clearTokens()
|
tokenRepository.clearTokens()
|
||||||
@@ -96,7 +160,34 @@ class NetworkAuthRepository @Inject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override suspend fun listSessions(): AppResult<List<AuthSession>> = withContext(ioDispatcher) {
|
||||||
|
try {
|
||||||
|
AppResult.Success(authApiService.sessions().map { it.toDomain() })
|
||||||
|
} catch (error: Throwable) {
|
||||||
|
AppResult.Error(error.toAppError())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun revokeSession(jti: String): AppResult<Unit> = withContext(ioDispatcher) {
|
||||||
|
try {
|
||||||
|
authApiService.revokeSession(jti = jti)
|
||||||
|
AppResult.Success(Unit)
|
||||||
|
} catch (error: Throwable) {
|
||||||
|
AppResult.Error(error.toAppError())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun revokeAllSessions(): AppResult<Unit> = withContext(ioDispatcher) {
|
||||||
|
try {
|
||||||
|
authApiService.revokeAllSessions()
|
||||||
|
AppResult.Success(Unit)
|
||||||
|
} catch (error: Throwable) {
|
||||||
|
AppResult.Error(error.toAppError())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
override suspend fun logout() {
|
override suspend fun logout() {
|
||||||
|
pushTokenSyncManager.unregisterCurrentTokenOnLogout()
|
||||||
tokenRepository.clearTokens()
|
tokenRepository.clearTokens()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -106,20 +197,46 @@ class NetworkAuthRepository @Inject constructor(
|
|||||||
email = email,
|
email = email,
|
||||||
name = name,
|
name = name,
|
||||||
username = username,
|
username = username,
|
||||||
|
bio = bio,
|
||||||
avatarUrl = avatarUrl,
|
avatarUrl = avatarUrl,
|
||||||
emailVerified = emailVerified,
|
emailVerified = emailVerified,
|
||||||
|
twofaEnabled = twofaEnabled,
|
||||||
|
privacyPrivateMessages = privacyPrivateMessages ?: "everyone",
|
||||||
|
privacyLastSeen = privacyLastSeen ?: "everyone",
|
||||||
|
privacyAvatar = privacyAvatar ?: "everyone",
|
||||||
|
privacyGroupInvites = privacyGroupInvites ?: "everyone",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun Throwable.toAppError(forLogin: Boolean): AppError {
|
private fun AuthUser.toStoredAccount(): StoredAccount {
|
||||||
return when (this) {
|
return StoredAccount(
|
||||||
is IOException -> AppError.Network
|
userId = id,
|
||||||
is HttpException -> when (code()) {
|
email = email,
|
||||||
400 -> if (forLogin) AppError.InvalidCredentials else AppError.Server(message = message())
|
name = name,
|
||||||
401, 403 -> if (forLogin) AppError.InvalidCredentials else AppError.Unauthorized
|
username = username,
|
||||||
else -> AppError.Server(message = message())
|
avatarUrl = avatarUrl,
|
||||||
|
lastActiveAt = System.currentTimeMillis(),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
else -> AppError.Unknown(cause = this)
|
|
||||||
|
private fun AuthSessionDto.toDomain(): AuthSession {
|
||||||
|
return AuthSession(
|
||||||
|
jti = jti,
|
||||||
|
createdAt = createdAt,
|
||||||
|
ipAddress = ipAddress,
|
||||||
|
userAgent = userAgent,
|
||||||
|
current = current,
|
||||||
|
tokenType = tokenType,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun CheckEmailStatusDto.toDomain(): AuthEmailStatus {
|
||||||
|
return AuthEmailStatus(
|
||||||
|
email = email,
|
||||||
|
registered = registered,
|
||||||
|
emailVerified = emailVerified,
|
||||||
|
twofaEnabled = twofaEnabled,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,26 @@
|
|||||||
package ru.daemonlord.messenger.data.chat.api
|
package ru.daemonlord.messenger.data.chat.api
|
||||||
|
|
||||||
|
import retrofit2.http.Body
|
||||||
|
import retrofit2.http.DELETE
|
||||||
import retrofit2.http.GET
|
import retrofit2.http.GET
|
||||||
|
import retrofit2.http.PATCH
|
||||||
import retrofit2.http.Path
|
import retrofit2.http.Path
|
||||||
|
import retrofit2.http.POST
|
||||||
|
import retrofit2.http.PUT
|
||||||
import retrofit2.http.Query
|
import retrofit2.http.Query
|
||||||
|
import ru.daemonlord.messenger.data.chat.dto.ChatBanDto
|
||||||
|
import ru.daemonlord.messenger.data.chat.dto.ChatCreateRequestDto
|
||||||
|
import ru.daemonlord.messenger.data.chat.dto.ChatInviteLinkDto
|
||||||
|
import ru.daemonlord.messenger.data.chat.dto.ChatJoinByInviteRequestDto
|
||||||
|
import ru.daemonlord.messenger.data.chat.dto.ChatMemberAddRequestDto
|
||||||
|
import ru.daemonlord.messenger.data.chat.dto.ChatMemberDto
|
||||||
|
import ru.daemonlord.messenger.data.chat.dto.ChatMemberRoleUpdateRequestDto
|
||||||
|
import ru.daemonlord.messenger.data.chat.dto.ChatNotificationSettingsDto
|
||||||
|
import ru.daemonlord.messenger.data.chat.dto.ChatNotificationSettingsUpdateDto
|
||||||
|
import ru.daemonlord.messenger.data.chat.dto.ChatProfileUpdateRequestDto
|
||||||
import ru.daemonlord.messenger.data.chat.dto.ChatReadDto
|
import ru.daemonlord.messenger.data.chat.dto.ChatReadDto
|
||||||
|
import ru.daemonlord.messenger.data.chat.dto.ChatTitleUpdateRequestDto
|
||||||
|
import ru.daemonlord.messenger.data.chat.dto.DiscoverChatDto
|
||||||
|
|
||||||
interface ChatApiService {
|
interface ChatApiService {
|
||||||
@GET("/api/v1/chats")
|
@GET("/api/v1/chats")
|
||||||
@@ -15,4 +32,132 @@ interface ChatApiService {
|
|||||||
suspend fun getChatById(
|
suspend fun getChatById(
|
||||||
@Path("chat_id") chatId: Long,
|
@Path("chat_id") chatId: Long,
|
||||||
): ChatReadDto
|
): ChatReadDto
|
||||||
|
|
||||||
|
@GET("/api/v1/chats/saved")
|
||||||
|
suspend fun getSavedChat(): ChatReadDto
|
||||||
|
|
||||||
|
@POST("/api/v1/chats/{chat_id}/invite-link")
|
||||||
|
suspend fun createInviteLink(
|
||||||
|
@Path("chat_id") chatId: Long,
|
||||||
|
): ChatInviteLinkDto
|
||||||
|
|
||||||
|
@POST("/api/v1/chats/join-by-invite")
|
||||||
|
suspend fun joinByInvite(
|
||||||
|
@Body request: ChatJoinByInviteRequestDto,
|
||||||
|
): ChatReadDto
|
||||||
|
|
||||||
|
@POST("/api/v1/chats")
|
||||||
|
suspend fun createChat(
|
||||||
|
@Body request: ChatCreateRequestDto,
|
||||||
|
): ChatReadDto
|
||||||
|
|
||||||
|
@GET("/api/v1/chats/discover")
|
||||||
|
suspend fun discoverChats(
|
||||||
|
@Query("query") query: String? = null,
|
||||||
|
): List<DiscoverChatDto>
|
||||||
|
|
||||||
|
@POST("/api/v1/chats/{chat_id}/join")
|
||||||
|
suspend fun joinChat(
|
||||||
|
@Path("chat_id") chatId: Long,
|
||||||
|
): ChatReadDto
|
||||||
|
|
||||||
|
@POST("/api/v1/chats/{chat_id}/leave")
|
||||||
|
suspend fun leaveChat(
|
||||||
|
@Path("chat_id") chatId: Long,
|
||||||
|
)
|
||||||
|
|
||||||
|
@DELETE("/api/v1/chats/{chat_id}")
|
||||||
|
suspend fun deleteChat(
|
||||||
|
@Path("chat_id") chatId: Long,
|
||||||
|
@Query("for_all") forAll: Boolean = false,
|
||||||
|
)
|
||||||
|
|
||||||
|
@POST("/api/v1/chats/{chat_id}/clear")
|
||||||
|
suspend fun clearChat(
|
||||||
|
@Path("chat_id") chatId: Long,
|
||||||
|
)
|
||||||
|
|
||||||
|
@POST("/api/v1/chats/{chat_id}/archive")
|
||||||
|
suspend fun archiveChat(
|
||||||
|
@Path("chat_id") chatId: Long,
|
||||||
|
): ChatReadDto
|
||||||
|
|
||||||
|
@POST("/api/v1/chats/{chat_id}/unarchive")
|
||||||
|
suspend fun unarchiveChat(
|
||||||
|
@Path("chat_id") chatId: Long,
|
||||||
|
): ChatReadDto
|
||||||
|
|
||||||
|
@POST("/api/v1/chats/{chat_id}/pin-chat")
|
||||||
|
suspend fun pinChat(
|
||||||
|
@Path("chat_id") chatId: Long,
|
||||||
|
): ChatReadDto
|
||||||
|
|
||||||
|
@POST("/api/v1/chats/{chat_id}/unpin-chat")
|
||||||
|
suspend fun unpinChat(
|
||||||
|
@Path("chat_id") chatId: Long,
|
||||||
|
): ChatReadDto
|
||||||
|
|
||||||
|
@PATCH("/api/v1/chats/{chat_id}/title")
|
||||||
|
suspend fun updateChatTitle(
|
||||||
|
@Path("chat_id") chatId: Long,
|
||||||
|
@Body request: ChatTitleUpdateRequestDto,
|
||||||
|
): ChatReadDto
|
||||||
|
|
||||||
|
@PATCH("/api/v1/chats/{chat_id}/profile")
|
||||||
|
suspend fun updateChatProfile(
|
||||||
|
@Path("chat_id") chatId: Long,
|
||||||
|
@Body request: ChatProfileUpdateRequestDto,
|
||||||
|
): ChatReadDto
|
||||||
|
|
||||||
|
@GET("/api/v1/chats/{chat_id}/notifications")
|
||||||
|
suspend fun getChatNotifications(
|
||||||
|
@Path("chat_id") chatId: Long,
|
||||||
|
): ChatNotificationSettingsDto
|
||||||
|
|
||||||
|
@PUT("/api/v1/chats/{chat_id}/notifications")
|
||||||
|
suspend fun updateChatNotifications(
|
||||||
|
@Path("chat_id") chatId: Long,
|
||||||
|
@Body request: ChatNotificationSettingsUpdateDto,
|
||||||
|
): ChatNotificationSettingsDto
|
||||||
|
|
||||||
|
@GET("/api/v1/chats/{chat_id}/members")
|
||||||
|
suspend fun listMembers(
|
||||||
|
@Path("chat_id") chatId: Long,
|
||||||
|
): List<ChatMemberDto>
|
||||||
|
|
||||||
|
@GET("/api/v1/chats/{chat_id}/bans")
|
||||||
|
suspend fun listBans(
|
||||||
|
@Path("chat_id") chatId: Long,
|
||||||
|
): List<ChatBanDto>
|
||||||
|
|
||||||
|
@POST("/api/v1/chats/{chat_id}/members")
|
||||||
|
suspend fun addMember(
|
||||||
|
@Path("chat_id") chatId: Long,
|
||||||
|
@Body request: ChatMemberAddRequestDto,
|
||||||
|
): ChatMemberDto
|
||||||
|
|
||||||
|
@PATCH("/api/v1/chats/{chat_id}/members/{user_id}/role")
|
||||||
|
suspend fun updateMemberRole(
|
||||||
|
@Path("chat_id") chatId: Long,
|
||||||
|
@Path("user_id") userId: Long,
|
||||||
|
@Body request: ChatMemberRoleUpdateRequestDto,
|
||||||
|
): ChatMemberDto
|
||||||
|
|
||||||
|
@DELETE("/api/v1/chats/{chat_id}/members/{user_id}")
|
||||||
|
suspend fun removeMember(
|
||||||
|
@Path("chat_id") chatId: Long,
|
||||||
|
@Path("user_id") userId: Long,
|
||||||
|
)
|
||||||
|
|
||||||
|
@POST("/api/v1/chats/{chat_id}/bans/{user_id}")
|
||||||
|
suspend fun banMember(
|
||||||
|
@Path("chat_id") chatId: Long,
|
||||||
|
@Path("user_id") userId: Long,
|
||||||
|
)
|
||||||
|
|
||||||
|
@DELETE("/api/v1/chats/{chat_id}/bans/{user_id}")
|
||||||
|
suspend fun unbanMember(
|
||||||
|
@Path("chat_id") chatId: Long,
|
||||||
|
@Path("user_id") userId: Long,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,6 +40,108 @@ data class ChatReadDto(
|
|||||||
val lastMessageType: String? = null,
|
val lastMessageType: String? = null,
|
||||||
@SerialName("last_message_created_at")
|
@SerialName("last_message_created_at")
|
||||||
val lastMessageCreatedAt: String? = null,
|
val lastMessageCreatedAt: String? = null,
|
||||||
|
@SerialName("pinned_message_id")
|
||||||
|
val pinnedMessageId: Long? = null,
|
||||||
|
@SerialName("my_role")
|
||||||
|
val myRole: String? = null,
|
||||||
@SerialName("created_at")
|
@SerialName("created_at")
|
||||||
val createdAt: String? = null,
|
val createdAt: String? = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class ChatInviteLinkDto(
|
||||||
|
@SerialName("chat_id")
|
||||||
|
val chatId: Long,
|
||||||
|
val token: String,
|
||||||
|
@SerialName("invite_url")
|
||||||
|
val inviteUrl: String,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class ChatJoinByInviteRequestDto(
|
||||||
|
val token: String,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class ChatCreateRequestDto(
|
||||||
|
val type: String,
|
||||||
|
val title: String? = null,
|
||||||
|
@SerialName("is_public")
|
||||||
|
val isPublic: Boolean = false,
|
||||||
|
val handle: String? = null,
|
||||||
|
val description: String? = null,
|
||||||
|
@SerialName("member_ids")
|
||||||
|
val memberIds: List<Long> = emptyList(),
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class DiscoverChatDto(
|
||||||
|
val id: Long,
|
||||||
|
val type: String,
|
||||||
|
@SerialName("display_title")
|
||||||
|
val displayTitle: String,
|
||||||
|
val handle: String? = null,
|
||||||
|
@SerialName("avatar_url")
|
||||||
|
val avatarUrl: String? = null,
|
||||||
|
@SerialName("is_member")
|
||||||
|
val isMember: Boolean = false,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class ChatMemberDto(
|
||||||
|
@SerialName("user_id")
|
||||||
|
val userId: Long,
|
||||||
|
val role: String,
|
||||||
|
val name: String? = null,
|
||||||
|
val username: String? = null,
|
||||||
|
@SerialName("avatar_url")
|
||||||
|
val avatarUrl: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class ChatBanDto(
|
||||||
|
@SerialName("user_id")
|
||||||
|
val userId: Long,
|
||||||
|
@SerialName("banned_at")
|
||||||
|
val bannedAt: String? = null,
|
||||||
|
val name: String? = null,
|
||||||
|
val username: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class ChatMemberRoleUpdateRequestDto(
|
||||||
|
val role: String,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class ChatMemberAddRequestDto(
|
||||||
|
@SerialName("user_id")
|
||||||
|
val userId: Long,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class ChatNotificationSettingsDto(
|
||||||
|
@SerialName("chat_id")
|
||||||
|
val chatId: Long,
|
||||||
|
@SerialName("user_id")
|
||||||
|
val userId: Long,
|
||||||
|
val muted: Boolean,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class ChatNotificationSettingsUpdateDto(
|
||||||
|
val muted: Boolean,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class ChatTitleUpdateRequestDto(
|
||||||
|
val title: String,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class ChatProfileUpdateRequestDto(
|
||||||
|
val title: String? = null,
|
||||||
|
val description: String? = null,
|
||||||
|
@SerialName("avatar_url")
|
||||||
|
val avatarUrl: String? = null,
|
||||||
|
)
|
||||||
|
|||||||
@@ -36,6 +36,8 @@ interface ChatDao {
|
|||||||
c.last_message_text,
|
c.last_message_text,
|
||||||
c.last_message_type,
|
c.last_message_type,
|
||||||
c.last_message_created_at,
|
c.last_message_created_at,
|
||||||
|
c.pinned_message_id,
|
||||||
|
c.my_role,
|
||||||
c.updated_sort_at
|
c.updated_sort_at
|
||||||
FROM chats c
|
FROM chats c
|
||||||
LEFT JOIN users_short u ON c.counterpart_user_id = u.id
|
LEFT JOIN users_short u ON c.counterpart_user_id = u.id
|
||||||
@@ -45,6 +47,49 @@ interface ChatDao {
|
|||||||
)
|
)
|
||||||
fun observeChats(archived: Boolean): Flow<List<ChatListLocalModel>>
|
fun observeChats(archived: Boolean): Flow<List<ChatListLocalModel>>
|
||||||
|
|
||||||
|
@Query(
|
||||||
|
"""
|
||||||
|
SELECT
|
||||||
|
c.id,
|
||||||
|
c.public_id,
|
||||||
|
c.type,
|
||||||
|
c.title,
|
||||||
|
COALESCE(c.display_title, u.display_name) AS display_title,
|
||||||
|
c.handle,
|
||||||
|
COALESCE(c.avatar_url, u.avatar_url) AS avatar_url,
|
||||||
|
c.archived,
|
||||||
|
c.pinned,
|
||||||
|
c.muted,
|
||||||
|
c.unread_count,
|
||||||
|
c.unread_mentions_count,
|
||||||
|
COALESCE(c.counterpart_name, u.display_name) AS counterpart_name,
|
||||||
|
COALESCE(c.counterpart_username, u.username) AS counterpart_username,
|
||||||
|
COALESCE(c.counterpart_avatar_url, u.avatar_url) AS counterpart_avatar_url,
|
||||||
|
c.counterpart_is_online,
|
||||||
|
c.counterpart_last_seen_at,
|
||||||
|
c.last_message_text,
|
||||||
|
c.last_message_type,
|
||||||
|
c.last_message_created_at,
|
||||||
|
c.pinned_message_id,
|
||||||
|
c.my_role,
|
||||||
|
c.updated_sort_at
|
||||||
|
FROM chats c
|
||||||
|
LEFT JOIN users_short u ON c.counterpart_user_id = u.id
|
||||||
|
WHERE c.id = :chatId
|
||||||
|
LIMIT 1
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
fun observeChatById(chatId: Long): Flow<ChatListLocalModel?>
|
||||||
|
|
||||||
|
@Query("SELECT display_title FROM chats WHERE id = :chatId LIMIT 1")
|
||||||
|
suspend fun getChatDisplayTitle(chatId: Long): String?
|
||||||
|
|
||||||
|
@Query("SELECT muted FROM chats WHERE id = :chatId LIMIT 1")
|
||||||
|
suspend fun isChatMuted(chatId: Long): Boolean?
|
||||||
|
|
||||||
|
@Query("UPDATE chats SET muted = :muted WHERE id = :chatId")
|
||||||
|
suspend fun updateChatMuted(chatId: Long, muted: Boolean)
|
||||||
|
|
||||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||||
suspend fun upsertChats(chats: List<ChatEntity>)
|
suspend fun upsertChats(chats: List<ChatEntity>)
|
||||||
|
|
||||||
@@ -97,6 +142,16 @@ interface ChatDao {
|
|||||||
)
|
)
|
||||||
suspend fun incrementUnread(chatId: Long, incrementBy: Int = 1)
|
suspend fun incrementUnread(chatId: Long, incrementBy: Int = 1)
|
||||||
|
|
||||||
|
@Query(
|
||||||
|
"""
|
||||||
|
UPDATE chats
|
||||||
|
SET unread_count = 0,
|
||||||
|
unread_mentions_count = 0
|
||||||
|
WHERE id = :chatId
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
suspend fun markChatRead(chatId: Long)
|
||||||
|
|
||||||
@Transaction
|
@Transaction
|
||||||
suspend fun clearAndReplaceChats(
|
suspend fun clearAndReplaceChats(
|
||||||
archived: Boolean,
|
archived: Boolean,
|
||||||
|
|||||||
@@ -5,15 +5,25 @@ import androidx.room.RoomDatabase
|
|||||||
import ru.daemonlord.messenger.data.chat.local.dao.ChatDao
|
import ru.daemonlord.messenger.data.chat.local.dao.ChatDao
|
||||||
import ru.daemonlord.messenger.data.chat.local.entity.ChatEntity
|
import ru.daemonlord.messenger.data.chat.local.entity.ChatEntity
|
||||||
import ru.daemonlord.messenger.data.chat.local.entity.UserShortEntity
|
import ru.daemonlord.messenger.data.chat.local.entity.UserShortEntity
|
||||||
|
import ru.daemonlord.messenger.data.message.local.dao.MessageDao
|
||||||
|
import ru.daemonlord.messenger.data.message.local.dao.PendingMessageActionDao
|
||||||
|
import ru.daemonlord.messenger.data.message.local.entity.MessageAttachmentEntity
|
||||||
|
import ru.daemonlord.messenger.data.message.local.entity.MessageEntity
|
||||||
|
import ru.daemonlord.messenger.data.message.local.entity.PendingMessageActionEntity
|
||||||
|
|
||||||
@Database(
|
@Database(
|
||||||
entities = [
|
entities = [
|
||||||
ChatEntity::class,
|
ChatEntity::class,
|
||||||
UserShortEntity::class,
|
UserShortEntity::class,
|
||||||
|
MessageEntity::class,
|
||||||
|
MessageAttachmentEntity::class,
|
||||||
|
PendingMessageActionEntity::class,
|
||||||
],
|
],
|
||||||
version = 1,
|
version = 9,
|
||||||
exportSchema = false,
|
exportSchema = false,
|
||||||
)
|
)
|
||||||
abstract class MessengerDatabase : RoomDatabase() {
|
abstract class MessengerDatabase : RoomDatabase() {
|
||||||
abstract fun chatDao(): ChatDao
|
abstract fun chatDao(): ChatDao
|
||||||
|
abstract fun messageDao(): MessageDao
|
||||||
|
abstract fun pendingMessageActionDao(): PendingMessageActionDao
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -56,6 +56,10 @@ data class ChatEntity(
|
|||||||
val lastMessageType: String?,
|
val lastMessageType: String?,
|
||||||
@ColumnInfo(name = "last_message_created_at")
|
@ColumnInfo(name = "last_message_created_at")
|
||||||
val lastMessageCreatedAt: String?,
|
val lastMessageCreatedAt: String?,
|
||||||
|
@ColumnInfo(name = "pinned_message_id")
|
||||||
|
val pinnedMessageId: Long?,
|
||||||
|
@ColumnInfo(name = "my_role")
|
||||||
|
val myRole: String?,
|
||||||
@ColumnInfo(name = "updated_sort_at")
|
@ColumnInfo(name = "updated_sort_at")
|
||||||
val updatedSortAt: String?,
|
val updatedSortAt: String?,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -43,6 +43,10 @@ data class ChatListLocalModel(
|
|||||||
val lastMessageType: String?,
|
val lastMessageType: String?,
|
||||||
@ColumnInfo(name = "last_message_created_at")
|
@ColumnInfo(name = "last_message_created_at")
|
||||||
val lastMessageCreatedAt: String?,
|
val lastMessageCreatedAt: String?,
|
||||||
|
@ColumnInfo(name = "pinned_message_id")
|
||||||
|
val pinnedMessageId: Long?,
|
||||||
|
@ColumnInfo(name = "my_role")
|
||||||
|
val myRole: String?,
|
||||||
@ColumnInfo(name = "updated_sort_at")
|
@ColumnInfo(name = "updated_sort_at")
|
||||||
val updatedSortAt: String?,
|
val updatedSortAt: String?,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package ru.daemonlord.messenger.data.chat.mapper
|
package ru.daemonlord.messenger.data.chat.mapper
|
||||||
|
|
||||||
import ru.daemonlord.messenger.data.chat.local.model.ChatListLocalModel
|
import ru.daemonlord.messenger.data.chat.local.model.ChatListLocalModel
|
||||||
|
import ru.daemonlord.messenger.data.chat.local.entity.ChatEntity
|
||||||
import ru.daemonlord.messenger.domain.chat.model.ChatItem
|
import ru.daemonlord.messenger.domain.chat.model.ChatItem
|
||||||
|
|
||||||
fun ChatListLocalModel.toDomain(): ChatItem {
|
fun ChatListLocalModel.toDomain(): ChatItem {
|
||||||
@@ -25,6 +26,36 @@ fun ChatListLocalModel.toDomain(): ChatItem {
|
|||||||
lastMessageText = lastMessageText,
|
lastMessageText = lastMessageText,
|
||||||
lastMessageType = lastMessageType,
|
lastMessageType = lastMessageType,
|
||||||
lastMessageCreatedAt = lastMessageCreatedAt,
|
lastMessageCreatedAt = lastMessageCreatedAt,
|
||||||
|
pinnedMessageId = pinnedMessageId,
|
||||||
|
myRole = myRole,
|
||||||
|
updatedSortAt = updatedSortAt,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun ChatEntity.toDomain(): ChatItem {
|
||||||
|
return ChatItem(
|
||||||
|
id = id,
|
||||||
|
publicId = publicId,
|
||||||
|
type = type,
|
||||||
|
title = title,
|
||||||
|
displayTitle = displayTitle,
|
||||||
|
handle = handle,
|
||||||
|
avatarUrl = avatarUrl,
|
||||||
|
archived = archived,
|
||||||
|
pinned = pinned,
|
||||||
|
muted = muted,
|
||||||
|
unreadCount = unreadCount,
|
||||||
|
unreadMentionsCount = unreadMentionsCount,
|
||||||
|
counterpartUsername = counterpartUsername,
|
||||||
|
counterpartName = counterpartName,
|
||||||
|
counterpartAvatarUrl = counterpartAvatarUrl,
|
||||||
|
counterpartIsOnline = counterpartIsOnline,
|
||||||
|
counterpartLastSeenAt = counterpartLastSeenAt,
|
||||||
|
lastMessageText = lastMessageText,
|
||||||
|
lastMessageType = lastMessageType,
|
||||||
|
lastMessageCreatedAt = lastMessageCreatedAt,
|
||||||
|
pinnedMessageId = pinnedMessageId,
|
||||||
|
myRole = myRole,
|
||||||
updatedSortAt = updatedSortAt,
|
updatedSortAt = updatedSortAt,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
package ru.daemonlord.messenger.data.chat.mapper
|
package ru.daemonlord.messenger.data.chat.mapper
|
||||||
|
|
||||||
import ru.daemonlord.messenger.data.chat.dto.ChatReadDto
|
import ru.daemonlord.messenger.data.chat.dto.ChatReadDto
|
||||||
|
import ru.daemonlord.messenger.data.chat.dto.ChatInviteLinkDto
|
||||||
import ru.daemonlord.messenger.data.chat.local.entity.ChatEntity
|
import ru.daemonlord.messenger.data.chat.local.entity.ChatEntity
|
||||||
import ru.daemonlord.messenger.data.chat.local.entity.UserShortEntity
|
import ru.daemonlord.messenger.data.chat.local.entity.UserShortEntity
|
||||||
|
import ru.daemonlord.messenger.domain.chat.model.ChatInviteLink
|
||||||
|
|
||||||
fun ChatReadDto.toChatEntity(): ChatEntity {
|
fun ChatReadDto.toChatEntity(): ChatEntity {
|
||||||
return ChatEntity(
|
return ChatEntity(
|
||||||
@@ -27,6 +29,8 @@ fun ChatReadDto.toChatEntity(): ChatEntity {
|
|||||||
lastMessageText = lastMessageText,
|
lastMessageText = lastMessageText,
|
||||||
lastMessageType = lastMessageType,
|
lastMessageType = lastMessageType,
|
||||||
lastMessageCreatedAt = lastMessageCreatedAt,
|
lastMessageCreatedAt = lastMessageCreatedAt,
|
||||||
|
pinnedMessageId = pinnedMessageId,
|
||||||
|
myRole = myRole,
|
||||||
updatedSortAt = lastMessageCreatedAt ?: createdAt,
|
updatedSortAt = lastMessageCreatedAt ?: createdAt,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -41,3 +45,11 @@ fun ChatReadDto.toUserShortEntityOrNull(): UserShortEntity? {
|
|||||||
avatarUrl = counterpartAvatarUrl,
|
avatarUrl = counterpartAvatarUrl,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun ChatInviteLinkDto.toDomain(): ChatInviteLink {
|
||||||
|
return ChatInviteLink(
|
||||||
|
chatId = chatId,
|
||||||
|
token = token,
|
||||||
|
inviteUrl = inviteUrl,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -4,21 +4,36 @@ import kotlinx.coroutines.CoroutineDispatcher
|
|||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.flow.collect
|
import kotlinx.coroutines.flow.collect
|
||||||
import kotlinx.coroutines.flow.channelFlow
|
import kotlinx.coroutines.flow.channelFlow
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.channels.awaitClose
|
import kotlinx.coroutines.channels.awaitClose
|
||||||
import retrofit2.HttpException
|
|
||||||
import ru.daemonlord.messenger.data.chat.api.ChatApiService
|
import ru.daemonlord.messenger.data.chat.api.ChatApiService
|
||||||
|
import ru.daemonlord.messenger.data.chat.dto.ChatBanDto
|
||||||
|
import ru.daemonlord.messenger.data.chat.dto.ChatCreateRequestDto
|
||||||
|
import ru.daemonlord.messenger.data.chat.dto.ChatJoinByInviteRequestDto
|
||||||
|
import ru.daemonlord.messenger.data.chat.dto.ChatMemberAddRequestDto
|
||||||
|
import ru.daemonlord.messenger.data.chat.dto.ChatMemberDto
|
||||||
|
import ru.daemonlord.messenger.data.chat.dto.ChatMemberRoleUpdateRequestDto
|
||||||
|
import ru.daemonlord.messenger.data.chat.dto.ChatNotificationSettingsDto
|
||||||
|
import ru.daemonlord.messenger.data.chat.dto.ChatNotificationSettingsUpdateDto
|
||||||
|
import ru.daemonlord.messenger.data.chat.dto.ChatProfileUpdateRequestDto
|
||||||
|
import ru.daemonlord.messenger.data.chat.dto.ChatTitleUpdateRequestDto
|
||||||
|
import ru.daemonlord.messenger.data.chat.dto.DiscoverChatDto
|
||||||
import ru.daemonlord.messenger.data.chat.local.dao.ChatDao
|
import ru.daemonlord.messenger.data.chat.local.dao.ChatDao
|
||||||
import ru.daemonlord.messenger.data.chat.mapper.toChatEntity
|
import ru.daemonlord.messenger.data.chat.mapper.toChatEntity
|
||||||
import ru.daemonlord.messenger.data.chat.mapper.toDomain
|
import ru.daemonlord.messenger.data.chat.mapper.toDomain
|
||||||
import ru.daemonlord.messenger.data.chat.mapper.toUserShortEntityOrNull
|
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.di.IoDispatcher
|
||||||
|
import ru.daemonlord.messenger.domain.chat.model.ChatBanItem
|
||||||
import ru.daemonlord.messenger.domain.chat.model.ChatItem
|
import ru.daemonlord.messenger.domain.chat.model.ChatItem
|
||||||
|
import ru.daemonlord.messenger.domain.chat.model.ChatMemberItem
|
||||||
|
import ru.daemonlord.messenger.domain.chat.model.ChatNotificationSettings
|
||||||
|
import ru.daemonlord.messenger.domain.chat.model.DiscoverChatItem
|
||||||
import ru.daemonlord.messenger.domain.chat.repository.ChatRepository
|
import ru.daemonlord.messenger.domain.chat.repository.ChatRepository
|
||||||
import ru.daemonlord.messenger.domain.common.AppError
|
|
||||||
import ru.daemonlord.messenger.domain.common.AppResult
|
import ru.daemonlord.messenger.domain.common.AppResult
|
||||||
import java.io.IOException
|
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
|
|
||||||
@@ -43,6 +58,10 @@ class NetworkChatRepository @Inject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun observeChat(chatId: Long): Flow<ChatItem?> {
|
||||||
|
return chatDao.observeChatById(chatId = chatId).map { it?.toDomain() }
|
||||||
|
}
|
||||||
|
|
||||||
override suspend fun refreshChats(archived: Boolean): AppResult<Unit> = withContext(ioDispatcher) {
|
override suspend fun refreshChats(archived: Boolean): AppResult<Unit> = withContext(ioDispatcher) {
|
||||||
try {
|
try {
|
||||||
val chats = chatApiService.getChats(archived = archived)
|
val chats = chatApiService.getChats(archived = archived)
|
||||||
@@ -70,21 +89,336 @@ class NetworkChatRepository @Inject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override suspend fun getSavedChat(): AppResult<ChatItem> = withContext(ioDispatcher) {
|
||||||
|
try {
|
||||||
|
val chat = chatApiService.getSavedChat()
|
||||||
|
chatDao.upsertUsers(chat.toUserShortEntityOrNull()?.let(::listOf).orEmpty())
|
||||||
|
val entity = chat.toChatEntity()
|
||||||
|
chatDao.upsertChats(listOf(entity))
|
||||||
|
AppResult.Success(entity.toDomain())
|
||||||
|
} catch (error: Throwable) {
|
||||||
|
AppResult.Error(error.toAppError())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun createInviteLink(chatId: Long): AppResult<ChatInviteLink> = withContext(ioDispatcher) {
|
||||||
|
try {
|
||||||
|
AppResult.Success(chatApiService.createInviteLink(chatId = chatId).toDomain())
|
||||||
|
} catch (error: Throwable) {
|
||||||
|
AppResult.Error(error.toAppError())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun joinByInvite(token: String): AppResult<ChatItem> = withContext(ioDispatcher) {
|
||||||
|
try {
|
||||||
|
val joined = chatApiService.joinByInvite(request = ChatJoinByInviteRequestDto(token = token))
|
||||||
|
chatDao.upsertUsers(joined.toUserShortEntityOrNull()?.let(::listOf).orEmpty())
|
||||||
|
val chatEntity = joined.toChatEntity()
|
||||||
|
chatDao.upsertChats(listOf(chatEntity))
|
||||||
|
AppResult.Success(chatEntity.toDomain())
|
||||||
|
} catch (error: Throwable) {
|
||||||
|
AppResult.Error(error.toAppError())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun createChat(
|
||||||
|
type: String,
|
||||||
|
title: String?,
|
||||||
|
isPublic: Boolean,
|
||||||
|
handle: String?,
|
||||||
|
description: String?,
|
||||||
|
memberIds: List<Long>,
|
||||||
|
): AppResult<ChatItem> = withContext(ioDispatcher) {
|
||||||
|
try {
|
||||||
|
val created = chatApiService.createChat(
|
||||||
|
request = ChatCreateRequestDto(
|
||||||
|
type = type,
|
||||||
|
title = title,
|
||||||
|
isPublic = isPublic,
|
||||||
|
handle = handle,
|
||||||
|
description = description,
|
||||||
|
memberIds = memberIds,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
chatDao.upsertUsers(created.toUserShortEntityOrNull()?.let(::listOf).orEmpty())
|
||||||
|
val chatEntity = created.toChatEntity()
|
||||||
|
chatDao.upsertChats(listOf(chatEntity))
|
||||||
|
AppResult.Success(chatEntity.toDomain())
|
||||||
|
} catch (error: Throwable) {
|
||||||
|
AppResult.Error(error.toAppError())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun discoverChats(query: String?): AppResult<List<DiscoverChatItem>> = withContext(ioDispatcher) {
|
||||||
|
try {
|
||||||
|
AppResult.Success(chatApiService.discoverChats(query = query).map { it.toDomain() })
|
||||||
|
} catch (error: Throwable) {
|
||||||
|
AppResult.Error(error.toAppError())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun joinChat(chatId: Long): AppResult<ChatItem> = withContext(ioDispatcher) {
|
||||||
|
try {
|
||||||
|
val joined = chatApiService.joinChat(chatId = chatId)
|
||||||
|
chatDao.upsertUsers(joined.toUserShortEntityOrNull()?.let(::listOf).orEmpty())
|
||||||
|
val entity = joined.toChatEntity()
|
||||||
|
chatDao.upsertChats(listOf(entity))
|
||||||
|
AppResult.Success(entity.toDomain())
|
||||||
|
} catch (error: Throwable) {
|
||||||
|
AppResult.Error(error.toAppError())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun leaveChat(chatId: Long): AppResult<Unit> = withContext(ioDispatcher) {
|
||||||
|
try {
|
||||||
|
chatApiService.leaveChat(chatId = chatId)
|
||||||
|
chatDao.deleteChat(chatId = chatId)
|
||||||
|
AppResult.Success(Unit)
|
||||||
|
} catch (error: Throwable) {
|
||||||
|
AppResult.Error(error.toAppError())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun archiveChat(chatId: Long): AppResult<ChatItem> = withContext(ioDispatcher) {
|
||||||
|
try {
|
||||||
|
val updated = chatApiService.archiveChat(chatId = chatId)
|
||||||
|
chatDao.upsertUsers(updated.toUserShortEntityOrNull()?.let(::listOf).orEmpty())
|
||||||
|
val entity = updated.toChatEntity()
|
||||||
|
chatDao.upsertChats(listOf(entity))
|
||||||
|
AppResult.Success(entity.toDomain())
|
||||||
|
} catch (error: Throwable) {
|
||||||
|
AppResult.Error(error.toAppError())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun unarchiveChat(chatId: Long): AppResult<ChatItem> = withContext(ioDispatcher) {
|
||||||
|
try {
|
||||||
|
val updated = chatApiService.unarchiveChat(chatId = chatId)
|
||||||
|
chatDao.upsertUsers(updated.toUserShortEntityOrNull()?.let(::listOf).orEmpty())
|
||||||
|
val entity = updated.toChatEntity()
|
||||||
|
chatDao.upsertChats(listOf(entity))
|
||||||
|
AppResult.Success(entity.toDomain())
|
||||||
|
} catch (error: Throwable) {
|
||||||
|
AppResult.Error(error.toAppError())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun pinChat(chatId: Long): AppResult<ChatItem> = withContext(ioDispatcher) {
|
||||||
|
try {
|
||||||
|
val updated = chatApiService.pinChat(chatId = chatId)
|
||||||
|
chatDao.upsertUsers(updated.toUserShortEntityOrNull()?.let(::listOf).orEmpty())
|
||||||
|
val entity = updated.toChatEntity()
|
||||||
|
chatDao.upsertChats(listOf(entity))
|
||||||
|
AppResult.Success(entity.toDomain())
|
||||||
|
} catch (error: Throwable) {
|
||||||
|
AppResult.Error(error.toAppError())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun unpinChat(chatId: Long): AppResult<ChatItem> = withContext(ioDispatcher) {
|
||||||
|
try {
|
||||||
|
val updated = chatApiService.unpinChat(chatId = chatId)
|
||||||
|
chatDao.upsertUsers(updated.toUserShortEntityOrNull()?.let(::listOf).orEmpty())
|
||||||
|
val entity = updated.toChatEntity()
|
||||||
|
chatDao.upsertChats(listOf(entity))
|
||||||
|
AppResult.Success(entity.toDomain())
|
||||||
|
} catch (error: Throwable) {
|
||||||
|
AppResult.Error(error.toAppError())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun updateChatTitle(chatId: Long, title: String): AppResult<ChatItem> = withContext(ioDispatcher) {
|
||||||
|
try {
|
||||||
|
val updated = chatApiService.updateChatTitle(
|
||||||
|
chatId = chatId,
|
||||||
|
request = ChatTitleUpdateRequestDto(title = title),
|
||||||
|
)
|
||||||
|
chatDao.upsertUsers(updated.toUserShortEntityOrNull()?.let(::listOf).orEmpty())
|
||||||
|
val entity = updated.toChatEntity()
|
||||||
|
chatDao.upsertChats(listOf(entity))
|
||||||
|
AppResult.Success(entity.toDomain())
|
||||||
|
} catch (error: Throwable) {
|
||||||
|
AppResult.Error(error.toAppError())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun updateChatProfile(
|
||||||
|
chatId: Long,
|
||||||
|
title: String?,
|
||||||
|
description: String?,
|
||||||
|
avatarUrl: String?,
|
||||||
|
): AppResult<ChatItem> = withContext(ioDispatcher) {
|
||||||
|
try {
|
||||||
|
val updated = chatApiService.updateChatProfile(
|
||||||
|
chatId = chatId,
|
||||||
|
request = ChatProfileUpdateRequestDto(
|
||||||
|
title = title,
|
||||||
|
description = description,
|
||||||
|
avatarUrl = avatarUrl,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
chatDao.upsertUsers(updated.toUserShortEntityOrNull()?.let(::listOf).orEmpty())
|
||||||
|
val entity = updated.toChatEntity()
|
||||||
|
chatDao.upsertChats(listOf(entity))
|
||||||
|
AppResult.Success(entity.toDomain())
|
||||||
|
} catch (error: Throwable) {
|
||||||
|
AppResult.Error(error.toAppError())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun clearChat(chatId: Long): AppResult<Unit> = withContext(ioDispatcher) {
|
||||||
|
try {
|
||||||
|
chatApiService.clearChat(chatId = chatId)
|
||||||
|
AppResult.Success(Unit)
|
||||||
|
} catch (error: Throwable) {
|
||||||
|
AppResult.Error(error.toAppError())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun removeChat(chatId: Long, forAll: Boolean): AppResult<Unit> = withContext(ioDispatcher) {
|
||||||
|
try {
|
||||||
|
chatApiService.deleteChat(chatId = chatId, forAll = forAll)
|
||||||
|
chatDao.deleteChat(chatId = chatId)
|
||||||
|
AppResult.Success(Unit)
|
||||||
|
} catch (error: Throwable) {
|
||||||
|
AppResult.Error(error.toAppError())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun getChatNotifications(chatId: Long): AppResult<ChatNotificationSettings> = withContext(ioDispatcher) {
|
||||||
|
try {
|
||||||
|
AppResult.Success(chatApiService.getChatNotifications(chatId = chatId).toDomain())
|
||||||
|
} catch (error: Throwable) {
|
||||||
|
AppResult.Error(error.toAppError())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun updateChatNotifications(chatId: Long, muted: Boolean): AppResult<ChatNotificationSettings> = withContext(ioDispatcher) {
|
||||||
|
try {
|
||||||
|
val settings = chatApiService.updateChatNotifications(
|
||||||
|
chatId = chatId,
|
||||||
|
request = ChatNotificationSettingsUpdateDto(muted = muted),
|
||||||
|
).toDomain()
|
||||||
|
chatDao.updateChatMuted(chatId = chatId, muted = settings.muted)
|
||||||
|
AppResult.Success(settings)
|
||||||
|
} catch (error: Throwable) {
|
||||||
|
AppResult.Error(error.toAppError())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun listMembers(chatId: Long): AppResult<List<ChatMemberItem>> = withContext(ioDispatcher) {
|
||||||
|
try {
|
||||||
|
AppResult.Success(chatApiService.listMembers(chatId = chatId).map { it.toDomain() })
|
||||||
|
} catch (error: Throwable) {
|
||||||
|
AppResult.Error(error.toAppError())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun listBans(chatId: Long): AppResult<List<ChatBanItem>> = withContext(ioDispatcher) {
|
||||||
|
try {
|
||||||
|
AppResult.Success(chatApiService.listBans(chatId = chatId).map { it.toDomain() })
|
||||||
|
} catch (error: Throwable) {
|
||||||
|
AppResult.Error(error.toAppError())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun addMember(chatId: Long, userId: Long): AppResult<ChatMemberItem> = withContext(ioDispatcher) {
|
||||||
|
try {
|
||||||
|
AppResult.Success(
|
||||||
|
chatApiService.addMember(
|
||||||
|
chatId = chatId,
|
||||||
|
request = ChatMemberAddRequestDto(userId = userId),
|
||||||
|
).toDomain()
|
||||||
|
)
|
||||||
|
} catch (error: Throwable) {
|
||||||
|
AppResult.Error(error.toAppError())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun updateMemberRole(chatId: Long, userId: Long, role: String): AppResult<ChatMemberItem> = withContext(ioDispatcher) {
|
||||||
|
try {
|
||||||
|
AppResult.Success(
|
||||||
|
chatApiService.updateMemberRole(
|
||||||
|
chatId = chatId,
|
||||||
|
userId = userId,
|
||||||
|
request = ChatMemberRoleUpdateRequestDto(role = role),
|
||||||
|
).toDomain()
|
||||||
|
)
|
||||||
|
} catch (error: Throwable) {
|
||||||
|
AppResult.Error(error.toAppError())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun removeMember(chatId: Long, userId: Long): AppResult<Unit> = withContext(ioDispatcher) {
|
||||||
|
try {
|
||||||
|
chatApiService.removeMember(chatId = chatId, userId = userId)
|
||||||
|
AppResult.Success(Unit)
|
||||||
|
} catch (error: Throwable) {
|
||||||
|
AppResult.Error(error.toAppError())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun banMember(chatId: Long, userId: Long): AppResult<Unit> = withContext(ioDispatcher) {
|
||||||
|
try {
|
||||||
|
chatApiService.banMember(chatId = chatId, userId = userId)
|
||||||
|
AppResult.Success(Unit)
|
||||||
|
} catch (error: Throwable) {
|
||||||
|
AppResult.Error(error.toAppError())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun unbanMember(chatId: Long, userId: Long): AppResult<Unit> = withContext(ioDispatcher) {
|
||||||
|
try {
|
||||||
|
chatApiService.unbanMember(chatId = chatId, userId = userId)
|
||||||
|
AppResult.Success(Unit)
|
||||||
|
} catch (error: Throwable) {
|
||||||
|
AppResult.Error(error.toAppError())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
override suspend fun deleteChat(chatId: Long) {
|
override suspend fun deleteChat(chatId: Long) {
|
||||||
withContext(ioDispatcher) {
|
withContext(ioDispatcher) {
|
||||||
chatDao.deleteChat(chatId = chatId)
|
chatDao.deleteChat(chatId = chatId)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun Throwable.toAppError(): AppError {
|
private fun DiscoverChatDto.toDomain(): DiscoverChatItem {
|
||||||
return when (this) {
|
return DiscoverChatItem(
|
||||||
is IOException -> AppError.Network
|
id = id,
|
||||||
is HttpException -> if (code() == 401 || code() == 403) {
|
type = type,
|
||||||
AppError.Unauthorized
|
displayTitle = displayTitle,
|
||||||
} else {
|
handle = handle,
|
||||||
AppError.Server(message = message())
|
avatarUrl = avatarUrl,
|
||||||
|
isMember = isMember,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
else -> AppError.Unknown(cause = this)
|
|
||||||
|
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,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
package ru.daemonlord.messenger.data.common
|
||||||
|
|
||||||
|
import retrofit2.HttpException
|
||||||
|
import ru.daemonlord.messenger.domain.common.AppError
|
||||||
|
import java.io.IOException
|
||||||
|
import org.json.JSONObject
|
||||||
|
|
||||||
|
enum class ApiErrorMode {
|
||||||
|
DEFAULT,
|
||||||
|
LOGIN,
|
||||||
|
}
|
||||||
|
|
||||||
|
fun Throwable.toAppError(mode: ApiErrorMode = ApiErrorMode.DEFAULT): AppError {
|
||||||
|
return when (this) {
|
||||||
|
is IOException -> AppError.Network
|
||||||
|
is HttpException -> when (mode) {
|
||||||
|
ApiErrorMode.LOGIN -> {
|
||||||
|
val detail = extractErrorDetail()
|
||||||
|
when (code()) {
|
||||||
|
400, 401, 403 -> {
|
||||||
|
if (detail?.contains("2fa code required", ignoreCase = true) == true) {
|
||||||
|
AppError.Server(message = detail)
|
||||||
|
} else {
|
||||||
|
AppError.InvalidCredentials
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else -> AppError.Server(message = detail ?: message())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ApiErrorMode.DEFAULT -> if (code() == 401 || code() == 403) {
|
||||||
|
AppError.Unauthorized
|
||||||
|
} else {
|
||||||
|
AppError.Server(message = extractErrorDetail() ?: message())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> AppError.Unknown(cause = this)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun HttpException.extractErrorDetail(): String? {
|
||||||
|
return runCatching {
|
||||||
|
val body = response()?.errorBody()?.string()?.trim().orEmpty()
|
||||||
|
if (body.isBlank()) return@runCatching null
|
||||||
|
val json = JSONObject(body)
|
||||||
|
json.optString("detail").takeIf { it.isNotBlank() }
|
||||||
|
}.getOrNull()
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
package ru.daemonlord.messenger.data.media.api
|
||||||
|
|
||||||
|
import retrofit2.http.Body
|
||||||
|
import retrofit2.http.GET
|
||||||
|
import retrofit2.http.Path
|
||||||
|
import retrofit2.http.POST
|
||||||
|
import retrofit2.http.Query
|
||||||
|
import ru.daemonlord.messenger.data.media.dto.AttachmentCreateRequestDto
|
||||||
|
import ru.daemonlord.messenger.data.media.dto.ChatAttachmentReadDto
|
||||||
|
import ru.daemonlord.messenger.data.media.dto.UploadUrlRequestDto
|
||||||
|
import ru.daemonlord.messenger.data.media.dto.UploadUrlResponseDto
|
||||||
|
|
||||||
|
interface MediaApiService {
|
||||||
|
@POST("/api/v1/media/upload-url")
|
||||||
|
suspend fun requestUploadUrl(
|
||||||
|
@Body request: UploadUrlRequestDto,
|
||||||
|
): UploadUrlResponseDto
|
||||||
|
|
||||||
|
@POST("/api/v1/media/attachments")
|
||||||
|
suspend fun createAttachment(
|
||||||
|
@Body request: AttachmentCreateRequestDto,
|
||||||
|
)
|
||||||
|
|
||||||
|
@GET("/api/v1/media/chats/{chat_id}/attachments")
|
||||||
|
suspend fun getChatAttachments(
|
||||||
|
@Path("chat_id") chatId: Long,
|
||||||
|
@Query("limit") limit: Int = 400,
|
||||||
|
@Query("before_id") beforeId: Long? = null,
|
||||||
|
): List<ChatAttachmentReadDto>
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
package ru.daemonlord.messenger.data.media.dto
|
||||||
|
|
||||||
|
import kotlinx.serialization.SerialName
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class UploadUrlRequestDto(
|
||||||
|
@SerialName("file_name")
|
||||||
|
val fileName: String,
|
||||||
|
@SerialName("file_type")
|
||||||
|
val fileType: String,
|
||||||
|
@SerialName("file_size")
|
||||||
|
val fileSize: Long,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class UploadUrlResponseDto(
|
||||||
|
@SerialName("upload_url")
|
||||||
|
val uploadUrl: String,
|
||||||
|
@SerialName("file_url")
|
||||||
|
val fileUrl: String,
|
||||||
|
@SerialName("object_key")
|
||||||
|
val objectKey: String,
|
||||||
|
@SerialName("expires_in")
|
||||||
|
val expiresIn: Int,
|
||||||
|
@SerialName("required_headers")
|
||||||
|
val requiredHeaders: Map<String, String> = emptyMap(),
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class AttachmentCreateRequestDto(
|
||||||
|
@SerialName("message_id")
|
||||||
|
val messageId: Long,
|
||||||
|
@SerialName("file_url")
|
||||||
|
val fileUrl: String,
|
||||||
|
@SerialName("file_type")
|
||||||
|
val fileType: String,
|
||||||
|
@SerialName("file_size")
|
||||||
|
val fileSize: Long,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class ChatAttachmentReadDto(
|
||||||
|
val id: Long,
|
||||||
|
@SerialName("message_id")
|
||||||
|
val messageId: Long,
|
||||||
|
@SerialName("sender_id")
|
||||||
|
val senderId: Long,
|
||||||
|
@SerialName("message_type")
|
||||||
|
val messageType: String,
|
||||||
|
@SerialName("message_created_at")
|
||||||
|
val messageCreatedAt: String,
|
||||||
|
@SerialName("file_url")
|
||||||
|
val fileUrl: String,
|
||||||
|
@SerialName("file_type")
|
||||||
|
val fileType: String,
|
||||||
|
@SerialName("file_size")
|
||||||
|
val fileSize: Long,
|
||||||
|
@SerialName("waveform_points")
|
||||||
|
val waveformPoints: List<Int>? = null,
|
||||||
|
)
|
||||||
@@ -0,0 +1,192 @@
|
|||||||
|
package ru.daemonlord.messenger.data.media.repository
|
||||||
|
|
||||||
|
import android.graphics.Bitmap
|
||||||
|
import android.graphics.BitmapFactory
|
||||||
|
import android.graphics.Canvas
|
||||||
|
import android.graphics.Movie
|
||||||
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import okhttp3.MediaType.Companion.toMediaTypeOrNull
|
||||||
|
import okhttp3.OkHttpClient
|
||||||
|
import okhttp3.Request
|
||||||
|
import okhttp3.RequestBody.Companion.toRequestBody
|
||||||
|
import ru.daemonlord.messenger.data.common.toAppError
|
||||||
|
import ru.daemonlord.messenger.data.media.api.MediaApiService
|
||||||
|
import ru.daemonlord.messenger.data.media.dto.AttachmentCreateRequestDto
|
||||||
|
import ru.daemonlord.messenger.data.media.dto.UploadUrlRequestDto
|
||||||
|
import ru.daemonlord.messenger.di.IoDispatcher
|
||||||
|
import ru.daemonlord.messenger.di.RefreshClient
|
||||||
|
import ru.daemonlord.messenger.domain.common.AppError
|
||||||
|
import ru.daemonlord.messenger.domain.common.AppResult
|
||||||
|
import ru.daemonlord.messenger.domain.media.model.UploadedAttachment
|
||||||
|
import ru.daemonlord.messenger.domain.media.repository.MediaRepository
|
||||||
|
import java.io.ByteArrayOutputStream
|
||||||
|
import kotlin.math.roundToInt
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
@Singleton
|
||||||
|
class NetworkMediaRepository @Inject constructor(
|
||||||
|
private val mediaApiService: MediaApiService,
|
||||||
|
@RefreshClient private val uploadClient: OkHttpClient,
|
||||||
|
@IoDispatcher private val ioDispatcher: CoroutineDispatcher,
|
||||||
|
) : MediaRepository {
|
||||||
|
|
||||||
|
override suspend fun uploadAndAttach(
|
||||||
|
messageId: Long,
|
||||||
|
fileName: String,
|
||||||
|
mimeType: String,
|
||||||
|
bytes: ByteArray,
|
||||||
|
): AppResult<UploadedAttachment> = withContext(ioDispatcher) {
|
||||||
|
try {
|
||||||
|
val uploadPayload = prepareUploadPayload(
|
||||||
|
fileName = fileName,
|
||||||
|
mimeType = mimeType,
|
||||||
|
bytes = bytes,
|
||||||
|
)
|
||||||
|
val uploadInfo = mediaApiService.requestUploadUrl(
|
||||||
|
request = UploadUrlRequestDto(
|
||||||
|
fileName = uploadPayload.fileName,
|
||||||
|
fileType = uploadPayload.mimeType,
|
||||||
|
fileSize = uploadPayload.bytes.size.toLong(),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
val body = uploadPayload.bytes.toRequestBody(uploadPayload.mimeType.toMediaTypeOrNull())
|
||||||
|
val uploadRequestBuilder = Request.Builder()
|
||||||
|
.url(uploadInfo.uploadUrl)
|
||||||
|
.put(body)
|
||||||
|
|
||||||
|
uploadInfo.requiredHeaders.forEach { (key, value) ->
|
||||||
|
uploadRequestBuilder.header(key, value)
|
||||||
|
}
|
||||||
|
|
||||||
|
uploadClient.newCall(uploadRequestBuilder.build()).execute().use { response ->
|
||||||
|
if (!response.isSuccessful) {
|
||||||
|
return@withContext AppResult.Error(
|
||||||
|
AppError.Server(message = "Upload failed: HTTP ${response.code}")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
mediaApiService.createAttachment(
|
||||||
|
request = AttachmentCreateRequestDto(
|
||||||
|
messageId = messageId,
|
||||||
|
fileUrl = uploadInfo.fileUrl,
|
||||||
|
fileType = uploadPayload.mimeType,
|
||||||
|
fileSize = uploadPayload.bytes.size.toLong(),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
AppResult.Success(
|
||||||
|
UploadedAttachment(
|
||||||
|
fileUrl = uploadInfo.fileUrl,
|
||||||
|
fileType = uploadPayload.mimeType,
|
||||||
|
fileSize = uploadPayload.bytes.size.toLong(),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
} catch (error: Throwable) {
|
||||||
|
AppResult.Error(error.toAppError())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun prepareUploadPayload(
|
||||||
|
fileName: String,
|
||||||
|
mimeType: String,
|
||||||
|
bytes: ByteArray,
|
||||||
|
): UploadPayload {
|
||||||
|
if (!mimeType.startsWith("image/", ignoreCase = true)) {
|
||||||
|
return UploadPayload(fileName = fileName, mimeType = mimeType, bytes = bytes)
|
||||||
|
}
|
||||||
|
if (mimeType.equals("image/gif", ignoreCase = true)) {
|
||||||
|
val still = gifToPngPayload(fileName = fileName, bytes = bytes)
|
||||||
|
if (still != null) return still
|
||||||
|
val bitmapFallback = runCatching { BitmapFactory.decodeByteArray(bytes, 0, bytes.size) }.getOrNull()
|
||||||
|
if (bitmapFallback != null) {
|
||||||
|
val output = ByteArrayOutputStream()
|
||||||
|
val compressed = bitmapFallback.compress(Bitmap.CompressFormat.PNG, 100, output)
|
||||||
|
bitmapFallback.recycle()
|
||||||
|
if (compressed) {
|
||||||
|
val pngBytes = output.toByteArray()
|
||||||
|
if (pngBytes.isNotEmpty()) {
|
||||||
|
val baseName = fileName.substringBeforeLast('.').ifBlank { "gif" }
|
||||||
|
return UploadPayload(
|
||||||
|
fileName = "${baseName}-still.png",
|
||||||
|
mimeType = "image/png",
|
||||||
|
bytes = pngBytes,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return UploadPayload(fileName = fileName, mimeType = "application/octet-stream", bytes = bytes)
|
||||||
|
}
|
||||||
|
val source = runCatching { BitmapFactory.decodeByteArray(bytes, 0, bytes.size) }.getOrNull()
|
||||||
|
?: return UploadPayload(fileName = fileName, mimeType = mimeType, bytes = bytes)
|
||||||
|
val maxSide = 1920
|
||||||
|
val width = source.width
|
||||||
|
val height = source.height
|
||||||
|
val scale = (maxSide.toFloat() / maxOf(width, height).toFloat()).coerceAtMost(1f)
|
||||||
|
val targetWidth = (width * scale).roundToInt().coerceAtLeast(1)
|
||||||
|
val targetHeight = (height * scale).roundToInt().coerceAtLeast(1)
|
||||||
|
|
||||||
|
val scaled = if (targetWidth != width || targetHeight != height) {
|
||||||
|
Bitmap.createScaledBitmap(source, targetWidth, targetHeight, true)
|
||||||
|
} else {
|
||||||
|
source
|
||||||
|
}
|
||||||
|
val output = ByteArrayOutputStream()
|
||||||
|
val compressedOk = runCatching {
|
||||||
|
scaled.compress(Bitmap.CompressFormat.JPEG, 82, output)
|
||||||
|
}.getOrDefault(false)
|
||||||
|
if (scaled !== source) {
|
||||||
|
scaled.recycle()
|
||||||
|
}
|
||||||
|
source.recycle()
|
||||||
|
if (!compressedOk) {
|
||||||
|
return UploadPayload(fileName = fileName, mimeType = mimeType, bytes = bytes)
|
||||||
|
}
|
||||||
|
val compressedBytes = output.toByteArray()
|
||||||
|
if (compressedBytes.size >= bytes.size) {
|
||||||
|
return UploadPayload(fileName = fileName, mimeType = mimeType, bytes = bytes)
|
||||||
|
}
|
||||||
|
val baseName = fileName.substringBeforeLast('.').ifBlank { "image" }
|
||||||
|
return UploadPayload(
|
||||||
|
fileName = "$baseName-web.jpg",
|
||||||
|
mimeType = "image/jpeg",
|
||||||
|
bytes = compressedBytes,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private data class UploadPayload(
|
||||||
|
val fileName: String,
|
||||||
|
val mimeType: String,
|
||||||
|
val bytes: ByteArray,
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun gifToPngPayload(
|
||||||
|
fileName: String,
|
||||||
|
bytes: ByteArray,
|
||||||
|
): UploadPayload? {
|
||||||
|
val movie = runCatching { Movie.decodeByteArray(bytes, 0, bytes.size) }.getOrNull() ?: return null
|
||||||
|
val width = movie.width().coerceAtLeast(1)
|
||||||
|
val height = movie.height().coerceAtLeast(1)
|
||||||
|
val bitmap = runCatching { Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888) }.getOrNull() ?: return null
|
||||||
|
return try {
|
||||||
|
val canvas = Canvas(bitmap)
|
||||||
|
movie.setTime(0)
|
||||||
|
movie.draw(canvas, 0f, 0f)
|
||||||
|
val output = ByteArrayOutputStream()
|
||||||
|
val compressed = bitmap.compress(Bitmap.CompressFormat.PNG, 100, output)
|
||||||
|
if (!compressed) return null
|
||||||
|
val pngBytes = output.toByteArray()
|
||||||
|
if (pngBytes.isEmpty()) return null
|
||||||
|
val baseName = fileName.substringBeforeLast('.').ifBlank { "gif" }
|
||||||
|
UploadPayload(
|
||||||
|
fileName = "${baseName}-still.png",
|
||||||
|
mimeType = "image/png",
|
||||||
|
bytes = pngBytes,
|
||||||
|
)
|
||||||
|
} finally {
|
||||||
|
bitmap.recycle()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
package ru.daemonlord.messenger.data.message.api
|
||||||
|
|
||||||
|
import retrofit2.http.Body
|
||||||
|
import retrofit2.http.DELETE
|
||||||
|
import retrofit2.http.GET
|
||||||
|
import retrofit2.http.POST
|
||||||
|
import retrofit2.http.PUT
|
||||||
|
import retrofit2.http.Path
|
||||||
|
import retrofit2.http.Query
|
||||||
|
import ru.daemonlord.messenger.data.message.dto.MessageCreateRequestDto
|
||||||
|
import ru.daemonlord.messenger.data.message.dto.MessageForwardRequestDto
|
||||||
|
import ru.daemonlord.messenger.data.message.dto.MessageForwardBulkRequestDto
|
||||||
|
import ru.daemonlord.messenger.data.message.dto.MessageReadDto
|
||||||
|
import ru.daemonlord.messenger.data.message.dto.MessageReactionDto
|
||||||
|
import ru.daemonlord.messenger.data.message.dto.MessageReactionToggleRequestDto
|
||||||
|
import ru.daemonlord.messenger.data.message.dto.MessageStatusUpdateRequestDto
|
||||||
|
import ru.daemonlord.messenger.data.message.dto.MessageUpdateRequestDto
|
||||||
|
|
||||||
|
interface MessageApiService {
|
||||||
|
@GET("/api/v1/messages/{chat_id}")
|
||||||
|
suspend fun getMessages(
|
||||||
|
@Path("chat_id") chatId: Long,
|
||||||
|
@Query("limit") limit: Int = 50,
|
||||||
|
@Query("before_id") beforeId: Long? = null,
|
||||||
|
): List<MessageReadDto>
|
||||||
|
|
||||||
|
@GET("/api/v1/messages/search")
|
||||||
|
suspend fun searchMessages(
|
||||||
|
@Query("query") query: String,
|
||||||
|
@Query("chat_id") chatId: Long? = null,
|
||||||
|
): List<MessageReadDto>
|
||||||
|
|
||||||
|
@GET("/api/v1/messages/{message_id}/thread")
|
||||||
|
suspend fun getMessageThread(
|
||||||
|
@Path("message_id") messageId: Long,
|
||||||
|
@Query("limit") limit: Int = 100,
|
||||||
|
): List<MessageReadDto>
|
||||||
|
|
||||||
|
@POST("/api/v1/messages")
|
||||||
|
suspend fun sendMessage(
|
||||||
|
@Body request: MessageCreateRequestDto,
|
||||||
|
): MessageReadDto
|
||||||
|
|
||||||
|
@PUT("/api/v1/messages/{message_id}")
|
||||||
|
suspend fun editMessage(
|
||||||
|
@Path("message_id") messageId: Long,
|
||||||
|
@Body request: MessageUpdateRequestDto,
|
||||||
|
): MessageReadDto
|
||||||
|
|
||||||
|
@DELETE("/api/v1/messages/{message_id}")
|
||||||
|
suspend fun deleteMessage(
|
||||||
|
@Path("message_id") messageId: Long,
|
||||||
|
@Query("for_all") forAll: Boolean = false,
|
||||||
|
)
|
||||||
|
|
||||||
|
@POST("/api/v1/messages/status")
|
||||||
|
suspend fun updateMessageStatus(
|
||||||
|
@Body request: MessageStatusUpdateRequestDto,
|
||||||
|
)
|
||||||
|
|
||||||
|
@POST("/api/v1/messages/{message_id}/forward")
|
||||||
|
suspend fun forwardMessage(
|
||||||
|
@Path("message_id") messageId: Long,
|
||||||
|
@Body request: MessageForwardRequestDto,
|
||||||
|
): MessageReadDto
|
||||||
|
|
||||||
|
@POST("/api/v1/messages/{message_id}/forward-bulk")
|
||||||
|
suspend fun forwardMessageBulk(
|
||||||
|
@Path("message_id") messageId: Long,
|
||||||
|
@Body request: MessageForwardBulkRequestDto,
|
||||||
|
): List<MessageReadDto>
|
||||||
|
|
||||||
|
@GET("/api/v1/messages/{message_id}/reactions")
|
||||||
|
suspend fun listReactions(
|
||||||
|
@Path("message_id") messageId: Long,
|
||||||
|
): List<MessageReactionDto>
|
||||||
|
|
||||||
|
@POST("/api/v1/messages/{message_id}/reactions/toggle")
|
||||||
|
suspend fun toggleReaction(
|
||||||
|
@Path("message_id") messageId: Long,
|
||||||
|
@Body request: MessageReactionToggleRequestDto,
|
||||||
|
): List<MessageReactionDto>
|
||||||
|
}
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
package ru.daemonlord.messenger.data.message.dto
|
||||||
|
|
||||||
|
import kotlinx.serialization.SerialName
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class MessageReadDto(
|
||||||
|
val id: Long,
|
||||||
|
@SerialName("chat_id")
|
||||||
|
val chatId: Long,
|
||||||
|
@SerialName("sender_id")
|
||||||
|
val senderId: Long,
|
||||||
|
@SerialName("sender_display_name")
|
||||||
|
val senderDisplayName: String? = null,
|
||||||
|
@SerialName("sender_username")
|
||||||
|
val senderUsername: String? = null,
|
||||||
|
@SerialName("sender_avatar_url")
|
||||||
|
val senderAvatarUrl: String? = null,
|
||||||
|
@SerialName("reply_to_message_id")
|
||||||
|
val replyToMessageId: Long? = null,
|
||||||
|
@SerialName("reply_preview_text")
|
||||||
|
val replyPreviewText: String? = null,
|
||||||
|
@SerialName("reply_preview_sender_name")
|
||||||
|
val replyPreviewSenderName: String? = null,
|
||||||
|
@SerialName("forwarded_from_message_id")
|
||||||
|
val forwardedFromMessageId: Long? = null,
|
||||||
|
@SerialName("forwarded_from_display_name")
|
||||||
|
val forwardedFromDisplayName: String? = null,
|
||||||
|
val type: String,
|
||||||
|
val text: String? = null,
|
||||||
|
@SerialName("delivery_status")
|
||||||
|
val deliveryStatus: String? = null,
|
||||||
|
@SerialName("attachment_waveform")
|
||||||
|
val attachmentWaveform: List<Int>? = null,
|
||||||
|
@SerialName("created_at")
|
||||||
|
val createdAt: String,
|
||||||
|
@SerialName("updated_at")
|
||||||
|
val updatedAt: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class MessageCreateRequestDto(
|
||||||
|
@SerialName("chat_id")
|
||||||
|
val chatId: Long,
|
||||||
|
val type: String,
|
||||||
|
val text: String? = null,
|
||||||
|
@SerialName("client_message_id")
|
||||||
|
val clientMessageId: String,
|
||||||
|
@SerialName("reply_to_message_id")
|
||||||
|
val replyToMessageId: Long? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class MessageUpdateRequestDto(
|
||||||
|
val text: String,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class MessageStatusUpdateRequestDto(
|
||||||
|
@SerialName("chat_id")
|
||||||
|
val chatId: Long,
|
||||||
|
@SerialName("message_id")
|
||||||
|
val messageId: Long,
|
||||||
|
val status: String,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class MessageForwardRequestDto(
|
||||||
|
@SerialName("target_chat_id")
|
||||||
|
val targetChatId: Long,
|
||||||
|
@SerialName("include_author")
|
||||||
|
val includeAuthor: Boolean = true,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class MessageForwardBulkRequestDto(
|
||||||
|
@SerialName("target_chat_ids")
|
||||||
|
val targetChatIds: List<Long>,
|
||||||
|
@SerialName("include_author")
|
||||||
|
val includeAuthor: Boolean = true,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class MessageReactionDto(
|
||||||
|
val emoji: String,
|
||||||
|
val count: Int,
|
||||||
|
val reacted: Boolean = false,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class MessageReactionToggleRequestDto(
|
||||||
|
val emoji: String,
|
||||||
|
)
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
package ru.daemonlord.messenger.data.message.local.dao
|
||||||
|
|
||||||
|
import androidx.room.Dao
|
||||||
|
import androidx.room.Insert
|
||||||
|
import androidx.room.OnConflictStrategy
|
||||||
|
import androidx.room.Query
|
||||||
|
import androidx.room.Transaction
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import ru.daemonlord.messenger.data.message.local.entity.MessageAttachmentEntity
|
||||||
|
import ru.daemonlord.messenger.data.message.local.entity.MessageEntity
|
||||||
|
import ru.daemonlord.messenger.data.message.local.model.MessageLocalModel
|
||||||
|
|
||||||
|
@Dao
|
||||||
|
interface MessageDao {
|
||||||
|
|
||||||
|
@Query(
|
||||||
|
"""
|
||||||
|
SELECT * FROM messages
|
||||||
|
WHERE chat_id = :chatId
|
||||||
|
ORDER BY created_at DESC, id DESC
|
||||||
|
LIMIT :limit
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
@Transaction
|
||||||
|
fun observeRecentMessages(
|
||||||
|
chatId: Long,
|
||||||
|
limit: Int = 50,
|
||||||
|
): Flow<List<MessageLocalModel>>
|
||||||
|
|
||||||
|
@Query("SELECT COUNT(*) FROM messages WHERE chat_id = :chatId")
|
||||||
|
suspend fun countMessages(chatId: Long): Int
|
||||||
|
|
||||||
|
@Query("SELECT chat_id FROM messages WHERE id = :messageId LIMIT 1")
|
||||||
|
suspend fun getChatIdByMessageId(messageId: Long): Long?
|
||||||
|
|
||||||
|
@Query(
|
||||||
|
"""
|
||||||
|
SELECT * FROM messages
|
||||||
|
WHERE chat_id = :chatId
|
||||||
|
AND id < :beforeMessageId
|
||||||
|
ORDER BY created_at DESC, id DESC
|
||||||
|
LIMIT :limit
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
suspend fun getMessagesPage(
|
||||||
|
chatId: Long,
|
||||||
|
beforeMessageId: Long,
|
||||||
|
limit: Int = 50,
|
||||||
|
): List<MessageEntity>
|
||||||
|
|
||||||
|
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||||
|
suspend fun upsertMessages(messages: List<MessageEntity>)
|
||||||
|
|
||||||
|
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||||
|
suspend fun upsertAttachments(attachments: List<MessageAttachmentEntity>)
|
||||||
|
|
||||||
|
@Query("DELETE FROM messages WHERE chat_id = :chatId")
|
||||||
|
suspend fun clearChatMessages(chatId: Long)
|
||||||
|
|
||||||
|
@Query(
|
||||||
|
"""
|
||||||
|
DELETE FROM message_attachments
|
||||||
|
WHERE message_id IN (
|
||||||
|
SELECT id FROM messages WHERE chat_id = :chatId
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
suspend fun clearChatAttachments(chatId: Long)
|
||||||
|
|
||||||
|
@Query("DELETE FROM messages WHERE id = :messageId")
|
||||||
|
suspend fun deleteMessage(messageId: Long)
|
||||||
|
|
||||||
|
@Query(
|
||||||
|
"""
|
||||||
|
UPDATE messages
|
||||||
|
SET text = :text,
|
||||||
|
updated_at = :updatedAt
|
||||||
|
WHERE id = :messageId
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
suspend fun updateMessageText(messageId: Long, text: String?, updatedAt: String?)
|
||||||
|
|
||||||
|
@Query(
|
||||||
|
"""
|
||||||
|
UPDATE messages
|
||||||
|
SET status = :status
|
||||||
|
WHERE id = :messageId
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
suspend fun updateMessageStatus(messageId: Long, status: String?)
|
||||||
|
|
||||||
|
@Transaction
|
||||||
|
suspend fun clearAndReplaceMessages(
|
||||||
|
chatId: Long,
|
||||||
|
messages: List<MessageEntity>,
|
||||||
|
attachments: List<MessageAttachmentEntity>,
|
||||||
|
) {
|
||||||
|
clearChatAttachments(chatId = chatId)
|
||||||
|
clearChatMessages(chatId = chatId)
|
||||||
|
upsertMessages(messages)
|
||||||
|
if (attachments.isNotEmpty()) {
|
||||||
|
upsertAttachments(attachments)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
package ru.daemonlord.messenger.data.message.local.dao
|
||||||
|
|
||||||
|
import androidx.room.Dao
|
||||||
|
import androidx.room.Insert
|
||||||
|
import androidx.room.OnConflictStrategy
|
||||||
|
import androidx.room.Query
|
||||||
|
import ru.daemonlord.messenger.data.message.local.entity.PendingMessageActionEntity
|
||||||
|
|
||||||
|
@Dao
|
||||||
|
interface PendingMessageActionDao {
|
||||||
|
|
||||||
|
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||||
|
suspend fun enqueue(action: PendingMessageActionEntity): Long
|
||||||
|
|
||||||
|
@Query(
|
||||||
|
"""
|
||||||
|
SELECT * FROM pending_message_actions
|
||||||
|
ORDER BY created_at ASC, id ASC
|
||||||
|
LIMIT :limit
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
suspend fun listPending(limit: Int = 50): List<PendingMessageActionEntity>
|
||||||
|
|
||||||
|
@Query("DELETE FROM pending_message_actions WHERE id = :id")
|
||||||
|
suspend fun deleteById(id: Long)
|
||||||
|
|
||||||
|
@Query(
|
||||||
|
"""
|
||||||
|
UPDATE pending_message_actions
|
||||||
|
SET attempts = attempts + 1
|
||||||
|
WHERE id = :id
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
suspend fun incrementAttempts(id: Long)
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
package ru.daemonlord.messenger.data.message.local.entity
|
||||||
|
|
||||||
|
import androidx.room.ColumnInfo
|
||||||
|
import androidx.room.Entity
|
||||||
|
import androidx.room.Index
|
||||||
|
import androidx.room.PrimaryKey
|
||||||
|
|
||||||
|
@Entity(
|
||||||
|
tableName = "message_attachments",
|
||||||
|
indices = [
|
||||||
|
Index(value = ["message_id"]),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
data class MessageAttachmentEntity(
|
||||||
|
@PrimaryKey
|
||||||
|
@ColumnInfo(name = "id")
|
||||||
|
val id: Long,
|
||||||
|
@ColumnInfo(name = "message_id")
|
||||||
|
val messageId: Long,
|
||||||
|
@ColumnInfo(name = "file_url")
|
||||||
|
val fileUrl: String,
|
||||||
|
@ColumnInfo(name = "file_type")
|
||||||
|
val fileType: String,
|
||||||
|
@ColumnInfo(name = "file_size")
|
||||||
|
val fileSize: Long,
|
||||||
|
@ColumnInfo(name = "waveform_points_json")
|
||||||
|
val waveformPointsJson: String?,
|
||||||
|
)
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
package ru.daemonlord.messenger.data.message.local.entity
|
||||||
|
|
||||||
|
import androidx.room.ColumnInfo
|
||||||
|
import androidx.room.Entity
|
||||||
|
import androidx.room.Index
|
||||||
|
import androidx.room.PrimaryKey
|
||||||
|
|
||||||
|
@Entity(
|
||||||
|
tableName = "messages",
|
||||||
|
indices = [
|
||||||
|
Index(value = ["chat_id", "created_at"]),
|
||||||
|
Index(value = ["chat_id", "id"]),
|
||||||
|
Index(value = ["chat_id", "updated_at"]),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
data class MessageEntity(
|
||||||
|
@PrimaryKey
|
||||||
|
@ColumnInfo(name = "id")
|
||||||
|
val id: Long,
|
||||||
|
@ColumnInfo(name = "chat_id")
|
||||||
|
val chatId: Long,
|
||||||
|
@ColumnInfo(name = "sender_id")
|
||||||
|
val senderId: Long,
|
||||||
|
@ColumnInfo(name = "sender_display_name")
|
||||||
|
val senderDisplayName: String?,
|
||||||
|
@ColumnInfo(name = "sender_username")
|
||||||
|
val senderUsername: String?,
|
||||||
|
@ColumnInfo(name = "sender_avatar_url")
|
||||||
|
val senderAvatarUrl: String?,
|
||||||
|
@ColumnInfo(name = "reply_to_message_id")
|
||||||
|
val replyToMessageId: Long?,
|
||||||
|
@ColumnInfo(name = "reply_preview_text")
|
||||||
|
val replyPreviewText: String?,
|
||||||
|
@ColumnInfo(name = "reply_preview_sender_name")
|
||||||
|
val replyPreviewSenderName: String?,
|
||||||
|
@ColumnInfo(name = "forwarded_from_message_id")
|
||||||
|
val forwardedFromMessageId: Long?,
|
||||||
|
@ColumnInfo(name = "forwarded_from_display_name")
|
||||||
|
val forwardedFromDisplayName: String?,
|
||||||
|
@ColumnInfo(name = "type")
|
||||||
|
val type: String,
|
||||||
|
@ColumnInfo(name = "text")
|
||||||
|
val text: String?,
|
||||||
|
@ColumnInfo(name = "status")
|
||||||
|
val status: String?,
|
||||||
|
@ColumnInfo(name = "attachment_waveform_json")
|
||||||
|
val attachmentWaveformJson: String?,
|
||||||
|
@ColumnInfo(name = "created_at")
|
||||||
|
val createdAt: String,
|
||||||
|
@ColumnInfo(name = "updated_at")
|
||||||
|
val updatedAt: String?,
|
||||||
|
)
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
package ru.daemonlord.messenger.data.message.local.entity
|
||||||
|
|
||||||
|
import androidx.room.ColumnInfo
|
||||||
|
import androidx.room.Entity
|
||||||
|
import androidx.room.Index
|
||||||
|
import androidx.room.PrimaryKey
|
||||||
|
|
||||||
|
@Entity(
|
||||||
|
tableName = "pending_message_actions",
|
||||||
|
indices = [Index(value = ["created_at"]), Index(value = ["chat_id"])],
|
||||||
|
)
|
||||||
|
data class PendingMessageActionEntity(
|
||||||
|
@PrimaryKey(autoGenerate = true)
|
||||||
|
@ColumnInfo(name = "id")
|
||||||
|
val id: Long = 0L,
|
||||||
|
@ColumnInfo(name = "type")
|
||||||
|
val type: String,
|
||||||
|
@ColumnInfo(name = "chat_id")
|
||||||
|
val chatId: Long,
|
||||||
|
@ColumnInfo(name = "message_id")
|
||||||
|
val messageId: Long?,
|
||||||
|
@ColumnInfo(name = "local_message_id")
|
||||||
|
val localMessageId: Long?,
|
||||||
|
@ColumnInfo(name = "text")
|
||||||
|
val text: String?,
|
||||||
|
@ColumnInfo(name = "reply_to_message_id")
|
||||||
|
val replyToMessageId: Long?,
|
||||||
|
@ColumnInfo(name = "for_all")
|
||||||
|
val forAll: Boolean?,
|
||||||
|
@ColumnInfo(name = "attempts")
|
||||||
|
val attempts: Int,
|
||||||
|
@ColumnInfo(name = "created_at")
|
||||||
|
val createdAt: String,
|
||||||
|
)
|
||||||
|
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
package ru.daemonlord.messenger.data.message.local.model
|
||||||
|
|
||||||
|
import androidx.room.Embedded
|
||||||
|
import androidx.room.Relation
|
||||||
|
import ru.daemonlord.messenger.data.message.local.entity.MessageAttachmentEntity
|
||||||
|
import ru.daemonlord.messenger.data.message.local.entity.MessageEntity
|
||||||
|
|
||||||
|
data class MessageLocalModel(
|
||||||
|
@Embedded
|
||||||
|
val message: MessageEntity,
|
||||||
|
@Relation(
|
||||||
|
parentColumn = "id",
|
||||||
|
entityColumn = "message_id",
|
||||||
|
)
|
||||||
|
val attachments: List<MessageAttachmentEntity>,
|
||||||
|
@Relation(
|
||||||
|
parentColumn = "reply_to_message_id",
|
||||||
|
entityColumn = "id",
|
||||||
|
)
|
||||||
|
val replyToMessage: MessageEntity?,
|
||||||
|
)
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
package ru.daemonlord.messenger.data.message.mapper
|
||||||
|
|
||||||
|
import kotlinx.serialization.encodeToString
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import ru.daemonlord.messenger.data.message.dto.MessageReadDto
|
||||||
|
import ru.daemonlord.messenger.data.message.dto.MessageReactionDto
|
||||||
|
import ru.daemonlord.messenger.data.message.local.entity.MessageEntity
|
||||||
|
import ru.daemonlord.messenger.data.message.local.entity.MessageAttachmentEntity
|
||||||
|
import ru.daemonlord.messenger.data.message.local.model.MessageLocalModel
|
||||||
|
import ru.daemonlord.messenger.domain.message.model.MessageAttachment
|
||||||
|
import ru.daemonlord.messenger.domain.message.model.MessageReaction
|
||||||
|
import ru.daemonlord.messenger.domain.message.model.MessageItem
|
||||||
|
|
||||||
|
private val mapperJson = Json { ignoreUnknownKeys = true }
|
||||||
|
|
||||||
|
fun MessageReadDto.toEntity(): MessageEntity {
|
||||||
|
return MessageEntity(
|
||||||
|
id = id,
|
||||||
|
chatId = chatId,
|
||||||
|
senderId = senderId,
|
||||||
|
senderDisplayName = senderDisplayName,
|
||||||
|
senderUsername = senderUsername,
|
||||||
|
senderAvatarUrl = senderAvatarUrl,
|
||||||
|
replyToMessageId = replyToMessageId,
|
||||||
|
replyPreviewText = replyPreviewText,
|
||||||
|
replyPreviewSenderName = replyPreviewSenderName,
|
||||||
|
forwardedFromMessageId = forwardedFromMessageId,
|
||||||
|
forwardedFromDisplayName = forwardedFromDisplayName,
|
||||||
|
type = type,
|
||||||
|
text = text,
|
||||||
|
status = deliveryStatus,
|
||||||
|
attachmentWaveformJson = attachmentWaveform?.let { mapperJson.encodeToString(it) },
|
||||||
|
createdAt = createdAt,
|
||||||
|
updatedAt = updatedAt,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun MessageLocalModel.toDomain(currentUserId: Long?): MessageItem {
|
||||||
|
val resolvedReplyPreviewText = message.replyPreviewText
|
||||||
|
?: replyToMessage?.text
|
||||||
|
?: replyToMessage?.type?.let { "[$it]" }
|
||||||
|
val resolvedReplyPreviewSenderName = message.replyPreviewSenderName
|
||||||
|
?: replyToMessage?.senderDisplayName
|
||||||
|
?: replyToMessage?.senderUsername?.takeIf { it.isNotBlank() }?.let { "@$it" }
|
||||||
|
?: replyToMessage?.senderId?.let { "User #$it" }
|
||||||
|
return MessageItem(
|
||||||
|
id = message.id,
|
||||||
|
chatId = message.chatId,
|
||||||
|
senderId = message.senderId,
|
||||||
|
senderDisplayName = message.senderDisplayName,
|
||||||
|
senderUsername = message.senderUsername,
|
||||||
|
type = message.type,
|
||||||
|
text = message.text,
|
||||||
|
createdAt = message.createdAt,
|
||||||
|
updatedAt = message.updatedAt,
|
||||||
|
isOutgoing = currentUserId != null && currentUserId == message.senderId,
|
||||||
|
status = message.status,
|
||||||
|
replyToMessageId = message.replyToMessageId,
|
||||||
|
replyPreviewText = resolvedReplyPreviewText,
|
||||||
|
replyPreviewSenderName = resolvedReplyPreviewSenderName,
|
||||||
|
forwardedFromMessageId = message.forwardedFromMessageId,
|
||||||
|
forwardedFromDisplayName = message.forwardedFromDisplayName,
|
||||||
|
attachmentWaveform = message.attachmentWaveformJson.toWaveformOrNull(),
|
||||||
|
attachments = attachments.map { it.toDomain() },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun MessageReactionDto.toDomain(): MessageReaction {
|
||||||
|
return MessageReaction(
|
||||||
|
emoji = emoji,
|
||||||
|
count = count,
|
||||||
|
reacted = reacted,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun ru.daemonlord.messenger.data.media.dto.ChatAttachmentReadDto.toEntity(): MessageAttachmentEntity {
|
||||||
|
return MessageAttachmentEntity(
|
||||||
|
id = id,
|
||||||
|
messageId = messageId,
|
||||||
|
fileUrl = fileUrl,
|
||||||
|
fileType = fileType,
|
||||||
|
fileSize = fileSize,
|
||||||
|
waveformPointsJson = waveformPoints?.let { mapperJson.encodeToString(it) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun MessageAttachmentEntity.toDomain(): MessageAttachment {
|
||||||
|
return MessageAttachment(
|
||||||
|
id = id,
|
||||||
|
messageId = messageId,
|
||||||
|
fileUrl = fileUrl,
|
||||||
|
fileType = fileType,
|
||||||
|
fileSize = fileSize,
|
||||||
|
waveformPoints = waveformPointsJson.toWaveformOrNull(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun String?.toWaveformOrNull(): List<Int>? {
|
||||||
|
if (this.isNullOrBlank()) return null
|
||||||
|
return runCatching { mapperJson.decodeFromString<List<Int>>(this) }.getOrNull()
|
||||||
|
}
|
||||||
@@ -0,0 +1,726 @@
|
|||||||
|
package ru.daemonlord.messenger.data.message.repository
|
||||||
|
|
||||||
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.SupervisorJob
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.collectLatest
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import ru.daemonlord.messenger.data.chat.local.dao.ChatDao
|
||||||
|
import ru.daemonlord.messenger.data.message.api.MessageApiService
|
||||||
|
import ru.daemonlord.messenger.data.message.dto.MessageReadDto
|
||||||
|
import ru.daemonlord.messenger.core.token.TokenRepository
|
||||||
|
import ru.daemonlord.messenger.data.common.toAppError
|
||||||
|
import ru.daemonlord.messenger.data.message.local.dao.MessageDao
|
||||||
|
import ru.daemonlord.messenger.data.message.local.dao.PendingMessageActionDao
|
||||||
|
import ru.daemonlord.messenger.data.message.local.entity.MessageEntity
|
||||||
|
import ru.daemonlord.messenger.data.message.local.entity.MessageAttachmentEntity
|
||||||
|
import ru.daemonlord.messenger.data.message.local.entity.PendingMessageActionEntity
|
||||||
|
import ru.daemonlord.messenger.data.message.mapper.toDomain
|
||||||
|
import ru.daemonlord.messenger.data.message.mapper.toEntity
|
||||||
|
import ru.daemonlord.messenger.data.media.api.MediaApiService
|
||||||
|
import ru.daemonlord.messenger.di.IoDispatcher
|
||||||
|
import ru.daemonlord.messenger.domain.common.AppError
|
||||||
|
import ru.daemonlord.messenger.domain.common.AppResult
|
||||||
|
import ru.daemonlord.messenger.domain.media.repository.MediaRepository
|
||||||
|
import ru.daemonlord.messenger.domain.message.model.MessageItem
|
||||||
|
import ru.daemonlord.messenger.domain.message.model.MessageReaction
|
||||||
|
import ru.daemonlord.messenger.domain.message.repository.MessageRepository
|
||||||
|
import ru.daemonlord.messenger.data.message.dto.MessageCreateRequestDto
|
||||||
|
import ru.daemonlord.messenger.data.message.dto.MessageForwardRequestDto
|
||||||
|
import ru.daemonlord.messenger.data.message.dto.MessageForwardBulkRequestDto
|
||||||
|
import ru.daemonlord.messenger.data.message.dto.MessageReactionToggleRequestDto
|
||||||
|
import ru.daemonlord.messenger.data.message.dto.MessageStatusUpdateRequestDto
|
||||||
|
import ru.daemonlord.messenger.data.message.dto.MessageUpdateRequestDto
|
||||||
|
import java.util.Base64
|
||||||
|
import java.util.UUID
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import kotlinx.serialization.json.jsonObject
|
||||||
|
import kotlinx.serialization.json.jsonPrimitive
|
||||||
|
|
||||||
|
@Singleton
|
||||||
|
class NetworkMessageRepository @Inject constructor(
|
||||||
|
private val messageApiService: MessageApiService,
|
||||||
|
private val messageDao: MessageDao,
|
||||||
|
private val pendingMessageActionDao: PendingMessageActionDao,
|
||||||
|
private val chatDao: ChatDao,
|
||||||
|
private val mediaRepository: MediaRepository,
|
||||||
|
private val mediaApiService: MediaApiService,
|
||||||
|
tokenRepository: TokenRepository,
|
||||||
|
@IoDispatcher private val ioDispatcher: CoroutineDispatcher,
|
||||||
|
) : MessageRepository {
|
||||||
|
|
||||||
|
@Volatile
|
||||||
|
private var currentUserId: Long? = null
|
||||||
|
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||||
|
|
||||||
|
init {
|
||||||
|
scope.launch {
|
||||||
|
tokenRepository.observeTokens().collectLatest { tokens ->
|
||||||
|
currentUserId = tokens?.accessToken?.extractUserIdFromJwt()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun observeMessages(chatId: Long, limit: Int): Flow<List<MessageItem>> {
|
||||||
|
return messageDao.observeRecentMessages(chatId = chatId, limit = limit).map { entities ->
|
||||||
|
entities.map { it.toDomain(currentUserId = currentUserId) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun searchMessages(query: String, chatId: Long?): AppResult<List<MessageItem>> = withContext(ioDispatcher) {
|
||||||
|
val normalized = query.trim()
|
||||||
|
if (normalized.isBlank()) return@withContext AppResult.Success(emptyList())
|
||||||
|
try {
|
||||||
|
val remote = messageApiService.searchMessages(query = normalized, chatId = chatId)
|
||||||
|
val mapped = remote.map { dto -> dto.toDomain(currentUserId = currentUserId) }
|
||||||
|
AppResult.Success(mapped)
|
||||||
|
} catch (error: Throwable) {
|
||||||
|
AppResult.Error(error.toAppError())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun getMessageThread(messageId: Long, limit: Int): AppResult<List<MessageItem>> = withContext(ioDispatcher) {
|
||||||
|
try {
|
||||||
|
val remote = messageApiService.getMessageThread(messageId = messageId, limit = limit)
|
||||||
|
AppResult.Success(remote.map { dto -> dto.toDomain(currentUserId = currentUserId) })
|
||||||
|
} catch (error: Throwable) {
|
||||||
|
AppResult.Error(error.toAppError())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun syncRecentMessages(chatId: Long): AppResult<Unit> = withContext(ioDispatcher) {
|
||||||
|
flushPendingActions(chatId = chatId)
|
||||||
|
try {
|
||||||
|
val remoteMessages = messageApiService.getMessages(chatId = chatId)
|
||||||
|
val messageEntities = remoteMessages.map { it.toEntity() }
|
||||||
|
val attachments = mediaApiService.getChatAttachments(chatId = chatId, limit = 400)
|
||||||
|
.map { it.toEntity() }
|
||||||
|
messageDao.clearAndReplaceMessages(
|
||||||
|
chatId = chatId,
|
||||||
|
messages = messageEntities,
|
||||||
|
attachments = attachments,
|
||||||
|
)
|
||||||
|
AppResult.Success(Unit)
|
||||||
|
} catch (error: Throwable) {
|
||||||
|
val mapped = error.toAppError()
|
||||||
|
val hasCached = messageDao.countMessages(chatId = chatId) > 0
|
||||||
|
if (mapped is AppError.Network && hasCached) {
|
||||||
|
AppResult.Success(Unit)
|
||||||
|
} else {
|
||||||
|
AppResult.Error(mapped)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun loadMoreMessages(chatId: Long, beforeMessageId: Long): AppResult<Unit> = withContext(ioDispatcher) {
|
||||||
|
flushPendingActions(chatId = chatId)
|
||||||
|
try {
|
||||||
|
val page = messageApiService.getMessages(
|
||||||
|
chatId = chatId,
|
||||||
|
beforeId = beforeMessageId,
|
||||||
|
)
|
||||||
|
if (page.isNotEmpty()) {
|
||||||
|
messageDao.upsertMessages(page.map { it.toEntity() })
|
||||||
|
val pageMessageIds = page.map { it.id }.toSet()
|
||||||
|
val attachments = mediaApiService.getChatAttachments(chatId = chatId, limit = 400)
|
||||||
|
.filter { it.messageId in pageMessageIds }
|
||||||
|
.map { it.toEntity() }
|
||||||
|
if (attachments.isNotEmpty()) {
|
||||||
|
messageDao.upsertAttachments(attachments)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
AppResult.Success(Unit)
|
||||||
|
} catch (error: Throwable) {
|
||||||
|
val mapped = error.toAppError()
|
||||||
|
val hasOlderLocal = messageDao.getMessagesPage(
|
||||||
|
chatId = chatId,
|
||||||
|
beforeMessageId = beforeMessageId,
|
||||||
|
limit = 1,
|
||||||
|
).isNotEmpty()
|
||||||
|
if (mapped is AppError.Network && hasOlderLocal) {
|
||||||
|
AppResult.Success(Unit)
|
||||||
|
} else {
|
||||||
|
AppResult.Error(mapped)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun sendTextMessage(
|
||||||
|
chatId: Long,
|
||||||
|
text: String,
|
||||||
|
replyToMessageId: Long?,
|
||||||
|
): AppResult<Unit> = withContext(ioDispatcher) {
|
||||||
|
flushPendingActions(chatId = chatId)
|
||||||
|
val tempId = -System.currentTimeMillis()
|
||||||
|
val tempMessage = MessageEntity(
|
||||||
|
id = tempId,
|
||||||
|
chatId = chatId,
|
||||||
|
senderId = currentUserId ?: 0L,
|
||||||
|
senderDisplayName = null,
|
||||||
|
senderUsername = null,
|
||||||
|
senderAvatarUrl = null,
|
||||||
|
replyToMessageId = replyToMessageId,
|
||||||
|
replyPreviewText = null,
|
||||||
|
replyPreviewSenderName = null,
|
||||||
|
forwardedFromMessageId = null,
|
||||||
|
forwardedFromDisplayName = null,
|
||||||
|
type = "text",
|
||||||
|
text = text,
|
||||||
|
status = "pending",
|
||||||
|
attachmentWaveformJson = null,
|
||||||
|
createdAt = java.time.Instant.now().toString(),
|
||||||
|
updatedAt = null,
|
||||||
|
)
|
||||||
|
messageDao.upsertMessages(listOf(tempMessage))
|
||||||
|
chatDao.updateLastMessage(
|
||||||
|
chatId = chatId,
|
||||||
|
lastMessageText = text,
|
||||||
|
lastMessageType = "text",
|
||||||
|
lastMessageCreatedAt = tempMessage.createdAt,
|
||||||
|
updatedSortAt = tempMessage.createdAt,
|
||||||
|
)
|
||||||
|
try {
|
||||||
|
val sent = messageApiService.sendMessage(
|
||||||
|
request = MessageCreateRequestDto(
|
||||||
|
chatId = chatId,
|
||||||
|
type = "text",
|
||||||
|
text = text,
|
||||||
|
clientMessageId = UUID.randomUUID().toString(),
|
||||||
|
replyToMessageId = replyToMessageId,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
messageDao.deleteMessage(tempId)
|
||||||
|
messageDao.upsertMessages(listOf(sent.toEntity()))
|
||||||
|
chatDao.updateLastMessage(
|
||||||
|
chatId = chatId,
|
||||||
|
lastMessageText = sent.text,
|
||||||
|
lastMessageType = sent.type,
|
||||||
|
lastMessageCreatedAt = sent.createdAt,
|
||||||
|
updatedSortAt = sent.createdAt,
|
||||||
|
)
|
||||||
|
AppResult.Success(Unit)
|
||||||
|
} catch (error: Throwable) {
|
||||||
|
val mapped = error.toAppError()
|
||||||
|
if (mapped is AppError.Network) {
|
||||||
|
pendingMessageActionDao.enqueue(
|
||||||
|
PendingMessageActionEntity(
|
||||||
|
type = PendingActionType.SEND_TEXT.name,
|
||||||
|
chatId = chatId,
|
||||||
|
messageId = null,
|
||||||
|
localMessageId = tempId,
|
||||||
|
text = text,
|
||||||
|
replyToMessageId = replyToMessageId,
|
||||||
|
forAll = null,
|
||||||
|
attempts = 0,
|
||||||
|
createdAt = java.time.Instant.now().toString(),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
AppResult.Success(Unit)
|
||||||
|
} else {
|
||||||
|
messageDao.deleteMessage(tempId)
|
||||||
|
AppResult.Error(mapped)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun editMessage(messageId: Long, newText: String): AppResult<Unit> = withContext(ioDispatcher) {
|
||||||
|
val chatId = messageDao.getChatIdByMessageId(messageId) ?: 0L
|
||||||
|
if (chatId > 0L) {
|
||||||
|
flushPendingActions(chatId = chatId)
|
||||||
|
}
|
||||||
|
messageDao.updateMessageText(
|
||||||
|
messageId = messageId,
|
||||||
|
text = newText,
|
||||||
|
updatedAt = java.time.Instant.now().toString(),
|
||||||
|
)
|
||||||
|
try {
|
||||||
|
val updated = messageApiService.editMessage(
|
||||||
|
messageId = messageId,
|
||||||
|
request = MessageUpdateRequestDto(text = newText),
|
||||||
|
)
|
||||||
|
messageDao.upsertMessages(listOf(updated.toEntity()))
|
||||||
|
AppResult.Success(Unit)
|
||||||
|
} catch (error: Throwable) {
|
||||||
|
val mapped = error.toAppError()
|
||||||
|
if (mapped is AppError.Network) {
|
||||||
|
pendingMessageActionDao.enqueue(
|
||||||
|
PendingMessageActionEntity(
|
||||||
|
type = PendingActionType.EDIT.name,
|
||||||
|
chatId = chatId,
|
||||||
|
messageId = messageId,
|
||||||
|
localMessageId = null,
|
||||||
|
text = newText,
|
||||||
|
replyToMessageId = null,
|
||||||
|
forAll = null,
|
||||||
|
attempts = 0,
|
||||||
|
createdAt = java.time.Instant.now().toString(),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
AppResult.Success(Unit)
|
||||||
|
} else {
|
||||||
|
AppResult.Error(mapped)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun deleteMessage(messageId: Long, forAll: Boolean): AppResult<Unit> = withContext(ioDispatcher) {
|
||||||
|
val chatId = messageDao.getChatIdByMessageId(messageId) ?: 0L
|
||||||
|
if (chatId > 0L) {
|
||||||
|
flushPendingActions(chatId = chatId)
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
messageApiService.deleteMessage(
|
||||||
|
messageId = messageId,
|
||||||
|
forAll = forAll,
|
||||||
|
)
|
||||||
|
messageDao.deleteMessage(messageId)
|
||||||
|
AppResult.Success(Unit)
|
||||||
|
} catch (error: Throwable) {
|
||||||
|
val mapped = error.toAppError()
|
||||||
|
if (mapped is AppError.Network) {
|
||||||
|
messageDao.deleteMessage(messageId)
|
||||||
|
pendingMessageActionDao.enqueue(
|
||||||
|
PendingMessageActionEntity(
|
||||||
|
type = PendingActionType.DELETE.name,
|
||||||
|
chatId = chatId,
|
||||||
|
messageId = messageId,
|
||||||
|
localMessageId = null,
|
||||||
|
text = null,
|
||||||
|
replyToMessageId = null,
|
||||||
|
forAll = forAll,
|
||||||
|
attempts = 0,
|
||||||
|
createdAt = java.time.Instant.now().toString(),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
AppResult.Success(Unit)
|
||||||
|
} else {
|
||||||
|
AppResult.Error(mapped)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun sendMediaMessage(
|
||||||
|
chatId: Long,
|
||||||
|
fileName: String,
|
||||||
|
mimeType: String,
|
||||||
|
bytes: ByteArray,
|
||||||
|
caption: String?,
|
||||||
|
replyToMessageId: Long?,
|
||||||
|
): AppResult<Unit> = withContext(ioDispatcher) {
|
||||||
|
val messageType = mapMimeToMessageType(mimeType = mimeType, fileName = fileName)
|
||||||
|
val tempId = -System.currentTimeMillis()
|
||||||
|
val tempMessage = MessageEntity(
|
||||||
|
id = tempId,
|
||||||
|
chatId = chatId,
|
||||||
|
senderId = currentUserId ?: 0L,
|
||||||
|
senderDisplayName = null,
|
||||||
|
senderUsername = null,
|
||||||
|
senderAvatarUrl = null,
|
||||||
|
replyToMessageId = replyToMessageId,
|
||||||
|
replyPreviewText = null,
|
||||||
|
replyPreviewSenderName = null,
|
||||||
|
forwardedFromMessageId = null,
|
||||||
|
forwardedFromDisplayName = null,
|
||||||
|
type = messageType,
|
||||||
|
text = caption,
|
||||||
|
status = "pending",
|
||||||
|
attachmentWaveformJson = null,
|
||||||
|
createdAt = java.time.Instant.now().toString(),
|
||||||
|
updatedAt = null,
|
||||||
|
)
|
||||||
|
messageDao.upsertMessages(listOf(tempMessage))
|
||||||
|
chatDao.updateLastMessage(
|
||||||
|
chatId = chatId,
|
||||||
|
lastMessageText = caption,
|
||||||
|
lastMessageType = messageType,
|
||||||
|
lastMessageCreatedAt = tempMessage.createdAt,
|
||||||
|
updatedSortAt = tempMessage.createdAt,
|
||||||
|
)
|
||||||
|
try {
|
||||||
|
val created = messageApiService.sendMessage(
|
||||||
|
request = MessageCreateRequestDto(
|
||||||
|
chatId = chatId,
|
||||||
|
type = messageType,
|
||||||
|
text = caption,
|
||||||
|
clientMessageId = UUID.randomUUID().toString(),
|
||||||
|
replyToMessageId = replyToMessageId,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
when (val mediaResult = mediaRepository.uploadAndAttach(
|
||||||
|
messageId = created.id,
|
||||||
|
fileName = fileName,
|
||||||
|
mimeType = mimeType,
|
||||||
|
bytes = bytes,
|
||||||
|
)) {
|
||||||
|
is AppResult.Success -> {
|
||||||
|
messageDao.deleteMessage(tempId)
|
||||||
|
messageDao.upsertMessages(listOf(created.toEntity()))
|
||||||
|
messageDao.upsertAttachments(
|
||||||
|
listOf(
|
||||||
|
MessageAttachmentEntity(
|
||||||
|
id = -System.currentTimeMillis(),
|
||||||
|
messageId = created.id,
|
||||||
|
fileUrl = mediaResult.data.fileUrl,
|
||||||
|
fileType = mediaResult.data.fileType,
|
||||||
|
fileSize = mediaResult.data.fileSize,
|
||||||
|
waveformPointsJson = null,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
chatDao.updateLastMessage(
|
||||||
|
chatId = chatId,
|
||||||
|
lastMessageText = created.text,
|
||||||
|
lastMessageType = created.type,
|
||||||
|
lastMessageCreatedAt = created.createdAt,
|
||||||
|
updatedSortAt = created.createdAt,
|
||||||
|
)
|
||||||
|
AppResult.Success(Unit)
|
||||||
|
}
|
||||||
|
|
||||||
|
is AppResult.Error -> {
|
||||||
|
messageDao.deleteMessage(tempId)
|
||||||
|
AppResult.Error(mediaResult.reason)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error: Throwable) {
|
||||||
|
messageDao.deleteMessage(tempId)
|
||||||
|
AppResult.Error(error.toAppError())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun sendImageUrlMessage(
|
||||||
|
chatId: Long,
|
||||||
|
imageUrl: String,
|
||||||
|
replyToMessageId: Long?,
|
||||||
|
): AppResult<Unit> = withContext(ioDispatcher) {
|
||||||
|
val normalizedUrl = imageUrl.trim()
|
||||||
|
if (normalizedUrl.isBlank()) {
|
||||||
|
return@withContext AppResult.Error(AppError.Server("Image URL is empty"))
|
||||||
|
}
|
||||||
|
val tempId = -System.currentTimeMillis()
|
||||||
|
val now = java.time.Instant.now().toString()
|
||||||
|
val tempMessage = MessageEntity(
|
||||||
|
id = tempId,
|
||||||
|
chatId = chatId,
|
||||||
|
senderId = currentUserId ?: 0L,
|
||||||
|
senderDisplayName = null,
|
||||||
|
senderUsername = null,
|
||||||
|
senderAvatarUrl = null,
|
||||||
|
replyToMessageId = replyToMessageId,
|
||||||
|
replyPreviewText = null,
|
||||||
|
replyPreviewSenderName = null,
|
||||||
|
forwardedFromMessageId = null,
|
||||||
|
forwardedFromDisplayName = null,
|
||||||
|
type = "image",
|
||||||
|
text = normalizedUrl,
|
||||||
|
status = "pending",
|
||||||
|
attachmentWaveformJson = null,
|
||||||
|
createdAt = now,
|
||||||
|
updatedAt = null,
|
||||||
|
)
|
||||||
|
messageDao.upsertMessages(listOf(tempMessage))
|
||||||
|
chatDao.updateLastMessage(
|
||||||
|
chatId = chatId,
|
||||||
|
lastMessageText = normalizedUrl,
|
||||||
|
lastMessageType = "image",
|
||||||
|
lastMessageCreatedAt = now,
|
||||||
|
updatedSortAt = now,
|
||||||
|
)
|
||||||
|
try {
|
||||||
|
val sent = messageApiService.sendMessage(
|
||||||
|
request = MessageCreateRequestDto(
|
||||||
|
chatId = chatId,
|
||||||
|
type = "image",
|
||||||
|
text = normalizedUrl,
|
||||||
|
clientMessageId = UUID.randomUUID().toString(),
|
||||||
|
replyToMessageId = replyToMessageId,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
messageDao.deleteMessage(tempId)
|
||||||
|
messageDao.upsertMessages(listOf(sent.toEntity()))
|
||||||
|
chatDao.updateLastMessage(
|
||||||
|
chatId = chatId,
|
||||||
|
lastMessageText = sent.text,
|
||||||
|
lastMessageType = sent.type,
|
||||||
|
lastMessageCreatedAt = sent.createdAt,
|
||||||
|
updatedSortAt = sent.createdAt,
|
||||||
|
)
|
||||||
|
AppResult.Success(Unit)
|
||||||
|
} catch (error: Throwable) {
|
||||||
|
messageDao.deleteMessage(tempId)
|
||||||
|
AppResult.Error(error.toAppError())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun markMessageDelivered(chatId: Long, messageId: Long): AppResult<Unit> = withContext(ioDispatcher) {
|
||||||
|
try {
|
||||||
|
messageApiService.updateMessageStatus(
|
||||||
|
request = MessageStatusUpdateRequestDto(
|
||||||
|
chatId = chatId,
|
||||||
|
messageId = messageId,
|
||||||
|
status = "message_delivered",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
AppResult.Success(Unit)
|
||||||
|
} catch (error: Throwable) {
|
||||||
|
AppResult.Error(error.toAppError())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun markMessageRead(chatId: Long, messageId: Long): AppResult<Unit> = withContext(ioDispatcher) {
|
||||||
|
// User already viewed this chat/message in UI, so unread badge should drop immediately.
|
||||||
|
chatDao.markChatRead(chatId)
|
||||||
|
try {
|
||||||
|
messageApiService.updateMessageStatus(
|
||||||
|
request = MessageStatusUpdateRequestDto(
|
||||||
|
chatId = chatId,
|
||||||
|
messageId = messageId,
|
||||||
|
status = "message_read",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
AppResult.Success(Unit)
|
||||||
|
} catch (error: Throwable) {
|
||||||
|
AppResult.Error(error.toAppError())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun forwardMessage(
|
||||||
|
messageId: Long,
|
||||||
|
targetChatId: Long,
|
||||||
|
includeAuthor: Boolean,
|
||||||
|
): AppResult<Unit> = withContext(ioDispatcher) {
|
||||||
|
try {
|
||||||
|
val forwarded = messageApiService.forwardMessage(
|
||||||
|
messageId = messageId,
|
||||||
|
request = MessageForwardRequestDto(
|
||||||
|
targetChatId = targetChatId,
|
||||||
|
includeAuthor = includeAuthor,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
messageDao.upsertMessages(listOf(forwarded.toEntity()))
|
||||||
|
chatDao.updateLastMessage(
|
||||||
|
chatId = targetChatId,
|
||||||
|
lastMessageText = forwarded.text,
|
||||||
|
lastMessageType = forwarded.type,
|
||||||
|
lastMessageCreatedAt = forwarded.createdAt,
|
||||||
|
updatedSortAt = forwarded.createdAt,
|
||||||
|
)
|
||||||
|
AppResult.Success(Unit)
|
||||||
|
} catch (error: Throwable) {
|
||||||
|
AppResult.Error(error.toAppError())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun forwardMessageBulk(
|
||||||
|
messageId: Long,
|
||||||
|
targetChatIds: List<Long>,
|
||||||
|
includeAuthor: Boolean,
|
||||||
|
): AppResult<Unit> = withContext(ioDispatcher) {
|
||||||
|
if (targetChatIds.isEmpty()) return@withContext AppResult.Success(Unit)
|
||||||
|
try {
|
||||||
|
val forwarded = messageApiService.forwardMessageBulk(
|
||||||
|
messageId = messageId,
|
||||||
|
request = MessageForwardBulkRequestDto(
|
||||||
|
targetChatIds = targetChatIds,
|
||||||
|
includeAuthor = includeAuthor,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
if (forwarded.isNotEmpty()) {
|
||||||
|
messageDao.upsertMessages(forwarded.map { it.toEntity() })
|
||||||
|
forwarded.forEach { message ->
|
||||||
|
chatDao.updateLastMessage(
|
||||||
|
chatId = message.chatId,
|
||||||
|
lastMessageText = message.text,
|
||||||
|
lastMessageType = message.type,
|
||||||
|
lastMessageCreatedAt = message.createdAt,
|
||||||
|
updatedSortAt = message.createdAt,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
AppResult.Success(Unit)
|
||||||
|
} catch (error: Throwable) {
|
||||||
|
AppResult.Error(error.toAppError())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun listReactions(messageId: Long): AppResult<List<MessageReaction>> = withContext(ioDispatcher) {
|
||||||
|
try {
|
||||||
|
val reactions = messageApiService.listReactions(messageId = messageId).map { it.toDomain() }
|
||||||
|
AppResult.Success(reactions)
|
||||||
|
} catch (error: Throwable) {
|
||||||
|
AppResult.Error(error.toAppError())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun toggleReaction(messageId: Long, emoji: String): AppResult<List<MessageReaction>> = withContext(ioDispatcher) {
|
||||||
|
try {
|
||||||
|
val reactions = messageApiService.toggleReaction(
|
||||||
|
messageId = messageId,
|
||||||
|
request = MessageReactionToggleRequestDto(emoji = emoji),
|
||||||
|
).map { it.toDomain() }
|
||||||
|
AppResult.Success(reactions)
|
||||||
|
} catch (error: Throwable) {
|
||||||
|
AppResult.Error(error.toAppError())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun mapMimeToMessageType(
|
||||||
|
mimeType: String,
|
||||||
|
fileName: String,
|
||||||
|
): String {
|
||||||
|
val normalizedMime = mimeType.lowercase()
|
||||||
|
val normalizedName = fileName.lowercase()
|
||||||
|
return when {
|
||||||
|
normalizedName.startsWith("circle_") -> "circle_video"
|
||||||
|
normalizedMime == "image/gif" || normalizedName.endsWith(".gif") || normalizedName.startsWith("gif_") -> "image"
|
||||||
|
normalizedName.startsWith("sticker_") || normalizedMime == "image/webp" -> "image"
|
||||||
|
normalizedMime.startsWith("image/") -> "image"
|
||||||
|
normalizedMime.startsWith("video/") -> "video"
|
||||||
|
normalizedMime.startsWith("audio/") && normalizedName.startsWith("voice_") -> "voice"
|
||||||
|
normalizedMime.startsWith("audio/") -> "audio"
|
||||||
|
else -> "file"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun flushPendingActions(chatId: Long) {
|
||||||
|
val pending = pendingMessageActionDao.listPending(limit = 100)
|
||||||
|
for (action in pending) {
|
||||||
|
if (action.chatId != chatId && action.chatId != 0L) continue
|
||||||
|
when (val result = performPendingAction(action)) {
|
||||||
|
is AppResult.Success -> pendingMessageActionDao.deleteById(action.id)
|
||||||
|
is AppResult.Error -> {
|
||||||
|
pendingMessageActionDao.incrementAttempts(action.id)
|
||||||
|
if (result.reason is AppError.Network) {
|
||||||
|
return
|
||||||
|
} else {
|
||||||
|
pendingMessageActionDao.deleteById(action.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun performPendingAction(action: PendingMessageActionEntity): AppResult<Unit> {
|
||||||
|
val type = runCatching { PendingActionType.valueOf(action.type) }.getOrNull()
|
||||||
|
?: return AppResult.Success(Unit)
|
||||||
|
return when (type) {
|
||||||
|
PendingActionType.SEND_TEXT -> {
|
||||||
|
val text = action.text ?: return AppResult.Success(Unit)
|
||||||
|
val chatId = action.chatId
|
||||||
|
runCatching {
|
||||||
|
messageApiService.sendMessage(
|
||||||
|
request = MessageCreateRequestDto(
|
||||||
|
chatId = chatId,
|
||||||
|
type = "text",
|
||||||
|
text = text,
|
||||||
|
clientMessageId = UUID.randomUUID().toString(),
|
||||||
|
replyToMessageId = action.replyToMessageId,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}.fold(
|
||||||
|
onSuccess = { sent ->
|
||||||
|
action.localMessageId?.let { messageDao.deleteMessage(it) }
|
||||||
|
messageDao.upsertMessages(listOf(sent.toEntity()))
|
||||||
|
chatDao.updateLastMessage(
|
||||||
|
chatId = chatId,
|
||||||
|
lastMessageText = sent.text,
|
||||||
|
lastMessageType = sent.type,
|
||||||
|
lastMessageCreatedAt = sent.createdAt,
|
||||||
|
updatedSortAt = sent.createdAt,
|
||||||
|
)
|
||||||
|
AppResult.Success(Unit)
|
||||||
|
},
|
||||||
|
onFailure = { error -> AppResult.Error(error.toAppError()) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
PendingActionType.EDIT -> {
|
||||||
|
val messageId = action.messageId ?: return AppResult.Success(Unit)
|
||||||
|
val text = action.text ?: return AppResult.Success(Unit)
|
||||||
|
runCatching {
|
||||||
|
messageApiService.editMessage(
|
||||||
|
messageId = messageId,
|
||||||
|
request = MessageUpdateRequestDto(text = text),
|
||||||
|
)
|
||||||
|
}.fold(
|
||||||
|
onSuccess = { updated ->
|
||||||
|
messageDao.upsertMessages(listOf(updated.toEntity()))
|
||||||
|
AppResult.Success(Unit)
|
||||||
|
},
|
||||||
|
onFailure = { error -> AppResult.Error(error.toAppError()) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
PendingActionType.DELETE -> {
|
||||||
|
val messageId = action.messageId ?: return AppResult.Success(Unit)
|
||||||
|
runCatching {
|
||||||
|
messageApiService.deleteMessage(
|
||||||
|
messageId = messageId,
|
||||||
|
forAll = action.forAll == true,
|
||||||
|
)
|
||||||
|
}.fold(
|
||||||
|
onSuccess = {
|
||||||
|
AppResult.Success(Unit)
|
||||||
|
},
|
||||||
|
onFailure = { error -> AppResult.Error(error.toAppError()) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private enum class PendingActionType {
|
||||||
|
SEND_TEXT,
|
||||||
|
EDIT,
|
||||||
|
DELETE,
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun MessageReadDto.toDomain(currentUserId: Long?): MessageItem {
|
||||||
|
return MessageItem(
|
||||||
|
id = id,
|
||||||
|
chatId = chatId,
|
||||||
|
senderId = senderId,
|
||||||
|
senderDisplayName = senderDisplayName,
|
||||||
|
senderUsername = senderUsername,
|
||||||
|
type = type,
|
||||||
|
text = text,
|
||||||
|
createdAt = createdAt,
|
||||||
|
updatedAt = updatedAt,
|
||||||
|
isOutgoing = currentUserId != null && currentUserId == senderId,
|
||||||
|
status = deliveryStatus,
|
||||||
|
replyToMessageId = replyToMessageId,
|
||||||
|
replyPreviewText = replyPreviewText,
|
||||||
|
replyPreviewSenderName = replyPreviewSenderName,
|
||||||
|
forwardedFromMessageId = forwardedFromMessageId,
|
||||||
|
forwardedFromDisplayName = forwardedFromDisplayName,
|
||||||
|
attachmentWaveform = attachmentWaveform,
|
||||||
|
attachments = emptyList(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun String.extractUserIdFromJwt(): Long? {
|
||||||
|
val payloadPart = split('.').getOrNull(1) ?: return null
|
||||||
|
val normalized = payloadPart
|
||||||
|
.replace('-', '+')
|
||||||
|
.replace('_', '/')
|
||||||
|
.let { part ->
|
||||||
|
when (part.length % 4) {
|
||||||
|
2 -> "$part=="
|
||||||
|
3 -> "$part="
|
||||||
|
else -> part
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val payloadJson = runCatching {
|
||||||
|
String(Base64.getDecoder().decode(normalized), Charsets.UTF_8)
|
||||||
|
}.getOrNull() ?: return null
|
||||||
|
return runCatching {
|
||||||
|
Json.parseToJsonElement(payloadJson).jsonObject["sub"]?.jsonPrimitive?.content?.toLongOrNull()
|
||||||
|
}.getOrNull()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
)
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
package ru.daemonlord.messenger.data.notifications.repository
|
||||||
|
|
||||||
|
import androidx.datastore.core.DataStore
|
||||||
|
import androidx.datastore.preferences.core.Preferences
|
||||||
|
import androidx.datastore.preferences.core.booleanPreferencesKey
|
||||||
|
import androidx.datastore.preferences.core.edit
|
||||||
|
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
|
import ru.daemonlord.messenger.domain.notifications.model.ChatNotificationOverride
|
||||||
|
import ru.daemonlord.messenger.domain.notifications.model.NotificationSettings
|
||||||
|
import ru.daemonlord.messenger.domain.notifications.repository.NotificationSettingsRepository
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
@Singleton
|
||||||
|
class DataStoreNotificationSettingsRepository @Inject constructor(
|
||||||
|
private val dataStore: DataStore<Preferences>,
|
||||||
|
) : NotificationSettingsRepository {
|
||||||
|
|
||||||
|
override fun observeSettings(): Flow<NotificationSettings> {
|
||||||
|
return dataStore.data.map { preferences ->
|
||||||
|
NotificationSettings(
|
||||||
|
globalEnabled = preferences[GLOBAL_ENABLED_KEY] ?: true,
|
||||||
|
previewEnabled = preferences[PREVIEW_ENABLED_KEY] ?: true,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun getSettings(): NotificationSettings {
|
||||||
|
return observeSettings().first()
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun setGlobalEnabled(enabled: Boolean) {
|
||||||
|
dataStore.edit { preferences ->
|
||||||
|
preferences[GLOBAL_ENABLED_KEY] = enabled
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun setPreviewEnabled(enabled: Boolean) {
|
||||||
|
dataStore.edit { preferences ->
|
||||||
|
preferences[PREVIEW_ENABLED_KEY] = enabled
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun observeChatOverride(chatId: Long): Flow<ChatNotificationOverride> {
|
||||||
|
return dataStore.data.map { preferences ->
|
||||||
|
preferences.chatOverride(chatId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun getChatOverride(chatId: Long): ChatNotificationOverride {
|
||||||
|
return observeChatOverride(chatId).first()
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun setChatOverride(chatId: Long, mode: ChatNotificationOverride) {
|
||||||
|
dataStore.edit { preferences ->
|
||||||
|
preferences[chatOverrideKey(chatId)] = mode.name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun clearChatOverride(chatId: Long) {
|
||||||
|
dataStore.edit { preferences ->
|
||||||
|
preferences.remove(chatOverrideKey(chatId))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun clearChatOverrides() {
|
||||||
|
dataStore.edit { preferences ->
|
||||||
|
val keysToRemove = preferences.asMap().keys
|
||||||
|
.filter { key -> key.name.startsWith(CHAT_OVERRIDE_PREFIX) }
|
||||||
|
keysToRemove.forEach { key -> preferences.remove(key) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun Preferences.chatOverride(chatId: Long): ChatNotificationOverride {
|
||||||
|
return this[chatOverrideKey(chatId)]
|
||||||
|
?.let { runCatching { ChatNotificationOverride.valueOf(it) }.getOrNull() }
|
||||||
|
?: ChatNotificationOverride.DEFAULT
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun chatOverrideKey(chatId: Long) = stringPreferencesKey("$CHAT_OVERRIDE_PREFIX$chatId")
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val CHAT_OVERRIDE_PREFIX = "notification_chat_override_"
|
||||||
|
val GLOBAL_ENABLED_KEY = booleanPreferencesKey("notification_global_enabled")
|
||||||
|
val PREVIEW_ENABLED_KEY = booleanPreferencesKey("notification_preview_enabled")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -22,22 +22,36 @@ class RealtimeEventParser @Inject constructor(
|
|||||||
val payload = root["payload"]?.jsonObject ?: JsonObject(emptyMap())
|
val payload = root["payload"]?.jsonObject ?: JsonObject(emptyMap())
|
||||||
|
|
||||||
return when (event) {
|
return when (event) {
|
||||||
|
"connect" -> RealtimeEvent.Connected
|
||||||
|
|
||||||
"receive_message" -> {
|
"receive_message" -> {
|
||||||
val chatId = payload["chat_id"].longOrNull() ?: return RealtimeEvent.Ignored
|
val chatId = payload["chat_id"].longOrNull() ?: return RealtimeEvent.Ignored
|
||||||
val messageObject = payload["message"]?.jsonObject
|
val messageObject = payload["message"]?.jsonObject
|
||||||
|
val messageId = messageObject?.get("id").longOrNull() ?: return RealtimeEvent.Ignored
|
||||||
|
val senderId = messageObject?.get("sender_id").longOrNull() ?: 0L
|
||||||
RealtimeEvent.ReceiveMessage(
|
RealtimeEvent.ReceiveMessage(
|
||||||
chatId = chatId,
|
chatId = chatId,
|
||||||
|
messageId = messageId,
|
||||||
|
senderId = senderId,
|
||||||
|
replyToMessageId = messageObject?.get("reply_to_message_id").longOrNull(),
|
||||||
text = messageObject?.get("text").stringOrNull(),
|
text = messageObject?.get("text").stringOrNull(),
|
||||||
type = messageObject?.get("type").stringOrNull(),
|
type = messageObject?.get("type").stringOrNull(),
|
||||||
createdAt = messageObject?.get("created_at").stringOrNull(),
|
createdAt = messageObject?.get("created_at").stringOrNull(),
|
||||||
|
isMention = messageObject?.get("is_mention").boolOrNull()
|
||||||
|
?: payload["is_mention"].boolOrNull()
|
||||||
|
?: messageObject?.get("mentions_me").boolOrNull()
|
||||||
|
?: payload["mentions_me"].boolOrNull()
|
||||||
|
?: false,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
"message_updated" -> {
|
"message_updated" -> {
|
||||||
val chatId = payload["chat_id"].longOrNull() ?: return RealtimeEvent.Ignored
|
val chatId = payload["chat_id"].longOrNull() ?: return RealtimeEvent.Ignored
|
||||||
val messageObject = payload["message"]?.jsonObject
|
val messageObject = payload["message"]?.jsonObject
|
||||||
|
val messageId = messageObject?.get("id").longOrNull() ?: return RealtimeEvent.Ignored
|
||||||
RealtimeEvent.MessageUpdated(
|
RealtimeEvent.MessageUpdated(
|
||||||
chatId = chatId,
|
chatId = chatId,
|
||||||
|
messageId = messageId,
|
||||||
text = messageObject?.get("text").stringOrNull(),
|
text = messageObject?.get("text").stringOrNull(),
|
||||||
type = messageObject?.get("type").stringOrNull(),
|
type = messageObject?.get("type").stringOrNull(),
|
||||||
updatedAt = messageObject?.get("updated_at").stringOrNull(),
|
updatedAt = messageObject?.get("updated_at").stringOrNull(),
|
||||||
@@ -53,12 +67,16 @@ class RealtimeEventParser @Inject constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
"chat_updated" -> {
|
"chat_updated" -> {
|
||||||
val chatId = payload["chat_id"].longOrNull() ?: return RealtimeEvent.Ignored
|
val chatId = payload["chat_id"].longOrNull()
|
||||||
|
?: payload["id"].longOrNull()
|
||||||
|
?: return RealtimeEvent.Ignored
|
||||||
RealtimeEvent.ChatUpdated(chatId = chatId)
|
RealtimeEvent.ChatUpdated(chatId = chatId)
|
||||||
}
|
}
|
||||||
|
|
||||||
"chat_deleted" -> {
|
"chat_deleted" -> {
|
||||||
val chatId = payload["chat_id"].longOrNull() ?: return RealtimeEvent.Ignored
|
val chatId = payload["chat_id"].longOrNull()
|
||||||
|
?: payload["id"].longOrNull()
|
||||||
|
?: return RealtimeEvent.Ignored
|
||||||
RealtimeEvent.ChatDeleted(chatId = chatId)
|
RealtimeEvent.ChatDeleted(chatId = chatId)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -75,6 +93,35 @@ class RealtimeEventParser @Inject constructor(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
"message_delivered" -> {
|
||||||
|
val chatId = payload["chat_id"].longOrNull() ?: return RealtimeEvent.Ignored
|
||||||
|
val messageId = payload["message_id"].longOrNull() ?: return RealtimeEvent.Ignored
|
||||||
|
RealtimeEvent.MessageDelivered(chatId = chatId, messageId = messageId)
|
||||||
|
}
|
||||||
|
|
||||||
|
"message_read" -> {
|
||||||
|
val chatId = payload["chat_id"].longOrNull() ?: return RealtimeEvent.Ignored
|
||||||
|
val messageId = payload["message_id"].longOrNull() ?: return RealtimeEvent.Ignored
|
||||||
|
RealtimeEvent.MessageRead(
|
||||||
|
chatId = chatId,
|
||||||
|
messageId = messageId,
|
||||||
|
userId = payload["user_id"].longOrNull(),
|
||||||
|
lastReadMessageId = payload["last_read_message_id"].longOrNull(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
"typing_start" -> {
|
||||||
|
val chatId = payload["chat_id"].longOrNull() ?: return RealtimeEvent.Ignored
|
||||||
|
RealtimeEvent.TypingStart(chatId = chatId, userId = payload["user_id"].longOrNull())
|
||||||
|
}
|
||||||
|
|
||||||
|
"typing_stop" -> {
|
||||||
|
val chatId = payload["chat_id"].longOrNull() ?: return RealtimeEvent.Ignored
|
||||||
|
RealtimeEvent.TypingStop(chatId = chatId, userId = payload["user_id"].longOrNull())
|
||||||
|
}
|
||||||
|
|
||||||
|
"pong" -> RealtimeEvent.Ignored
|
||||||
|
|
||||||
else -> RealtimeEvent.Ignored
|
else -> RealtimeEvent.Ignored
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -86,4 +133,13 @@ class RealtimeEventParser @Inject constructor(
|
|||||||
private fun JsonElement?.longOrNull(): Long? {
|
private fun JsonElement?.longOrNull(): Long? {
|
||||||
return this?.jsonPrimitive?.contentOrNull?.toLongOrNull()
|
return this?.jsonPrimitive?.contentOrNull?.toLongOrNull()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun JsonElement?.boolOrNull(): Boolean? {
|
||||||
|
val raw = this?.jsonPrimitive?.contentOrNull?.trim()?.lowercase() ?: return null
|
||||||
|
return when (raw) {
|
||||||
|
"true", "1" -> true
|
||||||
|
"false", "0" -> false
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,11 +2,14 @@ package ru.daemonlord.messenger.data.realtime
|
|||||||
|
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
import kotlinx.coroutines.SupervisorJob
|
import kotlinx.coroutines.SupervisorJob
|
||||||
import kotlinx.coroutines.cancel
|
import kotlinx.coroutines.cancel
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.asSharedFlow
|
import kotlinx.coroutines.flow.asSharedFlow
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import okhttp3.OkHttpClient
|
import okhttp3.OkHttpClient
|
||||||
@@ -18,8 +21,10 @@ import ru.daemonlord.messenger.BuildConfig
|
|||||||
import ru.daemonlord.messenger.core.token.TokenRepository
|
import ru.daemonlord.messenger.core.token.TokenRepository
|
||||||
import ru.daemonlord.messenger.di.RefreshClient
|
import ru.daemonlord.messenger.di.RefreshClient
|
||||||
import ru.daemonlord.messenger.domain.realtime.RealtimeManager
|
import ru.daemonlord.messenger.domain.realtime.RealtimeManager
|
||||||
|
import ru.daemonlord.messenger.domain.realtime.model.RealtimeConnectionState
|
||||||
import ru.daemonlord.messenger.domain.realtime.model.RealtimeEvent
|
import ru.daemonlord.messenger.domain.realtime.model.RealtimeEvent
|
||||||
import java.util.concurrent.atomic.AtomicBoolean
|
import java.util.concurrent.atomic.AtomicBoolean
|
||||||
|
import java.util.concurrent.atomic.AtomicLong
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
|
|
||||||
@@ -36,24 +41,35 @@ class WsRealtimeManager @Inject constructor(
|
|||||||
private val isConnected = AtomicBoolean(false)
|
private val isConnected = AtomicBoolean(false)
|
||||||
private val manualDisconnect = AtomicBoolean(false)
|
private val manualDisconnect = AtomicBoolean(false)
|
||||||
private var reconnectDelayMs: Long = INITIAL_RECONNECT_MS
|
private var reconnectDelayMs: Long = INITIAL_RECONNECT_MS
|
||||||
|
private val lastPongAtMs = AtomicLong(0L)
|
||||||
|
private var heartbeatJob: Job? = null
|
||||||
|
|
||||||
override val events: Flow<RealtimeEvent> = eventFlow.asSharedFlow()
|
override val events: Flow<RealtimeEvent> = eventFlow.asSharedFlow()
|
||||||
|
private val _connectionState = MutableStateFlow(RealtimeConnectionState.Disconnected)
|
||||||
|
override val connectionState: StateFlow<RealtimeConnectionState> = _connectionState
|
||||||
|
|
||||||
override fun connect() {
|
override fun connect() {
|
||||||
if (isConnected.get()) return
|
if (isConnected.get()) return
|
||||||
manualDisconnect.set(false)
|
manualDisconnect.set(false)
|
||||||
|
_connectionState.value = RealtimeConnectionState.Connecting
|
||||||
scope.launch { openSocket() }
|
scope.launch { openSocket() }
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun disconnect() {
|
override fun disconnect() {
|
||||||
manualDisconnect.set(true)
|
manualDisconnect.set(true)
|
||||||
isConnected.set(false)
|
isConnected.set(false)
|
||||||
|
_connectionState.value = RealtimeConnectionState.Disconnected
|
||||||
|
heartbeatJob?.cancel()
|
||||||
|
heartbeatJob = null
|
||||||
socket?.close(1000, "Client disconnect")
|
socket?.close(1000, "Client disconnect")
|
||||||
socket = null
|
socket = null
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun openSocket() {
|
private suspend fun openSocket() {
|
||||||
val accessToken = tokenRepository.getTokens()?.accessToken ?: return
|
val accessToken = tokenRepository.getTokens()?.accessToken ?: run {
|
||||||
|
_connectionState.value = RealtimeConnectionState.Disconnected
|
||||||
|
return
|
||||||
|
}
|
||||||
val wsUrl = BuildConfig.API_BASE_URL
|
val wsUrl = BuildConfig.API_BASE_URL
|
||||||
.replace("http://", "ws://")
|
.replace("http://", "ws://")
|
||||||
.replace("https://", "wss://")
|
.replace("https://", "wss://")
|
||||||
@@ -66,6 +82,7 @@ class WsRealtimeManager @Inject constructor(
|
|||||||
|
|
||||||
private fun scheduleReconnect() {
|
private fun scheduleReconnect() {
|
||||||
if (manualDisconnect.get()) return
|
if (manualDisconnect.get()) return
|
||||||
|
_connectionState.value = RealtimeConnectionState.Reconnecting
|
||||||
scope.launch {
|
scope.launch {
|
||||||
delay(reconnectDelayMs)
|
delay(reconnectDelayMs)
|
||||||
reconnectDelayMs = (reconnectDelayMs * 2).coerceAtMost(MAX_RECONNECT_MS)
|
reconnectDelayMs = (reconnectDelayMs * 2).coerceAtMost(MAX_RECONNECT_MS)
|
||||||
@@ -73,28 +90,61 @@ class WsRealtimeManager @Inject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun startHeartbeat(webSocket: WebSocket) {
|
||||||
|
heartbeatJob?.cancel()
|
||||||
|
lastPongAtMs.set(System.currentTimeMillis())
|
||||||
|
heartbeatJob = scope.launch {
|
||||||
|
while (isConnected.get() && !manualDisconnect.get()) {
|
||||||
|
val now = System.currentTimeMillis()
|
||||||
|
if (now - lastPongAtMs.get() > PONG_TIMEOUT_MS) {
|
||||||
|
webSocket.close(1001, "Heartbeat timeout")
|
||||||
|
break
|
||||||
|
}
|
||||||
|
webSocket.send("""{"event":"ping","payload":{}}""")
|
||||||
|
delay(PING_INTERVAL_MS)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private val listener = object : WebSocketListener() {
|
private val listener = object : WebSocketListener() {
|
||||||
override fun onOpen(webSocket: WebSocket, response: Response) {
|
override fun onOpen(webSocket: WebSocket, response: Response) {
|
||||||
isConnected.set(true)
|
isConnected.set(true)
|
||||||
|
_connectionState.value = RealtimeConnectionState.Connected
|
||||||
reconnectDelayMs = INITIAL_RECONNECT_MS
|
reconnectDelayMs = INITIAL_RECONNECT_MS
|
||||||
|
startHeartbeat(webSocket)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onMessage(webSocket: WebSocket, text: String) {
|
override fun onMessage(webSocket: WebSocket, text: String) {
|
||||||
|
if (text.contains("\"event\":\"pong\"")) {
|
||||||
|
lastPongAtMs.set(System.currentTimeMillis())
|
||||||
|
}
|
||||||
eventFlow.tryEmit(parser.parse(text))
|
eventFlow.tryEmit(parser.parse(text))
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onClosing(webSocket: WebSocket, code: Int, reason: String) {
|
override fun onClosing(webSocket: WebSocket, code: Int, reason: String) {
|
||||||
isConnected.set(false)
|
isConnected.set(false)
|
||||||
|
if (!manualDisconnect.get()) {
|
||||||
|
_connectionState.value = RealtimeConnectionState.Reconnecting
|
||||||
|
}
|
||||||
|
heartbeatJob?.cancel()
|
||||||
webSocket.close(code, reason)
|
webSocket.close(code, reason)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {
|
override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {
|
||||||
isConnected.set(false)
|
isConnected.set(false)
|
||||||
|
if (!manualDisconnect.get()) {
|
||||||
|
_connectionState.value = RealtimeConnectionState.Reconnecting
|
||||||
|
}
|
||||||
|
heartbeatJob?.cancel()
|
||||||
scheduleReconnect()
|
scheduleReconnect()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {
|
override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {
|
||||||
isConnected.set(false)
|
isConnected.set(false)
|
||||||
|
if (!manualDisconnect.get()) {
|
||||||
|
_connectionState.value = RealtimeConnectionState.Reconnecting
|
||||||
|
}
|
||||||
|
heartbeatJob?.cancel()
|
||||||
scheduleReconnect()
|
scheduleReconnect()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -108,5 +158,7 @@ class WsRealtimeManager @Inject constructor(
|
|||||||
private companion object {
|
private companion object {
|
||||||
const val INITIAL_RECONNECT_MS = 1_000L
|
const val INITIAL_RECONNECT_MS = 1_000L
|
||||||
const val MAX_RECONNECT_MS = 30_000L
|
const val MAX_RECONNECT_MS = 30_000L
|
||||||
|
const val PING_INTERVAL_MS = 25_000L
|
||||||
|
const val PONG_TIMEOUT_MS = 65_000L
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,6 +9,8 @@ import dagger.hilt.android.qualifiers.ApplicationContext
|
|||||||
import dagger.hilt.components.SingletonComponent
|
import dagger.hilt.components.SingletonComponent
|
||||||
import ru.daemonlord.messenger.data.chat.local.dao.ChatDao
|
import ru.daemonlord.messenger.data.chat.local.dao.ChatDao
|
||||||
import ru.daemonlord.messenger.data.chat.local.db.MessengerDatabase
|
import ru.daemonlord.messenger.data.chat.local.db.MessengerDatabase
|
||||||
|
import ru.daemonlord.messenger.data.message.local.dao.MessageDao
|
||||||
|
import ru.daemonlord.messenger.data.message.local.dao.PendingMessageActionDao
|
||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
|
|
||||||
@Module
|
@Module
|
||||||
@@ -31,4 +33,13 @@ object DatabaseModule {
|
|||||||
@Provides
|
@Provides
|
||||||
@Singleton
|
@Singleton
|
||||||
fun provideChatDao(database: MessengerDatabase): ChatDao = database.chatDao()
|
fun provideChatDao(database: MessengerDatabase): ChatDao = database.chatDao()
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
@Singleton
|
||||||
|
fun provideMessageDao(database: MessengerDatabase): MessageDao = database.messageDao()
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
@Singleton
|
||||||
|
fun providePendingMessageActionDao(database: MessengerDatabase): PendingMessageActionDao =
|
||||||
|
database.pendingMessageActionDao()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
package ru.daemonlord.messenger.di
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.media3.database.StandaloneDatabaseProvider
|
||||||
|
import androidx.media3.datasource.cache.Cache
|
||||||
|
import androidx.media3.datasource.cache.LeastRecentlyUsedCacheEvictor
|
||||||
|
import androidx.media3.datasource.cache.SimpleCache
|
||||||
|
import dagger.Module
|
||||||
|
import dagger.Provides
|
||||||
|
import dagger.hilt.InstallIn
|
||||||
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
|
import dagger.hilt.components.SingletonComponent
|
||||||
|
import java.io.File
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
@Module
|
||||||
|
@InstallIn(SingletonComponent::class)
|
||||||
|
object MediaCacheModule {
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
@Singleton
|
||||||
|
fun provideMediaCache(
|
||||||
|
@ApplicationContext context: Context,
|
||||||
|
): Cache {
|
||||||
|
val cacheDir = File(context.cacheDir, "exo_media_cache")
|
||||||
|
if (!cacheDir.exists()) {
|
||||||
|
cacheDir.mkdirs()
|
||||||
|
}
|
||||||
|
val maxBytes = 200L * 1024L * 1024L
|
||||||
|
return SimpleCache(
|
||||||
|
cacheDir,
|
||||||
|
LeastRecentlyUsedCacheEvictor(maxBytes),
|
||||||
|
StandaloneDatabaseProvider(context),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -13,9 +13,16 @@ import okhttp3.logging.HttpLoggingInterceptor
|
|||||||
import retrofit2.Retrofit
|
import retrofit2.Retrofit
|
||||||
import ru.daemonlord.messenger.BuildConfig
|
import ru.daemonlord.messenger.BuildConfig
|
||||||
import ru.daemonlord.messenger.core.network.AuthHeaderInterceptor
|
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.core.network.TokenRefreshAuthenticator
|
||||||
import ru.daemonlord.messenger.data.auth.api.AuthApiService
|
import ru.daemonlord.messenger.data.auth.api.AuthApiService
|
||||||
import ru.daemonlord.messenger.data.chat.api.ChatApiService
|
import ru.daemonlord.messenger.data.chat.api.ChatApiService
|
||||||
|
import ru.daemonlord.messenger.data.media.api.MediaApiService
|
||||||
|
import ru.daemonlord.messenger.data.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 com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory
|
||||||
import java.util.concurrent.TimeUnit
|
import java.util.concurrent.TimeUnit
|
||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
@@ -85,11 +92,13 @@ object NetworkModule {
|
|||||||
@Singleton
|
@Singleton
|
||||||
fun provideApiClient(
|
fun provideApiClient(
|
||||||
loggingInterceptor: HttpLoggingInterceptor,
|
loggingInterceptor: HttpLoggingInterceptor,
|
||||||
|
apiVersionInterceptor: ApiVersionInterceptor,
|
||||||
authHeaderInterceptor: AuthHeaderInterceptor,
|
authHeaderInterceptor: AuthHeaderInterceptor,
|
||||||
tokenRefreshAuthenticator: TokenRefreshAuthenticator,
|
tokenRefreshAuthenticator: TokenRefreshAuthenticator,
|
||||||
): OkHttpClient {
|
): OkHttpClient {
|
||||||
return OkHttpClient.Builder()
|
return OkHttpClient.Builder()
|
||||||
.addInterceptor(loggingInterceptor)
|
.addInterceptor(loggingInterceptor)
|
||||||
|
.addInterceptor(apiVersionInterceptor)
|
||||||
.addInterceptor(authHeaderInterceptor)
|
.addInterceptor(authHeaderInterceptor)
|
||||||
.authenticator(tokenRefreshAuthenticator)
|
.authenticator(tokenRefreshAuthenticator)
|
||||||
.connectTimeout(20, TimeUnit.SECONDS)
|
.connectTimeout(20, TimeUnit.SECONDS)
|
||||||
@@ -123,4 +132,40 @@ object NetworkModule {
|
|||||||
fun provideChatApiService(retrofit: Retrofit): ChatApiService {
|
fun provideChatApiService(retrofit: Retrofit): ChatApiService {
|
||||||
return retrofit.create(ChatApiService::class.java)
|
return retrofit.create(ChatApiService::class.java)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
@Singleton
|
||||||
|
fun provideMessageApiService(retrofit: Retrofit): MessageApiService {
|
||||||
|
return retrofit.create(MessageApiService::class.java)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
@Singleton
|
||||||
|
fun provideMediaApiService(retrofit: Retrofit): MediaApiService {
|
||||||
|
return retrofit.create(MediaApiService::class.java)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
@Singleton
|
||||||
|
fun provideUserApiService(retrofit: Retrofit): UserApiService {
|
||||||
|
return retrofit.create(UserApiService::class.java)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
@Singleton
|
||||||
|
fun provideSearchApiService(retrofit: Retrofit): SearchApiService {
|
||||||
|
return retrofit.create(SearchApiService::class.java)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
@Singleton
|
||||||
|
fun providePushTokenApiService(retrofit: Retrofit): PushTokenApiService {
|
||||||
|
return retrofit.create(PushTokenApiService::class.java)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
@Singleton
|
||||||
|
fun provideNotificationApiService(retrofit: Retrofit): NotificationApiService {
|
||||||
|
return retrofit.create(NotificationApiService::class.java)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,3 +13,7 @@ annotation class RefreshAuthApi
|
|||||||
@Qualifier
|
@Qualifier
|
||||||
@Retention(AnnotationRetention.BINARY)
|
@Retention(AnnotationRetention.BINARY)
|
||||||
annotation class IoDispatcher
|
annotation class IoDispatcher
|
||||||
|
|
||||||
|
@Qualifier
|
||||||
|
@Retention(AnnotationRetention.BINARY)
|
||||||
|
annotation class TokenPrefs
|
||||||
|
|||||||
@@ -5,9 +5,27 @@ import dagger.Module
|
|||||||
import dagger.hilt.InstallIn
|
import dagger.hilt.InstallIn
|
||||||
import dagger.hilt.components.SingletonComponent
|
import dagger.hilt.components.SingletonComponent
|
||||||
import ru.daemonlord.messenger.data.auth.repository.NetworkAuthRepository
|
import ru.daemonlord.messenger.data.auth.repository.NetworkAuthRepository
|
||||||
|
import ru.daemonlord.messenger.data.auth.repository.DefaultSessionCleanupRepository
|
||||||
import ru.daemonlord.messenger.data.chat.repository.NetworkChatRepository
|
import ru.daemonlord.messenger.data.chat.repository.NetworkChatRepository
|
||||||
|
import ru.daemonlord.messenger.data.chat.repository.DataStoreChatSearchRepository
|
||||||
|
import ru.daemonlord.messenger.data.media.repository.NetworkMediaRepository
|
||||||
|
import ru.daemonlord.messenger.data.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.AuthRepository
|
||||||
|
import ru.daemonlord.messenger.domain.auth.repository.SessionCleanupRepository
|
||||||
import ru.daemonlord.messenger.domain.chat.repository.ChatRepository
|
import ru.daemonlord.messenger.domain.chat.repository.ChatRepository
|
||||||
|
import ru.daemonlord.messenger.domain.chat.repository.ChatSearchRepository
|
||||||
|
import ru.daemonlord.messenger.domain.media.repository.MediaRepository
|
||||||
|
import ru.daemonlord.messenger.domain.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
|
import javax.inject.Singleton
|
||||||
|
|
||||||
@Module
|
@Module
|
||||||
@@ -20,9 +38,63 @@ abstract class RepositoryModule {
|
|||||||
repository: NetworkAuthRepository,
|
repository: NetworkAuthRepository,
|
||||||
): AuthRepository
|
): AuthRepository
|
||||||
|
|
||||||
|
@Binds
|
||||||
|
@Singleton
|
||||||
|
abstract fun bindSessionCleanupRepository(
|
||||||
|
repository: DefaultSessionCleanupRepository,
|
||||||
|
): SessionCleanupRepository
|
||||||
|
|
||||||
@Binds
|
@Binds
|
||||||
@Singleton
|
@Singleton
|
||||||
abstract fun bindChatRepository(
|
abstract fun bindChatRepository(
|
||||||
repository: NetworkChatRepository,
|
repository: NetworkChatRepository,
|
||||||
): ChatRepository
|
): ChatRepository
|
||||||
|
|
||||||
|
@Binds
|
||||||
|
@Singleton
|
||||||
|
abstract fun bindChatSearchRepository(
|
||||||
|
repository: DataStoreChatSearchRepository,
|
||||||
|
): ChatSearchRepository
|
||||||
|
|
||||||
|
@Binds
|
||||||
|
@Singleton
|
||||||
|
abstract fun bindMessageRepository(
|
||||||
|
repository: NetworkMessageRepository,
|
||||||
|
): MessageRepository
|
||||||
|
|
||||||
|
@Binds
|
||||||
|
@Singleton
|
||||||
|
abstract fun bindMediaRepository(
|
||||||
|
repository: NetworkMediaRepository,
|
||||||
|
): MediaRepository
|
||||||
|
|
||||||
|
@Binds
|
||||||
|
@Singleton
|
||||||
|
abstract fun bindNotificationSettingsRepository(
|
||||||
|
repository: DataStoreNotificationSettingsRepository,
|
||||||
|
): NotificationSettingsRepository
|
||||||
|
|
||||||
|
@Binds
|
||||||
|
@Singleton
|
||||||
|
abstract fun bindAccountRepository(
|
||||||
|
repository: NetworkAccountRepository,
|
||||||
|
): AccountRepository
|
||||||
|
|
||||||
|
@Binds
|
||||||
|
@Singleton
|
||||||
|
abstract fun bindSearchRepository(
|
||||||
|
repository: NetworkSearchRepository,
|
||||||
|
): SearchRepository
|
||||||
|
|
||||||
|
@Binds
|
||||||
|
@Singleton
|
||||||
|
abstract fun bindThemeRepository(
|
||||||
|
repository: DataStoreThemeRepository,
|
||||||
|
): ThemeRepository
|
||||||
|
|
||||||
|
@Binds
|
||||||
|
@Singleton
|
||||||
|
abstract fun bindLanguageRepository(
|
||||||
|
repository: DataStoreLanguageRepository,
|
||||||
|
): LanguageRepository
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,16 +1,19 @@
|
|||||||
package ru.daemonlord.messenger.di
|
package ru.daemonlord.messenger.di
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import android.content.SharedPreferences
|
||||||
import androidx.datastore.core.DataStore
|
import androidx.datastore.core.DataStore
|
||||||
import androidx.datastore.preferences.core.Preferences
|
import androidx.datastore.preferences.core.Preferences
|
||||||
import androidx.datastore.preferences.core.PreferenceDataStoreFactory
|
import androidx.datastore.preferences.core.PreferenceDataStoreFactory
|
||||||
import androidx.datastore.preferences.preferencesDataStoreFile
|
import androidx.datastore.preferences.preferencesDataStoreFile
|
||||||
|
import androidx.security.crypto.EncryptedSharedPreferences
|
||||||
|
import androidx.security.crypto.MasterKey
|
||||||
import dagger.Module
|
import dagger.Module
|
||||||
import dagger.Provides
|
import dagger.Provides
|
||||||
import dagger.hilt.InstallIn
|
import dagger.hilt.InstallIn
|
||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
import dagger.hilt.components.SingletonComponent
|
import dagger.hilt.components.SingletonComponent
|
||||||
import ru.daemonlord.messenger.core.token.DataStoreTokenRepository
|
import ru.daemonlord.messenger.core.token.EncryptedPrefsTokenRepository
|
||||||
import ru.daemonlord.messenger.core.token.TokenRepository
|
import ru.daemonlord.messenger.core.token.TokenRepository
|
||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
|
|
||||||
@@ -24,13 +27,31 @@ object StorageModule {
|
|||||||
@ApplicationContext context: Context,
|
@ApplicationContext context: Context,
|
||||||
): DataStore<Preferences> {
|
): DataStore<Preferences> {
|
||||||
return PreferenceDataStoreFactory.create(
|
return PreferenceDataStoreFactory.create(
|
||||||
produceFile = { context.preferencesDataStoreFile("messenger_tokens.preferences_pb") }
|
produceFile = { context.preferencesDataStoreFile("messenger_preferences.preferences_pb") }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
@Singleton
|
||||||
|
@TokenPrefs
|
||||||
|
fun provideTokenSharedPreferences(
|
||||||
|
@ApplicationContext context: Context,
|
||||||
|
): SharedPreferences {
|
||||||
|
val masterKey = MasterKey.Builder(context)
|
||||||
|
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
|
||||||
|
.build()
|
||||||
|
return EncryptedSharedPreferences.create(
|
||||||
|
context,
|
||||||
|
"messenger_secure_tokens",
|
||||||
|
masterKey,
|
||||||
|
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
|
||||||
|
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Provides
|
@Provides
|
||||||
@Singleton
|
@Singleton
|
||||||
fun provideTokenRepository(
|
fun provideTokenRepository(
|
||||||
repository: DataStoreTokenRepository,
|
repository: EncryptedPrefsTokenRepository,
|
||||||
): TokenRepository = repository
|
): TokenRepository = repository
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
)
|
||||||
|
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
package ru.daemonlord.messenger.domain.auth.model
|
||||||
|
|
||||||
|
data class AuthSession(
|
||||||
|
val jti: String,
|
||||||
|
val createdAt: String,
|
||||||
|
val ipAddress: String?,
|
||||||
|
val userAgent: String?,
|
||||||
|
val current: Boolean?,
|
||||||
|
val tokenType: String?,
|
||||||
|
)
|
||||||
@@ -5,6 +5,12 @@ data class AuthUser(
|
|||||||
val email: String,
|
val email: String,
|
||||||
val name: String,
|
val name: String,
|
||||||
val username: String,
|
val username: String,
|
||||||
|
val bio: String?,
|
||||||
val avatarUrl: String?,
|
val avatarUrl: String?,
|
||||||
val emailVerified: Boolean,
|
val emailVerified: Boolean,
|
||||||
|
val twofaEnabled: Boolean,
|
||||||
|
val privacyPrivateMessages: String,
|
||||||
|
val privacyLastSeen: String,
|
||||||
|
val privacyAvatar: String,
|
||||||
|
val privacyGroupInvites: String,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,12 +1,24 @@
|
|||||||
package ru.daemonlord.messenger.domain.auth.repository
|
package ru.daemonlord.messenger.domain.auth.repository
|
||||||
|
|
||||||
import ru.daemonlord.messenger.domain.auth.model.AuthUser
|
import ru.daemonlord.messenger.domain.auth.model.AuthUser
|
||||||
|
import ru.daemonlord.messenger.domain.auth.model.AuthSession
|
||||||
|
import ru.daemonlord.messenger.domain.auth.model.AuthEmailStatus
|
||||||
import ru.daemonlord.messenger.domain.common.AppResult
|
import ru.daemonlord.messenger.domain.common.AppResult
|
||||||
|
|
||||||
interface AuthRepository {
|
interface AuthRepository {
|
||||||
suspend fun login(email: String, password: String): AppResult<AuthUser>
|
suspend fun checkEmailStatus(email: String): AppResult<AuthEmailStatus>
|
||||||
|
suspend fun register(email: String, name: String, username: String, password: String): AppResult<Unit>
|
||||||
|
suspend fun login(
|
||||||
|
email: String,
|
||||||
|
password: String,
|
||||||
|
otpCode: String? = null,
|
||||||
|
recoveryCode: String? = null,
|
||||||
|
): AppResult<AuthUser>
|
||||||
suspend fun refreshTokens(): AppResult<Unit>
|
suspend fun refreshTokens(): AppResult<Unit>
|
||||||
suspend fun getMe(): AppResult<AuthUser>
|
suspend fun getMe(): AppResult<AuthUser>
|
||||||
suspend fun restoreSession(): AppResult<AuthUser>
|
suspend fun restoreSession(): AppResult<AuthUser>
|
||||||
|
suspend fun listSessions(): AppResult<List<AuthSession>>
|
||||||
|
suspend fun revokeSession(jti: String): AppResult<Unit>
|
||||||
|
suspend fun revokeAllSessions(): AppResult<Unit>
|
||||||
suspend fun logout()
|
suspend fun logout()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
package ru.daemonlord.messenger.domain.auth.repository
|
||||||
|
|
||||||
|
interface SessionCleanupRepository {
|
||||||
|
suspend fun clearLocalSessionData()
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
package ru.daemonlord.messenger.domain.auth.usecase
|
||||||
|
|
||||||
|
import ru.daemonlord.messenger.domain.auth.model.AuthSession
|
||||||
|
import ru.daemonlord.messenger.domain.auth.repository.AuthRepository
|
||||||
|
import ru.daemonlord.messenger.domain.common.AppResult
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
class ListSessionsUseCase @Inject constructor(
|
||||||
|
private val repository: AuthRepository,
|
||||||
|
) {
|
||||||
|
suspend operator fun invoke(): AppResult<List<AuthSession>> {
|
||||||
|
return repository.listSessions()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,7 +8,17 @@ import javax.inject.Inject
|
|||||||
class LoginUseCase @Inject constructor(
|
class LoginUseCase @Inject constructor(
|
||||||
private val authRepository: AuthRepository,
|
private val authRepository: AuthRepository,
|
||||||
) {
|
) {
|
||||||
suspend operator fun invoke(email: String, password: String): AppResult<AuthUser> {
|
suspend operator fun invoke(
|
||||||
return authRepository.login(email = email, password = password)
|
email: String,
|
||||||
|
password: String,
|
||||||
|
otpCode: String? = null,
|
||||||
|
recoveryCode: String? = null,
|
||||||
|
): AppResult<AuthUser> {
|
||||||
|
return authRepository.login(
|
||||||
|
email = email,
|
||||||
|
password = password,
|
||||||
|
otpCode = otpCode,
|
||||||
|
recoveryCode = recoveryCode,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
package ru.daemonlord.messenger.domain.auth.usecase
|
||||||
|
|
||||||
|
import ru.daemonlord.messenger.core.notifications.ActiveChatTracker
|
||||||
|
import ru.daemonlord.messenger.domain.auth.repository.AuthRepository
|
||||||
|
import ru.daemonlord.messenger.domain.auth.repository.SessionCleanupRepository
|
||||||
|
import ru.daemonlord.messenger.domain.realtime.RealtimeManager
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
class LogoutUseCase @Inject constructor(
|
||||||
|
private val authRepository: AuthRepository,
|
||||||
|
private val sessionCleanupRepository: SessionCleanupRepository,
|
||||||
|
private val realtimeManager: RealtimeManager,
|
||||||
|
private val activeChatTracker: ActiveChatTracker,
|
||||||
|
) {
|
||||||
|
suspend operator fun invoke() {
|
||||||
|
realtimeManager.disconnect()
|
||||||
|
activeChatTracker.clear()
|
||||||
|
authRepository.logout()
|
||||||
|
sessionCleanupRepository.clearLocalSessionData()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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,13 @@
|
|||||||
|
package ru.daemonlord.messenger.domain.auth.usecase
|
||||||
|
|
||||||
|
import ru.daemonlord.messenger.domain.auth.repository.AuthRepository
|
||||||
|
import ru.daemonlord.messenger.domain.common.AppResult
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
class RevokeAllSessionsUseCase @Inject constructor(
|
||||||
|
private val repository: AuthRepository,
|
||||||
|
) {
|
||||||
|
suspend operator fun invoke(): AppResult<Unit> {
|
||||||
|
return repository.revokeAllSessions()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
package ru.daemonlord.messenger.domain.auth.usecase
|
||||||
|
|
||||||
|
import ru.daemonlord.messenger.domain.auth.repository.AuthRepository
|
||||||
|
import ru.daemonlord.messenger.domain.common.AppResult
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
class RevokeSessionUseCase @Inject constructor(
|
||||||
|
private val repository: AuthRepository,
|
||||||
|
) {
|
||||||
|
suspend operator fun invoke(jti: String): AppResult<Unit> {
|
||||||
|
return repository.revokeSession(jti = jti)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,7 @@
|
|||||||
|
package ru.daemonlord.messenger.domain.chat.model
|
||||||
|
|
||||||
|
data class ChatInviteLink(
|
||||||
|
val chatId: Long,
|
||||||
|
val token: String,
|
||||||
|
val inviteUrl: String,
|
||||||
|
)
|
||||||
@@ -21,5 +21,7 @@ data class ChatItem(
|
|||||||
val lastMessageText: String?,
|
val lastMessageText: String?,
|
||||||
val lastMessageType: String?,
|
val lastMessageType: String?,
|
||||||
val lastMessageCreatedAt: String?,
|
val lastMessageCreatedAt: String?,
|
||||||
|
val pinnedMessageId: Long?,
|
||||||
|
val myRole: String?,
|
||||||
val updatedSortAt: String?,
|
val updatedSortAt: 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,12 +1,54 @@
|
|||||||
package ru.daemonlord.messenger.domain.chat.repository
|
package ru.daemonlord.messenger.domain.chat.repository
|
||||||
|
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import ru.daemonlord.messenger.domain.chat.model.ChatBanItem
|
||||||
|
import ru.daemonlord.messenger.domain.chat.model.ChatInviteLink
|
||||||
import ru.daemonlord.messenger.domain.chat.model.ChatItem
|
import ru.daemonlord.messenger.domain.chat.model.ChatItem
|
||||||
|
import ru.daemonlord.messenger.domain.chat.model.ChatMemberItem
|
||||||
|
import ru.daemonlord.messenger.domain.chat.model.ChatNotificationSettings
|
||||||
|
import ru.daemonlord.messenger.domain.chat.model.DiscoverChatItem
|
||||||
import ru.daemonlord.messenger.domain.common.AppResult
|
import ru.daemonlord.messenger.domain.common.AppResult
|
||||||
|
|
||||||
interface ChatRepository {
|
interface ChatRepository {
|
||||||
fun observeChats(archived: Boolean): Flow<List<ChatItem>>
|
fun observeChats(archived: Boolean): Flow<List<ChatItem>>
|
||||||
|
fun observeChat(chatId: Long): Flow<ChatItem?>
|
||||||
suspend fun refreshChats(archived: Boolean): AppResult<Unit>
|
suspend fun refreshChats(archived: Boolean): AppResult<Unit>
|
||||||
suspend fun refreshChat(chatId: Long): AppResult<Unit>
|
suspend fun refreshChat(chatId: Long): AppResult<Unit>
|
||||||
|
suspend fun getSavedChat(): AppResult<ChatItem>
|
||||||
|
suspend fun createInviteLink(chatId: Long): AppResult<ChatInviteLink>
|
||||||
|
suspend fun joinByInvite(token: String): AppResult<ChatItem>
|
||||||
|
suspend fun createChat(
|
||||||
|
type: String,
|
||||||
|
title: String?,
|
||||||
|
isPublic: Boolean,
|
||||||
|
handle: String?,
|
||||||
|
description: String?,
|
||||||
|
memberIds: List<Long>,
|
||||||
|
): AppResult<ChatItem>
|
||||||
|
suspend fun discoverChats(query: String?): AppResult<List<DiscoverChatItem>>
|
||||||
|
suspend fun joinChat(chatId: Long): AppResult<ChatItem>
|
||||||
|
suspend fun leaveChat(chatId: Long): AppResult<Unit>
|
||||||
|
suspend fun archiveChat(chatId: Long): AppResult<ChatItem>
|
||||||
|
suspend fun unarchiveChat(chatId: Long): AppResult<ChatItem>
|
||||||
|
suspend fun pinChat(chatId: Long): AppResult<ChatItem>
|
||||||
|
suspend fun unpinChat(chatId: Long): AppResult<ChatItem>
|
||||||
|
suspend fun updateChatTitle(chatId: Long, title: String): AppResult<ChatItem>
|
||||||
|
suspend fun updateChatProfile(
|
||||||
|
chatId: Long,
|
||||||
|
title: String? = null,
|
||||||
|
description: String? = null,
|
||||||
|
avatarUrl: String? = null,
|
||||||
|
): AppResult<ChatItem>
|
||||||
|
suspend fun clearChat(chatId: Long): AppResult<Unit>
|
||||||
|
suspend fun removeChat(chatId: Long, forAll: Boolean = false): AppResult<Unit>
|
||||||
|
suspend fun getChatNotifications(chatId: Long): AppResult<ChatNotificationSettings>
|
||||||
|
suspend fun updateChatNotifications(chatId: Long, muted: Boolean): AppResult<ChatNotificationSettings>
|
||||||
|
suspend fun listMembers(chatId: Long): AppResult<List<ChatMemberItem>>
|
||||||
|
suspend fun listBans(chatId: Long): AppResult<List<ChatBanItem>>
|
||||||
|
suspend fun addMember(chatId: Long, userId: Long): AppResult<ChatMemberItem>
|
||||||
|
suspend fun updateMemberRole(chatId: Long, userId: Long, role: String): AppResult<ChatMemberItem>
|
||||||
|
suspend fun removeMember(chatId: Long, userId: Long): AppResult<Unit>
|
||||||
|
suspend fun banMember(chatId: Long, userId: Long): AppResult<Unit>
|
||||||
|
suspend fun unbanMember(chatId: Long, userId: Long): AppResult<Unit>
|
||||||
suspend fun deleteChat(chatId: Long)
|
suspend fun deleteChat(chatId: Long)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,14 @@
|
|||||||
|
package ru.daemonlord.messenger.domain.chat.usecase
|
||||||
|
|
||||||
|
import ru.daemonlord.messenger.domain.chat.model.ChatInviteLink
|
||||||
|
import ru.daemonlord.messenger.domain.chat.repository.ChatRepository
|
||||||
|
import ru.daemonlord.messenger.domain.common.AppResult
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
class CreateInviteLinkUseCase @Inject constructor(
|
||||||
|
private val repository: ChatRepository,
|
||||||
|
) {
|
||||||
|
suspend operator fun invoke(chatId: Long): AppResult<ChatInviteLink> {
|
||||||
|
return repository.createInviteLink(chatId = chatId)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
package ru.daemonlord.messenger.domain.chat.usecase
|
||||||
|
|
||||||
|
import ru.daemonlord.messenger.domain.chat.model.ChatItem
|
||||||
|
import ru.daemonlord.messenger.domain.chat.repository.ChatRepository
|
||||||
|
import ru.daemonlord.messenger.domain.common.AppResult
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
class JoinByInviteUseCase @Inject constructor(
|
||||||
|
private val repository: ChatRepository,
|
||||||
|
) {
|
||||||
|
suspend operator fun invoke(token: String): AppResult<ChatItem> {
|
||||||
|
return repository.joinByInvite(token = token)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
package ru.daemonlord.messenger.domain.chat.usecase
|
||||||
|
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import ru.daemonlord.messenger.domain.chat.model.ChatItem
|
||||||
|
import ru.daemonlord.messenger.domain.chat.repository.ChatRepository
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
class ObserveChatUseCase @Inject constructor(
|
||||||
|
private val repository: ChatRepository,
|
||||||
|
) {
|
||||||
|
operator fun invoke(chatId: Long): Flow<ChatItem?> {
|
||||||
|
return repository.observeChat(chatId = chatId)
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user