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 .git
.vs .vs
.codex-* .codex-*
.env
.env.*
!.env.example
**/bin **/bin
**/obj **/obj
docker-data docker-data
+7
View File
@@ -11,3 +11,10 @@ Push__ServiceAccountJsonPath=
Push__ServiceAccountJson= Push__ServiceAccountJson=
Admin__Username= Admin__Username=
Admin__Password= 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)] [Parameter(Mandatory = $true)]
[string]$Password, [string]$Password,
[string]$ArgusBaseUrl = 'https://argus.kusoft.xyz', [string]$ArgusBaseUrl = 'https://argus.kusoft.xyz',
[string]$ArgusInternalBaseUrl = 'http://127.0.0.1:5105', [string]$ArgusInternalBaseUrl = 'unused',
[string]$ArgusEnvPath = '/home/sevenhill/argus-deploy/.env.server', [string]$ArgusEnvPath = 'unused',
[string]$Slug = 'ikar-android', [string]$Slug = 'ikar-android',
[string]$Notes = 'Android client update for Ikar', [string]$Notes = 'Android client update for Ikar',
[string]$AppName = 'Ikar Android', [string]$AppName = 'Ikar',
[string]$Summary = 'Android client build', [string]$Summary = 'Standalone Android client for Ikar on Kotlin and Jetpack Compose.',
[string]$Description = 'Android release channel for the Ikar client.', [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]$HomepageUrl = 'https://ikar.kusoft.xyz',
[string]$RepositoryUrl = 'https://git.kusoft.xyz/sevenhill/Ikar.git',
[string]$Platform = 'android', [string]$Platform = 'android',
[string]$PackageKind = 'apk', [string]$PackageKind = 'apk',
[string]$Channel = 'stable' [string]$Channel = 'stable',
[switch]$Unlisted
) )
$ErrorActionPreference = 'Stop' $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 if (-not (Test-Path $prepareScript)) {
$projectPath = '.\src\Ikar.Client\Ikar.Client.csproj' throw "Argus preparation script was not found: $prepareScript"
}
[xml]$projectXml = Get-Content $projectPath if (-not (Test-Path $publishScript)) {
$versionName = $projectXml.Project.PropertyGroup.ApplicationDisplayVersion | Select-Object -First 1 throw "Argus SSH publication script was not found: $publishScript"
$versionCode = [int]($projectXml.Project.PropertyGroup.ApplicationVersion | Select-Object -First 1)
$publishDir = Join-Path $repoRoot (".codex-temp\android-update-publish\v{0}" -f $versionCode)
if (Test-Path $publishDir) {
Remove-Item $publishDir -Recurse -Force
} }
dotnet publish $projectPath -f net10.0-android -c Release -p:AndroidPackageFormat=apk -p:UseSharedCompilation=false -o $publishDir 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' & $prepareScript @prepareArgs
if (-not (Test-Path $sourceApk)) { if ($LASTEXITCODE -ne 0) {
throw "Signed APK not found: $sourceApk" 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. жёсткие требования, которые реально проверяет сервер; `G:\Repos\Marketplace\ARGUS_PUBLICATION.md`
2. дополнительные требования, без которых приложение не будет нормально устанавливаться через `Argus Store`;
3. условия, при которых `Argus Store` сможет предлагать автообновления.
Если цель публикации именно в 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`: `ikar-android`
- `slug`; - `channel`: `stable`
- `name`; - `platform`: `android`
- `summary`; - `packageKind`: `apk`
- `description`; - public base URL: `https://argus.kusoft.xyz`
- `version`;
- `channel`;
- `platform`;
- `packageKind`;
- `notes` при наличии;
- `repositoryUrl` при наличии;
- `homepageUrl` при наличии;
- `androidPackageName` для Android-релизов;
- `androidVersionCode` для Android-релизов;
- решение, будет ли приложение публичным (`isListed=true`) или скрытым (`isListed=false`).
Причина: именно эти поля использует текущий publish-скрипт и серверные DTO. ## Local workflow
Источник: `scripts/publish-release.ps1:2-46`, `Contracts/ArgusDtos.cs:62-94`.
## 2. Жёсткие серверные требования 1. Build and stage a signed APK:
### 2.1. Требования к `slug` ```powershell
.\scripts\prepare-argus-publication.ps1
```
- `slug` обязателен. 2. Publish the staged APK through SSH/SQLite:
- Разрешены только строчные латинские буквы, цифры и дефисы.
- Максимальная длина `slug``100` символов.
- Значение должно уже быть нормализовано; сервер не принимает произвольный mixed-case `slug`.
Фактическое правило валидации: ```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, заменяет любые неалфавитно-цифровые символы на `-`, схлопывает повторные дефисы и обрезает дефисы по краям. 3. Verify the public manifest:
- `SlugUtility.IsValid(...)` принимает только строки, которые уже совпадают со своим нормализованным вариантом и имеют длину до `100`.
Источник: `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` - treat HTTP 404 from the manifest endpoint as "no compatible release";
- `summary` - compare the installed app version to `release.version`;
- `description` - 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;
Источник: `Program.cs:372-379`, `Program.cs:582-607`. - submit the APK to the Android package installer;
- record the new installed version only after Android reports a successful install.
### 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`.
+4
View File
@@ -24,10 +24,14 @@
- Локальная release-подпись хранится в gitignored файлах `argus-signing.local.properties` и `signing/`. - Локальная release-подпись хранится в gitignored файлах `argus-signing.local.properties` и `signing/`.
- Единая координата публикации для клиента Икар: slug `ikar-android`, channel `stable`, platform `android`. - Единая координата публикации для клиента Икар: slug `ikar-android`, channel `stable`, platform `android`.
Именно этот manifest запрашивают текущий Android-клиент и серверный proxy обновлений. Именно этот 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: - Первый запуск локального signing pipeline:
- `.\scripts\new-argus-upload-keystore.ps1` - `.\scripts\new-argus-upload-keystore.ps1`
- Подготовка signed APK и metadata JSON для Argus: - Подготовка signed APK и metadata JSON для Argus:
- `.\scripts\prepare-argus-publication.ps1` - `.\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: Скрипт подготовки собирает `release` APK, проверяет подпись и извлекает из самого APK:
- `androidPackageName` - `androidPackageName`
+6 -2
View File
@@ -37,8 +37,8 @@ android {
applicationId = "com.seven.ikar.kotlin" applicationId = "com.seven.ikar.kotlin"
minSdk = 24 minSdk = 24
targetSdk = 36 targetSdk = 36
versionCode = 180 versionCode = 209
versionName = "3.67" versionName = "3.96"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables.useSupportLibrary = true vectorDrawables.useSupportLibrary = true
@@ -88,6 +88,7 @@ android {
dependencies { dependencies {
val composeBom = platform("androidx.compose:compose-bom:2026.03.00") val composeBom = platform("androidx.compose:compose-bom:2026.03.00")
val roomVersion = "2.8.4" val roomVersion = "2.8.4"
val media3Version = "1.10.1"
implementation("androidx.core:core-ktx:1.17.0") implementation("androidx.core:core-ktx:1.17.0")
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.9.4") 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:okhttp:4.12.0")
implementation("com.squareup.okhttp3:logging-interceptor: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-compose:2.7.0")
implementation("io.coil-kt:coil-gif:2.7.0")
implementation("com.microsoft.signalr:signalr:8.0.17") implementation("com.microsoft.signalr:signalr:8.0.17")
implementation("com.google.firebase:firebase-messaging:25.0.1") implementation("com.google.firebase:firebase-messaging:25.0.1")
implementation("io.github.webrtc-sdk:android:144.7559.01") implementation("io.github.webrtc-sdk:android:144.7559.01")
implementation("androidx.media3:media3-exoplayer:$media3Version")
implementation("androidx.media3:media3-ui:$media3Version")
implementation(composeBom) implementation(composeBom)
androidTestImplementation(composeBom) androidTestImplementation(composeBom)
@@ -42,6 +42,41 @@
<category android:name="android.intent.category.DEFAULT" /> <category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="*/*" /> <data android:mimeType="*/*" />
</intent-filter> </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> <intent-filter>
<action android:name="android.intent.action.VIEW" /> <action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" /> <category android:name="android.intent.category.DEFAULT" />
@@ -17,7 +17,9 @@ import androidx.core.view.WindowInsetsControllerCompat
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import com.seven.ikar.kotlin.app.IkarApp import com.seven.ikar.kotlin.app.IkarApp
import com.seven.ikar.kotlin.app.IkarNativeApp 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.AndroidAppVisibilityState
import com.seven.ikar.kotlin.core.push.ChatNotificationDismissalManager
import com.seven.ikar.kotlin.core.push.IncomingCallFullScreenSetupHelper import com.seven.ikar.kotlin.core.push.IncomingCallFullScreenSetupHelper
import com.seven.ikar.kotlin.core.push.IncomingCallFullScreenSetupResult import com.seven.ikar.kotlin.core.push.IncomingCallFullScreenSetupResult
import com.seven.ikar.kotlin.core.push.InitialCallPermissionPromptStore 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.pendingIncomingCallInviteFromIntent
import com.seven.ikar.kotlin.core.push.PushDataKeys import com.seven.ikar.kotlin.core.push.PushDataKeys
import com.seven.ikar.kotlin.core.push.PushDataKinds 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.feature.share.PendingExternalShare
import com.seven.ikar.kotlin.ui.SecureWindowFlagController import com.seven.ikar.kotlin.ui.SecureWindowFlagController
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@@ -95,6 +98,10 @@ class MainActivity : ComponentActivity() {
override fun onStart() { override fun onStart() {
super.onStart() super.onStart()
AndroidAppVisibilityState.setVisible(true) AndroidAppVisibilityState.setVisible(true)
lifecycleScope.launch {
runCatching { applicationContainer.pushRegistrationManager.registerCurrentDeviceAsync() }
}
ChatNotificationDismissalManager.cancelChatNotifications(this, AndroidActiveChatState.getActiveChatId())
enforceAppLockIfNeeded() enforceAppLockIfNeeded()
} }
@@ -118,6 +125,10 @@ class MainActivity : ComponentActivity() {
} }
applicationContainer.appLockState.setLocked(true) applicationContainer.appLockState.setLocked(true)
if (applicationContainer.privacyRuntimeSettings.hasPasscode()) {
return
}
SecureWindowFlagController.setSecure(window, AppLockSecureToken, true) SecureWindowFlagController.setSecure(window, AppLockSecureToken, true)
val keyguardManager = getSystemService(KeyguardManager::class.java) val keyguardManager = getSystemService(KeyguardManager::class.java)
val unlockIntent = if (keyguardManager.isDeviceSecure) { val unlockIntent = if (keyguardManager.isDeviceSecure) {
@@ -176,6 +187,10 @@ class MainActivity : ComponentActivity() {
private fun handleIncomingIntent(intent: Intent?) { private fun handleIncomingIntent(intent: Intent?) {
val incomingIntent = intent ?: return val incomingIntent = intent ?: return
if (incomingIntent.action == Intent.ACTION_VIEW) { if (incomingIntent.action == Intent.ACTION_VIEW) {
if (handleIkarDeepLink(incomingIntent.data)) {
return
}
val archiveUri = incomingIntent.data ?: incomingIntent.readSingleStream() val archiveUri = incomingIntent.data ?: incomingIntent.readSingleStream()
if (archiveUri != null) { if (archiveUri != null) {
lifecycleScope.launch { 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? { private suspend fun extractPendingShare(intent: Intent): PendingExternalShare? {
val sharedText = intent.getStringExtra(Intent.EXTRA_TEXT)?.trim()?.takeIf { it.isNotBlank() } val sharedText = intent.getStringExtra(Intent.EXTRA_TEXT)?.trim()?.takeIf { it.isNotBlank() }
val attachmentUris = when (intent.action) { 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.ChatMessageCache
import com.seven.ikar.kotlin.core.local.IkarLocalDatabase import com.seven.ikar.kotlin.core.local.IkarLocalDatabase
import com.seven.ikar.kotlin.core.model.AuthSessionDto 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.ApiClient
import com.seven.ikar.kotlin.core.network.ClientVersionProvider import com.seven.ikar.kotlin.core.network.ClientVersionProvider
import com.seven.ikar.kotlin.core.push.AndroidPushSettings 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.settings.AppSettingsSnapshot
import com.seven.ikar.kotlin.core.session.AuthorizedSessionManager import com.seven.ikar.kotlin.core.session.AuthorizedSessionManager
import com.seven.ikar.kotlin.core.session.SessionStore 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.core.update.ArgusAppUpdateManager
import com.seven.ikar.kotlin.feature.archive.AndroidArchiveFileService import com.seven.ikar.kotlin.feature.archive.AndroidArchiveFileService
import com.seven.ikar.kotlin.feature.archive.IncomingArchiveStore 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.AndroidAttachmentFileService
import com.seven.ikar.kotlin.feature.chat.AndroidVoiceNotePlayerService import com.seven.ikar.kotlin.feature.chat.AndroidVoiceNotePlayerService
import com.seven.ikar.kotlin.feature.chat.AndroidVoiceNoteRecorderService 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.chat.ChatScrollStateStore
import com.seven.ikar.kotlin.feature.share.IncomingShareStore import com.seven.ikar.kotlin.feature.share.IncomingShareStore
import com.seven.ikar.kotlin.feature.transfer.TransferQueueRepository import com.seven.ikar.kotlin.feature.transfer.TransferQueueRepository
@@ -74,6 +77,8 @@ class AppContainer(context: Context) {
@Volatile @Volatile
private var activeHttpClient: OkHttpClient = buildHttpClient(appSettings.loadSnapshot()) private var activeHttpClient: OkHttpClient = buildHttpClient(appSettings.loadSnapshot())
@Volatile
private var activeProxySignature: String = proxySignature(appSettings.loadSnapshot())
val httpClient: OkHttpClient val httpClient: OkHttpClient
get() = activeHttpClient get() = activeHttpClient
@@ -89,7 +94,10 @@ class AppContainer(context: Context) {
dao = localDatabase.cachedChatDao(), dao = localDatabase.cachedChatDao(),
json = json json = json
) )
val appUpdateManager = ArgusAppUpdateManager(appContext) val appUpdateManager = ArgusAppUpdateManager(
context = appContext,
client = ArgusAppUpdateClient(proxyProvider = { buildProxy(appSettings.loadSnapshot()) })
)
val apiClient = ApiClient( val apiClient = ApiClient(
httpClientProvider = { httpClient }, httpClientProvider = { httpClient },
json = json, json = json,
@@ -114,13 +122,20 @@ class AppContainer(context: Context) {
scheduler = transferWorkScheduler scheduler = transferWorkScheduler
) )
val chatScrollStateStore = ChatScrollStateStore(appContext, json) val chatScrollStateStore = ChatScrollStateStore(appContext, json)
val chatDraftStore = ChatDraftStore(appContext)
val incomingShareStore = IncomingShareStore() val incomingShareStore = IncomingShareStore()
val incomingArchiveStore = IncomingArchiveStore() val incomingArchiveStore = IncomingArchiveStore()
val incomingPushNavigationStore = IncomingPushNavigationStore() val incomingPushNavigationStore = IncomingPushNavigationStore()
val incomingLinkNavigationStore = IncomingLinkNavigationStore()
val incomingCallInviteStore = IncomingCallInviteStore() val incomingCallInviteStore = IncomingCallInviteStore()
val voiceNotePlayerService = AndroidVoiceNotePlayerService() val voiceNotePlayerService = AndroidVoiceNotePlayerService(appContext)
val voiceNoteRecorderService = AndroidVoiceNoteRecorderService(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 webRtcCallService = AndroidWebRtcCallService(appContext)
val callTonePlayer = AndroidCallTonePlayer() val callTonePlayer = AndroidCallTonePlayer()
val callManager = CallManager( val callManager = CallManager(
@@ -141,7 +156,12 @@ class AppContainer(context: Context) {
init { init {
applicationScope.launch { applicationScope.launch {
appSettings.snapshot.collect { snapshot -> appSettings.snapshot.collect { snapshot ->
val nextProxySignature = proxySignature(snapshot)
activeHttpClient = buildHttpClient(snapshot) activeHttpClient = buildHttpClient(snapshot)
if (nextProxySignature != activeProxySignature) {
activeProxySignature = nextProxySignature
realtimeService.reconnectForNetworkSettings()
}
runCatching { runCatching {
attachmentFileService.enforceCachePolicy( attachmentFileService.enforceCachePolicy(
maxCacheMb = snapshot.maxCacheMb, maxCacheMb = snapshot.maxCacheMb,
@@ -180,4 +200,12 @@ class AppContainer(context: Context) {
} }
return Proxy(proxyType, InetSocketAddress(host, settings.proxyPort)) 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 package com.seven.ikar.kotlin.app
import android.net.Uri import android.net.Uri
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.background 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.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.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.CompositionLocalProvider
@@ -18,6 +24,9 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext 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.compose.collectAsStateWithLifecycle
import androidx.lifecycle.Lifecycle import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver 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.core.settings.AppLocaleManager
import com.seven.ikar.kotlin.feature.archive.ArchiveExtractorScreen import com.seven.ikar.kotlin.feature.archive.ArchiveExtractorScreen
import com.seven.ikar.kotlin.feature.archive.ArchiveExtractorViewModel import com.seven.ikar.kotlin.feature.archive.ArchiveExtractorViewModel
import com.seven.ikar.kotlin.feature.bots.BotEditorScreen import com.seven.ikar.kotlin.feature.browser.InAppBrowserScreen
import com.seven.ikar.kotlin.feature.bots.BotEditorViewModel
import com.seven.ikar.kotlin.feature.bots.BotsScreen
import com.seven.ikar.kotlin.feature.bots.BotsViewModel
import com.seven.ikar.kotlin.feature.call.CallScreen import com.seven.ikar.kotlin.feature.call.CallScreen
import com.seven.ikar.kotlin.core.push.AndroidActiveChatState 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.ChatScreen
import com.seven.ikar.kotlin.feature.chat.ChatViewModel import com.seven.ikar.kotlin.feature.chat.ChatViewModel
import com.seven.ikar.kotlin.feature.chatinfo.ChatInfoScreen import com.seven.ikar.kotlin.feature.chatinfo.ChatInfoScreen
import com.seven.ikar.kotlin.feature.chatinfo.ChatInfoViewModel 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.ChatsScreen
import com.seven.ikar.kotlin.feature.chats.ChatsViewModel import com.seven.ikar.kotlin.feature.chats.ChatsViewModel
import com.seven.ikar.kotlin.feature.contacts.ContactsScreen 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.folders.ChatFoldersViewModel
import com.seven.ikar.kotlin.feature.forward.ForwardScreen import com.seven.ikar.kotlin.feature.forward.ForwardScreen
import com.seven.ikar.kotlin.feature.forward.ForwardViewModel 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.LoginScreen
import com.seven.ikar.kotlin.feature.login.LoginViewModel import com.seven.ikar.kotlin.feature.login.LoginViewModel
import com.seven.ikar.kotlin.feature.mainmenu.MainMenuViewModel 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.ChatWallpaperScreen
import com.seven.ikar.kotlin.feature.settings.ChatSettingsViewModel import com.seven.ikar.kotlin.feature.settings.ChatSettingsViewModel
import com.seven.ikar.kotlin.feature.settings.AppSettingsViewModel 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.DataStorageSettingsScreen
import com.seven.ikar.kotlin.feature.settings.LanguagePlaceholderScreen
import com.seven.ikar.kotlin.feature.settings.NotificationSettingsScreen import com.seven.ikar.kotlin.feature.settings.NotificationSettingsScreen
import com.seven.ikar.kotlin.feature.settings.NotificationSettingsViewModel import com.seven.ikar.kotlin.feature.settings.NotificationSettingsViewModel
import com.seven.ikar.kotlin.feature.settings.PrivacyExceptionsPlaceholderScreen import com.seven.ikar.kotlin.feature.settings.ProxySettingsScreen
import com.seven.ikar.kotlin.feature.settings.ProxyPlaceholderScreen
import com.seven.ikar.kotlin.feature.settings.SettingsScreen import com.seven.ikar.kotlin.feature.settings.SettingsScreen
import com.seven.ikar.kotlin.feature.settings.SettingsViewModel 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.StoriesScreen
import com.seven.ikar.kotlin.feature.stories.StoriesViewModel import com.seven.ikar.kotlin.feature.stories.StoriesViewModel
import com.seven.ikar.kotlin.feature.stories.StoryComposerScreen 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.feature.update.AppUpdateViewModel
import com.seven.ikar.kotlin.ui.LocalIkarAccessToken import com.seven.ikar.kotlin.ui.LocalIkarAccessToken
import com.seven.ikar.kotlin.ui.LocalUnreadChatsCount import com.seven.ikar.kotlin.ui.LocalUnreadChatsCount
import com.seven.ikar.kotlin.ui.SecureWindowEffect
import com.seven.ikar.kotlin.ui.theme.IkarKotlinTheme import com.seven.ikar.kotlin.ui.theme.IkarKotlinTheme
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
private const val ChatRoutePattern = "chat/{chatId}?messageId={messageId}&scrollToLatest={scrollToLatest}&scrollRequestId={scrollRequestId}" 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( private fun chatRoute(
chatId: String, chatId: String,
@@ -117,6 +132,10 @@ private fun chatRoute(
return if (query.isEmpty()) route else "$route?${query.joinToString("&")}" 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 @Composable
fun IkarApp() { fun IkarApp() {
val context = LocalContext.current val context = LocalContext.current
@@ -128,8 +147,10 @@ fun IkarApp() {
val pendingArchive by application.container.incomingArchiveStore.pendingArchive.collectAsStateWithLifecycle() val pendingArchive by application.container.incomingArchiveStore.pendingArchive.collectAsStateWithLifecycle()
val pendingCallInvite by application.container.incomingCallInviteStore.pendingInvite.collectAsStateWithLifecycle() val pendingCallInvite by application.container.incomingCallInviteStore.pendingInvite.collectAsStateWithLifecycle()
val pendingPushNavigation by application.container.incomingPushNavigationStore.pendingNavigation.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 appSettings by application.container.appSettings.snapshot.collectAsStateWithLifecycle()
val isAppLocked by application.container.appLockState.isLocked.collectAsStateWithLifecycle() val isAppLocked by application.container.appLockState.isLocked.collectAsStateWithLifecycle()
SecureWindowEffect(enabled = isAppLocked)
val mainMenuViewModel: MainMenuViewModel = viewModel( val mainMenuViewModel: MainMenuViewModel = viewModel(
factory = viewModelFactory { factory = viewModelFactory {
initializer { MainMenuViewModel(application.container) } initializer { MainMenuViewModel(application.container) }
@@ -201,12 +222,13 @@ fun IkarApp() {
} }
LoginScreen( LoginScreen(
state = state, state = state,
onEmailChanged = viewModel::updateEmail,
onPhoneNumberChanged = viewModel::updatePhoneNumber, onPhoneNumberChanged = viewModel::updatePhoneNumber,
onDisplayNameChanged = viewModel::updateDisplayName, onDisplayNameChanged = viewModel::updateDisplayName,
onVerificationCodeChanged = viewModel::updateVerificationCode, onVerificationCodeChanged = viewModel::updateVerificationCode,
onRequestCodeClick = viewModel::requestCode, onRequestCodeClick = viewModel::requestCode,
onVerifyCodeClick = viewModel::verifyCode, onVerifyCodeClick = viewModel::verifyCode,
onChangePhoneNumberClick = viewModel::changePhoneNumber onChangeEmailClick = viewModel::changeEmail
) )
} }
@@ -218,6 +240,16 @@ fun IkarApp() {
) )
val state by viewModel.uiState.collectAsStateWithLifecycle() val state by viewModel.uiState.collectAsStateWithLifecycle()
val chatSettings by application.container.chatSettings.snapshot.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) { LaunchedEffect(state.pendingShareNavigation?.requestId) {
val target = state.pendingShareNavigation ?: return@LaunchedEffect val target = state.pendingShareNavigation ?: return@LaunchedEffect
viewModel.consumePendingShareNavigation() viewModel.consumePendingShareNavigation()
@@ -249,6 +281,11 @@ fun IkarApp() {
onPinSelectedChatsClick = viewModel::togglePinSelectedChats, onPinSelectedChatsClick = viewModel::togglePinSelectedChats,
onArchiveSelectedChatsClick = viewModel::archiveSelectedChats, onArchiveSelectedChatsClick = viewModel::archiveSelectedChats,
onMuteSelectedChatsClick = viewModel::muteSelectedChats, onMuteSelectedChatsClick = viewModel::muteSelectedChats,
onTogglePinChat = viewModel::togglePinChat,
onToggleArchiveChat = viewModel::toggleArchiveChat,
onToggleMuteChat = viewModel::toggleMuteChat,
onMarkChatRead = viewModel::markChatRead,
onSwipeDeleteChat = viewModel::showSwipeDeleteDialog,
onDismissDeleteDialog = viewModel::dismissDeleteDialog, onDismissDeleteDialog = viewModel::dismissDeleteDialog,
onDeleteForEveryoneChanged = viewModel::updateDeleteForEveryone, onDeleteForEveryoneChanged = viewModel::updateDeleteForEveryone,
onConfirmDeleteSelected = viewModel::confirmDeleteSelected, onConfirmDeleteSelected = viewModel::confirmDeleteSelected,
@@ -269,10 +306,99 @@ fun IkarApp() {
onSettingsClick = { navigatePrimary("settings") }, onSettingsClick = { navigatePrimary("settings") },
onNotificationsClick = { navController.navigate("settings/notifications") }, onNotificationsClick = { navController.navigate("settings/notifications") },
onGlobalSearchClick = { navController.navigate("search") }, onGlobalSearchClick = { navController.navigate("search") },
onFilterChanged = viewModel::updateFilter,
onQueryChanged = viewModel::updateQuery 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") { composable("search") {
val viewModel: GlobalSearchViewModel = viewModel( val viewModel: GlobalSearchViewModel = viewModel(
factory = viewModelFactory { factory = viewModelFactory {
@@ -345,6 +471,8 @@ fun IkarApp() {
state = state, state = state,
viewModel = viewModel, viewModel = viewModel,
chatSettings = chatSettings, chatSettings = chatSettings,
largeEmojiEnabled = appSettings.largeEmoji,
animatedBackgroundsEnabled = appSettings.animatedBackgrounds,
onBackClick = { navController.popBackStack() }, onBackClick = { navController.popBackStack() },
onPeerProfileClick = state.peer?.id?.let { userId -> onPeerProfileClick = state.peer?.id?.let { userId ->
{ navController.navigate("profile/user/$userId") } { navController.navigate("profile/user/$userId") }
@@ -359,6 +487,14 @@ fun IkarApp() {
navController.navigate("chat/$chatId/notifications?title=$encodedTitle") navController.navigate("chat/$chatId/notifications?title=$encodedTitle")
}, },
onChatWallpaperClick = { navController.navigate("settings/chats/wallpaper") }, 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 -> onForwardMessage = { message ->
if (!message.hasProtectedContent) { if (!message.hasProtectedContent) {
navController.navigate("forward/${message.chatId}/${message.id}") navController.navigate("forward/${message.chatId}/${message.id}")
@@ -424,6 +560,8 @@ fun IkarApp() {
onToggleCanPin = viewModel::toggleCanPin, onToggleCanPin = viewModel::toggleCanPin,
onToggleCanDelete = viewModel::toggleCanDelete, onToggleCanDelete = viewModel::toggleCanDelete,
onToggleCanInvite = viewModel::toggleCanInvite, onToggleCanInvite = viewModel::toggleCanInvite,
onTransferOwnership = viewModel::transferOwnership,
onRemoveMember = viewModel::removeMember,
onMemberSearchChanged = viewModel::updateMemberSearchQuery, onMemberSearchChanged = viewModel::updateMemberSearchQuery,
onAddMember = { user -> viewModel.addMember(user, asAdmin = false) }, onAddMember = { user -> viewModel.addMember(user, asAdmin = false) },
onAddAdmin = { user -> viewModel.addMember(user, asAdmin = true) }, onAddAdmin = { user -> viewModel.addMember(user, asAdmin = true) },
@@ -450,12 +588,47 @@ fun IkarApp() {
onRefreshClick = viewModel::refresh, onRefreshClick = viewModel::refresh,
onMembersClick = { navController.navigate("chat/$chatId/members") }, onMembersClick = { navController.navigate("chat/$chatId/members") },
onSearchClick = { navController.navigate("search") }, 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, onInviteTitleChanged = viewModel::updateInviteTitle,
onInviteJoinRequestChanged = viewModel::updateInviteCreatesJoinRequest, onInviteJoinRequestChanged = viewModel::updateInviteCreatesJoinRequest,
onInviteExpiresInHoursChanged = viewModel::updateInviteExpiresInHours,
onInviteMaxUsesChanged = viewModel::updateInviteMaxUses,
onInviteMemberLimitChanged = viewModel::updateInviteMemberLimit,
onCreateInviteClick = viewModel::createInviteLink, onCreateInviteClick = viewModel::createInviteLink,
onRevokeInviteClick = viewModel::revokeInviteLink, onRevokeInviteClick = viewModel::revokeInviteLink,
onTopicTitleChanged = viewModel::updateTopicTitle, 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() }, onBackClick = { navController.popBackStack() },
onDisplayNameChanged = viewModel::updateDisplayName, onDisplayNameChanged = viewModel::updateDisplayName,
onAboutChanged = viewModel::updateAbout, onAboutChanged = viewModel::updateAbout,
onSaveClick = viewModel::save,
onDeleteAvatarClick = viewModel::deleteAvatar, onDeleteAvatarClick = viewModel::deleteAvatar,
onAvatarSelected = viewModel::uploadAvatar onAvatarSelected = viewModel::uploadAvatar
) )
@@ -560,9 +732,13 @@ fun IkarApp() {
onQueryChanged = viewModel::updateQuery, onQueryChanged = viewModel::updateQuery,
onRefreshClick = viewModel::refresh, onRefreshClick = viewModel::refresh,
onUserClick = viewModel::handleUserTap, onUserClick = viewModel::handleUserTap,
onChannelClick = viewModel::openPublicChannel,
onBeginGroupSelection = viewModel::beginGroupSelection, onBeginGroupSelection = viewModel::beginGroupSelection,
onBeginChannelSelection = viewModel::beginChannelSelection, onBeginChannelSelection = viewModel::beginChannelSelection,
onCancelSelection = viewModel::cancelSelection, onCancelSelection = viewModel::cancelSelection,
onGroupBackClick = viewModel::previousGroupWizardStep,
onGroupTitleChanged = viewModel::updateGroupTitle,
onContinueGroupWizard = viewModel::continueGroupWizard,
onChannelBackClick = viewModel::previousChannelWizardStep, onChannelBackClick = viewModel::previousChannelWizardStep,
onChannelTitleChanged = viewModel::updateChannelTitle, onChannelTitleChanged = viewModel::updateChannelTitle,
onChannelDescriptionChanged = viewModel::updateChannelDescription, onChannelDescriptionChanged = viewModel::updateChannelDescription,
@@ -621,7 +797,6 @@ fun IkarApp() {
onChatSettingsClick = { navController.navigate("settings/chats") }, onChatSettingsClick = { navController.navigate("settings/chats") },
onPrivacyClick = { navController.navigate("settings/privacy") }, onPrivacyClick = { navController.navigate("settings/privacy") },
onNotificationsClick = { navController.navigate("settings/notifications") }, onNotificationsClick = { navController.navigate("settings/notifications") },
onBotsClick = { navController.navigate("bots") },
onFoldersClick = { navController.navigate("settings/folders") }, onFoldersClick = { navController.navigate("settings/folders") },
onDevicesClick = { navController.navigate("settings/devices") }, onDevicesClick = { navController.navigate("settings/devices") },
onUpdatesClick = { navController.navigate("settings/update") }, onUpdatesClick = { navController.navigate("settings/update") },
@@ -643,21 +818,19 @@ fun IkarApp() {
PrivacySecurityScreen( PrivacySecurityScreen(
state = state, state = state,
onBackClick = { navController.popBackStack() }, onBackClick = { navController.popBackStack() },
onRefreshClick = viewModel::refresh, onUpdate = viewModel::update,
onBlockedUsersClick = { navController.navigate("settings/privacy/blocked") }, onUserSearchQueryChanged = viewModel::updateUserSearchQuery,
onExceptionsClick = { navController.navigate("settings/privacy/exceptions") }, onBlockUser = viewModel::blockUser,
onUpdate = viewModel::update 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") { composable("settings/data-storage") {
val viewModel: AppSettingsViewModel = viewModel( val viewModel: AppSettingsViewModel = viewModel(
factory = viewModelFactory { factory = viewModelFactory {
@@ -681,7 +854,7 @@ fun IkarApp() {
} }
) )
val state by viewModel.uiState.collectAsStateWithLifecycle() val state by viewModel.uiState.collectAsStateWithLifecycle()
ThemePlaceholderScreen( ThemeSettingsScreen(
state = state, state = state,
onBackClick = { navController.popBackStack() }, onBackClick = { navController.popBackStack() },
onSnapshotChanged = viewModel::updateSnapshot, onSnapshotChanged = viewModel::updateSnapshot,
@@ -696,7 +869,7 @@ fun IkarApp() {
} }
) )
val state by viewModel.uiState.collectAsStateWithLifecycle() val state by viewModel.uiState.collectAsStateWithLifecycle()
LanguagePlaceholderScreen( LanguageSettingsScreen(
state = state, state = state,
onBackClick = { navController.popBackStack() }, onBackClick = { navController.popBackStack() },
onSnapshotChanged = viewModel::updateSnapshot onSnapshotChanged = viewModel::updateSnapshot
@@ -710,7 +883,7 @@ fun IkarApp() {
} }
) )
val state by viewModel.uiState.collectAsStateWithLifecycle() val state by viewModel.uiState.collectAsStateWithLifecycle()
ProxyPlaceholderScreen( ProxySettingsScreen(
state = state, state = state,
onBackClick = { navController.popBackStack() }, onBackClick = { navController.popBackStack() },
onSnapshotChanged = viewModel::updateSnapshot onSnapshotChanged = viewModel::updateSnapshot
@@ -724,6 +897,16 @@ fun IkarApp() {
} }
) )
val state by viewModel.uiState.collectAsStateWithLifecycle() 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( StoriesScreen(
state = state, state = state,
onBackClick = { navController.popBackStack() }, onBackClick = { navController.popBackStack() },
@@ -734,6 +917,11 @@ fun IkarApp() {
onCloseStoryPreview = viewModel::closeStoryPreview, onCloseStoryPreview = viewModel::closeStoryPreview,
onNextStory = viewModel::openNextStory, onNextStory = viewModel::openNextStory,
onPreviousStory = viewModel::openPreviousStory, onPreviousStory = viewModel::openPreviousStory,
onStoryReplyTextChanged = viewModel::updateStoryReplyText,
onSendStoryReply = viewModel::sendStoryReply,
onSendStoryReaction = viewModel::sendStoryReaction,
onShowStoryViews = viewModel::loadStoryViews,
onCloseStoryViews = viewModel::closeStoryViews,
onRetryTransfer = viewModel::retryTransfer, onRetryTransfer = viewModel::retryTransfer,
onCancelTransfer = viewModel::cancelTransfer onCancelTransfer = viewModel::cancelTransfer
) )
@@ -767,6 +955,11 @@ fun IkarApp() {
}, },
onNextStory = viewModel::openNextStory, onNextStory = viewModel::openNextStory,
onPreviousStory = viewModel::openPreviousStory, onPreviousStory = viewModel::openPreviousStory,
onStoryReplyTextChanged = viewModel::updateStoryReplyText,
onSendStoryReply = viewModel::sendStoryReply,
onSendStoryReaction = viewModel::sendStoryReaction,
onShowStoryViews = viewModel::loadStoryViews,
onCloseStoryViews = viewModel::closeStoryViews,
onRetryTransfer = viewModel::retryTransfer, onRetryTransfer = viewModel::retryTransfer,
onCancelTransfer = viewModel::cancelTransfer onCancelTransfer = viewModel::cancelTransfer
) )
@@ -805,8 +998,7 @@ fun IkarApp() {
DevicesScreen( DevicesScreen(
state = state, state = state,
onBackClick = { navController.popBackStack() }, onBackClick = { navController.popBackStack() },
onRefreshClick = viewModel::refresh, onRefreshClick = viewModel::refresh
onRevokeClick = viewModel::revoke
) )
} }
@@ -959,82 +1151,13 @@ fun IkarApp() {
NotificationSettingsScreen( NotificationSettingsScreen(
state = state, state = state,
messageSoundOptions = viewModel.messageSoundOptions, messageSoundOptions = viewModel.messageSoundOptions,
callRingtoneOptions = viewModel.callRingtoneOptions,
repeatIntervalOptions = viewModel.repeatIntervalOptions,
onBackClick = { navController.popBackStack() }, onBackClick = { navController.popBackStack() },
onSnapshotChanged = { snapshot -> viewModel.updateSnapshot { snapshot } }, onSnapshotChanged = { snapshot -> viewModel.updateSnapshot { snapshot } },
onMessageSoundChanged = viewModel::setMessageSound, onMessageSoundChanged = viewModel::setMessageSound,
onCallRingtoneChanged = viewModel::setCallRingtone,
onRepeatIntervalChanged = viewModel::setRepeatInterval,
onSaveClick = viewModel::save,
onResetClick = viewModel::reset 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) { LaunchedEffect(pendingShare?.id, currentBackStackEntry?.destination?.route) {
@@ -1044,11 +1167,13 @@ fun IkarApp() {
if (application.container.sessionStore.read() == null) { if (application.container.sessionStore.read() == null) {
return@LaunchedEffect return@LaunchedEffect
} }
if (currentBackStackEntry?.destination?.route == "chats") { if (currentBackStackEntry?.destination?.route == "share") {
return@LaunchedEffect return@LaunchedEffect
} }
navigatePrimary("chats") navController.navigate("share") {
launchSingleTop = true
}
} }
LaunchedEffect(pendingArchive?.id, currentBackStackEntry?.destination?.route) { LaunchedEffect(pendingArchive?.id, currentBackStackEntry?.destination?.route) {
@@ -1082,6 +1207,48 @@ fun IkarApp() {
application.container.incomingCallInviteStore.clear(invite.requestId) 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( LaunchedEffect(
pendingPushNavigation?.requestId, pendingPushNavigation?.requestId,
currentBackStackEntry?.destination?.route, currentBackStackEntry?.destination?.route,
@@ -1125,6 +1292,9 @@ fun IkarApp() {
null null
} }
AndroidActiveChatState.setActiveChatId(activeChatId) AndroidActiveChatState.setActiveChatId(activeChatId)
if (activeChatId != null) {
ChatNotificationDismissalManager.cancelChatNotifications(context, activeChatId)
}
} }
activeCall?.let { call -> activeCall?.let { call ->
@@ -1137,7 +1307,16 @@ fun IkarApp() {
} }
if (isAppLocked) { 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 @Composable
private fun AppLockedOverlay() { private fun AppLockedOverlay(onUnlock: (String) -> Boolean) {
var passcode by rememberSaveable { mutableStateOf("") }
var errorMessage by rememberSaveable { mutableStateOf<String?>(null) }
Box( Box(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.background(Color(0xFF101820)), .background(Color(0xFF101820)),
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
Text( Column(
text = "Икар заблокирован", modifier = Modifier.padding(horizontal = 28.dp),
style = MaterialTheme.typography.headlineLarge, horizontalAlignment = Alignment.CenterHorizontally,
color = Color.White 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 package com.seven.ikar.kotlin.app
import android.app.Application import android.app.Application
import android.os.Build
import coil.ImageLoader import coil.ImageLoader
import coil.ImageLoaderFactory import coil.ImageLoaderFactory
import coil.decode.GifDecoder
import coil.decode.ImageDecoderDecoder
import coil.disk.DiskCache import coil.disk.DiskCache
import coil.memory.MemoryCache import coil.memory.MemoryCache
import coil.request.CachePolicy import coil.request.CachePolicy
@@ -47,5 +50,12 @@ class IkarNativeApp : Application(), ImageLoaderFactory {
.networkCachePolicy(CachePolicy.ENABLED) .networkCachePolicy(CachePolicy.ENABLED)
.respectCacheHeaders(false) .respectCacheHeaders(false)
.crossfade(true) .crossfade(true)
.components {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
add(ImageDecoderDecoder.Factory())
} else {
add(GifDecoder.Factory())
}
}
.build() .build()
} }
@@ -67,22 +67,25 @@ class ResolvedContactsRepository(
return@withContext emptyList() return@withContext emptyList()
} }
val contacts = loadResolvedContacts() val contacts = loadResolvedContacts()
if (query.isBlank()) {
return@withContext contacts
}
val merged = contacts.toMutableList() val merged = contacts.toMutableList()
val existingIds = merged.map { it.id }.toMutableSet() 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)) { if (shouldShowCreatorBotShortcut(query)) {
merged += DiscoverUserItem( loadCreatorBotShortcut()?.let { user ->
user = user, if (existingIds.add(user.id)) {
displayName = user.displayName, merged += user.toDiscoverItem()
normalizedPhoneNumber = PhoneNumberNormalizer.normalize(user.phoneNumber) ?: "" }
)
} }
} }
@@ -92,4 +95,35 @@ class ResolvedContactsRepository(
.thenBy { it.displayName.lowercase() } .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> 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 @Upsert
suspend fun upsert(chats: List<CachedChatEntity>) 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) 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) @Serializable(with = ModerationLogTargetTypeSerializer::class)
enum class ModerationLogTargetType(val wireValue: Int) { enum class ModerationLogTargetType(val wireValue: Int) {
Chat(0), Chat(0),
@@ -202,6 +218,14 @@ data class MessageReactionDto(
val isMine: Boolean val isMine: Boolean
) )
@Serializable
data class StoryReplyDto(
val storyId: String,
val authorUserId: String,
val previewText: String,
val attachmentKind: String? = null
)
@Serializable @Serializable
data class MessageDto( data class MessageDto(
val id: String, val id: String,
@@ -225,7 +249,10 @@ data class MessageDto(
val ttlSeconds: Int? = null, val ttlSeconds: Int? = null,
val postedAsChannel: Boolean = false, val postedAsChannel: Boolean = false,
val displayAuthorName: String? = null, 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 @Serializable
@@ -272,7 +299,8 @@ data class ChatSummaryDto(
val publicUsername: String? = null, val publicUsername: String? = null,
val linkedDiscussionChatId: String? = null, val linkedDiscussionChatId: String? = null,
val channelSignaturesEnabled: Boolean = false, val channelSignaturesEnabled: Boolean = false,
val description: String? = null val description: String? = null,
val canDeleteForEveryone: Boolean = false
) )
@Serializable @Serializable
@@ -299,7 +327,8 @@ data class ChatDetailsDto(
val publicUsername: String? = null, val publicUsername: String? = null,
val linkedDiscussionChatId: String? = null, val linkedDiscussionChatId: String? = null,
val channelSignaturesEnabled: Boolean = false, val channelSignaturesEnabled: Boolean = false,
val description: String? = null val description: String? = null,
val canDeleteForEveryone: Boolean = false
) )
@Serializable @Serializable
@@ -320,6 +349,7 @@ data class ChatInfoDto(
val channelSignaturesEnabled: Boolean = false, val channelSignaturesEnabled: Boolean = false,
val canManageChannel: Boolean = false, val canManageChannel: Boolean = false,
val canPostAsChannel: Boolean = false, val canPostAsChannel: Boolean = false,
val canInviteUsers: Boolean = false,
val description: String? = null val description: String? = null
) )
@@ -355,7 +385,13 @@ data class ChatStateRequest(
val hasProtectedContent: Boolean? = null, val hasProtectedContent: Boolean? = null,
val defaultMessageTtlSeconds: Int? = null, val defaultMessageTtlSeconds: Int? = null,
val channelSignaturesEnabled: Boolean? = 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 @Serializable
@@ -368,13 +404,28 @@ data class ChatInviteLinkDto(
val createsJoinRequest: Boolean = false, val createsJoinRequest: Boolean = false,
val isRevoked: Boolean = false, val isRevoked: Boolean = false,
val createdAt: String, 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 @Serializable
data class CreateChatInviteLinkRequest( data class CreateChatInviteLinkRequest(
val title: String? = null, 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 @Serializable
@@ -507,6 +558,19 @@ data class PhoneCodeChallengeDto(
val isRegistered: Boolean 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 @Serializable
data class VerifyPhoneCodeRequest( data class VerifyPhoneCodeRequest(
val challengeId: String, val challengeId: String,
@@ -515,6 +579,15 @@ data class VerifyPhoneCodeRequest(
val displayName: String? = null 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 @Serializable
data class RefreshSessionRequest( data class RefreshSessionRequest(
val refreshToken: String val refreshToken: String
@@ -534,7 +607,8 @@ data class SendMessageRequest(
val replyToMessageId: String? = null, val replyToMessageId: String? = null,
val topicId: String? = null, val topicId: String? = null,
val hasProtectedContent: Boolean? = null, val hasProtectedContent: Boolean? = null,
val ttlSeconds: Int? = null val ttlSeconds: Int? = null,
val storyReplyStoryId: String? = null
) )
@Serializable @Serializable
@@ -566,6 +640,16 @@ data class RegisterPushDeviceRequest(
val notificationsEnabled: Boolean 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 @Serializable
data class AndroidAppReleaseDto( data class AndroidAppReleaseDto(
val versionCode: Int, val versionCode: Int,
@@ -586,10 +670,12 @@ data class ArgusManifestDto(
data class ArgusReleaseDto( data class ArgusReleaseDto(
val id: String, val id: String,
val version: String, val version: String,
val androidVersionCode: Int? = null,
val downloadPath: String, val downloadPath: String,
val originalFileName: String? = null, val originalFileName: String? = null,
val packageSizeBytes: Long? = null, val packageSizeBytes: Long? = null,
val publishedAt: String, val publishedAt: String,
val notes: String? = null,
val releaseNotes: String? = null val releaseNotes: String? = null
) )
@@ -648,6 +734,9 @@ data class PrivacySettingsDto(
val blockedUserIds: List<String> = emptyList(), val blockedUserIds: List<String> = emptyList(),
val alwaysAllowUserIds: List<String> = emptyList(), val alwaysAllowUserIds: List<String> = emptyList(),
val neverAllowUserIds: 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 passcodeLockEnabled: Boolean = false,
val showMessagePreview: Boolean = true, val showMessagePreview: Boolean = true,
val updatedAt: String 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.ChatTopicDto
import com.seven.ikar.kotlin.core.model.CreateChatInviteLinkRequest import com.seven.ikar.kotlin.core.model.CreateChatInviteLinkRequest
import com.seven.ikar.kotlin.core.model.ContactMatchDto 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.CreateChannelRequest
import com.seven.ikar.kotlin.core.model.CreateChatTopicRequest import com.seven.ikar.kotlin.core.model.CreateChatTopicRequest
import com.seven.ikar.kotlin.core.model.CreateStoryRequest 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.CreateDirectChatRequest
import com.seven.ikar.kotlin.core.model.CreateGroupChatRequest import com.seven.ikar.kotlin.core.model.CreateGroupChatRequest
import com.seven.ikar.kotlin.core.model.DownloadedAttachmentFile 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.ForwardMessageRequest
import com.seven.ikar.kotlin.core.model.JoinChatResultDto
import com.seven.ikar.kotlin.core.model.MessageDto import com.seven.ikar.kotlin.core.model.MessageDto
import com.seven.ikar.kotlin.core.model.MessageSearchPageDto import com.seven.ikar.kotlin.core.model.MessageSearchPageDto
import com.seven.ikar.kotlin.core.model.ModerationLogDto import com.seven.ikar.kotlin.core.model.ModerationLogDto
import com.seven.ikar.kotlin.core.model.PhoneCodeChallengeDto import com.seven.ikar.kotlin.core.model.PhoneCodeChallengeDto
import com.seven.ikar.kotlin.core.model.PrivacySettingsDto 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.RegenerateBotTokenResponseDto
import com.seven.ikar.kotlin.core.model.RequestEmailCodeRequest
import com.seven.ikar.kotlin.core.model.RequestPhoneCodeRequest import com.seven.ikar.kotlin.core.model.RequestPhoneCodeRequest
import com.seven.ikar.kotlin.core.model.RegisterPushDeviceRequest import com.seven.ikar.kotlin.core.model.RegisterPushDeviceRequest
import com.seven.ikar.kotlin.core.model.RefreshSessionRequest 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.UpdatePrivacySettingsRequest
import com.seven.ikar.kotlin.core.model.UserSessionDto import com.seven.ikar.kotlin.core.model.UserSessionDto
import com.seven.ikar.kotlin.core.model.UserSummaryDto 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 com.seven.ikar.kotlin.core.model.VerifyPhoneCodeRequest
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
@@ -138,6 +141,21 @@ class ApiClient(
PhoneCodeChallengeDto.serializer() 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( suspend fun verifyPhoneCode(
challengeId: String, challengeId: String,
phoneNumber: String, phoneNumber: String,
@@ -163,6 +181,33 @@ class ApiClient(
AuthSessionDto.serializer() 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 = suspend fun refreshSession(refreshToken: String): AuthSessionDto =
executeJsonRequest( executeJsonRequest(
Request.Builder() Request.Builder()
@@ -200,6 +245,32 @@ class ApiClient(
kotlinx.serialization.builtins.ListSerializer(ChatFolderDto.serializer()) 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> = suspend fun searchChats(accessToken: String, query: String, filter: String? = null, limit: Int = 50): List<ChatSearchResultDto> =
searchChatsPage( searchChatsPage(
accessToken = accessToken, accessToken = accessToken,
@@ -280,7 +351,10 @@ class ApiClient(
accessToken: String, accessToken: String,
chatId: String, chatId: String,
title: String? = null, title: String? = null,
createsJoinRequest: Boolean = false createsJoinRequest: Boolean = false,
expiresAt: String? = null,
maxUses: Int? = null,
memberLimit: Int? = null
): ChatInviteLinkDto = ): ChatInviteLinkDto =
executeJsonRequest( executeJsonRequest(
Request.Builder() Request.Builder()
@@ -288,7 +362,13 @@ class ApiClient(
.post( .post(
json.encodeToString( json.encodeToString(
CreateChatInviteLinkRequest.serializer(), 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) ).toRequestBody(JSON)
) )
.withBearer(accessToken) .withBearer(accessToken)
@@ -307,7 +387,7 @@ class ApiClient(
.build() .build()
) )
suspend fun joinChatByInviteLink(accessToken: String, token: String): ChatDetailsDto = suspend fun joinChatByInviteLink(accessToken: String, token: String): JoinChatResultDto =
executeJsonRequest( executeJsonRequest(
Request.Builder() Request.Builder()
.url(ServerConfig.absoluteMessengerUrl("/api/chats/join/${encodeQuery(token)}")) .url(ServerConfig.absoluteMessengerUrl("/api/chats/join/${encodeQuery(token)}"))
@@ -315,7 +395,7 @@ class ApiClient(
.withBearer(accessToken) .withBearer(accessToken)
.withClientVersionHeaders() .withClientVersionHeaders()
.build(), .build(),
ChatDetailsDto.serializer() JoinChatResultDto.serializer()
) )
suspend fun getChatJoinRequests(accessToken: String, chatId: String): List<ChatJoinRequestDto> = suspend fun getChatJoinRequests(accessToken: String, chatId: String): List<ChatJoinRequestDto> =
@@ -393,6 +473,22 @@ class ApiClient(
ChatMessagesPageDto.serializer() 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( suspend fun searchChatMessages(
accessToken: String, accessToken: String,
chatId: String, chatId: String,
@@ -501,6 +597,28 @@ class ApiClient(
ChatDetailsDto.serializer() 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> = suspend fun getChatModerationLog(accessToken: String, chatId: String, limit: Int = 100): List<ModerationLogDto> =
executeJsonRequest( executeJsonRequest(
Request.Builder() Request.Builder()
@@ -529,7 +647,8 @@ class ApiClient(
replyToMessageId: String? = null, replyToMessageId: String? = null,
topicId: String? = null, topicId: String? = null,
hasProtectedContent: Boolean? = null, hasProtectedContent: Boolean? = null,
ttlSeconds: Int? = null ttlSeconds: Int? = null,
storyReplyStoryId: String? = null
): MessageDto = ): MessageDto =
executeJsonRequest( executeJsonRequest(
Request.Builder() Request.Builder()
@@ -542,7 +661,8 @@ class ApiClient(
replyToMessageId = replyToMessageId, replyToMessageId = replyToMessageId,
topicId = topicId, topicId = topicId,
hasProtectedContent = hasProtectedContent, hasProtectedContent = hasProtectedContent,
ttlSeconds = ttlSeconds ttlSeconds = ttlSeconds,
storyReplyStoryId = storyReplyStoryId
) )
).toRequestBody(JSON) ).toRequestBody(JSON)
) )
@@ -627,13 +747,17 @@ class ApiClient(
chatId: String, chatId: String,
text: String?, text: String?,
replyToMessageId: String?, replyToMessageId: String?,
file: UploadFileSource file: UploadFileSource,
isVoiceNote: Boolean = false
): MessageDto = withContext(Dispatchers.IO) { ): MessageDto = withContext(Dispatchers.IO) {
val multipart = MultipartBody.Builder() val multipart = MultipartBody.Builder()
.setType(MultipartBody.FORM) .setType(MultipartBody.FORM)
.apply { .apply {
text?.takeIf { it.isNotBlank() }?.let { addFormDataPart("text", it) } text?.takeIf { it.isNotBlank() }?.let { addFormDataPart("text", it) }
replyToMessageId?.takeIf { it.isNotBlank() }?.let { addFormDataPart("replyToMessageId", it) } replyToMessageId?.takeIf { it.isNotBlank() }?.let { addFormDataPart("replyToMessageId", it) }
if (isVoiceNote) {
addFormDataPart("isVoiceNote", "true")
}
addFormDataPart("file", file.fileName, file.asRequestBody()) addFormDataPart("file", file.fileName, file.asRequestBody())
} }
.build() .build()
@@ -710,6 +834,16 @@ class ApiClient(
.build() .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 = suspend fun setMessageReaction(accessToken: String, chatId: String, messageId: String, emoji: String): MessageDto =
executeJsonRequest( executeJsonRequest(
Request.Builder() Request.Builder()
@@ -1154,22 +1288,6 @@ class ApiClient(
BotSummaryDto.serializer() 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 = suspend fun updateBot(accessToken: String, botId: String, displayName: String, about: String?, isEnabled: Boolean): BotSummaryDto =
executeJsonRequest( executeJsonRequest(
Request.Builder() Request.Builder()
@@ -1233,6 +1351,17 @@ class ApiClient(
.build() .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) = suspend fun unregisterPushDevice(accessToken: String, installationId: String) =
executeUnitRequest( executeUnitRequest(
Request.Builder() Request.Builder()
@@ -1256,19 +1385,39 @@ class ApiClient(
val version = manifest.release.version val version = manifest.release.version
val plusIndex = version.lastIndexOf('+') val plusIndex = version.lastIndexOf('+')
val versionName = if (plusIndex > 0) version.substring(0, plusIndex) else version 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( return AndroidAppReleaseDto(
versionCode = versionCode, versionCode = versionCode,
versionName = versionName, versionName = versionName,
downloadPath = ServerConfig.absoluteArgusUrl(manifest.release.downloadPath), downloadPath = ServerConfig.absoluteArgusUrl(manifest.release.downloadPath),
publishedAt = manifest.release.publishedAt, publishedAt = manifest.release.publishedAt,
notes = manifest.release.releaseNotes, notes = manifest.release.notes ?: manifest.release.releaseNotes,
isRequired = true, isRequired = true,
minimumSupportedVersionCode = versionCode 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 = private suspend fun <T> executeJsonRequest(request: Request, serializer: kotlinx.serialization.KSerializer<T>): T =
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
try { try {
@@ -1380,6 +1529,7 @@ class ApiClient(
companion object { companion object {
private val JSON = "application/json; charset=utf-8".toMediaType() 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.content.Context
import android.util.Base64 import android.util.Base64
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import java.util.UUID import java.util.UUID
class AndroidPushSettings(context: Context) { class AndroidPushSettings(context: Context) {
private val preferences = context.getSharedPreferences(PREFERENCES_NAME, Context.MODE_PRIVATE) private val preferences = context.getSharedPreferences(PREFERENCES_NAME, Context.MODE_PRIVATE)
private val _snapshot = MutableStateFlow(loadSnapshot())
val snapshot: StateFlow<NotificationSettingsSnapshot> = _snapshot.asStateFlow()
fun getInstallationId(): String { fun getInstallationId(): String {
val existing = preferences.getString(KEY_INSTALLATION_ID, null) val existing = preferences.getString(KEY_INSTALLATION_ID, null)
@@ -53,7 +58,7 @@ class AndroidPushSettings(context: Context) {
} }
fun getChannelsEnabled(): Boolean = fun getChannelsEnabled(): Boolean =
preferences.getBoolean(KEY_CHANNELS_ENABLED, false) preferences.getBoolean(KEY_CHANNELS_ENABLED, true)
fun setChannelsEnabled(enabled: Boolean) { fun setChannelsEnabled(enabled: Boolean) {
preferences.edit().putBoolean(KEY_CHANNELS_ENABLED, enabled).apply() preferences.edit().putBoolean(KEY_CHANNELS_ENABLED, enabled).apply()
@@ -137,7 +142,7 @@ class AndroidPushSettings(context: Context) {
} }
fun getPopupWindowsEnabled(): Boolean = fun getPopupWindowsEnabled(): Boolean =
preferences.getBoolean(KEY_POPUP_WINDOWS_ENABLED, false) preferences.getBoolean(KEY_POPUP_WINDOWS_ENABLED, true)
fun setPopupWindowsEnabled(enabled: Boolean) { fun setPopupWindowsEnabled(enabled: Boolean) {
preferences.edit().putBoolean(KEY_POPUP_WINDOWS_ENABLED, enabled).apply() preferences.edit().putBoolean(KEY_POPUP_WINDOWS_ENABLED, enabled).apply()
@@ -230,6 +235,7 @@ class AndroidPushSettings(context: Context) {
setRestartOnCloseEnabled(snapshot.restartOnCloseEnabled) setRestartOnCloseEnabled(snapshot.restartOnCloseEnabled)
setBackgroundConnectionEnabled(snapshot.backgroundConnectionEnabled) setBackgroundConnectionEnabled(snapshot.backgroundConnectionEnabled)
setRepeatInterval(snapshot.repeatInterval) setRepeatInterval(snapshot.repeatInterval)
_snapshot.value = loadSnapshot()
} }
fun loadChatNotificationSnapshot(chatId: String): ChatNotificationSettingsSnapshot = fun loadChatNotificationSnapshot(chatId: String): ChatNotificationSettingsSnapshot =
@@ -278,7 +284,7 @@ class AndroidPushSettings(context: Context) {
messageSound = NotificationSoundOption.Default, messageSound = NotificationSoundOption.Default,
privateChatsEnabled = true, privateChatsEnabled = true,
groupsEnabled = true, groupsEnabled = true,
channelsEnabled = false, channelsEnabled = true,
storiesEnabled = false, storiesEnabled = false,
reactionsEnabled = true, reactionsEnabled = true,
callVibrationEnabled = true, callVibrationEnabled = true,
@@ -290,7 +296,7 @@ class AndroidPushSettings(context: Context) {
inAppVibrationEnabled = true, inAppVibrationEnabled = true,
inAppShowTextEnabled = true, inAppShowTextEnabled = true,
soundInActiveChatEnabled = true, soundInActiveChatEnabled = true,
popupWindowsEnabled = false, popupWindowsEnabled = true,
contactJoinedEnabled = false, contactJoinedEnabled = false,
pinnedMessagesEnabled = true, pinnedMessagesEnabled = true,
restartOnCloseEnabled = false, restartOnCloseEnabled = false,
@@ -326,6 +332,7 @@ class AndroidPushSettings(context: Context) {
chatKeys.forEach(::remove) chatKeys.forEach(::remove)
apply() apply()
} }
_snapshot.value = loadSnapshot()
} }
private fun <T : Enum<T>> getEnum(key: String, defaultValue: T): T { 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.BroadcastReceiver
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import androidx.core.app.NotificationManagerCompat
import com.seven.ikar.kotlin.app.IkarNativeApp import com.seven.ikar.kotlin.app.IkarNativeApp
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
@@ -39,19 +38,23 @@ class NotificationActionReceiver : BroadcastReceiver() {
} }
val app = context.applicationContext as? IkarNativeApp ?: return val app = context.applicationContext as? IkarNativeApp ?: return
runCatching { val ownerUserId = runCatching {
app.container.executeAuthorized { session -> app.container.executeAuthorized { session ->
app.container.apiClient.acknowledgeRead( app.container.apiClient.acknowledgeRead(
accessToken = session.accessToken, accessToken = session.accessToken,
chatId = chatId, chatId = chatId,
messageId = messageId messageId = messageId
) )
session.user.id
} }
}.onSuccess { }.getOrNull() ?: return
if (notificationId != Int.MIN_VALUE) {
NotificationManagerCompat.from(context).cancel(notificationId) 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) { private fun handleAcceptIncomingCall(context: Context, intent: Intent) {
@@ -6,9 +6,13 @@ import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.pm.PackageManager import android.content.pm.PackageManager
import android.os.Build 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.NotificationCompat
import androidx.core.app.NotificationManagerCompat import androidx.core.app.NotificationManagerCompat
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import com.seven.ikar.kotlin.BuildConfig
import com.seven.ikar.kotlin.MainActivity import com.seven.ikar.kotlin.MainActivity
import com.seven.ikar.kotlin.app.IkarNativeApp import com.seven.ikar.kotlin.app.IkarNativeApp
import com.seven.ikar.kotlin.core.contacts.PhoneNumberNormalizer import com.seven.ikar.kotlin.core.contacts.PhoneNumberNormalizer
@@ -26,6 +30,22 @@ object PushNotificationDisplayService {
AndroidPushBootstrapper.tryInitialize(context) AndroidPushBootstrapper.tryInitialize(context)
val kind = data[PushDataKeys.Kind].orEmpty() 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 val app = context.applicationContext as? IkarNativeApp
when (kind) { when (kind) {
PushDataKinds.AudioCallInvite, PushDataKinds.AudioCallInvite,
@@ -65,6 +85,7 @@ object PushNotificationDisplayService {
val isMessageReaction = kind == PushDataKinds.MessageReaction val isMessageReaction = kind == PushDataKinds.MessageReaction
val isChatNotification = isChatMessage || isMessageReaction val isChatNotification = isChatMessage || isMessageReaction
val chatId = data[PushDataKeys.ChatId].orEmpty() val chatId = data[PushDataKeys.ChatId].orEmpty()
val chatType = data[PushDataKeys.ChatType].orEmpty()
val chatNotificationSettings = if (isChatNotification && chatId.isNotBlank()) { val chatNotificationSettings = if (isChatNotification && chatId.isNotBlank()) {
settings.loadChatNotificationSnapshot(chatId) settings.loadChatNotificationSnapshot(chatId)
} else { } else {
@@ -73,6 +94,9 @@ object PushNotificationDisplayService {
if (isChatNotification && chatNotificationSettings?.notificationsEnabled == false) { if (isChatNotification && chatNotificationSettings?.notificationsEnabled == false) {
return return
} }
if (isChatNotification && !isChatCategoryEnabled(settings, chatType, isMessageReaction)) {
return
}
val hideMessagePreview = isChatNotification && val hideMessagePreview = isChatNotification &&
(app?.container?.privacyRuntimeSettings?.loadSnapshot()?.showMessagePreview == false || (app?.container?.privacyRuntimeSettings?.loadSnapshot()?.showMessagePreview == false ||
chatNotificationSettings?.showMessagePreview == false) chatNotificationSettings?.showMessagePreview == false)
@@ -101,13 +125,13 @@ object PushNotificationDisplayService {
).joinToString(separator = "|").takeIf { it.isNotBlank() } ).joinToString(separator = "|").takeIf { it.isNotBlank() }
) )
} }
if (settings.getInAppVibrationEnabled()) {
vibrateForInAppNotification(context)
}
return return
} }
val popupWindowsEnabled = chatNotificationSettings?.popupWindowsEnabled ?: settings.getPopupWindowsEnabled() val popupWindowsEnabled = chatNotificationSettings?.popupWindowsEnabled ?: settings.getPopupWindowsEnabled()
if (isChatNotification && isAppVisible && !popupWindowsEnabled) {
return
}
val senderContactDisplayName = if (isChatNotification) { val senderContactDisplayName = if (isChatNotification) {
resolveContactDisplayName(context, data[PushDataKeys.SenderPhoneNumber]) 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)) { val title = if (isChatNotification && chatType.equals(ChatTypeDirect, ignoreCase = true)) {
senderContactDisplayName ?: fallbackTitle senderContactDisplayName ?: fallbackTitle
} else { } else {
@@ -179,10 +202,14 @@ object PushNotificationDisplayService {
data.forEach { (key, value) -> putExtra(key, value) } data.forEach { (key, value) -> putExtra(key, value) }
} }
val notificationId = ((if (isMessageReaction) "reaction:${data[PushDataKeys.MessageId].orEmpty()}" else data[PushDataKeys.MessageId]) val notificationId = if (isAppUpdateNotification) {
?: data[PushDataKeys.CallId] AppUpdateNotificationId
?: data[PushDataKeys.VersionCode] } else {
?: System.currentTimeMillis().toString()).hashCode() ((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) { val flags = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
@@ -212,12 +239,17 @@ object PushNotificationDisplayService {
PushDataKinds.AudioCallInvite, PushDataKinds.AudioCallInvite,
PushDataKinds.VideoCallInvite, PushDataKinds.VideoCallInvite,
PushDataKinds.AppUpdateAvailable -> NotificationCompat.PRIORITY_HIGH 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) { val category = when (kind) {
PushDataKinds.AudioCallInvite, PushDataKinds.AudioCallInvite,
PushDataKinds.VideoCallInvite -> NotificationCompat.CATEGORY_CALL PushDataKinds.VideoCallInvite -> NotificationCompat.CATEGORY_CALL
PushDataKinds.AppUpdateAvailable -> NotificationCompat.CATEGORY_STATUS
else -> NotificationCompat.CATEGORY_MESSAGE else -> NotificationCompat.CATEGORY_MESSAGE
} }
@@ -265,19 +297,103 @@ object PushNotificationDisplayService {
.addAction(android.R.drawable.ic_menu_view, "Отметить как прочитанное", markReadPendingIntent) .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()) 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 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 GenericNewReactionText = "\u041d\u043e\u0432\u0430\u044f \u0440\u0435\u0430\u043a\u0446\u0438\u044f"
private const val ChatTypeDirect = "Direct" private const val ChatTypeDirect = "Direct"
private const val ChatTypeGroup = "Group" private const val ChatTypeGroup = "Group"
private const val ChatTypeChannel = "Channel"
private const val ContactNameCacheTtlMillis = 5 * 60 * 1000L private const val ContactNameCacheTtlMillis = 5 * 60 * 1000L
private val contactNameCacheLock = Any() private val contactNameCacheLock = Any()
@Volatile @Volatile
private var contactNameCache: ContactNameCache? = null 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 { private fun PendingIncomingCallInvite.withResolvedContactDisplayName(context: Context): PendingIncomingCallInvite {
val contactName = resolveContactDisplayName(context, callerPhoneNumber) ?: return this val contactName = resolveContactDisplayName(context, callerPhoneNumber) ?: return this
return copy(callerDisplayName = contactName) return copy(callerDisplayName = contactName)
@@ -64,7 +64,9 @@ data class RealtimeMessageDto(
val ttlSeconds: Int? = null, val ttlSeconds: Int? = null,
val postedAsChannel: Boolean = false, val postedAsChannel: Boolean = false,
val displayAuthorName: String? = null, val displayAuthorName: String? = null,
val viewCount: Int? = null val viewCount: Int? = null,
val discussionMessageId: String? = null,
val commentCount: Int? = null
) )
data class RealtimeMessageReplyDto( data class RealtimeMessageReplyDto(
@@ -207,7 +209,9 @@ fun RealtimeMessageDto.toCore(): MessageDto = MessageDto(
ttlSeconds = ttlSeconds, ttlSeconds = ttlSeconds,
postedAsChannel = postedAsChannel, postedAsChannel = postedAsChannel,
displayAuthorName = displayAuthorName, displayAuthorName = displayAuthorName,
viewCount = viewCount viewCount = viewCount,
discussionMessageId = discussionMessageId,
commentCount = commentCount
) )
fun RealtimeMessageReplyDto.toCore(): MessageReplyDto = MessageReplyDto( fun RealtimeMessageReplyDto.toCore(): MessageReplyDto = MessageReplyDto(
@@ -29,6 +29,7 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.sync.withLock
import okhttp3.OkHttpClient
import java.net.URLEncoder import java.net.URLEncoder
import java.nio.charset.StandardCharsets import java.nio.charset.StandardCharsets
@@ -40,7 +41,8 @@ enum class RealtimeConnectionStage {
} }
class RealtimeService( 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 scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private val mutex = Mutex() private val mutex = Mutex()
@@ -66,6 +68,9 @@ class RealtimeService(
private val _chatsChanged = MutableSharedFlow<Unit>(extraBufferCapacity = 8) private val _chatsChanged = MutableSharedFlow<Unit>(extraBufferCapacity = 8)
val chatsChanged: SharedFlow<Unit> = _chatsChanged val chatsChanged: SharedFlow<Unit> = _chatsChanged
private val _storiesChanged = MutableSharedFlow<Unit>(extraBufferCapacity = 8)
val storiesChanged: SharedFlow<Unit> = _storiesChanged
private val _appUpdateAvailable = MutableSharedFlow<AndroidAppReleaseDto>(extraBufferCapacity = 8) private val _appUpdateAvailable = MutableSharedFlow<AndroidAppReleaseDto>(extraBufferCapacity = 8)
val appUpdateAvailable: SharedFlow<AndroidAppReleaseDto> = _appUpdateAvailable 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 { fun startCall(chatId: String, mediaType: CallMediaType): OutgoingCallDto {
val activeConnection = requireConnectedConnection() val activeConnection = requireConnectedConnection()
return if (mediaType == CallMediaType.Audio) { return if (mediaType == CallMediaType.Audio) {
@@ -190,6 +223,7 @@ class RealtimeService(
private fun buildConnection(accessToken: String): HubConnection { private fun buildConnection(accessToken: String): HubConnection {
val hubUrl = buildHubUrl() val hubUrl = buildHubUrl()
val hub = HubConnectionBuilder.create(hubUrl) val hub = HubConnectionBuilder.create(hubUrl)
.setHttpClientBuilderCallback { builder -> configureHttpClientBuilder(builder) }
.withAccessTokenProvider(Single.defer { Single.just(accessToken) }) .withAccessTokenProvider(Single.defer { Single.just(accessToken) })
.withHeader("X-Ikar-Client-Platform", clientVersionProvider.platform) .withHeader("X-Ikar-Client-Platform", clientVersionProvider.platform)
.withHeader("X-Ikar-Client-Version-Code", clientVersionProvider.versionCode) .withHeader("X-Ikar-Client-Version-Code", clientVersionProvider.versionCode)
@@ -216,6 +250,10 @@ class RealtimeService(
scope.launch { _chatsChanged.emit(Unit) } scope.launch { _chatsChanged.emit(Unit) }
}) })
hub.on("StoriesChanged", {
scope.launch { _storiesChanged.emit(Unit) }
})
hub.on("AppUpdateAvailable", { payload: AndroidAppReleaseDto -> hub.on("AppUpdateAvailable", { payload: AndroidAppReleaseDto ->
scope.launch { _appUpdateAvailable.emit(payload) } scope.launch { _appUpdateAvailable.emit(payload) }
}, AndroidAppReleaseDto::class.java) }, AndroidAppReleaseDto::class.java)
@@ -65,7 +65,7 @@ class AndroidAppSettings(context: Context) {
largeEmoji = preferences.getBoolean(KEY_LARGE_EMOJI, true), largeEmoji = preferences.getBoolean(KEY_LARGE_EMOJI, true),
languageMode = getEnum(KEY_LANGUAGE_MODE, AppLanguageMode.System), languageMode = getEnum(KEY_LANGUAGE_MODE, AppLanguageMode.System),
proxyEnabled = preferences.getBoolean(KEY_PROXY_ENABLED, false), 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(), proxyHost = preferences.getString(KEY_PROXY_HOST, "").orEmpty(),
proxyPort = preferences.getInt(KEY_PROXY_PORT, 1080).coerceIn(1, 65_535), proxyPort = preferences.getInt(KEY_PROXY_PORT, 1080).coerceIn(1, 65_535),
proxySecret = preferences.getString(KEY_PROXY_SECRET, "").orEmpty() proxySecret = preferences.getString(KEY_PROXY_SECRET, "").orEmpty()
@@ -79,6 +79,7 @@ class AndroidAppSettings(context: Context) {
val sanitized = snapshot.copy( val sanitized = snapshot.copy(
keepMediaDays = snapshot.keepMediaDays.coerceIn(1, 365), keepMediaDays = snapshot.keepMediaDays.coerceIn(1, 365),
maxCacheMb = snapshot.maxCacheMb.coerceIn(64, 8192), maxCacheMb = snapshot.maxCacheMb.coerceIn(64, 8192),
proxyType = snapshot.proxyType.supportedProxyType(),
proxyPort = snapshot.proxyPort.coerceIn(1, 65_535) proxyPort = snapshot.proxyPort.coerceIn(1, 65_535)
) )
preferences.edit().apply { preferences.edit().apply {
@@ -111,12 +112,16 @@ class AndroidAppSettings(context: Context) {
private fun <T : Enum<T>> getEnum(key: String, defaultValue: T): T { private fun <T : Enum<T>> getEnum(key: String, defaultValue: T): T {
val rawValue = preferences.getString(key, defaultValue.name).orEmpty() val rawValue = preferences.getString(key, defaultValue.name).orEmpty()
@Suppress("UNCHECKED_CAST")
return defaultValue.declaringJavaClass.enumConstants return defaultValue.declaringJavaClass.enumConstants
?.firstOrNull { it.name == rawValue } as? T ?.firstOrNull { it.name == rawValue }
?: defaultValue ?: defaultValue
} }
private fun AppProxyType.supportedProxyType(): AppProxyType = when (this) {
AppProxyType.MtProto -> AppProxyType.Socks5
else -> this
}
companion object { companion object {
private const val PREFERENCES_NAME = "ikar_kotlin_app_settings" private const val PREFERENCES_NAME = "ikar_kotlin_app_settings"
private const val KEY_AUTO_DOWNLOAD_PHOTOS = "auto_download_photos" private const val KEY_AUTO_DOWNLOAD_PHOTOS = "auto_download_photos"
@@ -1,10 +1,15 @@
package com.seven.ikar.kotlin.core.settings package com.seven.ikar.kotlin.core.settings
import android.content.Context import android.content.Context
import android.util.Base64
import com.seven.ikar.kotlin.core.model.PrivacySettingsDto import com.seven.ikar.kotlin.core.model.PrivacySettingsDto
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow 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( data class PrivacyRuntimeSnapshot(
val passcodeLockEnabled: Boolean = false, val passcodeLockEnabled: Boolean = false,
@@ -23,6 +28,9 @@ class AndroidPrivacyRuntimeSettings(context: Context) {
) )
fun saveFrom(settings: PrivacySettingsDto) { fun saveFrom(settings: PrivacySettingsDto) {
if (!settings.passcodeLockEnabled) {
clearPasscode()
}
saveSnapshot( saveSnapshot(
PrivacyRuntimeSnapshot( PrivacyRuntimeSnapshot(
passcodeLockEnabled = settings.passcodeLockEnabled, 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) { private fun saveSnapshot(snapshot: PrivacyRuntimeSnapshot) {
preferences.edit() preferences.edit()
.putBoolean(KEY_PASSCODE_LOCK_ENABLED, snapshot.passcodeLockEnabled) .putBoolean(KEY_PASSCODE_LOCK_ENABLED, snapshot.passcodeLockEnabled)
@@ -39,10 +79,22 @@ class AndroidPrivacyRuntimeSettings(context: Context) {
_snapshot.value = snapshot _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 { companion object {
private const val PREFERENCES_NAME = "ikar_kotlin_privacy_runtime" private const val PREFERENCES_NAME = "ikar_kotlin_privacy_runtime"
private const val KEY_PASSCODE_LOCK_ENABLED = "passcode_lock_enabled" private const val KEY_PASSCODE_LOCK_ENABLED = "passcode_lock_enabled"
private const val KEY_SHOW_MESSAGE_PREVIEW = "show_message_preview" 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 org.json.JSONObject
import java.io.File import java.io.File
import java.net.HttpURLConnection import java.net.HttpURLConnection
import java.net.Proxy
import java.net.URL import java.net.URL
import java.security.MessageDigest import java.security.MessageDigest
class ArgusAppUpdateClient { class ArgusAppUpdateClient(
private val proxyProvider: () -> Proxy? = { null }
) {
fun fetchManifest(config: ArgusAppUpdateConfig): ArgusAppManifest? { fun fetchManifest(config: ArgusAppUpdateConfig): ArgusAppManifest? {
val url = buildManifestUrl(config) val url = buildManifestUrl(config)
val connection = openConnection(url) val connection = openConnection(url)
@@ -62,12 +65,15 @@ class ArgusAppUpdateClient {
return "$baseUrl/api/apps/$slug/manifest?platform=${config.platform}&channel=${config.channel}" return "$baseUrl/api/apps/$slug/manifest?platform=${config.platform}&channel=${config.channel}"
} }
private fun openConnection(url: String): HttpURLConnection = private fun openConnection(url: String): HttpURLConnection {
(URL(url).openConnection() as HttpURLConnection).apply { val targetUrl = URL(url)
val connection = proxyProvider()?.let(targetUrl::openConnection) ?: targetUrl.openConnection()
return (connection as HttpURLConnection).apply {
connectTimeout = 15_000 connectTimeout = 15_000
readTimeout = 60_000 readTimeout = 60_000
setRequestProperty("Accept", "application/json") setRequestProperty("Accept", "application/json")
} }
}
private fun parseManifest(body: String): ArgusAppManifest { private fun parseManifest(body: String): ArgusAppManifest {
val root = JSONObject(body) val root = JSONObject(body)
@@ -17,7 +17,7 @@ class ArgusAppUpdateInstaller(private val context: Context) {
update: AvailableIkarUpdate update: AvailableIkarUpdate
): AppUpdateSubmissionResult { ): AppUpdateSubmissionResult {
val expectedPackageName = update.installedApp.packageName val expectedPackageName = update.installedApp.packageName
val expectedVersionCode = update.manifest.release.androidVersionCode val expectedVersionCode = update.manifest.release.resolvedVersionCode
val archiveInfo = readArchiveInfo(packageFile) val archiveInfo = readArchiveInfo(packageFile)
?: return reject( ?: return reject(
expectedPackageName, expectedPackageName,
@@ -45,18 +45,16 @@ class ArgusAppUpdateManager(
"На сервере Argus нет совместимого релиза для ${config.slug}." "На сервере Argus нет совместимого релиза для ${config.slug}."
) )
val manifestPackageName = manifest.androidPackageName?.trim() manifest.androidPackageName
if (manifestPackageName.isNullOrBlank()) { ?.trim()
return AppUpdateCheckResult.Incompatible( ?.takeIf { it.isNotBlank() }
"В карточке Argus не указан androidPackageName." ?.let { manifestPackageName ->
) if (!manifestPackageName.equals(installedApp.packageName, ignoreCase = false)) {
} return AppUpdateCheckResult.Incompatible(
"Argus отдаёт пакет $manifestPackageName, а текущее приложение имеет пакет ${installedApp.packageName}."
if (!manifestPackageName.equals(installedApp.packageName, ignoreCase = false)) { )
return AppUpdateCheckResult.Incompatible( }
"Argus отдаёт пакет $manifestPackageName, а текущее приложение имеет пакет ${installedApp.packageName}." }
)
}
if (!manifest.release.packageKind.equals("apk", ignoreCase = true)) { if (!manifest.release.packageKind.equals("apk", ignoreCase = true)) {
return AppUpdateCheckResult.Incompatible( return AppUpdateCheckResult.Incompatible(
@@ -64,12 +62,24 @@ class ArgusAppUpdateManager(
) )
} }
val remoteVersionCode = manifest.release.androidVersionCode val remoteVersionCode = manifest.release.resolvedVersionCode
?: return AppUpdateCheckResult.Incompatible( val installedVersionName = installedApp.versionName?.trim().orEmpty()
"Argus не вернул androidVersionCode для релиза." 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) return AppUpdateCheckResult.UpToDate(manifest)
} }
@@ -27,7 +27,38 @@ data class ArgusPublishedRelease(
val sha256: String, val sha256: String,
val publishedAt: String, val publishedAt: String,
val notes: 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( data class ArgusAppManifest(
val slug: String, val slug: String,
@@ -45,8 +76,112 @@ data class AvailableIkarUpdate(
val installedApp: InstalledIkarApp, val installedApp: InstalledIkarApp,
val manifest: ArgusAppManifest val manifest: ArgusAppManifest
) { ) {
val versionCode: Long
get() = manifest.release.resolvedVersionCode ?: 0L
val downloadUrl: String 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 { sealed interface AppUpdateCheckResult {
@@ -1,5 +1,6 @@
package com.seven.ikar.kotlin.feature.bots package com.seven.ikar.kotlin.feature.bots
import android.content.ClipData
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize 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.Delete
import androidx.compose.material.icons.outlined.SmartToy import androidx.compose.material.icons.outlined.SmartToy
import androidx.compose.material3.AlertDialog import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Card import androidx.compose.material3.Card
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
@@ -31,12 +31,14 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalClipboardManager import androidx.compose.ui.platform.ClipEntry
import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.platform.LocalClipboard
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import kotlinx.coroutines.launch
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
@@ -48,12 +50,12 @@ fun BotEditorScreen(
onAboutChanged: (String) -> Unit, onAboutChanged: (String) -> Unit,
onCommandsChanged: (String) -> Unit, onCommandsChanged: (String) -> Unit,
onEnabledChanged: (Boolean) -> Unit, onEnabledChanged: (Boolean) -> Unit,
onSaveClick: () -> Unit,
onRegenerateTokenClick: () -> Unit, onRegenerateTokenClick: () -> Unit,
onDeleteBotClick: () -> Unit, onDeleteBotClick: () -> Unit,
onOpenChatClick: () -> Unit onOpenChatClick: () -> Unit
) { ) {
val clipboardManager = LocalClipboardManager.current val clipboard = LocalClipboard.current
val clipboardScope = rememberCoroutineScope()
var showDeleteConfirmation by remember { mutableStateOf(false) } var showDeleteConfirmation by remember { mutableStateOf(false) }
if (showDeleteConfirmation) { if (showDeleteConfirmation) {
AlertDialog( AlertDialog(
@@ -81,7 +83,7 @@ fun BotEditorScreen(
Scaffold( Scaffold(
topBar = { topBar = {
TopAppBar( TopAppBar(
title = { Text(if (state.isExistingBot) "Редактор бота" else "Новый бот") }, title = { Text(if (state.isExistingBot) "Редактор бота" else "Создатель ботов") },
navigationIcon = { navigationIcon = {
IconButton(onClick = onBackClick) { IconButton(onClick = onBackClick) {
Icon(Icons.AutoMirrored.Outlined.ArrowBack, contentDescription = "Назад") Icon(Icons.AutoMirrored.Outlined.ArrowBack, contentDescription = "Назад")
@@ -147,8 +149,13 @@ fun BotEditorScreen(
} }
} }
Button(onClick = onSaveClick, enabled = !state.isSaving && !state.isDeleting, modifier = Modifier.fillMaxWidth()) { if (!state.isExistingBot) {
Text(if (state.isSaving) "Сохранение..." else "Сохранить") Text(
"Новых ботов создавайте в чате с @botfatherbot командой /создать_бота.",
color = MaterialTheme.colorScheme.onSurfaceVariant
)
} else if (state.isSaving) {
Text("Сохраняется...", color = MaterialTheme.colorScheme.onSurfaceVariant)
} }
if (state.isExistingBot) { if (state.isExistingBot) {
@@ -183,7 +190,13 @@ fun BotEditorScreen(
Text("Текущий токен", fontWeight = FontWeight.SemiBold) Text("Текущий токен", fontWeight = FontWeight.SemiBold)
Text(state.latestToken, style = MaterialTheme.typography.bodySmall) Text(state.latestToken, style = MaterialTheme.typography.bodySmall)
OutlinedButton( OutlinedButton(
onClick = { clipboardManager.setText(AnnotatedString(state.latestToken)) }, onClick = {
clipboardScope.launch {
clipboard.setClipEntry(
ClipEntry(ClipData.newPlainText("Ikar bot token", state.latestToken))
)
}
},
modifier = Modifier.fillMaxWidth() modifier = Modifier.fillMaxWidth()
) { ) {
Icon(Icons.Outlined.ContentCopy, contentDescription = null) 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.AuthSessionDto
import com.seven.ikar.kotlin.core.model.BotCommandDto import com.seven.ikar.kotlin.core.model.BotCommandDto
import com.seven.ikar.kotlin.core.model.BotSummaryDto 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.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
@@ -38,29 +40,34 @@ class BotEditorViewModel(
) : ViewModel() { ) : ViewModel() {
private val _uiState = MutableStateFlow(BotEditorUiState(session = container.sessionStore.read(), botId = botId)) private val _uiState = MutableStateFlow(BotEditorUiState(session = container.sessionStore.read(), botId = botId))
val uiState: StateFlow<BotEditorUiState> = _uiState.asStateFlow() val uiState: StateFlow<BotEditorUiState> = _uiState.asStateFlow()
private var autosaveJob: Job? = null
init { init {
load() load()
} }
fun updateDisplayName(value: String) { 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) { 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) { 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) { 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) { 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() { fun load() {
@@ -85,8 +92,10 @@ class BotEditorViewModel(
_uiState.value = state.copy(errorMessage = "Укажите отображаемое имя бота.") _uiState.value = state.copy(errorMessage = "Укажите отображаемое имя бота.")
return return
} }
if (!state.isExistingBot && state.username.isBlank()) { if (!state.isExistingBot) {
_uiState.value = state.copy(errorMessage = "Укажите имя пользователя бота.") _uiState.value = state.copy(
errorMessage = "Создавайте ботов через чат с @botfatherbot командой /создать_бота."
)
return return
} }
@@ -95,34 +104,19 @@ class BotEditorViewModel(
runCatching { runCatching {
container.executeAuthorized { session -> container.executeAuthorized { session ->
val parsedCommands = parseCommands(state.commandsText) val parsedCommands = parseCommands(state.commandsText)
if (!state.isExistingBot) { val updated = container.apiClient.updateBot(
val created = container.apiClient.createBot( accessToken = session.accessToken,
accessToken = session.accessToken, botId = state.botId.orEmpty(),
username = state.username.trim(), displayName = state.displayName.trim(),
displayName = state.displayName.trim(), about = state.about.trim().ifBlank { null },
about = state.about.trim().ifBlank { null } isEnabled = state.isEnabled
) )
val updatedBot = if (parsedCommands.isNotEmpty()) { val withCommands = container.apiClient.setBotCommands(
container.apiClient.setBotCommands(session.accessToken, created.bot.id, parsedCommands) accessToken = session.accessToken,
} else { botId = updated.id,
created.bot commands = parsedCommands
} )
Triple(session, updatedBot, Pair(created.accessToken, "Бот создан.")) Triple(session, withCommands, Pair(state.latestToken, "Бот обновлён."))
} else {
val updated = container.apiClient.updateBot(
accessToken = session.accessToken,
botId = state.botId.orEmpty(),
displayName = state.displayName.trim(),
about = state.about.trim().ifBlank { null },
isEnabled = state.isEnabled
)
val withCommands = container.apiClient.setBotCommands(
accessToken = session.accessToken,
botId = updated.id,
commands = parsedCommands
)
Triple(session, withCommands, Pair(state.latestToken, "Бот сохранён."))
}
} }
}.onSuccess { result -> }.onSuccess { result ->
val (session, bot, extra) = result val (session, bot, extra) = result
@@ -233,4 +227,20 @@ class BotEditorViewModel(
private fun formatCommands(commands: List<BotCommandDto>): String = private fun formatCommands(commands: List<BotCommandDto>): String =
commands.joinToString(separator = "\n") { "/${it.command} - ${it.description}" } 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.foundation.lazy.items
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.outlined.ArrowBack import androidx.compose.material.icons.automirrored.outlined.ArrowBack
import androidx.compose.material.icons.outlined.Add
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
@@ -41,7 +40,7 @@ import com.seven.ikar.kotlin.ui.theme.TelegramInkMuted
fun BotsScreen( fun BotsScreen(
state: BotsUiState, state: BotsUiState,
onBackClick: () -> Unit, onBackClick: () -> Unit,
onCreateBotClick: () -> Unit, onOpenCreatorBotClick: () -> Unit,
onBotClick: (BotSummaryDto) -> Unit onBotClick: (BotSummaryDto) -> Unit
) { ) {
IkarScreenSurface(modifier = Modifier.fillMaxSize()) { IkarScreenSurface(modifier = Modifier.fillMaxSize()) {
@@ -72,8 +71,8 @@ fun BotsScreen(
} }
) )
Button(onClick = onCreateBotClick) { Button(onClick = onOpenCreatorBotClick) {
Text("Создать бота") Text("Открыть Создателя ботов")
} }
state.errorMessage?.let { state.errorMessage?.let {
@@ -98,7 +97,7 @@ fun BotsScreen(
) { ) {
Text("Ботов пока нет", style = MaterialTheme.typography.headlineMedium) Text("Ботов пока нет", style = MaterialTheme.typography.headlineMedium)
Text( Text(
"Создайте первого бота и подключите его через Bot API.", "Создайте первого бота через чат с Создателем ботов.",
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
color = TelegramInkMuted 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.ContentValues
import android.content.Context import android.content.Context
import android.content.Intent 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.net.Uri
import android.os.Build import android.os.Build
import android.os.Environment import android.os.Environment
import android.provider.MediaStore import android.provider.MediaStore
import android.provider.OpenableColumns import android.provider.OpenableColumns
import android.util.Base64
import androidx.core.content.FileProvider import androidx.core.content.FileProvider
import com.seven.ikar.kotlin.core.model.DownloadedAttachmentFile import com.seven.ikar.kotlin.core.model.DownloadedAttachmentFile
import com.seven.ikar.kotlin.core.network.UploadFileSource import com.seven.ikar.kotlin.core.network.UploadFileSource
@@ -41,6 +48,7 @@ internal fun LocalAttachmentFile.toUploadFileSource(): UploadFileSource =
class AndroidAttachmentFileService( class AndroidAttachmentFileService(
private val context: Context private val context: Context
) { ) {
private val maxCacheFileNameLength = 120
private val maxAttachmentSizeBytes = 25L * 1024L * 1024L private val maxAttachmentSizeBytes = 25L * 1024L * 1024L
private val attachmentsDirectory: File by lazy { private val attachmentsDirectory: File by lazy {
File(context.cacheDir, "attachments").apply { mkdirs() } 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 = fun createAttachmentCacheFile(attachmentId: String, fileName: String): File =
File(attachmentsDirectory, "${attachmentId}_${sanitizeFileName(fileName)}") File(attachmentsDirectory, "${sanitizeFileName(attachmentId)}_${sanitizeCacheFileName(fileName)}")
suspend fun cacheDownloadedAttachment(file: DownloadedAttachmentFile): LocalAttachmentFile = suspend fun cacheDownloadedAttachment(file: DownloadedAttachmentFile): LocalAttachmentFile =
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
val target = File(file.localPath) val target = File(file.localPath)
target.setLastModified(System.currentTimeMillis()) target.setLastModified(System.currentTimeMillis())
LocalAttachmentFile( LocalAttachmentFile(
fileName = target.name.substringAfter('_', file.fileName), fileName = file.fileName,
contentType = file.contentType, contentType = file.contentType,
fileSizeBytes = file.fileSizeBytes.takeIf { it >= 0L } ?: target.length(), fileSizeBytes = file.fileSizeBytes.takeIf { it >= 0L } ?: target.length(),
localPath = target.absolutePath 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 { private fun requireExistingFile(localPath: String): File {
val file = File(localPath) val file = File(localPath)
if (!file.exists()) { if (!file.exists()) {
@@ -244,6 +341,29 @@ class AndroidAttachmentFileService(
return file 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 = private fun contentUriFor(file: File): Uri =
FileProvider.getUriForFile( FileProvider.getUriForFile(
context, context,
@@ -298,4 +418,29 @@ class AndroidAttachmentFileService(
private fun sanitizeFileName(value: String): String = private fun sanitizeFileName(value: String): String =
value.trim().ifBlank { "attachment" }.replace(Regex("[^A-Za-z0-9._-]"), "_") 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 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.CancellationException
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
@@ -11,27 +18,51 @@ import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.io.File
import java.util.concurrent.Semaphore import java.util.concurrent.Semaphore
data class VoiceNotePlaybackEvent( data class VoiceNotePlaybackEvent(
val attachmentId: String, val attachmentId: String,
val isPlaying: Boolean, val isPlaying: Boolean,
val positionMs: Long, 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 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) private val _events = MutableSharedFlow<VoiceNotePlaybackEvent>(extraBufferCapacity = 32)
val events: SharedFlow<VoiceNotePlaybackEvent> = _events.asSharedFlow() val events: SharedFlow<VoiceNotePlaybackEvent> = _events.asSharedFlow()
private var player: MediaPlayer? = null private var player: ExoPlayer? = null
private var currentAttachmentId: String? = null private var currentAttachmentId: String? = null
private var durationMs: Long = 0L private var durationMs: Long = 0L
private var tickerJob: Job? = null 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() sync.acquire()
try { try {
if (player != null && currentAttachmentId == attachmentId) { if (player != null && currentAttachmentId == attachmentId) {
@@ -41,19 +72,46 @@ class AndroidVoiceNotePlayerService {
stopCurrent() stopCurrent()
val nextPlayer = MediaPlayer().apply { val nextPlayer = ExoPlayer.Builder(context).build().apply {
setDataSource(localFilePath) setAudioAttributes(VoiceNoteAudioAttributes, pauseExternalAudio)
prepare()
} }
player = nextPlayer player = nextPlayer
currentAttachmentId = attachmentId currentAttachmentId = attachmentId
durationMs = nextPlayer.duration.toLong().coerceAtLeast(0L) durationMs = 0L
nextPlayer.setOnCompletionListener { nextPlayer.addListener(object : Player.Listener {
scope.launch { stopCurrent(playbackCompleted = true) } override fun onPlaybackStateChanged(playbackState: Int) {
} val activeAttachmentId = currentAttachmentId ?: return
nextPlayer.start() 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() startTicker()
emitEvent(attachmentId, true, 0L, durationMs) emitEvent(attachmentId, true, 0L, durationMs)
} finally { } finally {
@@ -81,16 +139,8 @@ class AndroidVoiceNotePlayerService {
durationMs = 0L durationMs = 0L
stopTicker() stopTicker()
existingPlayer?.let { mediaPlayer -> existingPlayer?.let { exoPlayer ->
try { exoPlayer.release()
if (safeIsPlaying(mediaPlayer)) {
mediaPlayer.stop()
}
} catch (_: Throwable) {
}
mediaPlayer.reset()
mediaPlayer.release()
} }
if (attachmentId != null) { 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 { try {
mediaPlayer?.isPlaying == true exoPlayer?.isPlaying == true
} catch (_: Throwable) { } catch (_: Throwable) {
false false
} }
private fun safePosition(mediaPlayer: MediaPlayer?): Long = private fun safePosition(exoPlayer: ExoPlayer?): Long =
try { try {
mediaPlayer?.currentPosition?.toLong()?.coerceAtLeast(0L) ?: 0L exoPlayer?.currentPosition?.coerceAtLeast(0L) ?: 0L
} catch (_: Throwable) { } catch (_: Throwable) {
0L 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 package com.seven.ikar.kotlin.feature.chat
import android.content.Context import android.content.Context
import android.media.AudioFocusRequest
import android.media.AudioManager
import android.media.MediaRecorder import android.media.MediaRecorder
import android.os.Build import android.os.Build
import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CancellationException
@@ -28,11 +30,15 @@ class AndroidVoiceNoteRecorderService(
private var currentFile: File? = null private var currentFile: File? = null
private var startedAtMs: Long = 0L private var startedAtMs: Long = 0L
private var tickerJob: Job? = null 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 val isRecording: Boolean
get() = recorder != null get() = recorder != null
suspend fun start() { suspend fun start(pauseExternalAudio: Boolean = false) {
sync.acquire() sync.acquire()
try { try {
if (recorder != null) { if (recorder != null) {
@@ -48,6 +54,7 @@ class AndroidVoiceNoteRecorderService(
} }
try { try {
requestAudioFocusIfNeeded(pauseExternalAudio)
nextRecorder.setAudioSource(MediaRecorder.AudioSource.MIC) nextRecorder.setAudioSource(MediaRecorder.AudioSource.MIC)
nextRecorder.setOutputFormat(MediaRecorder.OutputFormat.MPEG_4) nextRecorder.setOutputFormat(MediaRecorder.OutputFormat.MPEG_4)
nextRecorder.setAudioEncoder(MediaRecorder.AudioEncoder.AAC) nextRecorder.setAudioEncoder(MediaRecorder.AudioEncoder.AAC)
@@ -60,6 +67,7 @@ class AndroidVoiceNoteRecorderService(
nextRecorder.reset() nextRecorder.reset()
nextRecorder.release() nextRecorder.release()
file.delete() file.delete()
abandonAudioFocus()
throw error throw error
} }
@@ -87,6 +95,7 @@ class AndroidVoiceNoteRecorderService(
if (currentRecorder == null) { if (currentRecorder == null) {
_elapsedMs.value = 0L _elapsedMs.value = 0L
abandonAudioFocus()
return null return null
} }
@@ -98,6 +107,7 @@ class AndroidVoiceNoteRecorderService(
} finally { } finally {
currentRecorder.reset() currentRecorder.reset()
currentRecorder.release() currentRecorder.release()
abandonAudioFocus()
} }
_elapsedMs.value = 0L _elapsedMs.value = 0L
@@ -145,4 +155,40 @@ class AndroidVoiceNoteRecorderService(
tickerJob?.cancel() tickerJob?.cancel()
tickerJob = null 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.app.AppContainer
import com.seven.ikar.kotlin.core.contacts.PhoneNumberNormalizer import com.seven.ikar.kotlin.core.contacts.PhoneNumberNormalizer
import com.seven.ikar.kotlin.core.model.AttachmentDto 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.AuthSessionDto
import com.seven.ikar.kotlin.core.model.BotCommandDto import com.seven.ikar.kotlin.core.model.BotCommandDto
import com.seven.ikar.kotlin.core.model.CallMediaType import com.seven.ikar.kotlin.core.model.CallMediaType
@@ -37,6 +38,7 @@ import kotlinx.coroutines.Job
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import java.io.File
import java.time.OffsetDateTime import java.time.OffsetDateTime
import java.time.ZoneId import java.time.ZoneId
import java.time.ZoneOffset import java.time.ZoneOffset
@@ -111,6 +113,7 @@ data class ChatUiState(
val canSubscribe: Boolean = false, val canSubscribe: Boolean = false,
val canManageChannel: Boolean = false, val canManageChannel: Boolean = false,
val canPostAsChannel: Boolean = false, val canPostAsChannel: Boolean = false,
val canDeleteForEveryone: Boolean = false,
val publicUsername: String? = null, val publicUsername: String? = null,
val linkedDiscussionChatId: String? = null, val linkedDiscussionChatId: String? = null,
val channelDescription: String? = null, val channelDescription: String? = null,
@@ -128,6 +131,7 @@ data class ChatUiState(
val attachmentPlayback: Map<String, VoiceAttachmentPlaybackUiState> = emptyMap(), val attachmentPlayback: Map<String, VoiceAttachmentPlaybackUiState> = emptyMap(),
val botCommands: List<BotCommandDto> = emptyList(), val botCommands: List<BotCommandDto> = emptyList(),
val previewImageAttachmentId: String? = null, val previewImageAttachmentId: String? = null,
val previewVideoAttachmentId: String? = null,
val firstUnreadMessageId: String? = null, val firstUnreadMessageId: String? = null,
val selectedMessageIds: Set<String> = emptySet(), val selectedMessageIds: Set<String> = emptySet(),
val isDeleteSelectionDialogVisible: Boolean = false, val isDeleteSelectionDialogVisible: Boolean = false,
@@ -139,7 +143,8 @@ data class ChatUiState(
val scrollRequest: ChatScrollRequest? = null, val scrollRequest: ChatScrollRequest? = null,
val isSendingMessage: Boolean = false, val isSendingMessage: Boolean = false,
val isDeletingChat: Boolean = false, val isDeletingChat: Boolean = false,
val isChatDeleted: Boolean = false val isChatDeleted: Boolean = false,
val attachmentCacheVersion: Long = 0L
) { ) {
val isMessageSelectionMode: Boolean val isMessageSelectionMode: Boolean
get() = selectedMessageIds.isNotEmpty() get() = selectedMessageIds.isNotEmpty()
@@ -165,7 +170,13 @@ class ChatViewModel(
private val container: AppContainer, private val container: AppContainer,
private val chatId: String private val chatId: String
) : ViewModel() { ) : 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() val uiState: StateFlow<ChatUiState> = _uiState.asStateFlow()
private val cachedAttachmentFiles = mutableMapOf<String, LocalAttachmentFile>() private val cachedAttachmentFiles = mutableMapOf<String, LocalAttachmentFile>()
@@ -179,6 +190,8 @@ class ChatViewModel(
private var searchJob: Job? = null private var searchJob: Job? = null
private var typingStateSent = false private var typingStateSent = false
private var lastTypingSentAtMillis = 0L private var lastTypingSentAtMillis = 0L
private var lastLocallyReadMessageId: String? = null
private var lastDeliveredMessageId: String? = null
init { init {
attachRealtime() attachRealtime()
@@ -192,6 +205,9 @@ class ChatViewModel(
fun updateDraft(value: String) { fun updateDraft(value: String) {
_uiState.value = _uiState.value.copy(draftText = value) _uiState.value = _uiState.value.copy(draftText = value)
if (editingMessage == null) {
container.chatDraftStore.save(chatId, value)
}
notifyTyping(value.isNotBlank()) notifyTyping(value.isNotBlank())
} }
@@ -307,6 +323,56 @@ class ChatViewModel(
_uiState.value = _uiState.value.copy(draftText = _uiState.value.draftText + emoji) _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() { fun clearErrorMessage() {
_uiState.value = _uiState.value.copy(errorMessage = null) _uiState.value = _uiState.value.copy(errorMessage = null)
} }
@@ -350,6 +416,12 @@ class ChatViewModel(
if (messageId.isBlank()) { if (messageId.isBlank()) {
return return
} }
val state = _uiState.value
if (state.messages.none { it.id == messageId }) {
requestScrollToMessageInternal(messageId)
loadMessagesAround(messageId)
return
}
requestScrollToMessageInternal(messageId) 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() { fun sendMessage() {
val state = _uiState.value val state = _uiState.value
if (!state.canSendMessages || state.isSendingMessage) { if (!state.canSendMessages || state.isSendingMessage) {
@@ -531,6 +654,7 @@ class ChatViewModel(
) )
}.onSuccess { }.onSuccess {
clearPendingAttachment() clearPendingAttachment()
clearStoredDraft()
_uiState.value = _uiState.value.copy(draftText = "", replyTo = null, isSendingMessage = false) _uiState.value = _uiState.value.copy(draftText = "", replyTo = null, isSendingMessage = false)
}.onFailure { error -> }.onFailure { error ->
_uiState.value = _uiState.value.copy(isSendingMessage = false, errorMessage = error.message) _uiState.value = _uiState.value.copy(isSendingMessage = false, errorMessage = error.message)
@@ -546,6 +670,7 @@ class ChatViewModel(
} }
if (optimisticMessage != null) { if (optimisticMessage != null) {
clearStoredDraft()
val optimisticMessages = (state.messages + optimisticMessage) val optimisticMessages = (state.messages + optimisticMessage)
.distinctBy { it.id } .distinctBy { it.id }
.sortedBy { it.sentAt } .sortedBy { it.sentAt }
@@ -607,6 +732,7 @@ class ChatViewModel(
if (localMessageId != null) { if (localMessageId != null) {
replaceLocalMessage(localMessageId, message) replaceLocalMessage(localMessageId, message)
} else { } else {
clearStoredDraft()
_uiState.value = _uiState.value.copy(draftText = "", replyTo = null, isSendingMessage = false) _uiState.value = _uiState.value.copy(draftText = "", replyTo = null, isSendingMessage = false)
replaceOrInsertMessage(message) replaceOrInsertMessage(message)
} }
@@ -666,6 +792,7 @@ class ChatViewModel(
} }
}.onSuccess { message -> }.onSuccess { message ->
if (commandText.startsWith("/turn ", ignoreCase = true)) { if (commandText.startsWith("/turn ", ignoreCase = true)) {
clearStoredDraft()
_uiState.value = _uiState.value.copy(draftText = "") _uiState.value = _uiState.value.copy(draftText = "")
} }
replaceOrInsertMessage(message) replaceOrInsertMessage(message)
@@ -684,6 +811,7 @@ class ChatViewModel(
} }
}.onSuccess { ownerUserId -> }.onSuccess { ownerUserId ->
clearPendingAttachment() clearPendingAttachment()
clearStoredDraft()
cancelEditMessageInternal(clearDraft = true) cancelEditMessageInternal(clearDraft = true)
container.chatScrollStateStore.clear(chatId) container.chatScrollStateStore.clear(chatId)
clearCachedChat(ownerUserId) clearCachedChat(ownerUserId)
@@ -706,7 +834,7 @@ class ChatViewModel(
} }
} }
fun deleteChat() { fun deleteChat(forEveryone: Boolean = false) {
val state = _uiState.value val state = _uiState.value
if (state.isDeletingChat || state.isChatDeleted) { if (state.isDeletingChat || state.isChatDeleted) {
return return
@@ -719,12 +847,13 @@ class ChatViewModel(
container.apiClient.deleteChat( container.apiClient.deleteChat(
accessToken = session.accessToken, accessToken = session.accessToken,
chatId = chatId, chatId = chatId,
forEveryone = false forEveryone = forEveryone
) )
session.user.id session.user.id
} }
}.onSuccess { ownerUserId -> }.onSuccess { ownerUserId ->
clearPendingAttachment() clearPendingAttachment()
clearStoredDraft()
cancelEditMessageInternal(clearDraft = true) cancelEditMessageInternal(clearDraft = true)
container.chatScrollStateStore.clear(chatId) container.chatScrollStateStore.clear(chatId)
container.pushSettings.resetChatNotificationPreferences(chatId) container.pushSettings.resetChatNotificationPreferences(chatId)
@@ -779,6 +908,7 @@ class ChatViewModel(
} }
}.onSuccess { ownerUserId -> }.onSuccess { ownerUserId ->
clearPendingAttachment() clearPendingAttachment()
clearStoredDraft()
cancelEditMessageInternal(clearDraft = true) cancelEditMessageInternal(clearDraft = true)
container.chatScrollStateStore.clear(chatId) container.chatScrollStateStore.clear(chatId)
container.pushSettings.resetChatNotificationPreferences(chatId) container.pushSettings.resetChatNotificationPreferences(chatId)
@@ -960,11 +1090,16 @@ class ChatViewModel(
return return
} }
if (attachment.kind == com.seven.ikar.kotlin.core.model.AttachmentKind.VoiceNote) { if (attachment.isVoiceNoteAttachment()) {
toggleVoiceAttachmentPlayback(attachment) toggleVoiceAttachmentPlayback(attachment)
return return
} }
if (attachment.isVideoAttachment()) {
showVideoPreviewAttachment(attachment.id)
return
}
viewModelScope.launch { viewModelScope.launch {
runCatching { runCatching {
val local = ensureAttachmentDownloaded(attachment) val local = ensureAttachmentDownloaded(attachment)
@@ -987,6 +1122,14 @@ class ChatViewModel(
_uiState.value = _uiState.value.copy(previewImageAttachmentId = attachmentId) _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) { fun shareAttachmentExternally(attachment: AttachmentDto) {
if (isAttachmentInProtectedMessage(attachment)) { if (isAttachmentInProtectedMessage(attachment)) {
_uiState.value = _uiState.value.copy(errorMessage = "Защищённый контент нельзя пересылать.") _uiState.value = _uiState.value.copy(errorMessage = "Защищённый контент нельзя пересылать.")
@@ -1034,7 +1177,9 @@ class ChatViewModel(
viewModelScope.launch { viewModelScope.launch {
runCatching { runCatching {
container.voiceNoteRecorderService.start() container.voiceNoteRecorderService.start(
pauseExternalAudio = container.chatSettings.snapshot.value.pauseMusicOnRecordEnabled
)
}.onSuccess { }.onSuccess {
_uiState.value = _uiState.value.copy( _uiState.value = _uiState.value.copy(
isRecordingVoiceNote = true, isRecordingVoiceNote = true,
@@ -1079,7 +1224,8 @@ class ChatViewModel(
fileName = file.name, fileName = file.name,
contentType = "audio/mp4", contentType = "audio/mp4",
contentLength = file.length() contentLength = file.length()
) { file.inputStream() } ) { file.inputStream() },
isVoiceNote = true
) )
} }
} finally { } finally {
@@ -1165,9 +1311,31 @@ class ChatViewModel(
container.callManager.endCurrentCall() container.callManager.endCurrentCall()
} }
fun attachmentPreviewUrl(attachment: AttachmentDto): String = when { fun attachmentPreviewUrl(attachment: AttachmentDto): String {
attachment.downloadPath.startsWith("http://") || attachment.downloadPath.startsWith("https://") -> attachment.downloadPath val cachedFile = cachedAttachmentFiles[attachment.id]
else -> ServerConfig.absoluteMessengerUrl(attachment.downloadPath) ?.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 = fun showCallHeaderButton(state: ChatUiState = uiState.value): Boolean =
@@ -1305,6 +1473,7 @@ class ChatViewModel(
canSubscribe = chat.canSubscribe, canSubscribe = chat.canSubscribe,
canManageChannel = chat.canManageChannel, canManageChannel = chat.canManageChannel,
canPostAsChannel = chat.canPostAsChannel, canPostAsChannel = chat.canPostAsChannel,
canDeleteForEveryone = chat.canDeleteForEveryone,
publicUsername = chat.publicUsername, publicUsername = chat.publicUsername,
linkedDiscussionChatId = chat.linkedDiscussionChatId, linkedDiscussionChatId = chat.linkedDiscussionChatId,
channelDescription = chat.description, channelDescription = chat.description,
@@ -1326,6 +1495,11 @@ class ChatViewModel(
} }
) )
cacheMessages(session.user.id, chat.messages) 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) scheduleAutoDownloads(orderedMessages)
refreshCallBanner() refreshCallBanner()
} }
@@ -1335,9 +1509,8 @@ class ChatViewModel(
container.realtimeService.messagesCreated.collect { message -> container.realtimeService.messagesCreated.collect { message ->
if (message.chatId == chatId) { if (message.chatId == chatId) {
replaceOrInsertMessage(message) replaceOrInsertMessage(message)
requestScrollToMessageInternal(message.id)
if (message.sender.id != _uiState.value.session?.user?.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, positionMs = event.positionMs,
durationMs = event.durationMs 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 localPath = target.absolutePath
) )
cachedAttachmentFiles[attachment.id] = cached cachedAttachmentFiles[attachment.id] = cached
notifyAttachmentCacheChanged()
return cached return cached
} }
@@ -1517,10 +1694,27 @@ class ChatViewModel(
val downloaded = container.apiClient.downloadAttachmentTo(session.accessToken, attachment, target) val downloaded = container.apiClient.downloadAttachmentTo(session.accessToken, attachment, target)
val cached = container.attachmentFileService.cacheDownloadedAttachment(downloaded) val cached = container.attachmentFileService.cacheDownloadedAttachment(downloaded)
cachedAttachmentFiles[attachment.id] = cached cachedAttachmentFiles[attachment.id] = cached
saveImageToGalleryIfEnabled(attachment, cached)
notifyAttachmentCacheChanged()
cached 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>) { private fun scheduleAutoDownloads(messages: List<MessageDto>) {
val settings = container.appSettings.loadSnapshot() val settings = container.appSettings.loadSnapshot()
if (!settings.autoDownloadPhotos && !settings.autoDownloadVideosOnWifi && !settings.autoDownloadFilesOnWifi) { if (!settings.autoDownloadPhotos && !settings.autoDownloadVideosOnWifi && !settings.autoDownloadFilesOnWifi) {
@@ -1549,7 +1743,7 @@ class ChatViewModel(
isUnmetered: Boolean isUnmetered: Boolean
): Boolean = when { ): Boolean = when {
attachment.isImageAttachment() -> settings.autoDownloadPhotos attachment.isImageAttachment() -> settings.autoDownloadPhotos
attachment.contentType.startsWith("video/", ignoreCase = true) -> settings.autoDownloadVideosOnWifi && isUnmetered attachment.isVideoAttachment() -> settings.autoDownloadVideosOnWifi && isUnmetered
else -> settings.autoDownloadFilesOnWifi && isUnmetered else -> settings.autoDownloadFilesOnWifi && isUnmetered
} }
@@ -1572,7 +1766,22 @@ class ChatViewModel(
val local = ensureAttachmentDownloaded(attachment) val local = ensureAttachmentDownloaded(attachment)
container.voiceNotePlayerService.toggle( container.voiceNotePlayerService.toggle(
attachmentId = attachment.id, 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 -> }.onFailure { error ->
_uiState.value = _uiState.value.copy(errorMessage = error.message) _uiState.value = _uiState.value.copy(errorMessage = error.message)
@@ -1585,12 +1794,16 @@ class ChatViewModel(
_uiState.value = _uiState.value.copy(pendingAttachment = null) _uiState.value = _uiState.value.copy(pendingAttachment = null)
} }
private fun clearStoredDraft() {
container.chatDraftStore.clear(chatId)
}
private fun cancelEditMessageInternal(clearDraft: Boolean) { private fun cancelEditMessageInternal(clearDraft: Boolean) {
editingMessage = null editingMessage = null
_uiState.value = _uiState.value.copy( _uiState.value = _uiState.value.copy(
editingMessageId = null, editingMessageId = null,
editingMessageBanner = "", 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) { private fun clearCachedChat(ownerUserId: String) {
viewModelScope.launch(Dispatchers.IO) { viewModelScope.launch(Dispatchers.IO) {
container.chatMessageCache.clearChat(ownerUserId, chatId) container.chatMessageCache.clearChat(ownerUserId, chatId)
@@ -1723,11 +1941,57 @@ class ChatViewModel(
chatId = chatId, chatId = chatId,
messageId = messageId 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? { private fun resolvePeer(chat: ChatDetailsDto, session: AuthSessionDto): UserSummaryDto? {
if (chat.type != ChatType.Direct) { if (chat.type != ChatType.Direct) {
return null return null
@@ -1825,8 +2089,10 @@ class ChatViewModel(
state.activeCall == null state.activeCall == null
} }
internal fun MessageDto.canManage(currentUserId: String?): Boolean = internal fun MessageDto.canManage(currentUserId: String?, canPostAsChannel: Boolean = false): Boolean =
currentUserId != null && sender.id == currentUserId && deletedAt == null currentUserId != null &&
deletedAt == null &&
(sender.id == currentUserId || (chatType == ChatType.Channel && canPostAsChannel))
internal fun MessageDto.canDelete(currentUserId: String?): Boolean = internal fun MessageDto.canDelete(currentUserId: String?): Boolean =
currentUserId != null && deletedAt == null currentUserId != null && deletedAt == null
@@ -1945,6 +2211,104 @@ internal fun AttachmentDto.isImageAttachment(): Boolean =
fileName.endsWith(".webp", ignoreCase = true) || fileName.endsWith(".webp", ignoreCase = true) ||
fileName.endsWith(".gif", 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> { internal fun buildBotQuickActions(message: MessageDto): List<BotQuickAction> {
if (!message.sender.isBot || message.text.isBlank()) { if (!message.sender.isBot || message.text.isBlank()) {
return emptyList() 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 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.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.WindowInsetsSides 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.only
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawing import androidx.compose.foundation.layout.safeDrawing
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.rememberScrollState 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.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.ContentCopy
import androidx.compose.material3.Checkbox import androidx.compose.material3.Checkbox
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier 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.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp 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.ChatInviteLinkDto
import com.seven.ikar.kotlin.core.model.ChatSummaryDto
import com.seven.ikar.kotlin.core.model.ChatTopicDto import com.seven.ikar.kotlin.core.model.ChatTopicDto
import com.seven.ikar.kotlin.core.model.ChatType import com.seven.ikar.kotlin.core.model.ChatType
import com.seven.ikar.kotlin.ui.IkarRowDivider 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.TelegramBlue
import com.seven.ikar.kotlin.ui.theme.TelegramDanger import com.seven.ikar.kotlin.ui.theme.TelegramDanger
import com.seven.ikar.kotlin.ui.theme.TelegramInkMuted 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 @Composable
fun ChatInfoScreen( fun ChatInfoScreen(
@@ -44,14 +67,26 @@ fun ChatInfoScreen(
onRefreshClick: () -> Unit, onRefreshClick: () -> Unit,
onMembersClick: () -> Unit, onMembersClick: () -> Unit,
onSearchClick: () -> Unit, onSearchClick: () -> Unit,
onProfileTitleChanged: (String) -> Unit,
onProfileDescriptionChanged: (String) -> Unit,
onProfilePublicUsernameChanged: (String) -> Unit,
onMaterialsClick: (ChatMaterialFilter) -> Unit,
onInviteTitleChanged: (String) -> Unit, onInviteTitleChanged: (String) -> Unit,
onInviteJoinRequestChanged: (Boolean) -> Unit, onInviteJoinRequestChanged: (Boolean) -> Unit,
onInviteExpiresInHoursChanged: (String) -> Unit,
onInviteMaxUsesChanged: (String) -> Unit,
onInviteMemberLimitChanged: (String) -> Unit,
onCreateInviteClick: () -> Unit, onCreateInviteClick: () -> Unit,
onRevokeInviteClick: (ChatInviteLinkDto) -> Unit, onRevokeInviteClick: (ChatInviteLinkDto) -> Unit,
onTopicTitleChanged: (String) -> Unit, onTopicTitleChanged: (String) -> Unit,
onCreateTopicClick: () -> Unit onCreateTopicClick: () -> Unit,
onLinkDiscussionClick: (ChatSummaryDto) -> Unit,
onClearDiscussionClick: () -> Unit
) { ) {
val info = state.info val info = state.info
val clipboard = LocalClipboard.current
val clipboardScope = rememberCoroutineScope()
val context = LocalContext.current
IkarScreenSurface(modifier = Modifier.fillMaxSize()) { IkarScreenSurface(modifier = Modifier.fillMaxSize()) {
Column( Column(
modifier = Modifier modifier = Modifier
@@ -75,42 +110,145 @@ fun ChatInfoScreen(
if (info != null) { if (info != null) {
IkarSectionCard { IkarSectionCard {
IkarSectionTitle("Обзор", modifier = Modifier.padding(bottom = 8.dp)) Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text(info.chat.type.label(), style = MaterialTheme.typography.bodyMedium, color = TelegramInkMuted) IkarSectionTitle("Обзор")
Text(info.channelAudienceText(), style = MaterialTheme.typography.titleMedium) Text(info.chat.type.label(), style = MaterialTheme.typography.bodyMedium, color = TelegramInkMuted)
if (info.chat.type == ChatType.Channel && info.adminCount > 0) { Text(info.channelAudienceText(), style = MaterialTheme.typography.titleMedium)
Text("${info.adminCount} ${russianPlural(info.adminCount, "администратор", "администратора", "администраторов")}", style = MaterialTheme.typography.bodySmall, color = TelegramInkMuted) 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.canEditProfile()) {
} IkarSectionCard {
if (!info.publicUsername.isNullOrBlank()) { IkarSectionTitle("Профиль", modifier = Modifier.padding(bottom = 8.dp))
Text("@${info.publicUsername}", color = TelegramBlue) IkarSearchCard(
} value = state.profileTitle,
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { onValueChange = onProfileTitleChanged,
TextButton(onClick = onMembersClick) { Text("Участники") } placeholder = "Название"
TextButton(onClick = onSearchClick) { Text("Поиск") } )
TextButton(onClick = onRefreshClick) { Text("Обновить") } 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 { IkarSectionCard {
IkarSectionTitle("Общие материалы", modifier = Modifier.padding(bottom = 8.dp)) IkarSectionTitle("Общие материалы", modifier = Modifier.padding(bottom = 8.dp))
InfoStatRow("Медиа", info.mediaCount) InfoStatRow("Медиа", info.mediaCount, onClick = { onMaterialsClick(ChatMaterialFilter.Media) })
InfoStatRow("Файлы", info.fileCount) InfoStatRow("Файлы", info.fileCount, onClick = { onMaterialsClick(ChatMaterialFilter.Files) })
InfoStatRow("Ссылки", info.linkCount) InfoStatRow("Ссылки", info.linkCount, onClick = { onMaterialsClick(ChatMaterialFilter.Links) })
InfoStatRow("Голосовые", info.voiceCount) InfoStatRow("Голосовые", info.voiceCount, onClick = { onMaterialsClick(ChatMaterialFilter.Voice) })
InfoStatRow("Закреплённые", info.pinnedCount) InfoStatRow("Закреплённые", info.pinnedCount, onClick = { onMaterialsClick(ChatMaterialFilter.Pinned) })
} }
IkarSectionCard { if (info.chat.type == ChatType.Channel && info.canManageChannel) {
IkarSectionTitle("Пригласительные ссылки", modifier = Modifier.padding(bottom = 8.dp)) 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( IkarSearchCard(
value = state.inviteTitle, value = state.inviteTitle,
onValueChange = onInviteTitleChanged, onValueChange = onInviteTitleChanged,
placeholder = "Название ссылки" 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( Row(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp),
@@ -142,6 +280,7 @@ fun ChatInfoScreen(
IkarRowDivider() IkarRowDivider()
} }
} }
}
} }
IkarSectionCard { IkarSectionCard {
@@ -167,9 +306,101 @@ fun ChatInfoScreen(
} }
@Composable @Composable
private fun InfoStatRow(label: String, value: Int) { private fun DiscussionGroupRow(
group: ChatSummaryDto,
isLinked: Boolean,
isSaving: Boolean,
onLinkClick: () -> Unit
) {
Row( 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(), 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, horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
@@ -204,6 +435,13 @@ private fun InviteLinkRow(
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = if (link.isRevoked) TelegramDanger else TelegramInkMuted color = if (link.isRevoked) TelegramDanger else TelegramInkMuted
) )
inviteLinkLimitsText(link)?.let { limits ->
Text(
text = limits,
style = MaterialTheme.typography.bodySmall,
color = TelegramInkMuted
)
}
if (!link.isRevoked) { if (!link.isRevoked) {
TextButton(onClick = onRevokeClick) { Text("Отозвать") } TextButton(onClick = onRevokeClick) { Text("Отозвать") }
} }
@@ -233,7 +471,32 @@ private fun ChatType.label(): String = when (this) {
ChatType.Channel -> "Канал" 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) { if (chat.type != ChatType.Channel) {
return "${chat.participants.size} участников" return "${chat.participants.size} участников"
} }
@@ -3,17 +3,32 @@ package com.seven.ikar.kotlin.feature.chatinfo
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.seven.ikar.kotlin.app.AppContainer 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.ChatInfoDto
import com.seven.ikar.kotlin.core.model.ChatInviteLinkDto 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.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.time.OffsetDateTime
import java.time.ZoneOffset
import java.time.format.DateTimeFormatter
data class ChatInfoUiState( data class ChatInfoUiState(
val info: ChatInfoDto? = null, val info: ChatInfoDto? = null,
val discussionGroups: List<ChatSummaryDto> = emptyList(),
val inviteTitle: String = "", val inviteTitle: String = "",
val inviteCreatesJoinRequest: Boolean = false, 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 topicTitle: String = "",
val isLoading: Boolean = false, val isLoading: Boolean = false,
val isSaving: Boolean = false, val isSaving: Boolean = false,
@@ -26,6 +41,7 @@ class ChatInfoViewModel(
) : ViewModel() { ) : ViewModel() {
private val _uiState = MutableStateFlow(ChatInfoUiState()) private val _uiState = MutableStateFlow(ChatInfoUiState())
val uiState: StateFlow<ChatInfoUiState> = _uiState.asStateFlow() val uiState: StateFlow<ChatInfoUiState> = _uiState.asStateFlow()
private var profileAutosaveJob: Job? = null
init { init {
refresh() refresh()
@@ -36,10 +52,25 @@ class ChatInfoViewModel(
_uiState.value = _uiState.value.copy(isLoading = true, errorMessage = null) _uiState.value = _uiState.value.copy(isLoading = true, errorMessage = null)
runCatching { runCatching {
container.executeAuthorized { session -> 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 -> }.onSuccess { (info, discussionGroups) ->
_uiState.value = _uiState.value.copy(info = info, isLoading = false) _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 -> }.onFailure { error ->
_uiState.value = _uiState.value.copy(isLoading = false, errorMessage = error.message) _uiState.value = _uiState.value.copy(isLoading = false, errorMessage = error.message)
} }
@@ -54,13 +85,111 @@ class ChatInfoViewModel(
_uiState.value = _uiState.value.copy(inviteCreatesJoinRequest = value) _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) { fun updateTopicTitle(value: String) {
_uiState.value = _uiState.value.copy(topicTitle = value.take(80)) _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() { fun createInviteLink() {
val state = _uiState.value val state = _uiState.value
if (state.isSaving) return if (state.isSaving || state.info?.canInviteUsers != true) return
viewModelScope.launch { viewModelScope.launch {
_uiState.value = _uiState.value.copy(isSaving = true, errorMessage = null) _uiState.value = _uiState.value.copy(isSaving = true, errorMessage = null)
runCatching { runCatching {
@@ -69,13 +198,23 @@ class ChatInfoViewModel(
accessToken = session.accessToken, accessToken = session.accessToken,
chatId = chatId, chatId = chatId,
title = state.inviteTitle, 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 { }.onSuccess {
_uiState.value = _uiState.value.copy( _uiState.value = _uiState.value.copy(
inviteTitle = "", inviteTitle = "",
inviteCreatesJoinRequest = false, inviteCreatesJoinRequest = false,
inviteExpiresInHours = "",
inviteMaxUses = "",
inviteMemberLimit = "",
isSaving = false isSaving = false
) )
refresh() refresh()
@@ -86,7 +225,7 @@ class ChatInfoViewModel(
} }
fun revokeInviteLink(link: ChatInviteLinkDto) { fun revokeInviteLink(link: ChatInviteLinkDto) {
if (_uiState.value.isSaving) return if (_uiState.value.isSaving || _uiState.value.info?.canInviteUsers != true) return
viewModelScope.launch { viewModelScope.launch {
_uiState.value = _uiState.value.copy(isSaving = true, errorMessage = null) _uiState.value = _uiState.value.copy(isSaving = true, errorMessage = null)
runCatching { 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.material.icons.outlined.PushPin
import androidx.compose.material3.AlertDialog import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Checkbox import androidx.compose.material3.Checkbox
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SwipeToDismissBox
import androidx.compose.material3.SwipeToDismissBoxValue
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
import androidx.compose.material3.rememberSwipeToDismissBoxState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue 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.network.ServerConfig
import com.seven.ikar.kotlin.core.settings.ChatListLayoutOption import com.seven.ikar.kotlin.core.settings.ChatListLayoutOption
import com.seven.ikar.kotlin.core.settings.ChatSettingsSnapshot 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.feature.share.PendingExternalShare
import com.seven.ikar.kotlin.ui.AuthenticatedAsyncImage import com.seven.ikar.kotlin.ui.AuthenticatedAsyncImage
import com.seven.ikar.kotlin.ui.AvatarView import com.seven.ikar.kotlin.ui.AvatarView
@@ -99,6 +104,11 @@ fun ChatsScreen(
onPinSelectedChatsClick: () -> Unit, onPinSelectedChatsClick: () -> Unit,
onArchiveSelectedChatsClick: () -> Unit, onArchiveSelectedChatsClick: () -> Unit,
onMuteSelectedChatsClick: () -> Unit, onMuteSelectedChatsClick: () -> Unit,
onTogglePinChat: (ChatSummaryDto) -> Unit,
onToggleArchiveChat: (ChatSummaryDto) -> Unit,
onToggleMuteChat: (ChatSummaryDto) -> Unit,
onMarkChatRead: (ChatSummaryDto) -> Unit,
onSwipeDeleteChat: (ChatSummaryDto) -> Unit,
onDismissDeleteDialog: () -> Unit, onDismissDeleteDialog: () -> Unit,
onDeleteForEveryoneChanged: (Boolean) -> Unit, onDeleteForEveryoneChanged: (Boolean) -> Unit,
onConfirmDeleteSelected: () -> Unit, onConfirmDeleteSelected: () -> Unit,
@@ -112,6 +122,7 @@ fun ChatsScreen(
onSettingsClick: () -> Unit, onSettingsClick: () -> Unit,
onNotificationsClick: () -> Unit, onNotificationsClick: () -> Unit,
onGlobalSearchClick: () -> Unit, onGlobalSearchClick: () -> Unit,
onFilterChanged: (ChatListFilter) -> Unit,
onQueryChanged: (String) -> Unit onQueryChanged: (String) -> Unit
) { ) {
val isRealtimeConnected = !state.isLoading || state.chats.isNotEmpty() val isRealtimeConnected = !state.isLoading || state.chats.isNotEmpty()
@@ -229,6 +240,13 @@ fun ChatsScreen(
placeholder = "Поиск чатов" placeholder = "Поиск чатов"
) )
if (!state.isSelectionMode && !state.isShareMode) {
ChatFilterTabs(
selectedFilter = state.filter,
onFilterChanged = onFilterChanged
)
}
TextButton(onClick = onGlobalSearchClick) { TextButton(onClick = onGlobalSearchClick) {
Text("Глобальный поиск: сообщения, медиа, файлы и ссылки") Text("Глобальный поиск: сообщения, медиа, файлы и ссылки")
} }
@@ -275,20 +293,38 @@ fun ChatsScreen(
} }
} else { } else {
items(state.chats, key = { it.chat.id }) { chatItem -> items(state.chats, key = { it.chat.id }) { chatItem ->
ChatListRow( val rowClick = {
chatItem = chatItem, if (state.isSelectionMode) {
currentUserId = state.session?.user?.id, onChatSelectionClick(chatItem.chat)
accessToken = state.session?.accessToken, } else {
previewMaxLines = previewMaxLines, onChatClick(chatItem.chat)
onClick = { }
if (state.isSelectionMode) { }
onChatSelectionClick(chatItem.chat) if (state.isSelectionMode || state.isShareMode) {
} else { ChatListRow(
onChatClick(chatItem.chat) chatItem = chatItem,
} currentUserId = state.session?.user?.id,
}, accessToken = state.session?.accessToken,
onLongClick = { onChatLongPress(chatItem.chat) } 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)) } item { Box(modifier = Modifier.padding(bottom = 132.dp)) }
@@ -323,6 +359,7 @@ fun ChatsScreen(
selectedChatsCount = state.selectedChatsCount, selectedChatsCount = state.selectedChatsCount,
showDeleteForEveryoneOption = state.showDeleteForEveryoneOption, showDeleteForEveryoneOption = state.showDeleteForEveryoneOption,
deleteForEveryone = state.deleteForEveryone, deleteForEveryone = state.deleteForEveryone,
deleteForEveryoneLabel = state.deleteForEveryoneLabel,
isDeletingSelected = state.isDeletingSelected, isDeletingSelected = state.isDeletingSelected,
onDismiss = onDismissDeleteDialog, onDismiss = onDismissDeleteDialog,
onDeleteForEveryoneChanged = onDeleteForEveryoneChanged, 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 @Composable
private fun ChatsBrandHeader( private fun ChatsBrandHeader(
isRealtimeConnected: Boolean, isRealtimeConnected: Boolean,
@@ -370,7 +441,7 @@ private fun StoriesClusterButton(
currentUserId: String?, currentUserId: String?,
onClick: () -> Unit onClick: () -> Unit
) { ) {
val previewStories = stories.take(3) val previewStories = remember(stories) { stories.toAuthorGroups().take(3) }
val placeholderColors = listOf( val placeholderColors = listOf(
TelegramBlue, TelegramBlue,
Color(0xFF7C4DFF), Color(0xFF7C4DFF),
@@ -385,7 +456,8 @@ private fun StoriesClusterButton(
contentAlignment = Alignment.CenterStart contentAlignment = Alignment.CenterStart
) { ) {
repeat(3) { index -> repeat(3) { index ->
val story = previewStories.getOrNull(index) val group = previewStories.getOrNull(index)
val story = group?.latestStory
Box( Box(
modifier = Modifier modifier = Modifier
.padding(start = (index * 13).dp) .padding(start = (index * 13).dp)
@@ -432,6 +504,7 @@ private fun StoriesTray(
onComposeStoryClick: () -> Unit, onComposeStoryClick: () -> Unit,
onStoryClick: (StoryDto) -> Unit onStoryClick: (StoryDto) -> Unit
) { ) {
val storyGroups = remember(stories) { stories.toAuthorGroups() }
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .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( StoryBubble(
title = if (story.author.id == currentUserId) "Моя" else story.author.displayName, title = if (story.author.id == currentUserId) "Моя" else story.author.displayName,
avatarUrl = ServerConfig.normalizeMediaUrl(story.author.avatarPath), avatarUrl = ServerConfig.normalizeMediaUrl(story.author.avatarPath),
@@ -462,8 +536,9 @@ private fun StoriesTray(
story.attachments.firstOrNull { it.contentType.startsWith("image/") }?.downloadPath story.attachments.firstOrNull { it.contentType.startsWith("image/") }?.downloadPath
), ),
accessToken = accessToken, accessToken = accessToken,
viewed = story.viewedByMe || story.author.id == currentUserId, viewed = group.isViewedByMe || story.author.id == currentUserId,
onClick = { onStoryClick(story) } badgeText = group.stories.size.takeIf { it > 1 }?.toString(),
onClick = { onStoryClick(group.openStory) }
) )
} }
@@ -481,6 +556,7 @@ private fun StoryBubble(
previewUrl: String?, previewUrl: String?,
accessToken: String?, accessToken: String?,
viewed: Boolean, viewed: Boolean,
badgeText: String? = null,
onClick: () -> Unit, onClick: () -> Unit,
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
@@ -520,6 +596,24 @@ private fun StoryBubble(
size = 56.dp 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( Text(
title, 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 @Composable
private fun ShareSelectionHeader( private fun ShareSelectionHeader(
selectedChatsCount: Int, selectedChatsCount: Int,
@@ -718,6 +836,7 @@ private fun DeleteChatsDialog(
selectedChatsCount: Int, selectedChatsCount: Int,
showDeleteForEveryoneOption: Boolean, showDeleteForEveryoneOption: Boolean,
deleteForEveryone: Boolean, deleteForEveryone: Boolean,
deleteForEveryoneLabel: String,
isDeletingSelected: Boolean, isDeletingSelected: Boolean,
onDismiss: () -> Unit, onDismiss: () -> Unit,
onDeleteForEveryoneChanged: (Boolean) -> Unit, onDeleteForEveryoneChanged: (Boolean) -> Unit,
@@ -749,7 +868,7 @@ private fun DeleteChatsDialog(
onCheckedChange = onDeleteForEveryoneChanged onCheckedChange = onDeleteForEveryoneChanged
) )
Text( Text(
text = "Также удалить чат для собеседника", text = deleteForEveryoneLabel,
style = MaterialTheme.typography.bodyMedium 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 @Composable
@OptIn(ExperimentalFoundationApi::class) @OptIn(ExperimentalFoundationApi::class)
private fun ChatListRow( private fun ChatListRow(
@@ -795,7 +1002,7 @@ private fun ChatListRow(
val rowBackground = if (chatItem.isSelected) { val rowBackground = if (chatItem.isSelected) {
TelegramBlue.copy(alpha = 0.12f) TelegramBlue.copy(alpha = 0.12f)
} else { } else {
Color.Transparent MaterialTheme.colorScheme.background
} }
Column { Column {
@@ -848,6 +1055,7 @@ private fun ChatListRow(
) { ) {
ChatLastMessagePreview( ChatLastMessagePreview(
chat = chat, chat = chat,
draftText = chatItem.draftText,
accessToken = accessToken, accessToken = accessToken,
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
maxLines = previewMaxLines maxLines = previewMaxLines
@@ -887,12 +1095,37 @@ private fun ChatListRow(
@Composable @Composable
private fun ChatLastMessagePreview( private fun ChatLastMessagePreview(
chat: ChatSummaryDto, chat: ChatSummaryDto,
draftText: String,
accessToken: String?, accessToken: String?,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
maxLines: Int maxLines: Int
) { ) {
val attachment = chat.lastMessageAttachment val attachment = chat.lastMessageAttachment
when { 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 -> { attachment?.isChatPreviewImage() == true -> {
Row( Row(
modifier = modifier, modifier = modifier,
@@ -980,8 +1213,7 @@ private fun AttachmentDto.isChatPreviewImage(): Boolean =
fileName.endsWith(".gif", ignoreCase = true) fileName.endsWith(".gif", ignoreCase = true)
private fun AttachmentDto.isChatPreviewVoiceNote(): Boolean = private fun AttachmentDto.isChatPreviewVoiceNote(): Boolean =
kind == AttachmentKind.VoiceNote || kind == AttachmentKind.VoiceNote
contentType.startsWith("audio/", ignoreCase = true)
private fun selectionCountLabel(selectedChatsCount: Int): String = when { private fun selectionCountLabel(selectedChatsCount: Int): String = when {
selectedChatsCount % 10 == 1 && selectedChatsCount % 100 != 11 -> "Выбран $selectedChatsCount чат" selectedChatsCount % 10 == 1 && selectedChatsCount % 100 != 11 -> "Выбран $selectedChatsCount чат"
@@ -31,6 +31,7 @@ data class ChatListItemUiState(
val chat: ChatSummaryDto, val chat: ChatSummaryDto,
val resolvedTitle: String, val resolvedTitle: String,
val lastActivityLabel: String, val lastActivityLabel: String,
val draftText: String,
val isSelected: Boolean val isSelected: Boolean
) )
@@ -48,6 +49,8 @@ enum class ChatListFilter {
Archive Archive
} }
private const val DefaultDeleteForEveryoneLabel = "Также удалить чат для собеседника"
data class ChatsUiState( data class ChatsUiState(
val session: AuthSessionDto? = null, val session: AuthSessionDto? = null,
val query: String = "", val query: String = "",
@@ -64,7 +67,9 @@ data class ChatsUiState(
val isDeleteDialogVisible: Boolean = false, val isDeleteDialogVisible: Boolean = false,
val deleteForEveryone: Boolean = false, val deleteForEveryone: Boolean = false,
val showDeleteForEveryoneOption: 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 val isShareMode: Boolean
get() = pendingShare != null get() = pendingShare != null
@@ -81,11 +86,13 @@ class ChatsViewModel(private val container: AppContainer) : ViewModel() {
private val _allChats = mutableListOf<ChatSummaryDto>() private val _allChats = mutableListOf<ChatSummaryDto>()
private var contactNamesByNormalizedPhone: Map<String, String> = emptyMap() private var contactNamesByNormalizedPhone: Map<String, String> = emptyMap()
private var refreshJob: Job? = null private var refreshJob: Job? = null
private var hadActiveStoryTransfers = false
val uiState: StateFlow<ChatsUiState> = _uiState.asStateFlow() val uiState: StateFlow<ChatsUiState> = _uiState.asStateFlow()
init { init {
attachIncomingShare() attachIncomingShare()
attachRealtime() attachRealtime()
attachStoryTransferCompletion()
loadCachedChats() loadCachedChats()
refresh() refresh()
refreshLatestRelease() refreshLatestRelease()
@@ -128,7 +135,9 @@ class ChatsViewModel(private val container: AppContainer) : ViewModel() {
isDeleteDialogVisible = false, isDeleteDialogVisible = false,
deleteForEveryone = false, deleteForEveryone = false,
showDeleteForEveryoneOption = false, showDeleteForEveryoneOption = false,
isDeletingSelected = false deleteForEveryoneLabel = DefaultDeleteForEveryoneLabel,
isDeletingSelected = false,
selectionStartedFromSwipeDelete = false
) )
applyFilter() applyFilter()
} }
@@ -143,15 +152,23 @@ class ChatsViewModel(private val container: AppContainer) : ViewModel() {
_uiState.value = state.copy( _uiState.value = state.copy(
isDeleteDialogVisible = true, isDeleteDialogVisible = true,
deleteForEveryone = state.deleteForEveryone && showDeleteForEveryoneOption, deleteForEveryone = state.deleteForEveryone && showDeleteForEveryoneOption,
showDeleteForEveryoneOption = showDeleteForEveryoneOption showDeleteForEveryoneOption = showDeleteForEveryoneOption,
deleteForEveryoneLabel = deleteForEveryoneLabel(state.selectedChatIds),
selectionStartedFromSwipeDelete = false
) )
} }
fun dismissDeleteDialog() { 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, isDeleteDialogVisible = false,
deleteForEveryone = false deleteForEveryone = false,
showDeleteForEveryoneOption = false,
deleteForEveryoneLabel = DefaultDeleteForEveryoneLabel,
selectionStartedFromSwipeDelete = false
) )
applyFilter()
} }
fun updateDeleteForEveryone(value: Boolean) { 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) { private fun updateSelectedChatState(request: ChatStateRequest) {
val selectedChatIds = _uiState.value.selectedChatIds.toList() val selectedChatIds = _uiState.value.selectedChatIds.toList()
if (selectedChatIds.isEmpty()) { if (selectedChatIds.isEmpty()) {
@@ -403,15 +496,21 @@ class ChatsViewModel(private val container: AppContainer) : ViewModel() {
} else { } else {
chatItems.filter { item -> chatItems.filter { item ->
item.resolvedTitle.contains(query, ignoreCase = true) || 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.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( _uiState.value = currentState.copy(
chats = filtered, chats = filtered,
selectedChatIds = selectedChatIds, selectedChatIds = selectedChatIds,
showDeleteForEveryoneOption = showDeleteForEveryoneOption, showDeleteForEveryoneOption = showDeleteForEveryoneOption,
deleteForEveryoneLabel = if (showDeleteForEveryoneOption) {
deleteForEveryoneLabel(selectedChatIds)
} else {
DefaultDeleteForEveryoneLabel
},
deleteForEveryone = deleteForEveryone, deleteForEveryone = deleteForEveryone,
isDeleteDialogVisible = !currentState.isShareMode && isDeleteDialogVisible = !currentState.isShareMode &&
currentState.isDeleteDialogVisible && currentState.isDeleteDialogVisible &&
@@ -428,6 +527,7 @@ class ChatsViewModel(private val container: AppContainer) : ViewModel() {
chat = chat, chat = chat,
resolvedTitle = resolveChatTitle(chat, currentUserId), resolvedTitle = resolveChatTitle(chat, currentUserId),
lastActivityLabel = formatLastActivity(chat.lastActivityAt), lastActivityLabel = formatLastActivity(chat.lastActivityAt),
draftText = container.chatDraftStore.read(chat.id).trim(),
isSelected = selectedChatIds.contains(chat.id) 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 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() { private fun attachRealtime() {
@@ -485,11 +594,30 @@ class ChatsViewModel(private val container: AppContainer) : ViewModel() {
viewModelScope.launch { viewModelScope.launch {
container.realtimeService.messagesUpdated.collect { scheduleRefresh() } container.realtimeService.messagesUpdated.collect { scheduleRefresh() }
} }
viewModelScope.launch {
container.realtimeService.storiesChanged.collect { scheduleRefresh() }
}
viewModelScope.launch { viewModelScope.launch {
container.realtimeService.appUpdateAvailable.collect { release -> container.realtimeService.appUpdateAvailable.collect { release ->
_uiState.value = _uiState.value.copy(latestRelease = 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() { private fun scheduleRefresh() {
@@ -510,7 +638,9 @@ class ChatsViewModel(private val container: AppContainer) : ViewModel() {
isDeleteDialogVisible = if (pendingShare == null) currentState.isDeleteDialogVisible else false, isDeleteDialogVisible = if (pendingShare == null) currentState.isDeleteDialogVisible else false,
deleteForEveryone = if (pendingShare == null) currentState.deleteForEveryone else false, deleteForEveryone = if (pendingShare == null) currentState.deleteForEveryone else false,
showDeleteForEveryoneOption = if (pendingShare == null) currentState.showDeleteForEveryoneOption 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, isDeletingSelected = if (pendingShare == null) currentState.isDeletingSelected else false,
selectionStartedFromSwipeDelete = if (pendingShare == null) currentState.selectionStartedFromSwipeDelete else false,
isSendingShare = currentState.isSendingShare && pendingShare != null isSendingShare = currentState.isSendingShare && pendingShare != null
) )
applyFilter() applyFilter()
@@ -22,7 +22,8 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp 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.IkarRowDivider
import com.seven.ikar.kotlin.ui.IkarScreenSurface import com.seven.ikar.kotlin.ui.IkarScreenSurface
import com.seven.ikar.kotlin.ui.IkarSectionCard import com.seven.ikar.kotlin.ui.IkarSectionCard
@@ -36,8 +37,7 @@ import com.seven.ikar.kotlin.ui.theme.TelegramInkMuted
fun DevicesScreen( fun DevicesScreen(
state: DevicesUiState, state: DevicesUiState,
onBackClick: () -> Unit, onBackClick: () -> Unit,
onRefreshClick: () -> Unit, onRefreshClick: () -> Unit
onRevokeClick: (UserSessionDto) -> Unit
) { ) {
IkarScreenSurface(modifier = Modifier.fillMaxSize()) { IkarScreenSurface(modifier = Modifier.fillMaxSize()) {
Column( Column(
@@ -52,14 +52,25 @@ fun DevicesScreen(
state.errorMessage?.let { state.errorMessage?.let {
Text(it, color = TelegramDanger, style = MaterialTheme.typography.bodySmall) Text(it, color = TelegramDanger, style = MaterialTheme.typography.bodySmall)
} }
if (state.isLoading && state.sessions.isEmpty()) { if (state.isLoading && state.devices.isEmpty()) {
CircularProgressIndicator(color = TelegramBlue) CircularProgressIndicator(color = TelegramBlue)
} }
IkarSectionCard { IkarSectionCard {
IkarSectionTitle("Активные сеансы", modifier = Modifier.padding(bottom = 8.dp)) IkarSectionTitle("Устройства с Икаром", modifier = Modifier.padding(bottom = 8.dp))
state.sessions.forEachIndexed { index, session -> if (state.devices.isEmpty() && !state.isLoading) {
DeviceRow(session = session, enabled = !state.isSaving, onRevokeClick = { onRevokeClick(session) }) Text(
if (index != state.sessions.lastIndex) { 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() IkarRowDivider()
} }
} }
@@ -71,9 +82,8 @@ fun DevicesScreen(
@Composable @Composable
private fun DeviceRow( private fun DeviceRow(
session: UserSessionDto, device: PushDeviceDto,
enabled: Boolean, isCurrent: Boolean
onRevokeClick: () -> Unit
) { ) {
Row( Row(
modifier = Modifier modifier = Modifier
@@ -83,17 +93,34 @@ private fun DeviceRow(
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
Column(modifier = Modifier.weight(1f)) { Column(modifier = Modifier.weight(1f)) {
Text(session.deviceName, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
Text( 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, style = MaterialTheme.typography.bodySmall,
color = TelegramInkMuted color = TelegramInkMuted
) )
} }
if (session.isCurrent) { if (isCurrent) {
Text("Текущий", color = TelegramBlue) Text("Это устройство", color = TelegramBlue)
} else {
TextButton(enabled = enabled, onClick = onRevokeClick) { Text("Завершить") }
} }
} }
} }
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.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.seven.ikar.kotlin.app.AppContainer 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.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
data class DevicesUiState( data class DevicesUiState(
val sessions: List<UserSessionDto> = emptyList(), val devices: List<PushDeviceDto> = emptyList(),
val currentInstallationId: String = "",
val isLoading: Boolean = false, val isLoading: Boolean = false,
val isSaving: Boolean = false,
val errorMessage: String? = null val errorMessage: String? = null
) )
@@ -29,30 +29,17 @@ class DevicesViewModel(private val container: AppContainer) : ViewModel() {
_uiState.value = _uiState.value.copy(isLoading = true, errorMessage = null) _uiState.value = _uiState.value.copy(isLoading = true, errorMessage = null)
runCatching { runCatching {
container.executeAuthorized { session -> container.executeAuthorized { session ->
container.apiClient.getMySessions(session.accessToken) container.apiClient.getPushDevices(session.accessToken)
} }
}.onSuccess { sessions -> }.onSuccess { devices ->
_uiState.value = _uiState.value.copy(sessions = sessions, isLoading = false) _uiState.value = _uiState.value.copy(
devices = devices,
currentInstallationId = container.pushSettings.getInstallationId(),
isLoading = false
)
}.onFailure { error -> }.onFailure { error ->
_uiState.value = _uiState.value.copy(isLoading = false, errorMessage = error.message) _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.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer 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.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height 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.padding
import androidx.compose.foundation.layout.safeDrawing
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.outlined.ArrowBack import androidx.compose.material.icons.automirrored.outlined.ArrowBack
import androidx.compose.material.icons.outlined.Group 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.compose.ui.unit.dp
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import com.seven.ikar.kotlin.core.contacts.DiscoverUserItem 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.AvatarView
import com.seven.ikar.kotlin.ui.IkarCenteredHeader import com.seven.ikar.kotlin.ui.IkarCenteredHeader
import com.seven.ikar.kotlin.ui.IkarHeaderIconBubble import com.seven.ikar.kotlin.ui.IkarHeaderIconBubble
@@ -65,9 +74,13 @@ fun DiscoverScreen(
onQueryChanged: (String) -> Unit, onQueryChanged: (String) -> Unit,
onRefreshClick: () -> Unit, onRefreshClick: () -> Unit,
onUserClick: (DiscoverUserItem) -> Unit, onUserClick: (DiscoverUserItem) -> Unit,
onChannelClick: (ChatSummaryDto) -> Unit,
onBeginGroupSelection: () -> Unit, onBeginGroupSelection: () -> Unit,
onBeginChannelSelection: () -> Unit, onBeginChannelSelection: () -> Unit,
onCancelSelection: () -> Unit, onCancelSelection: () -> Unit,
onGroupBackClick: () -> Unit,
onGroupTitleChanged: (String) -> Unit,
onContinueGroupWizard: () -> Unit,
onChannelBackClick: () -> Unit, onChannelBackClick: () -> Unit,
onChannelTitleChanged: (String) -> Unit, onChannelTitleChanged: (String) -> Unit,
onChannelDescriptionChanged: (String) -> Unit, onChannelDescriptionChanged: (String) -> Unit,
@@ -85,8 +98,11 @@ fun DiscoverScreen(
} }
val hasContactsPermission = val hasContactsPermission =
ContextCompat.checkSelfPermission(context, Manifest.permission.READ_CONTACTS) == PackageManager.PERMISSION_GRANTED ContextCompat.checkSelfPermission(context, Manifest.permission.READ_CONTACTS) == PackageManager.PERMISSION_GRANTED
val isGroupWizard = state.groupWizardStep != null
val isChannelWizard = state.channelWizardStep != 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) { LaunchedEffect(hasContactsPermission) {
if (hasContactsPermission) { if (hasContactsPermission) {
@@ -95,20 +111,40 @@ fun DiscoverScreen(
} }
IkarScreenSurface(modifier = Modifier.fillMaxSize()) { 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( 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) verticalArrangement = Arrangement.spacedBy(12.dp)
) { ) {
IkarCenteredHeader( IkarCenteredHeader(
title = when { title = when {
isGroupWizard -> "Новая группа"
isChannelWizard -> "Новый канал" isChannelWizard -> "Новый канал"
state.selectionMode == DiscoverSelectionMode.Group -> "Новая группа" state.selectionMode == DiscoverSelectionMode.Group -> "Новая группа"
else -> "Новое сообщение" else -> "Новое сообщение"
}, },
left = { left = {
IkarHeaderIconBubble( 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) Icon(Icons.AutoMirrored.Outlined.ArrowBack, contentDescription = "Назад", tint = MaterialTheme.colorScheme.onSurface)
} }
@@ -134,8 +170,25 @@ fun DiscoverScreen(
) )
} }
when (state.channelWizardStep) { when {
ChannelWizardStep.Details -> ChannelDetailsStep( 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, state = state,
onTitleChanged = onChannelTitleChanged, onTitleChanged = onChannelTitleChanged,
onDescriptionChanged = onChannelDescriptionChanged, onDescriptionChanged = onChannelDescriptionChanged,
@@ -143,7 +196,7 @@ fun DiscoverScreen(
onNextClick = onContinueChannelWizard onNextClick = onContinueChannelWizard
) )
ChannelWizardStep.Type -> ChannelTypeStep( state.channelWizardStep == ChannelWizardStep.Type -> ChannelTypeStep(
state = state, state = state,
onVisibilityChanged = onChannelVisibilityChanged, onVisibilityChanged = onChannelVisibilityChanged,
onPublicUsernameChanged = onChannelPublicUsernameChanged, onPublicUsernameChanged = onChannelPublicUsernameChanged,
@@ -152,7 +205,7 @@ fun DiscoverScreen(
onNextClick = onContinueChannelWizard onNextClick = onContinueChannelWizard
) )
ChannelWizardStep.Subscribers -> SelectionBanner( state.channelWizardStep == ChannelWizardStep.Subscribers -> SelectionBanner(
title = "Добавить подписчиков", title = "Добавить подписчиков",
subtitle = "Можно создать канал сейчас и пригласить людей позже.", subtitle = "Можно создать канал сейчас и пригласить людей позже.",
selectedCount = state.selectedUserIds.size, selectedCount = state.selectedUserIds.size,
@@ -162,7 +215,7 @@ fun DiscoverScreen(
enabled = !state.isLoading enabled = !state.isLoading
) )
null -> { else -> {
if (state.selectionMode == null) { if (state.selectionMode == null) {
IkarSectionCard(padding = PaddingValues(0.dp)) { IkarSectionCard(padding = PaddingValues(0.dp)) {
DiscoverActionRow( DiscoverActionRow(
@@ -193,6 +246,15 @@ fun DiscoverScreen(
} }
} }
if (showPublicChannels) {
PublicChannelsSection(
channels = state.publicChannels,
query = state.query,
isLoading = state.isLoading,
onChannelClick = onChannelClick
)
}
state.errorMessage?.let { state.errorMessage?.let {
Text(it, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodyMedium) 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 @Composable
private fun ChannelDetailsStep( private fun ChannelDetailsStep(
state: DiscoverUiState, state: DiscoverUiState,
@@ -401,13 +603,13 @@ private fun StepActions(
} }
@Composable @Composable
private fun WizardStepIndicator(currentStep: Int) { private fun WizardStepIndicator(currentStep: Int, totalSteps: Int = 3) {
Row( Row(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
(1..3).forEach { step -> (1..totalSteps).forEach { step ->
Surface( Surface(
modifier = Modifier modifier = Modifier
.height(4.dp) .height(4.dp)
@@ -417,7 +619,7 @@ private fun WizardStepIndicator(currentStep: Int) {
) {} ) {}
} }
Text( Text(
"$currentStep/3", "$currentStep/$totalSteps",
style = MaterialTheme.typography.labelMedium, style = MaterialTheme.typography.labelMedium,
color = TelegramInkMuted, color = TelegramInkMuted,
fontWeight = FontWeight.SemiBold fontWeight = FontWeight.SemiBold
@@ -4,6 +4,7 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.seven.ikar.kotlin.app.AppContainer import com.seven.ikar.kotlin.app.AppContainer
import com.seven.ikar.kotlin.core.contacts.DiscoverUserItem import com.seven.ikar.kotlin.core.contacts.DiscoverUserItem
import com.seven.ikar.kotlin.core.model.ChatSummaryDto
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
@@ -23,6 +24,11 @@ enum class ChannelWizardStep {
Subscribers Subscribers
} }
enum class GroupWizardStep {
Details,
Members
}
enum class ChannelVisibility { enum class ChannelVisibility {
Public, Public,
Private Private
@@ -31,11 +37,14 @@ enum class ChannelVisibility {
data class DiscoverUiState( data class DiscoverUiState(
val query: String = "", val query: String = "",
val items: List<DiscoverUserItem> = emptyList(), val items: List<DiscoverUserItem> = emptyList(),
val publicChannels: List<ChatSummaryDto> = emptyList(),
val isLoading: Boolean = false, val isLoading: Boolean = false,
val errorMessage: String? = null, val errorMessage: String? = null,
val pendingChatId: String? = null, val pendingChatId: String? = null,
val selectionMode: DiscoverSelectionMode? = null, val selectionMode: DiscoverSelectionMode? = null,
val selectedUserIds: Set<String> = emptySet(), val selectedUserIds: Set<String> = emptySet(),
val groupWizardStep: GroupWizardStep? = null,
val groupTitle: String = "",
val channelWizardStep: ChannelWizardStep? = null, val channelWizardStep: ChannelWizardStep? = null,
val channelTitle: String = "", val channelTitle: String = "",
val channelDescription: String = "", val channelDescription: String = "",
@@ -65,22 +74,44 @@ class DiscoverViewModel(private val container: AppContainer) : ViewModel() {
fun refresh() { fun refresh() {
viewModelScope.launch { 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) _uiState.value = _uiState.value.copy(isLoading = true, errorMessage = null)
runCatching { container.resolvedContactsRepository.loadMergedCandidates(query) } val contactsResult = runCatching { container.resolvedContactsRepository.loadMergedCandidates(query) }
.onSuccess { items -> val channelsResult = if (shouldLoadPublicChannels) {
_uiState.value = _uiState.value.copy(items = items, isLoading = false, errorMessage = null) runCatching {
} container.executeAuthorized { session ->
.onFailure { error -> container.apiClient.getPublicChannels(session.accessToken, query.trim(), limit = 50)
_uiState.value = _uiState.value.copy(isLoading = false, errorMessage = error.message) }
} }
} 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() { fun beginGroupSelection() {
_uiState.value = _uiState.value.copy( _uiState.value = _uiState.value.copy(
selectionMode = DiscoverSelectionMode.Group, selectionMode = null,
selectedUserIds = emptySet(), selectedUserIds = emptySet(),
groupWizardStep = GroupWizardStep.Details,
groupTitle = "",
channelWizardStep = null, channelWizardStep = null,
errorMessage = null errorMessage = null
) )
@@ -90,6 +121,8 @@ class DiscoverViewModel(private val container: AppContainer) : ViewModel() {
_uiState.value = _uiState.value.copy( _uiState.value = _uiState.value.copy(
selectionMode = null, selectionMode = null,
selectedUserIds = emptySet(), selectedUserIds = emptySet(),
groupWizardStep = null,
groupTitle = "",
channelWizardStep = ChannelWizardStep.Details, channelWizardStep = ChannelWizardStep.Details,
channelTitle = "", channelTitle = "",
channelDescription = "", channelDescription = "",
@@ -104,11 +137,26 @@ class DiscoverViewModel(private val container: AppContainer) : ViewModel() {
_uiState.value = _uiState.value.copy( _uiState.value = _uiState.value.copy(
selectionMode = null, selectionMode = null,
selectedUserIds = emptySet(), selectedUserIds = emptySet(),
groupWizardStep = null,
groupTitle = "",
channelWizardStep = null, channelWizardStep = null,
errorMessage = 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() { fun previousChannelWizardStep() {
val state = _uiState.value val state = _uiState.value
when (state.channelWizardStep) { 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) { fun updateChannelTitle(value: String) {
_uiState.value = _uiState.value.copy(channelTitle = value.take(80), errorMessage = null) _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) { fun handleUserTap(user: DiscoverUserItem) {
val selectionMode = _uiState.value.selectionMode val selectionMode = _uiState.value.selectionMode
if (selectionMode == null) { if (selectionMode == null) {
@@ -203,7 +276,7 @@ class DiscoverViewModel(private val container: AppContainer) : ViewModel() {
when (mode) { when (mode) {
DiscoverSelectionMode.Group -> container.apiClient.createGroupChat( DiscoverSelectionMode.Group -> container.apiClient.createGroupChat(
accessToken = session.accessToken, accessToken = session.accessToken,
title = "Новая группа", title = state.groupTitle.trim(),
memberIds = selectedIds memberIds = selectedIds
) )
@@ -224,6 +297,8 @@ class DiscoverViewModel(private val container: AppContainer) : ViewModel() {
isLoading = false, isLoading = false,
selectionMode = null, selectionMode = null,
selectedUserIds = emptySet(), selectedUserIds = emptySet(),
groupWizardStep = null,
groupTitle = "",
channelWizardStep = null, channelWizardStep = null,
errorMessage = 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() { fun consumePendingChatNavigation() {
_uiState.value = _uiState.value.copy(pendingChatId = null) _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.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row 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.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.only
import androidx.compose.foundation.layout.padding 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.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
@@ -37,7 +42,11 @@ fun ForwardScreen(
onUserClick: (DiscoverUserItem) -> Unit onUserClick: (DiscoverUserItem) -> Unit
) { ) {
IkarScreenSurface(modifier = Modifier.fillMaxSize()) { IkarScreenSurface(modifier = Modifier.fillMaxSize()) {
Column(modifier = Modifier.fillMaxSize()) { Column(
modifier = Modifier
.fillMaxSize()
.windowInsetsPadding(WindowInsets.safeDrawing.only(WindowInsetsSides.Vertical))
) {
Column( Column(
modifier = Modifier.padding(start = 18.dp, top = 16.dp, end = 18.dp, bottom = 12.dp), modifier = Modifier.padding(start = 18.dp, top = 16.dp, end = 18.dp, bottom = 12.dp),
verticalArrangement = Arrangement.spacedBy(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 package com.seven.ikar.kotlin.feature.login
import androidx.compose.foundation.background
import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column 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.rememberScrollState
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults 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.font.FontWeight
import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.foundation.text.KeyboardOptions
@Composable @Composable
fun LoginScreen( fun LoginScreen(
state: LoginUiState, state: LoginUiState,
onEmailChanged: (String) -> Unit,
onPhoneNumberChanged: (String) -> Unit, onPhoneNumberChanged: (String) -> Unit,
onDisplayNameChanged: (String) -> Unit, onDisplayNameChanged: (String) -> Unit,
onVerificationCodeChanged: (String) -> Unit, onVerificationCodeChanged: (String) -> Unit,
onRequestCodeClick: () -> Unit, onRequestCodeClick: () -> Unit,
onVerifyCodeClick: () -> Unit, onVerifyCodeClick: () -> Unit,
onChangePhoneNumberClick: () -> Unit onChangeEmailClick: () -> Unit
) { ) {
Box( Box(
modifier = Modifier modifier = Modifier
@@ -116,7 +117,16 @@ fun LoginScreen(
fontWeight = FontWeight.SemiBold 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( OutlinedTextField(
value = state.phoneNumber, value = state.phoneNumber,
onValueChange = onPhoneNumberChanged, onValueChange = onPhoneNumberChanged,
@@ -128,7 +138,7 @@ fun LoginScreen(
Button( Button(
onClick = onRequestCodeClick, onClick = onRequestCodeClick,
enabled = !state.isLoading, enabled = !state.isLoading && state.email.isNotBlank() && state.phoneNumber.isNotBlank(),
contentPadding = PaddingValues(horizontal = 20.dp, vertical = 14.dp) contentPadding = PaddingValues(horizontal = 20.dp, vertical = 14.dp)
) { ) {
if (state.isLoading) { if (state.isLoading) {
@@ -137,7 +147,7 @@ fun LoginScreen(
modifier = Modifier.padding(horizontal = 8.dp) modifier = Modifier.padding(horizontal = 8.dp)
) )
} else { } else {
Text("Получить тестовый код") Text("Получить код")
} }
} }
} }
@@ -154,25 +164,16 @@ fun LoginScreen(
verticalArrangement = Arrangement.spacedBy(10.dp) verticalArrangement = Arrangement.spacedBy(10.dp)
) { ) {
Text( Text(
text = "Подтверждение номера", text = "Подтверждение email",
style = MaterialTheme.typography.titleMedium, style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold fontWeight = FontWeight.SemiBold
) )
Text( Text(
text = "Для тестового режима код показывается прямо в приложении. После подключения SMS-провайдера это сообщение исчезнет.", text = "Код отправлен на указанную электронную почту. Номер телефона будет привязан к аккаунту.",
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
color = Color(0xFF617A8F) color = Color(0xFF617A8F)
) )
if (state.hasTestCodeHint) {
Text(
text = "Тестовый код: ${state.testCodeHint}",
style = MaterialTheme.typography.bodyMedium,
color = Color(0xFF229AF0),
fontWeight = FontWeight.SemiBold
)
}
OutlinedTextField( OutlinedTextField(
value = state.verificationCode, value = state.verificationCode,
onValueChange = onVerificationCodeChanged, onValueChange = onVerificationCodeChanged,
@@ -194,7 +195,9 @@ fun LoginScreen(
Button( Button(
onClick = onVerifyCodeClick, onClick = onVerifyCodeClick,
enabled = !state.isLoading, enabled = !state.isLoading &&
state.verificationCode.isNotBlank() &&
(!state.isNewAccount || state.displayName.isNotBlank()),
contentPadding = PaddingValues(horizontal = 20.dp, vertical = 14.dp) contentPadding = PaddingValues(horizontal = 20.dp, vertical = 14.dp)
) { ) {
if (state.isLoading) { if (state.isLoading) {
@@ -208,7 +211,7 @@ fun LoginScreen(
} }
Button( Button(
onClick = onChangePhoneNumberClick, onClick = onChangeEmailClick,
enabled = !state.isLoading, enabled = !state.isLoading,
colors = ButtonDefaults.buttonColors( colors = ButtonDefaults.buttonColors(
containerColor = Color.Transparent, containerColor = Color.Transparent,
@@ -216,7 +219,7 @@ fun LoginScreen(
), ),
contentPadding = PaddingValues(horizontal = 0.dp, vertical = 12.dp) contentPadding = PaddingValues(horizontal = 0.dp, vertical = 12.dp)
) { ) {
Text("Изменить номер") Text("Изменить email или телефон")
} }
} }
} }
@@ -10,11 +10,11 @@ import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
data class LoginUiState( data class LoginUiState(
val email: String = "",
val phoneNumber: String = "", val phoneNumber: String = "",
val displayName: String = "", val displayName: String = "",
val verificationCode: String = "", val verificationCode: String = "",
val challengeId: String = "", val challengeId: String = "",
val testCodeHint: String = "",
val isAwaitingCode: Boolean = false, val isAwaitingCode: Boolean = false,
val isNewAccount: Boolean = false, val isNewAccount: Boolean = false,
val isLoading: Boolean = false, val isLoading: Boolean = false,
@@ -22,16 +22,16 @@ data class LoginUiState(
val isAuthenticated: Boolean = false val isAuthenticated: Boolean = false
) { ) {
val pageTitle: String val pageTitle: String
get() = if (isAwaitingCode) "Подтвердите номер" else "Вход в ИКАР" get() = if (isAwaitingCode) "Подтвердите email" else "Вход в ИКАР"
val subtitle: String val subtitle: String
get() = if (isAwaitingCode) { get() = if (isAwaitingCode) {
"Введите тестовый код. Когда подключим SMS-провайдера, здесь будет код из сообщения." "Введите код, который отправлен на указанную электронную почту."
} else { } else {
"Введите номер телефона, чтобы получить тестовый код входа." "Введите электронную почту и номер телефона. Код придёт только на email."
} }
val showPhoneStep: Boolean val showEmailStep: Boolean
get() = !isAwaitingCode get() = !isAwaitingCode
val showCodeStep: Boolean val showCodeStep: Boolean
@@ -39,15 +39,16 @@ data class LoginUiState(
val showDisplayName: Boolean val showDisplayName: Boolean
get() = isAwaitingCode && isNewAccount get() = isAwaitingCode && isNewAccount
val hasTestCodeHint: Boolean
get() = testCodeHint.isNotBlank()
} }
class LoginViewModel(private val container: AppContainer) : ViewModel() { class LoginViewModel(private val container: AppContainer) : ViewModel() {
private val _uiState = MutableStateFlow(LoginUiState()) private val _uiState = MutableStateFlow(LoginUiState())
val uiState: StateFlow<LoginUiState> = _uiState.asStateFlow() val uiState: StateFlow<LoginUiState> = _uiState.asStateFlow()
fun updateEmail(value: String) {
_uiState.value = _uiState.value.copy(email = value, errorMessage = null)
}
fun updatePhoneNumber(value: String) { fun updatePhoneNumber(value: String) {
_uiState.value = _uiState.value.copy(phoneNumber = value, errorMessage = null) _uiState.value = _uiState.value.copy(phoneNumber = value, errorMessage = null)
} }
@@ -62,19 +63,26 @@ class LoginViewModel(private val container: AppContainer) : ViewModel() {
fun requestCode() { fun requestCode() {
val state = _uiState.value 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 return
} }
viewModelScope.launch(Dispatchers.IO) { viewModelScope.launch(Dispatchers.IO) {
_uiState.value = state.copy(isLoading = true, errorMessage = null) _uiState.value = state.copy(isLoading = true, errorMessage = null)
runCatching { runCatching {
container.apiClient.requestPhoneCode(state.phoneNumber.trim()) container.apiClient.requestEmailCode(
email = state.email.trim(),
phoneNumber = state.phoneNumber.trim()
)
}.onSuccess { challenge -> }.onSuccess { challenge ->
_uiState.value = _uiState.value.copy( _uiState.value = _uiState.value.copy(
isLoading = false, isLoading = false,
challengeId = challenge.challengeId, challengeId = challenge.challengeId,
testCodeHint = challenge.testCode,
isAwaitingCode = true, isAwaitingCode = true,
isNewAccount = !challenge.isRegistered, isNewAccount = !challenge.isRegistered,
verificationCode = "", verificationCode = "",
@@ -83,7 +91,7 @@ class LoginViewModel(private val container: AppContainer) : ViewModel() {
}.onFailure { error -> }.onFailure { error ->
_uiState.value = _uiState.value.copy( _uiState.value = _uiState.value.copy(
isLoading = false, isLoading = false,
errorMessage = error.message ?: "Не удалось запросить код." errorMessage = error.message ?: "Не удалось отправить код на email."
) )
} }
} }
@@ -93,6 +101,7 @@ class LoginViewModel(private val container: AppContainer) : ViewModel() {
val state = _uiState.value val state = _uiState.value
if (state.isLoading || if (state.isLoading ||
state.challengeId.isBlank() || state.challengeId.isBlank() ||
state.email.isBlank() ||
state.phoneNumber.isBlank() || state.phoneNumber.isBlank() ||
state.verificationCode.isBlank() || state.verificationCode.isBlank() ||
(state.isNewAccount && state.displayName.isBlank()) (state.isNewAccount && state.displayName.isBlank())
@@ -103,8 +112,9 @@ class LoginViewModel(private val container: AppContainer) : ViewModel() {
viewModelScope.launch(Dispatchers.IO) { viewModelScope.launch(Dispatchers.IO) {
_uiState.value = state.copy(isLoading = true, errorMessage = null) _uiState.value = state.copy(isLoading = true, errorMessage = null)
runCatching { runCatching {
container.apiClient.verifyPhoneCode( container.apiClient.verifyEmailCode(
challengeId = state.challengeId, challengeId = state.challengeId,
email = state.email.trim(),
phoneNumber = state.phoneNumber.trim(), phoneNumber = state.phoneNumber.trim(),
code = state.verificationCode.trim(), code = state.verificationCode.trim(),
displayName = state.displayName.trim().ifBlank { null } displayName = state.displayName.trim().ifBlank { null }
@@ -116,16 +126,15 @@ class LoginViewModel(private val container: AppContainer) : ViewModel() {
}.onFailure { error -> }.onFailure { error ->
_uiState.value = _uiState.value.copy( _uiState.value = _uiState.value.copy(
isLoading = false, isLoading = false,
errorMessage = error.message ?: "Не удалось подтвердить код." errorMessage = error.message ?: "Не удалось подтвердить email-код."
) )
} }
} }
} }
fun changePhoneNumber() { fun changeEmail() {
_uiState.value = _uiState.value.copy( _uiState.value = _uiState.value.copy(
challengeId = "", challengeId = "",
testCodeHint = "",
verificationCode = "", verificationCode = "",
isAwaitingCode = false, isAwaitingCode = false,
isNewAccount = false, isNewAccount = false,
@@ -4,6 +4,7 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.seven.ikar.kotlin.app.AppContainer import com.seven.ikar.kotlin.app.AppContainer
import com.seven.ikar.kotlin.core.model.ChatSummaryDto import com.seven.ikar.kotlin.core.model.ChatSummaryDto
import com.seven.ikar.kotlin.core.push.NotificationSettingsSnapshot
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
@@ -22,8 +23,10 @@ class MainMenuViewModel(private val container: AppContainer) : ViewModel() {
private var debounceJob: Job? = null private var debounceJob: Job? = null
private var refreshJob: Job? = null private var refreshJob: Job? = null
private var latestChats: List<ChatSummaryDto> = emptyList()
init { init {
attachNotificationSettings()
attachRealtime() attachRealtime()
loadCachedUnreadChatsCount() loadCachedUnreadChatsCount()
refresh() refresh()
@@ -47,7 +50,8 @@ class MainMenuViewModel(private val container: AppContainer) : ViewModel() {
if (container.sessionStore.read()?.user?.id != ownerUserId) { if (container.sessionStore.read()?.user?.id != ownerUserId) {
return@onSuccess return@onSuccess
} }
_uiState.value = MainMenuUiState(unreadChatsCount = countUnreadChats(chats)) latestChats = chats
updateUnreadChatsCount(chats)
cacheChatList(ownerUserId, chats) cacheChatList(ownerUserId, chats)
}.onFailure { }.onFailure {
loadCachedUnreadChatsCount() loadCachedUnreadChatsCount()
@@ -58,6 +62,7 @@ class MainMenuViewModel(private val container: AppContainer) : ViewModel() {
fun clear() { fun clear() {
debounceJob?.cancel() debounceJob?.cancel()
refreshJob?.cancel() refreshJob?.cancel()
latestChats = emptyList()
_uiState.value = MainMenuUiState() _uiState.value = MainMenuUiState()
} }
@@ -69,7 +74,8 @@ class MainMenuViewModel(private val container: AppContainer) : ViewModel() {
container.chatListCache.loadChats(ownerUserId) container.chatListCache.loadChats(ownerUserId)
}.getOrDefault(emptyList()) }.getOrDefault(emptyList())
if (container.sessionStore.read()?.user?.id == ownerUserId) { 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() { private fun scheduleRefresh() {
debounceJob?.cancel() debounceJob?.cancel()
debounceJob = viewModelScope.launch { 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 = 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.Arrangement
import androidx.compose.foundation.layout.Column 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.Row
import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.WindowInsetsSides import androidx.compose.foundation.layout.WindowInsetsSides
@@ -52,6 +54,8 @@ fun ChatMembersScreen(
onToggleCanPin: (ChatMemberDto) -> Unit, onToggleCanPin: (ChatMemberDto) -> Unit,
onToggleCanDelete: (ChatMemberDto) -> Unit, onToggleCanDelete: (ChatMemberDto) -> Unit,
onToggleCanInvite: (ChatMemberDto) -> Unit, onToggleCanInvite: (ChatMemberDto) -> Unit,
onTransferOwnership: (ChatMemberDto) -> Unit,
onRemoveMember: (ChatMemberDto) -> Unit,
onMemberSearchChanged: (String) -> Unit, onMemberSearchChanged: (String) -> Unit,
onAddMember: (UserSummaryDto) -> Unit, onAddMember: (UserSummaryDto) -> Unit,
onAddAdmin: (UserSummaryDto) -> Unit, onAddAdmin: (UserSummaryDto) -> Unit,
@@ -153,12 +157,19 @@ fun ChatMembersScreen(
MemberPermissionRow( MemberPermissionRow(
member = member, member = member,
canManage = state.canManageMembers && !member.isOwner, 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), isUpdating = state.updatingUserIds.contains(member.user.id),
onToggleAdmin = { onToggleAdmin(member) }, onToggleAdmin = { onToggleAdmin(member) },
onToggleCanPost = { onToggleCanPost(member) }, onToggleCanPost = { onToggleCanPost(member) },
onToggleCanPin = { onToggleCanPin(member) }, onToggleCanPin = { onToggleCanPin(member) },
onToggleCanDelete = { onToggleCanDelete(member) }, onToggleCanDelete = { onToggleCanDelete(member) },
onToggleCanInvite = { onToggleCanInvite(member) } onToggleCanInvite = { onToggleCanInvite(member) },
onTransferOwnership = { onTransferOwnership(member) },
onRemoveMember = { onRemoveMember(member) }
) )
if (index != members.lastIndex) { if (index != members.lastIndex) {
IkarRowDivider(startIndent = 58.dp) IkarRowDivider(startIndent = 58.dp)
@@ -298,15 +309,19 @@ private fun JoinRequestRow(
} }
@Composable @Composable
@OptIn(ExperimentalLayoutApi::class)
private fun MemberPermissionRow( private fun MemberPermissionRow(
member: ChatMemberDto, member: ChatMemberDto,
canManage: Boolean, canManage: Boolean,
canTransferOwnership: Boolean,
isUpdating: Boolean, isUpdating: Boolean,
onToggleAdmin: () -> Unit, onToggleAdmin: () -> Unit,
onToggleCanPost: () -> Unit, onToggleCanPost: () -> Unit,
onToggleCanPin: () -> Unit, onToggleCanPin: () -> Unit,
onToggleCanDelete: () -> Unit, onToggleCanDelete: () -> Unit,
onToggleCanInvite: () -> Unit onToggleCanInvite: () -> Unit,
onTransferOwnership: () -> Unit,
onRemoveMember: () -> Unit
) { ) {
Column( Column(
modifier = Modifier modifier = Modifier
@@ -345,7 +360,18 @@ private fun MemberPermissionRow(
} }
if (canManage) { 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.canPostMessages, isUpdating, onToggleCanPost)
PermissionChip("закрепление", member.permissions.canPinMessages, isUpdating, onToggleCanPin) PermissionChip("закрепление", member.permissions.canPinMessages, isUpdating, onToggleCanPin)
PermissionChip("удаление", member.permissions.canDeleteMessages, isUpdating, onToggleCanDelete) PermissionChip("удаление", member.permissions.canDeleteMessages, isUpdating, onToggleCanDelete)
@@ -436,6 +462,7 @@ private fun moderationActionLabel(action: String): String = when (action) {
"member.added" -> "Участник добавлен" "member.added" -> "Участник добавлен"
"member.admin.added" -> "Администратор добавлен" "member.admin.added" -> "Администратор добавлен"
"member.permissions.updated" -> "Права участника обновлены" "member.permissions.updated" -> "Права участника обновлены"
"member.owner.transferred" -> "Владелец передан"
"message.deleted" -> "Сообщение удалено" "message.deleted" -> "Сообщение удалено"
"message.pinned" -> "Сообщение закреплено" "message.pinned" -> "Сообщение закреплено"
"message.unpinned" -> "Сообщение откреплено" "message.unpinned" -> "Сообщение откреплено"
@@ -21,6 +21,7 @@ import kotlinx.coroutines.launch
data class ChatMembersUiState( data class ChatMembersUiState(
val chat: ChatDetailsDto? = null, val chat: ChatDetailsDto? = null,
val currentUserId: String? = null,
val joinRequests: List<ChatJoinRequestDto> = emptyList(), val joinRequests: List<ChatJoinRequestDto> = emptyList(),
val moderationLog: List<ModerationLogDto> = emptyList(), val moderationLog: List<ModerationLogDto> = emptyList(),
val isLoading: Boolean = false, val isLoading: Boolean = false,
@@ -36,8 +37,26 @@ data class ChatMembersUiState(
val canManageMembers: Boolean val canManageMembers: Boolean
get() = chat?.currentUserPermissions?.canManageMembers == true 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 val canAddMembers: Boolean
get() = canManageMembers && chat?.type == ChatType.Channel get() = canManageMembers && when (chat?.type) {
ChatType.Group,
ChatType.Channel -> true
else -> false
}
} }
class ChatMembersViewModel( class ChatMembersViewModel(
@@ -61,6 +80,7 @@ class ChatMembersViewModel(
val canManageMembers = chat.currentUserPermissions?.canManageMembers == true val canManageMembers = chat.currentUserPermissions?.canManageMembers == true
ChatMembersPayload( ChatMembersPayload(
chat = chat, chat = chat,
currentUserId = session.user.id,
joinRequests = if (canManageMembers) { joinRequests = if (canManageMembers) {
container.apiClient.getChatJoinRequests(session.accessToken, chatId) container.apiClient.getChatJoinRequests(session.accessToken, chatId)
} else { } else {
@@ -76,6 +96,7 @@ class ChatMembersViewModel(
}.onSuccess { payload -> }.onSuccess { payload ->
_uiState.value = _uiState.value.copy( _uiState.value = _uiState.value.copy(
chat = payload.chat, chat = payload.chat,
currentUserId = payload.currentUserId,
joinRequests = payload.joinRequests, joinRequests = payload.joinRequests,
moderationLog = payload.moderationLog, moderationLog = payload.moderationLog,
memberSearchResults = filterMemberSearchResults(_uiState.value.memberSearchResults, payload.chat), memberSearchResults = filterMemberSearchResults(_uiState.value.memberSearchResults, payload.chat),
@@ -182,6 +203,79 @@ class ChatMembersViewModel(
updateMember(member, canInviteUsers = !member.permissions.canInviteUsers) 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( private fun updateMember(
member: ChatMemberDto, member: ChatMemberDto,
role: ChatMemberRole = member.role, role: ChatMemberRole = member.role,
@@ -289,6 +383,7 @@ class ChatMembersViewModel(
private data class ChatMembersPayload( private data class ChatMembersPayload(
val chat: ChatDetailsDto, val chat: ChatDetailsDto,
val currentUserId: String,
val joinRequests: List<ChatJoinRequestDto>, val joinRequests: List<ChatJoinRequestDto>,
val moderationLog: List<ModerationLogDto> 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.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.WindowInsetsSides import androidx.compose.foundation.layout.WindowInsetsSides
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.only import androidx.compose.foundation.layout.only
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawing import androidx.compose.foundation.layout.safeDrawing
import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.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 androidx.compose.ui.unit.dp
import com.seven.ikar.kotlin.core.model.PrivacySettingsDto 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.IkarRowDivider
import com.seven.ikar.kotlin.ui.IkarScreenSurface import com.seven.ikar.kotlin.ui.IkarScreenSurface
import com.seven.ikar.kotlin.ui.IkarSectionCard import com.seven.ikar.kotlin.ui.IkarSectionCard
@@ -33,11 +57,19 @@ import com.seven.ikar.kotlin.ui.theme.TelegramInkMuted
fun PrivacySecurityScreen( fun PrivacySecurityScreen(
state: PrivacySecurityUiState, state: PrivacySecurityUiState,
onBackClick: () -> Unit, onBackClick: () -> Unit,
onRefreshClick: () -> Unit, onUpdate: ((PrivacySettingsDto) -> PrivacySettingsDto) -> Unit,
onBlockedUsersClick: () -> Unit, onUserSearchQueryChanged: (String) -> Unit,
onExceptionsClick: () -> Unit, onBlockUser: (UserSummaryDto) -> Unit,
onUpdate: ((PrivacySettingsDto) -> PrivacySettingsDto) -> 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()) { IkarScreenSurface(modifier = Modifier.fillMaxSize()) {
Column( Column(
modifier = Modifier modifier = Modifier
@@ -58,41 +90,351 @@ fun PrivacySecurityScreen(
if (settings != null) { if (settings != null) {
IkarSectionCard { IkarSectionCard {
IkarSectionTitle("Конфиденциальность", modifier = Modifier.padding(bottom = 8.dp)) 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)) } onUpdate { it.copy(phoneNumberVisibility = nextVisibility(it.phoneNumberVisibility)) }
} }
IkarRowDivider() IkarRowDivider()
PrivacyRow("Последняя активность и онлайн", privacyValueLabel(settings.lastSeenVisibility)) { PrivacyRow("Последняя активность и онлайн", privacyValueLabel(settings.lastSeenVisibility), Icons.Outlined.Schedule) {
onUpdate { it.copy(lastSeenVisibility = nextVisibility(it.lastSeenVisibility)) } onUpdate { it.copy(lastSeenVisibility = nextVisibility(it.lastSeenVisibility)) }
} }
IkarRowDivider() IkarRowDivider()
PrivacyRow("Фотографии профиля", privacyValueLabel(settings.profilePhotoVisibility)) { PrivacyRow("Фотографии профиля", privacyValueLabel(settings.profilePhotoVisibility), Icons.Outlined.Image) {
onUpdate { it.copy(profilePhotoVisibility = nextVisibility(it.profilePhotoVisibility)) } onUpdate { it.copy(profilePhotoVisibility = nextVisibility(it.profilePhotoVisibility)) }
} }
IkarRowDivider() IkarRowDivider()
PrivacyRow("Заблокированные пользователи", "${settings.blockedUserIds.size}") { PrivacyRow("Заблокированные пользователи", "${settings.blockedUserIds.size}", Icons.Outlined.Block) {
onBlockedUsersClick() listDialogMode = PrivacyListDialogMode.Blocked
} }
IkarRowDivider() IkarRowDivider()
PrivacyRow("Исключения", "${settings.alwaysAllowUserIds.size + settings.neverAllowUserIds.size}") { PrivacyRow("Исключения", "${settings.alwaysAllowUserIds.size + settings.neverAllowUserIds.size}", Icons.Outlined.Security) {
onExceptionsClick() listDialogMode = PrivacyListDialogMode.Exceptions
} }
} }
IkarSectionCard { IkarSectionCard {
IkarSectionTitle("Безопасность", modifier = Modifier.padding(bottom = 8.dp)) IkarSectionTitle("Безопасность", modifier = Modifier.padding(bottom = 8.dp))
PrivacyRow("Код-пароль", if (settings.passcodeLockEnabled) "включено" else "выключено") { PrivacyRow("Код-пароль", if (settings.passcodeLockEnabled) "включено" else "выключено", Icons.Outlined.Lock) {
onUpdate { it.copy(passcodeLockEnabled = !it.passcodeLockEnabled) } passcodeDialogMode = if (settings.passcodeLockEnabled) {
PasscodeDialogMode.Disable
} else {
PasscodeDialogMode.Enable
}
} }
IkarRowDivider() IkarRowDivider()
PrivacyRow("Предпросмотр сообщений", if (settings.showMessagePreview) "включено" else "выключено") { PrivacyRow("Предпросмотр сообщений", if (settings.showMessagePreview) "включено" else "выключено", Icons.Outlined.Visibility) {
onUpdate { it.copy(showMessagePreview = !it.showMessagePreview) } 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( private fun PrivacyRow(
title: String, title: String,
value: String, value: String,
onClick: () -> Unit icon: ImageVector,
onClick: (() -> Unit)? = null
) { ) {
IkarSettingRow( IkarSettingRow(
title = title, title = title,
subtitle = "Сейчас: $value", subtitle = "Сейчас: $value",
onClick = onClick, onClick = onClick,
leading = { 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.app.AppContainer
import com.seven.ikar.kotlin.core.model.PrivacySettingsDto import com.seven.ikar.kotlin.core.model.PrivacySettingsDto
import com.seven.ikar.kotlin.core.model.UpdatePrivacySettingsRequest 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.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
@@ -12,6 +15,9 @@ import kotlinx.coroutines.launch
data class PrivacySecurityUiState( data class PrivacySecurityUiState(
val settings: PrivacySettingsDto? = null, val settings: PrivacySettingsDto? = null,
val userSearchQuery: String = "",
val userSearchResults: List<UserSummaryDto> = emptyList(),
val isSearchingUsers: Boolean = false,
val isLoading: Boolean = false, val isLoading: Boolean = false,
val isSaving: Boolean = false, val isSaving: Boolean = false,
val errorMessage: String? = null val errorMessage: String? = null
@@ -19,6 +25,7 @@ data class PrivacySecurityUiState(
class PrivacySecurityViewModel(private val container: AppContainer) : ViewModel() { class PrivacySecurityViewModel(private val container: AppContainer) : ViewModel() {
private val _uiState = MutableStateFlow(PrivacySecurityUiState()) private val _uiState = MutableStateFlow(PrivacySecurityUiState())
private var userSearchJob: Job? = null
val uiState: StateFlow<PrivacySecurityUiState> = _uiState.asStateFlow() val uiState: StateFlow<PrivacySecurityUiState> = _uiState.asStateFlow()
init { init {
@@ -44,6 +51,99 @@ class PrivacySecurityViewModel(private val container: AppContainer) : ViewModel(
fun update(transform: (PrivacySettingsDto) -> PrivacySettingsDto) { fun update(transform: (PrivacySettingsDto) -> PrivacySettingsDto) {
val current = _uiState.value.settings ?: return val current = _uiState.value.settings ?: return
val next = transform(current) 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 { viewModelScope.launch {
_uiState.value = _uiState.value.copy(settings = next, isSaving = true, errorMessage = null) _uiState.value = _uiState.value.copy(settings = next, isSaving = true, errorMessage = null)
runCatching { runCatching {
@@ -63,6 +163,7 @@ class PrivacySecurityViewModel(private val container: AppContainer) : ViewModel(
) )
} }
}.onSuccess { saved -> }.onSuccess { saved ->
afterSuccess()
container.privacyRuntimeSettings.saveFrom(saved) container.privacyRuntimeSettings.saveFrom(saved)
_uiState.value = _uiState.value.copy(settings = saved, isSaving = false) _uiState.value = _uiState.value.copy(settings = saved, isSaving = false)
}.onFailure { error -> }.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) { 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.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.seven.ikar.kotlin.ui.AvatarView import com.seven.ikar.kotlin.ui.AvatarView
import com.seven.ikar.kotlin.ui.IkarSectionCard import com.seven.ikar.kotlin.ui.IkarSectionCard
@@ -50,7 +51,6 @@ fun AccountSettingsScreen(
onBackClick: () -> Unit, onBackClick: () -> Unit,
onDisplayNameChanged: (String) -> Unit, onDisplayNameChanged: (String) -> Unit,
onAboutChanged: (String) -> Unit, onAboutChanged: (String) -> Unit,
onSaveClick: () -> Unit,
onDeleteAvatarClick: () -> Unit, onDeleteAvatarClick: () -> Unit,
onAvatarSelected: (Uri) -> Unit onAvatarSelected: (Uri) -> Unit
) { ) {
@@ -103,7 +103,9 @@ fun AccountSettingsScreen(
Text( Text(
text = "Нажмите на надпись, чтобы удалить текущее фото", text = "Нажмите на надпись, чтобы удалить текущее фото",
color = TelegramInkMuted, 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)) { Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
Text("Добавить личный канал", style = MaterialTheme.typography.titleLarge)
Text( Text(
"Этот сценарий будет доведён отдельным экраном. Сейчас основной профиль уже перенесён.", "Добавить личный канал",
style = MaterialTheme.typography.titleLarge,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Text(
"Отдельный экран будет добавлен позже.",
style = MaterialTheme.typography.bodySmall, 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 { state.errorMessage?.let {
Text(it, color = TelegramDanger, style = MaterialTheme.typography.bodyMedium) Text(it, color = TelegramDanger, style = MaterialTheme.typography.bodyMedium)
} }
if (state.isSaving) {
Text("Сохраняется...", color = TelegramInkMuted, style = MaterialTheme.typography.bodyMedium)
}
state.infoMessage?.let { state.infoMessage?.let {
Text(it, color = TelegramBlue, style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.SemiBold) Text(it, color = TelegramBlue, style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.SemiBold)
} }
@@ -220,8 +224,19 @@ private fun IkarInfoLine(
} }
} }
Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
Text(title, style = MaterialTheme.typography.titleLarge) Text(
Text(subtitle, style = MaterialTheme.typography.bodySmall, color = TelegramInkMuted) 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.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
data class ProfileUiState( data class ProfileUiState(
@@ -28,17 +30,20 @@ data class ProfileUiState(
class ProfileViewModel(private val container: AppContainer) : ViewModel() { class ProfileViewModel(private val container: AppContainer) : ViewModel() {
private val _uiState = MutableStateFlow(ProfileUiState(session = container.sessionStore.read())) private val _uiState = MutableStateFlow(ProfileUiState(session = container.sessionStore.read()))
val uiState: StateFlow<ProfileUiState> = _uiState.asStateFlow() val uiState: StateFlow<ProfileUiState> = _uiState.asStateFlow()
private var autosaveJob: Job? = null
init { init {
refresh() refresh()
} }
fun updateDisplayName(value: String) { 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) { 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() { 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 val state = _uiState.value
if (state.displayName.isBlank()) { if (state.displayName.isBlank()) {
_uiState.value = state.copy(errorMessage = "Укажите отображаемое имя.") _uiState.value = state.copy(errorMessage = "Укажите отображаемое имя.")
return return
} }
val submittedDisplayName = state.displayName.trim()
val submittedAbout = state.about.trim()
viewModelScope.launch { viewModelScope.launch {
_uiState.value = state.copy(isSaving = true, errorMessage = null, infoMessage = null) _uiState.value = state.copy(isSaving = true, errorMessage = null, infoMessage = null)
runCatching { runCatching {
container.executeAuthorized { session -> container.executeAuthorized { session ->
session to container.apiClient.updateMyProfile( session to container.apiClient.updateMyProfile(
accessToken = session.accessToken, accessToken = session.accessToken,
displayName = state.displayName.trim(), displayName = submittedDisplayName,
about = state.about.trim().ifBlank { null } about = submittedAbout.ifBlank { null }
) )
} }
}.onSuccess { (session, user) -> }.onSuccess { (session, user) ->
applyUser(session, user, infoMessage = "Профиль сохранён.") applySavedProfile(session, user, submittedDisplayName, submittedAbout)
}.onFailure { error -> }.onFailure { error ->
_uiState.value = _uiState.value.copy(isSaving = false, errorMessage = error.message) _uiState.value = _uiState.value.copy(isSaving = false, errorMessage = error.message)
} }
@@ -143,4 +158,34 @@ class ProfileViewModel(private val container: AppContainer) : ViewModel() {
infoMessage = infoMessage 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.clickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column 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.WindowInsets
import androidx.compose.foundation.layout.WindowInsetsSides import androidx.compose.foundation.layout.WindowInsetsSides
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
@@ -180,6 +181,7 @@ fun GlobalSearchScreen(
} }
@Composable @Composable
@OptIn(ExperimentalLayoutApi::class)
private fun GlobalSearchAdvancedFilters( private fun GlobalSearchAdvancedFilters(
selectedDate: GlobalSearchDateFilter, selectedDate: GlobalSearchDateFilter,
selectedType: GlobalSearchResultType, selectedType: GlobalSearchResultType,
@@ -189,7 +191,10 @@ private fun GlobalSearchAdvancedFilters(
onAuthorFilterChanged: (String) -> Unit onAuthorFilterChanged: (String) -> Unit
) { ) {
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { 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 -> GlobalSearchDateFilter.entries.forEach { filter ->
TextButton(onClick = { onDateFilterChanged(filter) }) { TextButton(onClick = { onDateFilterChanged(filter) }) {
Text( 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 -> GlobalSearchResultType.entries.forEach { type ->
TextButton(onClick = { onResultTypeChanged(type) }) { TextButton(onClick = { onResultTypeChanged(type) }) {
Text( Text(
@@ -220,11 +228,15 @@ private fun GlobalSearchAdvancedFilters(
} }
@Composable @Composable
@OptIn(ExperimentalLayoutApi::class)
private fun GlobalSearchFilterRow( private fun GlobalSearchFilterRow(
selected: GlobalSearchFilter, selected: GlobalSearchFilter,
onFilterChanged: (GlobalSearchFilter) -> Unit onFilterChanged: (GlobalSearchFilter) -> Unit
) { ) {
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { FlowRow(
horizontalArrangement = Arrangement.spacedBy(6.dp),
verticalArrangement = Arrangement.spacedBy(2.dp)
) {
GlobalSearchFilter.entries.forEach { filter -> GlobalSearchFilter.entries.forEach { filter ->
TextButton(onClick = { onFilterChanged(filter) }) { TextButton(onClick = { onFilterChanged(filter) }) {
Text( Text(
@@ -261,6 +273,19 @@ private fun GlobalSearchResultRow(
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis 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(
text = highlightedText(result.matchText.ifBlank { chatPreview(result.chat) }, query), text = highlightedText(result.matchText.ifBlank { chatPreview(result.chat) }, query),
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
@@ -276,18 +301,6 @@ private fun GlobalSearchResultRow(
overflow = TextOverflow.Ellipsis 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) { when (this) {
GlobalSearchResultType.All -> "Все типы" GlobalSearchResultType.All -> "Все типы"
GlobalSearchResultType.Chats -> "Чаты" GlobalSearchResultType.Chats -> "Чаты"
GlobalSearchResultType.Channels -> "Каналы"
GlobalSearchResultType.Groups -> "Группы"
GlobalSearchResultType.Messages -> "Сообщения" GlobalSearchResultType.Messages -> "Сообщения"
GlobalSearchResultType.Attachments -> "Вложения" GlobalSearchResultType.Attachments -> "Вложения"
GlobalSearchResultType.Bots -> "Боты" GlobalSearchResultType.Bots -> "Боты"
GlobalSearchResultType.GroupCalls -> "Звонки"
} }
private fun ChatSearchResultDto.botMiniAppDeepLink(): String? { private fun formatChannelSubscribers(count: Int): String =
val bot = chat.participants.firstOrNull { it.isBot } ?: return null when {
return "ikar://bot/${bot.username}?start=miniapp" count == 1 -> "1 подписчик"
} count in 2..4 -> "$count подписчика"
else -> "$count подписчиков"
}
@@ -32,19 +32,13 @@ enum class GlobalSearchDateFilter {
enum class GlobalSearchResultType { enum class GlobalSearchResultType {
All, All,
Chats, Chats,
Channels,
Groups,
Messages, Messages,
Attachments, Attachments,
Bots, Bots
GroupCalls
} }
data class SearchCapabilityHooks(
val supportsMediaDateGrouping: Boolean = true,
val supportsBotMiniAppPlaceholder: Boolean = true,
val supportsScheduledMessageOptions: Boolean = true,
val supportsGroupCallPlaceholder: Boolean = true
)
data class GlobalSearchUiState( data class GlobalSearchUiState(
val query: String = "", val query: String = "",
val filter: GlobalSearchFilter = GlobalSearchFilter.All, val filter: GlobalSearchFilter = GlobalSearchFilter.All,
@@ -56,8 +50,7 @@ data class GlobalSearchUiState(
val hasMore: Boolean = false, val hasMore: Boolean = false,
val isSearching: Boolean = false, val isSearching: Boolean = false,
val isLoadingMore: Boolean = false, val isLoadingMore: Boolean = false,
val errorMessage: String? = null, val errorMessage: String? = null
val hooks: SearchCapabilityHooks = SearchCapabilityHooks()
) { ) {
val visibleResults: List<ChatSearchResultDto> val visibleResults: List<ChatSearchResultDto>
get() = results.filter { result -> get() = results.filter { result ->
@@ -214,10 +207,11 @@ private fun ChatSearchResultDto.matchesType(type: GlobalSearchResultType): Boole
when (type) { when (type) {
GlobalSearchResultType.All -> true GlobalSearchResultType.All -> true
GlobalSearchResultType.Chats -> message == null GlobalSearchResultType.Chats -> message == null
GlobalSearchResultType.Channels -> chat.type == ChatType.Channel
GlobalSearchResultType.Groups -> chat.type == ChatType.Group
GlobalSearchResultType.Messages -> message != null GlobalSearchResultType.Messages -> message != null
GlobalSearchResultType.Attachments -> message?.attachments?.isNotEmpty() == true GlobalSearchResultType.Attachments -> message?.attachments?.isNotEmpty() == true
GlobalSearchResultType.Bots -> chat.participants.any { it.isBot } GlobalSearchResultType.Bots -> chat.participants.any { it.isBot }
GlobalSearchResultType.GroupCalls -> chat.type == ChatType.Group
} }
internal fun ChatSearchResultDto.searchDateGroupLabel(): String = 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.layout.windowInsetsPadding
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Surface 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 import com.seven.ikar.kotlin.ui.theme.TelegramInkMuted
@Composable @Composable
fun PrivacySettingsV2Screen( fun ThemeSettingsScreen(
onBackClick: () -> Unit,
onBlockedUsersClick: () -> Unit,
onExceptionsClick: () -> Unit
) {
IkarScreenSurface(modifier = Modifier.fillMaxSize()) {
Column(
modifier = Modifier
.fillMaxSize()
.windowInsetsPadding(WindowInsets.safeDrawing.only(WindowInsetsSides.Vertical))
.verticalScroll(rememberScrollState())
.padding(horizontal = 18.dp, vertical = 16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
IkarSimpleHeader(title = "Конфиденциальность", onBackClick = onBackClick)
IkarSectionCard {
IkarSectionTitle("Исключения", modifier = Modifier.padding(bottom = 12.dp))
IkarValueRow("Заблокированные пользователи", "Список блокировок хранится в настройках конфиденциальности.", "Открыть", onClick = onBlockedUsersClick)
IkarRowDivider(startIndent = 0.dp)
IkarValueRow("Исключения приватности", "Списки «всегда разрешать» и «никогда не разрешать» для телефона, фото и историй.", "Открыть", onClick = onExceptionsClick)
}
IkarSectionCard {
Text("Подключено к серверному контракту", style = MaterialTheme.typography.titleLarge, color = TelegramBlue)
Text(
"Основные переключатели конфиденциальности и безопасности доступны через прежний экран; этот экран оставляет навигацию по подразделам как в Telegram.",
modifier = Modifier.padding(top = 6.dp),
style = MaterialTheme.typography.bodySmall,
color = TelegramInkMuted
)
}
}
}
}
@Composable
fun BlockedUsersPlaceholderScreen(onBackClick: () -> Unit) {
SimpleSettingsInfoScreen(
title = "Заблокированные",
body = "Сервер уже хранит список заблокированных пользователей в настройках конфиденциальности. Следующий слой: выбор пользователя с поиском и быстрым снятием блокировки.",
onBackClick = onBackClick
)
}
@Composable
fun PrivacyExceptionsPlaceholderScreen(onBackClick: () -> Unit) {
SimpleSettingsInfoScreen(
title = "Исключения",
body = "Контракт поддерживает списки разрешённых и запрещённых пользователей. Для полного интерфейса как в Telegram нужен выбор пользователей из контактов и отдельные правила для каждого поля.",
onBackClick = onBackClick
)
}
@Composable
fun ThemePlaceholderScreen(
state: AppSettingsUiState, state: AppSettingsUiState,
onBackClick: () -> Unit, onBackClick: () -> Unit,
onSnapshotChanged: ((AppSettingsSnapshot) -> AppSettingsSnapshot) -> Unit, onSnapshotChanged: ((AppSettingsSnapshot) -> AppSettingsSnapshot) -> Unit,
@@ -138,14 +84,21 @@ fun ThemePlaceholderScreen(
onCheckedChange = { value -> onSnapshotChanged { it.copy(largeEmoji = value) } } 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 @Composable
fun LanguagePlaceholderScreen( fun LanguageSettingsScreen(
state: AppSettingsUiState, state: AppSettingsUiState,
onBackClick: () -> Unit, onBackClick: () -> Unit,
onSnapshotChanged: ((AppSettingsSnapshot) -> AppSettingsSnapshot) -> Unit onSnapshotChanged: ((AppSettingsSnapshot) -> AppSettingsSnapshot) -> Unit
@@ -167,9 +120,9 @@ fun LanguagePlaceholderScreen(
} }
} }
IkarSectionCard { IkarSectionCard {
Text("Локализация сохранена", style = MaterialTheme.typography.titleMedium, color = TelegramBlue) Text("Локализация применяется сразу", style = MaterialTheme.typography.titleMedium, color = TelegramBlue)
Text( Text(
"Значение уже хранится локально. Для полного эффекта нужно применить его в базовом контексте приложения или слое локали Compose.", "Выбранный язык сохраняется локально и сразу передается в контекст приложения. Системный режим возвращает язык устройства.",
modifier = Modifier.padding(top = 6.dp), modifier = Modifier.padding(top = 6.dp),
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = TelegramInkMuted color = TelegramInkMuted
@@ -179,7 +132,7 @@ fun LanguagePlaceholderScreen(
} }
@Composable @Composable
fun ProxyPlaceholderScreen( fun ProxySettingsScreen(
state: AppSettingsUiState, state: AppSettingsUiState,
onBackClick: () -> Unit, onBackClick: () -> Unit,
onSnapshotChanged: ((AppSettingsSnapshot) -> AppSettingsSnapshot) -> Unit onSnapshotChanged: ((AppSettingsSnapshot) -> AppSettingsSnapshot) -> Unit
@@ -189,14 +142,14 @@ fun ProxyPlaceholderScreen(
IkarSectionCard { IkarSectionCard {
IkarToggleRow( IkarToggleRow(
"Использовать прокси", "Использовать прокси",
"Профиль сохраняется локально и готов для подключения к OkHttp.", "Применяется к новым сетевым запросам сразу после изменения.",
snapshot.proxyEnabled, snapshot.proxyEnabled,
onCheckedChange = { value -> onSnapshotChanged { it.copy(proxyEnabled = value) } } onCheckedChange = { value -> onSnapshotChanged { it.copy(proxyEnabled = value) } }
) )
IkarRowDivider(startIndent = 0.dp) IkarRowDivider(startIndent = 0.dp)
IkarValueRow( IkarValueRow(
"Тип", "Тип",
"SOCKS5, MTProto или HTTP.", "SOCKS5 или HTTP.",
snapshot.proxyType.proxyTypeTitle(), snapshot.proxyType.proxyTypeTitle(),
onClick = { onSnapshotChanged { it.copy(proxyType = nextProxyType(it.proxyType)) } } onClick = { onSnapshotChanged { it.copy(proxyType = nextProxyType(it.proxyType)) } }
) )
@@ -224,15 +177,6 @@ fun ProxyPlaceholderScreen(
singleLine = true, singleLine = true,
label = { Text("Порт") } 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) { private fun AppThemeMode.themeTitle(): String = when (this) {
AppThemeMode.System -> "Как в системе" AppThemeMode.System -> "Как в системе"
AppThemeMode.Light -> "Дневная" AppThemeMode.Light -> "Дневная"
@@ -349,12 +274,12 @@ private fun nextAccent(current: String): String = when (current) {
private fun AppProxyType.proxyTypeTitle(): String = when (this) { private fun AppProxyType.proxyTypeTitle(): String = when (this) {
AppProxyType.Socks5 -> "SOCKS5" AppProxyType.Socks5 -> "SOCKS5"
AppProxyType.MtProto -> "MTProto" AppProxyType.MtProto -> "SOCKS5"
AppProxyType.Http -> "HTTP" AppProxyType.Http -> "HTTP"
} }
private fun nextProxyType(current: AppProxyType): AppProxyType = when (current) { private fun nextProxyType(current: AppProxyType): AppProxyType = when (current) {
AppProxyType.Socks5 -> AppProxyType.MtProto AppProxyType.Socks5 -> AppProxyType.Http
AppProxyType.MtProto -> AppProxyType.Http AppProxyType.MtProto -> AppProxyType.Socks5
AppProxyType.Http -> 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.padding
import androidx.compose.foundation.layout.safeDrawing import androidx.compose.foundation.layout.safeDrawing
import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
@@ -27,12 +26,12 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import com.seven.ikar.kotlin.ui.ChatWallpaperBackground 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.ChatSettingsSnapshot
import com.seven.ikar.kotlin.core.settings.ChatSwipeLeftActionOption
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Image import androidx.compose.material.icons.outlined.Image
import androidx.compose.material3.Icon 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 { ChatSettingsSection {
IkarSectionTitle("Медиафайлы и звук", modifier = Modifier.padding(bottom = 12.dp)) IkarSectionTitle("Медиафайлы", modifier = Modifier.padding(bottom = 12.dp))
IkarToggleRow( IkarToggleRow(
title = "Листать медиафайлы по нажатию", title = "Листать медиафайлы по нажатию",
subtitle = "Листать нажатием у края экрана в режиме просмотра медиафайлов.", subtitle = "Листать нажатием у края экрана в режиме просмотра медиафайлов.",
@@ -120,93 +140,38 @@ fun ChatSettingsScreen(
) )
IkarRowDivider(startIndent = 0.dp) IkarRowDivider(startIndent = 0.dp)
IkarToggleRow( IkarToggleRow(
title = "Поднести и слушать", title = "Пауза музыки при запуске аудио",
subtitle = "Переключаться на верхний динамик, когда вы подносите телефон к уху.", subtitle = "Ставить фоновые плееры на паузу при прослушивании голосовых и аудио.",
checked = snapshot.raiseToListenEnabled,
onCheckedChange = { onSnapshotChanged(snapshot.copy(raiseToListenEnabled = it)) }
)
IkarRowDivider(startIndent = 0.dp)
IkarToggleRow(
title = "Поднести и говорить",
subtitle = "Записывать голосовое сообщение, когда вы подносите телефон к уху.",
checked = snapshot.raiseToTalkEnabled,
onCheckedChange = { onSnapshotChanged(snapshot.copy(raiseToTalkEnabled = it)) }
)
IkarRowDivider(startIndent = 0.dp)
IkarToggleRow(
title = "Пауза музыки при записи",
subtitle = "Останавливать музыку на время записи голосового сообщения.",
checked = snapshot.pauseMusicOnRecordEnabled,
onCheckedChange = { onSnapshotChanged(snapshot.copy(pauseMusicOnRecordEnabled = it)) }
)
IkarRowDivider(startIndent = 0.dp)
IkarToggleRow(
title = "Пауза музыки при запуске медиа",
subtitle = "Останавливать фоновую музыку при воспроизведении медиафайлов в чате.",
checked = snapshot.pauseMusicOnPlaybackEnabled, checked = snapshot.pauseMusicOnPlaybackEnabled,
onCheckedChange = { onSnapshotChanged(snapshot.copy(pauseMusicOnPlaybackEnabled = it)) } onCheckedChange = { onSnapshotChanged(snapshot.copy(pauseMusicOnPlaybackEnabled = it)) }
) )
IkarRowDivider(startIndent = 0.dp) IkarRowDivider(startIndent = 0.dp)
IkarValueRow( IkarToggleRow(
title = "Микрофон", title = "Пауза музыки при записи",
subtitle = "Источник записи голосовых сообщений.", subtitle = "Ставить фоновые плееры на паузу при записи голосового сообщения.",
value = "По умолчанию", checked = snapshot.pauseMusicOnRecordEnabled,
enabled = false onCheckedChange = { onSnapshotChanged(snapshot.copy(pauseMusicOnRecordEnabled = it)) }
) )
} }
ChatSettingsSection { ChatSettingsSection {
IkarSectionTitle("Прочие настройки", modifier = Modifier.padding(bottom = 12.dp)) IkarSectionTitle("Ссылки", modifier = Modifier.padding(bottom = 12.dp))
IkarToggleRow(
title = "Смена темы ночью",
subtitle = "Автоматически включать ночную тему по локальному сценарию.",
checked = snapshot.autoNightThemeEnabled,
onCheckedChange = { onSnapshotChanged(snapshot.copy(autoNightThemeEnabled = it)) }
)
IkarRowDivider(startIndent = 0.dp)
IkarToggleRow( IkarToggleRow(
title = "Встроенный браузер", title = "Встроенный браузер",
subtitle = "Открывать ссылки внутри приложения, если доступен встроенный просмотр.", subtitle = "Открывать ссылки из сообщений внутри Икара.",
checked = snapshot.inAppBrowserEnabled, checked = snapshot.inAppBrowserEnabled,
onCheckedChange = { onSnapshotChanged(snapshot.copy(inAppBrowserEnabled = it)) } onCheckedChange = { onSnapshotChanged(snapshot.copy(inAppBrowserEnabled = it)) }
) )
IkarRowDivider(startIndent = 0.dp) }
IkarToggleRow(
title = "Анимации", ChatSettingsSection {
subtitle = "Использовать анимации интерфейса и медиа-переходов.", IkarSectionTitle("Отправка", modifier = Modifier.padding(bottom = 12.dp))
checked = snapshot.animationsEnabled,
onCheckedChange = { onSnapshotChanged(snapshot.copy(animationsEnabled = it)) }
)
IkarRowDivider(startIndent = 0.dp)
IkarToggleRow(
title = "Прямая отправка",
subtitle = "Показывать недавние чаты в системном меню «Поделиться».",
checked = snapshot.directShareEnabled,
onCheckedChange = { onSnapshotChanged(snapshot.copy(directShareEnabled = it)) }
)
IkarRowDivider(startIndent = 0.dp)
IkarToggleRow(
title = "Показывать материалы 18+",
subtitle = "Не скрывать чувствительные медиафайлы.",
checked = snapshot.showSensitiveContentEnabled,
onCheckedChange = { onSnapshotChanged(snapshot.copy(showSensitiveContentEnabled = it)) }
)
IkarRowDivider(startIndent = 0.dp)
IkarToggleRow( IkarToggleRow(
title = "Отправка по Вводу", title = "Отправка по Вводу",
subtitle = "Использовать действие отправки вместо перевода строки, если клавиатура поддерживает «Отправить».", subtitle = "Использовать действие отправки вместо перевода строки, если клавиатура поддерживает «Отправить».",
checked = snapshot.sendByEnterEnabled, checked = snapshot.sendByEnterEnabled,
onCheckedChange = { onSnapshotChanged(snapshot.copy(sendByEnterEnabled = it)) } onCheckedChange = { onSnapshotChanged(snapshot.copy(sendByEnterEnabled = it)) }
) )
IkarRowDivider(startIndent = 0.dp)
IkarValueRow(
title = "Мера расстояния",
subtitle = "Единицы для геоданных и расстояний в интерфейсе.",
value = snapshot.distanceUnit.title,
onClick = {
onSnapshotChanged(snapshot.copy(distanceUnit = snapshot.distanceUnit.next()))
}
)
} }
Text( Text(
@@ -293,6 +258,7 @@ private fun ChatStylePreview(
) { ) {
ChatWallpaperBackground( ChatWallpaperBackground(
snapshot = wallpaperSnapshot, snapshot = wallpaperSnapshot,
animated = wallpaperSnapshot.animationsEnabled,
modifier = Modifier.fillMaxSize() modifier = Modifier.fillMaxSize()
) )
} }
@@ -333,26 +299,28 @@ private fun ChatStylePreview(
} }
} }
@Composable private val ChatListLayoutOption.title: String
private fun BoxLine(width: Dp) {
Row(
modifier = Modifier
.width(width)
.background(Color(0xFFDCE4EA), RoundedCornerShape(999.dp))
.padding(vertical = 2.dp)
) {}
}
private val ChatDistanceUnitOption.title: String
get() = when (this) { get() = when (this) {
ChatDistanceUnitOption.Automatic -> "Автоматически" ChatListLayoutOption.TwoLine -> "2 строки"
ChatDistanceUnitOption.Kilometers -> "Километры" ChatListLayoutOption.ThreeLine -> "3 строки"
ChatDistanceUnitOption.Miles -> "Мили"
} }
private fun ChatDistanceUnitOption.next(): ChatDistanceUnitOption = when (this) { private fun ChatListLayoutOption.next(): ChatListLayoutOption = when (this) {
ChatDistanceUnitOption.Automatic -> ChatDistanceUnitOption.Kilometers ChatListLayoutOption.TwoLine -> ChatListLayoutOption.ThreeLine
ChatDistanceUnitOption.Kilometers -> ChatDistanceUnitOption.Miles ChatListLayoutOption.ThreeLine -> ChatListLayoutOption.TwoLine
ChatDistanceUnitOption.Miles -> ChatDistanceUnitOption.Automatic
} }
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 package com.seven.ikar.kotlin.feature.settings
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.WindowInsets 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.layout.windowInsetsPadding
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable 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.IkarSimpleHeader
import com.seven.ikar.kotlin.ui.IkarToggleRow import com.seven.ikar.kotlin.ui.IkarToggleRow
import com.seven.ikar.kotlin.ui.IkarValueRow import com.seven.ikar.kotlin.ui.IkarValueRow
import com.seven.ikar.kotlin.ui.theme.TelegramBlue
import com.seven.ikar.kotlin.ui.theme.TelegramInkMuted import com.seven.ikar.kotlin.ui.theme.TelegramInkMuted
@Composable @Composable
@@ -52,19 +53,19 @@ fun DataStorageSettingsScreen(
IkarSectionTitle("Использование памяти", modifier = Modifier.padding(bottom = 12.dp)) IkarSectionTitle("Использование памяти", modifier = Modifier.padding(bottom = 12.dp))
IkarValueRow( IkarValueRow(
"Кэш Ikar", "Кэш Ikar",
"Лимит локальных медиа и превью на устройстве.", "Лимит локальных медиа.",
"${snapshot.maxCacheMb} МБ", "${snapshot.maxCacheMb} МБ",
onClick = { onSnapshotChanged { it.copy(maxCacheMb = nextCacheLimit(it.maxCacheMb)) } } onClick = { onSnapshotChanged { it.copy(maxCacheMb = nextCacheLimit(it.maxCacheMb)) } }
) )
IkarRowDivider(startIndent = 0.dp) IkarRowDivider(startIndent = 0.dp)
IkarValueRow( IkarValueRow(
"Хранить медиа", "Хранить медиа",
"Срок хранения локальных файлов перед LRU-очисткой.", "Срок хранения файлов.",
"${snapshot.keepMediaDays} дн.", "${snapshot.keepMediaDays} дн.",
onClick = { onSnapshotChanged { it.copy(keepMediaDays = nextKeepDays(it.keepMediaDays)) } } onClick = { onSnapshotChanged { it.copy(keepMediaDays = nextKeepDays(it.keepMediaDays)) } }
) )
Text( Text(
"Параметры сохраняются локально и готовы для подключения к TransferQueue/LRU-очистке.", "Лимит и срок хранения применяются сразу к локальному кэшу медиафайлов.",
modifier = Modifier.padding(top = 8.dp), modifier = Modifier.padding(top = 8.dp),
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = TelegramInkMuted color = TelegramInkMuted
@@ -75,28 +76,28 @@ fun DataStorageSettingsScreen(
IkarSectionTitle("Автозагрузка медиа", modifier = Modifier.padding(bottom = 12.dp)) IkarSectionTitle("Автозагрузка медиа", modifier = Modifier.padding(bottom = 12.dp))
IkarToggleRow( IkarToggleRow(
"Фото", "Фото",
"Автоматически загружать фото в чатах.", "Автозагрузка фото.",
snapshot.autoDownloadPhotos, snapshot.autoDownloadPhotos,
onCheckedChange = { value -> onSnapshotChanged { it.copy(autoDownloadPhotos = value) } } onCheckedChange = { value -> onSnapshotChanged { it.copy(autoDownloadPhotos = value) } }
) )
IkarRowDivider(startIndent = 0.dp) IkarRowDivider(startIndent = 0.dp)
IkarToggleRow( IkarToggleRow(
"Видео", "Видео",
"Автоматически загружать видео только по Wi-Fi.", "Видео только по Wi-Fi.",
snapshot.autoDownloadVideosOnWifi, snapshot.autoDownloadVideosOnWifi,
onCheckedChange = { value -> onSnapshotChanged { it.copy(autoDownloadVideosOnWifi = value) } } onCheckedChange = { value -> onSnapshotChanged { it.copy(autoDownloadVideosOnWifi = value) } }
) )
IkarRowDivider(startIndent = 0.dp) IkarRowDivider(startIndent = 0.dp)
IkarToggleRow( IkarToggleRow(
"Файлы", "Файлы",
"Автозагрузка документов только по Wi-Fi.", "Документы только по Wi-Fi.",
snapshot.autoDownloadFilesOnWifi, snapshot.autoDownloadFilesOnWifi,
onCheckedChange = { value -> onSnapshotChanged { it.copy(autoDownloadFilesOnWifi = value) } } onCheckedChange = { value -> onSnapshotChanged { it.copy(autoDownloadFilesOnWifi = value) } }
) )
IkarRowDivider(startIndent = 0.dp) IkarRowDivider(startIndent = 0.dp)
IkarToggleRow( IkarToggleRow(
"Сохранять в галерею", "Сохранять фото в галерею",
"Добавлять новые медиа в системную галерею.", "Загруженные изображения сразу появляются в альбоме Ikar.",
snapshot.saveMediaToGallery, snapshot.saveMediaToGallery,
onCheckedChange = { value -> onSnapshotChanged { it.copy(saveMediaToGallery = value) } } onCheckedChange = { value -> onSnapshotChanged { it.copy(saveMediaToGallery = value) } }
) )
@@ -104,27 +105,23 @@ fun DataStorageSettingsScreen(
IkarSectionCard { IkarSectionCard {
IkarSectionTitle("Сеть", modifier = Modifier.padding(bottom = 12.dp)) IkarSectionTitle("Сеть", modifier = Modifier.padding(bottom = 12.dp))
IkarToggleRow(
"Меньше данных для звонков",
"Снижать битрейт звонков в мобильной сети.",
snapshot.lowDataCalls,
onCheckedChange = { value -> onSnapshotChanged { it.copy(lowDataCalls = value) } }
)
IkarRowDivider(startIndent = 0.dp)
IkarValueRow( IkarValueRow(
"Прокси", "Прокси",
"SOCKS/MTProto/HTTP профиль для сетевого слоя.", "SOCKS5/HTTP профиль.",
if (snapshot.proxyEnabled) "Вкл." else "Выкл.", if (snapshot.proxyEnabled) "Вкл." else "Выкл.",
onClick = onProxyClick onClick = onProxyClick
) )
} }
Button(onClick = onResetClick, modifier = Modifier.fillMaxWidth()) { Text(
Text("Сбросить настройки") text = "Сбросить настройки",
} modifier = Modifier
Button(onClick = onBackClick, modifier = Modifier.fillMaxWidth()) { .fillMaxWidth()
Text("Готово") .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.foundation.verticalScroll
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Campaign 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.Groups
import androidx.compose.material.icons.outlined.Notifications import androidx.compose.material.icons.outlined.Notifications
import androidx.compose.material.icons.outlined.PersonOutline 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.material.icons.outlined.VolumeUp
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp 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.NotificationSettingsSnapshot
import com.seven.ikar.kotlin.core.push.NotificationSoundChoice import com.seven.ikar.kotlin.core.push.NotificationSoundChoice
import com.seven.ikar.kotlin.core.push.NotificationSoundOption import com.seven.ikar.kotlin.core.push.NotificationSoundOption
@@ -49,14 +43,9 @@ import com.seven.ikar.kotlin.ui.theme.TelegramInkMuted
fun NotificationSettingsScreen( fun NotificationSettingsScreen(
state: NotificationSettingsUiState, state: NotificationSettingsUiState,
messageSoundOptions: List<NotificationSoundChoice>, messageSoundOptions: List<NotificationSoundChoice>,
callRingtoneOptions: List<NotificationSoundChoice>,
repeatIntervalOptions: List<NotificationRepeatIntervalChoice>,
onBackClick: () -> Unit, onBackClick: () -> Unit,
onSnapshotChanged: (NotificationSettingsSnapshot) -> Unit, onSnapshotChanged: (NotificationSettingsSnapshot) -> Unit,
onMessageSoundChanged: (NotificationSoundOption) -> Unit, onMessageSoundChanged: (NotificationSoundOption) -> Unit,
onCallRingtoneChanged: (NotificationSoundOption) -> Unit,
onRepeatIntervalChanged: (NotificationRepeatIntervalOption) -> Unit,
onSaveClick: () -> Unit,
onResetClick: () -> Unit onResetClick: () -> Unit
) { ) {
val snapshot = state.snapshot val snapshot = state.snapshot
@@ -73,86 +62,59 @@ fun NotificationSettingsScreen(
IkarSimpleHeader(title = "Уведомления и звуки", onBackClick = onBackClick) IkarSimpleHeader(title = "Уведомления и звуки", onBackClick = onBackClick)
NotificationSection { NotificationSection {
IkarSectionTitle("Уведомления из чатов", modifier = Modifier.padding(bottom = 12.dp)) 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) }) IkarToggleRow(
IkarRowDivider(startIndent = 32.dp) "Уведомления",
IkarToggleRow("Группы", "Нажмите для изменения", snapshot.groupsEnabled, { onSnapshotChanged(snapshot.copy(groupsEnabled = it)) }, leading = { androidx.compose.material3.Icon(Icons.Outlined.Groups, contentDescription = null, tint = TelegramInkMuted) }) "Регистрировать устройство и показывать push-уведомления.",
IkarRowDivider(startIndent = 32.dp) snapshot.notificationsEnabled,
IkarToggleRow("Каналы", "Нажмите для изменения", snapshot.channelsEnabled, { onSnapshotChanged(snapshot.copy(channelsEnabled = it)) }, leading = { androidx.compose.material3.Icon(Icons.Outlined.Campaign, contentDescription = null, tint = TelegramInkMuted) }) { onSnapshotChanged(snapshot.copy(notificationsEnabled = it)) },
IkarRowDivider(startIndent = 32.dp) leading = { androidx.compose.material3.Icon(Icons.Outlined.Notifications, contentDescription = null, tint = TelegramInkMuted) }
IkarToggleRow("Истории", "Отключены, 5 автоматических...", snapshot.storiesEnabled, { onSnapshotChanged(snapshot.copy(storiesEnabled = it)) }, leading = { androidx.compose.material3.Icon(Icons.Outlined.Schedule, contentDescription = null, tint = TelegramInkMuted) })
IkarRowDivider(startIndent = 32.dp)
IkarToggleRow("Реакции", "Сообщения, Истории", snapshot.reactionsEnabled, { onSnapshotChanged(snapshot.copy(reactionsEnabled = it)) }, leading = { Text("", color = TelegramBlue, style = MaterialTheme.typography.titleMedium) })
}
NotificationSection {
IkarSectionTitle("Звонки", modifier = Modifier.padding(bottom = 12.dp))
IkarValueRow(
title = "Вибросигнал",
subtitle = "Переключить режим вибрации при звонке",
value = if (snapshot.callVibrationEnabled) "По умолчанию" else "Выключен",
onClick = { onSnapshotChanged(snapshot.copy(callVibrationEnabled = !snapshot.callVibrationEnabled)) },
leading = { androidx.compose.material3.Icon(Icons.Outlined.Phone, contentDescription = null, tint = TelegramInkMuted) }
) )
IkarRowDivider(startIndent = 0.dp) IkarRowDivider(startIndent = 32.dp)
IkarValueRow( IkarValueRow(
title = "Рингтон", title = "Звук сообщений",
subtitle = "Системный звук входящего звонка в Икар", subtitle = "Канал уведомлений для новых сообщений.",
value = callRingtoneOptions.firstOrNull { it.value == snapshot.callRingtone }?.title ?: "По умолчанию", value = messageSoundOptions.firstOrNull { it.value == snapshot.messageSound }?.title ?: "По умолчанию",
onClick = { onClick = if (messageSoundOptions.isEmpty()) null else ({
val currentIndex = callRingtoneOptions.indexOfFirst { it.value == snapshot.callRingtone }.coerceAtLeast(0) val currentIndex = messageSoundOptions.indexOfFirst { it.value == snapshot.messageSound }.coerceAtLeast(0)
val next = callRingtoneOptions[(currentIndex + 1) % callRingtoneOptions.size] val next = messageSoundOptions[(currentIndex + 1) % messageSoundOptions.size]
onCallRingtoneChanged(next.value) onMessageSoundChanged(next.value)
}, }),
leading = { androidx.compose.material3.Icon(Icons.Outlined.VolumeUp, contentDescription = null, tint = TelegramInkMuted) } 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 { NotificationSection {
IkarSectionTitle("Счётчик сообщений", modifier = Modifier.padding(bottom = 12.dp)) 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) 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) IkarRowDivider(startIndent = 0.dp)
IkarToggleRow("Число сообщений", "Показывать количество непрочитанных сообщений", snapshot.countMessagesInBadge, { onSnapshotChanged(snapshot.copy(countMessagesInBadge = it)) }) IkarToggleRow("Число сообщений", "Показывать сумму сообщений вместо числа чатов.", snapshot.countMessagesInBadge, { onSnapshotChanged(snapshot.copy(countMessagesInBadge = it)) })
} }
NotificationSection { NotificationSection {
IkarSectionTitle("В приложении", modifier = Modifier.padding(bottom = 12.dp)) 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) 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) IkarRowDivider(startIndent = 0.dp)
IkarToggleRow("Показывать текст", "Показывать содержимое сообщения во всплывающем окне", snapshot.inAppShowTextEnabled, { onSnapshotChanged(snapshot.copy(inAppShowTextEnabled = it)) }) IkarToggleRow("Показывать текст", "Текст во всплывающем окне", snapshot.inAppShowTextEnabled, { onSnapshotChanged(snapshot.copy(inAppShowTextEnabled = it)) })
IkarRowDivider(startIndent = 0.dp) IkarRowDivider(startIndent = 0.dp)
IkarToggleRow("Звук в чате", "Короткий сигнал для уже открытого чата", snapshot.soundInActiveChatEnabled, { onSnapshotChanged(snapshot.copy(soundInActiveChatEnabled = it)) }) IkarToggleRow("Звук в чате", "Сигнал в открытом чате", snapshot.soundInActiveChatEnabled, { onSnapshotChanged(snapshot.copy(soundInActiveChatEnabled = it)) })
IkarRowDivider(startIndent = 0.dp) IkarRowDivider(startIndent = 0.dp)
IkarToggleRow("Всплывающие окна", "Показывать всплывающие окна, когда приложение открыто", snapshot.popupWindowsEnabled, { onSnapshotChanged(snapshot.copy(popupWindowsEnabled = it)) }) IkarToggleRow("Всплывающие окна", "Показывать поверх приложения", snapshot.popupWindowsEnabled, { onSnapshotChanged(snapshot.copy(popupWindowsEnabled = it)) })
}
NotificationSection {
IkarSectionTitle("События", modifier = Modifier.padding(bottom = 12.dp))
IkarToggleRow("Контакт присоединился к Ikar", "Сообщать, когда контакт появился в Ikar", snapshot.contactJoinedEnabled, { onSnapshotChanged(snapshot.copy(contactJoinedEnabled = it)) })
IkarRowDivider(startIndent = 0.dp)
IkarToggleRow("Закреплённые сообщения", "Показывать уведомления о закреплённых сообщениях", snapshot.pinnedMessagesEnabled, { onSnapshotChanged(snapshot.copy(pinnedMessagesEnabled = it)) })
}
NotificationSection {
IkarSectionTitle("Другое", modifier = Modifier.padding(bottom = 12.dp))
IkarToggleRow("Перезапуск при закрытии", "Поднимать фоновый сервис доставки уведомлений", snapshot.restartOnCloseEnabled, { onSnapshotChanged(snapshot.copy(restartOnCloseEnabled = it)) })
IkarRowDivider(startIndent = 0.dp)
IkarToggleRow("Фоновое соединение", "Держать минимальное фоновое соединение", snapshot.backgroundConnectionEnabled, { onSnapshotChanged(snapshot.copy(backgroundConnectionEnabled = it)) })
IkarRowDivider(startIndent = 0.dp)
IkarValueRow(
title = "Повтор уведомлений",
subtitle = "Повторять уведомление, пока в чате есть непрочитанные сообщения",
value = repeatIntervalOptions.firstOrNull { it.value == snapshot.repeatInterval }?.title ?: "1 час",
onClick = {
val currentIndex = repeatIntervalOptions.indexOfFirst { it.value == snapshot.repeatInterval }.coerceAtLeast(0)
val next = repeatIntervalOptions[(currentIndex + 1) % repeatIntervalOptions.size]
onRepeatIntervalChanged(next.value)
}
)
} }
NotificationSection { NotificationSection {
@@ -166,16 +128,12 @@ fun NotificationSettingsScreen(
style = MaterialTheme.typography.titleLarge style = MaterialTheme.typography.titleLarge
) )
Text( Text(
"Сбросить все особые настройки уведомлений для отдельных контактов, групп и каналов.", "Сбросить особые настройки контактов, групп и каналов.",
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = TelegramInkMuted color = TelegramInkMuted
) )
} }
androidx.compose.material3.Button(onClick = onSaveClick, modifier = Modifier.fillMaxWidth()) {
Text(if (state.isSaving) "Сохранение..." else "Сохранить")
}
state.errorMessage?.let { state.errorMessage?.let {
Text(it, color = TelegramDanger, style = MaterialTheme.typography.bodyMedium) Text(it, color = TelegramDanger, style = MaterialTheme.typography.bodyMedium)
} }
@@ -4,8 +4,6 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.seven.ikar.kotlin.app.AppContainer import com.seven.ikar.kotlin.app.AppContainer
import com.seven.ikar.kotlin.core.push.NotificationPreferenceCatalog 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.NotificationSettingsSnapshot
import com.seven.ikar.kotlin.core.push.NotificationSoundChoice import com.seven.ikar.kotlin.core.push.NotificationSoundChoice
import com.seven.ikar.kotlin.core.push.NotificationSoundOption 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 uiState: StateFlow<NotificationSettingsUiState> = _uiState.asStateFlow()
val messageSoundOptions: List<NotificationSoundChoice> = NotificationPreferenceCatalog.messageSoundChoices val messageSoundOptions: List<NotificationSoundChoice> = NotificationPreferenceCatalog.messageSoundChoices
val callRingtoneOptions: List<NotificationSoundChoice> = NotificationPreferenceCatalog.callRingtoneChoices private var saveRevision = 0
val repeatIntervalOptions: List<NotificationRepeatIntervalChoice> = NotificationPreferenceCatalog.repeatChoices
fun updateSnapshot(transform: (NotificationSettingsSnapshot) -> NotificationSettingsSnapshot) { 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 setMessageSound(value: NotificationSoundOption) = updateSnapshot { it.copy(messageSound = value) }
fun setCallRingtone(value: NotificationSoundOption) = updateSnapshot { it.copy(callRingtone = value) } private fun persist(snapshot: NotificationSettingsSnapshot) {
val revision = ++saveRevision
fun setRepeatInterval(value: NotificationRepeatIntervalOption) = updateSnapshot { it.copy(repeatInterval = value) }
fun save() {
viewModelScope.launch { viewModelScope.launch {
val snapshot = _uiState.value.snapshot
_uiState.value = _uiState.value.copy(isSaving = true, errorMessage = null, infoMessage = null) _uiState.value = _uiState.value.copy(isSaving = true, errorMessage = null, infoMessage = null)
runCatching { runCatching {
container.pushSettings.saveSnapshot(snapshot) container.pushSettings.saveSnapshot(snapshot)
container.pushRegistrationManager.registerCurrentDeviceAsync() container.pushRegistrationManager.registerCurrentDeviceAsync()
}.onSuccess { }.onSuccess {
_uiState.value = _uiState.value.copy(isSaving = false, infoMessage = "Настройки уведомлений сохранены.") if (revision == saveRevision) {
_uiState.value = _uiState.value.copy(isSaving = false, infoMessage = null)
}
}.onFailure { error -> }.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() { fun reset() {
container.pushSettings.resetNotificationPreferences() 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.only
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawing import androidx.compose.foundation.layout.safeDrawing
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons 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.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.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.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
@@ -31,6 +44,7 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color 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.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@@ -58,7 +72,6 @@ fun SettingsScreen(
onChatSettingsClick: () -> Unit, onChatSettingsClick: () -> Unit,
onPrivacyClick: () -> Unit, onPrivacyClick: () -> Unit,
onNotificationsClick: () -> Unit, onNotificationsClick: () -> Unit,
onBotsClick: () -> Unit,
onFoldersClick: () -> Unit, onFoldersClick: () -> Unit,
onDevicesClick: () -> Unit, onDevicesClick: () -> Unit,
onUpdatesClick: () -> Unit, onUpdatesClick: () -> Unit,
@@ -76,7 +89,6 @@ fun SettingsScreen(
onChatSettingsClick, onChatSettingsClick,
onPrivacyClick, onPrivacyClick,
onNotificationsClick, onNotificationsClick,
onBotsClick,
onFoldersClick, onFoldersClick,
onDevicesClick, onDevicesClick,
onUpdatesClick, onUpdatesClick,
@@ -92,31 +104,24 @@ fun SettingsScreen(
subtitle = "Телефон, имя и описание профиля", subtitle = "Телефон, имя и описание профиля",
backgroundColor = Color(0xFFE6F1FD), backgroundColor = Color(0xFFE6F1FD),
accentColor = TelegramBlue, accentColor = TelegramBlue,
letter = "А", icon = Icons.Outlined.AccountCircle,
onClick = onAccountClick, onClick = onAccountClick,
keywords = listOf("телефон", "номер", "имя", "username", "описание", "профиль") keywords = listOf("телефон", "номер", "имя", "username", "описание", "профиль")
), ),
SettingsEntryModel( SettingsEntryModel(
title = "Настройки чатов", title = "Настройки чатов",
subtitle = "Обои, список чатов, жесты и медиа", subtitle = "Обои, список чатов, текст, медиа и Enter",
backgroundColor = Color(0xFFFFF8ECD8), backgroundColor = Color(0xFFFFF8EC),
accentColor = Color(0xFFD7812B), accentColor = Color(0xFFD7812B),
letter = "Ч", icon = Icons.Outlined.Tune,
onClick = onChatSettingsClick, onClick = onChatSettingsClick,
keywords = listOf( keywords = listOf(
"обои", "обои",
"список чатов", "список чатов",
"жесты",
"медиа", "медиа",
"текст сообщений", "текст сообщений",
"углы", "углы",
"свайп", "отправка по enter"
"встроенный браузер",
"анимации",
"direct share",
"отправка по enter",
"микрофон",
"18+"
) )
), ),
SettingsEntryModel( SettingsEntryModel(
@@ -124,7 +129,7 @@ fun SettingsScreen(
subtitle = "Контакты и отображение имён в чатах", subtitle = "Контакты и отображение имён в чатах",
backgroundColor = Color(0xFFE9F7E5), backgroundColor = Color(0xFFE9F7E5),
accentColor = Color(0xFF44A948), accentColor = Color(0xFF44A948),
letter = "К", icon = Icons.Outlined.Security,
onClick = onPrivacyClick, onClick = onPrivacyClick,
keywords = listOf("контакты", "имена", "приватность", "конфиденциальность") keywords = listOf("контакты", "имена", "приватность", "конфиденциальность")
), ),
@@ -133,7 +138,7 @@ fun SettingsScreen(
subtitle = "Звуки, звонки и счётчик сообщений", subtitle = "Звуки, звонки и счётчик сообщений",
backgroundColor = Color(0xFFFDE8EA), backgroundColor = Color(0xFFFDE8EA),
accentColor = Color(0xFFDC4E5C), accentColor = Color(0xFFDC4E5C),
letter = "У", icon = Icons.Outlined.Notifications,
onClick = onNotificationsClick, onClick = onNotificationsClick,
keywords = listOf("уведомления", "звук", "звонки", "push", "счётчик") keywords = listOf("уведомления", "звук", "звонки", "push", "счётчик")
), ),
@@ -142,25 +147,16 @@ fun SettingsScreen(
subtitle = "Проверка новой версии Икара и установка из Argus", subtitle = "Проверка новой версии Икара и установка из Argus",
backgroundColor = Color(0xFFE8F3FF), backgroundColor = Color(0xFFE8F3FF),
accentColor = TelegramBlue, accentColor = TelegramBlue,
letter = "О", icon = Icons.Outlined.SystemUpdate,
onClick = onUpdatesClick, onClick = onUpdatesClick,
keywords = listOf("обновление", "version", "apk", "argus", "релиз", "установка") keywords = listOf("обновление", "version", "apk", "argus", "релиз", "установка")
), ),
SettingsEntryModel(
title = "Боты",
subtitle = "Создание, токены и команды API ботов",
backgroundColor = Color(0xFFEAF4FF),
accentColor = TelegramBlue,
letter = "Б",
onClick = onBotsClick,
keywords = listOf("bot api", "токен", "команды", "бот", "боты")
),
SettingsEntryModel( SettingsEntryModel(
title = "Данные и память", title = "Данные и память",
subtitle = "Кэш и локальные медиафайлы", subtitle = "Кэш и локальные медиафайлы",
backgroundColor = Color(0xFFEAF0FD), backgroundColor = Color(0xFFEAF0FD),
accentColor = Color(0xFF4B76E4), accentColor = Color(0xFF4B76E4),
letter = "Д", icon = Icons.Outlined.Storage,
onClick = onDataStorageClick, onClick = onDataStorageClick,
keywords = listOf("кэш", "память", "данные", "медиафайлы", "хранилище") keywords = listOf("кэш", "память", "данные", "медиафайлы", "хранилище")
), ),
@@ -169,34 +165,25 @@ fun SettingsScreen(
subtitle = "Стартовый фильтр для списка чатов", subtitle = "Стартовый фильтр для списка чатов",
backgroundColor = Color(0xFFEEF5FD), backgroundColor = Color(0xFFEEF5FD),
accentColor = Color(0xFF5293F1), accentColor = Color(0xFF5293F1),
letter = "П", icon = Icons.Outlined.Folder,
onClick = onFoldersClick, onClick = onFoldersClick,
keywords = listOf("папки", "фильтр", "список чатов", "архив") keywords = listOf("папки", "фильтр", "список чатов", "архив")
), ),
SettingsEntryModel( SettingsEntryModel(
title = "Устройства", title = "Устройства",
subtitle = "Устройства уведомлений и их состояние", subtitle = "Где используется Икар и состояние уведомлений",
backgroundColor = Color(0xFFEAF7FB), backgroundColor = Color(0xFFEAF7FB),
accentColor = Color(0xFF49A8C2), accentColor = Color(0xFF49A8C2),
letter = "У", icon = Icons.Outlined.Devices,
onClick = onDevicesClick, onClick = onDevicesClick,
keywords = listOf("устройства", "push", "сессии", "авторизация") keywords = listOf("устройства", "push", "телефон", "планшет", "компьютер")
),
SettingsEntryModel(
title = "Энергосбережение",
subtitle = "Фоновое обновление и прогрев превью",
backgroundColor = Color(0xFFFFF1E6),
accentColor = Color(0xFFD7812B),
letter = "Э",
onClick = null,
keywords = listOf("батарея", "энергосбережение", "фон", "превью")
), ),
SettingsEntryModel( SettingsEntryModel(
title = "Истории", title = "Истории",
subtitle = "Лента, редактор, срок жизни и защищённые истории", subtitle = "Лента, редактор, срок жизни и защищённые истории",
backgroundColor = Color(0xFFEAF4FF), backgroundColor = Color(0xFFEAF4FF),
accentColor = TelegramBlue, accentColor = TelegramBlue,
letter = "И", icon = Icons.Outlined.AutoStories,
onClick = onStoriesClick, onClick = onStoriesClick,
keywords = listOf("stories", "истории", "сторис", "композер", "story") keywords = listOf("stories", "истории", "сторис", "композер", "story")
), ),
@@ -205,27 +192,27 @@ fun SettingsScreen(
subtitle = "Тема, акцент, эмодзи и анимированные фоны", subtitle = "Тема, акцент, эмодзи и анимированные фоны",
backgroundColor = Color(0xFFE9F7E5), backgroundColor = Color(0xFFE9F7E5),
accentColor = Color(0xFF44A948), accentColor = Color(0xFF44A948),
letter = "Т", icon = Icons.Outlined.Palette,
onClick = onThemeClick, onClick = onThemeClick,
keywords = listOf("theme", "тема", "ночная", "оформление") keywords = listOf("theme", "тема", "ночная", "оформление")
), ),
SettingsEntryModel( SettingsEntryModel(
title = "Язык", title = "Язык",
subtitle = "Русский", subtitle = "Русский, английский или язык устройства",
backgroundColor = Color(0xFFF3EAFF), backgroundColor = Color(0xFFFFF8EC),
accentColor = Color(0xFFA25FE0), accentColor = Color(0xFFD7812B),
letter = "Я", icon = Icons.Outlined.Language,
onClick = onLanguageClick, onClick = onLanguageClick,
keywords = listOf("язык", "русский", "локаль") keywords = listOf("language", "язык", "русский", "английский", "локализация")
), ),
SettingsEntryModel( SettingsEntryModel(
title = "Прокси", title = "Прокси",
subtitle = "SOCKS/MTProto/HTTP профиль подключения", subtitle = "SOCKS5/HTTP профиль подключения",
backgroundColor = Color(0xFFEAF7FB), backgroundColor = Color(0xFFEAF7FB),
accentColor = Color(0xFF49A8C2), accentColor = Color(0xFF49A8C2),
letter = "П", icon = Icons.Outlined.VpnKey,
onClick = onProxyClick, onClick = onProxyClick,
keywords = listOf("proxy", "прокси", "socks", "mtproto", "сеть") keywords = listOf("proxy", "прокси", "socks", "http", "сеть")
) )
) )
} }
@@ -289,7 +276,7 @@ fun SettingsScreen(
subtitle = entry.subtitle, subtitle = entry.subtitle,
backgroundColor = entry.backgroundColor, backgroundColor = entry.backgroundColor,
accentColor = entry.accentColor, accentColor = entry.accentColor,
letter = entry.letter, icon = entry.icon,
onClick = entry.onClick onClick = entry.onClick
) )
if (index != visibleEntries.lastIndex) { if (index != visibleEntries.lastIndex) {
@@ -387,7 +374,7 @@ private data class SettingsEntryModel(
val subtitle: String, val subtitle: String,
val backgroundColor: Color, val backgroundColor: Color,
val accentColor: Color, val accentColor: Color,
val letter: String, val icon: ImageVector,
val onClick: (() -> Unit)?, val onClick: (() -> Unit)?,
val keywords: List<String> val keywords: List<String>
) )
@@ -437,7 +424,7 @@ private fun SettingsEntry(
subtitle: String, subtitle: String,
backgroundColor: Color, backgroundColor: Color,
accentColor: Color, accentColor: Color,
letter: String, icon: ImageVector,
onClick: (() -> Unit)? onClick: (() -> Unit)?
) { ) {
IkarSettingRow( IkarSettingRow(
@@ -451,11 +438,11 @@ private fun SettingsEntry(
shape = CircleShape shape = CircleShape
) { ) {
Box(contentAlignment = Alignment.Center) { Box(contentAlignment = Alignment.Center) {
Text( Icon(
text = letter, imageVector = icon,
style = MaterialTheme.typography.titleMedium, contentDescription = null,
color = accentColor, tint = accentColor,
fontWeight = FontWeight.SemiBold modifier = Modifier.size(22.dp)
) )
} }
} }
@@ -1,10 +1,15 @@
package com.seven.ikar.kotlin.feature.share 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.clickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.navigationBarsPadding 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.layout.statusBarsPadding
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp 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.contacts.DiscoverUserItem
import com.seven.ikar.kotlin.ui.AvatarView import com.seven.ikar.kotlin.ui.AvatarView
import com.seven.ikar.kotlin.ui.IkarRowDivider import com.seven.ikar.kotlin.ui.IkarRowDivider
@@ -37,9 +45,18 @@ fun ShareTargetScreen(
state: ShareTargetUiState, state: ShareTargetUiState,
onBackClick: () -> Unit, onBackClick: () -> Unit,
onQueryChanged: (String) -> Unit, onQueryChanged: (String) -> Unit,
onChatClick: (ShareChatTargetItemUiState) -> Unit, onRefresh: () -> Unit,
onUserClick: (DiscoverUserItem) -> 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()) { IkarScreenSurface(modifier = Modifier.fillMaxSize()) {
Column( Column(
modifier = Modifier modifier = Modifier
@@ -56,11 +73,26 @@ fun ShareTargetScreen(
IkarSearchCard( IkarSearchCard(
value = state.query, value = state.query,
onValueChange = onQueryChanged, onValueChange = onQueryChanged,
placeholder = "Поиск чата или контакта" placeholder = "Поиск контакта"
) )
SharePreviewCard(state.payload) 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 { state.errorMessage?.let {
Text(it, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodyMedium) Text(it, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodyMedium)
} }
@@ -74,7 +106,7 @@ fun ShareTargetScreen(
padding = PaddingValues(0.dp) padding = PaddingValues(0.dp)
) { ) {
LazyColumn { LazyColumn {
if (state.isLoading && state.chats.isEmpty() && state.contacts.isEmpty()) { if (state.isLoading && state.contacts.isEmpty()) {
item { item {
Row( Row(
modifier = Modifier 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()) { if (state.contacts.isNotEmpty()) {
item { item {
SectionLabel("Контакты") SectionLabel("Контакты")
@@ -106,7 +129,7 @@ fun ShareTargetScreen(
} }
} }
if (!state.isLoading && state.chats.isEmpty() && state.contacts.isEmpty()) { if (!state.isLoading && state.contacts.isEmpty()) {
item { item {
Column( Column(
modifier = Modifier modifier = Modifier
@@ -115,7 +138,7 @@ fun ShareTargetScreen(
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(8.dp) verticalArrangement = Arrangement.spacedBy(8.dp)
) { ) {
Text("Подходящих получателей нет", style = MaterialTheme.typography.headlineMedium) Text("Подходящих контактов нет", style = MaterialTheme.typography.headlineMedium)
Text( Text(
"Уточните поиск или откройте доступ к контактам.", "Уточните поиск или откройте доступ к контактам.",
color = TelegramInkMuted, 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 @Composable
private fun ShareContactRow( private fun ShareContactRow(
item: DiscoverUserItem, 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.contacts.PhoneNumberNormalizer
import com.seven.ikar.kotlin.core.model.ChatSummaryDto import com.seven.ikar.kotlin.core.model.ChatSummaryDto
import com.seven.ikar.kotlin.core.model.ChatType 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.Job
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
@@ -19,17 +17,9 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
data class ShareChatTargetItemUiState(
val chat: ChatSummaryDto,
val resolvedTitle: String,
val lastActivityLabel: String,
val preview: String
)
data class ShareTargetUiState( data class ShareTargetUiState(
val payload: PendingExternalShare, val payload: PendingExternalShare,
val query: String = "", val query: String = "",
val chats: List<ShareChatTargetItemUiState> = emptyList(),
val contacts: List<DiscoverUserItem> = emptyList(), val contacts: List<DiscoverUserItem> = emptyList(),
val isLoading: Boolean = false, val isLoading: Boolean = false,
val isSending: Boolean = false, val isSending: Boolean = false,
@@ -43,8 +33,6 @@ class ShareTargetViewModel(
) : ViewModel() { ) : ViewModel() {
private val _uiState = MutableStateFlow(ShareTargetUiState(payload = pendingShare)) private val _uiState = MutableStateFlow(ShareTargetUiState(payload = pendingShare))
private val _allChats = mutableListOf<ChatSummaryDto>() 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 private var searchJob: Job? = null
val uiState: StateFlow<ShareTargetUiState> = _uiState.asStateFlow() val uiState: StateFlow<ShareTargetUiState> = _uiState.asStateFlow()
@@ -67,18 +55,14 @@ class ShareTargetViewModel(
val query = _uiState.value.query.trim() val query = _uiState.value.query.trim()
_uiState.value = _uiState.value.copy(isLoading = true, errorMessage = null) _uiState.value = _uiState.value.copy(isLoading = true, errorMessage = null)
runCatching { runCatching {
contactNamesByNormalizedPhone = loadDeviceContactNames()
val chats = container.executeAuthorized { session -> val chats = container.executeAuthorized { session ->
currentUserId = session.user.id
container.apiClient.getChats(session.accessToken) container.apiClient.getChats(session.accessToken)
} }
_allChats.clear() _allChats.clear()
_allChats.addAll(chats) _allChats.addAll(chats)
val contacts = loadContactCandidates(query) loadContactCandidates(query)
chats to contacts }.onSuccess { contacts ->
}.onSuccess { (chats, contacts) ->
_uiState.value = _uiState.value.copy( _uiState.value = _uiState.value.copy(
chats = filterChats(chats, query),
contacts = contacts, contacts = contacts,
isLoading = false, isLoading = false,
errorMessage = null errorMessage = null
@@ -89,10 +73,6 @@ class ShareTargetViewModel(
} }
} }
fun sendToChat(chat: ShareChatTargetItemUiState) {
sendToChatId(chat.chat.id)
}
fun sendToUser(user: DiscoverUserItem) { fun sendToUser(user: DiscoverUserItem) {
if (_uiState.value.isSending) { if (_uiState.value.isSending) {
return return
@@ -119,26 +99,6 @@ class ShareTargetViewModel(
_uiState.value = _uiState.value.copy(pendingChatId = null) _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) { private suspend fun sendPendingShare(accessToken: String, chatId: String) {
val sharedText = pendingShare.text?.trim().orEmpty() val sharedText = pendingShare.text?.trim().orEmpty()
if (pendingShare.attachments.isEmpty()) { 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> { private suspend fun loadContactCandidates(query: String): List<DiscoverUserItem> {
val candidates = when { return when {
hasContactsPermission() -> container.resolvedContactsRepository.loadMergedCandidates(query) hasContactsPermission() -> container.resolvedContactsRepository.loadMergedCandidates(query)
query.isBlank() -> emptyList() query.isBlank() -> emptyList()
else -> container.executeAuthorized { session -> 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? = private fun findExistingDirectChatId(userId: String): String? =
@@ -221,16 +136,6 @@ class ShareTargetViewModel(
chat.type == ChatType.Direct && chat.participants.any { participant -> participant.id == userId } chat.type == ChatType.Direct && chat.participants.any { participant -> participant.id == userId }
}?.id }?.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 = private fun hasContactsPermission(): Boolean =
ContextCompat.checkSelfPermission( ContextCompat.checkSelfPermission(
container.appContext, container.appContext,
@@ -18,6 +18,7 @@ import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.only import androidx.compose.foundation.layout.only
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawing 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.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicTextField 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.foundation.verticalScroll
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Add 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.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable 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.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight 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.TextAlign
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties 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 coil.compose.AsyncImage
import com.seven.ikar.kotlin.core.model.AttachmentDto import com.seven.ikar.kotlin.core.model.AttachmentDto
import com.seven.ikar.kotlin.core.model.StoryDto import com.seven.ikar.kotlin.core.model.StoryDto
import com.seven.ikar.kotlin.core.model.StoryViewDto
import com.seven.ikar.kotlin.core.model.StoryVisibility import com.seven.ikar.kotlin.core.model.StoryVisibility
import com.seven.ikar.kotlin.core.network.ServerConfig import com.seven.ikar.kotlin.core.network.ServerConfig
import com.seven.ikar.kotlin.ui.AuthenticatedAsyncImage 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.SecureWindowEffect
import com.seven.ikar.kotlin.ui.theme.TelegramBlue import com.seven.ikar.kotlin.ui.theme.TelegramBlue
import com.seven.ikar.kotlin.ui.theme.TelegramInkMuted import com.seven.ikar.kotlin.ui.theme.TelegramInkMuted
import kotlinx.coroutines.delay
import java.io.File import java.io.File
private const val StoryAutoAdvanceDurationMillis = 5_000L
private const val StoryAutoAdvanceTickMillis = 50L
@Composable @Composable
fun StoriesScreen( fun StoriesScreen(
state: StoriesUiState, state: StoriesUiState,
@@ -85,6 +111,11 @@ fun StoriesScreen(
onCloseStoryPreview: () -> Unit, onCloseStoryPreview: () -> Unit,
onNextStory: () -> Unit, onNextStory: () -> Unit,
onPreviousStory: () -> Unit, onPreviousStory: () -> Unit,
onStoryReplyTextChanged: (String) -> Unit,
onSendStoryReply: (StoryDto) -> Unit,
onSendStoryReaction: (StoryDto, String) -> Unit,
onShowStoryViews: (StoryDto) -> Unit,
onCloseStoryViews: () -> Unit,
onRetryTransfer: (String) -> Unit, onRetryTransfer: (String) -> Unit,
onCancelTransfer: (String) -> Unit onCancelTransfer: (String) -> Unit
) { ) {
@@ -96,9 +127,22 @@ fun StoriesScreen(
stories = state.stories, stories = state.stories,
accessToken = state.accessToken, accessToken = state.accessToken,
isOwnStory = story.author.id == state.currentUserId, 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, onClose = onCloseStoryPreview,
onNextStory = onNextStory, onNextStory = onNextStory,
onPreviousStory = onPreviousStory, onPreviousStory = onPreviousStory,
onReplyTextChanged = onStoryReplyTextChanged,
onSendReply = { onSendStoryReply(story) },
onSendReaction = { emoji -> onSendStoryReaction(story, emoji) },
onShowStoryViews = { onShowStoryViews(story) },
onCloseStoryViews = onCloseStoryViews,
onDeleteStory = { onDeleteStory(story) } onDeleteStory = { onDeleteStory(story) }
) )
} }
@@ -346,9 +390,22 @@ private fun StoryViewerDialog(
stories: List<StoryDto>, stories: List<StoryDto>,
accessToken: String?, accessToken: String?,
isOwnStory: Boolean, isOwnStory: Boolean,
replyText: String,
isSendingReply: Boolean,
replyErrorMessage: String?,
replySent: Boolean,
storyViews: List<StoryViewDto>,
isLoadingStoryViews: Boolean,
storyViewsErrorMessage: String?,
showStoryViews: Boolean,
onClose: () -> Unit, onClose: () -> Unit,
onNextStory: () -> Unit, onNextStory: () -> Unit,
onPreviousStory: () -> Unit, onPreviousStory: () -> Unit,
onReplyTextChanged: (String) -> Unit,
onSendReply: () -> Unit,
onSendReaction: (String) -> Unit,
onShowStoryViews: () -> Unit,
onCloseStoryViews: () -> Unit,
onDeleteStory: () -> Unit onDeleteStory: () -> Unit
) { ) {
Dialog( Dialog(
@@ -356,26 +413,64 @@ private fun StoryViewerDialog(
properties = DialogProperties(usePlatformDefaultWidth = false) properties = DialogProperties(usePlatformDefaultWidth = false)
) { ) {
BackHandler(onBack = onClose) 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( Box(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.background(Color.Black) .background(Color.Black)
) { ) {
StoryViewerContent(story = story, accessToken = accessToken) StoryViewerContent(story = story, accessToken = accessToken, mediaIndex = safeMediaIndex)
Row(modifier = Modifier.fillMaxSize()) { Row(modifier = Modifier.fillMaxSize()) {
Box( Box(
modifier = Modifier modifier = Modifier
.weight(1f) .weight(1f)
.fillMaxSize() .fillMaxSize()
.clickable(onClick = onPreviousStory) .clickable {
if (safeMediaIndex > 0) {
selectedMediaIndex = safeMediaIndex - 1
} else {
onPreviousStory()
}
}
) )
Box( Box(
modifier = Modifier modifier = Modifier
.weight(1f) .weight(1f)
.fillMaxSize() .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, stories = stories,
accessToken = accessToken, accessToken = accessToken,
isOwnStory = isOwnStory, isOwnStory = isOwnStory,
mediaIndex = safeMediaIndex,
mediaCount = story.attachments.size,
progress = autoProgress,
onClose = onClose, onClose = onClose,
onDeleteStory = onDeleteStory onDeleteStory = onDeleteStory
) )
StoryViewerCaption( StoryViewerBottomBar(
story = story, 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) modifier = Modifier.align(Alignment.BottomCenter)
) )
} }
@@ -397,34 +510,38 @@ private fun StoryViewerDialog(
} }
@Composable @Composable
private fun StoryViewerContent(story: StoryDto, accessToken: String?) { private fun StoryViewerContent(story: StoryDto, accessToken: String?, mediaIndex: Int) {
val imageAttachment = story.attachments.firstOrNull { it.contentType.startsWith("image/") } val attachment = story.attachments.getOrNull(mediaIndex) ?: story.attachments.firstOrNull()
if (imageAttachment != null) { if (attachment != null && attachment.contentType.startsWith("image/", ignoreCase = true)) {
AuthenticatedAsyncImage( AuthenticatedAsyncImage(
imageUrl = ServerConfig.normalizeMediaUrl(imageAttachment.downloadPath), imageUrl = ServerConfig.normalizeMediaUrl(attachment.downloadPath),
accessToken = accessToken, accessToken = accessToken,
contentDescription = imageAttachment.fileName, contentDescription = attachment.fileName,
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop contentScale = ContentScale.Crop
) )
return return
} }
if (attachment != null && attachment.contentType.startsWith("video/", ignoreCase = true)) {
StoryVideoPlayer(attachment = attachment, accessToken = accessToken)
return
}
Box( Box(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.padding(28.dp), .padding(28.dp),
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
if (story.attachments.isNotEmpty()) { if (attachment != null) {
val first = story.attachments.first()
Column( Column(
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(10.dp) verticalArrangement = Arrangement.spacedBy(10.dp)
) { ) {
Text("Видео", style = MaterialTheme.typography.headlineMedium, color = Color.White) Text("Видео", style = MaterialTheme.typography.headlineMedium, color = Color.White)
Text( Text(
first.fileName, attachment.fileName,
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
color = Color.White.copy(alpha = 0.72f), color = Color.White.copy(alpha = 0.72f),
textAlign = TextAlign.Center, 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 @Composable
private fun StoryViewerTopBar( private fun StoryViewerTopBar(
story: StoryDto, story: StoryDto,
stories: List<StoryDto>, stories: List<StoryDto>,
accessToken: String?, accessToken: String?,
isOwnStory: Boolean, isOwnStory: Boolean,
mediaIndex: Int,
mediaCount: Int,
progress: Float,
onClose: () -> Unit, onClose: () -> Unit,
onDeleteStory: () -> Unit onDeleteStory: () -> Unit
) { ) {
@@ -459,7 +622,13 @@ private fun StoryViewerTopBar(
.padding(horizontal = 14.dp, vertical = 12.dp), .padding(horizontal = 14.dp, vertical = 12.dp),
verticalArrangement = Arrangement.spacedBy(12.dp) verticalArrangement = Arrangement.spacedBy(12.dp)
) { ) {
StoryProgressSegments(story = story, stories = stories) StoryProgressSegments(
story = story,
stories = stories,
mediaIndex = mediaIndex,
mediaCount = mediaCount,
progress = progress
)
Row( Row(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
@@ -498,41 +667,111 @@ private fun StoryViewerTopBar(
} }
@Composable @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 selectedIndex = stories.indexOfFirst { it.id == story.id }
val firstIndex = if (selectedIndex < 0) 0 else (selectedIndex - 6).coerceAtLeast(0) val firstIndex = if (selectedIndex < 0) 0 else (selectedIndex - 6).coerceAtLeast(0)
val segmentStories = stories.drop(firstIndex).take(12).ifEmpty { listOf(story) } 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()) { Row(horizontalArrangement = Arrangement.spacedBy(4.dp), modifier = Modifier.fillMaxWidth()) {
segmentStories.forEach { item -> segmentStories.forEachIndexed { index, _ ->
Box( StoryProgressSegment(
modifier = Modifier progress = when {
.weight(1f) index < activeSegmentIndex -> 1f
.height(3.dp) index == activeSegmentIndex -> progress
.clip(RoundedCornerShape(2.dp)) else -> 0f
.background(if (item.id == story.id) Color.White else Color.White.copy(alpha = 0.34f)) },
modifier = Modifier.weight(1f)
) )
} }
} }
} }
@Composable @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( Column(
modifier = modifier modifier = modifier
.fillMaxWidth() .fillMaxWidth()
.imePadding()
.background(Color.Black.copy(alpha = 0.54f)) .background(Color.Black.copy(alpha = 0.54f))
.padding(horizontal = 18.dp, vertical = 16.dp), .padding(horizontal = 18.dp, vertical = 16.dp),
verticalArrangement = Arrangement.spacedBy(6.dp) verticalArrangement = Arrangement.spacedBy(10.dp)
) { ) {
if (story.attachments.isNotEmpty() && story.text.isNotBlank()) { if (story.attachments.isNotEmpty() && story.text.isNotBlank()) {
Text(story.text, style = MaterialTheme.typography.bodyLarge, color = Color.White) Text(story.text, style = MaterialTheme.typography.bodyLarge, color = Color.White)
} }
Row(verticalAlignment = Alignment.CenterVertically) { Row(verticalAlignment = Alignment.CenterVertically) {
Text( if (isOwnStory) {
"${story.viewCount} просмотров", TextButton(onClick = if (showStoryViews) onCloseStoryViews else onShowStoryViews) {
style = MaterialTheme.typography.bodySmall, Text(
color = Color.White.copy(alpha = 0.72f) 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) { if (story.hasProtectedContent) {
Spacer(modifier = Modifier.width(10.dp)) Spacer(modifier = Modifier.width(10.dp))
Text( 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.app.AppContainer
import com.seven.ikar.kotlin.core.local.TransferQueueEntity import com.seven.ikar.kotlin.core.local.TransferQueueEntity
import com.seven.ikar.kotlin.core.model.StoryDto import com.seven.ikar.kotlin.core.model.StoryDto
import com.seven.ikar.kotlin.core.model.StoryViewDto
import com.seven.ikar.kotlin.core.model.StoryVisibility import com.seven.ikar.kotlin.core.model.StoryVisibility
import com.seven.ikar.kotlin.feature.chat.LocalAttachmentFile import com.seven.ikar.kotlin.feature.chat.LocalAttachmentFile
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
@@ -43,7 +44,16 @@ data class StoriesUiState(
val isImportingMedia: Boolean = false, val isImportingMedia: Boolean = false,
val errorMessage: String? = null, val errorMessage: String? = null,
val createdStoryId: 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() { class StoriesViewModel(private val container: AppContainer) : ViewModel() {
@@ -54,6 +64,7 @@ class StoriesViewModel(private val container: AppContainer) : ViewModel() {
init { init {
attachStoryTransferState() attachStoryTransferState()
attachRealtime()
refresh() 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) _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) { fun setVisibility(visibility: StoryVisibility) {
_uiState.value = _uiState.value.copy(visibility = visibility) _uiState.value = _uiState.value.copy(visibility = visibility)
} }
@@ -182,6 +201,7 @@ class StoriesViewModel(private val container: AppContainer) : ViewModel() {
isSaving = false, isSaving = false,
createdStoryId = story.id createdStoryId = story.id
) )
container.realtimeService.notifyStoriesChangedLocally()
onCreated() onCreated()
}.onFailure { error -> }.onFailure { error ->
_uiState.value = _uiState.value.copy(isSaving = false, errorMessage = error.message) _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) { fun openStory(story: StoryDto) {
viewModelScope.launch { 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 { runCatching {
container.executeAuthorized { session -> container.executeAuthorized { session ->
container.apiClient.getStory(session.accessToken, story.id) container.apiClient.getStory(session.accessToken, story.id)
@@ -199,6 +230,7 @@ class StoriesViewModel(private val container: AppContainer) : ViewModel() {
}.onSuccess { viewedStory -> }.onSuccess { viewedStory ->
_uiState.value = _uiState.value.copy( _uiState.value = _uiState.value.copy(
selectedStory = viewedStory, selectedStory = viewedStory,
selectedStoryAuthorId = viewedStory.author.id,
stories = _uiState.value.stories.map { if (it.id == viewedStory.id) viewedStory else it } stories = _uiState.value.stories.map { if (it.id == viewedStory.id) viewedStory else it }
) )
}.onFailure { error -> }.onFailure { error ->
@@ -223,8 +255,16 @@ class StoriesViewModel(private val container: AppContainer) : ViewModel() {
}.onSuccess { storyFromServer -> }.onSuccess { storyFromServer ->
_uiState.value = _uiState.value.copy( _uiState.value = _uiState.value.copy(
selectedStory = storyFromServer, selectedStory = storyFromServer,
selectedStoryAuthorId = storyFromServer.author.id,
stories = listOf(storyFromServer) + _uiState.value.stories.filterNot { it.id == storyFromServer.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 -> }.onFailure { error ->
_uiState.value = _uiState.value.copy(isLoading = false, errorMessage = error.message) _uiState.value = _uiState.value.copy(isLoading = false, errorMessage = error.message)
@@ -241,7 +281,112 @@ class StoriesViewModel(private val container: AppContainer) : ViewModel() {
} }
fun closeStoryPreview() { 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) { fun deleteStory(story: StoryDto) {
@@ -253,6 +398,7 @@ class StoriesViewModel(private val container: AppContainer) : ViewModel() {
stories = _uiState.value.stories.filterNot { it.id == story.id }, stories = _uiState.value.stories.filterNot { it.id == story.id },
selectedStory = _uiState.value.selectedStory?.takeUnless { it.id == story.id } selectedStory = _uiState.value.selectedStory?.takeUnless { it.id == story.id }
) )
container.realtimeService.notifyStoriesChangedLocally()
}.onFailure { error -> }.onFailure { error ->
_uiState.value = _uiState.value.copy(errorMessage = error.message) _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) { private fun openRelativeStory(offset: Int) {
val state = _uiState.value val state = _uiState.value
val selected = state.selectedStory ?: return 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()) { if (stories.isEmpty()) {
return return
} }
@@ -113,14 +113,16 @@ class AttachmentUploadWorker(
ttlSeconds = first.ttlSeconds ?: 86_400, ttlSeconds = first.ttlSeconds ?: 86_400,
files = files files = files
) )
} else if (files.size == 1) { } else if (files.size == 1 || !files.all(::isMediaAlbumFile)) {
container.apiClient.uploadAttachment( files.forEachIndexed { index, file ->
accessToken = session.accessToken, container.apiClient.uploadAttachment(
chatId = chatId!!, accessToken = session.accessToken,
text = first.text, chatId = chatId!!,
replyToMessageId = first.replyToMessageId, text = first.text.takeIf { index == 0 },
file = files.first() replyToMessageId = first.replyToMessageId.takeIf { index == 0 },
) file = file
)
}
} else { } else {
container.apiClient.uploadAttachmentAlbum( container.apiClient.uploadAttachmentAlbum(
accessToken = session.accessToken, accessToken = session.accessToken,
@@ -134,6 +136,9 @@ class AttachmentUploadWorker(
}.fold( }.fold(
onSuccess = { onSuccess = {
dao.updateGroupStatus(groupId, TransferQueueStatus.Completed, 100, null, System.currentTimeMillis()) dao.updateGroupStatus(groupId, TransferQueueStatus.Completed, 100, null, System.currentTimeMillis())
if (isStoryUpload) {
container.realtimeService.notifyStoriesChangedLocally()
}
Result.success() Result.success()
}, },
onFailure = { error -> 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 = private fun String?.toStoryVisibility(): StoryVisibility =
runCatching { StoryVisibility.valueOf(this ?: StoryVisibility.Everyone.name) } runCatching { StoryVisibility.valueOf(this ?: StoryVisibility.Everyone.name) }
.getOrDefault(StoryVisibility.Everyone) .getOrDefault(StoryVisibility.Everyone)
@@ -111,7 +111,7 @@ fun AppUpdateScreen(
state.latestManifest?.let { manifest -> state.latestManifest?.let { manifest ->
UpdateFieldRow( UpdateFieldRow(
title = "Последний релиз Argus", 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)) Spacer(modifier = Modifier.height(8.dp))
UpdateFieldRow( UpdateFieldRow(
title = "Версия", title = "Версия",
value = "${update.manifest.release.version} (${update.manifest.release.androidVersionCode ?: "?"})" value = "${update.manifest.release.resolvedVersionName} (${update.manifest.release.resolvedVersionCode ?: "?"})"
) )
UpdateFieldRow( UpdateFieldRow(
title = "Размер APK", title = "Размер APK",
@@ -126,7 +126,7 @@ class AppUpdateViewModel(private val container: AppContainer) : ViewModel() {
availableUpdate = result.update, availableUpdate = result.update,
latestManifest = manifest, latestManifest = manifest,
statusTitle = "Проверка проведена", 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.compose.AsyncImage
import coil.request.CachePolicy import coil.request.CachePolicy
import coil.request.ImageRequest import coil.request.ImageRequest
import java.security.MessageDigest
@Composable @Composable
fun AuthenticatedAsyncImage( fun AuthenticatedAsyncImage(
@@ -24,9 +25,11 @@ fun AuthenticatedAsyncImage(
val resolvedAccessToken = accessToken ?: LocalIkarAccessToken.current val resolvedAccessToken = accessToken ?: LocalIkarAccessToken.current
val context = LocalContext.current val context = LocalContext.current
val request = remember(imageUrl, resolvedAccessToken) { val request = remember(imageUrl, resolvedAccessToken) {
val diskCacheKey = buildAuthenticatedDiskCacheKey(imageUrl, resolvedAccessToken)
ImageRequest.Builder(context) ImageRequest.Builder(context)
.data(imageUrl) .data(imageUrl)
.diskCacheKey(imageUrl) .diskCacheKey(diskCacheKey)
.memoryCacheKey(diskCacheKey)
.diskCachePolicy(CachePolicy.ENABLED) .diskCachePolicy(CachePolicy.ENABLED)
.memoryCachePolicy(CachePolicy.ENABLED) .memoryCachePolicy(CachePolicy.ENABLED)
.apply { .apply {
@@ -45,3 +48,14 @@ fun AuthenticatedAsyncImage(
contentScale = contentScale 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 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.background
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxHeight
@@ -15,6 +20,7 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.seven.ikar.kotlin.core.settings.DEFAULT_CHAT_WALLPAPER_PRESET_ID import com.seven.ikar.kotlin.core.settings.DEFAULT_CHAT_WALLPAPER_PRESET_ID
@@ -166,6 +172,7 @@ object ChatWallpaperCatalog {
@Composable @Composable
fun ChatWallpaperBackground( fun ChatWallpaperBackground(
snapshot: ChatSettingsSnapshot, snapshot: ChatSettingsSnapshot,
animated: Boolean = false,
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
Box(modifier = modifier) { Box(modifier = modifier) {
@@ -188,6 +195,7 @@ fun ChatWallpaperBackground(
} else { } else {
PresetWallpaperBackground( PresetWallpaperBackground(
preset = ChatWallpaperCatalog.resolvePreset(snapshot.selectedWallpaperPresetId), preset = ChatWallpaperCatalog.resolvePreset(snapshot.selectedWallpaperPresetId),
animated = animated,
modifier = Modifier.fillMaxSize() modifier = Modifier.fillMaxSize()
) )
} }
@@ -197,8 +205,10 @@ fun ChatWallpaperBackground(
@Composable @Composable
fun PresetWallpaperBackground( fun PresetWallpaperBackground(
preset: ChatWallpaperPreset, preset: ChatWallpaperPreset,
animated: Boolean = false,
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
val motion = wallpaperMotion(animated)
Box( Box(
modifier = modifier.background( modifier = modifier.background(
Brush.linearGradient(colors = preset.gradientColors) Brush.linearGradient(colors = preset.gradientColors)
@@ -210,6 +220,7 @@ fun PresetWallpaperBackground(
modifier = Modifier modifier = Modifier
.align(Alignment.TopEnd) .align(Alignment.TopEnd)
.offset(x = 36.dp, y = (-18).dp) .offset(x = 36.dp, y = (-18).dp)
.wallpaperMotion(motion, x = 10f, y = -6f)
.size(132.dp), .size(132.dp),
color = preset.accentPrimary color = preset.accentPrimary
) )
@@ -217,6 +228,7 @@ fun PresetWallpaperBackground(
modifier = Modifier modifier = Modifier
.align(Alignment.BottomStart) .align(Alignment.BottomStart)
.offset(x = (-28).dp, y = 24.dp) .offset(x = (-28).dp, y = 24.dp)
.wallpaperMotion(motion, x = -8f, y = 7f)
.size(156.dp), .size(156.dp),
color = preset.accentSecondary color = preset.accentSecondary
) )
@@ -227,6 +239,7 @@ fun PresetWallpaperBackground(
modifier = Modifier modifier = Modifier
.align(Alignment.TopStart) .align(Alignment.TopStart)
.offset(x = (-40).dp, y = (-24).dp) .offset(x = (-40).dp, y = (-24).dp)
.wallpaperMotion(motion, x = -7f, y = 8f)
.size(150.dp), .size(150.dp),
color = preset.accentPrimary color = preset.accentPrimary
) )
@@ -234,6 +247,7 @@ fun PresetWallpaperBackground(
modifier = Modifier modifier = Modifier
.align(Alignment.CenterEnd) .align(Alignment.CenterEnd)
.offset(x = 28.dp) .offset(x = 28.dp)
.wallpaperMotion(motion, x = 9f, y = 4f)
.size(124.dp), .size(124.dp),
color = preset.accentSecondary color = preset.accentSecondary
) )
@@ -241,6 +255,7 @@ fun PresetWallpaperBackground(
modifier = Modifier modifier = Modifier
.align(Alignment.BottomCenter) .align(Alignment.BottomCenter)
.offset(y = 34.dp) .offset(y = 34.dp)
.wallpaperMotion(motion, x = 5f, y = -7f)
.size(108.dp), .size(108.dp),
color = preset.accentPrimary.copy(alpha = 0.45f) color = preset.accentPrimary.copy(alpha = 0.45f)
) )
@@ -251,6 +266,7 @@ fun PresetWallpaperBackground(
modifier = Modifier modifier = Modifier
.align(Alignment.TopStart) .align(Alignment.TopStart)
.offset(x = (-18).dp, y = 22.dp) .offset(x = (-18).dp, y = 22.dp)
.wallpaperMotion(motion, x = 8f, y = 5f)
.fillMaxWidth(0.9f) .fillMaxWidth(0.9f)
.height(64.dp), .height(64.dp),
color = preset.accentPrimary color = preset.accentPrimary
@@ -259,6 +275,7 @@ fun PresetWallpaperBackground(
modifier = Modifier modifier = Modifier
.align(Alignment.BottomEnd) .align(Alignment.BottomEnd)
.offset(x = 22.dp, y = (-14).dp) .offset(x = 22.dp, y = (-14).dp)
.wallpaperMotion(motion, x = -8f, y = -4f)
.fillMaxWidth(0.76f) .fillMaxWidth(0.76f)
.height(56.dp), .height(56.dp),
color = preset.accentSecondary 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 @Composable
private fun AccentCircle( private fun AccentCircle(
modifier: Modifier, modifier: Modifier,
@@ -338,8 +338,19 @@ fun IkarSettingRow(
content = leading content = leading
) )
Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) { Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) {
Text(title, style = MaterialTheme.typography.titleLarge) Text(
Text(subtitle, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) 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() trailing?.invoke()
} }
@@ -371,8 +382,19 @@ fun IkarToggleRow(
) )
} }
Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) { Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) {
Text(title, style = MaterialTheme.typography.titleLarge) Text(
Text(subtitle, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) 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( Switch(
checked = checked, checked = checked,
@@ -417,8 +439,19 @@ fun IkarValueRow(
Box(modifier = Modifier.size(24.dp), contentAlignment = Alignment.Center, content = leading) Box(modifier = Modifier.size(24.dp), contentAlignment = Alignment.Center, content = leading)
} }
Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) { Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) {
Text(title, style = MaterialTheme.typography.titleLarge) Text(
Text(subtitle, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) 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( Text(
value, value,
@@ -7,7 +7,7 @@ param(
[string]$Channel = 'stable', [string]$Channel = 'stable',
[string]$Platform = 'android', [string]$Platform = 'android',
[string]$Notes, [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', [string]$HomepageUrl = 'https://ikar.kusoft.xyz',
[switch]$Unlisted, [switch]$Unlisted,
[switch]$SkipSigningInitialization [switch]$SkipSigningInitialization
+381 -108
View File
@@ -1,10 +1,16 @@
param( 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)] [Parameter(Mandatory = $true)]
[string]$Username, [string]$SshPassword,
[Parameter(Mandatory = $true)] [Alias('BaseUrl')]
[string]$Password, [string]$PublicBaseUrl = 'https://argus.kusoft.xyz',
[string]$ArgusDataPath = '/srv/argus-data',
[Parameter(Mandatory = $true)] [Parameter(Mandatory = $true)]
[ValidatePattern('^[a-z0-9][a-z0-9-]{0,99}$')]
[string]$Slug, [string]$Slug,
[Parameter(Mandatory = $true)] [Parameter(Mandatory = $true)]
[string]$Name, [string]$Name,
@@ -12,13 +18,11 @@ param(
[string]$Summary, [string]$Summary,
[Parameter(Mandatory = $true)] [Parameter(Mandatory = $true)]
[string]$Description, [string]$Description,
[Parameter(Mandatory = $true)]
[string]$AndroidPackageName, [string]$AndroidPackageName,
[Parameter(Mandatory = $true)] [Parameter(Mandatory = $true)]
[string]$Version, [string]$Version,
[Parameter(Mandatory = $true)] [ValidateRange(0, [int]::MaxValue)]
[ValidateRange(1, [int]::MaxValue)] [int]$AndroidVersionCode = 0,
[int]$AndroidVersionCode,
[Parameter(Mandatory = $true)] [Parameter(Mandatory = $true)]
[string]$PackagePath, [string]$PackagePath,
[string]$Channel = 'stable', [string]$Channel = 'stable',
@@ -31,111 +35,380 @@ param(
) )
$ErrorActionPreference = 'Stop' $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 $resolvedPackagePath = (Resolve-Path -LiteralPath $PackagePath).Path
if (-not (Test-Path $resolvedPackagePath)) { $packageItem = Get-Item -LiteralPath $resolvedPackagePath
throw "Package file was not found: $resolvedPackagePath" if ($packageItem.Length -le 0) {
throw "Package file is empty: $resolvedPackagePath"
} }
$baseUri = $BaseUrl.TrimEnd('/') $baseUrl = $PublicBaseUrl.TrimEnd('/')
$cookieContainer = [System.Net.CookieContainer]::new() $manifestUrl = "{0}/api/apps/{1}/manifest?platform={2}&channel={3}" -f `
$handler = [System.Net.Http.HttpClientHandler]::new() $baseUrl,
$handler.CookieContainer = $cookieContainer [uri]::EscapeDataString($Slug),
$handler.UseCookies = $true [uri]::EscapeDataString($Platform),
$client = [System.Net.Http.HttpClient]::new($handler) [uri]::EscapeDataString($Channel)
$client.Timeout = [TimeSpan]::FromMinutes(15)
$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 { try {
$loginPayload = @{ $publishOutput = $pythonScript | python -
username = $Username $pythonExitCode = $LASTEXITCODE
password = $Password
} | ConvertTo-Json -Compress
$loginResponse = $client.PostAsync(
"$baseUri/api/admin/session/login",
(New-JsonContent $loginPayload)
).GetAwaiter().GetResult()
Ensure-Success $loginResponse 'Argus login'
$appPayload = @{
name = $Name
summary = $Summary
description = $Description
androidPackageName = $AndroidPackageName
repositoryUrl = if ([string]::IsNullOrWhiteSpace($RepositoryUrl)) { $null } else { $RepositoryUrl }
homepageUrl = if ([string]::IsNullOrWhiteSpace($HomepageUrl)) { $null } else { $HomepageUrl }
isListed = (-not $Unlisted)
} | ConvertTo-Json -Compress
$appResponse = $client.PutAsync(
"$baseUri/api/admin/apps/$Slug",
(New-JsonContent $appPayload)
).GetAwaiter().GetResult()
Ensure-Success $appResponse 'Argus app metadata update'
$form = [System.Net.Http.MultipartFormDataContent]::new()
$fields = [ordered]@{
Version = $Version
Channel = $Channel
Platform = $Platform
PackageKind = $PackageKind
AndroidVersionCode = [string]$AndroidVersionCode
}
foreach ($entry in $fields.GetEnumerator()) {
$form.Add([System.Net.Http.StringContent]::new($entry.Value, [System.Text.Encoding]::UTF8), $entry.Key)
}
if (-not [string]::IsNullOrWhiteSpace($Notes)) {
$form.Add([System.Net.Http.StringContent]::new($Notes, [System.Text.Encoding]::UTF8), 'Notes')
}
$fileStream = [System.IO.File]::OpenRead($resolvedPackagePath)
try {
$fileContent = [System.Net.Http.StreamContent]::new($fileStream)
$fileContent.Headers.ContentType = [System.Net.Http.Headers.MediaTypeHeaderValue]::Parse('application/vnd.android.package-archive')
$form.Add($fileContent, 'Package', [System.IO.Path]::GetFileName($resolvedPackagePath))
$releaseResponse = $client.PostAsync(
"$baseUri/api/admin/apps/$Slug/releases",
$form
).GetAwaiter().GetResult()
Ensure-Success $releaseResponse 'Argus release upload'
$releaseBody = Read-HttpBody $releaseResponse
if (-not [string]::IsNullOrWhiteSpace($releaseBody)) {
Write-Output $releaseBody
}
} finally {
$fileStream.Dispose()
$form.Dispose()
}
} finally { } finally {
$client.Dispose() Remove-Item Env:\IKAR_ARGUS_PUBLISH_PAYLOAD -ErrorAction SilentlyContinue
$handler.Dispose() 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 public sealed class BotsControllerTests
{ {
[Fact] [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 database = new SqliteTestDatabase();
await using var dbContext = await database.CreateContextAsync(); 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.UserSessions.AnyAsync(x => x.UserId == botUser.Id));
Assert.False(await dbContext.PushDevices.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.False(await dbContext.Users.AnyAsync(x => x.Id == botUser.Id));
Assert.True(reloadedBotUser.IsBot); Assert.False(await dbContext.Chats.AnyAsync(x => x.Id == chat.Id));
Assert.True(reloadedBotUser.IsBlocked); Assert.False(await dbContext.Messages.AnyAsync(x => x.Id == message.Id));
Assert.NotNull(reloadedBotUser.BlockedAt);
var reloadedMessage = await dbContext.Messages.AsNoTracking().SingleAsync(x => x.Id == message.Id);
Assert.Equal(botUser.Id, reloadedMessage.SenderId);
Assert.Equal("history", reloadedMessage.Text);
} }
[Fact] [Fact]
@@ -49,6 +49,72 @@ public sealed class ChannelDtoMapperTests
Assert.Equal(owner.Id, visibleMember.User.Id); 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] [Fact]
public void ChannelPost_MasksSenderForRegularSubscriber_AndKeepsViewCount() public void ChannelPost_MasksSenderForRegularSubscriber_AndKeepsViewCount()
{ {
@@ -83,6 +149,69 @@ public sealed class ChannelDtoMapperTests
Assert.Equal(owner.Id, ownerDto.Sender.Id); 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) private static Chat CreateChannel(User owner, params User[] subscribers)
{ {
var channel = new Chat 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.Auth;
using Ikar.Server.Infrastructure.Hubs; using Ikar.Server.Infrastructure.Hubs;
using Ikar.Server.Infrastructure.Push; using Ikar.Server.Infrastructure.Push;
using Ikar.Server.Infrastructure.Storage;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.SignalR; using Microsoft.AspNetCore.SignalR;
using Microsoft.Data.Sqlite; using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.FileProviders;
namespace Ikar.Server.Tests; namespace Ikar.Server.Tests;
@@ -37,15 +40,28 @@ internal sealed class SqliteTestDatabase : IAsyncDisposable
internal static class ControllerTestFixture 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( var controller = new ChatsController(
dbContext, dbContext,
new PresenceTracker(), new PresenceTracker(),
new NoopHubContext<MessengerHub>(), hubContext,
attachmentStorageService: null!, attachmentStorageService: attachmentStorageService!,
new PushDispatchQueue(), pushDispatchQueue,
new BotUpdateService(dbContext)); new BotUpdateService(dbContext),
new BotCreatorService(
dbContext,
new BotTokenService(),
new BotDeletionService(dbContext, botAttachmentStorageService),
new PresenceTracker(),
hubContext,
pushDispatchQueue));
AttachUser(controller, userId); AttachUser(controller, userId);
return controller; return controller;
} }
@@ -62,21 +78,38 @@ internal static class ControllerTestFixture
public static BotsController CreateBotsController(IkarDbContext dbContext, Guid userId) 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); AttachUser(controller, userId);
return controller; return controller;
} }
public static StoriesController CreateStoriesController(IkarDbContext dbContext, Guid userId) 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( var controller = new StoriesController(
dbContext, dbContext,
new PresenceTracker(), new PresenceTracker(),
attachmentStorageService: null!); hubContext,
attachmentStorageService ?? new AttachmentStorageService(new TestWebHostEnvironment(Path.GetTempPath())));
AttachUser(controller, userId); AttachUser(controller, userId);
return controller; return controller;
} }
public static AttachmentStorageService CreateAttachmentStorageService(string contentRootPath) =>
new(new TestWebHostEnvironment(contentRootPath));
public static T ValueOf<T>(ActionResult<T> actionResult) public static T ValueOf<T>(ActionResult<T> actionResult)
{ {
var ok = Assert.IsType<OkObjectResult>(actionResult.Result); 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> internal sealed class NoopHubContext<THub> : IHubContext<THub>
@@ -1,5 +1,9 @@
using Ikar.Server.Controllers;
using Ikar.Server.Data.Entities; using Ikar.Server.Data.Entities;
using Ikar.Server.Infrastructure.Hubs;
using Ikar.Shared; using Ikar.Shared;
using Microsoft.AspNetCore.Http;
using Microsoft.EntityFrameworkCore;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
namespace Ikar.Server.Tests; namespace Ikar.Server.Tests;
@@ -33,12 +37,103 @@ public sealed class PrivacyAndStoriesControllerTests
Assert.Equal("contacts", settings.PhoneNumberVisibility); Assert.Equal("contacts", settings.PhoneNumberVisibility);
Assert.Equal("nobody", settings.LastSeenVisibility); Assert.Equal("nobody", settings.LastSeenVisibility);
Assert.Equal([bob.Id], settings.BlockedUserIds); 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.AlwaysAllowUserIds);
Assert.Empty(settings.AlwaysAllowUsers);
Assert.Equal([bob.Id], settings.NeverAllowUserIds); 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.True(settings.PasscodeLockEnabled);
Assert.False(settings.ShowMessagePreview); 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] [Fact]
public async Task GetStories_ReturnsEveryoneStoriesWithoutSharedChat() public async Task GetStories_ReturnsEveryoneStoriesWithoutSharedChat()
{ {
@@ -106,6 +201,118 @@ public sealed class PrivacyAndStoriesControllerTests
Assert.Equal("own", story.Text); 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] [Fact]
public async Task GetAvatar_RespectsProfilePhotoContactsVisibility() 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) => private static User CreateUser(string username) =>
new() new()
{ {
@@ -179,4 +424,14 @@ public sealed class PrivacyAndStoriesControllerTests
CreatedAt = DateTimeOffset.UtcNow.AddMinutes(-5), CreatedAt = DateTimeOffset.UtcNow.AddMinutes(-5),
ExpiresAt = DateTimeOffset.UtcNow.AddHours(1) 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 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] [Fact]
public async Task DispatchMessageReactionAsync_SendsOnlyToMessageAuthorDevices() public async Task DispatchMessageReactionAsync_SendsOnlyToMessageAuthorDevices()
{ {
@@ -126,14 +243,18 @@ public sealed class PushNotificationDispatcherTests
private sealed class RecordingPushNotificationSender : IPushNotificationSender private sealed class RecordingPushNotificationSender : IPushNotificationSender
{ {
public List<MessagePushCall> MessageCalls { get; } = [];
public List<ReactionPushCall> ReactionCalls { get; } = []; public List<ReactionPushCall> ReactionCalls { get; } = [];
public Task SendMessageAsync( public Task SendMessageAsync(
Chat chat, Chat chat,
Message message, Message message,
IReadOnlyCollection<PushDevice> devices, IReadOnlyCollection<PushDevice> devices,
CancellationToken cancellationToken = default) => CancellationToken cancellationToken = default)
Task.CompletedTask; {
MessageCalls.Add(new MessagePushCall(chat, message, devices.ToList()));
return Task.CompletedTask;
}
public Task SendMessageReactionAsync( public Task SendMessageReactionAsync(
Chat chat, Chat chat,
@@ -166,6 +287,11 @@ public sealed class PushNotificationDispatcherTests
Task.CompletedTask; Task.CompletedTask;
} }
private sealed record MessagePushCall(
Chat Chat,
Message Message,
IReadOnlyCollection<PushDevice> Devices);
private sealed record ReactionPushCall( private sealed record ReactionPushCall(
Chat Chat, Chat Chat,
Message Message, 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()); 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] [Fact]
public async Task SearchPage_ReturnsPublicChannelForNonMember_ByAtUsername() public async Task SearchPage_ReturnsPublicChannelForNonMember_ByAtUsername()
{ {
@@ -104,6 +207,49 @@ public sealed class SearchControllerTests
Assert.False(result.Chat.IsSubscribed); 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) => private static User CreateUser(string username) =>
new() new()
{ {
@@ -129,7 +275,12 @@ public sealed class SearchControllerTests
return chat; 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 var message = new Message
{ {
@@ -139,7 +290,8 @@ public sealed class SearchControllerTests
SenderId = sender.Id, SenderId = sender.Id,
Sender = sender, Sender = sender,
Text = text, Text = text,
SentAt = sentAt SentAt = sentAt,
ExpiresAt = expiresAt
}; };
chat.Messages.Add(message); chat.Messages.Add(message);
return 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, IkarDbContext dbContext,
TokenService tokenService, TokenService tokenService,
PresenceTracker presenceTracker, PresenceTracker presenceTracker,
PhoneAuthChallengeStore phoneAuthChallengeStore) : ControllerBase PhoneAuthChallengeStore phoneAuthChallengeStore,
EmailAuthChallengeStore emailAuthChallengeStore,
IEmailCodeSender emailCodeSender) : ControllerBase
{ {
private readonly PasswordHasher<User> _passwordHasher = new(); private readonly PasswordHasher<User> _passwordHasher = new();
@@ -146,12 +148,17 @@ public sealed class AuthController(
var user = await dbContext.Users var user = await dbContext.Users
.AsNoTracking() .AsNoTracking()
.SingleOrDefaultAsync(x => x.NormalizedPhoneNumber == normalizedPhoneNumber, cancellationToken); .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) if (user?.IsBlocked == true)
{ {
return StatusCode(StatusCodes.Status423Locked, "User is blocked."); 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( return Ok(new PhoneCodeChallengeDto(
challenge.Id, challenge.Id,
challenge.ExpiresAt, challenge.ExpiresAt,
@@ -186,13 +193,7 @@ public sealed class AuthController(
if (user is null) if (user is null)
{ {
var displayName = request.DisplayName?.Trim() ?? string.Empty; return NotFound("Phone code login is available only for existing accounts. Register by email.");
if (string.IsNullOrWhiteSpace(displayName))
{
return ValidationProblem("Display name is required for a new account.");
}
user = await CreatePhoneOnlyUserAsync(normalizedPhoneNumber, request.PhoneNumber, displayName, cancellationToken);
} }
if (user.IsBlocked) if (user.IsBlocked)
@@ -207,6 +208,147 @@ public sealed class AuthController(
return Ok(session with { User = user.ToDto(presenceTracker) }); 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")] [HttpPost("refresh")]
[AllowAnonymous] [AllowAnonymous]
public async Task<ActionResult<AuthSessionDto>> Refresh(RefreshSessionRequest request, CancellationToken cancellationToken) public async Task<ActionResult<AuthSessionDto>> Refresh(RefreshSessionRequest request, CancellationToken cancellationToken)
@@ -258,6 +400,104 @@ public sealed class AuthController(
return user; 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) private async Task<string> GenerateInternalUsernameAsync(CancellationToken cancellationToken)
{ {
while (true) while (true)
+438 -1
View File
@@ -4,8 +4,10 @@ using Ikar.Server.Data.Entities;
using Ikar.Server.Infrastructure; using Ikar.Server.Infrastructure;
using Ikar.Server.Infrastructure.Auth; using Ikar.Server.Infrastructure.Auth;
using Ikar.Server.Infrastructure.Bots; using Ikar.Server.Infrastructure.Bots;
using Ikar.Server.Infrastructure.Channels;
using Ikar.Server.Infrastructure.Hubs; using Ikar.Server.Infrastructure.Hubs;
using Ikar.Server.Infrastructure.Push; using Ikar.Server.Infrastructure.Push;
using Ikar.Server.Infrastructure.Storage;
using Ikar.Shared; using Ikar.Shared;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
@@ -23,8 +25,13 @@ public sealed class BotApiController(
BotTokenService botTokenService, BotTokenService botTokenService,
BotUpdateService botUpdateService, BotUpdateService botUpdateService,
IHubContext<MessengerHub> hubContext, 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")] [HttpGet("getMe")]
public async Task<ActionResult<UserSummaryDto>> GetMe(string token, CancellationToken cancellationToken) public async Task<ActionResult<UserSummaryDto>> GetMe(string token, CancellationToken cancellationToken)
{ {
@@ -205,10 +212,334 @@ public sealed class BotApiController(
chat.LastActivityAt = message.SentAt; chat.LastActivityAt = message.SentAt;
dbContext.Messages.Add(message); 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 dbContext.SaveChangesAsync(cancellationToken);
await NotifyMessageCreatedAsync(chat, message, cancellationToken); await NotifyMessageCreatedAsync(chat, message, cancellationToken);
await pushDispatchQueue.EnqueueAsync(chat.Id, message.Id); 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)); return Ok(message.ToDto(presenceTracker, bot.UserId));
} }
@@ -257,6 +588,112 @@ public sealed class BotApiController(
return Task.WhenAll(tasks); 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) private static List<BotCommandDto> NormalizeCommands(IReadOnlyList<BotCommandDto> commands)
{ {
var normalized = new List<BotCommandDto>(); 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.Data.Entities;
using Ikar.Server.Infrastructure; using Ikar.Server.Infrastructure;
using Ikar.Server.Infrastructure.Auth; using Ikar.Server.Infrastructure.Auth;
using Ikar.Server.Infrastructure.Bots;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Ikar.Shared; using Ikar.Shared;
@@ -16,10 +17,10 @@ namespace Ikar.Server.Controllers;
[Route("api/[controller]")] [Route("api/[controller]")]
public sealed class BotsController( public sealed class BotsController(
IkarDbContext dbContext, 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 static readonly Regex BotUsernameRegex = new("^[A-Za-z0-9_]{5,64}$", RegexOptions.Compiled);
private readonly PasswordHasher<User> _passwordHasher = new();
[HttpGet("mine")] [HttpGet("mine")]
public async Task<ActionResult<IReadOnlyList<BotSummaryDto>>> GetMine(CancellationToken cancellationToken) public async Task<ActionResult<IReadOnlyList<BotSummaryDto>>> GetMine(CancellationToken cancellationToken)
@@ -49,48 +50,12 @@ public sealed class BotsController(
} }
[HttpPost] [HttpPost]
public async Task<ActionResult<CreatedBotDto>> CreateBot( public ActionResult<CreatedBotDto> CreateBot(CreateBotRequest request)
CreateBotRequest request,
CancellationToken cancellationToken)
{ {
var ownerUserId = User.GetRequiredUserId(); _ = request;
var validationError = await ValidateBotDefinitionAsync( return StatusCode(
request.Username, StatusCodes.Status410Gone,
request.DisplayName, "Создание ботов доступно только через чат с @botfatherbot. Напишите /создать_бота Имя бота | usernamebot | описание.");
request.About,
excludingBotId: null,
cancellationToken);
if (validationError is not null)
{
return ValidationProblem(validationError);
}
var botUser = new User
{
Username = request.Username.Trim(),
NormalizedUsername = request.Username.Trim().ToUpperInvariant(),
DisplayName = request.DisplayName.Trim(),
About = string.IsNullOrWhiteSpace(request.About) ? null : request.About.Trim(),
IsBot = true
};
botUser.PasswordHash = _passwordHasher.HashPassword(botUser, Guid.NewGuid().ToString("N"));
var bot = new Bot
{
User = botUser,
OwnerUserId = ownerUserId
};
var rawToken = botTokenService.GenerateToken(bot.Id);
bot.TokenHash = botTokenService.HashToken(rawToken);
dbContext.Bots.Add(bot);
await dbContext.SaveChangesAsync(cancellationToken);
var created = await LoadOwnedBotAsync(bot.Id, ownerUserId, cancellationToken)
?? throw new InvalidOperationException("Newly created bot was not found.");
return Ok(new CreatedBotDto(created.ToDto(), rawToken));
} }
[HttpPut("{botId:guid}")] [HttpPut("{botId:guid}")]
@@ -185,36 +150,14 @@ public sealed class BotsController(
[HttpDelete("{botId:guid}")] [HttpDelete("{botId:guid}")]
public async Task<IActionResult> DeleteBot(Guid botId, CancellationToken cancellationToken) public async Task<IActionResult> DeleteBot(Guid botId, CancellationToken cancellationToken)
{ {
var ownerUserId = User.GetRequiredUserId(); var deleted = await botDeletionService.DeleteBotAsync(
var bot = await dbContext.Bots botId,
.Include(x => x.User) User.GetRequiredUserId(),
.ThenInclude(x => x.Sessions) cancellationToken);
.Include(x => x.User) if (deleted is null)
.ThenInclude(x => x.PushDevices)
.SingleOrDefaultAsync(
x => x.Id == botId && x.OwnerUserId == ownerUserId,
cancellationToken);
if (bot is null)
{ {
return NotFound(); 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(); return NoContent();
} }
File diff suppressed because it is too large Load Diff
@@ -1,10 +1,12 @@
using System.Net.Mime; using System.Net.Mime;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.SignalR;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Ikar.Server.Data; using Ikar.Server.Data;
using Ikar.Server.Data.Entities; using Ikar.Server.Data.Entities;
using Ikar.Server.Infrastructure; using Ikar.Server.Infrastructure;
using Ikar.Server.Infrastructure.Hubs;
using Ikar.Server.Infrastructure.Storage; using Ikar.Server.Infrastructure.Storage;
using Ikar.Shared; using Ikar.Shared;
@@ -16,6 +18,7 @@ namespace Ikar.Server.Controllers;
public sealed class StoriesController( public sealed class StoriesController(
IkarDbContext dbContext, IkarDbContext dbContext,
PresenceTracker presenceTracker, PresenceTracker presenceTracker,
IHubContext<MessengerHub> hubContext,
AttachmentStorageService attachmentStorageService) : ControllerBase AttachmentStorageService attachmentStorageService) : ControllerBase
{ {
private const int MaxStoryAttachmentCount = 10; private const int MaxStoryAttachmentCount = 10;
@@ -32,6 +35,8 @@ public sealed class StoriesController(
.AsNoTracking() .AsNoTracking()
.Include(x => x.AuthorUser) .Include(x => x.AuthorUser)
.ThenInclude(x => x.Sessions) .ThenInclude(x => x.Sessions)
.Include(x => x.AuthorUser)
.ThenInclude(x => x.PrivacySettings)
.Include(x => x.Attachments) .Include(x => x.Attachments)
.Include(x => x.Views) .Include(x => x.Views)
.Where(x => .Where(x =>
@@ -42,7 +47,12 @@ public sealed class StoriesController(
.Take(100) .Take(100)
.ToListAsync(cancellationToken); .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}")] [HttpGet("{storyId:guid}")]
@@ -55,6 +65,8 @@ public sealed class StoriesController(
var story = await dbContext.Stories var story = await dbContext.Stories
.Include(x => x.AuthorUser) .Include(x => x.AuthorUser)
.ThenInclude(x => x.Sessions) .ThenInclude(x => x.Sessions)
.Include(x => x.AuthorUser)
.ThenInclude(x => x.PrivacySettings)
.Include(x => x.Attachments) .Include(x => x.Attachments)
.Include(x => x.Views) .Include(x => x.Views)
.Where(x => .Where(x =>
@@ -79,7 +91,10 @@ public sealed class StoriesController(
await dbContext.SaveChangesAsync(cancellationToken); await dbContext.SaveChangesAsync(cancellationToken);
} }
return Ok(story.ToDto(presenceTracker, userId)); return Ok(story.ToDto(
presenceTracker,
userId,
sharesChatWithViewer: peerAuthorIds.Contains(story.AuthorUserId)));
} }
[HttpPost] [HttpPost]
@@ -110,6 +125,7 @@ public sealed class StoriesController(
var loaded = await LoadStoryForAuthorAsync(story.Id, userId, cancellationToken) var loaded = await LoadStoryForAuthorAsync(story.Id, userId, cancellationToken)
?? throw new InvalidOperationException("The created story could not be reloaded."); ?? throw new InvalidOperationException("The created story could not be reloaded.");
await NotifyStoriesChangedAsync(loaded, cancellationToken);
return Ok(loaded.ToDto(presenceTracker, userId)); return Ok(loaded.ToDto(presenceTracker, userId));
} }
@@ -142,6 +158,12 @@ public sealed class StoriesController(
return BadRequest($"Story upload is limited to {MaxStoryUploadSizeBytes} bytes."); 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 now = DateTimeOffset.UtcNow;
var ttlSeconds = Math.Clamp(request.TtlSeconds, 60, 7 * 24 * 60 * 60); var ttlSeconds = Math.Clamp(request.TtlSeconds, 60, 7 * 24 * 60 * 60);
var story = new Story var story = new Story
@@ -180,6 +202,7 @@ public sealed class StoriesController(
var loaded = await LoadStoryForAuthorAsync(story.Id, userId, cancellationToken) var loaded = await LoadStoryForAuthorAsync(story.Id, userId, cancellationToken)
?? throw new InvalidOperationException("The created story could not be reloaded."); ?? throw new InvalidOperationException("The created story could not be reloaded.");
await NotifyStoriesChangedAsync(loaded, cancellationToken);
return Ok(loaded.ToDto(presenceTracker, userId)); return Ok(loaded.ToDto(presenceTracker, userId));
} }
@@ -208,6 +231,7 @@ public sealed class StoriesController(
story.DeletedAt = DateTimeOffset.UtcNow; story.DeletedAt = DateTimeOffset.UtcNow;
await AddModerationLogAsync(userId, ModerationLogTargetType.Story, story.Id, "story.deleted", cancellationToken); await AddModerationLogAsync(userId, ModerationLogTargetType.Story, story.Id, "story.deleted", cancellationToken);
await dbContext.SaveChangesAsync(cancellationToken); await dbContext.SaveChangesAsync(cancellationToken);
await NotifyStoriesChangedAsync(story, cancellationToken);
} }
return NoContent(); return NoContent();
@@ -217,6 +241,7 @@ public sealed class StoriesController(
public async Task<ActionResult<IReadOnlyList<StoryViewDto>>> GetStoryViews(Guid storyId, CancellationToken cancellationToken) public async Task<ActionResult<IReadOnlyList<StoryViewDto>>> GetStoryViews(Guid storyId, CancellationToken cancellationToken)
{ {
var userId = User.GetRequiredUserId(); var userId = User.GetRequiredUserId();
var peerAuthorIds = await LoadStoryPeerAuthorIdsAsync(userId, cancellationToken);
var storyExists = await dbContext.Stories var storyExists = await dbContext.Stories
.AsNoTracking() .AsNoTracking()
.AnyAsync(x => x.Id == storyId && x.AuthorUserId == userId, cancellationToken); .AnyAsync(x => x.Id == storyId && x.AuthorUserId == userId, cancellationToken);
@@ -229,13 +254,21 @@ public sealed class StoriesController(
.AsNoTracking() .AsNoTracking()
.Include(x => x.ViewerUser) .Include(x => x.ViewerUser)
.ThenInclude(x => x.Sessions) .ThenInclude(x => x.Sessions)
.Include(x => x.ViewerUser)
.ThenInclude(x => x.PrivacySettings)
.Where(x => x.StoryId == storyId) .Where(x => x.StoryId == storyId)
.OrderByDescending(x => x.ViewedAt) .OrderByDescending(x => x.ViewedAt)
.Take(250) .Take(250)
.ToListAsync(cancellationToken); .ToListAsync(cancellationToken);
return Ok(views 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()); .ToList());
} }
@@ -282,14 +315,53 @@ public sealed class StoriesController(
.ToList(); .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) => private async Task<Story?> LoadStoryForAuthorAsync(Guid storyId, Guid userId, CancellationToken cancellationToken) =>
await dbContext.Stories await dbContext.Stories
.Include(x => x.AuthorUser) .Include(x => x.AuthorUser)
.ThenInclude(x => x.Sessions) .ThenInclude(x => x.Sessions)
.Include(x => x.AuthorUser)
.ThenInclude(x => x.PrivacySettings)
.Include(x => x.Attachments) .Include(x => x.Attachments)
.Include(x => x.Views) .Include(x => x.Views)
.SingleOrDefaultAsync(x => x.Id == storyId && x.AuthorUserId == userId, cancellationToken); .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( private async Task AddModerationLogAsync(
Guid actorUserId, Guid actorUserId,
ModerationLogTargetType targetType, ModerationLogTargetType targetType,

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