commit 22e19cdfdb46b302d3dbcff98083a2309d3b344b Author: Курнат Андрей Date: Sun May 17 22:22:43 2026 +0300 Update Ikar server and Android client diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 00000000..a275c424 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,14 @@ +.git +.vs +.codex-* +**/bin +**/obj +docker-data +src/Ikar.Server/Data/Attachments +src/Ikar.Server/Data/*.db +src/Ikar.Server/Data/*.db-shm +src/Ikar.Server/Data/*.db-wal +src/Ikar.Client/bin +src/Ikar.Client/obj +server.out.log +server.err.log diff --git a/.env.example b/.env.example new file mode 100644 index 00000000..c1a6ec3e --- /dev/null +++ b/.env.example @@ -0,0 +1 @@ +IKAR_DATA_PATH=/media/myDrive/ikar-data diff --git a/.env.server.example b/.env.server.example new file mode 100644 index 00000000..33988daa --- /dev/null +++ b/.env.server.example @@ -0,0 +1,13 @@ +# Docker Compose bind mount path is configured in `.env` via `IKAR_DATA_PATH`. +ASPNETCORE_ENVIRONMENT=Production +ConnectionStrings__IkarDb=Data Source=Data/ikar.db +Jwt__Issuer=Ikar.Server +Jwt__Audience=Ikar.Client +Jwt__SigningKey=Ikar-Replace-This-Development-Key-With-A-Long-Random-Secret-CHANGE_ME_USE_SECRET_STORE_VALUE_32_CHARS_MINIMUM +Jwt__AccessTokenLifetimeMinutes=15 +Jwt__RefreshTokenLifetimeDays=30 +Push__FirebaseProjectId= +Push__ServiceAccountJsonPath= +Push__ServiceAccountJson= +Admin__Username= +Admin__Password= diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..118efad3 --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +.vs/ +**/bin/ +**/obj/ +src/Ikar.Server/Data/** +!src/Ikar.Server/Data/**/ +!src/Ikar.Server/Data/**/*.cs +*.log +src/Ikar.CodexBridge/appsettings.Local.json diff --git a/Dockerfile.server b/Dockerfile.server new file mode 100644 index 00000000..c7b2a965 --- /dev/null +++ b/Dockerfile.server @@ -0,0 +1,27 @@ +FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build +WORKDIR /src + +COPY ["src/Ikar.Server/Ikar.Server.csproj", "src/Ikar.Server/"] +COPY ["src/Ikar.Shared/Ikar.Shared.csproj", "src/Ikar.Shared/"] +RUN dotnet restore "src/Ikar.Server/Ikar.Server.csproj" + +COPY . . +RUN dotnet publish "src/Ikar.Server/Ikar.Server.csproj" -c Release -o /app/publish /p:UseAppHost=false + +FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS runtime +RUN apt-get update \ + && apt-get install -y --no-install-recommends curl \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /app +ENV ASPNETCORE_ENVIRONMENT=Production +ENV ASPNETCORE_URLS=http://+:5099 + +COPY --from=build /app/publish . + +EXPOSE 5099 + +HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \ + CMD curl --fail http://127.0.0.1:5099/health || exit 1 + +ENTRYPOINT ["dotnet", "Ikar.Server.dll"] diff --git a/Ikar.slnx b/Ikar.slnx new file mode 100644 index 00000000..22f6f389 --- /dev/null +++ b/Ikar.slnx @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/IkarClassic.sln b/IkarClassic.sln new file mode 100644 index 00000000..b98e9876 --- /dev/null +++ b/IkarClassic.sln @@ -0,0 +1,114 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.0.31903.59 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{827E0CD3-B72D-47B6-A68D-7590B98EB39B}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Ikar.Shared", "src\Ikar.Shared\Ikar.Shared.csproj", "{2FBB67AD-D250-4C69-AEA9-D0F7A18D623B}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Ikar.Server", "src\Ikar.Server\Ikar.Server.csproj", "{631B782D-44B2-4680-BC8C-1A0A948A1945}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Ikar.Client", "src\Ikar.Client\Ikar.Client.csproj", "{E5B4217E-FCA8-4944-99A6-12D07E7B5D60}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Ikar.WinUI", "src\Ikar.WinUI\Ikar.WinUI.csproj", "{2900F762-FB89-44B7-AF69-D4908EEC50B2}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Ikar.CodexBridge", "src\Ikar.CodexBridge\Ikar.CodexBridge.csproj", "{57EC4F75-199D-4FFB-A658-D455AF1F41CF}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Ikar.CodexBridge.Tests", "src\Ikar.CodexBridge.Tests\Ikar.CodexBridge.Tests.csproj", "{318E90D6-56ED-491E-8454-22CB84B6FDD3}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Debug|x64 = Debug|x64 + Debug|x86 = Debug|x86 + Release|Any CPU = Release|Any CPU + Release|x64 = Release|x64 + Release|x86 = Release|x86 + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {2FBB67AD-D250-4C69-AEA9-D0F7A18D623B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {2FBB67AD-D250-4C69-AEA9-D0F7A18D623B}.Debug|Any CPU.Build.0 = Debug|Any CPU + {2FBB67AD-D250-4C69-AEA9-D0F7A18D623B}.Debug|x64.ActiveCfg = Debug|Any CPU + {2FBB67AD-D250-4C69-AEA9-D0F7A18D623B}.Debug|x64.Build.0 = Debug|Any CPU + {2FBB67AD-D250-4C69-AEA9-D0F7A18D623B}.Debug|x86.ActiveCfg = Debug|Any CPU + {2FBB67AD-D250-4C69-AEA9-D0F7A18D623B}.Debug|x86.Build.0 = Debug|Any CPU + {2FBB67AD-D250-4C69-AEA9-D0F7A18D623B}.Release|Any CPU.ActiveCfg = Release|Any CPU + {2FBB67AD-D250-4C69-AEA9-D0F7A18D623B}.Release|Any CPU.Build.0 = Release|Any CPU + {2FBB67AD-D250-4C69-AEA9-D0F7A18D623B}.Release|x64.ActiveCfg = Release|Any CPU + {2FBB67AD-D250-4C69-AEA9-D0F7A18D623B}.Release|x64.Build.0 = Release|Any CPU + {2FBB67AD-D250-4C69-AEA9-D0F7A18D623B}.Release|x86.ActiveCfg = Release|Any CPU + {2FBB67AD-D250-4C69-AEA9-D0F7A18D623B}.Release|x86.Build.0 = Release|Any CPU + {631B782D-44B2-4680-BC8C-1A0A948A1945}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {631B782D-44B2-4680-BC8C-1A0A948A1945}.Debug|Any CPU.Build.0 = Debug|Any CPU + {631B782D-44B2-4680-BC8C-1A0A948A1945}.Debug|x64.ActiveCfg = Debug|Any CPU + {631B782D-44B2-4680-BC8C-1A0A948A1945}.Debug|x64.Build.0 = Debug|Any CPU + {631B782D-44B2-4680-BC8C-1A0A948A1945}.Debug|x86.ActiveCfg = Debug|Any CPU + {631B782D-44B2-4680-BC8C-1A0A948A1945}.Debug|x86.Build.0 = Debug|Any CPU + {631B782D-44B2-4680-BC8C-1A0A948A1945}.Release|Any CPU.ActiveCfg = Release|Any CPU + {631B782D-44B2-4680-BC8C-1A0A948A1945}.Release|Any CPU.Build.0 = Release|Any CPU + {631B782D-44B2-4680-BC8C-1A0A948A1945}.Release|x64.ActiveCfg = Release|Any CPU + {631B782D-44B2-4680-BC8C-1A0A948A1945}.Release|x64.Build.0 = Release|Any CPU + {631B782D-44B2-4680-BC8C-1A0A948A1945}.Release|x86.ActiveCfg = Release|Any CPU + {631B782D-44B2-4680-BC8C-1A0A948A1945}.Release|x86.Build.0 = Release|Any CPU + {E5B4217E-FCA8-4944-99A6-12D07E7B5D60}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {E5B4217E-FCA8-4944-99A6-12D07E7B5D60}.Debug|Any CPU.Build.0 = Debug|Any CPU + {E5B4217E-FCA8-4944-99A6-12D07E7B5D60}.Debug|x64.ActiveCfg = Debug|Any CPU + {E5B4217E-FCA8-4944-99A6-12D07E7B5D60}.Debug|x64.Build.0 = Debug|Any CPU + {E5B4217E-FCA8-4944-99A6-12D07E7B5D60}.Debug|x86.ActiveCfg = Debug|Any CPU + {E5B4217E-FCA8-4944-99A6-12D07E7B5D60}.Debug|x86.Build.0 = Debug|Any CPU + {E5B4217E-FCA8-4944-99A6-12D07E7B5D60}.Release|Any CPU.ActiveCfg = Release|Any CPU + {E5B4217E-FCA8-4944-99A6-12D07E7B5D60}.Release|Any CPU.Build.0 = Release|Any CPU + {E5B4217E-FCA8-4944-99A6-12D07E7B5D60}.Release|x64.ActiveCfg = Release|Any CPU + {E5B4217E-FCA8-4944-99A6-12D07E7B5D60}.Release|x64.Build.0 = Release|Any CPU + {E5B4217E-FCA8-4944-99A6-12D07E7B5D60}.Release|x86.ActiveCfg = Release|Any CPU + {E5B4217E-FCA8-4944-99A6-12D07E7B5D60}.Release|x86.Build.0 = Release|Any CPU + {2900F762-FB89-44B7-AF69-D4908EEC50B2}.Debug|Any CPU.ActiveCfg = Debug|x86 + {2900F762-FB89-44B7-AF69-D4908EEC50B2}.Debug|Any CPU.Build.0 = Debug|x86 + {2900F762-FB89-44B7-AF69-D4908EEC50B2}.Debug|x64.ActiveCfg = Debug|x64 + {2900F762-FB89-44B7-AF69-D4908EEC50B2}.Debug|x64.Build.0 = Debug|x64 + {2900F762-FB89-44B7-AF69-D4908EEC50B2}.Debug|x86.ActiveCfg = Debug|x86 + {2900F762-FB89-44B7-AF69-D4908EEC50B2}.Debug|x86.Build.0 = Debug|x86 + {2900F762-FB89-44B7-AF69-D4908EEC50B2}.Release|Any CPU.ActiveCfg = Release|x86 + {2900F762-FB89-44B7-AF69-D4908EEC50B2}.Release|Any CPU.Build.0 = Release|x86 + {2900F762-FB89-44B7-AF69-D4908EEC50B2}.Release|x64.ActiveCfg = Release|x64 + {2900F762-FB89-44B7-AF69-D4908EEC50B2}.Release|x64.Build.0 = Release|x64 + {2900F762-FB89-44B7-AF69-D4908EEC50B2}.Release|x86.ActiveCfg = Release|x86 + {2900F762-FB89-44B7-AF69-D4908EEC50B2}.Release|x86.Build.0 = Release|x86 + {57EC4F75-199D-4FFB-A658-D455AF1F41CF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {57EC4F75-199D-4FFB-A658-D455AF1F41CF}.Debug|Any CPU.Build.0 = Debug|Any CPU + {57EC4F75-199D-4FFB-A658-D455AF1F41CF}.Debug|x64.ActiveCfg = Debug|Any CPU + {57EC4F75-199D-4FFB-A658-D455AF1F41CF}.Debug|x64.Build.0 = Debug|Any CPU + {57EC4F75-199D-4FFB-A658-D455AF1F41CF}.Debug|x86.ActiveCfg = Debug|Any CPU + {57EC4F75-199D-4FFB-A658-D455AF1F41CF}.Debug|x86.Build.0 = Debug|Any CPU + {57EC4F75-199D-4FFB-A658-D455AF1F41CF}.Release|Any CPU.ActiveCfg = Release|Any CPU + {57EC4F75-199D-4FFB-A658-D455AF1F41CF}.Release|Any CPU.Build.0 = Release|Any CPU + {57EC4F75-199D-4FFB-A658-D455AF1F41CF}.Release|x64.ActiveCfg = Release|Any CPU + {57EC4F75-199D-4FFB-A658-D455AF1F41CF}.Release|x64.Build.0 = Release|Any CPU + {57EC4F75-199D-4FFB-A658-D455AF1F41CF}.Release|x86.ActiveCfg = Release|Any CPU + {57EC4F75-199D-4FFB-A658-D455AF1F41CF}.Release|x86.Build.0 = Release|Any CPU + {318E90D6-56ED-491E-8454-22CB84B6FDD3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {318E90D6-56ED-491E-8454-22CB84B6FDD3}.Debug|Any CPU.Build.0 = Debug|Any CPU + {318E90D6-56ED-491E-8454-22CB84B6FDD3}.Debug|x64.ActiveCfg = Debug|Any CPU + {318E90D6-56ED-491E-8454-22CB84B6FDD3}.Debug|x64.Build.0 = Debug|Any CPU + {318E90D6-56ED-491E-8454-22CB84B6FDD3}.Debug|x86.ActiveCfg = Debug|Any CPU + {318E90D6-56ED-491E-8454-22CB84B6FDD3}.Debug|x86.Build.0 = Debug|Any CPU + {318E90D6-56ED-491E-8454-22CB84B6FDD3}.Release|Any CPU.ActiveCfg = Release|Any CPU + {318E90D6-56ED-491E-8454-22CB84B6FDD3}.Release|Any CPU.Build.0 = Release|Any CPU + {318E90D6-56ED-491E-8454-22CB84B6FDD3}.Release|x64.ActiveCfg = Release|Any CPU + {318E90D6-56ED-491E-8454-22CB84B6FDD3}.Release|x64.Build.0 = Release|Any CPU + {318E90D6-56ED-491E-8454-22CB84B6FDD3}.Release|x86.ActiveCfg = Release|Any CPU + {318E90D6-56ED-491E-8454-22CB84B6FDD3}.Release|x86.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(NestedProjects) = preSolution + {2FBB67AD-D250-4C69-AEA9-D0F7A18D623B} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} + {631B782D-44B2-4680-BC8C-1A0A948A1945} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} + {E5B4217E-FCA8-4944-99A6-12D07E7B5D60} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} + {2900F762-FB89-44B7-AF69-D4908EEC50B2} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} + {57EC4F75-199D-4FFB-A658-D455AF1F41CF} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} + {318E90D6-56ED-491E-8454-22CB84B6FDD3} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} + EndGlobalSection +EndGlobal diff --git a/README.md b/README.md new file mode 100644 index 00000000..5bb39cd7 --- /dev/null +++ b/README.md @@ -0,0 +1,204 @@ +# Ikar + +Ikar is a working Telegram-like messenger MVP built in this folder with an ASP.NET Core backend, an Android MAUI client, and a dedicated WinUI 3 desktop client for Windows. + +## Stack + +- `src/Ikar.Server`: ASP.NET Core 10 Web API + SignalR + SQLite. +- `src/Ikar.Shared`: shared DTO/contracts used by server and client. +- `src/Ikar.Client`: .NET MAUI client targeting `net10.0-android`. +- `src/Ikar.WinUI`: WinUI 3 desktop client targeting `net10.0-windows10.0.19041.0`. + +## Implemented + +- phone-based sign-in with one-time test codes +- JWT access tokens with rotating refresh tokens +- secure client-side session persistence +- seeded demo accounts and sample chats +- Android contact-book lookup against registered phone numbers +- direct chats +- group chats +- channels with owner-only publishing +- message history persisted in SQLite +- file attachments with metadata persisted in SQLite +- voice notes as audio-classified attachments +- inline voice note playback in Android chat +- message edit/delete with realtime updates +- authenticated attachment download +- real-time message delivery with SignalR +- Android direct-chat audio/video calls with SignalR-based signaling and WebRTC media transport +- dedicated Android call screen for incoming and outgoing audio calls +- Android call ring/ringback tones and proximity-based screen blanking during active calls +- Android push registration API and FCM-based push delivery pipeline +- web admin panel for users, chats, attachments, and system status +- public landing page at `/` with latest Android APK download +- publishable Windows `exe` +- publishable Android `apk` + +## Not Implemented + +This is not full Telegram parity. The following are not implemented in this MVP: + +- microphone recording UI for voice notes +- stickers and inline media previews +- end-to-end encryption +- advanced channel admin roles, granular moderation policies, multi-device sync edge cases +- Windows cloud push notifications + +## Demo Accounts + +- `alice`, phone `+10000000001` +- `bob`, phone `+10000000002` +- `carol`, phone `+10000000003` + +These are created automatically on first server start in `src/Ikar.Server/Data/ikar.db`. + +## Run + +1. Start the backend: + +```powershell +./scripts/run-server.ps1 +``` + +2. Run the Windows client from Visual Studio or CLI: + +```powershell +dotnet build .\src\Ikar.WinUI\Ikar.WinUI.csproj -f net10.0-windows10.0.19041.0 -p:Platform=x64 +.\src\Ikar.WinUI\bin\x64\Debug\net10.0-windows10.0.19041.0\win-x64\Ikar.WinUI.exe +``` + +3. Run the Android client from Visual Studio or install the generated APK: + +```powershell +dotnet build .\src\Ikar.Client\Ikar.Client.csproj -f net10.0-android +``` + +The Android client is hard-wired to the current production endpoint: + +- Android client default base URL: `https://ikar.kusoft.xyz` + +The Android login screen no longer exposes a server URL field. + +The production Docker deployment binds the backend to `127.0.0.1:5099` on the Raspberry Pi host. Public and LAN clients should use the reverse-proxied HTTPS endpoint instead of direct access to port `5099`: + +- public Android / browser / admin URL: `https://ikar.kusoft.xyz` + +## Run In Docker + +Docker assets for Raspberry Pi 5 are included in the repo: + +- `Dockerfile.server` +- `docker-compose.rpi5.yml` +- `.env.example` +- `.env.server` +- `docs/raspberry-pi-5-docker.md` + +Quick start: + +```bash +cp .env.example .env +docker compose -f docker-compose.rpi5.yml up -d --build +``` + +To place server data on an external disk, set `IKAR_DATA_PATH` before running Compose. The current production Pi uses `/media/myDrive/ikar-data`. + +Health check: + +```bash +curl http://127.0.0.1:5099/health +``` + +Admin panel: + +- `/admin` redirects to `/admin/index.html` +- configure admin login through `Admin__Username` and `Admin__Password` +- the admin surface uses its own cookie session and does not reuse messenger JWT tokens +- `/` serves a public landing page with a button for the latest Android APK +- `/api/app-updates/android/download/latest` returns the latest published Android APK +- `scripts/publish-android-update.ps1` keeps only the newest `3` Android APK files on the server by default; override with `-KeepLatestCount ` if needed + +## Publish + +Windows EXE: + +```powershell +./scripts/publish-windows.ps1 +``` + +Android APK: + +```powershell +./scripts/publish-android-apk.ps1 +``` + +Smoke test against the running backend: + +```powershell +./scripts/smoke-test.ps1 +``` + +## Published Artifacts + +- Windows EXE: `artifacts/winui-win64/Ikar.WinUI.exe` +- Android APK: `src/Ikar.Client/bin/Release/net10.0-android/publish/com.seven.ikar.apk` +- Signed Android APK: `src/Ikar.Client/bin/Release/net10.0-android/publish/com.seven.ikar-Signed.apk` + +## Solutions + +- XML solution: `Ikar.slnx` +- classic Visual Studio solution: `IkarClassic.sln` + +## Auth Notes + +- Android client auth is currently `phone-only`. +- The current verification flow returns a temporary test code from the server and shows it in the client UI. +- Real SMS delivery is not implemented yet; it can be connected later by adding an SMS provider. +- Access tokens are short-lived JWTs. +- Refresh tokens are rotated on refresh and stored as hashed server-side sessions. +- Logout revokes the active session immediately, and the revoked access token stops working because the server validates the session id (`sid`) on authenticated requests. +- Session `LastSeenAt` writes are throttled server-side to reduce unnecessary SQLite writes on every authenticated request. +- The JWT signing key in `src/Ikar.Server/appsettings.json` is a development default. Replace it with a strong secret via configuration before any real deployment. + +## Contacts Notes + +- Android contact discovery requests `READ_CONTACTS` and only shows people from the device phone book. +- Server-side contact resolution matches users by normalized phone number through `api/users/contacts/resolve`. +- Users without a registered phone number do not appear in the Android contacts screen. + +## Attachment Notes + +- Attachments are stored on the server under `src/Ikar.Server/Data/Attachments`. +- Metadata stored for each attachment: original file name, content type, file size, upload time, and owning message. +- Audio files are classified as `voice notes` by MIME type / file extension and use the same secure attachment pipeline. +- Attachment content is downloaded through an authenticated API route, not via a public static files directory. + +## Channel And Message Lifecycle Notes + +- Channels are broadcast rooms: the creator is the owner, selected users join as subscribers, and only owners can publish. +- Message edit/delete is synchronized through SignalR `MessageUpdated` events. +- Delete is implemented as a soft-delete on the message row with attachment cleanup on the server. + +## Call Notes + +- Calls are currently implemented only for Android and only in direct one-to-one chats. +- The current call architecture uses SignalR for call lifecycle/signaling and WebRTC for audio/video media transport. +- ICE servers are configured server-side through the `WebRtc` configuration section. +- WinUI does not support calls in the current build. + +## Push Setup Notes + +- Android push in this repo is implemented through Firebase Cloud Messaging HTTP v1 on the server and `FirebaseMessagingService` on the Android client. +- Server-side device registrations are stored in SQLite in the `PushDevices` table and managed through `api/push/devices`. +- Push dispatch is queued through a background worker so message sends do not wait on outbound Firebase network latency. +- To enable real Android delivery, set `Push:FirebaseProjectId` and either `Push:ServiceAccountJsonPath` or `Push:ServiceAccountJson` in `src/Ikar.Server/appsettings.json` or environment-specific configuration. +- Before building the Android APK, fill `src/Ikar.Client/Resources/Raw/firebase.android.json` with the Android Firebase values: `applicationId`, `projectId`, `apiKey`, `senderId`. +- If the Firebase configuration is left empty, the app still builds and the server still accepts device registrations, but actual FCM delivery is skipped. +- The current Windows WinUI desktop target is unpackaged (`WindowsPackageType=None`), so this pass does not implement Windows cloud push. + +## Server Runtime Notes + +- Chat list loading now fetches only the latest message per chat for summary rendering instead of loading full message history. +- The server exposes `/health` for container and deployment health checks. +- The server exposes `/admin/api/*` plus static admin assets under `/admin/*` when admin credentials are configured. +- Startup enables SQLite write-ahead logging and ensures an index on `ChatMembers.UserId` for chat list lookup. diff --git a/docker-compose.rpi5.yml b/docker-compose.rpi5.yml new file mode 100644 index 00000000..004fcd77 --- /dev/null +++ b/docker-compose.rpi5.yml @@ -0,0 +1,15 @@ +services: + ikar-server: + container_name: ikar-server + image: ikar-server:rpi5 + platform: linux/arm64 + build: + context: . + dockerfile: Dockerfile.server + restart: unless-stopped + env_file: + - .env.server + ports: + - "127.0.0.1:5099:5099" + volumes: + - ${IKAR_DATA_PATH:?IKAR_DATA_PATH must be set in .env}:/app/Data diff --git a/docs/raspberry-pi-5-docker.md b/docs/raspberry-pi-5-docker.md new file mode 100644 index 00000000..327b23ad --- /dev/null +++ b/docs/raspberry-pi-5-docker.md @@ -0,0 +1,102 @@ +# Raspberry Pi 5 Docker Run + +This repository now contains a Dockerized server path for Raspberry Pi 5. + +Files used for this deployment: + +- `Dockerfile.server` +- `docker-compose.rpi5.yml` +- `.env.example` +- `.env.server` + +## What This Runs + +- ASP.NET Core server from `src/Ikar.Server` +- SQLite database, attachments, update APKs, and secrets persisted under `/app/Data` +- host-side data path controlled by `IKAR_DATA_PATH` +- host-side backend port bound only to `127.0.0.1:5099`; external access should go through the reverse proxy and HTTPS domain + +## Prerequisites On Raspberry Pi 5 + +1. Install Docker Engine using the official Docker docs: `https://docs.docker.com/engine/install/debian/` +2. Install Docker Compose plugin using the official Docker docs: `https://docs.docker.com/compose/install/` +3. Clone this repository onto the Raspberry Pi 5. +4. Create `.env` from `.env.example` and set `IKAR_DATA_PATH` to the desired host path. +5. Edit `.env.server` and replace `Jwt__SigningKey` before exposing the server outside your LAN. + +## Start + +Run from the repository root: + +```bash +cp .env.example .env +docker compose -f docker-compose.rpi5.yml up -d --build +``` + +## Stop + +```bash +docker compose -f docker-compose.rpi5.yml down +``` + +## Logs + +```bash +docker compose -f docker-compose.rpi5.yml logs -f ikar-server +``` + +## Update After Pulling New Code + +```bash +docker compose -f docker-compose.rpi5.yml up -d --build +``` + +## Verify + +Health endpoint: + +```bash +curl http://127.0.0.1:5099/health +``` + +Expected response: + +```json +{"status":"ok"} +``` + +## Data Location + +Docker bind mount: + +- host: `${IKAR_DATA_PATH}` +- container: `/app/Data` + +On the current Raspberry Pi 5 deployment, `IKAR_DATA_PATH` is set to `/media/myDrive/ikar-data`, which places the SQLite database, attachments, uploaded APK updates, and secrets on the external 1 TB disk instead of the SD card. + +The following data stays persistent there: + +- `ikar.db` +- `ikar.db-wal` +- `ikar.db-shm` +- `Attachments/` +- `AppUpdates/Android/` +- `secrets/firebase-admin.json` + +## Client Access + +For the current production deployment, clients should use the reverse-proxied HTTPS endpoint instead of direct access to port `5099`: + +- Android / browser / admin: `https://ikar.kusoft.xyz` + +Direct host access to `http://:5099` is intentionally not exposed outside the Raspberry Pi loopback interface. + +## Optional Firebase Push + +If you want real Android FCM delivery: + +1. Put the Firebase service account JSON onto the Raspberry Pi 5. +2. Set either `Push__ServiceAccountJsonPath` or `Push__ServiceAccountJson` in `.env.server`. +3. Set `Push__FirebaseProjectId` in `.env.server`. + +If those values stay empty, device registration still works but actual FCM delivery is skipped by the server. diff --git a/logo.png b/logo.png new file mode 100644 index 00000000..c386a287 Binary files /dev/null and b/logo.png differ diff --git a/logo1.png b/logo1.png new file mode 100644 index 00000000..c27464b7 Binary files /dev/null and b/logo1.png differ diff --git a/logo_bg.png b/logo_bg.png new file mode 100644 index 00000000..9832247a Binary files /dev/null and b/logo_bg.png differ diff --git a/scripts/admin-smoke-test.ps1 b/scripts/admin-smoke-test.ps1 new file mode 100644 index 00000000..4da5ca53 --- /dev/null +++ b/scripts/admin-smoke-test.ps1 @@ -0,0 +1,231 @@ +param( + [string]$BaseUrl = 'http://localhost:5099', + [string]$AdminUsername = $env:Admin__Username, + [string]$AdminPassword = $env:Admin__Password +) + +$ErrorActionPreference = 'Stop' +Add-Type -AssemblyName System.Net.Http + +if ([string]::IsNullOrWhiteSpace($AdminUsername) -or [string]::IsNullOrWhiteSpace($AdminPassword)) { + throw 'Admin__Username and Admin__Password must be provided.' +} + +$stamp = Get-Date -Format 'yyyyMMddHHmmss' +$actorUsername = "adminsmoke$stamp" +$targetUsername = "adminpeer$stamp" +$password = 'Demo123!' +$attachmentName = "admin-attachment-$stamp.txt" +$actorPhone = "+7911$stamp" +$targetPhone = "+7922$stamp" + +$actorSession = Invoke-RestMethod ` + -Uri "$BaseUrl/api/auth/register" ` + -Method Post ` + -ContentType 'application/json' ` + -Body (@{ + username = $actorUsername + displayName = 'Admin Smoke Actor' + password = $password + phoneNumber = $actorPhone + } | ConvertTo-Json) + +$targetSession = Invoke-RestMethod ` + -Uri "$BaseUrl/api/auth/register" ` + -Method Post ` + -ContentType 'application/json' ` + -Body (@{ + username = $targetUsername + displayName = 'Admin Smoke Peer' + password = $password + phoneNumber = $targetPhone + } | ConvertTo-Json) + +$actorHeaders = @{ + Authorization = "Bearer $($actorSession.accessToken)" +} + +$targetUser = Invoke-RestMethod ` + -Uri "$BaseUrl/api/users/search?q=$targetUsername" ` + -Headers $actorHeaders ` + -Method Get | Select-Object -First 1 + +$chat = Invoke-RestMethod ` + -Uri "$BaseUrl/api/chats/direct" ` + -Headers $actorHeaders ` + -Method Post ` + -ContentType 'application/json' ` + -Body (@{ + userId = $targetUser.id + } | ConvertTo-Json) + +$tempFile = Join-Path $env:TEMP $attachmentName +"admin attachment smoke $stamp" | Set-Content -Path $tempFile -NoNewline + +$handler = New-Object System.Net.Http.HttpClientHandler +$client = New-Object System.Net.Http.HttpClient($handler) +$client.BaseAddress = [Uri]::new("$BaseUrl/") +$client.DefaultRequestHeaders.Authorization = New-Object System.Net.Http.Headers.AuthenticationHeaderValue('Bearer', $actorSession.accessToken) +$form = New-Object System.Net.Http.MultipartFormDataContent +$form.Add((New-Object System.Net.Http.StringContent('admin smoke attachment')), 'text') +$fileStream = [System.IO.File]::OpenRead($tempFile) +$fileContent = New-Object System.Net.Http.StreamContent($fileStream) +$fileContent.Headers.ContentType = New-Object System.Net.Http.Headers.MediaTypeHeaderValue('text/plain') +$form.Add($fileContent, 'file', [System.IO.Path]::GetFileName($tempFile)) +$uploadResponse = $client.PostAsync("api/chats/$($chat.id)/attachments", $form).GetAwaiter().GetResult() +$uploadPayload = $uploadResponse.Content.ReadAsStringAsync().GetAwaiter().GetResult() +$fileStream.Dispose() +$form.Dispose() +$client.Dispose() +$handler.Dispose() + +if (-not $uploadResponse.IsSuccessStatusCode) { + throw $uploadPayload +} + +$uploadedMessage = $uploadPayload | ConvertFrom-Json +$attachment = $uploadedMessage.attachments[0] + +$adminSession = New-Object Microsoft.PowerShell.Commands.WebRequestSession +Invoke-RestMethod ` + -Uri "$BaseUrl/admin/api/session/login" ` + -WebSession $adminSession ` + -Method Post ` + -ContentType 'application/json' ` + -Body (@{ + username = $AdminUsername + password = $AdminPassword + } | ConvertTo-Json) | Out-Null + +$status = Invoke-RestMethod ` + -Uri "$BaseUrl/admin/api/status" ` + -WebSession $adminSession ` + -Method Get + +$usersBeforeDeleteResponse = Invoke-RestMethod ` + -Uri "$BaseUrl/admin/api/users?q=adminsmoke$stamp" ` + -WebSession $adminSession ` + -Method Get +[object[]]$usersBeforeDelete = if ($null -eq $usersBeforeDeleteResponse) { @() } else { @($usersBeforeDeleteResponse) } + +$chatsBeforeDeleteResponse = Invoke-RestMethod ` + -Uri "$BaseUrl/admin/api/chats?q=adminsmoke$stamp" ` + -WebSession $adminSession ` + -Method Get +[object[]]$chatsBeforeDelete = if ($null -eq $chatsBeforeDeleteResponse) { @() } else { @($chatsBeforeDeleteResponse) } + +$attachmentsBeforeDeleteResponse = Invoke-RestMethod ` + -Uri "$BaseUrl/admin/api/attachments?q=$attachmentName" ` + -WebSession $adminSession ` + -Method Get +[object[]]$attachmentsBeforeDelete = if ($null -eq $attachmentsBeforeDeleteResponse) { @() } else { @($attachmentsBeforeDeleteResponse) } + +$downloadPath = Join-Path $env:TEMP "admin-download-$stamp.txt" +Invoke-WebRequest ` + -Uri "$BaseUrl/admin/api/attachments/$($attachment.id)/content" ` + -WebSession $adminSession ` + -OutFile $downloadPath | Out-Null + +Invoke-RestMethod ` + -Uri "$BaseUrl/admin/api/users/$($actorSession.user.id)/block" ` + -WebSession $adminSession ` + -Method Post | Out-Null + +$blockedStatusCode = 0 +try { + Invoke-WebRequest ` + -Uri "$BaseUrl/api/auth/login" ` + -Method Post ` + -ContentType 'application/json' ` + -Body (@{ + username = $actorUsername + password = $password + } | ConvertTo-Json) | Out-Null +} +catch { + $blockedStatusCode = [int]$_.Exception.Response.StatusCode +} + +Invoke-RestMethod ` + -Uri "$BaseUrl/admin/api/users/$($actorSession.user.id)/unblock" ` + -WebSession $adminSession ` + -Method Post | Out-Null + +$actorSessionAfterUnblock = Invoke-RestMethod ` + -Uri "$BaseUrl/api/auth/login" ` + -Method Post ` + -ContentType 'application/json' ` + -Body (@{ + username = $actorUsername + password = $password + } | ConvertTo-Json) + +$actorHeadersAfterUnblock = @{ + Authorization = "Bearer $($actorSessionAfterUnblock.accessToken)" +} + +Invoke-RestMethod ` + -Uri "$BaseUrl/admin/api/chats/$($chat.id)" ` + -WebSession $adminSession ` + -Method Delete | Out-Null + +$chatDeleted = $false +try { + Invoke-RestMethod ` + -Uri "$BaseUrl/api/chats/$($chat.id)" ` + -Headers $actorHeadersAfterUnblock ` + -Method Get | Out-Null +} +catch { + $chatDeleted = [int]$_.Exception.Response.StatusCode -eq 404 +} + +Invoke-RestMethod ` + -Uri "$BaseUrl/admin/api/users/$($actorSession.user.id)" ` + -WebSession $adminSession ` + -Method Delete | Out-Null + +Invoke-RestMethod ` + -Uri "$BaseUrl/admin/api/users/$($targetSession.user.id)" ` + -WebSession $adminSession ` + -Method Delete | Out-Null + +$usersAfterDeleteResponse = Invoke-RestMethod ` + -Uri "$BaseUrl/admin/api/users?q=adminsmoke$stamp" ` + -WebSession $adminSession ` + -Method Get +[object[]]$usersAfterDelete = if ($null -eq $usersAfterDeleteResponse) { @() } else { @($usersAfterDeleteResponse) } + +Invoke-RestMethod ` + -Uri "$BaseUrl/admin/api/session/logout" ` + -WebSession $adminSession ` + -Method Post | Out-Null + +$logoutBlocked = $false +try { + Invoke-WebRequest ` + -Uri "$BaseUrl/admin/api/status" ` + -WebSession $adminSession ` + -Method Get | Out-Null +} +catch { + $logoutBlocked = [int]$_.Exception.Response.StatusCode -eq 401 +} + +[pscustomobject]@{ + ActorUsername = $actorUsername + TargetUsername = $targetUsername + TotalUsers = $status.totalUsers + DatabaseReachable = $status.databaseReachable + UsersBeforeCount = $usersBeforeDelete.Count + ChatsBeforeCount = $chatsBeforeDelete.Count + AttachmentsBeforeCount = $attachmentsBeforeDelete.Count + UsersListed = $usersBeforeDelete.Count -ge 1 + ChatsListed = $chatsBeforeDelete.Count -ge 1 + AttachmentsListed = $attachmentsBeforeDelete.Count -ge 1 + AttachmentDownloaded = (Get-Item $downloadPath).Length -gt 0 + BlockedLoginStatus = $blockedStatusCode + ChatDeleted = $chatDeleted + UsersDeleted = $usersAfterDelete.Count -eq 0 + AdminLogoutBlocked = $logoutBlocked +} diff --git a/scripts/phone-auth-smoke-test.ps1 b/scripts/phone-auth-smoke-test.ps1 new file mode 100644 index 00000000..ccd2b47a --- /dev/null +++ b/scripts/phone-auth-smoke-test.ps1 @@ -0,0 +1,123 @@ +param( + [string]$BaseUrl = 'http://localhost:5099' +) + +$ErrorActionPreference = 'Stop' + +$stamp = Get-Date -Format 'yyyyMMddHHmmss' +$phoneA = "+7999$stamp" +$phoneB = "+7888$stamp" + +function Invoke-JsonPost { + param( + [string]$Uri, + [hashtable]$Body, + [hashtable]$Headers = @{} + ) + + return Invoke-RestMethod ` + -Uri $Uri ` + -Method Post ` + -Headers $Headers ` + -ContentType 'application/json' ` + -Body ($Body | ConvertTo-Json) +} + +$challengeA = Invoke-JsonPost ` + -Uri "$BaseUrl/api/auth/request-code" ` + -Body @{ phoneNumber = $phoneA } + +$sessionA = Invoke-JsonPost ` + -Uri "$BaseUrl/api/auth/verify-code" ` + -Body @{ + challengeId = $challengeA.challengeId + phoneNumber = $phoneA + code = $challengeA.testCode + displayName = 'Phone Smoke A' + } + +$challengeB = Invoke-JsonPost ` + -Uri "$BaseUrl/api/auth/request-code" ` + -Body @{ phoneNumber = $phoneB } + +$sessionB = Invoke-JsonPost ` + -Uri "$BaseUrl/api/auth/verify-code" ` + -Body @{ + challengeId = $challengeB.challengeId + phoneNumber = $phoneB + code = $challengeB.testCode + displayName = 'Phone Smoke B' + } + +$headersA = @{ + Authorization = "Bearer $($sessionA.accessToken)" +} + +$meA = Invoke-RestMethod ` + -Uri "$BaseUrl/api/users/me" ` + -Method Get ` + -Headers $headersA + +$resolve = Invoke-JsonPost ` + -Uri "$BaseUrl/api/users/contacts/resolve" ` + -Headers $headersA ` + -Body @{ + phoneNumbers = @( + $phoneB, + '+7 (777) 000-00-00' + ) + } + +if ($null -eq $resolve) { + $resolve = @() +} +else { + $resolve = @($resolve) +} + +$repeatChallengeA = Invoke-JsonPost ` + -Uri "$BaseUrl/api/auth/request-code" ` + -Body @{ phoneNumber = $phoneA } + +$repeatSessionA = Invoke-JsonPost ` + -Uri "$BaseUrl/api/auth/verify-code" ` + -Body @{ + challengeId = $repeatChallengeA.challengeId + phoneNumber = $phoneA + code = $repeatChallengeA.testCode + displayName = $null + } + +$chat = Invoke-JsonPost ` + -Uri "$BaseUrl/api/chats/direct" ` + -Headers $headersA ` + -Body @{ + userId = $sessionB.user.id + } + +$chats = Invoke-RestMethod ` + -Uri "$BaseUrl/api/chats" ` + -Method Get ` + -Headers $headersA + +if ($null -eq $chats) { + $chats = @() +} +else { + $chats = @($chats) +} + +[pscustomobject]@{ + PhoneARegistered = -not $challengeA.isRegistered + PhoneBRegistered = -not $challengeB.isRegistered + PhoneAMeDisplayName = $meA.displayName + PhoneAMePhone = $meA.phoneNumber + ResolvedCount = $resolve.Count + ResolvedPhone = if ($resolve.Count -gt 0) { $resolve[0].phoneNumber } else { $null } + ResolvedDisplayName = if ($resolve.Count -gt 0) { $resolve[0].user.displayName } else { $null } + ReLoginRegistered = $repeatChallengeA.isRegistered + ReLoginSameUser = $repeatSessionA.user.id -eq $sessionA.user.id + DirectChatType = $chat.type + DirectChatCreated = $chat.type -eq 1 -or $chat.type -eq 'Direct' + ChatsCount = $chats.Count +} diff --git a/scripts/prepare-android-argus-package.ps1 b/scripts/prepare-android-argus-package.ps1 new file mode 100644 index 00000000..e829d0a1 --- /dev/null +++ b/scripts/prepare-android-argus-package.ps1 @@ -0,0 +1,222 @@ +param( + [string]$Slug = 'ikar-android', + [string]$Name, + [string]$Summary = 'Android client build', + [string]$Description = 'Android release channel for the Ikar client.', + [string]$Version, + [string]$Channel = 'stable', + [string]$Notes, + [string]$RepositoryUrl, + [string]$HomepageUrl = 'https://ikar.kusoft.xyz', + [switch]$Unlisted, + [switch]$SkipBuild +) + +$ErrorActionPreference = 'Stop' + +function Require-Tool([string]$Path, [string]$Label) { + if (-not (Test-Path $Path)) { + throw "$Label was not found: $Path" + } +} + +function Get-ApkBadgingValue([string]$BadgingText, [string]$Pattern) { + $match = [regex]::Match($BadgingText, $Pattern, [System.Text.RegularExpressions.RegexOptions]::Multiline) + if (-not $match.Success) { + return $null + } + + return $match.Groups[1].Value +} + +function Resolve-ArgusPlatform([string[]]$NativeCodes) { + $normalized = $NativeCodes | Where-Object { $_ } | ForEach-Object { $_.Trim().ToLowerInvariant() } + $allUniversalAbis = @('arm64-v8a', 'armeabi-v7a', 'x86', 'x86_64') + if (@($allUniversalAbis | Where-Object { $normalized -contains $_ }).Count -eq $allUniversalAbis.Count) { + return 'android-universal' + } + + if ($normalized -contains 'arm64-v8a') { + return 'android-arm64' + } + + if ($normalized -contains 'armeabi-v7a') { + return 'android-armv7' + } + + if ($normalized -contains 'x86_64') { + return 'android-x86_64' + } + + if ($normalized -contains 'x86') { + return 'android-x86' + } + + return 'android' +} + +function Get-RepositoryUrl() { + $remote = git remote get-url origin 2>$null + if ($LASTEXITCODE -eq 0 -and -not [string]::IsNullOrWhiteSpace($remote)) { + return $remote.Trim() + } + + return $null +} + +$repoRoot = Split-Path -Parent $PSScriptRoot +Set-Location $repoRoot + +$javaHome = if ($env:JAVA_HOME) { $env:JAVA_HOME } else { 'C:\Program Files\Android\openjdk\jdk-21.0.8' } +$androidHome = if ($env:ANDROID_HOME) { $env:ANDROID_HOME } else { 'C:\Users\seven\AppData\Local\Android\Sdk' } +$env:JAVA_HOME = $javaHome +$env:ANDROID_HOME = $androidHome + +$projectPath = Join-Path $repoRoot 'src\Ikar.Client\Ikar.Client.csproj' +$publishDir = Join-Path $repoRoot 'src\Ikar.Client\bin\Release\net10.0-android' +$releaseApkPath = Join-Path $publishDir 'com.seven.ikar-Signed.apk' +$aaptPath = Join-Path $androidHome 'build-tools\36.1.0\aapt.exe' +$apksignerPath = Join-Path $androidHome 'build-tools\36.1.0\apksigner.bat' + +Require-Tool $projectPath 'Ikar.Client project' +Require-Tool $aaptPath 'aapt' +Require-Tool $apksignerPath 'apksigner' + +if (-not $SkipBuild) { + dotnet publish $projectPath -f net10.0-android -c Release -p:AndroidPackageFormat=apk -v minimal --no-restore + if ($LASTEXITCODE -ne 0) { + throw "dotnet publish failed with exit code $LASTEXITCODE." + } +} + +if (-not (Test-Path $releaseApkPath)) { + throw "Release APK was not found: $releaseApkPath" +} + +$badging = & $aaptPath dump badging $releaseApkPath +if ($LASTEXITCODE -ne 0) { + throw "aapt badging failed with exit code $LASTEXITCODE." +} + +$badgingText = ($badging | Out-String) +$packageName = Get-ApkBadgingValue $badgingText "package: name='([^']+)'" +$versionCode = Get-ApkBadgingValue $badgingText "versionCode='([^']+)'" +$versionName = Get-ApkBadgingValue $badgingText "versionName='([^']+)'" +$targetSdk = Get-ApkBadgingValue $badgingText "targetSdkVersion:'([^']+)'" +$applicationLabel = Get-ApkBadgingValue $badgingText "application-label:'([^']+)'" +$nativeCodeLine = ($badging | Where-Object { $_ -like 'native-code:*' } | Select-Object -First 1) +$nativeCodes = if ($nativeCodeLine) { + [regex]::Matches($nativeCodeLine, "'([^']+)'") | ForEach-Object { $_.Groups[1].Value } +} else { + @() +} + +if (-not $packageName) { + throw 'Package name could not be extracted from APK.' +} + +if (-not $versionCode) { + throw 'Version code could not be extracted from APK.' +} + +if (-not $versionName) { + throw 'Version name could not be extracted from APK.' +} + +$signatureOutput = & $apksignerPath verify --print-certs $releaseApkPath +if ($LASTEXITCODE -ne 0) { + throw "apksigner verification failed with exit code $LASTEXITCODE." +} + +$signatureText = ($signatureOutput | Out-String) +$signerDn = Get-ApkBadgingValue $signatureText "Signer #1 certificate DN: (.+)" +$signerSha256 = Get-ApkBadgingValue $signatureText "Signer #1 certificate SHA-256 digest: ([A-Fa-f0-9]+)" +if ($signerDn) { + $signerDn = $signerDn.Trim() +} +if ($signerSha256) { + $signerSha256 = $signerSha256.Trim() +} +$isDebugSigned = $false +if ($signerDn -and $signerDn -like '*CN=Android Debug*') { + $isDebugSigned = $true +} + +$resolvedName = if ($Name) { $Name } elseif ($applicationLabel) { $applicationLabel } else { 'Ikar' } +$resolvedVersion = if ($Version) { $Version } else { '{0}+{1}' -f $versionName, $versionCode } +$resolvedRepositoryUrl = if ($RepositoryUrl) { $RepositoryUrl } else { Get-RepositoryUrl } +$resolvedPlatform = Resolve-ArgusPlatform $nativeCodes +$apkItem = Get-Item $releaseApkPath +$apkHash = (Get-FileHash -Path $releaseApkPath -Algorithm SHA256).Hash.ToLowerInvariant() + +$stageDirectory = Join-Path $repoRoot ("artifacts\argus\{0}\v{1}" -f $Slug, $versionCode) +if (-not (Test-Path $stageDirectory)) { + New-Item -ItemType Directory -Path $stageDirectory -Force | Out-Null +} + +$stagedApkName = "{0}-{1}.apk" -f $Slug, $versionCode +$stagedApkPath = Join-Path $stageDirectory $stagedApkName +Copy-Item $releaseApkPath $stagedApkPath -Force + +$metadata = [ordered]@{ + slug = $Slug + name = $resolvedName + summary = $Summary + description = $Description + version = $resolvedVersion + channel = $Channel + platform = $resolvedPlatform + packageKind = 'apk' + notes = if ($Notes) { $Notes } else { $null } + repositoryUrl = if ($resolvedRepositoryUrl) { $resolvedRepositoryUrl } else { $null } + homepageUrl = if ($HomepageUrl) { $HomepageUrl } else { $null } + androidPackageName = $packageName + androidVersionCode = [int]$versionCode + targetSdkVersion = if ($targetSdk) { [int]$targetSdk } else { $null } + isListed = (-not $Unlisted) + packagePath = $stagedApkPath + packageFileName = $stagedApkName + packageSizeBytes = $apkItem.Length + packageSha256 = $apkHash + preparedAt = [DateTimeOffset]::UtcNow.ToString('O') + verifiedFromApk = [ordered]@{ + sourceApk = $releaseApkPath + applicationLabel = if ($applicationLabel) { $applicationLabel } else { $null } + packageName = $packageName + versionCode = [int]$versionCode + versionName = $versionName + targetSdkVersion = if ($targetSdk) { [int]$targetSdk } else { $null } + nativeCodes = $nativeCodes + signerDn = if ($signerDn) { $signerDn } else { $null } + signerSha256 = if ($signerSha256) { $signerSha256.ToLowerInvariant() } else { $null } + isDebugSigned = $isDebugSigned + } +} + +$metadataPath = Join-Path $stageDirectory 'argus-package.json' +$metadata | ConvertTo-Json -Depth 6 | Set-Content -Path $metadataPath -Encoding UTF8 + +$summaryLines = @( + "slug=$Slug" + "name=$resolvedName" + "version=$resolvedVersion" + "channel=$Channel" + "platform=$resolvedPlatform" + "packageKind=apk" + "androidPackageName=$packageName" + "androidVersionCode=$versionCode" + "targetSdkVersion=$targetSdk" + "packagePath=$stagedApkPath" + "packageSizeBytes=$($apkItem.Length)" + "packageSha256=$apkHash" + "signerDn=$signerDn" + "signerSha256=$($signerSha256.ToLowerInvariant())" + "isDebugSigned=$isDebugSigned" +) +$summaryPath = Join-Path $stageDirectory 'argus-package-summary.txt' +$summaryLines | Set-Content -Path $summaryPath -Encoding UTF8 + +Write-Output "Prepared Argus package files." +Write-Output "APK: $stagedApkPath" +Write-Output "Metadata: $metadataPath" +Write-Output "Summary: $summaryPath" diff --git a/scripts/publish-android-apk.ps1 b/scripts/publish-android-apk.ps1 new file mode 100644 index 00000000..1c951b61 --- /dev/null +++ b/scripts/publish-android-apk.ps1 @@ -0,0 +1,10 @@ +$ErrorActionPreference = 'Stop' +Set-Location (Join-Path $PSScriptRoot '..') +$publishDir = '.\src\Ikar.Client\bin\Release\net10.0-android\publish' + +if (Test-Path $publishDir) { + Remove-Item $publishDir -Recurse -Force +} + +dotnet publish .\src\Ikar.Client\Ikar.Client.csproj -f net10.0-android -c Release -p:AndroidPackageFormat=apk +Get-ChildItem $publishDir | Where-Object { $_.Name -like 'com.seven.massenger*' } | Remove-Item -Force diff --git a/scripts/publish-android-update.ps1 b/scripts/publish-android-update.ps1 new file mode 100644 index 00000000..71f6eef3 --- /dev/null +++ b/scripts/publish-android-update.ps1 @@ -0,0 +1,298 @@ +param( + [string]$ServerHost = '192.168.0.185', + [string]$Username = 'sevenhill', + [Parameter(Mandatory = $true)] + [string]$Password, + [string]$ArgusBaseUrl = 'https://argus.kusoft.xyz', + [string]$ArgusInternalBaseUrl = 'http://127.0.0.1:5105', + [string]$ArgusEnvPath = '/home/sevenhill/argus-deploy/.env.server', + [string]$Slug = 'ikar-android', + [string]$Notes = 'Android client update for Ikar', + [string]$AppName = 'Ikar Android', + [string]$Summary = 'Android client build', + [string]$Description = 'Android release channel for the Ikar client.', + [string]$HomepageUrl = 'https://ikar.kusoft.xyz', + [string]$Platform = 'android', + [string]$PackageKind = 'apk', + [string]$Channel = 'stable' +) + +$ErrorActionPreference = 'Stop' + +Set-Location (Join-Path $PSScriptRoot '..') + +$repoRoot = (Get-Location).Path +$projectPath = '.\src\Ikar.Client\Ikar.Client.csproj' + +[xml]$projectXml = Get-Content $projectPath +$versionName = $projectXml.Project.PropertyGroup.ApplicationDisplayVersion | Select-Object -First 1 +$versionCode = [int]($projectXml.Project.PropertyGroup.ApplicationVersion | Select-Object -First 1) +$publishDir = Join-Path $repoRoot (".codex-temp\android-update-publish\v{0}" -f $versionCode) + +if (Test-Path $publishDir) { + Remove-Item $publishDir -Recurse -Force +} + +dotnet publish $projectPath -f net10.0-android -c Release -p:AndroidPackageFormat=apk -p:UseSharedCompilation=false -o $publishDir + +$sourceApk = Join-Path $publishDir 'com.seven.ikar-Signed.apk' +if (-not (Test-Path $sourceApk)) { + throw "Signed APK not found: $sourceApk" +} + +$remoteFileName = "ikar-android-$versionCode.apk" +$localCopy = Join-Path $publishDir $remoteFileName +Copy-Item $sourceApk $localCopy -Force + +$manifest = [ordered]@{ + versionCode = $versionCode + versionName = $versionName + packageFileName = $remoteFileName + publishedAt = [DateTimeOffset]::UtcNow.ToString('O') + notes = $Notes +} + +$manifestPath = Join-Path $publishDir 'latest.json' +$manifest | ConvertTo-Json -Depth 4 | Set-Content $manifestPath -Encoding UTF8 + +$argusPublishResult = @" +import json +import os +import paramiko +import posixpath + +host = os.environ['IKAR_UPDATE_HOST'] +username = os.environ['IKAR_UPDATE_USERNAME'] +password = os.environ['IKAR_UPDATE_PASSWORD'] +env_path = os.environ['IKAR_UPDATE_ARGUS_ENV_PATH'] +local_apk_path = os.environ['IKAR_UPDATE_APK_PATH'] +remote_apk_name = os.environ['IKAR_UPDATE_REMOTE_APK_NAME'] +argus_internal_base_url = os.environ['IKAR_UPDATE_ARGUS_INTERNAL_BASE_URL'] +slug = os.environ['IKAR_UPDATE_ARGUS_SLUG'] +app_name = os.environ['IKAR_UPDATE_ARGUS_NAME'] +summary = os.environ['IKAR_UPDATE_ARGUS_SUMMARY'] +description = os.environ['IKAR_UPDATE_ARGUS_DESCRIPTION'] +homepage_url = os.environ['IKAR_UPDATE_ARGUS_HOMEPAGE_URL'] +release_version = os.environ['IKAR_UPDATE_ARGUS_VERSION'] +channel = os.environ['IKAR_UPDATE_ARGUS_CHANNEL'] +platform = os.environ['IKAR_UPDATE_ARGUS_PLATFORM'] +package_kind = os.environ['IKAR_UPDATE_ARGUS_PACKAGE_KIND'] +notes = os.environ['IKAR_UPDATE_ARGUS_NOTES'] +temp_root = f"/home/{username}/.argus-upload" + +client = paramiko.SSHClient() +client.set_missing_host_key_policy(paramiko.AutoAddPolicy()) +client.connect(host, username=username, password=password, timeout=10) +sftp = client.open_sftp() + +with sftp.open(env_path, 'r') as handle: + content = handle.read().decode('utf-8', errors='replace') + +values = {} +for raw_line in content.splitlines(): + line = raw_line.strip() + if not line or line.startswith('#') or '=' not in line: + continue + key, value = line.split('=', 1) + values[key.strip()] = value.strip() + +username = values.get('Admin__Username') +password = values.get('Admin__Password') +if not username or not password: + raise RuntimeError(f"Admin credentials were not found in {env_path}.") + +parts = temp_root.strip('/').split('/') +current = '/' +for part in parts: + current = posixpath.join(current, part) + try: + sftp.stat(current) + except FileNotFoundError: + sftp.mkdir(current) + +remote_apk_path = posixpath.join(temp_root, remote_apk_name) +sftp.put(local_apk_path, remote_apk_path) +sftp.close() + +def run_remote_python(script: str) -> str: + stdin, stdout, stderr = client.exec_command("python3 -", timeout=600) + stdin.write(script) + stdin.channel.shutdown_write() + out = stdout.read().decode('utf-8', errors='replace') + err = stderr.read().decode('utf-8', errors='replace') + exit_status = stdout.channel.recv_exit_status() + if exit_status != 0: + raise RuntimeError(f"Remote publish failed with exit status {exit_status}\\nSTDOUT:\\n{out}\\nSTDERR:\\n{err}") + return out + +remote_payload = json.dumps({ + 'base_url': argus_internal_base_url.rstrip('/'), + 'slug': slug, + 'package_path': remote_apk_path, + 'admin_username': username, + 'admin_password': password, + 'app_name': app_name, + 'summary': summary, + 'description': description, + 'homepage_url': homepage_url, + 'release_version': release_version, + 'release_channel': channel, + 'release_platform': platform, + 'release_package_kind': package_kind, + 'release_notes': notes, +}) + +remote_script = ''' +import json +import os +import urllib.request +import uuid + +payload = json.loads(%s) +base_url = payload['base_url'] +slug = payload['slug'] +package_path = payload['package_path'] +admin_username = payload['admin_username'] +admin_password = payload['admin_password'] +app_name = payload['app_name'] +summary = payload['summary'] +description = payload['description'] +homepage_url = payload['homepage_url'] +release_version = payload['release_version'] +release_channel = payload['release_channel'] +release_platform = payload['release_platform'] +release_package_kind = payload['release_package_kind'] +release_notes = payload['release_notes'] + +def read_response(response): + body = response.read().decode('utf-8', errors='replace') + return response.getcode(), body + +opener = urllib.request.build_opener() + +login_payload = json.dumps({ + 'username': admin_username, + 'password': admin_password, +}).encode('utf-8') +login_request = urllib.request.Request( + f"{base_url}/api/admin/session/login", + data=login_payload, + headers={'Content-Type': 'application/json'}, + method='POST') +login_response = opener.open(login_request, timeout=600) +login_status, login_body = read_response(login_response) +if login_status < 200 or login_status >= 300: + raise RuntimeError(f"Login failed with status {login_status}: {login_body}") + +cookie_header = login_response.headers.get('Set-Cookie', '') +auth_cookie = cookie_header.split(';', 1)[0].strip() +if not auth_cookie: + raise RuntimeError('Argus login did not return an auth cookie.') + +metadata_payload = json.dumps({ + 'name': app_name, + 'summary': summary, + 'description': description, + 'homepageUrl': homepage_url or None, + 'repositoryUrl': None, + 'isListed': True, +}).encode('utf-8') +metadata_request = urllib.request.Request( + f"{base_url}/api/admin/apps/{slug}", + data=metadata_payload, + headers={ + 'Content-Type': 'application/json', + 'Cookie': auth_cookie, + }, + method='PUT') +metadata_response = opener.open(metadata_request, timeout=600) +metadata_status, metadata_body = read_response(metadata_response) +if metadata_status < 200 or metadata_status >= 300: + raise RuntimeError(f"Metadata update failed with status {metadata_status}: {metadata_body}") + +with open(package_path, 'rb') as handle: + file_bytes = handle.read() + +boundary = f"----IkarArgusBoundary{uuid.uuid4().hex}" +parts: list[bytes] = [] + +def add_field(name: str, value: str): + parts.append(f"--{boundary}\\r\\n".encode('utf-8')) + parts.append(f'Content-Disposition: form-data; name="{name}"\\r\\n\\r\\n'.encode('utf-8')) + parts.append(value.encode('utf-8')) + parts.append(b"\\r\\n") + +for field_name, field_value in [ + ('Version', release_version), + ('Channel', release_channel), + ('Platform', release_platform), + ('PackageKind', release_package_kind), +]: + add_field(field_name, field_value) + +if release_notes: + add_field('Notes', release_notes) + +file_name = os.path.basename(package_path) +parts.append(f"--{boundary}\\r\\n".encode('utf-8')) +parts.append( + f'Content-Disposition: form-data; name="Package"; filename="{file_name}"\\r\\n'.encode('utf-8')) +parts.append(b"Content-Type: application/vnd.android.package-archive\\r\\n\\r\\n") +parts.append(file_bytes) +parts.append(b"\\r\\n") +parts.append(f"--{boundary}--\\r\\n".encode('utf-8')) + +release_request = urllib.request.Request( + f"{base_url}/api/admin/apps/{slug}/releases", + data=b''.join(parts), + headers={ + 'Content-Type': f'multipart/form-data; boundary={boundary}', + 'Cookie': auth_cookie, + }, + method='POST') +release_response = opener.open(release_request, timeout=600) +release_status, release_body = read_response(release_response) +if release_status < 200 or release_status >= 300: + raise RuntimeError(f"Release upload failed with status {release_status}: {release_body}") + +print(release_body) +''' % json.dumps(remote_payload) + +publish_output = run_remote_python(remote_script) + +try: + sftp = client.open_sftp() + sftp.remove(remote_apk_path) + sftp.close() +except Exception: + pass + +client.close() +print(json.dumps({'status': 'UPLOAD_OK', 'release': publish_output.strip()})) +"@ | ForEach-Object { + $env:IKAR_UPDATE_HOST = $ServerHost + $env:IKAR_UPDATE_USERNAME = $Username + $env:IKAR_UPDATE_PASSWORD = $Password + $env:IKAR_UPDATE_ARGUS_ENV_PATH = $ArgusEnvPath + $env:IKAR_UPDATE_APK_PATH = (Resolve-Path $localCopy) + $env:IKAR_UPDATE_REMOTE_APK_NAME = $remoteFileName + $env:IKAR_UPDATE_ARGUS_INTERNAL_BASE_URL = $ArgusInternalBaseUrl + $env:IKAR_UPDATE_ARGUS_SLUG = $Slug + $env:IKAR_UPDATE_ARGUS_NAME = $AppName + $env:IKAR_UPDATE_ARGUS_SUMMARY = $Summary + $env:IKAR_UPDATE_ARGUS_DESCRIPTION = $Description + $env:IKAR_UPDATE_ARGUS_HOMEPAGE_URL = $HomepageUrl + $env:IKAR_UPDATE_ARGUS_VERSION = "$versionName+$versionCode" + $env:IKAR_UPDATE_ARGUS_CHANNEL = $Channel + $env:IKAR_UPDATE_ARGUS_PLATFORM = $Platform + $env:IKAR_UPDATE_ARGUS_PACKAGE_KIND = $PackageKind + $env:IKAR_UPDATE_ARGUS_NOTES = $Notes + $_ +} | python - + +$publishResult = $argusPublishResult | ConvertFrom-Json +if ($publishResult.status -ne 'UPLOAD_OK') { + throw "Argus publish failed." +} + +Write-Output $publishResult.status diff --git a/scripts/publish-windows.ps1 b/scripts/publish-windows.ps1 new file mode 100644 index 00000000..ebfae336 --- /dev/null +++ b/scripts/publish-windows.ps1 @@ -0,0 +1,15 @@ +$ErrorActionPreference = 'Stop' +Set-Location (Join-Path $PSScriptRoot '..') +$project = '.\src\Ikar.WinUI\Ikar.WinUI.csproj' +$target = '.\artifacts\winui-win64' + +dotnet build $project -c Release -f net10.0-windows10.0.19041.0 -p:Platform=x64 + +if (Test-Path $target) { + Remove-Item $target -Recurse -Force +} + +New-Item -ItemType Directory -Path $target | Out-Null +Copy-Item '.\src\Ikar.WinUI\bin\x64\Release\net10.0-windows10.0.19041.0\win-x64\*' $target -Recurse -Force +Get-ChildItem $target -Recurse | Where-Object { $_.Name -like 'Massenger*' } | Remove-Item -Recurse -Force +Write-Host "Windows client prepared at $target\Ikar.WinUI.exe" diff --git a/scripts/run-server.ps1 b/scripts/run-server.ps1 new file mode 100644 index 00000000..eed59b38 --- /dev/null +++ b/scripts/run-server.ps1 @@ -0,0 +1,3 @@ +$ErrorActionPreference = 'Stop' +Set-Location (Join-Path $PSScriptRoot '..') +dotnet run --project .\src\Ikar.Server\Ikar.Server.csproj diff --git a/scripts/smoke-test.ps1 b/scripts/smoke-test.ps1 new file mode 100644 index 00000000..7f5e6263 --- /dev/null +++ b/scripts/smoke-test.ps1 @@ -0,0 +1,190 @@ +$ErrorActionPreference = 'Stop' +Add-Type -AssemblyName System.Net.Http + +$baseUrl = 'http://localhost:5099' +$stamp = Get-Date -Format 'yyyyMMddHHmmss' +$username = "smoke$stamp" +$password = 'Demo123!' +$phoneNumber = "+7900$stamp" + +$session = Invoke-RestMethod ` + -Uri "$baseUrl/api/auth/register" ` + -Method Post ` + -ContentType 'application/json' ` + -Body (@{ + username = $username + displayName = 'Smoke Test' + password = $password + phoneNumber = $phoneNumber + } | ConvertTo-Json) + +$refreshed = Invoke-RestMethod ` + -Uri "$baseUrl/api/auth/refresh" ` + -Method Post ` + -ContentType 'application/json' ` + -Body (@{ + refreshToken = $session.refreshToken + } | ConvertTo-Json) + +$headers = @{ + Authorization = "Bearer $($refreshed.accessToken)" +} + +$installationId = "push-$stamp" +$pushDevice = Invoke-RestMethod ` + -Uri "$baseUrl/api/push/devices" ` + -Headers $headers ` + -Method Post ` + -ContentType 'application/json' ` + -Body (@{ + platform = 1 + installationId = $installationId + deviceToken = "token-$stamp" + deviceName = "Smoke Android $stamp" + notificationsEnabled = $true + } | ConvertTo-Json) + +$pushDevicesAfterRegister = Invoke-RestMethod ` + -Uri "$baseUrl/api/push/devices" ` + -Headers $headers ` + -Method Get +if ($null -eq $pushDevicesAfterRegister) { + $pushDevicesAfterRegister = @() +} +else { + $pushDevicesAfterRegister = @($pushDevicesAfterRegister) +} + +$bob = Invoke-RestMethod -Uri "$baseUrl/api/users/search?q=bob" -Headers $headers -Method Get | Select-Object -First 1 +$channel = Invoke-RestMethod ` + -Uri "$baseUrl/api/chats/channel" ` + -Headers $headers ` + -Method Post ` + -ContentType 'application/json' ` + -Body (@{ + title = "Smoke Channel $stamp" + memberIds = @($bob.id) + } | ConvertTo-Json) + +$message = Invoke-RestMethod ` + -Uri "$baseUrl/api/chats/$($channel.id)/messages" ` + -Headers $headers ` + -Method Post ` + -ContentType 'application/json' ` + -Body (@{ text = 'channel smoke message' } | ConvertTo-Json) + +$edited = Invoke-RestMethod ` + -Uri "$baseUrl/api/chats/$($channel.id)/messages/$($message.id)" ` + -Headers $headers ` + -Method Put ` + -ContentType 'application/json' ` + -Body (@{ text = 'channel smoke message edited' } | ConvertTo-Json) + +$tempFile = Join-Path $env:TEMP "ikar-voice-$stamp.ogg" +"voice smoke test $stamp" | Set-Content -Path $tempFile -NoNewline + +$handler = New-Object System.Net.Http.HttpClientHandler +$client = New-Object System.Net.Http.HttpClient($handler) +$client.BaseAddress = [Uri]::new("$baseUrl/") +$client.DefaultRequestHeaders.Authorization = New-Object System.Net.Http.Headers.AuthenticationHeaderValue('Bearer', $refreshed.accessToken) +$form = New-Object System.Net.Http.MultipartFormDataContent +$form.Add((New-Object System.Net.Http.StringContent('voice caption')), 'text') +$fileStream = [System.IO.File]::OpenRead($tempFile) +$fileContent = New-Object System.Net.Http.StreamContent($fileStream) +$fileContent.Headers.ContentType = New-Object System.Net.Http.Headers.MediaTypeHeaderValue('audio/ogg') +$form.Add($fileContent, 'file', [System.IO.Path]::GetFileName($tempFile)) +$uploadResponse = $client.PostAsync("api/chats/$($channel.id)/attachments", $form).GetAwaiter().GetResult() +$uploadPayload = $uploadResponse.Content.ReadAsStringAsync().GetAwaiter().GetResult() +$fileStream.Dispose() +$form.Dispose() +$client.Dispose() +$handler.Dispose() + +if (-not $uploadResponse.IsSuccessStatusCode) { + throw $uploadPayload +} + +$voiceMessage = $uploadPayload | ConvertFrom-Json +$attachment = $voiceMessage.attachments[0] +$downloadHandler = New-Object System.Net.Http.HttpClientHandler +$downloadClient = New-Object System.Net.Http.HttpClient($downloadHandler) +$downloadClient.BaseAddress = [Uri]::new("$baseUrl/") +$downloadClient.DefaultRequestHeaders.Authorization = New-Object System.Net.Http.Headers.AuthenticationHeaderValue('Bearer', $refreshed.accessToken) +$downloadResponse = $downloadClient.GetAsync($attachment.downloadPath.TrimStart('/')).GetAwaiter().GetResult() +$downloadBytes = $downloadResponse.Content.ReadAsByteArrayAsync().GetAwaiter().GetResult() +$downloadClient.Dispose() +$downloadHandler.Dispose() + +$deleted = Invoke-RestMethod ` + -Uri "$baseUrl/api/chats/$($channel.id)/messages/$($message.id)" ` + -Headers $headers ` + -Method Delete + +$bobSession = Invoke-RestMethod ` + -Uri "$baseUrl/api/auth/login" ` + -Method Post ` + -ContentType 'application/json' ` + -Body (@{ + username = 'bob' + password = 'demo123' + } | ConvertTo-Json) + +$bobHeaders = @{ + Authorization = "Bearer $($bobSession.accessToken)" +} + +$subscriberBlocked = $false +try { + Invoke-RestMethod ` + -Uri "$baseUrl/api/chats/$($channel.id)/messages" ` + -Headers $bobHeaders ` + -Method Post ` + -ContentType 'application/json' ` + -Body (@{ text = 'subscriber should not publish' } | ConvertTo-Json) | Out-Null +} +catch { + $subscriberBlocked = $_.Exception.Response.StatusCode.value__ -eq 403 +} + +$channelForBob = Invoke-RestMethod -Uri "$baseUrl/api/chats/$($channel.id)" -Headers $bobHeaders -Method Get +$channelReload = Invoke-RestMethod -Uri "$baseUrl/api/chats/$($channel.id)" -Headers $headers -Method Get +Invoke-RestMethod -Uri "$baseUrl/api/push/devices/$installationId" -Headers $headers -Method Delete | Out-Null +$pushDevicesAfterDelete = Invoke-RestMethod ` + -Uri "$baseUrl/api/push/devices" ` + -Headers $headers ` + -Method Get +if ($null -eq $pushDevicesAfterDelete) { + $pushDevicesAfterDelete = @() +} +else { + $pushDevicesAfterDelete = @($pushDevicesAfterDelete) +} +Invoke-RestMethod -Uri "$baseUrl/api/auth/logout" -Headers $headers -Method Post | Out-Null + +$logoutBlocked = $false +try { + Invoke-RestMethod -Uri "$baseUrl/api/users/me" -Headers $headers -Method Get | Out-Null +} +catch { + $logoutBlocked = $_.Exception.Response.StatusCode.value__ -eq 401 +} + +[pscustomobject]@{ + Username = $username + ChannelId = $channel.id + EditedMessageId = $edited.id + EditedText = $edited.text + DeletedFlag = $null -ne $deleted.deletedAt + VoiceAttachmentId = $attachment.id + VoiceKind = $attachment.kind + VoiceKindOk = ($attachment.kind -eq 2) -or ($attachment.kind -eq 'VoiceNote') + DownloadOk = [int]$downloadResponse.StatusCode -eq 200 -and $downloadBytes.Length -eq $attachment.fileSizeBytes + PushRegistered = $pushDevice.installationId -eq $installationId + PushListed = $pushDevicesAfterRegister.Count -eq 1 -and $pushDevicesAfterRegister[0].installationId -eq $installationId + PushDeleted = $pushDevicesAfterDelete.Count -eq 0 + MessageCount = $channelReload.messages.Count + SubscriberBlocked = $subscriberBlocked + ChannelReadOnly = -not $channelForBob.canSendMessages + RefreshRotated = $session.refreshToken -ne $refreshed.refreshToken + LogoutBlocked = $logoutBlocked +} diff --git a/src/Ikar.AndroidKotlin/.gitignore b/src/Ikar.AndroidKotlin/.gitignore new file mode 100644 index 00000000..73fb1c8d --- /dev/null +++ b/src/Ikar.AndroidKotlin/.gitignore @@ -0,0 +1,6 @@ +.gradle/ +build/ +app/build/ +local.properties +argus-signing.local.properties +signing/ diff --git a/src/Ikar.AndroidKotlin/ARGUS_PUBLISHING_REQUIREMENTS.md b/src/Ikar.AndroidKotlin/ARGUS_PUBLISHING_REQUIREMENTS.md new file mode 100644 index 00000000..21d4b442 --- /dev/null +++ b/src/Ikar.AndroidKotlin/ARGUS_PUBLISHING_REQUIREMENTS.md @@ -0,0 +1,372 @@ +# Требования к публикации приложений в Argus + +Этот документ фиксирует текущие требования публикации на основе фактического кода `Argus` и Android-клиента `Argus Store` в этом репозитории. Он разделяет: + +1. жёсткие требования, которые реально проверяет сервер; +2. дополнительные требования, без которых приложение не будет нормально устанавливаться через `Argus Store`; +3. условия, при которых `Argus Store` сможет предлагать автообновления. + +Если цель публикации именно в Android-клиент `Argus Store`, нужно одновременно соблюдать и серверные требования, и Android-специфичные требования из этого документа. + +Источники: `scripts/publish-release.ps1:2-139`, `Program.cs:179-239`, `Program.cs:335-661`, `Contracts/ArgusDtos.cs:3-96`, `Infrastructure/SlugUtility.cs:7-39`, `Infrastructure/IdentifierUtility.cs:7-38`, `Infrastructure/SemanticVersionUtility.cs:5-240`, `Infrastructure/PackageStorageService.cs:8-97`, `android/app/src/main/java/xyz/kusoft/argusstore/StoreModels.kt:12-110`, `android/app/src/main/java/xyz/kusoft/argusstore/StoreCatalogLoader.kt:7-34`, `android/app/src/main/java/xyz/kusoft/argusstore/PackageInstallerManager.kt:15-128`, `android/app/src/main/java/xyz/kusoft/argusstore/PackageStateResolver.kt:11-252`, `README.md:13-18`, `README.md:74-113`. + +## 1. Что ИИ обязан подготовить перед публикацией + +Перед публикацией ИИ должен выдать: + +- путь к одному готовому release-файлу; +- `slug`; +- `name`; +- `summary`; +- `description`; +- `version`; +- `channel`; +- `platform`; +- `packageKind`; +- `notes` при наличии; +- `repositoryUrl` при наличии; +- `homepageUrl` при наличии; +- `androidPackageName` для Android-релизов; +- `androidVersionCode` для Android-релизов; +- решение, будет ли приложение публичным (`isListed=true`) или скрытым (`isListed=false`). + +Причина: именно эти поля использует текущий publish-скрипт и серверные DTO. +Источник: `scripts/publish-release.ps1:2-46`, `Contracts/ArgusDtos.cs:62-94`. + +## 2. Жёсткие серверные требования + +### 2.1. Требования к `slug` + +- `slug` обязателен. +- Разрешены только строчные латинские буквы, цифры и дефисы. +- Максимальная длина `slug` — `100` символов. +- Значение должно уже быть нормализовано; сервер не принимает произвольный mixed-case `slug`. + +Фактическое правило валидации: + +- `SlugUtility.Normalize(...)` переводит строку в lowercase, заменяет любые неалфавитно-цифровые символы на `-`, схлопывает повторные дефисы и обрезает дефисы по краям. +- `SlugUtility.IsValid(...)` принимает только строки, которые уже совпадают со своим нормализованным вариантом и имеют длину до `100`. + +Источник: `Infrastructure/SlugUtility.cs:7-39`, `Program.cs:341-345`, `Program.cs:393-397`. + +### 2.2. Обязательные поля метаданных приложения + +Сервер отклоняет публикацию метаданных, если отсутствует хотя бы одно из полей: + +- `name` +- `summary` +- `description` + +Пустые строки и строки из одних пробелов считаются отсутствующими. + +Источник: `Program.cs:372-379`, `Program.cs:582-607`. + +### 2.3. `androidPackageName` + +- Для не-Android публикаций поле опционально. +- Для Android-публикаций поле обязательно на уровне карточки приложения до загрузки релиза. +- Формат должен быть похож на Java package name. + +Фактические правила проверки: + +- минимум 2 сегмента, разделённых точками; +- строка не может начинаться или заканчиваться точкой; +- каждый сегмент должен начинаться с буквы или `_`; +- в сегментах допустимы только буквы, цифры и `_`. + +Примеры подходящего формата: + +- `com.example.app` +- `xyz.kusoft.aletheia` + +Примеры, которые сервер отвергнет: + +- `App` +- `.com.example` +- `com.example.` +- `com.example.my-app` + +Источник: `Program.cs:423-429`, `Program.cs:601-605`, `Program.cs:639-656`. + +### 2.4. Обязательные поля релиза + +Сервер отклоняет загрузку релиза, если отсутствует хотя бы одно из полей: + +- `version` +- `package` (сам файл) + +Пустой файл тоже отклоняется. + +Источник: `Program.cs:401-405`, `Program.cs:616-624`. + +### 2.5. Когда `androidVersionCode` обязателен + +`androidVersionCode` обязателен, если сервер считает релиз Android-артефактом. Сейчас это происходит, когда выполняется хотя бы одно условие: + +- `platform` содержит подстроку `android`, или +- `packageKind` равен `apk`, `xapk` или `apks`. + +Дополнительные правила: + +- если `androidVersionCode` передан, он должен быть положительным целым числом; +- для Android-артефактов `androidVersionCode` не может быть `null`. + +Источник: `Program.cs:418-420`, `Program.cs:423-429`, `Program.cs:613-633`, `Program.cs:659-661`. + +### 2.6. Ограничение на дубликаты релизов + +Сервер не разрешает второй релиз с тем же набором: + +- `slug` +- `version` +- `channel` +- `platform` + +`packageKind` в этом конфликте не участвует. Это означает, что нельзя загрузить два разных файла с одинаковыми `version + channel + platform` для одного и того же приложения. + +Если нужны разные ABI-сборки одной версии, они должны отличаться именно значением `platform`. + +Источник: `Program.cs:432-444`. + +### 2.7. Публичность приложения + +- В publish-скрипте приложение по умолчанию публикуется как публичное (`isListed=true`). +- Если использовать ключ `-Unlisted`, сервер сохранит `isListed=false`. +- Публичные API `/api/apps`, `/api/apps/{slug}` и `/api/apps/{slug}/manifest` возвращают только `IsListed` приложения. + +Практический вывод: если приложение должно появиться в `Argus Store`, его нельзя публиковать как `Unlisted`. + +Источник: `scripts/publish-release.ps1:30`, `scripts/publish-release.ps1:59-67`, `Program.cs:179-239`, `Program.cs:378`. + +## 3. Что нужно именно для Android-клиента Argus Store + +Ниже не только серверные требования, но и дополнительные условия текущего Android-клиента. Без них релиз может загрузиться на сервер, но приложение либо не появится в клиенте, либо будет помечено как `Unsupported`, либо не сможет установиться. + +Источник: `android/app/src/main/java/xyz/kusoft/argusstore/StoreCatalogLoader.kt:7-34`, `StoreModels.kt:12-110`, `PackageInstallerManager.kt:15-128`, `PackageStateResolver.kt:11-129`. + +### 3.1. Для установки через Argus Store нужен именно `apk` + +Текущий Android-клиент считает установимыми только релизы, у которых: + +- `packageKind == "apk"` + +`xapk` и `apks` сейчас сервер распознаёт как Android-артефакты для валидации, но Android-клиент их не выбирает как installable release. + +Практическое требование: если приложение должно устанавливаться через `Argus Store`, публикуй обычный `.apk` и ставь `packageKind=apk`. + +Источник: `Program.cs:659-661`, `android/app/src/main/java/xyz/kusoft/argusstore/StoreModels.kt:27-29`, `StoreModels.kt:72-82`. + +### 3.2. Для Android-клиента `androidPackageName` должен быть заполнен + +Если у карточки приложения отсутствует `androidPackageName`, клиент помечает приложение как неподдерживаемое для установки. + +Источник: `android/app/src/main/java/xyz/kusoft/argusstore/PackageStateResolver.kt:12-21`. + +### 3.3. `androidVersionCode` релиза должен совпадать с APK + +Перед отправкой APK в `PackageInstaller` клиент читает скачанный APK и сверяет: + +- `archiveInfo.packageName == androidPackageName` +- `archive versionCode == androidVersionCode` + +Если хотя бы одна проверка не проходит, установка отклоняется. + +Практическое требование: публикуемый `androidPackageName` должен точно совпадать с `applicationId`/package name внутри APK, а `androidVersionCode` — точно с `versionCode` внутри APK. + +Источник: `android/app/src/main/java/xyz/kusoft/argusstore/PackageInstallerManager.kt:24-55`. + +### 3.4. Требования к `platform` для подбора совместимого APK + +Клиент выбирает релиз как совместимый, если: + +- `packageKind == "apk"`, и +- `platform` соответствует одному из распознаваемых вариантов. + +Релиз считается универсальным, если `platform` равен: + +- `android` +- `android-universal` +- `android-generic` +- `generic` + +Для ABI-специфичных APK клиент ищет в `platform` одну из подстрок, связанных с ABI устройства: + +- `arm64` +- `arm64-v8a` +- `android-arm64` +- `android-arm64-v8a` +- `armv7` +- `armeabi-v7a` +- `android-armv7` +- `android-armeabi-v7a` +- `x86_64` +- `android-x86_64` +- `x86` +- `android-x86` + +Рекомендация для предсказуемости публикации: + +- universal APK: использовать `platform=android-universal`; +- ARM64 APK: использовать `platform=android-arm64`; +- ARMv7 APK: использовать `platform=android-armv7`; +- x86_64 APK: использовать `platform=android-x86_64`; +- x86 APK: использовать `platform=android-x86`. + +Первые два блока выше — фактическое поведение клиента. Последний блок — рекомендация для единообразия, а не серверная проверка. + +Источник: `android/app/src/main/java/xyz/kusoft/argusstore/StoreModels.kt:72-110`. + +### 3.5. Как клиент выбирает предпочитаемый APK + +Среди совместимых installable APK клиент выбирает релиз по такому порядку: + +1. больший `androidVersionCode`; +2. более поздний `publishedAt`; +3. большее строковое `version`. + +Практический вывод: для Android-обновлений нельзя полагаться только на красивый `version`; нужно монотонно увеличивать `androidVersionCode`. + +Источник: `android/app/src/main/java/xyz/kusoft/argusstore/StoreModels.kt:72-82`, `PackageStateResolver.kt:44-59`. + +## 4. Что нужно для корректной работы обновлений + +### 4.1. Для обычного обнаружения обновлений + +Чтобы клиент видел, что доступно обновление, нужны одновременно: + +- установленный пакет с тем же `androidPackageName`; +- опубликованный совместимый APK; +- у релиза должен быть заполнен `androidVersionCode`; +- новый `androidVersionCode` должен быть больше установленного на устройстве. + +Источник: `android/app/src/main/java/xyz/kusoft/argusstore/PackageStateResolver.kt:33-59`. + +### 4.2. Для best-effort автообновлений без ручного подтверждения + +Текущий клиент считает автообновление потенциально возможным только если одновременно выполняются все условия: + +- приложение уже установлено; +- опубликованный APK имеет `androidVersionCode`; +- в настройках Android разрешены установки из `Argus Store`; +- устройство работает минимум на Android 12 / API 31; +- установленное приложение было установлено именно `Argus Store`; +- `targetSdkVersion` установленного приложения не ниже минимального значения для текущего API устройства. + +Минимальные `targetSdkVersion`, которые сейчас проверяет клиент: + +- API 31-32: `29` +- API 33: `30` +- API 34: `31` +- API 35: `33` +- API 36 и выше: `34` + +Важно: + +- эти условия не гарантируют полностью бесшумное обновление на любом устройстве; +- это только текущая эвристика клиента, при которой он считает no-touch update допустимым к попытке. + +Источник: `android/app/src/main/java/xyz/kusoft/argusstore/PackageStateResolver.kt:76-129`, `PackageStateResolver.kt:244-252`. + +## 5. Рекомендации по версии и артефакту + +### 5.1. `version` + +Сервер выбирает `latest` релиз по semantic version precedence, если обе сравниваемые версии парсятся как semver. Если хотя бы одна версия не парсится, сравнение откатывается к обычному case-insensitive строковому сравнению. + +Практическая рекомендация: использовать semver-совместимые версии: + +- `1.0.0` +- `1.2.3` +- `1.2.3-beta.1` +- `v1.2.3` + +Это рекомендация для предсказуемого выбора `latest`, а не отдельная серверная валидация. + +Источник: `Infrastructure/SemanticVersionUtility.cs:5-240`, `Program.cs:563-567`, `README.md:20`. + +### 5.2. Расширение файла + +Сервер сохраняет любой непустой загруженный файл и вычисляет для него: + +- `SHA-256` +- размер в байтах +- исходное имя файла +- content type + +Но текущий publish-скрипт автоматически выставляет специальный MIME type только для файлов с расширением `.apk`. Для остальных расширений он ставит `application/octet-stream`. + +Практическое требование для Android-публикаций: использовать реальный `.apk` файл. + +Источник: `Infrastructure/PackageStorageService.cs:8-97`, `scripts/publish-release.ps1:111-121`. + +## 6. Шаблон brief'а для другого ИИ + +Ниже шаблон, который можно отдавать ИИ как входное задание. Он построен только из полей и ограничений, которые реально используются текущим `Argus`. + +```yaml +argusPublication: + target: "Argus" + mustBeVisibleInArgusStore: true + + app: + slug: "" + name: "" + summary: "" + description: "" + repositoryUrl: "" + homepageUrl: "" + isListed: true + + android: + androidPackageName: "" + versionName: "" + versionCode: + targetSdkVersion: + abiKind: "" + + release: + version: "" + channel: "stable" + platform: "" + packageKind: "apk" + notes: "" + packagePath: "" + + mandatoryChecks: + - "APK exists and is non-empty." + - "APK package name exactly matches androidPackageName." + - "APK versionCode exactly matches androidVersionCode." + - "If the app should appear in Argus Store, isListed must be true." + - "No existing Argus release uses the same slug + version + channel + platform." + - "If this is an update, androidVersionCode is greater than the already published Android version code." +``` + +## 7. Минимальная команда публикации + +Текущий стандартный путь публикации в этом репозитории — `scripts/publish-release.ps1`, который: + +1. логинится в admin API; +2. делает `PUT /api/admin/apps/{slug}`; +3. делает `POST /api/admin/apps/{slug}/releases`. + +Минимальный пример для Android APK: + +```powershell +.\scripts\publish-release.ps1 ` + -BaseUrl "https://argus.kusoft.xyz" ` + -Username "" ` + -Password "" ` + -Slug "" ` + -Name "" ` + -Summary "" ` + -Description "" ` + -AndroidPackageName "" ` + -Version "" ` + -Channel "stable" ` + -Platform "android-universal" ` + -PackageKind "apk" ` + -AndroidVersionCode ` + -PackagePath "" +``` + +Источник: `scripts/publish-release.ps1:53-130`, `README.md:74-90`. diff --git a/src/Ikar.AndroidKotlin/MIGRATION_STATUS.md b/src/Ikar.AndroidKotlin/MIGRATION_STATUS.md new file mode 100644 index 00000000..9f2ec473 --- /dev/null +++ b/src/Ikar.AndroidKotlin/MIGRATION_STATUS.md @@ -0,0 +1,79 @@ +# Migration Status + +Источник переносимого клиента: +- `G:\Repos\Massenger\src\Ikar.Client` +- `G:\Repos\Massenger\src\Ikar.Client\Platforms\Android` + +## Уже поднято в Kotlin-проекте +- Gradle/AGP/Compose проект +- Session storage +- HTTP API client +- Прямой update manifest через Argus +- Phone auth: + - `request-code` + - `verify-code` + - рабочий login screen +- Chats screen +- Chat screen +- Realtime SignalR для: + - `MessageCreated` + - `MessageUpdated` + - `ChatCreated` + - `ChatsChanged` + - `AppUpdateAvailable` + - `IncomingCall` + - `CallConnected` + - `CallEnded` + - `CallOffer` + - `CallAnswer` + - `CallIceCandidate` +- Push/notification контур: + - Firebase manual bootstrap from `firebase.android.json` + - FCM token refresh service + - device registration to `/api/push/devices` + - Android notification channels + - notification display for message/update/call pushes + - живая push-проверка на телефоне +- Call state manager +- Call overlay screen +- Исходящий audio/video call signaling через SignalR +- Android WebRTC media pipeline для звонков: + - `PeerConnectionFactory` + - `PeerConnection` + - audio track capture/playback + - video capturer и renderer для call screen +- Profile screen: + - загрузка `me` + - редактирование display name / about + - upload/delete avatar +- Contacts screen +- Discover screen +- Forward screen +- Settings root screen +- Notification settings screen +- Bots list screen +- Bot editor screen: + - create + - update + - commands + - regenerate token + - open direct chat with bot + +## Остаётся перенести +- Media viewer / attachments upload +- Voice notes +- User profile screen для чужого пользователя +- Полный settings tree из MAUI-клиента +- Group/channel creation flow в Discover +- Runtime permission flow для microphone/camera/read contacts в полном UX-виде +- Чистую Firebase default initialization без warning в логах +- End-to-end верификацию media calls на авторизованном Kotlin-клиенте + +## Следующий практический этап +- проверить новые Kotlin-экраны вручную на телефоне +- затем перенести: + - media viewer + - attachments upload + - voice notes + - user profile + - полный settings tree diff --git a/src/Ikar.AndroidKotlin/README.md b/src/Ikar.AndroidKotlin/README.md new file mode 100644 index 00000000..b999d7cb --- /dev/null +++ b/src/Ikar.AndroidKotlin/README.md @@ -0,0 +1,39 @@ +# Ikar Android Kotlin + +Отдельный Android-клиент на Kotlin/Jetpack Compose, создаваемый как поэтапная замена текущего MAUI-клиента. + +Что уже перенесено в этом проекте: +- базовая Gradle/Android-структура +- Compose-приложение с экранами `Login`, `Chats`, `Chat` +- HTTP-клиент к `ikar.kusoft.xyz` +- хранение сессии +- прямой Android update manifest через `argus.kusoft.xyz` +- базовая модель данных, совместимая с текущими DTO `Ikar.Shared` + +Что еще не перенесено: +- push/FCM +- SignalR realtime и звонки +- запись и проигрывание voice notes +- загрузка вложений и аватаров в полном объеме +- контакты телефона, профиль, настройки, боты и остальная функциональность MAUI-клиента + +Текущее состояние нужно считать рабочим стартом переписывания, а не полным feature parity. + +## Подготовка к публикации в Argus + +- Локальная release-подпись хранится в gitignored файлах `argus-signing.local.properties` и `signing/`. +- Единая координата публикации для клиента Икар: slug `ikar-android`, channel `stable`, platform `android`. + Именно этот manifest запрашивают текущий Android-клиент и серверный proxy обновлений. +- Первый запуск локального signing pipeline: + - `.\scripts\new-argus-upload-keystore.ps1` +- Подготовка signed APK и metadata JSON для Argus: + - `.\scripts\prepare-argus-publication.ps1` + +Скрипт подготовки собирает `release` APK, проверяет подпись и извлекает из самого APK: +- `androidPackageName` +- `androidVersionCode` +- `version` +- `targetSdkVersion` +- ABI/`platform` + +После этого он складывает готовый APK и metadata JSON в `build/argus-publication/v/`. diff --git a/src/Ikar.AndroidKotlin/app/build.gradle.kts b/src/Ikar.AndroidKotlin/app/build.gradle.kts new file mode 100644 index 00000000..577622dc --- /dev/null +++ b/src/Ikar.AndroidKotlin/app/build.gradle.kts @@ -0,0 +1,122 @@ +import java.util.Properties + +plugins { + id("com.android.application") + id("com.google.devtools.ksp") + id("org.jetbrains.kotlin.plugin.compose") + id("org.jetbrains.kotlin.plugin.serialization") +} + +val argusSigningProperties = Properties().apply { + val propertiesFile = rootProject.file("argus-signing.local.properties") + if (propertiesFile.exists()) { + propertiesFile.inputStream().use(::load) + } +} + +fun argusSigningValue(propertyKey: String, envKey: String): String? = + (argusSigningProperties.getProperty(propertyKey) ?: System.getenv(envKey)) + ?.trim() + ?.takeIf { it.isNotEmpty() } + +val releaseStoreFilePath = argusSigningValue("storeFile", "IKAR_ARGUS_STORE_FILE") +val releaseStorePassword = argusSigningValue("storePassword", "IKAR_ARGUS_STORE_PASSWORD") +val releaseKeyAlias = argusSigningValue("keyAlias", "IKAR_ARGUS_KEY_ALIAS") +val releaseKeyPassword = argusSigningValue("keyPassword", "IKAR_ARGUS_KEY_PASSWORD") +val hasArgusReleaseSigning = + releaseStoreFilePath != null && + releaseStorePassword != null && + releaseKeyAlias != null && + releaseKeyPassword != null + +android { + namespace = "com.seven.ikar.kotlin" + compileSdk = 36 + + defaultConfig { + applicationId = "com.seven.ikar.kotlin" + minSdk = 24 + targetSdk = 36 + versionCode = 180 + versionName = "3.67" + + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + vectorDrawables.useSupportLibrary = true + } + + signingConfigs { + if (hasArgusReleaseSigning) { + create("argusRelease") { + storeFile = rootProject.file(releaseStoreFilePath!!) + storePassword = releaseStorePassword + keyAlias = releaseKeyAlias + keyPassword = releaseKeyPassword + } + } + } + + buildTypes { + release { + isMinifyEnabled = false + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro" + ) + if (hasArgusReleaseSigning) { + signingConfig = signingConfigs.getByName("argusRelease") + } + } + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + buildFeatures { + compose = true + buildConfig = true + } + + packaging { + resources { + excludes += "/META-INF/{AL2.0,LGPL2.1}" + } + } +} + +dependencies { + val composeBom = platform("androidx.compose:compose-bom:2026.03.00") + val roomVersion = "2.8.4" + + implementation("androidx.core:core-ktx:1.17.0") + implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.9.4") + implementation("androidx.lifecycle:lifecycle-runtime-compose:2.9.4") + implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.9.4") + implementation("androidx.activity:activity-compose:1.11.0") + implementation("androidx.fragment:fragment:1.3.0") + implementation("androidx.navigation:navigation-compose:2.9.1") + implementation("androidx.datastore:datastore-preferences:1.1.7") + implementation("androidx.room:room-runtime:$roomVersion") + implementation("androidx.room:room-ktx:$roomVersion") + implementation("androidx.work:work-runtime-ktx:2.11.0") + ksp("androidx.room:room-compiler:$roomVersion") + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.10.2") + implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.9.0") + implementation("com.squareup.okhttp3:okhttp:4.12.0") + implementation("com.squareup.okhttp3:logging-interceptor:4.12.0") + implementation("io.coil-kt:coil-compose:2.7.0") + implementation("com.microsoft.signalr:signalr:8.0.17") + implementation("com.google.firebase:firebase-messaging:25.0.1") + implementation("io.github.webrtc-sdk:android:144.7559.01") + + implementation(composeBom) + androidTestImplementation(composeBom) + implementation("androidx.compose.ui:ui") + implementation("androidx.compose.ui:ui-tooling-preview") + implementation("androidx.compose.foundation:foundation") + implementation("androidx.compose.material3:material3") + implementation("androidx.compose.material:material-icons-extended") + debugImplementation("androidx.compose.ui:ui-tooling") + debugImplementation("androidx.compose.ui:ui-test-manifest") +} diff --git a/src/Ikar.AndroidKotlin/app/proguard-rules.pro b/src/Ikar.AndroidKotlin/app/proguard-rules.pro new file mode 100644 index 00000000..bce7e92d --- /dev/null +++ b/src/Ikar.AndroidKotlin/app/proguard-rules.pro @@ -0,0 +1 @@ +# Intentionally empty for the first migration pass. diff --git a/src/Ikar.AndroidKotlin/app/src/main/AndroidManifest.xml b/src/Ikar.AndroidKotlin/app/src/main/AndroidManifest.xml new file mode 100644 index 00000000..56d41882 --- /dev/null +++ b/src/Ikar.AndroidKotlin/app/src/main/AndroidManifest.xml @@ -0,0 +1,99 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Ikar.AndroidKotlin/app/src/main/assets/firebase.android.json b/src/Ikar.AndroidKotlin/app/src/main/assets/firebase.android.json new file mode 100644 index 00000000..9936b57d --- /dev/null +++ b/src/Ikar.AndroidKotlin/app/src/main/assets/firebase.android.json @@ -0,0 +1,6 @@ +{ + "applicationId": "1:91097833269:android:c123132a32a690ba19b67c", + "projectId": "ikar-3bdfb", + "apiKey": "AIzaSyCgjHUi4zFNRfwAwsiLBlRZCGup78N74ks", + "senderId": "91097833269" +} diff --git a/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/MainActivity.kt b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/MainActivity.kt new file mode 100644 index 00000000..59ae9bce --- /dev/null +++ b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/MainActivity.kt @@ -0,0 +1,307 @@ +package com.seven.ikar.kotlin + +import android.Manifest +import android.app.Activity +import android.app.KeyguardManager +import android.content.Intent +import android.graphics.Color +import android.net.Uri +import android.os.Build +import android.os.Bundle +import android.provider.Settings +import androidx.activity.ComponentActivity +import androidx.activity.result.contract.ActivityResultContracts +import androidx.activity.compose.setContent +import androidx.core.view.WindowCompat +import androidx.core.view.WindowInsetsControllerCompat +import androidx.lifecycle.lifecycleScope +import com.seven.ikar.kotlin.app.IkarApp +import com.seven.ikar.kotlin.app.IkarNativeApp +import com.seven.ikar.kotlin.core.push.AndroidAppVisibilityState +import com.seven.ikar.kotlin.core.push.IncomingCallFullScreenSetupHelper +import com.seven.ikar.kotlin.core.push.IncomingCallFullScreenSetupResult +import com.seven.ikar.kotlin.core.push.InitialCallPermissionPromptStore +import com.seven.ikar.kotlin.core.push.endedCallIdFromIntent +import com.seven.ikar.kotlin.core.push.pendingIncomingCallInviteFromIntent +import com.seven.ikar.kotlin.core.push.PushDataKeys +import com.seven.ikar.kotlin.core.push.PushDataKinds +import com.seven.ikar.kotlin.feature.share.PendingExternalShare +import com.seven.ikar.kotlin.ui.SecureWindowFlagController +import kotlinx.coroutines.launch + +class MainActivity : ComponentActivity() { + private val applicationContainer + get() = (application as IkarNativeApp).container + private val initialCallPermissionPromptStore by lazy { InitialCallPermissionPromptStore(this) } + private var lastInitialCallPermissionRequirement: String? = null + private var isUnlockedForVisibleSession = false + private var isUnlockChallengeInProgress = false + private val appUnlockLauncher = registerForActivityResult( + ActivityResultContracts.StartActivityForResult() + ) { result -> + isUnlockChallengeInProgress = false + if (result.resultCode == Activity.RESULT_OK) { + isUnlockedForVisibleSession = true + applicationContainer.appLockState.setLocked(false) + SecureWindowFlagController.setSecure(window, AppLockSecureToken, false) + } else { + applicationContainer.appLockState.setLocked(true) + SecureWindowFlagController.setSecure(window, AppLockSecureToken, true) + } + } + private val initialCallPermissionSettingsLauncher = registerForActivityResult( + ActivityResultContracts.StartActivityForResult() + ) { + continueInitialCallPermissionSetup(fromSettingsReturn = true) + } + private val initialCallNotificationPermissionLauncher = registerForActivityResult( + ActivityResultContracts.RequestPermission() + ) { granted -> + if (granted) { + continueInitialCallPermissionSetup() + } else { + lastInitialCallPermissionRequirement = InitialRequirementAppNotifications + initialCallPermissionSettingsLauncher.launch(buildAppNotificationSettingsIntent()) + } + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + WindowCompat.setDecorFitsSystemWindows(window, false) + window.statusBarColor = Color.TRANSPARENT + window.navigationBarColor = Color.TRANSPARENT + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + window.isNavigationBarContrastEnforced = false + window.isStatusBarContrastEnforced = false + } + WindowInsetsControllerCompat(window, window.decorView).apply { + isAppearanceLightStatusBars = true + isAppearanceLightNavigationBars = true + } + handleIncomingIntent(intent) + setContent { + IkarApp() + } + enforceAppLockIfNeeded() + continueInitialCallPermissionSetup() + } + + override fun onNewIntent(intent: Intent) { + super.onNewIntent(intent) + setIntent(intent) + handleIncomingIntent(intent) + } + + override fun onStart() { + super.onStart() + AndroidAppVisibilityState.setVisible(true) + enforceAppLockIfNeeded() + } + + override fun onStop() { + if (!isUnlockChallengeInProgress) { + isUnlockedForVisibleSession = false + } + AndroidAppVisibilityState.setVisible(false) + super.onStop() + } + + private fun enforceAppLockIfNeeded() { + val privacy = applicationContainer.privacyRuntimeSettings.loadSnapshot() + if (!privacy.passcodeLockEnabled) { + applicationContainer.appLockState.setLocked(false) + SecureWindowFlagController.setSecure(window, AppLockSecureToken, false) + return + } + if (isUnlockedForVisibleSession || isUnlockChallengeInProgress) { + return + } + + applicationContainer.appLockState.setLocked(true) + SecureWindowFlagController.setSecure(window, AppLockSecureToken, true) + val keyguardManager = getSystemService(KeyguardManager::class.java) + val unlockIntent = if (keyguardManager.isDeviceSecure) { + keyguardManager.createConfirmDeviceCredentialIntent("Икар", "Разблокируйте устройство, чтобы продолжить") + } else { + null + } + + if (unlockIntent == null) { + isUnlockedForVisibleSession = true + applicationContainer.appLockState.setLocked(false) + SecureWindowFlagController.setSecure(window, AppLockSecureToken, false) + return + } + + isUnlockChallengeInProgress = true + appUnlockLauncher.launch(unlockIntent) + } + + private fun continueInitialCallPermissionSetup(fromSettingsReturn: Boolean = false) { + if (initialCallPermissionPromptStore.wasPrompted()) { + return + } + + when (val result = IncomingCallFullScreenSetupHelper.evaluate(this)) { + IncomingCallFullScreenSetupResult.Ready -> { + initialCallPermissionPromptStore.markPrompted() + lastInitialCallPermissionRequirement = null + } + + IncomingCallFullScreenSetupResult.NeedsPostNotificationsPermission -> { + if (fromSettingsReturn && lastInitialCallPermissionRequirement == InitialRequirementPostNotifications) { + initialCallPermissionPromptStore.markPrompted() + lastInitialCallPermissionRequirement = null + return + } + + lastInitialCallPermissionRequirement = InitialRequirementPostNotifications + initialCallNotificationPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS) + } + + is IncomingCallFullScreenSetupResult.NeedsSystemSettings -> { + val requirement = requirementKeyFor(result) + if (fromSettingsReturn && lastInitialCallPermissionRequirement == requirement) { + initialCallPermissionPromptStore.markPrompted() + lastInitialCallPermissionRequirement = null + return + } + + lastInitialCallPermissionRequirement = requirement + initialCallPermissionSettingsLauncher.launch(result.intent) + } + } + } + + private fun handleIncomingIntent(intent: Intent?) { + val incomingIntent = intent ?: return + if (incomingIntent.action == Intent.ACTION_VIEW) { + val archiveUri = incomingIntent.data ?: incomingIntent.readSingleStream() + if (archiveUri != null) { + lifecycleScope.launch { + runCatching { applicationContainer.archiveFileService.importFromUri(archiveUri) } + .onSuccess(applicationContainer.incomingArchiveStore::setPending) + .onFailure { error -> + applicationContainer.incomingArchiveStore.setError( + message = error.message ?: "Не удалось открыть архив.", + fileName = incomingIntent.getStringExtra(Intent.EXTRA_TITLE) + ?: archiveUri.lastPathSegment + ) + } + } + return + } + } + + if (incomingIntent.action == Intent.ACTION_SEND || incomingIntent.action == Intent.ACTION_SEND_MULTIPLE) { + lifecycleScope.launch { + runCatching { extractPendingShare(incomingIntent) } + .onSuccess(applicationContainer.incomingShareStore::setPending) + .onFailure { applicationContainer.incomingShareStore.clear() } + } + return + } + + val kind = incomingIntent.getStringExtra(PushDataKeys.Kind) + when (kind) { + PushDataKinds.AudioCallInvite, + PushDataKinds.VideoCallInvite -> { + val invite = pendingIncomingCallInviteFromIntent(incomingIntent) + if (invite != null) { + applicationContainer.incomingCallInviteStore.setPending( + invite + ) + } + return + } + + PushDataKinds.AudioCallEnded, + PushDataKinds.VideoCallEnded -> { + val callId = endedCallIdFromIntent(incomingIntent) + applicationContainer.incomingCallInviteStore.clearByCallId(callId) + applicationContainer.callManager.dismissCallIfMatches(callId) + return + } + } + + val chatId = incomingIntent.getStringExtra(PushDataKeys.ChatId) + if ((kind == PushDataKinds.ChatMessage || kind == PushDataKinds.MessageReaction) && !chatId.isNullOrBlank()) { + applicationContainer.incomingPushNavigationStore.setPendingChat( + chatId = chatId, + messageId = incomingIntent.getStringExtra(PushDataKeys.MessageId) + ) + } + } + + private suspend fun extractPendingShare(intent: Intent): PendingExternalShare? { + val sharedText = intent.getStringExtra(Intent.EXTRA_TEXT)?.trim()?.takeIf { it.isNotBlank() } + val attachmentUris = when (intent.action) { + Intent.ACTION_SEND -> intent.readSingleStream()?.let(::listOf) ?: emptyList() + Intent.ACTION_SEND_MULTIPLE -> intent.readMultipleStreams() + else -> emptyList() + } + val attachments = buildList { + attachmentUris.forEach { uri -> + add(applicationContainer.attachmentFileService.importFromUri(uri)) + } + } + if (sharedText == null && attachments.isEmpty()) { + return null + } + + return PendingExternalShare( + text = sharedText, + attachments = attachments + ) + } + + private fun Intent.readSingleStream(): Uri? { + val directStream = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + getParcelableExtra(Intent.EXTRA_STREAM, Uri::class.java) + } else { + @Suppress("DEPRECATION") + getParcelableExtra(Intent.EXTRA_STREAM) + } + return directStream ?: clipData?.takeIf { it.itemCount > 0 }?.getItemAt(0)?.uri + } + + private fun Intent.readMultipleStreams(): List { + val extraStreams = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + getParcelableArrayListExtra(Intent.EXTRA_STREAM, Uri::class.java).orEmpty() + } else { + @Suppress("DEPRECATION") + getParcelableArrayListExtra(Intent.EXTRA_STREAM).orEmpty() + } + val clipDataStreams = buildList { + val currentClipData = clipData ?: return@buildList + repeat(currentClipData.itemCount) { index -> + currentClipData.getItemAt(index).uri?.let(::add) + } + } + return (extraStreams + clipDataStreams).distinct() + } + + private fun buildAppNotificationSettingsIntent(): Intent = + Intent(Settings.ACTION_APP_NOTIFICATION_SETTINGS).apply { + putExtra(Settings.EXTRA_APP_PACKAGE, packageName) + putExtra("android.provider.extra.APP_PACKAGE", packageName) + data = Uri.parse("package:$packageName") + } + + private fun requirementKeyFor(result: IncomingCallFullScreenSetupResult.NeedsSystemSettings): String = + when (result.intent.action) { + Settings.ACTION_APP_NOTIFICATION_SETTINGS -> InitialRequirementAppNotifications + Settings.ACTION_CHANNEL_NOTIFICATION_SETTINGS -> InitialRequirementCallsChannel + Settings.ACTION_MANAGE_APP_USE_FULL_SCREEN_INTENT -> InitialRequirementFullScreenIntent + else -> InitialRequirementAppDetails + } + + private companion object { + const val InitialRequirementPostNotifications = "post_notifications" + const val InitialRequirementAppNotifications = "app_notifications" + const val InitialRequirementCallsChannel = "calls_channel" + const val InitialRequirementFullScreenIntent = "full_screen_intent" + const val InitialRequirementAppDetails = "app_details" + const val AppLockSecureToken = "app-lock" + } +} diff --git a/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/app/AppContainer.kt b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/app/AppContainer.kt new file mode 100644 index 00000000..cf20a763 --- /dev/null +++ b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/app/AppContainer.kt @@ -0,0 +1,183 @@ +package com.seven.ikar.kotlin.app + +import android.content.Context +import com.seven.ikar.kotlin.BuildConfig +import com.seven.ikar.kotlin.core.contacts.AndroidContactDiscoveryService +import com.seven.ikar.kotlin.core.contacts.ResolvedContactsRepository +import com.seven.ikar.kotlin.core.local.ChatListCache +import com.seven.ikar.kotlin.core.local.ChatMessageCache +import com.seven.ikar.kotlin.core.local.IkarLocalDatabase +import com.seven.ikar.kotlin.core.model.AuthSessionDto +import com.seven.ikar.kotlin.core.network.ApiClient +import com.seven.ikar.kotlin.core.network.ClientVersionProvider +import com.seven.ikar.kotlin.core.push.AndroidPushSettings +import com.seven.ikar.kotlin.core.push.IncomingCallInviteStore +import com.seven.ikar.kotlin.core.push.IncomingPushNavigationStore +import com.seven.ikar.kotlin.core.push.PushRegistrationManager +import com.seven.ikar.kotlin.core.realtime.RealtimeService +import com.seven.ikar.kotlin.core.settings.AndroidChatSettings +import com.seven.ikar.kotlin.core.settings.AndroidAppSettings +import com.seven.ikar.kotlin.core.settings.AndroidPrivacyRuntimeSettings +import com.seven.ikar.kotlin.core.settings.AppLockState +import com.seven.ikar.kotlin.core.settings.AppProxyType +import com.seven.ikar.kotlin.core.settings.AppSettingsSnapshot +import com.seven.ikar.kotlin.core.session.AuthorizedSessionManager +import com.seven.ikar.kotlin.core.session.SessionStore +import com.seven.ikar.kotlin.core.update.ArgusAppUpdateManager +import com.seven.ikar.kotlin.feature.archive.AndroidArchiveFileService +import com.seven.ikar.kotlin.feature.archive.IncomingArchiveStore +import com.seven.ikar.kotlin.feature.call.AndroidWebRtcCallService +import com.seven.ikar.kotlin.feature.call.AndroidCallTonePlayer +import com.seven.ikar.kotlin.feature.call.CallManager +import com.seven.ikar.kotlin.feature.chat.AndroidAttachmentFileService +import com.seven.ikar.kotlin.feature.chat.AndroidVoiceNotePlayerService +import com.seven.ikar.kotlin.feature.chat.AndroidVoiceNoteRecorderService +import com.seven.ikar.kotlin.feature.chat.ChatScrollStateStore +import com.seven.ikar.kotlin.feature.share.IncomingShareStore +import com.seven.ikar.kotlin.feature.transfer.TransferQueueRepository +import com.seven.ikar.kotlin.feature.transfer.TransferWorkScheduler +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.launch +import kotlinx.serialization.json.Json +import okhttp3.OkHttpClient +import okhttp3.logging.HttpLoggingInterceptor +import java.net.InetSocketAddress +import java.net.Proxy + +class AppContainer(context: Context) { + val appContext: Context = context.applicationContext + val applicationScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + + val json: Json = Json { + ignoreUnknownKeys = true + explicitNulls = false + isLenient = true + } + + private val loggingInterceptor = HttpLoggingInterceptor().apply { + level = HttpLoggingInterceptor.Level.BASIC + } + + val pushSettings = AndroidPushSettings(appContext) + val chatSettings = AndroidChatSettings(appContext) + val appSettings = AndroidAppSettings(appContext) + val privacyRuntimeSettings = AndroidPrivacyRuntimeSettings(appContext) + val appLockState = AppLockState() + + val clientVersionProvider = ClientVersionProvider( + versionCode = BuildConfig.VERSION_CODE.toString(), + versionName = BuildConfig.VERSION_NAME + ) + + @Volatile + private var activeHttpClient: OkHttpClient = buildHttpClient(appSettings.loadSnapshot()) + + val httpClient: OkHttpClient + get() = activeHttpClient + + val sessionStore = SessionStore(appContext, json) + val localDatabase = IkarLocalDatabase.getInstance(appContext) + val chatMessageCache = ChatMessageCache( + database = localDatabase, + dao = localDatabase.cachedChatMessageDao(), + json = json + ) + val chatListCache = ChatListCache( + dao = localDatabase.cachedChatDao(), + json = json + ) + val appUpdateManager = ArgusAppUpdateManager(appContext) + val apiClient = ApiClient( + httpClientProvider = { httpClient }, + json = json, + clientVersionProvider = clientVersionProvider + ) + val authorizedSessionManager = AuthorizedSessionManager( + apiClient = apiClient, + sessionStore = sessionStore + ) + val contactDiscoveryService = AndroidContactDiscoveryService(appContext) + val resolvedContactsRepository = ResolvedContactsRepository( + apiClient = apiClient, + sessionStore = sessionStore, + authorizedSessionManager = authorizedSessionManager, + contactDiscoveryService = contactDiscoveryService + ) + val attachmentFileService = AndroidAttachmentFileService(appContext) + val archiveFileService = AndroidArchiveFileService(appContext) + val transferWorkScheduler = TransferWorkScheduler(appContext) + val transferQueueRepository = TransferQueueRepository( + dao = localDatabase.transferQueueDao(), + scheduler = transferWorkScheduler + ) + val chatScrollStateStore = ChatScrollStateStore(appContext, json) + val incomingShareStore = IncomingShareStore() + val incomingArchiveStore = IncomingArchiveStore() + val incomingPushNavigationStore = IncomingPushNavigationStore() + val incomingCallInviteStore = IncomingCallInviteStore() + val voiceNotePlayerService = AndroidVoiceNotePlayerService() + val voiceNoteRecorderService = AndroidVoiceNoteRecorderService(appContext) + val realtimeService = RealtimeService(clientVersionProvider) + val webRtcCallService = AndroidWebRtcCallService(appContext) + val callTonePlayer = AndroidCallTonePlayer() + val callManager = CallManager( + realtimeService = realtimeService, + authorizedSessionManager = authorizedSessionManager, + webRtcCallService = webRtcCallService, + callTonePlayer = callTonePlayer, + appContext = appContext + ) + val pushRegistrationManager = PushRegistrationManager( + context = appContext, + apiClient = apiClient, + sessionStore = sessionStore, + authorizedSessionManager = authorizedSessionManager, + settings = pushSettings + ) + + init { + applicationScope.launch { + appSettings.snapshot.collect { snapshot -> + activeHttpClient = buildHttpClient(snapshot) + runCatching { + attachmentFileService.enforceCachePolicy( + maxCacheMb = snapshot.maxCacheMb, + keepMediaDays = snapshot.keepMediaDays + ) + } + } + } + } + + suspend fun executeAuthorized(block: suspend (AuthSessionDto) -> T): T = + authorizedSessionManager.executeAuthorized(block) + + private fun buildHttpClient(settings: AppSettingsSnapshot): OkHttpClient { + val proxy = buildProxy(settings) + return OkHttpClient.Builder() + .addInterceptor(loggingInterceptor) + .apply { + if (proxy != null) { + proxy(proxy) + } + } + .build() + } + + private fun buildProxy(settings: AppSettingsSnapshot): Proxy? { + val host = settings.proxyHost.trim() + if (!settings.proxyEnabled || host.isBlank()) { + return null + } + + val proxyType = when (settings.proxyType) { + AppProxyType.Http -> Proxy.Type.HTTP + AppProxyType.Socks5, + AppProxyType.MtProto -> Proxy.Type.SOCKS + } + return Proxy(proxyType, InetSocketAddress(host, settings.proxyPort)) + } +} diff --git a/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/app/IkarApp.kt b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/app/IkarApp.kt new file mode 100644 index 00000000..c243ffdc --- /dev/null +++ b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/app/IkarApp.kt @@ -0,0 +1,1162 @@ +package com.seven.ikar.kotlin.app + +import android.net.Uri +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver +import androidx.lifecycle.compose.LocalLifecycleOwner +import androidx.lifecycle.viewmodel.compose.viewModel +import androidx.lifecycle.viewmodel.initializer +import androidx.lifecycle.viewmodel.viewModelFactory +import androidx.navigation.NavType +import androidx.navigation.NavGraph.Companion.findStartDestination +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.composable +import androidx.navigation.compose.currentBackStackEntryAsState +import androidx.navigation.compose.rememberNavController +import androidx.navigation.navArgument +import com.seven.ikar.kotlin.core.model.ChatType +import com.seven.ikar.kotlin.core.settings.AppLocaleManager +import com.seven.ikar.kotlin.feature.archive.ArchiveExtractorScreen +import com.seven.ikar.kotlin.feature.archive.ArchiveExtractorViewModel +import com.seven.ikar.kotlin.feature.bots.BotEditorScreen +import com.seven.ikar.kotlin.feature.bots.BotEditorViewModel +import com.seven.ikar.kotlin.feature.bots.BotsScreen +import com.seven.ikar.kotlin.feature.bots.BotsViewModel +import com.seven.ikar.kotlin.feature.call.CallScreen +import com.seven.ikar.kotlin.core.push.AndroidActiveChatState +import com.seven.ikar.kotlin.feature.chat.ChatScreen +import com.seven.ikar.kotlin.feature.chat.ChatViewModel +import com.seven.ikar.kotlin.feature.chatinfo.ChatInfoScreen +import com.seven.ikar.kotlin.feature.chatinfo.ChatInfoViewModel +import com.seven.ikar.kotlin.feature.chats.ChatsScreen +import com.seven.ikar.kotlin.feature.chats.ChatsViewModel +import com.seven.ikar.kotlin.feature.contacts.ContactsScreen +import com.seven.ikar.kotlin.feature.contacts.ContactsViewModel +import com.seven.ikar.kotlin.feature.devices.DevicesScreen +import com.seven.ikar.kotlin.feature.devices.DevicesViewModel +import com.seven.ikar.kotlin.feature.discover.DiscoverScreen +import com.seven.ikar.kotlin.feature.discover.DiscoverViewModel +import com.seven.ikar.kotlin.feature.folders.ChatFoldersScreen +import com.seven.ikar.kotlin.feature.folders.ChatFoldersViewModel +import com.seven.ikar.kotlin.feature.forward.ForwardScreen +import com.seven.ikar.kotlin.feature.forward.ForwardViewModel +import com.seven.ikar.kotlin.feature.login.LoginScreen +import com.seven.ikar.kotlin.feature.login.LoginViewModel +import com.seven.ikar.kotlin.feature.mainmenu.MainMenuViewModel +import com.seven.ikar.kotlin.feature.members.ChatMembersScreen +import com.seven.ikar.kotlin.feature.members.ChatMembersViewModel +import com.seven.ikar.kotlin.feature.profile.AccountSettingsScreen +import com.seven.ikar.kotlin.feature.profile.PeerProfileScreen +import com.seven.ikar.kotlin.feature.profile.PeerProfileViewModel +import com.seven.ikar.kotlin.feature.profile.ProfileScreen +import com.seven.ikar.kotlin.feature.profile.ProfileViewModel +import com.seven.ikar.kotlin.feature.privacy.PrivacySecurityScreen +import com.seven.ikar.kotlin.feature.privacy.PrivacySecurityViewModel +import com.seven.ikar.kotlin.feature.search.GlobalSearchScreen +import com.seven.ikar.kotlin.feature.search.GlobalSearchViewModel +import com.seven.ikar.kotlin.feature.settings.ChatSettingsScreen +import com.seven.ikar.kotlin.feature.settings.ChatNotificationSettingsScreen +import com.seven.ikar.kotlin.feature.settings.ChatNotificationSettingsViewModel +import com.seven.ikar.kotlin.feature.settings.ChatWallpaperScreen +import com.seven.ikar.kotlin.feature.settings.ChatSettingsViewModel +import com.seven.ikar.kotlin.feature.settings.AppSettingsViewModel +import com.seven.ikar.kotlin.feature.settings.BlockedUsersPlaceholderScreen +import com.seven.ikar.kotlin.feature.settings.DataStorageSettingsScreen +import com.seven.ikar.kotlin.feature.settings.LanguagePlaceholderScreen +import com.seven.ikar.kotlin.feature.settings.NotificationSettingsScreen +import com.seven.ikar.kotlin.feature.settings.NotificationSettingsViewModel +import com.seven.ikar.kotlin.feature.settings.PrivacyExceptionsPlaceholderScreen +import com.seven.ikar.kotlin.feature.settings.ProxyPlaceholderScreen +import com.seven.ikar.kotlin.feature.settings.SettingsScreen +import com.seven.ikar.kotlin.feature.settings.SettingsViewModel +import com.seven.ikar.kotlin.feature.settings.ThemePlaceholderScreen +import com.seven.ikar.kotlin.feature.stories.StoriesScreen +import com.seven.ikar.kotlin.feature.stories.StoriesViewModel +import com.seven.ikar.kotlin.feature.stories.StoryComposerScreen +import com.seven.ikar.kotlin.feature.update.AppUpdateScreen +import com.seven.ikar.kotlin.feature.update.AppUpdateViewModel +import com.seven.ikar.kotlin.ui.LocalIkarAccessToken +import com.seven.ikar.kotlin.ui.LocalUnreadChatsCount +import com.seven.ikar.kotlin.ui.theme.IkarKotlinTheme +import kotlinx.coroutines.launch + +private const val ChatRoutePattern = "chat/{chatId}?messageId={messageId}&scrollToLatest={scrollToLatest}&scrollRequestId={scrollRequestId}" + +private fun chatRoute( + chatId: String, + messageId: String? = null, + scrollToLatest: Boolean = false, + scrollRequestId: Long? = null +): String { + val query = buildList { + messageId?.takeIf { it.isNotBlank() }?.let { add("messageId=${Uri.encode(it)}") } + if (scrollToLatest) { + add("scrollToLatest=true") + } + scrollRequestId?.let { add("scrollRequestId=$it") } + } + val route = "chat/${Uri.encode(chatId)}" + return if (query.isEmpty()) route else "$route?${query.joinToString("&")}" +} + +@Composable +fun IkarApp() { + val context = LocalContext.current + val application = context.applicationContext as IkarNativeApp + val navController = rememberNavController() + val currentBackStackEntry by navController.currentBackStackEntryAsState() + val activeCall by application.container.callManager.currentCall.collectAsStateWithLifecycle() + val pendingShare by application.container.incomingShareStore.pendingShare.collectAsStateWithLifecycle() + val pendingArchive by application.container.incomingArchiveStore.pendingArchive.collectAsStateWithLifecycle() + val pendingCallInvite by application.container.incomingCallInviteStore.pendingInvite.collectAsStateWithLifecycle() + val pendingPushNavigation by application.container.incomingPushNavigationStore.pendingNavigation.collectAsStateWithLifecycle() + val appSettings by application.container.appSettings.snapshot.collectAsStateWithLifecycle() + val isAppLocked by application.container.appLockState.isLocked.collectAsStateWithLifecycle() + val mainMenuViewModel: MainMenuViewModel = viewModel( + factory = viewModelFactory { + initializer { MainMenuViewModel(application.container) } + } + ) + val mainMenuState by mainMenuViewModel.uiState.collectAsStateWithLifecycle() + var currentAccessToken by rememberSaveable { mutableStateOf(application.container.sessionStore.read()?.accessToken) } + var startDestination by rememberSaveable { mutableStateOf("bootstrap") } + + fun navigatePrimary(route: String) { + if (currentBackStackEntry?.destination?.route == route) { + return + } + + navController.navigate(route) { + popUpTo(navController.graph.findStartDestination().id) { saveState = true } + launchSingleTop = true + restoreState = true + } + } + + fun logoutAndReturnToLogin() { + application.container.realtimeService.disconnect() + application.container.applicationScope.launch { + runCatching { application.container.pushRegistrationManager.unregisterCurrentDeviceAsync() } + } + application.container.sessionStore.clear() + currentAccessToken = null + mainMenuViewModel.clear() + navController.navigate("login") { + popUpTo(0) + launchSingleTop = true + } + } + + LaunchedEffect(Unit) { + val session = application.container.sessionStore.read() + currentAccessToken = session?.accessToken + startDestination = if (session == null) "login" else "chats" + } + + LaunchedEffect(appSettings.languageMode) { + AppLocaleManager.apply(context, appSettings.languageMode) + } + + IkarKotlinTheme(settings = appSettings) { + CompositionLocalProvider( + LocalUnreadChatsCount provides mainMenuState.unreadChatsCount, + LocalIkarAccessToken provides currentAccessToken + ) { + if (startDestination != "bootstrap") { + Box(modifier = Modifier.fillMaxSize()) { + NavHost(navController = navController, startDestination = startDestination) { + composable("login") { + val viewModel: LoginViewModel = viewModel( + factory = viewModelFactory { + initializer { LoginViewModel(application.container) } + } + ) + val state by viewModel.uiState.collectAsStateWithLifecycle() + if (state.isAuthenticated) { + LaunchedEffect(state.isAuthenticated) { + currentAccessToken = application.container.sessionStore.read()?.accessToken + mainMenuViewModel.refresh() + navController.navigate("chats") { + popUpTo("login") { inclusive = true } + } + } + } + LoginScreen( + state = state, + onPhoneNumberChanged = viewModel::updatePhoneNumber, + onDisplayNameChanged = viewModel::updateDisplayName, + onVerificationCodeChanged = viewModel::updateVerificationCode, + onRequestCodeClick = viewModel::requestCode, + onVerifyCodeClick = viewModel::verifyCode, + onChangePhoneNumberClick = viewModel::changePhoneNumber + ) + } + + composable("chats") { + val viewModel: ChatsViewModel = viewModel( + factory = viewModelFactory { + initializer { ChatsViewModel(application.container) } + } + ) + val state by viewModel.uiState.collectAsStateWithLifecycle() + val chatSettings by application.container.chatSettings.snapshot.collectAsStateWithLifecycle() + LaunchedEffect(state.pendingShareNavigation?.requestId) { + val target = state.pendingShareNavigation ?: return@LaunchedEffect + viewModel.consumePendingShareNavigation() + navController.navigate( + chatRoute( + chatId = target.chatId, + messageId = target.messageId, + scrollToLatest = target.scrollToLatest, + scrollRequestId = target.requestId + ) + ) + } + ChatsScreen( + state = state, + chatSettings = chatSettings, + onChatClick = { chat -> + if (state.isShareMode) { + viewModel.sendShareToChat(chat) + } else { + navController.navigate("chat/${chat.id}") + } + }, + onChatSelectionClick = viewModel::handleChatSelectionTap, + onChatLongPress = viewModel::handleChatLongPress, + onClearSelection = viewModel::clearSelection, + onSendSelectedChatsClick = viewModel::sendShareToSelectedChats, + onDismissPendingShare = viewModel::dismissPendingShare, + onDeleteSelectedClick = viewModel::showDeleteDialog, + onPinSelectedChatsClick = viewModel::togglePinSelectedChats, + onArchiveSelectedChatsClick = viewModel::archiveSelectedChats, + onMuteSelectedChatsClick = viewModel::muteSelectedChats, + onDismissDeleteDialog = viewModel::dismissDeleteDialog, + onDeleteForEveryoneChanged = viewModel::updateDeleteForEveryone, + onConfirmDeleteSelected = viewModel::confirmDeleteSelected, + onLogoutClick = { + viewModel.logout() + currentAccessToken = null + mainMenuViewModel.clear() + navController.navigate("login") { + popUpTo("chats") { inclusive = true } + } + }, + onRefreshClick = viewModel::refresh, + onProfileClick = { navigatePrimary("profile") }, + onContactsClick = { navigatePrimary("contacts") }, + onDiscoverClick = { navController.navigate("discover") }, + onComposeStoryClick = { navController.navigate("stories/compose") }, + onStoryClick = { story -> navController.navigate("stories/view/${story.id}") }, + onSettingsClick = { navigatePrimary("settings") }, + onNotificationsClick = { navController.navigate("settings/notifications") }, + onGlobalSearchClick = { navController.navigate("search") }, + onQueryChanged = viewModel::updateQuery + ) + } + + composable("search") { + val viewModel: GlobalSearchViewModel = viewModel( + factory = viewModelFactory { + initializer { GlobalSearchViewModel(application.container) } + } + ) + val state by viewModel.uiState.collectAsStateWithLifecycle() + GlobalSearchScreen( + state = state, + onBackClick = { navController.popBackStack() }, + onQueryChanged = viewModel::updateQuery, + onFilterChanged = viewModel::updateFilter, + onDateFilterChanged = viewModel::updateDateFilter, + onResultTypeChanged = viewModel::updateResultType, + onAuthorFilterChanged = viewModel::updateAuthorFilter, + onLoadMore = viewModel::loadMore, + onResultClick = { result -> + val messageId = result.message?.id + if (messageId == null) { + navController.navigate(chatRoute(result.chat.id)) + } else { + navController.navigate(chatRoute(result.chat.id, messageId = messageId)) + } + } + ) + } + + composable( + route = ChatRoutePattern, + arguments = listOf( + navArgument("chatId") { type = NavType.StringType }, + navArgument("messageId") { + type = NavType.StringType + nullable = true + defaultValue = null + }, + navArgument("scrollToLatest") { + type = NavType.BoolType + defaultValue = false + }, + navArgument("scrollRequestId") { + type = NavType.StringType + nullable = true + defaultValue = null + } + ) + ) { backStackEntry -> + val chatId = backStackEntry.arguments?.getString("chatId").orEmpty() + val messageId = backStackEntry.arguments?.getString("messageId")?.takeIf { it.isNotBlank() } + val scrollToLatest = backStackEntry.arguments?.getBoolean("scrollToLatest") ?: false + val scrollRequestId = backStackEntry.arguments?.getString("scrollRequestId") + val viewModel: ChatViewModel = viewModel( + key = "chat-$chatId", + factory = viewModelFactory { + initializer { ChatViewModel(application.container, chatId) } + } + ) + val state by viewModel.uiState.collectAsStateWithLifecycle() + val chatSettings by application.container.chatSettings.snapshot.collectAsStateWithLifecycle() + LaunchedEffect(messageId, scrollToLatest, scrollRequestId) { + if (scrollRequestId != null) { + viewModel.refresh() + } + when { + messageId != null -> viewModel.requestScrollToMessage(messageId) + scrollToLatest -> viewModel.requestScrollToLatest() + } + } + ChatScreen( + state = state, + viewModel = viewModel, + chatSettings = chatSettings, + onBackClick = { navController.popBackStack() }, + onPeerProfileClick = state.peer?.id?.let { userId -> + { navController.navigate("profile/user/$userId") } + } ?: when { + state.chatType != ChatType.Direct -> { + { navController.navigate("chat/$chatId/info") } + } + else -> null + }, + onChatNotificationsClick = { + val encodedTitle = Uri.encode(state.title.ifBlank { "Чат" }) + navController.navigate("chat/$chatId/notifications?title=$encodedTitle") + }, + onChatWallpaperClick = { navController.navigate("settings/chats/wallpaper") }, + onForwardMessage = { message -> + if (!message.hasProtectedContent) { + navController.navigate("forward/${message.chatId}/${message.id}") + } + } + ) + } + + composable( + route = "chat/{chatId}/notifications?title={title}", + arguments = listOf( + navArgument("chatId") { type = NavType.StringType }, + navArgument("title") { + type = NavType.StringType + nullable = true + defaultValue = null + } + ) + ) { backStackEntry -> + val chatId = backStackEntry.arguments?.getString("chatId").orEmpty() + val title = backStackEntry.arguments?.getString("title").orEmpty() + val viewModel: ChatNotificationSettingsViewModel = viewModel( + key = "chat-notifications-$chatId", + factory = viewModelFactory { + initializer { + ChatNotificationSettingsViewModel( + application.container, + chatId, + title + ) + } + } + ) + val state by viewModel.uiState.collectAsStateWithLifecycle() + ChatNotificationSettingsScreen( + state = state, + messageSoundOptions = viewModel.messageSoundOptions, + onBackClick = { navController.popBackStack() }, + onSnapshotChanged = { snapshot -> viewModel.updateSnapshot { snapshot } }, + onMessageSoundChanged = viewModel::setMessageSound, + onResetClick = viewModel::reset + ) + } + + composable( + route = "chat/{chatId}/members", + arguments = listOf(navArgument("chatId") { type = NavType.StringType }) + ) { backStackEntry -> + val chatId = backStackEntry.arguments?.getString("chatId").orEmpty() + val viewModel: ChatMembersViewModel = viewModel( + key = "chat-members-$chatId", + factory = viewModelFactory { + initializer { ChatMembersViewModel(application.container, chatId) } + } + ) + val state by viewModel.uiState.collectAsStateWithLifecycle() + ChatMembersScreen( + state = state, + onBackClick = { navController.popBackStack() }, + onRefreshClick = viewModel::refresh, + onToggleAdmin = viewModel::toggleAdmin, + onToggleCanPost = viewModel::toggleCanPost, + onToggleCanPin = viewModel::toggleCanPin, + onToggleCanDelete = viewModel::toggleCanDelete, + onToggleCanInvite = viewModel::toggleCanInvite, + onMemberSearchChanged = viewModel::updateMemberSearchQuery, + onAddMember = { user -> viewModel.addMember(user, asAdmin = false) }, + onAddAdmin = { user -> viewModel.addMember(user, asAdmin = true) }, + onApproveJoinRequest = viewModel::approveJoinRequest, + onDeclineJoinRequest = viewModel::declineJoinRequest + ) + } + + composable( + route = "chat/{chatId}/info", + arguments = listOf(navArgument("chatId") { type = NavType.StringType }) + ) { backStackEntry -> + val chatId = backStackEntry.arguments?.getString("chatId").orEmpty() + val viewModel: ChatInfoViewModel = viewModel( + key = "chat-info-$chatId", + factory = viewModelFactory { + initializer { ChatInfoViewModel(application.container, chatId) } + } + ) + val state by viewModel.uiState.collectAsStateWithLifecycle() + ChatInfoScreen( + state = state, + onBackClick = { navController.popBackStack() }, + onRefreshClick = viewModel::refresh, + onMembersClick = { navController.navigate("chat/$chatId/members") }, + onSearchClick = { navController.navigate("search") }, + onInviteTitleChanged = viewModel::updateInviteTitle, + onInviteJoinRequestChanged = viewModel::updateInviteCreatesJoinRequest, + onCreateInviteClick = viewModel::createInviteLink, + onRevokeInviteClick = viewModel::revokeInviteLink, + onTopicTitleChanged = viewModel::updateTopicTitle, + onCreateTopicClick = viewModel::createTopic + ) + } + + composable( + route = "profile/user/{userId}", + arguments = listOf(navArgument("userId") { type = NavType.StringType }) + ) { backStackEntry -> + val userId = backStackEntry.arguments?.getString("userId").orEmpty() + val viewModel: PeerProfileViewModel = viewModel( + key = "peer-profile-$userId", + factory = viewModelFactory { + initializer { PeerProfileViewModel(application.container, userId) } + } + ) + val state by viewModel.uiState.collectAsStateWithLifecycle() + PeerProfileScreen( + state = state, + onBackClick = { navController.popBackStack() }, + onRefreshClick = viewModel::refresh + ) + } + + composable("profile") { + val viewModel: ProfileViewModel = viewModel( + factory = viewModelFactory { + initializer { ProfileViewModel(application.container) } + } + ) + val state by viewModel.uiState.collectAsStateWithLifecycle() + ProfileScreen( + state = state, + onSettingsClick = { navController.navigate("settings/account") }, + onLogoutClick = ::logoutAndReturnToLogin, + onManageAvatarClick = viewModel::uploadAvatar, + onChatsClick = { navigatePrimary("chats") }, + onContactsClick = { navigatePrimary("contacts") }, + onProfileClick = { navigatePrimary("profile") }, + onSettingsTabClick = { navigatePrimary("settings") } + ) + } + + composable("settings/account") { + val viewModel: ProfileViewModel = viewModel( + key = "account-settings", + factory = viewModelFactory { + initializer { ProfileViewModel(application.container) } + } + ) + val state by viewModel.uiState.collectAsStateWithLifecycle() + AccountSettingsScreen( + state = state, + onBackClick = { navController.popBackStack() }, + onDisplayNameChanged = viewModel::updateDisplayName, + onAboutChanged = viewModel::updateAbout, + onSaveClick = viewModel::save, + onDeleteAvatarClick = viewModel::deleteAvatar, + onAvatarSelected = viewModel::uploadAvatar + ) + } + + composable("contacts") { + val viewModel: ContactsViewModel = viewModel( + factory = viewModelFactory { + initializer { ContactsViewModel(application.container) } + } + ) + val state by viewModel.uiState.collectAsStateWithLifecycle() + LaunchedEffect(state.pendingChatId) { + val chatId = state.pendingChatId ?: return@LaunchedEffect + viewModel.consumePendingChatNavigation() + navController.navigate("chat/$chatId") + } + ContactsScreen( + state = state, + items = viewModel.filteredItems(), + onQueryChanged = viewModel::updateQuery, + onContactClick = viewModel::startDirectChat, + onRefresh = viewModel::refresh, + onChatsClick = { navigatePrimary("chats") }, + onContactsClick = { navigatePrimary("contacts") }, + onSettingsClick = { navigatePrimary("settings") }, + onProfileClick = { navigatePrimary("profile") }, + onOpenNewMessageClick = { navController.navigate("discover") } + ) + } + + composable("discover") { + val viewModel: DiscoverViewModel = viewModel( + factory = viewModelFactory { + initializer { DiscoverViewModel(application.container) } + } + ) + val state by viewModel.uiState.collectAsStateWithLifecycle() + LaunchedEffect(state.pendingChatId) { + val chatId = state.pendingChatId ?: return@LaunchedEffect + viewModel.consumePendingChatNavigation() + navController.navigate("chat/$chatId") + } + DiscoverScreen( + state = state, + onBackClick = { navController.popBackStack() }, + onQueryChanged = viewModel::updateQuery, + onRefreshClick = viewModel::refresh, + onUserClick = viewModel::handleUserTap, + onBeginGroupSelection = viewModel::beginGroupSelection, + onBeginChannelSelection = viewModel::beginChannelSelection, + onCancelSelection = viewModel::cancelSelection, + onChannelBackClick = viewModel::previousChannelWizardStep, + onChannelTitleChanged = viewModel::updateChannelTitle, + onChannelDescriptionChanged = viewModel::updateChannelDescription, + onChannelVisibilityChanged = viewModel::updateChannelVisibility, + onChannelPublicUsernameChanged = viewModel::updateChannelPublicUsername, + onChannelSignaturesChanged = viewModel::updateChannelSignaturesEnabled, + onContinueChannelWizard = viewModel::continueChannelWizard, + onCompleteSelection = viewModel::completeSelection + ) + } + + composable( + route = "forward/{sourceChatId}/{sourceMessageId}", + arguments = listOf( + navArgument("sourceChatId") { type = NavType.StringType }, + navArgument("sourceMessageId") { type = NavType.StringType } + ) + ) { backStackEntry -> + val sourceChatId = backStackEntry.arguments?.getString("sourceChatId").orEmpty() + val sourceMessageId = backStackEntry.arguments?.getString("sourceMessageId").orEmpty() + val viewModel: ForwardViewModel = viewModel( + key = "forward-$sourceChatId-$sourceMessageId", + factory = viewModelFactory { + initializer { ForwardViewModel(application.container, sourceChatId, sourceMessageId) } + } + ) + val state by viewModel.uiState.collectAsStateWithLifecycle() + LaunchedEffect(state.pendingChatId) { + val chatId = state.pendingChatId ?: return@LaunchedEffect + viewModel.consumePendingChatNavigation() + navController.navigate("chat/$chatId") { + popUpTo("chats") + } + } + ForwardScreen( + state = state, + onBackClick = { navController.popBackStack() }, + onQueryChanged = viewModel::updateQuery, + onUserClick = viewModel::forwardToUser + ) + } + + composable("settings") { + val viewModel: SettingsViewModel = viewModel( + factory = viewModelFactory { + initializer { SettingsViewModel(application.container) } + } + ) + SettingsScreen( + state = viewModel.uiState, + onChatsClick = { navigatePrimary("chats") }, + onContactsClick = { navigatePrimary("contacts") }, + onSettingsClick = { navigatePrimary("settings") }, + onProfileClick = { navigatePrimary("profile") }, + onAccountClick = { navController.navigate("settings/account") }, + onChatSettingsClick = { navController.navigate("settings/chats") }, + onPrivacyClick = { navController.navigate("settings/privacy") }, + onNotificationsClick = { navController.navigate("settings/notifications") }, + onBotsClick = { navController.navigate("bots") }, + onFoldersClick = { navController.navigate("settings/folders") }, + onDevicesClick = { navController.navigate("settings/devices") }, + onUpdatesClick = { navController.navigate("settings/update") }, + onDataStorageClick = { navController.navigate("settings/data-storage") }, + onThemeClick = { navController.navigate("settings/theme") }, + onLanguageClick = { navController.navigate("settings/language") }, + onProxyClick = { navController.navigate("settings/proxy") }, + onStoriesClick = { navController.navigate("stories") }, + ) + } + + composable("settings/privacy") { + val viewModel: PrivacySecurityViewModel = viewModel( + factory = viewModelFactory { + initializer { PrivacySecurityViewModel(application.container) } + } + ) + val state by viewModel.uiState.collectAsStateWithLifecycle() + PrivacySecurityScreen( + state = state, + onBackClick = { navController.popBackStack() }, + onRefreshClick = viewModel::refresh, + onBlockedUsersClick = { navController.navigate("settings/privacy/blocked") }, + onExceptionsClick = { navController.navigate("settings/privacy/exceptions") }, + onUpdate = viewModel::update + ) + } + + composable("settings/privacy/blocked") { + BlockedUsersPlaceholderScreen(onBackClick = { navController.popBackStack() }) + } + + composable("settings/privacy/exceptions") { + PrivacyExceptionsPlaceholderScreen(onBackClick = { navController.popBackStack() }) + } + + composable("settings/data-storage") { + val viewModel: AppSettingsViewModel = viewModel( + factory = viewModelFactory { + initializer { AppSettingsViewModel(application.container) } + } + ) + val state by viewModel.uiState.collectAsStateWithLifecycle() + DataStorageSettingsScreen( + state = state, + onBackClick = { navController.popBackStack() }, + onSnapshotChanged = viewModel::updateSnapshot, + onProxyClick = { navController.navigate("settings/proxy") }, + onResetClick = viewModel::reset + ) + } + + composable("settings/theme") { + val viewModel: AppSettingsViewModel = viewModel( + factory = viewModelFactory { + initializer { AppSettingsViewModel(application.container) } + } + ) + val state by viewModel.uiState.collectAsStateWithLifecycle() + ThemePlaceholderScreen( + state = state, + onBackClick = { navController.popBackStack() }, + onSnapshotChanged = viewModel::updateSnapshot, + onResetClick = viewModel::reset + ) + } + + composable("settings/language") { + val viewModel: AppSettingsViewModel = viewModel( + factory = viewModelFactory { + initializer { AppSettingsViewModel(application.container) } + } + ) + val state by viewModel.uiState.collectAsStateWithLifecycle() + LanguagePlaceholderScreen( + state = state, + onBackClick = { navController.popBackStack() }, + onSnapshotChanged = viewModel::updateSnapshot + ) + } + + composable("settings/proxy") { + val viewModel: AppSettingsViewModel = viewModel( + factory = viewModelFactory { + initializer { AppSettingsViewModel(application.container) } + } + ) + val state by viewModel.uiState.collectAsStateWithLifecycle() + ProxyPlaceholderScreen( + state = state, + onBackClick = { navController.popBackStack() }, + onSnapshotChanged = viewModel::updateSnapshot + ) + } + + composable("stories") { + val viewModel: StoriesViewModel = viewModel( + factory = viewModelFactory { + initializer { StoriesViewModel(application.container) } + } + ) + val state by viewModel.uiState.collectAsStateWithLifecycle() + StoriesScreen( + state = state, + onBackClick = { navController.popBackStack() }, + onComposeClick = { navController.navigate("stories/compose") }, + onRefreshClick = viewModel::refresh, + onStoryClick = viewModel::openStory, + onDeleteStory = viewModel::deleteStory, + onCloseStoryPreview = viewModel::closeStoryPreview, + onNextStory = viewModel::openNextStory, + onPreviousStory = viewModel::openPreviousStory, + onRetryTransfer = viewModel::retryTransfer, + onCancelTransfer = viewModel::cancelTransfer + ) + } + + composable( + route = "stories/view/{storyId}", + arguments = listOf(navArgument("storyId") { type = NavType.StringType }) + ) { backStackEntry -> + val storyId = backStackEntry.arguments?.getString("storyId").orEmpty() + val viewModel: StoriesViewModel = viewModel( + key = "story-view-$storyId", + factory = viewModelFactory { + initializer { StoriesViewModel(application.container) } + } + ) + val state by viewModel.uiState.collectAsStateWithLifecycle() + LaunchedEffect(storyId) { + viewModel.openStoryById(storyId) + } + StoriesScreen( + state = state, + onBackClick = { navController.popBackStack() }, + onComposeClick = { navController.navigate("stories/compose") }, + onRefreshClick = viewModel::refresh, + onStoryClick = viewModel::openStory, + onDeleteStory = viewModel::deleteStory, + onCloseStoryPreview = { + viewModel.closeStoryPreview() + navController.popBackStack() + }, + onNextStory = viewModel::openNextStory, + onPreviousStory = viewModel::openPreviousStory, + onRetryTransfer = viewModel::retryTransfer, + onCancelTransfer = viewModel::cancelTransfer + ) + } + + composable("stories/compose") { + val viewModel: StoriesViewModel = viewModel( + factory = viewModelFactory { + initializer { StoriesViewModel(application.container) } + } + ) + val state by viewModel.uiState.collectAsStateWithLifecycle() + StoryComposerScreen( + state = state, + onBackClick = { navController.popBackStack() }, + onTextChanged = viewModel::updateDraftText, + onVisibilityChanged = viewModel::setVisibility, + onProtectedChanged = viewModel::setProtectedContent, + onTtlChanged = viewModel::setTtlSeconds, + onMediaSelected = viewModel::consumeMediaUris, + onRemoveMedia = viewModel::removePendingMedia, + onClearMedia = viewModel::clearPendingMedia, + onCreateClick = { viewModel.createStory { navController.popBackStack() } }, + onRetryTransfer = viewModel::retryTransfer, + onCancelTransfer = viewModel::cancelTransfer + ) + } + + composable("settings/devices") { + val viewModel: DevicesViewModel = viewModel( + factory = viewModelFactory { + initializer { DevicesViewModel(application.container) } + } + ) + val state by viewModel.uiState.collectAsStateWithLifecycle() + DevicesScreen( + state = state, + onBackClick = { navController.popBackStack() }, + onRefreshClick = viewModel::refresh, + onRevokeClick = viewModel::revoke + ) + } + + composable("settings/folders") { + val viewModel: ChatFoldersViewModel = viewModel( + factory = viewModelFactory { + initializer { ChatFoldersViewModel(application.container) } + } + ) + val state by viewModel.uiState.collectAsStateWithLifecycle() + ChatFoldersScreen( + state = state, + onBackClick = { navController.popBackStack() }, + onRefreshClick = viewModel::refresh, + onNewFolderKeyChanged = viewModel::updateNewFolderKey, + onFolderSelected = viewModel::selectFolder, + onChatToggled = viewModel::toggleChat, + onAssignSelected = viewModel::assignSelectedToFolder, + onClearSelected = viewModel::clearSelectedFolders + ) + } + + composable("settings/update") { + val viewModel: AppUpdateViewModel = viewModel( + factory = viewModelFactory { + initializer { AppUpdateViewModel(application.container) } + } + ) + val state by viewModel.uiState.collectAsStateWithLifecycle() + val lifecycleOwner = LocalLifecycleOwner.current + DisposableEffect(lifecycleOwner) { + val observer = LifecycleEventObserver { _, event -> + if (event == Lifecycle.Event.ON_RESUME) { + viewModel.refreshStatus() + } + } + lifecycleOwner.lifecycle.addObserver(observer) + onDispose { + lifecycleOwner.lifecycle.removeObserver(observer) + } + } + AppUpdateScreen( + state = state, + onBackClick = { navController.popBackStack() }, + onRefreshClick = { + if (state.canRequestPackageInstalls) { + viewModel.checkForUpdates() + } else { + viewModel.requestUnknownSourcesPermission() + context.startActivity( + application.container.appUpdateManager + .buildUnknownSourcesIntent() + .addFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK) + ) + } + }, + onInstallClick = viewModel::installAvailableUpdate, + onGrantPermissionClick = { + viewModel.requestUnknownSourcesPermission() + context.startActivity( + application.container.appUpdateManager + .buildUnknownSourcesIntent() + .addFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK) + ) + } + ) + } + + composable("archive/open") { + val viewModel: ArchiveExtractorViewModel = viewModel( + factory = viewModelFactory { + initializer { ArchiveExtractorViewModel(application.container) } + } + ) + val state by viewModel.uiState.collectAsStateWithLifecycle() + ArchiveExtractorScreen( + state = state, + onBackClick = { + viewModel.clearAndClose() + navController.popBackStack() + }, + onKeepFoldersChanged = viewModel::setKeepFolders, + onOverwriteExistingChanged = viewModel::setOverwriteExisting, + onExtractClick = viewModel::extract, + onCancelClick = viewModel::cancelExtraction + ) + } + + composable("settings/chats") { + val viewModel: ChatSettingsViewModel = viewModel( + factory = viewModelFactory { + initializer { ChatSettingsViewModel(application.container) } + } + ) + val state by viewModel.uiState.collectAsStateWithLifecycle() + ChatSettingsScreen( + state = state, + onBackClick = { navController.popBackStack() }, + onSnapshotChanged = { snapshot -> viewModel.updateSnapshot { snapshot } }, + onWallpaperClick = { navController.navigate("settings/chats/wallpaper") }, + onResetClick = viewModel::reset + ) + } + + composable("settings/chats/wallpaper") { + val viewModel: ChatSettingsViewModel = viewModel( + factory = viewModelFactory { + initializer { ChatSettingsViewModel(application.container) } + } + ) + val state by viewModel.uiState.collectAsStateWithLifecycle() + ChatWallpaperScreen( + snapshot = state.snapshot, + onBackClick = { navController.popBackStack() }, + onPresetSelected = { presetId -> + viewModel.updateSnapshot { + it.copy( + selectedWallpaperPresetId = presetId, + useCustomWallpaper = false + ) + } + }, + onCustomWallpaperSelected = { uri -> + viewModel.updateSnapshot { + it.copy( + customWallpaperUri = uri.toString(), + useCustomWallpaper = true + ) + } + }, + onUseCustomWallpaper = { + viewModel.updateSnapshot { snapshot -> + if (snapshot.customWallpaperUri.isNullOrBlank()) { + snapshot + } else { + snapshot.copy(useCustomWallpaper = true) + } + } + } + ) + } + + composable("settings/notifications") { + val viewModel: NotificationSettingsViewModel = viewModel( + factory = viewModelFactory { + initializer { NotificationSettingsViewModel(application.container) } + } + ) + val state by viewModel.uiState.collectAsStateWithLifecycle() + NotificationSettingsScreen( + state = state, + messageSoundOptions = viewModel.messageSoundOptions, + callRingtoneOptions = viewModel.callRingtoneOptions, + repeatIntervalOptions = viewModel.repeatIntervalOptions, + onBackClick = { navController.popBackStack() }, + onSnapshotChanged = { snapshot -> viewModel.updateSnapshot { snapshot } }, + onMessageSoundChanged = viewModel::setMessageSound, + onCallRingtoneChanged = viewModel::setCallRingtone, + onRepeatIntervalChanged = viewModel::setRepeatInterval, + onSaveClick = viewModel::save, + onResetClick = viewModel::reset + ) + } + + composable("bots") { + val viewModel: BotsViewModel = viewModel( + factory = viewModelFactory { + initializer { BotsViewModel(application.container) } + } + ) + val state by viewModel.uiState.collectAsStateWithLifecycle() + BotsScreen( + state = state, + onBackClick = { navController.popBackStack() }, + onCreateBotClick = { navController.navigate("bots/editor") }, + onBotClick = { bot -> navController.navigate("bots/editor?botId=${bot.id}") } + ) + } + + composable( + route = "bots/editor?botId={botId}", + arguments = listOf( + navArgument("botId") { + type = NavType.StringType + nullable = true + defaultValue = null + } + ) + ) { backStackEntry -> + val botId = backStackEntry.arguments?.getString("botId") + val viewModel: BotEditorViewModel = viewModel( + key = "bot-editor-${botId ?: "new"}", + factory = viewModelFactory { + initializer { BotEditorViewModel(application.container, botId) } + } + ) + val state by viewModel.uiState.collectAsStateWithLifecycle() + LaunchedEffect(state.pendingChatId) { + val chatId = state.pendingChatId ?: return@LaunchedEffect + viewModel.consumePendingChatNavigation() + navController.navigate("chat/$chatId") + } + LaunchedEffect(state.deleteCompleted) { + if (!state.deleteCompleted) { + return@LaunchedEffect + } + + viewModel.consumeDeleteCompleted() + navController.navigate("bots") { + popUpTo("bots") { + inclusive = true + } + } + } + BotEditorScreen( + state = state, + onBackClick = { navController.popBackStack() }, + onDisplayNameChanged = viewModel::updateDisplayName, + onUsernameChanged = viewModel::updateUsername, + onAboutChanged = viewModel::updateAbout, + onCommandsChanged = viewModel::updateCommandsText, + onEnabledChanged = viewModel::updateEnabled, + onSaveClick = viewModel::save, + onRegenerateTokenClick = viewModel::regenerateToken, + onDeleteBotClick = viewModel::deleteBot, + onOpenChatClick = viewModel::openChat + ) + } + } + + LaunchedEffect(pendingShare?.id, currentBackStackEntry?.destination?.route) { + if (pendingShare == null) { + return@LaunchedEffect + } + if (application.container.sessionStore.read() == null) { + return@LaunchedEffect + } + if (currentBackStackEntry?.destination?.route == "chats") { + return@LaunchedEffect + } + + navigatePrimary("chats") + } + + LaunchedEffect(pendingArchive?.id, currentBackStackEntry?.destination?.route) { + if (pendingArchive == null) { + return@LaunchedEffect + } + if (currentBackStackEntry?.destination?.route == "archive/open") { + return@LaunchedEffect + } + + navController.navigate("archive/open") { + launchSingleTop = true + } + } + + LaunchedEffect(pendingCallInvite?.requestId) { + val invite = pendingCallInvite ?: return@LaunchedEffect + if (application.container.sessionStore.read() == null) { + application.container.incomingCallInviteStore.clear(invite.requestId) + return@LaunchedEffect + } + + application.container.callManager.presentIncomingCallInvite( + callId = invite.callId, + chatId = invite.chatId, + callerId = invite.callerId, + callerDisplayName = invite.callerDisplayName, + callerPhoneNumber = invite.callerPhoneNumber, + mediaType = invite.mediaType + ) + application.container.incomingCallInviteStore.clear(invite.requestId) + } + + LaunchedEffect( + pendingPushNavigation?.requestId, + currentBackStackEntry?.destination?.route, + currentBackStackEntry?.arguments?.getString("chatId") + ) { + val pendingPush = pendingPushNavigation ?: return@LaunchedEffect + if (application.container.sessionStore.read() == null) { + return@LaunchedEffect + } + + val currentRoute = currentBackStackEntry?.destination?.route + val currentChatId = currentBackStackEntry?.arguments?.getString("chatId") + val route = chatRoute( + chatId = pendingPush.chatId, + messageId = pendingPush.messageId, + scrollRequestId = pendingPush.requestId + ) + if (currentRoute == ChatRoutePattern && currentChatId == pendingPush.chatId) { + if (!pendingPush.messageId.isNullOrBlank()) { + navController.navigate(route) { + launchSingleTop = true + } + } + application.container.incomingPushNavigationStore.clear(pendingPush.requestId) + return@LaunchedEffect + } + + navController.navigate(route) { + launchSingleTop = true + } + application.container.incomingPushNavigationStore.clear(pendingPush.requestId) + } + + LaunchedEffect( + currentBackStackEntry?.destination?.route, + currentBackStackEntry?.arguments?.getString("chatId") + ) { + val activeChatId = if (currentBackStackEntry?.destination?.route == ChatRoutePattern) { + currentBackStackEntry?.arguments?.getString("chatId") + } else { + null + } + AndroidActiveChatState.setActiveChatId(activeChatId) + } + + activeCall?.let { call -> + CallScreen( + state = call, + onAccept = application.container.callManager::acceptCurrentCall, + onDecline = application.container.callManager::declineCurrentCall, + onEnd = application.container.callManager::endCurrentCall + ) + } + + if (isAppLocked) { + AppLockedOverlay() + } + } + } + } +} +} + +@Composable +private fun AppLockedOverlay() { + Box( + modifier = Modifier + .fillMaxSize() + .background(Color(0xFF101820)), + contentAlignment = Alignment.Center + ) { + Text( + text = "Икар заблокирован", + style = MaterialTheme.typography.headlineLarge, + color = Color.White + ) + } +} diff --git a/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/app/IkarNativeApp.kt b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/app/IkarNativeApp.kt new file mode 100644 index 00000000..9a9211fd --- /dev/null +++ b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/app/IkarNativeApp.kt @@ -0,0 +1,51 @@ +package com.seven.ikar.kotlin.app + +import android.app.Application +import coil.ImageLoader +import coil.ImageLoaderFactory +import coil.disk.DiskCache +import coil.memory.MemoryCache +import coil.request.CachePolicy +import com.seven.ikar.kotlin.core.push.AndroidPushBootstrapper +import java.io.File +import kotlinx.coroutines.launch + +class IkarNativeApp : Application(), ImageLoaderFactory { + lateinit var container: AppContainer + private set + + override fun onCreate() { + super.onCreate() + AndroidPushBootstrapper.tryInitialize(this) + container = AppContainer(this) + container.applicationScope.launch { + container.pushRegistrationManager.initializeAsync() + if (container.sessionStore.read() != null) { + runCatching { container.pushRegistrationManager.registerCurrentDeviceAsync() } + } + } + } + + override fun newImageLoader(): ImageLoader = + ImageLoader.Builder(this) + .callFactory { container.httpClient } + .memoryCache { + MemoryCache.Builder(this) + .maxSizePercent(0.25) + .build() + } + .diskCache { + DiskCache.Builder() + .directory(File(cacheDir, "image_cache")) + .minimumMaxSizeBytes(64L * 1024L * 1024L) + .maximumMaxSizeBytes(256L * 1024L * 1024L) + .maxSizePercent(0.03) + .build() + } + .memoryCachePolicy(CachePolicy.ENABLED) + .diskCachePolicy(CachePolicy.ENABLED) + .networkCachePolicy(CachePolicy.ENABLED) + .respectCacheHeaders(false) + .crossfade(true) + .build() +} diff --git a/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/core/contacts/AndroidContactDiscoveryService.kt b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/core/contacts/AndroidContactDiscoveryService.kt new file mode 100644 index 00000000..f10c598d --- /dev/null +++ b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/core/contacts/AndroidContactDiscoveryService.kt @@ -0,0 +1,45 @@ +package com.seven.ikar.kotlin.core.contacts + +import android.content.Context +import android.provider.ContactsContract + +data class DeviceContactRecord( + val displayName: String, + val normalizedPhoneNumber: String +) + +class AndroidContactDiscoveryService(private val context: Context) { + fun getContacts(): List { + val contentResolver = context.contentResolver ?: return emptyList() + val contacts = linkedMapOf() + val projection = arrayOf( + ContactsContract.CommonDataKinds.Phone.DISPLAY_NAME, + ContactsContract.CommonDataKinds.Phone.NUMBER + ) + + contentResolver.query( + ContactsContract.CommonDataKinds.Phone.CONTENT_URI, + projection, + null, + null, + "${ContactsContract.CommonDataKinds.Phone.DISPLAY_NAME} COLLATE NOCASE ASC" + )?.use { cursor -> + val nameIndex = cursor.getColumnIndex(ContactsContract.CommonDataKinds.Phone.DISPLAY_NAME) + val phoneIndex = cursor.getColumnIndex(ContactsContract.CommonDataKinds.Phone.NUMBER) + + while (cursor.moveToNext()) { + val rawPhone = if (phoneIndex >= 0) cursor.getString(phoneIndex) else null + val normalizedPhone = PhoneNumberNormalizer.normalize(rawPhone) ?: continue + if (contacts.containsKey(normalizedPhone)) { + continue + } + + val rawName = if (nameIndex >= 0) cursor.getString(nameIndex) else null + val displayName = rawName?.trim().takeUnless { it.isNullOrBlank() } ?: normalizedPhone + contacts[normalizedPhone] = DeviceContactRecord(displayName, normalizedPhone) + } + } + + return contacts.values.toList() + } +} diff --git a/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/core/contacts/PhoneNumberNormalizer.kt b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/core/contacts/PhoneNumberNormalizer.kt new file mode 100644 index 00000000..a6833aeb --- /dev/null +++ b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/core/contacts/PhoneNumberNormalizer.kt @@ -0,0 +1,16 @@ +package com.seven.ikar.kotlin.core.contacts + +object PhoneNumberNormalizer { + fun normalize(phoneNumber: String?): String? { + if (phoneNumber.isNullOrBlank()) { + return null + } + + val digits = phoneNumber.filter(Char::isDigit) + if (digits.length < 10) { + return null + } + + return if (digits.length == 10) digits else digits.takeLast(10) + } +} diff --git a/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/core/contacts/ResolvedContactsRepository.kt b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/core/contacts/ResolvedContactsRepository.kt new file mode 100644 index 00000000..9e6e6bda --- /dev/null +++ b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/core/contacts/ResolvedContactsRepository.kt @@ -0,0 +1,95 @@ +package com.seven.ikar.kotlin.core.contacts + +import com.seven.ikar.kotlin.core.model.UserSummaryDto +import com.seven.ikar.kotlin.core.network.ApiClient +import com.seven.ikar.kotlin.core.network.ServerConfig +import com.seven.ikar.kotlin.core.session.AuthorizedSessionManager +import com.seven.ikar.kotlin.core.session.SessionStore +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +data class DiscoverUserItem( + val user: UserSummaryDto, + val displayName: String, + val normalizedPhoneNumber: String, + val isSelected: Boolean = false +) { + val id: String get() = user.id + val username: String get() = user.username + val avatarUrl: String? get() = ServerConfig.normalizeMediaUrl(user.avatarPath) + val secondaryLine: String + get() = if (user.isBot) { + "бот · @${user.username}" + } else if (displayName == user.displayName) { + "" + } else { + "ИКАР: ${user.displayName}" + } +} + +class ResolvedContactsRepository( + private val apiClient: ApiClient, + private val sessionStore: SessionStore, + private val authorizedSessionManager: AuthorizedSessionManager, + private val contactDiscoveryService: AndroidContactDiscoveryService +) { + suspend fun loadResolvedContacts(): List = withContext(Dispatchers.IO) { + val contacts = contactDiscoveryService.getContacts() + if (sessionStore.read() == null) { + return@withContext emptyList() + } + val resolved = authorizedSessionManager.executeAuthorized { session -> + apiClient.resolveContacts( + accessToken = session.accessToken, + phoneNumbers = contacts.map { it.normalizedPhoneNumber }.distinct() + ) + } + + val contactMap = contacts.associateBy { it.normalizedPhoneNumber } + return@withContext resolved + .mapNotNull { match -> + val deviceContact = contactMap[match.phoneNumber] ?: return@mapNotNull null + DiscoverUserItem( + user = match.user, + displayName = deviceContact.displayName, + normalizedPhoneNumber = deviceContact.normalizedPhoneNumber + ) + } + .sortedWith( + compareByDescending { it.user.isBot } + .thenByDescending { it.user.isOnline } + .thenBy { it.displayName.lowercase() } + ) + } + + suspend fun loadMergedCandidates(query: String): List = withContext(Dispatchers.IO) { + if (sessionStore.read() == null) { + return@withContext emptyList() + } + val contacts = loadResolvedContacts() + if (query.isBlank()) { + return@withContext contacts + } + + val merged = contacts.toMutableList() + val existingIds = merged.map { it.id }.toMutableSet() + val remoteUsers = authorizedSessionManager.executeAuthorized { session -> + apiClient.searchUsers(session.accessToken, query) + } + remoteUsers.forEach { user -> + if (existingIds.add(user.id)) { + merged += DiscoverUserItem( + user = user, + displayName = user.displayName, + normalizedPhoneNumber = PhoneNumberNormalizer.normalize(user.phoneNumber) ?: "" + ) + } + } + + return@withContext merged.sortedWith( + compareByDescending { it.user.isBot } + .thenByDescending { it.user.isOnline } + .thenBy { it.displayName.lowercase() } + ) + } +} diff --git a/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/core/local/CachedChatEntity.kt b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/core/local/CachedChatEntity.kt new file mode 100644 index 00000000..9ce7ec9f --- /dev/null +++ b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/core/local/CachedChatEntity.kt @@ -0,0 +1,53 @@ +package com.seven.ikar.kotlin.core.local + +import androidx.room.ColumnInfo +import androidx.room.Dao +import androidx.room.Entity +import androidx.room.Index +import androidx.room.Query +import androidx.room.Upsert + +@Entity( + tableName = "cached_chats", + primaryKeys = ["owner_user_id", "chat_id"], + indices = [ + Index(value = ["owner_user_id", "is_archived", "is_pinned"]), + Index(value = ["owner_user_id", "last_activity_at"]) + ] +) +data class CachedChatEntity( + @ColumnInfo(name = "owner_user_id") + val ownerUserId: String, + @ColumnInfo(name = "chat_id") + val chatId: String, + @ColumnInfo(name = "last_activity_at") + val lastActivityAt: String?, + @ColumnInfo(name = "is_pinned") + val isPinned: Boolean, + @ColumnInfo(name = "is_archived") + val isArchived: Boolean, + @ColumnInfo(name = "unread_count") + val unreadCount: Int, + @ColumnInfo(name = "payload_json") + val payloadJson: String, + @ColumnInfo(name = "updated_at_millis") + val updatedAtMillis: Long +) + +@Dao +interface CachedChatDao { + @Query( + """ + SELECT * FROM cached_chats + WHERE owner_user_id = :ownerUserId + ORDER BY is_pinned DESC, last_activity_at DESC + """ + ) + suspend fun loadChats(ownerUserId: String): List + + @Upsert + suspend fun upsert(chats: List) + + @Query("DELETE FROM cached_chats WHERE owner_user_id = :ownerUserId") + suspend fun clear(ownerUserId: String) +} diff --git a/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/core/local/CachedChatMessageDao.kt b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/core/local/CachedChatMessageDao.kt new file mode 100644 index 00000000..d74e6cc8 --- /dev/null +++ b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/core/local/CachedChatMessageDao.kt @@ -0,0 +1,58 @@ +package com.seven.ikar.kotlin.core.local + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query + +@Dao +interface CachedChatMessageDao { + @Query( + """ + SELECT * FROM cached_chat_messages + WHERE owner_user_id = :ownerUserId AND chat_id = :chatId + ORDER BY sent_at DESC + LIMIT :limit + """ + ) + suspend fun loadLatestMessages( + ownerUserId: String, + chatId: String, + limit: Int + ): List + + @Query( + """ + SELECT * FROM cached_chat_messages + WHERE owner_user_id = :ownerUserId AND chat_id = :chatId AND is_optimistic = 1 + ORDER BY sent_at ASC + """ + ) + suspend fun loadOptimisticMessages( + ownerUserId: String, + chatId: String + ): List + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun upsert(messages: List) + + @Query( + """ + DELETE FROM cached_chat_messages + WHERE owner_user_id = :ownerUserId AND chat_id = :chatId AND message_id = :messageId + """ + ) + suspend fun deleteMessage( + ownerUserId: String, + chatId: String, + messageId: String + ) + + @Query( + """ + DELETE FROM cached_chat_messages + WHERE owner_user_id = :ownerUserId AND chat_id = :chatId + """ + ) + suspend fun clearChat(ownerUserId: String, chatId: String) +} diff --git a/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/core/local/CachedChatMessageEntity.kt b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/core/local/CachedChatMessageEntity.kt new file mode 100644 index 00000000..34ccedf7 --- /dev/null +++ b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/core/local/CachedChatMessageEntity.kt @@ -0,0 +1,28 @@ +package com.seven.ikar.kotlin.core.local + +import androidx.room.ColumnInfo +import androidx.room.Entity +import androidx.room.Index + +@Entity( + tableName = "cached_chat_messages", + primaryKeys = ["owner_user_id", "chat_id", "message_id"], + indices = [ + Index(value = ["owner_user_id", "chat_id", "sent_at"]), + Index(value = ["owner_user_id", "chat_id", "is_optimistic"]) + ] +) +data class CachedChatMessageEntity( + @ColumnInfo(name = "owner_user_id") + val ownerUserId: String, + @ColumnInfo(name = "chat_id") + val chatId: String, + @ColumnInfo(name = "message_id") + val messageId: String, + @ColumnInfo(name = "sent_at") + val sentAt: String, + @ColumnInfo(name = "payload_json") + val payloadJson: String, + @ColumnInfo(name = "is_optimistic") + val isOptimistic: Boolean +) diff --git a/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/core/local/ChatListCache.kt b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/core/local/ChatListCache.kt new file mode 100644 index 00000000..56091386 --- /dev/null +++ b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/core/local/ChatListCache.kt @@ -0,0 +1,38 @@ +package com.seven.ikar.kotlin.core.local + +import com.seven.ikar.kotlin.core.model.ChatSummaryDto +import kotlinx.serialization.decodeFromString +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json + +class ChatListCache( + private val dao: CachedChatDao, + private val json: Json +) { + suspend fun loadChats(ownerUserId: String): List = + dao.loadChats(ownerUserId) + .mapNotNull { entity -> runCatching { json.decodeFromString(entity.payloadJson) }.getOrNull() } + + suspend fun replaceChats(ownerUserId: String, chats: List) { + val now = System.currentTimeMillis() + dao.clear(ownerUserId) + if (chats.isEmpty()) { + return + } + + dao.upsert( + chats.map { chat -> + CachedChatEntity( + ownerUserId = ownerUserId, + chatId = chat.id, + lastActivityAt = chat.lastActivityAt, + isPinned = chat.isPinned, + isArchived = chat.isArchived, + unreadCount = chat.unreadCount, + payloadJson = json.encodeToString(chat), + updatedAtMillis = now + ) + } + ) + } +} diff --git a/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/core/local/ChatMessageCache.kt b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/core/local/ChatMessageCache.kt new file mode 100644 index 00000000..828e39d5 --- /dev/null +++ b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/core/local/ChatMessageCache.kt @@ -0,0 +1,74 @@ +package com.seven.ikar.kotlin.core.local + +import androidx.room.withTransaction +import com.seven.ikar.kotlin.core.model.MessageDto +import kotlinx.serialization.json.Json +import kotlinx.serialization.decodeFromString +import kotlinx.serialization.encodeToString + +class ChatMessageCache( + private val database: IkarLocalDatabase, + private val dao: CachedChatMessageDao, + private val json: Json +) { + suspend fun loadLatestMessages( + ownerUserId: String, + chatId: String, + limit: Int = LatestMessagesLimit + ): List = + dao.loadLatestMessages(ownerUserId, chatId, limit) + .mapNotNull { it.toMessageOrNull() } + .sortedBy(MessageDto::sentAt) + + suspend fun loadOptimisticMessages(ownerUserId: String, chatId: String): List = + dao.loadOptimisticMessages(ownerUserId, chatId) + .mapNotNull { it.toMessageOrNull() } + .sortedBy(MessageDto::sentAt) + + suspend fun upsertMessages( + ownerUserId: String, + chatId: String, + messages: List, + isOptimistic: Boolean = false + ) { + if (messages.isEmpty()) { + return + } + + dao.upsert( + messages.map { message -> + CachedChatMessageEntity( + ownerUserId = ownerUserId, + chatId = chatId, + messageId = message.id, + sentAt = message.sentAt, + payloadJson = json.encodeToString(message), + isOptimistic = isOptimistic + ) + } + ) + } + + suspend fun replaceOptimisticMessage( + ownerUserId: String, + chatId: String, + localMessageId: String, + serverMessage: MessageDto + ) { + database.withTransaction { + dao.deleteMessage(ownerUserId, chatId, localMessageId) + upsertMessages(ownerUserId, chatId, listOf(serverMessage), isOptimistic = false) + } + } + + suspend fun clearChat(ownerUserId: String, chatId: String) { + dao.clearChat(ownerUserId, chatId) + } + + private fun CachedChatMessageEntity.toMessageOrNull(): MessageDto? = + runCatching { json.decodeFromString(payloadJson) }.getOrNull() + + private companion object { + const val LatestMessagesLimit = 100 + } +} diff --git a/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/core/local/IkarLocalDatabase.kt b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/core/local/IkarLocalDatabase.kt new file mode 100644 index 00000000..418f408f --- /dev/null +++ b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/core/local/IkarLocalDatabase.kt @@ -0,0 +1,94 @@ +package com.seven.ikar.kotlin.core.local + +import android.content.Context +import androidx.room.Database +import androidx.room.Room +import androidx.room.RoomDatabase +import androidx.room.migration.Migration +import androidx.sqlite.db.SupportSQLiteDatabase + +@Database( + entities = [CachedChatMessageEntity::class, CachedChatEntity::class, TransferQueueEntity::class], + version = 4, + exportSchema = false +) +abstract class IkarLocalDatabase : RoomDatabase() { + abstract fun cachedChatMessageDao(): CachedChatMessageDao + abstract fun cachedChatDao(): CachedChatDao + abstract fun transferQueueDao(): TransferQueueDao + + companion object { + @Volatile + private var instance: IkarLocalDatabase? = null + + private val Migration1To2 = object : Migration(1, 2) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL( + """ + CREATE TABLE IF NOT EXISTS transfer_queue ( + id TEXT NOT NULL PRIMARY KEY, + groupId TEXT NOT NULL, + type TEXT NOT NULL, + status TEXT NOT NULL, + chatId TEXT, + text TEXT, + replyToMessageId TEXT, + attachmentId TEXT, + fileName TEXT NOT NULL, + contentType TEXT NOT NULL, + fileSizeBytes INTEGER NOT NULL, + localPath TEXT NOT NULL, + sortOrder INTEGER NOT NULL, + progressPercent INTEGER NOT NULL, + errorMessage TEXT, + createdAtMillis INTEGER NOT NULL, + updatedAtMillis INTEGER NOT NULL + ) + """.trimIndent() + ) + } + } + + private val Migration2To3 = object : Migration(2, 3) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL( + """ + CREATE TABLE IF NOT EXISTS cached_chats ( + owner_user_id TEXT NOT NULL, + chat_id TEXT NOT NULL, + last_activity_at TEXT, + is_pinned INTEGER NOT NULL, + is_archived INTEGER NOT NULL, + unread_count INTEGER NOT NULL, + payload_json TEXT NOT NULL, + updated_at_millis INTEGER NOT NULL, + PRIMARY KEY(owner_user_id, chat_id) + ) + """.trimIndent() + ) + db.execSQL("CREATE INDEX IF NOT EXISTS index_cached_chats_owner_user_id_is_archived_is_pinned ON cached_chats(owner_user_id, is_archived, is_pinned)") + db.execSQL("CREATE INDEX IF NOT EXISTS index_cached_chats_owner_user_id_last_activity_at ON cached_chats(owner_user_id, last_activity_at)") + } + } + + private val Migration3To4 = object : Migration(3, 4) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL("ALTER TABLE transfer_queue ADD COLUMN storyVisibility TEXT") + db.execSQL("ALTER TABLE transfer_queue ADD COLUMN hasProtectedContent INTEGER NOT NULL DEFAULT 0") + db.execSQL("ALTER TABLE transfer_queue ADD COLUMN ttlSeconds INTEGER") + } + } + + fun getInstance(context: Context): IkarLocalDatabase = + instance ?: synchronized(this) { + instance ?: Room.databaseBuilder( + context.applicationContext, + IkarLocalDatabase::class.java, + "ikar-local.db" + ) + .addMigrations(Migration1To2, Migration2To3, Migration3To4) + .build() + .also { instance = it } + } + } +} diff --git a/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/core/local/TransferQueueEntity.kt b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/core/local/TransferQueueEntity.kt new file mode 100644 index 00000000..e38cd9f2 --- /dev/null +++ b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/core/local/TransferQueueEntity.kt @@ -0,0 +1,73 @@ +package com.seven.ikar.kotlin.core.local + +import androidx.room.Dao +import androidx.room.Entity +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.PrimaryKey +import androidx.room.Query +import kotlinx.coroutines.flow.Flow + +@Entity(tableName = "transfer_queue") +data class TransferQueueEntity( + @PrimaryKey val id: String, + val groupId: String, + val type: String, + val status: String, + val chatId: String?, + val text: String?, + val replyToMessageId: String?, + val attachmentId: String?, + val storyVisibility: String?, + val hasProtectedContent: Boolean, + val ttlSeconds: Int?, + val fileName: String, + val contentType: String, + val fileSizeBytes: Long, + val localPath: String, + val sortOrder: Int, + val progressPercent: Int, + val errorMessage: String?, + val createdAtMillis: Long, + val updatedAtMillis: Long +) + +@Dao +interface TransferQueueDao { + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun upsert(items: List) + + @Query("SELECT * FROM transfer_queue WHERE groupId = :groupId ORDER BY sortOrder ASC") + suspend fun getGroup(groupId: String): List + + @Query("SELECT * FROM transfer_queue WHERE chatId = :chatId AND status IN ('queued', 'running', 'failed') ORDER BY createdAtMillis ASC, sortOrder ASC") + fun observeActiveForChat(chatId: String): Flow> + + @Query("SELECT * FROM transfer_queue WHERE type = 'story_upload' AND status IN ('queued', 'running', 'failed') ORDER BY createdAtMillis ASC, sortOrder ASC") + fun observeActiveStoryUploads(): Flow> + + @Query("UPDATE transfer_queue SET status = :status, progressPercent = :progressPercent, errorMessage = :errorMessage, updatedAtMillis = :updatedAtMillis WHERE groupId = :groupId") + suspend fun updateGroupStatus( + groupId: String, + status: String, + progressPercent: Int, + errorMessage: String?, + updatedAtMillis: Long + ) + + @Query("DELETE FROM transfer_queue WHERE groupId = :groupId") + suspend fun deleteGroup(groupId: String) +} + +object TransferQueueTypes { + const val Upload = "upload" + const val StoryUpload = "story_upload" + const val Download = "download" +} + +object TransferQueueStatus { + const val Queued = "queued" + const val Running = "running" + const val Completed = "completed" + const val Failed = "failed" +} diff --git a/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/core/model/Models.kt b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/core/model/Models.kt new file mode 100644 index 00000000..31da6a90 --- /dev/null +++ b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/core/model/Models.kt @@ -0,0 +1,793 @@ +package com.seven.ikar.kotlin.core.model + +import kotlinx.serialization.KSerializer +import kotlinx.serialization.Serializable +import kotlinx.serialization.descriptors.PrimitiveKind +import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder + +@Serializable(with = ChatTypeSerializer::class) +enum class ChatType(val wireValue: Int) { + Direct(1), + Group(2), + Channel(3); + + companion object { + fun fromWireValue(value: Int): ChatType = entries.firstOrNull { it.wireValue == value } ?: Direct + } +} + +@Serializable(with = ChatMemberRoleSerializer::class) +enum class ChatMemberRole(val wireValue: Int) { + Member(0), + Admin(1), + Owner(2); + + companion object { + fun fromWireValue(value: Int): ChatMemberRole = entries.firstOrNull { it.wireValue == value } ?: Member + } +} + +object ChatMemberRoleSerializer : KSerializer { + override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("ChatMemberRole", PrimitiveKind.INT) + override fun deserialize(decoder: Decoder): ChatMemberRole = ChatMemberRole.fromWireValue(decoder.decodeInt()) + override fun serialize(encoder: Encoder, value: ChatMemberRole) = encoder.encodeInt(value.wireValue) +} + +@Serializable(with = ChatJoinRequestStatusSerializer::class) +enum class ChatJoinRequestStatus(val wireValue: Int) { + Pending(0), + Approved(1), + Declined(2); + + companion object { + fun fromWireValue(value: Int): ChatJoinRequestStatus = entries.firstOrNull { it.wireValue == value } ?: Pending + } +} + +object ChatJoinRequestStatusSerializer : KSerializer { + override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("ChatJoinRequestStatus", PrimitiveKind.INT) + override fun deserialize(decoder: Decoder): ChatJoinRequestStatus = ChatJoinRequestStatus.fromWireValue(decoder.decodeInt()) + override fun serialize(encoder: Encoder, value: ChatJoinRequestStatus) = encoder.encodeInt(value.wireValue) +} + +@Serializable(with = ModerationLogTargetTypeSerializer::class) +enum class ModerationLogTargetType(val wireValue: Int) { + Chat(0), + Message(1), + User(2), + InviteLink(3), + JoinRequest(4), + Story(5); + + companion object { + fun fromWireValue(value: Int): ModerationLogTargetType = entries.firstOrNull { it.wireValue == value } ?: Chat + } +} + +object ModerationLogTargetTypeSerializer : KSerializer { + override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("ModerationLogTargetType", PrimitiveKind.INT) + override fun deserialize(decoder: Decoder): ModerationLogTargetType = ModerationLogTargetType.fromWireValue(decoder.decodeInt()) + override fun serialize(encoder: Encoder, value: ModerationLogTargetType) = encoder.encodeInt(value.wireValue) +} + +object ChatTypeSerializer : KSerializer { + override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("ChatType", PrimitiveKind.INT) + override fun deserialize(decoder: Decoder): ChatType = ChatType.fromWireValue(decoder.decodeInt()) + override fun serialize(encoder: Encoder, value: ChatType) = encoder.encodeInt(value.wireValue) +} + +@Serializable(with = AttachmentKindSerializer::class) +enum class AttachmentKind(val wireValue: Int) { + File(1), + VoiceNote(2); + + companion object { + fun fromWireValue(value: Int): AttachmentKind = entries.firstOrNull { it.wireValue == value } ?: File + } +} + +object AttachmentKindSerializer : KSerializer { + override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("AttachmentKind", PrimitiveKind.INT) + override fun deserialize(decoder: Decoder): AttachmentKind = AttachmentKind.fromWireValue(decoder.decodeInt()) + override fun serialize(encoder: Encoder, value: AttachmentKind) = encoder.encodeInt(value.wireValue) +} + +@Serializable(with = MessageDeliveryStateSerializer::class) +enum class MessageDeliveryState(val wireValue: Int) { + Sent(0), + Delivered(1), + Read(2); + + companion object { + fun fromWireValue(value: Int): MessageDeliveryState = entries.firstOrNull { it.wireValue == value } ?: Sent + } +} + +object MessageDeliveryStateSerializer : KSerializer { + override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("MessageDeliveryState", PrimitiveKind.INT) + override fun deserialize(decoder: Decoder): MessageDeliveryState = MessageDeliveryState.fromWireValue(decoder.decodeInt()) + override fun serialize(encoder: Encoder, value: MessageDeliveryState) = encoder.encodeInt(value.wireValue) +} + +@Serializable(with = CallMediaTypeSerializer::class) +enum class CallMediaType(val wireValue: Int) { + Audio(0), + Video(1); + + companion object { + fun fromWireValue(value: Int): CallMediaType = entries.firstOrNull { it.wireValue == value } ?: Audio + } +} + +object CallMediaTypeSerializer : KSerializer { + override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("CallMediaType", PrimitiveKind.INT) + override fun deserialize(decoder: Decoder): CallMediaType = CallMediaType.fromWireValue(decoder.decodeInt()) + override fun serialize(encoder: Encoder, value: CallMediaType) = encoder.encodeInt(value.wireValue) +} + +@Serializable(with = PushPlatformSerializer::class) +enum class PushPlatform(val wireValue: Int) { + Unknown(0), + Android(1), + Windows(2); + + companion object { + fun fromWireValue(value: Int): PushPlatform = entries.firstOrNull { it.wireValue == value } ?: Unknown + } +} + +object PushPlatformSerializer : KSerializer { + override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("PushPlatform", PrimitiveKind.INT) + override fun deserialize(decoder: Decoder): PushPlatform = PushPlatform.fromWireValue(decoder.decodeInt()) + override fun serialize(encoder: Encoder, value: PushPlatform) = encoder.encodeInt(value.wireValue) +} + +@Serializable(with = StoryVisibilitySerializer::class) +enum class StoryVisibility(val wireValue: Int) { + Everyone(0), + Contacts(1), + CloseFriends(2), + Private(3); + + companion object { + fun fromWireValue(value: Int): StoryVisibility = entries.firstOrNull { it.wireValue == value } ?: Everyone + } +} + +object StoryVisibilitySerializer : KSerializer { + override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("StoryVisibility", PrimitiveKind.INT) + override fun deserialize(decoder: Decoder): StoryVisibility = StoryVisibility.fromWireValue(decoder.decodeInt()) + override fun serialize(encoder: Encoder, value: StoryVisibility) = encoder.encodeInt(value.wireValue) +} + +@Serializable +data class UserSummaryDto( + val id: String, + val username: String, + val displayName: String, + val isOnline: Boolean, + val phoneNumber: String? = null, + val lastSeenAt: String? = null, + val about: String? = null, + val avatarPath: String? = null, + val isBot: Boolean = false +) + +@Serializable +data class AttachmentDto( + val id: String, + val fileName: String, + val contentType: String, + val fileSizeBytes: Long, + val downloadPath: String, + val kind: AttachmentKind, + val sortOrder: Int = 0 +) + +@Serializable +data class MessageReplyDto( + val messageId: String, + val senderId: String, + val senderDisplayName: String, + val previewText: String +) + +@Serializable +data class MessageReactionDto( + val emoji: String, + val count: Int, + val isMine: Boolean +) + +@Serializable +data class MessageDto( + val id: String, + val chatId: String, + val chatType: ChatType = ChatType.Direct, + val sender: UserSummaryDto, + val text: String, + val attachments: List = emptyList(), + val sentAt: String, + val editedAt: String? = null, + val deletedAt: String? = null, + val deliveryState: MessageDeliveryState, + val forwardedFromDisplayName: String? = null, + val replyTo: MessageReplyDto? = null, + val pinnedAt: String? = null, + val pinnedBy: UserSummaryDto? = null, + val mediaAlbumId: String? = null, + val reactions: List = emptyList(), + val hasProtectedContent: Boolean = false, + val expiresAt: String? = null, + val ttlSeconds: Int? = null, + val postedAsChannel: Boolean = false, + val displayAuthorName: String? = null, + val viewCount: Int? = null +) + +@Serializable +data class ChatPermissionsDto( + val canPostMessages: Boolean = true, + val canPinMessages: Boolean = true, + val canDeleteMessages: Boolean = false, + val canInviteUsers: Boolean = true, + val canManageMembers: Boolean = false +) + +@Serializable +data class ChatMemberDto( + val user: UserSummaryDto, + val role: ChatMemberRole = ChatMemberRole.Member, + val isOwner: Boolean = false, + val permissions: ChatPermissionsDto = ChatPermissionsDto() +) + +@Serializable +data class ChatSummaryDto( + val id: String, + val title: String, + val type: ChatType, + val secondaryText: String? = null, + val lastActivityAt: String? = null, + val lastMessagePreview: String? = null, + val participants: List = emptyList(), + val canSendMessages: Boolean = true, + val unreadCount: Int = 0, + val isPinned: Boolean = false, + val isArchived: Boolean = false, + val isMuted: Boolean = false, + val folderKey: String? = null, + val hasProtectedContent: Boolean = false, + val defaultMessageTtlSeconds: Int? = null, + val lastMessageAttachment: AttachmentDto? = null, + val subscriberCount: Int = 0, + val adminCount: Int = 0, + val isSubscribed: Boolean = false, + val canSubscribe: Boolean = false, + val canManageChannel: Boolean = false, + val canPostAsChannel: Boolean = false, + val publicUsername: String? = null, + val linkedDiscussionChatId: String? = null, + val channelSignaturesEnabled: Boolean = false, + val description: String? = null +) + +@Serializable +data class ChatDetailsDto( + val id: String, + val title: String, + val type: ChatType, + val participants: List = emptyList(), + val canSendMessages: Boolean = true, + val messages: List = emptyList(), + val firstUnreadMessageId: String? = null, + val hasMoreMessages: Boolean = false, + val pinnedMessages: List = emptyList(), + val currentUserPermissions: ChatPermissionsDto? = null, + val members: List = emptyList(), + val hasProtectedContent: Boolean = false, + val defaultMessageTtlSeconds: Int? = null, + val subscriberCount: Int = 0, + val adminCount: Int = 0, + val isSubscribed: Boolean = false, + val canSubscribe: Boolean = false, + val canManageChannel: Boolean = false, + val canPostAsChannel: Boolean = false, + val publicUsername: String? = null, + val linkedDiscussionChatId: String? = null, + val channelSignaturesEnabled: Boolean = false, + val description: String? = null +) + +@Serializable +data class ChatInfoDto( + val chat: ChatDetailsDto, + val mediaCount: Int = 0, + val fileCount: Int = 0, + val linkCount: Int = 0, + val voiceCount: Int = 0, + val pinnedCount: Int = 0, + val topicCount: Int = 0, + val publicUsername: String? = null, + val inviteLinks: List = emptyList(), + val topics: List = emptyList(), + val subscriberCount: Int = 0, + val adminCount: Int = 0, + val linkedDiscussionChatId: String? = null, + val channelSignaturesEnabled: Boolean = false, + val canManageChannel: Boolean = false, + val canPostAsChannel: Boolean = false, + val description: String? = null +) + +@Serializable +data class ChatMessagesPageDto( + val messages: List = emptyList(), + val hasMoreMessages: Boolean = false +) + +@Serializable +data class TypingIndicatorDto( + val chatId: String, + val user: UserSummaryDto, + val isTyping: Boolean, + val typedAt: String +) + +@Serializable +data class ChatFolderDto( + val key: String, + val title: String, + val totalCount: Int, + val unreadCount: Int +) + +@Serializable +data class ChatStateRequest( + val isPinned: Boolean? = null, + val isArchived: Boolean? = null, + val mutedUntil: String? = null, + val folderKey: String? = null, + val isMuted: Boolean? = null, + val hasProtectedContent: Boolean? = null, + val defaultMessageTtlSeconds: Int? = null, + val channelSignaturesEnabled: Boolean? = null, + val linkedDiscussionChatId: String? = null +) + +@Serializable +data class ChatInviteLinkDto( + val id: String, + val chatId: String, + val token: String, + val link: String, + val title: String? = null, + val createsJoinRequest: Boolean = false, + val isRevoked: Boolean = false, + val createdAt: String, + val revokedAt: String? = null +) + +@Serializable +data class CreateChatInviteLinkRequest( + val title: String? = null, + val createsJoinRequest: Boolean = false +) + +@Serializable +data class ChatJoinRequestDto( + val id: String, + val chatId: String, + val inviteLinkId: String, + val user: UserSummaryDto, + val status: ChatJoinRequestStatus, + val requestedAt: String, + val resolvedAt: String? = null, + val resolvedBy: UserSummaryDto? = null +) + +@Serializable +data class ResolveChatJoinRequestRequest( + val approve: Boolean +) + +@Serializable +data class ModerationLogDto( + val id: Long, + val chatId: String? = null, + val actorUserId: String, + val actorDisplayName: String, + val targetType: ModerationLogTargetType, + val targetId: String? = null, + val action: String, + val reason: String? = null, + val createdAt: String +) + +@Serializable +data class ChatTopicDto( + val id: String, + val chatId: String, + val title: String, + val icon: String = "💬", + val isPinned: Boolean = false, + val isClosed: Boolean = false, + val createdAt: String, + val lastActivityAt: String, + val messageCount: Int = 0 +) + +@Serializable +data class CreateChatTopicRequest( + val title: String, + val icon: String? = null +) + +@Serializable +data class UpdateChatMemberPermissionsRequest( + val role: ChatMemberRole, + val canPinMessages: Boolean, + val canDeleteMessages: Boolean, + val canInviteUsers: Boolean, + val canPostMessages: Boolean +) + +@Serializable +data class AddChatMembersRequest( + val memberIds: List, + val role: ChatMemberRole = ChatMemberRole.Member, + val canPinMessages: Boolean = false, + val canDeleteMessages: Boolean = false, + val canInviteUsers: Boolean = false, + val canPostMessages: Boolean = false +) + +@Serializable +data class ChatSearchResultDto( + val chat: ChatSummaryDto, + val message: MessageDto? = null, + val matchText: String, + val matchedAt: String? = null, + val chatId: String? = null, + val messageId: String? = null, + val senderUserId: String? = null, + val messageSentAt: String? = null +) + +@Serializable +data class ChatSearchPageDto( + val results: List = emptyList(), + val page: Int = 1, + val limit: Int = 50, + val hasMore: Boolean = false +) + +@Serializable +data class MessageSearchPageDto( + val messages: List = emptyList(), + val page: Int = 1, + val limit: Int = 50, + val hasMore: Boolean = false +) + +@Serializable +data class CreateDirectChatRequest( + val userId: String +) + +@Serializable +data class CreateGroupChatRequest( + val title: String, + val memberIds: List +) + +@Serializable +data class CreateChannelRequest( + val title: String, + val memberIds: List, + val description: String? = null, + val publicUsername: String? = null, + val createInviteLink: Boolean = false, + val channelSignaturesEnabled: Boolean = false +) + +@Serializable +data class RequestPhoneCodeRequest( + val phoneNumber: String +) + +@Serializable +data class PhoneCodeChallengeDto( + val challengeId: String, + val expiresAt: String, + val testCode: String, + val isRegistered: Boolean +) + +@Serializable +data class VerifyPhoneCodeRequest( + val challengeId: String, + val phoneNumber: String, + val code: String, + val displayName: String? = null +) + +@Serializable +data class RefreshSessionRequest( + val refreshToken: String +) + +@Serializable +data class AuthSessionDto( + val accessToken: String, + val refreshToken: String, + val accessTokenExpiresAt: String, + val user: UserSummaryDto +) + +@Serializable +data class SendMessageRequest( + val text: String, + val replyToMessageId: String? = null, + val topicId: String? = null, + val hasProtectedContent: Boolean? = null, + val ttlSeconds: Int? = null +) + +@Serializable +data class UpdateMessageRequest( + val text: String +) + +@Serializable +data class UpdateMessageReceiptRequest( + val messageId: String +) + +@Serializable +data class ForwardMessageRequest( + val targetChatId: String +) + +@Serializable +data class SetMessageReactionRequest( + val emoji: String +) + +@Serializable +data class RegisterPushDeviceRequest( + val platform: PushPlatform, + val installationId: String, + val deviceToken: String, + val deviceName: String? = null, + val notificationsEnabled: Boolean +) + +@Serializable +data class AndroidAppReleaseDto( + val versionCode: Int, + val versionName: String, + val downloadPath: String, + val publishedAt: String, + val notes: String? = null, + val isRequired: Boolean = false, + val minimumSupportedVersionCode: Int? = null +) + +@Serializable +data class ArgusManifestDto( + val release: ArgusReleaseDto +) + +@Serializable +data class ArgusReleaseDto( + val id: String, + val version: String, + val downloadPath: String, + val originalFileName: String? = null, + val packageSizeBytes: Long? = null, + val publishedAt: String, + val releaseNotes: String? = null +) + +@Serializable +data class ResolveContactsRequest( + val phoneNumbers: List +) + +@Serializable +data class ContactMatchDto( + val phoneNumber: String, + val user: UserSummaryDto +) + +@Serializable +data class StoryDto( + val id: String, + val author: UserSummaryDto, + val text: String, + val attachments: List = emptyList(), + val visibility: StoryVisibility = StoryVisibility.Everyone, + val hasProtectedContent: Boolean = false, + val createdAt: String, + val expiresAt: String, + val deletedAt: String? = null, + val viewCount: Int = 0, + val viewedByMe: Boolean = false +) + +@Serializable +data class CreateStoryRequest( + val text: String? = null, + val visibility: StoryVisibility = StoryVisibility.Everyone, + val hasProtectedContent: Boolean = false, + val ttlSeconds: Int = 86_400 +) + +@Serializable +data class StoryViewDto( + val storyId: String, + val viewer: UserSummaryDto, + val viewedAt: String +) + +@Serializable +data class UpdateMyProfileRequest( + val displayName: String, + val about: String? = null +) + +@Serializable +data class PrivacySettingsDto( + val phoneNumberVisibility: String = "contacts", + val lastSeenVisibility: String = "contacts", + val profilePhotoVisibility: String = "everyone", + val blockedUserIds: List = emptyList(), + val alwaysAllowUserIds: List = emptyList(), + val neverAllowUserIds: List = emptyList(), + val passcodeLockEnabled: Boolean = false, + val showMessagePreview: Boolean = true, + val updatedAt: String +) + +@Serializable +data class UpdatePrivacySettingsRequest( + val phoneNumberVisibility: String, + val lastSeenVisibility: String, + val profilePhotoVisibility: String, + val blockedUserIds: List? = null, + val alwaysAllowUserIds: List? = null, + val neverAllowUserIds: List? = null, + val passcodeLockEnabled: Boolean, + val showMessagePreview: Boolean +) + +@Serializable +data class UserSessionDto( + val id: String, + val createdAt: String, + val lastSeenAt: String, + val isCurrent: Boolean, + val deviceName: String +) + +@Serializable +data class BotCommandDto( + val command: String, + val description: String +) + +@Serializable +data class BotSummaryDto( + val id: String, + val userId: String, + val username: String, + val displayName: String, + val about: String? = null, + val isEnabled: Boolean, + val createdAt: String, + val commands: List = emptyList() +) + +@Serializable +data class CreateBotRequest( + val username: String, + val displayName: String, + val about: String? = null +) + +@Serializable +data class UpdateBotRequest( + val displayName: String, + val about: String? = null, + val isEnabled: Boolean +) + +@Serializable +data class CreatedBotDto( + val bot: BotSummaryDto, + val accessToken: String +) + +@Serializable +data class RegenerateBotTokenResponseDto( + val accessToken: String +) + +@Serializable +data class SetBotCommandsRequest( + val commands: List +) + +@Serializable +data class OutgoingCallDto( + val callId: String, + val chatId: String, + val peer: UserSummaryDto, + val mediaType: CallMediaType +) + +@Serializable +data class IncomingCallDto( + val callId: String, + val chatId: String, + val caller: UserSummaryDto, + val mediaType: CallMediaType +) + +@Serializable +data class CallConnectedDto( + val callId: String, + val chatId: String, + val connectedAt: String, + val mediaType: CallMediaType, + val iceServers: List = emptyList(), + val shouldCreateOffer: Boolean = false +) + +@Serializable +data class CallEndedDto( + val callId: String, + val chatId: String, + val reason: String, + val mediaType: CallMediaType +) + +@Serializable +data class CallIceServerDto( + val urls: List, + val username: String? = null, + val credential: String? = null +) + +@Serializable +data class CallOfferDto( + val callId: String, + val type: String, + val sdp: String +) + +@Serializable +data class CallAnswerDto( + val callId: String, + val type: String, + val sdp: String +) + +@Serializable +data class CallIceCandidateDto( + val callId: String, + val candidate: String, + val sdpMid: String? = null, + val sdpMLineIndex: Int +) + +data class DownloadedAttachmentFile( + val fileName: String, + val contentType: String, + val fileSizeBytes: Long, + val localPath: String +) diff --git a/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/core/network/ApiClient.kt b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/core/network/ApiClient.kt new file mode 100644 index 00000000..332a9c28 --- /dev/null +++ b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/core/network/ApiClient.kt @@ -0,0 +1,1390 @@ +package com.seven.ikar.kotlin.core.network + +import com.seven.ikar.kotlin.core.model.AndroidAppReleaseDto +import com.seven.ikar.kotlin.core.model.AddChatMembersRequest +import com.seven.ikar.kotlin.core.model.ArgusManifestDto +import com.seven.ikar.kotlin.core.model.AttachmentDto +import com.seven.ikar.kotlin.core.model.AuthSessionDto +import com.seven.ikar.kotlin.core.model.BotSummaryDto +import com.seven.ikar.kotlin.core.model.BotCommandDto +import com.seven.ikar.kotlin.core.model.ChatDetailsDto +import com.seven.ikar.kotlin.core.model.ChatFolderDto +import com.seven.ikar.kotlin.core.model.ChatInfoDto +import com.seven.ikar.kotlin.core.model.ChatMessagesPageDto +import com.seven.ikar.kotlin.core.model.ChatSearchPageDto +import com.seven.ikar.kotlin.core.model.ChatSearchResultDto +import com.seven.ikar.kotlin.core.model.ChatStateRequest +import com.seven.ikar.kotlin.core.model.ChatSummaryDto +import com.seven.ikar.kotlin.core.model.ChatInviteLinkDto +import com.seven.ikar.kotlin.core.model.ChatJoinRequestDto +import com.seven.ikar.kotlin.core.model.ChatTopicDto +import com.seven.ikar.kotlin.core.model.CreateChatInviteLinkRequest +import com.seven.ikar.kotlin.core.model.ContactMatchDto +import com.seven.ikar.kotlin.core.model.CreateBotRequest +import com.seven.ikar.kotlin.core.model.CreateChannelRequest +import com.seven.ikar.kotlin.core.model.CreateChatTopicRequest +import com.seven.ikar.kotlin.core.model.CreateStoryRequest +import com.seven.ikar.kotlin.core.model.CreatedBotDto +import com.seven.ikar.kotlin.core.model.CreateDirectChatRequest +import com.seven.ikar.kotlin.core.model.CreateGroupChatRequest +import com.seven.ikar.kotlin.core.model.DownloadedAttachmentFile +import com.seven.ikar.kotlin.core.model.ForwardMessageRequest +import com.seven.ikar.kotlin.core.model.MessageDto +import com.seven.ikar.kotlin.core.model.MessageSearchPageDto +import com.seven.ikar.kotlin.core.model.ModerationLogDto +import com.seven.ikar.kotlin.core.model.PhoneCodeChallengeDto +import com.seven.ikar.kotlin.core.model.PrivacySettingsDto +import com.seven.ikar.kotlin.core.model.RegenerateBotTokenResponseDto +import com.seven.ikar.kotlin.core.model.RequestPhoneCodeRequest +import com.seven.ikar.kotlin.core.model.RegisterPushDeviceRequest +import com.seven.ikar.kotlin.core.model.RefreshSessionRequest +import com.seven.ikar.kotlin.core.model.ResolveChatJoinRequestRequest +import com.seven.ikar.kotlin.core.model.ResolveContactsRequest +import com.seven.ikar.kotlin.core.model.SendMessageRequest +import com.seven.ikar.kotlin.core.model.SetMessageReactionRequest +import com.seven.ikar.kotlin.core.model.SetBotCommandsRequest +import com.seven.ikar.kotlin.core.model.StoryDto +import com.seven.ikar.kotlin.core.model.StoryViewDto +import com.seven.ikar.kotlin.core.model.StoryVisibility +import com.seven.ikar.kotlin.core.model.UpdateBotRequest +import com.seven.ikar.kotlin.core.model.UpdateChatMemberPermissionsRequest +import com.seven.ikar.kotlin.core.model.UpdateMessageRequest +import com.seven.ikar.kotlin.core.model.UpdateMessageReceiptRequest +import com.seven.ikar.kotlin.core.model.UpdateMyProfileRequest +import com.seven.ikar.kotlin.core.model.UpdatePrivacySettingsRequest +import com.seven.ikar.kotlin.core.model.UserSessionDto +import com.seven.ikar.kotlin.core.model.UserSummaryDto +import com.seven.ikar.kotlin.core.model.VerifyPhoneCodeRequest +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import kotlinx.serialization.SerializationException +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import okhttp3.MediaType.Companion.toMediaTypeOrNull +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.MultipartBody +import okhttp3.OkHttpClient +import okhttp3.RequestBody +import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody +import okio.BufferedSink +import java.io.File +import java.io.IOException +import java.io.InputStream +import java.net.URLEncoder +import java.nio.charset.StandardCharsets + +class ApiException(val statusCode: Int, override val message: String) : RuntimeException(message) + +class UploadFileSource( + val fileName: String, + val contentType: String?, + val contentLength: Long = -1L, + private val onBytesWritten: ((Long) -> Unit)? = null, + private val openStream: () -> InputStream +) { + fun asRequestBody(): RequestBody { + val mediaType = contentType?.toMediaTypeOrNull() ?: "application/octet-stream".toMediaType() + return object : RequestBody() { + override fun contentType() = mediaType + override fun contentLength(): Long = this@UploadFileSource.contentLength.takeIf { it >= 0L } ?: -1L + + override fun writeTo(sink: BufferedSink) { + openStream().use { input -> + val buffer = ByteArray(DEFAULT_BUFFER_SIZE) + var bytesWritten = 0L + while (true) { + val read = input.read(buffer) + if (read < 0) { + return + } + sink.write(buffer, 0, read) + bytesWritten += read + onBytesWritten?.invoke(bytesWritten) + } + } + } + } + } +} + +class ApiClient( + private val httpClientProvider: () -> OkHttpClient, + private val json: Json, + private val clientVersionProvider: ClientVersionProvider +) { + constructor( + httpClient: OkHttpClient, + json: Json, + clientVersionProvider: ClientVersionProvider + ) : this({ httpClient }, json, clientVersionProvider) + + private val httpClient: OkHttpClient + get() = httpClientProvider() + + suspend fun requestPhoneCode(phoneNumber: String): PhoneCodeChallengeDto = + executeJsonRequest( + Request.Builder() + .url(ServerConfig.absoluteMessengerUrl("/api/auth/request-code")) + .post( + json.encodeToString( + RequestPhoneCodeRequest.serializer(), + RequestPhoneCodeRequest(phoneNumber = phoneNumber) + ).toRequestBody(JSON) + ) + .withClientVersionHeaders() + .build(), + PhoneCodeChallengeDto.serializer() + ) + + suspend fun verifyPhoneCode( + challengeId: String, + phoneNumber: String, + code: String, + displayName: String? + ): AuthSessionDto = + executeJsonRequest( + Request.Builder() + .url(ServerConfig.absoluteMessengerUrl("/api/auth/verify-code")) + .post( + json.encodeToString( + VerifyPhoneCodeRequest.serializer(), + VerifyPhoneCodeRequest( + challengeId = challengeId, + phoneNumber = phoneNumber, + code = code, + displayName = displayName?.takeIf { it.isNotBlank() } + ) + ).toRequestBody(JSON) + ) + .withClientVersionHeaders() + .build(), + AuthSessionDto.serializer() + ) + + suspend fun refreshSession(refreshToken: String): AuthSessionDto = + executeJsonRequest( + Request.Builder() + .url(ServerConfig.absoluteMessengerUrl("/api/auth/refresh")) + .post( + json.encodeToString( + RefreshSessionRequest.serializer(), + RefreshSessionRequest(refreshToken) + ).toRequestBody(JSON) + ) + .withClientVersionHeaders() + .build(), + AuthSessionDto.serializer() + ) + + suspend fun getChats(accessToken: String): List = + executeJsonRequest( + Request.Builder() + .url(ServerConfig.absoluteMessengerUrl("/api/chats")) + .get() + .withBearer(accessToken) + .withClientVersionHeaders() + .build(), + kotlinx.serialization.builtins.ListSerializer(ChatSummaryDto.serializer()) + ) + + suspend fun getChatFolders(accessToken: String): List = + executeJsonRequest( + Request.Builder() + .url(ServerConfig.absoluteMessengerUrl("/api/chats/folders")) + .get() + .withBearer(accessToken) + .withClientVersionHeaders() + .build(), + kotlinx.serialization.builtins.ListSerializer(ChatFolderDto.serializer()) + ) + + suspend fun searchChats(accessToken: String, query: String, filter: String? = null, limit: Int = 50): List = + searchChatsPage( + accessToken = accessToken, + query = query, + filter = filter, + limit = limit + ).results + + suspend fun searchChatsPage( + accessToken: String, + query: String, + filter: String? = null, + type: String? = null, + page: Int = 1, + limit: Int = 50, + dateFrom: String? = null, + dateTo: String? = null, + author: String? = null + ): ChatSearchPageDto = + executeJsonRequest( + Request.Builder() + .url( + ServerConfig.absoluteMessengerUrl( + buildSearchPath( + basePath = "/api/chats/search/page", + query = query, + filter = filter, + type = type, + page = page, + limit = limit, + dateFrom = dateFrom, + dateTo = dateTo, + author = author + ) + ) + ) + .get() + .withBearer(accessToken) + .withClientVersionHeaders() + .build(), + ChatSearchPageDto.serializer() + ) + + suspend fun getChat(chatId: String, accessToken: String): ChatDetailsDto = + executeJsonRequest( + Request.Builder() + .url(ServerConfig.absoluteMessengerUrl("/api/chats/$chatId")) + .get() + .withBearer(accessToken) + .withClientVersionHeaders() + .build(), + ChatDetailsDto.serializer() + ) + + suspend fun getChatInfo(accessToken: String, chatId: String): ChatInfoDto = + executeJsonRequest( + Request.Builder() + .url(ServerConfig.absoluteMessengerUrl("/api/chats/$chatId/info")) + .get() + .withBearer(accessToken) + .withClientVersionHeaders() + .build(), + ChatInfoDto.serializer() + ) + + suspend fun getChatInviteLinks(accessToken: String, chatId: String): List = + executeJsonRequest( + Request.Builder() + .url(ServerConfig.absoluteMessengerUrl("/api/chats/$chatId/invite-links")) + .get() + .withBearer(accessToken) + .withClientVersionHeaders() + .build(), + kotlinx.serialization.builtins.ListSerializer(ChatInviteLinkDto.serializer()) + ) + + suspend fun createChatInviteLink( + accessToken: String, + chatId: String, + title: String? = null, + createsJoinRequest: Boolean = false + ): ChatInviteLinkDto = + executeJsonRequest( + Request.Builder() + .url(ServerConfig.absoluteMessengerUrl("/api/chats/$chatId/invite-links")) + .post( + json.encodeToString( + CreateChatInviteLinkRequest.serializer(), + CreateChatInviteLinkRequest(title = title?.takeIf { it.isNotBlank() }, createsJoinRequest = createsJoinRequest) + ).toRequestBody(JSON) + ) + .withBearer(accessToken) + .withClientVersionHeaders() + .build(), + ChatInviteLinkDto.serializer() + ) + + suspend fun revokeChatInviteLink(accessToken: String, chatId: String, inviteLinkId: String) = + executeUnitRequest( + Request.Builder() + .url(ServerConfig.absoluteMessengerUrl("/api/chats/$chatId/invite-links/$inviteLinkId")) + .delete() + .withBearer(accessToken) + .withClientVersionHeaders() + .build() + ) + + suspend fun joinChatByInviteLink(accessToken: String, token: String): ChatDetailsDto = + executeJsonRequest( + Request.Builder() + .url(ServerConfig.absoluteMessengerUrl("/api/chats/join/${encodeQuery(token)}")) + .post("".toRequestBody(null)) + .withBearer(accessToken) + .withClientVersionHeaders() + .build(), + ChatDetailsDto.serializer() + ) + + suspend fun getChatJoinRequests(accessToken: String, chatId: String): List = + executeJsonRequest( + Request.Builder() + .url(ServerConfig.absoluteMessengerUrl("/api/chats/$chatId/join-requests")) + .get() + .withBearer(accessToken) + .withClientVersionHeaders() + .build(), + kotlinx.serialization.builtins.ListSerializer(ChatJoinRequestDto.serializer()) + ) + + suspend fun resolveChatJoinRequest( + accessToken: String, + chatId: String, + joinRequestId: String, + approve: Boolean + ): ChatJoinRequestDto = + executeJsonRequest( + Request.Builder() + .url(ServerConfig.absoluteMessengerUrl("/api/chats/$chatId/join-requests/$joinRequestId")) + .post( + json.encodeToString( + ResolveChatJoinRequestRequest.serializer(), + ResolveChatJoinRequestRequest(approve = approve) + ).toRequestBody(JSON) + ) + .withBearer(accessToken) + .withClientVersionHeaders() + .build(), + ChatJoinRequestDto.serializer() + ) + + suspend fun getChatTopics(accessToken: String, chatId: String): List = + executeJsonRequest( + Request.Builder() + .url(ServerConfig.absoluteMessengerUrl("/api/chats/$chatId/topics")) + .get() + .withBearer(accessToken) + .withClientVersionHeaders() + .build(), + kotlinx.serialization.builtins.ListSerializer(ChatTopicDto.serializer()) + ) + + suspend fun createChatTopic(accessToken: String, chatId: String, title: String, icon: String? = null): ChatTopicDto = + executeJsonRequest( + Request.Builder() + .url(ServerConfig.absoluteMessengerUrl("/api/chats/$chatId/topics")) + .post( + json.encodeToString( + CreateChatTopicRequest.serializer(), + CreateChatTopicRequest(title = title, icon = icon?.takeIf { it.isNotBlank() }) + ).toRequestBody(JSON) + ) + .withBearer(accessToken) + .withClientVersionHeaders() + .build(), + ChatTopicDto.serializer() + ) + + suspend fun getChatMessagesPage( + accessToken: String, + chatId: String, + beforeMessageId: String, + limit: Int = 50 + ): ChatMessagesPageDto = + executeJsonRequest( + Request.Builder() + .url(ServerConfig.absoluteMessengerUrl("/api/chats/$chatId/messages?beforeMessageId=$beforeMessageId&limit=$limit")) + .get() + .withBearer(accessToken) + .withClientVersionHeaders() + .build(), + ChatMessagesPageDto.serializer() + ) + + suspend fun searchChatMessages( + accessToken: String, + chatId: String, + query: String, + filter: String? = null, + limit: Int = 50 + ): List = + searchChatMessagesPage( + accessToken = accessToken, + chatId = chatId, + query = query, + filter = filter, + limit = limit + ).messages + + suspend fun searchChatMessagesPage( + accessToken: String, + chatId: String, + query: String, + filter: String? = null, + type: String? = null, + page: Int = 1, + limit: Int = 50, + dateFrom: String? = null, + dateTo: String? = null, + author: String? = null + ): MessageSearchPageDto = + executeJsonRequest( + Request.Builder() + .url( + ServerConfig.absoluteMessengerUrl( + buildSearchPath( + basePath = "/api/chats/$chatId/messages/search/page", + query = query, + filter = filter, + type = type, + page = page, + limit = limit, + dateFrom = dateFrom, + dateTo = dateTo, + author = author + ) + ) + ) + .get() + .withBearer(accessToken) + .withClientVersionHeaders() + .build(), + MessageSearchPageDto.serializer() + ) + + suspend fun updateChatState(accessToken: String, chatId: String, request: ChatStateRequest): ChatSummaryDto = + executeJsonRequest( + Request.Builder() + .url(ServerConfig.absoluteMessengerUrl("/api/chats/$chatId/state")) + .put( + json.encodeToString( + ChatStateRequest.serializer(), + request + ).toRequestBody(JSON) + ) + .withBearer(accessToken) + .withClientVersionHeaders() + .build(), + ChatSummaryDto.serializer() + ) + + suspend fun updateChatMemberPermissions( + accessToken: String, + chatId: String, + memberUserId: String, + request: UpdateChatMemberPermissionsRequest + ): ChatDetailsDto = + executeJsonRequest( + Request.Builder() + .url(ServerConfig.absoluteMessengerUrl("/api/chats/$chatId/members/$memberUserId/permissions")) + .put( + json.encodeToString( + UpdateChatMemberPermissionsRequest.serializer(), + request + ).toRequestBody(JSON) + ) + .withBearer(accessToken) + .withClientVersionHeaders() + .build(), + ChatDetailsDto.serializer() + ) + + suspend fun addChatMembers( + accessToken: String, + chatId: String, + request: AddChatMembersRequest + ): ChatDetailsDto = + executeJsonRequest( + Request.Builder() + .url(ServerConfig.absoluteMessengerUrl("/api/chats/$chatId/members")) + .post( + json.encodeToString( + AddChatMembersRequest.serializer(), + request + ).toRequestBody(JSON) + ) + .withBearer(accessToken) + .withClientVersionHeaders() + .build(), + ChatDetailsDto.serializer() + ) + + suspend fun getChatModerationLog(accessToken: String, chatId: String, limit: Int = 100): List = + executeJsonRequest( + Request.Builder() + .url(ServerConfig.absoluteMessengerUrl("/api/chats/$chatId/moderation-log?limit=$limit")) + .get() + .withBearer(accessToken) + .withClientVersionHeaders() + .build(), + kotlinx.serialization.builtins.ListSerializer(ModerationLogDto.serializer()) + ) + + suspend fun deleteChat(accessToken: String, chatId: String, forEveryone: Boolean) = + executeUnitRequest( + Request.Builder() + .url(ServerConfig.absoluteMessengerUrl("/api/chats/$chatId?forEveryone=$forEveryone")) + .delete() + .withBearer(accessToken) + .withClientVersionHeaders() + .build() + ) + + suspend fun sendMessage( + chatId: String, + text: String, + accessToken: String, + replyToMessageId: String? = null, + topicId: String? = null, + hasProtectedContent: Boolean? = null, + ttlSeconds: Int? = null + ): MessageDto = + executeJsonRequest( + Request.Builder() + .url(ServerConfig.absoluteMessengerUrl("/api/chats/$chatId/messages")) + .post( + json.encodeToString( + SendMessageRequest.serializer(), + SendMessageRequest( + text = text, + replyToMessageId = replyToMessageId, + topicId = topicId, + hasProtectedContent = hasProtectedContent, + ttlSeconds = ttlSeconds + ) + ).toRequestBody(JSON) + ) + .withBearer(accessToken) + .withClientVersionHeaders() + .build(), + MessageDto.serializer() + ) + + suspend fun getBotCommands(accessToken: String, userId: String): List = + executeJsonRequest( + Request.Builder() + .url(ServerConfig.absoluteMessengerUrl("/api/users/$userId/bot-commands")) + .get() + .withBearer(accessToken) + .withClientVersionHeaders() + .build(), + kotlinx.serialization.builtins.ListSerializer(BotCommandDto.serializer()) + ) + + suspend fun clearChatHistory(accessToken: String, chatId: String) = + executeUnitRequest( + Request.Builder() + .url(ServerConfig.absoluteMessengerUrl("/api/chats/$chatId/history")) + .delete() + .withBearer(accessToken) + .withClientVersionHeaders() + .build() + ) + + suspend fun updateMessage(accessToken: String, chatId: String, messageId: String, text: String): MessageDto = + executeJsonRequest( + Request.Builder() + .url(ServerConfig.absoluteMessengerUrl("/api/chats/$chatId/messages/$messageId")) + .put( + json.encodeToString( + UpdateMessageRequest.serializer(), + UpdateMessageRequest(text) + ).toRequestBody(JSON) + ) + .withBearer(accessToken) + .withClientVersionHeaders() + .build(), + MessageDto.serializer() + ) + + suspend fun deleteMessage(accessToken: String, chatId: String, messageId: String): MessageDto = + executeJsonRequest( + Request.Builder() + .url(ServerConfig.absoluteMessengerUrl("/api/chats/$chatId/messages/$messageId")) + .delete() + .withBearer(accessToken) + .withClientVersionHeaders() + .build(), + MessageDto.serializer() + ) + + suspend fun pinMessage(accessToken: String, chatId: String, messageId: String): MessageDto = + executeJsonRequest( + Request.Builder() + .url(ServerConfig.absoluteMessengerUrl("/api/chats/$chatId/messages/$messageId/pin")) + .put("".toRequestBody(null)) + .withBearer(accessToken) + .withClientVersionHeaders() + .build(), + MessageDto.serializer() + ) + + suspend fun unpinMessage(accessToken: String, chatId: String, messageId: String): MessageDto = + executeJsonRequest( + Request.Builder() + .url(ServerConfig.absoluteMessengerUrl("/api/chats/$chatId/messages/$messageId/pin")) + .delete() + .withBearer(accessToken) + .withClientVersionHeaders() + .build(), + MessageDto.serializer() + ) + + suspend fun uploadAttachment( + accessToken: String, + chatId: String, + text: String?, + replyToMessageId: String?, + file: UploadFileSource + ): MessageDto = withContext(Dispatchers.IO) { + val multipart = MultipartBody.Builder() + .setType(MultipartBody.FORM) + .apply { + text?.takeIf { it.isNotBlank() }?.let { addFormDataPart("text", it) } + replyToMessageId?.takeIf { it.isNotBlank() }?.let { addFormDataPart("replyToMessageId", it) } + addFormDataPart("file", file.fileName, file.asRequestBody()) + } + .build() + + executeJsonRequest( + Request.Builder() + .url(ServerConfig.absoluteMessengerUrl("/api/chats/$chatId/attachments")) + .post(multipart) + .withBearer(accessToken) + .withClientVersionHeaders() + .build(), + MessageDto.serializer() + ) + } + + suspend fun uploadAttachmentAlbum( + accessToken: String, + chatId: String, + text: String?, + replyToMessageId: String?, + files: List + ): MessageDto = withContext(Dispatchers.IO) { + check(files.isNotEmpty()) { "At least one file is required." } + + val multipart = MultipartBody.Builder() + .setType(MultipartBody.FORM) + .apply { + text?.takeIf { it.isNotBlank() }?.let { addFormDataPart("text", it) } + replyToMessageId?.takeIf { it.isNotBlank() }?.let { addFormDataPart("replyToMessageId", it) } + files.forEach { file -> + addFormDataPart("files", file.fileName, file.asRequestBody()) + } + } + .build() + + executeJsonRequest( + Request.Builder() + .url(ServerConfig.absoluteMessengerUrl("/api/chats/$chatId/attachments/album")) + .post(multipart) + .withBearer(accessToken) + .withClientVersionHeaders() + .build(), + MessageDto.serializer() + ) + } + + suspend fun acknowledgeDelivered(accessToken: String, chatId: String, messageId: String) = + executeUnitRequest( + Request.Builder() + .url(ServerConfig.absoluteMessengerUrl("/api/chats/$chatId/messages/delivered")) + .post( + json.encodeToString( + UpdateMessageReceiptRequest.serializer(), + UpdateMessageReceiptRequest(messageId) + ).toRequestBody(JSON) + ) + .withBearer(accessToken) + .withClientVersionHeaders() + .build() + ) + + suspend fun acknowledgeRead(accessToken: String, chatId: String, messageId: String) = + executeUnitRequest( + Request.Builder() + .url(ServerConfig.absoluteMessengerUrl("/api/chats/$chatId/messages/read")) + .post( + json.encodeToString( + UpdateMessageReceiptRequest.serializer(), + UpdateMessageReceiptRequest(messageId) + ).toRequestBody(JSON) + ) + .withBearer(accessToken) + .withClientVersionHeaders() + .build() + ) + + suspend fun setMessageReaction(accessToken: String, chatId: String, messageId: String, emoji: String): MessageDto = + executeJsonRequest( + Request.Builder() + .url(ServerConfig.absoluteMessengerUrl("/api/chats/$chatId/messages/$messageId/reaction")) + .put( + json.encodeToString( + SetMessageReactionRequest.serializer(), + SetMessageReactionRequest(emoji = emoji) + ).toRequestBody(JSON) + ) + .withBearer(accessToken) + .withClientVersionHeaders() + .build(), + MessageDto.serializer() + ) + + suspend fun downloadAttachmentTo( + accessToken: String, + attachment: AttachmentDto, + targetFile: File, + onProgress: ((bytesRead: Long, totalBytes: Long) -> Unit)? = null + ): DownloadedAttachmentFile = + withContext(Dispatchers.IO) { + val request = Request.Builder() + .url(ServerConfig.absoluteMessengerUrl(attachment.downloadPath)) + .get() + .withBearer(accessToken) + .withClientVersionHeaders() + .build() + + try { + httpClient.newCall(request).execute().use { response -> + if (!response.isSuccessful) { + throw ApiException(response.code, formatApiErrorMessage(response.code, response.body?.string().orEmpty())) + } + + val body = response.body ?: throw ApiException(response.code, "Empty attachment response.") + val totalBytes = body.contentLength() + .takeIf { it >= 0L } + ?: attachment.fileSizeBytes.takeIf { it >= 0L } + ?: -1L + targetFile.parentFile?.mkdirs() + body.byteStream().use { input -> + targetFile.outputStream().use { output -> + val buffer = ByteArray(DEFAULT_BUFFER_SIZE) + var bytesRead = 0L + while (true) { + val read = input.read(buffer) + if (read < 0) { + break + } + output.write(buffer, 0, read) + bytesRead += read + onProgress?.invoke(bytesRead, totalBytes) + } + } + } + + DownloadedAttachmentFile( + fileName = attachment.fileName, + contentType = body.contentType()?.toString() + ?: response.header("Content-Type") + ?: attachment.contentType, + fileSizeBytes = targetFile.length(), + localPath = targetFile.absolutePath + ) + } + } catch (error: IOException) { + throw ApiException(-1, networkErrorMessage()) + } + } + + suspend fun getStories(accessToken: String): List = + executeJsonRequest( + Request.Builder() + .url(ServerConfig.absoluteMessengerUrl("/api/stories")) + .get() + .withBearer(accessToken) + .withClientVersionHeaders() + .build(), + kotlinx.serialization.builtins.ListSerializer(StoryDto.serializer()) + ) + + suspend fun getStory(accessToken: String, storyId: String): StoryDto = + executeJsonRequest( + Request.Builder() + .url(ServerConfig.absoluteMessengerUrl("/api/stories/$storyId")) + .get() + .withBearer(accessToken) + .withClientVersionHeaders() + .build(), + StoryDto.serializer() + ) + + suspend fun createStory( + accessToken: String, + text: String, + visibility: StoryVisibility, + hasProtectedContent: Boolean, + ttlSeconds: Int + ): StoryDto = + executeJsonRequest( + Request.Builder() + .url(ServerConfig.absoluteMessengerUrl("/api/stories")) + .post( + json.encodeToString( + CreateStoryRequest.serializer(), + CreateStoryRequest( + text = text.takeIf { it.isNotBlank() }, + visibility = visibility, + hasProtectedContent = hasProtectedContent, + ttlSeconds = ttlSeconds + ) + ).toRequestBody(JSON) + ) + .withBearer(accessToken) + .withClientVersionHeaders() + .build(), + StoryDto.serializer() + ) + + suspend fun createMediaStory( + accessToken: String, + text: String?, + visibility: StoryVisibility, + hasProtectedContent: Boolean, + ttlSeconds: Int, + files: List + ): StoryDto = withContext(Dispatchers.IO) { + val multipart = MultipartBody.Builder() + .setType(MultipartBody.FORM) + .apply { + text?.takeIf { it.isNotBlank() }?.let { addFormDataPart("text", it) } + addFormDataPart("visibility", visibility.wireValue.toString()) + addFormDataPart("hasProtectedContent", hasProtectedContent.toString()) + addFormDataPart("ttlSeconds", ttlSeconds.toString()) + files.forEach { file -> + addFormDataPart("files", file.fileName, file.asRequestBody()) + } + } + .build() + + executeJsonRequest( + Request.Builder() + .url(ServerConfig.absoluteMessengerUrl("/api/stories/media")) + .post(multipart) + .withBearer(accessToken) + .withClientVersionHeaders() + .build(), + StoryDto.serializer() + ) + } + + suspend fun deleteStory(accessToken: String, storyId: String) = + executeUnitRequest( + Request.Builder() + .url(ServerConfig.absoluteMessengerUrl("/api/stories/$storyId")) + .delete() + .withBearer(accessToken) + .withClientVersionHeaders() + .build() + ) + + suspend fun getStoryViews(accessToken: String, storyId: String): List = + executeJsonRequest( + Request.Builder() + .url(ServerConfig.absoluteMessengerUrl("/api/stories/$storyId/views")) + .get() + .withBearer(accessToken) + .withClientVersionHeaders() + .build(), + kotlinx.serialization.builtins.ListSerializer(StoryViewDto.serializer()) + ) + + suspend fun getMe(accessToken: String): UserSummaryDto = + executeJsonRequest( + Request.Builder() + .url(ServerConfig.absoluteMessengerUrl("/api/users/me")) + .get() + .withBearer(accessToken) + .withClientVersionHeaders() + .build(), + UserSummaryDto.serializer() + ) + + suspend fun getUser(accessToken: String, userId: String): UserSummaryDto = + executeJsonRequest( + Request.Builder() + .url(ServerConfig.absoluteMessengerUrl("/api/users/$userId")) + .get() + .withBearer(accessToken) + .withClientVersionHeaders() + .build(), + UserSummaryDto.serializer() + ) + + suspend fun updateMyProfile(accessToken: String, displayName: String, about: String?): UserSummaryDto = + executeJsonRequest( + Request.Builder() + .url(ServerConfig.absoluteMessengerUrl("/api/users/me")) + .put( + json.encodeToString( + UpdateMyProfileRequest.serializer(), + UpdateMyProfileRequest(displayName = displayName, about = about?.takeIf { it.isNotBlank() }) + ).toRequestBody(JSON) + ) + .withBearer(accessToken) + .withClientVersionHeaders() + .build(), + UserSummaryDto.serializer() + ) + + suspend fun uploadMyAvatar( + accessToken: String, + file: UploadFileSource + ): UserSummaryDto = withContext(Dispatchers.IO) { + val multipart = MultipartBody.Builder() + .setType(MultipartBody.FORM) + .addFormDataPart("file", file.fileName, file.asRequestBody()) + .build() + + val request = Request.Builder() + .url(ServerConfig.absoluteMessengerUrl("/api/users/me/avatar")) + .post(multipart) + .withBearer(accessToken) + .withClientVersionHeaders() + .build() + + executeJsonRequest(request, UserSummaryDto.serializer()) + } + + suspend fun deleteMyAvatar(accessToken: String): UserSummaryDto = + executeJsonRequest( + Request.Builder() + .url(ServerConfig.absoluteMessengerUrl("/api/users/me/avatar")) + .delete() + .withBearer(accessToken) + .withClientVersionHeaders() + .build(), + UserSummaryDto.serializer() + ) + + suspend fun getMyPrivacySettings(accessToken: String): PrivacySettingsDto = + executeJsonRequest( + Request.Builder() + .url(ServerConfig.absoluteMessengerUrl("/api/users/me/privacy")) + .get() + .withBearer(accessToken) + .withClientVersionHeaders() + .build(), + PrivacySettingsDto.serializer() + ) + + suspend fun updateMyPrivacySettings(accessToken: String, request: UpdatePrivacySettingsRequest): PrivacySettingsDto = + executeJsonRequest( + Request.Builder() + .url(ServerConfig.absoluteMessengerUrl("/api/users/me/privacy")) + .put( + json.encodeToString( + UpdatePrivacySettingsRequest.serializer(), + request + ).toRequestBody(JSON) + ) + .withBearer(accessToken) + .withClientVersionHeaders() + .build(), + PrivacySettingsDto.serializer() + ) + + suspend fun getMySessions(accessToken: String): List = + executeJsonRequest( + Request.Builder() + .url(ServerConfig.absoluteMessengerUrl("/api/users/me/sessions")) + .get() + .withBearer(accessToken) + .withClientVersionHeaders() + .build(), + kotlinx.serialization.builtins.ListSerializer(UserSessionDto.serializer()) + ) + + suspend fun revokeMySession(accessToken: String, sessionId: String) = + executeUnitRequest( + Request.Builder() + .url(ServerConfig.absoluteMessengerUrl("/api/users/me/sessions/$sessionId")) + .delete() + .withBearer(accessToken) + .withClientVersionHeaders() + .build() + ) + + suspend fun searchUsers(accessToken: String, query: String): List = + executeJsonRequest( + Request.Builder() + .url(ServerConfig.absoluteMessengerUrl("/api/users/search?q=${java.net.URLEncoder.encode(query, Charsets.UTF_8.name())}")) + .get() + .withBearer(accessToken) + .withClientVersionHeaders() + .build(), + kotlinx.serialization.builtins.ListSerializer(UserSummaryDto.serializer()) + ) + + suspend fun resolveContacts(accessToken: String, phoneNumbers: List): List = + executeJsonRequest( + Request.Builder() + .url(ServerConfig.absoluteMessengerUrl("/api/users/contacts/resolve")) + .post( + json.encodeToString( + ResolveContactsRequest.serializer(), + ResolveContactsRequest(phoneNumbers) + ).toRequestBody(JSON) + ) + .withBearer(accessToken) + .withClientVersionHeaders() + .build(), + kotlinx.serialization.builtins.ListSerializer(ContactMatchDto.serializer()) + ) + + suspend fun createDirectChat(accessToken: String, userId: String): ChatDetailsDto = + executeJsonRequest( + Request.Builder() + .url(ServerConfig.absoluteMessengerUrl("/api/chats/direct")) + .post( + json.encodeToString( + CreateDirectChatRequest.serializer(), + CreateDirectChatRequest(userId) + ).toRequestBody(JSON) + ) + .withBearer(accessToken) + .withClientVersionHeaders() + .build(), + ChatDetailsDto.serializer() + ) + + suspend fun createGroupChat(accessToken: String, title: String, memberIds: List): ChatDetailsDto = + executeJsonRequest( + Request.Builder() + .url(ServerConfig.absoluteMessengerUrl("/api/chats/group")) + .post( + json.encodeToString( + CreateGroupChatRequest.serializer(), + CreateGroupChatRequest(title, memberIds) + ).toRequestBody(JSON) + ) + .withBearer(accessToken) + .withClientVersionHeaders() + .build(), + ChatDetailsDto.serializer() + ) + + suspend fun createChannel( + accessToken: String, + title: String, + memberIds: List, + description: String? = null, + publicUsername: String? = null, + createInviteLink: Boolean = false, + channelSignaturesEnabled: Boolean = false + ): ChatDetailsDto = + executeJsonRequest( + Request.Builder() + .url(ServerConfig.absoluteMessengerUrl("/api/chats/channel")) + .post( + json.encodeToString( + CreateChannelRequest.serializer(), + CreateChannelRequest( + title = title, + memberIds = memberIds, + description = description?.takeIf { it.isNotBlank() }, + publicUsername = publicUsername?.takeIf { it.isNotBlank() }, + createInviteLink = createInviteLink, + channelSignaturesEnabled = channelSignaturesEnabled + ) + ).toRequestBody(JSON) + ) + .withBearer(accessToken) + .withClientVersionHeaders() + .build(), + ChatDetailsDto.serializer() + ) + + suspend fun subscribeToChannel(accessToken: String, chatId: String): ChatDetailsDto = + executeJsonRequest( + Request.Builder() + .url(ServerConfig.absoluteMessengerUrl("/api/chats/$chatId/subscribe")) + .post("".toRequestBody(null)) + .withBearer(accessToken) + .withClientVersionHeaders() + .build(), + ChatDetailsDto.serializer() + ) + + suspend fun unsubscribeFromChannel(accessToken: String, chatId: String) = + executeUnitRequest( + Request.Builder() + .url(ServerConfig.absoluteMessengerUrl("/api/chats/$chatId/subscribe")) + .delete() + .withBearer(accessToken) + .withClientVersionHeaders() + .build() + ) + + suspend fun forwardMessage( + accessToken: String, + sourceChatId: String, + messageId: String, + targetChatId: String + ): MessageDto = + executeJsonRequest( + Request.Builder() + .url(ServerConfig.absoluteMessengerUrl("/api/chats/$sourceChatId/messages/$messageId/forward")) + .post( + json.encodeToString( + ForwardMessageRequest.serializer(), + ForwardMessageRequest(targetChatId) + ).toRequestBody(JSON) + ) + .withBearer(accessToken) + .withClientVersionHeaders() + .build(), + MessageDto.serializer() + ) + + suspend fun getMyBots(accessToken: String): List = + executeJsonRequest( + Request.Builder() + .url(ServerConfig.absoluteMessengerUrl("/api/bots/mine")) + .get() + .withBearer(accessToken) + .withClientVersionHeaders() + .build(), + kotlinx.serialization.builtins.ListSerializer(BotSummaryDto.serializer()) + ) + + suspend fun getBot(accessToken: String, botId: String): BotSummaryDto = + executeJsonRequest( + Request.Builder() + .url(ServerConfig.absoluteMessengerUrl("/api/bots/$botId")) + .get() + .withBearer(accessToken) + .withClientVersionHeaders() + .build(), + BotSummaryDto.serializer() + ) + + suspend fun createBot(accessToken: String, username: String, displayName: String, about: String?): CreatedBotDto = + executeJsonRequest( + Request.Builder() + .url(ServerConfig.absoluteMessengerUrl("/api/bots")) + .post( + json.encodeToString( + CreateBotRequest.serializer(), + CreateBotRequest(username, displayName, about?.takeIf { it.isNotBlank() }) + ).toRequestBody(JSON) + ) + .withBearer(accessToken) + .withClientVersionHeaders() + .build(), + CreatedBotDto.serializer() + ) + + suspend fun updateBot(accessToken: String, botId: String, displayName: String, about: String?, isEnabled: Boolean): BotSummaryDto = + executeJsonRequest( + Request.Builder() + .url(ServerConfig.absoluteMessengerUrl("/api/bots/$botId")) + .put( + json.encodeToString( + UpdateBotRequest.serializer(), + UpdateBotRequest(displayName, about?.takeIf { it.isNotBlank() }, isEnabled) + ).toRequestBody(JSON) + ) + .withBearer(accessToken) + .withClientVersionHeaders() + .build(), + BotSummaryDto.serializer() + ) + + suspend fun setBotCommands(accessToken: String, botId: String, commands: List): BotSummaryDto = + executeJsonRequest( + Request.Builder() + .url(ServerConfig.absoluteMessengerUrl("/api/bots/$botId/commands")) + .put( + json.encodeToString( + SetBotCommandsRequest.serializer(), + SetBotCommandsRequest(commands) + ).toRequestBody(JSON) + ) + .withBearer(accessToken) + .withClientVersionHeaders() + .build(), + BotSummaryDto.serializer() + ) + + suspend fun regenerateBotToken(accessToken: String, botId: String): RegenerateBotTokenResponseDto = + executeJsonRequest( + Request.Builder() + .url(ServerConfig.absoluteMessengerUrl("/api/bots/$botId/regenerate-token")) + .post(ByteArray(0).toRequestBody(null, 0, 0)) + .withBearer(accessToken) + .withClientVersionHeaders() + .build(), + RegenerateBotTokenResponseDto.serializer() + ) + + suspend fun deleteBot(accessToken: String, botId: String) = + executeUnitRequest( + Request.Builder() + .url(ServerConfig.absoluteMessengerUrl("/api/bots/$botId")) + .delete() + .withBearer(accessToken) + .withClientVersionHeaders() + .build() + ) + + suspend fun registerPushDevice(accessToken: String, request: RegisterPushDeviceRequest) = + executeUnitRequest( + Request.Builder() + .url(ServerConfig.absoluteMessengerUrl("/api/push/devices")) + .post(json.encodeToString(RegisterPushDeviceRequest.serializer(), request).toRequestBody(JSON)) + .withBearer(accessToken) + .withClientVersionHeaders() + .build() + ) + + suspend fun unregisterPushDevice(accessToken: String, installationId: String) = + executeUnitRequest( + Request.Builder() + .url(ServerConfig.absoluteMessengerUrl("/api/push/devices/${installationId.trim()}")) + .delete() + .withBearer(accessToken) + .withClientVersionHeaders() + .build() + ) + + suspend fun getLatestAndroidRelease(): AndroidAppReleaseDto { + val manifest = executeJsonRequest( + Request.Builder() + .url(ServerConfig.absoluteArgusUrl(ServerConfig.AndroidManifestPath)) + .get() + .withClientVersionHeaders() + .build(), + ArgusManifestDto.serializer() + ) + + val version = manifest.release.version + val plusIndex = version.lastIndexOf('+') + val versionName = if (plusIndex > 0) version.substring(0, plusIndex) else version + val versionCode = version.substringAfterLast('+', "0").toIntOrNull() ?: 0 + + return AndroidAppReleaseDto( + versionCode = versionCode, + versionName = versionName, + downloadPath = ServerConfig.absoluteArgusUrl(manifest.release.downloadPath), + publishedAt = manifest.release.publishedAt, + notes = manifest.release.releaseNotes, + isRequired = true, + minimumSupportedVersionCode = versionCode + ) + } + + private suspend fun executeJsonRequest(request: Request, serializer: kotlinx.serialization.KSerializer): T = + withContext(Dispatchers.IO) { + try { + httpClient.newCall(request).execute().use { response -> + val body = response.body?.string().orEmpty() + if (!response.isSuccessful) { + throw ApiException(response.code, formatApiErrorMessage(response.code, body)) + } + + return@withContext try { + json.decodeFromString(serializer, body) + } catch (error: SerializationException) { + throw ApiException(response.code, "Failed to decode server response: ${error.message}") + } + } + } catch (error: IOException) { + throw ApiException(-1, networkErrorMessage()) + } + } + + private suspend fun executeUnitRequest(request: Request) = + withContext(Dispatchers.IO) { + try { + httpClient.newCall(request).execute().use { response -> + val body = response.body?.string().orEmpty() + if (!response.isSuccessful) { + throw ApiException(response.code, formatApiErrorMessage(response.code, body)) + } + } + } catch (error: IOException) { + throw ApiException(-1, networkErrorMessage()) + } + } + + private fun formatApiErrorMessage(statusCode: Int, responseBody: String): String { + val trimmedBody = responseBody.trim() + val problemDetailsMessage = trimmedBody + .takeIf { it.startsWith("{") && it.endsWith("}") } + ?.let(::parseProblemDetailsMessage) + ?.takeIf { it.isNotBlank() } + + return when { + !problemDetailsMessage.isNullOrBlank() -> problemDetailsMessage + trimmedBody.startsWith("{") && trimmedBody.endsWith("}") -> defaultApiErrorMessage(statusCode) + trimmedBody.isNotBlank() -> trimmedBody + else -> defaultApiErrorMessage(statusCode) + } + } + + private fun parseProblemDetailsMessage(responseBody: String): String? = runCatching { + val jsonObject = json.parseToJsonElement(responseBody).jsonObject + val detail = jsonObject["detail"]?.jsonPrimitive?.content?.trim().orEmpty() + val title = jsonObject["title"]?.jsonPrimitive?.content?.trim().orEmpty() + when { + detail.isNotBlank() -> detail + title.isNotBlank() && title != "Unauthorized" -> title + else -> null + } + }.getOrNull() + + private fun defaultApiErrorMessage(statusCode: Int): String = when (statusCode) { + 400 -> "Некорректный запрос." + 401 -> "Ошибка авторизации." + 403 -> "Доступ запрещён." + 404 -> "Ничего не найдено." + 409 -> "Конфликт данных." + 429 -> "Слишком много запросов." + in 500..599 -> "Ошибка сервера." + else -> "HTTP $statusCode" + } + + private fun networkErrorMessage(): String = + "Не удалось подключиться к серверу. Проверьте интернет и попробуйте ещё раз." + + private fun Request.Builder.withBearer(accessToken: String): Request.Builder = + header("Authorization", "Bearer $accessToken") + + private fun Request.Builder.withClientVersionHeaders(): Request.Builder = + header("X-Ikar-Client-Platform", clientVersionProvider.platform) + .header("X-Ikar-Client-Version-Code", clientVersionProvider.versionCode) + .header("X-Ikar-Client-Version-Name", clientVersionProvider.versionName) + + private fun buildSearchPath( + basePath: String, + query: String, + filter: String?, + type: String?, + page: Int, + limit: Int, + dateFrom: String?, + dateTo: String?, + author: String? + ): String { + val queryParts = mutableListOf( + "q=${encodeQuery(query)}", + "page=${page.coerceAtLeast(1)}", + "limit=${limit.coerceIn(1, 100)}" + ) + filter?.takeIf { it.isNotBlank() }?.let { queryParts += "filter=${encodeQuery(it)}" } + type?.takeIf { it.isNotBlank() }?.let { queryParts += "type=${encodeQuery(it)}" } + dateFrom?.takeIf { it.isNotBlank() }?.let { queryParts += "dateFrom=${encodeQuery(it)}" } + dateTo?.takeIf { it.isNotBlank() }?.let { queryParts += "dateTo=${encodeQuery(it)}" } + author?.takeIf { it.isNotBlank() }?.let { queryParts += "author=${encodeQuery(it)}" } + return "$basePath?${queryParts.joinToString("&")}" + } + + private fun encodeQuery(value: String): String = + URLEncoder.encode(value, StandardCharsets.UTF_8.toString()) + + companion object { + private val JSON = "application/json; charset=utf-8".toMediaType() + } +} + +data class ClientVersionProvider( + val platform: String = "android", + val versionCode: String, + val versionName: String +) diff --git a/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/core/network/ServerConfig.kt b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/core/network/ServerConfig.kt new file mode 100644 index 00000000..183d8af2 --- /dev/null +++ b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/core/network/ServerConfig.kt @@ -0,0 +1,39 @@ +package com.seven.ikar.kotlin.core.network + +object ServerConfig { + const val MessengerBaseUrl = "https://ikar.kusoft.xyz" + const val ArgusBaseUrl = "https://argus.kusoft.xyz" + const val AndroidAppSlug = "ikar-android" + const val AndroidAppPlatform = "android" + const val AndroidUpdateChannel = "stable" + const val AndroidManifestPath = "/api/apps/" + AndroidAppSlug + "/manifest?platform=" + AndroidAppPlatform + "&channel=" + AndroidUpdateChannel + const val HubPath = "/hubs/messenger" + + fun absoluteMessengerUrl(path: String): String = buildAbsoluteUrl(MessengerBaseUrl, path) + + fun absoluteArgusUrl(path: String): String = buildAbsoluteUrl(ArgusBaseUrl, path) + + fun normalizeMediaUrl(path: String?): String? { + if (path.isNullOrBlank()) { + return null + } + + return when { + path.startsWith("http://") || path.startsWith("https://") -> path + path.startsWith("/api/") || path.startsWith("/uploads/") -> buildAbsoluteUrl(MessengerBaseUrl, path) + else -> path + } + } + + private fun buildAbsoluteUrl(baseUrl: String, path: String): String { + if (path.startsWith("http://") || path.startsWith("https://")) { + return path + } + + return if (path.startsWith("/")) { + baseUrl + path + } else { + "$baseUrl/$path" + } + } +} diff --git a/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/core/push/AndroidActiveChatState.kt b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/core/push/AndroidActiveChatState.kt new file mode 100644 index 00000000..744f18af --- /dev/null +++ b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/core/push/AndroidActiveChatState.kt @@ -0,0 +1,13 @@ +package com.seven.ikar.kotlin.core.push + +import java.util.concurrent.atomic.AtomicReference + +object AndroidActiveChatState { + private val activeChatId = AtomicReference(null) + + fun setActiveChatId(chatId: String?) { + activeChatId.set(chatId?.takeIf { it.isNotBlank() }) + } + + fun getActiveChatId(): String? = activeChatId.get() +} diff --git a/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/core/push/AndroidAppVisibilityState.kt b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/core/push/AndroidAppVisibilityState.kt new file mode 100644 index 00000000..9ec79a60 --- /dev/null +++ b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/core/push/AndroidAppVisibilityState.kt @@ -0,0 +1,13 @@ +package com.seven.ikar.kotlin.core.push + +import java.util.concurrent.atomic.AtomicBoolean + +object AndroidAppVisibilityState { + private val isVisible = AtomicBoolean(false) + + fun setVisible(value: Boolean) { + isVisible.set(value) + } + + fun isVisible(): Boolean = isVisible.get() +} diff --git a/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/core/push/AndroidFirebaseOptionsLoader.kt b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/core/push/AndroidFirebaseOptionsLoader.kt new file mode 100644 index 00000000..87b347d9 --- /dev/null +++ b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/core/push/AndroidFirebaseOptionsLoader.kt @@ -0,0 +1,35 @@ +package com.seven.ikar.kotlin.core.push + +import android.content.Context +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json + +object AndroidFirebaseOptionsLoader { + private var cached: AndroidFirebaseOptions? = null + + fun tryLoad(context: Context): AndroidFirebaseOptions? { + cached?.let { return it } + + return runCatching { + context.assets.open("firebase.android.json").bufferedReader().use { reader -> + Json { ignoreUnknownKeys = true } + .decodeFromString(AndroidFirebaseOptions.serializer(), reader.readText()) + } + }.getOrNull()?.takeIf { + it.applicationId.isNotBlank() && + it.projectId.isNotBlank() && + it.apiKey.isNotBlank() && + it.senderId.isNotBlank() + }?.also { + cached = it + } + } +} + +@Serializable +data class AndroidFirebaseOptions( + val applicationId: String, + val projectId: String, + val apiKey: String, + val senderId: String +) diff --git a/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/core/push/AndroidMessageNotificationSound.kt b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/core/push/AndroidMessageNotificationSound.kt new file mode 100644 index 00000000..4732c680 --- /dev/null +++ b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/core/push/AndroidMessageNotificationSound.kt @@ -0,0 +1,62 @@ +package com.seven.ikar.kotlin.core.push + +import android.content.Context +import android.media.AudioAttributes +import android.media.Ringtone +import android.media.RingtoneManager +import android.net.Uri +import android.os.SystemClock + +object AndroidMessageNotificationSound { + private const val DuplicateSuppressionWindowMs = 5000L + private const val MinimumGapBetweenSoundsMs = 2000L + private val playbackLock = Any() + private var lastPlayedKey: String? = null + private var lastPlayedAtMs: Long = 0L + private var activeRingtone: Ringtone? = null + + fun resolveUri(option: NotificationSoundOption): Uri? = when (option) { + NotificationSoundOption.Default -> RingtoneManager.getDefaultUri(RingtoneManager.TYPE_NOTIFICATION) + NotificationSoundOption.Ringtone -> RingtoneManager.getDefaultUri(RingtoneManager.TYPE_RINGTONE) + NotificationSoundOption.Alarm -> RingtoneManager.getDefaultUri(RingtoneManager.TYPE_ALARM) + NotificationSoundOption.Silent -> null + } + + fun resolveAudioAttributes(option: NotificationSoundOption): AudioAttributes? = when (option) { + NotificationSoundOption.Default -> notificationAudioAttributes(AudioAttributes.USAGE_NOTIFICATION) + NotificationSoundOption.Ringtone -> notificationAudioAttributes(AudioAttributes.USAGE_NOTIFICATION_RINGTONE) + NotificationSoundOption.Alarm -> notificationAudioAttributes(AudioAttributes.USAGE_ALARM) + NotificationSoundOption.Silent -> null + } + + fun play(context: Context, option: NotificationSoundOption, dedupeKey: String? = null) { + val soundUri = resolveUri(option) ?: return + val audioAttributes = resolveAudioAttributes(option) ?: return + val now = SystemClock.elapsedRealtime() + synchronized(playbackLock) { + if (now - lastPlayedAtMs < MinimumGapBetweenSoundsMs) { + return + } + if (dedupeKey != null && dedupeKey == lastPlayedKey && now - lastPlayedAtMs < DuplicateSuppressionWindowMs) { + return + } + + runCatching { + activeRingtone?.stop() + RingtoneManager.getRingtone(context.applicationContext, soundUri)?.apply { + this.audioAttributes = audioAttributes + play() + activeRingtone = this + } + } + lastPlayedKey = dedupeKey + lastPlayedAtMs = now + } + } + + private fun notificationAudioAttributes(usage: Int): AudioAttributes = + AudioAttributes.Builder() + .setUsage(usage) + .setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION) + .build() +} diff --git a/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/core/push/AndroidNotificationPermissionHelper.kt b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/core/push/AndroidNotificationPermissionHelper.kt new file mode 100644 index 00000000..2a24a5d9 --- /dev/null +++ b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/core/push/AndroidNotificationPermissionHelper.kt @@ -0,0 +1,35 @@ +package com.seven.ikar.kotlin.core.push + +import android.Manifest +import android.app.Activity +import android.content.Context +import android.content.pm.PackageManager +import android.os.Build +import androidx.core.app.NotificationManagerCompat +import androidx.core.content.ContextCompat + +object AndroidNotificationPermissionHelper { + const val RequestCode = 5101 + + fun areNotificationsEnabled(context: Context): Boolean { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && + ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED + ) { + return false + } + + return NotificationManagerCompat.from(context).areNotificationsEnabled() + } + + fun requestIfNeeded(activity: Activity) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) { + return + } + + if (ContextCompat.checkSelfPermission(activity, Manifest.permission.POST_NOTIFICATIONS) == PackageManager.PERMISSION_GRANTED) { + return + } + + activity.requestPermissions(arrayOf(Manifest.permission.POST_NOTIFICATIONS), RequestCode) + } +} diff --git a/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/core/push/AndroidPushBootstrapper.kt b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/core/push/AndroidPushBootstrapper.kt new file mode 100644 index 00000000..03e0d452 --- /dev/null +++ b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/core/push/AndroidPushBootstrapper.kt @@ -0,0 +1,150 @@ +package com.seven.ikar.kotlin.core.push + +import android.app.NotificationChannel +import android.app.NotificationManager +import android.content.Context +import android.os.Build +import com.google.firebase.FirebaseApp +import com.google.firebase.FirebaseOptions + +object AndroidPushBootstrapper { + private const val MessageDefaultChannelId = "ikar_kotlin_messages_default_v2" + private const val MessageRingtoneChannelId = "ikar_kotlin_messages_ringtone_v2" + private const val MessageAlarmChannelId = "ikar_kotlin_messages_alarm_v2" + private const val MessageSilentChannelId = "ikar_kotlin_messages_silent_v2" + private const val UpdatesChannelId = "ikar_kotlin_updates_v1" + private const val LegacyCallsChannelId = "ikar_kotlin_calls_v1" + private const val CallsChannelId = "ikar_kotlin_calls_v2" + + fun tryInitialize(context: Context): Boolean { + if (tryGetExistingApp() != null) { + ensureChannels(context) + return true + } + + val options = AndroidFirebaseOptionsLoader.tryLoad(context) ?: return false + val firebaseOptions = FirebaseOptions.Builder() + .setApplicationId(options.applicationId) + .setProjectId(options.projectId) + .setApiKey(options.apiKey) + .setGcmSenderId(options.senderId) + .build() + + return runCatching { + FirebaseApp.initializeApp(context, firebaseOptions) + }.getOrNull()?.let { + ensureChannels(context) + true + } ?: false + } + + fun messageChannelId(soundOption: NotificationSoundOption): String = when (soundOption) { + NotificationSoundOption.Default -> MessageDefaultChannelId + NotificationSoundOption.Ringtone -> MessageRingtoneChannelId + NotificationSoundOption.Alarm -> MessageAlarmChannelId + NotificationSoundOption.Silent -> MessageSilentChannelId + } + + fun updatesChannelId(): String = UpdatesChannelId + + fun callsChannelId(): String = CallsChannelId + + private fun ensureChannels(context: Context) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) { + return + } + + val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as? NotificationManager ?: return + if (manager.getNotificationChannel(LegacyCallsChannelId) != null) { + manager.deleteNotificationChannel(LegacyCallsChannelId) + } + + ensureMessageChannel( + manager = manager, + channelId = MessageDefaultChannelId, + channelName = "Сообщения", + soundOption = NotificationSoundOption.Default + ) + ensureMessageChannel( + manager = manager, + channelId = MessageRingtoneChannelId, + channelName = "Сообщения (рингтон)", + soundOption = NotificationSoundOption.Ringtone + ) + ensureMessageChannel( + manager = manager, + channelId = MessageAlarmChannelId, + channelName = "Сообщения (сигнал)", + soundOption = NotificationSoundOption.Alarm + ) + ensureMessageChannel( + manager = manager, + channelId = MessageSilentChannelId, + channelName = "Сообщения (без звука)", + soundOption = NotificationSoundOption.Silent + ) + + if (manager.getNotificationChannel(UpdatesChannelId) == null) { + manager.createNotificationChannel( + NotificationChannel( + UpdatesChannelId, + "Обновления", + NotificationManager.IMPORTANCE_HIGH + ).apply { + description = "Уведомления о новых версиях приложения." + setSound( + AndroidMessageNotificationSound.resolveUri(NotificationSoundOption.Default), + AndroidMessageNotificationSound.resolveAudioAttributes(NotificationSoundOption.Default) + ) + enableVibration(true) + } + ) + } + + if (manager.getNotificationChannel(CallsChannelId) == null) { + manager.createNotificationChannel( + NotificationChannel( + CallsChannelId, + "Звонки", + NotificationManager.IMPORTANCE_HIGH + ).apply { + description = "Уведомления о входящих звонках." + setSound( + AndroidMessageNotificationSound.resolveUri(NotificationSoundOption.Ringtone), + AndroidMessageNotificationSound.resolveAudioAttributes(NotificationSoundOption.Ringtone) + ) + enableVibration(true) + } + ) + } + } + + private fun ensureMessageChannel( + manager: NotificationManager, + channelId: String, + channelName: String, + soundOption: NotificationSoundOption + ) { + if (manager.getNotificationChannel(channelId) != null) { + return + } + + manager.createNotificationChannel( + NotificationChannel( + channelId, + channelName, + NotificationManager.IMPORTANCE_HIGH + ).apply { + description = "Уведомления о новых сообщениях." + setSound( + AndroidMessageNotificationSound.resolveUri(soundOption), + AndroidMessageNotificationSound.resolveAudioAttributes(soundOption) + ) + enableVibration(soundOption != NotificationSoundOption.Silent) + } + ) + } + + private fun tryGetExistingApp(): FirebaseApp? = + runCatching { FirebaseApp.getInstance() }.getOrNull() +} diff --git a/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/core/push/AndroidPushSettings.kt b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/core/push/AndroidPushSettings.kt new file mode 100644 index 00000000..37618c66 --- /dev/null +++ b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/core/push/AndroidPushSettings.kt @@ -0,0 +1,413 @@ +package com.seven.ikar.kotlin.core.push + +import android.content.Context +import android.util.Base64 +import java.util.UUID + +class AndroidPushSettings(context: Context) { + private val preferences = context.getSharedPreferences(PREFERENCES_NAME, Context.MODE_PRIVATE) + + fun getInstallationId(): String { + val existing = preferences.getString(KEY_INSTALLATION_ID, null) + if (!existing.isNullOrBlank()) { + return existing + } + + val generated = UUID.randomUUID().toString().replace("-", "") + preferences.edit().putString(KEY_INSTALLATION_ID, generated).apply() + return generated + } + + fun getDeviceToken(): String? = preferences.getString(KEY_DEVICE_TOKEN, null) + + fun setDeviceToken(token: String?) { + preferences.edit().putString(KEY_DEVICE_TOKEN, token).apply() + } + + fun areNotificationsEnabled(): Boolean = + preferences.getBoolean(KEY_NOTIFICATIONS_ENABLED, true) + + fun setNotificationsEnabled(enabled: Boolean) { + preferences.edit().putBoolean(KEY_NOTIFICATIONS_ENABLED, enabled).apply() + } + + fun getMessageSound(): NotificationSoundOption = + getEnum(KEY_MESSAGE_SOUND, NotificationSoundOption.Default) + + fun setMessageSound(value: NotificationSoundOption) { + setEnum(KEY_MESSAGE_SOUND, value) + } + + fun getPrivateChatsEnabled(): Boolean = + preferences.getBoolean(KEY_PRIVATE_CHATS_ENABLED, true) + + fun setPrivateChatsEnabled(enabled: Boolean) { + preferences.edit().putBoolean(KEY_PRIVATE_CHATS_ENABLED, enabled).apply() + } + + fun getGroupsEnabled(): Boolean = + preferences.getBoolean(KEY_GROUPS_ENABLED, true) + + fun setGroupsEnabled(enabled: Boolean) { + preferences.edit().putBoolean(KEY_GROUPS_ENABLED, enabled).apply() + } + + fun getChannelsEnabled(): Boolean = + preferences.getBoolean(KEY_CHANNELS_ENABLED, false) + + fun setChannelsEnabled(enabled: Boolean) { + preferences.edit().putBoolean(KEY_CHANNELS_ENABLED, enabled).apply() + } + + fun getStoriesEnabled(): Boolean = + preferences.getBoolean(KEY_STORIES_ENABLED, false) + + fun setStoriesEnabled(enabled: Boolean) { + preferences.edit().putBoolean(KEY_STORIES_ENABLED, enabled).apply() + } + + fun getReactionsEnabled(): Boolean = + preferences.getBoolean(KEY_REACTIONS_ENABLED, true) + + fun setReactionsEnabled(enabled: Boolean) { + preferences.edit().putBoolean(KEY_REACTIONS_ENABLED, enabled).apply() + } + + fun getCallVibrationEnabled(): Boolean = + preferences.getBoolean(KEY_CALL_VIBRATION_ENABLED, true) + + fun setCallVibrationEnabled(enabled: Boolean) { + preferences.edit().putBoolean(KEY_CALL_VIBRATION_ENABLED, enabled).apply() + } + + fun getCallRingtone(): NotificationSoundOption = + getEnum(KEY_CALL_RINGTONE, NotificationSoundOption.Default) + + fun setCallRingtone(value: NotificationSoundOption) { + setEnum(KEY_CALL_RINGTONE, value) + } + + fun getShowBadgeEnabled(): Boolean = + preferences.getBoolean(KEY_SHOW_BADGE, true) + + fun setShowBadgeEnabled(enabled: Boolean) { + preferences.edit().putBoolean(KEY_SHOW_BADGE, enabled).apply() + } + + fun getIncludeMutedChatsInBadge(): Boolean = + preferences.getBoolean(KEY_INCLUDE_MUTED_CHATS_IN_BADGE, true) + + fun setIncludeMutedChatsInBadge(enabled: Boolean) { + preferences.edit().putBoolean(KEY_INCLUDE_MUTED_CHATS_IN_BADGE, enabled).apply() + } + + fun getCountMessagesInBadge(): Boolean = + preferences.getBoolean(KEY_COUNT_MESSAGES_IN_BADGE, true) + + fun setCountMessagesInBadge(enabled: Boolean) { + preferences.edit().putBoolean(KEY_COUNT_MESSAGES_IN_BADGE, enabled).apply() + } + + fun getInAppSoundEnabled(): Boolean = + preferences.getBoolean(KEY_IN_APP_SOUND_ENABLED, true) + + fun setInAppSoundEnabled(enabled: Boolean) { + preferences.edit().putBoolean(KEY_IN_APP_SOUND_ENABLED, enabled).apply() + } + + fun getInAppVibrationEnabled(): Boolean = + preferences.getBoolean(KEY_IN_APP_VIBRATION_ENABLED, true) + + fun setInAppVibrationEnabled(enabled: Boolean) { + preferences.edit().putBoolean(KEY_IN_APP_VIBRATION_ENABLED, enabled).apply() + } + + fun getInAppShowTextEnabled(): Boolean = + preferences.getBoolean(KEY_IN_APP_SHOW_TEXT_ENABLED, true) + + fun setInAppShowTextEnabled(enabled: Boolean) { + preferences.edit().putBoolean(KEY_IN_APP_SHOW_TEXT_ENABLED, enabled).apply() + } + + fun getSoundInActiveChatEnabled(): Boolean = + preferences.getBoolean(KEY_SOUND_IN_ACTIVE_CHAT_ENABLED, true) + + fun setSoundInActiveChatEnabled(enabled: Boolean) { + preferences.edit().putBoolean(KEY_SOUND_IN_ACTIVE_CHAT_ENABLED, enabled).apply() + } + + fun getPopupWindowsEnabled(): Boolean = + preferences.getBoolean(KEY_POPUP_WINDOWS_ENABLED, false) + + fun setPopupWindowsEnabled(enabled: Boolean) { + preferences.edit().putBoolean(KEY_POPUP_WINDOWS_ENABLED, enabled).apply() + } + + fun getContactJoinedEnabled(): Boolean = + preferences.getBoolean(KEY_CONTACT_JOINED_ENABLED, false) + + fun setContactJoinedEnabled(enabled: Boolean) { + preferences.edit().putBoolean(KEY_CONTACT_JOINED_ENABLED, enabled).apply() + } + + fun getPinnedMessagesEnabled(): Boolean = + preferences.getBoolean(KEY_PINNED_MESSAGES_ENABLED, true) + + fun setPinnedMessagesEnabled(enabled: Boolean) { + preferences.edit().putBoolean(KEY_PINNED_MESSAGES_ENABLED, enabled).apply() + } + + fun getRestartOnCloseEnabled(): Boolean = + preferences.getBoolean(KEY_RESTART_ON_CLOSE_ENABLED, false) + + fun setRestartOnCloseEnabled(enabled: Boolean) { + preferences.edit().putBoolean(KEY_RESTART_ON_CLOSE_ENABLED, enabled).apply() + } + + fun getBackgroundConnectionEnabled(): Boolean = + preferences.getBoolean(KEY_BACKGROUND_CONNECTION_ENABLED, false) + + fun setBackgroundConnectionEnabled(enabled: Boolean) { + preferences.edit().putBoolean(KEY_BACKGROUND_CONNECTION_ENABLED, enabled).apply() + } + + fun getRepeatInterval(): NotificationRepeatIntervalOption { + val rawValue = preferences.getInt(KEY_REPEAT_INTERVAL, NotificationRepeatIntervalOption.OneHour.minutes) + return NotificationRepeatIntervalOption.entries.firstOrNull { it.minutes == rawValue } + ?: NotificationRepeatIntervalOption.OneHour + } + + fun setRepeatInterval(value: NotificationRepeatIntervalOption) { + preferences.edit().putInt(KEY_REPEAT_INTERVAL, value.minutes).apply() + } + + fun loadSnapshot(): NotificationSettingsSnapshot = + NotificationSettingsSnapshot( + notificationsEnabled = areNotificationsEnabled(), + messageSound = getMessageSound(), + privateChatsEnabled = getPrivateChatsEnabled(), + groupsEnabled = getGroupsEnabled(), + channelsEnabled = getChannelsEnabled(), + storiesEnabled = getStoriesEnabled(), + reactionsEnabled = getReactionsEnabled(), + callVibrationEnabled = getCallVibrationEnabled(), + callRingtone = getCallRingtone(), + showBadge = getShowBadgeEnabled(), + includeMutedChatsInBadge = getIncludeMutedChatsInBadge(), + countMessagesInBadge = getCountMessagesInBadge(), + inAppSoundEnabled = getInAppSoundEnabled(), + inAppVibrationEnabled = getInAppVibrationEnabled(), + inAppShowTextEnabled = getInAppShowTextEnabled(), + soundInActiveChatEnabled = getSoundInActiveChatEnabled(), + popupWindowsEnabled = getPopupWindowsEnabled(), + contactJoinedEnabled = getContactJoinedEnabled(), + pinnedMessagesEnabled = getPinnedMessagesEnabled(), + restartOnCloseEnabled = getRestartOnCloseEnabled(), + backgroundConnectionEnabled = getBackgroundConnectionEnabled(), + repeatInterval = getRepeatInterval() + ) + + fun saveSnapshot(snapshot: NotificationSettingsSnapshot) { + setNotificationsEnabled(snapshot.notificationsEnabled) + setMessageSound(snapshot.messageSound) + setPrivateChatsEnabled(snapshot.privateChatsEnabled) + setGroupsEnabled(snapshot.groupsEnabled) + setChannelsEnabled(snapshot.channelsEnabled) + setStoriesEnabled(snapshot.storiesEnabled) + setReactionsEnabled(snapshot.reactionsEnabled) + setCallVibrationEnabled(snapshot.callVibrationEnabled) + setCallRingtone(snapshot.callRingtone) + setShowBadgeEnabled(snapshot.showBadge) + setIncludeMutedChatsInBadge(snapshot.includeMutedChatsInBadge) + setCountMessagesInBadge(snapshot.countMessagesInBadge) + setInAppSoundEnabled(snapshot.inAppSoundEnabled) + setInAppVibrationEnabled(snapshot.inAppVibrationEnabled) + setInAppShowTextEnabled(snapshot.inAppShowTextEnabled) + setSoundInActiveChatEnabled(snapshot.soundInActiveChatEnabled) + setPopupWindowsEnabled(snapshot.popupWindowsEnabled) + setContactJoinedEnabled(snapshot.contactJoinedEnabled) + setPinnedMessagesEnabled(snapshot.pinnedMessagesEnabled) + setRestartOnCloseEnabled(snapshot.restartOnCloseEnabled) + setBackgroundConnectionEnabled(snapshot.backgroundConnectionEnabled) + setRepeatInterval(snapshot.repeatInterval) + } + + fun loadChatNotificationSnapshot(chatId: String): ChatNotificationSettingsSnapshot = + ChatNotificationSettingsSnapshot( + notificationsEnabled = getNullableBoolean(chatPreferenceKey(chatId, KEY_CHAT_NOTIFICATIONS_ENABLED)), + messageSound = getNullableEnum( + chatPreferenceKey(chatId, KEY_CHAT_MESSAGE_SOUND), + NotificationSoundOption.Default + ), + showMessagePreview = getNullableBoolean(chatPreferenceKey(chatId, KEY_CHAT_SHOW_MESSAGE_PREVIEW)), + popupWindowsEnabled = getNullableBoolean(chatPreferenceKey(chatId, KEY_CHAT_POPUP_WINDOWS_ENABLED)), + soundInActiveChatEnabled = getNullableBoolean(chatPreferenceKey(chatId, KEY_CHAT_SOUND_IN_ACTIVE_CHAT_ENABLED)) + ) + + fun saveChatNotificationSnapshot(chatId: String, snapshot: ChatNotificationSettingsSnapshot) { + preferences.edit().apply { + putNullableBoolean(chatPreferenceKey(chatId, KEY_CHAT_NOTIFICATIONS_ENABLED), snapshot.notificationsEnabled) + putNullableEnum(chatPreferenceKey(chatId, KEY_CHAT_MESSAGE_SOUND), snapshot.messageSound) + putNullableBoolean(chatPreferenceKey(chatId, KEY_CHAT_SHOW_MESSAGE_PREVIEW), snapshot.showMessagePreview) + putNullableBoolean(chatPreferenceKey(chatId, KEY_CHAT_POPUP_WINDOWS_ENABLED), snapshot.popupWindowsEnabled) + putNullableBoolean(chatPreferenceKey(chatId, KEY_CHAT_SOUND_IN_ACTIVE_CHAT_ENABLED), snapshot.soundInActiveChatEnabled) + apply() + } + } + + fun resetChatNotificationPreferences(chatId: String) { + val prefix = chatPreferencePrefix(chatId) + val keys = preferences.all.keys.filter { it.startsWith(prefix) } + preferences.edit().apply { + keys.forEach(::remove) + apply() + } + } + + fun resetAllChatNotificationPreferences() { + val keys = preferences.all.keys.filter { it.startsWith(KEY_CHAT_NOTIFICATION_PREFIX) } + preferences.edit().apply { + keys.forEach(::remove) + apply() + } + } + + fun createDefaultSnapshot(): NotificationSettingsSnapshot = + NotificationSettingsSnapshot( + notificationsEnabled = true, + messageSound = NotificationSoundOption.Default, + privateChatsEnabled = true, + groupsEnabled = true, + channelsEnabled = false, + storiesEnabled = false, + reactionsEnabled = true, + callVibrationEnabled = true, + callRingtone = NotificationSoundOption.Default, + showBadge = true, + includeMutedChatsInBadge = true, + countMessagesInBadge = true, + inAppSoundEnabled = true, + inAppVibrationEnabled = true, + inAppShowTextEnabled = true, + soundInActiveChatEnabled = true, + popupWindowsEnabled = false, + contactJoinedEnabled = false, + pinnedMessagesEnabled = true, + restartOnCloseEnabled = false, + backgroundConnectionEnabled = false, + repeatInterval = NotificationRepeatIntervalOption.OneHour + ) + + fun resetNotificationPreferences() { + val chatKeys = preferences.all.keys.filter { it.startsWith(KEY_CHAT_NOTIFICATION_PREFIX) } + preferences.edit().apply { + remove(KEY_NOTIFICATIONS_ENABLED) + remove(KEY_MESSAGE_SOUND) + remove(KEY_PRIVATE_CHATS_ENABLED) + remove(KEY_GROUPS_ENABLED) + remove(KEY_CHANNELS_ENABLED) + remove(KEY_STORIES_ENABLED) + remove(KEY_REACTIONS_ENABLED) + remove(KEY_CALL_VIBRATION_ENABLED) + remove(KEY_CALL_RINGTONE) + remove(KEY_SHOW_BADGE) + remove(KEY_INCLUDE_MUTED_CHATS_IN_BADGE) + remove(KEY_COUNT_MESSAGES_IN_BADGE) + remove(KEY_IN_APP_SOUND_ENABLED) + remove(KEY_IN_APP_VIBRATION_ENABLED) + remove(KEY_IN_APP_SHOW_TEXT_ENABLED) + remove(KEY_SOUND_IN_ACTIVE_CHAT_ENABLED) + remove(KEY_POPUP_WINDOWS_ENABLED) + remove(KEY_CONTACT_JOINED_ENABLED) + remove(KEY_PINNED_MESSAGES_ENABLED) + remove(KEY_RESTART_ON_CLOSE_ENABLED) + remove(KEY_BACKGROUND_CONNECTION_ENABLED) + remove(KEY_REPEAT_INTERVAL) + chatKeys.forEach(::remove) + apply() + } + } + + private fun > getEnum(key: String, defaultValue: T): T { + val rawValue = preferences.getString(key, defaultValue.name).orEmpty() + @Suppress("UNCHECKED_CAST") + return defaultValue.declaringJavaClass.enumConstants + ?.firstOrNull { it.name == rawValue } as? T + ?: defaultValue + } + + private fun > setEnum(key: String, value: T) { + preferences.edit().putString(key, value.name).apply() + } + + private fun getNullableBoolean(key: String): Boolean? = + if (preferences.contains(key)) preferences.getBoolean(key, true) else null + + private fun android.content.SharedPreferences.Editor.putNullableBoolean(key: String, value: Boolean?) { + if (value == null) { + remove(key) + } else { + putBoolean(key, value) + } + } + + private fun > getNullableEnum(key: String, defaultValue: T): T? { + if (!preferences.contains(key)) { + return null + } + return getEnum(key, defaultValue) + } + + private fun > android.content.SharedPreferences.Editor.putNullableEnum(key: String, value: T?) { + if (value == null) { + remove(key) + } else { + putString(key, value.name) + } + } + + private fun chatPreferenceKey(chatId: String, key: String): String = + "${chatPreferencePrefix(chatId)}.$key" + + private fun chatPreferencePrefix(chatId: String): String { + val encodedChatId = Base64.encodeToString( + chatId.toByteArray(Charsets.UTF_8), + Base64.NO_WRAP or Base64.URL_SAFE + ) + return "$KEY_CHAT_NOTIFICATION_PREFIX$encodedChatId" + } + + companion object { + private const val PREFERENCES_NAME = "ikar_kotlin_push" + private const val KEY_INSTALLATION_ID = "installation_id" + private const val KEY_DEVICE_TOKEN = "device_token" + private const val KEY_NOTIFICATIONS_ENABLED = "notifications_enabled" + private const val KEY_MESSAGE_SOUND = "notification_sound" + private const val KEY_PRIVATE_CHATS_ENABLED = "notification_private_chats_enabled" + private const val KEY_GROUPS_ENABLED = "notification_groups_enabled" + private const val KEY_CHANNELS_ENABLED = "notification_channels_enabled" + private const val KEY_STORIES_ENABLED = "notification_stories_enabled" + private const val KEY_REACTIONS_ENABLED = "notification_reactions_enabled" + private const val KEY_CALL_VIBRATION_ENABLED = "notification_call_vibration_enabled" + private const val KEY_CALL_RINGTONE = "notification_call_ringtone" + private const val KEY_SHOW_BADGE = "notification_show_badge_enabled" + private const val KEY_INCLUDE_MUTED_CHATS_IN_BADGE = "notification_include_muted_chats_in_badge" + private const val KEY_COUNT_MESSAGES_IN_BADGE = "notification_count_messages_in_badge" + private const val KEY_IN_APP_SOUND_ENABLED = "notification_in_app_sound_enabled" + private const val KEY_IN_APP_VIBRATION_ENABLED = "notification_in_app_vibration_enabled" + private const val KEY_IN_APP_SHOW_TEXT_ENABLED = "notification_in_app_show_text_enabled" + private const val KEY_SOUND_IN_ACTIVE_CHAT_ENABLED = "notification_sound_in_active_chat_enabled" + private const val KEY_POPUP_WINDOWS_ENABLED = "notification_popup_windows_enabled" + private const val KEY_CONTACT_JOINED_ENABLED = "notification_contact_joined_enabled" + private const val KEY_PINNED_MESSAGES_ENABLED = "notification_pinned_messages_enabled" + private const val KEY_RESTART_ON_CLOSE_ENABLED = "notification_restart_on_close_enabled" + private const val KEY_BACKGROUND_CONNECTION_ENABLED = "notification_background_connection_enabled" + private const val KEY_REPEAT_INTERVAL = "notification_repeat_interval_minutes" + private const val KEY_CHAT_NOTIFICATION_PREFIX = "chat_notification." + private const val KEY_CHAT_NOTIFICATIONS_ENABLED = "notifications_enabled" + private const val KEY_CHAT_MESSAGE_SOUND = "message_sound" + private const val KEY_CHAT_SHOW_MESSAGE_PREVIEW = "show_message_preview" + private const val KEY_CHAT_POPUP_WINDOWS_ENABLED = "popup_windows_enabled" + private const val KEY_CHAT_SOUND_IN_ACTIVE_CHAT_ENABLED = "sound_in_active_chat_enabled" + } +} diff --git a/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/core/push/FirebaseTokenProvider.kt b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/core/push/FirebaseTokenProvider.kt new file mode 100644 index 00000000..181c87b3 --- /dev/null +++ b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/core/push/FirebaseTokenProvider.kt @@ -0,0 +1,18 @@ +package com.seven.ikar.kotlin.core.push + +import android.content.Context +import com.google.android.gms.tasks.Tasks +import com.google.firebase.messaging.FirebaseMessaging +import java.util.concurrent.TimeUnit + +object FirebaseTokenProvider { + fun tryGetToken(context: Context): String? { + if (!AndroidPushBootstrapper.tryInitialize(context)) { + return null + } + + return runCatching { + Tasks.await(FirebaseMessaging.getInstance().token, 20, TimeUnit.SECONDS) + }.getOrNull() + } +} diff --git a/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/core/push/IkarFirebaseMessagingService.kt b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/core/push/IkarFirebaseMessagingService.kt new file mode 100644 index 00000000..54ccfa7b --- /dev/null +++ b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/core/push/IkarFirebaseMessagingService.kt @@ -0,0 +1,48 @@ +package com.seven.ikar.kotlin.core.push + +import android.util.Log +import com.google.firebase.messaging.FirebaseMessagingService +import com.google.firebase.messaging.RemoteMessage +import com.seven.ikar.kotlin.app.IkarNativeApp +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch + +class IkarFirebaseMessagingService : FirebaseMessagingService() { + override fun onNewToken(token: String) { + super.onNewToken(token) + val app = applicationContext as? IkarNativeApp ?: return + CoroutineScope(Dispatchers.IO).launch { + runCatching { + app.container.pushRegistrationManager.onTokenRefreshedAsync(token) + }.onFailure { error -> + Log.e(LOG_TAG, "OnNewToken failed", error) + } + } + } + + override fun onMessageReceived(message: RemoteMessage) { + super.onMessageReceived(message) + runCatching { + val data = message.data.orEmpty() + if (data.isNotEmpty()) { + PushNotificationDisplayService.show(this, data) + return + } + + PushNotificationDisplayService.show( + this, + mapOf( + PushDataKeys.Title to (message.notification?.title ?: "ИКАР"), + PushDataKeys.Body to (message.notification?.body ?: "Новое сообщение") + ) + ) + }.onFailure { error -> + Log.e(LOG_TAG, "OnMessageReceived failed", error) + } + } + + companion object { + private const val LOG_TAG = "IkarKotlinFcm" + } +} diff --git a/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/core/push/IncomingCallFullScreenSetupHelper.kt b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/core/push/IncomingCallFullScreenSetupHelper.kt new file mode 100644 index 00000000..afd7603f --- /dev/null +++ b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/core/push/IncomingCallFullScreenSetupHelper.kt @@ -0,0 +1,104 @@ +package com.seven.ikar.kotlin.core.push + +import android.Manifest +import android.app.NotificationManager +import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager +import android.net.Uri +import android.os.Build +import android.provider.Settings +import androidx.core.app.NotificationManagerCompat +import androidx.core.content.ContextCompat + +sealed interface IncomingCallFullScreenSetupResult { + data object Ready : IncomingCallFullScreenSetupResult + data object NeedsPostNotificationsPermission : IncomingCallFullScreenSetupResult + data class NeedsSystemSettings( + val detail: String, + val intent: Intent + ) : IncomingCallFullScreenSetupResult +} + +object IncomingCallFullScreenSetupHelper { + fun evaluate(context: Context): IncomingCallFullScreenSetupResult { + AndroidPushBootstrapper.tryInitialize(context) + + if ( + Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && + ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED + ) { + return IncomingCallFullScreenSetupResult.NeedsPostNotificationsPermission + } + + if (!NotificationManagerCompat.from(context).areNotificationsEnabled()) { + return IncomingCallFullScreenSetupResult.NeedsSystemSettings( + detail = "Для полноэкранного входящего звонка Android требует включённые уведомления для Икар.", + intent = buildAppNotificationsIntent(context) + ) + } + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val notificationManager = context.getSystemService(NotificationManager::class.java) + val callsChannel = notificationManager?.getNotificationChannel(AndroidPushBootstrapper.callsChannelId()) + if (callsChannel != null && callsChannel.importance < NotificationManager.IMPORTANCE_HIGH) { + return IncomingCallFullScreenSetupResult.NeedsSystemSettings( + detail = "Для полноэкранного входящего звонка Android требует высокий приоритет канала «Звонки».", + intent = buildCallsChannelIntent(context) + ) + } + } + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + val notificationManager = context.getSystemService(NotificationManager::class.java) + val canUseFullScreenIntent = notificationManager?.canUseFullScreenIntent() == true + if (!canUseFullScreenIntent) { + return IncomingCallFullScreenSetupResult.NeedsSystemSettings( + detail = "Для полноэкранного входящего звонка Android требует разрешить полноэкранный вызов для Икар.", + intent = buildFullScreenIntentSettingsIntent(context) + ) + } + } + + return IncomingCallFullScreenSetupResult.Ready + } + + private fun buildAppNotificationsIntent(context: Context): Intent = + resolveSettingsIntent( + context = context, + primary = Intent(Settings.ACTION_APP_NOTIFICATION_SETTINGS).apply { + putExtra(Settings.EXTRA_APP_PACKAGE, context.packageName) + putExtra("android.provider.extra.APP_PACKAGE", context.packageName) + data = Uri.parse("package:${context.packageName}") + } + ) + + private fun buildCallsChannelIntent(context: Context): Intent = + resolveSettingsIntent( + context = context, + primary = Intent(Settings.ACTION_CHANNEL_NOTIFICATION_SETTINGS).apply { + putExtra(Settings.EXTRA_APP_PACKAGE, context.packageName) + putExtra(Settings.EXTRA_CHANNEL_ID, AndroidPushBootstrapper.callsChannelId()) + } + ) + + private fun buildFullScreenIntentSettingsIntent(context: Context): Intent = + resolveSettingsIntent( + context = context, + primary = Intent(Settings.ACTION_MANAGE_APP_USE_FULL_SCREEN_INTENT).apply { + data = Uri.parse("package:${context.packageName}") + } + ) + + private fun resolveSettingsIntent(context: Context, primary: Intent): Intent { + val packageManager = context.packageManager + if (primary.resolveActivity(packageManager) != null) { + return primary.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } + + return Intent( + Settings.ACTION_APPLICATION_DETAILS_SETTINGS, + Uri.parse("package:${context.packageName}") + ).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } +} diff --git a/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/core/push/IncomingCallInviteStore.kt b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/core/push/IncomingCallInviteStore.kt new file mode 100644 index 00000000..5acfebb8 --- /dev/null +++ b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/core/push/IncomingCallInviteStore.kt @@ -0,0 +1,100 @@ +package com.seven.ikar.kotlin.core.push + +import android.content.Intent +import com.seven.ikar.kotlin.core.model.CallMediaType +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow + +data class PendingIncomingCallInvite( + val callId: String, + val chatId: String, + val callerId: String, + val callerDisplayName: String, + val callerPhoneNumber: String?, + val mediaType: CallMediaType, + val requestId: Long = System.nanoTime() +) + +class IncomingCallInviteStore { + private val _pendingInvite = MutableStateFlow(null) + val pendingInvite: StateFlow = _pendingInvite.asStateFlow() + + fun setPending(invite: PendingIncomingCallInvite?) { + _pendingInvite.value = invite + } + + fun clear(requestId: Long? = null) { + val current = _pendingInvite.value ?: return + if (requestId == null || current.requestId == requestId) { + _pendingInvite.value = null + } + } + + fun clearByCallId(callId: String?) { + val normalizedCallId = callId?.takeIf { it.isNotBlank() } ?: return + if (_pendingInvite.value?.callId == normalizedCallId) { + _pendingInvite.value = null + } + } +} + +fun PendingIncomingCallInvite.applyToIntent(intent: Intent): Intent = + intent.apply { + putExtra(PushDataKeys.Kind, if (mediaType == CallMediaType.Video) PushDataKinds.VideoCallInvite else PushDataKinds.AudioCallInvite) + putExtra(PushDataKeys.CallId, callId) + putExtra(PushDataKeys.ChatId, chatId) + putExtra(PushDataKeys.SenderId, callerId) + putExtra(PushDataKeys.SenderName, callerDisplayName) + putExtra(PushDataKeys.SenderPhoneNumber, callerPhoneNumber) + putExtra(PushDataKeys.CallMediaType, mediaType.name) + } + +fun pendingIncomingCallInviteFromMap(data: Map): PendingIncomingCallInvite? = + pendingIncomingCallInviteFromReader(data::get) + +fun pendingIncomingCallInviteFromIntent(intent: Intent): PendingIncomingCallInvite? = + pendingIncomingCallInviteFromReader(intent::getStringExtra) + +fun endedCallIdFromMap(data: Map): String? = + endedCallIdFromReader(data::get) + +fun endedCallIdFromIntent(intent: Intent): String? = + endedCallIdFromReader(intent::getStringExtra) + +private fun pendingIncomingCallInviteFromReader(readValue: (String) -> String?): PendingIncomingCallInvite? { + val kind = readValue(PushDataKeys.Kind) + if (kind != PushDataKinds.AudioCallInvite && kind != PushDataKinds.VideoCallInvite) { + return null + } + + val callId = readValue(PushDataKeys.CallId).orEmpty() + val chatId = readValue(PushDataKeys.ChatId).orEmpty() + val callerId = readValue(PushDataKeys.SenderId).orEmpty() + val callerName = readValue(PushDataKeys.SenderName).orEmpty() + if (callId.isBlank() || chatId.isBlank() || callerId.isBlank() || callerName.isBlank()) { + return null + } + + return PendingIncomingCallInvite( + callId = callId, + chatId = chatId, + callerId = callerId, + callerDisplayName = callerName, + callerPhoneNumber = readValue(PushDataKeys.SenderPhoneNumber), + mediaType = when { + kind == PushDataKinds.VideoCallInvite -> CallMediaType.Video + readValue(PushDataKeys.CallMediaType) == CallMediaType.Video.name -> CallMediaType.Video + else -> CallMediaType.Audio + } + ) +} + +private fun endedCallIdFromReader(readValue: (String) -> String?): String? { + val kind = readValue(PushDataKeys.Kind) + if (kind != PushDataKinds.AudioCallEnded && kind != PushDataKinds.VideoCallEnded) { + return null + } + + return readValue(PushDataKeys.CallId)?.takeIf { it.isNotBlank() } +} diff --git a/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/core/push/IncomingCallNotificationManager.kt b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/core/push/IncomingCallNotificationManager.kt new file mode 100644 index 00000000..af955c85 --- /dev/null +++ b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/core/push/IncomingCallNotificationManager.kt @@ -0,0 +1,111 @@ +package com.seven.ikar.kotlin.core.push + +import android.app.KeyguardManager +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import android.os.Build +import android.os.PowerManager +import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationManagerCompat +import androidx.core.app.Person +import com.seven.ikar.kotlin.feature.call.IncomingCallActivity + +object IncomingCallNotificationManager { + fun showIncomingCall(context: Context, invite: PendingIncomingCallInvite) { + val notificationId = notificationIdFor(invite.callId) + val contentIntent = IncomingCallActivity.createLaunchIntent(context, invite) + val contentPendingIntent = PendingIntent.getActivity( + context, + notificationId, + contentIntent, + pendingIntentFlags(updateCurrent = true) + ) + val answerPendingIntent = PendingIntent.getActivity( + context, + notificationId + 1, + IncomingCallActivity.createLaunchIntent(context, invite, autoAccept = true), + pendingIntentFlags(updateCurrent = true) + ) + val declinePendingIntent = PendingIntent.getBroadcast( + context, + notificationId + 2, + invite.applyToIntent( + Intent(context, NotificationActionReceiver::class.java).apply { + action = NotificationAction.DeclineIncomingCallAction + } + ), + pendingIntentFlags(updateCurrent = true) + ) + + val title = invite.callerDisplayName + val body = if (invite.mediaType == com.seven.ikar.kotlin.core.model.CallMediaType.Video) { + "Видеозвонок в Икар" + } else { + "Аудиозвонок в Икар" + } + + val useFullScreenIntent = shouldUseFullScreenIntent(context) + val notificationBuilder = NotificationCompat.Builder(context, AndroidPushBootstrapper.callsChannelId()) + .setSmallIcon(android.R.drawable.sym_call_incoming) + .setContentTitle(title) + .setContentText(body) + .setCategory(NotificationCompat.CATEGORY_CALL) + .setPriority(NotificationCompat.PRIORITY_MAX) + .setVisibility(NotificationCompat.VISIBILITY_PUBLIC) + .setOngoing(true) + .setAutoCancel(false) + .setContentIntent(contentPendingIntent) + if (useFullScreenIntent) { + notificationBuilder + .setColorized(true) + .setStyle( + NotificationCompat.CallStyle.forIncomingCall( + Person.Builder().setName(invite.callerDisplayName).build(), + declinePendingIntent, + answerPendingIntent + ) + ) + notificationBuilder.setFullScreenIntent(contentPendingIntent, true) + } else { + notificationBuilder + .setStyle(NotificationCompat.BigTextStyle().bigText(body)) + .addAction(android.R.drawable.ic_menu_close_clear_cancel, "Отклонить", declinePendingIntent) + .addAction(android.R.drawable.sym_call_incoming, "Принять", answerPendingIntent) + } + + NotificationManagerCompat.from(context).notify(notificationId, notificationBuilder.build()) + } + + fun cancelIncomingCall(context: Context, callId: String?) { + val normalizedCallId = callId?.takeIf { it.isNotBlank() } ?: return + NotificationManagerCompat.from(context).cancel(notificationIdFor(normalizedCallId)) + } + + fun openIncomingCallScreen( + context: Context, + invite: PendingIncomingCallInvite, + autoAccept: Boolean = false + ) { + context.startActivity(IncomingCallActivity.createLaunchIntent(context, invite, autoAccept)) + } + + private fun shouldUseFullScreenIntent(context: Context): Boolean { + val keyguardManager = context.getSystemService(Context.KEYGUARD_SERVICE) as? KeyguardManager + val powerManager = context.getSystemService(Context.POWER_SERVICE) as? PowerManager + val isKeyguardLocked = keyguardManager?.isKeyguardLocked == true + val isInteractive = powerManager?.isInteractive ?: true + return isKeyguardLocked || !isInteractive + } + + private fun notificationIdFor(callId: String): Int = callId.hashCode() + + private fun pendingIntentFlags(updateCurrent: Boolean): Int { + val mutableFlags = if (updateCurrent) PendingIntent.FLAG_UPDATE_CURRENT else 0 + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + mutableFlags or PendingIntent.FLAG_IMMUTABLE + } else { + mutableFlags + } + } +} diff --git a/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/core/push/IncomingPushNavigationStore.kt b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/core/push/IncomingPushNavigationStore.kt new file mode 100644 index 00000000..6d394d67 --- /dev/null +++ b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/core/push/IncomingPushNavigationStore.kt @@ -0,0 +1,38 @@ +package com.seven.ikar.kotlin.core.push + +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow + +data class PendingPushNavigation( + val chatId: String, + val messageId: String?, + val requestId: Long = System.nanoTime() +) + +class IncomingPushNavigationStore { + private val _pendingNavigation = MutableStateFlow(null) + val pendingNavigation: StateFlow = _pendingNavigation.asStateFlow() + + fun setPendingChat(chatId: String?, messageId: String?) { + val normalizedChatId = chatId?.takeIf { it.isNotBlank() } ?: run { + _pendingNavigation.value = null + return + } + _pendingNavigation.value = PendingPushNavigation( + chatId = normalizedChatId, + messageId = messageId?.takeIf { it.isNotBlank() } + ) + } + + fun setPendingChatId(chatId: String?) { + setPendingChat(chatId = chatId, messageId = null) + } + + fun clear(requestId: Long? = null) { + val current = _pendingNavigation.value ?: return + if (requestId == null || current.requestId == requestId) { + _pendingNavigation.value = null + } + } +} diff --git a/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/core/push/InitialCallPermissionPromptStore.kt b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/core/push/InitialCallPermissionPromptStore.kt new file mode 100644 index 00000000..2b33cf69 --- /dev/null +++ b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/core/push/InitialCallPermissionPromptStore.kt @@ -0,0 +1,18 @@ +package com.seven.ikar.kotlin.core.push + +import android.content.Context + +class InitialCallPermissionPromptStore(context: Context) { + private val preferences = context.getSharedPreferences(PreferencesName, Context.MODE_PRIVATE) + + fun wasPrompted(): Boolean = preferences.getBoolean(WasPromptedKey, false) + + fun markPrompted() { + preferences.edit().putBoolean(WasPromptedKey, true).apply() + } + + private companion object { + const val PreferencesName = "ikar_kotlin_initial_call_permission_prompt" + const val WasPromptedKey = "wasPrompted" + } +} diff --git a/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/core/push/NotificationActionReceiver.kt b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/core/push/NotificationActionReceiver.kt new file mode 100644 index 00000000..d9284d68 --- /dev/null +++ b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/core/push/NotificationActionReceiver.kt @@ -0,0 +1,95 @@ +package com.seven.ikar.kotlin.core.push + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import androidx.core.app.NotificationManagerCompat +import com.seven.ikar.kotlin.app.IkarNativeApp +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch + +class NotificationActionReceiver : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + val pendingResult = goAsync() + CoroutineScope(Dispatchers.IO).launch { + try { + handleIntent(context, intent) + } finally { + pendingResult.finish() + } + } + } + + private suspend fun handleIntent(context: Context, intent: Intent) { + val action = intent.action.orEmpty() + when (action) { + NotificationAction.MarkReadAction -> handleMarkRead(context, intent) + NotificationAction.AcceptIncomingCallAction -> handleAcceptIncomingCall(context, intent) + NotificationAction.DeclineIncomingCallAction -> handleDeclineIncomingCall(context, intent) + } + } + + private suspend fun handleMarkRead(context: Context, intent: Intent) { + val chatId = intent.getStringExtra(PushDataKeys.ChatId).orEmpty() + val messageId = intent.getStringExtra(PushDataKeys.MessageId).orEmpty() + val notificationId = intent.getIntExtra(NotificationAction.NotificationIdExtra, Int.MIN_VALUE) + if (chatId.isBlank() || messageId.isBlank()) { + return + } + + val app = context.applicationContext as? IkarNativeApp ?: return + runCatching { + app.container.executeAuthorized { session -> + app.container.apiClient.acknowledgeRead( + accessToken = session.accessToken, + chatId = chatId, + messageId = messageId + ) + } + }.onSuccess { + if (notificationId != Int.MIN_VALUE) { + NotificationManagerCompat.from(context).cancel(notificationId) + } + } + } + + private fun handleAcceptIncomingCall(context: Context, intent: Intent) { + val invite = pendingIncomingCallInviteFromIntent(intent) ?: return + val app = context.applicationContext as? IkarNativeApp ?: return + if (app.container.sessionStore.read() == null) { + return + } + + app.container.incomingCallInviteStore.clearByCallId(invite.callId) + IncomingCallNotificationManager.cancelIncomingCall(context, invite.callId) + IncomingCallNotificationManager.openIncomingCallScreen(context, invite, autoAccept = true) + } + + private fun handleDeclineIncomingCall(context: Context, intent: Intent) { + val invite = pendingIncomingCallInviteFromIntent(intent) ?: return + val app = context.applicationContext as? IkarNativeApp ?: return + if (app.container.sessionStore.read() == null) { + return + } + + app.container.incomingCallInviteStore.clearByCallId(invite.callId) + app.container.callManager.presentIncomingCallInvite( + callId = invite.callId, + chatId = invite.chatId, + callerId = invite.callerId, + callerDisplayName = invite.callerDisplayName, + callerPhoneNumber = invite.callerPhoneNumber, + mediaType = invite.mediaType + ) + IncomingCallNotificationManager.cancelIncomingCall(context, invite.callId) + app.container.callManager.declineCurrentCall() + } +} + +object NotificationAction { + const val MarkReadAction = "com.seven.ikar.kotlin.NOTIFICATION_MARK_READ" + const val AcceptIncomingCallAction = "com.seven.ikar.kotlin.NOTIFICATION_ACCEPT_INCOMING_CALL" + const val DeclineIncomingCallAction = "com.seven.ikar.kotlin.NOTIFICATION_DECLINE_INCOMING_CALL" + const val NotificationIdExtra = "notificationId" +} diff --git a/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/core/push/NotificationPreferences.kt b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/core/push/NotificationPreferences.kt new file mode 100644 index 00000000..8bb2c353 --- /dev/null +++ b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/core/push/NotificationPreferences.kt @@ -0,0 +1,83 @@ +package com.seven.ikar.kotlin.core.push + +enum class NotificationSoundOption { + Default, + Ringtone, + Alarm, + Silent +} + +enum class NotificationRepeatIntervalOption(val minutes: Int) { + Off(0), + FifteenMinutes(15), + ThirtyMinutes(30), + OneHour(60), + TwoHours(120) +} + +data class NotificationSoundChoice( + val value: NotificationSoundOption, + val title: String +) + +data class NotificationRepeatIntervalChoice( + val value: NotificationRepeatIntervalOption, + val title: String +) + +data class NotificationSettingsSnapshot( + val notificationsEnabled: Boolean, + val messageSound: NotificationSoundOption, + val privateChatsEnabled: Boolean, + val groupsEnabled: Boolean, + val channelsEnabled: Boolean, + val storiesEnabled: Boolean, + val reactionsEnabled: Boolean, + val callVibrationEnabled: Boolean, + val callRingtone: NotificationSoundOption, + val showBadge: Boolean, + val includeMutedChatsInBadge: Boolean, + val countMessagesInBadge: Boolean, + val inAppSoundEnabled: Boolean, + val inAppVibrationEnabled: Boolean, + val inAppShowTextEnabled: Boolean, + val soundInActiveChatEnabled: Boolean, + val popupWindowsEnabled: Boolean, + val contactJoinedEnabled: Boolean, + val pinnedMessagesEnabled: Boolean, + val restartOnCloseEnabled: Boolean, + val backgroundConnectionEnabled: Boolean, + val repeatInterval: NotificationRepeatIntervalOption +) + +data class ChatNotificationSettingsSnapshot( + val notificationsEnabled: Boolean? = null, + val messageSound: NotificationSoundOption? = null, + val showMessagePreview: Boolean? = null, + val popupWindowsEnabled: Boolean? = null, + val soundInActiveChatEnabled: Boolean? = null +) + +object NotificationPreferenceCatalog { + val messageSoundChoices: List = listOf( + NotificationSoundChoice(NotificationSoundOption.Default, "По умолчанию"), + NotificationSoundChoice(NotificationSoundOption.Ringtone, "Рингтон"), + NotificationSoundChoice(NotificationSoundOption.Alarm, "Сигнал"), + NotificationSoundChoice(NotificationSoundOption.Silent, "Без звука") + ) + + val callRingtoneChoices: List = listOf( + NotificationSoundChoice(NotificationSoundOption.Default, "По умолчанию"), + NotificationSoundChoice(NotificationSoundOption.Ringtone, "Рингтон"), + NotificationSoundChoice(NotificationSoundOption.Alarm, "Сигнал"), + NotificationSoundChoice(NotificationSoundOption.Silent, "Без звука") + ) + + val repeatChoices: List = listOf( + NotificationRepeatIntervalChoice(NotificationRepeatIntervalOption.Off, "Выключен"), + NotificationRepeatIntervalChoice(NotificationRepeatIntervalOption.FifteenMinutes, "15 минут"), + NotificationRepeatIntervalChoice(NotificationRepeatIntervalOption.ThirtyMinutes, "30 минут"), + NotificationRepeatIntervalChoice(NotificationRepeatIntervalOption.OneHour, "1 час"), + NotificationRepeatIntervalChoice(NotificationRepeatIntervalOption.TwoHours, "2 часа") + ) +} diff --git a/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/core/push/PushData.kt b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/core/push/PushData.kt new file mode 100644 index 00000000..53de1934 --- /dev/null +++ b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/core/push/PushData.kt @@ -0,0 +1,29 @@ +package com.seven.ikar.kotlin.core.push + +object PushDataKeys { + const val Kind = "kind" + const val CallId = "callId" + const val ChatId = "chatId" + const val ChatType = "chatType" + const val MessageId = "messageId" + const val Title = "title" + const val Body = "body" + const val SenderId = "senderId" + const val SenderName = "senderName" + const val SenderPhoneNumber = "senderPhoneNumber" + const val ReactionEmoji = "reactionEmoji" + const val CallAction = "callAction" + const val CallMediaType = "callMediaType" + const val VersionCode = "versionCode" + const val VersionName = "versionName" +} + +object PushDataKinds { + const val ChatMessage = "chat_message" + const val MessageReaction = "message_reaction" + const val AudioCallInvite = "audio_call_invite" + const val VideoCallInvite = "video_call_invite" + const val AudioCallEnded = "audio_call_ended" + const val VideoCallEnded = "video_call_ended" + const val AppUpdateAvailable = "app_update_available" +} diff --git a/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/core/push/PushNotificationDisplayService.kt b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/core/push/PushNotificationDisplayService.kt new file mode 100644 index 00000000..9b539118 --- /dev/null +++ b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/core/push/PushNotificationDisplayService.kt @@ -0,0 +1,333 @@ +package com.seven.ikar.kotlin.core.push + +import android.Manifest +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager +import android.os.Build +import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationManagerCompat +import androidx.core.content.ContextCompat +import com.seven.ikar.kotlin.MainActivity +import com.seven.ikar.kotlin.app.IkarNativeApp +import com.seven.ikar.kotlin.core.contacts.PhoneNumberNormalizer + +object PushNotificationDisplayService { + fun show(context: Context, data: Map) { + val settings = AndroidPushSettings(context) + if (!settings.areNotificationsEnabled()) { + return + } + if (!AndroidNotificationPermissionHelper.areNotificationsEnabled(context)) { + return + } + + AndroidPushBootstrapper.tryInitialize(context) + + val kind = data[PushDataKeys.Kind].orEmpty() + val app = context.applicationContext as? IkarNativeApp + when (kind) { + PushDataKinds.AudioCallInvite, + PushDataKinds.VideoCallInvite -> { + val invite = pendingIncomingCallInviteFromMap(data)?.withResolvedContactDisplayName(context) ?: return + if (app?.container?.sessionStore?.read() == null) { + return + } + + if (AndroidAppVisibilityState.isVisible()) { + app.container.callManager.presentIncomingCallInvite( + callId = invite.callId, + chatId = invite.chatId, + callerId = invite.callerId, + callerDisplayName = invite.callerDisplayName, + callerPhoneNumber = invite.callerPhoneNumber, + mediaType = invite.mediaType + ) + } else { + app.container.incomingCallInviteStore.setPending(invite) + IncomingCallNotificationManager.showIncomingCall(context, invite) + } + return + } + + PushDataKinds.AudioCallEnded, + PushDataKinds.VideoCallEnded -> { + val callId = endedCallIdFromMap(data) + app?.container?.incomingCallInviteStore?.clearByCallId(callId) + app?.container?.callManager?.dismissCallIfMatches(callId) + IncomingCallNotificationManager.cancelIncomingCall(context, callId) + return + } + } + + val isChatMessage = kind == PushDataKinds.ChatMessage + val isMessageReaction = kind == PushDataKinds.MessageReaction + val isChatNotification = isChatMessage || isMessageReaction + val chatId = data[PushDataKeys.ChatId].orEmpty() + val chatNotificationSettings = if (isChatNotification && chatId.isNotBlank()) { + settings.loadChatNotificationSnapshot(chatId) + } else { + null + } + if (isChatNotification && chatNotificationSettings?.notificationsEnabled == false) { + return + } + val hideMessagePreview = isChatNotification && + (app?.container?.privacyRuntimeSettings?.loadSnapshot()?.showMessagePreview == false || + chatNotificationSettings?.showMessagePreview == false) + val isAppVisible = AndroidAppVisibilityState.isVisible() + val isActiveChatVisible = isChatNotification && + isAppVisible && + chatId == AndroidActiveChatState.getActiveChatId() + + if (isActiveChatVisible) { + val soundInActiveChatEnabled = chatNotificationSettings?.soundInActiveChatEnabled + ?: settings.getSoundInActiveChatEnabled() + if (soundInActiveChatEnabled) { + val messageSound = chatNotificationSettings?.messageSound ?: settings.getMessageSound() + val activeChatSound = when (messageSound) { + NotificationSoundOption.Silent -> NotificationSoundOption.Silent + else -> NotificationSoundOption.Default + } + AndroidMessageNotificationSound.play( + context = context, + option = activeChatSound, + dedupeKey = data[PushDataKeys.MessageId] + ?: listOfNotNull( + data[PushDataKeys.ChatId], + data[PushDataKeys.Title], + data[PushDataKeys.Body] + ).joinToString(separator = "|").takeIf { it.isNotBlank() } + ) + } + return + } + + val popupWindowsEnabled = chatNotificationSettings?.popupWindowsEnabled ?: settings.getPopupWindowsEnabled() + if (isChatNotification && isAppVisible && !popupWindowsEnabled) { + return + } + + val senderContactDisplayName = if (isChatNotification) { + resolveContactDisplayName(context, data[PushDataKeys.SenderPhoneNumber]) + } else { + null + } + val fallbackTitle = data[PushDataKeys.Title].orEmpty().ifBlank { + when (kind) { + PushDataKinds.AppUpdateAvailable -> "Доступно обновление Икар" + PushDataKinds.AudioCallInvite -> "Входящий аудиозвонок" + PushDataKinds.VideoCallInvite -> "Входящий видеозвонок" + else -> "ИКАР" + } + } + + val chatType = data[PushDataKeys.ChatType].orEmpty() + val title = if (isChatNotification && chatType.equals(ChatTypeDirect, ignoreCase = true)) { + senderContactDisplayName ?: fallbackTitle + } else { + fallbackTitle + } + + val fallbackBody = data[PushDataKeys.Body].orEmpty().ifBlank { + when (kind) { + PushDataKinds.AppUpdateAvailable -> "Откройте приложение, чтобы установить обновление." + PushDataKinds.AudioCallInvite -> "Откройте приложение, чтобы ответить на звонок." + PushDataKinds.VideoCallInvite -> "Откройте приложение, чтобы ответить на звонок." + PushDataKinds.MessageReaction -> GenericNewReactionText + else -> "Новое сообщение" + } + } + val rawBody = if (isChatNotification && chatType.equals(ChatTypeGroup, ignoreCase = true)) { + prefixSenderName( + body = fallbackBody, + senderDisplayName = senderContactDisplayName ?: data[PushDataKeys.SenderName] + ) + } else { + fallbackBody + } + val showInAppText = chatNotificationSettings?.showMessagePreview ?: settings.getInAppShowTextEnabled() + val body = if (isChatNotification && isAppVisible && !showInAppText) { + if (isMessageReaction) GenericNewReactionText else GenericNewMessageText + } else { + rawBody + } + + val notificationTitle = if (hideMessagePreview) "Икар" else title + val notificationBody = if (hideMessagePreview) { + if (isMessageReaction) GenericNewReactionText else GenericNewMessageText + } else { + body + } + + val effectiveMessageSound = when { + !isChatNotification -> NotificationSoundOption.Default + isAppVisible && !settings.getInAppSoundEnabled() -> NotificationSoundOption.Silent + else -> chatNotificationSettings?.messageSound ?: settings.getMessageSound() + } + + val channelId = when (kind) { + PushDataKinds.AppUpdateAvailable -> AndroidPushBootstrapper.updatesChannelId() + PushDataKinds.AudioCallInvite, + PushDataKinds.VideoCallInvite -> AndroidPushBootstrapper.callsChannelId() + else -> AndroidPushBootstrapper.messageChannelId(effectiveMessageSound) + } + + val intent = Intent(context, MainActivity::class.java).apply { + addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_NEW_TASK) + data.forEach { (key, value) -> putExtra(key, value) } + } + + val notificationId = ((if (isMessageReaction) "reaction:${data[PushDataKeys.MessageId].orEmpty()}" else data[PushDataKeys.MessageId]) + ?: data[PushDataKeys.CallId] + ?: data[PushDataKeys.VersionCode] + ?: System.currentTimeMillis().toString()).hashCode() + + val flags = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + } else { + PendingIntent.FLAG_UPDATE_CURRENT + } + + val pendingIntent = PendingIntent.getActivity(context, notificationId, intent, flags) + val replyPendingIntent = PendingIntent.getActivity( + context, + notificationId + 1, + intent, + flags + ) + val markReadPendingIntent = PendingIntent.getBroadcast( + context, + notificationId + 2, + Intent(context, NotificationActionReceiver::class.java).apply { + action = NotificationAction.MarkReadAction + putExtra(PushDataKeys.ChatId, data[PushDataKeys.ChatId]) + putExtra(PushDataKeys.MessageId, data[PushDataKeys.MessageId]) + putExtra(NotificationAction.NotificationIdExtra, notificationId) + }, + flags + ) + val priority = when (kind) { + PushDataKinds.AudioCallInvite, + PushDataKinds.VideoCallInvite, + PushDataKinds.AppUpdateAvailable -> NotificationCompat.PRIORITY_HIGH + else -> NotificationCompat.PRIORITY_HIGH + } + + val category = when (kind) { + PushDataKinds.AudioCallInvite, + PushDataKinds.VideoCallInvite -> NotificationCompat.CATEGORY_CALL + else -> NotificationCompat.CATEGORY_MESSAGE + } + + val notificationBuilder = NotificationCompat.Builder(context, channelId) + .setSmallIcon(android.R.drawable.sym_def_app_icon) + .setContentTitle(notificationTitle) + .setContentText(notificationBody) + .setStyle(NotificationCompat.BigTextStyle().bigText(notificationBody)) + .setContentIntent(pendingIntent) + .setAutoCancel(true) + .setPriority(priority) + .setCategory(category) + + if (hideMessagePreview) { + val publicNotificationBody = if (isMessageReaction) GenericNewReactionText else GenericNewMessageText + val publicVersion = NotificationCompat.Builder(context, channelId) + .setSmallIcon(android.R.drawable.sym_def_app_icon) + .setContentTitle("Икар") + .setContentText(publicNotificationBody) + .setPriority(priority) + .setCategory(category) + .build() + notificationBuilder + .setVisibility(NotificationCompat.VISIBILITY_PRIVATE) + .setPublicVersion(publicVersion) + } + + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) { + val soundUri = AndroidMessageNotificationSound.resolveUri(effectiveMessageSound) + if (soundUri != null) { + notificationBuilder.setSound(soundUri) + } else { + notificationBuilder.setSilent(true) + } + } else if (effectiveMessageSound == NotificationSoundOption.Silent) { + notificationBuilder.setSilent(true) + } + + if (isChatMessage && + !data[PushDataKeys.ChatId].isNullOrBlank() && + !data[PushDataKeys.MessageId].isNullOrBlank() + ) { + notificationBuilder + .addAction(android.R.drawable.ic_menu_send, "Ответить", replyPendingIntent) + .addAction(android.R.drawable.ic_menu_view, "Отметить как прочитанное", markReadPendingIntent) + } + + NotificationManagerCompat.from(context).notify(notificationId, notificationBuilder.build()) + } + + private const val GenericNewMessageText = "\u041d\u043e\u0432\u043e\u0435 \u0441\u043e\u043e\u0431\u0449\u0435\u043d\u0438\u0435" + private const val GenericNewReactionText = "\u041d\u043e\u0432\u0430\u044f \u0440\u0435\u0430\u043a\u0446\u0438\u044f" + private const val ChatTypeDirect = "Direct" + private const val ChatTypeGroup = "Group" + private const val ContactNameCacheTtlMillis = 5 * 60 * 1000L + private val contactNameCacheLock = Any() + + @Volatile + private var contactNameCache: ContactNameCache? = null + + private fun PendingIncomingCallInvite.withResolvedContactDisplayName(context: Context): PendingIncomingCallInvite { + val contactName = resolveContactDisplayName(context, callerPhoneNumber) ?: return this + return copy(callerDisplayName = contactName) + } + + private fun resolveContactDisplayName(context: Context, phoneNumber: String?): String? { + val normalizedPhoneNumber = PhoneNumberNormalizer.normalize(phoneNumber) ?: return null + return loadContactNamesByPhone(context)[normalizedPhoneNumber]?.takeIf { it.isNotBlank() } + } + + private fun loadContactNamesByPhone(context: Context): Map { + if (!hasContactsPermission(context)) { + return emptyMap() + } + + val now = System.currentTimeMillis() + contactNameCache + ?.takeIf { now - it.loadedAtMillis < ContactNameCacheTtlMillis } + ?.let { return it.namesByPhone } + + return synchronized(contactNameCacheLock) { + contactNameCache + ?.takeIf { now - it.loadedAtMillis < ContactNameCacheTtlMillis } + ?.let { return@synchronized it.namesByPhone } + + val app = context.applicationContext as? IkarNativeApp ?: return@synchronized emptyMap() + val namesByPhone = runCatching { + app.container.contactDiscoveryService + .getContacts() + .associate { contact -> contact.normalizedPhoneNumber to contact.displayName } + }.getOrDefault(emptyMap()) + + contactNameCache = ContactNameCache(now, namesByPhone) + namesByPhone + } + } + + private fun hasContactsPermission(context: Context): Boolean = + ContextCompat.checkSelfPermission( + context, + Manifest.permission.READ_CONTACTS + ) == PackageManager.PERMISSION_GRANTED + + private fun prefixSenderName(body: String, senderDisplayName: String?): String { + val name = senderDisplayName?.trim().takeUnless { it.isNullOrBlank() } ?: return body + return if (body.startsWith("$name:")) body else "$name: $body" + } + + private data class ContactNameCache( + val loadedAtMillis: Long, + val namesByPhone: Map + ) +} diff --git a/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/core/push/PushRegistrationManager.kt b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/core/push/PushRegistrationManager.kt new file mode 100644 index 00000000..d23159d9 --- /dev/null +++ b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/core/push/PushRegistrationManager.kt @@ -0,0 +1,82 @@ +package com.seven.ikar.kotlin.core.push + +import android.content.Context +import android.os.Build +import com.seven.ikar.kotlin.core.model.PushPlatform +import com.seven.ikar.kotlin.core.model.RegisterPushDeviceRequest +import com.seven.ikar.kotlin.core.network.ApiClient +import com.seven.ikar.kotlin.core.session.AuthorizedSessionManager +import com.seven.ikar.kotlin.core.session.SessionStore +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +class PushRegistrationManager( + private val context: Context, + private val apiClient: ApiClient, + private val sessionStore: SessionStore, + private val authorizedSessionManager: AuthorizedSessionManager, + private val settings: AndroidPushSettings +) { + suspend fun initializeAsync() = withContext(Dispatchers.IO) { + AndroidPushBootstrapper.tryInitialize(context) + } + + suspend fun registerCurrentDeviceAsync() = withContext(Dispatchers.IO) { + if (!AndroidPushBootstrapper.tryInitialize(context)) { + return@withContext + } + + val token = settings.getDeviceToken().takeUnless { it.isNullOrBlank() } + ?: FirebaseTokenProvider.tryGetToken(context) + ?: return@withContext + + settings.setDeviceToken(token) + + val request = RegisterPushDeviceRequest( + platform = PushPlatform.Android, + installationId = settings.getInstallationId(), + deviceToken = token, + deviceName = buildDeviceName(), + notificationsEnabled = settings.areNotificationsEnabled() && + AndroidNotificationPermissionHelper.areNotificationsEnabled(context) + ) + + if (sessionStore.read() == null) { + return@withContext + } + + authorizedSessionManager.executeAuthorized { session -> + apiClient.registerPushDevice( + accessToken = session.accessToken, + request = request + ) + } + } + + suspend fun unregisterCurrentDeviceAsync() = withContext(Dispatchers.IO) { + if (sessionStore.read() == null) { + return@withContext + } + + authorizedSessionManager.executeAuthorized { session -> + apiClient.unregisterPushDevice( + accessToken = session.accessToken, + installationId = settings.getInstallationId() + ) + } + } + + suspend fun onTokenRefreshedAsync(token: String) = withContext(Dispatchers.IO) { + if (token.isBlank()) { + return@withContext + } + + settings.setDeviceToken(token) + if (sessionStore.read() != null) { + runCatching { registerCurrentDeviceAsync() } + } + } + + private fun buildDeviceName(): String = + "${Build.MANUFACTURER ?: "Android"} ${Build.MODEL ?: "device"}".trim() +} diff --git a/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/core/realtime/RealtimeDtos.kt b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/core/realtime/RealtimeDtos.kt new file mode 100644 index 00000000..7697e86a --- /dev/null +++ b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/core/realtime/RealtimeDtos.kt @@ -0,0 +1,332 @@ +package com.seven.ikar.kotlin.core.realtime + +import com.seven.ikar.kotlin.core.model.AndroidAppReleaseDto +import com.seven.ikar.kotlin.core.model.AttachmentDto +import com.seven.ikar.kotlin.core.model.AttachmentKind +import com.seven.ikar.kotlin.core.model.ChatSummaryDto +import com.seven.ikar.kotlin.core.model.ChatType +import com.seven.ikar.kotlin.core.model.CallAnswerDto +import com.seven.ikar.kotlin.core.model.CallConnectedDto +import com.seven.ikar.kotlin.core.model.CallEndedDto +import com.seven.ikar.kotlin.core.model.CallIceCandidateDto +import com.seven.ikar.kotlin.core.model.CallIceServerDto +import com.seven.ikar.kotlin.core.model.CallMediaType +import com.seven.ikar.kotlin.core.model.CallOfferDto +import com.seven.ikar.kotlin.core.model.IncomingCallDto +import com.seven.ikar.kotlin.core.model.MessageDeliveryState +import com.seven.ikar.kotlin.core.model.MessageDto +import com.seven.ikar.kotlin.core.model.MessageReactionDto +import com.seven.ikar.kotlin.core.model.MessageReplyDto +import com.seven.ikar.kotlin.core.model.OutgoingCallDto +import com.seven.ikar.kotlin.core.model.UserSummaryDto + +data class RealtimeUserSummaryDto( + val id: String, + val username: String, + val displayName: String, + val isOnline: Boolean, + val phoneNumber: String? = null, + val lastSeenAt: String? = null, + val about: String? = null, + val avatarPath: String? = null, + val isBot: Boolean = false +) + +data class RealtimeAttachmentDto( + val id: String, + val fileName: String, + val contentType: String, + val fileSizeBytes: Long, + val downloadPath: String, + val kind: Int, + val sortOrder: Int = 0 +) + +data class RealtimeMessageDto( + val id: String, + val chatId: String, + val chatType: Int, + val sender: RealtimeUserSummaryDto, + val text: String, + val attachments: List = emptyList(), + val sentAt: String, + val editedAt: String? = null, + val deletedAt: String? = null, + val deliveryState: Int, + val forwardedFromDisplayName: String? = null, + val replyTo: RealtimeMessageReplyDto? = null, + val pinnedAt: String? = null, + val pinnedBy: RealtimeUserSummaryDto? = null, + val mediaAlbumId: String? = null, + val reactions: List = emptyList(), + val hasProtectedContent: Boolean = false, + val expiresAt: String? = null, + val ttlSeconds: Int? = null, + val postedAsChannel: Boolean = false, + val displayAuthorName: String? = null, + val viewCount: Int? = null +) + +data class RealtimeMessageReplyDto( + val messageId: String, + val senderId: String, + val senderDisplayName: String, + val previewText: String +) + +data class RealtimeMessageReactionDto( + val emoji: String, + val count: Int, + val isMine: Boolean +) + +data class RealtimeChatSummaryDto( + val id: String, + val title: String, + val type: Int, + val secondaryText: String? = null, + val lastActivityAt: String? = null, + val lastMessagePreview: String? = null, + val participants: List = emptyList(), + val canSendMessages: Boolean = true, + val unreadCount: Int = 0, + val isPinned: Boolean = false, + val isArchived: Boolean = false, + val isMuted: Boolean = false, + val folderKey: String? = null, + val hasProtectedContent: Boolean = false, + val defaultMessageTtlSeconds: Int? = null, + val lastMessageAttachment: RealtimeAttachmentDto? = null, + val subscriberCount: Int = 0, + val adminCount: Int = 0, + val isSubscribed: Boolean = false, + val canSubscribe: Boolean = false, + val canManageChannel: Boolean = false, + val canPostAsChannel: Boolean = false, + val publicUsername: String? = null, + val linkedDiscussionChatId: String? = null, + val channelSignaturesEnabled: Boolean = false +) + +data class RealtimeOutgoingCallDto( + val callId: String, + val chatId: String, + val peer: RealtimeUserSummaryDto, + val mediaType: Int +) + +data class RealtimeIncomingCallDto( + val callId: String, + val chatId: String, + val caller: RealtimeUserSummaryDto, + val mediaType: Int +) + +data class RealtimeCallConnectedDto( + val callId: String, + val chatId: String, + val connectedAt: String, + val mediaType: Int, + val iceServers: List = emptyList(), + val shouldCreateOffer: Boolean = false +) + +data class RealtimeCallEndedDto( + val callId: String, + val chatId: String, + val reason: String, + val mediaType: Int +) + +data class RealtimeCallIceServerDto( + val urls: List, + val username: String? = null, + val credential: String? = null +) + +data class RealtimeCallOfferDto( + val callId: String, + val type: String, + val sdp: String +) + +data class RealtimeCallAnswerDto( + val callId: String, + val type: String, + val sdp: String +) + +data class RealtimeCallIceCandidateDto( + val callId: String, + val candidate: String, + val sdpMid: String? = null, + val sdpMLineIndex: Int +) + +fun RealtimeUserSummaryDto.toCore(): UserSummaryDto = UserSummaryDto( + id = id, + username = username, + displayName = displayName, + isOnline = isOnline, + phoneNumber = phoneNumber, + lastSeenAt = lastSeenAt, + about = about, + avatarPath = avatarPath, + isBot = isBot +) + +fun RealtimeAttachmentDto.toCore(): AttachmentDto = AttachmentDto( + id = id, + fileName = fileName, + contentType = contentType, + fileSizeBytes = fileSizeBytes, + downloadPath = downloadPath, + kind = AttachmentKind.fromWireValue(kind), + sortOrder = sortOrder +) + +fun RealtimeMessageDto.toCore(): MessageDto = MessageDto( + id = id, + chatId = chatId, + chatType = ChatType.fromWireValue(chatType), + sender = sender.toCore(), + text = text, + attachments = attachments.map { it.toCore() }, + sentAt = sentAt, + editedAt = editedAt, + deletedAt = deletedAt, + deliveryState = MessageDeliveryState.fromWireValue(deliveryState), + forwardedFromDisplayName = forwardedFromDisplayName, + replyTo = replyTo?.toCore(), + pinnedAt = pinnedAt, + pinnedBy = pinnedBy?.toCore(), + mediaAlbumId = mediaAlbumId, + reactions = reactions.map { it.toCore() }, + hasProtectedContent = hasProtectedContent, + expiresAt = expiresAt, + ttlSeconds = ttlSeconds, + postedAsChannel = postedAsChannel, + displayAuthorName = displayAuthorName, + viewCount = viewCount +) + +fun RealtimeMessageReplyDto.toCore(): MessageReplyDto = MessageReplyDto( + messageId = messageId, + senderId = senderId, + senderDisplayName = senderDisplayName, + previewText = previewText +) + +fun RealtimeMessageReactionDto.toCore(): MessageReactionDto = MessageReactionDto( + emoji = emoji, + count = count, + isMine = isMine +) + +fun RealtimeChatSummaryDto.toCore(): ChatSummaryDto = ChatSummaryDto( + id = id, + title = title, + type = ChatType.fromWireValue(type), + secondaryText = secondaryText, + lastActivityAt = lastActivityAt, + lastMessagePreview = lastMessagePreview, + participants = participants.map { it.toCore() }, + canSendMessages = canSendMessages, + unreadCount = unreadCount, + isPinned = isPinned, + isArchived = isArchived, + isMuted = isMuted, + folderKey = folderKey, + hasProtectedContent = hasProtectedContent, + defaultMessageTtlSeconds = defaultMessageTtlSeconds, + lastMessageAttachment = lastMessageAttachment?.toCore(), + subscriberCount = subscriberCount, + adminCount = adminCount, + isSubscribed = isSubscribed, + canSubscribe = canSubscribe, + canManageChannel = canManageChannel, + canPostAsChannel = canPostAsChannel, + publicUsername = publicUsername, + linkedDiscussionChatId = linkedDiscussionChatId, + channelSignaturesEnabled = channelSignaturesEnabled +) + +fun RealtimeOutgoingCallDto.toCore(): OutgoingCallDto = OutgoingCallDto( + callId = callId, + chatId = chatId, + peer = peer.toCore(), + mediaType = CallMediaType.fromWireValue(mediaType) +) + +fun RealtimeIncomingCallDto.toCore(): IncomingCallDto = IncomingCallDto( + callId = callId, + chatId = chatId, + caller = caller.toCore(), + mediaType = CallMediaType.fromWireValue(mediaType) +) + +fun RealtimeCallConnectedDto.toCore(): CallConnectedDto = CallConnectedDto( + callId = callId, + chatId = chatId, + connectedAt = connectedAt, + mediaType = CallMediaType.fromWireValue(mediaType), + iceServers = iceServers.map { it.toCore() }, + shouldCreateOffer = shouldCreateOffer +) + +fun RealtimeCallEndedDto.toCore(): CallEndedDto = CallEndedDto( + callId = callId, + chatId = chatId, + reason = reason, + mediaType = CallMediaType.fromWireValue(mediaType) +) + +fun RealtimeCallIceServerDto.toCore(): CallIceServerDto = CallIceServerDto( + urls = urls, + username = username, + credential = credential +) + +fun CallIceServerDto.toRealtime(): RealtimeCallIceServerDto = RealtimeCallIceServerDto( + urls = urls, + username = username, + credential = credential +) + +fun RealtimeCallOfferDto.toCore(): CallOfferDto = CallOfferDto( + callId = callId, + type = type, + sdp = sdp +) + +fun CallOfferDto.toRealtime(): RealtimeCallOfferDto = RealtimeCallOfferDto( + callId = callId, + type = type, + sdp = sdp +) + +fun RealtimeCallAnswerDto.toCore(): CallAnswerDto = CallAnswerDto( + callId = callId, + type = type, + sdp = sdp +) + +fun CallAnswerDto.toRealtime(): RealtimeCallAnswerDto = RealtimeCallAnswerDto( + callId = callId, + type = type, + sdp = sdp +) + +fun RealtimeCallIceCandidateDto.toCore(): CallIceCandidateDto = CallIceCandidateDto( + callId = callId, + candidate = candidate, + sdpMid = sdpMid, + sdpMLineIndex = sdpMLineIndex +) + +fun CallIceCandidateDto.toRealtime(): RealtimeCallIceCandidateDto = RealtimeCallIceCandidateDto( + callId = callId, + candidate = candidate, + sdpMid = sdpMid, + sdpMLineIndex = sdpMLineIndex +) diff --git a/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/core/realtime/RealtimeService.kt b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/core/realtime/RealtimeService.kt new file mode 100644 index 00000000..ca13e58b --- /dev/null +++ b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/core/realtime/RealtimeService.kt @@ -0,0 +1,297 @@ +package com.seven.ikar.kotlin.core.realtime + +import com.microsoft.signalr.HubConnection +import com.microsoft.signalr.HubConnectionBuilder +import com.seven.ikar.kotlin.core.model.AndroidAppReleaseDto +import com.seven.ikar.kotlin.core.model.CallAnswerDto +import com.seven.ikar.kotlin.core.model.CallConnectedDto +import com.seven.ikar.kotlin.core.model.CallEndedDto +import com.seven.ikar.kotlin.core.model.CallIceCandidateDto +import com.seven.ikar.kotlin.core.model.CallMediaType +import com.seven.ikar.kotlin.core.model.CallOfferDto +import com.seven.ikar.kotlin.core.model.ChatSummaryDto +import com.seven.ikar.kotlin.core.model.IncomingCallDto +import com.seven.ikar.kotlin.core.model.MessageDto +import com.seven.ikar.kotlin.core.model.OutgoingCallDto +import com.seven.ikar.kotlin.core.model.TypingIndicatorDto +import com.seven.ikar.kotlin.core.network.ClientVersionProvider +import com.seven.ikar.kotlin.core.network.ServerConfig +import io.reactivex.rxjava3.core.Single +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import java.net.URLEncoder +import java.nio.charset.StandardCharsets + +enum class RealtimeConnectionStage { + Disconnected, + Connecting, + Connected, + Reconnecting +} + +class RealtimeService( + private val clientVersionProvider: ClientVersionProvider +) { + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + private val mutex = Mutex() + private var connection: HubConnection? = null + private var accessToken: String? = null + private var reconnectJob: Job? = null + + private val _stage = MutableStateFlow(RealtimeConnectionStage.Disconnected) + val stage: StateFlow = _stage + + private val _messagesCreated = MutableSharedFlow(extraBufferCapacity = 32) + val messagesCreated: SharedFlow = _messagesCreated + + private val _messagesUpdated = MutableSharedFlow(extraBufferCapacity = 32) + val messagesUpdated: SharedFlow = _messagesUpdated + + private val _typingChanged = MutableSharedFlow(extraBufferCapacity = 64) + val typingChanged: SharedFlow = _typingChanged + + private val _chatCreated = MutableSharedFlow(extraBufferCapacity = 16) + val chatCreated: SharedFlow = _chatCreated + + private val _chatsChanged = MutableSharedFlow(extraBufferCapacity = 8) + val chatsChanged: SharedFlow = _chatsChanged + + private val _appUpdateAvailable = MutableSharedFlow(extraBufferCapacity = 8) + val appUpdateAvailable: SharedFlow = _appUpdateAvailable + + private val _incomingCall = MutableSharedFlow(extraBufferCapacity = 8) + val incomingCall: SharedFlow = _incomingCall + + private val _callConnected = MutableSharedFlow(extraBufferCapacity = 8) + val callConnected: SharedFlow = _callConnected + + private val _callEnded = MutableSharedFlow(extraBufferCapacity = 8) + val callEnded: SharedFlow = _callEnded + + private val _callOffer = MutableSharedFlow(extraBufferCapacity = 16) + val callOffer: SharedFlow = _callOffer + + private val _callAnswer = MutableSharedFlow(extraBufferCapacity = 16) + val callAnswer: SharedFlow = _callAnswer + + private val _callIceCandidate = MutableSharedFlow(extraBufferCapacity = 32) + val callIceCandidate: SharedFlow = _callIceCandidate + + fun connect(accessToken: String) { + this.accessToken = accessToken + scope.launch { + ensureConnected() + } + } + + suspend fun ensureConnected(accessToken: String) { + this.accessToken = accessToken + ensureConnected() + check(_stage.value == RealtimeConnectionStage.Connected) { + "Realtime hub is not connected." + } + } + + fun disconnect() { + scope.launch { + mutex.withLock { + accessToken = null + reconnectJob?.cancel() + reconnectJob = null + connection?.stop()?.blockingAwait() + connection = null + _stage.value = RealtimeConnectionStage.Disconnected + } + } + } + + fun startCall(chatId: String, mediaType: CallMediaType): OutgoingCallDto { + val activeConnection = requireConnectedConnection() + return if (mediaType == CallMediaType.Audio) { + activeConnection.invoke(RealtimeOutgoingCallDto::class.java, "StartCall", chatId).blockingGet().toCore() + } else { + activeConnection.invoke(RealtimeOutgoingCallDto::class.java, "StartMediaCall", chatId, mediaType.wireValue).blockingGet().toCore() + } + } + + fun acceptCall(callId: String) { + requireConnectedConnection().invoke("AcceptCall", callId).blockingAwait() + } + + fun declineCall(callId: String) { + requireConnectedConnection().invoke("DeclineCall", callId).blockingAwait() + } + + fun endCall(callId: String) { + requireConnectedConnection().invoke("EndCall", callId).blockingAwait() + } + + fun sendCallOffer(payload: CallOfferDto) { + requireConnectedConnection().send("SendCallOffer", payload.toRealtime()) + } + + fun sendCallAnswer(payload: CallAnswerDto) { + requireConnectedConnection().send("SendCallAnswer", payload.toRealtime()) + } + + fun sendCallIceCandidate(payload: CallIceCandidateDto) { + requireConnectedConnection().send("SendCallIceCandidate", payload.toRealtime()) + } + + fun sendTyping(chatId: String, isTyping: Boolean) { + requireConnectedConnection().send("SendTyping", chatId, isTyping) + } + + private suspend fun ensureConnected() { + mutex.withLock { + val current = connection + if (current != null && current.connectionState == com.microsoft.signalr.HubConnectionState.CONNECTED) { + return + } + + if (accessToken.isNullOrBlank()) { + _stage.value = RealtimeConnectionStage.Disconnected + return + } + + _stage.value = if (current == null) { + RealtimeConnectionStage.Connecting + } else { + RealtimeConnectionStage.Reconnecting + } + + val newConnection = buildConnection(accessToken!!) + connection = newConnection + runCatching { + newConnection.start().blockingAwait() + }.onSuccess { + _stage.value = RealtimeConnectionStage.Connected + }.onFailure { + if (connection == newConnection) { + connection = null + } + runCatching { newConnection.stop().blockingAwait() } + _stage.value = RealtimeConnectionStage.Disconnected + scheduleReconnect() + } + } + } + + private fun buildConnection(accessToken: String): HubConnection { + val hubUrl = buildHubUrl() + val hub = HubConnectionBuilder.create(hubUrl) + .withAccessTokenProvider(Single.defer { Single.just(accessToken) }) + .withHeader("X-Ikar-Client-Platform", clientVersionProvider.platform) + .withHeader("X-Ikar-Client-Version-Code", clientVersionProvider.versionCode) + .withHeader("X-Ikar-Client-Version-Name", clientVersionProvider.versionName) + .build() + + hub.on("MessageCreated", { payload: RealtimeMessageDto -> + scope.launch { _messagesCreated.emit(payload.toCore()) } + }, RealtimeMessageDto::class.java) + + hub.on("MessageUpdated", { payload: RealtimeMessageDto -> + scope.launch { _messagesUpdated.emit(payload.toCore()) } + }, RealtimeMessageDto::class.java) + + hub.on("TypingChanged", { payload: TypingIndicatorDto -> + scope.launch { _typingChanged.emit(payload) } + }, TypingIndicatorDto::class.java) + + hub.on("ChatCreated", { payload: RealtimeChatSummaryDto -> + scope.launch { _chatCreated.emit(payload.toCore()) } + }, RealtimeChatSummaryDto::class.java) + + hub.on("ChatsChanged", { + scope.launch { _chatsChanged.emit(Unit) } + }) + + hub.on("AppUpdateAvailable", { payload: AndroidAppReleaseDto -> + scope.launch { _appUpdateAvailable.emit(payload) } + }, AndroidAppReleaseDto::class.java) + + hub.on("IncomingCall", { payload: RealtimeIncomingCallDto -> + scope.launch { _incomingCall.emit(payload.toCore()) } + }, RealtimeIncomingCallDto::class.java) + + hub.on("CallConnected", { payload: RealtimeCallConnectedDto -> + scope.launch { _callConnected.emit(payload.toCore()) } + }, RealtimeCallConnectedDto::class.java) + + hub.on("CallEnded", { payload: RealtimeCallEndedDto -> + scope.launch { _callEnded.emit(payload.toCore()) } + }, RealtimeCallEndedDto::class.java) + + hub.on("CallOffer", { payload: RealtimeCallOfferDto -> + scope.launch { _callOffer.emit(payload.toCore()) } + }, RealtimeCallOfferDto::class.java) + + hub.on("CallAnswer", { payload: RealtimeCallAnswerDto -> + scope.launch { _callAnswer.emit(payload.toCore()) } + }, RealtimeCallAnswerDto::class.java) + + hub.on("CallIceCandidate", { payload: RealtimeCallIceCandidateDto -> + scope.launch { _callIceCandidate.emit(payload.toCore()) } + }, RealtimeCallIceCandidateDto::class.java) + + hub.onClosed { + _stage.value = RealtimeConnectionStage.Disconnected + if (!accessToken.isNullOrBlank()) { + scheduleReconnect() + } + } + + return hub + } + + private fun scheduleReconnect() { + if (accessToken.isNullOrBlank()) { + return + } + + if (reconnectJob?.isActive == true) { + return + } + + reconnectJob = scope.launch { + while (accessToken != null) { + delay(5_000) + ensureConnected() + if (_stage.value == RealtimeConnectionStage.Connected) { + return@launch + } + } + } + } + + private fun buildHubUrl(): String { + val query = listOf( + "clientPlatform=${encode(clientVersionProvider.platform)}", + "clientVersionCode=${encode(clientVersionProvider.versionCode)}", + "clientVersionName=${encode(clientVersionProvider.versionName)}" + ).joinToString("&") + return "${ServerConfig.absoluteMessengerUrl(ServerConfig.HubPath)}?$query" + } + + private fun encode(value: String): String = + URLEncoder.encode(value, StandardCharsets.UTF_8.toString()) + + private fun requireConnectedConnection(): HubConnection { + val activeConnection = connection + check(activeConnection != null && activeConnection.connectionState == com.microsoft.signalr.HubConnectionState.CONNECTED) { + "Realtime hub is not connected." + } + + return activeConnection + } +} diff --git a/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/core/session/AuthorizedSessionManager.kt b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/core/session/AuthorizedSessionManager.kt new file mode 100644 index 00000000..304fd3f8 --- /dev/null +++ b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/core/session/AuthorizedSessionManager.kt @@ -0,0 +1,40 @@ +package com.seven.ikar.kotlin.core.session + +import com.seven.ikar.kotlin.core.model.AuthSessionDto +import com.seven.ikar.kotlin.core.network.ApiClient +import com.seven.ikar.kotlin.core.network.ApiException +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock + +class AuthorizedSessionManager( + private val apiClient: ApiClient, + private val sessionStore: SessionStore +) { + private val refreshMutex = Mutex() + + suspend fun executeAuthorized(block: suspend (AuthSessionDto) -> T): T { + val session = sessionStore.read() ?: throw IllegalStateException("Сессия не найдена.") + return try { + block(session) + } catch (error: ApiException) { + if (error.statusCode != 401) { + throw error + } + + val refreshedSession = refreshSessionAfterUnauthorized(session) + block(refreshedSession) + } + } + + suspend fun refreshSessionAfterUnauthorized(failedSession: AuthSessionDto): AuthSessionDto = + refreshMutex.withLock { + val currentSession = sessionStore.read() ?: throw IllegalStateException("Сессия не найдена.") + if (currentSession.refreshToken != failedSession.refreshToken) { + return@withLock currentSession + } + + val refreshedSession = apiClient.refreshSession(failedSession.refreshToken) + sessionStore.save(refreshedSession) + refreshedSession + } +} diff --git a/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/core/session/SessionStore.kt b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/core/session/SessionStore.kt new file mode 100644 index 00000000..37d207fc --- /dev/null +++ b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/core/session/SessionStore.kt @@ -0,0 +1,30 @@ +package com.seven.ikar.kotlin.core.session + +import android.content.Context +import android.content.SharedPreferences +import com.seven.ikar.kotlin.core.model.AuthSessionDto +import kotlinx.serialization.json.Json + +class SessionStore(context: Context, private val json: Json) { + private val preferences: SharedPreferences = + context.getSharedPreferences("ikar_kotlin_session", Context.MODE_PRIVATE) + + fun read(): AuthSessionDto? { + val raw = preferences.getString(KEY_SESSION, null) ?: return null + return runCatching { json.decodeFromString(raw) }.getOrNull() + } + + fun save(session: AuthSessionDto) { + preferences.edit() + .putString(KEY_SESSION, json.encodeToString(AuthSessionDto.serializer(), session)) + .apply() + } + + fun clear() { + preferences.edit().remove(KEY_SESSION).apply() + } + + companion object { + private const val KEY_SESSION = "session" + } +} diff --git a/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/core/settings/AndroidAppSettings.kt b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/core/settings/AndroidAppSettings.kt new file mode 100644 index 00000000..aed81640 --- /dev/null +++ b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/core/settings/AndroidAppSettings.kt @@ -0,0 +1,140 @@ +package com.seven.ikar.kotlin.core.settings + +import android.content.Context +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow + +enum class AppThemeMode { + System, + Light, + Dark, + Auto +} + +enum class AppLanguageMode { + System, + Russian, + English +} + +enum class AppProxyType { + Socks5, + MtProto, + Http +} + +data class AppSettingsSnapshot( + val autoDownloadPhotos: Boolean = true, + val autoDownloadVideosOnWifi: Boolean = false, + val autoDownloadFilesOnWifi: Boolean = false, + val saveMediaToGallery: Boolean = false, + val lowDataCalls: Boolean = false, + val keepMediaDays: Int = 30, + val maxCacheMb: Int = 512, + val themeMode: AppThemeMode = AppThemeMode.System, + val accentColorName: String = "Telegram Blue", + val animatedBackgrounds: Boolean = true, + val largeEmoji: Boolean = true, + val languageMode: AppLanguageMode = AppLanguageMode.System, + val proxyEnabled: Boolean = false, + val proxyType: AppProxyType = AppProxyType.Socks5, + val proxyHost: String = "", + val proxyPort: Int = 1080, + val proxySecret: String = "" +) + +class AndroidAppSettings(context: Context) { + private val preferences = context.getSharedPreferences(PREFERENCES_NAME, Context.MODE_PRIVATE) + private val _snapshot = MutableStateFlow(loadSnapshot()) + val snapshot: StateFlow = _snapshot.asStateFlow() + + fun loadSnapshot(): AppSettingsSnapshot = + AppSettingsSnapshot( + autoDownloadPhotos = preferences.getBoolean(KEY_AUTO_DOWNLOAD_PHOTOS, true), + autoDownloadVideosOnWifi = preferences.getBoolean(KEY_AUTO_DOWNLOAD_VIDEOS_ON_WIFI, false), + autoDownloadFilesOnWifi = preferences.getBoolean(KEY_AUTO_DOWNLOAD_FILES_ON_WIFI, false), + saveMediaToGallery = preferences.getBoolean(KEY_SAVE_MEDIA_TO_GALLERY, false), + lowDataCalls = preferences.getBoolean(KEY_LOW_DATA_CALLS, false), + keepMediaDays = preferences.getInt(KEY_KEEP_MEDIA_DAYS, 30).coerceIn(1, 365), + maxCacheMb = preferences.getInt(KEY_MAX_CACHE_MB, 512).coerceIn(64, 8192), + themeMode = getEnum(KEY_THEME_MODE, AppThemeMode.System), + accentColorName = preferences.getString(KEY_ACCENT_COLOR_NAME, "Telegram Blue").orEmpty() + .ifBlank { "Telegram Blue" }, + animatedBackgrounds = preferences.getBoolean(KEY_ANIMATED_BACKGROUNDS, true), + largeEmoji = preferences.getBoolean(KEY_LARGE_EMOJI, true), + languageMode = getEnum(KEY_LANGUAGE_MODE, AppLanguageMode.System), + proxyEnabled = preferences.getBoolean(KEY_PROXY_ENABLED, false), + proxyType = getEnum(KEY_PROXY_TYPE, AppProxyType.Socks5), + proxyHost = preferences.getString(KEY_PROXY_HOST, "").orEmpty(), + proxyPort = preferences.getInt(KEY_PROXY_PORT, 1080).coerceIn(1, 65_535), + proxySecret = preferences.getString(KEY_PROXY_SECRET, "").orEmpty() + ) + + fun update(transform: (AppSettingsSnapshot) -> AppSettingsSnapshot) { + saveSnapshot(transform(_snapshot.value)) + } + + fun saveSnapshot(snapshot: AppSettingsSnapshot) { + val sanitized = snapshot.copy( + keepMediaDays = snapshot.keepMediaDays.coerceIn(1, 365), + maxCacheMb = snapshot.maxCacheMb.coerceIn(64, 8192), + proxyPort = snapshot.proxyPort.coerceIn(1, 65_535) + ) + preferences.edit().apply { + putBoolean(KEY_AUTO_DOWNLOAD_PHOTOS, sanitized.autoDownloadPhotos) + putBoolean(KEY_AUTO_DOWNLOAD_VIDEOS_ON_WIFI, sanitized.autoDownloadVideosOnWifi) + putBoolean(KEY_AUTO_DOWNLOAD_FILES_ON_WIFI, sanitized.autoDownloadFilesOnWifi) + putBoolean(KEY_SAVE_MEDIA_TO_GALLERY, sanitized.saveMediaToGallery) + putBoolean(KEY_LOW_DATA_CALLS, sanitized.lowDataCalls) + putInt(KEY_KEEP_MEDIA_DAYS, sanitized.keepMediaDays) + putInt(KEY_MAX_CACHE_MB, sanitized.maxCacheMb) + putString(KEY_THEME_MODE, sanitized.themeMode.name) + putString(KEY_ACCENT_COLOR_NAME, sanitized.accentColorName) + putBoolean(KEY_ANIMATED_BACKGROUNDS, sanitized.animatedBackgrounds) + putBoolean(KEY_LARGE_EMOJI, sanitized.largeEmoji) + putString(KEY_LANGUAGE_MODE, sanitized.languageMode.name) + putBoolean(KEY_PROXY_ENABLED, sanitized.proxyEnabled) + putString(KEY_PROXY_TYPE, sanitized.proxyType.name) + putString(KEY_PROXY_HOST, sanitized.proxyHost) + putInt(KEY_PROXY_PORT, sanitized.proxyPort) + putString(KEY_PROXY_SECRET, sanitized.proxySecret) + apply() + } + _snapshot.value = sanitized + } + + fun reset() { + preferences.edit().clear().apply() + _snapshot.value = AppSettingsSnapshot() + } + + private fun > getEnum(key: String, defaultValue: T): T { + val rawValue = preferences.getString(key, defaultValue.name).orEmpty() + @Suppress("UNCHECKED_CAST") + return defaultValue.declaringJavaClass.enumConstants + ?.firstOrNull { it.name == rawValue } as? T + ?: defaultValue + } + + companion object { + private const val PREFERENCES_NAME = "ikar_kotlin_app_settings" + private const val KEY_AUTO_DOWNLOAD_PHOTOS = "auto_download_photos" + private const val KEY_AUTO_DOWNLOAD_VIDEOS_ON_WIFI = "auto_download_videos_on_wifi" + private const val KEY_AUTO_DOWNLOAD_FILES_ON_WIFI = "auto_download_files_on_wifi" + private const val KEY_SAVE_MEDIA_TO_GALLERY = "save_media_to_gallery" + private const val KEY_LOW_DATA_CALLS = "low_data_calls" + private const val KEY_KEEP_MEDIA_DAYS = "keep_media_days" + private const val KEY_MAX_CACHE_MB = "max_cache_mb" + private const val KEY_THEME_MODE = "theme_mode" + private const val KEY_ACCENT_COLOR_NAME = "accent_color_name" + private const val KEY_ANIMATED_BACKGROUNDS = "animated_backgrounds" + private const val KEY_LARGE_EMOJI = "large_emoji" + private const val KEY_LANGUAGE_MODE = "language_mode" + private const val KEY_PROXY_ENABLED = "proxy_enabled" + private const val KEY_PROXY_TYPE = "proxy_type" + private const val KEY_PROXY_HOST = "proxy_host" + private const val KEY_PROXY_PORT = "proxy_port" + private const val KEY_PROXY_SECRET = "proxy_secret" + } +} diff --git a/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/core/settings/AndroidChatSettings.kt b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/core/settings/AndroidChatSettings.kt new file mode 100644 index 00000000..e46d8413 --- /dev/null +++ b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/core/settings/AndroidChatSettings.kt @@ -0,0 +1,177 @@ +package com.seven.ikar.kotlin.core.settings + +import android.content.Context +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow + +enum class ChatListLayoutOption { + TwoLine, + ThreeLine +} + +enum class ChatSwipeLeftActionOption { + Delete, + Archive, + Mute, + Read +} + +enum class ChatDistanceUnitOption { + Automatic, + Kilometers, + Miles +} + +const val DEFAULT_CHAT_WALLPAPER_PRESET_ID = "mint_field" + +data class ChatSettingsSnapshot( + val messageTextSizeSp: Int = 18, + val bubbleCornerRadiusDp: Int = 11, + val selectedWallpaperPresetId: String = DEFAULT_CHAT_WALLPAPER_PRESET_ID, + val customWallpaperUri: String? = null, + val useCustomWallpaper: Boolean = false, + val chatListLayout: ChatListLayoutOption = ChatListLayoutOption.TwoLine, + val swipeLeftAction: ChatSwipeLeftActionOption = ChatSwipeLeftActionOption.Delete, + val autoNightThemeEnabled: Boolean = false, + val inAppBrowserEnabled: Boolean = true, + val animationsEnabled: Boolean = true, + val mediaTapToBrowseEnabled: Boolean = true, + val raiseToListenEnabled: Boolean = true, + val raiseToTalkEnabled: Boolean = false, + val pauseMusicOnRecordEnabled: Boolean = true, + val pauseMusicOnPlaybackEnabled: Boolean = false, + val directShareEnabled: Boolean = true, + val showSensitiveContentEnabled: Boolean = false, + val sendByEnterEnabled: Boolean = false, + val distanceUnit: ChatDistanceUnitOption = ChatDistanceUnitOption.Automatic +) + +class AndroidChatSettings(context: Context) { + private val preferences = context.getSharedPreferences(PREFERENCES_NAME, Context.MODE_PRIVATE) + private val _snapshot = MutableStateFlow(loadSnapshot()) + val snapshot: StateFlow = _snapshot.asStateFlow() + + fun loadSnapshot(): ChatSettingsSnapshot = + ChatSettingsSnapshot( + messageTextSizeSp = preferences.getInt(KEY_MESSAGE_TEXT_SIZE_SP, DEFAULT_MESSAGE_TEXT_SIZE_SP) + .coerceIn(14, 26), + bubbleCornerRadiusDp = preferences.getInt(KEY_BUBBLE_CORNER_RADIUS_DP, DEFAULT_BUBBLE_CORNER_RADIUS_DP) + .coerceIn(4, 24), + selectedWallpaperPresetId = preferences + .getString(KEY_SELECTED_WALLPAPER_PRESET_ID, DEFAULT_CHAT_WALLPAPER_PRESET_ID) + .orEmpty() + .ifBlank { DEFAULT_CHAT_WALLPAPER_PRESET_ID }, + customWallpaperUri = preferences.getString(KEY_CUSTOM_WALLPAPER_URI, null), + useCustomWallpaper = preferences.getBoolean(KEY_USE_CUSTOM_WALLPAPER, false), + chatListLayout = getEnum(KEY_CHAT_LIST_LAYOUT, ChatListLayoutOption.TwoLine), + swipeLeftAction = getEnum(KEY_SWIPE_LEFT_ACTION, ChatSwipeLeftActionOption.Delete), + autoNightThemeEnabled = preferences.getBoolean(KEY_AUTO_NIGHT_THEME_ENABLED, false), + inAppBrowserEnabled = preferences.getBoolean(KEY_IN_APP_BROWSER_ENABLED, true), + animationsEnabled = preferences.getBoolean(KEY_ANIMATIONS_ENABLED, true), + mediaTapToBrowseEnabled = preferences.getBoolean(KEY_MEDIA_TAP_TO_BROWSE_ENABLED, true), + raiseToListenEnabled = preferences.getBoolean(KEY_RAISE_TO_LISTEN_ENABLED, true), + raiseToTalkEnabled = preferences.getBoolean(KEY_RAISE_TO_TALK_ENABLED, false), + pauseMusicOnRecordEnabled = preferences.getBoolean(KEY_PAUSE_MUSIC_ON_RECORD_ENABLED, true), + pauseMusicOnPlaybackEnabled = preferences.getBoolean(KEY_PAUSE_MUSIC_ON_PLAYBACK_ENABLED, false), + directShareEnabled = preferences.getBoolean(KEY_DIRECT_SHARE_ENABLED, true), + showSensitiveContentEnabled = preferences.getBoolean(KEY_SHOW_SENSITIVE_CONTENT_ENABLED, false), + sendByEnterEnabled = preferences.getBoolean(KEY_SEND_BY_ENTER_ENABLED, false), + distanceUnit = getEnum(KEY_DISTANCE_UNIT, ChatDistanceUnitOption.Automatic) + ) + + fun update(transform: (ChatSettingsSnapshot) -> ChatSettingsSnapshot) { + saveSnapshot(transform(_snapshot.value)) + } + + fun saveSnapshot(snapshot: ChatSettingsSnapshot) { + preferences.edit().apply { + putInt(KEY_MESSAGE_TEXT_SIZE_SP, snapshot.messageTextSizeSp.coerceIn(14, 26)) + putInt(KEY_BUBBLE_CORNER_RADIUS_DP, snapshot.bubbleCornerRadiusDp.coerceIn(4, 24)) + putString( + KEY_SELECTED_WALLPAPER_PRESET_ID, + snapshot.selectedWallpaperPresetId.ifBlank { DEFAULT_CHAT_WALLPAPER_PRESET_ID } + ) + putString(KEY_CUSTOM_WALLPAPER_URI, snapshot.customWallpaperUri) + putBoolean(KEY_USE_CUSTOM_WALLPAPER, snapshot.useCustomWallpaper) + putString(KEY_CHAT_LIST_LAYOUT, snapshot.chatListLayout.name) + putString(KEY_SWIPE_LEFT_ACTION, snapshot.swipeLeftAction.name) + putBoolean(KEY_AUTO_NIGHT_THEME_ENABLED, snapshot.autoNightThemeEnabled) + putBoolean(KEY_IN_APP_BROWSER_ENABLED, snapshot.inAppBrowserEnabled) + putBoolean(KEY_ANIMATIONS_ENABLED, snapshot.animationsEnabled) + putBoolean(KEY_MEDIA_TAP_TO_BROWSE_ENABLED, snapshot.mediaTapToBrowseEnabled) + putBoolean(KEY_RAISE_TO_LISTEN_ENABLED, snapshot.raiseToListenEnabled) + putBoolean(KEY_RAISE_TO_TALK_ENABLED, snapshot.raiseToTalkEnabled) + putBoolean(KEY_PAUSE_MUSIC_ON_RECORD_ENABLED, snapshot.pauseMusicOnRecordEnabled) + putBoolean(KEY_PAUSE_MUSIC_ON_PLAYBACK_ENABLED, snapshot.pauseMusicOnPlaybackEnabled) + putBoolean(KEY_DIRECT_SHARE_ENABLED, snapshot.directShareEnabled) + putBoolean(KEY_SHOW_SENSITIVE_CONTENT_ENABLED, snapshot.showSensitiveContentEnabled) + putBoolean(KEY_SEND_BY_ENTER_ENABLED, snapshot.sendByEnterEnabled) + putString(KEY_DISTANCE_UNIT, snapshot.distanceUnit.name) + apply() + } + _snapshot.value = snapshot.copy( + messageTextSizeSp = snapshot.messageTextSizeSp.coerceIn(14, 26), + bubbleCornerRadiusDp = snapshot.bubbleCornerRadiusDp.coerceIn(4, 24) + ) + } + + fun reset() { + preferences.edit().apply { + remove(KEY_MESSAGE_TEXT_SIZE_SP) + remove(KEY_BUBBLE_CORNER_RADIUS_DP) + remove(KEY_SELECTED_WALLPAPER_PRESET_ID) + remove(KEY_CUSTOM_WALLPAPER_URI) + remove(KEY_USE_CUSTOM_WALLPAPER) + remove(KEY_CHAT_LIST_LAYOUT) + remove(KEY_SWIPE_LEFT_ACTION) + remove(KEY_AUTO_NIGHT_THEME_ENABLED) + remove(KEY_IN_APP_BROWSER_ENABLED) + remove(KEY_ANIMATIONS_ENABLED) + remove(KEY_MEDIA_TAP_TO_BROWSE_ENABLED) + remove(KEY_RAISE_TO_LISTEN_ENABLED) + remove(KEY_RAISE_TO_TALK_ENABLED) + remove(KEY_PAUSE_MUSIC_ON_RECORD_ENABLED) + remove(KEY_PAUSE_MUSIC_ON_PLAYBACK_ENABLED) + remove(KEY_DIRECT_SHARE_ENABLED) + remove(KEY_SHOW_SENSITIVE_CONTENT_ENABLED) + remove(KEY_SEND_BY_ENTER_ENABLED) + remove(KEY_DISTANCE_UNIT) + apply() + } + _snapshot.value = ChatSettingsSnapshot() + } + + private fun > getEnum(key: String, defaultValue: T): T { + val rawValue = preferences.getString(key, defaultValue.name).orEmpty() + @Suppress("UNCHECKED_CAST") + return defaultValue.declaringJavaClass.enumConstants + ?.firstOrNull { it.name == rawValue } as? T + ?: defaultValue + } + + companion object { + private const val PREFERENCES_NAME = "ikar_kotlin_chat_settings" + private const val DEFAULT_MESSAGE_TEXT_SIZE_SP = 18 + private const val DEFAULT_BUBBLE_CORNER_RADIUS_DP = 11 + private const val KEY_MESSAGE_TEXT_SIZE_SP = "message_text_size_sp" + private const val KEY_BUBBLE_CORNER_RADIUS_DP = "bubble_corner_radius_dp" + private const val KEY_SELECTED_WALLPAPER_PRESET_ID = "selected_wallpaper_preset_id" + private const val KEY_CUSTOM_WALLPAPER_URI = "custom_wallpaper_uri" + private const val KEY_USE_CUSTOM_WALLPAPER = "use_custom_wallpaper" + private const val KEY_CHAT_LIST_LAYOUT = "chat_list_layout" + private const val KEY_SWIPE_LEFT_ACTION = "swipe_left_action" + private const val KEY_AUTO_NIGHT_THEME_ENABLED = "auto_night_theme_enabled" + private const val KEY_IN_APP_BROWSER_ENABLED = "in_app_browser_enabled" + private const val KEY_ANIMATIONS_ENABLED = "animations_enabled" + private const val KEY_MEDIA_TAP_TO_BROWSE_ENABLED = "media_tap_to_browse_enabled" + private const val KEY_RAISE_TO_LISTEN_ENABLED = "raise_to_listen_enabled" + private const val KEY_RAISE_TO_TALK_ENABLED = "raise_to_talk_enabled" + private const val KEY_PAUSE_MUSIC_ON_RECORD_ENABLED = "pause_music_on_record_enabled" + private const val KEY_PAUSE_MUSIC_ON_PLAYBACK_ENABLED = "pause_music_on_playback_enabled" + private const val KEY_DIRECT_SHARE_ENABLED = "direct_share_enabled" + private const val KEY_SHOW_SENSITIVE_CONTENT_ENABLED = "show_sensitive_content_enabled" + private const val KEY_SEND_BY_ENTER_ENABLED = "send_by_enter_enabled" + private const val KEY_DISTANCE_UNIT = "distance_unit" + } +} diff --git a/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/core/settings/AndroidPrivacyRuntimeSettings.kt b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/core/settings/AndroidPrivacyRuntimeSettings.kt new file mode 100644 index 00000000..1c8cf946 --- /dev/null +++ b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/core/settings/AndroidPrivacyRuntimeSettings.kt @@ -0,0 +1,56 @@ +package com.seven.ikar.kotlin.core.settings + +import android.content.Context +import com.seven.ikar.kotlin.core.model.PrivacySettingsDto +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow + +data class PrivacyRuntimeSnapshot( + val passcodeLockEnabled: Boolean = false, + val showMessagePreview: Boolean = true +) + +class AndroidPrivacyRuntimeSettings(context: Context) { + private val preferences = context.getSharedPreferences(PREFERENCES_NAME, Context.MODE_PRIVATE) + private val _snapshot = MutableStateFlow(loadSnapshot()) + val snapshot: StateFlow = _snapshot.asStateFlow() + + fun loadSnapshot(): PrivacyRuntimeSnapshot = + PrivacyRuntimeSnapshot( + passcodeLockEnabled = preferences.getBoolean(KEY_PASSCODE_LOCK_ENABLED, false), + showMessagePreview = preferences.getBoolean(KEY_SHOW_MESSAGE_PREVIEW, true) + ) + + fun saveFrom(settings: PrivacySettingsDto) { + saveSnapshot( + PrivacyRuntimeSnapshot( + passcodeLockEnabled = settings.passcodeLockEnabled, + showMessagePreview = settings.showMessagePreview + ) + ) + } + + private fun saveSnapshot(snapshot: PrivacyRuntimeSnapshot) { + preferences.edit() + .putBoolean(KEY_PASSCODE_LOCK_ENABLED, snapshot.passcodeLockEnabled) + .putBoolean(KEY_SHOW_MESSAGE_PREVIEW, snapshot.showMessagePreview) + .apply() + _snapshot.value = snapshot + } + + companion object { + private const val PREFERENCES_NAME = "ikar_kotlin_privacy_runtime" + private const val KEY_PASSCODE_LOCK_ENABLED = "passcode_lock_enabled" + private const val KEY_SHOW_MESSAGE_PREVIEW = "show_message_preview" + } +} + +class AppLockState { + private val _isLocked = MutableStateFlow(false) + val isLocked: StateFlow = _isLocked.asStateFlow() + + fun setLocked(locked: Boolean) { + _isLocked.value = locked + } +} diff --git a/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/core/settings/AppLocaleManager.kt b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/core/settings/AppLocaleManager.kt new file mode 100644 index 00000000..38bb6586 --- /dev/null +++ b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/core/settings/AppLocaleManager.kt @@ -0,0 +1,28 @@ +package com.seven.ikar.kotlin.core.settings + +import android.content.Context +import android.content.res.Configuration +import android.os.LocaleList +import java.util.Locale + +object AppLocaleManager { + fun apply(context: Context, mode: AppLanguageMode) { + val locale = localeFor(mode) ?: Locale.getDefault() + Locale.setDefault(locale) + + val configuration = Configuration(context.resources.configuration) + if (mode == AppLanguageMode.System) { + configuration.setLocales(LocaleList.getDefault()) + } else { + configuration.setLocale(locale) + configuration.setLocales(LocaleList(locale)) + } + context.resources.updateConfiguration(configuration, context.resources.displayMetrics) + } + + private fun localeFor(mode: AppLanguageMode): Locale? = when (mode) { + AppLanguageMode.System -> null + AppLanguageMode.Russian -> Locale("ru", "RU") + AppLanguageMode.English -> Locale.ENGLISH + } +} diff --git a/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/core/update/ArgusAppUpdateCache.kt b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/core/update/ArgusAppUpdateCache.kt new file mode 100644 index 00000000..16365b75 --- /dev/null +++ b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/core/update/ArgusAppUpdateCache.kt @@ -0,0 +1,33 @@ +package com.seven.ikar.kotlin.core.update + +import android.content.Context +import java.io.File + +internal object ArgusAppUpdateCache { + private const val DOWNLOAD_DIR_NAME = "argus-updater" + + fun buildDownloadTargetFile(context: Context, update: AvailableIkarUpdate): File = + File(downloadDir(context), update.manifest.release.originalFileName) + + fun deleteStaleDownloads(context: Context, keepFile: File) { + val keepPath = keepFile.absolutePath + downloadDir(context).listFiles() + ?.filter { it.absolutePath != keepPath } + ?.forEach { it.deleteRecursivelyQuietly() } + } + + fun deleteAllDownloads(context: Context) { + downloadDir(context).deleteRecursivelyQuietly() + } + + private fun downloadDir(context: Context): File = + File(context.cacheDir, DOWNLOAD_DIR_NAME) + + private fun File.deleteRecursivelyQuietly() { + if (!exists()) { + return + } + + runCatching { deleteRecursively() } + } +} diff --git a/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/core/update/ArgusAppUpdateClient.kt b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/core/update/ArgusAppUpdateClient.kt new file mode 100644 index 00000000..e9bb869e --- /dev/null +++ b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/core/update/ArgusAppUpdateClient.kt @@ -0,0 +1,126 @@ +package com.seven.ikar.kotlin.core.update + +import org.json.JSONObject +import java.io.File +import java.net.HttpURLConnection +import java.net.URL +import java.security.MessageDigest + +class ArgusAppUpdateClient { + fun fetchManifest(config: ArgusAppUpdateConfig): ArgusAppManifest? { + val url = buildManifestUrl(config) + val connection = openConnection(url) + + return connection.useJsonBody { statusCode, body -> + when (statusCode) { + HttpURLConnection.HTTP_OK -> parseManifest(body) + HttpURLConnection.HTTP_NOT_FOUND -> null + else -> throw IllegalStateException("Argus returned HTTP $statusCode for $url") + } + } + } + + fun downloadRelease(update: AvailableIkarUpdate, targetFile: File): File { + targetFile.parentFile?.mkdirs() + if (targetFile.exists() && sha256(targetFile).equals(update.manifest.release.sha256, ignoreCase = true)) { + return targetFile + } + + val connection = openConnection(update.downloadUrl) + connection.requestMethod = "GET" + connection.instanceFollowRedirects = true + + try { + val statusCode = connection.responseCode + if (statusCode != HttpURLConnection.HTTP_OK) { + throw IllegalStateException("Argus download failed with HTTP $statusCode") + } + + connection.inputStream.use { input -> + targetFile.outputStream().use { output -> + input.copyTo(output) + } + } + } finally { + connection.disconnect() + } + + val actualSha = sha256(targetFile) + if (!actualSha.equals(update.manifest.release.sha256, ignoreCase = true)) { + targetFile.delete() + throw IllegalStateException( + "SHA-256 mismatch. Expected ${update.manifest.release.sha256}, got $actualSha." + ) + } + + return targetFile + } + + private fun buildManifestUrl(config: ArgusAppUpdateConfig): String { + val baseUrl = config.baseUrl.trim().trimEnd('/') + val slug = config.slug.trim() + return "$baseUrl/api/apps/$slug/manifest?platform=${config.platform}&channel=${config.channel}" + } + + private fun openConnection(url: String): HttpURLConnection = + (URL(url).openConnection() as HttpURLConnection).apply { + connectTimeout = 15_000 + readTimeout = 60_000 + setRequestProperty("Accept", "application/json") + } + + private fun parseManifest(body: String): ArgusAppManifest { + val root = JSONObject(body) + val releaseJson = root.getJSONObject("release") + return ArgusAppManifest( + slug = root.getString("slug"), + name = root.getString("name"), + summary = root.getString("summary"), + description = root.getString("description"), + androidPackageName = root.optString("androidPackageName").ifBlank { null }, + repositoryUrl = root.optString("repositoryUrl").ifBlank { null }, + homepageUrl = root.optString("homepageUrl").ifBlank { null }, + release = ArgusPublishedRelease( + id = releaseJson.getString("id"), + version = releaseJson.getString("version"), + channel = releaseJson.getString("channel"), + platform = releaseJson.getString("platform"), + packageKind = releaseJson.getString("packageKind"), + androidVersionCode = releaseJson.takeIf { it.has("androidVersionCode") && !it.isNull("androidVersionCode") } + ?.getLong("androidVersionCode"), + downloadPath = releaseJson.getString("downloadPath"), + originalFileName = releaseJson.getString("originalFileName"), + contentType = releaseJson.getString("contentType"), + packageSizeBytes = releaseJson.getLong("packageSizeBytes"), + sha256 = releaseJson.getString("sha256"), + publishedAt = releaseJson.getString("publishedAt"), + notes = releaseJson.optString("notes").ifBlank { null } + ) + ) + } + + private fun HttpURLConnection.useJsonBody(block: (statusCode: Int, body: String) -> ArgusAppManifest?): ArgusAppManifest? = + try { + val statusCode = responseCode + val stream = if (statusCode >= 400) errorStream else inputStream + val body = stream?.bufferedReader()?.use { it.readText() }.orEmpty() + block(statusCode, body) + } finally { + disconnect() + } + + private fun sha256(file: File): String { + val digest = MessageDigest.getInstance("SHA-256") + file.inputStream().use { input -> + val buffer = ByteArray(DEFAULT_BUFFER_SIZE) + while (true) { + val read = input.read(buffer) + if (read <= 0) { + break + } + digest.update(buffer, 0, read) + } + } + return digest.digest().joinToString(separator = "") { byte -> "%02x".format(byte) } + } +} diff --git a/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/core/update/ArgusAppUpdateInstaller.kt b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/core/update/ArgusAppUpdateInstaller.kt new file mode 100644 index 00000000..e827b4ba --- /dev/null +++ b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/core/update/ArgusAppUpdateInstaller.kt @@ -0,0 +1,149 @@ +package com.seven.ikar.kotlin.core.update + +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import android.content.pm.PackageInfo +import android.content.pm.PackageInstaller +import android.content.pm.PackageManager +import android.os.Build +import java.io.File + +class ArgusAppUpdateInstaller(private val context: Context) { + private val preferences = ArgusAppUpdatePreferences(context) + + fun submitInstall( + packageFile: File, + update: AvailableIkarUpdate + ): AppUpdateSubmissionResult { + val expectedPackageName = update.installedApp.packageName + val expectedVersionCode = update.manifest.release.androidVersionCode + val archiveInfo = readArchiveInfo(packageFile) + ?: return reject( + expectedPackageName, + update.manifest.release.version, + expectedVersionCode, + "Android не смог прочитать скачанный APK." + ) + + if (archiveInfo.packageName != expectedPackageName) { + return reject( + expectedPackageName, + update.manifest.release.version, + expectedVersionCode, + "APK содержит пакет ${archiveInfo.packageName}, а ожидался $expectedPackageName." + ) + } + + val archiveVersionCode = archiveInfo.versionCodeCompat() + if (expectedVersionCode != null && archiveVersionCode != expectedVersionCode) { + return reject( + expectedPackageName, + update.manifest.release.version, + expectedVersionCode, + "APK содержит versionCode $archiveVersionCode, а Argus ожидал $expectedVersionCode." + ) + } + + val installer = context.packageManager.packageInstaller + val params = PackageInstaller.SessionParams(PackageInstaller.SessionParams.MODE_FULL_INSTALL).apply { + setAppPackageName(expectedPackageName) + setSize(packageFile.length()) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + setPackageSource(PackageInstaller.PACKAGE_SOURCE_STORE) + } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + setRequireUserAction(PackageInstaller.SessionParams.USER_ACTION_REQUIRED) + } + } + + val sessionId = installer.createSession(params) + val pendingIntent = PendingIntent.getBroadcast( + context, + sessionId, + Intent(context, ArgusAppUpdateInstallerStatusReceiver::class.java).apply { + action = ACTION_PACKAGE_STATUS + putExtra(EXTRA_PACKAGE_NAME, expectedPackageName) + putExtra(EXTRA_RELEASE_VERSION, update.manifest.release.version) + if (expectedVersionCode != null) { + putExtra(EXTRA_VERSION_CODE, expectedVersionCode) + } + }, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_MUTABLE + ) + + var session: PackageInstaller.Session? = null + try { + session = installer.openSession(sessionId) + packageFile.inputStream().use { input -> + session.openWrite("base.apk", 0, packageFile.length()).use { output -> + input.copyTo(output) + session.fsync(output) + } + } + session.commit(pendingIntent.intentSender) + } catch (error: Exception) { + session?.abandon() + return reject( + expectedPackageName, + update.manifest.release.version, + expectedVersionCode, + error.message ?: "Android не смог отправить сессию установки." + ) + } finally { + session?.close() + } + + val message = "Запрос на установку обновления отправлен в Android." + preferences.saveInstallerEvent( + state = AppUpdateInstallerEventState.Submitted, + packageName = expectedPackageName, + releaseVersion = update.manifest.release.version, + versionCode = expectedVersionCode ?: archiveVersionCode, + detail = message + ) + return AppUpdateSubmissionResult.Submitted(message) + } + + private fun reject( + packageName: String, + releaseVersion: String, + versionCode: Long?, + message: String + ): AppUpdateSubmissionResult.Rejected { + preferences.saveInstallerEvent( + state = AppUpdateInstallerEventState.Failed, + packageName = packageName, + releaseVersion = releaseVersion, + versionCode = versionCode, + detail = message + ) + return AppUpdateSubmissionResult.Rejected(message) + } + + private fun readArchiveInfo(packageFile: File): PackageInfo? = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + context.packageManager.getPackageArchiveInfo( + packageFile.absolutePath, + PackageManager.PackageInfoFlags.of(0) + ) + } else { + @Suppress("DEPRECATION") + context.packageManager.getPackageArchiveInfo(packageFile.absolutePath, 0) + } + + companion object { + const val ACTION_PACKAGE_STATUS = "com.seven.ikar.kotlin.PACKAGE_STATUS" + const val EXTRA_PACKAGE_NAME = "package_name" + const val EXTRA_RELEASE_VERSION = "release_version" + const val EXTRA_VERSION_CODE = "version_code" + } +} + +private fun PackageInfo.versionCodeCompat(): Long = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + longVersionCode + } else { + @Suppress("DEPRECATION") + versionCode.toLong() + } diff --git a/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/core/update/ArgusAppUpdateInstallerStatusReceiver.kt b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/core/update/ArgusAppUpdateInstallerStatusReceiver.kt new file mode 100644 index 00000000..b8503a6b --- /dev/null +++ b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/core/update/ArgusAppUpdateInstallerStatusReceiver.kt @@ -0,0 +1,70 @@ +package com.seven.ikar.kotlin.core.update + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.pm.PackageInstaller +import android.os.Build + +class ArgusAppUpdateInstallerStatusReceiver : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + if (intent.action != ArgusAppUpdateInstaller.ACTION_PACKAGE_STATUS) { + return + } + + val preferences = ArgusAppUpdatePreferences(context) + val packageName = intent.getStringExtra(PackageInstaller.EXTRA_PACKAGE_NAME) + ?: intent.getStringExtra(ArgusAppUpdateInstaller.EXTRA_PACKAGE_NAME) + val releaseVersion = intent.getStringExtra(ArgusAppUpdateInstaller.EXTRA_RELEASE_VERSION) + val versionCode = intent.getLongExtra(ArgusAppUpdateInstaller.EXTRA_VERSION_CODE, Long.MIN_VALUE) + .takeUnless { it == Long.MIN_VALUE } + val status = intent.getIntExtra(PackageInstaller.EXTRA_STATUS, PackageInstaller.STATUS_FAILURE) + val statusMessage = intent.getStringExtra(PackageInstaller.EXTRA_STATUS_MESSAGE) + + when (status) { + PackageInstaller.STATUS_PENDING_USER_ACTION -> { + preferences.saveInstallerEvent( + state = AppUpdateInstallerEventState.ApprovalRequired, + packageName = packageName, + releaseVersion = releaseVersion, + versionCode = versionCode, + detail = statusMessage ?: "Android просит подтвердить установку обновления." + ) + + extractConfirmationIntent(intent)?.let { confirmationIntent -> + confirmationIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + context.startActivity(confirmationIntent) + } + } + + PackageInstaller.STATUS_SUCCESS -> { + preferences.saveInstallerEvent( + state = AppUpdateInstallerEventState.Installed, + packageName = packageName, + releaseVersion = releaseVersion, + versionCode = versionCode, + detail = statusMessage ?: "Обновление установлено." + ) + ArgusAppUpdateCache.deleteAllDownloads(context) + } + + else -> { + preferences.saveInstallerEvent( + state = AppUpdateInstallerEventState.Failed, + packageName = packageName, + releaseVersion = releaseVersion, + versionCode = versionCode, + detail = statusMessage ?: "Android вернул ошибку установки: $status." + ) + } + } + } + + private fun extractConfirmationIntent(intent: Intent): Intent? = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + intent.getParcelableExtra(Intent.EXTRA_INTENT, Intent::class.java) + } else { + @Suppress("DEPRECATION") + intent.getParcelableExtra(Intent.EXTRA_INTENT) + } +} diff --git a/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/core/update/ArgusAppUpdateManager.kt b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/core/update/ArgusAppUpdateManager.kt new file mode 100644 index 00000000..a26ef040 --- /dev/null +++ b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/core/update/ArgusAppUpdateManager.kt @@ -0,0 +1,101 @@ +package com.seven.ikar.kotlin.core.update + +import android.content.Context +import android.content.Intent +import android.net.Uri +import android.os.Build +import android.provider.Settings + +class ArgusAppUpdateManager( + private val context: Context, + private val client: ArgusAppUpdateClient = ArgusAppUpdateClient(), + private val installer: ArgusAppUpdateInstaller = ArgusAppUpdateInstaller(context), + private val preferences: ArgusAppUpdatePreferences = ArgusAppUpdatePreferences(context) +) { + fun canRequestPackageInstalls(): Boolean = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + context.packageManager.canRequestPackageInstalls() + } else { + true + } + + fun buildUnknownSourcesIntent(): Intent = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + Intent( + Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES, + Uri.parse("package:${context.packageName}") + ) + } else { + Intent(Settings.ACTION_SECURITY_SETTINGS) + } + + fun loadLastInstallerEvent(): AppUpdateInstallerEvent? = + preferences.loadLastInstallerEvent() + + fun buildDownloadTargetFile(update: AvailableIkarUpdate) = + ArgusAppUpdateCache.buildDownloadTargetFile(context, update) + + fun checkForUpdate( + config: ArgusAppUpdateConfig, + installedApp: InstalledIkarApp + ): AppUpdateCheckResult = + try { + val manifest = client.fetchManifest(config) + ?: return AppUpdateCheckResult.NotPublished( + "На сервере Argus нет совместимого релиза для ${config.slug}." + ) + + val manifestPackageName = manifest.androidPackageName?.trim() + if (manifestPackageName.isNullOrBlank()) { + return AppUpdateCheckResult.Incompatible( + "В карточке Argus не указан androidPackageName." + ) + } + + if (!manifestPackageName.equals(installedApp.packageName, ignoreCase = false)) { + return AppUpdateCheckResult.Incompatible( + "Argus отдаёт пакет $manifestPackageName, а текущее приложение имеет пакет ${installedApp.packageName}." + ) + } + + if (!manifest.release.packageKind.equals("apk", ignoreCase = true)) { + return AppUpdateCheckResult.Incompatible( + "Argus отдал packageKind=${manifest.release.packageKind}, а updater поддерживает только APK." + ) + } + + val remoteVersionCode = manifest.release.androidVersionCode + ?: return AppUpdateCheckResult.Incompatible( + "Argus не вернул androidVersionCode для релиза." + ) + + if (remoteVersionCode <= installedApp.versionCode) { + return AppUpdateCheckResult.UpToDate(manifest) + } + + AppUpdateCheckResult.UpdateAvailable( + AvailableIkarUpdate( + config = config, + installedApp = installedApp, + manifest = manifest + ) + ) + } catch (error: Exception) { + AppUpdateCheckResult.Failed( + reason = error.message ?: "Не удалось проверить наличие обновления в Argus.", + cause = error + ) + } + + fun downloadAndInstall(update: AvailableIkarUpdate): AppUpdateSubmissionResult = + try { + val targetFile = buildDownloadTargetFile(update) + ArgusAppUpdateCache.deleteStaleDownloads(context, targetFile) + val apkFile = client.downloadRelease(update, targetFile) + installer.submitInstall(apkFile, update) + } catch (error: Exception) { + AppUpdateSubmissionResult.Rejected( + error.message ?: "Не удалось скачать или запустить установку обновления." + ) + } +} diff --git a/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/core/update/ArgusAppUpdateModels.kt b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/core/update/ArgusAppUpdateModels.kt new file mode 100644 index 00000000..88d13028 --- /dev/null +++ b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/core/update/ArgusAppUpdateModels.kt @@ -0,0 +1,84 @@ +package com.seven.ikar.kotlin.core.update + +data class ArgusAppUpdateConfig( + val baseUrl: String, + val slug: String, + val platform: String = "android", + val channel: String = "stable" +) + +data class InstalledIkarApp( + val packageName: String, + val versionCode: Long, + val versionName: String? +) + +data class ArgusPublishedRelease( + val id: String, + val version: String, + val channel: String, + val platform: String, + val packageKind: String, + val androidVersionCode: Long?, + val downloadPath: String, + val originalFileName: String, + val contentType: String, + val packageSizeBytes: Long, + val sha256: String, + val publishedAt: String, + val notes: String? +) + +data class ArgusAppManifest( + val slug: String, + val name: String, + val summary: String, + val description: String, + val androidPackageName: String?, + val repositoryUrl: String?, + val homepageUrl: String?, + val release: ArgusPublishedRelease +) + +data class AvailableIkarUpdate( + val config: ArgusAppUpdateConfig, + val installedApp: InstalledIkarApp, + val manifest: ArgusAppManifest +) { + val downloadUrl: String + get() = config.baseUrl.trimEnd('/') + manifest.release.downloadPath +} + +sealed interface AppUpdateCheckResult { + data class NotPublished(val reason: String) : AppUpdateCheckResult + + data class UpToDate(val manifest: ArgusAppManifest?) : AppUpdateCheckResult + + data class UpdateAvailable(val update: AvailableIkarUpdate) : AppUpdateCheckResult + + data class Incompatible(val reason: String) : AppUpdateCheckResult + + data class Failed(val reason: String, val cause: Throwable? = null) : AppUpdateCheckResult +} + +sealed interface AppUpdateSubmissionResult { + data class Submitted(val message: String) : AppUpdateSubmissionResult + + data class Rejected(val message: String) : AppUpdateSubmissionResult +} + +data class AppUpdateInstallerEvent( + val state: String, + val packageName: String?, + val releaseVersion: String?, + val versionCode: Long?, + val detail: String?, + val updatedAtEpochMillis: Long +) + +object AppUpdateInstallerEventState { + const val Submitted = "submitted" + const val ApprovalRequired = "approval_required" + const val Installed = "installed" + const val Failed = "failed" +} diff --git a/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/core/update/ArgusAppUpdatePreferences.kt b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/core/update/ArgusAppUpdatePreferences.kt new file mode 100644 index 00000000..4f78daca --- /dev/null +++ b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/core/update/ArgusAppUpdatePreferences.kt @@ -0,0 +1,57 @@ +package com.seven.ikar.kotlin.core.update + +import android.content.Context + +class ArgusAppUpdatePreferences(context: Context) { + private val preferences = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + + fun loadLastInstallerEvent(): AppUpdateInstallerEvent? { + val state = preferences.getString(KEY_INSTALLER_STATE, null) ?: return null + return AppUpdateInstallerEvent( + state = state, + packageName = preferences.getString(KEY_PACKAGE_NAME, null), + releaseVersion = preferences.getString(KEY_RELEASE_VERSION, null), + versionCode = if (preferences.contains(KEY_VERSION_CODE)) { + preferences.getLong(KEY_VERSION_CODE, 0L) + } else { + null + }, + detail = preferences.getString(KEY_DETAIL, null), + updatedAtEpochMillis = preferences.getLong(KEY_UPDATED_AT, 0L) + ) + } + + fun saveInstallerEvent( + state: String, + packageName: String?, + releaseVersion: String?, + versionCode: Long?, + detail: String?, + updatedAtEpochMillis: Long = System.currentTimeMillis() + ) { + preferences.edit() + .putString(KEY_INSTALLER_STATE, state) + .putString(KEY_PACKAGE_NAME, packageName) + .putString(KEY_RELEASE_VERSION, releaseVersion) + .putString(KEY_DETAIL, detail) + .putLong(KEY_UPDATED_AT, updatedAtEpochMillis) + .apply { + if (versionCode == null) { + remove(KEY_VERSION_CODE) + } else { + putLong(KEY_VERSION_CODE, versionCode) + } + } + .apply() + } + + private companion object { + const val PREFS_NAME = "ikar-argus-updater" + const val KEY_INSTALLER_STATE = "installer_state" + const val KEY_PACKAGE_NAME = "package_name" + const val KEY_RELEASE_VERSION = "release_version" + const val KEY_VERSION_CODE = "version_code" + const val KEY_DETAIL = "detail" + const val KEY_UPDATED_AT = "updated_at" + } +} diff --git a/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/archive/AndroidArchiveFileService.kt b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/archive/AndroidArchiveFileService.kt new file mode 100644 index 00000000..586ebf6c --- /dev/null +++ b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/archive/AndroidArchiveFileService.kt @@ -0,0 +1,569 @@ +package com.seven.ikar.kotlin.feature.archive + +import android.content.Context +import android.content.ContentUris +import android.content.ContentValues +import android.net.Uri +import android.os.Build +import android.os.Environment +import android.provider.MediaStore +import android.provider.OpenableColumns +import android.webkit.MimeTypeMap +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ensureActive +import kotlinx.coroutines.withContext +import java.io.File +import java.io.IOException +import java.io.InputStream +import java.text.Normalizer +import java.time.LocalDateTime +import java.time.format.DateTimeFormatter +import java.util.zip.ZipInputStream +import kotlin.coroutines.coroutineContext + +data class ArchiveEntryPreview( + val name: String, + val isDirectory: Boolean, + val sizeBytes: Long? +) + +data class ArchiveInspectionResult( + val totalEntries: Int, + val totalFiles: Int, + val totalDirectories: Int, + val knownUncompressedBytes: Long, + val skippedUnsafeEntries: Int, + val previewEntries: List +) + +data class ArchiveExtractionOptions( + val keepFolders: Boolean = true, + val overwriteExisting: Boolean = false +) + +data class ArchiveExtractionProgress( + val processedEntries: Int, + val totalEntries: Int, + val extractedFiles: Int, + val writtenBytes: Long, + val currentEntry: String? +) + +data class ArchiveExtractionResult( + val outputDirectoryPath: String, + val processedEntries: Int, + val extractedFiles: Int, + val createdDirectories: Int, + val writtenBytes: Long, + val skippedUnsafeEntries: Int +) + +class AndroidArchiveFileService( + private val context: Context +) { + private val maxArchiveImportBytes = 512L * 1024L * 1024L + private val archiveImportsDirectory: File by lazy { + File(context.cacheDir, "archive_imports").apply { mkdirs() } + } + + suspend fun importFromUri(uri: Uri): PendingArchiveFile = withContext(Dispatchers.IO) { + val resolver = context.contentResolver + val contentType = resolver.getType(uri) ?: "application/octet-stream" + val fileName = queryDisplayName(uri)?.takeIf { it.isNotBlank() } + ?: uri.lastPathSegment?.substringAfterLast('/') + ?: "archive.zip" + if (!isSupportedZip(fileName, contentType)) { + throw IOException("Поддерживается только ZIP-архив. Этот файл не похож на ZIP.") + } + + val declaredSize = queryFileSize(uri) + if (declaredSize != null && declaredSize > maxArchiveImportBytes) { + throw IOException("Архив слишком большой: максимум 512 МБ.") + } + + val target = File( + archiveImportsDirectory, + "open_${System.currentTimeMillis()}_${sanitizeFileName(fileName)}" + ) + val copiedBytes = try { + resolver.openInputStream(uri)?.use { input -> copyToFileWithLimit(input, target) } + ?: throw IOException("Android не дал доступ к выбранному архиву.") + } catch (error: Exception) { + target.delete() + throw error + } + + PendingArchiveFile( + fileName = fileName, + contentType = contentType, + fileSizeBytes = copiedBytes, + localPath = target.absolutePath + ) + } + + suspend fun inspectZip(archive: PendingArchiveFile): ArchiveInspectionResult = withContext(Dispatchers.IO) { + requireZipFile(archive).inputStream().use { input -> + ZipInputStream(input).use { zip -> + var totalEntries = 0 + var totalFiles = 0 + var totalDirectories = 0 + var knownUncompressedBytes = 0L + var skippedUnsafeEntries = 0 + val previewEntries = mutableListOf() + + while (true) { + coroutineContext.ensureActive() + val entry = zip.nextEntry ?: break + totalEntries += 1 + val safeName = safeRelativePath(entry.name) + if (safeName == null) { + skippedUnsafeEntries += 1 + zip.closeEntry() + continue + } + + if (entry.isDirectory) { + totalDirectories += 1 + } else { + totalFiles += 1 + if (entry.size > 0) { + knownUncompressedBytes += entry.size + } + } + + if (previewEntries.size < MaxPreviewEntries) { + previewEntries += ArchiveEntryPreview( + name = safeName, + isDirectory = entry.isDirectory, + sizeBytes = entry.size.takeIf { it >= 0L } + ) + } + zip.closeEntry() + } + + ArchiveInspectionResult( + totalEntries = totalEntries, + totalFiles = totalFiles, + totalDirectories = totalDirectories, + knownUncompressedBytes = knownUncompressedBytes, + skippedUnsafeEntries = skippedUnsafeEntries, + previewEntries = previewEntries + ) + } + } + } + + suspend fun extractZip( + archive: PendingArchiveFile, + inspection: ArchiveInspectionResult?, + options: ArchiveExtractionOptions, + onProgress: (ArchiveExtractionProgress) -> Unit + ): ArchiveExtractionResult = withContext(Dispatchers.IO) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + return@withContext extractZipToDownloads(archive, inspection, options, onProgress) + } + + extractZipToAppFiles(archive, inspection, options, onProgress) + } + + private suspend fun extractZipToAppFiles( + archive: PendingArchiveFile, + inspection: ArchiveInspectionResult?, + options: ArchiveExtractionOptions, + onProgress: (ArchiveExtractionProgress) -> Unit + ): ArchiveExtractionResult { + val archiveFile = requireZipFile(archive) + val outputDirectory = createOutputDirectory(archive.fileName) + var processedEntries = 0 + var extractedFiles = 0 + var createdDirectories = 0 + var writtenBytes = 0L + var skippedUnsafeEntries = 0 + val buffer = ByteArray(DEFAULT_BUFFER_SIZE) + + archiveFile.inputStream().use { input -> + ZipInputStream(input).use { zip -> + while (true) { + coroutineContext.ensureActive() + val entry = zip.nextEntry ?: break + processedEntries += 1 + val safeName = safeRelativePath(entry.name) + if (safeName == null) { + skippedUnsafeEntries += 1 + zip.closeEntry() + continue + } + + onProgress( + ArchiveExtractionProgress( + processedEntries = processedEntries, + totalEntries = inspection?.totalEntries ?: 0, + extractedFiles = extractedFiles, + writtenBytes = writtenBytes, + currentEntry = safeName + ) + ) + + val target = resolveTargetFile(outputDirectory, safeName, entry.isDirectory, options) + if (entry.isDirectory) { + if (!target.exists() && target.mkdirs()) { + createdDirectories += 1 + } + zip.closeEntry() + continue + } + + target.parentFile?.let { parent -> + if (!parent.exists() && parent.mkdirs()) { + createdDirectories += 1 + } + } + + val writableTarget = if (options.overwriteExisting) target else uniqueFile(target) + writableTarget.outputStream().use { output -> + while (true) { + coroutineContext.ensureActive() + val read = zip.read(buffer) + if (read < 0) { + break + } + output.write(buffer, 0, read) + writtenBytes += read + } + } + extractedFiles += 1 + zip.closeEntry() + } + } + } + + return ArchiveExtractionResult( + outputDirectoryPath = outputDirectory.absolutePath, + processedEntries = processedEntries, + extractedFiles = extractedFiles, + createdDirectories = createdDirectories, + writtenBytes = writtenBytes, + skippedUnsafeEntries = skippedUnsafeEntries + ) + } + + @androidx.annotation.RequiresApi(Build.VERSION_CODES.Q) + private suspend fun extractZipToDownloads( + archive: PendingArchiveFile, + inspection: ArchiveInspectionResult?, + options: ArchiveExtractionOptions, + onProgress: (ArchiveExtractionProgress) -> Unit + ): ArchiveExtractionResult { + val archiveFile = requireZipFile(archive) + val outputRoot = createDownloadsRelativeRoot(archive.fileName) + val collection = MediaStore.Downloads.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY) + val resolver = context.contentResolver + var processedEntries = 0 + var extractedFiles = 0 + var createdDirectories = 0 + var writtenBytes = 0L + var skippedUnsafeEntries = 0 + val buffer = ByteArray(DEFAULT_BUFFER_SIZE) + + archiveFile.inputStream().use { input -> + ZipInputStream(input).use { zip -> + while (true) { + coroutineContext.ensureActive() + val entry = zip.nextEntry ?: break + processedEntries += 1 + val safeName = safeRelativePath(entry.name) + if (safeName == null) { + skippedUnsafeEntries += 1 + zip.closeEntry() + continue + } + + onProgress( + ArchiveExtractionProgress( + processedEntries = processedEntries, + totalEntries = inspection?.totalEntries ?: 0, + extractedFiles = extractedFiles, + writtenBytes = writtenBytes, + currentEntry = safeName + ) + ) + + if (entry.isDirectory) { + createdDirectories += 1 + zip.closeEntry() + continue + } + + val relativeFilePath = if (options.keepFolders) { + safeName + } else { + safeName.substringAfterLast('/') + } + val displayName = relativeFilePath.substringAfterLast('/').ifBlank { + "file_${processedEntries}" + } + val parentPath = relativeFilePath.substringBeforeLast('/', missingDelimiterValue = "") + val relativePath = buildDownloadsRelativePath(outputRoot, parentPath) + if (options.overwriteExisting) { + deleteExistingDownload(collection, relativePath, displayName) + } + val finalDisplayName = if (options.overwriteExisting) { + displayName + } else { + uniqueDownloadDisplayName(collection, relativePath, displayName) + } + + val values = ContentValues().apply { + put(MediaStore.Downloads.DISPLAY_NAME, finalDisplayName) + put(MediaStore.Downloads.MIME_TYPE, guessMimeType(finalDisplayName)) + put(MediaStore.Downloads.RELATIVE_PATH, relativePath) + put(MediaStore.Downloads.IS_PENDING, 1) + } + val uri = resolver.insert(collection, values) + ?: throw IOException("Android не дал создать файл в Downloads.") + + try { + resolver.openOutputStream(uri)?.use { output -> + while (true) { + coroutineContext.ensureActive() + val read = zip.read(buffer) + if (read < 0) { + break + } + output.write(buffer, 0, read) + writtenBytes += read + } + } ?: throw IOException("Android не дал записать файл в Downloads.") + + val publishValues = ContentValues().apply { + put(MediaStore.Downloads.IS_PENDING, 0) + } + resolver.update(uri, publishValues, null, null) + } catch (error: Exception) { + resolver.delete(uri, null, null) + throw error + } + + extractedFiles += 1 + zip.closeEntry() + } + } + } + + return ArchiveExtractionResult( + outputDirectoryPath = outputRoot, + processedEntries = processedEntries, + extractedFiles = extractedFiles, + createdDirectories = createdDirectories, + writtenBytes = writtenBytes, + skippedUnsafeEntries = skippedUnsafeEntries + ) + } + + private fun requireZipFile(archive: PendingArchiveFile): File { + if (!isSupportedZip(archive.fileName, archive.contentType)) { + throw IOException("Поддерживается только ZIP-архив.") + } + val file = File(archive.localPath) + if (!file.exists() || !file.isFile) { + throw IOException("Файл архива не найден.") + } + return file + } + + private fun createOutputDirectory(fileName: String): File { + val baseDirectory = context.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS) + ?: File(context.filesDir, "downloads") + val archiveName = sanitizeFileName(fileName.substringBeforeLast('.', fileName)).ifBlank { "archive" } + val timestamp = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyyMMdd_HHmmss")) + return File(baseDirectory, "Ikar/Archives/${archiveName}_$timestamp").apply { mkdirs() } + } + + private fun createDownloadsRelativeRoot(fileName: String): String { + val archiveName = sanitizeFileName(fileName.substringBeforeLast('.', fileName)).ifBlank { "archive" } + val timestamp = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyyMMdd_HHmmss")) + return "${Environment.DIRECTORY_DOWNLOADS}/Ikar/Archives/${archiveName}_$timestamp" + } + + private fun buildDownloadsRelativePath(outputRoot: String, parentPath: String): String { + val normalizedParent = parentPath.trim('/').takeIf { it.isNotBlank() } + val path = if (normalizedParent == null) outputRoot else "$outputRoot/$normalizedParent" + return path.trim('/') + "/" + } + + private fun resolveTargetFile( + outputDirectory: File, + relativePath: String, + isDirectory: Boolean, + options: ArchiveExtractionOptions + ): File { + val path = if (options.keepFolders) { + relativePath + } else { + relativePath.trimEnd('/').substringAfterLast('/') + } + val target = File(outputDirectory, path) + val canonicalOutput = outputDirectory.canonicalFile + val canonicalTarget = if (isDirectory) { + target.canonicalFile + } else { + target.parentFile?.canonicalFile ?: outputDirectory.canonicalFile + } + if (canonicalTarget != canonicalOutput && !canonicalTarget.path.startsWith(canonicalOutput.path + File.separator)) { + throw IOException("Архив пытается записать файл вне папки распаковки.") + } + return target + } + + private fun safeRelativePath(name: String): String? { + val normalized = name.replace('\\', '/').trim().trimStart('/') + if (normalized.isBlank()) { + return null + } + val segments = normalized.split('/').filter { it.isNotBlank() } + if (segments.any { it == "." || it == ".." || it.contains(':') }) { + return null + } + return segments.joinToString("/") + } + + private fun uniqueFile(target: File): File { + if (!target.exists()) { + return target + } + val parent = target.parentFile ?: return target + val baseName = target.name.substringBeforeLast('.', target.name) + val extension = target.name.substringAfterLast('.', missingDelimiterValue = "") + .takeIf { it != target.name } + ?.let { ".$it" } + .orEmpty() + var index = 1 + while (true) { + val candidate = File(parent, "${baseName}_$index$extension") + if (!candidate.exists()) { + return candidate + } + index += 1 + } + } + + @androidx.annotation.RequiresApi(Build.VERSION_CODES.Q) + private fun uniqueDownloadDisplayName(collection: Uri, relativePath: String, displayName: String): String { + if (!downloadExists(collection, relativePath, displayName)) { + return displayName + } + + val baseName = displayName.substringBeforeLast('.', displayName) + val extension = displayName.substringAfterLast('.', missingDelimiterValue = "") + .takeIf { it != displayName } + ?.let { ".$it" } + .orEmpty() + var index = 1 + while (true) { + val candidate = "${baseName}_$index$extension" + if (!downloadExists(collection, relativePath, candidate)) { + return candidate + } + index += 1 + } + } + + @androidx.annotation.RequiresApi(Build.VERSION_CODES.Q) + private fun downloadExists(collection: Uri, relativePath: String, displayName: String): Boolean = + queryDownloadIds(collection, relativePath, displayName).isNotEmpty() + + @androidx.annotation.RequiresApi(Build.VERSION_CODES.Q) + private fun deleteExistingDownload(collection: Uri, relativePath: String, displayName: String) { + queryDownloadIds(collection, relativePath, displayName).forEach { id -> + context.contentResolver.delete(ContentUris.withAppendedId(collection, id), null, null) + } + } + + @androidx.annotation.RequiresApi(Build.VERSION_CODES.Q) + private fun queryDownloadIds(collection: Uri, relativePath: String, displayName: String): List { + val ids = mutableListOf() + context.contentResolver.query( + collection, + arrayOf(MediaStore.MediaColumns._ID), + "${MediaStore.Downloads.DISPLAY_NAME} = ? AND ${MediaStore.Downloads.RELATIVE_PATH} = ?", + arrayOf(displayName, relativePath), + null + )?.use { cursor -> + val idIndex = cursor.getColumnIndex(MediaStore.MediaColumns._ID) + while (idIndex >= 0 && cursor.moveToNext()) { + ids += cursor.getLong(idIndex) + } + } + return ids + } + + private fun queryDisplayName(uri: Uri): String? { + val resolver = context.contentResolver + resolver.query(uri, arrayOf(OpenableColumns.DISPLAY_NAME), null, null, null)?.use { cursor -> + val nameIndex = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME) + if (nameIndex >= 0 && cursor.moveToFirst() && !cursor.isNull(nameIndex)) { + return cursor.getString(nameIndex) + } + } + return null + } + + private fun queryFileSize(uri: Uri): Long? { + val resolver = context.contentResolver + resolver.query(uri, arrayOf(OpenableColumns.SIZE), null, null, null)?.use { cursor -> + val sizeIndex = cursor.getColumnIndex(OpenableColumns.SIZE) + if (sizeIndex >= 0 && cursor.moveToFirst() && !cursor.isNull(sizeIndex)) { + return cursor.getLong(sizeIndex).takeIf { it >= 0L } + } + } + return null + } + + private fun copyToFileWithLimit(input: InputStream, target: File): Long { + val buffer = ByteArray(DEFAULT_BUFFER_SIZE) + var totalBytes = 0L + target.outputStream().use { output -> + while (true) { + val read = input.read(buffer) + if (read < 0) { + return totalBytes + } + totalBytes += read + if (totalBytes > maxArchiveImportBytes) { + throw IOException("Архив слишком большой: максимум 512 МБ.") + } + output.write(buffer, 0, read) + } + } + } + + private fun sanitizeFileName(value: String): String { + val normalized = Normalizer.normalize(value.trim(), Normalizer.Form.NFKD) + return normalized + .replace(Regex("[^A-Za-z0-9._-]+"), "_") + .trim('_', '.', '-') + .ifBlank { "archive" } + } + + private fun guessMimeType(fileName: String): String { + val extension = fileName.substringAfterLast('.', missingDelimiterValue = "") + .takeIf { it.isNotBlank() } + ?.lowercase() + return extension + ?.let(MimeTypeMap.getSingleton()::getMimeTypeFromExtension) + ?: "application/octet-stream" + } + + companion object { + private const val MaxPreviewEntries = 30 + + fun isSupportedZip(fileName: String?, contentType: String?): Boolean { + val normalizedName = fileName.orEmpty().lowercase() + val normalizedType = contentType.orEmpty().lowercase() + return normalizedName.endsWith(".zip") || + normalizedType == "application/zip" || + normalizedType == "application/x-zip" || + normalizedType == "application/x-zip-compressed" + } + } +} diff --git a/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/archive/ArchiveExtractorScreen.kt b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/archive/ArchiveExtractorScreen.kt new file mode 100644 index 00000000..a67f6e08 --- /dev/null +++ b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/archive/ArchiveExtractorScreen.kt @@ -0,0 +1,464 @@ +package com.seven.ikar.kotlin.feature.archive + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.verticalScroll +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.WindowInsetsSides +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.only +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.safeDrawing +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.material3.Button +import androidx.compose.material3.Checkbox +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.RectangleShape +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.seven.ikar.kotlin.ui.IkarScreenSurface +import com.seven.ikar.kotlin.ui.IkarSimpleHeader +import com.seven.ikar.kotlin.ui.theme.TelegramBorder +import com.seven.ikar.kotlin.ui.theme.TelegramInkMuted + +@Composable +fun ArchiveExtractorScreen( + state: ArchiveExtractorUiState, + onBackClick: () -> Unit, + onKeepFoldersChanged: (Boolean) -> Unit, + onOverwriteExistingChanged: (Boolean) -> Unit, + onExtractClick: () -> Unit, + onCancelClick: () -> Unit +) { + IkarScreenSurface(modifier = Modifier.fillMaxSize()) { + Column( + modifier = Modifier + .fillMaxSize() + .windowInsetsPadding(WindowInsets.safeDrawing.only(WindowInsetsSides.Top)) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(start = 18.dp, top = 16.dp, end = 18.dp, bottom = 12.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + IkarSimpleHeader( + title = "Архив", + onBackClick = onBackClick + ) + } + + Column( + modifier = Modifier + .weight(1f) + .verticalScroll(rememberScrollState()) + .padding(horizontal = 18.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + state.openError?.let { error -> + ArchiveErrorPanel( + title = "Не удалось открыть архив", + message = error.message, + fileName = error.fileName + ) + } + + state.archive?.let { archive -> + ArchivePanel { + Text( + text = archive.fileName, + style = MaterialTheme.typography.headlineMedium, + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) + Text( + text = "${formatArchiveBytes(archive.fileSizeBytes)} · ZIP-архив", + style = MaterialTheme.typography.bodyMedium, + color = TelegramInkMuted + ) + } + + ArchivePanel { + Text( + text = "Что будет сделано", + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.SemiBold + ) + Spacer(modifier = Modifier.height(6.dp)) + Text( + text = "Икар распакует файлы в отдельную папку приложения. Опасные пути вида ../ будут пропущены, чтобы архив не смог записать файлы вне папки распаковки.", + style = MaterialTheme.typography.bodyMedium, + color = TelegramInkMuted + ) + } + } + + if (state.isInspecting) { + ArchivePanel { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + CircularProgressIndicator(modifier = Modifier.size(22.dp), strokeWidth = 2.dp) + Text( + text = "Читаю список файлов в архиве...", + style = MaterialTheme.typography.bodyMedium + ) + } + } + } + + state.inspection?.let { inspection -> + ArchivePanel { + Text( + text = "Состав архива", + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.SemiBold + ) + Spacer(modifier = Modifier.height(10.dp)) + ArchiveStatRow("Файлы", inspection.totalFiles.toString()) + ArchiveStatRow("Папки", inspection.totalDirectories.toString()) + ArchiveStatRow("Общий размер", formatArchiveBytes(inspection.knownUncompressedBytes)) + if (inspection.skippedUnsafeEntries > 0) { + ArchiveStatRow("Будет пропущено небезопасных путей", inspection.skippedUnsafeEntries.toString()) + } + } + + ArchivePanel(padding = PaddingValues(0.dp)) { + Column(modifier = Modifier.padding(horizontal = 16.dp, vertical = 14.dp)) { + Text( + text = "Первые файлы", + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.SemiBold + ) + Text( + text = "Показан предварительный список, сама распаковка начнётся только после нажатия кнопки.", + style = MaterialTheme.typography.bodySmall, + color = TelegramInkMuted + ) + } + + if (inspection.previewEntries.isEmpty()) { + Text( + text = "В архиве нет файлов для распаковки.", + modifier = Modifier.padding(horizontal = 16.dp, vertical = 14.dp), + style = MaterialTheme.typography.bodyMedium, + color = TelegramInkMuted + ) + } else { + inspection.previewEntries.forEach { entry -> + ArchiveEntryRow(entry) + } + } + } + + ArchivePanel(padding = PaddingValues(0.dp)) { + ArchiveOptionRow( + title = "Сохранить папки из архива", + subtitle = "Если выключить, все файлы будут сложены в одну папку.", + checked = state.options.keepFolders, + enabled = !state.isExtracting, + onCheckedChange = onKeepFoldersChanged + ) + ArchiveDivider() + ArchiveOptionRow( + title = "Заменять совпадающие файлы", + subtitle = "Если выключить, Икар добавит номер к имени файла и ничего не перезапишет.", + checked = state.options.overwriteExisting, + enabled = !state.isExtracting, + onCheckedChange = onOverwriteExistingChanged + ) + } + } + + state.progress?.let { progress -> + ArchivePanel { + Text( + text = "Распаковка выполняется", + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.SemiBold + ) + Spacer(modifier = Modifier.height(10.dp)) + LinearProgressIndicator(modifier = Modifier.fillMaxWidth()) + Spacer(modifier = Modifier.height(10.dp)) + Text( + text = buildProgressText(progress), + style = MaterialTheme.typography.bodyMedium, + color = TelegramInkMuted + ) + progress.currentEntry?.let { currentEntry -> + Text( + text = currentEntry, + style = MaterialTheme.typography.bodySmall, + color = TelegramInkMuted, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + } + } + + state.result?.let { result -> + ArchivePanel { + Text( + text = "Готово", + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.primary, + fontWeight = FontWeight.SemiBold + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = "Распаковано файлов: ${result.extractedFiles}. Записано: ${formatArchiveBytes(result.writtenBytes)}.", + style = MaterialTheme.typography.bodyMedium + ) + if (result.skippedUnsafeEntries > 0) { + Text( + text = "Пропущено небезопасных путей: ${result.skippedUnsafeEntries}.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.error + ) + } + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = result.outputDirectoryPath, + style = MaterialTheme.typography.bodySmall, + color = TelegramInkMuted + ) + } + } + + state.errorMessage?.let { message -> + ArchiveErrorPanel( + title = "Ошибка", + message = message, + fileName = state.archive?.fileName + ) + } + + Spacer(modifier = Modifier.height(8.dp)) + } + + ArchiveActionBar( + state = state, + onExtractClick = onExtractClick, + onCancelClick = onCancelClick + ) + } + } +} + +@Composable +private fun ArchiveActionBar( + state: ArchiveExtractorUiState, + onExtractClick: () -> Unit, + onCancelClick: () -> Unit +) { + Surface( + color = MaterialTheme.colorScheme.surface, + shape = RectangleShape, + shadowElevation = 8.dp + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .navigationBarsPadding() + .padding(horizontal = 18.dp, vertical = 12.dp), + verticalArrangement = Arrangement.spacedBy(10.dp) + ) { + if (state.isExtracting) { + OutlinedButton( + onClick = onCancelClick, + modifier = Modifier.fillMaxWidth() + ) { + Text("Отменить распаковку") + } + } else { + Button( + onClick = onExtractClick, + modifier = Modifier.fillMaxWidth(), + enabled = state.archive != null && state.inspection != null && state.inspection.totalFiles > 0 + ) { + Text("Распаковать ZIP") + } + } + } + } +} + +@Composable +private fun ArchivePanel( + modifier: Modifier = Modifier, + padding: PaddingValues = PaddingValues(16.dp), + content: @Composable ColumnScope.() -> Unit +) { + Surface( + modifier = modifier + .fillMaxWidth() + .border(1.dp, TelegramBorder, RectangleShape), + shape = RectangleShape, + color = MaterialTheme.colorScheme.surface, + shadowElevation = 0.dp + ) { + Column( + modifier = Modifier.padding(padding), + verticalArrangement = Arrangement.spacedBy(0.dp), + content = content + ) + } +} + +@Composable +private fun ArchiveErrorPanel( + title: String, + message: String, + fileName: String? +) { + ArchivePanel { + Text( + text = title, + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.error, + fontWeight = FontWeight.SemiBold + ) + fileName?.let { + Text( + text = it, + style = MaterialTheme.typography.bodyMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + Spacer(modifier = Modifier.height(6.dp)) + Text( + text = message, + style = MaterialTheme.typography.bodyMedium, + color = TelegramInkMuted + ) + } +} + +@Composable +private fun ArchiveStatRow( + title: String, + value: String +) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 4.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = title, + modifier = Modifier.weight(1f), + style = MaterialTheme.typography.bodyMedium, + color = TelegramInkMuted + ) + Text( + text = value, + style = MaterialTheme.typography.titleMedium + ) + } +} + +@Composable +private fun ArchiveEntryRow(entry: ArchiveEntryPreview) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 10.dp), + verticalArrangement = Arrangement.spacedBy(3.dp) + ) { + Text( + text = if (entry.isDirectory) "${entry.name}/" else entry.name, + style = MaterialTheme.typography.bodyMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + Text( + text = if (entry.isDirectory) "папка" else formatArchiveBytes(entry.sizeBytes), + style = MaterialTheme.typography.bodySmall, + color = TelegramInkMuted + ) + } + ArchiveDivider() +} + +@Composable +private fun ArchiveOptionRow( + title: String, + subtitle: String, + checked: Boolean, + enabled: Boolean, + onCheckedChange: (Boolean) -> Unit +) { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable(enabled = enabled) { onCheckedChange(!checked) } + .padding(horizontal = 12.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + Checkbox( + checked = checked, + enabled = enabled, + onCheckedChange = onCheckedChange + ) + Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(3.dp)) { + Text( + text = title, + style = MaterialTheme.typography.titleMedium + ) + Text( + text = subtitle, + style = MaterialTheme.typography.bodySmall, + color = TelegramInkMuted + ) + } + } +} + +@Composable +private fun ArchiveDivider() { + Box( + modifier = Modifier + .fillMaxWidth() + .height(1.dp) + .background(TelegramBorder) + ) +} + +private fun buildProgressText(progress: ArchiveExtractionProgress): String { + val total = progress.totalEntries.takeIf { it > 0 } + val entryPart = if (total != null) { + "${progress.processedEntries} из $total" + } else { + progress.processedEntries.toString() + } + return "Обработано записей: $entryPart. Файлов: ${progress.extractedFiles}. Записано: ${formatArchiveBytes(progress.writtenBytes)}." +} diff --git a/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/archive/ArchiveExtractorViewModel.kt b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/archive/ArchiveExtractorViewModel.kt new file mode 100644 index 00000000..d066f56b --- /dev/null +++ b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/archive/ArchiveExtractorViewModel.kt @@ -0,0 +1,172 @@ +package com.seven.ikar.kotlin.feature.archive + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.seven.ikar.kotlin.app.AppContainer +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.launch +import java.text.DecimalFormat + +data class ArchiveExtractorUiState( + val archive: PendingArchiveFile? = null, + val openError: PendingArchiveOpenError? = null, + val inspection: ArchiveInspectionResult? = null, + val options: ArchiveExtractionOptions = ArchiveExtractionOptions(), + val isInspecting: Boolean = false, + val isExtracting: Boolean = false, + val progress: ArchiveExtractionProgress? = null, + val result: ArchiveExtractionResult? = null, + val errorMessage: String? = null +) { + val hasArchive: Boolean + get() = archive != null +} + +class ArchiveExtractorViewModel( + private val container: AppContainer +) : ViewModel() { + private val _uiState = MutableStateFlow(ArchiveExtractorUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + private var extractionJob: Job? = null + + init { + viewModelScope.launch { + container.incomingArchiveStore.pendingArchive.collectLatest { pending -> + when (pending) { + is PendingArchiveFile -> loadArchive(pending) + is PendingArchiveOpenError -> { + extractionJob?.cancel() + _uiState.value = ArchiveExtractorUiState(openError = pending) + } + null -> { + extractionJob?.cancel() + _uiState.value = ArchiveExtractorUiState() + } + } + } + } + } + + fun setKeepFolders(enabled: Boolean) { + val current = _uiState.value + _uiState.value = current.copy( + options = current.options.copy(keepFolders = enabled), + result = null, + errorMessage = null + ) + } + + fun setOverwriteExisting(enabled: Boolean) { + val current = _uiState.value + _uiState.value = current.copy( + options = current.options.copy(overwriteExisting = enabled), + result = null, + errorMessage = null + ) + } + + fun extract() { + val archive = _uiState.value.archive ?: return + if (_uiState.value.isExtracting) { + return + } + + extractionJob = viewModelScope.launch { + _uiState.value = _uiState.value.copy( + isExtracting = true, + progress = ArchiveExtractionProgress( + processedEntries = 0, + totalEntries = _uiState.value.inspection?.totalEntries ?: 0, + extractedFiles = 0, + writtenBytes = 0, + currentEntry = null + ), + result = null, + errorMessage = null + ) + runCatching { + container.archiveFileService.extractZip( + archive = archive, + inspection = _uiState.value.inspection, + options = _uiState.value.options + ) { progress -> + _uiState.value = _uiState.value.copy(progress = progress) + } + }.onSuccess { result -> + _uiState.value = _uiState.value.copy( + isExtracting = false, + progress = null, + result = result, + errorMessage = null + ) + }.onFailure { error -> + _uiState.value = _uiState.value.copy( + isExtracting = false, + progress = null, + result = null, + errorMessage = error.message ?: "Не удалось распаковать архив." + ) + } + } + } + + fun cancelExtraction() { + extractionJob?.cancel() + extractionJob = null + _uiState.value = _uiState.value.copy( + isExtracting = false, + progress = null, + errorMessage = "Распаковка отменена." + ) + } + + fun clearAndClose() { + extractionJob?.cancel() + container.incomingArchiveStore.clear() + } + + private suspend fun loadArchive(archive: PendingArchiveFile) { + extractionJob?.cancel() + _uiState.value = ArchiveExtractorUiState( + archive = archive, + isInspecting = true + ) + runCatching { + container.archiveFileService.inspectZip(archive) + }.onSuccess { inspection -> + _uiState.value = _uiState.value.copy( + isInspecting = false, + inspection = inspection, + errorMessage = null + ) + }.onFailure { error -> + _uiState.value = _uiState.value.copy( + isInspecting = false, + inspection = null, + errorMessage = error.message ?: "Не удалось прочитать ZIP-архив." + ) + } + } +} + +fun formatArchiveBytes(value: Long?): String { + if (value == null || value < 0) { + return "размер неизвестен" + } + if (value == 0L) { + return "0 Б" + } + + val units = listOf("Б", "КБ", "МБ", "ГБ") + var size = value.toDouble() + var unitIndex = 0 + while (size >= 1024 && unitIndex < units.lastIndex) { + size /= 1024 + unitIndex += 1 + } + return "${DecimalFormat("#,##0.#").format(size).replace(',', '.')} ${units[unitIndex]}" +} diff --git a/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/archive/IncomingArchiveStore.kt b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/archive/IncomingArchiveStore.kt new file mode 100644 index 00000000..d1d833ac --- /dev/null +++ b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/archive/IncomingArchiveStore.kt @@ -0,0 +1,40 @@ +package com.seven.ikar.kotlin.feature.archive + +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow + +sealed interface PendingArchiveOpen { + val id: Long +} + +data class PendingArchiveFile( + override val id: Long = System.nanoTime(), + val fileName: String, + val contentType: String, + val fileSizeBytes: Long, + val localPath: String +) : PendingArchiveOpen + +data class PendingArchiveOpenError( + override val id: Long = System.nanoTime(), + val fileName: String?, + val message: String +) : PendingArchiveOpen + +class IncomingArchiveStore { + private val _pendingArchive = MutableStateFlow(null) + val pendingArchive: StateFlow = _pendingArchive.asStateFlow() + + fun setPending(file: PendingArchiveFile) { + _pendingArchive.value = file + } + + fun setError(message: String, fileName: String? = null) { + _pendingArchive.value = PendingArchiveOpenError(fileName = fileName, message = message) + } + + fun clear() { + _pendingArchive.value = null + } +} diff --git a/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/bots/BotEditorScreen.kt b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/bots/BotEditorScreen.kt new file mode 100644 index 00000000..ce301e7c --- /dev/null +++ b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/bots/BotEditorScreen.kt @@ -0,0 +1,197 @@ +package com.seven.ikar.kotlin.feature.bots + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.outlined.ArrowBack +import androidx.compose.material.icons.outlined.ContentCopy +import androidx.compose.material.icons.outlined.Delete +import androidx.compose.material.icons.outlined.SmartToy +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Card +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun BotEditorScreen( + state: BotEditorUiState, + onBackClick: () -> Unit, + onDisplayNameChanged: (String) -> Unit, + onUsernameChanged: (String) -> Unit, + onAboutChanged: (String) -> Unit, + onCommandsChanged: (String) -> Unit, + onEnabledChanged: (Boolean) -> Unit, + onSaveClick: () -> Unit, + onRegenerateTokenClick: () -> Unit, + onDeleteBotClick: () -> Unit, + onOpenChatClick: () -> Unit +) { + val clipboardManager = LocalClipboardManager.current + var showDeleteConfirmation by remember { mutableStateOf(false) } + if (showDeleteConfirmation) { + AlertDialog( + onDismissRequest = { showDeleteConfirmation = false }, + title = { Text("Удалить бота?") }, + text = { Text("Бот будет удалён из управления, его токен и команды перестанут работать. История сообщений останется в чатах.") }, + confirmButton = { + TextButton( + onClick = { + showDeleteConfirmation = false + onDeleteBotClick() + }, + colors = ButtonDefaults.textButtonColors(contentColor = MaterialTheme.colorScheme.error) + ) { + Text("Удалить") + } + }, + dismissButton = { + TextButton(onClick = { showDeleteConfirmation = false }) { + Text("Отмена") + } + } + ) + } + Scaffold( + topBar = { + TopAppBar( + title = { Text(if (state.isExistingBot) "Редактор бота" else "Новый бот") }, + navigationIcon = { + IconButton(onClick = onBackClick) { + Icon(Icons.AutoMirrored.Outlined.ArrowBack, contentDescription = "Назад") + } + } + ) + } + ) { innerPadding -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(innerPadding) + .verticalScroll(rememberScrollState()) + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(14.dp) + ) { + state.errorMessage?.let { + Text(it, color = MaterialTheme.colorScheme.error) + } + state.infoMessage?.let { + Text(it, color = MaterialTheme.colorScheme.primary, fontWeight = FontWeight.SemiBold) + } + + Card(modifier = Modifier.fillMaxWidth()) { + Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { + Icon(Icons.Outlined.SmartToy, contentDescription = null, tint = MaterialTheme.colorScheme.primary) + OutlinedTextField( + value = state.displayName, + onValueChange = onDisplayNameChanged, + modifier = Modifier.fillMaxWidth(), + label = { Text("Отображаемое имя") } + ) + OutlinedTextField( + value = state.username, + onValueChange = onUsernameChanged, + modifier = Modifier.fillMaxWidth(), + enabled = !state.isExistingBot, + label = { Text("Имя пользователя") }, + prefix = { Text("@") } + ) + OutlinedTextField( + value = state.about, + onValueChange = onAboutChanged, + modifier = Modifier.fillMaxWidth(), + minLines = 3, + label = { Text("О боте") } + ) + androidx.compose.foundation.layout.Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text("Бот активен") + Switch(checked = state.isEnabled, onCheckedChange = onEnabledChanged) + } + OutlinedTextField( + value = state.commandsText, + onValueChange = onCommandsChanged, + modifier = Modifier.fillMaxWidth(), + minLines = 5, + label = { Text("Команды") }, + supportingText = { Text("Формат: /help - описание") } + ) + } + } + + Button(onClick = onSaveClick, enabled = !state.isSaving && !state.isDeleting, modifier = Modifier.fillMaxWidth()) { + Text(if (state.isSaving) "Сохранение..." else "Сохранить") + } + + if (state.isExistingBot) { + OutlinedButton( + onClick = onRegenerateTokenClick, + enabled = !state.isDeleting && !state.isSaving, + modifier = Modifier.fillMaxWidth() + ) { + Text("Сгенерировать новый токен") + } + OutlinedButton( + onClick = onOpenChatClick, + enabled = !state.isDeleting && !state.isSaving, + modifier = Modifier.fillMaxWidth() + ) { + Text("Открыть чат с ботом") + } + OutlinedButton( + onClick = { showDeleteConfirmation = true }, + enabled = !state.isDeleting && !state.isSaving, + modifier = Modifier.fillMaxWidth(), + colors = ButtonDefaults.outlinedButtonColors(contentColor = MaterialTheme.colorScheme.error) + ) { + Icon(Icons.Outlined.Delete, contentDescription = null) + Text(if (state.isDeleting) "Удаление..." else "Удалить бота") + } + } + + if (state.latestToken.isNotBlank()) { + Card(modifier = Modifier.fillMaxWidth()) { + Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text("Текущий токен", fontWeight = FontWeight.SemiBold) + Text(state.latestToken, style = MaterialTheme.typography.bodySmall) + OutlinedButton( + onClick = { clipboardManager.setText(AnnotatedString(state.latestToken)) }, + modifier = Modifier.fillMaxWidth() + ) { + Icon(Icons.Outlined.ContentCopy, contentDescription = null) + Text("Скопировать токен") + } + } + } + } + } + } +} diff --git a/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/bots/BotEditorViewModel.kt b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/bots/BotEditorViewModel.kt new file mode 100644 index 00000000..4ad0021f --- /dev/null +++ b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/bots/BotEditorViewModel.kt @@ -0,0 +1,236 @@ +package com.seven.ikar.kotlin.feature.bots + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.seven.ikar.kotlin.app.AppContainer +import com.seven.ikar.kotlin.core.model.AuthSessionDto +import com.seven.ikar.kotlin.core.model.BotCommandDto +import com.seven.ikar.kotlin.core.model.BotSummaryDto +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch + +data class BotEditorUiState( + val session: AuthSessionDto? = null, + val botId: String? = null, + val botUserId: String? = null, + val displayName: String = "", + val username: String = "", + val about: String = "", + val isEnabled: Boolean = true, + val commandsText: String = "", + val latestToken: String = "", + val isLoading: Boolean = false, + val isSaving: Boolean = false, + val isDeleting: Boolean = false, + val errorMessage: String? = null, + val infoMessage: String? = null, + val pendingChatId: String? = null, + val deleteCompleted: Boolean = false +) { + val isExistingBot: Boolean get() = !botId.isNullOrBlank() +} + +class BotEditorViewModel( + private val container: AppContainer, + private val botId: String? +) : ViewModel() { + private val _uiState = MutableStateFlow(BotEditorUiState(session = container.sessionStore.read(), botId = botId)) + val uiState: StateFlow = _uiState.asStateFlow() + + init { + load() + } + + fun updateDisplayName(value: String) { + _uiState.value = _uiState.value.copy(displayName = value) + } + + fun updateUsername(value: String) { + _uiState.value = _uiState.value.copy(username = value) + } + + fun updateAbout(value: String) { + _uiState.value = _uiState.value.copy(about = value) + } + + fun updateCommandsText(value: String) { + _uiState.value = _uiState.value.copy(commandsText = value) + } + + fun updateEnabled(value: Boolean) { + _uiState.value = _uiState.value.copy(isEnabled = value) + } + + fun load() { + val existingBotId = botId ?: return + viewModelScope.launch { + _uiState.value = _uiState.value.copy(isLoading = true, errorMessage = null) + runCatching { + container.executeAuthorized { session -> + session to container.apiClient.getBot(session.accessToken, existingBotId) + } + }.onSuccess { (session, bot) -> + applyBot(session, bot, latestToken = "", infoMessage = null) + }.onFailure { error -> + _uiState.value = _uiState.value.copy(isLoading = false, errorMessage = error.message) + } + } + } + + fun save() { + val state = _uiState.value + if (state.displayName.isBlank()) { + _uiState.value = state.copy(errorMessage = "Укажите отображаемое имя бота.") + return + } + if (!state.isExistingBot && state.username.isBlank()) { + _uiState.value = state.copy(errorMessage = "Укажите имя пользователя бота.") + return + } + + viewModelScope.launch { + _uiState.value = state.copy(isSaving = true, errorMessage = null, infoMessage = null) + runCatching { + container.executeAuthorized { session -> + val parsedCommands = parseCommands(state.commandsText) + if (!state.isExistingBot) { + val created = container.apiClient.createBot( + accessToken = session.accessToken, + username = state.username.trim(), + displayName = state.displayName.trim(), + about = state.about.trim().ifBlank { null } + ) + val updatedBot = if (parsedCommands.isNotEmpty()) { + container.apiClient.setBotCommands(session.accessToken, created.bot.id, parsedCommands) + } else { + created.bot + } + Triple(session, updatedBot, Pair(created.accessToken, "Бот создан.")) + } else { + val updated = container.apiClient.updateBot( + accessToken = session.accessToken, + botId = state.botId.orEmpty(), + displayName = state.displayName.trim(), + about = state.about.trim().ifBlank { null }, + isEnabled = state.isEnabled + ) + val withCommands = container.apiClient.setBotCommands( + accessToken = session.accessToken, + botId = updated.id, + commands = parsedCommands + ) + Triple(session, withCommands, Pair(state.latestToken, "Бот сохранён.")) + } + } + }.onSuccess { result -> + val (session, bot, extra) = result + applyBot(session, bot, latestToken = extra.first, infoMessage = extra.second) + }.onFailure { error -> + _uiState.value = _uiState.value.copy(isSaving = false, errorMessage = error.message) + } + } + } + + fun regenerateToken() { + val existingBotId = _uiState.value.botId ?: return + viewModelScope.launch { + runCatching { + container.executeAuthorized { session -> + container.apiClient.regenerateBotToken(session.accessToken, existingBotId) + } + }.onSuccess { response -> + _uiState.value = _uiState.value.copy(latestToken = response.accessToken, infoMessage = "Токен обновлён.") + }.onFailure { error -> + _uiState.value = _uiState.value.copy(errorMessage = error.message) + } + } + } + + fun deleteBot() { + val existingBotId = _uiState.value.botId ?: return + val state = _uiState.value + if (state.isDeleting || state.isSaving) { + return + } + + viewModelScope.launch { + _uiState.value = state.copy(isDeleting = true, errorMessage = null, infoMessage = null) + runCatching { + container.executeAuthorized { session -> + container.apiClient.deleteBot(session.accessToken, existingBotId) + } + }.onSuccess { + _uiState.value = _uiState.value.copy(isDeleting = false, deleteCompleted = true) + }.onFailure { error -> + _uiState.value = _uiState.value.copy(isDeleting = false, errorMessage = error.message) + } + } + } + + fun openChat() { + val botUserId = _uiState.value.botUserId ?: return + viewModelScope.launch { + runCatching { + container.executeAuthorized { session -> + container.apiClient.createDirectChat(session.accessToken, botUserId) + } + }.onSuccess { chat -> + _uiState.value = _uiState.value.copy(pendingChatId = chat.id) + }.onFailure { error -> + _uiState.value = _uiState.value.copy(errorMessage = error.message) + } + } + } + + fun consumePendingChatNavigation() { + _uiState.value = _uiState.value.copy(pendingChatId = null) + } + + fun consumeDeleteCompleted() { + _uiState.value = _uiState.value.copy(deleteCompleted = false) + } + + private fun applyBot(session: AuthSessionDto, bot: BotSummaryDto, latestToken: String, infoMessage: String?) { + _uiState.value = _uiState.value.copy( + session = session, + botId = bot.id, + botUserId = bot.userId, + displayName = bot.displayName, + username = bot.username, + about = bot.about.orEmpty(), + isEnabled = bot.isEnabled, + commandsText = formatCommands(bot.commands), + latestToken = latestToken, + isLoading = false, + isSaving = false, + isDeleting = false, + errorMessage = null, + infoMessage = infoMessage, + deleteCompleted = false + ) + } + + private fun parseCommands(source: String): List = + source + .lines() + .map { it.trim() } + .filter { it.isNotBlank() } + .mapNotNull { line -> + val separatorIndex = line.indexOf(" - ").takeIf { it > 0 } + ?: line.indexOf(' ').takeIf { it > 0 } + ?: return@mapNotNull null + val separatorLength = if (line.contains(" - ")) 3 else 1 + val command = line.substring(0, separatorIndex).trim().trimStart('/') + val description = line.substring(separatorIndex + separatorLength).trim() + if (command.isBlank() || description.isBlank()) { + null + } else { + BotCommandDto(command = command, description = description) + } + } + + private fun formatCommands(commands: List): String = + commands.joinToString(separator = "\n") { "/${it.command} - ${it.description}" } +} diff --git a/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/bots/BotsScreen.kt b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/bots/BotsScreen.kt new file mode 100644 index 00000000..4cb885f9 --- /dev/null +++ b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/bots/BotsScreen.kt @@ -0,0 +1,137 @@ +package com.seven.ikar.kotlin.feature.bots + +import androidx.compose.foundation.Image +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.WindowInsetsSides +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.only +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.safeDrawing +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.outlined.ArrowBack +import androidx.compose.material.icons.outlined.Add +import androidx.compose.material3.Icon +import androidx.compose.material3.Button +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.unit.dp +import com.seven.ikar.kotlin.R +import com.seven.ikar.kotlin.core.model.BotSummaryDto +import com.seven.ikar.kotlin.ui.IkarCenteredHeader +import com.seven.ikar.kotlin.ui.IkarHeaderIconBubble +import com.seven.ikar.kotlin.ui.IkarScreenSurface +import com.seven.ikar.kotlin.ui.IkarSectionCard +import com.seven.ikar.kotlin.ui.theme.TelegramBlue +import com.seven.ikar.kotlin.ui.theme.TelegramInkMuted + +@Composable +fun BotsScreen( + state: BotsUiState, + onBackClick: () -> Unit, + onCreateBotClick: () -> Unit, + onBotClick: (BotSummaryDto) -> Unit +) { + IkarScreenSurface(modifier = Modifier.fillMaxSize()) { + Column( + modifier = Modifier + .fillMaxSize() + .windowInsetsPadding(WindowInsets.safeDrawing.only(WindowInsetsSides.Vertical)) + ) { + Column( + modifier = Modifier.padding(start = 18.dp, top = 18.dp, end = 18.dp, bottom = 14.dp), + verticalArrangement = Arrangement.spacedBy(14.dp) + ) { + IkarCenteredHeader( + title = "Боты", + left = { + IkarHeaderIconBubble(onClick = onBackClick) { + Icon(Icons.AutoMirrored.Outlined.ArrowBack, contentDescription = "Назад", tint = MaterialTheme.colorScheme.onSurface) + } + }, + right = { + IkarHeaderIconBubble { + Image( + painter = painterResource(id = R.drawable.dotnet_bot), + contentDescription = "API ботов", + modifier = Modifier.align(Alignment.Center) + ) + } + } + ) + + Button(onClick = onCreateBotClick) { + Text("Создать бота") + } + + state.errorMessage?.let { + Text(it, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodyMedium) + } + } + + LazyColumn( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 18.dp), + verticalArrangement = Arrangement.spacedBy(10.dp) + ) { + if (state.bots.isEmpty()) { + item { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 24.dp, vertical = 48.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Text("Ботов пока нет", style = MaterialTheme.typography.headlineMedium) + Text( + "Создайте первого бота и подключите его через Bot API.", + style = MaterialTheme.typography.bodyMedium, + color = TelegramInkMuted + ) + } + } + } else { + items(state.bots, key = { it.id }) { bot -> + IkarSectionCard( + modifier = Modifier + .fillMaxWidth() + .clickable { onBotClick(bot) } + ) { + Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { + Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text(bot.displayName, style = MaterialTheme.typography.headlineMedium) + Text("@${bot.username}", style = MaterialTheme.typography.bodySmall, color = TelegramBlue) + } + Text(bot.about.orEmpty().ifBlank { "Без описания" }, style = MaterialTheme.typography.bodySmall, color = TelegramInkMuted) + Text( + if (bot.commands.isEmpty()) "Команд пока нет" else "Команд: ${bot.commands.joinToString { it.command }}", + style = MaterialTheme.typography.bodySmall, + color = TelegramInkMuted + ) + Text( + if (bot.isEnabled) "Включён" else "Выключен", + style = MaterialTheme.typography.labelLarge, + color = TelegramBlue + ) + } + } + } + } + } + } + } +} diff --git a/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/bots/BotsViewModel.kt b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/bots/BotsViewModel.kt new file mode 100644 index 00000000..6351b3d2 --- /dev/null +++ b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/bots/BotsViewModel.kt @@ -0,0 +1,40 @@ +package com.seven.ikar.kotlin.feature.bots + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.seven.ikar.kotlin.app.AppContainer +import com.seven.ikar.kotlin.core.model.BotSummaryDto +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch + +data class BotsUiState( + val bots: List = emptyList(), + val isLoading: Boolean = false, + val errorMessage: String? = null +) + +class BotsViewModel(private val container: AppContainer) : ViewModel() { + private val _uiState = MutableStateFlow(BotsUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + init { + refresh() + } + + fun refresh() { + viewModelScope.launch { + _uiState.value = _uiState.value.copy(isLoading = true, errorMessage = null) + runCatching { + container.executeAuthorized { session -> + container.apiClient.getMyBots(session.accessToken) + } + }.onSuccess { bots -> + _uiState.value = _uiState.value.copy(bots = bots, isLoading = false, errorMessage = null) + }.onFailure { error -> + _uiState.value = _uiState.value.copy(isLoading = false, errorMessage = error.message) + } + } + } +} diff --git a/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/call/AndroidCallTonePlayer.kt b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/call/AndroidCallTonePlayer.kt new file mode 100644 index 00000000..179939ca --- /dev/null +++ b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/call/AndroidCallTonePlayer.kt @@ -0,0 +1,46 @@ +package com.seven.ikar.kotlin.feature.call + +import android.media.AudioManager +import android.media.ToneGenerator + +class AndroidCallTonePlayer { + private var toneGenerator: ToneGenerator? = null + private var isOutgoingRingbackActive: Boolean = false + + fun startOutgoingRingback() { + if (isOutgoingRingbackActive) { + return + } + + val generator = runCatching { + ToneGenerator(AudioManager.STREAM_RING, OUTGOING_RINGBACK_VOLUME) + }.getOrNull() ?: return + + stopOutgoingRingback() + toneGenerator = generator + isOutgoingRingbackActive = true + + val started = generator.startTone( + ToneGenerator.TONE_CDMA_NETWORK_USA_RINGBACK, + OUTGOING_RINGBACK_DURATION_MS + ) + if (!started) { + generator.startTone( + ToneGenerator.TONE_SUP_RINGTONE, + OUTGOING_RINGBACK_DURATION_MS + ) + } + } + + fun stopOutgoingRingback() { + isOutgoingRingbackActive = false + toneGenerator?.runCatching { stopTone() } + toneGenerator?.release() + toneGenerator = null + } + + companion object { + private const val OUTGOING_RINGBACK_VOLUME = 80 + private const val OUTGOING_RINGBACK_DURATION_MS = 120_000 + } +} diff --git a/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/call/AndroidWebRtcCallService.kt b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/call/AndroidWebRtcCallService.kt new file mode 100644 index 00000000..fceb195e --- /dev/null +++ b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/call/AndroidWebRtcCallService.kt @@ -0,0 +1,587 @@ +package com.seven.ikar.kotlin.feature.call + +import android.content.Context +import android.media.AudioDeviceInfo +import android.media.AudioManager +import android.os.Build +import com.seven.ikar.kotlin.core.model.CallAnswerDto +import com.seven.ikar.kotlin.core.model.CallIceCandidateDto +import com.seven.ikar.kotlin.core.model.CallIceServerDto +import com.seven.ikar.kotlin.core.model.CallMediaType +import com.seven.ikar.kotlin.core.model.CallOfferDto +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.suspendCancellableCoroutine +import org.webrtc.AudioSource +import org.webrtc.AudioTrack +import org.webrtc.Camera1Enumerator +import org.webrtc.Camera2Enumerator +import org.webrtc.CameraEnumerator +import org.webrtc.CameraVideoCapturer +import org.webrtc.DataChannel +import org.webrtc.DefaultVideoDecoderFactory +import org.webrtc.DefaultVideoEncoderFactory +import org.webrtc.EglBase +import org.webrtc.IceCandidate +import org.webrtc.MediaConstraints +import org.webrtc.MediaStream +import org.webrtc.MediaStreamTrack +import org.webrtc.PeerConnection +import org.webrtc.PeerConnectionFactory +import org.webrtc.RtpReceiver +import org.webrtc.RtpTransceiver +import org.webrtc.SdpObserver +import org.webrtc.SessionDescription +import org.webrtc.SurfaceTextureHelper +import org.webrtc.VideoCapturer +import org.webrtc.VideoSource +import org.webrtc.VideoTrack +import org.webrtc.audio.JavaAudioDeviceModule +import kotlin.coroutines.resume +import kotlin.coroutines.resumeWithException + +data class CallVideoRenderState( + val eglBaseContext: EglBase.Context?, + val localVideoTrack: VideoTrack? = null, + val remoteVideoTrack: VideoTrack? = null +) + +class AndroidWebRtcCallService( + context: Context +) { + private val appContext = context.applicationContext + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + private val audioManager = appContext.getSystemService(Context.AUDIO_SERVICE) as AudioManager + private val mutex = Mutex() + private val eglBase: EglBase = EglBase.create() + private val peerConnectionFactory: PeerConnectionFactory by lazy(::createPeerConnectionFactory) + + private var peerConnection: PeerConnection? = null + private var callId: String? = null + private var mediaType: CallMediaType? = null + private var audioSource: AudioSource? = null + private var localAudioTrack: AudioTrack? = null + private var videoSource: VideoSource? = null + private var localVideoTrack: VideoTrack? = null + private var remoteVideoTrack: VideoTrack? = null + private var surfaceTextureHelper: SurfaceTextureHelper? = null + private var videoCapturer: VideoCapturer? = null + private var sendOffer: (suspend (CallOfferDto) -> Unit)? = null + private var sendAnswer: (suspend (CallAnswerDto) -> Unit)? = null + private var sendIceCandidate: (suspend (CallIceCandidateDto) -> Unit)? = null + private var remoteDescriptionApplied = false + private var pendingRemoteOffer: CallOfferDto? = null + private var pendingRemoteAnswer: CallAnswerDto? = null + private val pendingRemoteIceCandidates = mutableListOf() + private var previousAudioMode: Int = AudioManager.MODE_NORMAL + private var previousSpeakerphoneState: Boolean = false + private var previousCommunicationDeviceType: Int? = null + + var onRenderStateChanged: ((CallVideoRenderState?) -> Unit)? = null + var onConnectionFailure: ((String) -> Unit)? = null + + suspend fun start( + callId: String, + mediaType: CallMediaType, + iceServers: List, + shouldCreateOffer: Boolean, + sendOffer: suspend (CallOfferDto) -> Unit, + sendAnswer: suspend (CallAnswerDto) -> Unit, + sendIceCandidate: suspend (CallIceCandidateDto) -> Unit + ) { + mutex.withLock { + stopInternal() + + this.callId = callId + this.mediaType = mediaType + this.sendOffer = sendOffer + this.sendAnswer = sendAnswer + this.sendIceCandidate = sendIceCandidate + remoteDescriptionApplied = false + previousAudioMode = audioManager.mode + previousSpeakerphoneState = audioManager.isSpeakerphoneOn + previousCommunicationDeviceType = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + audioManager.communicationDevice?.type + } else { + null + } + + preparePeerConnection(iceServers) + prepareLocalMedia(mediaType) + applyAudioRoutingForCurrentMediaType() + publishRenderState() + + if (shouldCreateOffer) { + createAndSendOffer(callId) + } else { + pendingRemoteOffer?.let { + pendingRemoteOffer = null + handleRemoteOfferLocked(it) + } + } + + pendingRemoteAnswer?.let { + pendingRemoteAnswer = null + handleRemoteAnswerLocked(it) + } + } + } + + suspend fun handleRemoteOffer(payload: CallOfferDto) { + mutex.withLock { + if (callId != payload.callId || peerConnection == null) { + pendingRemoteOffer = payload + return + } + + handleRemoteOfferLocked(payload) + } + } + + suspend fun handleRemoteAnswer(payload: CallAnswerDto) { + mutex.withLock { + if (callId != payload.callId || peerConnection == null) { + pendingRemoteAnswer = payload + return + } + + handleRemoteAnswerLocked(payload) + } + } + + suspend fun handleRemoteIceCandidate(payload: CallIceCandidateDto) { + mutex.withLock { + if (callId != payload.callId || peerConnection == null) { + pendingRemoteIceCandidates += payload + return + } + + if (!remoteDescriptionApplied) { + pendingRemoteIceCandidates += payload + return + } + + peerConnection?.addIceCandidate(payload.toNative()) + } + } + + suspend fun stop() { + mutex.withLock { + stopInternal() + } + } + + private fun preparePeerConnection(iceServers: List) { + val rtcConfiguration = PeerConnection.RTCConfiguration( + iceServers.map { it.toNative() } + ).apply { + sdpSemantics = PeerConnection.SdpSemantics.UNIFIED_PLAN + continualGatheringPolicy = PeerConnection.ContinualGatheringPolicy.GATHER_CONTINUALLY + } + val createdPeerConnection = peerConnectionFactory.createPeerConnection( + rtcConfiguration, + object : PeerConnection.Observer { + override fun onSignalingChange(newState: PeerConnection.SignalingState?) = Unit + + override fun onIceConnectionChange(newState: PeerConnection.IceConnectionState?) = Unit + + override fun onIceConnectionReceivingChange(receiving: Boolean) = Unit + + override fun onIceGatheringChange(newState: PeerConnection.IceGatheringState?) = Unit + + override fun onIceCandidate(candidate: IceCandidate?) { + val activeCallId = callId ?: return + val activeCandidate = candidate ?: return + val sender = sendIceCandidate ?: return + scope.launch { + sender( + CallIceCandidateDto( + callId = activeCallId, + candidate = activeCandidate.sdp, + sdpMid = activeCandidate.sdpMid, + sdpMLineIndex = activeCandidate.sdpMLineIndex + ) + ) + } + } + + override fun onIceCandidatesRemoved(candidates: Array?) = Unit + + override fun onAddStream(stream: MediaStream?) = Unit + + override fun onRemoveStream(stream: MediaStream?) = Unit + + override fun onDataChannel(dataChannel: DataChannel?) = Unit + + override fun onRenegotiationNeeded() = Unit + + override fun onAddTrack(receiver: RtpReceiver?, mediaStreams: Array?) { + receiver?.track()?.let(::handleRemoteTrack) + } + + override fun onTrack(transceiver: RtpTransceiver?) { + transceiver?.receiver?.track()?.let(::handleRemoteTrack) + } + + override fun onConnectionChange(newState: PeerConnection.PeerConnectionState?) { + if (newState == PeerConnection.PeerConnectionState.FAILED || + newState == PeerConnection.PeerConnectionState.CLOSED + ) { + onConnectionFailure?.invoke("WebRTC соединение завершилось: $newState") + } + } + } + ) + check(createdPeerConnection != null) { "Не удалось создать PeerConnection." } + peerConnection = createdPeerConnection + } + + private suspend fun handleRemoteOfferLocked(payload: CallOfferDto) { + val activePeerConnection = requireNotNull(peerConnection) { "PeerConnection не создан." } + setRemoteDescription(activePeerConnection, payload.toSessionDescription()) + remoteDescriptionApplied = true + flushPendingIceCandidatesLocked() + val answer = createAnswer(activePeerConnection, mediaType == CallMediaType.Video) + setLocalDescription(activePeerConnection, answer) + val sender = requireNotNull(sendAnswer) + sender(CallAnswerDto(payload.callId, answer.type.canonicalForm(), answer.description)) + } + + private suspend fun handleRemoteAnswerLocked(payload: CallAnswerDto) { + val activePeerConnection = requireNotNull(peerConnection) { "PeerConnection не создан." } + setRemoteDescription(activePeerConnection, payload.toSessionDescription()) + remoteDescriptionApplied = true + flushPendingIceCandidatesLocked() + } + + private suspend fun flushPendingIceCandidatesLocked() { + val activePeerConnection = peerConnection ?: return + val iterator = pendingRemoteIceCandidates.toList() + pendingRemoteIceCandidates.clear() + iterator.forEach { candidate -> + activePeerConnection.addIceCandidate(candidate.toNative()) + } + } + + private fun prepareLocalMedia(mediaType: CallMediaType) { + val activePeerConnection = requireNotNull(peerConnection) { "PeerConnection не создан." } + val newAudioSource = peerConnectionFactory.createAudioSource(MediaConstraints()) + val newLocalAudioTrack = peerConnectionFactory.createAudioTrack(AUDIO_TRACK_ID, newAudioSource) + activePeerConnection.addTrack(newLocalAudioTrack) + audioSource = newAudioSource + localAudioTrack = newLocalAudioTrack + + if (mediaType != CallMediaType.Video) { + return + } + + val capturer = createVideoCapturer() + val helper = SurfaceTextureHelper.create("IkarCaptureThread", eglBase.eglBaseContext) + val newVideoSource = peerConnectionFactory.createVideoSource(capturer.isScreencast) + capturer.initialize(helper, appContext, newVideoSource.capturerObserver) + capturer.startCapture(VIDEO_WIDTH, VIDEO_HEIGHT, VIDEO_FPS) + val newLocalVideoTrack = peerConnectionFactory.createVideoTrack(VIDEO_TRACK_ID, newVideoSource) + activePeerConnection.addTrack(newLocalVideoTrack) + videoCapturer = capturer + surfaceTextureHelper = helper + videoSource = newVideoSource + localVideoTrack = newLocalVideoTrack + } + + private suspend fun createAndSendOffer(callId: String) { + val activePeerConnection = requireNotNull(peerConnection) { "PeerConnection не создан." } + val offer = createOffer(activePeerConnection, mediaType == CallMediaType.Video) + setLocalDescription(activePeerConnection, offer) + val sender = requireNotNull(sendOffer) + sender(CallOfferDto(callId, offer.type.canonicalForm(), offer.description)) + } + + private fun handleRemoteTrack(track: MediaStreamTrack) { + when (track) { + is AudioTrack -> { + track.setEnabled(true) + applyAudioRoutingForCurrentMediaType() + } + is VideoTrack -> { + remoteVideoTrack = track + applyAudioRoutingForCurrentMediaType() + publishRenderState() + } + } + } + + private fun publishRenderState() { + onRenderStateChanged?.invoke( + if (mediaType == CallMediaType.Video) { + CallVideoRenderState( + eglBaseContext = eglBase.eglBaseContext, + localVideoTrack = localVideoTrack, + remoteVideoTrack = remoteVideoTrack + ) + } else { + null + } + ) + } + + private fun stopInternal() { + pendingRemoteOffer = null + pendingRemoteAnswer = null + pendingRemoteIceCandidates.clear() + remoteDescriptionApplied = false + + runCatching { videoCapturer?.stopCapture() } + runCatching { videoCapturer?.dispose() } + runCatching { localVideoTrack?.dispose() } + runCatching { videoSource?.dispose() } + runCatching { surfaceTextureHelper?.dispose() } + runCatching { localAudioTrack?.dispose() } + runCatching { audioSource?.dispose() } + runCatching { peerConnection?.dispose() } + + peerConnection = null + callId = null + mediaType = null + audioSource = null + localAudioTrack = null + videoSource = null + localVideoTrack = null + remoteVideoTrack = null + surfaceTextureHelper = null + videoCapturer = null + sendOffer = null + sendAnswer = null + sendIceCandidate = null + + restorePreviousAudioRouting() + audioManager.mode = previousAudioMode + publishRenderState() + } + + private fun applyAudioRoutingForCurrentMediaType() { + audioManager.mode = AudioManager.MODE_IN_COMMUNICATION + audioManager.isMicrophoneMute = false + + if (mediaType == CallMediaType.Video) { + runCatching { audioManager.stopBluetoothSco() } + runCatching { audioManager.setBluetoothScoOn(false) } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + val speakerDevice = audioManager.getAvailableCommunicationDevices().firstOrNull { + it.type == AudioDeviceInfo.TYPE_BUILTIN_SPEAKER || + it.type == AudioDeviceInfo.TYPE_BUILTIN_SPEAKER_SAFE + } + if (speakerDevice != null) { + audioManager.setCommunicationDevice(speakerDevice) + } else { + audioManager.clearCommunicationDevice() + } + } + audioManager.isSpeakerphoneOn = true + } else { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + audioManager.clearCommunicationDevice() + } + audioManager.isSpeakerphoneOn = false + } + } + + private fun restorePreviousAudioRouting() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + val previousDevice = previousCommunicationDeviceType?.let { previousType -> + audioManager.getAvailableCommunicationDevices().firstOrNull { it.type == previousType } + } + if (previousDevice != null) { + audioManager.setCommunicationDevice(previousDevice) + } else { + audioManager.clearCommunicationDevice() + } + } + audioManager.isSpeakerphoneOn = previousSpeakerphoneState + previousCommunicationDeviceType = null + } + + private fun createPeerConnectionFactory(): PeerConnectionFactory { + ensureFactoryInitialized(appContext) + val audioDeviceModule = JavaAudioDeviceModule.builder(appContext) + .setUseHardwareAcousticEchoCanceler(true) + .setUseHardwareNoiseSuppressor(true) + .createAudioDeviceModule() + + return try { + PeerConnectionFactory.builder() + .setAudioDeviceModule(audioDeviceModule) + .setVideoEncoderFactory(DefaultVideoEncoderFactory(eglBase.eglBaseContext, true, true)) + .setVideoDecoderFactory(DefaultVideoDecoderFactory(eglBase.eglBaseContext)) + .createPeerConnectionFactory() + } finally { + audioDeviceModule.release() + } + } + + private fun createVideoCapturer(): VideoCapturer { + val enumerator: CameraEnumerator = + if (Camera2Enumerator.isSupported(appContext)) { + Camera2Enumerator(appContext) + } else { + Camera1Enumerator(true) + } + + val preferredDevice = enumerator.deviceNames.firstOrNull { enumerator.isFrontFacing(it) } + ?: enumerator.deviceNames.firstOrNull() + ?: error("На устройстве нет камеры для видеозвонка.") + + return (enumerator.createCapturer(preferredDevice, null) as? CameraVideoCapturer) + ?: error("Не удалось подготовить видеокапчер для звонка.") + } + + private suspend fun createOffer( + peerConnection: PeerConnection, + offerToReceiveVideo: Boolean + ): SessionDescription = + suspendCancellableCoroutine { continuation -> + peerConnection.createOffer(object : SdpObserver { + override fun onCreateSuccess(description: SessionDescription?) { + val activeDescription = description ?: return continuation.resumeWithException( + IllegalStateException("WebRTC offer не был создан.") + ) + continuation.resume(activeDescription) + } + + override fun onSetSuccess() = Unit + + override fun onCreateFailure(error: String?) { + continuation.resumeWithException(IllegalStateException(error ?: "Не удалось создать WebRTC offer.")) + } + + override fun onSetFailure(error: String?) = Unit + }, buildSdpConstraints(offerToReceiveVideo)) + } + + private suspend fun createAnswer( + peerConnection: PeerConnection, + offerToReceiveVideo: Boolean + ): SessionDescription = + suspendCancellableCoroutine { continuation -> + peerConnection.createAnswer(object : SdpObserver { + override fun onCreateSuccess(description: SessionDescription?) { + val activeDescription = description ?: return continuation.resumeWithException( + IllegalStateException("WebRTC answer не был создан.") + ) + continuation.resume(activeDescription) + } + + override fun onSetSuccess() = Unit + + override fun onCreateFailure(error: String?) { + continuation.resumeWithException(IllegalStateException(error ?: "Не удалось создать WebRTC answer.")) + } + + override fun onSetFailure(error: String?) = Unit + }, buildSdpConstraints(offerToReceiveVideo)) + } + + private suspend fun setLocalDescription( + peerConnection: PeerConnection, + description: SessionDescription + ) { + suspendCancellableCoroutine { continuation -> + peerConnection.setLocalDescription(object : SdpObserver { + override fun onCreateSuccess(description: SessionDescription?) = Unit + + override fun onSetSuccess() { + continuation.resume(Unit) + } + + override fun onCreateFailure(error: String?) = Unit + + override fun onSetFailure(error: String?) { + continuation.resumeWithException( + IllegalStateException(error ?: "Не удалось установить local description.") + ) + } + }, description) + } + } + + private suspend fun setRemoteDescription( + peerConnection: PeerConnection, + description: SessionDescription + ) { + suspendCancellableCoroutine { continuation -> + peerConnection.setRemoteDescription(object : SdpObserver { + override fun onCreateSuccess(description: SessionDescription?) = Unit + + override fun onSetSuccess() { + continuation.resume(Unit) + } + + override fun onCreateFailure(error: String?) = Unit + + override fun onSetFailure(error: String?) { + continuation.resumeWithException( + IllegalStateException(error ?: "Не удалось установить remote description.") + ) + } + }, description) + } + } + + private fun buildSdpConstraints(offerToReceiveVideo: Boolean): MediaConstraints = + MediaConstraints().apply { + mandatory += MediaConstraints.KeyValuePair("OfferToReceiveAudio", "true") + mandatory += MediaConstraints.KeyValuePair( + "OfferToReceiveVideo", + if (offerToReceiveVideo) "true" else "false" + ) + } + + private fun CallIceServerDto.toNative(): PeerConnection.IceServer = + PeerConnection.IceServer.builder(urls) + .setUsername(username.orEmpty()) + .setPassword(credential.orEmpty()) + .createIceServer() + + private fun CallOfferDto.toSessionDescription(): SessionDescription = + SessionDescription(SessionDescription.Type.fromCanonicalForm(type), sdp) + + private fun CallAnswerDto.toSessionDescription(): SessionDescription = + SessionDescription(SessionDescription.Type.fromCanonicalForm(type), sdp) + + private fun CallIceCandidateDto.toNative(): IceCandidate = + IceCandidate(sdpMid, sdpMLineIndex, candidate) + + companion object { + private const val AUDIO_TRACK_ID = "IKAR_AUDIO_TRACK" + private const val VIDEO_TRACK_ID = "IKAR_VIDEO_TRACK" + private const val VIDEO_WIDTH = 640 + private const val VIDEO_HEIGHT = 480 + private const val VIDEO_FPS = 24 + + private val factoryInitializationLock = Any() + @Volatile + private var factoryInitialized = false + + private fun ensureFactoryInitialized(context: Context) { + if (factoryInitialized) { + return + } + + synchronized(factoryInitializationLock) { + if (factoryInitialized) { + return + } + + PeerConnectionFactory.initialize( + PeerConnectionFactory.InitializationOptions.builder(context) + .createInitializationOptions() + ) + factoryInitialized = true + } + } + } +} diff --git a/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/call/CallManager.kt b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/call/CallManager.kt new file mode 100644 index 00000000..a91ac89f --- /dev/null +++ b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/call/CallManager.kt @@ -0,0 +1,359 @@ +package com.seven.ikar.kotlin.feature.call + +import android.content.Context +import com.seven.ikar.kotlin.core.model.CallConnectedDto +import com.seven.ikar.kotlin.core.model.CallEndedDto +import com.seven.ikar.kotlin.core.model.CallMediaType +import com.seven.ikar.kotlin.core.model.IncomingCallDto +import com.seven.ikar.kotlin.core.model.OutgoingCallDto +import com.seven.ikar.kotlin.core.model.UserSummaryDto +import com.seven.ikar.kotlin.core.push.IncomingCallNotificationManager +import com.seven.ikar.kotlin.core.realtime.RealtimeService +import com.seven.ikar.kotlin.core.session.AuthorizedSessionManager +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import org.webrtc.VideoTrack +import java.time.OffsetDateTime +import java.time.format.DateTimeParseException + +enum class AudioCallStage { + None, + Incoming, + Outgoing, + Connecting, + Active +} + +data class AudioCallSnapshot( + val callId: String, + val chatId: String, + val peer: UserSummaryDto, + val stage: AudioCallStage, + val durationText: String, + val mediaType: CallMediaType, + val videoRenderState: CallVideoRenderState? = null +) + +class CallManager( + private val realtimeService: RealtimeService, + private val authorizedSessionManager: AuthorizedSessionManager, + private val webRtcCallService: AndroidWebRtcCallService, + private val callTonePlayer: AndroidCallTonePlayer, + private val appContext: Context +) { + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + private var durationJob: Job? = null + private var connectedAtRaw: String? = null + + private val _currentCall = MutableStateFlow(null) + val currentCall: StateFlow = _currentCall.asStateFlow() + + init { + webRtcCallService.onRenderStateChanged = fun(renderState) { + val current = _currentCall.value ?: return + if (current.mediaType != CallMediaType.Video) { + return + } + + _currentCall.value = current.copy(videoRenderState = renderState) + } + webRtcCallService.onConnectionFailure = { + scope.launch { + val current = _currentCall.value ?: return@launch + runCatching { + authorizedSessionManager.executeAuthorized { session -> + realtimeService.ensureConnected(session.accessToken) + realtimeService.endCall(current.callId) + } + } + clearCurrentCallInternal() + } + } + + scope.launch { + realtimeService.incomingCall.collect(::handleIncomingCall) + } + scope.launch { + realtimeService.callConnected.collect(::handleCallConnected) + } + scope.launch { + realtimeService.callEnded.collect(::handleCallEnded) + } + scope.launch { + realtimeService.callOffer.collect { offer -> + val current = _currentCall.value ?: return@collect + if (current.callId == offer.callId) { + runCatching { webRtcCallService.handleRemoteOffer(offer) } + .onFailure { clearCurrentCall() } + } + } + } + scope.launch { + realtimeService.callAnswer.collect { answer -> + val current = _currentCall.value ?: return@collect + if (current.callId == answer.callId) { + runCatching { webRtcCallService.handleRemoteAnswer(answer) } + .onFailure { clearCurrentCall() } + } + } + } + scope.launch { + realtimeService.callIceCandidate.collect { candidate -> + val current = _currentCall.value ?: return@collect + if (current.callId == candidate.callId) { + runCatching { webRtcCallService.handleRemoteIceCandidate(candidate) } + .onFailure { clearCurrentCall() } + } + } + } + } + + fun canStartCallForChat(chatId: String): Boolean = + chatId.isNotBlank() && _currentCall.value == null + + fun isCallForChat(chatId: String): Boolean = + _currentCall.value?.chatId == chatId + + suspend fun startOutgoingAudioCall(chatId: String) { + startOutgoingCall(chatId, CallMediaType.Audio) + } + + suspend fun startOutgoingVideoCall(chatId: String) { + startOutgoingCall(chatId, CallMediaType.Video) + } + + fun acceptCurrentCall() { + val current = _currentCall.value ?: return + if (current.stage != AudioCallStage.Incoming) { + return + } + + IncomingCallNotificationManager.cancelIncomingCall(appContext, current.callId) + _currentCall.value = current.copy(stage = AudioCallStage.Connecting) + scope.launch { + runCatching { + authorizedSessionManager.executeAuthorized { session -> + realtimeService.ensureConnected(session.accessToken) + realtimeService.acceptCall(current.callId) + } + }.onFailure { + clearCurrentCallInternal() + } + } + } + + fun declineCurrentCall() { + val current = _currentCall.value ?: return + IncomingCallNotificationManager.cancelIncomingCall(appContext, current.callId) + scope.launch { + runCatching { + authorizedSessionManager.executeAuthorized { session -> + realtimeService.ensureConnected(session.accessToken) + realtimeService.declineCall(current.callId) + } + } + clearCurrentCallInternal() + } + } + + fun endCurrentCall() { + val current = _currentCall.value ?: return + IncomingCallNotificationManager.cancelIncomingCall(appContext, current.callId) + scope.launch { + runCatching { + authorizedSessionManager.executeAuthorized { session -> + realtimeService.ensureConnected(session.accessToken) + realtimeService.endCall(current.callId) + } + } + clearCurrentCallInternal() + } + } + + fun presentIncomingCallInvite( + callId: String, + chatId: String, + callerId: String, + callerDisplayName: String, + callerPhoneNumber: String?, + mediaType: CallMediaType + ) { + val current = _currentCall.value + if (current?.callId == callId || current != null) { + return + } + + callTonePlayer.stopOutgoingRingback() + stopDurationLoop() + connectedAtRaw = null + _currentCall.value = AudioCallSnapshot( + callId = callId, + chatId = chatId, + peer = UserSummaryDto( + id = callerId, + username = callerDisplayName, + displayName = callerDisplayName, + isOnline = false, + phoneNumber = callerPhoneNumber + ), + stage = AudioCallStage.Incoming, + durationText = "00:00", + mediaType = mediaType + ) + } + + fun dismissCallIfMatches(callId: String?) { + val normalizedCallId = callId?.takeIf { it.isNotBlank() } ?: return + if (_currentCall.value?.callId == normalizedCallId) { + clearCurrentCall() + } + } + + private suspend fun startOutgoingCall(chatId: String, mediaType: CallMediaType) { + if (!canStartCallForChat(chatId)) { + return + } + + handleOutgoingCallStarted(realtimeService.startCall(chatId, mediaType)) + } + + private fun handleOutgoingCallStarted(call: OutgoingCallDto) { + callTonePlayer.startOutgoingRingback() + stopDurationLoop() + connectedAtRaw = null + _currentCall.value = AudioCallSnapshot( + callId = call.callId, + chatId = call.chatId, + peer = call.peer, + stage = AudioCallStage.Outgoing, + durationText = "00:00", + mediaType = call.mediaType + ) + } + + private fun handleIncomingCall(call: IncomingCallDto) { + if (_currentCall.value != null) { + return + } + + callTonePlayer.stopOutgoingRingback() + stopDurationLoop() + connectedAtRaw = null + _currentCall.value = AudioCallSnapshot( + callId = call.callId, + chatId = call.chatId, + peer = call.caller, + stage = AudioCallStage.Incoming, + durationText = "00:00", + mediaType = call.mediaType + ) + } + + private fun handleCallConnected(call: CallConnectedDto) { + val current = _currentCall.value ?: return + if (current.callId != call.callId) { + return + } + + callTonePlayer.stopOutgoingRingback() + IncomingCallNotificationManager.cancelIncomingCall(appContext, call.callId) + connectedAtRaw = call.connectedAt + _currentCall.value = current.copy( + stage = AudioCallStage.Active, + durationText = "00:00", + mediaType = call.mediaType + ) + restartDurationLoop() + + scope.launch { + runCatching { + webRtcCallService.start( + callId = call.callId, + mediaType = call.mediaType, + iceServers = call.iceServers, + shouldCreateOffer = call.shouldCreateOffer, + sendOffer = realtimeService::sendCallOffer, + sendAnswer = realtimeService::sendCallAnswer, + sendIceCandidate = realtimeService::sendCallIceCandidate + ) + }.onFailure { + clearCurrentCallInternal() + } + } + } + + private fun handleCallEnded(call: CallEndedDto) { + val current = _currentCall.value ?: return + if (current.callId != call.callId) { + return + } + + clearCurrentCall() + } + + private fun restartDurationLoop() { + stopDurationLoop() + val startedAt = parseConnectedAt(connectedAtRaw) ?: return + durationJob = scope.launch { + while (true) { + val current = _currentCall.value + if (current == null || current.stage != AudioCallStage.Active) { + return@launch + } + + _currentCall.value = current.copy( + durationText = formatDurationMillis( + System.currentTimeMillis() - startedAt.toInstant().toEpochMilli() + ) + ) + delay(1_000) + } + } + } + + private fun stopDurationLoop() { + durationJob?.cancel() + durationJob = null + } + + private fun clearCurrentCall() { + scope.launch { + clearCurrentCallInternal() + } + } + + private suspend fun clearCurrentCallInternal() { + stopDurationLoop() + connectedAtRaw = null + callTonePlayer.stopOutgoingRingback() + IncomingCallNotificationManager.cancelIncomingCall(appContext, _currentCall.value?.callId) + runCatching { webRtcCallService.stop() } + _currentCall.value = null + } + + private fun parseConnectedAt(value: String?): OffsetDateTime? { + if (value.isNullOrBlank()) { + return null + } + + return try { + OffsetDateTime.parse(value) + } catch (_: DateTimeParseException) { + null + } + } + + private fun formatDurationMillis(durationMillis: Long): String { + val totalSeconds = (durationMillis.coerceAtLeast(0) / 1_000).toInt() + val minutes = totalSeconds / 60 + val seconds = totalSeconds % 60 + return "%02d:%02d".format(minutes, seconds) + } +} diff --git a/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/call/CallScreen.kt b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/call/CallScreen.kt new file mode 100644 index 00000000..bdd853f6 --- /dev/null +++ b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/call/CallScreen.kt @@ -0,0 +1,561 @@ +package com.seven.ikar.kotlin.feature.call + +import android.Manifest +import android.content.pm.PackageManager +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.gestures.detectDragGestures +import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Call +import androidx.compose.material.icons.outlined.CallEnd +import androidx.compose.material.icons.outlined.Close +import androidx.compose.material.icons.outlined.Videocam +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.dp +import androidx.compose.ui.viewinterop.AndroidView +import androidx.compose.ui.zIndex +import androidx.core.content.ContextCompat +import com.seven.ikar.kotlin.core.model.CallMediaType +import kotlinx.coroutines.launch +import org.webrtc.SurfaceViewRenderer +import org.webrtc.VideoTrack +import kotlin.math.roundToInt + +@Composable +fun CallScreen( + state: AudioCallSnapshot, + onAccept: () -> Unit, + onDecline: () -> Unit, + onEnd: () -> Unit +) { + val context = LocalContext.current + val currentOnAccept by rememberUpdatedState(onAccept) + val currentMediaType by rememberUpdatedState(state.mediaType) + val currentContext by rememberUpdatedState(context) + val incomingPermissionsLauncher = rememberLauncherForActivityResult( + ActivityResultContracts.RequestMultiplePermissions() + ) { + val hasMicrophone = ContextCompat.checkSelfPermission( + currentContext, + Manifest.permission.RECORD_AUDIO + ) == PackageManager.PERMISSION_GRANTED + val hasCamera = currentMediaType != CallMediaType.Video || + ContextCompat.checkSelfPermission( + currentContext, + Manifest.permission.CAMERA + ) == PackageManager.PERMISSION_GRANTED + if (hasMicrophone && hasCamera) { + currentOnAccept() + } + } + + val handleAccept = { + val requiredPermissions = buildList { + if (ContextCompat.checkSelfPermission(context, Manifest.permission.RECORD_AUDIO) != PackageManager.PERMISSION_GRANTED) { + add(Manifest.permission.RECORD_AUDIO) + } + if ( + state.mediaType == CallMediaType.Video && + ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) != PackageManager.PERMISSION_GRANTED + ) { + add(Manifest.permission.CAMERA) + } + } + + if (requiredPermissions.isEmpty()) { + onAccept() + } else { + incomingPermissionsLauncher.launch(requiredPermissions.toTypedArray()) + } + } + + Surface( + modifier = Modifier.fillMaxSize(), + color = Color(0xFF081823) + ) { + Column( + modifier = Modifier + .fillMaxSize() + .background( + Brush.verticalGradient( + colors = listOf(Color(0xFF0A1F2D), Color(0xFF081823)) + ) + ) + .padding(horizontal = 24.dp, vertical = 28.dp), + verticalArrangement = Arrangement.SpaceBetween + ) { + Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { + Text( + text = "ИКАР", + style = MaterialTheme.typography.labelLarge, + color = Color(0xFF90D6FF) + ) + Text( + text = state.peer.displayName, + style = MaterialTheme.typography.headlineMedium, + color = Color.White, + fontWeight = FontWeight.SemiBold + ) + Text( + text = buildPrimaryStatus(state), + style = MaterialTheme.typography.bodyMedium, + color = Color(0xFFB2C9D6) + ) + } + + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(18.dp) + ) { + if (state.mediaType == CallMediaType.Video) { + val renderState = state.videoRenderState + val previewMarginPx = with(LocalDensity.current) { 16.dp.roundToPx() } + var videoStageSize by remember(state.callId) { mutableStateOf(IntSize.Zero) } + var localPreviewSize by remember(state.callId) { mutableStateOf(IntSize.Zero) } + var localPreviewOffset by remember(state.callId) { mutableStateOf(IntOffset.Zero) } + var isLocalPreviewPositionInitialized by remember(state.callId) { mutableStateOf(false) } + + fun clampLocalPreviewOffset(offset: IntOffset): IntOffset { + if (videoStageSize == IntSize.Zero || localPreviewSize == IntSize.Zero) { + return IntOffset.Zero + } + + val availableX = (videoStageSize.width - localPreviewSize.width).coerceAtLeast(0) + val availableY = (videoStageSize.height - localPreviewSize.height).coerceAtLeast(0) + val minX = previewMarginPx.coerceAtMost(availableX) + val minY = previewMarginPx.coerceAtMost(availableY) + val maxX = (availableX - previewMarginPx).coerceAtLeast(minX) + val maxY = (availableY - previewMarginPx).coerceAtLeast(minY) + + return IntOffset( + x = offset.x.coerceIn(minX, maxX), + y = offset.y.coerceIn(minY, maxY) + ) + } + + fun buildDefaultLocalPreviewOffset(): IntOffset = + clampLocalPreviewOffset( + IntOffset( + x = videoStageSize.width - localPreviewSize.width, + y = videoStageSize.height - localPreviewSize.height + ) + ) + + LaunchedEffect(videoStageSize, localPreviewSize, previewMarginPx, state.callId) { + if (videoStageSize == IntSize.Zero || localPreviewSize == IntSize.Zero) { + return@LaunchedEffect + } + + localPreviewOffset = if (isLocalPreviewPositionInitialized) { + clampLocalPreviewOffset(localPreviewOffset) + } else { + isLocalPreviewPositionInitialized = true + buildDefaultLocalPreviewOffset() + } + } + + Box( + modifier = Modifier + .fillMaxWidth() + .height(420.dp) + .onSizeChanged { videoStageSize = it } + .background(Color(0xFF122A39), RoundedCornerShape(28.dp)) + ) { + if (renderState?.eglBaseContext != null && renderState.remoteVideoTrack != null) { + CallVideoSurface( + eglBaseContext = renderState.eglBaseContext, + track = renderState.remoteVideoTrack, + isMirrored = false, + isOverlay = false, + modifier = Modifier.fillMaxSize() + ) + } else { + VideoPlaceholder( + text = buildStageText(state), + modifier = Modifier.fillMaxSize() + ) + } + + if (renderState?.eglBaseContext != null && renderState.localVideoTrack != null) { + CallVideoSurface( + eglBaseContext = renderState.eglBaseContext, + track = renderState.localVideoTrack, + isMirrored = true, + isOverlay = true, + modifier = Modifier + .zIndex(1f) + .offset { localPreviewOffset } + .onSizeChanged { localPreviewSize = it } + .pointerInput(state.callId, videoStageSize, localPreviewSize) { + detectDragGesturesAfterLongPress( + onDrag = { change, dragAmount -> + change.consume() + isLocalPreviewPositionInitialized = true + localPreviewOffset = clampLocalPreviewOffset( + IntOffset( + x = localPreviewOffset.x + dragAmount.x.roundToInt(), + y = localPreviewOffset.y + dragAmount.y.roundToInt() + ) + ) + } + ) + } + .fillMaxWidth(0.32f) + .aspectRatio(0.75f) + ) + } + } + } else { + Box( + modifier = Modifier + .size(220.dp) + .background(Color(0xFF122A39), CircleShape), + contentAlignment = Alignment.Center + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(10.dp) + ) { + Icon( + imageVector = Icons.Outlined.Call, + contentDescription = null, + tint = Color.White, + modifier = Modifier.size(88.dp) + ) + Text( + text = buildStageText(state), + color = Color.White, + style = MaterialTheme.typography.titleMedium + ) + } + } + } + } + + Column( + modifier = Modifier + .fillMaxWidth() + .navigationBarsPadding(), + verticalArrangement = Arrangement.spacedBy(14.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + if (state.stage == AudioCallStage.Incoming) { + IncomingCallSwipeControl( + onAccept = handleAccept, + onDecline = onDecline + ) + Text( + text = "Сдвиньте трубку вправо, чтобы принять, или влево, чтобы отклонить", + color = Color(0xFFB2C9D6), + style = MaterialTheme.typography.bodySmall + ) + } else { + CallActionButton( + text = if (state.stage == AudioCallStage.Active) "Завершить" else "Отменить", + icon = if (state.stage == AudioCallStage.Active) Icons.Outlined.CallEnd else Icons.Outlined.Close, + containerColor = Color(0xFFBE2D38), + onClick = onEnd + ) + } + } + } + } +} + +@Composable +private fun IncomingCallSwipeControl( + onAccept: () -> Unit, + onDecline: () -> Unit, + modifier: Modifier = Modifier +) { + val scope = rememberCoroutineScope() + var trackWidthPx by remember { mutableStateOf(0f) } + var handleWidthPx by remember { mutableStateOf(0f) } + val dragOffsetPx = remember { androidx.compose.animation.core.Animatable(0f) } + var isCompleting by remember { mutableStateOf(false) } + + val maxTravelPx = ((trackWidthPx - handleWidthPx) / 2f).coerceAtLeast(0f) + val triggerThresholdPx = maxTravelPx * 0.62f + + Box( + modifier = modifier + .fillMaxWidth() + .height(96.dp) + .clip(RoundedCornerShape(48.dp)) + .background(Color(0xFF102537)) + .border(1.dp, Color(0xFF1A425B), RoundedCornerShape(48.dp)) + .onSizeChanged { trackWidthPx = it.width.toFloat() } + ) { + Row( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 22.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + SlideActionHint( + icon = Icons.Outlined.Close, + label = "Отклонить", + color = Color(0xFFFF7A7A), + alignEnd = false + ) + SlideActionHint( + icon = Icons.Outlined.Call, + label = "Принять", + color = Color(0xFF75E2A0), + alignEnd = true + ) + } + + Box( + modifier = Modifier + .align(Alignment.Center) + .offset { IntOffset(dragOffsetPx.value.roundToInt(), 0) } + .size(76.dp) + .onSizeChanged { handleWidthPx = it.width.toFloat() } + .clip(CircleShape) + .background(Color.White) + .pointerInput(maxTravelPx, isCompleting) { + if (isCompleting) { + return@pointerInput + } + + detectDragGestures( + onDragStart = { + scope.launch { dragOffsetPx.stop() } + }, + onDragCancel = { + scope.launch { dragOffsetPx.animateTo(0f) } + }, + onDragEnd = { + if (isCompleting) { + return@detectDragGestures + } + + val currentOffset = dragOffsetPx.value + when { + currentOffset >= triggerThresholdPx -> { + isCompleting = true + scope.launch { + dragOffsetPx.animateTo(maxTravelPx) + onAccept() + } + } + + currentOffset <= -triggerThresholdPx -> { + isCompleting = true + scope.launch { + dragOffsetPx.animateTo(-maxTravelPx) + onDecline() + } + } + + else -> { + scope.launch { dragOffsetPx.animateTo(0f) } + } + } + }, + onDrag = { change, dragAmount -> + if (isCompleting) { + return@detectDragGestures + } + + change.consume() + val nextOffset = (dragOffsetPx.value + dragAmount.x) + .coerceIn(-maxTravelPx, maxTravelPx) + scope.launch { dragOffsetPx.snapTo(nextOffset) } + } + ) + }, + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = Icons.Outlined.Call, + contentDescription = null, + tint = if (dragOffsetPx.value >= 0f) Color(0xFF1B7F49) else Color(0xFFAF2330), + modifier = Modifier.size(34.dp) + ) + } + } +} + +@Composable +private fun SlideActionHint( + icon: androidx.compose.ui.graphics.vector.ImageVector, + label: String, + color: Color, + alignEnd: Boolean +) { + Column( + horizontalAlignment = if (alignEnd) Alignment.End else Alignment.Start, + verticalArrangement = Arrangement.spacedBy(6.dp) + ) { + Icon( + imageVector = icon, + contentDescription = null, + tint = color, + modifier = Modifier.size(22.dp) + ) + Text( + text = label, + color = color, + style = MaterialTheme.typography.labelLarge + ) + } +} + +@Composable +private fun CallActionButton( + text: String, + icon: androidx.compose.ui.graphics.vector.ImageVector, + containerColor: Color, + onClick: () -> Unit +) { + Button( + onClick = onClick, + colors = ButtonDefaults.buttonColors(containerColor = containerColor), + shape = RoundedCornerShape(26.dp), + modifier = Modifier.fillMaxWidth() + ) { + Icon( + imageVector = icon, + contentDescription = null, + modifier = Modifier.padding(end = 10.dp) + ) + Text(text) + } +} + +@Composable +private fun VideoPlaceholder( + text: String, + modifier: Modifier = Modifier +) { + Column( + modifier = modifier, + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Icon( + imageVector = Icons.Outlined.Videocam, + contentDescription = null, + tint = Color.White, + modifier = Modifier.size(72.dp) + ) + Text( + text = text, + color = Color.White, + style = MaterialTheme.typography.titleMedium + ) + } +} + +@Composable +private fun CallVideoSurface( + eglBaseContext: org.webrtc.EglBase.Context, + track: VideoTrack, + isMirrored: Boolean, + isOverlay: Boolean, + modifier: Modifier = Modifier +) { + val context = LocalContext.current + val currentTrack by rememberUpdatedState(track) + var renderer by remember { mutableStateOf(null) } + + AndroidView( + modifier = modifier, + factory = { viewContext -> + SurfaceViewRenderer(viewContext).apply { + init(eglBaseContext, null) + setEnableHardwareScaler(true) + setMirror(isMirrored) + setZOrderMediaOverlay(isOverlay) + setBackgroundColor(android.graphics.Color.TRANSPARENT) + renderer = this + } + }, + update = { view -> + view.setMirror(isMirrored) + view.setZOrderMediaOverlay(isOverlay) + if (renderer !== view) { + renderer = view + } + } + ) + + DisposableEffect(renderer, currentTrack) { + val activeRenderer = renderer + if (activeRenderer != null) { + currentTrack.addSink(activeRenderer) + } + + onDispose { + if (activeRenderer != null) { + currentTrack.removeSink(activeRenderer) + } + } + } + + DisposableEffect(context) { + onDispose { + renderer?.release() + renderer = null + } + } +} + +private fun buildPrimaryStatus(state: AudioCallSnapshot): String = + state.peer.phoneNumber ?: if (state.mediaType == CallMediaType.Video) "Ikar Video" else "Ikar Audio" + +private fun buildStageText(state: AudioCallSnapshot): String = when (state.stage) { + AudioCallStage.Incoming -> if (state.mediaType == CallMediaType.Video) "Входящий видеозвонок" else "Входящий аудиозвонок" + AudioCallStage.Outgoing -> "Идёт дозвон..." + AudioCallStage.Connecting -> "Соединение..." + AudioCallStage.Active -> if (state.mediaType == CallMediaType.Video) "Видеозвонок ${state.durationText}" else "Разговор ${state.durationText}" + AudioCallStage.None -> "Звонок" +} diff --git a/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/call/IncomingCallActivity.kt b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/call/IncomingCallActivity.kt new file mode 100644 index 00000000..fe852b48 --- /dev/null +++ b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/call/IncomingCallActivity.kt @@ -0,0 +1,192 @@ +package com.seven.ikar.kotlin.feature.call + +import android.Manifest +import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager +import android.graphics.Color +import android.os.Build +import android.os.Bundle +import android.view.WindowManager +import androidx.activity.ComponentActivity +import androidx.activity.result.contract.ActivityResultContracts +import androidx.activity.compose.setContent +import androidx.core.view.WindowCompat +import androidx.core.view.WindowInsetsControllerCompat +import androidx.core.content.ContextCompat +import androidx.compose.runtime.LaunchedEffect +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.seven.ikar.kotlin.app.IkarNativeApp +import com.seven.ikar.kotlin.core.model.CallMediaType +import com.seven.ikar.kotlin.core.push.AndroidAppVisibilityState +import com.seven.ikar.kotlin.core.push.PendingIncomingCallInvite +import com.seven.ikar.kotlin.core.push.applyToIntent +import com.seven.ikar.kotlin.core.push.pendingIncomingCallInviteFromIntent +import com.seven.ikar.kotlin.ui.theme.IkarKotlinTheme + +class IncomingCallActivity : ComponentActivity() { + private val applicationContainer + get() = (application as IkarNativeApp).container + private var shouldAutoAccept: Boolean = false + private val incomingCallPermissionsLauncher = registerForActivityResult( + ActivityResultContracts.RequestMultiplePermissions() + ) { + if (hasPermissionsForCurrentCall()) { + applicationContainer.callManager.acceptCurrentCall() + } + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + configureWindow() + syncCallFromIntent(intent) + syncAutoAcceptFromIntent(intent) + setContent { + IkarKotlinTheme { + val activeCall = applicationContainer.callManager.currentCall.collectAsStateWithLifecycle().value + LaunchedEffect(activeCall?.callId, activeCall?.stage) { + if (activeCall == null) { + finish() + } + } + if (activeCall != null) { + CallScreen( + state = activeCall, + onAccept = applicationContainer.callManager::acceptCurrentCall, + onDecline = applicationContainer.callManager::declineCurrentCall, + onEnd = applicationContainer.callManager::endCurrentCall + ) + } + } + } + } + + override fun onNewIntent(intent: Intent) { + super.onNewIntent(intent) + setIntent(intent) + syncCallFromIntent(intent) + syncAutoAcceptFromIntent(intent) + } + + override fun onStart() { + super.onStart() + AndroidAppVisibilityState.setVisible(true) + if (shouldAutoAccept) { + shouldAutoAccept = false + window.decorView.post { + acceptCurrentCallWithPermissions() + } + } + } + + override fun onStop() { + AndroidAppVisibilityState.setVisible(false) + super.onStop() + } + + private fun configureWindow() { + WindowCompat.setDecorFitsSystemWindows(window, false) + window.statusBarColor = Color.TRANSPARENT + window.navigationBarColor = Color.TRANSPARENT + window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1) { + setShowWhenLocked(true) + setTurnScreenOn(true) + } else { + @Suppress("DEPRECATION") + window.addFlags( + WindowManager.LayoutParams.FLAG_SHOW_WHEN_LOCKED or + WindowManager.LayoutParams.FLAG_TURN_SCREEN_ON + ) + } + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + window.isNavigationBarContrastEnforced = false + window.isStatusBarContrastEnforced = false + } + + WindowInsetsControllerCompat(window, window.decorView).apply { + isAppearanceLightStatusBars = false + isAppearanceLightNavigationBars = false + } + } + + private fun syncCallFromIntent(intent: Intent?) { + val invite = intent?.let(::pendingIncomingCallInviteFromIntent) ?: return + if (applicationContainer.sessionStore.read() == null) { + return + } + + applicationContainer.callManager.presentIncomingCallInvite( + callId = invite.callId, + chatId = invite.chatId, + callerId = invite.callerId, + callerDisplayName = invite.callerDisplayName, + callerPhoneNumber = invite.callerPhoneNumber, + mediaType = invite.mediaType + ) + } + + private fun syncAutoAcceptFromIntent(intent: Intent?) { + if (intent?.getBooleanExtra(AutoAcceptExtra, false) != true) { + return + } + + intent.putExtra(AutoAcceptExtra, false) + shouldAutoAccept = true + } + + private fun acceptCurrentCallWithPermissions() { + val requiredPermissions = buildList { + if (ContextCompat.checkSelfPermission(this@IncomingCallActivity, Manifest.permission.RECORD_AUDIO) != PackageManager.PERMISSION_GRANTED) { + add(Manifest.permission.RECORD_AUDIO) + } + if ( + applicationContainer.callManager.currentCall.value?.mediaType == CallMediaType.Video && + ContextCompat.checkSelfPermission(this@IncomingCallActivity, Manifest.permission.CAMERA) != PackageManager.PERMISSION_GRANTED + ) { + add(Manifest.permission.CAMERA) + } + } + + if (requiredPermissions.isEmpty()) { + applicationContainer.callManager.acceptCurrentCall() + } else { + incomingCallPermissionsLauncher.launch(requiredPermissions.toTypedArray()) + } + } + + private fun hasPermissionsForCurrentCall(): Boolean { + val hasMicrophone = ContextCompat.checkSelfPermission( + this, + Manifest.permission.RECORD_AUDIO + ) == PackageManager.PERMISSION_GRANTED + val hasCamera = applicationContainer.callManager.currentCall.value?.mediaType != CallMediaType.Video || + ContextCompat.checkSelfPermission( + this, + Manifest.permission.CAMERA + ) == PackageManager.PERMISSION_GRANTED + return hasMicrophone && hasCamera + } + + companion object { + private const val AutoAcceptExtra = "autoAcceptIncomingCall" + + fun createLaunchIntent( + context: Context, + invite: PendingIncomingCallInvite, + autoAccept: Boolean = false + ): Intent = + invite.applyToIntent( + Intent(context, IncomingCallActivity::class.java).apply { + addFlags( + Intent.FLAG_ACTIVITY_NEW_TASK or + Intent.FLAG_ACTIVITY_SINGLE_TOP or + Intent.FLAG_ACTIVITY_CLEAR_TOP + ) + putExtra(AutoAcceptExtra, autoAccept) + } + ) + } +} diff --git a/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/chat/AndroidAttachmentFileService.kt b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/chat/AndroidAttachmentFileService.kt new file mode 100644 index 00000000..56ed8103 --- /dev/null +++ b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/chat/AndroidAttachmentFileService.kt @@ -0,0 +1,301 @@ +package com.seven.ikar.kotlin.feature.chat + +import android.content.ActivityNotFoundException +import android.content.ContentValues +import android.content.Context +import android.content.Intent +import android.net.Uri +import android.os.Build +import android.os.Environment +import android.provider.MediaStore +import android.provider.OpenableColumns +import androidx.core.content.FileProvider +import com.seven.ikar.kotlin.core.model.DownloadedAttachmentFile +import com.seven.ikar.kotlin.core.network.UploadFileSource +import com.seven.ikar.kotlin.feature.archive.AndroidArchiveFileService +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import java.io.File +import java.io.IOException +import java.io.InputStream + +data class LocalAttachmentFile( + val fileName: String, + val contentType: String, + val fileSizeBytes: Long, + val localPath: String +) + +data class CacheCleanupResult( + val deletedFiles: Int, + val deletedBytes: Long +) + +internal fun LocalAttachmentFile.toUploadFileSource(): UploadFileSource = + UploadFileSource( + fileName = fileName, + contentType = contentType, + contentLength = fileSizeBytes + ) { File(localPath).inputStream() } + +class AndroidAttachmentFileService( + private val context: Context +) { + private val maxAttachmentSizeBytes = 25L * 1024L * 1024L + private val attachmentsDirectory: File by lazy { + File(context.cacheDir, "attachments").apply { mkdirs() } + } + + suspend fun importFromUri(uri: Uri): LocalAttachmentFile = withContext(Dispatchers.IO) { + val resolver = context.contentResolver + val contentType = resolver.getType(uri) ?: "application/octet-stream" + val fileName = sanitizeFileName(queryDisplayName(uri) ?: "attachment") + val declaredSize = queryFileSize(uri) + if (declaredSize != null && declaredSize > maxAttachmentSizeBytes) { + throw IOException("File must be 25 MB or smaller.") + } + + val target = File(attachmentsDirectory, "pending_${System.currentTimeMillis()}_$fileName") + val copiedBytes = try { + resolver.openInputStream(uri)?.use { input -> copyToFileWithLimit(input, target) } + ?: throw IOException("Failed to read selected file.") + } catch (error: Exception) { + target.delete() + throw error + } + + LocalAttachmentFile( + fileName = fileName, + contentType = contentType, + fileSizeBytes = copiedBytes, + localPath = target.absolutePath + ) + } + + fun createAttachmentCacheFile(attachmentId: String, fileName: String): File = + File(attachmentsDirectory, "${attachmentId}_${sanitizeFileName(fileName)}") + + suspend fun cacheDownloadedAttachment(file: DownloadedAttachmentFile): LocalAttachmentFile = + withContext(Dispatchers.IO) { + val target = File(file.localPath) + target.setLastModified(System.currentTimeMillis()) + LocalAttachmentFile( + fileName = target.name.substringAfter('_', file.fileName), + contentType = file.contentType, + fileSizeBytes = file.fileSizeBytes.takeIf { it >= 0L } ?: target.length(), + localPath = target.absolutePath + ) + } + + suspend fun enforceCachePolicy(maxCacheMb: Int, keepMediaDays: Int): CacheCleanupResult = + withContext(Dispatchers.IO) { + val files = attachmentsDirectory + .walkTopDown() + .filter { it.isFile } + .filterNot { it.name.startsWith("pending_") } + .toList() + val cutoffMillis = System.currentTimeMillis() - keepMediaDays.coerceAtLeast(1) * 24L * 60L * 60L * 1000L + var deletedFiles = 0 + var deletedBytes = 0L + + val retainedAfterAge = files.filter { file -> + if (file.lastModified() in 1 until cutoffMillis) { + val size = file.length() + if (file.delete()) { + deletedFiles += 1 + deletedBytes += size + } + false + } else { + true + } + }.toMutableList() + + val maxBytes = maxCacheMb.coerceAtLeast(1) * 1024L * 1024L + var totalBytes = retainedAfterAge.sumOf { it.length() } + if (totalBytes > maxBytes) { + retainedAfterAge + .sortedWith(compareBy { it.lastModified().takeIf { value -> value > 0L } ?: Long.MAX_VALUE }.thenBy { it.name }) + .forEach { file -> + if (totalBytes <= maxBytes) { + return@forEach + } + val size = file.length() + if (file.delete()) { + totalBytes -= size + deletedFiles += 1 + deletedBytes += size + } + } + } + + CacheCleanupResult(deletedFiles = deletedFiles, deletedBytes = deletedBytes) + } + + fun openCachedAttachment(localPath: String, fileName: String, contentType: String) { + val file = requireExistingFile(localPath) + val contentUri = contentUriFor(file) + + if (AndroidArchiveFileService.isSupportedZip(fileName, contentType)) { + openArchiveInternally(contentUri, fileName, contentType) + return + } + + val intent = Intent(Intent.ACTION_VIEW).apply { + setDataAndType(contentUri, contentType) + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + clipData = android.content.ClipData.newRawUri(fileName, contentUri) + } + + try { + context.startActivity(intent) + } catch (error: ActivityNotFoundException) { + throw IllegalStateException("No app found to open this attachment.") + } + } + + private fun openArchiveInternally(contentUri: Uri, fileName: String, contentType: String) { + val intent = Intent(Intent.ACTION_VIEW).apply { + setClassName(context.packageName, "${context.packageName}.MainActivity") + setDataAndType(contentUri, contentType.ifBlank { "application/zip" }) + putExtra(Intent.EXTRA_TITLE, fileName) + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP) + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + clipData = android.content.ClipData.newRawUri(fileName, contentUri) + } + + try { + context.startActivity(intent) + } catch (error: ActivityNotFoundException) { + throw IllegalStateException("Ikar could not open this ZIP archive.") + } + } + + fun shareCachedAttachment(localPath: String, fileName: String, contentType: String) { + val file = requireExistingFile(localPath) + val contentUri = contentUriFor(file) + + val chooserIntent = Intent.createChooser( + Intent(Intent.ACTION_SEND).apply { + type = contentType + putExtra(Intent.EXTRA_STREAM, contentUri) + putExtra(Intent.EXTRA_TITLE, fileName) + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + clipData = android.content.ClipData.newRawUri(fileName, contentUri) + }, + "Share" + ).apply { + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + } + + try { + context.startActivity(chooserIntent) + } catch (error: ActivityNotFoundException) { + throw IllegalStateException("No app found to share this attachment.") + } + } + + suspend fun saveCachedImageToGallery(localPath: String, fileName: String, contentType: String): Uri = + withContext(Dispatchers.IO) { + val file = requireExistingFile(localPath) + val resolver = context.contentResolver + val sanitizedFileName = sanitizeFileName(fileName) + val values = ContentValues().apply { + put(MediaStore.Images.Media.DISPLAY_NAME, sanitizedFileName) + put(MediaStore.Images.Media.MIME_TYPE, contentType) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + put(MediaStore.Images.Media.RELATIVE_PATH, Environment.DIRECTORY_PICTURES + "/Ikar") + put(MediaStore.Images.Media.IS_PENDING, 1) + } + } + + val uri = resolver.insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, values) + ?: throw IOException("Failed to create gallery entry.") + + try { + resolver.openOutputStream(uri)?.use { output -> + file.inputStream().use { input -> input.copyTo(output) } + } ?: throw IOException("Failed to open gallery output stream.") + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + val publishedValues = ContentValues().apply { + put(MediaStore.Images.Media.IS_PENDING, 0) + } + resolver.update(uri, publishedValues, null, null) + } + + uri + } catch (error: Exception) { + resolver.delete(uri, null, null) + throw error + } + } + + private fun requireExistingFile(localPath: String): File { + val file = File(localPath) + if (!file.exists()) { + throw IOException("Cached file not found.") + } + file.setLastModified(System.currentTimeMillis()) + return file + } + + private fun contentUriFor(file: File): Uri = + FileProvider.getUriForFile( + context, + "${context.packageName}.fileprovider", + file + ) + + private fun queryDisplayName(uri: Uri): String? { + val resolver = context.contentResolver + resolver.query(uri, arrayOf(OpenableColumns.DISPLAY_NAME), null, null, null)?.use { cursor -> + val nameIndex = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME) + if (nameIndex >= 0 && cursor.moveToFirst()) { + return cursor.getString(nameIndex) + } + } + + return uri.lastPathSegment + } + + private fun queryFileSize(uri: Uri): Long? { + val resolver = context.contentResolver + resolver.query(uri, arrayOf(OpenableColumns.SIZE), null, null, null)?.use { cursor -> + val sizeIndex = cursor.getColumnIndex(OpenableColumns.SIZE) + if (sizeIndex >= 0 && cursor.moveToFirst() && !cursor.isNull(sizeIndex)) { + return cursor.getLong(sizeIndex).takeIf { it >= 0L } + } + } + + return null + } + + private fun copyToFileWithLimit(input: InputStream, target: File): Long { + val buffer = ByteArray(DEFAULT_BUFFER_SIZE) + var totalBytes = 0L + + target.outputStream().use { output -> + while (true) { + val read = input.read(buffer) + if (read < 0) { + return totalBytes + } + + totalBytes += read + if (totalBytes > maxAttachmentSizeBytes) { + throw IOException("File must be 25 MB or smaller.") + } + + output.write(buffer, 0, read) + } + } + } + + private fun sanitizeFileName(value: String): String = + value.trim().ifBlank { "attachment" }.replace(Regex("[^A-Za-z0-9._-]"), "_") +} diff --git a/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/chat/AndroidVoiceNotePlayerService.kt b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/chat/AndroidVoiceNotePlayerService.kt new file mode 100644 index 00000000..de3bb8cd --- /dev/null +++ b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/chat/AndroidVoiceNotePlayerService.kt @@ -0,0 +1,150 @@ +package com.seven.ikar.kotlin.feature.chat + +import android.media.MediaPlayer +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.launch +import java.util.concurrent.Semaphore + +data class VoiceNotePlaybackEvent( + val attachmentId: String, + val isPlaying: Boolean, + val positionMs: Long, + val durationMs: Long +) + +class AndroidVoiceNotePlayerService { + private val sync = Semaphore(1) + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + private val _events = MutableSharedFlow(extraBufferCapacity = 32) + val events: SharedFlow = _events.asSharedFlow() + + private var player: MediaPlayer? = null + private var currentAttachmentId: String? = null + private var durationMs: Long = 0L + private var tickerJob: Job? = null + + suspend fun toggle(attachmentId: String, localFilePath: String) { + sync.acquire() + try { + if (player != null && currentAttachmentId == attachmentId) { + stopCurrent() + return + } + + stopCurrent() + + val nextPlayer = MediaPlayer().apply { + setDataSource(localFilePath) + prepare() + } + + player = nextPlayer + currentAttachmentId = attachmentId + durationMs = nextPlayer.duration.toLong().coerceAtLeast(0L) + + nextPlayer.setOnCompletionListener { + scope.launch { stopCurrent(playbackCompleted = true) } + } + nextPlayer.start() + startTicker() + emitEvent(attachmentId, true, 0L, durationMs) + } finally { + sync.release() + } + } + + suspend fun stop() { + sync.acquire() + try { + stopCurrent() + } finally { + sync.release() + } + } + + private suspend fun stopCurrent(playbackCompleted: Boolean = false) { + val existingPlayer = player + val attachmentId = currentAttachmentId + val completedDuration = durationMs + val finalPosition = if (playbackCompleted) completedDuration else safePosition(existingPlayer) + + player = null + currentAttachmentId = null + durationMs = 0L + stopTicker() + + existingPlayer?.let { mediaPlayer -> + try { + if (safeIsPlaying(mediaPlayer)) { + mediaPlayer.stop() + } + } catch (_: Throwable) { + } + + mediaPlayer.reset() + mediaPlayer.release() + } + + if (attachmentId != null) { + emitEvent(attachmentId, false, finalPosition, completedDuration) + } + } + + private fun startTicker() { + stopTicker() + tickerJob = scope.launch { + try { + while (true) { + delay(250) + val activePlayer = player ?: break + val attachmentId = currentAttachmentId ?: break + emitEvent( + attachmentId = attachmentId, + isPlaying = safeIsPlaying(activePlayer), + positionMs = safePosition(activePlayer), + durationMs = durationMs + ) + } + } catch (_: CancellationException) { + } + } + } + + private fun stopTicker() { + tickerJob?.cancel() + tickerJob = null + } + + private fun emitEvent(attachmentId: String, isPlaying: Boolean, positionMs: Long, durationMs: Long) { + _events.tryEmit( + VoiceNotePlaybackEvent( + attachmentId = attachmentId, + isPlaying = isPlaying, + positionMs = positionMs.coerceAtLeast(0L), + durationMs = durationMs.coerceAtLeast(0L) + ) + ) + } + + private fun safeIsPlaying(mediaPlayer: MediaPlayer?): Boolean = + try { + mediaPlayer?.isPlaying == true + } catch (_: Throwable) { + false + } + + private fun safePosition(mediaPlayer: MediaPlayer?): Long = + try { + mediaPlayer?.currentPosition?.toLong()?.coerceAtLeast(0L) ?: 0L + } catch (_: Throwable) { + 0L + } +} diff --git a/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/chat/AndroidVoiceNoteRecorderService.kt b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/chat/AndroidVoiceNoteRecorderService.kt new file mode 100644 index 00000000..4d6344c6 --- /dev/null +++ b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/chat/AndroidVoiceNoteRecorderService.kt @@ -0,0 +1,148 @@ +package com.seven.ikar.kotlin.feature.chat + +import android.content.Context +import android.media.MediaRecorder +import android.os.Build +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import java.io.File +import java.util.concurrent.Semaphore + +class AndroidVoiceNoteRecorderService( + private val context: Context +) { + private val sync = Semaphore(1) + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + private val _elapsedMs = MutableStateFlow(0L) + val elapsedMs: StateFlow = _elapsedMs.asStateFlow() + + private var recorder: MediaRecorder? = null + private var currentFile: File? = null + private var startedAtMs: Long = 0L + private var tickerJob: Job? = null + + val isRecording: Boolean + get() = recorder != null + + suspend fun start() { + sync.acquire() + try { + if (recorder != null) { + return + } + + val directory = File(context.cacheDir, "voice-notes").apply { mkdirs() } + val file = File(directory, "voice-${System.currentTimeMillis()}.m4a") + val nextRecorder = if (Build.VERSION.SDK_INT >= 31) { + MediaRecorder(context) + } else { + MediaRecorder() + } + + try { + nextRecorder.setAudioSource(MediaRecorder.AudioSource.MIC) + nextRecorder.setOutputFormat(MediaRecorder.OutputFormat.MPEG_4) + nextRecorder.setAudioEncoder(MediaRecorder.AudioEncoder.AAC) + nextRecorder.setAudioSamplingRate(44_100) + nextRecorder.setAudioEncodingBitRate(128_000) + nextRecorder.setOutputFile(file.absolutePath) + nextRecorder.prepare() + nextRecorder.start() + } catch (error: Throwable) { + nextRecorder.reset() + nextRecorder.release() + file.delete() + throw error + } + + recorder = nextRecorder + currentFile = file + startedAtMs = System.currentTimeMillis() + _elapsedMs.value = 0L + startTicker() + } finally { + sync.release() + } + } + + suspend fun stop(discard: Boolean): File? { + sync.acquire() + val currentRecorder = recorder + val outputFile = currentFile + val elapsedMs = _elapsedMs.value + recorder = null + currentFile = null + startedAtMs = 0L + sync.release() + + stopTicker() + + if (currentRecorder == null) { + _elapsedMs.value = 0L + return null + } + + var shouldDelete = discard + try { + currentRecorder.stop() + } catch (_: Throwable) { + shouldDelete = true + } finally { + currentRecorder.reset() + currentRecorder.release() + } + + _elapsedMs.value = 0L + + if (outputFile == null || !outputFile.exists()) { + return null + } + + if (shouldDelete) { + outputFile.delete() + return null + } + + if (outputFile.length() <= 0) { + outputFile.delete() + return null + } + + if (elapsedMs < 600L) { + outputFile.delete() + throw IllegalStateException("Голосовое сообщение слишком короткое.") + } + + return outputFile + } + + private fun startTicker() { + stopTicker() + tickerJob = scope.launch { + try { + while (true) { + delay(250) + if (recorder == null) { + break + } + + _elapsedMs.value = (System.currentTimeMillis() - startedAtMs).coerceAtLeast(0L) + } + } catch (_: CancellationException) { + } + } + } + + private fun stopTicker() { + tickerJob?.cancel() + tickerJob = null + } +} diff --git a/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/chat/ChatScreen.kt b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/chat/ChatScreen.kt new file mode 100644 index 00000000..eec1dee8 --- /dev/null +++ b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/chat/ChatScreen.kt @@ -0,0 +1,3723 @@ +package com.seven.ikar.kotlin.feature.chat + +import android.Manifest +import android.content.ActivityNotFoundException +import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager +import android.net.Uri +import android.util.Patterns +import androidx.activity.compose.BackHandler +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.tween +import androidx.compose.animation.expandVertically +import androidx.compose.animation.shrinkVertically +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.gestures.detectHorizontalDragGestures +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.gestures.detectTransformGestures +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.ime +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.navigationBars +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.statusBars +import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.windowInsetsTopHeight +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.lazy.grid.items +import androidx.compose.foundation.lazy.grid.rememberLazyGridState +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.outlined.ArrowBack +import androidx.compose.material.icons.automirrored.outlined.Send +import androidx.compose.material.icons.outlined.AttachFile +import androidx.compose.material.icons.outlined.ArrowDownward +import androidx.compose.material.icons.outlined.Call +import androidx.compose.material.icons.outlined.CheckCircle +import androidx.compose.material.icons.outlined.Close +import androidx.compose.material.icons.outlined.ContentCopy +import androidx.compose.material.icons.outlined.Delete +import androidx.compose.material.icons.outlined.Edit +import androidx.compose.material.icons.outlined.Image +import androidx.compose.material.icons.outlined.KeyboardArrowDown +import androidx.compose.material.icons.outlined.KeyboardArrowUp +import androidx.compose.material.icons.outlined.Menu +import androidx.compose.material.icons.outlined.Mic +import androidx.compose.material.icons.outlined.Notifications +import androidx.compose.material.icons.outlined.PlayArrow +import androidx.compose.material.icons.outlined.Mood +import androidx.compose.material.icons.outlined.PushPin +import androidx.compose.material.icons.outlined.Search +import androidx.compose.material.icons.outlined.Stop +import androidx.compose.material.icons.outlined.Videocam +import androidx.compose.material.icons.outlined.Visibility +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.TextLayoutResult +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.KeyboardCapitalization +import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import androidx.core.content.ContextCompat +import com.seven.ikar.kotlin.core.model.AttachmentDto +import com.seven.ikar.kotlin.core.model.AttachmentKind +import com.seven.ikar.kotlin.core.model.BotCommandDto +import com.seven.ikar.kotlin.core.model.ChatType +import com.seven.ikar.kotlin.core.model.MessageDeliveryState +import com.seven.ikar.kotlin.core.model.MessageDto +import com.seven.ikar.kotlin.core.model.MessageReactionDto +import com.seven.ikar.kotlin.core.network.ServerConfig +import com.seven.ikar.kotlin.core.push.IncomingCallFullScreenSetupHelper +import com.seven.ikar.kotlin.core.push.IncomingCallFullScreenSetupResult +import com.seven.ikar.kotlin.core.settings.ChatSettingsSnapshot +import com.seven.ikar.kotlin.ui.AuthenticatedAsyncImage +import com.seven.ikar.kotlin.ui.AvatarView +import com.seven.ikar.kotlin.ui.ChatWallpaperBackground +import com.seven.ikar.kotlin.ui.IkarHeaderIconBubble +import com.seven.ikar.kotlin.ui.SecureWindowEffect +import com.seven.ikar.kotlin.ui.theme.TelegramBlue +import com.seven.ikar.kotlin.ui.theme.TelegramDanger +import com.seven.ikar.kotlin.ui.theme.TelegramInk +import com.seven.ikar.kotlin.ui.theme.TelegramInkMuted +import com.seven.ikar.kotlin.ui.theme.TelegramSurface +import java.time.Instant +import java.time.LocalDate +import java.time.OffsetDateTime +import java.time.ZoneId +import java.time.format.DateTimeFormatter +import java.util.Locale +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.launch + +private sealed interface ChatRow { + data class DateSeparator(val id: String, val text: String) : ChatRow + data class Message(val item: MessageDto) : ChatRow +} + +private enum class LocalMessageStatus { + Sending, + Failed +} + +private fun ChatUiState.localMessageStatus(messageId: String): LocalMessageStatus? = when { + sendingLocalMessageIds.contains(messageId) -> LocalMessageStatus.Sending + failedLocalMessageIds.contains(messageId) -> LocalMessageStatus.Failed + else -> null +} + +private data class ChatImagePreviewEntry( + val message: MessageDto, + val attachment: AttachmentDto +) + +private data class EmojiGridItem( + val index: Int, + val categoryKey: String, + val categoryIndex: Int, + val emoji: String +) + +private val ChatTimeFormatter = DateTimeFormatter.ofPattern("HH:mm", Locale("ru", "RU")) +private val ChatDayFormatter = DateTimeFormatter.ofPattern("d MMMM", Locale("ru", "RU")) +private val ChatDayYearFormatter = DateTimeFormatter.ofPattern("d MMMM yyyy", Locale("ru", "RU")) + +private sealed interface EmojiPickerMode { + object Composer : EmojiPickerMode + data class Reaction(val message: MessageDto) : EmojiPickerMode +} + +private val EmojiKeyboardFallbackHeight = 300.dp +private val EmojiKeyboardMinHeight = 220.dp +private val EmojiKeyboardMaxHeight = 360.dp +private val EmojiGridMinCellWidth = 38.dp +private val EmojiGridCellHeight = 34.dp +private const val EmojiKeyboardAnimationMillis = 220 + +@OptIn(FlowPreview::class) +@Composable +fun ChatScreen( + state: ChatUiState, + viewModel: ChatViewModel, + chatSettings: ChatSettingsSnapshot, + onBackClick: () -> Unit, + onPeerProfileClick: (() -> Unit)? = null, + onChatNotificationsClick: () -> Unit, + onChatWallpaperClick: () -> Unit, + onForwardMessage: (MessageDto) -> Unit +) { + val clipboard = LocalClipboardManager.current + val context = LocalContext.current + val listState = rememberLazyListState() + val coroutineScope = rememberCoroutineScope() + val rows = remember(state.messages) { buildChatRows(state.messages) } + val latestMessageId = remember(state.messages) { state.messages.lastOrNull()?.id } + val savedScrollPosition = remember(state.chatId) { viewModel.readSavedScrollPosition() } + val firstUnreadRowIndex = remember(rows, state.firstUnreadMessageId) { + state.firstUnreadMessageId + ?.let { unreadMessageId -> rows.indexOfFirst { row -> row is ChatRow.Message && row.item.id == unreadMessageId } } + ?.takeIf { it >= 0 } + } + val currentUserId = state.session?.user?.id + val imagePreviewEntries = remember(state.messages) { + state.messages.flatMap { message -> + message.attachments + .filter { attachment -> attachment.isImageAttachment() } + .map { attachment -> ChatImagePreviewEntry(message = message, attachment = attachment) } + } + } + val currentImagePreviewIndex = remember(state.previewImageAttachmentId, imagePreviewEntries) { + state.previewImageAttachmentId + ?.let { attachmentId -> imagePreviewEntries.indexOfFirst { it.attachment.id == attachmentId } } + ?.takeIf { it >= 0 } + } + val currentImagePreviewEntry = remember(currentImagePreviewIndex, imagePreviewEntries) { + currentImagePreviewIndex?.let(imagePreviewEntries::getOrNull) + } + val hasProtectedContentVisible = remember(state.messages, currentImagePreviewEntry) { + state.messages.any { it.hasProtectedContent && !it.isDeleted() } || + currentImagePreviewEntry?.message?.hasProtectedContent == true + } + val searchResultIds = remember(state.search.resultMessageIds) { state.search.resultMessageIds.toSet() } + val activeSearchMessageId = state.search.activeMessageId + + SecureWindowEffect(enabled = hasProtectedContentVisible) + + LaunchedEffect(state.isChatDeleted) { + if (state.isChatDeleted) { + onBackClick() + } + } + + var emojiPickerMode by remember { mutableStateOf(null) } + var displayedEmojiPickerMode by remember { mutableStateOf(null) } + var frequentEmojis by remember(context) { mutableStateOf(loadFrequentEmojis(context)) } + val emojiCategories = remember(frequentEmojis) { emojiPickerCategories(frequentEmojis) } + val density = LocalDensity.current + val keyboardController = LocalSoftwareKeyboardController.current + var rememberedKeyboardHeight by remember { mutableStateOf(EmojiKeyboardFallbackHeight) } + val currentKeyboardHeight = with(density) { + (WindowInsets.ime.getBottom(this) - WindowInsets.navigationBars.getBottom(this)) + .coerceAtLeast(0) + .toDp() + } + val emojiKeyboardHeight = rememberedKeyboardHeight.coerceIn( + EmojiKeyboardMinHeight, + EmojiKeyboardMaxHeight + ) + var showHeaderMenu by remember { mutableStateOf(false) } + var showClearHistoryConfirm by remember { mutableStateOf(false) } + var showDeleteChatConfirm by remember { mutableStateOf(false) } + var showBotCommandsDialog by remember { mutableStateOf(false) } + var showBotThreadSelection by remember { mutableStateOf(false) } + var messageMenuTarget by remember { mutableStateOf(null) } + var deleteCandidate by remember { mutableStateOf(null) } + var isInitialScrollApplied by remember(state.chatId) { mutableStateOf(false) } + var observedLatestMessageId by remember(state.chatId) { mutableStateOf(null) } + var pendingVideoCallStartAfterSetup by remember { mutableStateOf(false) } + val showScrollToLatestButton by remember(listState, rows) { + derivedStateOf { + rows.isNotEmpty() && + (listState.firstVisibleItemIndex > 0 || listState.firstVisibleItemScrollOffset > 48) + } + } + val singleSelectedMessageId = remember(state.selectedMessageIds) { + state.selectedMessageIds.singleOrNull() + } + val singleSelectedMessage = remember(singleSelectedMessageId, state.messages) { + singleSelectedMessageId?.let { selectedId -> + state.messages.firstOrNull { it.id == selectedId } + } + } + + LaunchedEffect(currentKeyboardHeight) { + if (currentKeyboardHeight > 0.dp) { + rememberedKeyboardHeight = currentKeyboardHeight + } + } + + LaunchedEffect(emojiPickerMode) { + emojiPickerMode?.let { displayedEmojiPickerMode = it } + } + + BackHandler(enabled = state.isMessageSelectionMode) { + viewModel.clearMessageSelection() + } + + BackHandler(enabled = state.search.isActive && !state.isMessageSelectionMode) { + viewModel.closeMessageSearch() + } + + BackHandler(enabled = emojiPickerMode != null) { + emojiPickerMode = null + } + + var continueVideoCallAfterIncomingCallSetup: (() -> Unit)? = null + var runVideoCallIncomingCallPreflight: (() -> Unit)? = null + var requestVideoCallFullScreenNotificationPermission: (() -> Unit)? = null + + val attachmentPicker = rememberLauncherForActivityResult(ActivityResultContracts.GetMultipleContents()) { uris -> + if (uris.isNotEmpty()) { + viewModel.consumeAttachmentUris(uris) + } + } + val microphonePermissionLauncher = rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { granted -> + if (granted) { + viewModel.beginVoiceRecording() + } else { + viewModel.setErrorMessage("Нет доступа к микрофону.") + } + } + + val audioCallPermissionLauncher = rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { granted -> + if (granted) { + viewModel.startAudioCall() + } else { + viewModel.setErrorMessage("Нет доступа к микрофону.") + } + } + val videoCallPermissionLauncher = rememberLauncherForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { permissions -> + val hasMicrophone = permissions[Manifest.permission.RECORD_AUDIO] == true + val hasCamera = permissions[Manifest.permission.CAMERA] == true + if (hasMicrophone && hasCamera) { + pendingVideoCallStartAfterSetup = false + viewModel.startVideoCall() + } else { + pendingVideoCallStartAfterSetup = false + val missing = buildList { + if (!hasMicrophone) add("микрофону") + if (!hasCamera) add("камере") + }.joinToString(" и ") + viewModel.setErrorMessage("Нет доступа к $missing.") + } + } + val fullScreenCallSetupSettingsLauncher = rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { + if (!pendingVideoCallStartAfterSetup) { + return@rememberLauncherForActivityResult + } + + when (val result = IncomingCallFullScreenSetupHelper.evaluate(context)) { + IncomingCallFullScreenSetupResult.Ready -> { + continueVideoCallAfterIncomingCallSetup?.invoke() + } + + IncomingCallFullScreenSetupResult.NeedsPostNotificationsPermission -> { + requestVideoCallFullScreenNotificationPermission?.invoke() + } + + is IncomingCallFullScreenSetupResult.NeedsSystemSettings -> { + pendingVideoCallStartAfterSetup = false + viewModel.setErrorMessage(result.detail) + } + } + } + val fullScreenCallNotificationPermissionLauncher = rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { granted -> + if (!pendingVideoCallStartAfterSetup) { + return@rememberLauncherForActivityResult + } + + if (!granted) { + pendingVideoCallStartAfterSetup = false + viewModel.setErrorMessage("Без разрешения на уведомления полноэкранный входящий звонок на заблокированном экране может не появиться.") + return@rememberLauncherForActivityResult + } + + when (val result = IncomingCallFullScreenSetupHelper.evaluate(context)) { + IncomingCallFullScreenSetupResult.Ready -> { + continueVideoCallAfterIncomingCallSetup?.invoke() + } + + IncomingCallFullScreenSetupResult.NeedsPostNotificationsPermission -> { + pendingVideoCallStartAfterSetup = false + viewModel.setErrorMessage("Android не выдал разрешение на уведомления для Икар.") + } + + is IncomingCallFullScreenSetupResult.NeedsSystemSettings -> { + viewModel.setErrorMessage(result.detail) + fullScreenCallSetupSettingsLauncher.launch(result.intent) + } + } + } + requestVideoCallFullScreenNotificationPermission = { + fullScreenCallNotificationPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS) + } + + continueVideoCallAfterIncomingCallSetup = { + val hasMicrophone = ContextCompat.checkSelfPermission(context, Manifest.permission.RECORD_AUDIO) == PackageManager.PERMISSION_GRANTED + val hasCamera = ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED + if (hasMicrophone && hasCamera) { + pendingVideoCallStartAfterSetup = false + viewModel.startVideoCall() + } else { + videoCallPermissionLauncher.launch( + arrayOf( + Manifest.permission.RECORD_AUDIO, + Manifest.permission.CAMERA + ) + ) + } + } + runVideoCallIncomingCallPreflight = { + when (val result = IncomingCallFullScreenSetupHelper.evaluate(context)) { + IncomingCallFullScreenSetupResult.Ready -> { + continueVideoCallAfterIncomingCallSetup?.invoke() + } + + IncomingCallFullScreenSetupResult.NeedsPostNotificationsPermission -> { + pendingVideoCallStartAfterSetup = true + requestVideoCallFullScreenNotificationPermission?.invoke() + } + + is IncomingCallFullScreenSetupResult.NeedsSystemSettings -> { + pendingVideoCallStartAfterSetup = true + viewModel.setErrorMessage(result.detail) + fullScreenCallSetupSettingsLauncher.launch(result.intent) + } + } + } + + LaunchedEffect( + state.chatId, + rows, + state.errorMessage, + state.chatType, + state.firstUnreadMessageId, + firstUnreadRowIndex, + state.hasMoreMessages, + state.isLoadingOlderMessages, + savedScrollPosition?.rowKey, + savedScrollPosition?.itemIndex, + savedScrollPosition?.itemScrollOffset + ) { + if (isInitialScrollApplied || state.errorMessage != null || rows.isEmpty()) { + return@LaunchedEffect + } + + val channelReadRowIndex = if (state.chatType == ChatType.Channel) { + firstUnreadRowIndex?.let { findLastReadRowIndexBeforeFirstUnread(rows, it) } + } else { + null + } + val shouldLoadChannelReadPosition = + state.chatType == ChatType.Channel && + state.firstUnreadMessageId != null && + channelReadRowIndex == null && + state.hasMoreMessages + + if (shouldLoadChannelReadPosition) { + if (!state.isLoadingOlderMessages) { + viewModel.loadOlderMessages() + } + return@LaunchedEffect + } + + val initialScrollTarget = when { + state.chatType == ChatType.Channel && firstUnreadRowIndex != null -> + (channelReadRowIndex ?: firstUnreadRowIndex) to 0 + + firstUnreadRowIndex != null -> firstUnreadRowIndex to 0 + + else -> savedScrollPosition?.let { position -> + val restoredIndex = rows.indexOfFirst { chatRowKey(it) == position.rowKey } + .takeIf { it >= 0 } + ?: position.itemIndex.coerceIn(0, rows.lastIndex) + restoredIndex to position.itemScrollOffset.coerceAtLeast(0) + } + } + + initialScrollTarget?.let { (restoredIndex, restoredOffset) -> + listState.scrollToItem( + index = restoredIndex, + scrollOffset = restoredOffset + ) + } + isInitialScrollApplied = true + } + + LaunchedEffect(state.scrollRequest?.requestId, rows, isInitialScrollApplied) { + val request = state.scrollRequest ?: return@LaunchedEffect + if (!isInitialScrollApplied || rows.isEmpty()) { + return@LaunchedEffect + } + + val targetIndex = request.messageId?.let { messageId -> + rows.indexOfFirst { row -> row is ChatRow.Message && row.item.id == messageId } + } + if (request.messageId != null && targetIndex == -1) { + return@LaunchedEffect + } + + listState.animateScrollToItem(index = targetIndex?.takeIf { it >= 0 } ?: 0) + viewModel.consumeScrollRequest(request.requestId) + } + + LaunchedEffect(state.chatId, rows, isInitialScrollApplied) { + if (!isInitialScrollApplied || rows.isEmpty()) { + return@LaunchedEffect + } + + snapshotFlow { + val visibleIndex = listState.firstVisibleItemIndex.coerceIn(0, rows.lastIndex) + SavedChatScrollPosition( + rowKey = rows.getOrNull(visibleIndex)?.let(::chatRowKey), + itemIndex = visibleIndex, + itemScrollOffset = listState.firstVisibleItemScrollOffset.coerceAtLeast(0) + ) + } + .distinctUntilChanged() + .debounce(350) + .collect(viewModel::saveScrollPosition) + } + + LaunchedEffect(state.chatId, state.chatType, latestMessageId, isInitialScrollApplied) { + if (!isInitialScrollApplied || latestMessageId == null) { + return@LaunchedEffect + } + + val previousLatestMessageId = observedLatestMessageId + observedLatestMessageId = latestMessageId + if (previousLatestMessageId == null || previousLatestMessageId == latestMessageId) { + return@LaunchedEffect + } + if (state.chatType == ChatType.Channel) { + return@LaunchedEffect + } + + listState.animateScrollToItem(index = 0) + } + + LaunchedEffect(state.chatId, state.chatType, rows, isInitialScrollApplied) { + if (!isInitialScrollApplied || state.chatType != ChatType.Channel || rows.isEmpty()) { + return@LaunchedEffect + } + + snapshotFlow { + listState.layoutInfo.visibleItemsInfo + .mapNotNull { item -> (rows.getOrNull(item.index) as? ChatRow.Message)?.item } + .filterNot { message -> message.isDeleted() } + .maxByOrNull { message -> parseMessageDate(message.sentAt)?.toInstant() ?: Instant.EPOCH } + ?.id + } + .distinctUntilChanged() + .debounce(500) + .collect { messageId -> + if (messageId != null) { + viewModel.acknowledgeRead(messageId) + } + } + } + + LaunchedEffect( + state.chatId, + rows.size, + state.hasMoreMessages, + state.isLoadingOlderMessages, + isInitialScrollApplied + ) { + if (!isInitialScrollApplied || rows.isEmpty()) { + return@LaunchedEffect + } + + snapshotFlow { + if (!state.hasMoreMessages || state.isLoadingOlderMessages || rows.isEmpty()) { + false + } else { + val olderEdgeIndex = (rows.lastIndex - 3).coerceAtLeast(0) + listState.layoutInfo.visibleItemsInfo.any { item -> item.index >= olderEdgeIndex } + } + } + .distinctUntilChanged() + .collect { shouldLoadOlder -> + if (shouldLoadOlder) { + viewModel.loadOlderMessages() + } + } + } + + LaunchedEffect(state.isMessageSelectionMode) { + if (state.isMessageSelectionMode) { + messageMenuTarget = null + emojiPickerMode = null + deleteCandidate = null + showHeaderMenu = false + } + } + + LaunchedEffect(activeSearchMessageId, rows) { + val messageId = activeSearchMessageId ?: return@LaunchedEffect + val rowIndex = rows.indexOfFirst { row -> row is ChatRow.Message && row.item.id == messageId } + if (rowIndex >= 0) { + listState.animateScrollToItem(rowIndex) + } + } + + fun handleEmojiSelected(pickerMode: EmojiPickerMode, emoji: String) { + frequentEmojis = storeFrequentEmoji(context, frequentEmojis, emoji) + when (pickerMode) { + EmojiPickerMode.Composer -> viewModel.appendEmoji(emoji) + is EmojiPickerMode.Reaction -> { + emojiPickerMode = null + val target = pickerMode.message + if (state.selectedMessageIds.contains(target.id)) { + viewModel.toggleReactionFromSelection(target, emoji) + } else { + viewModel.toggleReaction(target, emoji) + } + } + } + } + + Box(modifier = Modifier.fillMaxSize()) { + ChatWallpaperBackground( + snapshot = chatSettings, + modifier = Modifier.fillMaxSize() + ) + + Column( + modifier = Modifier.fillMaxSize() + ) { + if (state.isMessageSelectionMode) { + MessageSelectionHeader( + selectedMessagesCount = state.selectedMessagesCount, + selectedMessage = singleSelectedMessage, + currentUserId = currentUserId, + isDeleteEnabled = !state.isDeletingSelectedMessages, + onClearSelection = viewModel::clearMessageSelection, + onReplySelected = { message -> + viewModel.clearMessageSelection() + viewModel.beginReplyToMessage(message) + }, + onCopySelected = { message -> + clipboard.setText(AnnotatedString(message.text)) + viewModel.clearMessageSelection() + }, + onForwardSelected = { message -> + viewModel.clearMessageSelection() + onForwardMessage(message) + }, + onReactSelected = { message -> + keyboardController?.hide() + displayedEmojiPickerMode = EmojiPickerMode.Reaction(message) + emojiPickerMode = EmojiPickerMode.Reaction(message) + }, + onPinSelected = { message -> + viewModel.clearMessageSelection() + viewModel.togglePinMessage(message) + }, + onDeleteSelectedClick = viewModel::showDeleteSelectedMessagesDialog + ) + } else if (state.search.isActive) { + ChatSearchHeader( + search = state.search, + onQueryChanged = viewModel::updateMessageSearchQuery, + onPrevious = { viewModel.navigateMessageSearch(-1) }, + onNext = { viewModel.navigateMessageSearch(1) }, + onClose = viewModel::closeMessageSearch + ) + } else { + ChatHeader( + title = state.title.ifBlank { "Чат" }, + subtitle = state.subtitle, + avatarTitle = state.title.ifBlank { state.peer?.displayName.orEmpty() }, + avatarUrl = ServerConfig.normalizeMediaUrl(state.peer?.avatarPath), + avatarAccessToken = state.session?.accessToken, + onBackClick = onBackClick, + onAvatarClick = onPeerProfileClick, + onMenuClick = { showHeaderMenu = true } + ) + } + + state.pinnedMessages.firstOrNull()?.let { pinnedMessage -> + PinnedMessageBanner( + message = pinnedMessage, + onClick = { + val rowIndex = rows.indexOfFirst { row -> + row is ChatRow.Message && row.item.id == pinnedMessage.id + } + if (rowIndex >= 0) { + coroutineScope.launch { + listState.animateScrollToItem(rowIndex) + } + } else { + viewModel.setErrorMessage("Закреплённое сообщение находится за пределами загруженной истории. Прокрутите выше, чтобы загрузить старые сообщения.") + } + }, + onUnpinClick = { viewModel.togglePinMessage(pinnedMessage) } + ) + } + + Box( + modifier = Modifier + .weight(1f) + .fillMaxWidth() + ) { + LazyColumn( + state = listState, + reverseLayout = true, + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(start = 10.dp, top = 8.dp, end = 10.dp, bottom = 8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + items( + items = rows, + key = { row -> + when (row) { + is ChatRow.DateSeparator -> row.id + is ChatRow.Message -> row.item.id + } + } + ) { row -> + when (row) { + is ChatRow.DateSeparator -> ChatDateChip(text = row.text) + is ChatRow.Message -> { + val localStatus = state.localMessageStatus(row.item.id) + MessageBubble( + message = row.item, + isOutgoing = row.item.sender.id == currentUserId && + !(state.chatType == ChatType.Channel && row.item.postedAsChannel), + showImagesBeforeText = state.chatType == ChatType.Channel, + isSelected = state.selectedMessageIds.contains(row.item.id), + isSearchMatch = searchResultIds.contains(row.item.id), + isActiveSearchResult = activeSearchMessageId == row.item.id, + isSelectionMode = state.isMessageSelectionMode, + localStatus = localStatus, + accessToken = state.session?.accessToken, + attachmentUrlResolver = viewModel::attachmentPreviewUrl, + onClick = { + if (state.isMessageSelectionMode) { + viewModel.toggleMessageSelection(row.item) + } else if (localStatus == null) { + messageMenuTarget = row.item + } + }, + onAttachmentClick = { attachment -> + if (state.isMessageSelectionMode) { + viewModel.toggleMessageSelection(row.item) + } else { + viewModel.openAttachment(attachment) + } + }, + onQuickActionClick = { commandText -> + if (state.isMessageSelectionMode) { + viewModel.toggleMessageSelection(row.item) + } else { + viewModel.executeBotCommand(commandText) + } + }, + onReactionClick = { emoji -> + if (state.isMessageSelectionMode) { + viewModel.toggleMessageSelection(row.item) + } else { + viewModel.toggleReaction(row.item, emoji) + } + }, + onDoubleTap = { + if (!state.isMessageSelectionMode && !row.item.isDeleted()) { + viewModel.toggleReaction(row.item, "\uD83D\uDC4D") + } + }, + onReplyPreviewClick = { replyMessageId -> + val rowIndex = rows.indexOfFirst { candidate -> + candidate is ChatRow.Message && candidate.item.id == replyMessageId + } + if (rowIndex >= 0) { + coroutineScope.launch { listState.animateScrollToItem(rowIndex) } + } else { + viewModel.setErrorMessage("Сообщение для ответа находится за пределами загруженной истории. Используйте поиск или прокрутите выше, чтобы загрузить старые сообщения.") + } + }, + onSwipeReply = { + if (localStatus == null) { + viewModel.beginReplyToMessage(row.item) + } + }, + onLongPress = { + if (localStatus == null) { + viewModel.handleMessageLongPress(row.item) + } + }, + attachmentPlaybackState = state.attachmentPlayback, + bubbleCornerRadiusDp = chatSettings.bubbleCornerRadiusDp, + messageTextSizeSp = chatSettings.messageTextSizeSp + ) + } + } + } + if (state.isLoadingOlderMessages) { + item(key = "older-messages-loading") { + OlderMessagesLoadingRow() + } + } + } + + if (state.isLoading && state.messages.isEmpty()) { + CircularProgressIndicator( + modifier = Modifier.align(Alignment.Center), + color = TelegramBlue + ) + } + + if (showScrollToLatestButton) { + Surface( + color = TelegramBlue, + shape = CircleShape, + shadowElevation = 6.dp, + modifier = Modifier + .align(Alignment.BottomEnd) + .padding(end = 16.dp, bottom = 16.dp) + .clickable { + coroutineScope.launch { + listState.animateScrollToItem(index = 0) + } + } + ) { + Box( + modifier = Modifier.padding(12.dp), + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = Icons.Outlined.ArrowDownward, + contentDescription = "К последнему сообщению", + tint = Color.White + ) + } + } + } + } + + Column( + modifier = Modifier + .fillMaxWidth() + .then(if (emojiPickerMode == null) Modifier.imePadding() else Modifier) + .navigationBarsPadding() + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 10.dp, vertical = 8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + state.activeCall?.let { + CallBanner( + title = viewModel.callBannerTitle(state), + subtitle = viewModel.callBannerSubtitle(state), + showAccept = viewModel.showAcceptCallButton(state), + showDecline = viewModel.showDeclineCallButton(state), + showEnd = viewModel.showEndCallButton(state), + onAccept = viewModel::acceptCall, + onDecline = viewModel::declineCall, + onEnd = viewModel::endCall + ) + } + + if (state.chatType == ChatType.Channel && !state.canSendMessages && !state.canSubscribe) { + InfoBanner(text = "Канал доступен только для чтения. Публикации создают администраторы.") + } + + state.editingMessageId?.let { + ComposerStateCard( + title = "Редактирование", + subtitle = state.editingMessageBanner, + actionText = "Отмена", + onActionClick = viewModel::cancelEditMessage + ) + } + + state.replyTo?.let { replyTo -> + ComposerStateCard( + title = "Ответ ${replyTo.senderDisplayName}", + subtitle = replyTo.previewText, + actionText = "Отмена", + onActionClick = viewModel::cancelReply + ) + } + + state.pendingAttachment?.takeIf { it.imagePreviews.size > 1 }?.let { pending -> + PendingImageAlbumCard( + pending = pending, + onRemoveClick = viewModel::removePendingAttachment + ) + } + + state.pendingAttachment?.takeUnless { it.imagePreviews.size > 1 }?.let { pending -> + ComposerStateCard( + title = pending.fileName, + subtitle = "${pending.contentType} • ${formatFileSize(pending.fileSizeBytes)}", + actionText = "Убрать", + onActionClick = viewModel::removePendingAttachment + ) + } + + state.activeTransfers.forEach { transfer -> + TransferQueueCard( + transfer = transfer, + onRetryClick = { viewModel.retryTransfer(transfer.groupId) }, + onCancelClick = { viewModel.cancelTransfer(transfer.groupId) } + ) + } + + if (state.isRecordingVoiceNote) { + ComposerStateCard( + title = "Идёт запись голосового сообщения", + subtitle = state.voiceRecordingElapsedText, + actionText = "Отменить", + onActionClick = viewModel::cancelVoiceRecording + ) + } + + state.errorMessage?.takeIf { it.isNotBlank() }?.let { + Text( + text = it, + style = MaterialTheme.typography.bodySmall, + color = TelegramDanger, + modifier = Modifier.padding(horizontal = 4.dp) + ) + } + + if (state.canSendMessages) { + ChatComposer( + draftText = state.draftText, + placeholder = if (state.chatType == ChatType.Channel) "Публикация" else "Сообщение", + showVoiceButton = state.draftText.isBlank() && state.pendingAttachment == null && state.editingMessageId == null, + showBotMenuButton = viewModel.showBotComposerMenuButton(state), + isRecording = state.isRecordingVoiceNote, + isSending = state.isSendingMessage, + recordingElapsedText = state.voiceRecordingElapsedText, + onDraftChanged = viewModel::updateDraft, + onEmojiClick = { + keyboardController?.hide() + emojiPickerMode = if (emojiPickerMode is EmojiPickerMode.Composer) { + null + } else { + displayedEmojiPickerMode = EmojiPickerMode.Composer + EmojiPickerMode.Composer + } + }, + onInputFocused = { emojiPickerMode = null }, + onBotMenuClick = { + viewModel.ensureBotCommandsLoaded() + showBotCommandsDialog = true + }, + onAttachClick = { attachmentPicker.launch("*/*") }, + onSendClick = viewModel::sendMessage, + onVoiceClick = { + if (state.isRecordingVoiceNote) { + viewModel.completeVoiceRecording() + } else { + microphonePermissionLauncher.launch(Manifest.permission.RECORD_AUDIO) + } + } + ) + } else if (state.chatType == ChatType.Channel) { + ChannelViewerBar( + isSubscribed = state.isSubscribed, + canSubscribe = state.canSubscribe, + isJoining = state.isJoiningChannel, + publicUsername = state.publicUsername, + subscriberCount = state.subscriberCount, + onSubscribeClick = viewModel::subscribeToChannel + ) + } + } + + AnimatedVisibility( + visible = emojiPickerMode != null, + enter = expandVertically( + expandFrom = Alignment.Bottom, + animationSpec = tween(EmojiKeyboardAnimationMillis) + ), + exit = shrinkVertically( + shrinkTowards = Alignment.Bottom, + animationSpec = tween(EmojiKeyboardAnimationMillis) + ) + ) { + displayedEmojiPickerMode?.let { pickerMode -> + EmojiKeyboardPanel( + categories = emojiCategories, + height = emojiKeyboardHeight, + onEmojiSelected = { emoji -> handleEmojiSelected(pickerMode, emoji) } + ) + } + } + } + } + } + + if (showHeaderMenu) { + val canStartCalls = viewModel.showCallHeaderButton(state) + MessageActionDialog( + title = "Меню чата", + onDismiss = { showHeaderMenu = false }, + actions = buildList { + add( + MessageActionItem( + label = "Уведомления", + icon = Icons.Outlined.Notifications, + onClick = { + showHeaderMenu = false + onChatNotificationsClick() + } + ) + ) + if (canStartCalls) { + add( + MessageActionItem( + label = "Видеозвонок", + icon = Icons.Outlined.Videocam, + onClick = { + showHeaderMenu = false + runVideoCallIncomingCallPreflight?.invoke() + } + ) + ) + add( + MessageActionItem( + label = "Аудиозвонок", + icon = Icons.Outlined.Call, + onClick = { + showHeaderMenu = false + audioCallPermissionLauncher.launch(Manifest.permission.RECORD_AUDIO) + } + ) + ) + } + add( + MessageActionItem( + label = "Настроить обои", + icon = Icons.Outlined.Image, + onClick = { + showHeaderMenu = false + onChatWallpaperClick() + } + ) + ) + if (viewModel.showBotComposerMenuButton(state)) { + add( + MessageActionItem( + label = "Удалить весь контент", + icon = Icons.Outlined.Delete, + isDestructive = true, + onClick = { + showHeaderMenu = false + showClearHistoryConfirm = true + } + ) + ) + } + if (state.chatType == ChatType.Channel && state.isSubscribed && !state.canManageChannel) { + add( + MessageActionItem( + label = if (state.isLeavingChannel) "Отписка..." else "Отписаться от канала", + icon = Icons.Outlined.Delete, + isDestructive = true, + onClick = { + showHeaderMenu = false + viewModel.unsubscribeFromChannel() + } + ) + ) + } else if (state.chatType != ChatType.Channel || state.isSubscribed) { + add( + MessageActionItem( + label = if (state.chatType == ChatType.Channel) "Удалить канал" else "Удалить чат", + icon = Icons.Outlined.Delete, + isDestructive = true, + onClick = { + showHeaderMenu = false + showDeleteChatConfirm = true + } + ) + ) + } + } + ) + } + + if (showDeleteChatConfirm) { + val deleteTitle = if (state.chatType == ChatType.Channel) "Удалить канал?" else "Удалить чат?" + val deleteText = if (state.chatType == ChatType.Channel) { + "Канал исчезнет из вашего списка после подтверждения." + } else { + "Чат исчезнет из вашего списка после подтверждения." + } + AlertDialog( + onDismissRequest = { showDeleteChatConfirm = false }, + title = { Text(deleteTitle) }, + text = { Text(deleteText) }, + confirmButton = { + TextButton( + enabled = !state.isDeletingChat, + onClick = { + showDeleteChatConfirm = false + viewModel.deleteChat() + } + ) { + Text("Удалить", color = MaterialTheme.colorScheme.error) + } + }, + dismissButton = { + TextButton( + enabled = !state.isDeletingChat, + onClick = { showDeleteChatConfirm = false } + ) { + Text("Отмена") + } + } + ) + } + + if (showClearHistoryConfirm) { + AlertDialog( + onDismissRequest = { showClearHistoryConfirm = false }, + title = { Text("Удалить весь контент") }, + text = { Text("Удалить все сообщения и вложения в этом чате с ботом без возможности восстановления?") }, + confirmButton = { + TextButton(onClick = { + showHeaderMenu = false + showClearHistoryConfirm = false + viewModel.clearChatHistory() + }) { + Text("Удалить", color = MaterialTheme.colorScheme.error) + } + }, + dismissButton = { + TextButton(onClick = { showClearHistoryConfirm = false }) { + Text("Отмена") + } + } + ) + } + + if (showBotCommandsDialog) { + ActionDialog( + title = "Команды бота", + onDismiss = { showBotCommandsDialog = false }, + actions = viewModel.availableBotCommands(state).map { command -> + val label = if (command.description.isBlank()) "/${command.command}" else "/${command.command} ${command.description}" + label to { + showBotCommandsDialog = false + when (command.command.lowercase(Locale.ROOT)) { + "use" -> { + val quickActions = viewModel.latestBotQuickActions(state) + if (quickActions.isNotEmpty()) { + showBotThreadSelection = true + } else { + viewModel.executeBotCommand("/threads") + } + } + + "newthread" -> viewModel.executeBotCommand("/newthread") + "turn" -> { + if (state.draftText.isBlank()) { + viewModel.setErrorMessage("Для /turn нужен текст. После выбора потока можно просто отправить обычное сообщение без команды.") + } else { + viewModel.executeBotCommand("/turn ${state.draftText.trim()}") + } + } + + else -> viewModel.executeBotCommand("/${command.command}") + } + } + } + ) + } + + if (showBotThreadSelection) { + ActionDialog( + title = "Выберите поток", + onDismiss = { showBotThreadSelection = false }, + actions = viewModel.latestBotQuickActions(state).map { action -> + action.title to { + showBotThreadSelection = false + viewModel.executeBotCommand(action.commandText) + } + } + ) + } + + messageMenuTarget?.let { target -> + val actionItems = buildMessageMenuItems( + message = target, + currentUserId = currentUserId, + onCopy = { + clipboard.setText(AnnotatedString(target.text)) + messageMenuTarget = null + }, + onForward = { + messageMenuTarget = null + onForwardMessage(target) + }, + onReply = { + messageMenuTarget = null + viewModel.beginReplyToMessage(target) + }, + onReact = { + messageMenuTarget = null + keyboardController?.hide() + displayedEmojiPickerMode = EmojiPickerMode.Reaction(target) + emojiPickerMode = EmojiPickerMode.Reaction(target) + }, + onPin = { + messageMenuTarget = null + viewModel.togglePinMessage(target) + }, + onEdit = { + messageMenuTarget = null + viewModel.beginEditMessage(target) + }, + onDelete = { + messageMenuTarget = null + deleteCandidate = target + } + ) + + if (actionItems.isNotEmpty()) { + MessageActionDialog( + title = "Действия с сообщением", + onDismiss = { messageMenuTarget = null }, + actions = actionItems + ) + } + } + + deleteCandidate?.let { target -> + AlertDialog( + onDismissRequest = { deleteCandidate = null }, + title = { Text("Удалить сообщение") }, + text = { Text("Удалить это сообщение для всех участников?") }, + confirmButton = { + TextButton(onClick = { + deleteCandidate = null + viewModel.deleteMessage(target) + }) { + Text("Удалить") + } + }, + dismissButton = { + TextButton(onClick = { deleteCandidate = null }) { + Text("Отмена") + } + } + ) + } + + if (state.isDeleteSelectionDialogVisible) { + DeleteSelectedMessagesDialog( + selectedMessagesCount = state.selectedMessagesCount, + isDeletingSelectedMessages = state.isDeletingSelectedMessages, + onDismiss = viewModel::dismissDeleteSelectedMessagesDialog, + onConfirmDelete = viewModel::confirmDeleteSelectedMessages + ) + } + + currentImagePreviewEntry?.let { previewEntry -> + val previewIndex = currentImagePreviewIndex ?: return@let + ChatImagePreviewDialog( + entry = previewEntry, + accessToken = state.session?.accessToken, + imageUrl = viewModel.attachmentPreviewUrl(previewEntry.attachment), + enableEdgeTapNavigation = chatSettings.mediaTapToBrowseEnabled, + canNavigatePrevious = previewIndex > 0, + canNavigateNext = previewIndex < imagePreviewEntries.lastIndex, + canDelete = previewEntry.message.canDelete(currentUserId), + isProtectedContent = previewEntry.message.hasProtectedContent, + onDismiss = viewModel::dismissImagePreview, + onNavigatePrevious = { + imagePreviewEntries.getOrNull(previewIndex - 1)?.let { previousEntry -> + viewModel.showImagePreviewAttachment(previousEntry.attachment.id) + } + }, + onNavigateNext = { + imagePreviewEntries.getOrNull(previewIndex + 1)?.let { nextEntry -> + viewModel.showImagePreviewAttachment(nextEntry.attachment.id) + } + }, + onSaveToGallery = { viewModel.saveImageAttachmentToGallery(previewEntry.attachment) }, + onReply = { + viewModel.beginReplyToMessage(previewEntry.message) + viewModel.dismissImagePreview() + }, + onShare = { viewModel.shareAttachmentExternally(previewEntry.attachment) }, + onDelete = { + viewModel.dismissImagePreview() + deleteCandidate = previewEntry.message + } + ) + } +} + +@Composable +private fun ChatHeader( + title: String, + subtitle: String, + avatarTitle: String, + avatarUrl: String?, + avatarAccessToken: String?, + onBackClick: () -> Unit, + onAvatarClick: (() -> Unit)?, + onMenuClick: () -> Unit +) { + Column( + modifier = Modifier + .fillMaxWidth() + .background(Color(0xFFF9FFFF)) + ) { + Spacer(modifier = Modifier.windowInsetsTopHeight(WindowInsets.statusBars)) + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 10.dp, vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp) + ) { + IkarHeaderIconBubble(onClick = onBackClick) { + Icon( + imageVector = Icons.AutoMirrored.Outlined.ArrowBack, + contentDescription = "Назад", + tint = TelegramInk + ) + } + + Box( + modifier = if (onAvatarClick != null) { + Modifier + .clip(CircleShape) + .clickable(onClick = onAvatarClick) + } else { + Modifier + } + ) { + AvatarView( + imageUrl = avatarUrl, + title = avatarTitle, + accessToken = avatarAccessToken, + size = 42.dp + ) + } + + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(1.dp) + ) { + Text( + text = title, + style = MaterialTheme.typography.headlineLarge, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + if (subtitle.isNotBlank()) { + Text( + text = subtitle, + style = MaterialTheme.typography.bodySmall, + color = TelegramInkMuted, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + } + + IkarHeaderIconBubble(onClick = onMenuClick) { + Icon(Icons.Outlined.Menu, contentDescription = "Меню", tint = TelegramInk) + } + } + } +} + +@Composable +private fun ChatSearchHeader( + search: ChatSearchUiState, + onQueryChanged: (String) -> Unit, + onPrevious: () -> Unit, + onNext: () -> Unit, + onClose: () -> Unit +) { + val hasResults = search.resultMessageIds.isNotEmpty() + val resultLabel = when { + search.query.isBlank() -> "" + search.isSearching -> "..." + hasResults -> "${search.activeResultIndex + 1}/${search.resultMessageIds.size}" + else -> "0/0" + } + + Column( + modifier = Modifier + .fillMaxWidth() + .background(Color(0xFFF9FFFF)) + ) { + Spacer(modifier = Modifier.windowInsetsTopHeight(WindowInsets.statusBars)) + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 10.dp, vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp) + ) { + IkarHeaderIconBubble(onClick = onClose) { + Icon(Icons.Outlined.Close, contentDescription = "Закрыть поиск", tint = TelegramInk) + } + + Surface( + color = TelegramSurface, + shape = RoundedCornerShape(18.dp), + modifier = Modifier.weight(1f) + ) { + BasicTextField( + value = search.query, + onValueChange = onQueryChanged, + singleLine = true, + textStyle = MaterialTheme.typography.bodyLarge.copy(color = TelegramInk), + modifier = Modifier.padding(horizontal = 14.dp, vertical = 10.dp), + decorationBox = { innerTextField -> + if (search.query.isBlank()) { + Text( + text = "Поиск сообщений", + style = MaterialTheme.typography.bodyLarge, + color = TelegramInkMuted + ) + } + innerTextField() + } + ) + } + + Text( + text = resultLabel, + style = MaterialTheme.typography.labelMedium, + color = TelegramInkMuted + ) + + IkarHeaderIconBubble(onClick = onPrevious.takeIf { hasResults }) { + Icon( + Icons.Outlined.KeyboardArrowUp, + contentDescription = "Предыдущий результат", + tint = if (hasResults) TelegramInk else TelegramInkMuted + ) + } + IkarHeaderIconBubble(onClick = onNext.takeIf { hasResults }) { + Icon( + Icons.Outlined.KeyboardArrowDown, + contentDescription = "Следующий результат", + tint = if (hasResults) TelegramInk else TelegramInkMuted + ) + } + } + if (search.isSearching) { + LinearProgressIndicator( + modifier = Modifier.fillMaxWidth(), + color = TelegramBlue + ) + } + } +} + +@Composable +private fun MessageSelectionHeader( + selectedMessagesCount: Int, + selectedMessage: MessageDto?, + currentUserId: String?, + isDeleteEnabled: Boolean, + onClearSelection: () -> Unit, + onReplySelected: (MessageDto) -> Unit, + onCopySelected: (MessageDto) -> Unit, + onForwardSelected: (MessageDto) -> Unit, + onReactSelected: (MessageDto) -> Unit, + onPinSelected: (MessageDto) -> Unit, + onDeleteSelectedClick: () -> Unit +) { + val canUseSingleActions = selectedMessagesCount == 1 && selectedMessage != null && !selectedMessage.isDeleted() + val canCopy = canUseSingleActions && + selectedMessage?.hasProtectedContent != true && + selectedMessage?.text?.isNotBlank() == true + val canForward = canUseSingleActions && + selectedMessage?.hasProtectedContent != true && + selectedMessage?.let { it.text.isNotBlank() || it.attachments.isNotEmpty() } == true + Column( + modifier = Modifier + .fillMaxWidth() + .background(Color(0xFFF9FFFF)) + ) { + Spacer(modifier = Modifier.windowInsetsTopHeight(WindowInsets.statusBars)) + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 10.dp, vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp) + ) { + IkarHeaderIconBubble(onClick = onClearSelection) { + Icon( + imageVector = Icons.Outlined.Close, + contentDescription = "Снять выделение", + tint = TelegramInk + ) + } + + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(1.dp) + ) { + Text( + text = messageSelectionCountLabel(selectedMessagesCount), + style = MaterialTheme.typography.headlineLarge, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + Text( + text = "Можно удалить выбранные сообщения", + style = MaterialTheme.typography.bodySmall, + color = TelegramInkMuted, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + + if (canUseSingleActions && selectedMessage != null) { + SelectionActionButton( + icon = Icons.AutoMirrored.Outlined.ArrowBack, + contentDescription = "Ответить", + enabled = true, + onClick = { onReplySelected(selectedMessage) } + ) + SelectionActionButton( + icon = Icons.Outlined.ContentCopy, + contentDescription = "Копировать", + enabled = canCopy, + onClick = { onCopySelected(selectedMessage) } + ) + SelectionActionButton( + icon = Icons.AutoMirrored.Outlined.Send, + contentDescription = "Переслать", + enabled = canForward, + onClick = { onForwardSelected(selectedMessage) } + ) + SelectionActionButton( + icon = Icons.Outlined.Mood, + contentDescription = "Реакция", + enabled = true, + onClick = { onReactSelected(selectedMessage) } + ) + SelectionActionButton( + icon = Icons.Outlined.PushPin, + contentDescription = if (selectedMessage.pinnedAt == null) "Закрепить" else "Открепить", + enabled = selectedMessage.canDelete(currentUserId), + onClick = { onPinSelected(selectedMessage) } + ) + } + + IkarHeaderIconBubble(onClick = onDeleteSelectedClick.takeIf { isDeleteEnabled }) { + Icon( + imageVector = Icons.Outlined.Delete, + contentDescription = "Удалить выбранные сообщения", + tint = if (isDeleteEnabled) TelegramDanger else TelegramInkMuted + ) + } + } + } +} + +@Composable +private fun SelectionActionButton( + icon: ImageVector, + contentDescription: String, + enabled: Boolean, + onClick: () -> Unit +) { + IkarHeaderIconBubble(onClick = onClick.takeIf { enabled }) { + Icon( + imageVector = icon, + contentDescription = contentDescription, + tint = if (enabled) TelegramInk else TelegramInkMuted + ) + } +} + +@Composable +private fun PinnedMessageBanner( + message: MessageDto, + onClick: () -> Unit, + onUnpinClick: () -> Unit +) { + Surface( + color = Color.White.copy(alpha = 0.96f), + shadowElevation = 2.dp, + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onClick) + ) { + Row( + modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp) + ) { + Box( + modifier = Modifier + .width(3.dp) + .height(36.dp) + .clip(CircleShape) + .background(TelegramBlue) + ) + Icon( + imageVector = Icons.Outlined.PushPin, + contentDescription = "Закрепленное сообщение", + tint = TelegramBlue, + modifier = Modifier.size(18.dp) + ) + Column(modifier = Modifier.weight(1f)) { + Text( + text = "Закрепленное сообщение", + style = MaterialTheme.typography.labelMedium, + color = TelegramBlue, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + Text( + text = pinnedMessagePreview(message), + style = MaterialTheme.typography.bodySmall, + color = TelegramInk, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + IkarHeaderIconBubble(onClick = onUnpinClick) { + Icon( + imageVector = Icons.Outlined.Close, + contentDescription = "Открепить", + tint = TelegramInkMuted + ) + } + } + } +} + +@Composable +private fun CallBanner( + title: String, + subtitle: String, + showAccept: Boolean, + showDecline: Boolean, + showEnd: Boolean, + onAccept: () -> Unit, + onDecline: () -> Unit, + onEnd: () -> Unit +) { + Surface( + color = Color(0xFFF2F8FF), + shape = RoundedCornerShape(16.dp), + shadowElevation = 0.dp + ) { + Column( + modifier = Modifier.padding(horizontal = 14.dp, vertical = 12.dp), + verticalArrangement = Arrangement.spacedBy(10.dp) + ) { + Text(title, style = MaterialTheme.typography.titleLarge) + if (subtitle.isNotBlank()) { + Text(subtitle, style = MaterialTheme.typography.bodySmall, color = TelegramInkMuted) + } + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + if (showAccept) { + ActionChip(text = "Принять", background = TelegramBlue, textColor = Color.White, onClick = onAccept) + } + if (showDecline) { + ActionChip(text = "Отклонить", background = Color(0xFFF8E1E3), textColor = TelegramDanger, onClick = onDecline) + } + if (showEnd) { + ActionChip(text = "Отбой", background = Color(0xFFF8E1E3), textColor = TelegramDanger, onClick = onEnd) + } + } + } + } +} + +@Composable +private fun ActionChip( + text: String, + background: Color, + textColor: Color, + enabled: Boolean = true, + onClick: () -> Unit +) { + Surface( + modifier = Modifier.clickable(enabled = enabled, onClick = onClick), + color = background, + shape = RoundedCornerShape(16.dp), + shadowElevation = 0.dp + ) { + Text( + text = text, + modifier = Modifier.padding(horizontal = 14.dp, vertical = 9.dp), + style = MaterialTheme.typography.labelLarge, + color = textColor + ) + } +} + +@Composable +private fun ChannelViewerBar( + isSubscribed: Boolean, + canSubscribe: Boolean, + isJoining: Boolean, + publicUsername: String?, + subscriberCount: Int, + onSubscribeClick: () -> Unit +) { + Surface( + color = Color(0xFFF9FFFF), + shape = RoundedCornerShape(24.dp), + shadowElevation = 4.dp + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 14.dp, vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp) + ) { + Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text( + text = when { + canSubscribe -> "Публичный канал" + isSubscribed -> "Вы подписаны на канал" + else -> "Канал только для чтения" + }, + style = MaterialTheme.typography.titleMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + Text( + text = listOfNotNull( + publicUsername?.let { "@$it" }, + formatChannelSubscribers(subscriberCount) + ).joinToString(" • "), + style = MaterialTheme.typography.bodySmall, + color = TelegramInkMuted, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + if (canSubscribe) { + ActionChip( + text = if (isJoining) "Подписка..." else "Подписаться", + background = TelegramBlue, + textColor = Color.White, + enabled = !isJoining, + onClick = onSubscribeClick + ) + } + } + } +} + +@Composable +private fun InfoBanner(text: String) { + Surface( + color = Color(0xFFFDF6E3), + shape = RoundedCornerShape(16.dp), + shadowElevation = 0.dp + ) { + Text( + text = text, + modifier = Modifier.padding(horizontal = 14.dp, vertical = 12.dp), + style = MaterialTheme.typography.bodyMedium, + color = Color(0xFF9C6B00) + ) + } +} + +@Composable +private fun PendingImageAlbumCard( + pending: PendingAttachmentUiState, + onRemoveClick: () -> Unit +) { + Surface( + color = Color(0xFFF9FFFF), + shape = RoundedCornerShape(18.dp), + shadowElevation = 0.dp + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 12.dp, vertical = 10.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp) + ) { + Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text(pending.fileName, style = MaterialTheme.typography.titleMedium, maxLines = 1, overflow = TextOverflow.Ellipsis) + Text( + text = "${pending.contentType} • ${formatFileSize(pending.fileSizeBytes)}", + style = MaterialTheme.typography.bodySmall, + color = TelegramInkMuted, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + TextButton(onClick = onRemoveClick) { + Text("Убрать") + } + } + + PendingImageAlbumGrid(previews = pending.imagePreviews) + } + } +} + +@Composable +private fun PendingImageAlbumGrid(previews: List) { + ImageCollageLayout( + itemCount = previews.size, + minHeight = 112.dp, + maxHeight = 144.dp + ) { index, modifier, overlayText -> + val preview = previews[index] + ImageCollageTile( + imageUrl = "file://${preview.localPath}", + accessToken = null, + contentDescription = preview.fileName, + modifier = modifier, + overlayText = overlayText + ) + } +} + +@Composable +private fun ComposerStateCard( + title: String, + subtitle: String, + actionText: String, + onActionClick: () -> Unit +) { + Surface( + color = Color(0xFFF9FFFF), + shape = RoundedCornerShape(18.dp), + shadowElevation = 0.dp + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 14.dp, vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp) + ) { + Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text(title, style = MaterialTheme.typography.titleMedium, maxLines = 1, overflow = TextOverflow.Ellipsis) + Text(subtitle, style = MaterialTheme.typography.bodySmall, color = TelegramInkMuted, maxLines = 1, overflow = TextOverflow.Ellipsis) + } + TextButton(onClick = onActionClick) { + Text(actionText) + } + } + } +} + +@Composable +private fun TransferQueueCard( + transfer: TransferQueueUiState, + onRetryClick: () -> Unit, + onCancelClick: () -> Unit +) { + val isFailed = transfer.status == "failed" + ComposerStateCard( + title = transfer.title, + subtitle = when { + isFailed -> transfer.errorMessage ?: "Не удалось загрузить" + transfer.status == "queued" -> "В очереди" + transfer.status == "running" -> "Загрузка ${transfer.progressPercent}%" + else -> transfer.status + }, + actionText = if (isFailed) "Повторить" else "Отменить", + onActionClick = if (isFailed) onRetryClick else onCancelClick + ) +} + +@Composable +private fun ChatComposer( + draftText: String, + placeholder: String, + showVoiceButton: Boolean, + showBotMenuButton: Boolean, + isRecording: Boolean, + isSending: Boolean, + recordingElapsedText: String, + onDraftChanged: (String) -> Unit, + onEmojiClick: () -> Unit, + onInputFocused: () -> Unit, + onBotMenuClick: () -> Unit, + onAttachClick: () -> Unit, + onSendClick: () -> Unit, + onVoiceClick: () -> Unit +) { + Surface( + color = Color(0xFFF9FFFF), + shape = RoundedCornerShape(26.dp), + shadowElevation = 4.dp + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 4.dp, vertical = 2.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(2.dp) + ) { + ChatComposerIcon( + icon = if (showBotMenuButton) Icons.Outlined.Menu else Icons.Outlined.Mood, + description = if (showBotMenuButton) "Команды бота" else "Эмодзи", + onClick = if (showBotMenuButton) onBotMenuClick else onEmojiClick + ) + + BasicTextField( + value = draftText, + onValueChange = onDraftChanged, + enabled = !isSending, + keyboardOptions = KeyboardOptions(capitalization = KeyboardCapitalization.Sentences), + modifier = Modifier + .weight(1f) + .onFocusChanged { focusState -> + if (focusState.isFocused) { + onInputFocused() + } + } + .padding(horizontal = 4.dp, vertical = 10.dp), + textStyle = MaterialTheme.typography.bodyLarge.copy( + color = TelegramInk, + fontSize = 19.sp + ), + singleLine = false, + maxLines = 4, + decorationBox = { innerTextField -> + Box( + modifier = Modifier.fillMaxWidth(), + contentAlignment = Alignment.CenterStart + ) { + if (draftText.isBlank()) { + Text( + text = placeholder, + style = MaterialTheme.typography.bodyLarge.copy(fontSize = 19.sp), + color = TelegramInkMuted + ) + } + innerTextField() + } + } + ) + + ChatComposerIcon( + icon = Icons.Outlined.AttachFile, + description = "Вложение", + onClick = onAttachClick + ) + + Surface( + color = TelegramBlue, + shape = CircleShape, + modifier = Modifier.padding(end = 2.dp) + ) { + Box( + modifier = Modifier + .clickable( + enabled = !isSending, + onClick = if (showVoiceButton || isRecording) onVoiceClick else onSendClick + ) + .padding(10.dp), + contentAlignment = Alignment.Center + ) { + if (isSending) { + CircularProgressIndicator( + modifier = Modifier.size(20.dp), + color = Color.White, + strokeWidth = 2.dp + ) + } else { + Icon( + imageVector = when { + isRecording -> Icons.Outlined.Stop + showVoiceButton -> Icons.Outlined.Mic + else -> Icons.AutoMirrored.Outlined.Send + }, + contentDescription = when { + isRecording -> "Остановить запись" + showVoiceButton -> "Голосовое сообщение" + else -> "Отправить" + }, + tint = Color.White + ) + } + } + } + } + } + + if (isRecording) { + Text( + text = "Запись: $recordingElapsedText", + style = MaterialTheme.typography.bodySmall, + color = TelegramDanger, + modifier = Modifier.padding(horizontal = 8.dp) + ) + } +} + +@Composable +private fun ChatComposerIcon( + icon: androidx.compose.ui.graphics.vector.ImageVector, + description: String, + onClick: () -> Unit +) { + Box( + modifier = Modifier + .clickable(onClick = onClick) + .padding(8.dp), + contentAlignment = Alignment.Center + ) { + Icon(icon, contentDescription = description, tint = TelegramInkMuted) + } +} + +@Composable +private fun OlderMessagesLoadingRow() { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 12.dp), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator( + modifier = Modifier.size(22.dp), + color = TelegramBlue, + strokeWidth = 2.dp + ) + } +} + +@Composable +private fun ChatDateChip(text: String) { + Box( + modifier = Modifier.fillMaxWidth(), + contentAlignment = Alignment.Center + ) { + Surface( + color = Color(0xFF8AA66F), + shape = RoundedCornerShape(12.dp), + shadowElevation = 0.dp + ) { + Text( + text = text, + modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp), + style = MaterialTheme.typography.labelMedium, + color = Color.White + ) + } + } +} + +@OptIn(ExperimentalFoundationApi::class) +@Composable +private fun MessageBubble( + message: MessageDto, + isOutgoing: Boolean, + showImagesBeforeText: Boolean, + isSelected: Boolean, + isSearchMatch: Boolean, + isActiveSearchResult: Boolean, + isSelectionMode: Boolean, + localStatus: LocalMessageStatus?, + accessToken: String?, + attachmentUrlResolver: (AttachmentDto) -> String, + onClick: () -> Unit, + onAttachmentClick: (AttachmentDto) -> Unit, + onQuickActionClick: (String) -> Unit, + onReactionClick: (String) -> Unit, + onDoubleTap: () -> Unit, + onReplyPreviewClick: (String) -> Unit, + onSwipeReply: () -> Unit, + onLongPress: () -> Unit, + attachmentPlaybackState: Map, + bubbleCornerRadiusDp: Int, + messageTextSizeSp: Int +) { + val botQuickActions = remember(message.id, message.text) { buildBotQuickActions(message) } + val hasAttachments = message.attachments.isNotEmpty() + val selectionBackground = when { + isSelected -> TelegramBlue.copy(alpha = 0.12f) + isActiveSearchResult -> Color(0xFFFFD166).copy(alpha = 0.46f) + isSearchMatch -> Color(0xFFFFD166).copy(alpha = 0.22f) + else -> Color.Transparent + } + val displayText = if (message.isDeleted()) "Сообщение удалено." else message.text + val displayContent = remember(message.id, displayText) { buildMessageDisplayContent(displayText) } + val bubbleContentSpacing = if (!hasAttachments && displayContent.imageUrls.isEmpty() && botQuickActions.isEmpty()) 4.dp else 8.dp + val bubbleShape = RoundedCornerShape(bubbleCornerRadiusDp.dp) + val selectionShape = RoundedCornerShape((bubbleCornerRadiusDp + 4).coerceIn(8, 28).dp) + val density = LocalDensity.current + val context = LocalContext.current + val maxSwipeOffsetPx = with(density) { 72.dp.toPx() } + val swipeReplyThresholdPx = with(density) { 42.dp.toPx() } + var swipeOffsetPx by remember(message.id, isSelectionMode) { mutableStateOf(0f) } + val canSwipeToReply = !isSelectionMode && !message.isDeleted() + BoxWithConstraints(modifier = Modifier.fillMaxWidth()) { + val maxBubbleWidth = maxWidth * 0.8f + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = if (isOutgoing) Arrangement.End else Arrangement.Start, + verticalAlignment = Alignment.Bottom + ) { + if (isOutgoing && isSelected) { + SelectionIndicator() + Spacer(modifier = Modifier.width(8.dp)) + } + + Box( + modifier = Modifier + .background(selectionBackground, selectionShape) + .padding(2.dp) + ) { + Surface( + color = if (hasAttachments) Color.Transparent else if (isOutgoing) Color(0xFFE7F7C8) else TelegramSurface, + shape = bubbleShape, + shadowElevation = if (hasAttachments) 0.dp else 1.dp, + modifier = Modifier + .widthIn(max = maxBubbleWidth) + .graphicsLayer { + translationX = swipeOffsetPx + alpha = if (localStatus == LocalMessageStatus.Sending) 0.72f else 1f + } + .pointerInput(message.id, canSwipeToReply) { + if (!canSwipeToReply) { + return@pointerInput + } + + detectHorizontalDragGestures( + onDragEnd = { + val shouldReply = swipeOffsetPx <= -swipeReplyThresholdPx + swipeOffsetPx = 0f + if (shouldReply) { + onSwipeReply() + } + }, + onDragCancel = { + swipeOffsetPx = 0f + } + ) { _, dragAmount -> + swipeOffsetPx = (swipeOffsetPx + dragAmount).coerceIn(-maxSwipeOffsetPx, 0f) + } + } + .combinedClickable( + onClick = onClick, + onDoubleClick = onDoubleTap, + onLongClick = onLongPress + ) + ) { + Column( + modifier = Modifier.padding( + horizontal = if (hasAttachments) 0.dp else 12.dp, + vertical = if (hasAttachments) 0.dp else 10.dp + ), + verticalArrangement = Arrangement.spacedBy(bubbleContentSpacing) + ) { + message.replyTo?.let { replyTo -> + ReplyPreviewCard( + replyTo = replyTo, + modifier = Modifier.padding(horizontal = if (hasAttachments) 6.dp else 0.dp), + onClick = { onReplyPreviewClick(replyTo.messageId) } + ) + } + + if (!message.forwardedFromDisplayName.isNullOrBlank()) { + Text( + text = "Переслано от ${message.forwardedFromDisplayName}", + style = MaterialTheme.typography.labelMedium, + color = TelegramBlue, + modifier = Modifier.padding(horizontal = if (hasAttachments) 6.dp else 0.dp) + ) + } + + val imageAttachments = message.attachments.filter { it.isImageAttachment() } + + if (showImagesBeforeText) { + displayContent.imageUrls.forEach { imageUrl -> + InlineMessageImage( + imageUrl = imageUrl, + onClick = { openExternalUrl(context, imageUrl) }, + onLongPress = onLongPress + ) + } + + if (imageAttachments.size > 1) { + AttachmentAlbumGrid( + attachments = imageAttachments, + attachmentUrlResolver = attachmentUrlResolver, + accessToken = accessToken, + onAttachmentClick = onAttachmentClick, + onLongPress = onLongPress + ) + } else { + imageAttachments.forEach { attachment -> + AttachmentCard( + attachment = attachment, + attachmentUrl = attachmentUrlResolver(attachment), + accessToken = accessToken, + playbackState = attachmentPlaybackState[attachment.id], + onClick = { onAttachmentClick(attachment) }, + onLongPress = onLongPress + ) + } + } + } + + if (displayContent.text.isNotBlank()) { + MessageText( + text = displayContent.text, + modifier = Modifier.padding(horizontal = if (hasAttachments) 6.dp else 0.dp), + textSizeSp = messageTextSizeSp, + onTap = onClick, + onLongPress = onLongPress + ) + } + + if (!showImagesBeforeText) { + displayContent.imageUrls.forEach { imageUrl -> + InlineMessageImage( + imageUrl = imageUrl, + onClick = { openExternalUrl(context, imageUrl) }, + onLongPress = onLongPress + ) + } + } + + val remainingAttachments = if (showImagesBeforeText || imageAttachments.size > 1) { + message.attachments.filterNot { it.isImageAttachment() } + } else { + message.attachments + } + + if (!showImagesBeforeText && imageAttachments.size > 1) { + AttachmentAlbumGrid( + attachments = imageAttachments, + attachmentUrlResolver = attachmentUrlResolver, + accessToken = accessToken, + onAttachmentClick = onAttachmentClick, + onLongPress = onLongPress + ) + } + + remainingAttachments.forEach { attachment -> + AttachmentCard( + attachment = attachment, + attachmentUrl = attachmentUrlResolver(attachment), + accessToken = accessToken, + playbackState = attachmentPlaybackState[attachment.id], + onClick = { onAttachmentClick(attachment) }, + onLongPress = onLongPress + ) + } + + if (botQuickActions.isNotEmpty()) { + FlowRow( + horizontalArrangement = Arrangement.spacedBy(6.dp), + verticalArrangement = Arrangement.spacedBy(6.dp) + ) { + botQuickActions.forEach { action -> + Surface( + color = Color(0xFFEEF5FD), + shape = RoundedCornerShape(16.dp), + modifier = Modifier.clickable { onQuickActionClick(action.commandText) } + ) { + Text( + text = action.title, + modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp), + style = MaterialTheme.typography.labelLarge, + color = TelegramBlue + ) + } + } + } + } + + if (message.reactions.isNotEmpty()) { + ReactionRow( + reactions = message.reactions, + onReactionClick = onReactionClick + ) + } + + MessageMeta( + sentAt = message.sentAt, + deliveryState = message.deliveryState, + isOutgoing = isOutgoing, + viewCount = message.viewCount, + localStatus = localStatus, + modifier = Modifier.align(Alignment.End) + ) + } + } + } + + if (!isOutgoing && isSelected) { + Spacer(modifier = Modifier.width(8.dp)) + SelectionIndicator() + } + } + } +} + +@Composable +private fun SelectionIndicator() { + Icon( + imageVector = Icons.Outlined.CheckCircle, + contentDescription = "Сообщение выбрано", + tint = TelegramBlue, + modifier = Modifier.size(20.dp) + ) +} + +@Composable +private fun EmojiKeyboardPanel( + categories: List, + height: Dp, + onEmojiSelected: (String) -> Unit +) { + val gridState = rememberLazyGridState() + val coroutineScope = rememberCoroutineScope() + val categoryScrollState = rememberScrollState() + val emojiGridItems = remember(categories) { + var globalIndex = 0 + categories.flatMapIndexed { categoryIndex, category -> + category.emojis.map { emoji -> + EmojiGridItem( + index = globalIndex++, + categoryKey = category.key, + categoryIndex = categoryIndex, + emoji = emoji + ) + } + } + } + val categoryFirstIndexes = remember(categories) { + var itemIndex = 0 + categories.map { category -> + val firstIndex = itemIndex + itemIndex += category.emojis.size + firstIndex + } + } + val selectedCategoryIndex by remember(emojiGridItems, gridState) { + derivedStateOf { + emojiGridItems + .getOrNull(gridState.firstVisibleItemIndex) + ?.categoryIndex + ?.coerceIn(0, categories.lastIndex.coerceAtLeast(0)) + ?: 0 + } + } + + Column( + modifier = Modifier + .fillMaxWidth() + .height(height) + .background(TelegramSurface) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .horizontalScroll(categoryScrollState) + .padding(horizontal = 8.dp, vertical = 6.dp), + horizontalArrangement = Arrangement.spacedBy(4.dp) + ) { + categories.forEachIndexed { index, category -> + EmojiCategoryChip( + category = category, + selected = index == selectedCategoryIndex, + onClick = { + coroutineScope.launch { + gridState.animateScrollToItem(categoryFirstIndexes.getOrElse(index) { 0 }) + } + } + ) + } + } + + HorizontalDivider(color = Color(0xFFE6EEF6)) + + LazyVerticalGrid( + columns = GridCells.Adaptive(minSize = EmojiGridMinCellWidth), + state = gridState, + modifier = Modifier + .fillMaxWidth() + .weight(1f), + contentPadding = PaddingValues(start = 6.dp, top = 8.dp, end = 6.dp, bottom = 8.dp), + horizontalArrangement = Arrangement.SpaceEvenly, + verticalArrangement = Arrangement.spacedBy(2.dp) + ) { + items( + items = emojiGridItems, + key = { item -> "${item.index}-${item.categoryKey}-${item.emoji}" } + ) { item -> + EmojiPickerButton( + emoji = item.emoji, + onClick = { onEmojiSelected(item.emoji) } + ) + } + } + } +} + +@Composable +private fun EmojiCategoryChip( + category: EmojiCategory, + selected: Boolean, + onClick: () -> Unit +) { + Surface( + color = if (selected) Color(0xFFE7F1FF) else Color(0xFFF4F7FA), + shape = RoundedCornerShape(16.dp), + modifier = Modifier + .size(34.dp) + .clickable(onClick = onClick) + ) { + Box(contentAlignment = Alignment.Center) { + Text( + text = category.icon, + fontSize = 18.sp, + color = if (selected) TelegramBlue else TelegramInk + ) + } + } +} + +@Composable +private fun EmojiPickerButton( + emoji: String, + onClick: () -> Unit +) { + Surface( + color = Color.Transparent, + shape = RoundedCornerShape(8.dp), + modifier = Modifier + .fillMaxWidth() + .height(EmojiGridCellHeight) + .clickable(onClick = onClick) + ) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Text(text = emoji, fontSize = 22.sp) + } + } +} + +@Composable +private fun ReplyPreviewCard( + replyTo: com.seven.ikar.kotlin.core.model.MessageReplyDto, + modifier: Modifier = Modifier, + onClick: () -> Unit = {} +) { + Surface( + color = Color(0x11000000), + shape = RoundedCornerShape(12.dp), + shadowElevation = 0.dp, + modifier = modifier + .fillMaxWidth() + .clickable(onClick = onClick) + ) { + Column( + modifier = Modifier.padding(horizontal = 10.dp, vertical = 8.dp), + verticalArrangement = Arrangement.spacedBy(2.dp) + ) { + Text( + text = replyTo.senderDisplayName, + style = MaterialTheme.typography.labelMedium, + color = TelegramBlue, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + Text( + text = replyTo.previewText, + style = MaterialTheme.typography.bodySmall, + color = TelegramInkMuted, + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) + } + } +} + +@Composable +private fun ReactionRow( + reactions: List, + onReactionClick: (String) -> Unit +) { + FlowRow( + horizontalArrangement = Arrangement.spacedBy(6.dp), + verticalArrangement = Arrangement.spacedBy(6.dp) + ) { + reactions.forEach { reaction -> + Surface( + color = if (reaction.isMine) Color(0xFFE7F1FF) else Color(0xFFF4F7FA), + shape = RoundedCornerShape(14.dp), + shadowElevation = 0.dp, + modifier = Modifier.clickable { onReactionClick(reaction.emoji) } + ) { + Text( + text = "${reaction.emoji} ${reaction.count}", + modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp), + style = MaterialTheme.typography.labelMedium, + color = if (reaction.isMine) TelegramBlue else TelegramInk + ) + } + } + } +} + +@Composable +private fun MessageText( + text: String, + modifier: Modifier = Modifier, + textSizeSp: Int, + onTap: () -> Unit, + onLongPress: () -> Unit +) { + val context = LocalContext.current + var revealedSpoilers by remember(text) { mutableStateOf>(emptySet()) } + val annotatedText = remember(text, revealedSpoilers) { + buildMessageAnnotatedText(text, revealedSpoilers) + } + var layoutResult by remember(annotatedText) { mutableStateOf(null) } + + Text( + text = annotatedText, + modifier = modifier.pointerInput(annotatedText) { + detectTapGestures( + onTap = { position -> + val offset = layoutResult?.getOffsetForPosition(position) ?: return@detectTapGestures + val urlAnnotation = annotatedText + .getStringAnnotations(tag = MessageUrlAnnotationTag, start = offset, end = offset) + .firstOrNull() + val spoilerAnnotation = annotatedText + .getStringAnnotations(tag = MessageSpoilerAnnotationTag, start = offset, end = offset) + .firstOrNull() + when { + urlAnnotation != null -> openExternalUrl(context, urlAnnotation.item) + spoilerAnnotation != null -> { + val key = spoilerAnnotation.item + revealedSpoilers = if (revealedSpoilers.contains(key)) { + revealedSpoilers - key + } else { + revealedSpoilers + key + } + } + else -> onTap() + } + }, + onLongPress = { onLongPress() } + ) + }, + style = MaterialTheme.typography.bodyLarge.copy(color = TelegramInk, fontSize = textSizeSp.sp), + onTextLayout = { layoutResult = it } + ) +} + +@Composable +private fun AttachmentAlbumGrid( + attachments: List, + attachmentUrlResolver: (AttachmentDto) -> String, + accessToken: String?, + onAttachmentClick: (AttachmentDto) -> Unit, + onLongPress: () -> Unit +) { + ImageCollageLayout(itemCount = attachments.size) { index, modifier, overlayText -> + val attachment = attachments[index] + ImageCollageTile( + imageUrl = attachmentUrlResolver(attachment), + accessToken = accessToken, + contentDescription = attachment.fileName, + modifier = modifier.combinedClickable( + onClick = { onAttachmentClick(attachment) }, + onLongClick = onLongPress + ), + overlayText = overlayText + ) + } +} + +@Composable +private fun ImageCollageLayout( + itemCount: Int, + minHeight: androidx.compose.ui.unit.Dp = 168.dp, + maxHeight: androidx.compose.ui.unit.Dp = 224.dp, + tileContent: @Composable (index: Int, modifier: Modifier, overlayText: String?) -> Unit +) { + if (itemCount <= 0) { + return + } + + BoxWithConstraints( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(16.dp)) + ) { + val gap = 2.dp + val collageHeight = when (itemCount) { + 1 -> minHeight + 2 -> (maxWidth * 0.62f).coerceIn(minHeight, maxHeight) + else -> maxHeight + } + val visibleCount = itemCount.coerceAtMost(4) + + when (visibleCount) { + 1 -> { + tileContent(0, Modifier.fillMaxWidth().height(collageHeight), null) + } + + 2 -> { + Row( + modifier = Modifier + .fillMaxWidth() + .height(collageHeight), + horizontalArrangement = Arrangement.spacedBy(gap) + ) { + repeat(2) { index -> + tileContent(index, Modifier.weight(1f).fillMaxHeight(), null) + } + } + } + + 3 -> { + Row( + modifier = Modifier + .fillMaxWidth() + .height(collageHeight), + horizontalArrangement = Arrangement.spacedBy(gap) + ) { + tileContent(0, Modifier.weight(1.35f).fillMaxHeight(), null) + Column( + modifier = Modifier.weight(1f).fillMaxHeight(), + verticalArrangement = Arrangement.spacedBy(gap) + ) { + tileContent(1, Modifier.fillMaxWidth().weight(1f), null) + tileContent(2, Modifier.fillMaxWidth().weight(1f), null) + } + } + } + + else -> { + Column( + modifier = Modifier + .fillMaxWidth() + .height(collageHeight), + verticalArrangement = Arrangement.spacedBy(gap) + ) { + repeat(2) { row -> + Row( + modifier = Modifier.fillMaxWidth().weight(1f), + horizontalArrangement = Arrangement.spacedBy(gap) + ) { + repeat(2) { column -> + val index = row * 2 + column + val overlayText = if (index == 3 && itemCount > 4) "+${itemCount - 4}" else null + tileContent(index, Modifier.weight(1f).fillMaxHeight(), overlayText) + } + } + } + } + } + } + } +} + +@Composable +private fun ImageCollageTile( + imageUrl: String?, + accessToken: String?, + contentDescription: String, + modifier: Modifier, + overlayText: String? = null +) { + Box( + modifier = modifier + .background(Color(0xFFF7FAFD)) + .clip(RoundedCornerShape(3.dp)) + ) { + AuthenticatedAsyncImage( + imageUrl = imageUrl, + accessToken = accessToken, + contentDescription = contentDescription, + modifier = Modifier.fillMaxSize(), + contentScale = ContentScale.Crop + ) + if (overlayText != null) { + Box( + modifier = Modifier + .fillMaxSize() + .background(Color.Black.copy(alpha = 0.42f)), + contentAlignment = Alignment.Center + ) { + Text( + text = overlayText, + style = MaterialTheme.typography.headlineLarge, + color = Color.White + ) + } + } + } +} + +@OptIn(ExperimentalFoundationApi::class) +@Composable +private fun InlineMessageImage( + imageUrl: String, + onClick: () -> Unit, + onLongPress: () -> Unit +) { + AuthenticatedAsyncImage( + imageUrl = imageUrl, + accessToken = "", + contentDescription = "", + modifier = Modifier + .fillMaxWidth() + .heightIn(min = 120.dp, max = 420.dp) + .clip(RoundedCornerShape(16.dp)) + .combinedClickable(onClick = onClick, onLongClick = onLongPress) + .background(Color(0xFFF7FAFD)), + contentScale = ContentScale.Fit + ) +} + +@Composable +private fun AttachmentCard( + attachment: AttachmentDto, + attachmentUrl: String, + accessToken: String?, + playbackState: VoiceAttachmentPlaybackUiState?, + onClick: () -> Unit, + onLongPress: () -> Unit +) { + when { + attachment.isImageAttachment() -> { + AuthenticatedAsyncImage( + imageUrl = attachmentUrl, + accessToken = accessToken, + contentDescription = attachment.fileName, + modifier = Modifier + .fillMaxWidth() + .heightIn(min = 120.dp, max = 420.dp) + .clip(RoundedCornerShape(16.dp)) + .combinedClickable(onClick = onClick, onLongClick = onLongPress) + .background(Color(0xFFF7FAFD)), + contentScale = ContentScale.Fit + ) + } + + attachment.kind == AttachmentKind.VoiceNote -> { + Surface( + color = Color(0xFFF3F8FD), + shape = RoundedCornerShape(16.dp), + shadowElevation = 0.dp, + modifier = Modifier.combinedClickable(onClick = onClick, onLongClick = onLongPress) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 12.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + Surface(color = TelegramBlue, shape = CircleShape) { + Box(modifier = Modifier.padding(10.dp), contentAlignment = Alignment.Center) { + Icon( + imageVector = if (playbackState?.isPlaying == true) Icons.Outlined.Stop else Icons.Outlined.PlayArrow, + contentDescription = "Голосовое", + tint = Color.White + ) + } + } + val durationMs = when { + playbackState?.durationMs?.let { it > 0L } == true -> playbackState.durationMs + else -> 0L + } + val progress = when { + durationMs > 0L -> (playbackState?.positionMs ?: 0L).toFloat() / durationMs.toFloat() + else -> 0f + }.coerceIn(0f, 1f) + + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(6.dp) + ) { + LinearProgressIndicator( + progress = { progress }, + modifier = Modifier + .fillMaxWidth() + .height(4.dp) + .clip(CircleShape), + color = TelegramBlue, + trackColor = Color(0xFFD6E5F5) + ) + Text( + text = buildVoiceNoteSecondaryText(attachment, playbackState), + style = MaterialTheme.typography.bodySmall, + color = TelegramInkMuted + ) + } + } + } + } + + else -> { + Surface( + color = Color(0xFFF7FAFD), + shape = RoundedCornerShape(16.dp), + shadowElevation = 0.dp, + modifier = Modifier.combinedClickable(onClick = onClick, onLongClick = onLongPress) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 12.dp, vertical = 10.dp), + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + Text(text = attachment.fileName, style = MaterialTheme.typography.titleMedium, color = TelegramInk) + Text( + text = "${attachment.contentType} • ${formatFileSize(attachment.fileSizeBytes)}", + style = MaterialTheme.typography.bodySmall, + color = TelegramInkMuted + ) + } + } + } + } +} + +@Composable +private fun MessageMeta( + sentAt: String, + deliveryState: MessageDeliveryState, + isOutgoing: Boolean, + viewCount: Int?, + localStatus: LocalMessageStatus?, + modifier: Modifier = Modifier +) { + Row( + modifier = modifier, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = formatMessageTime(sentAt), + style = MaterialTheme.typography.labelSmall, + color = if (isOutgoing) Color(0xFF6F8966) else TelegramInkMuted + ) + + if (viewCount != null) { + Spacer(modifier = Modifier.width(6.dp)) + Icon( + imageVector = Icons.Outlined.Visibility, + contentDescription = null, + tint = if (isOutgoing) Color(0xFF6F8966) else TelegramInkMuted, + modifier = Modifier.size(13.dp) + ) + Spacer(modifier = Modifier.width(2.dp)) + Text( + text = formatCompactCount(viewCount), + style = MaterialTheme.typography.labelSmall, + color = if (isOutgoing) Color(0xFF6F8966) else TelegramInkMuted + ) + } + + if (isOutgoing) { + Spacer(modifier = Modifier.width(4.dp)) + Text( + text = when (localStatus) { + LocalMessageStatus.Sending -> "..." + LocalMessageStatus.Failed -> "!" + null -> when (deliveryState) { + MessageDeliveryState.Read -> "\u2713\u2713" + MessageDeliveryState.Delivered -> "\u2713" + MessageDeliveryState.Sent -> "\u2713" + } + }, + style = MaterialTheme.typography.labelSmall.copy(fontWeight = FontWeight.SemiBold), + color = when (localStatus) { + LocalMessageStatus.Failed -> Color(0xFFE53935) + LocalMessageStatus.Sending -> Color(0xFF6F8966) + null -> if (deliveryState == MessageDeliveryState.Read) TelegramBlue else Color(0xFF6F8966) + } + ) + } + } +} + +private const val MessageUrlAnnotationTag = "message-url" +private const val MessageSpoilerAnnotationTag = "message-spoiler" + +private data class MessageUrlRange( + val start: Int, + val end: Int, + val url: String +) + +private data class MessageDisplayContent( + val text: String, + val imageUrls: List +) + +private val ImageLabelRegex = Regex("""(^|[\s:;,.!?()\[\]{}-])изображени[еяю]?([\s:;,.!?()\[\]{}-]|$)""", RegexOption.IGNORE_CASE) +private val ImageFileExtensions = setOf("jpg", "jpeg", "png", "webp", "gif", "bmp", "heic", "heif") + +private fun buildMessageDisplayContent(text: String): MessageDisplayContent { + if (text.isBlank()) { + return MessageDisplayContent(text = text, imageUrls = emptyList()) + } + + val imageUrls = mutableListOf() + val visibleLines = mutableListOf() + var pendingImageLabel = false + + text.lines().forEach { line -> + val hasImageLabel = ImageLabelRegex.containsMatchIn(line) + val ranges = extractMessageUrlRanges(line) + if (ranges.isEmpty()) { + val cleanedLine = if (hasImageLabel) cleanInlineImageLine(line, emptyList()) else line + if (hasImageLabel && cleanedLine.isBlank()) { + pendingImageLabel = true + return@forEach + } + + visibleLines += line + pendingImageLabel = false + return@forEach + } + + val imageRanges = ranges + .filter { range -> isInlineImageUrl(line, range, pendingImageLabel || hasImageLabel) } + if (imageRanges.isEmpty()) { + visibleLines += line + pendingImageLabel = false + return@forEach + } + + imageUrls += imageRanges.map { it.url } + val cleanedLine = cleanInlineImageLine(line, imageRanges) + if (cleanedLine.isNotBlank()) { + visibleLines += cleanedLine + } + pendingImageLabel = false + } + + return MessageDisplayContent( + text = visibleLines.joinToString("\n").trim(), + imageUrls = imageUrls.distinct() + ) +} + +private fun isInlineImageUrl(line: String, range: MessageUrlRange, forcedByLabel: Boolean): Boolean = + forcedByLabel || isImageUrl(range.url) || ImageLabelRegex.containsMatchIn(line) + +private fun cleanInlineImageLine(line: String, imageRanges: List): String { + var result = line + imageRanges + .sortedByDescending { it.start } + .forEach { range -> result = result.removeRange(range.start, range.end) } + + return ImageLabelRegex + .replace(result, " ") + .replace(Regex("""[ \t]{2,}"""), " ") + .trim() + .trim(':', ';', ',', '.', '-', '–', '—', '•') + .trim() +} + +private fun isImageUrl(url: String): Boolean { + val uri = runCatching { Uri.parse(url) }.getOrNull() ?: return false + val pathExtension = uri.path + ?.substringAfterLast('.', missingDelimiterValue = "") + ?.lowercase(Locale.US) + .orEmpty() + if (pathExtension in ImageFileExtensions) { + return true + } + + val query = uri.query.orEmpty().lowercase(Locale.US) + return ImageFileExtensions.any { extension -> + query.contains("format=$extension") || + query.contains("fm=$extension") || + query.contains("ext=$extension") || + query.contains("image/$extension") + } +} + +private fun buildMessageAnnotatedText(text: String, revealedSpoilers: Set = emptySet()): AnnotatedString { + val builder = AnnotatedString.Builder(text) + extractMessageUrlRanges(text).forEach { range -> + builder.addStyle( + style = SpanStyle( + color = TelegramBlue, + textDecoration = TextDecoration.Underline + ), + start = range.start, + end = range.end + ) + builder.addStringAnnotation( + tag = MessageUrlAnnotationTag, + annotation = range.url, + start = range.start, + end = range.end + ) + } + MentionHashtagRegex.findAll(text).forEach { match -> + builder.addStyle( + style = SpanStyle(color = TelegramBlue, fontWeight = FontWeight.SemiBold), + start = match.range.first, + end = match.range.last + 1 + ) + } + SpoilerRegex.findAll(text).forEach { match -> + val key = match.range.first.toString() + val isRevealed = revealedSpoilers.contains(key) + builder.addStyle( + style = SpanStyle( + color = if (isRevealed) TelegramInk else Color.Transparent, + background = if (isRevealed) Color(0xFFE4EEF6) else Color(0xFF9AA7B2) + ), + start = match.range.first, + end = match.range.last + 1 + ) + builder.addStringAnnotation( + tag = MessageSpoilerAnnotationTag, + annotation = key, + start = match.range.first, + end = match.range.last + 1 + ) + } + return builder.toAnnotatedString() +} + +private val MentionHashtagRegex = Regex("""(? { + val matcher = Patterns.WEB_URL.matcher(text) + val ranges = mutableListOf() + while (matcher.find()) { + val rawMatch = matcher.group().orEmpty() + val trimmedMatch = rawMatch.trimEnd('.', ',', '!', '?', ';', ':', ')', ']', '}', '>') + if (trimmedMatch.isBlank()) { + continue + } + + val start = matcher.start() + val end = start + trimmedMatch.length + ranges += MessageUrlRange( + start = start, + end = end, + url = normalizeExternalUrl(trimmedMatch) + ) + } + return ranges +} + +private fun normalizeExternalUrl(rawUrl: String): String = + if (rawUrl.startsWith("http://", ignoreCase = true) || rawUrl.startsWith("https://", ignoreCase = true)) { + rawUrl + } else { + "https://$rawUrl" + } + +private fun openExternalUrl(context: Context, url: String) { + val intent = Intent(Intent.ACTION_VIEW, Uri.parse(url)).apply { + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } + + try { + context.startActivity(intent) + } catch (_: ActivityNotFoundException) { + // Ignore when there is no browser available on the device. + } +} + +private data class MessageActionItem( + val label: String, + val icon: ImageVector, + val isDestructive: Boolean = false, + val onClick: () -> Unit +) + +@Composable +private fun MessageActionDialog( + title: String, + onDismiss: () -> Unit, + actions: List +) { + AlertDialog( + onDismissRequest = onDismiss, + confirmButton = {}, + text = { + Column(verticalArrangement = Arrangement.spacedBy(0.dp)) { + actions.forEachIndexed { index, action -> + Row( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = action.onClick) + .padding(horizontal = 4.dp, vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(14.dp) + ) { + Icon( + imageVector = action.icon, + contentDescription = null, + tint = if (action.isDestructive) MaterialTheme.colorScheme.error else TelegramInkMuted + ) + Text( + text = action.label, + modifier = Modifier.weight(1f), + style = MaterialTheme.typography.titleMedium, + color = if (action.isDestructive) MaterialTheme.colorScheme.error else TelegramInk + ) + } + if (index != actions.lastIndex) { + HorizontalDivider() + } + } + } + } + ) +} + +@Composable +private fun ActionDialog( + title: String, + onDismiss: () -> Unit, + actions: List Unit>> +) { + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(title) }, + text = { + Column { + actions.forEachIndexed { index, (label, onClick) -> + TextButton( + modifier = Modifier.fillMaxWidth(), + onClick = onClick + ) { + Text(label, modifier = Modifier.fillMaxWidth()) + } + if (index != actions.lastIndex) { + HorizontalDivider() + } + } + } + }, + confirmButton = {}, + dismissButton = { + TextButton(onClick = onDismiss) { + Text("Отмена") + } + } + ) +} + +@Composable +private fun DeleteSelectedMessagesDialog( + selectedMessagesCount: Int, + isDeletingSelectedMessages: Boolean, + onDismiss: () -> Unit, + onConfirmDelete: () -> Unit +) { + AlertDialog( + onDismissRequest = { + if (!isDeletingSelectedMessages) { + onDismiss() + } + }, + title = { Text(deleteSelectedMessagesDialogTitle(selectedMessagesCount)) }, + text = { Text(deleteSelectedMessagesDialogMessage(selectedMessagesCount), color = TelegramInkMuted) }, + confirmButton = { + TextButton( + enabled = !isDeletingSelectedMessages, + onClick = onConfirmDelete + ) { + Text("Удалить", color = MaterialTheme.colorScheme.error) + } + }, + dismissButton = { + TextButton( + enabled = !isDeletingSelectedMessages, + onClick = onDismiss + ) { + Text("Отмена") + } + } + ) +} + +@Composable +private fun ImagePreviewDialog( + attachment: AttachmentDto, + accessToken: String?, + imageUrl: String, + onDismiss: () -> Unit +) { + AlertDialog( + onDismissRequest = onDismiss, + confirmButton = {}, + dismissButton = { + TextButton(onClick = onDismiss) { + Text("Закрыть") + } + }, + text = { + AuthenticatedAsyncImage( + imageUrl = imageUrl, + accessToken = accessToken, + contentDescription = attachment.fileName, + modifier = Modifier + .fillMaxWidth() + .height(360.dp) + .clip(RoundedCornerShape(16.dp)), + contentScale = ContentScale.Fit + ) + } + ) +} + +@Composable +private fun ChatImagePreviewDialog( + entry: ChatImagePreviewEntry, + accessToken: String?, + imageUrl: String, + enableEdgeTapNavigation: Boolean, + canNavigatePrevious: Boolean, + canNavigateNext: Boolean, + canDelete: Boolean, + isProtectedContent: Boolean, + onDismiss: () -> Unit, + onNavigatePrevious: () -> Unit, + onNavigateNext: () -> Unit, + onSaveToGallery: () -> Unit, + onReply: () -> Unit, + onShare: () -> Unit, + onDelete: () -> Unit +) { + var showMenu by remember(entry.attachment.id) { mutableStateOf(false) } + var scale by remember(entry.attachment.id) { mutableStateOf(1f) } + var translation by remember(entry.attachment.id) { mutableStateOf(Offset.Zero) } + val density = LocalDensity.current + + Dialog( + onDismissRequest = onDismiss, + properties = DialogProperties(usePlatformDefaultWidth = false) + ) { + Box( + modifier = Modifier + .fillMaxSize() + .background(Color.Black) + ) { + Column(modifier = Modifier.fillMaxSize()) { + Spacer(modifier = Modifier.windowInsetsTopHeight(WindowInsets.statusBars)) + Row( + modifier = Modifier + .fillMaxWidth() + .background(Color.Black.copy(alpha = 0.72f)) + .padding(horizontal = 12.dp, vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp) + ) { + ImagePreviewHeaderButton( + icon = Icons.AutoMirrored.Outlined.ArrowBack, + contentDescription = "Назад", + onClick = onDismiss + ) + + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(2.dp) + ) { + Text( + text = entry.message.sender.displayName, + style = MaterialTheme.typography.titleMedium, + color = Color.White, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + Text( + text = formatImagePreviewTimestamp(entry.message.sentAt), + style = MaterialTheme.typography.bodySmall, + color = Color.White.copy(alpha = 0.8f), + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + + Box { + ImagePreviewHeaderButton( + icon = Icons.Outlined.Menu, + contentDescription = "Меню", + onClick = { showMenu = true } + ) + DropdownMenu( + expanded = showMenu, + onDismissRequest = { showMenu = false } + ) { + if (!isProtectedContent) { + DropdownMenuItem( + text = { Text("Сохранить в галерею") }, + onClick = { + showMenu = false + onSaveToGallery() + } + ) + } + DropdownMenuItem( + text = { Text("Ответить") }, + onClick = { + showMenu = false + onReply() + } + ) + if (!isProtectedContent) { + DropdownMenuItem( + text = { Text("Поделиться") }, + onClick = { + showMenu = false + onShare() + } + ) + } + if (canDelete) { + DropdownMenuItem( + text = { Text("Удалить", color = TelegramDanger) }, + onClick = { + showMenu = false + onDelete() + } + ) + } + } + } + } + + BoxWithConstraints( + modifier = Modifier + .weight(1f) + .fillMaxWidth() + ) { + val containerWidthPx = with(density) { maxWidth.toPx() } + val containerHeightPx = with(density) { maxHeight.toPx() } + + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Box( + modifier = Modifier + .fillMaxSize() + .pointerInput(entry.attachment.id) { + detectTransformGestures { _, pan, zoom, _ -> + val nextScale = (scale * zoom).coerceIn(1f, 4f) + val maxTranslationX = ((nextScale - 1f) * containerWidthPx / 2f).coerceAtLeast(0f) + val maxTranslationY = ((nextScale - 1f) * containerHeightPx / 2f).coerceAtLeast(0f) + translation = if (nextScale == 1f) { + Offset.Zero + } else { + Offset( + x = (translation.x + pan.x).coerceIn(-maxTranslationX, maxTranslationX), + y = (translation.y + pan.y).coerceIn(-maxTranslationY, maxTranslationY) + ) + } + scale = nextScale + } + }, + contentAlignment = Alignment.Center + ) { + AuthenticatedAsyncImage( + imageUrl = imageUrl, + accessToken = accessToken, + contentDescription = entry.attachment.fileName, + modifier = Modifier + .fillMaxSize() + .graphicsLayer { + scaleX = scale + scaleY = scale + translationX = translation.x + translationY = translation.y + }, + contentScale = ContentScale.Fit + ) + } + + if (enableEdgeTapNavigation && canNavigatePrevious) { + ImagePreviewEdgeTapZone( + modifier = Modifier.align(Alignment.CenterStart), + onTap = onNavigatePrevious + ) + } + + if (enableEdgeTapNavigation && canNavigateNext) { + ImagePreviewEdgeTapZone( + modifier = Modifier.align(Alignment.CenterEnd), + onTap = onNavigateNext + ) + } + } + } + } + } + } +} + +@Composable +private fun ImagePreviewHeaderButton( + icon: ImageVector, + contentDescription: String, + onClick: () -> Unit +) { + Surface( + color = Color.White.copy(alpha = 0.14f), + shape = CircleShape, + modifier = Modifier + .size(40.dp) + .clickable(onClick = onClick) + ) { + Box(contentAlignment = Alignment.Center) { + Icon( + imageVector = icon, + contentDescription = contentDescription, + tint = Color.White + ) + } + } +} + +@Composable +private fun ImagePreviewEdgeTapZone( + modifier: Modifier = Modifier, + onTap: () -> Unit +) { + Box( + modifier = modifier + .fillMaxHeight() + .width(72.dp) + .pointerInput(onTap) { + detectTapGestures(onTap = { onTap() }) + } + ) +} + +private fun buildMessageMenuItems( + message: MessageDto, + currentUserId: String?, + onCopy: () -> Unit, + onForward: () -> Unit, + onReply: () -> Unit, + onReact: () -> Unit, + onPin: () -> Unit, + onEdit: () -> Unit, + onDelete: () -> Unit +): List { + val actions = mutableListOf() + if (!message.isDeleted() && !message.hasProtectedContent && message.text.isNotBlank()) { + actions.add( + MessageActionItem( + label = "Копировать", + icon = Icons.Outlined.ContentCopy, + onClick = onCopy + ) + ) + } + + if (!message.isDeleted() && (message.attachments.isNotEmpty() || message.text.isNotBlank())) { + if (!message.hasProtectedContent) { + actions.add( + MessageActionItem( + label = "Переслать", + icon = Icons.AutoMirrored.Outlined.Send, + onClick = onForward + ) + ) + } + actions.add( + MessageActionItem( + label = "Ответить", + icon = Icons.AutoMirrored.Outlined.ArrowBack, + onClick = onReply + ) + ) + actions.add( + MessageActionItem( + label = "Реакция", + icon = Icons.Outlined.Mood, + onClick = onReact + ) + ) + } + + if (!message.isDeleted() && (message.attachments.isNotEmpty() || message.text.isNotBlank())) { + actions.add( + MessageActionItem( + label = if (message.pinnedAt == null) "Закрепить" else "Открепить", + icon = Icons.Outlined.PushPin, + onClick = onPin + ) + ) + } + + if (message.canManage(currentUserId)) { + actions.add( + MessageActionItem( + label = "Изменить", + icon = Icons.Outlined.Edit, + onClick = onEdit + ) + ) + actions.add( + MessageActionItem( + label = "Удалить", + icon = Icons.Outlined.Delete, + isDestructive = true, + onClick = onDelete + ) + ) + } + + if (!message.canManage(currentUserId) && message.canDelete(currentUserId)) { + actions.add(buildDeleteMessageAction(onDelete)) + } + + return actions +} + +private fun buildDeleteMessageAction(onDelete: () -> Unit): MessageActionItem = + MessageActionItem( + label = "\u0423\u0434\u0430\u043b\u0438\u0442\u044c", + icon = Icons.Outlined.Delete, + isDestructive = true, + onClick = onDelete + ) + +private fun buildMessageActionItems( + message: MessageDto, + currentUserId: String?, + onCopy: () -> Unit, + onForward: () -> Unit, + onReply: () -> Unit, + onReact: () -> Unit, + onEdit: () -> Unit, + onDelete: () -> Unit +): List Unit>> { + val actions = mutableListOf Unit>>() + if (!message.isDeleted() && !message.hasProtectedContent && message.text.isNotBlank()) { + actions.add("Копировать" to onCopy) + } + + if (!message.isDeleted() && !message.hasProtectedContent && (message.attachments.isNotEmpty() || message.text.isNotBlank())) { + actions.add("Переслать" to onForward) + actions.add("Ответить" to onReply) + actions.add("Реакция" to onReact) + } + + if (message.canManage(currentUserId)) { + actions.add("Изменить" to onEdit) + actions.add("Удалить" to onDelete) + } + + return actions +} + +private fun messageSelectionCountLabel(selectedMessagesCount: Int): String = when { + selectedMessagesCount % 10 == 1 && selectedMessagesCount % 100 != 11 -> "Выбрано $selectedMessagesCount сообщение" + selectedMessagesCount % 10 in 2..4 && selectedMessagesCount % 100 !in 12..14 -> "Выбрано $selectedMessagesCount сообщения" + else -> "Выбрано $selectedMessagesCount сообщений" +} + +private fun pinnedMessagePreview(message: MessageDto): String = when { + message.text.isNotBlank() -> message.text + message.attachments.size == 1 -> message.attachments.first().fileName + message.attachments.isNotEmpty() -> "${message.attachments.size} files" + else -> "Message" +} + +private fun deleteSelectedMessagesDialogTitle(selectedMessagesCount: Int): String = + if (selectedMessagesCount == 1) "Удалить выбранное сообщение?" else "Удалить выбранные сообщения?" + +private fun deleteSelectedMessagesDialogMessage(selectedMessagesCount: Int): String = + if (selectedMessagesCount == 1) { + "Выбранное сообщение исчезнет из чата после подтверждения." + } else { + "Выбранные сообщения исчезнут из чата после подтверждения." + } + +private fun buildChatRows(messages: List): List { + if (messages.isEmpty()) { + return emptyList() + } + + val chronologicalRows = buildList { + var previousDate: LocalDate? = null + messages.forEach { message -> + val currentDate = parseMessageDate(message.sentAt)?.toLocalDate() + if (currentDate != null && currentDate != previousDate) { + add(ChatRow.DateSeparator("date-$currentDate", formatDateSeparator(currentDate))) + previousDate = currentDate + } + add(ChatRow.Message(message)) + } + } + + return chronologicalRows.asReversed() +} + +private fun chatRowKey(row: ChatRow): String = when (row) { + is ChatRow.DateSeparator -> row.id + is ChatRow.Message -> row.item.id +} + +private fun findLastReadRowIndexBeforeFirstUnread(rows: List, firstUnreadRowIndex: Int): Int? { + if (firstUnreadRowIndex >= rows.lastIndex) { + return null + } + + return ((firstUnreadRowIndex + 1)..rows.lastIndex) + .firstOrNull { index -> rows[index] is ChatRow.Message } +} + +private fun formatMessageTime(value: String): String = + parseMessageDate(value)?.format(ChatTimeFormatter) ?: value + +private fun formatCompactCount(value: Int): String = when { + value >= 1_000_000 -> String.format(Locale.US, "%.1fM", value / 1_000_000f).trimTrailingZero() + value >= 1_000 -> String.format(Locale.US, "%.1fK", value / 1_000f).trimTrailingZero() + else -> value.toString() +} + +private fun formatImagePreviewTimestamp(value: String): String = + parseMessageDate(value)?.let { timestamp -> + val dateFormatter = if (timestamp.year == LocalDate.now().year) ChatDayFormatter else ChatDayYearFormatter + "${timestamp.format(dateFormatter)} • ${timestamp.format(ChatTimeFormatter)}" + } ?: value + +private fun formatDateSeparator(date: LocalDate): String { + val today = LocalDate.now() + return when (date) { + today -> "Сегодня" + today.minusDays(1) -> "Вчера" + else -> if (date.year == today.year) date.format(ChatDayFormatter) else date.format(ChatDayYearFormatter) + } +} + +private fun parseMessageDate(value: String): OffsetDateTime? = + runCatching { + OffsetDateTime.parse(value) + .atZoneSameInstant(ZoneId.systemDefault()) + .toOffsetDateTime() + }.getOrNull() + +private fun formatFileSize(sizeBytes: Long): String = when { + sizeBytes >= 1024L * 1024L -> String.format(Locale.US, "%.1f MB", sizeBytes / 1024f / 1024f) + sizeBytes >= 1024L -> "${sizeBytes / 1024L} KB" + else -> "$sizeBytes B" +} + +private fun formatChannelSubscribers(count: Int): String = + "$count ${russianPlural(count, "подписчик", "подписчика", "подписчиков")}" + +private fun russianPlural(count: Int, one: String, few: String, many: String): String { + val value = kotlin.math.abs(count) % 100 + val lastDigit = value % 10 + if (value in 11..14) { + return many + } + + return when (lastDigit) { + 1 -> one + in 2..4 -> few + else -> many + } +} + +private fun String.trimTrailingZero(): String = + replace(".0", "") + +private fun buildVoiceNoteSecondaryText( + attachment: AttachmentDto, + playbackState: VoiceAttachmentPlaybackUiState? +): String { + val durationText = playbackState?.durationMs?.takeIf { it > 0L }?.let(::formatDurationMs) + ?: formatFileSize(attachment.fileSizeBytes) + val positionText = playbackState?.positionMs?.takeIf { it > 0L }?.let(::formatDurationMs) + + return if (positionText != null) "$positionText / $durationText" else durationText +} diff --git a/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/chat/ChatScrollStateStore.kt b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/chat/ChatScrollStateStore.kt new file mode 100644 index 00000000..a3d4005c --- /dev/null +++ b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/chat/ChatScrollStateStore.kt @@ -0,0 +1,43 @@ +package com.seven.ikar.kotlin.feature.chat + +import android.content.Context +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json + +@Serializable +data class SavedChatScrollPosition( + val rowKey: String? = null, + val itemIndex: Int = 0, + val itemScrollOffset: Int = 0 +) + +class ChatScrollStateStore( + context: Context, + private val json: Json +) { + private val preferences = + context.getSharedPreferences(PREFERENCES_NAME, Context.MODE_PRIVATE) + + fun read(chatId: String): SavedChatScrollPosition? { + val raw = preferences.getString(key(chatId), null) ?: return null + return runCatching { + json.decodeFromString(raw) + }.getOrNull() + } + + fun save(chatId: String, position: SavedChatScrollPosition) { + preferences.edit() + .putString(key(chatId), json.encodeToString(SavedChatScrollPosition.serializer(), position)) + .apply() + } + + fun clear(chatId: String) { + preferences.edit().remove(key(chatId)).apply() + } + + private fun key(chatId: String): String = "scroll_$chatId" + + private companion object { + private const val PREFERENCES_NAME = "ikar_kotlin_chat_scroll" + } +} diff --git a/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/chat/ChatViewModel.kt b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/chat/ChatViewModel.kt new file mode 100644 index 00000000..b6f23102 --- /dev/null +++ b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/chat/ChatViewModel.kt @@ -0,0 +1,2006 @@ +package com.seven.ikar.kotlin.feature.chat + +import android.Manifest +import android.content.Context +import android.content.pm.PackageManager +import android.net.ConnectivityManager +import android.net.NetworkCapabilities +import android.net.Uri +import androidx.core.content.ContextCompat +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.seven.ikar.kotlin.app.AppContainer +import com.seven.ikar.kotlin.core.contacts.PhoneNumberNormalizer +import com.seven.ikar.kotlin.core.model.AttachmentDto +import com.seven.ikar.kotlin.core.model.AuthSessionDto +import com.seven.ikar.kotlin.core.model.BotCommandDto +import com.seven.ikar.kotlin.core.model.CallMediaType +import com.seven.ikar.kotlin.core.model.ChatDetailsDto +import com.seven.ikar.kotlin.core.model.ChatType +import com.seven.ikar.kotlin.core.model.MessageDeliveryState +import com.seven.ikar.kotlin.core.model.MessageDto +import com.seven.ikar.kotlin.core.model.MessageReplyDto +import com.seven.ikar.kotlin.core.model.TypingIndicatorDto +import com.seven.ikar.kotlin.core.model.UserSummaryDto +import com.seven.ikar.kotlin.core.network.ServerConfig +import com.seven.ikar.kotlin.core.network.UploadFileSource +import com.seven.ikar.kotlin.core.local.TransferQueueEntity +import com.seven.ikar.kotlin.feature.call.AudioCallSnapshot +import com.seven.ikar.kotlin.feature.call.AudioCallStage +import com.seven.ikar.kotlin.feature.transfer.TransferForegroundNotification +import com.seven.ikar.kotlin.ui.formatPresence +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import java.time.OffsetDateTime +import java.time.ZoneId +import java.time.ZoneOffset +import java.time.format.DateTimeFormatter +import java.util.Locale +import java.util.UUID + +data class PendingAttachmentUiState( + val fileName: String, + val contentType: String, + val fileSizeBytes: Long, + val imagePreviews: List = emptyList() +) + +data class PendingImageAttachmentUiState( + val fileName: String, + val localPath: String +) + +data class TransferQueueUiState( + val groupId: String, + val title: String, + val status: String, + val progressPercent: Int, + val errorMessage: String? +) + +data class VoiceAttachmentPlaybackUiState( + val isPlaying: Boolean = false, + val positionMs: Long = 0L, + val durationMs: Long = 0L +) + +data class BotQuickAction( + val title: String, + val commandText: String +) + +data class ChatSearchUiState( + val isActive: Boolean = false, + val query: String = "", + val resultMessageIds: List = emptyList(), + val activeResultIndex: Int = 0, + val isSearching: Boolean = false +) { + val activeMessageId: String? + get() = resultMessageIds.getOrNull(activeResultIndex) +} + +data class ChatScrollRequest( + val messageId: String?, + val requestId: Long = System.nanoTime() +) + +data class ChatUiState( + val session: AuthSessionDto? = null, + val title: String = "", + val subtitle: String = "", + val chatId: String = "", + val chatType: ChatType = ChatType.Direct, + val peer: UserSummaryDto? = null, + val botPeer: UserSummaryDto? = null, + val messages: List = emptyList(), + val pinnedMessages: List = emptyList(), + val hasMoreMessages: Boolean = false, + val isLoadingOlderMessages: Boolean = false, + val draftText: String = "", + val canSendMessages: Boolean = true, + val subscriberCount: Int = 0, + val adminCount: Int = 0, + val isSubscribed: Boolean = false, + val canSubscribe: Boolean = false, + val canManageChannel: Boolean = false, + val canPostAsChannel: Boolean = false, + val publicUsername: String? = null, + val linkedDiscussionChatId: String? = null, + val channelDescription: String? = null, + val isJoiningChannel: Boolean = false, + val isLeavingChannel: Boolean = false, + val isLoading: Boolean = false, + val errorMessage: String? = null, + val activeCall: AudioCallSnapshot? = null, + val pendingAttachment: PendingAttachmentUiState? = null, + val replyTo: MessageReplyDto? = null, + val editingMessageId: String? = null, + val editingMessageBanner: String = "", + val isRecordingVoiceNote: Boolean = false, + val voiceRecordingElapsedText: String = "00:00", + val attachmentPlayback: Map = emptyMap(), + val botCommands: List = emptyList(), + val previewImageAttachmentId: String? = null, + val firstUnreadMessageId: String? = null, + val selectedMessageIds: Set = emptySet(), + val isDeleteSelectionDialogVisible: Boolean = false, + val isDeletingSelectedMessages: Boolean = false, + val sendingLocalMessageIds: Set = emptySet(), + val failedLocalMessageIds: Set = emptySet(), + val activeTransfers: List = emptyList(), + val search: ChatSearchUiState = ChatSearchUiState(), + val scrollRequest: ChatScrollRequest? = null, + val isSendingMessage: Boolean = false, + val isDeletingChat: Boolean = false, + val isChatDeleted: Boolean = false +) { + val isMessageSelectionMode: Boolean + get() = selectedMessageIds.isNotEmpty() + + val selectedMessagesCount: Int + get() = selectedMessageIds.size +} + +private val DefaultCodexBridgeBotCommands = listOf( + BotCommandDto(command = "help", description = "Показать список команд моста"), + BotCommandDto(command = "status", description = "Показать состояние моста и выбранный thread"), + BotCommandDto(command = "threads", description = "Показать thread по именам"), + BotCommandDto(command = "use", description = "Выбрать thread из последнего списка"), + BotCommandDto(command = "newthread", description = "Создать новый thread"), + BotCommandDto(command = "turn", description = "Запустить turn в выбранном thread") +) + +private val BotThreadOptionRegex = Regex("""^(?\d+)\.\s+(?.+?)(?<selected>\s+\[выбран])?$""") +private val ChatTimestampFormatter: DateTimeFormatter = DateTimeFormatter.ofPattern("HH:mm", Locale("ru", "RU")) +private const val LocalMessageIdPrefix = "local-" + +class ChatViewModel( + private val container: AppContainer, + private val chatId: String +) : ViewModel() { + private val _uiState = MutableStateFlow(ChatUiState(chatId = chatId, session = container.sessionStore.read())) + val uiState: StateFlow<ChatUiState> = _uiState.asStateFlow() + + private val cachedAttachmentFiles = mutableMapOf<String, LocalAttachmentFile>() + private var contactNamesByNormalizedPhone: Map<String, String> = emptyMap() + private var editingMessage: MessageDto? = null + private var pendingAttachments: List<LocalAttachmentFile> = emptyList() + private var baseSubtitle: String = "" + private val typingUsers = mutableMapOf<String, UserSummaryDto>() + private val typingExpiryJobs = mutableMapOf<String, Job>() + private var typingStopJob: Job? = null + private var searchJob: Job? = null + private var typingStateSent = false + private var lastTypingSentAtMillis = 0L + + init { + attachRealtime() + attachCallState() + attachVoicePlaybackState() + attachVoiceRecordingState() + attachTransferQueueState() + loadCachedMessages() + refresh() + } + + fun updateDraft(value: String) { + _uiState.value = _uiState.value.copy(draftText = value) + notifyTyping(value.isNotBlank()) + } + + override fun onCleared() { + notifyTyping(false) + typingExpiryJobs.values.forEach(Job::cancel) + searchJob?.cancel() + super.onCleared() + } + + fun readSavedScrollPosition(): SavedChatScrollPosition? = + container.chatScrollStateStore.read(chatId) + + fun saveScrollPosition(position: SavedChatScrollPosition) { + container.chatScrollStateStore.save(chatId, position) + } + + fun toggleMessageSelection(message: MessageDto) { + if (!canSelectMessage(message)) { + return + } + + val currentState = _uiState.value + val nextSelectedMessageIds = if (currentState.selectedMessageIds.contains(message.id)) { + currentState.selectedMessageIds - message.id + } else { + currentState.selectedMessageIds + message.id + } + + _uiState.value = currentState.copy( + selectedMessageIds = nextSelectedMessageIds, + isDeleteSelectionDialogVisible = currentState.isDeleteSelectionDialogVisible && nextSelectedMessageIds.isNotEmpty(), + isDeletingSelectedMessages = currentState.isDeletingSelectedMessages && nextSelectedMessageIds.isNotEmpty() + ) + } + + fun handleMessageLongPress(message: MessageDto) { + if (!canSelectMessage(message)) { + return + } + + val currentState = _uiState.value + if (currentState.isMessageSelectionMode) { + toggleMessageSelection(message) + } else { + _uiState.value = currentState.copy(selectedMessageIds = setOf(message.id)) + } + } + + fun clearMessageSelection() { + _uiState.value = _uiState.value.copy( + selectedMessageIds = emptySet(), + isDeleteSelectionDialogVisible = false, + isDeletingSelectedMessages = false + ) + } + + fun showDeleteSelectedMessagesDialog() { + val state = _uiState.value + if (state.selectedMessageIds.isEmpty() || state.isDeletingSelectedMessages) { + return + } + + _uiState.value = state.copy(isDeleteSelectionDialogVisible = true) + } + + fun dismissDeleteSelectedMessagesDialog() { + if (_uiState.value.isDeletingSelectedMessages) { + return + } + + _uiState.value = _uiState.value.copy(isDeleteSelectionDialogVisible = false) + } + + fun confirmDeleteSelectedMessages() { + val state = _uiState.value + if (state.selectedMessageIds.isEmpty() || state.isDeletingSelectedMessages) { + return + } + + val selectedMessages = state.messages.filter { state.selectedMessageIds.contains(it.id) && canSelectMessage(it) } + if (selectedMessages.isEmpty()) { + clearMessageSelection() + return + } + + viewModelScope.launch { + _uiState.value = state.copy(isDeletingSelectedMessages = true, errorMessage = null) + runCatching { + container.executeAuthorized { session -> + selectedMessages.map { message -> + container.apiClient.deleteMessage(session.accessToken, chatId, message.id) + } + } + }.onSuccess { updatedMessages -> + updatedMessages.forEach { updated -> + if (editingMessage?.id == updated.id) { + cancelEditMessageInternal(clearDraft = true) + } + replaceOrInsertMessage(updated) + } + clearMessageSelection() + }.onFailure { error -> + _uiState.value = _uiState.value.copy( + isDeletingSelectedMessages = false, + errorMessage = error.message + ) + } + } + } + + fun appendEmoji(emoji: String) { + _uiState.value = _uiState.value.copy(draftText = _uiState.value.draftText + emoji) + } + + fun clearErrorMessage() { + _uiState.value = _uiState.value.copy(errorMessage = null) + } + + fun setErrorMessage(message: String?) { + _uiState.value = _uiState.value.copy(errorMessage = message) + } + + fun openMessageSearch() { + val state = _uiState.value + _uiState.value = state.copy( + selectedMessageIds = emptySet(), + isDeleteSelectionDialogVisible = false, + search = state.search.copy(isActive = true) + ) + } + + fun closeMessageSearch() { + searchJob?.cancel() + _uiState.value = _uiState.value.copy(search = ChatSearchUiState()) + } + + fun jumpToMessage(messageId: String) { + if (messageId.isBlank()) { + return + } + + _uiState.value = _uiState.value.copy( + selectedMessageIds = emptySet(), + isDeleteSelectionDialogVisible = false, + search = ChatSearchUiState( + isActive = true, + resultMessageIds = listOf(messageId), + activeResultIndex = 0, + isSearching = false + ) + ) + } + + fun requestScrollToMessage(messageId: String) { + if (messageId.isBlank()) { + return + } + requestScrollToMessageInternal(messageId) + } + + fun requestScrollToLatest() { + requestScrollToMessageInternal(messageId = null) + } + + fun consumeScrollRequest(requestId: Long) { + val current = _uiState.value.scrollRequest ?: return + if (current.requestId == requestId) { + _uiState.value = _uiState.value.copy(scrollRequest = null) + } + } + + fun updateMessageSearchQuery(value: String) { + searchJob?.cancel() + val localResultIds = searchLoadedMessageIds(value, _uiState.value.messages) + _uiState.value = _uiState.value.copy( + search = ChatSearchUiState( + isActive = true, + query = value, + resultMessageIds = localResultIds, + activeResultIndex = 0, + isSearching = value.trim().isNotBlank() + ) + ) + + val query = value.trim() + if (query.isBlank()) { + _uiState.value = _uiState.value.copy(search = _uiState.value.search.copy(isSearching = false)) + return + } + + searchJob = viewModelScope.launch { + delay(250) + runCatching { + container.executeAuthorized { session -> + session to container.apiClient.searchChatMessages( + accessToken = session.accessToken, + chatId = chatId, + query = query, + limit = 100 + ) + } + }.onSuccess { (session, remoteMessages) -> + val currentState = _uiState.value + if (currentState.search.query.trim() != query) { + return@onSuccess + } + + val mergedMessages = (currentState.messages + remoteMessages) + .distinctBy { it.id } + .sortedBy { it.sentAt } + val resultMessageIds = remoteMessages + .filterNot { it.isDeleted() } + .sortedByDescending { it.sentAt } + .map(MessageDto::id) + .ifEmpty { searchLoadedMessageIds(query, mergedMessages) } + + _uiState.value = currentState.copy( + messages = mergedMessages, + search = currentState.search.copy( + resultMessageIds = resultMessageIds, + activeResultIndex = 0, + isSearching = false + ) + ) + cacheMessages(session.user.id, remoteMessages) + }.onFailure { error -> + _uiState.value = _uiState.value.copy( + search = _uiState.value.search.copy(isSearching = false), + errorMessage = error.message + ) + } + } + } + + fun navigateMessageSearch(delta: Int) { + val state = _uiState.value + val size = state.search.resultMessageIds.size + if (size == 0) { + return + } + + val nextIndex = (state.search.activeResultIndex + delta).floorMod(size) + _uiState.value = state.copy(search = state.search.copy(activeResultIndex = nextIndex)) + } + + fun refresh() { + if (container.sessionStore.read() == null) return + viewModelScope.launch { + _uiState.value = _uiState.value.copy(isLoading = true, errorMessage = null) + runCatching { + container.executeAuthorized { session -> + container.realtimeService.connect(session.accessToken) + session to container.apiClient.getChat(chatId, session.accessToken) + } + }.onSuccess { (session, chat) -> + contactNamesByNormalizedPhone = loadDeviceContactNames() + applyChat(chat, session) + }.onFailure { error -> + _uiState.value = _uiState.value.copy(isLoading = false, errorMessage = error.message) + } + } + } + + fun loadOlderMessages() { + val state = _uiState.value + val oldestMessageId = state.messages.firstOrNull()?.id + if ( + oldestMessageId == null || + !state.hasMoreMessages || + state.isLoadingOlderMessages || + state.isLoading + ) { + return + } + + viewModelScope.launch { + _uiState.value = _uiState.value.copy(isLoadingOlderMessages = true, errorMessage = null) + runCatching { + container.executeAuthorized { session -> + session to container.apiClient.getChatMessagesPage( + accessToken = session.accessToken, + chatId = chatId, + beforeMessageId = oldestMessageId + ) + } + }.onSuccess { (session, page) -> + val currentState = _uiState.value + val mergedMessages = (page.messages + currentState.messages) + .distinctBy { it.id } + .sortedBy { it.sentAt } + val selectedMessageIds = sanitizeSelectedMessageIds(currentState.selectedMessageIds, mergedMessages) + + _uiState.value = currentState.copy( + messages = mergedMessages, + hasMoreMessages = page.hasMoreMessages, + isLoadingOlderMessages = false, + selectedMessageIds = selectedMessageIds, + isDeleteSelectionDialogVisible = currentState.isDeleteSelectionDialogVisible && selectedMessageIds.isNotEmpty(), + isDeletingSelectedMessages = currentState.isDeletingSelectedMessages && selectedMessageIds.isNotEmpty() + ) + cacheMessages(session.user.id, page.messages) + }.onFailure { error -> + _uiState.value = _uiState.value.copy( + isLoadingOlderMessages = false, + errorMessage = error.message + ) + } + } + } + + fun sendMessage() { + val state = _uiState.value + if (!state.canSendMessages || state.isSendingMessage) { + return + } + + val attachments = pendingAttachments + val messageBeingEdited = editingMessage + val draftText = state.draftText.trim() + val replyToMessageId = state.replyTo?.messageId + if (attachments.isEmpty() && messageBeingEdited == null && draftText.isBlank()) { + return + } + + notifyTyping(false) + + if (attachments.isNotEmpty()) { + _uiState.value = state.copy(isSendingMessage = true, errorMessage = null) + viewModelScope.launch { + runCatching { + container.transferQueueRepository.enqueueUpload( + chatId = chatId, + text = draftText.takeIf { it.isNotBlank() }, + replyToMessageId = replyToMessageId, + files = attachments + ) + }.onSuccess { + clearPendingAttachment() + _uiState.value = _uiState.value.copy(draftText = "", replyTo = null, isSendingMessage = false) + }.onFailure { error -> + _uiState.value = _uiState.value.copy(isSendingMessage = false, errorMessage = error.message) + } + } + return + } + + val optimisticMessage = if (messageBeingEdited == null && draftText.isNotBlank()) { + buildOptimisticMessage(state, draftText) + } else { + null + } + + if (optimisticMessage != null) { + val optimisticMessages = (state.messages + optimisticMessage) + .distinctBy { it.id } + .sortedBy { it.sentAt } + _uiState.value = state.copy( + messages = optimisticMessages, + draftText = "", + replyTo = null, + isSendingMessage = false, + errorMessage = null, + sendingLocalMessageIds = state.sendingLocalMessageIds + optimisticMessage.id, + failedLocalMessageIds = state.failedLocalMessageIds - optimisticMessage.id + ) + cacheMessage(optimisticMessage, isOptimistic = true) + } else { + _uiState.value = state.copy(isSendingMessage = true, errorMessage = null) + } + + viewModelScope.launch { + runCatching { + when { + messageBeingEdited != null -> { + container.executeAuthorized { session -> + container.apiClient.updateMessage( + accessToken = session.accessToken, + chatId = chatId, + messageId = messageBeingEdited.id, + text = draftText + ) + } + } + + draftText.isNotBlank() -> { + container.executeAuthorized { session -> + container.apiClient.sendMessage( + chatId = chatId, + text = draftText, + accessToken = session.accessToken, + replyToMessageId = replyToMessageId + ) + } + } + + else -> null + } + }.onSuccess { message -> + val localMessageId = optimisticMessage?.id + when { + message == null -> { + _uiState.value = _uiState.value.copy(isSendingMessage = false) + } + + messageBeingEdited != null -> { + cancelEditMessageInternal(clearDraft = true) + _uiState.value = _uiState.value.copy(isSendingMessage = false) + replaceOrInsertMessage(message) + } + + else -> { + if (localMessageId != null) { + replaceLocalMessage(localMessageId, message) + } else { + _uiState.value = _uiState.value.copy(draftText = "", replyTo = null, isSendingMessage = false) + replaceOrInsertMessage(message) + } + } + } + }.onFailure { error -> + val localMessageId = optimisticMessage?.id + if (localMessageId != null) { + val currentState = _uiState.value + _uiState.value = currentState.copy( + errorMessage = error.message, + sendingLocalMessageIds = currentState.sendingLocalMessageIds - localMessageId, + failedLocalMessageIds = currentState.failedLocalMessageIds + localMessageId + ) + } else { + _uiState.value = _uiState.value.copy(isSendingMessage = false, errorMessage = error.message) + } + } + } + } + + fun ensureBotCommandsLoaded() { + val state = _uiState.value + val botPeer = state.botPeer ?: state.peer?.takeIf { it.isBot } + if (state.chatType != ChatType.Direct || botPeer == null) { + return + } + + if (state.botCommands.isNotEmpty()) { + return + } + + viewModelScope.launch { + runCatching { + container.executeAuthorized { session -> + container.apiClient.getBotCommands(session.accessToken, botPeer.id) + } + }.onSuccess { commands -> + _uiState.value = _uiState.value.copy( + botCommands = commands.ifEmpty { DefaultCodexBridgeBotCommands } + ) + }.onFailure { + _uiState.value = _uiState.value.copy(botCommands = DefaultCodexBridgeBotCommands) + } + } + } + + fun executeBotCommand(commandText: String) { + if (commandText.isBlank()) { + return + } + + viewModelScope.launch { + runCatching { + container.executeAuthorized { session -> + container.apiClient.sendMessage(chatId, commandText.trim(), session.accessToken) + } + }.onSuccess { message -> + if (commandText.startsWith("/turn ", ignoreCase = true)) { + _uiState.value = _uiState.value.copy(draftText = "") + } + replaceOrInsertMessage(message) + }.onFailure { error -> + _uiState.value = _uiState.value.copy(errorMessage = error.message) + } + } + } + + fun clearChatHistory() { + viewModelScope.launch { + runCatching { + container.executeAuthorized { session -> + container.apiClient.clearChatHistory(session.accessToken, chatId) + session.user.id + } + }.onSuccess { ownerUserId -> + clearPendingAttachment() + cancelEditMessageInternal(clearDraft = true) + container.chatScrollStateStore.clear(chatId) + clearCachedChat(ownerUserId) + _uiState.value = _uiState.value.copy( + messages = emptyList(), + pinnedMessages = emptyList(), + hasMoreMessages = false, + isLoadingOlderMessages = false, + errorMessage = null, + replyTo = null, + selectedMessageIds = emptySet(), + isDeleteSelectionDialogVisible = false, + isDeletingSelectedMessages = false, + sendingLocalMessageIds = emptySet(), + failedLocalMessageIds = emptySet() + ) + }.onFailure { error -> + _uiState.value = _uiState.value.copy(errorMessage = error.message) + } + } + } + + fun deleteChat() { + val state = _uiState.value + if (state.isDeletingChat || state.isChatDeleted) { + return + } + + viewModelScope.launch { + _uiState.value = _uiState.value.copy(isDeletingChat = true, errorMessage = null) + runCatching { + container.executeAuthorized { session -> + container.apiClient.deleteChat( + accessToken = session.accessToken, + chatId = chatId, + forEveryone = false + ) + session.user.id + } + }.onSuccess { ownerUserId -> + clearPendingAttachment() + cancelEditMessageInternal(clearDraft = true) + container.chatScrollStateStore.clear(chatId) + container.pushSettings.resetChatNotificationPreferences(chatId) + clearCachedChat(ownerUserId) + _uiState.value = _uiState.value.copy( + isDeletingChat = false, + isChatDeleted = true, + errorMessage = null + ) + }.onFailure { error -> + _uiState.value = _uiState.value.copy( + isDeletingChat = false, + errorMessage = error.message + ) + } + } + } + + fun subscribeToChannel() { + val state = _uiState.value + if (state.chatType != ChatType.Channel || !state.canSubscribe || state.isJoiningChannel) { + return + } + + viewModelScope.launch { + _uiState.value = _uiState.value.copy(isJoiningChannel = true, errorMessage = null) + runCatching { + container.executeAuthorized { session -> + session to container.apiClient.subscribeToChannel(session.accessToken, chatId) + } + }.onSuccess { (session, chat) -> + applyChat(chat, session) + requestScrollToLatest() + }.onFailure { error -> + _uiState.value = _uiState.value.copy(isJoiningChannel = false, errorMessage = error.message) + } + } + } + + fun unsubscribeFromChannel() { + val state = _uiState.value + if (state.chatType != ChatType.Channel || !state.isSubscribed || state.canManageChannel || state.isLeavingChannel) { + return + } + + viewModelScope.launch { + _uiState.value = _uiState.value.copy(isLeavingChannel = true, errorMessage = null) + runCatching { + container.executeAuthorized { session -> + container.apiClient.unsubscribeFromChannel(session.accessToken, chatId) + session.user.id + } + }.onSuccess { ownerUserId -> + clearPendingAttachment() + cancelEditMessageInternal(clearDraft = true) + container.chatScrollStateStore.clear(chatId) + container.pushSettings.resetChatNotificationPreferences(chatId) + clearCachedChat(ownerUserId) + _uiState.value = _uiState.value.copy( + isLeavingChannel = false, + isChatDeleted = true, + errorMessage = null + ) + }.onFailure { error -> + _uiState.value = _uiState.value.copy(isLeavingChannel = false, errorMessage = error.message) + } + } + } + + fun beginEditMessage(message: MessageDto) { + if (!message.canManage(currentUserId = _uiState.value.session?.user?.id)) { + return + } + + clearPendingAttachment() + cancelReplyInternal() + editingMessage = message + _uiState.value = _uiState.value.copy( + editingMessageId = message.id, + editingMessageBanner = "Сообщение от ${formatTimestamp(message.sentAt)}", + draftText = message.text + ) + } + + fun cancelEditMessage() { + cancelEditMessageInternal(clearDraft = true) + } + + fun beginReplyToMessage(message: MessageDto) { + if (message.isDeleted()) { + return + } + + val wasEditing = editingMessage != null + cancelEditMessageInternal(clearDraft = wasEditing) + _uiState.value = _uiState.value.copy(replyTo = message.toReplyPreview()) + } + + fun cancelReply() { + cancelReplyInternal() + } + + fun toggleReaction(message: MessageDto, emoji: String) { + if (message.isDeleted() || emoji.isBlank()) { + return + } + + viewModelScope.launch { + runCatching { + container.executeAuthorized { session -> + container.apiClient.setMessageReaction( + accessToken = session.accessToken, + chatId = chatId, + messageId = message.id, + emoji = emoji + ) + } + }.onSuccess(::replaceOrInsertMessage) + .onFailure { error -> _uiState.value = _uiState.value.copy(errorMessage = error.message) } + } + } + + fun toggleReactionFromSelection(message: MessageDto, emoji: String) { + if (message.isDeleted() || emoji.isBlank()) { + return + } + + viewModelScope.launch { + runCatching { + container.executeAuthorized { session -> + container.apiClient.setMessageReaction( + accessToken = session.accessToken, + chatId = chatId, + messageId = message.id, + emoji = emoji + ) + } + }.onSuccess { updatedMessage -> + replaceOrInsertMessage(updatedMessage) + clearMessageSelection() + }.onFailure { error -> + _uiState.value = _uiState.value.copy(errorMessage = error.message) + } + } + } + + fun deleteMessage(message: MessageDto) { + if (!message.canDelete(currentUserId = _uiState.value.session?.user?.id)) { + return + } + + viewModelScope.launch { + runCatching { + container.executeAuthorized { session -> + container.apiClient.deleteMessage(session.accessToken, chatId, message.id) + } + }.onSuccess { updated -> + if (editingMessage?.id == updated.id) { + cancelEditMessageInternal(clearDraft = true) + } + replaceOrInsertMessage(updated) + }.onFailure { error -> + _uiState.value = _uiState.value.copy(errorMessage = error.message) + } + } + } + + fun togglePinMessage(message: MessageDto) { + if (message.isLocalMessage() || message.isDeleted()) { + return + } + + viewModelScope.launch { + runCatching { + container.executeAuthorized { session -> + if (message.pinnedAt == null) { + container.apiClient.pinMessage(session.accessToken, chatId, message.id) + } else { + container.apiClient.unpinMessage(session.accessToken, chatId, message.id) + } + } + }.onSuccess(::replaceOrInsertMessage) + .onFailure { error -> _uiState.value = _uiState.value.copy(errorMessage = error.message) } + } + } + + fun consumeAttachmentUri(uri: Uri) { + consumeAttachmentUris(listOf(uri)) + } + + fun consumeAttachmentUris(uris: List<Uri>) { + if (uris.isEmpty()) { + return + } + + viewModelScope.launch { + runCatching { + uris.map { uri -> container.attachmentFileService.importFromUri(uri) } + }.onSuccess { attachments -> + cancelEditMessageInternal(clearDraft = false) + pendingAttachments = attachments + _uiState.value = _uiState.value.copy( + pendingAttachment = attachments.toPendingAttachmentUiState(), + errorMessage = null + ) + }.onFailure { error -> + _uiState.value = _uiState.value.copy(errorMessage = error.message) + } + } + } + + fun removePendingAttachment() { + clearPendingAttachment() + } + + fun retryTransfer(groupId: String) { + viewModelScope.launch { + runCatching { container.transferQueueRepository.retry(groupId) } + .onFailure { error -> _uiState.value = _uiState.value.copy(errorMessage = error.message) } + } + } + + fun cancelTransfer(groupId: String) { + viewModelScope.launch { + runCatching { container.transferQueueRepository.cancel(groupId) } + .onFailure { error -> _uiState.value = _uiState.value.copy(errorMessage = error.message) } + } + } + + fun openAttachment(attachment: AttachmentDto) { + if (attachment.isImageAttachment()) { + showImagePreviewAttachment(attachment.id) + return + } + + if (attachment.kind == com.seven.ikar.kotlin.core.model.AttachmentKind.VoiceNote) { + toggleVoiceAttachmentPlayback(attachment) + return + } + + viewModelScope.launch { + runCatching { + val local = ensureAttachmentDownloaded(attachment) + container.attachmentFileService.openCachedAttachment( + localPath = local.localPath, + fileName = local.fileName, + contentType = local.contentType + ) + }.onFailure { error -> + _uiState.value = _uiState.value.copy(errorMessage = error.message) + } + } + } + + fun dismissImagePreview() { + _uiState.value = _uiState.value.copy(previewImageAttachmentId = null) + } + + fun showImagePreviewAttachment(attachmentId: String) { + _uiState.value = _uiState.value.copy(previewImageAttachmentId = attachmentId) + } + + fun shareAttachmentExternally(attachment: AttachmentDto) { + if (isAttachmentInProtectedMessage(attachment)) { + _uiState.value = _uiState.value.copy(errorMessage = "Защищённый контент нельзя пересылать.") + return + } + + viewModelScope.launch { + runCatching { + val local = ensureAttachmentDownloaded(attachment) + container.attachmentFileService.shareCachedAttachment( + localPath = local.localPath, + fileName = local.fileName, + contentType = local.contentType + ) + }.onFailure { error -> + _uiState.value = _uiState.value.copy(errorMessage = error.message) + } + } + } + + fun saveImageAttachmentToGallery(attachment: AttachmentDto) { + if (isAttachmentInProtectedMessage(attachment)) { + _uiState.value = _uiState.value.copy(errorMessage = "Защищённый контент нельзя сохранять.") + return + } + + viewModelScope.launch { + runCatching { + val local = ensureAttachmentDownloaded(attachment) + container.attachmentFileService.saveCachedImageToGallery( + localPath = local.localPath, + fileName = local.fileName, + contentType = local.contentType + ) + }.onFailure { error -> + _uiState.value = _uiState.value.copy(errorMessage = error.message) + } + } + } + + fun beginVoiceRecording() { + if (_uiState.value.isRecordingVoiceNote || !_uiState.value.canSendMessages) { + return + } + + viewModelScope.launch { + runCatching { + container.voiceNoteRecorderService.start() + }.onSuccess { + _uiState.value = _uiState.value.copy( + isRecordingVoiceNote = true, + voiceRecordingElapsedText = "00:00", + errorMessage = null + ) + }.onFailure { error -> + _uiState.value = _uiState.value.copy(errorMessage = error.message) + } + } + } + + fun completeVoiceRecording() { + if (!_uiState.value.isRecordingVoiceNote) { + return + } + + viewModelScope.launch { + runCatching { + val file = container.voiceNoteRecorderService.stop(discard = false) + if (file == null) { + null + } else { + if (file.length() <= 0L) { + file.delete() + throw IllegalStateException("Голосовое сообщение пустое.") + } + + if (_uiState.value.voiceRecordingElapsedText == "00:00") { + file.delete() + throw IllegalStateException("Голосовое сообщение слишком короткое.") + } + + try { + container.executeAuthorized { session -> + container.apiClient.uploadAttachment( + accessToken = session.accessToken, + chatId = chatId, + text = null, + replyToMessageId = _uiState.value.replyTo?.messageId, + file = UploadFileSource( + fileName = file.name, + contentType = "audio/mp4", + contentLength = file.length() + ) { file.inputStream() } + ) + } + } finally { + file.delete() + } + } + }.onSuccess { message -> + _uiState.value = _uiState.value.copy( + isRecordingVoiceNote = false, + voiceRecordingElapsedText = "00:00", + replyTo = null + ) + message?.let(::replaceOrInsertMessage) + }.onFailure { error -> + _uiState.value = _uiState.value.copy( + isRecordingVoiceNote = false, + voiceRecordingElapsedText = "00:00", + errorMessage = error.message + ) + } + } + } + + fun cancelVoiceRecording() { + viewModelScope.launch { + runCatching { container.voiceNoteRecorderService.stop(discard = true) } + _uiState.value = _uiState.value.copy( + isRecordingVoiceNote = false, + voiceRecordingElapsedText = "00:00" + ) + } + } + + fun stopVoicePlayback() { + viewModelScope.launch { + container.voiceNotePlayerService.stop() + } + } + + fun startAudioCall() { + val state = _uiState.value + if (!canStartAudioCall(state)) { + return + } + + viewModelScope.launch { + runCatching { + container.executeAuthorized { session -> + container.realtimeService.ensureConnected(session.accessToken) + container.callManager.startOutgoingAudioCall(chatId) + } + } + .onFailure { error -> _uiState.value = _uiState.value.copy(errorMessage = error.message) } + } + } + + fun startVideoCall() { + val state = _uiState.value + if (!canStartAudioCall(state)) { + return + } + + viewModelScope.launch { + runCatching { + container.executeAuthorized { session -> + container.realtimeService.ensureConnected(session.accessToken) + container.callManager.startOutgoingVideoCall(chatId) + } + } + .onFailure { error -> _uiState.value = _uiState.value.copy(errorMessage = error.message) } + } + } + + fun acceptCall() { + container.callManager.acceptCurrentCall() + } + + fun declineCall() { + container.callManager.declineCurrentCall() + } + + fun endCall() { + container.callManager.endCurrentCall() + } + + fun attachmentPreviewUrl(attachment: AttachmentDto): String = when { + attachment.downloadPath.startsWith("http://") || attachment.downloadPath.startsWith("https://") -> attachment.downloadPath + else -> ServerConfig.absoluteMessengerUrl(attachment.downloadPath) + } + + fun showCallHeaderButton(state: ChatUiState = uiState.value): Boolean = + state.chatType == ChatType.Direct && state.peer?.isBot == false && state.botPeer == null + + fun showBotComposerMenuButton(state: ChatUiState = uiState.value): Boolean = + state.chatType == ChatType.Direct && (state.botPeer != null || state.peer?.isBot == true) + + fun showHeaderMenuButton(state: ChatUiState = uiState.value): Boolean = + state.chatType == ChatType.Direct && (state.botPeer != null || state.peer?.isBot == true) + + fun showAcceptCallButton(state: ChatUiState = uiState.value): Boolean = + state.activeCall?.stage == AudioCallStage.Incoming + + fun showDeclineCallButton(state: ChatUiState = uiState.value): Boolean = + state.activeCall?.stage in setOf(AudioCallStage.Incoming, AudioCallStage.Outgoing, AudioCallStage.Connecting) + + fun showEndCallButton(state: ChatUiState = uiState.value): Boolean = + state.activeCall?.stage == AudioCallStage.Active + + fun callBannerTitle(state: ChatUiState = uiState.value): String = + state.activeCall?.peer?.displayName.orEmpty() + + fun callBannerSubtitle(state: ChatUiState = uiState.value): String { + val call = state.activeCall ?: return "" + val callPrefix = if (call.mediaType == CallMediaType.Video) "Видеозвонок" else "Аудиозвонок" + return when (call.stage) { + AudioCallStage.Incoming -> "Входящий ${callPrefix.lowercase()}" + AudioCallStage.Outgoing -> if (call.mediaType == CallMediaType.Video) "Идёт видеовызов..." else "Идёт вызов..." + AudioCallStage.Connecting -> "Подключение..." + AudioCallStage.Active -> if (call.mediaType == CallMediaType.Video) "Видеозвонок ${call.durationText}" else "Разговор ${call.durationText}" + AudioCallStage.None -> "" + } + } + + fun availableBotCommands(state: ChatUiState = uiState.value): List<BotCommandDto> = + state.botCommands.ifEmpty { DefaultCodexBridgeBotCommands } + + fun latestBotQuickActions(state: ChatUiState = uiState.value): List<BotQuickAction> = + state.messages.lastOrNull { buildBotQuickActions(it).isNotEmpty() } + ?.let(::buildBotQuickActions) + .orEmpty() + + private fun loadCachedMessages(session: AuthSessionDto? = container.sessionStore.read()) { + if (session == null) { + return + } + + viewModelScope.launch { + val cachedMessages = withContext(Dispatchers.IO) { + val latestMessages = container.chatMessageCache.loadLatestMessages(session.user.id, chatId) + val optimisticMessages = container.chatMessageCache.loadOptimisticMessages(session.user.id, chatId) + (latestMessages + optimisticMessages) + .distinctBy { it.id } + .sortedBy { it.sentAt } + } + if (cachedMessages.isEmpty()) { + return@launch + } + + val currentState = _uiState.value + val mergedMessages = (currentState.messages + cachedMessages) + .distinctBy { it.id } + .sortedBy { it.sentAt } + val cachedLocalMessageIds = cachedMessages + .filter { it.isLocalMessage() } + .mapTo(mutableSetOf()) { it.id } + val selectedMessageIds = sanitizeSelectedMessageIds(currentState.selectedMessageIds, mergedMessages) + + _uiState.value = currentState.copy( + session = session, + messages = mergedMessages, + selectedMessageIds = selectedMessageIds, + isDeleteSelectionDialogVisible = currentState.isDeleteSelectionDialogVisible && selectedMessageIds.isNotEmpty(), + isDeletingSelectedMessages = currentState.isDeletingSelectedMessages && selectedMessageIds.isNotEmpty(), + failedLocalMessageIds = currentState.failedLocalMessageIds + + (cachedLocalMessageIds - currentState.sendingLocalMessageIds) + ) + } + } + + private fun buildOptimisticMessage(state: ChatUiState, text: String): MessageDto? { + val session = state.session ?: container.sessionStore.read() ?: return null + return MessageDto( + id = "$LocalMessageIdPrefix${UUID.randomUUID()}", + chatId = chatId, + chatType = state.chatType, + sender = session.user, + text = text, + attachments = emptyList(), + sentAt = OffsetDateTime.now(ZoneOffset.UTC).format(DateTimeFormatter.ISO_OFFSET_DATE_TIME), + deliveryState = MessageDeliveryState.Sent, + replyTo = state.replyTo, + postedAsChannel = state.chatType == ChatType.Channel, + displayAuthorName = if (state.chatType == ChatType.Channel) state.title else session.user.displayName, + viewCount = if (state.chatType == ChatType.Channel) 0 else null + ) + } + + private fun applyChat(chat: ChatDetailsDto, session: AuthSessionDto) { + val previousState = _uiState.value + val localMessages = previousState.messages.filter { it.isLocalMessage() } + val orderedMessages = (chat.messages + localMessages) + .distinctBy { it.id } + .sortedBy { it.sentAt } + val localMessageIds = orderedMessages + .filter { it.isLocalMessage() } + .mapTo(mutableSetOf()) { it.id } + val peer = resolvePeer(chat, session) + val botPeer = resolveBotPeer(chat, session, peer, orderedMessages) + val resolvedTitle = resolveChatTitle(chat, peer) + baseSubtitle = buildSubtitle(chat, peer) + val updatedEditingMessage = previousState.editingMessageId?.let { editingId -> + orderedMessages.firstOrNull { it.id == editingId && it.deletedAt == null } + } + val selectedMessageIds = sanitizeSelectedMessageIds(previousState.selectedMessageIds, orderedMessages) + editingMessage = updatedEditingMessage + + _uiState.value = previousState.copy( + session = session, + title = resolvedTitle, + subtitle = buildTypingSubtitle().ifBlank { baseSubtitle }, + chatType = chat.type, + peer = peer, + botPeer = botPeer, + messages = orderedMessages, + pinnedMessages = chat.pinnedMessages, + hasMoreMessages = chat.hasMoreMessages, + isLoadingOlderMessages = false, + firstUnreadMessageId = chat.firstUnreadMessageId, + canSendMessages = chat.canSendMessages, + subscriberCount = chat.subscriberCount, + adminCount = chat.adminCount, + isSubscribed = chat.isSubscribed, + canSubscribe = chat.canSubscribe, + canManageChannel = chat.canManageChannel, + canPostAsChannel = chat.canPostAsChannel, + publicUsername = chat.publicUsername, + linkedDiscussionChatId = chat.linkedDiscussionChatId, + channelDescription = chat.description, + isJoiningChannel = false, + isLeavingChannel = false, + isLoading = false, + errorMessage = null, + selectedMessageIds = selectedMessageIds, + isDeleteSelectionDialogVisible = previousState.isDeleteSelectionDialogVisible && selectedMessageIds.isNotEmpty(), + isDeletingSelectedMessages = previousState.isDeletingSelectedMessages && selectedMessageIds.isNotEmpty(), + sendingLocalMessageIds = previousState.sendingLocalMessageIds.intersect(localMessageIds), + failedLocalMessageIds = previousState.failedLocalMessageIds.intersect(localMessageIds), + editingMessageId = updatedEditingMessage?.id, + editingMessageBanner = updatedEditingMessage?.let { "Сообщение от ${formatTimestamp(it.sentAt)}" }.orEmpty(), + draftText = when { + updatedEditingMessage != null -> previousState.draftText + previousState.editingMessageId != null -> "" + else -> previousState.draftText + } + ) + cacheMessages(session.user.id, chat.messages) + scheduleAutoDownloads(orderedMessages) + refreshCallBanner() + } + + private fun attachRealtime() { + viewModelScope.launch { + container.realtimeService.messagesCreated.collect { message -> + if (message.chatId == chatId) { + replaceOrInsertMessage(message) + requestScrollToMessageInternal(message.id) + if (message.sender.id != _uiState.value.session?.user?.id) { + acknowledgeRead(message.id) + } + } + } + } + viewModelScope.launch { + container.realtimeService.messagesUpdated.collect { message -> + if (message.chatId == chatId) { + if (editingMessage?.id == message.id && message.deletedAt != null) { + cancelEditMessageInternal(clearDraft = true) + } + replaceOrInsertMessage(message) + } + } + } + viewModelScope.launch { + container.realtimeService.chatsChanged.collect { + refresh() + } + } + viewModelScope.launch { + container.realtimeService.typingChanged.collect(::handleTypingChanged) + } + } + + private fun handleTypingChanged(payload: TypingIndicatorDto) { + val currentUserId = _uiState.value.session?.user?.id + if (payload.chatId != chatId || payload.user.id == currentUserId) { + return + } + + val userId = payload.user.id + typingExpiryJobs.remove(userId)?.cancel() + if (payload.isTyping) { + typingUsers[userId] = payload.user + typingExpiryJobs[userId] = viewModelScope.launch { + delay(4_000) + typingUsers.remove(userId) + typingExpiryJobs.remove(userId) + updateTypingSubtitle() + } + } else { + typingUsers.remove(userId) + } + + updateTypingSubtitle() + } + + private fun notifyTyping(isTyping: Boolean) { + if (!_uiState.value.canSendMessages) { + return + } + + if (!isTyping) { + typingStopJob?.cancel() + typingStopJob = null + if (typingStateSent) { + typingStateSent = false + sendTypingSignal(false) + } + return + } + + val now = System.currentTimeMillis() + if (!typingStateSent || now - lastTypingSentAtMillis > 2_000) { + typingStateSent = true + lastTypingSentAtMillis = now + sendTypingSignal(true) + } + + typingStopJob?.cancel() + typingStopJob = viewModelScope.launch { + delay(2_500) + typingStateSent = false + sendTypingSignal(false) + } + } + + private fun sendTypingSignal(isTyping: Boolean) { + viewModelScope.launch(Dispatchers.IO) { + runCatching { + container.executeAuthorized { session -> + container.realtimeService.ensureConnected(session.accessToken) + container.realtimeService.sendTyping(chatId, isTyping) + } + } + } + } + + private fun updateTypingSubtitle() { + _uiState.value = _uiState.value.copy( + subtitle = buildTypingSubtitle().ifBlank { baseSubtitle } + ) + } + + private fun buildTypingSubtitle(): String { + val names = typingUsers.values + .sortedBy { it.displayName.lowercase(Locale.getDefault()) } + .map { it.displayName.ifBlank { it.username } } + + return when { + names.isEmpty() -> "" + names.size == 1 && _uiState.value.chatType == ChatType.Direct -> "печатает..." + names.size == 1 -> "${names.first()} печатает..." + names.size == 2 -> "${names[0]} и ${names[1]} печатают..." + else -> "${names.size} участников печатают..." + } + } + + private fun attachCallState() { + viewModelScope.launch { + container.callManager.currentCall.collect { + refreshCallBanner() + } + } + } + + private fun attachVoicePlaybackState() { + viewModelScope.launch { + container.voiceNotePlayerService.events.collect { event -> + val nextMap = _uiState.value.attachmentPlayback.toMutableMap() + nextMap[event.attachmentId] = VoiceAttachmentPlaybackUiState( + isPlaying = event.isPlaying, + positionMs = event.positionMs, + durationMs = event.durationMs + ) + _uiState.value = _uiState.value.copy(attachmentPlayback = nextMap) + } + } + } + + private fun attachVoiceRecordingState() { + viewModelScope.launch { + container.voiceNoteRecorderService.elapsedMs.collect { elapsedMs -> + if (_uiState.value.isRecordingVoiceNote) { + _uiState.value = _uiState.value.copy( + voiceRecordingElapsedText = formatDurationMs(elapsedMs) + ) + } + } + } + } + + private fun attachTransferQueueState() { + viewModelScope.launch { + container.transferQueueRepository.observeActiveForChat(chatId).collect { items -> + _uiState.value = _uiState.value.copy(activeTransfers = items.toTransferQueueUiState()) + } + } + } + + private fun refreshCallBanner() { + val current = container.callManager.currentCall.value + _uiState.value = _uiState.value.copy(activeCall = current?.takeIf { it.chatId == chatId }) + } + + private suspend fun ensureAttachmentDownloaded(attachment: AttachmentDto): LocalAttachmentFile { + cachedAttachmentFiles[attachment.id]?.let { return it } + + val target = container.attachmentFileService.createAttachmentCacheFile(attachment.id, attachment.fileName) + if (target.exists()) { + val cached = LocalAttachmentFile( + fileName = attachment.fileName, + contentType = attachment.contentType, + fileSizeBytes = target.length(), + localPath = target.absolutePath + ) + cachedAttachmentFiles[attachment.id] = cached + return cached + } + + if (attachment.fileSizeBytes >= TransferForegroundNotification.LargeTransferThresholdBytes) { + container.transferQueueRepository.enqueueDownload(chatId, attachment) + throw IllegalStateException("Large download queued. Try opening it again when the transfer completes.") + } + + return container.executeAuthorized { session -> + val downloaded = container.apiClient.downloadAttachmentTo(session.accessToken, attachment, target) + val cached = container.attachmentFileService.cacheDownloadedAttachment(downloaded) + cachedAttachmentFiles[attachment.id] = cached + cached + } + } + + private fun scheduleAutoDownloads(messages: List<MessageDto>) { + val settings = container.appSettings.loadSnapshot() + if (!settings.autoDownloadPhotos && !settings.autoDownloadVideosOnWifi && !settings.autoDownloadFilesOnWifi) { + return + } + + val isUnmetered = isUnmeteredNetwork() + messages + .asSequence() + .filterNot { it.hasProtectedContent || it.isDeleted() } + .flatMap { it.attachments.asSequence() } + .filter { attachment -> shouldAutoDownload(attachment, settings, isUnmetered) } + .filterNot { attachment -> cachedAttachmentFiles.containsKey(attachment.id) } + .filterNot { attachment -> container.attachmentFileService.createAttachmentCacheFile(attachment.id, attachment.fileName).exists() } + .take(8) + .forEach { attachment -> + viewModelScope.launch { + runCatching { ensureAttachmentDownloaded(attachment) } + } + } + } + + private fun shouldAutoDownload( + attachment: AttachmentDto, + settings: com.seven.ikar.kotlin.core.settings.AppSettingsSnapshot, + isUnmetered: Boolean + ): Boolean = when { + attachment.isImageAttachment() -> settings.autoDownloadPhotos + attachment.contentType.startsWith("video/", ignoreCase = true) -> settings.autoDownloadVideosOnWifi && isUnmetered + else -> settings.autoDownloadFilesOnWifi && isUnmetered + } + + private fun isUnmeteredNetwork(): Boolean { + val connectivityManager = container.appContext.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager + ?: return false + val network = connectivityManager.activeNetwork ?: return false + val capabilities = connectivityManager.getNetworkCapabilities(network) ?: return false + return capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_METERED) + } + + private fun isAttachmentInProtectedMessage(attachment: AttachmentDto): Boolean = + _uiState.value.messages.any { message -> + message.hasProtectedContent && message.attachments.any { it.id == attachment.id } + } + + private fun toggleVoiceAttachmentPlayback(attachment: AttachmentDto) { + viewModelScope.launch { + runCatching { + val local = ensureAttachmentDownloaded(attachment) + container.voiceNotePlayerService.toggle( + attachmentId = attachment.id, + localFilePath = local.localPath + ) + }.onFailure { error -> + _uiState.value = _uiState.value.copy(errorMessage = error.message) + } + } + } + + private fun clearPendingAttachment() { + pendingAttachments = emptyList() + _uiState.value = _uiState.value.copy(pendingAttachment = null) + } + + private fun cancelEditMessageInternal(clearDraft: Boolean) { + editingMessage = null + _uiState.value = _uiState.value.copy( + editingMessageId = null, + editingMessageBanner = "", + draftText = if (clearDraft) "" else _uiState.value.draftText + ) + } + + private fun cancelReplyInternal() { + _uiState.value = _uiState.value.copy(replyTo = null) + } + + private fun replaceOrInsertMessage(message: MessageDto, cacheAsOptimistic: Boolean = message.isLocalMessage()) { + val currentState = _uiState.value + val matchingLocalMessageId = findMatchingPendingLocalMessageId(currentState, message) + val nextMessages = currentState.messages + .filterNot { it.id == matchingLocalMessageId } + .toMutableList() + val existingIndex = nextMessages.indexOfFirst { it.id == message.id } + if (existingIndex >= 0) { + nextMessages[existingIndex] = message + } else { + nextMessages += message + } + + val sortedMessages = nextMessages.sortedBy { it.sentAt } + val updatedReplyTo = currentState.replyTo?.let { currentReply -> + if (currentReply.messageId == message.id) message.toReplyPreview() else currentReply + } + val selectedMessageIds = sanitizeSelectedMessageIds(currentState.selectedMessageIds, sortedMessages) + val sendingLocalMessageIds = matchingLocalMessageId?.let { currentState.sendingLocalMessageIds - it } + ?: currentState.sendingLocalMessageIds + val failedLocalMessageIds = matchingLocalMessageId?.let { currentState.failedLocalMessageIds - it } + ?: currentState.failedLocalMessageIds + val pinnedMessages = updatePinnedMessages(currentState.pinnedMessages, message) + + _uiState.value = currentState.copy( + messages = sortedMessages, + pinnedMessages = pinnedMessages, + errorMessage = null, + replyTo = updatedReplyTo, + selectedMessageIds = selectedMessageIds, + isDeleteSelectionDialogVisible = currentState.isDeleteSelectionDialogVisible && selectedMessageIds.isNotEmpty(), + isDeletingSelectedMessages = currentState.isDeletingSelectedMessages && selectedMessageIds.isNotEmpty(), + sendingLocalMessageIds = sendingLocalMessageIds, + failedLocalMessageIds = failedLocalMessageIds + ) + if (matchingLocalMessageId != null) { + cacheReplaceOptimisticMessage(matchingLocalMessageId, message) + } else { + cacheMessage(message, isOptimistic = cacheAsOptimistic) + } + scheduleAutoDownloads(listOf(message)) + } + + private fun requestScrollToMessageInternal(messageId: String?) { + _uiState.value = _uiState.value.copy(scrollRequest = ChatScrollRequest(messageId = messageId)) + } + + private fun replaceLocalMessage(localMessageId: String, serverMessage: MessageDto) { + val currentState = _uiState.value + val nextMessages = currentState.messages + .filterNot { it.id == localMessageId || it.id == serverMessage.id } + .plus(serverMessage) + .sortedBy { it.sentAt } + val selectedMessageIds = sanitizeSelectedMessageIds(currentState.selectedMessageIds, nextMessages) + val pinnedMessages = updatePinnedMessages(currentState.pinnedMessages, serverMessage) + + _uiState.value = currentState.copy( + messages = nextMessages, + pinnedMessages = pinnedMessages, + errorMessage = null, + selectedMessageIds = selectedMessageIds, + isDeleteSelectionDialogVisible = currentState.isDeleteSelectionDialogVisible && selectedMessageIds.isNotEmpty(), + isDeletingSelectedMessages = currentState.isDeletingSelectedMessages && selectedMessageIds.isNotEmpty(), + sendingLocalMessageIds = currentState.sendingLocalMessageIds - localMessageId, + failedLocalMessageIds = currentState.failedLocalMessageIds - localMessageId + ) + cacheReplaceOptimisticMessage(localMessageId, serverMessage) + } + + private fun findMatchingPendingLocalMessageId(state: ChatUiState, serverMessage: MessageDto): String? { + if (serverMessage.isLocalMessage() || state.sendingLocalMessageIds.isEmpty()) { + return null + } + + return state.messages.firstOrNull { localMessage -> + state.sendingLocalMessageIds.contains(localMessage.id) && + localMessage.sender.id == serverMessage.sender.id && + localMessage.text == serverMessage.text && + localMessage.attachments.isEmpty() && + serverMessage.attachments.isEmpty() && + localMessage.replyTo?.messageId == serverMessage.replyTo?.messageId + }?.id + } + + private fun cacheMessage(message: MessageDto, isOptimistic: Boolean = false) { + val ownerUserId = _uiState.value.session?.user?.id ?: container.sessionStore.read()?.user?.id ?: return + cacheMessages(ownerUserId, listOf(message), isOptimistic = isOptimistic) + } + + private fun cacheMessages( + ownerUserId: String, + messages: List<MessageDto>, + isOptimistic: Boolean = false + ) { + if (messages.isEmpty()) { + return + } + + viewModelScope.launch(Dispatchers.IO) { + container.chatMessageCache.upsertMessages(ownerUserId, chatId, messages, isOptimistic) + } + } + + private fun cacheReplaceOptimisticMessage(localMessageId: String, serverMessage: MessageDto) { + val ownerUserId = _uiState.value.session?.user?.id ?: container.sessionStore.read()?.user?.id ?: return + viewModelScope.launch(Dispatchers.IO) { + container.chatMessageCache.replaceOptimisticMessage(ownerUserId, chatId, localMessageId, serverMessage) + } + } + + private fun clearCachedChat(ownerUserId: String) { + viewModelScope.launch(Dispatchers.IO) { + container.chatMessageCache.clearChat(ownerUserId, chatId) + } + } + + fun acknowledgeRead(messageId: String) { + viewModelScope.launch { + runCatching { + container.executeAuthorized { session -> + container.apiClient.acknowledgeRead( + accessToken = session.accessToken, + chatId = chatId, + messageId = messageId + ) + } + } + } + } + + private fun resolvePeer(chat: ChatDetailsDto, session: AuthSessionDto): UserSummaryDto? { + if (chat.type != ChatType.Direct) { + return null + } + + return chat.participants.firstOrNull { it.id != session.user.id } + } + + private fun resolveBotPeer( + chat: ChatDetailsDto, + session: AuthSessionDto, + peer: UserSummaryDto?, + messages: List<MessageDto> + ): UserSummaryDto? { + if (chat.type != ChatType.Direct) { + return null + } + + return peer?.takeIf { it.isBot } + ?: chat.participants.firstOrNull { it.id != session.user.id && it.isBot } + ?: messages.asSequence() + .map { it.sender } + .firstOrNull { it.id != session.user.id && it.isBot } + } + + private fun resolveChatTitle(chat: ChatDetailsDto, peer: UserSummaryDto?): String { + if (chat.type != ChatType.Direct) { + return chat.title + } + + val normalizedPhone = PhoneNumberNormalizer.normalize(peer?.phoneNumber) + val contactName = normalizedPhone?.let(contactNamesByNormalizedPhone::get) + return contactName?.takeIf { it.isNotBlank() } + ?: peer?.displayName?.takeIf { it.isNotBlank() } + ?: chat.title + } + + private suspend fun loadDeviceContactNames(): Map<String, String> = withContext(Dispatchers.IO) { + if (!hasContactsPermission()) { + return@withContext emptyMap() + } + + runCatching { + container.contactDiscoveryService + .getContacts() + .associate { contact -> contact.normalizedPhoneNumber to contact.displayName } + }.getOrDefault(emptyMap()) + } + + private fun hasContactsPermission(): Boolean = + ContextCompat.checkSelfPermission( + container.appContext, + Manifest.permission.READ_CONTACTS + ) == PackageManager.PERMISSION_GRANTED + + private fun canSelectMessage(message: MessageDto): Boolean = + !message.isLocalMessage() && message.canDelete(currentUserId = _uiState.value.session?.user?.id) + + private fun sanitizeSelectedMessageIds(selectedMessageIds: Set<String>, messages: List<MessageDto>): Set<String> { + if (selectedMessageIds.isEmpty()) { + return emptySet() + } + + val existingSelectableIds = messages + .asSequence() + .filter(::canSelectMessage) + .map(MessageDto::id) + .toSet() + + return selectedMessageIds.intersect(existingSelectableIds) + } + + private fun updatePinnedMessages(current: List<MessageDto>, message: MessageDto): List<MessageDto> { + val withoutMessage = current.filterNot { it.id == message.id } + if (message.pinnedAt == null || message.isDeleted()) { + return withoutMessage + } + + return (withoutMessage + message) + .sortedByDescending { it.pinnedAt.orEmpty() } + .take(5) + } + + private fun buildSubtitle(chat: ChatDetailsDto, peer: UserSummaryDto?): String = when { + chat.type == ChatType.Direct && peer != null -> formatPresence(peer) + chat.type == ChatType.Channel && chat.canPostAsChannel -> "${formatChannelSubscribers(chat)} · публикация разрешена" + chat.type == ChatType.Channel -> formatChannelSubscribers(chat) + chat.type == ChatType.Group -> "${chat.participants.size} участников" + else -> "${chat.participants.size} участника" + } + + private fun canStartAudioCall(state: ChatUiState): Boolean = + state.chatType == ChatType.Direct && + state.peer?.isBot == false && + state.activeCall == null +} + +internal fun MessageDto.canManage(currentUserId: String?): Boolean = + currentUserId != null && sender.id == currentUserId && deletedAt == null + +internal fun MessageDto.canDelete(currentUserId: String?): Boolean = + currentUserId != null && deletedAt == null + +private fun MessageDto.isLocalMessage(): Boolean = id.startsWith(LocalMessageIdPrefix) + +private fun searchLoadedMessageIds(query: String, messages: List<MessageDto>): List<String> { + val normalizedQuery = query.trim() + if (normalizedQuery.isBlank()) { + return emptyList() + } + + return messages + .asSequence() + .filterNot { it.isDeleted() } + .filter { message -> + message.text.contains(normalizedQuery, ignoreCase = true) || + message.sender.displayName.contains(normalizedQuery, ignoreCase = true) || + message.attachments.any { attachment -> + attachment.fileName.contains(normalizedQuery, ignoreCase = true) || + attachment.contentType.contains(normalizedQuery, ignoreCase = true) + } + } + .sortedByDescending { it.sentAt } + .map(MessageDto::id) + .toList() +} + +private fun Int.floorMod(modulus: Int): Int = ((this % modulus) + modulus) % modulus + +private fun formatChannelSubscribers(chat: ChatDetailsDto): String { + val count = if (chat.subscriberCount > 0) chat.subscriberCount else chat.participants.size + return "$count ${russianPlural(count, "подписчик", "подписчика", "подписчиков")}" +} + +private fun russianPlural(count: Int, one: String, few: String, many: String): String { + val value = kotlin.math.abs(count) % 100 + val lastDigit = value % 10 + if (value in 11..14) { + return many + } + + return when (lastDigit) { + 1 -> one + in 2..4 -> few + else -> many + } +} + +private fun List<LocalAttachmentFile>.toPendingAttachmentUiState(): PendingAttachmentUiState? { + if (isEmpty()) { + return null + } + + val imagePreviews = filter { it.isImageAttachment() } + .map { attachment -> + PendingImageAttachmentUiState( + fileName = attachment.fileName, + localPath = attachment.localPath + ) + } + + if (size == 1) { + val attachment = first() + return PendingAttachmentUiState( + fileName = attachment.fileName, + contentType = attachment.contentType, + fileSizeBytes = attachment.fileSizeBytes, + imagePreviews = imagePreviews + ) + } + + val allImages = imagePreviews.size == size + return PendingAttachmentUiState( + fileName = if (allImages) { + "$size ${russianPlural(size, "фотография", "фотографии", "фотографий")}" + } else { + "$size ${russianPlural(size, "файл", "файла", "файлов")}" + }, + contentType = if (allImages) "альбом" else "вложения", + fileSizeBytes = sumOf { it.fileSizeBytes }, + imagePreviews = imagePreviews + ) +} + +private fun LocalAttachmentFile.isImageAttachment(): Boolean = + contentType.startsWith("image/", ignoreCase = true) || + fileName.endsWith(".jpg", ignoreCase = true) || + fileName.endsWith(".jpeg", ignoreCase = true) || + fileName.endsWith(".png", ignoreCase = true) || + fileName.endsWith(".webp", ignoreCase = true) || + fileName.endsWith(".gif", ignoreCase = true) + +private fun List<TransferQueueEntity>.toTransferQueueUiState(): List<TransferQueueUiState> = + groupBy { it.groupId } + .map { (groupId, items) -> + val ordered = items.sortedBy { it.sortOrder } + val first = ordered.first() + TransferQueueUiState( + groupId = groupId, + title = if (ordered.size == 1) first.fileName else "${ordered.size} файлов", + status = first.status, + progressPercent = ordered.maxOfOrNull { it.progressPercent } ?: 0, + errorMessage = ordered.firstNotNullOfOrNull { it.errorMessage } + ) + } + .sortedBy { it.title } + +internal fun MessageDto.isDeleted(): Boolean = deletedAt != null + +internal fun AttachmentDto.isImageAttachment(): Boolean = + contentType.startsWith("image/", ignoreCase = true) || + fileName.endsWith(".jpg", ignoreCase = true) || + fileName.endsWith(".jpeg", ignoreCase = true) || + fileName.endsWith(".png", ignoreCase = true) || + fileName.endsWith(".webp", ignoreCase = true) || + fileName.endsWith(".gif", ignoreCase = true) + +internal fun buildBotQuickActions(message: MessageDto): List<BotQuickAction> { + if (!message.sender.isBot || message.text.isBlank()) { + return emptyList() + } + + val normalizedText = message.text.replace("\r\n", "\n") + if (!normalizedText.startsWith("Текущие thread:") && + !normalizedText.startsWith("Thread по запросу") + ) { + return if (normalizedText.contains("Создать новый thread:")) { + listOf(BotQuickAction(title = "Создать новый поток", commandText = "/newthread")) + } else { + emptyList() + } + } + + return normalizedText + .split('\n') + .mapNotNull { rawLine -> + val match = BotThreadOptionRegex.matchEntire(rawLine.trim()) ?: return@mapNotNull null + val index = match.groups["index"]?.value?.trim().orEmpty() + val title = match.groups["title"]?.value?.trim().orEmpty() + if (index.isBlank() || title.isBlank()) { + null + } else { + BotQuickAction(title = "$index. $title", commandText = "/use $index") + } + } +} + +internal fun formatDurationMs(value: Long): String { + val totalSeconds = (value / 1000L).coerceAtLeast(0L) + val minutes = totalSeconds / 60L + val seconds = totalSeconds % 60L + return String.format(Locale.US, "%02d:%02d", minutes, seconds) +} + +private fun formatTimestamp(value: String): String = + runCatching { + OffsetDateTime.parse(value) + .atZoneSameInstant(ZoneId.systemDefault()) + .toOffsetDateTime() + .format(ChatTimestampFormatter) + }.getOrDefault(value) + +private fun MessageDto.toReplyPreview(): MessageReplyDto = + MessageReplyDto( + messageId = id, + senderId = sender.id, + senderDisplayName = sender.displayName, + previewText = when { + isDeleted() -> "Сообщение удалено" + text.isNotBlank() && attachments.isNotEmpty() -> "$text (${attachments.size})" + text.isNotBlank() -> text + attachments.size == 1 -> attachments.first().fileName + attachments.isNotEmpty() -> "${attachments.size} файлов" + else -> "Сообщение" + } + ) diff --git a/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/chat/EmojiCatalog.kt b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/chat/EmojiCatalog.kt new file mode 100644 index 00000000..5140c6ad --- /dev/null +++ b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/chat/EmojiCatalog.kt @@ -0,0 +1,224 @@ +package com.seven.ikar.kotlin.feature.chat + +import android.content.Context + +internal data class EmojiCategory( + val key: String, + val title: String, + val icon: String, + val emojis: List<String> +) + +private const val EmojiPrefsName = "ikar_emoji_picker" +private const val FrequentEmojiKey = "frequent_emojis" +private const val EmojiSeparator = "\u001F" +private const val MaxFrequentEmoji = 48 + +internal val DefaultFrequentEmojis = listOf( + "😀", "😂", "😍", "👍", "❤️", "🔥", "🎉", "🙏", + "😊", "🥰", "😁", "😎", "👏", "🤔", "😢", "😮" +) + +private val StandardEmojiCategories = listOf( + EmojiCategory( + key = "smiles_people", + title = "Смайлы и люди", + icon = "😀", + emojis = listOf( + "😀", "😃", "😄", "😁", "😆", "😅", "😂", "🤣", + "😊", "😇", "🙂", "🙃", "😉", "😌", "😍", "🥰", + "😘", "😗", "😙", "😚", "😋", "😛", "😜", "🤪", + "😝", "🤑", "🤗", "🤭", "🤫", "🤔", "🤐", "🤨", + "😐", "😑", "😶", "😏", "😒", "🙄", "😬", "🤥", + "😌", "😔", "😪", "🤤", "😴", "😷", "🤒", "🤕", + "🤢", "🤮", "🤧", "🥵", "🥶", "🥴", "😵", "🤯", + "🤠", "🥳", "😎", "🤓", "🧐", "😕", "😟", "🙁", + "☹️", "😮", "😯", "😲", "😳", "🥺", "😦", "😧", + "😨", "😰", "😥", "😢", "😭", "😱", "😖", "😣", + "😞", "😓", "😩", "😫", "😤", "😡", "😠", "🤬", + "👍", "👎", "👌", "🤌", "🤏", "✌️", "🤞", "🤟", + "🤘", "🤙", "👈", "👉", "👆", "👇", "☝️", "👋", + "🤚", "🖐️", "✋", "🖖", "👏", "🙌", "👐", "🤲", + "🙏", "✍️", "💪", "🦾", "🧠", "👀", "👂", "👃" + ) + ), + EmojiCategory( + key = "animals_nature", + title = "Животные и природа", + icon = "🌿", + emojis = listOf( + "🐶", "🐱", "🐭", "🐹", "🐰", "🦊", "🐻", "🐼", + "🐨", "🐯", "🦁", "🐮", "🐷", "🐸", "🐵", "🙈", + "🙉", "🙊", "🐒", "🐔", "🐧", "🐦", "🐤", "🦆", + "🦅", "🦉", "🦇", "🐺", "🐗", "🐴", "🦄", "🐝", + "🪲", "🦋", "🐌", "🐞", "🐜", "🪰", "🪱", "🦗", + "🕷️", "🦂", "🐢", "🐍", "🦎", "🦖", "🦕", "🐙", + "🦑", "🦐", "🦞", "🦀", "🐡", "🐠", "🐟", "🐬", + "🐳", "🐋", "🦈", "🐊", "🐅", "🐆", "🦓", "🦍", + "🦧", "🐘", "🦛", "🦏", "🐪", "🐫", "🦒", "🦘", + "🌵", "🎄", "🌲", "🌳", "🌴", "🪵", "🌱", "🌿", + "☘️", "🍀", "🎍", "🪴", "🎋", "🍃", "🍂", "🍁", + "🌾", "🌺", "🌻", "🌹", "🥀", "🌷", "🌼", "🌸", + "💐", "🍄", "🌰", "🌍", "🌎", "🌏", "🌕", "🌙", + "⭐", "🌟", "✨", "⚡", "🔥", "🌈", "☀️", "☁️", + "⛄", "❄️", "💧", "🌊" + ) + ), + EmojiCategory( + key = "food_drinks", + title = "Еда и напитки", + icon = "🍔", + emojis = listOf( + "🍏", "🍎", "🍐", "🍊", "🍋", "🍌", "🍉", "🍇", + "🍓", "🫐", "🍈", "🍒", "🍑", "🥭", "🍍", "🥥", + "🥝", "🍅", "🍆", "🥑", "🥦", "🥬", "🥒", "🌶️", + "🫑", "🌽", "🥕", "🫒", "🧄", "🧅", "🥔", "🍠", + "🥐", "🥯", "🍞", "🥖", "🥨", "🧀", "🥚", "🍳", + "🧈", "🥞", "🧇", "🥓", "🥩", "🍗", "🍖", "🌭", + "🍔", "🍟", "🍕", "🫓", "🥪", "🥙", "🧆", "🌮", + "🌯", "🫔", "🥗", "🥘", "🫕", "🥫", "🍝", "🍜", + "🍲", "🍛", "🍣", "🍱", "🥟", "🦪", "🍤", "🍙", + "🍚", "🍘", "🍥", "🥠", "🥮", "🍢", "🍡", "🍧", + "🍨", "🍦", "🥧", "🧁", "🍰", "🎂", "🍮", "🍭", + "🍬", "🍫", "🍿", "🍩", "🍪", "🌰", "🥜", "🍯", + "🥛", "🍼", "☕", "🫖", "🍵", "🧃", "🥤", "🧋", + "🍶", "🍺", "🍻", "🥂", "🍷", "🥃", "🍸", "🍹" + ) + ), + EmojiCategory( + key = "activities", + title = "Занятия", + icon = "⚽", + emojis = listOf( + "⚽", "🏀", "🏈", "⚾", "🥎", "🎾", "🏐", "🏉", + "🥏", "🎱", "🪀", "🏓", "🏸", "🏒", "🏑", "🥍", + "🏏", "🪃", "🥅", "⛳", "🪁", "🏹", "🎣", "🤿", + "🥊", "🥋", "🎽", "🛹", "🛼", "🛷", "⛸️", "🥌", + "🎿", "⛷️", "🏂", "🪂", "🏋️", "🤼", "🤸", "⛹️", + "🤺", "🤾", "🏌️", "🏇", "🧘", "🏄", "🏊", "🤽", + "🚣", "🧗", "🚵", "🚴", "🏆", "🥇", "🥈", "🥉", + "🏅", "🎖️", "🏵️", "🎗️", "🎫", "🎟️", "🎪", "🤹", + "🎭", "🩰", "🎨", "🎬", "🎤", "🎧", "🎼", "🎹", + "🥁", "🪘", "🎷", "🎺", "🪗", "🎸", "🪕", "🎻", + "🎲", "♟️", "🎯", "🎳", "🎮", "🎰", "🧩" + ) + ), + EmojiCategory( + key = "travel_places", + title = "Места и путешествия", + icon = "✈️", + emojis = listOf( + "🚗", "🚕", "🚙", "🚌", "🚎", "🏎️", "🚓", "🚑", + "🚒", "🚐", "🛻", "🚚", "🚛", "🚜", "🛵", "🏍️", + "🛺", "🚲", "🛴", "🚏", "🛣️", "🛤️", "🛢️", "⛽", + "🚨", "🚥", "🚦", "🛑", "🚧", "⚓", "⛵", "🛶", + "🚤", "🛳️", "⛴️", "🚢", "✈️", "🛩️", "🛫", "🛬", + "🪂", "💺", "🚁", "🚟", "🚠", "🚡", "🛰️", "🚀", + "🛸", "🛎️", "🧳", "⌛", "⏳", "⌚", "⏰", "⏱️", + "🗺️", "🗿", "🗽", "🗼", "🏰", "🏯", "🏟️", "🎡", + "🎢", "🎠", "⛲", "⛱️", "🏖️", "🏝️", "🏜️", "🌋", + "⛰️", "🏔️", "🗻", "🏕️", "⛺", "🏠", "🏡", "🏘️", + "🏚️", "🏗️", "🏭", "🏢", "🏬", "🏣", "🏤", "🏥", + "🏦", "🏨", "🏪", "🏫", "🏩", "💒", "🏛️", "⛪", + "🕌", "🛕", "🕍", "⛩️", "🕋" + ) + ), + EmojiCategory( + key = "objects", + title = "Предметы", + icon = "💡", + emojis = listOf( + "⌚", "📱", "📲", "💻", "⌨️", "🖥️", "🖨️", "🖱️", + "🖲️", "🕹️", "🗜️", "💽", "💾", "💿", "📀", "📼", + "📷", "📸", "📹", "🎥", "📽️", "🎞️", "📞", "☎️", + "📟", "📠", "📺", "📻", "🎙️", "🎚️", "🎛️", "🧭", + "⏱️", "⏲️", "⏰", "🕰️", "⌛", "⏳", "📡", "🔋", + "🔌", "💡", "🔦", "🕯️", "🪔", "🧯", "🛢️", "💸", + "💵", "💴", "💶", "💷", "🪙", "💰", "💳", "💎", + "⚖️", "🪜", "🧰", "🪛", "🔧", "🔨", "⚒️", "🛠️", + "⛏️", "🪚", "🔩", "⚙️", "🪤", "🧱", "⛓️", "🧲", + "🔫", "💣", "🧨", "🪓", "🔪", "🗡️", "⚔️", "🛡️", + "🚬", "⚰️", "🪦", "⚱️", "🏺", "🔮", "📿", "🧿", + "💈", "⚗️", "🔭", "🔬", "🕳️", "🩹", "🩺", "💊", + "💉", "🩸", "🧬", "🦠", "🧫", "🧪", "🌡️", "🧹", + "🧺", "🧻", "🚽", "🚰", "🚿", "🛁", "🪒", "🧴", + "🧷", "🧵", "🪡", "🧶", "👓", "🕶️", "🥽", "🥼", + "🦺", "👔", "👕", "👖", "🧣", "🧤", "🧥", "🧦", + "👗", "👘", "🥻", "🩱", "🩲", "🩳", "👙", "👚", + "👛", "👜", "👝", "🛍️", "🎒", "🩴", "👞", "👟" + ) + ), + EmojiCategory( + key = "symbols", + title = "Символы", + icon = "❤️", + emojis = listOf( + "❤️", "🧡", "💛", "💚", "💙", "💜", "🖤", "🤍", + "🤎", "💔", "❣️", "💕", "💞", "💓", "💗", "💖", + "💘", "💝", "💟", "☮️", "✝️", "☪️", "🕉️", "☸️", + "✡️", "🔯", "🕎", "☯️", "☦️", "🛐", "⛎", "♈", + "♉", "♊", "♋", "♌", "♍", "♎", "♏", "♐", + "♑", "♒", "♓", "🆔", "⚛️", "🉑", "☢️", "☣️", + "📴", "📳", "🈶", "🈚", "🈸", "🈺", "🈷️", "✴️", + "🆚", "💮", "🉐", "㊙️", "㊗️", "🈴", "🈵", "🈹", + "🈲", "🅰️", "🅱️", "🆎", "🆑", "🅾️", "🆘", "❌", + "⭕", "🛑", "⛔", "📛", "🚫", "💯", "💢", "♨️", + "🚷", "🚯", "🚳", "🚱", "🔞", "📵", "🚭", "❗", + "❕", "❓", "❔", "‼️", "⁉️", "🔅", "🔆", "〽️", + "⚠️", "🚸", "🔱", "⚜️", "🔰", "♻️", "✅", "🈯", + "💹", "❇️", "✳️", "❎", "🌐", "💠", "Ⓜ️", "🌀", + "💤", "🏧", "🚾", "♿", "🅿️", "🛗", "🈳", "🈂️", + "🔵", "🔴", "🟠", "🟡", "🟢", "🟣", "⚫", "⚪", + "🟤", "🔶", "🔷", "🔸", "🔹", "🔺", "🔻", "💬" + ) + ), + EmojiCategory( + key = "flags", + title = "Флаги", + icon = "🏳️", + emojis = listOf( + "🏳️", "🏴", "🏁", "🚩", "🇷🇺", "🇺🇸", "🇬🇧", "🇩🇪", + "🇫🇷", "🇮🇹", "🇪🇸", "🇵🇹", "🇳🇱", "🇧🇪", "🇨🇭", "🇦🇹", + "🇵🇱", "🇨🇿", "🇸🇰", "🇭🇺", "🇷🇴", "🇧🇬", "🇬🇷", "🇹🇷", + "🇺🇦", "🇧🇾", "🇰🇿", "🇬🇪", "🇦🇲", "🇦🇿", "🇨🇳", "🇯🇵", + "🇰🇷", "🇮🇳", "🇮🇩", "🇹🇭", "🇻🇳", "🇦🇺", "🇳🇿", "🇨🇦", + "🇲🇽", "🇧🇷", "🇦🇷", "🇨🇱", "🇿🇦", "🇪🇬", "🇸🇦", "🇦🇪", + "🇮🇱", "🇮🇷" + ) + ) +) + +internal fun emojiPickerCategories(frequentEmojis: List<String>): List<EmojiCategory> = + listOf( + EmojiCategory( + key = "frequent", + title = "Частые", + icon = "🕘", + emojis = frequentEmojis.ifEmpty { DefaultFrequentEmojis } + ) + ) + StandardEmojiCategories + +internal fun loadFrequentEmojis(context: Context): List<String> { + val value = context.getSharedPreferences(EmojiPrefsName, Context.MODE_PRIVATE) + .getString(FrequentEmojiKey, null) + val stored = value + ?.split(EmojiSeparator) + ?.map { it.trim() } + ?.filter { it.isNotEmpty() } + ?.distinct() + ?.take(MaxFrequentEmoji) + .orEmpty() + return stored.ifEmpty { DefaultFrequentEmojis } +} + +internal fun storeFrequentEmoji(context: Context, current: List<String>, emoji: String): List<String> { + val updated = listOf(emoji) + .plus(current) + .distinct() + .take(MaxFrequentEmoji) + context.getSharedPreferences(EmojiPrefsName, Context.MODE_PRIVATE) + .edit() + .putString(FrequentEmojiKey, updated.joinToString(EmojiSeparator)) + .apply() + return updated +} diff --git a/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/chatinfo/ChatInfoScreen.kt b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/chatinfo/ChatInfoScreen.kt new file mode 100644 index 00000000..36d36ead --- /dev/null +++ b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/chatinfo/ChatInfoScreen.kt @@ -0,0 +1,261 @@ +package com.seven.ikar.kotlin.feature.chatinfo + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.WindowInsetsSides +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.only +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.safeDrawing +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Checkbox +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.seven.ikar.kotlin.core.model.ChatInviteLinkDto +import com.seven.ikar.kotlin.core.model.ChatTopicDto +import com.seven.ikar.kotlin.core.model.ChatType +import com.seven.ikar.kotlin.ui.IkarRowDivider +import com.seven.ikar.kotlin.ui.IkarScreenSurface +import com.seven.ikar.kotlin.ui.IkarSearchCard +import com.seven.ikar.kotlin.ui.IkarSectionCard +import com.seven.ikar.kotlin.ui.IkarSectionTitle +import com.seven.ikar.kotlin.ui.IkarSimpleHeader +import com.seven.ikar.kotlin.ui.theme.TelegramBlue +import com.seven.ikar.kotlin.ui.theme.TelegramDanger +import com.seven.ikar.kotlin.ui.theme.TelegramInkMuted + +@Composable +fun ChatInfoScreen( + state: ChatInfoUiState, + onBackClick: () -> Unit, + onRefreshClick: () -> Unit, + onMembersClick: () -> Unit, + onSearchClick: () -> Unit, + onInviteTitleChanged: (String) -> Unit, + onInviteJoinRequestChanged: (Boolean) -> Unit, + onCreateInviteClick: () -> Unit, + onRevokeInviteClick: (ChatInviteLinkDto) -> Unit, + onTopicTitleChanged: (String) -> Unit, + onCreateTopicClick: () -> Unit +) { + val info = state.info + IkarScreenSurface(modifier = Modifier.fillMaxSize()) { + Column( + modifier = Modifier + .fillMaxSize() + .windowInsetsPadding(WindowInsets.safeDrawing.only(WindowInsetsSides.Vertical)) + .verticalScroll(rememberScrollState()) + .padding(horizontal = 18.dp, vertical = 16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + IkarSimpleHeader( + title = info?.chat?.title?.ifBlank { "Информация о чате" } ?: "Информация о чате", + onBackClick = onBackClick + ) + + state.errorMessage?.let { + Text(it, color = TelegramDanger, style = MaterialTheme.typography.bodySmall) + } + if (state.isLoading && info == null) { + CircularProgressIndicator(color = TelegramBlue) + } + + if (info != null) { + IkarSectionCard { + IkarSectionTitle("Обзор", modifier = Modifier.padding(bottom = 8.dp)) + Text(info.chat.type.label(), style = MaterialTheme.typography.bodyMedium, color = TelegramInkMuted) + Text(info.channelAudienceText(), style = MaterialTheme.typography.titleMedium) + if (info.chat.type == ChatType.Channel && info.adminCount > 0) { + Text("${info.adminCount} ${russianPlural(info.adminCount, "администратор", "администратора", "администраторов")}", style = MaterialTheme.typography.bodySmall, color = TelegramInkMuted) + } + val description = info.description ?: info.chat.description + if (!description.isNullOrBlank()) { + Text(description, style = MaterialTheme.typography.bodyMedium) + } + if (!info.publicUsername.isNullOrBlank()) { + Text("@${info.publicUsername}", color = TelegramBlue) + } + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + TextButton(onClick = onMembersClick) { Text("Участники") } + TextButton(onClick = onSearchClick) { Text("Поиск") } + TextButton(onClick = onRefreshClick) { Text("Обновить") } + } + } + + IkarSectionCard { + IkarSectionTitle("Общие материалы", modifier = Modifier.padding(bottom = 8.dp)) + InfoStatRow("Медиа", info.mediaCount) + InfoStatRow("Файлы", info.fileCount) + InfoStatRow("Ссылки", info.linkCount) + InfoStatRow("Голосовые", info.voiceCount) + InfoStatRow("Закреплённые", info.pinnedCount) + } + + IkarSectionCard { + IkarSectionTitle("Пригласительные ссылки", modifier = Modifier.padding(bottom = 8.dp)) + IkarSearchCard( + value = state.inviteTitle, + onValueChange = onInviteTitleChanged, + placeholder = "Название ссылки" + ) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Checkbox( + checked = state.inviteCreatesJoinRequest, + onCheckedChange = onInviteJoinRequestChanged, + enabled = !state.isSaving + ) + Column(modifier = Modifier.weight(1f)) { + Text("Требовать одобрение администратора", style = MaterialTheme.typography.bodyMedium) + Text( + "Новые пользователи попадут в заявки на вступление, а не войдут сразу.", + style = MaterialTheme.typography.bodySmall, + color = TelegramInkMuted + ) + } + } + TextButton(enabled = !state.isSaving, onClick = onCreateInviteClick) { + Text(if (state.isSaving) "Сохранение..." else "Создать ссылку") + } + if (info.inviteLinks.isEmpty()) { + Text("Пригласительных ссылок пока нет.", style = MaterialTheme.typography.bodySmall, color = TelegramInkMuted) + } + info.inviteLinks.forEachIndexed { index, link -> + InviteLinkRow(link = link, onRevokeClick = { onRevokeInviteClick(link) }) + if (index != info.inviteLinks.lastIndex) { + IkarRowDivider() + } + } + } + + IkarSectionCard { + IkarSectionTitle("Темы", modifier = Modifier.padding(bottom = 8.dp)) + IkarSearchCard( + value = state.topicTitle, + onValueChange = onTopicTitleChanged, + placeholder = "Новая тема" + ) + TextButton(enabled = !state.isSaving, onClick = onCreateTopicClick) { + Text(if (state.isSaving) "Сохранение..." else "Создать тему") + } + info.topics.forEachIndexed { index, topic -> + TopicRow(topic) + if (index != info.topics.lastIndex) { + IkarRowDivider() + } + } + } + } + } + } +} + +@Composable +private fun InfoStatRow(label: String, value: Int) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text(label, style = MaterialTheme.typography.bodyMedium) + Text(value.toString(), style = MaterialTheme.typography.titleMedium, color = TelegramBlue) + } +} + +@Composable +private fun InviteLinkRow( + link: ChatInviteLinkDto, + onRevokeClick: () -> Unit +) { + Column( + modifier = Modifier.padding(vertical = 10.dp), + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + Text(link.title ?: "Пригласительная ссылка", style = MaterialTheme.typography.titleMedium) + Text( + text = link.link, + style = MaterialTheme.typography.bodySmall, + color = if (link.isRevoked) TelegramInkMuted else TelegramBlue, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + Text( + text = when { + link.isRevoked -> "Отозвана" + link.createsJoinRequest -> "Требует одобрения" + else -> "Мгновенный вход" + }, + style = MaterialTheme.typography.bodySmall, + color = if (link.isRevoked) TelegramDanger else TelegramInkMuted + ) + if (!link.isRevoked) { + TextButton(onClick = onRevokeClick) { Text("Отозвать") } + } + } +} + +@Composable +private fun TopicRow(topic: ChatTopicDto) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 10.dp), + horizontalArrangement = Arrangement.spacedBy(10.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text(topic.icon, style = MaterialTheme.typography.titleLarge) + Column(modifier = Modifier.weight(1f)) { + Text(topic.title, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold) + Text("${topic.messageCount} сообщений", style = MaterialTheme.typography.bodySmall, color = TelegramInkMuted) + } + } +} + +private fun ChatType.label(): String = when (this) { + ChatType.Direct -> "Личный чат" + ChatType.Group -> "Группа" + ChatType.Channel -> "Канал" +} + +private fun com.seven.ikar.kotlin.core.model.ChatInfoDto.channelAudienceText(): String { + if (chat.type != ChatType.Channel) { + return "${chat.participants.size} участников" + } + + val count = when { + subscriberCount > 0 -> subscriberCount + chat.subscriberCount > 0 -> chat.subscriberCount + else -> chat.participants.size + } + return "$count ${russianPlural(count, "подписчик", "подписчика", "подписчиков")}" +} + +private fun russianPlural(count: Int, one: String, few: String, many: String): String { + val value = kotlin.math.abs(count) % 100 + val lastDigit = value % 10 + if (value in 11..14) { + return many + } + + return when (lastDigit) { + 1 -> one + in 2..4 -> few + else -> many + } +} diff --git a/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/chatinfo/ChatInfoViewModel.kt b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/chatinfo/ChatInfoViewModel.kt new file mode 100644 index 00000000..efe9f57e --- /dev/null +++ b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/chatinfo/ChatInfoViewModel.kt @@ -0,0 +1,123 @@ +package com.seven.ikar.kotlin.feature.chatinfo + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.seven.ikar.kotlin.app.AppContainer +import com.seven.ikar.kotlin.core.model.ChatInfoDto +import com.seven.ikar.kotlin.core.model.ChatInviteLinkDto +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch + +data class ChatInfoUiState( + val info: ChatInfoDto? = null, + val inviteTitle: String = "", + val inviteCreatesJoinRequest: Boolean = false, + val topicTitle: String = "", + val isLoading: Boolean = false, + val isSaving: Boolean = false, + val errorMessage: String? = null +) + +class ChatInfoViewModel( + private val container: AppContainer, + private val chatId: String +) : ViewModel() { + private val _uiState = MutableStateFlow(ChatInfoUiState()) + val uiState: StateFlow<ChatInfoUiState> = _uiState.asStateFlow() + + init { + refresh() + } + + fun refresh() { + viewModelScope.launch { + _uiState.value = _uiState.value.copy(isLoading = true, errorMessage = null) + runCatching { + container.executeAuthorized { session -> + container.apiClient.getChatInfo(session.accessToken, chatId) + } + }.onSuccess { info -> + _uiState.value = _uiState.value.copy(info = info, isLoading = false) + }.onFailure { error -> + _uiState.value = _uiState.value.copy(isLoading = false, errorMessage = error.message) + } + } + } + + fun updateInviteTitle(value: String) { + _uiState.value = _uiState.value.copy(inviteTitle = value.take(64)) + } + + fun updateInviteCreatesJoinRequest(value: Boolean) { + _uiState.value = _uiState.value.copy(inviteCreatesJoinRequest = value) + } + + fun updateTopicTitle(value: String) { + _uiState.value = _uiState.value.copy(topicTitle = value.take(80)) + } + + fun createInviteLink() { + val state = _uiState.value + if (state.isSaving) return + viewModelScope.launch { + _uiState.value = _uiState.value.copy(isSaving = true, errorMessage = null) + runCatching { + container.executeAuthorized { session -> + container.apiClient.createChatInviteLink( + accessToken = session.accessToken, + chatId = chatId, + title = state.inviteTitle, + createsJoinRequest = state.inviteCreatesJoinRequest + ) + } + }.onSuccess { + _uiState.value = _uiState.value.copy( + inviteTitle = "", + inviteCreatesJoinRequest = false, + isSaving = false + ) + refresh() + }.onFailure { error -> + _uiState.value = _uiState.value.copy(isSaving = false, errorMessage = error.message) + } + } + } + + fun revokeInviteLink(link: ChatInviteLinkDto) { + if (_uiState.value.isSaving) return + viewModelScope.launch { + _uiState.value = _uiState.value.copy(isSaving = true, errorMessage = null) + runCatching { + container.executeAuthorized { session -> + container.apiClient.revokeChatInviteLink(session.accessToken, chatId, link.id) + } + }.onSuccess { + _uiState.value = _uiState.value.copy(isSaving = false) + refresh() + }.onFailure { error -> + _uiState.value = _uiState.value.copy(isSaving = false, errorMessage = error.message) + } + } + } + + fun createTopic() { + val state = _uiState.value + val title = state.topicTitle.trim() + if (state.isSaving || title.length < 2) return + viewModelScope.launch { + _uiState.value = _uiState.value.copy(isSaving = true, errorMessage = null) + runCatching { + container.executeAuthorized { session -> + container.apiClient.createChatTopic(session.accessToken, chatId, title) + } + }.onSuccess { + _uiState.value = _uiState.value.copy(topicTitle = "", isSaving = false) + refresh() + }.onFailure { error -> + _uiState.value = _uiState.value.copy(isSaving = false, errorMessage = error.message) + } + } + } +} diff --git a/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/chats/ChatsScreen.kt b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/chats/ChatsScreen.kt new file mode 100644 index 00000000..5acbb650 --- /dev/null +++ b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/chats/ChatsScreen.kt @@ -0,0 +1,1007 @@ +package com.seven.ikar.kotlin.feature.chats + +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.WindowInsetsSides +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.only +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.safeDrawing +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Archive +import androidx.compose.material.icons.outlined.CheckCircle +import androidx.compose.material.icons.outlined.Close +import androidx.compose.material.icons.outlined.Delete +import androidx.compose.material.icons.outlined.Edit +import androidx.compose.material.icons.outlined.Image +import androidx.compose.material.icons.outlined.NotificationsOff +import androidx.compose.material.icons.outlined.PlayArrow +import androidx.compose.material.icons.outlined.PushPin +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Checkbox +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.nestedscroll.NestedScrollConnection +import androidx.compose.ui.input.nestedscroll.NestedScrollSource +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.seven.ikar.kotlin.core.model.AttachmentDto +import com.seven.ikar.kotlin.core.model.AttachmentKind +import com.seven.ikar.kotlin.core.model.ChatSummaryDto +import com.seven.ikar.kotlin.core.model.ChatType +import com.seven.ikar.kotlin.core.model.StoryDto +import com.seven.ikar.kotlin.core.network.ServerConfig +import com.seven.ikar.kotlin.core.settings.ChatListLayoutOption +import com.seven.ikar.kotlin.core.settings.ChatSettingsSnapshot +import com.seven.ikar.kotlin.feature.share.PendingExternalShare +import com.seven.ikar.kotlin.ui.AuthenticatedAsyncImage +import com.seven.ikar.kotlin.ui.AvatarView +import com.seven.ikar.kotlin.ui.IkarFab +import com.seven.ikar.kotlin.ui.IkarFloatingMainMenu +import com.seven.ikar.kotlin.ui.IkarMainMenuTab +import com.seven.ikar.kotlin.ui.IkarRowDivider +import com.seven.ikar.kotlin.ui.IkarScreenSurface +import com.seven.ikar.kotlin.ui.IkarSearchCard +import com.seven.ikar.kotlin.ui.IkarSectionCard +import com.seven.ikar.kotlin.ui.chatPreview +import com.seven.ikar.kotlin.ui.theme.TelegramBlue +import com.seven.ikar.kotlin.ui.theme.TelegramInkMuted + +@Composable +fun ChatsScreen( + state: ChatsUiState, + chatSettings: ChatSettingsSnapshot, + onChatClick: (ChatSummaryDto) -> Unit, + onChatSelectionClick: (ChatSummaryDto) -> Unit, + onChatLongPress: (ChatSummaryDto) -> Unit, + onClearSelection: () -> Unit, + onSendSelectedChatsClick: () -> Unit, + onDismissPendingShare: () -> Unit, + onDeleteSelectedClick: () -> Unit, + onPinSelectedChatsClick: () -> Unit, + onArchiveSelectedChatsClick: () -> Unit, + onMuteSelectedChatsClick: () -> Unit, + onDismissDeleteDialog: () -> Unit, + onDeleteForEveryoneChanged: (Boolean) -> Unit, + onConfirmDeleteSelected: () -> Unit, + onLogoutClick: () -> Unit, + onRefreshClick: () -> Unit, + onProfileClick: () -> Unit, + onContactsClick: () -> Unit, + onDiscoverClick: () -> Unit, + onComposeStoryClick: () -> Unit, + onStoryClick: (StoryDto) -> Unit, + onSettingsClick: () -> Unit, + onNotificationsClick: () -> Unit, + onGlobalSearchClick: () -> Unit, + onQueryChanged: (String) -> Unit +) { + val isRealtimeConnected = !state.isLoading || state.chats.isNotEmpty() + val previewMaxLines = if (chatSettings.chatListLayout == ChatListLayoutOption.ThreeLine) 2 else 1 + val chatListState = rememberLazyListState() + var isStoriesHeaderVisible by remember { mutableStateOf(false) } + var storyHeaderDragOffset by remember { mutableStateOf(0f) } + val canToggleStoriesHeader = !state.isSelectionMode && !state.isShareMode + val storyHeaderDragThresholdPx = with(LocalDensity.current) { 42.dp.toPx() } + + LaunchedEffect(canToggleStoriesHeader) { + if (!canToggleStoriesHeader) { + isStoriesHeaderVisible = false + storyHeaderDragOffset = 0f + } + } + + val storyHeaderNestedScrollConnection = remember( + canToggleStoriesHeader, + chatListState, + storyHeaderDragThresholdPx + ) { + object : NestedScrollConnection { + override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset { + if (!canToggleStoriesHeader || source != NestedScrollSource.UserInput) { + return Offset.Zero + } + + val isChatListAtTop = chatListState.firstVisibleItemIndex == 0 && + chatListState.firstVisibleItemScrollOffset == 0 + when { + available.y > 0f && !isStoriesHeaderVisible && isChatListAtTop -> { + storyHeaderDragOffset += available.y + if (storyHeaderDragOffset >= storyHeaderDragThresholdPx) { + isStoriesHeaderVisible = true + storyHeaderDragOffset = 0f + } + } + + available.y < 0f && isStoriesHeaderVisible -> { + storyHeaderDragOffset += available.y + if (storyHeaderDragOffset <= -storyHeaderDragThresholdPx) { + isStoriesHeaderVisible = false + storyHeaderDragOffset = 0f + } + } + + else -> { + storyHeaderDragOffset = 0f + } + } + + return Offset.Zero + } + } + } + + IkarScreenSurface(modifier = Modifier.fillMaxSize()) { + Column( + modifier = Modifier + .fillMaxSize() + .windowInsetsPadding(WindowInsets.safeDrawing.only(WindowInsetsSides.Vertical)) + .nestedScroll(storyHeaderNestedScrollConnection) + ) { + Column( + modifier = Modifier.padding(start = 18.dp, top = 16.dp, end = 18.dp, bottom = 12.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + when { + state.isSelectionMode && state.isShareMode -> { + ShareSelectionHeader( + selectedChatsCount = state.selectedChatsCount, + isSendingShare = state.isSendingShare, + onClearSelection = onClearSelection, + onSendSelectedChatsClick = onSendSelectedChatsClick + ) + } + + state.isSelectionMode -> { + SelectionHeader( + selectedChatsCount = state.selectedChatsCount, + isDeleteEnabled = !state.isDeletingSelected, + onClearSelection = onClearSelection, + onPinSelectedChatsClick = onPinSelectedChatsClick, + onArchiveSelectedChatsClick = onArchiveSelectedChatsClick, + onMuteSelectedChatsClick = onMuteSelectedChatsClick, + onDeleteSelectedClick = onDeleteSelectedClick + ) + } + + else -> { + if (isStoriesHeaderVisible) { + StoriesTray( + stories = state.stories, + accessToken = state.session?.accessToken, + currentUserId = state.session?.user?.id, + onComposeStoryClick = onComposeStoryClick, + onStoryClick = onStoryClick + ) + } else { + ChatsBrandHeader( + isRealtimeConnected = isRealtimeConnected, + stories = state.stories, + accessToken = state.session?.accessToken, + currentUserId = state.session?.user?.id, + onStoriesClick = { isStoriesHeaderVisible = true } + ) + } + } + } + + IkarSearchCard( + value = state.query, + onValueChange = onQueryChanged, + placeholder = "Поиск чатов" + ) + + TextButton(onClick = onGlobalSearchClick) { + Text("Глобальный поиск: сообщения, медиа, файлы и ссылки") + } + + state.pendingShare?.let { pendingShare -> + PendingShareCard( + pendingShare = pendingShare, + isSendingShare = state.isSendingShare, + onDismiss = onDismissPendingShare + ) + } + + state.errorMessage?.let { + Text(it, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall) + } + } + + Box(modifier = Modifier.weight(1f).fillMaxWidth()) { + LazyColumn( + state = chatListState, + modifier = Modifier.fillMaxSize(), + verticalArrangement = Arrangement.spacedBy(0.dp) + ) { + if (state.chats.isEmpty()) { + item { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 24.dp, vertical = 48.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Text("Пока нет чатов", style = MaterialTheme.typography.headlineMedium) + Text( + if (state.isShareMode) { + "Поделиться пока некуда: сначала создайте хотя бы один чат." + } else { + "Найдите пользователя или откройте новый чат." + }, + color = TelegramInkMuted, + style = MaterialTheme.typography.bodyMedium + ) + } + } + } else { + items(state.chats, key = { it.chat.id }) { chatItem -> + ChatListRow( + chatItem = chatItem, + currentUserId = state.session?.user?.id, + accessToken = state.session?.accessToken, + previewMaxLines = previewMaxLines, + onClick = { + if (state.isSelectionMode) { + onChatSelectionClick(chatItem.chat) + } else { + onChatClick(chatItem.chat) + } + }, + onLongClick = { onChatLongPress(chatItem.chat) } + ) + } + } + item { Box(modifier = Modifier.padding(bottom = 132.dp)) } + } + + if (!state.isSelectionMode && !state.isShareMode) { + IkarFab( + icon = Icons.Outlined.Edit, + contentDescription = "Новое сообщение", + modifier = Modifier + .align(Alignment.BottomEnd) + .padding(end = 20.dp, bottom = 126.dp), + onClick = onDiscoverClick + ) + } + } + } + + IkarFloatingMainMenu( + selectedTab = IkarMainMenuTab.Chats, + modifier = Modifier + .align(Alignment.BottomCenter) + .padding(horizontal = 8.dp, vertical = 18.dp), + onChatsClick = {}, + onContactsClick = onContactsClick, + onSettingsClick = onSettingsClick, + onProfileClick = onProfileClick + ) + + if (state.isDeleteDialogVisible) { + DeleteChatsDialog( + selectedChatsCount = state.selectedChatsCount, + showDeleteForEveryoneOption = state.showDeleteForEveryoneOption, + deleteForEveryone = state.deleteForEveryone, + isDeletingSelected = state.isDeletingSelected, + onDismiss = onDismissDeleteDialog, + onDeleteForEveryoneChanged = onDeleteForEveryoneChanged, + onConfirmDeleteSelected = onConfirmDeleteSelected + ) + } + } +} + +@Composable +private fun ChatsBrandHeader( + isRealtimeConnected: Boolean, + stories: List<StoryDto>, + accessToken: String?, + currentUserId: String?, + onStoriesClick: () -> Unit +) { + Row( + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + StoriesClusterButton( + stories = stories, + accessToken = accessToken, + currentUserId = currentUserId, + onClick = onStoriesClick + ) + Column(verticalArrangement = Arrangement.spacedBy(0.dp)) { + Text( + text = if (isRealtimeConnected) "ИКАР" else "Соединение...", + style = MaterialTheme.typography.displayMedium.copy( + letterSpacing = if (isRealtimeConnected) 3.sp else 0.sp + ), + color = TelegramBlue + ) + Text("Чаты", style = MaterialTheme.typography.bodySmall, color = TelegramInkMuted) + } + } +} + +@Composable +private fun StoriesClusterButton( + stories: List<StoryDto>, + accessToken: String?, + currentUserId: String?, + onClick: () -> Unit +) { + val previewStories = stories.take(3) + val placeholderColors = listOf( + TelegramBlue, + Color(0xFF7C4DFF), + Color(0xFF00A884) + ) + + Box( + modifier = Modifier + .width(58.dp) + .height(36.dp) + .clickable(onClick = onClick), + contentAlignment = Alignment.CenterStart + ) { + repeat(3) { index -> + val story = previewStories.getOrNull(index) + Box( + modifier = Modifier + .padding(start = (index * 13).dp) + .size(30.dp) + .clip(CircleShape) + .background(placeholderColors[index].copy(alpha = if (story == null) 0.18f else 1f)) + .border(2.dp, Color.White, CircleShape) + .padding(2.dp) + .clip(CircleShape), + contentAlignment = Alignment.Center + ) { + if (story != null) { + val title = if (story.author.id == currentUserId) "Моя" else story.author.displayName + val previewUrl = ServerConfig.normalizeMediaUrl( + story.attachments.firstOrNull { it.contentType.startsWith("image/", ignoreCase = true) }?.downloadPath + ) + if (previewUrl != null) { + AuthenticatedAsyncImage( + imageUrl = previewUrl, + accessToken = accessToken, + contentDescription = title, + modifier = Modifier.fillMaxSize(), + contentScale = ContentScale.Crop + ) + } else { + AvatarView( + imageUrl = ServerConfig.normalizeMediaUrl(story.author.avatarPath), + title = title, + accessToken = accessToken, + size = 26.dp + ) + } + } + } + } + } +} + +@Composable +private fun StoriesTray( + stories: List<StoryDto>, + accessToken: String?, + currentUserId: String?, + onComposeStoryClick: () -> Unit, + onStoryClick: (StoryDto) -> Unit +) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 2.dp), + verticalArrangement = Arrangement.spacedBy(0.dp) + ) { + LazyRow( + horizontalArrangement = Arrangement.spacedBy(12.dp), + modifier = Modifier.fillMaxWidth() + ) { + item { + StoryBubble( + title = "Моя", + avatarUrl = null, + previewUrl = null, + accessToken = accessToken, + viewed = false, + onClick = onComposeStoryClick, + modifier = Modifier.padding(start = 18.dp) + ) + } + + items(stories, key = { it.id }) { story -> + StoryBubble( + title = if (story.author.id == currentUserId) "Моя" else story.author.displayName, + avatarUrl = ServerConfig.normalizeMediaUrl(story.author.avatarPath), + previewUrl = ServerConfig.normalizeMediaUrl( + story.attachments.firstOrNull { it.contentType.startsWith("image/") }?.downloadPath + ), + accessToken = accessToken, + viewed = story.viewedByMe || story.author.id == currentUserId, + onClick = { onStoryClick(story) } + ) + } + + item { + Spacer(modifier = Modifier.width(6.dp)) + } + } + } +} + +@Composable +private fun StoryBubble( + title: String, + avatarUrl: String?, + previewUrl: String?, + accessToken: String?, + viewed: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier +) { + Column( + modifier = modifier + .width(74.dp) + .clickable(onClick = onClick), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(6.dp) + ) { + Box( + modifier = Modifier + .size(62.dp) + .clip(CircleShape) + .border( + width = 2.dp, + color = if (viewed) Color(0xFFD8DEE6) else TelegramBlue, + shape = CircleShape + ) + .padding(3.dp) + .clip(CircleShape), + contentAlignment = Alignment.Center + ) { + if (previewUrl != null) { + AuthenticatedAsyncImage( + imageUrl = previewUrl, + accessToken = accessToken, + contentDescription = title, + modifier = Modifier.fillMaxSize(), + contentScale = androidx.compose.ui.layout.ContentScale.Crop + ) + } else { + AvatarView( + imageUrl = avatarUrl, + title = title, + accessToken = accessToken, + size = 56.dp + ) + } + } + Text( + title, + style = MaterialTheme.typography.bodySmall, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } +} + +@Composable +private fun ShareSelectionHeader( + selectedChatsCount: Int, + isSendingShare: Boolean, + onClearSelection: () -> Unit, + onSendSelectedChatsClick: () -> Unit +) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(14.dp) + ) { + Icon( + imageVector = Icons.Outlined.Close, + contentDescription = "Снять выделение", + tint = TelegramInkMuted, + modifier = Modifier.clickable(enabled = !isSendingShare, onClick = onClearSelection) + ) + + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(2.dp) + ) { + Text( + text = selectionCountLabel(selectedChatsCount), + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.SemiBold + ) + Text( + text = "Можно отправить в выбранные чаты", + style = MaterialTheme.typography.bodySmall, + color = TelegramInkMuted + ) + } + + TextButton( + enabled = !isSendingShare, + onClick = onSendSelectedChatsClick + ) { + Text( + text = if (isSendingShare) "Отправка..." else "Отправить", + color = if (isSendingShare) TelegramInkMuted else TelegramBlue + ) + } + } +} + +@Composable +private fun SelectionHeader( + selectedChatsCount: Int, + isDeleteEnabled: Boolean, + onClearSelection: () -> Unit, + onPinSelectedChatsClick: () -> Unit, + onArchiveSelectedChatsClick: () -> Unit, + onMuteSelectedChatsClick: () -> Unit, + onDeleteSelectedClick: () -> Unit +) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(14.dp) + ) { + Icon( + imageVector = Icons.Outlined.Close, + contentDescription = "Снять выделение", + tint = TelegramInkMuted, + modifier = Modifier.clickable(onClick = onClearSelection) + ) + + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(2.dp) + ) { + Text( + text = selectionCountLabel(selectedChatsCount), + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.SemiBold + ) + Text( + text = "Можно удалить выбранные чаты", + style = MaterialTheme.typography.bodySmall, + color = TelegramInkMuted + ) + } + + Icon( + imageVector = Icons.Outlined.PushPin, + contentDescription = "Закрепить выбранные чаты", + tint = TelegramBlue, + modifier = Modifier.clickable(onClick = onPinSelectedChatsClick) + ) + + Icon( + imageVector = Icons.Outlined.Archive, + contentDescription = "Архивировать выбранные чаты", + tint = TelegramBlue, + modifier = Modifier.clickable(onClick = onArchiveSelectedChatsClick) + ) + + Icon( + imageVector = Icons.Outlined.NotificationsOff, + contentDescription = "Отключить уведомления в выбранных чатах", + tint = TelegramBlue, + modifier = Modifier.clickable(onClick = onMuteSelectedChatsClick) + ) + + Icon( + imageVector = Icons.Outlined.Delete, + contentDescription = "Удалить выбранные чаты", + tint = if (isDeleteEnabled) MaterialTheme.colorScheme.error else TelegramInkMuted, + modifier = Modifier.clickable(enabled = isDeleteEnabled, onClick = onDeleteSelectedClick) + ) + } +} + +@Composable +private fun PendingShareCard( + pendingShare: PendingExternalShare, + isSendingShare: Boolean, + onDismiss: () -> Unit +) { + IkarSectionCard { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.Top + ) { + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(2.dp) + ) { + Text( + text = if (isSendingShare) "Отправка..." else "Будет отправлено", + style = MaterialTheme.typography.labelLarge, + color = TelegramBlue + ) + Text( + text = if (isSendingShare) { + "Подождите, отправка уже выполняется." + } else { + "Коснитесь чата, чтобы отправить сразу. Удерживайте, чтобы выбрать несколько." + }, + style = MaterialTheme.typography.bodySmall, + color = TelegramInkMuted + ) + } + Icon( + imageVector = Icons.Outlined.Close, + contentDescription = "Отменить поделиться", + tint = TelegramInkMuted, + modifier = Modifier.clickable(enabled = !isSendingShare, onClick = onDismiss) + ) + } + + pendingShare.text?.takeIf { it.isNotBlank() }?.let { sharedText -> + Text( + text = sharedText, + style = MaterialTheme.typography.bodyMedium, + maxLines = 3, + overflow = TextOverflow.Ellipsis + ) + } + + if (pendingShare.attachments.isNotEmpty()) { + Text( + text = pendingShareAttachmentSummary(pendingShare), + style = MaterialTheme.typography.bodySmall, + color = TelegramInkMuted + ) + pendingShare.attachments.take(3).forEach { attachment -> + Text( + text = attachment.fileName, + style = MaterialTheme.typography.bodyMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + } + } + } +} + +@Composable +private fun DeleteChatsDialog( + selectedChatsCount: Int, + showDeleteForEveryoneOption: Boolean, + deleteForEveryone: Boolean, + isDeletingSelected: Boolean, + onDismiss: () -> Unit, + onDeleteForEveryoneChanged: (Boolean) -> Unit, + onConfirmDeleteSelected: () -> Unit +) { + AlertDialog( + onDismissRequest = { + if (!isDeletingSelected) { + onDismiss() + } + }, + title = { + Text(deleteDialogTitle(selectedChatsCount)) + }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { + Text( + text = deleteDialogMessage(selectedChatsCount), + color = TelegramInkMuted + ) + if (showDeleteForEveryoneOption) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp) + ) { + Checkbox( + checked = deleteForEveryone, + enabled = !isDeletingSelected, + onCheckedChange = onDeleteForEveryoneChanged + ) + Text( + text = "Также удалить чат для собеседника", + style = MaterialTheme.typography.bodyMedium + ) + } + } + } + }, + confirmButton = { + TextButton( + enabled = !isDeletingSelected, + onClick = onConfirmDeleteSelected + ) { + Text("Удалить", color = MaterialTheme.colorScheme.error) + } + }, + dismissButton = { + TextButton( + enabled = !isDeletingSelected, + onClick = onDismiss + ) { + Text("Отмена") + } + } + ) +} + +@Composable +@OptIn(ExperimentalFoundationApi::class) +private fun ChatListRow( + chatItem: ChatListItemUiState, + currentUserId: String?, + accessToken: String?, + previewMaxLines: Int, + onClick: () -> Unit, + onLongClick: () -> Unit +) { + val chat = chatItem.chat + val avatarUser = if (chat.type == ChatType.Direct) { + chat.participants.firstOrNull { it.id != currentUserId } ?: chat.participants.firstOrNull() + } else { + null + } + val avatarUrl = ServerConfig.normalizeMediaUrl(avatarUser?.avatarPath) + val rowBackground = if (chatItem.isSelected) { + TelegramBlue.copy(alpha = 0.12f) + } else { + Color.Transparent + } + + Column { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp, vertical = 2.dp) + .clip(MaterialTheme.shapes.large) + .background(rowBackground) + .combinedClickable(onClick = onClick, onLongClick = onLongClick) + .padding(horizontal = 8.dp, vertical = 10.dp), + horizontalArrangement = Arrangement.spacedBy(14.dp), + verticalAlignment = Alignment.CenterVertically + ) { + AvatarView( + imageUrl = avatarUrl, + title = chatItem.resolvedTitle, + size = 56.dp + ) + + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(3.dp) + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = chatItem.resolvedTitle, + modifier = Modifier.weight(1f), + style = MaterialTheme.typography.titleLarge, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + if (chatItem.lastActivityLabel.isNotBlank()) { + Text( + text = chatItem.lastActivityLabel, + style = MaterialTheme.typography.bodySmall, + color = TelegramInkMuted, + maxLines = 1 + ) + } + } + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + ChatLastMessagePreview( + chat = chat, + accessToken = accessToken, + modifier = Modifier.weight(1f), + maxLines = previewMaxLines + ) + if (chat.unreadCount > 0) { + Box( + modifier = Modifier + .background(TelegramBlue, CircleShape) + .padding(horizontal = 8.dp, vertical = 4.dp), + contentAlignment = Alignment.Center + ) { + Text( + text = chat.unreadCount.toString(), + color = Color.White, + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.SemiBold, + maxLines = 1 + ) + } + } + } + } + + if (chatItem.isSelected) { + Spacer(modifier = Modifier.width(4.dp)) + Icon( + imageVector = Icons.Outlined.CheckCircle, + contentDescription = "Чат выбран", + tint = TelegramBlue + ) + } + } + IkarRowDivider(startIndent = 86.dp) + } +} + +@Composable +private fun ChatLastMessagePreview( + chat: ChatSummaryDto, + accessToken: String?, + modifier: Modifier = Modifier, + maxLines: Int +) { + val attachment = chat.lastMessageAttachment + when { + attachment?.isChatPreviewImage() == true -> { + Row( + modifier = modifier, + horizontalArrangement = Arrangement.spacedBy(6.dp), + verticalAlignment = Alignment.CenterVertically + ) { + val imageUrl = ServerConfig.normalizeMediaUrl(attachment.downloadPath) + Box( + modifier = Modifier + .size(18.dp) + .clip(RoundedCornerShape(4.dp)) + .background(TelegramBlue.copy(alpha = 0.12f)), + contentAlignment = Alignment.Center + ) { + if (imageUrl != null) { + AuthenticatedAsyncImage( + imageUrl = imageUrl, + accessToken = accessToken, + contentDescription = "Фотография", + modifier = Modifier.fillMaxSize(), + contentScale = ContentScale.Crop + ) + } else { + Icon( + imageVector = Icons.Outlined.Image, + contentDescription = null, + tint = TelegramInkMuted, + modifier = Modifier.size(14.dp) + ) + } + } + Text( + text = "фотография", + modifier = Modifier.weight(1f), + style = MaterialTheme.typography.bodySmall, + color = TelegramInkMuted, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + } + + attachment?.isChatPreviewVoiceNote() == true -> { + Row( + modifier = modifier, + horizontalArrangement = Arrangement.spacedBy(6.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = Icons.Outlined.PlayArrow, + contentDescription = null, + tint = TelegramInkMuted, + modifier = Modifier.size(16.dp) + ) + Text( + text = "голосовое сообщение", + modifier = Modifier.weight(1f), + style = MaterialTheme.typography.bodySmall, + color = TelegramInkMuted, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + } + + else -> { + Text( + text = chatPreview(chat), + modifier = modifier, + style = MaterialTheme.typography.bodySmall, + color = TelegramInkMuted, + maxLines = maxLines, + overflow = TextOverflow.Ellipsis + ) + } + } +} + +private fun AttachmentDto.isChatPreviewImage(): Boolean = + contentType.startsWith("image/", ignoreCase = true) || + fileName.endsWith(".jpg", ignoreCase = true) || + fileName.endsWith(".jpeg", ignoreCase = true) || + fileName.endsWith(".png", ignoreCase = true) || + fileName.endsWith(".webp", ignoreCase = true) || + fileName.endsWith(".gif", ignoreCase = true) + +private fun AttachmentDto.isChatPreviewVoiceNote(): Boolean = + kind == AttachmentKind.VoiceNote || + contentType.startsWith("audio/", ignoreCase = true) + +private fun selectionCountLabel(selectedChatsCount: Int): String = when { + selectedChatsCount % 10 == 1 && selectedChatsCount % 100 != 11 -> "Выбран $selectedChatsCount чат" + selectedChatsCount % 10 in 2..4 && selectedChatsCount % 100 !in 12..14 -> "Выбрано $selectedChatsCount чата" + else -> "Выбрано $selectedChatsCount чатов" +} + +private fun deleteDialogTitle(selectedChatsCount: Int): String = + if (selectedChatsCount == 1) "Удалить чат?" else "Удалить выбранные чаты?" + +private fun deleteDialogMessage(selectedChatsCount: Int): String = + if (selectedChatsCount == 1) { + "Чат исчезнет из вашего списка после подтверждения." + } else { + "Выбранные чаты исчезнут из вашего списка после подтверждения." + } + +private fun pendingShareAttachmentSummary(pendingShare: PendingExternalShare): String = when (pendingShare.attachments.size) { + 0 -> "" + 1 -> "1 файл" + 2, 3, 4 -> "${pendingShare.attachments.size} файла" + else -> "${pendingShare.attachments.size} файлов" +} diff --git a/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/chats/ChatsViewModel.kt b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/chats/ChatsViewModel.kt new file mode 100644 index 00000000..a2539dfe --- /dev/null +++ b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/chats/ChatsViewModel.kt @@ -0,0 +1,588 @@ +package com.seven.ikar.kotlin.feature.chats + +import android.Manifest +import android.content.pm.PackageManager +import androidx.core.content.ContextCompat +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.seven.ikar.kotlin.app.AppContainer +import com.seven.ikar.kotlin.core.contacts.PhoneNumberNormalizer +import com.seven.ikar.kotlin.core.model.AndroidAppReleaseDto +import com.seven.ikar.kotlin.core.model.AuthSessionDto +import com.seven.ikar.kotlin.core.model.ChatStateRequest +import com.seven.ikar.kotlin.core.model.ChatSummaryDto +import com.seven.ikar.kotlin.core.model.ChatType +import com.seven.ikar.kotlin.core.model.StoryDto +import com.seven.ikar.kotlin.feature.share.PendingExternalShare +import com.seven.ikar.kotlin.ui.formatLastActivity +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import java.time.OffsetDateTime +import java.time.ZoneOffset +import java.time.format.DateTimeFormatter + +data class ChatListItemUiState( + val chat: ChatSummaryDto, + val resolvedTitle: String, + val lastActivityLabel: String, + val isSelected: Boolean +) + +data class PendingShareNavigationTarget( + val chatId: String, + val messageId: String?, + val scrollToLatest: Boolean, + val requestId: Long = System.nanoTime() +) + +enum class ChatListFilter { + All, + Unread, + Pinned, + Archive +} + +data class ChatsUiState( + val session: AuthSessionDto? = null, + val query: String = "", + val filter: ChatListFilter = ChatListFilter.All, + val chats: List<ChatListItemUiState> = emptyList(), + val stories: List<StoryDto> = emptyList(), + val isLoading: Boolean = false, + val errorMessage: String? = null, + val latestRelease: AndroidAppReleaseDto? = null, + val pendingShare: PendingExternalShare? = null, + val pendingShareNavigation: PendingShareNavigationTarget? = null, + val isSendingShare: Boolean = false, + val selectedChatIds: Set<String> = emptySet(), + val isDeleteDialogVisible: Boolean = false, + val deleteForEveryone: Boolean = false, + val showDeleteForEveryoneOption: Boolean = false, + val isDeletingSelected: Boolean = false +) { + val isShareMode: Boolean + get() = pendingShare != null + + val isSelectionMode: Boolean + get() = selectedChatIds.isNotEmpty() + + val selectedChatsCount: Int + get() = selectedChatIds.size +} + +class ChatsViewModel(private val container: AppContainer) : ViewModel() { + private val _uiState = MutableStateFlow(ChatsUiState(session = container.sessionStore.read())) + private val _allChats = mutableListOf<ChatSummaryDto>() + private var contactNamesByNormalizedPhone: Map<String, String> = emptyMap() + private var refreshJob: Job? = null + val uiState: StateFlow<ChatsUiState> = _uiState.asStateFlow() + + init { + attachIncomingShare() + attachRealtime() + loadCachedChats() + refresh() + refreshLatestRelease() + } + + fun updateQuery(value: String) { + _uiState.value = _uiState.value.copy(query = value) + applyFilter() + } + + fun updateFilter(value: ChatListFilter) { + _uiState.value = _uiState.value.copy(filter = value) + applyFilter() + } + + fun handleChatSelectionTap(chat: ChatSummaryDto) { + val selectedChatIds = _uiState.value.selectedChatIds + val nextSelectedChatIds = if (selectedChatIds.contains(chat.id)) { + selectedChatIds - chat.id + } else { + selectedChatIds + chat.id + } + + _uiState.value = _uiState.value.copy(selectedChatIds = nextSelectedChatIds) + applyFilter() + } + + fun handleChatLongPress(chat: ChatSummaryDto) { + if (_uiState.value.selectedChatIds.contains(chat.id)) { + return + } + + _uiState.value = _uiState.value.copy(selectedChatIds = _uiState.value.selectedChatIds + chat.id) + applyFilter() + } + + fun clearSelection() { + _uiState.value = _uiState.value.copy( + selectedChatIds = emptySet(), + isDeleteDialogVisible = false, + deleteForEveryone = false, + showDeleteForEveryoneOption = false, + isDeletingSelected = false + ) + applyFilter() + } + + fun showDeleteDialog() { + val state = _uiState.value + if (state.isShareMode || state.selectedChatIds.isEmpty() || state.isDeletingSelected) { + return + } + + val showDeleteForEveryoneOption = canDeleteSelectedForEveryone(state.selectedChatIds) + _uiState.value = state.copy( + isDeleteDialogVisible = true, + deleteForEveryone = state.deleteForEveryone && showDeleteForEveryoneOption, + showDeleteForEveryoneOption = showDeleteForEveryoneOption + ) + } + + fun dismissDeleteDialog() { + _uiState.value = _uiState.value.copy( + isDeleteDialogVisible = false, + deleteForEveryone = false + ) + } + + fun updateDeleteForEveryone(value: Boolean) { + if (!_uiState.value.showDeleteForEveryoneOption) { + return + } + + _uiState.value = _uiState.value.copy(deleteForEveryone = value) + } + + fun confirmDeleteSelected() { + val state = _uiState.value + if (state.isShareMode || state.selectedChatIds.isEmpty() || state.isDeletingSelected) { + return + } + + val selectedChats = _allChats.filter { state.selectedChatIds.contains(it.id) } + if (selectedChats.isEmpty()) { + clearSelection() + return + } + + val deleteForEveryone = state.deleteForEveryone && state.showDeleteForEveryoneOption + + viewModelScope.launch { + _uiState.value = _uiState.value.copy(isDeletingSelected = true, errorMessage = null) + runCatching { + container.executeAuthorized { session -> + selectedChats.forEach { chat -> + container.apiClient.deleteChat( + accessToken = session.accessToken, + chatId = chat.id, + forEveryone = deleteForEveryone + ) + } + } + }.onSuccess { + clearSelection() + refresh() + }.onFailure { error -> + _uiState.value = _uiState.value.copy(isDeletingSelected = false, errorMessage = error.message) + } + } + } + + fun togglePinSelectedChats() { + val selectedChats = _allChats.filter { _uiState.value.selectedChatIds.contains(it.id) } + if (selectedChats.isEmpty()) return + val shouldPin = selectedChats.any { !it.isPinned } + updateSelectedChatState(ChatStateRequest(isPinned = shouldPin)) + } + + fun archiveSelectedChats() { + val selectedChats = _allChats.filter { _uiState.value.selectedChatIds.contains(it.id) } + if (selectedChats.isEmpty()) return + val shouldArchive = selectedChats.any { !it.isArchived } + updateSelectedChatState(ChatStateRequest(isArchived = shouldArchive)) + } + + fun muteSelectedChats() { + val selectedChats = _allChats.filter { _uiState.value.selectedChatIds.contains(it.id) } + if (selectedChats.isEmpty()) return + val shouldMute = selectedChats.any { !it.isMuted } + updateSelectedChatState( + ChatStateRequest( + mutedUntil = if (shouldMute) { + OffsetDateTime.now(ZoneOffset.UTC).plusYears(1).format(DateTimeFormatter.ISO_OFFSET_DATE_TIME) + } else { + null + }, + isMuted = shouldMute + ) + ) + } + + private fun updateSelectedChatState(request: ChatStateRequest) { + val selectedChatIds = _uiState.value.selectedChatIds.toList() + if (selectedChatIds.isEmpty()) { + return + } + + viewModelScope.launch { + _uiState.value = _uiState.value.copy(errorMessage = null) + runCatching { + container.executeAuthorized { session -> + selectedChatIds.forEach { chatId -> + container.apiClient.updateChatState(session.accessToken, chatId, request) + } + } + }.onSuccess { + clearSelection() + refresh() + }.onFailure { error -> + _uiState.value = _uiState.value.copy(errorMessage = error.message) + } + } + } + + fun refresh() { + if (container.sessionStore.read() == null) { + _allChats.clear() + contactNamesByNormalizedPhone = emptyMap() + _uiState.value = _uiState.value.copy(session = null, chats = emptyList(), stories = emptyList(), selectedChatIds = emptySet()) + return + } + + viewModelScope.launch { + val currentSession = container.sessionStore.read() + _uiState.value = _uiState.value.copy(session = currentSession, isLoading = true, errorMessage = null) + runCatching { + container.executeAuthorized { session -> + container.realtimeService.connect(session.accessToken) + val chats = container.apiClient.getChats(session.accessToken) + val stories = runCatching { container.apiClient.getStories(session.accessToken) }.getOrDefault(emptyList()) + Triple(session, chats, stories) + } + }.onSuccess { result -> + updateContactNames(loadDeviceContactNames()) + _allChats.clear() + _allChats.addAll(result.second) + cacheChatList(result.first.user.id, result.second) + _uiState.value = _uiState.value.copy( + session = result.first, + stories = result.third.sortedByDescending { it.createdAt }, + isLoading = false, + errorMessage = null + ) + applyFilter() + }.onFailure { error -> + updateContactNames(loadDeviceContactNames()) + _uiState.value = _uiState.value.copy(isLoading = false, errorMessage = error.message) + applyFilter() + } + } + } + + fun logout() { + container.realtimeService.disconnect() + viewModelScope.launch { + runCatching { container.pushRegistrationManager.unregisterCurrentDeviceAsync() } + } + container.incomingShareStore.clear() + container.sessionStore.clear() + _allChats.clear() + contactNamesByNormalizedPhone = emptyMap() + _uiState.value = ChatsUiState() + } + + fun sendShareToChat(chat: ChatSummaryDto) { + if (_uiState.value.pendingShare == null) { + return + } + + sendShare(chatIds = listOf(chat.id), navigateToChatId = chat.id) + } + + fun sendShareToSelectedChats() { + val state = _uiState.value + if (state.pendingShare == null || state.selectedChatIds.isEmpty()) { + return + } + + sendShare(chatIds = state.selectedChatIds.toList(), navigateToChatId = null) + } + + fun dismissPendingShare() { + container.incomingShareStore.clear() + _uiState.value = _uiState.value.copy( + errorMessage = null, + isSendingShare = false, + selectedChatIds = emptySet() + ) + applyFilter() + } + + fun consumePendingShareNavigation() { + _uiState.value = _uiState.value.copy(pendingShareNavigation = null) + } + + private fun refreshLatestRelease() { + viewModelScope.launch { + runCatching { container.apiClient.getLatestAndroidRelease() } + .onSuccess { release -> _uiState.value = _uiState.value.copy(latestRelease = release) } + } + } + + private fun loadCachedChats() { + val session = container.sessionStore.read() ?: return + viewModelScope.launch { + val cachedContactNames = loadDeviceContactNames() + val cachedChats = withContext(Dispatchers.IO) { + runCatching { container.chatListCache.loadChats(session.user.id) }.getOrDefault(emptyList()) + } + if (cachedChats.isEmpty() || _allChats.isNotEmpty()) { + return@launch + } + + updateContactNames(cachedContactNames) + _allChats.clear() + _allChats.addAll(cachedChats) + _uiState.value = _uiState.value.copy(session = session) + applyFilter() + } + } + + private fun cacheChatList(ownerUserId: String, chats: List<ChatSummaryDto>) { + viewModelScope.launch(Dispatchers.IO) { + runCatching { container.chatListCache.replaceChats(ownerUserId, chats) } + } + } + + private fun updateContactNames(contactNames: Map<String, String>) { + if (contactNames.isNotEmpty() || contactNamesByNormalizedPhone.isEmpty()) { + contactNamesByNormalizedPhone = contactNames + } + } + + private fun applyFilter() { + val currentState = _uiState.value + val query = currentState.query.trim() + val existingChatIds = _allChats.mapTo(linkedSetOf(), ChatSummaryDto::id) + val selectedChatIds = currentState.selectedChatIds.intersect(existingChatIds) + val showDeleteForEveryoneOption = if (currentState.isShareMode) { + false + } else { + canDeleteSelectedForEveryone(selectedChatIds) + } + val deleteForEveryone = !currentState.isShareMode && + currentState.deleteForEveryone && + showDeleteForEveryoneOption + val chatItems = _allChats + .asSequence() + .filter { chat -> + when (currentState.filter) { + ChatListFilter.All -> !chat.isArchived + ChatListFilter.Unread -> chat.unreadCount > 0 && !chat.isArchived + ChatListFilter.Pinned -> chat.isPinned + ChatListFilter.Archive -> chat.isArchived + } + } + .sortedWith( + compareByDescending<ChatSummaryDto> { it.isPinned } + .thenByDescending { it.lastActivityAt.orEmpty() } + ) + .map { chat -> toChatListItem(chat, selectedChatIds) } + .toList() + val filtered = if (query.isBlank()) { + chatItems + } else { + chatItems.filter { item -> + item.resolvedTitle.contains(query, ignoreCase = true) || + item.chat.title.contains(query, ignoreCase = true) || + item.chat.secondaryText.orEmpty().contains(query, ignoreCase = true) || + item.chat.lastMessagePreview.orEmpty().contains(query, ignoreCase = true) + } + } + _uiState.value = currentState.copy( + chats = filtered, + selectedChatIds = selectedChatIds, + showDeleteForEveryoneOption = showDeleteForEveryoneOption, + deleteForEveryone = deleteForEveryone, + isDeleteDialogVisible = !currentState.isShareMode && + currentState.isDeleteDialogVisible && + selectedChatIds.isNotEmpty(), + isDeletingSelected = !currentState.isShareMode && + currentState.isDeletingSelected && + selectedChatIds.isNotEmpty() + ) + } + + private fun toChatListItem(chat: ChatSummaryDto, selectedChatIds: Set<String>): ChatListItemUiState { + val currentUserId = _uiState.value.session?.user?.id + return ChatListItemUiState( + chat = chat, + resolvedTitle = resolveChatTitle(chat, currentUserId), + lastActivityLabel = formatLastActivity(chat.lastActivityAt), + isSelected = selectedChatIds.contains(chat.id) + ) + } + + private fun resolveChatTitle(chat: ChatSummaryDto, currentUserId: String?): String { + if (chat.type != ChatType.Direct) { + return chat.title + } + + val peer = chat.participants.firstOrNull { it.id != currentUserId } ?: chat.participants.firstOrNull() + val normalizedPhone = PhoneNumberNormalizer.normalize(peer?.phoneNumber) + val contactName = normalizedPhone?.let(contactNamesByNormalizedPhone::get) + return contactName?.takeIf { it.isNotBlank() } + ?: peer?.displayName?.takeIf { it.isNotBlank() } + ?: chat.title + } + + private suspend fun loadDeviceContactNames(): Map<String, String> = withContext(Dispatchers.IO) { + if (!hasContactsPermission()) { + return@withContext emptyMap() + } + + runCatching { + container.contactDiscoveryService + .getContacts() + .associate { contact -> contact.normalizedPhoneNumber to contact.displayName } + }.getOrDefault(emptyMap()) + } + + private fun hasContactsPermission(): Boolean = + ContextCompat.checkSelfPermission( + container.appContext, + Manifest.permission.READ_CONTACTS + ) == PackageManager.PERMISSION_GRANTED + + private fun canDeleteSelectedForEveryone(selectedChatIds: Set<String>): Boolean { + if (selectedChatIds.size != 1) { + return false + } + + val chat = _allChats.firstOrNull { selectedChatIds.contains(it.id) } ?: return false + return chat.type == ChatType.Direct + } + + private fun attachRealtime() { + viewModelScope.launch { + container.realtimeService.chatCreated.collect { scheduleRefresh() } + } + viewModelScope.launch { + container.realtimeService.chatsChanged.collect { scheduleRefresh() } + } + viewModelScope.launch { + container.realtimeService.messagesCreated.collect { scheduleRefresh() } + } + viewModelScope.launch { + container.realtimeService.messagesUpdated.collect { scheduleRefresh() } + } + viewModelScope.launch { + container.realtimeService.appUpdateAvailable.collect { release -> + _uiState.value = _uiState.value.copy(latestRelease = release) + } + } + } + + private fun scheduleRefresh() { + refreshJob?.cancel() + refreshJob = viewModelScope.launch { + delay(400) + refresh() + } + } + + private fun attachIncomingShare() { + viewModelScope.launch { + container.incomingShareStore.pendingShare.collect { pendingShare -> + val currentState = _uiState.value + _uiState.value = currentState.copy( + pendingShare = pendingShare, + selectedChatIds = if (pendingShare == null) emptySet() else currentState.selectedChatIds, + isDeleteDialogVisible = if (pendingShare == null) currentState.isDeleteDialogVisible else false, + deleteForEveryone = if (pendingShare == null) currentState.deleteForEveryone else false, + showDeleteForEveryoneOption = if (pendingShare == null) currentState.showDeleteForEveryoneOption else false, + isDeletingSelected = if (pendingShare == null) currentState.isDeletingSelected else false, + isSendingShare = currentState.isSendingShare && pendingShare != null + ) + applyFilter() + } + } + } + + private fun sendShare(chatIds: List<String>, navigateToChatId: String?) { + val state = _uiState.value + val pendingShare = state.pendingShare ?: return + if (chatIds.isEmpty() || state.isSendingShare) { + return + } + + viewModelScope.launch { + _uiState.value = state.copy(isSendingShare = true, errorMessage = null) + runCatching { + container.executeAuthorized { session -> + var navigatedMessageId: String? = null + chatIds.forEach { chatId -> + val messageId = sendPendingShare( + accessToken = session.accessToken, + chatId = chatId, + pendingShare = pendingShare + ) + if (chatId == navigateToChatId) { + navigatedMessageId = messageId + } + } + navigatedMessageId + } + }.onSuccess { navigatedMessageId -> + _uiState.value = _uiState.value.copy( + isSendingShare = false, + errorMessage = null, + pendingShareNavigation = navigateToChatId?.let { chatId -> + PendingShareNavigationTarget( + chatId = chatId, + messageId = navigatedMessageId, + scrollToLatest = navigatedMessageId == null + ) + }, + selectedChatIds = emptySet() + ) + container.incomingShareStore.clear() + refresh() + }.onFailure { error -> + _uiState.value = _uiState.value.copy( + isSendingShare = false, + errorMessage = error.message + ) + } + } + } + + private suspend fun sendPendingShare( + accessToken: String, + chatId: String, + pendingShare: PendingExternalShare + ): String? { + val sharedText = pendingShare.text?.trim().orEmpty() + if (pendingShare.attachments.isEmpty()) { + check(sharedText.isNotBlank()) { "Нечего отправлять." } + return container.apiClient.sendMessage(chatId, sharedText, accessToken).id + } + + container.transferQueueRepository.enqueueUpload( + chatId = chatId, + text = sharedText.takeIf { it.isNotBlank() }, + replyToMessageId = null, + files = pendingShare.attachments + ) + return null + } +} diff --git a/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/contacts/ContactsScreen.kt b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/contacts/ContactsScreen.kt new file mode 100644 index 00000000..5e4233e5 --- /dev/null +++ b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/contacts/ContactsScreen.kt @@ -0,0 +1,251 @@ +package com.seven.ikar.kotlin.feature.contacts + +import android.Manifest +import android.content.pm.PackageManager +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.WindowInsetsSides +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.only +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.safeDrawing +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Add +import androidx.compose.material.icons.outlined.Menu +import androidx.compose.material.icons.outlined.PersonAddAlt1 +import androidx.compose.material.icons.outlined.PhoneInTalk +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.core.content.ContextCompat +import com.seven.ikar.kotlin.core.contacts.DiscoverUserItem +import com.seven.ikar.kotlin.ui.AvatarView +import com.seven.ikar.kotlin.ui.IkarFab +import com.seven.ikar.kotlin.ui.IkarFloatingMainMenu +import com.seven.ikar.kotlin.ui.IkarMainMenuTab +import com.seven.ikar.kotlin.ui.IkarRowDivider +import com.seven.ikar.kotlin.ui.IkarScreenSurface +import com.seven.ikar.kotlin.ui.IkarSearchCard +import com.seven.ikar.kotlin.ui.IkarSectionCard +import com.seven.ikar.kotlin.ui.formatDiscoverPresence +import com.seven.ikar.kotlin.ui.formatPresenceColor +import com.seven.ikar.kotlin.ui.theme.TelegramBlue +import com.seven.ikar.kotlin.ui.theme.TelegramInkMuted + +@Composable +fun ContactsScreen( + state: ContactsUiState, + items: List<DiscoverUserItem>, + onQueryChanged: (String) -> Unit, + onContactClick: (DiscoverUserItem) -> Unit, + onRefresh: () -> Unit, + onChatsClick: () -> Unit, + onContactsClick: () -> Unit, + onSettingsClick: () -> Unit, + onProfileClick: () -> Unit, + onOpenNewMessageClick: () -> Unit +) { + val context = LocalContext.current + val permissionLauncher = rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { granted -> + if (granted) { + onRefresh() + } + } + val hasContactsPermission = + ContextCompat.checkSelfPermission(context, Manifest.permission.READ_CONTACTS) == PackageManager.PERMISSION_GRANTED + + LaunchedEffect(hasContactsPermission) { + if (hasContactsPermission) { + onRefresh() + } + } + + IkarScreenSurface(modifier = Modifier.fillMaxSize()) { + Column( + modifier = Modifier + .fillMaxSize() + .windowInsetsPadding(WindowInsets.safeDrawing.only(WindowInsetsSides.Vertical)) + ) { + Column( + modifier = Modifier.padding(start = 18.dp, top = 16.dp, end = 18.dp, bottom = 12.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text("Контакты", style = MaterialTheme.typography.headlineLarge) + Icon(Icons.Outlined.Menu, contentDescription = null, tint = MaterialTheme.colorScheme.onSurface) + } + + if (!hasContactsPermission) { + IkarSectionCard { + Text("Нужен доступ к контактам", style = MaterialTheme.typography.headlineMedium) + Text( + "Разрешите доступ к телефонной книге, чтобы видеть знакомых из контактов.", + style = MaterialTheme.typography.bodyMedium, + color = TelegramInkMuted + ) + androidx.compose.foundation.layout.Spacer(modifier = Modifier.padding(top = 8.dp)) + androidx.compose.material3.Button(onClick = { permissionLauncher.launch(Manifest.permission.READ_CONTACTS) }) { + Text("Разрешить доступ") + } + } + } + + IkarSearchCard( + value = state.query, + onValueChange = onQueryChanged, + placeholder = "Поиск контактов" + ) + + IkarSectionCard(padding = androidx.compose.foundation.layout.PaddingValues(0.dp)) { + ContactsActionRow( + icon = Icons.Outlined.PersonAddAlt1, + title = "Пригласить друзей", + subtitle = "Поделиться ссылкой на Икар." + ) + IkarRowDivider(startIndent = 56.dp) + ContactsActionRow( + icon = Icons.Outlined.PhoneInTalk, + title = "Недавние звонки", + subtitle = "История звонков внутри Икар." + ) + } + + state.errorMessage?.let { + Text(it, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodyMedium) + } + } + + Box(modifier = Modifier.weight(1f).fillMaxWidth()) { + IkarSectionCard( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 18.dp), + padding = androidx.compose.foundation.layout.PaddingValues(0.dp) + ) { + Text( + "Сортировка по времени захода", + modifier = Modifier.padding(start = 18.dp, top = 16.dp, end = 18.dp, bottom = 8.dp), + style = MaterialTheme.typography.labelLarge, + color = TelegramBlue + ) + + LazyColumn { + if (items.isEmpty()) { + item { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 24.dp, vertical = 28.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Text("Контакты не найдены", style = MaterialTheme.typography.headlineMedium) + Text( + "Разрешите доступ к контактам и убедитесь, что нужные люди зарегистрированы по номеру телефона.", + color = TelegramInkMuted, + style = MaterialTheme.typography.bodyMedium + ) + } + } + } else { + items(items, key = { it.id }) { item -> + ContactsUserRow(item = item, onClick = { onContactClick(item) }) + } + } + item { Box(modifier = Modifier.padding(bottom = 132.dp)) } + } + } + + IkarFab( + icon = Icons.Outlined.Add, + contentDescription = "Новое сообщение", + modifier = Modifier + .align(Alignment.BottomEnd) + .padding(end = 20.dp, bottom = 126.dp), + onClick = onOpenNewMessageClick + ) + } + } + + IkarFloatingMainMenu( + selectedTab = IkarMainMenuTab.Contacts, + modifier = Modifier + .align(Alignment.BottomCenter) + .padding(horizontal = 8.dp, vertical = 18.dp), + onChatsClick = onChatsClick, + onContactsClick = onContactsClick, + onSettingsClick = onSettingsClick, + onProfileClick = onProfileClick + ) + } +} + +@Composable +private fun ContactsActionRow( + icon: androidx.compose.ui.graphics.vector.ImageVector, + title: String, + subtitle: String +) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 18.dp, vertical = 16.dp), + horizontalArrangement = Arrangement.spacedBy(14.dp) + ) { + Icon(icon, contentDescription = null, tint = TelegramBlue, modifier = Modifier.size(24.dp)) + Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text(title, style = MaterialTheme.typography.headlineMedium) + Text(subtitle, style = MaterialTheme.typography.bodySmall, color = TelegramInkMuted) + } + } +} + +@Composable +private fun ContactsUserRow( + item: DiscoverUserItem, + onClick: () -> Unit +) { + Column { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onClick) + .padding(horizontal = 18.dp, vertical = 12.dp), + horizontalArrangement = Arrangement.spacedBy(14.dp), + verticalAlignment = Alignment.CenterVertically + ) { + AvatarView(imageUrl = item.avatarUrl, title = item.displayName, size = 54.dp) + Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(3.dp)) { + Text(item.displayName, style = MaterialTheme.typography.titleLarge, maxLines = 1, overflow = TextOverflow.Ellipsis) + Text( + formatDiscoverPresence(item), + style = MaterialTheme.typography.bodySmall, + color = formatPresenceColor(item) + ) + } + } + IkarRowDivider(startIndent = 86.dp) + } +} diff --git a/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/contacts/ContactsViewModel.kt b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/contacts/ContactsViewModel.kt new file mode 100644 index 00000000..f0f0ed52 --- /dev/null +++ b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/contacts/ContactsViewModel.kt @@ -0,0 +1,71 @@ +package com.seven.ikar.kotlin.feature.contacts + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.seven.ikar.kotlin.app.AppContainer +import com.seven.ikar.kotlin.core.contacts.DiscoverUserItem +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch + +data class ContactsUiState( + val query: String = "", + val items: List<DiscoverUserItem> = emptyList(), + val isLoading: Boolean = false, + val errorMessage: String? = null, + val pendingChatId: String? = null +) + +class ContactsViewModel(private val container: AppContainer) : ViewModel() { + private val _uiState = MutableStateFlow(ContactsUiState()) + val uiState: StateFlow<ContactsUiState> = _uiState.asStateFlow() + + fun updateQuery(value: String) { + _uiState.value = _uiState.value.copy(query = value) + } + + fun refresh() { + viewModelScope.launch { + _uiState.value = _uiState.value.copy(isLoading = true, errorMessage = null) + runCatching { container.resolvedContactsRepository.loadResolvedContacts() } + .onSuccess { items -> + _uiState.value = _uiState.value.copy(items = items, isLoading = false, errorMessage = null) + } + .onFailure { error -> + _uiState.value = _uiState.value.copy(isLoading = false, errorMessage = error.message) + } + } + } + + fun startDirectChat(user: DiscoverUserItem) { + viewModelScope.launch { + runCatching { + container.executeAuthorized { session -> + container.apiClient.createDirectChat(session.accessToken, user.id) + } + }.onSuccess { chat -> + _uiState.value = _uiState.value.copy(pendingChatId = chat.id) + }.onFailure { error -> + _uiState.value = _uiState.value.copy(errorMessage = error.message) + } + } + } + + fun consumePendingChatNavigation() { + _uiState.value = _uiState.value.copy(pendingChatId = null) + } + + fun filteredItems(): List<DiscoverUserItem> { + val query = _uiState.value.query.trim() + if (query.isBlank()) { + return _uiState.value.items + } + + return _uiState.value.items.filter { + it.displayName.contains(query, ignoreCase = true) || + it.secondaryLine.contains(query, ignoreCase = true) || + it.normalizedPhoneNumber.contains(query, ignoreCase = true) + } + } +} diff --git a/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/devices/DevicesScreen.kt b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/devices/DevicesScreen.kt new file mode 100644 index 00000000..bb034951 --- /dev/null +++ b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/devices/DevicesScreen.kt @@ -0,0 +1,99 @@ +package com.seven.ikar.kotlin.feature.devices + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.WindowInsetsSides +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.only +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.safeDrawing +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import com.seven.ikar.kotlin.core.model.UserSessionDto +import com.seven.ikar.kotlin.ui.IkarRowDivider +import com.seven.ikar.kotlin.ui.IkarScreenSurface +import com.seven.ikar.kotlin.ui.IkarSectionCard +import com.seven.ikar.kotlin.ui.IkarSectionTitle +import com.seven.ikar.kotlin.ui.IkarSimpleHeader +import com.seven.ikar.kotlin.ui.theme.TelegramBlue +import com.seven.ikar.kotlin.ui.theme.TelegramDanger +import com.seven.ikar.kotlin.ui.theme.TelegramInkMuted + +@Composable +fun DevicesScreen( + state: DevicesUiState, + onBackClick: () -> Unit, + onRefreshClick: () -> Unit, + onRevokeClick: (UserSessionDto) -> Unit +) { + IkarScreenSurface(modifier = Modifier.fillMaxSize()) { + Column( + modifier = Modifier + .fillMaxSize() + .windowInsetsPadding(WindowInsets.safeDrawing.only(WindowInsetsSides.Vertical)) + .verticalScroll(rememberScrollState()) + .padding(horizontal = 18.dp, vertical = 16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + IkarSimpleHeader(title = "Устройства", onBackClick = onBackClick) + state.errorMessage?.let { + Text(it, color = TelegramDanger, style = MaterialTheme.typography.bodySmall) + } + if (state.isLoading && state.sessions.isEmpty()) { + CircularProgressIndicator(color = TelegramBlue) + } + IkarSectionCard { + IkarSectionTitle("Активные сеансы", modifier = Modifier.padding(bottom = 8.dp)) + state.sessions.forEachIndexed { index, session -> + DeviceRow(session = session, enabled = !state.isSaving, onRevokeClick = { onRevokeClick(session) }) + if (index != state.sessions.lastIndex) { + IkarRowDivider() + } + } + } + TextButton(onClick = onRefreshClick) { Text("Обновить") } + } + } +} + +@Composable +private fun DeviceRow( + session: UserSessionDto, + enabled: Boolean, + onRevokeClick: () -> Unit +) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 12.dp), + horizontalArrangement = Arrangement.spacedBy(10.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Column(modifier = Modifier.weight(1f)) { + Text(session.deviceName, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold) + Text( + text = "Последняя активность: ${session.lastSeenAt}", + style = MaterialTheme.typography.bodySmall, + color = TelegramInkMuted + ) + } + if (session.isCurrent) { + Text("Текущий", color = TelegramBlue) + } else { + TextButton(enabled = enabled, onClick = onRevokeClick) { Text("Завершить") } + } + } +} diff --git a/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/devices/DevicesViewModel.kt b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/devices/DevicesViewModel.kt new file mode 100644 index 00000000..ab90ee44 --- /dev/null +++ b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/devices/DevicesViewModel.kt @@ -0,0 +1,58 @@ +package com.seven.ikar.kotlin.feature.devices + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.seven.ikar.kotlin.app.AppContainer +import com.seven.ikar.kotlin.core.model.UserSessionDto +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch + +data class DevicesUiState( + val sessions: List<UserSessionDto> = emptyList(), + val isLoading: Boolean = false, + val isSaving: Boolean = false, + val errorMessage: String? = null +) + +class DevicesViewModel(private val container: AppContainer) : ViewModel() { + private val _uiState = MutableStateFlow(DevicesUiState()) + val uiState: StateFlow<DevicesUiState> = _uiState.asStateFlow() + + init { + refresh() + } + + fun refresh() { + viewModelScope.launch { + _uiState.value = _uiState.value.copy(isLoading = true, errorMessage = null) + runCatching { + container.executeAuthorized { session -> + container.apiClient.getMySessions(session.accessToken) + } + }.onSuccess { sessions -> + _uiState.value = _uiState.value.copy(sessions = sessions, isLoading = false) + }.onFailure { error -> + _uiState.value = _uiState.value.copy(isLoading = false, errorMessage = error.message) + } + } + } + + fun revoke(session: UserSessionDto) { + if (session.isCurrent || _uiState.value.isSaving) return + viewModelScope.launch { + _uiState.value = _uiState.value.copy(isSaving = true, errorMessage = null) + runCatching { + container.executeAuthorized { auth -> + container.apiClient.revokeMySession(auth.accessToken, session.id) + } + }.onSuccess { + _uiState.value = _uiState.value.copy(isSaving = false) + refresh() + }.onFailure { error -> + _uiState.value = _uiState.value.copy(isSaving = false, errorMessage = error.message) + } + } + } +} diff --git a/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/discover/DiscoverScreen.kt b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/discover/DiscoverScreen.kt new file mode 100644 index 00000000..db96bb58 --- /dev/null +++ b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/discover/DiscoverScreen.kt @@ -0,0 +1,560 @@ +package com.seven.ikar.kotlin.feature.discover + +import android.Manifest +import android.content.pm.PackageManager +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.outlined.ArrowBack +import androidx.compose.material.icons.outlined.Group +import androidx.compose.material.icons.outlined.Menu +import androidx.compose.material.icons.outlined.Speaker +import androidx.compose.material3.Button +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.RadioButton +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.core.content.ContextCompat +import com.seven.ikar.kotlin.core.contacts.DiscoverUserItem +import com.seven.ikar.kotlin.ui.AvatarView +import com.seven.ikar.kotlin.ui.IkarCenteredHeader +import com.seven.ikar.kotlin.ui.IkarHeaderIconBubble +import com.seven.ikar.kotlin.ui.IkarRowDivider +import com.seven.ikar.kotlin.ui.IkarScreenSurface +import com.seven.ikar.kotlin.ui.IkarSearchCard +import com.seven.ikar.kotlin.ui.IkarSectionCard +import com.seven.ikar.kotlin.ui.IkarToggleRow +import com.seven.ikar.kotlin.ui.formatDiscoverPresence +import com.seven.ikar.kotlin.ui.formatPresenceColor +import com.seven.ikar.kotlin.ui.theme.TelegramBlue +import com.seven.ikar.kotlin.ui.theme.TelegramBluePanel +import com.seven.ikar.kotlin.ui.theme.TelegramInkMuted + +@Composable +fun DiscoverScreen( + state: DiscoverUiState, + onBackClick: () -> Unit, + onQueryChanged: (String) -> Unit, + onRefreshClick: () -> Unit, + onUserClick: (DiscoverUserItem) -> Unit, + onBeginGroupSelection: () -> Unit, + onBeginChannelSelection: () -> Unit, + onCancelSelection: () -> Unit, + onChannelBackClick: () -> Unit, + onChannelTitleChanged: (String) -> Unit, + onChannelDescriptionChanged: (String) -> Unit, + onChannelVisibilityChanged: (ChannelVisibility) -> Unit, + onChannelPublicUsernameChanged: (String) -> Unit, + onChannelSignaturesChanged: (Boolean) -> Unit, + onContinueChannelWizard: () -> Unit, + onCompleteSelection: () -> Unit +) { + val context = LocalContext.current + val permissionLauncher = rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { granted -> + if (granted) { + onRefreshClick() + } + } + val hasContactsPermission = + ContextCompat.checkSelfPermission(context, Manifest.permission.READ_CONTACTS) == PackageManager.PERMISSION_GRANTED + val isChannelWizard = state.channelWizardStep != null + val showContacts = state.selectionMode != null || state.channelWizardStep == null + + LaunchedEffect(hasContactsPermission) { + if (hasContactsPermission) { + onRefreshClick() + } + } + + IkarScreenSurface(modifier = Modifier.fillMaxSize()) { + Column(modifier = Modifier.fillMaxSize()) { + Column( + modifier = Modifier.padding(start = 18.dp, top = 16.dp, end = 18.dp, bottom = 12.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + IkarCenteredHeader( + title = when { + isChannelWizard -> "Новый канал" + state.selectionMode == DiscoverSelectionMode.Group -> "Новая группа" + else -> "Новое сообщение" + }, + left = { + IkarHeaderIconBubble( + onClick = if (isChannelWizard) onChannelBackClick else onBackClick + ) { + Icon(Icons.AutoMirrored.Outlined.ArrowBack, contentDescription = "Назад", tint = MaterialTheme.colorScheme.onSurface) + } + }, + right = { + if (!isChannelWizard && state.selectionMode == null) { + IkarHeaderIconBubble { + Icon(Icons.Outlined.Menu, contentDescription = null, tint = MaterialTheme.colorScheme.onSurface) + } + } + } + ) + + if (!hasContactsPermission && showContacts) { + IkarPermissionCard(onGrantClick = { permissionLauncher.launch(Manifest.permission.READ_CONTACTS) }) + } + + if (showContacts) { + IkarSearchCard( + value = state.query, + onValueChange = onQueryChanged, + placeholder = if (state.selectionMode == DiscoverSelectionMode.Channel) "Поиск подписчиков" else "Поиск контактов" + ) + } + + when (state.channelWizardStep) { + ChannelWizardStep.Details -> ChannelDetailsStep( + state = state, + onTitleChanged = onChannelTitleChanged, + onDescriptionChanged = onChannelDescriptionChanged, + onCancelClick = onCancelSelection, + onNextClick = onContinueChannelWizard + ) + + ChannelWizardStep.Type -> ChannelTypeStep( + state = state, + onVisibilityChanged = onChannelVisibilityChanged, + onPublicUsernameChanged = onChannelPublicUsernameChanged, + onSignaturesChanged = onChannelSignaturesChanged, + onBackClick = onChannelBackClick, + onNextClick = onContinueChannelWizard + ) + + ChannelWizardStep.Subscribers -> SelectionBanner( + title = "Добавить подписчиков", + subtitle = "Можно создать канал сейчас и пригласить людей позже.", + selectedCount = state.selectedUserIds.size, + actionText = if (state.isLoading) "Создание..." else "Создать", + onBackClick = onChannelBackClick, + onActionClick = onCompleteSelection, + enabled = !state.isLoading + ) + + null -> { + if (state.selectionMode == null) { + IkarSectionCard(padding = PaddingValues(0.dp)) { + DiscoverActionRow( + icon = Icons.Outlined.Group, + title = "Создать группу", + subtitle = "Выбрать участников и открыть общий чат.", + onClick = onBeginGroupSelection + ) + IkarRowDivider(startIndent = 56.dp) + DiscoverActionRow( + icon = Icons.Outlined.Speaker, + title = "Создать канал", + subtitle = "Открыть канал и пригласить подписчиков.", + onClick = onBeginChannelSelection + ) + } + } else { + SelectionBanner( + title = "Новая группа", + subtitle = "Выберите участников", + selectedCount = state.selectedUserIds.size, + actionText = if (state.isLoading) "Создание..." else "Создать", + onBackClick = onCancelSelection, + onActionClick = onCompleteSelection, + enabled = !state.isLoading + ) + } + } + } + + state.errorMessage?.let { + Text(it, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodyMedium) + } + } + + if (showContacts) { + ContactsSection( + modifier = Modifier.weight(1f), + state = state, + onUserClick = onUserClick + ) + } + } + } +} + +@Composable +private fun ChannelDetailsStep( + state: DiscoverUiState, + onTitleChanged: (String) -> Unit, + onDescriptionChanged: (String) -> Unit, + onCancelClick: () -> Unit, + onNextClick: () -> Unit +) { + IkarSectionCard { + WizardStepIndicator(currentStep = 1) + Row( + horizontalArrangement = Arrangement.spacedBy(14.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Surface( + modifier = Modifier.size(64.dp), + color = TelegramBluePanel, + shape = CircleShape + ) { + Box(contentAlignment = Alignment.Center) { + Icon(Icons.Outlined.Speaker, contentDescription = null, tint = TelegramBlue, modifier = Modifier.size(30.dp)) + } + } + Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(10.dp)) { + OutlinedTextField( + value = state.channelTitle, + onValueChange = onTitleChanged, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + label = { Text("Название канала") } + ) + OutlinedTextField( + value = state.channelDescription, + onValueChange = onDescriptionChanged, + modifier = Modifier.fillMaxWidth(), + minLines = 3, + maxLines = 4, + label = { Text("Описание") } + ) + } + } + StepActions( + backText = "Отмена", + nextText = "Далее", + onBackClick = onCancelClick, + onNextClick = onNextClick, + nextEnabled = state.channelTitle.isNotBlank() + ) + } +} + +@Composable +private fun ChannelTypeStep( + state: DiscoverUiState, + onVisibilityChanged: (ChannelVisibility) -> Unit, + onPublicUsernameChanged: (String) -> Unit, + onSignaturesChanged: (Boolean) -> Unit, + onBackClick: () -> Unit, + onNextClick: () -> Unit +) { + val isPublic = state.channelVisibility == ChannelVisibility.Public + IkarSectionCard { + WizardStepIndicator(currentStep = 2) + ChannelVisibilityRow( + title = "Публичный канал", + subtitle = "Канал будет виден в поиске и доступен по короткой ссылке.", + selected = isPublic, + onClick = { onVisibilityChanged(ChannelVisibility.Public) } + ) + IkarRowDivider(startIndent = 0.dp) + ChannelVisibilityRow( + title = "Приватный канал", + subtitle = "Войти можно будет по приглашению или invite-ссылке.", + selected = state.channelVisibility == ChannelVisibility.Private, + onClick = { onVisibilityChanged(ChannelVisibility.Private) } + ) + if (isPublic) { + OutlinedTextField( + value = state.channelPublicUsername, + onValueChange = onPublicUsernameChanged, + modifier = Modifier + .fillMaxWidth() + .padding(top = 10.dp), + singleLine = true, + prefix = { Text("@") }, + label = { Text("Публичная ссылка") }, + supportingText = { Text("Минимум 5 символов: латиница, цифры и _") } + ) + } + IkarToggleRow( + title = "Подписи администраторов", + subtitle = "Имя автора будет показано рядом с публикацией.", + checked = state.channelSignaturesEnabled, + onCheckedChange = onSignaturesChanged, + leading = { + Icon(Icons.Outlined.Speaker, contentDescription = null, tint = TelegramInkMuted) + } + ) + StepActions( + backText = "Назад", + nextText = "Далее", + onBackClick = onBackClick, + onNextClick = onNextClick, + nextEnabled = !isPublic || state.channelPublicUsername.length >= 5 + ) + } +} + +@Composable +private fun ChannelVisibilityRow( + title: String, + subtitle: String, + selected: Boolean, + onClick: () -> Unit +) { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onClick) + .padding(vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + RadioButton(selected = selected, onClick = onClick) + Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text(title, style = MaterialTheme.typography.titleLarge) + Text(subtitle, style = MaterialTheme.typography.bodySmall, color = TelegramInkMuted) + } + } +} + +@Composable +private fun SelectionBanner( + title: String, + subtitle: String, + selectedCount: Int, + actionText: String, + onBackClick: () -> Unit, + onActionClick: () -> Unit, + enabled: Boolean +) { + Surface( + modifier = Modifier.fillMaxWidth(), + color = TelegramBluePanel, + shape = RoundedCornerShape(20.dp) + ) { + Row( + modifier = Modifier.padding(horizontal = 14.dp, vertical = 12.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + Icons.AutoMirrored.Outlined.ArrowBack, + contentDescription = "Назад", + tint = TelegramBlue, + modifier = Modifier.clickable(enabled = enabled, onClick = onBackClick) + ) + Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text(title, style = MaterialTheme.typography.titleMedium, color = TelegramBlue) + Text(subtitle, style = MaterialTheme.typography.bodySmall, color = TelegramInkMuted) + } + Text(selectedCount.toString(), style = MaterialTheme.typography.titleMedium) + Button(onClick = onActionClick, enabled = enabled) { + Text(actionText) + } + } + } +} + +@Composable +private fun StepActions( + backText: String, + nextText: String, + onBackClick: () -> Unit, + onNextClick: () -> Unit, + nextEnabled: Boolean +) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.End, + verticalAlignment = Alignment.CenterVertically + ) { + TextButton(onClick = onBackClick) { + Text(backText) + } + Button(onClick = onNextClick, enabled = nextEnabled) { + Text(nextText) + } + } +} + +@Composable +private fun WizardStepIndicator(currentStep: Int) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + (1..3).forEach { step -> + Surface( + modifier = Modifier + .height(4.dp) + .weight(1f), + color = if (step <= currentStep) TelegramBlue else TelegramBluePanel, + shape = RoundedCornerShape(2.dp) + ) {} + } + Text( + "$currentStep/3", + style = MaterialTheme.typography.labelMedium, + color = TelegramInkMuted, + fontWeight = FontWeight.SemiBold + ) + } +} + +@Composable +private fun ContactsSection( + modifier: Modifier = Modifier, + state: DiscoverUiState, + onUserClick: (DiscoverUserItem) -> Unit +) { + IkarSectionCard( + modifier = modifier + .fillMaxWidth() + .padding(horizontal = 18.dp) + .padding(bottom = 24.dp), + padding = PaddingValues(0.dp) + ) { + Text( + "Сортировка по времени захода", + modifier = Modifier.padding(start = 18.dp, top = 16.dp, end = 18.dp, bottom = 8.dp), + style = MaterialTheme.typography.labelLarge, + color = TelegramBlue + ) + + LazyColumn { + if (state.items.isEmpty()) { + item { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 24.dp, vertical = 28.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Text("Контакты не найдены", style = MaterialTheme.typography.headlineMedium) + Text( + "Разрешите доступ к контактам и убедитесь, что нужные люди зарегистрированы по номеру телефона.", + color = TelegramInkMuted, + style = MaterialTheme.typography.bodyMedium + ) + } + } + } else { + items(state.items, key = { it.id }) { item -> + DiscoverUserRow( + item = item, + isSelected = state.selectedUserIds.contains(item.id), + onClick = { onUserClick(item) } + ) + } + } + } + } +} + +@Composable +private fun IkarPermissionCard(onGrantClick: () -> Unit) { + IkarSectionCard { + Text("Нужен доступ к контактам", style = MaterialTheme.typography.headlineMedium) + Text( + "Без него не получится показать знакомых из телефонной книги.", + style = MaterialTheme.typography.bodyMedium, + color = TelegramInkMuted + ) + Spacer(modifier = Modifier.padding(top = 8.dp)) + Button(onClick = onGrantClick) { + Text("Разрешить доступ") + } + } +} + +@Composable +private fun DiscoverActionRow( + icon: androidx.compose.ui.graphics.vector.ImageVector, + title: String, + subtitle: String, + onClick: () -> Unit +) { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onClick) + .padding(horizontal = 18.dp, vertical = 16.dp), + horizontalArrangement = Arrangement.spacedBy(14.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon(icon, contentDescription = null, tint = TelegramBlue, modifier = Modifier.size(24.dp)) + Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text(title, style = MaterialTheme.typography.headlineMedium) + Text(subtitle, style = MaterialTheme.typography.bodySmall, color = TelegramInkMuted) + } + } +} + +@Composable +private fun DiscoverUserRow( + item: DiscoverUserItem, + isSelected: Boolean, + onClick: () -> Unit +) { + Column { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onClick) + .padding(horizontal = 18.dp, vertical = 12.dp), + horizontalArrangement = Arrangement.spacedBy(14.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Box { + AvatarView(imageUrl = item.avatarUrl, title = item.displayName, size = 54.dp) + if (isSelected) { + Surface( + modifier = Modifier + .align(Alignment.BottomEnd) + .size(20.dp), + color = TelegramBlue, + shape = CircleShape + ) { + Box(contentAlignment = Alignment.Center) { + Text("✓", color = Color.White, style = MaterialTheme.typography.labelSmall) + } + } + } + } + Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(3.dp)) { + Text(item.displayName, style = MaterialTheme.typography.titleLarge, maxLines = 1, overflow = TextOverflow.Ellipsis) + Text( + formatDiscoverPresence(item), + style = MaterialTheme.typography.bodySmall, + color = formatPresenceColor(item) + ) + } + } + IkarRowDivider(startIndent = 86.dp) + } +} diff --git a/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/discover/DiscoverViewModel.kt b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/discover/DiscoverViewModel.kt new file mode 100644 index 00000000..84288810 --- /dev/null +++ b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/discover/DiscoverViewModel.kt @@ -0,0 +1,261 @@ +package com.seven.ikar.kotlin.feature.discover + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.seven.ikar.kotlin.app.AppContainer +import com.seven.ikar.kotlin.core.contacts.DiscoverUserItem +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import java.util.Locale + +enum class DiscoverSelectionMode { + Group, + Channel +} + +enum class ChannelWizardStep { + Details, + Type, + Subscribers +} + +enum class ChannelVisibility { + Public, + Private +} + +data class DiscoverUiState( + val query: String = "", + val items: List<DiscoverUserItem> = emptyList(), + val isLoading: Boolean = false, + val errorMessage: String? = null, + val pendingChatId: String? = null, + val selectionMode: DiscoverSelectionMode? = null, + val selectedUserIds: Set<String> = emptySet(), + val channelWizardStep: ChannelWizardStep? = null, + val channelTitle: String = "", + val channelDescription: String = "", + val channelVisibility: ChannelVisibility = ChannelVisibility.Private, + val channelPublicUsername: String = "", + val channelSignaturesEnabled: Boolean = false +) + +class DiscoverViewModel(private val container: AppContainer) : ViewModel() { + private val _uiState = MutableStateFlow(DiscoverUiState()) + val uiState: StateFlow<DiscoverUiState> = _uiState.asStateFlow() + + private var searchJob: Job? = null + + init { + refresh() + } + + fun updateQuery(value: String) { + _uiState.value = _uiState.value.copy(query = value) + searchJob?.cancel() + searchJob = viewModelScope.launch { + delay(250) + refresh() + } + } + + fun refresh() { + viewModelScope.launch { + val query = _uiState.value.query + _uiState.value = _uiState.value.copy(isLoading = true, errorMessage = null) + runCatching { container.resolvedContactsRepository.loadMergedCandidates(query) } + .onSuccess { items -> + _uiState.value = _uiState.value.copy(items = items, isLoading = false, errorMessage = null) + } + .onFailure { error -> + _uiState.value = _uiState.value.copy(isLoading = false, errorMessage = error.message) + } + } + } + + fun beginGroupSelection() { + _uiState.value = _uiState.value.copy( + selectionMode = DiscoverSelectionMode.Group, + selectedUserIds = emptySet(), + channelWizardStep = null, + errorMessage = null + ) + } + + fun beginChannelSelection() { + _uiState.value = _uiState.value.copy( + selectionMode = null, + selectedUserIds = emptySet(), + channelWizardStep = ChannelWizardStep.Details, + channelTitle = "", + channelDescription = "", + channelVisibility = ChannelVisibility.Private, + channelPublicUsername = "", + channelSignaturesEnabled = false, + errorMessage = null + ) + } + + fun cancelSelection() { + _uiState.value = _uiState.value.copy( + selectionMode = null, + selectedUserIds = emptySet(), + channelWizardStep = null, + errorMessage = null + ) + } + + fun previousChannelWizardStep() { + val state = _uiState.value + when (state.channelWizardStep) { + ChannelWizardStep.Details, null -> cancelSelection() + ChannelWizardStep.Type -> _uiState.value = state.copy(channelWizardStep = ChannelWizardStep.Details, errorMessage = null) + ChannelWizardStep.Subscribers -> _uiState.value = state.copy( + channelWizardStep = ChannelWizardStep.Type, + selectionMode = null, + selectedUserIds = emptySet(), + errorMessage = null + ) + } + } + + fun updateChannelTitle(value: String) { + _uiState.value = _uiState.value.copy(channelTitle = value.take(80), errorMessage = null) + } + + fun updateChannelDescription(value: String) { + _uiState.value = _uiState.value.copy(channelDescription = value.take(255), errorMessage = null) + } + + fun updateChannelVisibility(value: ChannelVisibility) { + _uiState.value = _uiState.value.copy(channelVisibility = value, errorMessage = null) + } + + fun updateChannelPublicUsername(value: String) { + val normalized = normalizePublicUsernameInput(value) + _uiState.value = _uiState.value.copy(channelPublicUsername = normalized, errorMessage = null) + } + + fun updateChannelSignaturesEnabled(value: Boolean) { + _uiState.value = _uiState.value.copy(channelSignaturesEnabled = value, errorMessage = null) + } + + fun continueChannelWizard() { + val state = _uiState.value + when (state.channelWizardStep) { + ChannelWizardStep.Details -> { + if (state.channelTitle.isBlank()) { + _uiState.value = state.copy(errorMessage = "Введите название канала.") + return + } + _uiState.value = state.copy(channelWizardStep = ChannelWizardStep.Type, errorMessage = null) + } + + ChannelWizardStep.Type -> { + if (state.channelVisibility == ChannelVisibility.Public && state.channelPublicUsername.length < 5) { + _uiState.value = state.copy(errorMessage = "Публичная ссылка должна содержать минимум 5 символов.") + return + } + _uiState.value = state.copy( + channelWizardStep = ChannelWizardStep.Subscribers, + selectionMode = DiscoverSelectionMode.Channel, + selectedUserIds = emptySet(), + errorMessage = null + ) + } + + ChannelWizardStep.Subscribers -> completeSelection() + null -> Unit + } + } + + fun handleUserTap(user: DiscoverUserItem) { + val selectionMode = _uiState.value.selectionMode + if (selectionMode == null) { + startDirectChat(user) + return + } + + val selected = _uiState.value.selectedUserIds.toMutableSet() + if (!selected.add(user.id)) { + selected.remove(user.id) + } + _uiState.value = _uiState.value.copy(selectedUserIds = selected, errorMessage = null) + } + + fun completeSelection() { + val mode = _uiState.value.selectionMode ?: return + val state = _uiState.value + val selectedIds = state.selectedUserIds.toList() + if (mode == DiscoverSelectionMode.Group && selectedIds.isEmpty()) { + _uiState.value = state.copy(errorMessage = "Выберите хотя бы одного участника.") + return + } + + viewModelScope.launch { + _uiState.value = _uiState.value.copy(isLoading = true, errorMessage = null) + runCatching { + container.executeAuthorized { session -> + when (mode) { + DiscoverSelectionMode.Group -> container.apiClient.createGroupChat( + accessToken = session.accessToken, + title = "Новая группа", + memberIds = selectedIds + ) + + DiscoverSelectionMode.Channel -> container.apiClient.createChannel( + accessToken = session.accessToken, + title = state.channelTitle.trim(), + memberIds = selectedIds, + description = state.channelDescription.trim().takeIf { it.isNotEmpty() }, + publicUsername = state.channelPublicUsername.takeIf { state.channelVisibility == ChannelVisibility.Public }, + createInviteLink = state.channelVisibility == ChannelVisibility.Private, + channelSignaturesEnabled = state.channelSignaturesEnabled + ) + } + } + }.onSuccess { chat -> + _uiState.value = _uiState.value.copy( + pendingChatId = chat.id, + isLoading = false, + selectionMode = null, + selectedUserIds = emptySet(), + channelWizardStep = null, + errorMessage = null + ) + }.onFailure { error -> + _uiState.value = _uiState.value.copy(isLoading = false, errorMessage = error.message) + } + } + } + + fun startDirectChat(user: DiscoverUserItem) { + viewModelScope.launch { + runCatching { + container.executeAuthorized { session -> + container.apiClient.createDirectChat(session.accessToken, user.id) + } + }.onSuccess { chat -> + _uiState.value = _uiState.value.copy(pendingChatId = chat.id) + }.onFailure { error -> + _uiState.value = _uiState.value.copy(errorMessage = error.message) + } + } + } + + fun consumePendingChatNavigation() { + _uiState.value = _uiState.value.copy(pendingChatId = null) + } + + private fun normalizePublicUsernameInput(value: String): String = + value + .trim() + .trimStart('@') + .filter { it in 'a'..'z' || it in 'A'..'Z' || it.isDigit() || it == '_' } + .take(32) + .lowercase(Locale.US) +} diff --git a/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/folders/ChatFoldersScreen.kt b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/folders/ChatFoldersScreen.kt new file mode 100644 index 00000000..067e32df --- /dev/null +++ b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/folders/ChatFoldersScreen.kt @@ -0,0 +1,179 @@ +package com.seven.ikar.kotlin.feature.folders + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.WindowInsetsSides +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.only +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.safeDrawing +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Checkbox +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.seven.ikar.kotlin.core.model.ChatFolderDto +import com.seven.ikar.kotlin.core.model.ChatSummaryDto +import com.seven.ikar.kotlin.ui.IkarRowDivider +import com.seven.ikar.kotlin.ui.IkarScreenSurface +import com.seven.ikar.kotlin.ui.IkarSearchCard +import com.seven.ikar.kotlin.ui.IkarSectionCard +import com.seven.ikar.kotlin.ui.IkarSectionTitle +import com.seven.ikar.kotlin.ui.IkarSimpleHeader +import com.seven.ikar.kotlin.ui.chatPreview +import com.seven.ikar.kotlin.ui.theme.TelegramBlue +import com.seven.ikar.kotlin.ui.theme.TelegramDanger +import com.seven.ikar.kotlin.ui.theme.TelegramInkMuted + +@Composable +fun ChatFoldersScreen( + state: ChatFoldersUiState, + onBackClick: () -> Unit, + onRefreshClick: () -> Unit, + onNewFolderKeyChanged: (String) -> Unit, + onFolderSelected: (String?) -> Unit, + onChatToggled: (String) -> Unit, + onAssignSelected: () -> Unit, + onClearSelected: () -> Unit +) { + IkarScreenSurface(modifier = Modifier.fillMaxSize()) { + Column( + modifier = Modifier + .fillMaxSize() + .windowInsetsPadding(WindowInsets.safeDrawing.only(WindowInsetsSides.Vertical)) + .verticalScroll(rememberScrollState()) + .padding(horizontal = 18.dp, vertical = 16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + IkarSimpleHeader(title = "Папки чатов", onBackClick = onBackClick) + + state.errorMessage?.let { + Text(it, color = TelegramDanger, style = MaterialTheme.typography.bodySmall) + } + + if (state.isLoading && state.chats.isEmpty()) { + CircularProgressIndicator(color = TelegramBlue) + } + + IkarSectionCard { + IkarSectionTitle("Папки", modifier = Modifier.padding(bottom = 8.dp)) + FolderChipRow( + folders = state.folders, + selectedFolderKey = state.selectedFolderKey, + onFolderSelected = onFolderSelected + ) + IkarSearchCard( + value = state.newFolderKey, + onValueChange = onNewFolderKeyChanged, + placeholder = "новая-папка" + ) + Text( + text = "Выберите чаты ниже, введите ключ папки и назначьте. Пустое назначение убирает чат из пользовательских папок.", + style = MaterialTheme.typography.bodySmall, + color = TelegramInkMuted + ) + } + + IkarSectionCard { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + IkarSectionTitle("Чаты (выбрано: ${state.selectedChatIds.size})") + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + TextButton(enabled = !state.isSaving, onClick = onClearSelected) { + Text("Очистить") + } + TextButton(enabled = !state.isSaving, onClick = onAssignSelected) { + Text(if (state.isSaving) "Сохранение..." else "Назначить") + } + } + } + + state.chats.forEachIndexed { index, chat -> + FolderChatRow( + chat = chat, + checked = state.selectedChatIds.contains(chat.id), + onClick = { onChatToggled(chat.id) } + ) + if (index != state.chats.lastIndex) { + IkarRowDivider(startIndent = 42.dp) + } + } + } + + TextButton(onClick = onRefreshClick) { + Text("Обновить") + } + } + } +} + +@Composable +private fun FolderChipRow( + folders: List<ChatFolderDto>, + selectedFolderKey: String?, + onFolderSelected: (String?) -> Unit +) { + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + folders.forEach { folder -> + TextButton(onClick = { onFolderSelected(folder.key.takeUnless { it in BuiltInFolderKeys }) }) { + Text( + text = "${folder.title} (${folder.totalCount})", + color = if (folder.key == selectedFolderKey) TelegramBlue else MaterialTheme.colorScheme.onSurface, + fontWeight = if (folder.key == selectedFolderKey) FontWeight.SemiBold else FontWeight.Normal + ) + } + } + } +} + +@Composable +private fun FolderChatRow( + chat: ChatSummaryDto, + checked: Boolean, + onClick: () -> Unit +) { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onClick) + .padding(vertical = 10.dp), + horizontalArrangement = Arrangement.spacedBy(10.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Checkbox(checked = checked, onCheckedChange = { onClick() }) + Column(modifier = Modifier.weight(1f)) { + Text( + text = chat.title, + style = MaterialTheme.typography.titleMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + Text( + text = listOfNotNull(chat.folderKey?.let { "папка: $it" }, chatPreview(chat).takeIf { it.isNotBlank() }) + .joinToString(" • "), + style = MaterialTheme.typography.bodySmall, + color = TelegramInkMuted, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + } +} + +private val BuiltInFolderKeys = setOf("all", "unread", "pinned", "archive") diff --git a/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/folders/ChatFoldersViewModel.kt b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/folders/ChatFoldersViewModel.kt new file mode 100644 index 00000000..9dc6f677 --- /dev/null +++ b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/folders/ChatFoldersViewModel.kt @@ -0,0 +1,113 @@ +package com.seven.ikar.kotlin.feature.folders + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.seven.ikar.kotlin.app.AppContainer +import com.seven.ikar.kotlin.core.model.ChatFolderDto +import com.seven.ikar.kotlin.core.model.ChatStateRequest +import com.seven.ikar.kotlin.core.model.ChatSummaryDto +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch + +data class ChatFoldersUiState( + val folders: List<ChatFolderDto> = emptyList(), + val chats: List<ChatSummaryDto> = emptyList(), + val selectedChatIds: Set<String> = emptySet(), + val newFolderKey: String = "", + val selectedFolderKey: String? = null, + val isLoading: Boolean = false, + val isSaving: Boolean = false, + val errorMessage: String? = null +) + +class ChatFoldersViewModel(private val container: AppContainer) : ViewModel() { + private val _uiState = MutableStateFlow(ChatFoldersUiState()) + val uiState: StateFlow<ChatFoldersUiState> = _uiState.asStateFlow() + + init { + refresh() + } + + fun refresh() { + viewModelScope.launch { + _uiState.value = _uiState.value.copy(isLoading = true, errorMessage = null) + runCatching { + container.executeAuthorized { session -> + val folders = container.apiClient.getChatFolders(session.accessToken) + val chats = container.apiClient.getChats(session.accessToken) + folders to chats + } + }.onSuccess { (folders, chats) -> + _uiState.value = _uiState.value.copy( + folders = folders, + chats = chats, + selectedChatIds = _uiState.value.selectedChatIds.intersect(chats.mapTo(mutableSetOf(), ChatSummaryDto::id)), + isLoading = false + ) + }.onFailure { error -> + _uiState.value = _uiState.value.copy(isLoading = false, errorMessage = error.message) + } + } + } + + fun updateNewFolderKey(value: String) { + val normalized = normalizeFolderKey(value) + _uiState.value = _uiState.value.copy(newFolderKey = normalized, selectedFolderKey = normalized.ifBlank { null }) + } + + fun selectFolder(folderKey: String?) { + _uiState.value = _uiState.value.copy(selectedFolderKey = folderKey, newFolderKey = folderKey.orEmpty()) + } + + fun toggleChat(chatId: String) { + val current = _uiState.value.selectedChatIds + _uiState.value = _uiState.value.copy( + selectedChatIds = if (current.contains(chatId)) current - chatId else current + chatId + ) + } + + fun assignSelectedToFolder() { + val state = _uiState.value + val folderKey = normalizeFolderKey(state.selectedFolderKey ?: state.newFolderKey) + if (folderKey.isBlank() || state.selectedChatIds.isEmpty() || state.isSaving) { + return + } + updateSelectedChats(ChatStateRequest(folderKey = folderKey)) + } + + fun clearSelectedFolders() { + val state = _uiState.value + if (state.selectedChatIds.isEmpty() || state.isSaving) { + return + } + updateSelectedChats(ChatStateRequest(folderKey = "")) + } + + private fun updateSelectedChats(request: ChatStateRequest) { + val chatIds = _uiState.value.selectedChatIds.toList() + viewModelScope.launch { + _uiState.value = _uiState.value.copy(isSaving = true, errorMessage = null) + runCatching { + container.executeAuthorized { session -> + chatIds.forEach { chatId -> + container.apiClient.updateChatState(session.accessToken, chatId, request) + } + } + }.onSuccess { + _uiState.value = _uiState.value.copy(isSaving = false, selectedChatIds = emptySet()) + refresh() + }.onFailure { error -> + _uiState.value = _uiState.value.copy(isSaving = false, errorMessage = error.message) + } + } + } +} + +private fun normalizeFolderKey(value: String): String = + value.trim() + .lowercase() + .replace(Regex("\\s+"), "-") + .replace(Regex("[^a-z0-9_-]"), "") + .take(32) diff --git a/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/forward/ForwardScreen.kt b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/forward/ForwardScreen.kt new file mode 100644 index 00000000..8b2c3117 --- /dev/null +++ b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/forward/ForwardScreen.kt @@ -0,0 +1,119 @@ +package com.seven.ikar.kotlin.feature.forward + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.seven.ikar.kotlin.core.contacts.DiscoverUserItem +import com.seven.ikar.kotlin.ui.AvatarView +import com.seven.ikar.kotlin.ui.IkarScreenSurface +import com.seven.ikar.kotlin.ui.IkarSearchCard +import com.seven.ikar.kotlin.ui.IkarSectionCard +import com.seven.ikar.kotlin.ui.IkarSimpleHeader +import com.seven.ikar.kotlin.ui.IkarRowDivider +import com.seven.ikar.kotlin.ui.formatDiscoverPresence +import com.seven.ikar.kotlin.ui.formatPresenceColor +import com.seven.ikar.kotlin.ui.theme.TelegramBlue +import com.seven.ikar.kotlin.ui.theme.TelegramInkMuted + +@Composable +fun ForwardScreen( + state: ForwardUiState, + onBackClick: () -> Unit, + onQueryChanged: (String) -> Unit, + onUserClick: (DiscoverUserItem) -> Unit +) { + IkarScreenSurface(modifier = Modifier.fillMaxSize()) { + Column(modifier = Modifier.fillMaxSize()) { + Column( + modifier = Modifier.padding(start = 18.dp, top = 16.dp, end = 18.dp, bottom = 12.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + IkarSimpleHeader(title = "Переслать", onBackClick = onBackClick) + + IkarSearchCard( + value = state.query, + onValueChange = onQueryChanged, + placeholder = "Поиск контакта" + ) + + state.errorMessage?.let { + Text(it, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodyMedium) + } + } + + IkarSectionCard( + modifier = Modifier + .weight(1f) + .padding(horizontal = 18.dp, vertical = 0.dp) + .padding(bottom = 24.dp), + padding = androidx.compose.foundation.layout.PaddingValues(0.dp) + ) { + Text( + "Выберите получателя", + modifier = Modifier.padding(start = 18.dp, top = 16.dp, end = 18.dp, bottom = 8.dp), + style = MaterialTheme.typography.labelLarge, + color = TelegramBlue + ) + + LazyColumn { + if (state.items.isEmpty()) { + item { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 24.dp, vertical = 28.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Text("Контакты не найдены", style = MaterialTheme.typography.headlineMedium) + Text( + "Откройте доступ к контактам или уточните поиск.", + color = TelegramInkMuted, + style = MaterialTheme.typography.bodyMedium + ) + } + } + } else { + items(state.items, key = { it.id }) { item -> + Column { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { onUserClick(item) } + .padding(horizontal = 18.dp, vertical = 12.dp), + horizontalArrangement = Arrangement.spacedBy(14.dp), + verticalAlignment = Alignment.CenterVertically + ) { + AvatarView(imageUrl = item.avatarUrl, title = item.displayName, size = 54.dp) + Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(3.dp)) { + Text(item.displayName, style = MaterialTheme.typography.titleLarge, maxLines = 1, overflow = TextOverflow.Ellipsis) + Text( + formatDiscoverPresence(item), + style = MaterialTheme.typography.bodySmall, + color = formatPresenceColor(item) + ) + } + } + IkarRowDivider(startIndent = 86.dp) + } + } + } + } + } + } + } +} diff --git a/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/forward/ForwardViewModel.kt b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/forward/ForwardViewModel.kt new file mode 100644 index 00000000..12ea90d7 --- /dev/null +++ b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/forward/ForwardViewModel.kt @@ -0,0 +1,90 @@ +package com.seven.ikar.kotlin.feature.forward + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.seven.ikar.kotlin.app.AppContainer +import com.seven.ikar.kotlin.core.contacts.DiscoverUserItem +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch + +data class ForwardUiState( + val sourceChatId: String, + val sourceMessageId: String, + val query: String = "", + val items: List<DiscoverUserItem> = emptyList(), + val isLoading: Boolean = false, + val errorMessage: String? = null, + val pendingChatId: String? = null +) + +class ForwardViewModel( + private val container: AppContainer, + private val sourceChatId: String, + private val sourceMessageId: String +) : ViewModel() { + private val _uiState = MutableStateFlow( + ForwardUiState( + sourceChatId = sourceChatId, + sourceMessageId = sourceMessageId + ) + ) + val uiState: StateFlow<ForwardUiState> = _uiState.asStateFlow() + + private var searchJob: Job? = null + + init { + refresh() + } + + fun updateQuery(value: String) { + _uiState.value = _uiState.value.copy(query = value) + searchJob?.cancel() + searchJob = viewModelScope.launch { + delay(250) + refresh() + } + } + + fun refresh() { + viewModelScope.launch { + val query = _uiState.value.query + _uiState.value = _uiState.value.copy(isLoading = true, errorMessage = null) + runCatching { container.resolvedContactsRepository.loadMergedCandidates(query) } + .onSuccess { items -> + _uiState.value = _uiState.value.copy(items = items, isLoading = false, errorMessage = null) + } + .onFailure { error -> + _uiState.value = _uiState.value.copy(isLoading = false, errorMessage = error.message) + } + } + } + + fun forwardToUser(user: DiscoverUserItem) { + viewModelScope.launch { + runCatching { + container.executeAuthorized { session -> + val chat = container.apiClient.createDirectChat(session.accessToken, user.id) + container.apiClient.forwardMessage( + accessToken = session.accessToken, + sourceChatId = sourceChatId, + messageId = sourceMessageId, + targetChatId = chat.id + ) + chat.id + } + }.onSuccess { chatId -> + _uiState.value = _uiState.value.copy(pendingChatId = chatId) + }.onFailure { error -> + _uiState.value = _uiState.value.copy(errorMessage = error.message) + } + } + } + + fun consumePendingChatNavigation() { + _uiState.value = _uiState.value.copy(pendingChatId = null) + } +} diff --git a/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/login/LoginScreen.kt b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/login/LoginScreen.kt new file mode 100644 index 00000000..430b60f8 --- /dev/null +++ b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/login/LoginScreen.kt @@ -0,0 +1,236 @@ +package com.seven.ikar.kotlin.feature.login + +import androidx.compose.foundation.background +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.unit.dp +import androidx.compose.foundation.text.KeyboardOptions + +@Composable +fun LoginScreen( + state: LoginUiState, + onPhoneNumberChanged: (String) -> Unit, + onDisplayNameChanged: (String) -> Unit, + onVerificationCodeChanged: (String) -> Unit, + onRequestCodeClick: () -> Unit, + onVerifyCodeClick: () -> Unit, + onChangePhoneNumberClick: () -> Unit +) { + Box( + modifier = Modifier + .fillMaxSize() + .background( + Brush.linearGradient( + colors = listOf( + Color(0xFF3DA9F5), + Color(0xFF229AF0), + Color(0xFF1A9CEF) + ) + ) + ) + ) { + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(horizontal = 24.dp, vertical = 32.dp), + verticalArrangement = Arrangement.spacedBy(20.dp) + ) { + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + Surface( + shape = CircleShape, + color = Color.White.copy(alpha = 0.14f) + ) { + Box( + modifier = Modifier.padding(28.dp), + contentAlignment = Alignment.Center + ) { + Text( + text = "ИКАР", + style = MaterialTheme.typography.headlineSmall, + color = Color.White, + fontWeight = FontWeight.SemiBold + ) + } + } + + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + Text( + text = state.pageTitle, + style = MaterialTheme.typography.headlineSmall, + color = Color.White, + fontWeight = FontWeight.SemiBold + ) + Text( + text = state.subtitle, + style = MaterialTheme.typography.bodyMedium, + color = Color(0xFFDDEEFF) + ) + } + } + + Surface( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(28.dp), + color = Color.White + ) { + Column( + modifier = Modifier.padding(22.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + Text( + text = "АККАУНТ", + style = MaterialTheme.typography.labelLarge, + color = Color(0xFF5A8EB2), + fontWeight = FontWeight.SemiBold + ) + + if (state.showPhoneStep) { + OutlinedTextField( + value = state.phoneNumber, + onValueChange = onPhoneNumberChanged, + modifier = Modifier.fillMaxWidth(), + label = { Text("Номер телефона") }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Phone), + singleLine = true + ) + + Button( + onClick = onRequestCodeClick, + enabled = !state.isLoading, + contentPadding = PaddingValues(horizontal = 20.dp, vertical = 14.dp) + ) { + if (state.isLoading) { + CircularProgressIndicator( + strokeWidth = 2.dp, + modifier = Modifier.padding(horizontal = 8.dp) + ) + } else { + Text("Получить тестовый код") + } + } + } + + if (state.showCodeStep) { + Surface( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(18.dp), + color = Color(0xFFF5FAFF), + border = BorderStroke(1.dp, Color(0xFFD7E8F7)) + ) { + Column( + modifier = Modifier.padding(14.dp), + verticalArrangement = Arrangement.spacedBy(10.dp) + ) { + Text( + text = "Подтверждение номера", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold + ) + Text( + text = "Для тестового режима код показывается прямо в приложении. После подключения SMS-провайдера это сообщение исчезнет.", + style = MaterialTheme.typography.bodyMedium, + color = Color(0xFF617A8F) + ) + + if (state.hasTestCodeHint) { + Text( + text = "Тестовый код: ${state.testCodeHint}", + style = MaterialTheme.typography.bodyMedium, + color = Color(0xFF229AF0), + fontWeight = FontWeight.SemiBold + ) + } + + OutlinedTextField( + value = state.verificationCode, + onValueChange = onVerificationCodeChanged, + modifier = Modifier.fillMaxWidth(), + label = { Text("Код подтверждения") }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + singleLine = true + ) + + if (state.showDisplayName) { + OutlinedTextField( + value = state.displayName, + onValueChange = onDisplayNameChanged, + modifier = Modifier.fillMaxWidth(), + label = { Text("Ваше имя") }, + singleLine = true + ) + } + + Button( + onClick = onVerifyCodeClick, + enabled = !state.isLoading, + contentPadding = PaddingValues(horizontal = 20.dp, vertical = 14.dp) + ) { + if (state.isLoading) { + CircularProgressIndicator( + strokeWidth = 2.dp, + modifier = Modifier.padding(horizontal = 8.dp) + ) + } else { + Text("Продолжить") + } + } + + Button( + onClick = onChangePhoneNumberClick, + enabled = !state.isLoading, + colors = ButtonDefaults.buttonColors( + containerColor = Color.Transparent, + contentColor = Color(0xFF229AF0) + ), + contentPadding = PaddingValues(horizontal = 0.dp, vertical = 12.dp) + ) { + Text("Изменить номер") + } + } + } + } + + state.errorMessage?.let { + Text( + text = it, + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodyMedium + ) + } + } + } + } + } +} diff --git a/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/login/LoginViewModel.kt b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/login/LoginViewModel.kt new file mode 100644 index 00000000..8e105e6f --- /dev/null +++ b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/login/LoginViewModel.kt @@ -0,0 +1,135 @@ +package com.seven.ikar.kotlin.feature.login + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.seven.ikar.kotlin.app.AppContainer +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch + +data class LoginUiState( + val phoneNumber: String = "", + val displayName: String = "", + val verificationCode: String = "", + val challengeId: String = "", + val testCodeHint: String = "", + val isAwaitingCode: Boolean = false, + val isNewAccount: Boolean = false, + val isLoading: Boolean = false, + val errorMessage: String? = null, + val isAuthenticated: Boolean = false +) { + val pageTitle: String + get() = if (isAwaitingCode) "Подтвердите номер" else "Вход в ИКАР" + + val subtitle: String + get() = if (isAwaitingCode) { + "Введите тестовый код. Когда подключим SMS-провайдера, здесь будет код из сообщения." + } else { + "Введите номер телефона, чтобы получить тестовый код входа." + } + + val showPhoneStep: Boolean + get() = !isAwaitingCode + + val showCodeStep: Boolean + get() = isAwaitingCode + + val showDisplayName: Boolean + get() = isAwaitingCode && isNewAccount + + val hasTestCodeHint: Boolean + get() = testCodeHint.isNotBlank() +} + +class LoginViewModel(private val container: AppContainer) : ViewModel() { + private val _uiState = MutableStateFlow(LoginUiState()) + val uiState: StateFlow<LoginUiState> = _uiState.asStateFlow() + + fun updatePhoneNumber(value: String) { + _uiState.value = _uiState.value.copy(phoneNumber = value, errorMessage = null) + } + + fun updateDisplayName(value: String) { + _uiState.value = _uiState.value.copy(displayName = value, errorMessage = null) + } + + fun updateVerificationCode(value: String) { + _uiState.value = _uiState.value.copy(verificationCode = value, errorMessage = null) + } + + fun requestCode() { + val state = _uiState.value + if (state.phoneNumber.isBlank() || state.isLoading) { + return + } + + viewModelScope.launch(Dispatchers.IO) { + _uiState.value = state.copy(isLoading = true, errorMessage = null) + runCatching { + container.apiClient.requestPhoneCode(state.phoneNumber.trim()) + }.onSuccess { challenge -> + _uiState.value = _uiState.value.copy( + isLoading = false, + challengeId = challenge.challengeId, + testCodeHint = challenge.testCode, + isAwaitingCode = true, + isNewAccount = !challenge.isRegistered, + verificationCode = "", + errorMessage = null + ) + }.onFailure { error -> + _uiState.value = _uiState.value.copy( + isLoading = false, + errorMessage = error.message ?: "Не удалось запросить код." + ) + } + } + } + + fun verifyCode() { + val state = _uiState.value + if (state.isLoading || + state.challengeId.isBlank() || + state.phoneNumber.isBlank() || + state.verificationCode.isBlank() || + (state.isNewAccount && state.displayName.isBlank()) + ) { + return + } + + viewModelScope.launch(Dispatchers.IO) { + _uiState.value = state.copy(isLoading = true, errorMessage = null) + runCatching { + container.apiClient.verifyPhoneCode( + challengeId = state.challengeId, + phoneNumber = state.phoneNumber.trim(), + code = state.verificationCode.trim(), + displayName = state.displayName.trim().ifBlank { null } + ) + }.onSuccess { session -> + container.sessionStore.save(session) + runCatching { container.pushRegistrationManager.registerCurrentDeviceAsync() } + _uiState.value = _uiState.value.copy(isLoading = false, isAuthenticated = true) + }.onFailure { error -> + _uiState.value = _uiState.value.copy( + isLoading = false, + errorMessage = error.message ?: "Не удалось подтвердить код." + ) + } + } + } + + fun changePhoneNumber() { + _uiState.value = _uiState.value.copy( + challengeId = "", + testCodeHint = "", + verificationCode = "", + isAwaitingCode = false, + isNewAccount = false, + errorMessage = null + ) + } +} diff --git a/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/mainmenu/MainMenuViewModel.kt b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/mainmenu/MainMenuViewModel.kt new file mode 100644 index 00000000..119f6b97 --- /dev/null +++ b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/mainmenu/MainMenuViewModel.kt @@ -0,0 +1,108 @@ +package com.seven.ikar.kotlin.feature.mainmenu + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.seven.ikar.kotlin.app.AppContainer +import com.seven.ikar.kotlin.core.model.ChatSummaryDto +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch + +data class MainMenuUiState( + val unreadChatsCount: Int = 0 +) + +class MainMenuViewModel(private val container: AppContainer) : ViewModel() { + private val _uiState = MutableStateFlow(MainMenuUiState()) + val uiState: StateFlow<MainMenuUiState> = _uiState.asStateFlow() + + private var debounceJob: Job? = null + private var refreshJob: Job? = null + + init { + attachRealtime() + loadCachedUnreadChatsCount() + refresh() + } + + fun refresh() { + val currentSession = container.sessionStore.read() + if (currentSession == null) { + clear() + return + } + + refreshJob?.cancel() + refreshJob = viewModelScope.launch { + runCatching { + container.executeAuthorized { session -> + val chats = container.apiClient.getChats(session.accessToken) + session.user.id to chats + } + }.onSuccess { (ownerUserId, chats) -> + if (container.sessionStore.read()?.user?.id != ownerUserId) { + return@onSuccess + } + _uiState.value = MainMenuUiState(unreadChatsCount = countUnreadChats(chats)) + cacheChatList(ownerUserId, chats) + }.onFailure { + loadCachedUnreadChatsCount() + } + } + } + + fun clear() { + debounceJob?.cancel() + refreshJob?.cancel() + _uiState.value = MainMenuUiState() + } + + private fun loadCachedUnreadChatsCount() { + val session = container.sessionStore.read() ?: return + val ownerUserId = session.user.id + viewModelScope.launch(Dispatchers.IO) { + val chats = runCatching { + container.chatListCache.loadChats(ownerUserId) + }.getOrDefault(emptyList()) + if (container.sessionStore.read()?.user?.id == ownerUserId) { + _uiState.value = MainMenuUiState(unreadChatsCount = countUnreadChats(chats)) + } + } + } + + private fun cacheChatList(ownerUserId: String, chats: List<ChatSummaryDto>) { + viewModelScope.launch(Dispatchers.IO) { + runCatching { container.chatListCache.replaceChats(ownerUserId, chats) } + } + } + + private fun attachRealtime() { + viewModelScope.launch { + container.realtimeService.chatCreated.collect { scheduleRefresh() } + } + viewModelScope.launch { + container.realtimeService.chatsChanged.collect { scheduleRefresh() } + } + viewModelScope.launch { + container.realtimeService.messagesCreated.collect { scheduleRefresh() } + } + viewModelScope.launch { + container.realtimeService.messagesUpdated.collect { scheduleRefresh() } + } + } + + private fun scheduleRefresh() { + debounceJob?.cancel() + debounceJob = viewModelScope.launch { + delay(400) + refresh() + } + } + + private fun countUnreadChats(chats: List<ChatSummaryDto>): Int = + chats.count { chat -> chat.unreadCount > 0 } +} diff --git a/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/members/ChatMembersScreen.kt b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/members/ChatMembersScreen.kt new file mode 100644 index 00000000..8b310897 --- /dev/null +++ b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/members/ChatMembersScreen.kt @@ -0,0 +1,443 @@ +package com.seven.ikar.kotlin.feature.members + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.WindowInsetsSides +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.only +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.safeDrawing +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.seven.ikar.kotlin.core.model.ChatJoinRequestDto +import com.seven.ikar.kotlin.core.model.ChatJoinRequestStatus +import com.seven.ikar.kotlin.core.model.ChatMemberDto +import com.seven.ikar.kotlin.core.model.ChatMemberRole +import com.seven.ikar.kotlin.core.model.ModerationLogDto +import com.seven.ikar.kotlin.core.model.ModerationLogTargetType +import com.seven.ikar.kotlin.core.model.UserSummaryDto +import com.seven.ikar.kotlin.ui.AvatarView +import com.seven.ikar.kotlin.ui.IkarSearchCard +import com.seven.ikar.kotlin.ui.IkarRowDivider +import com.seven.ikar.kotlin.ui.IkarScreenSurface +import com.seven.ikar.kotlin.ui.IkarSectionCard +import com.seven.ikar.kotlin.ui.IkarSectionTitle +import com.seven.ikar.kotlin.ui.IkarSimpleHeader +import com.seven.ikar.kotlin.ui.formatLastActivity +import com.seven.ikar.kotlin.ui.theme.TelegramBlue +import com.seven.ikar.kotlin.ui.theme.TelegramDanger +import com.seven.ikar.kotlin.ui.theme.TelegramInkMuted + +@Composable +fun ChatMembersScreen( + state: ChatMembersUiState, + onBackClick: () -> Unit, + onRefreshClick: () -> Unit, + onToggleAdmin: (ChatMemberDto) -> Unit, + onToggleCanPost: (ChatMemberDto) -> Unit, + onToggleCanPin: (ChatMemberDto) -> Unit, + onToggleCanDelete: (ChatMemberDto) -> Unit, + onToggleCanInvite: (ChatMemberDto) -> Unit, + onMemberSearchChanged: (String) -> Unit, + onAddMember: (UserSummaryDto) -> Unit, + onAddAdmin: (UserSummaryDto) -> Unit, + onApproveJoinRequest: (ChatJoinRequestDto) -> Unit, + onDeclineJoinRequest: (ChatJoinRequestDto) -> Unit +) { + IkarScreenSurface(modifier = Modifier.fillMaxSize()) { + Column( + modifier = Modifier + .fillMaxSize() + .windowInsetsPadding(WindowInsets.safeDrawing.only(WindowInsetsSides.Vertical)) + .verticalScroll(rememberScrollState()) + .padding(horizontal = 18.dp, vertical = 16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + IkarSimpleHeader( + title = state.chat?.title?.ifBlank { "Участники" } ?: "Участники", + onBackClick = onBackClick + ) + + state.errorMessage?.let { + Text(it, color = TelegramDanger, style = MaterialTheme.typography.bodySmall) + } + + if (state.isLoading && state.chat == null) { + CircularProgressIndicator(color = TelegramBlue) + } + + if (state.canAddMembers) { + IkarSectionCard { + IkarSectionTitle("Добавить участника или бота", modifier = Modifier.padding(bottom = 8.dp)) + IkarSearchCard( + value = state.memberSearchQuery, + onValueChange = onMemberSearchChanged, + placeholder = "Имя, username или бот" + ) + if (state.isMemberSearchLoading) { + CircularProgressIndicator(color = TelegramBlue) + } + if (state.memberSearchQuery.trim().length >= 2 && + !state.isMemberSearchLoading && + state.memberSearchResults.isEmpty() + ) { + Text("Подходящих пользователей или ботов не найдено.", style = MaterialTheme.typography.bodySmall, color = TelegramInkMuted) + } + state.memberSearchResults.forEachIndexed { index, user -> + AddMemberCandidateRow( + user = user, + isAdding = state.addingUserIds.contains(user.id), + onAddMember = { onAddMember(user) }, + onAddAdmin = { onAddAdmin(user) } + ) + if (index != state.memberSearchResults.lastIndex) { + IkarRowDivider(startIndent = 58.dp) + } + } + } + } + + if (state.canManageMembers) { + IkarSectionCard { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + IkarSectionTitle("Заявки на вступление") + if (state.isAdminPanelLoading) { + CircularProgressIndicator(color = TelegramBlue) + } + } + val pendingRequests = state.joinRequests.filter { it.status == ChatJoinRequestStatus.Pending } + val resolvedRequests = state.joinRequests.filterNot { it.status == ChatJoinRequestStatus.Pending } + val displayedRequests = pendingRequests + resolvedRequests + if (state.joinRequests.isEmpty()) { + Text("Заявок на вступление пока нет.", style = MaterialTheme.typography.bodySmall, color = TelegramInkMuted) + } + displayedRequests.forEachIndexed { index, request -> + JoinRequestRow( + request = request, + isResolving = state.resolvingJoinRequestIds.contains(request.id), + onApproveClick = { onApproveJoinRequest(request) }, + onDeclineClick = { onDeclineJoinRequest(request) } + ) + if (index != displayedRequests.lastIndex) { + IkarRowDivider(startIndent = 58.dp) + } + } + } + } + + IkarSectionCard { + IkarSectionTitle( + text = if (state.canManageMembers) "Участники и права" else "Участники", + modifier = Modifier.padding(bottom = 8.dp) + ) + val members = state.chat?.members.orEmpty() + members.forEachIndexed { index, member -> + MemberPermissionRow( + member = member, + canManage = state.canManageMembers && !member.isOwner, + isUpdating = state.updatingUserIds.contains(member.user.id), + onToggleAdmin = { onToggleAdmin(member) }, + onToggleCanPost = { onToggleCanPost(member) }, + onToggleCanPin = { onToggleCanPin(member) }, + onToggleCanDelete = { onToggleCanDelete(member) }, + onToggleCanInvite = { onToggleCanInvite(member) } + ) + if (index != members.lastIndex) { + IkarRowDivider(startIndent = 58.dp) + } + } + } + + if (state.canManageMembers) { + IkarSectionCard { + IkarSectionTitle("Журнал модерации", modifier = Modifier.padding(bottom = 8.dp)) + if (state.moderationLog.isEmpty()) { + Text("Событий модерации пока нет.", style = MaterialTheme.typography.bodySmall, color = TelegramInkMuted) + } + state.moderationLog.forEachIndexed { index, entry -> + ModerationLogRow(entry) + if (index != state.moderationLog.lastIndex) { + IkarRowDivider() + } + } + } + } + + TextButton(onClick = onRefreshClick) { + Text("Обновить") + } + } + } +} + +@Composable +private fun AddMemberCandidateRow( + user: UserSummaryDto, + isAdding: Boolean, + onAddMember: () -> Unit, + onAddAdmin: () -> Unit +) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 10.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + AvatarView( + imageUrl = user.avatarPath, + title = user.displayName, + size = 46.dp + ) + Column(modifier = Modifier.weight(1f)) { + Text( + text = user.displayName.ifBlank { "@${user.username}" }, + style = MaterialTheme.typography.titleMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + Text( + text = if (user.isBot) "бот · @${user.username}" else "@${user.username}", + style = MaterialTheme.typography.bodySmall, + color = TelegramInkMuted, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + } + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + TextButton(enabled = !isAdding, onClick = onAddMember) { + Text(if (isAdding) "Добавление..." else "Добавить") + } + TextButton(enabled = !isAdding, onClick = onAddAdmin) { + Text("Добавить админом", color = TelegramBlue) + } + } + } +} + +@Composable +private fun JoinRequestRow( + request: ChatJoinRequestDto, + isResolving: Boolean, + onApproveClick: () -> Unit, + onDeclineClick: () -> Unit +) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 10.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + AvatarView( + imageUrl = request.user.avatarPath, + title = request.user.displayName, + size = 46.dp + ) + Column(modifier = Modifier.weight(1f)) { + Text( + text = request.user.displayName.ifBlank { "@${request.user.username}" }, + style = MaterialTheme.typography.titleMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + Text( + text = "Запрос: ${formatLastActivity(request.requestedAt)} - ${joinRequestStatusLabel(request.status)}", + style = MaterialTheme.typography.bodySmall, + color = TelegramInkMuted + ) + } + } + if (request.status == ChatJoinRequestStatus.Pending) { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + TextButton(enabled = !isResolving, onClick = onApproveClick) { + Text(if (isResolving) "Обработка..." else "Одобрить") + } + TextButton(enabled = !isResolving, onClick = onDeclineClick) { + Text("Отклонить", color = TelegramDanger) + } + } + } else { + val resolvedBy = request.resolvedBy?.displayName?.takeIf { it.isNotBlank() } + ?: request.resolvedBy?.username + Text( + text = listOfNotNull(request.resolvedAt?.let { "Решено: ${formatLastActivity(it)}" }, resolvedBy?.let { "кем: $it" }) + .joinToString(" - "), + style = MaterialTheme.typography.bodySmall, + color = TelegramInkMuted + ) + } + } +} + +@Composable +private fun MemberPermissionRow( + member: ChatMemberDto, + canManage: Boolean, + isUpdating: Boolean, + onToggleAdmin: () -> Unit, + onToggleCanPost: () -> Unit, + onToggleCanPin: () -> Unit, + onToggleCanDelete: () -> Unit, + onToggleCanInvite: () -> Unit +) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 10.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + AvatarView( + imageUrl = member.user.avatarPath, + title = member.user.displayName, + size = 46.dp + ) + Column(modifier = Modifier.weight(1f)) { + Text( + text = member.user.displayName.ifBlank { "@${member.user.username}" }, + style = MaterialTheme.typography.titleMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + Text( + text = roleLabel(member), + style = MaterialTheme.typography.bodySmall, + color = TelegramInkMuted + ) + } + if (canManage) { + TextButton(enabled = !isUpdating, onClick = onToggleAdmin) { + Text(if (member.role == ChatMemberRole.Admin) "Сделать участником" else "Сделать администратором") + } + } + } + + if (canManage) { + Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { + PermissionChip("публикации", member.permissions.canPostMessages, isUpdating, onToggleCanPost) + PermissionChip("закрепление", member.permissions.canPinMessages, isUpdating, onToggleCanPin) + PermissionChip("удаление", member.permissions.canDeleteMessages, isUpdating, onToggleCanDelete) + PermissionChip("приглашения", member.permissions.canInviteUsers, isUpdating, onToggleCanInvite) + } + } + } +} + +@Composable +private fun ModerationLogRow(entry: ModerationLogDto) { + Column( + modifier = Modifier.padding(vertical = 10.dp), + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = moderationActionLabel(entry.action), + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold + ) + Text( + text = formatLastActivity(entry.createdAt), + style = MaterialTheme.typography.bodySmall, + color = TelegramInkMuted + ) + } + Text( + text = "${entry.actorDisplayName} - ${moderationTargetLabel(entry.targetType)}", + style = MaterialTheme.typography.bodySmall, + color = TelegramInkMuted, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + entry.reason?.takeIf { it.isNotBlank() }?.let { reason -> + Text(reason, style = MaterialTheme.typography.bodySmall) + } + } +} + +@Composable +private fun PermissionChip( + label: String, + checked: Boolean, + disabled: Boolean, + onClick: () -> Unit +) { + TextButton(enabled = !disabled, onClick = onClick) { + Text( + text = "$label: ${if (checked) "вкл" else "выкл"}", + color = if (checked) TelegramBlue else TelegramInkMuted, + fontWeight = if (checked) FontWeight.SemiBold else FontWeight.Normal + ) + } +} + +private fun roleLabel(member: ChatMemberDto): String = when { + member.isOwner || member.role == ChatMemberRole.Owner -> "владелец" + member.role == ChatMemberRole.Admin -> "администратор" + else -> "участник" +} + +private fun joinRequestStatusLabel(status: ChatJoinRequestStatus): String = when (status) { + ChatJoinRequestStatus.Pending -> "ожидает" + ChatJoinRequestStatus.Approved -> "одобрена" + ChatJoinRequestStatus.Declined -> "отклонена" +} + +private fun moderationTargetLabel(targetType: ModerationLogTargetType): String = when (targetType) { + ModerationLogTargetType.Chat -> "чат" + ModerationLogTargetType.Message -> "сообщение" + ModerationLogTargetType.User -> "участник" + ModerationLogTargetType.InviteLink -> "пригласительная ссылка" + ModerationLogTargetType.JoinRequest -> "заявка на вступление" + ModerationLogTargetType.Story -> "история" +} + +private fun moderationActionLabel(action: String): String = when (action) { + "chat.security.updated" -> "Настройки безопасности чата обновлены" + "invite_link.joined" -> "Вход по пригласительной ссылке" + "join_request.created" -> "Заявка на вступление создана" + "join_request.approved" -> "Заявка на вступление одобрена" + "join_request.declined" -> "Заявка на вступление отклонена" + "member.added" -> "Участник добавлен" + "member.admin.added" -> "Администратор добавлен" + "member.permissions.updated" -> "Права участника обновлены" + "message.deleted" -> "Сообщение удалено" + "message.pinned" -> "Сообщение закреплено" + "message.unpinned" -> "Сообщение откреплено" + else -> action.replace('.', ' ').replace('_', ' ') +} diff --git a/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/members/ChatMembersViewModel.kt b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/members/ChatMembersViewModel.kt new file mode 100644 index 00000000..2374cbcb --- /dev/null +++ b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/members/ChatMembersViewModel.kt @@ -0,0 +1,294 @@ +package com.seven.ikar.kotlin.feature.members + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.seven.ikar.kotlin.app.AppContainer +import com.seven.ikar.kotlin.core.model.AddChatMembersRequest +import com.seven.ikar.kotlin.core.model.ChatDetailsDto +import com.seven.ikar.kotlin.core.model.ChatJoinRequestDto +import com.seven.ikar.kotlin.core.model.ChatMemberDto +import com.seven.ikar.kotlin.core.model.ChatMemberRole +import com.seven.ikar.kotlin.core.model.ChatType +import com.seven.ikar.kotlin.core.model.ModerationLogDto +import com.seven.ikar.kotlin.core.model.UpdateChatMemberPermissionsRequest +import com.seven.ikar.kotlin.core.model.UserSummaryDto +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch + +data class ChatMembersUiState( + val chat: ChatDetailsDto? = null, + val joinRequests: List<ChatJoinRequestDto> = emptyList(), + val moderationLog: List<ModerationLogDto> = emptyList(), + val isLoading: Boolean = false, + val isAdminPanelLoading: Boolean = false, + val memberSearchQuery: String = "", + val memberSearchResults: List<UserSummaryDto> = emptyList(), + val isMemberSearchLoading: Boolean = false, + val addingUserIds: Set<String> = emptySet(), + val updatingUserIds: Set<String> = emptySet(), + val resolvingJoinRequestIds: Set<String> = emptySet(), + val errorMessage: String? = null +) { + val canManageMembers: Boolean + get() = chat?.currentUserPermissions?.canManageMembers == true + + val canAddMembers: Boolean + get() = canManageMembers && chat?.type == ChatType.Channel +} + +class ChatMembersViewModel( + private val container: AppContainer, + private val chatId: String +) : ViewModel() { + private val _uiState = MutableStateFlow(ChatMembersUiState()) + val uiState: StateFlow<ChatMembersUiState> = _uiState.asStateFlow() + private var memberSearchJob: Job? = null + + init { + refresh() + } + + fun refresh() { + viewModelScope.launch { + _uiState.value = _uiState.value.copy(isLoading = true, isAdminPanelLoading = true, errorMessage = null) + runCatching { + container.executeAuthorized { session -> + val chat = container.apiClient.getChat(chatId, session.accessToken) + val canManageMembers = chat.currentUserPermissions?.canManageMembers == true + ChatMembersPayload( + chat = chat, + joinRequests = if (canManageMembers) { + container.apiClient.getChatJoinRequests(session.accessToken, chatId) + } else { + emptyList() + }, + moderationLog = if (canManageMembers) { + container.apiClient.getChatModerationLog(session.accessToken, chatId) + } else { + emptyList() + } + ) + } + }.onSuccess { payload -> + _uiState.value = _uiState.value.copy( + chat = payload.chat, + joinRequests = payload.joinRequests, + moderationLog = payload.moderationLog, + memberSearchResults = filterMemberSearchResults(_uiState.value.memberSearchResults, payload.chat), + isLoading = false, + isAdminPanelLoading = false, + errorMessage = null + ) + }.onFailure { error -> + _uiState.value = _uiState.value.copy( + isLoading = false, + isAdminPanelLoading = false, + errorMessage = error.message + ) + } + } + } + + fun updateMemberSearchQuery(value: String) { + val query = value.take(80) + _uiState.value = _uiState.value.copy(memberSearchQuery = query, errorMessage = null) + memberSearchJob?.cancel() + + if (query.trim().length < 2 || !_uiState.value.canAddMembers) { + _uiState.value = _uiState.value.copy(memberSearchResults = emptyList(), isMemberSearchLoading = false) + return + } + + memberSearchJob = viewModelScope.launch { + delay(250) + searchMembers(query.trim()) + } + } + + fun addMember(user: UserSummaryDto, asAdmin: Boolean) { + val state = _uiState.value + if (!state.canAddMembers || state.addingUserIds.contains(user.id)) { + return + } + + val role = if (asAdmin) ChatMemberRole.Admin else ChatMemberRole.Member + viewModelScope.launch { + _uiState.value = _uiState.value.copy( + addingUserIds = _uiState.value.addingUserIds + user.id, + errorMessage = null + ) + runCatching { + container.executeAuthorized { session -> + container.apiClient.addChatMembers( + accessToken = session.accessToken, + chatId = chatId, + request = AddChatMembersRequest( + memberIds = listOf(user.id), + role = role, + canPinMessages = asAdmin, + canDeleteMessages = asAdmin, + canInviteUsers = asAdmin, + canPostMessages = asAdmin + ) + ) + } + }.onSuccess { chat -> + val current = _uiState.value + _uiState.value = current.copy( + chat = chat, + addingUserIds = current.addingUserIds - user.id, + memberSearchResults = filterMemberSearchResults(current.memberSearchResults, chat) + ) + refresh() + }.onFailure { error -> + _uiState.value = _uiState.value.copy( + addingUserIds = _uiState.value.addingUserIds - user.id, + errorMessage = error.message + ) + } + } + } + + fun approveJoinRequest(request: ChatJoinRequestDto) { + resolveJoinRequest(request, approve = true) + } + + fun declineJoinRequest(request: ChatJoinRequestDto) { + resolveJoinRequest(request, approve = false) + } + + fun toggleAdmin(member: ChatMemberDto) { + val nextRole = if (member.role == ChatMemberRole.Admin) ChatMemberRole.Member else ChatMemberRole.Admin + updateMember(member, role = nextRole) + } + + fun toggleCanPost(member: ChatMemberDto) { + updateMember(member, canPostMessages = !member.permissions.canPostMessages) + } + + fun toggleCanPin(member: ChatMemberDto) { + updateMember(member, canPinMessages = !member.permissions.canPinMessages) + } + + fun toggleCanDelete(member: ChatMemberDto) { + updateMember(member, canDeleteMessages = !member.permissions.canDeleteMessages) + } + + fun toggleCanInvite(member: ChatMemberDto) { + updateMember(member, canInviteUsers = !member.permissions.canInviteUsers) + } + + private fun updateMember( + member: ChatMemberDto, + role: ChatMemberRole = member.role, + canPostMessages: Boolean = member.permissions.canPostMessages, + canPinMessages: Boolean = member.permissions.canPinMessages, + canDeleteMessages: Boolean = member.permissions.canDeleteMessages, + canInviteUsers: Boolean = member.permissions.canInviteUsers + ) { + if (member.isOwner || _uiState.value.updatingUserIds.contains(member.user.id)) { + return + } + + viewModelScope.launch { + _uiState.value = _uiState.value.copy( + updatingUserIds = _uiState.value.updatingUserIds + member.user.id, + errorMessage = null + ) + runCatching { + container.executeAuthorized { session -> + container.apiClient.updateChatMemberPermissions( + accessToken = session.accessToken, + chatId = chatId, + memberUserId = member.user.id, + request = UpdateChatMemberPermissionsRequest( + role = role, + canPinMessages = canPinMessages, + canDeleteMessages = canDeleteMessages, + canInviteUsers = canInviteUsers, + canPostMessages = canPostMessages + ) + ) + } + }.onSuccess { chat -> + _uiState.value = _uiState.value.copy( + chat = chat, + updatingUserIds = _uiState.value.updatingUserIds - member.user.id + ) + }.onFailure { error -> + _uiState.value = _uiState.value.copy( + updatingUserIds = _uiState.value.updatingUserIds - member.user.id, + errorMessage = error.message + ) + } + } + } + + private suspend fun searchMembers(query: String) { + _uiState.value = _uiState.value.copy(isMemberSearchLoading = true, errorMessage = null) + runCatching { + container.executeAuthorized { session -> + container.apiClient.searchUsers(session.accessToken, query) + } + }.onSuccess { users -> + val current = _uiState.value + _uiState.value = current.copy( + memberSearchResults = filterMemberSearchResults(users, current.chat), + isMemberSearchLoading = false + ) + }.onFailure { error -> + _uiState.value = _uiState.value.copy( + isMemberSearchLoading = false, + errorMessage = error.message + ) + } + } + + private fun filterMemberSearchResults(users: List<UserSummaryDto>, chat: ChatDetailsDto?): List<UserSummaryDto> { + val memberIds = chat?.members.orEmpty().mapTo(mutableSetOf()) { it.user.id } + return users.filterNot { user -> memberIds.contains(user.id) } + } + + private fun resolveJoinRequest(request: ChatJoinRequestDto, approve: Boolean) { + if (_uiState.value.resolvingJoinRequestIds.contains(request.id)) { + return + } + + viewModelScope.launch { + _uiState.value = _uiState.value.copy( + resolvingJoinRequestIds = _uiState.value.resolvingJoinRequestIds + request.id, + errorMessage = null + ) + runCatching { + container.executeAuthorized { session -> + container.apiClient.resolveChatJoinRequest( + accessToken = session.accessToken, + chatId = chatId, + joinRequestId = request.id, + approve = approve + ) + } + }.onSuccess { + _uiState.value = _uiState.value.copy( + resolvingJoinRequestIds = _uiState.value.resolvingJoinRequestIds - request.id + ) + refresh() + }.onFailure { error -> + _uiState.value = _uiState.value.copy( + resolvingJoinRequestIds = _uiState.value.resolvingJoinRequestIds - request.id, + errorMessage = error.message + ) + } + } + } +} + +private data class ChatMembersPayload( + val chat: ChatDetailsDto, + val joinRequests: List<ChatJoinRequestDto>, + val moderationLog: List<ModerationLogDto> +) diff --git a/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/privacy/PrivacySecurityScreen.kt b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/privacy/PrivacySecurityScreen.kt new file mode 100644 index 00000000..382498df --- /dev/null +++ b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/privacy/PrivacySecurityScreen.kt @@ -0,0 +1,123 @@ +package com.seven.ikar.kotlin.feature.privacy + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.WindowInsetsSides +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.only +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.safeDrawing +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.seven.ikar.kotlin.core.model.PrivacySettingsDto +import com.seven.ikar.kotlin.ui.IkarRowDivider +import com.seven.ikar.kotlin.ui.IkarScreenSurface +import com.seven.ikar.kotlin.ui.IkarSectionCard +import com.seven.ikar.kotlin.ui.IkarSectionTitle +import com.seven.ikar.kotlin.ui.IkarSettingRow +import com.seven.ikar.kotlin.ui.IkarSimpleHeader +import com.seven.ikar.kotlin.ui.theme.TelegramBlue +import com.seven.ikar.kotlin.ui.theme.TelegramDanger +import com.seven.ikar.kotlin.ui.theme.TelegramInkMuted + +@Composable +fun PrivacySecurityScreen( + state: PrivacySecurityUiState, + onBackClick: () -> Unit, + onRefreshClick: () -> Unit, + onBlockedUsersClick: () -> Unit, + onExceptionsClick: () -> Unit, + onUpdate: ((PrivacySettingsDto) -> PrivacySettingsDto) -> Unit +) { + IkarScreenSurface(modifier = Modifier.fillMaxSize()) { + Column( + modifier = Modifier + .fillMaxSize() + .windowInsetsPadding(WindowInsets.safeDrawing.only(WindowInsetsSides.Vertical)) + .verticalScroll(rememberScrollState()) + .padding(horizontal = 18.dp, vertical = 16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + IkarSimpleHeader(title = "Конфиденциальность и безопасность", onBackClick = onBackClick) + state.errorMessage?.let { + Text(it, color = TelegramDanger, style = MaterialTheme.typography.bodySmall) + } + if (state.isLoading && state.settings == null) { + CircularProgressIndicator(color = TelegramBlue) + } + val settings = state.settings + if (settings != null) { + IkarSectionCard { + IkarSectionTitle("Конфиденциальность", modifier = Modifier.padding(bottom = 8.dp)) + PrivacyRow("Номер телефона", privacyValueLabel(settings.phoneNumberVisibility)) { + onUpdate { it.copy(phoneNumberVisibility = nextVisibility(it.phoneNumberVisibility)) } + } + IkarRowDivider() + PrivacyRow("Последняя активность и онлайн", privacyValueLabel(settings.lastSeenVisibility)) { + onUpdate { it.copy(lastSeenVisibility = nextVisibility(it.lastSeenVisibility)) } + } + IkarRowDivider() + PrivacyRow("Фотографии профиля", privacyValueLabel(settings.profilePhotoVisibility)) { + onUpdate { it.copy(profilePhotoVisibility = nextVisibility(it.profilePhotoVisibility)) } + } + IkarRowDivider() + PrivacyRow("Заблокированные пользователи", "${settings.blockedUserIds.size}") { + onBlockedUsersClick() + } + IkarRowDivider() + PrivacyRow("Исключения", "${settings.alwaysAllowUserIds.size + settings.neverAllowUserIds.size}") { + onExceptionsClick() + } + } + + IkarSectionCard { + IkarSectionTitle("Безопасность", modifier = Modifier.padding(bottom = 8.dp)) + PrivacyRow("Код-пароль", if (settings.passcodeLockEnabled) "включено" else "выключено") { + onUpdate { it.copy(passcodeLockEnabled = !it.passcodeLockEnabled) } + } + IkarRowDivider() + PrivacyRow("Предпросмотр сообщений", if (settings.showMessagePreview) "включено" else "выключено") { + onUpdate { it.copy(showMessagePreview = !it.showMessagePreview) } + } + } + } + TextButton(enabled = !state.isSaving, onClick = onRefreshClick) { + Text(if (state.isSaving) "Сохранение..." else "Обновить") + } + } + } +} + +@Composable +private fun PrivacyRow( + title: String, + value: String, + onClick: () -> Unit +) { + IkarSettingRow( + title = title, + subtitle = "Сейчас: $value", + onClick = onClick, + leading = { + Text(value.take(1).uppercase(), color = TelegramInkMuted) + } + ) +} + +private fun privacyValueLabel(value: String): String = when (value.lowercase()) { + "everyone" -> "все" + "contacts" -> "контакты" + "nobody" -> "никто" + "enabled" -> "включено" + "disabled" -> "выключено" + else -> value +} diff --git a/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/privacy/PrivacySecurityViewModel.kt b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/privacy/PrivacySecurityViewModel.kt new file mode 100644 index 00000000..85b88bb9 --- /dev/null +++ b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/privacy/PrivacySecurityViewModel.kt @@ -0,0 +1,79 @@ +package com.seven.ikar.kotlin.feature.privacy + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.seven.ikar.kotlin.app.AppContainer +import com.seven.ikar.kotlin.core.model.PrivacySettingsDto +import com.seven.ikar.kotlin.core.model.UpdatePrivacySettingsRequest +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch + +data class PrivacySecurityUiState( + val settings: PrivacySettingsDto? = null, + val isLoading: Boolean = false, + val isSaving: Boolean = false, + val errorMessage: String? = null +) + +class PrivacySecurityViewModel(private val container: AppContainer) : ViewModel() { + private val _uiState = MutableStateFlow(PrivacySecurityUiState()) + val uiState: StateFlow<PrivacySecurityUiState> = _uiState.asStateFlow() + + init { + refresh() + } + + fun refresh() { + viewModelScope.launch { + _uiState.value = _uiState.value.copy(isLoading = true, errorMessage = null) + runCatching { + container.executeAuthorized { session -> + container.apiClient.getMyPrivacySettings(session.accessToken) + } + }.onSuccess { settings -> + container.privacyRuntimeSettings.saveFrom(settings) + _uiState.value = _uiState.value.copy(settings = settings, isLoading = false) + }.onFailure { error -> + _uiState.value = _uiState.value.copy(isLoading = false, errorMessage = error.message) + } + } + } + + fun update(transform: (PrivacySettingsDto) -> PrivacySettingsDto) { + val current = _uiState.value.settings ?: return + val next = transform(current) + viewModelScope.launch { + _uiState.value = _uiState.value.copy(settings = next, isSaving = true, errorMessage = null) + runCatching { + container.executeAuthorized { session -> + container.apiClient.updateMyPrivacySettings( + session.accessToken, + UpdatePrivacySettingsRequest( + phoneNumberVisibility = next.phoneNumberVisibility, + lastSeenVisibility = next.lastSeenVisibility, + profilePhotoVisibility = next.profilePhotoVisibility, + blockedUserIds = next.blockedUserIds, + alwaysAllowUserIds = next.alwaysAllowUserIds, + neverAllowUserIds = next.neverAllowUserIds, + passcodeLockEnabled = next.passcodeLockEnabled, + showMessagePreview = next.showMessagePreview + ) + ) + } + }.onSuccess { saved -> + container.privacyRuntimeSettings.saveFrom(saved) + _uiState.value = _uiState.value.copy(settings = saved, isSaving = false) + }.onFailure { error -> + _uiState.value = _uiState.value.copy(settings = current, isSaving = false, errorMessage = error.message) + } + } + } +} + +internal fun nextVisibility(value: String): String = when (value) { + "everyone" -> "contacts" + "contacts" -> "nobody" + else -> "everyone" +} diff --git a/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/profile/AccountSettingsScreen.kt b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/profile/AccountSettingsScreen.kt new file mode 100644 index 00000000..f001fc81 --- /dev/null +++ b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/profile/AccountSettingsScreen.kt @@ -0,0 +1,257 @@ +package com.seven.ikar.kotlin.feature.profile + +import android.net.Uri +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.PickVisualMediaRequest +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.WindowInsetsSides +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.only +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.safeDrawing +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.verticalScroll +import androidx.compose.foundation.clickable +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Edit +import androidx.compose.material.icons.outlined.Info +import androidx.compose.material.icons.outlined.Phone +import androidx.compose.material3.Button +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import com.seven.ikar.kotlin.ui.AvatarView +import com.seven.ikar.kotlin.ui.IkarSectionCard +import com.seven.ikar.kotlin.ui.IkarSimpleHeader +import com.seven.ikar.kotlin.ui.IkarScreenSurface +import com.seven.ikar.kotlin.ui.theme.TelegramBlue +import com.seven.ikar.kotlin.ui.theme.TelegramDanger +import com.seven.ikar.kotlin.ui.theme.TelegramInkMuted +import com.seven.ikar.kotlin.ui.theme.TelegramSurface + +@Composable +fun AccountSettingsScreen( + state: ProfileUiState, + onBackClick: () -> Unit, + onDisplayNameChanged: (String) -> Unit, + onAboutChanged: (String) -> Unit, + onSaveClick: () -> Unit, + onDeleteAvatarClick: () -> Unit, + onAvatarSelected: (Uri) -> Unit +) { + val avatarPicker = rememberLauncherForActivityResult(ActivityResultContracts.PickVisualMedia()) { uri -> + if (uri != null) { + onAvatarSelected(uri) + } + } + + IkarScreenSurface(modifier = Modifier.fillMaxSize()) { + Column( + modifier = Modifier + .fillMaxSize() + .windowInsetsPadding(WindowInsets.safeDrawing.only(WindowInsetsSides.Vertical)) + .verticalScroll(rememberScrollState()) + .padding(horizontal = 18.dp, vertical = 16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + IkarSimpleHeader(title = "Аккаунт", onBackClick = onBackClick) + + IkarSectionCard { + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(10.dp), + horizontalAlignment = androidx.compose.ui.Alignment.CenterHorizontally + ) { + AvatarView( + imageUrl = state.avatarUrl, + title = state.displayName.ifBlank { state.phoneNumber.ifBlank { "ИКАР" } }, + size = 92.dp + ) + + Button( + onClick = { + avatarPicker.launch(PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly)) + } + ) { + Text(if (state.avatarUrl.isNullOrBlank()) "Добавить фото профиля" else "Изменить фото профиля") + } + + if (!state.avatarUrl.isNullOrBlank()) { + Text( + text = "Удалить фото", + modifier = Modifier + .padding(top = 2.dp) + .clickable(onClick = onDeleteAvatarClick), + style = MaterialTheme.typography.titleMedium.copy(color = TelegramDanger), + fontWeight = FontWeight.SemiBold + ) + Text( + text = "Нажмите на надпись, чтобы удалить текущее фото", + color = TelegramInkMuted, + style = MaterialTheme.typography.bodySmall + ) + } + } + } + + IkarSectionCard { + IkarInfoLine( + icon = Icons.Outlined.Phone, + title = state.phoneNumber.ifBlank { "Номер не загружен" }, + subtitle = "Нажмите, чтобы изменить номер телефона" + ) + + androidx.compose.foundation.layout.Spacer(modifier = Modifier.padding(top = 12.dp)) + androidx.compose.foundation.layout.Box( + modifier = Modifier + .fillMaxWidth() + .padding(start = 54.dp) + .height(1.dp) + .background(com.seven.ikar.kotlin.ui.theme.TelegramBorder) + ) + androidx.compose.foundation.layout.Spacer(modifier = Modifier.padding(top = 12.dp)) + + IkarInfoLine( + icon = Icons.Outlined.Info, + title = "Указать день рождения", + subtitle = "Поля дня рождения и его видимости будут добавлены отдельно." + ) + } + + IkarSectionCard { + Text("Ваше имя", style = MaterialTheme.typography.labelLarge, color = TelegramBlue) + androidx.compose.foundation.layout.Spacer(modifier = Modifier.padding(top = 10.dp)) + IkarTextInput( + value = state.displayName, + onValueChange = onDisplayNameChanged, + placeholder = "Введите отображаемое имя" + ) + } + + IkarSectionCard { + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { + Text("О себе", style = MaterialTheme.typography.labelLarge, color = TelegramBlue) + Text("${state.about.length}/200", style = MaterialTheme.typography.bodySmall, color = TelegramInkMuted) + } + androidx.compose.foundation.layout.Spacer(modifier = Modifier.padding(top = 10.dp)) + IkarTextInput( + value = state.about, + onValueChange = onAboutChanged, + placeholder = "Напишите что-нибудь о себе...", + minLines = 4 + ) + androidx.compose.foundation.layout.Spacer(modifier = Modifier.padding(top = 8.dp)) + Text( + "Вы можете добавить несколько строк о себе. Сейчас это поле сохраняется в профиле Ikar.", + color = TelegramInkMuted, + style = MaterialTheme.typography.bodySmall + ) + } + + IkarSectionCard { + Row(horizontalArrangement = Arrangement.spacedBy(14.dp)) { + Surface( + modifier = Modifier.size(40.dp), + color = Color(0xFFEFF6FD), + shape = androidx.compose.foundation.shape.CircleShape + ) { + androidx.compose.foundation.layout.Box(contentAlignment = androidx.compose.ui.Alignment.Center) { + androidx.compose.material3.Icon(Icons.Outlined.Edit, contentDescription = null, tint = TelegramBlue) + } + } + Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text("Добавить личный канал", style = MaterialTheme.typography.titleLarge) + Text( + "Этот сценарий будет доведён отдельным экраном. Сейчас основной профиль уже перенесён.", + style = MaterialTheme.typography.bodySmall, + color = TelegramInkMuted + ) + } + } + } + + Button( + onClick = onSaveClick, + enabled = !state.isLoading && !state.isSaving, + modifier = Modifier.fillMaxWidth() + ) { + Text(if (state.isSaving) "Сохранение..." else "Сохранить") + } + + state.errorMessage?.let { + Text(it, color = TelegramDanger, style = MaterialTheme.typography.bodyMedium) + } + state.infoMessage?.let { + Text(it, color = TelegramBlue, style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.SemiBold) + } + } + } +} + +@Composable +private fun IkarInfoLine( + icon: androidx.compose.ui.graphics.vector.ImageVector, + title: String, + subtitle: String +) { + Row(horizontalArrangement = Arrangement.spacedBy(14.dp)) { + Surface( + modifier = Modifier.size(32.dp), + color = TelegramSurface, + shape = androidx.compose.foundation.shape.CircleShape + ) { + androidx.compose.foundation.layout.Box(contentAlignment = androidx.compose.ui.Alignment.Center) { + androidx.compose.material3.Icon(icon, contentDescription = null, tint = TelegramInkMuted) + } + } + Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text(title, style = MaterialTheme.typography.titleLarge) + Text(subtitle, style = MaterialTheme.typography.bodySmall, color = TelegramInkMuted) + } + } +} + +@Composable +private fun IkarTextInput( + value: String, + onValueChange: (String) -> Unit, + placeholder: String, + minLines: Int = 1 +) { + Surface( + modifier = Modifier.fillMaxWidth(), + color = TelegramSurface, + shape = androidx.compose.foundation.shape.RoundedCornerShape(18.dp) + ) { + BasicTextField( + value = value, + onValueChange = onValueChange, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 14.dp, vertical = 12.dp), + textStyle = MaterialTheme.typography.bodyLarge.copy(color = MaterialTheme.colorScheme.onSurface), + minLines = minLines, + decorationBox = { innerTextField -> + if (value.isBlank()) { + Text(placeholder, color = TelegramInkMuted, style = MaterialTheme.typography.bodyLarge) + } + innerTextField() + } + ) + } +} diff --git a/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/profile/PeerProfileScreen.kt b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/profile/PeerProfileScreen.kt new file mode 100644 index 00000000..9cf9f62e --- /dev/null +++ b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/profile/PeerProfileScreen.kt @@ -0,0 +1,260 @@ +package com.seven.ikar.kotlin.feature.profile + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.WindowInsetsSides +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.only +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.safeDrawing +import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.outlined.ArrowBack +import androidx.compose.material.icons.outlined.Refresh +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import com.seven.ikar.kotlin.ui.AuthenticatedAsyncImage +import com.seven.ikar.kotlin.ui.AvatarView +import com.seven.ikar.kotlin.ui.IkarHeaderIconBubble +import com.seven.ikar.kotlin.ui.IkarRowDivider +import com.seven.ikar.kotlin.ui.IkarRoundedTag +import com.seven.ikar.kotlin.ui.IkarScreenSurface +import com.seven.ikar.kotlin.ui.IkarSectionCard +import com.seven.ikar.kotlin.ui.formatPresence +import com.seven.ikar.kotlin.ui.theme.TelegramBlue +import com.seven.ikar.kotlin.ui.theme.TelegramDanger +import com.seven.ikar.kotlin.ui.theme.TelegramInkMuted + +@Composable +fun PeerProfileScreen( + state: PeerProfileUiState, + onBackClick: () -> Unit, + onRefreshClick: () -> Unit +) { + val user = state.user + val displayName = user?.displayName?.takeIf { it.isNotBlank() } + ?: user?.phoneNumber?.takeIf { !it.isNullOrBlank() } + ?: "Профиль" + val usernameText = user?.username?.takeIf { it.isNotBlank() }?.let { "@$it" } ?: "Без имени пользователя" + val aboutText = user?.about?.takeIf { it.isNotBlank() } + val statusText = user?.let(::formatPresence) ?: "был давно" + var showAvatarPreview by rememberSaveable(state.userId) { mutableStateOf(false) } + + IkarScreenSurface(modifier = Modifier.fillMaxSize()) { + Column( + modifier = Modifier + .fillMaxSize() + .windowInsetsPadding(WindowInsets.safeDrawing.only(WindowInsetsSides.Vertical)) + .verticalScroll(rememberScrollState()) + .padding(horizontal = 18.dp, vertical = 18.dp), + verticalArrangement = Arrangement.spacedBy(18.dp) + ) { + Box( + modifier = Modifier.fillMaxWidth(), + contentAlignment = Alignment.Center + ) { + IkarHeaderIconBubble( + modifier = Modifier.align(Alignment.CenterStart), + onClick = onBackClick + ) { + Icon( + imageVector = Icons.AutoMirrored.Outlined.ArrowBack, + contentDescription = "Назад", + tint = MaterialTheme.colorScheme.onSurface + ) + } + Text( + text = "Профиль", + style = MaterialTheme.typography.displayLarge, + maxLines = 1 + ) + IkarHeaderIconBubble( + modifier = Modifier.align(Alignment.CenterEnd), + onClick = onRefreshClick + ) { + Icon( + imageVector = Icons.Outlined.Refresh, + contentDescription = "Обновить", + tint = MaterialTheme.colorScheme.onSurface + ) + } + } + + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(10.dp) + ) { + Box( + modifier = Modifier + .clip(CircleShape) + .clickable( + enabled = !state.avatarUrl.isNullOrBlank(), + interactionSource = remember { MutableInteractionSource() }, + indication = null + ) { + showAvatarPreview = true + } + ) { + AvatarView( + imageUrl = state.avatarUrl, + title = displayName, + accessToken = state.accessToken, + size = 112.dp + ) + } + + Text( + text = displayName, + style = MaterialTheme.typography.displayLarge, + fontWeight = FontWeight.SemiBold + ) + Text( + text = statusText, + style = MaterialTheme.typography.bodyMedium, + color = TelegramInkMuted + ) + if (user?.isBot == true) { + IkarRoundedTag(text = "Бот") + } + } + + IkarSectionCard { + PeerProfileInfo( + value = user?.phoneNumber?.takeIf { it.isNotBlank() } ?: "Телефон не указан", + subtitle = "Телефон" + ) + IkarRowDivider(startIndent = 0.dp) + PeerProfileInfo(value = usernameText, subtitle = "Имя пользователя") + IkarRowDivider(startIndent = 0.dp) + PeerProfileInfo(value = statusText, subtitle = "Статус") + } + + aboutText?.let { + IkarSectionCard { + PeerProfileInfo(value = it, subtitle = "О себе") + } + } + + state.errorMessage?.let { + Text(it, color = TelegramDanger, style = MaterialTheme.typography.bodyMedium) + } + + Spacer(modifier = Modifier.height(24.dp)) + } + + if (state.isLoading && user == null) { + CircularProgressIndicator( + modifier = Modifier.align(Alignment.Center), + color = TelegramBlue + ) + } + + if (showAvatarPreview && !state.avatarUrl.isNullOrBlank()) { + PeerAvatarPreview( + imageUrl = state.avatarUrl, + accessToken = state.accessToken, + title = displayName, + onDismiss = { showAvatarPreview = false } + ) + } + } +} + +@Composable +private fun PeerProfileInfo( + value: String, + subtitle: String +) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 12.dp), + verticalArrangement = Arrangement.spacedBy(2.dp) + ) { + Text(value, style = MaterialTheme.typography.titleLarge) + Text(subtitle, style = MaterialTheme.typography.bodySmall, color = TelegramInkMuted) + } +} + +@Composable +private fun PeerAvatarPreview( + imageUrl: String, + accessToken: String?, + title: String, + onDismiss: () -> Unit +) { + Box( + modifier = Modifier + .fillMaxSize() + .background(Color.Black.copy(alpha = 0.55f)) + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, + onClick = onDismiss + ) + ) { + Surface( + modifier = Modifier + .align(Alignment.TopCenter) + .fillMaxWidth() + .fillMaxHeight(0.4f) + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, + onClick = {} + ), + shape = RoundedCornerShape(bottomStart = 28.dp, bottomEnd = 28.dp), + color = Color.Black + ) { + AuthenticatedAsyncImage( + imageUrl = imageUrl, + accessToken = accessToken, + contentDescription = title, + modifier = Modifier.fillMaxSize(), + contentScale = ContentScale.Crop + ) + } + + TextButton( + modifier = Modifier + .align(Alignment.TopEnd) + .statusBarsPadding() + .padding(top = 10.dp, end = 12.dp), + onClick = onDismiss + ) { + Text("Закрыть", color = Color.White) + } + } +} diff --git a/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/profile/PeerProfileViewModel.kt b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/profile/PeerProfileViewModel.kt new file mode 100644 index 00000000..b46a7788 --- /dev/null +++ b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/profile/PeerProfileViewModel.kt @@ -0,0 +1,54 @@ +package com.seven.ikar.kotlin.feature.profile + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.seven.ikar.kotlin.app.AppContainer +import com.seven.ikar.kotlin.core.model.UserSummaryDto +import com.seven.ikar.kotlin.core.network.ServerConfig +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch + +data class PeerProfileUiState( + val userId: String, + val accessToken: String? = null, + val user: UserSummaryDto? = null, + val avatarUrl: String? = null, + val isLoading: Boolean = false, + val errorMessage: String? = null +) + +class PeerProfileViewModel( + private val container: AppContainer, + private val userId: String +) : ViewModel() { + private val _uiState = MutableStateFlow(PeerProfileUiState(userId = userId)) + val uiState: StateFlow<PeerProfileUiState> = _uiState.asStateFlow() + + init { + refresh() + } + + fun refresh() { + if (container.sessionStore.read() == null) return + viewModelScope.launch { + _uiState.value = _uiState.value.copy(isLoading = true, errorMessage = null) + runCatching { + container.executeAuthorized { session -> + session.accessToken to container.apiClient.getUser(session.accessToken, userId) + } + }.onSuccess { (accessToken, user) -> + _uiState.value = _uiState.value.copy( + accessToken = accessToken, + user = user, + avatarUrl = ServerConfig.normalizeMediaUrl(user.avatarPath), + isLoading = false, + errorMessage = null + ) + }.onFailure { error -> + _uiState.value = _uiState.value.copy(isLoading = false, errorMessage = error.message) + } + } + } +} diff --git a/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/profile/ProfileScreen.kt b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/profile/ProfileScreen.kt new file mode 100644 index 00000000..b811d564 --- /dev/null +++ b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/profile/ProfileScreen.kt @@ -0,0 +1,271 @@ +package com.seven.ikar.kotlin.feature.profile + +import android.net.Uri +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.PickVisualMediaRequest +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.WindowInsetsSides +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.only +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.safeDrawing +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Close +import androidx.compose.material.icons.outlined.Edit +import androidx.compose.material.icons.outlined.Image +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.seven.ikar.kotlin.R +import com.seven.ikar.kotlin.ui.AvatarView +import com.seven.ikar.kotlin.ui.IkarCenteredHeader +import com.seven.ikar.kotlin.ui.IkarFloatingMainMenu +import com.seven.ikar.kotlin.ui.IkarMainMenuTab +import com.seven.ikar.kotlin.ui.IkarPillCard +import com.seven.ikar.kotlin.ui.IkarScreenSurface +import com.seven.ikar.kotlin.ui.IkarSectionCard +import com.seven.ikar.kotlin.ui.formatPresence +import com.seven.ikar.kotlin.ui.theme.TelegramBlue +import com.seven.ikar.kotlin.ui.theme.TelegramDanger +import com.seven.ikar.kotlin.ui.theme.TelegramInkMuted + +@Composable +fun ProfileScreen( + state: ProfileUiState, + onSettingsClick: () -> Unit, + onLogoutClick: () -> Unit, + onManageAvatarClick: (Uri) -> Unit, + onChatsClick: () -> Unit, + onContactsClick: () -> Unit, + onProfileClick: () -> Unit, + onSettingsTabClick: () -> Unit +) { + val avatarPicker = rememberLauncherForActivityResult(ActivityResultContracts.PickVisualMedia()) { uri -> + if (uri != null) { + onManageAvatarClick(uri) + } + } + val statusText = state.session?.user?.let(::formatPresence) ?: "был давно" + + IkarScreenSurface(modifier = Modifier.fillMaxSize()) { + Column( + modifier = Modifier + .fillMaxSize() + .windowInsetsPadding(WindowInsets.safeDrawing.only(WindowInsetsSides.Vertical)) + .verticalScroll(rememberScrollState()) + .padding(horizontal = 18.dp, vertical = 18.dp), + verticalArrangement = Arrangement.spacedBy(18.dp) + ) { + IkarCenteredHeader( + title = "Профиль", + left = { + Image( + painter = painterResource(id = R.drawable.ikar_logo), + contentDescription = "Икар" + ) + }, + right = {} + ) + + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(10.dp) + ) { + AvatarView( + imageUrl = state.avatarUrl, + title = state.displayName.ifBlank { state.phoneNumber.ifBlank { "ИКАР" } }, + size = 108.dp + ) + + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(2.dp) + ) { + Text( + text = state.displayName.ifBlank { "Без имени" }, + style = MaterialTheme.typography.displayLarge, + fontWeight = FontWeight.SemiBold + ) + Text( + text = statusText, + style = MaterialTheme.typography.bodyMedium, + color = TelegramInkMuted + ) + } + } + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(10.dp) + ) { + ProfileActionCard( + modifier = Modifier.weight(1f), + title = "Выбрать фото", + iconTint = TelegramBlue, + onClick = { + avatarPicker.launch( + PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly) + ) + } + ) { tint -> + Icon( + imageVector = Icons.Outlined.Image, + contentDescription = null, + tint = tint, + modifier = Modifier.size(30.dp) + ) + } + ProfileActionCard( + modifier = Modifier.weight(1f), + title = "Изменить", + iconTint = TelegramBlue, + onClick = onSettingsClick + ) { tint -> + Icon( + imageVector = Icons.Outlined.Edit, + contentDescription = null, + tint = tint, + modifier = Modifier.size(30.dp) + ) + } + ProfileActionCard( + modifier = Modifier.weight(1f), + title = "Выход", + iconTint = TelegramDanger, + textColor = TelegramDanger, + onClick = onLogoutClick + ) { tint -> + Icon( + imageVector = Icons.Outlined.Close, + contentDescription = null, + tint = tint, + modifier = Modifier.size(30.dp) + ) + } + } + + IkarSectionCard { + IkarProfileInfo( + value = state.phoneNumber.ifBlank { "Номер не загружен" }, + subtitle = "Телефон" + ) + com.seven.ikar.kotlin.ui.IkarRowDivider(startIndent = 0.dp) + IkarProfileInfo( + value = state.displayName.ifBlank { "Без имени" }, + subtitle = "Отображаемое имя" + ) + com.seven.ikar.kotlin.ui.IkarRowDivider(startIndent = 0.dp) + IkarProfileInfo( + value = statusText, + subtitle = "Состояние" + ) + } + + state.errorMessage?.let { + Text(it, color = TelegramDanger, style = MaterialTheme.typography.bodyMedium) + } + state.infoMessage?.let { + Text( + text = it, + color = TelegramBlue, + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.SemiBold + ) + } + + Box( + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 116.dp) + ) + } + + IkarFloatingMainMenu( + selectedTab = IkarMainMenuTab.Profile, + modifier = Modifier + .align(Alignment.BottomCenter) + .padding(horizontal = 8.dp, vertical = 18.dp), + onChatsClick = onChatsClick, + onContactsClick = onContactsClick, + onSettingsClick = onSettingsTabClick, + onProfileClick = onProfileClick + ) + } +} + +@Composable +private fun IkarProfileInfo( + value: String, + subtitle: String +) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 12.dp), + verticalArrangement = Arrangement.spacedBy(2.dp) + ) { + Text(value, style = MaterialTheme.typography.titleLarge) + Text(subtitle, style = MaterialTheme.typography.bodySmall, color = TelegramInkMuted) + } +} + +@Composable +private fun ProfileActionCard( + title: String, + iconTint: Color, + modifier: Modifier = Modifier, + textColor: Color? = null, + onClick: () -> Unit, + icon: @Composable (Color) -> Unit +) { + val resolvedTextColor = textColor ?: MaterialTheme.colorScheme.onSurface + IkarPillCard( + modifier = modifier.height(104.dp), + onClick = onClick, + contentPadding = PaddingValues(horizontal = 8.dp, vertical = 10.dp) + ) { + Box( + modifier = Modifier + .fillMaxWidth() + .height(42.dp), + contentAlignment = Alignment.Center + ) { + icon(iconTint) + } + Text( + text = title, + modifier = Modifier.fillMaxWidth(), + style = MaterialTheme.typography.labelMedium.copy( + fontSize = 11.sp, + lineHeight = 11.sp + ), + color = resolvedTextColor, + textAlign = TextAlign.Center, + maxLines = 1, + softWrap = false + ) + } +} diff --git a/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/profile/ProfileViewModel.kt b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/profile/ProfileViewModel.kt new file mode 100644 index 00000000..730f5fda --- /dev/null +++ b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/profile/ProfileViewModel.kt @@ -0,0 +1,146 @@ +package com.seven.ikar.kotlin.feature.profile + +import android.net.Uri +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.seven.ikar.kotlin.app.AppContainer +import com.seven.ikar.kotlin.core.model.AuthSessionDto +import com.seven.ikar.kotlin.core.model.UserSummaryDto +import com.seven.ikar.kotlin.core.network.ServerConfig +import com.seven.ikar.kotlin.core.network.UploadFileSource +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch + +data class ProfileUiState( + val session: AuthSessionDto? = null, + val displayName: String = "", + val phoneNumber: String = "", + val about: String = "", + val avatarUrl: String? = null, + val isLoading: Boolean = false, + val isSaving: Boolean = false, + val errorMessage: String? = null, + val infoMessage: String? = null +) + +class ProfileViewModel(private val container: AppContainer) : ViewModel() { + private val _uiState = MutableStateFlow(ProfileUiState(session = container.sessionStore.read())) + val uiState: StateFlow<ProfileUiState> = _uiState.asStateFlow() + + init { + refresh() + } + + fun updateDisplayName(value: String) { + _uiState.value = _uiState.value.copy(displayName = value) + } + + fun updateAbout(value: String) { + _uiState.value = _uiState.value.copy(about = value) + } + + fun refresh() { + if (container.sessionStore.read() == null) return + viewModelScope.launch { + _uiState.value = _uiState.value.copy(isLoading = true, errorMessage = null, infoMessage = null) + runCatching { + container.executeAuthorized { session -> + session to container.apiClient.getMe(session.accessToken) + } + }.onSuccess { (session, user) -> + applyUser(session, user, infoMessage = null) + }.onFailure { error -> + _uiState.value = _uiState.value.copy(isLoading = false, errorMessage = error.message) + } + } + } + + fun save() { + val state = _uiState.value + if (state.displayName.isBlank()) { + _uiState.value = state.copy(errorMessage = "Укажите отображаемое имя.") + return + } + + viewModelScope.launch { + _uiState.value = state.copy(isSaving = true, errorMessage = null, infoMessage = null) + runCatching { + container.executeAuthorized { session -> + session to container.apiClient.updateMyProfile( + accessToken = session.accessToken, + displayName = state.displayName.trim(), + about = state.about.trim().ifBlank { null } + ) + } + }.onSuccess { (session, user) -> + applyUser(session, user, infoMessage = "Профиль сохранён.") + }.onFailure { error -> + _uiState.value = _uiState.value.copy(isSaving = false, errorMessage = error.message) + } + } + } + + fun uploadAvatar(uri: Uri) { + if (container.sessionStore.read() == null) return + viewModelScope.launch { + val state = _uiState.value + _uiState.value = state.copy(isSaving = true, errorMessage = null, infoMessage = null) + runCatching { + val contentResolver = container.appContext.contentResolver + val contentType = contentResolver.getType(uri) + val fileName = contentType?.substringAfter('/')?.let { "avatar.$it" } ?: "avatar.jpg" + container.executeAuthorized { session -> + session to container.apiClient.uploadMyAvatar( + accessToken = session.accessToken, + file = UploadFileSource( + fileName = fileName, + contentType = contentType + ) { + contentResolver.openInputStream(uri) + ?: throw IllegalStateException("Не удалось открыть выбранное изображение.") + } + ) + } + }.onSuccess { (session, user) -> + applyUser(session, user, infoMessage = "Аватар обновлён.") + }.onFailure { error -> + _uiState.value = _uiState.value.copy(isSaving = false, errorMessage = error.message) + } + } + } + + fun deleteAvatar() { + if (container.sessionStore.read() == null) return + viewModelScope.launch { + val state = _uiState.value + _uiState.value = state.copy(isSaving = true, errorMessage = null, infoMessage = null) + runCatching { + container.executeAuthorized { session -> + session to container.apiClient.deleteMyAvatar(session.accessToken) + } + }.onSuccess { (session, user) -> + applyUser(session, user, infoMessage = "Аватар удалён.") + }.onFailure { error -> + _uiState.value = _uiState.value.copy(isSaving = false, errorMessage = error.message) + } + } + } + + private fun applyUser(session: AuthSessionDto, user: UserSummaryDto, infoMessage: String?) { + val updatedSession = session.copy(user = user) + container.sessionStore.save(updatedSession) + _uiState.value = _uiState.value.copy( + session = updatedSession, + displayName = user.displayName, + phoneNumber = user.phoneNumber.orEmpty(), + about = user.about.orEmpty(), + avatarUrl = ServerConfig.normalizeMediaUrl(user.avatarPath), + isLoading = false, + isSaving = false, + errorMessage = null, + infoMessage = infoMessage + ) + } +} diff --git a/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/search/GlobalSearchScreen.kt b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/search/GlobalSearchScreen.kt new file mode 100644 index 00000000..be2ecfb6 --- /dev/null +++ b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/search/GlobalSearchScreen.kt @@ -0,0 +1,344 @@ +package com.seven.ikar.kotlin.feature.search + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.WindowInsetsSides +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.only +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.safeDrawing +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.seven.ikar.kotlin.core.model.ChatSearchResultDto +import com.seven.ikar.kotlin.core.model.ChatType +import com.seven.ikar.kotlin.ui.IkarRowDivider +import com.seven.ikar.kotlin.ui.IkarScreenSurface +import com.seven.ikar.kotlin.ui.IkarSearchCard +import com.seven.ikar.kotlin.ui.IkarSectionCard +import com.seven.ikar.kotlin.ui.IkarSimpleHeader +import com.seven.ikar.kotlin.ui.chatPreview +import com.seven.ikar.kotlin.ui.theme.TelegramBlue +import com.seven.ikar.kotlin.ui.theme.TelegramDanger +import com.seven.ikar.kotlin.ui.theme.TelegramInkMuted + +@Composable +fun GlobalSearchScreen( + state: GlobalSearchUiState, + onBackClick: () -> Unit, + onQueryChanged: (String) -> Unit, + onFilterChanged: (GlobalSearchFilter) -> Unit, + onDateFilterChanged: (GlobalSearchDateFilter) -> Unit = {}, + onResultTypeChanged: (GlobalSearchResultType) -> Unit = {}, + onAuthorFilterChanged: (String) -> Unit = {}, + onLoadMore: () -> Unit = {}, + onResultClick: (ChatSearchResultDto) -> Unit +) { + val visibleResults = state.visibleResults + IkarScreenSurface(modifier = Modifier.fillMaxSize()) { + Column( + modifier = Modifier + .fillMaxSize() + .windowInsetsPadding(WindowInsets.safeDrawing.only(WindowInsetsSides.Vertical)) + .padding(horizontal = 18.dp, vertical = 16.dp), + verticalArrangement = Arrangement.spacedBy(14.dp) + ) { + IkarSimpleHeader(title = "Глобальный поиск", onBackClick = onBackClick) + + IkarSearchCard( + value = state.query, + onValueChange = onQueryChanged, + placeholder = "Поиск чатов и сообщений" + ) + + GlobalSearchFilterRow( + selected = state.filter, + onFilterChanged = onFilterChanged + ) + + GlobalSearchAdvancedFilters( + selectedDate = state.dateFilter, + selectedType = state.resultType, + authorFilter = state.authorFilter, + onDateFilterChanged = onDateFilterChanged, + onResultTypeChanged = onResultTypeChanged, + onAuthorFilterChanged = onAuthorFilterChanged + ) + + state.errorMessage?.let { + Text(it, color = TelegramDanger, style = MaterialTheme.typography.bodySmall) + } + + IkarSectionCard(modifier = Modifier.weight(1f)) { + when { + state.isSearching -> { + Column( + modifier = Modifier.fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + CircularProgressIndicator(color = TelegramBlue) + } + } + + state.results.isEmpty() -> { + Column( + modifier = Modifier.fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Text("Ничего не найдено", style = MaterialTheme.typography.titleMedium) + Text( + "Введите текст или переключитесь на медиа, файлы или ссылки.", + style = MaterialTheme.typography.bodySmall, + color = TelegramInkMuted + ) + } + } + + visibleResults.isEmpty() -> { + Column( + modifier = Modifier.fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Text("Нет результатов по фильтрам", style = MaterialTheme.typography.titleMedium) + Text( + "Расширьте фильтр по дате, автору или типу.", + style = MaterialTheme.typography.bodySmall, + color = TelegramInkMuted + ) + } + } + + else -> { + LazyColumn(modifier = Modifier.fillMaxSize()) { + if (state.filter == GlobalSearchFilter.Media || state.resultType == GlobalSearchResultType.Attachments) { + visibleResults + .groupBy { it.searchDateGroupLabel() } + .forEach { (dateGroup, results) -> + item(key = "date:$dateGroup") { + Text( + text = dateGroup, + style = MaterialTheme.typography.labelMedium, + color = TelegramInkMuted, + modifier = Modifier.padding(top = 10.dp, bottom = 4.dp) + ) + } + items(results, key = { "${it.chat.id}:${it.message?.id.orEmpty()}" }) { result -> + GlobalSearchResultRow( + result = result, + query = state.query, + onClick = { onResultClick(result) } + ) + IkarRowDivider() + } + } + } else { + items(visibleResults, key = { "${it.chat.id}:${it.message?.id.orEmpty()}" }) { result -> + GlobalSearchResultRow( + result = result, + query = state.query, + onClick = { onResultClick(result) } + ) + IkarRowDivider() + } + } + if (state.hasMore) { + item(key = "load-more") { + TextButton( + enabled = !state.isLoadingMore, + onClick = onLoadMore, + modifier = Modifier.fillMaxWidth() + ) { + Text(if (state.isLoadingMore) "Загрузка..." else "Показать ещё", color = TelegramBlue) + } + } + } + } + } + } + } + } + } +} + +@Composable +private fun GlobalSearchAdvancedFilters( + selectedDate: GlobalSearchDateFilter, + selectedType: GlobalSearchResultType, + authorFilter: String, + onDateFilterChanged: (GlobalSearchDateFilter) -> Unit, + onResultTypeChanged: (GlobalSearchResultType) -> Unit, + onAuthorFilterChanged: (String) -> Unit +) { + Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { + Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { + GlobalSearchDateFilter.entries.forEach { filter -> + TextButton(onClick = { onDateFilterChanged(filter) }) { + Text( + text = filter.label(), + color = if (filter == selectedDate) TelegramBlue else TelegramInkMuted, + fontWeight = if (filter == selectedDate) FontWeight.SemiBold else FontWeight.Normal + ) + } + } + } + Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { + GlobalSearchResultType.entries.forEach { type -> + TextButton(onClick = { onResultTypeChanged(type) }) { + Text( + text = type.label(), + color = if (type == selectedType) TelegramBlue else TelegramInkMuted, + fontWeight = if (type == selectedType) FontWeight.SemiBold else FontWeight.Normal + ) + } + } + } + IkarSearchCard( + value = authorFilter, + onValueChange = onAuthorFilterChanged, + placeholder = "Фильтр по автору" + ) + } +} + +@Composable +private fun GlobalSearchFilterRow( + selected: GlobalSearchFilter, + onFilterChanged: (GlobalSearchFilter) -> Unit +) { + Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { + GlobalSearchFilter.entries.forEach { filter -> + TextButton(onClick = { onFilterChanged(filter) }) { + Text( + text = when (filter) { + GlobalSearchFilter.All -> "Все" + GlobalSearchFilter.Media -> "Медиа" + GlobalSearchFilter.Files -> "Файлы" + GlobalSearchFilter.Links -> "Ссылки" + }, + color = if (filter == selected) TelegramBlue else TelegramInkMuted, + fontWeight = if (filter == selected) FontWeight.SemiBold else FontWeight.Normal + ) + } + } + } +} + +@Composable +private fun GlobalSearchResultRow( + result: ChatSearchResultDto, + query: String, + onClick: () -> Unit +) { + Column( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onClick) + .padding(vertical = 12.dp), + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + Text( + text = highlightedText(result.chat.title, query), + style = MaterialTheme.typography.titleMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + Text( + text = highlightedText(result.matchText.ifBlank { chatPreview(result.chat) }, query), + style = MaterialTheme.typography.bodyMedium, + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) + result.message?.attachments?.takeIf { it.isNotEmpty() }?.let { attachments -> + Text( + text = attachments.joinToString(limit = 3) { it.fileName }, + style = MaterialTheme.typography.bodySmall, + color = TelegramInkMuted, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + result.botMiniAppDeepLink()?.let { deepLink -> + TextButton(onClick = {}) { + Text("Мини-приложение: $deepLink", color = TelegramBlue) + } + } + if (result.chat.type == ChatType.Group) { + Text( + text = "Групповой звонок готов для голосовых и видеодействий.", + style = MaterialTheme.typography.bodySmall, + color = TelegramInkMuted + ) + } + } +} + +@Composable +private fun highlightedText(text: String, query: String) = buildAnnotatedString { + val needle = query.trim() + if (needle.length < 2) { + append(text) + return@buildAnnotatedString + } + + var currentIndex = 0 + while (currentIndex < text.length) { + val matchIndex = text.indexOf(needle, startIndex = currentIndex, ignoreCase = true) + if (matchIndex < 0) { + append(text.substring(currentIndex)) + break + } + append(text.substring(currentIndex, matchIndex)) + pushStyle( + SpanStyle( + color = MaterialTheme.colorScheme.primary, + fontWeight = FontWeight.SemiBold, + background = MaterialTheme.colorScheme.primary.copy(alpha = 0.14f) + ) + ) + append(text.substring(matchIndex, matchIndex + needle.length)) + pop() + currentIndex = matchIndex + needle.length + } +} + +private fun GlobalSearchDateFilter.label(): String = + when (this) { + GlobalSearchDateFilter.Anytime -> "Любая дата" + GlobalSearchDateFilter.Today -> "Сегодня" + GlobalSearchDateFilter.ThisWeek -> "Неделя" + GlobalSearchDateFilter.ThisMonth -> "Месяц" + } + +private fun GlobalSearchResultType.label(): String = + when (this) { + GlobalSearchResultType.All -> "Все типы" + GlobalSearchResultType.Chats -> "Чаты" + GlobalSearchResultType.Messages -> "Сообщения" + GlobalSearchResultType.Attachments -> "Вложения" + GlobalSearchResultType.Bots -> "Боты" + GlobalSearchResultType.GroupCalls -> "Звонки" + } + +private fun ChatSearchResultDto.botMiniAppDeepLink(): String? { + val bot = chat.participants.firstOrNull { it.isBot } ?: return null + return "ikar://bot/${bot.username}?start=miniapp" +} diff --git a/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/search/GlobalSearchViewModel.kt b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/search/GlobalSearchViewModel.kt new file mode 100644 index 00000000..ad355081 --- /dev/null +++ b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/search/GlobalSearchViewModel.kt @@ -0,0 +1,237 @@ +package com.seven.ikar.kotlin.feature.search + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.seven.ikar.kotlin.app.AppContainer +import com.seven.ikar.kotlin.core.model.ChatType +import com.seven.ikar.kotlin.core.model.ChatSearchResultDto +import java.text.SimpleDateFormat +import java.util.Calendar +import java.util.Locale +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch + +enum class GlobalSearchFilter(val wireValue: String?) { + All(null), + Media("media"), + Files("files"), + Links("links") +} + +enum class GlobalSearchDateFilter { + Anytime, + Today, + ThisWeek, + ThisMonth +} + +enum class GlobalSearchResultType { + All, + Chats, + Messages, + Attachments, + Bots, + GroupCalls +} + +data class SearchCapabilityHooks( + val supportsMediaDateGrouping: Boolean = true, + val supportsBotMiniAppPlaceholder: Boolean = true, + val supportsScheduledMessageOptions: Boolean = true, + val supportsGroupCallPlaceholder: Boolean = true +) + +data class GlobalSearchUiState( + val query: String = "", + val filter: GlobalSearchFilter = GlobalSearchFilter.All, + val dateFilter: GlobalSearchDateFilter = GlobalSearchDateFilter.Anytime, + val resultType: GlobalSearchResultType = GlobalSearchResultType.All, + val authorFilter: String = "", + val results: List<ChatSearchResultDto> = emptyList(), + val page: Int = 1, + val hasMore: Boolean = false, + val isSearching: Boolean = false, + val isLoadingMore: Boolean = false, + val errorMessage: String? = null, + val hooks: SearchCapabilityHooks = SearchCapabilityHooks() +) { + val visibleResults: List<ChatSearchResultDto> + get() = results.filter { result -> + result.matchesAuthor(authorFilter) && + result.matchesDate(dateFilter) && + result.matchesType(resultType) + } +} + +class GlobalSearchViewModel(private val container: AppContainer) : ViewModel() { + private val _uiState = MutableStateFlow(GlobalSearchUiState()) + val uiState: StateFlow<GlobalSearchUiState> = _uiState.asStateFlow() + private var searchJob: Job? = null + + fun updateQuery(value: String) { + _uiState.value = _uiState.value.copy(query = value) + scheduleSearch() + } + + fun updateFilter(filter: GlobalSearchFilter) { + _uiState.value = _uiState.value.copy(filter = filter) + scheduleSearch(immediate = true) + } + + fun updateDateFilter(filter: GlobalSearchDateFilter) { + _uiState.value = _uiState.value.copy(dateFilter = filter) + scheduleSearch(immediate = true) + } + + fun updateResultType(type: GlobalSearchResultType) { + _uiState.value = _uiState.value.copy(resultType = type) + scheduleSearch(immediate = true) + } + + fun updateAuthorFilter(value: String) { + _uiState.value = _uiState.value.copy(authorFilter = value) + scheduleSearch() + } + + fun refresh() { + scheduleSearch(immediate = true) + } + + fun loadMore() { + val state = _uiState.value + if (!state.hasMore || state.isSearching || state.isLoadingMore) { + return + } + scheduleSearch(immediate = true, append = true) + } + + private fun scheduleSearch(immediate: Boolean = false, append: Boolean = false) { + searchJob?.cancel() + val state = _uiState.value + val query = state.query.trim() + if (query.length < 2 && state.filter == GlobalSearchFilter.All) { + _uiState.value = state.copy(results = emptyList(), page = 1, hasMore = false, isSearching = false, isLoadingMore = false, errorMessage = null) + return + } + + searchJob = viewModelScope.launch { + if (!immediate) { + delay(250) + } + val requestState = _uiState.value + val targetPage = if (append) requestState.page + 1 else 1 + val dateRange = requestState.dateFilter.toServerRange() + _uiState.value = requestState.copy( + isSearching = !append, + isLoadingMore = append, + errorMessage = null + ) + runCatching { + container.executeAuthorized { session -> + container.apiClient.searchChatsPage( + accessToken = session.accessToken, + query = requestState.query.trim().ifBlank { " " }, + filter = requestState.filter.wireValue, + type = requestState.resultType.serverType(), + author = requestState.authorFilter.trim().takeIf { it.isNotBlank() }, + dateFrom = dateRange?.first, + dateTo = dateRange?.second, + page = targetPage, + limit = 40 + ) + } + }.onSuccess { page -> + val currentResults = if (append) _uiState.value.results else emptyList() + _uiState.value = _uiState.value.copy( + results = (currentResults + page.results).distinctBy { "${it.chat.id}:${it.message?.id.orEmpty()}:${it.matchText}" }, + page = page.page, + hasMore = page.hasMore, + isSearching = false, + isLoadingMore = false + ) + }.onFailure { error -> + _uiState.value = _uiState.value.copy(isSearching = false, isLoadingMore = false, errorMessage = error.message) + } + } + } +} + +private fun GlobalSearchResultType.serverType(): String? = + when (this) { + GlobalSearchResultType.Messages -> "text" + GlobalSearchResultType.Attachments -> "attachments" + else -> null + } + +private fun GlobalSearchDateFilter.toServerRange(): Pair<String, String>? = + when (this) { + GlobalSearchDateFilter.Anytime -> null + GlobalSearchDateFilter.Today -> dateKey(0).toDayRange() + GlobalSearchDateFilter.ThisWeek -> offsetDateKey(-6).toDateStart() to todayKey().toDateEnd() + GlobalSearchDateFilter.ThisMonth -> todayKey().take(8).let { prefix -> + "${prefix}01".toDateStart() to todayKey().toDateEnd() + } + } + +private fun String.toDayRange(): Pair<String, String> = toDateStart() to toDateEnd() + +private fun String.toDateStart(): String = "${this}T00:00:00Z" + +private fun String.toDateEnd(): String = "${this}T23:59:59Z" + +private fun ChatSearchResultDto.matchesAuthor(authorFilter: String): Boolean { + val query = authorFilter.trim() + if (query.isBlank()) { + return true + } + + val sender = message?.sender ?: return false + return sender.displayName.contains(query, ignoreCase = true) || + sender.username.contains(query, ignoreCase = true) || + (sender.phoneNumber?.contains(query, ignoreCase = true) ?: false) +} + +private fun ChatSearchResultDto.matchesDate(filter: GlobalSearchDateFilter): Boolean { + if (filter == GlobalSearchDateFilter.Anytime) { + return true + } + + val dateKey = searchDateKey() ?: return false + val today = todayKey() + return when (filter) { + GlobalSearchDateFilter.Anytime -> true + GlobalSearchDateFilter.Today -> dateKey == today + GlobalSearchDateFilter.ThisWeek -> dateKey >= offsetDateKey(-6) && dateKey <= today + GlobalSearchDateFilter.ThisMonth -> dateKey.take(7) == today.take(7) + } +} + +private fun ChatSearchResultDto.matchesType(type: GlobalSearchResultType): Boolean = + when (type) { + GlobalSearchResultType.All -> true + GlobalSearchResultType.Chats -> message == null + GlobalSearchResultType.Messages -> message != null + GlobalSearchResultType.Attachments -> message?.attachments?.isNotEmpty() == true + GlobalSearchResultType.Bots -> chat.participants.any { it.isBot } + GlobalSearchResultType.GroupCalls -> chat.type == ChatType.Group + } + +internal fun ChatSearchResultDto.searchDateGroupLabel(): String = + searchDateKey() ?: "No date" + +private fun ChatSearchResultDto.searchDateKey(): String? = + (matchedAt ?: message?.sentAt ?: chat.lastActivityAt)?.takeIf { it.length >= 10 }?.take(10) + +private fun todayKey(): String = dateKey(0) + +private fun offsetDateKey(days: Int): String = dateKey(days) + +private fun dateKey(daysFromToday: Int): String { + val calendar = Calendar.getInstance() + calendar.add(Calendar.DAY_OF_YEAR, daysFromToday) + return SimpleDateFormat("yyyy-MM-dd", Locale.US).format(calendar.time) +} diff --git a/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/settings/AppSettingsViewModel.kt b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/settings/AppSettingsViewModel.kt new file mode 100644 index 00000000..aa6c437a --- /dev/null +++ b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/settings/AppSettingsViewModel.kt @@ -0,0 +1,35 @@ +package com.seven.ikar.kotlin.feature.settings + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.seven.ikar.kotlin.app.AppContainer +import com.seven.ikar.kotlin.core.settings.AppSettingsSnapshot +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch + +data class AppSettingsUiState( + val snapshot: AppSettingsSnapshot = AppSettingsSnapshot() +) + +class AppSettingsViewModel(private val container: AppContainer) : ViewModel() { + private val _uiState = MutableStateFlow(AppSettingsUiState(snapshot = container.appSettings.loadSnapshot())) + val uiState: StateFlow<AppSettingsUiState> = _uiState.asStateFlow() + + init { + viewModelScope.launch { + container.appSettings.snapshot.collect { snapshot -> + _uiState.value = AppSettingsUiState(snapshot = snapshot) + } + } + } + + fun updateSnapshot(transform: (AppSettingsSnapshot) -> AppSettingsSnapshot) { + container.appSettings.update(transform) + } + + fun reset() { + container.appSettings.reset() + } +} diff --git a/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/settings/ChatNotificationSettingsScreen.kt b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/settings/ChatNotificationSettingsScreen.kt new file mode 100644 index 00000000..4c8abde7 --- /dev/null +++ b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/settings/ChatNotificationSettingsScreen.kt @@ -0,0 +1,233 @@ +package com.seven.ikar.kotlin.feature.settings + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.WindowInsetsSides +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.only +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.safeDrawing +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.outlined.VolumeUp +import androidx.compose.material.icons.outlined.ChatBubbleOutline +import androidx.compose.material.icons.outlined.Notifications +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.lifecycle.ViewModel +import com.seven.ikar.kotlin.app.AppContainer +import com.seven.ikar.kotlin.core.push.ChatNotificationSettingsSnapshot +import com.seven.ikar.kotlin.core.push.NotificationPreferenceCatalog +import com.seven.ikar.kotlin.core.push.NotificationSettingsSnapshot +import com.seven.ikar.kotlin.core.push.NotificationSoundChoice +import com.seven.ikar.kotlin.core.push.NotificationSoundOption +import com.seven.ikar.kotlin.ui.IkarRowDivider +import com.seven.ikar.kotlin.ui.IkarScreenSurface +import com.seven.ikar.kotlin.ui.IkarSectionCard +import com.seven.ikar.kotlin.ui.IkarSectionTitle +import com.seven.ikar.kotlin.ui.IkarSimpleHeader +import com.seven.ikar.kotlin.ui.IkarToggleRow +import com.seven.ikar.kotlin.ui.IkarValueRow +import com.seven.ikar.kotlin.ui.theme.TelegramBlue +import com.seven.ikar.kotlin.ui.theme.TelegramDanger +import com.seven.ikar.kotlin.ui.theme.TelegramInkMuted +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow + +data class ChatNotificationSettingsUiState( + val chatId: String, + val chatTitle: String, + val snapshot: ChatNotificationSettingsSnapshot, + val globalSnapshot: NotificationSettingsSnapshot, + val errorMessage: String? = null, + val infoMessage: String? = null +) + +class ChatNotificationSettingsViewModel( + private val container: AppContainer, + private val chatId: String, + chatTitle: String +) : ViewModel() { + private val _uiState = MutableStateFlow( + ChatNotificationSettingsUiState( + chatId = chatId, + chatTitle = chatTitle, + snapshot = container.pushSettings.loadChatNotificationSnapshot(chatId), + globalSnapshot = container.pushSettings.loadSnapshot() + ) + ) + val uiState: StateFlow<ChatNotificationSettingsUiState> = _uiState.asStateFlow() + + val messageSoundOptions: List<NotificationSoundChoice> = NotificationPreferenceCatalog.messageSoundChoices + + fun updateSnapshot(transform: (ChatNotificationSettingsSnapshot) -> ChatNotificationSettingsSnapshot) { + val nextSnapshot = transform(_uiState.value.snapshot) + container.pushSettings.saveChatNotificationSnapshot(chatId, nextSnapshot) + _uiState.value = _uiState.value.copy( + snapshot = nextSnapshot, + infoMessage = null, + errorMessage = null + ) + } + + fun setMessageSound(value: NotificationSoundOption?) = updateSnapshot { it.copy(messageSound = value) } + + fun reset() { + container.pushSettings.resetChatNotificationPreferences(chatId) + _uiState.value = _uiState.value.copy( + snapshot = container.pushSettings.loadChatNotificationSnapshot(chatId), + infoMessage = "Для этого чата снова используются общие настройки.", + errorMessage = null + ) + } +} + +@Composable +fun ChatNotificationSettingsScreen( + state: ChatNotificationSettingsUiState, + messageSoundOptions: List<NotificationSoundChoice>, + onBackClick: () -> Unit, + onSnapshotChanged: (ChatNotificationSettingsSnapshot) -> Unit, + onMessageSoundChanged: (NotificationSoundOption?) -> Unit, + onResetClick: () -> Unit +) { + val snapshot = state.snapshot + val global = state.globalSnapshot + val effectiveNotificationsEnabled = snapshot.notificationsEnabled ?: global.notificationsEnabled + val effectiveShowPreview = snapshot.showMessagePreview ?: global.inAppShowTextEnabled + val effectivePopupWindows = snapshot.popupWindowsEnabled ?: global.popupWindowsEnabled + val effectiveSoundInActiveChat = snapshot.soundInActiveChatEnabled ?: global.soundInActiveChatEnabled + + IkarScreenSurface(modifier = Modifier.fillMaxSize()) { + Column( + modifier = Modifier + .fillMaxSize() + .windowInsetsPadding(WindowInsets.safeDrawing.only(WindowInsetsSides.Vertical)) + .verticalScroll(rememberScrollState()) + .padding(horizontal = 18.dp, vertical = 16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + IkarSimpleHeader(title = "Уведомления чата", onBackClick = onBackClick) + + ChatNotificationSection { + val chatLabel = state.chatTitle.ifBlank { "Текущий чат" } + IkarSectionTitle("Для этого чата", modifier = Modifier.padding(bottom = 12.dp)) + Text( + text = chatLabel, + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.padding(bottom = 2.dp) + ) + Text( + text = "Изменения применяются сразу.", + style = MaterialTheme.typography.bodyMedium, + color = TelegramInkMuted, + modifier = Modifier.padding(bottom = 10.dp) + ) + IkarToggleRow( + title = "Уведомления", + subtitle = overrideModeLabel(snapshot.notificationsEnabled), + checked = effectiveNotificationsEnabled, + onCheckedChange = { onSnapshotChanged(snapshot.copy(notificationsEnabled = it)) }, + leading = { Icon(Icons.Outlined.Notifications, contentDescription = null, tint = TelegramInkMuted) } + ) + IkarRowDivider(startIndent = 32.dp) + IkarValueRow( + title = "Звук сообщений", + subtitle = overrideModeLabel(snapshot.messageSound), + value = soundCurrentLabel(snapshot.messageSound, global.messageSound, messageSoundOptions), + onClick = { + val values = listOf<NotificationSoundOption?>(null) + messageSoundOptions.map { it.value } + val currentIndex = values.indexOf(snapshot.messageSound).coerceAtLeast(0) + onMessageSoundChanged(values[(currentIndex + 1) % values.size]) + }, + leading = { Icon(Icons.AutoMirrored.Outlined.VolumeUp, contentDescription = null, tint = TelegramInkMuted) } + ) + } + + ChatNotificationSection { + IkarSectionTitle("Поведение", modifier = Modifier.padding(bottom = 12.dp)) + IkarToggleRow( + title = "Показывать текст", + subtitle = overrideModeLabel(snapshot.showMessagePreview), + checked = effectiveShowPreview, + onCheckedChange = { onSnapshotChanged(snapshot.copy(showMessagePreview = it)) }, + leading = { Icon(Icons.Outlined.ChatBubbleOutline, contentDescription = null, tint = TelegramInkMuted) } + ) + IkarRowDivider(startIndent = 32.dp) + IkarToggleRow( + title = "Всплывающие окна", + subtitle = overrideModeLabel(snapshot.popupWindowsEnabled), + checked = effectivePopupWindows, + onCheckedChange = { onSnapshotChanged(snapshot.copy(popupWindowsEnabled = it)) } + ) + IkarRowDivider(startIndent = 0.dp) + IkarToggleRow( + title = "Звук в чате", + subtitle = overrideModeLabel(snapshot.soundInActiveChatEnabled), + checked = effectiveSoundInActiveChat, + onCheckedChange = { onSnapshotChanged(snapshot.copy(soundInActiveChatEnabled = it)) } + ) + } + + ChatNotificationSection { + IkarSectionTitle("Сброс", modifier = Modifier.padding(bottom = 12.dp)) + Text( + "Использовать общие настройки", + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 4.dp) + .clickable(onClick = onResetClick), + style = MaterialTheme.typography.titleLarge + ) + Text( + "Вернуть все пункты этого экрана к общим настройкам.", + style = MaterialTheme.typography.bodySmall, + color = TelegramInkMuted + ) + } + + state.errorMessage?.let { + Text(it, color = TelegramDanger, style = MaterialTheme.typography.bodyMedium) + } + state.infoMessage?.let { + Text(it, color = TelegramBlue, style = MaterialTheme.typography.bodyMedium) + } + } + } +} + +@Composable +private fun ChatNotificationSection( + content: @Composable ColumnScope.() -> Unit +) { + IkarSectionCard { + Column(content = content) + } +} + +private fun overrideModeLabel(value: Any?): String = + if (value == null) "Общие настройки" else "Персонально" + +private fun soundCurrentLabel( + value: NotificationSoundOption?, + globalValue: NotificationSoundOption, + options: List<NotificationSoundChoice> +): String { + val currentValue = value ?: globalValue + return soundTitle(currentValue, options) +} + +private fun soundTitle(value: NotificationSoundOption, options: List<NotificationSoundChoice>): String = + options.firstOrNull { it.value == value }?.title ?: "По умолчанию" diff --git a/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/settings/ChatSettingsScreen.kt b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/settings/ChatSettingsScreen.kt new file mode 100644 index 00000000..b0b4d01d --- /dev/null +++ b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/settings/ChatSettingsScreen.kt @@ -0,0 +1,358 @@ +package com.seven.ikar.kotlin.feature.settings + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.WindowInsetsSides +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.only +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.safeDrawing +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Slider +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.seven.ikar.kotlin.ui.ChatWallpaperBackground +import com.seven.ikar.kotlin.core.settings.ChatDistanceUnitOption +import com.seven.ikar.kotlin.core.settings.ChatSettingsSnapshot +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Image +import androidx.compose.material3.Icon +import com.seven.ikar.kotlin.ui.IkarRowDivider +import com.seven.ikar.kotlin.ui.IkarScreenSurface +import com.seven.ikar.kotlin.ui.IkarSectionCard +import com.seven.ikar.kotlin.ui.IkarSectionTitle +import com.seven.ikar.kotlin.ui.IkarSimpleHeader +import com.seven.ikar.kotlin.ui.IkarToggleRow +import com.seven.ikar.kotlin.ui.IkarValueRow +import com.seven.ikar.kotlin.ui.theme.TelegramBlue +import com.seven.ikar.kotlin.ui.theme.TelegramInk +import com.seven.ikar.kotlin.ui.theme.TelegramInkMuted +import kotlin.math.roundToInt + +@Composable +fun ChatSettingsScreen( + state: ChatSettingsUiState, + onBackClick: () -> Unit, + onSnapshotChanged: (ChatSettingsSnapshot) -> Unit, + onWallpaperClick: () -> Unit, + onResetClick: () -> Unit +) { + val snapshot = state.snapshot + + IkarScreenSurface(modifier = Modifier.fillMaxSize()) { + Column( + modifier = Modifier + .fillMaxSize() + .windowInsetsPadding(WindowInsets.safeDrawing.only(WindowInsetsSides.Vertical)) + .verticalScroll(rememberScrollState()) + .padding(horizontal = 18.dp, vertical = 16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + IkarSimpleHeader(title = "Настройки чатов", onBackClick = onBackClick) + + ChatSettingsSection { + IkarSectionTitle("Оформление", modifier = Modifier.padding(bottom = 12.dp)) + SliderSettingBlock( + title = "Размер текста сообщений", + value = snapshot.messageTextSizeSp, + range = 14..26, + previewMessageTextSizeSp = snapshot.messageTextSizeSp, + previewBubbleCornerRadiusDp = snapshot.bubbleCornerRadiusDp, + wallpaperSnapshot = snapshot, + onValueChange = { value -> + onSnapshotChanged(snapshot.copy(messageTextSizeSp = value)) + } + ) + IkarRowDivider(startIndent = 0.dp) + SliderSettingBlock( + title = "Углы блоков с сообщениями", + value = snapshot.bubbleCornerRadiusDp, + range = 4..24, + previewMessageTextSizeSp = snapshot.messageTextSizeSp, + previewBubbleCornerRadiusDp = snapshot.bubbleCornerRadiusDp, + wallpaperSnapshot = snapshot, + onValueChange = { value -> + onSnapshotChanged(snapshot.copy(bubbleCornerRadiusDp = value)) + } + ) + IkarRowDivider(startIndent = 0.dp) + IkarValueRow( + title = "Изменить обои", + subtitle = "Выбрать фон ленты чатов из галереи или готовых вариантов.", + value = "Открыть", + onClick = onWallpaperClick, + leading = { + Icon( + imageVector = Icons.Outlined.Image, + contentDescription = null, + tint = TelegramBlue + ) + } + ) + } + + ChatSettingsSection { + IkarSectionTitle("Медиафайлы и звук", modifier = Modifier.padding(bottom = 12.dp)) + IkarToggleRow( + title = "Листать медиафайлы по нажатию", + subtitle = "Листать нажатием у края экрана в режиме просмотра медиафайлов.", + checked = snapshot.mediaTapToBrowseEnabled, + onCheckedChange = { onSnapshotChanged(snapshot.copy(mediaTapToBrowseEnabled = it)) } + ) + IkarRowDivider(startIndent = 0.dp) + IkarToggleRow( + title = "Поднести и слушать", + subtitle = "Переключаться на верхний динамик, когда вы подносите телефон к уху.", + checked = snapshot.raiseToListenEnabled, + onCheckedChange = { onSnapshotChanged(snapshot.copy(raiseToListenEnabled = it)) } + ) + IkarRowDivider(startIndent = 0.dp) + IkarToggleRow( + title = "Поднести и говорить", + subtitle = "Записывать голосовое сообщение, когда вы подносите телефон к уху.", + checked = snapshot.raiseToTalkEnabled, + onCheckedChange = { onSnapshotChanged(snapshot.copy(raiseToTalkEnabled = it)) } + ) + IkarRowDivider(startIndent = 0.dp) + IkarToggleRow( + title = "Пауза музыки при записи", + subtitle = "Останавливать музыку на время записи голосового сообщения.", + checked = snapshot.pauseMusicOnRecordEnabled, + onCheckedChange = { onSnapshotChanged(snapshot.copy(pauseMusicOnRecordEnabled = it)) } + ) + IkarRowDivider(startIndent = 0.dp) + IkarToggleRow( + title = "Пауза музыки при запуске медиа", + subtitle = "Останавливать фоновую музыку при воспроизведении медиафайлов в чате.", + checked = snapshot.pauseMusicOnPlaybackEnabled, + onCheckedChange = { onSnapshotChanged(snapshot.copy(pauseMusicOnPlaybackEnabled = it)) } + ) + IkarRowDivider(startIndent = 0.dp) + IkarValueRow( + title = "Микрофон", + subtitle = "Источник записи голосовых сообщений.", + value = "По умолчанию", + enabled = false + ) + } + + ChatSettingsSection { + IkarSectionTitle("Прочие настройки", modifier = Modifier.padding(bottom = 12.dp)) + IkarToggleRow( + title = "Смена темы ночью", + subtitle = "Автоматически включать ночную тему по локальному сценарию.", + checked = snapshot.autoNightThemeEnabled, + onCheckedChange = { onSnapshotChanged(snapshot.copy(autoNightThemeEnabled = it)) } + ) + IkarRowDivider(startIndent = 0.dp) + IkarToggleRow( + title = "Встроенный браузер", + subtitle = "Открывать ссылки внутри приложения, если доступен встроенный просмотр.", + checked = snapshot.inAppBrowserEnabled, + onCheckedChange = { onSnapshotChanged(snapshot.copy(inAppBrowserEnabled = it)) } + ) + IkarRowDivider(startIndent = 0.dp) + IkarToggleRow( + title = "Анимации", + subtitle = "Использовать анимации интерфейса и медиа-переходов.", + checked = snapshot.animationsEnabled, + onCheckedChange = { onSnapshotChanged(snapshot.copy(animationsEnabled = it)) } + ) + IkarRowDivider(startIndent = 0.dp) + IkarToggleRow( + title = "Прямая отправка", + subtitle = "Показывать недавние чаты в системном меню «Поделиться».", + checked = snapshot.directShareEnabled, + onCheckedChange = { onSnapshotChanged(snapshot.copy(directShareEnabled = it)) } + ) + IkarRowDivider(startIndent = 0.dp) + IkarToggleRow( + title = "Показывать материалы 18+", + subtitle = "Не скрывать чувствительные медиафайлы.", + checked = snapshot.showSensitiveContentEnabled, + onCheckedChange = { onSnapshotChanged(snapshot.copy(showSensitiveContentEnabled = it)) } + ) + IkarRowDivider(startIndent = 0.dp) + IkarToggleRow( + title = "Отправка по Вводу", + subtitle = "Использовать действие отправки вместо перевода строки, если клавиатура поддерживает «Отправить».", + checked = snapshot.sendByEnterEnabled, + onCheckedChange = { onSnapshotChanged(snapshot.copy(sendByEnterEnabled = it)) } + ) + IkarRowDivider(startIndent = 0.dp) + IkarValueRow( + title = "Мера расстояния", + subtitle = "Единицы для геоданных и расстояний в интерфейсе.", + value = snapshot.distanceUnit.title, + onClick = { + onSnapshotChanged(snapshot.copy(distanceUnit = snapshot.distanceUnit.next())) + } + ) + } + + Text( + text = "Сбросить настройки чатов", + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onResetClick) + .padding(vertical = 4.dp), + style = MaterialTheme.typography.titleLarge, + color = TelegramBlue, + fontWeight = FontWeight.SemiBold + ) + } + } +} + +@Composable +private fun ChatSettingsSection( + content: @Composable ColumnScope.() -> Unit +) { + IkarSectionCard { + Column(content = content) + } +} + +@Composable +private fun SliderSettingBlock( + title: String, + value: Int, + range: IntRange, + previewMessageTextSizeSp: Int, + previewBubbleCornerRadiusDp: Int, + wallpaperSnapshot: ChatSettingsSnapshot, + onValueChange: (Int) -> Unit +) { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text(title, style = MaterialTheme.typography.titleLarge) + Text( + text = value.toString(), + style = MaterialTheme.typography.titleMedium, + color = TelegramBlue + ) + } + Slider( + value = value.toFloat(), + onValueChange = { onValueChange(it.roundToInt().coerceIn(range.first, range.last)) }, + valueRange = range.first.toFloat()..range.last.toFloat(), + steps = (range.last - range.first - 1).coerceAtLeast(0) + ) + ChatStylePreview( + messageTextSizeSp = previewMessageTextSizeSp, + bubbleCornerRadiusDp = previewBubbleCornerRadiusDp, + wallpaperSnapshot = wallpaperSnapshot + ) + } +} + +@Composable +private fun ChatStylePreview( + messageTextSizeSp: Int, + bubbleCornerRadiusDp: Int, + wallpaperSnapshot: ChatSettingsSnapshot +) { + val bubbleRadius = bubbleCornerRadiusDp.dp + Box( + modifier = Modifier + .fillMaxWidth() + .background(Color(0xFFE7F6F0), RoundedCornerShape(20.dp)) + ) { + Box( + modifier = Modifier + .fillMaxSize() + .background(Color.White.copy(alpha = 0.08f), RoundedCornerShape(20.dp)) + ) + Box( + modifier = Modifier + .fillMaxSize() + .background(Color.Transparent, RoundedCornerShape(20.dp)) + ) { + ChatWallpaperBackground( + snapshot = wallpaperSnapshot, + modifier = Modifier.fillMaxSize() + ) + } + Column( + modifier = Modifier + .fillMaxSize() + .background(Color.White.copy(alpha = 0.12f), RoundedCornerShape(20.dp)) + .padding(12.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.Start + ) { + Text( + text = "Доброе утро!", + modifier = Modifier + .background(Color(0xFFFBE8EC), RoundedCornerShape(bubbleRadius)) + .padding(horizontal = 12.dp, vertical = 8.dp), + style = MaterialTheme.typography.bodyLarge.copy(fontSize = messageTextSizeSp.sp), + color = TelegramInk + ) + } + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.End + ) { + Text( + text = "Знаю, который час?", + modifier = Modifier + .background(Color.White, RoundedCornerShape(bubbleRadius)) + .padding(horizontal = 12.dp, vertical = 8.dp), + style = MaterialTheme.typography.bodyLarge.copy(fontSize = messageTextSizeSp.sp), + color = TelegramInk + ) + } + } + } +} + +@Composable +private fun BoxLine(width: Dp) { + Row( + modifier = Modifier + .width(width) + .background(Color(0xFFDCE4EA), RoundedCornerShape(999.dp)) + .padding(vertical = 2.dp) + ) {} +} + +private val ChatDistanceUnitOption.title: String + get() = when (this) { + ChatDistanceUnitOption.Automatic -> "Автоматически" + ChatDistanceUnitOption.Kilometers -> "Километры" + ChatDistanceUnitOption.Miles -> "Мили" + } + +private fun ChatDistanceUnitOption.next(): ChatDistanceUnitOption = when (this) { + ChatDistanceUnitOption.Automatic -> ChatDistanceUnitOption.Kilometers + ChatDistanceUnitOption.Kilometers -> ChatDistanceUnitOption.Miles + ChatDistanceUnitOption.Miles -> ChatDistanceUnitOption.Automatic +} + diff --git a/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/settings/ChatSettingsViewModel.kt b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/settings/ChatSettingsViewModel.kt new file mode 100644 index 00000000..bde36662 --- /dev/null +++ b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/settings/ChatSettingsViewModel.kt @@ -0,0 +1,35 @@ +package com.seven.ikar.kotlin.feature.settings + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.seven.ikar.kotlin.app.AppContainer +import com.seven.ikar.kotlin.core.settings.ChatSettingsSnapshot +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch + +data class ChatSettingsUiState( + val snapshot: ChatSettingsSnapshot = ChatSettingsSnapshot() +) + +class ChatSettingsViewModel(private val container: AppContainer) : ViewModel() { + private val _uiState = MutableStateFlow(ChatSettingsUiState(snapshot = container.chatSettings.loadSnapshot())) + val uiState: StateFlow<ChatSettingsUiState> = _uiState.asStateFlow() + + init { + viewModelScope.launch { + container.chatSettings.snapshot.collect { snapshot -> + _uiState.value = ChatSettingsUiState(snapshot = snapshot) + } + } + } + + fun updateSnapshot(transform: (ChatSettingsSnapshot) -> ChatSettingsSnapshot) { + container.chatSettings.update(transform) + } + + fun reset() { + container.chatSettings.reset() + } +} diff --git a/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/settings/ChatWallpaperScreen.kt b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/settings/ChatWallpaperScreen.kt new file mode 100644 index 00000000..0942cdc6 --- /dev/null +++ b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/settings/ChatWallpaperScreen.kt @@ -0,0 +1,216 @@ +package com.seven.ikar.kotlin.feature.settings + +import android.content.Intent +import android.net.Uri +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.WindowInsetsSides +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.only +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.safeDrawing +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.CheckCircle +import androidx.compose.material.icons.outlined.Image +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.seven.ikar.kotlin.core.settings.ChatSettingsSnapshot +import com.seven.ikar.kotlin.ui.AuthenticatedAsyncImage +import com.seven.ikar.kotlin.ui.ChatWallpaperCatalog +import com.seven.ikar.kotlin.ui.IkarScreenSurface +import com.seven.ikar.kotlin.ui.IkarSectionCard +import com.seven.ikar.kotlin.ui.IkarSimpleHeader +import com.seven.ikar.kotlin.ui.IkarValueRow +import com.seven.ikar.kotlin.ui.PresetWallpaperBackground +import com.seven.ikar.kotlin.ui.theme.TelegramBlue +import com.seven.ikar.kotlin.ui.theme.TelegramInkMuted + +@Composable +fun ChatWallpaperScreen( + snapshot: ChatSettingsSnapshot, + onBackClick: () -> Unit, + onPresetSelected: (String) -> Unit, + onCustomWallpaperSelected: (Uri) -> Unit, + onUseCustomWallpaper: () -> Unit +) { + val context = LocalContext.current + val presets = remember { ChatWallpaperCatalog.presets } + val wallpaperPicker = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { uri -> + if (uri != null) { + runCatching { + context.contentResolver.takePersistableUriPermission( + uri, + Intent.FLAG_GRANT_READ_URI_PERMISSION + ) + } + onCustomWallpaperSelected(uri) + } + } + + IkarScreenSurface(modifier = Modifier.fillMaxSize()) { + Column( + modifier = Modifier + .fillMaxSize() + .windowInsetsPadding(WindowInsets.safeDrawing.only(WindowInsetsSides.Vertical)) + .padding(horizontal = 18.dp, vertical = 16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + IkarSimpleHeader( + title = "Обои чата", + onBackClick = onBackClick + ) + + IkarSectionCard { + IkarValueRow( + title = "Выбрать из галереи", + subtitle = "Поставить свою картинку как фон ленты чатов.", + value = "Открыть", + onClick = { wallpaperPicker.launch(arrayOf("image/*")) }, + leading = { + Icon( + imageVector = Icons.Outlined.Image, + contentDescription = null, + tint = TelegramBlue + ) + } + ) + } + + Text( + text = "Готовые обои", + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.SemiBold + ) + + LazyVerticalGrid( + columns = GridCells.Fixed(3), + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(bottom = 24.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + snapshot.customWallpaperUri?.takeIf { it.isNotBlank() }?.let { customWallpaperUri -> + item(key = "custom-wallpaper") { + WallpaperChoiceCard( + title = "Мои обои", + isSelected = snapshot.useCustomWallpaper, + onClick = onUseCustomWallpaper + ) { + AuthenticatedAsyncImage( + imageUrl = customWallpaperUri, + contentDescription = "Пользовательские обои", + modifier = Modifier.fillMaxSize(), + contentScale = ContentScale.Crop + ) + } + } + } + + items(items = presets, key = { it.id }) { preset -> + WallpaperChoiceCard( + title = preset.title, + isSelected = !snapshot.useCustomWallpaper && snapshot.selectedWallpaperPresetId == preset.id, + onClick = { onPresetSelected(preset.id) } + ) { + PresetWallpaperBackground( + preset = preset, + modifier = Modifier.fillMaxSize() + ) + } + } + } + } + } +} + +@Composable +private fun WallpaperChoiceCard( + title: String, + isSelected: Boolean, + onClick: () -> Unit, + preview: @Composable BoxScope.() -> Unit +) { + Surface( + modifier = Modifier + .fillMaxWidth() + .aspectRatio(0.78f) + .clickable(onClick = onClick), + shape = RoundedCornerShape(20.dp), + color = MaterialTheme.colorScheme.surface, + border = BorderStroke( + width = if (isSelected) 2.dp else 1.dp, + color = if (isSelected) TelegramBlue else MaterialTheme.colorScheme.outline.copy(alpha = 0.32f) + ), + shadowElevation = if (isSelected) 8.dp else 4.dp + ) { + Column( + modifier = Modifier.padding(8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Box( + modifier = Modifier + .fillMaxWidth() + .height(96.dp) + .clip(RoundedCornerShape(14.dp)), + contentAlignment = Alignment.TopEnd + ) { + preview() + if (isSelected) { + Surface( + modifier = Modifier + .padding(8.dp) + .size(22.dp), + shape = RoundedCornerShape(999.dp), + color = TelegramBlue, + shadowElevation = 3.dp + ) { + Box(contentAlignment = Alignment.Center) { + Icon( + imageVector = Icons.Outlined.CheckCircle, + contentDescription = "Обои выбраны", + tint = MaterialTheme.colorScheme.surface, + modifier = Modifier.size(16.dp) + ) + } + } + } + } + Text( + text = title, + style = MaterialTheme.typography.bodySmall, + color = if (isSelected) MaterialTheme.colorScheme.onSurface else TelegramInkMuted, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + } +} diff --git a/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/settings/DataStorageSettingsScreen.kt b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/settings/DataStorageSettingsScreen.kt new file mode 100644 index 00000000..4971c677 --- /dev/null +++ b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/settings/DataStorageSettingsScreen.kt @@ -0,0 +1,145 @@ +package com.seven.ikar.kotlin.feature.settings + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.WindowInsetsSides +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.only +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.safeDrawing +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Button +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.seven.ikar.kotlin.core.settings.AppSettingsSnapshot +import com.seven.ikar.kotlin.ui.IkarRowDivider +import com.seven.ikar.kotlin.ui.IkarScreenSurface +import com.seven.ikar.kotlin.ui.IkarSectionCard +import com.seven.ikar.kotlin.ui.IkarSectionTitle +import com.seven.ikar.kotlin.ui.IkarSimpleHeader +import com.seven.ikar.kotlin.ui.IkarToggleRow +import com.seven.ikar.kotlin.ui.IkarValueRow +import com.seven.ikar.kotlin.ui.theme.TelegramInkMuted + +@Composable +fun DataStorageSettingsScreen( + state: AppSettingsUiState, + onBackClick: () -> Unit, + onSnapshotChanged: ((AppSettingsSnapshot) -> AppSettingsSnapshot) -> Unit, + onProxyClick: () -> Unit, + onResetClick: () -> Unit +) { + val snapshot = state.snapshot + IkarScreenSurface(modifier = Modifier.fillMaxSize()) { + Column( + modifier = Modifier + .fillMaxSize() + .windowInsetsPadding(WindowInsets.safeDrawing.only(WindowInsetsSides.Vertical)) + .verticalScroll(rememberScrollState()) + .padding(horizontal = 18.dp, vertical = 16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + IkarSimpleHeader(title = "Данные и память", onBackClick = onBackClick) + + IkarSectionCard { + IkarSectionTitle("Использование памяти", modifier = Modifier.padding(bottom = 12.dp)) + IkarValueRow( + "Кэш Ikar", + "Лимит локальных медиа и превью на устройстве.", + "${snapshot.maxCacheMb} МБ", + onClick = { onSnapshotChanged { it.copy(maxCacheMb = nextCacheLimit(it.maxCacheMb)) } } + ) + IkarRowDivider(startIndent = 0.dp) + IkarValueRow( + "Хранить медиа", + "Срок хранения локальных файлов перед LRU-очисткой.", + "${snapshot.keepMediaDays} дн.", + onClick = { onSnapshotChanged { it.copy(keepMediaDays = nextKeepDays(it.keepMediaDays)) } } + ) + Text( + "Параметры сохраняются локально и готовы для подключения к TransferQueue/LRU-очистке.", + modifier = Modifier.padding(top = 8.dp), + style = MaterialTheme.typography.bodySmall, + color = TelegramInkMuted + ) + } + + IkarSectionCard { + IkarSectionTitle("Автозагрузка медиа", modifier = Modifier.padding(bottom = 12.dp)) + IkarToggleRow( + "Фото", + "Автоматически загружать фото в чатах.", + snapshot.autoDownloadPhotos, + onCheckedChange = { value -> onSnapshotChanged { it.copy(autoDownloadPhotos = value) } } + ) + IkarRowDivider(startIndent = 0.dp) + IkarToggleRow( + "Видео", + "Автоматически загружать видео только по Wi-Fi.", + snapshot.autoDownloadVideosOnWifi, + onCheckedChange = { value -> onSnapshotChanged { it.copy(autoDownloadVideosOnWifi = value) } } + ) + IkarRowDivider(startIndent = 0.dp) + IkarToggleRow( + "Файлы", + "Автозагрузка документов только по Wi-Fi.", + snapshot.autoDownloadFilesOnWifi, + onCheckedChange = { value -> onSnapshotChanged { it.copy(autoDownloadFilesOnWifi = value) } } + ) + IkarRowDivider(startIndent = 0.dp) + IkarToggleRow( + "Сохранять в галерею", + "Добавлять новые медиа в системную галерею.", + snapshot.saveMediaToGallery, + onCheckedChange = { value -> onSnapshotChanged { it.copy(saveMediaToGallery = value) } } + ) + } + + IkarSectionCard { + IkarSectionTitle("Сеть", modifier = Modifier.padding(bottom = 12.dp)) + IkarToggleRow( + "Меньше данных для звонков", + "Снижать битрейт звонков в мобильной сети.", + snapshot.lowDataCalls, + onCheckedChange = { value -> onSnapshotChanged { it.copy(lowDataCalls = value) } } + ) + IkarRowDivider(startIndent = 0.dp) + IkarValueRow( + "Прокси", + "SOCKS/MTProto/HTTP профиль для сетевого слоя.", + if (snapshot.proxyEnabled) "Вкл." else "Выкл.", + onClick = onProxyClick + ) + } + + Button(onClick = onResetClick, modifier = Modifier.fillMaxWidth()) { + Text("Сбросить настройки") + } + Button(onClick = onBackClick, modifier = Modifier.fillMaxWidth()) { + Text("Готово") + } + } + } +} + +private fun nextCacheLimit(current: Int): Int = when { + current < 512 -> 512 + current < 1024 -> 1024 + current < 4096 -> 4096 + else -> 128 +} + +private fun nextKeepDays(current: Int): Int = when { + current < 7 -> 7 + current < 30 -> 30 + current < 90 -> 90 + current < 365 -> 365 + else -> 1 +} diff --git a/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/settings/NotificationSettingsScreen.kt b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/settings/NotificationSettingsScreen.kt new file mode 100644 index 00000000..9c6a373a --- /dev/null +++ b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/settings/NotificationSettingsScreen.kt @@ -0,0 +1,196 @@ +package com.seven.ikar.kotlin.feature.settings + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.WindowInsetsSides +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.only +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.safeDrawing +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Campaign +import androidx.compose.material.icons.outlined.ChatBubbleOutline +import androidx.compose.material.icons.outlined.Groups +import androidx.compose.material.icons.outlined.Notifications +import androidx.compose.material.icons.outlined.PersonOutline +import androidx.compose.material.icons.outlined.Phone +import androidx.compose.material.icons.outlined.Schedule +import androidx.compose.material.icons.outlined.VolumeUp +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.seven.ikar.kotlin.core.push.NotificationPreferenceCatalog +import com.seven.ikar.kotlin.core.push.NotificationRepeatIntervalChoice +import com.seven.ikar.kotlin.core.push.NotificationRepeatIntervalOption +import com.seven.ikar.kotlin.core.push.NotificationSettingsSnapshot +import com.seven.ikar.kotlin.core.push.NotificationSoundChoice +import com.seven.ikar.kotlin.core.push.NotificationSoundOption +import com.seven.ikar.kotlin.ui.IkarRowDivider +import com.seven.ikar.kotlin.ui.IkarScreenSurface +import com.seven.ikar.kotlin.ui.IkarSectionCard +import com.seven.ikar.kotlin.ui.IkarSectionTitle +import com.seven.ikar.kotlin.ui.IkarSimpleHeader +import com.seven.ikar.kotlin.ui.IkarToggleRow +import com.seven.ikar.kotlin.ui.IkarValueRow +import com.seven.ikar.kotlin.ui.theme.TelegramBlue +import com.seven.ikar.kotlin.ui.theme.TelegramDanger +import com.seven.ikar.kotlin.ui.theme.TelegramInkMuted + +@Composable +fun NotificationSettingsScreen( + state: NotificationSettingsUiState, + messageSoundOptions: List<NotificationSoundChoice>, + callRingtoneOptions: List<NotificationSoundChoice>, + repeatIntervalOptions: List<NotificationRepeatIntervalChoice>, + onBackClick: () -> Unit, + onSnapshotChanged: (NotificationSettingsSnapshot) -> Unit, + onMessageSoundChanged: (NotificationSoundOption) -> Unit, + onCallRingtoneChanged: (NotificationSoundOption) -> Unit, + onRepeatIntervalChanged: (NotificationRepeatIntervalOption) -> Unit, + onSaveClick: () -> Unit, + onResetClick: () -> Unit +) { + val snapshot = state.snapshot + + IkarScreenSurface(modifier = Modifier.fillMaxSize()) { + Column( + modifier = Modifier + .fillMaxSize() + .windowInsetsPadding(WindowInsets.safeDrawing.only(WindowInsetsSides.Vertical)) + .verticalScroll(rememberScrollState()) + .padding(horizontal = 18.dp, vertical = 16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + IkarSimpleHeader(title = "Уведомления и звуки", onBackClick = onBackClick) + + NotificationSection { + IkarSectionTitle("Уведомления из чатов", modifier = Modifier.padding(bottom = 12.dp)) + IkarToggleRow("Личные чаты", "Нажмите для изменения", snapshot.privateChatsEnabled, { onSnapshotChanged(snapshot.copy(privateChatsEnabled = it)) }, leading = { androidx.compose.material3.Icon(Icons.Outlined.PersonOutline, contentDescription = null, tint = TelegramInkMuted) }) + IkarRowDivider(startIndent = 32.dp) + IkarToggleRow("Группы", "Нажмите для изменения", snapshot.groupsEnabled, { onSnapshotChanged(snapshot.copy(groupsEnabled = it)) }, leading = { androidx.compose.material3.Icon(Icons.Outlined.Groups, contentDescription = null, tint = TelegramInkMuted) }) + IkarRowDivider(startIndent = 32.dp) + IkarToggleRow("Каналы", "Нажмите для изменения", snapshot.channelsEnabled, { onSnapshotChanged(snapshot.copy(channelsEnabled = it)) }, leading = { androidx.compose.material3.Icon(Icons.Outlined.Campaign, contentDescription = null, tint = TelegramInkMuted) }) + IkarRowDivider(startIndent = 32.dp) + IkarToggleRow("Истории", "Отключены, 5 автоматических...", snapshot.storiesEnabled, { onSnapshotChanged(snapshot.copy(storiesEnabled = it)) }, leading = { androidx.compose.material3.Icon(Icons.Outlined.Schedule, contentDescription = null, tint = TelegramInkMuted) }) + IkarRowDivider(startIndent = 32.dp) + IkarToggleRow("Реакции", "Сообщения, Истории", snapshot.reactionsEnabled, { onSnapshotChanged(snapshot.copy(reactionsEnabled = it)) }, leading = { Text("♥", color = TelegramBlue, style = MaterialTheme.typography.titleMedium) }) + } + + NotificationSection { + IkarSectionTitle("Звонки", modifier = Modifier.padding(bottom = 12.dp)) + IkarValueRow( + title = "Вибросигнал", + subtitle = "Переключить режим вибрации при звонке", + value = if (snapshot.callVibrationEnabled) "По умолчанию" else "Выключен", + onClick = { onSnapshotChanged(snapshot.copy(callVibrationEnabled = !snapshot.callVibrationEnabled)) }, + leading = { androidx.compose.material3.Icon(Icons.Outlined.Phone, contentDescription = null, tint = TelegramInkMuted) } + ) + IkarRowDivider(startIndent = 0.dp) + IkarValueRow( + title = "Рингтон", + subtitle = "Системный звук входящего звонка в Икар", + value = callRingtoneOptions.firstOrNull { it.value == snapshot.callRingtone }?.title ?: "По умолчанию", + onClick = { + val currentIndex = callRingtoneOptions.indexOfFirst { it.value == snapshot.callRingtone }.coerceAtLeast(0) + val next = callRingtoneOptions[(currentIndex + 1) % callRingtoneOptions.size] + onCallRingtoneChanged(next.value) + }, + leading = { androidx.compose.material3.Icon(Icons.Outlined.VolumeUp, contentDescription = null, tint = TelegramInkMuted) } + ) + } + + NotificationSection { + IkarSectionTitle("Счётчик сообщений", modifier = Modifier.padding(bottom = 12.dp)) + IkarToggleRow("Показывать счётчик", "Разрешать значок-счётчик на иконке приложения", snapshot.showBadge, { onSnapshotChanged(snapshot.copy(showBadge = it)) }) + IkarRowDivider(startIndent = 0.dp) + IkarToggleRow("Чаты без уведомлений", "Показывать muted-чаты внутри общего счётчика", snapshot.includeMutedChatsInBadge, { onSnapshotChanged(snapshot.copy(includeMutedChatsInBadge = it)) }) + IkarRowDivider(startIndent = 0.dp) + IkarToggleRow("Число сообщений", "Показывать количество непрочитанных сообщений", snapshot.countMessagesInBadge, { onSnapshotChanged(snapshot.copy(countMessagesInBadge = it)) }) + } + + NotificationSection { + IkarSectionTitle("В приложении", modifier = Modifier.padding(bottom = 12.dp)) + IkarToggleRow("Звук", "Проигрывать звук для всплывающих уведомлений", snapshot.inAppSoundEnabled, { onSnapshotChanged(snapshot.copy(inAppSoundEnabled = it)) }) + IkarRowDivider(startIndent = 0.dp) + IkarToggleRow("Вибросигнал", "Вибрировать для in-app уведомлений", snapshot.inAppVibrationEnabled, { onSnapshotChanged(snapshot.copy(inAppVibrationEnabled = it)) }) + IkarRowDivider(startIndent = 0.dp) + IkarToggleRow("Показывать текст", "Показывать содержимое сообщения во всплывающем окне", snapshot.inAppShowTextEnabled, { onSnapshotChanged(snapshot.copy(inAppShowTextEnabled = it)) }) + IkarRowDivider(startIndent = 0.dp) + IkarToggleRow("Звук в чате", "Короткий сигнал для уже открытого чата", snapshot.soundInActiveChatEnabled, { onSnapshotChanged(snapshot.copy(soundInActiveChatEnabled = it)) }) + IkarRowDivider(startIndent = 0.dp) + IkarToggleRow("Всплывающие окна", "Показывать всплывающие окна, когда приложение открыто", snapshot.popupWindowsEnabled, { onSnapshotChanged(snapshot.copy(popupWindowsEnabled = it)) }) + } + + NotificationSection { + IkarSectionTitle("События", modifier = Modifier.padding(bottom = 12.dp)) + IkarToggleRow("Контакт присоединился к Ikar", "Сообщать, когда контакт появился в Ikar", snapshot.contactJoinedEnabled, { onSnapshotChanged(snapshot.copy(contactJoinedEnabled = it)) }) + IkarRowDivider(startIndent = 0.dp) + IkarToggleRow("Закреплённые сообщения", "Показывать уведомления о закреплённых сообщениях", snapshot.pinnedMessagesEnabled, { onSnapshotChanged(snapshot.copy(pinnedMessagesEnabled = it)) }) + } + + NotificationSection { + IkarSectionTitle("Другое", modifier = Modifier.padding(bottom = 12.dp)) + IkarToggleRow("Перезапуск при закрытии", "Поднимать фоновый сервис доставки уведомлений", snapshot.restartOnCloseEnabled, { onSnapshotChanged(snapshot.copy(restartOnCloseEnabled = it)) }) + IkarRowDivider(startIndent = 0.dp) + IkarToggleRow("Фоновое соединение", "Держать минимальное фоновое соединение", snapshot.backgroundConnectionEnabled, { onSnapshotChanged(snapshot.copy(backgroundConnectionEnabled = it)) }) + IkarRowDivider(startIndent = 0.dp) + IkarValueRow( + title = "Повтор уведомлений", + subtitle = "Повторять уведомление, пока в чате есть непрочитанные сообщения", + value = repeatIntervalOptions.firstOrNull { it.value == snapshot.repeatInterval }?.title ?: "1 час", + onClick = { + val currentIndex = repeatIntervalOptions.indexOfFirst { it.value == snapshot.repeatInterval }.coerceAtLeast(0) + val next = repeatIntervalOptions[(currentIndex + 1) % repeatIntervalOptions.size] + onRepeatIntervalChanged(next.value) + } + ) + } + + NotificationSection { + IkarSectionTitle("Сброс", modifier = Modifier.padding(bottom = 12.dp)) + Text( + "Сбросить настройки уведомлений", + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 4.dp) + .clickable(onClick = onResetClick), + style = MaterialTheme.typography.titleLarge + ) + Text( + "Сбросить все особые настройки уведомлений для отдельных контактов, групп и каналов.", + style = MaterialTheme.typography.bodySmall, + color = TelegramInkMuted + ) + } + + androidx.compose.material3.Button(onClick = onSaveClick, modifier = Modifier.fillMaxWidth()) { + Text(if (state.isSaving) "Сохранение..." else "Сохранить") + } + + state.errorMessage?.let { + Text(it, color = TelegramDanger, style = MaterialTheme.typography.bodyMedium) + } + state.infoMessage?.let { + Text(it, color = TelegramBlue, style = MaterialTheme.typography.bodyMedium) + } + } + } +} + +@Composable +private fun NotificationSection( + content: @Composable ColumnScope.() -> Unit +) { + IkarSectionCard { + Column(content = content) + } +} diff --git a/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/settings/NotificationSettingsViewModel.kt b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/settings/NotificationSettingsViewModel.kt new file mode 100644 index 00000000..a4bb9632 --- /dev/null +++ b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/settings/NotificationSettingsViewModel.kt @@ -0,0 +1,63 @@ +package com.seven.ikar.kotlin.feature.settings + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.seven.ikar.kotlin.app.AppContainer +import com.seven.ikar.kotlin.core.push.NotificationPreferenceCatalog +import com.seven.ikar.kotlin.core.push.NotificationRepeatIntervalChoice +import com.seven.ikar.kotlin.core.push.NotificationRepeatIntervalOption +import com.seven.ikar.kotlin.core.push.NotificationSettingsSnapshot +import com.seven.ikar.kotlin.core.push.NotificationSoundChoice +import com.seven.ikar.kotlin.core.push.NotificationSoundOption +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch + +data class NotificationSettingsUiState( + val snapshot: NotificationSettingsSnapshot, + val isSaving: Boolean = false, + val errorMessage: String? = null, + val infoMessage: String? = null +) + +class NotificationSettingsViewModel(private val container: AppContainer) : ViewModel() { + private val _uiState = MutableStateFlow( + NotificationSettingsUiState(snapshot = container.pushSettings.loadSnapshot()) + ) + val uiState: StateFlow<NotificationSettingsUiState> = _uiState.asStateFlow() + + val messageSoundOptions: List<NotificationSoundChoice> = NotificationPreferenceCatalog.messageSoundChoices + val callRingtoneOptions: List<NotificationSoundChoice> = NotificationPreferenceCatalog.callRingtoneChoices + val repeatIntervalOptions: List<NotificationRepeatIntervalChoice> = NotificationPreferenceCatalog.repeatChoices + + fun updateSnapshot(transform: (NotificationSettingsSnapshot) -> NotificationSettingsSnapshot) { + _uiState.value = _uiState.value.copy(snapshot = transform(_uiState.value.snapshot)) + } + + fun setMessageSound(value: NotificationSoundOption) = updateSnapshot { it.copy(messageSound = value) } + + fun setCallRingtone(value: NotificationSoundOption) = updateSnapshot { it.copy(callRingtone = value) } + + fun setRepeatInterval(value: NotificationRepeatIntervalOption) = updateSnapshot { it.copy(repeatInterval = value) } + + fun save() { + viewModelScope.launch { + val snapshot = _uiState.value.snapshot + _uiState.value = _uiState.value.copy(isSaving = true, errorMessage = null, infoMessage = null) + runCatching { + container.pushSettings.saveSnapshot(snapshot) + container.pushRegistrationManager.registerCurrentDeviceAsync() + }.onSuccess { + _uiState.value = _uiState.value.copy(isSaving = false, infoMessage = "Настройки уведомлений сохранены.") + }.onFailure { error -> + _uiState.value = _uiState.value.copy(isSaving = false, errorMessage = error.message) + } + } + } + + fun reset() { + container.pushSettings.resetNotificationPreferences() + _uiState.value = NotificationSettingsUiState(snapshot = container.pushSettings.createDefaultSnapshot()) + } +} diff --git a/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/settings/SettingsPlaceholdersScreen.kt b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/settings/SettingsPlaceholdersScreen.kt new file mode 100644 index 00000000..fcb030db --- /dev/null +++ b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/settings/SettingsPlaceholdersScreen.kt @@ -0,0 +1,360 @@ +package com.seven.ikar.kotlin.feature.settings + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.WindowInsetsSides +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.only +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.safeDrawing +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Button +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import com.seven.ikar.kotlin.core.settings.AppLanguageMode +import com.seven.ikar.kotlin.core.settings.AppProxyType +import com.seven.ikar.kotlin.core.settings.AppSettingsSnapshot +import com.seven.ikar.kotlin.core.settings.AppThemeMode +import com.seven.ikar.kotlin.ui.IkarRowDivider +import com.seven.ikar.kotlin.ui.IkarScreenSurface +import com.seven.ikar.kotlin.ui.IkarSectionCard +import com.seven.ikar.kotlin.ui.IkarSectionTitle +import com.seven.ikar.kotlin.ui.IkarSimpleHeader +import com.seven.ikar.kotlin.ui.IkarToggleRow +import com.seven.ikar.kotlin.ui.IkarValueRow +import com.seven.ikar.kotlin.ui.theme.TelegramBlue +import com.seven.ikar.kotlin.ui.theme.TelegramInkMuted + +@Composable +fun PrivacySettingsV2Screen( + onBackClick: () -> Unit, + onBlockedUsersClick: () -> Unit, + onExceptionsClick: () -> Unit +) { + IkarScreenSurface(modifier = Modifier.fillMaxSize()) { + Column( + modifier = Modifier + .fillMaxSize() + .windowInsetsPadding(WindowInsets.safeDrawing.only(WindowInsetsSides.Vertical)) + .verticalScroll(rememberScrollState()) + .padding(horizontal = 18.dp, vertical = 16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + IkarSimpleHeader(title = "Конфиденциальность", onBackClick = onBackClick) + IkarSectionCard { + IkarSectionTitle("Исключения", modifier = Modifier.padding(bottom = 12.dp)) + IkarValueRow("Заблокированные пользователи", "Список блокировок хранится в настройках конфиденциальности.", "Открыть", onClick = onBlockedUsersClick) + IkarRowDivider(startIndent = 0.dp) + IkarValueRow("Исключения приватности", "Списки «всегда разрешать» и «никогда не разрешать» для телефона, фото и историй.", "Открыть", onClick = onExceptionsClick) + } + IkarSectionCard { + Text("Подключено к серверному контракту", style = MaterialTheme.typography.titleLarge, color = TelegramBlue) + Text( + "Основные переключатели конфиденциальности и безопасности доступны через прежний экран; этот экран оставляет навигацию по подразделам как в Telegram.", + modifier = Modifier.padding(top = 6.dp), + style = MaterialTheme.typography.bodySmall, + color = TelegramInkMuted + ) + } + } + } +} + +@Composable +fun BlockedUsersPlaceholderScreen(onBackClick: () -> Unit) { + SimpleSettingsInfoScreen( + title = "Заблокированные", + body = "Сервер уже хранит список заблокированных пользователей в настройках конфиденциальности. Следующий слой: выбор пользователя с поиском и быстрым снятием блокировки.", + onBackClick = onBackClick + ) +} + +@Composable +fun PrivacyExceptionsPlaceholderScreen(onBackClick: () -> Unit) { + SimpleSettingsInfoScreen( + title = "Исключения", + body = "Контракт поддерживает списки разрешённых и запрещённых пользователей. Для полного интерфейса как в Telegram нужен выбор пользователей из контактов и отдельные правила для каждого поля.", + onBackClick = onBackClick + ) +} + +@Composable +fun ThemePlaceholderScreen( + state: AppSettingsUiState, + onBackClick: () -> Unit, + onSnapshotChanged: ((AppSettingsSnapshot) -> AppSettingsSnapshot) -> Unit, + onResetClick: () -> Unit +) { + val snapshot = state.snapshot + IkarSettingsScaffold(title = "Тема", onBackClick = onBackClick) { + IkarSectionCard { + IkarSectionTitle("Режим", modifier = Modifier.padding(bottom = 10.dp)) + AppThemeMode.entries.forEachIndexed { index, mode -> + SelectableSettingsRow( + title = mode.themeTitle(), + subtitle = mode.themeSubtitle(), + selected = snapshot.themeMode == mode, + onClick = { onSnapshotChanged { it.copy(themeMode = mode) } } + ) + if (index != AppThemeMode.entries.lastIndex) { + IkarRowDivider(startIndent = 0.dp) + } + } + } + IkarSectionCard { + IkarSectionTitle("Оформление", modifier = Modifier.padding(bottom = 10.dp)) + IkarValueRow( + "Акцент", + "Быстрый выбор фирменного цвета.", + accentColorTitle(snapshot.accentColorName), + onClick = { onSnapshotChanged { it.copy(accentColorName = nextAccent(it.accentColorName)) } } + ) + IkarRowDivider(startIndent = 0.dp) + IkarToggleRow( + "Анимированные фоны", + "Живые градиенты в чатах как в Telegram.", + snapshot.animatedBackgrounds, + onCheckedChange = { value -> onSnapshotChanged { it.copy(animatedBackgrounds = value) } } + ) + IkarRowDivider(startIndent = 0.dp) + IkarToggleRow( + "Крупные эмодзи", + "Показывать одиночные эмодзи как большие стикеры.", + snapshot.largeEmoji, + onCheckedChange = { value -> onSnapshotChanged { it.copy(largeEmoji = value) } } + ) + } + Button(onClick = onResetClick, modifier = Modifier.fillMaxWidth()) { + Text("Сбросить") + } + } +} + +@Composable +fun LanguagePlaceholderScreen( + state: AppSettingsUiState, + onBackClick: () -> Unit, + onSnapshotChanged: ((AppSettingsSnapshot) -> AppSettingsSnapshot) -> Unit +) { + val snapshot = state.snapshot + IkarSettingsScaffold(title = "Язык", onBackClick = onBackClick) { + IkarSectionCard { + IkarSectionTitle("Язык приложения", modifier = Modifier.padding(bottom = 10.dp)) + AppLanguageMode.entries.forEachIndexed { index, mode -> + SelectableSettingsRow( + title = mode.languageTitle(), + subtitle = mode.languageSubtitle(), + selected = snapshot.languageMode == mode, + onClick = { onSnapshotChanged { it.copy(languageMode = mode) } } + ) + if (index != AppLanguageMode.entries.lastIndex) { + IkarRowDivider(startIndent = 0.dp) + } + } + } + IkarSectionCard { + Text("Локализация сохранена", style = MaterialTheme.typography.titleMedium, color = TelegramBlue) + Text( + "Значение уже хранится локально. Для полного эффекта нужно применить его в базовом контексте приложения или слое локали Compose.", + modifier = Modifier.padding(top = 6.dp), + style = MaterialTheme.typography.bodySmall, + color = TelegramInkMuted + ) + } + } +} + +@Composable +fun ProxyPlaceholderScreen( + state: AppSettingsUiState, + onBackClick: () -> Unit, + onSnapshotChanged: ((AppSettingsSnapshot) -> AppSettingsSnapshot) -> Unit +) { + val snapshot = state.snapshot + IkarSettingsScaffold(title = "Прокси", onBackClick = onBackClick) { + IkarSectionCard { + IkarToggleRow( + "Использовать прокси", + "Профиль сохраняется локально и готов для подключения к OkHttp.", + snapshot.proxyEnabled, + onCheckedChange = { value -> onSnapshotChanged { it.copy(proxyEnabled = value) } } + ) + IkarRowDivider(startIndent = 0.dp) + IkarValueRow( + "Тип", + "SOCKS5, MTProto или HTTP.", + snapshot.proxyType.proxyTypeTitle(), + onClick = { onSnapshotChanged { it.copy(proxyType = nextProxyType(it.proxyType)) } } + ) + } + + IkarSectionCard { + IkarSectionTitle("Подключение", modifier = Modifier.padding(bottom = 10.dp)) + OutlinedTextField( + value = snapshot.proxyHost, + onValueChange = { value -> onSnapshotChanged { it.copy(proxyHost = value.take(120)) } }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + label = { Text("Хост") } + ) + OutlinedTextField( + value = snapshot.proxyPort.toString(), + onValueChange = { value -> + value.filter { it.isDigit() }.toIntOrNull()?.let { port -> + onSnapshotChanged { it.copy(proxyPort = port) } + } + }, + modifier = Modifier + .fillMaxWidth() + .padding(top = 10.dp), + singleLine = true, + label = { Text("Порт") } + ) + OutlinedTextField( + value = snapshot.proxySecret, + onValueChange = { value -> onSnapshotChanged { it.copy(proxySecret = value.take(256)) } }, + modifier = Modifier + .fillMaxWidth() + .padding(top = 10.dp), + singleLine = true, + label = { Text("Секрет / пароль") } + ) + } + } +} + +@Composable +private fun IkarSettingsScaffold( + title: String, + onBackClick: () -> Unit, + content: @Composable ColumnScope.() -> Unit +) { + IkarScreenSurface(modifier = Modifier.fillMaxSize()) { + Column( + modifier = Modifier + .fillMaxSize() + .windowInsetsPadding(WindowInsets.safeDrawing.only(WindowInsetsSides.Vertical)) + .verticalScroll(rememberScrollState()) + .padding(horizontal = 18.dp, vertical = 16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + IkarSimpleHeader(title = title, onBackClick = onBackClick) + content() + } + } +} + +@Composable +private fun SelectableSettingsRow( + title: String, + subtitle: String, + selected: Boolean, + onClick: () -> Unit +) { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onClick) + .padding(vertical = 12.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + Surface(color = if (selected) TelegramBlue else Color(0xFFDDE7F0), shape = MaterialTheme.shapes.small) { + Text( + if (selected) "✓" else " ", + modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp), + color = Color.White, + fontWeight = FontWeight.Bold + ) + } + Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(3.dp)) { + Text(title, style = MaterialTheme.typography.titleMedium) + Text(subtitle, style = MaterialTheme.typography.bodySmall, color = TelegramInkMuted) + } + } +} + +@Composable +private fun SimpleSettingsInfoScreen( + title: String, + body: String, + onBackClick: () -> Unit +) { + IkarSettingsScaffold(title = title, onBackClick = onBackClick) { + IkarSectionCard { + Text("Готова модель данных", style = MaterialTheme.typography.titleLarge, color = TelegramBlue) + Text( + body, + modifier = Modifier.padding(top = 6.dp), + style = MaterialTheme.typography.bodyMedium, + color = TelegramInkMuted + ) + } + } +} + +private fun AppThemeMode.themeTitle(): String = when (this) { + AppThemeMode.System -> "Как в системе" + AppThemeMode.Light -> "Дневная" + AppThemeMode.Dark -> "Ночная" + AppThemeMode.Auto -> "Авто-ночная" +} + +private fun AppThemeMode.themeSubtitle(): String = when (this) { + AppThemeMode.System -> "Следовать настройке Android." + AppThemeMode.Light -> "Всегда светлая тема." + AppThemeMode.Dark -> "Всегда темная тема." + AppThemeMode.Auto -> "Переключать по времени суток." +} + +private fun AppLanguageMode.languageTitle(): String = when (this) { + AppLanguageMode.System -> "Как в системе" + AppLanguageMode.Russian -> "Русский" + AppLanguageMode.English -> "Английский" +} + +private fun AppLanguageMode.languageSubtitle(): String = when (this) { + AppLanguageMode.System -> "Использовать язык устройства." + AppLanguageMode.Russian -> "Закрепить русский интерфейс." + AppLanguageMode.English -> "Включить английский интерфейс." +} + +private fun accentColorTitle(value: String): String = when (value) { + "Telegram Blue" -> "Синий Telegram" + "Mint" -> "Мятный" + "Graphite" -> "Графит" + "Amber" -> "Янтарный" + else -> value +} + +private fun nextAccent(current: String): String = when (current) { + "Telegram Blue" -> "Mint" + "Mint" -> "Graphite" + "Graphite" -> "Amber" + else -> "Telegram Blue" +} + +private fun AppProxyType.proxyTypeTitle(): String = when (this) { + AppProxyType.Socks5 -> "SOCKS5" + AppProxyType.MtProto -> "MTProto" + AppProxyType.Http -> "HTTP" +} + +private fun nextProxyType(current: AppProxyType): AppProxyType = when (current) { + AppProxyType.Socks5 -> AppProxyType.MtProto + AppProxyType.MtProto -> AppProxyType.Http + AppProxyType.Http -> AppProxyType.Socks5 +} diff --git a/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/settings/SettingsScreen.kt b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/settings/SettingsScreen.kt new file mode 100644 index 00000000..fa3654cf --- /dev/null +++ b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/settings/SettingsScreen.kt @@ -0,0 +1,464 @@ +package com.seven.ikar.kotlin.feature.settings + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.WindowInsetsSides +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.only +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.safeDrawing +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Close +import androidx.compose.material.icons.outlined.Search +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.seven.ikar.kotlin.core.model.UserSummaryDto +import com.seven.ikar.kotlin.ui.AvatarView +import com.seven.ikar.kotlin.ui.IkarFloatingMainMenu +import com.seven.ikar.kotlin.ui.IkarHeaderIconBubble +import com.seven.ikar.kotlin.ui.IkarMainMenuTab +import com.seven.ikar.kotlin.ui.IkarRowDivider +import com.seven.ikar.kotlin.ui.IkarScreenSurface +import com.seven.ikar.kotlin.ui.IkarSearchCard +import com.seven.ikar.kotlin.ui.IkarSectionCard +import com.seven.ikar.kotlin.ui.IkarSettingRow +import com.seven.ikar.kotlin.ui.theme.TelegramBlue +import java.util.Locale + +@Composable +fun SettingsScreen( + state: SettingsUiState, + onChatsClick: () -> Unit, + onContactsClick: () -> Unit, + onSettingsClick: () -> Unit, + onProfileClick: () -> Unit, + onAccountClick: () -> Unit, + onChatSettingsClick: () -> Unit, + onPrivacyClick: () -> Unit, + onNotificationsClick: () -> Unit, + onBotsClick: () -> Unit, + onFoldersClick: () -> Unit, + onDevicesClick: () -> Unit, + onUpdatesClick: () -> Unit, + onDataStorageClick: () -> Unit, + onThemeClick: () -> Unit, + onLanguageClick: () -> Unit, + onProxyClick: () -> Unit, + onStoriesClick: () -> Unit +) { + var isSearchVisible by rememberSaveable { mutableStateOf(false) } + var searchQuery by rememberSaveable { mutableStateOf("") } + + val settingsEntries = remember( + onAccountClick, + onChatSettingsClick, + onPrivacyClick, + onNotificationsClick, + onBotsClick, + onFoldersClick, + onDevicesClick, + onUpdatesClick, + onDataStorageClick, + onThemeClick, + onLanguageClick, + onProxyClick, + onStoriesClick + ) { + listOf( + SettingsEntryModel( + title = "Аккаунт", + subtitle = "Телефон, имя и описание профиля", + backgroundColor = Color(0xFFE6F1FD), + accentColor = TelegramBlue, + letter = "А", + onClick = onAccountClick, + keywords = listOf("телефон", "номер", "имя", "username", "описание", "профиль") + ), + SettingsEntryModel( + title = "Настройки чатов", + subtitle = "Обои, список чатов, жесты и медиа", + backgroundColor = Color(0xFFFFF8ECD8), + accentColor = Color(0xFFD7812B), + letter = "Ч", + onClick = onChatSettingsClick, + keywords = listOf( + "обои", + "список чатов", + "жесты", + "медиа", + "текст сообщений", + "углы", + "свайп", + "встроенный браузер", + "анимации", + "direct share", + "отправка по enter", + "микрофон", + "18+" + ) + ), + SettingsEntryModel( + title = "Конфиденциальность", + subtitle = "Контакты и отображение имён в чатах", + backgroundColor = Color(0xFFE9F7E5), + accentColor = Color(0xFF44A948), + letter = "К", + onClick = onPrivacyClick, + keywords = listOf("контакты", "имена", "приватность", "конфиденциальность") + ), + SettingsEntryModel( + title = "Уведомления и звуки", + subtitle = "Звуки, звонки и счётчик сообщений", + backgroundColor = Color(0xFFFDE8EA), + accentColor = Color(0xFFDC4E5C), + letter = "У", + onClick = onNotificationsClick, + keywords = listOf("уведомления", "звук", "звонки", "push", "счётчик") + ), + SettingsEntryModel( + title = "Обновление приложения", + subtitle = "Проверка новой версии Икара и установка из Argus", + backgroundColor = Color(0xFFE8F3FF), + accentColor = TelegramBlue, + letter = "О", + onClick = onUpdatesClick, + keywords = listOf("обновление", "version", "apk", "argus", "релиз", "установка") + ), + SettingsEntryModel( + title = "Боты", + subtitle = "Создание, токены и команды API ботов", + backgroundColor = Color(0xFFEAF4FF), + accentColor = TelegramBlue, + letter = "Б", + onClick = onBotsClick, + keywords = listOf("bot api", "токен", "команды", "бот", "боты") + ), + SettingsEntryModel( + title = "Данные и память", + subtitle = "Кэш и локальные медиафайлы", + backgroundColor = Color(0xFFEAF0FD), + accentColor = Color(0xFF4B76E4), + letter = "Д", + onClick = onDataStorageClick, + keywords = listOf("кэш", "память", "данные", "медиафайлы", "хранилище") + ), + SettingsEntryModel( + title = "Папки с чатами", + subtitle = "Стартовый фильтр для списка чатов", + backgroundColor = Color(0xFFEEF5FD), + accentColor = Color(0xFF5293F1), + letter = "П", + onClick = onFoldersClick, + keywords = listOf("папки", "фильтр", "список чатов", "архив") + ), + SettingsEntryModel( + title = "Устройства", + subtitle = "Устройства уведомлений и их состояние", + backgroundColor = Color(0xFFEAF7FB), + accentColor = Color(0xFF49A8C2), + letter = "У", + onClick = onDevicesClick, + keywords = listOf("устройства", "push", "сессии", "авторизация") + ), + SettingsEntryModel( + title = "Энергосбережение", + subtitle = "Фоновое обновление и прогрев превью", + backgroundColor = Color(0xFFFFF1E6), + accentColor = Color(0xFFD7812B), + letter = "Э", + onClick = null, + keywords = listOf("батарея", "энергосбережение", "фон", "превью") + ), + SettingsEntryModel( + title = "Истории", + subtitle = "Лента, редактор, срок жизни и защищённые истории", + backgroundColor = Color(0xFFEAF4FF), + accentColor = TelegramBlue, + letter = "И", + onClick = onStoriesClick, + keywords = listOf("stories", "истории", "сторис", "композер", "story") + ), + SettingsEntryModel( + title = "Тема", + subtitle = "Тема, акцент, эмодзи и анимированные фоны", + backgroundColor = Color(0xFFE9F7E5), + accentColor = Color(0xFF44A948), + letter = "Т", + onClick = onThemeClick, + keywords = listOf("theme", "тема", "ночная", "оформление") + ), + SettingsEntryModel( + title = "Язык", + subtitle = "Русский", + backgroundColor = Color(0xFFF3EAFF), + accentColor = Color(0xFFA25FE0), + letter = "Я", + onClick = onLanguageClick, + keywords = listOf("язык", "русский", "локаль") + ), + SettingsEntryModel( + title = "Прокси", + subtitle = "SOCKS/MTProto/HTTP профиль подключения", + backgroundColor = Color(0xFFEAF7FB), + accentColor = Color(0xFF49A8C2), + letter = "П", + onClick = onProxyClick, + keywords = listOf("proxy", "прокси", "socks", "mtproto", "сеть") + ) + ) + } + + val visibleEntries = remember(settingsEntries, searchQuery) { + filterSettingsEntries(settingsEntries, searchQuery) + } + + IkarScreenSurface(modifier = Modifier.fillMaxSize()) { + Column( + modifier = Modifier + .fillMaxSize() + .windowInsetsPadding(WindowInsets.safeDrawing.only(WindowInsetsSides.Vertical)) + .verticalScroll(rememberScrollState()) + .padding(horizontal = 18.dp, vertical = 18.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + SettingsProfileHeader( + user = state.session?.user, + isSearchVisible = isSearchVisible, + onAccountClick = onAccountClick, + onSearchClick = { + if (isSearchVisible) { + if (searchQuery.isBlank()) { + isSearchVisible = false + } else { + searchQuery = "" + } + } else { + isSearchVisible = true + } + } + ) + + if (isSearchVisible) { + IkarSearchCard( + value = searchQuery, + onValueChange = { searchQuery = it }, + placeholder = "Искать в настройках" + ) + } + + IkarSectionCard { + if (visibleEntries.isEmpty()) { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 18.dp), + contentAlignment = Alignment.Center + ) { + Text( + text = "Ничего не найдено", + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } else { + visibleEntries.forEachIndexed { index, entry -> + SettingsEntry( + title = entry.title, + subtitle = entry.subtitle, + backgroundColor = entry.backgroundColor, + accentColor = entry.accentColor, + letter = entry.letter, + onClick = entry.onClick + ) + if (index != visibleEntries.lastIndex) { + IkarRowDivider() + } + } + } + } + + Box( + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 116.dp) + ) + } + + IkarFloatingMainMenu( + selectedTab = IkarMainMenuTab.Settings, + modifier = Modifier + .align(Alignment.BottomCenter) + .padding(horizontal = 8.dp, vertical = 18.dp), + onChatsClick = onChatsClick, + onContactsClick = onContactsClick, + onSettingsClick = onSettingsClick, + onProfileClick = onProfileClick + ) + } +} + +@Composable +private fun SettingsProfileHeader( + user: UserSummaryDto?, + isSearchVisible: Boolean, + onAccountClick: () -> Unit, + onSearchClick: () -> Unit +) { + val profileName = user?.displayName?.takeIf { it.isNotBlank() } + ?: user?.username?.takeIf { it.isNotBlank() }?.let { "@$it" } + ?: "Профиль" + val phoneText = user?.phoneNumber?.takeIf { it.isNotBlank() } + ?: user?.username?.takeIf { it.isNotBlank() }?.let { "@$it" } + + Box( + modifier = Modifier + .fillMaxWidth() + .clickable(enabled = user != null, onClick = onAccountClick) + .padding(top = 8.dp, bottom = 4.dp), + contentAlignment = Alignment.TopCenter + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 52.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(6.dp) + ) { + AvatarView( + imageUrl = user?.avatarPath, + title = profileName, + size = 104.dp + ) + Text( + text = profileName, + style = MaterialTheme.typography.displayLarge, + fontWeight = FontWeight.SemiBold, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + if (!phoneText.isNullOrBlank()) { + Text( + text = phoneText, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + } + + IkarHeaderIconBubble( + modifier = Modifier.align(Alignment.TopEnd), + onClick = onSearchClick + ) { + Icon( + imageVector = if (isSearchVisible) Icons.Outlined.Close else Icons.Outlined.Search, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurface + ) + } + } +} + +private data class SettingsEntryModel( + val title: String, + val subtitle: String, + val backgroundColor: Color, + val accentColor: Color, + val letter: String, + val onClick: (() -> Unit)?, + val keywords: List<String> +) + +private data class RankedSettingsEntry( + val entry: SettingsEntryModel, + val rank: Int, + val originalIndex: Int +) + +private fun filterSettingsEntries( + entries: List<SettingsEntryModel>, + query: String +): List<SettingsEntryModel> { + val normalizedQuery = query.trim().lowercase(Locale.ROOT) + if (normalizedQuery.isBlank()) { + return entries + } + + return entries.mapIndexedNotNull { index, entry -> + val rank = entry.matchRank(normalizedQuery) ?: return@mapIndexedNotNull null + RankedSettingsEntry(entry = entry, rank = rank, originalIndex = index) + }.sortedWith(compareBy<RankedSettingsEntry> { it.rank }.thenBy { it.originalIndex }) + .map { it.entry } +} + +private fun SettingsEntryModel.matchRank(normalizedQuery: String): Int? { + val normalizedTitle = title.lowercase(Locale.ROOT) + val normalizedSubtitle = subtitle.lowercase(Locale.ROOT) + val normalizedKeywords = keywords.map { it.lowercase(Locale.ROOT) } + + return when { + normalizedTitle == normalizedQuery -> 0 + normalizedTitle.startsWith(normalizedQuery) -> 1 + normalizedTitle.contains(normalizedQuery) -> 2 + normalizedKeywords.any { it == normalizedQuery } -> 3 + normalizedKeywords.any { it.startsWith(normalizedQuery) } -> 4 + normalizedKeywords.any { it.contains(normalizedQuery) } -> 5 + normalizedSubtitle.contains(normalizedQuery) -> 6 + else -> null + } +} + +@Composable +private fun SettingsEntry( + title: String, + subtitle: String, + backgroundColor: Color, + accentColor: Color, + letter: String, + onClick: (() -> Unit)? +) { + IkarSettingRow( + title = title, + subtitle = subtitle, + onClick = onClick, + leading = { + Surface( + modifier = Modifier.fillMaxSize(), + color = backgroundColor, + shape = CircleShape + ) { + Box(contentAlignment = Alignment.Center) { + Text( + text = letter, + style = MaterialTheme.typography.titleMedium, + color = accentColor, + fontWeight = FontWeight.SemiBold + ) + } + } + } + ) +} diff --git a/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/settings/SettingsViewModel.kt b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/settings/SettingsViewModel.kt new file mode 100644 index 00000000..6b235748 --- /dev/null +++ b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/settings/SettingsViewModel.kt @@ -0,0 +1,14 @@ +package com.seven.ikar.kotlin.feature.settings + +import androidx.lifecycle.ViewModel +import com.seven.ikar.kotlin.app.AppContainer +import com.seven.ikar.kotlin.core.model.AuthSessionDto + +data class SettingsUiState( + val session: AuthSessionDto? = null +) + +class SettingsViewModel(container: AppContainer) : ViewModel() { + private val session = container.sessionStore.read() + val uiState = SettingsUiState(session = session) +} diff --git a/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/share/IncomingShareStore.kt b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/share/IncomingShareStore.kt new file mode 100644 index 00000000..d8d9fce9 --- /dev/null +++ b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/share/IncomingShareStore.kt @@ -0,0 +1,28 @@ +package com.seven.ikar.kotlin.feature.share + +import com.seven.ikar.kotlin.feature.chat.LocalAttachmentFile +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow + +data class PendingExternalShare( + val id: Long = System.nanoTime(), + val text: String? = null, + val attachments: List<LocalAttachmentFile> = emptyList() +) { + val hasContent: Boolean + get() = !text.isNullOrBlank() || attachments.isNotEmpty() +} + +class IncomingShareStore { + private val _pendingShare = MutableStateFlow<PendingExternalShare?>(null) + val pendingShare: StateFlow<PendingExternalShare?> = _pendingShare.asStateFlow() + + fun setPending(share: PendingExternalShare?) { + _pendingShare.value = share?.takeIf(PendingExternalShare::hasContent) + } + + fun clear() { + _pendingShare.value = null + } +} diff --git a/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/share/ShareTargetScreen.kt b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/share/ShareTargetScreen.kt new file mode 100644 index 00000000..52150d22 --- /dev/null +++ b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/share/ShareTargetScreen.kt @@ -0,0 +1,273 @@ +package com.seven.ikar.kotlin.feature.share + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.seven.ikar.kotlin.core.contacts.DiscoverUserItem +import com.seven.ikar.kotlin.ui.AvatarView +import com.seven.ikar.kotlin.ui.IkarRowDivider +import com.seven.ikar.kotlin.ui.IkarScreenSurface +import com.seven.ikar.kotlin.ui.IkarSearchCard +import com.seven.ikar.kotlin.ui.IkarSectionCard +import com.seven.ikar.kotlin.ui.IkarSimpleHeader +import com.seven.ikar.kotlin.ui.formatDiscoverPresence +import com.seven.ikar.kotlin.ui.formatPresenceColor +import com.seven.ikar.kotlin.ui.theme.TelegramBlue +import com.seven.ikar.kotlin.ui.theme.TelegramInkMuted + +@Composable +fun ShareTargetScreen( + state: ShareTargetUiState, + onBackClick: () -> Unit, + onQueryChanged: (String) -> Unit, + onChatClick: (ShareChatTargetItemUiState) -> Unit, + onUserClick: (DiscoverUserItem) -> Unit +) { + IkarScreenSurface(modifier = Modifier.fillMaxSize()) { + Column( + modifier = Modifier + .fillMaxSize() + .statusBarsPadding() + .navigationBarsPadding() + ) { + Column( + modifier = Modifier.padding(start = 18.dp, top = 16.dp, end = 18.dp, bottom = 12.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + IkarSimpleHeader(title = "Поделиться", onBackClick = onBackClick) + + IkarSearchCard( + value = state.query, + onValueChange = onQueryChanged, + placeholder = "Поиск чата или контакта" + ) + + SharePreviewCard(state.payload) + + state.errorMessage?.let { + Text(it, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodyMedium) + } + } + + IkarSectionCard( + modifier = Modifier + .weight(1f) + .padding(horizontal = 18.dp) + .padding(bottom = 18.dp), + padding = PaddingValues(0.dp) + ) { + LazyColumn { + if (state.isLoading && state.chats.isEmpty() && state.contacts.isEmpty()) { + item { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 24.dp, vertical = 32.dp), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically + ) { + CircularProgressIndicator() + } + } + } + + if (state.chats.isNotEmpty()) { + item { + SectionLabel("Чаты") + } + items(state.chats, key = { it.chat.id }) { item -> + ShareChatRow(item = item, onClick = { onChatClick(item) }) + } + } + + if (state.contacts.isNotEmpty()) { + item { + SectionLabel("Контакты") + } + items(state.contacts, key = { it.id }) { item -> + ShareContactRow(item = item, onClick = { onUserClick(item) }) + } + } + + if (!state.isLoading && state.chats.isEmpty() && state.contacts.isEmpty()) { + item { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 24.dp, vertical = 28.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Text("Подходящих получателей нет", style = MaterialTheme.typography.headlineMedium) + Text( + "Уточните поиск или откройте доступ к контактам.", + color = TelegramInkMuted, + style = MaterialTheme.typography.bodyMedium + ) + } + } + } + } + } + } + } +} + +@Composable +private fun SharePreviewCard(payload: PendingExternalShare) { + IkarSectionCard { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text( + text = "Будет отправлено", + style = MaterialTheme.typography.labelLarge, + color = TelegramBlue + ) + payload.text?.takeIf { it.isNotBlank() }?.let { sharedText -> + Text( + text = sharedText, + style = MaterialTheme.typography.bodyMedium, + maxLines = 3, + overflow = TextOverflow.Ellipsis + ) + } + if (payload.attachments.isNotEmpty()) { + Text( + text = attachmentSummary(payload), + style = MaterialTheme.typography.bodySmall, + color = TelegramInkMuted + ) + payload.attachments.take(3).forEach { attachment -> + Text( + text = attachment.fileName, + style = MaterialTheme.typography.bodyMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + } + } + } +} + +@Composable +private fun SectionLabel(text: String) { + Text( + text = text, + modifier = Modifier.padding(start = 18.dp, top = 16.dp, end = 18.dp, bottom = 8.dp), + style = MaterialTheme.typography.labelLarge, + color = TelegramBlue + ) +} + +@Composable +private fun ShareChatRow( + item: ShareChatTargetItemUiState, + onClick: () -> Unit +) { + Column { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onClick) + .padding(horizontal = 18.dp, vertical = 12.dp), + horizontalArrangement = Arrangement.spacedBy(14.dp), + verticalAlignment = Alignment.CenterVertically + ) { + AvatarView(imageUrl = null, title = item.resolvedTitle, size = 54.dp) + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(3.dp) + ) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + Text( + text = item.resolvedTitle, + modifier = Modifier.weight(1f), + style = MaterialTheme.typography.titleLarge, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + if (item.lastActivityLabel.isNotBlank()) { + Text( + text = item.lastActivityLabel, + style = MaterialTheme.typography.bodySmall, + color = TelegramInkMuted, + maxLines = 1 + ) + } + } + Text( + text = item.preview, + style = MaterialTheme.typography.bodySmall, + color = TelegramInkMuted, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + } + IkarRowDivider(startIndent = 86.dp) + } +} + +@Composable +private fun ShareContactRow( + item: DiscoverUserItem, + onClick: () -> Unit +) { + Column { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onClick) + .padding(horizontal = 18.dp, vertical = 12.dp), + horizontalArrangement = Arrangement.spacedBy(14.dp), + verticalAlignment = Alignment.CenterVertically + ) { + AvatarView(imageUrl = item.avatarUrl, title = item.displayName, size = 54.dp) + Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(3.dp)) { + Text( + text = item.displayName, + style = MaterialTheme.typography.titleLarge, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + val secondaryText = item.secondaryLine.ifBlank { formatDiscoverPresence(item) } + Text( + text = secondaryText, + style = MaterialTheme.typography.bodySmall, + color = if (item.secondaryLine.isBlank()) formatPresenceColor(item) else TelegramInkMuted, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + } + IkarRowDivider(startIndent = 86.dp) + } +} + +private fun attachmentSummary(payload: PendingExternalShare): String = when (payload.attachments.size) { + 0 -> "" + 1 -> "1 файл" + 2, 3, 4 -> "${payload.attachments.size} файла" + else -> "${payload.attachments.size} файлов" +} diff --git a/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/share/ShareTargetViewModel.kt b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/share/ShareTargetViewModel.kt new file mode 100644 index 00000000..e5120488 --- /dev/null +++ b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/share/ShareTargetViewModel.kt @@ -0,0 +1,239 @@ +package com.seven.ikar.kotlin.feature.share + +import android.Manifest +import android.content.pm.PackageManager +import androidx.core.content.ContextCompat +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.seven.ikar.kotlin.app.AppContainer +import com.seven.ikar.kotlin.core.contacts.DiscoverUserItem +import com.seven.ikar.kotlin.core.contacts.PhoneNumberNormalizer +import com.seven.ikar.kotlin.core.model.ChatSummaryDto +import com.seven.ikar.kotlin.core.model.ChatType +import com.seven.ikar.kotlin.ui.chatPreview +import com.seven.ikar.kotlin.ui.formatLastActivity +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch + +data class ShareChatTargetItemUiState( + val chat: ChatSummaryDto, + val resolvedTitle: String, + val lastActivityLabel: String, + val preview: String +) + +data class ShareTargetUiState( + val payload: PendingExternalShare, + val query: String = "", + val chats: List<ShareChatTargetItemUiState> = emptyList(), + val contacts: List<DiscoverUserItem> = emptyList(), + val isLoading: Boolean = false, + val isSending: Boolean = false, + val errorMessage: String? = null, + val pendingChatId: String? = null +) + +class ShareTargetViewModel( + private val container: AppContainer, + private val pendingShare: PendingExternalShare +) : ViewModel() { + private val _uiState = MutableStateFlow(ShareTargetUiState(payload = pendingShare)) + private val _allChats = mutableListOf<ChatSummaryDto>() + private var contactNamesByNormalizedPhone: Map<String, String> = emptyMap() + private var currentUserId: String? = container.sessionStore.read()?.user?.id + private var searchJob: Job? = null + + val uiState: StateFlow<ShareTargetUiState> = _uiState.asStateFlow() + + init { + refresh() + } + + fun updateQuery(value: String) { + _uiState.value = _uiState.value.copy(query = value) + searchJob?.cancel() + searchJob = viewModelScope.launch { + delay(250) + refresh() + } + } + + fun refresh() { + viewModelScope.launch { + val query = _uiState.value.query.trim() + _uiState.value = _uiState.value.copy(isLoading = true, errorMessage = null) + runCatching { + contactNamesByNormalizedPhone = loadDeviceContactNames() + val chats = container.executeAuthorized { session -> + currentUserId = session.user.id + container.apiClient.getChats(session.accessToken) + } + _allChats.clear() + _allChats.addAll(chats) + val contacts = loadContactCandidates(query) + chats to contacts + }.onSuccess { (chats, contacts) -> + _uiState.value = _uiState.value.copy( + chats = filterChats(chats, query), + contacts = contacts, + isLoading = false, + errorMessage = null + ) + }.onFailure { error -> + _uiState.value = _uiState.value.copy(isLoading = false, errorMessage = error.message) + } + } + } + + fun sendToChat(chat: ShareChatTargetItemUiState) { + sendToChatId(chat.chat.id) + } + + fun sendToUser(user: DiscoverUserItem) { + if (_uiState.value.isSending) { + return + } + + viewModelScope.launch { + _uiState.value = _uiState.value.copy(isSending = true, errorMessage = null) + runCatching { + container.executeAuthorized { session -> + val existingChatId = findExistingDirectChatId(user.id) + val targetChatId = existingChatId ?: container.apiClient.createDirectChat(session.accessToken, user.id).id + sendPendingShare(session.accessToken, targetChatId) + targetChatId + } + }.onSuccess { chatId -> + _uiState.value = _uiState.value.copy(isSending = false, pendingChatId = chatId) + }.onFailure { error -> + _uiState.value = _uiState.value.copy(isSending = false, errorMessage = error.message) + } + } + } + + fun consumePendingChatNavigation() { + _uiState.value = _uiState.value.copy(pendingChatId = null) + } + + private fun sendToChatId(chatId: String) { + if (_uiState.value.isSending) { + return + } + + viewModelScope.launch { + _uiState.value = _uiState.value.copy(isSending = true, errorMessage = null) + runCatching { + container.executeAuthorized { session -> + sendPendingShare(session.accessToken, chatId) + chatId + } + }.onSuccess { targetChatId -> + _uiState.value = _uiState.value.copy(isSending = false, pendingChatId = targetChatId) + }.onFailure { error -> + _uiState.value = _uiState.value.copy(isSending = false, errorMessage = error.message) + } + } + } + + private suspend fun sendPendingShare(accessToken: String, chatId: String) { + val sharedText = pendingShare.text?.trim().orEmpty() + if (pendingShare.attachments.isEmpty()) { + check(sharedText.isNotBlank()) { "Нечего отправлять." } + container.apiClient.sendMessage(chatId, sharedText, accessToken) + return + } + + container.transferQueueRepository.enqueueUpload( + chatId = chatId, + text = sharedText.takeIf { it.isNotBlank() }, + replyToMessageId = null, + files = pendingShare.attachments + ) + } + + private fun filterChats(chats: List<ChatSummaryDto>, query: String): List<ShareChatTargetItemUiState> { + val items = chats + .filter { it.canSendMessages } + .map { chat -> + ShareChatTargetItemUiState( + chat = chat, + resolvedTitle = resolveChatTitle(chat), + lastActivityLabel = formatLastActivity(chat.lastActivityAt), + preview = chatPreview(chat) + ) + } + + if (query.isBlank()) { + return items + } + + return items.filter { item -> + item.resolvedTitle.contains(query, ignoreCase = true) || + item.chat.title.contains(query, ignoreCase = true) || + item.chat.secondaryText.orEmpty().contains(query, ignoreCase = true) || + item.chat.lastMessagePreview.orEmpty().contains(query, ignoreCase = true) + } + } + + private suspend fun loadContactCandidates(query: String): List<DiscoverUserItem> { + val candidates = when { + hasContactsPermission() -> container.resolvedContactsRepository.loadMergedCandidates(query) + query.isBlank() -> emptyList() + else -> container.executeAuthorized { session -> + container.apiClient.searchUsers(session.accessToken, query).map { user -> + DiscoverUserItem( + user = user, + displayName = user.displayName, + normalizedPhoneNumber = PhoneNumberNormalizer.normalize(user.phoneNumber) ?: "" + ) + } + } + } + + val directParticipantIds = _allChats + .asSequence() + .filter { it.type == ChatType.Direct } + .flatMap { chat -> chat.participants.asSequence().map { participant -> participant.id } } + .toSet() + + return candidates.filterNot { directParticipantIds.contains(it.id) } + } + + private fun resolveChatTitle(chat: ChatSummaryDto): String { + if (chat.type != ChatType.Direct) { + return chat.title + } + + val peer = chat.participants.firstOrNull { it.id != currentUserId } ?: chat.participants.firstOrNull() + val normalizedPhone = PhoneNumberNormalizer.normalize(peer?.phoneNumber) + val contactName = normalizedPhone?.let(contactNamesByNormalizedPhone::get) + return contactName?.takeIf { it.isNotBlank() } + ?: peer?.displayName?.takeIf { it.isNotBlank() } + ?: chat.title + } + + private fun findExistingDirectChatId(userId: String): String? = + _allChats.firstOrNull { chat -> + chat.type == ChatType.Direct && chat.participants.any { participant -> participant.id == userId } + }?.id + + private fun loadDeviceContactNames(): Map<String, String> { + if (!hasContactsPermission()) { + return emptyMap() + } + + return container.contactDiscoveryService.getContacts().associate { contact -> + contact.normalizedPhoneNumber to contact.displayName + } + } + + private fun hasContactsPermission(): Boolean = + ContextCompat.checkSelfPermission( + container.appContext, + Manifest.permission.READ_CONTACTS + ) == PackageManager.PERMISSION_GRANTED +} diff --git a/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/stories/StoriesScreen.kt b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/stories/StoriesScreen.kt new file mode 100644 index 00000000..7178a339 --- /dev/null +++ b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/stories/StoriesScreen.kt @@ -0,0 +1,811 @@ +package com.seven.ikar.kotlin.feature.stories + +import android.net.Uri +import androidx.activity.compose.BackHandler +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.border +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.WindowInsetsSides +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.only +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.safeDrawing +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Add +import androidx.compose.material.icons.outlined.Close +import androidx.compose.material.icons.outlined.Delete +import androidx.compose.material.icons.outlined.Schedule +import androidx.compose.material3.Button +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import coil.compose.AsyncImage +import com.seven.ikar.kotlin.core.model.AttachmentDto +import com.seven.ikar.kotlin.core.model.StoryDto +import com.seven.ikar.kotlin.core.model.StoryVisibility +import com.seven.ikar.kotlin.core.network.ServerConfig +import com.seven.ikar.kotlin.ui.AuthenticatedAsyncImage +import com.seven.ikar.kotlin.ui.AvatarView +import com.seven.ikar.kotlin.ui.IkarHeaderIconBubble +import com.seven.ikar.kotlin.ui.IkarRowDivider +import com.seven.ikar.kotlin.ui.IkarScreenSurface +import com.seven.ikar.kotlin.ui.IkarSectionCard +import com.seven.ikar.kotlin.ui.IkarSectionTitle +import com.seven.ikar.kotlin.ui.IkarSimpleHeader +import com.seven.ikar.kotlin.ui.IkarToggleRow +import com.seven.ikar.kotlin.ui.SecureWindowEffect +import com.seven.ikar.kotlin.ui.theme.TelegramBlue +import com.seven.ikar.kotlin.ui.theme.TelegramInkMuted +import java.io.File + +@Composable +fun StoriesScreen( + state: StoriesUiState, + onBackClick: () -> Unit, + onComposeClick: () -> Unit, + onRefreshClick: () -> Unit, + onStoryClick: (StoryDto) -> Unit, + onDeleteStory: (StoryDto) -> Unit, + onCloseStoryPreview: () -> Unit, + onNextStory: () -> Unit, + onPreviousStory: () -> Unit, + onRetryTransfer: (String) -> Unit, + onCancelTransfer: (String) -> Unit +) { + SecureWindowEffect(enabled = state.selectedStory?.hasProtectedContent == true) + + state.selectedStory?.let { story -> + StoryViewerDialog( + story = story, + stories = state.stories, + accessToken = state.accessToken, + isOwnStory = story.author.id == state.currentUserId, + onClose = onCloseStoryPreview, + onNextStory = onNextStory, + onPreviousStory = onPreviousStory, + onDeleteStory = { onDeleteStory(story) } + ) + } + + IkarScreenSurface(modifier = Modifier.fillMaxSize()) { + Column( + modifier = Modifier + .fillMaxSize() + .windowInsetsPadding(WindowInsets.safeDrawing.only(WindowInsetsSides.Vertical)) + .verticalScroll(rememberScrollState()) + .padding(horizontal = 18.dp, vertical = 16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + IkarSimpleHeader( + title = "Истории", + onBackClick = onBackClick, + rightContent = { + IkarHeaderIconBubble(onClick = onComposeClick) { + Icon(Icons.Outlined.Add, contentDescription = null, tint = TelegramBlue) + } + } + ) + + if (state.activeTransfers.isNotEmpty()) { + StoryTransfersCard( + transfers = state.activeTransfers, + onRetryTransfer = onRetryTransfer, + onCancelTransfer = onCancelTransfer + ) + } + + state.errorMessage?.let { message -> + IkarSectionCard { + Text("Ошибка", style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.error) + Text(message, modifier = Modifier.padding(top = 6.dp), style = MaterialTheme.typography.bodySmall, color = TelegramInkMuted) + } + } + + IkarSectionCard { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + IkarSectionTitle("Сегодня") + TextButton(onClick = onRefreshClick) { + Text(if (state.isLoading) "Загрузка..." else "Обновить") + } + } + + if (state.stories.isEmpty()) { + EmptyStoriesState(onComposeClick = onComposeClick) + } else { + state.stories.forEachIndexed { index, story -> + StoryPreviewRow(story = story, accessToken = state.accessToken, onClick = { onStoryClick(story) }) + if (index != state.stories.lastIndex) { + IkarRowDivider() + } + } + } + } + + IkarSectionCard { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon(Icons.Outlined.Schedule, contentDescription = null, tint = TelegramBlue) + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + Text("Медиаистории подключены", style = MaterialTheme.typography.titleLarge) + Text( + "Текстовые истории публикуются сразу, а медиаистории уходят через очередь фоновой отправки с прогрессом, повтором и отменой.", + style = MaterialTheme.typography.bodySmall, + color = TelegramInkMuted + ) + } + } + } + } + } +} + +@Composable +fun StoryComposerScreen( + state: StoriesUiState, + onBackClick: () -> Unit, + onTextChanged: (String) -> Unit, + onVisibilityChanged: (StoryVisibility) -> Unit, + onProtectedChanged: (Boolean) -> Unit, + onTtlChanged: (Int) -> Unit, + onMediaSelected: (List<Uri>) -> Unit, + onRemoveMedia: (String) -> Unit, + onClearMedia: () -> Unit, + onCreateClick: () -> Unit, + onRetryTransfer: (String) -> Unit, + onCancelTransfer: (String) -> Unit +) { + val mediaPicker = rememberLauncherForActivityResult(ActivityResultContracts.OpenMultipleDocuments()) { uris -> + onMediaSelected(uris) + } + + IkarScreenSurface(modifier = Modifier.fillMaxSize()) { + Column( + modifier = Modifier + .fillMaxSize() + .windowInsetsPadding(WindowInsets.safeDrawing.only(WindowInsetsSides.Vertical)) + .verticalScroll(rememberScrollState()) + .padding(horizontal = 18.dp, vertical = 16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + IkarSimpleHeader(title = "Новая история", onBackClick = onBackClick) + + if (state.activeTransfers.isNotEmpty()) { + StoryTransfersCard( + transfers = state.activeTransfers, + onRetryTransfer = onRetryTransfer, + onCancelTransfer = onCancelTransfer + ) + } + + IkarSectionCard { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + IkarSectionTitle("Медиа") + Text("${state.pendingMedia.size}/10", style = MaterialTheme.typography.bodySmall, color = TelegramInkMuted) + } + OutlinedButton( + onClick = { mediaPicker.launch(arrayOf("image/*", "video/*")) }, + enabled = !state.isImportingMedia && state.pendingMedia.size < 10, + modifier = Modifier + .fillMaxWidth() + .padding(top = 10.dp) + ) { + Text(if (state.isImportingMedia) "Импортируем..." else "Добавить фото или видео") + } + if (state.pendingMedia.isNotEmpty()) { + MediaPreviewGrid( + media = state.pendingMedia, + onRemoveMedia = onRemoveMedia, + modifier = Modifier.padding(top = 12.dp) + ) + TextButton(onClick = onClearMedia, modifier = Modifier.fillMaxWidth()) { + Text("Очистить медиа") + } + } + } + + IkarSectionCard { + Text("Текст", style = MaterialTheme.typography.titleLarge) + BasicTextField( + value = state.draftText, + onValueChange = onTextChanged, + modifier = Modifier + .fillMaxWidth() + .padding(top = 12.dp) + .background(Color(0xFFF2F6FA), RoundedCornerShape(18.dp)) + .padding(14.dp), + textStyle = MaterialTheme.typography.bodyLarge.copy(color = MaterialTheme.colorScheme.onSurface), + decorationBox = { inner -> + if (state.draftText.isBlank()) { + Text("Что происходит?", color = TelegramInkMuted, style = MaterialTheme.typography.bodyLarge) + } + inner() + } + ) + Text("${state.draftText.length}/500", modifier = Modifier.padding(top = 8.dp), style = MaterialTheme.typography.bodySmall, color = TelegramInkMuted) + } + + IkarSectionCard { + IkarSectionTitle("Кто увидит", modifier = Modifier.padding(bottom = 10.dp)) + StoryVisibility.entries.forEachIndexed { index, visibility -> + StoryOptionRow( + title = visibility.label(), + subtitle = visibility.subtitle(), + selected = state.visibility == visibility, + onClick = { onVisibilityChanged(visibility) } + ) + if (index != StoryVisibility.entries.lastIndex) { + IkarRowDivider(startIndent = 0.dp) + } + } + } + + IkarSectionCard { + IkarSectionTitle("Срок жизни", modifier = Modifier.padding(bottom = 10.dp)) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth()) { + listOf(3_600, 43_200, 86_400, 604_800).forEach { ttl -> + Surface( + modifier = Modifier + .weight(1f) + .clickable { onTtlChanged(ttl) }, + color = if (state.ttlSeconds == ttl) Color(0xFFEAF4FF) else Color(0xFFF2F6FA), + shape = RoundedCornerShape(16.dp) + ) { + Text( + ttl.labelTtl(), + modifier = Modifier.padding(vertical = 12.dp), + style = MaterialTheme.typography.bodyMedium, + color = if (state.ttlSeconds == ttl) TelegramBlue else MaterialTheme.colorScheme.onSurface, + fontWeight = if (state.ttlSeconds == ttl) FontWeight.SemiBold else FontWeight.Normal + ) + } + } + } + } + + IkarSectionCard { + IkarToggleRow( + "Защитить контент", + "Флаг передаётся серверу; клиент должен блокировать пересылку, сохранение и скриншоты там, где это возможно.", + state.hasProtectedContent, + onProtectedChanged + ) + } + + state.errorMessage?.let { message -> + Text(message, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall) + } + + Button( + onClick = onCreateClick, + enabled = (state.draftText.isNotBlank() || state.pendingMedia.isNotEmpty()) && !state.isSaving, + modifier = Modifier.fillMaxWidth() + ) { + val hasMedia = state.pendingMedia.isNotEmpty() + Text( + when { + state.isSaving -> "Публикуем..." + hasMedia -> "Поставить в очередь" + else -> "Опубликовать" + } + ) + } + } + } +} + +@Composable +private fun StoryViewerDialog( + story: StoryDto, + stories: List<StoryDto>, + accessToken: String?, + isOwnStory: Boolean, + onClose: () -> Unit, + onNextStory: () -> Unit, + onPreviousStory: () -> Unit, + onDeleteStory: () -> Unit +) { + Dialog( + onDismissRequest = onClose, + properties = DialogProperties(usePlatformDefaultWidth = false) + ) { + BackHandler(onBack = onClose) + + Box( + modifier = Modifier + .fillMaxSize() + .background(Color.Black) + ) { + StoryViewerContent(story = story, accessToken = accessToken) + + Row(modifier = Modifier.fillMaxSize()) { + Box( + modifier = Modifier + .weight(1f) + .fillMaxSize() + .clickable(onClick = onPreviousStory) + ) + Box( + modifier = Modifier + .weight(1f) + .fillMaxSize() + .clickable(onClick = onNextStory) + ) + } + + StoryViewerTopBar( + story = story, + stories = stories, + accessToken = accessToken, + isOwnStory = isOwnStory, + onClose = onClose, + onDeleteStory = onDeleteStory + ) + + StoryViewerCaption( + story = story, + modifier = Modifier.align(Alignment.BottomCenter) + ) + } + } +} + +@Composable +private fun StoryViewerContent(story: StoryDto, accessToken: String?) { + val imageAttachment = story.attachments.firstOrNull { it.contentType.startsWith("image/") } + if (imageAttachment != null) { + AuthenticatedAsyncImage( + imageUrl = ServerConfig.normalizeMediaUrl(imageAttachment.downloadPath), + accessToken = accessToken, + contentDescription = imageAttachment.fileName, + modifier = Modifier.fillMaxSize(), + contentScale = ContentScale.Crop + ) + return + } + + Box( + modifier = Modifier + .fillMaxSize() + .padding(28.dp), + contentAlignment = Alignment.Center + ) { + if (story.attachments.isNotEmpty()) { + val first = story.attachments.first() + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(10.dp) + ) { + Text("Видео", style = MaterialTheme.typography.headlineMedium, color = Color.White) + Text( + first.fileName, + style = MaterialTheme.typography.bodyMedium, + color = Color.White.copy(alpha = 0.72f), + textAlign = TextAlign.Center, + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) + } + } else { + Text( + story.text.ifBlank { "История без подписи" }, + style = MaterialTheme.typography.headlineMedium, + color = Color.White, + textAlign = TextAlign.Center + ) + } + } +} + +@Composable +private fun StoryViewerTopBar( + story: StoryDto, + stories: List<StoryDto>, + accessToken: String?, + isOwnStory: Boolean, + onClose: () -> Unit, + onDeleteStory: () -> Unit +) { + Column( + modifier = Modifier + .fillMaxWidth() + .background(Color.Black.copy(alpha = 0.42f)) + .padding(horizontal = 14.dp, vertical = 12.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + StoryProgressSegments(story = story, stories = stories) + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + AvatarView( + imageUrl = ServerConfig.normalizeMediaUrl(story.author.avatarPath), + title = story.author.displayName, + accessToken = accessToken, + size = 42.dp + ) + Spacer(modifier = Modifier.width(10.dp)) + Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text( + story.author.displayName, + style = MaterialTheme.typography.titleMedium, + color = Color.White, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + Text( + story.createdAt.shortDateTime(), + style = MaterialTheme.typography.bodySmall, + color = Color.White.copy(alpha = 0.72f) + ) + } + if (isOwnStory) { + IconButton(onClick = onDeleteStory) { + Icon(Icons.Outlined.Delete, contentDescription = "Удалить историю", tint = Color.White) + } + } + IconButton(onClick = onClose) { + Icon(Icons.Outlined.Close, contentDescription = "Закрыть", tint = Color.White) + } + } + } +} + +@Composable +private fun StoryProgressSegments(story: StoryDto, stories: List<StoryDto>) { + val selectedIndex = stories.indexOfFirst { it.id == story.id } + val firstIndex = if (selectedIndex < 0) 0 else (selectedIndex - 6).coerceAtLeast(0) + val segmentStories = stories.drop(firstIndex).take(12).ifEmpty { listOf(story) } + Row(horizontalArrangement = Arrangement.spacedBy(4.dp), modifier = Modifier.fillMaxWidth()) { + segmentStories.forEach { item -> + Box( + modifier = Modifier + .weight(1f) + .height(3.dp) + .clip(RoundedCornerShape(2.dp)) + .background(if (item.id == story.id) Color.White else Color.White.copy(alpha = 0.34f)) + ) + } + } +} + +@Composable +private fun StoryViewerCaption(story: StoryDto, modifier: Modifier = Modifier) { + Column( + modifier = modifier + .fillMaxWidth() + .background(Color.Black.copy(alpha = 0.54f)) + .padding(horizontal = 18.dp, vertical = 16.dp), + verticalArrangement = Arrangement.spacedBy(6.dp) + ) { + if (story.attachments.isNotEmpty() && story.text.isNotBlank()) { + Text(story.text, style = MaterialTheme.typography.bodyLarge, color = Color.White) + } + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + "${story.viewCount} просмотров", + style = MaterialTheme.typography.bodySmall, + color = Color.White.copy(alpha = 0.72f) + ) + if (story.hasProtectedContent) { + Spacer(modifier = Modifier.width(10.dp)) + Text( + "защищено", + style = MaterialTheme.typography.labelSmall, + color = Color.White.copy(alpha = 0.72f) + ) + } + } + } +} + +@Composable +private fun StoryTransfersCard( + transfers: List<StoryTransferUiState>, + onRetryTransfer: (String) -> Unit, + onCancelTransfer: (String) -> Unit +) { + IkarSectionCard { + IkarSectionTitle("Очередь историй", modifier = Modifier.padding(bottom = 8.dp)) + transfers.forEachIndexed { index, transfer -> + Column(verticalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.padding(vertical = 8.dp)) { + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { + Text(transfer.title, style = MaterialTheme.typography.titleMedium, maxLines = 1, overflow = TextOverflow.Ellipsis) + Text("${transfer.progressPercent}%", style = MaterialTheme.typography.bodySmall, color = TelegramInkMuted) + } + LinearProgressIndicator( + progress = { transfer.progressPercent / 100f }, + modifier = Modifier.fillMaxWidth(), + color = TelegramBlue + ) + Text(transfer.errorMessage ?: transfer.status, style = MaterialTheme.typography.bodySmall, color = TelegramInkMuted) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + TextButton(onClick = { onRetryTransfer(transfer.groupId) }) { Text("Повторить") } + TextButton(onClick = { onCancelTransfer(transfer.groupId) }) { Text("Отменить") } + } + } + if (index != transfers.lastIndex) { + IkarRowDivider(startIndent = 0.dp) + } + } + } +} + +@Composable +private fun MediaPreviewGrid( + media: List<StoryMediaPreviewUiState>, + onRemoveMedia: (String) -> Unit, + modifier: Modifier = Modifier +) { + Column(modifier = modifier, verticalArrangement = Arrangement.spacedBy(10.dp)) { + media.chunked(2).forEach { row -> + Row(horizontalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.fillMaxWidth()) { + row.forEach { item -> + StoryMediaTile( + item = item, + onRemoveMedia = onRemoveMedia, + modifier = Modifier.weight(1f) + ) + } + if (row.size == 1) { + Box(modifier = Modifier.weight(1f)) + } + } + } + } +} + +@Composable +private fun StoryMediaTile( + item: StoryMediaPreviewUiState, + onRemoveMedia: (String) -> Unit, + modifier: Modifier = Modifier +) { + Surface(modifier = modifier, color = Color(0xFFF2F6FA), shape = RoundedCornerShape(18.dp)) { + Column(verticalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.padding(10.dp)) { + Box( + modifier = Modifier + .fillMaxWidth() + .aspectRatio(1f) + .clip(RoundedCornerShape(14.dp)) + .background(Color(0xFFEAF4FF)), + contentAlignment = Alignment.Center + ) { + if (item.contentType.startsWith("image/")) { + AsyncImage( + model = File(item.localPath), + contentDescription = null, + modifier = Modifier.fillMaxSize(), + contentScale = ContentScale.Crop + ) + } else { + Text("ВИДЕО", color = TelegramBlue, fontWeight = FontWeight.SemiBold) + } + } + Text(item.fileName, maxLines = 1, overflow = TextOverflow.Ellipsis, style = MaterialTheme.typography.bodySmall) + Text(item.fileSizeBytes.formatBytes(), style = MaterialTheme.typography.bodySmall, color = TelegramInkMuted) + TextButton(onClick = { onRemoveMedia(item.localPath) }, modifier = Modifier.fillMaxWidth()) { + Text("Удалить") + } + } + } +} + +@Composable +private fun EmptyStoriesState(onComposeClick: () -> Unit) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 18.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(10.dp) + ) { + Box( + modifier = Modifier + .size(62.dp) + .clip(CircleShape) + .background(Color(0xFFEAF4FF)), + contentAlignment = Alignment.Center + ) { + Icon(Icons.Outlined.Add, contentDescription = null, tint = TelegramBlue) + } + Text("Пока нет активных историй", style = MaterialTheme.typography.titleMedium) + Text("Опубликуйте первую историю со сроком жизни, приватностью и медиа.", style = MaterialTheme.typography.bodySmall, color = TelegramInkMuted) + TextButton(onClick = onComposeClick) { Text("Создать историю") } + } +} + +@Composable +private fun StoryPreviewRow( + story: StoryDto, + accessToken: String?, + onClick: () -> Unit +) { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onClick) + .padding(vertical = 12.dp), + horizontalArrangement = Arrangement.spacedBy(14.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Surface( + modifier = Modifier.size(48.dp), + color = if (story.viewedByMe) Color(0xFFF1F3F5) else Color(0xFFEAF4FF), + shape = CircleShape + ) { + Box(contentAlignment = Alignment.Center) { + val preview = story.attachments.firstOrNull { it.contentType.startsWith("image/") } + if (preview != null) { + AuthenticatedAsyncImage( + imageUrl = ServerConfig.normalizeMediaUrl(preview.downloadPath), + accessToken = accessToken, + contentDescription = preview.fileName, + modifier = Modifier.fillMaxSize(), + contentScale = ContentScale.Crop + ) + } else { + Text( + text = story.author.displayName.firstOrNull()?.uppercaseChar()?.toString() + ?: story.author.username.firstOrNull()?.uppercaseChar()?.toString().orEmpty(), + style = MaterialTheme.typography.titleLarge, + color = TelegramBlue, + fontWeight = FontWeight.SemiBold + ) + } + } + } + Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(3.dp)) { + Text(story.author.displayName, style = MaterialTheme.typography.titleLarge) + Text(story.text.ifBlank { "${story.attachments.size} медиа" }, style = MaterialTheme.typography.bodySmall, color = TelegramInkMuted, maxLines = 1, overflow = TextOverflow.Ellipsis) + Text("до ${story.expiresAt.shortDateTime()} · ${story.viewCount} просмотров", style = MaterialTheme.typography.bodySmall, color = TelegramInkMuted) + } + if (story.hasProtectedContent) { + Text("защищено", style = MaterialTheme.typography.labelSmall, color = TelegramBlue) + } + } +} + +@Composable +private fun StoryAttachmentStrip(story: StoryDto, accessToken: String?) { + if (story.attachments.isEmpty()) { + return + } + + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + story.attachments.take(4).forEach { attachment -> + StoryAttachmentPreview(attachment = attachment, accessToken = accessToken) + } + if (story.attachments.size > 4) { + Text("+${story.attachments.size - 4} медиа", style = MaterialTheme.typography.bodySmall, color = TelegramInkMuted) + } + } +} + +@Composable +private fun StoryAttachmentPreview(attachment: AttachmentDto, accessToken: String?) { + if (attachment.contentType.startsWith("image/")) { + AuthenticatedAsyncImage( + imageUrl = ServerConfig.normalizeMediaUrl(attachment.downloadPath), + accessToken = accessToken, + contentDescription = attachment.fileName, + modifier = Modifier + .fillMaxWidth() + .aspectRatio(16f / 10f) + .clip(RoundedCornerShape(18.dp)), + contentScale = ContentScale.Crop + ) + } else { + Surface(color = Color(0xFFEAF4FF), shape = RoundedCornerShape(18.dp), modifier = Modifier.fillMaxWidth()) { + Column(modifier = Modifier.padding(14.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) { + Text("Видео", color = TelegramBlue, fontWeight = FontWeight.SemiBold) + Text(attachment.fileName, maxLines = 1, overflow = TextOverflow.Ellipsis, style = MaterialTheme.typography.bodySmall, color = TelegramInkMuted) + } + } + } +} + +@Composable +private fun StoryOptionRow( + title: String, + subtitle: String, + selected: Boolean, + onClick: () -> Unit +) { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onClick) + .padding(vertical = 12.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Box( + modifier = Modifier + .size(20.dp) + .clip(CircleShape) + .background(if (selected) TelegramBlue else Color(0xFFDDE7F0)) + ) + Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text(title, style = MaterialTheme.typography.titleMedium) + Text(subtitle, style = MaterialTheme.typography.bodySmall, color = TelegramInkMuted) + } + } +} + +private fun StoryVisibility.label(): String = when (this) { + StoryVisibility.Everyone -> "Все" + StoryVisibility.Contacts -> "Контакты" + StoryVisibility.CloseFriends -> "Близкие друзья" + StoryVisibility.Private -> "Только я" +} + +private fun StoryVisibility.subtitle(): String = when (this) { + StoryVisibility.Everyone -> "История доступна всем пользователям." + StoryVisibility.Contacts -> "Показывать только контактам." + StoryVisibility.CloseFriends -> "Использовать приватный круг близких." + StoryVisibility.Private -> "Скрыть от всех кроме автора." +} + +private fun Int.labelTtl(): String = when (this) { + 3_600 -> "1 ч" + 43_200 -> "12 ч" + 86_400 -> "24 ч" + 604_800 -> "7 д" + else -> "${this / 3_600} ч" +} + +private fun Long.formatBytes(): String = when { + this >= 1024L * 1024L -> "${this / (1024L * 1024L)} MB" + this >= 1024L -> "${this / 1024L} KB" + else -> "$this B" +} + +private fun String.shortDateTime(): String = + take(16).replace('T', ' ') diff --git a/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/stories/StoriesViewModel.kt b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/stories/StoriesViewModel.kt new file mode 100644 index 00000000..fe20806c --- /dev/null +++ b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/stories/StoriesViewModel.kt @@ -0,0 +1,335 @@ +package com.seven.ikar.kotlin.feature.stories + +import android.net.Uri +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.seven.ikar.kotlin.app.AppContainer +import com.seven.ikar.kotlin.core.local.TransferQueueEntity +import com.seven.ikar.kotlin.core.model.StoryDto +import com.seven.ikar.kotlin.core.model.StoryVisibility +import com.seven.ikar.kotlin.feature.chat.LocalAttachmentFile +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch + +data class StoryMediaPreviewUiState( + val fileName: String, + val contentType: String, + val fileSizeBytes: Long, + val localPath: String +) + +data class StoryTransferUiState( + val groupId: String, + val title: String, + val status: String, + val progressPercent: Int, + val errorMessage: String? +) + +data class StoriesUiState( + val accessToken: String? = null, + val currentUserId: String? = null, + val stories: List<StoryDto> = emptyList(), + val draftText: String = "", + val visibility: StoryVisibility = StoryVisibility.Everyone, + val hasProtectedContent: Boolean = false, + val ttlSeconds: Int = 86_400, + val pendingMedia: List<StoryMediaPreviewUiState> = emptyList(), + val activeTransfers: List<StoryTransferUiState> = emptyList(), + val isLoading: Boolean = false, + val isSaving: Boolean = false, + val isImportingMedia: Boolean = false, + val errorMessage: String? = null, + val createdStoryId: String? = null, + val selectedStory: StoryDto? = null +) + +class StoriesViewModel(private val container: AppContainer) : ViewModel() { + private val _uiState = MutableStateFlow(StoriesUiState()) + val uiState: StateFlow<StoriesUiState> = _uiState.asStateFlow() + private var pendingMediaFiles: List<LocalAttachmentFile> = emptyList() + private var hadActiveStoryTransfers = false + + init { + attachStoryTransferState() + refresh() + } + + fun refresh() { + viewModelScope.launch { + _uiState.value = _uiState.value.copy(isLoading = true, errorMessage = null) + runCatching { + container.executeAuthorized { session -> + session.accessToken to container.apiClient.getStories(session.accessToken) + } + }.onSuccess { (accessToken, stories) -> + _uiState.value = _uiState.value.copy( + accessToken = accessToken, + currentUserId = container.sessionStore.read()?.user?.id, + stories = stories.sortedByDescending { it.createdAt }, + isLoading = false + ) + }.onFailure { error -> + _uiState.value = _uiState.value.copy(isLoading = false, errorMessage = error.message) + } + } + } + + fun updateDraftText(value: String) { + _uiState.value = _uiState.value.copy(draftText = value.take(500), errorMessage = null, createdStoryId = null) + } + + fun setVisibility(visibility: StoryVisibility) { + _uiState.value = _uiState.value.copy(visibility = visibility) + } + + fun setProtectedContent(value: Boolean) { + _uiState.value = _uiState.value.copy(hasProtectedContent = value) + } + + fun setTtlSeconds(value: Int) { + _uiState.value = _uiState.value.copy(ttlSeconds = value.coerceIn(300, 604_800)) + } + + fun consumeMediaUris(uris: List<Uri>) { + if (uris.isEmpty()) { + return + } + + val availableSlots = MaxStoryMediaCount - pendingMediaFiles.size + if (availableSlots <= 0) { + _uiState.value = _uiState.value.copy(errorMessage = "В одной истории максимум $MaxStoryMediaCount файлов.") + return + } + + viewModelScope.launch { + _uiState.value = _uiState.value.copy(isImportingMedia = true, errorMessage = null) + runCatching { + uris.take(availableSlots).map { uri -> container.attachmentFileService.importFromUri(uri) } + }.onSuccess { imported -> + pendingMediaFiles = (pendingMediaFiles + imported).take(MaxStoryMediaCount) + _uiState.value = _uiState.value.copy( + pendingMedia = pendingMediaFiles.toStoryMediaPreviewUiState(), + isImportingMedia = false + ) + }.onFailure { error -> + _uiState.value = _uiState.value.copy(isImportingMedia = false, errorMessage = error.message) + } + } + } + + fun removePendingMedia(localPath: String) { + pendingMediaFiles = pendingMediaFiles.filterNot { it.localPath == localPath } + _uiState.value = _uiState.value.copy(pendingMedia = pendingMediaFiles.toStoryMediaPreviewUiState()) + } + + fun clearPendingMedia() { + pendingMediaFiles = emptyList() + _uiState.value = _uiState.value.copy(pendingMedia = emptyList()) + } + + fun createStory(onCreated: () -> Unit = {}) { + val state = _uiState.value + val text = state.draftText.trim() + val mediaFiles = pendingMediaFiles + if ((text.isBlank() && mediaFiles.isEmpty()) || state.isSaving) { + _uiState.value = state.copy(errorMessage = "Добавьте текст или медиа.") + return + } + + viewModelScope.launch { + _uiState.value = _uiState.value.copy(isSaving = true, errorMessage = null) + if (mediaFiles.isNotEmpty()) { + runCatching { + container.transferQueueRepository.enqueueStoryUpload( + text = text.takeIf { it.isNotBlank() }, + visibility = state.visibility, + hasProtectedContent = state.hasProtectedContent, + ttlSeconds = state.ttlSeconds, + files = mediaFiles + ) + }.onSuccess { groupId -> + pendingMediaFiles = emptyList() + _uiState.value = _uiState.value.copy( + draftText = "", + pendingMedia = emptyList(), + isSaving = false, + createdStoryId = groupId + ) + onCreated() + }.onFailure { error -> + _uiState.value = _uiState.value.copy(isSaving = false, errorMessage = error.message) + } + return@launch + } + + runCatching { + container.executeAuthorized { session -> + container.apiClient.createStory( + accessToken = session.accessToken, + text = text, + visibility = state.visibility, + hasProtectedContent = state.hasProtectedContent, + ttlSeconds = state.ttlSeconds + ) + } + }.onSuccess { story -> + _uiState.value = _uiState.value.copy( + stories = listOf(story) + _uiState.value.stories.filterNot { it.id == story.id }, + draftText = "", + isSaving = false, + createdStoryId = story.id + ) + onCreated() + }.onFailure { error -> + _uiState.value = _uiState.value.copy(isSaving = false, errorMessage = error.message) + } + } + } + + fun openStory(story: StoryDto) { + viewModelScope.launch { + _uiState.value = _uiState.value.copy(selectedStory = story, errorMessage = null) + runCatching { + container.executeAuthorized { session -> + container.apiClient.getStory(session.accessToken, story.id) + } + }.onSuccess { viewedStory -> + _uiState.value = _uiState.value.copy( + selectedStory = viewedStory, + stories = _uiState.value.stories.map { if (it.id == viewedStory.id) viewedStory else it } + ) + }.onFailure { error -> + _uiState.value = _uiState.value.copy(errorMessage = error.message) + } + } + } + + fun openStoryById(storyId: String) { + val story = _uiState.value.stories.firstOrNull { it.id == storyId } + if (story != null) { + openStory(story) + return + } + + viewModelScope.launch { + _uiState.value = _uiState.value.copy(isLoading = true, errorMessage = null) + runCatching { + container.executeAuthorized { session -> + container.apiClient.getStory(session.accessToken, storyId) + } + }.onSuccess { storyFromServer -> + _uiState.value = _uiState.value.copy( + selectedStory = storyFromServer, + stories = listOf(storyFromServer) + _uiState.value.stories.filterNot { it.id == storyFromServer.id }, + isLoading = false + ) + }.onFailure { error -> + _uiState.value = _uiState.value.copy(isLoading = false, errorMessage = error.message) + } + } + } + + fun openNextStory() { + openRelativeStory(offset = 1) + } + + fun openPreviousStory() { + openRelativeStory(offset = -1) + } + + fun closeStoryPreview() { + _uiState.value = _uiState.value.copy(selectedStory = null) + } + + fun deleteStory(story: StoryDto) { + viewModelScope.launch { + runCatching { + container.executeAuthorized { session -> container.apiClient.deleteStory(session.accessToken, story.id) } + }.onSuccess { + _uiState.value = _uiState.value.copy( + stories = _uiState.value.stories.filterNot { it.id == story.id }, + selectedStory = _uiState.value.selectedStory?.takeUnless { it.id == story.id } + ) + }.onFailure { error -> + _uiState.value = _uiState.value.copy(errorMessage = error.message) + } + } + } + + fun retryTransfer(groupId: String) { + viewModelScope.launch { + runCatching { container.transferQueueRepository.retry(groupId) } + .onFailure { error -> _uiState.value = _uiState.value.copy(errorMessage = error.message) } + } + } + + fun cancelTransfer(groupId: String) { + viewModelScope.launch { + runCatching { container.transferQueueRepository.cancel(groupId) } + .onFailure { error -> _uiState.value = _uiState.value.copy(errorMessage = error.message) } + } + } + + private fun attachStoryTransferState() { + viewModelScope.launch { + container.transferQueueRepository.observeActiveStoryUploads().collect { items -> + val transfers = items.toStoryTransferUiState() + if (hadActiveStoryTransfers && transfers.isEmpty()) { + refresh() + } + hadActiveStoryTransfers = transfers.isNotEmpty() + _uiState.value = _uiState.value.copy(activeTransfers = transfers) + } + } + } + + private fun openRelativeStory(offset: Int) { + val state = _uiState.value + val selected = state.selectedStory ?: return + val stories = state.stories + if (stories.isEmpty()) { + return + } + val currentIndex = stories.indexOfFirst { it.id == selected.id } + if (currentIndex < 0) { + return + } + + val nextIndex = (currentIndex + offset).coerceIn(0, stories.lastIndex) + if (nextIndex != currentIndex) { + openStory(stories[nextIndex]) + } + } + + companion object { + private const val MaxStoryMediaCount = 10 + } +} + +private fun List<LocalAttachmentFile>.toStoryMediaPreviewUiState(): List<StoryMediaPreviewUiState> = + map { file -> + StoryMediaPreviewUiState( + fileName = file.fileName, + contentType = file.contentType, + fileSizeBytes = file.fileSizeBytes, + localPath = file.localPath + ) + } + +private fun List<TransferQueueEntity>.toStoryTransferUiState(): List<StoryTransferUiState> = + groupBy { it.groupId } + .map { (groupId, items) -> + val ordered = items.sortedBy { it.sortOrder } + val first = ordered.first() + StoryTransferUiState( + groupId = groupId, + title = if (ordered.size == 1) first.fileName else "${ordered.size} файлов", + status = first.status, + progressPercent = ordered.maxOfOrNull { it.progressPercent } ?: 0, + errorMessage = ordered.firstNotNullOfOrNull { it.errorMessage } + ) + } + .sortedBy { it.title } diff --git a/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/transfer/AttachmentDownloadWorker.kt b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/transfer/AttachmentDownloadWorker.kt new file mode 100644 index 00000000..c2e207fe --- /dev/null +++ b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/transfer/AttachmentDownloadWorker.kt @@ -0,0 +1,123 @@ +package com.seven.ikar.kotlin.feature.transfer + +import android.content.Context +import androidx.work.CoroutineWorker +import androidx.work.WorkerParameters +import com.seven.ikar.kotlin.app.IkarNativeApp +import com.seven.ikar.kotlin.core.local.TransferQueueStatus +import com.seven.ikar.kotlin.core.model.AttachmentDto +import com.seven.ikar.kotlin.core.model.AttachmentKind +import kotlinx.coroutines.runBlocking +import java.io.File + +class AttachmentDownloadWorker( + appContext: Context, + params: WorkerParameters +) : CoroutineWorker(appContext, params) { + override suspend fun doWork(): Result { + val groupId = inputData.getString(KeyGroupId) ?: return Result.failure() + val container = (applicationContext as IkarNativeApp).container + val dao = container.localDatabase.transferQueueDao() + val items = dao.getGroup(groupId) + if (items.isEmpty()) { + return Result.success() + } + + val first = items.first() + val attachmentId = first.attachmentId ?: return Result.failure() + val chatId = first.chatId ?: return Result.failure() + val now = System.currentTimeMillis() + val totalBytes = first.fileSizeBytes.coerceAtLeast(0L) + val showForeground = totalBytes >= TransferForegroundNotification.LargeTransferThresholdBytes + if (showForeground) { + setForeground( + TransferForegroundNotification.createForegroundInfo( + context = applicationContext, + groupId = groupId, + title = "Загрузка вложения", + message = first.fileName, + progressPercent = null + ) + ) + } + dao.updateGroupStatus(groupId, TransferQueueStatus.Running, 5, null, now) + var lastProgressPercent = 5 + var lastProgressAtMillis = now + + suspend fun publishProgress(bytesTransferred: Long, reportedTotalBytes: Long, force: Boolean = false) { + val effectiveTotalBytes = reportedTotalBytes.takeIf { it > 0L } ?: totalBytes.takeIf { it > 0L } ?: return + val progressPercent = ((bytesTransferred.coerceAtMost(effectiveTotalBytes) * 100L) / effectiveTotalBytes) + .toInt() + .coerceIn(5, 99) + val currentTime = System.currentTimeMillis() + val shouldPublish = force || + progressPercent != lastProgressPercent && currentTime - lastProgressAtMillis >= ProgressUpdateIntervalMillis || + progressPercent >= 99 && lastProgressPercent < 99 + if (!shouldPublish) { + return + } + + lastProgressPercent = progressPercent + lastProgressAtMillis = currentTime + dao.updateGroupStatus(groupId, TransferQueueStatus.Running, progressPercent, null, currentTime) + if (showForeground) { + setForeground( + TransferForegroundNotification.createForegroundInfo( + context = applicationContext, + groupId = groupId, + title = "Загрузка вложения", + message = first.fileName, + progressPercent = progressPercent + ) + ) + } + } + + return runCatching { + val attachment = AttachmentDto( + id = attachmentId, + fileName = first.fileName, + contentType = first.contentType, + fileSizeBytes = first.fileSizeBytes, + downloadPath = "/api/chats/$chatId/attachments/$attachmentId/content", + kind = AttachmentKind.File, + sortOrder = first.sortOrder + ) + val target = File(first.localPath).takeIf { it.path.isNotBlank() } + ?: container.attachmentFileService.createAttachmentCacheFile(attachment.id, attachment.fileName) + container.executeAuthorized { session -> + val downloaded = container.apiClient.downloadAttachmentTo( + accessToken = session.accessToken, + attachment = attachment, + targetFile = target, + onProgress = { bytesRead, reportedTotalBytes -> + runBlocking { + publishProgress(bytesRead, reportedTotalBytes) + } + } + ) + container.attachmentFileService.cacheDownloadedAttachment(downloaded) + } + }.fold( + onSuccess = { + dao.updateGroupStatus(groupId, TransferQueueStatus.Completed, 100, null, System.currentTimeMillis()) + Result.success() + }, + onFailure = { error -> + dao.updateGroupStatus( + groupId = groupId, + status = TransferQueueStatus.Failed, + progressPercent = 0, + errorMessage = error.message, + updatedAtMillis = System.currentTimeMillis() + ) + if (runAttemptCount < 3) Result.retry() else Result.failure() + } + ) + } + + companion object { + const val KeyGroupId = "group_id" + private const val ProgressUpdateIntervalMillis = 500L + } +} diff --git a/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/transfer/AttachmentUploadWorker.kt b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/transfer/AttachmentUploadWorker.kt new file mode 100644 index 00000000..687a440d --- /dev/null +++ b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/transfer/AttachmentUploadWorker.kt @@ -0,0 +1,160 @@ +package com.seven.ikar.kotlin.feature.transfer + +import android.content.Context +import androidx.work.CoroutineWorker +import androidx.work.WorkerParameters +import com.seven.ikar.kotlin.app.IkarNativeApp +import com.seven.ikar.kotlin.core.local.TransferQueueStatus +import com.seven.ikar.kotlin.core.local.TransferQueueTypes +import com.seven.ikar.kotlin.core.model.StoryVisibility +import com.seven.ikar.kotlin.core.network.UploadFileSource +import kotlinx.coroutines.runBlocking +import java.io.File + +class AttachmentUploadWorker( + appContext: Context, + params: WorkerParameters +) : CoroutineWorker(appContext, params) { + override suspend fun doWork(): Result { + val groupId = inputData.getString(KeyGroupId) ?: return Result.failure() + val container = (applicationContext as IkarNativeApp).container + val dao = container.localDatabase.transferQueueDao() + val items = dao.getGroup(groupId) + if (items.isEmpty()) { + return Result.success() + } + + val first = items.first() + val isStoryUpload = first.type == TransferQueueTypes.StoryUpload + val chatId = if (isStoryUpload) null else first.chatId ?: return Result.failure() + val now = System.currentTimeMillis() + val totalBytes = items.sumOf { it.fileSizeBytes.coerceAtLeast(0L) } + val showForeground = totalBytes >= TransferForegroundNotification.LargeTransferThresholdBytes + val notificationMessage = if (items.size == 1) first.fileName else "${items.size} файлов" + val notificationTitle = if (isStoryUpload) "Загрузка истории" else "Загрузка вложений" + if (showForeground) { + setForeground( + TransferForegroundNotification.createForegroundInfo( + context = applicationContext, + groupId = groupId, + title = notificationTitle, + message = notificationMessage, + progressPercent = null + ) + ) + } + dao.updateGroupStatus(groupId, TransferQueueStatus.Running, 5, null, now) + var lastProgressPercent = 5 + var lastProgressAtMillis = now + + suspend fun publishProgress(bytesTransferred: Long, force: Boolean = false) { + if (totalBytes <= 0L) { + return + } + + val progressPercent = ((bytesTransferred.coerceAtMost(totalBytes) * 100L) / totalBytes) + .toInt() + .coerceIn(5, 99) + val currentTime = System.currentTimeMillis() + val shouldPublish = force || + progressPercent != lastProgressPercent && currentTime - lastProgressAtMillis >= ProgressUpdateIntervalMillis || + progressPercent >= 99 && lastProgressPercent < 99 + if (!shouldPublish) { + return + } + + lastProgressPercent = progressPercent + lastProgressAtMillis = currentTime + dao.updateGroupStatus(groupId, TransferQueueStatus.Running, progressPercent, null, currentTime) + if (showForeground) { + setForeground( + TransferForegroundNotification.createForegroundInfo( + context = applicationContext, + groupId = groupId, + title = notificationTitle, + message = notificationMessage, + progressPercent = progressPercent + ) + ) + } + } + + return runCatching { + val uploadedBytesByItem = LongArray(items.size) + val progressLock = Any() + val files = items + .sortedBy { it.sortOrder } + .mapIndexed { index, item -> + UploadFileSource( + fileName = item.fileName, + contentType = item.contentType, + contentLength = item.fileSizeBytes, + onBytesWritten = { itemBytesWritten -> + val bytesTransferred = synchronized(progressLock) { + uploadedBytesByItem[index] = maxOf(uploadedBytesByItem[index], itemBytesWritten) + uploadedBytesByItem.sum() + } + runBlocking { + publishProgress(bytesTransferred) + } + } + ) { + File(item.localPath).inputStream() + } + } + + container.executeAuthorized { session -> + if (isStoryUpload) { + container.apiClient.createMediaStory( + accessToken = session.accessToken, + text = first.text, + visibility = first.storyVisibility.toStoryVisibility(), + hasProtectedContent = first.hasProtectedContent, + ttlSeconds = first.ttlSeconds ?: 86_400, + files = files + ) + } else if (files.size == 1) { + container.apiClient.uploadAttachment( + accessToken = session.accessToken, + chatId = chatId!!, + text = first.text, + replyToMessageId = first.replyToMessageId, + file = files.first() + ) + } else { + container.apiClient.uploadAttachmentAlbum( + accessToken = session.accessToken, + chatId = chatId!!, + text = first.text, + replyToMessageId = first.replyToMessageId, + files = files + ) + } + } + }.fold( + onSuccess = { + dao.updateGroupStatus(groupId, TransferQueueStatus.Completed, 100, null, System.currentTimeMillis()) + Result.success() + }, + onFailure = { error -> + dao.updateGroupStatus( + groupId = groupId, + status = TransferQueueStatus.Failed, + progressPercent = 0, + errorMessage = error.message, + updatedAtMillis = System.currentTimeMillis() + ) + if (runAttemptCount < 3) Result.retry() else Result.failure() + } + ) + } + + companion object { + const val KeyGroupId = "group_id" + private const val ProgressUpdateIntervalMillis = 500L + } +} + +private fun String?.toStoryVisibility(): StoryVisibility = + runCatching { StoryVisibility.valueOf(this ?: StoryVisibility.Everyone.name) } + .getOrDefault(StoryVisibility.Everyone) diff --git a/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/transfer/TransferForegroundNotification.kt b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/transfer/TransferForegroundNotification.kt new file mode 100644 index 00000000..bd04c353 --- /dev/null +++ b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/transfer/TransferForegroundNotification.kt @@ -0,0 +1,71 @@ +package com.seven.ikar.kotlin.feature.transfer + +import android.app.NotificationChannel +import android.app.NotificationManager +import android.content.Context +import android.content.pm.ServiceInfo +import android.os.Build +import androidx.core.app.NotificationCompat +import androidx.core.content.getSystemService +import androidx.work.ForegroundInfo +import com.seven.ikar.kotlin.R + +object TransferForegroundNotification { + const val LargeTransferThresholdBytes: Long = 8L * 1024L * 1024L + private const val ChannelId = "ikar_transfers" + + fun createForegroundInfo( + context: Context, + groupId: String, + title: String, + message: String, + progressPercent: Int? + ): ForegroundInfo { + ensureChannel(context) + val notification = NotificationCompat.Builder(context, ChannelId) + .setSmallIcon(R.drawable.ic_notification_transfer) + .setContentTitle(title) + .setContentText(message) + .setOnlyAlertOnce(true) + .setOngoing(true) + .setPriority(NotificationCompat.PRIORITY_LOW) + .apply { + val progress = progressPercent?.coerceIn(0, 100) + if (progress == null) { + setProgress(0, 0, true) + } else { + setProgress(100, progress, false) + } + } + .build() + + val notificationId = (groupId.hashCode() and Int.MAX_VALUE).coerceAtLeast(1) + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + ForegroundInfo(notificationId, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC) + } else { + ForegroundInfo(notificationId, notification) + } + } + + private fun ensureChannel(context: Context) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) { + return + } + + val manager = context.getSystemService<NotificationManager>() ?: return + if (manager.getNotificationChannel(ChannelId) != null) { + return + } + + manager.createNotificationChannel( + NotificationChannel( + ChannelId, + "Передача файлов", + NotificationManager.IMPORTANCE_LOW + ).apply { + description = "Статус крупных загрузок и скачиваний" + setShowBadge(false) + } + ) + } +} diff --git a/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/transfer/TransferQueueRepository.kt b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/transfer/TransferQueueRepository.kt new file mode 100644 index 00000000..15aa63f3 --- /dev/null +++ b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/transfer/TransferQueueRepository.kt @@ -0,0 +1,204 @@ +package com.seven.ikar.kotlin.feature.transfer + +import android.content.Context +import androidx.work.Constraints +import androidx.work.ExistingWorkPolicy +import androidx.work.NetworkType +import androidx.work.OneTimeWorkRequestBuilder +import androidx.work.WorkManager +import androidx.work.workDataOf +import com.seven.ikar.kotlin.core.local.TransferQueueDao +import com.seven.ikar.kotlin.core.local.TransferQueueEntity +import com.seven.ikar.kotlin.core.local.TransferQueueStatus +import com.seven.ikar.kotlin.core.local.TransferQueueTypes +import com.seven.ikar.kotlin.core.model.AttachmentDto +import com.seven.ikar.kotlin.core.model.StoryVisibility +import com.seven.ikar.kotlin.feature.chat.LocalAttachmentFile +import kotlinx.coroutines.flow.Flow +import java.util.UUID + +class TransferQueueRepository( + private val dao: TransferQueueDao, + private val scheduler: TransferWorkScheduler +) { + fun observeActiveForChat(chatId: String): Flow<List<TransferQueueEntity>> = + dao.observeActiveForChat(chatId) + + fun observeActiveStoryUploads(): Flow<List<TransferQueueEntity>> = + dao.observeActiveStoryUploads() + + suspend fun enqueueUpload( + chatId: String, + text: String?, + replyToMessageId: String?, + files: List<LocalAttachmentFile> + ): String { + require(files.isNotEmpty()) { "Нужен хотя бы один файл." } + + val groupId = UUID.randomUUID().toString() + val now = System.currentTimeMillis() + dao.upsert( + files.mapIndexed { index, file -> + TransferQueueEntity( + id = UUID.randomUUID().toString(), + groupId = groupId, + type = TransferQueueTypes.Upload, + status = TransferQueueStatus.Queued, + chatId = chatId, + text = text?.takeIf { it.isNotBlank() }, + replyToMessageId = replyToMessageId, + attachmentId = null, + storyVisibility = null, + hasProtectedContent = false, + ttlSeconds = null, + fileName = file.fileName, + contentType = file.contentType, + fileSizeBytes = file.fileSizeBytes, + localPath = file.localPath, + sortOrder = index, + progressPercent = 0, + errorMessage = null, + createdAtMillis = now, + updatedAtMillis = now + ) + } + ) + scheduler.enqueueUpload(groupId) + return groupId + } + + suspend fun enqueueStoryUpload( + text: String?, + visibility: StoryVisibility, + hasProtectedContent: Boolean, + ttlSeconds: Int, + files: List<LocalAttachmentFile> + ): String { + require(files.isNotEmpty()) { "Нужен хотя бы один файл." } + + val groupId = UUID.randomUUID().toString() + val now = System.currentTimeMillis() + dao.upsert( + files.mapIndexed { index, file -> + TransferQueueEntity( + id = UUID.randomUUID().toString(), + groupId = groupId, + type = TransferQueueTypes.StoryUpload, + status = TransferQueueStatus.Queued, + chatId = null, + text = text?.takeIf { it.isNotBlank() }, + replyToMessageId = null, + attachmentId = null, + storyVisibility = visibility.name, + hasProtectedContent = hasProtectedContent, + ttlSeconds = ttlSeconds, + fileName = file.fileName, + contentType = file.contentType, + fileSizeBytes = file.fileSizeBytes, + localPath = file.localPath, + sortOrder = index, + progressPercent = 0, + errorMessage = null, + createdAtMillis = now, + updatedAtMillis = now + ) + } + ) + scheduler.enqueueStoryUpload(groupId) + return groupId + } + + suspend fun enqueueDownload(chatId: String, attachment: AttachmentDto): String { + val groupId = UUID.randomUUID().toString() + val now = System.currentTimeMillis() + dao.upsert( + listOf( + TransferQueueEntity( + id = UUID.randomUUID().toString(), + groupId = groupId, + type = TransferQueueTypes.Download, + status = TransferQueueStatus.Queued, + chatId = chatId, + text = null, + replyToMessageId = null, + attachmentId = attachment.id, + storyVisibility = null, + hasProtectedContent = false, + ttlSeconds = null, + fileName = attachment.fileName, + contentType = attachment.contentType, + fileSizeBytes = attachment.fileSizeBytes, + localPath = "", + sortOrder = attachment.sortOrder, + progressPercent = 0, + errorMessage = null, + createdAtMillis = now, + updatedAtMillis = now + ) + ) + ) + scheduler.enqueueDownload(groupId) + return groupId + } + + suspend fun retry(groupId: String) { + val type = dao.getGroup(groupId).firstOrNull()?.type + dao.updateGroupStatus(groupId, TransferQueueStatus.Queued, 0, null, System.currentTimeMillis()) + when (type) { + TransferQueueTypes.Download -> scheduler.enqueueDownload(groupId) + TransferQueueTypes.StoryUpload -> scheduler.enqueueStoryUpload(groupId) + else -> scheduler.enqueueUpload(groupId) + } + } + + suspend fun cancel(groupId: String) { + scheduler.cancel(groupId) + dao.deleteGroup(groupId) + } +} + +class TransferWorkScheduler(context: Context) { + private val workManager = WorkManager.getInstance(context.applicationContext) + private val networkConstraints = Constraints.Builder() + .setRequiredNetworkType(NetworkType.CONNECTED) + .build() + + fun enqueueUpload(groupId: String) { + val request = OneTimeWorkRequestBuilder<AttachmentUploadWorker>() + .setInputData(workDataOf(AttachmentUploadWorker.KeyGroupId to groupId)) + .setConstraints(networkConstraints) + .build() + + workManager.enqueueUniqueWork(uploadWorkName(groupId), ExistingWorkPolicy.REPLACE, request) + } + + fun enqueueStoryUpload(groupId: String) { + val request = OneTimeWorkRequestBuilder<AttachmentUploadWorker>() + .setInputData(workDataOf(AttachmentUploadWorker.KeyGroupId to groupId)) + .setConstraints(networkConstraints) + .build() + + workManager.enqueueUniqueWork(storyUploadWorkName(groupId), ExistingWorkPolicy.REPLACE, request) + } + + fun enqueueDownload(groupId: String) { + val request = OneTimeWorkRequestBuilder<AttachmentDownloadWorker>() + .setInputData(workDataOf(AttachmentDownloadWorker.KeyGroupId to groupId)) + .setConstraints(networkConstraints) + .build() + + workManager.enqueueUniqueWork(downloadWorkName(groupId), ExistingWorkPolicy.REPLACE, request) + } + + fun cancel(groupId: String) { + workManager.cancelUniqueWork(uploadWorkName(groupId)) + workManager.cancelUniqueWork(storyUploadWorkName(groupId)) + workManager.cancelUniqueWork(downloadWorkName(groupId)) + } + + private fun uploadWorkName(groupId: String): String = "upload-$groupId" + + private fun storyUploadWorkName(groupId: String): String = "story-upload-$groupId" + + private fun downloadWorkName(groupId: String): String = "download-$groupId" +} diff --git a/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/update/AppUpdateScreen.kt b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/update/AppUpdateScreen.kt new file mode 100644 index 00000000..246c206e --- /dev/null +++ b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/update/AppUpdateScreen.kt @@ -0,0 +1,372 @@ +package com.seven.ikar.kotlin.feature.update + +import androidx.compose.foundation.border +import androidx.compose.foundation.verticalScroll +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.WindowInsetsSides +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.only +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.safeDrawing +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.material3.Button +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.RectangleShape +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.seven.ikar.kotlin.core.update.AppUpdateInstallerEvent +import com.seven.ikar.kotlin.ui.IkarScreenSurface +import com.seven.ikar.kotlin.ui.IkarSimpleHeader +import com.seven.ikar.kotlin.ui.theme.TelegramBorder +import com.seven.ikar.kotlin.ui.theme.TelegramInkMuted +import java.text.DecimalFormat +import java.time.OffsetDateTime +import java.time.ZoneId +import java.time.format.DateTimeFormatter + +@Composable +fun AppUpdateScreen( + state: AppUpdateUiState, + onBackClick: () -> Unit, + onRefreshClick: () -> Unit, + onInstallClick: () -> Unit, + onGrantPermissionClick: () -> Unit +) { + IkarScreenSurface(modifier = Modifier.fillMaxSize()) { + Column( + modifier = Modifier + .fillMaxSize() + .windowInsetsPadding(WindowInsets.safeDrawing.only(WindowInsetsSides.Top)) + ) { + Column( + modifier = Modifier.padding(start = 18.dp, top = 16.dp, end = 18.dp, bottom = 12.dp) + ) { + IkarSimpleHeader( + title = "Обновление приложения", + onBackClick = onBackClick + ) + } + + Column( + modifier = Modifier + .weight(1f) + .verticalScroll(rememberScrollState()) + .padding(horizontal = 18.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + UpdatePanel { + Text( + text = state.statusTitle, + style = MaterialTheme.typography.headlineMedium, + color = MaterialTheme.colorScheme.primary, + fontWeight = FontWeight.SemiBold + ) + Spacer(modifier = Modifier.height(6.dp)) + Text( + text = state.statusDetail, + style = MaterialTheme.typography.bodyMedium, + color = TelegramInkMuted + ) + } + + if (state.isChecking || state.isSubmitting) { + LinearProgressIndicator(modifier = Modifier.fillMaxWidth()) + } + + UpdatePanel { + Text( + text = "Текущая установка", + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.SemiBold + ) + Spacer(modifier = Modifier.height(8.dp)) + UpdateFieldRow( + title = "Версия Икар", + value = "${state.currentVersionName} (${state.currentVersionCode})" + ) + UpdateFieldRow( + title = "Разрешение Android", + value = if (state.canRequestPackageInstalls) "Включено" else "Нужно включить" + ) + state.latestManifest?.let { manifest -> + UpdateFieldRow( + title = "Последний релиз Argus", + value = "${manifest.release.version} (${manifest.release.androidVersionCode ?: "?"})" + ) + } + } + + UpdatePanel { + Text( + text = "Как работает установка", + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.SemiBold + ) + Spacer(modifier = Modifier.height(6.dp)) + Text( + text = "Икар скачивает APK из Argus, проверяет имя пакета и код версии, затем передаёт файл системному установщику Android. Переключатель в настройках Android только разрешает запуск установки, но не устанавливает обновление сам.", + style = MaterialTheme.typography.bodyMedium, + color = TelegramInkMuted + ) + } + + state.availableUpdate?.let { update -> + UpdatePanel { + Text( + text = "Доступное обновление", + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.SemiBold + ) + Spacer(modifier = Modifier.height(8.dp)) + UpdateFieldRow( + title = "Версия", + value = "${update.manifest.release.version} (${update.manifest.release.androidVersionCode ?: "?"})" + ) + UpdateFieldRow( + title = "Размер APK", + value = formatBytes(update.manifest.release.packageSizeBytes) + ) + UpdateFieldRow( + title = "Опубликовано", + value = formatPublishedAt(update.manifest.release.publishedAt) + ) + update.manifest.release.notes?.takeIf { it.isNotBlank() }?.let { notes -> + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = "Что нового", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold + ) + Text( + text = notes, + style = MaterialTheme.typography.bodyMedium, + color = TelegramInkMuted + ) + } + } + } + + state.lastInstallerEvent?.let { event -> + UpdatePanel { + Text( + text = "Последний статус установки", + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.SemiBold + ) + Spacer(modifier = Modifier.height(8.dp)) + UpdateFieldRow(title = "Состояние", value = humanInstallerState(event)) + event.releaseVersion?.let { UpdateFieldRow(title = "Версия", value = it) } + event.versionCode?.let { UpdateFieldRow(title = "Код версии", value = it.toString()) } + event.detail?.let { + Spacer(modifier = Modifier.height(6.dp)) + Text( + text = it, + style = MaterialTheme.typography.bodyMedium, + color = TelegramInkMuted + ) + } + Spacer(modifier = Modifier.height(6.dp)) + Text( + text = formatInstallerTime(event), + style = MaterialTheme.typography.bodySmall, + color = TelegramInkMuted + ) + } + } + + Spacer(modifier = Modifier.height(8.dp)) + } + + UpdateActionBar( + state = state, + onRefreshClick = onRefreshClick, + onInstallClick = onInstallClick, + onGrantPermissionClick = onGrantPermissionClick + ) + } + } +} + +@Composable +private fun UpdateActionBar( + state: AppUpdateUiState, + onRefreshClick: () -> Unit, + onInstallClick: () -> Unit, + onGrantPermissionClick: () -> Unit +) { + Surface( + color = MaterialTheme.colorScheme.surface, + shape = RectangleShape, + shadowElevation = 8.dp + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .navigationBarsPadding() + .padding(horizontal = 18.dp, vertical = 12.dp), + verticalArrangement = Arrangement.spacedBy(10.dp) + ) { + when { + state.availableUpdate != null && !state.canRequestPackageInstalls -> { + Button( + onClick = onGrantPermissionClick, + modifier = Modifier.fillMaxWidth(), + enabled = !state.isChecking && !state.isSubmitting + ) { + Text("Открыть разрешение Android") + } + OutlinedButton( + onClick = onRefreshClick, + modifier = Modifier.fillMaxWidth(), + enabled = !state.isChecking && !state.isSubmitting + ) { + Text("Проверить ещё раз") + } + } + + state.availableUpdate != null -> { + Button( + onClick = onInstallClick, + modifier = Modifier.fillMaxWidth(), + enabled = !state.isChecking && !state.isSubmitting + ) { + if (state.isSubmitting) { + CircularProgressIndicator(modifier = Modifier.size(18.dp), strokeWidth = 2.dp) + } else { + Text("Скачать и установить") + } + } + OutlinedButton( + onClick = onRefreshClick, + modifier = Modifier.fillMaxWidth(), + enabled = !state.isChecking && !state.isSubmitting + ) { + Text("Проверить заново") + } + } + + else -> { + Button( + onClick = onRefreshClick, + modifier = Modifier.fillMaxWidth(), + enabled = !state.isChecking && !state.isSubmitting + ) { + if (state.isChecking) { + CircularProgressIndicator(modifier = Modifier.size(18.dp), strokeWidth = 2.dp) + } else if (state.canRequestPackageInstalls) { + Text("Проверить обновление") + } else { + Text("Открыть разрешение установки APK") + } + } + } + } + } + } +} + +@Composable +private fun UpdatePanel( + modifier: Modifier = Modifier, + padding: PaddingValues = PaddingValues(16.dp), + content: @Composable ColumnScope.() -> Unit +) { + Surface( + modifier = modifier + .fillMaxWidth() + .border(1.dp, TelegramBorder, RectangleShape), + shape = RectangleShape, + color = MaterialTheme.colorScheme.surface, + shadowElevation = 0.dp + ) { + Column( + modifier = Modifier.padding(padding), + content = content + ) + } +} + +@Composable +private fun UpdateFieldRow( + title: String, + value: String +) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 5.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = title, + modifier = Modifier.weight(1f), + style = MaterialTheme.typography.bodyMedium, + color = TelegramInkMuted + ) + Text( + text = value, + style = MaterialTheme.typography.titleMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } +} + +private fun humanInstallerState(event: AppUpdateInstallerEvent): String = + when (event.state) { + "submitted" -> "Запрос передан в Android" + "approval_required" -> "Нужно подтверждение пользователя" + "installed" -> "Обновление установлено" + "failed" -> "Установка завершилась ошибкой" + else -> event.state + } + +private fun formatPublishedAt(value: String): String = + runCatching { + OffsetDateTime.parse(value) + .atZoneSameInstant(ZoneId.systemDefault()) + .format(DateTimeFormatter.ofPattern("dd.MM.yyyy HH:mm")) + }.getOrElse { value } + +private fun formatInstallerTime(event: AppUpdateInstallerEvent): String = + runCatching { + java.time.Instant.ofEpochMilli(event.updatedAtEpochMillis) + .atZone(ZoneId.systemDefault()) + .format(DateTimeFormatter.ofPattern("dd.MM.yyyy HH:mm:ss")) + }.getOrElse { event.updatedAtEpochMillis.toString() } + +private fun formatBytes(value: Long): String { + if (value <= 0) { + return "0 Б" + } + + val units = listOf("Б", "КБ", "МБ", "ГБ") + var size = value.toDouble() + var unitIndex = 0 + while (size >= 1024 && unitIndex < units.lastIndex) { + size /= 1024 + unitIndex++ + } + return "${DecimalFormat("#,##0.#").format(size).replace(',', '.')} ${units[unitIndex]}" +} diff --git a/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/update/AppUpdateViewModel.kt b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/update/AppUpdateViewModel.kt new file mode 100644 index 00000000..0b4eaad2 --- /dev/null +++ b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/update/AppUpdateViewModel.kt @@ -0,0 +1,226 @@ +package com.seven.ikar.kotlin.feature.update + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.seven.ikar.kotlin.BuildConfig +import com.seven.ikar.kotlin.app.AppContainer +import com.seven.ikar.kotlin.core.network.ServerConfig +import com.seven.ikar.kotlin.core.update.AppUpdateCheckResult +import com.seven.ikar.kotlin.core.update.AppUpdateInstallerEvent +import com.seven.ikar.kotlin.core.update.AppUpdateSubmissionResult +import com.seven.ikar.kotlin.core.update.ArgusAppManifest +import com.seven.ikar.kotlin.core.update.ArgusAppUpdateConfig +import com.seven.ikar.kotlin.core.update.AvailableIkarUpdate +import com.seven.ikar.kotlin.core.update.InstalledIkarApp +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +data class AppUpdateUiState( + val currentVersionName: String, + val currentVersionCode: Long, + val canRequestPackageInstalls: Boolean, + val isChecking: Boolean = false, + val isSubmitting: Boolean = false, + val statusTitle: String, + val statusDetail: String, + val availableUpdate: AvailableIkarUpdate? = null, + val latestManifest: ArgusAppManifest? = null, + val lastInstallerEvent: AppUpdateInstallerEvent? = null +) + +class AppUpdateViewModel(private val container: AppContainer) : ViewModel() { + private val config = ArgusAppUpdateConfig( + baseUrl = ServerConfig.ArgusBaseUrl, + slug = ServerConfig.AndroidAppSlug, + platform = ServerConfig.AndroidAppPlatform, + channel = ServerConfig.AndroidUpdateChannel + ) + + private val installedApp = InstalledIkarApp( + packageName = BuildConfig.APPLICATION_ID, + versionCode = BuildConfig.VERSION_CODE.toLong(), + versionName = BuildConfig.VERSION_NAME + ) + + private val _uiState = MutableStateFlow( + createInitialState(container.appUpdateManager.canRequestPackageInstalls()) + ) + val uiState: StateFlow<AppUpdateUiState> = _uiState.asStateFlow() + + init { + refreshStatus() + } + + fun refreshStatus() { + val canRequestPackageInstalls = container.appUpdateManager.canRequestPackageInstalls() + val currentState = _uiState.value + val shouldReplaceStatus = + currentState.statusTitle == initialStatusTitle(false) || + currentState.statusTitle == initialStatusTitle(true) || + currentState.statusTitle == permissionRequiredTitle || + currentState.statusTitle == permissionGrantedTitle + + _uiState.value = currentState.copy( + canRequestPackageInstalls = canRequestPackageInstalls, + lastInstallerEvent = container.appUpdateManager.loadLastInstallerEvent(), + statusTitle = if (shouldReplaceStatus) { + if (canRequestPackageInstalls) permissionGrantedTitle else initialStatusTitle(false) + } else { + currentState.statusTitle + }, + statusDetail = if (shouldReplaceStatus) { + if (canRequestPackageInstalls) permissionGrantedDetail else initialStatusDetail(false) + } else { + currentState.statusDetail + } + ) + } + + fun requestUnknownSourcesPermission() { + _uiState.value = _uiState.value.copy( + canRequestPackageInstalls = false, + statusTitle = permissionRequiredTitle, + statusDetail = permissionRequiredDetail + ) + } + + fun checkForUpdates() { + if (_uiState.value.isChecking) { + return + } + + viewModelScope.launch { + _uiState.value = _uiState.value.copy(isChecking = true) + val result = withContext(Dispatchers.IO) { + container.appUpdateManager.checkForUpdate(config, installedApp) + } + when (result) { + is AppUpdateCheckResult.NotPublished -> { + _uiState.value = _uiState.value.copy( + isChecking = false, + availableUpdate = null, + latestManifest = null, + statusTitle = "Проверка проведена", + statusDetail = result.reason + ) + } + + is AppUpdateCheckResult.UpToDate -> { + _uiState.value = _uiState.value.copy( + isChecking = false, + availableUpdate = null, + latestManifest = result.manifest, + statusTitle = "Проверка проведена", + statusDetail = "Обновлений нет." + ) + } + + is AppUpdateCheckResult.UpdateAvailable -> { + val manifest = result.update.manifest + _uiState.value = _uiState.value.copy( + isChecking = false, + availableUpdate = result.update, + latestManifest = manifest, + statusTitle = "Проверка проведена", + statusDetail = "Обновления есть: версия ${manifest.release.version} (${manifest.release.androidVersionCode ?: "?"})." + ) + } + + is AppUpdateCheckResult.Incompatible -> { + _uiState.value = _uiState.value.copy( + isChecking = false, + availableUpdate = null, + latestManifest = null, + statusTitle = "Проверка проведена", + statusDetail = result.reason + ) + } + + is AppUpdateCheckResult.Failed -> { + _uiState.value = _uiState.value.copy( + isChecking = false, + availableUpdate = null, + latestManifest = null, + statusTitle = "Проверка не выполнена", + statusDetail = result.reason + ) + } + } + refreshStatus() + } + } + + fun installAvailableUpdate() { + val update = _uiState.value.availableUpdate ?: return + if (_uiState.value.isSubmitting) { + return + } + + if (!container.appUpdateManager.canRequestPackageInstalls()) { + requestUnknownSourcesPermission() + return + } + + viewModelScope.launch { + _uiState.value = _uiState.value.copy(isSubmitting = true) + val result = withContext(Dispatchers.IO) { + container.appUpdateManager.downloadAndInstall(update) + } + when (result) { + is AppUpdateSubmissionResult.Submitted -> { + _uiState.value = _uiState.value.copy( + isSubmitting = false, + statusTitle = "Запрос отправлен в Android", + statusDetail = result.message + ) + } + + is AppUpdateSubmissionResult.Rejected -> { + _uiState.value = _uiState.value.copy( + isSubmitting = false, + statusTitle = "Установка не запущена", + statusDetail = result.message + ) + } + } + refreshStatus() + } + } + + private fun createInitialState(canRequestPackageInstalls: Boolean): AppUpdateUiState = + AppUpdateUiState( + currentVersionName = BuildConfig.VERSION_NAME, + currentVersionCode = BuildConfig.VERSION_CODE.toLong(), + canRequestPackageInstalls = canRequestPackageInstalls, + statusTitle = initialStatusTitle(canRequestPackageInstalls), + statusDetail = initialStatusDetail(canRequestPackageInstalls), + lastInstallerEvent = container.appUpdateManager.loadLastInstallerEvent() + ) + + private companion object { + const val permissionRequiredTitle = "Нужно разрешение на установку" + const val permissionRequiredDetail = + "Android откроет системный экран разрешения установки APK для Икар. После разрешения нажмите «Проверить» ещё раз." + const val permissionGrantedTitle = "Разрешение на установку включено" + const val permissionGrantedDetail = + "Теперь кнопка «Проверить» будет искать доступные обновления на сайте Argus." + + fun initialStatusTitle(canRequestPackageInstalls: Boolean): String = + if (canRequestPackageInstalls) { + "Проверка обновлений ещё не запускалась" + } else { + "Сначала разрешите установку APK" + } + + fun initialStatusDetail(canRequestPackageInstalls: Boolean): String = + if (canRequestPackageInstalls) { + "Нажмите «Проверить», чтобы сверить установленную версию с Argus." + } else { + "При первом нажатии «Проверить» Android должен открыть разрешение установки приложений из неизвестных источников для Икар." + } + } +} diff --git a/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/ui/AuthenticatedAsyncImage.kt b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/ui/AuthenticatedAsyncImage.kt new file mode 100644 index 00000000..2a85bbcf --- /dev/null +++ b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/ui/AuthenticatedAsyncImage.kt @@ -0,0 +1,47 @@ +package com.seven.ikar.kotlin.ui + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import coil.compose.AsyncImage +import coil.request.CachePolicy +import coil.request.ImageRequest + +@Composable +fun AuthenticatedAsyncImage( + imageUrl: String?, + accessToken: String? = null, + contentDescription: String, + modifier: Modifier = Modifier, + contentScale: ContentScale = ContentScale.Crop +) { + if (imageUrl.isNullOrBlank()) { + return + } + + val resolvedAccessToken = accessToken ?: LocalIkarAccessToken.current + val context = LocalContext.current + val request = remember(imageUrl, resolvedAccessToken) { + ImageRequest.Builder(context) + .data(imageUrl) + .diskCacheKey(imageUrl) + .diskCachePolicy(CachePolicy.ENABLED) + .memoryCachePolicy(CachePolicy.ENABLED) + .apply { + if (!resolvedAccessToken.isNullOrBlank()) { + addHeader("Authorization", "Bearer $resolvedAccessToken") + } + } + .crossfade(true) + .build() + } + + AsyncImage( + model = request, + contentDescription = contentDescription, + modifier = modifier, + contentScale = contentScale + ) +} diff --git a/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/ui/AvatarView.kt b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/ui/AvatarView.kt new file mode 100644 index 00000000..e52e9c38 --- /dev/null +++ b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/ui/AvatarView.kt @@ -0,0 +1,66 @@ +package com.seven.ikar.kotlin.ui + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.compositionLocalOf +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.seven.ikar.kotlin.core.network.ServerConfig + +val LocalIkarAccessToken = compositionLocalOf<String?> { null } + +@Composable +fun AvatarView( + imageUrl: String?, + title: String, + accessToken: String? = null, + size: Dp = 44.dp +) { + val resolvedImageUrl = ServerConfig.normalizeMediaUrl(imageUrl) + val resolvedAccessToken = accessToken ?: LocalIkarAccessToken.current + val avatarColor = ikarAvatarColor(title) + val initials = ikarAvatarInitials(title) + val fontSize = when { + size >= 96.dp -> 32.dp + size >= 72.dp -> 24.dp + size >= 54.dp -> 18.dp + else -> 16.dp + } + + Box( + modifier = Modifier + .size(size) + .clip(CircleShape) + .background(avatarColor), + contentAlignment = Alignment.Center + ) { + Text( + text = initials, + style = MaterialTheme.typography.titleLarge.copy( + fontSize = fontSize.value.sp, + color = Color.White + ), + textAlign = TextAlign.Center + ) + + AuthenticatedAsyncImage( + imageUrl = resolvedImageUrl, + accessToken = resolvedAccessToken, + contentDescription = title, + modifier = Modifier.matchParentSize(), + contentScale = ContentScale.Crop + ) + } +} diff --git a/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/ui/ChatWallpapers.kt b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/ui/ChatWallpapers.kt new file mode 100644 index 00000000..c0bdd3fd --- /dev/null +++ b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/ui/ChatWallpapers.kt @@ -0,0 +1,292 @@ +package com.seven.ikar.kotlin.ui + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.unit.dp +import com.seven.ikar.kotlin.core.settings.DEFAULT_CHAT_WALLPAPER_PRESET_ID +import com.seven.ikar.kotlin.core.settings.ChatSettingsSnapshot + +enum class ChatWallpaperAccentStyle { + SoftCircles, + CornerGlow, + Ribbon +} + +data class ChatWallpaperPreset( + val id: String, + val title: String, + val gradientColors: List<Color>, + val accentPrimary: Color, + val accentSecondary: Color, + val accentStyle: ChatWallpaperAccentStyle +) + +object ChatWallpaperCatalog { + val presets: List<ChatWallpaperPreset> = listOf( + ChatWallpaperPreset( + id = DEFAULT_CHAT_WALLPAPER_PRESET_ID, + title = "Мятное поле", + gradientColors = listOf(Color(0xFFE9F4D7), Color(0xFFD5E8BB), Color(0xFFC8E0AD)), + accentPrimary = Color(0x88FFFFFF), + accentSecondary = Color(0x445C8F5A), + accentStyle = ChatWallpaperAccentStyle.SoftCircles + ), + ChatWallpaperPreset( + id = "sky_haze", + title = "Небесная дымка", + gradientColors = listOf(Color(0xFFE8F5FF), Color(0xFFD6ECFF), Color(0xFFC1DFFF)), + accentPrimary = Color(0x88FFFFFF), + accentSecondary = Color(0x44489AD9), + accentStyle = ChatWallpaperAccentStyle.CornerGlow + ), + ChatWallpaperPreset( + id = "peach_dawn", + title = "Персиковый рассвет", + gradientColors = listOf(Color(0xFFFFF0E2), Color(0xFFFFDDBD), Color(0xFFFFCCA3)), + accentPrimary = Color(0x88FFFFFF), + accentSecondary = Color(0x44F08F5A), + accentStyle = ChatWallpaperAccentStyle.Ribbon + ), + ChatWallpaperPreset( + id = "violet_dusk", + title = "Сиреневый закат", + gradientColors = listOf(Color(0xFFF0E8FF), Color(0xFFE0D1FF), Color(0xFFD1BCFF)), + accentPrimary = Color(0x88FFFFFF), + accentSecondary = Color(0x447D5AD8), + accentStyle = ChatWallpaperAccentStyle.SoftCircles + ), + ChatWallpaperPreset( + id = "ocean_breeze", + title = "Океан", + gradientColors = listOf(Color(0xFFE3FAFF), Color(0xFFC7F0F7), Color(0xFFA5E1EC)), + accentPrimary = Color(0x88FFFFFF), + accentSecondary = Color(0x4446A8B8), + accentStyle = ChatWallpaperAccentStyle.CornerGlow + ), + ChatWallpaperPreset( + id = "lemon_mist", + title = "Лимонный туман", + gradientColors = listOf(Color(0xFFFFF7D7), Color(0xFFFFEFAE), Color(0xFFFBE18D)), + accentPrimary = Color(0x88FFFFFF), + accentSecondary = Color(0x44C9A94C), + accentStyle = ChatWallpaperAccentStyle.Ribbon + ), + ChatWallpaperPreset( + id = "rose_cloud", + title = "Розовое облако", + gradientColors = listOf(Color(0xFFFFE7EE), Color(0xFFFFD7E7), Color(0xFFF7C4DB)), + accentPrimary = Color(0x88FFFFFF), + accentSecondary = Color(0x44D36C95), + accentStyle = ChatWallpaperAccentStyle.SoftCircles + ), + ChatWallpaperPreset( + id = "graphite_night", + title = "Графитовая ночь", + gradientColors = listOf(Color(0xFF2A3342), Color(0xFF1D2734), Color(0xFF131B27)), + accentPrimary = Color(0x2267A3F3), + accentSecondary = Color(0x2241E0C2), + accentStyle = ChatWallpaperAccentStyle.Ribbon + ), + ChatWallpaperPreset( + id = "forest_glow", + title = "Лесное свечение", + gradientColors = listOf(Color(0xFFE4F4E5), Color(0xFFCFE6C6), Color(0xFFBCD7B2)), + accentPrimary = Color(0x88FFFFFF), + accentSecondary = Color(0x444B8658), + accentStyle = ChatWallpaperAccentStyle.CornerGlow + ), + ChatWallpaperPreset( + id = "coral_sand", + title = "Коралловый песок", + gradientColors = listOf(Color(0xFFFFEEE6), Color(0xFFFFD8C8), Color(0xFFF6BFA8)), + accentPrimary = Color(0x88FFFFFF), + accentSecondary = Color(0x44CF7861), + accentStyle = ChatWallpaperAccentStyle.Ribbon + ), + ChatWallpaperPreset( + id = "azure_glass", + title = "Лазурное стекло", + gradientColors = listOf(Color(0xFFF0FCFF), Color(0xFFD7F4FF), Color(0xFFBAE7FA)), + accentPrimary = Color(0x88FFFFFF), + accentSecondary = Color(0x444C9BC4), + accentStyle = ChatWallpaperAccentStyle.SoftCircles + ), + ChatWallpaperPreset( + id = "lilac_ice", + title = "Лиловый лёд", + gradientColors = listOf(Color(0xFFF4EEFF), Color(0xFFE7DDFF), Color(0xFFD5C7FF)), + accentPrimary = Color(0x88FFFFFF), + accentSecondary = Color(0x447C73CF), + accentStyle = ChatWallpaperAccentStyle.CornerGlow + ), + ChatWallpaperPreset( + id = "amber_paper", + title = "Янтарная бумага", + gradientColors = listOf(Color(0xFFFFF2DC), Color(0xFFF6DFB7), Color(0xFFE9C88D)), + accentPrimary = Color(0x88FFFFFF), + accentSecondary = Color(0x44A06A2B), + accentStyle = ChatWallpaperAccentStyle.Ribbon + ), + ChatWallpaperPreset( + id = "olive_smoke", + title = "Оливковый дым", + gradientColors = listOf(Color(0xFFF0F4E6), Color(0xFFDEE4C6), Color(0xFFCDD59D)), + accentPrimary = Color(0x88FFFFFF), + accentSecondary = Color(0x446B8A4B), + accentStyle = ChatWallpaperAccentStyle.SoftCircles + ), + ChatWallpaperPreset( + id = "ruby_night", + title = "Рубиновая ночь", + gradientColors = listOf(Color(0xFF422437), Color(0xFF2F1A28), Color(0xFF1C1119)), + accentPrimary = Color(0x22FF8AAE), + accentSecondary = Color(0x22FFD4A3), + accentStyle = ChatWallpaperAccentStyle.CornerGlow + ) + ) + + fun resolvePreset(id: String?): ChatWallpaperPreset = + presets.firstOrNull { it.id == id } ?: presets.first { it.id == DEFAULT_CHAT_WALLPAPER_PRESET_ID } +} + +@Composable +fun ChatWallpaperBackground( + snapshot: ChatSettingsSnapshot, + modifier: Modifier = Modifier +) { + Box(modifier = modifier) { + if (snapshot.useCustomWallpaper && !snapshot.customWallpaperUri.isNullOrBlank()) { + AuthenticatedAsyncImage( + imageUrl = snapshot.customWallpaperUri, + contentDescription = "Пользовательские обои чата", + modifier = Modifier.fillMaxSize(), + contentScale = ContentScale.Crop + ) + Box( + modifier = Modifier + .fillMaxSize() + .background( + Brush.verticalGradient( + colors = listOf(Color.White.copy(alpha = 0.08f), Color.Black.copy(alpha = 0.05f)) + ) + ) + ) + } else { + PresetWallpaperBackground( + preset = ChatWallpaperCatalog.resolvePreset(snapshot.selectedWallpaperPresetId), + modifier = Modifier.fillMaxSize() + ) + } + } +} + +@Composable +fun PresetWallpaperBackground( + preset: ChatWallpaperPreset, + modifier: Modifier = Modifier +) { + Box( + modifier = modifier.background( + Brush.linearGradient(colors = preset.gradientColors) + ) + ) { + when (preset.accentStyle) { + ChatWallpaperAccentStyle.SoftCircles -> { + AccentCircle( + modifier = Modifier + .align(Alignment.TopEnd) + .offset(x = 36.dp, y = (-18).dp) + .size(132.dp), + color = preset.accentPrimary + ) + AccentCircle( + modifier = Modifier + .align(Alignment.BottomStart) + .offset(x = (-28).dp, y = 24.dp) + .size(156.dp), + color = preset.accentSecondary + ) + } + + ChatWallpaperAccentStyle.CornerGlow -> { + AccentCircle( + modifier = Modifier + .align(Alignment.TopStart) + .offset(x = (-40).dp, y = (-24).dp) + .size(150.dp), + color = preset.accentPrimary + ) + AccentCircle( + modifier = Modifier + .align(Alignment.CenterEnd) + .offset(x = 28.dp) + .size(124.dp), + color = preset.accentSecondary + ) + AccentCircle( + modifier = Modifier + .align(Alignment.BottomCenter) + .offset(y = 34.dp) + .size(108.dp), + color = preset.accentPrimary.copy(alpha = 0.45f) + ) + } + + ChatWallpaperAccentStyle.Ribbon -> { + AccentBand( + modifier = Modifier + .align(Alignment.TopStart) + .offset(x = (-18).dp, y = 22.dp) + .fillMaxWidth(0.9f) + .height(64.dp), + color = preset.accentPrimary + ) + AccentBand( + modifier = Modifier + .align(Alignment.BottomEnd) + .offset(x = 22.dp, y = (-14).dp) + .fillMaxWidth(0.76f) + .height(56.dp), + color = preset.accentSecondary + ) + } + } + } +} + +@Composable +private fun AccentCircle( + modifier: Modifier, + color: Color +) { + Box( + modifier = modifier.background(color = color, shape = CircleShape) + ) +} + +@Composable +private fun AccentBand( + modifier: Modifier, + color: Color +) { + Box( + modifier = modifier.background( + color = color, + shape = RoundedCornerShape(28.dp) + ) + ) +} diff --git a/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/ui/IkarChrome.kt b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/ui/IkarChrome.kt new file mode 100644 index 00000000..d310f5e4 --- /dev/null +++ b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/ui/IkarChrome.kt @@ -0,0 +1,572 @@ +package com.seven.ikar.kotlin.ui + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.WindowInsetsSides +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.navigationBars +import androidx.compose.foundation.layout.only +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.outlined.ArrowBack +import androidx.compose.material.icons.outlined.ChatBubbleOutline +import androidx.compose.material.icons.outlined.PersonOutline +import androidx.compose.material.icons.outlined.Search +import androidx.compose.material.icons.outlined.Settings +import androidx.compose.material.icons.outlined.SupervisorAccount +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Switch +import androidx.compose.material3.SwitchDefaults +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.compositionLocalOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import com.seven.ikar.kotlin.ui.theme.TelegramBadgeBackground +import com.seven.ikar.kotlin.ui.theme.TelegramBadgeText +import com.seven.ikar.kotlin.ui.theme.TelegramBlue +import com.seven.ikar.kotlin.ui.theme.TelegramBluePanel +import com.seven.ikar.kotlin.ui.theme.TelegramBorder +import com.seven.ikar.kotlin.ui.theme.TelegramCardShadow +import com.seven.ikar.kotlin.ui.theme.TelegramFabShadow +import com.seven.ikar.kotlin.ui.theme.TelegramFloatingBorder +import com.seven.ikar.kotlin.ui.theme.TelegramFloatingSurface +import com.seven.ikar.kotlin.ui.theme.TelegramHeaderShadow +import com.seven.ikar.kotlin.ui.theme.TelegramInkMuted + +enum class IkarMainMenuTab { + Chats, + Contacts, + Settings, + Profile +} + +val LocalUnreadChatsCount = compositionLocalOf { 0 } + +@Composable +fun IkarScreenSurface( + modifier: Modifier = Modifier, + content: @Composable BoxScope.() -> Unit +) { + Box( + modifier = modifier + .background(MaterialTheme.colorScheme.background), + content = content + ) +} + +@Composable +fun IkarCenteredHeader( + title: String, + modifier: Modifier = Modifier, + left: @Composable (() -> Unit), + right: @Composable (() -> Unit) +) { + Row( + modifier = modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + Box(modifier = Modifier.width(44.dp), contentAlignment = Alignment.CenterStart) { + left() + } + Text( + text = title, + modifier = Modifier.weight(1f), + style = MaterialTheme.typography.displayLarge, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + Box(modifier = Modifier.width(44.dp), contentAlignment = Alignment.CenterEnd) { + right() + } + } +} + +@Composable +fun IkarSimpleHeader( + title: String, + modifier: Modifier = Modifier, + onBackClick: (() -> Unit)? = null, + rightContent: @Composable RowScope.() -> Unit = {} +) { + Row( + modifier = modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + if (onBackClick != null) { + IconButton(onClick = onBackClick, modifier = Modifier.size(24.dp)) { + Icon( + Icons.AutoMirrored.Outlined.ArrowBack, + contentDescription = "Назад", + tint = MaterialTheme.colorScheme.onBackground + ) + } + } + + Text( + text = title, + modifier = Modifier.weight(1f), + style = MaterialTheme.typography.headlineLarge, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + + Row(horizontalArrangement = Arrangement.spacedBy(12.dp), content = rightContent) + } +} + +@Composable +fun IkarHeaderIconBubble( + modifier: Modifier = Modifier, + onClick: (() -> Unit)? = null, + content: @Composable BoxScope.() -> Unit +) { + val shape = RoundedCornerShape(18.dp) + Surface( + modifier = modifier.then(if (onClick != null) Modifier.clickable(onClick = onClick) else Modifier), + shape = shape, + color = MaterialTheme.colorScheme.surface, + shadowElevation = 8.dp + ) { + Box( + modifier = Modifier + .size(40.dp) + .padding(10.dp), + contentAlignment = Alignment.Center, + content = content + ) + } +} + +@Composable +fun IkarSectionCard( + modifier: Modifier = Modifier, + padding: PaddingValues = PaddingValues(18.dp), + content: @Composable ColumnScope.() -> Unit +) { + Surface( + modifier = modifier, + color = MaterialTheme.colorScheme.surface, + shape = RoundedCornerShape(24.dp), + shadowElevation = 9.dp + ) { + Column( + modifier = Modifier.padding(padding), + verticalArrangement = Arrangement.spacedBy(0.dp), + content = content + ) + } +} + +@Composable +fun IkarPillCard( + modifier: Modifier = Modifier, + onClick: (() -> Unit)? = null, + contentPadding: PaddingValues = PaddingValues(horizontal = 14.dp, vertical = 12.dp), + content: @Composable ColumnScope.() -> Unit +) { + val clickableModifier = if (onClick != null) Modifier.clickable(onClick = onClick) else Modifier + Surface( + modifier = modifier.then(clickableModifier), + color = MaterialTheme.colorScheme.surface, + shape = RoundedCornerShape(20.dp), + shadowElevation = 6.dp + ) { + Column( + modifier = Modifier.padding(contentPadding), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(6.dp), + content = content + ) + } +} + +@Composable +fun IkarSearchCard( + value: String, + onValueChange: (String) -> Unit, + placeholder: String, + modifier: Modifier = Modifier +) { + val shape = RoundedCornerShape(20.dp) + BasicTextField( + value = value, + onValueChange = onValueChange, + modifier = modifier.fillMaxWidth(), + textStyle = MaterialTheme.typography.bodyLarge.copy(color = MaterialTheme.colorScheme.onSurface), + singleLine = true, + decorationBox = { innerTextField -> + Surface( + shape = shape, + color = MaterialTheme.colorScheme.surface, + tonalElevation = 0.dp, + shadowElevation = 0.dp + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 14.dp, vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp) + ) { + Icon( + Icons.Outlined.Search, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(18.dp) + ) + Box(modifier = Modifier.weight(1f), contentAlignment = Alignment.CenterStart) { + if (value.isBlank()) { + Text( + placeholder, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + innerTextField() + } + } + } + } + ) +} + +@Composable +fun IkarBlueNotice( + title: String, + subtitle: String, + modifier: Modifier = Modifier, + trailing: (@Composable () -> Unit)? = null +) { + Surface( + modifier = modifier.fillMaxWidth(), + color = TelegramBluePanel, + shape = RoundedCornerShape(18.dp), + shadowElevation = 0.dp + ) { + Row( + modifier = Modifier.padding(14.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp) + ) { + Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(3.dp)) { + Text(title, style = MaterialTheme.typography.titleMedium, color = TelegramBlue) + Text(subtitle, style = MaterialTheme.typography.bodySmall, color = TelegramInkMuted) + } + trailing?.invoke() + } + } +} + +@Composable +fun IkarRowDivider(startIndent: Dp = 54.dp) { + Spacer( + modifier = Modifier + .fillMaxWidth() + .padding(start = startIndent) + .height(1.dp) + .background(TelegramBorder) + ) +} + +@Composable +fun IkarSectionTitle(text: String, modifier: Modifier = Modifier) { + Text( + text = text, + modifier = modifier, + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.primary + ) +} + +@Composable +fun IkarSettingRow( + title: String, + subtitle: String, + modifier: Modifier = Modifier, + onClick: (() -> Unit)? = null, + leading: @Composable BoxScope.() -> Unit, + trailing: @Composable (() -> Unit)? = null +) { + val clickableModifier = if (onClick != null) Modifier.clickable(onClick = onClick) else Modifier + Row( + modifier = modifier + .fillMaxWidth() + .then(clickableModifier) + .padding(vertical = 14.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(14.dp) + ) { + Box( + modifier = Modifier + .size(40.dp) + .clip(CircleShape), + contentAlignment = Alignment.Center, + content = leading + ) + Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text(title, style = MaterialTheme.typography.titleLarge) + Text(subtitle, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + trailing?.invoke() + } +} + +@Composable +fun IkarToggleRow( + title: String, + subtitle: String, + checked: Boolean, + onCheckedChange: (Boolean) -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, + leading: (@Composable BoxScope.() -> Unit)? = null +) { + Row( + modifier = modifier + .fillMaxWidth() + .padding(vertical = 14.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + if (leading != null) { + Box( + modifier = Modifier + .size(24.dp), + contentAlignment = Alignment.Center, + content = leading + ) + } + Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text(title, style = MaterialTheme.typography.titleLarge) + Text(subtitle, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + Switch( + checked = checked, + onCheckedChange = onCheckedChange, + enabled = enabled, + colors = SwitchDefaults.colors( + checkedThumbColor = MaterialTheme.colorScheme.primary, + checkedTrackColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.52f), + checkedBorderColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.52f), + uncheckedThumbColor = Color.White, + uncheckedTrackColor = Color(0xFFDDE2E6), + uncheckedBorderColor = Color(0xFFDDE2E6), + disabledCheckedThumbColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.55f), + disabledCheckedTrackColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.32f), + disabledUncheckedThumbColor = Color.White.copy(alpha = 0.85f), + disabledUncheckedTrackColor = Color(0xFFE9ECEF) + ) + ) + } +} + +@Composable +fun IkarValueRow( + title: String, + subtitle: String, + value: String, + modifier: Modifier = Modifier, + enabled: Boolean = true, + onClick: (() -> Unit)? = null, + leading: (@Composable BoxScope.() -> Unit)? = null +) { + val clickableModifier = if (enabled && onClick != null) Modifier.clickable(onClick = onClick) else Modifier + Row( + modifier = modifier + .fillMaxWidth() + .then(clickableModifier) + .padding(vertical = 14.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + if (leading != null) { + Box(modifier = Modifier.size(24.dp), contentAlignment = Alignment.Center, content = leading) + } + Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text(title, style = MaterialTheme.typography.titleLarge) + Text(subtitle, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + Text( + value, + style = MaterialTheme.typography.titleMedium, + color = if (enabled) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } +} + +@Composable +fun IkarRoundedTag( + text: String, + modifier: Modifier = Modifier, + backgroundColor: Color = TelegramBadgeBackground, + textColor: Color = TelegramBadgeText +) { + Surface( + modifier = modifier, + color = backgroundColor, + shape = RoundedCornerShape(10.dp), + shadowElevation = 0.dp + ) { + Text( + text = text, + modifier = Modifier.padding(horizontal = 8.dp, vertical = 3.dp), + style = MaterialTheme.typography.labelMedium, + color = textColor + ) + } +} + +@Composable +fun IkarFab( + icon: ImageVector, + contentDescription: String, + modifier: Modifier = Modifier, + onClick: () -> Unit +) { + Surface( + modifier = modifier + .size(60.dp) + .clickable(onClick = onClick), + color = MaterialTheme.colorScheme.primary, + shape = RoundedCornerShape(30.dp), + shadowElevation = 12.dp + ) { + Box(contentAlignment = Alignment.Center) { + Icon(icon, contentDescription = contentDescription, tint = Color.White, modifier = Modifier.size(24.dp)) + } + } +} + +@Composable +fun IkarFloatingMainMenu( + selectedTab: IkarMainMenuTab, + modifier: Modifier = Modifier, + onChatsClick: () -> Unit, + onContactsClick: () -> Unit, + onSettingsClick: () -> Unit, + onProfileClick: () -> Unit +) { + Surface( + modifier = modifier.windowInsetsPadding(WindowInsets.navigationBars.only(WindowInsetsSides.Bottom)), + color = TelegramFloatingSurface, + shape = RoundedCornerShape(32.dp), + shadowElevation = 10.dp + ) { + Row( + modifier = Modifier + .border(1.dp, TelegramFloatingBorder, RoundedCornerShape(32.dp)) + .padding(horizontal = 12.dp, vertical = 10.dp), + horizontalArrangement = Arrangement.spacedBy(6.dp) + ) { + val unreadChatsCount = LocalUnreadChatsCount.current + IkarFloatingMenuItem( + icon = Icons.Outlined.ChatBubbleOutline, + label = "Чаты", + selected = selectedTab == IkarMainMenuTab.Chats, + badgeCount = unreadChatsCount, + onClick = onChatsClick + ) + IkarFloatingMenuItem( + icon = Icons.Outlined.SupervisorAccount, + label = "Контакты", + selected = selectedTab == IkarMainMenuTab.Contacts, + onClick = onContactsClick + ) + IkarFloatingMenuItem( + icon = Icons.Outlined.Settings, + label = "Настройки", + selected = selectedTab == IkarMainMenuTab.Settings, + onClick = onSettingsClick + ) + IkarFloatingMenuItem( + icon = Icons.Outlined.PersonOutline, + label = "Профиль", + selected = selectedTab == IkarMainMenuTab.Profile, + onClick = onProfileClick + ) + } + } +} + +@Composable +private fun IkarFloatingMenuItem( + icon: ImageVector, + label: String, + selected: Boolean, + badgeCount: Int = 0, + onClick: () -> Unit +) { + val shape = RoundedCornerShape(22.dp) + val backgroundColor = if (selected) MaterialTheme.colorScheme.primary.copy(alpha = 0.12f) else Color.Transparent + val tint = if (selected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant + Column( + modifier = Modifier + .clip(shape) + .clickable(onClick = onClick) + .background(backgroundColor) + .padding(horizontal = 10.dp, vertical = 8.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + Box( + modifier = Modifier.size(28.dp), + contentAlignment = Alignment.Center + ) { + Icon(icon, contentDescription = label, tint = tint, modifier = Modifier.size(22.dp)) + if (badgeCount > 0) { + Box( + modifier = Modifier + .align(Alignment.TopEnd) + .background(TelegramBadgeBackground, CircleShape) + .border(1.dp, TelegramFloatingSurface, CircleShape) + .padding(horizontal = 4.dp, vertical = 1.dp), + contentAlignment = Alignment.Center + ) { + Text( + text = if (badgeCount > 99) "99+" else badgeCount.toString(), + style = MaterialTheme.typography.labelSmall, + color = TelegramBadgeText, + maxLines = 1 + ) + } + } + } + Text(label, style = MaterialTheme.typography.labelSmall, color = tint) + } +} diff --git a/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/ui/IkarFormatters.kt b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/ui/IkarFormatters.kt new file mode 100644 index 00000000..4bf5e667 --- /dev/null +++ b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/ui/IkarFormatters.kt @@ -0,0 +1,146 @@ +package com.seven.ikar.kotlin.ui + +import androidx.compose.ui.graphics.Color +import com.seven.ikar.kotlin.core.contacts.DiscoverUserItem +import com.seven.ikar.kotlin.core.model.ChatSummaryDto +import com.seven.ikar.kotlin.core.model.ChatType +import com.seven.ikar.kotlin.core.model.UserSummaryDto +import com.seven.ikar.kotlin.ui.theme.TelegramBlue +import com.seven.ikar.kotlin.ui.theme.TelegramInkMuted +import java.time.OffsetDateTime +import java.time.ZoneId +import java.time.format.DateTimeFormatter +import java.util.Locale +import kotlin.math.abs + +private val AvatarPalette = listOf( + Color(0xFF5AA7F0), + Color(0xFF47BFD8), + Color(0xFF4F83F1), + Color(0xFF60C689), + Color(0xFFD488F5), + Color(0xFFF2A45A), + Color(0xFFF06C8E), + Color(0xFF7E8A98) +) + +private val TimeFormatter = DateTimeFormatter.ofPattern("HH:mm", Locale("ru", "RU")) +private val DayMonthFormatter = DateTimeFormatter.ofPattern("d MMM", Locale("ru", "RU")) +private val DayMonthYearFormatter = DateTimeFormatter.ofPattern("d MMM yyyy", Locale("ru", "RU")) + +fun ikarAvatarInitials(value: String?): String { + if (value.isNullOrBlank()) { + return "M" + } + + val parts = value + .split(' ', '-', '_') + .map { it.trim() } + .filter { it.isNotEmpty() } + .take(2) + .map { it.first().uppercaseChar() } + + return if (parts.isEmpty()) "M" else parts.joinToString("") +} + +fun ikarAvatarColor(value: String?): Color { + if (value.isNullOrBlank()) { + return AvatarPalette.first() + } + + var hash = 17 + value.trim().forEach { symbol -> + hash = (hash * 31) + symbol.uppercaseChar().code + } + + if (hash == Int.MIN_VALUE) { + hash = Int.MAX_VALUE + } + + return AvatarPalette[abs(hash) % AvatarPalette.size] +} + +fun formatPresence(user: UserSummaryDto): String { + if (user.isBot) { + return "бот" + } + if (user.isOnline) { + return "в сети" + } + + val lastSeen = parseOffsetDateTime(user.lastSeenAt) ?: return "был давно" + val now = OffsetDateTime.now() + val delta = java.time.Duration.between(lastSeen, now) + + return when { + delta < java.time.Duration.ofDays(7) -> "был недавно" + else -> "был давно" + } +} + +fun formatDiscoverPresence(item: DiscoverUserItem): String = when { + item.user.isBot -> "бот · @${item.username}" + item.secondaryLine.isNotBlank() -> item.secondaryLine + else -> formatPresence(item.user) +} + +fun formatPresenceColor(item: DiscoverUserItem): Color = when { + item.user.isOnline -> TelegramBlue + else -> TelegramInkMuted +} + +fun chatSubtitle(chat: ChatSummaryDto): String = if (!chat.secondaryText.isNullOrBlank()) { + chat.secondaryText +} else { + when (chat.type) { + ChatType.Direct -> "личный чат" + ChatType.Group -> "группа" + ChatType.Channel -> if (chat.canSendMessages) "канал" else "канал только для чтения" + } +} + +fun chatPreview(chat: ChatSummaryDto): String = if (!chat.lastMessagePreview.isNullOrBlank()) { + chat.lastMessagePreview.stripSenderPrefix() +} else { + when (chat.type) { + ChatType.Channel -> "Пока нет публикаций" + else -> "Пока нет сообщений" + } +} + +private fun String.stripSenderPrefix(): String { + val delimiter = ": " + val delimiterIndex = indexOf(delimiter) + if (delimiterIndex <= 0) { + return this + } + + val previewWithoutSender = substring(delimiterIndex + delimiter.length) + return previewWithoutSender.ifBlank { this } +} + +fun chatTypeLabel(chatType: ChatType): String = when (chatType) { + ChatType.Direct -> "ЛИЧНЫЙ" + ChatType.Group -> "ГРУППА" + ChatType.Channel -> "КАНАЛ" +} + +fun formatLastActivity(lastActivityAt: String?): String { + val value = parseOffsetDateTime(lastActivityAt) ?: return "" + val now = OffsetDateTime.now() + return when { + value.toLocalDate() == now.toLocalDate() -> value.format(TimeFormatter) + value.toLocalDate() == now.minusDays(1).toLocalDate() -> "Вчера" + value.year == now.year -> value.format(DayMonthFormatter) + else -> value.format(DayMonthYearFormatter) + } +} + +private fun parseOffsetDateTime(value: String?): OffsetDateTime? = + value?.takeIf { it.isNotBlank() }?.let { + runCatching { + OffsetDateTime.parse(it) + .atZoneSameInstant(ZoneId.systemDefault()) + .toOffsetDateTime() + }.getOrNull() + } diff --git a/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/ui/SecureWindowEffect.kt b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/ui/SecureWindowEffect.kt new file mode 100644 index 00000000..736db923 --- /dev/null +++ b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/ui/SecureWindowEffect.kt @@ -0,0 +1,60 @@ +package com.seven.ikar.kotlin.ui + +import android.app.Activity +import android.content.Context +import android.content.ContextWrapper +import android.view.Window +import android.view.WindowManager +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.platform.LocalView +import java.util.UUID + +object SecureWindowFlagController { + private val activeTokensByWindow = mutableMapOf<Int, MutableSet<String>>() + + fun setSecure(window: Window, token: String, enabled: Boolean) { + synchronized(activeTokensByWindow) { + val key = System.identityHashCode(window) + val tokens = activeTokensByWindow.getOrPut(key) { mutableSetOf() } + if (enabled) { + tokens += token + } else { + tokens -= token + } + + if (tokens.isEmpty()) { + activeTokensByWindow -= key + window.clearFlags(WindowManager.LayoutParams.FLAG_SECURE) + } else { + window.addFlags(WindowManager.LayoutParams.FLAG_SECURE) + } + } + } +} + +@Composable +fun SecureWindowEffect(enabled: Boolean) { + val view = LocalView.current + val token = remember { UUID.randomUUID().toString() } + + DisposableEffect(view, enabled, token) { + val window = view.context.findActivity()?.window + if (window != null) { + SecureWindowFlagController.setSecure(window, token, enabled) + } + + onDispose { + if (window != null) { + SecureWindowFlagController.setSecure(window, token, false) + } + } + } +} + +private tailrec fun Context.findActivity(): Activity? = when (this) { + is Activity -> this + is ContextWrapper -> baseContext.findActivity() + else -> null +} diff --git a/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/ui/SettingsRow.kt b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/ui/SettingsRow.kt new file mode 100644 index 00000000..f2161cfc --- /dev/null +++ b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/ui/SettingsRow.kt @@ -0,0 +1,48 @@ +package com.seven.ikar.kotlin.ui + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.outlined.KeyboardArrowRight +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.unit.dp + +@Composable +fun SettingsRow( + title: String, + subtitle: String? = null, + icon: ImageVector, + onClick: () -> Unit +) { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onClick) + .padding(horizontal = 16.dp, vertical = 14.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(16.dp) + ) { + Icon(icon, contentDescription = null, tint = MaterialTheme.colorScheme.primary) + Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text(title, style = MaterialTheme.typography.titleMedium) + if (!subtitle.isNullOrBlank()) { + Text( + subtitle, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + Icon(Icons.AutoMirrored.Outlined.KeyboardArrowRight, contentDescription = null) + } +} diff --git a/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/ui/theme/Theme.kt b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/ui/theme/Theme.kt new file mode 100644 index 00000000..07520e68 --- /dev/null +++ b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/ui/theme/Theme.kt @@ -0,0 +1,135 @@ +package com.seven.ikar.kotlin.ui.theme + +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Typography +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.Font +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.sp +import com.seven.ikar.kotlin.R +import com.seven.ikar.kotlin.core.settings.AppSettingsSnapshot +import com.seven.ikar.kotlin.core.settings.AppThemeMode +import java.time.LocalTime + +val TelegramBlue = Color(0xFF229AF0) +val TelegramBubbleBlue = Color(0xFF2D7ED5) +val TelegramBlueStrong = Color(0xFF1A9CEF) +val TelegramBlueSoft = Color(0xFF53A6F3) +val TelegramBluePressed = Color(0xFF368DE8) +val TelegramSurface = Color(0xFFFFFFFF) +val TelegramSurfaceSoft = Color(0xFFF6F8FB) +val TelegramSurfaceMuted = Color(0xFFF0F0F0) +val TelegramBorder = Color(0xFFE6E6E6) +val TelegramInk = Color(0xFF333333) +val TelegramInkMuted = Color(0xFF808384) +val TelegramDanger = Color(0xFFD9534F) +val TelegramSuccess = Color(0xFF29A8E7) +val TelegramBackground = Color(0xFFF5F6F8) +val TelegramFabShadow = Color(0x26229AF0) +val TelegramCardShadow = Color(0x1F29384A) +val TelegramHeaderShadow = Color(0x1A000000) +val TelegramFloatingSurface = Color(0xFFF7FAFE) +val TelegramFloatingBorder = Color(0xFFDCE7F2) +val TelegramSelectionBackground = Color(0xFFEAF4FF) +val TelegramSearchBackground = Color(0xFFFFFFFF) +val TelegramBadgeBackground = Color(0xFFDDE4EC) +val TelegramBadgeText = Color(0xFF6D7782) +val TelegramBluePanel = Color(0xFFEDF6FF) + +private val OpenSans = FontFamily( + Font(R.font.opensans_regular, FontWeight.Normal), + Font(R.font.opensans_semibold, FontWeight.SemiBold), + Font(R.font.opensans_semibold, FontWeight.Bold) +) + +private val IkarTypography = Typography( + displayLarge = TextStyle(fontFamily = OpenSans, fontWeight = FontWeight.SemiBold, fontSize = 28.sp, lineHeight = 34.sp), + displayMedium = TextStyle(fontFamily = OpenSans, fontWeight = FontWeight.SemiBold, fontSize = 24.sp, lineHeight = 30.sp), + headlineLarge = TextStyle(fontFamily = OpenSans, fontWeight = FontWeight.SemiBold, fontSize = 20.sp, lineHeight = 24.sp), + headlineMedium = TextStyle(fontFamily = OpenSans, fontWeight = FontWeight.SemiBold, fontSize = 18.sp, lineHeight = 23.sp), + titleLarge = TextStyle(fontFamily = OpenSans, fontWeight = FontWeight.SemiBold, fontSize = 17.sp, lineHeight = 22.sp), + titleMedium = TextStyle(fontFamily = OpenSans, fontWeight = FontWeight.SemiBold, fontSize = 15.sp, lineHeight = 20.sp), + bodyLarge = TextStyle(fontFamily = OpenSans, fontWeight = FontWeight.Normal, fontSize = 15.sp, lineHeight = 20.sp), + bodyMedium = TextStyle(fontFamily = OpenSans, fontWeight = FontWeight.Normal, fontSize = 14.sp, lineHeight = 19.sp), + bodySmall = TextStyle(fontFamily = OpenSans, fontWeight = FontWeight.Normal, fontSize = 13.sp, lineHeight = 17.sp), + labelLarge = TextStyle(fontFamily = OpenSans, fontWeight = FontWeight.SemiBold, fontSize = 13.sp, lineHeight = 17.sp), + labelMedium = TextStyle(fontFamily = OpenSans, fontWeight = FontWeight.SemiBold, fontSize = 12.sp, lineHeight = 15.sp), + labelSmall = TextStyle(fontFamily = OpenSans, fontWeight = FontWeight.SemiBold, fontSize = 11.sp, lineHeight = 14.sp) +) + +private val LightColors = lightColorScheme( + primary = TelegramBlue, + secondary = TelegramBlueSoft, + tertiary = TelegramBubbleBlue, + background = TelegramBackground, + surface = TelegramSurface, + onPrimary = Color.White, + onSecondary = Color.White, + onTertiary = Color.White, + onBackground = TelegramInk, + onSurface = TelegramInk, + onSurfaceVariant = TelegramInkMuted, + error = TelegramDanger +) + +@Composable +fun IkarKotlinTheme( + settings: AppSettingsSnapshot = AppSettingsSnapshot(), + content: @Composable () -> Unit +) { + val systemDark = isSystemInDarkTheme() + val darkTheme = resolveDarkTheme(settings.themeMode, systemDark) + val accent = resolveAccentColor(settings.accentColorName) + val colorScheme = if (darkTheme) { + darkColorScheme( + primary = accent, + secondary = accent.copy(alpha = 0.82f), + tertiary = TelegramBubbleBlue, + background = Color(0xFF121820), + surface = Color(0xFF1C2530), + surfaceVariant = Color(0xFF253241), + onPrimary = Color.White, + onSecondary = Color.White, + onTertiary = Color.White, + onBackground = Color(0xFFEAF1F8), + onSurface = Color(0xFFEAF1F8), + onSurfaceVariant = Color(0xFFAAB7C4), + error = Color(0xFFFF8A80) + ) + } else { + LightColors.copy( + primary = accent, + secondary = accent.copy(alpha = 0.82f), + tertiary = if (settings.accentColorName == "Telegram Blue") TelegramBubbleBlue else accent + ) + } + + MaterialTheme( + colorScheme = colorScheme, + typography = IkarTypography, + content = content + ) +} + +fun resolveDarkTheme(mode: AppThemeMode, systemDark: Boolean): Boolean = when (mode) { + AppThemeMode.System -> systemDark + AppThemeMode.Light -> false + AppThemeMode.Dark -> true + AppThemeMode.Auto -> { + val hour = LocalTime.now().hour + hour >= 22 || hour < 7 + } +} + +fun resolveAccentColor(name: String): Color = when (name) { + "Mint" -> Color(0xFF1FAE8A) + "Graphite" -> Color(0xFF5B6B7C) + "Amber" -> Color(0xFFF0A526) + else -> TelegramBlue +} diff --git a/src/Ikar.AndroidKotlin/app/src/main/res/drawable-nodpi/appicon.png b/src/Ikar.AndroidKotlin/app/src/main/res/drawable-nodpi/appicon.png new file mode 100644 index 00000000..9a98dae8 Binary files /dev/null and b/src/Ikar.AndroidKotlin/app/src/main/res/drawable-nodpi/appicon.png differ diff --git a/src/Ikar.AndroidKotlin/app/src/main/res/drawable-nodpi/dotnet_bot.png b/src/Ikar.AndroidKotlin/app/src/main/res/drawable-nodpi/dotnet_bot.png new file mode 100644 index 00000000..054167e5 Binary files /dev/null and b/src/Ikar.AndroidKotlin/app/src/main/res/drawable-nodpi/dotnet_bot.png differ diff --git a/src/Ikar.AndroidKotlin/app/src/main/res/drawable-nodpi/ikar_logo.png b/src/Ikar.AndroidKotlin/app/src/main/res/drawable-nodpi/ikar_logo.png new file mode 100644 index 00000000..9832247a Binary files /dev/null and b/src/Ikar.AndroidKotlin/app/src/main/res/drawable-nodpi/ikar_logo.png differ diff --git a/src/Ikar.AndroidKotlin/app/src/main/res/drawable/ic_notification_transfer.xml b/src/Ikar.AndroidKotlin/app/src/main/res/drawable/ic_notification_transfer.xml new file mode 100644 index 00000000..ffabcb8f --- /dev/null +++ b/src/Ikar.AndroidKotlin/app/src/main/res/drawable/ic_notification_transfer.xml @@ -0,0 +1,9 @@ +<vector xmlns:android="http://schemas.android.com/apk/res/android" + android:width="24dp" + android:height="24dp" + android:viewportWidth="24" + android:viewportHeight="24"> + <path + android:fillColor="#FFFFFFFF" + android:pathData="M5,20h14v-2H5v2zM19,9h-4V3H9v6H5l7,7l7,-7z" /> +</vector> diff --git a/src/Ikar.AndroidKotlin/app/src/main/res/font/opensans_regular.ttf b/src/Ikar.AndroidKotlin/app/src/main/res/font/opensans_regular.ttf new file mode 100644 index 00000000..2cc82d2e Binary files /dev/null and b/src/Ikar.AndroidKotlin/app/src/main/res/font/opensans_regular.ttf differ diff --git a/src/Ikar.AndroidKotlin/app/src/main/res/font/opensans_semibold.ttf b/src/Ikar.AndroidKotlin/app/src/main/res/font/opensans_semibold.ttf new file mode 100644 index 00000000..fcb52846 Binary files /dev/null and b/src/Ikar.AndroidKotlin/app/src/main/res/font/opensans_semibold.ttf differ diff --git a/src/Ikar.AndroidKotlin/app/src/main/res/values/firebase.xml b/src/Ikar.AndroidKotlin/app/src/main/res/values/firebase.xml new file mode 100644 index 00000000..ff838c65 --- /dev/null +++ b/src/Ikar.AndroidKotlin/app/src/main/res/values/firebase.xml @@ -0,0 +1,8 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + <string name="google_app_id">1:91097833269:android:c123132a32a690ba19b67c</string> + <string name="google_api_key">AIzaSyCgjHUi4zFNRfwAwsiLBlRZCGup78N74ks</string> + <string name="gcm_defaultSenderId">91097833269</string> + <string name="project_id">ikar-3bdfb</string> + <string name="google_storage_bucket">ikar-3bdfb.firebasestorage.app</string> +</resources> diff --git a/src/Ikar.AndroidKotlin/app/src/main/res/xml/file_provider_paths.xml b/src/Ikar.AndroidKotlin/app/src/main/res/xml/file_provider_paths.xml new file mode 100644 index 00000000..da529451 --- /dev/null +++ b/src/Ikar.AndroidKotlin/app/src/main/res/xml/file_provider_paths.xml @@ -0,0 +1,6 @@ +<?xml version="1.0" encoding="utf-8"?> +<paths xmlns:android="http://schemas.android.com/apk/res/android"> + <cache-path + name="cache" + path="." /> +</paths> diff --git a/src/Ikar.AndroidKotlin/build.gradle.kts b/src/Ikar.AndroidKotlin/build.gradle.kts new file mode 100644 index 00000000..2994f926 --- /dev/null +++ b/src/Ikar.AndroidKotlin/build.gradle.kts @@ -0,0 +1,6 @@ +plugins { + id("com.android.application") version "9.1.0" apply false + id("com.google.devtools.ksp") version "2.3.6" apply false + id("org.jetbrains.kotlin.plugin.compose") version "2.3.10" apply false + id("org.jetbrains.kotlin.plugin.serialization") version "2.3.10" apply false +} diff --git a/src/Ikar.AndroidKotlin/gradle.properties b/src/Ikar.AndroidKotlin/gradle.properties new file mode 100644 index 00000000..9a848c2c --- /dev/null +++ b/src/Ikar.AndroidKotlin/gradle.properties @@ -0,0 +1,5 @@ +org.gradle.jvmargs=-Xmx4g -Dfile.encoding=UTF-8 +org.gradle.java.installations.paths=C:\\Program Files\\Android\\Android Studio\\jbr +android.useAndroidX=true +android.nonTransitiveRClass=true +kotlin.code.style=official diff --git a/src/Ikar.AndroidKotlin/gradle/wrapper/gradle-wrapper.jar b/src/Ikar.AndroidKotlin/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 00000000..61285a65 Binary files /dev/null and b/src/Ikar.AndroidKotlin/gradle/wrapper/gradle-wrapper.jar differ diff --git a/src/Ikar.AndroidKotlin/gradle/wrapper/gradle-wrapper.properties b/src/Ikar.AndroidKotlin/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 00000000..37f78a6a --- /dev/null +++ b/src/Ikar.AndroidKotlin/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.1-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/src/Ikar.AndroidKotlin/gradlew b/src/Ikar.AndroidKotlin/gradlew new file mode 100644 index 00000000..adff685a --- /dev/null +++ b/src/Ikar.AndroidKotlin/gradlew @@ -0,0 +1,248 @@ +#!/bin/sh + +# +# Copyright © 2015 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/src/Ikar.AndroidKotlin/gradlew.bat b/src/Ikar.AndroidKotlin/gradlew.bat new file mode 100644 index 00000000..c4bdd3ab --- /dev/null +++ b/src/Ikar.AndroidKotlin/gradlew.bat @@ -0,0 +1,93 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/src/Ikar.AndroidKotlin/scripts/new-argus-upload-keystore.ps1 b/src/Ikar.AndroidKotlin/scripts/new-argus-upload-keystore.ps1 new file mode 100644 index 00000000..bf31ff29 --- /dev/null +++ b/src/Ikar.AndroidKotlin/scripts/new-argus-upload-keystore.ps1 @@ -0,0 +1,89 @@ +param( + [string]$KeystorePath = 'signing/argus-upload.jks', + [string]$PropertiesPath = 'argus-signing.local.properties', + [string]$KeyAlias = 'argus-upload', + [switch]$Force +) + +$ErrorActionPreference = 'Stop' + +function New-Secret { + $value = ((1..3 | ForEach-Object { [Guid]::NewGuid().ToString('N') }) -join '') + return $value.Substring(0, 32) +} + +$repoRoot = Split-Path -Parent $PSScriptRoot +$resolvedKeystorePath = if ([System.IO.Path]::IsPathRooted($KeystorePath)) { + $KeystorePath +} else { + Join-Path $repoRoot $KeystorePath +} +$resolvedPropertiesPath = if ([System.IO.Path]::IsPathRooted($PropertiesPath)) { + $PropertiesPath +} else { + Join-Path $repoRoot $PropertiesPath +} + +if ((Test-Path $resolvedKeystorePath) -and (Test-Path $resolvedPropertiesPath) -and -not $Force) { + Write-Output "Keystore or properties already exist." + Write-Output "Keystore: $resolvedKeystorePath" + Write-Output "Properties: $resolvedPropertiesPath" + exit 0 +} + +if (((Test-Path $resolvedKeystorePath) -xor (Test-Path $resolvedPropertiesPath)) -and -not $Force) { + throw 'Keystore and properties are in a partial state. Use -Force to recreate the pair.' +} + +$keystoreDirectory = Split-Path -Parent $resolvedKeystorePath +if (-not (Test-Path $keystoreDirectory)) { + New-Item -ItemType Directory -Path $keystoreDirectory | Out-Null +} + +$storePassword = New-Secret +$keyPassword = $storePassword +$javaHome = if ($env:JAVA_HOME) { $env:JAVA_HOME } else { 'C:\Program Files\Android\openjdk\jdk-21.0.8' } +$keytoolPath = Join-Path $javaHome 'bin\keytool.exe' + +if (-not (Test-Path $keytoolPath)) { + throw "keytool.exe was not found: $keytoolPath" +} + +if (Test-Path $resolvedKeystorePath) { + Remove-Item $resolvedKeystorePath -Force +} + +& $keytoolPath ` + -genkeypair ` + -keystore $resolvedKeystorePath ` + -storetype PKCS12 ` + -storepass $storePassword ` + -keypass $keyPassword ` + -alias $KeyAlias ` + -keyalg RSA ` + -keysize 4096 ` + -validity 10000 ` + -dname 'CN=Ikar Argus Upload, O=Ikar, C=RU' | Out-Null + +if ($LASTEXITCODE -ne 0) { + throw "keytool failed with exit code $LASTEXITCODE." +} + +Push-Location $repoRoot +try { + $relativeKeystorePath = (Resolve-Path -LiteralPath $resolvedKeystorePath -Relative).Replace('\', '/') +} finally { + Pop-Location +} +$propertiesContent = @( + "storeFile=$relativeKeystorePath" + "storePassword=$storePassword" + "keyAlias=$KeyAlias" + "keyPassword=$keyPassword" +) -join [Environment]::NewLine + +[System.IO.File]::WriteAllText($resolvedPropertiesPath, $propertiesContent, [System.Text.Encoding]::ASCII) + +Write-Output "Created Argus upload keystore." +Write-Output "Keystore: $resolvedKeystorePath" +Write-Output "Properties: $resolvedPropertiesPath" diff --git a/src/Ikar.AndroidKotlin/scripts/prepare-argus-publication.ps1 b/src/Ikar.AndroidKotlin/scripts/prepare-argus-publication.ps1 new file mode 100644 index 00000000..b820a608 --- /dev/null +++ b/src/Ikar.AndroidKotlin/scripts/prepare-argus-publication.ps1 @@ -0,0 +1,189 @@ +param( + [string]$Slug = 'ikar-android', + [string]$Name, + [string]$Summary = 'Standalone Android client for Ikar on Kotlin and Jetpack Compose.', + [string]$Description = 'Standalone Android client on Kotlin and Jetpack Compose, prepared as a staged replacement for the current MAUI client.', + [string]$Version, + [string]$Channel = 'stable', + [string]$Platform = 'android', + [string]$Notes, + [string]$RepositoryUrl = 'https://git.kusoft.xyz/sevenhill/Massenger.git', + [string]$HomepageUrl = 'https://ikar.kusoft.xyz', + [switch]$Unlisted, + [switch]$SkipSigningInitialization +) + +$ErrorActionPreference = 'Stop' + +function Require-Tool([string]$Path, [string]$Label) { + if (-not (Test-Path $Path)) { + throw "$Label was not found: $Path" + } +} + +function Get-ApkBadgingValue([string]$BadgingText, [string]$Pattern) { + $match = [regex]::Match($BadgingText, $Pattern, [System.Text.RegularExpressions.RegexOptions]::Multiline) + if (-not $match.Success) { + return $null + } + return $match.Groups[1].Value +} + +function Resolve-ArgusPlatform([string[]]$NativeCodes) { + $normalized = $NativeCodes | Where-Object { $_ } | ForEach-Object { $_.Trim().ToLowerInvariant() } + $allUniversalAbis = @('arm64-v8a', 'armeabi-v7a', 'x86', 'x86_64') + if (@($allUniversalAbis | Where-Object { $normalized -contains $_ }).Count -eq $allUniversalAbis.Count) { + return 'android-universal' + } + if ($normalized -contains 'arm64-v8a') { + return 'android-arm64' + } + if ($normalized -contains 'armeabi-v7a') { + return 'android-armv7' + } + if ($normalized -contains 'x86_64') { + return 'android-x86_64' + } + if ($normalized -contains 'x86') { + return 'android-x86' + } + return 'android' +} + +$repoRoot = Split-Path -Parent $PSScriptRoot +Set-Location $repoRoot + +$javaHome = if ($env:JAVA_HOME) { $env:JAVA_HOME } else { 'C:\Program Files\Android\openjdk\jdk-21.0.8' } +$androidHome = if ($env:ANDROID_HOME) { $env:ANDROID_HOME } else { 'C:\Users\seven\AppData\Local\Android\Sdk' } +$env:JAVA_HOME = $javaHome +$env:ANDROID_HOME = $androidHome + +$gradlePath = Join-Path $repoRoot 'gradlew.bat' +$aaptPath = Join-Path $androidHome 'build-tools\36.1.0\aapt.exe' +$apksignerPath = Join-Path $androidHome 'build-tools\36.1.0\apksigner.bat' +$signingPropertiesPath = Join-Path $repoRoot 'argus-signing.local.properties' +$signingInitScript = Join-Path $PSScriptRoot 'new-argus-upload-keystore.ps1' + +Require-Tool $gradlePath 'Gradle wrapper' +Require-Tool $aaptPath 'aapt' +Require-Tool $apksignerPath 'apksigner' +Require-Tool $signingInitScript 'Argus signing init script' + +if (-not (Test-Path $signingPropertiesPath)) { + if ($SkipSigningInitialization) { + throw "Signing properties were not found: $signingPropertiesPath" + } + & $signingInitScript + if ($LASTEXITCODE -ne 0) { + throw "Signing initialization failed with exit code $LASTEXITCODE." + } +} + +& $gradlePath :app:assembleRelease --console=plain +if ($LASTEXITCODE -ne 0) { + throw "Gradle release build failed with exit code $LASTEXITCODE." +} + +$outputMetadataPath = Join-Path $repoRoot 'app\build\outputs\apk\release\output-metadata.json' +if (-not (Test-Path $outputMetadataPath)) { + throw "Release output metadata was not found: $outputMetadataPath" +} + +$outputMetadata = Get-Content $outputMetadataPath -Raw | ConvertFrom-Json +$outputFile = $outputMetadata.elements[0].outputFile +$releaseApkPath = Join-Path $repoRoot ("app\build\outputs\apk\release\{0}" -f $outputFile) +if (-not (Test-Path $releaseApkPath)) { + throw "Release APK was not found: $releaseApkPath" +} +if ($releaseApkPath -like '*-unsigned.apk') { + throw "Release APK is unsigned: $releaseApkPath" +} + +$badging = & $aaptPath dump badging $releaseApkPath +if ($LASTEXITCODE -ne 0) { + throw "aapt badging failed with exit code $LASTEXITCODE." +} +$badgingText = ($badging | Out-String) +$packageName = Get-ApkBadgingValue $badgingText "package: name='([^']+)'" +$versionCode = Get-ApkBadgingValue $badgingText "versionCode='([^']+)'" +$versionName = Get-ApkBadgingValue $badgingText "versionName='([^']+)'" +$targetSdk = Get-ApkBadgingValue $badgingText "targetSdkVersion:'([^']+)'" +$applicationLabel = Get-ApkBadgingValue $badgingText "application-label:'([^']+)'" +$nativeCodeLine = ($badging | Where-Object { $_ -like 'native-code:*' } | Select-Object -First 1) +$nativeCodes = if ($nativeCodeLine) { + [regex]::Matches($nativeCodeLine, "'([^']+)'") | ForEach-Object { $_.Groups[1].Value } +} else { + @() +} +$detectedPlatform = Resolve-ArgusPlatform $nativeCodes +$resolvedPlatform = if ([string]::IsNullOrWhiteSpace($Platform)) { + $detectedPlatform +} else { + $Platform.Trim() +} + +if (-not $packageName) { + throw 'Package name could not be extracted from APK.' +} +if (-not $versionCode) { + throw 'Version code could not be extracted from APK.' +} +if (-not $versionName) { + throw 'Version name could not be extracted from APK.' +} +if (-not $targetSdk) { + throw 'Target SDK could not be extracted from APK.' +} + +& $apksignerPath verify --print-certs $releaseApkPath | Out-Null +if ($LASTEXITCODE -ne 0) { + throw "apksigner verification failed with exit code $LASTEXITCODE." +} + +$resolvedName = if ($Name) { $Name } elseif ($applicationLabel) { $applicationLabel } else { 'Ikar' } +$releaseVersion = if ($Version) { $Version } else { $versionName } +$stageDirectory = Join-Path $repoRoot ("build\argus-publication\v{0}" -f $versionCode) +if (-not (Test-Path $stageDirectory)) { + New-Item -ItemType Directory -Path $stageDirectory | Out-Null +} + +$stagedApkName = "{0}-{1}.apk" -f $Slug, $versionCode +$stagedApkPath = Join-Path $stageDirectory $stagedApkName +Copy-Item $releaseApkPath $stagedApkPath -Force + +$metadata = [ordered]@{ + packagePath = $stagedApkPath + slug = $Slug + name = $resolvedName + summary = $Summary + description = $Description + version = $releaseVersion + channel = $Channel + platform = $resolvedPlatform + packageKind = 'apk' + notes = if ($Notes) { $Notes } else { $null } + repositoryUrl = if ($RepositoryUrl) { $RepositoryUrl } else { $null } + homepageUrl = if ($HomepageUrl) { $HomepageUrl } else { $null } + androidPackageName = $packageName + androidVersionCode = [int]$versionCode + targetSdkVersion = [int]$targetSdk + isListed = (-not $Unlisted) + verifiedFromApk = [ordered]@{ + outputFile = $releaseApkPath + packageName = $packageName + versionCode = [int]$versionCode + versionName = $versionName + targetSdkVersion = [int]$targetSdk + nativeCodes = $nativeCodes + detectedPlatform = $detectedPlatform + applicationLabel = if ($applicationLabel) { $applicationLabel } else { $null } + verifiedAt = [DateTimeOffset]::UtcNow.ToString('O') + } +} + +$metadataPath = Join-Path $stageDirectory 'argus-publication.json' +$metadata | ConvertTo-Json -Depth 6 | Set-Content -Path $metadataPath -Encoding UTF8 + +Write-Output "Prepared Argus publication files." +Write-Output "APK: $stagedApkPath" +Write-Output "Metadata: $metadataPath" diff --git a/src/Ikar.AndroidKotlin/scripts/publish-release.ps1 b/src/Ikar.AndroidKotlin/scripts/publish-release.ps1 new file mode 100644 index 00000000..42e3d6b2 --- /dev/null +++ b/src/Ikar.AndroidKotlin/scripts/publish-release.ps1 @@ -0,0 +1,141 @@ +param( + [string]$BaseUrl = 'https://argus.kusoft.xyz', + [Parameter(Mandatory = $true)] + [string]$Username, + [Parameter(Mandatory = $true)] + [string]$Password, + [Parameter(Mandatory = $true)] + [string]$Slug, + [Parameter(Mandatory = $true)] + [string]$Name, + [Parameter(Mandatory = $true)] + [string]$Summary, + [Parameter(Mandatory = $true)] + [string]$Description, + [Parameter(Mandatory = $true)] + [string]$AndroidPackageName, + [Parameter(Mandatory = $true)] + [string]$Version, + [Parameter(Mandatory = $true)] + [ValidateRange(1, [int]::MaxValue)] + [int]$AndroidVersionCode, + [Parameter(Mandatory = $true)] + [string]$PackagePath, + [string]$Channel = 'stable', + [string]$Platform = 'android', + [string]$PackageKind = 'apk', + [string]$Notes, + [string]$RepositoryUrl, + [string]$HomepageUrl = 'https://ikar.kusoft.xyz', + [switch]$Unlisted +) + +$ErrorActionPreference = 'Stop' +Add-Type -AssemblyName System.Net.Http + +function Read-HttpBody([System.Net.Http.HttpResponseMessage]$Response) { + if ($null -eq $Response.Content) { + return '' + } + + return $Response.Content.ReadAsStringAsync().GetAwaiter().GetResult() +} + +function Ensure-Success( + [System.Net.Http.HttpResponseMessage]$Response, + [string]$Operation +) { + if ($Response.IsSuccessStatusCode) { + return + } + + $body = Read-HttpBody $Response + throw "$Operation failed with status $([int]$Response.StatusCode) ($($Response.ReasonPhrase)). Body: $body" +} + +function New-JsonContent([string]$Json) { + return [System.Net.Http.StringContent]::new($Json, [System.Text.Encoding]::UTF8, 'application/json') +} + +$resolvedPackagePath = (Resolve-Path -LiteralPath $PackagePath).Path +if (-not (Test-Path $resolvedPackagePath)) { + throw "Package file was not found: $resolvedPackagePath" +} + +$baseUri = $BaseUrl.TrimEnd('/') +$cookieContainer = [System.Net.CookieContainer]::new() +$handler = [System.Net.Http.HttpClientHandler]::new() +$handler.CookieContainer = $cookieContainer +$handler.UseCookies = $true +$client = [System.Net.Http.HttpClient]::new($handler) +$client.Timeout = [TimeSpan]::FromMinutes(15) + +try { + $loginPayload = @{ + username = $Username + password = $Password + } | ConvertTo-Json -Compress + + $loginResponse = $client.PostAsync( + "$baseUri/api/admin/session/login", + (New-JsonContent $loginPayload) + ).GetAwaiter().GetResult() + Ensure-Success $loginResponse 'Argus login' + + $appPayload = @{ + name = $Name + summary = $Summary + description = $Description + androidPackageName = $AndroidPackageName + repositoryUrl = if ([string]::IsNullOrWhiteSpace($RepositoryUrl)) { $null } else { $RepositoryUrl } + homepageUrl = if ([string]::IsNullOrWhiteSpace($HomepageUrl)) { $null } else { $HomepageUrl } + isListed = (-not $Unlisted) + } | ConvertTo-Json -Compress + + $appResponse = $client.PutAsync( + "$baseUri/api/admin/apps/$Slug", + (New-JsonContent $appPayload) + ).GetAwaiter().GetResult() + Ensure-Success $appResponse 'Argus app metadata update' + + $form = [System.Net.Http.MultipartFormDataContent]::new() + $fields = [ordered]@{ + Version = $Version + Channel = $Channel + Platform = $Platform + PackageKind = $PackageKind + AndroidVersionCode = [string]$AndroidVersionCode + } + + foreach ($entry in $fields.GetEnumerator()) { + $form.Add([System.Net.Http.StringContent]::new($entry.Value, [System.Text.Encoding]::UTF8), $entry.Key) + } + + if (-not [string]::IsNullOrWhiteSpace($Notes)) { + $form.Add([System.Net.Http.StringContent]::new($Notes, [System.Text.Encoding]::UTF8), 'Notes') + } + + $fileStream = [System.IO.File]::OpenRead($resolvedPackagePath) + try { + $fileContent = [System.Net.Http.StreamContent]::new($fileStream) + $fileContent.Headers.ContentType = [System.Net.Http.Headers.MediaTypeHeaderValue]::Parse('application/vnd.android.package-archive') + $form.Add($fileContent, 'Package', [System.IO.Path]::GetFileName($resolvedPackagePath)) + + $releaseResponse = $client.PostAsync( + "$baseUri/api/admin/apps/$Slug/releases", + $form + ).GetAwaiter().GetResult() + Ensure-Success $releaseResponse 'Argus release upload' + + $releaseBody = Read-HttpBody $releaseResponse + if (-not [string]::IsNullOrWhiteSpace($releaseBody)) { + Write-Output $releaseBody + } + } finally { + $fileStream.Dispose() + $form.Dispose() + } +} finally { + $client.Dispose() + $handler.Dispose() +} diff --git a/src/Ikar.AndroidKotlin/settings.gradle.kts b/src/Ikar.AndroidKotlin/settings.gradle.kts new file mode 100644 index 00000000..4e4c91b1 --- /dev/null +++ b/src/Ikar.AndroidKotlin/settings.gradle.kts @@ -0,0 +1,18 @@ +pluginManagement { + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} + +dependencyResolutionManagement { + repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) + repositories { + google() + mavenCentral() + } +} + +rootProject.name = "IkarAndroidKotlin" +include(":app") diff --git a/src/Ikar.Server.Tests/BotsControllerTests.cs b/src/Ikar.Server.Tests/BotsControllerTests.cs new file mode 100644 index 00000000..6f295f27 --- /dev/null +++ b/src/Ikar.Server.Tests/BotsControllerTests.cs @@ -0,0 +1,151 @@ +using Ikar.Server.Data.Entities; +using Ikar.Shared; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; + +namespace Ikar.Server.Tests; + +public sealed class BotsControllerTests +{ + [Fact] + public async Task DeleteBot_RemovesBotProfileAndSafeData_ButKeepsMessageHistory() + { + await using var database = new SqliteTestDatabase(); + await using var dbContext = await database.CreateContextAsync(); + + var owner = CreateUser("owner"); + var botUser = CreateUser("helperbot", isBot: true); + botUser.Sessions.Add(new UserSession + { + UserId = botUser.Id, + User = botUser, + TokenHash = "bot-session" + }); + botUser.PushDevices.Add(new PushDevice + { + UserId = botUser.Id, + User = botUser, + Platform = PushPlatform.Android, + InstallationId = "bot-installation", + DeviceToken = "bot-device-token", + NotificationsEnabled = true + }); + + var bot = new Bot + { + Id = Guid.NewGuid(), + UserId = botUser.Id, + User = botUser, + OwnerUserId = owner.Id, + OwnerUser = owner, + TokenHash = "bot-token" + }; + var command = new BotCommand + { + BotId = bot.Id, + Bot = bot, + Command = "help", + Description = "Show help" + }; + + var chat = CreateDirectChat(owner, botUser); + var message = new Message + { + Id = Guid.NewGuid(), + ChatId = chat.Id, + Chat = chat, + SenderId = botUser.Id, + Sender = botUser, + Text = "history", + SentAt = new DateTimeOffset(2026, 5, 17, 12, 0, 0, TimeSpan.Zero) + }; + chat.Messages.Add(message); + var update = new BotUpdate + { + BotId = bot.Id, + Bot = bot, + ChatId = chat.Id, + Chat = chat, + MessageId = message.Id, + Message = message, + CreatedAt = message.SentAt + }; + + dbContext.AddRange(owner, botUser, bot, command, chat, update); + await dbContext.SaveChangesAsync(); + + var controller = ControllerTestFixture.CreateBotsController(dbContext, owner.Id); + var response = await controller.DeleteBot(bot.Id, CancellationToken.None); + + Assert.IsType<NoContentResult>(response); + Assert.False(await dbContext.Bots.AnyAsync(x => x.Id == bot.Id)); + Assert.False(await dbContext.BotCommands.AnyAsync(x => x.BotId == bot.Id)); + Assert.False(await dbContext.BotUpdates.AnyAsync(x => x.BotId == bot.Id)); + Assert.False(await dbContext.UserSessions.AnyAsync(x => x.UserId == botUser.Id)); + Assert.False(await dbContext.PushDevices.AnyAsync(x => x.UserId == botUser.Id)); + + var reloadedBotUser = await dbContext.Users.SingleAsync(x => x.Id == botUser.Id); + Assert.True(reloadedBotUser.IsBot); + Assert.True(reloadedBotUser.IsBlocked); + Assert.NotNull(reloadedBotUser.BlockedAt); + + var reloadedMessage = await dbContext.Messages.AsNoTracking().SingleAsync(x => x.Id == message.Id); + Assert.Equal(botUser.Id, reloadedMessage.SenderId); + Assert.Equal("history", reloadedMessage.Text); + } + + [Fact] + public async Task DeleteBot_ReturnsNotFoundForBotOwnedByAnotherUser() + { + await using var database = new SqliteTestDatabase(); + await using var dbContext = await database.CreateContextAsync(); + + var owner = CreateUser("owner"); + var otherUser = CreateUser("other"); + var botUser = CreateUser("otherbot", isBot: true); + var bot = new Bot + { + Id = Guid.NewGuid(), + UserId = botUser.Id, + User = botUser, + OwnerUserId = owner.Id, + OwnerUser = owner, + TokenHash = "other-bot-token" + }; + + dbContext.AddRange(owner, otherUser, botUser, bot); + await dbContext.SaveChangesAsync(); + + var controller = ControllerTestFixture.CreateBotsController(dbContext, otherUser.Id); + var response = await controller.DeleteBot(bot.Id, CancellationToken.None); + + Assert.IsType<NotFoundResult>(response); + Assert.True(await dbContext.Bots.AnyAsync(x => x.Id == bot.Id)); + } + + private static User CreateUser(string username, bool isBot = false) => + new() + { + Id = Guid.NewGuid(), + Username = username, + NormalizedUsername = username.ToUpperInvariant(), + DisplayName = username, + PasswordHash = "test", + IsBot = isBot + }; + + private static Chat CreateDirectChat(User first, User second) + { + var chat = new Chat + { + Id = Guid.NewGuid(), + Type = ChatType.Direct, + CreatedById = first.Id, + CreatedBy = first, + CreatedAt = new DateTimeOffset(2026, 5, 17, 11, 0, 0, TimeSpan.Zero) + }; + chat.Members.Add(new ChatMember { ChatId = chat.Id, Chat = chat, UserId = first.Id, User = first }); + chat.Members.Add(new ChatMember { ChatId = chat.Id, Chat = chat, UserId = second.Id, User = second }); + return chat; + } +} diff --git a/src/Ikar.Server.Tests/ChannelDtoMapperTests.cs b/src/Ikar.Server.Tests/ChannelDtoMapperTests.cs new file mode 100644 index 00000000..ac521c8e --- /dev/null +++ b/src/Ikar.Server.Tests/ChannelDtoMapperTests.cs @@ -0,0 +1,140 @@ +using Ikar.Server.Data.Entities; +using Ikar.Server.Infrastructure; +using Ikar.Shared; + +namespace Ikar.Server.Tests; + +public sealed class ChannelDtoMapperTests +{ + [Fact] + public void ChannelDetails_HideOtherSubscribersFromRegularSubscriber() + { + var owner = CreateUser("owner", "Owner"); + var subscriber = CreateUser("subscriber", "Subscriber"); + var hiddenSubscriber = CreateUser("hidden", "Hidden Subscriber"); + var channel = CreateChannel(owner, subscriber, hiddenSubscriber); + + var subscriberDetails = channel.ToDetailsDto(subscriber.Id, new PresenceTracker()); + var ownerDetails = channel.ToDetailsDto(owner.Id, new PresenceTracker()); + + Assert.Equal(3, subscriberDetails.SubscriberCount); + Assert.Equal("Product news and release notes.", subscriberDetails.Description); + Assert.Equal(1, subscriberDetails.AdminCount); + Assert.False(subscriberDetails.CanPostAsChannel); + Assert.False(subscriberDetails.CanSendMessages); + Assert.Contains(subscriberDetails.Members!, member => member.User.Id == owner.Id); + Assert.Contains(subscriberDetails.Members!, member => member.User.Id == subscriber.Id); + Assert.DoesNotContain(subscriberDetails.Members!, member => member.User.Id == hiddenSubscriber.Id); + Assert.Equal(3, ownerDetails.Members!.Count); + Assert.True(ownerDetails.CanPostAsChannel); + } + + [Fact] + public void PublicChannelDetails_AllowPreviewAndSubscribe_ForNonMember() + { + var owner = CreateUser("owner", "Owner"); + var subscriber = CreateUser("subscriber", "Subscriber"); + var viewer = CreateUser("viewer", "Viewer"); + var channel = CreateChannel(owner, subscriber); + channel.PublicUsername = "ikar_updates"; + + var details = channel.ToDetailsDto(viewer.Id, new PresenceTracker()); + + Assert.Equal(2, details.SubscriberCount); + Assert.False(details.IsSubscribed); + Assert.True(details.CanSubscribe); + Assert.False(details.CanSendMessages); + Assert.False(details.CanPostAsChannel); + var visibleMember = Assert.Single(details.Members!); + Assert.Equal(owner.Id, visibleMember.User.Id); + } + + [Fact] + public void ChannelPost_MasksSenderForRegularSubscriber_AndKeepsViewCount() + { + var owner = CreateUser("owner", "Owner"); + var subscriber = CreateUser("subscriber", "Subscriber"); + var channel = CreateChannel(owner, subscriber); + var post = new Message + { + Id = Guid.NewGuid(), + ChatId = channel.Id, + Chat = channel, + SenderId = owner.Id, + Sender = owner, + Text = "Channel post", + PostedAsChannel = true, + SentAt = DateTimeOffset.UtcNow + }; + post.Views.Add(new MessageView + { + MessageId = post.Id, + ViewerUserId = subscriber.Id, + ViewedAt = DateTimeOffset.UtcNow + }); + + var subscriberDto = post.ToDto(new PresenceTracker(), subscriber.Id); + var ownerDto = post.ToDto(new PresenceTracker(), owner.Id); + + Assert.True(subscriberDto.PostedAsChannel); + Assert.Equal(channel.Id, subscriberDto.Sender.Id); + Assert.Equal("Ikar Updates", subscriberDto.DisplayAuthorName); + Assert.Equal(1, subscriberDto.ViewCount); + Assert.Equal(owner.Id, ownerDto.Sender.Id); + } + + private static Chat CreateChannel(User owner, params User[] subscribers) + { + var channel = new Chat + { + Id = Guid.NewGuid(), + Type = ChatType.Channel, + Title = "Ikar Updates", + Description = "Product news and release notes.", + CreatedById = owner.Id, + CreatedBy = owner, + CreatedAt = DateTimeOffset.UtcNow + }; + channel.Members.Add(new ChatMember + { + ChatId = channel.Id, + Chat = channel, + UserId = owner.Id, + User = owner, + IsOwner = true, + Role = ChatMemberRole.Owner, + CanPinMessages = true, + CanDeleteMessages = true, + CanInviteUsers = true, + CanPostMessages = true + }); + + foreach (var subscriber in subscribers) + { + channel.Members.Add(new ChatMember + { + ChatId = channel.Id, + Chat = channel, + UserId = subscriber.Id, + User = subscriber, + Role = ChatMemberRole.Member, + CanPinMessages = false, + CanDeleteMessages = false, + CanInviteUsers = false, + CanPostMessages = false + }); + } + + return channel; + } + + private static User CreateUser(string username, string displayName) => + new() + { + Id = Guid.NewGuid(), + Username = username, + NormalizedUsername = username.ToUpperInvariant(), + DisplayName = displayName, + PasswordHash = "test" + }; +} diff --git a/src/Ikar.Server.Tests/ChannelMembersControllerTests.cs b/src/Ikar.Server.Tests/ChannelMembersControllerTests.cs new file mode 100644 index 00000000..0e20cf4d --- /dev/null +++ b/src/Ikar.Server.Tests/ChannelMembersControllerTests.cs @@ -0,0 +1,80 @@ +using Ikar.Server.Data.Entities; +using Ikar.Shared; + +namespace Ikar.Server.Tests; + +public sealed class ChannelMembersControllerTests +{ + [Fact] + public async Task AddMembers_CanAddBotAsChannelAdmin() + { + await using var database = new SqliteTestDatabase(); + await using var dbContext = await database.CreateContextAsync(); + var owner = CreateUser("owner"); + var bot = CreateUser("news_bot", isBot: true); + var channel = CreateChannel(owner); + dbContext.AddRange(owner, bot, channel); + await dbContext.SaveChangesAsync(); + + var controller = ControllerTestFixture.CreateChatsController(dbContext, owner.Id); + var response = await controller.AddMembers( + channel.Id, + new AddChatMembersRequest([bot.Id], ChatMemberRole.Admin), + CancellationToken.None); + + var details = ControllerTestFixture.ValueOf(response); + var members = details.Members ?? throw new InvalidOperationException("Chat members were not returned."); + var botMember = Assert.Single(members, member => member.User.Id == bot.Id); + Assert.True(botMember.User.IsBot); + Assert.Equal(ChatMemberRole.Admin, botMember.Role); + Assert.True(botMember.Permissions.CanManageMembers); + Assert.True(botMember.Permissions.CanPostMessages); + Assert.True(botMember.Permissions.CanPinMessages); + Assert.True(botMember.Permissions.CanDeleteMessages); + Assert.True(botMember.Permissions.CanInviteUsers); + Assert.Equal(2, details.SubscriberCount); + Assert.Equal(2, details.AdminCount); + } + + private static User CreateUser(string username, bool isBot = false) => + new() + { + Id = Guid.NewGuid(), + Username = username, + NormalizedUsername = username.ToUpperInvariant(), + DisplayName = username, + PasswordHash = "test", + IsBot = isBot + }; + + private static Chat CreateChannel(User owner) + { + var channel = new Chat + { + Id = Guid.NewGuid(), + Type = ChatType.Channel, + Title = "News", + Description = "Public news channel", + PublicUsername = "news", + CreatedById = owner.Id, + CreatedBy = owner, + CreatedAt = new DateTimeOffset(2026, 1, 1, 9, 0, 0, TimeSpan.Zero), + LastActivityAt = new DateTimeOffset(2026, 1, 1, 9, 0, 0, TimeSpan.Zero) + }; + channel.Members.Add(new ChatMember + { + ChatId = channel.Id, + Chat = channel, + UserId = owner.Id, + User = owner, + IsOwner = true, + Role = ChatMemberRole.Owner, + CanPinMessages = true, + CanDeleteMessages = true, + CanInviteUsers = true, + CanPostMessages = true + }); + + return channel; + } +} diff --git a/src/Ikar.Server.Tests/ClientVersionRequestReaderTests.cs b/src/Ikar.Server.Tests/ClientVersionRequestReaderTests.cs new file mode 100644 index 00000000..42c669ce --- /dev/null +++ b/src/Ikar.Server.Tests/ClientVersionRequestReaderTests.cs @@ -0,0 +1,55 @@ +using Ikar.Server.Data.Entities; +using Ikar.Server.Infrastructure.Auth; +using Ikar.Shared; +using Microsoft.AspNetCore.Http; + +namespace Ikar.Server.Tests; + +public sealed class ClientVersionRequestReaderTests +{ + [Fact] + public void Read_UsesClientVersionHeaders() + { + var context = new DefaultHttpContext(); + context.Request.Headers[ClientVersionMetadata.PlatformHeaderName] = " android "; + context.Request.Headers[ClientVersionMetadata.VersionCodeHeaderName] = "168"; + context.Request.Headers[ClientVersionMetadata.VersionNameHeaderName] = "3.55"; + + var snapshot = ClientVersionRequestReader.Read(context.Request); + + Assert.Equal("android", snapshot.ClientPlatform); + Assert.Equal(168, snapshot.ClientVersionCode); + Assert.Equal("3.55", snapshot.ClientVersionName); + } + + [Fact] + public void Apply_DoesNotClearExistingClientVersionWhenSnapshotIsEmpty() + { + var session = new UserSession + { + ClientPlatform = "android", + ClientVersionCode = 168, + ClientVersionName = "3.55" + }; + + var changed = ClientVersionRequestReader.Apply(session, new(null, null, null)); + + Assert.False(changed); + Assert.Equal("android", session.ClientPlatform); + Assert.Equal(168, session.ClientVersionCode); + Assert.Equal("3.55", session.ClientVersionName); + } + + [Fact] + public void Apply_StoresClientVersionOnPushDevice() + { + var device = new PushDevice(); + + var changed = ClientVersionRequestReader.Apply(device, new("android", 168, "3.55")); + + Assert.True(changed); + Assert.Equal("android", device.ClientPlatform); + Assert.Equal(168, device.ClientVersionCode); + Assert.Equal("3.55", device.ClientVersionName); + } +} diff --git a/src/Ikar.Server.Tests/ControllerTestFixture.cs b/src/Ikar.Server.Tests/ControllerTestFixture.cs new file mode 100644 index 00000000..447c3013 --- /dev/null +++ b/src/Ikar.Server.Tests/ControllerTestFixture.cs @@ -0,0 +1,145 @@ +using System.Security.Claims; +using Ikar.Server.Controllers; +using Ikar.Server.Data; +using Ikar.Server.Infrastructure; +using Ikar.Server.Infrastructure.Bots; +using Ikar.Server.Infrastructure.Auth; +using Ikar.Server.Infrastructure.Hubs; +using Ikar.Server.Infrastructure.Push; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.SignalR; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; + +namespace Ikar.Server.Tests; + +internal sealed class SqliteTestDatabase : IAsyncDisposable +{ + private readonly SqliteConnection _connection = new("Data Source=:memory:"); + + public async Task<IkarDbContext> CreateContextAsync() + { + await _connection.OpenAsync(); + var options = new DbContextOptionsBuilder<IkarDbContext>() + .UseSqlite(_connection) + .Options; + var dbContext = new IkarDbContext(options); + await dbContext.Database.EnsureCreatedAsync(); + return dbContext; + } + + public async ValueTask DisposeAsync() + { + await _connection.DisposeAsync(); + } +} + +internal static class ControllerTestFixture +{ + public static ChatsController CreateChatsController(IkarDbContext dbContext, Guid userId) + { + var controller = new ChatsController( + dbContext, + new PresenceTracker(), + new NoopHubContext<MessengerHub>(), + attachmentStorageService: null!, + new PushDispatchQueue(), + new BotUpdateService(dbContext)); + AttachUser(controller, userId); + return controller; + } + + public static UsersController CreateUsersController(IkarDbContext dbContext, Guid userId) + { + var controller = new UsersController( + dbContext, + new PresenceTracker(), + avatarStorageService: null!); + AttachUser(controller, userId); + return controller; + } + + public static BotsController CreateBotsController(IkarDbContext dbContext, Guid userId) + { + var controller = new BotsController(dbContext, new BotTokenService()); + AttachUser(controller, userId); + return controller; + } + + public static StoriesController CreateStoriesController(IkarDbContext dbContext, Guid userId) + { + var controller = new StoriesController( + dbContext, + new PresenceTracker(), + attachmentStorageService: null!); + AttachUser(controller, userId); + return controller; + } + + public static T ValueOf<T>(ActionResult<T> actionResult) + { + var ok = Assert.IsType<OkObjectResult>(actionResult.Result); + return Assert.IsAssignableFrom<T>(ok.Value); + } + + private static void AttachUser(ControllerBase controller, Guid userId) + { + var identity = new ClaimsIdentity( + [new Claim(ClaimTypes.NameIdentifier, userId.ToString())], + authenticationType: "Test"); + controller.ControllerContext = new ControllerContext + { + HttpContext = new DefaultHttpContext + { + User = new ClaimsPrincipal(identity) + } + }; + } +} + +internal sealed class NoopHubContext<THub> : IHubContext<THub> + where THub : Hub +{ + public IHubClients Clients { get; } = new NoopHubClients(); + + public IGroupManager Groups { get; } = new NoopGroupManager(); + + private sealed class NoopHubClients : IHubClients + { + private static readonly IClientProxy Proxy = new NoopClientProxy(); + + public IClientProxy All => Proxy; + + public IClientProxy AllExcept(IReadOnlyList<string> excludedConnectionIds) => Proxy; + + public IClientProxy Client(string connectionId) => Proxy; + + public IClientProxy Clients(IReadOnlyList<string> connectionIds) => Proxy; + + public IClientProxy Group(string groupName) => Proxy; + + public IClientProxy GroupExcept(string groupName, IReadOnlyList<string> excludedConnectionIds) => Proxy; + + public IClientProxy Groups(IReadOnlyList<string> groupNames) => Proxy; + + public IClientProxy User(string userId) => Proxy; + + public IClientProxy Users(IReadOnlyList<string> userIds) => Proxy; + } + + private sealed class NoopClientProxy : IClientProxy + { + public Task SendCoreAsync(string method, object?[] args, CancellationToken cancellationToken = default) => + Task.CompletedTask; + } + + private sealed class NoopGroupManager : IGroupManager + { + public Task AddToGroupAsync(string connectionId, string groupName, CancellationToken cancellationToken = default) => + Task.CompletedTask; + + public Task RemoveFromGroupAsync(string connectionId, string groupName, CancellationToken cancellationToken = default) => + Task.CompletedTask; + } +} diff --git a/src/Ikar.Server.Tests/Ikar.Server.Tests.csproj b/src/Ikar.Server.Tests/Ikar.Server.Tests.csproj new file mode 100644 index 00000000..8336950c --- /dev/null +++ b/src/Ikar.Server.Tests/Ikar.Server.Tests.csproj @@ -0,0 +1,25 @@ +<Project Sdk="Microsoft.NET.Sdk"> + + <PropertyGroup> + <TargetFramework>net10.0</TargetFramework> + <ImplicitUsings>enable</ImplicitUsings> + <Nullable>enable</Nullable> + <IsPackable>false</IsPackable> + </PropertyGroup> + + <ItemGroup> + <PackageReference Include="Microsoft.Data.Sqlite" Version="10.0.4" /> + <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" /> + <PackageReference Include="xunit" Version="2.9.3" /> + <PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" /> + </ItemGroup> + + <ItemGroup> + <Using Include="Xunit" /> + </ItemGroup> + + <ItemGroup> + <ProjectReference Include="..\Ikar.Server\Ikar.Server.csproj" /> + </ItemGroup> + +</Project> diff --git a/src/Ikar.Server.Tests/PrivacyAndStoriesControllerTests.cs b/src/Ikar.Server.Tests/PrivacyAndStoriesControllerTests.cs new file mode 100644 index 00000000..60d45e49 --- /dev/null +++ b/src/Ikar.Server.Tests/PrivacyAndStoriesControllerTests.cs @@ -0,0 +1,182 @@ +using Ikar.Server.Data.Entities; +using Ikar.Shared; +using Microsoft.AspNetCore.Mvc; + +namespace Ikar.Server.Tests; + +public sealed class PrivacyAndStoriesControllerTests +{ + [Fact] + public async Task UpdatePrivacySettings_NormalizesVisibilityAndDropsOwnUserId() + { + await using var database = new SqliteTestDatabase(); + await using var dbContext = await database.CreateContextAsync(); + var alice = CreateUser("alice"); + var bob = CreateUser("bob"); + dbContext.Users.AddRange(alice, bob); + await dbContext.SaveChangesAsync(); + + var controller = ControllerTestFixture.CreateUsersController(dbContext, alice.Id); + var response = await controller.UpdateMyPrivacySettings( + new UpdatePrivacySettingsRequest( + PhoneNumberVisibility: "invalid", + LastSeenVisibility: "nobody", + ProfilePhotoVisibility: "everyone", + BlockedUserIds: [alice.Id, bob.Id, bob.Id], + AlwaysAllowUserIds: [alice.Id], + NeverAllowUserIds: [bob.Id], + PasscodeLockEnabled: true, + ShowMessagePreview: false), + CancellationToken.None); + + var settings = ControllerTestFixture.ValueOf(response); + Assert.Equal("contacts", settings.PhoneNumberVisibility); + Assert.Equal("nobody", settings.LastSeenVisibility); + Assert.Equal([bob.Id], settings.BlockedUserIds); + Assert.Empty(settings.AlwaysAllowUserIds); + Assert.Equal([bob.Id], settings.NeverAllowUserIds); + Assert.True(settings.PasscodeLockEnabled); + Assert.False(settings.ShowMessagePreview); + } + + [Fact] + public async Task GetStories_ReturnsEveryoneStoriesWithoutSharedChat() + { + await using var database = new SqliteTestDatabase(); + await using var dbContext = await database.CreateContextAsync(); + var alice = CreateUser("alice"); + var bob = CreateUser("bob"); + var charlie = CreateUser("charlie"); + var sharedChat = CreateDirectChat(alice, bob); + var peerStory = CreateStory(bob, "peer", StoryVisibility.Everyone); + var publicStory = CreateStory(charlie, "public", StoryVisibility.Everyone); + dbContext.AddRange(alice, bob, charlie, sharedChat, peerStory, publicStory); + await dbContext.SaveChangesAsync(); + + var controller = ControllerTestFixture.CreateStoriesController(dbContext, alice.Id); + var response = await controller.GetStories(CancellationToken.None); + + var stories = ControllerTestFixture.ValueOf(response); + Assert.Contains(stories, story => story.Id == peerStory.Id); + Assert.Contains(stories, story => story.Id == publicStory.Id); + } + + [Fact] + public async Task GetStories_ContactsOnlyReturnsStoriesFromSharedChats() + { + await using var database = new SqliteTestDatabase(); + await using var dbContext = await database.CreateContextAsync(); + var alice = CreateUser("alice"); + var bob = CreateUser("bob"); + var charlie = CreateUser("charlie"); + var sharedChat = CreateDirectChat(alice, bob); + var visibleStory = CreateStory(bob, "visible", StoryVisibility.Contacts); + var hiddenStory = CreateStory(charlie, "hidden", StoryVisibility.Contacts); + dbContext.AddRange(alice, bob, charlie, sharedChat, visibleStory, hiddenStory); + await dbContext.SaveChangesAsync(); + + var controller = ControllerTestFixture.CreateStoriesController(dbContext, alice.Id); + var response = await controller.GetStories(CancellationToken.None); + + var stories = ControllerTestFixture.ValueOf(response); + var story = Assert.Single(stories); + Assert.Equal(visibleStory.Id, story.Id); + Assert.Equal("visible", story.Text); + } + + [Fact] + public async Task GetStories_PrivateOnlyReturnsOwnStories() + { + await using var database = new SqliteTestDatabase(); + await using var dbContext = await database.CreateContextAsync(); + var alice = CreateUser("alice"); + var bob = CreateUser("bob"); + var sharedChat = CreateDirectChat(alice, bob); + var ownStory = CreateStory(alice, "own", StoryVisibility.Private); + var peerStory = CreateStory(bob, "peer", StoryVisibility.Private); + dbContext.AddRange(alice, bob, sharedChat, ownStory, peerStory); + await dbContext.SaveChangesAsync(); + + var controller = ControllerTestFixture.CreateStoriesController(dbContext, alice.Id); + var response = await controller.GetStories(CancellationToken.None); + + var stories = ControllerTestFixture.ValueOf(response); + var story = Assert.Single(stories); + Assert.Equal(ownStory.Id, story.Id); + Assert.Equal("own", story.Text); + } + + [Fact] + public async Task GetAvatar_RespectsProfilePhotoContactsVisibility() + { + var avatarPath = Path.GetTempFileName(); + await File.WriteAllTextAsync(avatarPath, "avatar"); + try + { + await using var database = new SqliteTestDatabase(); + await using var dbContext = await database.CreateContextAsync(); + var alice = CreateUser("alice"); + var bob = CreateUser("bob"); + var charlie = CreateUser("charlie"); + alice.AvatarStoragePath = avatarPath; + alice.AvatarContentType = "image/png"; + alice.PrivacySettings = new UserPrivacySettings + { + UserId = alice.Id, + User = alice, + ProfilePhotoVisibility = "contacts" + }; + var sharedChat = CreateDirectChat(alice, bob); + dbContext.AddRange(alice, bob, charlie, sharedChat); + await dbContext.SaveChangesAsync(); + + var bobController = ControllerTestFixture.CreateUsersController(dbContext, bob.Id); + var bobResponse = await bobController.GetAvatar(alice.Id, Path.GetFileName(avatarPath), CancellationToken.None); + Assert.IsType<PhysicalFileResult>(bobResponse); + + var charlieController = ControllerTestFixture.CreateUsersController(dbContext, charlie.Id); + var charlieResponse = await charlieController.GetAvatar(alice.Id, Path.GetFileName(avatarPath), CancellationToken.None); + Assert.IsType<NotFoundResult>(charlieResponse); + } + finally + { + File.Delete(avatarPath); + } + } + + private static User CreateUser(string username) => + new() + { + Id = Guid.NewGuid(), + Username = username, + NormalizedUsername = username.ToUpperInvariant(), + DisplayName = username, + PasswordHash = "test" + }; + + private static Chat CreateDirectChat(User first, User second) + { + var chat = new Chat + { + Id = Guid.NewGuid(), + Type = ChatType.Direct, + CreatedById = first.Id, + CreatedBy = first + }; + chat.Members.Add(new ChatMember { ChatId = chat.Id, Chat = chat, UserId = first.Id, User = first }); + chat.Members.Add(new ChatMember { ChatId = chat.Id, Chat = chat, UserId = second.Id, User = second }); + return chat; + } + + private static Story CreateStory(User author, string text, StoryVisibility visibility) => + new() + { + Id = Guid.NewGuid(), + AuthorUserId = author.Id, + AuthorUser = author, + Text = text, + Visibility = visibility, + CreatedAt = DateTimeOffset.UtcNow.AddMinutes(-5), + ExpiresAt = DateTimeOffset.UtcNow.AddHours(1) + }; +} diff --git a/src/Ikar.Server.Tests/PushNotificationDispatcherTests.cs b/src/Ikar.Server.Tests/PushNotificationDispatcherTests.cs new file mode 100644 index 00000000..6114c55c --- /dev/null +++ b/src/Ikar.Server.Tests/PushNotificationDispatcherTests.cs @@ -0,0 +1,175 @@ +using Ikar.Server.Data.Entities; +using Ikar.Server.Infrastructure.Push; +using Ikar.Shared; +using Microsoft.Extensions.Logging.Abstractions; + +namespace Ikar.Server.Tests; + +public sealed class PushNotificationDispatcherTests +{ + [Fact] + public async Task DispatchMessageReactionAsync_SendsOnlyToMessageAuthorDevices() + { + await using var database = new SqliteTestDatabase(); + await using var dbContext = await database.CreateContextAsync(); + + var owner = CreateUser("owner"); + var reactor = CreateUser("reactor"); + var ownerDevice = CreateAndroidDevice(owner, "owner-device", notificationsEnabled: true); + var reactorDevice = CreateAndroidDevice(reactor, "reactor-device", notificationsEnabled: true); + var disabledOwnerDevice = CreateAndroidDevice(owner, "disabled-owner-device", notificationsEnabled: false); + var chat = CreateChat(owner, reactor); + var message = new Message + { + Chat = chat, + ChatId = chat.Id, + Sender = owner, + SenderId = owner.Id, + Text = "hello" + }; + + dbContext.AddRange(owner, reactor, chat, message, ownerDevice, reactorDevice, disabledOwnerDevice); + await dbContext.SaveChangesAsync(); + + var sender = new RecordingPushNotificationSender(); + var dispatcher = new PushNotificationDispatcher( + dbContext, + sender, + NullLogger<PushNotificationDispatcher>.Instance); + + await dispatcher.DispatchMessageReactionAsync(chat, message, reactor, "+1"); + + var call = Assert.Single(sender.ReactionCalls); + Assert.Equal(chat.Id, call.Chat.Id); + Assert.Equal(message.Id, call.Message.Id); + Assert.Equal(reactor.Id, call.Reactor.Id); + Assert.Equal("+1", call.Emoji); + var device = Assert.Single(call.Devices); + Assert.Equal(ownerDevice.Id, device.Id); + } + + [Fact] + public async Task DispatchMessageReactionAsync_DoesNotNotifySelfReaction() + { + await using var database = new SqliteTestDatabase(); + await using var dbContext = await database.CreateContextAsync(); + + var owner = CreateUser("owner"); + var ownerDevice = CreateAndroidDevice(owner, "owner-device", notificationsEnabled: true); + var chat = CreateChat(owner); + var message = new Message + { + Chat = chat, + ChatId = chat.Id, + Sender = owner, + SenderId = owner.Id, + Text = "hello" + }; + + dbContext.AddRange(owner, chat, message, ownerDevice); + await dbContext.SaveChangesAsync(); + + var sender = new RecordingPushNotificationSender(); + var dispatcher = new PushNotificationDispatcher( + dbContext, + sender, + NullLogger<PushNotificationDispatcher>.Instance); + + await dispatcher.DispatchMessageReactionAsync(chat, message, owner, "+1"); + + Assert.Empty(sender.ReactionCalls); + } + + private static User CreateUser(string username) => + new() + { + Username = username, + NormalizedUsername = username.ToUpperInvariant(), + DisplayName = username, + PasswordHash = "test" + }; + + private static Chat CreateChat(params User[] users) + { + var chat = new Chat + { + Type = users.Length == 1 ? ChatType.Direct : ChatType.Group, + Title = "Test chat", + CreatedBy = users[0], + CreatedById = users[0].Id + }; + + foreach (var user in users) + { + chat.Members.Add(new ChatMember + { + Chat = chat, + ChatId = chat.Id, + User = user, + UserId = user.Id + }); + } + + return chat; + } + + private static PushDevice CreateAndroidDevice(User user, string installationId, bool notificationsEnabled) => + new() + { + User = user, + UserId = user.Id, + Platform = PushPlatform.Android, + InstallationId = installationId, + DeviceToken = $"{installationId}-token", + NotificationsEnabled = notificationsEnabled + }; + + private sealed class RecordingPushNotificationSender : IPushNotificationSender + { + public List<ReactionPushCall> ReactionCalls { get; } = []; + + public Task SendMessageAsync( + Chat chat, + Message message, + IReadOnlyCollection<PushDevice> devices, + CancellationToken cancellationToken = default) => + Task.CompletedTask; + + public Task SendMessageReactionAsync( + Chat chat, + Message message, + User reactor, + string emoji, + IReadOnlyCollection<PushDevice> devices, + CancellationToken cancellationToken = default) + { + ReactionCalls.Add(new ReactionPushCall(chat, message, reactor, emoji, devices.ToList())); + return Task.CompletedTask; + } + + public Task SendIncomingCallAsync( + IncomingCallDto call, + IReadOnlyCollection<PushDevice> devices, + CancellationToken cancellationToken = default) => + Task.CompletedTask; + + public Task SendCallEndedAsync( + CallEndedDto call, + IReadOnlyCollection<PushDevice> devices, + CancellationToken cancellationToken = default) => + Task.CompletedTask; + + public Task SendAppUpdateAvailableAsync( + AndroidAppReleaseDto release, + IReadOnlyCollection<PushDevice> devices, + CancellationToken cancellationToken = default) => + Task.CompletedTask; + } + + private sealed record ReactionPushCall( + Chat Chat, + Message Message, + User Reactor, + string Emoji, + IReadOnlyCollection<PushDevice> Devices); +} diff --git a/src/Ikar.Server.Tests/SearchControllerTests.cs b/src/Ikar.Server.Tests/SearchControllerTests.cs new file mode 100644 index 00000000..2cbb2b69 --- /dev/null +++ b/src/Ikar.Server.Tests/SearchControllerTests.cs @@ -0,0 +1,178 @@ +using Ikar.Server.Data.Entities; +using Ikar.Shared; + +namespace Ikar.Server.Tests; + +public sealed class SearchControllerTests +{ + [Fact] + public async Task SearchPage_FiltersByAuthorDateAndType_AndReturnsJumpFields() + { + await using var database = new SqliteTestDatabase(); + await using var dbContext = await database.CreateContextAsync(); + var alice = CreateUser("alice"); + var bob = CreateUser("bob"); + var chat = CreateChat(alice, bob); + var targetMessage = CreateMessage(chat, bob, "alpha image", new DateTimeOffset(2026, 1, 3, 10, 0, 0, TimeSpan.Zero)); + targetMessage.Attachments.Add(new MessageAttachment + { + MessageId = targetMessage.Id, + OriginalFileName = "alpha.png", + ContentType = "image/png", + StoragePath = "alpha.png", + FileSizeBytes = 123 + }); + dbContext.AddRange( + alice, + bob, + chat, + CreateMessage(chat, alice, "alpha document", new DateTimeOffset(2026, 1, 2, 10, 0, 0, TimeSpan.Zero)), + targetMessage, + CreateMessage(chat, bob, "alpha later file", new DateTimeOffset(2026, 1, 4, 10, 0, 0, TimeSpan.Zero))); + await dbContext.SaveChangesAsync(); + + var controller = ControllerTestFixture.CreateChatsController(dbContext, alice.Id); + var response = await controller.SearchPage( + q: "alpha", + type: "media", + page: 1, + limit: 10, + dateFrom: new DateTimeOffset(2026, 1, 3, 0, 0, 0, TimeSpan.Zero), + dateTo: new DateTimeOffset(2026, 1, 3, 23, 59, 59, TimeSpan.Zero), + authorId: bob.Id); + + var page = ControllerTestFixture.ValueOf(response); + var result = Assert.Single(page.Results); + Assert.False(page.HasMore); + Assert.Equal(1, page.Page); + Assert.Equal(targetMessage.Id, result.MessageId); + Assert.Equal(chat.Id, result.ChatId); + Assert.Equal(bob.Id, result.SenderUserId); + Assert.Equal(targetMessage.SentAt, result.MessageSentAt); + Assert.Equal(targetMessage.Id, result.Message?.Id); + } + + [Fact] + public async Task SearchMessagesPage_PaginatesMessageResults() + { + await using var database = new SqliteTestDatabase(); + await using var dbContext = await database.CreateContextAsync(); + var alice = CreateUser("alice"); + var bob = CreateUser("bob"); + var chat = CreateChat(alice, bob); + dbContext.AddRange( + alice, + bob, + chat, + CreateMessage(chat, bob, "alpha one", new DateTimeOffset(2026, 1, 1, 10, 0, 0, TimeSpan.Zero)), + CreateMessage(chat, bob, "alpha two", new DateTimeOffset(2026, 1, 2, 10, 0, 0, TimeSpan.Zero)), + CreateMessage(chat, bob, "alpha three", new DateTimeOffset(2026, 1, 3, 10, 0, 0, TimeSpan.Zero))); + await dbContext.SaveChangesAsync(); + + var controller = ControllerTestFixture.CreateChatsController(dbContext, alice.Id); + var firstPageResponse = await controller.SearchMessagesPage(chat.Id, q: "alpha", page: 1, limit: 2); + var secondPageResponse = await controller.SearchMessagesPage(chat.Id, q: "alpha", page: 2, limit: 2); + + var firstPage = ControllerTestFixture.ValueOf(firstPageResponse); + var secondPage = ControllerTestFixture.ValueOf(secondPageResponse); + Assert.True(firstPage.HasMore); + Assert.Equal(["alpha three", "alpha two"], firstPage.Messages.Select(x => x.Text).ToArray()); + Assert.False(secondPage.HasMore); + Assert.Equal(["alpha one"], secondPage.Messages.Select(x => x.Text).ToArray()); + } + + [Fact] + public async Task SearchPage_ReturnsPublicChannelForNonMember_ByAtUsername() + { + await using var database = new SqliteTestDatabase(); + await using var dbContext = await database.CreateContextAsync(); + var viewer = CreateUser("viewer"); + var owner = CreateUser("owner"); + var publicChannel = CreateChannel(owner, "Daily News", "daily_news", "Public news channel"); + var privateChannel = CreateChannel(owner, "Daily News Private", null, "Private news channel"); + dbContext.AddRange(viewer, owner, publicChannel, privateChannel); + await dbContext.SaveChangesAsync(); + + var controller = ControllerTestFixture.CreateChatsController(dbContext, viewer.Id); + var response = await controller.SearchPage(q: "@daily_news", page: 1, limit: 10); + + var page = ControllerTestFixture.ValueOf(response); + var result = Assert.Single(page.Results); + Assert.Equal(publicChannel.Id, result.Chat.Id); + Assert.Equal(ChatType.Channel, result.Chat.Type); + Assert.True(result.Chat.CanSubscribe); + Assert.False(result.Chat.IsSubscribed); + } + + private static User CreateUser(string username) => + new() + { + Id = Guid.NewGuid(), + Username = username, + NormalizedUsername = username.ToUpperInvariant(), + DisplayName = username, + PasswordHash = "test" + }; + + private static Chat CreateChat(User first, User second) + { + var chat = new Chat + { + Id = Guid.NewGuid(), + Type = ChatType.Direct, + CreatedById = first.Id, + CreatedBy = first, + CreatedAt = new DateTimeOffset(2026, 1, 1, 9, 0, 0, TimeSpan.Zero) + }; + chat.Members.Add(new ChatMember { ChatId = chat.Id, Chat = chat, UserId = first.Id, User = first }); + chat.Members.Add(new ChatMember { ChatId = chat.Id, Chat = chat, UserId = second.Id, User = second }); + return chat; + } + + private static Message CreateMessage(Chat chat, User sender, string text, DateTimeOffset sentAt) + { + var message = new Message + { + Id = Guid.NewGuid(), + ChatId = chat.Id, + Chat = chat, + SenderId = sender.Id, + Sender = sender, + Text = text, + SentAt = sentAt + }; + chat.Messages.Add(message); + return message; + } + + private static Chat CreateChannel(User owner, string title, string? publicUsername, string description) + { + var channel = new Chat + { + Id = Guid.NewGuid(), + Type = ChatType.Channel, + Title = title, + Description = description, + PublicUsername = publicUsername, + CreatedById = owner.Id, + CreatedBy = owner, + CreatedAt = new DateTimeOffset(2026, 1, 1, 9, 0, 0, TimeSpan.Zero), + LastActivityAt = new DateTimeOffset(2026, 1, 1, 9, 0, 0, TimeSpan.Zero) + }; + channel.Members.Add(new ChatMember + { + ChatId = channel.Id, + Chat = channel, + UserId = owner.Id, + User = owner, + IsOwner = true, + Role = ChatMemberRole.Owner, + CanPinMessages = true, + CanDeleteMessages = true, + CanInviteUsers = true, + CanPostMessages = true + }); + + return channel; + } +} diff --git a/src/Ikar.Server/Controllers/AdminController.cs b/src/Ikar.Server/Controllers/AdminController.cs new file mode 100644 index 00000000..f82c731b --- /dev/null +++ b/src/Ikar.Server/Controllers/AdminController.cs @@ -0,0 +1,261 @@ +using System.Security.Claims; +using System.Security.Cryptography; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Options; +using Ikar.Server.Data; +using Ikar.Server.Infrastructure.Admin; +using Ikar.Shared; + +namespace Ikar.Server.Controllers; + +[ApiController] +[Route("admin/api")] +public sealed class AdminController( + IOptions<AdminOptions> adminOptions, + AdminService adminService, + IkarDbContext dbContext) : ControllerBase +{ + [HttpGet("session")] + [AllowAnonymous] + public async Task<ActionResult<AdminSessionStateDto>> GetSessionState() + { + var auth = await HttpContext.AuthenticateAsync(AdminAuthConstants.Scheme); + return Ok(new AdminSessionStateDto( + adminOptions.Value.IsConfigured, + auth.Succeeded, + auth.Principal?.Identity?.Name)); + } + + [HttpPost("session/login")] + [AllowAnonymous] + public async Task<IActionResult> Login(AdminLoginRequest request) + { + if (!adminOptions.Value.IsConfigured) + { + return StatusCode(StatusCodes.Status503ServiceUnavailable, "Admin login is not configured."); + } + + var username = request.Username?.Trim() ?? string.Empty; + var password = request.Password ?? string.Empty; + if (string.IsNullOrWhiteSpace(username) || string.IsNullOrWhiteSpace(password)) + { + return ValidationProblem("Username and password are required."); + } + + if (!SecureEquals(adminOptions.Value.Username, username) || + !SecureEquals(adminOptions.Value.Password, password)) + { + return Unauthorized("Invalid admin credentials."); + } + + var claims = new List<Claim> + { + new(ClaimTypes.Name, adminOptions.Value.Username), + new(ClaimTypes.Role, "Admin") + }; + + var identity = new ClaimsIdentity(claims, AdminAuthConstants.Scheme); + var principal = new ClaimsPrincipal(identity); + + await HttpContext.SignInAsync(AdminAuthConstants.Scheme, principal); + return NoContent(); + } + + [HttpPost("session/logout")] + [Authorize(Policy = AdminAuthConstants.Policy)] + public async Task<IActionResult> Logout() + { + await HttpContext.SignOutAsync(AdminAuthConstants.Scheme); + return NoContent(); + } + + [HttpGet("status")] + [Authorize(Policy = AdminAuthConstants.Policy)] + public async Task<ActionResult<AdminStatusDto>> GetStatus(CancellationToken cancellationToken) => + Ok(await adminService.GetStatusAsync(cancellationToken)); + + [HttpGet("app-updates/compatibility-report")] + [Authorize(Policy = AdminAuthConstants.Policy)] + public async Task<ActionResult<AdminAndroidUpdateCompatibilityReportDto>> GetAndroidUpdateCompatibilityReport(CancellationToken cancellationToken) => + Ok(await adminService.GetAndroidUpdateCompatibilityReportAsync(cancellationToken)); + + [HttpGet("users")] + [Authorize(Policy = AdminAuthConstants.Policy)] + public async Task<ActionResult<IReadOnlyList<AdminUserDto>>> GetUsers([FromQuery] string? q, CancellationToken cancellationToken) => + Ok(await adminService.GetUsersAsync(q, cancellationToken)); + + [HttpPost("users/{userId:guid}/block")] + [Authorize(Policy = AdminAuthConstants.Policy)] + public async Task<IActionResult> BlockUser(Guid userId, CancellationToken cancellationToken) + { + var updated = await adminService.SetUserBlockedAsync(userId, true, cancellationToken); + return updated ? NoContent() : NotFound(); + } + + [HttpPost("users/{userId:guid}/unblock")] + [Authorize(Policy = AdminAuthConstants.Policy)] + public async Task<IActionResult> UnblockUser(Guid userId, CancellationToken cancellationToken) + { + var updated = await adminService.SetUserBlockedAsync(userId, false, cancellationToken); + return updated ? NoContent() : NotFound(); + } + + [HttpDelete("users/{userId:guid}")] + [Authorize(Policy = AdminAuthConstants.Policy)] + public async Task<IActionResult> DeleteUser(Guid userId, CancellationToken cancellationToken) + { + var deleted = await adminService.DeleteUserAsync(userId, cancellationToken); + return deleted ? NoContent() : NotFound(); + } + + [HttpGet("chats")] + [Authorize(Policy = AdminAuthConstants.Policy)] + public async Task<ActionResult<IReadOnlyList<AdminChatDto>>> GetChats([FromQuery] string? q, CancellationToken cancellationToken) => + Ok(await adminService.GetChatsAsync(q, cancellationToken)); + + [HttpGet("chats/{chatId:guid}/details")] + [Authorize(Policy = AdminAuthConstants.Policy)] + public async Task<ActionResult<AdminChatDetailsDto>> GetChatDetails(Guid chatId, CancellationToken cancellationToken) + { + var details = await adminService.GetChatDetailsAsync(chatId, cancellationToken); + return details is null ? NotFound() : Ok(details); + } + + [HttpPut("chats/{chatId:guid}/settings")] + [Authorize(Policy = AdminAuthConstants.Policy)] + public async Task<ActionResult<AdminChatDetailsDto>> UpdateChatSettings( + Guid chatId, + AdminChatSettingsRequest request, + CancellationToken cancellationToken) + { + try + { + var details = await adminService.UpdateChatSettingsAsync(chatId, request, cancellationToken); + return details is null ? NotFound() : Ok(details); + } + catch (InvalidOperationException ex) + { + return BadRequest(ex.Message); + } + } + + [HttpPut("chats/{chatId:guid}/members/{memberUserId:guid}/permissions")] + [Authorize(Policy = AdminAuthConstants.Policy)] + public async Task<ActionResult<AdminChatDetailsDto>> UpdateChatMemberPermissions( + Guid chatId, + Guid memberUserId, + AdminChatMemberPermissionsRequest request, + CancellationToken cancellationToken) + { + var details = await adminService.UpdateMemberPermissionsAsync(chatId, memberUserId, request, cancellationToken); + return details is null ? NotFound() : Ok(details); + } + + [HttpPost("chats/{chatId:guid}/invite-links")] + [Authorize(Policy = AdminAuthConstants.Policy)] + public async Task<ActionResult<AdminChatDetailsDto>> CreateInviteLink( + Guid chatId, + AdminCreateInviteLinkRequest request, + CancellationToken cancellationToken) + { + try + { + var details = await adminService.CreateInviteLinkAsync(chatId, request, cancellationToken); + return details is null ? NotFound() : Ok(details); + } + catch (InvalidOperationException ex) + { + return BadRequest(ex.Message); + } + } + + [HttpDelete("chats/{chatId:guid}/invite-links/{inviteLinkId:guid}")] + [Authorize(Policy = AdminAuthConstants.Policy)] + public async Task<ActionResult<AdminChatDetailsDto>> RevokeInviteLink( + Guid chatId, + Guid inviteLinkId, + CancellationToken cancellationToken) + { + var details = await adminService.RevokeInviteLinkAsync(chatId, inviteLinkId, cancellationToken); + return details is null ? NotFound() : Ok(details); + } + + [HttpPost("chats/{chatId:guid}/join-requests/{joinRequestId:guid}")] + [Authorize(Policy = AdminAuthConstants.Policy)] + public async Task<ActionResult<AdminChatDetailsDto>> ResolveJoinRequest( + Guid chatId, + Guid joinRequestId, + AdminResolveJoinRequestRequest request, + CancellationToken cancellationToken) + { + var details = await adminService.ResolveJoinRequestAsync(chatId, joinRequestId, request.Approve, cancellationToken); + return details is null ? NotFound() : Ok(details); + } + + [HttpDelete("chats/{chatId:guid}")] + [Authorize(Policy = AdminAuthConstants.Policy)] + public async Task<IActionResult> DeleteChat(Guid chatId, CancellationToken cancellationToken) + { + var deleted = await adminService.DeleteChatAsync(chatId, cancellationToken); + return deleted ? NoContent() : NotFound(); + } + + [HttpGet("attachments")] + [Authorize(Policy = AdminAuthConstants.Policy)] + public async Task<ActionResult<IReadOnlyList<AdminAttachmentDto>>> GetAttachments([FromQuery] string? q, CancellationToken cancellationToken) => + Ok(await adminService.GetAttachmentsAsync(q, cancellationToken)); + + [HttpGet("attachments/{attachmentId:guid}/content")] + [Authorize(Policy = AdminAuthConstants.Policy)] + public async Task<IActionResult> DownloadAttachment(Guid attachmentId, CancellationToken cancellationToken) + { + var attachment = await dbContext.MessageAttachments + .AsNoTracking() + .SingleOrDefaultAsync(x => x.Id == attachmentId, cancellationToken); + + if (attachment is null || !System.IO.File.Exists(attachment.StoragePath)) + { + return NotFound(); + } + + var stream = System.IO.File.OpenRead(attachment.StoragePath); + var contentType = string.IsNullOrWhiteSpace(attachment.ContentType) + ? "application/octet-stream" + : attachment.ContentType; + + return File(stream, contentType, attachment.OriginalFileName); + } + + [HttpDelete("attachments/{attachmentId:guid}")] + [Authorize(Policy = AdminAuthConstants.Policy)] + public async Task<IActionResult> DeleteAttachment(Guid attachmentId, CancellationToken cancellationToken) + { + var deleted = await adminService.DeleteAttachmentAsync(attachmentId, cancellationToken); + return deleted ? NoContent() : NotFound(); + } + + [HttpGet("moderation-log")] + [Authorize(Policy = AdminAuthConstants.Policy)] + public async Task<ActionResult<IReadOnlyList<AdminModerationLogItemDto>>> GetModerationLog( + [FromQuery] Guid? chatId, + [FromQuery] int limit = 100, + CancellationToken cancellationToken = default) + => Ok(await adminService.GetModerationLogAsync(chatId, limit, cancellationToken)); + + [HttpPost("messages/cleanup-expired")] + [Authorize(Policy = AdminAuthConstants.Policy)] + public async Task<ActionResult<AdminExpiredMessagesCleanupDto>> CleanupExpiredMessages(CancellationToken cancellationToken) => + Ok(await adminService.CleanupExpiredMessagesAsync(cancellationToken)); + + private static bool SecureEquals(string left, string right) + { + var leftBytes = System.Text.Encoding.UTF8.GetBytes(left ?? string.Empty); + var rightBytes = System.Text.Encoding.UTF8.GetBytes(right ?? string.Empty); + return CryptographicOperations.FixedTimeEquals(leftBytes, rightBytes); + } + + public sealed record AdminLoginRequest(string? Username, string? Password); +} diff --git a/src/Ikar.Server/Controllers/AppUpdatesController.cs b/src/Ikar.Server/Controllers/AppUpdatesController.cs new file mode 100644 index 00000000..f7a1e9c2 --- /dev/null +++ b/src/Ikar.Server/Controllers/AppUpdatesController.cs @@ -0,0 +1,83 @@ +using Microsoft.AspNetCore.Mvc; +using Ikar.Server.Infrastructure.Updates; +using Ikar.Shared; + +namespace Ikar.Server.Controllers; + +[ApiController] +[Route("api/app-updates")] +public sealed class AppUpdatesController( + AndroidAppUpdateService androidAppUpdateService, + AndroidUpdateCompatibilityTrackingService trackingService) : ControllerBase +{ + [HttpGet("android/latest")] + [ResponseCache(Location = ResponseCacheLocation.None, NoStore = true)] + [ProducesResponseType(typeof(AndroidAppReleaseDto), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task<ActionResult<AndroidAppReleaseDto>> GetLatestAndroidRelease(CancellationToken cancellationToken) + { + ApplyNoCacheHeaders(); + await trackingService.TrackHitAsync(Request, "android/latest", null, cancellationToken); + var release = await androidAppUpdateService.GetLatestReleaseAsync(cancellationToken); + return release is null ? NotFound() : Ok(release); + } + + [HttpGet("android/download/{fileName}")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task<IActionResult> DownloadAndroidRelease(string fileName, CancellationToken cancellationToken) + { + await trackingService.TrackHitAsync(Request, "android/download/file", fileName, cancellationToken); + var package = await androidAppUpdateService.ResolvePackageAsync(fileName, cancellationToken); + if (package is null) + { + return NotFound(); + } + + if (!string.IsNullOrWhiteSpace(package.RedirectUrl)) + { + return Redirect(package.RedirectUrl); + } + + var physicalPath = package.PhysicalPath!; + return PhysicalFile( + physicalPath, + "application/vnd.android.package-archive", + package.FileName, + enableRangeProcessing: true); + } + + [HttpGet("android/download/latest")] + [ResponseCache(Location = ResponseCacheLocation.None, NoStore = true)] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task<IActionResult> DownloadLatestAndroidRelease(CancellationToken cancellationToken) + { + ApplyNoCacheHeaders(); + await trackingService.TrackHitAsync(Request, "android/download/latest", null, cancellationToken); + var package = await androidAppUpdateService.ResolveLatestPackageAsync(cancellationToken); + if (package is null) + { + return NotFound(); + } + + if (!string.IsNullOrWhiteSpace(package.RedirectUrl)) + { + return Redirect(package.RedirectUrl); + } + + var physicalPath = package.PhysicalPath!; + return PhysicalFile( + physicalPath, + "application/vnd.android.package-archive", + package.FileName, + enableRangeProcessing: true); + } + + private void ApplyNoCacheHeaders() + { + Response.Headers.CacheControl = "no-store, no-cache, must-revalidate, max-age=0"; + Response.Headers.Pragma = "no-cache"; + Response.Headers.Expires = "0"; + } +} diff --git a/src/Ikar.Server/Controllers/AuthController.cs b/src/Ikar.Server/Controllers/AuthController.cs new file mode 100644 index 00000000..3abb808c --- /dev/null +++ b/src/Ikar.Server/Controllers/AuthController.cs @@ -0,0 +1,275 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using Ikar.Server.Data; +using Ikar.Server.Data.Entities; +using Ikar.Server.Infrastructure; +using Ikar.Server.Infrastructure.Auth; +using Ikar.Shared; + +namespace Ikar.Server.Controllers; + +[ApiController] +[Route("api/[controller]")] +public sealed class AuthController( + IkarDbContext dbContext, + TokenService tokenService, + PresenceTracker presenceTracker, + PhoneAuthChallengeStore phoneAuthChallengeStore) : ControllerBase +{ + private readonly PasswordHasher<User> _passwordHasher = new(); + + [HttpPost("register")] + [AllowAnonymous] + public async Task<ActionResult<AuthSessionDto>> Register(RegisterRequest request, CancellationToken cancellationToken) + { + var username = request.Username?.Trim() ?? string.Empty; + var displayName = request.DisplayName?.Trim() ?? string.Empty; + var password = request.Password ?? string.Empty; + var phoneNumber = request.PhoneNumber?.Trim() ?? string.Empty; + var normalizedPhoneNumber = PhoneNumberNormalizer.Normalize(phoneNumber); + + if (string.IsNullOrWhiteSpace(username) || + string.IsNullOrWhiteSpace(displayName) || + password.Length < 6 || + normalizedPhoneNumber is null) + { + return ValidationProblem("Username, display name, phone number and a password of at least 6 characters are required."); + } + + var normalizedUsername = username.ToUpperInvariant(); + if (await dbContext.Users.AnyAsync(x => x.NormalizedUsername == normalizedUsername, cancellationToken)) + { + return Conflict("Username is already taken."); + } + + if (await dbContext.Users.AnyAsync(x => x.NormalizedPhoneNumber == normalizedPhoneNumber, cancellationToken)) + { + return Conflict("Phone number is already used."); + } + + var user = new User + { + Username = username, + NormalizedUsername = normalizedUsername, + DisplayName = displayName, + PhoneNumber = phoneNumber, + NormalizedPhoneNumber = normalizedPhoneNumber + }; + + user.PasswordHash = _passwordHasher.HashPassword(user, password); + dbContext.Users.Add(user); + try + { + await dbContext.SaveChangesAsync(cancellationToken); + } + catch (DbUpdateException) + { + var usernameTaken = await dbContext.Users + .AsNoTracking() + .AnyAsync(x => x.NormalizedUsername == normalizedUsername, cancellationToken); + if (usernameTaken) + { + return Conflict("Username is already taken."); + } + + var phoneTaken = await dbContext.Users + .AsNoTracking() + .AnyAsync(x => x.NormalizedPhoneNumber == normalizedPhoneNumber, cancellationToken); + if (phoneTaken) + { + return Conflict("Phone number is already used."); + } + + throw; + } + + var session = await tokenService.CreateSessionAsync( + user, + ClientVersionRequestReader.Read(Request), + cancellationToken); + return Ok(session with { User = user.ToDto(presenceTracker) }); + } + + [HttpPost("login")] + [AllowAnonymous] + public async Task<ActionResult<AuthSessionDto>> Login(LoginRequest request, CancellationToken cancellationToken) + { + var username = request.Username?.Trim() ?? string.Empty; + var password = request.Password ?? string.Empty; + var normalizedUsername = username.ToUpperInvariant(); + var user = await dbContext.Users.SingleOrDefaultAsync( + x => x.NormalizedUsername == normalizedUsername, + cancellationToken); + + if (user is null) + { + return Unauthorized("Invalid username or password."); + } + + if (user.IsBot) + { + return Unauthorized("Bot accounts cannot sign in through the user client."); + } + + if (user.IsBlocked) + { + return StatusCode(StatusCodes.Status423Locked, "User is blocked."); + } + + var verification = _passwordHasher.VerifyHashedPassword(user, user.PasswordHash, password); + if (verification == PasswordVerificationResult.Failed) + { + return Unauthorized("Invalid username or password."); + } + + var session = await tokenService.CreateSessionAsync( + user, + ClientVersionRequestReader.Read(Request), + cancellationToken); + return Ok(session with { User = user.ToDto(presenceTracker) }); + } + + [HttpPost("request-code")] + [AllowAnonymous] + public async Task<ActionResult<PhoneCodeChallengeDto>> RequestPhoneCode( + RequestPhoneCodeRequest request, + CancellationToken cancellationToken) + { + var normalizedPhoneNumber = PhoneNumberNormalizer.Normalize(request.PhoneNumber); + if (normalizedPhoneNumber is null) + { + return ValidationProblem("Valid phone number is required."); + } + + var user = await dbContext.Users + .AsNoTracking() + .SingleOrDefaultAsync(x => x.NormalizedPhoneNumber == normalizedPhoneNumber, cancellationToken); + if (user?.IsBlocked == true) + { + return StatusCode(StatusCodes.Status423Locked, "User is blocked."); + } + + var challenge = phoneAuthChallengeStore.Create(normalizedPhoneNumber, user is not null); + return Ok(new PhoneCodeChallengeDto( + challenge.Id, + challenge.ExpiresAt, + challenge.Code, + challenge.IsRegistered)); + } + + [HttpPost("verify-code")] + [AllowAnonymous] + public async Task<ActionResult<AuthSessionDto>> VerifyPhoneCode( + VerifyPhoneCodeRequest request, + CancellationToken cancellationToken) + { + var normalizedPhoneNumber = PhoneNumberNormalizer.Normalize(request.PhoneNumber); + if (normalizedPhoneNumber is null) + { + return ValidationProblem("Valid phone number is required."); + } + + var challenge = phoneAuthChallengeStore.Consume( + request.ChallengeId?.Trim() ?? string.Empty, + normalizedPhoneNumber, + request.Code?.Trim() ?? string.Empty); + if (challenge is null) + { + return Unauthorized("Invalid or expired verification code."); + } + + var user = await dbContext.Users.SingleOrDefaultAsync( + x => x.NormalizedPhoneNumber == normalizedPhoneNumber, + cancellationToken); + + if (user is null) + { + var displayName = request.DisplayName?.Trim() ?? string.Empty; + if (string.IsNullOrWhiteSpace(displayName)) + { + return ValidationProblem("Display name is required for a new account."); + } + + user = await CreatePhoneOnlyUserAsync(normalizedPhoneNumber, request.PhoneNumber, displayName, cancellationToken); + } + + if (user.IsBlocked) + { + return StatusCode(StatusCodes.Status423Locked, "User is blocked."); + } + + var session = await tokenService.CreateSessionAsync( + user, + ClientVersionRequestReader.Read(Request), + cancellationToken); + return Ok(session with { User = user.ToDto(presenceTracker) }); + } + + [HttpPost("refresh")] + [AllowAnonymous] + public async Task<ActionResult<AuthSessionDto>> Refresh(RefreshSessionRequest request, CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(request.RefreshToken)) + { + return Unauthorized(); + } + + var session = await tokenService.RefreshSessionAsync( + request.RefreshToken, + ClientVersionRequestReader.Read(Request), + cancellationToken); + if (session is null) + { + return Unauthorized(); + } + + return Ok(session with { User = session.User with { IsOnline = presenceTracker.IsOnline(session.User.Id) } }); + } + + [HttpPost("logout")] + [Authorize] + public async Task<IActionResult> Logout(CancellationToken cancellationToken) + { + await tokenService.RevokeSessionAsync(User.GetRequiredSessionId(), cancellationToken); + return NoContent(); + } + + private async Task<User> CreatePhoneOnlyUserAsync( + string normalizedPhoneNumber, + string rawPhoneNumber, + string displayName, + CancellationToken cancellationToken) + { + var username = await GenerateInternalUsernameAsync(cancellationToken); + var user = new User + { + Username = username, + NormalizedUsername = username.ToUpperInvariant(), + DisplayName = displayName, + PhoneNumber = rawPhoneNumber.Trim(), + NormalizedPhoneNumber = normalizedPhoneNumber + }; + + user.PasswordHash = _passwordHasher.HashPassword(user, Guid.NewGuid().ToString("N")); + dbContext.Users.Add(user); + await dbContext.SaveChangesAsync(cancellationToken); + return user; + } + + private async Task<string> GenerateInternalUsernameAsync(CancellationToken cancellationToken) + { + while (true) + { + var candidate = $"user_{Guid.NewGuid():N}".ToLowerInvariant(); + var exists = await dbContext.Users + .AsNoTracking() + .AnyAsync(x => x.NormalizedUsername == candidate.ToUpperInvariant(), cancellationToken); + if (!exists) + { + return candidate; + } + } + } +} diff --git a/src/Ikar.Server/Controllers/BotApiController.cs b/src/Ikar.Server/Controllers/BotApiController.cs new file mode 100644 index 00000000..d9d7518b --- /dev/null +++ b/src/Ikar.Server/Controllers/BotApiController.cs @@ -0,0 +1,289 @@ +using System.Text.RegularExpressions; +using Ikar.Server.Data; +using Ikar.Server.Data.Entities; +using Ikar.Server.Infrastructure; +using Ikar.Server.Infrastructure.Auth; +using Ikar.Server.Infrastructure.Bots; +using Ikar.Server.Infrastructure.Hubs; +using Ikar.Server.Infrastructure.Push; +using Ikar.Shared; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.SignalR; +using Microsoft.EntityFrameworkCore; + +namespace Ikar.Server.Controllers; + +[ApiController] +[AllowAnonymous] +[Route("api/bot/{token}")] +public sealed class BotApiController( + IkarDbContext dbContext, + PresenceTracker presenceTracker, + BotTokenService botTokenService, + BotUpdateService botUpdateService, + IHubContext<MessengerHub> hubContext, + PushDispatchQueue pushDispatchQueue) : ControllerBase +{ + [HttpGet("getMe")] + public async Task<ActionResult<UserSummaryDto>> GetMe(string token, CancellationToken cancellationToken) + { + var bot = await LoadBotByTokenAsync(token, cancellationToken); + if (bot is null) + { + return Unauthorized(); + } + + if (!bot.IsEnabled) + { + return StatusCode(StatusCodes.Status423Locked, "Bot is disabled."); + } + + return Ok(bot.User.ToDto(presenceTracker)); + } + + [HttpGet("getUpdates")] + public async Task<ActionResult<BotUpdatesResponseDto>> GetUpdates( + string token, + [FromQuery] long offset = 0, + [FromQuery] int limit = 100, + [FromQuery] int timeoutSeconds = 20, + CancellationToken cancellationToken = default) + { + var bot = await LoadBotByTokenAsync(token, cancellationToken); + if (bot is null) + { + return Unauthorized(); + } + + if (!bot.IsEnabled) + { + return StatusCode(StatusCodes.Status423Locked, "Bot is disabled."); + } + + limit = Math.Clamp(limit, 1, 100); + timeoutSeconds = Math.Clamp(timeoutSeconds, 0, 50); + + var timeoutAt = DateTimeOffset.UtcNow.AddSeconds(timeoutSeconds); + while (true) + { + var updates = await LoadUpdatesAsync(bot, offset, limit, cancellationToken); + if (updates.Count > 0 || timeoutSeconds == 0 || DateTimeOffset.UtcNow >= timeoutAt) + { + var messages = updates + .Select(x => x.Message) + .Where(x => x is not null) + .Cast<Message>() + .ToList(); + if (messages.Count > 0) + { + await botUpdateService.MarkMessagesReadByBotAsync(bot.UserId, messages, cancellationToken); + } + + var payload = updates + .Select(update => new BotUpdateDto( + update.Id, + BotUpdateKind.Message, + update.Message?.ToDto(presenceTracker, bot.UserId))) + .ToList(); + + var nextOffset = payload.Count == 0 ? offset : payload[^1].Id + 1; + return Ok(new BotUpdatesResponseDto(nextOffset, payload)); + } + + await Task.Delay(TimeSpan.FromSeconds(1), cancellationToken); + } + } + + [HttpGet("getMyCommands")] + public async Task<ActionResult<IReadOnlyList<BotCommandDto>>> GetMyCommands(string token, CancellationToken cancellationToken) + { + var bot = await LoadBotByTokenAsync(token, cancellationToken); + if (bot is null) + { + return Unauthorized(); + } + + return Ok(bot.Commands + .OrderBy(x => x.SortOrder) + .ThenBy(x => x.Command, StringComparer.OrdinalIgnoreCase) + .Select(x => new BotCommandDto(x.Command, x.Description)) + .ToList()); + } + + [HttpPost("setMyCommands")] + public async Task<ActionResult<IReadOnlyList<BotCommandDto>>> SetMyCommands( + string token, + SetBotCommandsRequest request, + CancellationToken cancellationToken) + { + var bot = await LoadBotByTokenAsync(token, cancellationToken); + if (bot is null) + { + return Unauthorized(); + } + + if (!bot.IsEnabled) + { + return StatusCode(StatusCodes.Status423Locked, "Bot is disabled."); + } + + var commands = NormalizeCommands(request.Commands); + var existingCommands = await dbContext.BotCommands + .Where(x => x.BotId == bot.Id) + .ToListAsync(cancellationToken); + dbContext.BotCommands.RemoveRange(existingCommands); + await dbContext.SaveChangesAsync(cancellationToken); + + for (var index = 0; index < commands.Count; index++) + { + dbContext.BotCommands.Add(new BotCommand + { + BotId = bot.Id, + Command = commands[index].Command, + Description = commands[index].Description, + SortOrder = index + }); + } + + bot.UpdatedAt = DateTimeOffset.UtcNow; + await dbContext.SaveChangesAsync(cancellationToken); + return Ok(commands); + } + + [HttpPost("sendMessage")] + public async Task<ActionResult<MessageDto>> SendMessage( + string token, + BotSendMessageRequest request, + CancellationToken cancellationToken) + { + var bot = await LoadBotByTokenAsync(token, cancellationToken); + if (bot is null) + { + return Unauthorized(); + } + + if (!bot.IsEnabled) + { + return StatusCode(StatusCodes.Status423Locked, "Bot is disabled."); + } + + var text = request.Text?.Trim() ?? string.Empty; + if (string.IsNullOrWhiteSpace(text)) + { + return ValidationProblem("Message text is required."); + } + + var chat = await dbContext.Chats + .Include(x => x.Members) + .SingleOrDefaultAsync( + x => x.Id == request.ChatId && x.Members.Any(member => member.UserId == bot.UserId), + cancellationToken); + if (chat is null) + { + return NotFound("Chat not found."); + } + + if (!DtoMapper.BuildPermissions(chat.Members.FirstOrDefault(member => member.UserId == bot.UserId), chat.Type).CanPostMessages) + { + return StatusCode(StatusCodes.Status403Forbidden, "Bot cannot publish in this chat."); + } + + var message = new Message + { + ChatId = chat.Id, + Chat = chat, + SenderId = bot.UserId, + Sender = bot.User, + PostedAsChannel = chat.Type == ChatType.Channel, + AuthorSignature = chat.Type == ChatType.Channel && chat.ChannelSignaturesEnabled + ? string.IsNullOrWhiteSpace(bot.User.DisplayName) ? bot.User.Username : bot.User.DisplayName + : null, + Text = text, + SentAt = DateTimeOffset.UtcNow + }; + + chat.LastActivityAt = message.SentAt; + dbContext.Messages.Add(message); + await dbContext.SaveChangesAsync(cancellationToken); + + await NotifyMessageCreatedAsync(chat, message, cancellationToken); + await pushDispatchQueue.EnqueueAsync(chat.Id, message.Id); + + return Ok(message.ToDto(presenceTracker, bot.UserId)); + } + + private async Task<Bot?> LoadBotByTokenAsync(string token, CancellationToken cancellationToken) + { + var tokenHash = botTokenService.HashToken(token); + return await dbContext.Bots + .Include(x => x.User) + .ThenInclude(x => x.Sessions) + .Include(x => x.Commands) + .SingleOrDefaultAsync(x => x.TokenHash == tokenHash, cancellationToken); + } + + private async Task<List<BotUpdate>> LoadUpdatesAsync(Bot bot, long offset, int limit, CancellationToken cancellationToken) + { + return await dbContext.BotUpdates + .AsNoTracking() + .Where(x => x.BotId == bot.Id && x.Id >= offset) + .OrderBy(x => x.Id) + .Take(limit) + .Include(x => x.Message) + .ThenInclude(x => x.Sender) + .ThenInclude(x => x.Sessions) + .Include(x => x.Message) + .ThenInclude(x => x.Attachments) + .Include(x => x.Message) + .ThenInclude(x => x.Reactions) + .Include(x => x.Message) + .ThenInclude(x => x.Views) + .Include(x => x.Message) + .ThenInclude(x => x.Chat) + .ThenInclude(x => x.Members) + .ToListAsync(cancellationToken); + } + + private Task NotifyMessageCreatedAsync(Chat chat, Message message, CancellationToken cancellationToken) + { + var tasks = chat.Members.Select(async member => + { + var dto = message.ToDto(presenceTracker, member.UserId); + await hubContext.Clients.User(member.UserId.ToString()) + .SendAsync("MessageCreated", dto, cancellationToken); + }); + + return Task.WhenAll(tasks); + } + + private static List<BotCommandDto> NormalizeCommands(IReadOnlyList<BotCommandDto> commands) + { + var normalized = new List<BotCommandDto>(); + var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase); + + foreach (var command in commands ?? Array.Empty<BotCommandDto>()) + { + var normalizedCommand = (command.Command ?? string.Empty).Trim().TrimStart('/'); + var normalizedDescription = (command.Description ?? string.Empty).Trim(); + if (string.IsNullOrWhiteSpace(normalizedCommand) || string.IsNullOrWhiteSpace(normalizedDescription)) + { + continue; + } + + if (!Regex.IsMatch(normalizedCommand, "^[a-z0-9_]{1,32}$", RegexOptions.IgnoreCase)) + { + continue; + } + + if (!seen.Add(normalizedCommand)) + { + continue; + } + + normalized.Add(new BotCommandDto(normalizedCommand.ToLowerInvariant(), normalizedDescription)); + } + + return normalized; + } +} diff --git a/src/Ikar.Server/Controllers/BotsController.cs b/src/Ikar.Server/Controllers/BotsController.cs new file mode 100644 index 00000000..4c8e5ccb --- /dev/null +++ b/src/Ikar.Server/Controllers/BotsController.cs @@ -0,0 +1,307 @@ +using System.Text.RegularExpressions; +using Ikar.Server.Data; +using Ikar.Server.Data.Entities; +using Ikar.Server.Infrastructure; +using Ikar.Server.Infrastructure.Auth; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using Ikar.Shared; + +namespace Ikar.Server.Controllers; + +[ApiController] +[Authorize] +[Route("api/[controller]")] +public sealed class BotsController( + IkarDbContext dbContext, + BotTokenService botTokenService) : ControllerBase +{ + private static readonly Regex BotUsernameRegex = new("^[A-Za-z0-9_]{5,64}$", RegexOptions.Compiled); + private readonly PasswordHasher<User> _passwordHasher = new(); + + [HttpGet("mine")] + public async Task<ActionResult<IReadOnlyList<BotSummaryDto>>> GetMine(CancellationToken cancellationToken) + { + var ownerUserId = User.GetRequiredUserId(); + var bots = await dbContext.Bots + .Include(x => x.User) + .Include(x => x.Commands) + .AsNoTracking() + .Where(x => x.OwnerUserId == ownerUserId) + .OrderBy(x => x.User.DisplayName) + .ToListAsync(cancellationToken); + + return Ok(bots.Select(x => x.ToDto()).ToList()); + } + + [HttpGet("{botId:guid}")] + public async Task<ActionResult<BotSummaryDto>> GetBot(Guid botId, CancellationToken cancellationToken) + { + var bot = await LoadOwnedBotAsync(botId, User.GetRequiredUserId(), cancellationToken); + if (bot is null) + { + return NotFound(); + } + + return Ok(bot.ToDto()); + } + + [HttpPost] + public async Task<ActionResult<CreatedBotDto>> CreateBot( + CreateBotRequest request, + CancellationToken cancellationToken) + { + var ownerUserId = User.GetRequiredUserId(); + var validationError = await ValidateBotDefinitionAsync( + request.Username, + request.DisplayName, + request.About, + excludingBotId: null, + cancellationToken); + if (validationError is not null) + { + return ValidationProblem(validationError); + } + + var botUser = new User + { + Username = request.Username.Trim(), + NormalizedUsername = request.Username.Trim().ToUpperInvariant(), + DisplayName = request.DisplayName.Trim(), + About = string.IsNullOrWhiteSpace(request.About) ? null : request.About.Trim(), + IsBot = true + }; + botUser.PasswordHash = _passwordHasher.HashPassword(botUser, Guid.NewGuid().ToString("N")); + + var bot = new Bot + { + User = botUser, + OwnerUserId = ownerUserId + }; + + var rawToken = botTokenService.GenerateToken(bot.Id); + bot.TokenHash = botTokenService.HashToken(rawToken); + + dbContext.Bots.Add(bot); + await dbContext.SaveChangesAsync(cancellationToken); + + var created = await LoadOwnedBotAsync(bot.Id, ownerUserId, cancellationToken) + ?? throw new InvalidOperationException("Newly created bot was not found."); + + return Ok(new CreatedBotDto(created.ToDto(), rawToken)); + } + + [HttpPut("{botId:guid}")] + public async Task<ActionResult<BotSummaryDto>> UpdateBot( + Guid botId, + UpdateBotRequest request, + CancellationToken cancellationToken) + { + var ownerUserId = User.GetRequiredUserId(); + var bot = await LoadOwnedBotAsync(botId, ownerUserId, cancellationToken); + if (bot is null) + { + return NotFound(); + } + + var validationError = await ValidateBotDefinitionAsync( + bot.User.Username, + request.DisplayName, + request.About, + excludingBotId: botId, + cancellationToken); + if (validationError is not null) + { + return ValidationProblem(validationError); + } + + bot.User.DisplayName = request.DisplayName.Trim(); + bot.User.About = string.IsNullOrWhiteSpace(request.About) ? null : request.About.Trim(); + bot.IsEnabled = request.IsEnabled; + bot.UpdatedAt = DateTimeOffset.UtcNow; + await dbContext.SaveChangesAsync(cancellationToken); + + return Ok(bot.ToDto()); + } + + [HttpPut("{botId:guid}/commands")] + public async Task<ActionResult<BotSummaryDto>> SetCommands( + Guid botId, + SetBotCommandsRequest request, + CancellationToken cancellationToken) + { + var ownerUserId = User.GetRequiredUserId(); + var bot = await LoadOwnedBotAsync(botId, ownerUserId, cancellationToken); + if (bot is null) + { + return NotFound(); + } + + var normalizedCommands = NormalizeCommands(request.Commands); + var existingCommands = await dbContext.BotCommands + .Where(x => x.BotId == bot.Id) + .ToListAsync(cancellationToken); + dbContext.BotCommands.RemoveRange(existingCommands); + await dbContext.SaveChangesAsync(cancellationToken); + + for (var index = 0; index < normalizedCommands.Count; index++) + { + dbContext.BotCommands.Add(new BotCommand + { + BotId = bot.Id, + Command = normalizedCommands[index].Command, + Description = normalizedCommands[index].Description, + SortOrder = index + }); + } + + bot.UpdatedAt = DateTimeOffset.UtcNow; + await dbContext.SaveChangesAsync(cancellationToken); + var reloaded = await LoadOwnedBotAsync(bot.Id, ownerUserId, cancellationToken) + ?? throw new InvalidOperationException("Updated bot was not found."); + return Ok(reloaded.ToDto()); + } + + [HttpPost("{botId:guid}/regenerate-token")] + public async Task<ActionResult<RegenerateBotTokenResponseDto>> RegenerateToken(Guid botId, CancellationToken cancellationToken) + { + var ownerUserId = User.GetRequiredUserId(); + var bot = await LoadOwnedBotAsync(botId, ownerUserId, cancellationToken); + if (bot is null) + { + return NotFound(); + } + + var rawToken = botTokenService.GenerateToken(bot.Id); + bot.TokenHash = botTokenService.HashToken(rawToken); + bot.UpdatedAt = DateTimeOffset.UtcNow; + await dbContext.SaveChangesAsync(cancellationToken); + + return Ok(new RegenerateBotTokenResponseDto(rawToken)); + } + + [HttpDelete("{botId:guid}")] + public async Task<IActionResult> DeleteBot(Guid botId, CancellationToken cancellationToken) + { + var ownerUserId = User.GetRequiredUserId(); + var bot = await dbContext.Bots + .Include(x => x.User) + .ThenInclude(x => x.Sessions) + .Include(x => x.User) + .ThenInclude(x => x.PushDevices) + .SingleOrDefaultAsync( + x => x.Id == botId && x.OwnerUserId == ownerUserId, + cancellationToken); + if (bot is null) + { + return NotFound(); + } + + var botCommands = await dbContext.BotCommands + .Where(x => x.BotId == bot.Id) + .ToListAsync(cancellationToken); + var botUpdates = await dbContext.BotUpdates + .Where(x => x.BotId == bot.Id) + .ToListAsync(cancellationToken); + + dbContext.BotUpdates.RemoveRange(botUpdates); + dbContext.BotCommands.RemoveRange(botCommands); + dbContext.UserSessions.RemoveRange(bot.User.Sessions); + dbContext.PushDevices.RemoveRange(bot.User.PushDevices); + bot.User.IsBlocked = true; + bot.User.BlockedAt = DateTimeOffset.UtcNow; + dbContext.Bots.Remove(bot); + + await dbContext.SaveChangesAsync(cancellationToken); + return NoContent(); + } + + private async Task<Bot?> LoadOwnedBotAsync(Guid botId, Guid ownerUserId, CancellationToken cancellationToken) + { + return await dbContext.Bots + .Include(x => x.User) + .Include(x => x.Commands) + .SingleOrDefaultAsync( + x => x.Id == botId && x.OwnerUserId == ownerUserId, + cancellationToken); + } + + private async Task<string?> ValidateBotDefinitionAsync( + string? username, + string? displayName, + string? about, + Guid? excludingBotId, + CancellationToken cancellationToken) + { + var trimmedUsername = username?.Trim() ?? string.Empty; + var trimmedDisplayName = displayName?.Trim() ?? string.Empty; + var normalizedUsername = trimmedUsername.ToUpperInvariant(); + + if (string.IsNullOrWhiteSpace(trimmedDisplayName)) + { + return "Display name is required."; + } + + if (trimmedDisplayName.Length > 120) + { + return "Display name must be 120 characters or fewer."; + } + + if (!string.IsNullOrWhiteSpace(about) && about.Trim().Length > 70) + { + return "About must be 70 characters or fewer."; + } + + if (!BotUsernameRegex.IsMatch(trimmedUsername) || + !trimmedUsername.EndsWith("bot", StringComparison.OrdinalIgnoreCase)) + { + return "Bot username must contain only latin letters, digits or underscore and end with 'bot'."; + } + + var usernameTaken = await dbContext.Users + .AsNoTracking() + .Include(x => x.BotProfile) + .AnyAsync( + x => x.NormalizedUsername == normalizedUsername && + (!excludingBotId.HasValue || x.BotProfile == null || x.BotProfile.Id != excludingBotId.Value), + cancellationToken); + if (usernameTaken) + { + return "Username is already taken."; + } + + return null; + } + + private static List<BotCommandDto> NormalizeCommands(IReadOnlyList<BotCommandDto> commands) + { + var normalized = new List<BotCommandDto>(); + var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase); + + foreach (var command in commands ?? Array.Empty<BotCommandDto>()) + { + var normalizedCommand = (command.Command ?? string.Empty).Trim().TrimStart('/'); + var normalizedDescription = (command.Description ?? string.Empty).Trim(); + if (string.IsNullOrWhiteSpace(normalizedCommand) || string.IsNullOrWhiteSpace(normalizedDescription)) + { + continue; + } + + if (!Regex.IsMatch(normalizedCommand, "^[a-z0-9_]{1,32}$", RegexOptions.IgnoreCase)) + { + continue; + } + + if (!seen.Add(normalizedCommand)) + { + continue; + } + + normalized.Add(new BotCommandDto(normalizedCommand.ToLowerInvariant(), normalizedDescription)); + } + + return normalized; + } +} diff --git a/src/Ikar.Server/Controllers/ChatsController.cs b/src/Ikar.Server/Controllers/ChatsController.cs new file mode 100644 index 00000000..f1cf37a2 --- /dev/null +++ b/src/Ikar.Server/Controllers/ChatsController.cs @@ -0,0 +1,3273 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.SignalR; +using Microsoft.EntityFrameworkCore; +using Ikar.Server.Data; +using Ikar.Server.Data.Entities; +using Ikar.Server.Infrastructure; +using Ikar.Server.Infrastructure.Bots; +using Ikar.Server.Infrastructure.Hubs; +using Ikar.Server.Infrastructure.Push; +using Ikar.Server.Infrastructure.Storage; +using Ikar.Shared; +using System.Net.Mime; +using System.Security.Cryptography; + +namespace Ikar.Server.Controllers; + +[ApiController] +[Authorize] +[Route("api/[controller]")] +public sealed class ChatsController( + IkarDbContext dbContext, + PresenceTracker presenceTracker, + IHubContext<MessengerHub> hubContext, + AttachmentStorageService attachmentStorageService, + PushDispatchQueue pushDispatchQueue, + BotUpdateService botUpdateService) : ControllerBase +{ + private const long MaxAttachmentSizeBytes = 25 * 1024 * 1024; + private const long MaxAlbumSizeBytes = 100 * 1024 * 1024; + private const int MaxAlbumAttachments = 10; + private const int DefaultMessagePageSize = 50; + private const int MaxMessagePageSize = 100; + + [HttpGet] + public async Task<ActionResult<IReadOnlyList<ChatSummaryDto>>> GetChats(CancellationToken cancellationToken) + { + var userId = User.GetRequiredUserId(); + + var chats = await dbContext.Chats + .Where(x => x.Members.Any(member => member.UserId == userId)) + .Include(x => x.Members) + .ThenInclude(x => x.User) + .AsNoTracking() + .AsSplitQuery() + .ToListAsync(cancellationToken); + + var chatIds = chats.Select(x => x.Id).ToList(); + var latestMessagesByChatId = await LoadLatestMessagesByChatIdAsync(chatIds, cancellationToken); + var unreadCountsByChatId = await LoadUnreadCountsByChatIdAsync(userId, chatIds, cancellationToken); + + return Ok(chats + .OrderByDescending(x => x.LastActivityAt ?? x.CreatedAt) + .Select(x => x.ToSummaryDto( + userId, + presenceTracker, + latestMessagesByChatId.GetValueOrDefault(x.Id), + unreadCountsByChatId.GetValueOrDefault(x.Id))) + .ToList()); + } + + [HttpGet("folders")] + public async Task<ActionResult<IReadOnlyList<ChatFolderDto>>> GetFolders(CancellationToken cancellationToken) + { + var userId = User.GetRequiredUserId(); + var memberships = await dbContext.ChatMembers + .Where(member => member.UserId == userId) + .Select(member => new + { + member.ChatId, + member.IsArchived, + member.IsPinned, + member.FolderKey, + member.LastReadAt + }) + .ToListAsync(cancellationToken); + + var chatIds = memberships.Select(x => x.ChatId).ToList(); + var unreadCounts = await LoadUnreadCountsByChatIdAsync(userId, chatIds, cancellationToken); + var folders = new List<ChatFolderDto> + { + new("all", "All chats", memberships.Count, unreadCounts.Values.Sum()), + new("unread", "Unread", unreadCounts.Count(x => x.Value > 0), unreadCounts.Values.Sum()), + new("pinned", "Pinned", memberships.Count(x => x.IsPinned), memberships.Where(x => x.IsPinned).Sum(x => unreadCounts.GetValueOrDefault(x.ChatId))), + new("archive", "Archive", memberships.Count(x => x.IsArchived), memberships.Where(x => x.IsArchived).Sum(x => unreadCounts.GetValueOrDefault(x.ChatId))) + }; + + folders.AddRange(memberships + .Where(x => !string.IsNullOrWhiteSpace(x.FolderKey)) + .GroupBy(x => x.FolderKey!) + .OrderBy(group => group.Key, StringComparer.OrdinalIgnoreCase) + .Select(group => new ChatFolderDto( + group.Key, + group.Key, + group.Count(), + group.Sum(x => unreadCounts.GetValueOrDefault(x.ChatId))))); + + return Ok(folders); + } + + [HttpGet("search")] + public async Task<ActionResult<IReadOnlyList<ChatSearchResultDto>>> Search( + [FromQuery] string q, + [FromQuery] string? filter = null, + [FromQuery] string? type = null, + [FromQuery] int page = 1, + [FromQuery] int limit = 50, + [FromQuery] DateTimeOffset? date = null, + [FromQuery] DateTimeOffset? dateFrom = null, + [FromQuery] DateTimeOffset? dateTo = null, + [FromQuery] Guid? authorId = null, + [FromQuery] string? author = null, + CancellationToken cancellationToken = default) + { + var userId = User.GetRequiredUserId(); + var searchPage = await SearchChatsPageAsync( + userId, + q, + BuildSearchFilters(filter, type, date, dateFrom, dateTo, authorId, author), + page, + limit, + cancellationToken); + WriteSearchPageHeaders(searchPage.Page, searchPage.Limit, searchPage.HasMore); + return Ok(searchPage.Results); + } + + [HttpGet("search/page")] + public async Task<ActionResult<ChatSearchPageDto>> SearchPage( + [FromQuery] string q, + [FromQuery] string? filter = null, + [FromQuery] string? type = null, + [FromQuery] int page = 1, + [FromQuery] int limit = 50, + [FromQuery] DateTimeOffset? date = null, + [FromQuery] DateTimeOffset? dateFrom = null, + [FromQuery] DateTimeOffset? dateTo = null, + [FromQuery] Guid? authorId = null, + [FromQuery] string? author = null, + CancellationToken cancellationToken = default) + { + var userId = User.GetRequiredUserId(); + var searchPage = await SearchChatsPageAsync( + userId, + q, + BuildSearchFilters(filter, type, date, dateFrom, dateTo, authorId, author), + page, + limit, + cancellationToken); + return Ok(searchPage); + } + + [HttpGet("{chatId:guid}")] + public async Task<ActionResult<ChatDetailsDto>> GetChat(Guid chatId, CancellationToken cancellationToken) + { + var userId = User.GetRequiredUserId(); + var chat = await LoadChatMetadata( + chatId, + userId, + cancellationToken, + asNoTracking: false, + allowPublicChannelPreview: true); + + if (chat is null) + { + return NotFound(); + } + + var currentMembership = chat.Members.FirstOrDefault(member => member.UserId == userId); + var previousReadAt = currentMembership?.LastReadAt; + var firstUnreadMessageId = previousReadAt is null + ? null + : await dbContext.Messages + .Where(message => + message.ChatId == chatId && + message.SenderId != userId && + message.SentAt > previousReadAt.Value) + .OrderBy(message => message.SentAt) + .Select(message => (Guid?)message.Id) + .FirstOrDefaultAsync(cancellationToken); + + if (currentMembership is not null && chat.Type != ChatType.Channel) + { + await UpdateReadReceiptForOpenedChatAsync(chat, userId, cancellationToken); + } + + var page = await LoadMessagesPageAsync(chat, beforeMessageId: null, DefaultMessagePageSize, cancellationToken); + await MarkChannelMessagesViewedAsync(chat, userId, page.Messages, cancellationToken); + var pinnedMessages = await LoadPinnedMessagesAsync(chat, cancellationToken); + return Ok(chat.ToDetailsDto( + userId, + presenceTracker, + firstUnreadMessageId, + page.Messages, + page.HasMoreMessages, + pinnedMessages)); + } + + [HttpGet("{chatId:guid}/info")] + public async Task<ActionResult<ChatInfoDto>> GetChatInfo(Guid chatId, CancellationToken cancellationToken) + { + var userId = User.GetRequiredUserId(); + var chat = await LoadChatMetadata( + chatId, + userId, + cancellationToken, + allowPublicChannelPreview: true); + if (chat is null) + { + return NotFound(); + } + + var page = await LoadMessagesPageAsync(chat, beforeMessageId: null, DefaultMessagePageSize, cancellationToken); + var pinnedMessages = await LoadPinnedMessagesAsync(chat, cancellationToken); + var details = chat.ToDetailsDto( + userId, + presenceTracker, + messagesOverride: page.Messages, + hasMoreMessages: page.HasMoreMessages, + pinnedMessagesOverride: pinnedMessages); + + var mediaCount = await dbContext.MessageAttachments + .CountAsync(x => + x.Message.ChatId == chatId && + x.Message.DeletedAt == null && + (x.ContentType.StartsWith("image/") || x.ContentType.StartsWith("video/")), + cancellationToken); + var fileCount = await dbContext.MessageAttachments + .CountAsync(x => + x.Message.ChatId == chatId && + x.Message.DeletedAt == null && + !x.ContentType.StartsWith("image/") && + !x.ContentType.StartsWith("video/") && + !x.ContentType.StartsWith("audio/"), + cancellationToken); + var voiceCount = await dbContext.MessageAttachments + .CountAsync(x => x.Message.ChatId == chatId && x.Message.DeletedAt == null && x.ContentType.StartsWith("audio/"), cancellationToken); + var linkCount = await dbContext.Messages + .CountAsync(x => + x.ChatId == chatId && + x.DeletedAt == null && + (x.Text.Contains("http://") || x.Text.Contains("https://")), + cancellationToken); + var pinnedCount = await dbContext.Messages + .CountAsync(x => x.ChatId == chatId && x.DeletedAt == null && x.PinnedAt != null, cancellationToken); + var canInspectInviteLinks = ResolvePermissions(chat, userId).CanInviteUsers; + var inviteLinks = canInspectInviteLinks + ? await dbContext.ChatInviteLinks + .AsNoTracking() + .Where(x => x.ChatId == chatId) + .OrderByDescending(x => x.CreatedAt) + .Take(20) + .ToListAsync(cancellationToken) + : []; + var topics = await LoadChatTopicDtosAsync(chatId, cancellationToken); + + return Ok(new ChatInfoDto( + details, + mediaCount, + fileCount, + linkCount, + voiceCount, + pinnedCount, + topics.Count, + chat.PublicUsername, + inviteLinks.Select(ToInviteLinkDto).ToList(), + topics, + details.SubscriberCount, + details.AdminCount, + chat.LinkedDiscussionChatId, + chat.ChannelSignaturesEnabled, + details.CanManageChannel, + details.CanPostAsChannel, + chat.Description)); + } + + [HttpGet("{chatId:guid}/invite-links")] + public async Task<ActionResult<IReadOnlyList<ChatInviteLinkDto>>> GetInviteLinks(Guid chatId, CancellationToken cancellationToken) + { + var userId = User.GetRequiredUserId(); + var chat = await LoadChatMetadata(chatId, userId, cancellationToken); + if (chat is null) + { + return NotFound(); + } + + if (!ResolvePermissions(chat, userId).CanInviteUsers) + { + return StatusCode(StatusCodes.Status403Forbidden, "You cannot manage invite links in this chat."); + } + + var links = await dbContext.ChatInviteLinks + .AsNoTracking() + .Where(x => x.ChatId == chatId) + .OrderByDescending(x => x.CreatedAt) + .ToListAsync(cancellationToken); + return Ok(links.Select(ToInviteLinkDto).ToList()); + } + + [HttpPost("{chatId:guid}/invite-links")] + public async Task<ActionResult<ChatInviteLinkDto>> CreateInviteLink( + Guid chatId, + CreateChatInviteLinkRequest request, + CancellationToken cancellationToken) + { + var userId = User.GetRequiredUserId(); + var chat = await LoadChatMetadata(chatId, userId, cancellationToken, asNoTracking: false); + if (chat is null) + { + return NotFound(); + } + + if (!ResolvePermissions(chat, userId).CanInviteUsers) + { + return StatusCode(StatusCodes.Status403Forbidden, "You cannot create invite links in this chat."); + } + + if (chat.Type == ChatType.Direct) + { + return BadRequest("Direct chats do not support invite links."); + } + + var link = new ChatInviteLink + { + ChatId = chatId, + CreatedById = userId, + Token = await GenerateInviteTokenAsync(cancellationToken), + Title = TrimToNull(request.Title, 64), + CreatesJoinRequest = request.CreatesJoinRequest, + CreatedAt = DateTimeOffset.UtcNow + }; + + dbContext.ChatInviteLinks.Add(link); + await dbContext.SaveChangesAsync(cancellationToken); + return Ok(ToInviteLinkDto(link)); + } + + [HttpDelete("{chatId:guid}/invite-links/{inviteLinkId:guid}")] + public async Task<IActionResult> RevokeInviteLink(Guid chatId, Guid inviteLinkId, CancellationToken cancellationToken) + { + var userId = User.GetRequiredUserId(); + var chat = await LoadChatMetadata(chatId, userId, cancellationToken); + if (chat is null) + { + return NotFound(); + } + + if (!ResolvePermissions(chat, userId).CanInviteUsers) + { + return StatusCode(StatusCodes.Status403Forbidden, "You cannot revoke invite links in this chat."); + } + + var link = await dbContext.ChatInviteLinks + .SingleOrDefaultAsync(x => x.Id == inviteLinkId && x.ChatId == chatId, cancellationToken); + if (link is null) + { + return NotFound(); + } + + link.IsRevoked = true; + link.RevokedAt = DateTimeOffset.UtcNow; + await dbContext.SaveChangesAsync(cancellationToken); + return NoContent(); + } + + [HttpGet("{chatId:guid}/join-requests")] + public async Task<ActionResult<IReadOnlyList<ChatJoinRequestDto>>> GetJoinRequests(Guid chatId, CancellationToken cancellationToken) + { + var userId = User.GetRequiredUserId(); + var chat = await LoadChatMetadata(chatId, userId, cancellationToken); + if (chat is null) + { + return NotFound(); + } + + if (!CanManageMembers(chat, userId)) + { + return StatusCode(StatusCodes.Status403Forbidden, "Only admins can review join requests."); + } + + var requests = await dbContext.ChatJoinRequests + .AsNoTracking() + .Include(x => x.User) + .ThenInclude(x => x.Sessions) + .Include(x => x.ResolvedByUser) + .ThenInclude(x => x!.Sessions) + .Where(x => x.ChatId == chatId) + .OrderByDescending(x => x.RequestedAt) + .Take(100) + .ToListAsync(cancellationToken); + + return Ok(requests.Select(ToJoinRequestDto).ToList()); + } + + [HttpPost("{chatId:guid}/join-requests/{joinRequestId:guid}")] + public async Task<ActionResult<ChatJoinRequestDto>> ResolveJoinRequest( + Guid chatId, + Guid joinRequestId, + ResolveChatJoinRequestRequest request, + CancellationToken cancellationToken) + { + var userId = User.GetRequiredUserId(); + var chat = await dbContext.Chats + .Include(x => x.Members) + .ThenInclude(x => x.User) + .SingleOrDefaultAsync(x => x.Id == chatId && x.Members.Any(member => member.UserId == userId), cancellationToken); + if (chat is null) + { + return NotFound(); + } + + if (!CanManageMembers(chat, userId)) + { + return StatusCode(StatusCodes.Status403Forbidden, "Only admins can resolve join requests."); + } + + var joinRequest = await dbContext.ChatJoinRequests + .Include(x => x.User) + .ThenInclude(x => x.Sessions) + .Include(x => x.ResolvedByUser) + .ThenInclude(x => x!.Sessions) + .SingleOrDefaultAsync(x => x.Id == joinRequestId && x.ChatId == chatId, cancellationToken); + if (joinRequest is null) + { + return NotFound(); + } + + if (joinRequest.Status != ChatJoinRequestStatus.Pending) + { + return Ok(ToJoinRequestDto(joinRequest)); + } + + joinRequest.Status = request.Approve ? ChatJoinRequestStatus.Approved : ChatJoinRequestStatus.Declined; + joinRequest.ResolvedAt = DateTimeOffset.UtcNow; + joinRequest.ResolvedByUserId = userId; + + if (request.Approve && chat.Members.All(member => member.UserId != joinRequest.UserId)) + { + chat.Members.Add(CreateSubscriberMembership(chat, joinRequest.UserId)); + chat.LastActivityAt = DateTimeOffset.UtcNow; + } + + await AddModerationLogAsync( + userId, + chatId, + ModerationLogTargetType.JoinRequest, + joinRequest.Id, + request.Approve ? "join_request.approved" : "join_request.declined", + null, + cancellationToken); + await dbContext.SaveChangesAsync(cancellationToken); + await NotifyChatsChangedAsync(chat.Members.Select(member => member.UserId).Append(joinRequest.UserId), cancellationToken); + return Ok(ToJoinRequestDto(joinRequest)); + } + + [HttpPost("join/{token}")] + public async Task<ActionResult<ChatDetailsDto>> JoinByInviteLink(string token, CancellationToken cancellationToken) + { + var userId = User.GetRequiredUserId(); + var normalizedToken = token?.Trim() ?? string.Empty; + var invite = await dbContext.ChatInviteLinks + .Include(x => x.Chat) + .ThenInclude(x => x.Members) + .ThenInclude(x => x.User) + .SingleOrDefaultAsync(x => x.Token == normalizedToken && !x.IsRevoked, cancellationToken); + if (invite is null) + { + return NotFound(); + } + + if (invite.Chat.Type == ChatType.Direct) + { + return BadRequest("Direct chats do not support invite links."); + } + + if (invite.CreatesJoinRequest) + { + if (invite.Chat.Members.Any(x => x.UserId == userId)) + { + var existingChat = await LoadChatWithMessages(invite.ChatId, userId, cancellationToken) + ?? throw new InvalidOperationException("Joined chat could not be loaded."); + return Ok(existingChat.ToDetailsDto(userId, presenceTracker)); + } + + var existingRequest = await dbContext.ChatJoinRequests + .SingleOrDefaultAsync(x => x.InviteLinkId == invite.Id && x.UserId == userId, cancellationToken); + if (existingRequest is null) + { + existingRequest = new ChatJoinRequest + { + ChatId = invite.ChatId, + InviteLinkId = invite.Id, + UserId = userId, + RequestedAt = DateTimeOffset.UtcNow + }; + dbContext.ChatJoinRequests.Add(existingRequest); + await AddModerationLogAsync( + userId, + invite.ChatId, + ModerationLogTargetType.JoinRequest, + existingRequest.Id, + "join_request.created", + null, + cancellationToken); + await dbContext.SaveChangesAsync(cancellationToken); + } + + return Accepted(new { joinRequestId = existingRequest.Id, status = existingRequest.Status.ToString() }); + } + + if (!invite.Chat.Members.Any(x => x.UserId == userId)) + { + invite.Chat.Members.Add(CreateSubscriberMembership(invite.Chat, userId)); + invite.Chat.LastActivityAt = DateTimeOffset.UtcNow; + await AddModerationLogAsync(userId, invite.ChatId, ModerationLogTargetType.InviteLink, invite.Id, "invite_link.joined", null, cancellationToken); + await dbContext.SaveChangesAsync(cancellationToken); + var joined = await LoadChatWithMessages(invite.ChatId, userId, cancellationToken) + ?? throw new InvalidOperationException("Joined chat could not be loaded."); + await NotifyChatCreatedAsync(joined, cancellationToken); + return Ok(joined.ToDetailsDto(userId, presenceTracker)); + } + + var loaded = await LoadChatWithMessages(invite.ChatId, userId, cancellationToken) + ?? throw new InvalidOperationException("Joined chat could not be loaded."); + return Ok(loaded.ToDetailsDto(userId, presenceTracker)); + } + + [HttpPost("{chatId:guid}/subscribe")] + public async Task<ActionResult<ChatDetailsDto>> SubscribeToChannel(Guid chatId, CancellationToken cancellationToken) + { + var userId = User.GetRequiredUserId(); + var chat = await dbContext.Chats + .Include(x => x.Members) + .ThenInclude(x => x.User) + .SingleOrDefaultAsync(x => x.Id == chatId && x.Type == ChatType.Channel, cancellationToken); + if (chat is null) + { + return NotFound(); + } + + if (chat.Members.Any(member => member.UserId == userId)) + { + var existing = await LoadChatWithMessages(chatId, userId, cancellationToken) + ?? throw new InvalidOperationException("Subscribed channel could not be loaded."); + return Ok(existing.ToDetailsDto(userId, presenceTracker)); + } + + if (string.IsNullOrWhiteSpace(chat.PublicUsername)) + { + return StatusCode(StatusCodes.Status403Forbidden, "Private channels require an invite link."); + } + + chat.Members.Add(CreateSubscriberMembership(chat, userId)); + chat.LastActivityAt = DateTimeOffset.UtcNow; + await AddModerationLogAsync(userId, chatId, ModerationLogTargetType.User, userId, "channel.subscribed", null, cancellationToken); + await dbContext.SaveChangesAsync(cancellationToken); + + var loaded = await LoadChatWithMessages(chatId, userId, cancellationToken) + ?? throw new InvalidOperationException("Subscribed channel could not be loaded."); + await NotifyChatCreatedAsync(loaded, cancellationToken); + return Ok(loaded.ToDetailsDto(userId, presenceTracker)); + } + + [HttpDelete("{chatId:guid}/subscribe")] + public async Task<IActionResult> UnsubscribeFromChannel(Guid chatId, CancellationToken cancellationToken) + { + var userId = User.GetRequiredUserId(); + var chat = await dbContext.Chats + .Include(x => x.Members) + .SingleOrDefaultAsync(x => x.Id == chatId && x.Type == ChatType.Channel, cancellationToken); + if (chat is null) + { + return NotFound(); + } + + var membership = chat.Members.FirstOrDefault(member => member.UserId == userId); + if (membership is null) + { + return NoContent(); + } + + if (membership.IsOwner || membership.Role == ChatMemberRole.Owner) + { + return BadRequest("Transfer channel ownership before leaving the channel."); + } + + dbContext.ChatMembers.Remove(membership); + await AddModerationLogAsync(userId, chatId, ModerationLogTargetType.User, userId, "channel.unsubscribed", null, cancellationToken); + await dbContext.SaveChangesAsync(cancellationToken); + await NotifyChatsChangedAsync([userId], cancellationToken); + return NoContent(); + } + + [HttpGet("{chatId:guid}/topics")] + public async Task<ActionResult<IReadOnlyList<ChatTopicDto>>> GetTopics(Guid chatId, CancellationToken cancellationToken) + { + var userId = User.GetRequiredUserId(); + var chat = await LoadChatMetadata(chatId, userId, cancellationToken); + if (chat is null) + { + return NotFound(); + } + + return Ok(await LoadChatTopicDtosAsync(chatId, cancellationToken)); + } + + [HttpPost("{chatId:guid}/topics")] + public async Task<ActionResult<ChatTopicDto>> CreateTopic( + Guid chatId, + CreateChatTopicRequest request, + CancellationToken cancellationToken) + { + var userId = User.GetRequiredUserId(); + var chat = await LoadChatMetadata(chatId, userId, cancellationToken, asNoTracking: false); + if (chat is null) + { + return NotFound(); + } + + if (chat.Type == ChatType.Direct) + { + return BadRequest("Direct chats do not support topics."); + } + + if (!CanManageMembers(chat, userId)) + { + return StatusCode(StatusCodes.Status403Forbidden, "Only admins can create topics."); + } + + var title = request.Title?.Trim() ?? string.Empty; + if (title.Length is < 2 or > 80) + { + return ValidationProblem("Topic title must be between 2 and 80 characters."); + } + + var topic = new ChatTopic + { + ChatId = chatId, + CreatedById = userId, + Title = title, + Icon = string.IsNullOrWhiteSpace(request.Icon) ? "💬" : request.Icon.Trim()[..Math.Min(request.Icon.Trim().Length, 8)], + CreatedAt = DateTimeOffset.UtcNow, + LastActivityAt = DateTimeOffset.UtcNow + }; + chat.TopicsEnabled = true; + dbContext.ChatTopics.Add(topic); + await dbContext.SaveChangesAsync(cancellationToken); + return Ok(new ChatTopicDto(topic.Id, topic.ChatId, topic.Title, topic.Icon, topic.IsPinned, topic.IsClosed, topic.CreatedAt, topic.LastActivityAt, 0)); + } + + [HttpGet("{chatId:guid}/messages")] + public async Task<ActionResult<ChatMessagesPageDto>> GetMessages( + Guid chatId, + [FromQuery] Guid? beforeMessageId = null, + [FromQuery] int limit = DefaultMessagePageSize, + CancellationToken cancellationToken = default) + { + var userId = User.GetRequiredUserId(); + var chat = await LoadChatMetadata(chatId, userId, cancellationToken, allowPublicChannelPreview: true); + if (chat is null) + { + return NotFound(); + } + + var page = await LoadMessagesPageAsync(chat, beforeMessageId, limit, cancellationToken); + await MarkChannelMessagesViewedAsync(chat, userId, page.Messages, cancellationToken); + var messages = page.Messages + .Select(message => message.ToDto(presenceTracker, userId)) + .ToList(); + + return Ok(new ChatMessagesPageDto(messages, page.HasMoreMessages)); + } + + [HttpGet("{chatId:guid}/messages/search")] + public async Task<ActionResult<IReadOnlyList<MessageDto>>> SearchMessages( + Guid chatId, + [FromQuery] string q, + [FromQuery] string? filter = null, + [FromQuery] string? type = null, + [FromQuery] int page = 1, + [FromQuery] int limit = 50, + [FromQuery] DateTimeOffset? date = null, + [FromQuery] DateTimeOffset? dateFrom = null, + [FromQuery] DateTimeOffset? dateTo = null, + [FromQuery] Guid? authorId = null, + [FromQuery] string? author = null, + CancellationToken cancellationToken = default) + { + var userId = User.GetRequiredUserId(); + var chat = await LoadChatMetadata(chatId, userId, cancellationToken, allowPublicChannelPreview: true); + if (chat is null) + { + return NotFound(); + } + + var searchPage = await SearchMessagesPageAsync( + chat, + userId, + q, + BuildSearchFilters(filter, type, date, dateFrom, dateTo, authorId, author), + page, + limit, + cancellationToken); + WriteSearchPageHeaders(searchPage.Page, searchPage.Limit, searchPage.HasMore); + return Ok(searchPage.Messages); + } + + [HttpGet("{chatId:guid}/messages/search/page")] + public async Task<ActionResult<MessageSearchPageDto>> SearchMessagesPage( + Guid chatId, + [FromQuery] string q, + [FromQuery] string? filter = null, + [FromQuery] string? type = null, + [FromQuery] int page = 1, + [FromQuery] int limit = 50, + [FromQuery] DateTimeOffset? date = null, + [FromQuery] DateTimeOffset? dateFrom = null, + [FromQuery] DateTimeOffset? dateTo = null, + [FromQuery] Guid? authorId = null, + [FromQuery] string? author = null, + CancellationToken cancellationToken = default) + { + var userId = User.GetRequiredUserId(); + var chat = await LoadChatMetadata(chatId, userId, cancellationToken, allowPublicChannelPreview: true); + if (chat is null) + { + return NotFound(); + } + + return Ok(await SearchMessagesPageAsync( + chat, + userId, + q, + BuildSearchFilters(filter, type, date, dateFrom, dateTo, authorId, author), + page, + limit, + cancellationToken)); + } + + [HttpPut("{chatId:guid}/state")] + public async Task<ActionResult<ChatSummaryDto>> UpdateChatState( + Guid chatId, + ChatStateRequest request, + CancellationToken cancellationToken) + { + var userId = User.GetRequiredUserId(); + var chat = await dbContext.Chats + .Include(x => x.Members) + .ThenInclude(x => x.User) + .SingleOrDefaultAsync( + x => x.Id == chatId && x.Members.Any(member => member.UserId == userId), + cancellationToken); + + if (chat is null) + { + return NotFound(); + } + + var membership = chat.Members.First(member => member.UserId == userId); + membership.IsPinned = request.IsPinned ?? membership.IsPinned; + membership.IsArchived = request.IsArchived ?? membership.IsArchived; + membership.MutedUntil = request.IsMuted switch + { + false => null, + true => request.MutedUntil ?? DateTimeOffset.UtcNow.AddYears(1), + _ => request.MutedUntil ?? membership.MutedUntil + }; + + if (request.FolderKey is not null) + { + membership.FolderKey = NormalizeFolderKey(request.FolderKey); + } + + if (request.HasProtectedContent is not null || request.DefaultMessageTtlSeconds is not null) + { + if (!CanManageMembers(chat, userId)) + { + return StatusCode(StatusCodes.Status403Forbidden, "Only admins can update chat security settings."); + } + + chat.HasProtectedContent = request.HasProtectedContent ?? chat.HasProtectedContent; + if (request.DefaultMessageTtlSeconds is not null) + { + chat.DefaultMessageTtlSeconds = NormalizeTtlSeconds(request.DefaultMessageTtlSeconds, allowNull: true); + } + await AddModerationLogAsync( + userId, + chatId, + ModerationLogTargetType.Chat, + chatId, + "chat.security.updated", + null, + cancellationToken); + } + + if (request.ChannelSignaturesEnabled is not null || request.LinkedDiscussionChatId is not null) + { + if (chat.Type != ChatType.Channel) + { + return BadRequest("Only channels support channel settings."); + } + + if (!CanManageMembers(chat, userId)) + { + return StatusCode(StatusCodes.Status403Forbidden, "Only admins can update channel settings."); + } + + if (request.ChannelSignaturesEnabled is not null) + { + chat.ChannelSignaturesEnabled = request.ChannelSignaturesEnabled.Value; + } + + if (request.LinkedDiscussionChatId is not null) + { + var discussionChatExists = await dbContext.Chats + .AsNoTracking() + .AnyAsync(x => + x.Id == request.LinkedDiscussionChatId.Value && + x.Type == ChatType.Group && + x.Members.Any(member => member.UserId == userId), + cancellationToken); + if (!discussionChatExists) + { + return BadRequest("Linked discussion chat must be an accessible group chat."); + } + + chat.LinkedDiscussionChatId = request.LinkedDiscussionChatId; + } + + await AddModerationLogAsync( + userId, + chatId, + ModerationLogTargetType.Chat, + chatId, + "channel.settings.updated", + null, + cancellationToken); + } + + await dbContext.SaveChangesAsync(cancellationToken); + + var latestMessages = await LoadLatestMessagesByChatIdAsync([chatId], cancellationToken); + var unreadCounts = await LoadUnreadCountsByChatIdAsync(userId, [chatId], cancellationToken); + await NotifyChatsChangedAsync([userId], cancellationToken); + return Ok(chat.ToSummaryDto( + userId, + presenceTracker, + latestMessages.GetValueOrDefault(chatId), + unreadCounts.GetValueOrDefault(chatId))); + } + + [HttpPost("{chatId:guid}/members")] + public async Task<ActionResult<ChatDetailsDto>> AddMembers( + Guid chatId, + AddChatMembersRequest request, + CancellationToken cancellationToken) + { + var userId = User.GetRequiredUserId(); + var requestedMemberIds = (request.MemberIds ?? Array.Empty<Guid>()) + .Where(id => id != Guid.Empty) + .Distinct() + .ToList(); + + if (requestedMemberIds.Count == 0) + { + return BadRequest("At least one member is required."); + } + + if (request.Role is not ChatMemberRole.Member and not ChatMemberRole.Admin) + { + return BadRequest("Members can only be added as a member or admin."); + } + + var chat = await dbContext.Chats + .Include(x => x.Members) + .ThenInclude(x => x.User) + .SingleOrDefaultAsync( + x => x.Id == chatId && x.Members.Any(member => member.UserId == userId), + cancellationToken); + + if (chat is null) + { + return NotFound(); + } + + if (chat.Type != ChatType.Channel) + { + return BadRequest("Only channels support adding members with this endpoint."); + } + + if (!CanManageMembers(chat, userId)) + { + return StatusCode(StatusCodes.Status403Forbidden, "Only admins can add members."); + } + + var existingMemberIds = chat.Members + .Select(member => member.UserId) + .ToHashSet(); + var newMemberIds = requestedMemberIds + .Where(id => !existingMemberIds.Contains(id)) + .ToList(); + + if (newMemberIds.Count == 0) + { + var existing = await LoadChatWithMessages(chatId, userId, cancellationToken) + ?? throw new InvalidOperationException("The chat could not be reloaded."); + var existingPage = await LoadMessagesPageAsync(existing, beforeMessageId: null, DefaultMessagePageSize, cancellationToken); + var existingPinnedMessages = await LoadPinnedMessagesAsync(existing, cancellationToken); + return Ok(existing.ToDetailsDto( + userId, + presenceTracker, + messagesOverride: existingPage.Messages, + hasMoreMessages: existingPage.HasMoreMessages, + pinnedMessagesOverride: existingPinnedMessages)); + } + + var users = await dbContext.Users + .Where(user => newMemberIds.Contains(user.Id) && !user.IsBlocked) + .Select(user => user.Id) + .ToListAsync(cancellationToken); + + if (users.Count != newMemberIds.Count) + { + return BadRequest("Some members do not exist."); + } + + var role = request.Role; + var isAdmin = role == ChatMemberRole.Admin; + var joinedAt = DateTimeOffset.UtcNow; + + foreach (var memberId in newMemberIds) + { + chat.Members.Add(new ChatMember + { + ChatId = chat.Id, + Chat = chat, + UserId = memberId, + Role = role, + IsOwner = false, + CanPinMessages = isAdmin || request.CanPinMessages, + CanDeleteMessages = isAdmin || request.CanDeleteMessages, + CanInviteUsers = isAdmin || request.CanInviteUsers, + CanPostMessages = isAdmin || request.CanPostMessages, + JoinedAt = joinedAt + }); + + await AddModerationLogAsync( + userId, + chatId, + ModerationLogTargetType.User, + memberId, + isAdmin ? "member.admin.added" : "member.added", + null, + cancellationToken); + } + + chat.LastActivityAt = DateTimeOffset.UtcNow; + await dbContext.SaveChangesAsync(cancellationToken); + await NotifyChatsChangedAsync(chat.Members.Select(member => member.UserId).Concat(newMemberIds), cancellationToken); + + var loaded = await LoadChatWithMessages(chatId, userId, cancellationToken) + ?? throw new InvalidOperationException("The chat could not be reloaded."); + var page = await LoadMessagesPageAsync(loaded, beforeMessageId: null, DefaultMessagePageSize, cancellationToken); + var pinnedMessages = await LoadPinnedMessagesAsync(loaded, cancellationToken); + return Ok(loaded.ToDetailsDto( + userId, + presenceTracker, + messagesOverride: page.Messages, + hasMoreMessages: page.HasMoreMessages, + pinnedMessagesOverride: pinnedMessages)); + } + + [HttpPut("{chatId:guid}/members/{memberUserId:guid}/permissions")] + public async Task<ActionResult<ChatDetailsDto>> UpdateMemberPermissions( + Guid chatId, + Guid memberUserId, + UpdateChatMemberPermissionsRequest request, + CancellationToken cancellationToken) + { + var userId = User.GetRequiredUserId(); + var chat = await dbContext.Chats + .Include(x => x.Members) + .ThenInclude(x => x.User) + .SingleOrDefaultAsync( + x => x.Id == chatId && + x.Members.Any(member => member.UserId == userId) && + x.Members.Any(member => member.UserId == memberUserId), + cancellationToken); + + if (chat is null) + { + return NotFound(); + } + + if (!CanManageMembers(chat, userId)) + { + return StatusCode(StatusCodes.Status403Forbidden, "Only admins can update member permissions."); + } + + var target = chat.Members.First(member => member.UserId == memberUserId); + if (target.IsOwner && target.UserId != userId) + { + return BadRequest("Owner permissions cannot be changed by another member."); + } + + target.Role = request.Role; + target.IsOwner = request.Role == ChatMemberRole.Owner; + target.CanPinMessages = request.CanPinMessages; + target.CanDeleteMessages = request.CanDeleteMessages; + target.CanInviteUsers = request.CanInviteUsers; + target.CanPostMessages = request.CanPostMessages; + + await AddModerationLogAsync(userId, chatId, ModerationLogTargetType.User, memberUserId, "member.permissions.updated", null, cancellationToken); + await dbContext.SaveChangesAsync(cancellationToken); + await NotifyChatsChangedAsync(chat.Members.Select(member => member.UserId), cancellationToken); + + var loaded = await LoadChatWithMessages(chatId, userId, cancellationToken) + ?? throw new InvalidOperationException("The chat could not be reloaded."); + var page = await LoadMessagesPageAsync(loaded, beforeMessageId: null, DefaultMessagePageSize, cancellationToken); + var pinnedMessages = await LoadPinnedMessagesAsync(loaded, cancellationToken); + return Ok(loaded.ToDetailsDto(userId, presenceTracker, messagesOverride: page.Messages, hasMoreMessages: page.HasMoreMessages, pinnedMessagesOverride: pinnedMessages)); + } + + [HttpDelete("{chatId:guid}")] + public async Task<IActionResult> DeleteChat( + Guid chatId, + [FromQuery] bool forEveryone = false, + CancellationToken cancellationToken = default) + { + var userId = User.GetRequiredUserId(); + var chat = await dbContext.Chats + .Include(x => x.Members) + .ThenInclude(x => x.User) + .Include(x => x.Messages) + .ThenInclude(x => x.Attachments) + .SingleOrDefaultAsync(x => x.Id == chatId && x.Members.Any(member => member.UserId == userId), cancellationToken); + + if (chat is null) + { + return NotFound(); + } + + var affectedUserIds = chat.Members + .Select(x => x.UserId) + .ToHashSet(); + + if (forEveryone) + { + var currentMembership = chat.Members.First(x => x.UserId == userId); + if (chat.Type != ChatType.Direct && !currentMembership.IsOwner) + { + return StatusCode(StatusCodes.Status403Forbidden, "Only chat owners can delete this chat for everyone."); + } + + var attachmentPaths = chat.Messages + .SelectMany(x => x.Attachments) + .Select(x => x.StoragePath) + .Where(x => !string.IsNullOrWhiteSpace(x)) + .Distinct() + .ToList(); + + dbContext.Chats.Remove(chat); + await dbContext.SaveChangesAsync(cancellationToken); + + DeleteFiles(attachmentPaths); + await NotifyChatsChangedAsync(affectedUserIds, cancellationToken); + return NoContent(); + } + + var membership = chat.Members.FirstOrDefault(x => x.UserId == userId); + if (membership is null) + { + return NotFound(); + } + + dbContext.ChatMembers.Remove(membership); + chat.Members.Remove(membership); + + if (chat.Members.Count == 0) + { + var attachmentPaths = chat.Messages + .SelectMany(x => x.Attachments) + .Select(x => x.StoragePath) + .Where(x => !string.IsNullOrWhiteSpace(x)) + .Distinct() + .ToList(); + + dbContext.Chats.Remove(chat); + await dbContext.SaveChangesAsync(cancellationToken); + + DeleteFiles(attachmentPaths); + await NotifyChatsChangedAsync(affectedUserIds, cancellationToken); + return NoContent(); + } + + if (chat.Type == ChatType.Direct && chat.Members.Count == 1) + { + chat.Title = string.IsNullOrWhiteSpace(membership.User.DisplayName) + ? membership.User.PhoneNumber ?? "Dialog" + : membership.User.DisplayName; + } + + if (chat.CreatedById == userId) + { + var replacementOwner = chat.Members + .OrderByDescending(x => x.IsOwner) + .ThenBy(x => x.JoinedAt) + .First(); + + replacementOwner.IsOwner = true; + replacementOwner.Role = ChatMemberRole.Owner; + replacementOwner.CanDeleteMessages = true; + replacementOwner.CanPinMessages = true; + replacementOwner.CanInviteUsers = true; + replacementOwner.CanPostMessages = true; + chat.CreatedById = replacementOwner.UserId; + } + + if (chat.Type == ChatType.Channel && chat.Members.All(x => !x.IsOwner)) + { + var replacementChannelOwner = chat.Members + .OrderBy(x => x.JoinedAt) + .First(); + replacementChannelOwner.IsOwner = true; + replacementChannelOwner.Role = ChatMemberRole.Owner; + replacementChannelOwner.CanDeleteMessages = true; + replacementChannelOwner.CanPinMessages = true; + replacementChannelOwner.CanInviteUsers = true; + replacementChannelOwner.CanPostMessages = true; + } + + chat.LastActivityAt = chat.Messages + .OrderByDescending(x => x.SentAt) + .Select(x => (DateTimeOffset?)x.SentAt) + .FirstOrDefault() ?? chat.CreatedAt; + + await dbContext.SaveChangesAsync(cancellationToken); + await NotifyChatsChangedAsync(affectedUserIds, cancellationToken); + return NoContent(); + } + + [HttpDelete("{chatId:guid}/history")] + public async Task<IActionResult> ClearChatHistory(Guid chatId, CancellationToken cancellationToken = default) + { + var userId = User.GetRequiredUserId(); + var chat = await dbContext.Chats + .Include(x => x.Members) + .ThenInclude(x => x.User) + .Include(x => x.Messages) + .ThenInclude(x => x.Attachments) + .SingleOrDefaultAsync(x => x.Id == chatId && x.Members.Any(member => member.UserId == userId), cancellationToken); + + if (chat is null) + { + return NotFound(); + } + + var hasBotPeer = chat.Type == ChatType.Direct && + chat.Members.Any(member => member.UserId != userId && member.User.IsBot); + if (!hasBotPeer) + { + return StatusCode( + StatusCodes.Status403Forbidden, + "Chat history can only be cleared in direct chats with bots."); + } + + var attachmentPaths = chat.Messages + .SelectMany(x => x.Attachments) + .Select(x => x.StoragePath) + .Where(x => !string.IsNullOrWhiteSpace(x)) + .Distinct() + .ToList(); + + var attachments = chat.Messages + .SelectMany(x => x.Attachments) + .ToList(); + var messages = chat.Messages.ToList(); + + dbContext.MessageAttachments.RemoveRange(attachments); + dbContext.Messages.RemoveRange(messages); + chat.LastActivityAt = chat.CreatedAt; + + await dbContext.SaveChangesAsync(cancellationToken); + + DeleteFiles(attachmentPaths); + await NotifyChatsChangedAsync(chat.Members.Select(x => x.UserId), cancellationToken); + return NoContent(); + } + + [HttpPost("direct")] + public async Task<ActionResult<ChatDetailsDto>> CreateDirectChat(CreateDirectChatRequest request, CancellationToken cancellationToken) + { + var currentUserId = User.GetRequiredUserId(); + if (request.UserId == currentUserId) + { + return BadRequest("You cannot create a direct chat with yourself."); + } + + var otherUserExists = await dbContext.Users.AnyAsync(x => x.Id == request.UserId && !x.IsBlocked, cancellationToken); + if (!otherUserExists) + { + return NotFound("User not found."); + } + + if (await IsBlockedByPrivacyAsync(currentUserId, request.UserId, cancellationToken) || + await IsBlockedByPrivacyAsync(request.UserId, currentUserId, cancellationToken)) + { + return StatusCode(StatusCodes.Status403Forbidden, "Direct chats are blocked by privacy settings."); + } + + var existingChat = await dbContext.Chats + .Include(x => x.Members) + .ThenInclude(x => x.User) + .Include(x => x.Messages) + .ThenInclude(x => x.Sender) + .Include(x => x.Messages) + .ThenInclude(x => x.Attachments) + .AsSplitQuery() + .SingleOrDefaultAsync(x => + x.Type == ChatType.Direct && + x.Members.Count == 2 && + x.Members.Any(m => m.UserId == currentUserId) && + x.Members.Any(m => m.UserId == request.UserId), + cancellationToken); + + if (existingChat is not null) + { + return Ok(existingChat.ToDetailsDto(currentUserId, presenceTracker)); + } + + var chat = new Chat + { + Type = ChatType.Direct, + CreatedById = currentUserId, + LastActivityAt = DateTimeOffset.UtcNow, + Members = + [ + new ChatMember { UserId = currentUserId, IsOwner = true, Role = ChatMemberRole.Owner, CanDeleteMessages = true }, + new ChatMember { UserId = request.UserId, IsOwner = false } + ] + }; + + dbContext.Chats.Add(chat); + await dbContext.SaveChangesAsync(cancellationToken); + + var loaded = await LoadChatWithMessages(chat.Id, currentUserId, cancellationToken) + ?? throw new InvalidOperationException("Newly created chat could not be loaded."); + + await NotifyChatCreatedAsync(loaded, cancellationToken); + return Ok(loaded.ToDetailsDto(currentUserId, presenceTracker)); + } + + [HttpPost("group")] + public async Task<ActionResult<ChatDetailsDto>> CreateGroupChat(CreateGroupChatRequest request, CancellationToken cancellationToken) + { + var currentUserId = User.GetRequiredUserId(); + var title = request.Title?.Trim() ?? string.Empty; + var memberIds = (request.MemberIds ?? Array.Empty<Guid>()) + .Append(currentUserId) + .Distinct() + .ToList(); + + if (string.IsNullOrWhiteSpace(title) || memberIds.Count < 3) + { + return BadRequest("A title and at least 2 invited members are required for a group chat."); + } + + var users = await dbContext.Users + .Where(x => memberIds.Contains(x.Id) && !x.IsBlocked) + .Select(x => x.Id) + .ToListAsync(cancellationToken); + + if (users.Count != memberIds.Count) + { + return BadRequest("Some group members do not exist."); + } + + var chat = new Chat + { + Type = ChatType.Group, + Title = title, + CreatedById = currentUserId, + LastActivityAt = DateTimeOffset.UtcNow, + Members = memberIds + .Select(x => new ChatMember + { + UserId = x, + IsOwner = x == currentUserId, + Role = x == currentUserId ? ChatMemberRole.Owner : ChatMemberRole.Member, + CanDeleteMessages = x == currentUserId + }) + .ToList() + }; + + dbContext.Chats.Add(chat); + await dbContext.SaveChangesAsync(cancellationToken); + + var loaded = await LoadChatWithMessages(chat.Id, currentUserId, cancellationToken) + ?? throw new InvalidOperationException("Newly created group chat could not be loaded."); + + await NotifyChatCreatedAsync(loaded, cancellationToken); + return Ok(loaded.ToDetailsDto(currentUserId, presenceTracker)); + } + + [HttpPost("channel")] + public async Task<ActionResult<ChatDetailsDto>> CreateChannel(CreateChannelRequest request, CancellationToken cancellationToken) + { + var currentUserId = User.GetRequiredUserId(); + var title = request.Title?.Trim() ?? string.Empty; + var description = TrimToNull(request.Description, 255); + var memberIds = (request.MemberIds ?? Array.Empty<Guid>()) + .Append(currentUserId) + .Distinct() + .ToList(); + string? publicUsername; + + if (string.IsNullOrWhiteSpace(title)) + { + return BadRequest("Channel title is required."); + } + + try + { + publicUsername = NormalizePublicUsername(request.PublicUsername); + } + catch (InvalidOperationException ex) + { + return BadRequest(ex.Message); + } + + if (publicUsername is not null) + { + var usernameTaken = await dbContext.Chats + .AnyAsync(x => x.PublicUsername == publicUsername, cancellationToken); + if (usernameTaken) + { + return Conflict("Public username is already taken."); + } + } + + var users = await dbContext.Users + .Where(x => memberIds.Contains(x.Id) && !x.IsBlocked) + .Select(x => x.Id) + .ToListAsync(cancellationToken); + + if (users.Count != memberIds.Count) + { + return BadRequest("Some channel members do not exist."); + } + + var chat = new Chat + { + Type = ChatType.Channel, + Title = title, + Description = description, + PublicUsername = publicUsername, + ChannelSignaturesEnabled = request.ChannelSignaturesEnabled, + CreatedById = currentUserId, + LastActivityAt = DateTimeOffset.UtcNow, + Members = memberIds + .Select(x => new ChatMember + { + UserId = x, + IsOwner = x == currentUserId, + Role = x == currentUserId ? ChatMemberRole.Owner : ChatMemberRole.Member, + CanPinMessages = x == currentUserId, + CanDeleteMessages = x == currentUserId, + CanInviteUsers = x == currentUserId, + CanPostMessages = x == currentUserId + }) + .ToList() + }; + + dbContext.Chats.Add(chat); + if (publicUsername is null && request.CreateInviteLink) + { + dbContext.ChatInviteLinks.Add(new ChatInviteLink + { + ChatId = chat.Id, + CreatedById = currentUserId, + Token = await GenerateInviteTokenAsync(cancellationToken), + Title = "Основная ссылка", + CreatedAt = DateTimeOffset.UtcNow + }); + } + + await dbContext.SaveChangesAsync(cancellationToken); + + var loaded = await LoadChatWithMessages(chat.Id, currentUserId, cancellationToken) + ?? throw new InvalidOperationException("Newly created channel could not be loaded."); + + await NotifyChatCreatedAsync(loaded, cancellationToken); + return Ok(loaded.ToDetailsDto(currentUserId, presenceTracker)); + } + + [HttpPost("{chatId:guid}/messages")] + public async Task<ActionResult<MessageDto>> SendMessage(Guid chatId, SendMessageRequest request, CancellationToken cancellationToken) + { + var userId = User.GetRequiredUserId(); + var text = request.Text?.Trim() ?? string.Empty; + if (string.IsNullOrWhiteSpace(text)) + { + return BadRequest("Message text is required."); + } + + var chat = await dbContext.Chats + .Include(x => x.Members) + .FirstOrDefaultAsync(x => x.Id == chatId && x.Members.Any(m => m.UserId == userId), cancellationToken); + + if (chat is null) + { + return NotFound(); + } + + if (!CanUserPublish(chat, userId)) + { + return StatusCode(StatusCodes.Status403Forbidden, "Only channel owners can publish."); + } + + var replyToMessage = await ResolveReplyTargetAsync(chatId, request.ReplyToMessageId, userId, cancellationToken); + if (request.ReplyToMessageId is not null && replyToMessage is null) + { + return NotFound("The replied message was not found."); + } + + if (request.TopicId is not null) + { + var topic = await dbContext.ChatTopics + .SingleOrDefaultAsync(x => x.Id == request.TopicId.Value && x.ChatId == chatId, cancellationToken); + if (topic is null || topic.IsClosed) + { + return NotFound("The topic was not found."); + } + topic.LastActivityAt = DateTimeOffset.UtcNow; + } + + var sender = await dbContext.Users.SingleAsync(x => x.Id == userId, cancellationToken); + var ttlSeconds = NormalizeTtlSeconds(request.TtlSeconds ?? chat.DefaultMessageTtlSeconds, allowNull: true); + var sentAt = DateTimeOffset.UtcNow; + var message = new Message + { + ChatId = chatId, + Chat = chat, + SenderId = userId, + Sender = sender, + PostedAsChannel = ShouldPostAsChannel(chat), + AuthorSignature = ResolveChannelAuthorSignature(chat, sender), + Text = text, + ReplyToMessageId = replyToMessage?.Id, + ReplyToMessage = replyToMessage, + TopicId = request.TopicId, + HasProtectedContent = request.HasProtectedContent ?? chat.HasProtectedContent, + TtlSeconds = ttlSeconds, + ExpiresAt = ttlSeconds is null ? null : sentAt.AddSeconds(ttlSeconds.Value), + SentAt = sentAt + }; + + chat.LastActivityAt = message.SentAt; + dbContext.Messages.Add(message); + await dbContext.SaveChangesAsync(cancellationToken); + + var loadedMessage = await LoadMessageForMemberAsync(chatId, message.Id, userId, cancellationToken) + ?? throw new InvalidOperationException("The created message could not be reloaded."); + + var dto = loadedMessage.ToDto(presenceTracker, userId); + await NotifyMessageCreatedAsync(loadedMessage.Chat, loadedMessage, cancellationToken); + await botUpdateService.EnqueueIncomingMessageAsync(loadedMessage.Id, cancellationToken); + await pushDispatchQueue.EnqueueAsync(chat.Id, message.Id); + + return Ok(dto); + } + + [HttpPost("{chatId:guid}/messages/{messageId:guid}/forward")] + public async Task<ActionResult<MessageDto>> ForwardMessage( + Guid chatId, + Guid messageId, + ForwardMessageRequest request, + CancellationToken cancellationToken) + { + var userId = User.GetRequiredUserId(); + var sourceMessage = await LoadMessageForMemberAsync(chatId, messageId, userId, cancellationToken); + if (sourceMessage is null) + { + return NotFound(); + } + + if (sourceMessage.DeletedAt is not null) + { + return BadRequest("Deleted messages cannot be forwarded."); + } + + if (sourceMessage.HasProtectedContent || sourceMessage.Chat.HasProtectedContent) + { + return BadRequest("Protected messages cannot be forwarded."); + } + + var targetChat = await dbContext.Chats + .Include(x => x.Members) + .FirstOrDefaultAsync( + x => x.Id == request.TargetChatId && + x.Members.Any(member => member.UserId == userId), + cancellationToken); + if (targetChat is null) + { + return NotFound(); + } + + if (!CanUserPublish(targetChat, userId)) + { + return StatusCode(StatusCodes.Status403Forbidden, "Only channel owners can publish."); + } + + var sender = await dbContext.Users.SingleAsync(x => x.Id == userId, cancellationToken); + var ttlSeconds = NormalizeTtlSeconds(targetChat.DefaultMessageTtlSeconds, allowNull: true); + var sentAt = DateTimeOffset.UtcNow; + var forwardedMessage = new Message + { + ChatId = targetChat.Id, + Chat = targetChat, + SenderId = userId, + Sender = sender, + PostedAsChannel = ShouldPostAsChannel(targetChat), + AuthorSignature = ResolveChannelAuthorSignature(targetChat, sender), + Text = sourceMessage.Text, + ForwardedFromDisplayName = ResolveForwardedFromDisplayName(sourceMessage), + MediaAlbumId = sourceMessage.MediaAlbumId is null ? null : Guid.NewGuid(), + HasProtectedContent = targetChat.HasProtectedContent, + TtlSeconds = ttlSeconds, + ExpiresAt = ttlSeconds is null ? null : sentAt.AddSeconds(ttlSeconds.Value), + SentAt = sentAt + }; + + var clonedAttachments = new List<MessageAttachment>(); + try + { + foreach (var sourceAttachment in sourceMessage.Attachments) + { + var clonedAttachment = await attachmentStorageService.CloneAsync( + forwardedMessage.Id, + sourceAttachment, + cancellationToken); + clonedAttachments.Add(clonedAttachment); + forwardedMessage.Attachments.Add(clonedAttachment); + } + + targetChat.LastActivityAt = forwardedMessage.SentAt; + dbContext.Messages.Add(forwardedMessage); + await dbContext.SaveChangesAsync(cancellationToken); + } + catch + { + foreach (var clonedAttachment in clonedAttachments) + { + attachmentStorageService.DeleteIfExists(clonedAttachment.StoragePath); + } + + throw; + } + + var loadedForwardedMessage = await LoadMessageForMemberAsync(targetChat.Id, forwardedMessage.Id, userId, cancellationToken) + ?? throw new InvalidOperationException("The forwarded message could not be reloaded."); + + var dto = loadedForwardedMessage.ToDto(presenceTracker, userId); + await NotifyMessageCreatedAsync(loadedForwardedMessage.Chat, loadedForwardedMessage, cancellationToken); + await botUpdateService.EnqueueIncomingMessageAsync(loadedForwardedMessage.Id, cancellationToken); + await pushDispatchQueue.EnqueueAsync(targetChat.Id, forwardedMessage.Id); + return Ok(dto); + } + + [HttpPost("{chatId:guid}/messages/delivered")] + public Task<IActionResult> AcknowledgeDelivered( + Guid chatId, + UpdateMessageReceiptRequest request, + CancellationToken cancellationToken) => + AcknowledgeMessageReceiptAsync(chatId, request.MessageId, User.GetRequiredUserId(), markRead: false, cancellationToken); + + [HttpPost("{chatId:guid}/messages/read")] + public Task<IActionResult> AcknowledgeRead( + Guid chatId, + UpdateMessageReceiptRequest request, + CancellationToken cancellationToken) => + AcknowledgeMessageReceiptAsync(chatId, request.MessageId, User.GetRequiredUserId(), markRead: true, cancellationToken); + + [HttpPut("{chatId:guid}/messages/{messageId:guid}/reaction")] + public async Task<ActionResult<MessageDto>> SetReaction( + Guid chatId, + Guid messageId, + SetMessageReactionRequest request, + CancellationToken cancellationToken) + { + var userId = User.GetRequiredUserId(); + var emoji = request.Emoji?.Trim() ?? string.Empty; + if (string.IsNullOrWhiteSpace(emoji)) + { + return BadRequest("Reaction emoji is required."); + } + + if (emoji.Length > 24) + { + return BadRequest("Reaction emoji is too long."); + } + + var message = await LoadMessageForMemberAsync(chatId, messageId, userId, cancellationToken); + if (message is null) + { + return NotFound(); + } + + if (message.DeletedAt is not null) + { + return BadRequest("Deleted messages cannot be reacted to."); + } + + var shouldNotifyReaction = false; + var existingReaction = message.Reactions.FirstOrDefault(reaction => reaction.UserId == userId); + if (existingReaction is not null && string.Equals(existingReaction.Emoji, emoji, StringComparison.Ordinal)) + { + dbContext.MessageReactions.Remove(existingReaction); + } + else if (existingReaction is not null) + { + existingReaction.Emoji = emoji; + existingReaction.CreatedAt = DateTimeOffset.UtcNow; + shouldNotifyReaction = message.SenderId != userId; + } + else + { + dbContext.MessageReactions.Add(new MessageReaction + { + MessageId = message.Id, + UserId = userId, + Emoji = emoji, + CreatedAt = DateTimeOffset.UtcNow + }); + shouldNotifyReaction = message.SenderId != userId; + } + + await dbContext.SaveChangesAsync(cancellationToken); + + var updatedMessage = await LoadMessageForMemberAsync(chatId, messageId, userId, cancellationToken) + ?? throw new InvalidOperationException("The reacted message could not be reloaded."); + + var dto = updatedMessage.ToDto(presenceTracker, userId); + await NotifyMessageUpdatedAsync(updatedMessage.Chat, [updatedMessage], cancellationToken); + if (shouldNotifyReaction) + { + await pushDispatchQueue.EnqueueReactionAsync( + chatId, + messageId, + userId, + emoji, + cancellationToken); + } + + return Ok(dto); + } + + [HttpPut("{chatId:guid}/messages/{messageId:guid}/pin")] + public Task<ActionResult<MessageDto>> PinMessage(Guid chatId, Guid messageId, CancellationToken cancellationToken) => + SetMessagePinAsync(chatId, messageId, isPinned: true, cancellationToken); + + [HttpDelete("{chatId:guid}/messages/{messageId:guid}/pin")] + public Task<ActionResult<MessageDto>> UnpinMessage(Guid chatId, Guid messageId, CancellationToken cancellationToken) => + SetMessagePinAsync(chatId, messageId, isPinned: false, cancellationToken); + + [HttpPost("{chatId:guid}/attachments")] + [RequestFormLimits(MultipartBodyLengthLimit = MaxAttachmentSizeBytes)] + [RequestSizeLimit(MaxAttachmentSizeBytes)] + public async Task<ActionResult<MessageDto>> UploadAttachment( + Guid chatId, + [FromForm] UploadAttachmentRequest request, + CancellationToken cancellationToken) + { + var userId = User.GetRequiredUserId(); + if (request.File is null) + { + return BadRequest("File is required."); + } + + if (request.File.Length <= 0) + { + return BadRequest("File is empty."); + } + + if (request.File.Length > MaxAttachmentSizeBytes) + { + return BadRequest($"File exceeds {MaxAttachmentSizeBytes} bytes."); + } + + var chat = await dbContext.Chats + .Include(x => x.Members) + .FirstOrDefaultAsync(x => x.Id == chatId && x.Members.Any(m => m.UserId == userId), cancellationToken); + + if (chat is null) + { + return NotFound(); + } + + if (!CanUserPublish(chat, userId)) + { + return StatusCode(StatusCodes.Status403Forbidden, "Only channel owners can publish."); + } + + var replyToMessage = await ResolveReplyTargetAsync(chatId, request.ReplyToMessageId, userId, cancellationToken); + if (request.ReplyToMessageId is not null && replyToMessage is null) + { + return NotFound("The replied message was not found."); + } + + if (request.TopicId is not null) + { + var topic = await dbContext.ChatTopics + .SingleOrDefaultAsync(x => x.Id == request.TopicId.Value && x.ChatId == chatId, cancellationToken); + if (topic is null || topic.IsClosed) + { + return NotFound("The topic was not found."); + } + topic.LastActivityAt = DateTimeOffset.UtcNow; + } + + var sender = await dbContext.Users.SingleAsync(x => x.Id == userId, cancellationToken); + var text = request.Text?.Trim() ?? string.Empty; + var ttlSeconds = NormalizeTtlSeconds(request.TtlSeconds ?? chat.DefaultMessageTtlSeconds, allowNull: true); + var sentAt = DateTimeOffset.UtcNow; + var message = new Message + { + ChatId = chatId, + Chat = chat, + SenderId = userId, + Sender = sender, + PostedAsChannel = ShouldPostAsChannel(chat), + AuthorSignature = ResolveChannelAuthorSignature(chat, sender), + Text = text, + ReplyToMessageId = replyToMessage?.Id, + ReplyToMessage = replyToMessage, + TopicId = request.TopicId, + HasProtectedContent = request.HasProtectedContent ?? chat.HasProtectedContent, + TtlSeconds = ttlSeconds, + ExpiresAt = ttlSeconds is null ? null : sentAt.AddSeconds(ttlSeconds.Value), + SentAt = sentAt + }; + + MessageAttachment? attachment = null; + try + { + attachment = await attachmentStorageService.SaveAsync(message.Id, request.File, cancellationToken); + message.Attachments.Add(attachment); + chat.LastActivityAt = message.SentAt; + dbContext.Messages.Add(message); + await dbContext.SaveChangesAsync(cancellationToken); + } + catch + { + if (attachment is not null) + { + attachmentStorageService.DeleteIfExists(attachment.StoragePath); + } + + throw; + } + + var loadedMessage = await LoadMessageForMemberAsync(chatId, message.Id, userId, cancellationToken) + ?? throw new InvalidOperationException("The uploaded message could not be reloaded."); + + var dto = loadedMessage.ToDto(presenceTracker, userId); + await NotifyMessageCreatedAsync(loadedMessage.Chat, loadedMessage, cancellationToken); + await botUpdateService.EnqueueIncomingMessageAsync(loadedMessage.Id, cancellationToken); + await pushDispatchQueue.EnqueueAsync(chat.Id, message.Id); + + return Ok(dto); + } + + [HttpPost("{chatId:guid}/attachments/album")] + [RequestFormLimits(MultipartBodyLengthLimit = MaxAlbumSizeBytes)] + [RequestSizeLimit(MaxAlbumSizeBytes)] + public async Task<ActionResult<MessageDto>> UploadAttachmentAlbum( + Guid chatId, + [FromForm] UploadAttachmentAlbumRequest request, + CancellationToken cancellationToken) + { + var userId = User.GetRequiredUserId(); + var files = request.Files + .Where(file => file is not null) + .ToList(); + + if (files.Count == 0) + { + return BadRequest("At least one file is required."); + } + + if (files.Count > MaxAlbumAttachments) + { + return BadRequest($"Albums can contain up to {MaxAlbumAttachments} files."); + } + + if (files.Any(file => file.Length <= 0)) + { + return BadRequest("Album contains an empty file."); + } + + if (files.Any(file => file.Length > MaxAttachmentSizeBytes) || files.Sum(file => file.Length) > MaxAlbumSizeBytes) + { + return BadRequest($"Album exceeds {MaxAlbumSizeBytes} bytes or contains a file over {MaxAttachmentSizeBytes} bytes."); + } + + var chat = await dbContext.Chats + .Include(x => x.Members) + .FirstOrDefaultAsync(x => x.Id == chatId && x.Members.Any(m => m.UserId == userId), cancellationToken); + + if (chat is null) + { + return NotFound(); + } + + if (!CanUserPublish(chat, userId)) + { + return StatusCode(StatusCodes.Status403Forbidden, "You cannot publish in this chat."); + } + + var replyToMessage = await ResolveReplyTargetAsync(chatId, request.ReplyToMessageId, userId, cancellationToken); + if (request.ReplyToMessageId is not null && replyToMessage is null) + { + return NotFound("The replied message was not found."); + } + + if (request.TopicId is not null) + { + var topic = await dbContext.ChatTopics + .SingleOrDefaultAsync(x => x.Id == request.TopicId.Value && x.ChatId == chatId, cancellationToken); + if (topic is null || topic.IsClosed) + { + return NotFound("The topic was not found."); + } + topic.LastActivityAt = DateTimeOffset.UtcNow; + } + + var sender = await dbContext.Users.SingleAsync(x => x.Id == userId, cancellationToken); + var ttlSeconds = NormalizeTtlSeconds(request.TtlSeconds ?? chat.DefaultMessageTtlSeconds, allowNull: true); + var sentAt = DateTimeOffset.UtcNow; + var message = new Message + { + ChatId = chatId, + Chat = chat, + SenderId = userId, + Sender = sender, + PostedAsChannel = ShouldPostAsChannel(chat), + AuthorSignature = ResolveChannelAuthorSignature(chat, sender), + Text = request.Text?.Trim() ?? string.Empty, + ReplyToMessageId = replyToMessage?.Id, + ReplyToMessage = replyToMessage, + TopicId = request.TopicId, + MediaAlbumId = Guid.NewGuid(), + HasProtectedContent = request.HasProtectedContent ?? chat.HasProtectedContent, + TtlSeconds = ttlSeconds, + ExpiresAt = ttlSeconds is null ? null : sentAt.AddSeconds(ttlSeconds.Value), + SentAt = sentAt + }; + + var savedAttachments = new List<MessageAttachment>(); + try + { + for (var index = 0; index < files.Count; index++) + { + var attachment = await attachmentStorageService.SaveAsync(message.Id, files[index], cancellationToken, sortOrder: index); + savedAttachments.Add(attachment); + message.Attachments.Add(attachment); + } + + chat.LastActivityAt = message.SentAt; + dbContext.Messages.Add(message); + await dbContext.SaveChangesAsync(cancellationToken); + } + catch + { + foreach (var attachment in savedAttachments) + { + attachmentStorageService.DeleteIfExists(attachment.StoragePath); + } + + throw; + } + + var loadedMessage = await LoadMessageForMemberAsync(chatId, message.Id, userId, cancellationToken) + ?? throw new InvalidOperationException("The uploaded album could not be reloaded."); + + var dto = loadedMessage.ToDto(presenceTracker, userId); + await NotifyMessageCreatedAsync(loadedMessage.Chat, loadedMessage, cancellationToken); + await botUpdateService.EnqueueIncomingMessageAsync(loadedMessage.Id, cancellationToken); + await pushDispatchQueue.EnqueueAsync(chat.Id, message.Id); + + return Ok(dto); + } + + [HttpPut("{chatId:guid}/messages/{messageId:guid}")] + public async Task<ActionResult<MessageDto>> UpdateMessage( + Guid chatId, + Guid messageId, + UpdateMessageRequest request, + CancellationToken cancellationToken) + { + var userId = User.GetRequiredUserId(); + var message = await LoadMessageForMemberAsync(chatId, messageId, userId, cancellationToken); + if (message is null) + { + return NotFound(); + } + + if (message.SenderId != userId) + { + return StatusCode(StatusCodes.Status403Forbidden, "Only the message author can edit it."); + } + + if (message.DeletedAt is not null) + { + return BadRequest("Deleted messages cannot be edited."); + } + + var text = request.Text?.Trim() ?? string.Empty; + if (string.IsNullOrWhiteSpace(text) && message.Attachments.Count == 0) + { + return BadRequest("Message text is required."); + } + + message.Text = text; + message.EditedAt = DateTimeOffset.UtcNow; + await dbContext.SaveChangesAsync(cancellationToken); + + var updatedMessage = await LoadMessageForMemberAsync(chatId, messageId, userId, cancellationToken) + ?? throw new InvalidOperationException("The updated message could not be reloaded."); + + var dto = updatedMessage.ToDto(presenceTracker, userId); + await NotifyMessageUpdatedAsync(updatedMessage.Chat, [updatedMessage], cancellationToken); + return Ok(dto); + } + + [HttpDelete("{chatId:guid}/messages/{messageId:guid}")] + public async Task<ActionResult<MessageDto>> DeleteMessage(Guid chatId, Guid messageId, CancellationToken cancellationToken) + { + var userId = User.GetRequiredUserId(); + var message = await LoadMessageForMemberAsync(chatId, messageId, userId, cancellationToken); + if (message is null) + { + return NotFound(); + } + + if (message.DeletedAt is not null) + { + return Ok(message.ToDto(presenceTracker, userId)); + } + + if (message.SenderId != userId && !ResolvePermissions(message.Chat, userId).CanDeleteMessages) + { + return StatusCode(StatusCodes.Status403Forbidden, "You cannot delete this message."); + } + + var attachments = message.Attachments.ToList(); + foreach (var attachment in attachments) + { + attachmentStorageService.DeleteIfExists(attachment.StoragePath); + } + + if (message.Reactions.Count > 0) + { + dbContext.MessageReactions.RemoveRange(message.Reactions); + message.Reactions.Clear(); + } + + dbContext.MessageAttachments.RemoveRange(attachments); + message.Attachments.Clear(); + message.Text = string.Empty; + message.EditedAt = null; + message.DeletedAt = DateTimeOffset.UtcNow; + message.PinnedAt = null; + message.PinnedByUserId = null; + message.PinnedByUser = null; + await AddModerationLogAsync(userId, chatId, ModerationLogTargetType.Message, messageId, "message.deleted", null, cancellationToken); + await dbContext.SaveChangesAsync(cancellationToken); + + var updatedMessage = await LoadMessageForMemberAsync(chatId, messageId, userId, cancellationToken) + ?? throw new InvalidOperationException("The deleted message could not be reloaded."); + + var dto = updatedMessage.ToDto(presenceTracker, userId); + await NotifyMessageUpdatedAsync(updatedMessage.Chat, [updatedMessage], cancellationToken); + return Ok(dto); + } + + [HttpGet("{chatId:guid}/attachments/{attachmentId:guid}/content")] + public async Task<IActionResult> DownloadAttachment(Guid chatId, Guid attachmentId, CancellationToken cancellationToken) + { + var userId = User.GetRequiredUserId(); + var attachment = await dbContext.MessageAttachments + .Include(x => x.Message) + .ThenInclude(x => x.Chat) + .ThenInclude(x => x.Members) + .SingleOrDefaultAsync( + x => x.Id == attachmentId && + x.Message.ChatId == chatId && + x.Message.Chat.Members.Any(m => m.UserId == userId), + cancellationToken); + + if (attachment is null || !System.IO.File.Exists(attachment.StoragePath)) + { + return NotFound(); + } + + var stream = System.IO.File.OpenRead(attachment.StoragePath); + return File(stream, string.IsNullOrWhiteSpace(attachment.ContentType) ? MediaTypeNames.Application.Octet : attachment.ContentType, attachment.OriginalFileName); + } + + [HttpPost("messages/cleanup-expired")] + public async Task<ActionResult<ExpiredMessagesCleanupDto>> CleanupExpiredMessages(CancellationToken cancellationToken) + { + var userId = User.GetRequiredUserId(); + var now = DateTimeOffset.UtcNow; + var messages = await dbContext.Messages + .Include(x => x.Chat) + .ThenInclude(x => x.Members) + .Include(x => x.Attachments) + .Include(x => x.Reactions) + .Include(x => x.Views) + .Where(x => + x.ExpiresAt != null && + x.ExpiresAt <= now && + x.DeletedAt == null && + x.Chat.Members.Any(member => member.UserId == userId)) + .Take(250) + .ToListAsync(cancellationToken); + + var affectedChats = messages + .Select(x => x.Chat) + .DistinctBy(x => x.Id) + .ToList(); + + foreach (var message in messages) + { + DeleteFiles(message.Attachments.Select(x => x.StoragePath)); + dbContext.MessageAttachments.RemoveRange(message.Attachments); + dbContext.MessageReactions.RemoveRange(message.Reactions); + message.Attachments.Clear(); + message.Reactions.Clear(); + message.Text = string.Empty; + message.EditedAt = null; + message.DeletedAt = now; + message.PinnedAt = null; + message.PinnedByUserId = null; + } + + await dbContext.SaveChangesAsync(cancellationToken); + + foreach (var chat in affectedChats) + { + await NotifyMessageUpdatedAsync(chat, messages.Where(x => x.ChatId == chat.Id).ToList(), cancellationToken); + } + + return Ok(new ExpiredMessagesCleanupDto(messages.Count, now)); + } + + [HttpGet("{chatId:guid}/moderation-log")] + public async Task<ActionResult<IReadOnlyList<ModerationLogDto>>> GetModerationLog( + Guid chatId, + [FromQuery] int limit = 100, + CancellationToken cancellationToken = default) + { + var userId = User.GetRequiredUserId(); + var chat = await LoadChatMetadata(chatId, userId, cancellationToken); + if (chat is null) + { + return NotFound(); + } + + if (!CanManageMembers(chat, userId)) + { + return StatusCode(StatusCodes.Status403Forbidden, "Only admins can view moderation logs."); + } + + var take = Math.Clamp(limit, 1, 250); + var entries = await dbContext.ModerationLogEntries + .AsNoTracking() + .Include(x => x.ActorUser) + .ThenInclude(x => x.Sessions) + .Where(x => x.ChatId == chatId) + .OrderByDescending(x => x.CreatedAt) + .Take(take) + .ToListAsync(cancellationToken); + + return Ok(entries.Select(x => x.ToDto(presenceTracker)).ToList()); + } + + private async Task<ActionResult<MessageDto>> SetMessagePinAsync( + Guid chatId, + Guid messageId, + bool isPinned, + CancellationToken cancellationToken) + { + var userId = User.GetRequiredUserId(); + var message = await LoadMessageForMemberAsync(chatId, messageId, userId, cancellationToken); + if (message is null) + { + return NotFound(); + } + + if (message.DeletedAt is not null) + { + return BadRequest("Deleted messages cannot be pinned."); + } + + if (!ResolvePermissions(message.Chat, userId).CanPinMessages) + { + return StatusCode(StatusCodes.Status403Forbidden, "You cannot pin messages in this chat."); + } + + if (isPinned) + { + message.PinnedAt = DateTimeOffset.UtcNow; + message.PinnedByUserId = userId; + } + else + { + message.PinnedAt = null; + message.PinnedByUserId = null; + message.PinnedByUser = null; + } + + await AddModerationLogAsync( + userId, + chatId, + ModerationLogTargetType.Message, + messageId, + isPinned ? "message.pinned" : "message.unpinned", + null, + cancellationToken); + await dbContext.SaveChangesAsync(cancellationToken); + + var updatedMessage = await LoadMessageForMemberAsync(chatId, messageId, userId, cancellationToken) + ?? throw new InvalidOperationException("The pinned message could not be reloaded."); + + var dto = updatedMessage.ToDto(presenceTracker, userId); + await NotifyMessageUpdatedAsync(updatedMessage.Chat, [updatedMessage], cancellationToken); + return Ok(dto); + } + + private async Task<IActionResult> AcknowledgeMessageReceiptAsync( + Guid chatId, + Guid messageId, + Guid userId, + bool markRead, + CancellationToken cancellationToken) + { + var targetMessage = await dbContext.Messages + .Include(x => x.Chat) + .ThenInclude(x => x.Members) + .Include(x => x.Sender) + .Include(x => x.PinnedByUser) + .Include(x => x.Attachments) + .Include(x => x.Reactions) + .Include(x => x.Views) + .Include(x => x.ReplyToMessage) + .ThenInclude(x => x!.Sender) + .Include(x => x.ReplyToMessage) + .ThenInclude(x => x!.Attachments) + .SingleOrDefaultAsync( + x => x.Id == messageId && + x.ChatId == chatId && + x.Chat.Members.Any(member => member.UserId == userId), + cancellationToken); + + if (targetMessage is null) + { + return NotFound(); + } + + if (targetMessage.SenderId == userId) + { + return Ok(); + } + + var membership = targetMessage.Chat.Members.First(member => member.UserId == userId); + var previousDeliveredAt = membership.LastDeliveredAt; + var previousReadAt = membership.LastReadAt; + + var changed = false; + if (membership.LastDeliveredAt is null || membership.LastDeliveredAt.Value < targetMessage.SentAt) + { + membership.LastDeliveredAt = targetMessage.SentAt; + changed = true; + } + + if (markRead && (membership.LastReadAt is null || membership.LastReadAt.Value < targetMessage.SentAt)) + { + membership.LastReadAt = targetMessage.SentAt; + changed = true; + } + + if (!changed) + { + return Ok(); + } + + await dbContext.SaveChangesAsync(cancellationToken); + + var changedAfter = markRead ? previousReadAt : previousDeliveredAt; + var changedMessages = await LoadReceiptAffectedMessagesAsync( + chatId, + userId, + changedAfter, + targetMessage.SentAt, + cancellationToken); + + if (changedMessages.Count > 0) + { + await NotifyMessageUpdatedAsync(targetMessage.Chat, changedMessages, cancellationToken); + } + + await NotifyChatsChangedAsync([userId], cancellationToken); + + return Ok(); + } + + private async Task UpdateReadReceiptForOpenedChatAsync(Chat chat, Guid userId, CancellationToken cancellationToken) + { + var latestIncomingAt = await dbContext.Messages + .Where(message => message.ChatId == chat.Id && message.SenderId != userId) + .Select(message => (DateTimeOffset?)message.SentAt) + .MaxAsync(cancellationToken); + + if (latestIncomingAt is null) + { + return; + } + + var membership = chat.Members.First(member => member.UserId == userId); + var previousReadAt = membership.LastReadAt; + var previousDeliveredAt = membership.LastDeliveredAt; + + var changed = false; + if (membership.LastDeliveredAt is null || membership.LastDeliveredAt.Value < latestIncomingAt.Value) + { + membership.LastDeliveredAt = latestIncomingAt.Value; + changed = true; + } + + if (membership.LastReadAt is null || membership.LastReadAt.Value < latestIncomingAt.Value) + { + membership.LastReadAt = latestIncomingAt.Value; + changed = true; + } + + if (!changed) + { + return; + } + + await dbContext.SaveChangesAsync(cancellationToken); + + var changedAfter = previousReadAt ?? previousDeliveredAt; + var changedMessages = await LoadReceiptAffectedMessagesAsync( + chat.Id, + userId, + changedAfter, + latestIncomingAt.Value, + cancellationToken); + + if (changedMessages.Count > 0) + { + await NotifyMessageUpdatedAsync(chat, changedMessages, cancellationToken); + } + + await NotifyChatsChangedAsync([userId], cancellationToken); + } + + private async Task MarkChannelMessagesViewedAsync( + Chat chat, + Guid userId, + IReadOnlyCollection<Message> messages, + CancellationToken cancellationToken) + { + if (chat.Type != ChatType.Channel || messages.Count == 0) + { + return; + } + + var messageIds = messages + .Where(message => message.PostedAsChannel && message.DeletedAt is null) + .Select(message => message.Id) + .Distinct() + .ToList(); + if (messageIds.Count == 0) + { + return; + } + + var existingViewedMessageIds = await dbContext.MessageViews + .AsNoTracking() + .Where(view => view.ViewerUserId == userId && messageIds.Contains(view.MessageId)) + .Select(view => view.MessageId) + .ToListAsync(cancellationToken); + var existingSet = existingViewedMessageIds.ToHashSet(); + var viewedAt = DateTimeOffset.UtcNow; + var changed = false; + + foreach (var message in messages.Where(message => messageIds.Contains(message.Id))) + { + if (!existingSet.Add(message.Id)) + { + continue; + } + + var view = new MessageView + { + MessageId = message.Id, + ViewerUserId = userId, + ViewedAt = viewedAt + }; + dbContext.MessageViews.Add(view); + message.Views.Add(view); + changed = true; + } + + if (changed) + { + await dbContext.SaveChangesAsync(cancellationToken); + } + } + + private async Task<List<Message>> LoadReceiptAffectedMessagesAsync( + Guid chatId, + Guid recipientUserId, + DateTimeOffset? changedAfter, + DateTimeOffset changedBeforeOrAt, + CancellationToken cancellationToken) + { + var messages = await dbContext.Messages + .Include(x => x.Chat) + .ThenInclude(x => x.Members) + .Include(x => x.Sender) + .Include(x => x.PinnedByUser) + .Include(x => x.Attachments) + .Include(x => x.Reactions) + .Include(x => x.Views) + .Include(x => x.ReplyToMessage) + .ThenInclude(x => x!.Sender) + .Include(x => x.ReplyToMessage) + .ThenInclude(x => x!.Attachments) + .Where(x => + x.ChatId == chatId && + x.SenderId != recipientUserId) + .ToListAsync(cancellationToken); + + return messages + .Where(x => + x.SentAt <= changedBeforeOrAt && + (changedAfter is null || x.SentAt > changedAfter.Value)) + .OrderBy(x => x.SentAt) + .ToList(); + } + + private async Task<IReadOnlyDictionary<Guid, Message>> LoadLatestMessagesByChatIdAsync( + IReadOnlyCollection<Guid> chatIds, + CancellationToken cancellationToken) + { + if (chatIds.Count == 0) + { + return new Dictionary<Guid, Message>(); + } + + var latestSentAtByChatQuery = dbContext.Messages + .Where(message => chatIds.Contains(message.ChatId)) + .GroupBy(message => message.ChatId) + .Select(group => new + { + ChatId = group.Key, + SentAt = group.Max(message => message.SentAt) + }); + + var latestMessages = await dbContext.Messages + .Join( + latestSentAtByChatQuery, + message => new { message.ChatId, message.SentAt }, + latest => new { latest.ChatId, latest.SentAt }, + (message, _) => message) + .Include(message => message.Sender) + .Include(message => message.PinnedByUser) + .Include(message => message.Attachments) + .AsNoTracking() + .ToListAsync(cancellationToken); + + return latestMessages + .GroupBy(message => message.ChatId) + .ToDictionary( + group => group.Key, + group => group.OrderByDescending(message => message.SentAt).First()); + } + + private async Task<IReadOnlyDictionary<Guid, int>> LoadUnreadCountsByChatIdAsync( + Guid userId, + IReadOnlyCollection<Guid> chatIds, + CancellationToken cancellationToken) + { + if (chatIds.Count == 0) + { + return new Dictionary<Guid, int>(); + } + + var unreadCounts = await dbContext.Messages + .Where(message => + chatIds.Contains(message.ChatId) && + message.SenderId != userId && + message.DeletedAt == null) + .Join( + dbContext.ChatMembers.Where(member => member.UserId == userId), + message => message.ChatId, + member => member.ChatId, + (message, member) => new + { + message.ChatId, + message.SentAt, + member.LastReadAt + }) + .Where(x => x.LastReadAt == null || x.SentAt > x.LastReadAt.Value) + .GroupBy(x => x.ChatId) + .Select(group => new + { + ChatId = group.Key, + Count = group.Count() + }) + .ToListAsync(cancellationToken); + + return unreadCounts.ToDictionary(x => x.ChatId, x => x.Count); + } + + private async Task<IReadOnlyList<Message>> LoadPinnedMessagesAsync(Chat chat, CancellationToken cancellationToken) + { + var messages = await dbContext.Messages + .Where(message => + message.ChatId == chat.Id && + message.PinnedAt != null && + message.DeletedAt == null) + .OrderByDescending(message => message.PinnedAt) + .Take(5) + .Include(message => message.Sender) + .Include(message => message.PinnedByUser) + .Include(message => message.Attachments) + .Include(message => message.Reactions) + .Include(message => message.Views) + .Include(message => message.ReplyToMessage) + .ThenInclude(message => message!.Sender) + .Include(message => message.ReplyToMessage) + .ThenInclude(message => message!.Attachments) + .AsNoTracking() + .ToListAsync(cancellationToken); + + foreach (var message in messages) + { + message.Chat = chat; + } + + return messages; + } + + private sealed record MessagesPage(IReadOnlyList<Message> Messages, bool HasMoreMessages); + + private async Task<MessagesPage> LoadMessagesPageAsync( + Chat chat, + Guid? beforeMessageId, + int requestedLimit, + CancellationToken cancellationToken) + { + var limit = Math.Clamp(requestedLimit, 1, MaxMessagePageSize); + var query = dbContext.Messages.Where(message => message.ChatId == chat.Id); + + if (beforeMessageId is not null) + { + var boundary = await dbContext.Messages + .AsNoTracking() + .Where(message => message.ChatId == chat.Id && message.Id == beforeMessageId.Value) + .Select(message => new { message.SentAt }) + .SingleOrDefaultAsync(cancellationToken); + + if (boundary is null) + { + return new MessagesPage([], HasMoreMessages: false); + } + + query = query.Where(message => message.SentAt < boundary.SentAt); + } + + var messages = await query + .OrderByDescending(message => message.SentAt) + .Take(limit + 1) + .Include(message => message.Sender) + .Include(message => message.PinnedByUser) + .Include(message => message.Attachments) + .Include(message => message.Reactions) + .Include(message => message.Views) + .Include(message => message.ReplyToMessage) + .ThenInclude(message => message!.Sender) + .Include(message => message.ReplyToMessage) + .ThenInclude(message => message!.Attachments) + .AsNoTracking() + .ToListAsync(cancellationToken); + + var hasMoreMessages = messages.Count > limit; + var pageMessages = messages + .Take(limit) + .OrderBy(message => message.SentAt) + .ToList(); + + foreach (var message in pageMessages) + { + message.Chat = chat; + } + + return new MessagesPage(pageMessages, hasMoreMessages); + } + + private async Task<Chat?> LoadChatMetadata( + Guid chatId, + Guid userId, + CancellationToken cancellationToken, + bool asNoTracking = true, + bool allowPublicChannelPreview = false) + { + var query = dbContext.Chats + .Include(x => x.Members) + .ThenInclude(x => x.User) + .AsSplitQuery(); + + if (asNoTracking) + { + query = query.AsNoTracking(); + } + + return await query.SingleOrDefaultAsync( + x => x.Id == chatId && + (x.Members.Any(m => m.UserId == userId) || + (allowPublicChannelPreview && + x.Type == ChatType.Channel && + x.PublicUsername != null && + x.PublicUsername != string.Empty)), + cancellationToken); + } + + private async Task<Chat?> LoadChatWithMessages(Guid chatId, Guid userId, CancellationToken cancellationToken, bool asNoTracking = true) + { + var query = dbContext.Chats + .Include(x => x.Members) + .ThenInclude(x => x.User) + .Include(x => x.Messages) + .ThenInclude(x => x.Sender) + .Include(x => x.Messages) + .ThenInclude(x => x.PinnedByUser) + .Include(x => x.Messages) + .ThenInclude(x => x.Attachments) + .Include(x => x.Messages) + .ThenInclude(x => x.Reactions) + .Include(x => x.Messages) + .ThenInclude(x => x.Views) + .Include(x => x.Messages) + .ThenInclude(x => x.ReplyToMessage) + .ThenInclude(x => x!.Sender) + .Include(x => x.Messages) + .ThenInclude(x => x.ReplyToMessage) + .ThenInclude(x => x!.Attachments) + .AsSplitQuery(); + + if (asNoTracking) + { + query = query.AsNoTracking(); + } + + return await query.SingleOrDefaultAsync(x => x.Id == chatId && x.Members.Any(m => m.UserId == userId), cancellationToken); + } + + private async Task<Message?> LoadMessageForMemberAsync(Guid chatId, Guid messageId, Guid userId, CancellationToken cancellationToken) + { + return await dbContext.Messages + .Include(x => x.Chat) + .ThenInclude(x => x.Members) + .Include(x => x.Sender) + .Include(x => x.PinnedByUser) + .Include(x => x.Attachments) + .Include(x => x.Reactions) + .Include(x => x.Views) + .Include(x => x.ReplyToMessage) + .ThenInclude(x => x!.Sender) + .Include(x => x.ReplyToMessage) + .ThenInclude(x => x!.Attachments) + .SingleOrDefaultAsync( + x => x.Id == messageId && + x.ChatId == chatId && + x.Chat.Members.Any(m => m.UserId == userId), + cancellationToken); + } + + private Task<Message?> ResolveReplyTargetAsync( + Guid chatId, + Guid? replyToMessageId, + Guid userId, + CancellationToken cancellationToken) + { + if (replyToMessageId is null) + { + return Task.FromResult<Message?>(null); + } + + return dbContext.Messages + .Include(x => x.Sender) + .Include(x => x.Attachments) + .SingleOrDefaultAsync( + x => x.Id == replyToMessageId.Value && + x.ChatId == chatId && + x.Chat.Members.Any(member => member.UserId == userId), + cancellationToken); + } + + private async Task<List<Chat>> LoadChatSummariesForUserAsync(Guid userId, CancellationToken cancellationToken) => + await dbContext.Chats + .Where(x => x.Members.Any(member => member.UserId == userId)) + .Include(x => x.Members) + .ThenInclude(x => x.User) + .AsNoTracking() + .AsSplitQuery() + .ToListAsync(cancellationToken); + + private async Task<ChatSearchPageDto> SearchChatsPageAsync( + Guid userId, + string? rawQuery, + SearchFilters filters, + int requestedPage, + int requestedLimit, + CancellationToken cancellationToken) + { + var query = rawQuery?.Trim() ?? string.Empty; + var page = NormalizeSearchPage(requestedPage); + var take = NormalizeSearchLimit(requestedLimit); + if (query.Length < 2 && !filters.HasMessageFilter) + { + return new ChatSearchPageDto([], page, take, HasMore: false); + } + + var skip = (page - 1) * take; + var chats = await LoadChatSummariesForUserAsync(userId, cancellationToken); + var chatIds = chats.Select(x => x.Id).ToList(); + var latestMessagesByChatId = await LoadLatestMessagesByChatIdAsync(chatIds, cancellationToken); + var unreadCountsByChatId = await LoadUnreadCountsByChatIdAsync(userId, chatIds, cancellationToken); + var summariesByChatId = chats.ToDictionary( + x => x.Id, + x => x.ToSummaryDto( + userId, + presenceTracker, + latestMessagesByChatId.GetValueOrDefault(x.Id), + unreadCountsByChatId.GetValueOrDefault(x.Id))); + + var chatMatches = query.Length >= 2 && !filters.HasMessageFilter + ? chats + .Concat(await LoadPublicChannelSearchResultsAsync(query, cancellationToken)) + .GroupBy(chat => chat.Id) + .Select(group => group.First()) + .Where(chat => MatchesChatSearch(chat, query)) + .OrderByDescending(chat => chat.LastActivityAt ?? chat.CreatedAt) + .Select(chat => + { + var summary = summariesByChatId.TryGetValue(chat.Id, out var memberSummary) + ? memberSummary + : chat.ToSummaryDto(userId, presenceTracker); + return new ChatSearchResultDto( + summary, + null, + summary.Title, + summary.LastActivityAt, + summary.Id); + }) + .ToList() + : []; + + var selectedChatMatches = chatMatches + .Skip(skip) + .Take(take) + .ToList(); + var messageSkip = Math.Max(0, skip - chatMatches.Count); + var messageTake = take - selectedChatMatches.Count; + var results = new List<ChatSearchResultDto>(selectedChatMatches); + var messageHasMore = false; + + if (messageTake > 0) + { + var messageMatches = await LoadSearchMessagesAsync( + chatIds, + query, + filters, + messageSkip, + messageTake + 1, + cancellationToken); + messageHasMore = messageMatches.Count > messageTake; + + foreach (var message in messageMatches.Take(messageTake)) + { + if (!summariesByChatId.TryGetValue(message.ChatId, out var summary)) + { + continue; + } + + results.Add(ToSearchResult(summary, message, userId)); + } + } + + var hasMore = skip + take < chatMatches.Count || messageHasMore; + return new ChatSearchPageDto(results, page, take, hasMore); + } + + private async Task<List<Chat>> LoadPublicChannelSearchResultsAsync( + string query, + CancellationToken cancellationToken) + { + var pattern = BuildLikePattern(query); + var usernameQuery = NormalizePublicSearchUsername(query); + var usernamePattern = usernameQuery is null ? null : BuildLikePattern(usernameQuery); + return await dbContext.Chats + .Where(chat => + chat.Type == ChatType.Channel && + chat.PublicUsername != null && + chat.PublicUsername != string.Empty && + ((chat.Title != null && EF.Functions.Like(chat.Title, pattern, "\\")) || + EF.Functions.Like(chat.PublicUsername, pattern, "\\") || + (usernamePattern != null && EF.Functions.Like(chat.PublicUsername, usernamePattern, "\\")) || + (chat.Description != null && EF.Functions.Like(chat.Description, pattern, "\\")))) + .Include(chat => chat.Members) + .ThenInclude(member => member.User) + .AsNoTracking() + .AsSplitQuery() + .ToListAsync(cancellationToken); + } + + private async Task<MessageSearchPageDto> SearchMessagesPageAsync( + Chat chat, + Guid userId, + string? rawQuery, + SearchFilters filters, + int requestedPage, + int requestedLimit, + CancellationToken cancellationToken) + { + var query = rawQuery?.Trim() ?? string.Empty; + var page = NormalizeSearchPage(requestedPage); + var take = NormalizeSearchLimit(requestedLimit); + if (query.Length < 2 && !filters.HasMessageFilter) + { + return new MessageSearchPageDto([], page, take, HasMore: false); + } + + var messages = await LoadSearchMessagesAsync( + [chat.Id], + query, + filters, + (page - 1) * take, + take + 1, + cancellationToken); + var hasMore = messages.Count > take; + var dtos = messages + .Take(take) + .Select(message => + { + message.Chat = chat; + return message.ToDto(presenceTracker, userId); + }) + .ToList(); + + return new MessageSearchPageDto(dtos, page, take, hasMore); + } + + private ChatSearchResultDto ToSearchResult(ChatSummaryDto summary, Message message, Guid userId) + { + var messageDto = message.ToDto(presenceTracker, userId); + return new ChatSearchResultDto( + summary, + messageDto, + BuildSearchMatchText(message), + message.SentAt, + summary.Id, + message.Id, + message.SenderId, + message.SentAt); + } + + private async Task<List<Message>> LoadSearchMessagesAsync( + IReadOnlyCollection<Guid> chatIds, + string query, + SearchFilters filters, + int skip, + int limit, + CancellationToken cancellationToken) + { + if (chatIds.Count == 0) + { + return []; + } + + var messagesQuery = dbContext.Messages + .Where(message => chatIds.Contains(message.ChatId) && message.DeletedAt == null); + + if (!string.IsNullOrWhiteSpace(query)) + { + var queryPattern = BuildLikePattern(query); + messagesQuery = messagesQuery.Where(message => + EF.Functions.Like(message.Text, queryPattern, "\\") || + message.Attachments.Any(attachment => EF.Functions.Like(attachment.OriginalFileName, queryPattern, "\\"))); + } + + if (filters.DateFrom is not null) + { + messagesQuery = messagesQuery.Where(message => message.SentAt >= filters.DateFrom.Value); + } + + if (filters.DateTo is not null) + { + messagesQuery = messagesQuery.Where(message => message.SentAt <= filters.DateTo.Value); + } + + if (filters.AuthorId is not null) + { + messagesQuery = messagesQuery.Where(message => message.SenderId == filters.AuthorId.Value); + } + + if (!string.IsNullOrWhiteSpace(filters.AuthorQuery)) + { + var authorPattern = BuildLikePattern(filters.AuthorQuery); + messagesQuery = messagesQuery.Where(message => + EF.Functions.Like(message.Sender.DisplayName, authorPattern, "\\") || + EF.Functions.Like(message.Sender.Username, authorPattern, "\\") || + (message.Sender.PhoneNumber != null && EF.Functions.Like(message.Sender.PhoneNumber, authorPattern, "\\"))); + } + + messagesQuery = filters.Type switch + { + "text" => messagesQuery.Where(message => message.Text != ""), + "attachments" => messagesQuery.Where(message => message.Attachments.Any()), + "media" => messagesQuery.Where(message => message.Attachments.Any(attachment => attachment.ContentType.StartsWith("image/") || attachment.ContentType.StartsWith("video/"))), + "files" => messagesQuery.Where(message => message.Attachments.Any(attachment => + !attachment.ContentType.StartsWith("image/") && + !attachment.ContentType.StartsWith("video/") && + !attachment.ContentType.StartsWith("audio/"))), + "links" => messagesQuery.Where(message => message.Text.Contains("http://") || message.Text.Contains("https://")), + "voice" => messagesQuery.Where(message => message.Attachments.Any(attachment => attachment.ContentType.StartsWith("audio/"))), + _ => messagesQuery + }; + + return await messagesQuery + .OrderByDescending(message => message.SentAt) + .Skip(skip) + .Take(limit) + .Include(message => message.Chat) + .ThenInclude(chat => chat.Members) + .ThenInclude(member => member.User) + .Include(message => message.Sender) + .Include(message => message.PinnedByUser) + .Include(message => message.Attachments) + .Include(message => message.Reactions) + .Include(message => message.Views) + .Include(message => message.ReplyToMessage) + .ThenInclude(message => message!.Sender) + .Include(message => message.ReplyToMessage) + .ThenInclude(message => message!.Attachments) + .AsNoTracking() + .ToListAsync(cancellationToken); + } + + private static bool MatchesChatSearch(Chat chat, string query) + { + if (!string.IsNullOrWhiteSpace(chat.Title) && chat.Title.Contains(query, StringComparison.OrdinalIgnoreCase)) + { + return true; + } + + if (!string.IsNullOrWhiteSpace(chat.PublicUsername) && + chat.PublicUsername.Contains(query, StringComparison.OrdinalIgnoreCase)) + { + return true; + } + + var usernameQuery = NormalizePublicSearchUsername(query); + if (!string.IsNullOrWhiteSpace(usernameQuery) && + !string.IsNullOrWhiteSpace(chat.PublicUsername) && + chat.PublicUsername.Contains(usernameQuery, StringComparison.OrdinalIgnoreCase)) + { + return true; + } + + if (!string.IsNullOrWhiteSpace(chat.Description) && + chat.Description.Contains(query, StringComparison.OrdinalIgnoreCase)) + { + return true; + } + + return chat.Members.Any(member => + member.User.DisplayName.Contains(query, StringComparison.OrdinalIgnoreCase) || + member.User.Username.Contains(query, StringComparison.OrdinalIgnoreCase) || + (member.User.PhoneNumber?.Contains(query, StringComparison.OrdinalIgnoreCase) ?? false)); + } + + private static string BuildSearchMatchText(Message message) + { + if (!string.IsNullOrWhiteSpace(message.Text)) + { + return message.Text.Length <= 160 ? message.Text : message.Text[..160]; + } + + var attachment = message.Attachments + .OrderBy(x => x.SortOrder) + .ThenBy(x => x.OriginalFileName) + .FirstOrDefault(); + + return attachment is null ? "Message" : attachment.OriginalFileName; + } + + private static SearchFilters BuildSearchFilters( + string? legacyFilter, + string? type, + DateTimeOffset? date, + DateTimeOffset? dateFrom, + DateTimeOffset? dateTo, + Guid? authorId, + string? author) + { + var normalizedType = NormalizeSearchType(type) ?? NormalizeSearchType(legacyFilter); + var effectiveDateFrom = dateFrom; + var effectiveDateTo = dateTo; + if (date is not null) + { + var dateStart = new DateTimeOffset(date.Value.Date, date.Value.Offset); + effectiveDateFrom ??= dateStart; + effectiveDateTo ??= dateStart.AddDays(1).AddTicks(-1); + } + + return new SearchFilters( + normalizedType, + effectiveDateFrom, + effectiveDateTo, + authorId, + string.IsNullOrWhiteSpace(author) ? null : author.Trim()); + } + + private static string? NormalizeSearchType(string? value) + { + var normalized = value?.Trim().ToLowerInvariant(); + return normalized switch + { + "attachment" or "attachments" => "attachments", + "file" or "files" or "document" or "documents" => "files", + "image" or "images" or "video" or "videos" or "media" => "media", + "link" or "links" or "url" or "urls" => "links", + "audio" or "voice" or "voice-note" or "voice_note" or "voice-notes" or "voice_notes" => "voice", + "message" or "messages" or "text" => "text", + _ => null + }; + } + + private static int NormalizeSearchPage(int page) => Math.Clamp(page, 1, 10_000); + + private static int NormalizeSearchLimit(int limit) => Math.Clamp(limit, 1, 100); + + private static string BuildLikePattern(string value) + { + var escaped = value + .Replace("\\", "\\\\", StringComparison.Ordinal) + .Replace("%", "\\%", StringComparison.Ordinal) + .Replace("_", "\\_", StringComparison.Ordinal); + return $"%{escaped}%"; + } + + private static string? NormalizePublicSearchUsername(string value) + { + var normalized = value.Trim().TrimStart('@'); + return string.IsNullOrWhiteSpace(normalized) ? null : normalized; + } + + private void WriteSearchPageHeaders(int page, int limit, bool hasMore) + { + Response.Headers["X-Search-Page"] = page.ToString(System.Globalization.CultureInfo.InvariantCulture); + Response.Headers["X-Search-Limit"] = limit.ToString(System.Globalization.CultureInfo.InvariantCulture); + Response.Headers["X-Search-Has-More"] = hasMore ? "true" : "false"; + } + + private sealed record SearchFilters( + string? Type, + DateTimeOffset? DateFrom, + DateTimeOffset? DateTo, + Guid? AuthorId, + string? AuthorQuery) + { + public bool HasMessageFilter => + Type is not null || + DateFrom is not null || + DateTo is not null || + AuthorId is not null || + !string.IsNullOrWhiteSpace(AuthorQuery); + } + + private static string? NormalizeFolderKey(string? value) + { + var normalized = value?.Trim().ToLowerInvariant(); + if (string.IsNullOrWhiteSpace(normalized)) + { + return null; + } + + return new string(normalized + .Where(ch => char.IsLetterOrDigit(ch) || ch is '-' or '_') + .Take(48) + .ToArray()); + } + + private static string? NormalizePublicUsername(string? value) + { + var trimmed = value?.Trim().TrimStart('@'); + if (string.IsNullOrWhiteSpace(trimmed)) + { + return null; + } + + var normalized = new string(trimmed + .Where(ch => ch is >= 'a' and <= 'z' || ch is >= 'A' and <= 'Z' || char.IsDigit(ch) || ch == '_') + .Take(32) + .ToArray()) + .ToLowerInvariant(); + + if (normalized.Length < 5) + { + throw new InvalidOperationException("Public username must contain at least 5 characters."); + } + + return normalized; + } + + private ChatInviteLinkDto ToInviteLinkDto(ChatInviteLink link) => + new( + link.Id, + link.ChatId, + link.Token, + $"{Request.Scheme}://{Request.Host}/join/{Uri.EscapeDataString(link.Token)}", + link.Title, + link.CreatesJoinRequest, + link.IsRevoked, + link.CreatedAt, + link.RevokedAt); + + private ChatJoinRequestDto ToJoinRequestDto(ChatJoinRequest request) => + new( + request.Id, + request.ChatId, + request.InviteLinkId, + request.User.ToDto(presenceTracker), + request.Status, + request.RequestedAt, + request.ResolvedAt, + request.ResolvedByUser?.ToDto(presenceTracker)); + + private async Task AddModerationLogAsync( + Guid actorUserId, + Guid? chatId, + ModerationLogTargetType targetType, + Guid? targetId, + string action, + string? reason, + CancellationToken cancellationToken) + { + var actorExists = await dbContext.Users.AnyAsync(x => x.Id == actorUserId, cancellationToken); + if (!actorExists) + { + return; + } + + dbContext.ModerationLogEntries.Add(new ModerationLogEntry + { + ActorUserId = actorUserId, + ChatId = chatId, + TargetType = targetType, + TargetId = targetId, + Action = action, + Reason = TrimToNull(reason, 256), + CreatedAt = DateTimeOffset.UtcNow + }); + } + + private static int? NormalizeTtlSeconds(int? ttlSeconds, bool allowNull) + { + if (ttlSeconds is null || ttlSeconds <= 0) + { + return allowNull ? null : 86400; + } + + return Math.Clamp(ttlSeconds.Value, 5, 31 * 24 * 60 * 60); + } + + private async Task<bool> IsBlockedByPrivacyAsync(Guid ownerUserId, Guid candidateUserId, CancellationToken cancellationToken) + { + var rawBlocked = await dbContext.UserPrivacySettings + .AsNoTracking() + .Where(x => x.UserId == ownerUserId) + .Select(x => x.BlockedUserIds) + .SingleOrDefaultAsync(cancellationToken); + + return ContainsSerializedUserId(rawBlocked, candidateUserId); + } + + private static bool ContainsSerializedUserId(string? value, Guid userId) => + !string.IsNullOrWhiteSpace(value) && + value + .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .Any(x => Guid.TryParse(x, out var parsed) && parsed == userId); + + private async Task<string> GenerateInviteTokenAsync(CancellationToken cancellationToken) + { + while (true) + { + var token = Convert.ToBase64String(RandomNumberGenerator.GetBytes(18)) + .TrimEnd('=') + .Replace('+', '-') + .Replace('/', '_'); + var exists = await dbContext.ChatInviteLinks + .AnyAsync(x => x.Token == token, cancellationToken); + if (!exists) + { + return token; + } + } + } + + private static string? TrimToNull(string? value, int maxLength) + { + var trimmed = value?.Trim(); + if (string.IsNullOrWhiteSpace(trimmed)) + { + return null; + } + + return trimmed.Length <= maxLength ? trimmed : trimmed[..maxLength]; + } + + private async Task<List<ChatTopicDto>> LoadChatTopicDtosAsync(Guid chatId, CancellationToken cancellationToken) + { + var messageCounts = await dbContext.Messages + .AsNoTracking() + .Where(x => x.ChatId == chatId && x.TopicId != null && x.DeletedAt == null) + .GroupBy(x => x.TopicId!.Value) + .Select(group => new { TopicId = group.Key, Count = group.Count() }) + .ToDictionaryAsync(x => x.TopicId, x => x.Count, cancellationToken); + + var topics = await dbContext.ChatTopics + .AsNoTracking() + .Where(x => x.ChatId == chatId) + .OrderByDescending(x => x.IsPinned) + .ThenByDescending(x => x.LastActivityAt) + .ToListAsync(cancellationToken); + + return topics + .Select(x => new ChatTopicDto( + x.Id, + x.ChatId, + x.Title, + x.Icon, + x.IsPinned, + x.IsClosed, + x.CreatedAt, + x.LastActivityAt, + messageCounts.GetValueOrDefault(x.Id))) + .ToList(); + } + + private static ChatPermissionsDto ResolvePermissions(Chat chat, Guid userId) => + DtoMapper.BuildPermissions(chat.Members.FirstOrDefault(member => member.UserId == userId), chat.Type); + + private static bool CanManageMembers(Chat chat, Guid userId) => + ResolvePermissions(chat, userId).CanManageMembers; + + private static bool CanUserPublish(Chat chat, Guid userId) => + ResolvePermissions(chat, userId).CanPostMessages; + + private static bool ShouldPostAsChannel(Chat chat) => + chat.Type == ChatType.Channel; + + private static string? ResolveChannelAuthorSignature(Chat chat, User sender) + { + if (chat.Type != ChatType.Channel || !chat.ChannelSignaturesEnabled) + { + return null; + } + + return string.IsNullOrWhiteSpace(sender.DisplayName) ? sender.Username : sender.DisplayName; + } + + private static ChatMember CreateSubscriberMembership(Chat chat, Guid userId) + { + var isChannel = chat.Type == ChatType.Channel; + return new ChatMember + { + ChatId = chat.Id, + UserId = userId, + Role = ChatMemberRole.Member, + CanPinMessages = !isChannel, + CanDeleteMessages = false, + CanInviteUsers = !isChannel, + CanPostMessages = !isChannel, + JoinedAt = DateTimeOffset.UtcNow + }; + } + + private static string ResolveForwardedFromDisplayName(Message sourceMessage) + { + var forwardedFromDisplayName = sourceMessage.ForwardedFromDisplayName; + if (!string.IsNullOrWhiteSpace(forwardedFromDisplayName)) + { + return forwardedFromDisplayName; + } + + if (sourceMessage.Chat.Type == ChatType.Channel && sourceMessage.PostedAsChannel) + { + return string.IsNullOrWhiteSpace(sourceMessage.Chat.Title) ? "Channel" : sourceMessage.Chat.Title!; + } + + return string.IsNullOrWhiteSpace(sourceMessage.Sender.DisplayName) + ? sourceMessage.Sender.Username + : sourceMessage.Sender.DisplayName; + } + + private async Task NotifyMessageCreatedAsync(Chat chat, Message message, CancellationToken cancellationToken) + { + foreach (var member in chat.Members) + { + var dto = message.ToDto(presenceTracker, member.UserId); + await hubContext.Clients.User(member.UserId.ToString()) + .SendAsync("MessageCreated", dto, cancellationToken); + } + + await NotifyChatsChangedAsync(chat.Members.Select(member => member.UserId), cancellationToken); + } + + private async Task NotifyMessageUpdatedAsync(Chat chat, IReadOnlyCollection<Message> messages, CancellationToken cancellationToken) + { + foreach (var member in chat.Members) + { + foreach (var message in messages.OrderBy(x => x.SentAt)) + { + var dto = message.ToDto(presenceTracker, member.UserId); + await hubContext.Clients.User(member.UserId.ToString()) + .SendAsync("MessageUpdated", dto, cancellationToken); + } + } + } + + private async Task NotifyChatCreatedAsync(Chat chat, CancellationToken cancellationToken) + { + foreach (var member in chat.Members) + { + var summary = chat.ToSummaryDto(member.UserId, presenceTracker); + await hubContext.Clients.User(member.UserId.ToString()) + .SendAsync("ChatCreated", summary, cancellationToken); + } + + await hubContext.Clients.Users(chat.Members.Select(x => x.UserId.ToString())) + .SendAsync("ChatsChanged", cancellationToken); + } + + private Task NotifyChatsChangedAsync(IEnumerable<Guid> userIds, CancellationToken cancellationToken) => + hubContext.Clients.Users(userIds.Select(x => x.ToString()).Distinct()) + .SendAsync("ChatsChanged", cancellationToken); + + private void DeleteFiles(IEnumerable<string> attachmentPaths) + { + foreach (var path in attachmentPaths) + { + attachmentStorageService.DeleteIfExists(path); + } + } +} diff --git a/src/Ikar.Server/Controllers/PushController.cs b/src/Ikar.Server/Controllers/PushController.cs new file mode 100644 index 00000000..e51af448 --- /dev/null +++ b/src/Ikar.Server/Controllers/PushController.cs @@ -0,0 +1,113 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using Ikar.Server.Data; +using Ikar.Server.Data.Entities; +using Ikar.Server.Infrastructure; +using Ikar.Server.Infrastructure.Auth; +using Ikar.Shared; + +namespace Ikar.Server.Controllers; + +[ApiController] +[Authorize] +[Route("api/[controller]")] +public sealed class PushController(IkarDbContext dbContext) : ControllerBase +{ + [HttpGet("devices")] + public async Task<ActionResult<IReadOnlyList<PushDeviceDto>>> GetDevices(CancellationToken cancellationToken) + { + var userId = User.GetRequiredUserId(); + var devices = await dbContext.PushDevices + .AsNoTracking() + .Where(x => x.UserId == userId) + .ToListAsync(cancellationToken); + + return Ok(devices + .OrderByDescending(x => x.UpdatedAt) + .Select(x => x.ToDto()) + .ToList()); + } + + [HttpPost("devices")] + public async Task<ActionResult<PushDeviceDto>> RegisterDevice( + RegisterPushDeviceRequest request, + CancellationToken cancellationToken) + { + var userId = User.GetRequiredUserId(); + var installationId = request.InstallationId?.Trim() ?? string.Empty; + var deviceToken = request.DeviceToken?.Trim() ?? string.Empty; + if (request.Platform == PushPlatform.Unknown || + string.IsNullOrWhiteSpace(installationId) || + string.IsNullOrWhiteSpace(deviceToken)) + { + return ValidationProblem("Platform, installationId and deviceToken are required."); + } + + var clientVersion = ClientVersionRequestReader.Read(Request); + var device = await dbContext.PushDevices + .SingleOrDefaultAsync(x => x.InstallationId == installationId, cancellationToken); + + if (device is null) + { + device = new PushDevice + { + UserId = userId, + Platform = request.Platform, + InstallationId = installationId, + DeviceToken = deviceToken, + DeviceName = NormalizeDeviceName(request.DeviceName), + NotificationsEnabled = request.NotificationsEnabled, + UpdatedAt = DateTimeOffset.UtcNow + }; + ClientVersionRequestReader.Apply(device, clientVersion); + + dbContext.PushDevices.Add(device); + } + else + { + device.UserId = userId; + device.Platform = request.Platform; + device.DeviceToken = deviceToken; + device.DeviceName = NormalizeDeviceName(request.DeviceName); + device.NotificationsEnabled = request.NotificationsEnabled; + device.UpdatedAt = DateTimeOffset.UtcNow; + ClientVersionRequestReader.Apply(device, clientVersion); + } + + await dbContext.SaveChangesAsync(cancellationToken); + return Ok(device.ToDto()); + } + + [HttpDelete("devices/{installationId}")] + public async Task<IActionResult> UnregisterDevice(string installationId, CancellationToken cancellationToken) + { + var userId = User.GetRequiredUserId(); + var normalizedInstallationId = installationId?.Trim() ?? string.Empty; + if (string.IsNullOrWhiteSpace(normalizedInstallationId)) + { + return NotFound(); + } + + var device = await dbContext.PushDevices + .SingleOrDefaultAsync( + x => x.InstallationId == normalizedInstallationId && + x.UserId == userId, + cancellationToken); + + if (device is null) + { + return NotFound(); + } + + dbContext.PushDevices.Remove(device); + await dbContext.SaveChangesAsync(cancellationToken); + return NoContent(); + } + + private static string? NormalizeDeviceName(string? deviceName) + { + var normalized = deviceName?.Trim(); + return string.IsNullOrWhiteSpace(normalized) ? null : normalized; + } +} diff --git a/src/Ikar.Server/Controllers/StoriesController.cs b/src/Ikar.Server/Controllers/StoriesController.cs new file mode 100644 index 00000000..895bf7a8 --- /dev/null +++ b/src/Ikar.Server/Controllers/StoriesController.cs @@ -0,0 +1,335 @@ +using System.Net.Mime; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using Ikar.Server.Data; +using Ikar.Server.Data.Entities; +using Ikar.Server.Infrastructure; +using Ikar.Server.Infrastructure.Storage; +using Ikar.Shared; + +namespace Ikar.Server.Controllers; + +[ApiController] +[Authorize] +[Route("api/[controller]")] +public sealed class StoriesController( + IkarDbContext dbContext, + PresenceTracker presenceTracker, + AttachmentStorageService attachmentStorageService) : ControllerBase +{ + private const int MaxStoryAttachmentCount = 10; + private const long MaxStoryUploadSizeBytes = 100L * 1024L * 1024L; + + [HttpGet] + public async Task<ActionResult<IReadOnlyList<StoryDto>>> GetStories(CancellationToken cancellationToken) + { + var userId = User.GetRequiredUserId(); + var peerAuthorIds = await LoadStoryPeerAuthorIdsAsync(userId, cancellationToken); + var now = DateTimeOffset.UtcNow; + + var stories = await dbContext.Stories + .AsNoTracking() + .Include(x => x.AuthorUser) + .ThenInclude(x => x.Sessions) + .Include(x => x.Attachments) + .Include(x => x.Views) + .Where(x => + x.DeletedAt == null && + x.ExpiresAt > now) + .WhereVisibleTo(userId, peerAuthorIds) + .OrderByDescending(x => x.CreatedAt) + .Take(100) + .ToListAsync(cancellationToken); + + return Ok(stories.Select(x => x.ToDto(presenceTracker, userId)).ToList()); + } + + [HttpGet("{storyId:guid}")] + public async Task<ActionResult<StoryDto>> GetStory(Guid storyId, CancellationToken cancellationToken) + { + var userId = User.GetRequiredUserId(); + var peerAuthorIds = await LoadStoryPeerAuthorIdsAsync(userId, cancellationToken); + var now = DateTimeOffset.UtcNow; + + var story = await dbContext.Stories + .Include(x => x.AuthorUser) + .ThenInclude(x => x.Sessions) + .Include(x => x.Attachments) + .Include(x => x.Views) + .Where(x => + x.Id == storyId && + x.DeletedAt == null && + x.ExpiresAt > now) + .WhereVisibleTo(userId, peerAuthorIds) + .SingleOrDefaultAsync(cancellationToken); + if (story is null) + { + return NotFound(); + } + + if (story.AuthorUserId != userId && story.Views.All(x => x.ViewerUserId != userId)) + { + story.Views.Add(new StoryView + { + StoryId = story.Id, + ViewerUserId = userId, + ViewedAt = DateTimeOffset.UtcNow + }); + await dbContext.SaveChangesAsync(cancellationToken); + } + + return Ok(story.ToDto(presenceTracker, userId)); + } + + [HttpPost] + public async Task<ActionResult<StoryDto>> CreateStory(CreateStoryRequest request, CancellationToken cancellationToken) + { + var userId = User.GetRequiredUserId(); + var text = request.Text?.Trim() ?? string.Empty; + if (string.IsNullOrWhiteSpace(text)) + { + return BadRequest("Story text is required for MVP stories."); + } + + var now = DateTimeOffset.UtcNow; + var ttlSeconds = Math.Clamp(request.TtlSeconds, 60, 7 * 24 * 60 * 60); + var story = new Story + { + AuthorUserId = userId, + Text = text.Length <= 4096 ? text : text[..4096], + Visibility = request.Visibility, + HasProtectedContent = request.HasProtectedContent, + CreatedAt = now, + ExpiresAt = now.AddSeconds(ttlSeconds) + }; + + dbContext.Stories.Add(story); + await AddModerationLogAsync(userId, ModerationLogTargetType.Story, story.Id, "story.created", cancellationToken); + await dbContext.SaveChangesAsync(cancellationToken); + + var loaded = await LoadStoryForAuthorAsync(story.Id, userId, cancellationToken) + ?? throw new InvalidOperationException("The created story could not be reloaded."); + return Ok(loaded.ToDto(presenceTracker, userId)); + } + + [HttpPost("media")] + [RequestFormLimits(MultipartBodyLengthLimit = MaxStoryUploadSizeBytes)] + [RequestSizeLimit(MaxStoryUploadSizeBytes)] + public async Task<ActionResult<StoryDto>> CreateMediaStory( + [FromForm] UploadStoryRequest request, + CancellationToken cancellationToken) + { + var userId = User.GetRequiredUserId(); + var files = request.Files + .Where(file => file.Length > 0) + .ToList(); + var text = request.Text?.Trim() ?? string.Empty; + + if (files.Count == 0 && string.IsNullOrWhiteSpace(text)) + { + return BadRequest("Story text or media is required."); + } + + if (files.Count > MaxStoryAttachmentCount) + { + return BadRequest($"A story can contain at most {MaxStoryAttachmentCount} files."); + } + + var totalBytes = files.Sum(x => x.Length); + if (totalBytes > MaxStoryUploadSizeBytes) + { + return BadRequest($"Story upload is limited to {MaxStoryUploadSizeBytes} bytes."); + } + + var now = DateTimeOffset.UtcNow; + var ttlSeconds = Math.Clamp(request.TtlSeconds, 60, 7 * 24 * 60 * 60); + var story = new Story + { + AuthorUserId = userId, + Text = text.Length <= 4096 ? text : text[..4096], + Visibility = request.Visibility, + HasProtectedContent = request.HasProtectedContent, + CreatedAt = now, + ExpiresAt = now.AddSeconds(ttlSeconds) + }; + + dbContext.Stories.Add(story); + var savedAttachments = new List<StoryAttachment>(); + try + { + for (var index = 0; index < files.Count; index++) + { + var attachment = await attachmentStorageService.SaveStoryAsync(story.Id, files[index], cancellationToken, index); + savedAttachments.Add(attachment); + story.Attachments.Add(attachment); + } + + await AddModerationLogAsync(userId, ModerationLogTargetType.Story, story.Id, "story.media.created", cancellationToken); + await dbContext.SaveChangesAsync(cancellationToken); + } + catch (Exception ex) when (ex is IOException or InvalidOperationException) + { + foreach (var attachment in savedAttachments) + { + attachmentStorageService.DeleteIfExists(attachment.StoragePath); + } + + return BadRequest(ex.Message); + } + + var loaded = await LoadStoryForAuthorAsync(story.Id, userId, cancellationToken) + ?? throw new InvalidOperationException("The created story could not be reloaded."); + return Ok(loaded.ToDto(presenceTracker, userId)); + } + + [HttpDelete("{storyId:guid}")] + public async Task<IActionResult> DeleteStory(Guid storyId, CancellationToken cancellationToken) + { + var userId = User.GetRequiredUserId(); + var story = await dbContext.Stories + .Include(x => x.Attachments) + .SingleOrDefaultAsync(x => x.Id == storyId && x.AuthorUserId == userId, cancellationToken); + if (story is null) + { + return NotFound(); + } + + if (story.DeletedAt is null) + { + foreach (var attachment in story.Attachments) + { + attachmentStorageService.DeleteIfExists(attachment.StoragePath); + } + + dbContext.StoryAttachments.RemoveRange(story.Attachments); + story.Attachments.Clear(); + story.Text = string.Empty; + story.DeletedAt = DateTimeOffset.UtcNow; + await AddModerationLogAsync(userId, ModerationLogTargetType.Story, story.Id, "story.deleted", cancellationToken); + await dbContext.SaveChangesAsync(cancellationToken); + } + + return NoContent(); + } + + [HttpGet("{storyId:guid}/views")] + public async Task<ActionResult<IReadOnlyList<StoryViewDto>>> GetStoryViews(Guid storyId, CancellationToken cancellationToken) + { + var userId = User.GetRequiredUserId(); + var storyExists = await dbContext.Stories + .AsNoTracking() + .AnyAsync(x => x.Id == storyId && x.AuthorUserId == userId, cancellationToken); + if (!storyExists) + { + return NotFound(); + } + + var views = await dbContext.StoryViews + .AsNoTracking() + .Include(x => x.ViewerUser) + .ThenInclude(x => x.Sessions) + .Where(x => x.StoryId == storyId) + .OrderByDescending(x => x.ViewedAt) + .Take(250) + .ToListAsync(cancellationToken); + + return Ok(views + .Select(x => new StoryViewDto(x.StoryId, x.ViewerUser.ToDto(presenceTracker), x.ViewedAt)) + .ToList()); + } + + [HttpGet("{storyId:guid}/attachments/{attachmentId:guid}/content")] + public async Task<IActionResult> DownloadAttachment(Guid storyId, Guid attachmentId, CancellationToken cancellationToken) + { + var userId = User.GetRequiredUserId(); + var peerAuthorIds = await LoadStoryPeerAuthorIdsAsync(userId, cancellationToken); + var now = DateTimeOffset.UtcNow; + var attachment = await dbContext.StoryAttachments + .Include(x => x.Story) + .Where( + x => x.Id == attachmentId && + x.StoryId == storyId && + x.Story.DeletedAt == null && + x.Story.ExpiresAt > now) + .WhereStoryVisibleTo(userId, peerAuthorIds) + .SingleOrDefaultAsync(cancellationToken); + if (attachment is null || !System.IO.File.Exists(attachment.StoragePath)) + { + return NotFound(); + } + + var stream = System.IO.File.OpenRead(attachment.StoragePath); + return File(stream, string.IsNullOrWhiteSpace(attachment.ContentType) ? MediaTypeNames.Application.Octet : attachment.ContentType, attachment.OriginalFileName); + } + + private async Task<List<Guid>> LoadStoryPeerAuthorIdsAsync(Guid userId, CancellationToken cancellationToken) + { + var peerIds = await dbContext.ChatMembers + .AsNoTracking() + .Where(x => x.UserId == userId) + .Join( + dbContext.ChatMembers.AsNoTracking(), + mine => mine.ChatId, + other => other.ChatId, + (_, other) => other.UserId) + .Distinct() + .ToListAsync(cancellationToken); + + return peerIds + .Where(id => id != userId) + .Distinct() + .ToList(); + } + + private async Task<Story?> LoadStoryForAuthorAsync(Guid storyId, Guid userId, CancellationToken cancellationToken) => + await dbContext.Stories + .Include(x => x.AuthorUser) + .ThenInclude(x => x.Sessions) + .Include(x => x.Attachments) + .Include(x => x.Views) + .SingleOrDefaultAsync(x => x.Id == storyId && x.AuthorUserId == userId, cancellationToken); + + private async Task AddModerationLogAsync( + Guid actorUserId, + ModerationLogTargetType targetType, + Guid targetId, + string action, + CancellationToken cancellationToken) + { + dbContext.ModerationLogEntries.Add(new ModerationLogEntry + { + ActorUserId = actorUserId, + TargetType = targetType, + TargetId = targetId, + Action = action, + CreatedAt = DateTimeOffset.UtcNow + }); + + await Task.CompletedTask; + } +} + +internal static class StoryVisibilityQueryExtensions +{ + public static IQueryable<Story> WhereVisibleTo( + this IQueryable<Story> query, + Guid userId, + IReadOnlyCollection<Guid> peerAuthorIds) => + query.Where(story => + story.AuthorUserId == userId || + story.Visibility == StoryVisibility.Everyone || + ((story.Visibility == StoryVisibility.Contacts || story.Visibility == StoryVisibility.CloseFriends) && + peerAuthorIds.Contains(story.AuthorUserId))); + + public static IQueryable<StoryAttachment> WhereStoryVisibleTo( + this IQueryable<StoryAttachment> query, + Guid userId, + IReadOnlyCollection<Guid> peerAuthorIds) => + query.Where(attachment => + attachment.Story.AuthorUserId == userId || + attachment.Story.Visibility == StoryVisibility.Everyone || + ((attachment.Story.Visibility == StoryVisibility.Contacts || + attachment.Story.Visibility == StoryVisibility.CloseFriends) && + peerAuthorIds.Contains(attachment.Story.AuthorUserId))); +} diff --git a/src/Ikar.Server/Controllers/UploadAttachmentRequest.cs b/src/Ikar.Server/Controllers/UploadAttachmentRequest.cs new file mode 100644 index 00000000..1539734f --- /dev/null +++ b/src/Ikar.Server/Controllers/UploadAttachmentRequest.cs @@ -0,0 +1,46 @@ +using Ikar.Shared; + +namespace Ikar.Server.Controllers; + +public sealed class UploadAttachmentRequest +{ + public string? Text { get; set; } + + public Guid? ReplyToMessageId { get; set; } + + public Guid? TopicId { get; set; } + + public bool? HasProtectedContent { get; set; } + + public int? TtlSeconds { get; set; } + + public IFormFile? File { get; set; } +} + +public sealed class UploadAttachmentAlbumRequest +{ + public string? Text { get; set; } + + public Guid? ReplyToMessageId { get; set; } + + public Guid? TopicId { get; set; } + + public bool? HasProtectedContent { get; set; } + + public int? TtlSeconds { get; set; } + + public List<IFormFile> Files { get; set; } = []; +} + +public sealed class UploadStoryRequest +{ + public string? Text { get; set; } + + public StoryVisibility Visibility { get; set; } = StoryVisibility.Everyone; + + public bool HasProtectedContent { get; set; } + + public int TtlSeconds { get; set; } = 86400; + + public List<IFormFile> Files { get; set; } = []; +} diff --git a/src/Ikar.Server/Controllers/UsersController.cs b/src/Ikar.Server/Controllers/UsersController.cs new file mode 100644 index 00000000..3c051bf8 --- /dev/null +++ b/src/Ikar.Server/Controllers/UsersController.cs @@ -0,0 +1,544 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using System.Security.Claims; +using Ikar.Server.Data; +using Ikar.Server.Data.Entities; +using Ikar.Server.Infrastructure; +using Ikar.Server.Infrastructure.Storage; +using Ikar.Shared; + +namespace Ikar.Server.Controllers; + +[ApiController] +[Authorize] +[Route("api/[controller]")] +public sealed class UsersController( + IkarDbContext dbContext, + PresenceTracker presenceTracker, + AvatarStorageService avatarStorageService) : ControllerBase +{ + private const long MaxAvatarSizeBytes = 5 * 1024 * 1024; + private const int ResolveContactsBatchSize = 500; + private static readonly HashSet<string> AllowedAvatarContentTypes = + [ + "image/gif", + "image/heic", + "image/heif", + "image/jpeg", + "image/png", + "image/webp" + ]; + + private static readonly HashSet<string> AllowedAvatarExtensions = + [ + ".gif", + ".heic", + ".heif", + ".jpeg", + ".jpg", + ".png", + ".webp" + ]; + + [HttpGet("me")] + public async Task<ActionResult<UserSummaryDto>> GetMe(CancellationToken cancellationToken) + { + var userId = User.GetRequiredUserId(); + var user = await dbContext.Users + .Include(x => x.Sessions) + .AsNoTracking() + .SingleAsync(x => x.Id == userId, cancellationToken); + return Ok(user.ToDto(presenceTracker)); + } + + [HttpGet("{userId:guid}")] + public async Task<ActionResult<UserSummaryDto>> GetUser(Guid userId, CancellationToken cancellationToken) + { + var currentUserId = User.GetRequiredUserId(); + var user = await dbContext.Users + .Include(x => x.Sessions) + .AsNoTracking() + .SingleOrDefaultAsync(x => x.Id == userId && !x.IsBlocked, cancellationToken); + + if (user is null) + { + return NotFound(); + } + + if (user.Id != currentUserId && !user.IsBot) + { + var hasSharedChat = await dbContext.ChatMembers + .AsNoTracking() + .Where(x => x.UserId == currentUserId) + .Join( + dbContext.ChatMembers.AsNoTracking().Where(x => x.UserId == userId), + left => left.ChatId, + right => right.ChatId, + (left, _) => left.ChatId) + .AnyAsync(cancellationToken); + + if (!hasSharedChat) + { + return NotFound(); + } + } + + return Ok(user.ToDto(presenceTracker)); + } + + [HttpGet("{userId:guid}/bot-commands")] + public async Task<ActionResult<IReadOnlyList<BotCommandDto>>> GetBotCommands( + Guid userId, + CancellationToken cancellationToken) + { + var botUser = await dbContext.Users + .AsNoTracking() + .Include(x => x.BotProfile) + .ThenInclude(x => x!.Commands) + .SingleOrDefaultAsync(x => x.Id == userId && x.IsBot && !x.IsBlocked, cancellationToken); + + if (botUser?.BotProfile is null || !botUser.BotProfile.IsEnabled) + { + return NotFound(); + } + + return Ok(botUser.BotProfile.Commands + .OrderBy(x => x.SortOrder) + .Select(x => new BotCommandDto(x.Command, x.Description)) + .ToList()); + } + + [HttpPut("me")] + public async Task<ActionResult<UserSummaryDto>> UpdateMe( + UpdateMyProfileRequest request, + CancellationToken cancellationToken) + { + var userId = User.GetRequiredUserId(); + var displayName = request.DisplayName?.Trim() ?? string.Empty; + var about = string.IsNullOrWhiteSpace(request.About) + ? null + : request.About.Trim(); + + if (string.IsNullOrWhiteSpace(displayName)) + { + return ValidationProblem("Display name is required."); + } + + if (displayName.Length > 120) + { + return ValidationProblem("Display name must be 120 characters or fewer."); + } + + if (about?.Length > 70) + { + return ValidationProblem("About must be 70 characters or fewer."); + } + + var user = await dbContext.Users + .Include(x => x.Sessions) + .SingleAsync(x => x.Id == userId, cancellationToken); + + user.DisplayName = displayName; + user.About = about; + await dbContext.SaveChangesAsync(cancellationToken); + + return Ok(user.ToDto(presenceTracker)); + } + + [HttpPost("me/avatar")] + public async Task<ActionResult<UserSummaryDto>> UploadMyAvatar( + [FromForm] IFormFile? file, + CancellationToken cancellationToken) + { + if (file is null) + { + return ValidationProblem("Avatar file is required."); + } + + if (file.Length <= 0) + { + return ValidationProblem("Avatar file is empty."); + } + + if (file.Length > MaxAvatarSizeBytes) + { + return ValidationProblem("Avatar file must be 5 MB or smaller."); + } + + if (!IsSupportedAvatar(file)) + { + return ValidationProblem("Avatar must be PNG, JPG, HEIC, HEIF, WEBP or GIF."); + } + + var userId = User.GetRequiredUserId(); + var user = await dbContext.Users + .Include(x => x.Sessions) + .SingleAsync(x => x.Id == userId, cancellationToken); + + var previousAvatarPath = user.AvatarStoragePath; + var storedAvatar = await avatarStorageService.SaveAsync(userId, file, cancellationToken); + + user.AvatarStoragePath = storedAvatar.PhysicalPath; + user.AvatarContentType = storedAvatar.ContentType; + await dbContext.SaveChangesAsync(cancellationToken); + + avatarStorageService.DeleteIfExists(previousAvatarPath); + return Ok(user.ToDto(presenceTracker)); + } + + [HttpDelete("me/avatar")] + public async Task<ActionResult<UserSummaryDto>> DeleteMyAvatar(CancellationToken cancellationToken) + { + var userId = User.GetRequiredUserId(); + var user = await dbContext.Users + .Include(x => x.Sessions) + .SingleAsync(x => x.Id == userId, cancellationToken); + + var previousAvatarPath = user.AvatarStoragePath; + user.AvatarStoragePath = null; + user.AvatarContentType = null; + await dbContext.SaveChangesAsync(cancellationToken); + + avatarStorageService.DeleteIfExists(previousAvatarPath); + return Ok(user.ToDto(presenceTracker)); + } + + [HttpGet("me/privacy")] + public async Task<ActionResult<PrivacySettingsDto>> GetMyPrivacySettings(CancellationToken cancellationToken) + { + var settings = await GetOrCreatePrivacySettingsAsync(User.GetRequiredUserId(), cancellationToken); + return Ok(ToPrivacyDto(settings)); + } + + [HttpPut("me/privacy")] + public async Task<ActionResult<PrivacySettingsDto>> UpdateMyPrivacySettings( + UpdatePrivacySettingsRequest request, + CancellationToken cancellationToken) + { + var settings = await GetOrCreatePrivacySettingsAsync(User.GetRequiredUserId(), cancellationToken); + settings.PhoneNumberVisibility = NormalizePrivacyVisibility(request.PhoneNumberVisibility, fallback: "contacts"); + settings.LastSeenVisibility = NormalizePrivacyVisibility(request.LastSeenVisibility, fallback: "contacts"); + settings.ProfilePhotoVisibility = NormalizePrivacyVisibility(request.ProfilePhotoVisibility, fallback: "everyone"); + if (request.BlockedUserIds is not null) + { + settings.BlockedUserIds = SerializeUserIdList(request.BlockedUserIds, userId: User.GetRequiredUserId()); + } + + if (request.AlwaysAllowUserIds is not null) + { + settings.AlwaysAllowUserIds = SerializeUserIdList(request.AlwaysAllowUserIds, userId: User.GetRequiredUserId()); + } + + if (request.NeverAllowUserIds is not null) + { + settings.NeverAllowUserIds = SerializeUserIdList(request.NeverAllowUserIds, userId: User.GetRequiredUserId()); + } + + settings.PasscodeLockEnabled = request.PasscodeLockEnabled; + settings.ShowMessagePreview = request.ShowMessagePreview; + settings.UpdatedAt = DateTimeOffset.UtcNow; + await dbContext.SaveChangesAsync(cancellationToken); + return Ok(ToPrivacyDto(settings)); + } + + [HttpGet("me/sessions")] + public async Task<ActionResult<IReadOnlyList<UserSessionDto>>> GetMySessions(CancellationToken cancellationToken) + { + var userId = User.GetRequiredUserId(); + var currentSessionId = User.GetRequiredSessionId(); + var sessions = await dbContext.UserSessions + .AsNoTracking() + .Where(x => x.UserId == userId) + .OrderByDescending(x => x.LastSeenAt) + .Select(x => new UserSessionDto( + x.Id, + x.CreatedAt, + x.LastSeenAt, + x.Id == currentSessionId, + x.Id == currentSessionId ? "This device" : "Ikar session")) + .ToListAsync(cancellationToken); + return Ok(sessions); + } + + [HttpDelete("me/sessions/{sessionId:guid}")] + public async Task<IActionResult> RevokeMySession(Guid sessionId, CancellationToken cancellationToken) + { + var userId = User.GetRequiredUserId(); + var session = await dbContext.UserSessions + .SingleOrDefaultAsync(x => x.Id == sessionId && x.UserId == userId, cancellationToken); + if (session is null) + { + return NotFound(); + } + + dbContext.UserSessions.Remove(session); + await dbContext.SaveChangesAsync(cancellationToken); + return NoContent(); + } + + [AllowAnonymous] + [HttpGet("{userId:guid}/avatar/{avatarFileName}")] + public async Task<IActionResult> GetAvatar( + Guid userId, + string avatarFileName, + CancellationToken cancellationToken) + { + var avatar = await dbContext.Users + .AsNoTracking() + .Where(x => x.Id == userId) + .Select(x => new + { + x.AvatarStoragePath, + x.AvatarContentType, + ProfilePhotoVisibility = x.PrivacySettings == null ? "everyone" : x.PrivacySettings.ProfilePhotoVisibility, + AlwaysAllowUserIds = x.PrivacySettings == null ? string.Empty : x.PrivacySettings.AlwaysAllowUserIds, + NeverAllowUserIds = x.PrivacySettings == null ? string.Empty : x.PrivacySettings.NeverAllowUserIds + }) + .SingleOrDefaultAsync(cancellationToken); + + if (avatar is null || string.IsNullOrWhiteSpace(avatar.AvatarStoragePath)) + { + return NotFound(); + } + + var storedFileName = Path.GetFileName(avatar.AvatarStoragePath); + if (!string.Equals(storedFileName, avatarFileName, StringComparison.OrdinalIgnoreCase) || + !System.IO.File.Exists(avatar.AvatarStoragePath)) + { + return NotFound(); + } + + var viewerUserId = TryGetAuthenticatedUserId(); + if (!await CanViewAvatarAsync( + ownerUserId: userId, + viewerUserId, + avatar.ProfilePhotoVisibility, + avatar.AlwaysAllowUserIds, + avatar.NeverAllowUserIds, + cancellationToken)) + { + return NotFound(); + } + + return PhysicalFile( + avatar.AvatarStoragePath, + string.IsNullOrWhiteSpace(avatar.AvatarContentType) ? "application/octet-stream" : avatar.AvatarContentType); + } + + [HttpGet("search")] + public async Task<ActionResult<IReadOnlyList<UserSummaryDto>>> Search([FromQuery] string? q, CancellationToken cancellationToken) + { + var userId = User.GetRequiredUserId(); + var query = q?.Trim(); + var normalizedPhone = PhoneNumberNormalizer.Normalize(query); + + var usersQuery = dbContext.Users + .AsNoTracking() + .Where(x => x.Id != userId && !x.IsBlocked); + if (!string.IsNullOrWhiteSpace(query)) + { + var normalized = query.ToUpperInvariant(); + usersQuery = usersQuery.Where(x => + x.NormalizedUsername.Contains(normalized) || + EF.Functions.Like(x.DisplayName, $"%{query}%") || + (normalizedPhone != null && x.NormalizedPhoneNumber == normalizedPhone)); + } + + var users = await usersQuery + .Include(x => x.Sessions) + .OrderBy(x => x.DisplayName) + .Take(25) + .ToListAsync(cancellationToken); + + return Ok(users.Select(x => x.ToDto(presenceTracker)).ToList()); + } + + [HttpPost("contacts/resolve")] + public async Task<ActionResult<IReadOnlyList<ContactMatchDto>>> ResolveContacts( + ResolveContactsRequest request, + CancellationToken cancellationToken) + { + var userId = User.GetRequiredUserId(); + var normalizedPhones = request.PhoneNumbers + .Select(PhoneNumberNormalizer.Normalize) + .Where(x => !string.IsNullOrWhiteSpace(x)) + .Distinct(StringComparer.Ordinal) + .ToList(); + + if (normalizedPhones.Count == 0) + { + return Ok(Array.Empty<ContactMatchDto>()); + } + + var phoneOrder = normalizedPhones + .Select((phone, index) => new { phone, index }) + .ToDictionary(x => x.phone!, x => x.index, StringComparer.Ordinal); + + var users = new List<Data.Entities.User>(normalizedPhones.Count); + foreach (var batch in normalizedPhones.Chunk(ResolveContactsBatchSize)) + { + var batchPhones = batch.ToList(); + var batchUsers = await dbContext.Users + .Include(x => x.Sessions) + .AsNoTracking() + .Where(x => + x.Id != userId && + !x.IsBlocked && + x.NormalizedPhoneNumber != null && + batchPhones.Contains(x.NormalizedPhoneNumber)) + .ToListAsync(cancellationToken); + + users.AddRange(batchUsers); + } + + var matches = users + .Where(x => x.NormalizedPhoneNumber is not null) + .OrderBy(x => phoneOrder[x.NormalizedPhoneNumber!]) + .Select(x => new ContactMatchDto( + x.NormalizedPhoneNumber!, + x.ToDto(presenceTracker))) + .ToList(); + + return Ok(matches); + } + + private async Task<UserPrivacySettings> GetOrCreatePrivacySettingsAsync(Guid userId, CancellationToken cancellationToken) + { + var settings = await dbContext.UserPrivacySettings + .SingleOrDefaultAsync(x => x.UserId == userId, cancellationToken); + if (settings is not null) + { + return settings; + } + + settings = new UserPrivacySettings + { + UserId = userId, + PhoneNumberVisibility = "contacts", + LastSeenVisibility = "contacts", + ProfilePhotoVisibility = "everyone", + BlockedUserIds = string.Empty, + AlwaysAllowUserIds = string.Empty, + NeverAllowUserIds = string.Empty, + ShowMessagePreview = true, + UpdatedAt = DateTimeOffset.UtcNow + }; + dbContext.UserPrivacySettings.Add(settings); + await dbContext.SaveChangesAsync(cancellationToken); + return settings; + } + + private static PrivacySettingsDto ToPrivacyDto(UserPrivacySettings settings) => + new( + settings.PhoneNumberVisibility, + settings.LastSeenVisibility, + settings.ProfilePhotoVisibility, + DeserializeUserIdList(settings.BlockedUserIds), + DeserializeUserIdList(settings.AlwaysAllowUserIds), + DeserializeUserIdList(settings.NeverAllowUserIds), + settings.PasscodeLockEnabled, + settings.ShowMessagePreview, + settings.UpdatedAt); + + private static string NormalizePrivacyVisibility(string? value, string fallback) + { + var normalized = value?.Trim().ToLowerInvariant(); + return normalized is "everyone" or "contacts" or "nobody" ? normalized : fallback; + } + + private static string SerializeUserIdList(IEnumerable<Guid> userIds, Guid userId) => + string.Join( + ',', + userIds + .Where(x => x != Guid.Empty && x != userId) + .Distinct() + .Take(500) + .Select(x => x.ToString("D"))); + + private Guid? TryGetAuthenticatedUserId() + { + var value = User.FindFirstValue(ClaimTypes.NameIdentifier); + return Guid.TryParse(value, out var userId) ? userId : null; + } + + private async Task<bool> CanViewAvatarAsync( + Guid ownerUserId, + Guid? viewerUserId, + string? profilePhotoVisibility, + string? alwaysAllowUserIds, + string? neverAllowUserIds, + CancellationToken cancellationToken) + { + if (viewerUserId == ownerUserId) + { + return true; + } + + var neverAllow = DeserializeUserIdList(neverAllowUserIds).ToHashSet(); + if (viewerUserId is not null && neverAllow.Contains(viewerUserId.Value)) + { + return false; + } + + var visibility = NormalizePrivacyVisibility(profilePhotoVisibility, fallback: "everyone"); + if (viewerUserId is null) + { + return visibility == "everyone" && neverAllow.Count == 0; + } + + var alwaysAllow = DeserializeUserIdList(alwaysAllowUserIds).ToHashSet(); + if (alwaysAllow.Contains(viewerUserId.Value)) + { + return true; + } + + return visibility switch + { + "everyone" => true, + "contacts" => await HasSharedChatAsync(ownerUserId, viewerUserId.Value, cancellationToken), + _ => false + }; + } + + private async Task<bool> HasSharedChatAsync( + Guid firstUserId, + Guid secondUserId, + CancellationToken cancellationToken) => + await dbContext.ChatMembers + .AsNoTracking() + .Where(x => x.UserId == firstUserId) + .Join( + dbContext.ChatMembers.AsNoTracking().Where(x => x.UserId == secondUserId), + left => left.ChatId, + right => right.ChatId, + (left, _) => left.ChatId) + .AnyAsync(cancellationToken); + + private static IReadOnlyList<Guid> DeserializeUserIdList(string? value) + { + if (string.IsNullOrWhiteSpace(value)) + { + return []; + } + + return value + .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .Select(x => Guid.TryParse(x, out var parsed) ? parsed : Guid.Empty) + .Where(x => x != Guid.Empty) + .Distinct() + .ToList(); + } + + private static bool IsSupportedAvatar(IFormFile file) + { + var extension = Path.GetExtension(file.FileName ?? string.Empty).ToLowerInvariant(); + if (!string.IsNullOrWhiteSpace(extension) && AllowedAvatarExtensions.Contains(extension)) + { + return true; + } + + return !string.IsNullOrWhiteSpace(file.ContentType) && + AllowedAvatarContentTypes.Contains(file.ContentType.Trim().ToLowerInvariant()); + } +} diff --git a/src/Ikar.Server/Data/Entities/Entities.cs b/src/Ikar.Server/Data/Entities/Entities.cs new file mode 100644 index 00000000..38faddcc --- /dev/null +++ b/src/Ikar.Server/Data/Entities/Entities.cs @@ -0,0 +1,338 @@ +using Ikar.Shared; + +namespace Ikar.Server.Data.Entities; + +public sealed class User +{ + public Guid Id { get; set; } = Guid.NewGuid(); + public string Username { get; set; } = string.Empty; + public string NormalizedUsername { get; set; } = string.Empty; + public string DisplayName { get; set; } = string.Empty; + public string PasswordHash { get; set; } = string.Empty; + public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow; + public bool IsBlocked { get; set; } + public DateTimeOffset? BlockedAt { get; set; } + public string? PhoneNumber { get; set; } + public string? NormalizedPhoneNumber { get; set; } + public string? About { get; set; } + public string? AvatarStoragePath { get; set; } + public string? AvatarContentType { get; set; } + public bool IsBot { get; set; } + + public ICollection<UserSession> Sessions { get; set; } = new List<UserSession>(); + public UserPrivacySettings? PrivacySettings { get; set; } + public ICollection<ChatMember> ChatMemberships { get; set; } = new List<ChatMember>(); + public ICollection<Chat> CreatedChats { get; set; } = new List<Chat>(); + public ICollection<Message> Messages { get; set; } = new List<Message>(); + public ICollection<MessageReaction> MessageReactions { get; set; } = new List<MessageReaction>(); + public ICollection<PushDevice> PushDevices { get; set; } = new List<PushDevice>(); + public ICollection<Bot> OwnedBots { get; set; } = new List<Bot>(); + public Bot? BotProfile { get; set; } +} + +public sealed class UserPrivacySettings +{ + public Guid UserId { get; set; } + public User User { get; set; } = null!; + public string PhoneNumberVisibility { get; set; } = "contacts"; + public string LastSeenVisibility { get; set; } = "contacts"; + public string ProfilePhotoVisibility { get; set; } = "everyone"; + public string BlockedUserIds { get; set; } = string.Empty; + public string AlwaysAllowUserIds { get; set; } = string.Empty; + public string NeverAllowUserIds { get; set; } = string.Empty; + public bool PasscodeLockEnabled { get; set; } + public bool ShowMessagePreview { get; set; } = true; + public DateTimeOffset UpdatedAt { get; set; } = DateTimeOffset.UtcNow; +} + +public sealed class UserSession +{ + public Guid Id { get; set; } = Guid.NewGuid(); + public Guid UserId { get; set; } + public User User { get; set; } = null!; + public string TokenHash { get; set; } = string.Empty; + public string? ClientPlatform { get; set; } + public int? ClientVersionCode { get; set; } + public string? ClientVersionName { get; set; } + public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow; + public DateTimeOffset LastSeenAt { get; set; } = DateTimeOffset.UtcNow; +} + +public sealed class Chat +{ + public Guid Id { get; set; } = Guid.NewGuid(); + public ChatType Type { get; set; } + public string? Title { get; set; } + public string? Description { get; set; } + public string? PublicUsername { get; set; } + public Guid? LinkedDiscussionChatId { get; set; } + public Chat? LinkedDiscussionChat { get; set; } + public bool ChannelSignaturesEnabled { get; set; } + public bool TopicsEnabled { get; set; } + public bool HasProtectedContent { get; set; } + public int? DefaultMessageTtlSeconds { get; set; } + public Guid CreatedById { get; set; } + public User CreatedBy { get; set; } = null!; + public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow; + public DateTimeOffset? LastActivityAt { get; set; } + + public ICollection<ChatMember> Members { get; set; } = new List<ChatMember>(); + public ICollection<Message> Messages { get; set; } = new List<Message>(); + public ICollection<ChatInviteLink> InviteLinks { get; set; } = new List<ChatInviteLink>(); + public ICollection<ChatJoinRequest> JoinRequests { get; set; } = new List<ChatJoinRequest>(); + public ICollection<ChatTopic> Topics { get; set; } = new List<ChatTopic>(); +} + +public sealed class ChatInviteLink +{ + public Guid Id { get; set; } = Guid.NewGuid(); + public Guid ChatId { get; set; } + public Chat Chat { get; set; } = null!; + public Guid CreatedById { get; set; } + public User CreatedBy { get; set; } = null!; + public string Token { get; set; } = string.Empty; + public string? Title { get; set; } + public bool CreatesJoinRequest { get; set; } + public bool IsRevoked { get; set; } + public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow; + public DateTimeOffset? RevokedAt { get; set; } +} + +public sealed class ChatTopic +{ + public Guid Id { get; set; } = Guid.NewGuid(); + public Guid ChatId { get; set; } + public Chat Chat { get; set; } = null!; + public Guid CreatedById { get; set; } + public User CreatedBy { get; set; } = null!; + public string Title { get; set; } = string.Empty; + public string Icon { get; set; } = "💬"; + public bool IsPinned { get; set; } + public bool IsClosed { get; set; } + public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow; + public DateTimeOffset LastActivityAt { get; set; } = DateTimeOffset.UtcNow; +} + +public sealed class ChatJoinRequest +{ + public Guid Id { get; set; } = Guid.NewGuid(); + public Guid ChatId { get; set; } + public Chat Chat { get; set; } = null!; + public Guid InviteLinkId { get; set; } + public ChatInviteLink InviteLink { get; set; } = null!; + public Guid UserId { get; set; } + public User User { get; set; } = null!; + public ChatJoinRequestStatus Status { get; set; } = ChatJoinRequestStatus.Pending; + public DateTimeOffset RequestedAt { get; set; } = DateTimeOffset.UtcNow; + public DateTimeOffset? ResolvedAt { get; set; } + public Guid? ResolvedByUserId { get; set; } + public User? ResolvedByUser { get; set; } +} + +public sealed class ChatMember +{ + public Guid ChatId { get; set; } + public Chat Chat { get; set; } = null!; + public Guid UserId { get; set; } + public User User { get; set; } = null!; + public bool IsOwner { get; set; } + public ChatMemberRole Role { get; set; } = ChatMemberRole.Member; + public bool CanPinMessages { get; set; } = true; + public bool CanDeleteMessages { get; set; } + public bool CanInviteUsers { get; set; } = true; + public bool CanPostMessages { get; set; } = true; + public bool IsArchived { get; set; } + public bool IsPinned { get; set; } + public DateTimeOffset? MutedUntil { get; set; } + public string? FolderKey { get; set; } + public DateTimeOffset JoinedAt { get; set; } = DateTimeOffset.UtcNow; + public DateTimeOffset? LastDeliveredAt { get; set; } + public DateTimeOffset? LastReadAt { get; set; } +} + +public sealed class Message +{ + public Guid Id { get; set; } = Guid.NewGuid(); + public Guid ChatId { get; set; } + public Chat Chat { get; set; } = null!; + public Guid SenderId { get; set; } + public User Sender { get; set; } = null!; + public string Text { get; set; } = string.Empty; + public DateTimeOffset SentAt { get; set; } = DateTimeOffset.UtcNow; + public DateTimeOffset? EditedAt { get; set; } + public DateTimeOffset? DeletedAt { get; set; } + public string? ForwardedFromDisplayName { get; set; } + public Guid? ReplyToMessageId { get; set; } + public Message? ReplyToMessage { get; set; } + public DateTimeOffset? PinnedAt { get; set; } + public Guid? PinnedByUserId { get; set; } + public User? PinnedByUser { get; set; } + public Guid? MediaAlbumId { get; set; } + public Guid? TopicId { get; set; } + public ChatTopic? Topic { get; set; } + public bool HasProtectedContent { get; set; } + public DateTimeOffset? ExpiresAt { get; set; } + public int? TtlSeconds { get; set; } + public bool PostedAsChannel { get; set; } + public string? AuthorSignature { get; set; } + + public ICollection<Message> Replies { get; set; } = new List<Message>(); + public ICollection<MessageAttachment> Attachments { get; set; } = new List<MessageAttachment>(); + public ICollection<MessageReaction> Reactions { get; set; } = new List<MessageReaction>(); + public ICollection<MessageView> Views { get; set; } = new List<MessageView>(); + public ICollection<BotUpdate> BotUpdates { get; set; } = new List<BotUpdate>(); +} + +public sealed class MessageAttachment +{ + public Guid Id { get; set; } = Guid.NewGuid(); + public Guid MessageId { get; set; } + public Message Message { get; set; } = null!; + public string OriginalFileName { get; set; } = string.Empty; + public string ContentType { get; set; } = "application/octet-stream"; + public long FileSizeBytes { get; set; } + public string StoragePath { get; set; } = string.Empty; + public int SortOrder { get; set; } + public DateTimeOffset UploadedAt { get; set; } = DateTimeOffset.UtcNow; +} + +public sealed class MessageReaction +{ + public Guid Id { get; set; } = Guid.NewGuid(); + public Guid MessageId { get; set; } + public Message Message { get; set; } = null!; + public Guid UserId { get; set; } + public User User { get; set; } = null!; + public string Emoji { get; set; } = string.Empty; + public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow; +} + +public sealed class MessageView +{ + public Guid MessageId { get; set; } + public Message Message { get; set; } = null!; + public Guid ViewerUserId { get; set; } + public User ViewerUser { get; set; } = null!; + public DateTimeOffset ViewedAt { get; set; } = DateTimeOffset.UtcNow; +} + +public sealed class PushDevice +{ + public Guid Id { get; set; } = Guid.NewGuid(); + public Guid UserId { get; set; } + public User User { get; set; } = null!; + public PushPlatform Platform { get; set; } + public string InstallationId { get; set; } = string.Empty; + public string DeviceToken { get; set; } = string.Empty; + public string? DeviceName { get; set; } + public string? ClientPlatform { get; set; } + public int? ClientVersionCode { get; set; } + public string? ClientVersionName { get; set; } + public bool NotificationsEnabled { get; set; } + public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow; + public DateTimeOffset UpdatedAt { get; set; } = DateTimeOffset.UtcNow; +} + +public sealed class Bot +{ + public Guid Id { get; set; } = Guid.NewGuid(); + public Guid UserId { get; set; } + public User User { get; set; } = null!; + public Guid OwnerUserId { get; set; } + public User OwnerUser { get; set; } = null!; + public string TokenHash { get; set; } = string.Empty; + public bool IsEnabled { get; set; } = true; + public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow; + public DateTimeOffset UpdatedAt { get; set; } = DateTimeOffset.UtcNow; + + public ICollection<BotCommand> Commands { get; set; } = new List<BotCommand>(); + public ICollection<BotUpdate> Updates { get; set; } = new List<BotUpdate>(); +} + +public sealed class BotCommand +{ + public Guid Id { get; set; } = Guid.NewGuid(); + public Guid BotId { get; set; } + public Bot Bot { get; set; } = null!; + public string Command { get; set; } = string.Empty; + public string Description { get; set; } = string.Empty; + public int SortOrder { get; set; } +} + +public sealed class BotUpdate +{ + public long Id { get; set; } + public Guid BotId { get; set; } + public Bot Bot { get; set; } = null!; + public Guid ChatId { get; set; } + public Chat Chat { get; set; } = null!; + public Guid MessageId { get; set; } + public Message Message { get; set; } = null!; + public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow; +} + +public sealed class ModerationLogEntry +{ + public long Id { get; set; } + public Guid? ChatId { get; set; } + public Chat? Chat { get; set; } + public Guid ActorUserId { get; set; } + public User ActorUser { get; set; } = null!; + public ModerationLogTargetType TargetType { get; set; } + public Guid? TargetId { get; set; } + public string Action { get; set; } = string.Empty; + public string? Reason { get; set; } + public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow; +} + +public sealed class Story +{ + public Guid Id { get; set; } = Guid.NewGuid(); + public Guid AuthorUserId { get; set; } + public User AuthorUser { get; set; } = null!; + public string Text { get; set; } = string.Empty; + public StoryVisibility Visibility { get; set; } = StoryVisibility.Everyone; + public bool HasProtectedContent { get; set; } + public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow; + public DateTimeOffset ExpiresAt { get; set; } = DateTimeOffset.UtcNow.AddHours(24); + public DateTimeOffset? DeletedAt { get; set; } + + public ICollection<StoryAttachment> Attachments { get; set; } = new List<StoryAttachment>(); + public ICollection<StoryView> Views { get; set; } = new List<StoryView>(); +} + +public sealed class StoryAttachment +{ + public Guid Id { get; set; } = Guid.NewGuid(); + public Guid StoryId { get; set; } + public Story Story { get; set; } = null!; + public string OriginalFileName { get; set; } = string.Empty; + public string ContentType { get; set; } = "application/octet-stream"; + public long FileSizeBytes { get; set; } + public string StoragePath { get; set; } = string.Empty; + public int SortOrder { get; set; } + public DateTimeOffset UploadedAt { get; set; } = DateTimeOffset.UtcNow; +} + +public sealed class StoryView +{ + public Guid StoryId { get; set; } + public Story Story { get; set; } = null!; + public Guid ViewerUserId { get; set; } + public User ViewerUser { get; set; } = null!; + public DateTimeOffset ViewedAt { get; set; } = DateTimeOffset.UtcNow; +} + +public sealed class AndroidUpdateCompatibilityHit +{ + public long Id { get; set; } + public DateTimeOffset HitAt { get; set; } = DateTimeOffset.UtcNow; + public string Route { get; set; } = string.Empty; + public string RequestPath { get; set; } = string.Empty; + public string? RequestedFileName { get; set; } + public string? ClientPlatform { get; set; } + public int? ClientVersionCode { get; set; } + public string? ClientVersionName { get; set; } + public string? UserAgent { get; set; } + public string? RemoteIp { get; set; } +} diff --git a/src/Ikar.Server/Data/IkarDbContext.cs b/src/Ikar.Server/Data/IkarDbContext.cs new file mode 100644 index 00000000..8a755627 --- /dev/null +++ b/src/Ikar.Server/Data/IkarDbContext.cs @@ -0,0 +1,495 @@ +using Ikar.Server.Data.Entities; +using System.Globalization; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +namespace Ikar.Server.Data; + +public sealed class IkarDbContext(DbContextOptions<IkarDbContext> options) : DbContext(options) +{ + private static readonly ValueConverter<DateTimeOffset, string> DateTimeOffsetToStringConverter = new( + value => value.ToUniversalTime().ToString("O", CultureInfo.InvariantCulture), + value => DateTimeOffset.Parse(value, CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind)); + + private static readonly ValueConverter<DateTimeOffset?, string?> NullableDateTimeOffsetToStringConverter = new( + value => value.HasValue ? value.Value.ToUniversalTime().ToString("O", CultureInfo.InvariantCulture) : null, + value => value == null ? null : DateTimeOffset.Parse(value, CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind)); + + public DbSet<User> Users => Set<User>(); + public DbSet<UserSession> UserSessions => Set<UserSession>(); + public DbSet<UserPrivacySettings> UserPrivacySettings => Set<UserPrivacySettings>(); + public DbSet<Chat> Chats => Set<Chat>(); + public DbSet<ChatMember> ChatMembers => Set<ChatMember>(); + public DbSet<ChatInviteLink> ChatInviteLinks => Set<ChatInviteLink>(); + public DbSet<ChatJoinRequest> ChatJoinRequests => Set<ChatJoinRequest>(); + public DbSet<ChatTopic> ChatTopics => Set<ChatTopic>(); + public DbSet<Message> Messages => Set<Message>(); + public DbSet<MessageAttachment> MessageAttachments => Set<MessageAttachment>(); + public DbSet<MessageReaction> MessageReactions => Set<MessageReaction>(); + public DbSet<MessageView> MessageViews => Set<MessageView>(); + public DbSet<PushDevice> PushDevices => Set<PushDevice>(); + public DbSet<Bot> Bots => Set<Bot>(); + public DbSet<BotCommand> BotCommands => Set<BotCommand>(); + public DbSet<BotUpdate> BotUpdates => Set<BotUpdate>(); + public DbSet<ModerationLogEntry> ModerationLogEntries => Set<ModerationLogEntry>(); + public DbSet<Story> Stories => Set<Story>(); + public DbSet<StoryAttachment> StoryAttachments => Set<StoryAttachment>(); + public DbSet<StoryView> StoryViews => Set<StoryView>(); + public DbSet<AndroidUpdateCompatibilityHit> AndroidUpdateCompatibilityHits => Set<AndroidUpdateCompatibilityHit>(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + ConfigureUsers(modelBuilder); + ConfigureChats(modelBuilder); + ConfigureMessages(modelBuilder); + ConfigurePushDevices(modelBuilder); + ConfigureBots(modelBuilder); + ConfigureModeration(modelBuilder); + ConfigureStories(modelBuilder); + ConfigureAndroidUpdateCompatibility(modelBuilder); + ConfigureDateTimeOffsetConverters(modelBuilder); + } + + private static void ConfigureDateTimeOffsetConverters(ModelBuilder modelBuilder) + { + foreach (var property in modelBuilder.Model.GetEntityTypes().SelectMany(entityType => entityType.GetProperties())) + { + if (property.ClrType == typeof(DateTimeOffset)) + { + property.SetValueConverter(DateTimeOffsetToStringConverter); + } + else if (property.ClrType == typeof(DateTimeOffset?)) + { + property.SetValueConverter(NullableDateTimeOffsetToStringConverter); + } + } + } + + private static void ConfigureUsers(ModelBuilder modelBuilder) + { + modelBuilder.Entity<User>(entity => + { + entity.ToTable("Users"); + entity.HasKey(x => x.Id); + entity.HasIndex(x => x.NormalizedUsername).IsUnique(); + entity.HasIndex(x => x.NormalizedPhoneNumber) + .IsUnique() + .HasFilter("\"NormalizedPhoneNumber\" IS NOT NULL"); + + entity.Property(x => x.Username).IsRequired(); + entity.Property(x => x.NormalizedUsername).IsRequired(); + entity.Property(x => x.DisplayName).IsRequired(); + entity.Property(x => x.PasswordHash).IsRequired(); + + entity.HasMany(x => x.Sessions) + .WithOne(x => x.User) + .HasForeignKey(x => x.UserId) + .OnDelete(DeleteBehavior.Cascade); + + entity.HasOne(x => x.PrivacySettings) + .WithOne(x => x.User) + .HasForeignKey<UserPrivacySettings>(x => x.UserId) + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity<UserPrivacySettings>(entity => + { + entity.ToTable("UserPrivacySettings"); + entity.HasKey(x => x.UserId); + entity.Property(x => x.PhoneNumberVisibility).IsRequired(); + entity.Property(x => x.LastSeenVisibility).IsRequired(); + entity.Property(x => x.ProfilePhotoVisibility).IsRequired(); + entity.Property(x => x.BlockedUserIds).IsRequired(); + entity.Property(x => x.AlwaysAllowUserIds).IsRequired(); + entity.Property(x => x.NeverAllowUserIds).IsRequired(); + }); + + modelBuilder.Entity<UserSession>(entity => + { + entity.ToTable("UserSessions"); + entity.HasKey(x => x.Id); + entity.HasIndex(x => x.TokenHash).IsUnique(); + entity.HasIndex(x => x.UserId); + entity.Property(x => x.TokenHash).IsRequired(); + entity.Property(x => x.ClientPlatform).HasMaxLength(32); + entity.Property(x => x.ClientVersionName).HasMaxLength(64); + }); + } + + private static void ConfigureChats(ModelBuilder modelBuilder) + { + modelBuilder.Entity<Chat>(entity => + { + entity.ToTable("Chats"); + entity.HasKey(x => x.Id); + entity.HasIndex(x => x.CreatedById); + entity.HasIndex(x => x.LastActivityAt); + entity.HasIndex(x => x.LinkedDiscussionChatId); + entity.HasIndex(x => x.PublicUsername) + .IsUnique() + .HasFilter("\"PublicUsername\" IS NOT NULL"); + + entity.HasOne(x => x.CreatedBy) + .WithMany(x => x.CreatedChats) + .HasForeignKey(x => x.CreatedById) + .OnDelete(DeleteBehavior.Restrict); + + entity.HasMany(x => x.Members) + .WithOne(x => x.Chat) + .HasForeignKey(x => x.ChatId) + .OnDelete(DeleteBehavior.Cascade); + + entity.HasMany(x => x.Messages) + .WithOne(x => x.Chat) + .HasForeignKey(x => x.ChatId) + .OnDelete(DeleteBehavior.Cascade); + + entity.HasMany(x => x.InviteLinks) + .WithOne(x => x.Chat) + .HasForeignKey(x => x.ChatId) + .OnDelete(DeleteBehavior.Cascade); + + entity.HasMany(x => x.JoinRequests) + .WithOne(x => x.Chat) + .HasForeignKey(x => x.ChatId) + .OnDelete(DeleteBehavior.Cascade); + + entity.HasMany(x => x.Topics) + .WithOne(x => x.Chat) + .HasForeignKey(x => x.ChatId) + .OnDelete(DeleteBehavior.Cascade); + + entity.HasOne(x => x.LinkedDiscussionChat) + .WithMany() + .HasForeignKey(x => x.LinkedDiscussionChatId) + .OnDelete(DeleteBehavior.SetNull); + }); + + modelBuilder.Entity<ChatInviteLink>(entity => + { + entity.ToTable("ChatInviteLinks"); + entity.HasKey(x => x.Id); + entity.HasIndex(x => x.ChatId); + entity.HasIndex(x => x.Token).IsUnique(); + entity.Property(x => x.Token).IsRequired(); + + entity.HasOne(x => x.CreatedBy) + .WithMany() + .HasForeignKey(x => x.CreatedById) + .OnDelete(DeleteBehavior.Restrict); + }); + + modelBuilder.Entity<ChatJoinRequest>(entity => + { + entity.ToTable("ChatJoinRequests"); + entity.HasKey(x => x.Id); + entity.HasIndex(x => new { x.ChatId, x.Status }); + entity.HasIndex(x => new { x.InviteLinkId, x.UserId }).IsUnique(); + entity.HasIndex(x => x.UserId); + + entity.HasOne(x => x.InviteLink) + .WithMany() + .HasForeignKey(x => x.InviteLinkId) + .OnDelete(DeleteBehavior.Cascade); + + entity.HasOne(x => x.User) + .WithMany() + .HasForeignKey(x => x.UserId) + .OnDelete(DeleteBehavior.Cascade); + + entity.HasOne(x => x.ResolvedByUser) + .WithMany() + .HasForeignKey(x => x.ResolvedByUserId) + .OnDelete(DeleteBehavior.SetNull); + }); + + modelBuilder.Entity<ChatTopic>(entity => + { + entity.ToTable("ChatTopics"); + entity.HasKey(x => x.Id); + entity.HasIndex(x => new { x.ChatId, x.IsPinned }); + entity.HasIndex(x => new { x.ChatId, x.LastActivityAt }); + entity.Property(x => x.Title).IsRequired(); + entity.Property(x => x.Icon).IsRequired(); + + entity.HasOne(x => x.CreatedBy) + .WithMany() + .HasForeignKey(x => x.CreatedById) + .OnDelete(DeleteBehavior.Restrict); + }); + + modelBuilder.Entity<ChatMember>(entity => + { + entity.ToTable("ChatMembers"); + entity.HasKey(x => new { x.ChatId, x.UserId }); + entity.HasIndex(x => x.UserId); + entity.HasIndex(x => new { x.UserId, x.IsPinned }); + entity.HasIndex(x => new { x.UserId, x.IsArchived }); + entity.HasIndex(x => new { x.UserId, x.FolderKey }); + + entity.HasOne(x => x.User) + .WithMany(x => x.ChatMemberships) + .HasForeignKey(x => x.UserId) + .OnDelete(DeleteBehavior.Cascade); + }); + } + + private static void ConfigureMessages(ModelBuilder modelBuilder) + { + modelBuilder.Entity<Message>(entity => + { + entity.ToTable("Messages"); + entity.HasKey(x => x.Id); + entity.HasIndex(x => new { x.ChatId, x.SentAt }); + entity.HasIndex(x => new { x.ChatId, x.SenderId, x.SentAt }); + entity.HasIndex(x => x.SenderId); + entity.HasIndex(x => x.ReplyToMessageId); + entity.HasIndex(x => x.TopicId); + entity.HasIndex(x => new { x.ChatId, x.PinnedAt }); + entity.HasIndex(x => x.MediaAlbumId); + entity.HasIndex(x => x.ExpiresAt); + + entity.Property(x => x.Text).IsRequired(); + + entity.HasOne(x => x.Sender) + .WithMany(x => x.Messages) + .HasForeignKey(x => x.SenderId) + .OnDelete(DeleteBehavior.Restrict); + + entity.HasOne(x => x.ReplyToMessage) + .WithMany(x => x.Replies) + .HasForeignKey(x => x.ReplyToMessageId) + .OnDelete(DeleteBehavior.SetNull); + + entity.HasOne(x => x.PinnedByUser) + .WithMany() + .HasForeignKey(x => x.PinnedByUserId) + .OnDelete(DeleteBehavior.SetNull); + + entity.HasOne(x => x.Topic) + .WithMany() + .HasForeignKey(x => x.TopicId) + .OnDelete(DeleteBehavior.SetNull); + + entity.HasMany(x => x.Attachments) + .WithOne(x => x.Message) + .HasForeignKey(x => x.MessageId) + .OnDelete(DeleteBehavior.Cascade); + + entity.HasMany(x => x.Reactions) + .WithOne(x => x.Message) + .HasForeignKey(x => x.MessageId) + .OnDelete(DeleteBehavior.Cascade); + + entity.HasMany(x => x.Views) + .WithOne(x => x.Message) + .HasForeignKey(x => x.MessageId) + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity<MessageAttachment>(entity => + { + entity.ToTable("MessageAttachments"); + entity.HasKey(x => x.Id); + entity.HasIndex(x => x.MessageId); + entity.HasIndex(x => new { x.MessageId, x.SortOrder }); + entity.Property(x => x.OriginalFileName).IsRequired(); + entity.Property(x => x.ContentType).IsRequired(); + entity.Property(x => x.StoragePath).IsRequired(); + }); + + modelBuilder.Entity<MessageReaction>(entity => + { + entity.ToTable("MessageReactions"); + entity.HasKey(x => x.Id); + entity.HasIndex(x => x.MessageId); + entity.HasIndex(x => new { x.MessageId, x.UserId }).IsUnique(); + entity.Property(x => x.Emoji).IsRequired(); + + entity.HasOne(x => x.User) + .WithMany(x => x.MessageReactions) + .HasForeignKey(x => x.UserId) + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity<MessageView>(entity => + { + entity.ToTable("MessageViews"); + entity.HasKey(x => new { x.MessageId, x.ViewerUserId }); + entity.HasIndex(x => x.ViewerUserId); + + entity.HasOne(x => x.ViewerUser) + .WithMany() + .HasForeignKey(x => x.ViewerUserId) + .OnDelete(DeleteBehavior.Cascade); + }); + } + + private static void ConfigureModeration(ModelBuilder modelBuilder) + { + modelBuilder.Entity<ModerationLogEntry>(entity => + { + entity.ToTable("ModerationLogEntries"); + entity.HasKey(x => x.Id); + entity.Property(x => x.Id).ValueGeneratedOnAdd(); + entity.Property(x => x.Action).IsRequired(); + entity.HasIndex(x => new { x.ChatId, x.CreatedAt }); + entity.HasIndex(x => new { x.TargetType, x.TargetId }); + + entity.HasOne(x => x.Chat) + .WithMany() + .HasForeignKey(x => x.ChatId) + .OnDelete(DeleteBehavior.SetNull); + + entity.HasOne(x => x.ActorUser) + .WithMany() + .HasForeignKey(x => x.ActorUserId) + .OnDelete(DeleteBehavior.Restrict); + }); + } + + private static void ConfigureStories(ModelBuilder modelBuilder) + { + modelBuilder.Entity<Story>(entity => + { + entity.ToTable("Stories"); + entity.HasKey(x => x.Id); + entity.HasIndex(x => new { x.AuthorUserId, x.CreatedAt }); + entity.HasIndex(x => x.ExpiresAt); + entity.Property(x => x.Text).IsRequired(); + + entity.HasOne(x => x.AuthorUser) + .WithMany() + .HasForeignKey(x => x.AuthorUserId) + .OnDelete(DeleteBehavior.Cascade); + + entity.HasMany(x => x.Attachments) + .WithOne(x => x.Story) + .HasForeignKey(x => x.StoryId) + .OnDelete(DeleteBehavior.Cascade); + + entity.HasMany(x => x.Views) + .WithOne(x => x.Story) + .HasForeignKey(x => x.StoryId) + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity<StoryAttachment>(entity => + { + entity.ToTable("StoryAttachments"); + entity.HasKey(x => x.Id); + entity.HasIndex(x => new { x.StoryId, x.SortOrder }); + entity.Property(x => x.OriginalFileName).IsRequired(); + entity.Property(x => x.ContentType).IsRequired(); + entity.Property(x => x.StoragePath).IsRequired(); + }); + + modelBuilder.Entity<StoryView>(entity => + { + entity.ToTable("StoryViews"); + entity.HasKey(x => new { x.StoryId, x.ViewerUserId }); + entity.HasIndex(x => x.ViewerUserId); + + entity.HasOne(x => x.ViewerUser) + .WithMany() + .HasForeignKey(x => x.ViewerUserId) + .OnDelete(DeleteBehavior.Cascade); + }); + } + + private static void ConfigurePushDevices(ModelBuilder modelBuilder) + { + modelBuilder.Entity<PushDevice>(entity => + { + entity.ToTable("PushDevices"); + entity.HasKey(x => x.Id); + entity.HasIndex(x => x.InstallationId).IsUnique(); + entity.HasIndex(x => x.UserId); + entity.Property(x => x.InstallationId).IsRequired(); + entity.Property(x => x.DeviceToken).IsRequired(); + entity.Property(x => x.ClientPlatform).HasMaxLength(32); + entity.Property(x => x.ClientVersionName).HasMaxLength(64); + + entity.HasOne(x => x.User) + .WithMany(x => x.PushDevices) + .HasForeignKey(x => x.UserId) + .OnDelete(DeleteBehavior.Cascade); + }); + } + + private static void ConfigureBots(ModelBuilder modelBuilder) + { + modelBuilder.Entity<Bot>(entity => + { + entity.ToTable("Bots"); + entity.HasKey(x => x.Id); + entity.HasIndex(x => x.UserId).IsUnique(); + entity.HasIndex(x => x.OwnerUserId); + entity.HasIndex(x => x.TokenHash).IsUnique(); + entity.Property(x => x.TokenHash).IsRequired(); + + entity.HasOne(x => x.User) + .WithOne(x => x.BotProfile) + .HasForeignKey<Bot>(x => x.UserId) + .OnDelete(DeleteBehavior.Cascade); + + entity.HasOne(x => x.OwnerUser) + .WithMany(x => x.OwnedBots) + .HasForeignKey(x => x.OwnerUserId) + .OnDelete(DeleteBehavior.Restrict); + }); + + modelBuilder.Entity<BotCommand>(entity => + { + entity.ToTable("BotCommands"); + entity.HasKey(x => x.Id); + entity.HasIndex(x => new { x.BotId, x.Command }).IsUnique(); + entity.Property(x => x.Command).IsRequired(); + entity.Property(x => x.Description).IsRequired(); + + entity.HasOne(x => x.Bot) + .WithMany(x => x.Commands) + .HasForeignKey(x => x.BotId) + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity<BotUpdate>(entity => + { + entity.ToTable("BotUpdates"); + entity.HasKey(x => x.Id); + entity.Property(x => x.Id).ValueGeneratedOnAdd(); + entity.HasIndex(x => new { x.BotId, x.Id }); + entity.HasIndex(x => new { x.BotId, x.MessageId }).IsUnique(); + + entity.HasOne(x => x.Bot) + .WithMany(x => x.Updates) + .HasForeignKey(x => x.BotId) + .OnDelete(DeleteBehavior.Cascade); + + entity.HasOne(x => x.Chat) + .WithMany() + .HasForeignKey(x => x.ChatId) + .OnDelete(DeleteBehavior.Cascade); + + entity.HasOne(x => x.Message) + .WithMany(x => x.BotUpdates) + .HasForeignKey(x => x.MessageId) + .OnDelete(DeleteBehavior.Cascade); + }); + } + + private static void ConfigureAndroidUpdateCompatibility(ModelBuilder modelBuilder) + { + modelBuilder.Entity<AndroidUpdateCompatibilityHit>(entity => + { + entity.ToTable("AndroidUpdateCompatibilityHits"); + entity.HasKey(x => x.Id); + entity.Property(x => x.Id).ValueGeneratedOnAdd(); + entity.HasIndex(x => x.HitAt); + entity.HasIndex(x => new { x.Route, x.HitAt }); + entity.HasIndex(x => new { x.ClientPlatform, x.ClientVersionCode, x.HitAt }); + entity.Property(x => x.Route).IsRequired(); + entity.Property(x => x.RequestPath).IsRequired(); + }); + } +} diff --git a/src/Ikar.Server/Ikar.Server.csproj b/src/Ikar.Server/Ikar.Server.csproj new file mode 100644 index 00000000..ce2e83bc --- /dev/null +++ b/src/Ikar.Server/Ikar.Server.csproj @@ -0,0 +1,19 @@ +<Project Sdk="Microsoft.NET.Sdk.Web"> + + <PropertyGroup> + <TargetFramework>net10.0</TargetFramework> + <Nullable>enable</Nullable> + <ImplicitUsings>enable</ImplicitUsings> + </PropertyGroup> + + <ItemGroup> + <PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.4" /> + <PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.4" /> + <PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.4" /> + </ItemGroup> + + <ItemGroup> + <ProjectReference Include="..\Ikar.Shared\Ikar.Shared.csproj" /> + </ItemGroup> + +</Project> diff --git a/src/Ikar.Server/Ikar.Server.http b/src/Ikar.Server/Ikar.Server.http new file mode 100644 index 00000000..36fbba3b --- /dev/null +++ b/src/Ikar.Server/Ikar.Server.http @@ -0,0 +1,6 @@ +@Ikar.Server_HostAddress = http://localhost:5289 + +GET {{Ikar.Server_HostAddress}}/weatherforecast/ +Accept: application/json + +### diff --git a/src/Ikar.Server/Infrastructure/Admin/AdminAuthConstants.cs b/src/Ikar.Server/Infrastructure/Admin/AdminAuthConstants.cs new file mode 100644 index 00000000..5c9a4ba8 --- /dev/null +++ b/src/Ikar.Server/Infrastructure/Admin/AdminAuthConstants.cs @@ -0,0 +1,7 @@ +namespace Ikar.Server.Infrastructure.Admin; + +public static class AdminAuthConstants +{ + public const string Scheme = "AdminCookie"; + public const string Policy = "AdminOnly"; +} diff --git a/src/Ikar.Server/Infrastructure/Admin/AdminDtos.cs b/src/Ikar.Server/Infrastructure/Admin/AdminDtos.cs new file mode 100644 index 00000000..a7c200b6 --- /dev/null +++ b/src/Ikar.Server/Infrastructure/Admin/AdminDtos.cs @@ -0,0 +1,247 @@ +using Ikar.Shared; + +namespace Ikar.Server.Infrastructure.Admin; + +public sealed record AdminSessionStateDto( + bool Configured, + bool Authenticated, + string? Username); + +public sealed record AdminStatusDto( + string Environment, + string ServerVersion, + DateTimeOffset StartedAtUtc, + DateTimeOffset CurrentTimeUtc, + bool DatabaseReachable, + string? DatabaseConnection, + int TotalUsers, + int BlockedUsers, + int BotUsers, + int TotalBots, + int EnabledBots, + int TotalChats, + int DirectChats, + int GroupChats, + int ChannelChats, + int ProtectedChats, + int TotalMessages, + int ProtectedMessages, + int ExpiringMessages, + int PinnedMessages, + int TotalAttachments, + long AttachmentBytes, + int TotalStories, + int ActiveStories, + int ProtectedStories, + int ActiveSessions, + int RegisteredPushDevices, + int PendingJoinRequests, + int ActiveInviteLinks, + int Topics, + int ModerationEvents, + int ProcessId); + +public sealed record AdminUserDto( + Guid Id, + string Username, + string DisplayName, + bool IsBlocked, + bool IsBot, + DateTimeOffset CreatedAt, + DateTimeOffset? BlockedAt, + DateTimeOffset? LastSeenAt, + string? PhoneNumber, + string? About, + string? ClientPlatform, + int? ClientVersionCode, + string? ClientVersionName, + IReadOnlyList<AdminUserClientVersionDto> ClientVersions, + int ActiveSessions, + int PushDevices, + int ChatMemberships, + int MessagesSent, + int Reactions, + bool PasscodeLockEnabled, + bool ShowMessagePreview); + +public sealed record AdminUserClientVersionDto( + string? ClientPlatform, + int? ClientVersionCode, + string? ClientVersionName, + DateTimeOffset LastSeenAt, + int Sessions, + int PushDevices); + +public sealed record AdminChatDto( + Guid Id, + string Type, + string Title, + string CreatedBy, + DateTimeOffset CreatedAt, + DateTimeOffset? LastActivityAt, + int Members, + int Messages, + int Attachments, + int PinnedMessages, + int ProtectedMessages, + int InviteLinks, + int PendingJoinRequests, + int Topics, + string? PublicUsername, + bool TopicsEnabled, + bool HasProtectedContent, + int? DefaultMessageTtlSeconds); + +public sealed record AdminAttachmentDto( + Guid Id, + Guid ChatId, + Guid MessageId, + string ChatTitle, + string UploadedBy, + string FileName, + string ContentType, + long FileSizeBytes, + int SortOrder, + Guid? MediaAlbumId, + DateTimeOffset UploadedAt, + bool Exists, + bool IsProtected, + string DownloadPath); + +public sealed record AdminChatDetailsDto( + AdminChatDto Chat, + IReadOnlyList<AdminChatMemberDto> Members, + IReadOnlyList<AdminChatInviteLinkDto> InviteLinks, + IReadOnlyList<AdminChatJoinRequestDto> JoinRequests, + IReadOnlyList<AdminChatTopicDto> Topics, + IReadOnlyList<AdminMessagePreviewDto> RecentMessages, + IReadOnlyList<AdminModerationLogItemDto> ModerationLog); + +public sealed record AdminChatMemberDto( + Guid UserId, + string Username, + string DisplayName, + bool IsBot, + ChatMemberRole Role, + bool IsOwner, + bool CanPinMessages, + bool CanDeleteMessages, + bool CanInviteUsers, + bool CanPostMessages, + bool IsArchived, + bool IsPinned, + DateTimeOffset? MutedUntil, + string? FolderKey, + DateTimeOffset JoinedAt, + DateTimeOffset? LastReadAt); + +public sealed record AdminChatInviteLinkDto( + Guid Id, + string Token, + string Link, + string? Title, + bool CreatesJoinRequest, + bool IsRevoked, + DateTimeOffset CreatedAt, + DateTimeOffset? RevokedAt); + +public sealed record AdminChatJoinRequestDto( + Guid Id, + Guid InviteLinkId, + string Username, + string DisplayName, + ChatJoinRequestStatus Status, + DateTimeOffset RequestedAt, + DateTimeOffset? ResolvedAt, + string? ResolvedBy); + +public sealed record AdminChatTopicDto( + Guid Id, + string Title, + string Icon, + bool IsPinned, + bool IsClosed, + DateTimeOffset CreatedAt, + DateTimeOffset LastActivityAt, + int MessageCount); + +public sealed record AdminMessagePreviewDto( + Guid Id, + string Sender, + string Text, + DateTimeOffset SentAt, + DateTimeOffset? EditedAt, + DateTimeOffset? DeletedAt, + bool HasProtectedContent, + Guid? MediaAlbumId, + int Attachments, + int Reactions); + +public sealed record AdminModerationLogItemDto( + long Id, + Guid? ChatId, + string ActorDisplayName, + ModerationLogTargetType TargetType, + Guid? TargetId, + string Action, + string? Reason, + DateTimeOffset CreatedAt); + +public sealed record AdminChatSettingsRequest( + string? PublicUsername, + bool TopicsEnabled, + bool HasProtectedContent, + int? DefaultMessageTtlSeconds); + +public sealed record AdminChatMemberPermissionsRequest( + ChatMemberRole Role, + bool CanPinMessages, + bool CanDeleteMessages, + bool CanInviteUsers, + bool CanPostMessages); + +public sealed record AdminCreateInviteLinkRequest( + string? Title, + bool CreatesJoinRequest); + +public sealed record AdminResolveJoinRequestRequest(bool Approve); + +public sealed record AdminExpiredMessagesCleanupDto( + int DeletedMessages, + int DeletedAttachments); + +public sealed record AdminAndroidUpdateCompatibilityReportDto( + DateTimeOffset CurrentTimeUtc, + int TotalHitsLast24Hours, + int TotalHitsLast7Days, + int HitsWithoutVersionLast24Hours, + int HitsWithoutVersionLast7Days, + DateTimeOffset? LatestHitAt, + IReadOnlyList<AdminAndroidUpdateCompatibilityRouteDto> RoutesLast7Days, + IReadOnlyList<AdminAndroidUpdateCompatibilityVersionDto> VersionsLast7Days, + IReadOnlyList<AdminAndroidUpdateCompatibilityHitDto> RecentHits); + +public sealed record AdminAndroidUpdateCompatibilityRouteDto( + string Route, + int HitsLast24Hours, + int HitsLast7Days, + DateTimeOffset? LatestHitAt); + +public sealed record AdminAndroidUpdateCompatibilityVersionDto( + string ClientPlatform, + int? ClientVersionCode, + string? ClientVersionName, + int HitsLast24Hours, + int HitsLast7Days, + DateTimeOffset? LatestHitAt); + +public sealed record AdminAndroidUpdateCompatibilityHitDto( + DateTimeOffset HitAt, + string Route, + string RequestPath, + string? RequestedFileName, + string? ClientPlatform, + int? ClientVersionCode, + string? ClientVersionName, + string? RemoteIp, + string? UserAgent); diff --git a/src/Ikar.Server/Infrastructure/Admin/AdminOptions.cs b/src/Ikar.Server/Infrastructure/Admin/AdminOptions.cs new file mode 100644 index 00000000..a6a431e9 --- /dev/null +++ b/src/Ikar.Server/Infrastructure/Admin/AdminOptions.cs @@ -0,0 +1,14 @@ +namespace Ikar.Server.Infrastructure.Admin; + +public sealed class AdminOptions +{ + public const string SectionName = "Admin"; + + public string Username { get; set; } = string.Empty; + + public string Password { get; set; } = string.Empty; + + public bool IsConfigured => + !string.IsNullOrWhiteSpace(Username) && + !string.IsNullOrWhiteSpace(Password); +} diff --git a/src/Ikar.Server/Infrastructure/Admin/AdminService.cs b/src/Ikar.Server/Infrastructure/Admin/AdminService.cs new file mode 100644 index 00000000..032e9dcc --- /dev/null +++ b/src/Ikar.Server/Infrastructure/Admin/AdminService.cs @@ -0,0 +1,1090 @@ +using System.Security.Cryptography; +using Microsoft.AspNetCore.SignalR; +using Microsoft.EntityFrameworkCore; +using Ikar.Server.Data; +using Ikar.Server.Data.Entities; +using Ikar.Server.Infrastructure.Hubs; +using Ikar.Server.Infrastructure.Storage; +using Ikar.Shared; + +namespace Ikar.Server.Infrastructure.Admin; + +public sealed class AdminService( + IkarDbContext dbContext, + IWebHostEnvironment environment, + ServerRuntimeState runtimeState, + AttachmentStorageService attachmentStorageService, + IHubContext<MessengerHub> hubContext) +{ + public async Task<AdminStatusDto> GetStatusAsync(CancellationToken cancellationToken) + { + var now = DateTimeOffset.UtcNow; + var databaseReachable = await dbContext.Database.CanConnectAsync(cancellationToken); + var serverVersion = typeof(Program).Assembly.GetName().Version?.ToString() ?? "unknown"; + var attachmentBytes = await dbContext.MessageAttachments.SumAsync(x => (long?)x.FileSizeBytes, cancellationToken) ?? 0; + + return new AdminStatusDto( + environment.EnvironmentName, + serverVersion, + runtimeState.StartedAtUtc, + now, + databaseReachable, + dbContext.Database.GetConnectionString(), + await dbContext.Users.CountAsync(cancellationToken), + await dbContext.Users.CountAsync(x => x.IsBlocked, cancellationToken), + await dbContext.Users.CountAsync(x => x.IsBot, cancellationToken), + await dbContext.Bots.CountAsync(cancellationToken), + await dbContext.Bots.CountAsync(x => x.IsEnabled, cancellationToken), + await dbContext.Chats.CountAsync(cancellationToken), + await dbContext.Chats.CountAsync(x => x.Type == ChatType.Direct, cancellationToken), + await dbContext.Chats.CountAsync(x => x.Type == ChatType.Group, cancellationToken), + await dbContext.Chats.CountAsync(x => x.Type == ChatType.Channel, cancellationToken), + await dbContext.Chats.CountAsync(x => x.HasProtectedContent, cancellationToken), + await dbContext.Messages.CountAsync(cancellationToken), + await dbContext.Messages.CountAsync(x => x.HasProtectedContent, cancellationToken), + await dbContext.Messages.CountAsync(x => x.ExpiresAt != null && x.ExpiresAt > now && x.DeletedAt == null, cancellationToken), + await dbContext.Messages.CountAsync(x => x.PinnedAt != null && x.DeletedAt == null, cancellationToken), + await dbContext.MessageAttachments.CountAsync(cancellationToken), + attachmentBytes, + await dbContext.Stories.CountAsync(cancellationToken), + await dbContext.Stories.CountAsync(x => x.DeletedAt == null && x.ExpiresAt > now, cancellationToken), + await dbContext.Stories.CountAsync(x => x.HasProtectedContent && x.DeletedAt == null, cancellationToken), + await dbContext.UserSessions.CountAsync(cancellationToken), + await dbContext.PushDevices.CountAsync(cancellationToken), + await dbContext.ChatJoinRequests.CountAsync(x => x.Status == ChatJoinRequestStatus.Pending, cancellationToken), + await dbContext.ChatInviteLinks.CountAsync(x => !x.IsRevoked, cancellationToken), + await dbContext.ChatTopics.CountAsync(cancellationToken), + await dbContext.ModerationLogEntries.CountAsync(cancellationToken), + Environment.ProcessId); + } + + public async Task<AdminAndroidUpdateCompatibilityReportDto> GetAndroidUpdateCompatibilityReportAsync(CancellationToken cancellationToken) + { + var now = DateTimeOffset.UtcNow; + var since24Hours = now.AddHours(-24); + var since7Days = now.AddDays(-7); + + var allHits = await dbContext.AndroidUpdateCompatibilityHits + .AsNoTracking() + .ToListAsync(cancellationToken); + + var hitsLast7Days = allHits + .Where(x => x.HitAt >= since7Days) + .ToList(); + + var totalHitsLast24Hours = hitsLast7Days.Count(x => x.HitAt >= since24Hours); + var totalHitsLast7Days = hitsLast7Days.Count; + var hitsWithoutVersionLast24Hours = hitsLast7Days.Count(x => x.HitAt >= since24Hours && x.ClientVersionCode == null); + var hitsWithoutVersionLast7Days = hitsLast7Days.Count(x => x.ClientVersionCode == null); + var latestHitAt = hitsLast7Days + .Select(x => (DateTimeOffset?)x.HitAt) + .DefaultIfEmpty(null) + .Max(); + + var routeBuckets = hitsLast7Days + .GroupBy(x => x.Route) + .Select(group => new AdminAndroidUpdateCompatibilityRouteDto( + group.Key, + group.Count(x => x.HitAt >= since24Hours), + group.Count(), + group.Max(x => (DateTimeOffset?)x.HitAt))) + .OrderByDescending(x => x.HitsLast7Days) + .ThenByDescending(x => x.LatestHitAt) + .ToList(); + + var versionBuckets = hitsLast7Days + .GroupBy(x => new { x.ClientPlatform, x.ClientVersionCode, x.ClientVersionName }) + .Select(group => new AdminAndroidUpdateCompatibilityVersionDto( + group.Key.ClientPlatform ?? "unknown", + group.Key.ClientVersionCode, + group.Key.ClientVersionName, + group.Count(x => x.HitAt >= since24Hours), + group.Count(), + group.Max(x => (DateTimeOffset?)x.HitAt))) + .OrderByDescending(x => x.HitsLast7Days) + .ThenByDescending(x => x.LatestHitAt) + .ToList(); + + var recentHits = hitsLast7Days + .OrderByDescending(x => x.HitAt) + .Take(50) + .Select(x => new AdminAndroidUpdateCompatibilityHitDto( + x.HitAt, + x.Route, + x.RequestPath, + x.RequestedFileName, + x.ClientPlatform, + x.ClientVersionCode, + x.ClientVersionName, + x.RemoteIp, + x.UserAgent)) + .ToList(); + + return new AdminAndroidUpdateCompatibilityReportDto( + now, + totalHitsLast24Hours, + totalHitsLast7Days, + hitsWithoutVersionLast24Hours, + hitsWithoutVersionLast7Days, + latestHitAt, + routeBuckets, + versionBuckets, + recentHits); + } + + public async Task<IReadOnlyList<AdminUserDto>> GetUsersAsync(string? query, CancellationToken cancellationToken) + { + var usersQuery = dbContext.Users.AsNoTracking(); + if (!string.IsNullOrWhiteSpace(query)) + { + var normalized = query.Trim().ToUpperInvariant(); + var like = $"%{query.Trim()}%"; + usersQuery = usersQuery.Where(x => + x.NormalizedUsername.Contains(normalized) || + EF.Functions.Like(x.DisplayName, like) || + (x.PhoneNumber != null && EF.Functions.Like(x.PhoneNumber, like))); + } + + var users = await usersQuery + .OrderByDescending(x => x.CreatedAt) + .Take(250) + .Select(x => new + { + x.Id, + x.Username, + x.DisplayName, + x.IsBlocked, + x.IsBot, + x.CreatedAt, + x.BlockedAt, + LastSeenAt = x.Sessions + .OrderByDescending(session => session.LastSeenAt) + .Select(session => (DateTimeOffset?)session.LastSeenAt) + .FirstOrDefault(), + x.PhoneNumber, + x.About, + ActiveSessions = x.Sessions.Count, + PushDevices = x.PushDevices.Count, + ChatMemberships = x.ChatMemberships.Count, + MessagesSent = x.Messages.Count, + Reactions = x.MessageReactions.Count, + PasscodeLockEnabled = x.PrivacySettings != null && x.PrivacySettings.PasscodeLockEnabled, + ShowMessagePreview = x.PrivacySettings == null || x.PrivacySettings.ShowMessagePreview + }) + .ToListAsync(cancellationToken); + + var userIds = users.Select(x => x.Id).ToList(); + var sessionVersions = userIds.Count == 0 + ? new List<AdminUserClientVersionProjection>() + : await dbContext.UserSessions + .AsNoTracking() + .Where(x => userIds.Contains(x.UserId)) + .Select(x => new AdminUserClientVersionProjection( + x.UserId, + x.ClientPlatform, + x.ClientVersionCode, + x.ClientVersionName, + x.LastSeenAt, + false)) + .ToListAsync(cancellationToken); + + var pushDeviceVersions = userIds.Count == 0 + ? new List<AdminUserClientVersionProjection>() + : await dbContext.PushDevices + .AsNoTracking() + .Where(x => userIds.Contains(x.UserId)) + .Select(x => new AdminUserClientVersionProjection( + x.UserId, + x.ClientPlatform, + x.ClientVersionCode, + x.ClientVersionName, + x.UpdatedAt, + true)) + .ToListAsync(cancellationToken); + + var versionsByUser = sessionVersions + .Concat(pushDeviceVersions) + .GroupBy(x => x.UserId) + .ToDictionary(x => x.Key, x => x.ToList()); + + return users + .Select(user => + { + versionsByUser.TryGetValue(user.Id, out var versions); + versions ??= new List<AdminUserClientVersionProjection>(); + + var latestClientVersion = versions + .Where(HasClientVersion) + .OrderByDescending(x => x.LastSeenAt) + .FirstOrDefault(); + + return new AdminUserDto( + user.Id, + user.Username, + user.DisplayName, + user.IsBlocked, + user.IsBot, + user.CreatedAt, + user.BlockedAt, + user.LastSeenAt, + user.PhoneNumber, + user.About, + latestClientVersion?.ClientPlatform, + latestClientVersion?.ClientVersionCode, + latestClientVersion?.ClientVersionName, + BuildClientVersions(versions), + user.ActiveSessions, + user.PushDevices, + user.ChatMemberships, + user.MessagesSent, + user.Reactions, + user.PasscodeLockEnabled, + user.ShowMessagePreview); + }) + .ToList(); + } + + public async Task<IReadOnlyList<AdminChatDto>> GetChatsAsync(string? query, CancellationToken cancellationToken) + { + var chatsQuery = BuildChatsQuery(query); + var projections = await ProjectChats(chatsQuery.OrderByDescending(x => x.LastActivityAt ?? x.CreatedAt)) + .Take(250) + .ToListAsync(cancellationToken); + + var directTitles = await LoadDirectTitlesAsync(projections, cancellationToken); + return projections + .Select(x => ToAdminChatDto(x, directTitles)) + .ToList(); + } + + public async Task<AdminChatDetailsDto?> GetChatDetailsAsync(Guid chatId, CancellationToken cancellationToken) + { + var projection = await ProjectChats(dbContext.Chats.AsNoTracking().Where(x => x.Id == chatId)) + .SingleOrDefaultAsync(cancellationToken); + if (projection is null) + { + return null; + } + + var directTitles = await LoadDirectTitlesAsync([projection], cancellationToken); + var chatDto = ToAdminChatDto(projection, directTitles); + + var members = await dbContext.ChatMembers + .AsNoTracking() + .Where(x => x.ChatId == chatId) + .Include(x => x.User) + .OrderByDescending(x => x.IsOwner) + .ThenByDescending(x => x.Role) + .ThenBy(x => x.User.DisplayName) + .Select(x => new AdminChatMemberDto( + x.UserId, + x.User.Username, + x.User.DisplayName, + x.User.IsBot, + x.Role, + x.IsOwner, + x.CanPinMessages, + x.CanDeleteMessages, + x.CanInviteUsers, + x.CanPostMessages, + x.IsArchived, + x.IsPinned, + x.MutedUntil, + x.FolderKey, + x.JoinedAt, + x.LastReadAt)) + .ToListAsync(cancellationToken); + + var inviteLinks = await dbContext.ChatInviteLinks + .AsNoTracking() + .Where(x => x.ChatId == chatId) + .OrderByDescending(x => x.CreatedAt) + .Select(x => new AdminChatInviteLinkDto( + x.Id, + x.Token, + $"/join/{x.Token}", + x.Title, + x.CreatesJoinRequest, + x.IsRevoked, + x.CreatedAt, + x.RevokedAt)) + .ToListAsync(cancellationToken); + + var joinRequests = await dbContext.ChatJoinRequests + .AsNoTracking() + .Where(x => x.ChatId == chatId) + .Include(x => x.User) + .Include(x => x.ResolvedByUser) + .OrderByDescending(x => x.Status == ChatJoinRequestStatus.Pending) + .ThenByDescending(x => x.RequestedAt) + .Select(x => new AdminChatJoinRequestDto( + x.Id, + x.InviteLinkId, + x.User.Username, + x.User.DisplayName, + x.Status, + x.RequestedAt, + x.ResolvedAt, + x.ResolvedByUser == null ? null : x.ResolvedByUser.DisplayName)) + .ToListAsync(cancellationToken); + + var topics = await dbContext.ChatTopics + .AsNoTracking() + .Where(x => x.ChatId == chatId) + .OrderByDescending(x => x.IsPinned) + .ThenByDescending(x => x.LastActivityAt) + .Select(x => new AdminChatTopicDto( + x.Id, + x.Title, + x.Icon, + x.IsPinned, + x.IsClosed, + x.CreatedAt, + x.LastActivityAt, + dbContext.Messages.Count(message => message.TopicId == x.Id && message.DeletedAt == null))) + .ToListAsync(cancellationToken); + + var recentMessages = await dbContext.Messages + .AsNoTracking() + .Where(x => x.ChatId == chatId) + .Include(x => x.Sender) + .OrderByDescending(x => x.SentAt) + .Take(30) + .Select(x => new AdminMessagePreviewDto( + x.Id, + x.Sender.DisplayName, + x.DeletedAt == null ? x.Text : string.Empty, + x.SentAt, + x.EditedAt, + x.DeletedAt, + x.HasProtectedContent, + x.MediaAlbumId, + x.Attachments.Count, + x.Reactions.Count)) + .ToListAsync(cancellationToken); + + var moderationLog = await GetModerationLogItemsAsync(chatId, 80, cancellationToken); + + return new AdminChatDetailsDto( + chatDto, + members, + inviteLinks, + joinRequests, + topics, + recentMessages, + moderationLog); + } + + public async Task<AdminChatDetailsDto?> UpdateChatSettingsAsync(Guid chatId, AdminChatSettingsRequest request, CancellationToken cancellationToken) + { + var chat = await dbContext.Chats + .Include(x => x.Members) + .SingleOrDefaultAsync(x => x.Id == chatId, cancellationToken); + if (chat is null) + { + return null; + } + + var publicUsername = NormalizePublicUsername(request.PublicUsername); + if (publicUsername is not null) + { + var duplicate = await dbContext.Chats + .AnyAsync(x => x.Id != chatId && x.PublicUsername == publicUsername, cancellationToken); + if (duplicate) + { + throw new InvalidOperationException("Публичное имя уже занято другим чатом."); + } + } + + chat.PublicUsername = publicUsername; + chat.TopicsEnabled = request.TopicsEnabled; + chat.HasProtectedContent = request.HasProtectedContent; + chat.DefaultMessageTtlSeconds = NormalizeTtlSeconds(request.DefaultMessageTtlSeconds); + chat.LastActivityAt ??= DateTimeOffset.UtcNow; + + await dbContext.SaveChangesAsync(cancellationToken); + await NotifyChatsChangedAsync(chat.Members.Select(x => x.UserId), cancellationToken); + return await GetChatDetailsAsync(chatId, cancellationToken); + } + + public async Task<AdminChatDetailsDto?> UpdateMemberPermissionsAsync( + Guid chatId, + Guid memberUserId, + AdminChatMemberPermissionsRequest request, + CancellationToken cancellationToken) + { + var chat = await dbContext.Chats + .Include(x => x.Members) + .SingleOrDefaultAsync(x => x.Id == chatId, cancellationToken); + if (chat is null) + { + return null; + } + + var member = chat.Members.FirstOrDefault(x => x.UserId == memberUserId); + if (member is null) + { + return null; + } + + member.Role = request.Role; + member.IsOwner = request.Role == ChatMemberRole.Owner; + member.CanPinMessages = request.CanPinMessages || member.IsOwner; + member.CanDeleteMessages = request.CanDeleteMessages || member.IsOwner; + member.CanInviteUsers = request.CanInviteUsers || member.IsOwner; + member.CanPostMessages = request.CanPostMessages || member.IsOwner; + + await dbContext.SaveChangesAsync(cancellationToken); + await NotifyChatsChangedAsync(chat.Members.Select(x => x.UserId), cancellationToken); + return await GetChatDetailsAsync(chatId, cancellationToken); + } + + public async Task<AdminChatDetailsDto?> CreateInviteLinkAsync(Guid chatId, AdminCreateInviteLinkRequest request, CancellationToken cancellationToken) + { + var chat = await dbContext.Chats + .Include(x => x.Members) + .SingleOrDefaultAsync(x => x.Id == chatId, cancellationToken); + if (chat is null) + { + return null; + } + + if (chat.Type == ChatType.Direct) + { + throw new InvalidOperationException("Личные чаты не поддерживают ссылки-приглашения."); + } + + var link = new ChatInviteLink + { + ChatId = chatId, + CreatedById = chat.CreatedById, + Token = await GenerateInviteTokenAsync(cancellationToken), + Title = TrimToNull(request.Title, 64), + CreatesJoinRequest = request.CreatesJoinRequest, + CreatedAt = DateTimeOffset.UtcNow + }; + + dbContext.ChatInviteLinks.Add(link); + await dbContext.SaveChangesAsync(cancellationToken); + await NotifyChatsChangedAsync(chat.Members.Select(x => x.UserId), cancellationToken); + return await GetChatDetailsAsync(chatId, cancellationToken); + } + + public async Task<AdminChatDetailsDto?> RevokeInviteLinkAsync(Guid chatId, Guid inviteLinkId, CancellationToken cancellationToken) + { + var chat = await dbContext.Chats + .Include(x => x.Members) + .SingleOrDefaultAsync(x => x.Id == chatId, cancellationToken); + if (chat is null) + { + return null; + } + + var link = await dbContext.ChatInviteLinks + .SingleOrDefaultAsync(x => x.Id == inviteLinkId && x.ChatId == chatId, cancellationToken); + if (link is null) + { + return null; + } + + link.IsRevoked = true; + link.RevokedAt = DateTimeOffset.UtcNow; + + await dbContext.SaveChangesAsync(cancellationToken); + await NotifyChatsChangedAsync(chat.Members.Select(x => x.UserId), cancellationToken); + return await GetChatDetailsAsync(chatId, cancellationToken); + } + + public async Task<AdminChatDetailsDto?> ResolveJoinRequestAsync( + Guid chatId, + Guid joinRequestId, + bool approve, + CancellationToken cancellationToken) + { + var chat = await dbContext.Chats + .Include(x => x.Members) + .SingleOrDefaultAsync(x => x.Id == chatId, cancellationToken); + if (chat is null) + { + return null; + } + + var joinRequest = await dbContext.ChatJoinRequests + .Include(x => x.User) + .SingleOrDefaultAsync(x => x.Id == joinRequestId && x.ChatId == chatId, cancellationToken); + if (joinRequest is null) + { + return null; + } + + if (joinRequest.Status == ChatJoinRequestStatus.Pending) + { + joinRequest.Status = approve ? ChatJoinRequestStatus.Approved : ChatJoinRequestStatus.Declined; + joinRequest.ResolvedAt = DateTimeOffset.UtcNow; + joinRequest.ResolvedByUserId = null; + + if (approve && chat.Members.All(x => x.UserId != joinRequest.UserId)) + { + chat.Members.Add(new ChatMember + { + ChatId = chatId, + UserId = joinRequest.UserId, + Role = ChatMemberRole.Member, + JoinedAt = DateTimeOffset.UtcNow + }); + chat.LastActivityAt = DateTimeOffset.UtcNow; + } + + await dbContext.SaveChangesAsync(cancellationToken); + await NotifyChatsChangedAsync(chat.Members.Select(x => x.UserId).Append(joinRequest.UserId), cancellationToken); + } + + return await GetChatDetailsAsync(chatId, cancellationToken); + } + + public async Task<IReadOnlyList<AdminAttachmentDto>> GetAttachmentsAsync(string? query, CancellationToken cancellationToken) + { + var attachmentsQuery = dbContext.MessageAttachments + .AsNoTracking() + .Include(x => x.Message) + .ThenInclude(x => x.Chat) + .Include(x => x.Message) + .ThenInclude(x => x.Sender) + .AsQueryable(); + + if (!string.IsNullOrWhiteSpace(query)) + { + var like = $"%{query.Trim()}%"; + attachmentsQuery = attachmentsQuery.Where(x => + EF.Functions.Like(x.OriginalFileName, like) || + EF.Functions.Like(x.ContentType, like) || + EF.Functions.Like(x.Message.Sender.DisplayName, like) || + (x.Message.Chat.Title != null && EF.Functions.Like(x.Message.Chat.Title, like))); + } + + var attachments = await attachmentsQuery.ToListAsync(cancellationToken); + + return attachments + .OrderByDescending(x => x.UploadedAt) + .Take(250) + .Select(x => new AdminAttachmentDto( + x.Id, + x.Message.ChatId, + x.MessageId, + string.IsNullOrWhiteSpace(x.Message.Chat.Title) ? x.Message.Chat.Type.ToString() : x.Message.Chat.Title!, + x.Message.Sender.DisplayName, + x.OriginalFileName, + x.ContentType, + x.FileSizeBytes, + x.SortOrder, + x.Message.MediaAlbumId, + x.UploadedAt, + File.Exists(x.StoragePath), + x.Message.HasProtectedContent || x.Message.Chat.HasProtectedContent, + $"/admin/api/attachments/{x.Id}/content")) + .ToList(); + } + + public async Task<bool> DeleteAttachmentAsync(Guid attachmentId, CancellationToken cancellationToken) + { + var attachment = await dbContext.MessageAttachments + .Include(x => x.Message) + .ThenInclude(x => x.Chat) + .ThenInclude(x => x.Members) + .SingleOrDefaultAsync(x => x.Id == attachmentId, cancellationToken); + if (attachment is null) + { + return false; + } + + var affectedUserIds = attachment.Message.Chat.Members.Select(x => x.UserId).ToList(); + var path = attachment.StoragePath; + dbContext.MessageAttachments.Remove(attachment); + await dbContext.SaveChangesAsync(cancellationToken); + + if (!string.IsNullOrWhiteSpace(path)) + { + attachmentStorageService.DeleteIfExists(path); + } + + await NotifyChatsChangedAsync(affectedUserIds, cancellationToken); + return true; + } + + public async Task<IReadOnlyList<AdminModerationLogItemDto>> GetModerationLogAsync(Guid? chatId, int limit, CancellationToken cancellationToken) => + await GetModerationLogItemsAsync(chatId, Math.Clamp(limit, 1, 250), cancellationToken); + + public async Task<AdminExpiredMessagesCleanupDto> CleanupExpiredMessagesAsync(CancellationToken cancellationToken) + { + var now = DateTimeOffset.UtcNow; + var messages = await dbContext.Messages + .Include(x => x.Chat) + .ThenInclude(x => x.Members) + .Include(x => x.Attachments) + .Include(x => x.Reactions) + .Where(x => x.ExpiresAt != null && x.ExpiresAt <= now && x.DeletedAt == null) + .Take(500) + .ToListAsync(cancellationToken); + + var deletedAttachments = 0; + var affectedUserIds = messages + .SelectMany(x => x.Chat.Members.Select(member => member.UserId)) + .Distinct() + .ToList(); + + foreach (var message in messages) + { + DeleteFiles(message.Attachments.Select(x => x.StoragePath)); + deletedAttachments += message.Attachments.Count; + dbContext.MessageAttachments.RemoveRange(message.Attachments); + dbContext.MessageReactions.RemoveRange(message.Reactions); + message.Attachments.Clear(); + message.Reactions.Clear(); + message.Text = string.Empty; + message.EditedAt = null; + message.DeletedAt = now; + message.PinnedAt = null; + message.PinnedByUserId = null; + } + + await dbContext.SaveChangesAsync(cancellationToken); + await NotifyChatsChangedAsync(affectedUserIds, cancellationToken); + return new AdminExpiredMessagesCleanupDto(messages.Count, deletedAttachments); + } + + public async Task<bool> SetUserBlockedAsync(Guid userId, bool isBlocked, CancellationToken cancellationToken) + { + var user = await dbContext.Users + .Include(x => x.Sessions) + .SingleOrDefaultAsync(x => x.Id == userId, cancellationToken); + + if (user is null) + { + return false; + } + + user.IsBlocked = isBlocked; + user.BlockedAt = isBlocked ? DateTimeOffset.UtcNow : null; + + if (isBlocked && user.Sessions.Count > 0) + { + dbContext.UserSessions.RemoveRange(user.Sessions); + } + + await dbContext.SaveChangesAsync(cancellationToken); + return true; + } + + public async Task<bool> DeleteChatAsync(Guid chatId, CancellationToken cancellationToken) + { + var chat = await dbContext.Chats + .Include(x => x.Members) + .Include(x => x.Messages) + .ThenInclude(x => x.Attachments) + .SingleOrDefaultAsync(x => x.Id == chatId, cancellationToken); + + if (chat is null) + { + return false; + } + + var affectedUserIds = chat.Members.Select(x => x.UserId).Distinct().ToList(); + var attachmentPaths = chat.Messages + .SelectMany(x => x.Attachments) + .Select(x => x.StoragePath) + .Where(x => !string.IsNullOrWhiteSpace(x)) + .Distinct() + .ToList(); + + dbContext.Chats.Remove(chat); + await dbContext.SaveChangesAsync(cancellationToken); + + DeleteFiles(attachmentPaths); + await NotifyChatsChangedAsync(affectedUserIds, cancellationToken); + + return true; + } + + public async Task<bool> DeleteUserAsync(Guid userId, CancellationToken cancellationToken) + { + var user = await dbContext.Users + .Include(x => x.Sessions) + .Include(x => x.PushDevices) + .SingleOrDefaultAsync(x => x.Id == userId, cancellationToken); + + if (user is null) + { + return false; + } + + var chats = await dbContext.Chats + .Include(x => x.Members) + .Include(x => x.Messages) + .ThenInclude(x => x.Attachments) + .Where(x => x.CreatedById == userId || x.Members.Any(member => member.UserId == userId)) + .ToListAsync(cancellationToken); + + var affectedUserIds = new HashSet<Guid>(); + var attachmentPathsToDelete = new HashSet<string>(StringComparer.OrdinalIgnoreCase); + var chatsToDelete = new List<Chat>(); + + foreach (var chat in chats) + { + foreach (var memberId in chat.Members.Select(x => x.UserId)) + { + if (memberId != userId) + { + affectedUserIds.Add(memberId); + } + } + + if (chat.Type == ChatType.Direct && chat.Members.Any(x => x.UserId == userId)) + { + CollectAttachmentPaths(chat.Messages.SelectMany(x => x.Attachments), attachmentPathsToDelete); + chatsToDelete.Add(chat); + continue; + } + + var messagesToDelete = chat.Messages + .Where(x => x.SenderId == userId) + .ToList(); + + CollectAttachmentPaths(messagesToDelete.SelectMany(x => x.Attachments), attachmentPathsToDelete); + + if (messagesToDelete.Count > 0) + { + dbContext.Messages.RemoveRange(messagesToDelete); + } + + var membership = chat.Members.FirstOrDefault(x => x.UserId == userId); + if (membership is not null) + { + dbContext.ChatMembers.Remove(membership); + } + + var remainingMembers = chat.Members + .Where(x => x.UserId != userId) + .OrderByDescending(x => x.IsOwner) + .ThenBy(x => x.JoinedAt) + .ToList(); + + if (remainingMembers.Count == 0) + { + chatsToDelete.Add(chat); + continue; + } + + if (chat.CreatedById == userId) + { + var replacementOwner = remainingMembers.First(); + replacementOwner.IsOwner = true; + replacementOwner.Role = ChatMemberRole.Owner; + chat.CreatedById = replacementOwner.UserId; + } + + if (chat.Type == ChatType.Channel && remainingMembers.All(x => !x.IsOwner)) + { + remainingMembers[0].IsOwner = true; + remainingMembers[0].Role = ChatMemberRole.Owner; + } + + var latestMessageSentAt = chat.Messages + .Where(x => x.SenderId != userId) + .OrderByDescending(x => x.SentAt) + .Select(x => (DateTimeOffset?)x.SentAt) + .FirstOrDefault(); + chat.LastActivityAt = latestMessageSentAt ?? chat.CreatedAt; + } + + foreach (var chat in chatsToDelete.DistinctBy(x => x.Id)) + { + CollectAttachmentPaths(chat.Messages.SelectMany(x => x.Attachments), attachmentPathsToDelete); + dbContext.Chats.Remove(chat); + } + + if (user.Sessions.Count > 0) + { + dbContext.UserSessions.RemoveRange(user.Sessions); + } + + if (user.PushDevices.Count > 0) + { + dbContext.PushDevices.RemoveRange(user.PushDevices); + } + + dbContext.Users.Remove(user); + await dbContext.SaveChangesAsync(cancellationToken); + + DeleteFiles(attachmentPathsToDelete); + await NotifyChatsChangedAsync(affectedUserIds, cancellationToken); + + return true; + } + + private IQueryable<Chat> BuildChatsQuery(string? query) + { + var chatsQuery = dbContext.Chats.AsNoTracking(); + if (string.IsNullOrWhiteSpace(query)) + { + return chatsQuery; + } + + var like = $"%{query.Trim()}%"; + return chatsQuery.Where(x => + (x.Title != null && EF.Functions.Like(x.Title, like)) || + (x.PublicUsername != null && EF.Functions.Like(x.PublicUsername, like)) || + EF.Functions.Like(x.CreatedBy.DisplayName, like) || + x.Members.Any(member => + EF.Functions.Like(member.User.DisplayName, like) || + EF.Functions.Like(member.User.Username, like))); + } + + private IQueryable<AdminChatProjection> ProjectChats(IQueryable<Chat> chatsQuery) => + chatsQuery.Select(x => new AdminChatProjection( + x.Id, + x.Type, + x.Title, + x.PublicUsername, + x.TopicsEnabled, + x.HasProtectedContent, + x.DefaultMessageTtlSeconds, + x.CreatedBy.DisplayName, + x.CreatedAt, + x.LastActivityAt, + x.Members.Count, + x.Messages.Count, + x.Messages.SelectMany(m => m.Attachments).Count(), + x.Messages.Count(m => m.PinnedAt != null && m.DeletedAt == null), + x.Messages.Count(m => m.HasProtectedContent && m.DeletedAt == null), + x.InviteLinks.Count(m => !m.IsRevoked), + x.JoinRequests.Count(m => m.Status == ChatJoinRequestStatus.Pending), + x.Topics.Count)); + + private async Task<Dictionary<Guid, string>> LoadDirectTitlesAsync(IEnumerable<AdminChatProjection> chats, CancellationToken cancellationToken) + { + var directChatIds = chats + .Where(x => x.Type == ChatType.Direct) + .Select(x => x.Id) + .ToList(); + + if (directChatIds.Count == 0) + { + return []; + } + + var rows = await dbContext.ChatMembers + .AsNoTracking() + .Where(x => directChatIds.Contains(x.ChatId)) + .Select(x => new { x.ChatId, x.User.DisplayName, x.User.Username }) + .ToListAsync(cancellationToken); + + return rows + .GroupBy(x => x.ChatId) + .ToDictionary( + x => x.Key, + x => string.Join(" / ", x.Select(member => DisplayName(member.DisplayName, member.Username)))); + } + + private static AdminChatDto ToAdminChatDto(AdminChatProjection chat, IReadOnlyDictionary<Guid, string> directTitles) + { + var title = chat.Type == ChatType.Direct + ? directTitles.GetValueOrDefault(chat.Id, "Личный чат") + : string.IsNullOrWhiteSpace(chat.Title) ? "Без названия" : chat.Title!; + + return new AdminChatDto( + chat.Id, + chat.Type.ToString(), + title, + chat.CreatedBy, + chat.CreatedAt, + chat.LastActivityAt, + chat.Members, + chat.Messages, + chat.Attachments, + chat.PinnedMessages, + chat.ProtectedMessages, + chat.InviteLinks, + chat.PendingJoinRequests, + chat.Topics, + chat.PublicUsername, + chat.TopicsEnabled, + chat.HasProtectedContent, + chat.DefaultMessageTtlSeconds); + } + + private async Task<IReadOnlyList<AdminModerationLogItemDto>> GetModerationLogItemsAsync(Guid? chatId, int limit, CancellationToken cancellationToken) + { + var take = Math.Clamp(limit, 1, 250); + var query = dbContext.ModerationLogEntries + .AsNoTracking() + .Include(x => x.ActorUser) + .AsQueryable(); + + if (chatId is not null) + { + query = query.Where(x => x.ChatId == chatId.Value); + } + + return await query + .OrderByDescending(x => x.CreatedAt) + .Take(take) + .Select(x => new AdminModerationLogItemDto( + x.Id, + x.ChatId, + string.IsNullOrWhiteSpace(x.ActorUser.DisplayName) ? x.ActorUser.Username : x.ActorUser.DisplayName, + x.TargetType, + x.TargetId, + x.Action, + x.Reason, + x.CreatedAt)) + .ToListAsync(cancellationToken); + } + + private async Task<string> GenerateInviteTokenAsync(CancellationToken cancellationToken) + { + while (true) + { + var token = Convert.ToBase64String(RandomNumberGenerator.GetBytes(18)) + .TrimEnd('=') + .Replace('+', '-') + .Replace('/', '_'); + var exists = await dbContext.ChatInviteLinks + .AnyAsync(x => x.Token == token, cancellationToken); + if (!exists) + { + return token; + } + } + } + + private static string? NormalizePublicUsername(string? value) + { + var trimmed = value?.Trim().TrimStart('@'); + if (string.IsNullOrWhiteSpace(trimmed)) + { + return null; + } + + var normalized = new string(trimmed + .Where(ch => ch is >= 'a' and <= 'z' || ch is >= 'A' and <= 'Z' || char.IsDigit(ch) || ch == '_') + .Take(32) + .ToArray()) + .ToLowerInvariant(); + + if (normalized.Length < 5) + { + throw new InvalidOperationException("Публичное имя должно содержать минимум 5 символов."); + } + + return normalized; + } + + private static int? NormalizeTtlSeconds(int? ttlSeconds) + { + if (ttlSeconds is null || ttlSeconds <= 0) + { + return null; + } + + return Math.Clamp(ttlSeconds.Value, 5, 31 * 24 * 60 * 60); + } + + private static string? TrimToNull(string? value, int maxLength) + { + var trimmed = value?.Trim(); + if (string.IsNullOrWhiteSpace(trimmed)) + { + return null; + } + + return trimmed.Length <= maxLength ? trimmed : trimmed[..maxLength]; + } + + private static string DisplayName(string? displayName, string username) => + string.IsNullOrWhiteSpace(displayName) ? $"@{username}" : displayName!; + + private static IReadOnlyList<AdminUserClientVersionDto> BuildClientVersions( + IEnumerable<AdminUserClientVersionProjection> versions) + { + return versions + .Where(HasClientVersion) + .GroupBy(x => new + { + x.ClientPlatform, + x.ClientVersionCode, + x.ClientVersionName + }) + .Select(x => new AdminUserClientVersionDto( + x.Key.ClientPlatform, + x.Key.ClientVersionCode, + x.Key.ClientVersionName, + x.Max(version => version.LastSeenAt), + x.Count(version => !version.IsPushDevice), + x.Count(version => version.IsPushDevice))) + .OrderByDescending(x => x.LastSeenAt) + .ToList(); + } + + private static bool HasClientVersion(AdminUserClientVersionProjection version) => + !string.IsNullOrWhiteSpace(version.ClientPlatform) || + version.ClientVersionCode is not null || + !string.IsNullOrWhiteSpace(version.ClientVersionName); + + private static void CollectAttachmentPaths(IEnumerable<MessageAttachment> attachments, ISet<string> target) + { + foreach (var path in attachments + .Select(x => x.StoragePath) + .Where(x => !string.IsNullOrWhiteSpace(x))) + { + target.Add(path); + } + } + + private void DeleteFiles(IEnumerable<string> attachmentPaths) + { + foreach (var path in attachmentPaths) + { + attachmentStorageService.DeleteIfExists(path); + } + } + + private Task NotifyChatsChangedAsync(IEnumerable<Guid> userIds, CancellationToken cancellationToken) + { + var affectedUsers = userIds + .Distinct() + .Select(x => x.ToString()) + .ToList(); + + return affectedUsers.Count == 0 + ? Task.CompletedTask + : hubContext.Clients.Users(affectedUsers).SendAsync("ChatsChanged", cancellationToken); + } + + private sealed record AdminChatProjection( + Guid Id, + ChatType Type, + string? Title, + string? PublicUsername, + bool TopicsEnabled, + bool HasProtectedContent, + int? DefaultMessageTtlSeconds, + string CreatedBy, + DateTimeOffset CreatedAt, + DateTimeOffset? LastActivityAt, + int Members, + int Messages, + int Attachments, + int PinnedMessages, + int ProtectedMessages, + int InviteLinks, + int PendingJoinRequests, + int Topics); + + private sealed record AdminUserClientVersionProjection( + Guid UserId, + string? ClientPlatform, + int? ClientVersionCode, + string? ClientVersionName, + DateTimeOffset LastSeenAt, + bool IsPushDevice); +} diff --git a/src/Ikar.Server/Infrastructure/Admin/ServerRuntimeState.cs b/src/Ikar.Server/Infrastructure/Admin/ServerRuntimeState.cs new file mode 100644 index 00000000..2e8ad1f6 --- /dev/null +++ b/src/Ikar.Server/Infrastructure/Admin/ServerRuntimeState.cs @@ -0,0 +1,3 @@ +namespace Ikar.Server.Infrastructure.Admin; + +public sealed record ServerRuntimeState(DateTimeOffset StartedAtUtc); diff --git a/src/Ikar.Server/Infrastructure/ApiRateLimitMiddleware.cs b/src/Ikar.Server/Infrastructure/ApiRateLimitMiddleware.cs new file mode 100644 index 00000000..a8ca8efe --- /dev/null +++ b/src/Ikar.Server/Infrastructure/ApiRateLimitMiddleware.cs @@ -0,0 +1,103 @@ +using System.Collections.Concurrent; +using System.Security.Claims; + +namespace Ikar.Server.Infrastructure; + +public sealed class ApiRateLimitMiddleware(RequestDelegate next, ILogger<ApiRateLimitMiddleware> logger) +{ + private static readonly TimeSpan Window = TimeSpan.FromMinutes(1); + private static readonly ConcurrentDictionary<string, RateLimitBucket> Buckets = new(); + private static long _lastCleanupTicks; + + public async Task InvokeAsync(HttpContext context) + { + if (!ShouldRateLimit(context.Request)) + { + await next(context); + return; + } + + var now = DateTimeOffset.UtcNow; + var key = BuildKey(context); + var limit = context.User.Identity?.IsAuthenticated == true ? 1800 : 240; + var bucket = Buckets.GetOrAdd(key, _ => new RateLimitBucket(now, 0)); + int count; + DateTimeOffset resetAt; + + lock (bucket) + { + if (now - bucket.WindowStartedAt >= Window) + { + bucket.WindowStartedAt = now; + bucket.Count = 0; + } + + bucket.Count++; + count = bucket.Count; + resetAt = bucket.WindowStartedAt.Add(Window); + } + + MaybeCleanup(now); + + if (count <= limit) + { + context.Response.Headers["X-RateLimit-Limit"] = limit.ToString(); + context.Response.Headers["X-RateLimit-Remaining"] = Math.Max(0, limit - count).ToString(); + context.Response.Headers["X-RateLimit-Reset"] = resetAt.ToUnixTimeSeconds().ToString(); + await next(context); + return; + } + + logger.LogWarning("Rate limit exceeded for {RateLimitKey} on {Path}.", key, context.Request.Path); + context.Response.StatusCode = StatusCodes.Status429TooManyRequests; + context.Response.Headers["Retry-After"] = Math.Max(1, (int)(resetAt - now).TotalSeconds).ToString(); + await context.Response.WriteAsJsonAsync(new { error = "Too many requests. Try again later." }, context.RequestAborted); + } + + private static bool ShouldRateLimit(HttpRequest request) => + request.Path.StartsWithSegments("/api", StringComparison.OrdinalIgnoreCase) || + request.Path.StartsWithSegments("/hubs/messenger", StringComparison.OrdinalIgnoreCase); + + private static string BuildKey(HttpContext context) + { + var userId = context.User.FindFirstValue(ClaimTypes.NameIdentifier); + if (!string.IsNullOrWhiteSpace(userId)) + { + return $"user:{userId}"; + } + + var forwardedFor = context.Request.Headers["X-Forwarded-For"].ToString(); + var ip = forwardedFor.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).FirstOrDefault() + ?? context.Connection.RemoteIpAddress?.ToString() + ?? "unknown"; + return $"ip:{ip}"; + } + + private static void MaybeCleanup(DateTimeOffset now) + { + var lastCleanup = new DateTimeOffset(Interlocked.Read(ref _lastCleanupTicks), TimeSpan.Zero); + if (now - lastCleanup < Window) + { + return; + } + + if (Interlocked.Exchange(ref _lastCleanupTicks, now.UtcTicks) == now.UtcTicks) + { + return; + } + + foreach (var (key, bucket) in Buckets) + { + if (now - bucket.WindowStartedAt > Window.Add(Window)) + { + Buckets.TryRemove(key, out _); + } + } + } + + private sealed class RateLimitBucket(DateTimeOffset windowStartedAt, int count) + { + public DateTimeOffset WindowStartedAt { get; set; } = windowStartedAt; + public int Count { get; set; } = count; + } +} diff --git a/src/Ikar.Server/Infrastructure/Auth/BotTokenService.cs b/src/Ikar.Server/Infrastructure/Auth/BotTokenService.cs new file mode 100644 index 00000000..1d9ae144 --- /dev/null +++ b/src/Ikar.Server/Infrastructure/Auth/BotTokenService.cs @@ -0,0 +1,23 @@ +using System.Security.Cryptography; +using System.Text; + +namespace Ikar.Server.Infrastructure.Auth; + +public sealed class BotTokenService +{ + public string GenerateToken(Guid botId) + { + var secret = Convert.ToBase64String(RandomNumberGenerator.GetBytes(32)) + .TrimEnd('=') + .Replace('+', '-') + .Replace('/', '_'); + + return $"ikarbot_{botId:N}_{secret}"; + } + + public string HashToken(string token) + { + var hashBytes = SHA256.HashData(Encoding.UTF8.GetBytes(token)); + return Convert.ToHexString(hashBytes); + } +} diff --git a/src/Ikar.Server/Infrastructure/Auth/ClientVersionRequestReader.cs b/src/Ikar.Server/Infrastructure/Auth/ClientVersionRequestReader.cs new file mode 100644 index 00000000..1d8111d7 --- /dev/null +++ b/src/Ikar.Server/Infrastructure/Auth/ClientVersionRequestReader.cs @@ -0,0 +1,133 @@ +using Microsoft.AspNetCore.Http; +using Ikar.Server.Data.Entities; +using Ikar.Shared; + +namespace Ikar.Server.Infrastructure.Auth; + +public sealed record ClientVersionSnapshot( + string? ClientPlatform, + int? ClientVersionCode, + string? ClientVersionName); + +public static class ClientVersionRequestReader +{ + private const int MaxClientPlatformLength = 32; + private const int MaxClientVersionNameLength = 64; + + public static ClientVersionSnapshot Read(HttpRequest request) + { + var clientPlatform = TrimToNull( + ReadString(request, ClientVersionMetadata.PlatformHeaderName, ClientVersionMetadata.PlatformQueryName), + MaxClientPlatformLength); + + var clientVersionName = TrimToNull( + ReadString(request, ClientVersionMetadata.VersionNameHeaderName, ClientVersionMetadata.VersionNameQueryName), + MaxClientVersionNameLength); + + var clientVersionCode = TryGetClientVersionCode(request, out var parsedVersionCode) + ? parsedVersionCode + : (int?)null; + + return new ClientVersionSnapshot(clientPlatform, clientVersionCode, clientVersionName); + } + + public static bool Apply(UserSession session, ClientVersionSnapshot snapshot) + { + return Apply( + snapshot, + session.ClientPlatform, + session.ClientVersionCode, + session.ClientVersionName, + platform => session.ClientPlatform = platform, + versionCode => session.ClientVersionCode = versionCode, + versionName => session.ClientVersionName = versionName); + } + + public static bool Apply(PushDevice device, ClientVersionSnapshot snapshot) + { + return Apply( + snapshot, + device.ClientPlatform, + device.ClientVersionCode, + device.ClientVersionName, + platform => device.ClientPlatform = platform, + versionCode => device.ClientVersionCode = versionCode, + versionName => device.ClientVersionName = versionName); + } + + private static bool Apply( + ClientVersionSnapshot snapshot, + string? currentPlatform, + int? currentVersionCode, + string? currentVersionName, + Action<string?> setPlatform, + Action<int?> setVersionCode, + Action<string?> setVersionName) + { + var changed = false; + + if (snapshot.ClientPlatform is not null && + !string.Equals(currentPlatform, snapshot.ClientPlatform, StringComparison.Ordinal)) + { + setPlatform(snapshot.ClientPlatform); + changed = true; + } + + if (snapshot.ClientVersionCode is not null && currentVersionCode != snapshot.ClientVersionCode) + { + setVersionCode(snapshot.ClientVersionCode); + changed = true; + } + + if (snapshot.ClientVersionName is not null && + !string.Equals(currentVersionName, snapshot.ClientVersionName, StringComparison.Ordinal)) + { + setVersionName(snapshot.ClientVersionName); + changed = true; + } + + return changed; + } + + public static bool TryGetClientPlatform(HttpRequest request, out string? clientPlatform) + { + clientPlatform = TrimToNull( + ReadString(request, ClientVersionMetadata.PlatformHeaderName, ClientVersionMetadata.PlatformQueryName), + MaxClientPlatformLength); + return clientPlatform is not null; + } + + public static bool TryGetClientVersionCode(HttpRequest request, out int clientVersionCode) + { + clientVersionCode = 0; + var rawVersionCode = ReadString( + request, + ClientVersionMetadata.VersionCodeHeaderName, + ClientVersionMetadata.VersionCodeQueryName); + + return int.TryParse(rawVersionCode, out clientVersionCode) && clientVersionCode > 0; + } + + private static string? ReadString(HttpRequest request, string headerName, string queryName) + { + var value = request.Headers[headerName].ToString(); + if (!string.IsNullOrWhiteSpace(value)) + { + return value; + } + + value = request.Query[queryName].ToString(); + return string.IsNullOrWhiteSpace(value) ? null : value; + } + + private static string? TrimToNull(string? value, int maxLength) + { + if (string.IsNullOrWhiteSpace(value)) + { + return null; + } + + var trimmed = value.Trim(); + return trimmed.Length <= maxLength ? trimmed : trimmed[..maxLength]; + } +} diff --git a/src/Ikar.Server/Infrastructure/Auth/JwtOptions.cs b/src/Ikar.Server/Infrastructure/Auth/JwtOptions.cs new file mode 100644 index 00000000..9f3e230a --- /dev/null +++ b/src/Ikar.Server/Infrastructure/Auth/JwtOptions.cs @@ -0,0 +1,16 @@ +namespace Ikar.Server.Infrastructure.Auth; + +public sealed class JwtOptions +{ + public const string SectionName = "Jwt"; + + public string Issuer { get; init; } = "Ikar.Server"; + + public string Audience { get; init; } = "Ikar.Client"; + + public string SigningKey { get; init; } = "Ikar-Replace-This-Development-Key-With-A-Long-Random-Secret-CHANGE_ME_USE_SECRET_STORE_VALUE_32_CHARS_MINIMUM"; + + public int AccessTokenLifetimeMinutes { get; init; } = 15; + + public int RefreshTokenLifetimeDays { get; init; } = 30; +} diff --git a/src/Ikar.Server/Infrastructure/Auth/PhoneAuthChallengeStore.cs b/src/Ikar.Server/Infrastructure/Auth/PhoneAuthChallengeStore.cs new file mode 100644 index 00000000..7026c6ef --- /dev/null +++ b/src/Ikar.Server/Infrastructure/Auth/PhoneAuthChallengeStore.cs @@ -0,0 +1,64 @@ +using System.Collections.Concurrent; +using System.Security.Cryptography; + +namespace Ikar.Server.Infrastructure.Auth; + +public sealed class PhoneAuthChallengeStore +{ + private static readonly TimeSpan ChallengeLifetime = TimeSpan.FromMinutes(10); + private readonly ConcurrentDictionary<string, PhoneAuthChallenge> _challenges = new(StringComparer.Ordinal); + + public PhoneAuthChallenge Create(string normalizedPhoneNumber, bool isRegistered) + { + CleanupExpired(); + + var challenge = new PhoneAuthChallenge( + Guid.NewGuid().ToString("N"), + normalizedPhoneNumber, + RandomNumberGenerator.GetInt32(0, 1_000_000).ToString("D6"), + DateTimeOffset.UtcNow.Add(ChallengeLifetime), + isRegistered); + + _challenges[challenge.Id] = challenge; + return challenge; + } + + public PhoneAuthChallenge? Consume(string challengeId, string normalizedPhoneNumber, string code) + { + CleanupExpired(); + + if (!_challenges.TryGetValue(challengeId, out var challenge)) + { + return null; + } + + if (!string.Equals(challenge.NormalizedPhoneNumber, normalizedPhoneNumber, StringComparison.Ordinal) || + !string.Equals(challenge.Code, code, StringComparison.Ordinal) || + challenge.ExpiresAt <= DateTimeOffset.UtcNow) + { + return null; + } + + _challenges.TryRemove(challenge.Id, out _); + return challenge; + } + + private void CleanupExpired() + { + var now = DateTimeOffset.UtcNow; + foreach (var pair in _challenges) + { + if (pair.Value.ExpiresAt <= now) + { + _challenges.TryRemove(pair.Key, out _); + } + } + } + + public sealed record PhoneAuthChallenge( + string Id, + string NormalizedPhoneNumber, + string Code, + DateTimeOffset ExpiresAt, + bool IsRegistered); +} diff --git a/src/Ikar.Server/Infrastructure/Auth/TokenService.cs b/src/Ikar.Server/Infrastructure/Auth/TokenService.cs new file mode 100644 index 00000000..dc641245 --- /dev/null +++ b/src/Ikar.Server/Infrastructure/Auth/TokenService.cs @@ -0,0 +1,163 @@ +using System.IdentityModel.Tokens.Jwt; +using System.Security.Claims; +using System.Security.Cryptography; +using System.Text; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Options; +using Microsoft.IdentityModel.Tokens; +using Ikar.Server.Data; +using Ikar.Server.Data.Entities; +using Ikar.Shared; + +namespace Ikar.Server.Infrastructure.Auth; + +public sealed class TokenService( + IkarDbContext dbContext, + IOptions<JwtOptions> jwtOptions) +{ + private readonly JwtOptions _jwtOptions = jwtOptions.Value; + + public Task<AuthSessionDto> CreateSessionAsync(User user, CancellationToken cancellationToken = default) => + CreateSessionAsync(user, null, cancellationToken); + + public async Task<AuthSessionDto> CreateSessionAsync( + User user, + ClientVersionSnapshot? clientVersion, + CancellationToken cancellationToken = default) + { + var now = DateTimeOffset.UtcNow; + var session = new UserSession + { + UserId = user.Id, + LastSeenAt = now + }; + if (clientVersion is not null) + { + ClientVersionRequestReader.Apply(session, clientVersion); + } + + var refreshToken = CreateOpaqueToken(); + session.TokenHash = HashToken(refreshToken); + dbContext.UserSessions.Add(session); + + await dbContext.SaveChangesAsync(cancellationToken); + return CreateAuthSession(user, session.Id, refreshToken, now); + } + + public Task<AuthSessionDto?> RefreshSessionAsync(string rawRefreshToken, CancellationToken cancellationToken = default) => + RefreshSessionAsync(rawRefreshToken, null, cancellationToken); + + public async Task<AuthSessionDto?> RefreshSessionAsync( + string rawRefreshToken, + ClientVersionSnapshot? clientVersion, + CancellationToken cancellationToken = default) + { + var hash = HashToken(rawRefreshToken); + var session = await dbContext.UserSessions.SingleOrDefaultAsync(x => x.TokenHash == hash, cancellationToken); + if (session is null) + { + return null; + } + + if (session.CreatedAt.AddDays(_jwtOptions.RefreshTokenLifetimeDays) <= DateTimeOffset.UtcNow) + { + dbContext.UserSessions.Remove(session); + await dbContext.SaveChangesAsync(cancellationToken); + return null; + } + + var user = await dbContext.Users.SingleAsync(x => x.Id == session.UserId, cancellationToken); + if (user.IsBlocked) + { + dbContext.UserSessions.Remove(session); + await dbContext.SaveChangesAsync(cancellationToken); + return null; + } + + var now = DateTimeOffset.UtcNow; + var refreshToken = CreateOpaqueToken(); + session.TokenHash = HashToken(refreshToken); + session.CreatedAt = now; + session.LastSeenAt = now; + if (clientVersion is not null) + { + ClientVersionRequestReader.Apply(session, clientVersion); + } + + await dbContext.SaveChangesAsync(cancellationToken); + + return CreateAuthSession(user, session.Id, refreshToken, now); + } + + public async Task<bool> RevokeSessionAsync(Guid sessionId, CancellationToken cancellationToken = default) + { + var session = await dbContext.UserSessions.SingleOrDefaultAsync(x => x.Id == sessionId, cancellationToken); + if (session is null) + { + return false; + } + + dbContext.UserSessions.Remove(session); + await dbContext.SaveChangesAsync(cancellationToken); + return true; + } + + public static string HashToken(string token) + { + var hashBytes = SHA256.HashData(Encoding.UTF8.GetBytes(token)); + return Convert.ToHexString(hashBytes); + } + + private AuthSessionDto CreateAuthSession(User user, Guid sessionId, string refreshToken, DateTimeOffset now) + { + var accessTokenExpiresAt = now.AddMinutes(_jwtOptions.AccessTokenLifetimeMinutes); + var credentials = new SigningCredentials( + new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_jwtOptions.SigningKey)), + SecurityAlgorithms.HmacSha256); + + var claims = new List<Claim> + { + new(ClaimTypes.NameIdentifier, user.Id.ToString()), + new(ClaimTypes.Name, user.DisplayName), + new("username", user.Username), + new("sid", sessionId.ToString()), + new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString("N")) + }; + + var jwt = new JwtSecurityToken( + issuer: _jwtOptions.Issuer, + audience: _jwtOptions.Audience, + claims: claims, + notBefore: now.UtcDateTime, + expires: accessTokenExpiresAt.UtcDateTime, + signingCredentials: credentials); + + var accessToken = new JwtSecurityTokenHandler().WriteToken(jwt); + + return new AuthSessionDto( + accessToken, + refreshToken, + accessTokenExpiresAt, + new( + user.Id, + user.Username, + user.DisplayName, + false, + user.PhoneNumber, + now, + user.About, + string.IsNullOrWhiteSpace(user.AvatarStoragePath) + ? null + : $"/api/users/{user.Id}/avatar/{Uri.EscapeDataString(Path.GetFileName(user.AvatarStoragePath))}", + user.IsBot)); + } + + private static string CreateOpaqueToken() + { + var tokenBytes = RandomNumberGenerator.GetBytes(32); + return Convert.ToBase64String(tokenBytes) + .TrimEnd('=') + .Replace('+', '-') + .Replace('/', '_'); + } +} diff --git a/src/Ikar.Server/Infrastructure/Bots/BotUpdateService.cs b/src/Ikar.Server/Infrastructure/Bots/BotUpdateService.cs new file mode 100644 index 00000000..1ebee96b --- /dev/null +++ b/src/Ikar.Server/Infrastructure/Bots/BotUpdateService.cs @@ -0,0 +1,94 @@ +using Ikar.Server.Data; +using Ikar.Server.Data.Entities; +using Microsoft.EntityFrameworkCore; + +namespace Ikar.Server.Infrastructure.Bots; + +public sealed class BotUpdateService(IkarDbContext dbContext) +{ + public async Task EnqueueIncomingMessageAsync(Guid messageId, CancellationToken cancellationToken = default) + { + var message = await dbContext.Messages + .Include(x => x.Sender) + .Include(x => x.Chat) + .ThenInclude(x => x.Members) + .ThenInclude(x => x.User) + .ThenInclude(x => x.BotProfile) + .SingleOrDefaultAsync(x => x.Id == messageId, cancellationToken); + + if (message is null || + message.Sender.IsBot || + message.Chat.Type != Ikar.Shared.ChatType.Direct || + message.Chat.Members.Count != 2) + { + return; + } + + var botMember = message.Chat.Members + .FirstOrDefault(member => member.User.IsBot && member.User.BotProfile is not null); + if (botMember?.User.BotProfile is null || !botMember.User.BotProfile.IsEnabled) + { + return; + } + + var updateExists = await dbContext.BotUpdates + .AsNoTracking() + .AnyAsync( + x => x.BotId == botMember.User.BotProfile.Id && + x.MessageId == message.Id, + cancellationToken); + if (updateExists) + { + return; + } + + dbContext.BotUpdates.Add(new BotUpdate + { + BotId = botMember.User.BotProfile.Id, + ChatId = message.ChatId, + MessageId = message.Id, + CreatedAt = message.SentAt + }); + + await dbContext.SaveChangesAsync(cancellationToken); + } + + public async Task MarkMessagesReadByBotAsync( + Guid botUserId, + IReadOnlyCollection<Message> messages, + CancellationToken cancellationToken = default) + { + var maxByChat = messages + .GroupBy(message => message.ChatId) + .Select(group => new + { + ChatId = group.Key, + MaxSentAt = group.Max(message => message.SentAt) + }) + .ToList(); + + foreach (var item in maxByChat) + { + var membership = await dbContext.ChatMembers + .SingleOrDefaultAsync( + x => x.ChatId == item.ChatId && x.UserId == botUserId, + cancellationToken); + if (membership is null) + { + continue; + } + + if (membership.LastDeliveredAt is null || membership.LastDeliveredAt < item.MaxSentAt) + { + membership.LastDeliveredAt = item.MaxSentAt; + } + + if (membership.LastReadAt is null || membership.LastReadAt < item.MaxSentAt) + { + membership.LastReadAt = item.MaxSentAt; + } + } + + await dbContext.SaveChangesAsync(cancellationToken); + } +} diff --git a/src/Ikar.Server/Infrastructure/Calls/CallSessionStore.cs b/src/Ikar.Server/Infrastructure/Calls/CallSessionStore.cs new file mode 100644 index 00000000..78c6e6a7 --- /dev/null +++ b/src/Ikar.Server/Infrastructure/Calls/CallSessionStore.cs @@ -0,0 +1,213 @@ +using Ikar.Shared; + +namespace Ikar.Server.Infrastructure.Calls; + +public sealed class CallSessionStore +{ + private readonly object _sync = new(); + private readonly Dictionary<Guid, StoredCallSession> _sessions = []; + private readonly Dictionary<Guid, Guid> _participantCalls = []; + + public bool TryCreate(Guid chatId, Guid callerId, Guid calleeId, CallMediaType mediaType, out ActiveCallSession? session, out string error) + { + lock (_sync) + { + if (_participantCalls.ContainsKey(callerId)) + { + error = "У вас уже есть активный звонок."; + session = default; + return false; + } + + if (_participantCalls.ContainsKey(calleeId)) + { + error = "Собеседник уже занят другим звонком."; + session = default; + return false; + } + + var stored = new StoredCallSession(chatId, callerId, calleeId, mediaType); + _sessions[stored.CallId] = stored; + _participantCalls[callerId] = stored.CallId; + _participantCalls[calleeId] = stored.CallId; + session = stored.ToSnapshot(); + error = string.Empty; + return true; + } + } + + public bool TryAccept(Guid callId, Guid userId, out ActiveCallSession? session, out string error) + { + lock (_sync) + { + if (!_sessions.TryGetValue(callId, out var stored)) + { + error = "Звонок уже завершён."; + session = default; + return false; + } + + if (stored.State != CallSessionState.Ringing) + { + error = "Звонок уже недоступен."; + session = default; + return false; + } + + if (stored.CalleeId != userId) + { + error = "Принять звонок может только вызываемый пользователь."; + session = default; + return false; + } + + stored.State = CallSessionState.Connected; + stored.ConnectedAt = DateTimeOffset.UtcNow; + session = stored.ToSnapshot(); + error = string.Empty; + return true; + } + } + + public bool TryDecline(Guid callId, Guid userId, out ActiveCallSession? session, out string error) => + TryTerminate(callId, userId, "declined", requireParticipant: true, out session, out error); + + public bool TryEnd(Guid callId, Guid userId, string reason, out ActiveCallSession? session, out string error) => + TryTerminate(callId, userId, reason, requireParticipant: true, out session, out error); + + public bool TryEndByParticipant(Guid userId, string reason, out ActiveCallSession? session) + { + lock (_sync) + { + if (!_participantCalls.TryGetValue(userId, out var callId) || + !_sessions.TryGetValue(callId, out var stored)) + { + session = default; + return false; + } + + RemoveSession(stored, reason); + session = stored.ToSnapshot(); + return true; + } + } + + public bool TryResolveRecipient(Guid callId, Guid senderUserId, out Guid recipientUserId, out ActiveCallSession? session, out string error) + { + lock (_sync) + { + if (!_sessions.TryGetValue(callId, out var stored)) + { + recipientUserId = Guid.Empty; + session = default; + error = "Звонок уже завершён."; + return false; + } + + if (stored.State != CallSessionState.Connected) + { + recipientUserId = Guid.Empty; + session = default; + error = "Соединение ещё не установлено."; + return false; + } + + if (stored.CallerId == senderUserId) + { + recipientUserId = stored.CalleeId; + session = stored.ToSnapshot(); + error = string.Empty; + return true; + } + + if (stored.CalleeId == senderUserId) + { + recipientUserId = stored.CallerId; + session = stored.ToSnapshot(); + error = string.Empty; + return true; + } + + recipientUserId = Guid.Empty; + session = default; + error = "Нет доступа к этому звонку."; + return false; + } + } + + private bool TryTerminate(Guid callId, Guid userId, string reason, bool requireParticipant, out ActiveCallSession? session, out string error) + { + lock (_sync) + { + if (!_sessions.TryGetValue(callId, out var stored)) + { + error = "Звонок уже завершён."; + session = default; + return false; + } + + if (requireParticipant && stored.CallerId != userId && stored.CalleeId != userId) + { + error = "Нет доступа к этому звонку."; + session = default; + return false; + } + + RemoveSession(stored, reason); + session = stored.ToSnapshot(); + error = string.Empty; + return true; + } + } + + private void RemoveSession(StoredCallSession stored, string reason) + { + stored.State = CallSessionState.Ended; + stored.EndReason = reason; + _sessions.Remove(stored.CallId); + _participantCalls.Remove(stored.CallerId); + _participantCalls.Remove(stored.CalleeId); + } + + private sealed class StoredCallSession(Guid chatId, Guid callerId, Guid calleeId, CallMediaType mediaType) + { + public Guid CallId { get; } = Guid.NewGuid(); + + public Guid ChatId { get; } = chatId; + + public Guid CallerId { get; } = callerId; + + public Guid CalleeId { get; } = calleeId; + + public CallMediaType MediaType { get; } = mediaType; + + public DateTimeOffset CreatedAt { get; } = DateTimeOffset.UtcNow; + + public DateTimeOffset? ConnectedAt { get; set; } + + public string EndReason { get; set; } = string.Empty; + + public CallSessionState State { get; set; } = CallSessionState.Ringing; + + public ActiveCallSession ToSnapshot() => + new(CallId, ChatId, CallerId, CalleeId, MediaType, CreatedAt, ConnectedAt, EndReason, State); + } +} + +public sealed record ActiveCallSession( + Guid CallId, + Guid ChatId, + Guid CallerId, + Guid CalleeId, + CallMediaType MediaType, + DateTimeOffset CreatedAt, + DateTimeOffset? ConnectedAt, + string EndReason, + CallSessionState State); + +public enum CallSessionState +{ + Ringing = 0, + Connected = 1, + Ended = 2 +} diff --git a/src/Ikar.Server/Infrastructure/Calls/WebRtcOptions.cs b/src/Ikar.Server/Infrastructure/Calls/WebRtcOptions.cs new file mode 100644 index 00000000..f432fca2 --- /dev/null +++ b/src/Ikar.Server/Infrastructure/Calls/WebRtcOptions.cs @@ -0,0 +1,34 @@ +using Ikar.Shared; + +namespace Ikar.Server.Infrastructure.Calls; + +public sealed class WebRtcOptions +{ + public const string SectionName = "WebRtc"; + + public List<WebRtcIceServerOptions> IceServers { get; set; } = + [ + new() + { + Urls = ["stun:stun.l.google.com:19302"] + } + ]; + + public IReadOnlyList<CallIceServerDto> ToDto() => + IceServers + .Where(server => server.Urls.Count > 0) + .Select(server => new CallIceServerDto( + Urls: server.Urls, + Username: string.IsNullOrWhiteSpace(server.Username) ? null : server.Username, + Credential: string.IsNullOrWhiteSpace(server.Credential) ? null : server.Credential)) + .ToList(); +} + +public sealed class WebRtcIceServerOptions +{ + public List<string> Urls { get; set; } = []; + + public string? Username { get; set; } + + public string? Credential { get; set; } +} diff --git a/src/Ikar.Server/Infrastructure/ClaimsPrincipalExtensions.cs b/src/Ikar.Server/Infrastructure/ClaimsPrincipalExtensions.cs new file mode 100644 index 00000000..d9ecef83 --- /dev/null +++ b/src/Ikar.Server/Infrastructure/ClaimsPrincipalExtensions.cs @@ -0,0 +1,22 @@ +using System.Security.Claims; + +namespace Ikar.Server.Infrastructure; + +public static class ClaimsPrincipalExtensions +{ + public static Guid GetRequiredUserId(this ClaimsPrincipal principal) + { + var value = principal.FindFirstValue(ClaimTypes.NameIdentifier); + return Guid.TryParse(value, out var userId) + ? userId + : throw new InvalidOperationException("Authenticated user id claim is missing."); + } + + public static Guid GetRequiredSessionId(this ClaimsPrincipal principal) + { + var value = principal.FindFirstValue("sid"); + return Guid.TryParse(value, out var sessionId) + ? sessionId + : throw new InvalidOperationException("Authenticated session id claim is missing."); + } +} diff --git a/src/Ikar.Server/Infrastructure/DatabaseSeeder.cs b/src/Ikar.Server/Infrastructure/DatabaseSeeder.cs new file mode 100644 index 00000000..91a32c74 --- /dev/null +++ b/src/Ikar.Server/Infrastructure/DatabaseSeeder.cs @@ -0,0 +1,119 @@ +using Microsoft.AspNetCore.Identity; +using Microsoft.EntityFrameworkCore; +using Ikar.Server.Data; +using Ikar.Server.Data.Entities; +using Ikar.Shared; + +namespace Ikar.Server.Infrastructure; + +public sealed class DatabaseSeeder(IkarDbContext dbContext) +{ + private readonly PasswordHasher<User> _passwordHasher = new(); + + public async Task SeedAsync() + { + if (await dbContext.Users.AnyAsync()) + { + return; + } + + var alice = CreateUser("alice", "Alice Carter", "demo123", "+10000000001"); + var bob = CreateUser("bob", "Bob Miller", "demo123", "+10000000002"); + var carol = CreateUser("carol", "Carol Stone", "demo123", "+10000000003"); + + var directChat = new Chat + { + Type = ChatType.Direct, + CreatedBy = alice, + CreatedAt = DateTimeOffset.UtcNow.AddMinutes(-30), + LastActivityAt = DateTimeOffset.UtcNow.AddMinutes(-5), + Members = + [ + new ChatMember { User = alice, IsOwner = true, JoinedAt = DateTimeOffset.UtcNow.AddMinutes(-30) }, + new ChatMember { User = bob, IsOwner = false, JoinedAt = DateTimeOffset.UtcNow.AddMinutes(-30) } + ] + }; + + var groupChat = new Chat + { + Type = ChatType.Group, + Title = "Ikar Crew", + CreatedBy = alice, + CreatedAt = DateTimeOffset.UtcNow.AddMinutes(-20), + LastActivityAt = DateTimeOffset.UtcNow.AddMinutes(-2), + Members = + [ + new ChatMember { User = alice, IsOwner = true, JoinedAt = DateTimeOffset.UtcNow.AddMinutes(-20) }, + new ChatMember { User = bob, IsOwner = false, JoinedAt = DateTimeOffset.UtcNow.AddMinutes(-20) }, + new ChatMember { User = carol, IsOwner = false, JoinedAt = DateTimeOffset.UtcNow.AddMinutes(-20) } + ] + }; + + directChat.Messages.Add(new Message + { + Chat = directChat, + Sender = alice, + Text = "Welcome to Ikar. This is a seeded private dialog.", + SentAt = DateTimeOffset.UtcNow.AddMinutes(-10) + }); + + directChat.Messages.Add(new Message + { + Chat = directChat, + Sender = bob, + Text = "Messages are stored in SQLite and delivered through SignalR.", + SentAt = DateTimeOffset.UtcNow.AddMinutes(-5) + }); + + groupChat.Messages.Add(new Message + { + Chat = groupChat, + Sender = carol, + Text = "Use this group to validate shared conversations.", + SentAt = DateTimeOffset.UtcNow.AddMinutes(-2) + }); + + var channel = new Chat + { + Type = ChatType.Channel, + Title = "Ikar Updates", + CreatedBy = alice, + CreatedAt = DateTimeOffset.UtcNow.AddMinutes(-15), + LastActivityAt = DateTimeOffset.UtcNow.AddMinutes(-1), + Members = + [ + new ChatMember { User = alice, IsOwner = true, Role = ChatMemberRole.Owner, CanPinMessages = true, CanDeleteMessages = true, CanInviteUsers = true, CanPostMessages = true, JoinedAt = DateTimeOffset.UtcNow.AddMinutes(-15) }, + new ChatMember { User = bob, IsOwner = false, CanPostMessages = false, JoinedAt = DateTimeOffset.UtcNow.AddMinutes(-15) }, + new ChatMember { User = carol, IsOwner = false, CanPostMessages = false, JoinedAt = DateTimeOffset.UtcNow.AddMinutes(-15) } + ] + }; + + channel.Messages.Add(new Message + { + Chat = channel, + Sender = alice, + Text = "This channel is broadcast-only. Owners can post, subscribers can read.", + PostedAsChannel = true, + SentAt = DateTimeOffset.UtcNow.AddMinutes(-1) + }); + + dbContext.Users.AddRange(alice, bob, carol); + dbContext.Chats.AddRange(directChat, groupChat, channel); + await dbContext.SaveChangesAsync(); + } + + private User CreateUser(string username, string displayName, string password, string phoneNumber) + { + var user = new User + { + Username = username, + NormalizedUsername = username.ToUpperInvariant(), + DisplayName = displayName, + PhoneNumber = phoneNumber, + NormalizedPhoneNumber = PhoneNumberNormalizer.Normalize(phoneNumber) + }; + + user.PasswordHash = _passwordHasher.HashPassword(user, password); + return user; + } +} diff --git a/src/Ikar.Server/Infrastructure/DtoMapper.cs b/src/Ikar.Server/Infrastructure/DtoMapper.cs new file mode 100644 index 00000000..102d5526 --- /dev/null +++ b/src/Ikar.Server/Infrastructure/DtoMapper.cs @@ -0,0 +1,666 @@ +using Ikar.Server.Data.Entities; +using Ikar.Shared; + +namespace Ikar.Server.Infrastructure; + +public static class DtoMapper +{ + private static readonly HashSet<string> AudioExtensions = + [ + ".aac", + ".flac", + ".m4a", + ".mp3", + ".oga", + ".ogg", + ".opus", + ".wav", + ".webm" + ]; + + public static UserSummaryDto ToDto(this User user, PresenceTracker presenceTracker) => + new( + user.Id, + user.Username, + user.DisplayName, + presenceTracker.IsOnline(user.Id), + user.PhoneNumber, + user.Sessions + .OrderByDescending(x => x.LastSeenAt) + .Select(x => (DateTimeOffset?)x.LastSeenAt) + .FirstOrDefault(), + user.About, + ResolveAvatarPath(user), + user.IsBot); + + public static BotSummaryDto ToDto(this Bot bot) => + new( + bot.Id, + bot.UserId, + bot.User.Username, + bot.User.DisplayName, + bot.User.About, + bot.IsEnabled, + bot.CreatedAt, + bot.Commands + .OrderBy(x => x.SortOrder) + .ThenBy(x => x.Command, StringComparer.OrdinalIgnoreCase) + .Select(x => new BotCommandDto(x.Command, x.Description)) + .ToList()); + + public static PushDeviceDto ToDto(this PushDevice device) => + new( + device.Id, + device.Platform, + device.InstallationId, + device.DeviceName, + device.NotificationsEnabled, + device.UpdatedAt); + + public static MessageDto ToDto(this Message message, PresenceTracker presenceTracker, Guid currentUserId) + { + var sender = ResolveMessageSenderDto(message, presenceTracker, currentUserId); + var displayAuthorName = ResolveDisplayAuthorName(message); + var viewCount = message.Chat?.Type == ChatType.Channel && message.PostedAsChannel + ? message.Views.Count + : (int?)null; + + return new MessageDto( + message.Id, + message.ChatId, + message.Chat?.Type ?? ChatType.Direct, + sender, + message.Text, + message.Attachments + .OrderBy(x => x.SortOrder) + .ThenBy(x => x.OriginalFileName) + .Select(x => x.ToDto(message.ChatId)) + .ToList(), + message.SentAt, + message.EditedAt, + message.DeletedAt, + ResolveDeliveryState(message, currentUserId), + message.ForwardedFromDisplayName, + BuildReplyDto(message.ReplyToMessage, message.Chat), + message.PinnedAt, + message.PinnedByUser?.ToDto(presenceTracker), + message.MediaAlbumId, + BuildReactions(message, currentUserId), + message.HasProtectedContent || message.Chat?.HasProtectedContent == true, + message.ExpiresAt, + message.TtlSeconds, + message.PostedAsChannel, + displayAuthorName, + viewCount); + } + + public static AttachmentDto ToDto(this MessageAttachment attachment, Guid chatId) => + new( + attachment.Id, + attachment.OriginalFileName, + attachment.ContentType, + attachment.FileSizeBytes, + $"/api/chats/{chatId}/attachments/{attachment.Id}/content", + ResolveAttachmentKind(attachment), + attachment.SortOrder); + + public static ChatSummaryDto ToSummaryDto( + this Chat chat, + Guid currentUserId, + PresenceTracker presenceTracker, + Message? latestMessage = null, + int? unreadCountOverride = null) + { + var currentMembership = chat.Members.FirstOrDefault(x => x.UserId == currentUserId); + var permissions = BuildPermissions(currentMembership, chat.Type); + var canSendMessages = permissions.CanPostMessages; + var channelStats = BuildChannelStats(chat); + var visibleMembers = BuildVisibleMembers(chat, currentMembership); + var orderedMembers = visibleMembers + .Select(x => x.User) + .OrderBy(x => x.DisplayName) + .ToList(); + + var participants = orderedMembers + .Select(user => user.ToDto(presenceTracker)) + .ToList(); + + var counterpart = chat.Type == ChatType.Direct + ? orderedMembers.FirstOrDefault(x => x.Id != currentUserId) + : null; + var title = chat.Type == ChatType.Direct + ? counterpart?.DisplayName ?? (string.IsNullOrWhiteSpace(chat.Title) ? "Dialog" : chat.Title) + : string.IsNullOrWhiteSpace(chat.Title) ? "New Group" : chat.Title; + + var secondary = chat.Type switch + { + ChatType.Direct => counterpart is null + ? null + : counterpart.IsBot + ? $"бот · @{counterpart.Username}" + : $"@{counterpart.Username}", + ChatType.Channel => $"{FormatSubscribers(channelStats.SubscriberCount)}{(canSendMessages ? " · публикация разрешена" : string.Empty)}", + _ => $"{participants.Count} members" + }; + + var lastMessage = latestMessage ?? chat.Messages.OrderByDescending(x => x.SentAt).FirstOrDefault(); + if (lastMessage is not null) + { + lastMessage.Chat ??= chat; + } + + var lastPreview = lastMessage is null ? null : BuildMessagePreview(lastMessage); + var lastMessageAttachment = lastMessage is null || + lastMessage.DeletedAt is not null || + !string.IsNullOrWhiteSpace(lastMessage.Text) + ? null + : lastMessage.Attachments + .OrderBy(attachment => attachment.SortOrder) + .ThenBy(attachment => attachment.Id) + .FirstOrDefault() + ?.ToDto(chat.Id); + var unreadCount = unreadCountOverride ?? chat.Messages.Count(message => + message.SenderId != currentUserId && + message.DeletedAt is null && + (currentMembership?.LastReadAt is null || message.SentAt > currentMembership.LastReadAt.Value)); + + return new ChatSummaryDto( + chat.Id, + title, + chat.Type, + secondary, + chat.LastActivityAt ?? chat.CreatedAt, + lastPreview, + participants, + canSendMessages, + unreadCount, + currentMembership?.IsPinned == true, + currentMembership?.IsArchived == true, + currentMembership?.MutedUntil is not null && currentMembership.MutedUntil > DateTimeOffset.UtcNow, + currentMembership?.FolderKey, + chat.HasProtectedContent, + chat.DefaultMessageTtlSeconds, + lastMessageAttachment, + channelStats.SubscriberCount, + channelStats.AdminCount, + currentMembership is not null, + chat.Type == ChatType.Channel && currentMembership is null && !string.IsNullOrWhiteSpace(chat.PublicUsername), + permissions.CanManageMembers, + chat.Type == ChatType.Channel && canSendMessages, + chat.PublicUsername, + chat.LinkedDiscussionChatId, + chat.ChannelSignaturesEnabled, + chat.Description); + } + + public static ChatDetailsDto ToDetailsDto( + this Chat chat, + Guid currentUserId, + PresenceTracker presenceTracker, + Guid? firstUnreadMessageId = null, + IReadOnlyList<Message>? messagesOverride = null, + bool hasMoreMessages = false, + IReadOnlyList<Message>? pinnedMessagesOverride = null) + { + var currentMembership = chat.Members.FirstOrDefault(x => x.UserId == currentUserId); + var permissions = BuildPermissions(currentMembership, chat.Type); + var canSendMessages = permissions.CanPostMessages; + var channelStats = BuildChannelStats(chat); + var visibleMembers = BuildVisibleMembers(chat, currentMembership); + var participants = visibleMembers + .Select(x => x.User) + .OrderBy(x => x.DisplayName) + .Select(x => x.ToDto(presenceTracker)) + .ToList(); + var members = visibleMembers + .OrderByDescending(x => x.IsOwner || x.Role == ChatMemberRole.Owner) + .ThenByDescending(x => x.Role == ChatMemberRole.Admin) + .ThenBy(x => x.User.DisplayName) + .Select(x => new ChatMemberDto( + x.User.ToDto(presenceTracker), + x.Role, + x.IsOwner, + BuildPermissions(x, chat.Type))) + .ToList(); + + var counterpart = chat.Type == ChatType.Direct + ? chat.Members.Select(x => x.User).FirstOrDefault(x => x.Id != currentUserId) + : null; + + var title = chat.Type == ChatType.Direct + ? counterpart?.DisplayName ?? (string.IsNullOrWhiteSpace(chat.Title) ? "Dialog" : chat.Title) + : string.IsNullOrWhiteSpace(chat.Title) ? "New Group" : chat.Title; + + var sourceMessages = messagesOverride ?? chat.Messages.OrderBy(x => x.SentAt).ToList(); + foreach (var message in sourceMessages) + { + message.Chat = chat; + } + + var messages = sourceMessages + .OrderBy(x => x.SentAt) + .Select(x => x.ToDto(presenceTracker, currentUserId)) + .ToList(); + + var pinnedMessages = (pinnedMessagesOverride ?? chat.Messages + .Where(x => x.PinnedAt is not null && x.DeletedAt is null) + .OrderByDescending(x => x.PinnedAt) + .Take(5) + .ToList()) + .Select(message => + { + message.Chat = chat; + return message.ToDto(presenceTracker, currentUserId); + }) + .ToList(); + + return new ChatDetailsDto( + chat.Id, + title, + chat.Type, + participants, + canSendMessages, + messages, + firstUnreadMessageId, + hasMoreMessages, + pinnedMessages, + permissions, + members, + chat.HasProtectedContent, + chat.DefaultMessageTtlSeconds, + channelStats.SubscriberCount, + channelStats.AdminCount, + currentMembership is not null, + chat.Type == ChatType.Channel && currentMembership is null && !string.IsNullOrWhiteSpace(chat.PublicUsername), + permissions.CanManageMembers, + chat.Type == ChatType.Channel && canSendMessages, + chat.PublicUsername, + chat.LinkedDiscussionChatId, + chat.ChannelSignaturesEnabled, + chat.Description); + } + + public static ModerationLogDto ToDto(this ModerationLogEntry entry, PresenceTracker presenceTracker) => + new( + entry.Id, + entry.ChatId, + entry.ActorUserId, + string.IsNullOrWhiteSpace(entry.ActorUser.DisplayName) + ? entry.ActorUser.Username + : entry.ActorUser.DisplayName, + entry.TargetType, + entry.TargetId, + entry.Action, + entry.Reason, + entry.CreatedAt); + + public static StoryDto ToDto(this Story story, PresenceTracker presenceTracker, Guid currentUserId) => + new( + story.Id, + story.AuthorUser.ToDto(presenceTracker), + story.DeletedAt is null ? story.Text : string.Empty, + story.DeletedAt is null + ? story.Attachments + .OrderBy(x => x.SortOrder) + .ThenBy(x => x.OriginalFileName) + .Select(x => x.ToDto(story.Id)) + .ToList() + : [], + story.Visibility, + story.HasProtectedContent, + story.CreatedAt, + story.ExpiresAt, + story.DeletedAt, + story.Views.Count, + story.Views.Any(x => x.ViewerUserId == currentUserId)); + + public static AttachmentDto ToDto(this StoryAttachment attachment, Guid storyId) => + new( + attachment.Id, + attachment.OriginalFileName, + attachment.ContentType, + attachment.FileSizeBytes, + $"/api/stories/{storyId}/attachments/{attachment.Id}/content", + AttachmentKind.File, + attachment.SortOrder); + + public static ChatPermissionsDto BuildPermissions(ChatMember? membership, ChatType chatType) + { + if (membership is null) + { + return new ChatPermissionsDto(false, false, false, false, false); + } + + var isManager = membership.IsOwner || membership.Role is ChatMemberRole.Owner or ChatMemberRole.Admin; + var canPost = chatType != ChatType.Channel || isManager || membership.CanPostMessages; + return new ChatPermissionsDto( + CanPostMessages: canPost, + CanPinMessages: isManager || membership.CanPinMessages, + CanDeleteMessages: isManager || membership.CanDeleteMessages, + CanInviteUsers: isManager || membership.CanInviteUsers, + CanManageMembers: isManager); + } + + private static IReadOnlyList<ChatMember> BuildVisibleMembers(Chat chat, ChatMember? currentMembership) + { + if (chat.Type != ChatType.Channel) + { + return chat.Members.ToList(); + } + + if (currentMembership is not null && BuildPermissions(currentMembership, chat.Type).CanManageMembers) + { + return chat.Members.ToList(); + } + + if (currentMembership is null) + { + return chat.Members + .Where(IsChannelManager) + .ToList(); + } + + return chat.Members + .Where(member => member.UserId == currentMembership.UserId || IsChannelManager(member)) + .ToList(); + } + + private static ChannelStats BuildChannelStats(Chat chat) + { + if (chat.Type != ChatType.Channel) + { + var memberCount = chat.Members.Count; + return new ChannelStats(memberCount, chat.Members.Count(IsChannelManager)); + } + + return new ChannelStats( + chat.Members.Count, + chat.Members.Count(IsChannelManager)); + } + + private static bool IsChannelManager(ChatMember member) => + member.IsOwner || member.Role is ChatMemberRole.Owner or ChatMemberRole.Admin; + + private static string FormatSubscribers(int count) => + $"{count} {FormatRussianPlural(count, "подписчик", "подписчика", "подписчиков")}"; + + private static string FormatRussianPlural(int count, string one, string few, string many) + { + var value = Math.Abs(count) % 100; + var lastDigit = value % 10; + if (value is >= 11 and <= 14) + { + return many; + } + + return lastDigit switch + { + 1 => one, + >= 2 and <= 4 => few, + _ => many + }; + } + + private static UserSummaryDto ResolveMessageSenderDto(Message message, PresenceTracker presenceTracker, Guid currentUserId) + { + if (message.Chat is { Type: ChatType.Channel } chat && + message.PostedAsChannel && + !CanInspectChannelSender(chat, currentUserId)) + { + return BuildChannelIdentityDto(chat); + } + + return message.Sender.ToDto(presenceTracker); + } + + private static bool CanInspectChannelSender(Chat chat, Guid currentUserId) + { + var membership = chat.Members.FirstOrDefault(member => member.UserId == currentUserId); + return membership is not null && BuildPermissions(membership, chat.Type).CanManageMembers; + } + + private static UserSummaryDto BuildChannelIdentityDto(Chat chat) => + new( + chat.Id, + chat.PublicUsername ?? $"channel-{chat.Id:N}", + string.IsNullOrWhiteSpace(chat.Title) ? "Channel" : chat.Title!, + false, + null, + null, + null, + null); + + private static string ResolveDisplayAuthorName(Message message) + { + if (message.Chat?.Type == ChatType.Channel && message.PostedAsChannel) + { + if (!string.IsNullOrWhiteSpace(message.AuthorSignature)) + { + return message.AuthorSignature!; + } + + return string.IsNullOrWhiteSpace(message.Chat.Title) ? "Channel" : message.Chat.Title!; + } + + return string.IsNullOrWhiteSpace(message.Sender.DisplayName) + ? message.Sender.Username + : message.Sender.DisplayName; + } + + private static string ResolveReplyAuthorName(Message replyToMessage, Chat? currentChat) + { + if (currentChat?.Type == ChatType.Channel && replyToMessage.PostedAsChannel) + { + if (!string.IsNullOrWhiteSpace(replyToMessage.AuthorSignature)) + { + return replyToMessage.AuthorSignature!; + } + + return string.IsNullOrWhiteSpace(currentChat.Title) ? "Channel" : currentChat.Title!; + } + + return string.IsNullOrWhiteSpace(replyToMessage.Sender.DisplayName) + ? replyToMessage.Sender.Username + : replyToMessage.Sender.DisplayName; + } + + public static string BuildPushTitle(this Chat chat, Message message) => + chat.Type switch + { + ChatType.Direct => message.Sender.DisplayName, + ChatType.Channel when message.PostedAsChannel => string.IsNullOrWhiteSpace(chat.Title) ? "Channel" : chat.Title!, + _ => string.IsNullOrWhiteSpace(chat.Title) ? message.Sender.DisplayName : chat.Title! + }; + + public static string BuildPushBody(this Message message) + { + if (message.DeletedAt is not null) + { + return "Message removed"; + } + + if (!string.IsNullOrWhiteSpace(message.Text)) + { + return message.Attachments.Count == 0 + ? message.Text + : $"{message.Text} ({BuildAttachmentPreview(message.Attachments)})"; + } + + return BuildAttachmentPreview(message.Attachments); + } + + private static string BuildMessagePreview(Message lastMessage) + { + var isChannelPost = lastMessage.Chat?.Type == ChatType.Channel && lastMessage.PostedAsChannel; + + if (lastMessage.DeletedAt is not null) + { + return isChannelPost + ? "Публикация удалена" + : $"{ResolveDisplayAuthorName(lastMessage)}: message removed"; + } + + if (string.IsNullOrWhiteSpace(lastMessage.Text)) + { + var attachmentPreview = BuildAttachmentPreview(lastMessage.Attachments); + return isChannelPost + ? attachmentPreview + : $"{ResolveDisplayAuthorName(lastMessage)}: {attachmentPreview}"; + } + + var textPreview = lastMessage.Attachments.Count == 0 + ? lastMessage.Text + : $"{lastMessage.Text} ({BuildAttachmentPreview(lastMessage.Attachments)})"; + + return isChannelPost + ? textPreview + : $"{ResolveDisplayAuthorName(lastMessage)}: {textPreview}"; + } + + private static MessageReplyDto? BuildReplyDto(Message? replyToMessage, Chat? currentChat) + { + if (replyToMessage is null) + { + return null; + } + + return new MessageReplyDto( + replyToMessage.Id, + replyToMessage.SenderId, + ResolveReplyAuthorName(replyToMessage, currentChat), + BuildReplyPreviewText(replyToMessage)); + } + + private static IReadOnlyList<MessageReactionDto> BuildReactions(Message message, Guid currentUserId) => + message.Reactions + .Where(reaction => !string.IsNullOrWhiteSpace(reaction.Emoji)) + .GroupBy(reaction => reaction.Emoji) + .Select(group => new MessageReactionDto( + group.Key, + group.Count(), + group.Any(reaction => reaction.UserId == currentUserId))) + .OrderByDescending(reaction => reaction.Count) + .ThenBy(reaction => reaction.Emoji, StringComparer.Ordinal) + .ToList(); + + private static string BuildAttachmentPreview(ICollection<MessageAttachment> attachments) + { + if (attachments.Count == 0) + { + return string.Empty; + } + + if (attachments.Count == 1) + { + var attachment = attachments.First(); + if (ResolveAttachmentKind(attachment) == AttachmentKind.VoiceNote) + { + return "голосовое сообщение"; + } + + if (IsImageAttachment(attachment.ContentType, attachment.OriginalFileName)) + { + return "фотография"; + } + + return $"файл: {attachment.OriginalFileName}"; + } + + if (attachments.All(attachment => IsImageAttachment(attachment.ContentType, attachment.OriginalFileName))) + { + return $"{attachments.Count} {FormatRussianPlural(attachments.Count, "фотография", "фотографии", "фотографий")}"; + } + + return $"{attachments.Count} {FormatRussianPlural(attachments.Count, "файл", "файла", "файлов")}"; + } + + private static string BuildReplyPreviewText(Message message) + { + if (message.DeletedAt is not null) + { + return "Сообщение удалено"; + } + + if (!string.IsNullOrWhiteSpace(message.Text)) + { + return message.Attachments.Count == 0 + ? message.Text + : $"{message.Text} ({BuildAttachmentPreview(message.Attachments)})"; + } + + return BuildAttachmentPreview(message.Attachments); + } + + private static AttachmentKind ResolveAttachmentKind(MessageAttachment attachment) => + IsAudioAttachment(attachment.ContentType, attachment.OriginalFileName) + ? AttachmentKind.VoiceNote + : AttachmentKind.File; + + private static MessageDeliveryState ResolveDeliveryState(Message message, Guid currentUserId) + { + if (message.SenderId != currentUserId) + { + return MessageDeliveryState.Sent; + } + + var recipients = (message.Chat?.Members ?? []) + .Where(member => member.UserId != currentUserId) + .ToList(); + + if (recipients.Count == 0) + { + return MessageDeliveryState.Sent; + } + + if (recipients.All(member => member.LastReadAt is not null && member.LastReadAt.Value >= message.SentAt)) + { + return MessageDeliveryState.Read; + } + + if (recipients.All(member => member.LastDeliveredAt is not null && member.LastDeliveredAt.Value >= message.SentAt)) + { + return MessageDeliveryState.Delivered; + } + + return MessageDeliveryState.Sent; + } + + private static bool IsAudioAttachment(string? contentType, string? fileName) + { + if (!string.IsNullOrWhiteSpace(contentType) && + contentType.StartsWith("audio/", StringComparison.OrdinalIgnoreCase)) + { + return true; + } + + var extension = Path.GetExtension(fileName ?? string.Empty); + return AudioExtensions.Contains(extension); + } + + private static bool IsImageAttachment(string? contentType, string? fileName) + { + if (!string.IsNullOrWhiteSpace(contentType) && + contentType.StartsWith("image/", StringComparison.OrdinalIgnoreCase)) + { + return true; + } + + var extension = Path.GetExtension(fileName ?? string.Empty).ToLowerInvariant(); + return extension is ".jpg" or ".jpeg" or ".png" or ".webp" or ".gif"; + } + + private sealed record ChannelStats(int SubscriberCount, int AdminCount); + + private static string? ResolveAvatarPath(User user) + { + if (string.IsNullOrWhiteSpace(user.AvatarStoragePath)) + { + return null; + } + + var fileName = Path.GetFileName(user.AvatarStoragePath); + return string.IsNullOrWhiteSpace(fileName) + ? null + : $"/api/users/{user.Id}/avatar/{Uri.EscapeDataString(fileName)}"; + } +} diff --git a/src/Ikar.Server/Infrastructure/Hubs/MessengerHub.cs b/src/Ikar.Server/Infrastructure/Hubs/MessengerHub.cs new file mode 100644 index 00000000..50fc3702 --- /dev/null +++ b/src/Ikar.Server/Infrastructure/Hubs/MessengerHub.cs @@ -0,0 +1,367 @@ +using Ikar.Server.Data; +using Ikar.Server.Data.Entities; +using Ikar.Server.Infrastructure.Auth; +using Ikar.Server.Infrastructure.Calls; +using Ikar.Server.Infrastructure.Push; +using Ikar.Shared; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.SignalR; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Options; + +namespace Ikar.Server.Infrastructure.Hubs; + +[Authorize] +public sealed class MessengerHub( + PresenceTracker presenceTracker, + IkarDbContext dbContext, + CallSessionStore callSessionStore, + IPushNotificationSender pushNotificationSender, + IOptions<WebRtcOptions> webRtcOptions) : Hub +{ + public override async Task OnConnectedAsync() + { + var userId = Context.User!.GetRequiredUserId(); + await Groups.AddToGroupAsync(Context.ConnectionId, GetUserGroup(userId)); + presenceTracker.MarkOnline(userId); + await CaptureClientVersionAsync(); + await base.OnConnectedAsync(); + } + + public override async Task OnDisconnectedAsync(Exception? exception) + { + if (Context.User?.Identity?.IsAuthenticated == true) + { + var userId = Context.User.GetRequiredUserId(); + presenceTracker.MarkOffline(userId); + if (callSessionStore.TryEndByParticipant(userId, "disconnected", out var endedSession)) + { + await NotifyCallEndedAsync(endedSession!); + } + } + + await base.OnDisconnectedAsync(exception); + } + + public Task<OutgoingCallDto> StartCall(Guid chatId) => + StartMediaCall(chatId, CallMediaType.Audio); + + public async Task SendTyping(Guid chatId, bool isTyping) + { + var userId = Context.User!.GetRequiredUserId(); + var chat = await dbContext.Chats + .Include(x => x.Members) + .ThenInclude(x => x.User) + .AsNoTracking() + .SingleOrDefaultAsync( + x => x.Id == chatId && x.Members.Any(member => member.UserId == userId), + Context.ConnectionAborted); + + if (chat is null) + { + throw new HubException("Chat not found."); + } + + if (chat.Type == ChatType.Channel && + !DtoMapper.BuildPermissions(chat.Members.FirstOrDefault(member => member.UserId == userId), chat.Type).CanPostMessages) + { + throw new HubException("Read-only channel."); + } + + var sender = chat.Members.Single(member => member.UserId == userId).User; + var payload = new TypingIndicatorDto(chatId, sender.ToDto(presenceTracker), isTyping, DateTimeOffset.UtcNow); + var tasks = chat.Members + .Where(member => member.UserId != userId) + .Select(member => Clients.User(member.UserId.ToString()) + .SendAsync("TypingChanged", payload, Context.ConnectionAborted)); + + await Task.WhenAll(tasks); + } + + public async Task<OutgoingCallDto> StartMediaCall(Guid chatId, CallMediaType mediaType) + { + var callerId = Context.User!.GetRequiredUserId(); + var chat = await LoadDirectChatAsync(chatId, callerId); + var caller = chat.Members.Single(member => member.UserId == callerId).User; + var callee = chat.Members.Single(member => member.UserId != callerId).User; + var calleeDevices = await LoadPushDevicesAsync(callee.Id); + var hasRealtimePresence = presenceTracker.IsOnline(callee.Id); + var hasPushDevices = calleeDevices.Count > 0; + + if (!hasRealtimePresence && !hasPushDevices) + { + throw new HubException("Собеседник сейчас недоступен для звонка."); + } + + if (!callSessionStore.TryCreate(chatId, callerId, callee.Id, mediaType, out var session, out var error)) + { + throw new HubException(error); + } + + var callerDto = caller.ToDto(presenceTracker); + var calleeDto = callee.ToDto(presenceTracker); + var resolvedSession = session!; + var incomingCall = new IncomingCallDto(resolvedSession.CallId, chatId, callerDto, mediaType); + + if (hasRealtimePresence) + { + await Clients.Group(GetUserGroup(callee.Id)) + .SendAsync("IncomingCall", incomingCall); + } + + if (hasPushDevices) + { + await pushNotificationSender.SendIncomingCallAsync(incomingCall, calleeDevices, Context.ConnectionAborted); + } + + return new OutgoingCallDto(resolvedSession.CallId, chatId, calleeDto, mediaType); + } + + public async Task AcceptCall(Guid callId) + { + var userId = Context.User!.GetRequiredUserId(); + if (!callSessionStore.TryAccept(callId, userId, out var session, out var error)) + { + throw new HubException(error); + } + + var resolvedSession = session!; + var connectedAt = resolvedSession.ConnectedAt ?? DateTimeOffset.UtcNow; + await NotifyCallConnectedAsync(resolvedSession with { ConnectedAt = connectedAt }); + } + + public async Task DeclineCall(Guid callId) + { + var userId = Context.User!.GetRequiredUserId(); + if (!callSessionStore.TryDecline(callId, userId, out var session, out var error)) + { + throw new HubException(error); + } + + await NotifyCallEndedAsync(session!); + } + + public async Task EndCall(Guid callId) + { + var userId = Context.User!.GetRequiredUserId(); + if (!callSessionStore.TryEnd(callId, userId, "ended", out var session, out var error)) + { + throw new HubException(error); + } + + await NotifyCallEndedAsync(session!); + } + + public async Task SendCallOffer(CallOfferDto payload) + { + if (string.IsNullOrWhiteSpace(payload.Sdp) || string.IsNullOrWhiteSpace(payload.Type)) + { + throw new HubException("Пустое WebRTC offer."); + } + + var recipientUserId = ResolveRecipientForConnectedCall(payload.CallId); + await Clients.Group(GetUserGroup(recipientUserId)) + .SendAsync("CallOffer", payload); + } + + public async Task SendCallAnswer(CallAnswerDto payload) + { + if (string.IsNullOrWhiteSpace(payload.Sdp) || string.IsNullOrWhiteSpace(payload.Type)) + { + throw new HubException("Пустой WebRTC answer."); + } + + var recipientUserId = ResolveRecipientForConnectedCall(payload.CallId); + await Clients.Group(GetUserGroup(recipientUserId)) + .SendAsync("CallAnswer", payload); + } + + public async Task SendCallIceCandidate(CallIceCandidateDto payload) + { + if (string.IsNullOrWhiteSpace(payload.Candidate)) + { + throw new HubException("Пустой ICE candidate."); + } + + var recipientUserId = ResolveRecipientForConnectedCall(payload.CallId); + await Clients.Group(GetUserGroup(recipientUserId)) + .SendAsync("CallIceCandidate", payload); + } + + private Guid ResolveRecipientForConnectedCall(Guid callId) + { + var userId = Context.User!.GetRequiredUserId(); + if (!callSessionStore.TryResolveRecipient(callId, userId, out var recipientUserId, out _, out var error)) + { + throw new HubException(error); + } + + return recipientUserId; + } + + private async Task<Chat> LoadDirectChatAsync(Guid chatId, Guid userId) + { + var chat = await dbContext.Chats + .Include(x => x.Members) + .ThenInclude(x => x.User) + .SingleOrDefaultAsync(x => x.Id == chatId); + + if (chat is null) + { + throw new HubException("Чат не найден."); + } + + if (chat.Type != ChatType.Direct) + { + throw new HubException("Звонки доступны только в личных чатах."); + } + + if (!chat.Members.Any(member => member.UserId == userId)) + { + throw new HubException("Нет доступа к этому чату."); + } + + if (chat.Members.Count != 2) + { + throw new HubException("Для звонка требуется один собеседник."); + } + + if (chat.Members.Any(member => member.User.IsBot)) + { + throw new HubException("Звонки недоступны в чатах с ботами."); + } + + return chat; + } + + private Task NotifyCallConnectedAsync(ActiveCallSession session) + { + var iceServers = webRtcOptions.Value.ToDto(); + var callerPayload = new CallConnectedDto( + session.CallId, + session.ChatId, + session.ConnectedAt ?? DateTimeOffset.UtcNow, + session.MediaType, + iceServers, + ShouldCreateOffer: true); + var calleePayload = new CallConnectedDto( + session.CallId, + session.ChatId, + session.ConnectedAt ?? DateTimeOffset.UtcNow, + session.MediaType, + iceServers, + ShouldCreateOffer: false); + + return Task.WhenAll( + Clients.Group(GetUserGroup(session.CallerId)).SendAsync("CallConnected", callerPayload), + Clients.Group(GetUserGroup(session.CalleeId)).SendAsync("CallConnected", calleePayload)); + } + + private async Task NotifyCallEndedAsync(ActiveCallSession session) + { + var payload = new CallEndedDto(session.CallId, session.ChatId, session.EndReason, session.MediaType); + await Task.WhenAll( + Clients.Group(GetUserGroup(session.CallerId)).SendAsync("CallEnded", payload), + Clients.Group(GetUserGroup(session.CalleeId)).SendAsync("CallEnded", payload)); + + await PersistCallHistoryMessageAsync(session); + + var calleeDevices = await LoadPushDevicesAsync(session.CalleeId); + if (calleeDevices.Count > 0) + { + await pushNotificationSender.SendCallEndedAsync(payload, calleeDevices, Context.ConnectionAborted); + } + } + + private async Task PersistCallHistoryMessageAsync(ActiveCallSession session) + { + var chat = await dbContext.Chats + .Include(x => x.Members) + .SingleOrDefaultAsync(x => x.Id == session.ChatId, Context.ConnectionAborted); + + if (chat is null || chat.Members.Count == 0) + { + return; + } + + var sender = await dbContext.Users + .SingleOrDefaultAsync(x => x.Id == session.CallerId, Context.ConnectionAborted); + + if (sender is null) + { + return; + } + + var sentAt = DateTimeOffset.UtcNow; + var message = new Message + { + ChatId = chat.Id, + Chat = chat, + SenderId = sender.Id, + Sender = sender, + Text = BuildCallHistoryText(session), + SentAt = sentAt + }; + + chat.LastActivityAt = sentAt; + dbContext.Messages.Add(message); + await dbContext.SaveChangesAsync(Context.ConnectionAborted); + await NotifyMessageCreatedAsync(chat, message); + } + + private Task NotifyMessageCreatedAsync(Chat chat, Message message) + { + var tasks = chat.Members.Select(async member => + { + var dto = message.ToDto(presenceTracker, member.UserId); + await Clients.User(member.UserId.ToString()) + .SendAsync("MessageCreated", dto, Context.ConnectionAborted); + }); + + return Task.WhenAll(tasks); + } + + private static string BuildCallHistoryText(ActiveCallSession session) + { + var callType = session.MediaType == CallMediaType.Video ? "Видеозвонок" : "Аудиозвонок"; + if (session.ConnectedAt is not null) + { + return $"{callType} завершён"; + } + + return session.EndReason switch + { + "declined" => "Вызов отклонён", + _ => $"{callType} без ответа" + }; + } + + private Task<List<PushDevice>> LoadPushDevicesAsync(Guid userId) => + dbContext.PushDevices + .Where(device => device.UserId == userId && device.NotificationsEnabled) + .ToListAsync(Context.ConnectionAborted); + + private async Task CaptureClientVersionAsync() + { + var httpContext = Context.GetHttpContext(); + if (httpContext is null) + { + return; + } + + var sessionId = Context.User!.GetRequiredSessionId(); + var session = await dbContext.UserSessions + .SingleOrDefaultAsync(x => x.Id == sessionId, Context.ConnectionAborted); + if (session is null) + { + return; + } + + if (ClientVersionRequestReader.Apply(session, ClientVersionRequestReader.Read(httpContext.Request))) + { + await dbContext.SaveChangesAsync(Context.ConnectionAborted); + } + } + + private static string GetUserGroup(Guid userId) => $"user:{userId:D}"; +} diff --git a/src/Ikar.Server/Infrastructure/PresenceTracker.cs b/src/Ikar.Server/Infrastructure/PresenceTracker.cs new file mode 100644 index 00000000..ffc2881c --- /dev/null +++ b/src/Ikar.Server/Infrastructure/PresenceTracker.cs @@ -0,0 +1,41 @@ +using System.Collections.Concurrent; + +namespace Ikar.Server.Infrastructure; + +public sealed class PresenceTracker +{ + private readonly ConcurrentDictionary<Guid, int> _connections = new(); + + public void MarkOnline(Guid userId) + { + _connections.AddOrUpdate(userId, 1, (_, current) => current + 1); + } + + public void MarkOffline(Guid userId) + { + while (true) + { + if (!_connections.TryGetValue(userId, out var current)) + { + return; + } + + if (current <= 1) + { + if (_connections.TryRemove(userId, out _)) + { + return; + } + + continue; + } + + if (_connections.TryUpdate(userId, current - 1, current)) + { + return; + } + } + } + + public bool IsOnline(Guid userId) => _connections.ContainsKey(userId); +} diff --git a/src/Ikar.Server/Infrastructure/Push/FirebasePushNotificationSender.cs b/src/Ikar.Server/Infrastructure/Push/FirebasePushNotificationSender.cs new file mode 100644 index 00000000..874e8c97 --- /dev/null +++ b/src/Ikar.Server/Infrastructure/Push/FirebasePushNotificationSender.cs @@ -0,0 +1,492 @@ +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; +using Microsoft.Extensions.Options; +using Ikar.Server.Data; +using Ikar.Server.Data.Entities; +using Ikar.Shared; + +namespace Ikar.Server.Infrastructure.Push; + +public sealed class FirebasePushNotificationSender( + HttpClient httpClient, + IOptions<PushOptions> options, + IHostEnvironment hostEnvironment, + IkarDbContext dbContext, + ILogger<FirebasePushNotificationSender> logger) : IPushNotificationSender +{ + private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web); + private readonly PushOptions _options = options.Value; + private readonly SemaphoreSlim _tokenLock = new(1, 1); + + private OAuthTokenCache? _cachedToken; + private ServiceAccountCredentials? _cachedCredentials; + + public async Task SendMessageAsync( + Chat chat, + Message message, + IReadOnlyCollection<PushDevice> devices, + CancellationToken cancellationToken = default) + { + var androidDevices = GetAndroidDevices(devices); + + if (androidDevices.Count == 0) + { + return; + } + + var credentials = LoadCredentials(); + if (credentials is null) + { + logger.LogDebug("Firebase push is not configured. Skipping {DeviceCount} Android device(s).", androidDevices.Count); + return; + } + + var projectId = string.IsNullOrWhiteSpace(_options.FirebaseProjectId) + ? credentials.ProjectId + : _options.FirebaseProjectId.Trim(); + + if (string.IsNullOrWhiteSpace(projectId)) + { + logger.LogWarning("Push:FirebaseProjectId is empty and the service account JSON does not contain project_id."); + return; + } + + var accessToken = await GetAccessTokenAsync(credentials, cancellationToken); + var title = chat.BuildPushTitle(message); + var body = message.BuildPushBody(); + + foreach (var device in androidDevices) + { + await SendToAndroidAsync( + projectId, + accessToken, + device, + new Dictionary<string, string> + { + [PushDataKeys.Kind] = PushDataKinds.ChatMessage, + [PushDataKeys.ChatId] = message.ChatId.ToString(), + [PushDataKeys.ChatType] = chat.Type.ToString(), + [PushDataKeys.MessageId] = message.Id.ToString(), + [PushDataKeys.Title] = title, + [PushDataKeys.Body] = body, + [PushDataKeys.SenderId] = message.SenderId.ToString(), + [PushDataKeys.SenderName] = message.Sender.DisplayName, + [PushDataKeys.SenderPhoneNumber] = message.Sender.PhoneNumber ?? string.Empty + }, + cancellationToken); + } + } + + public async Task SendMessageReactionAsync( + Chat chat, + Message message, + User reactor, + string emoji, + IReadOnlyCollection<PushDevice> devices, + CancellationToken cancellationToken = default) + { + var androidDevices = GetAndroidDevices(devices); + if (androidDevices.Count == 0) + { + return; + } + + var credentials = LoadCredentials(); + if (credentials is null) + { + logger.LogDebug("Firebase push is not configured. Skipping reaction push for {DeviceCount} Android device(s).", androidDevices.Count); + return; + } + + var projectId = ResolveProjectId(credentials); + if (string.IsNullOrWhiteSpace(projectId)) + { + logger.LogWarning("Push:FirebaseProjectId is empty and the service account JSON does not contain project_id."); + return; + } + + var accessToken = await GetAccessTokenAsync(credentials, cancellationToken); + var title = BuildReactionPushTitle(chat, reactor); + var body = BuildReactionPushBody(emoji); + + foreach (var device in androidDevices) + { + await SendToAndroidAsync( + projectId, + accessToken, + device, + new Dictionary<string, string> + { + [PushDataKeys.Kind] = PushDataKinds.MessageReaction, + [PushDataKeys.ChatId] = message.ChatId.ToString(), + [PushDataKeys.ChatType] = chat.Type.ToString(), + [PushDataKeys.MessageId] = message.Id.ToString(), + [PushDataKeys.Title] = title, + [PushDataKeys.Body] = body, + [PushDataKeys.SenderId] = reactor.Id.ToString(), + [PushDataKeys.SenderName] = ResolveDisplayName(reactor), + [PushDataKeys.SenderPhoneNumber] = reactor.PhoneNumber ?? string.Empty, + [PushDataKeys.ReactionEmoji] = emoji + }, + cancellationToken); + } + } + + public async Task SendIncomingCallAsync( + IncomingCallDto call, + IReadOnlyCollection<PushDevice> devices, + CancellationToken cancellationToken = default) + { + var androidDevices = GetAndroidDevices(devices); + if (androidDevices.Count == 0) + { + return; + } + + var credentials = LoadCredentials(); + if (credentials is null) + { + logger.LogDebug("Firebase push is not configured. Skipping incoming call push for {CallId}.", call.CallId); + return; + } + + var projectId = ResolveProjectId(credentials); + if (string.IsNullOrWhiteSpace(projectId)) + { + logger.LogWarning("Push:FirebaseProjectId is empty and the service account JSON does not contain project_id."); + return; + } + + var accessToken = await GetAccessTokenAsync(credentials, cancellationToken); + var isVideoCall = call.MediaType == CallMediaType.Video; + var data = new Dictionary<string, string> + { + [PushDataKeys.Kind] = isVideoCall ? PushDataKinds.VideoCallInvite : PushDataKinds.AudioCallInvite, + [PushDataKeys.CallId] = call.CallId.ToString(), + [PushDataKeys.ChatId] = call.ChatId.ToString(), + [PushDataKeys.SenderId] = call.Caller.Id.ToString(), + [PushDataKeys.SenderName] = call.Caller.DisplayName, + [PushDataKeys.SenderPhoneNumber] = call.Caller.PhoneNumber ?? string.Empty, + [PushDataKeys.CallMediaType] = call.MediaType.ToString(), + [PushDataKeys.Title] = call.Caller.DisplayName, + [PushDataKeys.Body] = isVideoCall ? "Входящий видеозвонок" : "Входящий аудиозвонок" + }; + + foreach (var device in androidDevices) + { + await SendToAndroidAsync(projectId, accessToken, device, data, cancellationToken); + } + } + + public async Task SendCallEndedAsync( + CallEndedDto call, + IReadOnlyCollection<PushDevice> devices, + CancellationToken cancellationToken = default) + { + var androidDevices = GetAndroidDevices(devices); + if (androidDevices.Count == 0) + { + return; + } + + var credentials = LoadCredentials(); + if (credentials is null) + { + logger.LogDebug("Firebase push is not configured. Skipping call ended push for {CallId}.", call.CallId); + return; + } + + var projectId = ResolveProjectId(credentials); + if (string.IsNullOrWhiteSpace(projectId)) + { + logger.LogWarning("Push:FirebaseProjectId is empty and the service account JSON does not contain project_id."); + return; + } + + var accessToken = await GetAccessTokenAsync(credentials, cancellationToken); + var isVideoCall = call.MediaType == CallMediaType.Video; + var data = new Dictionary<string, string> + { + [PushDataKeys.Kind] = isVideoCall ? PushDataKinds.VideoCallEnded : PushDataKinds.AudioCallEnded, + [PushDataKeys.CallId] = call.CallId.ToString(), + [PushDataKeys.ChatId] = call.ChatId.ToString(), + [PushDataKeys.CallMediaType] = call.MediaType.ToString(), + [PushDataKeys.Body] = call.Reason + }; + + foreach (var device in androidDevices) + { + await SendToAndroidAsync(projectId, accessToken, device, data, cancellationToken); + } + } + + public async Task SendAppUpdateAvailableAsync( + AndroidAppReleaseDto release, + IReadOnlyCollection<PushDevice> devices, + CancellationToken cancellationToken = default) + { + var androidDevices = GetAndroidDevices(devices); + if (androidDevices.Count == 0) + { + return; + } + + var credentials = LoadCredentials(); + if (credentials is null) + { + logger.LogDebug("Firebase push is not configured. Skipping app update push for version {VersionCode}.", release.VersionCode); + return; + } + + var projectId = ResolveProjectId(credentials); + if (string.IsNullOrWhiteSpace(projectId)) + { + logger.LogWarning("Push:FirebaseProjectId is empty and the service account JSON does not contain project_id."); + return; + } + + var accessToken = await GetAccessTokenAsync(credentials, cancellationToken); + var data = new Dictionary<string, string> + { + [PushDataKeys.Kind] = PushDataKinds.AppUpdateAvailable, + [PushDataKeys.Title] = "Доступно обновление Ikar", + [PushDataKeys.Body] = $"Доступна версия {release.VersionName}", + [PushDataKeys.VersionCode] = release.VersionCode.ToString(), + [PushDataKeys.VersionName] = release.VersionName + }; + + foreach (var device in androidDevices) + { + await SendToAndroidAsync(projectId, accessToken, device, data, cancellationToken); + } + } + + private ServiceAccountCredentials? LoadCredentials() + { + if (_cachedCredentials is not null) + { + return _cachedCredentials; + } + + var json = _options.ServiceAccountJson; + if (string.IsNullOrWhiteSpace(json) && !string.IsNullOrWhiteSpace(_options.ServiceAccountJsonPath)) + { + var path = _options.ServiceAccountJsonPath; + if (!Path.IsPathRooted(path)) + { + path = Path.Combine(hostEnvironment.ContentRootPath, path); + } + + if (!File.Exists(path)) + { + logger.LogWarning("Push service account file was not found: {Path}", path); + return null; + } + + json = File.ReadAllText(path); + } + + if (string.IsNullOrWhiteSpace(json)) + { + return null; + } + + try + { + var credentials = JsonSerializer.Deserialize<ServiceAccountCredentials>(json, JsonOptions); + if (credentials is null || + string.IsNullOrWhiteSpace(credentials.ClientEmail) || + string.IsNullOrWhiteSpace(credentials.PrivateKey)) + { + logger.LogWarning("Push service account JSON is missing required fields."); + return null; + } + + _cachedCredentials = credentials; + return credentials; + } + catch (JsonException ex) + { + logger.LogWarning(ex, "Push service account JSON could not be parsed."); + return null; + } + } + + private async Task<string> GetAccessTokenAsync(ServiceAccountCredentials credentials, CancellationToken cancellationToken) + { + var now = DateTimeOffset.UtcNow; + if (_cachedToken is not null && _cachedToken.ExpiresAt > now.AddMinutes(5)) + { + return _cachedToken.AccessToken; + } + + await _tokenLock.WaitAsync(cancellationToken); + try + { + now = DateTimeOffset.UtcNow; + if (_cachedToken is not null && _cachedToken.ExpiresAt > now.AddMinutes(5)) + { + return _cachedToken.AccessToken; + } + + var assertion = CreateJwtAssertion(credentials, now); + using var request = new HttpRequestMessage(HttpMethod.Post, credentials.TokenUri ?? "https://oauth2.googleapis.com/token") + { + Content = new FormUrlEncodedContent( + [ + new KeyValuePair<string, string>("grant_type", "urn:ietf:params:oauth:grant-type:jwt-bearer"), + new KeyValuePair<string, string>("assertion", assertion) + ]) + }; + + using var response = await httpClient.SendAsync(request, cancellationToken); + var payload = await response.Content.ReadAsStringAsync(cancellationToken); + if (!response.IsSuccessStatusCode) + { + throw new InvalidOperationException( + string.IsNullOrWhiteSpace(payload) + ? $"Failed to acquire Firebase access token: {(int)response.StatusCode}." + : $"Failed to acquire Firebase access token: {payload}"); + } + + var tokenResponse = JsonSerializer.Deserialize<OAuthTokenResponse>(payload, JsonOptions) + ?? throw new InvalidOperationException("Firebase access token response was empty."); + + _cachedToken = new OAuthTokenCache( + tokenResponse.AccessToken, + now.AddSeconds(tokenResponse.ExpiresIn)); + + return _cachedToken.AccessToken; + } + finally + { + _tokenLock.Release(); + } + } + + private async Task SendToAndroidAsync( + string projectId, + string accessToken, + PushDevice device, + IReadOnlyDictionary<string, string> data, + CancellationToken cancellationToken) + { + var payload = new + { + message = new + { + token = device.DeviceToken, + data, + android = new + { + priority = "high", + ttl = "45s" + } + } + }; + + using var request = new HttpRequestMessage( + HttpMethod.Post, + $"https://fcm.googleapis.com/v1/projects/{projectId}/messages:send"); + request.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", accessToken); + request.Content = new StringContent(JsonSerializer.Serialize(payload, JsonOptions), Encoding.UTF8, "application/json"); + + using var response = await httpClient.SendAsync(request, cancellationToken); + if (response.IsSuccessStatusCode) + { + return; + } + + var responseBody = await response.Content.ReadAsStringAsync(cancellationToken); + logger.LogWarning( + "Push send failed for installation {InstallationId} with status {StatusCode}. Response: {ResponseBody}", + device.InstallationId, + (int)response.StatusCode, + responseBody); + + if (!ShouldDeleteDevice(responseBody)) + { + return; + } + + dbContext.PushDevices.Remove(device); + await dbContext.SaveChangesAsync(cancellationToken); + } + + private static bool ShouldDeleteDevice(string? responseBody) => + !string.IsNullOrWhiteSpace(responseBody) && + (responseBody.Contains("UNREGISTERED", StringComparison.OrdinalIgnoreCase) || + responseBody.Contains("registration-token-not-registered", StringComparison.OrdinalIgnoreCase)); + + private static List<PushDevice> GetAndroidDevices(IReadOnlyCollection<PushDevice> devices) => + devices + .Where(x => x.Platform == PushPlatform.Android && !string.IsNullOrWhiteSpace(x.DeviceToken)) + .ToList(); + + private string ResolveProjectId(ServiceAccountCredentials credentials) => + string.IsNullOrWhiteSpace(_options.FirebaseProjectId) + ? credentials.ProjectId + : _options.FirebaseProjectId.Trim(); + + private static string BuildReactionPushTitle(Chat chat, User reactor) + { + var reactorName = ResolveDisplayName(reactor); + return chat.Type switch + { + ChatType.Direct => reactorName, + _ => string.IsNullOrWhiteSpace(chat.Title) ? reactorName : chat.Title! + }; + } + + private static string BuildReactionPushBody(string emoji) => + string.IsNullOrWhiteSpace(emoji) + ? "\u041d\u043e\u0432\u0430\u044f \u0440\u0435\u0430\u043a\u0446\u0438\u044f \u043d\u0430 \u0432\u0430\u0448\u0435 \u0441\u043e\u043e\u0431\u0449\u0435\u043d\u0438\u0435" + : $"\u0420\u0435\u0430\u043a\u0446\u0438\u044f {emoji} \u043d\u0430 \u0432\u0430\u0448\u0435 \u0441\u043e\u043e\u0431\u0449\u0435\u043d\u0438\u0435"; + + private static string ResolveDisplayName(User user) => + string.IsNullOrWhiteSpace(user.DisplayName) + ? user.Username + : user.DisplayName; + + private static string CreateJwtAssertion(ServiceAccountCredentials credentials, DateTimeOffset now) + { + var header = Base64UrlEncode("""{"alg":"RS256","typ":"JWT"}"""); + var payload = Base64UrlEncode(JsonSerializer.Serialize(new Dictionary<string, object> + { + ["iss"] = credentials.ClientEmail, + ["scope"] = "https://www.googleapis.com/auth/firebase.messaging", + ["aud"] = credentials.TokenUri ?? "https://oauth2.googleapis.com/token", + ["iat"] = now.ToUnixTimeSeconds(), + ["exp"] = now.AddHours(1).ToUnixTimeSeconds() + })); + + var unsignedToken = $"{header}.{payload}"; + using var rsa = RSA.Create(); + rsa.ImportFromPem(credentials.PrivateKey); + var signatureBytes = rsa.SignData(Encoding.UTF8.GetBytes(unsignedToken), HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); + return $"{unsignedToken}.{Base64UrlEncode(signatureBytes)}"; + } + + private static string Base64UrlEncode(string value) => + Base64UrlEncode(Encoding.UTF8.GetBytes(value)); + + private static string Base64UrlEncode(byte[] value) => + Convert.ToBase64String(value) + .TrimEnd('=') + .Replace('+', '-') + .Replace('/', '_'); + + private sealed record OAuthTokenCache(string AccessToken, DateTimeOffset ExpiresAt); + + private sealed record OAuthTokenResponse( + [property: JsonPropertyName("access_token")] string AccessToken, + [property: JsonPropertyName("expires_in")] int ExpiresIn); + + private sealed record ServiceAccountCredentials( + [property: JsonPropertyName("project_id")] string ProjectId, + [property: JsonPropertyName("client_email")] string ClientEmail, + [property: JsonPropertyName("private_key")] string PrivateKey, + [property: JsonPropertyName("token_uri")] string? TokenUri); +} diff --git a/src/Ikar.Server/Infrastructure/Push/IPushNotificationSender.cs b/src/Ikar.Server/Infrastructure/Push/IPushNotificationSender.cs new file mode 100644 index 00000000..c0926d76 --- /dev/null +++ b/src/Ikar.Server/Infrastructure/Push/IPushNotificationSender.cs @@ -0,0 +1,36 @@ +using Ikar.Server.Data.Entities; +using Ikar.Shared; + +namespace Ikar.Server.Infrastructure.Push; + +public interface IPushNotificationSender +{ + Task SendMessageAsync( + Chat chat, + Message message, + IReadOnlyCollection<PushDevice> devices, + CancellationToken cancellationToken = default); + + Task SendMessageReactionAsync( + Chat chat, + Message message, + User reactor, + string emoji, + IReadOnlyCollection<PushDevice> devices, + CancellationToken cancellationToken = default); + + Task SendIncomingCallAsync( + IncomingCallDto call, + IReadOnlyCollection<PushDevice> devices, + CancellationToken cancellationToken = default); + + Task SendCallEndedAsync( + CallEndedDto call, + IReadOnlyCollection<PushDevice> devices, + CancellationToken cancellationToken = default); + + Task SendAppUpdateAvailableAsync( + AndroidAppReleaseDto release, + IReadOnlyCollection<PushDevice> devices, + CancellationToken cancellationToken = default); +} diff --git a/src/Ikar.Server/Infrastructure/Push/PushDispatchBackgroundService.cs b/src/Ikar.Server/Infrastructure/Push/PushDispatchBackgroundService.cs new file mode 100644 index 00000000..9c3b71fd --- /dev/null +++ b/src/Ikar.Server/Infrastructure/Push/PushDispatchBackgroundService.cs @@ -0,0 +1,97 @@ +using Microsoft.EntityFrameworkCore; +using Ikar.Server.Data; + +namespace Ikar.Server.Infrastructure.Push; + +public sealed class PushDispatchBackgroundService( + IServiceScopeFactory serviceScopeFactory, + PushDispatchQueue queue, + ILogger<PushDispatchBackgroundService> logger) : BackgroundService +{ + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + await foreach (var workItem in queue.ReadAllAsync(stoppingToken)) + { + try + { + await ProcessAsync(workItem, stoppingToken); + } + catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) + { + break; + } + catch (Exception ex) + { + logger.LogWarning( + ex, + "Background push dispatch failed for chat {ChatId}, message {MessageId}, kind {Kind}.", + workItem.ChatId, + workItem.MessageId, + workItem.Kind); + } + } + } + + private async Task ProcessAsync(PushDispatchWorkItem workItem, CancellationToken cancellationToken) + { + await using var scope = serviceScopeFactory.CreateAsyncScope(); + var dbContext = scope.ServiceProvider.GetRequiredService<IkarDbContext>(); + var dispatcher = scope.ServiceProvider.GetRequiredService<PushNotificationDispatcher>(); + + var chat = await dbContext.Chats + .Include(x => x.Members) + .SingleOrDefaultAsync(x => x.Id == workItem.ChatId, cancellationToken); + if (chat is null) + { + logger.LogDebug("Skipping push dispatch because chat {ChatId} was not found.", workItem.ChatId); + return; + } + + var message = await dbContext.Messages + .Include(x => x.Sender) + .Include(x => x.Attachments) + .SingleOrDefaultAsync( + x => x.Id == workItem.MessageId && + x.ChatId == workItem.ChatId, + cancellationToken); + if (message is null) + { + logger.LogDebug( + "Skipping push dispatch because message {MessageId} for chat {ChatId} was not found.", + workItem.MessageId, + workItem.ChatId); + return; + } + + if (workItem.Kind == PushDispatchKind.MessageReaction) + { + if (workItem.ActorUserId is null || string.IsNullOrWhiteSpace(workItem.ReactionEmoji)) + { + logger.LogDebug( + "Skipping reaction push dispatch for message {MessageId} because actor or emoji is missing.", + workItem.MessageId); + return; + } + + var reactor = await dbContext.Users + .SingleOrDefaultAsync(x => x.Id == workItem.ActorUserId.Value, cancellationToken); + if (reactor is null) + { + logger.LogDebug( + "Skipping reaction push dispatch because reactor {UserId} was not found.", + workItem.ActorUserId.Value); + return; + } + + await dispatcher.DispatchMessageReactionAsync( + chat, + message, + reactor, + workItem.ReactionEmoji, + cancellationToken); + return; + } + + await dispatcher.DispatchMessageAsync(chat, message, cancellationToken); + } +} diff --git a/src/Ikar.Server/Infrastructure/Push/PushDispatchQueue.cs b/src/Ikar.Server/Infrastructure/Push/PushDispatchQueue.cs new file mode 100644 index 00000000..1dc286ec --- /dev/null +++ b/src/Ikar.Server/Infrastructure/Push/PushDispatchQueue.cs @@ -0,0 +1,47 @@ +using System.Threading.Channels; + +namespace Ikar.Server.Infrastructure.Push; + +public enum PushDispatchKind +{ + ChatMessage = 0, + MessageReaction = 1 +} + +public sealed record PushDispatchWorkItem( + Guid ChatId, + Guid MessageId, + PushDispatchKind Kind = PushDispatchKind.ChatMessage, + Guid? ActorUserId = null, + string? ReactionEmoji = null); + +public sealed class PushDispatchQueue +{ + private readonly Channel<PushDispatchWorkItem> _channel = Channel.CreateUnbounded<PushDispatchWorkItem>( + new UnboundedChannelOptions + { + SingleReader = true, + SingleWriter = false + }); + + public ValueTask EnqueueAsync(Guid chatId, Guid messageId, CancellationToken cancellationToken = default) => + _channel.Writer.WriteAsync(new PushDispatchWorkItem(chatId, messageId), cancellationToken); + + public ValueTask EnqueueReactionAsync( + Guid chatId, + Guid messageId, + Guid reactorUserId, + string emoji, + CancellationToken cancellationToken = default) => + _channel.Writer.WriteAsync( + new PushDispatchWorkItem( + chatId, + messageId, + PushDispatchKind.MessageReaction, + reactorUserId, + emoji), + cancellationToken); + + public IAsyncEnumerable<PushDispatchWorkItem> ReadAllAsync(CancellationToken cancellationToken) => + _channel.Reader.ReadAllAsync(cancellationToken); +} diff --git a/src/Ikar.Server/Infrastructure/Push/PushNotificationDispatcher.cs b/src/Ikar.Server/Infrastructure/Push/PushNotificationDispatcher.cs new file mode 100644 index 00000000..15dc7eaa --- /dev/null +++ b/src/Ikar.Server/Infrastructure/Push/PushNotificationDispatcher.cs @@ -0,0 +1,83 @@ +using Microsoft.EntityFrameworkCore; +using Ikar.Server.Data; +using Ikar.Server.Data.Entities; + +namespace Ikar.Server.Infrastructure.Push; + +public sealed class PushNotificationDispatcher( + IkarDbContext dbContext, + IPushNotificationSender pushNotificationSender, + ILogger<PushNotificationDispatcher> logger) +{ + public async Task DispatchMessageAsync(Chat chat, Message message, CancellationToken cancellationToken = default) + { + var recipientIds = chat.Members + .Select(x => x.UserId) + .Where(x => x != message.SenderId) + .Distinct() + .ToList(); + + if (recipientIds.Count == 0) + { + return; + } + + var devices = await dbContext.PushDevices + .Where(x => recipientIds.Contains(x.UserId) && x.NotificationsEnabled) + .ToListAsync(cancellationToken); + + if (devices.Count == 0) + { + return; + } + + try + { + await pushNotificationSender.SendMessageAsync(chat, message, devices, cancellationToken); + } + catch (Exception ex) + { + logger.LogWarning(ex, "Push dispatch failed for message {MessageId}.", message.Id); + } + } + + public async Task DispatchMessageReactionAsync( + Chat chat, + Message message, + User reactor, + string emoji, + CancellationToken cancellationToken = default) + { + if (message.SenderId == reactor.Id || string.IsNullOrWhiteSpace(emoji)) + { + return; + } + + var devices = await dbContext.PushDevices + .Where(x => x.UserId == message.SenderId && x.NotificationsEnabled) + .ToListAsync(cancellationToken); + + if (devices.Count == 0) + { + return; + } + + try + { + await pushNotificationSender.SendMessageReactionAsync( + chat, + message, + reactor, + emoji, + devices, + cancellationToken); + } + catch (Exception ex) + { + logger.LogWarning( + ex, + "Push dispatch failed for reaction to message {MessageId}.", + message.Id); + } + } +} diff --git a/src/Ikar.Server/Infrastructure/Push/PushOptions.cs b/src/Ikar.Server/Infrastructure/Push/PushOptions.cs new file mode 100644 index 00000000..7eb25565 --- /dev/null +++ b/src/Ikar.Server/Infrastructure/Push/PushOptions.cs @@ -0,0 +1,12 @@ +namespace Ikar.Server.Infrastructure.Push; + +public sealed class PushOptions +{ + public const string SectionName = "Push"; + + public string FirebaseProjectId { get; set; } = string.Empty; + + public string ServiceAccountJsonPath { get; set; } = string.Empty; + + public string ServiceAccountJson { get; set; } = string.Empty; +} diff --git a/src/Ikar.Server/Infrastructure/Storage/AttachmentStorageService.cs b/src/Ikar.Server/Infrastructure/Storage/AttachmentStorageService.cs new file mode 100644 index 00000000..28931b92 --- /dev/null +++ b/src/Ikar.Server/Infrastructure/Storage/AttachmentStorageService.cs @@ -0,0 +1,128 @@ +using Ikar.Server.Data.Entities; + +namespace Ikar.Server.Infrastructure.Storage; + +public sealed class AttachmentStorageService(IWebHostEnvironment environment) +{ + private readonly string _rootPath = Path.Combine(environment.ContentRootPath, "Data", "Attachments"); + + public async Task<MessageAttachment> SaveAsync( + Guid messageId, + IFormFile file, + CancellationToken cancellationToken, + int sortOrder = 0) + { + if (file.Length <= 0) + { + throw new InvalidOperationException("Attachment is empty."); + } + + var (attachmentId, physicalPath) = CreateTargetPath(file.FileName); + + await using (var source = file.OpenReadStream()) + await using (var target = File.Create(physicalPath)) + { + await source.CopyToAsync(target, cancellationToken); + } + + return new MessageAttachment + { + Id = attachmentId, + MessageId = messageId, + OriginalFileName = string.IsNullOrWhiteSpace(file.FileName) ? "attachment" : Path.GetFileName(file.FileName), + ContentType = string.IsNullOrWhiteSpace(file.ContentType) ? "application/octet-stream" : file.ContentType, + FileSizeBytes = file.Length, + StoragePath = physicalPath, + SortOrder = sortOrder, + UploadedAt = DateTimeOffset.UtcNow + }; + } + + public async Task<StoryAttachment> SaveStoryAsync( + Guid storyId, + IFormFile file, + CancellationToken cancellationToken, + int sortOrder = 0) + { + if (file.Length <= 0) + { + throw new InvalidOperationException("Attachment is empty."); + } + + var (attachmentId, physicalPath) = CreateTargetPath(file.FileName); + + await using (var source = file.OpenReadStream()) + await using (var target = File.Create(physicalPath)) + { + await source.CopyToAsync(target, cancellationToken); + } + + return new StoryAttachment + { + Id = attachmentId, + StoryId = storyId, + OriginalFileName = string.IsNullOrWhiteSpace(file.FileName) ? "story-attachment" : Path.GetFileName(file.FileName), + ContentType = string.IsNullOrWhiteSpace(file.ContentType) ? "application/octet-stream" : file.ContentType, + FileSizeBytes = file.Length, + StoragePath = physicalPath, + SortOrder = sortOrder, + UploadedAt = DateTimeOffset.UtcNow + }; + } + + public async Task<MessageAttachment> CloneAsync( + Guid messageId, + MessageAttachment sourceAttachment, + CancellationToken cancellationToken, + int? sortOrder = null) + { + if (string.IsNullOrWhiteSpace(sourceAttachment.StoragePath) || !File.Exists(sourceAttachment.StoragePath)) + { + throw new FileNotFoundException("Source attachment file was not found.", sourceAttachment.StoragePath); + } + + var (attachmentId, physicalPath) = CreateTargetPath(sourceAttachment.OriginalFileName); + + await using (var source = File.OpenRead(sourceAttachment.StoragePath)) + await using (var target = File.Create(physicalPath)) + { + await source.CopyToAsync(target, cancellationToken); + } + + return new MessageAttachment + { + Id = attachmentId, + MessageId = messageId, + OriginalFileName = sourceAttachment.OriginalFileName, + ContentType = sourceAttachment.ContentType, + FileSizeBytes = sourceAttachment.FileSizeBytes, + StoragePath = physicalPath, + SortOrder = sortOrder ?? sourceAttachment.SortOrder, + UploadedAt = DateTimeOffset.UtcNow + }; + } + + public void DeleteIfExists(string? physicalPath) + { + if (!string.IsNullOrWhiteSpace(physicalPath) && File.Exists(physicalPath)) + { + File.Delete(physicalPath); + } + } + + private (Guid AttachmentId, string PhysicalPath) CreateTargetPath(string? originalFileName) + { + Directory.CreateDirectory(_rootPath); + + var attachmentId = Guid.NewGuid(); + var extension = Path.GetExtension(originalFileName); + var year = DateTime.UtcNow.ToString("yyyy"); + var month = DateTime.UtcNow.ToString("MM"); + var targetDirectory = Path.Combine(_rootPath, year, month); + Directory.CreateDirectory(targetDirectory); + + var storedFileName = $"{attachmentId:N}{extension}"; + var physicalPath = Path.Combine(targetDirectory, storedFileName); + return (attachmentId, physicalPath); + } +} diff --git a/src/Ikar.Server/Infrastructure/Storage/AvatarStorageService.cs b/src/Ikar.Server/Infrastructure/Storage/AvatarStorageService.cs new file mode 100644 index 00000000..70f2855a --- /dev/null +++ b/src/Ikar.Server/Infrastructure/Storage/AvatarStorageService.cs @@ -0,0 +1,59 @@ +namespace Ikar.Server.Infrastructure.Storage; + +public sealed record AvatarStorageResult(string PhysicalPath, string ContentType); + +public sealed class AvatarStorageService(IWebHostEnvironment environment) +{ + private readonly string _rootPath = Path.Combine(environment.ContentRootPath, "Data", "Avatars"); + + public async Task<AvatarStorageResult> SaveAsync(Guid userId, IFormFile file, CancellationToken cancellationToken) + { + if (file.Length <= 0) + { + throw new InvalidOperationException("Avatar file is empty."); + } + + Directory.CreateDirectory(_rootPath); + + var extension = Path.GetExtension(file.FileName); + var safeExtension = string.IsNullOrWhiteSpace(extension) ? ".bin" : extension; + var targetDirectory = Path.Combine(_rootPath, userId.ToString("N")); + Directory.CreateDirectory(targetDirectory); + + var storedFileName = $"{Guid.NewGuid():N}{safeExtension}"; + var physicalPath = Path.Combine(targetDirectory, storedFileName); + + await using (var source = file.OpenReadStream()) + await using (var target = File.Create(physicalPath)) + { + await source.CopyToAsync(target, cancellationToken); + } + + var contentType = ResolveContentType(file.ContentType, safeExtension); + + return new AvatarStorageResult(physicalPath, contentType); + } + + public void DeleteIfExists(string? physicalPath) + { + if (!string.IsNullOrWhiteSpace(physicalPath) && File.Exists(physicalPath)) + { + File.Delete(physicalPath); + } + } + + private static string ResolveContentType(string? uploadedContentType, string extension) => + string.IsNullOrWhiteSpace(uploadedContentType) + ? extension.ToLowerInvariant() switch + { + ".gif" => "image/gif", + ".heic" => "image/heic", + ".heif" => "image/heif", + ".jpeg" => "image/jpeg", + ".jpg" => "image/jpeg", + ".png" => "image/png", + ".webp" => "image/webp", + _ => "application/octet-stream" + } + : uploadedContentType; +} diff --git a/src/Ikar.Server/Infrastructure/Storage/DatabaseSchemaBootstrapper.cs b/src/Ikar.Server/Infrastructure/Storage/DatabaseSchemaBootstrapper.cs new file mode 100644 index 00000000..56f8e616 --- /dev/null +++ b/src/Ikar.Server/Infrastructure/Storage/DatabaseSchemaBootstrapper.cs @@ -0,0 +1,704 @@ +using System.Data; +using Microsoft.EntityFrameworkCore; +using Ikar.Server.Data; + +namespace Ikar.Server.Infrastructure.Storage; + +public sealed class DatabaseSchemaBootstrapper(IkarDbContext dbContext) +{ + public async Task EnsureLatestAsync(CancellationToken cancellationToken = default) + { + await EnableWriteAheadLoggingAsync(cancellationToken); + await EnsureMessageAttachmentsTableAsync(cancellationToken); + await EnsurePushDevicesTableAsync(cancellationToken); + await EnsureUserPrivacySettingsTableAsync(cancellationToken); + await EnsureChatInviteLinksTableAsync(cancellationToken); + await EnsureChatJoinRequestsTableAsync(cancellationToken); + await EnsureChatTopicsTableAsync(cancellationToken); + await EnsureBotsTableAsync(cancellationToken); + await EnsureBotCommandsTableAsync(cancellationToken); + await EnsureBotUpdatesTableAsync(cancellationToken); + await EnsureModerationLogEntriesTableAsync(cancellationToken); + await EnsureStoriesTablesAsync(cancellationToken); + await EnsureMessageReactionsTableAsync(cancellationToken); + await EnsureMessageViewsTableAsync(cancellationToken); + await EnsureAndroidUpdateCompatibilityHitsTableAsync(cancellationToken); + await EnsureChatMembersIndexesAsync(cancellationToken); + await EnsureColumnAsync("ChatMembers", "LastDeliveredAt", "TEXT NULL", cancellationToken); + await EnsureColumnAsync("ChatMembers", "LastReadAt", "TEXT NULL", cancellationToken); + await EnsureColumnAsync("ChatMembers", "Role", "INTEGER NOT NULL DEFAULT 0", cancellationToken); + await EnsureColumnAsync("ChatMembers", "CanPinMessages", "INTEGER NOT NULL DEFAULT 1", cancellationToken); + await EnsureColumnAsync("ChatMembers", "CanDeleteMessages", "INTEGER NOT NULL DEFAULT 0", cancellationToken); + await EnsureColumnAsync("ChatMembers", "CanInviteUsers", "INTEGER NOT NULL DEFAULT 1", cancellationToken); + await EnsureColumnAsync("ChatMembers", "CanPostMessages", "INTEGER NOT NULL DEFAULT 1", cancellationToken); + await EnsureColumnAsync("ChatMembers", "IsArchived", "INTEGER NOT NULL DEFAULT 0", cancellationToken); + await EnsureColumnAsync("ChatMembers", "IsPinned", "INTEGER NOT NULL DEFAULT 0", cancellationToken); + await EnsureColumnAsync("ChatMembers", "MutedUntil", "TEXT NULL", cancellationToken); + await EnsureColumnAsync("ChatMembers", "FolderKey", "TEXT NULL", cancellationToken); + await EnsureColumnAsync("UserSessions", "ClientPlatform", "TEXT NULL", cancellationToken); + await EnsureColumnAsync("UserSessions", "ClientVersionCode", "INTEGER NULL", cancellationToken); + await EnsureColumnAsync("UserSessions", "ClientVersionName", "TEXT NULL", cancellationToken); + await EnsureColumnAsync("PushDevices", "ClientPlatform", "TEXT NULL", cancellationToken); + await EnsureColumnAsync("PushDevices", "ClientVersionCode", "INTEGER NULL", cancellationToken); + await EnsureColumnAsync("PushDevices", "ClientVersionName", "TEXT NULL", cancellationToken); + await EnsureColumnAsync("Chats", "Description", "TEXT NULL", cancellationToken); + await EnsureColumnAsync("Chats", "PublicUsername", "TEXT NULL", cancellationToken); + await EnsureColumnAsync("Chats", "LinkedDiscussionChatId", "TEXT NULL", cancellationToken); + await EnsureColumnAsync("Chats", "ChannelSignaturesEnabled", "INTEGER NOT NULL DEFAULT 0", cancellationToken); + await EnsureColumnAsync("Chats", "TopicsEnabled", "INTEGER NOT NULL DEFAULT 0", cancellationToken); + await EnsureColumnAsync("Chats", "HasProtectedContent", "INTEGER NOT NULL DEFAULT 0", cancellationToken); + await EnsureColumnAsync("Chats", "DefaultMessageTtlSeconds", "INTEGER NULL", cancellationToken); + await EnsureColumnAsync("Messages", "EditedAt", "TEXT NULL", cancellationToken); + await EnsureColumnAsync("Messages", "DeletedAt", "TEXT NULL", cancellationToken); + await EnsureColumnAsync("Messages", "ForwardedFromDisplayName", "TEXT NULL", cancellationToken); + await EnsureColumnAsync("Messages", "ReplyToMessageId", "TEXT NULL", cancellationToken); + await EnsureColumnAsync("Messages", "PinnedAt", "TEXT NULL", cancellationToken); + await EnsureColumnAsync("Messages", "PinnedByUserId", "TEXT NULL", cancellationToken); + await EnsureColumnAsync("Messages", "MediaAlbumId", "TEXT NULL", cancellationToken); + await EnsureColumnAsync("Messages", "TopicId", "TEXT NULL", cancellationToken); + await EnsureColumnAsync("Messages", "HasProtectedContent", "INTEGER NOT NULL DEFAULT 0", cancellationToken); + await EnsureColumnAsync("Messages", "ExpiresAt", "TEXT NULL", cancellationToken); + await EnsureColumnAsync("Messages", "TtlSeconds", "INTEGER NULL", cancellationToken); + await EnsureColumnAsync("Messages", "PostedAsChannel", "INTEGER NOT NULL DEFAULT 0", cancellationToken); + await EnsureColumnAsync("Messages", "AuthorSignature", "TEXT NULL", cancellationToken); + await EnsureLegacyChannelPostsMarkedAsync(cancellationToken); + await EnsureColumnAsync("MessageAttachments", "SortOrder", "INTEGER NOT NULL DEFAULT 0", cancellationToken); + await EnsureMessagesPinnedIndexAsync(cancellationToken); + await EnsureMessagesChatSenderSentAtIndexAsync(cancellationToken); + await EnsureMessagesAlbumIndexAsync(cancellationToken); + await EnsureMessagesTopicIndexAsync(cancellationToken); + await EnsureChatsPublicUsernameIndexAsync(cancellationToken); + await EnsureChatsLinkedDiscussionIndexAsync(cancellationToken); + await EnsureMessageAttachmentsSortIndexAsync(cancellationToken); + await EnsureChatMemberStateIndexesAsync(cancellationToken); + await EnsureColumnAsync("Users", "IsBlocked", "INTEGER NOT NULL DEFAULT 0", cancellationToken); + await EnsureColumnAsync("Users", "BlockedAt", "TEXT NULL", cancellationToken); + await EnsureColumnAsync("Users", "PhoneNumber", "TEXT NULL", cancellationToken); + await EnsureColumnAsync("Users", "NormalizedPhoneNumber", "TEXT NULL", cancellationToken); + await EnsureColumnAsync("Users", "About", "TEXT NULL", cancellationToken); + await EnsureColumnAsync("Users", "AvatarStoragePath", "TEXT NULL", cancellationToken); + await EnsureColumnAsync("Users", "AvatarContentType", "TEXT NULL", cancellationToken); + await EnsureColumnAsync("Users", "IsBot", "INTEGER NOT NULL DEFAULT 0", cancellationToken); + await EnsureColumnAsync("UserPrivacySettings", "BlockedUserIds", "TEXT NOT NULL DEFAULT ''", cancellationToken); + await EnsureColumnAsync("UserPrivacySettings", "AlwaysAllowUserIds", "TEXT NOT NULL DEFAULT ''", cancellationToken); + await EnsureColumnAsync("UserPrivacySettings", "NeverAllowUserIds", "TEXT NOT NULL DEFAULT ''", cancellationToken); + await EnsureMessagesExpiresAtIndexAsync(cancellationToken); + await EnsureUsersPhoneIndexAsync(cancellationToken); + } + + private async Task EnableWriteAheadLoggingAsync(CancellationToken cancellationToken) + { + await dbContext.Database.ExecuteSqlRawAsync("PRAGMA journal_mode = WAL;", cancellationToken); + } + + private async Task EnsureMessageAttachmentsTableAsync(CancellationToken cancellationToken) + { + await dbContext.Database.ExecuteSqlRawAsync( + """ + CREATE TABLE IF NOT EXISTS "MessageAttachments" ( + "Id" TEXT NOT NULL CONSTRAINT "PK_MessageAttachments" PRIMARY KEY, + "MessageId" TEXT NOT NULL, + "OriginalFileName" TEXT NOT NULL, + "ContentType" TEXT NOT NULL, + "FileSizeBytes" INTEGER NOT NULL, + "StoragePath" TEXT NOT NULL, + "UploadedAt" TEXT NOT NULL, + CONSTRAINT "FK_MessageAttachments_Messages_MessageId" FOREIGN KEY ("MessageId") REFERENCES "Messages" ("Id") ON DELETE CASCADE + ); + """, + cancellationToken); + + await dbContext.Database.ExecuteSqlRawAsync( + """ + CREATE INDEX IF NOT EXISTS "IX_MessageAttachments_MessageId" ON "MessageAttachments" ("MessageId"); + """, + cancellationToken); + } + + private async Task EnsurePushDevicesTableAsync(CancellationToken cancellationToken) + { + await dbContext.Database.ExecuteSqlRawAsync( + """ + CREATE TABLE IF NOT EXISTS "PushDevices" ( + "Id" TEXT NOT NULL CONSTRAINT "PK_PushDevices" PRIMARY KEY, + "UserId" TEXT NOT NULL, + "Platform" INTEGER NOT NULL, + "InstallationId" TEXT NOT NULL, + "DeviceToken" TEXT NOT NULL, + "DeviceName" TEXT NULL, + "NotificationsEnabled" INTEGER NOT NULL, + "CreatedAt" TEXT NOT NULL, + "UpdatedAt" TEXT NOT NULL, + CONSTRAINT "FK_PushDevices_Users_UserId" FOREIGN KEY ("UserId") REFERENCES "Users" ("Id") ON DELETE CASCADE + ); + """, + cancellationToken); + + await dbContext.Database.ExecuteSqlRawAsync( + """ + CREATE UNIQUE INDEX IF NOT EXISTS "IX_PushDevices_InstallationId" ON "PushDevices" ("InstallationId"); + """, + cancellationToken); + + await dbContext.Database.ExecuteSqlRawAsync( + """ + CREATE INDEX IF NOT EXISTS "IX_PushDevices_UserId" ON "PushDevices" ("UserId"); + """, + cancellationToken); + } + + private async Task EnsureBotsTableAsync(CancellationToken cancellationToken) + { + await dbContext.Database.ExecuteSqlRawAsync( + """ + CREATE TABLE IF NOT EXISTS "Bots" ( + "Id" TEXT NOT NULL CONSTRAINT "PK_Bots" PRIMARY KEY, + "UserId" TEXT NOT NULL, + "OwnerUserId" TEXT NOT NULL, + "TokenHash" TEXT NOT NULL, + "IsEnabled" INTEGER NOT NULL DEFAULT 1, + "CreatedAt" TEXT NOT NULL, + "UpdatedAt" TEXT NOT NULL, + CONSTRAINT "FK_Bots_Users_UserId" FOREIGN KEY ("UserId") REFERENCES "Users" ("Id") ON DELETE CASCADE, + CONSTRAINT "FK_Bots_Users_OwnerUserId" FOREIGN KEY ("OwnerUserId") REFERENCES "Users" ("Id") ON DELETE RESTRICT + ); + """, + cancellationToken); + + await dbContext.Database.ExecuteSqlRawAsync( + """ + CREATE UNIQUE INDEX IF NOT EXISTS "IX_Bots_UserId" ON "Bots" ("UserId"); + """, + cancellationToken); + + await dbContext.Database.ExecuteSqlRawAsync( + """ + CREATE UNIQUE INDEX IF NOT EXISTS "IX_Bots_TokenHash" ON "Bots" ("TokenHash"); + """, + cancellationToken); + + await dbContext.Database.ExecuteSqlRawAsync( + """ + CREATE INDEX IF NOT EXISTS "IX_Bots_OwnerUserId" ON "Bots" ("OwnerUserId"); + """, + cancellationToken); + } + + private async Task EnsureBotCommandsTableAsync(CancellationToken cancellationToken) + { + await dbContext.Database.ExecuteSqlRawAsync( + """ + CREATE TABLE IF NOT EXISTS "BotCommands" ( + "Id" TEXT NOT NULL CONSTRAINT "PK_BotCommands" PRIMARY KEY, + "BotId" TEXT NOT NULL, + "Command" TEXT NOT NULL, + "Description" TEXT NOT NULL, + "SortOrder" INTEGER NOT NULL, + CONSTRAINT "FK_BotCommands_Bots_BotId" FOREIGN KEY ("BotId") REFERENCES "Bots" ("Id") ON DELETE CASCADE + ); + """, + cancellationToken); + + await dbContext.Database.ExecuteSqlRawAsync( + """ + CREATE UNIQUE INDEX IF NOT EXISTS "IX_BotCommands_BotId_Command" ON "BotCommands" ("BotId", "Command"); + """, + cancellationToken); + } + + private async Task EnsureBotUpdatesTableAsync(CancellationToken cancellationToken) + { + await dbContext.Database.ExecuteSqlRawAsync( + """ + CREATE TABLE IF NOT EXISTS "BotUpdates" ( + "Id" INTEGER NOT NULL CONSTRAINT "PK_BotUpdates" PRIMARY KEY AUTOINCREMENT, + "BotId" TEXT NOT NULL, + "ChatId" TEXT NOT NULL, + "MessageId" TEXT NOT NULL, + "CreatedAt" TEXT NOT NULL, + CONSTRAINT "FK_BotUpdates_Bots_BotId" FOREIGN KEY ("BotId") REFERENCES "Bots" ("Id") ON DELETE CASCADE, + CONSTRAINT "FK_BotUpdates_Messages_MessageId" FOREIGN KEY ("MessageId") REFERENCES "Messages" ("Id") ON DELETE CASCADE + ); + """, + cancellationToken); + + await dbContext.Database.ExecuteSqlRawAsync( + """ + CREATE INDEX IF NOT EXISTS "IX_BotUpdates_BotId_Id" ON "BotUpdates" ("BotId", "Id"); + """, + cancellationToken); + + await dbContext.Database.ExecuteSqlRawAsync( + """ + CREATE UNIQUE INDEX IF NOT EXISTS "IX_BotUpdates_BotId_MessageId" ON "BotUpdates" ("BotId", "MessageId"); + """, + cancellationToken); + } + + private async Task EnsureModerationLogEntriesTableAsync(CancellationToken cancellationToken) + { + await dbContext.Database.ExecuteSqlRawAsync( + """ + CREATE TABLE IF NOT EXISTS "ModerationLogEntries" ( + "Id" INTEGER NOT NULL CONSTRAINT "PK_ModerationLogEntries" PRIMARY KEY AUTOINCREMENT, + "ChatId" TEXT NULL, + "ActorUserId" TEXT NOT NULL, + "TargetType" INTEGER NOT NULL, + "TargetId" TEXT NULL, + "Action" TEXT NOT NULL, + "Reason" TEXT NULL, + "CreatedAt" TEXT NOT NULL, + CONSTRAINT "FK_ModerationLogEntries_Chats_ChatId" FOREIGN KEY ("ChatId") REFERENCES "Chats" ("Id") ON DELETE SET NULL, + CONSTRAINT "FK_ModerationLogEntries_Users_ActorUserId" FOREIGN KEY ("ActorUserId") REFERENCES "Users" ("Id") ON DELETE RESTRICT + ); + """, + cancellationToken); + + await dbContext.Database.ExecuteSqlRawAsync( + """ + CREATE INDEX IF NOT EXISTS "IX_ModerationLogEntries_ChatId_CreatedAt" ON "ModerationLogEntries" ("ChatId", "CreatedAt"); + """, + cancellationToken); + + await dbContext.Database.ExecuteSqlRawAsync( + """ + CREATE INDEX IF NOT EXISTS "IX_ModerationLogEntries_TargetType_TargetId" ON "ModerationLogEntries" ("TargetType", "TargetId"); + """, + cancellationToken); + } + + private async Task EnsureStoriesTablesAsync(CancellationToken cancellationToken) + { + await dbContext.Database.ExecuteSqlRawAsync( + """ + CREATE TABLE IF NOT EXISTS "Stories" ( + "Id" TEXT NOT NULL CONSTRAINT "PK_Stories" PRIMARY KEY, + "AuthorUserId" TEXT NOT NULL, + "Text" TEXT NOT NULL, + "Visibility" INTEGER NOT NULL DEFAULT 0, + "HasProtectedContent" INTEGER NOT NULL DEFAULT 0, + "CreatedAt" TEXT NOT NULL, + "ExpiresAt" TEXT NOT NULL, + "DeletedAt" TEXT NULL, + CONSTRAINT "FK_Stories_Users_AuthorUserId" FOREIGN KEY ("AuthorUserId") REFERENCES "Users" ("Id") ON DELETE CASCADE + ); + """, + cancellationToken); + + await dbContext.Database.ExecuteSqlRawAsync( + """ + CREATE TABLE IF NOT EXISTS "StoryAttachments" ( + "Id" TEXT NOT NULL CONSTRAINT "PK_StoryAttachments" PRIMARY KEY, + "StoryId" TEXT NOT NULL, + "OriginalFileName" TEXT NOT NULL, + "ContentType" TEXT NOT NULL, + "FileSizeBytes" INTEGER NOT NULL, + "StoragePath" TEXT NOT NULL, + "SortOrder" INTEGER NOT NULL DEFAULT 0, + "UploadedAt" TEXT NOT NULL, + CONSTRAINT "FK_StoryAttachments_Stories_StoryId" FOREIGN KEY ("StoryId") REFERENCES "Stories" ("Id") ON DELETE CASCADE + ); + """, + cancellationToken); + + await dbContext.Database.ExecuteSqlRawAsync( + """ + CREATE TABLE IF NOT EXISTS "StoryViews" ( + "StoryId" TEXT NOT NULL, + "ViewerUserId" TEXT NOT NULL, + "ViewedAt" TEXT NOT NULL, + CONSTRAINT "PK_StoryViews" PRIMARY KEY ("StoryId", "ViewerUserId"), + CONSTRAINT "FK_StoryViews_Stories_StoryId" FOREIGN KEY ("StoryId") REFERENCES "Stories" ("Id") ON DELETE CASCADE, + CONSTRAINT "FK_StoryViews_Users_ViewerUserId" FOREIGN KEY ("ViewerUserId") REFERENCES "Users" ("Id") ON DELETE CASCADE + ); + """, + cancellationToken); + + await dbContext.Database.ExecuteSqlRawAsync("""CREATE INDEX IF NOT EXISTS "IX_Stories_AuthorUserId_CreatedAt" ON "Stories" ("AuthorUserId", "CreatedAt");""", cancellationToken); + await dbContext.Database.ExecuteSqlRawAsync("""CREATE INDEX IF NOT EXISTS "IX_Stories_ExpiresAt" ON "Stories" ("ExpiresAt");""", cancellationToken); + await dbContext.Database.ExecuteSqlRawAsync("""CREATE INDEX IF NOT EXISTS "IX_StoryAttachments_StoryId_SortOrder" ON "StoryAttachments" ("StoryId", "SortOrder");""", cancellationToken); + await dbContext.Database.ExecuteSqlRawAsync("""CREATE INDEX IF NOT EXISTS "IX_StoryViews_ViewerUserId" ON "StoryViews" ("ViewerUserId");""", cancellationToken); + } + + private async Task EnsureAndroidUpdateCompatibilityHitsTableAsync(CancellationToken cancellationToken) + { + await dbContext.Database.ExecuteSqlRawAsync( + """ + CREATE TABLE IF NOT EXISTS "AndroidUpdateCompatibilityHits" ( + "Id" INTEGER NOT NULL CONSTRAINT "PK_AndroidUpdateCompatibilityHits" PRIMARY KEY AUTOINCREMENT, + "HitAt" TEXT NOT NULL, + "Route" TEXT NOT NULL, + "RequestPath" TEXT NOT NULL, + "RequestedFileName" TEXT NULL, + "ClientPlatform" TEXT NULL, + "ClientVersionCode" INTEGER NULL, + "ClientVersionName" TEXT NULL, + "UserAgent" TEXT NULL, + "RemoteIp" TEXT NULL + ); + """, + cancellationToken); + + await dbContext.Database.ExecuteSqlRawAsync( + """ + CREATE INDEX IF NOT EXISTS "IX_AndroidUpdateCompatibilityHits_HitAt" ON "AndroidUpdateCompatibilityHits" ("HitAt"); + """, + cancellationToken); + + await dbContext.Database.ExecuteSqlRawAsync( + """ + CREATE INDEX IF NOT EXISTS "IX_AndroidUpdateCompatibilityHits_Route_HitAt" ON "AndroidUpdateCompatibilityHits" ("Route", "HitAt"); + """, + cancellationToken); + + await dbContext.Database.ExecuteSqlRawAsync( + """ + CREATE INDEX IF NOT EXISTS "IX_AndroidUpdateCompatibilityHits_ClientPlatform_ClientVersionCode_HitAt" + ON "AndroidUpdateCompatibilityHits" ("ClientPlatform", "ClientVersionCode", "HitAt"); + """, + cancellationToken); + } + + private async Task EnsureMessageReactionsTableAsync(CancellationToken cancellationToken) + { + await dbContext.Database.ExecuteSqlRawAsync( + """ + CREATE TABLE IF NOT EXISTS "MessageReactions" ( + "Id" TEXT NOT NULL CONSTRAINT "PK_MessageReactions" PRIMARY KEY, + "MessageId" TEXT NOT NULL, + "UserId" TEXT NOT NULL, + "Emoji" TEXT NOT NULL, + "CreatedAt" TEXT NOT NULL, + CONSTRAINT "FK_MessageReactions_Messages_MessageId" FOREIGN KEY ("MessageId") REFERENCES "Messages" ("Id") ON DELETE CASCADE, + CONSTRAINT "FK_MessageReactions_Users_UserId" FOREIGN KEY ("UserId") REFERENCES "Users" ("Id") ON DELETE CASCADE + ); + """, + cancellationToken); + + await dbContext.Database.ExecuteSqlRawAsync( + """ + CREATE INDEX IF NOT EXISTS "IX_MessageReactions_MessageId" ON "MessageReactions" ("MessageId"); + """, + cancellationToken); + + await dbContext.Database.ExecuteSqlRawAsync( + """ + CREATE UNIQUE INDEX IF NOT EXISTS "IX_MessageReactions_MessageId_UserId" ON "MessageReactions" ("MessageId", "UserId"); + """, + cancellationToken); + + await dbContext.Database.ExecuteSqlRawAsync( + """ + CREATE INDEX IF NOT EXISTS "IX_Messages_ReplyToMessageId" ON "Messages" ("ReplyToMessageId"); + """, + cancellationToken); + } + + private async Task EnsureMessageViewsTableAsync(CancellationToken cancellationToken) + { + await dbContext.Database.ExecuteSqlRawAsync( + """ + CREATE TABLE IF NOT EXISTS "MessageViews" ( + "MessageId" TEXT NOT NULL, + "ViewerUserId" TEXT NOT NULL, + "ViewedAt" TEXT NOT NULL, + CONSTRAINT "PK_MessageViews" PRIMARY KEY ("MessageId", "ViewerUserId"), + CONSTRAINT "FK_MessageViews_Messages_MessageId" FOREIGN KEY ("MessageId") REFERENCES "Messages" ("Id") ON DELETE CASCADE, + CONSTRAINT "FK_MessageViews_Users_ViewerUserId" FOREIGN KEY ("ViewerUserId") REFERENCES "Users" ("Id") ON DELETE CASCADE + ); + """, + cancellationToken); + + await dbContext.Database.ExecuteSqlRawAsync( + """ + CREATE INDEX IF NOT EXISTS "IX_MessageViews_ViewerUserId" ON "MessageViews" ("ViewerUserId"); + """, + cancellationToken); + } + + private async Task EnsureChatMembersIndexesAsync(CancellationToken cancellationToken) + { + await dbContext.Database.ExecuteSqlRawAsync( + """ + CREATE INDEX IF NOT EXISTS "IX_ChatMembers_UserId" ON "ChatMembers" ("UserId"); + """, + cancellationToken); + } + + private async Task EnsureUserPrivacySettingsTableAsync(CancellationToken cancellationToken) + { + await dbContext.Database.ExecuteSqlRawAsync( + """ + CREATE TABLE IF NOT EXISTS "UserPrivacySettings" ( + "UserId" TEXT NOT NULL CONSTRAINT "PK_UserPrivacySettings" PRIMARY KEY, + "PhoneNumberVisibility" TEXT NOT NULL DEFAULT 'contacts', + "LastSeenVisibility" TEXT NOT NULL DEFAULT 'contacts', + "ProfilePhotoVisibility" TEXT NOT NULL DEFAULT 'everyone', + "BlockedUserIds" TEXT NOT NULL DEFAULT '', + "AlwaysAllowUserIds" TEXT NOT NULL DEFAULT '', + "NeverAllowUserIds" TEXT NOT NULL DEFAULT '', + "PasscodeLockEnabled" INTEGER NOT NULL DEFAULT 0, + "ShowMessagePreview" INTEGER NOT NULL DEFAULT 1, + "UpdatedAt" TEXT NOT NULL, + CONSTRAINT "FK_UserPrivacySettings_Users_UserId" FOREIGN KEY ("UserId") REFERENCES "Users" ("Id") ON DELETE CASCADE + ); + """, + cancellationToken); + } + + private async Task EnsureChatInviteLinksTableAsync(CancellationToken cancellationToken) + { + await dbContext.Database.ExecuteSqlRawAsync( + """ + CREATE TABLE IF NOT EXISTS "ChatInviteLinks" ( + "Id" TEXT NOT NULL CONSTRAINT "PK_ChatInviteLinks" PRIMARY KEY, + "ChatId" TEXT NOT NULL, + "CreatedById" TEXT NOT NULL, + "Token" TEXT NOT NULL, + "Title" TEXT NULL, + "CreatesJoinRequest" INTEGER NOT NULL DEFAULT 0, + "IsRevoked" INTEGER NOT NULL DEFAULT 0, + "CreatedAt" TEXT NOT NULL, + "RevokedAt" TEXT NULL, + CONSTRAINT "FK_ChatInviteLinks_Chats_ChatId" FOREIGN KEY ("ChatId") REFERENCES "Chats" ("Id") ON DELETE CASCADE, + CONSTRAINT "FK_ChatInviteLinks_Users_CreatedById" FOREIGN KEY ("CreatedById") REFERENCES "Users" ("Id") ON DELETE RESTRICT + ); + """, + cancellationToken); + + await dbContext.Database.ExecuteSqlRawAsync( + """ + CREATE UNIQUE INDEX IF NOT EXISTS "IX_ChatInviteLinks_Token" ON "ChatInviteLinks" ("Token"); + """, + cancellationToken); + + await dbContext.Database.ExecuteSqlRawAsync( + """ + CREATE INDEX IF NOT EXISTS "IX_ChatInviteLinks_ChatId" ON "ChatInviteLinks" ("ChatId"); + """, + cancellationToken); + } + + private async Task EnsureChatJoinRequestsTableAsync(CancellationToken cancellationToken) + { + await dbContext.Database.ExecuteSqlRawAsync( + """ + CREATE TABLE IF NOT EXISTS "ChatJoinRequests" ( + "Id" TEXT NOT NULL CONSTRAINT "PK_ChatJoinRequests" PRIMARY KEY, + "ChatId" TEXT NOT NULL, + "InviteLinkId" TEXT NOT NULL, + "UserId" TEXT NOT NULL, + "Status" INTEGER NOT NULL DEFAULT 0, + "RequestedAt" TEXT NOT NULL, + "ResolvedAt" TEXT NULL, + "ResolvedByUserId" TEXT NULL, + CONSTRAINT "FK_ChatJoinRequests_Chats_ChatId" FOREIGN KEY ("ChatId") REFERENCES "Chats" ("Id") ON DELETE CASCADE, + CONSTRAINT "FK_ChatJoinRequests_ChatInviteLinks_InviteLinkId" FOREIGN KEY ("InviteLinkId") REFERENCES "ChatInviteLinks" ("Id") ON DELETE CASCADE, + CONSTRAINT "FK_ChatJoinRequests_Users_UserId" FOREIGN KEY ("UserId") REFERENCES "Users" ("Id") ON DELETE CASCADE, + CONSTRAINT "FK_ChatJoinRequests_Users_ResolvedByUserId" FOREIGN KEY ("ResolvedByUserId") REFERENCES "Users" ("Id") ON DELETE SET NULL + ); + """, + cancellationToken); + + await dbContext.Database.ExecuteSqlRawAsync( + """ + CREATE INDEX IF NOT EXISTS "IX_ChatJoinRequests_ChatId_Status" ON "ChatJoinRequests" ("ChatId", "Status"); + """, + cancellationToken); + + await dbContext.Database.ExecuteSqlRawAsync( + """ + CREATE UNIQUE INDEX IF NOT EXISTS "IX_ChatJoinRequests_InviteLinkId_UserId" ON "ChatJoinRequests" ("InviteLinkId", "UserId"); + """, + cancellationToken); + } + + private async Task EnsureChatTopicsTableAsync(CancellationToken cancellationToken) + { + await dbContext.Database.ExecuteSqlRawAsync( + """ + CREATE TABLE IF NOT EXISTS "ChatTopics" ( + "Id" TEXT NOT NULL CONSTRAINT "PK_ChatTopics" PRIMARY KEY, + "ChatId" TEXT NOT NULL, + "CreatedById" TEXT NOT NULL, + "Title" TEXT NOT NULL, + "Icon" TEXT NOT NULL DEFAULT '💬', + "IsPinned" INTEGER NOT NULL DEFAULT 0, + "IsClosed" INTEGER NOT NULL DEFAULT 0, + "CreatedAt" TEXT NOT NULL, + "LastActivityAt" TEXT NOT NULL, + CONSTRAINT "FK_ChatTopics_Chats_ChatId" FOREIGN KEY ("ChatId") REFERENCES "Chats" ("Id") ON DELETE CASCADE, + CONSTRAINT "FK_ChatTopics_Users_CreatedById" FOREIGN KEY ("CreatedById") REFERENCES "Users" ("Id") ON DELETE RESTRICT + ); + """, + cancellationToken); + + await dbContext.Database.ExecuteSqlRawAsync( + """ + CREATE INDEX IF NOT EXISTS "IX_ChatTopics_ChatId_IsPinned" ON "ChatTopics" ("ChatId", "IsPinned"); + """, + cancellationToken); + + await dbContext.Database.ExecuteSqlRawAsync( + """ + CREATE INDEX IF NOT EXISTS "IX_ChatTopics_ChatId_LastActivityAt" ON "ChatTopics" ("ChatId", "LastActivityAt"); + """, + cancellationToken); + } + + private async Task EnsureMessagesPinnedIndexAsync(CancellationToken cancellationToken) + { + await dbContext.Database.ExecuteSqlRawAsync( + """ + CREATE INDEX IF NOT EXISTS "IX_Messages_ChatId_PinnedAt" ON "Messages" ("ChatId", "PinnedAt"); + """, + cancellationToken); + } + + private async Task EnsureMessagesChatSenderSentAtIndexAsync(CancellationToken cancellationToken) + { + await dbContext.Database.ExecuteSqlRawAsync( + """ + CREATE INDEX IF NOT EXISTS "IX_Messages_ChatId_SenderId_SentAt" ON "Messages" ("ChatId", "SenderId", "SentAt"); + """, + cancellationToken); + } + + private async Task EnsureMessagesAlbumIndexAsync(CancellationToken cancellationToken) + { + await dbContext.Database.ExecuteSqlRawAsync( + """ + CREATE INDEX IF NOT EXISTS "IX_Messages_MediaAlbumId" ON "Messages" ("MediaAlbumId"); + """, + cancellationToken); + } + + private async Task EnsureMessagesTopicIndexAsync(CancellationToken cancellationToken) + { + await dbContext.Database.ExecuteSqlRawAsync( + """ + CREATE INDEX IF NOT EXISTS "IX_Messages_TopicId" ON "Messages" ("TopicId"); + """, + cancellationToken); + } + + private async Task EnsureMessagesExpiresAtIndexAsync(CancellationToken cancellationToken) + { + await dbContext.Database.ExecuteSqlRawAsync( + """ + CREATE INDEX IF NOT EXISTS "IX_Messages_ExpiresAt" ON "Messages" ("ExpiresAt"); + """, + cancellationToken); + } + + private async Task EnsureChatsPublicUsernameIndexAsync(CancellationToken cancellationToken) + { + await dbContext.Database.ExecuteSqlRawAsync( + """ + CREATE UNIQUE INDEX IF NOT EXISTS "IX_Chats_PublicUsername" ON "Chats" ("PublicUsername") WHERE "PublicUsername" IS NOT NULL; + """, + cancellationToken); + } + + private async Task EnsureChatsLinkedDiscussionIndexAsync(CancellationToken cancellationToken) + { + await dbContext.Database.ExecuteSqlRawAsync( + """ + CREATE INDEX IF NOT EXISTS "IX_Chats_LinkedDiscussionChatId" ON "Chats" ("LinkedDiscussionChatId"); + """, + cancellationToken); + } + + private async Task EnsureLegacyChannelPostsMarkedAsync(CancellationToken cancellationToken) + { + await dbContext.Database.ExecuteSqlRawAsync( + """ + UPDATE "Messages" + SET "PostedAsChannel" = 1 + WHERE "PostedAsChannel" = 0 + AND "ChatId" IN (SELECT "Id" FROM "Chats" WHERE "Type" = 3); + """, + cancellationToken); + } + + private async Task EnsureMessageAttachmentsSortIndexAsync(CancellationToken cancellationToken) + { + await dbContext.Database.ExecuteSqlRawAsync( + """ + CREATE INDEX IF NOT EXISTS "IX_MessageAttachments_MessageId_SortOrder" ON "MessageAttachments" ("MessageId", "SortOrder"); + """, + cancellationToken); + } + + private async Task EnsureChatMemberStateIndexesAsync(CancellationToken cancellationToken) + { + await dbContext.Database.ExecuteSqlRawAsync( + """ + CREATE INDEX IF NOT EXISTS "IX_ChatMembers_UserId_IsPinned" ON "ChatMembers" ("UserId", "IsPinned"); + """, + cancellationToken); + + await dbContext.Database.ExecuteSqlRawAsync( + """ + CREATE INDEX IF NOT EXISTS "IX_ChatMembers_UserId_IsArchived" ON "ChatMembers" ("UserId", "IsArchived"); + """, + cancellationToken); + + await dbContext.Database.ExecuteSqlRawAsync( + """ + CREATE INDEX IF NOT EXISTS "IX_ChatMembers_UserId_FolderKey" ON "ChatMembers" ("UserId", "FolderKey"); + """, + cancellationToken); + } + + private async Task EnsureUsersPhoneIndexAsync(CancellationToken cancellationToken) + { + await dbContext.Database.ExecuteSqlRawAsync( + """ + CREATE UNIQUE INDEX IF NOT EXISTS "IX_Users_NormalizedPhoneNumber" ON "Users" ("NormalizedPhoneNumber") WHERE "NormalizedPhoneNumber" IS NOT NULL; + """, + cancellationToken); + } + + private async Task EnsureColumnAsync( + string tableName, + string columnName, + string definition, + CancellationToken cancellationToken) + { + var connection = dbContext.Database.GetDbConnection(); + var shouldClose = connection.State != ConnectionState.Open; + if (shouldClose) + { + await connection.OpenAsync(cancellationToken); + } + + try + { + await using var command = connection.CreateCommand(); + command.CommandText = $"PRAGMA table_info(\"{tableName}\");"; + + await using var reader = await command.ExecuteReaderAsync(cancellationToken); + while (await reader.ReadAsync(cancellationToken)) + { + if (string.Equals(reader.GetString(1), columnName, StringComparison.OrdinalIgnoreCase)) + { + return; + } + } + + await reader.DisposeAsync(); + + await using var alterCommand = connection.CreateCommand(); + alterCommand.CommandText = $"ALTER TABLE \"{tableName}\" ADD COLUMN \"{columnName}\" {definition};"; + await alterCommand.ExecuteNonQueryAsync(cancellationToken); + } + finally + { + if (shouldClose) + { + await connection.CloseAsync(); + } + } + } +} diff --git a/src/Ikar.Server/Infrastructure/Updates/AndroidAppUpdateMonitorService.cs b/src/Ikar.Server/Infrastructure/Updates/AndroidAppUpdateMonitorService.cs new file mode 100644 index 00000000..b25b6503 --- /dev/null +++ b/src/Ikar.Server/Infrastructure/Updates/AndroidAppUpdateMonitorService.cs @@ -0,0 +1,99 @@ +using Ikar.Server.Data; +using Ikar.Server.Infrastructure.Hubs; +using Ikar.Server.Infrastructure.Push; +using Ikar.Shared; +using Microsoft.AspNetCore.SignalR; +using Microsoft.EntityFrameworkCore; + +namespace Ikar.Server.Infrastructure.Updates; + +public sealed class AndroidAppUpdateMonitorService( + IServiceScopeFactory serviceScopeFactory, + AndroidAppUpdateService androidAppUpdateService, + IHubContext<MessengerHub> hubContext, + ILogger<AndroidAppUpdateMonitorService> logger) : BackgroundService +{ + private static readonly TimeSpan PollInterval = TimeSpan.FromSeconds(15); + private AndroidAppReleaseDto? _lastObservedRelease; + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + _lastObservedRelease = await androidAppUpdateService.GetLatestReleaseAsync(stoppingToken); + + using var timer = new PeriodicTimer(PollInterval); + while (await timer.WaitForNextTickAsync(stoppingToken)) + { + try + { + var currentRelease = await androidAppUpdateService.GetLatestReleaseAsync(stoppingToken); + if (currentRelease is null) + { + continue; + } + + if (!HasRelevantUpdateChange(_lastObservedRelease, currentRelease)) + { + continue; + } + + var previousRelease = _lastObservedRelease; + _lastObservedRelease = currentRelease; + await NotifyClientsAsync(currentRelease, stoppingToken); + await NotifyPushDevicesAsync(currentRelease, stoppingToken); + + logger.LogInformation( + "Detected Android app update change from version {PreviousVersion} to {CurrentVersion}.", + previousRelease?.VersionName ?? "none", + currentRelease.VersionName); + } + catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) + { + break; + } + catch (Exception ex) + { + logger.LogWarning(ex, "Android app update monitor failed during polling."); + } + } + } + + private static bool HasRelevantUpdateChange(AndroidAppReleaseDto? previousRelease, AndroidAppReleaseDto currentRelease) + { + if (previousRelease is null) + { + return true; + } + + return previousRelease.VersionCode != currentRelease.VersionCode || + !string.Equals(previousRelease.VersionName, currentRelease.VersionName, StringComparison.Ordinal) || + !string.Equals(previousRelease.DownloadPath, currentRelease.DownloadPath, StringComparison.Ordinal); + } + + private Task NotifyClientsAsync(AndroidAppReleaseDto release, CancellationToken cancellationToken) => + hubContext.Clients.All.SendAsync("AppUpdateAvailable", release, cancellationToken); + + private async Task NotifyPushDevicesAsync(AndroidAppReleaseDto release, CancellationToken cancellationToken) + { + await using var scope = serviceScopeFactory.CreateAsyncScope(); + var dbContext = scope.ServiceProvider.GetRequiredService<IkarDbContext>(); + var pushNotificationSender = scope.ServiceProvider.GetRequiredService<IPushNotificationSender>(); + + var devices = await dbContext.PushDevices + .Where(device => device.Platform == PushPlatform.Android && device.NotificationsEnabled) + .ToListAsync(cancellationToken); + + if (devices.Count == 0) + { + return; + } + + try + { + await pushNotificationSender.SendAppUpdateAvailableAsync(release, devices, cancellationToken); + } + catch (Exception ex) + { + logger.LogWarning(ex, "Android app update push dispatch failed for version {VersionCode}.", release.VersionCode); + } + } +} diff --git a/src/Ikar.Server/Infrastructure/Updates/AndroidAppUpdateService.cs b/src/Ikar.Server/Infrastructure/Updates/AndroidAppUpdateService.cs new file mode 100644 index 00000000..9efd61bd --- /dev/null +++ b/src/Ikar.Server/Infrastructure/Updates/AndroidAppUpdateService.cs @@ -0,0 +1,347 @@ +using System.Globalization; +using System.Net; +using System.Net.Http.Headers; +using System.Net.Http.Json; +using System.Text.Json; +using System.Text.RegularExpressions; +using Ikar.Shared; + +namespace Ikar.Server.Infrastructure.Updates; + +public sealed class AndroidAppUpdateService( + IHostEnvironment hostEnvironment, + IHttpClientFactory httpClientFactory, + ILogger<AndroidAppUpdateService> logger) +{ + private const string ArgusBaseUrl = "https://argus.kusoft.xyz"; + private const string ArgusSlug = "ikar-android"; + private const string ArgusChannel = "stable"; + private const string ArgusPlatform = "android"; + private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web); + private static readonly Regex VersionCodeFromFileNameRegex = new( + @"ikar-android-(\d+)\.apk$", + RegexOptions.Compiled | RegexOptions.CultureInvariant | RegexOptions.IgnoreCase); + + private string UpdatesRootPath => Path.Combine(hostEnvironment.ContentRootPath, "Data", "AppUpdates", "Android"); + + private string MetadataPath => Path.Combine(UpdatesRootPath, "latest.json"); + + public void EnsureStorage() + { + Directory.CreateDirectory(UpdatesRootPath); + } + + public async Task<AndroidAppReleaseDto?> GetLatestReleaseAsync(CancellationToken cancellationToken = default) + { + var argusManifest = await TryGetArgusManifestAsync(cancellationToken); + if (argusManifest is not null) + { + return ApplyMandatoryLatestPolicy(argusManifest.Release); + } + + var localRelease = GetLatestLocalRelease(); + return localRelease is null ? null : ApplyMandatoryLatestPolicy(localRelease); + } + + public async Task<AndroidAppDownloadResolution?> ResolvePackageAsync(string fileName, CancellationToken cancellationToken = default) + { + var argusManifest = await TryGetArgusManifestAsync(cancellationToken); + if (argusManifest is not null && MatchesArgusFileName(argusManifest, fileName)) + { + return AndroidAppDownloadResolution.ForRedirect( + argusManifest.Release.DownloadPath, + argusManifest.OriginalFileName); + } + + var latest = GetLocalMetadata(); + if (latest is null) + { + return null; + } + + if (!string.Equals(latest.PackageFileName, fileName, StringComparison.Ordinal)) + { + return null; + } + + var physicalPath = Path.Combine(UpdatesRootPath, latest.PackageFileName); + if (!File.Exists(physicalPath)) + { + return null; + } + + return AndroidAppDownloadResolution.ForPhysical(physicalPath, latest.PackageFileName); + } + + public async Task<AndroidAppDownloadResolution?> ResolveLatestPackageAsync(CancellationToken cancellationToken = default) + { + var argusManifest = await TryGetArgusManifestAsync(cancellationToken); + if (argusManifest is not null) + { + return AndroidAppDownloadResolution.ForRedirect( + argusManifest.Release.DownloadPath, + argusManifest.OriginalFileName); + } + + var latest = GetLocalMetadata(); + if (latest is null) + { + return null; + } + + var physicalPath = Path.Combine(UpdatesRootPath, latest.PackageFileName); + if (!File.Exists(physicalPath)) + { + return null; + } + + return AndroidAppDownloadResolution.ForPhysical(physicalPath, latest.PackageFileName); + } + + private async Task<ArgusAndroidRelease?> TryGetArgusManifestAsync(CancellationToken cancellationToken) + { + try + { + using var client = httpClientFactory.CreateClient(); + using var request = new HttpRequestMessage( + HttpMethod.Get, + $"{ArgusBaseUrl.TrimEnd('/')}/api/apps/{ArgusSlug}/manifest?platform={ArgusPlatform}&channel={ArgusChannel}"); + request.Headers.CacheControl = new CacheControlHeaderValue + { + NoCache = true, + NoStore = true + }; + + using var response = await client.SendAsync(request, cancellationToken); + if (response.StatusCode == HttpStatusCode.NotFound) + { + return null; + } + + if (!response.IsSuccessStatusCode) + { + logger.LogWarning( + "Argus manifest request failed with status code {StatusCode}.", + (int)response.StatusCode); + return null; + } + + var manifest = await response.Content.ReadFromJsonAsync<ArgusManifestDto>(JsonOptions, cancellationToken); + if (manifest?.Release is null) + { + logger.LogWarning("Argus manifest response was empty or missing release metadata."); + return null; + } + + if (!TryMapArgusRelease(manifest.Release, out var release, out var originalFileName)) + { + logger.LogWarning( + "Argus manifest could not be mapped to AndroidAppReleaseDto. Version={Version}, OriginalFileName={OriginalFileName}.", + manifest.Release.Version, + manifest.Release.OriginalFileName); + return null; + } + + return new ArgusAndroidRelease(release, originalFileName); + } + catch (Exception ex) + { + logger.LogWarning(ex, "Failed to load Android app release metadata from Argus."); + return null; + } + } + + private static bool TryMapArgusRelease( + ArgusReleaseDto argusRelease, + out AndroidAppReleaseDto release, + out string originalFileName) + { + release = null!; + originalFileName = argusRelease.OriginalFileName; + + if (!TryParseVersion(argusRelease.Version, argusRelease.OriginalFileName, out var versionCode, out var versionName)) + { + return false; + } + + var downloadPath = BuildAbsoluteArgusUrl(argusRelease.DownloadPath); + if (downloadPath is null) + { + return false; + } + + release = new AndroidAppReleaseDto( + versionCode, + versionName, + downloadPath, + argusRelease.PublishedAt, + argusRelease.Notes); + + return true; + } + + private static bool TryParseVersion( + string rawVersion, + string? originalFileName, + out int versionCode, + out string versionName) + { + versionCode = 0; + versionName = rawVersion.Trim(); + + var buildSeparatorIndex = versionName.IndexOf('+'); + if (buildSeparatorIndex >= 0) + { + var parsedVersionName = versionName[..buildSeparatorIndex].Trim(); + var buildMetadata = versionName[(buildSeparatorIndex + 1)..].Trim(); + + if (!string.IsNullOrWhiteSpace(parsedVersionName)) + { + versionName = parsedVersionName; + } + + if (int.TryParse(buildMetadata, NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsedVersionCode) && + parsedVersionCode > 0) + { + versionCode = parsedVersionCode; + } + } + + if (versionCode <= 0 && + !string.IsNullOrWhiteSpace(originalFileName) && + TryParseVersionCodeFromFileName(originalFileName, out var parsedFromFileName)) + { + versionCode = parsedFromFileName; + } + + return versionCode > 0 && !string.IsNullOrWhiteSpace(versionName); + } + + private static bool TryParseVersionCodeFromFileName(string fileName, out int versionCode) + { + versionCode = 0; + var match = VersionCodeFromFileNameRegex.Match(fileName); + if (!match.Success) + { + return false; + } + + return int.TryParse(match.Groups[1].Value, NumberStyles.Integer, CultureInfo.InvariantCulture, out versionCode) && + versionCode > 0; + } + + private static string? BuildAbsoluteArgusUrl(string? rawPath) + { + if (string.IsNullOrWhiteSpace(rawPath)) + { + return null; + } + + if (Uri.TryCreate(rawPath, UriKind.Absolute, out var absoluteUri) && + (absoluteUri.Scheme == Uri.UriSchemeHttp || absoluteUri.Scheme == Uri.UriSchemeHttps)) + { + return absoluteUri.ToString(); + } + + return new Uri(new Uri($"{ArgusBaseUrl.TrimEnd('/')}/"), rawPath.TrimStart('/')).ToString(); + } + + private static bool MatchesArgusFileName(ArgusAndroidRelease argusManifest, string requestedFileName) + { + var normalizedRequested = requestedFileName.Trim(); + if (string.Equals(normalizedRequested, argusManifest.OriginalFileName, StringComparison.OrdinalIgnoreCase)) + { + return true; + } + + var generatedFileName = BuildExpectedFileName(argusManifest.Release.VersionCode); + return string.Equals(normalizedRequested, generatedFileName, StringComparison.OrdinalIgnoreCase); + } + + private AndroidAppReleaseDto? GetLatestLocalRelease() + { + var metadata = GetLocalMetadata(); + if (metadata is null) + { + return null; + } + + var packagePath = Path.Combine(UpdatesRootPath, metadata.PackageFileName); + if (!File.Exists(packagePath)) + { + return null; + } + + return new AndroidAppReleaseDto( + metadata.VersionCode, + metadata.VersionName, + $"/api/app-updates/android/download/{Uri.EscapeDataString(metadata.PackageFileName)}", + metadata.PublishedAt, + metadata.Notes); + } + + private static AndroidAppReleaseDto ApplyMandatoryLatestPolicy(AndroidAppReleaseDto release) + { + return release with + { + IsRequired = true, + MinimumSupportedVersionCode = release.VersionCode + }; + } + + private AndroidAppReleaseMetadata? GetLocalMetadata() + { + if (!File.Exists(MetadataPath)) + { + return null; + } + + return JsonSerializer.Deserialize<AndroidAppReleaseMetadata>(File.ReadAllText(MetadataPath), JsonOptions); + } + + private static string BuildExpectedFileName(int versionCode) => $"ikar-android-{versionCode}.apk"; + + private sealed record ArgusManifestDto( + string Slug, + string Name, + string Summary, + string Description, + string? RepositoryUrl, + string? HomepageUrl, + ArgusReleaseDto Release); + + private sealed record ArgusReleaseDto( + Guid Id, + string Version, + string Channel, + string Platform, + string PackageKind, + string DownloadPath, + string OriginalFileName, + string ContentType, + long PackageSizeBytes, + string Sha256, + DateTimeOffset PublishedAt, + string? Notes); + + private sealed record ArgusAndroidRelease(AndroidAppReleaseDto Release, string OriginalFileName); + + public sealed record AndroidAppDownloadResolution( + string FileName, + string? PhysicalPath, + string? RedirectUrl) + { + public static AndroidAppDownloadResolution ForPhysical(string physicalPath, string fileName) => + new(fileName, physicalPath, null); + + public static AndroidAppDownloadResolution ForRedirect(string redirectUrl, string fileName) => + new(fileName, null, redirectUrl); + } + + public sealed record AndroidAppReleaseMetadata( + int VersionCode, + string VersionName, + string PackageFileName, + DateTimeOffset PublishedAt, + string? Notes); +} diff --git a/src/Ikar.Server/Infrastructure/Updates/AndroidUpdateCompatibilityTrackingService.cs b/src/Ikar.Server/Infrastructure/Updates/AndroidUpdateCompatibilityTrackingService.cs new file mode 100644 index 00000000..2346bb79 --- /dev/null +++ b/src/Ikar.Server/Infrastructure/Updates/AndroidUpdateCompatibilityTrackingService.cs @@ -0,0 +1,82 @@ +using Microsoft.EntityFrameworkCore; +using Ikar.Server.Data; +using Ikar.Server.Data.Entities; +using Ikar.Shared; + +namespace Ikar.Server.Infrastructure.Updates; + +public sealed class AndroidUpdateCompatibilityTrackingService(IkarDbContext dbContext) +{ + private const int MaxUserAgentLength = 512; + + public async Task TrackHitAsync( + HttpRequest request, + string route, + string? requestedFileName, + CancellationToken cancellationToken) + { + var hit = new AndroidUpdateCompatibilityHit + { + HitAt = DateTimeOffset.UtcNow, + Route = TrimToLength(route, 64) ?? "unknown", + RequestPath = TrimToLength(request.Path.Value, 256) ?? string.Empty, + RequestedFileName = TrimToLength(requestedFileName, 260), + ClientPlatform = TrimToLength(ReadClientPlatform(request), 32), + ClientVersionCode = ReadClientVersionCode(request), + ClientVersionName = TrimToLength(ReadClientVersionName(request), 64), + UserAgent = TrimToLength(request.Headers.UserAgent.ToString(), MaxUserAgentLength), + RemoteIp = TrimToLength(request.HttpContext.Connection.RemoteIpAddress?.ToString(), 64) + }; + + dbContext.AndroidUpdateCompatibilityHits.Add(hit); + await dbContext.SaveChangesAsync(cancellationToken); + } + + private static string? ReadClientPlatform(HttpRequest request) + { + var platform = request.Headers[ClientVersionMetadata.PlatformHeaderName].ToString(); + if (!string.IsNullOrWhiteSpace(platform)) + { + return platform; + } + + platform = request.Query[ClientVersionMetadata.PlatformQueryName].ToString(); + return string.IsNullOrWhiteSpace(platform) ? null : platform; + } + + private static int? ReadClientVersionCode(HttpRequest request) + { + var rawVersionCode = request.Headers[ClientVersionMetadata.VersionCodeHeaderName].ToString(); + if (string.IsNullOrWhiteSpace(rawVersionCode)) + { + rawVersionCode = request.Query[ClientVersionMetadata.VersionCodeQueryName].ToString(); + } + + return int.TryParse(rawVersionCode, out var parsedVersionCode) && parsedVersionCode > 0 + ? parsedVersionCode + : null; + } + + private static string? ReadClientVersionName(HttpRequest request) + { + var versionName = request.Headers[ClientVersionMetadata.VersionNameHeaderName].ToString(); + if (!string.IsNullOrWhiteSpace(versionName)) + { + return versionName; + } + + versionName = request.Query[ClientVersionMetadata.VersionNameQueryName].ToString(); + return string.IsNullOrWhiteSpace(versionName) ? null : versionName; + } + + private static string? TrimToLength(string? value, int maxLength) + { + if (string.IsNullOrWhiteSpace(value)) + { + return null; + } + + var trimmed = value.Trim(); + return trimmed.Length <= maxLength ? trimmed : trimmed[..maxLength]; + } +} diff --git a/src/Ikar.Server/Program.cs b/src/Ikar.Server/Program.cs new file mode 100644 index 00000000..c4046ffd --- /dev/null +++ b/src/Ikar.Server/Program.cs @@ -0,0 +1,297 @@ +using System.Net; +using System.Security.Claims; +using Microsoft.AspNetCore.Authentication.JwtBearer; +using Microsoft.AspNetCore.Authentication.Cookies; +using Microsoft.AspNetCore.HttpOverrides; +using Microsoft.EntityFrameworkCore; +using Microsoft.IdentityModel.Tokens; +using Ikar.Server.Data; +using Ikar.Server.Infrastructure; +using Ikar.Server.Infrastructure.Admin; +using Ikar.Server.Infrastructure.Auth; +using Ikar.Server.Infrastructure.Bots; +using Ikar.Server.Infrastructure.Calls; +using Ikar.Server.Infrastructure.Hubs; +using Ikar.Server.Infrastructure.Push; +using Ikar.Server.Infrastructure.Storage; +using Ikar.Server.Infrastructure.Updates; +using Ikar.Shared; +using System.Text; + +var builder = WebApplication.CreateBuilder(args); +var sessionActivityWriteInterval = TimeSpan.FromMinutes(1); +var seedDemoData = builder.Configuration.GetValue<bool?>("SeedDemoData") ?? builder.Environment.IsDevelopment(); +var configuredUrls = + builder.Configuration["ASPNETCORE_URLS"] ?? + builder.Configuration["URLS"] ?? + "http://0.0.0.0:5099"; + +builder.WebHost.UseUrls(configuredUrls); + +builder.Services.AddControllers(); +builder.Services.AddOpenApi(); +builder.Services.AddSignalR(); +builder.Services.AddEndpointsApiExplorer(); +builder.Services.AddHttpClient(); + +builder.Services.AddDbContext<IkarDbContext>(options => + options.UseSqlite(builder.Configuration.GetConnectionString("IkarDb") ?? + "Data Source=Data/ikar.db")); + +builder.Services.Configure<ForwardedHeadersOptions>(options => +{ + options.ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto | ForwardedHeaders.XForwardedHost; + options.KnownProxies.Add(IPAddress.Loopback); + options.KnownProxies.Add(IPAddress.IPv6Loopback); +}); + +builder.Services.Configure<JwtOptions>(builder.Configuration.GetSection(JwtOptions.SectionName)); +builder.Services.Configure<PushOptions>(builder.Configuration.GetSection(PushOptions.SectionName)); +builder.Services.Configure<AdminOptions>(builder.Configuration.GetSection(AdminOptions.SectionName)); +builder.Services.Configure<WebRtcOptions>(builder.Configuration.GetSection(WebRtcOptions.SectionName)); + +var jwtOptions = builder.Configuration + .GetSection(JwtOptions.SectionName) + .Get<JwtOptions>() ?? new JwtOptions(); +var signingKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtOptions.SigningKey)); + +builder.Services + .AddAuthentication(JwtBearerDefaults.AuthenticationScheme) + .AddJwtBearer(options => + { + options.TokenValidationParameters = new TokenValidationParameters + { + ValidateIssuer = true, + ValidateAudience = true, + ValidateIssuerSigningKey = true, + ValidateLifetime = true, + ValidIssuer = jwtOptions.Issuer, + ValidAudience = jwtOptions.Audience, + IssuerSigningKey = signingKey, + ClockSkew = TimeSpan.FromSeconds(30) + }; + + options.Events = new JwtBearerEvents + { + OnMessageReceived = context => + { + var accessToken = context.Request.Query["access_token"]; + var path = context.HttpContext.Request.Path; + if (!string.IsNullOrWhiteSpace(accessToken) && + path.StartsWithSegments("/hubs/messenger")) + { + context.Token = accessToken; + } + + return Task.CompletedTask; + }, + OnTokenValidated = async context => + { + var principal = context.Principal; + if (principal is null) + { + context.Fail("Token principal is missing."); + return; + } + + var sessionId = principal.GetRequiredSessionId(); + var dbContext = context.HttpContext.RequestServices.GetRequiredService<IkarDbContext>(); + var session = await dbContext.UserSessions + .Include(x => x.User) + .SingleOrDefaultAsync(x => x.Id == sessionId, context.HttpContext.RequestAborted); + if (session is null) + { + context.Fail("Session is no longer active."); + return; + } + + if (session.User.IsBlocked) + { + context.Fail("User is blocked."); + return; + } + + var now = DateTimeOffset.UtcNow; + var changed = ClientVersionRequestReader.Apply( + session, + ClientVersionRequestReader.Read(context.HttpContext.Request)); + if (session.LastSeenAt <= now.Subtract(sessionActivityWriteInterval)) + { + session.LastSeenAt = now; + changed = true; + } + + if (changed) + { + await dbContext.SaveChangesAsync(context.HttpContext.RequestAborted); + } + } + }; + }) + .AddCookie(AdminAuthConstants.Scheme, options => + { + options.Cookie.Name = "Ikar.Admin"; + options.Cookie.HttpOnly = true; + options.Cookie.SameSite = SameSiteMode.Strict; + options.Cookie.SecurePolicy = builder.Environment.IsDevelopment() + ? CookieSecurePolicy.SameAsRequest + : CookieSecurePolicy.Always; + options.ExpireTimeSpan = TimeSpan.FromHours(12); + options.SlidingExpiration = true; + options.Events = new CookieAuthenticationEvents + { + OnRedirectToLogin = context => + { + context.Response.StatusCode = StatusCodes.Status401Unauthorized; + return Task.CompletedTask; + }, + OnRedirectToAccessDenied = context => + { + context.Response.StatusCode = StatusCodes.Status403Forbidden; + return Task.CompletedTask; + } + }; + }); + +builder.Services.AddAuthorization(options => +{ + options.AddPolicy(AdminAuthConstants.Policy, policy => + { + policy.AuthenticationSchemes.Add(AdminAuthConstants.Scheme); + policy.RequireAuthenticatedUser(); + policy.RequireClaim(ClaimTypes.Role, "Admin"); + }); +}); +builder.Services.AddSingleton<PresenceTracker>(); +builder.Services.AddSingleton<CallSessionStore>(); +builder.Services.AddSingleton(new ServerRuntimeState(DateTimeOffset.UtcNow)); +builder.Services.AddScoped<TokenService>(); +builder.Services.AddSingleton<BotTokenService>(); +builder.Services.AddSingleton<PhoneAuthChallengeStore>(); +builder.Services.AddScoped<DatabaseSeeder>(); +builder.Services.AddScoped<DatabaseSchemaBootstrapper>(); +builder.Services.AddScoped<AttachmentStorageService>(); +builder.Services.AddScoped<AvatarStorageService>(); +builder.Services.AddScoped<BotUpdateService>(); +builder.Services.AddScoped<PushNotificationDispatcher>(); +builder.Services.AddScoped<AndroidUpdateCompatibilityTrackingService>(); +builder.Services.AddScoped<AdminService>(); +builder.Services.AddSingleton<AndroidAppUpdateService>(); +builder.Services.AddHostedService<AndroidAppUpdateMonitorService>(); +builder.Services.AddSingleton<PushDispatchQueue>(); +builder.Services.AddHostedService<PushDispatchBackgroundService>(); +builder.Services.AddHttpClient<IPushNotificationSender, FirebasePushNotificationSender>(); + +var app = builder.Build(); + +Directory.CreateDirectory(Path.Combine(app.Environment.ContentRootPath, "Data")); +app.Services.GetRequiredService<AndroidAppUpdateService>().EnsureStorage(); + +await using (var scope = app.Services.CreateAsyncScope()) +{ + var db = scope.ServiceProvider.GetRequiredService<IkarDbContext>(); + await db.Database.EnsureCreatedAsync(); + await scope.ServiceProvider.GetRequiredService<DatabaseSchemaBootstrapper>().EnsureLatestAsync(); + if (seedDemoData) + { + await scope.ServiceProvider.GetRequiredService<DatabaseSeeder>().SeedAsync(); + } +} + +if (app.Environment.IsDevelopment()) +{ + app.MapOpenApi(); +} + +app.Use(async (context, next) => +{ + if (context.Request.Path == "/" || context.Request.Path == "/index.html") + { + context.Response.Redirect("https://argus.kusoft.xyz/#catalog", permanent: false); + return; + } + + if (context.Request.Path == "/admin" || context.Request.Path == "/admin/") + { + context.Response.Redirect("/admin/index.html"); + return; + } + + await next(); +}); +app.UseForwardedHeaders(); +app.UseStaticFiles(); +app.Use(async (context, next) => +{ + if (!ShouldEnforceAndroidClientVersion(context.Request)) + { + await next(); + return; + } + + var release = await context.RequestServices + .GetRequiredService<AndroidAppUpdateService>() + .GetLatestReleaseAsync(context.RequestAborted); + + var minimumSupportedVersionCode = release?.MinimumSupportedVersionCode ?? 0; + if (release is null || minimumSupportedVersionCode <= 0) + { + await next(); + return; + } + + if (!TryGetClientPlatform(context.Request, out var clientPlatform) || + !string.Equals(clientPlatform, ClientPlatformNames.Android, StringComparison.OrdinalIgnoreCase)) + { + await next(); + return; + } + + if (!TryGetClientVersionCode(context.Request, out var clientVersionCode) || + clientVersionCode >= minimumSupportedVersionCode) + { + await next(); + return; + } + + context.Response.StatusCode = StatusCodes.Status426UpgradeRequired; + context.Response.ContentType = "application/json; charset=utf-8"; + await context.Response.WriteAsJsonAsync(release, cancellationToken: context.RequestAborted); +}); +app.UseAuthentication(); +app.UseMiddleware<ApiRateLimitMiddleware>(); +app.UseAuthorization(); + +app.MapControllers(); +app.MapGet("/health", async (IkarDbContext dbContext, CancellationToken cancellationToken) => +{ + var canConnect = await dbContext.Database.CanConnectAsync(cancellationToken); + return canConnect + ? Results.Ok(new { status = "ok" }) + : Results.StatusCode(StatusCodes.Status503ServiceUnavailable); +}); +app.MapHub<MessengerHub>("/hubs/messenger"); + +app.Run(); + +static bool ShouldEnforceAndroidClientVersion(HttpRequest request) +{ + if (request.Path.StartsWithSegments("/health", StringComparison.OrdinalIgnoreCase) || + request.Path.StartsWithSegments("/api/app-updates", StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + return request.Path.StartsWithSegments("/api", StringComparison.OrdinalIgnoreCase) || + request.Path.StartsWithSegments("/hubs/messenger", StringComparison.OrdinalIgnoreCase); +} + +static bool TryGetClientPlatform(HttpRequest request, out string? clientPlatform) +{ + return ClientVersionRequestReader.TryGetClientPlatform(request, out clientPlatform); +} + +static bool TryGetClientVersionCode(HttpRequest request, out int clientVersionCode) +{ + return ClientVersionRequestReader.TryGetClientVersionCode(request, out clientVersionCode); +} diff --git a/src/Ikar.Server/Properties/launchSettings.json b/src/Ikar.Server/Properties/launchSettings.json new file mode 100644 index 00000000..402e6edd --- /dev/null +++ b/src/Ikar.Server/Properties/launchSettings.json @@ -0,0 +1,23 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": false, + "applicationUrl": "http://localhost:5099", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "Ikar.Server": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": false, + "applicationUrl": "http://localhost:5099", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/src/Ikar.Server/appsettings.Development.json b/src/Ikar.Server/appsettings.Development.json new file mode 100644 index 00000000..70147db7 --- /dev/null +++ b/src/Ikar.Server/appsettings.Development.json @@ -0,0 +1,9 @@ +{ + "SeedDemoData": true, + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/src/Ikar.Server/appsettings.json b/src/Ikar.Server/appsettings.json new file mode 100644 index 00000000..7af62eda --- /dev/null +++ b/src/Ikar.Server/appsettings.json @@ -0,0 +1,38 @@ +{ + "ConnectionStrings": { + "IkarDb": "Data Source=Data/ikar.db" + }, + "Jwt": { + "Issuer": "Ikar.Server", + "Audience": "Ikar.Client", + "SigningKey": "Ikar-Replace-This-Development-Key-With-A-Long-Random-Secret-CHANGE_ME_USE_SECRET_STORE_VALUE_32_CHARS_MINIMUM", + "AccessTokenLifetimeMinutes": 15, + "RefreshTokenLifetimeDays": 30 + }, + "Push": { + "FirebaseProjectId": "", + "ServiceAccountJsonPath": "", + "ServiceAccountJson": "" + }, + "WebRtc": { + "IceServers": [ + { + "Urls": [ + "stun:stun.l.google.com:19302" + ] + } + ] + }, + "Admin": { + "Username": "", + "Password": "" + }, + "SeedDemoData": false, + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +} diff --git a/src/Ikar.Server/wwwroot/admin/admin.css b/src/Ikar.Server/wwwroot/admin/admin.css new file mode 100644 index 00000000..184bc1fa --- /dev/null +++ b/src/Ikar.Server/wwwroot/admin/admin.css @@ -0,0 +1,704 @@ +:root { + --paper: #f4f1ea; + --paper-2: #ebe6dc; + --ink: #14171b; + --ink-2: #2a3038; + --muted: #68707b; + --line: #c9c2b5; + --line-strong: #9e9688; + --panel: #fffdf8; + --rail: #171b20; + --rail-2: #20262e; + --accent: #0f5f9f; + --accent-2: #0b7c86; + --warning: #a56300; + --danger: #b3261e; + --ok: #256b3b; + --shadow: 0 24px 80px rgba(34, 29, 22, 0.12); + --font: "Aptos", "Segoe UI Variable", "Segoe UI", sans-serif; + --mono: "Cascadia Mono", "JetBrains Mono", "Consolas", monospace; +} + +* { + box-sizing: border-box; +} + +html, +body { + margin: 0; + min-height: 100%; +} + +body { + background: + linear-gradient(90deg, rgba(20, 23, 27, 0.035) 1px, transparent 1px), + linear-gradient(0deg, rgba(20, 23, 27, 0.025) 1px, transparent 1px), + linear-gradient(135deg, #f9f6ef 0%, var(--paper) 48%, #e7edf0 100%); + background-size: 40px 40px, 40px 40px, auto; + color: var(--ink); + font-family: var(--font); +} + +button, +input, +select { + font: inherit; +} + +button { + border-radius: 0; +} + +.hidden { + display: none !important; +} + +.login-view { + min-height: 100vh; + display: grid; + place-items: center; + padding: 32px; +} + +.login-card { + width: min(480px, 100%); + border: 1px solid var(--line-strong); + background: var(--panel); + padding: 34px; + box-shadow: var(--shadow); +} + +.login-card h1, +.topbar h1, +.command-panel h2, +.empty-state h2 { + margin: 0; + letter-spacing: -0.035em; +} + +.login-card h1 { + font-size: clamp(32px, 5vw, 52px); + line-height: 0.95; +} + +.app-shell { + min-height: 100vh; + display: grid; + grid-template-columns: 292px minmax(0, 1fr); +} + +.rail { + position: sticky; + top: 0; + height: 100vh; + display: flex; + flex-direction: column; + gap: 22px; + padding: 22px; + border-right: 1px solid #0b0d10; + background: + linear-gradient(180deg, rgba(255, 255, 255, 0.04), transparent 30%), + var(--rail); + color: #f5efe4; +} + +.brand { + display: grid; + grid-template-columns: 46px minmax(0, 1fr); + gap: 12px; + align-items: center; + padding-bottom: 18px; + border-bottom: 1px solid rgba(255, 255, 255, 0.14); +} + +.brand-mark { + display: grid; + place-items: center; + width: 46px; + height: 46px; + border: 1px solid rgba(255, 255, 255, 0.35); + background: #f5efe4; + color: var(--rail); + font-weight: 850; + letter-spacing: -0.08em; +} + +.brand-title { + font-size: 21px; + font-weight: 820; + letter-spacing: -0.04em; +} + +.brand-subtitle, +.rail-label, +.rail-search span, +.eyebrow, +.muted, +.cell-secondary, +th, +.topbar-meta, +.counter { + color: var(--muted); +} + +.rail .muted, +.rail-label, +.rail-search span, +.brand-subtitle { + color: rgba(245, 239, 228, 0.62); +} + +.rail-section { + border: 1px solid rgba(255, 255, 255, 0.14); + padding: 13px; + background: rgba(255, 255, 255, 0.035); +} + +.rail-value { + margin-top: 6px; + overflow: hidden; + color: #fffdf8; + font-weight: 760; + text-overflow: ellipsis; +} + +.rail-search { + display: grid; + gap: 8px; +} + +.rail-search input, +.field input, +.toolbar select, +.toolbar input, +.inspector input, +.inspector select { + width: 100%; + min-height: 40px; + border: 1px solid var(--line-strong); + border-radius: 0; + background: #fffdf8; + color: var(--ink); + padding: 9px 10px; +} + +.rail-search input { + border-color: rgba(255, 255, 255, 0.2); + background: #101318; + color: #fffdf8; +} + +input:focus, +select:focus, +button:focus-visible { + outline: 2px solid color-mix(in srgb, var(--accent) 40%, transparent); + outline-offset: 2px; +} + +.nav-list { + display: grid; + gap: 6px; +} + +.nav-item { + width: 100%; + border: 1px solid transparent; + background: transparent; + color: rgba(245, 239, 228, 0.72); + cursor: pointer; + padding: 11px 12px; + text-align: left; +} + +.nav-item:hover, +.nav-item.active { + border-color: rgba(255, 255, 255, 0.18); + background: var(--rail-2); + color: #fffdf8; +} + +.rail-footer { + display: grid; + gap: 9px; + margin-top: auto; +} + +.workspace { + min-width: 0; + padding: 28px; +} + +.topbar { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 24px; + margin-bottom: 20px; +} + +.topbar h1 { + font-size: clamp(28px, 4vw, 46px); + line-height: 0.98; +} + +.topbar-meta { + display: flex; + flex-wrap: wrap; + gap: 10px; + align-items: center; + justify-content: flex-end; + font-size: 13px; +} + +.section { + display: none; +} + +.section.active { + display: grid; + gap: 18px; +} + +.hero-grid { + display: grid; + grid-template-columns: minmax(0, 1.55fr) minmax(320px, 0.85fr); + gap: 18px; +} + +.split-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 18px; +} + +.registry-layout { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(360px, 460px); + gap: 18px; + align-items: start; +} + +.command-panel, +.inspector { + border: 1px solid var(--line); + background: color-mix(in srgb, var(--panel) 92%, white); + box-shadow: 0 1px 0 rgba(255, 255, 255, 0.7) inset; +} + +.command-panel { + padding: 18px; +} + +.inspector { + position: sticky; + top: 18px; + max-height: calc(100vh - 56px); + overflow: auto; + padding: 0; +} + +.panel-head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; + margin-bottom: 14px; +} + +.eyebrow { + margin: 0 0 7px; + font-size: 11px; + font-weight: 850; + letter-spacing: 0.13em; + text-transform: uppercase; +} + +.muted { + line-height: 1.55; +} + +.mono { + font-family: var(--mono); +} + +.metric-grid { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 10px; +} + +.metric-grid.compact { + grid-template-columns: repeat(3, minmax(0, 1fr)); +} + +.metric { + min-height: 104px; + border: 1px solid var(--line); + background: #fbf8f1; + padding: 13px; +} + +.metric-label { + color: var(--muted); + font-size: 11px; + font-weight: 800; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.metric-value { + margin-top: 12px; + font-size: clamp(24px, 3vw, 34px); + font-weight: 860; + letter-spacing: -0.045em; +} + +.metric-note { + margin-top: 8px; + color: var(--muted); + font-size: 13px; +} + +.focus-list, +.feature-matrix, +.action-stack, +.stack { + display: grid; + gap: 10px; +} + +.focus-item, +.feature-row, +.operation-result { + border-left: 4px solid var(--line-strong); + background: #fbf8f1; + padding: 12px; +} + +.focus-item.ok { + border-left-color: var(--ok); +} + +.focus-item.warn { + border-left-color: var(--warning); +} + +.focus-item.danger { + border-left-color: var(--danger); +} + +.feature-row { + display: flex; + justify-content: space-between; + gap: 12px; +} + +.toolbar { + display: flex; + flex-wrap: wrap; + gap: 12px; + margin-bottom: 12px; +} + +.toolbar label { + display: grid; + min-width: 180px; + gap: 6px; + color: var(--muted); + font-size: 13px; +} + +.table-wrap { + overflow: auto; + border: 1px solid var(--line); + background: #fffdf8; +} + +table { + width: 100%; + border-collapse: collapse; +} + +th, +td { + padding: 11px 12px; + border-bottom: 1px solid #e2dbcf; + text-align: left; + vertical-align: top; +} + +th { + position: sticky; + top: 0; + z-index: 1; + background: #ede7dc; + font-size: 11px; + font-weight: 850; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +td { + font-size: 14px; +} + +tr[data-chat-id], +tr[data-row-click] { + cursor: pointer; +} + +tr[data-chat-id]:hover, +tr[data-row-click]:hover { + background: #f7f1e7; +} + +tr.selected { + background: #eef5f4; + box-shadow: inset 4px 0 0 var(--accent-2); +} + +.cell-primary { + font-weight: 780; +} + +.cell-secondary { + margin-top: 4px; + font-size: 12px; + line-height: 1.45; +} + +.status, +.counter, +.rect-badge { + display: inline-flex; + align-items: center; + min-height: 28px; + border: 1px solid var(--line-strong); + background: #fffdf8; + padding: 5px 8px; + font-size: 12px; + font-weight: 780; + white-space: nowrap; +} + +.status.ok, +.rect-badge.ok { + border-color: color-mix(in srgb, var(--ok) 60%, var(--line)); + color: var(--ok); +} + +.status.warn, +.rect-badge.warn { + border-color: color-mix(in srgb, var(--warning) 60%, var(--line)); + color: var(--warning); +} + +.status.danger, +.rect-badge.danger { + border-color: color-mix(in srgb, var(--danger) 60%, var(--line)); + color: var(--danger); +} + +.button { + border: 1px solid var(--line-strong); + background: #fffdf8; + color: var(--ink); + cursor: pointer; + min-height: 38px; + padding: 9px 12px; + font-weight: 780; +} + +.button:hover { + transform: translateY(-1px); +} + +.button.primary { + border-color: var(--accent); + background: var(--accent); + color: #fff; +} + +.button.secondary { + border-color: var(--ink-2); + background: var(--ink-2); + color: #fffdf8; +} + +.button.ghost { + background: transparent; + color: inherit; +} + +.button.danger { + border-color: var(--danger); + background: #fff7f4; + color: var(--danger); +} + +.button.small { + min-height: 30px; + padding: 6px 8px; + font-size: 12px; +} + +.action-row { + display: flex; + flex-wrap: wrap; + gap: 7px; +} + +.table-link { + color: var(--accent); + font-weight: 800; + text-decoration: none; +} + +.table-link:hover { + text-decoration: underline; +} + +.notice, +.toast { + border: 1px solid var(--line-strong); + background: #fff9e9; + color: #6b3f00; + padding: 12px 14px; +} + +.notice { + margin-bottom: 16px; +} + +.toast { + position: fixed; + right: 22px; + bottom: 22px; + z-index: 20; + max-width: min(460px, calc(100vw - 44px)); + background: var(--ink); + color: #fffdf8; +} + +.field { + display: grid; + gap: 7px; +} + +.field span { + color: var(--muted); + font-size: 13px; +} + +.inspector-block { + padding: 16px; + border-bottom: 1px solid var(--line); +} + +.inspector-block h3 { + margin: 0 0 10px; + font-size: 16px; +} + +.inspector-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 10px; +} + +.check-line { + display: flex; + align-items: center; + gap: 8px; + min-height: 36px; +} + +.check-line input { + width: auto; +} + +.empty-state { + padding: 22px; +} + +.mini-list { + display: grid; + gap: 8px; +} + +.mini-row { + border: 1px solid #e2dbcf; + background: #fbf8f1; + padding: 10px; +} + +.permissions-grid { + display: grid; + grid-template-columns: 1fr; + gap: 8px; +} + +.permission-row { + border: 1px solid #e2dbcf; + padding: 10px; + background: #fffdf8; +} + +.permission-controls { + display: grid; + grid-template-columns: minmax(120px, 0.9fr) repeat(4, minmax(70px, 1fr)) auto; + gap: 8px; + align-items: center; + margin-top: 8px; +} + +.coverage-list { + margin: 0; + padding-left: 18px; + color: var(--ink-2); + line-height: 1.65; +} + +@media (max-width: 1280px) { + .metric-grid, + .metric-grid.compact { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .hero-grid, + .split-grid, + .registry-layout { + grid-template-columns: 1fr; + } + + .inspector { + position: static; + max-height: none; + } +} + +@media (max-width: 860px) { + .app-shell { + grid-template-columns: 1fr; + } + + .rail { + position: static; + height: auto; + } + + .workspace { + padding: 18px; + } + + .topbar { + display: grid; + } + + .metric-grid, + .metric-grid.compact, + .inspector-grid, + .permission-controls { + grid-template-columns: 1fr; + } +} diff --git a/src/Ikar.Server/wwwroot/admin/admin.js b/src/Ikar.Server/wwwroot/admin/admin.js new file mode 100644 index 00000000..de50b74a --- /dev/null +++ b/src/Ikar.Server/wwwroot/admin/admin.js @@ -0,0 +1,1027 @@ +const state = { + session: null, + activeSection: "overview", + query: "", + status: null, + compatibility: null, + users: [], + chats: [], + attachments: [], + moderation: [], + selectedChatId: null, + selectedChatDetails: null, + refreshTimer: null, +}; + +const sectionMeta = { + overview: ["Обзор", "Операционная картина"], + users: ["Пользователи", "Пользователи и боты"], + chats: ["Чаты и каналы", "Управление чатами, группами и каналами"], + media: ["Медиа", "Хранилище вложений"], + moderation: ["Модерация", "Журнал действий"], + updates: ["Обновления", "Android-релизы и совместимость"], + operations: ["Операции", "Maintenance и контроль данных"], +}; + +const elements = { + loginView: document.getElementById("loginView"), + appView: document.getElementById("appView"), + loginForm: document.getElementById("loginForm"), + loginUsername: document.getElementById("loginUsername"), + loginPassword: document.getElementById("loginPassword"), + loginNotice: document.getElementById("loginNotice"), + adminIdentity: document.getElementById("adminIdentity"), + globalSearch: document.getElementById("globalSearch"), + refreshButton: document.getElementById("refreshButton"), + logoutButton: document.getElementById("logoutButton"), + sectionKicker: document.getElementById("sectionKicker"), + sectionTitle: document.getElementById("sectionTitle"), + lastUpdated: document.getElementById("lastUpdated"), + healthBadge: document.getElementById("healthBadge"), + noticeBar: document.getElementById("noticeBar"), + metricGrid: document.getElementById("metricGrid"), + uptimeValue: document.getElementById("uptimeValue"), + focusList: document.getElementById("focusList"), + featureMatrix: document.getElementById("featureMatrix"), + usersCount: document.getElementById("usersCount"), + usersTable: document.getElementById("usersTable"), + chatsCount: document.getElementById("chatsCount"), + chatsTable: document.getElementById("chatsTable"), + chatTypeFilter: document.getElementById("chatTypeFilter"), + chatAttentionFilter: document.getElementById("chatAttentionFilter"), + chatInspector: document.getElementById("chatInspector"), + attachmentsCount: document.getElementById("attachmentsCount"), + attachmentsTable: document.getElementById("attachmentsTable"), + moderationCount: document.getElementById("moderationCount"), + moderationTable: document.getElementById("moderationTable"), + compatibilityCount: document.getElementById("compatibilityCount"), + compatibilitySummary: document.getElementById("compatibilitySummary"), + updateRiskList: document.getElementById("updateRiskList"), + compatibilityRoutesTable: document.getElementById("compatibilityRoutesTable"), + compatibilityVersionsTable: document.getElementById("compatibilityVersionsTable"), + compatibilityHitsTable: document.getElementById("compatibilityHitsTable"), + operationResult: document.getElementById("operationResult"), + toast: document.getElementById("toast"), +}; + +document.addEventListener("DOMContentLoaded", () => { + wireEvents(); + initialize().catch(handleError); +}); + +function wireEvents() { + elements.loginForm.addEventListener("submit", async (event) => { + event.preventDefault(); + await login(); + }); + + elements.refreshButton.addEventListener("click", () => refreshAll().catch(handleError)); + elements.logoutButton.addEventListener("click", () => logout().catch(handleError)); + elements.chatTypeFilter.addEventListener("change", renderChats); + elements.chatAttentionFilter.addEventListener("change", renderChats); + + elements.globalSearch.addEventListener("input", () => { + window.clearTimeout(state.refreshTimer); + state.refreshTimer = window.setTimeout(() => { + state.query = elements.globalSearch.value.trim(); + refreshAll().catch(handleError); + }, 360); + }); + + document.addEventListener("click", handleDocumentClick); +} + +async function initialize() { + state.session = await fetchJson("/admin/api/session", { method: "GET" }, false); + renderSession(); + + if (!state.session.configured) { + showLogin("Административные учетные данные на сервере не настроены."); + return; + } + + if (!state.session.authenticated) { + showLogin(); + return; + } + + showApp(); + await refreshAll(); +} + +async function login() { + elements.loginNotice.classList.add("hidden"); + await fetchJson("/admin/api/session/login", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + username: elements.loginUsername.value.trim(), + password: elements.loginPassword.value, + }), + }, false); + + state.session = await fetchJson("/admin/api/session", { method: "GET" }, false); + elements.loginPassword.value = ""; + renderSession(); + showApp(); + await refreshAll(); +} + +async function logout() { + await fetchJson("/admin/api/session/logout", { method: "POST" }); + state.session = await fetchJson("/admin/api/session", { method: "GET" }, false); + state.selectedChatId = null; + state.selectedChatDetails = null; + renderSession(); + showLogin(); +} + +async function refreshAll() { + state.query = elements.globalSearch.value.trim(); + const query = state.query ? `?q=${encodeURIComponent(state.query)}` : ""; + + const [status, compatibility, users, chats, attachments, moderation] = await Promise.all([ + fetchJson("/admin/api/status"), + fetchJson("/admin/api/app-updates/compatibility-report"), + fetchJson(`/admin/api/users${query}`), + fetchJson(`/admin/api/chats${query}`), + fetchJson(`/admin/api/attachments${query}`), + fetchJson("/admin/api/moderation-log?limit=150"), + ]); + + state.status = status; + state.compatibility = compatibility; + state.users = users || []; + state.chats = chats || []; + state.attachments = attachments || []; + state.moderation = moderation || []; + + if (state.selectedChatId) { + await loadChatDetails(state.selectedChatId, false); + } + + elements.lastUpdated.textContent = `обновлено ${new Date().toLocaleTimeString("ru-RU")}`; + renderAll(); +} + +async function loadChatDetails(chatId, rerender = true) { + state.selectedChatId = chatId; + state.selectedChatDetails = await fetchJson(`/admin/api/chats/${chatId}/details`); + if (rerender) { + renderChats(); + renderChatInspector(); + } +} + +function showLogin(message) { + elements.appView.classList.add("hidden"); + elements.loginView.classList.remove("hidden"); + if (message) { + elements.loginNotice.textContent = message; + elements.loginNotice.classList.remove("hidden"); + } else { + elements.loginNotice.classList.add("hidden"); + } +} + +function showApp() { + elements.loginView.classList.add("hidden"); + elements.appView.classList.remove("hidden"); + setSection(state.activeSection); +} + +function setSection(section) { + state.activeSection = section; + document.querySelectorAll(".nav-item").forEach((item) => { + item.classList.toggle("active", item.dataset.section === section); + }); + document.querySelectorAll(".section").forEach((panel) => { + panel.classList.toggle("active", panel.dataset.panel === section); + }); + + const [kicker, title] = sectionMeta[section] || sectionMeta.overview; + elements.sectionKicker.textContent = kicker; + elements.sectionTitle.textContent = title; +} + +function renderAll() { + renderStatus(); + renderUsers(); + renderChats(); + renderAttachments(); + renderModeration(); + renderCompatibility(); + renderChatInspector(); +} + +function renderSession() { + elements.adminIdentity.textContent = state.session?.authenticated ? state.session.username : "Гость"; +} + +function renderStatus() { + const status = state.status; + if (!status) { + return; + } + + const healthy = Boolean(status.databaseReachable); + elements.healthBadge.textContent = healthy ? "База доступна" : "База недоступна"; + elements.healthBadge.className = `status ${healthy ? "ok" : "danger"}`; + elements.uptimeValue.textContent = formatDuration(status.startedAtUtc, status.currentTimeUtc); + + elements.metricGrid.innerHTML = [ + metric("Пользователи", status.totalUsers, `${status.blockedUsers} заблокировано, ${status.botUsers} ботов`), + metric("Чаты", status.totalChats, `${status.groupChats} групп, ${status.channelChats} каналов`), + metric("Сообщения", status.totalMessages, `${status.pinnedMessages} закреплено, ${status.expiringMessages} с TTL`), + metric("Медиа", status.totalAttachments, formatBytes(status.attachmentBytes)), + metric("Protected", status.protectedChats, `${status.protectedMessages} защищенных сообщений`), + metric("Stories", status.totalStories, `${status.activeStories} активных, ${status.protectedStories} protected`), + metric("Join requests", status.pendingJoinRequests, `${status.activeInviteLinks} активных invite links`), + metric("Сессии", status.activeSessions, `${status.registeredPushDevices} push-устройств`), + ].join(""); + + elements.focusList.innerHTML = [ + focus(healthy ? "ok" : "danger", "База данных", healthy ? "Соединение активно." : "Нужно проверить подключение к БД."), + focus(status.pendingJoinRequests > 0 ? "warn" : "ok", "Заявки на вход", `${status.pendingJoinRequests} ожидают решения.`), + focus(status.expiringMessages > 0 ? "warn" : "ok", "TTL-сообщения", `${status.expiringMessages} активных сообщений с автоудалением.`), + focus(status.blockedUsers > 0 ? "warn" : "ok", "Ограниченные аккаунты", `${status.blockedUsers} пользователей заблокировано.`), + focus(status.moderationEvents > 0 ? "ok" : "warn", "Модерация", `${status.moderationEvents} событий в журнале.`), + ].join(""); + + elements.featureMatrix.innerHTML = [ + feature("Пользователи и privacy", `${status.totalUsers} аккаунтов`, status.totalUsers > 0), + feature("Группы и каналы", `${status.groupChats + status.channelChats} публичных контуров`, status.groupChats + status.channelChats > 0), + feature("Protected content", `${status.protectedChats} чатов`, status.protectedChats > 0), + feature("Топики и роли", `${status.topics} топиков`, status.topics > 0), + feature("Истории", `${status.activeStories} активных`, status.totalStories > 0), + feature("Боты", `${status.enabledBots}/${status.totalBots} включено`, status.totalBots > 0), + ].join(""); +} + +function renderUsers() { + elements.usersCount.textContent = `${state.users.length} строк`; + elements.usersTable.innerHTML = state.users.length === 0 + ? emptyRow("Пользователи по текущему фильтру не найдены.", 9) + : state.users.map((user) => ` + <tr> + <td> + <div class="cell-primary">${escapeHtml(displayName(user.displayName, user.username))}</div> + <div class="cell-secondary">@${escapeHtml(user.username)}${user.phoneNumber ? ` · ${escapeHtml(user.phoneNumber)}` : ""}</div> + ${user.about ? `<div class="cell-secondary">${escapeHtml(user.about)}</div>` : ""} + </td> + <td>${badge(user.isBot ? "Бот" : "Человек", user.isBot ? "warn" : "ok")}</td> + <td>${formatDate(user.createdAt)}</td> + <td>${user.lastSeenAt ? formatDate(user.lastSeenAt) : "—"}</td> + <td>${renderUserClientVersion(user)}</td> + <td>${user.activeSessions} / ${user.pushDevices}</td> + <td>${user.chatMemberships} / ${user.messagesSent}</td> + <td> + ${badge(user.passcodeLockEnabled ? "Passcode" : "Без lock", user.passcodeLockEnabled ? "ok" : "warn")} + <div class="cell-secondary">${user.showMessagePreview ? "Preview включены" : "Preview скрыты"}</div> + </td> + <td> + <div class="action-row"> + <button class="button small" type="button" data-action="${user.isBlocked ? "unblock-user" : "block-user"}" data-user-id="${escapeAttribute(user.id)}"> + ${user.isBlocked ? "Разблокировать" : "Заблокировать"} + </button> + <button class="button small danger" type="button" data-action="delete-user" data-user-id="${escapeAttribute(user.id)}" data-user-name="${escapeAttribute(user.username)}"> + Удалить + </button> + </div> + </td> + </tr> + `).join(""); +} + +function renderUserClientVersion(user) { + const versions = Array.isArray(user.clientVersions) ? user.clientVersions : []; + if (!versions.length && user.clientVersionCode == null && !user.clientVersionName && !user.clientPlatform) { + return "—"; + } + + const platform = user.clientPlatform ? escapeHtml(user.clientPlatform) : "unknown"; + const details = versions.slice(0, 3).map((version) => { + const sessions = Number(version.sessions || 0); + const pushDevices = Number(version.pushDevices || 0); + const countParts = []; + if (sessions > 0) { + countParts.push(`${sessions} сесс.`); + } + if (pushDevices > 0) { + countParts.push(`${pushDevices} push`); + } + const countLabel = countParts.length ? ` · ${countParts.join(" / ")}` : ""; + const seenLabel = version.lastSeenAt ? ` · ${formatDate(version.lastSeenAt)}` : ""; + return `<div class="cell-secondary">${escapeHtml(version.clientPlatform || "unknown")} · ${formatVersion(version.clientVersionCode, version.clientVersionName)}${countLabel}${seenLabel}</div>`; + }).join(""); + + return ` + <div class="cell-primary">${formatVersion(user.clientVersionCode, user.clientVersionName)}</div> + <div class="cell-secondary">${platform}</div> + ${details} + `; +} + +function renderChats() { + const typeFilter = elements.chatTypeFilter.value; + const attentionFilter = elements.chatAttentionFilter.value; + const rows = state.chats.filter((chat) => { + if (typeFilter !== "all" && chat.type !== typeFilter) { + return false; + } + if (attentionFilter === "pending") { + return chat.pendingJoinRequests > 0; + } + if (attentionFilter === "protected") { + return chat.hasProtectedContent || chat.protectedMessages > 0; + } + if (attentionFilter === "topics") { + return chat.topicsEnabled || chat.topics > 0; + } + return true; + }); + + elements.chatsCount.textContent = `${rows.length} строк`; + elements.chatsTable.innerHTML = rows.length === 0 + ? emptyRow("Чаты по текущему фильтру не найдены.", 8) + : rows.map((chat) => ` + <tr data-chat-id="${escapeAttribute(chat.id)}" class="${chat.id === state.selectedChatId ? "selected" : ""}"> + <td> + <div class="cell-primary">${escapeHtml(chat.title)}</div> + <div class="cell-secondary">${chat.publicUsername ? `@${escapeHtml(chat.publicUsername)}` : "без public username"}</div> + </td> + <td>${formatChatType(chat.type)}</td> + <td>${escapeHtml(chat.createdBy || "—")}</td> + <td>${chat.lastActivityAt ? formatDate(chat.lastActivityAt) : formatDate(chat.createdAt)}</td> + <td>${chat.members}</td> + <td> + <div>${chat.messages}</div> + <div class="cell-secondary">${chat.pinnedMessages} pinned · ${chat.protectedMessages} protected</div> + </td> + <td> + <div class="action-row"> + ${chat.hasProtectedContent ? badge("Protected", "ok") : badge("Обычный", "warn")} + ${chat.pendingJoinRequests > 0 ? badge(`${chat.pendingJoinRequests} заявок`, "warn") : ""} + ${chat.topicsEnabled ? badge(`${chat.topics} топиков`, "ok") : ""} + </div> + </td> + <td> + <div class="action-row"> + <button class="button small" type="button" data-action="open-chat" data-chat-id="${escapeAttribute(chat.id)}">Инспектор</button> + <button class="button small danger" type="button" data-action="delete-chat" data-chat-id="${escapeAttribute(chat.id)}" data-chat-title="${escapeAttribute(chat.title)}">Удалить</button> + </div> + </td> + </tr> + `).join(""); +} + +function renderChatInspector() { + const details = state.selectedChatDetails; + if (!details) { + elements.chatInspector.innerHTML = ` + <div class="empty-state"> + <p class="eyebrow">Инспектор</p> + <h2>Выберите чат</h2> + <p class="muted">Здесь появятся настройки protected mode, TTL, public username, участники, права, invite links и заявки на вход.</p> + </div> + `; + return; + } + + const chat = details.chat; + elements.chatInspector.innerHTML = ` + <div class="inspector-block"> + <p class="eyebrow">${formatChatType(chat.type)}</p> + <h2>${escapeHtml(chat.title)}</h2> + <p class="muted">${chat.members} участников · ${chat.messages} сообщений · ${chat.attachments} вложений</p> + </div> + + <div class="inspector-block"> + <h3>Настройки чата</h3> + <div class="inspector-grid"> + <label class="field"> + <span>Public username</span> + <input id="chatPublicUsername" value="${escapeAttribute(chat.publicUsername || "")}" placeholder="channel_name"> + </label> + <label class="field"> + <span>TTL по умолчанию, сек.</span> + <input id="chatTtlSeconds" type="number" min="0" value="${chat.defaultMessageTtlSeconds ?? ""}" placeholder="0 = выключено"> + </label> + <label class="check-line"> + <input id="chatTopicsEnabled" type="checkbox" ${chat.topicsEnabled ? "checked" : ""}> + <span>Топики включены</span> + </label> + <label class="check-line"> + <input id="chatProtected" type="checkbox" ${chat.hasProtectedContent ? "checked" : ""}> + <span>Protected content</span> + </label> + </div> + <div class="action-row" style="margin-top: 12px"> + <button class="button secondary" type="button" data-action="save-chat-settings" data-chat-id="${escapeAttribute(chat.id)}">Сохранить настройки</button> + </div> + </div> + + <div class="inspector-block"> + <h3>Участники и права</h3> + <div class="permissions-grid"> + ${details.members.length === 0 ? `<div class="muted">Участников нет.</div>` : details.members.map(renderMemberPermissions).join("")} + </div> + </div> + + <div class="inspector-block"> + <h3>Invite links</h3> + <div class="inspector-grid"> + <label class="field"> + <span>Название ссылки</span> + <input id="newInviteTitle" placeholder="Например: Публичная ссылка"> + </label> + <label class="check-line"> + <input id="newInviteRequiresApproval" type="checkbox"> + <span>Требовать approval</span> + </label> + </div> + <div class="action-row" style="margin-top: 12px"> + <button class="button secondary" type="button" data-action="create-invite" data-chat-id="${escapeAttribute(chat.id)}">Создать ссылку</button> + </div> + <div class="mini-list" style="margin-top: 12px"> + ${details.inviteLinks.length === 0 ? `<div class="muted">Ссылок пока нет.</div>` : details.inviteLinks.map((link) => ` + <div class="mini-row"> + <div class="cell-primary">${escapeHtml(link.title || "Без названия")}</div> + <div class="cell-secondary mono">${escapeHtml(link.link)}</div> + <div class="action-row" style="margin-top: 8px"> + ${badge(link.createsJoinRequest ? "approval" : "direct join", link.createsJoinRequest ? "warn" : "ok")} + ${badge(link.isRevoked ? "отозвана" : "активна", link.isRevoked ? "danger" : "ok")} + ${link.isRevoked ? "" : `<button class="button small danger" type="button" data-action="revoke-invite" data-chat-id="${escapeAttribute(chat.id)}" data-invite-id="${escapeAttribute(link.id)}">Отозвать</button>`} + </div> + </div> + `).join("")} + </div> + </div> + + <div class="inspector-block"> + <h3>Заявки на вход</h3> + <div class="mini-list"> + ${details.joinRequests.length === 0 ? `<div class="muted">Заявок нет.</div>` : details.joinRequests.map((request) => ` + <div class="mini-row"> + <div class="cell-primary">${escapeHtml(displayName(request.displayName, request.username))}</div> + <div class="cell-secondary">${formatDate(request.requestedAt)} · ${formatJoinStatus(request.status)}</div> + ${isPendingJoinRequest(request.status) ? ` + <div class="action-row" style="margin-top: 8px"> + <button class="button small secondary" type="button" data-action="resolve-join" data-approve="true" data-chat-id="${escapeAttribute(chat.id)}" data-request-id="${escapeAttribute(request.id)}">Одобрить</button> + <button class="button small danger" type="button" data-action="resolve-join" data-approve="false" data-chat-id="${escapeAttribute(chat.id)}" data-request-id="${escapeAttribute(request.id)}">Отклонить</button> + </div> + ` : ""} + </div> + `).join("")} + </div> + </div> + + <div class="inspector-block"> + <h3>Топики</h3> + <div class="mini-list"> + ${details.topics.length === 0 ? `<div class="muted">Топиков нет.</div>` : details.topics.map((topic) => ` + <div class="mini-row"> + <div class="cell-primary">${escapeHtml(topic.icon || "")} ${escapeHtml(topic.title)}</div> + <div class="cell-secondary">${topic.messageCount} сообщений · ${topic.isPinned ? "закреплен" : "обычный"} · ${topic.isClosed ? "закрыт" : "открыт"}</div> + </div> + `).join("")} + </div> + </div> + + <div class="inspector-block"> + <h3>Последние сообщения</h3> + <div class="mini-list"> + ${details.recentMessages.length === 0 ? `<div class="muted">Сообщений нет.</div>` : details.recentMessages.map((message) => ` + <div class="mini-row"> + <div class="cell-primary">${escapeHtml(message.sender)} · ${formatDate(message.sentAt)}</div> + <div class="cell-secondary">${message.deletedAt ? "Сообщение удалено" : escapeHtml(shorten(message.text || "медиа-сообщение", 160))}</div> + <div class="cell-secondary">${message.attachments} вложений · ${message.reactions} реакций ${message.hasProtectedContent ? "· protected" : ""}</div> + </div> + `).join("")} + </div> + </div> + + <div class="inspector-block"> + <h3>Модерация чата</h3> + <div class="mini-list"> + ${details.moderationLog.length === 0 ? `<div class="muted">Событий нет.</div>` : details.moderationLog.map((event) => ` + <div class="mini-row"> + <div class="cell-primary">${escapeHtml(event.action)}</div> + <div class="cell-secondary">${formatDate(event.createdAt)} · ${escapeHtml(event.actorDisplayName || "—")}</div> + </div> + `).join("")} + </div> + </div> + `; +} + +function renderMemberPermissions(member) { + const role = normalizeRole(member.role); + return ` + <div class="permission-row" data-member-id="${escapeAttribute(member.userId)}"> + <div class="cell-primary">${escapeHtml(displayName(member.displayName, member.username))}</div> + <div class="cell-secondary">@${escapeHtml(member.username)}${member.isBot ? " · бот" : ""}</div> + <div class="permission-controls"> + <select data-field="role"> + <option value="0" ${role === "Member" ? "selected" : ""}>Member</option> + <option value="1" ${role === "Admin" ? "selected" : ""}>Admin</option> + <option value="2" ${role === "Owner" ? "selected" : ""}>Owner</option> + </select> + ${permissionCheckbox("pin", "Pin", member.canPinMessages)} + ${permissionCheckbox("delete", "Delete", member.canDeleteMessages)} + ${permissionCheckbox("invite", "Invite", member.canInviteUsers)} + ${permissionCheckbox("post", "Post", member.canPostMessages)} + <button class="button small" type="button" data-action="save-member-permissions" data-chat-id="${escapeAttribute(state.selectedChatId)}" data-member-id="${escapeAttribute(member.userId)}">Сохранить</button> + </div> + </div> + `; +} + +function renderAttachments() { + elements.attachmentsCount.textContent = `${state.attachments.length} строк`; + elements.attachmentsTable.innerHTML = state.attachments.length === 0 + ? emptyRow("Вложения по текущему фильтру не найдены.", 7) + : state.attachments.map((attachment) => ` + <tr> + <td> + <div class="cell-primary">${escapeHtml(attachment.fileName)}</div> + <div class="cell-secondary">${escapeHtml(attachment.contentType || "application/octet-stream")} · sort ${attachment.sortOrder}</div> + ${attachment.mediaAlbumId ? `<div class="cell-secondary mono">album ${escapeHtml(attachment.mediaAlbumId)}</div>` : ""} + </td> + <td>${escapeHtml(attachment.chatTitle)}</td> + <td>${escapeHtml(attachment.uploadedBy)}</td> + <td>${formatDate(attachment.uploadedAt)}</td> + <td>${formatBytes(attachment.fileSizeBytes)}</td> + <td> + <div class="action-row"> + ${badge(attachment.exists ? "файл есть" : "нет файла", attachment.exists ? "ok" : "danger")} + ${attachment.isProtected ? badge("protected", "ok") : ""} + </div> + </td> + <td> + <div class="action-row"> + ${attachment.exists ? `<a class="table-link" href="${escapeAttribute(attachment.downloadPath)}" target="_blank" rel="noopener">Скачать</a>` : ""} + <button class="button small danger" type="button" data-action="delete-attachment" data-attachment-id="${escapeAttribute(attachment.id)}" data-file-name="${escapeAttribute(attachment.fileName)}">Удалить</button> + </div> + </td> + </tr> + `).join(""); +} + +function renderModeration() { + elements.moderationCount.textContent = `${state.moderation.length} событий`; + elements.moderationTable.innerHTML = state.moderation.length === 0 + ? emptyRow("Журнал модерации пуст.", 6) + : state.moderation.map((event) => ` + <tr> + <td>${formatDate(event.createdAt)}</td> + <td>${escapeHtml(event.actorDisplayName || "—")}</td> + <td> + <div class="cell-primary">${formatModerationTarget(event.targetType)}</div> + <div class="cell-secondary mono">${escapeHtml(event.targetId || "—")}</div> + </td> + <td>${escapeHtml(event.action)}</td> + <td>${escapeHtml(event.reason || "—")}</td> + <td class="mono">${escapeHtml(event.chatId || "—")}</td> + </tr> + `).join(""); +} + +function renderCompatibility() { + const report = state.compatibility; + if (!report) { + return; + } + + elements.compatibilityCount.textContent = `${report.recentHits.length} последних записей`; + elements.compatibilitySummary.innerHTML = [ + metric("Хиты за 24 часа", report.totalHitsLast24Hours, `${report.hitsWithoutVersionLast24Hours} без версии`), + metric("Хиты за 7 дней", report.totalHitsLast7Days, `${report.hitsWithoutVersionLast7Days} без версии`), + metric("Последний запрос", report.latestHitAt ? formatDate(report.latestHitAt) : "нет данных", "мониторинг обновлений"), + ].join(""); + + elements.updateRiskList.innerHTML = [ + focus(report.hitsWithoutVersionLast24Hours > 0 ? "warn" : "ok", "Без versionCode за 24 часа", `${report.hitsWithoutVersionLast24Hours} обращений.`), + focus(report.hitsWithoutVersionLast7Days > 0 ? "warn" : "ok", "Без versionCode за 7 дней", `${report.hitsWithoutVersionLast7Days} обращений.`), + focus(report.totalHitsLast7Days > 0 ? "ok" : "warn", "Сигнал обновлений", `${report.totalHitsLast7Days} обращений за неделю.`), + ].join(""); + + elements.compatibilityRoutesTable.innerHTML = report.routesLast7Days.length === 0 + ? emptyRow("Данных по маршрутам пока нет.", 4) + : report.routesLast7Days.map((route) => ` + <tr> + <td>${escapeHtml(route.route)}</td> + <td>${route.hitsLast24Hours}</td> + <td>${route.hitsLast7Days}</td> + <td>${route.latestHitAt ? formatDate(route.latestHitAt) : "—"}</td> + </tr> + `).join(""); + + elements.compatibilityVersionsTable.innerHTML = report.versionsLast7Days.length === 0 + ? emptyRow("Данных по версиям пока нет.", 6) + : report.versionsLast7Days.map((version) => ` + <tr> + <td>${escapeHtml(version.clientPlatform || "unknown")}</td> + <td>${version.clientVersionCode ?? "—"}</td> + <td>${escapeHtml(version.clientVersionName || "—")}</td> + <td>${version.hitsLast24Hours}</td> + <td>${version.hitsLast7Days}</td> + <td>${version.latestHitAt ? formatDate(version.latestHitAt) : "—"}</td> + </tr> + `).join(""); + + elements.compatibilityHitsTable.innerHTML = report.recentHits.length === 0 + ? emptyRow("Последних обращений нет.", 6) + : report.recentHits.map((hit) => ` + <tr> + <td>${formatDate(hit.hitAt)}</td> + <td>${escapeHtml(hit.route)}</td> + <td>${escapeHtml(hit.requestPath)}</td> + <td>${escapeHtml(hit.clientPlatform || "—")}</td> + <td>${formatVersion(hit.clientVersionCode, hit.clientVersionName)}</td> + <td>${escapeHtml(hit.remoteIp || "—")}</td> + </tr> + `).join(""); +} + +async function handleDocumentClick(event) { + const nav = event.target.closest("[data-section]"); + if (nav) { + setSection(nav.dataset.section); + return; + } + + const jump = event.target.closest("[data-section-jump]"); + if (jump) { + setSection(jump.dataset.sectionJump); + return; + } + + const chatRow = event.target.closest("tr[data-chat-id]"); + if (chatRow && !event.target.closest("button,a,input,select")) { + await loadChatDetails(chatRow.dataset.chatId); + return; + } + + const button = event.target.closest("[data-action]"); + if (!button) { + return; + } + + const action = button.dataset.action; + try { + if (action === "refresh") { + await refreshAll(); + showToast("Данные обновлены."); + return; + } + + if (action === "open-chat") { + await loadChatDetails(button.dataset.chatId); + return; + } + + if (action === "block-user" || action === "unblock-user") { + await fetchJson(`/admin/api/users/${button.dataset.userId}/${action === "block-user" ? "block" : "unblock"}`, { method: "POST" }); + showToast(action === "block-user" ? "Пользователь заблокирован." : "Пользователь разблокирован."); + await refreshAll(); + return; + } + + if (action === "delete-user") { + if (!window.confirm(`Удалить пользователя @${button.dataset.userName}? Будут затронуты его сообщения, сессии, push-устройства и личные чаты.`)) { + return; + } + await fetchJson(`/admin/api/users/${button.dataset.userId}`, { method: "DELETE" }); + showToast("Пользователь удален."); + await refreshAll(); + return; + } + + if (action === "delete-chat") { + if (!window.confirm(`Удалить чат «${button.dataset.chatTitle}» вместе с сообщениями и вложениями?`)) { + return; + } + await fetchJson(`/admin/api/chats/${button.dataset.chatId}`, { method: "DELETE" }); + if (state.selectedChatId === button.dataset.chatId) { + state.selectedChatId = null; + state.selectedChatDetails = null; + } + showToast("Чат удален."); + await refreshAll(); + return; + } + + if (action === "delete-attachment") { + if (!window.confirm(`Удалить вложение «${button.dataset.fileName}» из базы и файлового хранилища?`)) { + return; + } + await fetchJson(`/admin/api/attachments/${button.dataset.attachmentId}`, { method: "DELETE" }); + showToast("Вложение удалено."); + await refreshAll(); + return; + } + + if (action === "save-chat-settings") { + const ttlRaw = document.getElementById("chatTtlSeconds").value.trim(); + state.selectedChatDetails = await fetchJson(`/admin/api/chats/${button.dataset.chatId}/settings`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + publicUsername: document.getElementById("chatPublicUsername").value.trim() || null, + topicsEnabled: document.getElementById("chatTopicsEnabled").checked, + hasProtectedContent: document.getElementById("chatProtected").checked, + defaultMessageTtlSeconds: ttlRaw ? Number(ttlRaw) : null, + }), + }); + showToast("Настройки чата сохранены."); + await refreshAll(); + return; + } + + if (action === "save-member-permissions") { + const row = button.closest(".permission-row"); + state.selectedChatDetails = await fetchJson(`/admin/api/chats/${button.dataset.chatId}/members/${button.dataset.memberId}/permissions`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + role: Number(row.querySelector('[data-field="role"]').value), + canPinMessages: row.querySelector('[data-field="pin"]').checked, + canDeleteMessages: row.querySelector('[data-field="delete"]').checked, + canInviteUsers: row.querySelector('[data-field="invite"]').checked, + canPostMessages: row.querySelector('[data-field="post"]').checked, + }), + }); + showToast("Права участника обновлены."); + await refreshAll(); + return; + } + + if (action === "create-invite") { + state.selectedChatDetails = await fetchJson(`/admin/api/chats/${button.dataset.chatId}/invite-links`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + title: document.getElementById("newInviteTitle").value.trim() || null, + createsJoinRequest: document.getElementById("newInviteRequiresApproval").checked, + }), + }); + showToast("Invite link создан."); + await refreshAll(); + return; + } + + if (action === "revoke-invite") { + state.selectedChatDetails = await fetchJson(`/admin/api/chats/${button.dataset.chatId}/invite-links/${button.dataset.inviteId}`, { + method: "DELETE", + }); + showToast("Invite link отозван."); + await refreshAll(); + return; + } + + if (action === "resolve-join") { + state.selectedChatDetails = await fetchJson(`/admin/api/chats/${button.dataset.chatId}/join-requests/${button.dataset.requestId}`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ approve: button.dataset.approve === "true" }), + }); + showToast(button.dataset.approve === "true" ? "Заявка одобрена." : "Заявка отклонена."); + await refreshAll(); + return; + } + + if (action === "cleanup-expired") { + if (!window.confirm("Очистить все истекшие TTL-сообщения на сервере?")) { + return; + } + const result = await fetchJson("/admin/api/messages/cleanup-expired", { method: "POST" }); + elements.operationResult.textContent = `Удалено сообщений: ${result.deletedMessages}; вложений: ${result.deletedAttachments}.`; + showToast("Очистка TTL-сообщений выполнена."); + await refreshAll(); + } + } catch (error) { + handleError(error); + } +} + +async function fetchJson(url, options = {}, useAuth = true) { + const response = await fetch(url, { + credentials: "same-origin", + headers: { + Accept: "application/json", + ...(options.headers || {}), + }, + ...options, + }); + + if (response.status === 401 && useAuth) { + state.session = { configured: true, authenticated: false, username: null }; + renderSession(); + showLogin("Сессия администратора истекла."); + throw new Error("Сессия администратора истекла."); + } + + if (!response.ok) { + let message = `HTTP ${response.status}`; + try { + message = await response.text(); + } catch { + // Тело ошибки не всегда доступно. + } + throw new Error(message || `HTTP ${response.status}`); + } + + if (response.status === 204) { + return null; + } + + const contentType = response.headers.get("content-type") || ""; + return contentType.includes("application/json") ? response.json() : response.text(); +} + +function handleError(error) { + const message = error instanceof Error ? error.message : String(error); + if (!elements.appView.classList.contains("hidden")) { + elements.noticeBar.textContent = message; + elements.noticeBar.classList.remove("hidden"); + showToast(message); + window.setTimeout(() => elements.noticeBar.classList.add("hidden"), 6000); + return; + } + + showLogin(message); +} + +function showToast(message) { + elements.toast.textContent = message; + elements.toast.classList.remove("hidden"); + window.clearTimeout(showToast.timeoutId); + showToast.timeoutId = window.setTimeout(() => elements.toast.classList.add("hidden"), 3600); +} + +function metric(label, value, note) { + return ` + <article class="metric"> + <div class="metric-label">${escapeHtml(label)}</div> + <div class="metric-value">${escapeHtml(String(value ?? "—"))}</div> + <div class="metric-note">${escapeHtml(note || "")}</div> + </article> + `; +} + +function focus(level, title, text) { + return ` + <div class="focus-item ${level}"> + <div class="cell-primary">${escapeHtml(title)}</div> + <div class="cell-secondary">${escapeHtml(text)}</div> + </div> + `; +} + +function feature(title, value, enabled) { + return ` + <div class="feature-row"> + <span>${escapeHtml(title)}</span> + <strong>${escapeHtml(value)}</strong> + ${badge(enabled ? "активно" : "нет данных", enabled ? "ok" : "warn")} + </div> + `; +} + +function badge(text, tone = "neutral") { + return `<span class="rect-badge ${tone}">${escapeHtml(text)}</span>`; +} + +function permissionCheckbox(field, label, checked) { + return ` + <label class="check-line"> + <input type="checkbox" data-field="${field}" ${checked ? "checked" : ""}> + <span>${escapeHtml(label)}</span> + </label> + `; +} + +function emptyRow(text, colspan) { + return `<tr><td colspan="${colspan}" class="cell-secondary">${escapeHtml(text)}</td></tr>`; +} + +function displayName(displayNameValue, username) { + return displayNameValue && displayNameValue.trim() ? displayNameValue : `@${username}`; +} + +function formatDate(value) { + if (!value) { + return "—"; + } + return new Date(value).toLocaleString("ru-RU"); +} + +function formatDuration(startValue, endValue) { + if (!startValue || !endValue) { + return "—"; + } + + const hours = Math.max(0, Math.round((new Date(endValue) - new Date(startValue)) / 3600000)); + if (hours < 24) { + return `${hours} ч`; + } + + const days = Math.floor(hours / 24); + return `${days} д ${hours % 24} ч`; +} + +function formatBytes(value) { + const number = Number(value || 0); + const units = ["B", "KB", "MB", "GB", "TB"]; + let size = number; + let index = 0; + while (size >= 1024 && index < units.length - 1) { + size /= 1024; + index += 1; + } + return `${size.toFixed(size >= 10 || index === 0 ? 0 : 1)} ${units[index]}`; +} + +function formatVersion(versionCode, versionName) { + if (versionCode == null && !versionName) { + return "—"; + } + if (versionCode == null) { + return escapeHtml(versionName); + } + return escapeHtml(`${versionName || "без имени"} / ${versionCode}`); +} + +function formatChatType(type) { + if (type === "Direct" || type === 0) { + return "Личный"; + } + if (type === "Group" || type === 1) { + return "Группа"; + } + if (type === "Channel" || type === 2) { + return "Канал"; + } + return escapeHtml(String(type || "—")); +} + +function normalizeRole(role) { + if (role === "Owner" || role === 2) { + return "Owner"; + } + if (role === "Admin" || role === 1) { + return "Admin"; + } + return "Member"; +} + +function formatJoinStatus(status) { + if (status === "Approved" || status === 1) { + return "одобрена"; + } + if (status === "Declined" || status === 2) { + return "отклонена"; + } + return "ожидает"; +} + +function isPendingJoinRequest(status) { + return status === "Pending" || status === 0; +} + +function formatModerationTarget(target) { + const map = { + 0: "Чат", + 1: "Сообщение", + 2: "Пользователь", + 3: "Invite link", + 4: "Join request", + 5: "Story", + Chat: "Чат", + Message: "Сообщение", + User: "Пользователь", + InviteLink: "Invite link", + JoinRequest: "Join request", + Story: "Story", + }; + return map[target] || String(target || "—"); +} + +function shorten(value, max) { + const text = String(value || ""); + return text.length <= max ? text : `${text.slice(0, max - 1)}…`; +} + +function escapeHtml(value) { + return String(value) + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """) + .replaceAll("'", "'"); +} + +function escapeAttribute(value) { + return escapeHtml(value); +} diff --git a/src/Ikar.Server/wwwroot/admin/index.html b/src/Ikar.Server/wwwroot/admin/index.html new file mode 100644 index 00000000..b4fc34c9 --- /dev/null +++ b/src/Ikar.Server/wwwroot/admin/index.html @@ -0,0 +1,406 @@ +<!doctype html> +<html lang="ru"> +<head> + <meta charset="utf-8"> + <meta name="viewport" content="width=device-width, initial-scale=1"> + <title>Икар — административная консоль + + + + + + + + + + + + diff --git a/src/Ikar.Server/wwwroot/index.html b/src/Ikar.Server/wwwroot/index.html new file mode 100644 index 00000000..d997e944 --- /dev/null +++ b/src/Ikar.Server/wwwroot/index.html @@ -0,0 +1,59 @@ + + + + + + Икар + + + + + +
+
+
+ + +

Икар

+

Скачайте последнюю версию клиента для Android

+

+ Прямая загрузка актуального APK с сервера Икар. +

+ +
+

Текущий релиз

+

Загрузка…

+

Получаем информацию о версии

+

+
+ + +
+ + +
+
+ + + + diff --git a/src/Ikar.Server/wwwroot/logo.png b/src/Ikar.Server/wwwroot/logo.png new file mode 100644 index 00000000..c386a287 Binary files /dev/null and b/src/Ikar.Server/wwwroot/logo.png differ diff --git a/src/Ikar.Server/wwwroot/logo_bg.png b/src/Ikar.Server/wwwroot/logo_bg.png new file mode 100644 index 00000000..9832247a Binary files /dev/null and b/src/Ikar.Server/wwwroot/logo_bg.png differ diff --git a/src/Ikar.Server/wwwroot/site.css b/src/Ikar.Server/wwwroot/site.css new file mode 100644 index 00000000..02b34805 --- /dev/null +++ b/src/Ikar.Server/wwwroot/site.css @@ -0,0 +1,236 @@ +:root { + color-scheme: light; + --bg: #eef4fb; + --surface: rgba(255, 255, 255, 0.82); + --surface-strong: #ffffff; + --text: #17243b; + --muted: #5f6f89; + --line: rgba(23, 36, 59, 0.1); + --accent: #2492ff; + --accent-deep: #1275d6; + --shadow: 0 24px 64px rgba(33, 68, 118, 0.14); + --radius-xl: 32px; + --radius-lg: 22px; + --radius-md: 16px; + --max-width: 1120px; + font-family: "Segoe UI", Arial, sans-serif; +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + min-height: 100vh; + color: var(--text); + background: + radial-gradient(circle at top left, rgba(36, 146, 255, 0.18), transparent 34%), + radial-gradient(circle at bottom right, rgba(18, 117, 214, 0.14), transparent 28%), + linear-gradient(180deg, #f7fbff 0%, var(--bg) 100%); +} + +.page { + width: min(calc(100% - 32px), var(--max-width)); + margin: 0 auto; + padding: 32px 0; +} + +.hero { + display: grid; + grid-template-columns: minmax(0, 1.35fr) minmax(280px, 0.8fr); + gap: 28px; + align-items: stretch; + min-height: calc(100vh - 64px); +} + +.hero-copy, +.hero-panel { + border: 1px solid var(--line); + border-radius: var(--radius-xl); + box-shadow: var(--shadow); + backdrop-filter: blur(18px); +} + +.hero-copy { + padding: clamp(28px, 4vw, 56px); + background: linear-gradient(180deg, rgba(255,255,255,0.92) 0%, rgba(255,255,255,0.84) 100%); + display: flex; + flex-direction: column; + justify-content: center; +} + +.brand-hero-logo { + display: block; + width: min(100%, 520px); + margin: 0 0 18px; + filter: drop-shadow(0 18px 32px rgba(42, 88, 149, 0.12)); +} + +.hero-panel { + background: + linear-gradient(180deg, rgba(36, 146, 255, 0.96) 0%, rgba(18, 117, 214, 0.96) 100%); + color: #fff; + padding: clamp(24px, 3vw, 36px); + display: flex; + flex-direction: column; + justify-content: flex-end; + position: relative; + overflow: hidden; +} + +.hero-panel::before, +.hero-panel::after { + content: ""; + position: absolute; + border-radius: 999px; + background: rgba(255, 255, 255, 0.14); +} + +.hero-panel::before { + width: 220px; + height: 220px; + top: -72px; + right: -44px; +} + +.hero-panel::after { + width: 120px; + height: 120px; + bottom: 76px; + left: -34px; +} + +.eyebrow, +.panel-kicker, +.release-label { + margin: 0; + font-size: 0.82rem; + letter-spacing: 0.18em; + text-transform: uppercase; +} + +.eyebrow { + color: var(--accent-deep); + font-weight: 700; +} + +h1 { + margin: 18px 0 14px; + max-width: 11ch; + font-size: clamp(2.6rem, 5vw, 4.8rem); + line-height: 0.94; + letter-spacing: -0.04em; +} + +.lead { + max-width: 36rem; + margin: 0; + color: var(--muted); + font-size: 1.08rem; + line-height: 1.7; +} + +.release-card { + margin-top: 28px; + padding: 22px 24px; + border-radius: var(--radius-lg); + background: var(--surface-strong); + border: 1px solid rgba(36, 146, 255, 0.16); +} + +.release-version { + margin: 10px 0 8px; + font-size: clamp(1.6rem, 2vw, 2rem); + font-weight: 700; + letter-spacing: -0.03em; +} + +.release-meta, +.release-notes { + margin: 0; + color: var(--muted); + line-height: 1.55; +} + +.release-notes:not(:empty) { + margin-top: 10px; +} + +.actions { + margin-top: 28px; + display: flex; + gap: 14px; + flex-wrap: wrap; +} + +.button { + appearance: none; + border: 0; + border-radius: 999px; + padding: 15px 24px; + text-decoration: none; + font-weight: 700; + transition: transform 180ms ease, background-color 180ms ease, box-shadow 180ms ease; +} + +.button:hover { + transform: translateY(-1px); +} + +.button-primary { + background: var(--accent); + color: #fff; + box-shadow: 0 16px 40px rgba(36, 146, 255, 0.28); +} + +.button-primary:hover { + background: var(--accent-deep); +} + +.button-secondary { + background: rgba(23, 36, 59, 0.06); + color: var(--text); +} + +.panel-block { + position: relative; + z-index: 1; + padding: 18px 0; + border-top: 1px solid rgba(255, 255, 255, 0.18); +} + +.panel-block:first-child { + border-top: 0; +} + +.panel-block strong { + display: block; + margin-top: 8px; + font-size: 1.12rem; + line-height: 1.4; +} + +.inline-link { + color: #fff; +} + +@media (max-width: 900px) { + .page { + width: min(calc(100% - 24px), var(--max-width)); + padding: 12px 0 24px; + } + + .hero { + grid-template-columns: 1fr; + min-height: auto; + } + + .hero-panel { + min-height: 280px; + } + + h1 { + max-width: none; + } +} diff --git a/src/Ikar.Server/wwwroot/site.js b/src/Ikar.Server/wwwroot/site.js new file mode 100644 index 00000000..252a23c8 --- /dev/null +++ b/src/Ikar.Server/wwwroot/site.js @@ -0,0 +1,46 @@ +(function () { + const downloadLink = document.getElementById("download-link"); + const releaseVersion = document.getElementById("release-version"); + const releaseMeta = document.getElementById("release-meta"); + const releaseNotes = document.getElementById("release-notes"); + + const fallbackText = "Последняя версия APK пока не опубликована"; + + async function loadRelease() { + try { + const response = await fetch("/api/app-updates/android/latest", { + headers: { + "Accept": "application/json" + } + }); + + if (!response.ok) { + throw new Error(`HTTP ${response.status}`); + } + + const release = await response.json(); + const publishedAt = new Date(release.publishedAt); + const publishedLabel = Number.isNaN(publishedAt.getTime()) + ? "Дата публикации не указана" + : publishedAt.toLocaleString("ru-RU", { + year: "numeric", + month: "long", + day: "numeric", + hour: "2-digit", + minute: "2-digit" + }); + + releaseVersion.textContent = `Версия ${release.versionName}`; + releaseMeta.textContent = `Build ${release.versionCode} • опубликовано ${publishedLabel}`; + releaseNotes.textContent = release.notes ?? ""; + downloadLink.href = release.downloadPath; + downloadLink.removeAttribute("aria-disabled"); + } catch (error) { + releaseVersion.textContent = "APK недоступен"; + releaseMeta.textContent = fallbackText; + releaseNotes.textContent = ""; + } + } + + loadRelease(); +})(); diff --git a/src/Ikar.Shared/AppUpdateContracts.cs b/src/Ikar.Shared/AppUpdateContracts.cs new file mode 100644 index 00000000..d6103c2f --- /dev/null +++ b/src/Ikar.Shared/AppUpdateContracts.cs @@ -0,0 +1,10 @@ +namespace Ikar.Shared; + +public sealed record AndroidAppReleaseDto( + int VersionCode, + string VersionName, + string DownloadPath, + DateTimeOffset PublishedAt, + string? Notes, + bool IsRequired = false, + int? MinimumSupportedVersionCode = null); diff --git a/src/Ikar.Shared/AttachmentKind.cs b/src/Ikar.Shared/AttachmentKind.cs new file mode 100644 index 00000000..a7e25743 --- /dev/null +++ b/src/Ikar.Shared/AttachmentKind.cs @@ -0,0 +1,7 @@ +namespace Ikar.Shared; + +public enum AttachmentKind +{ + File = 1, + VoiceNote = 2 +} diff --git a/src/Ikar.Shared/AuthContracts.cs b/src/Ikar.Shared/AuthContracts.cs new file mode 100644 index 00000000..fdbe7710 --- /dev/null +++ b/src/Ikar.Shared/AuthContracts.cs @@ -0,0 +1,13 @@ +namespace Ikar.Shared; + +public sealed record RegisterRequest(string Username, string DisplayName, string Password, string PhoneNumber); + +public sealed record LoginRequest(string Username, string Password); + +public sealed record RefreshSessionRequest(string RefreshToken); + +public sealed record AuthSessionDto( + string AccessToken, + string RefreshToken, + DateTimeOffset AccessTokenExpiresAt, + UserSummaryDto User); diff --git a/src/Ikar.Shared/BotContracts.cs b/src/Ikar.Shared/BotContracts.cs new file mode 100644 index 00000000..9a435740 --- /dev/null +++ b/src/Ikar.Shared/BotContracts.cs @@ -0,0 +1,51 @@ +namespace Ikar.Shared; + +public sealed record BotSummaryDto( + Guid Id, + Guid UserId, + string Username, + string DisplayName, + string? About, + bool IsEnabled, + DateTimeOffset CreatedAt, + IReadOnlyList Commands); + +public sealed record CreateBotRequest( + string Username, + string DisplayName, + string? About); + +public sealed record UpdateBotRequest( + string DisplayName, + string? About, + bool IsEnabled); + +public sealed record CreatedBotDto( + BotSummaryDto Bot, + string AccessToken); + +public sealed record RegenerateBotTokenResponseDto(string AccessToken); + +public sealed record BotCommandDto( + string Command, + string Description); + +public sealed record SetBotCommandsRequest(IReadOnlyList Commands); + +public enum BotUpdateKind +{ + Message = 0 +} + +public sealed record BotUpdateDto( + long Id, + BotUpdateKind Kind, + MessageDto? Message); + +public sealed record BotUpdatesResponseDto( + long NextOffset, + IReadOnlyList Updates); + +public sealed record BotSendMessageRequest( + Guid ChatId, + string Text); diff --git a/src/Ikar.Shared/CallContracts.cs b/src/Ikar.Shared/CallContracts.cs new file mode 100644 index 00000000..4ea1dd9c --- /dev/null +++ b/src/Ikar.Shared/CallContracts.cs @@ -0,0 +1,54 @@ +namespace Ikar.Shared; + +public enum CallMediaType +{ + Audio = 0, + Video = 1 +} + +public sealed record OutgoingCallDto( + Guid CallId, + Guid ChatId, + UserSummaryDto Peer, + CallMediaType MediaType); + +public sealed record IncomingCallDto( + Guid CallId, + Guid ChatId, + UserSummaryDto Caller, + CallMediaType MediaType); + +public sealed record CallConnectedDto( + Guid CallId, + Guid ChatId, + DateTimeOffset ConnectedAt, + CallMediaType MediaType, + IReadOnlyList IceServers, + bool ShouldCreateOffer); + +public sealed record CallEndedDto( + Guid CallId, + Guid ChatId, + string Reason, + CallMediaType MediaType); + +public sealed record CallIceServerDto( + IReadOnlyList Urls, + string? Username = null, + string? Credential = null); + +public sealed record CallOfferDto( + Guid CallId, + string Type, + string Sdp); + +public sealed record CallAnswerDto( + Guid CallId, + string Type, + string Sdp); + +public sealed record CallIceCandidateDto( + Guid CallId, + string Candidate, + string? SdpMid, + int SdpMLineIndex); diff --git a/src/Ikar.Shared/ChatContracts.cs b/src/Ikar.Shared/ChatContracts.cs new file mode 100644 index 00000000..e365bb44 --- /dev/null +++ b/src/Ikar.Shared/ChatContracts.cs @@ -0,0 +1,218 @@ +namespace Ikar.Shared; + +public enum ChatMemberRole +{ + Member = 0, + Admin = 1, + Owner = 2 +} + +public sealed record ChatPermissionsDto( + bool CanPostMessages, + bool CanPinMessages, + bool CanDeleteMessages, + bool CanInviteUsers, + bool CanManageMembers); + +public sealed record ChatMemberDto( + UserSummaryDto User, + ChatMemberRole Role, + bool IsOwner, + ChatPermissionsDto Permissions); + +public sealed record ChatSummaryDto( + Guid Id, + string Title, + ChatType Type, + string? SecondaryText, + DateTimeOffset? LastActivityAt, + string? LastMessagePreview, + IReadOnlyList Participants, + bool CanSendMessages, + int UnreadCount, + bool IsPinned = false, + bool IsArchived = false, + bool IsMuted = false, + string? FolderKey = null, + bool HasProtectedContent = false, + int? DefaultMessageTtlSeconds = null, + AttachmentDto? LastMessageAttachment = null, + int SubscriberCount = 0, + int AdminCount = 0, + bool IsSubscribed = false, + bool CanSubscribe = false, + bool CanManageChannel = false, + bool CanPostAsChannel = false, + string? PublicUsername = null, + Guid? LinkedDiscussionChatId = null, + bool ChannelSignaturesEnabled = false, + string? Description = null); + +public sealed record ChatDetailsDto( + Guid Id, + string Title, + ChatType Type, + IReadOnlyList Participants, + bool CanSendMessages, + IReadOnlyList Messages, + Guid? FirstUnreadMessageId = null, + bool HasMoreMessages = false, + IReadOnlyList? PinnedMessages = null, + ChatPermissionsDto? CurrentUserPermissions = null, + IReadOnlyList? Members = null, + bool HasProtectedContent = false, + int? DefaultMessageTtlSeconds = null, + int SubscriberCount = 0, + int AdminCount = 0, + bool IsSubscribed = false, + bool CanSubscribe = false, + bool CanManageChannel = false, + bool CanPostAsChannel = false, + string? PublicUsername = null, + Guid? LinkedDiscussionChatId = null, + bool ChannelSignaturesEnabled = false, + string? Description = null); + +public sealed record ChatInfoDto( + ChatDetailsDto Chat, + int MediaCount, + int FileCount, + int LinkCount, + int VoiceCount, + int PinnedCount, + int TopicCount, + string? PublicUsername, + IReadOnlyList InviteLinks, + IReadOnlyList Topics, + int SubscriberCount = 0, + int AdminCount = 0, + Guid? LinkedDiscussionChatId = null, + bool ChannelSignaturesEnabled = false, + bool CanManageChannel = false, + bool CanPostAsChannel = false, + string? Description = null); + +public sealed record ChatMessagesPageDto( + IReadOnlyList Messages, + bool HasMoreMessages); + +public sealed record ChatFolderDto( + string Key, + string Title, + int TotalCount, + int UnreadCount); + +public sealed record ChatStateRequest( + bool? IsPinned = null, + bool? IsArchived = null, + DateTimeOffset? MutedUntil = null, + string? FolderKey = null, + bool? IsMuted = null, + bool? HasProtectedContent = null, + int? DefaultMessageTtlSeconds = null, + bool? ChannelSignaturesEnabled = null, + Guid? LinkedDiscussionChatId = null); + +public sealed record UpdateChatMemberPermissionsRequest( + ChatMemberRole Role, + bool CanPinMessages, + bool CanDeleteMessages, + bool CanInviteUsers, + bool CanPostMessages); + +public sealed record AddChatMembersRequest( + IReadOnlyList MemberIds, + ChatMemberRole Role = ChatMemberRole.Member, + bool CanPinMessages = false, + bool CanDeleteMessages = false, + bool CanInviteUsers = false, + bool CanPostMessages = false); + +public sealed record ChatInviteLinkDto( + Guid Id, + Guid ChatId, + string Token, + string Link, + string? Title, + bool CreatesJoinRequest, + bool IsRevoked, + DateTimeOffset CreatedAt, + DateTimeOffset? RevokedAt = null); + +public sealed record CreateChatInviteLinkRequest( + string? Title = null, + bool CreatesJoinRequest = false); + +public sealed record ChatTopicDto( + Guid Id, + Guid ChatId, + string Title, + string Icon, + bool IsPinned, + bool IsClosed, + DateTimeOffset CreatedAt, + DateTimeOffset LastActivityAt, + int MessageCount); + +public sealed record CreateChatTopicRequest( + string Title, + string? Icon = null); + +public sealed record ChatSearchResultDto( + ChatSummaryDto Chat, + MessageDto? Message, + string MatchText, + DateTimeOffset? MatchedAt, + Guid? ChatId = null, + Guid? MessageId = null, + Guid? SenderUserId = null, + DateTimeOffset? MessageSentAt = null); + +public sealed record ChatSearchPageDto( + IReadOnlyList Results, + int Page, + int Limit, + bool HasMore); + +public sealed record MessageSearchPageDto( + IReadOnlyList Messages, + int Page, + int Limit, + bool HasMore); + +public sealed record TypingIndicatorDto( + Guid ChatId, + UserSummaryDto User, + bool IsTyping, + DateTimeOffset TypedAt); + +public sealed record CreateDirectChatRequest(Guid UserId); + +public sealed record CreateGroupChatRequest(string Title, IReadOnlyList MemberIds); + +public sealed record CreateChannelRequest( + string Title, + IReadOnlyList MemberIds, + string? Description = null, + string? PublicUsername = null, + bool CreateInviteLink = false, + bool ChannelSignaturesEnabled = false); + +public enum ChatJoinRequestStatus +{ + Pending = 0, + Approved = 1, + Declined = 2 +} + +public sealed record ChatJoinRequestDto( + Guid Id, + Guid ChatId, + Guid InviteLinkId, + UserSummaryDto User, + ChatJoinRequestStatus Status, + DateTimeOffset RequestedAt, + DateTimeOffset? ResolvedAt, + UserSummaryDto? ResolvedBy); + +public sealed record ResolveChatJoinRequestRequest(bool Approve); diff --git a/src/Ikar.Shared/ChatType.cs b/src/Ikar.Shared/ChatType.cs new file mode 100644 index 00000000..ab8fca69 --- /dev/null +++ b/src/Ikar.Shared/ChatType.cs @@ -0,0 +1,8 @@ +namespace Ikar.Shared; + +public enum ChatType +{ + Direct = 1, + Group = 2, + Channel = 3 +} diff --git a/src/Ikar.Shared/ClientVersionContracts.cs b/src/Ikar.Shared/ClientVersionContracts.cs new file mode 100644 index 00000000..dc0ded67 --- /dev/null +++ b/src/Ikar.Shared/ClientVersionContracts.cs @@ -0,0 +1,17 @@ +namespace Ikar.Shared; + +public static class ClientVersionMetadata +{ + public const string PlatformHeaderName = "X-Ikar-Client-Platform"; + public const string VersionCodeHeaderName = "X-Ikar-Client-Version-Code"; + public const string VersionNameHeaderName = "X-Ikar-Client-Version-Name"; + public const string PlatformQueryName = "clientPlatform"; + public const string VersionCodeQueryName = "clientVersionCode"; + public const string VersionNameQueryName = "clientVersionName"; +} + +public static class ClientPlatformNames +{ + public const string Android = "android"; + public const string Windows = "windows"; +} diff --git a/src/Ikar.Shared/Ikar.Shared.csproj b/src/Ikar.Shared/Ikar.Shared.csproj new file mode 100644 index 00000000..b7601447 --- /dev/null +++ b/src/Ikar.Shared/Ikar.Shared.csproj @@ -0,0 +1,9 @@ + + + + net10.0 + enable + enable + + + diff --git a/src/Ikar.Shared/MessageContracts.cs b/src/Ikar.Shared/MessageContracts.cs new file mode 100644 index 00000000..8fee0ef1 --- /dev/null +++ b/src/Ikar.Shared/MessageContracts.cs @@ -0,0 +1,69 @@ +namespace Ikar.Shared; + +public enum MessageDeliveryState +{ + Sent = 0, + Delivered = 1, + Read = 2 +} + +public sealed record AttachmentDto( + Guid Id, + string FileName, + string ContentType, + long FileSizeBytes, + string DownloadPath, + AttachmentKind Kind, + int SortOrder = 0); + +public sealed record MessageReplyDto( + Guid MessageId, + Guid SenderId, + string SenderDisplayName, + string PreviewText); + +public sealed record MessageReactionDto( + string Emoji, + int Count, + bool IsMine); + +public sealed record MessageDto( + Guid Id, + Guid ChatId, + ChatType ChatType, + UserSummaryDto Sender, + string Text, + IReadOnlyList Attachments, + DateTimeOffset SentAt, + DateTimeOffset? EditedAt, + DateTimeOffset? DeletedAt, + MessageDeliveryState DeliveryState, + string? ForwardedFromDisplayName = null, + MessageReplyDto? ReplyTo = null, + DateTimeOffset? PinnedAt = null, + UserSummaryDto? PinnedBy = null, + Guid? MediaAlbumId = null, + IReadOnlyList? Reactions = null, + bool HasProtectedContent = false, + DateTimeOffset? ExpiresAt = null, + int? TtlSeconds = null, + bool PostedAsChannel = false, + string? DisplayAuthorName = null, + int? ViewCount = null); + +public sealed record SendMessageRequest( + string Text, + Guid? ReplyToMessageId = null, + Guid? TopicId = null, + bool? HasProtectedContent = null, + int? TtlSeconds = null); + +public sealed record UpdateMessageRequest(string Text); + +public sealed record UpdateMessageReceiptRequest(Guid MessageId); + +public sealed record ForwardMessageRequest(Guid TargetChatId); + +public sealed record SetMessageReactionRequest(string Emoji); + +public sealed record ExpiredMessagesCleanupDto(int DeletedMessages, DateTimeOffset CleanedUpAt); diff --git a/src/Ikar.Shared/ModerationContracts.cs b/src/Ikar.Shared/ModerationContracts.cs new file mode 100644 index 00000000..cb6ffe14 --- /dev/null +++ b/src/Ikar.Shared/ModerationContracts.cs @@ -0,0 +1,23 @@ +namespace Ikar.Shared; + +public enum ModerationLogTargetType +{ + Chat = 0, + Message = 1, + User = 2, + InviteLink = 3, + JoinRequest = 4, + Story = 5 +} + +public sealed record ModerationLogDto( + long Id, + Guid? ChatId, + Guid ActorUserId, + string ActorDisplayName, + ModerationLogTargetType TargetType, + Guid? TargetId, + string Action, + string? Reason, + DateTimeOffset CreatedAt); + diff --git a/src/Ikar.Shared/PhoneAuthContracts.cs b/src/Ikar.Shared/PhoneAuthContracts.cs new file mode 100644 index 00000000..477fd85a --- /dev/null +++ b/src/Ikar.Shared/PhoneAuthContracts.cs @@ -0,0 +1,15 @@ +namespace Ikar.Shared; + +public sealed record RequestPhoneCodeRequest(string PhoneNumber); + +public sealed record PhoneCodeChallengeDto( + string ChallengeId, + DateTimeOffset ExpiresAt, + string TestCode, + bool IsRegistered); + +public sealed record VerifyPhoneCodeRequest( + string ChallengeId, + string PhoneNumber, + string Code, + string? DisplayName); diff --git a/src/Ikar.Shared/PhoneNumberNormalizer.cs b/src/Ikar.Shared/PhoneNumberNormalizer.cs new file mode 100644 index 00000000..3af3aebf --- /dev/null +++ b/src/Ikar.Shared/PhoneNumberNormalizer.cs @@ -0,0 +1,22 @@ +namespace Ikar.Shared; + +public static class PhoneNumberNormalizer +{ + public static string? Normalize(string? phoneNumber) + { + if (string.IsNullOrWhiteSpace(phoneNumber)) + { + return null; + } + + var digits = new string(phoneNumber.Where(char.IsDigit).ToArray()); + if (digits.Length < 10) + { + return null; + } + + return digits.Length == 10 + ? digits + : digits[^10..]; + } +} diff --git a/src/Ikar.Shared/PushContracts.cs b/src/Ikar.Shared/PushContracts.cs new file mode 100644 index 00000000..50dc78ee --- /dev/null +++ b/src/Ikar.Shared/PushContracts.cs @@ -0,0 +1,53 @@ +namespace Ikar.Shared; + +public enum PushPlatform +{ + Unknown = 0, + Android = 1, + Windows = 2 +} + +public sealed record RegisterPushDeviceRequest( + PushPlatform Platform, + string InstallationId, + string DeviceToken, + string? DeviceName, + bool NotificationsEnabled); + +public sealed record PushDeviceDto( + Guid Id, + PushPlatform Platform, + string InstallationId, + string? DeviceName, + bool NotificationsEnabled, + DateTimeOffset UpdatedAt); + +public static class PushDataKeys +{ + public const string Kind = "kind"; + public const string CallId = "callId"; + public const string ChatId = "chatId"; + public const string ChatType = "chatType"; + public const string MessageId = "messageId"; + public const string Title = "title"; + public const string Body = "body"; + public const string SenderId = "senderId"; + public const string SenderName = "senderName"; + public const string SenderPhoneNumber = "senderPhoneNumber"; + public const string ReactionEmoji = "reactionEmoji"; + public const string CallAction = "callAction"; + public const string CallMediaType = "callMediaType"; + public const string VersionCode = "versionCode"; + public const string VersionName = "versionName"; +} + +public static class PushDataKinds +{ + public const string ChatMessage = "chat_message"; + public const string MessageReaction = "message_reaction"; + public const string AudioCallInvite = "audio_call_invite"; + public const string VideoCallInvite = "video_call_invite"; + public const string AudioCallEnded = "audio_call_ended"; + public const string VideoCallEnded = "video_call_ended"; + public const string AppUpdateAvailable = "app_update_available"; +} diff --git a/src/Ikar.Shared/StoryContracts.cs b/src/Ikar.Shared/StoryContracts.cs new file mode 100644 index 00000000..59dad539 --- /dev/null +++ b/src/Ikar.Shared/StoryContracts.cs @@ -0,0 +1,34 @@ +namespace Ikar.Shared; + +public enum StoryVisibility +{ + Everyone = 0, + Contacts = 1, + CloseFriends = 2, + Private = 3 +} + +public sealed record StoryDto( + Guid Id, + UserSummaryDto Author, + string Text, + IReadOnlyList Attachments, + StoryVisibility Visibility, + bool HasProtectedContent, + DateTimeOffset CreatedAt, + DateTimeOffset ExpiresAt, + DateTimeOffset? DeletedAt, + int ViewCount, + bool ViewedByMe); + +public sealed record CreateStoryRequest( + string? Text, + StoryVisibility Visibility = StoryVisibility.Everyone, + bool HasProtectedContent = false, + int TtlSeconds = 86400); + +public sealed record StoryViewDto( + Guid StoryId, + UserSummaryDto Viewer, + DateTimeOffset ViewedAt); + diff --git a/src/Ikar.Shared/UserContracts.cs b/src/Ikar.Shared/UserContracts.cs new file mode 100644 index 00000000..618f8d65 --- /dev/null +++ b/src/Ikar.Shared/UserContracts.cs @@ -0,0 +1,50 @@ +namespace Ikar.Shared; + +public sealed record UserSummaryDto( + Guid Id, + string Username, + string DisplayName, + bool IsOnline, + string? PhoneNumber, + DateTimeOffset? LastSeenAt, + string? About, + string? AvatarPath, + bool IsBot = false); + +public sealed record ResolveContactsRequest(IReadOnlyList PhoneNumbers); + +public sealed record ContactMatchDto( + string PhoneNumber, + UserSummaryDto User); + +public sealed record UpdateMyProfileRequest( + string DisplayName, + string? About); + +public sealed record PrivacySettingsDto( + string PhoneNumberVisibility, + string LastSeenVisibility, + string ProfilePhotoVisibility, + IReadOnlyList BlockedUserIds, + IReadOnlyList AlwaysAllowUserIds, + IReadOnlyList NeverAllowUserIds, + bool PasscodeLockEnabled, + bool ShowMessagePreview, + DateTimeOffset UpdatedAt); + +public sealed record UpdatePrivacySettingsRequest( + string PhoneNumberVisibility, + string LastSeenVisibility, + string ProfilePhotoVisibility, + IReadOnlyList? BlockedUserIds = null, + IReadOnlyList? AlwaysAllowUserIds = null, + IReadOnlyList? NeverAllowUserIds = null, + bool PasscodeLockEnabled = false, + bool ShowMessagePreview = true); + +public sealed record UserSessionDto( + Guid Id, + DateTimeOffset CreatedAt, + DateTimeOffset LastSeenAt, + bool IsCurrent, + string DeviceName);