Compare commits
407 Commits
74d9163dde
...
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 | ||
|
|
4939754de8 | ||
|
|
9d842c1d88 | ||
|
|
2dfad1a624 | ||
|
|
21aa11c342 | ||
|
|
d006998867 | ||
|
|
f838fe1d5d | ||
|
|
390dcb8b2d | ||
|
|
54b0d4eb8c | ||
|
|
0ff838baf7 | ||
|
|
acdb83e04e | ||
| c86c8cf344 | |||
| 3c855d78a6 | |||
| bf7b4fa3c0 | |||
| 0bc7760eee | |||
| f3f593c8c9 | |||
| d7513d7caf | |||
| 7889c7a958 | |||
| c18ed3db81 | |||
| 8fcd2156c6 | |||
| 9f8bcb5724 | |||
| f8b377904e | |||
| ff4aa48a34 | |||
| 10eb82c82d | |||
| 16f3d91c3b | |||
| f9c8ba5c52 | |||
| 2dc04f565f | |||
| 15c7b7ac43 | |||
| 794dcece29 | |||
| d971e0ac0f | |||
| ec3dbddad6 | |||
| 92f60972de | |||
| 751f8c9067 | |||
| 4697193243 | |||
| 8da090778e | |||
| 3416d44afa | |||
| 97dd543d30 | |||
| 8189c0c933 | |||
| 00df092096 | |||
| c742d785e3 | |||
| 4b95f84f6e | |||
| de8037d73c | |||
| e233cab993 | |||
| cf967026f4 | |||
| 9f94084e3f | |||
| 119b423632 | |||
| f3a00155d3 | |||
| 3506231295 | |||
| cb37e735b0 | |||
| 4555a8454c | |||
| 775236b483 | |||
| 2f6aa86cc9 | |||
| 6e24c559aa | |||
| 90320ffd5d | |||
| 5909503012 | |||
| 6b724e260f | |||
| 926413534b | |||
| af3c5bd79e | |||
| 727df4c7f8 | |||
| b6ffff8015 | |||
| a7965aa882 | |||
| d6378ab346 | |||
| eb27371f0d | |||
| c222c93628 | |||
| 84613228aa | |||
| fb0e4dabba | |||
| f12f9e590c | |||
| 21c8f57169 | |||
| e59c60094f | |||
| 8092cb53c5 | |||
| 11d108f0a6 | |||
| f0582bf4ab | |||
| 20f31cd15e | |||
| 418c9e6044 | |||
| 6c039ae94f | |||
| 42596fba16 | |||
| 25b6f470d5 | |||
| 586d3acc16 | |||
| 4122882b7e | |||
| 362098b954 | |||
| f57e254bcc | |||
| f6c686a343 | |||
| f746e31616 | |||
| a900713a48 | |||
| 1337a7c10e | |||
| 4cd374e33e | |||
| aaae5b313e | |||
| 6fbb98cf2f | |||
| bbb97292d2 | |||
| 58e85d0a64 | |||
| 90c2bdcd96 | |||
| ee43d13ba4 | |||
| 58c80460fa | |||
| 80bda6e537 | |||
| 60e5225c80 | |||
| 7453e1ec06 | |||
| 1d2610a796 | |||
| ace8c79051 | |||
| 190b7b9d71 | |||
| 9f03aafd18 | |||
| 9ffcf7b3ef | |||
| c5b90bc91c | |||
| 1a3a54cfb9 | |||
| 57b687a036 | |||
| d6cd0e719c | |||
| 724bd24b4f | |||
| 9bc695ca58 | |||
| f369083b6a | |||
| 6930e73b9f | |||
| f03fcb2bb7 | |||
| 84ac0c0e60 | |||
| 65c20faecd | |||
| 1d250f0420 | |||
| ac82e25d16 | |||
| 1ef0cdf29d | |||
| 101f39771e | |||
| 744ded914d | |||
| a896568c53 | |||
| 8965dc93fd | |||
| 702679c99d | |||
| 958a85be91 | |||
| a1436ca27f | |||
| 67752b9f47 | |||
| cb59f1063e | |||
| fb812c9a39 | |||
| f91a6493ff | |||
| d069ff1121 | |||
| af1ce20640 | |||
| 1c9855b34c | |||
| 7e38123d4a | |||
| 8830192642 | |||
| 661f8acf63 | |||
| 0db741cb8e | |||
| 4d9b64973d | |||
| f186f12bde | |||
| db700bcbcd | |||
| 76cc5e0f12 | |||
| 528778238b | |||
| 07e970e81f | |||
| 33e467d2a5 | |||
| 5ae5821c20 | |||
| 539ba70294 | |||
| f670305073 | |||
| 9b3b404993 | |||
| a9106b7fa3 | |||
| b6175352d0 | |||
| bc9d943d11 | |||
| f7413bc626 | |||
| 688cf0dd39 | |||
| 5d69d53301 | |||
| 88ff11c130 | |||
| c6e8b779b0 | |||
| cf1a77ae76 | |||
| 10b11b065f | |||
| c214cc8fd8 | |||
| eb0852e64d | |||
| 704781e359 | |||
| 041f7ac171 | |||
| a32ef745c1 | |||
| 18596e6dab | |||
| 13b5f5b855 | |||
| eda84d4d82 | |||
| 10d4e0386a | |||
| 072677b9ad | |||
| d2dd9aa01b | |||
| 65d8a9379b | |||
| 58208787e7 | |||
| 82322c4d42 | |||
| 613edbecfe | |||
| dcc0f2abbc | |||
| 2af4588688 | |||
| d7160af908 | |||
| 30169a3a27 | |||
| 3b82b5e558 | |||
| 8689283e99 | |||
| 831047447b | |||
| 0594b890c3 | |||
| fc7a9cc3a6 | |||
| 79baadb522 | |||
| 1546ae7381 | |||
| f6fecf57c7 | |||
| cbd1b008bb | |||
| 897defc39d | |||
| 27d3340a37 | |||
| e685a38be6 | |||
| da73b79ee7 | |||
| 39b61ec94b | |||
| 8fcfd60ff5 | |||
| 4fe89ce89a | |||
| 68ba97bb90 | |||
| 14610b5699 | |||
| 03bf197949 | |||
| c58678ee09 | |||
| 48f521e551 | |||
| 0e44988634 | |||
| 663df37d92 | |||
| 99e7c70901 | |||
| a4fa72df30 | |||
| 72c3b10ba5 | |||
| a77516cfea | |||
| 1119cc65b8 | |||
| 6a96a99775 | |||
| 52c41b6958 | |||
| f01bbda14e | |||
| cc70394960 | |||
| 7c4a5f990d | |||
| 8cdcd9531d | |||
| fdf973eeab | |||
| 76f008d635 | |||
| 6adb8c24d7 | |||
| bc483afd78 | |||
| 76ab9c72f5 | |||
| f1b2e47df8 | |||
| d74e2c08c1 | |||
| eef89983e0 | |||
| 874f9da12c | |||
| 71d0472337 | |||
| df79a70baf |
@@ -34,6 +34,11 @@ SMTP_USE_TLS=false
|
||||
SMTP_USE_SSL=false
|
||||
SMTP_TIMEOUT_SECONDS=10
|
||||
SMTP_FROM_EMAIL=no-reply@benyamessenger.local
|
||||
FIREBASE_ENABLED=false
|
||||
FIREBASE_CREDENTIALS_HOST_PATH=./secrets/firebase-service-account.json
|
||||
FIREBASE_CREDENTIALS_PATH=
|
||||
FIREBASE_CREDENTIALS_JSON=
|
||||
FIREBASE_WEBPUSH_LINK=https://chat.daemonlord.ru/
|
||||
|
||||
LOGIN_RATE_LIMIT_PER_MINUTE=10
|
||||
REGISTER_RATE_LIMIT_PER_MINUTE=5
|
||||
|
||||
45
.github/workflows/android-ci.yml
vendored
Normal file
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/dist
|
||||
web/tsconfig.tsbuildinfo
|
||||
secrets/
|
||||
|
||||
@@ -9,7 +9,9 @@ COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir --upgrade pip && pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY . .
|
||||
RUN chmod +x /app/docker/backend-entrypoint.sh
|
||||
|
||||
EXPOSE 8000
|
||||
|
||||
ENTRYPOINT ["/app/docker/backend-entrypoint.sh"]
|
||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
|
||||
@@ -38,12 +38,17 @@ Run full stack (web + api + worker + postgres + redis + minio + mailpit):
|
||||
1. cp .env.docker.example .env
|
||||
2. edit `.env` (`SECRET_KEY`, passwords, domain, `S3_PUBLIC_ENDPOINT_URL`)
|
||||
3. docker compose up -d --build
|
||||
2. Open:
|
||||
4. check backend readiness:
|
||||
- `http://localhost:8000/health/live`
|
||||
- `http://localhost:8000/health/ready`
|
||||
5. Open:
|
||||
- Web: http://localhost
|
||||
- API docs: http://localhost:8000/docs
|
||||
- Mailpit UI: http://localhost:8025
|
||||
- MinIO console: http://localhost:9001
|
||||
|
||||
`RUN_MIGRATIONS_ON_STARTUP=true` (default in compose) runs `alembic upgrade head` before backend start.
|
||||
|
||||
### Production Mode
|
||||
|
||||
Use production override to close internal ports (postgres/redis/minio/mailpit/backend):
|
||||
|
||||
28
alembic/versions/0012_user_pm_privacy.py
Normal file
28
alembic/versions/0012_user_pm_privacy.py
Normal file
@@ -0,0 +1,28 @@
|
||||
"""add allow_private_messages setting
|
||||
|
||||
Revision ID: 0012_user_pm_privacy
|
||||
Revises: 0011_chat_public_id
|
||||
Create Date: 2026-03-08 16:00:00.000000
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision: str = "0012_user_pm_privacy"
|
||||
down_revision: Union[str, Sequence[str], None] = "0011_chat_public_id"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
"users",
|
||||
sa.Column("allow_private_messages", sa.Boolean(), nullable=False, server_default=sa.text("true")),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("users", "allow_private_messages")
|
||||
45
alembic/versions/0013_message_reactions.py
Normal file
45
alembic/versions/0013_message_reactions.py
Normal file
@@ -0,0 +1,45 @@
|
||||
"""add message reactions
|
||||
|
||||
Revision ID: 0013_msg_reactions
|
||||
Revises: 0012_user_pm_privacy
|
||||
Create Date: 2026-03-08 18:40:00.000000
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision: str = "0013_msg_reactions"
|
||||
down_revision: Union[str, Sequence[str], None] = "0012_user_pm_privacy"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"message_reactions",
|
||||
sa.Column("id", sa.Integer(), nullable=False),
|
||||
sa.Column("message_id", sa.Integer(), nullable=False),
|
||||
sa.Column("user_id", sa.Integer(), nullable=False),
|
||||
sa.Column("emoji", sa.String(length=16), nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
|
||||
sa.ForeignKeyConstraint(["message_id"], ["messages.id"], name=op.f("fk_message_reactions_message_id_messages"), ondelete="CASCADE"),
|
||||
sa.ForeignKeyConstraint(["user_id"], ["users.id"], name=op.f("fk_message_reactions_user_id_users"), ondelete="CASCADE"),
|
||||
sa.PrimaryKeyConstraint("id", name=op.f("pk_message_reactions")),
|
||||
sa.UniqueConstraint("message_id", "user_id", name="uq_message_reactions_message_user"),
|
||||
)
|
||||
op.create_index(op.f("ix_message_reactions_id"), "message_reactions", ["id"], unique=False)
|
||||
op.create_index(op.f("ix_message_reactions_message_id"), "message_reactions", ["message_id"], unique=False)
|
||||
op.create_index(op.f("ix_message_reactions_user_id"), "message_reactions", ["user_id"], unique=False)
|
||||
op.create_index(op.f("ix_message_reactions_emoji"), "message_reactions", ["emoji"], unique=False)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index(op.f("ix_message_reactions_emoji"), table_name="message_reactions")
|
||||
op.drop_index(op.f("ix_message_reactions_user_id"), table_name="message_reactions")
|
||||
op.drop_index(op.f("ix_message_reactions_message_id"), table_name="message_reactions")
|
||||
op.drop_index(op.f("ix_message_reactions_id"), table_name="message_reactions")
|
||||
op.drop_table("message_reactions")
|
||||
|
||||
43
alembic/versions/0014_chat_user_settings.py
Normal file
43
alembic/versions/0014_chat_user_settings.py
Normal file
@@ -0,0 +1,43 @@
|
||||
"""add chat user settings for archive
|
||||
|
||||
Revision ID: 0014_chat_user_set
|
||||
Revises: 0013_msg_reactions
|
||||
Create Date: 2026-03-08 19:00:00.000000
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision: str = "0014_chat_user_set"
|
||||
down_revision: Union[str, Sequence[str], None] = "0013_msg_reactions"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"chat_user_settings",
|
||||
sa.Column("id", sa.Integer(), nullable=False),
|
||||
sa.Column("chat_id", sa.Integer(), nullable=False),
|
||||
sa.Column("user_id", sa.Integer(), nullable=False),
|
||||
sa.Column("archived", sa.Boolean(), nullable=False, server_default=sa.text("false")),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
|
||||
sa.ForeignKeyConstraint(["chat_id"], ["chats.id"], name=op.f("fk_chat_user_settings_chat_id_chats"), ondelete="CASCADE"),
|
||||
sa.ForeignKeyConstraint(["user_id"], ["users.id"], name=op.f("fk_chat_user_settings_user_id_users"), ondelete="CASCADE"),
|
||||
sa.PrimaryKeyConstraint("id", name=op.f("pk_chat_user_settings")),
|
||||
sa.UniqueConstraint("chat_id", "user_id", name="uq_chat_user_settings_chat_user"),
|
||||
)
|
||||
op.create_index(op.f("ix_chat_user_settings_id"), "chat_user_settings", ["id"], unique=False)
|
||||
op.create_index(op.f("ix_chat_user_settings_chat_id"), "chat_user_settings", ["chat_id"], unique=False)
|
||||
op.create_index(op.f("ix_chat_user_settings_user_id"), "chat_user_settings", ["user_id"], unique=False)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index(op.f("ix_chat_user_settings_user_id"), table_name="chat_user_settings")
|
||||
op.drop_index(op.f("ix_chat_user_settings_chat_id"), table_name="chat_user_settings")
|
||||
op.drop_index(op.f("ix_chat_user_settings_id"), table_name="chat_user_settings")
|
||||
op.drop_table("chat_user_settings")
|
||||
|
||||
28
alembic/versions/0015_chat_pin_fields.py
Normal file
28
alembic/versions/0015_chat_pin_fields.py
Normal file
@@ -0,0 +1,28 @@
|
||||
"""add chat pin fields to chat user settings
|
||||
|
||||
Revision ID: 0015_chat_pin_set
|
||||
Revises: 0014_chat_user_set
|
||||
Create Date: 2026-03-08 19:20:00.000000
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision: str = "0015_chat_pin_set"
|
||||
down_revision: Union[str, Sequence[str], None] = "0014_chat_user_set"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column("chat_user_settings", sa.Column("pinned", sa.Boolean(), nullable=False, server_default=sa.text("false")))
|
||||
op.add_column("chat_user_settings", sa.Column("pinned_at", sa.DateTime(timezone=True), nullable=True))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("chat_user_settings", "pinned_at")
|
||||
op.drop_column("chat_user_settings", "pinned")
|
||||
|
||||
46
alembic/versions/0016_chat_invite_links.py
Normal file
46
alembic/versions/0016_chat_invite_links.py
Normal file
@@ -0,0 +1,46 @@
|
||||
"""add chat invite links
|
||||
|
||||
Revision ID: 0016_chat_invites
|
||||
Revises: 0015_chat_pin_set
|
||||
Create Date: 2026-03-08 19:45:00.000000
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision: str = "0016_chat_invites"
|
||||
down_revision: Union[str, Sequence[str], None] = "0015_chat_pin_set"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"chat_invite_links",
|
||||
sa.Column("id", sa.Integer(), nullable=False),
|
||||
sa.Column("chat_id", sa.Integer(), nullable=False),
|
||||
sa.Column("creator_user_id", sa.Integer(), nullable=False),
|
||||
sa.Column("token", sa.String(length=64), nullable=False),
|
||||
sa.Column("is_active", sa.Boolean(), nullable=False, server_default=sa.text("true")),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
|
||||
sa.ForeignKeyConstraint(["chat_id"], ["chats.id"], name=op.f("fk_chat_invite_links_chat_id_chats"), ondelete="CASCADE"),
|
||||
sa.ForeignKeyConstraint(["creator_user_id"], ["users.id"], name=op.f("fk_chat_invite_links_creator_user_id_users"), ondelete="CASCADE"),
|
||||
sa.PrimaryKeyConstraint("id", name=op.f("pk_chat_invite_links")),
|
||||
sa.UniqueConstraint("token", name="uq_chat_invite_links_token"),
|
||||
)
|
||||
op.create_index(op.f("ix_chat_invite_links_id"), "chat_invite_links", ["id"], unique=False)
|
||||
op.create_index(op.f("ix_chat_invite_links_chat_id"), "chat_invite_links", ["chat_id"], unique=False)
|
||||
op.create_index(op.f("ix_chat_invite_links_creator_user_id"), "chat_invite_links", ["creator_user_id"], unique=False)
|
||||
op.create_index(op.f("ix_chat_invite_links_token"), "chat_invite_links", ["token"], unique=False)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index(op.f("ix_chat_invite_links_token"), table_name="chat_invite_links")
|
||||
op.drop_index(op.f("ix_chat_invite_links_creator_user_id"), table_name="chat_invite_links")
|
||||
op.drop_index(op.f("ix_chat_invite_links_chat_id"), table_name="chat_invite_links")
|
||||
op.drop_index(op.f("ix_chat_invite_links_id"), table_name="chat_invite_links")
|
||||
op.drop_table("chat_invite_links")
|
||||
|
||||
42
alembic/versions/0017_user_contacts.py
Normal file
42
alembic/versions/0017_user_contacts.py
Normal file
@@ -0,0 +1,42 @@
|
||||
"""add user contacts table
|
||||
|
||||
Revision ID: 0017_user_contacts
|
||||
Revises: 0016_chat_invites
|
||||
Create Date: 2026-03-08 23:10:00.000000
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision: str = "0017_user_contacts"
|
||||
down_revision: Union[str, Sequence[str], None] = "0016_chat_invites"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"user_contacts",
|
||||
sa.Column("id", sa.Integer(), nullable=False),
|
||||
sa.Column("user_id", sa.Integer(), nullable=False),
|
||||
sa.Column("contact_user_id", sa.Integer(), nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
|
||||
sa.ForeignKeyConstraint(["user_id"], ["users.id"], name=op.f("fk_user_contacts_user_id_users"), ondelete="CASCADE"),
|
||||
sa.ForeignKeyConstraint(["contact_user_id"], ["users.id"], name=op.f("fk_user_contacts_contact_user_id_users"), ondelete="CASCADE"),
|
||||
sa.PrimaryKeyConstraint("id", name=op.f("pk_user_contacts")),
|
||||
sa.UniqueConstraint("user_id", "contact_user_id", name="uq_user_contacts_pair"),
|
||||
)
|
||||
op.create_index(op.f("ix_user_contacts_id"), "user_contacts", ["id"], unique=False)
|
||||
op.create_index(op.f("ix_user_contacts_user_id"), "user_contacts", ["user_id"], unique=False)
|
||||
op.create_index(op.f("ix_user_contacts_contact_user_id"), "user_contacts", ["contact_user_id"], unique=False)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index(op.f("ix_user_contacts_contact_user_id"), table_name="user_contacts")
|
||||
op.drop_index(op.f("ix_user_contacts_user_id"), table_name="user_contacts")
|
||||
op.drop_index(op.f("ix_user_contacts_id"), table_name="user_contacts")
|
||||
op.drop_table("user_contacts")
|
||||
|
||||
28
alembic/versions/0018_user_twofa.py
Normal file
28
alembic/versions/0018_user_twofa.py
Normal file
@@ -0,0 +1,28 @@
|
||||
"""add user twofa fields
|
||||
|
||||
Revision ID: 0018_user_twofa
|
||||
Revises: 0017_user_contacts
|
||||
Create Date: 2026-03-08 23:35:00.000000
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision: str = "0018_user_twofa"
|
||||
down_revision: Union[str, Sequence[str], None] = "0017_user_contacts"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column("users", sa.Column("twofa_enabled", sa.Boolean(), nullable=False, server_default=sa.text("false")))
|
||||
op.add_column("users", sa.Column("twofa_secret", sa.String(length=64), nullable=True))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("users", "twofa_secret")
|
||||
op.drop_column("users", "twofa_enabled")
|
||||
|
||||
39
alembic/versions/0019_user_privacy_fields.py
Normal file
39
alembic/versions/0019_user_privacy_fields.py
Normal file
@@ -0,0 +1,39 @@
|
||||
"""add user privacy fields
|
||||
|
||||
Revision ID: 0019_user_privacy_fields
|
||||
Revises: 0018_user_twofa
|
||||
Create Date: 2026-03-08 23:59:00.000000
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision: str = "0019_user_privacy_fields"
|
||||
down_revision: Union[str, Sequence[str], None] = "0018_user_twofa"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
"users",
|
||||
sa.Column("privacy_last_seen", sa.String(length=16), nullable=False, server_default=sa.text("'everyone'")),
|
||||
)
|
||||
op.add_column(
|
||||
"users",
|
||||
sa.Column("privacy_avatar", sa.String(length=16), nullable=False, server_default=sa.text("'everyone'")),
|
||||
)
|
||||
op.add_column(
|
||||
"users",
|
||||
sa.Column("privacy_group_invites", sa.String(length=16), nullable=False, server_default=sa.text("'everyone'")),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("users", "privacy_group_invites")
|
||||
op.drop_column("users", "privacy_avatar")
|
||||
op.drop_column("users", "privacy_last_seen")
|
||||
|
||||
27
alembic/versions/0020_attachment_waveform_data.py
Normal file
27
alembic/versions/0020_attachment_waveform_data.py
Normal file
@@ -0,0 +1,27 @@
|
||||
"""add waveform data to attachments
|
||||
|
||||
Revision ID: 0020_attachment_waveform_data
|
||||
Revises: 0019_user_privacy_fields
|
||||
Create Date: 2026-03-08
|
||||
"""
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "0020_attachment_waveform_data"
|
||||
down_revision: str | None = "0019_user_privacy_fields"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column("attachments", sa.Column("waveform_data", sa.Text(), nullable=True))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("attachments", "waveform_data")
|
||||
|
||||
26
alembic/versions/0021_chat_avatar_url.py
Normal file
26
alembic/versions/0021_chat_avatar_url.py
Normal file
@@ -0,0 +1,26 @@
|
||||
"""add avatar url for chats
|
||||
|
||||
Revision ID: 0021_chat_avatar_url
|
||||
Revises: 0020_attachment_waveform_data
|
||||
Create Date: 2026-03-08
|
||||
"""
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "0021_chat_avatar_url"
|
||||
down_revision: str | None = "0020_attachment_waveform_data"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column("chats", sa.Column("avatar_url", sa.String(length=512), nullable=True))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("chats", "avatar_url")
|
||||
26
alembic/versions/0022_user_access_revoked_before.py
Normal file
26
alembic/versions/0022_user_access_revoked_before.py
Normal file
@@ -0,0 +1,26 @@
|
||||
"""add access token revoke marker for users
|
||||
|
||||
Revision ID: 0022_user_access_revoked_before
|
||||
Revises: 0021_chat_avatar_url
|
||||
Create Date: 2026-03-08
|
||||
"""
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "0022_user_access_revoked_before"
|
||||
down_revision: str | None = "0021_chat_avatar_url"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column("users", sa.Column("access_revoked_before", sa.DateTime(timezone=True), nullable=True))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("users", "access_revoked_before")
|
||||
38
alembic/versions/0023_privacy_pm_level.py
Normal file
38
alembic/versions/0023_privacy_pm_level.py
Normal file
@@ -0,0 +1,38 @@
|
||||
"""add privacy private messages level
|
||||
|
||||
Revision ID: 0023_privacy_pm_level
|
||||
Revises: 0022_user_access_revoked_before
|
||||
Create Date: 2026-03-09 00:40:00.000000
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision: str = "0023_privacy_pm_level"
|
||||
down_revision: Union[str, Sequence[str], None] = "0022_user_access_revoked_before"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
"users",
|
||||
sa.Column("privacy_private_messages", sa.String(length=16), nullable=False, server_default=sa.text("'everyone'")),
|
||||
)
|
||||
op.execute(
|
||||
sa.text(
|
||||
"UPDATE users "
|
||||
"SET privacy_private_messages = CASE "
|
||||
"WHEN allow_private_messages IS TRUE THEN 'everyone' "
|
||||
"ELSE 'nobody' "
|
||||
"END"
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("users", "privacy_private_messages")
|
||||
|
||||
46
alembic/versions/0024_chat_bans.py
Normal file
46
alembic/versions/0024_chat_bans.py
Normal file
@@ -0,0 +1,46 @@
|
||||
"""add chat bans for moderation
|
||||
|
||||
Revision ID: 0024_chat_bans
|
||||
Revises: 0023_privacy_pm_level
|
||||
Create Date: 2026-03-09 01:10:00.000000
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision: str = "0024_chat_bans"
|
||||
down_revision: Union[str, Sequence[str], None] = "0023_privacy_pm_level"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"chat_bans",
|
||||
sa.Column("id", sa.Integer(), nullable=False),
|
||||
sa.Column("chat_id", sa.Integer(), nullable=False),
|
||||
sa.Column("user_id", sa.Integer(), nullable=False),
|
||||
sa.Column("banned_by_user_id", sa.Integer(), nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
|
||||
sa.ForeignKeyConstraint(["chat_id"], ["chats.id"], ondelete="CASCADE"),
|
||||
sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"),
|
||||
sa.ForeignKeyConstraint(["banned_by_user_id"], ["users.id"], ondelete="CASCADE"),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.UniqueConstraint("chat_id", "user_id", name="uq_chat_bans_chat_user"),
|
||||
)
|
||||
op.create_index("ix_chat_bans_id", "chat_bans", ["id"], unique=False)
|
||||
op.create_index("ix_chat_bans_chat_id", "chat_bans", ["chat_id"], unique=False)
|
||||
op.create_index("ix_chat_bans_user_id", "chat_bans", ["user_id"], unique=False)
|
||||
op.create_index("ix_chat_bans_banned_by_user_id", "chat_bans", ["banned_by_user_id"], unique=False)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("ix_chat_bans_banned_by_user_id", table_name="chat_bans")
|
||||
op.drop_index("ix_chat_bans_user_id", table_name="chat_bans")
|
||||
op.drop_index("ix_chat_bans_chat_id", table_name="chat_bans")
|
||||
op.drop_index("ix_chat_bans_id", table_name="chat_bans")
|
||||
op.drop_table("chat_bans")
|
||||
|
||||
26
alembic/versions/0025_user_twofa_recovery_codes.py
Normal file
26
alembic/versions/0025_user_twofa_recovery_codes.py
Normal file
@@ -0,0 +1,26 @@
|
||||
"""add recovery codes storage for 2fa
|
||||
|
||||
Revision ID: 0025_user_twofa_recovery_codes
|
||||
Revises: 0024_chat_bans
|
||||
Create Date: 2026-03-09 16:55:00.000000
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision: str = "0025_user_twofa_recovery_codes"
|
||||
down_revision: Union[str, Sequence[str], None] = "0024_chat_bans"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column("users", sa.Column("twofa_recovery_codes_hashes", sa.String(length=4096), nullable=True))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("users", "twofa_recovery_codes_hashes")
|
||||
|
||||
216
alembic/versions/0026_deduplicate_saved_chats.py
Normal file
216
alembic/versions/0026_deduplicate_saved_chats.py
Normal file
@@ -0,0 +1,216 @@
|
||||
"""deduplicate saved chats per user
|
||||
|
||||
Revision ID: 0026_deduplicate_saved_chats
|
||||
Revises: 0025_user_twofa_recovery_codes
|
||||
Create Date: 2026-03-10 00:25:00.000000
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision: str = "0026_deduplicate_saved_chats"
|
||||
down_revision: Union[str, Sequence[str], None] = "0025_user_twofa_recovery_codes"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
|
||||
duplicate_user_ids = [
|
||||
int(row[0])
|
||||
for row in bind.execute(
|
||||
sa.text(
|
||||
"""
|
||||
SELECT cm.user_id
|
||||
FROM chat_members cm
|
||||
JOIN chats c ON c.id = cm.chat_id
|
||||
WHERE c.is_saved IS TRUE
|
||||
GROUP BY cm.user_id
|
||||
HAVING COUNT(*) > 1
|
||||
"""
|
||||
)
|
||||
).fetchall()
|
||||
]
|
||||
|
||||
for user_id in duplicate_user_ids:
|
||||
saved_chat_ids = [
|
||||
int(row[0])
|
||||
for row in bind.execute(
|
||||
sa.text(
|
||||
"""
|
||||
SELECT c.id
|
||||
FROM chats c
|
||||
JOIN chat_members cm ON cm.chat_id = c.id
|
||||
WHERE c.is_saved IS TRUE
|
||||
AND cm.user_id = :user_id
|
||||
ORDER BY c.id ASC
|
||||
"""
|
||||
),
|
||||
{"user_id": user_id},
|
||||
).fetchall()
|
||||
]
|
||||
if len(saved_chat_ids) <= 1:
|
||||
continue
|
||||
|
||||
keep_chat_id = saved_chat_ids[0]
|
||||
duplicate_chat_ids = saved_chat_ids[1:]
|
||||
|
||||
for duplicate_chat_id in duplicate_chat_ids:
|
||||
bind.execute(
|
||||
sa.text(
|
||||
"""
|
||||
UPDATE chats keep
|
||||
SET pinned_message_id = COALESCE(
|
||||
keep.pinned_message_id,
|
||||
(SELECT pinned_message_id FROM chats WHERE id = :dup_chat_id)
|
||||
)
|
||||
WHERE keep.id = :keep_chat_id
|
||||
"""
|
||||
),
|
||||
{"keep_chat_id": keep_chat_id, "dup_chat_id": duplicate_chat_id},
|
||||
)
|
||||
|
||||
bind.execute(
|
||||
sa.text(
|
||||
"""
|
||||
UPDATE messages
|
||||
SET chat_id = :keep_chat_id
|
||||
WHERE chat_id = :dup_chat_id
|
||||
"""
|
||||
),
|
||||
{"keep_chat_id": keep_chat_id, "dup_chat_id": duplicate_chat_id},
|
||||
)
|
||||
|
||||
bind.execute(
|
||||
sa.text(
|
||||
"""
|
||||
INSERT INTO message_receipts (chat_id, user_id, last_delivered_message_id, last_read_message_id, updated_at)
|
||||
SELECT :keep_chat_id, mr.user_id, mr.last_delivered_message_id, mr.last_read_message_id, mr.updated_at
|
||||
FROM message_receipts mr
|
||||
WHERE mr.chat_id = :dup_chat_id
|
||||
ON CONFLICT (chat_id, user_id) DO UPDATE
|
||||
SET last_delivered_message_id = GREATEST(
|
||||
COALESCE(message_receipts.last_delivered_message_id, 0),
|
||||
COALESCE(EXCLUDED.last_delivered_message_id, 0)
|
||||
),
|
||||
last_read_message_id = GREATEST(
|
||||
COALESCE(message_receipts.last_read_message_id, 0),
|
||||
COALESCE(EXCLUDED.last_read_message_id, 0)
|
||||
),
|
||||
updated_at = GREATEST(message_receipts.updated_at, EXCLUDED.updated_at)
|
||||
"""
|
||||
),
|
||||
{"keep_chat_id": keep_chat_id, "dup_chat_id": duplicate_chat_id},
|
||||
)
|
||||
bind.execute(
|
||||
sa.text("DELETE FROM message_receipts WHERE chat_id = :dup_chat_id"),
|
||||
{"dup_chat_id": duplicate_chat_id},
|
||||
)
|
||||
|
||||
bind.execute(
|
||||
sa.text(
|
||||
"""
|
||||
INSERT INTO chat_notification_settings (chat_id, user_id, muted, updated_at)
|
||||
SELECT :keep_chat_id, cns.user_id, cns.muted, cns.updated_at
|
||||
FROM chat_notification_settings cns
|
||||
WHERE cns.chat_id = :dup_chat_id
|
||||
ON CONFLICT (chat_id, user_id) DO UPDATE
|
||||
SET muted = chat_notification_settings.muted OR EXCLUDED.muted,
|
||||
updated_at = GREATEST(chat_notification_settings.updated_at, EXCLUDED.updated_at)
|
||||
"""
|
||||
),
|
||||
{"keep_chat_id": keep_chat_id, "dup_chat_id": duplicate_chat_id},
|
||||
)
|
||||
bind.execute(
|
||||
sa.text("DELETE FROM chat_notification_settings WHERE chat_id = :dup_chat_id"),
|
||||
{"dup_chat_id": duplicate_chat_id},
|
||||
)
|
||||
|
||||
bind.execute(
|
||||
sa.text(
|
||||
"""
|
||||
INSERT INTO chat_user_settings (chat_id, user_id, archived, pinned, pinned_at, updated_at)
|
||||
SELECT :keep_chat_id, cus.user_id, cus.archived, cus.pinned, cus.pinned_at, cus.updated_at
|
||||
FROM chat_user_settings cus
|
||||
WHERE cus.chat_id = :dup_chat_id
|
||||
ON CONFLICT (chat_id, user_id) DO UPDATE
|
||||
SET archived = chat_user_settings.archived OR EXCLUDED.archived,
|
||||
pinned = chat_user_settings.pinned OR EXCLUDED.pinned,
|
||||
pinned_at = CASE
|
||||
WHEN chat_user_settings.pinned_at IS NULL THEN EXCLUDED.pinned_at
|
||||
WHEN EXCLUDED.pinned_at IS NULL THEN chat_user_settings.pinned_at
|
||||
ELSE GREATEST(chat_user_settings.pinned_at, EXCLUDED.pinned_at)
|
||||
END,
|
||||
updated_at = GREATEST(chat_user_settings.updated_at, EXCLUDED.updated_at)
|
||||
"""
|
||||
),
|
||||
{"keep_chat_id": keep_chat_id, "dup_chat_id": duplicate_chat_id},
|
||||
)
|
||||
bind.execute(
|
||||
sa.text("DELETE FROM chat_user_settings WHERE chat_id = :dup_chat_id"),
|
||||
{"dup_chat_id": duplicate_chat_id},
|
||||
)
|
||||
|
||||
bind.execute(
|
||||
sa.text(
|
||||
"""
|
||||
INSERT INTO message_idempotency_keys (chat_id, sender_id, client_message_id, message_id, created_at)
|
||||
SELECT :keep_chat_id, mik.sender_id, mik.client_message_id, mik.message_id, mik.created_at
|
||||
FROM message_idempotency_keys mik
|
||||
WHERE mik.chat_id = :dup_chat_id
|
||||
ON CONFLICT (chat_id, sender_id, client_message_id) DO NOTHING
|
||||
"""
|
||||
),
|
||||
{"keep_chat_id": keep_chat_id, "dup_chat_id": duplicate_chat_id},
|
||||
)
|
||||
bind.execute(
|
||||
sa.text("DELETE FROM message_idempotency_keys WHERE chat_id = :dup_chat_id"),
|
||||
{"dup_chat_id": duplicate_chat_id},
|
||||
)
|
||||
|
||||
bind.execute(
|
||||
sa.text(
|
||||
"""
|
||||
INSERT INTO chat_members (chat_id, user_id, role, joined_at)
|
||||
SELECT :keep_chat_id, cm.user_id, cm.role, cm.joined_at
|
||||
FROM chat_members cm
|
||||
WHERE cm.chat_id = :dup_chat_id
|
||||
ON CONFLICT (chat_id, user_id) DO UPDATE
|
||||
SET role = CASE
|
||||
WHEN chat_members.role = 'OWNER' OR EXCLUDED.role = 'OWNER' THEN 'OWNER'::chatmemberrole
|
||||
WHEN chat_members.role = 'ADMIN' OR EXCLUDED.role = 'ADMIN' THEN 'ADMIN'::chatmemberrole
|
||||
ELSE 'MEMBER'::chatmemberrole
|
||||
END,
|
||||
joined_at = LEAST(chat_members.joined_at, EXCLUDED.joined_at)
|
||||
"""
|
||||
),
|
||||
{"keep_chat_id": keep_chat_id, "dup_chat_id": duplicate_chat_id},
|
||||
)
|
||||
bind.execute(
|
||||
sa.text("DELETE FROM chat_members WHERE chat_id = :dup_chat_id"),
|
||||
{"dup_chat_id": duplicate_chat_id},
|
||||
)
|
||||
|
||||
bind.execute(
|
||||
sa.text("DELETE FROM chat_bans WHERE chat_id = :dup_chat_id"),
|
||||
{"dup_chat_id": duplicate_chat_id},
|
||||
)
|
||||
bind.execute(
|
||||
sa.text("DELETE FROM chat_invite_links WHERE chat_id = :dup_chat_id"),
|
||||
{"dup_chat_id": duplicate_chat_id},
|
||||
)
|
||||
|
||||
bind.execute(
|
||||
sa.text("DELETE FROM chats WHERE id = :dup_chat_id"),
|
||||
{"dup_chat_id": duplicate_chat_id},
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# data-cleanup migration; no reversible schema changes
|
||||
pass
|
||||
|
||||
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")
|
||||
1025
android/CHANGELOG.md
Normal file
1025
android/CHANGELOG.md
Normal file
File diff suppressed because it is too large
Load Diff
13
android/README.md
Normal file
13
android/README.md
Normal file
@@ -0,0 +1,13 @@
|
||||
# Android App (Phase 0)
|
||||
|
||||
Минимальный каркас Android-клиента на Kotlin + Jetpack Compose.
|
||||
|
||||
## Что уже есть
|
||||
- Gradle multi-module root (`:app`)
|
||||
- Compose `MainActivity`
|
||||
- Базовые зависимости для дальнейшей реализации
|
||||
|
||||
## Следующий шаг
|
||||
1. Добавить network layer (Retrofit/OkHttp + auth interceptor).
|
||||
2. Внедрить DI и feature-модули.
|
||||
3. Поднять auth flow (email-first) и chat list.
|
||||
188
android/app/build.gradle.kts
Normal file
188
android/app/build.gradle.kts
Normal file
@@ -0,0 +1,188 @@
|
||||
import java.util.Properties
|
||||
|
||||
plugins {
|
||||
id("com.android.application")
|
||||
id("org.jetbrains.kotlin.android")
|
||||
id("org.jetbrains.kotlin.plugin.compose")
|
||||
id("org.jetbrains.kotlin.kapt")
|
||||
id("org.jetbrains.kotlin.plugin.serialization")
|
||||
id("com.google.dagger.hilt.android")
|
||||
id("com.google.gms.google-services")
|
||||
id("com.google.firebase.crashlytics")
|
||||
}
|
||||
|
||||
val localProperties = Properties().apply {
|
||||
val file = rootProject.file("local.properties")
|
||||
if (file.exists()) {
|
||||
file.inputStream().use { load(it) }
|
||||
}
|
||||
}
|
||||
|
||||
fun String.escapeForBuildConfig(): String = replace("\\", "\\\\").replace("\"", "\\\"")
|
||||
|
||||
android {
|
||||
namespace = "ru.daemonlord.messenger"
|
||||
compileSdk = 35
|
||||
|
||||
defaultConfig {
|
||||
applicationId = "ru.daemonlord.messenger"
|
||||
minSdk = 26
|
||||
targetSdk = 35
|
||||
versionCode = 1
|
||||
versionName = "0.1.0"
|
||||
buildConfigField("String", "API_BASE_URL", "\"https://chat.daemonlord.ru/\"")
|
||||
buildConfigField("String", "API_VERSION_HEADER", "\"2026-03\"")
|
||||
val giphyApiKey = (
|
||||
localProperties.getProperty("GIPHY_API_KEY")
|
||||
?: System.getenv("GIPHY_API_KEY")
|
||||
?: ""
|
||||
).trim()
|
||||
buildConfigField("String", "GIPHY_API_KEY", "\"${giphyApiKey.escapeForBuildConfig()}\"")
|
||||
buildConfigField("boolean", "FEATURE_ACCOUNT_MANAGEMENT", "true")
|
||||
buildConfigField("boolean", "FEATURE_TWO_FACTOR", "true")
|
||||
buildConfigField("boolean", "FEATURE_MEDIA_GALLERY", "true")
|
||||
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
vectorDrawables {
|
||||
useSupportLibrary = true
|
||||
}
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
isMinifyEnabled = false
|
||||
proguardFiles(
|
||||
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||
"proguard-rules.pro"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
}
|
||||
|
||||
kotlinOptions {
|
||||
jvmTarget = "17"
|
||||
}
|
||||
|
||||
buildFeatures {
|
||||
compose = true
|
||||
buildConfig = true
|
||||
}
|
||||
|
||||
composeOptions {
|
||||
kotlinCompilerExtensionVersion = "1.5.15"
|
||||
}
|
||||
|
||||
packaging {
|
||||
resources {
|
||||
excludes += "/META-INF/{AL2.0,LGPL2.1}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(project(":core:common"))
|
||||
implementation(platform("com.google.firebase:firebase-bom:34.10.0"))
|
||||
|
||||
implementation("androidx.core:core-ktx:1.15.0")
|
||||
implementation("androidx.appcompat:appcompat:1.7.0")
|
||||
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.8.7")
|
||||
implementation("androidx.lifecycle:lifecycle-runtime-compose:2.8.7")
|
||||
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.8.7")
|
||||
implementation("androidx.activity:activity-compose:1.10.1")
|
||||
implementation("androidx.navigation:navigation-compose:2.8.5")
|
||||
|
||||
implementation("androidx.compose.ui:ui:1.7.6")
|
||||
implementation("androidx.compose.ui:ui-tooling-preview:1.7.6")
|
||||
implementation("androidx.compose.material3:material3:1.3.1")
|
||||
implementation("androidx.compose.material:material-icons-extended:1.7.6")
|
||||
implementation("io.coil-kt:coil:2.7.0")
|
||||
implementation("io.coil-kt:coil-compose:2.7.0")
|
||||
implementation("io.coil-kt:coil-gif:2.7.0")
|
||||
implementation("io.coil-kt:coil-video:2.7.0")
|
||||
implementation("androidx.media3:media3-exoplayer:1.4.1")
|
||||
implementation("androidx.media3:media3-ui:1.4.1")
|
||||
implementation("androidx.media3:media3-datasource:1.4.1")
|
||||
implementation("androidx.media3:media3-datasource-okhttp:1.4.1")
|
||||
implementation("androidx.camera:camera-core:1.4.2")
|
||||
implementation("androidx.camera:camera-camera2:1.4.2")
|
||||
implementation("androidx.camera:camera-lifecycle:1.4.2")
|
||||
implementation("androidx.camera:camera-video:1.4.2")
|
||||
implementation("androidx.camera:camera-view:1.4.2")
|
||||
|
||||
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0")
|
||||
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3")
|
||||
|
||||
implementation("com.squareup.retrofit2:retrofit:2.11.0")
|
||||
implementation("com.jakewharton.retrofit:retrofit2-kotlinx-serialization-converter:1.0.0")
|
||||
implementation("com.squareup.okhttp3:okhttp:4.12.0")
|
||||
implementation("com.squareup.okhttp3:logging-interceptor:4.12.0")
|
||||
|
||||
implementation("androidx.datastore:datastore-preferences:1.1.1")
|
||||
implementation("androidx.security:security-crypto:1.1.0-alpha06")
|
||||
implementation("androidx.room:room-runtime:2.6.1")
|
||||
implementation("androidx.room:room-ktx:2.6.1")
|
||||
kapt("androidx.room:room-compiler:2.6.1")
|
||||
|
||||
implementation("com.google.dagger:hilt-android:2.52")
|
||||
kapt("com.google.dagger:hilt-compiler:2.52")
|
||||
implementation("androidx.hilt:hilt-navigation-compose:1.2.0")
|
||||
implementation("com.google.firebase:firebase-messaging")
|
||||
implementation("com.google.firebase:firebase-crashlytics")
|
||||
implementation("com.jakewharton.timber:timber:5.0.1")
|
||||
|
||||
testImplementation("junit:junit:4.13.2")
|
||||
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.9.0")
|
||||
testImplementation("androidx.datastore:datastore-preferences-core:1.1.1")
|
||||
testImplementation("androidx.room:room-testing:2.6.1")
|
||||
testImplementation("androidx.test:core:1.6.1")
|
||||
testImplementation("org.robolectric:robolectric:4.13")
|
||||
testImplementation("com.squareup.okhttp3:mockwebserver:4.12.0")
|
||||
androidTestImplementation("androidx.compose.ui:ui-test-junit4:1.7.6")
|
||||
androidTestImplementation("androidx.test.ext:junit:1.2.1")
|
||||
androidTestImplementation("androidx.test.espresso:espresso-core:3.6.1")
|
||||
|
||||
debugImplementation("androidx.compose.ui:ui-tooling:1.7.6")
|
||||
debugImplementation("androidx.compose.ui:ui-test-manifest:1.7.6")
|
||||
}
|
||||
|
||||
kapt {
|
||||
correctErrorTypes = true
|
||||
}
|
||||
|
||||
fun registerHiltInjectorBackfillTask(variantName: String) {
|
||||
val cap = variantName.replaceFirstChar { if (it.isLowerCase()) it.titlecase() else it.toString() }
|
||||
val taskName = "backfill${cap}HiltApplicationInjector"
|
||||
val dexBuilderTaskName = "dexBuilder$cap"
|
||||
val compileJavaTaskName = "compile${cap}JavaWithJavac"
|
||||
|
||||
tasks.register(taskName) {
|
||||
dependsOn(compileJavaTaskName)
|
||||
doLast {
|
||||
val javacOutput = file("$buildDir/intermediates/javac/$variantName/$compileJavaTaskName/classes")
|
||||
val asmOutput = file("$buildDir/intermediates/classes/$variantName/transform${cap}ClassesWithAsm/dirs")
|
||||
if (!javacOutput.exists() || !asmOutput.exists()) return@doLast
|
||||
|
||||
fileTree(javacOutput) {
|
||||
include("**/*Application_GeneratedInjector.class")
|
||||
}.forEach { source ->
|
||||
val relativePath = source.relativeTo(javacOutput).path
|
||||
val target = file("${asmOutput.path}/$relativePath")
|
||||
if (!target.exists()) {
|
||||
target.parentFile?.mkdirs()
|
||||
source.copyTo(target, overwrite = true)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
tasks.matching { it.name == dexBuilderTaskName }.configureEach {
|
||||
dependsOn(taskName)
|
||||
}
|
||||
}
|
||||
|
||||
registerHiltInjectorBackfillTask("debug")
|
||||
registerHiltInjectorBackfillTask("release")
|
||||
1
android/app/proguard-rules.pro
vendored
Normal file
1
android/app/proguard-rules.pro
vendored
Normal file
@@ -0,0 +1 @@
|
||||
# App-specific ProGuard rules.
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
62
android/app/src/main/AndroidManifest.xml
Normal file
62
android/app/src/main/AndroidManifest.xml
Normal file
@@ -0,0 +1,62 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||
<uses-permission android:name="android.permission.CAMERA" />
|
||||
|
||||
<application
|
||||
android:name=".MessengerApplication"
|
||||
android:allowBackup="true"
|
||||
android:enableOnBackInvokedCallback="true"
|
||||
android:icon="@android:drawable/sym_def_app_icon"
|
||||
android:label="@string/app_name"
|
||||
android:roundIcon="@android:drawable/sym_def_app_icon"
|
||||
android:supportsRtl="true"
|
||||
android:usesCleartextTraffic="true"
|
||||
android:theme="@style/Theme.Messenger">
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
<intent-filter android:autoVerify="true">
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
<data
|
||||
android:host="chat.daemonlord.ru"
|
||||
android:pathPrefix="/join"
|
||||
android:scheme="https" />
|
||||
<data
|
||||
android:host="chat.daemonlord.ru"
|
||||
android:pathPrefix="/verify-email"
|
||||
android:scheme="https" />
|
||||
<data
|
||||
android:host="chat.daemonlord.ru"
|
||||
android:pathPrefix="/reset-password"
|
||||
android:scheme="https" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
<service
|
||||
android:name=".push.MessengerFirebaseMessagingService"
|
||||
android:exported="false">
|
||||
<intent-filter>
|
||||
<action android:name="com.google.firebase.MESSAGING_EVENT" />
|
||||
</intent-filter>
|
||||
</service>
|
||||
<provider
|
||||
android:name="androidx.core.content.FileProvider"
|
||||
android:authorities="${applicationId}.fileprovider"
|
||||
android:exported="false"
|
||||
android:grantUriPermissions="true">
|
||||
<meta-data
|
||||
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||
android:resource="@xml/file_paths" />
|
||||
</provider>
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
@@ -0,0 +1,171 @@
|
||||
package ru.daemonlord.messenger
|
||||
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.appcompat.app.AppCompatDelegate
|
||||
import androidx.core.os.LocaleListCompat
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import ru.daemonlord.messenger.core.notifications.NotificationDispatcher
|
||||
import ru.daemonlord.messenger.core.notifications.NotificationIntentExtras
|
||||
import ru.daemonlord.messenger.domain.settings.repository.LanguageRepository
|
||||
import ru.daemonlord.messenger.domain.settings.model.AppThemeMode
|
||||
import ru.daemonlord.messenger.domain.settings.repository.ThemeRepository
|
||||
import ru.daemonlord.messenger.ui.navigation.MessengerNavHost
|
||||
import ru.daemonlord.messenger.ui.theme.MessengerTheme
|
||||
import javax.inject.Inject
|
||||
|
||||
@AndroidEntryPoint
|
||||
class MainActivity : AppCompatActivity() {
|
||||
@Inject
|
||||
lateinit var themeRepository: ThemeRepository
|
||||
|
||||
@Inject
|
||||
lateinit var languageRepository: LanguageRepository
|
||||
|
||||
@Inject
|
||||
lateinit var notificationDispatcher: NotificationDispatcher
|
||||
|
||||
private var pendingInviteToken by mutableStateOf<String?>(null)
|
||||
private var pendingVerifyEmailToken by mutableStateOf<String?>(null)
|
||||
private var pendingResetPasswordToken by mutableStateOf<String?>(null)
|
||||
private var pendingNotificationChatId by mutableStateOf<Long?>(null)
|
||||
private var pendingNotificationMessageId by mutableStateOf<Long?>(null)
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
val savedThemeMode = if (this::themeRepository.isInitialized) {
|
||||
runBlocking { themeRepository.getThemeMode() }
|
||||
} else {
|
||||
AppThemeMode.SYSTEM
|
||||
}
|
||||
AppCompatDelegate.setDefaultNightMode(
|
||||
when (savedThemeMode) {
|
||||
AppThemeMode.LIGHT -> AppCompatDelegate.MODE_NIGHT_NO
|
||||
AppThemeMode.DARK -> AppCompatDelegate.MODE_NIGHT_YES
|
||||
AppThemeMode.SYSTEM -> AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM
|
||||
}
|
||||
)
|
||||
val savedLanguageTag = if (this::languageRepository.isInitialized) {
|
||||
runBlocking { languageRepository.getLanguage().tag }
|
||||
} else {
|
||||
null
|
||||
}
|
||||
val locales = savedLanguageTag?.let { LocaleListCompat.forLanguageTags(it) } ?: LocaleListCompat.getEmptyLocaleList()
|
||||
AppCompatDelegate.setApplicationLocales(locales)
|
||||
pendingInviteToken = intent.extractInviteToken()
|
||||
pendingVerifyEmailToken = intent.extractVerifyEmailToken()
|
||||
pendingResetPasswordToken = intent.extractResetPasswordToken()
|
||||
val notificationPayload = intent.extractNotificationOpenPayload()
|
||||
pendingNotificationChatId = notificationPayload?.first
|
||||
pendingNotificationMessageId = notificationPayload?.second
|
||||
notificationPayload?.first?.let(notificationDispatcher::clearChatNotifications)
|
||||
enableEdgeToEdge()
|
||||
setContent {
|
||||
MessengerTheme {
|
||||
Surface(modifier = Modifier.fillMaxSize()) {
|
||||
AppRoot(
|
||||
inviteToken = pendingInviteToken,
|
||||
onInviteTokenConsumed = { pendingInviteToken = null },
|
||||
verifyEmailToken = pendingVerifyEmailToken,
|
||||
onVerifyEmailTokenConsumed = { pendingVerifyEmailToken = null },
|
||||
resetPasswordToken = pendingResetPasswordToken,
|
||||
onResetPasswordTokenConsumed = { pendingResetPasswordToken = null },
|
||||
notificationChatId = pendingNotificationChatId,
|
||||
notificationMessageId = pendingNotificationMessageId,
|
||||
onNotificationConsumed = {
|
||||
pendingNotificationChatId = null
|
||||
pendingNotificationMessageId = null
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onNewIntent(intent: Intent) {
|
||||
super.onNewIntent(intent)
|
||||
setIntent(intent)
|
||||
pendingInviteToken = intent.extractInviteToken() ?: pendingInviteToken
|
||||
pendingVerifyEmailToken = intent.extractVerifyEmailToken() ?: pendingVerifyEmailToken
|
||||
pendingResetPasswordToken = intent.extractResetPasswordToken() ?: pendingResetPasswordToken
|
||||
val notificationPayload = intent.extractNotificationOpenPayload()
|
||||
if (notificationPayload != null) {
|
||||
pendingNotificationChatId = notificationPayload.first
|
||||
pendingNotificationMessageId = notificationPayload.second
|
||||
notificationDispatcher.clearChatNotifications(notificationPayload.first)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AppRoot(
|
||||
inviteToken: String?,
|
||||
onInviteTokenConsumed: () -> Unit,
|
||||
verifyEmailToken: String?,
|
||||
onVerifyEmailTokenConsumed: () -> Unit,
|
||||
resetPasswordToken: String?,
|
||||
onResetPasswordTokenConsumed: () -> Unit,
|
||||
notificationChatId: Long?,
|
||||
notificationMessageId: Long?,
|
||||
onNotificationConsumed: () -> Unit,
|
||||
) {
|
||||
MessengerNavHost(
|
||||
inviteToken = inviteToken,
|
||||
onInviteTokenConsumed = onInviteTokenConsumed,
|
||||
verifyEmailToken = verifyEmailToken,
|
||||
onVerifyEmailTokenConsumed = onVerifyEmailTokenConsumed,
|
||||
resetPasswordToken = resetPasswordToken,
|
||||
onResetPasswordTokenConsumed = onResetPasswordTokenConsumed,
|
||||
notificationChatId = notificationChatId,
|
||||
notificationMessageId = notificationMessageId,
|
||||
onNotificationConsumed = onNotificationConsumed,
|
||||
)
|
||||
}
|
||||
|
||||
private fun Intent?.extractVerifyEmailToken(): String? {
|
||||
val uri = this?.data ?: return null
|
||||
val isVerifyPath = uri.pathSegments.contains("verify-email") || uri.path.equals("/verify-email", ignoreCase = true)
|
||||
if (!isVerifyPath) return null
|
||||
return uri.getQueryParameter("token")?.trim()?.takeIf { it.isNotBlank() }
|
||||
}
|
||||
|
||||
private fun Intent?.extractResetPasswordToken(): String? {
|
||||
val uri = this?.data ?: return null
|
||||
val isResetPath = uri.pathSegments.contains("reset-password") || uri.path.equals("/reset-password", ignoreCase = true)
|
||||
if (!isResetPath) return null
|
||||
return uri.getQueryParameter("token")?.trim()?.takeIf { it.isNotBlank() }
|
||||
}
|
||||
|
||||
private fun Intent?.extractInviteToken(): String? {
|
||||
val uri = this?.data ?: return null
|
||||
val queryToken = uri.getQueryParameter("token")?.trim().orEmpty()
|
||||
if (queryToken.isNotBlank()) return queryToken
|
||||
|
||||
val segments = uri.pathSegments
|
||||
val joinIndex = segments.indexOf("join")
|
||||
if (joinIndex >= 0 && joinIndex + 1 < segments.size) {
|
||||
val token = segments[joinIndex + 1].trim()
|
||||
if (token.isNotBlank()) return token
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
private fun Intent?.extractNotificationOpenPayload(): Pair<Long, Long?>? {
|
||||
val source = this ?: return null
|
||||
val chatId = source.getLongExtra(NotificationIntentExtras.EXTRA_CHAT_ID, -1L)
|
||||
if (chatId <= 0L) return null
|
||||
val rawMessageId = source.getLongExtra(NotificationIntentExtras.EXTRA_MESSAGE_ID, -1L)
|
||||
val messageId = rawMessageId.takeIf { it > 0L }
|
||||
return chatId to messageId
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
package ru.daemonlord.messenger
|
||||
|
||||
import android.app.Application
|
||||
import android.os.Build
|
||||
import coil.ImageLoader
|
||||
import coil.ImageLoaderFactory
|
||||
import coil.decode.GifDecoder
|
||||
import coil.decode.ImageDecoderDecoder
|
||||
import coil.disk.DiskCache
|
||||
import coil.memory.MemoryCache
|
||||
import com.google.firebase.crashlytics.FirebaseCrashlytics
|
||||
import dagger.hilt.android.HiltAndroidApp
|
||||
import ru.daemonlord.messenger.core.notifications.NotificationChannels
|
||||
import ru.daemonlord.messenger.push.PushTokenSyncManager
|
||||
import java.io.File
|
||||
import timber.log.Timber
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltAndroidApp
|
||||
class MessengerApplication : Application(), ImageLoaderFactory {
|
||||
|
||||
@Inject
|
||||
lateinit var pushTokenSyncManager: PushTokenSyncManager
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
if (BuildConfig.DEBUG) {
|
||||
Timber.plant(Timber.DebugTree())
|
||||
}
|
||||
FirebaseCrashlytics.getInstance().setCrashlyticsCollectionEnabled(!BuildConfig.DEBUG)
|
||||
NotificationChannels.ensureCreated(this)
|
||||
pushTokenSyncManager.triggerBestEffortSync()
|
||||
}
|
||||
|
||||
override fun newImageLoader(): ImageLoader {
|
||||
val diskCacheDir = File(cacheDir, "coil_images")
|
||||
if (!diskCacheDir.exists()) {
|
||||
diskCacheDir.mkdirs()
|
||||
}
|
||||
return ImageLoader.Builder(this)
|
||||
.components {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
|
||||
add(ImageDecoderDecoder.Factory())
|
||||
} else {
|
||||
add(GifDecoder.Factory())
|
||||
}
|
||||
}
|
||||
.memoryCache {
|
||||
MemoryCache.Builder(this)
|
||||
.maxSizePercent(0.25)
|
||||
.build()
|
||||
}
|
||||
.diskCache {
|
||||
DiskCache.Builder()
|
||||
.directory(diskCacheDir)
|
||||
.maxSizeBytes(250L * 1024L * 1024L)
|
||||
.build()
|
||||
}
|
||||
.respectCacheHeaders(false)
|
||||
.build()
|
||||
}
|
||||
}
|
||||
@@ -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,37 @@
|
||||
package ru.daemonlord.messenger.core.network
|
||||
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import okhttp3.Interceptor
|
||||
import okhttp3.Response
|
||||
import ru.daemonlord.messenger.core.token.TokenRepository
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
class AuthHeaderInterceptor @Inject constructor(
|
||||
private val tokenRepository: TokenRepository,
|
||||
) : Interceptor {
|
||||
|
||||
override fun intercept(chain: Interceptor.Chain): Response {
|
||||
val originalRequest = chain.request()
|
||||
val noAuthHeader = originalRequest.header(NO_AUTH_HEADER)
|
||||
|
||||
if (noAuthHeader == "true") {
|
||||
val requestWithoutMarker = originalRequest.newBuilder()
|
||||
.removeHeader(NO_AUTH_HEADER)
|
||||
.build()
|
||||
return chain.proceed(requestWithoutMarker)
|
||||
}
|
||||
|
||||
val accessToken = runBlocking { tokenRepository.getTokens()?.accessToken }
|
||||
val requestBuilder = originalRequest.newBuilder()
|
||||
if (!accessToken.isNullOrBlank()) {
|
||||
requestBuilder.header("Authorization", "Bearer $accessToken")
|
||||
}
|
||||
return chain.proceed(requestBuilder.build())
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val NO_AUTH_HEADER = "No-Auth"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
package ru.daemonlord.messenger.core.network
|
||||
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import okhttp3.Authenticator
|
||||
import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
import okhttp3.Route
|
||||
import ru.daemonlord.messenger.core.token.TokenBundle
|
||||
import ru.daemonlord.messenger.core.token.TokenRepository
|
||||
import ru.daemonlord.messenger.data.auth.api.AuthApiService
|
||||
import ru.daemonlord.messenger.data.auth.dto.RefreshTokenRequestDto
|
||||
import ru.daemonlord.messenger.di.RefreshAuthApi
|
||||
import java.io.IOException
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
class TokenRefreshAuthenticator @Inject constructor(
|
||||
private val tokenRepository: TokenRepository,
|
||||
@RefreshAuthApi
|
||||
private val refreshAuthApiService: AuthApiService,
|
||||
) : Authenticator {
|
||||
|
||||
override fun authenticate(route: Route?, response: Response): Request? {
|
||||
if (responseCount(response) >= MAX_RETRIES) {
|
||||
return null
|
||||
}
|
||||
|
||||
val marker = response.request.header(NO_AUTH_HEADER)
|
||||
if (marker == "true") {
|
||||
return null
|
||||
}
|
||||
|
||||
val refreshedAccessToken = synchronized(this) {
|
||||
runBlocking {
|
||||
val currentTokens = tokenRepository.getTokens() ?: return@runBlocking null
|
||||
tryRefreshTokens(currentTokens)
|
||||
}
|
||||
} ?: return null
|
||||
|
||||
return response.request.newBuilder()
|
||||
.header("Authorization", "Bearer $refreshedAccessToken")
|
||||
.build()
|
||||
}
|
||||
|
||||
private suspend fun tryRefreshTokens(tokens: TokenBundle): String? {
|
||||
return try {
|
||||
val refreshed = refreshAuthApiService.refresh(
|
||||
request = RefreshTokenRequestDto(refreshToken = tokens.refreshToken)
|
||||
)
|
||||
tokenRepository.saveTokens(
|
||||
TokenBundle(
|
||||
accessToken = refreshed.accessToken,
|
||||
refreshToken = refreshed.refreshToken,
|
||||
savedAtMillis = System.currentTimeMillis(),
|
||||
)
|
||||
)
|
||||
refreshed.accessToken
|
||||
} catch (_: IOException) {
|
||||
null
|
||||
} catch (_: Exception) {
|
||||
tokenRepository.clearTokens()
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
private fun responseCount(response: Response): Int {
|
||||
var current: Response? = response
|
||||
var count = 1
|
||||
while (current?.priorResponse != null) {
|
||||
count++
|
||||
current = current.priorResponse
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val MAX_RETRIES = 2
|
||||
const val NO_AUTH_HEADER = "No-Auth"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
package ru.daemonlord.messenger.core.token
|
||||
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import androidx.datastore.preferences.core.edit
|
||||
import androidx.datastore.preferences.core.longPreferencesKey
|
||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.map
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
class DataStoreTokenRepository @Inject constructor(
|
||||
private val dataStore: DataStore<Preferences>,
|
||||
) : TokenRepository {
|
||||
|
||||
override fun observeTokens(): Flow<TokenBundle?> = dataStore.data.map { preferences ->
|
||||
preferences.toTokenBundleOrNull()
|
||||
}
|
||||
|
||||
override fun observeAccounts(): Flow<List<StoredAccount>> {
|
||||
return observeTokens().map { tokens ->
|
||||
if (tokens == null) emptyList() else {
|
||||
val userId = tokens.accessToken.extractUserIdFromJwt() ?: return@map emptyList()
|
||||
listOf(
|
||||
StoredAccount(
|
||||
userId = userId,
|
||||
email = null,
|
||||
name = "User #$userId",
|
||||
username = null,
|
||||
avatarUrl = null,
|
||||
lastActiveAt = tokens.savedAtMillis,
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun observeActiveUserId(): Flow<Long?> {
|
||||
return observeTokens().map { it?.accessToken?.extractUserIdFromJwt() }
|
||||
}
|
||||
|
||||
override suspend fun getTokens(): TokenBundle? {
|
||||
return observeTokens().first()
|
||||
}
|
||||
|
||||
override suspend fun getAccounts(): List<StoredAccount> {
|
||||
return observeAccounts().first()
|
||||
}
|
||||
|
||||
override suspend fun getActiveUserId(): Long? {
|
||||
return observeActiveUserId().first()
|
||||
}
|
||||
|
||||
override suspend fun saveTokens(tokens: TokenBundle) {
|
||||
dataStore.edit { preferences ->
|
||||
preferences[ACCESS_TOKEN_KEY] = tokens.accessToken
|
||||
preferences[REFRESH_TOKEN_KEY] = tokens.refreshToken
|
||||
preferences[SAVED_AT_KEY] = tokens.savedAtMillis
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun upsertAccount(account: StoredAccount) {
|
||||
// DataStoreTokenRepository is not used in production DI currently.
|
||||
}
|
||||
|
||||
override suspend fun switchAccount(userId: Long): Boolean {
|
||||
return getActiveUserId() == userId
|
||||
}
|
||||
|
||||
override suspend fun removeAccount(userId: Long) {
|
||||
if (getActiveUserId() == userId) {
|
||||
clearTokens()
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun clearTokens() {
|
||||
dataStore.edit { preferences ->
|
||||
preferences.remove(ACCESS_TOKEN_KEY)
|
||||
preferences.remove(REFRESH_TOKEN_KEY)
|
||||
preferences.remove(SAVED_AT_KEY)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun clearAllTokens() {
|
||||
clearTokens()
|
||||
}
|
||||
|
||||
private fun Preferences.toTokenBundleOrNull(): TokenBundle? {
|
||||
val access = this[ACCESS_TOKEN_KEY]
|
||||
val refresh = this[REFRESH_TOKEN_KEY]
|
||||
val savedAt = this[SAVED_AT_KEY]
|
||||
|
||||
if (access.isNullOrBlank() || refresh.isNullOrBlank() || savedAt == null) {
|
||||
return null
|
||||
}
|
||||
|
||||
return TokenBundle(
|
||||
accessToken = access,
|
||||
refreshToken = refresh,
|
||||
savedAtMillis = savedAt,
|
||||
)
|
||||
}
|
||||
|
||||
private fun String.extractUserIdFromJwt(): Long? {
|
||||
val payload = split('.').getOrNull(1) ?: return null
|
||||
val normalized = payload
|
||||
.replace('-', '+')
|
||||
.replace('_', '/')
|
||||
.let { source ->
|
||||
when (source.length % 4) {
|
||||
0 -> source
|
||||
2 -> source + "=="
|
||||
3 -> source + "="
|
||||
else -> return null
|
||||
}
|
||||
}
|
||||
return runCatching {
|
||||
val json = String(java.util.Base64.getDecoder().decode(normalized), Charsets.UTF_8)
|
||||
val marker = "\"sub\":\""
|
||||
val start = json.indexOf(marker)
|
||||
if (start < 0) null
|
||||
else {
|
||||
val valueStart = start + marker.length
|
||||
val valueEnd = json.indexOf('"', valueStart)
|
||||
if (valueEnd <= valueStart) null else json.substring(valueStart, valueEnd).toLongOrNull()
|
||||
}
|
||||
}.getOrNull()
|
||||
}
|
||||
|
||||
private companion object {
|
||||
val ACCESS_TOKEN_KEY = stringPreferencesKey("access_token")
|
||||
val REFRESH_TOKEN_KEY = stringPreferencesKey("refresh_token")
|
||||
val SAVED_AT_KEY = longPreferencesKey("tokens_saved_at")
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
package ru.daemonlord.messenger.core.token
|
||||
|
||||
data class TokenBundle(
|
||||
val accessToken: String,
|
||||
val refreshToken: String,
|
||||
val savedAtMillis: Long,
|
||||
)
|
||||
@@ -0,0 +1,18 @@
|
||||
package ru.daemonlord.messenger.core.token
|
||||
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
interface TokenRepository {
|
||||
fun observeTokens(): Flow<TokenBundle?>
|
||||
fun observeAccounts(): Flow<List<StoredAccount>>
|
||||
fun observeActiveUserId(): Flow<Long?>
|
||||
suspend fun getTokens(): TokenBundle?
|
||||
suspend fun getAccounts(): List<StoredAccount>
|
||||
suspend fun getActiveUserId(): Long?
|
||||
suspend fun saveTokens(tokens: TokenBundle)
|
||||
suspend fun upsertAccount(account: StoredAccount)
|
||||
suspend fun switchAccount(userId: Long): Boolean
|
||||
suspend fun removeAccount(userId: Long)
|
||||
suspend fun clearTokens()
|
||||
suspend fun clearAllTokens()
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
package ru.daemonlord.messenger.data.auth.api
|
||||
|
||||
import ru.daemonlord.messenger.data.auth.dto.AuthUserDto
|
||||
import ru.daemonlord.messenger.data.auth.dto.AuthSessionDto
|
||||
import ru.daemonlord.messenger.data.auth.dto.CheckEmailStatusDto
|
||||
import ru.daemonlord.messenger.data.auth.dto.LoginRequestDto
|
||||
import ru.daemonlord.messenger.data.auth.dto.MessageResponseDto
|
||||
import ru.daemonlord.messenger.data.auth.dto.RegisterRequestDto
|
||||
import ru.daemonlord.messenger.data.auth.dto.ResendVerificationRequestDto
|
||||
import ru.daemonlord.messenger.data.auth.dto.RequestPasswordResetDto
|
||||
import ru.daemonlord.messenger.data.auth.dto.ResetPasswordRequestDto
|
||||
import ru.daemonlord.messenger.data.auth.dto.RefreshTokenRequestDto
|
||||
import ru.daemonlord.messenger.data.auth.dto.TokenResponseDto
|
||||
import ru.daemonlord.messenger.data.auth.dto.TwoFactorCodeRequestDto
|
||||
import ru.daemonlord.messenger.data.auth.dto.TwoFactorRecoveryCodesDto
|
||||
import ru.daemonlord.messenger.data.auth.dto.TwoFactorRecoveryStatusDto
|
||||
import ru.daemonlord.messenger.data.auth.dto.TwoFactorSetupDto
|
||||
import ru.daemonlord.messenger.data.auth.dto.VerifyEmailRequestDto
|
||||
import retrofit2.http.Headers
|
||||
import retrofit2.http.Body
|
||||
import retrofit2.http.GET
|
||||
import retrofit2.http.POST
|
||||
import retrofit2.http.DELETE
|
||||
import retrofit2.http.Path
|
||||
import retrofit2.http.Query
|
||||
|
||||
interface AuthApiService {
|
||||
@Headers("No-Auth: true")
|
||||
@GET("/api/v1/auth/check-email")
|
||||
suspend fun checkEmailStatus(@Query("email") email: String): CheckEmailStatusDto
|
||||
|
||||
@Headers("No-Auth: true")
|
||||
@POST("/api/v1/auth/register")
|
||||
suspend fun register(@Body request: RegisterRequestDto): MessageResponseDto
|
||||
|
||||
@Headers("No-Auth: true")
|
||||
@POST("/api/v1/auth/login")
|
||||
suspend fun login(@Body request: LoginRequestDto): TokenResponseDto
|
||||
|
||||
@Headers("No-Auth: true")
|
||||
@POST("/api/v1/auth/refresh")
|
||||
suspend fun refresh(@Body request: RefreshTokenRequestDto): TokenResponseDto
|
||||
|
||||
@GET("/api/v1/auth/me")
|
||||
suspend fun me(): AuthUserDto
|
||||
|
||||
@Headers("No-Auth: true")
|
||||
@POST("/api/v1/auth/verify-email")
|
||||
suspend fun verifyEmail(@Body request: VerifyEmailRequestDto): MessageResponseDto
|
||||
|
||||
@Headers("No-Auth: true")
|
||||
@POST("/api/v1/auth/request-password-reset")
|
||||
suspend fun requestPasswordReset(@Body request: RequestPasswordResetDto): MessageResponseDto
|
||||
|
||||
@Headers("No-Auth: true")
|
||||
@POST("/api/v1/auth/resend-verification")
|
||||
suspend fun resendVerification(@Body request: ResendVerificationRequestDto): MessageResponseDto
|
||||
|
||||
@Headers("No-Auth: true")
|
||||
@POST("/api/v1/auth/reset-password")
|
||||
suspend fun resetPassword(@Body request: ResetPasswordRequestDto): MessageResponseDto
|
||||
|
||||
@GET("/api/v1/auth/sessions")
|
||||
suspend fun sessions(): List<AuthSessionDto>
|
||||
|
||||
@DELETE("/api/v1/auth/sessions/{jti}")
|
||||
suspend fun revokeSession(@Path("jti") jti: String)
|
||||
|
||||
@DELETE("/api/v1/auth/sessions")
|
||||
suspend fun revokeAllSessions()
|
||||
|
||||
@POST("/api/v1/auth/2fa/setup")
|
||||
suspend fun setupTwoFactor(): TwoFactorSetupDto
|
||||
|
||||
@POST("/api/v1/auth/2fa/enable")
|
||||
suspend fun enableTwoFactor(@Body request: TwoFactorCodeRequestDto): MessageResponseDto
|
||||
|
||||
@POST("/api/v1/auth/2fa/disable")
|
||||
suspend fun disableTwoFactor(@Body request: TwoFactorCodeRequestDto): MessageResponseDto
|
||||
|
||||
@GET("/api/v1/auth/2fa/recovery-codes/status")
|
||||
suspend fun twoFactorRecoveryStatus(): TwoFactorRecoveryStatusDto
|
||||
|
||||
@POST("/api/v1/auth/2fa/recovery-codes/regenerate")
|
||||
suspend fun regenerateTwoFactorRecoveryCodes(@Body request: TwoFactorCodeRequestDto): TwoFactorRecoveryCodesDto
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
package ru.daemonlord.messenger.data.auth.dto
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class LoginRequestDto(
|
||||
val email: String,
|
||||
val password: String,
|
||||
@SerialName("otp_code")
|
||||
val otpCode: String? = null,
|
||||
@SerialName("recovery_code")
|
||||
val recoveryCode: String? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class RefreshTokenRequestDto(
|
||||
@SerialName("refresh_token")
|
||||
val refreshToken: String,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class TokenResponseDto(
|
||||
@SerialName("access_token")
|
||||
val accessToken: String,
|
||||
@SerialName("refresh_token")
|
||||
val refreshToken: String,
|
||||
@SerialName("token_type")
|
||||
val tokenType: String,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class AuthUserDto(
|
||||
val id: Long,
|
||||
val email: String,
|
||||
val name: String,
|
||||
val username: String,
|
||||
val bio: String? = null,
|
||||
@SerialName("avatar_url")
|
||||
val avatarUrl: String? = null,
|
||||
@SerialName("email_verified")
|
||||
val emailVerified: Boolean,
|
||||
@SerialName("twofa_enabled")
|
||||
val twofaEnabled: Boolean = false,
|
||||
@SerialName("privacy_private_messages")
|
||||
val privacyPrivateMessages: String? = null,
|
||||
@SerialName("privacy_last_seen")
|
||||
val privacyLastSeen: String? = null,
|
||||
@SerialName("privacy_avatar")
|
||||
val privacyAvatar: String? = null,
|
||||
@SerialName("privacy_group_invites")
|
||||
val privacyGroupInvites: String? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class AuthSessionDto(
|
||||
val jti: String,
|
||||
@SerialName("created_at")
|
||||
val createdAt: String,
|
||||
@SerialName("ip_address")
|
||||
val ipAddress: String? = null,
|
||||
@SerialName("user_agent")
|
||||
val userAgent: String? = null,
|
||||
val current: Boolean? = null,
|
||||
@SerialName("token_type")
|
||||
val tokenType: String? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ErrorResponseDto(
|
||||
val detail: String? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class MessageResponseDto(
|
||||
val message: String,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class VerifyEmailRequestDto(
|
||||
val token: String,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class RequestPasswordResetDto(
|
||||
val email: String,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ResendVerificationRequestDto(
|
||||
val email: String,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ResetPasswordRequestDto(
|
||||
val token: String,
|
||||
val password: String,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class CheckEmailStatusDto(
|
||||
val email: String,
|
||||
val registered: Boolean,
|
||||
@SerialName("email_verified")
|
||||
val emailVerified: Boolean,
|
||||
@SerialName("twofa_enabled")
|
||||
val twofaEnabled: Boolean,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class RegisterRequestDto(
|
||||
val email: String,
|
||||
val name: String,
|
||||
val username: String,
|
||||
val password: String,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class TwoFactorSetupDto(
|
||||
val secret: String,
|
||||
@SerialName("otpauth_url")
|
||||
val otpauthUrl: String,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class TwoFactorCodeRequestDto(
|
||||
val code: String,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class TwoFactorRecoveryStatusDto(
|
||||
@SerialName("remaining_codes")
|
||||
val remainingCodes: Int,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class TwoFactorRecoveryCodesDto(
|
||||
val codes: List<String>,
|
||||
)
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,242 @@
|
||||
package ru.daemonlord.messenger.data.auth.repository
|
||||
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.withContext
|
||||
import ru.daemonlord.messenger.core.token.TokenBundle
|
||||
import ru.daemonlord.messenger.core.token.StoredAccount
|
||||
import ru.daemonlord.messenger.core.token.TokenRepository
|
||||
import ru.daemonlord.messenger.data.auth.api.AuthApiService
|
||||
import ru.daemonlord.messenger.data.auth.dto.AuthUserDto
|
||||
import ru.daemonlord.messenger.data.auth.dto.CheckEmailStatusDto
|
||||
import ru.daemonlord.messenger.data.auth.dto.AuthSessionDto
|
||||
import ru.daemonlord.messenger.data.auth.dto.LoginRequestDto
|
||||
import ru.daemonlord.messenger.data.auth.dto.RefreshTokenRequestDto
|
||||
import ru.daemonlord.messenger.data.auth.dto.RegisterRequestDto
|
||||
import ru.daemonlord.messenger.data.common.ApiErrorMode
|
||||
import ru.daemonlord.messenger.data.common.toAppError
|
||||
import ru.daemonlord.messenger.di.IoDispatcher
|
||||
import ru.daemonlord.messenger.domain.auth.model.AuthEmailStatus
|
||||
import ru.daemonlord.messenger.domain.auth.model.AuthUser
|
||||
import ru.daemonlord.messenger.domain.auth.model.AuthSession
|
||||
import ru.daemonlord.messenger.domain.auth.repository.AuthRepository
|
||||
import ru.daemonlord.messenger.domain.common.AppError
|
||||
import ru.daemonlord.messenger.domain.common.AppResult
|
||||
import ru.daemonlord.messenger.push.PushTokenSyncManager
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
class NetworkAuthRepository @Inject constructor(
|
||||
private val authApiService: AuthApiService,
|
||||
private val tokenRepository: TokenRepository,
|
||||
private val pushTokenSyncManager: PushTokenSyncManager,
|
||||
@IoDispatcher private val ioDispatcher: CoroutineDispatcher,
|
||||
) : AuthRepository {
|
||||
|
||||
override suspend fun checkEmailStatus(email: String): AppResult<AuthEmailStatus> = withContext(ioDispatcher) {
|
||||
val normalized = email.trim().lowercase()
|
||||
if (normalized.isBlank()) return@withContext AppResult.Error(AppError.Server("Email is required"))
|
||||
try {
|
||||
AppResult.Success(authApiService.checkEmailStatus(normalized).toDomain())
|
||||
} catch (error: Throwable) {
|
||||
AppResult.Error(error.toAppError())
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun register(
|
||||
email: String,
|
||||
name: String,
|
||||
username: String,
|
||||
password: String,
|
||||
): AppResult<Unit> = withContext(ioDispatcher) {
|
||||
val normalizedEmail = email.trim().lowercase()
|
||||
val normalizedName = name.trim()
|
||||
val normalizedUsername = username.trim().removePrefix("@")
|
||||
if (normalizedEmail.isBlank() || normalizedName.isBlank() || normalizedUsername.isBlank() || password.isBlank()) {
|
||||
return@withContext AppResult.Error(AppError.Server("Email, name, username and password are required"))
|
||||
}
|
||||
try {
|
||||
authApiService.register(
|
||||
request = RegisterRequestDto(
|
||||
email = normalizedEmail,
|
||||
name = normalizedName,
|
||||
username = normalizedUsername,
|
||||
password = password,
|
||||
)
|
||||
)
|
||||
AppResult.Success(Unit)
|
||||
} catch (error: Throwable) {
|
||||
AppResult.Error(error.toAppError())
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun login(
|
||||
email: String,
|
||||
password: String,
|
||||
otpCode: String?,
|
||||
recoveryCode: String?,
|
||||
): AppResult<AuthUser> = withContext(ioDispatcher) {
|
||||
try {
|
||||
val tokenResponse = authApiService.login(
|
||||
request = LoginRequestDto(
|
||||
email = email,
|
||||
password = password,
|
||||
otpCode = otpCode?.trim()?.ifBlank { null },
|
||||
recoveryCode = recoveryCode?.trim()?.ifBlank { null },
|
||||
)
|
||||
)
|
||||
tokenRepository.saveTokens(
|
||||
TokenBundle(
|
||||
accessToken = tokenResponse.accessToken,
|
||||
refreshToken = tokenResponse.refreshToken,
|
||||
savedAtMillis = System.currentTimeMillis(),
|
||||
)
|
||||
)
|
||||
pushTokenSyncManager.triggerBestEffortSync()
|
||||
when (val meResult = getMe()) {
|
||||
is AppResult.Success -> {
|
||||
tokenRepository.upsertAccount(meResult.data.toStoredAccount())
|
||||
meResult
|
||||
}
|
||||
is AppResult.Error -> meResult
|
||||
}
|
||||
} catch (error: Throwable) {
|
||||
AppResult.Error(error.toAppError(mode = ApiErrorMode.LOGIN))
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun refreshTokens(): AppResult<Unit> = withContext(ioDispatcher) {
|
||||
val tokens = tokenRepository.getTokens()
|
||||
?: return@withContext AppResult.Error(AppError.Unauthorized)
|
||||
try {
|
||||
val refreshed = authApiService.refresh(
|
||||
request = RefreshTokenRequestDto(refreshToken = tokens.refreshToken)
|
||||
)
|
||||
tokenRepository.saveTokens(
|
||||
TokenBundle(
|
||||
accessToken = refreshed.accessToken,
|
||||
refreshToken = refreshed.refreshToken,
|
||||
savedAtMillis = System.currentTimeMillis(),
|
||||
)
|
||||
)
|
||||
pushTokenSyncManager.triggerBestEffortSync()
|
||||
AppResult.Success(Unit)
|
||||
} catch (error: Throwable) {
|
||||
tokenRepository.clearTokens()
|
||||
AppResult.Error(error.toAppError())
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun getMe(): AppResult<AuthUser> = withContext(ioDispatcher) {
|
||||
try {
|
||||
val user = authApiService.me().toDomain()
|
||||
tokenRepository.upsertAccount(user.toStoredAccount())
|
||||
AppResult.Success(user)
|
||||
} catch (error: Throwable) {
|
||||
AppResult.Error(error.toAppError())
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun restoreSession(): AppResult<AuthUser> = withContext(ioDispatcher) {
|
||||
val tokens = tokenRepository.getTokens()
|
||||
?: return@withContext AppResult.Error(AppError.Unauthorized)
|
||||
|
||||
if (tokens.accessToken.isBlank() || tokens.refreshToken.isBlank()) {
|
||||
tokenRepository.clearTokens()
|
||||
return@withContext AppResult.Error(AppError.Unauthorized)
|
||||
}
|
||||
|
||||
when (val meResult = getMe()) {
|
||||
is AppResult.Success -> {
|
||||
pushTokenSyncManager.triggerBestEffortSync()
|
||||
meResult
|
||||
}
|
||||
is AppResult.Error -> {
|
||||
if (meResult.reason is AppError.Unauthorized) {
|
||||
tokenRepository.clearTokens()
|
||||
}
|
||||
meResult
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun listSessions(): AppResult<List<AuthSession>> = withContext(ioDispatcher) {
|
||||
try {
|
||||
AppResult.Success(authApiService.sessions().map { it.toDomain() })
|
||||
} catch (error: Throwable) {
|
||||
AppResult.Error(error.toAppError())
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun revokeSession(jti: String): AppResult<Unit> = withContext(ioDispatcher) {
|
||||
try {
|
||||
authApiService.revokeSession(jti = jti)
|
||||
AppResult.Success(Unit)
|
||||
} catch (error: Throwable) {
|
||||
AppResult.Error(error.toAppError())
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun revokeAllSessions(): AppResult<Unit> = withContext(ioDispatcher) {
|
||||
try {
|
||||
authApiService.revokeAllSessions()
|
||||
AppResult.Success(Unit)
|
||||
} catch (error: Throwable) {
|
||||
AppResult.Error(error.toAppError())
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun logout() {
|
||||
pushTokenSyncManager.unregisterCurrentTokenOnLogout()
|
||||
tokenRepository.clearTokens()
|
||||
}
|
||||
|
||||
private fun AuthUserDto.toDomain(): AuthUser {
|
||||
return AuthUser(
|
||||
id = id,
|
||||
email = email,
|
||||
name = name,
|
||||
username = username,
|
||||
bio = bio,
|
||||
avatarUrl = avatarUrl,
|
||||
emailVerified = emailVerified,
|
||||
twofaEnabled = twofaEnabled,
|
||||
privacyPrivateMessages = privacyPrivateMessages ?: "everyone",
|
||||
privacyLastSeen = privacyLastSeen ?: "everyone",
|
||||
privacyAvatar = privacyAvatar ?: "everyone",
|
||||
privacyGroupInvites = privacyGroupInvites ?: "everyone",
|
||||
)
|
||||
}
|
||||
|
||||
private fun AuthUser.toStoredAccount(): StoredAccount {
|
||||
return StoredAccount(
|
||||
userId = id,
|
||||
email = email,
|
||||
name = name,
|
||||
username = username,
|
||||
avatarUrl = avatarUrl,
|
||||
lastActiveAt = System.currentTimeMillis(),
|
||||
)
|
||||
}
|
||||
|
||||
private fun AuthSessionDto.toDomain(): AuthSession {
|
||||
return AuthSession(
|
||||
jti = jti,
|
||||
createdAt = createdAt,
|
||||
ipAddress = ipAddress,
|
||||
userAgent = userAgent,
|
||||
current = current,
|
||||
tokenType = tokenType,
|
||||
)
|
||||
}
|
||||
|
||||
private fun CheckEmailStatusDto.toDomain(): AuthEmailStatus {
|
||||
return AuthEmailStatus(
|
||||
email = email,
|
||||
registered = registered,
|
||||
emailVerified = emailVerified,
|
||||
twofaEnabled = twofaEnabled,
|
||||
)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
package ru.daemonlord.messenger.data.chat.api
|
||||
|
||||
import retrofit2.http.Body
|
||||
import retrofit2.http.DELETE
|
||||
import retrofit2.http.GET
|
||||
import retrofit2.http.PATCH
|
||||
import retrofit2.http.Path
|
||||
import retrofit2.http.POST
|
||||
import retrofit2.http.PUT
|
||||
import retrofit2.http.Query
|
||||
import ru.daemonlord.messenger.data.chat.dto.ChatBanDto
|
||||
import ru.daemonlord.messenger.data.chat.dto.ChatCreateRequestDto
|
||||
import ru.daemonlord.messenger.data.chat.dto.ChatInviteLinkDto
|
||||
import ru.daemonlord.messenger.data.chat.dto.ChatJoinByInviteRequestDto
|
||||
import ru.daemonlord.messenger.data.chat.dto.ChatMemberAddRequestDto
|
||||
import ru.daemonlord.messenger.data.chat.dto.ChatMemberDto
|
||||
import ru.daemonlord.messenger.data.chat.dto.ChatMemberRoleUpdateRequestDto
|
||||
import ru.daemonlord.messenger.data.chat.dto.ChatNotificationSettingsDto
|
||||
import ru.daemonlord.messenger.data.chat.dto.ChatNotificationSettingsUpdateDto
|
||||
import ru.daemonlord.messenger.data.chat.dto.ChatProfileUpdateRequestDto
|
||||
import ru.daemonlord.messenger.data.chat.dto.ChatReadDto
|
||||
import ru.daemonlord.messenger.data.chat.dto.ChatTitleUpdateRequestDto
|
||||
import ru.daemonlord.messenger.data.chat.dto.DiscoverChatDto
|
||||
|
||||
interface ChatApiService {
|
||||
@GET("/api/v1/chats")
|
||||
suspend fun getChats(
|
||||
@Query("archived") archived: Boolean = false,
|
||||
): List<ChatReadDto>
|
||||
|
||||
@GET("/api/v1/chats/{chat_id}")
|
||||
suspend fun getChatById(
|
||||
@Path("chat_id") chatId: Long,
|
||||
): ChatReadDto
|
||||
|
||||
@GET("/api/v1/chats/saved")
|
||||
suspend fun getSavedChat(): ChatReadDto
|
||||
|
||||
@POST("/api/v1/chats/{chat_id}/invite-link")
|
||||
suspend fun createInviteLink(
|
||||
@Path("chat_id") chatId: Long,
|
||||
): ChatInviteLinkDto
|
||||
|
||||
@POST("/api/v1/chats/join-by-invite")
|
||||
suspend fun joinByInvite(
|
||||
@Body request: ChatJoinByInviteRequestDto,
|
||||
): ChatReadDto
|
||||
|
||||
@POST("/api/v1/chats")
|
||||
suspend fun createChat(
|
||||
@Body request: ChatCreateRequestDto,
|
||||
): ChatReadDto
|
||||
|
||||
@GET("/api/v1/chats/discover")
|
||||
suspend fun discoverChats(
|
||||
@Query("query") query: String? = null,
|
||||
): List<DiscoverChatDto>
|
||||
|
||||
@POST("/api/v1/chats/{chat_id}/join")
|
||||
suspend fun joinChat(
|
||||
@Path("chat_id") chatId: Long,
|
||||
): ChatReadDto
|
||||
|
||||
@POST("/api/v1/chats/{chat_id}/leave")
|
||||
suspend fun leaveChat(
|
||||
@Path("chat_id") chatId: Long,
|
||||
)
|
||||
|
||||
@DELETE("/api/v1/chats/{chat_id}")
|
||||
suspend fun deleteChat(
|
||||
@Path("chat_id") chatId: Long,
|
||||
@Query("for_all") forAll: Boolean = false,
|
||||
)
|
||||
|
||||
@POST("/api/v1/chats/{chat_id}/clear")
|
||||
suspend fun clearChat(
|
||||
@Path("chat_id") chatId: Long,
|
||||
)
|
||||
|
||||
@POST("/api/v1/chats/{chat_id}/archive")
|
||||
suspend fun archiveChat(
|
||||
@Path("chat_id") chatId: Long,
|
||||
): ChatReadDto
|
||||
|
||||
@POST("/api/v1/chats/{chat_id}/unarchive")
|
||||
suspend fun unarchiveChat(
|
||||
@Path("chat_id") chatId: Long,
|
||||
): ChatReadDto
|
||||
|
||||
@POST("/api/v1/chats/{chat_id}/pin-chat")
|
||||
suspend fun pinChat(
|
||||
@Path("chat_id") chatId: Long,
|
||||
): ChatReadDto
|
||||
|
||||
@POST("/api/v1/chats/{chat_id}/unpin-chat")
|
||||
suspend fun unpinChat(
|
||||
@Path("chat_id") chatId: Long,
|
||||
): ChatReadDto
|
||||
|
||||
@PATCH("/api/v1/chats/{chat_id}/title")
|
||||
suspend fun updateChatTitle(
|
||||
@Path("chat_id") chatId: Long,
|
||||
@Body request: ChatTitleUpdateRequestDto,
|
||||
): ChatReadDto
|
||||
|
||||
@PATCH("/api/v1/chats/{chat_id}/profile")
|
||||
suspend fun updateChatProfile(
|
||||
@Path("chat_id") chatId: Long,
|
||||
@Body request: ChatProfileUpdateRequestDto,
|
||||
): ChatReadDto
|
||||
|
||||
@GET("/api/v1/chats/{chat_id}/notifications")
|
||||
suspend fun getChatNotifications(
|
||||
@Path("chat_id") chatId: Long,
|
||||
): ChatNotificationSettingsDto
|
||||
|
||||
@PUT("/api/v1/chats/{chat_id}/notifications")
|
||||
suspend fun updateChatNotifications(
|
||||
@Path("chat_id") chatId: Long,
|
||||
@Body request: ChatNotificationSettingsUpdateDto,
|
||||
): ChatNotificationSettingsDto
|
||||
|
||||
@GET("/api/v1/chats/{chat_id}/members")
|
||||
suspend fun listMembers(
|
||||
@Path("chat_id") chatId: Long,
|
||||
): List<ChatMemberDto>
|
||||
|
||||
@GET("/api/v1/chats/{chat_id}/bans")
|
||||
suspend fun listBans(
|
||||
@Path("chat_id") chatId: Long,
|
||||
): List<ChatBanDto>
|
||||
|
||||
@POST("/api/v1/chats/{chat_id}/members")
|
||||
suspend fun addMember(
|
||||
@Path("chat_id") chatId: Long,
|
||||
@Body request: ChatMemberAddRequestDto,
|
||||
): ChatMemberDto
|
||||
|
||||
@PATCH("/api/v1/chats/{chat_id}/members/{user_id}/role")
|
||||
suspend fun updateMemberRole(
|
||||
@Path("chat_id") chatId: Long,
|
||||
@Path("user_id") userId: Long,
|
||||
@Body request: ChatMemberRoleUpdateRequestDto,
|
||||
): ChatMemberDto
|
||||
|
||||
@DELETE("/api/v1/chats/{chat_id}/members/{user_id}")
|
||||
suspend fun removeMember(
|
||||
@Path("chat_id") chatId: Long,
|
||||
@Path("user_id") userId: Long,
|
||||
)
|
||||
|
||||
@POST("/api/v1/chats/{chat_id}/bans/{user_id}")
|
||||
suspend fun banMember(
|
||||
@Path("chat_id") chatId: Long,
|
||||
@Path("user_id") userId: Long,
|
||||
)
|
||||
|
||||
@DELETE("/api/v1/chats/{chat_id}/bans/{user_id}")
|
||||
suspend fun unbanMember(
|
||||
@Path("chat_id") chatId: Long,
|
||||
@Path("user_id") userId: Long,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
package ru.daemonlord.messenger.data.chat.dto
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class ChatReadDto(
|
||||
val id: Long,
|
||||
@SerialName("public_id")
|
||||
val publicId: String,
|
||||
val type: String,
|
||||
val title: String? = null,
|
||||
@SerialName("display_title")
|
||||
val displayTitle: String,
|
||||
val handle: String? = null,
|
||||
@SerialName("avatar_url")
|
||||
val avatarUrl: String? = null,
|
||||
val archived: Boolean = false,
|
||||
val pinned: Boolean = false,
|
||||
val muted: Boolean = false,
|
||||
@SerialName("unread_count")
|
||||
val unreadCount: Int = 0,
|
||||
@SerialName("unread_mentions_count")
|
||||
val unreadMentionsCount: Int = 0,
|
||||
@SerialName("counterpart_user_id")
|
||||
val counterpartUserId: Long? = null,
|
||||
@SerialName("counterpart_name")
|
||||
val counterpartName: String? = null,
|
||||
@SerialName("counterpart_username")
|
||||
val counterpartUsername: String? = null,
|
||||
@SerialName("counterpart_avatar_url")
|
||||
val counterpartAvatarUrl: String? = null,
|
||||
@SerialName("counterpart_is_online")
|
||||
val counterpartIsOnline: Boolean? = null,
|
||||
@SerialName("counterpart_last_seen_at")
|
||||
val counterpartLastSeenAt: String? = null,
|
||||
@SerialName("last_message_text")
|
||||
val lastMessageText: String? = null,
|
||||
@SerialName("last_message_type")
|
||||
val lastMessageType: String? = null,
|
||||
@SerialName("last_message_created_at")
|
||||
val lastMessageCreatedAt: String? = null,
|
||||
@SerialName("pinned_message_id")
|
||||
val pinnedMessageId: Long? = null,
|
||||
@SerialName("my_role")
|
||||
val myRole: String? = null,
|
||||
@SerialName("created_at")
|
||||
val createdAt: String? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ChatInviteLinkDto(
|
||||
@SerialName("chat_id")
|
||||
val chatId: Long,
|
||||
val token: String,
|
||||
@SerialName("invite_url")
|
||||
val inviteUrl: String,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ChatJoinByInviteRequestDto(
|
||||
val token: String,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ChatCreateRequestDto(
|
||||
val type: String,
|
||||
val title: String? = null,
|
||||
@SerialName("is_public")
|
||||
val isPublic: Boolean = false,
|
||||
val handle: String? = null,
|
||||
val description: String? = null,
|
||||
@SerialName("member_ids")
|
||||
val memberIds: List<Long> = emptyList(),
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class DiscoverChatDto(
|
||||
val id: Long,
|
||||
val type: String,
|
||||
@SerialName("display_title")
|
||||
val displayTitle: String,
|
||||
val handle: String? = null,
|
||||
@SerialName("avatar_url")
|
||||
val avatarUrl: String? = null,
|
||||
@SerialName("is_member")
|
||||
val isMember: Boolean = false,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ChatMemberDto(
|
||||
@SerialName("user_id")
|
||||
val userId: Long,
|
||||
val role: String,
|
||||
val name: String? = null,
|
||||
val username: String? = null,
|
||||
@SerialName("avatar_url")
|
||||
val avatarUrl: String? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ChatBanDto(
|
||||
@SerialName("user_id")
|
||||
val userId: Long,
|
||||
@SerialName("banned_at")
|
||||
val bannedAt: String? = null,
|
||||
val name: String? = null,
|
||||
val username: String? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ChatMemberRoleUpdateRequestDto(
|
||||
val role: String,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ChatMemberAddRequestDto(
|
||||
@SerialName("user_id")
|
||||
val userId: Long,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ChatNotificationSettingsDto(
|
||||
@SerialName("chat_id")
|
||||
val chatId: Long,
|
||||
@SerialName("user_id")
|
||||
val userId: Long,
|
||||
val muted: Boolean,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ChatNotificationSettingsUpdateDto(
|
||||
val muted: Boolean,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ChatTitleUpdateRequestDto(
|
||||
val title: String,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ChatProfileUpdateRequestDto(
|
||||
val title: String? = null,
|
||||
val description: String? = null,
|
||||
@SerialName("avatar_url")
|
||||
val avatarUrl: String? = null,
|
||||
)
|
||||
@@ -0,0 +1,165 @@
|
||||
package ru.daemonlord.messenger.data.chat.local.dao
|
||||
|
||||
import androidx.room.Dao
|
||||
import androidx.room.Insert
|
||||
import androidx.room.OnConflictStrategy
|
||||
import androidx.room.Query
|
||||
import androidx.room.Transaction
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import ru.daemonlord.messenger.data.chat.local.entity.ChatEntity
|
||||
import ru.daemonlord.messenger.data.chat.local.entity.UserShortEntity
|
||||
import ru.daemonlord.messenger.data.chat.local.model.ChatListLocalModel
|
||||
|
||||
@Dao
|
||||
interface ChatDao {
|
||||
|
||||
@Query(
|
||||
"""
|
||||
SELECT
|
||||
c.id,
|
||||
c.public_id,
|
||||
c.type,
|
||||
c.title,
|
||||
COALESCE(c.display_title, u.display_name) AS display_title,
|
||||
c.handle,
|
||||
COALESCE(c.avatar_url, u.avatar_url) AS avatar_url,
|
||||
c.archived,
|
||||
c.pinned,
|
||||
c.muted,
|
||||
c.unread_count,
|
||||
c.unread_mentions_count,
|
||||
COALESCE(c.counterpart_name, u.display_name) AS counterpart_name,
|
||||
COALESCE(c.counterpart_username, u.username) AS counterpart_username,
|
||||
COALESCE(c.counterpart_avatar_url, u.avatar_url) AS counterpart_avatar_url,
|
||||
c.counterpart_is_online,
|
||||
c.counterpart_last_seen_at,
|
||||
c.last_message_text,
|
||||
c.last_message_type,
|
||||
c.last_message_created_at,
|
||||
c.pinned_message_id,
|
||||
c.my_role,
|
||||
c.updated_sort_at
|
||||
FROM chats c
|
||||
LEFT JOIN users_short u ON c.counterpart_user_id = u.id
|
||||
WHERE c.archived = :archived
|
||||
ORDER BY c.pinned DESC, c.updated_sort_at DESC, c.id DESC
|
||||
"""
|
||||
)
|
||||
fun observeChats(archived: Boolean): Flow<List<ChatListLocalModel>>
|
||||
|
||||
@Query(
|
||||
"""
|
||||
SELECT
|
||||
c.id,
|
||||
c.public_id,
|
||||
c.type,
|
||||
c.title,
|
||||
COALESCE(c.display_title, u.display_name) AS display_title,
|
||||
c.handle,
|
||||
COALESCE(c.avatar_url, u.avatar_url) AS avatar_url,
|
||||
c.archived,
|
||||
c.pinned,
|
||||
c.muted,
|
||||
c.unread_count,
|
||||
c.unread_mentions_count,
|
||||
COALESCE(c.counterpart_name, u.display_name) AS counterpart_name,
|
||||
COALESCE(c.counterpart_username, u.username) AS counterpart_username,
|
||||
COALESCE(c.counterpart_avatar_url, u.avatar_url) AS counterpart_avatar_url,
|
||||
c.counterpart_is_online,
|
||||
c.counterpart_last_seen_at,
|
||||
c.last_message_text,
|
||||
c.last_message_type,
|
||||
c.last_message_created_at,
|
||||
c.pinned_message_id,
|
||||
c.my_role,
|
||||
c.updated_sort_at
|
||||
FROM chats c
|
||||
LEFT JOIN users_short u ON c.counterpart_user_id = u.id
|
||||
WHERE c.id = :chatId
|
||||
LIMIT 1
|
||||
"""
|
||||
)
|
||||
fun observeChatById(chatId: Long): Flow<ChatListLocalModel?>
|
||||
|
||||
@Query("SELECT display_title FROM chats WHERE id = :chatId LIMIT 1")
|
||||
suspend fun getChatDisplayTitle(chatId: Long): String?
|
||||
|
||||
@Query("SELECT muted FROM chats WHERE id = :chatId LIMIT 1")
|
||||
suspend fun isChatMuted(chatId: Long): Boolean?
|
||||
|
||||
@Query("UPDATE chats SET muted = :muted WHERE id = :chatId")
|
||||
suspend fun updateChatMuted(chatId: Long, muted: Boolean)
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
suspend fun upsertChats(chats: List<ChatEntity>)
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
suspend fun upsertUsers(users: List<UserShortEntity>)
|
||||
|
||||
@Query("DELETE FROM chats WHERE archived = :archived")
|
||||
suspend fun deleteChatsByArchived(archived: Boolean)
|
||||
|
||||
@Query("DELETE FROM chats WHERE id = :chatId")
|
||||
suspend fun deleteChat(chatId: Long)
|
||||
|
||||
@Query(
|
||||
"""
|
||||
UPDATE chats
|
||||
SET counterpart_is_online = :isOnline,
|
||||
counterpart_last_seen_at = :lastSeenAt
|
||||
WHERE id = :chatId
|
||||
"""
|
||||
)
|
||||
suspend fun updatePresence(chatId: Long, isOnline: Boolean, lastSeenAt: String?)
|
||||
|
||||
@Query(
|
||||
"""
|
||||
UPDATE chats
|
||||
SET last_message_text = :lastMessageText,
|
||||
last_message_type = :lastMessageType,
|
||||
last_message_created_at = :lastMessageCreatedAt,
|
||||
updated_sort_at = :updatedSortAt
|
||||
WHERE id = :chatId
|
||||
"""
|
||||
)
|
||||
suspend fun updateLastMessage(
|
||||
chatId: Long,
|
||||
lastMessageText: String?,
|
||||
lastMessageType: String?,
|
||||
lastMessageCreatedAt: String?,
|
||||
updatedSortAt: String?,
|
||||
)
|
||||
|
||||
@Query(
|
||||
"""
|
||||
UPDATE chats
|
||||
SET unread_count = CASE
|
||||
WHEN :incrementBy > 0 THEN unread_count + :incrementBy
|
||||
ELSE unread_count
|
||||
END
|
||||
WHERE id = :chatId
|
||||
"""
|
||||
)
|
||||
suspend fun incrementUnread(chatId: Long, incrementBy: Int = 1)
|
||||
|
||||
@Query(
|
||||
"""
|
||||
UPDATE chats
|
||||
SET unread_count = 0,
|
||||
unread_mentions_count = 0
|
||||
WHERE id = :chatId
|
||||
"""
|
||||
)
|
||||
suspend fun markChatRead(chatId: Long)
|
||||
|
||||
@Transaction
|
||||
suspend fun clearAndReplaceChats(
|
||||
archived: Boolean,
|
||||
chats: List<ChatEntity>,
|
||||
users: List<UserShortEntity>,
|
||||
) {
|
||||
upsertUsers(users)
|
||||
deleteChatsByArchived(archived = archived)
|
||||
upsertChats(chats)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package ru.daemonlord.messenger.data.chat.local.db
|
||||
|
||||
import androidx.room.Database
|
||||
import androidx.room.RoomDatabase
|
||||
import ru.daemonlord.messenger.data.chat.local.dao.ChatDao
|
||||
import ru.daemonlord.messenger.data.chat.local.entity.ChatEntity
|
||||
import ru.daemonlord.messenger.data.chat.local.entity.UserShortEntity
|
||||
import ru.daemonlord.messenger.data.message.local.dao.MessageDao
|
||||
import ru.daemonlord.messenger.data.message.local.dao.PendingMessageActionDao
|
||||
import ru.daemonlord.messenger.data.message.local.entity.MessageAttachmentEntity
|
||||
import ru.daemonlord.messenger.data.message.local.entity.MessageEntity
|
||||
import ru.daemonlord.messenger.data.message.local.entity.PendingMessageActionEntity
|
||||
|
||||
@Database(
|
||||
entities = [
|
||||
ChatEntity::class,
|
||||
UserShortEntity::class,
|
||||
MessageEntity::class,
|
||||
MessageAttachmentEntity::class,
|
||||
PendingMessageActionEntity::class,
|
||||
],
|
||||
version = 9,
|
||||
exportSchema = false,
|
||||
)
|
||||
abstract class MessengerDatabase : RoomDatabase() {
|
||||
abstract fun chatDao(): ChatDao
|
||||
abstract fun messageDao(): MessageDao
|
||||
abstract fun pendingMessageActionDao(): PendingMessageActionDao
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
package ru.daemonlord.messenger.data.chat.local.entity
|
||||
|
||||
import androidx.room.ColumnInfo
|
||||
import androidx.room.Entity
|
||||
import androidx.room.Index
|
||||
import androidx.room.PrimaryKey
|
||||
|
||||
@Entity(
|
||||
tableName = "chats",
|
||||
indices = [
|
||||
Index(value = ["archived", "pinned", "updated_sort_at"]),
|
||||
Index(value = ["archived", "last_message_created_at"]),
|
||||
],
|
||||
)
|
||||
data class ChatEntity(
|
||||
@PrimaryKey
|
||||
@ColumnInfo(name = "id")
|
||||
val id: Long,
|
||||
@ColumnInfo(name = "public_id")
|
||||
val publicId: String,
|
||||
@ColumnInfo(name = "type")
|
||||
val type: String,
|
||||
@ColumnInfo(name = "title")
|
||||
val title: String?,
|
||||
@ColumnInfo(name = "display_title")
|
||||
val displayTitle: String,
|
||||
@ColumnInfo(name = "handle")
|
||||
val handle: String?,
|
||||
@ColumnInfo(name = "avatar_url")
|
||||
val avatarUrl: String?,
|
||||
@ColumnInfo(name = "archived")
|
||||
val archived: Boolean,
|
||||
@ColumnInfo(name = "pinned")
|
||||
val pinned: Boolean,
|
||||
@ColumnInfo(name = "muted")
|
||||
val muted: Boolean,
|
||||
@ColumnInfo(name = "unread_count")
|
||||
val unreadCount: Int,
|
||||
@ColumnInfo(name = "unread_mentions_count")
|
||||
val unreadMentionsCount: Int,
|
||||
@ColumnInfo(name = "counterpart_user_id")
|
||||
val counterpartUserId: Long?,
|
||||
@ColumnInfo(name = "counterpart_name")
|
||||
val counterpartName: String?,
|
||||
@ColumnInfo(name = "counterpart_username")
|
||||
val counterpartUsername: String?,
|
||||
@ColumnInfo(name = "counterpart_avatar_url")
|
||||
val counterpartAvatarUrl: String?,
|
||||
@ColumnInfo(name = "counterpart_is_online")
|
||||
val counterpartIsOnline: Boolean?,
|
||||
@ColumnInfo(name = "counterpart_last_seen_at")
|
||||
val counterpartLastSeenAt: String?,
|
||||
@ColumnInfo(name = "last_message_text")
|
||||
val lastMessageText: String?,
|
||||
@ColumnInfo(name = "last_message_type")
|
||||
val lastMessageType: String?,
|
||||
@ColumnInfo(name = "last_message_created_at")
|
||||
val lastMessageCreatedAt: String?,
|
||||
@ColumnInfo(name = "pinned_message_id")
|
||||
val pinnedMessageId: Long?,
|
||||
@ColumnInfo(name = "my_role")
|
||||
val myRole: String?,
|
||||
@ColumnInfo(name = "updated_sort_at")
|
||||
val updatedSortAt: String?,
|
||||
)
|
||||
@@ -0,0 +1,20 @@
|
||||
package ru.daemonlord.messenger.data.chat.local.entity
|
||||
|
||||
import androidx.room.ColumnInfo
|
||||
import androidx.room.Entity
|
||||
import androidx.room.PrimaryKey
|
||||
|
||||
@Entity(
|
||||
tableName = "users_short",
|
||||
)
|
||||
data class UserShortEntity(
|
||||
@PrimaryKey
|
||||
@ColumnInfo(name = "id")
|
||||
val id: Long,
|
||||
@ColumnInfo(name = "display_name")
|
||||
val displayName: String,
|
||||
@ColumnInfo(name = "username")
|
||||
val username: String?,
|
||||
@ColumnInfo(name = "avatar_url")
|
||||
val avatarUrl: String?,
|
||||
)
|
||||
@@ -0,0 +1,52 @@
|
||||
package ru.daemonlord.messenger.data.chat.local.model
|
||||
|
||||
import androidx.room.ColumnInfo
|
||||
|
||||
data class ChatListLocalModel(
|
||||
@ColumnInfo(name = "id")
|
||||
val id: Long,
|
||||
@ColumnInfo(name = "public_id")
|
||||
val publicId: String,
|
||||
@ColumnInfo(name = "type")
|
||||
val type: String,
|
||||
@ColumnInfo(name = "title")
|
||||
val title: String?,
|
||||
@ColumnInfo(name = "display_title")
|
||||
val displayTitle: String,
|
||||
@ColumnInfo(name = "handle")
|
||||
val handle: String?,
|
||||
@ColumnInfo(name = "avatar_url")
|
||||
val avatarUrl: String?,
|
||||
@ColumnInfo(name = "archived")
|
||||
val archived: Boolean,
|
||||
@ColumnInfo(name = "pinned")
|
||||
val pinned: Boolean,
|
||||
@ColumnInfo(name = "muted")
|
||||
val muted: Boolean,
|
||||
@ColumnInfo(name = "unread_count")
|
||||
val unreadCount: Int,
|
||||
@ColumnInfo(name = "unread_mentions_count")
|
||||
val unreadMentionsCount: Int,
|
||||
@ColumnInfo(name = "counterpart_name")
|
||||
val counterpartName: String?,
|
||||
@ColumnInfo(name = "counterpart_username")
|
||||
val counterpartUsername: String?,
|
||||
@ColumnInfo(name = "counterpart_avatar_url")
|
||||
val counterpartAvatarUrl: String?,
|
||||
@ColumnInfo(name = "counterpart_is_online")
|
||||
val counterpartIsOnline: Boolean?,
|
||||
@ColumnInfo(name = "counterpart_last_seen_at")
|
||||
val counterpartLastSeenAt: String?,
|
||||
@ColumnInfo(name = "last_message_text")
|
||||
val lastMessageText: String?,
|
||||
@ColumnInfo(name = "last_message_type")
|
||||
val lastMessageType: String?,
|
||||
@ColumnInfo(name = "last_message_created_at")
|
||||
val lastMessageCreatedAt: String?,
|
||||
@ColumnInfo(name = "pinned_message_id")
|
||||
val pinnedMessageId: Long?,
|
||||
@ColumnInfo(name = "my_role")
|
||||
val myRole: String?,
|
||||
@ColumnInfo(name = "updated_sort_at")
|
||||
val updatedSortAt: String?,
|
||||
)
|
||||
@@ -0,0 +1,61 @@
|
||||
package ru.daemonlord.messenger.data.chat.mapper
|
||||
|
||||
import ru.daemonlord.messenger.data.chat.local.model.ChatListLocalModel
|
||||
import ru.daemonlord.messenger.data.chat.local.entity.ChatEntity
|
||||
import ru.daemonlord.messenger.domain.chat.model.ChatItem
|
||||
|
||||
fun ChatListLocalModel.toDomain(): ChatItem {
|
||||
return ChatItem(
|
||||
id = id,
|
||||
publicId = publicId,
|
||||
type = type,
|
||||
title = title,
|
||||
displayTitle = displayTitle,
|
||||
handle = handle,
|
||||
avatarUrl = avatarUrl,
|
||||
archived = archived,
|
||||
pinned = pinned,
|
||||
muted = muted,
|
||||
unreadCount = unreadCount,
|
||||
unreadMentionsCount = unreadMentionsCount,
|
||||
counterpartUsername = counterpartUsername,
|
||||
counterpartName = counterpartName,
|
||||
counterpartAvatarUrl = counterpartAvatarUrl,
|
||||
counterpartIsOnline = counterpartIsOnline,
|
||||
counterpartLastSeenAt = counterpartLastSeenAt,
|
||||
lastMessageText = lastMessageText,
|
||||
lastMessageType = lastMessageType,
|
||||
lastMessageCreatedAt = lastMessageCreatedAt,
|
||||
pinnedMessageId = pinnedMessageId,
|
||||
myRole = myRole,
|
||||
updatedSortAt = updatedSortAt,
|
||||
)
|
||||
}
|
||||
|
||||
fun ChatEntity.toDomain(): ChatItem {
|
||||
return ChatItem(
|
||||
id = id,
|
||||
publicId = publicId,
|
||||
type = type,
|
||||
title = title,
|
||||
displayTitle = displayTitle,
|
||||
handle = handle,
|
||||
avatarUrl = avatarUrl,
|
||||
archived = archived,
|
||||
pinned = pinned,
|
||||
muted = muted,
|
||||
unreadCount = unreadCount,
|
||||
unreadMentionsCount = unreadMentionsCount,
|
||||
counterpartUsername = counterpartUsername,
|
||||
counterpartName = counterpartName,
|
||||
counterpartAvatarUrl = counterpartAvatarUrl,
|
||||
counterpartIsOnline = counterpartIsOnline,
|
||||
counterpartLastSeenAt = counterpartLastSeenAt,
|
||||
lastMessageText = lastMessageText,
|
||||
lastMessageType = lastMessageType,
|
||||
lastMessageCreatedAt = lastMessageCreatedAt,
|
||||
pinnedMessageId = pinnedMessageId,
|
||||
myRole = myRole,
|
||||
updatedSortAt = updatedSortAt,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package ru.daemonlord.messenger.data.chat.mapper
|
||||
|
||||
import ru.daemonlord.messenger.data.chat.dto.ChatReadDto
|
||||
import ru.daemonlord.messenger.data.chat.dto.ChatInviteLinkDto
|
||||
import ru.daemonlord.messenger.data.chat.local.entity.ChatEntity
|
||||
import ru.daemonlord.messenger.data.chat.local.entity.UserShortEntity
|
||||
import ru.daemonlord.messenger.domain.chat.model.ChatInviteLink
|
||||
|
||||
fun ChatReadDto.toChatEntity(): ChatEntity {
|
||||
return ChatEntity(
|
||||
id = id,
|
||||
publicId = publicId,
|
||||
type = type,
|
||||
title = title,
|
||||
displayTitle = displayTitle,
|
||||
handle = handle,
|
||||
avatarUrl = avatarUrl,
|
||||
archived = archived,
|
||||
pinned = pinned,
|
||||
muted = muted,
|
||||
unreadCount = unreadCount,
|
||||
unreadMentionsCount = unreadMentionsCount,
|
||||
counterpartUserId = counterpartUserId,
|
||||
counterpartName = counterpartName,
|
||||
counterpartUsername = counterpartUsername,
|
||||
counterpartAvatarUrl = counterpartAvatarUrl,
|
||||
counterpartIsOnline = counterpartIsOnline,
|
||||
counterpartLastSeenAt = counterpartLastSeenAt,
|
||||
lastMessageText = lastMessageText,
|
||||
lastMessageType = lastMessageType,
|
||||
lastMessageCreatedAt = lastMessageCreatedAt,
|
||||
pinnedMessageId = pinnedMessageId,
|
||||
myRole = myRole,
|
||||
updatedSortAt = lastMessageCreatedAt ?: createdAt,
|
||||
)
|
||||
}
|
||||
|
||||
fun ChatReadDto.toUserShortEntityOrNull(): UserShortEntity? {
|
||||
val userId = counterpartUserId ?: return null
|
||||
val displayName = counterpartName ?: counterpartUsername ?: return null
|
||||
return UserShortEntity(
|
||||
id = userId,
|
||||
displayName = displayName,
|
||||
username = counterpartUsername,
|
||||
avatarUrl = counterpartAvatarUrl,
|
||||
)
|
||||
}
|
||||
|
||||
fun ChatInviteLinkDto.toDomain(): ChatInviteLink {
|
||||
return ChatInviteLink(
|
||||
chatId = chatId,
|
||||
token = token,
|
||||
inviteUrl = inviteUrl,
|
||||
)
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,424 @@
|
||||
package ru.daemonlord.messenger.data.chat.repository
|
||||
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.collect
|
||||
import kotlinx.coroutines.flow.channelFlow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.channels.awaitClose
|
||||
import ru.daemonlord.messenger.data.chat.api.ChatApiService
|
||||
import ru.daemonlord.messenger.data.chat.dto.ChatBanDto
|
||||
import ru.daemonlord.messenger.data.chat.dto.ChatCreateRequestDto
|
||||
import ru.daemonlord.messenger.data.chat.dto.ChatJoinByInviteRequestDto
|
||||
import ru.daemonlord.messenger.data.chat.dto.ChatMemberAddRequestDto
|
||||
import ru.daemonlord.messenger.data.chat.dto.ChatMemberDto
|
||||
import ru.daemonlord.messenger.data.chat.dto.ChatMemberRoleUpdateRequestDto
|
||||
import ru.daemonlord.messenger.data.chat.dto.ChatNotificationSettingsDto
|
||||
import ru.daemonlord.messenger.data.chat.dto.ChatNotificationSettingsUpdateDto
|
||||
import ru.daemonlord.messenger.data.chat.dto.ChatProfileUpdateRequestDto
|
||||
import ru.daemonlord.messenger.data.chat.dto.ChatTitleUpdateRequestDto
|
||||
import ru.daemonlord.messenger.data.chat.dto.DiscoverChatDto
|
||||
import ru.daemonlord.messenger.data.chat.local.dao.ChatDao
|
||||
import ru.daemonlord.messenger.data.chat.mapper.toChatEntity
|
||||
import ru.daemonlord.messenger.data.chat.mapper.toDomain
|
||||
import ru.daemonlord.messenger.data.chat.mapper.toUserShortEntityOrNull
|
||||
import ru.daemonlord.messenger.data.common.toAppError
|
||||
import ru.daemonlord.messenger.domain.chat.model.ChatInviteLink
|
||||
import ru.daemonlord.messenger.di.IoDispatcher
|
||||
import ru.daemonlord.messenger.domain.chat.model.ChatBanItem
|
||||
import ru.daemonlord.messenger.domain.chat.model.ChatItem
|
||||
import ru.daemonlord.messenger.domain.chat.model.ChatMemberItem
|
||||
import ru.daemonlord.messenger.domain.chat.model.ChatNotificationSettings
|
||||
import ru.daemonlord.messenger.domain.chat.model.DiscoverChatItem
|
||||
import ru.daemonlord.messenger.domain.chat.repository.ChatRepository
|
||||
import ru.daemonlord.messenger.domain.common.AppResult
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
class NetworkChatRepository @Inject constructor(
|
||||
private val chatApiService: ChatApiService,
|
||||
private val chatDao: ChatDao,
|
||||
@IoDispatcher private val ioDispatcher: CoroutineDispatcher,
|
||||
) : ChatRepository {
|
||||
|
||||
override fun observeChats(archived: Boolean): Flow<List<ChatItem>> {
|
||||
return channelFlow {
|
||||
val dbCollection = launch {
|
||||
chatDao.observeChats(archived = archived).collect { rows ->
|
||||
send(rows.map { it.toDomain() })
|
||||
}
|
||||
}
|
||||
launch(ioDispatcher) {
|
||||
refreshChats(archived = archived)
|
||||
}
|
||||
awaitClose { dbCollection.cancel() }
|
||||
}
|
||||
}
|
||||
|
||||
override fun observeChat(chatId: Long): Flow<ChatItem?> {
|
||||
return chatDao.observeChatById(chatId = chatId).map { it?.toDomain() }
|
||||
}
|
||||
|
||||
override suspend fun refreshChats(archived: Boolean): AppResult<Unit> = withContext(ioDispatcher) {
|
||||
try {
|
||||
val chats = chatApiService.getChats(archived = archived)
|
||||
val chatEntities = chats.map { it.toChatEntity() }
|
||||
val userEntities = chats.mapNotNull { it.toUserShortEntityOrNull() }
|
||||
chatDao.clearAndReplaceChats(
|
||||
archived = archived,
|
||||
chats = chatEntities,
|
||||
users = userEntities,
|
||||
)
|
||||
AppResult.Success(Unit)
|
||||
} catch (error: Throwable) {
|
||||
AppResult.Error(error.toAppError())
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun refreshChat(chatId: Long): AppResult<Unit> = withContext(ioDispatcher) {
|
||||
try {
|
||||
val chat = chatApiService.getChatById(chatId = chatId)
|
||||
chatDao.upsertUsers(chat.toUserShortEntityOrNull()?.let(::listOf).orEmpty())
|
||||
chatDao.upsertChats(listOf(chat.toChatEntity()))
|
||||
AppResult.Success(Unit)
|
||||
} catch (error: Throwable) {
|
||||
AppResult.Error(error.toAppError())
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun getSavedChat(): AppResult<ChatItem> = withContext(ioDispatcher) {
|
||||
try {
|
||||
val chat = chatApiService.getSavedChat()
|
||||
chatDao.upsertUsers(chat.toUserShortEntityOrNull()?.let(::listOf).orEmpty())
|
||||
val entity = chat.toChatEntity()
|
||||
chatDao.upsertChats(listOf(entity))
|
||||
AppResult.Success(entity.toDomain())
|
||||
} catch (error: Throwable) {
|
||||
AppResult.Error(error.toAppError())
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun createInviteLink(chatId: Long): AppResult<ChatInviteLink> = withContext(ioDispatcher) {
|
||||
try {
|
||||
AppResult.Success(chatApiService.createInviteLink(chatId = chatId).toDomain())
|
||||
} catch (error: Throwable) {
|
||||
AppResult.Error(error.toAppError())
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun joinByInvite(token: String): AppResult<ChatItem> = withContext(ioDispatcher) {
|
||||
try {
|
||||
val joined = chatApiService.joinByInvite(request = ChatJoinByInviteRequestDto(token = token))
|
||||
chatDao.upsertUsers(joined.toUserShortEntityOrNull()?.let(::listOf).orEmpty())
|
||||
val chatEntity = joined.toChatEntity()
|
||||
chatDao.upsertChats(listOf(chatEntity))
|
||||
AppResult.Success(chatEntity.toDomain())
|
||||
} catch (error: Throwable) {
|
||||
AppResult.Error(error.toAppError())
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun createChat(
|
||||
type: String,
|
||||
title: String?,
|
||||
isPublic: Boolean,
|
||||
handle: String?,
|
||||
description: String?,
|
||||
memberIds: List<Long>,
|
||||
): AppResult<ChatItem> = withContext(ioDispatcher) {
|
||||
try {
|
||||
val created = chatApiService.createChat(
|
||||
request = ChatCreateRequestDto(
|
||||
type = type,
|
||||
title = title,
|
||||
isPublic = isPublic,
|
||||
handle = handle,
|
||||
description = description,
|
||||
memberIds = memberIds,
|
||||
)
|
||||
)
|
||||
chatDao.upsertUsers(created.toUserShortEntityOrNull()?.let(::listOf).orEmpty())
|
||||
val chatEntity = created.toChatEntity()
|
||||
chatDao.upsertChats(listOf(chatEntity))
|
||||
AppResult.Success(chatEntity.toDomain())
|
||||
} catch (error: Throwable) {
|
||||
AppResult.Error(error.toAppError())
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun discoverChats(query: String?): AppResult<List<DiscoverChatItem>> = withContext(ioDispatcher) {
|
||||
try {
|
||||
AppResult.Success(chatApiService.discoverChats(query = query).map { it.toDomain() })
|
||||
} catch (error: Throwable) {
|
||||
AppResult.Error(error.toAppError())
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun joinChat(chatId: Long): AppResult<ChatItem> = withContext(ioDispatcher) {
|
||||
try {
|
||||
val joined = chatApiService.joinChat(chatId = chatId)
|
||||
chatDao.upsertUsers(joined.toUserShortEntityOrNull()?.let(::listOf).orEmpty())
|
||||
val entity = joined.toChatEntity()
|
||||
chatDao.upsertChats(listOf(entity))
|
||||
AppResult.Success(entity.toDomain())
|
||||
} catch (error: Throwable) {
|
||||
AppResult.Error(error.toAppError())
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun leaveChat(chatId: Long): AppResult<Unit> = withContext(ioDispatcher) {
|
||||
try {
|
||||
chatApiService.leaveChat(chatId = chatId)
|
||||
chatDao.deleteChat(chatId = chatId)
|
||||
AppResult.Success(Unit)
|
||||
} catch (error: Throwable) {
|
||||
AppResult.Error(error.toAppError())
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun archiveChat(chatId: Long): AppResult<ChatItem> = withContext(ioDispatcher) {
|
||||
try {
|
||||
val updated = chatApiService.archiveChat(chatId = chatId)
|
||||
chatDao.upsertUsers(updated.toUserShortEntityOrNull()?.let(::listOf).orEmpty())
|
||||
val entity = updated.toChatEntity()
|
||||
chatDao.upsertChats(listOf(entity))
|
||||
AppResult.Success(entity.toDomain())
|
||||
} catch (error: Throwable) {
|
||||
AppResult.Error(error.toAppError())
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun unarchiveChat(chatId: Long): AppResult<ChatItem> = withContext(ioDispatcher) {
|
||||
try {
|
||||
val updated = chatApiService.unarchiveChat(chatId = chatId)
|
||||
chatDao.upsertUsers(updated.toUserShortEntityOrNull()?.let(::listOf).orEmpty())
|
||||
val entity = updated.toChatEntity()
|
||||
chatDao.upsertChats(listOf(entity))
|
||||
AppResult.Success(entity.toDomain())
|
||||
} catch (error: Throwable) {
|
||||
AppResult.Error(error.toAppError())
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun pinChat(chatId: Long): AppResult<ChatItem> = withContext(ioDispatcher) {
|
||||
try {
|
||||
val updated = chatApiService.pinChat(chatId = chatId)
|
||||
chatDao.upsertUsers(updated.toUserShortEntityOrNull()?.let(::listOf).orEmpty())
|
||||
val entity = updated.toChatEntity()
|
||||
chatDao.upsertChats(listOf(entity))
|
||||
AppResult.Success(entity.toDomain())
|
||||
} catch (error: Throwable) {
|
||||
AppResult.Error(error.toAppError())
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun unpinChat(chatId: Long): AppResult<ChatItem> = withContext(ioDispatcher) {
|
||||
try {
|
||||
val updated = chatApiService.unpinChat(chatId = chatId)
|
||||
chatDao.upsertUsers(updated.toUserShortEntityOrNull()?.let(::listOf).orEmpty())
|
||||
val entity = updated.toChatEntity()
|
||||
chatDao.upsertChats(listOf(entity))
|
||||
AppResult.Success(entity.toDomain())
|
||||
} catch (error: Throwable) {
|
||||
AppResult.Error(error.toAppError())
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun updateChatTitle(chatId: Long, title: String): AppResult<ChatItem> = withContext(ioDispatcher) {
|
||||
try {
|
||||
val updated = chatApiService.updateChatTitle(
|
||||
chatId = chatId,
|
||||
request = ChatTitleUpdateRequestDto(title = title),
|
||||
)
|
||||
chatDao.upsertUsers(updated.toUserShortEntityOrNull()?.let(::listOf).orEmpty())
|
||||
val entity = updated.toChatEntity()
|
||||
chatDao.upsertChats(listOf(entity))
|
||||
AppResult.Success(entity.toDomain())
|
||||
} catch (error: Throwable) {
|
||||
AppResult.Error(error.toAppError())
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun updateChatProfile(
|
||||
chatId: Long,
|
||||
title: String?,
|
||||
description: String?,
|
||||
avatarUrl: String?,
|
||||
): AppResult<ChatItem> = withContext(ioDispatcher) {
|
||||
try {
|
||||
val updated = chatApiService.updateChatProfile(
|
||||
chatId = chatId,
|
||||
request = ChatProfileUpdateRequestDto(
|
||||
title = title,
|
||||
description = description,
|
||||
avatarUrl = avatarUrl,
|
||||
),
|
||||
)
|
||||
chatDao.upsertUsers(updated.toUserShortEntityOrNull()?.let(::listOf).orEmpty())
|
||||
val entity = updated.toChatEntity()
|
||||
chatDao.upsertChats(listOf(entity))
|
||||
AppResult.Success(entity.toDomain())
|
||||
} catch (error: Throwable) {
|
||||
AppResult.Error(error.toAppError())
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun clearChat(chatId: Long): AppResult<Unit> = withContext(ioDispatcher) {
|
||||
try {
|
||||
chatApiService.clearChat(chatId = chatId)
|
||||
AppResult.Success(Unit)
|
||||
} catch (error: Throwable) {
|
||||
AppResult.Error(error.toAppError())
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun removeChat(chatId: Long, forAll: Boolean): AppResult<Unit> = withContext(ioDispatcher) {
|
||||
try {
|
||||
chatApiService.deleteChat(chatId = chatId, forAll = forAll)
|
||||
chatDao.deleteChat(chatId = chatId)
|
||||
AppResult.Success(Unit)
|
||||
} catch (error: Throwable) {
|
||||
AppResult.Error(error.toAppError())
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun getChatNotifications(chatId: Long): AppResult<ChatNotificationSettings> = withContext(ioDispatcher) {
|
||||
try {
|
||||
AppResult.Success(chatApiService.getChatNotifications(chatId = chatId).toDomain())
|
||||
} catch (error: Throwable) {
|
||||
AppResult.Error(error.toAppError())
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun updateChatNotifications(chatId: Long, muted: Boolean): AppResult<ChatNotificationSettings> = withContext(ioDispatcher) {
|
||||
try {
|
||||
val settings = chatApiService.updateChatNotifications(
|
||||
chatId = chatId,
|
||||
request = ChatNotificationSettingsUpdateDto(muted = muted),
|
||||
).toDomain()
|
||||
chatDao.updateChatMuted(chatId = chatId, muted = settings.muted)
|
||||
AppResult.Success(settings)
|
||||
} catch (error: Throwable) {
|
||||
AppResult.Error(error.toAppError())
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun listMembers(chatId: Long): AppResult<List<ChatMemberItem>> = withContext(ioDispatcher) {
|
||||
try {
|
||||
AppResult.Success(chatApiService.listMembers(chatId = chatId).map { it.toDomain() })
|
||||
} catch (error: Throwable) {
|
||||
AppResult.Error(error.toAppError())
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun listBans(chatId: Long): AppResult<List<ChatBanItem>> = withContext(ioDispatcher) {
|
||||
try {
|
||||
AppResult.Success(chatApiService.listBans(chatId = chatId).map { it.toDomain() })
|
||||
} catch (error: Throwable) {
|
||||
AppResult.Error(error.toAppError())
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun addMember(chatId: Long, userId: Long): AppResult<ChatMemberItem> = withContext(ioDispatcher) {
|
||||
try {
|
||||
AppResult.Success(
|
||||
chatApiService.addMember(
|
||||
chatId = chatId,
|
||||
request = ChatMemberAddRequestDto(userId = userId),
|
||||
).toDomain()
|
||||
)
|
||||
} catch (error: Throwable) {
|
||||
AppResult.Error(error.toAppError())
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun updateMemberRole(chatId: Long, userId: Long, role: String): AppResult<ChatMemberItem> = withContext(ioDispatcher) {
|
||||
try {
|
||||
AppResult.Success(
|
||||
chatApiService.updateMemberRole(
|
||||
chatId = chatId,
|
||||
userId = userId,
|
||||
request = ChatMemberRoleUpdateRequestDto(role = role),
|
||||
).toDomain()
|
||||
)
|
||||
} catch (error: Throwable) {
|
||||
AppResult.Error(error.toAppError())
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun removeMember(chatId: Long, userId: Long): AppResult<Unit> = withContext(ioDispatcher) {
|
||||
try {
|
||||
chatApiService.removeMember(chatId = chatId, userId = userId)
|
||||
AppResult.Success(Unit)
|
||||
} catch (error: Throwable) {
|
||||
AppResult.Error(error.toAppError())
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun banMember(chatId: Long, userId: Long): AppResult<Unit> = withContext(ioDispatcher) {
|
||||
try {
|
||||
chatApiService.banMember(chatId = chatId, userId = userId)
|
||||
AppResult.Success(Unit)
|
||||
} catch (error: Throwable) {
|
||||
AppResult.Error(error.toAppError())
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun unbanMember(chatId: Long, userId: Long): AppResult<Unit> = withContext(ioDispatcher) {
|
||||
try {
|
||||
chatApiService.unbanMember(chatId = chatId, userId = userId)
|
||||
AppResult.Success(Unit)
|
||||
} catch (error: Throwable) {
|
||||
AppResult.Error(error.toAppError())
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun deleteChat(chatId: Long) {
|
||||
withContext(ioDispatcher) {
|
||||
chatDao.deleteChat(chatId = chatId)
|
||||
}
|
||||
}
|
||||
|
||||
private fun DiscoverChatDto.toDomain(): DiscoverChatItem {
|
||||
return DiscoverChatItem(
|
||||
id = id,
|
||||
type = type,
|
||||
displayTitle = displayTitle,
|
||||
handle = handle,
|
||||
avatarUrl = avatarUrl,
|
||||
isMember = isMember,
|
||||
)
|
||||
}
|
||||
|
||||
private fun ChatMemberDto.toDomain(): ChatMemberItem {
|
||||
val fallbackName = username?.takeIf { it.isNotBlank() }?.let { "@$it" } ?: "User #$userId"
|
||||
return ChatMemberItem(
|
||||
userId = userId,
|
||||
role = role,
|
||||
name = name?.takeIf { it.isNotBlank() } ?: fallbackName,
|
||||
username = username,
|
||||
avatarUrl = avatarUrl,
|
||||
)
|
||||
}
|
||||
|
||||
private fun ChatBanDto.toDomain(): ChatBanItem {
|
||||
val fallbackName = username?.takeIf { it.isNotBlank() }?.let { "@$it" } ?: "User #$userId"
|
||||
return ChatBanItem(
|
||||
userId = userId,
|
||||
name = name?.takeIf { it.isNotBlank() } ?: fallbackName,
|
||||
username = username,
|
||||
bannedAt = bannedAt,
|
||||
)
|
||||
}
|
||||
|
||||
private fun ChatNotificationSettingsDto.toDomain(): ChatNotificationSettings {
|
||||
return ChatNotificationSettings(
|
||||
chatId = chatId,
|
||||
userId = userId,
|
||||
muted = muted,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
package ru.daemonlord.messenger.data.realtime
|
||||
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.contentOrNull
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import ru.daemonlord.messenger.domain.realtime.model.RealtimeEvent
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
class RealtimeEventParser @Inject constructor(
|
||||
private val json: Json,
|
||||
) {
|
||||
|
||||
fun parse(raw: String): RealtimeEvent {
|
||||
val root = runCatching { json.parseToJsonElement(raw).jsonObject }.getOrNull()
|
||||
?: return RealtimeEvent.Ignored
|
||||
val event = root["event"].stringOrNull() ?: return RealtimeEvent.Ignored
|
||||
val payload = root["payload"]?.jsonObject ?: JsonObject(emptyMap())
|
||||
|
||||
return when (event) {
|
||||
"connect" -> RealtimeEvent.Connected
|
||||
|
||||
"receive_message" -> {
|
||||
val chatId = payload["chat_id"].longOrNull() ?: return RealtimeEvent.Ignored
|
||||
val messageObject = payload["message"]?.jsonObject
|
||||
val messageId = messageObject?.get("id").longOrNull() ?: return RealtimeEvent.Ignored
|
||||
val senderId = messageObject?.get("sender_id").longOrNull() ?: 0L
|
||||
RealtimeEvent.ReceiveMessage(
|
||||
chatId = chatId,
|
||||
messageId = messageId,
|
||||
senderId = senderId,
|
||||
replyToMessageId = messageObject?.get("reply_to_message_id").longOrNull(),
|
||||
text = messageObject?.get("text").stringOrNull(),
|
||||
type = messageObject?.get("type").stringOrNull(),
|
||||
createdAt = messageObject?.get("created_at").stringOrNull(),
|
||||
isMention = messageObject?.get("is_mention").boolOrNull()
|
||||
?: payload["is_mention"].boolOrNull()
|
||||
?: messageObject?.get("mentions_me").boolOrNull()
|
||||
?: payload["mentions_me"].boolOrNull()
|
||||
?: false,
|
||||
)
|
||||
}
|
||||
|
||||
"message_updated" -> {
|
||||
val chatId = payload["chat_id"].longOrNull() ?: return RealtimeEvent.Ignored
|
||||
val messageObject = payload["message"]?.jsonObject
|
||||
val messageId = messageObject?.get("id").longOrNull() ?: return RealtimeEvent.Ignored
|
||||
RealtimeEvent.MessageUpdated(
|
||||
chatId = chatId,
|
||||
messageId = messageId,
|
||||
text = messageObject?.get("text").stringOrNull(),
|
||||
type = messageObject?.get("type").stringOrNull(),
|
||||
updatedAt = messageObject?.get("updated_at").stringOrNull(),
|
||||
)
|
||||
}
|
||||
|
||||
"message_deleted" -> {
|
||||
val chatId = payload["chat_id"].longOrNull() ?: return RealtimeEvent.Ignored
|
||||
RealtimeEvent.MessageDeleted(
|
||||
chatId = chatId,
|
||||
messageId = payload["message_id"].longOrNull(),
|
||||
)
|
||||
}
|
||||
|
||||
"chat_updated" -> {
|
||||
val chatId = payload["chat_id"].longOrNull()
|
||||
?: payload["id"].longOrNull()
|
||||
?: return RealtimeEvent.Ignored
|
||||
RealtimeEvent.ChatUpdated(chatId = chatId)
|
||||
}
|
||||
|
||||
"chat_deleted" -> {
|
||||
val chatId = payload["chat_id"].longOrNull()
|
||||
?: payload["id"].longOrNull()
|
||||
?: return RealtimeEvent.Ignored
|
||||
RealtimeEvent.ChatDeleted(chatId = chatId)
|
||||
}
|
||||
|
||||
"user_online" -> {
|
||||
val chatId = payload["chat_id"].longOrNull() ?: return RealtimeEvent.Ignored
|
||||
RealtimeEvent.UserOnline(chatId = chatId)
|
||||
}
|
||||
|
||||
"user_offline" -> {
|
||||
val chatId = payload["chat_id"].longOrNull() ?: return RealtimeEvent.Ignored
|
||||
RealtimeEvent.UserOffline(
|
||||
chatId = chatId,
|
||||
lastSeenAt = payload["last_seen_at"].stringOrNull(),
|
||||
)
|
||||
}
|
||||
|
||||
"message_delivered" -> {
|
||||
val chatId = payload["chat_id"].longOrNull() ?: return RealtimeEvent.Ignored
|
||||
val messageId = payload["message_id"].longOrNull() ?: return RealtimeEvent.Ignored
|
||||
RealtimeEvent.MessageDelivered(chatId = chatId, messageId = messageId)
|
||||
}
|
||||
|
||||
"message_read" -> {
|
||||
val chatId = payload["chat_id"].longOrNull() ?: return RealtimeEvent.Ignored
|
||||
val messageId = payload["message_id"].longOrNull() ?: return RealtimeEvent.Ignored
|
||||
RealtimeEvent.MessageRead(
|
||||
chatId = chatId,
|
||||
messageId = messageId,
|
||||
userId = payload["user_id"].longOrNull(),
|
||||
lastReadMessageId = payload["last_read_message_id"].longOrNull(),
|
||||
)
|
||||
}
|
||||
|
||||
"typing_start" -> {
|
||||
val chatId = payload["chat_id"].longOrNull() ?: return RealtimeEvent.Ignored
|
||||
RealtimeEvent.TypingStart(chatId = chatId, userId = payload["user_id"].longOrNull())
|
||||
}
|
||||
|
||||
"typing_stop" -> {
|
||||
val chatId = payload["chat_id"].longOrNull() ?: return RealtimeEvent.Ignored
|
||||
RealtimeEvent.TypingStop(chatId = chatId, userId = payload["user_id"].longOrNull())
|
||||
}
|
||||
|
||||
"pong" -> RealtimeEvent.Ignored
|
||||
|
||||
else -> RealtimeEvent.Ignored
|
||||
}
|
||||
}
|
||||
|
||||
private fun JsonElement?.stringOrNull(): String? {
|
||||
return this?.jsonPrimitive?.contentOrNull
|
||||
}
|
||||
|
||||
private fun JsonElement?.longOrNull(): Long? {
|
||||
return this?.jsonPrimitive?.contentOrNull?.toLongOrNull()
|
||||
}
|
||||
|
||||
private fun JsonElement?.boolOrNull(): Boolean? {
|
||||
val raw = this?.jsonPrimitive?.contentOrNull?.trim()?.lowercase() ?: return null
|
||||
return when (raw) {
|
||||
"true", "1" -> true
|
||||
"false", "0" -> false
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
package ru.daemonlord.messenger.data.realtime
|
||||
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asSharedFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
import okhttp3.WebSocket
|
||||
import okhttp3.WebSocketListener
|
||||
import ru.daemonlord.messenger.BuildConfig
|
||||
import ru.daemonlord.messenger.core.token.TokenRepository
|
||||
import ru.daemonlord.messenger.di.RefreshClient
|
||||
import ru.daemonlord.messenger.domain.realtime.RealtimeManager
|
||||
import ru.daemonlord.messenger.domain.realtime.model.RealtimeConnectionState
|
||||
import ru.daemonlord.messenger.domain.realtime.model.RealtimeEvent
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import java.util.concurrent.atomic.AtomicLong
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
class WsRealtimeManager @Inject constructor(
|
||||
@RefreshClient private val okHttpClient: OkHttpClient,
|
||||
private val tokenRepository: TokenRepository,
|
||||
private val parser: RealtimeEventParser,
|
||||
) : RealtimeManager {
|
||||
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
private val eventFlow = MutableSharedFlow<RealtimeEvent>(extraBufferCapacity = 64)
|
||||
private var socket: WebSocket? = null
|
||||
private val isConnected = AtomicBoolean(false)
|
||||
private val manualDisconnect = AtomicBoolean(false)
|
||||
private var reconnectDelayMs: Long = INITIAL_RECONNECT_MS
|
||||
private val lastPongAtMs = AtomicLong(0L)
|
||||
private var heartbeatJob: Job? = null
|
||||
|
||||
override val events: Flow<RealtimeEvent> = eventFlow.asSharedFlow()
|
||||
private val _connectionState = MutableStateFlow(RealtimeConnectionState.Disconnected)
|
||||
override val connectionState: StateFlow<RealtimeConnectionState> = _connectionState
|
||||
|
||||
override fun connect() {
|
||||
if (isConnected.get()) return
|
||||
manualDisconnect.set(false)
|
||||
_connectionState.value = RealtimeConnectionState.Connecting
|
||||
scope.launch { openSocket() }
|
||||
}
|
||||
|
||||
override fun disconnect() {
|
||||
manualDisconnect.set(true)
|
||||
isConnected.set(false)
|
||||
_connectionState.value = RealtimeConnectionState.Disconnected
|
||||
heartbeatJob?.cancel()
|
||||
heartbeatJob = null
|
||||
socket?.close(1000, "Client disconnect")
|
||||
socket = null
|
||||
}
|
||||
|
||||
private suspend fun openSocket() {
|
||||
val accessToken = tokenRepository.getTokens()?.accessToken ?: run {
|
||||
_connectionState.value = RealtimeConnectionState.Disconnected
|
||||
return
|
||||
}
|
||||
val wsUrl = BuildConfig.API_BASE_URL
|
||||
.replace("http://", "ws://")
|
||||
.replace("https://", "wss://")
|
||||
.trimEnd('/') + "/api/v1/realtime/ws?token=$accessToken"
|
||||
val request = Request.Builder()
|
||||
.url(wsUrl)
|
||||
.build()
|
||||
socket = okHttpClient.newWebSocket(request, listener)
|
||||
}
|
||||
|
||||
private fun scheduleReconnect() {
|
||||
if (manualDisconnect.get()) return
|
||||
_connectionState.value = RealtimeConnectionState.Reconnecting
|
||||
scope.launch {
|
||||
delay(reconnectDelayMs)
|
||||
reconnectDelayMs = (reconnectDelayMs * 2).coerceAtMost(MAX_RECONNECT_MS)
|
||||
openSocket()
|
||||
}
|
||||
}
|
||||
|
||||
private fun startHeartbeat(webSocket: WebSocket) {
|
||||
heartbeatJob?.cancel()
|
||||
lastPongAtMs.set(System.currentTimeMillis())
|
||||
heartbeatJob = scope.launch {
|
||||
while (isConnected.get() && !manualDisconnect.get()) {
|
||||
val now = System.currentTimeMillis()
|
||||
if (now - lastPongAtMs.get() > PONG_TIMEOUT_MS) {
|
||||
webSocket.close(1001, "Heartbeat timeout")
|
||||
break
|
||||
}
|
||||
webSocket.send("""{"event":"ping","payload":{}}""")
|
||||
delay(PING_INTERVAL_MS)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val listener = object : WebSocketListener() {
|
||||
override fun onOpen(webSocket: WebSocket, response: Response) {
|
||||
isConnected.set(true)
|
||||
_connectionState.value = RealtimeConnectionState.Connected
|
||||
reconnectDelayMs = INITIAL_RECONNECT_MS
|
||||
startHeartbeat(webSocket)
|
||||
}
|
||||
|
||||
override fun onMessage(webSocket: WebSocket, text: String) {
|
||||
if (text.contains("\"event\":\"pong\"")) {
|
||||
lastPongAtMs.set(System.currentTimeMillis())
|
||||
}
|
||||
eventFlow.tryEmit(parser.parse(text))
|
||||
}
|
||||
|
||||
override fun onClosing(webSocket: WebSocket, code: Int, reason: String) {
|
||||
isConnected.set(false)
|
||||
if (!manualDisconnect.get()) {
|
||||
_connectionState.value = RealtimeConnectionState.Reconnecting
|
||||
}
|
||||
heartbeatJob?.cancel()
|
||||
webSocket.close(code, reason)
|
||||
}
|
||||
|
||||
override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {
|
||||
isConnected.set(false)
|
||||
if (!manualDisconnect.get()) {
|
||||
_connectionState.value = RealtimeConnectionState.Reconnecting
|
||||
}
|
||||
heartbeatJob?.cancel()
|
||||
scheduleReconnect()
|
||||
}
|
||||
|
||||
override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {
|
||||
isConnected.set(false)
|
||||
if (!manualDisconnect.get()) {
|
||||
_connectionState.value = RealtimeConnectionState.Reconnecting
|
||||
}
|
||||
heartbeatJob?.cancel()
|
||||
scheduleReconnect()
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("unused")
|
||||
fun shutdown() {
|
||||
disconnect()
|
||||
scope.cancel()
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val INITIAL_RECONNECT_MS = 1_000L
|
||||
const val MAX_RECONNECT_MS = 30_000L
|
||||
const val PING_INTERVAL_MS = 25_000L
|
||||
const val PONG_TIMEOUT_MS = 65_000L
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package ru.daemonlord.messenger.data.search.api
|
||||
|
||||
import retrofit2.http.GET
|
||||
import retrofit2.http.Query
|
||||
import ru.daemonlord.messenger.data.search.dto.GlobalSearchResponseDto
|
||||
|
||||
interface SearchApiService {
|
||||
@GET("/api/v1/search")
|
||||
suspend fun globalSearch(
|
||||
@Query("query") query: String,
|
||||
@Query("users_limit") usersLimit: Int = 10,
|
||||
@Query("chats_limit") chatsLimit: Int = 10,
|
||||
@Query("messages_limit") messagesLimit: Int = 10,
|
||||
): GlobalSearchResponseDto
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package ru.daemonlord.messenger.data.search.dto
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
import ru.daemonlord.messenger.data.chat.dto.DiscoverChatDto
|
||||
import ru.daemonlord.messenger.data.message.dto.MessageReadDto
|
||||
import ru.daemonlord.messenger.data.user.dto.UserSearchDto
|
||||
|
||||
@Serializable
|
||||
data class GlobalSearchResponseDto(
|
||||
val users: List<UserSearchDto> = emptyList(),
|
||||
val chats: List<DiscoverChatDto> = emptyList(),
|
||||
val messages: List<MessageReadDto> = emptyList(),
|
||||
)
|
||||
@@ -0,0 +1,93 @@
|
||||
package ru.daemonlord.messenger.data.search.repository
|
||||
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.withContext
|
||||
import ru.daemonlord.messenger.data.common.toAppError
|
||||
import ru.daemonlord.messenger.data.search.api.SearchApiService
|
||||
import ru.daemonlord.messenger.di.IoDispatcher
|
||||
import ru.daemonlord.messenger.domain.account.model.UserSearchItem
|
||||
import ru.daemonlord.messenger.domain.chat.model.DiscoverChatItem
|
||||
import ru.daemonlord.messenger.domain.common.AppResult
|
||||
import ru.daemonlord.messenger.domain.message.model.MessageItem
|
||||
import ru.daemonlord.messenger.domain.search.model.GlobalSearchResult
|
||||
import ru.daemonlord.messenger.domain.search.repository.SearchRepository
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
class NetworkSearchRepository @Inject constructor(
|
||||
private val searchApiService: SearchApiService,
|
||||
@IoDispatcher private val ioDispatcher: CoroutineDispatcher,
|
||||
) : SearchRepository {
|
||||
|
||||
override suspend fun globalSearch(
|
||||
query: String,
|
||||
usersLimit: Int,
|
||||
chatsLimit: Int,
|
||||
messagesLimit: Int,
|
||||
): AppResult<GlobalSearchResult> = withContext(ioDispatcher) {
|
||||
val normalized = query.trim()
|
||||
if (normalized.isBlank()) return@withContext AppResult.Success(
|
||||
GlobalSearchResult(
|
||||
users = emptyList(),
|
||||
chats = emptyList(),
|
||||
messages = emptyList(),
|
||||
)
|
||||
)
|
||||
try {
|
||||
val response = searchApiService.globalSearch(
|
||||
query = normalized,
|
||||
usersLimit = usersLimit,
|
||||
chatsLimit = chatsLimit,
|
||||
messagesLimit = messagesLimit,
|
||||
)
|
||||
AppResult.Success(
|
||||
GlobalSearchResult(
|
||||
users = response.users.map { dto ->
|
||||
UserSearchItem(
|
||||
id = dto.id,
|
||||
name = dto.name?.trim().takeUnless { it.isNullOrBlank() }
|
||||
?: dto.username?.trim().takeUnless { it.isNullOrBlank() }
|
||||
?: "User #${dto.id}",
|
||||
username = dto.username,
|
||||
avatarUrl = dto.avatarUrl,
|
||||
)
|
||||
},
|
||||
chats = response.chats.map { dto ->
|
||||
DiscoverChatItem(
|
||||
id = dto.id,
|
||||
type = dto.type,
|
||||
displayTitle = dto.displayTitle,
|
||||
handle = dto.handle,
|
||||
avatarUrl = dto.avatarUrl,
|
||||
isMember = dto.isMember,
|
||||
)
|
||||
},
|
||||
messages = response.messages.map { dto ->
|
||||
MessageItem(
|
||||
id = dto.id,
|
||||
chatId = dto.chatId,
|
||||
senderId = dto.senderId,
|
||||
senderDisplayName = dto.senderDisplayName,
|
||||
type = dto.type,
|
||||
text = dto.text,
|
||||
createdAt = dto.createdAt,
|
||||
updatedAt = dto.updatedAt,
|
||||
isOutgoing = false,
|
||||
status = dto.deliveryStatus,
|
||||
replyToMessageId = dto.replyToMessageId,
|
||||
replyPreviewText = dto.replyPreviewText,
|
||||
replyPreviewSenderName = dto.replyPreviewSenderName,
|
||||
forwardedFromMessageId = dto.forwardedFromMessageId,
|
||||
forwardedFromDisplayName = dto.forwardedFromDisplayName,
|
||||
attachmentWaveform = dto.attachmentWaveform,
|
||||
attachments = emptyList(),
|
||||
)
|
||||
},
|
||||
)
|
||||
)
|
||||
} catch (error: Throwable) {
|
||||
AppResult.Error(error.toAppError())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package ru.daemonlord.messenger.data.settings.repository
|
||||
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import androidx.datastore.preferences.core.edit
|
||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.map
|
||||
import ru.daemonlord.messenger.domain.settings.model.AppLanguage
|
||||
import ru.daemonlord.messenger.domain.settings.repository.LanguageRepository
|
||||
|
||||
@Singleton
|
||||
class DataStoreLanguageRepository @Inject constructor(
|
||||
private val dataStore: DataStore<Preferences>,
|
||||
) : LanguageRepository {
|
||||
|
||||
override fun observeLanguage(): Flow<AppLanguage> {
|
||||
return dataStore.data.map { prefs ->
|
||||
AppLanguage.fromTag(prefs[LANGUAGE_TAG_KEY])
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun getLanguage(): AppLanguage {
|
||||
return observeLanguage().first()
|
||||
}
|
||||
|
||||
override suspend fun setLanguage(language: AppLanguage) {
|
||||
dataStore.edit { prefs ->
|
||||
if (language.tag == null) {
|
||||
prefs.remove(LANGUAGE_TAG_KEY)
|
||||
} else {
|
||||
prefs[LANGUAGE_TAG_KEY] = language.tag
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
val LANGUAGE_TAG_KEY = stringPreferencesKey("app_language_tag")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
package ru.daemonlord.messenger.data.settings.repository
|
||||
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import androidx.datastore.preferences.core.edit
|
||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.map
|
||||
import ru.daemonlord.messenger.domain.settings.model.AppThemeMode
|
||||
import ru.daemonlord.messenger.domain.settings.repository.ThemeRepository
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
class DataStoreThemeRepository @Inject constructor(
|
||||
private val dataStore: DataStore<Preferences>,
|
||||
) : ThemeRepository {
|
||||
|
||||
override fun observeThemeMode(): Flow<AppThemeMode> {
|
||||
return dataStore.data.map { prefs ->
|
||||
prefs[THEME_MODE_KEY]
|
||||
?.let { raw -> runCatching { AppThemeMode.valueOf(raw) }.getOrNull() }
|
||||
?: AppThemeMode.SYSTEM
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun getThemeMode(): AppThemeMode {
|
||||
return observeThemeMode().first()
|
||||
}
|
||||
|
||||
override suspend fun setThemeMode(mode: AppThemeMode) {
|
||||
dataStore.edit { prefs ->
|
||||
prefs[THEME_MODE_KEY] = mode.name
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
val THEME_MODE_KEY = stringPreferencesKey("app_theme_mode")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
package ru.daemonlord.messenger.data.user.api
|
||||
|
||||
import retrofit2.http.Body
|
||||
import retrofit2.http.DELETE
|
||||
import retrofit2.http.GET
|
||||
import retrofit2.http.POST
|
||||
import retrofit2.http.PUT
|
||||
import retrofit2.http.Path
|
||||
import retrofit2.http.Query
|
||||
import ru.daemonlord.messenger.data.auth.dto.AuthUserDto
|
||||
import ru.daemonlord.messenger.data.user.dto.AddContactByEmailRequestDto
|
||||
import ru.daemonlord.messenger.data.user.dto.UserProfileUpdateRequestDto
|
||||
import ru.daemonlord.messenger.data.user.dto.UserSearchDto
|
||||
|
||||
interface UserApiService {
|
||||
@PUT("/api/v1/users/profile")
|
||||
suspend fun updateProfile(@Body request: UserProfileUpdateRequestDto): AuthUserDto
|
||||
|
||||
@GET("/api/v1/users/blocked")
|
||||
suspend fun listBlockedUsers(): List<UserSearchDto>
|
||||
|
||||
@GET("/api/v1/users/search")
|
||||
suspend fun searchUsers(
|
||||
@Query("query") query: String,
|
||||
@Query("limit") limit: Int = 20,
|
||||
): List<UserSearchDto>
|
||||
|
||||
@POST("/api/v1/users/{user_id}/block")
|
||||
suspend fun blockUser(@Path("user_id") userId: Long)
|
||||
|
||||
@DELETE("/api/v1/users/{user_id}/block")
|
||||
suspend fun unblockUser(@Path("user_id") userId: Long)
|
||||
|
||||
@GET("/api/v1/users/contacts")
|
||||
suspend fun listContacts(): List<UserSearchDto>
|
||||
|
||||
@POST("/api/v1/users/{user_id}/contacts")
|
||||
suspend fun addContact(@Path("user_id") userId: Long)
|
||||
|
||||
@POST("/api/v1/users/contacts/by-email")
|
||||
suspend fun addContactByEmail(@Body request: AddContactByEmailRequestDto)
|
||||
|
||||
@DELETE("/api/v1/users/{user_id}/contacts")
|
||||
suspend fun removeContact(@Path("user_id") userId: Long)
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package ru.daemonlord.messenger.data.user.dto
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class UserProfileUpdateRequestDto(
|
||||
val name: String? = null,
|
||||
val username: String? = null,
|
||||
val bio: String? = null,
|
||||
@SerialName("avatar_url")
|
||||
val avatarUrl: String? = null,
|
||||
@SerialName("allow_private_messages")
|
||||
val allowPrivateMessages: Boolean? = null,
|
||||
@SerialName("privacy_private_messages")
|
||||
val privacyPrivateMessages: String? = null,
|
||||
@SerialName("privacy_last_seen")
|
||||
val privacyLastSeen: String? = null,
|
||||
@SerialName("privacy_avatar")
|
||||
val privacyAvatar: String? = null,
|
||||
@SerialName("privacy_group_invites")
|
||||
val privacyGroupInvites: String? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class UserSearchDto(
|
||||
val id: Long,
|
||||
val email: String? = null,
|
||||
val name: String? = null,
|
||||
val username: String? = null,
|
||||
@SerialName("avatar_url")
|
||||
val avatarUrl: String? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class AddContactByEmailRequestDto(
|
||||
val email: String,
|
||||
)
|
||||
@@ -0,0 +1,385 @@
|
||||
package ru.daemonlord.messenger.data.user.repository
|
||||
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.withContext
|
||||
import okhttp3.MediaType.Companion.toMediaTypeOrNull
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import ru.daemonlord.messenger.data.auth.api.AuthApiService
|
||||
import ru.daemonlord.messenger.data.auth.dto.AuthSessionDto
|
||||
import ru.daemonlord.messenger.data.auth.dto.AuthUserDto
|
||||
import ru.daemonlord.messenger.data.auth.dto.RequestPasswordResetDto
|
||||
import ru.daemonlord.messenger.data.auth.dto.ResendVerificationRequestDto
|
||||
import ru.daemonlord.messenger.data.auth.dto.ResetPasswordRequestDto
|
||||
import ru.daemonlord.messenger.data.auth.dto.TwoFactorCodeRequestDto
|
||||
import ru.daemonlord.messenger.data.auth.dto.VerifyEmailRequestDto
|
||||
import ru.daemonlord.messenger.data.common.toAppError
|
||||
import ru.daemonlord.messenger.data.media.api.MediaApiService
|
||||
import ru.daemonlord.messenger.data.media.dto.UploadUrlRequestDto
|
||||
import ru.daemonlord.messenger.data.notifications.api.NotificationApiService
|
||||
import ru.daemonlord.messenger.data.notifications.dto.NotificationReadDto
|
||||
import ru.daemonlord.messenger.di.RefreshClient
|
||||
import ru.daemonlord.messenger.data.user.api.UserApiService
|
||||
import ru.daemonlord.messenger.data.user.dto.AddContactByEmailRequestDto
|
||||
import ru.daemonlord.messenger.data.user.dto.UserProfileUpdateRequestDto
|
||||
import ru.daemonlord.messenger.data.user.dto.UserSearchDto
|
||||
import ru.daemonlord.messenger.di.IoDispatcher
|
||||
import ru.daemonlord.messenger.domain.account.model.UserSearchItem
|
||||
import ru.daemonlord.messenger.domain.account.model.AccountNotification
|
||||
import ru.daemonlord.messenger.domain.account.repository.AccountRepository
|
||||
import ru.daemonlord.messenger.domain.auth.model.AuthSession
|
||||
import ru.daemonlord.messenger.domain.auth.model.AuthUser
|
||||
import ru.daemonlord.messenger.domain.common.AppError
|
||||
import ru.daemonlord.messenger.domain.common.AppResult
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import kotlinx.serialization.json.contentOrNull
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
class NetworkAccountRepository @Inject constructor(
|
||||
private val authApiService: AuthApiService,
|
||||
private val userApiService: UserApiService,
|
||||
private val mediaApiService: MediaApiService,
|
||||
private val notificationApiService: NotificationApiService,
|
||||
@RefreshClient private val uploadClient: OkHttpClient,
|
||||
@IoDispatcher private val ioDispatcher: CoroutineDispatcher,
|
||||
) : AccountRepository {
|
||||
|
||||
override suspend fun getMe(): AppResult<AuthUser> = withContext(ioDispatcher) {
|
||||
try {
|
||||
AppResult.Success(authApiService.me().toDomain())
|
||||
} catch (error: Throwable) {
|
||||
AppResult.Error(error.toAppError())
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun updateProfile(
|
||||
name: String,
|
||||
username: String,
|
||||
bio: String?,
|
||||
avatarUrl: String?,
|
||||
): AppResult<AuthUser> = withContext(ioDispatcher) {
|
||||
try {
|
||||
val updated = userApiService.updateProfile(
|
||||
request = UserProfileUpdateRequestDto(
|
||||
name = name.trim().ifBlank { null },
|
||||
username = username.trim().ifBlank { null },
|
||||
bio = bio?.trim(),
|
||||
avatarUrl = avatarUrl?.trim(),
|
||||
)
|
||||
)
|
||||
AppResult.Success(updated.toDomain())
|
||||
} catch (error: Throwable) {
|
||||
AppResult.Error(error.toAppError())
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun uploadAvatar(
|
||||
fileName: String,
|
||||
mimeType: String,
|
||||
bytes: ByteArray,
|
||||
): AppResult<String> = withContext(ioDispatcher) {
|
||||
try {
|
||||
val uploadInfo = mediaApiService.requestUploadUrl(
|
||||
UploadUrlRequestDto(
|
||||
fileName = fileName,
|
||||
fileType = mimeType,
|
||||
fileSize = bytes.size.toLong(),
|
||||
)
|
||||
)
|
||||
val body = bytes.toRequestBody(mimeType.toMediaTypeOrNull())
|
||||
val uploadRequestBuilder = Request.Builder()
|
||||
.url(uploadInfo.uploadUrl)
|
||||
.put(body)
|
||||
uploadInfo.requiredHeaders.forEach { (key, value) ->
|
||||
uploadRequestBuilder.header(key, value)
|
||||
}
|
||||
uploadClient.newCall(uploadRequestBuilder.build()).execute().use { response ->
|
||||
if (!response.isSuccessful) {
|
||||
return@withContext AppResult.Error(AppError.Server("Upload failed: HTTP ${response.code}"))
|
||||
}
|
||||
}
|
||||
AppResult.Success(uploadInfo.fileUrl)
|
||||
} catch (error: Throwable) {
|
||||
AppResult.Error(error.toAppError())
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun updatePrivacy(
|
||||
privateMessages: String,
|
||||
lastSeen: String,
|
||||
avatar: String,
|
||||
groupInvites: String,
|
||||
): AppResult<AuthUser> = withContext(ioDispatcher) {
|
||||
try {
|
||||
val allowPrivateMessages = privateMessages != "nobody"
|
||||
val updated = userApiService.updateProfile(
|
||||
request = UserProfileUpdateRequestDto(
|
||||
allowPrivateMessages = allowPrivateMessages,
|
||||
privacyPrivateMessages = privateMessages,
|
||||
privacyLastSeen = lastSeen,
|
||||
privacyAvatar = avatar,
|
||||
privacyGroupInvites = groupInvites,
|
||||
)
|
||||
)
|
||||
AppResult.Success(updated.toDomain())
|
||||
} catch (error: Throwable) {
|
||||
AppResult.Error(error.toAppError())
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun listBlockedUsers(): AppResult<List<UserSearchItem>> = withContext(ioDispatcher) {
|
||||
try {
|
||||
AppResult.Success(userApiService.listBlockedUsers().map { it.toDomain() })
|
||||
} catch (error: Throwable) {
|
||||
AppResult.Error(error.toAppError())
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun listContacts(): AppResult<List<UserSearchItem>> = withContext(ioDispatcher) {
|
||||
try {
|
||||
AppResult.Success(userApiService.listContacts().map { it.toDomain() })
|
||||
} catch (error: Throwable) {
|
||||
AppResult.Error(error.toAppError())
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun searchUsers(query: String, limit: Int): AppResult<List<UserSearchItem>> = withContext(ioDispatcher) {
|
||||
val normalized = query.trim()
|
||||
if (normalized.isBlank()) return@withContext AppResult.Success(emptyList())
|
||||
try {
|
||||
AppResult.Success(userApiService.searchUsers(query = normalized, limit = limit).map { it.toDomain() })
|
||||
} catch (error: Throwable) {
|
||||
AppResult.Error(error.toAppError())
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun addContact(userId: Long): AppResult<Unit> = withContext(ioDispatcher) {
|
||||
try {
|
||||
userApiService.addContact(userId = userId)
|
||||
AppResult.Success(Unit)
|
||||
} catch (error: Throwable) {
|
||||
AppResult.Error(error.toAppError())
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun addContactByEmail(email: String): AppResult<Unit> = withContext(ioDispatcher) {
|
||||
val normalized = email.trim()
|
||||
if (normalized.isBlank()) return@withContext AppResult.Error(AppError.Server("Email is required"))
|
||||
try {
|
||||
userApiService.addContactByEmail(
|
||||
request = AddContactByEmailRequestDto(email = normalized),
|
||||
)
|
||||
AppResult.Success(Unit)
|
||||
} catch (error: Throwable) {
|
||||
AppResult.Error(error.toAppError())
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun removeContact(userId: Long): AppResult<Unit> = withContext(ioDispatcher) {
|
||||
try {
|
||||
userApiService.removeContact(userId = userId)
|
||||
AppResult.Success(Unit)
|
||||
} catch (error: Throwable) {
|
||||
AppResult.Error(error.toAppError())
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun blockUser(userId: Long): AppResult<Unit> = withContext(ioDispatcher) {
|
||||
try {
|
||||
userApiService.blockUser(userId)
|
||||
AppResult.Success(Unit)
|
||||
} catch (error: Throwable) {
|
||||
AppResult.Error(error.toAppError())
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun unblockUser(userId: Long): AppResult<Unit> = withContext(ioDispatcher) {
|
||||
try {
|
||||
userApiService.unblockUser(userId)
|
||||
AppResult.Success(Unit)
|
||||
} catch (error: Throwable) {
|
||||
AppResult.Error(error.toAppError())
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun listSessions(): AppResult<List<AuthSession>> = withContext(ioDispatcher) {
|
||||
try {
|
||||
AppResult.Success(authApiService.sessions().map { it.toDomain() })
|
||||
} catch (error: Throwable) {
|
||||
AppResult.Error(error.toAppError())
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun listNotifications(limit: Int): AppResult<List<AccountNotification>> = withContext(ioDispatcher) {
|
||||
try {
|
||||
AppResult.Success(notificationApiService.list(limit = limit).map { it.toDomain() })
|
||||
} catch (error: Throwable) {
|
||||
AppResult.Error(error.toAppError())
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun revokeSession(jti: String): AppResult<Unit> = withContext(ioDispatcher) {
|
||||
try {
|
||||
authApiService.revokeSession(jti)
|
||||
AppResult.Success(Unit)
|
||||
} catch (error: Throwable) {
|
||||
AppResult.Error(error.toAppError())
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun revokeAllSessions(): AppResult<Unit> = withContext(ioDispatcher) {
|
||||
try {
|
||||
authApiService.revokeAllSessions()
|
||||
AppResult.Success(Unit)
|
||||
} catch (error: Throwable) {
|
||||
AppResult.Error(error.toAppError())
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun verifyEmail(token: String): AppResult<String> = withContext(ioDispatcher) {
|
||||
try {
|
||||
val result = authApiService.verifyEmail(VerifyEmailRequestDto(token))
|
||||
AppResult.Success(result.message)
|
||||
} catch (error: Throwable) {
|
||||
AppResult.Error(error.toAppError())
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun requestPasswordReset(email: String): AppResult<String> = withContext(ioDispatcher) {
|
||||
try {
|
||||
val result = authApiService.requestPasswordReset(RequestPasswordResetDto(email = email.trim()))
|
||||
AppResult.Success(result.message)
|
||||
} catch (error: Throwable) {
|
||||
AppResult.Error(error.toAppError())
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun resendVerification(email: String): AppResult<String> = withContext(ioDispatcher) {
|
||||
try {
|
||||
val result = authApiService.resendVerification(ResendVerificationRequestDto(email = email.trim()))
|
||||
AppResult.Success(result.message)
|
||||
} catch (error: Throwable) {
|
||||
AppResult.Error(error.toAppError())
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun resetPassword(token: String, password: String): AppResult<String> = withContext(ioDispatcher) {
|
||||
try {
|
||||
val result = authApiService.resetPassword(
|
||||
ResetPasswordRequestDto(
|
||||
token = token.trim(),
|
||||
password = password,
|
||||
)
|
||||
)
|
||||
AppResult.Success(result.message)
|
||||
} catch (error: Throwable) {
|
||||
AppResult.Error(error.toAppError())
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun setupTwoFactor(): AppResult<Pair<String, String>> = withContext(ioDispatcher) {
|
||||
try {
|
||||
val response = authApiService.setupTwoFactor()
|
||||
AppResult.Success(response.secret to response.otpauthUrl)
|
||||
} catch (error: Throwable) {
|
||||
AppResult.Error(error.toAppError())
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun enableTwoFactor(code: String): AppResult<String> = withContext(ioDispatcher) {
|
||||
try {
|
||||
val response = authApiService.enableTwoFactor(TwoFactorCodeRequestDto(code.trim()))
|
||||
AppResult.Success(response.message)
|
||||
} catch (error: Throwable) {
|
||||
AppResult.Error(error.toAppError())
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun disableTwoFactor(code: String): AppResult<String> = withContext(ioDispatcher) {
|
||||
try {
|
||||
val response = authApiService.disableTwoFactor(TwoFactorCodeRequestDto(code.trim()))
|
||||
AppResult.Success(response.message)
|
||||
} catch (error: Throwable) {
|
||||
AppResult.Error(error.toAppError())
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun twoFactorRecoveryStatus(): AppResult<Int> = withContext(ioDispatcher) {
|
||||
try {
|
||||
AppResult.Success(authApiService.twoFactorRecoveryStatus().remainingCodes)
|
||||
} catch (error: Throwable) {
|
||||
AppResult.Error(error.toAppError())
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun regenerateTwoFactorRecoveryCodes(code: String): AppResult<List<String>> = withContext(ioDispatcher) {
|
||||
try {
|
||||
val response = authApiService.regenerateTwoFactorRecoveryCodes(TwoFactorCodeRequestDto(code.trim()))
|
||||
AppResult.Success(response.codes)
|
||||
} catch (error: Throwable) {
|
||||
AppResult.Error(error.toAppError())
|
||||
}
|
||||
}
|
||||
|
||||
private fun AuthUserDto.toDomain(): AuthUser {
|
||||
return AuthUser(
|
||||
id = id,
|
||||
email = email,
|
||||
name = name,
|
||||
username = username,
|
||||
bio = bio,
|
||||
avatarUrl = avatarUrl,
|
||||
emailVerified = emailVerified,
|
||||
twofaEnabled = twofaEnabled,
|
||||
privacyPrivateMessages = privacyPrivateMessages ?: "everyone",
|
||||
privacyLastSeen = privacyLastSeen ?: "everyone",
|
||||
privacyAvatar = privacyAvatar ?: "everyone",
|
||||
privacyGroupInvites = privacyGroupInvites ?: "everyone",
|
||||
)
|
||||
}
|
||||
|
||||
private fun AuthSessionDto.toDomain(): AuthSession {
|
||||
return AuthSession(
|
||||
jti = jti,
|
||||
createdAt = createdAt,
|
||||
ipAddress = ipAddress,
|
||||
userAgent = userAgent,
|
||||
current = current,
|
||||
tokenType = tokenType,
|
||||
)
|
||||
}
|
||||
|
||||
private fun UserSearchDto.toDomain(): UserSearchItem {
|
||||
return UserSearchItem(
|
||||
id = id,
|
||||
name = name?.trim().takeUnless { it.isNullOrBlank() } ?: username?.trim().takeUnless { it.isNullOrBlank() } ?: "User #$id",
|
||||
username = username,
|
||||
avatarUrl = avatarUrl,
|
||||
)
|
||||
}
|
||||
|
||||
private fun NotificationReadDto.toDomain(): AccountNotification {
|
||||
val payloadObject = runCatching {
|
||||
Json.parseToJsonElement(payload).jsonObject
|
||||
}.getOrNull()
|
||||
val chatId = payloadObject?.get("chat_id")?.jsonPrimitive?.contentOrNull?.toLongOrNull()
|
||||
val messageId = payloadObject?.get("message_id")?.jsonPrimitive?.contentOrNull?.toLongOrNull()
|
||||
val text = payloadObject?.get("text")?.jsonPrimitive?.contentOrNull
|
||||
?: payloadObject?.get("body")?.jsonPrimitive?.contentOrNull
|
||||
?: payloadObject?.get("title")?.jsonPrimitive?.contentOrNull
|
||||
return AccountNotification(
|
||||
id = id,
|
||||
eventType = eventType,
|
||||
createdAt = createdAt,
|
||||
payloadRaw = payload,
|
||||
chatId = chatId,
|
||||
messageId = messageId,
|
||||
text = text,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package ru.daemonlord.messenger.di
|
||||
|
||||
import android.content.Context
|
||||
import androidx.room.Room
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import ru.daemonlord.messenger.data.chat.local.dao.ChatDao
|
||||
import ru.daemonlord.messenger.data.chat.local.db.MessengerDatabase
|
||||
import ru.daemonlord.messenger.data.message.local.dao.MessageDao
|
||||
import ru.daemonlord.messenger.data.message.local.dao.PendingMessageActionDao
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
object DatabaseModule {
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideDatabase(
|
||||
@ApplicationContext context: Context,
|
||||
): MessengerDatabase {
|
||||
return Room.databaseBuilder(
|
||||
context,
|
||||
MessengerDatabase::class.java,
|
||||
"messenger.db",
|
||||
).fallbackToDestructiveMigration()
|
||||
.build()
|
||||
}
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideChatDao(database: MessengerDatabase): ChatDao = database.chatDao()
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideMessageDao(database: MessengerDatabase): MessageDao = database.messageDao()
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun providePendingMessageActionDao(database: MessengerDatabase): PendingMessageActionDao =
|
||||
database.pendingMessageActionDao()
|
||||
}
|
||||
@@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
package ru.daemonlord.messenger.di
|
||||
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.serialization.json.Json
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.logging.HttpLoggingInterceptor
|
||||
import retrofit2.Retrofit
|
||||
import ru.daemonlord.messenger.BuildConfig
|
||||
import ru.daemonlord.messenger.core.network.AuthHeaderInterceptor
|
||||
import ru.daemonlord.messenger.core.network.ApiVersionInterceptor
|
||||
import ru.daemonlord.messenger.core.network.TokenRefreshAuthenticator
|
||||
import ru.daemonlord.messenger.data.auth.api.AuthApiService
|
||||
import ru.daemonlord.messenger.data.chat.api.ChatApiService
|
||||
import ru.daemonlord.messenger.data.media.api.MediaApiService
|
||||
import ru.daemonlord.messenger.data.message.api.MessageApiService
|
||||
import ru.daemonlord.messenger.data.notifications.api.NotificationApiService
|
||||
import ru.daemonlord.messenger.data.notifications.api.PushTokenApiService
|
||||
import ru.daemonlord.messenger.data.search.api.SearchApiService
|
||||
import ru.daemonlord.messenger.data.user.api.UserApiService
|
||||
import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory
|
||||
import java.util.concurrent.TimeUnit
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
object NetworkModule {
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideJson(): Json {
|
||||
return Json {
|
||||
ignoreUnknownKeys = true
|
||||
explicitNulls = false
|
||||
isLenient = true
|
||||
}
|
||||
}
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideHttpLoggingInterceptor(): HttpLoggingInterceptor {
|
||||
return HttpLoggingInterceptor().apply {
|
||||
level = if (BuildConfig.DEBUG) {
|
||||
HttpLoggingInterceptor.Level.BODY
|
||||
} else {
|
||||
HttpLoggingInterceptor.Level.NONE
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
@RefreshClient
|
||||
fun provideRefreshClient(
|
||||
loggingInterceptor: HttpLoggingInterceptor,
|
||||
): OkHttpClient {
|
||||
return OkHttpClient.Builder()
|
||||
.addInterceptor(loggingInterceptor)
|
||||
.connectTimeout(20, TimeUnit.SECONDS)
|
||||
.readTimeout(20, TimeUnit.SECONDS)
|
||||
.writeTimeout(20, TimeUnit.SECONDS)
|
||||
.build()
|
||||
}
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
@RefreshAuthApi
|
||||
fun provideRefreshApiService(
|
||||
@RefreshClient refreshClient: OkHttpClient,
|
||||
json: Json,
|
||||
): AuthApiService {
|
||||
val contentType = "application/json".toMediaType()
|
||||
return Retrofit.Builder()
|
||||
.baseUrl(BuildConfig.API_BASE_URL)
|
||||
.addConverterFactory(json.asConverterFactory(contentType))
|
||||
.client(refreshClient)
|
||||
.build()
|
||||
.create(AuthApiService::class.java)
|
||||
}
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
@IoDispatcher
|
||||
fun provideIoDispatcher(): CoroutineDispatcher = Dispatchers.IO
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideApiClient(
|
||||
loggingInterceptor: HttpLoggingInterceptor,
|
||||
apiVersionInterceptor: ApiVersionInterceptor,
|
||||
authHeaderInterceptor: AuthHeaderInterceptor,
|
||||
tokenRefreshAuthenticator: TokenRefreshAuthenticator,
|
||||
): OkHttpClient {
|
||||
return OkHttpClient.Builder()
|
||||
.addInterceptor(loggingInterceptor)
|
||||
.addInterceptor(apiVersionInterceptor)
|
||||
.addInterceptor(authHeaderInterceptor)
|
||||
.authenticator(tokenRefreshAuthenticator)
|
||||
.connectTimeout(20, TimeUnit.SECONDS)
|
||||
.readTimeout(20, TimeUnit.SECONDS)
|
||||
.writeTimeout(20, TimeUnit.SECONDS)
|
||||
.build()
|
||||
}
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideRetrofit(
|
||||
client: OkHttpClient,
|
||||
json: Json,
|
||||
): Retrofit {
|
||||
val contentType = "application/json".toMediaType()
|
||||
return Retrofit.Builder()
|
||||
.baseUrl(BuildConfig.API_BASE_URL)
|
||||
.addConverterFactory(json.asConverterFactory(contentType))
|
||||
.client(client)
|
||||
.build()
|
||||
}
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideAuthApiService(retrofit: Retrofit): AuthApiService {
|
||||
return retrofit.create(AuthApiService::class.java)
|
||||
}
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideChatApiService(retrofit: Retrofit): ChatApiService {
|
||||
return retrofit.create(ChatApiService::class.java)
|
||||
}
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideMessageApiService(retrofit: Retrofit): MessageApiService {
|
||||
return retrofit.create(MessageApiService::class.java)
|
||||
}
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideMediaApiService(retrofit: Retrofit): MediaApiService {
|
||||
return retrofit.create(MediaApiService::class.java)
|
||||
}
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideUserApiService(retrofit: Retrofit): UserApiService {
|
||||
return retrofit.create(UserApiService::class.java)
|
||||
}
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideSearchApiService(retrofit: Retrofit): SearchApiService {
|
||||
return retrofit.create(SearchApiService::class.java)
|
||||
}
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun providePushTokenApiService(retrofit: Retrofit): PushTokenApiService {
|
||||
return retrofit.create(PushTokenApiService::class.java)
|
||||
}
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideNotificationApiService(retrofit: Retrofit): NotificationApiService {
|
||||
return retrofit.create(NotificationApiService::class.java)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package ru.daemonlord.messenger.di
|
||||
|
||||
import javax.inject.Qualifier
|
||||
|
||||
@Qualifier
|
||||
@Retention(AnnotationRetention.BINARY)
|
||||
annotation class RefreshClient
|
||||
|
||||
@Qualifier
|
||||
@Retention(AnnotationRetention.BINARY)
|
||||
annotation class RefreshAuthApi
|
||||
|
||||
@Qualifier
|
||||
@Retention(AnnotationRetention.BINARY)
|
||||
annotation class IoDispatcher
|
||||
|
||||
@Qualifier
|
||||
@Retention(AnnotationRetention.BINARY)
|
||||
annotation class TokenPrefs
|
||||
@@ -0,0 +1,20 @@
|
||||
package ru.daemonlord.messenger.di
|
||||
|
||||
import dagger.Binds
|
||||
import dagger.Module
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import ru.daemonlord.messenger.data.realtime.WsRealtimeManager
|
||||
import ru.daemonlord.messenger.domain.realtime.RealtimeManager
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
abstract class RealtimeModule {
|
||||
|
||||
@Binds
|
||||
@Singleton
|
||||
abstract fun bindRealtimeManager(
|
||||
manager: WsRealtimeManager,
|
||||
): RealtimeManager
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
package ru.daemonlord.messenger.di
|
||||
|
||||
import dagger.Binds
|
||||
import dagger.Module
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import ru.daemonlord.messenger.data.auth.repository.NetworkAuthRepository
|
||||
import ru.daemonlord.messenger.data.auth.repository.DefaultSessionCleanupRepository
|
||||
import ru.daemonlord.messenger.data.chat.repository.NetworkChatRepository
|
||||
import ru.daemonlord.messenger.data.chat.repository.DataStoreChatSearchRepository
|
||||
import ru.daemonlord.messenger.data.media.repository.NetworkMediaRepository
|
||||
import ru.daemonlord.messenger.data.message.repository.NetworkMessageRepository
|
||||
import ru.daemonlord.messenger.data.notifications.repository.DataStoreNotificationSettingsRepository
|
||||
import ru.daemonlord.messenger.data.search.repository.NetworkSearchRepository
|
||||
import ru.daemonlord.messenger.data.settings.repository.DataStoreLanguageRepository
|
||||
import ru.daemonlord.messenger.data.settings.repository.DataStoreThemeRepository
|
||||
import ru.daemonlord.messenger.data.user.repository.NetworkAccountRepository
|
||||
import ru.daemonlord.messenger.domain.account.repository.AccountRepository
|
||||
import ru.daemonlord.messenger.domain.auth.repository.AuthRepository
|
||||
import ru.daemonlord.messenger.domain.auth.repository.SessionCleanupRepository
|
||||
import ru.daemonlord.messenger.domain.chat.repository.ChatRepository
|
||||
import ru.daemonlord.messenger.domain.chat.repository.ChatSearchRepository
|
||||
import ru.daemonlord.messenger.domain.media.repository.MediaRepository
|
||||
import ru.daemonlord.messenger.domain.message.repository.MessageRepository
|
||||
import ru.daemonlord.messenger.domain.notifications.repository.NotificationSettingsRepository
|
||||
import ru.daemonlord.messenger.domain.search.repository.SearchRepository
|
||||
import ru.daemonlord.messenger.domain.settings.repository.LanguageRepository
|
||||
import ru.daemonlord.messenger.domain.settings.repository.ThemeRepository
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
abstract class RepositoryModule {
|
||||
|
||||
@Binds
|
||||
@Singleton
|
||||
abstract fun bindAuthRepository(
|
||||
repository: NetworkAuthRepository,
|
||||
): AuthRepository
|
||||
|
||||
@Binds
|
||||
@Singleton
|
||||
abstract fun bindSessionCleanupRepository(
|
||||
repository: DefaultSessionCleanupRepository,
|
||||
): SessionCleanupRepository
|
||||
|
||||
@Binds
|
||||
@Singleton
|
||||
abstract fun bindChatRepository(
|
||||
repository: NetworkChatRepository,
|
||||
): ChatRepository
|
||||
|
||||
@Binds
|
||||
@Singleton
|
||||
abstract fun bindChatSearchRepository(
|
||||
repository: DataStoreChatSearchRepository,
|
||||
): ChatSearchRepository
|
||||
|
||||
@Binds
|
||||
@Singleton
|
||||
abstract fun bindMessageRepository(
|
||||
repository: NetworkMessageRepository,
|
||||
): MessageRepository
|
||||
|
||||
@Binds
|
||||
@Singleton
|
||||
abstract fun bindMediaRepository(
|
||||
repository: NetworkMediaRepository,
|
||||
): MediaRepository
|
||||
|
||||
@Binds
|
||||
@Singleton
|
||||
abstract fun bindNotificationSettingsRepository(
|
||||
repository: DataStoreNotificationSettingsRepository,
|
||||
): NotificationSettingsRepository
|
||||
|
||||
@Binds
|
||||
@Singleton
|
||||
abstract fun bindAccountRepository(
|
||||
repository: NetworkAccountRepository,
|
||||
): AccountRepository
|
||||
|
||||
@Binds
|
||||
@Singleton
|
||||
abstract fun bindSearchRepository(
|
||||
repository: NetworkSearchRepository,
|
||||
): SearchRepository
|
||||
|
||||
@Binds
|
||||
@Singleton
|
||||
abstract fun bindThemeRepository(
|
||||
repository: DataStoreThemeRepository,
|
||||
): ThemeRepository
|
||||
|
||||
@Binds
|
||||
@Singleton
|
||||
abstract fun bindLanguageRepository(
|
||||
repository: DataStoreLanguageRepository,
|
||||
): LanguageRepository
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package ru.daemonlord.messenger.di
|
||||
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import androidx.datastore.preferences.core.PreferenceDataStoreFactory
|
||||
import androidx.datastore.preferences.preferencesDataStoreFile
|
||||
import androidx.security.crypto.EncryptedSharedPreferences
|
||||
import androidx.security.crypto.MasterKey
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import ru.daemonlord.messenger.core.token.EncryptedPrefsTokenRepository
|
||||
import ru.daemonlord.messenger.core.token.TokenRepository
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
object StorageModule {
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun providePreferenceDataStore(
|
||||
@ApplicationContext context: Context,
|
||||
): DataStore<Preferences> {
|
||||
return PreferenceDataStoreFactory.create(
|
||||
produceFile = { context.preferencesDataStoreFile("messenger_preferences.preferences_pb") }
|
||||
)
|
||||
}
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
@TokenPrefs
|
||||
fun provideTokenSharedPreferences(
|
||||
@ApplicationContext context: Context,
|
||||
): SharedPreferences {
|
||||
val masterKey = MasterKey.Builder(context)
|
||||
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
|
||||
.build()
|
||||
return EncryptedSharedPreferences.create(
|
||||
context,
|
||||
"messenger_secure_tokens",
|
||||
masterKey,
|
||||
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
|
||||
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,
|
||||
)
|
||||
}
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideTokenRepository(
|
||||
repository: EncryptedPrefsTokenRepository,
|
||||
): TokenRepository = repository
|
||||
}
|
||||
@@ -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?,
|
||||
)
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user