Expand Ikar messaging and Android release support

This commit is contained in:
Курнат Андрей
2026-06-20 19:52:33 +03:00
parent 22e19cdfdb
commit e18f5f5ad6
126 changed files with 15172 additions and 2206 deletions
+3
View File
@@ -1,6 +1,9 @@
.git
.vs
.codex-*
.env
.env.*
!.env.example
**/bin
**/obj
docker-data
+7
View File
@@ -11,3 +11,10 @@ Push__ServiceAccountJsonPath=
Push__ServiceAccountJson=
Admin__Username=
Admin__Password=
EmailAuth__SmtpHost=smtp.yandex.ru
EmailAuth__SmtpPort=587
EmailAuth__EnableSsl=true
EmailAuth__Username=kusoft
EmailAuth__Password=
EmailAuth__FromAddress=kusoft@yandex.ru
EmailAuth__FromName=Ikar
+71 -278
View File
@@ -4,295 +4,88 @@ param(
[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]$ArgusInternalBaseUrl = 'unused',
[string]$ArgusEnvPath = 'unused',
[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]$AppName = 'Ikar',
[string]$Summary = 'Standalone Android client for Ikar on Kotlin and Jetpack Compose.',
[string]$Description = 'Standalone Android client on Kotlin and Jetpack Compose, prepared as a staged replacement for the current MAUI client.',
[string]$HomepageUrl = 'https://ikar.kusoft.xyz',
[string]$RepositoryUrl = 'https://git.kusoft.xyz/sevenhill/Ikar.git',
[string]$Platform = 'android',
[string]$PackageKind = 'apk',
[string]$Channel = 'stable'
[string]$Channel = 'stable',
[switch]$Unlisted
)
$ErrorActionPreference = 'Stop'
Set-Location (Join-Path $PSScriptRoot '..')
$repoRoot = Split-Path -Parent $PSScriptRoot
$androidRoot = Join-Path $repoRoot 'src\Ikar.AndroidKotlin'
$prepareScript = Join-Path $androidRoot 'scripts\prepare-argus-publication.ps1'
$publishScript = Join-Path $androidRoot 'scripts\publish-release.ps1'
$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
if (-not (Test-Path $prepareScript)) {
throw "Argus preparation script was not found: $prepareScript"
}
if (-not (Test-Path $publishScript)) {
throw "Argus SSH publication script was not found: $publishScript"
}
dotnet publish $projectPath -f net10.0-android -c Release -p:AndroidPackageFormat=apk -p:UseSharedCompilation=false -o $publishDir
Push-Location $androidRoot
try {
$prepareArgs = @(
'-Slug', $Slug,
'-Name', $AppName,
'-Summary', $Summary,
'-Description', $Description,
'-Channel', $Channel,
'-Platform', $Platform,
'-Notes', $Notes,
'-RepositoryUrl', $RepositoryUrl,
'-HomepageUrl', $HomepageUrl
)
if ($Unlisted) {
$prepareArgs += '-Unlisted'
}
$sourceApk = Join-Path $publishDir 'com.seven.ikar-Signed.apk'
if (-not (Test-Path $sourceApk)) {
throw "Signed APK not found: $sourceApk"
& $prepareScript @prepareArgs
if ($LASTEXITCODE -ne 0) {
throw "Argus package preparation failed with exit code $LASTEXITCODE."
}
$metadataPath = Get-ChildItem -Path (Join-Path $androidRoot 'build\argus-publication') `
-Recurse `
-Filter 'argus-publication.json' |
Sort-Object LastWriteTimeUtc -Descending |
Select-Object -First 1
if ($null -eq $metadataPath) {
throw 'Prepared Argus publication metadata was not found.'
}
$metadata = Get-Content -LiteralPath $metadataPath.FullName -Raw | ConvertFrom-Json
& $publishScript `
-SshHost $ServerHost `
-SshUsername $Username `
-SshPassword $Password `
-PublicBaseUrl $ArgusBaseUrl `
-Slug $metadata.slug `
-Name $metadata.name `
-Summary $metadata.summary `
-Description $metadata.description `
-AndroidPackageName $metadata.androidPackageName `
-Version $metadata.version `
-AndroidVersionCode $metadata.androidVersionCode `
-PackagePath $metadata.packagePath `
-Channel $metadata.channel `
-Platform $metadata.platform `
-PackageKind $PackageKind `
-Notes $metadata.notes `
-RepositoryUrl $metadata.repositoryUrl `
-HomepageUrl $metadata.homepageUrl `
-Unlisted:([bool](-not $metadata.isListed))
} finally {
Pop-Location
}
$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
@@ -1,372 +1,65 @@
# Требования к публикации приложений в Argus
# Argus publication requirements for Ikar Android
Этот документ фиксирует текущие требования публикации на основе фактического кода `Argus` и Android-клиента `Argus Store` в этом репозитории. Он разделяет:
This repository follows the shared Argus publication instruction:
1. жёсткие требования, которые реально проверяет сервер;
2. дополнительные требования, без которых приложение не будет нормально устанавливаться через `Argus Store`;
3. условия, при которых `Argus Store` сможет предлагать автообновления.
`G:\Repos\Marketplace\ARGUS_PUBLICATION.md`
Если цель публикации именно в Android-клиент `Argus Store`, нужно одновременно соблюдать и серверные требования, и Android-специфичные требования из этого документа.
That file is the source of truth for publishing any new Ikar Android version to Argus.
Источники: `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`.
## Current rules
## 1. Что ИИ обязан подготовить перед публикацией
- Argus is a read-only public catalog for clients.
- Do not use an HTTP admin API, upload endpoint, token, or browser form for publication.
- Publish only through SSH to the Argus server.
- Store release files under `/srv/argus-data/Packages`.
- Store release metadata in SQLite database `/srv/argus-data/argus.db`.
- Update clients must call only the public manifest endpoint:
`GET https://argus.kusoft.xyz/api/apps/{slug}/manifest?platform={platform}&channel={channel}`.
Перед публикацией ИИ должен выдать:
## Ikar Android values
- путь к одному готовому release-файлу;
- `slug`;
- `name`;
- `summary`;
- `description`;
- `version`;
- `channel`;
- `platform`;
- `packageKind`;
- `notes` при наличии;
- `repositoryUrl` при наличии;
- `homepageUrl` при наличии;
- `androidPackageName` для Android-релизов;
- `androidVersionCode` для Android-релизов;
- решение, будет ли приложение публичным (`isListed=true`) или скрытым (`isListed=false`).
- `slug`: `ikar-android`
- `channel`: `stable`
- `platform`: `android`
- `packageKind`: `apk`
- public base URL: `https://argus.kusoft.xyz`
Причина: именно эти поля использует текущий publish-скрипт и серверные DTO.
Источник: `scripts/publish-release.ps1:2-46`, `Contracts/ArgusDtos.cs:62-94`.
## Local workflow
## 2. Жёсткие серверные требования
1. Build and stage a signed APK:
### 2.1. Требования к `slug`
```powershell
.\scripts\prepare-argus-publication.ps1
```
- `slug` обязателен.
- Разрешены только строчные латинские буквы, цифры и дефисы.
- Максимальная длина `slug``100` символов.
- Значение должно уже быть нормализовано; сервер не принимает произвольный mixed-case `slug`.
2. Publish the staged APK through SSH/SQLite:
Фактическое правило валидации:
```powershell
.\scripts\publish-release.ps1 `
-SshPassword "<ssh-password>" `
-Slug "ikar-android" `
-Name "Ikar" `
-Summary "<summary>" `
-Description "<description>" `
-Version "<semantic-version>" `
-PackagePath "<path-to-apk>"
```
- `SlugUtility.Normalize(...)` переводит строку в lowercase, заменяет любые неалфавитно-цифровые символы на `-`, схлопывает повторные дефисы и обрезает дефисы по краям.
- `SlugUtility.IsValid(...)` принимает только строки, которые уже совпадают со своим нормализованным вариантом и имеют длину до `100`.
3. Verify the public manifest:
Источник: `Infrastructure/SlugUtility.cs:7-39`, `Program.cs:341-345`, `Program.cs:393-397`.
```text
https://argus.kusoft.xyz/api/apps/ikar-android/manifest?platform=android&channel=stable
```
### 2.2. Обязательные поля метаданных приложения
## Client update behavior
Сервер отклоняет публикацию метаданных, если отсутствует хотя бы одно из полей:
The Ikar Android updater must:
- `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`.
- treat HTTP 404 from the manifest endpoint as "no compatible release";
- compare the installed app version to `release.version`;
- build the download URL as `https://argus.kusoft.xyz` plus `release.downloadPath` unless `downloadPath` is already absolute;
- download the APK to a temporary/cache file;
- verify the downloaded file with `release.sha256`;
- delete and reject the download on SHA-256 mismatch;
- submit the APK to the Android package installer;
- record the new installed version only after Android reports a successful install.
+4
View File
@@ -24,10 +24,14 @@
- Локальная release-подпись хранится в gitignored файлах `argus-signing.local.properties` и `signing/`.
- Единая координата публикации для клиента Икар: slug `ikar-android`, channel `stable`, platform `android`.
Именно этот manifest запрашивают текущий Android-клиент и серверный proxy обновлений.
- Публикация новых версий в Argus выполняется только по инструкции `G:\Repos\Marketplace\ARGUS_PUBLICATION.md`.
У Argus больше нет HTTP admin API/upload endpoint для публикации: файл релиза передается на Pi по SSH, кладется в `/srv/argus-data/Packages`, а запись релиза добавляется в `/srv/argus-data/argus.db`.
- Первый запуск локального signing pipeline:
- `.\scripts\new-argus-upload-keystore.ps1`
- Подготовка signed APK и metadata JSON для Argus:
- `.\scripts\prepare-argus-publication.ps1`
- Публикация уже подготовленного APK:
- `.\scripts\publish-release.ps1 -SshPassword "<ssh-password>" -Slug "ikar-android" -Name "Ikar" -Summary "<summary>" -Description "<description>" -Version "<version>" -PackagePath "<apk>"`
Скрипт подготовки собирает `release` APK, проверяет подпись и извлекает из самого APK:
- `androidPackageName`
+6 -2
View File
@@ -37,8 +37,8 @@ android {
applicationId = "com.seven.ikar.kotlin"
minSdk = 24
targetSdk = 36
versionCode = 180
versionName = "3.67"
versionCode = 209
versionName = "3.96"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables.useSupportLibrary = true
@@ -88,6 +88,7 @@ android {
dependencies {
val composeBom = platform("androidx.compose:compose-bom:2026.03.00")
val roomVersion = "2.8.4"
val media3Version = "1.10.1"
implementation("androidx.core:core-ktx:1.17.0")
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.9.4")
@@ -106,9 +107,12 @@ dependencies {
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("io.coil-kt:coil-gif: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("androidx.media3:media3-exoplayer:$media3Version")
implementation("androidx.media3:media3-ui:$media3Version")
implementation(composeBom)
androidTestImplementation(composeBom)
@@ -42,6 +42,41 @@
<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:host="ikar.kusoft.xyz"
android:pathPrefix="/join"
android:scheme="https" />
<data
android:host="ikar.kusoft.xyz"
android:pathPrefix="/chat"
android:scheme="https" />
<data
android:host="ikar.kusoft.xyz"
android:pathPrefix="/channel"
android:scheme="https" />
<data
android:host="ikar.kusoft.xyz"
android:pathPrefix="/@"
android:scheme="https" />
</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:host="join"
android:scheme="ikar" />
<data
android:host="chat"
android:scheme="ikar" />
<data
android:host="channel"
android:scheme="ikar" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
@@ -17,7 +17,9 @@ 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.AndroidActiveChatState
import com.seven.ikar.kotlin.core.push.AndroidAppVisibilityState
import com.seven.ikar.kotlin.core.push.ChatNotificationDismissalManager
import com.seven.ikar.kotlin.core.push.IncomingCallFullScreenSetupHelper
import com.seven.ikar.kotlin.core.push.IncomingCallFullScreenSetupResult
import com.seven.ikar.kotlin.core.push.InitialCallPermissionPromptStore
@@ -25,6 +27,7 @@ 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.core.network.ServerConfig
import com.seven.ikar.kotlin.feature.share.PendingExternalShare
import com.seven.ikar.kotlin.ui.SecureWindowFlagController
import kotlinx.coroutines.launch
@@ -95,6 +98,10 @@ class MainActivity : ComponentActivity() {
override fun onStart() {
super.onStart()
AndroidAppVisibilityState.setVisible(true)
lifecycleScope.launch {
runCatching { applicationContainer.pushRegistrationManager.registerCurrentDeviceAsync() }
}
ChatNotificationDismissalManager.cancelChatNotifications(this, AndroidActiveChatState.getActiveChatId())
enforceAppLockIfNeeded()
}
@@ -118,6 +125,10 @@ class MainActivity : ComponentActivity() {
}
applicationContainer.appLockState.setLocked(true)
if (applicationContainer.privacyRuntimeSettings.hasPasscode()) {
return
}
SecureWindowFlagController.setSecure(window, AppLockSecureToken, true)
val keyguardManager = getSystemService(KeyguardManager::class.java)
val unlockIntent = if (keyguardManager.isDeviceSecure) {
@@ -176,6 +187,10 @@ class MainActivity : ComponentActivity() {
private fun handleIncomingIntent(intent: Intent?) {
val incomingIntent = intent ?: return
if (incomingIntent.action == Intent.ACTION_VIEW) {
if (handleIkarDeepLink(incomingIntent.data)) {
return
}
val archiveUri = incomingIntent.data ?: incomingIntent.readSingleStream()
if (archiveUri != null) {
lifecycleScope.launch {
@@ -233,6 +248,65 @@ class MainActivity : ComponentActivity() {
}
}
private fun handleIkarDeepLink(uri: Uri?): Boolean {
val link = uri ?: return false
val scheme = link.scheme?.lowercase()
val host = link.host?.lowercase()
val messengerHost = Uri.parse(ServerConfig.MessengerBaseUrl).host?.lowercase()
val segments = link.pathSegments.orEmpty()
if (scheme in setOf("http", "https") && host == messengerHost) {
when (segments.firstOrNull()) {
"join" -> {
applicationContainer.incomingLinkNavigationStore.setPendingInvite(segments.getOrNull(1))
return true
}
"chat" -> {
applicationContainer.incomingPushNavigationStore.setPendingChat(
chatId = segments.getOrNull(1),
messageId = link.getQueryParameter("messageId")
)
return true
}
"channel" -> {
applicationContainer.incomingLinkNavigationStore.setPendingPublicChannel(segments.getOrNull(1))
return true
}
}
segments.firstOrNull()?.takeIf { it.startsWith("@") }?.let { usernameSegment ->
applicationContainer.incomingLinkNavigationStore.setPendingPublicChannel(usernameSegment)
return true
}
}
if (scheme == "ikar") {
when (host) {
"join" -> {
applicationContainer.incomingLinkNavigationStore.setPendingInvite(segments.firstOrNull())
return true
}
"chat" -> {
applicationContainer.incomingPushNavigationStore.setPendingChat(
chatId = segments.firstOrNull(),
messageId = link.getQueryParameter("messageId")
)
return true
}
"channel" -> {
applicationContainer.incomingLinkNavigationStore.setPendingPublicChannel(segments.firstOrNull())
return true
}
}
}
return false
}
private suspend fun extractPendingShare(intent: Intent): PendingExternalShare? {
val sharedText = intent.getStringExtra(Intent.EXTRA_TEXT)?.trim()?.takeIf { it.isNotBlank() }
val attachmentUris = when (intent.action) {
@@ -8,6 +8,7 @@ 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.navigation.IncomingLinkNavigationStore
import com.seven.ikar.kotlin.core.network.ApiClient
import com.seven.ikar.kotlin.core.network.ClientVersionProvider
import com.seven.ikar.kotlin.core.push.AndroidPushSettings
@@ -23,6 +24,7 @@ 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.ArgusAppUpdateClient
import com.seven.ikar.kotlin.core.update.ArgusAppUpdateManager
import com.seven.ikar.kotlin.feature.archive.AndroidArchiveFileService
import com.seven.ikar.kotlin.feature.archive.IncomingArchiveStore
@@ -32,6 +34,7 @@ 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.ChatDraftStore
import com.seven.ikar.kotlin.feature.chat.ChatScrollStateStore
import com.seven.ikar.kotlin.feature.share.IncomingShareStore
import com.seven.ikar.kotlin.feature.transfer.TransferQueueRepository
@@ -74,6 +77,8 @@ class AppContainer(context: Context) {
@Volatile
private var activeHttpClient: OkHttpClient = buildHttpClient(appSettings.loadSnapshot())
@Volatile
private var activeProxySignature: String = proxySignature(appSettings.loadSnapshot())
val httpClient: OkHttpClient
get() = activeHttpClient
@@ -89,7 +94,10 @@ class AppContainer(context: Context) {
dao = localDatabase.cachedChatDao(),
json = json
)
val appUpdateManager = ArgusAppUpdateManager(appContext)
val appUpdateManager = ArgusAppUpdateManager(
context = appContext,
client = ArgusAppUpdateClient(proxyProvider = { buildProxy(appSettings.loadSnapshot()) })
)
val apiClient = ApiClient(
httpClientProvider = { httpClient },
json = json,
@@ -114,13 +122,20 @@ class AppContainer(context: Context) {
scheduler = transferWorkScheduler
)
val chatScrollStateStore = ChatScrollStateStore(appContext, json)
val chatDraftStore = ChatDraftStore(appContext)
val incomingShareStore = IncomingShareStore()
val incomingArchiveStore = IncomingArchiveStore()
val incomingPushNavigationStore = IncomingPushNavigationStore()
val incomingLinkNavigationStore = IncomingLinkNavigationStore()
val incomingCallInviteStore = IncomingCallInviteStore()
val voiceNotePlayerService = AndroidVoiceNotePlayerService()
val voiceNotePlayerService = AndroidVoiceNotePlayerService(appContext)
val voiceNoteRecorderService = AndroidVoiceNoteRecorderService(appContext)
val realtimeService = RealtimeService(clientVersionProvider)
val realtimeService = RealtimeService(
clientVersionProvider = clientVersionProvider,
configureHttpClientBuilder = { builder ->
buildProxy(appSettings.loadSnapshot())?.let(builder::proxy)
}
)
val webRtcCallService = AndroidWebRtcCallService(appContext)
val callTonePlayer = AndroidCallTonePlayer()
val callManager = CallManager(
@@ -141,7 +156,12 @@ class AppContainer(context: Context) {
init {
applicationScope.launch {
appSettings.snapshot.collect { snapshot ->
val nextProxySignature = proxySignature(snapshot)
activeHttpClient = buildHttpClient(snapshot)
if (nextProxySignature != activeProxySignature) {
activeProxySignature = nextProxySignature
realtimeService.reconnectForNetworkSettings()
}
runCatching {
attachmentFileService.enforceCachePolicy(
maxCacheMb = snapshot.maxCacheMb,
@@ -180,4 +200,12 @@ class AppContainer(context: Context) {
}
return Proxy(proxyType, InetSocketAddress(host, settings.proxyPort))
}
private fun proxySignature(settings: AppSettingsSnapshot): String =
listOf(
settings.proxyEnabled.toString(),
settings.proxyType.name,
settings.proxyHost.trim(),
settings.proxyPort.toString()
).joinToString(separator = "|")
}
@@ -1,10 +1,16 @@
package com.seven.ikar.kotlin.app
import android.net.Uri
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.Button
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
@@ -18,6 +24,9 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
@@ -36,16 +45,17 @@ import com.seven.ikar.kotlin.core.model.ChatType
import com.seven.ikar.kotlin.core.settings.AppLocaleManager
import com.seven.ikar.kotlin.feature.archive.ArchiveExtractorScreen
import com.seven.ikar.kotlin.feature.archive.ArchiveExtractorViewModel
import com.seven.ikar.kotlin.feature.bots.BotEditorScreen
import com.seven.ikar.kotlin.feature.bots.BotEditorViewModel
import com.seven.ikar.kotlin.feature.bots.BotsScreen
import com.seven.ikar.kotlin.feature.bots.BotsViewModel
import com.seven.ikar.kotlin.feature.browser.InAppBrowserScreen
import com.seven.ikar.kotlin.feature.call.CallScreen
import com.seven.ikar.kotlin.core.push.AndroidActiveChatState
import com.seven.ikar.kotlin.core.push.ChatNotificationDismissalManager
import com.seven.ikar.kotlin.feature.chat.ChatScreen
import com.seven.ikar.kotlin.feature.chat.ChatViewModel
import com.seven.ikar.kotlin.feature.chatinfo.ChatInfoScreen
import com.seven.ikar.kotlin.feature.chatinfo.ChatInfoViewModel
import com.seven.ikar.kotlin.feature.chatinfo.ChatMaterialFilter
import com.seven.ikar.kotlin.feature.chatinfo.ChatMaterialsScreen
import com.seven.ikar.kotlin.feature.chatinfo.ChatMaterialsViewModel
import com.seven.ikar.kotlin.feature.chats.ChatsScreen
import com.seven.ikar.kotlin.feature.chats.ChatsViewModel
import com.seven.ikar.kotlin.feature.contacts.ContactsScreen
@@ -58,6 +68,8 @@ import com.seven.ikar.kotlin.feature.folders.ChatFoldersScreen
import com.seven.ikar.kotlin.feature.folders.ChatFoldersViewModel
import com.seven.ikar.kotlin.feature.forward.ForwardScreen
import com.seven.ikar.kotlin.feature.forward.ForwardViewModel
import com.seven.ikar.kotlin.feature.join.JoinInviteScreen
import com.seven.ikar.kotlin.feature.join.JoinInviteViewModel
import com.seven.ikar.kotlin.feature.login.LoginScreen
import com.seven.ikar.kotlin.feature.login.LoginViewModel
import com.seven.ikar.kotlin.feature.mainmenu.MainMenuViewModel
@@ -78,16 +90,16 @@ import com.seven.ikar.kotlin.feature.settings.ChatNotificationSettingsViewModel
import com.seven.ikar.kotlin.feature.settings.ChatWallpaperScreen
import com.seven.ikar.kotlin.feature.settings.ChatSettingsViewModel
import com.seven.ikar.kotlin.feature.settings.AppSettingsViewModel
import com.seven.ikar.kotlin.feature.settings.BlockedUsersPlaceholderScreen
import com.seven.ikar.kotlin.feature.settings.DataStorageSettingsScreen
import com.seven.ikar.kotlin.feature.settings.LanguagePlaceholderScreen
import com.seven.ikar.kotlin.feature.settings.NotificationSettingsScreen
import com.seven.ikar.kotlin.feature.settings.NotificationSettingsViewModel
import com.seven.ikar.kotlin.feature.settings.PrivacyExceptionsPlaceholderScreen
import com.seven.ikar.kotlin.feature.settings.ProxyPlaceholderScreen
import com.seven.ikar.kotlin.feature.settings.ProxySettingsScreen
import com.seven.ikar.kotlin.feature.settings.SettingsScreen
import com.seven.ikar.kotlin.feature.settings.SettingsViewModel
import com.seven.ikar.kotlin.feature.settings.ThemePlaceholderScreen
import com.seven.ikar.kotlin.feature.settings.LanguageSettingsScreen
import com.seven.ikar.kotlin.feature.settings.ThemeSettingsScreen
import com.seven.ikar.kotlin.feature.share.ShareTargetScreen
import com.seven.ikar.kotlin.feature.share.ShareTargetViewModel
import com.seven.ikar.kotlin.feature.stories.StoriesScreen
import com.seven.ikar.kotlin.feature.stories.StoriesViewModel
import com.seven.ikar.kotlin.feature.stories.StoryComposerScreen
@@ -95,10 +107,13 @@ import com.seven.ikar.kotlin.feature.update.AppUpdateScreen
import com.seven.ikar.kotlin.feature.update.AppUpdateViewModel
import com.seven.ikar.kotlin.ui.LocalIkarAccessToken
import com.seven.ikar.kotlin.ui.LocalUnreadChatsCount
import com.seven.ikar.kotlin.ui.SecureWindowEffect
import com.seven.ikar.kotlin.ui.theme.IkarKotlinTheme
import kotlinx.coroutines.launch
private const val ChatRoutePattern = "chat/{chatId}?messageId={messageId}&scrollToLatest={scrollToLatest}&scrollRequestId={scrollRequestId}"
private const val JoinInviteRoutePattern = "join/{token}"
private const val BrowserRoutePattern = "browser?url={url}"
private fun chatRoute(
chatId: String,
@@ -117,6 +132,10 @@ private fun chatRoute(
return if (query.isEmpty()) route else "$route?${query.joinToString("&")}"
}
private fun joinInviteRoute(token: String): String = "join/${Uri.encode(token)}"
private fun browserRoute(url: String): String = "browser?url=${Uri.encode(url)}"
@Composable
fun IkarApp() {
val context = LocalContext.current
@@ -128,8 +147,10 @@ fun IkarApp() {
val pendingArchive by application.container.incomingArchiveStore.pendingArchive.collectAsStateWithLifecycle()
val pendingCallInvite by application.container.incomingCallInviteStore.pendingInvite.collectAsStateWithLifecycle()
val pendingPushNavigation by application.container.incomingPushNavigationStore.pendingNavigation.collectAsStateWithLifecycle()
val pendingLinkNavigation by application.container.incomingLinkNavigationStore.pendingNavigation.collectAsStateWithLifecycle()
val appSettings by application.container.appSettings.snapshot.collectAsStateWithLifecycle()
val isAppLocked by application.container.appLockState.isLocked.collectAsStateWithLifecycle()
SecureWindowEffect(enabled = isAppLocked)
val mainMenuViewModel: MainMenuViewModel = viewModel(
factory = viewModelFactory {
initializer { MainMenuViewModel(application.container) }
@@ -201,12 +222,13 @@ fun IkarApp() {
}
LoginScreen(
state = state,
onEmailChanged = viewModel::updateEmail,
onPhoneNumberChanged = viewModel::updatePhoneNumber,
onDisplayNameChanged = viewModel::updateDisplayName,
onVerificationCodeChanged = viewModel::updateVerificationCode,
onRequestCodeClick = viewModel::requestCode,
onVerifyCodeClick = viewModel::verifyCode,
onChangePhoneNumberClick = viewModel::changePhoneNumber
onChangeEmailClick = viewModel::changeEmail
)
}
@@ -218,6 +240,16 @@ fun IkarApp() {
)
val state by viewModel.uiState.collectAsStateWithLifecycle()
val chatSettings by application.container.chatSettings.snapshot.collectAsStateWithLifecycle()
val lifecycleOwner = LocalLifecycleOwner.current
DisposableEffect(lifecycleOwner) {
val observer = LifecycleEventObserver { _, event ->
if (event == Lifecycle.Event.ON_RESUME) {
viewModel.refresh()
}
}
lifecycleOwner.lifecycle.addObserver(observer)
onDispose { lifecycleOwner.lifecycle.removeObserver(observer) }
}
LaunchedEffect(state.pendingShareNavigation?.requestId) {
val target = state.pendingShareNavigation ?: return@LaunchedEffect
viewModel.consumePendingShareNavigation()
@@ -249,6 +281,11 @@ fun IkarApp() {
onPinSelectedChatsClick = viewModel::togglePinSelectedChats,
onArchiveSelectedChatsClick = viewModel::archiveSelectedChats,
onMuteSelectedChatsClick = viewModel::muteSelectedChats,
onTogglePinChat = viewModel::togglePinChat,
onToggleArchiveChat = viewModel::toggleArchiveChat,
onToggleMuteChat = viewModel::toggleMuteChat,
onMarkChatRead = viewModel::markChatRead,
onSwipeDeleteChat = viewModel::showSwipeDeleteDialog,
onDismissDeleteDialog = viewModel::dismissDeleteDialog,
onDeleteForEveryoneChanged = viewModel::updateDeleteForEveryone,
onConfirmDeleteSelected = viewModel::confirmDeleteSelected,
@@ -269,10 +306,99 @@ fun IkarApp() {
onSettingsClick = { navigatePrimary("settings") },
onNotificationsClick = { navController.navigate("settings/notifications") },
onGlobalSearchClick = { navController.navigate("search") },
onFilterChanged = viewModel::updateFilter,
onQueryChanged = viewModel::updateQuery
)
}
composable("share") {
val share = pendingShare
if (share == null) {
LaunchedEffect(Unit) {
navigatePrimary("chats")
}
} else {
val viewModel: ShareTargetViewModel = viewModel(
key = "share-target-${share.id}",
factory = viewModelFactory {
initializer { ShareTargetViewModel(application.container, share) }
}
)
val state by viewModel.uiState.collectAsStateWithLifecycle()
LaunchedEffect(state.pendingChatId) {
val chatId = state.pendingChatId ?: return@LaunchedEffect
viewModel.consumePendingChatNavigation()
application.container.incomingShareStore.clear()
navController.navigate(
chatRoute(
chatId = chatId,
scrollToLatest = true,
scrollRequestId = System.currentTimeMillis()
)
) {
popUpTo("share") {
inclusive = true
}
launchSingleTop = true
}
}
ShareTargetScreen(
state = state,
onBackClick = {
application.container.incomingShareStore.clear()
navController.popBackStack()
},
onQueryChanged = viewModel::updateQuery,
onRefresh = viewModel::refresh,
onUserClick = viewModel::sendToUser
)
}
}
composable(
route = BrowserRoutePattern,
arguments = listOf(
navArgument("url") {
type = NavType.StringType
nullable = true
defaultValue = null
}
)
) { backStackEntry ->
val url = backStackEntry.arguments?.getString("url").orEmpty()
InAppBrowserScreen(
url = url,
onBackClick = { navController.popBackStack() }
)
}
composable(
route = JoinInviteRoutePattern,
arguments = listOf(navArgument("token") { type = NavType.StringType })
) { backStackEntry ->
val token = backStackEntry.arguments?.getString("token").orEmpty()
val viewModel: JoinInviteViewModel = viewModel(
key = "join-invite-$token",
factory = viewModelFactory {
initializer { JoinInviteViewModel(application.container, token) }
}
)
val state by viewModel.uiState.collectAsStateWithLifecycle()
LaunchedEffect(state.pendingChatId) {
val chatId = state.pendingChatId ?: return@LaunchedEffect
viewModel.consumePendingChatNavigation()
navController.navigate(chatRoute(chatId = chatId, scrollToLatest = true)) {
popUpTo(JoinInviteRoutePattern) { inclusive = true }
launchSingleTop = true
}
}
JoinInviteScreen(
state = state,
onBackClick = { navigatePrimary("chats") },
onRetryClick = viewModel::join
)
}
composable("search") {
val viewModel: GlobalSearchViewModel = viewModel(
factory = viewModelFactory {
@@ -345,6 +471,8 @@ fun IkarApp() {
state = state,
viewModel = viewModel,
chatSettings = chatSettings,
largeEmojiEnabled = appSettings.largeEmoji,
animatedBackgroundsEnabled = appSettings.animatedBackgrounds,
onBackClick = { navController.popBackStack() },
onPeerProfileClick = state.peer?.id?.let { userId ->
{ navController.navigate("profile/user/$userId") }
@@ -359,6 +487,14 @@ fun IkarApp() {
navController.navigate("chat/$chatId/notifications?title=$encodedTitle")
},
onChatWallpaperClick = { navController.navigate("settings/chats/wallpaper") },
onOpenLinkedDiscussion = { discussionChatId, discussionMessageId ->
navController.navigate(chatRoute(discussionChatId, messageId = discussionMessageId)) {
launchSingleTop = true
}
},
onOpenUrl = { url ->
navController.navigate(browserRoute(url))
},
onForwardMessage = { message ->
if (!message.hasProtectedContent) {
navController.navigate("forward/${message.chatId}/${message.id}")
@@ -424,6 +560,8 @@ fun IkarApp() {
onToggleCanPin = viewModel::toggleCanPin,
onToggleCanDelete = viewModel::toggleCanDelete,
onToggleCanInvite = viewModel::toggleCanInvite,
onTransferOwnership = viewModel::transferOwnership,
onRemoveMember = viewModel::removeMember,
onMemberSearchChanged = viewModel::updateMemberSearchQuery,
onAddMember = { user -> viewModel.addMember(user, asAdmin = false) },
onAddAdmin = { user -> viewModel.addMember(user, asAdmin = true) },
@@ -450,12 +588,47 @@ fun IkarApp() {
onRefreshClick = viewModel::refresh,
onMembersClick = { navController.navigate("chat/$chatId/members") },
onSearchClick = { navController.navigate("search") },
onProfileTitleChanged = viewModel::updateProfileTitle,
onProfileDescriptionChanged = viewModel::updateProfileDescription,
onProfilePublicUsernameChanged = viewModel::updateProfilePublicUsername,
onMaterialsClick = { filter -> navController.navigate("chat/$chatId/materials/${filter.routeValue}") },
onInviteTitleChanged = viewModel::updateInviteTitle,
onInviteJoinRequestChanged = viewModel::updateInviteCreatesJoinRequest,
onInviteExpiresInHoursChanged = viewModel::updateInviteExpiresInHours,
onInviteMaxUsesChanged = viewModel::updateInviteMaxUses,
onInviteMemberLimitChanged = viewModel::updateInviteMemberLimit,
onCreateInviteClick = viewModel::createInviteLink,
onRevokeInviteClick = viewModel::revokeInviteLink,
onTopicTitleChanged = viewModel::updateTopicTitle,
onCreateTopicClick = viewModel::createTopic
onCreateTopicClick = viewModel::createTopic,
onLinkDiscussionClick = viewModel::setLinkedDiscussion,
onClearDiscussionClick = viewModel::clearLinkedDiscussion
)
}
composable(
route = "chat/{chatId}/materials/{filter}",
arguments = listOf(
navArgument("chatId") { type = NavType.StringType },
navArgument("filter") { type = NavType.StringType }
)
) { backStackEntry ->
val chatId = backStackEntry.arguments?.getString("chatId").orEmpty()
val filter = ChatMaterialFilter.fromRoute(backStackEntry.arguments?.getString("filter"))
val viewModel: ChatMaterialsViewModel = viewModel(
key = "chat-materials-$chatId-${filter.routeValue}",
factory = viewModelFactory {
initializer { ChatMaterialsViewModel(application.container, chatId, filter) }
}
)
val state by viewModel.uiState.collectAsStateWithLifecycle()
ChatMaterialsScreen(
state = state,
onBackClick = { navController.popBackStack() },
onLoadMore = viewModel::loadMore,
onMessageClick = { message ->
navController.navigate(chatRoute(chatId, messageId = message.id))
}
)
}
@@ -510,7 +683,6 @@ fun IkarApp() {
onBackClick = { navController.popBackStack() },
onDisplayNameChanged = viewModel::updateDisplayName,
onAboutChanged = viewModel::updateAbout,
onSaveClick = viewModel::save,
onDeleteAvatarClick = viewModel::deleteAvatar,
onAvatarSelected = viewModel::uploadAvatar
)
@@ -560,9 +732,13 @@ fun IkarApp() {
onQueryChanged = viewModel::updateQuery,
onRefreshClick = viewModel::refresh,
onUserClick = viewModel::handleUserTap,
onChannelClick = viewModel::openPublicChannel,
onBeginGroupSelection = viewModel::beginGroupSelection,
onBeginChannelSelection = viewModel::beginChannelSelection,
onCancelSelection = viewModel::cancelSelection,
onGroupBackClick = viewModel::previousGroupWizardStep,
onGroupTitleChanged = viewModel::updateGroupTitle,
onContinueGroupWizard = viewModel::continueGroupWizard,
onChannelBackClick = viewModel::previousChannelWizardStep,
onChannelTitleChanged = viewModel::updateChannelTitle,
onChannelDescriptionChanged = viewModel::updateChannelDescription,
@@ -621,7 +797,6 @@ fun IkarApp() {
onChatSettingsClick = { navController.navigate("settings/chats") },
onPrivacyClick = { navController.navigate("settings/privacy") },
onNotificationsClick = { navController.navigate("settings/notifications") },
onBotsClick = { navController.navigate("bots") },
onFoldersClick = { navController.navigate("settings/folders") },
onDevicesClick = { navController.navigate("settings/devices") },
onUpdatesClick = { navController.navigate("settings/update") },
@@ -643,21 +818,19 @@ fun IkarApp() {
PrivacySecurityScreen(
state = state,
onBackClick = { navController.popBackStack() },
onRefreshClick = viewModel::refresh,
onBlockedUsersClick = { navController.navigate("settings/privacy/blocked") },
onExceptionsClick = { navController.navigate("settings/privacy/exceptions") },
onUpdate = viewModel::update
onUpdate = viewModel::update,
onUserSearchQueryChanged = viewModel::updateUserSearchQuery,
onBlockUser = viewModel::blockUser,
onUnblockUser = viewModel::unblockUser,
onAlwaysAllowUser = viewModel::alwaysAllowUser,
onNeverAllowUser = viewModel::neverAllowUser,
onRemoveAlwaysAllow = viewModel::removeAlwaysAllow,
onRemoveNeverAllow = viewModel::removeNeverAllow,
onEnablePasscode = viewModel::enablePasscode,
onDisablePasscode = viewModel::disablePasscode
)
}
composable("settings/privacy/blocked") {
BlockedUsersPlaceholderScreen(onBackClick = { navController.popBackStack() })
}
composable("settings/privacy/exceptions") {
PrivacyExceptionsPlaceholderScreen(onBackClick = { navController.popBackStack() })
}
composable("settings/data-storage") {
val viewModel: AppSettingsViewModel = viewModel(
factory = viewModelFactory {
@@ -681,7 +854,7 @@ fun IkarApp() {
}
)
val state by viewModel.uiState.collectAsStateWithLifecycle()
ThemePlaceholderScreen(
ThemeSettingsScreen(
state = state,
onBackClick = { navController.popBackStack() },
onSnapshotChanged = viewModel::updateSnapshot,
@@ -696,7 +869,7 @@ fun IkarApp() {
}
)
val state by viewModel.uiState.collectAsStateWithLifecycle()
LanguagePlaceholderScreen(
LanguageSettingsScreen(
state = state,
onBackClick = { navController.popBackStack() },
onSnapshotChanged = viewModel::updateSnapshot
@@ -710,7 +883,7 @@ fun IkarApp() {
}
)
val state by viewModel.uiState.collectAsStateWithLifecycle()
ProxyPlaceholderScreen(
ProxySettingsScreen(
state = state,
onBackClick = { navController.popBackStack() },
onSnapshotChanged = viewModel::updateSnapshot
@@ -724,6 +897,16 @@ fun IkarApp() {
}
)
val state by viewModel.uiState.collectAsStateWithLifecycle()
val lifecycleOwner = LocalLifecycleOwner.current
DisposableEffect(lifecycleOwner) {
val observer = LifecycleEventObserver { _, event ->
if (event == Lifecycle.Event.ON_RESUME) {
viewModel.refresh()
}
}
lifecycleOwner.lifecycle.addObserver(observer)
onDispose { lifecycleOwner.lifecycle.removeObserver(observer) }
}
StoriesScreen(
state = state,
onBackClick = { navController.popBackStack() },
@@ -734,6 +917,11 @@ fun IkarApp() {
onCloseStoryPreview = viewModel::closeStoryPreview,
onNextStory = viewModel::openNextStory,
onPreviousStory = viewModel::openPreviousStory,
onStoryReplyTextChanged = viewModel::updateStoryReplyText,
onSendStoryReply = viewModel::sendStoryReply,
onSendStoryReaction = viewModel::sendStoryReaction,
onShowStoryViews = viewModel::loadStoryViews,
onCloseStoryViews = viewModel::closeStoryViews,
onRetryTransfer = viewModel::retryTransfer,
onCancelTransfer = viewModel::cancelTransfer
)
@@ -767,6 +955,11 @@ fun IkarApp() {
},
onNextStory = viewModel::openNextStory,
onPreviousStory = viewModel::openPreviousStory,
onStoryReplyTextChanged = viewModel::updateStoryReplyText,
onSendStoryReply = viewModel::sendStoryReply,
onSendStoryReaction = viewModel::sendStoryReaction,
onShowStoryViews = viewModel::loadStoryViews,
onCloseStoryViews = viewModel::closeStoryViews,
onRetryTransfer = viewModel::retryTransfer,
onCancelTransfer = viewModel::cancelTransfer
)
@@ -805,8 +998,7 @@ fun IkarApp() {
DevicesScreen(
state = state,
onBackClick = { navController.popBackStack() },
onRefreshClick = viewModel::refresh,
onRevokeClick = viewModel::revoke
onRefreshClick = viewModel::refresh
)
}
@@ -959,82 +1151,13 @@ fun IkarApp() {
NotificationSettingsScreen(
state = state,
messageSoundOptions = viewModel.messageSoundOptions,
callRingtoneOptions = viewModel.callRingtoneOptions,
repeatIntervalOptions = viewModel.repeatIntervalOptions,
onBackClick = { navController.popBackStack() },
onSnapshotChanged = { snapshot -> viewModel.updateSnapshot { snapshot } },
onMessageSoundChanged = viewModel::setMessageSound,
onCallRingtoneChanged = viewModel::setCallRingtone,
onRepeatIntervalChanged = viewModel::setRepeatInterval,
onSaveClick = viewModel::save,
onResetClick = viewModel::reset
)
}
composable("bots") {
val viewModel: BotsViewModel = viewModel(
factory = viewModelFactory {
initializer { BotsViewModel(application.container) }
}
)
val state by viewModel.uiState.collectAsStateWithLifecycle()
BotsScreen(
state = state,
onBackClick = { navController.popBackStack() },
onCreateBotClick = { navController.navigate("bots/editor") },
onBotClick = { bot -> navController.navigate("bots/editor?botId=${bot.id}") }
)
}
composable(
route = "bots/editor?botId={botId}",
arguments = listOf(
navArgument("botId") {
type = NavType.StringType
nullable = true
defaultValue = null
}
)
) { backStackEntry ->
val botId = backStackEntry.arguments?.getString("botId")
val viewModel: BotEditorViewModel = viewModel(
key = "bot-editor-${botId ?: "new"}",
factory = viewModelFactory {
initializer { BotEditorViewModel(application.container, botId) }
}
)
val state by viewModel.uiState.collectAsStateWithLifecycle()
LaunchedEffect(state.pendingChatId) {
val chatId = state.pendingChatId ?: return@LaunchedEffect
viewModel.consumePendingChatNavigation()
navController.navigate("chat/$chatId")
}
LaunchedEffect(state.deleteCompleted) {
if (!state.deleteCompleted) {
return@LaunchedEffect
}
viewModel.consumeDeleteCompleted()
navController.navigate("bots") {
popUpTo("bots") {
inclusive = true
}
}
}
BotEditorScreen(
state = state,
onBackClick = { navController.popBackStack() },
onDisplayNameChanged = viewModel::updateDisplayName,
onUsernameChanged = viewModel::updateUsername,
onAboutChanged = viewModel::updateAbout,
onCommandsChanged = viewModel::updateCommandsText,
onEnabledChanged = viewModel::updateEnabled,
onSaveClick = viewModel::save,
onRegenerateTokenClick = viewModel::regenerateToken,
onDeleteBotClick = viewModel::deleteBot,
onOpenChatClick = viewModel::openChat
)
}
}
LaunchedEffect(pendingShare?.id, currentBackStackEntry?.destination?.route) {
@@ -1044,11 +1167,13 @@ fun IkarApp() {
if (application.container.sessionStore.read() == null) {
return@LaunchedEffect
}
if (currentBackStackEntry?.destination?.route == "chats") {
if (currentBackStackEntry?.destination?.route == "share") {
return@LaunchedEffect
}
navigatePrimary("chats")
navController.navigate("share") {
launchSingleTop = true
}
}
LaunchedEffect(pendingArchive?.id, currentBackStackEntry?.destination?.route) {
@@ -1082,6 +1207,48 @@ fun IkarApp() {
application.container.incomingCallInviteStore.clear(invite.requestId)
}
LaunchedEffect(
pendingLinkNavigation?.requestId,
currentBackStackEntry?.destination?.route,
currentBackStackEntry?.arguments?.getString("token")
) {
val pendingLink = pendingLinkNavigation ?: return@LaunchedEffect
if (application.container.sessionStore.read() == null) {
return@LaunchedEffect
}
pendingLink.publicChannelUsername?.let { username ->
runCatching {
application.container.executeAuthorized { session ->
application.container.apiClient.getPublicChannelByUsername(session.accessToken, username)
}
}.onSuccess { chat ->
navController.navigate(chatRoute(chatId = chat.id)) {
launchSingleTop = true
}
}
application.container.incomingLinkNavigationStore.clear(pendingLink.requestId)
return@LaunchedEffect
}
val inviteToken = pendingLink.inviteToken ?: run {
application.container.incomingLinkNavigationStore.clear(pendingLink.requestId)
return@LaunchedEffect
}
val currentRoute = currentBackStackEntry?.destination?.route
val currentToken = currentBackStackEntry?.arguments?.getString("token")
val route = joinInviteRoute(inviteToken)
if (currentRoute == JoinInviteRoutePattern && currentToken == inviteToken) {
application.container.incomingLinkNavigationStore.clear(pendingLink.requestId)
return@LaunchedEffect
}
navController.navigate(route) {
launchSingleTop = true
}
application.container.incomingLinkNavigationStore.clear(pendingLink.requestId)
}
LaunchedEffect(
pendingPushNavigation?.requestId,
currentBackStackEntry?.destination?.route,
@@ -1125,6 +1292,9 @@ fun IkarApp() {
null
}
AndroidActiveChatState.setActiveChatId(activeChatId)
if (activeChatId != null) {
ChatNotificationDismissalManager.cancelChatNotifications(context, activeChatId)
}
}
activeCall?.let { call ->
@@ -1137,7 +1307,16 @@ fun IkarApp() {
}
if (isAppLocked) {
AppLockedOverlay()
AppLockedOverlay(
onUnlock = { passcode ->
if (application.container.privacyRuntimeSettings.verifyPasscode(passcode)) {
application.container.appLockState.setLocked(false)
true
} else {
false
}
}
)
}
}
}
@@ -1146,17 +1325,52 @@ fun IkarApp() {
}
@Composable
private fun AppLockedOverlay() {
private fun AppLockedOverlay(onUnlock: (String) -> Boolean) {
var passcode by rememberSaveable { mutableStateOf("") }
var errorMessage by rememberSaveable { mutableStateOf<String?>(null) }
Box(
modifier = Modifier
.fillMaxSize()
.background(Color(0xFF101820)),
contentAlignment = Alignment.Center
) {
Text(
text = "Икар заблокирован",
style = MaterialTheme.typography.headlineLarge,
color = Color.White
)
Column(
modifier = Modifier.padding(horizontal = 28.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(14.dp)
) {
Text(
text = "Икар заблокирован",
style = MaterialTheme.typography.headlineLarge,
color = Color.White
)
OutlinedTextField(
value = passcode,
onValueChange = {
passcode = it.filter(Char::isDigit).take(12)
errorMessage = null
},
singleLine = true,
visualTransformation = PasswordVisualTransformation(),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.NumberPassword),
label = { Text("Код-пароль") }
)
errorMessage?.let {
Text(it, color = Color(0xFFFFB4AB), style = MaterialTheme.typography.bodySmall)
}
Button(
enabled = passcode.length >= 4,
onClick = {
if (onUnlock(passcode)) {
passcode = ""
errorMessage = null
} else {
errorMessage = "Неверный код"
}
}
) {
Text("Разблокировать")
}
}
}
}
@@ -1,8 +1,11 @@
package com.seven.ikar.kotlin.app
import android.app.Application
import android.os.Build
import coil.ImageLoader
import coil.ImageLoaderFactory
import coil.decode.GifDecoder
import coil.decode.ImageDecoderDecoder
import coil.disk.DiskCache
import coil.memory.MemoryCache
import coil.request.CachePolicy
@@ -47,5 +50,12 @@ class IkarNativeApp : Application(), ImageLoaderFactory {
.networkCachePolicy(CachePolicy.ENABLED)
.respectCacheHeaders(false)
.crossfade(true)
.components {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
add(ImageDecoderDecoder.Factory())
} else {
add(GifDecoder.Factory())
}
}
.build()
}
@@ -67,22 +67,25 @@ class ResolvedContactsRepository(
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)
if (query.isNotBlank()) {
val remoteUsers = authorizedSessionManager.executeAuthorized { session ->
apiClient.searchUsers(session.accessToken, query)
}
remoteUsers.forEach { user ->
if (existingIds.add(user.id)) {
merged += user.toDiscoverItem()
}
}
}
remoteUsers.forEach { user ->
if (existingIds.add(user.id)) {
merged += DiscoverUserItem(
user = user,
displayName = user.displayName,
normalizedPhoneNumber = PhoneNumberNormalizer.normalize(user.phoneNumber) ?: ""
)
if (shouldShowCreatorBotShortcut(query)) {
loadCreatorBotShortcut()?.let { user ->
if (existingIds.add(user.id)) {
merged += user.toDiscoverItem()
}
}
}
@@ -92,4 +95,35 @@ class ResolvedContactsRepository(
.thenBy { it.displayName.lowercase() }
)
}
private suspend fun loadCreatorBotShortcut(): UserSummaryDto? =
runCatching {
authorizedSessionManager.executeAuthorized { session ->
apiClient.searchUsers(session.accessToken, CreatorBotUsername)
.firstOrNull { user ->
user.isBot && user.username.equals(CreatorBotUsername, ignoreCase = true)
}
}
}.getOrNull()
private fun UserSummaryDto.toDiscoverItem(): DiscoverUserItem =
DiscoverUserItem(
user = this,
displayName = displayName,
normalizedPhoneNumber = PhoneNumberNormalizer.normalize(phoneNumber) ?: ""
)
private fun shouldShowCreatorBotShortcut(query: String): Boolean {
val normalized = query.trim().lowercase()
return normalized.isBlank() ||
CreatorBotUsername.contains(normalized) ||
"botfather".contains(normalized) ||
"создатель ботов".contains(normalized) ||
"создать бота".contains(normalized) ||
"бот".contains(normalized)
}
private companion object {
private const val CreatorBotUsername = "botfatherbot"
}
}
@@ -45,6 +45,15 @@ interface CachedChatDao {
)
suspend fun loadChats(ownerUserId: String): List<CachedChatEntity>
@Query(
"""
SELECT * FROM cached_chats
WHERE owner_user_id = :ownerUserId AND chat_id = :chatId
LIMIT 1
"""
)
suspend fun loadChat(ownerUserId: String, chatId: String): CachedChatEntity?
@Upsert
suspend fun upsert(chats: List<CachedChatEntity>)
@@ -35,4 +35,24 @@ class ChatListCache(
}
)
}
suspend fun markChatRead(ownerUserId: String, chatId: String) {
val entity = dao.loadChat(ownerUserId, chatId) ?: return
val payload = runCatching {
val chat = json.decodeFromString<ChatSummaryDto>(entity.payloadJson)
json.encodeToString(chat.copy(unreadCount = 0))
}.getOrElse {
entity.payloadJson
}
dao.upsert(
listOf(
entity.copy(
unreadCount = 0,
payloadJson = payload,
updatedAtMillis = System.currentTimeMillis()
)
)
)
}
}
@@ -53,6 +53,22 @@ object ChatJoinRequestStatusSerializer : KSerializer<ChatJoinRequestStatus> {
override fun serialize(encoder: Encoder, value: ChatJoinRequestStatus) = encoder.encodeInt(value.wireValue)
}
@Serializable(with = JoinChatResultStatusSerializer::class)
enum class JoinChatResultStatus(val wireValue: Int) {
Joined(0),
PendingApproval(1);
companion object {
fun fromWireValue(value: Int): JoinChatResultStatus = entries.firstOrNull { it.wireValue == value } ?: Joined
}
}
object JoinChatResultStatusSerializer : KSerializer<JoinChatResultStatus> {
override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("JoinChatResultStatus", PrimitiveKind.INT)
override fun deserialize(decoder: Decoder): JoinChatResultStatus = JoinChatResultStatus.fromWireValue(decoder.decodeInt())
override fun serialize(encoder: Encoder, value: JoinChatResultStatus) = encoder.encodeInt(value.wireValue)
}
@Serializable(with = ModerationLogTargetTypeSerializer::class)
enum class ModerationLogTargetType(val wireValue: Int) {
Chat(0),
@@ -202,6 +218,14 @@ data class MessageReactionDto(
val isMine: Boolean
)
@Serializable
data class StoryReplyDto(
val storyId: String,
val authorUserId: String,
val previewText: String,
val attachmentKind: String? = null
)
@Serializable
data class MessageDto(
val id: String,
@@ -225,7 +249,10 @@ data class MessageDto(
val ttlSeconds: Int? = null,
val postedAsChannel: Boolean = false,
val displayAuthorName: String? = null,
val viewCount: Int? = null
val viewCount: Int? = null,
val storyReply: StoryReplyDto? = null,
val discussionMessageId: String? = null,
val commentCount: Int? = null
)
@Serializable
@@ -272,7 +299,8 @@ data class ChatSummaryDto(
val publicUsername: String? = null,
val linkedDiscussionChatId: String? = null,
val channelSignaturesEnabled: Boolean = false,
val description: String? = null
val description: String? = null,
val canDeleteForEveryone: Boolean = false
)
@Serializable
@@ -299,7 +327,8 @@ data class ChatDetailsDto(
val publicUsername: String? = null,
val linkedDiscussionChatId: String? = null,
val channelSignaturesEnabled: Boolean = false,
val description: String? = null
val description: String? = null,
val canDeleteForEveryone: Boolean = false
)
@Serializable
@@ -320,6 +349,7 @@ data class ChatInfoDto(
val channelSignaturesEnabled: Boolean = false,
val canManageChannel: Boolean = false,
val canPostAsChannel: Boolean = false,
val canInviteUsers: Boolean = false,
val description: String? = null
)
@@ -355,7 +385,13 @@ data class ChatStateRequest(
val hasProtectedContent: Boolean? = null,
val defaultMessageTtlSeconds: Int? = null,
val channelSignaturesEnabled: Boolean? = null,
val linkedDiscussionChatId: String? = null
val linkedDiscussionChatId: String? = null,
val clearLinkedDiscussionChat: Boolean = false,
val title: String? = null,
val description: String? = null,
val publicUsername: String? = null,
val clearDescription: Boolean = false,
val clearPublicUsername: Boolean = false
)
@Serializable
@@ -368,13 +404,28 @@ data class ChatInviteLinkDto(
val createsJoinRequest: Boolean = false,
val isRevoked: Boolean = false,
val createdAt: String,
val revokedAt: String? = null
val revokedAt: String? = null,
val expiresAt: String? = null,
val maxUses: Int? = null,
val usesCount: Int = 0,
val memberLimit: Int? = null
)
@Serializable
data class CreateChatInviteLinkRequest(
val title: String? = null,
val createsJoinRequest: Boolean = false
val createsJoinRequest: Boolean = false,
val expiresAt: String? = null,
val maxUses: Int? = null,
val memberLimit: Int? = null
)
@Serializable
data class JoinChatResultDto(
val status: JoinChatResultStatus,
val chat: ChatDetailsDto? = null,
val joinRequestId: String? = null,
val joinRequestStatus: ChatJoinRequestStatus? = null
)
@Serializable
@@ -507,6 +558,19 @@ data class PhoneCodeChallengeDto(
val isRegistered: Boolean
)
@Serializable
data class RequestEmailCodeRequest(
val email: String,
val phoneNumber: String
)
@Serializable
data class EmailCodeChallengeDto(
val challengeId: String,
val expiresAt: String,
val isRegistered: Boolean
)
@Serializable
data class VerifyPhoneCodeRequest(
val challengeId: String,
@@ -515,6 +579,15 @@ data class VerifyPhoneCodeRequest(
val displayName: String? = null
)
@Serializable
data class VerifyEmailCodeRequest(
val challengeId: String,
val email: String,
val phoneNumber: String,
val code: String,
val displayName: String? = null
)
@Serializable
data class RefreshSessionRequest(
val refreshToken: String
@@ -534,7 +607,8 @@ data class SendMessageRequest(
val replyToMessageId: String? = null,
val topicId: String? = null,
val hasProtectedContent: Boolean? = null,
val ttlSeconds: Int? = null
val ttlSeconds: Int? = null,
val storyReplyStoryId: String? = null
)
@Serializable
@@ -566,6 +640,16 @@ data class RegisterPushDeviceRequest(
val notificationsEnabled: Boolean
)
@Serializable
data class PushDeviceDto(
val id: String,
val platform: PushPlatform,
val installationId: String,
val deviceName: String? = null,
val notificationsEnabled: Boolean,
val updatedAt: String
)
@Serializable
data class AndroidAppReleaseDto(
val versionCode: Int,
@@ -586,10 +670,12 @@ data class ArgusManifestDto(
data class ArgusReleaseDto(
val id: String,
val version: String,
val androidVersionCode: Int? = null,
val downloadPath: String,
val originalFileName: String? = null,
val packageSizeBytes: Long? = null,
val publishedAt: String,
val notes: String? = null,
val releaseNotes: String? = null
)
@@ -648,6 +734,9 @@ data class PrivacySettingsDto(
val blockedUserIds: List<String> = emptyList(),
val alwaysAllowUserIds: List<String> = emptyList(),
val neverAllowUserIds: List<String> = emptyList(),
val blockedUsers: List<UserSummaryDto> = emptyList(),
val alwaysAllowUsers: List<UserSummaryDto> = emptyList(),
val neverAllowUsers: List<UserSummaryDto> = emptyList(),
val passcodeLockEnabled: Boolean = false,
val showMessagePreview: Boolean = true,
val updatedAt: String
@@ -0,0 +1,42 @@
package com.seven.ikar.kotlin.core.navigation
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
data class PendingLinkNavigation(
val inviteToken: String? = null,
val publicChannelUsername: String? = null,
val requestId: Long = System.nanoTime()
)
class IncomingLinkNavigationStore {
private val _pendingNavigation = MutableStateFlow<PendingLinkNavigation?>(null)
val pendingNavigation: StateFlow<PendingLinkNavigation?> = _pendingNavigation.asStateFlow()
fun setPendingInvite(inviteToken: String?) {
val normalizedToken = inviteToken?.trim()?.takeIf { it.isNotBlank() } ?: run {
_pendingNavigation.value = null
return
}
_pendingNavigation.value = PendingLinkNavigation(inviteToken = normalizedToken)
}
fun setPendingPublicChannel(username: String?) {
val normalizedUsername = username
?.trim()
?.trimStart('@')
?.takeIf { it.isNotBlank() } ?: run {
_pendingNavigation.value = null
return
}
_pendingNavigation.value = PendingLinkNavigation(publicChannelUsername = normalizedUsername)
}
fun clear(requestId: Long? = null) {
val current = _pendingNavigation.value ?: return
if (requestId == null || current.requestId == requestId) {
_pendingNavigation.value = null
}
}
}
@@ -20,21 +20,23 @@ import com.seven.ikar.kotlin.core.model.ChatJoinRequestDto
import com.seven.ikar.kotlin.core.model.ChatTopicDto
import com.seven.ikar.kotlin.core.model.CreateChatInviteLinkRequest
import com.seven.ikar.kotlin.core.model.ContactMatchDto
import com.seven.ikar.kotlin.core.model.CreateBotRequest
import com.seven.ikar.kotlin.core.model.CreateChannelRequest
import com.seven.ikar.kotlin.core.model.CreateChatTopicRequest
import com.seven.ikar.kotlin.core.model.CreateStoryRequest
import com.seven.ikar.kotlin.core.model.CreatedBotDto
import com.seven.ikar.kotlin.core.model.CreateDirectChatRequest
import com.seven.ikar.kotlin.core.model.CreateGroupChatRequest
import com.seven.ikar.kotlin.core.model.DownloadedAttachmentFile
import com.seven.ikar.kotlin.core.model.EmailCodeChallengeDto
import com.seven.ikar.kotlin.core.model.ForwardMessageRequest
import com.seven.ikar.kotlin.core.model.JoinChatResultDto
import com.seven.ikar.kotlin.core.model.MessageDto
import com.seven.ikar.kotlin.core.model.MessageSearchPageDto
import com.seven.ikar.kotlin.core.model.ModerationLogDto
import com.seven.ikar.kotlin.core.model.PhoneCodeChallengeDto
import com.seven.ikar.kotlin.core.model.PrivacySettingsDto
import com.seven.ikar.kotlin.core.model.PushDeviceDto
import com.seven.ikar.kotlin.core.model.RegenerateBotTokenResponseDto
import com.seven.ikar.kotlin.core.model.RequestEmailCodeRequest
import com.seven.ikar.kotlin.core.model.RequestPhoneCodeRequest
import com.seven.ikar.kotlin.core.model.RegisterPushDeviceRequest
import com.seven.ikar.kotlin.core.model.RefreshSessionRequest
@@ -54,6 +56,7 @@ import com.seven.ikar.kotlin.core.model.UpdateMyProfileRequest
import com.seven.ikar.kotlin.core.model.UpdatePrivacySettingsRequest
import com.seven.ikar.kotlin.core.model.UserSessionDto
import com.seven.ikar.kotlin.core.model.UserSummaryDto
import com.seven.ikar.kotlin.core.model.VerifyEmailCodeRequest
import com.seven.ikar.kotlin.core.model.VerifyPhoneCodeRequest
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
@@ -138,6 +141,21 @@ class ApiClient(
PhoneCodeChallengeDto.serializer()
)
suspend fun requestEmailCode(email: String, phoneNumber: String): EmailCodeChallengeDto =
executeJsonRequest(
Request.Builder()
.url(ServerConfig.absoluteMessengerUrl("/api/auth/request-email-code"))
.post(
json.encodeToString(
RequestEmailCodeRequest.serializer(),
RequestEmailCodeRequest(email = email, phoneNumber = phoneNumber)
).toRequestBody(JSON)
)
.withClientVersionHeaders()
.build(),
EmailCodeChallengeDto.serializer()
)
suspend fun verifyPhoneCode(
challengeId: String,
phoneNumber: String,
@@ -163,6 +181,33 @@ class ApiClient(
AuthSessionDto.serializer()
)
suspend fun verifyEmailCode(
challengeId: String,
email: String,
phoneNumber: String,
code: String,
displayName: String?
): AuthSessionDto =
executeJsonRequest(
Request.Builder()
.url(ServerConfig.absoluteMessengerUrl("/api/auth/verify-email-code"))
.post(
json.encodeToString(
VerifyEmailCodeRequest.serializer(),
VerifyEmailCodeRequest(
challengeId = challengeId,
email = email,
phoneNumber = phoneNumber,
code = code,
displayName = displayName?.takeIf { it.isNotBlank() }
)
).toRequestBody(JSON)
)
.withClientVersionHeaders()
.build(),
AuthSessionDto.serializer()
)
suspend fun refreshSession(refreshToken: String): AuthSessionDto =
executeJsonRequest(
Request.Builder()
@@ -200,6 +245,32 @@ class ApiClient(
kotlinx.serialization.builtins.ListSerializer(ChatFolderDto.serializer())
)
suspend fun getPublicChannels(accessToken: String, query: String = "", limit: Int = 50): List<ChatSummaryDto> =
executeJsonRequest(
Request.Builder()
.url(
ServerConfig.absoluteMessengerUrl(
"/api/chats/public-channels?q=${encodeQuery(query)}&limit=${limit.coerceIn(1, 100)}"
)
)
.get()
.withBearer(accessToken)
.withClientVersionHeaders()
.build(),
kotlinx.serialization.builtins.ListSerializer(ChatSummaryDto.serializer())
)
suspend fun getPublicChannelByUsername(accessToken: String, username: String): ChatDetailsDto =
executeJsonRequest(
Request.Builder()
.url(ServerConfig.absoluteMessengerUrl("/api/chats/public/${encodeQuery(username.trim().trimStart('@'))}"))
.get()
.withBearer(accessToken)
.withClientVersionHeaders()
.build(),
ChatDetailsDto.serializer()
)
suspend fun searchChats(accessToken: String, query: String, filter: String? = null, limit: Int = 50): List<ChatSearchResultDto> =
searchChatsPage(
accessToken = accessToken,
@@ -280,7 +351,10 @@ class ApiClient(
accessToken: String,
chatId: String,
title: String? = null,
createsJoinRequest: Boolean = false
createsJoinRequest: Boolean = false,
expiresAt: String? = null,
maxUses: Int? = null,
memberLimit: Int? = null
): ChatInviteLinkDto =
executeJsonRequest(
Request.Builder()
@@ -288,7 +362,13 @@ class ApiClient(
.post(
json.encodeToString(
CreateChatInviteLinkRequest.serializer(),
CreateChatInviteLinkRequest(title = title?.takeIf { it.isNotBlank() }, createsJoinRequest = createsJoinRequest)
CreateChatInviteLinkRequest(
title = title?.takeIf { it.isNotBlank() },
createsJoinRequest = createsJoinRequest,
expiresAt = expiresAt,
maxUses = maxUses,
memberLimit = memberLimit
)
).toRequestBody(JSON)
)
.withBearer(accessToken)
@@ -307,7 +387,7 @@ class ApiClient(
.build()
)
suspend fun joinChatByInviteLink(accessToken: String, token: String): ChatDetailsDto =
suspend fun joinChatByInviteLink(accessToken: String, token: String): JoinChatResultDto =
executeJsonRequest(
Request.Builder()
.url(ServerConfig.absoluteMessengerUrl("/api/chats/join/${encodeQuery(token)}"))
@@ -315,7 +395,7 @@ class ApiClient(
.withBearer(accessToken)
.withClientVersionHeaders()
.build(),
ChatDetailsDto.serializer()
JoinChatResultDto.serializer()
)
suspend fun getChatJoinRequests(accessToken: String, chatId: String): List<ChatJoinRequestDto> =
@@ -393,6 +473,22 @@ class ApiClient(
ChatMessagesPageDto.serializer()
)
suspend fun getChatMessagesAround(
accessToken: String,
chatId: String,
aroundMessageId: String,
limit: Int = 50
): ChatMessagesPageDto =
executeJsonRequest(
Request.Builder()
.url(ServerConfig.absoluteMessengerUrl("/api/chats/$chatId/messages?aroundMessageId=$aroundMessageId&limit=$limit"))
.get()
.withBearer(accessToken)
.withClientVersionHeaders()
.build(),
ChatMessagesPageDto.serializer()
)
suspend fun searchChatMessages(
accessToken: String,
chatId: String,
@@ -501,6 +597,28 @@ class ApiClient(
ChatDetailsDto.serializer()
)
suspend fun transferChatOwnership(accessToken: String, chatId: String, memberUserId: String): ChatDetailsDto =
executeJsonRequest(
Request.Builder()
.url(ServerConfig.absoluteMessengerUrl("/api/chats/$chatId/members/$memberUserId/transfer-owner"))
.post("".toRequestBody(JSON))
.withBearer(accessToken)
.withClientVersionHeaders()
.build(),
ChatDetailsDto.serializer()
)
suspend fun removeChatMember(accessToken: String, chatId: String, memberUserId: String): ChatDetailsDto =
executeJsonRequest(
Request.Builder()
.url(ServerConfig.absoluteMessengerUrl("/api/chats/$chatId/members/$memberUserId"))
.delete()
.withBearer(accessToken)
.withClientVersionHeaders()
.build(),
ChatDetailsDto.serializer()
)
suspend fun getChatModerationLog(accessToken: String, chatId: String, limit: Int = 100): List<ModerationLogDto> =
executeJsonRequest(
Request.Builder()
@@ -529,7 +647,8 @@ class ApiClient(
replyToMessageId: String? = null,
topicId: String? = null,
hasProtectedContent: Boolean? = null,
ttlSeconds: Int? = null
ttlSeconds: Int? = null,
storyReplyStoryId: String? = null
): MessageDto =
executeJsonRequest(
Request.Builder()
@@ -542,7 +661,8 @@ class ApiClient(
replyToMessageId = replyToMessageId,
topicId = topicId,
hasProtectedContent = hasProtectedContent,
ttlSeconds = ttlSeconds
ttlSeconds = ttlSeconds,
storyReplyStoryId = storyReplyStoryId
)
).toRequestBody(JSON)
)
@@ -627,13 +747,17 @@ class ApiClient(
chatId: String,
text: String?,
replyToMessageId: String?,
file: UploadFileSource
file: UploadFileSource,
isVoiceNote: Boolean = false
): MessageDto = withContext(Dispatchers.IO) {
val multipart = MultipartBody.Builder()
.setType(MultipartBody.FORM)
.apply {
text?.takeIf { it.isNotBlank() }?.let { addFormDataPart("text", it) }
replyToMessageId?.takeIf { it.isNotBlank() }?.let { addFormDataPart("replyToMessageId", it) }
if (isVoiceNote) {
addFormDataPart("isVoiceNote", "true")
}
addFormDataPart("file", file.fileName, file.asRequestBody())
}
.build()
@@ -710,6 +834,16 @@ class ApiClient(
.build()
)
suspend fun markChatRead(accessToken: String, chatId: String) =
executeUnitRequest(
Request.Builder()
.url(ServerConfig.absoluteMessengerUrl("/api/chats/$chatId/read"))
.post(ByteArray(0).toRequestBody(null))
.withBearer(accessToken)
.withClientVersionHeaders()
.build()
)
suspend fun setMessageReaction(accessToken: String, chatId: String, messageId: String, emoji: String): MessageDto =
executeJsonRequest(
Request.Builder()
@@ -1154,22 +1288,6 @@ class ApiClient(
BotSummaryDto.serializer()
)
suspend fun createBot(accessToken: String, username: String, displayName: String, about: String?): CreatedBotDto =
executeJsonRequest(
Request.Builder()
.url(ServerConfig.absoluteMessengerUrl("/api/bots"))
.post(
json.encodeToString(
CreateBotRequest.serializer(),
CreateBotRequest(username, displayName, about?.takeIf { it.isNotBlank() })
).toRequestBody(JSON)
)
.withBearer(accessToken)
.withClientVersionHeaders()
.build(),
CreatedBotDto.serializer()
)
suspend fun updateBot(accessToken: String, botId: String, displayName: String, about: String?, isEnabled: Boolean): BotSummaryDto =
executeJsonRequest(
Request.Builder()
@@ -1233,6 +1351,17 @@ class ApiClient(
.build()
)
suspend fun getPushDevices(accessToken: String): List<PushDeviceDto> =
executeJsonRequest(
Request.Builder()
.url(ServerConfig.absoluteMessengerUrl("/api/push/devices"))
.get()
.withBearer(accessToken)
.withClientVersionHeaders()
.build(),
kotlinx.serialization.builtins.ListSerializer(PushDeviceDto.serializer())
)
suspend fun unregisterPushDevice(accessToken: String, installationId: String) =
executeUnitRequest(
Request.Builder()
@@ -1256,19 +1385,39 @@ class ApiClient(
val version = manifest.release.version
val plusIndex = version.lastIndexOf('+')
val versionName = if (plusIndex > 0) version.substring(0, plusIndex) else version
val versionCode = version.substringAfterLast('+', "0").toIntOrNull() ?: 0
val versionCode = manifest.release.androidVersionCode
?: parseVersionCodeFromVersion(version)
?: parseVersionCodeFromFileName(manifest.release.originalFileName)
?: 0
return AndroidAppReleaseDto(
versionCode = versionCode,
versionName = versionName,
downloadPath = ServerConfig.absoluteArgusUrl(manifest.release.downloadPath),
publishedAt = manifest.release.publishedAt,
notes = manifest.release.releaseNotes,
notes = manifest.release.notes ?: manifest.release.releaseNotes,
isRequired = true,
minimumSupportedVersionCode = versionCode
)
}
private fun parseVersionCodeFromVersion(version: String): Int? {
val buildMetadata = version
.substringAfterLast('+', missingDelimiterValue = "")
.takeIf { it.isNotBlank() }
?: return null
return buildMetadata.toIntOrNull()?.takeIf { it > 0 }
}
private fun parseVersionCodeFromFileName(fileName: String?): Int? =
fileName
?.let(AndroidApkVersionCodeRegex::find)
?.groupValues
?.getOrNull(1)
?.toIntOrNull()
?.takeIf { it > 0 }
private suspend fun <T> executeJsonRequest(request: Request, serializer: kotlinx.serialization.KSerializer<T>): T =
withContext(Dispatchers.IO) {
try {
@@ -1380,6 +1529,7 @@ class ApiClient(
companion object {
private val JSON = "application/json; charset=utf-8".toMediaType()
private val AndroidApkVersionCodeRegex = Regex("""ikar-android-(\d+)\.apk$""", RegexOption.IGNORE_CASE)
}
}
@@ -2,10 +2,15 @@ package com.seven.ikar.kotlin.core.push
import android.content.Context
import android.util.Base64
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import java.util.UUID
class AndroidPushSettings(context: Context) {
private val preferences = context.getSharedPreferences(PREFERENCES_NAME, Context.MODE_PRIVATE)
private val _snapshot = MutableStateFlow(loadSnapshot())
val snapshot: StateFlow<NotificationSettingsSnapshot> = _snapshot.asStateFlow()
fun getInstallationId(): String {
val existing = preferences.getString(KEY_INSTALLATION_ID, null)
@@ -53,7 +58,7 @@ class AndroidPushSettings(context: Context) {
}
fun getChannelsEnabled(): Boolean =
preferences.getBoolean(KEY_CHANNELS_ENABLED, false)
preferences.getBoolean(KEY_CHANNELS_ENABLED, true)
fun setChannelsEnabled(enabled: Boolean) {
preferences.edit().putBoolean(KEY_CHANNELS_ENABLED, enabled).apply()
@@ -137,7 +142,7 @@ class AndroidPushSettings(context: Context) {
}
fun getPopupWindowsEnabled(): Boolean =
preferences.getBoolean(KEY_POPUP_WINDOWS_ENABLED, false)
preferences.getBoolean(KEY_POPUP_WINDOWS_ENABLED, true)
fun setPopupWindowsEnabled(enabled: Boolean) {
preferences.edit().putBoolean(KEY_POPUP_WINDOWS_ENABLED, enabled).apply()
@@ -230,6 +235,7 @@ class AndroidPushSettings(context: Context) {
setRestartOnCloseEnabled(snapshot.restartOnCloseEnabled)
setBackgroundConnectionEnabled(snapshot.backgroundConnectionEnabled)
setRepeatInterval(snapshot.repeatInterval)
_snapshot.value = loadSnapshot()
}
fun loadChatNotificationSnapshot(chatId: String): ChatNotificationSettingsSnapshot =
@@ -278,7 +284,7 @@ class AndroidPushSettings(context: Context) {
messageSound = NotificationSoundOption.Default,
privateChatsEnabled = true,
groupsEnabled = true,
channelsEnabled = false,
channelsEnabled = true,
storiesEnabled = false,
reactionsEnabled = true,
callVibrationEnabled = true,
@@ -290,7 +296,7 @@ class AndroidPushSettings(context: Context) {
inAppVibrationEnabled = true,
inAppShowTextEnabled = true,
soundInActiveChatEnabled = true,
popupWindowsEnabled = false,
popupWindowsEnabled = true,
contactJoinedEnabled = false,
pinnedMessagesEnabled = true,
restartOnCloseEnabled = false,
@@ -326,6 +332,7 @@ class AndroidPushSettings(context: Context) {
chatKeys.forEach(::remove)
apply()
}
_snapshot.value = loadSnapshot()
}
private fun <T : Enum<T>> getEnum(key: String, defaultValue: T): T {
@@ -0,0 +1,72 @@
package com.seven.ikar.kotlin.core.push
import android.content.Context
import androidx.core.app.NotificationManagerCompat
object ChatNotificationDismissalManager {
private const val PreferencesName = "ikar_chat_notifications"
private const val ChatKeyPrefix = "chat:"
private const val MaxTrackedNotificationsPerChat = 64
fun recordChatNotification(context: Context, chatId: String?, notificationId: Int) {
val normalizedChatId = chatId?.trim()?.takeIf { it.isNotBlank() } ?: return
val preferences = context.applicationContext.getSharedPreferences(PreferencesName, Context.MODE_PRIVATE)
val key = chatKey(normalizedChatId)
val currentIds = preferences.getStringSet(key, emptySet()).orEmpty()
.mapNotNull(String::toIntOrNull)
.filterNot { it == notificationId }
val nextIds = (currentIds + notificationId)
.takeLast(MaxTrackedNotificationsPerChat)
.map(Int::toString)
.toSet()
preferences.edit()
.putStringSet(key, nextIds)
.apply()
}
fun cancelChatNotifications(context: Context, chatId: String?) {
val normalizedChatId = chatId?.trim()?.takeIf { it.isNotBlank() } ?: return
val preferences = context.applicationContext.getSharedPreferences(PreferencesName, Context.MODE_PRIVATE)
val key = chatKey(normalizedChatId)
val notificationIds = preferences.getStringSet(key, emptySet()).orEmpty()
.mapNotNull(String::toIntOrNull)
if (notificationIds.isNotEmpty()) {
val notificationManager = NotificationManagerCompat.from(context)
notificationIds.forEach(notificationManager::cancel)
}
preferences.edit()
.remove(key)
.apply()
}
fun cancelNotification(context: Context, chatId: String?, notificationId: Int?) {
if (notificationId != null) {
NotificationManagerCompat.from(context).cancel(notificationId)
}
val normalizedChatId = chatId?.trim()?.takeIf { it.isNotBlank() } ?: return
val resolvedNotificationId = notificationId ?: return
val preferences = context.applicationContext.getSharedPreferences(PreferencesName, Context.MODE_PRIVATE)
val key = chatKey(normalizedChatId)
val nextIds = preferences.getStringSet(key, emptySet()).orEmpty()
.mapNotNull(String::toIntOrNull)
.filterNot { it == resolvedNotificationId }
.map(Int::toString)
.toSet()
preferences.edit()
.apply {
if (nextIds.isEmpty()) {
remove(key)
} else {
putStringSet(key, nextIds)
}
}
.apply()
}
private fun chatKey(chatId: String): String = ChatKeyPrefix + chatId
}
@@ -3,7 +3,6 @@ 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
@@ -39,19 +38,23 @@ class NotificationActionReceiver : BroadcastReceiver() {
}
val app = context.applicationContext as? IkarNativeApp ?: return
runCatching {
val ownerUserId = runCatching {
app.container.executeAuthorized { session ->
app.container.apiClient.acknowledgeRead(
accessToken = session.accessToken,
chatId = chatId,
messageId = messageId
)
session.user.id
}
}.onSuccess {
if (notificationId != Int.MIN_VALUE) {
NotificationManagerCompat.from(context).cancel(notificationId)
}
}.getOrNull() ?: return
runCatching { app.container.chatListCache.markChatRead(ownerUserId, chatId) }
app.container.realtimeService.notifyChatsChangedLocally()
if (notificationId != Int.MIN_VALUE) {
ChatNotificationDismissalManager.cancelNotification(context, chatId, notificationId)
}
ChatNotificationDismissalManager.cancelChatNotifications(context, chatId)
}
private fun handleAcceptIncomingCall(context: Context, intent: Intent) {
@@ -6,9 +6,13 @@ import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.os.Build
import android.os.VibrationEffect
import android.os.Vibrator
import android.os.VibratorManager
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import androidx.core.content.ContextCompat
import com.seven.ikar.kotlin.BuildConfig
import com.seven.ikar.kotlin.MainActivity
import com.seven.ikar.kotlin.app.IkarNativeApp
import com.seven.ikar.kotlin.core.contacts.PhoneNumberNormalizer
@@ -26,6 +30,22 @@ object PushNotificationDisplayService {
AndroidPushBootstrapper.tryInitialize(context)
val kind = data[PushDataKeys.Kind].orEmpty()
clearStaleAppUpdateNotification(context)
val isAppUpdateNotification = kind == PushDataKinds.AppUpdateAvailable
if (isAppUpdateNotification) {
val updateVersionCodeRaw = data[PushDataKeys.VersionCode]
val updateVersionCode = updateVersionCodeRaw?.toLongOrNull()
if (updateVersionCode == null || updateVersionCode <= BuildConfig.VERSION_CODE.toLong()) {
cancelAppUpdateNotification(context, updateVersionCodeRaw)
return
}
if (AndroidAppVisibilityState.isVisible()) {
cancelAppUpdateNotification(context, updateVersionCodeRaw)
return
}
}
val app = context.applicationContext as? IkarNativeApp
when (kind) {
PushDataKinds.AudioCallInvite,
@@ -65,6 +85,7 @@ object PushNotificationDisplayService {
val isMessageReaction = kind == PushDataKinds.MessageReaction
val isChatNotification = isChatMessage || isMessageReaction
val chatId = data[PushDataKeys.ChatId].orEmpty()
val chatType = data[PushDataKeys.ChatType].orEmpty()
val chatNotificationSettings = if (isChatNotification && chatId.isNotBlank()) {
settings.loadChatNotificationSnapshot(chatId)
} else {
@@ -73,6 +94,9 @@ object PushNotificationDisplayService {
if (isChatNotification && chatNotificationSettings?.notificationsEnabled == false) {
return
}
if (isChatNotification && !isChatCategoryEnabled(settings, chatType, isMessageReaction)) {
return
}
val hideMessagePreview = isChatNotification &&
(app?.container?.privacyRuntimeSettings?.loadSnapshot()?.showMessagePreview == false ||
chatNotificationSettings?.showMessagePreview == false)
@@ -101,13 +125,13 @@ object PushNotificationDisplayService {
).joinToString(separator = "|").takeIf { it.isNotBlank() }
)
}
if (settings.getInAppVibrationEnabled()) {
vibrateForInAppNotification(context)
}
return
}
val popupWindowsEnabled = chatNotificationSettings?.popupWindowsEnabled ?: settings.getPopupWindowsEnabled()
if (isChatNotification && isAppVisible && !popupWindowsEnabled) {
return
}
val senderContactDisplayName = if (isChatNotification) {
resolveContactDisplayName(context, data[PushDataKeys.SenderPhoneNumber])
@@ -123,7 +147,6 @@ object PushNotificationDisplayService {
}
}
val chatType = data[PushDataKeys.ChatType].orEmpty()
val title = if (isChatNotification && chatType.equals(ChatTypeDirect, ignoreCase = true)) {
senderContactDisplayName ?: fallbackTitle
} else {
@@ -179,10 +202,14 @@ object PushNotificationDisplayService {
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 notificationId = if (isAppUpdateNotification) {
AppUpdateNotificationId
} else {
((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
@@ -212,12 +239,17 @@ object PushNotificationDisplayService {
PushDataKinds.AudioCallInvite,
PushDataKinds.VideoCallInvite,
PushDataKinds.AppUpdateAvailable -> NotificationCompat.PRIORITY_HIGH
else -> NotificationCompat.PRIORITY_HIGH
else -> if (isChatNotification && isAppVisible && !popupWindowsEnabled) {
NotificationCompat.PRIORITY_DEFAULT
} else {
NotificationCompat.PRIORITY_HIGH
}
}
val category = when (kind) {
PushDataKinds.AudioCallInvite,
PushDataKinds.VideoCallInvite -> NotificationCompat.CATEGORY_CALL
PushDataKinds.AppUpdateAvailable -> NotificationCompat.CATEGORY_STATUS
else -> NotificationCompat.CATEGORY_MESSAGE
}
@@ -265,19 +297,103 @@ object PushNotificationDisplayService {
.addAction(android.R.drawable.ic_menu_view, "Отметить как прочитанное", markReadPendingIntent)
}
if (isAppUpdateNotification) {
cancelAppUpdateNotification(context, data[PushDataKeys.VersionCode])
data[PushDataKeys.VersionCode]
?.toLongOrNull()
?.let { rememberAppUpdateNotification(context, it) }
}
NotificationManagerCompat.from(context).notify(notificationId, notificationBuilder.build())
if (isChatNotification && chatId.isNotBlank()) {
ChatNotificationDismissalManager.recordChatNotification(context, chatId, notificationId)
}
}
private const val AppUpdateNotificationId = 193000
private const val AppUpdatePreferencesName = "ikar_app_update_notifications"
private const val AppUpdateVersionCodeKey = "version_code"
private const val InAppVibrationDurationMillis = 60L
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 ChatTypeChannel = "Channel"
private const val ContactNameCacheTtlMillis = 5 * 60 * 1000L
private val contactNameCacheLock = Any()
@Volatile
private var contactNameCache: ContactNameCache? = null
private fun cancelAppUpdateNotification(context: Context, versionCode: String?) {
val notificationManager = NotificationManagerCompat.from(context)
notificationManager.cancel(AppUpdateNotificationId)
versionCode?.takeIf { it.isNotBlank() }?.let { notificationManager.cancel(it.hashCode()) }
context.applicationContext
.getSharedPreferences(AppUpdatePreferencesName, Context.MODE_PRIVATE)
.edit()
.remove(AppUpdateVersionCodeKey)
.apply()
}
private fun rememberAppUpdateNotification(context: Context, versionCode: Long) {
context.applicationContext
.getSharedPreferences(AppUpdatePreferencesName, Context.MODE_PRIVATE)
.edit()
.putLong(AppUpdateVersionCodeKey, versionCode)
.apply()
}
private fun clearStaleAppUpdateNotification(context: Context) {
val preferences = context.applicationContext.getSharedPreferences(AppUpdatePreferencesName, Context.MODE_PRIVATE)
val updateVersionCode = preferences.getLong(AppUpdateVersionCodeKey, Long.MIN_VALUE)
if (updateVersionCode != Long.MIN_VALUE && updateVersionCode <= BuildConfig.VERSION_CODE.toLong()) {
cancelAppUpdateNotification(context, updateVersionCode.toString())
}
}
private fun isChatCategoryEnabled(
settings: AndroidPushSettings,
chatType: String,
isMessageReaction: Boolean
): Boolean {
if (isMessageReaction && !settings.getReactionsEnabled()) {
return false
}
return when {
chatType.equals(ChatTypeDirect, ignoreCase = true) -> settings.getPrivateChatsEnabled()
chatType.equals(ChatTypeGroup, ignoreCase = true) -> settings.getGroupsEnabled()
chatType.equals(ChatTypeChannel, ignoreCase = true) -> settings.getChannelsEnabled()
else -> true
}
}
private fun vibrateForInAppNotification(context: Context) {
val vibrator = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
context.getSystemService(VibratorManager::class.java)?.defaultVibrator
} else {
@Suppress("DEPRECATION")
context.getSystemService(Context.VIBRATOR_SERVICE) as? Vibrator
} ?: return
if (!vibrator.hasVibrator()) {
return
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
vibrator.vibrate(
VibrationEffect.createOneShot(
InAppVibrationDurationMillis,
VibrationEffect.DEFAULT_AMPLITUDE
)
)
} else {
@Suppress("DEPRECATION")
vibrator.vibrate(InAppVibrationDurationMillis)
}
}
private fun PendingIncomingCallInvite.withResolvedContactDisplayName(context: Context): PendingIncomingCallInvite {
val contactName = resolveContactDisplayName(context, callerPhoneNumber) ?: return this
return copy(callerDisplayName = contactName)
@@ -64,7 +64,9 @@ data class RealtimeMessageDto(
val ttlSeconds: Int? = null,
val postedAsChannel: Boolean = false,
val displayAuthorName: String? = null,
val viewCount: Int? = null
val viewCount: Int? = null,
val discussionMessageId: String? = null,
val commentCount: Int? = null
)
data class RealtimeMessageReplyDto(
@@ -207,7 +209,9 @@ fun RealtimeMessageDto.toCore(): MessageDto = MessageDto(
ttlSeconds = ttlSeconds,
postedAsChannel = postedAsChannel,
displayAuthorName = displayAuthorName,
viewCount = viewCount
viewCount = viewCount,
discussionMessageId = discussionMessageId,
commentCount = commentCount
)
fun RealtimeMessageReplyDto.toCore(): MessageReplyDto = MessageReplyDto(
@@ -29,6 +29,7 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import okhttp3.OkHttpClient
import java.net.URLEncoder
import java.nio.charset.StandardCharsets
@@ -40,7 +41,8 @@ enum class RealtimeConnectionStage {
}
class RealtimeService(
private val clientVersionProvider: ClientVersionProvider
private val clientVersionProvider: ClientVersionProvider,
private val configureHttpClientBuilder: (OkHttpClient.Builder) -> Unit = {}
) {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private val mutex = Mutex()
@@ -66,6 +68,9 @@ class RealtimeService(
private val _chatsChanged = MutableSharedFlow<Unit>(extraBufferCapacity = 8)
val chatsChanged: SharedFlow<Unit> = _chatsChanged
private val _storiesChanged = MutableSharedFlow<Unit>(extraBufferCapacity = 8)
val storiesChanged: SharedFlow<Unit> = _storiesChanged
private val _appUpdateAvailable = MutableSharedFlow<AndroidAppReleaseDto>(extraBufferCapacity = 8)
val appUpdateAvailable: SharedFlow<AndroidAppReleaseDto> = _appUpdateAvailable
@@ -115,6 +120,34 @@ class RealtimeService(
}
}
fun reconnectForNetworkSettings() {
scope.launch {
var shouldReconnect = false
mutex.withLock {
if (!accessToken.isNullOrBlank()) {
shouldReconnect = true
reconnectJob?.cancel()
reconnectJob = null
connection?.stop()?.blockingAwait()
connection = null
_stage.value = RealtimeConnectionStage.Disconnected
}
}
if (shouldReconnect) {
ensureConnected()
}
}
}
fun notifyChatsChangedLocally() {
_chatsChanged.tryEmit(Unit)
}
fun notifyStoriesChangedLocally() {
_storiesChanged.tryEmit(Unit)
}
fun startCall(chatId: String, mediaType: CallMediaType): OutgoingCallDto {
val activeConnection = requireConnectedConnection()
return if (mediaType == CallMediaType.Audio) {
@@ -190,6 +223,7 @@ class RealtimeService(
private fun buildConnection(accessToken: String): HubConnection {
val hubUrl = buildHubUrl()
val hub = HubConnectionBuilder.create(hubUrl)
.setHttpClientBuilderCallback { builder -> configureHttpClientBuilder(builder) }
.withAccessTokenProvider(Single.defer { Single.just(accessToken) })
.withHeader("X-Ikar-Client-Platform", clientVersionProvider.platform)
.withHeader("X-Ikar-Client-Version-Code", clientVersionProvider.versionCode)
@@ -216,6 +250,10 @@ class RealtimeService(
scope.launch { _chatsChanged.emit(Unit) }
})
hub.on("StoriesChanged", {
scope.launch { _storiesChanged.emit(Unit) }
})
hub.on("AppUpdateAvailable", { payload: AndroidAppReleaseDto ->
scope.launch { _appUpdateAvailable.emit(payload) }
}, AndroidAppReleaseDto::class.java)
@@ -65,7 +65,7 @@ class AndroidAppSettings(context: Context) {
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),
proxyType = getEnum(KEY_PROXY_TYPE, AppProxyType.Socks5).supportedProxyType(),
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()
@@ -79,6 +79,7 @@ class AndroidAppSettings(context: Context) {
val sanitized = snapshot.copy(
keepMediaDays = snapshot.keepMediaDays.coerceIn(1, 365),
maxCacheMb = snapshot.maxCacheMb.coerceIn(64, 8192),
proxyType = snapshot.proxyType.supportedProxyType(),
proxyPort = snapshot.proxyPort.coerceIn(1, 65_535)
)
preferences.edit().apply {
@@ -111,12 +112,16 @@ class AndroidAppSettings(context: Context) {
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
?.firstOrNull { it.name == rawValue }
?: defaultValue
}
private fun AppProxyType.supportedProxyType(): AppProxyType = when (this) {
AppProxyType.MtProto -> AppProxyType.Socks5
else -> this
}
companion object {
private const val PREFERENCES_NAME = "ikar_kotlin_app_settings"
private const val KEY_AUTO_DOWNLOAD_PHOTOS = "auto_download_photos"
@@ -1,10 +1,15 @@
package com.seven.ikar.kotlin.core.settings
import android.content.Context
import android.util.Base64
import com.seven.ikar.kotlin.core.model.PrivacySettingsDto
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import java.security.MessageDigest
import java.security.SecureRandom
import javax.crypto.SecretKeyFactory
import javax.crypto.spec.PBEKeySpec
data class PrivacyRuntimeSnapshot(
val passcodeLockEnabled: Boolean = false,
@@ -23,6 +28,9 @@ class AndroidPrivacyRuntimeSettings(context: Context) {
)
fun saveFrom(settings: PrivacySettingsDto) {
if (!settings.passcodeLockEnabled) {
clearPasscode()
}
saveSnapshot(
PrivacyRuntimeSnapshot(
passcodeLockEnabled = settings.passcodeLockEnabled,
@@ -31,6 +39,38 @@ class AndroidPrivacyRuntimeSettings(context: Context) {
)
}
fun hasPasscode(): Boolean =
!preferences.getString(KEY_PASSCODE_SALT, null).isNullOrBlank() &&
!preferences.getString(KEY_PASSCODE_HASH, null).isNullOrBlank()
fun setPasscode(passcode: String) {
val salt = ByteArray(PasscodeSaltBytes)
SecureRandom().nextBytes(salt)
val hash = hashPasscode(passcode, salt)
preferences.edit()
.putString(KEY_PASSCODE_SALT, Base64.encodeToString(salt, Base64.NO_WRAP))
.putString(KEY_PASSCODE_HASH, Base64.encodeToString(hash, Base64.NO_WRAP))
.apply()
}
fun verifyPasscode(passcode: String): Boolean {
val salt = preferences.getString(KEY_PASSCODE_SALT, null)
?.let { Base64.decode(it, Base64.NO_WRAP) }
?: return false
val expectedHash = preferences.getString(KEY_PASSCODE_HASH, null)
?.let { Base64.decode(it, Base64.NO_WRAP) }
?: return false
val actualHash = hashPasscode(passcode, salt)
return MessageDigest.isEqual(expectedHash, actualHash)
}
fun clearPasscode() {
preferences.edit()
.remove(KEY_PASSCODE_SALT)
.remove(KEY_PASSCODE_HASH)
.apply()
}
private fun saveSnapshot(snapshot: PrivacyRuntimeSnapshot) {
preferences.edit()
.putBoolean(KEY_PASSCODE_LOCK_ENABLED, snapshot.passcodeLockEnabled)
@@ -39,10 +79,22 @@ class AndroidPrivacyRuntimeSettings(context: Context) {
_snapshot.value = snapshot
}
private fun hashPasscode(passcode: String, salt: ByteArray): ByteArray {
val spec = PBEKeySpec(passcode.toCharArray(), salt, PasscodeIterations, PasscodeKeyBits)
return SecretKeyFactory.getInstance("PBKDF2WithHmacSHA1")
.generateSecret(spec)
.encoded
}
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"
private const val KEY_PASSCODE_SALT = "passcode_salt"
private const val KEY_PASSCODE_HASH = "passcode_hash"
private const val PasscodeSaltBytes = 16
private const val PasscodeIterations = 120_000
private const val PasscodeKeyBits = 256
}
}
@@ -3,10 +3,13 @@ package com.seven.ikar.kotlin.core.update
import org.json.JSONObject
import java.io.File
import java.net.HttpURLConnection
import java.net.Proxy
import java.net.URL
import java.security.MessageDigest
class ArgusAppUpdateClient {
class ArgusAppUpdateClient(
private val proxyProvider: () -> Proxy? = { null }
) {
fun fetchManifest(config: ArgusAppUpdateConfig): ArgusAppManifest? {
val url = buildManifestUrl(config)
val connection = openConnection(url)
@@ -62,12 +65,15 @@ class ArgusAppUpdateClient {
return "$baseUrl/api/apps/$slug/manifest?platform=${config.platform}&channel=${config.channel}"
}
private fun openConnection(url: String): HttpURLConnection =
(URL(url).openConnection() as HttpURLConnection).apply {
private fun openConnection(url: String): HttpURLConnection {
val targetUrl = URL(url)
val connection = proxyProvider()?.let(targetUrl::openConnection) ?: targetUrl.openConnection()
return (connection as HttpURLConnection).apply {
connectTimeout = 15_000
readTimeout = 60_000
setRequestProperty("Accept", "application/json")
}
}
private fun parseManifest(body: String): ArgusAppManifest {
val root = JSONObject(body)
@@ -17,7 +17,7 @@ class ArgusAppUpdateInstaller(private val context: Context) {
update: AvailableIkarUpdate
): AppUpdateSubmissionResult {
val expectedPackageName = update.installedApp.packageName
val expectedVersionCode = update.manifest.release.androidVersionCode
val expectedVersionCode = update.manifest.release.resolvedVersionCode
val archiveInfo = readArchiveInfo(packageFile)
?: return reject(
expectedPackageName,
@@ -45,18 +45,16 @@ class ArgusAppUpdateManager(
"На сервере 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}."
)
}
manifest.androidPackageName
?.trim()
?.takeIf { it.isNotBlank() }
?.let { manifestPackageName ->
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(
@@ -64,12 +62,24 @@ class ArgusAppUpdateManager(
)
}
val remoteVersionCode = manifest.release.androidVersionCode
?: return AppUpdateCheckResult.Incompatible(
"Argus не вернул androidVersionCode для релиза."
)
val remoteVersionCode = manifest.release.resolvedVersionCode
val installedVersionName = installedApp.versionName?.trim().orEmpty()
val versionNameComparison = installedVersionName
.takeIf { it.isNotBlank() }
?.let {
ArgusReleaseVersionComparator.compare(
remoteVersion = manifest.release.resolvedVersionName,
localVersion = it
)
}
val isUpdateAvailable = when {
versionNameComparison == null -> remoteVersionCode == null || remoteVersionCode > installedApp.versionCode
versionNameComparison > 0 -> true
versionNameComparison == 0 -> remoteVersionCode != null && remoteVersionCode > installedApp.versionCode
else -> false
}
if (remoteVersionCode <= installedApp.versionCode) {
if (!isUpdateAvailable) {
return AppUpdateCheckResult.UpToDate(manifest)
}
@@ -27,7 +27,38 @@ data class ArgusPublishedRelease(
val sha256: String,
val publishedAt: String,
val notes: String?
)
) {
val resolvedVersionName: String
get() = version.substringBefore('+').trim().ifBlank { version.trim() }
val resolvedVersionCode: Long?
get() = androidVersionCode?.takeIf { it > 0 }
?: version.substringAfterLast('+', missingDelimiterValue = "")
.trim()
.toLongOrNull()
?.takeIf { it > 0 }
?: parseVersionCodeFromFileName(originalFileName)
companion object {
private val VersionCodeFileNameRegex = Regex(
"""(?:^|[-_])(\d+)\.apk$""",
RegexOption.IGNORE_CASE
)
fun parseVersionCodeFromFileName(fileName: String?): Long? {
if (fileName.isNullOrBlank()) {
return null
}
return VersionCodeFileNameRegex
.find(fileName.trim())
?.groupValues
?.getOrNull(1)
?.toLongOrNull()
?.takeIf { it > 0 }
}
}
}
data class ArgusAppManifest(
val slug: String,
@@ -45,8 +76,112 @@ data class AvailableIkarUpdate(
val installedApp: InstalledIkarApp,
val manifest: ArgusAppManifest
) {
val versionCode: Long
get() = manifest.release.resolvedVersionCode ?: 0L
val downloadUrl: String
get() = config.baseUrl.trimEnd('/') + manifest.release.downloadPath
get() {
val rawPath = manifest.release.downloadPath.trim()
if (rawPath.startsWith("http://", ignoreCase = true) ||
rawPath.startsWith("https://", ignoreCase = true)
) {
return rawPath
}
return config.baseUrl.trimEnd('/') + "/" + rawPath.trimStart('/')
}
}
internal object ArgusReleaseVersionComparator {
fun compare(remoteVersion: String, localVersion: String?): Int {
val remote = normalize(remoteVersion)
val local = normalize(localVersion.orEmpty())
if (remote.isBlank()) {
return 0
}
if (local.isBlank()) {
return 1
}
val remoteSemantic = SemanticVersion.parse(remote)
val localSemantic = SemanticVersion.parse(local)
return if (remoteSemantic != null && localSemantic != null) {
remoteSemantic.compareTo(localSemantic)
} else {
remote.compareTo(local, ignoreCase = true)
}
}
private fun normalize(version: String): String =
version.trim()
.removePrefix("v")
.removePrefix("V")
.substringBefore('+')
.trim()
private data class SemanticVersion(
val major: Long,
val minor: Long,
val patch: Long,
val preRelease: List<String>
) : Comparable<SemanticVersion> {
override fun compareTo(other: SemanticVersion): Int =
compareValuesBy(this, other, SemanticVersion::major, SemanticVersion::minor, SemanticVersion::patch)
.takeIf { it != 0 }
?: comparePreRelease(preRelease, other.preRelease)
companion object {
private val Pattern = Regex("""^(\d+)(?:\.(\d+))?(?:\.(\d+))?(?:-([0-9A-Za-z.-]+))?$""")
fun parse(value: String): SemanticVersion? {
val match = Pattern.matchEntire(value) ?: return null
return SemanticVersion(
major = match.groupValues[1].toLongOrNull() ?: return null,
minor = match.groupValues.getOrNull(2)?.takeIf { it.isNotBlank() }?.toLongOrNull() ?: 0L,
patch = match.groupValues.getOrNull(3)?.takeIf { it.isNotBlank() }?.toLongOrNull() ?: 0L,
preRelease = match.groupValues.getOrNull(4)
?.takeIf { it.isNotBlank() }
?.split('.')
.orEmpty()
)
}
private fun comparePreRelease(left: List<String>, right: List<String>): Int {
if (left.isEmpty() && right.isEmpty()) {
return 0
}
if (left.isEmpty()) {
return 1
}
if (right.isEmpty()) {
return -1
}
val maxSize = maxOf(left.size, right.size)
for (index in 0 until maxSize) {
val leftPart = left.getOrNull(index) ?: return -1
val rightPart = right.getOrNull(index) ?: return 1
val comparison = comparePreReleasePart(leftPart, rightPart)
if (comparison != 0) {
return comparison
}
}
return 0
}
private fun comparePreReleasePart(left: String, right: String): Int {
val leftNumber = left.toLongOrNull()
val rightNumber = right.toLongOrNull()
return when {
leftNumber != null && rightNumber != null -> leftNumber.compareTo(rightNumber)
leftNumber != null -> -1
rightNumber != null -> 1
else -> left.compareTo(right, ignoreCase = true)
}
}
}
}
}
sealed interface AppUpdateCheckResult {
@@ -1,5 +1,6 @@
package com.seven.ikar.kotlin.feature.bots
import android.content.ClipData
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
@@ -13,7 +14,6 @@ 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
@@ -31,12 +31,14 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.platform.ClipEntry
import androidx.compose.ui.platform.LocalClipboard
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.launch
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@@ -48,12 +50,12 @@ fun BotEditorScreen(
onAboutChanged: (String) -> Unit,
onCommandsChanged: (String) -> Unit,
onEnabledChanged: (Boolean) -> Unit,
onSaveClick: () -> Unit,
onRegenerateTokenClick: () -> Unit,
onDeleteBotClick: () -> Unit,
onOpenChatClick: () -> Unit
) {
val clipboardManager = LocalClipboardManager.current
val clipboard = LocalClipboard.current
val clipboardScope = rememberCoroutineScope()
var showDeleteConfirmation by remember { mutableStateOf(false) }
if (showDeleteConfirmation) {
AlertDialog(
@@ -81,7 +83,7 @@ fun BotEditorScreen(
Scaffold(
topBar = {
TopAppBar(
title = { Text(if (state.isExistingBot) "Редактор бота" else "Новый бот") },
title = { Text(if (state.isExistingBot) "Редактор бота" else "Создатель ботов") },
navigationIcon = {
IconButton(onClick = onBackClick) {
Icon(Icons.AutoMirrored.Outlined.ArrowBack, contentDescription = "Назад")
@@ -147,8 +149,13 @@ fun BotEditorScreen(
}
}
Button(onClick = onSaveClick, enabled = !state.isSaving && !state.isDeleting, modifier = Modifier.fillMaxWidth()) {
Text(if (state.isSaving) "Сохранение..." else "Сохранить")
if (!state.isExistingBot) {
Text(
"Новых ботов создавайте в чате с @botfatherbot командой /создать_бота.",
color = MaterialTheme.colorScheme.onSurfaceVariant
)
} else if (state.isSaving) {
Text("Сохраняется...", color = MaterialTheme.colorScheme.onSurfaceVariant)
}
if (state.isExistingBot) {
@@ -183,7 +190,13 @@ fun BotEditorScreen(
Text("Текущий токен", fontWeight = FontWeight.SemiBold)
Text(state.latestToken, style = MaterialTheme.typography.bodySmall)
OutlinedButton(
onClick = { clipboardManager.setText(AnnotatedString(state.latestToken)) },
onClick = {
clipboardScope.launch {
clipboard.setClipEntry(
ClipEntry(ClipData.newPlainText("Ikar bot token", state.latestToken))
)
}
},
modifier = Modifier.fillMaxWidth()
) {
Icon(Icons.Outlined.ContentCopy, contentDescription = null)
@@ -6,6 +6,8 @@ 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.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
@@ -38,29 +40,34 @@ class BotEditorViewModel(
) : ViewModel() {
private val _uiState = MutableStateFlow(BotEditorUiState(session = container.sessionStore.read(), botId = botId))
val uiState: StateFlow<BotEditorUiState> = _uiState.asStateFlow()
private var autosaveJob: Job? = null
init {
load()
}
fun updateDisplayName(value: String) {
_uiState.value = _uiState.value.copy(displayName = value)
_uiState.value = _uiState.value.copy(displayName = value, errorMessage = null, infoMessage = null)
scheduleExistingBotAutosave()
}
fun updateUsername(value: String) {
_uiState.value = _uiState.value.copy(username = value)
_uiState.value = _uiState.value.copy(username = value, errorMessage = null, infoMessage = null)
}
fun updateAbout(value: String) {
_uiState.value = _uiState.value.copy(about = value)
_uiState.value = _uiState.value.copy(about = value, errorMessage = null, infoMessage = null)
scheduleExistingBotAutosave()
}
fun updateCommandsText(value: String) {
_uiState.value = _uiState.value.copy(commandsText = value)
_uiState.value = _uiState.value.copy(commandsText = value, errorMessage = null, infoMessage = null)
scheduleExistingBotAutosave()
}
fun updateEnabled(value: Boolean) {
_uiState.value = _uiState.value.copy(isEnabled = value)
_uiState.value = _uiState.value.copy(isEnabled = value, errorMessage = null, infoMessage = null)
scheduleExistingBotAutosave()
}
fun load() {
@@ -85,8 +92,10 @@ class BotEditorViewModel(
_uiState.value = state.copy(errorMessage = "Укажите отображаемое имя бота.")
return
}
if (!state.isExistingBot && state.username.isBlank()) {
_uiState.value = state.copy(errorMessage = "Укажите имя пользователя бота.")
if (!state.isExistingBot) {
_uiState.value = state.copy(
errorMessage = "Создавайте ботов через чат с @botfatherbot командой /создать_бота."
)
return
}
@@ -95,34 +104,19 @@ class BotEditorViewModel(
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, "Бот сохранён."))
}
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
@@ -233,4 +227,20 @@ class BotEditorViewModel(
private fun formatCommands(commands: List<BotCommandDto>): String =
commands.joinToString(separator = "\n") { "/${it.command} - ${it.description}" }
private fun scheduleExistingBotAutosave() {
if (!_uiState.value.isExistingBot) {
return
}
autosaveJob?.cancel()
autosaveJob = viewModelScope.launch {
delay(BotAutosaveDelayMillis)
save()
}
}
companion object {
private const val BotAutosaveDelayMillis = 600L
}
}
@@ -18,7 +18,6 @@ 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
@@ -41,7 +40,7 @@ import com.seven.ikar.kotlin.ui.theme.TelegramInkMuted
fun BotsScreen(
state: BotsUiState,
onBackClick: () -> Unit,
onCreateBotClick: () -> Unit,
onOpenCreatorBotClick: () -> Unit,
onBotClick: (BotSummaryDto) -> Unit
) {
IkarScreenSurface(modifier = Modifier.fillMaxSize()) {
@@ -72,8 +71,8 @@ fun BotsScreen(
}
)
Button(onClick = onCreateBotClick) {
Text("Создать бота")
Button(onClick = onOpenCreatorBotClick) {
Text("Открыть Создателя ботов")
}
state.errorMessage?.let {
@@ -98,7 +97,7 @@ fun BotsScreen(
) {
Text("Ботов пока нет", style = MaterialTheme.typography.headlineMedium)
Text(
"Создайте первого бота и подключите его через Bot API.",
"Создайте первого бота через чат с Создателем ботов.",
style = MaterialTheme.typography.bodyMedium,
color = TelegramInkMuted
)
@@ -0,0 +1,117 @@
package com.seven.ikar.kotlin.feature.browser
import android.annotation.SuppressLint
import android.webkit.WebChromeClient
import android.webkit.WebResourceRequest
import android.webkit.WebView
import android.webkit.WebViewClient
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.WindowInsetsSides
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.only
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawing
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
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.LocalContext
import androidx.compose.ui.viewinterop.AndroidView
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.TelegramInkMuted
@SuppressLint("SetJavaScriptEnabled")
@Composable
fun InAppBrowserScreen(
url: String,
onBackClick: () -> Unit
) {
val context = LocalContext.current
var title by remember(url) { mutableStateOf(url.hostTitle()) }
var progress by remember(url) { mutableIntStateOf(0) }
val webView = remember(url) {
WebView(context).apply {
settings.javaScriptEnabled = true
settings.domStorageEnabled = true
settings.loadsImagesAutomatically = true
settings.allowFileAccess = false
settings.allowContentAccess = false
webViewClient = object : WebViewClient() {
override fun shouldOverrideUrlLoading(view: WebView, request: WebResourceRequest): Boolean {
return false
}
}
webChromeClient = object : WebChromeClient() {
override fun onReceivedTitle(view: WebView, receivedTitle: String?) {
title = receivedTitle?.takeIf { it.isNotBlank() } ?: view.url?.hostTitle() ?: title
}
override fun onProgressChanged(view: WebView, newProgress: Int) {
progress = newProgress.coerceIn(0, 100)
}
}
if (url.isNotBlank()) {
loadUrl(url)
}
}
}
BackHandler {
if (webView.canGoBack()) {
webView.goBack()
} else {
onBackClick()
}
}
DisposableEffect(webView) {
onDispose {
webView.stopLoading()
webView.destroy()
}
}
IkarScreenSurface(modifier = Modifier.fillMaxSize()) {
Column(
modifier = Modifier
.fillMaxSize()
.windowInsetsPadding(WindowInsets.safeDrawing.only(WindowInsetsSides.Vertical))
.padding(horizontal = 12.dp, vertical = 12.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
IkarSimpleHeader(title = title, onBackClick = onBackClick)
Text(
text = url,
style = MaterialTheme.typography.bodySmall,
color = TelegramInkMuted,
maxLines = 1
)
if (progress in 1..99) {
LinearProgressIndicator(progress = { progress / 100f })
}
AndroidView(
factory = { webView },
modifier = Modifier.fillMaxSize()
)
}
}
}
private fun String.hostTitle(): String =
runCatching { android.net.Uri.parse(this).host }
.getOrNull()
?.takeIf { it.isNotBlank() }
?: "Браузер"
@@ -4,11 +4,18 @@ import android.content.ActivityNotFoundException
import android.content.ContentValues
import android.content.Context
import android.content.Intent
import android.graphics.Bitmap
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.Paint
import android.graphics.RectF
import android.graphics.Typeface
import android.net.Uri
import android.os.Build
import android.os.Environment
import android.provider.MediaStore
import android.provider.OpenableColumns
import android.util.Base64
import androidx.core.content.FileProvider
import com.seven.ikar.kotlin.core.model.DownloadedAttachmentFile
import com.seven.ikar.kotlin.core.network.UploadFileSource
@@ -41,6 +48,7 @@ internal fun LocalAttachmentFile.toUploadFileSource(): UploadFileSource =
class AndroidAttachmentFileService(
private val context: Context
) {
private val maxCacheFileNameLength = 120
private val maxAttachmentSizeBytes = 25L * 1024L * 1024L
private val attachmentsDirectory: File by lazy {
File(context.cacheDir, "attachments").apply { mkdirs() }
@@ -72,15 +80,94 @@ class AndroidAttachmentFileService(
)
}
suspend fun createBuiltInGifAttachment(fileName: String, base64Data: String): LocalAttachmentFile =
withContext(Dispatchers.IO) {
val sanitizedFileName = sanitizeFileName(fileName).ifBlank { "ikar_gif.gif" }
val bytes = Base64.decode(base64Data, Base64.DEFAULT)
if (bytes.size > maxAttachmentSizeBytes) {
throw IOException("File must be 25 MB or smaller.")
}
val target = File(attachmentsDirectory, "pending_${System.currentTimeMillis()}_$sanitizedFileName")
target.outputStream().use { output -> output.write(bytes) }
LocalAttachmentFile(
fileName = sanitizedFileName,
contentType = "image/gif",
fileSizeBytes = bytes.size.toLong(),
localPath = target.absolutePath
)
}
suspend fun createBuiltInStickerAttachment(
fileName: String,
emoji: String,
label: String,
backgroundColor: Int,
accentColor: Int
): LocalAttachmentFile = withContext(Dispatchers.IO) {
val sanitizedFileName = sanitizeFileName(fileName).ifBlank { "ikar_sticker.png" }
val target = File(attachmentsDirectory, "pending_${System.currentTimeMillis()}_$sanitizedFileName")
val bitmap = Bitmap.createBitmap(StickerCanvasSizePx, StickerCanvasSizePx, Bitmap.Config.ARGB_8888)
try {
val canvas = Canvas(bitmap)
val bounds = RectF(0f, 0f, StickerCanvasSizePx.toFloat(), StickerCanvasSizePx.toFloat())
val backgroundPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = backgroundColor
style = Paint.Style.FILL
}
canvas.drawRoundRect(bounds, 96f, 96f, backgroundPaint)
val accentPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = accentColor
style = Paint.Style.FILL
alpha = 36
}
canvas.drawCircle(StickerCanvasSizePx * 0.78f, StickerCanvasSizePx * 0.18f, 96f, accentPaint)
canvas.drawCircle(StickerCanvasSizePx * 0.18f, StickerCanvasSizePx * 0.78f, 72f, accentPaint)
val emojiPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = Color.BLACK
textAlign = Paint.Align.CENTER
textSize = 220f
typeface = Typeface.DEFAULT
}
val emojiBaseline = StickerCanvasSizePx * 0.50f - (emojiPaint.descent() + emojiPaint.ascent()) / 2
canvas.drawText(emoji, StickerCanvasSizePx / 2f, emojiBaseline, emojiPaint)
val labelPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = accentColor
textAlign = Paint.Align.CENTER
textSize = 46f
typeface = Typeface.create(Typeface.DEFAULT, Typeface.BOLD)
}
canvas.drawText(label.take(22), StickerCanvasSizePx / 2f, StickerCanvasSizePx * 0.88f, labelPaint)
target.outputStream().use { output ->
if (!bitmap.compress(Bitmap.CompressFormat.PNG, 100, output)) {
throw IOException("Failed to create sticker file.")
}
}
} finally {
bitmap.recycle()
}
LocalAttachmentFile(
fileName = sanitizedFileName,
contentType = "image/png",
fileSizeBytes = target.length(),
localPath = target.absolutePath
)
}
fun createAttachmentCacheFile(attachmentId: String, fileName: String): File =
File(attachmentsDirectory, "${attachmentId}_${sanitizeFileName(fileName)}")
File(attachmentsDirectory, "${sanitizeFileName(attachmentId)}_${sanitizeCacheFileName(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),
fileName = file.fileName,
contentType = file.contentType,
fileSizeBytes = file.fileSizeBytes.takeIf { it >= 0L } ?: target.length(),
localPath = target.absolutePath
@@ -235,6 +322,16 @@ class AndroidAttachmentFileService(
}
}
suspend fun saveCachedImageToGalleryIfMissing(localPath: String, fileName: String, contentType: String): Uri? =
withContext(Dispatchers.IO) {
val sanitizedFileName = sanitizeFileName(fileName)
if (galleryImageExists(sanitizedFileName)) {
return@withContext null
}
saveCachedImageToGallery(localPath, sanitizedFileName, contentType)
}
private fun requireExistingFile(localPath: String): File {
val file = File(localPath)
if (!file.exists()) {
@@ -244,6 +341,29 @@ class AndroidAttachmentFileService(
return file
}
private fun galleryImageExists(displayName: String): Boolean {
val resolver = context.contentResolver
val collection = MediaStore.Images.Media.EXTERNAL_CONTENT_URI
val projection = arrayOf(MediaStore.Images.Media._ID)
val selection: String
val arguments: Array<String>
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
selection = "${MediaStore.Images.Media.DISPLAY_NAME} = ? AND (${MediaStore.Images.Media.RELATIVE_PATH} = ? OR ${MediaStore.Images.Media.RELATIVE_PATH} = ?)"
arguments = arrayOf(
displayName,
Environment.DIRECTORY_PICTURES + "/Ikar",
Environment.DIRECTORY_PICTURES + "/Ikar/"
)
} else {
selection = "${MediaStore.Images.Media.DISPLAY_NAME} = ?"
arguments = arrayOf(displayName)
}
return resolver.query(collection, projection, selection, arguments, null)?.use { cursor ->
cursor.moveToFirst()
} ?: false
}
private fun contentUriFor(file: File): Uri =
FileProvider.getUriForFile(
context,
@@ -298,4 +418,29 @@ class AndroidAttachmentFileService(
private fun sanitizeFileName(value: String): String =
value.trim().ifBlank { "attachment" }.replace(Regex("[^A-Za-z0-9._-]"), "_")
private fun sanitizeCacheFileName(value: String): String {
val decodedName = runCatching { Uri.decode(value) }.getOrDefault(value)
val sanitizedName = sanitizeFileName(decodedName)
if (sanitizedName.length <= maxCacheFileNameLength) {
return sanitizedName
}
val extension = sanitizedName
.substringAfterLast('.', missingDelimiterValue = "")
.takeIf { it.isNotBlank() && it.length <= 16 && sanitizedName.contains('.') }
?.let { ".$it" }
.orEmpty()
val baseName = if (extension.isBlank()) {
sanitizedName
} else {
sanitizedName.dropLast(extension.length).trimEnd('.').ifBlank { "attachment" }
}
val maxBaseLength = (maxCacheFileNameLength - extension.length).coerceAtLeast(1)
return baseName.take(maxBaseLength) + extension
}
private companion object {
const val StickerCanvasSizePx = 512
}
}
@@ -1,6 +1,13 @@
package com.seven.ikar.kotlin.feature.chat
import android.media.MediaPlayer
import android.content.Context
import android.net.Uri
import androidx.media3.common.AudioAttributes
import androidx.media3.common.C
import androidx.media3.common.MediaItem
import androidx.media3.common.PlaybackException
import androidx.media3.common.Player
import androidx.media3.exoplayer.ExoPlayer
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
@@ -11,27 +18,51 @@ import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.launch
import java.io.File
import java.util.concurrent.Semaphore
data class VoiceNotePlaybackEvent(
val attachmentId: String,
val isPlaying: Boolean,
val positionMs: Long,
val durationMs: Long
val durationMs: Long,
val errorMessage: String? = null
)
class AndroidVoiceNotePlayerService {
class AndroidVoiceNotePlayerService(
private val context: Context
) {
private val sync = Semaphore(1)
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
private val _events = MutableSharedFlow<VoiceNotePlaybackEvent>(extraBufferCapacity = 32)
val events: SharedFlow<VoiceNotePlaybackEvent> = _events.asSharedFlow()
private var player: MediaPlayer? = null
private var player: ExoPlayer? = null
private var currentAttachmentId: String? = null
private var durationMs: Long = 0L
private var tickerJob: Job? = null
suspend fun toggle(attachmentId: String, localFilePath: String) {
suspend fun toggle(attachmentId: String, localFilePath: String, pauseExternalAudio: Boolean = false) {
toggleMedia(
attachmentId = attachmentId,
mediaItem = MediaItem.fromUri(Uri.fromFile(File(localFilePath))),
pauseExternalAudio = pauseExternalAudio
)
}
suspend fun toggleUri(attachmentId: String, uri: Uri, pauseExternalAudio: Boolean = false) {
toggleMedia(
attachmentId = attachmentId,
mediaItem = MediaItem.fromUri(uri),
pauseExternalAudio = pauseExternalAudio
)
}
private suspend fun toggleMedia(
attachmentId: String,
mediaItem: MediaItem,
pauseExternalAudio: Boolean
) {
sync.acquire()
try {
if (player != null && currentAttachmentId == attachmentId) {
@@ -41,19 +72,46 @@ class AndroidVoiceNotePlayerService {
stopCurrent()
val nextPlayer = MediaPlayer().apply {
setDataSource(localFilePath)
prepare()
val nextPlayer = ExoPlayer.Builder(context).build().apply {
setAudioAttributes(VoiceNoteAudioAttributes, pauseExternalAudio)
}
player = nextPlayer
currentAttachmentId = attachmentId
durationMs = nextPlayer.duration.toLong().coerceAtLeast(0L)
durationMs = 0L
nextPlayer.setOnCompletionListener {
scope.launch { stopCurrent(playbackCompleted = true) }
}
nextPlayer.start()
nextPlayer.addListener(object : Player.Listener {
override fun onPlaybackStateChanged(playbackState: Int) {
val activeAttachmentId = currentAttachmentId ?: return
durationMs = safeDuration(nextPlayer)
when (playbackState) {
Player.STATE_READY -> emitEvent(
attachmentId = activeAttachmentId,
isPlaying = safeIsPlaying(nextPlayer),
positionMs = safePosition(nextPlayer),
durationMs = durationMs
)
Player.STATE_ENDED -> scope.launch { stopCurrent(playbackCompleted = true) }
}
}
override fun onPlayerError(error: PlaybackException) {
val failedAttachmentId = currentAttachmentId ?: return
scope.launch {
stopCurrent()
emitEvent(
attachmentId = failedAttachmentId,
isPlaying = false,
positionMs = 0L,
durationMs = 0L,
errorMessage = "Не удалось воспроизвести голосовое сообщение."
)
}
}
})
nextPlayer.setMediaItem(mediaItem)
nextPlayer.prepare()
nextPlayer.play()
startTicker()
emitEvent(attachmentId, true, 0L, durationMs)
} finally {
@@ -81,16 +139,8 @@ class AndroidVoiceNotePlayerService {
durationMs = 0L
stopTicker()
existingPlayer?.let { mediaPlayer ->
try {
if (safeIsPlaying(mediaPlayer)) {
mediaPlayer.stop()
}
} catch (_: Throwable) {
}
mediaPlayer.reset()
mediaPlayer.release()
existingPlayer?.let { exoPlayer ->
exoPlayer.release()
}
if (attachmentId != null) {
@@ -134,17 +184,49 @@ class AndroidVoiceNotePlayerService {
)
}
private fun safeIsPlaying(mediaPlayer: MediaPlayer?): Boolean =
private fun emitEvent(
attachmentId: String,
isPlaying: Boolean,
positionMs: Long,
durationMs: Long,
errorMessage: String?
) {
_events.tryEmit(
VoiceNotePlaybackEvent(
attachmentId = attachmentId,
isPlaying = isPlaying,
positionMs = positionMs.coerceAtLeast(0L),
durationMs = durationMs.coerceAtLeast(0L),
errorMessage = errorMessage
)
)
}
private fun safeIsPlaying(exoPlayer: ExoPlayer?): Boolean =
try {
mediaPlayer?.isPlaying == true
exoPlayer?.isPlaying == true
} catch (_: Throwable) {
false
}
private fun safePosition(mediaPlayer: MediaPlayer?): Long =
private fun safePosition(exoPlayer: ExoPlayer?): Long =
try {
mediaPlayer?.currentPosition?.toLong()?.coerceAtLeast(0L) ?: 0L
exoPlayer?.currentPosition?.coerceAtLeast(0L) ?: 0L
} catch (_: Throwable) {
0L
}
private fun safeDuration(exoPlayer: ExoPlayer?): Long =
try {
exoPlayer?.duration?.takeIf { it != C.TIME_UNSET }?.coerceAtLeast(0L) ?: 0L
} catch (_: Throwable) {
0L
}
private companion object {
val VoiceNoteAudioAttributes: AudioAttributes = AudioAttributes.Builder()
.setUsage(C.USAGE_MEDIA)
.setContentType(C.AUDIO_CONTENT_TYPE_SPEECH)
.build()
}
}
@@ -1,6 +1,8 @@
package com.seven.ikar.kotlin.feature.chat
import android.content.Context
import android.media.AudioFocusRequest
import android.media.AudioManager
import android.media.MediaRecorder
import android.os.Build
import kotlinx.coroutines.CancellationException
@@ -28,11 +30,15 @@ class AndroidVoiceNoteRecorderService(
private var currentFile: File? = null
private var startedAtMs: Long = 0L
private var tickerJob: Job? = null
private var activeAudioFocusRequest: AudioFocusRequest? = null
private var hasLegacyAudioFocus: Boolean = false
private val audioManager = context.getSystemService(Context.AUDIO_SERVICE) as AudioManager
private val audioFocusChangeListener = AudioManager.OnAudioFocusChangeListener { }
val isRecording: Boolean
get() = recorder != null
suspend fun start() {
suspend fun start(pauseExternalAudio: Boolean = false) {
sync.acquire()
try {
if (recorder != null) {
@@ -48,6 +54,7 @@ class AndroidVoiceNoteRecorderService(
}
try {
requestAudioFocusIfNeeded(pauseExternalAudio)
nextRecorder.setAudioSource(MediaRecorder.AudioSource.MIC)
nextRecorder.setOutputFormat(MediaRecorder.OutputFormat.MPEG_4)
nextRecorder.setAudioEncoder(MediaRecorder.AudioEncoder.AAC)
@@ -60,6 +67,7 @@ class AndroidVoiceNoteRecorderService(
nextRecorder.reset()
nextRecorder.release()
file.delete()
abandonAudioFocus()
throw error
}
@@ -87,6 +95,7 @@ class AndroidVoiceNoteRecorderService(
if (currentRecorder == null) {
_elapsedMs.value = 0L
abandonAudioFocus()
return null
}
@@ -98,6 +107,7 @@ class AndroidVoiceNoteRecorderService(
} finally {
currentRecorder.reset()
currentRecorder.release()
abandonAudioFocus()
}
_elapsedMs.value = 0L
@@ -145,4 +155,40 @@ class AndroidVoiceNoteRecorderService(
tickerJob?.cancel()
tickerJob = null
}
private fun requestAudioFocusIfNeeded(enabled: Boolean) {
if (!enabled) {
return
}
if (Build.VERSION.SDK_INT >= 26) {
val request = AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN_TRANSIENT)
.setOnAudioFocusChangeListener(audioFocusChangeListener)
.build()
if (audioManager.requestAudioFocus(request) == AudioManager.AUDIOFOCUS_REQUEST_GRANTED) {
activeAudioFocusRequest = request
}
} else {
@Suppress("DEPRECATION")
if (audioManager.requestAudioFocus(
audioFocusChangeListener,
AudioManager.STREAM_MUSIC,
AudioManager.AUDIOFOCUS_GAIN_TRANSIENT
) == AudioManager.AUDIOFOCUS_REQUEST_GRANTED
) {
hasLegacyAudioFocus = true
}
}
}
private fun abandonAudioFocus() {
if (Build.VERSION.SDK_INT >= 26) {
activeAudioFocusRequest?.let(audioManager::abandonAudioFocusRequest)
activeAudioFocusRequest = null
} else if (hasLegacyAudioFocus) {
@Suppress("DEPRECATION")
audioManager.abandonAudioFocus(audioFocusChangeListener)
hasLegacyAudioFocus = false
}
}
}
@@ -0,0 +1,41 @@
package com.seven.ikar.kotlin.feature.chat
import android.content.Context
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.SharedFlow
class ChatDraftStore(context: Context) {
private val preferences =
context.getSharedPreferences(PREFERENCES_NAME, Context.MODE_PRIVATE)
private val _changes = MutableSharedFlow<Unit>(extraBufferCapacity = 16)
val changes: SharedFlow<Unit> = _changes
fun read(chatId: String): String =
preferences.getString(key(chatId), null).orEmpty()
fun save(chatId: String, draft: String) {
val normalizedDraft = draft.take(MAX_DRAFT_LENGTH)
if (normalizedDraft.isBlank()) {
clear(chatId)
return
}
preferences.edit()
.putString(key(chatId), normalizedDraft)
.apply()
_changes.tryEmit(Unit)
}
fun clear(chatId: String) {
preferences.edit().remove(key(chatId)).apply()
_changes.tryEmit(Unit)
}
private fun key(chatId: String): String = "draft_$chatId"
private companion object {
private const val PREFERENCES_NAME = "ikar_kotlin_chat_drafts"
private const val MAX_DRAFT_LENGTH = 16_384
}
}
@@ -12,6 +12,7 @@ import androidx.lifecycle.viewModelScope
import com.seven.ikar.kotlin.app.AppContainer
import com.seven.ikar.kotlin.core.contacts.PhoneNumberNormalizer
import com.seven.ikar.kotlin.core.model.AttachmentDto
import com.seven.ikar.kotlin.core.model.AttachmentKind
import com.seven.ikar.kotlin.core.model.AuthSessionDto
import com.seven.ikar.kotlin.core.model.BotCommandDto
import com.seven.ikar.kotlin.core.model.CallMediaType
@@ -37,6 +38,7 @@ import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.io.File
import java.time.OffsetDateTime
import java.time.ZoneId
import java.time.ZoneOffset
@@ -111,6 +113,7 @@ data class ChatUiState(
val canSubscribe: Boolean = false,
val canManageChannel: Boolean = false,
val canPostAsChannel: Boolean = false,
val canDeleteForEveryone: Boolean = false,
val publicUsername: String? = null,
val linkedDiscussionChatId: String? = null,
val channelDescription: String? = null,
@@ -128,6 +131,7 @@ data class ChatUiState(
val attachmentPlayback: Map<String, VoiceAttachmentPlaybackUiState> = emptyMap(),
val botCommands: List<BotCommandDto> = emptyList(),
val previewImageAttachmentId: String? = null,
val previewVideoAttachmentId: String? = null,
val firstUnreadMessageId: String? = null,
val selectedMessageIds: Set<String> = emptySet(),
val isDeleteSelectionDialogVisible: Boolean = false,
@@ -139,7 +143,8 @@ data class ChatUiState(
val scrollRequest: ChatScrollRequest? = null,
val isSendingMessage: Boolean = false,
val isDeletingChat: Boolean = false,
val isChatDeleted: Boolean = false
val isChatDeleted: Boolean = false,
val attachmentCacheVersion: Long = 0L
) {
val isMessageSelectionMode: Boolean
get() = selectedMessageIds.isNotEmpty()
@@ -165,7 +170,13 @@ class ChatViewModel(
private val container: AppContainer,
private val chatId: String
) : ViewModel() {
private val _uiState = MutableStateFlow(ChatUiState(chatId = chatId, session = container.sessionStore.read()))
private val _uiState = MutableStateFlow(
ChatUiState(
chatId = chatId,
session = container.sessionStore.read(),
draftText = container.chatDraftStore.read(chatId)
)
)
val uiState: StateFlow<ChatUiState> = _uiState.asStateFlow()
private val cachedAttachmentFiles = mutableMapOf<String, LocalAttachmentFile>()
@@ -179,6 +190,8 @@ class ChatViewModel(
private var searchJob: Job? = null
private var typingStateSent = false
private var lastTypingSentAtMillis = 0L
private var lastLocallyReadMessageId: String? = null
private var lastDeliveredMessageId: String? = null
init {
attachRealtime()
@@ -192,6 +205,9 @@ class ChatViewModel(
fun updateDraft(value: String) {
_uiState.value = _uiState.value.copy(draftText = value)
if (editingMessage == null) {
container.chatDraftStore.save(chatId, value)
}
notifyTyping(value.isNotBlank())
}
@@ -307,6 +323,56 @@ class ChatViewModel(
_uiState.value = _uiState.value.copy(draftText = _uiState.value.draftText + emoji)
}
internal fun sendPickerMedia(item: PickerMediaItem) {
val state = _uiState.value
if (!state.canSendMessages || state.isSendingMessage) {
return
}
if (editingMessage != null) {
_uiState.value = state.copy(errorMessage = "Сначала завершите редактирование сообщения.")
return
}
viewModelScope.launch {
_uiState.value = _uiState.value.copy(isSendingMessage = true, errorMessage = null)
runCatching {
val attachment = when (item.kind) {
PickerMediaKind.Gif -> container.attachmentFileService.createBuiltInGifAttachment(
fileName = item.fileName,
base64Data = item.base64Data ?: error("GIF data is missing.")
)
PickerMediaKind.Sticker -> container.attachmentFileService.createBuiltInStickerAttachment(
fileName = item.fileName,
emoji = item.stickerEmoji ?: item.previewEmoji,
label = item.stickerLabel ?: item.title,
backgroundColor = item.backgroundColor,
accentColor = item.accentColor
)
}
container.transferQueueRepository.enqueueUpload(
chatId = chatId,
text = null,
replyToMessageId = state.replyTo?.messageId,
files = listOf(attachment)
)
}.onSuccess {
_uiState.value = _uiState.value.copy(
replyTo = null,
isSendingMessage = false,
errorMessage = null
)
}.onFailure { error ->
_uiState.value = _uiState.value.copy(
isSendingMessage = false,
errorMessage = error.message
)
}
}
}
fun clearErrorMessage() {
_uiState.value = _uiState.value.copy(errorMessage = null)
}
@@ -350,6 +416,12 @@ class ChatViewModel(
if (messageId.isBlank()) {
return
}
val state = _uiState.value
if (state.messages.none { it.id == messageId }) {
requestScrollToMessageInternal(messageId)
loadMessagesAround(messageId)
return
}
requestScrollToMessageInternal(messageId)
}
@@ -503,6 +575,57 @@ class ChatViewModel(
}
}
private fun loadMessagesAround(messageId: String) {
val state = _uiState.value
if (state.isLoadingOlderMessages || state.isLoading) {
return
}
viewModelScope.launch {
_uiState.value = _uiState.value.copy(isLoadingOlderMessages = true, errorMessage = null)
runCatching {
container.executeAuthorized { session ->
session to container.apiClient.getChatMessagesAround(
accessToken = session.accessToken,
chatId = chatId,
aroundMessageId = messageId
)
}
}.onSuccess { (session, page) ->
val currentState = _uiState.value
if (page.messages.none { it.id == messageId }) {
_uiState.value = currentState.copy(
isLoadingOlderMessages = false,
scrollRequest = null,
errorMessage = "Сообщение не найдено."
)
return@onSuccess
}
val mergedMessages = (page.messages + currentState.messages)
.distinctBy { it.id }
.sortedBy { it.sentAt }
val selectedMessageIds = sanitizeSelectedMessageIds(currentState.selectedMessageIds, mergedMessages)
_uiState.value = currentState.copy(
messages = mergedMessages,
hasMoreMessages = page.hasMoreMessages || currentState.hasMoreMessages,
isLoadingOlderMessages = false,
selectedMessageIds = selectedMessageIds,
isDeleteSelectionDialogVisible = currentState.isDeleteSelectionDialogVisible && selectedMessageIds.isNotEmpty(),
isDeletingSelectedMessages = currentState.isDeletingSelectedMessages && selectedMessageIds.isNotEmpty()
)
cacheMessages(session.user.id, page.messages)
requestScrollToMessageInternal(messageId)
}.onFailure { error ->
_uiState.value = _uiState.value.copy(
isLoadingOlderMessages = false,
errorMessage = error.message
)
}
}
}
fun sendMessage() {
val state = _uiState.value
if (!state.canSendMessages || state.isSendingMessage) {
@@ -531,6 +654,7 @@ class ChatViewModel(
)
}.onSuccess {
clearPendingAttachment()
clearStoredDraft()
_uiState.value = _uiState.value.copy(draftText = "", replyTo = null, isSendingMessage = false)
}.onFailure { error ->
_uiState.value = _uiState.value.copy(isSendingMessage = false, errorMessage = error.message)
@@ -546,6 +670,7 @@ class ChatViewModel(
}
if (optimisticMessage != null) {
clearStoredDraft()
val optimisticMessages = (state.messages + optimisticMessage)
.distinctBy { it.id }
.sortedBy { it.sentAt }
@@ -607,6 +732,7 @@ class ChatViewModel(
if (localMessageId != null) {
replaceLocalMessage(localMessageId, message)
} else {
clearStoredDraft()
_uiState.value = _uiState.value.copy(draftText = "", replyTo = null, isSendingMessage = false)
replaceOrInsertMessage(message)
}
@@ -666,6 +792,7 @@ class ChatViewModel(
}
}.onSuccess { message ->
if (commandText.startsWith("/turn ", ignoreCase = true)) {
clearStoredDraft()
_uiState.value = _uiState.value.copy(draftText = "")
}
replaceOrInsertMessage(message)
@@ -684,6 +811,7 @@ class ChatViewModel(
}
}.onSuccess { ownerUserId ->
clearPendingAttachment()
clearStoredDraft()
cancelEditMessageInternal(clearDraft = true)
container.chatScrollStateStore.clear(chatId)
clearCachedChat(ownerUserId)
@@ -706,7 +834,7 @@ class ChatViewModel(
}
}
fun deleteChat() {
fun deleteChat(forEveryone: Boolean = false) {
val state = _uiState.value
if (state.isDeletingChat || state.isChatDeleted) {
return
@@ -719,12 +847,13 @@ class ChatViewModel(
container.apiClient.deleteChat(
accessToken = session.accessToken,
chatId = chatId,
forEveryone = false
forEveryone = forEveryone
)
session.user.id
}
}.onSuccess { ownerUserId ->
clearPendingAttachment()
clearStoredDraft()
cancelEditMessageInternal(clearDraft = true)
container.chatScrollStateStore.clear(chatId)
container.pushSettings.resetChatNotificationPreferences(chatId)
@@ -779,6 +908,7 @@ class ChatViewModel(
}
}.onSuccess { ownerUserId ->
clearPendingAttachment()
clearStoredDraft()
cancelEditMessageInternal(clearDraft = true)
container.chatScrollStateStore.clear(chatId)
container.pushSettings.resetChatNotificationPreferences(chatId)
@@ -960,11 +1090,16 @@ class ChatViewModel(
return
}
if (attachment.kind == com.seven.ikar.kotlin.core.model.AttachmentKind.VoiceNote) {
if (attachment.isVoiceNoteAttachment()) {
toggleVoiceAttachmentPlayback(attachment)
return
}
if (attachment.isVideoAttachment()) {
showVideoPreviewAttachment(attachment.id)
return
}
viewModelScope.launch {
runCatching {
val local = ensureAttachmentDownloaded(attachment)
@@ -987,6 +1122,14 @@ class ChatViewModel(
_uiState.value = _uiState.value.copy(previewImageAttachmentId = attachmentId)
}
fun dismissVideoPreview() {
_uiState.value = _uiState.value.copy(previewVideoAttachmentId = null)
}
fun showVideoPreviewAttachment(attachmentId: String) {
_uiState.value = _uiState.value.copy(previewVideoAttachmentId = attachmentId)
}
fun shareAttachmentExternally(attachment: AttachmentDto) {
if (isAttachmentInProtectedMessage(attachment)) {
_uiState.value = _uiState.value.copy(errorMessage = "Защищённый контент нельзя пересылать.")
@@ -1034,7 +1177,9 @@ class ChatViewModel(
viewModelScope.launch {
runCatching {
container.voiceNoteRecorderService.start()
container.voiceNoteRecorderService.start(
pauseExternalAudio = container.chatSettings.snapshot.value.pauseMusicOnRecordEnabled
)
}.onSuccess {
_uiState.value = _uiState.value.copy(
isRecordingVoiceNote = true,
@@ -1079,7 +1224,8 @@ class ChatViewModel(
fileName = file.name,
contentType = "audio/mp4",
contentLength = file.length()
) { file.inputStream() }
) { file.inputStream() },
isVoiceNote = true
)
}
} finally {
@@ -1165,9 +1311,31 @@ class ChatViewModel(
container.callManager.endCurrentCall()
}
fun attachmentPreviewUrl(attachment: AttachmentDto): String = when {
attachment.downloadPath.startsWith("http://") || attachment.downloadPath.startsWith("https://") -> attachment.downloadPath
else -> ServerConfig.absoluteMessengerUrl(attachment.downloadPath)
fun attachmentPreviewUrl(attachment: AttachmentDto): String {
val cachedFile = cachedAttachmentFiles[attachment.id]
?.localPath
?.let(::File)
?.takeIf { it.exists() }
?: container.attachmentFileService
.createAttachmentCacheFile(attachment.id, attachment.fileName)
.takeIf { it.exists() }
?.also { file ->
cachedAttachmentFiles[attachment.id] = LocalAttachmentFile(
fileName = attachment.fileName,
contentType = attachment.contentType,
fileSizeBytes = file.length(),
localPath = file.absolutePath
)
}
if (cachedFile != null) {
return cachedFile.toURI().toString()
}
return when {
attachment.downloadPath.startsWith("http://") || attachment.downloadPath.startsWith("https://") -> attachment.downloadPath
else -> ServerConfig.absoluteMessengerUrl(attachment.downloadPath)
}
}
fun showCallHeaderButton(state: ChatUiState = uiState.value): Boolean =
@@ -1305,6 +1473,7 @@ class ChatViewModel(
canSubscribe = chat.canSubscribe,
canManageChannel = chat.canManageChannel,
canPostAsChannel = chat.canPostAsChannel,
canDeleteForEveryone = chat.canDeleteForEveryone,
publicUsername = chat.publicUsername,
linkedDiscussionChatId = chat.linkedDiscussionChatId,
channelDescription = chat.description,
@@ -1326,6 +1495,11 @@ class ChatViewModel(
}
)
cacheMessages(session.user.id, chat.messages)
acknowledgeLatestIncomingDelivered(orderedMessages, session.user.id)
val pendingScrollMessageId = _uiState.value.scrollRequest?.messageId
if (pendingScrollMessageId != null && orderedMessages.none { it.id == pendingScrollMessageId }) {
loadMessagesAround(pendingScrollMessageId)
}
scheduleAutoDownloads(orderedMessages)
refreshCallBanner()
}
@@ -1335,9 +1509,8 @@ class ChatViewModel(
container.realtimeService.messagesCreated.collect { message ->
if (message.chatId == chatId) {
replaceOrInsertMessage(message)
requestScrollToMessageInternal(message.id)
if (message.sender.id != _uiState.value.session?.user?.id) {
acknowledgeRead(message.id)
acknowledgeDelivered(message.id)
}
}
}
@@ -1463,7 +1636,10 @@ class ChatViewModel(
positionMs = event.positionMs,
durationMs = event.durationMs
)
_uiState.value = _uiState.value.copy(attachmentPlayback = nextMap)
_uiState.value = _uiState.value.copy(
attachmentPlayback = nextMap,
errorMessage = event.errorMessage ?: _uiState.value.errorMessage
)
}
}
}
@@ -1505,6 +1681,7 @@ class ChatViewModel(
localPath = target.absolutePath
)
cachedAttachmentFiles[attachment.id] = cached
notifyAttachmentCacheChanged()
return cached
}
@@ -1517,10 +1694,27 @@ class ChatViewModel(
val downloaded = container.apiClient.downloadAttachmentTo(session.accessToken, attachment, target)
val cached = container.attachmentFileService.cacheDownloadedAttachment(downloaded)
cachedAttachmentFiles[attachment.id] = cached
saveImageToGalleryIfEnabled(attachment, cached)
notifyAttachmentCacheChanged()
cached
}
}
private suspend fun saveImageToGalleryIfEnabled(attachment: AttachmentDto, local: LocalAttachmentFile) {
val settings = container.appSettings.loadSnapshot()
if (!settings.saveMediaToGallery || !attachment.isImageAttachment()) {
return
}
runCatching {
container.attachmentFileService.saveCachedImageToGalleryIfMissing(
localPath = local.localPath,
fileName = local.fileName,
contentType = local.contentType
)
}
}
private fun scheduleAutoDownloads(messages: List<MessageDto>) {
val settings = container.appSettings.loadSnapshot()
if (!settings.autoDownloadPhotos && !settings.autoDownloadVideosOnWifi && !settings.autoDownloadFilesOnWifi) {
@@ -1549,7 +1743,7 @@ class ChatViewModel(
isUnmetered: Boolean
): Boolean = when {
attachment.isImageAttachment() -> settings.autoDownloadPhotos
attachment.contentType.startsWith("video/", ignoreCase = true) -> settings.autoDownloadVideosOnWifi && isUnmetered
attachment.isVideoAttachment() -> settings.autoDownloadVideosOnWifi && isUnmetered
else -> settings.autoDownloadFilesOnWifi && isUnmetered
}
@@ -1572,7 +1766,22 @@ class ChatViewModel(
val local = ensureAttachmentDownloaded(attachment)
container.voiceNotePlayerService.toggle(
attachmentId = attachment.id,
localFilePath = local.localPath
localFilePath = local.localPath,
pauseExternalAudio = container.chatSettings.snapshot.value.pauseMusicOnPlaybackEnabled
)
}.onFailure { error ->
_uiState.value = _uiState.value.copy(errorMessage = error.message)
}
}
}
fun toggleInlineAudioPlayback(audioId: String, audioUrl: String) {
viewModelScope.launch {
runCatching {
container.voiceNotePlayerService.toggleUri(
attachmentId = audioId,
uri = Uri.parse(audioUrl),
pauseExternalAudio = container.chatSettings.snapshot.value.pauseMusicOnPlaybackEnabled
)
}.onFailure { error ->
_uiState.value = _uiState.value.copy(errorMessage = error.message)
@@ -1585,12 +1794,16 @@ class ChatViewModel(
_uiState.value = _uiState.value.copy(pendingAttachment = null)
}
private fun clearStoredDraft() {
container.chatDraftStore.clear(chatId)
}
private fun cancelEditMessageInternal(clearDraft: Boolean) {
editingMessage = null
_uiState.value = _uiState.value.copy(
editingMessageId = null,
editingMessageBanner = "",
draftText = if (clearDraft) "" else _uiState.value.draftText
draftText = if (clearDraft) container.chatDraftStore.read(chatId) else _uiState.value.draftText
)
}
@@ -1708,6 +1921,11 @@ class ChatViewModel(
}
}
private fun notifyAttachmentCacheChanged() {
val currentState = _uiState.value
_uiState.value = currentState.copy(attachmentCacheVersion = currentState.attachmentCacheVersion + 1L)
}
private fun clearCachedChat(ownerUserId: String) {
viewModelScope.launch(Dispatchers.IO) {
container.chatMessageCache.clearChat(ownerUserId, chatId)
@@ -1723,11 +1941,57 @@ class ChatViewModel(
chatId = chatId,
messageId = messageId
)
session.user.id
}
}.onSuccess { ownerUserId ->
markChatReadLocally(ownerUserId, messageId)
}
}
}
private fun acknowledgeLatestIncomingDelivered(messages: List<MessageDto>, currentUserId: String) {
val latestIncomingMessageId = messages
.asReversed()
.firstOrNull { message ->
message.sender.id != currentUserId &&
!message.isDeleted() &&
!message.isLocalMessage()
}
?.id
?: return
acknowledgeDelivered(latestIncomingMessageId)
}
private fun acknowledgeDelivered(messageId: String) {
if (lastDeliveredMessageId == messageId) {
return
}
lastDeliveredMessageId = messageId
viewModelScope.launch {
runCatching {
container.executeAuthorized { session ->
container.apiClient.acknowledgeDelivered(
accessToken = session.accessToken,
chatId = chatId,
messageId = messageId
)
}
}
}
}
private fun markChatReadLocally(ownerUserId: String, readThroughMessageId: String) {
if (lastLocallyReadMessageId == readThroughMessageId) {
return
}
lastLocallyReadMessageId = readThroughMessageId
viewModelScope.launch(Dispatchers.IO) {
runCatching { container.chatListCache.markChatRead(ownerUserId, chatId) }
container.realtimeService.notifyChatsChangedLocally()
}
}
private fun resolvePeer(chat: ChatDetailsDto, session: AuthSessionDto): UserSummaryDto? {
if (chat.type != ChatType.Direct) {
return null
@@ -1825,8 +2089,10 @@ class ChatViewModel(
state.activeCall == null
}
internal fun MessageDto.canManage(currentUserId: String?): Boolean =
currentUserId != null && sender.id == currentUserId && deletedAt == null
internal fun MessageDto.canManage(currentUserId: String?, canPostAsChannel: Boolean = false): Boolean =
currentUserId != null &&
deletedAt == null &&
(sender.id == currentUserId || (chatType == ChatType.Channel && canPostAsChannel))
internal fun MessageDto.canDelete(currentUserId: String?): Boolean =
currentUserId != null && deletedAt == null
@@ -1945,6 +2211,104 @@ internal fun AttachmentDto.isImageAttachment(): Boolean =
fileName.endsWith(".webp", ignoreCase = true) ||
fileName.endsWith(".gif", ignoreCase = true)
internal fun AttachmentDto.isVideoAttachment(): Boolean {
if (kind == AttachmentKind.VoiceNote) {
return false
}
val normalizedContentType = contentType
.substringBefore(';')
.trim()
.lowercase(Locale.US)
if (normalizedContentType in VoiceLikeContentTypes) {
return false
}
if (normalizedContentType.startsWith("video/") || normalizedContentType in VideoLikeContentTypes) {
return true
}
val cleanedFileName = fileName.substringBefore('?').substringBefore('#').trim()
val extension = cleanedFileName
.substringAfterLast('.', missingDelimiterValue = "")
.lowercase(Locale.US)
.takeIf { it.isNotBlank() && it != cleanedFileName.lowercase(Locale.US) }
if (extension in VoiceLikeFileSuffixes) {
return false
}
return extension in VideoLikeFileSuffixes
}
internal fun AttachmentDto.isVoiceNoteAttachment(): Boolean {
if (kind == AttachmentKind.VoiceNote) {
return true
}
val normalizedContentType = contentType
.substringBefore(';')
.trim()
.lowercase(Locale.US)
if (normalizedContentType in VoiceLikeContentTypes) {
return true
}
val cleanedFileName = fileName.substringBefore('?').substringBefore('#').trim()
val extension = cleanedFileName
.substringAfterLast('.', missingDelimiterValue = "")
.lowercase(Locale.US)
.takeIf { it.isNotBlank() && it != cleanedFileName.lowercase(Locale.US) }
if (extension in VoiceLikeFileSuffixes) {
return true
}
val lastToken = cleanedFileName
.split(' ', '.', '_', '-', '(', ')', '[', ']')
.lastOrNull { it.isNotBlank() }
?.lowercase(Locale.US)
return lastToken in VoiceLikeFileSuffixes
}
private val VoiceLikeContentTypes = setOf(
"audio/ogg",
"audio/opus",
"application/ogg",
"application/opus",
"application/x-ogg",
"video/ogg"
)
private val VideoLikeContentTypes = setOf(
"application/mp4",
"application/x-mp4",
"video/x-m4v"
)
private val VideoLikeFileSuffixes = setOf("mp4", "m4v", "mov", "webm", "mkv")
private val VoiceLikeFileSuffixes = setOf("oga", "ogg", "opus")
internal fun String.looksLikeAudioFileName(): Boolean {
val cleaned = substringBefore('?').substringBefore('#').trim()
if (cleaned.isBlank()) {
return false
}
val audioSuffixes = setOf("aac", "flac", "m4a", "mp3", "oga", "ogg", "opus", "wav", "webm")
val extension = cleaned.substringAfterLast('.', missingDelimiterValue = "")
.lowercase(Locale.US)
.takeIf { it != cleaned.lowercase(Locale.US) }
if (extension in audioSuffixes) {
return true
}
val lastToken = cleaned
.split(' ', '.', '_', '-', '(', ')', '[', ']')
.lastOrNull { it.isNotBlank() }
?.lowercase(Locale.US)
return lastToken in audioSuffixes
}
internal fun buildBotQuickActions(message: MessageDto): List<BotQuickAction> {
if (!message.sender.isBot || message.text.isBlank()) {
return emptyList()
@@ -0,0 +1,194 @@
package com.seven.ikar.kotlin.feature.chat
internal enum class PickerMediaKind {
Gif,
Sticker
}
internal data class PickerMediaItem(
val key: String,
val title: String,
val previewEmoji: String,
val fileName: String,
val kind: PickerMediaKind,
val base64Data: String? = null,
val stickerEmoji: String? = null,
val stickerLabel: String? = null,
val backgroundColor: Int = 0xFFFFFFFF.toInt(),
val accentColor: Int = 0xFF2AABEE.toInt()
)
internal data class PickerMediaPack(
val key: String,
val title: String,
val icon: String,
val items: List<PickerMediaItem>
)
internal val BuiltInGifPacks = listOf(
PickerMediaPack(
key = "reactions",
title = "Реакции",
icon = "\u2764\uFE0F",
items = listOf(
PickerMediaItem(
key = "gif_heart",
title = "Сердце",
previewEmoji = "\u2764\uFE0F",
fileName = "ikar_gif_heart.gif",
kind = PickerMediaKind.Gif,
base64Data = GifHeartBase64
),
PickerMediaItem(
key = "gif_check",
title = "Готово",
previewEmoji = "\u2705",
fileName = "ikar_gif_check.gif",
kind = PickerMediaKind.Gif,
base64Data = GifCheckBase64
)
)
),
PickerMediaPack(
key = "energy",
title = "Акцент",
icon = "\uD83D\uDD25",
items = listOf(
PickerMediaItem(
key = "gif_fire",
title = "Огонь",
previewEmoji = "\uD83D\uDD25",
fileName = "ikar_gif_fire.gif",
kind = PickerMediaKind.Gif,
base64Data = GifFireFixedBase64
),
PickerMediaItem(
key = "gif_star",
title = "Звезда",
previewEmoji = "\u2B50",
fileName = "ikar_gif_star.gif",
kind = PickerMediaKind.Gif,
base64Data = GifStarBase64
)
)
),
PickerMediaPack(
key = "ikar_motion",
title = "Икар motion",
icon = "\uD83D\uDCA0",
items = listOf(
gif("gif_ikar_pulse", "Пульс", "\uD83D\uDCA0", GifIkarPulseBase64),
gif("gif_ikar_orbit", "Орбита", "\uD83E\uDE90", GifIkarOrbitBase64),
gif("gif_ikar_spark", "Искра", "\u2728", GifIkarSparkBase64)
)
),
PickerMediaPack(
key = "ikar_actions",
title = "Икар действия",
icon = "\uD83D\uDE80",
items = listOf(
gif("gif_ikar_launch", "Запуск", "\uD83D\uDE80", GifIkarLaunchBase64),
gif("gif_ikar_typing", "Пишу", "\uD83D\uDCAC", GifIkarTypingBase64),
gif("gif_ikar_shield", "Защита", "\uD83D\uDEE1\uFE0F", GifIkarShieldBase64)
)
)
)
internal val BuiltInStickerPacks = listOf(
PickerMediaPack(
key = "daily",
title = "На каждый день",
icon = "\uD83D\uDC4B",
items = listOf(
sticker("sticker_hi", "Привет", "\uD83D\uDC4B", 0xFFE7F1FF.toInt(), 0xFF2AABEE.toInt()),
sticker("sticker_ok", "Ок", "\uD83D\uDC4D", 0xFFEAF8EF.toInt(), 0xFF2FA86B.toInt()),
sticker("sticker_thanks", "Спасибо", "\uD83D\uDE4F", 0xFFFFF3D8.toInt(), 0xFFFFA726.toInt()),
sticker("sticker_laugh", "Смешно", "\uD83D\uDE02", 0xFFFFEEF5.toInt(), 0xFFE91E63.toInt()),
sticker("sticker_love", "Люблю", "\uD83E\uDD70", 0xFFFFE5ED.toInt(), 0xFFE84370.toInt()),
sticker("sticker_sad", "Грустно", "\uD83D\uDE22", 0xFFEAF0FF.toInt(), 0xFF5B6DD6.toInt())
)
),
PickerMediaPack(
key = "work",
title = "Дела",
icon = "\uD83D\uDCCC",
items = listOf(
sticker("sticker_done", "Готово", "\u2705", 0xFFEAF8EF.toInt(), 0xFF229E5D.toInt()),
sticker("sticker_wait", "Жду", "\u23F3", 0xFFFFF6E6.toInt(), 0xFFE09123.toInt()),
sticker("sticker_idea", "Идея", "\uD83D\uDCA1", 0xFFFFF9D7.toInt(), 0xFFF4B400.toInt()),
sticker("sticker_rocket", "Запуск", "\uD83D\uDE80", 0xFFEAF6FF.toInt(), 0xFF1E88E5.toInt()),
sticker("sticker_warning", "Важно", "\u26A0\uFE0F", 0xFFFFECE8.toInt(), 0xFFE25543.toInt()),
sticker("sticker_pin", "Закрепить", "\uD83D\uDCCC", 0xFFF2ECFF.toInt(), 0xFF7E57C2.toInt())
)
),
PickerMediaPack(
key = "ikar_signals",
title = "Икар сигналы",
icon = "\uD83D\uDCE1",
items = listOf(
sticker("sticker_ikar_online", "На связи", "\uD83D\uDCE1", 0xFFEAF6FF.toInt(), 0xFF2AABEE.toInt()),
sticker("sticker_ikar_seen", "Вижу", "\uD83D\uDC40", 0xFFEFFAF2.toInt(), 0xFF22C55E.toInt()),
sticker("sticker_ikar_route", "В пути", "\uD83E\uDDED", 0xFFFFF4E0.toInt(), 0xFFFF8A00.toInt()),
sticker("sticker_ikar_done", "Принял", "\u2705", 0xFFE8F8F0.toInt(), 0xFF14A46C.toInt()),
sticker("sticker_ikar_time", "Нужно время", "\u23F3", 0xFFF6F0FF.toInt(), 0xFF7C3AED.toInt()),
sticker("sticker_ikar_quiet", "Тихо", "\uD83C\uDF19", 0xFFEAF0FF.toInt(), 0xFF4F46E5.toInt())
)
),
PickerMediaPack(
key = "ikar_mood",
title = "Икар настроение",
icon = "\u2728",
items = listOf(
sticker("sticker_ikar_wow", "Вау", "\u2728", 0xFFFFF7D6.toInt(), 0xFFEAB308.toInt()),
sticker("sticker_ikar_power", "Погнали", "\u26A1", 0xFFFFECEB.toInt(), 0xFFEF4444.toInt()),
sticker("sticker_ikar_focus", "Фокус", "\uD83C\uDFAF", 0xFFEAF5FF.toInt(), 0xFF0284C7.toInt()),
sticker("sticker_ikar_fix", "Исправлю", "\uD83D\uDEE0\uFE0F", 0xFFF1F5F9.toInt(), 0xFF475569.toInt()),
sticker("sticker_ikar_hug", "Поддержка", "\uD83E\uDD17", 0xFFFFEAF2.toInt(), 0xFFE11D48.toInt()),
sticker("sticker_ikar_light", "Идея", "\uD83D\uDCA1", 0xFFFFF9DB.toInt(), 0xFFF59E0B.toInt())
)
)
)
private fun gif(
key: String,
title: String,
emoji: String,
base64Data: String
) = PickerMediaItem(
key = key,
title = title,
previewEmoji = emoji,
fileName = "ikar_$key.gif",
kind = PickerMediaKind.Gif,
base64Data = base64Data
)
private fun sticker(
key: String,
title: String,
emoji: String,
backgroundColor: Int,
accentColor: Int
) = PickerMediaItem(
key = key,
title = title,
previewEmoji = emoji,
fileName = "ikar_$key.png",
kind = PickerMediaKind.Sticker,
stickerEmoji = emoji,
stickerLabel = title,
backgroundColor = backgroundColor,
accentColor = accentColor
)
private const val GifIkarPulseBase64 = "R0lGODlhIAAgAIEAACqr7g8XKgAAAAAAACH/C05FVFNDQVBFMi4wAwEAAAAh+QQICgAAACwAAAAAIAAgAAAIrQADCBxIsKDBgwgTKlxoEIDDhxAjAmAo0CHFgRYVTmwosWHCjQQzFhRZ8SDIiidNnkwZYOXFlhhHhnxZsqZNlhQ3urRJU+dMmCofIpy4EydRi0aBljQqkSRGlyyPRlT6dGbUpkJHQuWI9apVrlg5fpXZlCrKsVrLio1pdilSk1R9BnUac2fbnDxB4mQol+1djTzzvtQLt6pGwkO1qvz5MaXaszm7dqRJubLlAAEBACH5BAgKAAAALAAAAAAgACAAgSqr7g8XKgAAAAAAAAizAAMIHEiwoMGDCBMqXGgQgMOHECMCYCjQYUGJEwdaVJix4saLHzs2JCgyYceSFUlSTMlSZcuVGUueXHnxZUyEEXEGEDkR5U6ZPj/uHFpTJ0iVMkkKBap05M+cRF16LPoUokaqP4ti3JhUY9etN6VmdQn2ZdSxVz1CPXtWKEuMZtuy5bn06FWUPuPWDcq0Ic+4at3e1UoTqVO2DMNSzXtQ8WHGZBdyFfwUrWSwEgtr3sxZYEAAIfkECAoAAAAsAAAAACAAIACBKqvuDxcqAAAAAAAACLYAAwgcSLCgwYMIEypcaBCAw4cQIwJgKNBhRYsNLWJMOLEixY4dD4IMyVCjSI8UC04k6ZHlwIgIN6J0KTNATZs4X+Z86ZKnSpI1ezZUSTDoR6I+df6kWbSp04swlV6UuhMqRKQ2QwItKlGmVq1Ps3Z9unErz7FSy4YVe5Us2LBoUU6dK/fsUKpGS2LNupfj3pU/ObI0SnNw4aFCo2Y8KXThTZ2PY0b22ZhrZctdJaYUqXmzZ4YBAQAh+QQICgAAACwAAAAAIAAgAIEqq+4PFyoAAAAAAAAItgADCBxIsKDBgwgTAljIEEDChwEaSpzoEKLAhhYjLoTosGLGix4NdhQ5keTBkQQ3mkwZEmTBliJfvgypkiNNlik/Rsw58KbOnS4v9vw5VGNRoCRhCjXKVCnFkzs7VnRaMuZIlTAp1hx6dWrSqjm7LmX5VKZYpGQxWo3q9StDqCg9KtU4d6rcoz/t4p2rsKdcnxkBj2Vq0+9MmQ9bUj28Ei/jtG8RK+R7kjJkrWp1Rp5p2aZEiwEBADs="
private const val GifIkarOrbitBase64 = "R0lGODlhIAAgAIIAAP/////RZiqr7otc9g8XKgAAAAAAAAAAACH/C05FVFNDQVBFMi4wAwEAAAAh+QQICgAAACwAAAAAIAAgAAAIdQAJCBxIsKDBgwgTKlzIsKHDhxAJAJgYceHEixUTXqSY8eBGAB0RYgxJsqTJgwNSpjxJQKVLky5jkoxJk6CAmw5pyhR4s2dDnS959hTwE+hKoT4ZGj2K9KFRmDVP7mTJMoBVqgSsaqWq9WrVrli3Yh1LtuzYgAAh+QQICgAAACwAAAAAIAAgAIL/////0WYqq+6LXPYPFyoAAAAAAAAAAAAIdAAJCBxIsKDBgwgTKlzIsKHDhxAjSpxIsaJFhAEyQgTAMWHGjw45isT4MUBIkQBIgiQ4oGVLgihTqizosibMjhBr6rSos2fFnjspArUpdOjLokZ5Dr1IAChTgUGfSp1KFaKAq1Svap2qFavUrgKyeq1KNmJAACH5BAgKAAAALAAAAAAgACAAgv/////RZiqr7otc9g8XKgAAAAAAAAAAAAh4AAkIHEiwoMGDCBMqXMiwocOHEBkGmBhx4cSLFRNepJjx4MYAHRFiDEmyZMcBKFGaFJiypcmWMB8KmEkQps2GM3MOtBmTYU6aLHmmxPlzp1CVRAXUPDqA5NGXN1f2XEmVIYCrVQlc3Vp1K1aqXgFk5Zq1rNmzFQMCACH5BAgKAAAALAAAAAAgACAAgv/////RZiqr7otc9g8XKgAAAAAAAAAAAAh6AAkIHEiwoMGDCBMqXMiwocOCAiIKeMhQokSKCi1GxJhQ40SOCC2CHEmypEmQA1KmPClQpcuTLmOWjEmTJE2ZI2++zKlzJc+eDwEIJQhUYICjCYUqJVrT6FGkB5UOZaqS4FOoBqUCcHg1QNKpXLGyHEu2rNmzaNOeDQgAOw=="
private const val GifIkarLaunchBase64 = "R0lGODlhIAAgAIEAAP/RZv9rNQ8XKgAAACH/C05FVFNDQVBFMi4wAwEAAAAh+QQICgAAACwAAAAAIAAgAAAIaAAFCBxIsKDBgwgTKlzIsKHDhxAjSpxIsaLFixgzLgygsSDHjgIDiAQpQOTHjiZBmhypceVJjC5bunxZcSbNiTZZ1sx5MedNiD51Sgz60yFRikSFPjQJoKlTACkrPnWqcWpHq1ebOgwIACH5BAgKAAAALAAAAAAgACAAgf/RZv9rNQ8XKgAAAAhsAAUIHEiwoMGDCBMqXMiwocOHECNKnEixYsIAFi9mPBig48aCHTF+HBhypMCQHkeiFPlxpcqVLC3CjElxZsqKNmlKzCkzp86HPn86DFozqNCFIQEoXQqgZEWmS0dCNTlVKlOTAqJi1UpVqcOAACH5BAgKAAAALAAAAAAgACAAgf/RZv9rNQ8XKgAAAAhwAAUIHEiwoMGDCBMqXMiwocOHEBEGiOhwIsWFATJeVJjR4saDHT+C7OhRpECSJU2iNDkQpUaWLlNejPnyI02ZEW+KvFmTIk+cFXlu/NnzYUcASJMCCPlRaVKWAp1Cjap0KlUAVq9mjbqVa9evYCMGBAAh+QQICgAAACwAAAAAIAAgAIH/0Wb/azUPFyoAAAAIdwAFCBxIsKDBgwgTDgygsKFChg4jEgxAUaJFihAtOsSocSPGjB0PfgQZsuDIkiJHkkSpcmXIlhVRCoTpUiNNmQJoxiyps2bEnix7+nxIEYDRowA4ykR6FCdBpk6fIo06ECpVgU2vYjWqdSuArljBhhVLtqxZjQEBADs="
private const val GifIkarSparkBase64 = "R0lGODlhIAAgAIEAAP/////RZg8XKgAAACH/C05FVFNDQVBFMi4wAwEAAAAh+QQICgAAACwAAAAAIAAgAAAIXwAFCBxIsKDBgwgTKlzIsKHDhxAjPgQgsSHFigkBaNSIMWNHhRc/Pgwg8mCAkyUJnlyZUsDKlyVfyvwosybGmjhv4mTZcSdJkTlTzmzpEiVRgT+PKl3KtKnTp1CjNgwIACH5BAgKAAAALAAAAAAgACAAgf/////RZg8XKgAAAAhlAAUIHEiwoMGDCBMqXMiwocOHECM+BCCxIcWKCQFo1Igx48EAHQVeJBigZMiPJk+SLJlSpQCWLU/CZClz5kyMNnNGzMkzZsOeMHECrXnTZVGjQV0KpKl0qU+jTaNKnUq1qlWpAQEAIfkECAoAAAAsAAAAACAAIACB/////9FmDxcqAAAACG8ABQgcSLCgwYMIEypcyLChw4cQIz4EILEhRYMBKgoEwJFjwYwaN2IMAFLjRYIkS4ZEmVLlSgEtXYaMSfKlQJoyI+JsKXGnz4Y+g9ZkKJRmz6AvcdqEaXRpzKU3eUJlmnNlyqkDqybFyrWr169LAwIAIfkECAoAAAAsAAAAACAAIACB/////9FmDxcqAAAACHcABQgcSLCgwYMIEypcyLChw4cQEQaIaBDAwYkUCVosGKBjRgEAQoYk2BHjx40kPX4EybGkyZUDXb6EKUAmzZgyZ37MqRMiz5w+fwrtiXOoUZUSjwJ9qBQmz5s1l960CTVqyapWkUK9itVqV69fidIU+7WsWYYBAQA7"
private const val GifIkarTypingBase64 = "R0lGODlhIAAgAIEAAP///yLFXg8XKgAAACH/C05FVFNDQVBFMi4wAwEAAAAh+QQICgAAACwAAAAAIAAgAAAIegAFCBxIsKDBgwgTKlzIsKHDhxAjSpxIsaLFixgzCgzAsaPHjx4ZBpg4MmFJkghPbuT4UOVAlR8FAJgpcCaAgi43vvxos6dNgjkFnATp0yfQg0N5Fv35EinQkEyZNjXosmPLlBWDTo2o9SnIryE1ih1LtqzZs2jTXgwIACH5BAgKAAAALAAAAAAgACAAgf///yLFXg8XKgAAAAh8AAUIHEiwoMGDCBMqXMiwocOHECNKnEixosWLGDMKDMCxo8ePHhkGmDgyYUmSCE8e7KhQ5UCXAGIK+CggJoCCLjcStAkAJM+bL1futOmTJ8GcM4fGLGrzqFClM0M2dWoQ6UuOLVNWtKpTIterIMOG1Ei2rNmzaNOqXXsxIAAh+QQICgAAACwAAAAAIAAgAIH///8ixV4PFyoAAAAIfAAFCBxIsKDBgwgTKlzIsKHDhxAjSpxIsaLFixgzCgzAsaPHjx4ZBpg4MmFJkghPiuRYUOVAlwBiCowJQMBHgi43EqTJMybIkzlt7uxJ8+fLgyqJFgV51CBMmQJo2gzZtOXDjlaRVgxaFSJXnD/DYtVItqzZs2jTql17MSAAIfkECAoAAAAsAAAAACAAIACB////IsVeDxcqAAAACHoABQgcSLCgwYMIEypcyLChw4cQI0qcSLGixYsYMwoMwLGjx48eGQaYODJhSZIIT27k+FDlQJUfBQCYKXAmgIIuN778aLOnTYI5BZwE6dMn0INDeRb9+RIp0JBMmTY16LJjy5QVg06NqPUpyK8hNYodS7as2bNo014MCAA7"
private const val GifIkarShieldBase64 = "R0lGODlhIAAgAIEAAP///xS4pg8XKgAAACH/C05FVFNDQVBFMi4wAwEAAAAh+QQICgAAACwAAAAAIAAgAAAIlwAFCBxIsKDBgwgTKlzIsKHDhxAjLgSwMIBEigkDaNQIEaPBjSA5MvQ4MKRJkApJnlyJ0iBJASxNAth48GVMkAByiiT4EubNADln7hzY02dMnUMrHkVq0eFSoUkV4sTJNGpGjUGxVrWKkOrMrVy7bgwKNmLIrCbNnl2p9mdTiG4lCrwpl+DJuh9b4i1Ic+/Bt34DCx6sMCAAIfkECAoAAAAsAAAAACAAIACB////FLimDxcqAAAACKAABQgcSLCgwYMIEypcyLChw4cQI0oUAGBggIkHKwoIwJEjRoIAOor0ODFAyJEoLzrseDIlyoYcW7o02REmzZkmAZyEKTOlzpYOe6LUWbOh0JE/izI8ypKoUoUVRcpMKtLoxphOqVZdqFEqUacjl1psqvUpQo0CkYLdCpXgUJdcC+IM21buXJUJ0dqdKTZhSqsL6fYNTHIwQ7wfEyteLDEgACH5BAgKAAAALAAAAAAgACAAgf///xS4pg8XKgAAAAiZAAUIHEiwoMGDCBMqXMiwocOHECNKLBhgIICJBgNo1HgR48CNIAFUnAiypEiQD0uqPFmyocqQMDW6fMlxI4CbMhnSDMDyJkuHNHv+BLrSJs6cM01yPIpUp02eS1k2XWgUp0+VKavylLoRYkifXEdmhXoVq9eaUF9GRLtTbMqwLSXCRTmxo1qMHQXGxUtxasS8BN16HEy4cMSAACH5BAgKAAAALAAAAAAgACAAgf///xS4pg8XKgAAAAicAAUIHEiwoMGDCBMqXMiwocOHECNKLBhgIYCIATJmRHjxocaPGwt2XAiy5EeBIxOaXPkxJUKWJQFkdPkSpkYAOAPQPGhzZk6ZDnvm1OkRJk6ZQIOyPDqzotKmN4fKdNow6lSmUIv6RDpVp0aILY92nYox7NivYEF29RpSq8m1EpeijRtz7kQBaqneHXhT716+RP8e3Cm4sOHDBgMCADs="
private const val GifHeartBase64 = "R0lGODlhYABgAIEAAP/0+OpAYAAAAAAAACH/C05FVFNDQVBFMi4wAwEAAAAh+QQJCQAAACwAAAAAYABgAAAI/wABCBxIsKDBgwgTKlzIsKHDhxAjSpxIsaLFixgzatzIsaPHjyBDihxJsqTJkyhTqlzJsqXLlzBjypxJs6bNmzhz6tzJs6fPn0CDCh1KtKjRo0iTPgzAtGkAjE6jPp0pterEqlJjYt3qcCvXll7DKgzrVSVZsgjPlj2pFq3BtmtJwo07cO7XkXbp5r0bci/Wun6tigz8FwBhwSAPKz7bd7FjuhwfS0bccbLlpokvW86sWTLnzo4/g1YsejTh0qb9Nk59GjXruatf58Ure/bg2rDl4m5bcrdatr4h6w5OuTfxrGaPY2apfCpY4lp908RtUzZO1jpN8+wMdHNQz0NDE0JdfLR10r1KAcNNT3A9e/Vu38MX/j6+/PZ87+NHrv8g//7+LQdgQgIOGKCBYzmH4FsKLujggxBGKOGEFFZo4YUCBQQAIfkECQkAAAAsDAAZAEgAQQCB//T46kBgAAAAAAAACP8AAQgcSJBggIMIDxZcyLChQYQOIzZMSJGixIsAKibEGFGjR4gcB37UGFLkyJEcT34sqfLkxZYoJcJU2XHmSoc2aTLM6XInz5gPf94MKtRjwaJDBSIFmnGpUZNOSSqNWhEqVYtNr2LVSpKrRa9gUYYdS7as2bNo06pdy7at27dms8JFOnVu0bp2eRLNO3Mv35ZH/8L0Kbhn4MJJDyPGOnFx1ZqOFcqMHCClY5aIS+LNq9nq3M5+24JeCHc04bWmG6tNjTMta8hlX78kKxvj2NqWteLGTHW35qi+QS8NPvou8eI/j6fOqZz14OavdUJ3znT68sTWTT/NHv0xd9nev3cRlyy+Nsjy4CujN69+fXfoAQEAIfkECQkAAAAsCgAYAEwARACB//T46kBgAAAAAAAACP8AAQgcSLBggIMIDxZcyLChwIQJHUqUCLEiwokYAViEmBHjxo8BOhoEaVEkQZIgRaJMaXIlSY8uWU6MiZIiTZkNb9ZkqBMnz54+NQL96HDoy5NGN/5MqnQgU6Ijn5Z8KLVi1KochWLNSnUrR69cwYo9OrbsVLNoL2pNW3Yt27Fu34KNK9cr3bpV7+J9qnevUad+sQIOLHUw4aRIDyNOrBjo1cY3l0KmmXNyzKKWV87MHFQy54gZP1tVKVqhyb6KT3fNrNow5NaMU8N+7He2Z7y2K+/Njbkub5tvf29GKzy02eId2yJPPnd5S8HOTxeO3pop9dl/r2PvqT23zu68KYMqD+9yPPmd5r0fTX9+KvvfTd8LHy2/OOj6yNXitx9yf/7+/s0HYIDkjRcQACH5BAkJAAAALAwAGQBIAEEAgf/0+OpAYAAAAAAAAAj/AAEIHEiQYICDCA8WXMiwoUGEDiM2TEiRosSLAComxBhRo0eIHAd+1BhS5MiRHE9+LKny5MWWKCXCVNlx5kqHNmkyzOlyJ8+YD3/eDCrUY8GiQwUiBZpxqVGTTkkqVVoRKlWLTa9i1UqSq0WvYFGGHUu2rNmzaNOqXcu2rdu3ZrPCRTp1btG6dnkSzTtzL9+WR//C9Cm4Z+DCSQ8jxjpxcdWajhXKjBwgpWOWiEvizavZ6tzOftuCXgh3NOG1phurTY0zLWvIZV+/JCsb49jalrXixkx1t+aovkEvDT76LvHiP4+nzqmc9eDmr3VCd850+vLE1k0/zR79MXfZ3r93EZcsvjbI8uArozevfn136AEBACH5BAkJAAAALBAAHABAADoAgf/0+OpAYAAAAAAAAAjuAAEIHEhQYICDCAMUXMhwYcKHChtKNAgR4kSJFS1eLJix40aKHSt+BBCy5MSSIU+iNMlwZcqWLllyjOlxJs2MDm/iJKjzJcieGn8CTThw6E6SRkUiTfpQKNOnOKFKnUq1qtWrWLNq3cq1q9evYL8uDatzLFmaZs+uLKo2Jtu2KG3Crfl2rlK5dhFizEu0IV+9KvN+FDxy7si6YQ/j7ao4p9fGMLlC9rt18t6rljdazTx4KufCUD+DZir6cNLSkIGinlx2NWu3rl+vjW3ZJe3McW/jpqu79t3eu/sC/yx8OPGDxksjTy56OXPOzl0HBAAh+QQJCQAAACwTAB4AOgA1AIH/9PjqQGAAAAAAAAAI2wABCBxIkGCAgggTKgzA8KDChwgbSmQI8eFEiRUXXryYceBGjh0BfPyYceTGjiZJakwJciXLkwVfjnQpE6PBmipv4mwpcifPnj5BBoUJdChGoy2RKk1ZdKnTo0+jSp1KtarVq1izap3YdGtNgV59gg371SPZlzrPzoypdm3atjYjwuVKs23FuQ7v2kWpNuTYsH7fXg3MFivhhIMPI56qGKLUxo6fQta7dHJJpJb5is0ccjPnzmU/+5Up+jDL0opNom6cc7Xpn64Dx42duiHtybZvs86r+3Xv3aUDAgAh+QQJCQAAACwVAB8ANgAyAIH/9PjqQGAAAAAAAAAIywABCBxIcGCAgggTJjyosCHCABAjRnS4UKJEig8tamRIcaNGjAA8imwocqPDkiMrorSocGVKgi49FowpEyZNkwZvfhSos2ZPnD93BmU59GPRo0iTKl3KtKnTp1CjSp0KNSTVm1avusypFSXXrjV5gn0pdizLmWbPok3LMaNZjGNBZp0q92vVumXv4s3rdK/Nvn7/Kg3cMilhkkcPnxyquOPPxnB1QgaJdTLlrZblrsy8lyxnzWo/4w0tevTE0n4vok7ddnVdiK5ZWw4IACH5BAkJAAAALBMAHgA6ADUAgf/0+OpAYAAAAAAAAAjbAAEIHEiQYICCCBMqDMDwoMKHCBtKZAjx4USJFRdevJhx4EaOHQF8/Jhx5MaOJklqTAlyJcuTBV+OdCkTo8GaKm/ibClyJ8+ePkEGhQl0KEajLZEqTVl0qdOjT6NKnUq1qtWrWLNqndh0a02BXn2CDfvVI9mXOs/OjKl2bdq2NiPC5UqzbcW5Du/aRak25Niwft9eDcwWK+GEgw8jnqoYotTGjp9C1rt0ckmklvmKzRxyM+fOZT/7lSn6MMvSik2ibpxztemfrgPHjZ26Ie3Jtm+zzqv7de/dpQMCADs="
private const val GifCheckBase64 = "R0lGODlhYABgAIEAAPD69iGjZv///wAAACH/C05FVFNDQVBFMi4wAwEAAAAh+QQJCQAAACwAAAAAYABgAAAI/wABCBxIsKDBgwgTKlzIsKHDhxAjSpxIsaLFixgzatzIsaPHjyBDihxJsqTJkyhTqlzJsqXLlzBjypxJs6bNmzhz6twJIIDPn0AD8KwYtKhRoUMbHl1aNClCplCPOh0YtWrTpFazXtWptStQrl7DIrUptizZsmZnol0rc61XAUFhuu0qoG7clnO11t17d2Veq3sDf/X7t2pgwT9VFjZ8GLHPlIuhNm6cGGXkpZMPDzZ5+Whmx5VLdi76me/WkaNJl4ZrVHTqn6tZtyb52mdspq5T38aN+vVuqL1H/wYu0vdqq8EvDyceEvDb41mTY7abdXlU6UY1My7tFXvQyVGtV6j1DjszU/Hji3vmvp59d/WqoX+X/x7+fPfl8XfNHf8zUPTI0TYdewAGSF5+sfnHFn/ZJahgWgw26KB2aFkm2YSmVWjheRjKpuFJ2yWYl2LhiTgXSyE++CGJJaq4IosXgnfiS4AJ5uGLeNUmlVo69sVjj2PVBCRYr/FUJFaRTSXQYkoWtGCTB4UFpUPMTWnllVhmqeWWXHbp5ZdghinmmGSWaeaZaKYZUkAAIfkECQkAAAAsEAAQAEEAQQCB8Pr2IaNm////AAAACP8AAQgcSLCgQYMBEipcGOCgw4cQIxZkSLFiQ4kYMw60yNGixo8HO4r0CPLjyJMkS0ZEyZKiyoctY7p8SVCmzYU0Bd7cmZAmz58qfwoFKbSoxqJIMSKVKSAlzKUsBUituBIqSqlYqTq0ehVr1pkIuY706hVsTbEiyZI1qxMtR7VqGU50axHuWrN0K9otq7VtXoV7v/b9yzDwVI5+CRtu2jFx3sUjHbuFHJlwAMqWxxrO3DUwZ82e0TJuiZmr4JOlrd4FvXdy3LSbRdvtmBpqbL23Vde+nNsmANq9eYfeKblw7t0xixsPjTz5b9aLZxtVvjw63OnUq1s/zfMs6u3cu3tRhw1+adjv1qGG7Bxc/Hn0w7G/h/46KUTS0u3fx591tP79nzlVVYBymUSgQkEd6FOAOT2XWYPZWQXheGJNOJ96Fq5nXoYAusehRL59+JJIGQYEACH5BAkJAAAALA4ADgBFAEUAgfD69iGjZv///wAAAAj/AAEIHEiwoMGDAAIoXMgwAMKHECNKJNiwokWHEzNqpHixo8WNIBF6HOkxpEmSKC+a1JiypcqVEF3KrAjz4MybNGsKxMmToc6eQBXCDEr0JNGiG48qZal06cSmTiNClSmgZMypLQVotSoSK0qtYDs+9PoVbNiXBcmWNbsV7UC1I9mydbsT7kW5cj9ytFsR71y3fPv6PQs48MLBhNEaZoi4rdXFChtXJVnXsOSUlflexrx4M+S1gz9nbSwatF/NMz2rTUxSNdm/rUnbxRsbsebTHV17lW1R91TJkxv6npkwLu/Dx3lmzn18OPHitUMHcP58NHDpR9+mvI496F7T3IMjX10enTvUtNbNNzVIVf169C6vY7XZPvl49vVxv++aH/Z+/vGZNd18EpU2UkYGclVggjkxxaBQIT2IkVEG/lSaTuTxheF3cG2IX4ce0kdgiABGReJVQJ0Y4XMq1nQgiQEBACH5BAkJAAAALBAAEABBAEEAgfD69iGjZv///wAAAAj/AAEIHEiwoEGDARIqXBjgoMOHECMWZEixYkOJGDMOtMjRosaPBzuK9Ajy48iTJEtGRMmSosqHLWO6fElQps2FNAXe3JmQJs+fKn8KBSm0qMaiSDEilSkgJcylLAVIrbgSKkqpWKk6tHoVa9aZCLmO9OoVbE2xIsmSNasTLUe1ahlOdGsR7lqzdCvaLau1bV6Fe7/2/csw8FSOfgkbbtoxcd7FIx27hRyZcADKlscaztw1MGfNntEybomZq+CTpa3eBb13cty0m0Xb7Zgaamy9t1XXvpzbJgDavXmH3im5cO7dMYsbD408+W/Wi2cbVb48Otzp1KtbP83zLOrt3Lt7UYcNfmnY79ahhuwcXPx59MOxv4f+OilE0tLt38efdbT+/Z85VVWAcplEoEJBHehTgDk9l1mD2VkF4XhiTTifehauZ16GALrHoUS+ffiSSBkGBAAh+QQJCQAAACwSABIAPQA9AIHw+vYho2b///8AAAAI/wABCBxIsKBBggESKlwY4KDDhxAjAmBIsWJDiRgzWtxIMaNHgxxDWvzoUaTJjiQhnlyJMmVBljAXukQYs+ZFlzZz4syp8yPPnyV/whTAEKPQoQKIylR5dGXSp0UfNj35tOrSg1OpVoWqEGtWkVu3Xh34NWTYsF1flrV4VmxasmsrtrUaVWBcinO51r2LN69SlHwX+v0LOHCAwRztBkac2DBjwxwfQ94oeXLfvF8Ja8WcVa/JykLdgvWbGa1Z0p3bRkY9lfXlualdKwS9cqJc2Ydx17Z9m3NC2rtXcwYeXPhg2EAVUz6OvKfy5cxFO3/ONrpnmyBPWz+aXfvxpl69+z5OHt54c55MR59Hn148XaFGP1vVPD2i5ZYa7yfcaXkmb8P+URdXgHDdRaBaXx1YHncKSsVegxLVBCF/Ix0YEAAh+QQJCQAAACwUABQAOQA5AIHw+vYho2b///8AAAAI/wABCBxIsKBBgQESKlwY4KDDhxAdMpxIsWHEixcrapyIsWPBjSA5eswYsiTDkQ9NqlyI0uDKlwlbIoRJ06LHmjhv4nwpQGHHnSsFCI1JEmhJoUiJpjR6FGlSmy6ZNnU6FCpBqSGpUrU6E6tGrVq5ev0K1qnSrmMZljXrc2DaiWuftgXwFm7cnifp1lV4F2/evQn7VkSbVvBgwIYBV0ysmCLjxgsf1/SrUjJNuVPXMt2auezmsCAtw9RMNu5n0o7vGu1Lma9qmHojv5ZtGnZs2qgDiA5J2HXt3bxvq2Vdu6bbxcQ9Gz+OPDnb5cxTO2/9Mmrz5EAPbnSeXft21kaXljMuDl3i99w0MYZWnlP9+qTUbbuHfHZ+Y5nC0+KPPnb/Vf3+fSRVgOKVR6B51R04EkgBBgQAIfkECQkAAAAsFgAWADUANQCB8Pr2IaNm////AAAACP8AAQgcSLCgwQAIEyoMYLChw4cPF0qUCLGixYkYMVrcODCjR40cIX4cSTFkQ5IoFZokmLKlSpMuYwYQEFKmSwE4N9psibPnxZ0kewoVCTSoUJ8ni448evSg0o9MmRZ86jFqU5ZUMVoVihBr1oVbeyb0+jVhWJxjBZYFe3ah2rUIzwooCXdmW4p15dbVenevRL1+2YbdOfdm35hiUwKWeRTlYsRMlx42HLXqZJ6D+WaGfNnuZpQAFD6O29njW7OHR5M8jTqz6tWsScv9nJJs69mVY9q+jTux7t2yexf+Ddxz751J/x63GXEibuRElZce+dM57ZY6NUdmzjFj4+HEu/sXXdlxLfmpX887fareYdH21UHDh2lafUAAIfkECQkAAAAsFAAUADkAOQCB8Pr2IaNm////AAAACP8AAQgcSLCgQYEBEipcGOCgw4cQHTKcSLFhxIsXK2qciLFjwY0gOXrMGLIkw5EPTapciNLgypcJWyKESdOix5o4b+J8KUBhx50rBQiNSRJoSaFIiaY0ehRpUpsumTZ1OhQqQakhqVK1OhOrRq1auXr9Ctap0q5jGZY163Ng2olrn7YF8BZu3J4n6dZVeBdv3r0J+1ZEm1bwYMCGAVdMrJgi48YLH9f0q1IyTblT1zLdmrns5rAgLcPUTDbuZ9KO7xrtS5mvaph6I7+WbRp2bNqoA4gOSdh17d28b6tlXbum28XEPRs/jjw52+XMUztv/TJq8+RAD250nl37dtZGl5YzLg5d4vfcNDGGVp5T/fqk1G27h3x2fmOZwtPijz52/1X9/n0kVYDilUegedUdOBJIAQYEADs="
private const val GifFireFixedBase64 = "R0lGODlhQABAAIIAAP/46/9ZI//oWP+AJP/aQAAAAAAAAAAAACH/C05FVFNDQVBFMi4wAwEAAAAh+QQJCQAAACwAAAAAQABAAAAI/wABCBxIsKDBgwgTKlzIsKHDhxAjSpxIsaLFixgzatzIUWGAjiAHBhgZEuTIjyU3niSZMuNKlC0tvmQZk+JMmDUj3qSZ8+FOnD0Z/uQZNOHQk0UXHiWalOBSpE0NPmXadKeAnVGd3rx6M6tAqwK4zsz6M6zYsVW3mj27Mq1asz+Tll0bN+jctWzb9rxLF2tOvnjrxgTct+tgwnAFhxyKt/HQlIwbFzZsMrLksEcXI3asWOPRy4Efc7QMOrFfz6RL5305erNk0a1nquaMtiPY2aZZa16JmzbUki97h/5dckBw4WwHxDTOG/lL5S0HMD8p/Dn0lNKnB6h+Mvvy7CcJ4Gkm0N37d+kjxc8mHyD79ejgAxBQD3r+SPc53Rufr9q+/p76yUdfY/7hB6B+/F02n37v1cRggngt+F9SCA5oloTmNVVhhARMmBWCEXroFYhhdWigVwSBB1eGKBqE3lXStdiQADLWaCNDAQEAIfkECRIAAAAsDgAIACUAOACC//jr/1kj/+hY/4Ak/9pAAAAAAAAAAAAACP8AAQgcSLDgwAAGEypcWDCAQ4YQIwJwiFCixYYUK168mFHjxogdH34EGXIkxJAUTS5EKVIlRpYeXYYUUNKlwJk1ZXYUwDOnyZk9O+rMyLOoz41AjQodibJo0KUckzpFiVSqUqgkdzqdenSlVa5YvX69GjZh061bqTJkiTatWoVs25LNKFar3LkpDcK8C7ZrXL54W978C5gmS4J7Cz/NmVjx4qWNHcOcSNixYaqVJb/NDPjwwbOWPX8eK1c0YtJt3xYcEJKAYgIhByhk3dF1YdgdZSccQJsiAdt3f+fWvbt3gN+AhVPkzZC3ceRylTt03ty5b+BopTsnPpt3RuhbpQdBoA5x+/W22smXt+4QPM/0zC2abx9+uXqJ23sDxz1++0j2DpF130cABnBZf9yZBOBlA9pk3Hg21RVThC9ReNJIAQEAIfkECQkAAAAsDgAGACUAOACC//jr/1kj/+hY/4Ak/9pAAAAAAAAAAAAACP8AAQgcSLDgwAAGEypcWDCAQ4YQIwJwiFCixYYUK168mFHjxogdH34EGXIkxJAUTS5EKVIlRpYeXcLM6PLgzJYqb9KUqRPnR5YCWOZEGRSlSaACipbcCDOp0qUWkToVGlVqUphViTp92lFi061XqTL8ChbrWKtlxSYkC5brzrVs06q1ibbtTIMz28o1SvCm3q03+8b9e1eg37+AC+dFvLfkYcZT7y6GHFZsXchqLzOeG5KyXqiCKXq2+7bggI6jI1McoPB0xtSVHbJOOMC16NEda7eu/Rr3at20a9sm4JnAb+DBeQcgTtl4AOGzdysnwBwxddnCGUI/TR3y9e0Qty9Cr972O/SI2wd0/0s9vcT068G2B29xe/yt87NvtE9eQH7k+0HXHwH0mWSfUwSeVxMAwgUwlX4LDsRbUABGWJAAIwUEACH5BAkSAAAALA4ABAAlADwAgv/46/9ZI//oWP+AJP/aQAAAAAAAAAAAAAj/AAEIHEiw4MAABhMqXFgwgEOGECMCcIhQosWGFCtevJhR48aIHR9+BBlyJMSQFE0uRClSJUaWHl3CzOjy4MyWKm/SlKkT58eeO0cCTSl0KFGORoNKnClgJlKYTZ0uhSogKsupVK1ePZm1qtSVTKt6/ZrwptixMMF2FXuzrNmzaNO+ZAn3bFuCOuuyvSswr16tWye+/QsYZd/Bf3UKRqy352K6hPfKfRwysmTDNlFathv4cOXNcZVmzgg6tM+BAzqWvhxggMLUpFcDdp1wAOwAsiXbfm3bYe6xtmkbDA47t0PivIPjlt0aefLipY8HZ0g89erm0xdWH0AANIHtEKt3Sd/83Tl14gTGE05fXSJ69X/ZZ48oHj5c+fPpB0+/vvzuje/phZ9wANrGX134ubSffQnWBICB9/3nIGrqfTehQmJdyJAAGm44UkAAOw=="
private const val GifStarBase64 = "R0lGODlhYABgAIEAAPX5///CLv/reAAAACH/C05FVFNDQVBFMi4wAwEAAAAh+QQJCQAAACwAAAAAYABgAAAI/wABCBxIsKDBgwgTKlzIsKHDhxAjSpxIsaLFixgzatzIsaPHjyBDihxJsqTJkyhTqlzJsqXLlzBjyqwYYCbLmjZTBtiZE+VOnD1J/gQaVORQokU/Hk0a8ihPph6dIoWKUepUqhatXsU6USvXql6/ZtW6VSxDsk/NRkRbVi1CtmndnoUr1yHcuHUP3sWbt+DetnX/8u0LQDDgoIYTL/WpuLHjoR0fS04McrJltCMvBxDAmfPlkpM7i/b8+OTj0agdq1SMujXlm4Jbyxb8Mrbs1Htj7r09m+5MuLxdY+7JNjjusIjJGh9NFqry5Z2RO7cKXYBVs9ShS1Wrdfl17N15S37HWrz3+Onlo7MVqxny1/Y/38OPT37+4OT2udq/n3M/faa0CfQXVQMSlBt6w+m1HoC+JdQgfuc52FxSE0JUIYSLdbVdUd+N5RSHGWb0IYZRuUccfyKi2NJ/Tam40mEcwUjYjDTWaOONOOao44489ujjj0AGKeSQRBZp5JEXBQQAIfkECQkAAAAsDQAQAEMAQwCB9fn//8Iu/+t4AAAACP8AAQgcSLAgwQAGEypcyLChw4EBED6cSLGiwYgSLWrcqBBjRo4gN3r8GLLkw5EkTapMiDLlypcCW8I8+LKly5AYa9q8adEjzJ0RQcrUCVSjzZlAc04s+jNp0IZJZwJwqpSlU6lUq8bMKnVq1qdfnyINS5ZnybJku25FS1WtV7ZR3cK9KnfuTrdr7aLE+3angL9/ufIFCrhw4LhqkxpezDQx4cWMj3Z1Crny0LGUK0ceiTmz5s0+V4b9DFkyTrKkQXPmyDa14cYU57oujPik3dkC2jrUGxG34I68MboOu1Qv6bRGW1suexo1bbiig3/FKh22yuq1TWLPfnb73ei/GdJG1T5d+/XwfG3rTh97PPvi3N+Ljy9/If369r/jh793f/vL/u0GYIBQDUjgfKEdqJ5YCgqoVYMImgXhQQ9OmJ+FxWF4EnsBAQAh+QQJCQAAACwTAA4AQQBEAIH1+f//wi7/63gAAAAI/wABCBxIsKDBgwgHBkjIsKHDhwgDLIRIsWJFiRMtatxoECPHjxwxZgRJ0qHIkSVTdjypsmXBkx5duoQpUWZLmjVtlsSZU+dHnj19bgSKcuhMoilFkiQa9CdNjUybOuX5MKrUqUgjWr1pNabArkVBgh3LkivZsUfPgpWpdi3btlFtwo0rdy5Qn2QF6NVL12zUvYD5Mk1LNLDhriqZGl7sVizQxZDJOsYJmfFZrCIrR24LlaZmy3Atev4c2K5XhpRJA7Z7cbRqwZdF83wtIHbn2a8bhyxMGvHSv5r7/rZaOWvivKup1jX99C3z5n6fl40u/fTO6tCvY4dJ3TgA3cO9v0PcmvSuSfLhlcoerD1sa/FGu08Xehsn/d3K79fnrn+o/f7+8QfgevMNSJGABhLoXoLnXcVgVQ4+2JB1EkIUYYWopRQQACH5BAkJAAAALA0ADQBDAEMAgfX5///CLv/reAAAAAj/AAEIHEiwoMGDAAIgXMiwocOHAwMohEixokWBEi9q3HhQYkaOIDV6nBiyJMSRJE2qRIhypcuOLV/KROlR5kuaH22qxJlTZ0ieKX1yBBpUqEWiRY1SRJpUqUOmTZ0uhCp1KdSoVQlexZoV41aTXE9+fYp059azPM2iXVtTLVu0Lt+uLSlX7sa6eMMWxCugb1+7ZPP6HfwXLsu8KAkrNjwVcQDFkMcGlgu5MlWrlCsvLnvxrWbLQEGu/Rw5Ld2tpDfTXIk69WCcM5m6fh3TpuzZsIUSnS3AtM/dqUMrBUqas27ioIkOR0pYsk7Hq49DHyl9es/Y1ttiz36dNXftN896Q3Xu/epe89uNG7wcl31j4e3VN1QeHz7m2m5zi4yeH/9Q8Kfxp1Z51NnWn15dredfgmIByGCDCD54XoQSakVhhRFhGBAAIfkECQkAAAAsDgATAEQAQQCB9fn//8Iu/+t4AAAACP8AAQgcSHBggIIIEypcWPAgw4cEAziESPGhxIoUJU7EyFEjR4gaN3606HHkwpAXTSpEKVKlQZYuG8KMiZBlSpU2W9IEkFNnxpw7a/b0ebJnUJlDKw69eVTgUqYJnzZFunTlU6I0r5Z8eXVqRK0pwUKdKrYs1p1mxXqlmrbqWq5t3b51GjfpXLh1Z97lmRfoXr5PBQgW3PUv4JyDExM2avgwS8WQGRvuCbmy37+UK1u2uXep5s2XyQ79rNlu08CkI5tGizp1YqlZtbpWrNWl2Nmva480i1tA2Y9pe5tVGnd2XJJ1U+ct2vdzX53PQ9KOvpG69arXs0tmqF0u5t+NQYJMD4+8MPnysM+jX63eavr2zL3Dd89+vtD69u+Hzh8VP3+8KP233lYC9qdXgQYGiGB8BC7I1lkLKuggfRAiGNKEyGHI3VgaRtQhd3cFBAAh+QQJCQAAACwQAA0AQwBDAIH1+f//wi7/63gAAAAI/wABCBxIsKDBgwgBBEjIsKHDhwwDLIRIsaJFgRIvatx4UGJGjiA1epwYsuTDkSRNqkSIcqVLgyhTvlwZ8+NMmjVv4syp02RNmz05/gQaVORQmUUpHiWa9ORSpE0bPoUaleVUqlULXsVakavSrSqfOr26c6vZmC7Pqm1Zdq3Zl27VzowbNyzduyDpCti7l67Rs3wD973rdaBawYgJs7V6FbFjxYUVTnVMGe/XpZQf17WIObPms0KHev5MNqTo0YLB2o2JOvVUuKxb8xU7t6ZsAUt1nm49NOhR3j99d84cvOjkysV7mnXdWzjkkc6fe4wuPfLG6otrY59+czt07aVBg0LPTfAtbPIdS6c96lD9ap5j2a9Hyxm9T/p/k99nmh9/Se5tAfifbv5lVV92BnaFYIKX8cdgfA9e52CE7VlHIUwaBQQAIfkECQkAAAAsDQAOAEAARACB9fn//8Iu/+t4AAAACP8AAQgcSLCgwYEBDipcyLChQ4MBEj6cSLEiwogWM2o8GFHixo8ZO3oESdKhyJElU0I8qbIlwZMdXbqEiVGmSpo1bZLEmVPnRp49fVoEilIoRaJFQSatiHTp0Zg7m4akWbJpUIVEU1p1ulVr14tbnTINS1bkzbJoz6IN23It27Zurc6Mi1Qm3bpqgQrYu7esV6R8A/eVG7Wp4MOENZI9zBjv1LCMIwN9XDZyY55j11qWTPXh3QCbL8M0+Rl0aMGdJ949jTr1U7SsA+P8CTv25I+Lbd9WapW146qANyf+y9Py27nFZZO1WRoz8uYsn0M3C3f66LzWoRLPrh34cL/bd689/M1bKmnnhbMOdU1bPe7u7dFrTc/eqGrx9u/Pzr++Pv/z0f2XWYACvgZfgZ5Rh+CACz7WoIMPjhXhUAIGBAAh+QQJCQAAACwQABAAQwBDAIH1+f//wi7/63gAAAAI/wABCBxIsKDBgwgTCgygsKHDhxALBmAYsaLFiBMpXtzIkWBGjR1DVvwIUqRJhSRLnlzpMeVKlS9dxoQZMuVElgA+nrSZEafNjjx7+vxpMahQlkYxGr2JM2fShkuPIo2aMKrUqVYlWmXadKtQr1y7gh1L0yTZsU0HnvWatuXapW3VvoUbd+HcoHXt3iWa16uAv3/p9o0KuHBgnnnlBjXM+Gldo4wjO47LM7JlwWkrW25MVWzKzZezzvwMmrNokZBLG0YLNLVqwGc3En4Ne+1Iq7QF3IXol/besnq3vv591W3vzXsfrkUe+/bb1awvEgdrdvrk6tbxjs7OFzV37djBo0hka3Yn+cRKT6NXvnW98+vuoXaOzxszfYf2749HrD+9zP7sdQegfP8NSCBJBtanU4IKhsXgfg4+WFVxEh60YIXyYciehso1FRAAOw=="
@@ -1,7 +1,12 @@
package com.seven.ikar.kotlin.feature.chatinfo
import android.content.ClipData
import android.widget.Toast
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.WindowInsetsSides
@@ -10,21 +15,35 @@ 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.size
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.ContentCopy
import androidx.compose.material3.Checkbox
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.ClipEntry
import androidx.compose.ui.platform.LocalClipboard
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.seven.ikar.kotlin.core.model.ChatInfoDto
import com.seven.ikar.kotlin.core.model.ChatInviteLinkDto
import com.seven.ikar.kotlin.core.model.ChatSummaryDto
import com.seven.ikar.kotlin.core.model.ChatTopicDto
import com.seven.ikar.kotlin.core.model.ChatType
import com.seven.ikar.kotlin.ui.IkarRowDivider
@@ -36,6 +55,10 @@ import com.seven.ikar.kotlin.ui.IkarSimpleHeader
import com.seven.ikar.kotlin.ui.theme.TelegramBlue
import com.seven.ikar.kotlin.ui.theme.TelegramDanger
import com.seven.ikar.kotlin.ui.theme.TelegramInkMuted
import java.time.OffsetDateTime
import java.time.ZoneId
import java.time.format.DateTimeFormatter
import kotlinx.coroutines.launch
@Composable
fun ChatInfoScreen(
@@ -44,14 +67,26 @@ fun ChatInfoScreen(
onRefreshClick: () -> Unit,
onMembersClick: () -> Unit,
onSearchClick: () -> Unit,
onProfileTitleChanged: (String) -> Unit,
onProfileDescriptionChanged: (String) -> Unit,
onProfilePublicUsernameChanged: (String) -> Unit,
onMaterialsClick: (ChatMaterialFilter) -> Unit,
onInviteTitleChanged: (String) -> Unit,
onInviteJoinRequestChanged: (Boolean) -> Unit,
onInviteExpiresInHoursChanged: (String) -> Unit,
onInviteMaxUsesChanged: (String) -> Unit,
onInviteMemberLimitChanged: (String) -> Unit,
onCreateInviteClick: () -> Unit,
onRevokeInviteClick: (ChatInviteLinkDto) -> Unit,
onTopicTitleChanged: (String) -> Unit,
onCreateTopicClick: () -> Unit
onCreateTopicClick: () -> Unit,
onLinkDiscussionClick: (ChatSummaryDto) -> Unit,
onClearDiscussionClick: () -> Unit
) {
val info = state.info
val clipboard = LocalClipboard.current
val clipboardScope = rememberCoroutineScope()
val context = LocalContext.current
IkarScreenSurface(modifier = Modifier.fillMaxSize()) {
Column(
modifier = Modifier
@@ -75,42 +110,145 @@ fun ChatInfoScreen(
if (info != null) {
IkarSectionCard {
IkarSectionTitle("Обзор", modifier = Modifier.padding(bottom = 8.dp))
Text(info.chat.type.label(), style = MaterialTheme.typography.bodyMedium, color = TelegramInkMuted)
Text(info.channelAudienceText(), style = MaterialTheme.typography.titleMedium)
if (info.chat.type == ChatType.Channel && info.adminCount > 0) {
Text("${info.adminCount} ${russianPlural(info.adminCount, "администратор", "администратора", "администраторов")}", style = MaterialTheme.typography.bodySmall, color = TelegramInkMuted)
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
IkarSectionTitle("Обзор")
Text(info.chat.type.label(), style = MaterialTheme.typography.bodyMedium, color = TelegramInkMuted)
Text(info.channelAudienceText(), style = MaterialTheme.typography.titleMedium)
if (info.chat.type == ChatType.Channel && info.adminCount > 0) {
Text("${info.adminCount} ${russianPlural(info.adminCount, "администратор", "администратора", "администраторов")}", style = MaterialTheme.typography.bodySmall, color = TelegramInkMuted)
}
val description = info.description ?: info.chat.description
if (!description.isNullOrBlank()) {
Text(description, style = MaterialTheme.typography.bodyMedium)
}
if (!info.publicUsername.isNullOrBlank()) {
Text("@${info.publicUsername}", color = TelegramBlue)
}
if (info.chat.type == ChatType.Channel) {
ChannelIdRow(
chatId = info.chat.id,
onCopyClick = {
clipboardScope.launch {
clipboard.setClipEntry(
ClipEntry(ClipData.newPlainText("Ikar channel id", info.chat.id))
)
}
Toast.makeText(context, "UUID канала скопирован", Toast.LENGTH_SHORT).show()
}
)
}
OverviewActions(
onMembersClick = onMembersClick,
onSearchClick = onSearchClick,
onRefreshClick = onRefreshClick
)
}
val description = info.description ?: info.chat.description
if (!description.isNullOrBlank()) {
Text(description, style = MaterialTheme.typography.bodyMedium)
}
if (!info.publicUsername.isNullOrBlank()) {
Text("@${info.publicUsername}", color = TelegramBlue)
}
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
TextButton(onClick = onMembersClick) { Text("Участники") }
TextButton(onClick = onSearchClick) { Text("Поиск") }
TextButton(onClick = onRefreshClick) { Text("Обновить") }
}
if (info.canEditProfile()) {
IkarSectionCard {
IkarSectionTitle("Профиль", modifier = Modifier.padding(bottom = 8.dp))
IkarSearchCard(
value = state.profileTitle,
onValueChange = onProfileTitleChanged,
placeholder = "Название"
)
IkarSearchCard(
value = state.profileDescription,
onValueChange = onProfileDescriptionChanged,
placeholder = "Описание"
)
if (info.chat.type == ChatType.Channel) {
IkarSearchCard(
value = state.profilePublicUsername,
onValueChange = onProfilePublicUsernameChanged,
placeholder = "Публичное имя канала"
)
}
if (state.isSaving) {
Text("Обновление...", style = MaterialTheme.typography.bodySmall, color = TelegramInkMuted)
}
}
}
IkarSectionCard {
IkarSectionTitle("Общие материалы", modifier = Modifier.padding(bottom = 8.dp))
InfoStatRow("Медиа", info.mediaCount)
InfoStatRow("Файлы", info.fileCount)
InfoStatRow("Ссылки", info.linkCount)
InfoStatRow("Голосовые", info.voiceCount)
InfoStatRow("Закреплённые", info.pinnedCount)
InfoStatRow("Медиа", info.mediaCount, onClick = { onMaterialsClick(ChatMaterialFilter.Media) })
InfoStatRow("Файлы", info.fileCount, onClick = { onMaterialsClick(ChatMaterialFilter.Files) })
InfoStatRow("Ссылки", info.linkCount, onClick = { onMaterialsClick(ChatMaterialFilter.Links) })
InfoStatRow("Голосовые", info.voiceCount, onClick = { onMaterialsClick(ChatMaterialFilter.Voice) })
InfoStatRow("Закреплённые", info.pinnedCount, onClick = { onMaterialsClick(ChatMaterialFilter.Pinned) })
}
IkarSectionCard {
IkarSectionTitle("Пригласительные ссылки", modifier = Modifier.padding(bottom = 8.dp))
if (info.chat.type == ChatType.Channel && info.canManageChannel) {
IkarSectionCard {
IkarSectionTitle("Обсуждение канала", modifier = Modifier.padding(bottom = 8.dp))
val linkedDiscussionId = info.linkedDiscussionChatId ?: info.chat.linkedDiscussionChatId
val linkedGroup = state.discussionGroups.firstOrNull { it.id == linkedDiscussionId }
Text(
text = linkedGroup?.let { "Привязана группа: ${it.title}" }
?: linkedDiscussionId?.let { "Привязана группа: $it" }
?: "Группа обсуждения не привязана.",
style = MaterialTheme.typography.bodyMedium,
color = if (linkedDiscussionId == null) TelegramInkMuted else MaterialTheme.colorScheme.onSurface
)
if (linkedDiscussionId != null) {
TextButton(enabled = !state.isSaving, onClick = onClearDiscussionClick) {
Text(if (state.isSaving) "Обновление..." else "Отвязать обсуждение", color = TelegramDanger)
}
}
if (state.discussionGroups.isEmpty()) {
Text(
"Доступных групп пока нет. Создайте группу и добавьте туда администраторов канала.",
style = MaterialTheme.typography.bodySmall,
color = TelegramInkMuted
)
} else {
state.discussionGroups.forEachIndexed { index, group ->
DiscussionGroupRow(
group = group,
isLinked = group.id == linkedDiscussionId,
isSaving = state.isSaving,
onLinkClick = { onLinkDiscussionClick(group) }
)
if (index != state.discussionGroups.lastIndex) {
IkarRowDivider()
}
}
}
}
}
if (info.canInviteUsers) {
IkarSectionCard {
IkarSectionTitle("Пригласительные ссылки", modifier = Modifier.padding(bottom = 8.dp))
IkarSearchCard(
value = state.inviteTitle,
onValueChange = onInviteTitleChanged,
placeholder = "Название ссылки"
)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
IkarSearchCard(
value = state.inviteExpiresInHours,
onValueChange = onInviteExpiresInHoursChanged,
placeholder = "Срок, часов",
modifier = Modifier.weight(1f)
)
IkarSearchCard(
value = state.inviteMaxUses,
onValueChange = onInviteMaxUsesChanged,
placeholder = "Входов",
modifier = Modifier.weight(1f)
)
}
IkarSearchCard(
value = state.inviteMemberLimit,
onValueChange = onInviteMemberLimitChanged,
placeholder = "Лимит участников"
)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp),
@@ -142,6 +280,7 @@ fun ChatInfoScreen(
IkarRowDivider()
}
}
}
}
IkarSectionCard {
@@ -167,9 +306,101 @@ fun ChatInfoScreen(
}
@Composable
private fun InfoStatRow(label: String, value: Int) {
private fun DiscussionGroupRow(
group: ChatSummaryDto,
isLinked: Boolean,
isSaving: Boolean,
onLinkClick: () -> Unit
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 10.dp),
horizontalArrangement = Arrangement.spacedBy(10.dp),
verticalAlignment = Alignment.CenterVertically
) {
Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) {
Text(group.title, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
Text(
group.secondaryText ?: "${group.participants.size} участников",
style = MaterialTheme.typography.bodySmall,
color = TelegramInkMuted,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
TextButton(enabled = !isSaving && !isLinked, onClick = onLinkClick) {
Text(if (isLinked) "Привязана" else "Привязать", color = if (isLinked) TelegramInkMuted else TelegramBlue)
}
}
}
@Composable
private fun ChannelIdRow(
chatId: String,
onCopyClick: () -> Unit
) {
Surface(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(14.dp),
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.45f)
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(start = 12.dp, top = 10.dp, end = 8.dp, bottom = 10.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically
) {
Column(
modifier = Modifier.weight(1f),
verticalArrangement = Arrangement.spacedBy(2.dp)
) {
Text("UUID канала", style = MaterialTheme.typography.labelMedium, color = TelegramInkMuted)
SelectionContainer {
Text(
text = chatId,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurface,
overflow = TextOverflow.Visible
)
}
}
IconButton(onClick = onCopyClick, modifier = Modifier.size(40.dp)) {
Icon(
imageVector = Icons.Outlined.ContentCopy,
contentDescription = "Скопировать UUID канала",
tint = TelegramBlue,
modifier = Modifier.size(20.dp)
)
}
}
}
}
@OptIn(ExperimentalLayoutApi::class)
@Composable
private fun OverviewActions(
onMembersClick: () -> Unit,
onSearchClick: () -> Unit,
onRefreshClick: () -> Unit
) {
FlowRow(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
TextButton(onClick = onMembersClick) { Text("Участники") }
TextButton(onClick = onSearchClick) { Text("Поиск") }
TextButton(onClick = onRefreshClick) { Text("Обновить") }
}
}
@Composable
private fun InfoStatRow(label: String, value: Int, onClick: (() -> Unit)? = null) {
Row(
modifier = Modifier
.fillMaxWidth()
.then(if (onClick != null) Modifier.clickable(onClick = onClick) else Modifier),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
@@ -204,6 +435,13 @@ private fun InviteLinkRow(
style = MaterialTheme.typography.bodySmall,
color = if (link.isRevoked) TelegramDanger else TelegramInkMuted
)
inviteLinkLimitsText(link)?.let { limits ->
Text(
text = limits,
style = MaterialTheme.typography.bodySmall,
color = TelegramInkMuted
)
}
if (!link.isRevoked) {
TextButton(onClick = onRevokeClick) { Text("Отозвать") }
}
@@ -233,7 +471,32 @@ private fun ChatType.label(): String = when (this) {
ChatType.Channel -> "Канал"
}
private fun com.seven.ikar.kotlin.core.model.ChatInfoDto.channelAudienceText(): String {
private fun inviteLinkLimitsText(link: ChatInviteLinkDto): String? {
val parts = buildList {
link.maxUses?.let { maxUses ->
add("${link.usesCount}/$maxUses использований")
}
link.memberLimit?.let { memberLimit ->
add("до $memberLimit участников")
}
link.expiresAt?.let { expiresAt ->
add("до ${formatInviteDate(expiresAt)}")
}
}
return parts.takeIf { it.isNotEmpty() }?.joinToString(" · ")
}
private fun formatInviteDate(value: String): String =
runCatching {
OffsetDateTime.parse(value)
.atZoneSameInstant(ZoneId.systemDefault())
.format(DateTimeFormatter.ofPattern("dd.MM.yyyy HH:mm"))
}.getOrElse { value }
private fun ChatInfoDto.canEditProfile(): Boolean =
chat.type != ChatType.Direct && (canManageChannel || chat.currentUserPermissions?.canManageMembers == true)
private fun ChatInfoDto.channelAudienceText(): String {
if (chat.type != ChatType.Channel) {
return "${chat.participants.size} участников"
}
@@ -3,17 +3,32 @@ package com.seven.ikar.kotlin.feature.chatinfo
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.seven.ikar.kotlin.app.AppContainer
import com.seven.ikar.kotlin.core.model.ChatStateRequest
import com.seven.ikar.kotlin.core.model.ChatInfoDto
import com.seven.ikar.kotlin.core.model.ChatInviteLinkDto
import com.seven.ikar.kotlin.core.model.ChatSummaryDto
import com.seven.ikar.kotlin.core.model.ChatType
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import java.time.OffsetDateTime
import java.time.ZoneOffset
import java.time.format.DateTimeFormatter
data class ChatInfoUiState(
val info: ChatInfoDto? = null,
val discussionGroups: List<ChatSummaryDto> = emptyList(),
val inviteTitle: String = "",
val inviteCreatesJoinRequest: Boolean = false,
val inviteExpiresInHours: String = "",
val inviteMaxUses: String = "",
val inviteMemberLimit: String = "",
val profileTitle: String = "",
val profileDescription: String = "",
val profilePublicUsername: String = "",
val topicTitle: String = "",
val isLoading: Boolean = false,
val isSaving: Boolean = false,
@@ -26,6 +41,7 @@ class ChatInfoViewModel(
) : ViewModel() {
private val _uiState = MutableStateFlow(ChatInfoUiState())
val uiState: StateFlow<ChatInfoUiState> = _uiState.asStateFlow()
private var profileAutosaveJob: Job? = null
init {
refresh()
@@ -36,10 +52,25 @@ class ChatInfoViewModel(
_uiState.value = _uiState.value.copy(isLoading = true, errorMessage = null)
runCatching {
container.executeAuthorized { session ->
container.apiClient.getChatInfo(session.accessToken, chatId)
val info = container.apiClient.getChatInfo(session.accessToken, chatId)
val discussionGroups = if (info.chat.type == ChatType.Channel && info.canManageChannel) {
container.apiClient.getChats(session.accessToken)
.filter { chat -> chat.type == ChatType.Group }
.sortedBy { chat -> chat.title.lowercase() }
} else {
emptyList()
}
info to discussionGroups
}
}.onSuccess { info ->
_uiState.value = _uiState.value.copy(info = info, isLoading = false)
}.onSuccess { (info, discussionGroups) ->
_uiState.value = _uiState.value.copy(
info = info,
discussionGroups = discussionGroups,
profileTitle = info.chat.title,
profileDescription = info.description ?: info.chat.description.orEmpty(),
profilePublicUsername = info.publicUsername ?: info.chat.publicUsername.orEmpty(),
isLoading = false
)
}.onFailure { error ->
_uiState.value = _uiState.value.copy(isLoading = false, errorMessage = error.message)
}
@@ -54,13 +85,111 @@ class ChatInfoViewModel(
_uiState.value = _uiState.value.copy(inviteCreatesJoinRequest = value)
}
fun updateInviteExpiresInHours(value: String) {
_uiState.value = _uiState.value.copy(inviteExpiresInHours = value.digitsOnly(maxLength = 5))
}
fun updateInviteMaxUses(value: String) {
_uiState.value = _uiState.value.copy(inviteMaxUses = value.digitsOnly(maxLength = 6))
}
fun updateInviteMemberLimit(value: String) {
_uiState.value = _uiState.value.copy(inviteMemberLimit = value.digitsOnly(maxLength = 6))
}
fun updateTopicTitle(value: String) {
_uiState.value = _uiState.value.copy(topicTitle = value.take(80))
}
fun updateProfileTitle(value: String) {
_uiState.value = _uiState.value.copy(profileTitle = value.take(120), errorMessage = null)
scheduleProfileAutosave()
}
fun updateProfileDescription(value: String) {
_uiState.value = _uiState.value.copy(profileDescription = value.take(255), errorMessage = null)
scheduleProfileAutosave()
}
fun updateProfilePublicUsername(value: String) {
_uiState.value = _uiState.value.copy(
profilePublicUsername = value
.trim()
.trimStart('@')
.filter { it.isLetterOrDigit() || it == '_' }
.take(32)
.lowercase(),
errorMessage = null
)
scheduleProfileAutosave()
}
private fun scheduleProfileAutosave() {
profileAutosaveJob?.cancel()
profileAutosaveJob = viewModelScope.launch {
delay(ProfileAutosaveDelayMillis)
profileAutosaveJob = null
saveProfileFields()
}
}
private fun saveProfileFields() {
val state = _uiState.value
val info = state.info ?: return
if (!info.canEditProfile()) return
if (state.isSaving) {
scheduleProfileAutosave()
return
}
val title = state.profileTitle.trim()
if (title.isBlank()) {
_uiState.value = state.copy(errorMessage = "Название не может быть пустым.")
return
}
val description = state.profileDescription.trim()
val publicUsername = state.profilePublicUsername.trim().trimStart('@')
if (profileMatchesInfo(info, title, description, publicUsername)) {
return
}
viewModelScope.launch {
_uiState.value = _uiState.value.copy(isSaving = true, errorMessage = null)
runCatching {
container.executeAuthorized { session ->
container.apiClient.updateChatState(
accessToken = session.accessToken,
chatId = chatId,
request = ChatStateRequest(
title = title,
description = description.takeIf { it.isNotBlank() },
publicUsername = publicUsername.takeIf { info.chat.type == ChatType.Channel && it.isNotBlank() },
clearDescription = description.isBlank(),
clearPublicUsername = info.chat.type == ChatType.Channel && publicUsername.isBlank()
)
)
}
}.onSuccess {
val current = _uiState.value
val changedAgain = current.profileTitle.trim() != title ||
current.profileDescription.trim() != description ||
current.profilePublicUsername.trim().trimStart('@') != publicUsername
_uiState.value = current.copy(isSaving = false)
if (changedAgain) {
scheduleProfileAutosave()
} else {
refresh()
}
}.onFailure { error ->
_uiState.value = _uiState.value.copy(isSaving = false, errorMessage = error.message)
}
}
}
fun createInviteLink() {
val state = _uiState.value
if (state.isSaving) return
if (state.isSaving || state.info?.canInviteUsers != true) return
viewModelScope.launch {
_uiState.value = _uiState.value.copy(isSaving = true, errorMessage = null)
runCatching {
@@ -69,13 +198,23 @@ class ChatInfoViewModel(
accessToken = session.accessToken,
chatId = chatId,
title = state.inviteTitle,
createsJoinRequest = state.inviteCreatesJoinRequest
createsJoinRequest = state.inviteCreatesJoinRequest,
expiresAt = state.inviteExpiresInHours.toPositiveIntOrNull()?.let { hours ->
OffsetDateTime.now(ZoneOffset.UTC)
.plusHours(hours.toLong())
.format(DateTimeFormatter.ISO_OFFSET_DATE_TIME)
},
maxUses = state.inviteMaxUses.toPositiveIntOrNull(),
memberLimit = state.inviteMemberLimit.toPositiveIntOrNull()
)
}
}.onSuccess {
_uiState.value = _uiState.value.copy(
inviteTitle = "",
inviteCreatesJoinRequest = false,
inviteExpiresInHours = "",
inviteMaxUses = "",
inviteMemberLimit = "",
isSaving = false
)
refresh()
@@ -86,7 +225,7 @@ class ChatInfoViewModel(
}
fun revokeInviteLink(link: ChatInviteLinkDto) {
if (_uiState.value.isSaving) return
if (_uiState.value.isSaving || _uiState.value.info?.canInviteUsers != true) return
viewModelScope.launch {
_uiState.value = _uiState.value.copy(isSaving = true, errorMessage = null)
runCatching {
@@ -120,4 +259,64 @@ class ChatInfoViewModel(
}
}
}
fun setLinkedDiscussion(group: ChatSummaryDto) {
updateLinkedDiscussion(ChatStateRequest(linkedDiscussionChatId = group.id))
}
fun clearLinkedDiscussion() {
updateLinkedDiscussion(ChatStateRequest(clearLinkedDiscussionChat = true))
}
private fun updateLinkedDiscussion(request: ChatStateRequest) {
val state = _uiState.value
val info = state.info ?: return
if (state.isSaving || info.chat.type != ChatType.Channel || !info.canManageChannel) {
return
}
viewModelScope.launch {
_uiState.value = _uiState.value.copy(isSaving = true, errorMessage = null)
runCatching {
container.executeAuthorized { session ->
container.apiClient.updateChatState(
accessToken = session.accessToken,
chatId = chatId,
request = request
)
}
}.onSuccess {
_uiState.value = _uiState.value.copy(isSaving = false)
refresh()
}.onFailure { error ->
_uiState.value = _uiState.value.copy(isSaving = false, errorMessage = error.message)
}
}
}
}
private const val ProfileAutosaveDelayMillis = 600L
private fun String.digitsOnly(maxLength: Int): String =
filter { it.isDigit() }.take(maxLength)
private fun String.toPositiveIntOrNull(): Int? =
toIntOrNull()?.takeIf { it > 0 }
private fun ChatInfoDto.canEditProfile(): Boolean =
chat.type != ChatType.Direct && (canManageChannel || chat.currentUserPermissions?.canManageMembers == true)
private fun profileMatchesInfo(
info: ChatInfoDto,
title: String,
description: String,
publicUsername: String
): Boolean {
val currentDescription = (info.description ?: info.chat.description.orEmpty()).trim()
val currentPublicUsername = (info.publicUsername ?: info.chat.publicUsername.orEmpty())
.trim()
.trimStart('@')
return info.chat.title.trim() == title &&
currentDescription == description &&
(info.chat.type != ChatType.Channel || currentPublicUsername == publicUsername)
}
@@ -0,0 +1,175 @@
package com.seven.ikar.kotlin.feature.chatinfo
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.WindowInsetsSides
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.only
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawing
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.seven.ikar.kotlin.core.model.MessageDto
import com.seven.ikar.kotlin.ui.IkarRowDivider
import com.seven.ikar.kotlin.ui.IkarScreenSurface
import com.seven.ikar.kotlin.ui.IkarSectionCard
import com.seven.ikar.kotlin.ui.IkarSimpleHeader
import com.seven.ikar.kotlin.ui.formatLastActivity
import com.seven.ikar.kotlin.ui.theme.TelegramBlue
import com.seven.ikar.kotlin.ui.theme.TelegramDanger
import com.seven.ikar.kotlin.ui.theme.TelegramInkMuted
@Composable
fun ChatMaterialsScreen(
state: ChatMaterialsUiState,
onBackClick: () -> Unit,
onLoadMore: () -> Unit,
onMessageClick: (MessageDto) -> Unit
) {
IkarScreenSurface(modifier = Modifier.fillMaxSize()) {
Column(
modifier = Modifier
.fillMaxSize()
.windowInsetsPadding(WindowInsets.safeDrawing.only(WindowInsetsSides.Vertical))
.padding(horizontal = 18.dp, vertical = 16.dp),
verticalArrangement = Arrangement.spacedBy(14.dp)
) {
IkarSimpleHeader(title = state.filter.title, onBackClick = onBackClick)
state.errorMessage?.let {
Text(it, color = TelegramDanger, style = MaterialTheme.typography.bodySmall)
}
IkarSectionCard(modifier = Modifier.weight(1f)) {
when {
state.isLoading -> {
Column(
modifier = Modifier.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
CircularProgressIndicator(color = TelegramBlue)
}
}
state.messages.isEmpty() -> {
Column(
modifier = Modifier.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Text("Пока пусто", style = MaterialTheme.typography.titleMedium)
Text(
emptySubtitle(state.filter),
style = MaterialTheme.typography.bodySmall,
color = TelegramInkMuted
)
}
}
else -> {
LazyColumn(modifier = Modifier.fillMaxSize()) {
items(state.messages, key = { it.id }) { message ->
ChatMaterialRow(
message = message,
filter = state.filter,
onClick = { onMessageClick(message) }
)
IkarRowDivider(startIndent = 0.dp)
}
if (state.hasMore) {
item(key = "load-more") {
TextButton(
enabled = !state.isLoadingMore,
onClick = onLoadMore,
modifier = Modifier.fillMaxWidth()
) {
Text(if (state.isLoadingMore) "Загрузка..." else "Показать ещё", color = TelegramBlue)
}
}
}
}
}
}
}
}
}
}
@Composable
private fun ChatMaterialRow(
message: MessageDto,
filter: ChatMaterialFilter,
onClick: () -> Unit
) {
Row(
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onClick)
.padding(vertical = 12.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.Top
) {
Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(3.dp)) {
Text(
text = message.displayAuthorName ?: message.sender.displayName,
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Text(
text = materialPreview(message, filter),
style = MaterialTheme.typography.bodyMedium,
maxLines = 2,
overflow = TextOverflow.Ellipsis
)
message.attachments.takeIf { it.isNotEmpty() }?.let { attachments ->
Text(
text = attachments.joinToString(limit = 3) { it.fileName },
style = MaterialTheme.typography.bodySmall,
color = TelegramInkMuted,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
}
Text(
text = formatLastActivity(message.sentAt),
style = MaterialTheme.typography.bodySmall,
color = TelegramInkMuted
)
}
}
private fun materialPreview(message: MessageDto, filter: ChatMaterialFilter): String =
when {
message.text.isNotBlank() -> message.text
filter == ChatMaterialFilter.Pinned -> "Закреплённое сообщение"
message.attachments.isNotEmpty() -> message.attachments.first().fileName
else -> "Сообщение"
}
private fun emptySubtitle(filter: ChatMaterialFilter): String =
when (filter) {
ChatMaterialFilter.Media -> "В этом чате пока нет фото или видео."
ChatMaterialFilter.Files -> "В этом чате пока нет файлов."
ChatMaterialFilter.Links -> "В этом чате пока нет ссылок."
ChatMaterialFilter.Voice -> "В этом чате пока нет голосовых сообщений."
ChatMaterialFilter.Pinned -> "В этом чате пока нет закреплённых сообщений."
}
@@ -0,0 +1,97 @@
package com.seven.ikar.kotlin.feature.chatinfo
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.seven.ikar.kotlin.app.AppContainer
import com.seven.ikar.kotlin.core.model.MessageDto
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
enum class ChatMaterialFilter(val routeValue: String, val wireValue: String, val title: String) {
Media("media", "media", "Медиа"),
Files("files", "files", "Файлы"),
Links("links", "links", "Ссылки"),
Voice("voice", "voice", "Голосовые"),
Pinned("pinned", "pinned", "Закреплённые");
companion object {
fun fromRoute(value: String?): ChatMaterialFilter =
entries.firstOrNull { it.routeValue == value } ?: Media
}
}
data class ChatMaterialsUiState(
val filter: ChatMaterialFilter,
val messages: List<MessageDto> = emptyList(),
val page: Int = 1,
val hasMore: Boolean = false,
val isLoading: Boolean = false,
val isLoadingMore: Boolean = false,
val errorMessage: String? = null
)
class ChatMaterialsViewModel(
private val container: AppContainer,
private val chatId: String,
private val filter: ChatMaterialFilter
) : ViewModel() {
private val _uiState = MutableStateFlow(ChatMaterialsUiState(filter = filter, isLoading = true))
val uiState: StateFlow<ChatMaterialsUiState> = _uiState.asStateFlow()
init {
refresh()
}
fun refresh() {
loadPage(page = 1, append = false)
}
fun loadMore() {
val state = _uiState.value
if (!state.hasMore || state.isLoading || state.isLoadingMore) {
return
}
loadPage(page = state.page + 1, append = true)
}
private fun loadPage(page: Int, append: Boolean) {
viewModelScope.launch {
val current = _uiState.value
_uiState.value = current.copy(
isLoading = !append,
isLoadingMore = append,
errorMessage = null
)
runCatching {
container.executeAuthorized { session ->
container.apiClient.searchChatMessagesPage(
accessToken = session.accessToken,
chatId = chatId,
query = " ",
filter = filter.wireValue,
page = page,
limit = 40
)
}
}.onSuccess { result ->
val existing = if (append) _uiState.value.messages else emptyList()
_uiState.value = _uiState.value.copy(
messages = (existing + result.messages).distinctBy { it.id },
page = result.page,
hasMore = result.hasMore,
isLoading = false,
isLoadingMore = false
)
}.onFailure { error ->
_uiState.value = _uiState.value.copy(
isLoading = false,
isLoadingMore = false,
errorMessage = error.message
)
}
}
}
}
@@ -39,10 +39,14 @@ import androidx.compose.material.icons.outlined.PlayArrow
import androidx.compose.material.icons.outlined.PushPin
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Checkbox
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SwipeToDismissBox
import androidx.compose.material3.SwipeToDismissBoxValue
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.rememberSwipeToDismissBoxState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
@@ -71,6 +75,7 @@ import com.seven.ikar.kotlin.core.model.StoryDto
import com.seven.ikar.kotlin.core.network.ServerConfig
import com.seven.ikar.kotlin.core.settings.ChatListLayoutOption
import com.seven.ikar.kotlin.core.settings.ChatSettingsSnapshot
import com.seven.ikar.kotlin.core.settings.ChatSwipeLeftActionOption
import com.seven.ikar.kotlin.feature.share.PendingExternalShare
import com.seven.ikar.kotlin.ui.AuthenticatedAsyncImage
import com.seven.ikar.kotlin.ui.AvatarView
@@ -99,6 +104,11 @@ fun ChatsScreen(
onPinSelectedChatsClick: () -> Unit,
onArchiveSelectedChatsClick: () -> Unit,
onMuteSelectedChatsClick: () -> Unit,
onTogglePinChat: (ChatSummaryDto) -> Unit,
onToggleArchiveChat: (ChatSummaryDto) -> Unit,
onToggleMuteChat: (ChatSummaryDto) -> Unit,
onMarkChatRead: (ChatSummaryDto) -> Unit,
onSwipeDeleteChat: (ChatSummaryDto) -> Unit,
onDismissDeleteDialog: () -> Unit,
onDeleteForEveryoneChanged: (Boolean) -> Unit,
onConfirmDeleteSelected: () -> Unit,
@@ -112,6 +122,7 @@ fun ChatsScreen(
onSettingsClick: () -> Unit,
onNotificationsClick: () -> Unit,
onGlobalSearchClick: () -> Unit,
onFilterChanged: (ChatListFilter) -> Unit,
onQueryChanged: (String) -> Unit
) {
val isRealtimeConnected = !state.isLoading || state.chats.isNotEmpty()
@@ -229,6 +240,13 @@ fun ChatsScreen(
placeholder = "Поиск чатов"
)
if (!state.isSelectionMode && !state.isShareMode) {
ChatFilterTabs(
selectedFilter = state.filter,
onFilterChanged = onFilterChanged
)
}
TextButton(onClick = onGlobalSearchClick) {
Text("Глобальный поиск: сообщения, медиа, файлы и ссылки")
}
@@ -275,20 +293,38 @@ fun ChatsScreen(
}
} else {
items(state.chats, key = { it.chat.id }) { chatItem ->
ChatListRow(
chatItem = chatItem,
currentUserId = state.session?.user?.id,
accessToken = state.session?.accessToken,
previewMaxLines = previewMaxLines,
onClick = {
if (state.isSelectionMode) {
onChatSelectionClick(chatItem.chat)
} else {
onChatClick(chatItem.chat)
}
},
onLongClick = { onChatLongPress(chatItem.chat) }
)
val rowClick = {
if (state.isSelectionMode) {
onChatSelectionClick(chatItem.chat)
} else {
onChatClick(chatItem.chat)
}
}
if (state.isSelectionMode || state.isShareMode) {
ChatListRow(
chatItem = chatItem,
currentUserId = state.session?.user?.id,
accessToken = state.session?.accessToken,
previewMaxLines = previewMaxLines,
onClick = rowClick,
onLongClick = { onChatLongPress(chatItem.chat) }
)
} else {
SwipeableChatListRow(
chatItem = chatItem,
currentUserId = state.session?.user?.id,
accessToken = state.session?.accessToken,
previewMaxLines = previewMaxLines,
swipeLeftAction = chatSettings.swipeLeftAction,
onClick = rowClick,
onLongClick = { onChatLongPress(chatItem.chat) },
onTogglePin = { onTogglePinChat(chatItem.chat) },
onToggleArchive = { onToggleArchiveChat(chatItem.chat) },
onToggleMute = { onToggleMuteChat(chatItem.chat) },
onMarkRead = { onMarkChatRead(chatItem.chat) },
onDelete = { onSwipeDeleteChat(chatItem.chat) }
)
}
}
}
item { Box(modifier = Modifier.padding(bottom = 132.dp)) }
@@ -323,6 +359,7 @@ fun ChatsScreen(
selectedChatsCount = state.selectedChatsCount,
showDeleteForEveryoneOption = state.showDeleteForEveryoneOption,
deleteForEveryone = state.deleteForEveryone,
deleteForEveryoneLabel = state.deleteForEveryoneLabel,
isDeletingSelected = state.isDeletingSelected,
onDismiss = onDismissDeleteDialog,
onDeleteForEveryoneChanged = onDeleteForEveryoneChanged,
@@ -332,6 +369,40 @@ fun ChatsScreen(
}
}
@Composable
private fun ChatFilterTabs(
selectedFilter: ChatListFilter,
onFilterChanged: (ChatListFilter) -> Unit
) {
val filters = listOf(
ChatListFilter.All to "Все",
ChatListFilter.Unread to "Непрочитанные",
ChatListFilter.Pinned to "Закреплённые",
ChatListFilter.Archive to "Архив"
)
LazyRow(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
items(filters, key = { it.first.name }) { (filter, label) ->
val isSelected = filter == selectedFilter
Text(
text = label,
modifier = Modifier
.clip(RoundedCornerShape(18.dp))
.background(if (isSelected) TelegramBlue else TelegramBlue.copy(alpha = 0.08f))
.clickable { onFilterChanged(filter) }
.padding(horizontal = 14.dp, vertical = 8.dp),
color = if (isSelected) Color.White else TelegramBlue,
style = MaterialTheme.typography.labelLarge,
fontWeight = FontWeight.SemiBold,
maxLines = 1
)
}
}
}
@Composable
private fun ChatsBrandHeader(
isRealtimeConnected: Boolean,
@@ -370,7 +441,7 @@ private fun StoriesClusterButton(
currentUserId: String?,
onClick: () -> Unit
) {
val previewStories = stories.take(3)
val previewStories = remember(stories) { stories.toAuthorGroups().take(3) }
val placeholderColors = listOf(
TelegramBlue,
Color(0xFF7C4DFF),
@@ -385,7 +456,8 @@ private fun StoriesClusterButton(
contentAlignment = Alignment.CenterStart
) {
repeat(3) { index ->
val story = previewStories.getOrNull(index)
val group = previewStories.getOrNull(index)
val story = group?.latestStory
Box(
modifier = Modifier
.padding(start = (index * 13).dp)
@@ -432,6 +504,7 @@ private fun StoriesTray(
onComposeStoryClick: () -> Unit,
onStoryClick: (StoryDto) -> Unit
) {
val storyGroups = remember(stories) { stories.toAuthorGroups() }
Column(
modifier = Modifier
.fillMaxWidth()
@@ -454,7 +527,8 @@ private fun StoriesTray(
)
}
items(stories, key = { it.id }) { story ->
items(storyGroups, key = { it.authorId }) { group ->
val story = group.latestStory
StoryBubble(
title = if (story.author.id == currentUserId) "Моя" else story.author.displayName,
avatarUrl = ServerConfig.normalizeMediaUrl(story.author.avatarPath),
@@ -462,8 +536,9 @@ private fun StoriesTray(
story.attachments.firstOrNull { it.contentType.startsWith("image/") }?.downloadPath
),
accessToken = accessToken,
viewed = story.viewedByMe || story.author.id == currentUserId,
onClick = { onStoryClick(story) }
viewed = group.isViewedByMe || story.author.id == currentUserId,
badgeText = group.stories.size.takeIf { it > 1 }?.toString(),
onClick = { onStoryClick(group.openStory) }
)
}
@@ -481,6 +556,7 @@ private fun StoryBubble(
previewUrl: String?,
accessToken: String?,
viewed: Boolean,
badgeText: String? = null,
onClick: () -> Unit,
modifier: Modifier = Modifier
) {
@@ -520,6 +596,24 @@ private fun StoryBubble(
size = 56.dp
)
}
badgeText?.let { text ->
Box(
modifier = Modifier
.align(Alignment.BottomEnd)
.size(20.dp)
.clip(CircleShape)
.background(TelegramBlue)
.border(1.dp, Color.White, CircleShape),
contentAlignment = Alignment.Center
) {
Text(
text = text,
style = MaterialTheme.typography.labelSmall,
color = Color.White,
maxLines = 1
)
}
}
}
Text(
title,
@@ -530,6 +624,30 @@ private fun StoryBubble(
}
}
private data class StoryAuthorGroup(
val authorId: String,
val stories: List<StoryDto>
) {
val latestStory: StoryDto
get() = stories.first()
val openStory: StoryDto
get() = stories.firstOrNull { !it.viewedByMe } ?: latestStory
val isViewedByMe: Boolean
get() = stories.all { it.viewedByMe }
}
private fun List<StoryDto>.toAuthorGroups(): List<StoryAuthorGroup> =
groupBy { it.author.id }
.map { (authorId, authorStories) ->
StoryAuthorGroup(
authorId = authorId,
stories = authorStories.sortedByDescending { it.createdAt }
)
}
.sortedByDescending { it.latestStory.createdAt }
@Composable
private fun ShareSelectionHeader(
selectedChatsCount: Int,
@@ -718,6 +836,7 @@ private fun DeleteChatsDialog(
selectedChatsCount: Int,
showDeleteForEveryoneOption: Boolean,
deleteForEveryone: Boolean,
deleteForEveryoneLabel: String,
isDeletingSelected: Boolean,
onDismiss: () -> Unit,
onDeleteForEveryoneChanged: (Boolean) -> Unit,
@@ -749,7 +868,7 @@ private fun DeleteChatsDialog(
onCheckedChange = onDeleteForEveryoneChanged
)
Text(
text = "Также удалить чат для собеседника",
text = deleteForEveryoneLabel,
style = MaterialTheme.typography.bodyMedium
)
}
@@ -775,6 +894,94 @@ private fun DeleteChatsDialog(
)
}
@Composable
@OptIn(ExperimentalMaterial3Api::class)
private fun SwipeableChatListRow(
chatItem: ChatListItemUiState,
currentUserId: String?,
accessToken: String?,
previewMaxLines: Int,
swipeLeftAction: ChatSwipeLeftActionOption,
onClick: () -> Unit,
onLongClick: () -> Unit,
onTogglePin: () -> Unit,
onToggleArchive: () -> Unit,
onToggleMute: () -> Unit,
onMarkRead: () -> Unit,
onDelete: () -> Unit
) {
val dismissState = rememberSwipeToDismissBoxState(
confirmValueChange = { value ->
when (value) {
SwipeToDismissBoxValue.StartToEnd -> onTogglePin()
SwipeToDismissBoxValue.EndToStart -> when (swipeLeftAction) {
ChatSwipeLeftActionOption.Delete -> onDelete()
ChatSwipeLeftActionOption.Archive -> onToggleArchive()
ChatSwipeLeftActionOption.Mute -> onToggleMute()
ChatSwipeLeftActionOption.Read -> onMarkRead()
}
SwipeToDismissBoxValue.Settled -> Unit
}
false
}
)
SwipeToDismissBox(
state = dismissState,
enableDismissFromStartToEnd = true,
enableDismissFromEndToStart = true,
backgroundContent = {
Row(
modifier = Modifier
.fillMaxSize()
.background(TelegramBlue.copy(alpha = 0.08f))
.padding(horizontal = 24.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) {
Icon(Icons.Outlined.PushPin, contentDescription = null, tint = TelegramBlue)
Text(
if (chatItem.chat.isPinned) "Открепить" else "Закрепить",
style = MaterialTheme.typography.labelLarge,
color = TelegramBlue
)
}
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) {
val leftSwipeIcon = when (swipeLeftAction) {
ChatSwipeLeftActionOption.Delete -> Icons.Outlined.Delete
ChatSwipeLeftActionOption.Archive -> Icons.Outlined.Archive
ChatSwipeLeftActionOption.Mute -> Icons.Outlined.NotificationsOff
ChatSwipeLeftActionOption.Read -> Icons.Outlined.CheckCircle
}
Text(
chatItem.chat.leftSwipeActionLabel(swipeLeftAction),
style = MaterialTheme.typography.labelLarge,
color = TelegramBlue
)
Icon(leftSwipeIcon, contentDescription = null, tint = TelegramBlue)
}
}
}
) {
ChatListRow(
chatItem = chatItem,
currentUserId = currentUserId,
accessToken = accessToken,
previewMaxLines = previewMaxLines,
onClick = onClick,
onLongClick = onLongClick
)
}
}
private fun ChatSummaryDto.leftSwipeActionLabel(action: ChatSwipeLeftActionOption): String =
when (action) {
ChatSwipeLeftActionOption.Delete -> "Удалить"
ChatSwipeLeftActionOption.Archive -> if (isArchived) "Вернуть" else "В архив"
ChatSwipeLeftActionOption.Mute -> if (isMuted) "Включить звук" else "Без звука"
ChatSwipeLeftActionOption.Read -> if (unreadCount > 0) "Прочитать" else "Прочитано"
}
@Composable
@OptIn(ExperimentalFoundationApi::class)
private fun ChatListRow(
@@ -795,7 +1002,7 @@ private fun ChatListRow(
val rowBackground = if (chatItem.isSelected) {
TelegramBlue.copy(alpha = 0.12f)
} else {
Color.Transparent
MaterialTheme.colorScheme.background
}
Column {
@@ -848,6 +1055,7 @@ private fun ChatListRow(
) {
ChatLastMessagePreview(
chat = chat,
draftText = chatItem.draftText,
accessToken = accessToken,
modifier = Modifier.weight(1f),
maxLines = previewMaxLines
@@ -887,12 +1095,37 @@ private fun ChatListRow(
@Composable
private fun ChatLastMessagePreview(
chat: ChatSummaryDto,
draftText: String,
accessToken: String?,
modifier: Modifier = Modifier,
maxLines: Int
) {
val attachment = chat.lastMessageAttachment
when {
draftText.isNotBlank() -> {
Row(
modifier = modifier,
horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "Черновик:",
style = MaterialTheme.typography.bodySmall,
color = TelegramBlue,
fontWeight = FontWeight.SemiBold,
maxLines = 1
)
Text(
text = draftText,
modifier = Modifier.weight(1f),
style = MaterialTheme.typography.bodySmall,
color = TelegramInkMuted,
maxLines = maxLines,
overflow = TextOverflow.Ellipsis
)
}
}
attachment?.isChatPreviewImage() == true -> {
Row(
modifier = modifier,
@@ -980,8 +1213,7 @@ private fun AttachmentDto.isChatPreviewImage(): Boolean =
fileName.endsWith(".gif", ignoreCase = true)
private fun AttachmentDto.isChatPreviewVoiceNote(): Boolean =
kind == AttachmentKind.VoiceNote ||
contentType.startsWith("audio/", ignoreCase = true)
kind == AttachmentKind.VoiceNote
private fun selectionCountLabel(selectedChatsCount: Int): String = when {
selectedChatsCount % 10 == 1 && selectedChatsCount % 100 != 11 -> "Выбран $selectedChatsCount чат"
@@ -31,6 +31,7 @@ data class ChatListItemUiState(
val chat: ChatSummaryDto,
val resolvedTitle: String,
val lastActivityLabel: String,
val draftText: String,
val isSelected: Boolean
)
@@ -48,6 +49,8 @@ enum class ChatListFilter {
Archive
}
private const val DefaultDeleteForEveryoneLabel = "Также удалить чат для собеседника"
data class ChatsUiState(
val session: AuthSessionDto? = null,
val query: String = "",
@@ -64,7 +67,9 @@ data class ChatsUiState(
val isDeleteDialogVisible: Boolean = false,
val deleteForEveryone: Boolean = false,
val showDeleteForEveryoneOption: Boolean = false,
val isDeletingSelected: Boolean = false
val deleteForEveryoneLabel: String = DefaultDeleteForEveryoneLabel,
val isDeletingSelected: Boolean = false,
val selectionStartedFromSwipeDelete: Boolean = false
) {
val isShareMode: Boolean
get() = pendingShare != null
@@ -81,11 +86,13 @@ class ChatsViewModel(private val container: AppContainer) : ViewModel() {
private val _allChats = mutableListOf<ChatSummaryDto>()
private var contactNamesByNormalizedPhone: Map<String, String> = emptyMap()
private var refreshJob: Job? = null
private var hadActiveStoryTransfers = false
val uiState: StateFlow<ChatsUiState> = _uiState.asStateFlow()
init {
attachIncomingShare()
attachRealtime()
attachStoryTransferCompletion()
loadCachedChats()
refresh()
refreshLatestRelease()
@@ -128,7 +135,9 @@ class ChatsViewModel(private val container: AppContainer) : ViewModel() {
isDeleteDialogVisible = false,
deleteForEveryone = false,
showDeleteForEveryoneOption = false,
isDeletingSelected = false
deleteForEveryoneLabel = DefaultDeleteForEveryoneLabel,
isDeletingSelected = false,
selectionStartedFromSwipeDelete = false
)
applyFilter()
}
@@ -143,15 +152,23 @@ class ChatsViewModel(private val container: AppContainer) : ViewModel() {
_uiState.value = state.copy(
isDeleteDialogVisible = true,
deleteForEveryone = state.deleteForEveryone && showDeleteForEveryoneOption,
showDeleteForEveryoneOption = showDeleteForEveryoneOption
showDeleteForEveryoneOption = showDeleteForEveryoneOption,
deleteForEveryoneLabel = deleteForEveryoneLabel(state.selectedChatIds),
selectionStartedFromSwipeDelete = false
)
}
fun dismissDeleteDialog() {
_uiState.value = _uiState.value.copy(
val state = _uiState.value
_uiState.value = state.copy(
selectedChatIds = if (state.selectionStartedFromSwipeDelete) emptySet() else state.selectedChatIds,
isDeleteDialogVisible = false,
deleteForEveryone = false
deleteForEveryone = false,
showDeleteForEveryoneOption = false,
deleteForEveryoneLabel = DefaultDeleteForEveryoneLabel,
selectionStartedFromSwipeDelete = false
)
applyFilter()
}
fun updateDeleteForEveryone(value: Boolean) {
@@ -227,6 +244,82 @@ class ChatsViewModel(private val container: AppContainer) : ViewModel() {
)
}
fun togglePinChat(chat: ChatSummaryDto) {
updateSingleChatState(chat.id, ChatStateRequest(isPinned = !chat.isPinned))
}
fun toggleArchiveChat(chat: ChatSummaryDto) {
updateSingleChatState(chat.id, ChatStateRequest(isArchived = !chat.isArchived))
}
fun toggleMuteChat(chat: ChatSummaryDto) {
val shouldMute = !chat.isMuted
updateSingleChatState(
chat.id,
ChatStateRequest(
mutedUntil = if (shouldMute) {
OffsetDateTime.now(ZoneOffset.UTC).plusYears(1).format(DateTimeFormatter.ISO_OFFSET_DATE_TIME)
} else {
null
},
isMuted = shouldMute
)
)
}
fun markChatRead(chat: ChatSummaryDto) {
if (chat.unreadCount <= 0) {
return
}
viewModelScope.launch {
_uiState.value = _uiState.value.copy(errorMessage = null)
runCatching {
container.executeAuthorized { session ->
container.apiClient.markChatRead(session.accessToken, chat.id)
}
}.onSuccess {
refresh()
}.onFailure { error ->
_uiState.value = _uiState.value.copy(errorMessage = error.message)
}
}
}
fun showSwipeDeleteDialog(chat: ChatSummaryDto) {
val state = _uiState.value
if (state.isShareMode || state.isDeletingSelected) {
return
}
val selectedChatIds = setOf(chat.id)
val showDeleteForEveryoneOption = canDeleteSelectedForEveryone(selectedChatIds)
_uiState.value = state.copy(
selectedChatIds = selectedChatIds,
isDeleteDialogVisible = true,
deleteForEveryone = false,
showDeleteForEveryoneOption = showDeleteForEveryoneOption,
deleteForEveryoneLabel = deleteForEveryoneLabel(selectedChatIds),
selectionStartedFromSwipeDelete = true
)
applyFilter()
}
private fun updateSingleChatState(chatId: String, request: ChatStateRequest) {
viewModelScope.launch {
_uiState.value = _uiState.value.copy(errorMessage = null)
runCatching {
container.executeAuthorized { session ->
container.apiClient.updateChatState(session.accessToken, chatId, request)
}
}.onSuccess {
refresh()
}.onFailure { error ->
_uiState.value = _uiState.value.copy(errorMessage = error.message)
}
}
}
private fun updateSelectedChatState(request: ChatStateRequest) {
val selectedChatIds = _uiState.value.selectedChatIds.toList()
if (selectedChatIds.isEmpty()) {
@@ -403,15 +496,21 @@ class ChatsViewModel(private val container: AppContainer) : ViewModel() {
} else {
chatItems.filter { item ->
item.resolvedTitle.contains(query, ignoreCase = true) ||
item.chat.title.contains(query, ignoreCase = true) ||
item.chat.title.contains(query, ignoreCase = true) ||
item.chat.secondaryText.orEmpty().contains(query, ignoreCase = true) ||
item.chat.lastMessagePreview.orEmpty().contains(query, ignoreCase = true)
item.chat.lastMessagePreview.orEmpty().contains(query, ignoreCase = true) ||
item.draftText.contains(query, ignoreCase = true)
}
}
_uiState.value = currentState.copy(
chats = filtered,
selectedChatIds = selectedChatIds,
showDeleteForEveryoneOption = showDeleteForEveryoneOption,
deleteForEveryoneLabel = if (showDeleteForEveryoneOption) {
deleteForEveryoneLabel(selectedChatIds)
} else {
DefaultDeleteForEveryoneLabel
},
deleteForEveryone = deleteForEveryone,
isDeleteDialogVisible = !currentState.isShareMode &&
currentState.isDeleteDialogVisible &&
@@ -428,6 +527,7 @@ class ChatsViewModel(private val container: AppContainer) : ViewModel() {
chat = chat,
resolvedTitle = resolveChatTitle(chat, currentUserId),
lastActivityLabel = formatLastActivity(chat.lastActivityAt),
draftText = container.chatDraftStore.read(chat.id).trim(),
isSelected = selectedChatIds.contains(chat.id)
)
}
@@ -469,7 +569,16 @@ class ChatsViewModel(private val container: AppContainer) : ViewModel() {
}
val chat = _allChats.firstOrNull { selectedChatIds.contains(it.id) } ?: return false
return chat.type == ChatType.Direct
return chat.canDeleteForEveryone
}
private fun deleteForEveryoneLabel(selectedChatIds: Set<String>): String {
val chat = _allChats.firstOrNull { selectedChatIds.contains(it.id) }
return when (chat?.type) {
ChatType.Channel -> "Удалить канал для всех подписчиков"
ChatType.Group -> "Удалить чат для всех участников"
else -> DefaultDeleteForEveryoneLabel
}
}
private fun attachRealtime() {
@@ -485,11 +594,30 @@ class ChatsViewModel(private val container: AppContainer) : ViewModel() {
viewModelScope.launch {
container.realtimeService.messagesUpdated.collect { scheduleRefresh() }
}
viewModelScope.launch {
container.realtimeService.storiesChanged.collect { scheduleRefresh() }
}
viewModelScope.launch {
container.realtimeService.appUpdateAvailable.collect { release ->
_uiState.value = _uiState.value.copy(latestRelease = release)
}
}
viewModelScope.launch {
container.chatDraftStore.changes.collect {
applyFilter()
}
}
}
private fun attachStoryTransferCompletion() {
viewModelScope.launch {
container.transferQueueRepository.observeActiveStoryUploads().collect { transfers ->
if (hadActiveStoryTransfers && transfers.isEmpty()) {
scheduleRefresh()
}
hadActiveStoryTransfers = transfers.isNotEmpty()
}
}
}
private fun scheduleRefresh() {
@@ -510,7 +638,9 @@ class ChatsViewModel(private val container: AppContainer) : ViewModel() {
isDeleteDialogVisible = if (pendingShare == null) currentState.isDeleteDialogVisible else false,
deleteForEveryone = if (pendingShare == null) currentState.deleteForEveryone else false,
showDeleteForEveryoneOption = if (pendingShare == null) currentState.showDeleteForEveryoneOption else false,
deleteForEveryoneLabel = if (pendingShare == null) currentState.deleteForEveryoneLabel else DefaultDeleteForEveryoneLabel,
isDeletingSelected = if (pendingShare == null) currentState.isDeletingSelected else false,
selectionStartedFromSwipeDelete = if (pendingShare == null) currentState.selectionStartedFromSwipeDelete else false,
isSendingShare = currentState.isSendingShare && pendingShare != null
)
applyFilter()
@@ -22,7 +22,8 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import com.seven.ikar.kotlin.core.model.UserSessionDto
import com.seven.ikar.kotlin.core.model.PushDeviceDto
import com.seven.ikar.kotlin.core.model.PushPlatform
import com.seven.ikar.kotlin.ui.IkarRowDivider
import com.seven.ikar.kotlin.ui.IkarScreenSurface
import com.seven.ikar.kotlin.ui.IkarSectionCard
@@ -36,8 +37,7 @@ import com.seven.ikar.kotlin.ui.theme.TelegramInkMuted
fun DevicesScreen(
state: DevicesUiState,
onBackClick: () -> Unit,
onRefreshClick: () -> Unit,
onRevokeClick: (UserSessionDto) -> Unit
onRefreshClick: () -> Unit
) {
IkarScreenSurface(modifier = Modifier.fillMaxSize()) {
Column(
@@ -52,14 +52,25 @@ fun DevicesScreen(
state.errorMessage?.let {
Text(it, color = TelegramDanger, style = MaterialTheme.typography.bodySmall)
}
if (state.isLoading && state.sessions.isEmpty()) {
if (state.isLoading && state.devices.isEmpty()) {
CircularProgressIndicator(color = TelegramBlue)
}
IkarSectionCard {
IkarSectionTitle("Активные сеансы", modifier = Modifier.padding(bottom = 8.dp))
state.sessions.forEachIndexed { index, session ->
DeviceRow(session = session, enabled = !state.isSaving, onRevokeClick = { onRevokeClick(session) })
if (index != state.sessions.lastIndex) {
IkarSectionTitle("Устройства с Икаром", modifier = Modifier.padding(bottom = 8.dp))
if (state.devices.isEmpty() && !state.isLoading) {
Text(
text = "Зарегистрированных устройств нет.",
style = MaterialTheme.typography.bodyMedium,
color = TelegramInkMuted,
modifier = Modifier.padding(vertical = 12.dp)
)
}
state.devices.forEachIndexed { index, device ->
DeviceRow(
device = device,
isCurrent = device.installationId == state.currentInstallationId
)
if (index != state.devices.lastIndex) {
IkarRowDivider()
}
}
@@ -71,9 +82,8 @@ fun DevicesScreen(
@Composable
private fun DeviceRow(
session: UserSessionDto,
enabled: Boolean,
onRevokeClick: () -> Unit
device: PushDeviceDto,
isCurrent: Boolean
) {
Row(
modifier = Modifier
@@ -83,17 +93,34 @@ private fun DeviceRow(
verticalAlignment = Alignment.CenterVertically
) {
Column(modifier = Modifier.weight(1f)) {
Text(session.deviceName, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
Text(
text = "Последняя активность: ${session.lastSeenAt}",
text = device.deviceName?.takeIf { it.isNotBlank() } ?: formatPlatform(device.platform),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold
)
Text(
text = "${formatPlatform(device.platform)} · ${formatNotificationState(device.notificationsEnabled)}",
style = MaterialTheme.typography.bodySmall,
color = TelegramInkMuted
)
Text(
text = "Последняя регистрация: ${device.updatedAt}",
style = MaterialTheme.typography.bodySmall,
color = TelegramInkMuted
)
}
if (session.isCurrent) {
Text("Текущий", color = TelegramBlue)
} else {
TextButton(enabled = enabled, onClick = onRevokeClick) { Text("Завершить") }
if (isCurrent) {
Text("Это устройство", color = TelegramBlue)
}
}
}
private fun formatPlatform(platform: PushPlatform): String =
when (platform) {
PushPlatform.Android -> "Android"
PushPlatform.Windows -> "Windows"
PushPlatform.Unknown -> "Устройство"
}
private fun formatNotificationState(enabled: Boolean): String =
if (enabled) "уведомления включены" else "уведомления выключены"
@@ -3,16 +3,16 @@ package com.seven.ikar.kotlin.feature.devices
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.seven.ikar.kotlin.app.AppContainer
import com.seven.ikar.kotlin.core.model.UserSessionDto
import com.seven.ikar.kotlin.core.model.PushDeviceDto
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
data class DevicesUiState(
val sessions: List<UserSessionDto> = emptyList(),
val devices: List<PushDeviceDto> = emptyList(),
val currentInstallationId: String = "",
val isLoading: Boolean = false,
val isSaving: Boolean = false,
val errorMessage: String? = null
)
@@ -29,30 +29,17 @@ class DevicesViewModel(private val container: AppContainer) : ViewModel() {
_uiState.value = _uiState.value.copy(isLoading = true, errorMessage = null)
runCatching {
container.executeAuthorized { session ->
container.apiClient.getMySessions(session.accessToken)
container.apiClient.getPushDevices(session.accessToken)
}
}.onSuccess { sessions ->
_uiState.value = _uiState.value.copy(sessions = sessions, isLoading = false)
}.onSuccess { devices ->
_uiState.value = _uiState.value.copy(
devices = devices,
currentInstallationId = container.pushSettings.getInstallationId(),
isLoading = false
)
}.onFailure { error ->
_uiState.value = _uiState.value.copy(isLoading = false, errorMessage = error.message)
}
}
}
fun revoke(session: UserSessionDto) {
if (session.isCurrent || _uiState.value.isSaving) return
viewModelScope.launch {
_uiState.value = _uiState.value.copy(isSaving = true, errorMessage = null)
runCatching {
container.executeAuthorized { auth ->
container.apiClient.revokeMySession(auth.accessToken, session.id)
}
}.onSuccess {
_uiState.value = _uiState.value.copy(isSaving = false)
refresh()
}.onFailure { error ->
_uiState.value = _uiState.value.copy(isSaving = false, errorMessage = error.message)
}
}
}
}
@@ -5,21 +5,29 @@ import android.content.pm.PackageManager
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.clickable
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.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.heightIn
import androidx.compose.foundation.layout.only
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawing
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.outlined.ArrowBack
import androidx.compose.material.icons.outlined.Group
@@ -44,6 +52,7 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat
import com.seven.ikar.kotlin.core.contacts.DiscoverUserItem
import com.seven.ikar.kotlin.core.model.ChatSummaryDto
import com.seven.ikar.kotlin.ui.AvatarView
import com.seven.ikar.kotlin.ui.IkarCenteredHeader
import com.seven.ikar.kotlin.ui.IkarHeaderIconBubble
@@ -65,9 +74,13 @@ fun DiscoverScreen(
onQueryChanged: (String) -> Unit,
onRefreshClick: () -> Unit,
onUserClick: (DiscoverUserItem) -> Unit,
onChannelClick: (ChatSummaryDto) -> Unit,
onBeginGroupSelection: () -> Unit,
onBeginChannelSelection: () -> Unit,
onCancelSelection: () -> Unit,
onGroupBackClick: () -> Unit,
onGroupTitleChanged: (String) -> Unit,
onContinueGroupWizard: () -> Unit,
onChannelBackClick: () -> Unit,
onChannelTitleChanged: (String) -> Unit,
onChannelDescriptionChanged: (String) -> Unit,
@@ -85,8 +98,11 @@ fun DiscoverScreen(
}
val hasContactsPermission =
ContextCompat.checkSelfPermission(context, Manifest.permission.READ_CONTACTS) == PackageManager.PERMISSION_GRANTED
val isGroupWizard = state.groupWizardStep != null
val isChannelWizard = state.channelWizardStep != null
val showContacts = state.selectionMode != null || state.channelWizardStep == null
val showContacts = state.selectionMode != null || (state.groupWizardStep == null && state.channelWizardStep == null)
val showPublicChannels = state.selectionMode == null && state.groupWizardStep == null && state.channelWizardStep == null
val headerScrollState = rememberScrollState()
LaunchedEffect(hasContactsPermission) {
if (hasContactsPermission) {
@@ -95,20 +111,40 @@ fun DiscoverScreen(
}
IkarScreenSurface(modifier = Modifier.fillMaxSize()) {
Column(modifier = Modifier.fillMaxSize()) {
Column(
modifier = Modifier
.fillMaxSize()
.windowInsetsPadding(WindowInsets.safeDrawing.only(WindowInsetsSides.Vertical))
) {
val headerModifier = if (showContacts) {
Modifier
.weight(1f, fill = false)
.verticalScroll(headerScrollState)
} else {
Modifier
.weight(1f)
.verticalScroll(headerScrollState)
}
Column(
modifier = Modifier.padding(start = 18.dp, top = 16.dp, end = 18.dp, bottom = 12.dp),
modifier = headerModifier
.fillMaxWidth()
.padding(start = 18.dp, top = 16.dp, end = 18.dp, bottom = 12.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
IkarCenteredHeader(
title = when {
isGroupWizard -> "Новая группа"
isChannelWizard -> "Новый канал"
state.selectionMode == DiscoverSelectionMode.Group -> "Новая группа"
else -> "Новое сообщение"
},
left = {
IkarHeaderIconBubble(
onClick = if (isChannelWizard) onChannelBackClick else onBackClick
onClick = when {
isGroupWizard -> onGroupBackClick
isChannelWizard -> onChannelBackClick
else -> onBackClick
}
) {
Icon(Icons.AutoMirrored.Outlined.ArrowBack, contentDescription = "Назад", tint = MaterialTheme.colorScheme.onSurface)
}
@@ -134,8 +170,25 @@ fun DiscoverScreen(
)
}
when (state.channelWizardStep) {
ChannelWizardStep.Details -> ChannelDetailsStep(
when {
state.groupWizardStep == GroupWizardStep.Details -> GroupDetailsStep(
state = state,
onTitleChanged = onGroupTitleChanged,
onCancelClick = onCancelSelection,
onNextClick = onContinueGroupWizard
)
state.groupWizardStep == GroupWizardStep.Members -> SelectionBanner(
title = "Участники группы",
subtitle = "Выберите контакты, которые войдут в новый общий чат.",
selectedCount = state.selectedUserIds.size,
actionText = if (state.isLoading) "Создание..." else "Создать",
onBackClick = onGroupBackClick,
onActionClick = onContinueGroupWizard,
enabled = !state.isLoading
)
state.channelWizardStep == ChannelWizardStep.Details -> ChannelDetailsStep(
state = state,
onTitleChanged = onChannelTitleChanged,
onDescriptionChanged = onChannelDescriptionChanged,
@@ -143,7 +196,7 @@ fun DiscoverScreen(
onNextClick = onContinueChannelWizard
)
ChannelWizardStep.Type -> ChannelTypeStep(
state.channelWizardStep == ChannelWizardStep.Type -> ChannelTypeStep(
state = state,
onVisibilityChanged = onChannelVisibilityChanged,
onPublicUsernameChanged = onChannelPublicUsernameChanged,
@@ -152,7 +205,7 @@ fun DiscoverScreen(
onNextClick = onContinueChannelWizard
)
ChannelWizardStep.Subscribers -> SelectionBanner(
state.channelWizardStep == ChannelWizardStep.Subscribers -> SelectionBanner(
title = "Добавить подписчиков",
subtitle = "Можно создать канал сейчас и пригласить людей позже.",
selectedCount = state.selectedUserIds.size,
@@ -162,7 +215,7 @@ fun DiscoverScreen(
enabled = !state.isLoading
)
null -> {
else -> {
if (state.selectionMode == null) {
IkarSectionCard(padding = PaddingValues(0.dp)) {
DiscoverActionRow(
@@ -193,6 +246,15 @@ fun DiscoverScreen(
}
}
if (showPublicChannels) {
PublicChannelsSection(
channels = state.publicChannels,
query = state.query,
isLoading = state.isLoading,
onChannelClick = onChannelClick
)
}
state.errorMessage?.let {
Text(it, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodyMedium)
}
@@ -209,6 +271,146 @@ fun DiscoverScreen(
}
}
@Composable
private fun PublicChannelsSection(
channels: List<ChatSummaryDto>,
query: String,
isLoading: Boolean,
onChannelClick: (ChatSummaryDto) -> Unit
) {
if (channels.isEmpty() && query.isBlank() && !isLoading) {
return
}
IkarSectionCard(padding = PaddingValues(0.dp)) {
Text(
"Публичные каналы",
modifier = Modifier.padding(start = 18.dp, top = 16.dp, end = 18.dp, bottom = 8.dp),
style = MaterialTheme.typography.labelLarge,
color = TelegramBlue
)
when {
isLoading && channels.isEmpty() -> {
Text(
"Загрузка...",
modifier = Modifier.padding(horizontal = 18.dp, vertical = 14.dp),
style = MaterialTheme.typography.bodyMedium,
color = TelegramInkMuted
)
}
channels.isEmpty() -> {
Text(
"Публичные каналы не найдены",
modifier = Modifier.padding(horizontal = 18.dp, vertical = 14.dp),
style = MaterialTheme.typography.bodyMedium,
color = TelegramInkMuted
)
}
else -> {
LazyColumn(modifier = Modifier.heightIn(max = 260.dp)) {
items(channels, key = { it.id }) { channel ->
PublicChannelRow(channel = channel, onClick = { onChannelClick(channel) })
}
}
}
}
}
}
@Composable
private fun PublicChannelRow(
channel: ChatSummaryDto,
onClick: () -> Unit
) {
Column {
Row(
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onClick)
.padding(horizontal = 18.dp, vertical = 12.dp),
horizontalArrangement = Arrangement.spacedBy(14.dp),
verticalAlignment = Alignment.CenterVertically
) {
Surface(
modifier = Modifier.size(48.dp),
color = TelegramBluePanel,
shape = CircleShape
) {
Box(contentAlignment = Alignment.Center) {
Icon(Icons.Outlined.Speaker, contentDescription = null, tint = TelegramBlue, modifier = Modifier.size(24.dp))
}
}
Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(3.dp)) {
Text(channel.title, style = MaterialTheme.typography.titleLarge, maxLines = 1, overflow = TextOverflow.Ellipsis)
Text(
channel.publicUsername?.let { "@$it" } ?: channel.secondaryText.orEmpty(),
style = MaterialTheme.typography.bodySmall,
color = TelegramInkMuted,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
channel.description?.takeIf { it.isNotBlank() }?.let {
Text(
it,
style = MaterialTheme.typography.bodySmall,
color = TelegramInkMuted,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
}
TextButton(onClick = onClick) {
Text("Открыть", color = TelegramBlue)
}
}
IkarRowDivider(startIndent = 80.dp)
}
}
@Composable
private fun GroupDetailsStep(
state: DiscoverUiState,
onTitleChanged: (String) -> Unit,
onCancelClick: () -> Unit,
onNextClick: () -> Unit
) {
IkarSectionCard {
WizardStepIndicator(currentStep = 1, totalSteps = 2)
Row(
horizontalArrangement = Arrangement.spacedBy(14.dp),
verticalAlignment = Alignment.CenterVertically
) {
Surface(
modifier = Modifier.size(64.dp),
color = TelegramBluePanel,
shape = CircleShape
) {
Box(contentAlignment = Alignment.Center) {
Icon(Icons.Outlined.Group, contentDescription = null, tint = TelegramBlue, modifier = Modifier.size(30.dp))
}
}
Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(10.dp)) {
OutlinedTextField(
value = state.groupTitle,
onValueChange = onTitleChanged,
modifier = Modifier.fillMaxWidth(),
singleLine = true,
label = { Text("Название группы") }
)
}
}
StepActions(
backText = "Отмена",
nextText = "Далее",
onBackClick = onCancelClick,
onNextClick = onNextClick,
nextEnabled = state.groupTitle.isNotBlank()
)
}
}
@Composable
private fun ChannelDetailsStep(
state: DiscoverUiState,
@@ -401,13 +603,13 @@ private fun StepActions(
}
@Composable
private fun WizardStepIndicator(currentStep: Int) {
private fun WizardStepIndicator(currentStep: Int, totalSteps: Int = 3) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically
) {
(1..3).forEach { step ->
(1..totalSteps).forEach { step ->
Surface(
modifier = Modifier
.height(4.dp)
@@ -417,7 +619,7 @@ private fun WizardStepIndicator(currentStep: Int) {
) {}
}
Text(
"$currentStep/3",
"$currentStep/$totalSteps",
style = MaterialTheme.typography.labelMedium,
color = TelegramInkMuted,
fontWeight = FontWeight.SemiBold
@@ -4,6 +4,7 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.seven.ikar.kotlin.app.AppContainer
import com.seven.ikar.kotlin.core.contacts.DiscoverUserItem
import com.seven.ikar.kotlin.core.model.ChatSummaryDto
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
@@ -23,6 +24,11 @@ enum class ChannelWizardStep {
Subscribers
}
enum class GroupWizardStep {
Details,
Members
}
enum class ChannelVisibility {
Public,
Private
@@ -31,11 +37,14 @@ enum class ChannelVisibility {
data class DiscoverUiState(
val query: String = "",
val items: List<DiscoverUserItem> = emptyList(),
val publicChannels: List<ChatSummaryDto> = emptyList(),
val isLoading: Boolean = false,
val errorMessage: String? = null,
val pendingChatId: String? = null,
val selectionMode: DiscoverSelectionMode? = null,
val selectedUserIds: Set<String> = emptySet(),
val groupWizardStep: GroupWizardStep? = null,
val groupTitle: String = "",
val channelWizardStep: ChannelWizardStep? = null,
val channelTitle: String = "",
val channelDescription: String = "",
@@ -65,22 +74,44 @@ class DiscoverViewModel(private val container: AppContainer) : ViewModel() {
fun refresh() {
viewModelScope.launch {
val query = _uiState.value.query
val state = _uiState.value
val query = state.query
val shouldLoadPublicChannels =
state.selectionMode == null && state.groupWizardStep == null && state.channelWizardStep == null
_uiState.value = _uiState.value.copy(isLoading = true, errorMessage = null)
runCatching { container.resolvedContactsRepository.loadMergedCandidates(query) }
.onSuccess { items ->
_uiState.value = _uiState.value.copy(items = items, isLoading = false, errorMessage = null)
}
.onFailure { error ->
_uiState.value = _uiState.value.copy(isLoading = false, errorMessage = error.message)
val contactsResult = runCatching { container.resolvedContactsRepository.loadMergedCandidates(query) }
val channelsResult = if (shouldLoadPublicChannels) {
runCatching {
container.executeAuthorized { session ->
container.apiClient.getPublicChannels(session.accessToken, query.trim(), limit = 50)
}
}
} else {
Result.success(emptyList())
}
val items = contactsResult.getOrDefault(emptyList())
val publicChannels = channelsResult.getOrDefault(emptyList())
val errorMessage = when {
shouldLoadPublicChannels && channelsResult.isFailure -> channelsResult.exceptionOrNull()?.message
!shouldLoadPublicChannels && contactsResult.isFailure -> contactsResult.exceptionOrNull()?.message
contactsResult.isFailure && publicChannels.isEmpty() -> contactsResult.exceptionOrNull()?.message
else -> null
}
_uiState.value = _uiState.value.copy(
items = items,
publicChannels = publicChannels,
isLoading = false,
errorMessage = errorMessage
)
}
}
fun beginGroupSelection() {
_uiState.value = _uiState.value.copy(
selectionMode = DiscoverSelectionMode.Group,
selectionMode = null,
selectedUserIds = emptySet(),
groupWizardStep = GroupWizardStep.Details,
groupTitle = "",
channelWizardStep = null,
errorMessage = null
)
@@ -90,6 +121,8 @@ class DiscoverViewModel(private val container: AppContainer) : ViewModel() {
_uiState.value = _uiState.value.copy(
selectionMode = null,
selectedUserIds = emptySet(),
groupWizardStep = null,
groupTitle = "",
channelWizardStep = ChannelWizardStep.Details,
channelTitle = "",
channelDescription = "",
@@ -104,11 +137,26 @@ class DiscoverViewModel(private val container: AppContainer) : ViewModel() {
_uiState.value = _uiState.value.copy(
selectionMode = null,
selectedUserIds = emptySet(),
groupWizardStep = null,
groupTitle = "",
channelWizardStep = null,
errorMessage = null
)
}
fun previousGroupWizardStep() {
val state = _uiState.value
when (state.groupWizardStep) {
GroupWizardStep.Details, null -> cancelSelection()
GroupWizardStep.Members -> _uiState.value = state.copy(
groupWizardStep = GroupWizardStep.Details,
selectionMode = null,
selectedUserIds = emptySet(),
errorMessage = null
)
}
}
fun previousChannelWizardStep() {
val state = _uiState.value
when (state.channelWizardStep) {
@@ -123,6 +171,10 @@ class DiscoverViewModel(private val container: AppContainer) : ViewModel() {
}
}
fun updateGroupTitle(value: String) {
_uiState.value = _uiState.value.copy(groupTitle = value.take(80), errorMessage = null)
}
fun updateChannelTitle(value: String) {
_uiState.value = _uiState.value.copy(channelTitle = value.take(80), errorMessage = null)
}
@@ -173,6 +225,27 @@ class DiscoverViewModel(private val container: AppContainer) : ViewModel() {
}
}
fun continueGroupWizard() {
val state = _uiState.value
when (state.groupWizardStep) {
GroupWizardStep.Details -> {
if (state.groupTitle.isBlank()) {
_uiState.value = state.copy(errorMessage = "Введите название группы.")
return
}
_uiState.value = state.copy(
groupWizardStep = GroupWizardStep.Members,
selectionMode = DiscoverSelectionMode.Group,
selectedUserIds = emptySet(),
errorMessage = null
)
}
GroupWizardStep.Members -> completeSelection()
null -> Unit
}
}
fun handleUserTap(user: DiscoverUserItem) {
val selectionMode = _uiState.value.selectionMode
if (selectionMode == null) {
@@ -203,7 +276,7 @@ class DiscoverViewModel(private val container: AppContainer) : ViewModel() {
when (mode) {
DiscoverSelectionMode.Group -> container.apiClient.createGroupChat(
accessToken = session.accessToken,
title = "Новая группа",
title = state.groupTitle.trim(),
memberIds = selectedIds
)
@@ -224,6 +297,8 @@ class DiscoverViewModel(private val container: AppContainer) : ViewModel() {
isLoading = false,
selectionMode = null,
selectedUserIds = emptySet(),
groupWizardStep = null,
groupTitle = "",
channelWizardStep = null,
errorMessage = null
)
@@ -247,6 +322,10 @@ class DiscoverViewModel(private val container: AppContainer) : ViewModel() {
}
}
fun openPublicChannel(channel: ChatSummaryDto) {
_uiState.value = _uiState.value.copy(pendingChatId = channel.id)
}
fun consumePendingChatNavigation() {
_uiState.value = _uiState.value.copy(pendingChatId = null)
}
@@ -4,9 +4,14 @@ import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.WindowInsetsSides
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.only
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawing
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
@@ -37,7 +42,11 @@ fun ForwardScreen(
onUserClick: (DiscoverUserItem) -> Unit
) {
IkarScreenSurface(modifier = Modifier.fillMaxSize()) {
Column(modifier = Modifier.fillMaxSize()) {
Column(
modifier = Modifier
.fillMaxSize()
.windowInsetsPadding(WindowInsets.safeDrawing.only(WindowInsetsSides.Vertical))
) {
Column(
modifier = Modifier.padding(start = 18.dp, top = 16.dp, end = 18.dp, bottom = 12.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
@@ -0,0 +1,92 @@
package com.seven.ikar.kotlin.feature.join
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.WindowInsetsSides
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.only
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawing
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
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.TelegramBlue
import com.seven.ikar.kotlin.ui.theme.TelegramDanger
import com.seven.ikar.kotlin.ui.theme.TelegramInkMuted
@Composable
fun JoinInviteScreen(
state: JoinInviteUiState,
onBackClick: () -> Unit,
onRetryClick: () -> Unit
) {
IkarScreenSurface(modifier = Modifier.fillMaxSize()) {
Column(
modifier = Modifier
.fillMaxSize()
.windowInsetsPadding(WindowInsets.safeDrawing.only(WindowInsetsSides.Vertical))
.padding(horizontal = 18.dp, vertical = 16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
IkarSimpleHeader(title = "Приглашение", onBackClick = onBackClick)
Column(
modifier = Modifier
.weight(1f)
.padding(horizontal = 8.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
when {
state.isLoading -> {
CircularProgressIndicator(color = TelegramBlue)
Text(
text = "Открываем приглашение...",
modifier = Modifier.padding(top = 16.dp),
style = MaterialTheme.typography.bodyMedium,
color = TelegramInkMuted
)
}
state.errorMessage != null -> {
Text(
text = state.errorMessage,
style = MaterialTheme.typography.bodyLarge,
color = TelegramDanger
)
Button(
onClick = onRetryClick,
modifier = Modifier.padding(top = 16.dp)
) {
Text("Повторить")
}
}
state.message != null -> {
Text(
text = state.message,
style = MaterialTheme.typography.bodyLarge,
color = TelegramInkMuted
)
OutlinedButton(
onClick = onBackClick,
modifier = Modifier.padding(top = 16.dp)
) {
Text("К чатам")
}
}
}
}
}
}
}
@@ -0,0 +1,72 @@
package com.seven.ikar.kotlin.feature.join
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.seven.ikar.kotlin.app.AppContainer
import com.seven.ikar.kotlin.core.model.JoinChatResultStatus
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
data class JoinInviteUiState(
val isLoading: Boolean = false,
val message: String? = null,
val errorMessage: String? = null,
val pendingChatId: String? = null,
val isPendingApproval: Boolean = false
)
class JoinInviteViewModel(
private val container: AppContainer,
private val inviteToken: String
) : ViewModel() {
private val _uiState = MutableStateFlow(JoinInviteUiState(isLoading = true))
val uiState: StateFlow<JoinInviteUiState> = _uiState.asStateFlow()
init {
join()
}
fun join() {
if (inviteToken.isBlank()) {
_uiState.value = JoinInviteUiState(errorMessage = "Ссылка недействительна.")
return
}
viewModelScope.launch {
_uiState.value = _uiState.value.copy(isLoading = true, errorMessage = null, message = null)
runCatching {
container.executeAuthorized { session ->
container.apiClient.joinChatByInviteLink(session.accessToken, inviteToken)
}
}.onSuccess { result ->
when (result.status) {
JoinChatResultStatus.Joined -> {
val chatId = result.chat?.id
_uiState.value = if (chatId.isNullOrBlank()) {
JoinInviteUiState(errorMessage = "Чат недоступен после вступления.")
} else {
JoinInviteUiState(pendingChatId = chatId)
}
}
JoinChatResultStatus.PendingApproval -> {
_uiState.value = JoinInviteUiState(
message = "Заявка на вступление отправлена. Дождитесь одобрения администратора.",
isPendingApproval = true
)
}
}
}.onFailure { error ->
_uiState.value = JoinInviteUiState(
errorMessage = error.message ?: "Не удалось открыть пригласительную ссылку."
)
}
}
}
fun consumePendingChatNavigation() {
_uiState.value = _uiState.value.copy(pendingChatId = null)
}
}
@@ -1,7 +1,7 @@
package com.seven.ikar.kotlin.feature.login
import androidx.compose.foundation.background
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@@ -12,6 +12,7 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
@@ -28,17 +29,17 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import androidx.compose.foundation.text.KeyboardOptions
@Composable
fun LoginScreen(
state: LoginUiState,
onEmailChanged: (String) -> Unit,
onPhoneNumberChanged: (String) -> Unit,
onDisplayNameChanged: (String) -> Unit,
onVerificationCodeChanged: (String) -> Unit,
onRequestCodeClick: () -> Unit,
onVerifyCodeClick: () -> Unit,
onChangePhoneNumberClick: () -> Unit
onChangeEmailClick: () -> Unit
) {
Box(
modifier = Modifier
@@ -116,7 +117,16 @@ fun LoginScreen(
fontWeight = FontWeight.SemiBold
)
if (state.showPhoneStep) {
if (state.showEmailStep) {
OutlinedTextField(
value = state.email,
onValueChange = onEmailChanged,
modifier = Modifier.fillMaxWidth(),
label = { Text("Электронная почта") },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Email),
singleLine = true
)
OutlinedTextField(
value = state.phoneNumber,
onValueChange = onPhoneNumberChanged,
@@ -128,7 +138,7 @@ fun LoginScreen(
Button(
onClick = onRequestCodeClick,
enabled = !state.isLoading,
enabled = !state.isLoading && state.email.isNotBlank() && state.phoneNumber.isNotBlank(),
contentPadding = PaddingValues(horizontal = 20.dp, vertical = 14.dp)
) {
if (state.isLoading) {
@@ -137,7 +147,7 @@ fun LoginScreen(
modifier = Modifier.padding(horizontal = 8.dp)
)
} else {
Text("Получить тестовый код")
Text("Получить код")
}
}
}
@@ -154,25 +164,16 @@ fun LoginScreen(
verticalArrangement = Arrangement.spacedBy(10.dp)
) {
Text(
text = "Подтверждение номера",
text = "Подтверждение email",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold
)
Text(
text = "Для тестового режима код показывается прямо в приложении. После подключения SMS-провайдера это сообщение исчезнет.",
text = "Код отправлен на указанную электронную почту. Номер телефона будет привязан к аккаунту.",
style = MaterialTheme.typography.bodyMedium,
color = Color(0xFF617A8F)
)
if (state.hasTestCodeHint) {
Text(
text = "Тестовый код: ${state.testCodeHint}",
style = MaterialTheme.typography.bodyMedium,
color = Color(0xFF229AF0),
fontWeight = FontWeight.SemiBold
)
}
OutlinedTextField(
value = state.verificationCode,
onValueChange = onVerificationCodeChanged,
@@ -194,7 +195,9 @@ fun LoginScreen(
Button(
onClick = onVerifyCodeClick,
enabled = !state.isLoading,
enabled = !state.isLoading &&
state.verificationCode.isNotBlank() &&
(!state.isNewAccount || state.displayName.isNotBlank()),
contentPadding = PaddingValues(horizontal = 20.dp, vertical = 14.dp)
) {
if (state.isLoading) {
@@ -208,7 +211,7 @@ fun LoginScreen(
}
Button(
onClick = onChangePhoneNumberClick,
onClick = onChangeEmailClick,
enabled = !state.isLoading,
colors = ButtonDefaults.buttonColors(
containerColor = Color.Transparent,
@@ -216,7 +219,7 @@ fun LoginScreen(
),
contentPadding = PaddingValues(horizontal = 0.dp, vertical = 12.dp)
) {
Text("Изменить номер")
Text("Изменить email или телефон")
}
}
}
@@ -10,11 +10,11 @@ import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
data class LoginUiState(
val email: String = "",
val phoneNumber: String = "",
val displayName: String = "",
val verificationCode: String = "",
val challengeId: String = "",
val testCodeHint: String = "",
val isAwaitingCode: Boolean = false,
val isNewAccount: Boolean = false,
val isLoading: Boolean = false,
@@ -22,16 +22,16 @@ data class LoginUiState(
val isAuthenticated: Boolean = false
) {
val pageTitle: String
get() = if (isAwaitingCode) "Подтвердите номер" else "Вход в ИКАР"
get() = if (isAwaitingCode) "Подтвердите email" else "Вход в ИКАР"
val subtitle: String
get() = if (isAwaitingCode) {
"Введите тестовый код. Когда подключим SMS-провайдера, здесь будет код из сообщения."
"Введите код, который отправлен на указанную электронную почту."
} else {
"Введите номер телефона, чтобы получить тестовый код входа."
"Введите электронную почту и номер телефона. Код придёт только на email."
}
val showPhoneStep: Boolean
val showEmailStep: Boolean
get() = !isAwaitingCode
val showCodeStep: Boolean
@@ -39,15 +39,16 @@ data class LoginUiState(
val showDisplayName: Boolean
get() = isAwaitingCode && isNewAccount
val hasTestCodeHint: Boolean
get() = testCodeHint.isNotBlank()
}
class LoginViewModel(private val container: AppContainer) : ViewModel() {
private val _uiState = MutableStateFlow(LoginUiState())
val uiState: StateFlow<LoginUiState> = _uiState.asStateFlow()
fun updateEmail(value: String) {
_uiState.value = _uiState.value.copy(email = value, errorMessage = null)
}
fun updatePhoneNumber(value: String) {
_uiState.value = _uiState.value.copy(phoneNumber = value, errorMessage = null)
}
@@ -62,19 +63,26 @@ class LoginViewModel(private val container: AppContainer) : ViewModel() {
fun requestCode() {
val state = _uiState.value
if (state.phoneNumber.isBlank() || state.isLoading) {
if (state.isLoading) {
return
}
if (state.email.isBlank() || state.phoneNumber.isBlank()) {
_uiState.value = state.copy(errorMessage = "Введите email и номер телефона.")
return
}
viewModelScope.launch(Dispatchers.IO) {
_uiState.value = state.copy(isLoading = true, errorMessage = null)
runCatching {
container.apiClient.requestPhoneCode(state.phoneNumber.trim())
container.apiClient.requestEmailCode(
email = state.email.trim(),
phoneNumber = state.phoneNumber.trim()
)
}.onSuccess { challenge ->
_uiState.value = _uiState.value.copy(
isLoading = false,
challengeId = challenge.challengeId,
testCodeHint = challenge.testCode,
isAwaitingCode = true,
isNewAccount = !challenge.isRegistered,
verificationCode = "",
@@ -83,7 +91,7 @@ class LoginViewModel(private val container: AppContainer) : ViewModel() {
}.onFailure { error ->
_uiState.value = _uiState.value.copy(
isLoading = false,
errorMessage = error.message ?: "Не удалось запросить код."
errorMessage = error.message ?: "Не удалось отправить код на email."
)
}
}
@@ -93,6 +101,7 @@ class LoginViewModel(private val container: AppContainer) : ViewModel() {
val state = _uiState.value
if (state.isLoading ||
state.challengeId.isBlank() ||
state.email.isBlank() ||
state.phoneNumber.isBlank() ||
state.verificationCode.isBlank() ||
(state.isNewAccount && state.displayName.isBlank())
@@ -103,8 +112,9 @@ class LoginViewModel(private val container: AppContainer) : ViewModel() {
viewModelScope.launch(Dispatchers.IO) {
_uiState.value = state.copy(isLoading = true, errorMessage = null)
runCatching {
container.apiClient.verifyPhoneCode(
container.apiClient.verifyEmailCode(
challengeId = state.challengeId,
email = state.email.trim(),
phoneNumber = state.phoneNumber.trim(),
code = state.verificationCode.trim(),
displayName = state.displayName.trim().ifBlank { null }
@@ -116,16 +126,15 @@ class LoginViewModel(private val container: AppContainer) : ViewModel() {
}.onFailure { error ->
_uiState.value = _uiState.value.copy(
isLoading = false,
errorMessage = error.message ?: "Не удалось подтвердить код."
errorMessage = error.message ?: "Не удалось подтвердить email-код."
)
}
}
}
fun changePhoneNumber() {
fun changeEmail() {
_uiState.value = _uiState.value.copy(
challengeId = "",
testCodeHint = "",
verificationCode = "",
isAwaitingCode = false,
isNewAccount = false,
@@ -4,6 +4,7 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.seven.ikar.kotlin.app.AppContainer
import com.seven.ikar.kotlin.core.model.ChatSummaryDto
import com.seven.ikar.kotlin.core.push.NotificationSettingsSnapshot
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
@@ -22,8 +23,10 @@ class MainMenuViewModel(private val container: AppContainer) : ViewModel() {
private var debounceJob: Job? = null
private var refreshJob: Job? = null
private var latestChats: List<ChatSummaryDto> = emptyList()
init {
attachNotificationSettings()
attachRealtime()
loadCachedUnreadChatsCount()
refresh()
@@ -47,7 +50,8 @@ class MainMenuViewModel(private val container: AppContainer) : ViewModel() {
if (container.sessionStore.read()?.user?.id != ownerUserId) {
return@onSuccess
}
_uiState.value = MainMenuUiState(unreadChatsCount = countUnreadChats(chats))
latestChats = chats
updateUnreadChatsCount(chats)
cacheChatList(ownerUserId, chats)
}.onFailure {
loadCachedUnreadChatsCount()
@@ -58,6 +62,7 @@ class MainMenuViewModel(private val container: AppContainer) : ViewModel() {
fun clear() {
debounceJob?.cancel()
refreshJob?.cancel()
latestChats = emptyList()
_uiState.value = MainMenuUiState()
}
@@ -69,7 +74,8 @@ class MainMenuViewModel(private val container: AppContainer) : ViewModel() {
container.chatListCache.loadChats(ownerUserId)
}.getOrDefault(emptyList())
if (container.sessionStore.read()?.user?.id == ownerUserId) {
_uiState.value = MainMenuUiState(unreadChatsCount = countUnreadChats(chats))
latestChats = chats
updateUnreadChatsCount(chats)
}
}
}
@@ -95,6 +101,14 @@ class MainMenuViewModel(private val container: AppContainer) : ViewModel() {
}
}
private fun attachNotificationSettings() {
viewModelScope.launch {
container.pushSettings.snapshot.collect {
updateUnreadChatsCount(latestChats)
}
}
}
private fun scheduleRefresh() {
debounceJob?.cancel()
debounceJob = viewModelScope.launch {
@@ -103,6 +117,31 @@ class MainMenuViewModel(private val container: AppContainer) : ViewModel() {
}
}
private fun updateUnreadChatsCount(chats: List<ChatSummaryDto>) {
_uiState.value = MainMenuUiState(unreadChatsCount = countUnreadChats(chats))
}
private fun countUnreadChats(chats: List<ChatSummaryDto>): Int =
chats.count { chat -> chat.unreadCount > 0 }
countUnreadChats(chats, container.pushSettings.snapshot.value)
private fun countUnreadChats(
chats: List<ChatSummaryDto>,
settings: NotificationSettingsSnapshot
): Int {
if (!settings.showBadge) {
return 0
}
val eligibleChats = if (settings.includeMutedChatsInBadge) {
chats
} else {
chats.filterNot { it.isMuted }
}
return if (settings.countMessagesInBadge) {
eligibleChats.sumOf { it.unreadCount.coerceAtLeast(0) }
} else {
eligibleChats.count { it.unreadCount > 0 }
}
}
}
@@ -2,6 +2,8 @@ package com.seven.ikar.kotlin.feature.members
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.WindowInsetsSides
@@ -52,6 +54,8 @@ fun ChatMembersScreen(
onToggleCanPin: (ChatMemberDto) -> Unit,
onToggleCanDelete: (ChatMemberDto) -> Unit,
onToggleCanInvite: (ChatMemberDto) -> Unit,
onTransferOwnership: (ChatMemberDto) -> Unit,
onRemoveMember: (ChatMemberDto) -> Unit,
onMemberSearchChanged: (String) -> Unit,
onAddMember: (UserSummaryDto) -> Unit,
onAddAdmin: (UserSummaryDto) -> Unit,
@@ -153,12 +157,19 @@ fun ChatMembersScreen(
MemberPermissionRow(
member = member,
canManage = state.canManageMembers && !member.isOwner,
canTransferOwnership = state.canTransferOwnership &&
!member.isOwner &&
member.role != ChatMemberRole.Owner &&
!member.user.isBot &&
member.user.id != state.currentUserId,
isUpdating = state.updatingUserIds.contains(member.user.id),
onToggleAdmin = { onToggleAdmin(member) },
onToggleCanPost = { onToggleCanPost(member) },
onToggleCanPin = { onToggleCanPin(member) },
onToggleCanDelete = { onToggleCanDelete(member) },
onToggleCanInvite = { onToggleCanInvite(member) }
onToggleCanInvite = { onToggleCanInvite(member) },
onTransferOwnership = { onTransferOwnership(member) },
onRemoveMember = { onRemoveMember(member) }
)
if (index != members.lastIndex) {
IkarRowDivider(startIndent = 58.dp)
@@ -298,15 +309,19 @@ private fun JoinRequestRow(
}
@Composable
@OptIn(ExperimentalLayoutApi::class)
private fun MemberPermissionRow(
member: ChatMemberDto,
canManage: Boolean,
canTransferOwnership: Boolean,
isUpdating: Boolean,
onToggleAdmin: () -> Unit,
onToggleCanPost: () -> Unit,
onToggleCanPin: () -> Unit,
onToggleCanDelete: () -> Unit,
onToggleCanInvite: () -> Unit
onToggleCanInvite: () -> Unit,
onTransferOwnership: () -> Unit,
onRemoveMember: () -> Unit
) {
Column(
modifier = Modifier
@@ -345,7 +360,18 @@ private fun MemberPermissionRow(
}
if (canManage) {
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
FlowRow(
horizontalArrangement = Arrangement.spacedBy(6.dp),
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
if (canTransferOwnership) {
TextButton(enabled = !isUpdating, onClick = onTransferOwnership) {
Text("Передать владельца", color = TelegramBlue)
}
}
TextButton(enabled = !isUpdating, onClick = onRemoveMember) {
Text("Удалить", color = TelegramDanger)
}
PermissionChip("публикации", member.permissions.canPostMessages, isUpdating, onToggleCanPost)
PermissionChip("закрепление", member.permissions.canPinMessages, isUpdating, onToggleCanPin)
PermissionChip("удаление", member.permissions.canDeleteMessages, isUpdating, onToggleCanDelete)
@@ -436,6 +462,7 @@ private fun moderationActionLabel(action: String): String = when (action) {
"member.added" -> "Участник добавлен"
"member.admin.added" -> "Администратор добавлен"
"member.permissions.updated" -> "Права участника обновлены"
"member.owner.transferred" -> "Владелец передан"
"message.deleted" -> "Сообщение удалено"
"message.pinned" -> "Сообщение закреплено"
"message.unpinned" -> "Сообщение откреплено"
@@ -21,6 +21,7 @@ import kotlinx.coroutines.launch
data class ChatMembersUiState(
val chat: ChatDetailsDto? = null,
val currentUserId: String? = null,
val joinRequests: List<ChatJoinRequestDto> = emptyList(),
val moderationLog: List<ModerationLogDto> = emptyList(),
val isLoading: Boolean = false,
@@ -36,8 +37,26 @@ data class ChatMembersUiState(
val canManageMembers: Boolean
get() = chat?.currentUserPermissions?.canManageMembers == true
val isCurrentUserOwner: Boolean
get() = currentUserId != null &&
chat?.members.orEmpty().any { member ->
member.user.id == currentUserId &&
(member.isOwner || member.role == ChatMemberRole.Owner)
}
val canTransferOwnership: Boolean
get() = isCurrentUserOwner && when (chat?.type) {
ChatType.Group,
ChatType.Channel -> true
else -> false
}
val canAddMembers: Boolean
get() = canManageMembers && chat?.type == ChatType.Channel
get() = canManageMembers && when (chat?.type) {
ChatType.Group,
ChatType.Channel -> true
else -> false
}
}
class ChatMembersViewModel(
@@ -61,6 +80,7 @@ class ChatMembersViewModel(
val canManageMembers = chat.currentUserPermissions?.canManageMembers == true
ChatMembersPayload(
chat = chat,
currentUserId = session.user.id,
joinRequests = if (canManageMembers) {
container.apiClient.getChatJoinRequests(session.accessToken, chatId)
} else {
@@ -76,6 +96,7 @@ class ChatMembersViewModel(
}.onSuccess { payload ->
_uiState.value = _uiState.value.copy(
chat = payload.chat,
currentUserId = payload.currentUserId,
joinRequests = payload.joinRequests,
moderationLog = payload.moderationLog,
memberSearchResults = filterMemberSearchResults(_uiState.value.memberSearchResults, payload.chat),
@@ -182,6 +203,79 @@ class ChatMembersViewModel(
updateMember(member, canInviteUsers = !member.permissions.canInviteUsers)
}
fun removeMember(member: ChatMemberDto) {
if (member.isOwner || _uiState.value.updatingUserIds.contains(member.user.id)) {
return
}
viewModelScope.launch {
_uiState.value = _uiState.value.copy(
updatingUserIds = _uiState.value.updatingUserIds + member.user.id,
errorMessage = null
)
runCatching {
container.executeAuthorized { session ->
container.apiClient.removeChatMember(
accessToken = session.accessToken,
chatId = chatId,
memberUserId = member.user.id
)
}
}.onSuccess { chat ->
_uiState.value = _uiState.value.copy(
chat = chat,
updatingUserIds = _uiState.value.updatingUserIds - member.user.id
)
refresh()
}.onFailure { error ->
_uiState.value = _uiState.value.copy(
updatingUserIds = _uiState.value.updatingUserIds - member.user.id,
errorMessage = error.message
)
}
}
}
fun transferOwnership(member: ChatMemberDto) {
val state = _uiState.value
if (!state.canTransferOwnership ||
member.isOwner ||
member.role == ChatMemberRole.Owner ||
member.user.isBot ||
state.currentUserId == member.user.id ||
state.updatingUserIds.contains(member.user.id)
) {
return
}
viewModelScope.launch {
_uiState.value = _uiState.value.copy(
updatingUserIds = _uiState.value.updatingUserIds + member.user.id,
errorMessage = null
)
runCatching {
container.executeAuthorized { session ->
container.apiClient.transferChatOwnership(
accessToken = session.accessToken,
chatId = chatId,
memberUserId = member.user.id
)
}
}.onSuccess { chat ->
_uiState.value = _uiState.value.copy(
chat = chat,
updatingUserIds = _uiState.value.updatingUserIds - member.user.id
)
refresh()
}.onFailure { error ->
_uiState.value = _uiState.value.copy(
updatingUserIds = _uiState.value.updatingUserIds - member.user.id,
errorMessage = error.message
)
}
}
}
private fun updateMember(
member: ChatMemberDto,
role: ChatMemberRole = member.role,
@@ -289,6 +383,7 @@ class ChatMembersViewModel(
private data class ChatMembersPayload(
val chat: ChatDetailsDto,
val currentUserId: String,
val joinRequests: List<ChatJoinRequestDto>,
val moderationLog: List<ModerationLogDto>
)
@@ -2,23 +2,47 @@ package com.seven.ikar.kotlin.feature.privacy
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.WindowInsetsSides
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.only
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawing
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Block
import androidx.compose.material.icons.outlined.Image
import androidx.compose.material.icons.outlined.Lock
import androidx.compose.material.icons.outlined.Phone
import androidx.compose.material.icons.outlined.Schedule
import androidx.compose.material.icons.outlined.Security
import androidx.compose.material.icons.outlined.Visibility
import androidx.compose.material3.Icon
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.ui.unit.dp
import com.seven.ikar.kotlin.core.model.PrivacySettingsDto
import com.seven.ikar.kotlin.core.model.UserSummaryDto
import com.seven.ikar.kotlin.ui.AvatarView
import com.seven.ikar.kotlin.ui.IkarRowDivider
import com.seven.ikar.kotlin.ui.IkarScreenSurface
import com.seven.ikar.kotlin.ui.IkarSectionCard
@@ -33,11 +57,19 @@ import com.seven.ikar.kotlin.ui.theme.TelegramInkMuted
fun PrivacySecurityScreen(
state: PrivacySecurityUiState,
onBackClick: () -> Unit,
onRefreshClick: () -> Unit,
onBlockedUsersClick: () -> Unit,
onExceptionsClick: () -> Unit,
onUpdate: ((PrivacySettingsDto) -> PrivacySettingsDto) -> Unit
onUpdate: ((PrivacySettingsDto) -> PrivacySettingsDto) -> Unit,
onUserSearchQueryChanged: (String) -> Unit,
onBlockUser: (UserSummaryDto) -> Unit,
onUnblockUser: (String) -> Unit,
onAlwaysAllowUser: (UserSummaryDto) -> Unit,
onNeverAllowUser: (UserSummaryDto) -> Unit,
onRemoveAlwaysAllow: (String) -> Unit,
onRemoveNeverAllow: (String) -> Unit,
onEnablePasscode: (String, String) -> Unit,
onDisablePasscode: (String) -> Unit
) {
var passcodeDialogMode by remember { mutableStateOf<PasscodeDialogMode?>(null) }
var listDialogMode by remember { mutableStateOf<PrivacyListDialogMode?>(null) }
IkarScreenSurface(modifier = Modifier.fillMaxSize()) {
Column(
modifier = Modifier
@@ -58,41 +90,351 @@ fun PrivacySecurityScreen(
if (settings != null) {
IkarSectionCard {
IkarSectionTitle("Конфиденциальность", modifier = Modifier.padding(bottom = 8.dp))
PrivacyRow("Номер телефона", privacyValueLabel(settings.phoneNumberVisibility)) {
PrivacyRow("Номер телефона", privacyValueLabel(settings.phoneNumberVisibility), Icons.Outlined.Phone) {
onUpdate { it.copy(phoneNumberVisibility = nextVisibility(it.phoneNumberVisibility)) }
}
IkarRowDivider()
PrivacyRow("Последняя активность и онлайн", privacyValueLabel(settings.lastSeenVisibility)) {
PrivacyRow("Последняя активность и онлайн", privacyValueLabel(settings.lastSeenVisibility), Icons.Outlined.Schedule) {
onUpdate { it.copy(lastSeenVisibility = nextVisibility(it.lastSeenVisibility)) }
}
IkarRowDivider()
PrivacyRow("Фотографии профиля", privacyValueLabel(settings.profilePhotoVisibility)) {
PrivacyRow("Фотографии профиля", privacyValueLabel(settings.profilePhotoVisibility), Icons.Outlined.Image) {
onUpdate { it.copy(profilePhotoVisibility = nextVisibility(it.profilePhotoVisibility)) }
}
IkarRowDivider()
PrivacyRow("Заблокированные пользователи", "${settings.blockedUserIds.size}") {
onBlockedUsersClick()
PrivacyRow("Заблокированные пользователи", "${settings.blockedUserIds.size}", Icons.Outlined.Block) {
listDialogMode = PrivacyListDialogMode.Blocked
}
IkarRowDivider()
PrivacyRow("Исключения", "${settings.alwaysAllowUserIds.size + settings.neverAllowUserIds.size}") {
onExceptionsClick()
PrivacyRow("Исключения", "${settings.alwaysAllowUserIds.size + settings.neverAllowUserIds.size}", Icons.Outlined.Security) {
listDialogMode = PrivacyListDialogMode.Exceptions
}
}
IkarSectionCard {
IkarSectionTitle("Безопасность", modifier = Modifier.padding(bottom = 8.dp))
PrivacyRow("Код-пароль", if (settings.passcodeLockEnabled) "включено" else "выключено") {
onUpdate { it.copy(passcodeLockEnabled = !it.passcodeLockEnabled) }
PrivacyRow("Код-пароль", if (settings.passcodeLockEnabled) "включено" else "выключено", Icons.Outlined.Lock) {
passcodeDialogMode = if (settings.passcodeLockEnabled) {
PasscodeDialogMode.Disable
} else {
PasscodeDialogMode.Enable
}
}
IkarRowDivider()
PrivacyRow("Предпросмотр сообщений", if (settings.showMessagePreview) "включено" else "выключено") {
PrivacyRow("Предпросмотр сообщений", if (settings.showMessagePreview) "включено" else "выключено", Icons.Outlined.Visibility) {
onUpdate { it.copy(showMessagePreview = !it.showMessagePreview) }
}
}
}
TextButton(enabled = !state.isSaving, onClick = onRefreshClick) {
Text(if (state.isSaving) "Сохранение..." else "Обновить")
}
}
passcodeDialogMode?.let { mode ->
PasscodeDialog(
mode = mode,
isSaving = state.isSaving,
onDismiss = { passcodeDialogMode = null },
onEnable = { passcode, confirmation ->
onEnablePasscode(passcode, confirmation)
passcodeDialogMode = null
},
onDisable = { passcode ->
onDisablePasscode(passcode)
passcodeDialogMode = null
}
)
}
listDialogMode?.let { mode ->
PrivacyUsersDialog(
mode = mode,
state = state,
onDismiss = { listDialogMode = null },
onUserSearchQueryChanged = onUserSearchQueryChanged,
onBlockUser = onBlockUser,
onUnblockUser = onUnblockUser,
onAlwaysAllowUser = onAlwaysAllowUser,
onNeverAllowUser = onNeverAllowUser,
onRemoveAlwaysAllow = onRemoveAlwaysAllow,
onRemoveNeverAllow = onRemoveNeverAllow
)
}
}
private enum class PasscodeDialogMode {
Enable,
Disable
}
private enum class PrivacyListDialogMode {
Blocked,
Exceptions
}
@Composable
private fun PasscodeDialog(
mode: PasscodeDialogMode,
isSaving: Boolean,
onDismiss: () -> Unit,
onEnable: (String, String) -> Unit,
onDisable: (String) -> Unit
) {
var passcode by remember(mode) { mutableStateOf("") }
var confirmation by remember(mode) { mutableStateOf("") }
val isEnable = mode == PasscodeDialogMode.Enable
val canConfirm = if (isEnable) {
passcode.length >= 4 && confirmation.length >= 4
} else {
passcode.length >= 4
}
AlertDialog(
onDismissRequest = {
if (!isSaving) {
onDismiss()
}
},
title = { Text(if (isEnable) "Включить код-пароль" else "Отключить код-пароль") },
text = {
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
OutlinedTextField(
value = passcode,
onValueChange = { passcode = it.filter(Char::isDigit).take(12) },
singleLine = true,
visualTransformation = PasswordVisualTransformation(),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.NumberPassword),
label = { Text(if (isEnable) "Новый код" else "Текущий код") }
)
if (isEnable) {
OutlinedTextField(
value = confirmation,
onValueChange = { confirmation = it.filter(Char::isDigit).take(12) },
singleLine = true,
visualTransformation = PasswordVisualTransformation(),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.NumberPassword),
label = { Text("Повторите код") }
)
}
Text(
text = "Используйте 4-12 цифр.",
style = MaterialTheme.typography.bodySmall,
color = TelegramInkMuted
)
}
},
confirmButton = {
TextButton(
enabled = !isSaving && canConfirm,
onClick = {
if (isEnable) {
onEnable(passcode, confirmation)
} else {
onDisable(passcode)
}
}
) {
Text(if (isEnable) "Включить" else "Отключить")
}
},
dismissButton = {
TextButton(enabled = !isSaving, onClick = onDismiss) {
Text("Отмена")
}
}
)
}
@Composable
private fun PrivacyUsersDialog(
mode: PrivacyListDialogMode,
state: PrivacySecurityUiState,
onDismiss: () -> Unit,
onUserSearchQueryChanged: (String) -> Unit,
onBlockUser: (UserSummaryDto) -> Unit,
onUnblockUser: (String) -> Unit,
onAlwaysAllowUser: (UserSummaryDto) -> Unit,
onNeverAllowUser: (UserSummaryDto) -> Unit,
onRemoveAlwaysAllow: (String) -> Unit,
onRemoveNeverAllow: (String) -> Unit
) {
val settings = state.settings ?: return
AlertDialog(
onDismissRequest = onDismiss,
title = {
Text(
when (mode) {
PrivacyListDialogMode.Blocked -> "Заблокированные пользователи"
PrivacyListDialogMode.Exceptions -> "Исключения"
}
)
},
text = {
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
OutlinedTextField(
value = state.userSearchQuery,
onValueChange = onUserSearchQueryChanged,
singleLine = true,
label = { Text("Поиск пользователя") }
)
if (state.isSearchingUsers) {
CircularProgressIndicator(color = TelegramBlue)
}
state.userSearchResults.take(8).forEach { user ->
when (mode) {
PrivacyListDialogMode.Blocked -> PrivacyUserResultRow(
user = user,
primaryActionLabel = if (settings.blockedUserIds.contains(user.id)) "Уже заблокирован" else "Заблокировать",
primaryEnabled = !settings.blockedUserIds.contains(user.id) && !state.isSaving,
onPrimaryClick = { onBlockUser(user) }
)
PrivacyListDialogMode.Exceptions -> PrivacyUserResultRow(
user = user,
primaryActionLabel = "Всегда",
primaryEnabled = !settings.alwaysAllowUserIds.contains(user.id) && !state.isSaving,
onPrimaryClick = { onAlwaysAllowUser(user) },
secondaryActionLabel = "Никогда",
secondaryEnabled = !settings.neverAllowUserIds.contains(user.id) && !state.isSaving,
onSecondaryClick = { onNeverAllowUser(user) }
)
}
}
when (mode) {
PrivacyListDialogMode.Blocked -> PrivacyIdList(
title = "Сейчас заблокированы",
userIds = settings.blockedUserIds,
users = settings.blockedUsers,
removeLabel = "Разблокировать",
isSaving = state.isSaving,
onRemove = onUnblockUser
)
PrivacyListDialogMode.Exceptions -> {
PrivacyIdList(
title = "Всегда разрешать",
userIds = settings.alwaysAllowUserIds,
users = settings.alwaysAllowUsers,
removeLabel = "Убрать",
isSaving = state.isSaving,
onRemove = onRemoveAlwaysAllow
)
PrivacyIdList(
title = "Никогда не разрешать",
userIds = settings.neverAllowUserIds,
users = settings.neverAllowUsers,
removeLabel = "Убрать",
isSaving = state.isSaving,
onRemove = onRemoveNeverAllow
)
}
}
}
},
confirmButton = {
TextButton(onClick = onDismiss) {
Text("Закрыть")
}
}
)
}
@Composable
private fun PrivacyUserResultRow(
user: UserSummaryDto,
primaryActionLabel: String,
primaryEnabled: Boolean,
onPrimaryClick: () -> Unit,
secondaryActionLabel: String? = null,
secondaryEnabled: Boolean = false,
onSecondaryClick: (() -> Unit)? = null
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
Column(modifier = Modifier.weight(1f)) {
Text(user.displayName, style = MaterialTheme.typography.bodyMedium)
Text("@${user.username}", style = MaterialTheme.typography.bodySmall, color = TelegramInkMuted)
}
TextButton(enabled = primaryEnabled, onClick = onPrimaryClick) {
Text(primaryActionLabel)
}
if (secondaryActionLabel != null && onSecondaryClick != null) {
TextButton(enabled = secondaryEnabled, onClick = onSecondaryClick) {
Text(secondaryActionLabel)
}
}
}
}
@Composable
private fun PrivacyIdList(
title: String,
userIds: List<String>,
users: List<UserSummaryDto>,
removeLabel: String,
isSaving: Boolean,
onRemove: (String) -> Unit
) {
val usersById = remember(users) { users.associateBy { it.id } }
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
Text(title, style = MaterialTheme.typography.labelLarge, color = TelegramBlue)
if (userIds.isEmpty()) {
Text("Список пуст.", style = MaterialTheme.typography.bodySmall, color = TelegramInkMuted)
} else {
userIds.take(20).forEach { userId ->
PrivacySavedUserRow(
userId = userId,
user = usersById[userId],
removeLabel = removeLabel,
isSaving = isSaving,
onRemove = onRemove
)
}
}
}
}
@Composable
private fun PrivacySavedUserRow(
userId: String,
user: UserSummaryDto?,
removeLabel: String,
isSaving: Boolean,
onRemove: (String) -> Unit
) {
val title = user?.displayName?.takeIf { it.isNotBlank() }
?: user?.username?.takeIf { it.isNotBlank() }?.let { "@$it" }
?: userId.take(8)
val subtitle = user?.username?.takeIf { it.isNotBlank() }?.let { "@$it" }
?: user?.phoneNumber?.takeIf { !it.isNullOrBlank() }
?: userId
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
AvatarView(
imageUrl = user?.avatarPath,
title = title,
size = 36.dp
)
Column(modifier = Modifier.weight(1f)) {
Text(
title,
style = MaterialTheme.typography.bodyMedium,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Text(
subtitle,
style = MaterialTheme.typography.bodySmall,
color = TelegramInkMuted,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
TextButton(enabled = !isSaving, onClick = { onRemove(userId) }) {
Text(removeLabel)
}
}
}
@@ -101,14 +443,15 @@ fun PrivacySecurityScreen(
private fun PrivacyRow(
title: String,
value: String,
onClick: () -> Unit
icon: ImageVector,
onClick: (() -> Unit)? = null
) {
IkarSettingRow(
title = title,
subtitle = "Сейчас: $value",
onClick = onClick,
leading = {
Text(value.take(1).uppercase(), color = TelegramInkMuted)
Icon(icon, contentDescription = null, tint = TelegramInkMuted)
}
)
}
@@ -5,6 +5,9 @@ import androidx.lifecycle.viewModelScope
import com.seven.ikar.kotlin.app.AppContainer
import com.seven.ikar.kotlin.core.model.PrivacySettingsDto
import com.seven.ikar.kotlin.core.model.UpdatePrivacySettingsRequest
import com.seven.ikar.kotlin.core.model.UserSummaryDto
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
@@ -12,6 +15,9 @@ import kotlinx.coroutines.launch
data class PrivacySecurityUiState(
val settings: PrivacySettingsDto? = null,
val userSearchQuery: String = "",
val userSearchResults: List<UserSummaryDto> = emptyList(),
val isSearchingUsers: Boolean = false,
val isLoading: Boolean = false,
val isSaving: Boolean = false,
val errorMessage: String? = null
@@ -19,6 +25,7 @@ data class PrivacySecurityUiState(
class PrivacySecurityViewModel(private val container: AppContainer) : ViewModel() {
private val _uiState = MutableStateFlow(PrivacySecurityUiState())
private var userSearchJob: Job? = null
val uiState: StateFlow<PrivacySecurityUiState> = _uiState.asStateFlow()
init {
@@ -44,6 +51,99 @@ class PrivacySecurityViewModel(private val container: AppContainer) : ViewModel(
fun update(transform: (PrivacySettingsDto) -> PrivacySettingsDto) {
val current = _uiState.value.settings ?: return
val next = transform(current)
saveSettings(current, next)
}
fun updateUserSearchQuery(value: String) {
_uiState.value = _uiState.value.copy(userSearchQuery = value)
userSearchJob?.cancel()
userSearchJob = viewModelScope.launch {
delay(250)
searchUsers(value)
}
}
fun blockUser(user: UserSummaryDto) {
update { settings ->
settings.copy(
blockedUserIds = (settings.blockedUserIds + user.id).distinct(),
alwaysAllowUserIds = settings.alwaysAllowUserIds - user.id
)
}
}
fun unblockUser(userId: String) {
update { settings -> settings.copy(blockedUserIds = settings.blockedUserIds - userId) }
}
fun alwaysAllowUser(user: UserSummaryDto) {
update { settings ->
settings.copy(
alwaysAllowUserIds = (settings.alwaysAllowUserIds + user.id).distinct(),
neverAllowUserIds = settings.neverAllowUserIds - user.id,
blockedUserIds = settings.blockedUserIds - user.id
)
}
}
fun neverAllowUser(user: UserSummaryDto) {
update { settings ->
settings.copy(
neverAllowUserIds = (settings.neverAllowUserIds + user.id).distinct(),
alwaysAllowUserIds = settings.alwaysAllowUserIds - user.id
)
}
}
fun removeAlwaysAllow(userId: String) {
update { settings -> settings.copy(alwaysAllowUserIds = settings.alwaysAllowUserIds - userId) }
}
fun removeNeverAllow(userId: String) {
update { settings -> settings.copy(neverAllowUserIds = settings.neverAllowUserIds - userId) }
}
fun enablePasscode(passcode: String, confirmation: String) {
val current = _uiState.value.settings ?: return
val normalized = passcode.trim()
if (!isValidPasscode(normalized)) {
_uiState.value = _uiState.value.copy(errorMessage = "Код должен содержать 4-12 цифр.")
return
}
if (normalized != confirmation.trim()) {
_uiState.value = _uiState.value.copy(errorMessage = "Коды не совпадают.")
return
}
saveSettings(
current = current,
next = current.copy(passcodeLockEnabled = true),
afterSuccess = { container.privacyRuntimeSettings.setPasscode(normalized) }
)
}
fun disablePasscode(passcode: String) {
val current = _uiState.value.settings ?: return
val normalized = passcode.trim()
if (container.privacyRuntimeSettings.hasPasscode() &&
!container.privacyRuntimeSettings.verifyPasscode(normalized)
) {
_uiState.value = _uiState.value.copy(errorMessage = "Неверный код.")
return
}
saveSettings(
current = current,
next = current.copy(passcodeLockEnabled = false),
afterSuccess = { container.privacyRuntimeSettings.clearPasscode() }
)
}
private fun saveSettings(
current: PrivacySettingsDto,
next: PrivacySettingsDto,
afterSuccess: () -> Unit = {}
) {
viewModelScope.launch {
_uiState.value = _uiState.value.copy(settings = next, isSaving = true, errorMessage = null)
runCatching {
@@ -63,6 +163,7 @@ class PrivacySecurityViewModel(private val container: AppContainer) : ViewModel(
)
}
}.onSuccess { saved ->
afterSuccess()
container.privacyRuntimeSettings.saveFrom(saved)
_uiState.value = _uiState.value.copy(settings = saved, isSaving = false)
}.onFailure { error ->
@@ -70,6 +171,28 @@ class PrivacySecurityViewModel(private val container: AppContainer) : ViewModel(
}
}
}
private fun isValidPasscode(value: String): Boolean =
value.length in 4..12 && value.all { it.isDigit() }
private suspend fun searchUsers(query: String) {
val normalizedQuery = query.trim()
if (normalizedQuery.length < 2) {
_uiState.value = _uiState.value.copy(userSearchResults = emptyList(), isSearchingUsers = false)
return
}
_uiState.value = _uiState.value.copy(isSearchingUsers = true)
runCatching {
container.executeAuthorized { session ->
container.apiClient.searchUsers(session.accessToken, normalizedQuery)
}
}.onSuccess { users ->
_uiState.value = _uiState.value.copy(userSearchResults = users, isSearchingUsers = false)
}.onFailure { error ->
_uiState.value = _uiState.value.copy(isSearchingUsers = false, errorMessage = error.message)
}
}
}
internal fun nextVisibility(value: String): String = when (value) {
@@ -34,6 +34,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.seven.ikar.kotlin.ui.AvatarView
import com.seven.ikar.kotlin.ui.IkarSectionCard
@@ -50,7 +51,6 @@ fun AccountSettingsScreen(
onBackClick: () -> Unit,
onDisplayNameChanged: (String) -> Unit,
onAboutChanged: (String) -> Unit,
onSaveClick: () -> Unit,
onDeleteAvatarClick: () -> Unit,
onAvatarSelected: (Uri) -> Unit
) {
@@ -103,7 +103,9 @@ fun AccountSettingsScreen(
Text(
text = "Нажмите на надпись, чтобы удалить текущее фото",
color = TelegramInkMuted,
style = MaterialTheme.typography.bodySmall
style = MaterialTheme.typography.bodySmall,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
}
@@ -175,27 +177,29 @@ fun AccountSettingsScreen(
}
}
Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
Text("Добавить личный канал", style = MaterialTheme.typography.titleLarge)
Text(
"Этот сценарий будет доведён отдельным экраном. Сейчас основной профиль уже перенесён.",
"Добавить личный канал",
style = MaterialTheme.typography.titleLarge,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Text(
"Отдельный экран будет добавлен позже.",
style = MaterialTheme.typography.bodySmall,
color = TelegramInkMuted
color = TelegramInkMuted,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
}
}
Button(
onClick = onSaveClick,
enabled = !state.isLoading && !state.isSaving,
modifier = Modifier.fillMaxWidth()
) {
Text(if (state.isSaving) "Сохранение..." else "Сохранить")
}
state.errorMessage?.let {
Text(it, color = TelegramDanger, style = MaterialTheme.typography.bodyMedium)
}
if (state.isSaving) {
Text("Сохраняется...", color = TelegramInkMuted, style = MaterialTheme.typography.bodyMedium)
}
state.infoMessage?.let {
Text(it, color = TelegramBlue, style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.SemiBold)
}
@@ -220,8 +224,19 @@ private fun IkarInfoLine(
}
}
Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
Text(title, style = MaterialTheme.typography.titleLarge)
Text(subtitle, style = MaterialTheme.typography.bodySmall, color = TelegramInkMuted)
Text(
title,
style = MaterialTheme.typography.titleLarge,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Text(
subtitle,
style = MaterialTheme.typography.bodySmall,
color = TelegramInkMuted,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
}
}
@@ -11,6 +11,8 @@ import com.seven.ikar.kotlin.core.network.UploadFileSource
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
data class ProfileUiState(
@@ -28,17 +30,20 @@ data class ProfileUiState(
class ProfileViewModel(private val container: AppContainer) : ViewModel() {
private val _uiState = MutableStateFlow(ProfileUiState(session = container.sessionStore.read()))
val uiState: StateFlow<ProfileUiState> = _uiState.asStateFlow()
private var autosaveJob: Job? = null
init {
refresh()
}
fun updateDisplayName(value: String) {
_uiState.value = _uiState.value.copy(displayName = value)
_uiState.value = _uiState.value.copy(displayName = value, errorMessage = null, infoMessage = null)
scheduleAutosave()
}
fun updateAbout(value: String) {
_uiState.value = _uiState.value.copy(about = value)
_uiState.value = _uiState.value.copy(about = value.take(200), errorMessage = null, infoMessage = null)
scheduleAutosave()
}
fun refresh() {
@@ -57,25 +62,35 @@ class ProfileViewModel(private val container: AppContainer) : ViewModel() {
}
}
fun save() {
private fun scheduleAutosave() {
autosaveJob?.cancel()
autosaveJob = viewModelScope.launch {
delay(ProfileAutosaveDelayMillis)
saveProfileFields()
}
}
private fun saveProfileFields() {
val state = _uiState.value
if (state.displayName.isBlank()) {
_uiState.value = state.copy(errorMessage = "Укажите отображаемое имя.")
return
}
val submittedDisplayName = state.displayName.trim()
val submittedAbout = state.about.trim()
viewModelScope.launch {
_uiState.value = state.copy(isSaving = true, errorMessage = null, infoMessage = null)
runCatching {
container.executeAuthorized { session ->
session to container.apiClient.updateMyProfile(
accessToken = session.accessToken,
displayName = state.displayName.trim(),
about = state.about.trim().ifBlank { null }
displayName = submittedDisplayName,
about = submittedAbout.ifBlank { null }
)
}
}.onSuccess { (session, user) ->
applyUser(session, user, infoMessage = "Профиль сохранён.")
applySavedProfile(session, user, submittedDisplayName, submittedAbout)
}.onFailure { error ->
_uiState.value = _uiState.value.copy(isSaving = false, errorMessage = error.message)
}
@@ -143,4 +158,34 @@ class ProfileViewModel(private val container: AppContainer) : ViewModel() {
infoMessage = infoMessage
)
}
private fun applySavedProfile(
session: AuthSessionDto,
user: UserSummaryDto,
submittedDisplayName: String,
submittedAbout: String
) {
val updatedSession = session.copy(user = user)
container.sessionStore.save(updatedSession)
val current = _uiState.value
val changedAgain = current.displayName.trim() != submittedDisplayName ||
current.about.trim() != submittedAbout
_uiState.value = current.copy(
session = updatedSession,
avatarUrl = ServerConfig.normalizeMediaUrl(user.avatarPath),
isLoading = false,
isSaving = false,
errorMessage = null,
infoMessage = if (changedAgain) null else "Профиль обновлён."
)
if (changedAgain) {
scheduleAutosave()
}
}
companion object {
private const val ProfileAutosaveDelayMillis = 600L
}
}
@@ -3,7 +3,8 @@ package com.seven.ikar.kotlin.feature.search
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.WindowInsetsSides
import androidx.compose.foundation.layout.fillMaxSize
@@ -180,6 +181,7 @@ fun GlobalSearchScreen(
}
@Composable
@OptIn(ExperimentalLayoutApi::class)
private fun GlobalSearchAdvancedFilters(
selectedDate: GlobalSearchDateFilter,
selectedType: GlobalSearchResultType,
@@ -189,7 +191,10 @@ private fun GlobalSearchAdvancedFilters(
onAuthorFilterChanged: (String) -> Unit
) {
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
FlowRow(
horizontalArrangement = Arrangement.spacedBy(6.dp),
verticalArrangement = Arrangement.spacedBy(2.dp)
) {
GlobalSearchDateFilter.entries.forEach { filter ->
TextButton(onClick = { onDateFilterChanged(filter) }) {
Text(
@@ -200,7 +205,10 @@ private fun GlobalSearchAdvancedFilters(
}
}
}
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
FlowRow(
horizontalArrangement = Arrangement.spacedBy(6.dp),
verticalArrangement = Arrangement.spacedBy(2.dp)
) {
GlobalSearchResultType.entries.forEach { type ->
TextButton(onClick = { onResultTypeChanged(type) }) {
Text(
@@ -220,11 +228,15 @@ private fun GlobalSearchAdvancedFilters(
}
@Composable
@OptIn(ExperimentalLayoutApi::class)
private fun GlobalSearchFilterRow(
selected: GlobalSearchFilter,
onFilterChanged: (GlobalSearchFilter) -> Unit
) {
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
FlowRow(
horizontalArrangement = Arrangement.spacedBy(6.dp),
verticalArrangement = Arrangement.spacedBy(2.dp)
) {
GlobalSearchFilter.entries.forEach { filter ->
TextButton(onClick = { onFilterChanged(filter) }) {
Text(
@@ -261,6 +273,19 @@ private fun GlobalSearchResultRow(
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
if (result.chat.type == ChatType.Channel) {
Text(
text = listOfNotNull(
result.chat.publicUsername?.let { "@$it" },
"Публичный канал",
formatChannelSubscribers(result.chat.subscriberCount)
).joinToString(""),
style = MaterialTheme.typography.bodySmall,
color = TelegramBlue,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
Text(
text = highlightedText(result.matchText.ifBlank { chatPreview(result.chat) }, query),
style = MaterialTheme.typography.bodyMedium,
@@ -276,18 +301,6 @@ private fun GlobalSearchResultRow(
overflow = TextOverflow.Ellipsis
)
}
result.botMiniAppDeepLink()?.let { deepLink ->
TextButton(onClick = {}) {
Text("Мини-приложение: $deepLink", color = TelegramBlue)
}
}
if (result.chat.type == ChatType.Group) {
Text(
text = "Групповой звонок готов для голосовых и видеодействий.",
style = MaterialTheme.typography.bodySmall,
color = TelegramInkMuted
)
}
}
}
@@ -332,13 +345,16 @@ private fun GlobalSearchResultType.label(): String =
when (this) {
GlobalSearchResultType.All -> "Все типы"
GlobalSearchResultType.Chats -> "Чаты"
GlobalSearchResultType.Channels -> "Каналы"
GlobalSearchResultType.Groups -> "Группы"
GlobalSearchResultType.Messages -> "Сообщения"
GlobalSearchResultType.Attachments -> "Вложения"
GlobalSearchResultType.Bots -> "Боты"
GlobalSearchResultType.GroupCalls -> "Звонки"
}
private fun ChatSearchResultDto.botMiniAppDeepLink(): String? {
val bot = chat.participants.firstOrNull { it.isBot } ?: return null
return "ikar://bot/${bot.username}?start=miniapp"
}
private fun formatChannelSubscribers(count: Int): String =
when {
count == 1 -> "1 подписчик"
count in 2..4 -> "$count подписчика"
else -> "$count подписчиков"
}
@@ -32,19 +32,13 @@ enum class GlobalSearchDateFilter {
enum class GlobalSearchResultType {
All,
Chats,
Channels,
Groups,
Messages,
Attachments,
Bots,
GroupCalls
Bots
}
data class SearchCapabilityHooks(
val supportsMediaDateGrouping: Boolean = true,
val supportsBotMiniAppPlaceholder: Boolean = true,
val supportsScheduledMessageOptions: Boolean = true,
val supportsGroupCallPlaceholder: Boolean = true
)
data class GlobalSearchUiState(
val query: String = "",
val filter: GlobalSearchFilter = GlobalSearchFilter.All,
@@ -56,8 +50,7 @@ data class GlobalSearchUiState(
val hasMore: Boolean = false,
val isSearching: Boolean = false,
val isLoadingMore: Boolean = false,
val errorMessage: String? = null,
val hooks: SearchCapabilityHooks = SearchCapabilityHooks()
val errorMessage: String? = null
) {
val visibleResults: List<ChatSearchResultDto>
get() = results.filter { result ->
@@ -214,10 +207,11 @@ private fun ChatSearchResultDto.matchesType(type: GlobalSearchResultType): Boole
when (type) {
GlobalSearchResultType.All -> true
GlobalSearchResultType.Chats -> message == null
GlobalSearchResultType.Channels -> chat.type == ChatType.Channel
GlobalSearchResultType.Groups -> chat.type == ChatType.Group
GlobalSearchResultType.Messages -> message != null
GlobalSearchResultType.Attachments -> message?.attachments?.isNotEmpty() == true
GlobalSearchResultType.Bots -> chat.participants.any { it.isBot }
GlobalSearchResultType.GroupCalls -> chat.type == ChatType.Group
}
internal fun ChatSearchResultDto.searchDateGroupLabel(): String =
@@ -15,7 +15,6 @@ import androidx.compose.foundation.layout.safeDrawing
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Surface
@@ -40,60 +39,7 @@ import com.seven.ikar.kotlin.ui.theme.TelegramBlue
import com.seven.ikar.kotlin.ui.theme.TelegramInkMuted
@Composable
fun PrivacySettingsV2Screen(
onBackClick: () -> Unit,
onBlockedUsersClick: () -> Unit,
onExceptionsClick: () -> Unit
) {
IkarScreenSurface(modifier = Modifier.fillMaxSize()) {
Column(
modifier = Modifier
.fillMaxSize()
.windowInsetsPadding(WindowInsets.safeDrawing.only(WindowInsetsSides.Vertical))
.verticalScroll(rememberScrollState())
.padding(horizontal = 18.dp, vertical = 16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
IkarSimpleHeader(title = "Конфиденциальность", onBackClick = onBackClick)
IkarSectionCard {
IkarSectionTitle("Исключения", modifier = Modifier.padding(bottom = 12.dp))
IkarValueRow("Заблокированные пользователи", "Список блокировок хранится в настройках конфиденциальности.", "Открыть", onClick = onBlockedUsersClick)
IkarRowDivider(startIndent = 0.dp)
IkarValueRow("Исключения приватности", "Списки «всегда разрешать» и «никогда не разрешать» для телефона, фото и историй.", "Открыть", onClick = onExceptionsClick)
}
IkarSectionCard {
Text("Подключено к серверному контракту", style = MaterialTheme.typography.titleLarge, color = TelegramBlue)
Text(
"Основные переключатели конфиденциальности и безопасности доступны через прежний экран; этот экран оставляет навигацию по подразделам как в Telegram.",
modifier = Modifier.padding(top = 6.dp),
style = MaterialTheme.typography.bodySmall,
color = TelegramInkMuted
)
}
}
}
}
@Composable
fun BlockedUsersPlaceholderScreen(onBackClick: () -> Unit) {
SimpleSettingsInfoScreen(
title = "Заблокированные",
body = "Сервер уже хранит список заблокированных пользователей в настройках конфиденциальности. Следующий слой: выбор пользователя с поиском и быстрым снятием блокировки.",
onBackClick = onBackClick
)
}
@Composable
fun PrivacyExceptionsPlaceholderScreen(onBackClick: () -> Unit) {
SimpleSettingsInfoScreen(
title = "Исключения",
body = "Контракт поддерживает списки разрешённых и запрещённых пользователей. Для полного интерфейса как в Telegram нужен выбор пользователей из контактов и отдельные правила для каждого поля.",
onBackClick = onBackClick
)
}
@Composable
fun ThemePlaceholderScreen(
fun ThemeSettingsScreen(
state: AppSettingsUiState,
onBackClick: () -> Unit,
onSnapshotChanged: ((AppSettingsSnapshot) -> AppSettingsSnapshot) -> Unit,
@@ -138,14 +84,21 @@ fun ThemePlaceholderScreen(
onCheckedChange = { value -> onSnapshotChanged { it.copy(largeEmoji = value) } }
)
}
Button(onClick = onResetClick, modifier = Modifier.fillMaxWidth()) {
Text("Сбросить")
}
Text(
text = "Сбросить",
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onResetClick)
.padding(vertical = 4.dp),
style = MaterialTheme.typography.titleLarge,
color = TelegramBlue,
fontWeight = FontWeight.SemiBold
)
}
}
@Composable
fun LanguagePlaceholderScreen(
fun LanguageSettingsScreen(
state: AppSettingsUiState,
onBackClick: () -> Unit,
onSnapshotChanged: ((AppSettingsSnapshot) -> AppSettingsSnapshot) -> Unit
@@ -167,9 +120,9 @@ fun LanguagePlaceholderScreen(
}
}
IkarSectionCard {
Text("Локализация сохранена", style = MaterialTheme.typography.titleMedium, color = TelegramBlue)
Text("Локализация применяется сразу", style = MaterialTheme.typography.titleMedium, color = TelegramBlue)
Text(
"Значение уже хранится локально. Для полного эффекта нужно применить его в базовом контексте приложения или слое локали Compose.",
"Выбранный язык сохраняется локально и сразу передается в контекст приложения. Системный режим возвращает язык устройства.",
modifier = Modifier.padding(top = 6.dp),
style = MaterialTheme.typography.bodySmall,
color = TelegramInkMuted
@@ -179,7 +132,7 @@ fun LanguagePlaceholderScreen(
}
@Composable
fun ProxyPlaceholderScreen(
fun ProxySettingsScreen(
state: AppSettingsUiState,
onBackClick: () -> Unit,
onSnapshotChanged: ((AppSettingsSnapshot) -> AppSettingsSnapshot) -> Unit
@@ -189,14 +142,14 @@ fun ProxyPlaceholderScreen(
IkarSectionCard {
IkarToggleRow(
"Использовать прокси",
"Профиль сохраняется локально и готов для подключения к OkHttp.",
"Применяется к новым сетевым запросам сразу после изменения.",
snapshot.proxyEnabled,
onCheckedChange = { value -> onSnapshotChanged { it.copy(proxyEnabled = value) } }
)
IkarRowDivider(startIndent = 0.dp)
IkarValueRow(
"Тип",
"SOCKS5, MTProto или HTTP.",
"SOCKS5 или HTTP.",
snapshot.proxyType.proxyTypeTitle(),
onClick = { onSnapshotChanged { it.copy(proxyType = nextProxyType(it.proxyType)) } }
)
@@ -224,15 +177,6 @@ fun ProxyPlaceholderScreen(
singleLine = true,
label = { Text("Порт") }
)
OutlinedTextField(
value = snapshot.proxySecret,
onValueChange = { value -> onSnapshotChanged { it.copy(proxySecret = value.take(256)) } },
modifier = Modifier
.fillMaxWidth()
.padding(top = 10.dp),
singleLine = true,
label = { Text("Секрет / пароль") }
)
}
}
}
@@ -287,25 +231,6 @@ private fun SelectableSettingsRow(
}
}
@Composable
private fun SimpleSettingsInfoScreen(
title: String,
body: String,
onBackClick: () -> Unit
) {
IkarSettingsScaffold(title = title, onBackClick = onBackClick) {
IkarSectionCard {
Text("Готова модель данных", style = MaterialTheme.typography.titleLarge, color = TelegramBlue)
Text(
body,
modifier = Modifier.padding(top = 6.dp),
style = MaterialTheme.typography.bodyMedium,
color = TelegramInkMuted
)
}
}
}
private fun AppThemeMode.themeTitle(): String = when (this) {
AppThemeMode.System -> "Как в системе"
AppThemeMode.Light -> "Дневная"
@@ -349,12 +274,12 @@ private fun nextAccent(current: String): String = when (current) {
private fun AppProxyType.proxyTypeTitle(): String = when (this) {
AppProxyType.Socks5 -> "SOCKS5"
AppProxyType.MtProto -> "MTProto"
AppProxyType.MtProto -> "SOCKS5"
AppProxyType.Http -> "HTTP"
}
private fun nextProxyType(current: AppProxyType): AppProxyType = when (current) {
AppProxyType.Socks5 -> AppProxyType.MtProto
AppProxyType.MtProto -> AppProxyType.Http
AppProxyType.Socks5 -> AppProxyType.Http
AppProxyType.MtProto -> AppProxyType.Socks5
AppProxyType.Http -> AppProxyType.Socks5
}
@@ -15,7 +15,6 @@ import androidx.compose.foundation.layout.only
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawing
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
@@ -27,12 +26,12 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.seven.ikar.kotlin.ui.ChatWallpaperBackground
import com.seven.ikar.kotlin.core.settings.ChatDistanceUnitOption
import com.seven.ikar.kotlin.core.settings.ChatListLayoutOption
import com.seven.ikar.kotlin.core.settings.ChatSettingsSnapshot
import com.seven.ikar.kotlin.core.settings.ChatSwipeLeftActionOption
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Image
import androidx.compose.material3.Icon
@@ -108,10 +107,31 @@ fun ChatSettingsScreen(
)
}
)
IkarRowDivider(startIndent = 0.dp)
IkarValueRow(
title = "Список чатов",
subtitle = "Количество строк предпросмотра в списке чатов.",
value = snapshot.chatListLayout.title,
onClick = { onSnapshotChanged(snapshot.copy(chatListLayout = snapshot.chatListLayout.next())) }
)
IkarRowDivider(startIndent = 0.dp)
IkarValueRow(
title = "Свайп влево",
subtitle = "Действие при свайпе строки чата справа налево.",
value = snapshot.swipeLeftAction.title,
onClick = { onSnapshotChanged(snapshot.copy(swipeLeftAction = snapshot.swipeLeftAction.next())) }
)
IkarRowDivider(startIndent = 0.dp)
IkarToggleRow(
title = "Анимации в чате",
subtitle = "Плавное движение выбранных обоев в окне переписки.",
checked = snapshot.animationsEnabled,
onCheckedChange = { onSnapshotChanged(snapshot.copy(animationsEnabled = it)) }
)
}
ChatSettingsSection {
IkarSectionTitle("Медиафайлы и звук", modifier = Modifier.padding(bottom = 12.dp))
IkarSectionTitle("Медиафайлы", modifier = Modifier.padding(bottom = 12.dp))
IkarToggleRow(
title = "Листать медиафайлы по нажатию",
subtitle = "Листать нажатием у края экрана в режиме просмотра медиафайлов.",
@@ -120,93 +140,38 @@ fun ChatSettingsScreen(
)
IkarRowDivider(startIndent = 0.dp)
IkarToggleRow(
title = "Поднести и слушать",
subtitle = "Переключаться на верхний динамик, когда вы подносите телефон к уху.",
checked = snapshot.raiseToListenEnabled,
onCheckedChange = { onSnapshotChanged(snapshot.copy(raiseToListenEnabled = it)) }
)
IkarRowDivider(startIndent = 0.dp)
IkarToggleRow(
title = "Поднести и говорить",
subtitle = "Записывать голосовое сообщение, когда вы подносите телефон к уху.",
checked = snapshot.raiseToTalkEnabled,
onCheckedChange = { onSnapshotChanged(snapshot.copy(raiseToTalkEnabled = it)) }
)
IkarRowDivider(startIndent = 0.dp)
IkarToggleRow(
title = "Пауза музыки при записи",
subtitle = "Останавливать музыку на время записи голосового сообщения.",
checked = snapshot.pauseMusicOnRecordEnabled,
onCheckedChange = { onSnapshotChanged(snapshot.copy(pauseMusicOnRecordEnabled = it)) }
)
IkarRowDivider(startIndent = 0.dp)
IkarToggleRow(
title = "Пауза музыки при запуске медиа",
subtitle = "Останавливать фоновую музыку при воспроизведении медиафайлов в чате.",
title = "Пауза музыки при запуске аудио",
subtitle = "Ставить фоновые плееры на паузу при прослушивании голосовых и аудио.",
checked = snapshot.pauseMusicOnPlaybackEnabled,
onCheckedChange = { onSnapshotChanged(snapshot.copy(pauseMusicOnPlaybackEnabled = it)) }
)
IkarRowDivider(startIndent = 0.dp)
IkarValueRow(
title = "Микрофон",
subtitle = "Источник записи голосовых сообщений.",
value = "По умолчанию",
enabled = false
IkarToggleRow(
title = "Пауза музыки при записи",
subtitle = "Ставить фоновые плееры на паузу при записи голосового сообщения.",
checked = snapshot.pauseMusicOnRecordEnabled,
onCheckedChange = { onSnapshotChanged(snapshot.copy(pauseMusicOnRecordEnabled = it)) }
)
}
ChatSettingsSection {
IkarSectionTitle("Прочие настройки", modifier = Modifier.padding(bottom = 12.dp))
IkarToggleRow(
title = "Смена темы ночью",
subtitle = "Автоматически включать ночную тему по локальному сценарию.",
checked = snapshot.autoNightThemeEnabled,
onCheckedChange = { onSnapshotChanged(snapshot.copy(autoNightThemeEnabled = it)) }
)
IkarRowDivider(startIndent = 0.dp)
IkarSectionTitle("Ссылки", modifier = Modifier.padding(bottom = 12.dp))
IkarToggleRow(
title = "Встроенный браузер",
subtitle = "Открывать ссылки внутри приложения, если доступен встроенный просмотр.",
subtitle = "Открывать ссылки из сообщений внутри Икара.",
checked = snapshot.inAppBrowserEnabled,
onCheckedChange = { onSnapshotChanged(snapshot.copy(inAppBrowserEnabled = it)) }
)
IkarRowDivider(startIndent = 0.dp)
IkarToggleRow(
title = "Анимации",
subtitle = "Использовать анимации интерфейса и медиа-переходов.",
checked = snapshot.animationsEnabled,
onCheckedChange = { onSnapshotChanged(snapshot.copy(animationsEnabled = it)) }
)
IkarRowDivider(startIndent = 0.dp)
IkarToggleRow(
title = "Прямая отправка",
subtitle = "Показывать недавние чаты в системном меню «Поделиться».",
checked = snapshot.directShareEnabled,
onCheckedChange = { onSnapshotChanged(snapshot.copy(directShareEnabled = it)) }
)
IkarRowDivider(startIndent = 0.dp)
IkarToggleRow(
title = "Показывать материалы 18+",
subtitle = "Не скрывать чувствительные медиафайлы.",
checked = snapshot.showSensitiveContentEnabled,
onCheckedChange = { onSnapshotChanged(snapshot.copy(showSensitiveContentEnabled = it)) }
)
IkarRowDivider(startIndent = 0.dp)
}
ChatSettingsSection {
IkarSectionTitle("Отправка", modifier = Modifier.padding(bottom = 12.dp))
IkarToggleRow(
title = "Отправка по Вводу",
subtitle = "Использовать действие отправки вместо перевода строки, если клавиатура поддерживает «Отправить».",
checked = snapshot.sendByEnterEnabled,
onCheckedChange = { onSnapshotChanged(snapshot.copy(sendByEnterEnabled = it)) }
)
IkarRowDivider(startIndent = 0.dp)
IkarValueRow(
title = "Мера расстояния",
subtitle = "Единицы для геоданных и расстояний в интерфейсе.",
value = snapshot.distanceUnit.title,
onClick = {
onSnapshotChanged(snapshot.copy(distanceUnit = snapshot.distanceUnit.next()))
}
)
}
Text(
@@ -293,6 +258,7 @@ private fun ChatStylePreview(
) {
ChatWallpaperBackground(
snapshot = wallpaperSnapshot,
animated = wallpaperSnapshot.animationsEnabled,
modifier = Modifier.fillMaxSize()
)
}
@@ -333,26 +299,28 @@ private fun ChatStylePreview(
}
}
@Composable
private fun BoxLine(width: Dp) {
Row(
modifier = Modifier
.width(width)
.background(Color(0xFFDCE4EA), RoundedCornerShape(999.dp))
.padding(vertical = 2.dp)
) {}
}
private val ChatDistanceUnitOption.title: String
private val ChatListLayoutOption.title: String
get() = when (this) {
ChatDistanceUnitOption.Automatic -> "Автоматически"
ChatDistanceUnitOption.Kilometers -> "Километры"
ChatDistanceUnitOption.Miles -> "Мили"
ChatListLayoutOption.TwoLine -> "2 строки"
ChatListLayoutOption.ThreeLine -> "3 строки"
}
private fun ChatDistanceUnitOption.next(): ChatDistanceUnitOption = when (this) {
ChatDistanceUnitOption.Automatic -> ChatDistanceUnitOption.Kilometers
ChatDistanceUnitOption.Kilometers -> ChatDistanceUnitOption.Miles
ChatDistanceUnitOption.Miles -> ChatDistanceUnitOption.Automatic
private fun ChatListLayoutOption.next(): ChatListLayoutOption = when (this) {
ChatListLayoutOption.TwoLine -> ChatListLayoutOption.ThreeLine
ChatListLayoutOption.ThreeLine -> ChatListLayoutOption.TwoLine
}
private val ChatSwipeLeftActionOption.title: String
get() = when (this) {
ChatSwipeLeftActionOption.Delete -> "Удалить"
ChatSwipeLeftActionOption.Archive -> "Архив"
ChatSwipeLeftActionOption.Mute -> "Без звука"
ChatSwipeLeftActionOption.Read -> "Прочитать"
}
private fun ChatSwipeLeftActionOption.next(): ChatSwipeLeftActionOption = when (this) {
ChatSwipeLeftActionOption.Delete -> ChatSwipeLeftActionOption.Archive
ChatSwipeLeftActionOption.Archive -> ChatSwipeLeftActionOption.Mute
ChatSwipeLeftActionOption.Mute -> ChatSwipeLeftActionOption.Read
ChatSwipeLeftActionOption.Read -> ChatSwipeLeftActionOption.Delete
}
@@ -1,5 +1,6 @@
package com.seven.ikar.kotlin.feature.settings
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.WindowInsets
@@ -12,7 +13,6 @@ import androidx.compose.foundation.layout.safeDrawing
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
@@ -26,6 +26,7 @@ import com.seven.ikar.kotlin.ui.IkarSectionTitle
import com.seven.ikar.kotlin.ui.IkarSimpleHeader
import com.seven.ikar.kotlin.ui.IkarToggleRow
import com.seven.ikar.kotlin.ui.IkarValueRow
import com.seven.ikar.kotlin.ui.theme.TelegramBlue
import com.seven.ikar.kotlin.ui.theme.TelegramInkMuted
@Composable
@@ -52,19 +53,19 @@ fun DataStorageSettingsScreen(
IkarSectionTitle("Использование памяти", modifier = Modifier.padding(bottom = 12.dp))
IkarValueRow(
"Кэш Ikar",
"Лимит локальных медиа и превью на устройстве.",
"Лимит локальных медиа.",
"${snapshot.maxCacheMb} МБ",
onClick = { onSnapshotChanged { it.copy(maxCacheMb = nextCacheLimit(it.maxCacheMb)) } }
)
IkarRowDivider(startIndent = 0.dp)
IkarValueRow(
"Хранить медиа",
"Срок хранения локальных файлов перед LRU-очисткой.",
"Срок хранения файлов.",
"${snapshot.keepMediaDays} дн.",
onClick = { onSnapshotChanged { it.copy(keepMediaDays = nextKeepDays(it.keepMediaDays)) } }
)
Text(
"Параметры сохраняются локально и готовы для подключения к TransferQueue/LRU-очистке.",
"Лимит и срок хранения применяются сразу к локальному кэшу медиафайлов.",
modifier = Modifier.padding(top = 8.dp),
style = MaterialTheme.typography.bodySmall,
color = TelegramInkMuted
@@ -75,28 +76,28 @@ fun DataStorageSettingsScreen(
IkarSectionTitle("Автозагрузка медиа", modifier = Modifier.padding(bottom = 12.dp))
IkarToggleRow(
"Фото",
"Автоматически загружать фото в чатах.",
"Автозагрузка фото.",
snapshot.autoDownloadPhotos,
onCheckedChange = { value -> onSnapshotChanged { it.copy(autoDownloadPhotos = value) } }
)
IkarRowDivider(startIndent = 0.dp)
IkarToggleRow(
"Видео",
"Автоматически загружать видео только по Wi-Fi.",
"Видео только по Wi-Fi.",
snapshot.autoDownloadVideosOnWifi,
onCheckedChange = { value -> onSnapshotChanged { it.copy(autoDownloadVideosOnWifi = value) } }
)
IkarRowDivider(startIndent = 0.dp)
IkarToggleRow(
"Файлы",
"Автозагрузка документов только по Wi-Fi.",
"Документы только по Wi-Fi.",
snapshot.autoDownloadFilesOnWifi,
onCheckedChange = { value -> onSnapshotChanged { it.copy(autoDownloadFilesOnWifi = value) } }
)
IkarRowDivider(startIndent = 0.dp)
IkarToggleRow(
"Сохранять в галерею",
"Добавлять новые медиа в системную галерею.",
"Сохранять фото в галерею",
"Загруженные изображения сразу появляются в альбоме Ikar.",
snapshot.saveMediaToGallery,
onCheckedChange = { value -> onSnapshotChanged { it.copy(saveMediaToGallery = value) } }
)
@@ -104,27 +105,23 @@ fun DataStorageSettingsScreen(
IkarSectionCard {
IkarSectionTitle("Сеть", modifier = Modifier.padding(bottom = 12.dp))
IkarToggleRow(
"Меньше данных для звонков",
"Снижать битрейт звонков в мобильной сети.",
snapshot.lowDataCalls,
onCheckedChange = { value -> onSnapshotChanged { it.copy(lowDataCalls = value) } }
)
IkarRowDivider(startIndent = 0.dp)
IkarValueRow(
"Прокси",
"SOCKS/MTProto/HTTP профиль для сетевого слоя.",
"SOCKS5/HTTP профиль.",
if (snapshot.proxyEnabled) "Вкл." else "Выкл.",
onClick = onProxyClick
)
}
Button(onClick = onResetClick, modifier = Modifier.fillMaxWidth()) {
Text("Сбросить настройки")
}
Button(onClick = onBackClick, modifier = Modifier.fillMaxWidth()) {
Text("Готово")
}
Text(
text = "Сбросить настройки",
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onResetClick)
.padding(vertical = 4.dp),
style = MaterialTheme.typography.titleLarge,
color = TelegramBlue
)
}
}
}
@@ -16,21 +16,15 @@ import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Campaign
import androidx.compose.material.icons.outlined.ChatBubbleOutline
import androidx.compose.material.icons.outlined.Groups
import androidx.compose.material.icons.outlined.Notifications
import androidx.compose.material.icons.outlined.PersonOutline
import androidx.compose.material.icons.outlined.Phone
import androidx.compose.material.icons.outlined.Schedule
import androidx.compose.material.icons.outlined.VolumeUp
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.seven.ikar.kotlin.core.push.NotificationPreferenceCatalog
import com.seven.ikar.kotlin.core.push.NotificationRepeatIntervalChoice
import com.seven.ikar.kotlin.core.push.NotificationRepeatIntervalOption
import com.seven.ikar.kotlin.core.push.NotificationSettingsSnapshot
import com.seven.ikar.kotlin.core.push.NotificationSoundChoice
import com.seven.ikar.kotlin.core.push.NotificationSoundOption
@@ -49,14 +43,9 @@ import com.seven.ikar.kotlin.ui.theme.TelegramInkMuted
fun NotificationSettingsScreen(
state: NotificationSettingsUiState,
messageSoundOptions: List<NotificationSoundChoice>,
callRingtoneOptions: List<NotificationSoundChoice>,
repeatIntervalOptions: List<NotificationRepeatIntervalChoice>,
onBackClick: () -> Unit,
onSnapshotChanged: (NotificationSettingsSnapshot) -> Unit,
onMessageSoundChanged: (NotificationSoundOption) -> Unit,
onCallRingtoneChanged: (NotificationSoundOption) -> Unit,
onRepeatIntervalChanged: (NotificationRepeatIntervalOption) -> Unit,
onSaveClick: () -> Unit,
onResetClick: () -> Unit
) {
val snapshot = state.snapshot
@@ -73,86 +62,59 @@ fun NotificationSettingsScreen(
IkarSimpleHeader(title = "Уведомления и звуки", onBackClick = onBackClick)
NotificationSection {
IkarSectionTitle("Уведомления из чатов", modifier = Modifier.padding(bottom = 12.dp))
IkarToggleRow("Личные чаты", "Нажмите для изменения", snapshot.privateChatsEnabled, { onSnapshotChanged(snapshot.copy(privateChatsEnabled = it)) }, leading = { androidx.compose.material3.Icon(Icons.Outlined.PersonOutline, contentDescription = null, tint = TelegramInkMuted) })
IkarRowDivider(startIndent = 32.dp)
IkarToggleRow("Группы", "Нажмите для изменения", snapshot.groupsEnabled, { onSnapshotChanged(snapshot.copy(groupsEnabled = it)) }, leading = { androidx.compose.material3.Icon(Icons.Outlined.Groups, contentDescription = null, tint = TelegramInkMuted) })
IkarRowDivider(startIndent = 32.dp)
IkarToggleRow("Каналы", "Нажмите для изменения", snapshot.channelsEnabled, { onSnapshotChanged(snapshot.copy(channelsEnabled = it)) }, leading = { androidx.compose.material3.Icon(Icons.Outlined.Campaign, contentDescription = null, tint = TelegramInkMuted) })
IkarRowDivider(startIndent = 32.dp)
IkarToggleRow("Истории", "Отключены, 5 автоматических...", snapshot.storiesEnabled, { onSnapshotChanged(snapshot.copy(storiesEnabled = it)) }, leading = { androidx.compose.material3.Icon(Icons.Outlined.Schedule, contentDescription = null, tint = TelegramInkMuted) })
IkarRowDivider(startIndent = 32.dp)
IkarToggleRow("Реакции", "Сообщения, Истории", snapshot.reactionsEnabled, { onSnapshotChanged(snapshot.copy(reactionsEnabled = it)) }, leading = { Text("", color = TelegramBlue, style = MaterialTheme.typography.titleMedium) })
}
NotificationSection {
IkarSectionTitle("Звонки", modifier = Modifier.padding(bottom = 12.dp))
IkarValueRow(
title = "Вибросигнал",
subtitle = "Переключить режим вибрации при звонке",
value = if (snapshot.callVibrationEnabled) "По умолчанию" else "Выключен",
onClick = { onSnapshotChanged(snapshot.copy(callVibrationEnabled = !snapshot.callVibrationEnabled)) },
leading = { androidx.compose.material3.Icon(Icons.Outlined.Phone, contentDescription = null, tint = TelegramInkMuted) }
IkarSectionTitle("Основное", modifier = Modifier.padding(bottom = 12.dp))
IkarToggleRow(
"Уведомления",
"Регистрировать устройство и показывать push-уведомления.",
snapshot.notificationsEnabled,
{ onSnapshotChanged(snapshot.copy(notificationsEnabled = it)) },
leading = { androidx.compose.material3.Icon(Icons.Outlined.Notifications, contentDescription = null, tint = TelegramInkMuted) }
)
IkarRowDivider(startIndent = 0.dp)
IkarRowDivider(startIndent = 32.dp)
IkarValueRow(
title = "Рингтон",
subtitle = "Системный звук входящего звонка в Икар",
value = callRingtoneOptions.firstOrNull { it.value == snapshot.callRingtone }?.title ?: "По умолчанию",
onClick = {
val currentIndex = callRingtoneOptions.indexOfFirst { it.value == snapshot.callRingtone }.coerceAtLeast(0)
val next = callRingtoneOptions[(currentIndex + 1) % callRingtoneOptions.size]
onCallRingtoneChanged(next.value)
},
title = "Звук сообщений",
subtitle = "Канал уведомлений для новых сообщений.",
value = messageSoundOptions.firstOrNull { it.value == snapshot.messageSound }?.title ?: "По умолчанию",
onClick = if (messageSoundOptions.isEmpty()) null else ({
val currentIndex = messageSoundOptions.indexOfFirst { it.value == snapshot.messageSound }.coerceAtLeast(0)
val next = messageSoundOptions[(currentIndex + 1) % messageSoundOptions.size]
onMessageSoundChanged(next.value)
}),
leading = { androidx.compose.material3.Icon(Icons.Outlined.VolumeUp, contentDescription = null, tint = TelegramInkMuted) }
)
}
NotificationSection {
IkarSectionTitle("Уведомления из чатов", modifier = Modifier.padding(bottom = 12.dp))
IkarToggleRow("Личные чаты", "Уведомления личных чатов", snapshot.privateChatsEnabled, { onSnapshotChanged(snapshot.copy(privateChatsEnabled = it)) }, leading = { androidx.compose.material3.Icon(Icons.Outlined.PersonOutline, contentDescription = null, tint = TelegramInkMuted) })
IkarRowDivider(startIndent = 32.dp)
IkarToggleRow("Группы", "Уведомления групп", snapshot.groupsEnabled, { onSnapshotChanged(snapshot.copy(groupsEnabled = it)) }, leading = { androidx.compose.material3.Icon(Icons.Outlined.Groups, contentDescription = null, tint = TelegramInkMuted) })
IkarRowDivider(startIndent = 32.dp)
IkarToggleRow("Каналы", "Уведомления каналов", snapshot.channelsEnabled, { onSnapshotChanged(snapshot.copy(channelsEnabled = it)) }, leading = { androidx.compose.material3.Icon(Icons.Outlined.Campaign, contentDescription = null, tint = TelegramInkMuted) })
IkarRowDivider(startIndent = 32.dp)
IkarToggleRow("Реакции", "Реакции на сообщения", snapshot.reactionsEnabled, { onSnapshotChanged(snapshot.copy(reactionsEnabled = it)) }, leading = { Text("", color = TelegramBlue, style = MaterialTheme.typography.titleMedium) })
}
NotificationSection {
IkarSectionTitle("Счётчик сообщений", modifier = Modifier.padding(bottom = 12.dp))
IkarToggleRow("Показывать счётчик", "Разрешать значок-счётчик на иконке приложения", snapshot.showBadge, { onSnapshotChanged(snapshot.copy(showBadge = it)) })
IkarToggleRow("Показывать счётчик", "Индикатор на значке «Чаты» в нижнем меню.", snapshot.showBadge, { onSnapshotChanged(snapshot.copy(showBadge = it)) })
IkarRowDivider(startIndent = 0.dp)
IkarToggleRow("Чаты без уведомлений", "Показывать muted-чаты внутри общего счётчика", snapshot.includeMutedChatsInBadge, { onSnapshotChanged(snapshot.copy(includeMutedChatsInBadge = it)) })
IkarToggleRow("Чаты без уведомлений", "Включать в общий счётчик", snapshot.includeMutedChatsInBadge, { onSnapshotChanged(snapshot.copy(includeMutedChatsInBadge = it)) })
IkarRowDivider(startIndent = 0.dp)
IkarToggleRow("Число сообщений", "Показывать количество непрочитанных сообщений", snapshot.countMessagesInBadge, { onSnapshotChanged(snapshot.copy(countMessagesInBadge = it)) })
IkarToggleRow("Число сообщений", "Показывать сумму сообщений вместо числа чатов.", snapshot.countMessagesInBadge, { onSnapshotChanged(snapshot.copy(countMessagesInBadge = it)) })
}
NotificationSection {
IkarSectionTitle("В приложении", modifier = Modifier.padding(bottom = 12.dp))
IkarToggleRow("Звук", "Проигрывать звук для всплывающих уведомлений", snapshot.inAppSoundEnabled, { onSnapshotChanged(snapshot.copy(inAppSoundEnabled = it)) })
IkarToggleRow("Звук", "Звук всплывающих уведомлений", snapshot.inAppSoundEnabled, { onSnapshotChanged(snapshot.copy(inAppSoundEnabled = it)) })
IkarRowDivider(startIndent = 0.dp)
IkarToggleRow("Вибросигнал", "Вибрировать для in-app уведомлений", snapshot.inAppVibrationEnabled, { onSnapshotChanged(snapshot.copy(inAppVibrationEnabled = it)) })
IkarToggleRow("Вибросигнал", "Вибрация внутри приложения", snapshot.inAppVibrationEnabled, { onSnapshotChanged(snapshot.copy(inAppVibrationEnabled = it)) })
IkarRowDivider(startIndent = 0.dp)
IkarToggleRow("Показывать текст", "Показывать содержимое сообщения во всплывающем окне", snapshot.inAppShowTextEnabled, { onSnapshotChanged(snapshot.copy(inAppShowTextEnabled = it)) })
IkarToggleRow("Показывать текст", "Текст во всплывающем окне", snapshot.inAppShowTextEnabled, { onSnapshotChanged(snapshot.copy(inAppShowTextEnabled = it)) })
IkarRowDivider(startIndent = 0.dp)
IkarToggleRow("Звук в чате", "Короткий сигнал для уже открытого чата", snapshot.soundInActiveChatEnabled, { onSnapshotChanged(snapshot.copy(soundInActiveChatEnabled = it)) })
IkarToggleRow("Звук в чате", "Сигнал в открытом чате", snapshot.soundInActiveChatEnabled, { onSnapshotChanged(snapshot.copy(soundInActiveChatEnabled = it)) })
IkarRowDivider(startIndent = 0.dp)
IkarToggleRow("Всплывающие окна", "Показывать всплывающие окна, когда приложение открыто", snapshot.popupWindowsEnabled, { onSnapshotChanged(snapshot.copy(popupWindowsEnabled = it)) })
}
NotificationSection {
IkarSectionTitle("События", modifier = Modifier.padding(bottom = 12.dp))
IkarToggleRow("Контакт присоединился к Ikar", "Сообщать, когда контакт появился в Ikar", snapshot.contactJoinedEnabled, { onSnapshotChanged(snapshot.copy(contactJoinedEnabled = it)) })
IkarRowDivider(startIndent = 0.dp)
IkarToggleRow("Закреплённые сообщения", "Показывать уведомления о закреплённых сообщениях", snapshot.pinnedMessagesEnabled, { onSnapshotChanged(snapshot.copy(pinnedMessagesEnabled = it)) })
}
NotificationSection {
IkarSectionTitle("Другое", modifier = Modifier.padding(bottom = 12.dp))
IkarToggleRow("Перезапуск при закрытии", "Поднимать фоновый сервис доставки уведомлений", snapshot.restartOnCloseEnabled, { onSnapshotChanged(snapshot.copy(restartOnCloseEnabled = it)) })
IkarRowDivider(startIndent = 0.dp)
IkarToggleRow("Фоновое соединение", "Держать минимальное фоновое соединение", snapshot.backgroundConnectionEnabled, { onSnapshotChanged(snapshot.copy(backgroundConnectionEnabled = it)) })
IkarRowDivider(startIndent = 0.dp)
IkarValueRow(
title = "Повтор уведомлений",
subtitle = "Повторять уведомление, пока в чате есть непрочитанные сообщения",
value = repeatIntervalOptions.firstOrNull { it.value == snapshot.repeatInterval }?.title ?: "1 час",
onClick = {
val currentIndex = repeatIntervalOptions.indexOfFirst { it.value == snapshot.repeatInterval }.coerceAtLeast(0)
val next = repeatIntervalOptions[(currentIndex + 1) % repeatIntervalOptions.size]
onRepeatIntervalChanged(next.value)
}
)
IkarToggleRow("Всплывающие окна", "Показывать поверх приложения", snapshot.popupWindowsEnabled, { onSnapshotChanged(snapshot.copy(popupWindowsEnabled = it)) })
}
NotificationSection {
@@ -166,16 +128,12 @@ fun NotificationSettingsScreen(
style = MaterialTheme.typography.titleLarge
)
Text(
"Сбросить все особые настройки уведомлений для отдельных контактов, групп и каналов.",
"Сбросить особые настройки контактов, групп и каналов.",
style = MaterialTheme.typography.bodySmall,
color = TelegramInkMuted
)
}
androidx.compose.material3.Button(onClick = onSaveClick, modifier = Modifier.fillMaxWidth()) {
Text(if (state.isSaving) "Сохранение..." else "Сохранить")
}
state.errorMessage?.let {
Text(it, color = TelegramDanger, style = MaterialTheme.typography.bodyMedium)
}
@@ -4,8 +4,6 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.seven.ikar.kotlin.app.AppContainer
import com.seven.ikar.kotlin.core.push.NotificationPreferenceCatalog
import com.seven.ikar.kotlin.core.push.NotificationRepeatIntervalChoice
import com.seven.ikar.kotlin.core.push.NotificationRepeatIntervalOption
import com.seven.ikar.kotlin.core.push.NotificationSettingsSnapshot
import com.seven.ikar.kotlin.core.push.NotificationSoundChoice
import com.seven.ikar.kotlin.core.push.NotificationSoundOption
@@ -28,36 +26,39 @@ class NotificationSettingsViewModel(private val container: AppContainer) : ViewM
val uiState: StateFlow<NotificationSettingsUiState> = _uiState.asStateFlow()
val messageSoundOptions: List<NotificationSoundChoice> = NotificationPreferenceCatalog.messageSoundChoices
val callRingtoneOptions: List<NotificationSoundChoice> = NotificationPreferenceCatalog.callRingtoneChoices
val repeatIntervalOptions: List<NotificationRepeatIntervalChoice> = NotificationPreferenceCatalog.repeatChoices
private var saveRevision = 0
fun updateSnapshot(transform: (NotificationSettingsSnapshot) -> NotificationSettingsSnapshot) {
_uiState.value = _uiState.value.copy(snapshot = transform(_uiState.value.snapshot))
val snapshot = transform(_uiState.value.snapshot)
_uiState.value = _uiState.value.copy(snapshot = snapshot, errorMessage = null, infoMessage = null)
persist(snapshot)
}
fun setMessageSound(value: NotificationSoundOption) = updateSnapshot { it.copy(messageSound = value) }
fun setCallRingtone(value: NotificationSoundOption) = updateSnapshot { it.copy(callRingtone = value) }
fun setRepeatInterval(value: NotificationRepeatIntervalOption) = updateSnapshot { it.copy(repeatInterval = value) }
fun save() {
private fun persist(snapshot: NotificationSettingsSnapshot) {
val revision = ++saveRevision
viewModelScope.launch {
val snapshot = _uiState.value.snapshot
_uiState.value = _uiState.value.copy(isSaving = true, errorMessage = null, infoMessage = null)
runCatching {
container.pushSettings.saveSnapshot(snapshot)
container.pushRegistrationManager.registerCurrentDeviceAsync()
}.onSuccess {
_uiState.value = _uiState.value.copy(isSaving = false, infoMessage = "Настройки уведомлений сохранены.")
if (revision == saveRevision) {
_uiState.value = _uiState.value.copy(isSaving = false, infoMessage = null)
}
}.onFailure { error ->
_uiState.value = _uiState.value.copy(isSaving = false, errorMessage = error.message)
if (revision == saveRevision) {
_uiState.value = _uiState.value.copy(isSaving = false, errorMessage = error.message)
}
}
}
}
fun reset() {
container.pushSettings.resetNotificationPreferences()
_uiState.value = NotificationSettingsUiState(snapshot = container.pushSettings.createDefaultSnapshot())
val snapshot = container.pushSettings.createDefaultSnapshot()
_uiState.value = NotificationSettingsUiState(snapshot = snapshot)
persist(snapshot)
}
}
@@ -11,13 +11,26 @@ 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.size
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.AccountCircle
import androidx.compose.material.icons.outlined.AutoStories
import androidx.compose.material.icons.outlined.Close
import androidx.compose.material.icons.outlined.Devices
import androidx.compose.material.icons.outlined.Folder
import androidx.compose.material.icons.outlined.Language
import androidx.compose.material.icons.outlined.Notifications
import androidx.compose.material.icons.outlined.Palette
import androidx.compose.material.icons.outlined.Security
import androidx.compose.material.icons.outlined.Search
import androidx.compose.material.icons.outlined.Storage
import androidx.compose.material.icons.outlined.SystemUpdate
import androidx.compose.material.icons.outlined.Tune
import androidx.compose.material.icons.outlined.VpnKey
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
@@ -31,6 +44,7 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
@@ -58,7 +72,6 @@ fun SettingsScreen(
onChatSettingsClick: () -> Unit,
onPrivacyClick: () -> Unit,
onNotificationsClick: () -> Unit,
onBotsClick: () -> Unit,
onFoldersClick: () -> Unit,
onDevicesClick: () -> Unit,
onUpdatesClick: () -> Unit,
@@ -76,7 +89,6 @@ fun SettingsScreen(
onChatSettingsClick,
onPrivacyClick,
onNotificationsClick,
onBotsClick,
onFoldersClick,
onDevicesClick,
onUpdatesClick,
@@ -92,31 +104,24 @@ fun SettingsScreen(
subtitle = "Телефон, имя и описание профиля",
backgroundColor = Color(0xFFE6F1FD),
accentColor = TelegramBlue,
letter = "А",
icon = Icons.Outlined.AccountCircle,
onClick = onAccountClick,
keywords = listOf("телефон", "номер", "имя", "username", "описание", "профиль")
),
SettingsEntryModel(
title = "Настройки чатов",
subtitle = "Обои, список чатов, жесты и медиа",
backgroundColor = Color(0xFFFFF8ECD8),
subtitle = "Обои, список чатов, текст, медиа и Enter",
backgroundColor = Color(0xFFFFF8EC),
accentColor = Color(0xFFD7812B),
letter = "Ч",
icon = Icons.Outlined.Tune,
onClick = onChatSettingsClick,
keywords = listOf(
"обои",
"список чатов",
"жесты",
"медиа",
"текст сообщений",
"углы",
"свайп",
"встроенный браузер",
"анимации",
"direct share",
"отправка по enter",
"микрофон",
"18+"
"отправка по enter"
)
),
SettingsEntryModel(
@@ -124,7 +129,7 @@ fun SettingsScreen(
subtitle = "Контакты и отображение имён в чатах",
backgroundColor = Color(0xFFE9F7E5),
accentColor = Color(0xFF44A948),
letter = "К",
icon = Icons.Outlined.Security,
onClick = onPrivacyClick,
keywords = listOf("контакты", "имена", "приватность", "конфиденциальность")
),
@@ -133,7 +138,7 @@ fun SettingsScreen(
subtitle = "Звуки, звонки и счётчик сообщений",
backgroundColor = Color(0xFFFDE8EA),
accentColor = Color(0xFFDC4E5C),
letter = "У",
icon = Icons.Outlined.Notifications,
onClick = onNotificationsClick,
keywords = listOf("уведомления", "звук", "звонки", "push", "счётчик")
),
@@ -142,25 +147,16 @@ fun SettingsScreen(
subtitle = "Проверка новой версии Икара и установка из Argus",
backgroundColor = Color(0xFFE8F3FF),
accentColor = TelegramBlue,
letter = "О",
icon = Icons.Outlined.SystemUpdate,
onClick = onUpdatesClick,
keywords = listOf("обновление", "version", "apk", "argus", "релиз", "установка")
),
SettingsEntryModel(
title = "Боты",
subtitle = "Создание, токены и команды API ботов",
backgroundColor = Color(0xFFEAF4FF),
accentColor = TelegramBlue,
letter = "Б",
onClick = onBotsClick,
keywords = listOf("bot api", "токен", "команды", "бот", "боты")
),
SettingsEntryModel(
title = "Данные и память",
subtitle = "Кэш и локальные медиафайлы",
backgroundColor = Color(0xFFEAF0FD),
accentColor = Color(0xFF4B76E4),
letter = "Д",
icon = Icons.Outlined.Storage,
onClick = onDataStorageClick,
keywords = listOf("кэш", "память", "данные", "медиафайлы", "хранилище")
),
@@ -169,34 +165,25 @@ fun SettingsScreen(
subtitle = "Стартовый фильтр для списка чатов",
backgroundColor = Color(0xFFEEF5FD),
accentColor = Color(0xFF5293F1),
letter = "П",
icon = Icons.Outlined.Folder,
onClick = onFoldersClick,
keywords = listOf("папки", "фильтр", "список чатов", "архив")
),
SettingsEntryModel(
title = "Устройства",
subtitle = "Устройства уведомлений и их состояние",
subtitle = "Где используется Икар и состояние уведомлений",
backgroundColor = Color(0xFFEAF7FB),
accentColor = Color(0xFF49A8C2),
letter = "У",
icon = Icons.Outlined.Devices,
onClick = onDevicesClick,
keywords = listOf("устройства", "push", "сессии", "авторизация")
),
SettingsEntryModel(
title = "Энергосбережение",
subtitle = "Фоновое обновление и прогрев превью",
backgroundColor = Color(0xFFFFF1E6),
accentColor = Color(0xFFD7812B),
letter = "Э",
onClick = null,
keywords = listOf("батарея", "энергосбережение", "фон", "превью")
keywords = listOf("устройства", "push", "телефон", "планшет", "компьютер")
),
SettingsEntryModel(
title = "Истории",
subtitle = "Лента, редактор, срок жизни и защищённые истории",
backgroundColor = Color(0xFFEAF4FF),
accentColor = TelegramBlue,
letter = "И",
icon = Icons.Outlined.AutoStories,
onClick = onStoriesClick,
keywords = listOf("stories", "истории", "сторис", "композер", "story")
),
@@ -205,27 +192,27 @@ fun SettingsScreen(
subtitle = "Тема, акцент, эмодзи и анимированные фоны",
backgroundColor = Color(0xFFE9F7E5),
accentColor = Color(0xFF44A948),
letter = "Т",
icon = Icons.Outlined.Palette,
onClick = onThemeClick,
keywords = listOf("theme", "тема", "ночная", "оформление")
),
SettingsEntryModel(
title = "Язык",
subtitle = "Русский",
backgroundColor = Color(0xFFF3EAFF),
accentColor = Color(0xFFA25FE0),
letter = "Я",
subtitle = "Русский, английский или язык устройства",
backgroundColor = Color(0xFFFFF8EC),
accentColor = Color(0xFFD7812B),
icon = Icons.Outlined.Language,
onClick = onLanguageClick,
keywords = listOf("язык", "русский", "локаль")
keywords = listOf("language", "язык", "русский", "английский", "локализация")
),
SettingsEntryModel(
title = "Прокси",
subtitle = "SOCKS/MTProto/HTTP профиль подключения",
subtitle = "SOCKS5/HTTP профиль подключения",
backgroundColor = Color(0xFFEAF7FB),
accentColor = Color(0xFF49A8C2),
letter = "П",
icon = Icons.Outlined.VpnKey,
onClick = onProxyClick,
keywords = listOf("proxy", "прокси", "socks", "mtproto", "сеть")
keywords = listOf("proxy", "прокси", "socks", "http", "сеть")
)
)
}
@@ -289,7 +276,7 @@ fun SettingsScreen(
subtitle = entry.subtitle,
backgroundColor = entry.backgroundColor,
accentColor = entry.accentColor,
letter = entry.letter,
icon = entry.icon,
onClick = entry.onClick
)
if (index != visibleEntries.lastIndex) {
@@ -387,7 +374,7 @@ private data class SettingsEntryModel(
val subtitle: String,
val backgroundColor: Color,
val accentColor: Color,
val letter: String,
val icon: ImageVector,
val onClick: (() -> Unit)?,
val keywords: List<String>
)
@@ -437,7 +424,7 @@ private fun SettingsEntry(
subtitle: String,
backgroundColor: Color,
accentColor: Color,
letter: String,
icon: ImageVector,
onClick: (() -> Unit)?
) {
IkarSettingRow(
@@ -451,11 +438,11 @@ private fun SettingsEntry(
shape = CircleShape
) {
Box(contentAlignment = Alignment.Center) {
Text(
text = letter,
style = MaterialTheme.typography.titleMedium,
color = accentColor,
fontWeight = FontWeight.SemiBold
Icon(
imageVector = icon,
contentDescription = null,
tint = accentColor,
modifier = Modifier.size(22.dp)
)
}
}
@@ -1,10 +1,15 @@
package com.seven.ikar.kotlin.feature.share
import android.Manifest
import android.content.pm.PackageManager
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.navigationBarsPadding
@@ -12,14 +17,17 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat
import com.seven.ikar.kotlin.core.contacts.DiscoverUserItem
import com.seven.ikar.kotlin.ui.AvatarView
import com.seven.ikar.kotlin.ui.IkarRowDivider
@@ -37,9 +45,18 @@ fun ShareTargetScreen(
state: ShareTargetUiState,
onBackClick: () -> Unit,
onQueryChanged: (String) -> Unit,
onChatClick: (ShareChatTargetItemUiState) -> Unit,
onRefresh: () -> Unit,
onUserClick: (DiscoverUserItem) -> Unit
) {
val context = LocalContext.current
val permissionLauncher = rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { granted ->
if (granted) {
onRefresh()
}
}
val hasContactsPermission =
ContextCompat.checkSelfPermission(context, Manifest.permission.READ_CONTACTS) == PackageManager.PERMISSION_GRANTED
IkarScreenSurface(modifier = Modifier.fillMaxSize()) {
Column(
modifier = Modifier
@@ -56,11 +73,26 @@ fun ShareTargetScreen(
IkarSearchCard(
value = state.query,
onValueChange = onQueryChanged,
placeholder = "Поиск чата или контакта"
placeholder = "Поиск контакта"
)
SharePreviewCard(state.payload)
if (!hasContactsPermission) {
IkarSectionCard {
Text("Нужен доступ к контактам", style = MaterialTheme.typography.headlineMedium)
Text(
"Разрешите доступ к телефонной книге, чтобы выбрать получателя из контактов.",
style = MaterialTheme.typography.bodyMedium,
color = TelegramInkMuted
)
Spacer(modifier = Modifier.padding(top = 8.dp))
Button(onClick = { permissionLauncher.launch(Manifest.permission.READ_CONTACTS) }) {
Text("Разрешить доступ")
}
}
}
state.errorMessage?.let {
Text(it, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodyMedium)
}
@@ -74,7 +106,7 @@ fun ShareTargetScreen(
padding = PaddingValues(0.dp)
) {
LazyColumn {
if (state.isLoading && state.chats.isEmpty() && state.contacts.isEmpty()) {
if (state.isLoading && state.contacts.isEmpty()) {
item {
Row(
modifier = Modifier
@@ -88,15 +120,6 @@ fun ShareTargetScreen(
}
}
if (state.chats.isNotEmpty()) {
item {
SectionLabel("Чаты")
}
items(state.chats, key = { it.chat.id }) { item ->
ShareChatRow(item = item, onClick = { onChatClick(item) })
}
}
if (state.contacts.isNotEmpty()) {
item {
SectionLabel("Контакты")
@@ -106,7 +129,7 @@ fun ShareTargetScreen(
}
}
if (!state.isLoading && state.chats.isEmpty() && state.contacts.isEmpty()) {
if (!state.isLoading && state.contacts.isEmpty()) {
item {
Column(
modifier = Modifier
@@ -115,7 +138,7 @@ fun ShareTargetScreen(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
Text("Подходящих получателей нет", style = MaterialTheme.typography.headlineMedium)
Text("Подходящих контактов нет", style = MaterialTheme.typography.headlineMedium)
Text(
"Уточните поиск или откройте доступ к контактам.",
color = TelegramInkMuted,
@@ -176,59 +199,6 @@ private fun SectionLabel(text: String) {
)
}
@Composable
private fun ShareChatRow(
item: ShareChatTargetItemUiState,
onClick: () -> Unit
) {
Column {
Row(
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onClick)
.padding(horizontal = 18.dp, vertical = 12.dp),
horizontalArrangement = Arrangement.spacedBy(14.dp),
verticalAlignment = Alignment.CenterVertically
) {
AvatarView(imageUrl = null, title = item.resolvedTitle, size = 54.dp)
Column(
modifier = Modifier.weight(1f),
verticalArrangement = Arrangement.spacedBy(3.dp)
) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
Text(
text = item.resolvedTitle,
modifier = Modifier.weight(1f),
style = MaterialTheme.typography.titleLarge,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
if (item.lastActivityLabel.isNotBlank()) {
Text(
text = item.lastActivityLabel,
style = MaterialTheme.typography.bodySmall,
color = TelegramInkMuted,
maxLines = 1
)
}
}
Text(
text = item.preview,
style = MaterialTheme.typography.bodySmall,
color = TelegramInkMuted,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
}
IkarRowDivider(startIndent = 86.dp)
}
}
@Composable
private fun ShareContactRow(
item: DiscoverUserItem,
@@ -10,8 +10,6 @@ import com.seven.ikar.kotlin.core.contacts.DiscoverUserItem
import com.seven.ikar.kotlin.core.contacts.PhoneNumberNormalizer
import com.seven.ikar.kotlin.core.model.ChatSummaryDto
import com.seven.ikar.kotlin.core.model.ChatType
import com.seven.ikar.kotlin.ui.chatPreview
import com.seven.ikar.kotlin.ui.formatLastActivity
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
@@ -19,17 +17,9 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
data class ShareChatTargetItemUiState(
val chat: ChatSummaryDto,
val resolvedTitle: String,
val lastActivityLabel: String,
val preview: String
)
data class ShareTargetUiState(
val payload: PendingExternalShare,
val query: String = "",
val chats: List<ShareChatTargetItemUiState> = emptyList(),
val contacts: List<DiscoverUserItem> = emptyList(),
val isLoading: Boolean = false,
val isSending: Boolean = false,
@@ -43,8 +33,6 @@ class ShareTargetViewModel(
) : ViewModel() {
private val _uiState = MutableStateFlow(ShareTargetUiState(payload = pendingShare))
private val _allChats = mutableListOf<ChatSummaryDto>()
private var contactNamesByNormalizedPhone: Map<String, String> = emptyMap()
private var currentUserId: String? = container.sessionStore.read()?.user?.id
private var searchJob: Job? = null
val uiState: StateFlow<ShareTargetUiState> = _uiState.asStateFlow()
@@ -67,18 +55,14 @@ class ShareTargetViewModel(
val query = _uiState.value.query.trim()
_uiState.value = _uiState.value.copy(isLoading = true, errorMessage = null)
runCatching {
contactNamesByNormalizedPhone = loadDeviceContactNames()
val chats = container.executeAuthorized { session ->
currentUserId = session.user.id
container.apiClient.getChats(session.accessToken)
}
_allChats.clear()
_allChats.addAll(chats)
val contacts = loadContactCandidates(query)
chats to contacts
}.onSuccess { (chats, contacts) ->
loadContactCandidates(query)
}.onSuccess { contacts ->
_uiState.value = _uiState.value.copy(
chats = filterChats(chats, query),
contacts = contacts,
isLoading = false,
errorMessage = null
@@ -89,10 +73,6 @@ class ShareTargetViewModel(
}
}
fun sendToChat(chat: ShareChatTargetItemUiState) {
sendToChatId(chat.chat.id)
}
fun sendToUser(user: DiscoverUserItem) {
if (_uiState.value.isSending) {
return
@@ -119,26 +99,6 @@ class ShareTargetViewModel(
_uiState.value = _uiState.value.copy(pendingChatId = null)
}
private fun sendToChatId(chatId: String) {
if (_uiState.value.isSending) {
return
}
viewModelScope.launch {
_uiState.value = _uiState.value.copy(isSending = true, errorMessage = null)
runCatching {
container.executeAuthorized { session ->
sendPendingShare(session.accessToken, chatId)
chatId
}
}.onSuccess { targetChatId ->
_uiState.value = _uiState.value.copy(isSending = false, pendingChatId = targetChatId)
}.onFailure { error ->
_uiState.value = _uiState.value.copy(isSending = false, errorMessage = error.message)
}
}
}
private suspend fun sendPendingShare(accessToken: String, chatId: String) {
val sharedText = pendingShare.text?.trim().orEmpty()
if (pendingShare.attachments.isEmpty()) {
@@ -155,32 +115,8 @@ class ShareTargetViewModel(
)
}
private fun filterChats(chats: List<ChatSummaryDto>, query: String): List<ShareChatTargetItemUiState> {
val items = chats
.filter { it.canSendMessages }
.map { chat ->
ShareChatTargetItemUiState(
chat = chat,
resolvedTitle = resolveChatTitle(chat),
lastActivityLabel = formatLastActivity(chat.lastActivityAt),
preview = chatPreview(chat)
)
}
if (query.isBlank()) {
return items
}
return items.filter { item ->
item.resolvedTitle.contains(query, ignoreCase = true) ||
item.chat.title.contains(query, ignoreCase = true) ||
item.chat.secondaryText.orEmpty().contains(query, ignoreCase = true) ||
item.chat.lastMessagePreview.orEmpty().contains(query, ignoreCase = true)
}
}
private suspend fun loadContactCandidates(query: String): List<DiscoverUserItem> {
val candidates = when {
return when {
hasContactsPermission() -> container.resolvedContactsRepository.loadMergedCandidates(query)
query.isBlank() -> emptyList()
else -> container.executeAuthorized { session ->
@@ -193,27 +129,6 @@ class ShareTargetViewModel(
}
}
}
val directParticipantIds = _allChats
.asSequence()
.filter { it.type == ChatType.Direct }
.flatMap { chat -> chat.participants.asSequence().map { participant -> participant.id } }
.toSet()
return candidates.filterNot { directParticipantIds.contains(it.id) }
}
private fun resolveChatTitle(chat: ChatSummaryDto): String {
if (chat.type != ChatType.Direct) {
return chat.title
}
val peer = chat.participants.firstOrNull { it.id != currentUserId } ?: chat.participants.firstOrNull()
val normalizedPhone = PhoneNumberNormalizer.normalize(peer?.phoneNumber)
val contactName = normalizedPhone?.let(contactNamesByNormalizedPhone::get)
return contactName?.takeIf { it.isNotBlank() }
?: peer?.displayName?.takeIf { it.isNotBlank() }
?: chat.title
}
private fun findExistingDirectChatId(userId: String): String? =
@@ -221,16 +136,6 @@ class ShareTargetViewModel(
chat.type == ChatType.Direct && chat.participants.any { participant -> participant.id == userId }
}?.id
private fun loadDeviceContactNames(): Map<String, String> {
if (!hasContactsPermission()) {
return emptyMap()
}
return container.contactDiscoveryService.getContacts().associate { contact ->
contact.normalizedPhoneNumber to contact.displayName
}
}
private fun hasContactsPermission(): Boolean =
ContextCompat.checkSelfPermission(
container.appContext,
@@ -18,6 +18,7 @@ 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.imePadding
import androidx.compose.foundation.layout.only
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawing
@@ -28,6 +29,9 @@ import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.automirrored.outlined.Send
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Add
@@ -44,20 +48,38 @@ import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import androidx.compose.ui.viewinterop.AndroidView
import androidx.media3.common.MediaItem
import androidx.media3.common.Player
import androidx.media3.datasource.DefaultHttpDataSource
import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.exoplayer.source.ProgressiveMediaSource
import androidx.media3.ui.AspectRatioFrameLayout
import androidx.media3.ui.PlayerView
import coil.compose.AsyncImage
import com.seven.ikar.kotlin.core.model.AttachmentDto
import com.seven.ikar.kotlin.core.model.StoryDto
import com.seven.ikar.kotlin.core.model.StoryViewDto
import com.seven.ikar.kotlin.core.model.StoryVisibility
import com.seven.ikar.kotlin.core.network.ServerConfig
import com.seven.ikar.kotlin.ui.AuthenticatedAsyncImage
@@ -72,8 +94,12 @@ import com.seven.ikar.kotlin.ui.IkarToggleRow
import com.seven.ikar.kotlin.ui.SecureWindowEffect
import com.seven.ikar.kotlin.ui.theme.TelegramBlue
import com.seven.ikar.kotlin.ui.theme.TelegramInkMuted
import kotlinx.coroutines.delay
import java.io.File
private const val StoryAutoAdvanceDurationMillis = 5_000L
private const val StoryAutoAdvanceTickMillis = 50L
@Composable
fun StoriesScreen(
state: StoriesUiState,
@@ -85,6 +111,11 @@ fun StoriesScreen(
onCloseStoryPreview: () -> Unit,
onNextStory: () -> Unit,
onPreviousStory: () -> Unit,
onStoryReplyTextChanged: (String) -> Unit,
onSendStoryReply: (StoryDto) -> Unit,
onSendStoryReaction: (StoryDto, String) -> Unit,
onShowStoryViews: (StoryDto) -> Unit,
onCloseStoryViews: () -> Unit,
onRetryTransfer: (String) -> Unit,
onCancelTransfer: (String) -> Unit
) {
@@ -96,9 +127,22 @@ fun StoriesScreen(
stories = state.stories,
accessToken = state.accessToken,
isOwnStory = story.author.id == state.currentUserId,
replyText = state.storyReplyText,
isSendingReply = state.isSendingStoryReply,
replyErrorMessage = state.storyReplyErrorMessage,
replySent = state.storyReplySentStoryId == story.id,
storyViews = state.storyViews.takeIf { state.storyViewsStoryId == story.id }.orEmpty(),
isLoadingStoryViews = state.isLoadingStoryViews && state.storyViewsStoryId == story.id,
storyViewsErrorMessage = state.storyViewsErrorMessage.takeIf { state.storyViewsStoryId == story.id },
showStoryViews = state.storyViewsStoryId == story.id,
onClose = onCloseStoryPreview,
onNextStory = onNextStory,
onPreviousStory = onPreviousStory,
onReplyTextChanged = onStoryReplyTextChanged,
onSendReply = { onSendStoryReply(story) },
onSendReaction = { emoji -> onSendStoryReaction(story, emoji) },
onShowStoryViews = { onShowStoryViews(story) },
onCloseStoryViews = onCloseStoryViews,
onDeleteStory = { onDeleteStory(story) }
)
}
@@ -346,9 +390,22 @@ private fun StoryViewerDialog(
stories: List<StoryDto>,
accessToken: String?,
isOwnStory: Boolean,
replyText: String,
isSendingReply: Boolean,
replyErrorMessage: String?,
replySent: Boolean,
storyViews: List<StoryViewDto>,
isLoadingStoryViews: Boolean,
storyViewsErrorMessage: String?,
showStoryViews: Boolean,
onClose: () -> Unit,
onNextStory: () -> Unit,
onPreviousStory: () -> Unit,
onReplyTextChanged: (String) -> Unit,
onSendReply: () -> Unit,
onSendReaction: (String) -> Unit,
onShowStoryViews: () -> Unit,
onCloseStoryViews: () -> Unit,
onDeleteStory: () -> Unit
) {
Dialog(
@@ -356,26 +413,64 @@ private fun StoryViewerDialog(
properties = DialogProperties(usePlatformDefaultWidth = false)
) {
BackHandler(onBack = onClose)
var selectedMediaIndex by remember(story.id, story.attachments.size) { mutableIntStateOf(0) }
val lastMediaIndex = (story.attachments.size - 1).coerceAtLeast(0)
val safeMediaIndex = selectedMediaIndex.coerceIn(0, lastMediaIndex)
var autoProgress by remember(story.id, safeMediaIndex) { mutableFloatStateOf(0f) }
val autoAdvancePaused = showStoryViews || replyText.isNotBlank() || isSendingReply
LaunchedEffect(story.id, safeMediaIndex, autoAdvancePaused) {
autoProgress = 0f
if (autoAdvancePaused) {
return@LaunchedEffect
}
var elapsedMillis = 0L
while (elapsedMillis < StoryAutoAdvanceDurationMillis) {
delay(StoryAutoAdvanceTickMillis)
elapsedMillis += StoryAutoAdvanceTickMillis
autoProgress = (elapsedMillis.toFloat() / StoryAutoAdvanceDurationMillis)
.coerceIn(0f, 1f)
}
if (story.attachments.isNotEmpty() && safeMediaIndex < story.attachments.lastIndex) {
selectedMediaIndex = safeMediaIndex + 1
} else {
onNextStory()
}
}
Box(
modifier = Modifier
.fillMaxSize()
.background(Color.Black)
) {
StoryViewerContent(story = story, accessToken = accessToken)
StoryViewerContent(story = story, accessToken = accessToken, mediaIndex = safeMediaIndex)
Row(modifier = Modifier.fillMaxSize()) {
Box(
modifier = Modifier
.weight(1f)
.fillMaxSize()
.clickable(onClick = onPreviousStory)
.clickable {
if (safeMediaIndex > 0) {
selectedMediaIndex = safeMediaIndex - 1
} else {
onPreviousStory()
}
}
)
Box(
modifier = Modifier
.weight(1f)
.fillMaxSize()
.clickable(onClick = onNextStory)
.clickable {
if (story.attachments.isNotEmpty() && safeMediaIndex < story.attachments.lastIndex) {
selectedMediaIndex = safeMediaIndex + 1
} else {
onNextStory()
}
}
)
}
@@ -384,12 +479,30 @@ private fun StoryViewerDialog(
stories = stories,
accessToken = accessToken,
isOwnStory = isOwnStory,
mediaIndex = safeMediaIndex,
mediaCount = story.attachments.size,
progress = autoProgress,
onClose = onClose,
onDeleteStory = onDeleteStory
)
StoryViewerCaption(
StoryViewerBottomBar(
story = story,
isOwnStory = isOwnStory,
replyText = replyText,
isSendingReply = isSendingReply,
replyErrorMessage = replyErrorMessage,
replySent = replySent,
storyViews = storyViews,
isLoadingStoryViews = isLoadingStoryViews,
storyViewsErrorMessage = storyViewsErrorMessage,
showStoryViews = showStoryViews,
accessToken = accessToken,
onReplyTextChanged = onReplyTextChanged,
onSendReply = onSendReply,
onSendReaction = onSendReaction,
onShowStoryViews = onShowStoryViews,
onCloseStoryViews = onCloseStoryViews,
modifier = Modifier.align(Alignment.BottomCenter)
)
}
@@ -397,34 +510,38 @@ private fun StoryViewerDialog(
}
@Composable
private fun StoryViewerContent(story: StoryDto, accessToken: String?) {
val imageAttachment = story.attachments.firstOrNull { it.contentType.startsWith("image/") }
if (imageAttachment != null) {
private fun StoryViewerContent(story: StoryDto, accessToken: String?, mediaIndex: Int) {
val attachment = story.attachments.getOrNull(mediaIndex) ?: story.attachments.firstOrNull()
if (attachment != null && attachment.contentType.startsWith("image/", ignoreCase = true)) {
AuthenticatedAsyncImage(
imageUrl = ServerConfig.normalizeMediaUrl(imageAttachment.downloadPath),
imageUrl = ServerConfig.normalizeMediaUrl(attachment.downloadPath),
accessToken = accessToken,
contentDescription = imageAttachment.fileName,
contentDescription = attachment.fileName,
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop
)
return
}
if (attachment != null && attachment.contentType.startsWith("video/", ignoreCase = true)) {
StoryVideoPlayer(attachment = attachment, accessToken = accessToken)
return
}
Box(
modifier = Modifier
.fillMaxSize()
.padding(28.dp),
contentAlignment = Alignment.Center
) {
if (story.attachments.isNotEmpty()) {
val first = story.attachments.first()
if (attachment != null) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(10.dp)
) {
Text("Видео", style = MaterialTheme.typography.headlineMedium, color = Color.White)
Text(
first.fileName,
attachment.fileName,
style = MaterialTheme.typography.bodyMedium,
color = Color.White.copy(alpha = 0.72f),
textAlign = TextAlign.Center,
@@ -443,12 +560,58 @@ private fun StoryViewerContent(story: StoryDto, accessToken: String?) {
}
}
@Composable
private fun StoryVideoPlayer(attachment: AttachmentDto, accessToken: String?) {
val context = LocalContext.current
val videoUrl = remember(attachment.downloadPath) {
ServerConfig.normalizeMediaUrl(attachment.downloadPath)
}
if (videoUrl.isNullOrBlank()) {
return
}
val player = remember(videoUrl, accessToken) {
val dataSourceFactory = DefaultHttpDataSource.Factory().apply {
accessToken?.takeIf { it.isNotBlank() }?.let { token ->
setDefaultRequestProperties(mapOf("Authorization" to "Bearer $token"))
}
}
ExoPlayer.Builder(context).build().apply {
repeatMode = Player.REPEAT_MODE_ONE
playWhenReady = true
setMediaSource(ProgressiveMediaSource.Factory(dataSourceFactory).createMediaSource(MediaItem.fromUri(videoUrl)))
prepare()
}
}
DisposableEffect(player) {
onDispose { player.release() }
}
AndroidView(
modifier = Modifier.fillMaxSize(),
factory = { viewContext ->
PlayerView(viewContext).apply {
useController = false
resizeMode = AspectRatioFrameLayout.RESIZE_MODE_ZOOM
this.player = player
}
},
update = { view ->
view.player = player
}
)
}
@Composable
private fun StoryViewerTopBar(
story: StoryDto,
stories: List<StoryDto>,
accessToken: String?,
isOwnStory: Boolean,
mediaIndex: Int,
mediaCount: Int,
progress: Float,
onClose: () -> Unit,
onDeleteStory: () -> Unit
) {
@@ -459,7 +622,13 @@ private fun StoryViewerTopBar(
.padding(horizontal = 14.dp, vertical = 12.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
StoryProgressSegments(story = story, stories = stories)
StoryProgressSegments(
story = story,
stories = stories,
mediaIndex = mediaIndex,
mediaCount = mediaCount,
progress = progress
)
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
@@ -498,41 +667,111 @@ private fun StoryViewerTopBar(
}
@Composable
private fun StoryProgressSegments(story: StoryDto, stories: List<StoryDto>) {
private fun StoryProgressSegments(
story: StoryDto,
stories: List<StoryDto>,
mediaIndex: Int,
mediaCount: Int,
progress: Float
) {
if (mediaCount > 1) {
Row(horizontalArrangement = Arrangement.spacedBy(4.dp), modifier = Modifier.fillMaxWidth()) {
repeat(mediaCount.coerceAtMost(10)) { index ->
StoryProgressSegment(
progress = when {
index < mediaIndex -> 1f
index == mediaIndex -> progress
else -> 0f
},
modifier = Modifier.weight(1f)
)
}
}
return
}
val selectedIndex = stories.indexOfFirst { it.id == story.id }
val firstIndex = if (selectedIndex < 0) 0 else (selectedIndex - 6).coerceAtLeast(0)
val segmentStories = stories.drop(firstIndex).take(12).ifEmpty { listOf(story) }
val activeSegmentIndex = segmentStories.indexOfFirst { it.id == story.id }.coerceAtLeast(0)
Row(horizontalArrangement = Arrangement.spacedBy(4.dp), modifier = Modifier.fillMaxWidth()) {
segmentStories.forEach { item ->
Box(
modifier = Modifier
.weight(1f)
.height(3.dp)
.clip(RoundedCornerShape(2.dp))
.background(if (item.id == story.id) Color.White else Color.White.copy(alpha = 0.34f))
segmentStories.forEachIndexed { index, _ ->
StoryProgressSegment(
progress = when {
index < activeSegmentIndex -> 1f
index == activeSegmentIndex -> progress
else -> 0f
},
modifier = Modifier.weight(1f)
)
}
}
}
@Composable
private fun StoryViewerCaption(story: StoryDto, modifier: Modifier = Modifier) {
private fun StoryProgressSegment(progress: Float, modifier: Modifier = Modifier) {
Box(
modifier = modifier
.height(3.dp)
.clip(RoundedCornerShape(2.dp))
.background(Color.White.copy(alpha = 0.34f))
) {
Box(
modifier = Modifier
.fillMaxWidth(progress.coerceIn(0f, 1f))
.height(3.dp)
.background(Color.White)
)
}
}
@Composable
private fun StoryViewerBottomBar(
story: StoryDto,
isOwnStory: Boolean,
replyText: String,
isSendingReply: Boolean,
replyErrorMessage: String?,
replySent: Boolean,
storyViews: List<StoryViewDto>,
isLoadingStoryViews: Boolean,
storyViewsErrorMessage: String?,
showStoryViews: Boolean,
accessToken: String?,
onReplyTextChanged: (String) -> Unit,
onSendReply: () -> Unit,
onSendReaction: (String) -> Unit,
onShowStoryViews: () -> Unit,
onCloseStoryViews: () -> Unit,
modifier: Modifier = Modifier
) {
Column(
modifier = modifier
.fillMaxWidth()
.imePadding()
.background(Color.Black.copy(alpha = 0.54f))
.padding(horizontal = 18.dp, vertical = 16.dp),
verticalArrangement = Arrangement.spacedBy(6.dp)
verticalArrangement = Arrangement.spacedBy(10.dp)
) {
if (story.attachments.isNotEmpty() && story.text.isNotBlank()) {
Text(story.text, style = MaterialTheme.typography.bodyLarge, color = Color.White)
}
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
"${story.viewCount} просмотров",
style = MaterialTheme.typography.bodySmall,
color = Color.White.copy(alpha = 0.72f)
)
if (isOwnStory) {
TextButton(onClick = if (showStoryViews) onCloseStoryViews else onShowStoryViews) {
Text(
if (showStoryViews) "Скрыть просмотры" else "${story.viewCount} просмотров",
style = MaterialTheme.typography.bodySmall,
color = Color.White
)
}
} else {
Text(
"${story.viewCount} просмотров",
style = MaterialTheme.typography.bodySmall,
color = Color.White.copy(alpha = 0.72f)
)
}
if (story.hasProtectedContent) {
Spacer(modifier = Modifier.width(10.dp))
Text(
@@ -542,6 +781,204 @@ private fun StoryViewerCaption(story: StoryDto, modifier: Modifier = Modifier) {
)
}
}
if (isOwnStory && showStoryViews) {
StoryViewsPanel(
views = storyViews,
isLoading = isLoadingStoryViews,
errorMessage = storyViewsErrorMessage,
accessToken = accessToken
)
}
if (!isOwnStory) {
StoryReplyComposer(
replyText = replyText,
isSendingReply = isSendingReply,
replySent = replySent,
replyErrorMessage = replyErrorMessage,
onReplyTextChanged = onReplyTextChanged,
onSendReply = onSendReply,
onSendReaction = onSendReaction
)
}
}
}
@Composable
private fun StoryReplyComposer(
replyText: String,
isSendingReply: Boolean,
replySent: Boolean,
replyErrorMessage: String?,
onReplyTextChanged: (String) -> Unit,
onSendReply: () -> Unit,
onSendReaction: (String) -> Unit
) {
val canSend = replyText.isNotBlank() && !isSendingReply
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
StoryQuickReactionRow(
enabled = !isSendingReply,
onSendReaction = onSendReaction
)
Surface(
color = Color.White.copy(alpha = 0.14f),
shape = RoundedCornerShape(24.dp),
modifier = Modifier.fillMaxWidth()
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(start = 16.dp, top = 6.dp, bottom = 6.dp, end = 4.dp),
verticalAlignment = Alignment.CenterVertically
) {
BasicTextField(
value = replyText,
onValueChange = onReplyTextChanged,
enabled = !isSendingReply,
singleLine = true,
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Send),
keyboardActions = KeyboardActions(onSend = { if (canSend) onSendReply() }),
modifier = Modifier.weight(1f),
textStyle = MaterialTheme.typography.bodyLarge.copy(color = Color.White),
decorationBox = { inner ->
if (replyText.isBlank()) {
Text(
"Сообщение",
color = Color.White.copy(alpha = 0.62f),
style = MaterialTheme.typography.bodyLarge
)
}
inner()
}
)
IconButton(
onClick = onSendReply,
enabled = canSend
) {
Icon(
imageVector = Icons.AutoMirrored.Outlined.Send,
contentDescription = "Отправить",
tint = if (canSend) Color.White else Color.White.copy(alpha = 0.34f)
)
}
}
}
replyErrorMessage?.let { message ->
Text(message, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.error)
}
if (replySent) {
Text("Отправлено", style = MaterialTheme.typography.bodySmall, color = Color.White.copy(alpha = 0.72f))
}
}
}
@Composable
private fun StoryQuickReactionRow(
enabled: Boolean,
onSendReaction: (String) -> Unit
) {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
StoryQuickReactions.forEach { emoji ->
Surface(
color = Color.White.copy(alpha = if (enabled) 0.16f else 0.08f),
shape = CircleShape,
modifier = Modifier
.size(40.dp)
.clickable(enabled = enabled) { onSendReaction(emoji) }
) {
Box(contentAlignment = Alignment.Center) {
Text(emoji, style = MaterialTheme.typography.titleMedium)
}
}
}
}
}
private val StoryQuickReactions = listOf("❤️", "👍", "🔥", "👏", "😂", "😮")
@Composable
private fun StoryViewsPanel(
views: List<StoryViewDto>,
isLoading: Boolean,
errorMessage: String?,
accessToken: String?
) {
Surface(
color = Color.White.copy(alpha = 0.12f),
shape = RoundedCornerShape(18.dp),
modifier = Modifier.fillMaxWidth()
) {
Column(
modifier = Modifier.padding(horizontal = 12.dp, vertical = 10.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
Text(
text = "Просмотрели",
style = MaterialTheme.typography.labelLarge,
color = Color.White.copy(alpha = 0.84f)
)
when {
isLoading -> Text(
text = "Загрузка...",
style = MaterialTheme.typography.bodySmall,
color = Color.White.copy(alpha = 0.72f)
)
!errorMessage.isNullOrBlank() -> Text(
text = errorMessage,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error
)
views.isEmpty() -> Text(
text = "Пока никто не посмотрел",
style = MaterialTheme.typography.bodySmall,
color = Color.White.copy(alpha = 0.72f)
)
else -> {
views.take(8).forEach { view ->
StoryViewRow(view = view, accessToken = accessToken)
}
if (views.size > 8) {
Text(
text = "И ещё ${views.size - 8}",
style = MaterialTheme.typography.bodySmall,
color = Color.White.copy(alpha = 0.72f)
)
}
}
}
}
}
}
@Composable
private fun StoryViewRow(view: StoryViewDto, accessToken: String?) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(10.dp),
verticalAlignment = Alignment.CenterVertically
) {
AvatarView(
imageUrl = ServerConfig.normalizeMediaUrl(view.viewer.avatarPath),
title = view.viewer.displayName,
accessToken = accessToken,
size = 34.dp
)
Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(1.dp)) {
Text(
text = view.viewer.displayName.ifBlank { "@${view.viewer.username}" },
style = MaterialTheme.typography.bodyMedium,
color = Color.White,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Text(
text = view.viewedAt.shortDateTime(),
style = MaterialTheme.typography.bodySmall,
color = Color.White.copy(alpha = 0.68f)
)
}
}
}
@@ -6,6 +6,7 @@ import androidx.lifecycle.viewModelScope
import com.seven.ikar.kotlin.app.AppContainer
import com.seven.ikar.kotlin.core.local.TransferQueueEntity
import com.seven.ikar.kotlin.core.model.StoryDto
import com.seven.ikar.kotlin.core.model.StoryViewDto
import com.seven.ikar.kotlin.core.model.StoryVisibility
import com.seven.ikar.kotlin.feature.chat.LocalAttachmentFile
import kotlinx.coroutines.flow.MutableStateFlow
@@ -43,7 +44,16 @@ data class StoriesUiState(
val isImportingMedia: Boolean = false,
val errorMessage: String? = null,
val createdStoryId: String? = null,
val selectedStory: StoryDto? = null
val selectedStory: StoryDto? = null,
val storyReplyText: String = "",
val isSendingStoryReply: Boolean = false,
val storyReplyErrorMessage: String? = null,
val storyReplySentStoryId: String? = null,
val storyViewsStoryId: String? = null,
val storyViews: List<StoryViewDto> = emptyList(),
val isLoadingStoryViews: Boolean = false,
val storyViewsErrorMessage: String? = null,
val selectedStoryAuthorId: String? = null
)
class StoriesViewModel(private val container: AppContainer) : ViewModel() {
@@ -54,6 +64,7 @@ class StoriesViewModel(private val container: AppContainer) : ViewModel() {
init {
attachStoryTransferState()
attachRealtime()
refresh()
}
@@ -81,6 +92,14 @@ class StoriesViewModel(private val container: AppContainer) : ViewModel() {
_uiState.value = _uiState.value.copy(draftText = value.take(500), errorMessage = null, createdStoryId = null)
}
fun updateStoryReplyText(value: String) {
_uiState.value = _uiState.value.copy(
storyReplyText = value.take(1_000),
storyReplyErrorMessage = null,
storyReplySentStoryId = null
)
}
fun setVisibility(visibility: StoryVisibility) {
_uiState.value = _uiState.value.copy(visibility = visibility)
}
@@ -182,6 +201,7 @@ class StoriesViewModel(private val container: AppContainer) : ViewModel() {
isSaving = false,
createdStoryId = story.id
)
container.realtimeService.notifyStoriesChangedLocally()
onCreated()
}.onFailure { error ->
_uiState.value = _uiState.value.copy(isSaving = false, errorMessage = error.message)
@@ -191,7 +211,18 @@ class StoriesViewModel(private val container: AppContainer) : ViewModel() {
fun openStory(story: StoryDto) {
viewModelScope.launch {
_uiState.value = _uiState.value.copy(selectedStory = story, errorMessage = null)
_uiState.value = _uiState.value.copy(
selectedStory = story,
selectedStoryAuthorId = story.author.id,
errorMessage = null,
storyReplyText = "",
storyReplyErrorMessage = null,
storyReplySentStoryId = null,
storyViewsStoryId = null,
storyViews = emptyList(),
isLoadingStoryViews = false,
storyViewsErrorMessage = null
)
runCatching {
container.executeAuthorized { session ->
container.apiClient.getStory(session.accessToken, story.id)
@@ -199,6 +230,7 @@ class StoriesViewModel(private val container: AppContainer) : ViewModel() {
}.onSuccess { viewedStory ->
_uiState.value = _uiState.value.copy(
selectedStory = viewedStory,
selectedStoryAuthorId = viewedStory.author.id,
stories = _uiState.value.stories.map { if (it.id == viewedStory.id) viewedStory else it }
)
}.onFailure { error ->
@@ -223,8 +255,16 @@ class StoriesViewModel(private val container: AppContainer) : ViewModel() {
}.onSuccess { storyFromServer ->
_uiState.value = _uiState.value.copy(
selectedStory = storyFromServer,
selectedStoryAuthorId = storyFromServer.author.id,
stories = listOf(storyFromServer) + _uiState.value.stories.filterNot { it.id == storyFromServer.id },
isLoading = false
isLoading = false,
storyReplyText = "",
storyReplyErrorMessage = null,
storyReplySentStoryId = null,
storyViewsStoryId = null,
storyViews = emptyList(),
isLoadingStoryViews = false,
storyViewsErrorMessage = null
)
}.onFailure { error ->
_uiState.value = _uiState.value.copy(isLoading = false, errorMessage = error.message)
@@ -241,7 +281,112 @@ class StoriesViewModel(private val container: AppContainer) : ViewModel() {
}
fun closeStoryPreview() {
_uiState.value = _uiState.value.copy(selectedStory = null)
_uiState.value = _uiState.value.copy(
selectedStory = null,
selectedStoryAuthorId = null,
storyReplyText = "",
storyReplyErrorMessage = null,
storyReplySentStoryId = null,
storyViewsStoryId = null,
storyViews = emptyList(),
isLoadingStoryViews = false,
storyViewsErrorMessage = null
)
}
fun loadStoryViews(story: StoryDto) {
if (story.author.id != _uiState.value.currentUserId || _uiState.value.isLoadingStoryViews) {
return
}
viewModelScope.launch {
_uiState.value = _uiState.value.copy(
storyViewsStoryId = story.id,
isLoadingStoryViews = true,
storyViewsErrorMessage = null
)
runCatching {
container.executeAuthorized { session ->
container.apiClient.getStoryViews(session.accessToken, story.id)
}
}.onSuccess { views ->
_uiState.value = _uiState.value.copy(
storyViews = views,
isLoadingStoryViews = false
)
}.onFailure { error ->
_uiState.value = _uiState.value.copy(
isLoadingStoryViews = false,
storyViewsErrorMessage = error.message ?: "Не удалось загрузить просмотры."
)
}
}
}
fun closeStoryViews() {
_uiState.value = _uiState.value.copy(
storyViewsStoryId = null,
storyViews = emptyList(),
isLoadingStoryViews = false,
storyViewsErrorMessage = null
)
}
fun sendStoryReply(story: StoryDto) {
val state = _uiState.value
val text = state.storyReplyText.trim()
if (text.isBlank() || state.isSendingStoryReply) {
return
}
sendStoryMessage(story, text, clearDraft = true)
}
fun sendStoryReaction(story: StoryDto, emoji: String) {
val text = emoji.trim().take(16)
if (text.isBlank() || _uiState.value.isSendingStoryReply) {
return
}
sendStoryMessage(story, text, clearDraft = false)
}
private fun sendStoryMessage(story: StoryDto, text: String, clearDraft: Boolean) {
val state = _uiState.value
if (story.author.id == state.currentUserId) {
_uiState.value = state.copy(storyReplyErrorMessage = "Нельзя отправить ответ себе.")
return
}
viewModelScope.launch {
_uiState.value = _uiState.value.copy(
isSendingStoryReply = true,
storyReplyErrorMessage = null,
storyReplySentStoryId = null
)
runCatching {
container.executeAuthorized { session ->
val chat = container.apiClient.createDirectChat(session.accessToken, story.author.id)
container.apiClient.sendMessage(
chatId = chat.id,
text = text,
accessToken = session.accessToken,
storyReplyStoryId = story.id
)
}
}.onSuccess {
_uiState.value = _uiState.value.copy(
storyReplyText = if (clearDraft) "" else _uiState.value.storyReplyText,
isSendingStoryReply = false,
storyReplySentStoryId = story.id
)
}.onFailure { error ->
_uiState.value = _uiState.value.copy(
isSendingStoryReply = false,
storyReplyErrorMessage = error.message ?: "Не удалось отправить сообщение."
)
}
}
}
fun deleteStory(story: StoryDto) {
@@ -253,6 +398,7 @@ class StoriesViewModel(private val container: AppContainer) : ViewModel() {
stories = _uiState.value.stories.filterNot { it.id == story.id },
selectedStory = _uiState.value.selectedStory?.takeUnless { it.id == story.id }
)
container.realtimeService.notifyStoriesChangedLocally()
}.onFailure { error ->
_uiState.value = _uiState.value.copy(errorMessage = error.message)
}
@@ -286,10 +432,21 @@ class StoriesViewModel(private val container: AppContainer) : ViewModel() {
}
}
private fun attachRealtime() {
viewModelScope.launch {
container.realtimeService.storiesChanged.collect {
refresh()
}
}
}
private fun openRelativeStory(offset: Int) {
val state = _uiState.value
val selected = state.selectedStory ?: return
val stories = state.stories
val stories = state.selectedStoryAuthorId
?.let { authorId -> state.stories.filter { it.author.id == authorId } }
?.takeIf { it.isNotEmpty() }
?: state.stories
if (stories.isEmpty()) {
return
}
@@ -113,14 +113,16 @@ class AttachmentUploadWorker(
ttlSeconds = first.ttlSeconds ?: 86_400,
files = files
)
} else if (files.size == 1) {
container.apiClient.uploadAttachment(
accessToken = session.accessToken,
chatId = chatId!!,
text = first.text,
replyToMessageId = first.replyToMessageId,
file = files.first()
)
} else if (files.size == 1 || !files.all(::isMediaAlbumFile)) {
files.forEachIndexed { index, file ->
container.apiClient.uploadAttachment(
accessToken = session.accessToken,
chatId = chatId!!,
text = first.text.takeIf { index == 0 },
replyToMessageId = first.replyToMessageId.takeIf { index == 0 },
file = file
)
}
} else {
container.apiClient.uploadAttachmentAlbum(
accessToken = session.accessToken,
@@ -134,6 +136,9 @@ class AttachmentUploadWorker(
}.fold(
onSuccess = {
dao.updateGroupStatus(groupId, TransferQueueStatus.Completed, 100, null, System.currentTimeMillis())
if (isStoryUpload) {
container.realtimeService.notifyStoriesChangedLocally()
}
Result.success()
},
onFailure = { error ->
@@ -155,6 +160,23 @@ class AttachmentUploadWorker(
}
}
private fun isMediaAlbumFile(file: UploadFileSource): Boolean {
val contentType = file.contentType
?.substringBefore(';')
?.trim()
?.lowercase()
if (contentType != null && (contentType.startsWith("image/") || contentType.startsWith("video/"))) {
return true
}
val extension = file.fileName
.substringBefore('?')
.substringBefore('#')
.substringAfterLast('.', missingDelimiterValue = "")
.lowercase()
return extension in setOf("jpg", "jpeg", "png", "gif", "webp", "bmp", "mp4", "m4v", "mov", "webm", "mkv")
}
private fun String?.toStoryVisibility(): StoryVisibility =
runCatching { StoryVisibility.valueOf(this ?: StoryVisibility.Everyone.name) }
.getOrDefault(StoryVisibility.Everyone)
@@ -111,7 +111,7 @@ fun AppUpdateScreen(
state.latestManifest?.let { manifest ->
UpdateFieldRow(
title = "Последний релиз Argus",
value = "${manifest.release.version} (${manifest.release.androidVersionCode ?: "?"})"
value = "${manifest.release.resolvedVersionName} (${manifest.release.resolvedVersionCode ?: "?"})"
)
}
}
@@ -140,7 +140,7 @@ fun AppUpdateScreen(
Spacer(modifier = Modifier.height(8.dp))
UpdateFieldRow(
title = "Версия",
value = "${update.manifest.release.version} (${update.manifest.release.androidVersionCode ?: "?"})"
value = "${update.manifest.release.resolvedVersionName} (${update.manifest.release.resolvedVersionCode ?: "?"})"
)
UpdateFieldRow(
title = "Размер APK",
@@ -126,7 +126,7 @@ class AppUpdateViewModel(private val container: AppContainer) : ViewModel() {
availableUpdate = result.update,
latestManifest = manifest,
statusTitle = "Проверка проведена",
statusDetail = "Обновления есть: версия ${manifest.release.version} (${manifest.release.androidVersionCode ?: "?"})."
statusDetail = "Обновления есть: версия ${manifest.release.resolvedVersionName} (${manifest.release.resolvedVersionCode ?: "?"})."
)
}
@@ -8,6 +8,7 @@ import androidx.compose.ui.platform.LocalContext
import coil.compose.AsyncImage
import coil.request.CachePolicy
import coil.request.ImageRequest
import java.security.MessageDigest
@Composable
fun AuthenticatedAsyncImage(
@@ -24,9 +25,11 @@ fun AuthenticatedAsyncImage(
val resolvedAccessToken = accessToken ?: LocalIkarAccessToken.current
val context = LocalContext.current
val request = remember(imageUrl, resolvedAccessToken) {
val diskCacheKey = buildAuthenticatedDiskCacheKey(imageUrl, resolvedAccessToken)
ImageRequest.Builder(context)
.data(imageUrl)
.diskCacheKey(imageUrl)
.diskCacheKey(diskCacheKey)
.memoryCacheKey(diskCacheKey)
.diskCachePolicy(CachePolicy.ENABLED)
.memoryCachePolicy(CachePolicy.ENABLED)
.apply {
@@ -45,3 +48,14 @@ fun AuthenticatedAsyncImage(
contentScale = contentScale
)
}
private fun buildAuthenticatedDiskCacheKey(imageUrl: String, accessToken: String?): String {
if (accessToken.isNullOrBlank()) {
return imageUrl
}
val digest = MessageDigest.getInstance("SHA-256")
.digest(accessToken.toByteArray(Charsets.UTF_8))
.joinToString(separator = "") { byte -> "%02x".format(byte) }
return "$imageUrl#auth:$digest"
}
@@ -1,5 +1,10 @@
package com.seven.ikar.kotlin.ui
import androidx.compose.animation.core.RepeatMode
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxHeight
@@ -15,6 +20,7 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.unit.dp
import com.seven.ikar.kotlin.core.settings.DEFAULT_CHAT_WALLPAPER_PRESET_ID
@@ -166,6 +172,7 @@ object ChatWallpaperCatalog {
@Composable
fun ChatWallpaperBackground(
snapshot: ChatSettingsSnapshot,
animated: Boolean = false,
modifier: Modifier = Modifier
) {
Box(modifier = modifier) {
@@ -188,6 +195,7 @@ fun ChatWallpaperBackground(
} else {
PresetWallpaperBackground(
preset = ChatWallpaperCatalog.resolvePreset(snapshot.selectedWallpaperPresetId),
animated = animated,
modifier = Modifier.fillMaxSize()
)
}
@@ -197,8 +205,10 @@ fun ChatWallpaperBackground(
@Composable
fun PresetWallpaperBackground(
preset: ChatWallpaperPreset,
animated: Boolean = false,
modifier: Modifier = Modifier
) {
val motion = wallpaperMotion(animated)
Box(
modifier = modifier.background(
Brush.linearGradient(colors = preset.gradientColors)
@@ -210,6 +220,7 @@ fun PresetWallpaperBackground(
modifier = Modifier
.align(Alignment.TopEnd)
.offset(x = 36.dp, y = (-18).dp)
.wallpaperMotion(motion, x = 10f, y = -6f)
.size(132.dp),
color = preset.accentPrimary
)
@@ -217,6 +228,7 @@ fun PresetWallpaperBackground(
modifier = Modifier
.align(Alignment.BottomStart)
.offset(x = (-28).dp, y = 24.dp)
.wallpaperMotion(motion, x = -8f, y = 7f)
.size(156.dp),
color = preset.accentSecondary
)
@@ -227,6 +239,7 @@ fun PresetWallpaperBackground(
modifier = Modifier
.align(Alignment.TopStart)
.offset(x = (-40).dp, y = (-24).dp)
.wallpaperMotion(motion, x = -7f, y = 8f)
.size(150.dp),
color = preset.accentPrimary
)
@@ -234,6 +247,7 @@ fun PresetWallpaperBackground(
modifier = Modifier
.align(Alignment.CenterEnd)
.offset(x = 28.dp)
.wallpaperMotion(motion, x = 9f, y = 4f)
.size(124.dp),
color = preset.accentSecondary
)
@@ -241,6 +255,7 @@ fun PresetWallpaperBackground(
modifier = Modifier
.align(Alignment.BottomCenter)
.offset(y = 34.dp)
.wallpaperMotion(motion, x = 5f, y = -7f)
.size(108.dp),
color = preset.accentPrimary.copy(alpha = 0.45f)
)
@@ -251,6 +266,7 @@ fun PresetWallpaperBackground(
modifier = Modifier
.align(Alignment.TopStart)
.offset(x = (-18).dp, y = 22.dp)
.wallpaperMotion(motion, x = 8f, y = 5f)
.fillMaxWidth(0.9f)
.height(64.dp),
color = preset.accentPrimary
@@ -259,6 +275,7 @@ fun PresetWallpaperBackground(
modifier = Modifier
.align(Alignment.BottomEnd)
.offset(x = 22.dp, y = (-14).dp)
.wallpaperMotion(motion, x = -8f, y = -4f)
.fillMaxWidth(0.76f)
.height(56.dp),
color = preset.accentSecondary
@@ -268,6 +285,35 @@ fun PresetWallpaperBackground(
}
}
@Composable
private fun wallpaperMotion(animated: Boolean): Float {
if (!animated) {
return 0f
}
val transition = rememberInfiniteTransition(label = "chat-wallpaper-motion")
val motion = transition.animateFloat(
initialValue = -1f,
targetValue = 1f,
animationSpec = infiniteRepeatable(
animation = tween(durationMillis = 7000),
repeatMode = RepeatMode.Reverse
),
label = "chat-wallpaper-motion-progress"
)
return motion.value
}
private fun Modifier.wallpaperMotion(progress: Float, x: Float, y: Float): Modifier =
if (progress == 0f) {
this
} else {
graphicsLayer(
translationX = progress * x,
translationY = progress * y
)
}
@Composable
private fun AccentCircle(
modifier: Modifier,
@@ -338,8 +338,19 @@ fun IkarSettingRow(
content = leading
)
Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) {
Text(title, style = MaterialTheme.typography.titleLarge)
Text(subtitle, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
Text(
title,
style = MaterialTheme.typography.titleLarge,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Text(
subtitle,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
trailing?.invoke()
}
@@ -371,8 +382,19 @@ fun IkarToggleRow(
)
}
Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) {
Text(title, style = MaterialTheme.typography.titleLarge)
Text(subtitle, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
Text(
title,
style = MaterialTheme.typography.titleLarge,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Text(
subtitle,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
Switch(
checked = checked,
@@ -417,8 +439,19 @@ fun IkarValueRow(
Box(modifier = Modifier.size(24.dp), contentAlignment = Alignment.Center, content = leading)
}
Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) {
Text(title, style = MaterialTheme.typography.titleLarge)
Text(subtitle, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
Text(
title,
style = MaterialTheme.typography.titleLarge,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Text(
subtitle,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
Text(
value,
@@ -7,7 +7,7 @@ param(
[string]$Channel = 'stable',
[string]$Platform = 'android',
[string]$Notes,
[string]$RepositoryUrl = 'https://git.kusoft.xyz/sevenhill/Massenger.git',
[string]$RepositoryUrl = 'https://git.kusoft.xyz/sevenhill/Ikar.git',
[string]$HomepageUrl = 'https://ikar.kusoft.xyz',
[switch]$Unlisted,
[switch]$SkipSigningInitialization
+381 -108
View File
@@ -1,10 +1,16 @@
param(
[string]$BaseUrl = 'https://argus.kusoft.xyz',
[Alias('ServerHost')]
[string]$SshHost = '192.168.0.185',
[Alias('Username')]
[string]$SshUsername = 'sevenhill',
[Alias('Password')]
[Parameter(Mandatory = $true)]
[string]$Username,
[Parameter(Mandatory = $true)]
[string]$Password,
[string]$SshPassword,
[Alias('BaseUrl')]
[string]$PublicBaseUrl = 'https://argus.kusoft.xyz',
[string]$ArgusDataPath = '/srv/argus-data',
[Parameter(Mandatory = $true)]
[ValidatePattern('^[a-z0-9][a-z0-9-]{0,99}$')]
[string]$Slug,
[Parameter(Mandatory = $true)]
[string]$Name,
@@ -12,13 +18,11 @@ param(
[string]$Summary,
[Parameter(Mandatory = $true)]
[string]$Description,
[Parameter(Mandatory = $true)]
[string]$AndroidPackageName,
[Parameter(Mandatory = $true)]
[string]$Version,
[Parameter(Mandatory = $true)]
[ValidateRange(1, [int]::MaxValue)]
[int]$AndroidVersionCode,
[ValidateRange(0, [int]::MaxValue)]
[int]$AndroidVersionCode = 0,
[Parameter(Mandatory = $true)]
[string]$PackagePath,
[string]$Channel = 'stable',
@@ -31,111 +35,380 @@ param(
)
$ErrorActionPreference = 'Stop'
Add-Type -AssemblyName System.Net.Http
function Read-HttpBody([System.Net.Http.HttpResponseMessage]$Response) {
if ($null -eq $Response.Content) {
return ''
}
return $Response.Content.ReadAsStringAsync().GetAwaiter().GetResult()
}
function Ensure-Success(
[System.Net.Http.HttpResponseMessage]$Response,
[string]$Operation
) {
if ($Response.IsSuccessStatusCode) {
return
}
$body = Read-HttpBody $Response
throw "$Operation failed with status $([int]$Response.StatusCode) ($($Response.ReasonPhrase)). Body: $body"
}
function New-JsonContent([string]$Json) {
return [System.Net.Http.StringContent]::new($Json, [System.Text.Encoding]::UTF8, 'application/json')
}
$resolvedPackagePath = (Resolve-Path -LiteralPath $PackagePath).Path
if (-not (Test-Path $resolvedPackagePath)) {
throw "Package file was not found: $resolvedPackagePath"
$packageItem = Get-Item -LiteralPath $resolvedPackagePath
if ($packageItem.Length -le 0) {
throw "Package file is empty: $resolvedPackagePath"
}
$baseUri = $BaseUrl.TrimEnd('/')
$cookieContainer = [System.Net.CookieContainer]::new()
$handler = [System.Net.Http.HttpClientHandler]::new()
$handler.CookieContainer = $cookieContainer
$handler.UseCookies = $true
$client = [System.Net.Http.HttpClient]::new($handler)
$client.Timeout = [TimeSpan]::FromMinutes(15)
$baseUrl = $PublicBaseUrl.TrimEnd('/')
$manifestUrl = "{0}/api/apps/{1}/manifest?platform={2}&channel={3}" -f `
$baseUrl,
[uri]::EscapeDataString($Slug),
[uri]::EscapeDataString($Platform),
[uri]::EscapeDataString($Channel)
$payload = [ordered]@{
sshHost = $SshHost
sshUsername = $SshUsername
argusDataPath = $ArgusDataPath
publicBaseUrl = $baseUrl
packagePath = $resolvedPackagePath
slug = $Slug
name = $Name
summary = $Summary
description = $Description
repositoryUrl = if ([string]::IsNullOrWhiteSpace($RepositoryUrl)) { $null } else { $RepositoryUrl.Trim() }
homepageUrl = if ([string]::IsNullOrWhiteSpace($HomepageUrl)) { $null } else { $HomepageUrl.Trim() }
isListed = (-not $Unlisted)
version = $Version
channel = $Channel
platform = $Platform
packageKind = $PackageKind
notes = if ([string]::IsNullOrWhiteSpace($Notes)) { $null } else { $Notes }
} | ConvertTo-Json -Depth 6 -Compress
$env:IKAR_ARGUS_PUBLISH_PAYLOAD = $payload
$env:IKAR_ARGUS_PUBLISH_SSH_PASSWORD = $SshPassword
$pythonScript = @'
import hashlib
import json
import mimetypes
import os
import posixpath
import re
import shutil
import sqlite3
import sys
import uuid
from datetime import datetime, timezone
from pathlib import Path
try:
import paramiko
except ImportError as exc:
raise SystemExit("Python package 'paramiko' is required for SSH publication.") from exc
payload = json.loads(os.environ["IKAR_ARGUS_PUBLISH_PAYLOAD"])
ssh_password = os.environ["IKAR_ARGUS_PUBLISH_SSH_PASSWORD"]
host = payload["sshHost"]
username = payload["sshUsername"]
local_package = Path(payload["packagePath"]).resolve()
remote_upload_dir = f"/home/{username}/.argus-upload/{uuid.uuid4().hex}"
remote_package = posixpath.join(remote_upload_dir, local_package.name)
def ensure_remote_dir(sftp, path):
current = "/"
for part in [p for p in path.strip("/").split("/") if p]:
current = posixpath.join(current, part)
try:
sftp.stat(current)
except FileNotFoundError:
sftp.mkdir(current)
def cleanup_upload(client):
try:
sftp = client.open_sftp()
try:
sftp.remove(remote_package)
except FileNotFoundError:
pass
try:
sftp.rmdir(remote_upload_dir)
except OSError:
pass
sftp.close()
except Exception:
pass
remote_payload = dict(payload)
remote_payload["sourcePath"] = remote_package
remote_body = r"""
source = Path(PAYLOAD["sourcePath"]).resolve()
data_root = Path(PAYLOAD.get("argusDataPath", "/srv/argus-data")).resolve()
db_path = data_root / "argus.db"
packages_root = data_root / "Packages"
slug = PAYLOAD["slug"].strip()
name = PAYLOAD["name"].strip()
summary = PAYLOAD["summary"].strip()
description = PAYLOAD["description"].strip()
repository_url = (PAYLOAD.get("repositoryUrl") or "").strip() or None
homepage_url = (PAYLOAD.get("homepageUrl") or "").strip() or None
is_listed = 1 if PAYLOAD.get("isListed", True) else 0
version = PAYLOAD["version"].strip()
channel = (PAYLOAD.get("channel") or "stable").strip().lower()
platform = (PAYLOAD.get("platform") or "generic").strip().lower()
package_kind = (PAYLOAD.get("packageKind") or "binary").strip().lower()
notes = (PAYLOAD.get("notes") or "").strip() or None
if not re.fullmatch(r"[a-z0-9][a-z0-9-]{0,99}", slug):
raise SystemExit("ARGUS_SLUG must contain only lowercase letters, digits, and hyphens, max 100 chars.")
if not source.is_file():
raise SystemExit(f"SOURCE_FILE does not exist: {source}")
if not db_path.is_file():
raise SystemExit(f"Argus database does not exist: {db_path}")
for key, value in {
"ARGUS_NAME": name,
"ARGUS_SUMMARY": summary,
"ARGUS_DESCRIPTION": description,
"ARGUS_VERSION": version,
}.items():
if not value:
raise SystemExit(f"{key} is required.")
release_id = str(uuid.uuid4()).upper()
now = datetime.now(timezone.utc).isoformat(timespec="microseconds")
safe_version = re.sub(r"[^a-zA-Z0-9._-]+", "-", version).strip("-._") or "release"
extension = source.suffix or ".bin"
stored_name = f"{datetime.now(timezone.utc):%Y%m%d%H%M%S}-{safe_version}-{release_id.replace('-', '')}{extension}"
stored_relative_path = f"{slug}/{stored_name}"
target_dir = packages_root / slug
target_path = target_dir / stored_name
target_dir.mkdir(parents=True, exist_ok=True)
shutil.copy2(source, target_path)
size_bytes = target_path.stat().st_size
sha256 = hashlib.sha256(target_path.read_bytes()).hexdigest()
content_type = mimetypes.guess_type(source.name)[0] or "application/octet-stream"
if source.suffix.lower() == ".apk":
content_type = "application/vnd.android.package-archive"
conn = sqlite3.connect(db_path)
try:
conn.execute("PRAGMA foreign_keys = ON")
conn.execute("BEGIN")
row = conn.execute('SELECT "Id" FROM "Apps" WHERE "Slug" = ?', (slug,)).fetchone()
if row is None:
app_id = str(uuid.uuid4()).upper()
conn.execute(
'''
INSERT INTO "Apps"
("Id", "Slug", "Name", "Summary", "Description", "RepositoryUrl", "HomepageUrl", "IsListed", "CreatedAt", "UpdatedAt")
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
''',
(app_id, slug, name, summary, description, repository_url, homepage_url, is_listed, now, now),
)
else:
app_id = row[0]
conn.execute(
'''
UPDATE "Apps"
SET "Name" = ?,
"Summary" = ?,
"Description" = ?,
"RepositoryUrl" = ?,
"HomepageUrl" = ?,
"IsListed" = ?,
"UpdatedAt" = ?
WHERE "Id" = ?
''',
(name, summary, description, repository_url, homepage_url, is_listed, now, app_id),
)
duplicate = conn.execute(
'''
SELECT "Id"
FROM "Releases"
WHERE "CatalogAppId" = ? AND "Version" = ? AND "Channel" = ? AND "Platform" = ?
''',
(app_id, version, channel, platform),
).fetchone()
if duplicate is not None:
raise RuntimeError(f"Release already exists for {slug} {version} {channel} {platform}.")
conn.execute(
'''
INSERT INTO "Releases"
("Id", "CatalogAppId", "Version", "Channel", "Platform", "PackageKind",
"OriginalFileName", "StoredRelativePath", "ContentType", "PackageSizeBytes",
"Sha256", "Notes", "PublishedAt")
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
''',
(
release_id,
app_id,
version,
channel,
platform,
package_kind,
source.name,
stored_relative_path,
content_type,
size_bytes,
sha256,
notes,
now,
),
)
conn.commit()
except Exception:
conn.rollback()
try:
target_path.unlink()
except FileNotFoundError:
pass
raise
finally:
conn.close()
print(json.dumps({
"releaseId": release_id,
"slug": slug,
"version": version,
"channel": channel,
"platform": platform,
"storedRelativePath": stored_relative_path,
"sizeBytes": size_bytes,
"sha256": sha256,
"manifestUrl": f"{PAYLOAD['publicBaseUrl']}/api/apps/{slug}/manifest?platform={platform}&channel={channel}",
}, ensure_ascii=False))
"""
remote_script = (
"import json\n"
"PAYLOAD = json.loads(" + json.dumps(json.dumps(remote_payload, ensure_ascii=False), ensure_ascii=False) + ")\n"
"import hashlib\n"
"import mimetypes\n"
"import re\n"
"import shutil\n"
"import sqlite3\n"
"import uuid\n"
"from datetime import datetime, timezone\n"
"from pathlib import Path\n"
+ remote_body
)
client = paramiko.SSHClient()
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
client.connect(host, username=username, password=ssh_password, timeout=15)
try:
sftp = client.open_sftp()
ensure_remote_dir(sftp, remote_upload_dir)
sftp.put(str(local_package), remote_package)
sftp.close()
stdin, stdout, stderr = client.exec_command("sudo -n true", timeout=30)
stdout.read()
stderr.read()
can_sudo_without_password = stdout.channel.recv_exit_status() == 0
if can_sudo_without_password:
command = "sudo -n python3 -"
input_text = remote_script
else:
command = "sudo -S -p '' python3 -"
input_text = ssh_password + "\n" + remote_script
stdin, stdout, stderr = client.exec_command(command, timeout=900)
stdin.write(input_text)
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 Argus publication failed with exit status {exit_status}\n"
f"STDOUT:\n{out}\nSTDERR:\n{err}"
)
print(out.strip())
finally:
cleanup_upload(client)
client.close()
'@
try {
$loginPayload = @{
username = $Username
password = $Password
} | ConvertTo-Json -Compress
$loginResponse = $client.PostAsync(
"$baseUri/api/admin/session/login",
(New-JsonContent $loginPayload)
).GetAwaiter().GetResult()
Ensure-Success $loginResponse 'Argus login'
$appPayload = @{
name = $Name
summary = $Summary
description = $Description
androidPackageName = $AndroidPackageName
repositoryUrl = if ([string]::IsNullOrWhiteSpace($RepositoryUrl)) { $null } else { $RepositoryUrl }
homepageUrl = if ([string]::IsNullOrWhiteSpace($HomepageUrl)) { $null } else { $HomepageUrl }
isListed = (-not $Unlisted)
} | ConvertTo-Json -Compress
$appResponse = $client.PutAsync(
"$baseUri/api/admin/apps/$Slug",
(New-JsonContent $appPayload)
).GetAwaiter().GetResult()
Ensure-Success $appResponse 'Argus app metadata update'
$form = [System.Net.Http.MultipartFormDataContent]::new()
$fields = [ordered]@{
Version = $Version
Channel = $Channel
Platform = $Platform
PackageKind = $PackageKind
AndroidVersionCode = [string]$AndroidVersionCode
}
foreach ($entry in $fields.GetEnumerator()) {
$form.Add([System.Net.Http.StringContent]::new($entry.Value, [System.Text.Encoding]::UTF8), $entry.Key)
}
if (-not [string]::IsNullOrWhiteSpace($Notes)) {
$form.Add([System.Net.Http.StringContent]::new($Notes, [System.Text.Encoding]::UTF8), 'Notes')
}
$fileStream = [System.IO.File]::OpenRead($resolvedPackagePath)
try {
$fileContent = [System.Net.Http.StreamContent]::new($fileStream)
$fileContent.Headers.ContentType = [System.Net.Http.Headers.MediaTypeHeaderValue]::Parse('application/vnd.android.package-archive')
$form.Add($fileContent, 'Package', [System.IO.Path]::GetFileName($resolvedPackagePath))
$releaseResponse = $client.PostAsync(
"$baseUri/api/admin/apps/$Slug/releases",
$form
).GetAwaiter().GetResult()
Ensure-Success $releaseResponse 'Argus release upload'
$releaseBody = Read-HttpBody $releaseResponse
if (-not [string]::IsNullOrWhiteSpace($releaseBody)) {
Write-Output $releaseBody
}
} finally {
$fileStream.Dispose()
$form.Dispose()
}
$publishOutput = $pythonScript | python -
$pythonExitCode = $LASTEXITCODE
} finally {
$client.Dispose()
$handler.Dispose()
Remove-Item Env:\IKAR_ARGUS_PUBLISH_PAYLOAD -ErrorAction SilentlyContinue
Remove-Item Env:\IKAR_ARGUS_PUBLISH_SSH_PASSWORD -ErrorAction SilentlyContinue
}
if ($pythonExitCode -ne 0) {
throw "Argus SSH publication failed with exit code $pythonExitCode."
}
if ($publishOutput) {
$publishOutput | Write-Output
}
$publishJson = $publishOutput |
Where-Object { $_ -and $_.TrimStart().StartsWith('{') } |
Select-Object -Last 1
if (-not $publishJson) {
throw 'Argus publication did not return a JSON result.'
}
$publishResult = $publishJson | ConvertFrom-Json
$manifest = $null
$lastManifestError = $null
for ($attempt = 1; $attempt -le 6; $attempt++) {
try {
$manifest = Invoke-RestMethod -Method Get -Uri $manifestUrl -TimeoutSec 60
if ($manifest.release.version -eq $Version) {
break
}
} catch {
$lastManifestError = $_
}
Start-Sleep -Seconds 2
}
if ($null -eq $manifest) {
throw "Could not verify Argus public manifest at $manifestUrl. Last error: $lastManifestError"
}
if ($manifest.release.version -ne $Version) {
throw "Argus public manifest is reachable, but latest version is $($manifest.release.version), expected $Version."
}
if ($manifest.release.sha256 -and $publishResult.sha256 -and
-not $manifest.release.sha256.Equals($publishResult.sha256, [System.StringComparison]::OrdinalIgnoreCase)
) {
throw "Argus public manifest SHA-256 does not match the published file."
}
$downloadPath = [string]$manifest.release.downloadPath
if ([string]::IsNullOrWhiteSpace($downloadPath)) {
throw 'Argus public manifest does not contain release.downloadPath.'
}
$downloadUrl = if ($downloadPath.StartsWith('http://', [System.StringComparison]::OrdinalIgnoreCase) -or
$downloadPath.StartsWith('https://', [System.StringComparison]::OrdinalIgnoreCase)
) {
$downloadPath
} else {
$baseUrl + '/' + $downloadPath.TrimStart('/')
}
$downloadResponse = $null
try {
$downloadRequest = [System.Net.HttpWebRequest]::Create($downloadUrl)
$downloadRequest.Method = 'GET'
$downloadRequest.Timeout = 60000
$downloadRequest.AddRange(0, 0)
$downloadResponse = [System.Net.HttpWebResponse]$downloadRequest.GetResponse()
$downloadStatus = [int]$downloadResponse.StatusCode
if ($downloadStatus -ne 200 -and $downloadStatus -ne 206) {
throw "Argus download endpoint returned HTTP $downloadStatus."
}
} catch {
throw "Could not verify Argus download endpoint at $downloadUrl. $($_.Exception.Message)"
} finally {
if ($null -ne $downloadResponse) {
$downloadResponse.Dispose()
}
}
Write-Output "Verified manifest: $manifestUrl"
Write-Output "Verified download: $downloadUrl"
@@ -0,0 +1,180 @@
using Ikar.Server.Data;
using Ikar.Server.Data.Entities;
using Ikar.Server.Infrastructure;
using Ikar.Server.Infrastructure.Admin;
using Ikar.Server.Infrastructure.Bots;
using Ikar.Server.Infrastructure.Hubs;
using Ikar.Server.Infrastructure.Storage;
using Ikar.Shared;
using Microsoft.AspNetCore.Hosting;
using Microsoft.Extensions.FileProviders;
using Microsoft.EntityFrameworkCore;
namespace Ikar.Server.Tests;
public sealed class AdminUserDeletionTests
{
[Fact]
public async Task DeleteUserAsync_UserOwnsBot_RemovesOwnerAndBotUser()
{
await using var database = new SqliteTestDatabase();
await using var dbContext = await database.CreateContextAsync();
using var tempRoot = new TempDirectory();
var service = CreateService(dbContext, tempRoot.Path);
var owner = CreateUser("owner");
var botUser = CreateUser("helperbot", isBot: true);
var bot = new Bot
{
User = botUser,
OwnerUser = owner,
TokenHash = "bot-token"
};
var chat = CreateDirectChat(owner, botUser);
chat.Messages.Add(new Message
{
Chat = chat,
Sender = botUser,
Text = "bot history",
SentAt = DateTimeOffset.UtcNow
});
dbContext.AddRange(owner, botUser, bot, chat);
await dbContext.SaveChangesAsync();
var deleted = await service.DeleteUserAsync(owner.Id, CancellationToken.None);
Assert.True(deleted);
Assert.False(await dbContext.Users.AnyAsync(x => x.Id == owner.Id));
Assert.False(await dbContext.Users.AnyAsync(x => x.Id == botUser.Id));
Assert.False(await dbContext.Bots.AnyAsync(x => x.Id == bot.Id));
Assert.False(await dbContext.Chats.AnyAsync(x => x.Id == chat.Id));
}
[Fact]
public async Task DeleteUserAsync_UserHasRestrictingRows_RemovesRowsWithoutThrowing()
{
await using var database = new SqliteTestDatabase();
await using var dbContext = await database.CreateContextAsync();
using var tempRoot = new TempDirectory();
var service = CreateService(dbContext, tempRoot.Path);
var user = CreateUser("deleted");
var peer = CreateUser("peer");
var chat = new Chat
{
Type = ChatType.Group,
Title = "Group",
CreatedBy = peer,
LastActivityAt = DateTimeOffset.UtcNow
};
chat.Members.Add(new ChatMember { Chat = chat, User = user, Role = ChatMemberRole.Member });
chat.Members.Add(new ChatMember { Chat = chat, User = peer, IsOwner = true, Role = ChatMemberRole.Owner });
var message = new Message
{
Chat = chat,
Sender = user,
Text = "remove me",
SentAt = DateTimeOffset.UtcNow
};
chat.Messages.Add(message);
var inviteLink = new ChatInviteLink
{
Chat = chat,
CreatedBy = user,
Token = "invite-token"
};
var topic = new ChatTopic
{
Chat = chat,
CreatedBy = user,
Title = "Topic",
Icon = "chat"
};
var moderationEntry = new ModerationLogEntry
{
Chat = chat,
ActorUser = user,
TargetType = ModerationLogTargetType.Message,
TargetId = message.Id,
Action = "message.deleted"
};
dbContext.AddRange(user, peer, chat, inviteLink, topic, moderationEntry);
await dbContext.SaveChangesAsync();
var deleted = await service.DeleteUserAsync(user.Id, CancellationToken.None);
Assert.True(deleted);
Assert.False(await dbContext.Users.AnyAsync(x => x.Id == user.Id));
Assert.False(await dbContext.Messages.AnyAsync(x => x.Id == message.Id));
Assert.False(await dbContext.ChatInviteLinks.AnyAsync(x => x.Id == inviteLink.Id));
Assert.False(await dbContext.ChatTopics.AnyAsync(x => x.Id == topic.Id));
Assert.False(await dbContext.ModerationLogEntries.AnyAsync(x => x.Id == moderationEntry.Id));
Assert.True(await dbContext.Users.AnyAsync(x => x.Id == peer.Id));
Assert.True(await dbContext.Chats.AnyAsync(x => x.Id == chat.Id));
}
private static AdminService CreateService(IkarDbContext dbContext, string contentRootPath)
{
var attachmentStorageService = new AttachmentStorageService(new TestWebHostEnvironment(contentRootPath));
return new AdminService(
dbContext,
new TestWebHostEnvironment(contentRootPath),
new ServerRuntimeState(DateTimeOffset.UtcNow),
attachmentStorageService,
new BotDeletionService(dbContext, attachmentStorageService),
new NoopHubContext<MessengerHub>());
}
private static User CreateUser(string username, bool isBot = false) =>
new()
{
Id = Guid.NewGuid(),
Username = username,
NormalizedUsername = username.ToUpperInvariant(),
DisplayName = username,
PasswordHash = "test",
IsBot = isBot
};
private static Chat CreateDirectChat(User first, User second)
{
var chat = new Chat
{
Type = ChatType.Direct,
CreatedBy = first,
LastActivityAt = DateTimeOffset.UtcNow
};
chat.Members.Add(new ChatMember { Chat = chat, User = first, IsOwner = true, Role = ChatMemberRole.Owner });
chat.Members.Add(new ChatMember { Chat = chat, User = second });
return chat;
}
private sealed class TempDirectory : IDisposable
{
public TempDirectory()
{
Path = System.IO.Path.Combine(System.IO.Path.GetTempPath(), $"ikar-admin-delete-{Guid.NewGuid():N}");
Directory.CreateDirectory(Path);
}
public string Path { get; }
public void Dispose()
{
if (Directory.Exists(Path))
{
Directory.Delete(Path, recursive: true);
}
}
}
private sealed class TestWebHostEnvironment(string contentRootPath) : IWebHostEnvironment
{
public string ApplicationName { get; set; } = "Ikar.Server.Tests";
public IFileProvider ContentRootFileProvider { get; set; } = new PhysicalFileProvider(contentRootPath);
public string ContentRootPath { get; set; } = contentRootPath;
public string EnvironmentName { get; set; } = "Test";
public string WebRootPath { get; set; } = contentRootPath;
public IFileProvider WebRootFileProvider { get; set; } = new PhysicalFileProvider(contentRootPath);
}
}
@@ -0,0 +1,201 @@
using Ikar.Server.Controllers;
using Ikar.Server.Data;
using Ikar.Server.Data.Entities;
using Ikar.Server.Infrastructure;
using Ikar.Server.Infrastructure.Auth;
using Ikar.Shared;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Options;
namespace Ikar.Server.Tests;
public sealed class AuthEmailControllerTests
{
[Fact]
public async Task VerifyEmailCode_NewEmail_CreatesUserAndSession()
{
await using var database = new SqliteTestDatabase();
await using var dbContext = await database.CreateContextAsync();
var sender = new RecordingEmailCodeSender();
var challengeStore = new EmailAuthChallengeStore();
var controller = CreateController(dbContext, challengeStore, sender);
var challengeResult = await controller.RequestEmailCode(
new RequestEmailCodeRequest("user@yandex.ru", "+10000000001"),
CancellationToken.None);
var challenge = ControllerTestFixture.ValueOf(challengeResult);
var sessionResult = await controller.VerifyEmailCode(
new VerifyEmailCodeRequest(challenge.ChallengeId, "user@yandex.ru", "+10000000001", sender.LastCode!, "Иван"),
CancellationToken.None);
var session = ControllerTestFixture.ValueOf(sessionResult);
Assert.Equal("Иван", session.User.DisplayName);
Assert.NotEmpty(session.AccessToken);
Assert.NotEmpty(session.RefreshToken);
Assert.Contains(dbContext.Users, user => user.NormalizedEmail == "USER@YANDEX.RU");
Assert.Contains(dbContext.Users, user => user.NormalizedPhoneNumber == PhoneNumberNormalizer.Normalize("+10000000001"));
}
[Fact]
public async Task RequestEmailCode_NewAccountWithUnsupportedEmailDomain_ReturnsValidationProblem()
{
await using var database = new SqliteTestDatabase();
await using var dbContext = await database.CreateContextAsync();
var sender = new RecordingEmailCodeSender();
var controller = CreateController(dbContext, new EmailAuthChallengeStore(), sender);
var result = await controller.RequestEmailCode(
new RequestEmailCodeRequest("user@gmail.com", "+10000000009"),
CancellationToken.None);
var badRequest = Assert.IsType<BadRequestObjectResult>(result.Result);
Assert.Equal(EmailAddressNormalizer.UnsupportedRegistrationDomainMessage, badRequest.Value);
Assert.Null(sender.LastCode);
}
[Fact]
public async Task VerifyEmailCode_ExistingPhoneOnlyUser_LinksEmailAndCreatesSession()
{
await using var database = new SqliteTestDatabase();
await using var dbContext = await database.CreateContextAsync();
const string phoneNumber = "+10000000002";
var existingUser = new User
{
Username = "legacy",
NormalizedUsername = "LEGACY",
DisplayName = "Legacy",
PasswordHash = "test",
PhoneNumber = phoneNumber,
NormalizedPhoneNumber = PhoneNumberNormalizer.Normalize(phoneNumber)
};
dbContext.Users.Add(existingUser);
await dbContext.SaveChangesAsync();
var sender = new RecordingEmailCodeSender();
var controller = CreateController(dbContext, new EmailAuthChallengeStore(), sender);
var challengeResult = await controller.RequestEmailCode(
new RequestEmailCodeRequest("legacy@mail.ru", phoneNumber),
CancellationToken.None);
var challenge = ControllerTestFixture.ValueOf(challengeResult);
var sessionResult = await controller.VerifyEmailCode(
new VerifyEmailCodeRequest(challenge.ChallengeId, "legacy@mail.ru", phoneNumber, sender.LastCode!, null),
CancellationToken.None);
var session = ControllerTestFixture.ValueOf(sessionResult);
var storedUser = Assert.Single(dbContext.Users, user => user.Id == existingUser.Id);
Assert.Equal(existingUser.Id, session.User.Id);
Assert.Equal("LEGACY@MAIL.RU", storedUser.NormalizedEmail);
Assert.Equal(PhoneNumberNormalizer.Normalize(phoneNumber), storedUser.NormalizedPhoneNumber);
}
[Fact]
public async Task RequestEmailCode_EmailAndPhoneFromDifferentAccounts_ReturnsConflict()
{
await using var database = new SqliteTestDatabase();
await using var dbContext = await database.CreateContextAsync();
dbContext.Users.AddRange(
new User
{
Username = "email-user",
NormalizedUsername = "EMAIL-USER",
DisplayName = "Email",
PasswordHash = "test",
Email = "user@yandex.ru",
NormalizedEmail = "USER@YANDEX.RU",
PhoneNumber = "+10000000003",
NormalizedPhoneNumber = "10000000003"
},
new User
{
Username = "phone-user",
NormalizedUsername = "PHONE-USER",
DisplayName = "Phone",
PasswordHash = "test",
PhoneNumber = "+10000000004",
NormalizedPhoneNumber = "10000000004"
});
await dbContext.SaveChangesAsync();
var sender = new RecordingEmailCodeSender();
var controller = CreateController(dbContext, new EmailAuthChallengeStore(), sender);
var result = await controller.RequestEmailCode(
new RequestEmailCodeRequest("user@yandex.ru", "+10000000004"),
CancellationToken.None);
Assert.IsType<ConflictObjectResult>(result.Result);
Assert.Null(sender.LastCode);
}
[Fact]
public async Task VerifyEmailCode_WithDifferentPhoneThanChallenge_ReturnsUnauthorized()
{
await using var database = new SqliteTestDatabase();
await using var dbContext = await database.CreateContextAsync();
var sender = new RecordingEmailCodeSender();
var controller = CreateController(dbContext, new EmailAuthChallengeStore(), sender);
var challengeResult = await controller.RequestEmailCode(
new RequestEmailCodeRequest("user@yandex.ru", "+10000000005"),
CancellationToken.None);
var challenge = ControllerTestFixture.ValueOf(challengeResult);
var sessionResult = await controller.VerifyEmailCode(
new VerifyEmailCodeRequest(challenge.ChallengeId, "user@yandex.ru", "+10000000006", sender.LastCode!, "Иван"),
CancellationToken.None);
Assert.IsType<UnauthorizedObjectResult>(sessionResult.Result);
}
[Fact]
public async Task RequestPhoneCode_NewPhone_DoesNotRegister()
{
await using var database = new SqliteTestDatabase();
await using var dbContext = await database.CreateContextAsync();
var controller = CreateController(dbContext, new EmailAuthChallengeStore(), new RecordingEmailCodeSender());
var result = await controller.RequestPhoneCode(
new RequestPhoneCodeRequest("+10000009999"),
CancellationToken.None);
Assert.IsType<NotFoundObjectResult>(result.Result);
}
private static AuthController CreateController(
IkarDbContext dbContext,
EmailAuthChallengeStore emailChallengeStore,
IEmailCodeSender emailCodeSender)
{
var controller = new AuthController(
dbContext,
new TokenService(dbContext, Options.Create(new JwtOptions())),
new PresenceTracker(),
new PhoneAuthChallengeStore(),
emailChallengeStore,
emailCodeSender);
controller.ControllerContext = new ControllerContext
{
HttpContext = new DefaultHttpContext()
};
return controller;
}
private sealed class RecordingEmailCodeSender : IEmailCodeSender
{
public string? LastCode { get; private set; }
public Task SendLoginCodeAsync(
string email,
string code,
DateTimeOffset expiresAt,
CancellationToken cancellationToken = default)
{
LastCode = code;
return Task.CompletedTask;
}
}
}
@@ -0,0 +1,790 @@
using Ikar.Server.Controllers;
using Ikar.Server.Data;
using Ikar.Server.Data.Entities;
using Ikar.Server.Infrastructure;
using Ikar.Server.Infrastructure.Auth;
using Ikar.Server.Infrastructure.Bots;
using Ikar.Server.Infrastructure.Hubs;
using Ikar.Server.Infrastructure.Push;
using Ikar.Server.Infrastructure.Storage;
using Ikar.Shared;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Http;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.FileProviders;
namespace Ikar.Server.Tests;
public sealed class BotApiControllerTests
{
[Fact]
public async Task SendDocument_DecodesPercentEncodedFileName()
{
var tempRoot = Path.Combine(Path.GetTempPath(), $"ikar-bot-api-tests-{Guid.NewGuid():N}");
Directory.CreateDirectory(tempRoot);
try
{
await using var database = new SqliteTestDatabase();
await using var dbContext = await database.CreateContextAsync();
var owner = CreateUser("owner");
var botUser = CreateUser("documentbot", isBot: true);
var tokenService = new BotTokenService();
var token = "bot-token";
var bot = new Bot
{
Id = Guid.NewGuid(),
UserId = botUser.Id,
User = botUser,
OwnerUserId = owner.Id,
OwnerUser = owner,
TokenHash = tokenService.HashToken(token)
};
var chat = CreateDirectChat(owner, botUser);
dbContext.AddRange(owner, botUser, bot, chat);
await dbContext.SaveChangesAsync();
await using var stream = new MemoryStream("%PDF-1.3"u8.ToArray());
var encodedFileName = "Fluke_8508A_" + Uri.EscapeDataString("методика.pdf");
var file = new FormFile(stream, 0, stream.Length, "file", encodedFileName)
{
Headers = new HeaderDictionary(),
ContentType = "application/pdf"
};
var controller = CreateController(dbContext, tokenService, tempRoot);
var response = await controller.SendDocument(
token,
new BotSendAttachmentRequest
{
ChatId = chat.Id,
Text = "",
File = file
},
CancellationToken.None);
var message = ControllerTestFixture.ValueOf(response);
var attachment = Assert.Single(message.Attachments);
Assert.Equal("Fluke_8508A_методика.pdf", attachment.FileName);
Assert.Equal("application/pdf", attachment.ContentType);
}
finally
{
if (Directory.Exists(tempRoot))
{
Directory.Delete(tempRoot, recursive: true);
}
}
}
[Fact]
public async Task SendDocument_SavesOggAsVoiceNoteAttachment()
{
var tempRoot = Path.Combine(Path.GetTempPath(), $"ikar-bot-api-tests-{Guid.NewGuid():N}");
Directory.CreateDirectory(tempRoot);
try
{
await using var database = new SqliteTestDatabase();
await using var dbContext = await database.CreateContextAsync();
var owner = CreateUser("owner");
var botUser = CreateUser("documentbot", isBot: true);
var tokenService = new BotTokenService();
var token = "bot-token";
var bot = new Bot
{
Id = Guid.NewGuid(),
UserId = botUser.Id,
User = botUser,
OwnerUserId = owner.Id,
OwnerUser = owner,
TokenHash = tokenService.HashToken(token)
};
var chat = CreateDirectChat(owner, botUser);
dbContext.AddRange(owner, botUser, bot, chat);
await dbContext.SaveChangesAsync();
await using var stream = new MemoryStream([1, 2, 3, 4]);
var file = new FormFile(stream, 0, stream.Length, "file", "song.ogg")
{
Headers = new HeaderDictionary(),
ContentType = "audio/ogg"
};
var controller = CreateController(dbContext, tokenService, tempRoot);
var response = await controller.SendDocument(
token,
new BotSendAttachmentRequest
{
ChatId = chat.Id,
Text = "",
File = file
},
CancellationToken.None);
var message = ControllerTestFixture.ValueOf(response);
var attachment = Assert.Single(message.Attachments);
Assert.Equal(AttachmentKind.VoiceNote, attachment.Kind);
Assert.Equal("audio/ogg", attachment.ContentType);
}
finally
{
if (Directory.Exists(tempRoot))
{
Directory.Delete(tempRoot, recursive: true);
}
}
}
[Fact]
public async Task SendPhoto_SavesImageAttachment()
{
var tempRoot = Path.Combine(Path.GetTempPath(), $"ikar-bot-api-tests-{Guid.NewGuid():N}");
Directory.CreateDirectory(tempRoot);
try
{
await using var database = new SqliteTestDatabase();
await using var dbContext = await database.CreateContextAsync();
var owner = CreateUser("owner");
var botUser = CreateUser("photobot", isBot: true);
var tokenService = new BotTokenService();
var token = "bot-token";
var bot = new Bot
{
Id = Guid.NewGuid(),
UserId = botUser.Id,
User = botUser,
OwnerUserId = owner.Id,
OwnerUser = owner,
TokenHash = tokenService.HashToken(token)
};
var chat = CreateDirectChat(owner, botUser);
dbContext.AddRange(owner, botUser, bot, chat);
await dbContext.SaveChangesAsync();
await using var stream = new MemoryStream([0xFF, 0xD8, 0xFF, 0xD9]);
var file = new FormFile(stream, 0, stream.Length, "file", "photo.jpg")
{
Headers = new HeaderDictionary(),
ContentType = "image/jpeg"
};
var controller = CreateController(dbContext, tokenService, tempRoot);
var response = await controller.SendPhoto(
token,
new BotSendAttachmentRequest
{
ChatId = chat.Id,
Text = "caption",
File = file
},
CancellationToken.None);
var message = ControllerTestFixture.ValueOf(response);
Assert.Equal("caption", message.Text);
var attachment = Assert.Single(message.Attachments);
Assert.Equal(AttachmentKind.File, attachment.Kind);
Assert.Equal("image/jpeg", attachment.ContentType);
Assert.Equal("photo.jpg", attachment.FileName);
}
finally
{
if (Directory.Exists(tempRoot))
{
Directory.Delete(tempRoot, recursive: true);
}
}
}
[Fact]
public async Task SendPhoto_RejectsVideoAttachment()
{
var tempRoot = Path.Combine(Path.GetTempPath(), $"ikar-bot-api-tests-{Guid.NewGuid():N}");
Directory.CreateDirectory(tempRoot);
try
{
await using var database = new SqliteTestDatabase();
await using var dbContext = await database.CreateContextAsync();
var owner = CreateUser("owner");
var botUser = CreateUser("photobot", isBot: true);
var tokenService = new BotTokenService();
var token = "bot-token";
var bot = new Bot
{
Id = Guid.NewGuid(),
UserId = botUser.Id,
User = botUser,
OwnerUserId = owner.Id,
OwnerUser = owner,
TokenHash = tokenService.HashToken(token)
};
var chat = CreateDirectChat(owner, botUser);
dbContext.AddRange(owner, botUser, bot, chat);
await dbContext.SaveChangesAsync();
await using var stream = new MemoryStream([1, 2, 3, 4]);
var file = new FormFile(stream, 0, stream.Length, "file", "clip.mp4")
{
Headers = new HeaderDictionary(),
ContentType = "video/mp4"
};
var controller = CreateController(dbContext, tokenService, tempRoot);
var response = await controller.SendPhoto(
token,
new BotSendAttachmentRequest
{
ChatId = chat.Id,
Text = "",
File = file
},
CancellationToken.None);
var badRequest = Assert.IsType<Microsoft.AspNetCore.Mvc.BadRequestObjectResult>(response.Result);
Assert.Equal("sendPhoto accepts only image files.", badRequest.Value);
}
finally
{
if (Directory.Exists(tempRoot))
{
Directory.Delete(tempRoot, recursive: true);
}
}
}
[Fact]
public async Task SendVideo_SavesVideoAttachment()
{
var tempRoot = Path.Combine(Path.GetTempPath(), $"ikar-bot-api-tests-{Guid.NewGuid():N}");
Directory.CreateDirectory(tempRoot);
try
{
await using var database = new SqliteTestDatabase();
await using var dbContext = await database.CreateContextAsync();
var owner = CreateUser("owner");
var botUser = CreateUser("videobot", isBot: true);
var tokenService = new BotTokenService();
var token = "bot-token";
var bot = new Bot
{
Id = Guid.NewGuid(),
UserId = botUser.Id,
User = botUser,
OwnerUserId = owner.Id,
OwnerUser = owner,
TokenHash = tokenService.HashToken(token)
};
var chat = CreateDirectChat(owner, botUser);
dbContext.AddRange(owner, botUser, bot, chat);
await dbContext.SaveChangesAsync();
await using var stream = new MemoryStream([0, 0, 0, 24, 102, 116, 121, 112]);
var file = new FormFile(stream, 0, stream.Length, "file", "clip.mp4")
{
Headers = new HeaderDictionary(),
ContentType = "video/mp4"
};
var controller = CreateController(dbContext, tokenService, tempRoot);
var response = await controller.SendVideo(
token,
new BotSendAttachmentRequest
{
ChatId = chat.Id,
Text = "clip",
File = file
},
CancellationToken.None);
var message = ControllerTestFixture.ValueOf(response);
Assert.Equal("clip", message.Text);
var attachment = Assert.Single(message.Attachments);
Assert.Equal(AttachmentKind.File, attachment.Kind);
Assert.Equal("video/mp4", attachment.ContentType);
Assert.Equal("clip.mp4", attachment.FileName);
}
finally
{
if (Directory.Exists(tempRoot))
{
Directory.Delete(tempRoot, recursive: true);
}
}
}
[Fact]
public async Task SendMediaGroup_SavesImageAndVideoAlbum()
{
var tempRoot = Path.Combine(Path.GetTempPath(), $"ikar-bot-api-tests-{Guid.NewGuid():N}");
Directory.CreateDirectory(tempRoot);
try
{
await using var database = new SqliteTestDatabase();
await using var dbContext = await database.CreateContextAsync();
var owner = CreateUser("owner");
var botUser = CreateUser("albumbot", isBot: true);
var tokenService = new BotTokenService();
var token = "bot-token";
var bot = new Bot
{
Id = Guid.NewGuid(),
UserId = botUser.Id,
User = botUser,
OwnerUserId = owner.Id,
OwnerUser = owner,
TokenHash = tokenService.HashToken(token)
};
var chat = CreateDirectChat(owner, botUser);
dbContext.AddRange(owner, botUser, bot, chat);
await dbContext.SaveChangesAsync();
var photo = CreateFormFile([0xFF, 0xD8, 0xFF, 0xD9], "photo.jpg", "image/jpeg");
var video = CreateFormFile([0, 0, 0, 24, 102, 116, 121, 112], "clip.mp4", "video/mp4");
var controller = CreateController(dbContext, tokenService, tempRoot);
var response = await controller.SendMediaGroup(
token,
new BotSendMediaGroupRequest
{
ChatId = chat.Id,
Text = "album",
Files = [photo, video]
},
CancellationToken.None);
var message = ControllerTestFixture.ValueOf(response);
Assert.Equal("album", message.Text);
Assert.NotNull(message.MediaAlbumId);
Assert.Equal(2, message.Attachments.Count);
Assert.Equal("photo.jpg", message.Attachments[0].FileName);
Assert.Equal(0, message.Attachments[0].SortOrder);
Assert.Equal("clip.mp4", message.Attachments[1].FileName);
Assert.Equal(1, message.Attachments[1].SortOrder);
}
finally
{
if (Directory.Exists(tempRoot))
{
Directory.Delete(tempRoot, recursive: true);
}
}
}
[Fact]
public async Task SendMediaGroup_RejectsNonMediaAttachment()
{
var tempRoot = Path.Combine(Path.GetTempPath(), $"ikar-bot-api-tests-{Guid.NewGuid():N}");
Directory.CreateDirectory(tempRoot);
try
{
await using var database = new SqliteTestDatabase();
await using var dbContext = await database.CreateContextAsync();
var owner = CreateUser("owner");
var botUser = CreateUser("albumbot", isBot: true);
var tokenService = new BotTokenService();
var token = "bot-token";
var bot = new Bot
{
Id = Guid.NewGuid(),
UserId = botUser.Id,
User = botUser,
OwnerUserId = owner.Id,
OwnerUser = owner,
TokenHash = tokenService.HashToken(token)
};
var chat = CreateDirectChat(owner, botUser);
dbContext.AddRange(owner, botUser, bot, chat);
await dbContext.SaveChangesAsync();
var photo = CreateFormFile([0xFF, 0xD8, 0xFF, 0xD9], "photo.jpg", "image/jpeg");
var document = CreateFormFile([0x25, 0x50, 0x44, 0x46], "manual.pdf", "application/pdf");
var controller = CreateController(dbContext, tokenService, tempRoot);
var response = await controller.SendMediaGroup(
token,
new BotSendMediaGroupRequest
{
ChatId = chat.Id,
Files = [photo, document]
},
CancellationToken.None);
var badRequest = Assert.IsType<Microsoft.AspNetCore.Mvc.BadRequestObjectResult>(response.Result);
Assert.Equal("sendMediaGroup accepts only image and video files.", badRequest.Value);
}
finally
{
if (Directory.Exists(tempRoot))
{
Directory.Delete(tempRoot, recursive: true);
}
}
}
[Fact]
public async Task SendVoice_SavesOctetStreamOggNameAsVoiceNoteAttachment()
{
var tempRoot = Path.Combine(Path.GetTempPath(), $"ikar-bot-api-tests-{Guid.NewGuid():N}");
Directory.CreateDirectory(tempRoot);
try
{
await using var database = new SqliteTestDatabase();
await using var dbContext = await database.CreateContextAsync();
var owner = CreateUser("owner");
var botUser = CreateUser("voicebot", isBot: true);
var tokenService = new BotTokenService();
var token = "bot-token";
var bot = new Bot
{
Id = Guid.NewGuid(),
UserId = botUser.Id,
User = botUser,
OwnerUserId = owner.Id,
OwnerUser = owner,
TokenHash = tokenService.HashToken(token)
};
var chat = CreateDirectChat(owner, botUser);
dbContext.AddRange(owner, botUser, bot, chat);
await dbContext.SaveChangesAsync();
await using var stream = new MemoryStream([1, 2, 3, 4, 5]);
var file = new FormFile(stream, 0, stream.Length, "file", "Правила ogg")
{
Headers = new HeaderDictionary(),
ContentType = "application/octet-stream"
};
var controller = CreateController(dbContext, tokenService, tempRoot);
var response = await controller.SendVoice(
token,
new BotSendAttachmentRequest
{
ChatId = chat.Id,
Text = "",
File = file
},
CancellationToken.None);
var message = ControllerTestFixture.ValueOf(response);
var attachment = Assert.Single(message.Attachments);
Assert.Equal(AttachmentKind.VoiceNote, attachment.Kind);
Assert.Equal("audio/ogg", attachment.ContentType);
}
finally
{
if (Directory.Exists(tempRoot))
{
Directory.Delete(tempRoot, recursive: true);
}
}
}
[Fact]
public async Task SendMessage_ChannelWithLinkedDiscussion_CreatesDiscussionRoot()
{
var tempRoot = Path.Combine(Path.GetTempPath(), $"ikar-bot-api-tests-{Guid.NewGuid():N}");
Directory.CreateDirectory(tempRoot);
try
{
await using var database = new SqliteTestDatabase();
await using var dbContext = await database.CreateContextAsync();
var owner = CreateUser("owner");
var botUser = CreateUser("newsbot", isBot: true);
var tokenService = new BotTokenService();
var token = "bot-token";
var bot = new Bot
{
Id = Guid.NewGuid(),
UserId = botUser.Id,
User = botUser,
OwnerUserId = owner.Id,
OwnerUser = owner,
TokenHash = tokenService.HashToken(token)
};
var group = CreateGroup(owner, botUser, canBotPost: true);
var channel = CreateChannel(owner, botUser, group);
dbContext.AddRange(owner, botUser, bot, group, channel);
await dbContext.SaveChangesAsync();
var controller = CreateController(dbContext, tokenService, tempRoot);
var response = await controller.SendMessage(
token,
new BotSendMessageRequest(channel.Id, "Bot channel post"),
CancellationToken.None);
var postDto = ControllerTestFixture.ValueOf(response);
Assert.NotNull(postDto.DiscussionMessageId);
var discussionRoot = dbContext.Messages.Single(x => x.Id == postDto.DiscussionMessageId);
Assert.Equal(group.Id, discussionRoot.ChatId);
Assert.Equal("Bot channel post", discussionRoot.Text);
Assert.Equal(channel.Title, discussionRoot.ForwardedFromDisplayName);
}
finally
{
if (Directory.Exists(tempRoot))
{
Directory.Delete(tempRoot, recursive: true);
}
}
}
[Fact]
public async Task SendVoice_ChannelWithLinkedDiscussion_MirrorsVoiceAttachmentToDiscussionRoot()
{
var tempRoot = Path.Combine(Path.GetTempPath(), $"ikar-bot-api-tests-{Guid.NewGuid():N}");
Directory.CreateDirectory(tempRoot);
try
{
await using var database = new SqliteTestDatabase();
await using var dbContext = await database.CreateContextAsync();
var owner = CreateUser("owner");
var botUser = CreateUser("voicebot", isBot: true);
var tokenService = new BotTokenService();
var token = "bot-token";
var bot = new Bot
{
Id = Guid.NewGuid(),
UserId = botUser.Id,
User = botUser,
OwnerUserId = owner.Id,
OwnerUser = owner,
TokenHash = tokenService.HashToken(token)
};
var group = CreateGroup(owner, botUser, canBotPost: true);
var channel = CreateChannel(owner, botUser, group);
dbContext.AddRange(owner, botUser, bot, group, channel);
await dbContext.SaveChangesAsync();
var file = CreateFormFile([1, 2, 3, 4], "rules.ogg", "application/octet-stream");
var controller = CreateController(dbContext, tokenService, tempRoot);
var response = await controller.SendVoice(
token,
new BotSendAttachmentRequest
{
ChatId = channel.Id,
Text = "Rules",
File = file
},
CancellationToken.None);
var postDto = ControllerTestFixture.ValueOf(response);
Assert.NotNull(postDto.DiscussionMessageId);
var discussionRoot = dbContext.Messages
.Include(x => x.Attachments)
.Single(x => x.Id == postDto.DiscussionMessageId);
var mirroredAttachment = Assert.Single(discussionRoot.Attachments);
Assert.Equal(group.Id, discussionRoot.ChatId);
Assert.Equal("Rules", discussionRoot.Text);
Assert.Equal(AttachmentKind.VoiceNote, mirroredAttachment.Kind);
Assert.Equal("audio/ogg", mirroredAttachment.ContentType);
Assert.Equal("rules.ogg", mirroredAttachment.OriginalFileName);
Assert.True(File.Exists(mirroredAttachment.StoragePath));
}
finally
{
if (Directory.Exists(tempRoot))
{
Directory.Delete(tempRoot, recursive: true);
}
}
}
[Fact]
public async Task SendMessage_DeniesRestrictedGroupBot()
{
var tempRoot = Path.Combine(Path.GetTempPath(), $"ikar-bot-api-tests-{Guid.NewGuid():N}");
Directory.CreateDirectory(tempRoot);
try
{
await using var database = new SqliteTestDatabase();
await using var dbContext = await database.CreateContextAsync();
var owner = CreateUser("owner");
var botUser = CreateUser("restrictedbot", isBot: true);
var tokenService = new BotTokenService();
var token = "bot-token";
var bot = new Bot
{
Id = Guid.NewGuid(),
UserId = botUser.Id,
User = botUser,
OwnerUserId = owner.Id,
OwnerUser = owner,
TokenHash = tokenService.HashToken(token)
};
var group = CreateGroup(owner, botUser, canBotPost: false);
dbContext.AddRange(owner, botUser, bot, group);
await dbContext.SaveChangesAsync();
var controller = CreateController(dbContext, tokenService, tempRoot);
var response = await controller.SendMessage(
token,
new BotSendMessageRequest(group.Id, "test"),
CancellationToken.None);
var forbidden = Assert.IsType<Microsoft.AspNetCore.Mvc.ObjectResult>(response.Result);
Assert.Equal(StatusCodes.Status403Forbidden, forbidden.StatusCode);
}
finally
{
if (Directory.Exists(tempRoot))
{
Directory.Delete(tempRoot, recursive: true);
}
}
}
private static BotApiController CreateController(
IkarDbContext dbContext,
BotTokenService tokenService,
string contentRootPath) =>
new(
dbContext,
new PresenceTracker(),
tokenService,
new BotUpdateService(dbContext),
new NoopHubContext<MessengerHub>(),
new PushDispatchQueue(),
new AttachmentStorageService(new TestWebHostEnvironment(contentRootPath)));
private static FormFile CreateFormFile(byte[] content, string fileName, string contentType)
{
var stream = new MemoryStream(content);
return new FormFile(stream, 0, stream.Length, "file", fileName)
{
Headers = new HeaderDictionary(),
ContentType = contentType
};
}
private static User CreateUser(string username, bool isBot = false) =>
new()
{
Id = Guid.NewGuid(),
Username = username,
NormalizedUsername = username.ToUpperInvariant(),
DisplayName = username,
PasswordHash = "test",
IsBot = isBot
};
private static Chat CreateChannel(User owner, User botUser, Chat linkedDiscussionChat)
{
var channel = new Chat
{
Id = Guid.NewGuid(),
Type = ChatType.Channel,
Title = "News",
CreatedById = owner.Id,
CreatedBy = owner,
LinkedDiscussionChatId = linkedDiscussionChat.Id,
CreatedAt = new DateTimeOffset(2026, 5, 21, 12, 0, 0, TimeSpan.Zero)
};
channel.Members.Add(new ChatMember
{
ChatId = channel.Id,
Chat = channel,
UserId = owner.Id,
User = owner,
IsOwner = true,
Role = ChatMemberRole.Owner,
CanPostMessages = true,
CanDeleteMessages = true,
CanInviteUsers = true,
CanPinMessages = true
});
channel.Members.Add(new ChatMember
{
ChatId = channel.Id,
Chat = channel,
UserId = botUser.Id,
User = botUser,
Role = ChatMemberRole.Admin,
CanPostMessages = true
});
return channel;
}
private static Chat CreateDirectChat(User first, User second)
{
var chat = new Chat
{
Id = Guid.NewGuid(),
Type = ChatType.Direct,
CreatedById = first.Id,
CreatedBy = first,
CreatedAt = new DateTimeOffset(2026, 5, 21, 12, 0, 0, TimeSpan.Zero)
};
chat.Members.Add(new ChatMember { ChatId = chat.Id, Chat = chat, UserId = first.Id, User = first });
chat.Members.Add(new ChatMember { ChatId = chat.Id, Chat = chat, UserId = second.Id, User = second });
return chat;
}
private static Chat CreateGroup(User owner, User botUser, bool canBotPost)
{
var chat = new Chat
{
Id = Guid.NewGuid(),
Type = ChatType.Group,
Title = "Group",
CreatedById = owner.Id,
CreatedBy = owner,
CreatedAt = new DateTimeOffset(2026, 5, 21, 12, 0, 0, TimeSpan.Zero)
};
chat.Members.Add(new ChatMember
{
ChatId = chat.Id,
Chat = chat,
UserId = owner.Id,
User = owner,
IsOwner = true,
Role = ChatMemberRole.Owner,
CanPostMessages = true,
CanDeleteMessages = true,
CanInviteUsers = true,
CanPinMessages = true
});
chat.Members.Add(new ChatMember
{
ChatId = chat.Id,
Chat = chat,
UserId = botUser.Id,
User = botUser,
Role = ChatMemberRole.Member,
CanPostMessages = canBotPost
});
return chat;
}
private sealed class TestWebHostEnvironment(string contentRootPath) : IWebHostEnvironment
{
public string ApplicationName { get; set; } = "Ikar.Server.Tests";
public IFileProvider WebRootFileProvider { get; set; } = new NullFileProvider();
public string WebRootPath { get; set; } = contentRootPath;
public string EnvironmentName { get; set; } = "Development";
public string ContentRootPath { get; set; } = contentRootPath;
public IFileProvider ContentRootFileProvider { get; set; } = new PhysicalFileProvider(contentRootPath);
}
}
@@ -0,0 +1,106 @@
using Ikar.Server.Data.Entities;
using Ikar.Server.Infrastructure;
using Ikar.Server.Infrastructure.Auth;
using Ikar.Server.Infrastructure.Bots;
using Ikar.Server.Infrastructure.Hubs;
using Ikar.Server.Infrastructure.Push;
using Ikar.Shared;
using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
namespace Ikar.Server.Tests;
public sealed class BotCreatorServiceTests
{
[Fact]
public async Task TryHandleIncomingMessage_RussianCreateCommand_CreatesBot()
{
await using var database = new SqliteTestDatabase();
await using var dbContext = await database.CreateContextAsync();
var service = CreateService(dbContext);
await service.EnsureCreatorBotAsync();
var owner = CreateUser("owner", "Owner");
dbContext.Users.Add(owner);
await dbContext.SaveChangesAsync();
var creatorUser = await dbContext.Users.SingleAsync(x => x.NormalizedUsername == BotCreatorService.CreatorUsername.ToUpperInvariant());
var chat = new Chat
{
Type = ChatType.Direct,
CreatedById = owner.Id,
LastActivityAt = DateTimeOffset.UtcNow,
Members =
[
new ChatMember { UserId = owner.Id, IsOwner = true, Role = ChatMemberRole.Owner },
new ChatMember { UserId = creatorUser.Id }
]
};
var message = new Message
{
Chat = chat,
Sender = owner,
Text = "/создать_бота Эхо | echobot | тестовый бот",
SentAt = DateTimeOffset.UtcNow
};
chat.Messages.Add(message);
dbContext.Chats.Add(chat);
await dbContext.SaveChangesAsync();
var handled = await service.TryHandleIncomingMessageAsync(message.Id);
Assert.True(handled);
var createdBot = await dbContext.Bots
.Include(x => x.User)
.SingleOrDefaultAsync(x => x.User.NormalizedUsername == "ECHOBOT");
Assert.NotNull(createdBot);
Assert.Equal(owner.Id, createdBot.OwnerUserId);
Assert.Contains(dbContext.Messages, x => x.ChatId == chat.Id && x.SenderId == creatorUser.Id && x.Text.Contains("Токен"));
}
[Fact]
public async Task UsersSearch_FindsCreatorBot()
{
await using var database = new SqliteTestDatabase();
await using var dbContext = await database.CreateContextAsync();
var service = CreateService(dbContext);
await service.EnsureCreatorBotAsync();
var owner = CreateUser("owner", "Owner");
dbContext.Users.Add(owner);
await dbContext.SaveChangesAsync();
var controller = ControllerTestFixture.CreateUsersController(dbContext, owner.Id);
var response = await controller.Search("botfatherbot", CancellationToken.None);
var users = ControllerTestFixture.ValueOf(response);
var creator = Assert.Single(users);
Assert.True(creator.IsBot);
Assert.Equal(BotCreatorService.CreatorUsername, creator.Username);
Assert.Equal("Создатель ботов", creator.DisplayName);
}
private static BotCreatorService CreateService(Ikar.Server.Data.IkarDbContext dbContext)
{
var pushQueue = new PushDispatchQueue();
return new BotCreatorService(
dbContext,
new BotTokenService(),
new BotDeletionService(dbContext, ControllerTestFixture.CreateAttachmentStorageService(Path.GetTempPath())),
new PresenceTracker(),
new NoopHubContext<MessengerHub>(),
pushQueue);
}
private static User CreateUser(string username, string displayName)
{
var user = new User
{
Username = username,
NormalizedUsername = username.ToUpperInvariant(),
DisplayName = displayName
};
user.PasswordHash = new PasswordHasher<User>().HashPassword(user, "password");
return user;
}
}
@@ -0,0 +1,141 @@
using Ikar.Server.Data.Entities;
using Ikar.Server.Infrastructure.Bots;
using Ikar.Shared;
using Microsoft.EntityFrameworkCore;
namespace Ikar.Server.Tests;
public sealed class BotUpdateServiceTests
{
[Fact]
public async Task EnqueueIncomingMessageAsync_CreatesUpdateForGroupBotMember()
{
await using var database = new SqliteTestDatabase();
await using var dbContext = await database.CreateContextAsync();
var owner = CreateUser("owner");
var botUser = CreateUser("groupbot", isBot: true);
var bot = CreateBot(owner, botUser);
var group = CreateChat(ChatType.Group, owner, botUser, botCanPost: false);
var message = CreateMessage(group, owner, "hello group bot");
dbContext.AddRange(owner, botUser, bot, group, message);
await dbContext.SaveChangesAsync();
var service = new BotUpdateService(dbContext);
await service.EnqueueIncomingMessageAsync(message.Id, CancellationToken.None);
var update = await dbContext.BotUpdates.SingleAsync();
Assert.Equal(bot.Id, update.BotId);
Assert.Equal(group.Id, update.ChatId);
Assert.Equal(message.Id, update.MessageId);
}
[Fact]
public async Task EnqueueIncomingMessageAsync_CreatesUpdateForChannelAdminBotOnly()
{
await using var database = new SqliteTestDatabase();
await using var dbContext = await database.CreateContextAsync();
var owner = CreateUser("owner");
var adminBotUser = CreateUser("adminbot", isBot: true);
var subscriberBotUser = CreateUser("subscriberbot", isBot: true);
var adminBot = CreateBot(owner, adminBotUser);
var subscriberBot = CreateBot(owner, subscriberBotUser);
var channel = CreateChat(ChatType.Channel, owner, adminBotUser, botCanPost: true);
channel.Members.Add(new ChatMember
{
ChatId = channel.Id,
Chat = channel,
UserId = subscriberBotUser.Id,
User = subscriberBotUser,
Role = ChatMemberRole.Member,
CanPostMessages = false
});
var message = CreateMessage(channel, owner, "channel post");
dbContext.AddRange(owner, adminBotUser, subscriberBotUser, adminBot, subscriberBot, channel, message);
await dbContext.SaveChangesAsync();
var service = new BotUpdateService(dbContext);
await service.EnqueueIncomingMessageAsync(message.Id, CancellationToken.None);
var update = await dbContext.BotUpdates.SingleAsync();
Assert.Equal(adminBot.Id, update.BotId);
Assert.DoesNotContain(dbContext.BotUpdates, x => x.BotId == subscriberBot.Id);
}
private static Bot CreateBot(User owner, User botUser) =>
new()
{
Id = Guid.NewGuid(),
UserId = botUser.Id,
User = botUser,
OwnerUserId = owner.Id,
OwnerUser = owner,
TokenHash = $"token-{botUser.Username}",
IsEnabled = true
};
private static User CreateUser(string username, bool isBot = false) =>
new()
{
Id = Guid.NewGuid(),
Username = username,
NormalizedUsername = username.ToUpperInvariant(),
DisplayName = username,
PasswordHash = "test",
IsBot = isBot
};
private static Chat CreateChat(ChatType type, User owner, User botUser, bool botCanPost)
{
var chat = new Chat
{
Id = Guid.NewGuid(),
Type = type,
Title = type == ChatType.Channel ? "Channel" : "Group",
CreatedById = owner.Id,
CreatedBy = owner,
CreatedAt = new DateTimeOffset(2026, 6, 1, 12, 0, 0, TimeSpan.Zero)
};
chat.Members.Add(new ChatMember
{
ChatId = chat.Id,
Chat = chat,
UserId = owner.Id,
User = owner,
IsOwner = true,
Role = ChatMemberRole.Owner,
CanPostMessages = true,
CanDeleteMessages = true,
CanInviteUsers = true,
CanPinMessages = true
});
chat.Members.Add(new ChatMember
{
ChatId = chat.Id,
Chat = chat,
UserId = botUser.Id,
User = botUser,
Role = botCanPost ? ChatMemberRole.Admin : ChatMemberRole.Member,
CanPostMessages = botCanPost,
CanDeleteMessages = botCanPost,
CanInviteUsers = botCanPost,
CanPinMessages = botCanPost
});
return chat;
}
private static Message CreateMessage(Chat chat, User sender, string text)
{
var message = new Message
{
Id = Guid.NewGuid(),
ChatId = chat.Id,
Chat = chat,
SenderId = sender.Id,
Sender = sender,
Text = text,
SentAt = new DateTimeOffset(2026, 6, 1, 12, 5, 0, TimeSpan.Zero)
};
chat.Messages.Add(message);
return message;
}
}
+26 -9
View File
@@ -8,7 +8,29 @@ namespace Ikar.Server.Tests;
public sealed class BotsControllerTests
{
[Fact]
public async Task DeleteBot_RemovesBotProfileAndSafeData_ButKeepsMessageHistory()
public async Task CreateBot_ReturnsGoneBecauseBotsAreCreatedThroughCreatorBot()
{
await using var database = new SqliteTestDatabase();
await using var dbContext = await database.CreateContextAsync();
var owner = CreateUser("owner");
dbContext.Users.Add(owner);
await dbContext.SaveChangesAsync();
var controller = ControllerTestFixture.CreateBotsController(dbContext, owner.Id);
var response = controller.CreateBot(new CreateBotRequest("helperbot", "Helper", "test bot"));
var result = Assert.IsType<ObjectResult>(response.Result);
Assert.Equal(410, result.StatusCode);
var message = Assert.IsType<string>(result.Value);
Assert.Contains("@botfatherbot", message);
Assert.Contains("/создать_бота", message);
Assert.False(await dbContext.Bots.AnyAsync());
Assert.False(await dbContext.Users.AnyAsync(x => x.IsBot));
}
[Fact]
public async Task DeleteBot_RemovesBotUserAndDirectChatHistory()
{
await using var database = new SqliteTestDatabase();
await using var dbContext = await database.CreateContextAsync();
@@ -84,14 +106,9 @@ public sealed class BotsControllerTests
Assert.False(await dbContext.UserSessions.AnyAsync(x => x.UserId == botUser.Id));
Assert.False(await dbContext.PushDevices.AnyAsync(x => x.UserId == botUser.Id));
var reloadedBotUser = await dbContext.Users.SingleAsync(x => x.Id == botUser.Id);
Assert.True(reloadedBotUser.IsBot);
Assert.True(reloadedBotUser.IsBlocked);
Assert.NotNull(reloadedBotUser.BlockedAt);
var reloadedMessage = await dbContext.Messages.AsNoTracking().SingleAsync(x => x.Id == message.Id);
Assert.Equal(botUser.Id, reloadedMessage.SenderId);
Assert.Equal("history", reloadedMessage.Text);
Assert.False(await dbContext.Users.AnyAsync(x => x.Id == botUser.Id));
Assert.False(await dbContext.Chats.AnyAsync(x => x.Id == chat.Id));
Assert.False(await dbContext.Messages.AnyAsync(x => x.Id == message.Id));
}
[Fact]
@@ -49,6 +49,72 @@ public sealed class ChannelDtoMapperTests
Assert.Equal(owner.Id, visibleMember.User.Id);
}
[Fact]
public void PublicChannelPreview_HidesContactOnlyProfileFields_ForNonMember()
{
var owner = CreateUser("owner", "Owner");
owner.PhoneNumber = "+10000000001";
owner.AvatarStoragePath = "owner-avatar.png";
owner.PrivacySettings = new UserPrivacySettings
{
UserId = owner.Id,
User = owner,
PhoneNumberVisibility = "contacts",
LastSeenVisibility = "contacts",
ProfilePhotoVisibility = "contacts"
};
owner.Sessions.Add(new UserSession
{
Id = Guid.NewGuid(),
UserId = owner.Id,
User = owner,
LastSeenAt = DateTimeOffset.UtcNow.AddMinutes(-5)
});
var viewer = CreateUser("viewer", "Viewer");
var channel = CreateChannel(owner);
channel.PublicUsername = "ikar_updates";
var details = channel.ToDetailsDto(viewer.Id, new PresenceTracker());
var visibleMember = Assert.Single(details.Members!);
Assert.Equal(owner.Id, visibleMember.User.Id);
Assert.Null(visibleMember.User.PhoneNumber);
Assert.Null(visibleMember.User.LastSeenAt);
Assert.Null(visibleMember.User.AvatarPath);
}
[Fact]
public void ChannelDetails_ShowsContactOnlyProfileFields_ForSubscriber()
{
var owner = CreateUser("owner", "Owner");
owner.PhoneNumber = "+10000000001";
owner.AvatarStoragePath = "owner-avatar.png";
owner.PrivacySettings = new UserPrivacySettings
{
UserId = owner.Id,
User = owner,
PhoneNumberVisibility = "contacts",
LastSeenVisibility = "contacts",
ProfilePhotoVisibility = "contacts"
};
owner.Sessions.Add(new UserSession
{
Id = Guid.NewGuid(),
UserId = owner.Id,
User = owner,
LastSeenAt = DateTimeOffset.UtcNow.AddMinutes(-5)
});
var subscriber = CreateUser("subscriber", "Subscriber");
var channel = CreateChannel(owner, subscriber);
var details = channel.ToDetailsDto(subscriber.Id, new PresenceTracker());
var ownerMember = Assert.Single(details.Members!, member => member.User.Id == owner.Id);
Assert.Equal(owner.PhoneNumber, ownerMember.User.PhoneNumber);
Assert.NotNull(ownerMember.User.LastSeenAt);
Assert.NotNull(ownerMember.User.AvatarPath);
}
[Fact]
public void ChannelPost_MasksSenderForRegularSubscriber_AndKeepsViewCount()
{
@@ -83,6 +149,69 @@ public sealed class ChannelDtoMapperTests
Assert.Equal(owner.Id, ownerDto.Sender.Id);
}
[Fact]
public void MessageAttachment_MapsExplicitKindToVoiceNote()
{
var owner = CreateUser("owner", "Owner");
var channel = CreateChannel(owner);
var message = new Message
{
Id = Guid.NewGuid(),
ChatId = channel.Id,
Chat = channel,
SenderId = owner.Id,
Sender = owner,
Text = string.Empty,
SentAt = DateTimeOffset.UtcNow
};
message.Attachments.Add(new MessageAttachment
{
Id = Guid.NewGuid(),
MessageId = message.Id,
Message = message,
OriginalFileName = "VOICE.OGG",
ContentType = "application/ogg",
Kind = AttachmentKind.VoiceNote,
StoragePath = "VOICE.OGG",
FileSizeBytes = 4096
});
var dto = message.ToDto(new PresenceTracker(), owner.Id);
var attachment = Assert.Single(dto.Attachments);
Assert.Equal(AttachmentKind.VoiceNote, attachment.Kind);
}
[Fact]
public void MessageAttachment_InfersVoiceNoteFromOggLikeFileName()
{
var owner = CreateUser("owner", "Owner");
var channel = CreateChannel(owner);
var message = new Message
{
Id = Guid.NewGuid(),
ChatId = channel.Id,
Chat = channel,
SenderId = owner.Id,
Sender = owner,
Text = string.Empty,
SentAt = DateTimeOffset.UtcNow
};
message.Attachments.Add(new MessageAttachment
{
Id = Guid.NewGuid(),
MessageId = message.Id,
Message = message,
OriginalFileName = "Правила ogg",
ContentType = "application/octet-stream",
StoragePath = "rules",
FileSizeBytes = 4096
});
var dto = message.ToDto(new PresenceTracker(), owner.Id);
var attachment = Assert.Single(dto.Attachments);
Assert.Equal(AttachmentKind.VoiceNote, attachment.Kind);
}
private static Chat CreateChannel(User owner, params User[] subscribers)
{
var channel = new Chat
File diff suppressed because it is too large Load Diff
+118 -7
View File
@@ -6,11 +6,14 @@ using Ikar.Server.Infrastructure.Bots;
using Ikar.Server.Infrastructure.Auth;
using Ikar.Server.Infrastructure.Hubs;
using Ikar.Server.Infrastructure.Push;
using Ikar.Server.Infrastructure.Storage;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.SignalR;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.FileProviders;
namespace Ikar.Server.Tests;
@@ -37,15 +40,28 @@ internal sealed class SqliteTestDatabase : IAsyncDisposable
internal static class ControllerTestFixture
{
public static ChatsController CreateChatsController(IkarDbContext dbContext, Guid userId)
public static ChatsController CreateChatsController(
IkarDbContext dbContext,
Guid userId,
AttachmentStorageService? attachmentStorageService = null)
{
var pushDispatchQueue = new PushDispatchQueue();
var hubContext = new NoopHubContext<MessengerHub>();
var botAttachmentStorageService = attachmentStorageService ?? CreateAttachmentStorageService(Path.GetTempPath());
var controller = new ChatsController(
dbContext,
new PresenceTracker(),
new NoopHubContext<MessengerHub>(),
attachmentStorageService: null!,
new PushDispatchQueue(),
new BotUpdateService(dbContext));
hubContext,
attachmentStorageService: attachmentStorageService!,
pushDispatchQueue,
new BotUpdateService(dbContext),
new BotCreatorService(
dbContext,
new BotTokenService(),
new BotDeletionService(dbContext, botAttachmentStorageService),
new PresenceTracker(),
hubContext,
pushDispatchQueue));
AttachUser(controller, userId);
return controller;
}
@@ -62,21 +78,38 @@ internal static class ControllerTestFixture
public static BotsController CreateBotsController(IkarDbContext dbContext, Guid userId)
{
var controller = new BotsController(dbContext, new BotTokenService());
var attachmentStorageService = CreateAttachmentStorageService(Path.GetTempPath());
var controller = new BotsController(
dbContext,
new BotTokenService(),
new BotDeletionService(dbContext, attachmentStorageService));
AttachUser(controller, userId);
return controller;
}
public static StoriesController CreateStoriesController(IkarDbContext dbContext, Guid userId)
{
return CreateStoriesController(dbContext, userId, new NoopHubContext<MessengerHub>());
}
public static StoriesController CreateStoriesController(
IkarDbContext dbContext,
Guid userId,
IHubContext<MessengerHub> hubContext,
AttachmentStorageService? attachmentStorageService = null)
{
var controller = new StoriesController(
dbContext,
new PresenceTracker(),
attachmentStorageService: null!);
hubContext,
attachmentStorageService ?? new AttachmentStorageService(new TestWebHostEnvironment(Path.GetTempPath())));
AttachUser(controller, userId);
return controller;
}
public static AttachmentStorageService CreateAttachmentStorageService(string contentRootPath) =>
new(new TestWebHostEnvironment(contentRootPath));
public static T ValueOf<T>(ActionResult<T> actionResult)
{
var ok = Assert.IsType<OkObjectResult>(actionResult.Result);
@@ -96,6 +129,84 @@ internal static class ControllerTestFixture
}
};
}
private sealed class TestWebHostEnvironment(string contentRootPath) : IWebHostEnvironment
{
public string ApplicationName { get; set; } = "Ikar.Server.Tests";
public IFileProvider WebRootFileProvider { get; set; } = new NullFileProvider();
public string WebRootPath { get; set; } = contentRootPath;
public string EnvironmentName { get; set; } = "Development";
public string ContentRootPath { get; set; } = contentRootPath;
public IFileProvider ContentRootFileProvider { get; set; } = new PhysicalFileProvider(contentRootPath);
}
}
internal sealed record RecordedHubSend(
string TargetKind,
IReadOnlyList<string> Targets,
string Method,
int ArgumentCount);
internal sealed class RecordingHubContext<THub> : IHubContext<THub>
where THub : Hub
{
private readonly RecordingHubClients _clients = new();
public IHubClients Clients => _clients;
public IGroupManager Groups { get; } = new RecordingGroupManager();
public IReadOnlyList<RecordedHubSend> Sends => _clients.Sends;
private sealed class RecordingHubClients : IHubClients
{
private readonly List<RecordedHubSend> _sends = [];
public IReadOnlyList<RecordedHubSend> Sends => _sends;
public IClientProxy All => Proxy("All", []);
public IClientProxy AllExcept(IReadOnlyList<string> excludedConnectionIds) => Proxy("AllExcept", excludedConnectionIds);
public IClientProxy Client(string connectionId) => Proxy("Client", [connectionId]);
public IClientProxy Clients(IReadOnlyList<string> connectionIds) => Proxy("Clients", connectionIds);
public IClientProxy Group(string groupName) => Proxy("Group", [groupName]);
public IClientProxy GroupExcept(string groupName, IReadOnlyList<string> excludedConnectionIds) =>
Proxy("GroupExcept", [groupName, .. excludedConnectionIds]);
public IClientProxy Groups(IReadOnlyList<string> groupNames) => Proxy("Groups", groupNames);
public IClientProxy User(string userId) => Proxy("User", [userId]);
public IClientProxy Users(IReadOnlyList<string> userIds) => Proxy("Users", userIds);
private IClientProxy Proxy(string targetKind, IReadOnlyList<string> targets) =>
new RecordingClientProxy(_sends, targetKind, targets);
}
private sealed class RecordingClientProxy(
List<RecordedHubSend> sends,
string targetKind,
IReadOnlyList<string> targets) : IClientProxy
{
public Task SendCoreAsync(string method, object?[] args, CancellationToken cancellationToken = default)
{
sends.Add(new RecordedHubSend(targetKind, targets.ToList(), method, args.Length));
return Task.CompletedTask;
}
}
private sealed class RecordingGroupManager : IGroupManager
{
public Task AddToGroupAsync(string connectionId, string groupName, CancellationToken cancellationToken = default) =>
Task.CompletedTask;
public Task RemoveFromGroupAsync(string connectionId, string groupName, CancellationToken cancellationToken = default) =>
Task.CompletedTask;
}
}
internal sealed class NoopHubContext<THub> : IHubContext<THub>
@@ -1,5 +1,9 @@
using Ikar.Server.Controllers;
using Ikar.Server.Data.Entities;
using Ikar.Server.Infrastructure.Hubs;
using Ikar.Shared;
using Microsoft.AspNetCore.Http;
using Microsoft.EntityFrameworkCore;
using Microsoft.AspNetCore.Mvc;
namespace Ikar.Server.Tests;
@@ -33,12 +37,103 @@ public sealed class PrivacyAndStoriesControllerTests
Assert.Equal("contacts", settings.PhoneNumberVisibility);
Assert.Equal("nobody", settings.LastSeenVisibility);
Assert.Equal([bob.Id], settings.BlockedUserIds);
var blockedUser = Assert.Single(settings.BlockedUsers);
Assert.Equal(bob.Id, blockedUser.Id);
Assert.Equal("bob", blockedUser.DisplayName);
Assert.Empty(settings.AlwaysAllowUserIds);
Assert.Empty(settings.AlwaysAllowUsers);
Assert.Equal([bob.Id], settings.NeverAllowUserIds);
var neverAllowUser = Assert.Single(settings.NeverAllowUsers);
Assert.Equal(bob.Id, neverAllowUser.Id);
Assert.Equal("bob", neverAllowUser.DisplayName);
Assert.True(settings.PasscodeLockEnabled);
Assert.False(settings.ShowMessagePreview);
}
[Fact]
public async Task GetUser_HidesPrivatePhoneLastSeenAndAvatar()
{
await using var database = new SqliteTestDatabase();
await using var dbContext = await database.CreateContextAsync();
var alice = CreateUser("alice");
var bob = CreateUser("bob");
var phoneNumber = "+10000000001";
var normalizedPhoneNumber = PhoneNumberNormalizer.Normalize(phoneNumber)!;
alice.PhoneNumber = phoneNumber;
alice.NormalizedPhoneNumber = normalizedPhoneNumber;
alice.AvatarStoragePath = "avatar.png";
alice.AvatarContentType = "image/png";
alice.PrivacySettings = new UserPrivacySettings
{
UserId = alice.Id,
User = alice,
PhoneNumberVisibility = "nobody",
LastSeenVisibility = "nobody",
ProfilePhotoVisibility = "nobody"
};
alice.Sessions.Add(new UserSession
{
UserId = alice.Id,
User = alice,
LastSeenAt = new DateTimeOffset(2026, 1, 1, 12, 0, 0, TimeSpan.Zero)
});
var sharedChat = CreateDirectChat(alice, bob);
dbContext.AddRange(alice, bob, sharedChat);
await dbContext.SaveChangesAsync();
var controller = ControllerTestFixture.CreateUsersController(dbContext, bob.Id);
var response = await controller.GetUser(alice.Id, CancellationToken.None);
var user = ControllerTestFixture.ValueOf(response);
Assert.Null(user.PhoneNumber);
Assert.Null(user.LastSeenAt);
Assert.Null(user.AvatarPath);
Assert.False(user.IsOnline);
}
[Fact]
public async Task ResolveContacts_UsesPrivacyFilteredUserDto()
{
await using var database = new SqliteTestDatabase();
await using var dbContext = await database.CreateContextAsync();
var alice = CreateUser("alice");
var bob = CreateUser("bob");
var phoneNumber = "+10000000001";
var normalizedPhoneNumber = PhoneNumberNormalizer.Normalize(phoneNumber)!;
alice.PhoneNumber = phoneNumber;
alice.NormalizedPhoneNumber = normalizedPhoneNumber;
alice.AvatarStoragePath = "avatar.png";
alice.AvatarContentType = "image/png";
alice.PrivacySettings = new UserPrivacySettings
{
UserId = alice.Id,
User = alice,
PhoneNumberVisibility = "nobody",
LastSeenVisibility = "nobody",
ProfilePhotoVisibility = "nobody"
};
alice.Sessions.Add(new UserSession
{
UserId = alice.Id,
User = alice,
LastSeenAt = new DateTimeOffset(2026, 1, 1, 12, 0, 0, TimeSpan.Zero)
});
dbContext.AddRange(alice, bob);
await dbContext.SaveChangesAsync();
var controller = ControllerTestFixture.CreateUsersController(dbContext, bob.Id);
var response = await controller.ResolveContacts(
new ResolveContactsRequest([phoneNumber]),
CancellationToken.None);
var match = Assert.Single(ControllerTestFixture.ValueOf(response));
Assert.Equal(normalizedPhoneNumber, match.PhoneNumber);
Assert.Equal(alice.Id, match.User.Id);
Assert.Null(match.User.PhoneNumber);
Assert.Null(match.User.LastSeenAt);
Assert.Null(match.User.AvatarPath);
}
[Fact]
public async Task GetStories_ReturnsEveryoneStoriesWithoutSharedChat()
{
@@ -106,6 +201,118 @@ public sealed class PrivacyAndStoriesControllerTests
Assert.Equal("own", story.Text);
}
[Fact]
public async Task CreateStory_NotifiesAllClientsForEveryoneStory()
{
await using var database = new SqliteTestDatabase();
await using var dbContext = await database.CreateContextAsync();
var alice = CreateUser("alice");
dbContext.Users.Add(alice);
await dbContext.SaveChangesAsync();
var hubContext = new RecordingHubContext<MessengerHub>();
var controller = ControllerTestFixture.CreateStoriesController(dbContext, alice.Id, hubContext);
await controller.CreateStory(new CreateStoryRequest("public", StoryVisibility.Everyone), CancellationToken.None);
var send = Assert.Single(hubContext.Sends, x => x.Method == "StoriesChanged");
Assert.Equal("All", send.TargetKind);
Assert.Empty(send.Targets);
Assert.Equal(0, send.ArgumentCount);
}
[Fact]
public async Task CreateStory_NotifiesOnlyAuthorAndPeersForContactsStory()
{
await using var database = new SqliteTestDatabase();
await using var dbContext = await database.CreateContextAsync();
var alice = CreateUser("alice");
var bob = CreateUser("bob");
var charlie = CreateUser("charlie");
var sharedChat = CreateDirectChat(alice, bob);
dbContext.AddRange(alice, bob, charlie, sharedChat);
await dbContext.SaveChangesAsync();
var hubContext = new RecordingHubContext<MessengerHub>();
var controller = ControllerTestFixture.CreateStoriesController(dbContext, alice.Id, hubContext);
await controller.CreateStory(new CreateStoryRequest("contacts", StoryVisibility.Contacts), CancellationToken.None);
var send = Assert.Single(hubContext.Sends, x => x.Method == "StoriesChanged");
Assert.Equal("Users", send.TargetKind);
Assert.Equal(
new[] { alice.Id.ToString(), bob.Id.ToString() }.OrderBy(x => x).ToArray(),
send.Targets.OrderBy(x => x).ToArray());
Assert.DoesNotContain(charlie.Id.ToString(), send.Targets);
Assert.Equal(0, send.ArgumentCount);
}
[Fact]
public async Task CreateMediaStory_PreservesAllSelectedFilesInOrder()
{
var tempRoot = Path.Combine(Path.GetTempPath(), $"ikar-story-upload-{Guid.NewGuid():N}");
Directory.CreateDirectory(tempRoot);
try
{
await using var database = new SqliteTestDatabase();
await using var dbContext = await database.CreateContextAsync();
var alice = CreateUser("alice");
dbContext.Users.Add(alice);
await dbContext.SaveChangesAsync();
var controller = ControllerTestFixture.CreateStoriesController(
dbContext,
alice.Id,
new RecordingHubContext<MessengerHub>(),
ControllerTestFixture.CreateAttachmentStorageService(tempRoot));
var response = await controller.CreateMediaStory(
new UploadStoryRequest
{
Text = "trip",
Visibility = StoryVisibility.Everyone,
Files =
[
CreateFormFile([1, 2, 3], "first.jpg", "image/jpeg"),
CreateFormFile([4, 5, 6], "second.jpg", "image/jpeg"),
CreateFormFile([7, 8, 9], "third.jpg", "image/jpeg")
]
},
CancellationToken.None);
var dto = ControllerTestFixture.ValueOf(response);
Assert.Equal(3, dto.Attachments.Count);
Assert.Collection(
dto.Attachments,
attachment =>
{
Assert.Equal("first.jpg", attachment.FileName);
Assert.Equal(0, attachment.SortOrder);
},
attachment =>
{
Assert.Equal("second.jpg", attachment.FileName);
Assert.Equal(1, attachment.SortOrder);
},
attachment =>
{
Assert.Equal("third.jpg", attachment.FileName);
Assert.Equal(2, attachment.SortOrder);
});
var storedStory = await dbContext.Stories
.Include(x => x.Attachments)
.SingleAsync(x => x.Id == dto.Id);
var storedAttachments = storedStory.Attachments
.OrderBy(x => x.SortOrder)
.ToList();
Assert.Equal(3, storedAttachments.Count);
Assert.All(storedAttachments, attachment => Assert.True(File.Exists(attachment.StoragePath)));
}
finally
{
Directory.Delete(tempRoot, recursive: true);
}
}
[Fact]
public async Task GetAvatar_RespectsProfilePhotoContactsVisibility()
{
@@ -144,6 +351,44 @@ public sealed class PrivacyAndStoriesControllerTests
}
}
[Fact]
public async Task GetStories_HidesContactOnlyAuthorProfileFields_ForNonPeerViewer()
{
await using var database = new SqliteTestDatabase();
await using var dbContext = await database.CreateContextAsync();
var alice = CreateUser("alice");
var charlie = CreateUser("charlie");
alice.PhoneNumber = "+10000000001";
alice.AvatarStoragePath = "alice-avatar.png";
alice.PrivacySettings = new UserPrivacySettings
{
UserId = alice.Id,
User = alice,
PhoneNumberVisibility = "contacts",
LastSeenVisibility = "contacts",
ProfilePhotoVisibility = "contacts"
};
alice.Sessions.Add(new UserSession
{
Id = Guid.NewGuid(),
UserId = alice.Id,
User = alice,
LastSeenAt = DateTimeOffset.UtcNow.AddMinutes(-5)
});
var story = CreateStory(alice, "public", StoryVisibility.Everyone);
dbContext.AddRange(alice, charlie, story);
await dbContext.SaveChangesAsync();
var controller = ControllerTestFixture.CreateStoriesController(dbContext, charlie.Id);
var response = await controller.GetStories(CancellationToken.None);
var visibleStory = Assert.Single(ControllerTestFixture.ValueOf(response));
Assert.Equal(alice.Id, visibleStory.Author.Id);
Assert.Null(visibleStory.Author.PhoneNumber);
Assert.Null(visibleStory.Author.LastSeenAt);
Assert.Null(visibleStory.Author.AvatarPath);
}
private static User CreateUser(string username) =>
new()
{
@@ -179,4 +424,14 @@ public sealed class PrivacyAndStoriesControllerTests
CreatedAt = DateTimeOffset.UtcNow.AddMinutes(-5),
ExpiresAt = DateTimeOffset.UtcNow.AddHours(1)
};
private static FormFile CreateFormFile(byte[] content, string fileName, string contentType)
{
var stream = new MemoryStream(content);
return new FormFile(stream, 0, stream.Length, "files", fileName)
{
Headers = new HeaderDictionary(),
ContentType = contentType
};
}
}
@@ -7,6 +7,123 @@ namespace Ikar.Server.Tests;
public sealed class PushNotificationDispatcherTests
{
[Fact]
public async Task DispatchMessageAsync_DoesNotNotifyMutedRecipient()
{
await using var database = new SqliteTestDatabase();
await using var dbContext = await database.CreateContextAsync();
var senderUser = CreateUser("sender");
var mutedRecipient = CreateUser("muted");
var recipientDevice = CreateAndroidDevice(mutedRecipient, "muted-device", notificationsEnabled: true);
var chat = CreateChat(senderUser, mutedRecipient);
chat.Members.Single(member => member.UserId == mutedRecipient.Id).MutedUntil = DateTimeOffset.UtcNow.AddHours(1);
var message = new Message
{
Chat = chat,
ChatId = chat.Id,
Sender = senderUser,
SenderId = senderUser.Id,
Text = "hello"
};
dbContext.AddRange(senderUser, mutedRecipient, chat, message, recipientDevice);
await dbContext.SaveChangesAsync();
var sender = new RecordingPushNotificationSender();
var dispatcher = new PushNotificationDispatcher(
dbContext,
sender,
NullLogger<PushNotificationDispatcher>.Instance);
await dispatcher.DispatchMessageAsync(chat, message);
Assert.Empty(sender.MessageCalls);
}
[Fact]
public async Task DispatchMessageAsync_NotifiesRecipientWhenMuteExpired()
{
await using var database = new SqliteTestDatabase();
await using var dbContext = await database.CreateContextAsync();
var senderUser = CreateUser("sender");
var recipient = CreateUser("recipient");
var recipientDevice = CreateAndroidDevice(recipient, "recipient-device", notificationsEnabled: true);
var chat = CreateChat(senderUser, recipient);
chat.Members.Single(member => member.UserId == recipient.Id).MutedUntil = DateTimeOffset.UtcNow.AddMinutes(-1);
var message = new Message
{
Chat = chat,
ChatId = chat.Id,
Sender = senderUser,
SenderId = senderUser.Id,
Text = "hello"
};
dbContext.AddRange(senderUser, recipient, chat, message, recipientDevice);
await dbContext.SaveChangesAsync();
var sender = new RecordingPushNotificationSender();
var dispatcher = new PushNotificationDispatcher(
dbContext,
sender,
NullLogger<PushNotificationDispatcher>.Instance);
await dispatcher.DispatchMessageAsync(chat, message);
var call = Assert.Single(sender.MessageCalls);
Assert.Equal(chat.Id, call.Chat.Id);
Assert.Equal(message.Id, call.Message.Id);
var device = Assert.Single(call.Devices);
Assert.Equal(recipientDevice.Id, device.Id);
}
[Fact]
public async Task DispatchMessageAsync_HidesPreviewAndSenderPhoneForPrivateRecipient()
{
await using var database = new SqliteTestDatabase();
await using var dbContext = await database.CreateContextAsync();
var senderUser = CreateUser("sender");
senderUser.PhoneNumber = "+10000000000";
var recipient = CreateUser("recipient");
var recipientDevice = CreateAndroidDevice(recipient, "recipient-device", notificationsEnabled: true);
var chat = CreateChat(senderUser, recipient);
var message = new Message
{
Chat = chat,
ChatId = chat.Id,
Sender = senderUser,
SenderId = senderUser.Id,
Text = "secret"
};
var privacy = new UserPrivacySettings
{
UserId = recipient.Id,
User = recipient,
ShowMessagePreview = false
};
dbContext.AddRange(senderUser, recipient, chat, message, recipientDevice, privacy);
await dbContext.SaveChangesAsync();
var sender = new RecordingPushNotificationSender();
var dispatcher = new PushNotificationDispatcher(
dbContext,
sender,
NullLogger<PushNotificationDispatcher>.Instance);
await dispatcher.DispatchMessageAsync(chat, message);
var call = Assert.Single(sender.MessageCalls);
Assert.Equal(message.Id, call.Message.Id);
Assert.Equal("Новое сообщение", call.Message.Text);
Assert.Null(call.Message.Sender.PhoneNumber);
var device = Assert.Single(call.Devices);
Assert.Equal(recipientDevice.Id, device.Id);
}
[Fact]
public async Task DispatchMessageReactionAsync_SendsOnlyToMessageAuthorDevices()
{
@@ -126,14 +243,18 @@ public sealed class PushNotificationDispatcherTests
private sealed class RecordingPushNotificationSender : IPushNotificationSender
{
public List<MessagePushCall> MessageCalls { get; } = [];
public List<ReactionPushCall> ReactionCalls { get; } = [];
public Task SendMessageAsync(
Chat chat,
Message message,
IReadOnlyCollection<PushDevice> devices,
CancellationToken cancellationToken = default) =>
Task.CompletedTask;
CancellationToken cancellationToken = default)
{
MessageCalls.Add(new MessagePushCall(chat, message, devices.ToList()));
return Task.CompletedTask;
}
public Task SendMessageReactionAsync(
Chat chat,
@@ -166,6 +287,11 @@ public sealed class PushNotificationDispatcherTests
Task.CompletedTask;
}
private sealed record MessagePushCall(
Chat Chat,
Message Message,
IReadOnlyCollection<PushDevice> Devices);
private sealed record ReactionPushCall(
Chat Chat,
Message Message,
+154 -2
View File
@@ -81,6 +81,109 @@ public sealed class SearchControllerTests
Assert.Equal(["alpha one"], secondPage.Messages.Select(x => x.Text).ToArray());
}
[Fact]
public async Task GetChats_IgnoresExpiredMessagesForPreviewAndUnreadCount()
{
await using var database = new SqliteTestDatabase();
await using var dbContext = await database.CreateContextAsync();
var alice = CreateUser("alice");
var bob = CreateUser("bob");
var chat = CreateChat(alice, bob);
var now = DateTimeOffset.UtcNow;
dbContext.AddRange(
alice,
bob,
chat,
CreateMessage(chat, bob, "visible", now.AddMinutes(-10)),
CreateMessage(chat, bob, "expired", now.AddMinutes(-5), expiresAt: now.AddMinutes(-1)));
await dbContext.SaveChangesAsync();
var controller = ControllerTestFixture.CreateChatsController(dbContext, alice.Id);
var response = await controller.GetChats(CancellationToken.None);
var summary = Assert.Single(ControllerTestFixture.ValueOf(response));
Assert.Equal("bob: visible", summary.LastMessagePreview);
Assert.Equal(1, summary.UnreadCount);
}
[Fact]
public async Task MarkChatRead_ClearsUnreadCountForChat()
{
await using var database = new SqliteTestDatabase();
await using var dbContext = await database.CreateContextAsync();
var alice = CreateUser("alice");
var bob = CreateUser("bob");
var chat = CreateChat(alice, bob);
var first = CreateMessage(chat, bob, "first", new DateTimeOffset(2026, 1, 1, 10, 0, 0, TimeSpan.Zero));
var second = CreateMessage(chat, bob, "second", new DateTimeOffset(2026, 1, 1, 10, 1, 0, TimeSpan.Zero));
dbContext.AddRange(alice, bob, chat, first, second);
await dbContext.SaveChangesAsync();
var controller = ControllerTestFixture.CreateChatsController(dbContext, alice.Id);
var before = Assert.Single(ControllerTestFixture.ValueOf(await controller.GetChats(CancellationToken.None)));
Assert.Equal(2, before.UnreadCount);
await controller.MarkChatRead(chat.Id, CancellationToken.None);
var after = Assert.Single(ControllerTestFixture.ValueOf(await controller.GetChats(CancellationToken.None)));
Assert.Equal(0, after.UnreadCount);
Assert.Equal(second.SentAt, chat.Members.First(member => member.UserId == alice.Id).LastReadAt);
}
[Fact]
public async Task SearchMessagesPage_IgnoresExpiredMessages()
{
await using var database = new SqliteTestDatabase();
await using var dbContext = await database.CreateContextAsync();
var alice = CreateUser("alice");
var bob = CreateUser("bob");
var chat = CreateChat(alice, bob);
var now = DateTimeOffset.UtcNow;
dbContext.AddRange(
alice,
bob,
chat,
CreateMessage(chat, bob, "alpha visible", now.AddMinutes(-10)),
CreateMessage(chat, bob, "alpha expired", now.AddMinutes(-5), expiresAt: now.AddMinutes(-1)));
await dbContext.SaveChangesAsync();
var controller = ControllerTestFixture.CreateChatsController(dbContext, alice.Id);
var response = await controller.SearchMessagesPage(chat.Id, q: "alpha", page: 1, limit: 10);
var page = ControllerTestFixture.ValueOf(response);
var message = Assert.Single(page.Messages);
Assert.Equal("alpha visible", message.Text);
}
[Fact]
public async Task SearchMessagesPage_FiltersPinnedMessages()
{
await using var database = new SqliteTestDatabase();
await using var dbContext = await database.CreateContextAsync();
var alice = CreateUser("alice");
var bob = CreateUser("bob");
var chat = CreateChat(alice, bob);
var pinned = CreateMessage(chat, bob, "pinned note", new DateTimeOffset(2026, 1, 2, 10, 0, 0, TimeSpan.Zero));
pinned.PinnedAt = new DateTimeOffset(2026, 1, 2, 10, 5, 0, TimeSpan.Zero);
pinned.PinnedByUserId = alice.Id;
pinned.PinnedByUser = alice;
dbContext.AddRange(
alice,
bob,
chat,
CreateMessage(chat, bob, "regular note", new DateTimeOffset(2026, 1, 3, 10, 0, 0, TimeSpan.Zero)),
pinned);
await dbContext.SaveChangesAsync();
var controller = ControllerTestFixture.CreateChatsController(dbContext, alice.Id);
var response = await controller.SearchMessagesPage(chat.Id, q: " ", filter: "pinned", page: 1, limit: 10);
var page = ControllerTestFixture.ValueOf(response);
var message = Assert.Single(page.Messages);
Assert.Equal(pinned.Id, message.Id);
Assert.NotNull(message.PinnedAt);
}
[Fact]
public async Task SearchPage_ReturnsPublicChannelForNonMember_ByAtUsername()
{
@@ -104,6 +207,49 @@ public sealed class SearchControllerTests
Assert.False(result.Chat.IsSubscribed);
}
[Fact]
public async Task GetPublicChannels_ReturnsPublicChannelsForNonMember_AndFiltersByTitle()
{
await using var database = new SqliteTestDatabase();
await using var dbContext = await database.CreateContextAsync();
var viewer = CreateUser("viewer");
var owner = CreateUser("owner");
var publicChannel = CreateChannel(owner, "Daily News", "daily_news", "Public news channel");
var privateChannel = CreateChannel(owner, "Daily News Private", null, "Private news channel");
dbContext.AddRange(viewer, owner, publicChannel, privateChannel);
await dbContext.SaveChangesAsync();
var controller = ControllerTestFixture.CreateChatsController(dbContext, viewer.Id);
var response = await controller.GetPublicChannels(q: "daily", limit: 10);
var channels = ControllerTestFixture.ValueOf(response);
var channel = Assert.Single(channels);
Assert.Equal(publicChannel.Id, channel.Id);
Assert.Equal(ChatType.Channel, channel.Type);
Assert.Equal("daily_news", channel.PublicUsername);
Assert.True(channel.CanSubscribe);
Assert.False(channel.IsSubscribed);
}
[Fact]
public async Task GetPublicChannels_FindsCyrillicTitleIgnoringCase()
{
await using var database = new SqliteTestDatabase();
await using var dbContext = await database.CreateContextAsync();
var viewer = CreateUser("viewer");
var owner = CreateUser("owner");
var publicChannel = CreateChannel(owner, "РИА новости", "ria_news", "Новости из агрегатора");
dbContext.AddRange(viewer, owner, publicChannel);
await dbContext.SaveChangesAsync();
var controller = ControllerTestFixture.CreateChatsController(dbContext, viewer.Id);
var response = await controller.GetPublicChannels(q: "риа", limit: 10);
var channel = Assert.Single(ControllerTestFixture.ValueOf(response));
Assert.Equal(publicChannel.Id, channel.Id);
Assert.Equal("ria_news", channel.PublicUsername);
}
private static User CreateUser(string username) =>
new()
{
@@ -129,7 +275,12 @@ public sealed class SearchControllerTests
return chat;
}
private static Message CreateMessage(Chat chat, User sender, string text, DateTimeOffset sentAt)
private static Message CreateMessage(
Chat chat,
User sender,
string text,
DateTimeOffset sentAt,
DateTimeOffset? expiresAt = null)
{
var message = new Message
{
@@ -139,7 +290,8 @@ public sealed class SearchControllerTests
SenderId = sender.Id,
Sender = sender,
Text = text,
SentAt = sentAt
SentAt = sentAt,
ExpiresAt = expiresAt
};
chat.Messages.Add(message);
return message;
@@ -0,0 +1,105 @@
using Ikar.Server.Data.Entities;
using Ikar.Shared;
using Microsoft.AspNetCore.Mvc;
namespace Ikar.Server.Tests;
public sealed class StoryReplyControllerTests
{
[Fact]
public async Task SendMessage_WithStoryReplyStoryId_PersistsStoryReplySnapshot()
{
await using var database = new SqliteTestDatabase();
await using var dbContext = await database.CreateContextAsync();
var author = CreateUser("author");
var viewer = CreateUser("viewer");
var chat = CreateDirectChat(author, viewer);
var story = new Story
{
Id = Guid.NewGuid(),
AuthorUserId = author.Id,
AuthorUser = author,
Text = "Morning update from the road",
Visibility = StoryVisibility.Everyone,
CreatedAt = DateTimeOffset.UtcNow.AddMinutes(-5),
ExpiresAt = DateTimeOffset.UtcNow.AddHours(1)
};
dbContext.AddRange(author, viewer, chat, story);
await dbContext.SaveChangesAsync();
var controller = ControllerTestFixture.CreateChatsController(dbContext, viewer.Id);
var response = await controller.SendMessage(
chat.Id,
new SendMessageRequest("Looks good", StoryReplyStoryId: story.Id),
CancellationToken.None);
var dto = ControllerTestFixture.ValueOf(response);
Assert.Equal("Looks good", dto.Text);
Assert.NotNull(dto.StoryReply);
Assert.Equal(story.Id, dto.StoryReply!.StoryId);
Assert.Equal(author.Id, dto.StoryReply.AuthorUserId);
Assert.Equal("Morning update from the road", dto.StoryReply.PreviewText);
Assert.Equal("text", dto.StoryReply.AttachmentKind);
var storedMessage = Assert.Single(dbContext.Messages.Where(message => message.ChatId == chat.Id));
Assert.Equal(story.Id, storedMessage.StoryReplyStoryId);
Assert.Equal(author.Id, storedMessage.StoryReplyAuthorUserId);
Assert.Equal("Morning update from the road", storedMessage.StoryReplyPreviewText);
Assert.Equal("text", storedMessage.StoryReplyAttachmentKind);
}
[Fact]
public async Task SendMessage_WithStoryReplyStoryId_RejectsWrongDirectChat()
{
await using var database = new SqliteTestDatabase();
await using var dbContext = await database.CreateContextAsync();
var author = CreateUser("author");
var viewer = CreateUser("viewer");
var other = CreateUser("other");
var wrongChat = CreateDirectChat(viewer, other);
var story = new Story
{
Id = Guid.NewGuid(),
AuthorUserId = author.Id,
AuthorUser = author,
Text = "Morning update",
Visibility = StoryVisibility.Everyone,
CreatedAt = DateTimeOffset.UtcNow.AddMinutes(-5),
ExpiresAt = DateTimeOffset.UtcNow.AddHours(1)
};
dbContext.AddRange(author, viewer, other, wrongChat, story);
await dbContext.SaveChangesAsync();
var controller = ControllerTestFixture.CreateChatsController(dbContext, viewer.Id);
var response = await controller.SendMessage(
wrongChat.Id,
new SendMessageRequest("Looks good", StoryReplyStoryId: story.Id),
CancellationToken.None);
Assert.IsType<BadRequestObjectResult>(response.Result);
}
private static User CreateUser(string username) =>
new()
{
Id = Guid.NewGuid(),
Username = username,
NormalizedUsername = username.ToUpperInvariant(),
DisplayName = username,
PasswordHash = "test"
};
private static Chat CreateDirectChat(User first, User second)
{
var chat = new Chat
{
Id = Guid.NewGuid(),
Type = ChatType.Direct,
CreatedById = first.Id,
CreatedBy = first
};
chat.Members.Add(new ChatMember { ChatId = chat.Id, Chat = chat, UserId = first.Id, User = first });
chat.Members.Add(new ChatMember { ChatId = chat.Id, Chat = chat, UserId = second.Id, User = second });
return chat;
}
}
+249 -9
View File
@@ -16,7 +16,9 @@ public sealed class AuthController(
IkarDbContext dbContext,
TokenService tokenService,
PresenceTracker presenceTracker,
PhoneAuthChallengeStore phoneAuthChallengeStore) : ControllerBase
PhoneAuthChallengeStore phoneAuthChallengeStore,
EmailAuthChallengeStore emailAuthChallengeStore,
IEmailCodeSender emailCodeSender) : ControllerBase
{
private readonly PasswordHasher<User> _passwordHasher = new();
@@ -146,12 +148,17 @@ public sealed class AuthController(
var user = await dbContext.Users
.AsNoTracking()
.SingleOrDefaultAsync(x => x.NormalizedPhoneNumber == normalizedPhoneNumber, cancellationToken);
if (user is null)
{
return NotFound("Phone code login is available only for existing accounts. Register by email.");
}
if (user?.IsBlocked == true)
{
return StatusCode(StatusCodes.Status423Locked, "User is blocked.");
}
var challenge = phoneAuthChallengeStore.Create(normalizedPhoneNumber, user is not null);
var challenge = phoneAuthChallengeStore.Create(normalizedPhoneNumber, isRegistered: true);
return Ok(new PhoneCodeChallengeDto(
challenge.Id,
challenge.ExpiresAt,
@@ -186,13 +193,7 @@ public sealed class AuthController(
if (user is null)
{
var displayName = request.DisplayName?.Trim() ?? string.Empty;
if (string.IsNullOrWhiteSpace(displayName))
{
return ValidationProblem("Display name is required for a new account.");
}
user = await CreatePhoneOnlyUserAsync(normalizedPhoneNumber, request.PhoneNumber, displayName, cancellationToken);
return NotFound("Phone code login is available only for existing accounts. Register by email.");
}
if (user.IsBlocked)
@@ -207,6 +208,147 @@ public sealed class AuthController(
return Ok(session with { User = user.ToDto(presenceTracker) });
}
[HttpPost("request-email-code")]
[AllowAnonymous]
public async Task<ActionResult<EmailCodeChallengeDto>> RequestEmailCode(
RequestEmailCodeRequest request,
CancellationToken cancellationToken)
{
var normalizedEmail = EmailAddressNormalizer.Normalize(request.Email);
if (normalizedEmail is null)
{
return ValidationProblem("Valid email is required.");
}
var normalizedPhoneNumber = PhoneNumberNormalizer.Normalize(request.PhoneNumber);
if (normalizedPhoneNumber is null)
{
return ValidationProblem("Valid phone number is required.");
}
var email = request.Email.Trim();
var (user, conflictMessage) = await LoadEmailPhoneUserAsync(
normalizedEmail,
normalizedPhoneNumber,
cancellationToken);
if (conflictMessage is not null)
{
return Conflict(conflictMessage);
}
if (RequiresAllowedRegistrationDomain(user, normalizedEmail) &&
!EmailAddressNormalizer.IsAllowedRegistrationDomain(normalizedEmail))
{
return BadRequest(EmailAddressNormalizer.UnsupportedRegistrationDomainMessage);
}
if (user?.IsBlocked == true)
{
return StatusCode(StatusCodes.Status423Locked, "User is blocked.");
}
var challenge = emailAuthChallengeStore.Create(normalizedEmail, normalizedPhoneNumber, user is not null);
try
{
await emailCodeSender.SendLoginCodeAsync(email, challenge.Code, challenge.ExpiresAt, cancellationToken);
}
catch
{
emailAuthChallengeStore.Remove(challenge.Id);
throw;
}
return Ok(new EmailCodeChallengeDto(
challenge.Id,
challenge.ExpiresAt,
challenge.IsRegistered));
}
[HttpPost("verify-email-code")]
[AllowAnonymous]
public async Task<ActionResult<AuthSessionDto>> VerifyEmailCode(
VerifyEmailCodeRequest request,
CancellationToken cancellationToken)
{
var normalizedEmail = EmailAddressNormalizer.Normalize(request.Email);
if (normalizedEmail is null)
{
return ValidationProblem("Valid email is required.");
}
var normalizedPhoneNumber = PhoneNumberNormalizer.Normalize(request.PhoneNumber);
if (normalizedPhoneNumber is null)
{
return ValidationProblem("Valid phone number is required.");
}
var challenge = emailAuthChallengeStore.Consume(
request.ChallengeId?.Trim() ?? string.Empty,
normalizedEmail,
normalizedPhoneNumber,
request.Code?.Trim() ?? string.Empty);
if (challenge is null)
{
return Unauthorized("Invalid or expired verification code.");
}
var (user, conflictMessage) = await LoadEmailPhoneUserAsync(
normalizedEmail,
normalizedPhoneNumber,
cancellationToken);
if (conflictMessage is not null)
{
return Conflict(conflictMessage);
}
if (RequiresAllowedRegistrationDomain(user, normalizedEmail) &&
!EmailAddressNormalizer.IsAllowedRegistrationDomain(normalizedEmail))
{
return BadRequest(EmailAddressNormalizer.UnsupportedRegistrationDomainMessage);
}
if (user is null)
{
var displayName = request.DisplayName?.Trim() ?? string.Empty;
if (string.IsNullOrWhiteSpace(displayName))
{
return ValidationProblem("Display name is required for a new account.");
}
user = await CreateEmailPhoneUserAsync(
normalizedEmail,
request.Email,
normalizedPhoneNumber,
request.PhoneNumber,
displayName,
cancellationToken);
}
if (user.IsBot)
{
return Unauthorized("Bot accounts cannot sign in through the user client.");
}
if (user.IsBlocked)
{
return StatusCode(StatusCodes.Status423Locked, "User is blocked.");
}
await EnsureEmailPhoneLinkedAsync(
user,
normalizedEmail,
request.Email,
normalizedPhoneNumber,
request.PhoneNumber,
cancellationToken);
var session = await tokenService.CreateSessionAsync(
user,
ClientVersionRequestReader.Read(Request),
cancellationToken);
return Ok(session with { User = user.ToDto(presenceTracker) });
}
[HttpPost("refresh")]
[AllowAnonymous]
public async Task<ActionResult<AuthSessionDto>> Refresh(RefreshSessionRequest request, CancellationToken cancellationToken)
@@ -258,6 +400,104 @@ public sealed class AuthController(
return user;
}
private async Task<User> CreateEmailPhoneUserAsync(
string normalizedEmail,
string rawEmail,
string normalizedPhoneNumber,
string rawPhoneNumber,
string displayName,
CancellationToken cancellationToken)
{
var username = await GenerateInternalUsernameAsync(cancellationToken);
var user = new User
{
Username = username,
NormalizedUsername = username.ToUpperInvariant(),
DisplayName = displayName,
Email = rawEmail.Trim(),
NormalizedEmail = normalizedEmail,
PhoneNumber = rawPhoneNumber.Trim(),
NormalizedPhoneNumber = normalizedPhoneNumber
};
user.PasswordHash = _passwordHasher.HashPassword(user, Guid.NewGuid().ToString("N"));
dbContext.Users.Add(user);
await dbContext.SaveChangesAsync(cancellationToken);
return user;
}
private async Task<(User? User, string? ConflictMessage)> LoadEmailPhoneUserAsync(
string normalizedEmail,
string normalizedPhoneNumber,
CancellationToken cancellationToken)
{
var users = await dbContext.Users
.Where(x =>
x.NormalizedEmail == normalizedEmail ||
x.NormalizedPhoneNumber == normalizedPhoneNumber)
.ToListAsync(cancellationToken);
var emailUser = users.SingleOrDefault(x => x.NormalizedEmail == normalizedEmail);
var phoneUser = users.SingleOrDefault(x => x.NormalizedPhoneNumber == normalizedPhoneNumber);
if (emailUser is not null && phoneUser is not null && emailUser.Id != phoneUser.Id)
{
return (null, "Email and phone number belong to different accounts.");
}
var user = emailUser ?? phoneUser;
if (user is null)
{
return (null, null);
}
if (!string.IsNullOrWhiteSpace(user.NormalizedEmail) &&
!string.Equals(user.NormalizedEmail, normalizedEmail, StringComparison.Ordinal))
{
return (null, "Phone number is already linked to another email.");
}
if (!string.IsNullOrWhiteSpace(user.NormalizedPhoneNumber) &&
!string.Equals(user.NormalizedPhoneNumber, normalizedPhoneNumber, StringComparison.Ordinal))
{
return (null, "Email is already linked to another phone number.");
}
return (user, null);
}
private static bool RequiresAllowedRegistrationDomain(User? user, string normalizedEmail) =>
user is null ||
!string.Equals(user.NormalizedEmail, normalizedEmail, StringComparison.Ordinal);
private async Task EnsureEmailPhoneLinkedAsync(
User user,
string normalizedEmail,
string rawEmail,
string normalizedPhoneNumber,
string rawPhoneNumber,
CancellationToken cancellationToken)
{
var changed = false;
if (string.IsNullOrWhiteSpace(user.NormalizedEmail))
{
user.Email = rawEmail.Trim();
user.NormalizedEmail = normalizedEmail;
changed = true;
}
if (string.IsNullOrWhiteSpace(user.NormalizedPhoneNumber))
{
user.PhoneNumber = rawPhoneNumber.Trim();
user.NormalizedPhoneNumber = normalizedPhoneNumber;
changed = true;
}
if (changed)
{
await dbContext.SaveChangesAsync(cancellationToken);
}
}
private async Task<string> GenerateInternalUsernameAsync(CancellationToken cancellationToken)
{
while (true)
+438 -1
View File
@@ -4,8 +4,10 @@ using Ikar.Server.Data.Entities;
using Ikar.Server.Infrastructure;
using Ikar.Server.Infrastructure.Auth;
using Ikar.Server.Infrastructure.Bots;
using Ikar.Server.Infrastructure.Channels;
using Ikar.Server.Infrastructure.Hubs;
using Ikar.Server.Infrastructure.Push;
using Ikar.Server.Infrastructure.Storage;
using Ikar.Shared;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
@@ -23,8 +25,13 @@ public sealed class BotApiController(
BotTokenService botTokenService,
BotUpdateService botUpdateService,
IHubContext<MessengerHub> hubContext,
PushDispatchQueue pushDispatchQueue) : ControllerBase
PushDispatchQueue pushDispatchQueue,
AttachmentStorageService attachmentStorageService) : ControllerBase
{
private const long MaxBotAttachmentSizeBytes = 25 * 1024 * 1024;
private const long MaxBotMediaGroupSizeBytes = 100 * 1024 * 1024;
private const int MaxBotMediaGroupAttachments = 10;
[HttpGet("getMe")]
public async Task<ActionResult<UserSummaryDto>> GetMe(string token, CancellationToken cancellationToken)
{
@@ -205,10 +212,334 @@ public sealed class BotApiController(
chat.LastActivityAt = message.SentAt;
dbContext.Messages.Add(message);
var discussionRootMessage = await ChannelDiscussionMessageFactory.CreateLinkedDiscussionRootAsync(
dbContext,
attachmentStorageService,
chat,
message,
bot.User,
message.SentAt,
cancellationToken);
if (discussionRootMessage is not null)
{
message.DiscussionMessageId = discussionRootMessage.Id;
}
await dbContext.SaveChangesAsync(cancellationToken);
await NotifyMessageCreatedAsync(chat, message, cancellationToken);
await pushDispatchQueue.EnqueueAsync(chat.Id, message.Id);
if (discussionRootMessage is not null)
{
await NotifyMessageCreatedAsync(discussionRootMessage.Chat, discussionRootMessage, cancellationToken);
await pushDispatchQueue.EnqueueAsync(discussionRootMessage.ChatId, discussionRootMessage.Id);
}
return Ok(message.ToDto(presenceTracker, bot.UserId));
}
[HttpPost("sendDocument")]
[RequestFormLimits(MultipartBodyLengthLimit = MaxBotAttachmentSizeBytes)]
[RequestSizeLimit(MaxBotAttachmentSizeBytes)]
public Task<ActionResult<MessageDto>> SendDocument(
string token,
[FromForm] BotSendAttachmentRequest request,
CancellationToken cancellationToken) =>
SendAttachmentAsync(token, request, forceVoiceNote: false, requiredMediaKind: null, cancellationToken: cancellationToken);
[HttpPost("sendPhoto")]
[RequestFormLimits(MultipartBodyLengthLimit = MaxBotAttachmentSizeBytes)]
[RequestSizeLimit(MaxBotAttachmentSizeBytes)]
public Task<ActionResult<MessageDto>> SendPhoto(
string token,
[FromForm] BotSendAttachmentRequest request,
CancellationToken cancellationToken) =>
SendAttachmentAsync(token, request, forceVoiceNote: false, requiredMediaKind: BotAttachmentMediaKind.Photo, cancellationToken: cancellationToken);
[HttpPost("sendVideo")]
[RequestFormLimits(MultipartBodyLengthLimit = MaxBotAttachmentSizeBytes)]
[RequestSizeLimit(MaxBotAttachmentSizeBytes)]
public Task<ActionResult<MessageDto>> SendVideo(
string token,
[FromForm] BotSendAttachmentRequest request,
CancellationToken cancellationToken) =>
SendAttachmentAsync(token, request, forceVoiceNote: false, requiredMediaKind: BotAttachmentMediaKind.Video, cancellationToken: cancellationToken);
[HttpPost("sendMediaGroup")]
[RequestFormLimits(MultipartBodyLengthLimit = MaxBotMediaGroupSizeBytes)]
[RequestSizeLimit(MaxBotMediaGroupSizeBytes)]
public async Task<ActionResult<MessageDto>> SendMediaGroup(
string token,
[FromForm] BotSendMediaGroupRequest request,
CancellationToken cancellationToken)
{
var bot = await LoadBotByTokenAsync(token, cancellationToken);
if (bot is null)
{
return Unauthorized();
}
if (!bot.IsEnabled)
{
return StatusCode(StatusCodes.Status423Locked, "Bot is disabled.");
}
if (request.ChatId == Guid.Empty)
{
return ValidationProblem("ChatId is required.");
}
var files = request.Files
.Where(file => file is not null)
.ToList();
if (files.Count < 2)
{
return BadRequest("Media group must contain at least 2 files.");
}
if (files.Count > MaxBotMediaGroupAttachments)
{
return BadRequest($"Media group can contain up to {MaxBotMediaGroupAttachments} files.");
}
if (files.Any(file => file.Length <= 0))
{
return BadRequest("Media group contains an empty file.");
}
if (files.Any(file => file.Length > MaxBotAttachmentSizeBytes) || files.Sum(file => file.Length) > MaxBotMediaGroupSizeBytes)
{
return BadRequest($"Media group exceeds {MaxBotMediaGroupSizeBytes} bytes or contains a file over {MaxBotAttachmentSizeBytes} bytes.");
}
if (files.Any(file =>
!MatchesRequiredMediaKind(file, BotAttachmentMediaKind.Photo) &&
!MatchesRequiredMediaKind(file, BotAttachmentMediaKind.Video)))
{
return BadRequest("sendMediaGroup accepts only image and video files.");
}
var chat = await dbContext.Chats
.Include(x => x.Members)
.SingleOrDefaultAsync(
x => x.Id == request.ChatId && x.Members.Any(member => member.UserId == bot.UserId),
cancellationToken);
if (chat is null)
{
return NotFound("Chat not found.");
}
if (!DtoMapper.BuildPermissions(chat.Members.FirstOrDefault(member => member.UserId == bot.UserId), chat.Type).CanPostMessages)
{
return StatusCode(StatusCodes.Status403Forbidden, "Bot cannot publish in this chat.");
}
var sentAt = DateTimeOffset.UtcNow;
var message = new Message
{
ChatId = chat.Id,
Chat = chat,
SenderId = bot.UserId,
Sender = bot.User,
PostedAsChannel = chat.Type == ChatType.Channel,
AuthorSignature = chat.Type == ChatType.Channel && chat.ChannelSignaturesEnabled
? string.IsNullOrWhiteSpace(bot.User.DisplayName) ? bot.User.Username : bot.User.DisplayName
: null,
Text = request.Text?.Trim() ?? string.Empty,
MediaAlbumId = Guid.NewGuid(),
SentAt = sentAt
};
var savedAttachments = new List<MessageAttachment>();
Message? discussionRootMessage = null;
try
{
for (var index = 0; index < files.Count; index++)
{
var attachment = await attachmentStorageService.SaveAsync(message.Id, files[index], cancellationToken, sortOrder: index);
savedAttachments.Add(attachment);
message.Attachments.Add(attachment);
}
chat.LastActivityAt = message.SentAt;
dbContext.Messages.Add(message);
discussionRootMessage = await ChannelDiscussionMessageFactory.CreateLinkedDiscussionRootAsync(
dbContext,
attachmentStorageService,
chat,
message,
bot.User,
sentAt,
cancellationToken);
if (discussionRootMessage is not null)
{
message.DiscussionMessageId = discussionRootMessage.Id;
}
await dbContext.SaveChangesAsync(cancellationToken);
}
catch
{
foreach (var attachment in savedAttachments)
{
attachmentStorageService.DeleteIfExists(attachment.StoragePath);
}
if (discussionRootMessage is not null)
{
DeleteFiles(discussionRootMessage.Attachments.Select(x => x.StoragePath));
}
throw;
}
await NotifyMessageCreatedAsync(chat, message, cancellationToken);
await pushDispatchQueue.EnqueueAsync(chat.Id, message.Id);
if (discussionRootMessage is not null)
{
await NotifyMessageCreatedAsync(discussionRootMessage.Chat, discussionRootMessage, cancellationToken);
await pushDispatchQueue.EnqueueAsync(discussionRootMessage.ChatId, discussionRootMessage.Id);
}
return Ok(message.ToDto(presenceTracker, bot.UserId));
}
[HttpPost("sendVoice")]
[RequestFormLimits(MultipartBodyLengthLimit = MaxBotAttachmentSizeBytes)]
[RequestSizeLimit(MaxBotAttachmentSizeBytes)]
public Task<ActionResult<MessageDto>> SendVoice(
string token,
[FromForm] BotSendAttachmentRequest request,
CancellationToken cancellationToken) =>
SendAttachmentAsync(token, request, forceVoiceNote: true, requiredMediaKind: null, cancellationToken: cancellationToken);
private async Task<ActionResult<MessageDto>> SendAttachmentAsync(
string token,
BotSendAttachmentRequest request,
bool forceVoiceNote,
BotAttachmentMediaKind? requiredMediaKind,
CancellationToken cancellationToken)
{
var bot = await LoadBotByTokenAsync(token, cancellationToken);
if (bot is null)
{
return Unauthorized();
}
if (!bot.IsEnabled)
{
return StatusCode(StatusCodes.Status423Locked, "Bot is disabled.");
}
if (request.ChatId == Guid.Empty)
{
return ValidationProblem("ChatId is required.");
}
if (request.File is null)
{
return BadRequest("File is required.");
}
if (request.File.Length <= 0)
{
return BadRequest("File is empty.");
}
if (request.File.Length > MaxBotAttachmentSizeBytes)
{
return BadRequest($"File exceeds {MaxBotAttachmentSizeBytes} bytes.");
}
if (requiredMediaKind is not null && !MatchesRequiredMediaKind(request.File, requiredMediaKind.Value))
{
return BadRequest(requiredMediaKind == BotAttachmentMediaKind.Photo
? "sendPhoto accepts only image files."
: "sendVideo accepts only video files.");
}
var chat = await dbContext.Chats
.Include(x => x.Members)
.SingleOrDefaultAsync(
x => x.Id == request.ChatId && x.Members.Any(member => member.UserId == bot.UserId),
cancellationToken);
if (chat is null)
{
return NotFound("Chat not found.");
}
if (!DtoMapper.BuildPermissions(chat.Members.FirstOrDefault(member => member.UserId == bot.UserId), chat.Type).CanPostMessages)
{
return StatusCode(StatusCodes.Status403Forbidden, "Bot cannot publish in this chat.");
}
var sentAt = DateTimeOffset.UtcNow;
var message = new Message
{
ChatId = chat.Id,
Chat = chat,
SenderId = bot.UserId,
Sender = bot.User,
PostedAsChannel = chat.Type == ChatType.Channel,
AuthorSignature = chat.Type == ChatType.Channel && chat.ChannelSignaturesEnabled
? string.IsNullOrWhiteSpace(bot.User.DisplayName) ? bot.User.Username : bot.User.DisplayName
: null,
Text = request.Text?.Trim() ?? string.Empty,
SentAt = sentAt
};
MessageAttachment? attachment = null;
Message? discussionRootMessage = null;
try
{
attachment = await attachmentStorageService.SaveAsync(message.Id, request.File, cancellationToken);
if (forceVoiceNote || IsVoiceLikeAttachment(attachment))
{
attachment.ContentType = ResolveVoiceContentType(attachment);
attachment.Kind = AttachmentKind.VoiceNote;
}
message.Attachments.Add(attachment);
chat.LastActivityAt = message.SentAt;
dbContext.Messages.Add(message);
discussionRootMessage = await ChannelDiscussionMessageFactory.CreateLinkedDiscussionRootAsync(
dbContext,
attachmentStorageService,
chat,
message,
bot.User,
sentAt,
cancellationToken);
if (discussionRootMessage is not null)
{
message.DiscussionMessageId = discussionRootMessage.Id;
}
await dbContext.SaveChangesAsync(cancellationToken);
}
catch
{
if (attachment is not null)
{
attachmentStorageService.DeleteIfExists(attachment.StoragePath);
}
if (discussionRootMessage is not null)
{
DeleteFiles(discussionRootMessage.Attachments.Select(x => x.StoragePath));
}
throw;
}
await NotifyMessageCreatedAsync(chat, message, cancellationToken);
await pushDispatchQueue.EnqueueAsync(chat.Id, message.Id);
if (discussionRootMessage is not null)
{
await NotifyMessageCreatedAsync(discussionRootMessage.Chat, discussionRootMessage, cancellationToken);
await pushDispatchQueue.EnqueueAsync(discussionRootMessage.ChatId, discussionRootMessage.Id);
}
return Ok(message.ToDto(presenceTracker, bot.UserId));
}
@@ -257,6 +588,112 @@ public sealed class BotApiController(
return Task.WhenAll(tasks);
}
private void DeleteFiles(IEnumerable<string> attachmentPaths)
{
foreach (var path in attachmentPaths)
{
attachmentStorageService.DeleteIfExists(path);
}
}
private static string ResolveVoiceContentType(MessageAttachment attachment)
{
var normalized = attachment.ContentType
.Split(';', 2, StringSplitOptions.TrimEntries)
.FirstOrDefault();
if (!string.IsNullOrWhiteSpace(normalized) &&
normalized.StartsWith("audio/", StringComparison.OrdinalIgnoreCase))
{
return attachment.ContentType;
}
var extension = Path.GetExtension(attachment.OriginalFileName).ToLowerInvariant();
if (string.IsNullOrWhiteSpace(extension))
{
var lastToken = attachment.OriginalFileName
.Split([' ', '.', '_', '-', '(', ')', '[', ']'], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.LastOrDefault()
?.ToLowerInvariant();
extension = string.IsNullOrWhiteSpace(lastToken) ? string.Empty : $".{lastToken}";
}
return extension switch
{
".aac" => "audio/aac",
".flac" => "audio/flac",
".m4a" => "audio/mp4",
".mp3" => "audio/mpeg",
".oga" or ".ogg" => "audio/ogg",
".opus" => "audio/opus",
".wav" => "audio/wav",
".webm" => "audio/webm",
_ => "audio/ogg"
};
}
private static bool IsVoiceLikeAttachment(MessageAttachment attachment)
{
var normalized = attachment.ContentType
.Split(';', 2, StringSplitOptions.TrimEntries)
.FirstOrDefault()
?.Trim()
.ToLowerInvariant();
if (normalized is "audio/ogg" or "audio/opus" or "application/ogg" or "application/opus" or "application/x-ogg" or "video/ogg")
{
return true;
}
var extension = Path.GetExtension(attachment.OriginalFileName).ToLowerInvariant();
if (string.IsNullOrWhiteSpace(extension))
{
var lastToken = attachment.OriginalFileName
.Split([' ', '.', '_', '-', '(', ')', '[', ']'], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.LastOrDefault()
?.ToLowerInvariant();
extension = string.IsNullOrWhiteSpace(lastToken) ? string.Empty : $".{lastToken}";
}
return extension is ".ogg" or ".oga" or ".opus";
}
private static bool MatchesRequiredMediaKind(IFormFile file, BotAttachmentMediaKind requiredMediaKind)
{
var normalizedContentType = file.ContentType
?.Split(';', 2, StringSplitOptions.TrimEntries)
.FirstOrDefault()
?.Trim()
.ToLowerInvariant();
if (!string.IsNullOrWhiteSpace(normalizedContentType))
{
if (requiredMediaKind == BotAttachmentMediaKind.Photo &&
normalizedContentType.StartsWith("image/", StringComparison.Ordinal))
{
return true;
}
if (requiredMediaKind == BotAttachmentMediaKind.Video &&
normalizedContentType.StartsWith("video/", StringComparison.Ordinal))
{
return true;
}
}
var extension = Path.GetExtension(file.FileName).ToLowerInvariant();
return requiredMediaKind switch
{
BotAttachmentMediaKind.Photo => extension is ".jpg" or ".jpeg" or ".png" or ".gif" or ".webp" or ".bmp",
BotAttachmentMediaKind.Video => extension is ".mp4" or ".m4v" or ".mov" or ".webm" or ".mkv",
_ => false
};
}
private enum BotAttachmentMediaKind
{
Photo,
Video
}
private static List<BotCommandDto> NormalizeCommands(IReadOnlyList<BotCommandDto> commands)
{
var normalized = new List<BotCommandDto>();
+14 -71
View File
@@ -3,8 +3,9 @@ using Ikar.Server.Data;
using Ikar.Server.Data.Entities;
using Ikar.Server.Infrastructure;
using Ikar.Server.Infrastructure.Auth;
using Ikar.Server.Infrastructure.Bots;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Ikar.Shared;
@@ -16,10 +17,10 @@ namespace Ikar.Server.Controllers;
[Route("api/[controller]")]
public sealed class BotsController(
IkarDbContext dbContext,
BotTokenService botTokenService) : ControllerBase
BotTokenService botTokenService,
BotDeletionService botDeletionService) : ControllerBase
{
private static readonly Regex BotUsernameRegex = new("^[A-Za-z0-9_]{5,64}$", RegexOptions.Compiled);
private readonly PasswordHasher<User> _passwordHasher = new();
[HttpGet("mine")]
public async Task<ActionResult<IReadOnlyList<BotSummaryDto>>> GetMine(CancellationToken cancellationToken)
@@ -49,48 +50,12 @@ public sealed class BotsController(
}
[HttpPost]
public async Task<ActionResult<CreatedBotDto>> CreateBot(
CreateBotRequest request,
CancellationToken cancellationToken)
public ActionResult<CreatedBotDto> CreateBot(CreateBotRequest request)
{
var ownerUserId = User.GetRequiredUserId();
var validationError = await ValidateBotDefinitionAsync(
request.Username,
request.DisplayName,
request.About,
excludingBotId: null,
cancellationToken);
if (validationError is not null)
{
return ValidationProblem(validationError);
}
var botUser = new User
{
Username = request.Username.Trim(),
NormalizedUsername = request.Username.Trim().ToUpperInvariant(),
DisplayName = request.DisplayName.Trim(),
About = string.IsNullOrWhiteSpace(request.About) ? null : request.About.Trim(),
IsBot = true
};
botUser.PasswordHash = _passwordHasher.HashPassword(botUser, Guid.NewGuid().ToString("N"));
var bot = new Bot
{
User = botUser,
OwnerUserId = ownerUserId
};
var rawToken = botTokenService.GenerateToken(bot.Id);
bot.TokenHash = botTokenService.HashToken(rawToken);
dbContext.Bots.Add(bot);
await dbContext.SaveChangesAsync(cancellationToken);
var created = await LoadOwnedBotAsync(bot.Id, ownerUserId, cancellationToken)
?? throw new InvalidOperationException("Newly created bot was not found.");
return Ok(new CreatedBotDto(created.ToDto(), rawToken));
_ = request;
return StatusCode(
StatusCodes.Status410Gone,
"Создание ботов доступно только через чат с @botfatherbot. Напишите /создать_бота Имя бота | usernamebot | описание.");
}
[HttpPut("{botId:guid}")]
@@ -185,36 +150,14 @@ public sealed class BotsController(
[HttpDelete("{botId:guid}")]
public async Task<IActionResult> DeleteBot(Guid botId, CancellationToken cancellationToken)
{
var ownerUserId = User.GetRequiredUserId();
var bot = await dbContext.Bots
.Include(x => x.User)
.ThenInclude(x => x.Sessions)
.Include(x => x.User)
.ThenInclude(x => x.PushDevices)
.SingleOrDefaultAsync(
x => x.Id == botId && x.OwnerUserId == ownerUserId,
cancellationToken);
if (bot is null)
var deleted = await botDeletionService.DeleteBotAsync(
botId,
User.GetRequiredUserId(),
cancellationToken);
if (deleted is null)
{
return NotFound();
}
var botCommands = await dbContext.BotCommands
.Where(x => x.BotId == bot.Id)
.ToListAsync(cancellationToken);
var botUpdates = await dbContext.BotUpdates
.Where(x => x.BotId == bot.Id)
.ToListAsync(cancellationToken);
dbContext.BotUpdates.RemoveRange(botUpdates);
dbContext.BotCommands.RemoveRange(botCommands);
dbContext.UserSessions.RemoveRange(bot.User.Sessions);
dbContext.PushDevices.RemoveRange(bot.User.PushDevices);
bot.User.IsBlocked = true;
bot.User.BlockedAt = DateTimeOffset.UtcNow;
dbContext.Bots.Remove(bot);
await dbContext.SaveChangesAsync(cancellationToken);
return NoContent();
}
File diff suppressed because it is too large Load Diff
@@ -1,10 +1,12 @@
using System.Net.Mime;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.SignalR;
using Microsoft.EntityFrameworkCore;
using Ikar.Server.Data;
using Ikar.Server.Data.Entities;
using Ikar.Server.Infrastructure;
using Ikar.Server.Infrastructure.Hubs;
using Ikar.Server.Infrastructure.Storage;
using Ikar.Shared;
@@ -16,6 +18,7 @@ namespace Ikar.Server.Controllers;
public sealed class StoriesController(
IkarDbContext dbContext,
PresenceTracker presenceTracker,
IHubContext<MessengerHub> hubContext,
AttachmentStorageService attachmentStorageService) : ControllerBase
{
private const int MaxStoryAttachmentCount = 10;
@@ -32,6 +35,8 @@ public sealed class StoriesController(
.AsNoTracking()
.Include(x => x.AuthorUser)
.ThenInclude(x => x.Sessions)
.Include(x => x.AuthorUser)
.ThenInclude(x => x.PrivacySettings)
.Include(x => x.Attachments)
.Include(x => x.Views)
.Where(x =>
@@ -42,7 +47,12 @@ public sealed class StoriesController(
.Take(100)
.ToListAsync(cancellationToken);
return Ok(stories.Select(x => x.ToDto(presenceTracker, userId)).ToList());
return Ok(stories
.Select(x => x.ToDto(
presenceTracker,
userId,
sharesChatWithViewer: peerAuthorIds.Contains(x.AuthorUserId)))
.ToList());
}
[HttpGet("{storyId:guid}")]
@@ -55,6 +65,8 @@ public sealed class StoriesController(
var story = await dbContext.Stories
.Include(x => x.AuthorUser)
.ThenInclude(x => x.Sessions)
.Include(x => x.AuthorUser)
.ThenInclude(x => x.PrivacySettings)
.Include(x => x.Attachments)
.Include(x => x.Views)
.Where(x =>
@@ -79,7 +91,10 @@ public sealed class StoriesController(
await dbContext.SaveChangesAsync(cancellationToken);
}
return Ok(story.ToDto(presenceTracker, userId));
return Ok(story.ToDto(
presenceTracker,
userId,
sharesChatWithViewer: peerAuthorIds.Contains(story.AuthorUserId)));
}
[HttpPost]
@@ -110,6 +125,7 @@ public sealed class StoriesController(
var loaded = await LoadStoryForAuthorAsync(story.Id, userId, cancellationToken)
?? throw new InvalidOperationException("The created story could not be reloaded.");
await NotifyStoriesChangedAsync(loaded, cancellationToken);
return Ok(loaded.ToDto(presenceTracker, userId));
}
@@ -142,6 +158,12 @@ public sealed class StoriesController(
return BadRequest($"Story upload is limited to {MaxStoryUploadSizeBytes} bytes.");
}
var unsupportedFile = files.FirstOrDefault(file => !IsSupportedStoryMedia(file));
if (unsupportedFile is not null)
{
return BadRequest($"Story media type is not supported: {unsupportedFile.ContentType}");
}
var now = DateTimeOffset.UtcNow;
var ttlSeconds = Math.Clamp(request.TtlSeconds, 60, 7 * 24 * 60 * 60);
var story = new Story
@@ -180,6 +202,7 @@ public sealed class StoriesController(
var loaded = await LoadStoryForAuthorAsync(story.Id, userId, cancellationToken)
?? throw new InvalidOperationException("The created story could not be reloaded.");
await NotifyStoriesChangedAsync(loaded, cancellationToken);
return Ok(loaded.ToDto(presenceTracker, userId));
}
@@ -208,6 +231,7 @@ public sealed class StoriesController(
story.DeletedAt = DateTimeOffset.UtcNow;
await AddModerationLogAsync(userId, ModerationLogTargetType.Story, story.Id, "story.deleted", cancellationToken);
await dbContext.SaveChangesAsync(cancellationToken);
await NotifyStoriesChangedAsync(story, cancellationToken);
}
return NoContent();
@@ -217,6 +241,7 @@ public sealed class StoriesController(
public async Task<ActionResult<IReadOnlyList<StoryViewDto>>> GetStoryViews(Guid storyId, CancellationToken cancellationToken)
{
var userId = User.GetRequiredUserId();
var peerAuthorIds = await LoadStoryPeerAuthorIdsAsync(userId, cancellationToken);
var storyExists = await dbContext.Stories
.AsNoTracking()
.AnyAsync(x => x.Id == storyId && x.AuthorUserId == userId, cancellationToken);
@@ -229,13 +254,21 @@ public sealed class StoriesController(
.AsNoTracking()
.Include(x => x.ViewerUser)
.ThenInclude(x => x.Sessions)
.Include(x => x.ViewerUser)
.ThenInclude(x => x.PrivacySettings)
.Where(x => x.StoryId == storyId)
.OrderByDescending(x => x.ViewedAt)
.Take(250)
.ToListAsync(cancellationToken);
return Ok(views
.Select(x => new StoryViewDto(x.StoryId, x.ViewerUser.ToDto(presenceTracker), x.ViewedAt))
.Select(x => new StoryViewDto(
x.StoryId,
x.ViewerUser.ToDtoForViewer(
presenceTracker,
userId,
sharesChatWithViewer: peerAuthorIds.Contains(x.ViewerUserId)),
x.ViewedAt))
.ToList());
}
@@ -282,14 +315,53 @@ public sealed class StoriesController(
.ToList();
}
private async Task NotifyStoriesChangedAsync(Story story, CancellationToken cancellationToken)
{
if (story.Visibility == StoryVisibility.Everyone)
{
await hubContext.Clients.All.SendAsync("StoriesChanged", cancellationToken);
return;
}
var userIds = new List<Guid> { story.AuthorUserId };
if (story.Visibility is StoryVisibility.Contacts or StoryVisibility.CloseFriends)
{
userIds.AddRange(await LoadStoryPeerAuthorIdsAsync(story.AuthorUserId, cancellationToken));
}
await NotifyStoriesChangedAsync(userIds, cancellationToken);
}
private Task NotifyStoriesChangedAsync(IEnumerable<Guid> userIds, CancellationToken cancellationToken)
{
var affectedUsers = userIds
.Distinct()
.Select(userId => userId.ToString())
.ToList();
return affectedUsers.Count == 0
? Task.CompletedTask
: hubContext.Clients.Users(affectedUsers).SendAsync("StoriesChanged", cancellationToken);
}
private async Task<Story?> LoadStoryForAuthorAsync(Guid storyId, Guid userId, CancellationToken cancellationToken) =>
await dbContext.Stories
.Include(x => x.AuthorUser)
.ThenInclude(x => x.Sessions)
.Include(x => x.AuthorUser)
.ThenInclude(x => x.PrivacySettings)
.Include(x => x.Attachments)
.Include(x => x.Views)
.SingleOrDefaultAsync(x => x.Id == storyId && x.AuthorUserId == userId, cancellationToken);
private static bool IsSupportedStoryMedia(IFormFile file)
{
var contentType = file.ContentType?.Split(';', 2, StringSplitOptions.TrimEntries).FirstOrDefault();
return !string.IsNullOrWhiteSpace(contentType) &&
(contentType.StartsWith("image/", StringComparison.OrdinalIgnoreCase) ||
contentType.StartsWith("video/", StringComparison.OrdinalIgnoreCase));
}
private async Task AddModerationLogAsync(
Guid actorUserId,
ModerationLogTargetType targetType,

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