Update Ikar server and Android client

This commit is contained in:
Курнат Андрей
2026-05-17 22:22:43 +03:00
commit 22e19cdfdb
258 changed files with 51047 additions and 0 deletions
+14
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
IKAR_DATA_PATH=/media/myDrive/ikar-data
+13
View File
@@ -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=
+8
View File
@@ -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
+27
View File
@@ -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"]
+8
View File
@@ -0,0 +1,8 @@
<Solution>
<Folder Name="/src/">
<Project Path="src/Ikar.Client/Ikar.Client.csproj" />
<Project Path="src/Ikar.CodexBridge/Ikar.CodexBridge.csproj" />
<Project Path="src/Ikar.Server/Ikar.Server.csproj" />
<Project Path="src/Ikar.Shared/Ikar.Shared.csproj" />
</Folder>
</Solution>
+114
View File
@@ -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
+204
View File
@@ -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 <N>` 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.
+15
View File
@@ -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
+102
View File
@@ -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://<pi-ip>: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.
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 182 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 193 KiB

+231
View File
@@ -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
}
+123
View File
@@ -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
}
+222
View File
@@ -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"
+10
View File
@@ -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
+298
View File
@@ -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
+15
View File
@@ -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"
+3
View File
@@ -0,0 +1,3 @@
$ErrorActionPreference = 'Stop'
Set-Location (Join-Path $PSScriptRoot '..')
dotnet run --project .\src\Ikar.Server\Ikar.Server.csproj
+190
View File
@@ -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
}
+6
View File
@@ -0,0 +1,6 @@
.gradle/
build/
app/build/
local.properties
argus-signing.local.properties
signing/
@@ -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: "<lowercase-slug-with-hyphens-only>"
name: "<public app name>"
summary: "<short one-line summary>"
description: "<full description>"
repositoryUrl: "<optional https url or null>"
homepageUrl: "<optional https url or null>"
isListed: true
android:
androidPackageName: "<java.package.name>"
versionName: "<human-readable version, ideally semver>"
versionCode: <positive integer>
targetSdkVersion: <integer>
abiKind: "<universal|arm64|armv7|x86_64|x86>"
release:
version: "<same semantic release label as versionName, if applicable>"
channel: "stable"
platform: "<android-universal|android-arm64|android-armv7|android-x86_64|android-x86>"
packageKind: "apk"
notes: "<optional release notes or null>"
packagePath: "<absolute or repo-relative path to final .apk>"
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 "<admin>" `
-Password "<password>" `
-Slug "<slug>" `
-Name "<name>" `
-Summary "<summary>" `
-Description "<description>" `
-AndroidPackageName "<java.package.name>" `
-Version "<version>" `
-Channel "stable" `
-Platform "android-universal" `
-PackageKind "apk" `
-AndroidVersionCode <positive integer> `
-PackagePath "<path-to.apk>"
```
Источник: `scripts/publish-release.ps1:53-130`, `README.md:74-90`.
@@ -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
+39
View File
@@ -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<versionCode>/`.
+122
View File
@@ -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")
}
+1
View File
@@ -0,0 +1 @@
# Intentionally empty for the first migration pass.
@@ -0,0 +1,99 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.READ_CONTACTS" />
<uses-permission android:name="android.permission.VIBRATE" />
<uses-permission android:name="android.permission.USE_FULL_SCREEN_INTENT" />
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
<application
android:name=".app.IkarNativeApp"
android:allowBackup="true"
android:icon="@drawable/appicon"
android:label="Икар"
android:roundIcon="@drawable/appicon"
android:supportsRtl="true"
android:theme="@android:style/Theme.Material.Light.NoActionBar">
<activity
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTop"
android:windowSoftInputMode="adjustResize">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.SEND" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="*/*" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.SEND_MULTIPLE" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="*/*" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="content" />
<data android:scheme="file" />
<data android:mimeType="application/zip" />
<data android:mimeType="application/x-zip" />
<data android:mimeType="application/x-zip-compressed" />
<data android:mimeType="application/octet-stream" />
</intent-filter>
</activity>
<activity
android:name=".feature.call.IncomingCallActivity"
android:excludeFromRecents="true"
android:exported="false"
android:launchMode="singleTop"
android:showWhenLocked="true"
android:theme="@android:style/Theme.Material.Light.NoActionBar"
android:turnScreenOn="true" />
<service
android:name=".core.push.IkarFirebaseMessagingService"
android:exported="false">
<intent-filter>
<action android:name="com.google.firebase.MESSAGING_EVENT" />
</intent-filter>
</service>
<receiver
android:name=".core.push.NotificationActionReceiver"
android:exported="false" />
<receiver
android:name=".core.update.ArgusAppUpdateInstallerStatusReceiver"
android:exported="false" />
<service
android:name="androidx.work.impl.foreground.SystemForegroundService"
android:foregroundServiceType="dataSync"
tools:node="merge" />
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_provider_paths" />
</provider>
</application>
</manifest>
@@ -0,0 +1,6 @@
{
"applicationId": "1:91097833269:android:c123132a32a690ba19b67c",
"projectId": "ikar-3bdfb",
"apiKey": "AIzaSyCgjHUi4zFNRfwAwsiLBlRZCGup78N74ks",
"senderId": "91097833269"
}
@@ -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<Uri>(Intent.EXTRA_STREAM)
}
return directStream ?: clipData?.takeIf { it.itemCount > 0 }?.getItemAt(0)?.uri
}
private fun Intent.readMultipleStreams(): List<Uri> {
val extraStreams = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
getParcelableArrayListExtra(Intent.EXTRA_STREAM, Uri::class.java).orEmpty()
} else {
@Suppress("DEPRECATION")
getParcelableArrayListExtra<Uri>(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"
}
}
@@ -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 <T> 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))
}
}
File diff suppressed because it is too large Load Diff
@@ -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()
}
@@ -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<DeviceContactRecord> {
val contentResolver = context.contentResolver ?: return emptyList()
val contacts = linkedMapOf<String, DeviceContactRecord>()
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()
}
}
@@ -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)
}
}
@@ -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<DiscoverUserItem> = 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<DiscoverUserItem> { it.user.isBot }
.thenByDescending { it.user.isOnline }
.thenBy { it.displayName.lowercase() }
)
}
suspend fun loadMergedCandidates(query: String): List<DiscoverUserItem> = 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<DiscoverUserItem> { it.user.isBot }
.thenByDescending { it.user.isOnline }
.thenBy { it.displayName.lowercase() }
)
}
}
@@ -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<CachedChatEntity>
@Upsert
suspend fun upsert(chats: List<CachedChatEntity>)
@Query("DELETE FROM cached_chats WHERE owner_user_id = :ownerUserId")
suspend fun clear(ownerUserId: String)
}
@@ -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<CachedChatMessageEntity>
@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<CachedChatMessageEntity>
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun upsert(messages: List<CachedChatMessageEntity>)
@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)
}
@@ -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
)
@@ -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<ChatSummaryDto> =
dao.loadChats(ownerUserId)
.mapNotNull { entity -> runCatching { json.decodeFromString<ChatSummaryDto>(entity.payloadJson) }.getOrNull() }
suspend fun replaceChats(ownerUserId: String, chats: List<ChatSummaryDto>) {
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
)
}
)
}
}
@@ -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<MessageDto> =
dao.loadLatestMessages(ownerUserId, chatId, limit)
.mapNotNull { it.toMessageOrNull() }
.sortedBy(MessageDto::sentAt)
suspend fun loadOptimisticMessages(ownerUserId: String, chatId: String): List<MessageDto> =
dao.loadOptimisticMessages(ownerUserId, chatId)
.mapNotNull { it.toMessageOrNull() }
.sortedBy(MessageDto::sentAt)
suspend fun upsertMessages(
ownerUserId: String,
chatId: String,
messages: List<MessageDto>,
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<MessageDto>(payloadJson) }.getOrNull()
private companion object {
const val LatestMessagesLimit = 100
}
}
@@ -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 }
}
}
}
@@ -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<TransferQueueEntity>)
@Query("SELECT * FROM transfer_queue WHERE groupId = :groupId ORDER BY sortOrder ASC")
suspend fun getGroup(groupId: String): List<TransferQueueEntity>
@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<List<TransferQueueEntity>>
@Query("SELECT * FROM transfer_queue WHERE type = 'story_upload' AND status IN ('queued', 'running', 'failed') ORDER BY createdAtMillis ASC, sortOrder ASC")
fun observeActiveStoryUploads(): Flow<List<TransferQueueEntity>>
@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"
}
@@ -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<ChatMemberRole> {
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<ChatJoinRequestStatus> {
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<ModerationLogTargetType> {
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<ChatType> {
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<AttachmentKind> {
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<MessageDeliveryState> {
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<CallMediaType> {
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<PushPlatform> {
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<StoryVisibility> {
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<AttachmentDto> = 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<MessageReactionDto> = 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<UserSummaryDto> = 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<UserSummaryDto> = emptyList(),
val canSendMessages: Boolean = true,
val messages: List<MessageDto> = emptyList(),
val firstUnreadMessageId: String? = null,
val hasMoreMessages: Boolean = false,
val pinnedMessages: List<MessageDto> = emptyList(),
val currentUserPermissions: ChatPermissionsDto? = null,
val members: List<ChatMemberDto> = 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<ChatInviteLinkDto> = emptyList(),
val topics: List<ChatTopicDto> = 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<MessageDto> = 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<String>,
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<ChatSearchResultDto> = emptyList(),
val page: Int = 1,
val limit: Int = 50,
val hasMore: Boolean = false
)
@Serializable
data class MessageSearchPageDto(
val messages: List<MessageDto> = 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<String>
)
@Serializable
data class CreateChannelRequest(
val title: String,
val memberIds: List<String>,
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<String>
)
@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<AttachmentDto> = 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<String> = emptyList(),
val alwaysAllowUserIds: List<String> = emptyList(),
val neverAllowUserIds: List<String> = 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<String>? = null,
val alwaysAllowUserIds: List<String>? = null,
val neverAllowUserIds: List<String>? = 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<BotCommandDto> = 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<BotCommandDto>
)
@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<CallIceServerDto> = 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<String>,
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
)
@@ -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"
}
}
}
@@ -0,0 +1,13 @@
package com.seven.ikar.kotlin.core.push
import java.util.concurrent.atomic.AtomicReference
object AndroidActiveChatState {
private val activeChatId = AtomicReference<String?>(null)
fun setActiveChatId(chatId: String?) {
activeChatId.set(chatId?.takeIf { it.isNotBlank() })
}
fun getActiveChatId(): String? = activeChatId.get()
}
@@ -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()
}
@@ -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
)
@@ -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()
}
@@ -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)
}
}
@@ -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()
}
@@ -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 <T : Enum<T>> 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 <T : Enum<T>> 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 <T : Enum<T>> getNullableEnum(key: String, defaultValue: T): T? {
if (!preferences.contains(key)) {
return null
}
return getEnum(key, defaultValue)
}
private fun <T : Enum<T>> 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"
}
}
@@ -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()
}
}
@@ -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"
}
}
@@ -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)
}
}
@@ -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<PendingIncomingCallInvite?>(null)
val pendingInvite: StateFlow<PendingIncomingCallInvite?> = _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<String, String>): PendingIncomingCallInvite? =
pendingIncomingCallInviteFromReader(data::get)
fun pendingIncomingCallInviteFromIntent(intent: Intent): PendingIncomingCallInvite? =
pendingIncomingCallInviteFromReader(intent::getStringExtra)
fun endedCallIdFromMap(data: Map<String, String>): 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() }
}
@@ -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
}
}
}
@@ -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<PendingPushNavigation?>(null)
val pendingNavigation: StateFlow<PendingPushNavigation?> = _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
}
}
}
@@ -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"
}
}
@@ -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"
}
@@ -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<NotificationSoundChoice> = listOf(
NotificationSoundChoice(NotificationSoundOption.Default, "По умолчанию"),
NotificationSoundChoice(NotificationSoundOption.Ringtone, "Рингтон"),
NotificationSoundChoice(NotificationSoundOption.Alarm, "Сигнал"),
NotificationSoundChoice(NotificationSoundOption.Silent, "Без звука")
)
val callRingtoneChoices: List<NotificationSoundChoice> = listOf(
NotificationSoundChoice(NotificationSoundOption.Default, "По умолчанию"),
NotificationSoundChoice(NotificationSoundOption.Ringtone, "Рингтон"),
NotificationSoundChoice(NotificationSoundOption.Alarm, "Сигнал"),
NotificationSoundChoice(NotificationSoundOption.Silent, "Без звука")
)
val repeatChoices: List<NotificationRepeatIntervalChoice> = listOf(
NotificationRepeatIntervalChoice(NotificationRepeatIntervalOption.Off, "Выключен"),
NotificationRepeatIntervalChoice(NotificationRepeatIntervalOption.FifteenMinutes, "15 минут"),
NotificationRepeatIntervalChoice(NotificationRepeatIntervalOption.ThirtyMinutes, "30 минут"),
NotificationRepeatIntervalChoice(NotificationRepeatIntervalOption.OneHour, "1 час"),
NotificationRepeatIntervalChoice(NotificationRepeatIntervalOption.TwoHours, "2 часа")
)
}
@@ -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"
}
@@ -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<String, String>) {
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<String, String> {
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<String, String>
)
}
@@ -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()
}
@@ -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<RealtimeAttachmentDto> = 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<RealtimeMessageReactionDto> = 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<RealtimeUserSummaryDto> = 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<RealtimeCallIceServerDto> = 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<String>,
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
)
@@ -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<RealtimeConnectionStage> = _stage
private val _messagesCreated = MutableSharedFlow<MessageDto>(extraBufferCapacity = 32)
val messagesCreated: SharedFlow<MessageDto> = _messagesCreated
private val _messagesUpdated = MutableSharedFlow<MessageDto>(extraBufferCapacity = 32)
val messagesUpdated: SharedFlow<MessageDto> = _messagesUpdated
private val _typingChanged = MutableSharedFlow<TypingIndicatorDto>(extraBufferCapacity = 64)
val typingChanged: SharedFlow<TypingIndicatorDto> = _typingChanged
private val _chatCreated = MutableSharedFlow<ChatSummaryDto>(extraBufferCapacity = 16)
val chatCreated: SharedFlow<ChatSummaryDto> = _chatCreated
private val _chatsChanged = MutableSharedFlow<Unit>(extraBufferCapacity = 8)
val chatsChanged: SharedFlow<Unit> = _chatsChanged
private val _appUpdateAvailable = MutableSharedFlow<AndroidAppReleaseDto>(extraBufferCapacity = 8)
val appUpdateAvailable: SharedFlow<AndroidAppReleaseDto> = _appUpdateAvailable
private val _incomingCall = MutableSharedFlow<IncomingCallDto>(extraBufferCapacity = 8)
val incomingCall: SharedFlow<IncomingCallDto> = _incomingCall
private val _callConnected = MutableSharedFlow<CallConnectedDto>(extraBufferCapacity = 8)
val callConnected: SharedFlow<CallConnectedDto> = _callConnected
private val _callEnded = MutableSharedFlow<CallEndedDto>(extraBufferCapacity = 8)
val callEnded: SharedFlow<CallEndedDto> = _callEnded
private val _callOffer = MutableSharedFlow<CallOfferDto>(extraBufferCapacity = 16)
val callOffer: SharedFlow<CallOfferDto> = _callOffer
private val _callAnswer = MutableSharedFlow<CallAnswerDto>(extraBufferCapacity = 16)
val callAnswer: SharedFlow<CallAnswerDto> = _callAnswer
private val _callIceCandidate = MutableSharedFlow<CallIceCandidateDto>(extraBufferCapacity = 32)
val callIceCandidate: SharedFlow<CallIceCandidateDto> = _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
}
}
@@ -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 <T> 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
}
}
@@ -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<AuthSessionDto>(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"
}
}
@@ -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<AppSettingsSnapshot> = _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 <T : Enum<T>> 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"
}
}
@@ -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<ChatSettingsSnapshot> = _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 <T : Enum<T>> 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"
}
}
@@ -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<PrivacyRuntimeSnapshot> = _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<Boolean> = _isLocked.asStateFlow()
fun setLocked(locked: Boolean) {
_isLocked.value = locked
}
}
@@ -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
}
}
@@ -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() }
}
}
@@ -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) }
}
}
@@ -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()
}
@@ -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)
}
}
@@ -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 ?: "Не удалось скачать или запустить установку обновления."
)
}
}
@@ -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"
}
@@ -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"
}
}
@@ -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<ArchiveEntryPreview>
)
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<ArchiveEntryPreview>()
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<Long> {
val ids = mutableListOf<Long>()
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"
}
}
}
@@ -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)}."
}
@@ -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<ArchiveExtractorUiState> = _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]}"
}
@@ -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<PendingArchiveOpen?>(null)
val pendingArchive: StateFlow<PendingArchiveOpen?> = _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
}
}
@@ -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("Скопировать токен")
}
}
}
}
}
}
}
@@ -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<BotEditorUiState> = _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<BotCommandDto> =
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<BotCommandDto>): String =
commands.joinToString(separator = "\n") { "/${it.command} - ${it.description}" }
}
@@ -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
)
}
}
}
}
}
}
}
}
@@ -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<BotSummaryDto> = emptyList(),
val isLoading: Boolean = false,
val errorMessage: String? = null
)
class BotsViewModel(private val container: AppContainer) : ViewModel() {
private val _uiState = MutableStateFlow(BotsUiState())
val uiState: StateFlow<BotsUiState> = _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)
}
}
}
}
@@ -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
}
}
@@ -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<CallIceCandidateDto>()
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<CallIceServerDto>,
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<CallIceServerDto>) {
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<out IceCandidate>?) = 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<out MediaStream>?) {
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<Unit> { 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<Unit> { 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
}
}
}
}
@@ -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<AudioCallSnapshot?>(null)
val currentCall: StateFlow<AudioCallSnapshot?> = _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)
}
}
@@ -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<SurfaceViewRenderer?>(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 -> "Звонок"
}
@@ -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)
}
)
}
}
@@ -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<File> { 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._-]"), "_")
}
@@ -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<VoiceNotePlaybackEvent>(extraBufferCapacity = 32)
val events: SharedFlow<VoiceNotePlaybackEvent> = _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
}
}
@@ -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<Long> = _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
}
}
@@ -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<SavedChatScrollPosition>(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"
}
}
@@ -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
}

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