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