Update Ikar server and Android client
This commit is contained in:
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
IKAR_DATA_PATH=/media/myDrive/ikar-data
|
||||
@@ -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=
|
||||
@@ -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
|
||||
@@ -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"]
|
||||
@@ -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
@@ -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
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -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
Binary file not shown.
|
After Width: | Height: | Size: 193 KiB |
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -0,0 +1,3 @@
|
||||
$ErrorActionPreference = 'Stop'
|
||||
Set-Location (Join-Path $PSScriptRoot '..')
|
||||
dotnet run --project .\src\Ikar.Server\Ikar.Server.csproj
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
@@ -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>/`.
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
+45
@@ -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()
|
||||
}
|
||||
}
|
||||
+16
@@ -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)
|
||||
}
|
||||
}
|
||||
+95
@@ -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() }
|
||||
)
|
||||
}
|
||||
}
|
||||
+53
@@ -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)
|
||||
}
|
||||
+58
@@ -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)
|
||||
}
|
||||
+28
@@ -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
|
||||
)
|
||||
+38
@@ -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
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
+74
@@ -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
|
||||
}
|
||||
}
|
||||
+94
@@ -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 }
|
||||
}
|
||||
}
|
||||
}
|
||||
+73
@@ -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
|
||||
)
|
||||
+1390
File diff suppressed because it is too large
Load Diff
+39
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
+13
@@ -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()
|
||||
}
|
||||
+13
@@ -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()
|
||||
}
|
||||
+35
@@ -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
|
||||
)
|
||||
+62
@@ -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()
|
||||
}
|
||||
+35
@@ -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)
|
||||
}
|
||||
}
|
||||
+150
@@ -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()
|
||||
}
|
||||
+413
@@ -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"
|
||||
}
|
||||
}
|
||||
+18
@@ -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()
|
||||
}
|
||||
}
|
||||
+48
@@ -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"
|
||||
}
|
||||
}
|
||||
+104
@@ -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)
|
||||
}
|
||||
}
|
||||
+100
@@ -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() }
|
||||
}
|
||||
+111
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
+38
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
+18
@@ -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"
|
||||
}
|
||||
}
|
||||
+95
@@ -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"
|
||||
}
|
||||
+83
@@ -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"
|
||||
}
|
||||
+333
@@ -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>
|
||||
)
|
||||
}
|
||||
+82
@@ -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()
|
||||
}
|
||||
+332
@@ -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
|
||||
)
|
||||
+297
@@ -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
|
||||
}
|
||||
}
|
||||
+40
@@ -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
|
||||
}
|
||||
}
|
||||
+30
@@ -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"
|
||||
}
|
||||
}
|
||||
+140
@@ -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"
|
||||
}
|
||||
}
|
||||
+177
@@ -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"
|
||||
}
|
||||
}
|
||||
+56
@@ -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
|
||||
}
|
||||
}
|
||||
+28
@@ -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
|
||||
}
|
||||
}
|
||||
+33
@@ -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() }
|
||||
}
|
||||
}
|
||||
+126
@@ -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) }
|
||||
}
|
||||
}
|
||||
+149
@@ -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()
|
||||
}
|
||||
+70
@@ -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)
|
||||
}
|
||||
}
|
||||
+101
@@ -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 ?: "Не удалось скачать или запустить установку обновления."
|
||||
)
|
||||
}
|
||||
}
|
||||
+84
@@ -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"
|
||||
}
|
||||
+57
@@ -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"
|
||||
}
|
||||
}
|
||||
+569
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
+464
@@ -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)}."
|
||||
}
|
||||
+172
@@ -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]}"
|
||||
}
|
||||
+40
@@ -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
|
||||
}
|
||||
}
|
||||
+197
@@ -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("Скопировать токен")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+236
@@ -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}" }
|
||||
}
|
||||
+137
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+40
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+46
@@ -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
|
||||
}
|
||||
}
|
||||
+587
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+359
@@ -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)
|
||||
}
|
||||
}
|
||||
+561
@@ -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 -> "Звонок"
|
||||
}
|
||||
+192
@@ -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)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
+301
@@ -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._-]"), "_")
|
||||
}
|
||||
+150
@@ -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
|
||||
}
|
||||
}
|
||||
+148
@@ -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
|
||||
}
|
||||
}
|
||||
+3723
File diff suppressed because it is too large
Load Diff
+43
@@ -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"
|
||||
}
|
||||
}
|
||||
+2006
File diff suppressed because it is too large
Load Diff
+224
@@ -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
Reference in New Issue
Block a user