Initial QMAX production app
This commit is contained in:
@@ -0,0 +1,19 @@
|
||||
.git
|
||||
.gradle
|
||||
android/.gradle
|
||||
android/app/build
|
||||
android/build
|
||||
**/bin
|
||||
**/obj
|
||||
**/node_modules
|
||||
data
|
||||
storage
|
||||
secrets
|
||||
.env
|
||||
.env.*
|
||||
!deploy/.env.example
|
||||
qmax-*-firebase-adminsdk-*.json
|
||||
google-services.json
|
||||
*.jks
|
||||
*.keystore
|
||||
key.properties
|
||||
+50
@@ -0,0 +1,50 @@
|
||||
bin/
|
||||
obj/
|
||||
.vs/
|
||||
.idea/
|
||||
.gradle/
|
||||
build/
|
||||
artifacts/
|
||||
node_modules/
|
||||
local.properties
|
||||
*.user
|
||||
*.suo
|
||||
*.db
|
||||
*.db-shm
|
||||
*.db-wal
|
||||
*.sqlite
|
||||
*.sqlite-shm
|
||||
*.sqlite-wal
|
||||
*.part
|
||||
*.hprof
|
||||
|
||||
# QMAX runtime data and secrets
|
||||
data/
|
||||
storage/
|
||||
secrets/
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
appsettings.Production.local.json
|
||||
qmax-*-firebase-adminsdk-*.json
|
||||
firebase-adminsdk*.json
|
||||
service-account*.json
|
||||
*.keystore
|
||||
*.jks
|
||||
key.properties
|
||||
!qmax-*-firebase-adminsdk-*.json
|
||||
!firebase-adminsdk*.json
|
||||
!service-account*.json
|
||||
!android/qmax-release.jks
|
||||
!android/key.properties
|
||||
|
||||
# Firebase Android config may be used locally, but should be reviewed before publishing.
|
||||
google-services.json
|
||||
!google-services.json
|
||||
!android/app/google-services.json
|
||||
|
||||
# Android/IDE output
|
||||
android/.kotlin/
|
||||
android/captures/
|
||||
android/app/release/
|
||||
android/app/debug/
|
||||
@@ -0,0 +1,343 @@
|
||||
# Argus Publication And Update Integration
|
||||
|
||||
Эта инструкция является единой точкой входа для любого приложения, которое нужно публиковать через Argus или подключить к системе обновлений Argus.
|
||||
|
||||
Argus в текущей архитектуре является read-only web catalog. У него нет HTTP admin API, upload endpoint, токена публикации или браузерной формы публикации. Публикация выполняется только на сервере через SSH: файл релиза кладётся в `Data/Packages`, а метаданные записываются в SQLite `Data/argus.db`.
|
||||
|
||||
## Production Argus
|
||||
|
||||
- Public base URL: `https://argus.kusoft.xyz`
|
||||
- SSH host: `192.168.0.185`
|
||||
- SSH user: `sevenhill`
|
||||
- Server project path: `/home/sevenhill/argus`
|
||||
- Data path: `/srv/argus-data`
|
||||
- SQLite database: `/srv/argus-data/argus.db`
|
||||
- Package root: `/srv/argus-data/Packages`
|
||||
|
||||
Do not put SSH passwords, private keys, or local `.env` files into application repositories.
|
||||
|
||||
## App Values
|
||||
|
||||
Every app must choose stable values:
|
||||
|
||||
- `slug`: public Argus id. Use lowercase letters, digits, and hyphens only, for example `keeper-android` or `my-desktop-app`.
|
||||
- `name`: display name.
|
||||
- `summary`: short catalog text.
|
||||
- `description`: full catalog text.
|
||||
- `version`: release version. Semantic versions are recommended, for example `1.2.3`.
|
||||
- `channel`: release channel, normally `stable`.
|
||||
- `platform`: target platform, for example `windows-x64`, `linux-arm64`, `android`, `web`.
|
||||
- `packageKind`: package type, for example `zip`, `msi`, `deb`, `apk`, `binary`.
|
||||
|
||||
Argus selects the latest release by semantic version first, then by publish time. The running service currently prunes to one retained release per app on service startup.
|
||||
|
||||
## Publish A Release Over SSH
|
||||
|
||||
From the development machine, copy the built artifact to the Pi:
|
||||
|
||||
```bash
|
||||
scp ./path/to/my-app-1.2.3.zip sevenhill@192.168.0.185:/tmp/my-app-1.2.3.zip
|
||||
```
|
||||
|
||||
Then connect to the Pi:
|
||||
|
||||
```bash
|
||||
ssh sevenhill@192.168.0.185
|
||||
```
|
||||
|
||||
Run this publication block on the Pi after editing the variables at the top:
|
||||
|
||||
```bash
|
||||
set -euo pipefail
|
||||
|
||||
export ARGUS_DATA="/srv/argus-data"
|
||||
export SOURCE_FILE="/tmp/my-app-1.2.3.zip"
|
||||
|
||||
export ARGUS_SLUG="my-app"
|
||||
export ARGUS_NAME="My App"
|
||||
export ARGUS_SUMMARY="Short summary for the Argus catalog."
|
||||
export ARGUS_DESCRIPTION="Full description shown in the Argus catalog."
|
||||
export ARGUS_REPOSITORY_URL=""
|
||||
export ARGUS_HOMEPAGE_URL=""
|
||||
export ARGUS_IS_LISTED="1"
|
||||
|
||||
export ARGUS_VERSION="1.2.3"
|
||||
export ARGUS_CHANNEL="stable"
|
||||
export ARGUS_PLATFORM="windows-x64"
|
||||
export ARGUS_PACKAGE_KIND="zip"
|
||||
export ARGUS_NOTES="What changed in this release."
|
||||
|
||||
python3 - "$SOURCE_FILE" <<'PY'
|
||||
import hashlib
|
||||
import mimetypes
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import sqlite3
|
||||
import sys
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
source = Path(sys.argv[1]).resolve()
|
||||
data_root = Path(os.environ.get("ARGUS_DATA", "/srv/argus-data")).resolve()
|
||||
db_path = data_root / "argus.db"
|
||||
packages_root = data_root / "Packages"
|
||||
|
||||
slug = os.environ["ARGUS_SLUG"].strip()
|
||||
name = os.environ["ARGUS_NAME"].strip()
|
||||
summary = os.environ["ARGUS_SUMMARY"].strip()
|
||||
description = os.environ["ARGUS_DESCRIPTION"].strip()
|
||||
repository_url = os.environ.get("ARGUS_REPOSITORY_URL", "").strip() or None
|
||||
homepage_url = os.environ.get("ARGUS_HOMEPAGE_URL", "").strip() or None
|
||||
is_listed = 1 if os.environ.get("ARGUS_IS_LISTED", "1").strip() != "0" else 0
|
||||
|
||||
version = os.environ["ARGUS_VERSION"].strip()
|
||||
channel = os.environ.get("ARGUS_CHANNEL", "stable").strip().lower()
|
||||
platform = os.environ.get("ARGUS_PLATFORM", "generic").strip().lower()
|
||||
package_kind = os.environ.get("ARGUS_PACKAGE_KIND", "binary").strip().lower()
|
||||
notes = os.environ.get("ARGUS_NOTES", "").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(f"Published app={slug} version={version} channel={channel} platform={platform}")
|
||||
print(f"StoredRelativePath={stored_relative_path}")
|
||||
print(f"Size={size_bytes}")
|
||||
print(f"Sha256={sha256}")
|
||||
print(f"Manifest=https://argus.kusoft.xyz/api/apps/{slug}/manifest?platform={platform}&channel={channel}")
|
||||
PY
|
||||
```
|
||||
|
||||
Verify publication from the Pi:
|
||||
|
||||
```bash
|
||||
curl -sS "http://127.0.0.1:5105/api/apps/$ARGUS_SLUG/manifest?platform=$ARGUS_PLATFORM&channel=$ARGUS_CHANNEL"
|
||||
```
|
||||
|
||||
Verify publication from outside the Pi:
|
||||
|
||||
```bash
|
||||
curl -sS "https://argus.kusoft.xyz/api/apps/$ARGUS_SLUG/manifest?platform=$ARGUS_PLATFORM&channel=$ARGUS_CHANNEL"
|
||||
```
|
||||
|
||||
If you want Argus to apply its one-release retention rule immediately after manual publication, restart the container:
|
||||
|
||||
```bash
|
||||
docker restart argus
|
||||
```
|
||||
|
||||
The restart is optional for making a newer semantic version visible. It is only needed to force startup pruning immediately.
|
||||
|
||||
## Public API Contract For Update Clients
|
||||
|
||||
Update clients must use the public read-only API. They must not call `/api/admin/*`; those routes are not part of this system.
|
||||
|
||||
Check for an update:
|
||||
|
||||
```text
|
||||
GET https://argus.kusoft.xyz/api/apps/{slug}/manifest?platform={platform}&channel={channel}
|
||||
```
|
||||
|
||||
A `404` response means one of these states:
|
||||
|
||||
- the app slug is not listed;
|
||||
- there is no release for the requested `platform`;
|
||||
- there is no release for the requested `channel`.
|
||||
|
||||
A successful manifest response contains:
|
||||
|
||||
```json
|
||||
{
|
||||
"slug": "my-app",
|
||||
"name": "My App",
|
||||
"summary": "Short summary.",
|
||||
"description": "Full description.",
|
||||
"repositoryUrl": null,
|
||||
"homepageUrl": null,
|
||||
"release": {
|
||||
"id": "release-guid",
|
||||
"version": "1.2.3",
|
||||
"channel": "stable",
|
||||
"platform": "windows-x64",
|
||||
"packageKind": "zip",
|
||||
"downloadPath": "/api/apps/my-app/releases/release-guid/download",
|
||||
"originalFileName": "my-app-1.2.3.zip",
|
||||
"contentType": "application/zip",
|
||||
"packageSizeBytes": 123456,
|
||||
"sha256": "hex-encoded-sha256",
|
||||
"publishedAt": "2026-06-13T18:00:00+00:00",
|
||||
"notes": "What changed."
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Download URL:
|
||||
|
||||
```text
|
||||
https://argus.kusoft.xyz{release.downloadPath}
|
||||
```
|
||||
|
||||
There is also a latest-download endpoint:
|
||||
|
||||
```text
|
||||
GET https://argus.kusoft.xyz/api/apps/{slug}/download/latest?platform={platform}&channel={channel}
|
||||
```
|
||||
|
||||
The manifest path is preferred for update clients because it provides `sha256`, `packageSizeBytes`, `version`, and release metadata before downloading.
|
||||
|
||||
## Required Update Client Behavior
|
||||
|
||||
Every app integrating with Argus must implement this flow:
|
||||
|
||||
1. Store its current installed version locally.
|
||||
2. Request the manifest for its `slug`, `platform`, and `channel`.
|
||||
3. Treat HTTP `404` as "no update available for this app/platform/channel".
|
||||
4. Compare local version to `release.version`.
|
||||
5. If update is needed, download `baseUrl + release.downloadPath` to a temporary file.
|
||||
6. Calculate SHA-256 of the downloaded file.
|
||||
7. Compare the calculated SHA-256 to `release.sha256`.
|
||||
8. Reject and delete the temporary file if SHA-256 does not match.
|
||||
9. Apply the update with the app platform's own installer or replacement mechanism.
|
||||
10. Store the new installed version only after a successful install or successful staged update.
|
||||
|
||||
Argus does not install files on client devices. It only publishes metadata and serves package bytes. Installation logic belongs to each app.
|
||||
|
||||
## Versioning Rule
|
||||
|
||||
Use semantic versions when possible:
|
||||
|
||||
```text
|
||||
1.2.3
|
||||
1.2.4
|
||||
1.3.0
|
||||
2.0.0
|
||||
```
|
||||
|
||||
Argus latest selection is semantic-version aware. If an app uses a non-semantic version string, Argus falls back to case-insensitive string comparison for ordering. For predictable updates, keep release versions semantic.
|
||||
|
||||
## Recommended Prompt For App Repositories
|
||||
|
||||
When asking another app project to integrate with Argus, use this instruction:
|
||||
|
||||
```text
|
||||
Read ARGUS_PUBLICATION.md from the Argus repository.
|
||||
Implement the "Required Update Client Behavior" section for this app.
|
||||
Use:
|
||||
- baseUrl: https://argus.kusoft.xyz
|
||||
- slug: <app-slug>
|
||||
- platform: <platform>
|
||||
- channel: stable
|
||||
Do not implement publishing over HTTP. Publishing is SSH-only and happens on the Argus server.
|
||||
Always verify release.sha256 before installing or applying an update.
|
||||
```
|
||||
@@ -0,0 +1,14 @@
|
||||
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
|
||||
WORKDIR /src
|
||||
COPY QMax.slnx ./
|
||||
COPY server/QMax.Api/QMax.Api.csproj server/QMax.Api/
|
||||
RUN dotnet restore server/QMax.Api/QMax.Api.csproj
|
||||
COPY server/QMax.Api server/QMax.Api
|
||||
RUN dotnet publish server/QMax.Api/QMax.Api.csproj -c Release -o /app/publish
|
||||
|
||||
FROM mcr.microsoft.com/dotnet/aspnet:10.0
|
||||
WORKDIR /app
|
||||
COPY --from=build /app/publish .
|
||||
ENV ASPNETCORE_URLS=http://+:8080
|
||||
EXPOSE 8080
|
||||
ENTRYPOINT ["dotnet", "QMax.Api.dll"]
|
||||
@@ -0,0 +1,8 @@
|
||||
<Solution>
|
||||
<Folder Name="/server/">
|
||||
<Project Path="server/QMax.Api/QMax.Api.csproj" />
|
||||
</Folder>
|
||||
<Folder Name="/tests/">
|
||||
<Project Path="tests/QMax.Tests/QMax.Tests.csproj" />
|
||||
</Folder>
|
||||
</Solution>
|
||||
@@ -0,0 +1,145 @@
|
||||
# QMAX
|
||||
|
||||
QMAX is a private Android messenger client backed by a personal bridge server. The Android app talks to your QMAX server, and the server keeps a browser session for `https://web.max.ru`.
|
||||
|
||||
Current shape:
|
||||
|
||||
- `server/QMax.Api` - ASP.NET Core API, SQLite cache, JWT pairing auth, SignalR hub, attachment storage, APK update catalog.
|
||||
- `worker` - Node/Playwright worker with a persistent MAX Web browser profile.
|
||||
- `android` - Kotlin + Jetpack Compose Android client pointed at `https://qmax.kusoft.xyz`.
|
||||
- `deploy` - Docker Compose + Caddy for Raspberry Pi 5.
|
||||
|
||||
## Local Checks
|
||||
|
||||
```powershell
|
||||
dotnet test QMax.slnx
|
||||
cd android
|
||||
.\gradlew.bat :app:assembleDebug :app:assembleRelease --console=plain --no-daemon
|
||||
cd ..\worker
|
||||
npm.cmd install
|
||||
node --check src/server.js
|
||||
```
|
||||
|
||||
## Raspberry Pi Deployment
|
||||
|
||||
1. Point DNS `qmax.kusoft.xyz` to the Raspberry Pi public IP.
|
||||
2. Install Docker Engine and the Docker Compose plugin on the Pi.
|
||||
3. Copy the repository or the archive produced by `scripts/deploy-rpi.ps1`.
|
||||
4. On the Pi:
|
||||
|
||||
```bash
|
||||
cd ~/qmax/deploy
|
||||
cp .env.example .env
|
||||
nano .env
|
||||
docker compose up -d --build
|
||||
```
|
||||
|
||||
This compose publishes QMAX API on `127.0.0.1:18080`. The existing system Caddy should contain:
|
||||
|
||||
```caddy
|
||||
qmax.kusoft.xyz {
|
||||
encode zstd gzip
|
||||
reverse_proxy 127.0.0.1:18080
|
||||
}
|
||||
```
|
||||
|
||||
Set strong values in `.env`:
|
||||
|
||||
- `QMAX_JWT_SECRET` - at least 32 random characters.
|
||||
- `QMAX_PAIRING_CODE` - one-time-ish pairing password for your Android client and `/admin/max`.
|
||||
- `QMAX_MAX_PHONE_NUMBER` - the phone number used for MAX Web login.
|
||||
|
||||
Secrets must stay in `.env`, not in git.
|
||||
|
||||
## MAX Login
|
||||
|
||||
Open:
|
||||
|
||||
```text
|
||||
https://qmax.kusoft.xyz/admin/max?pairingCode=YOUR_PAIRING_CODE
|
||||
```
|
||||
|
||||
Use **Start phone login**. If MAX shows a robot check, solve it manually on this page using the screenshot plus click/type forms. When MAX sends the confirmation code, submit it on the same page or from the Android app MAX panel.
|
||||
|
||||
The worker stores browser state in the `qmax-max-profile` Docker volume, so the MAX session should survive restarts.
|
||||
|
||||
## Android Pairing
|
||||
|
||||
Build or install:
|
||||
|
||||
```powershell
|
||||
cd android
|
||||
.\gradlew.bat :app:assembleRelease --console=plain --no-daemon
|
||||
```
|
||||
|
||||
On first launch:
|
||||
|
||||
- Server: `https://qmax.kusoft.xyz`
|
||||
- Code: `QMAX_PAIRING_CODE` from `.env`
|
||||
|
||||
The first screen after pairing is the chat list.
|
||||
|
||||
## APK Update Catalog
|
||||
|
||||
Create a release package:
|
||||
|
||||
```powershell
|
||||
.\scripts\package-android-release.ps1 -Version 0.1.4 -VersionCode 5
|
||||
```
|
||||
|
||||
The compose file mounts `deploy/releases` as `/data/releases`, so files generated by the packaging script are served automatically after `docker compose up -d`. The server exposes:
|
||||
|
||||
```text
|
||||
GET /api/app-updates/android/latest
|
||||
GET /api/app-updates/android/download/{fileName}
|
||||
```
|
||||
|
||||
Use one stable release key for production APKs. Do not install a new APK signed by a different key over an already installed QMAX app.
|
||||
|
||||
## Push Notifications
|
||||
|
||||
The server has an FCM HTTP v1 dispatcher. To enable real push delivery on the Raspberry Pi, put a Firebase service account JSON under `deploy/secrets/` and set:
|
||||
|
||||
```dotenv
|
||||
QMAX_FIREBASE_PROJECT_ID=qmax-29df6
|
||||
QMAX_FIREBASE_SERVICE_ACCOUNT_PATH=/run/secrets/qmax/firebase-service-account.json
|
||||
```
|
||||
|
||||
The Android app registers its FCM token after pairing. It uses the Google Services Gradle plugin with `android/app/google-services.json` for the package `xyz.kusoft.qmax`. `FirebaseBootstrap` still supports asset-based `firebase.android.json` or `google-services.json` as a fallback for custom local builds.
|
||||
|
||||
## Argus Publication
|
||||
|
||||
QMAX is published in Argus with:
|
||||
|
||||
- base URL: `https://argus.kusoft.xyz`
|
||||
- slug: `qmax`
|
||||
- platform: `android`
|
||||
- channel: `stable`
|
||||
|
||||
Manifest:
|
||||
|
||||
```text
|
||||
https://argus.kusoft.xyz/api/apps/qmax/manifest?platform=android&channel=stable
|
||||
```
|
||||
|
||||
Latest APK:
|
||||
|
||||
```text
|
||||
https://argus.kusoft.xyz/api/apps/qmax/download/latest?platform=android&channel=stable
|
||||
```
|
||||
|
||||
The Android app checks this manifest, compares semantic versions, downloads the APK to a temporary file, verifies SHA-256, and only then opens Android's package installer.
|
||||
|
||||
## Current MAX Mapping Status
|
||||
|
||||
The deployed worker is authorized in MAX Web and currently maps:
|
||||
|
||||
- chat list extraction from the left MAX Web dialog list;
|
||||
- visible chat history extraction when Android opens a dialog;
|
||||
- text sending through the MAX Web composer;
|
||||
- attachment upload through the MAX Web file chooser;
|
||||
- image, video, file and voice attachment projection from MAX Web;
|
||||
- Android image attachment caching with `.part` downloads before a file is shown from local storage;
|
||||
- manual browser inspection endpoints for future selector changes.
|
||||
|
||||
The bridge uses DOM selectors in `worker/src/server.js`, so MAX Web UI changes may require a worker redeploy without changing the Android app contract.
|
||||
@@ -0,0 +1,82 @@
|
||||
import java.util.Properties
|
||||
|
||||
plugins {
|
||||
id("com.android.application")
|
||||
id("org.jetbrains.kotlin.plugin.compose")
|
||||
id("org.jetbrains.kotlin.plugin.serialization")
|
||||
id("com.google.gms.google-services")
|
||||
}
|
||||
|
||||
val signingProperties = Properties().apply {
|
||||
val file = rootProject.file("key.properties")
|
||||
if (file.exists()) {
|
||||
file.inputStream().use(::load)
|
||||
}
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "xyz.kusoft.qmax"
|
||||
compileSdk = 36
|
||||
|
||||
defaultConfig {
|
||||
applicationId = "xyz.kusoft.qmax"
|
||||
minSdk = 26
|
||||
targetSdk = 36
|
||||
versionCode = 47
|
||||
versionName = "0.1.46"
|
||||
|
||||
buildConfigField("String", "QMAX_DEFAULT_SERVER_URL", "\"https://qmax.kusoft.xyz\"")
|
||||
buildConfigField("String", "QMAX_DEFAULT_PAIRING_CODE", "\"qmax-MxRq4h2HQBEIFs6k\"")
|
||||
}
|
||||
|
||||
buildFeatures {
|
||||
compose = true
|
||||
buildConfig = true
|
||||
}
|
||||
|
||||
signingConfigs {
|
||||
create("release") {
|
||||
val storeFilePath = signingProperties.getProperty("storeFile")
|
||||
if (!storeFilePath.isNullOrBlank()) {
|
||||
storeFile = rootProject.file(storeFilePath)
|
||||
storePassword = signingProperties.getProperty("storePassword")
|
||||
keyAlias = signingProperties.getProperty("keyAlias")
|
||||
keyPassword = signingProperties.getProperty("keyPassword")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
isMinifyEnabled = false
|
||||
signingConfig = if (signingProperties.getProperty("storeFile").isNullOrBlank()) {
|
||||
signingConfigs.getByName("debug")
|
||||
} else {
|
||||
signingConfigs.getByName("release")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation("androidx.core:core-ktx:1.18.0")
|
||||
implementation("androidx.activity:activity-compose:1.11.0")
|
||||
implementation("androidx.compose.ui:ui:1.10.5")
|
||||
implementation("androidx.compose.ui:ui-tooling-preview:1.10.5")
|
||||
implementation("androidx.compose.material3:material3:1.4.0")
|
||||
implementation("androidx.compose.material:material-icons-extended:1.7.8")
|
||||
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.9.4")
|
||||
implementation("androidx.lifecycle:lifecycle-runtime-compose:2.9.4")
|
||||
implementation("androidx.navigation:navigation-compose:2.9.1")
|
||||
implementation("androidx.media3:media3-exoplayer:1.8.0")
|
||||
implementation("androidx.media3:media3-ui:1.8.0")
|
||||
implementation("androidx.datastore:datastore-preferences:1.1.7")
|
||||
implementation("com.squareup.okhttp3:okhttp:4.12.0")
|
||||
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.9.0")
|
||||
implementation("com.google.code.gson:gson:2.11.0")
|
||||
implementation("io.coil-kt:coil-compose:2.7.0")
|
||||
implementation("com.microsoft.signalr:signalr:8.0.17")
|
||||
implementation("com.google.firebase:firebase-messaging:25.0.1")
|
||||
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-play-services:1.9.0")
|
||||
debugImplementation("androidx.compose.ui:ui-tooling:1.10.5")
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"project_info": {
|
||||
"project_number": "471457572036",
|
||||
"project_id": "qmax-29df6",
|
||||
"storage_bucket": "qmax-29df6.firebasestorage.app"
|
||||
},
|
||||
"client": [
|
||||
{
|
||||
"client_info": {
|
||||
"mobilesdk_app_id": "1:471457572036:android:249a779d7a39b9c9359591",
|
||||
"android_client_info": {
|
||||
"package_name": "xyz.kusoft.qmax"
|
||||
}
|
||||
},
|
||||
"oauth_client": [],
|
||||
"api_key": [
|
||||
{
|
||||
"current_key": "AIzaSyBYwLKxYG6nxBG6P11XHE-A4r6Pzz2umUk"
|
||||
},
|
||||
{
|
||||
"current_key": "AIzaSyB5QNIY1gBFdz92D_HFxX3wWMCn0HJSUc8"
|
||||
}
|
||||
],
|
||||
"services": {
|
||||
"appinvite_service": {
|
||||
"other_platform_oauth_client": []
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"configuration_version": "1"
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />
|
||||
<uses-permission android:name="android.permission.READ_MEDIA_VIDEO" />
|
||||
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
|
||||
|
||||
<application
|
||||
android:name=".QMaxApplication"
|
||||
android:allowBackup="false"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="QMAX"
|
||||
android:roundIcon="@mipmap/ic_launcher"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/AppTheme">
|
||||
<provider
|
||||
android:name="androidx.core.content.FileProvider"
|
||||
android:authorities="${applicationId}.fileprovider"
|
||||
android:exported="false"
|
||||
android:grantUriPermissions="true">
|
||||
<meta-data
|
||||
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||
android:resource="@xml/file_paths" />
|
||||
</provider>
|
||||
|
||||
<service
|
||||
android:name=".core.push.QMaxFirebaseMessagingService"
|
||||
android:exported="false">
|
||||
<intent-filter>
|
||||
<action android:name="com.google.firebase.MESSAGING_EVENT" />
|
||||
</intent-filter>
|
||||
</service>
|
||||
|
||||
<receiver
|
||||
android:name=".core.push.MarkChatReadReceiver"
|
||||
android:exported="false" />
|
||||
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
android:launchMode="singleTop"
|
||||
android:windowSoftInputMode="adjustResize">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
<intent-filter>
|
||||
<action android:name="QMAX_OPEN_CHAT" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
</application>
|
||||
</manifest>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,20 @@
|
||||
package xyz.kusoft.qmax
|
||||
|
||||
import android.app.Application
|
||||
import xyz.kusoft.qmax.core.QMaxContainer
|
||||
import xyz.kusoft.qmax.core.push.FirebaseBootstrap
|
||||
import xyz.kusoft.qmax.core.push.ForegroundChatTracker
|
||||
import xyz.kusoft.qmax.core.push.PushNotifications
|
||||
|
||||
class QMaxApplication : Application() {
|
||||
lateinit var container: QMaxContainer
|
||||
private set
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
ForegroundChatTracker.clearActiveChat(this)
|
||||
FirebaseBootstrap.ensureInitialized(this)
|
||||
PushNotifications.ensureChannels(this)
|
||||
container = QMaxContainer(this)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package xyz.kusoft.qmax.core
|
||||
|
||||
import android.content.Context
|
||||
import xyz.kusoft.qmax.core.local.AttachmentDiskCache
|
||||
import xyz.kusoft.qmax.core.local.LocalMessageCache
|
||||
import xyz.kusoft.qmax.core.network.QMaxApi
|
||||
import xyz.kusoft.qmax.core.settings.MessageDraftStore
|
||||
import xyz.kusoft.qmax.core.settings.TokenStore
|
||||
|
||||
class QMaxContainer(context: Context) {
|
||||
val tokenStore = TokenStore(context)
|
||||
val draftStore = MessageDraftStore(context)
|
||||
val messageCache = LocalMessageCache(context)
|
||||
val attachmentCache = AttachmentDiskCache(context)
|
||||
val api = QMaxApi()
|
||||
val repository = QMaxRepository(context, api, tokenStore, draftStore, messageCache, attachmentCache)
|
||||
}
|
||||
@@ -0,0 +1,501 @@
|
||||
package xyz.kusoft.qmax.core
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.ClipData
|
||||
import android.content.ActivityNotFoundException
|
||||
import android.net.Uri
|
||||
import android.provider.OpenableColumns
|
||||
import androidx.core.content.FileProvider
|
||||
import com.google.firebase.messaging.FirebaseMessaging
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.tasks.await
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import okhttp3.RequestBody.Companion.asRequestBody
|
||||
import okhttp3.MediaType.Companion.toMediaTypeOrNull
|
||||
import okhttp3.RequestBody
|
||||
import okio.BufferedSink
|
||||
import xyz.kusoft.qmax.BuildConfig
|
||||
import xyz.kusoft.qmax.core.local.AttachmentDiskCache
|
||||
import xyz.kusoft.qmax.core.local.LocalMessageCache
|
||||
import xyz.kusoft.qmax.core.model.ArgusManifestDto
|
||||
import xyz.kusoft.qmax.core.model.AttachmentDto
|
||||
import xyz.kusoft.qmax.core.model.AuthResponse
|
||||
import xyz.kusoft.qmax.core.model.ChatDto
|
||||
import xyz.kusoft.qmax.core.model.ChatPresenceDto
|
||||
import xyz.kusoft.qmax.core.model.MaxBridgeStatusDto
|
||||
import xyz.kusoft.qmax.core.model.MessageDto
|
||||
import xyz.kusoft.qmax.core.model.QMaxSession
|
||||
import xyz.kusoft.qmax.core.network.QMaxApi
|
||||
import xyz.kusoft.qmax.core.network.QMaxHttpException
|
||||
import xyz.kusoft.qmax.core.push.FirebaseBootstrap
|
||||
import xyz.kusoft.qmax.core.settings.MessageDraftStore
|
||||
import xyz.kusoft.qmax.core.settings.TokenStore
|
||||
import java.io.File
|
||||
import java.security.MessageDigest
|
||||
import java.util.Locale
|
||||
|
||||
class QMaxRepository(
|
||||
private val context: Context,
|
||||
private val api: QMaxApi,
|
||||
private val tokenStore: TokenStore,
|
||||
private val draftStore: MessageDraftStore,
|
||||
private val messageCache: LocalMessageCache,
|
||||
private val attachmentCache: AttachmentDiskCache
|
||||
) {
|
||||
val session: Flow<QMaxSession?> = tokenStore.session
|
||||
val drafts: Flow<Map<String, String>> = draftStore.drafts
|
||||
private val refreshMutex = Mutex()
|
||||
private var lastRefreshedSession: QMaxSession? = null
|
||||
|
||||
suspend fun login(serverUrl: String, pairingCode: String): AuthResponse {
|
||||
val response = api.login(serverUrl.ifBlank { BuildConfig.QMAX_DEFAULT_SERVER_URL }, pairingCode, android.os.Build.MODEL)
|
||||
tokenStore.save(
|
||||
QMaxSession(
|
||||
serverUrl = serverUrl.ifBlank { BuildConfig.QMAX_DEFAULT_SERVER_URL },
|
||||
accessToken = response.accessToken,
|
||||
refreshToken = response.refreshToken,
|
||||
userName = response.user.displayName
|
||||
)
|
||||
)
|
||||
return response
|
||||
}
|
||||
|
||||
suspend fun logout() {
|
||||
tokenStore.clear()
|
||||
draftStore.clearAll()
|
||||
messageCache.clearAll()
|
||||
attachmentCache.clearAll()
|
||||
}
|
||||
|
||||
suspend fun cachedChats(session: QMaxSession): List<ChatDto> {
|
||||
return messageCache.chats(session)
|
||||
}
|
||||
|
||||
suspend fun cacheChats(session: QMaxSession, chats: List<ChatDto>) {
|
||||
messageCache.saveChats(session, orderedChats(chats))
|
||||
}
|
||||
|
||||
suspend fun chats(session: QMaxSession): List<ChatDto> {
|
||||
val chats = withFreshSession(session) { api.chats(it.serverUrl, it.accessToken) }
|
||||
if (chats.isEmpty()) {
|
||||
error("Chat list response is empty.")
|
||||
}
|
||||
messageCache.saveChats(session, chats)
|
||||
return chats
|
||||
}
|
||||
|
||||
suspend fun createChat(session: QMaxSession, externalId: String, title: String): ChatDto {
|
||||
val chat = withFreshSession(session) { api.createDirect(it.serverUrl, it.accessToken, externalId, title) }
|
||||
val updatedChats = (cachedChats(session).filterNot { it.id == chat.id } + chat)
|
||||
.let(::orderedChats)
|
||||
messageCache.saveChats(session, updatedChats)
|
||||
return chat
|
||||
}
|
||||
|
||||
suspend fun cachedMessages(session: QMaxSession, chatId: String): List<MessageDto> {
|
||||
return messageCache.messages(session, chatId)
|
||||
}
|
||||
|
||||
suspend fun messages(session: QMaxSession, chatId: String): List<MessageDto> {
|
||||
val messages = withFreshSession(session) { api.messages(it.serverUrl, it.accessToken, chatId) }
|
||||
messageCache.saveMessages(session, chatId, messages)
|
||||
return messages
|
||||
}
|
||||
|
||||
suspend fun markChatRead(session: QMaxSession, chatId: String) {
|
||||
withFreshSession(session) { api.markChatRead(it.serverUrl, it.accessToken, chatId) }
|
||||
val updatedChats = cachedChats(session).map { chat ->
|
||||
if (chat.id == chatId) chat.copy(unreadCount = 0) else chat
|
||||
}
|
||||
messageCache.saveChats(session, orderedChats(updatedChats))
|
||||
}
|
||||
|
||||
suspend fun searchMessages(session: QMaxSession, chatId: String, query: String): List<MessageDto> {
|
||||
val messages = withFreshSession(session) { api.searchMessages(it.serverUrl, it.accessToken, chatId, query) }
|
||||
messages.forEach { messageCache.upsertMessage(session, it) }
|
||||
return messages
|
||||
}
|
||||
|
||||
suspend fun chatPresence(session: QMaxSession, chatId: String): ChatPresenceDto {
|
||||
return withFreshSession(session) { api.chatPresence(it.serverUrl, it.accessToken, chatId) }
|
||||
}
|
||||
|
||||
suspend fun sendMessage(session: QMaxSession, chatId: String, text: String, replyToMessageId: String?): MessageDto {
|
||||
val message = withFreshSession(session) {
|
||||
api.sendMessage(it.serverUrl, it.accessToken, chatId, text, replyToMessageId)
|
||||
}
|
||||
messageCache.upsertMessage(session, message)
|
||||
return message
|
||||
}
|
||||
|
||||
suspend fun editMessage(session: QMaxSession, chatId: String, messageId: String, text: String): MessageDto {
|
||||
val message = withFreshSession(session) {
|
||||
api.editMessage(it.serverUrl, it.accessToken, chatId, messageId, text)
|
||||
}
|
||||
messageCache.upsertMessage(session, message)
|
||||
return message
|
||||
}
|
||||
|
||||
suspend fun deleteMessage(session: QMaxSession, chatId: String, messageId: String) {
|
||||
withFreshSession(session) {
|
||||
api.deleteMessage(it.serverUrl, it.accessToken, chatId, messageId)
|
||||
}
|
||||
messageCache.deleteMessage(session, chatId, messageId)
|
||||
}
|
||||
|
||||
suspend fun deleteCachedMessage(session: QMaxSession, chatId: String, messageId: String) {
|
||||
messageCache.deleteMessage(session, chatId, messageId)
|
||||
}
|
||||
|
||||
suspend fun setReaction(session: QMaxSession, chatId: String, messageId: String, emoji: String): MessageDto {
|
||||
val message = withFreshSession(session) {
|
||||
api.setReaction(it.serverUrl, it.accessToken, chatId, messageId, emoji)
|
||||
}
|
||||
messageCache.upsertMessage(session, message)
|
||||
return message
|
||||
}
|
||||
|
||||
suspend fun clearReaction(session: QMaxSession, chatId: String, messageId: String): MessageDto {
|
||||
val message = withFreshSession(session) {
|
||||
api.clearReaction(it.serverUrl, it.accessToken, chatId, messageId)
|
||||
}
|
||||
messageCache.upsertMessage(session, message)
|
||||
return message
|
||||
}
|
||||
|
||||
suspend fun forwardMessage(
|
||||
session: QMaxSession,
|
||||
sourceChatId: String,
|
||||
messageId: String,
|
||||
targetChatId: String
|
||||
): MessageDto {
|
||||
val message = withFreshSession(session) {
|
||||
api.forwardMessage(it.serverUrl, it.accessToken, sourceChatId, messageId, targetChatId)
|
||||
}
|
||||
messageCache.upsertMessage(session, message)
|
||||
return message
|
||||
}
|
||||
|
||||
suspend fun upload(session: QMaxSession, chatId: String, uri: Uri, caption: String?, replyToMessageId: String?): MessageDto {
|
||||
val meta = queryMeta(uri)
|
||||
val body = contentUriRequestBody(uri, meta.contentType)
|
||||
return withFreshSession(session) {
|
||||
api.uploadAttachment(
|
||||
it.serverUrl,
|
||||
it.accessToken,
|
||||
chatId,
|
||||
meta.fileName,
|
||||
meta.contentType,
|
||||
body,
|
||||
caption,
|
||||
replyToMessageId
|
||||
)
|
||||
}.also { messageCache.upsertMessage(session, it) }
|
||||
}
|
||||
|
||||
suspend fun uploadVoice(session: QMaxSession, chatId: String, file: File, replyToMessageId: String?): MessageDto {
|
||||
val contentType = "audio/mp4"
|
||||
val body = file.asRequestBody(contentType.toMediaTypeOrNull())
|
||||
return withFreshSession(session) {
|
||||
api.uploadAttachment(it.serverUrl, it.accessToken, chatId, file.name, contentType, body, null, replyToMessageId)
|
||||
}.also { messageCache.upsertMessage(session, it) }
|
||||
}
|
||||
|
||||
suspend fun cacheRealtimeMessage(session: QMaxSession, message: MessageDto) {
|
||||
messageCache.upsertMessage(session, message)
|
||||
}
|
||||
|
||||
suspend fun cachedAttachmentFile(session: QMaxSession, attachment: AttachmentDto): File {
|
||||
return withFreshSession(session) { freshSession ->
|
||||
val url = absoluteUrl(freshSession, attachment.downloadPath)
|
||||
attachmentCache.cachedFile(freshSession, attachment) { target ->
|
||||
api.downloadToFile(
|
||||
url = url,
|
||||
target = target,
|
||||
token = freshSession.accessToken,
|
||||
expectedSize = attachment.fileSizeBytes
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun openAttachment(session: QMaxSession, attachment: AttachmentDto): File {
|
||||
val file = cachedAttachmentFile(session, attachment)
|
||||
val uri = FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", file)
|
||||
val contentType = attachment.contentType.trim()
|
||||
val fileName = attachment.fileName.lowercase(Locale.ROOT)
|
||||
val mimeType = when {
|
||||
contentType.contains("vcard", ignoreCase = true) || fileName.endsWith(".vcf") -> "text/x-vcard"
|
||||
contentType.isNotBlank() -> contentType
|
||||
else -> "application/octet-stream"
|
||||
}
|
||||
val intent = Intent(Intent.ACTION_VIEW)
|
||||
.setDataAndType(uri, mimeType)
|
||||
.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
intent.clipData = ClipData.newUri(context.contentResolver, attachment.fileName, uri)
|
||||
try {
|
||||
context.startActivity(Intent.createChooser(intent, attachment.fileName).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK))
|
||||
} catch (_: ActivityNotFoundException) {
|
||||
throw IllegalStateException("Нет приложения для открытия этого файла")
|
||||
}
|
||||
return file
|
||||
}
|
||||
|
||||
suspend fun shareMessage(session: QMaxSession, message: MessageDto) {
|
||||
sharePayload(
|
||||
session = session,
|
||||
text = message.text?.takeIf { it.isNotBlank() },
|
||||
attachments = message.attachments.sortedBy { it.sortOrder }
|
||||
)
|
||||
}
|
||||
|
||||
suspend fun shareAttachment(session: QMaxSession, attachment: AttachmentDto) {
|
||||
sharePayload(session = session, text = null, attachments = listOf(attachment))
|
||||
}
|
||||
|
||||
suspend fun maxStatus(session: QMaxSession): MaxBridgeStatusDto {
|
||||
return withFreshSession(session) { api.maxStatus(it.serverUrl, it.accessToken) }
|
||||
}
|
||||
|
||||
suspend fun startMaxLogin(session: QMaxSession, phone: String?): MaxBridgeStatusDto {
|
||||
return withFreshSession(session) { api.startMaxLogin(it.serverUrl, it.accessToken, phone) }
|
||||
}
|
||||
|
||||
suspend fun submitMaxCode(session: QMaxSession, code: String): MaxBridgeStatusDto {
|
||||
return withFreshSession(session) { api.submitMaxCode(it.serverUrl, it.accessToken, code) }
|
||||
}
|
||||
|
||||
suspend fun registerCurrentPushDevice(session: QMaxSession) {
|
||||
if (!FirebaseBootstrap.ensureInitialized(context)) {
|
||||
return
|
||||
}
|
||||
|
||||
val token = FirebaseMessaging.getInstance().token.await()
|
||||
registerPushDevice(session, token)
|
||||
}
|
||||
|
||||
suspend fun registerPushDevice(session: QMaxSession, firebaseToken: String) {
|
||||
if (firebaseToken.isBlank()) {
|
||||
return
|
||||
}
|
||||
|
||||
withFreshSession(session) {
|
||||
api.registerPushDevice(it.serverUrl, it.accessToken, firebaseToken)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun sharePayload(session: QMaxSession, text: String?, attachments: List<AttachmentDto>) {
|
||||
val files = attachments.map { attachment ->
|
||||
SharedAttachment(
|
||||
attachment = attachment,
|
||||
file = cachedAttachmentFile(session, attachment),
|
||||
mimeType = attachment.contentType.takeIf { it.isNotBlank() } ?: "application/octet-stream"
|
||||
)
|
||||
}
|
||||
val uris = files.map {
|
||||
FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", it.file)
|
||||
}
|
||||
|
||||
val intent = when {
|
||||
files.isEmpty() -> Intent(Intent.ACTION_SEND)
|
||||
.setType("text/plain")
|
||||
.putExtra(Intent.EXTRA_TEXT, text.orEmpty())
|
||||
files.size == 1 -> Intent(Intent.ACTION_SEND)
|
||||
.setType(files.single().mimeType)
|
||||
.putExtra(Intent.EXTRA_STREAM, uris.single())
|
||||
else -> Intent(Intent.ACTION_SEND_MULTIPLE)
|
||||
.setType(commonShareMime(files.map { it.mimeType }))
|
||||
.putParcelableArrayListExtra(Intent.EXTRA_STREAM, ArrayList(uris))
|
||||
}
|
||||
|
||||
text?.takeIf { it.isNotBlank() }?.let {
|
||||
intent.putExtra(Intent.EXTRA_TEXT, it)
|
||||
}
|
||||
|
||||
if (uris.isNotEmpty()) {
|
||||
intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||
intent.clipData = shareClipData(files, uris)
|
||||
}
|
||||
|
||||
try {
|
||||
context.startActivity(
|
||||
Intent.createChooser(intent, "Поделиться")
|
||||
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||
)
|
||||
} catch (_: ActivityNotFoundException) {
|
||||
throw IllegalStateException("Нет приложения, чтобы поделиться этим содержимым")
|
||||
}
|
||||
}
|
||||
|
||||
private fun shareClipData(files: List<SharedAttachment>, uris: List<Uri>): ClipData {
|
||||
val first = files.first()
|
||||
return ClipData.newUri(context.contentResolver, first.attachment.fileName, uris.first()).apply {
|
||||
uris.drop(1).forEach { uri ->
|
||||
addItem(ClipData.Item(uri))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun commonShareMime(types: List<String>): String {
|
||||
val normalized = types.map { it.takeIf(String::isNotBlank) ?: "application/octet-stream" }
|
||||
if (normalized.isEmpty()) return "*/*"
|
||||
if (normalized.distinct().size == 1) return normalized.first()
|
||||
val families = normalized.map { it.substringBefore('/', missingDelimiterValue = "*") }.distinct()
|
||||
return if (families.size == 1 && families.first() != "*") {
|
||||
"${families.first()}/*"
|
||||
} else {
|
||||
"*/*"
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun refreshSession(session: QMaxSession): QMaxSession {
|
||||
val response = api.refresh(session.serverUrl, session.refreshToken)
|
||||
val refreshed = session.copy(
|
||||
accessToken = response.accessToken,
|
||||
refreshToken = response.refreshToken,
|
||||
userName = response.user.displayName
|
||||
)
|
||||
tokenStore.save(refreshed)
|
||||
lastRefreshedSession = refreshed
|
||||
return refreshed
|
||||
}
|
||||
|
||||
fun absoluteUrl(session: QMaxSession, path: String): String = api.absoluteUrl(session.serverUrl, path)
|
||||
|
||||
private fun orderedChats(chats: List<ChatDto>): List<ChatDto> {
|
||||
return chats.sortedWith(
|
||||
compareByDescending<ChatDto> { it.isPinned }
|
||||
.thenByDescending { it.unreadCount > 0 }
|
||||
.thenByDescending { it.lastMessageAt.orEmpty() }
|
||||
.thenBy { it.title.lowercase() }
|
||||
)
|
||||
}
|
||||
|
||||
suspend fun draft(chatId: String): String = draftStore.draft(chatId)
|
||||
|
||||
suspend fun saveDraft(chatId: String, text: String) {
|
||||
draftStore.save(chatId, text)
|
||||
}
|
||||
|
||||
suspend fun clearDraft(chatId: String) {
|
||||
draftStore.clear(chatId)
|
||||
}
|
||||
|
||||
suspend fun checkArgusUpdate(): ArgusManifestDto? {
|
||||
val manifest = api.argusManifest() ?: return null
|
||||
return if (isNewerVersion(manifest.release.version, BuildConfig.VERSION_NAME)) manifest else null
|
||||
}
|
||||
|
||||
suspend fun downloadAndOpenArgusUpdate(manifest: ArgusManifestDto): File {
|
||||
val downloadUrl = "https://argus.kusoft.xyz${manifest.release.downloadPath}"
|
||||
val target = File(context.cacheDir, "qmax-${manifest.release.version}.apk")
|
||||
val size = api.downloadToFile(downloadUrl, target)
|
||||
if (size != manifest.release.packageSizeBytes) {
|
||||
target.delete()
|
||||
error("APK size mismatch")
|
||||
}
|
||||
|
||||
val sha256 = sha256(target)
|
||||
if (!sha256.equals(manifest.release.sha256, ignoreCase = true)) {
|
||||
target.delete()
|
||||
error("APK SHA-256 mismatch")
|
||||
}
|
||||
|
||||
val uri = FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", target)
|
||||
@Suppress("DEPRECATION")
|
||||
val intent = Intent(Intent.ACTION_INSTALL_PACKAGE)
|
||||
.setDataAndType(uri, "application/vnd.android.package-archive")
|
||||
.putExtra(Intent.EXTRA_NOT_UNKNOWN_SOURCE, true)
|
||||
.putExtra(Intent.EXTRA_RETURN_RESULT, true)
|
||||
.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
context.startActivity(intent)
|
||||
return target
|
||||
}
|
||||
|
||||
private fun queryMeta(uri: Uri): FileMeta {
|
||||
val contentType = context.contentResolver.getType(uri) ?: "application/octet-stream"
|
||||
var fileName = "upload"
|
||||
context.contentResolver.query(uri, null, null, null, null)?.use { cursor ->
|
||||
val nameIndex = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME)
|
||||
if (cursor.moveToFirst() && nameIndex >= 0) {
|
||||
fileName = cursor.getString(nameIndex) ?: fileName
|
||||
}
|
||||
}
|
||||
return FileMeta(fileName, contentType)
|
||||
}
|
||||
|
||||
private fun contentUriRequestBody(uri: Uri, contentType: String): RequestBody {
|
||||
return object : RequestBody() {
|
||||
override fun contentType() = contentType.toMediaTypeOrNull()
|
||||
|
||||
override fun writeTo(sink: BufferedSink) {
|
||||
context.contentResolver.openInputStream(uri).use { input ->
|
||||
requireNotNull(input) { "Cannot open selected file" }
|
||||
val buffer = ByteArray(DEFAULT_BUFFER_SIZE)
|
||||
while (true) {
|
||||
val read = input.read(buffer)
|
||||
if (read == -1) break
|
||||
sink.write(buffer, 0, read)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private data class FileMeta(val fileName: String, val contentType: String)
|
||||
|
||||
private suspend fun <T> withFreshSession(
|
||||
session: QMaxSession,
|
||||
block: suspend (QMaxSession) -> T
|
||||
): T {
|
||||
return try {
|
||||
block(session)
|
||||
} catch (error: QMaxHttpException) {
|
||||
if (error.code != 401) {
|
||||
throw error
|
||||
}
|
||||
val refreshed = refreshMutex.withLock {
|
||||
lastRefreshedSession
|
||||
?.takeIf { it.serverUrl == session.serverUrl && it.accessToken != session.accessToken }
|
||||
?: refreshSession(session)
|
||||
}
|
||||
block(refreshed)
|
||||
}
|
||||
}
|
||||
|
||||
private fun sha256(file: File): String {
|
||||
val digest = MessageDigest.getInstance("SHA-256")
|
||||
file.inputStream().use { input ->
|
||||
val buffer = ByteArray(DEFAULT_BUFFER_SIZE)
|
||||
while (true) {
|
||||
val read = input.read(buffer)
|
||||
if (read == -1) break
|
||||
digest.update(buffer, 0, read)
|
||||
}
|
||||
}
|
||||
return digest.digest().joinToString("") { "%02x".format(it) }
|
||||
}
|
||||
|
||||
private fun isNewerVersion(remote: String, local: String): Boolean {
|
||||
val remoteParts = remote.split('.', '-', '_').map { it.toIntOrNull() ?: 0 }
|
||||
val localParts = local.split('.', '-', '_').map { it.toIntOrNull() ?: 0 }
|
||||
val max = maxOf(remoteParts.size, localParts.size)
|
||||
for (index in 0 until max) {
|
||||
val r = remoteParts.getOrElse(index) { 0 }
|
||||
val l = localParts.getOrElse(index) { 0 }
|
||||
if (r != l) return r > l
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
private data class SharedAttachment(
|
||||
val attachment: AttachmentDto,
|
||||
val file: File,
|
||||
val mimeType: String
|
||||
)
|
||||
@@ -0,0 +1,114 @@
|
||||
package xyz.kusoft.qmax.core.local
|
||||
|
||||
import android.content.Context
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import xyz.kusoft.qmax.core.model.AttachmentDto
|
||||
import xyz.kusoft.qmax.core.model.QMaxSession
|
||||
import java.io.File
|
||||
import java.security.MessageDigest
|
||||
import java.util.Locale
|
||||
|
||||
class AttachmentDiskCache(context: Context) {
|
||||
private val root = File(context.filesDir, "qmax-attachments")
|
||||
|
||||
suspend fun cachedFile(
|
||||
session: QMaxSession,
|
||||
attachment: AttachmentDto,
|
||||
downloader: suspend (File) -> Long
|
||||
): File {
|
||||
return withContext(Dispatchers.IO) {
|
||||
val target = targetFile(session, attachment)
|
||||
if (isValid(target, attachment)) {
|
||||
return@withContext target
|
||||
}
|
||||
|
||||
target.delete()
|
||||
downloader(target)
|
||||
|
||||
if (!isValid(target, attachment)) {
|
||||
val expected = attachment.fileSizeBytes.takeIf { it > 0 }?.toString() ?: "non-empty"
|
||||
val actual = if (target.exists()) target.length().toString() else "missing"
|
||||
target.delete()
|
||||
error("Attachment cache mismatch: expected $expected, got $actual")
|
||||
}
|
||||
|
||||
target
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun clearAll() {
|
||||
withContext(Dispatchers.IO) {
|
||||
root.deleteRecursively()
|
||||
}
|
||||
}
|
||||
|
||||
private fun targetFile(session: QMaxSession, attachment: AttachmentDto): File {
|
||||
val sessionDir = root.resolve(cacheKey(session)).also { it.mkdirs() }
|
||||
val key = sha256(
|
||||
listOf(
|
||||
attachment.id,
|
||||
attachment.downloadPath,
|
||||
attachment.fileName,
|
||||
attachment.contentType,
|
||||
attachment.fileSizeBytes.toString()
|
||||
).joinToString("|")
|
||||
).take(40)
|
||||
return sessionDir.resolve("$key.${extension(attachment)}")
|
||||
}
|
||||
|
||||
private fun isValid(file: File, attachment: AttachmentDto): Boolean {
|
||||
if (!file.exists() || file.length() <= 0L) {
|
||||
return false
|
||||
}
|
||||
|
||||
return attachment.fileSizeBytes <= 0L || file.length() == attachment.fileSizeBytes
|
||||
}
|
||||
|
||||
private fun cacheKey(session: QMaxSession): String {
|
||||
return sha256("${session.serverUrl}|${session.userName}").take(24)
|
||||
}
|
||||
|
||||
private fun extension(attachment: AttachmentDto): String {
|
||||
val fileExtension = attachment.fileName
|
||||
.substringAfterLast('.', "")
|
||||
.lowercase(Locale.ROOT)
|
||||
.takeIf { it.length in 1..12 && it.all { char -> char.isLetterOrDigit() } }
|
||||
|
||||
if (fileExtension != null) {
|
||||
return fileExtension
|
||||
}
|
||||
|
||||
return when (attachment.contentType.substringBefore(';').lowercase(Locale.ROOT)) {
|
||||
"image/jpeg" -> "jpg"
|
||||
"image/png" -> "png"
|
||||
"image/webp" -> "webp"
|
||||
"image/gif" -> "gif"
|
||||
"image/bmp" -> "bmp"
|
||||
"image/heic" -> "heic"
|
||||
"image/heif" -> "heif"
|
||||
"video/mp4" -> "mp4"
|
||||
"video/quicktime" -> "mov"
|
||||
"video/webm" -> "webm"
|
||||
"video/x-matroska" -> "mkv"
|
||||
"audio/ogg" -> "ogg"
|
||||
"audio/opus" -> "opus"
|
||||
"audio/mp4" -> "m4a"
|
||||
"audio/aac" -> "aac"
|
||||
"audio/mpeg" -> "mp3"
|
||||
"audio/wav" -> "wav"
|
||||
"audio/flac" -> "flac"
|
||||
"application/pdf" -> "pdf"
|
||||
"application/zip" -> "zip"
|
||||
"text/vcard" -> "vcf"
|
||||
"text/x-vcard" -> "vcf"
|
||||
"text/plain" -> "txt"
|
||||
else -> "bin"
|
||||
}
|
||||
}
|
||||
|
||||
private fun sha256(value: String): String {
|
||||
val bytes = MessageDigest.getInstance("SHA-256").digest(value.toByteArray())
|
||||
return bytes.joinToString("") { "%02x".format(it) }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
package xyz.kusoft.qmax.core.local
|
||||
|
||||
import android.content.Context
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import xyz.kusoft.qmax.core.model.ChatDto
|
||||
import xyz.kusoft.qmax.core.model.MessageDto
|
||||
import xyz.kusoft.qmax.core.model.QMaxSession
|
||||
import java.io.File
|
||||
import java.security.MessageDigest
|
||||
|
||||
class LocalMessageCache(context: Context) {
|
||||
private val root = File(context.filesDir, "qmax-cache")
|
||||
private val json = Json {
|
||||
ignoreUnknownKeys = true
|
||||
encodeDefaults = true
|
||||
}
|
||||
|
||||
suspend fun chats(session: QMaxSession): List<ChatDto> {
|
||||
return withContext(Dispatchers.IO) {
|
||||
readPayload<ChatCachePayload>(cacheDir(session).resolve("chats.json"))
|
||||
?.chats
|
||||
.orEmpty()
|
||||
.map(::cleanChat)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun saveChats(session: QMaxSession, chats: List<ChatDto>) {
|
||||
withContext(Dispatchers.IO) {
|
||||
writePayload(cacheDir(session).resolve("chats.json"), ChatCachePayload(chats = chats.map(::cleanChat)))
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun messages(session: QMaxSession, chatId: String): List<MessageDto> {
|
||||
return withContext(Dispatchers.IO) {
|
||||
readPayload<MessageCachePayload>(messageFile(session, chatId))
|
||||
?.messages
|
||||
.orEmpty()
|
||||
.filterNot(::isGenericMediaPlaceholder)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun saveMessages(session: QMaxSession, chatId: String, messages: List<MessageDto>) {
|
||||
withContext(Dispatchers.IO) {
|
||||
writePayload(
|
||||
messageFile(session, chatId),
|
||||
MessageCachePayload(
|
||||
messages = messages
|
||||
.filterNot(::isGenericMediaPlaceholder)
|
||||
.sortedBy { it.sentAt }
|
||||
.takeLast(MaxMessagesPerChat)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun upsertMessage(session: QMaxSession, message: MessageDto) {
|
||||
val current = messages(session, message.chatId)
|
||||
val updated = (current.filterNot { it.id == message.id } + message)
|
||||
.filterNot(::isGenericMediaPlaceholder)
|
||||
.sortedBy { it.sentAt }
|
||||
.takeLast(MaxMessagesPerChat)
|
||||
saveMessages(session, message.chatId, updated)
|
||||
}
|
||||
|
||||
suspend fun deleteMessage(session: QMaxSession, chatId: String, messageId: String) {
|
||||
val updated = messages(session, chatId).filterNot { it.id == messageId }
|
||||
saveMessages(session, chatId, updated)
|
||||
}
|
||||
|
||||
suspend fun clearAll() {
|
||||
withContext(Dispatchers.IO) {
|
||||
root.deleteRecursively()
|
||||
}
|
||||
}
|
||||
|
||||
private fun cacheDir(session: QMaxSession): File {
|
||||
return root.resolve(cacheKey(session)).also { it.mkdirs() }
|
||||
}
|
||||
|
||||
private fun messageFile(session: QMaxSession, chatId: String): File {
|
||||
return cacheDir(session).resolve("messages-${sha256(chatId).take(24)}.json")
|
||||
}
|
||||
|
||||
private inline fun <reified T> readPayload(file: File): T? {
|
||||
if (!file.exists() || file.length() == 0L) {
|
||||
return null
|
||||
}
|
||||
|
||||
return runCatching {
|
||||
json.decodeFromString<T>(file.readText())
|
||||
}.getOrNull()
|
||||
}
|
||||
|
||||
private inline fun <reified T> writePayload(file: File, payload: T) {
|
||||
file.parentFile?.mkdirs()
|
||||
val part = File(file.parentFile, "${file.name}.part")
|
||||
part.writeText(json.encodeToString(payload))
|
||||
if (file.exists()) {
|
||||
file.delete()
|
||||
}
|
||||
part.renameTo(file)
|
||||
}
|
||||
|
||||
private fun cacheKey(session: QMaxSession): String {
|
||||
return sha256("${session.serverUrl}|${session.userName}").take(24)
|
||||
}
|
||||
|
||||
private fun sha256(value: String): String {
|
||||
val bytes = MessageDigest.getInstance("SHA-256").digest(value.toByteArray())
|
||||
return bytes.joinToString("") { "%02x".format(it) }
|
||||
}
|
||||
|
||||
private fun cleanChat(chat: ChatDto): ChatDto {
|
||||
return when {
|
||||
isGenericMediaLabel(chat.lastMessagePreview) -> chat.copy(lastMessagePreview = "\u041c\u0435\u0434\u0438\u0430")
|
||||
isQuestionMarkArtifact(chat.lastMessagePreview) -> chat.copy(lastMessagePreview = "\u0421\u043e\u043e\u0431\u0449\u0435\u043d\u0438\u0435")
|
||||
else -> chat
|
||||
}
|
||||
}
|
||||
|
||||
private fun isGenericMediaPlaceholder(message: MessageDto): Boolean {
|
||||
return message.attachments.isEmpty() && isGenericMediaLabel(message.text)
|
||||
}
|
||||
|
||||
private fun isQuestionMarkArtifact(value: String?): Boolean {
|
||||
val text = value?.trim()
|
||||
if (text.isNullOrBlank()) return false
|
||||
val questionMarks = text.count { it == '?' || it == '\uFFFD' }
|
||||
if (questionMarks < 4) return false
|
||||
val meaningfulCharacters = text.count { it.isLetterOrDigit() && it != '?' }
|
||||
return meaningfulCharacters == 0
|
||||
}
|
||||
|
||||
private fun isGenericMediaLabel(value: String?): Boolean {
|
||||
return when (value?.trim()?.lowercase()) {
|
||||
"\u0444\u043e\u0442\u043e",
|
||||
"photo",
|
||||
"image",
|
||||
"gif",
|
||||
"\u0432\u0438\u0434\u0435\u043e",
|
||||
"video",
|
||||
"\u0430\u0443\u0434\u0438\u043e",
|
||||
"audio",
|
||||
"\u0433\u043e\u043b\u043e\u0441\u043e\u0432\u043e\u0435",
|
||||
"voice",
|
||||
"\u0441\u0442\u0438\u043a\u0435\u0440",
|
||||
"sticker",
|
||||
"\u044d\u043c\u043e\u0434\u0437\u0438",
|
||||
"emoji",
|
||||
"\u043f\u0440\u0438\u043a\u0440\u0435\u043f\u043b\u0435\u043d\u043d\u044b\u0435 \u0444\u043e\u0442\u043e",
|
||||
"\u043f\u0440\u0438\u043a\u0440\u0435\u043f\u043b\u0451\u043d\u043d\u044b\u0435 \u0444\u043e\u0442\u043e",
|
||||
"attached photos" -> true
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
private data class ChatCachePayload(
|
||||
val version: Int = 1,
|
||||
val chats: List<ChatDto>
|
||||
)
|
||||
|
||||
@Serializable
|
||||
private data class MessageCachePayload(
|
||||
val version: Int = 1,
|
||||
val messages: List<MessageDto>
|
||||
)
|
||||
|
||||
private companion object {
|
||||
const val MaxMessagesPerChat = 250
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
package xyz.kusoft.qmax.core.model
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class UserDto(
|
||||
val id: String,
|
||||
val displayName: String,
|
||||
val phoneNumber: String? = null,
|
||||
val avatarPath: String? = null
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class AuthResponse(
|
||||
val accessToken: String,
|
||||
val refreshToken: String,
|
||||
val expiresAt: String,
|
||||
val user: UserDto
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class DeviceLoginRequest(
|
||||
val pairingCode: String,
|
||||
val deviceName: String
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class RefreshTokenRequest(val refreshToken: String)
|
||||
|
||||
@Serializable
|
||||
data class ChatDto(
|
||||
val id: String,
|
||||
val externalId: String? = null,
|
||||
val kind: String,
|
||||
val title: String,
|
||||
val avatarUrl: String? = null,
|
||||
val lastMessagePreview: String? = null,
|
||||
val lastMessageAt: String? = null,
|
||||
val unreadCount: Int = 0,
|
||||
val isPinned: Boolean = false,
|
||||
val isMuted: Boolean = false,
|
||||
val isArchived: Boolean = false
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ChatPresenceDto(
|
||||
val isTyping: Boolean = false,
|
||||
val statusText: String? = null,
|
||||
val updatedAt: String? = null
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class MessageDto(
|
||||
val id: String,
|
||||
val chatId: String,
|
||||
val externalId: String? = null,
|
||||
val senderName: String? = null,
|
||||
val direction: String,
|
||||
val text: String? = null,
|
||||
val sentAt: String,
|
||||
val editedAt: String? = null,
|
||||
val deletedAt: String? = null,
|
||||
val replyToMessageId: String? = null,
|
||||
val forwardedFrom: String? = null,
|
||||
val deliveryState: String,
|
||||
val error: String? = null,
|
||||
val reactions: List<ReactionDto> = emptyList(),
|
||||
val attachments: List<AttachmentDto> = emptyList()
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ReactionDto(
|
||||
val emoji: String,
|
||||
val count: Int,
|
||||
val reactedByMe: Boolean = false
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class AttachmentDto(
|
||||
val id: String,
|
||||
val fileName: String,
|
||||
val contentType: String,
|
||||
val fileSizeBytes: Long,
|
||||
val downloadPath: String,
|
||||
val kind: String,
|
||||
val sortOrder: Int
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class SendMessageRequest(
|
||||
val text: String,
|
||||
val replyToMessageId: String? = null
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class MarkChatReadRequest(
|
||||
val read: Boolean = true
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class EditMessageRequest(
|
||||
val text: String
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ForwardMessageRequest(
|
||||
val targetChatId: String
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class SetReactionRequest(
|
||||
val emoji: String
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class MessageDeletedDto(
|
||||
val chatId: String,
|
||||
val messageId: String
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class CreateDirectChatRequest(
|
||||
val externalChatId: String,
|
||||
val title: String,
|
||||
val avatarUrl: String? = null
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class MaxBridgeStatusDto(
|
||||
val mode: String,
|
||||
val isAuthorized: Boolean,
|
||||
val loginStage: String? = null,
|
||||
val status: String,
|
||||
val url: String? = null,
|
||||
val title: String? = null,
|
||||
val lastError: String? = null,
|
||||
val updatedAt: String
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class BeginMaxLoginRequest(val phoneNumber: String? = null)
|
||||
|
||||
@Serializable
|
||||
data class SubmitMaxCodeRequest(val code: String)
|
||||
|
||||
@Serializable
|
||||
data class RegisterPushDeviceRequest(
|
||||
val firebaseToken: String,
|
||||
val platform: String = "android"
|
||||
)
|
||||
|
||||
data class QMaxSession(
|
||||
val serverUrl: String,
|
||||
val accessToken: String,
|
||||
val refreshToken: String,
|
||||
val userName: String
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ArgusManifestDto(
|
||||
val slug: String,
|
||||
val name: String,
|
||||
val summary: String? = null,
|
||||
val description: String? = null,
|
||||
val repositoryUrl: String? = null,
|
||||
val homepageUrl: String? = null,
|
||||
val release: ArgusReleaseDto
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ArgusReleaseDto(
|
||||
val id: String,
|
||||
val version: String,
|
||||
val channel: String,
|
||||
val platform: String,
|
||||
val packageKind: String,
|
||||
val downloadPath: String,
|
||||
val originalFileName: String,
|
||||
val contentType: String,
|
||||
val packageSizeBytes: Long,
|
||||
val sha256: String,
|
||||
val publishedAt: String,
|
||||
val notes: String? = null
|
||||
)
|
||||
@@ -0,0 +1,418 @@
|
||||
package xyz.kusoft.qmax.core.network
|
||||
|
||||
import android.util.Log
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.contentOrNull
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import okhttp3.Call
|
||||
import okhttp3.Callback
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.MultipartBody
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.RequestBody
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import okhttp3.Response
|
||||
import xyz.kusoft.qmax.BuildConfig
|
||||
import xyz.kusoft.qmax.core.model.AuthResponse
|
||||
import xyz.kusoft.qmax.core.model.ArgusManifestDto
|
||||
import xyz.kusoft.qmax.core.model.BeginMaxLoginRequest
|
||||
import xyz.kusoft.qmax.core.model.ChatDto
|
||||
import xyz.kusoft.qmax.core.model.ChatPresenceDto
|
||||
import xyz.kusoft.qmax.core.model.CreateDirectChatRequest
|
||||
import xyz.kusoft.qmax.core.model.DeviceLoginRequest
|
||||
import xyz.kusoft.qmax.core.model.EditMessageRequest
|
||||
import xyz.kusoft.qmax.core.model.ForwardMessageRequest
|
||||
import xyz.kusoft.qmax.core.model.MarkChatReadRequest
|
||||
import xyz.kusoft.qmax.core.model.MaxBridgeStatusDto
|
||||
import xyz.kusoft.qmax.core.model.MessageDto
|
||||
import xyz.kusoft.qmax.core.model.RegisterPushDeviceRequest
|
||||
import xyz.kusoft.qmax.core.model.SendMessageRequest
|
||||
import xyz.kusoft.qmax.core.model.SetReactionRequest
|
||||
import xyz.kusoft.qmax.core.model.SubmitMaxCodeRequest
|
||||
import java.io.IOException
|
||||
import java.io.File
|
||||
import java.io.FileOutputStream
|
||||
import java.net.URLEncoder
|
||||
import java.nio.charset.StandardCharsets
|
||||
import java.util.concurrent.TimeUnit
|
||||
import kotlin.coroutines.resume
|
||||
import kotlin.coroutines.resumeWithException
|
||||
|
||||
class QMaxHttpException(val code: Int, message: String) : IOException(message)
|
||||
|
||||
class QMaxApi {
|
||||
private val client = OkHttpClient.Builder()
|
||||
.connectTimeout(20, TimeUnit.SECONDS)
|
||||
.readTimeout(120, TimeUnit.SECONDS)
|
||||
.writeTimeout(30, TimeUnit.SECONDS)
|
||||
.callTimeout(150, TimeUnit.SECONDS)
|
||||
.build()
|
||||
private val downloadClient = client.newBuilder()
|
||||
.callTimeout(5, TimeUnit.MINUTES)
|
||||
.build()
|
||||
private val json = Json {
|
||||
ignoreUnknownKeys = true
|
||||
encodeDefaults = true
|
||||
}
|
||||
private val jsonMediaType = "application/json; charset=utf-8".toMediaType()
|
||||
|
||||
suspend fun login(serverUrl: String, pairingCode: String, deviceName: String): AuthResponse {
|
||||
return post(serverUrl, "/api/auth/device/login", null, DeviceLoginRequest(pairingCode, deviceName))
|
||||
}
|
||||
|
||||
suspend fun refresh(serverUrl: String, refreshToken: String): AuthResponse {
|
||||
return post(serverUrl, "/api/auth/refresh", null, xyz.kusoft.qmax.core.model.RefreshTokenRequest(refreshToken))
|
||||
}
|
||||
|
||||
suspend fun chats(sessionServerUrl: String, token: String): List<ChatDto> {
|
||||
return get(sessionServerUrl, "/api/chats", token)
|
||||
}
|
||||
|
||||
suspend fun createDirect(sessionServerUrl: String, token: String, externalChatId: String, title: String): ChatDto {
|
||||
return post(sessionServerUrl, "/api/chats/direct", token, CreateDirectChatRequest(externalChatId, title))
|
||||
}
|
||||
|
||||
suspend fun messages(sessionServerUrl: String, token: String, chatId: String): List<MessageDto> {
|
||||
return get(sessionServerUrl, "/api/chats/$chatId/messages", token)
|
||||
}
|
||||
|
||||
suspend fun markChatRead(sessionServerUrl: String, token: String, chatId: String) {
|
||||
postNoContent(sessionServerUrl, "/api/chats/$chatId/read", token, MarkChatReadRequest())
|
||||
}
|
||||
|
||||
suspend fun searchMessages(sessionServerUrl: String, token: String, chatId: String, query: String): List<MessageDto> {
|
||||
val encodedQuery = URLEncoder.encode(query, StandardCharsets.UTF_8.toString())
|
||||
return get(sessionServerUrl, "/api/chats/$chatId/search?q=$encodedQuery", token)
|
||||
}
|
||||
|
||||
suspend fun chatPresence(sessionServerUrl: String, token: String, chatId: String): ChatPresenceDto {
|
||||
return get(sessionServerUrl, "/api/chats/$chatId/presence", token)
|
||||
}
|
||||
|
||||
suspend fun sendMessage(
|
||||
sessionServerUrl: String,
|
||||
token: String,
|
||||
chatId: String,
|
||||
text: String,
|
||||
replyToMessageId: String?
|
||||
): MessageDto {
|
||||
return post(sessionServerUrl, "/api/chats/$chatId/messages", token, SendMessageRequest(text, replyToMessageId))
|
||||
}
|
||||
|
||||
suspend fun editMessage(
|
||||
sessionServerUrl: String,
|
||||
token: String,
|
||||
chatId: String,
|
||||
messageId: String,
|
||||
text: String
|
||||
): MessageDto {
|
||||
return patch(sessionServerUrl, "/api/chats/$chatId/messages/$messageId", token, EditMessageRequest(text))
|
||||
}
|
||||
|
||||
suspend fun deleteMessage(sessionServerUrl: String, token: String, chatId: String, messageId: String) {
|
||||
delete(sessionServerUrl, "/api/chats/$chatId/messages/$messageId", token)
|
||||
}
|
||||
|
||||
suspend fun setReaction(sessionServerUrl: String, token: String, chatId: String, messageId: String, emoji: String): MessageDto {
|
||||
return put(sessionServerUrl, "/api/chats/$chatId/messages/$messageId/reaction", token, SetReactionRequest(emoji))
|
||||
}
|
||||
|
||||
suspend fun clearReaction(sessionServerUrl: String, token: String, chatId: String, messageId: String): MessageDto {
|
||||
return deleteForJson(sessionServerUrl, "/api/chats/$chatId/messages/$messageId/reaction", token)
|
||||
}
|
||||
|
||||
suspend fun forwardMessage(
|
||||
sessionServerUrl: String,
|
||||
token: String,
|
||||
sourceChatId: String,
|
||||
messageId: String,
|
||||
targetChatId: String
|
||||
): MessageDto {
|
||||
return post(
|
||||
sessionServerUrl,
|
||||
"/api/chats/$sourceChatId/messages/$messageId/forward",
|
||||
token,
|
||||
ForwardMessageRequest(targetChatId)
|
||||
)
|
||||
}
|
||||
|
||||
suspend fun uploadAttachment(
|
||||
sessionServerUrl: String,
|
||||
token: String,
|
||||
chatId: String,
|
||||
fileName: String,
|
||||
contentType: String,
|
||||
body: RequestBody,
|
||||
caption: String?,
|
||||
replyToMessageId: String?
|
||||
): MessageDto {
|
||||
val multipart = MultipartBody.Builder()
|
||||
.setType(MultipartBody.FORM)
|
||||
.addFormDataPart("file", fileName, body)
|
||||
.apply {
|
||||
if (!caption.isNullOrBlank()) addFormDataPart("caption", caption)
|
||||
if (!replyToMessageId.isNullOrBlank()) addFormDataPart("replyToMessageId", replyToMessageId)
|
||||
}
|
||||
.build()
|
||||
|
||||
val request = Request.Builder()
|
||||
.url("${sessionServerUrl.trimEnd('/')}/api/chats/$chatId/attachments")
|
||||
.header("Authorization", "Bearer $token")
|
||||
.post(multipart)
|
||||
.build()
|
||||
|
||||
return execute(request)
|
||||
}
|
||||
|
||||
suspend fun maxStatus(sessionServerUrl: String, token: String): MaxBridgeStatusDto {
|
||||
return get(sessionServerUrl, "/api/max/status", token)
|
||||
}
|
||||
|
||||
suspend fun startMaxLogin(sessionServerUrl: String, token: String, phone: String?): MaxBridgeStatusDto {
|
||||
return post(sessionServerUrl, "/api/max/login/start", token, BeginMaxLoginRequest(phone))
|
||||
}
|
||||
|
||||
suspend fun submitMaxCode(sessionServerUrl: String, token: String, code: String): MaxBridgeStatusDto {
|
||||
return post(sessionServerUrl, "/api/max/login/code", token, SubmitMaxCodeRequest(code))
|
||||
}
|
||||
|
||||
suspend fun registerPushDevice(sessionServerUrl: String, token: String, firebaseToken: String) {
|
||||
postNoContent(sessionServerUrl, "/api/push/devices", token, RegisterPushDeviceRequest(firebaseToken))
|
||||
}
|
||||
|
||||
fun absoluteUrl(serverUrl: String, path: String): String {
|
||||
return if (path.startsWith("http")) path else "${serverUrl.trimEnd('/')}$path"
|
||||
}
|
||||
|
||||
suspend fun argusManifest(
|
||||
baseUrl: String = "https://argus.kusoft.xyz",
|
||||
slug: String = "qmax",
|
||||
platform: String = "android",
|
||||
channel: String = "stable"
|
||||
): ArgusManifestDto? {
|
||||
val request = Request.Builder()
|
||||
.url("${baseUrl.trimEnd('/')}/api/apps/$slug/manifest?platform=$platform&channel=$channel")
|
||||
.get()
|
||||
.build()
|
||||
|
||||
return withContext(Dispatchers.IO) {
|
||||
debugLog("GET ${request.url.encodedPath}")
|
||||
client.newCall(request).execute().use { response ->
|
||||
if (response.code == 404) return@withContext null
|
||||
val body = response.body?.string().orEmpty()
|
||||
debugLog("GET ${request.url.encodedPath} -> ${response.code}")
|
||||
if (!response.isSuccessful) throw QMaxHttpException(response.code, httpErrorMessage(response.code, body))
|
||||
json.decodeFromString<ArgusManifestDto>(body)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun downloadToFile(url: String, target: File, token: String? = null, expectedSize: Long? = null): Long {
|
||||
val request = Request.Builder()
|
||||
.url(url)
|
||||
.apply {
|
||||
if (!token.isNullOrBlank()) header("Authorization", "Bearer $token")
|
||||
}
|
||||
.get()
|
||||
.build()
|
||||
return withContext(Dispatchers.IO) {
|
||||
debugLog("DOWNLOAD ${request.url.encodedPath}")
|
||||
downloadClient.newCall(request).execute().use { response ->
|
||||
debugLog("DOWNLOAD ${request.url.encodedPath} -> ${response.code}")
|
||||
target.parentFile?.mkdirs()
|
||||
val part = File(target.parentFile, target.name + ".part")
|
||||
if (part.exists()) part.delete()
|
||||
if (!response.isSuccessful) {
|
||||
val responseBody = response.body?.string().orEmpty()
|
||||
throw QMaxHttpException(response.code, httpErrorMessage(response.code, responseBody))
|
||||
}
|
||||
response.body?.byteStream().use { input ->
|
||||
requireNotNull(input) { "Empty response body" }
|
||||
FileOutputStream(part).use { output ->
|
||||
input.copyTo(output)
|
||||
}
|
||||
}
|
||||
if (expectedSize != null && expectedSize > 0 && part.length() != expectedSize) {
|
||||
val actual = part.length()
|
||||
part.delete()
|
||||
throw IOException("Download size mismatch: expected $expectedSize, got $actual")
|
||||
}
|
||||
if (target.exists()) target.delete()
|
||||
if (!part.renameTo(target)) {
|
||||
part.delete()
|
||||
throw IOException("Cannot move downloaded file into cache")
|
||||
}
|
||||
target.length()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend inline fun <reified T> get(serverUrl: String, path: String, token: String): T {
|
||||
val request = Request.Builder()
|
||||
.url("${serverUrl.trimEnd('/')}$path")
|
||||
.header("Authorization", "Bearer $token")
|
||||
.get()
|
||||
.build()
|
||||
return execute(request)
|
||||
}
|
||||
|
||||
private suspend inline fun <reified T, reified B> post(serverUrl: String, path: String, token: String?, body: B): T {
|
||||
val requestBody = json.encodeToString(body).toRequestBody(jsonMediaType)
|
||||
val request = Request.Builder()
|
||||
.url("${serverUrl.trimEnd('/')}$path")
|
||||
.apply {
|
||||
if (!token.isNullOrBlank()) header("Authorization", "Bearer $token")
|
||||
}
|
||||
.post(requestBody)
|
||||
.build()
|
||||
return execute(request)
|
||||
}
|
||||
|
||||
private suspend inline fun <reified T, reified B> put(serverUrl: String, path: String, token: String, body: B): T {
|
||||
val requestBody = json.encodeToString(body).toRequestBody(jsonMediaType)
|
||||
val request = Request.Builder()
|
||||
.url("${serverUrl.trimEnd('/')}$path")
|
||||
.header("Authorization", "Bearer $token")
|
||||
.put(requestBody)
|
||||
.build()
|
||||
return execute(request)
|
||||
}
|
||||
|
||||
private suspend inline fun <reified T, reified B> patch(serverUrl: String, path: String, token: String, body: B): T {
|
||||
val requestBody = json.encodeToString(body).toRequestBody(jsonMediaType)
|
||||
val request = Request.Builder()
|
||||
.url("${serverUrl.trimEnd('/')}$path")
|
||||
.header("Authorization", "Bearer $token")
|
||||
.patch(requestBody)
|
||||
.build()
|
||||
return execute(request)
|
||||
}
|
||||
|
||||
private suspend fun delete(serverUrl: String, path: String, token: String) {
|
||||
val request = Request.Builder()
|
||||
.url("${serverUrl.trimEnd('/')}$path")
|
||||
.header("Authorization", "Bearer $token")
|
||||
.delete()
|
||||
.build()
|
||||
|
||||
withContext(Dispatchers.IO) {
|
||||
debugLog("DELETE ${request.url.encodedPath}")
|
||||
client.newCall(request).execute().use { response ->
|
||||
val responseBody = response.body?.string().orEmpty()
|
||||
debugLog("DELETE ${request.url.encodedPath} -> ${response.code}")
|
||||
if (!response.isSuccessful) {
|
||||
throw QMaxHttpException(response.code, httpErrorMessage(response.code, responseBody))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend inline fun <reified T> deleteForJson(serverUrl: String, path: String, token: String): T {
|
||||
val request = Request.Builder()
|
||||
.url("${serverUrl.trimEnd('/')}$path")
|
||||
.header("Authorization", "Bearer $token")
|
||||
.delete()
|
||||
.build()
|
||||
return execute(request)
|
||||
}
|
||||
|
||||
private suspend inline fun <reified B> postNoContent(serverUrl: String, path: String, token: String, body: B) {
|
||||
val requestBody = json.encodeToString(body).toRequestBody(jsonMediaType)
|
||||
val request = Request.Builder()
|
||||
.url("${serverUrl.trimEnd('/')}$path")
|
||||
.header("Authorization", "Bearer $token")
|
||||
.post(requestBody)
|
||||
.build()
|
||||
|
||||
withContext(Dispatchers.IO) {
|
||||
debugLog("POST ${request.url.encodedPath}")
|
||||
client.newCall(request).execute().use { response ->
|
||||
val responseBody = response.body?.string().orEmpty()
|
||||
debugLog("POST ${request.url.encodedPath} -> ${response.code}")
|
||||
if (!response.isSuccessful) {
|
||||
throw QMaxHttpException(response.code, httpErrorMessage(response.code, responseBody))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend inline fun <reified T> execute(request: Request): T {
|
||||
debugLog("${request.method} ${request.url.encodedPath}")
|
||||
val result = executeCancellable(request)
|
||||
debugLog("${request.method} ${request.url.encodedPath} -> ${result.code}")
|
||||
if (!result.isSuccessful) {
|
||||
throw QMaxHttpException(result.code, httpErrorMessage(result.code, result.body))
|
||||
}
|
||||
return json.decodeFromString(result.body)
|
||||
}
|
||||
|
||||
private suspend fun executeCancellable(request: Request): HttpResult {
|
||||
return suspendCancellableCoroutine { continuation ->
|
||||
val call = client.newCall(request)
|
||||
continuation.invokeOnCancellation { call.cancel() }
|
||||
call.enqueue(object : Callback {
|
||||
override fun onFailure(call: Call, e: IOException) {
|
||||
if (continuation.isActive) {
|
||||
continuation.resumeWithException(e)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onResponse(call: Call, response: Response) {
|
||||
response.use {
|
||||
try {
|
||||
val body = it.body?.string().orEmpty()
|
||||
if (continuation.isActive) {
|
||||
continuation.resume(HttpResult(it.code, it.isSuccessful, body))
|
||||
}
|
||||
} catch (error: Throwable) {
|
||||
if (continuation.isActive) {
|
||||
continuation.resumeWithException(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
private data class HttpResult(
|
||||
val code: Int,
|
||||
val isSuccessful: Boolean,
|
||||
val body: String
|
||||
)
|
||||
|
||||
private fun httpErrorMessage(code: Int, body: String): String {
|
||||
val trimmed = body.trim()
|
||||
if (trimmed.isBlank()) {
|
||||
return "HTTP $code"
|
||||
}
|
||||
|
||||
runCatching {
|
||||
val jsonObject = json.parseToJsonElement(trimmed).jsonObject
|
||||
listOf("detail", "error", "message", "title")
|
||||
.firstNotNullOfOrNull { key ->
|
||||
jsonObject[key]?.jsonPrimitive?.contentOrNull?.trim()?.takeIf { it.isNotBlank() }
|
||||
}
|
||||
}.getOrNull()?.let { message ->
|
||||
return message
|
||||
}
|
||||
|
||||
return trimmed.take(500)
|
||||
}
|
||||
|
||||
private fun debugLog(message: String) {
|
||||
if (BuildConfig.DEBUG) {
|
||||
Log.d(LogTag, message)
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val LogTag = "QMAX"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
package xyz.kusoft.qmax.core.push
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import com.google.firebase.FirebaseApp
|
||||
import com.google.firebase.FirebaseOptions
|
||||
import org.json.JSONObject
|
||||
|
||||
object FirebaseBootstrap {
|
||||
private const val tag = "QMaxFirebase"
|
||||
|
||||
fun ensureInitialized(context: Context): Boolean {
|
||||
if (FirebaseApp.getApps(context).isNotEmpty()) {
|
||||
return true
|
||||
}
|
||||
|
||||
val config = readFirebaseAndroidConfig(context)
|
||||
?: readGoogleServicesConfig(context)
|
||||
?: return false
|
||||
|
||||
return runCatching {
|
||||
val options = FirebaseOptions.Builder()
|
||||
.setApplicationId(config.applicationId)
|
||||
.setApiKey(config.apiKey)
|
||||
.setProjectId(config.projectId)
|
||||
.setGcmSenderId(config.senderId)
|
||||
.build()
|
||||
FirebaseApp.initializeApp(context, options)
|
||||
true
|
||||
}.onFailure {
|
||||
Log.w(tag, "Firebase initialization failed", it)
|
||||
}.getOrDefault(false)
|
||||
}
|
||||
|
||||
private fun readFirebaseAndroidConfig(context: Context): FirebaseConfig? {
|
||||
val json = assetText(context, "firebase.android.json") ?: return null
|
||||
val obj = JSONObject(json)
|
||||
return FirebaseConfig(
|
||||
applicationId = obj.optString("applicationId"),
|
||||
apiKey = obj.optString("apiKey"),
|
||||
projectId = obj.optString("projectId"),
|
||||
senderId = obj.optString("senderId")
|
||||
).takeIf { it.isComplete }
|
||||
}
|
||||
|
||||
private fun readGoogleServicesConfig(context: Context): FirebaseConfig? {
|
||||
val json = assetText(context, "google-services.json") ?: return null
|
||||
val obj = JSONObject(json)
|
||||
val project = obj.getJSONObject("project_info")
|
||||
val clients = obj.optJSONArray("client") ?: return null
|
||||
var fallback: FirebaseConfig? = null
|
||||
|
||||
for (index in 0 until clients.length()) {
|
||||
val client = clients.getJSONObject(index)
|
||||
val clientInfo = client.getJSONObject("client_info")
|
||||
val packageName = clientInfo
|
||||
.getJSONObject("android_client_info")
|
||||
.optString("package_name")
|
||||
val apiKey = client
|
||||
.getJSONArray("api_key")
|
||||
.getJSONObject(0)
|
||||
.optString("current_key")
|
||||
val config = FirebaseConfig(
|
||||
applicationId = clientInfo.optString("mobilesdk_app_id"),
|
||||
apiKey = apiKey,
|
||||
projectId = project.optString("project_id"),
|
||||
senderId = project.optString("project_number")
|
||||
).takeIf { it.isComplete }
|
||||
|
||||
if (packageName == context.packageName) {
|
||||
return config
|
||||
}
|
||||
if (fallback == null) {
|
||||
fallback = config
|
||||
}
|
||||
}
|
||||
|
||||
return fallback
|
||||
}
|
||||
|
||||
private fun assetText(context: Context, name: String): String? {
|
||||
return runCatching {
|
||||
context.assets.open(name).bufferedReader().use { it.readText() }
|
||||
}.getOrNull()
|
||||
}
|
||||
|
||||
private data class FirebaseConfig(
|
||||
val applicationId: String,
|
||||
val apiKey: String,
|
||||
val projectId: String,
|
||||
val senderId: String
|
||||
) {
|
||||
val isComplete: Boolean
|
||||
get() = applicationId.isNotBlank() &&
|
||||
apiKey.isNotBlank() &&
|
||||
projectId.isNotBlank() &&
|
||||
senderId.isNotBlank()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package xyz.kusoft.qmax.core.push
|
||||
|
||||
import android.content.Context
|
||||
|
||||
object ForegroundChatTracker {
|
||||
private const val PreferencesName = "qmax_foreground_chat"
|
||||
private const val ActiveChatIdKey = "active_chat_id"
|
||||
|
||||
fun setActiveChat(context: Context, chatId: String) {
|
||||
context.applicationContext
|
||||
.getSharedPreferences(PreferencesName, Context.MODE_PRIVATE)
|
||||
.edit()
|
||||
.putString(ActiveChatIdKey, chatId)
|
||||
.apply()
|
||||
}
|
||||
|
||||
fun clearActiveChat(context: Context, chatId: String? = null) {
|
||||
val preferences = context.applicationContext.getSharedPreferences(PreferencesName, Context.MODE_PRIVATE)
|
||||
if (chatId != null && preferences.getString(ActiveChatIdKey, null) != chatId) {
|
||||
return
|
||||
}
|
||||
|
||||
preferences.edit().remove(ActiveChatIdKey).apply()
|
||||
}
|
||||
|
||||
fun isChatActive(context: Context, chatId: String?): Boolean {
|
||||
if (chatId.isNullOrBlank()) {
|
||||
return false
|
||||
}
|
||||
|
||||
return context.applicationContext
|
||||
.getSharedPreferences(PreferencesName, Context.MODE_PRIVATE)
|
||||
.getString(ActiveChatIdKey, null)
|
||||
?.equals(chatId, ignoreCase = true) == true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package xyz.kusoft.qmax.core.push
|
||||
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.util.Log
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.flow.firstOrNull
|
||||
import kotlinx.coroutines.launch
|
||||
import xyz.kusoft.qmax.core.QMaxContainer
|
||||
|
||||
class MarkChatReadReceiver : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
if (intent.action != PushNotifications.MarkChatReadAction) {
|
||||
return
|
||||
}
|
||||
|
||||
val chatId = intent.getStringExtra(PushNotifications.ExtraChatId)
|
||||
?.takeIf { it.isNotBlank() }
|
||||
?: return
|
||||
val notificationId = intent.getIntExtra(PushNotifications.ExtraNotificationId, 0)
|
||||
if (notificationId != 0) {
|
||||
NotificationManagerCompat.from(context).cancel(notificationId)
|
||||
}
|
||||
|
||||
val pendingResult = goAsync()
|
||||
CoroutineScope(SupervisorJob() + Dispatchers.IO).launch {
|
||||
try {
|
||||
val container = QMaxContainer(context.applicationContext)
|
||||
val session = container.tokenStore.session.firstOrNull()
|
||||
if (session != null) {
|
||||
container.repository.markChatRead(session, chatId)
|
||||
}
|
||||
} catch (error: Throwable) {
|
||||
Log.w("QMaxPush", "Unable to mark notification chat as read", error)
|
||||
} finally {
|
||||
pendingResult.finish()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package xyz.kusoft.qmax.core.push
|
||||
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
|
||||
object PushNotifications {
|
||||
const val MessagesChannelId = "qmax_messages_v2"
|
||||
const val OpenChatAction = "QMAX_OPEN_CHAT"
|
||||
const val MarkChatReadAction = "QMAX_MARK_CHAT_READ"
|
||||
const val ExtraChatId = "chatId"
|
||||
const val ExtraMessageId = "messageId"
|
||||
const val ExtraNotificationId = "notificationId"
|
||||
|
||||
fun ensureChannels(context: Context) {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
|
||||
return
|
||||
}
|
||||
|
||||
val manager = context.getSystemService(NotificationManager::class.java)
|
||||
val channel = NotificationChannel(
|
||||
MessagesChannelId,
|
||||
"QMAX messages",
|
||||
NotificationManager.IMPORTANCE_HIGH
|
||||
).apply {
|
||||
description = "Incoming QMAX messages"
|
||||
enableVibration(true)
|
||||
setShowBadge(true)
|
||||
lockscreenVisibility = android.app.Notification.VISIBILITY_PUBLIC
|
||||
}
|
||||
manager.createNotificationChannel(channel)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
package xyz.kusoft.qmax.core.push
|
||||
|
||||
import android.app.PendingIntent
|
||||
import android.content.Intent
|
||||
import android.util.Log
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import com.google.firebase.messaging.FirebaseMessagingService
|
||||
import com.google.firebase.messaging.RemoteMessage
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.flow.firstOrNull
|
||||
import kotlinx.coroutines.launch
|
||||
import xyz.kusoft.qmax.MainActivity
|
||||
import xyz.kusoft.qmax.QMaxApplication
|
||||
import xyz.kusoft.qmax.R
|
||||
|
||||
class QMaxFirebaseMessagingService : FirebaseMessagingService() {
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
|
||||
override fun onNewToken(token: String) {
|
||||
super.onNewToken(token)
|
||||
registerToken(token)
|
||||
}
|
||||
|
||||
override fun onMessageReceived(message: RemoteMessage) {
|
||||
super.onMessageReceived(message)
|
||||
PushNotifications.ensureChannels(this)
|
||||
|
||||
val type = message.data["type"]
|
||||
if (type != null && type != "new_message") {
|
||||
return
|
||||
}
|
||||
|
||||
val title = message.notification?.title
|
||||
?: message.data["title"]
|
||||
?: message.data["chatTitle"]
|
||||
?: "QMAX"
|
||||
val body = message.notification?.body
|
||||
?: message.data["body"]
|
||||
?: "New message"
|
||||
if (isPresenceText(body)) {
|
||||
return
|
||||
}
|
||||
|
||||
val chatId = message.data[PushNotifications.ExtraChatId]
|
||||
val messageId = message.data[PushNotifications.ExtraMessageId]
|
||||
if (ForegroundChatTracker.isChatActive(this, chatId)) {
|
||||
return
|
||||
}
|
||||
|
||||
val notificationId = stableNotificationId(chatId ?: messageId ?: body)
|
||||
val intent = Intent(this, MainActivity::class.java)
|
||||
.setAction(PushNotifications.OpenChatAction)
|
||||
.putExtra(PushNotifications.ExtraChatId, chatId)
|
||||
.putExtra(PushNotifications.ExtraMessageId, messageId)
|
||||
.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP)
|
||||
val pendingIntent = PendingIntent.getActivity(
|
||||
this,
|
||||
notificationId,
|
||||
intent,
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
||||
)
|
||||
|
||||
val markReadPendingIntent = chatId?.takeIf { it.isNotBlank() }?.let { id ->
|
||||
val markReadIntent = Intent(this, MarkChatReadReceiver::class.java)
|
||||
.setAction(PushNotifications.MarkChatReadAction)
|
||||
.putExtra(PushNotifications.ExtraChatId, id)
|
||||
.putExtra(PushNotifications.ExtraNotificationId, notificationId)
|
||||
PendingIntent.getBroadcast(
|
||||
this,
|
||||
notificationId + 1,
|
||||
markReadIntent,
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
||||
)
|
||||
}
|
||||
|
||||
val notification = NotificationCompat.Builder(this, PushNotifications.MessagesChannelId)
|
||||
.setSmallIcon(R.mipmap.ic_launcher)
|
||||
.setContentTitle(title)
|
||||
.setContentText(body)
|
||||
.setStyle(NotificationCompat.BigTextStyle().bigText(body))
|
||||
.setContentIntent(pendingIntent)
|
||||
.setAutoCancel(true)
|
||||
.setCategory(NotificationCompat.CATEGORY_MESSAGE)
|
||||
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
|
||||
.setPriority(NotificationCompat.PRIORITY_HIGH)
|
||||
.setDefaults(NotificationCompat.DEFAULT_ALL)
|
||||
.apply {
|
||||
if (markReadPendingIntent != null) {
|
||||
addAction(
|
||||
R.drawable.ic_mark_read_24,
|
||||
"\u041f\u0440\u043e\u0447\u0438\u0442\u0430\u043d\u043e",
|
||||
markReadPendingIntent
|
||||
)
|
||||
}
|
||||
}
|
||||
.build()
|
||||
|
||||
runCatching {
|
||||
NotificationManagerCompat.from(this)
|
||||
.notify(notificationId, notification)
|
||||
}.onFailure {
|
||||
Log.w("QMaxPush", "Unable to show notification", it)
|
||||
}
|
||||
}
|
||||
|
||||
private fun registerToken(token: String) {
|
||||
if (token.isBlank()) {
|
||||
return
|
||||
}
|
||||
|
||||
scope.launch {
|
||||
val container = (application as QMaxApplication).container
|
||||
val session = container.tokenStore.session.firstOrNull() ?: return@launch
|
||||
runCatching {
|
||||
container.repository.registerPushDevice(session, token)
|
||||
}.onFailure {
|
||||
Log.w("QMaxPush", "Unable to register refreshed FCM token", it)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun stableNotificationId(value: String): Int {
|
||||
return value.hashCode() and Int.MAX_VALUE
|
||||
}
|
||||
|
||||
private fun isPresenceText(value: String): Boolean {
|
||||
val text = value
|
||||
.trim()
|
||||
.lowercase()
|
||||
.replace("...", "")
|
||||
.replace("\u2026", "")
|
||||
.trim()
|
||||
if (text.isBlank() || text.length > 120) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (text.contains("\u043f\u0435\u0447\u0430\u0442") ||
|
||||
text.contains("\u043d\u0430\u0431\u0438\u0440\u0430") ||
|
||||
text.contains("typing")
|
||||
) {
|
||||
return true
|
||||
}
|
||||
|
||||
val hasProcessVerb = text.contains("\u043e\u0442\u043f\u0440\u0430\u0432\u043b\u044f\u0435\u0442") ||
|
||||
text.contains("\u0432\u044b\u0431\u0438\u0440\u0430\u0435\u0442") ||
|
||||
text.contains("\u0437\u0430\u0433\u0440\u0443\u0436\u0430\u0435\u0442") ||
|
||||
text.contains("\u043f\u0440\u0438\u043a\u0440\u0435\u043f\u043b\u044f\u0435\u0442") ||
|
||||
text.contains("\u0437\u0430\u043f\u0438\u0441\u044b\u0432\u0430\u0435\u0442") ||
|
||||
text.contains("is sending") ||
|
||||
text.contains("sending") ||
|
||||
text.contains("choosing") ||
|
||||
text.contains("uploading") ||
|
||||
text.contains("recording")
|
||||
if (!hasProcessVerb) {
|
||||
return false
|
||||
}
|
||||
|
||||
return text.contains("\u0444\u043e\u0442\u043e") ||
|
||||
text.contains("\u0438\u0437\u043e\u0431\u0440\u0430\u0436") ||
|
||||
text.contains("\u043a\u0430\u0440\u0442\u0438\u043d") ||
|
||||
text.contains("\u0432\u0438\u0434\u0435\u043e") ||
|
||||
text.contains("\u0441\u0442\u0438\u043a\u0435\u0440") ||
|
||||
text.contains("\u044d\u043c\u043e\u0434\u0437\u0438") ||
|
||||
text.contains("\u0433\u043e\u043b\u043e\u0441") ||
|
||||
text.contains("\u0430\u0443\u0434\u0438\u043e") ||
|
||||
text.contains("\u0444\u0430\u0439\u043b") ||
|
||||
text.contains("photo") ||
|
||||
text.contains("image") ||
|
||||
text.contains("video") ||
|
||||
text.contains("sticker") ||
|
||||
text.contains("emoji") ||
|
||||
text.contains("voice") ||
|
||||
text.contains("audio") ||
|
||||
text.contains("file")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
package xyz.kusoft.qmax.core.realtime
|
||||
|
||||
import com.google.gson.JsonElement
|
||||
import com.microsoft.signalr.HubConnection
|
||||
import com.microsoft.signalr.HubConnectionBuilder
|
||||
import com.microsoft.signalr.HubConnectionState
|
||||
import io.reactivex.rxjava3.core.Single
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.json.Json
|
||||
import xyz.kusoft.qmax.core.model.MessageDeletedDto
|
||||
import xyz.kusoft.qmax.core.model.MessageDto
|
||||
import xyz.kusoft.qmax.core.model.QMaxSession
|
||||
|
||||
class QMaxRealtimeClient {
|
||||
private val json = Json {
|
||||
ignoreUnknownKeys = true
|
||||
encodeDefaults = true
|
||||
}
|
||||
|
||||
private var hub: HubConnection? = null
|
||||
private var joinedChatId: String? = null
|
||||
|
||||
suspend fun connect(
|
||||
session: QMaxSession,
|
||||
onChatListInvalidated: () -> Unit,
|
||||
onMessageCreated: (MessageDto) -> Unit,
|
||||
onMessageUpdated: (MessageDto) -> Unit,
|
||||
onMessageDeleted: (MessageDeletedDto) -> Unit
|
||||
) = withContext(Dispatchers.IO) {
|
||||
disconnect()
|
||||
|
||||
val connection = HubConnectionBuilder
|
||||
.create("${session.serverUrl.trimEnd('/')}/hubs/qmax")
|
||||
.withAccessTokenProvider(Single.just(session.accessToken))
|
||||
.build()
|
||||
|
||||
connection.on("ChatListInvalidated", onChatListInvalidated)
|
||||
connection.on(
|
||||
"MessageCreated",
|
||||
{ payload: JsonElement ->
|
||||
runCatching {
|
||||
onMessageCreated(json.decodeFromString<MessageDto>(payload.toString()))
|
||||
}
|
||||
},
|
||||
JsonElement::class.java
|
||||
)
|
||||
connection.on(
|
||||
"MessageUpdated",
|
||||
{ payload: JsonElement ->
|
||||
runCatching {
|
||||
onMessageUpdated(json.decodeFromString<MessageDto>(payload.toString()))
|
||||
}
|
||||
},
|
||||
JsonElement::class.java
|
||||
)
|
||||
connection.on(
|
||||
"MessageDeleted",
|
||||
{ payload: JsonElement ->
|
||||
runCatching {
|
||||
onMessageDeleted(json.decodeFromString<MessageDeletedDto>(payload.toString()))
|
||||
}
|
||||
},
|
||||
JsonElement::class.java
|
||||
)
|
||||
connection.onClosed {
|
||||
joinedChatId = null
|
||||
}
|
||||
|
||||
connection.start().blockingAwait()
|
||||
hub = connection
|
||||
}
|
||||
|
||||
suspend fun joinChat(chatId: String?) = withContext(Dispatchers.IO) {
|
||||
val connection = hub ?: return@withContext
|
||||
if (connection.connectionState != HubConnectionState.CONNECTED) {
|
||||
return@withContext
|
||||
}
|
||||
|
||||
val previous = joinedChatId
|
||||
if (!previous.isNullOrBlank() && previous != chatId) {
|
||||
runCatching { connection.send("LeaveChat", previous) }
|
||||
}
|
||||
|
||||
if (!chatId.isNullOrBlank() && previous != chatId) {
|
||||
connection.send("JoinChat", chatId)
|
||||
joinedChatId = chatId
|
||||
} else if (chatId.isNullOrBlank()) {
|
||||
joinedChatId = null
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun disconnect() = withContext(Dispatchers.IO) {
|
||||
val connection = hub
|
||||
hub = null
|
||||
joinedChatId = null
|
||||
if (connection != null && connection.connectionState != HubConnectionState.DISCONNECTED) {
|
||||
runCatching { connection.stop().blockingAwait() }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package xyz.kusoft.qmax.core.settings
|
||||
|
||||
import android.content.Context
|
||||
import androidx.datastore.preferences.core.edit
|
||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import androidx.datastore.preferences.preferencesDataStore
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.map
|
||||
|
||||
private val Context.qmaxDraftDataStore by preferencesDataStore("qmax_drafts")
|
||||
|
||||
class MessageDraftStore(private val context: Context) {
|
||||
private val prefix = "draft_"
|
||||
|
||||
val drafts: Flow<Map<String, String>> = context.qmaxDraftDataStore.data.map { prefs ->
|
||||
prefs.asMap().mapNotNull { (key, value) ->
|
||||
val name = key.name
|
||||
val text = value as? String
|
||||
if (!name.startsWith(prefix) || text.isNullOrBlank()) {
|
||||
null
|
||||
} else {
|
||||
name.removePrefix(prefix) to text
|
||||
}
|
||||
}.toMap()
|
||||
}
|
||||
|
||||
suspend fun draft(chatId: String): String {
|
||||
return context.qmaxDraftDataStore.data
|
||||
.map { it[draftKey(chatId)].orEmpty() }
|
||||
.first()
|
||||
}
|
||||
|
||||
suspend fun save(chatId: String, text: String) {
|
||||
context.qmaxDraftDataStore.edit { prefs ->
|
||||
val key = draftKey(chatId)
|
||||
if (text.isBlank()) {
|
||||
prefs.remove(key)
|
||||
} else {
|
||||
prefs[key] = text
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun clear(chatId: String) {
|
||||
context.qmaxDraftDataStore.edit { it.remove(draftKey(chatId)) }
|
||||
}
|
||||
|
||||
suspend fun clearAll() {
|
||||
context.qmaxDraftDataStore.edit { it.clear() }
|
||||
}
|
||||
|
||||
private fun draftKey(chatId: String) = stringPreferencesKey("$prefix$chatId")
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package xyz.kusoft.qmax.core.settings
|
||||
|
||||
import android.content.Context
|
||||
import androidx.datastore.preferences.core.edit
|
||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import androidx.datastore.preferences.preferencesDataStore
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import xyz.kusoft.qmax.BuildConfig
|
||||
import xyz.kusoft.qmax.core.model.QMaxSession
|
||||
|
||||
private val Context.qmaxDataStore by preferencesDataStore("qmax")
|
||||
|
||||
class TokenStore(private val context: Context) {
|
||||
private val serverUrlKey = stringPreferencesKey("server_url")
|
||||
private val accessTokenKey = stringPreferencesKey("access_token")
|
||||
private val refreshTokenKey = stringPreferencesKey("refresh_token")
|
||||
private val userNameKey = stringPreferencesKey("user_name")
|
||||
|
||||
val session: Flow<QMaxSession?> = context.qmaxDataStore.data.map { prefs ->
|
||||
val accessToken = prefs[accessTokenKey]
|
||||
val refreshToken = prefs[refreshTokenKey]
|
||||
if (accessToken.isNullOrBlank() || refreshToken.isNullOrBlank()) {
|
||||
null
|
||||
} else {
|
||||
QMaxSession(
|
||||
serverUrl = prefs[serverUrlKey] ?: BuildConfig.QMAX_DEFAULT_SERVER_URL,
|
||||
accessToken = accessToken,
|
||||
refreshToken = refreshToken,
|
||||
userName = prefs[userNameKey] ?: "QMAX"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun save(session: QMaxSession) {
|
||||
context.qmaxDataStore.edit { prefs ->
|
||||
prefs[serverUrlKey] = session.serverUrl
|
||||
prefs[accessTokenKey] = session.accessToken
|
||||
prefs[refreshTokenKey] = session.refreshToken
|
||||
prefs[userNameKey] = session.userName
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun clear() {
|
||||
context.qmaxDataStore.edit { it.clear() }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,950 @@
|
||||
package xyz.kusoft.qmax.ui
|
||||
|
||||
import android.net.Uri
|
||||
import android.util.Log
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.TimeoutCancellationException
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withTimeout
|
||||
import xyz.kusoft.qmax.BuildConfig
|
||||
import xyz.kusoft.qmax.core.QMaxRepository
|
||||
import xyz.kusoft.qmax.core.model.AttachmentDto
|
||||
import xyz.kusoft.qmax.core.model.ChatDto
|
||||
import xyz.kusoft.qmax.core.model.MaxBridgeStatusDto
|
||||
import xyz.kusoft.qmax.core.model.MessageDeletedDto
|
||||
import xyz.kusoft.qmax.core.model.MessageDto
|
||||
import xyz.kusoft.qmax.core.model.QMaxSession
|
||||
import xyz.kusoft.qmax.core.realtime.QMaxRealtimeClient
|
||||
import java.io.File
|
||||
|
||||
data class QMaxUiState(
|
||||
val serverUrl: String = BuildConfig.QMAX_DEFAULT_SERVER_URL,
|
||||
val pairingCode: String = BuildConfig.QMAX_DEFAULT_PAIRING_CODE,
|
||||
val session: QMaxSession? = null,
|
||||
val chats: List<ChatDto> = emptyList(),
|
||||
val selectedChat: ChatDto? = null,
|
||||
val messages: List<MessageDto> = emptyList(),
|
||||
val chatSearchResults: List<MessageDto> = emptyList(),
|
||||
val chatSearchLoading: Boolean = false,
|
||||
val chatPresenceText: String? = null,
|
||||
val replyTarget: MessageDto? = null,
|
||||
val editTarget: MessageDto? = null,
|
||||
val forwardTarget: MessageDto? = null,
|
||||
val drafts: Map<String, String> = emptyMap(),
|
||||
val searchQuery: String = "",
|
||||
val composerText: String = "",
|
||||
val newChatExternalId: String = "",
|
||||
val newChatTitle: String = "",
|
||||
val maxStatus: MaxBridgeStatusDto? = null,
|
||||
val maxCode: String = "",
|
||||
val updateMessage: String? = null,
|
||||
val loading: Boolean = false,
|
||||
val sendingMessage: Boolean = false,
|
||||
val chatListLoading: Boolean = false,
|
||||
val chatListConnectionIssue: Boolean = false,
|
||||
val error: String? = null,
|
||||
val previewImageUrl: String? = null,
|
||||
val previewImageGallery: List<String> = emptyList(),
|
||||
val previewImageIndex: Int = 0,
|
||||
val cachedImagePaths: Map<String, String> = emptyMap()
|
||||
)
|
||||
|
||||
class QMaxViewModel(private val repository: QMaxRepository) : ViewModel() {
|
||||
var state = androidx.compose.runtime.mutableStateOf(QMaxUiState())
|
||||
private set
|
||||
|
||||
private val realtime = QMaxRealtimeClient()
|
||||
private var messagePollJob: Job? = null
|
||||
private var presencePollJob: Job? = null
|
||||
private var realtimeConnectJob: Job? = null
|
||||
private var chatListRefreshJob: Job? = null
|
||||
private var chatRefreshJob: Job? = null
|
||||
private var chatRefreshGeneration = 0L
|
||||
private var chatSearchJob: Job? = null
|
||||
private var autoLoginJob: Job? = null
|
||||
private var pushRegisteredForToken: String? = null
|
||||
private var autoLoginAttempted = false
|
||||
private var pendingOpenChatId: String? = null
|
||||
private val locallyReadChatFingerprints = mutableMapOf<String, String>()
|
||||
private val imageCacheInFlight = mutableSetOf<String>()
|
||||
|
||||
init {
|
||||
viewModelScope.launch {
|
||||
repository.session.collectLatest { session ->
|
||||
val previousSession = state.value.session
|
||||
val sessionChanged = previousSession?.serverUrl != session?.serverUrl ||
|
||||
previousSession?.userName != session?.userName
|
||||
state.value = state.value.copy(
|
||||
session = session,
|
||||
serverUrl = session?.serverUrl ?: state.value.serverUrl,
|
||||
cachedImagePaths = if (sessionChanged) emptyMap() else state.value.cachedImagePaths
|
||||
)
|
||||
if (session != null) {
|
||||
autoLoginJob?.cancel()
|
||||
restoreCachedChats(session)
|
||||
loadChats()
|
||||
connectRealtime(session)
|
||||
registerPushDevice(session)
|
||||
loadMaxStatus()
|
||||
} else {
|
||||
realtimeConnectJob?.cancel()
|
||||
messagePollJob?.cancel()
|
||||
presencePollJob?.cancel()
|
||||
realtime.disconnect()
|
||||
if (!autoLoginAttempted && state.value.pairingCode.isNotBlank()) {
|
||||
autoLoginAttempted = true
|
||||
autoLoginJob?.cancel()
|
||||
autoLoginJob = viewModelScope.launch {
|
||||
delay(AutoLoginDelayMs)
|
||||
if (state.value.session == null) {
|
||||
login()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
viewModelScope.launch {
|
||||
repository.drafts.collectLatest { drafts ->
|
||||
state.value = state.value.copy(drafts = drafts)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun updateServerUrl(value: String) {
|
||||
state.value = state.value.copy(serverUrl = value)
|
||||
}
|
||||
|
||||
fun updatePairingCode(value: String) {
|
||||
state.value = state.value.copy(pairingCode = value)
|
||||
}
|
||||
|
||||
fun updateComposer(value: String) {
|
||||
state.value = state.value.copy(composerText = value)
|
||||
if (state.value.editTarget != null) {
|
||||
return
|
||||
}
|
||||
val chatId = state.value.selectedChat?.id ?: return
|
||||
viewModelScope.launch {
|
||||
repository.saveDraft(chatId, value)
|
||||
}
|
||||
}
|
||||
|
||||
fun updateSearchQuery(value: String) {
|
||||
state.value = state.value.copy(searchQuery = value)
|
||||
}
|
||||
|
||||
fun updateNewChatExternalId(value: String) {
|
||||
state.value = state.value.copy(newChatExternalId = value)
|
||||
}
|
||||
|
||||
fun updateNewChatTitle(value: String) {
|
||||
state.value = state.value.copy(newChatTitle = value)
|
||||
}
|
||||
|
||||
fun updateMaxCode(value: String) {
|
||||
state.value = state.value.copy(maxCode = value)
|
||||
}
|
||||
|
||||
fun login() = launchLoading {
|
||||
val current = state.value
|
||||
repository.login(current.serverUrl, current.pairingCode)
|
||||
}
|
||||
|
||||
fun logout() = viewModelScope.launch {
|
||||
autoLoginAttempted = true
|
||||
repository.logout()
|
||||
realtime.disconnect()
|
||||
state.value = QMaxUiState(serverUrl = state.value.serverUrl, pairingCode = "")
|
||||
}
|
||||
|
||||
fun loadChats() = refreshChats()
|
||||
|
||||
private suspend fun restoreCachedChats(session: QMaxSession) {
|
||||
runCatching {
|
||||
repository.cachedChats(session)
|
||||
}.getOrNull()?.takeIf { it.isNotEmpty() }?.let { cached ->
|
||||
val currentSession = state.value.session
|
||||
if (currentSession?.serverUrl == session.serverUrl && currentSession.userName == session.userName) {
|
||||
val orderedCached = normalizeChats(cached)
|
||||
state.value = state.value.copy(
|
||||
chats = orderedCached,
|
||||
chatListLoading = false,
|
||||
chatListConnectionIssue = false,
|
||||
error = null
|
||||
)
|
||||
openPendingChatIfAvailable(orderedCached)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun refreshChats(showLoading: Boolean = true) {
|
||||
if (showLoading) {
|
||||
chatRefreshJob?.cancel()
|
||||
} else if (chatRefreshJob?.isActive == true) {
|
||||
return
|
||||
}
|
||||
|
||||
val generation = ++chatRefreshGeneration
|
||||
chatRefreshJob = viewModelScope.launch {
|
||||
if (showLoading) {
|
||||
state.value = state.value.copy(
|
||||
chatListLoading = state.value.chats.isEmpty(),
|
||||
error = null
|
||||
)
|
||||
}
|
||||
try {
|
||||
val session = requireSession()
|
||||
val cached = repository.cachedChats(session)
|
||||
if (cached.isNotEmpty()) {
|
||||
val orderedCached = normalizeChats(cached)
|
||||
state.value = state.value.copy(
|
||||
chats = orderedCached,
|
||||
chatListLoading = false
|
||||
)
|
||||
openPendingChatIfAvailable(orderedCached)
|
||||
}
|
||||
|
||||
val fresh = withTimeout(ChatListTimeoutMs) {
|
||||
repository.chats(session)
|
||||
}
|
||||
if (fresh.isEmpty()) {
|
||||
error("Chat list response is empty.")
|
||||
}
|
||||
val orderedFresh = normalizeChats(fresh)
|
||||
repository.cacheChats(session, orderedFresh)
|
||||
state.value = state.value.copy(
|
||||
chats = orderedFresh,
|
||||
chatListLoading = false,
|
||||
chatListConnectionIssue = false,
|
||||
error = null
|
||||
)
|
||||
openPendingChatIfAvailable(orderedFresh)
|
||||
} catch (error: TimeoutCancellationException) {
|
||||
Log.w(LogTag, "chat refresh timed out", error)
|
||||
if (generation == chatRefreshGeneration) {
|
||||
state.value = state.value.copy(
|
||||
chatListLoading = false,
|
||||
chatListConnectionIssue = true
|
||||
)
|
||||
}
|
||||
} catch (error: CancellationException) {
|
||||
if (generation == chatRefreshGeneration) {
|
||||
throw error
|
||||
}
|
||||
} catch (error: Throwable) {
|
||||
Log.w(LogTag, "chat refresh failed", error)
|
||||
if (generation == chatRefreshGeneration) {
|
||||
state.value = state.value.copy(
|
||||
chatListLoading = false,
|
||||
chatListConnectionIssue = true
|
||||
)
|
||||
}
|
||||
} finally {
|
||||
if (showLoading && generation == chatRefreshGeneration) {
|
||||
state.value = state.value.copy(chatListLoading = false)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun createChat() = launchLoading {
|
||||
val session = requireSession()
|
||||
val current = state.value
|
||||
val externalId = current.newChatExternalId.ifBlank { current.newChatTitle }
|
||||
val title = current.newChatTitle.ifBlank { externalId }
|
||||
if (externalId.isBlank()) return@launchLoading
|
||||
val chat = repository.createChat(session, externalId, title)
|
||||
state.value = state.value.copy(newChatExternalId = "", newChatTitle = "")
|
||||
openChat(chat)
|
||||
loadChats()
|
||||
}
|
||||
|
||||
fun openChat(chat: ChatDto) {
|
||||
saveCurrentDraft()
|
||||
chatSearchJob?.cancel()
|
||||
val openedChat = chat.copy(unreadCount = 0)
|
||||
rememberChatRead(openedChat)
|
||||
val updatedChats = normalizeChats(
|
||||
state.value.chats.map { current ->
|
||||
if (current.id == chat.id) current.copy(unreadCount = 0) else current
|
||||
},
|
||||
selectedChatId = chat.id
|
||||
)
|
||||
state.value = state.value.copy(
|
||||
selectedChat = openedChat,
|
||||
chats = updatedChats,
|
||||
messages = emptyList(),
|
||||
chatSearchResults = emptyList(),
|
||||
chatSearchLoading = false,
|
||||
chatPresenceText = null,
|
||||
replyTarget = null,
|
||||
editTarget = null,
|
||||
forwardTarget = null,
|
||||
composerText = ""
|
||||
)
|
||||
persistCurrentChats()
|
||||
syncChatRead(chat.id)
|
||||
messagePollJob?.cancel()
|
||||
viewModelScope.launch {
|
||||
val draft = repository.draft(chat.id)
|
||||
if (state.value.selectedChat?.id == chat.id) {
|
||||
state.value = state.value.copy(composerText = draft)
|
||||
}
|
||||
realtime.joinChat(chat.id)
|
||||
}
|
||||
loadMessages(showLoading = true)
|
||||
presencePollJob?.cancel()
|
||||
presencePollJob = viewModelScope.launch {
|
||||
while (state.value.selectedChat?.id == chat.id) {
|
||||
refreshPresence(chat.id)
|
||||
delay(2_000)
|
||||
}
|
||||
}
|
||||
messagePollJob = viewModelScope.launch {
|
||||
while (true) {
|
||||
delay(30000)
|
||||
if (!state.value.sendingMessage) {
|
||||
loadMessages(showLoading = false)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun openChatById(chatId: String) {
|
||||
val chat = state.value.chats.firstOrNull { it.id.equals(chatId, ignoreCase = true) }
|
||||
if (chat != null) {
|
||||
pendingOpenChatId = null
|
||||
if (state.value.selectedChat?.id != chat.id) {
|
||||
openChat(chat)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
pendingOpenChatId = chatId
|
||||
if (state.value.session != null) {
|
||||
loadChats()
|
||||
}
|
||||
}
|
||||
|
||||
fun closeChat() {
|
||||
val closingChat = state.value.selectedChat
|
||||
val closingChatId = closingChat?.id
|
||||
saveCurrentDraft()
|
||||
messagePollJob?.cancel()
|
||||
presencePollJob?.cancel()
|
||||
viewModelScope.launch {
|
||||
realtime.joinChat(null)
|
||||
}
|
||||
chatSearchJob?.cancel()
|
||||
val reorderedChats = if (closingChatId.isNullOrBlank()) {
|
||||
state.value.chats
|
||||
} else {
|
||||
closingChat?.let(::rememberChatRead)
|
||||
normalizeChats(state.value.chats.map { chat ->
|
||||
if (chat.id == closingChatId) chat.copy(unreadCount = 0) else chat
|
||||
}, selectedChatId = closingChatId)
|
||||
}
|
||||
state.value = state.value.copy(
|
||||
selectedChat = null,
|
||||
chats = reorderedChats,
|
||||
messages = emptyList(),
|
||||
chatSearchResults = emptyList(),
|
||||
chatSearchLoading = false,
|
||||
chatPresenceText = null,
|
||||
replyTarget = null,
|
||||
editTarget = null,
|
||||
forwardTarget = null,
|
||||
composerText = ""
|
||||
)
|
||||
persistCurrentChats()
|
||||
closingChatId?.let(::syncChatRead)
|
||||
}
|
||||
|
||||
fun loadMessages(showLoading: Boolean = true) = launchLoading(showLoading) {
|
||||
if (!showLoading && state.value.sendingMessage) {
|
||||
return@launchLoading
|
||||
}
|
||||
val session = requireSession()
|
||||
val chat = state.value.selectedChat ?: return@launchLoading
|
||||
val cached = repository.cachedMessages(session, chat.id)
|
||||
if (cached.isNotEmpty() && state.value.selectedChat?.id == chat.id && state.value.messages.isEmpty()) {
|
||||
state.value = state.value.copy(messages = cached)
|
||||
}
|
||||
val fresh = withTimeout(MessageSyncTimeoutMs) {
|
||||
repository.messages(session, chat.id)
|
||||
}
|
||||
if (state.value.selectedChat?.id == chat.id) {
|
||||
val selected = state.value.selectedChat?.copy(unreadCount = 0)
|
||||
selected?.let(::rememberChatRead)
|
||||
state.value = state.value.copy(
|
||||
selectedChat = selected,
|
||||
chats = normalizeChats(state.value.chats, selectedChatId = chat.id),
|
||||
messages = fresh
|
||||
)
|
||||
persistCurrentChats()
|
||||
}
|
||||
}
|
||||
|
||||
fun sendMessage() = sendComposer(emptyList())
|
||||
|
||||
fun sendComposer(attachmentUris: List<Uri>, onSuccess: () -> Unit = {}) = launchLoading(false) {
|
||||
val session = requireSession()
|
||||
val chat = state.value.selectedChat ?: return@launchLoading
|
||||
val text = state.value.composerText.trim()
|
||||
if (text.isBlank() && attachmentUris.isEmpty()) return@launchLoading
|
||||
|
||||
state.value = state.value.copy(sendingMessage = true, error = null)
|
||||
try {
|
||||
val editTarget = state.value.editTarget
|
||||
if (editTarget != null) {
|
||||
if (text.isBlank()) return@launchLoading
|
||||
val edited = repository.editMessage(session, chat.id, editTarget.id, text)
|
||||
repository.clearDraft(chat.id)
|
||||
val current = state.value
|
||||
if (current.selectedChat?.id == chat.id) {
|
||||
state.value = current.copy(
|
||||
composerText = "",
|
||||
editTarget = null,
|
||||
replyTarget = null,
|
||||
messages = upsertMessage(current.messages, edited)
|
||||
)
|
||||
}
|
||||
onSuccess()
|
||||
loadChats()
|
||||
return@launchLoading
|
||||
}
|
||||
|
||||
val replyToMessageId = state.value.replyTarget?.id
|
||||
val sentMessages = mutableListOf<MessageDto>()
|
||||
|
||||
if (attachmentUris.isEmpty()) {
|
||||
sentMessages += repository.sendMessage(session, chat.id, text, replyToMessageId)
|
||||
} else {
|
||||
attachmentUris.forEachIndexed { index, uri ->
|
||||
val caption = text.takeIf { index == 0 && it.isNotBlank() }
|
||||
sentMessages += repository.upload(session, chat.id, uri, caption, replyToMessageId)
|
||||
}
|
||||
}
|
||||
|
||||
repository.clearDraft(chat.id)
|
||||
val current = state.value
|
||||
if (current.selectedChat?.id == chat.id) {
|
||||
state.value = current.copy(
|
||||
composerText = "",
|
||||
replyTarget = null,
|
||||
messages = sentMessages.fold(current.messages, ::upsertMessage)
|
||||
)
|
||||
}
|
||||
onSuccess()
|
||||
loadChats()
|
||||
} finally {
|
||||
state.value = state.value.copy(sendingMessage = false)
|
||||
}
|
||||
}
|
||||
|
||||
fun upload(uri: Uri) = launchLoading(false) {
|
||||
val session = requireSession()
|
||||
val chat = state.value.selectedChat ?: return@launchLoading
|
||||
val caption = state.value.composerText.ifBlank { null }
|
||||
val uploaded = repository.upload(session, chat.id, uri, caption, state.value.replyTarget?.id)
|
||||
repository.clearDraft(chat.id)
|
||||
val current = state.value
|
||||
state.value = current.copy(
|
||||
composerText = "",
|
||||
replyTarget = null,
|
||||
messages = if (current.selectedChat?.id == chat.id) {
|
||||
upsertMessage(current.messages, uploaded)
|
||||
} else {
|
||||
current.messages
|
||||
}
|
||||
)
|
||||
loadChats()
|
||||
}
|
||||
|
||||
fun uploadVoice(file: File) = launchLoading(false) {
|
||||
try {
|
||||
val session = requireSession()
|
||||
val chat = state.value.selectedChat ?: return@launchLoading
|
||||
val uploaded = repository.uploadVoice(session, chat.id, file, state.value.replyTarget?.id)
|
||||
val current = state.value
|
||||
state.value = current.copy(
|
||||
replyTarget = null,
|
||||
messages = if (current.selectedChat?.id == chat.id) {
|
||||
upsertMessage(current.messages, uploaded)
|
||||
} else {
|
||||
current.messages
|
||||
}
|
||||
)
|
||||
loadChats()
|
||||
} finally {
|
||||
file.delete()
|
||||
}
|
||||
}
|
||||
|
||||
fun loadMaxStatus() = launchLoading(false) {
|
||||
val session = requireSession()
|
||||
state.value = state.value.copy(maxStatus = repository.maxStatus(session))
|
||||
}
|
||||
|
||||
fun startMaxLogin() = launchLoading {
|
||||
val session = requireSession()
|
||||
state.value = state.value.copy(maxStatus = repository.startMaxLogin(session, null))
|
||||
}
|
||||
|
||||
fun submitMaxCode() = launchLoading {
|
||||
val session = requireSession()
|
||||
val code = state.value.maxCode.trim()
|
||||
if (code.isBlank()) return@launchLoading
|
||||
state.value = state.value.copy(maxStatus = repository.submitMaxCode(session, code), maxCode = "")
|
||||
}
|
||||
|
||||
fun checkArgusUpdate() = launchLoading {
|
||||
val manifest = repository.checkArgusUpdate()
|
||||
if (manifest == null) {
|
||||
state.value = state.value.copy(updateMessage = "Обновлений нет")
|
||||
} else {
|
||||
state.value = state.value.copy(updateMessage = "Найдена версия ${manifest.release.version}")
|
||||
repository.downloadAndOpenArgusUpdate(manifest)
|
||||
}
|
||||
}
|
||||
|
||||
fun openImage(url: String, gallery: List<String> = emptyList()) {
|
||||
val images = (gallery.ifEmpty { listOf(url) })
|
||||
.filter { it.isNotBlank() }
|
||||
.distinct()
|
||||
val selectedIndex = images.indexOf(url).takeIf { it >= 0 } ?: 0
|
||||
state.value = state.value.copy(
|
||||
previewImageUrl = images.getOrNull(selectedIndex) ?: url,
|
||||
previewImageGallery = images,
|
||||
previewImageIndex = selectedIndex
|
||||
)
|
||||
}
|
||||
|
||||
fun closeImage() {
|
||||
state.value = state.value.copy(
|
||||
previewImageUrl = null,
|
||||
previewImageGallery = emptyList(),
|
||||
previewImageIndex = 0
|
||||
)
|
||||
}
|
||||
|
||||
fun moveImagePreview(delta: Int) {
|
||||
val gallery = state.value.previewImageGallery
|
||||
if (gallery.size < 2) return
|
||||
val nextIndex = (state.value.previewImageIndex + delta + gallery.size) % gallery.size
|
||||
state.value = state.value.copy(
|
||||
previewImageUrl = gallery[nextIndex],
|
||||
previewImageIndex = nextIndex
|
||||
)
|
||||
}
|
||||
|
||||
fun openAttachment(attachment: AttachmentDto) = launchLoading(false) {
|
||||
val session = requireSession()
|
||||
repository.openAttachment(session, attachment)
|
||||
}
|
||||
|
||||
fun shareMessage(message: MessageDto) = launchLoading(false) {
|
||||
val session = requireSession()
|
||||
repository.shareMessage(session, message)
|
||||
}
|
||||
|
||||
fun shareAttachment(attachment: AttachmentDto) = launchLoading(false) {
|
||||
val session = requireSession()
|
||||
repository.shareAttachment(session, attachment)
|
||||
}
|
||||
|
||||
fun replyTo(message: MessageDto) {
|
||||
state.value = state.value.copy(replyTarget = message, editTarget = null)
|
||||
}
|
||||
|
||||
fun clearReplyTarget() {
|
||||
state.value = state.value.copy(replyTarget = null)
|
||||
}
|
||||
|
||||
fun openEditMessage(message: MessageDto) {
|
||||
if (!message.direction.equals("Outgoing", ignoreCase = true) || message.text.isNullOrBlank()) {
|
||||
return
|
||||
}
|
||||
state.value = state.value.copy(
|
||||
editTarget = message,
|
||||
replyTarget = null,
|
||||
composerText = message.text
|
||||
)
|
||||
}
|
||||
|
||||
fun clearEditTarget() {
|
||||
val chatId = state.value.selectedChat?.id
|
||||
state.value = state.value.copy(editTarget = null, composerText = "")
|
||||
if (chatId != null) {
|
||||
viewModelScope.launch {
|
||||
val draft = repository.draft(chatId)
|
||||
if (state.value.selectedChat?.id == chatId && state.value.editTarget == null) {
|
||||
state.value = state.value.copy(composerText = draft)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun deleteMessage(message: MessageDto) = launchLoading(false) {
|
||||
val session = requireSession()
|
||||
repository.deleteMessage(session, message.chatId, message.id)
|
||||
val current = state.value
|
||||
state.value = current.copy(
|
||||
messages = current.messages.filterNot { it.id == message.id },
|
||||
replyTarget = current.replyTarget?.takeIf { it.id != message.id },
|
||||
editTarget = current.editTarget?.takeIf { it.id != message.id },
|
||||
forwardTarget = current.forwardTarget?.takeIf { it.id != message.id }
|
||||
)
|
||||
loadChats()
|
||||
}
|
||||
|
||||
fun toggleReaction(message: MessageDto, emoji: String) = launchLoading(false) {
|
||||
val session = requireSession()
|
||||
val currentReaction = message.reactions.firstOrNull { it.reactedByMe }
|
||||
val updated = if (currentReaction?.emoji == emoji) {
|
||||
repository.clearReaction(session, message.chatId, message.id)
|
||||
} else {
|
||||
repository.setReaction(session, message.chatId, message.id, emoji)
|
||||
}
|
||||
|
||||
val current = state.value
|
||||
if (current.selectedChat?.id == updated.chatId) {
|
||||
state.value = current.copy(
|
||||
messages = (current.messages.filterNot { it.id == updated.id } + updated).sortedBy { it.sentAt },
|
||||
replyTarget = current.replyTarget?.let { if (it.id == updated.id) updated else it },
|
||||
editTarget = current.editTarget?.let { if (it.id == updated.id) updated else it },
|
||||
forwardTarget = current.forwardTarget?.let { if (it.id == updated.id) updated else it }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun openForwardPicker(message: MessageDto) {
|
||||
state.value = state.value.copy(forwardTarget = message)
|
||||
}
|
||||
|
||||
fun closeForwardPicker() {
|
||||
state.value = state.value.copy(forwardTarget = null)
|
||||
}
|
||||
|
||||
fun forwardTo(chat: ChatDto) = launchLoading(false) {
|
||||
val session = requireSession()
|
||||
val source = state.value.forwardTarget ?: return@launchLoading
|
||||
val forwarded = repository.forwardMessage(session, source.chatId, source.id, chat.id)
|
||||
val current = state.value
|
||||
state.value = if (current.selectedChat?.id == chat.id) {
|
||||
current.copy(
|
||||
forwardTarget = null,
|
||||
messages = (current.messages.filterNot { it.id == forwarded.id } + forwarded).sortedBy { it.sentAt }
|
||||
)
|
||||
} else {
|
||||
current.copy(forwardTarget = null)
|
||||
}
|
||||
loadChats()
|
||||
}
|
||||
|
||||
fun searchChatMessages(query: String) {
|
||||
chatSearchJob?.cancel()
|
||||
val trimmed = query.trim()
|
||||
val session = state.value.session
|
||||
val chat = state.value.selectedChat
|
||||
if (trimmed.isBlank() || session == null || chat == null) {
|
||||
state.value = state.value.copy(chatSearchResults = emptyList(), chatSearchLoading = false)
|
||||
return
|
||||
}
|
||||
|
||||
val chatId = chat.id
|
||||
chatSearchJob = viewModelScope.launch {
|
||||
delay(250)
|
||||
state.value = state.value.copy(chatSearchLoading = true)
|
||||
try {
|
||||
val results = withTimeout(ChatListTimeoutMs) {
|
||||
repository.searchMessages(session, chatId, trimmed)
|
||||
}
|
||||
if (state.value.selectedChat?.id == chatId) {
|
||||
state.value = state.value.copy(
|
||||
chatSearchResults = results,
|
||||
chatSearchLoading = false,
|
||||
error = null
|
||||
)
|
||||
}
|
||||
} catch (error: CancellationException) {
|
||||
throw error
|
||||
} catch (error: Throwable) {
|
||||
if (state.value.selectedChat?.id == chatId) {
|
||||
state.value = state.value.copy(chatSearchLoading = false)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun cacheImage(attachment: AttachmentDto) {
|
||||
val session = state.value.session ?: return
|
||||
val key = attachment.id
|
||||
if (state.value.cachedImagePaths.containsKey(key) || !imageCacheInFlight.add(key)) {
|
||||
return
|
||||
}
|
||||
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
val file = repository.cachedAttachmentFile(session, attachment)
|
||||
val currentSession = state.value.session
|
||||
if (currentSession?.serverUrl == session.serverUrl && currentSession.userName == session.userName) {
|
||||
state.value = state.value.copy(
|
||||
cachedImagePaths = state.value.cachedImagePaths + (key to file.absolutePath)
|
||||
)
|
||||
}
|
||||
} catch (_: Throwable) {
|
||||
// The network-backed image loader remains as a fallback if the local cache cannot be populated.
|
||||
} finally {
|
||||
imageCacheInFlight.remove(key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun requireSession(): QMaxSession = state.value.session ?: error("No QMAX session")
|
||||
|
||||
private fun saveCurrentDraft() {
|
||||
val current = state.value
|
||||
if (current.editTarget != null) {
|
||||
return
|
||||
}
|
||||
val chatId = current.selectedChat?.id ?: return
|
||||
viewModelScope.launch {
|
||||
repository.saveDraft(chatId, current.composerText)
|
||||
}
|
||||
}
|
||||
|
||||
private fun openPendingChatIfAvailable(chats: List<ChatDto>) {
|
||||
val chatId = pendingOpenChatId ?: return
|
||||
val chat = chats.firstOrNull { it.id.equals(chatId, ignoreCase = true) } ?: return
|
||||
pendingOpenChatId = null
|
||||
if (state.value.selectedChat?.id != chat.id) {
|
||||
openChat(chat)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun refreshPresence(chatId: String) {
|
||||
val session = state.value.session ?: return
|
||||
val presence = runCatching {
|
||||
repository.chatPresence(session, chatId)
|
||||
}.getOrNull() ?: return
|
||||
if (state.value.selectedChat?.id != chatId) {
|
||||
return
|
||||
}
|
||||
|
||||
val text = if (presence.isTyping) {
|
||||
presence.statusText?.takeIf { it.isNotBlank() } ?: "\u043f\u0435\u0447\u0430\u0442\u0430\u0435\u0442..."
|
||||
} else {
|
||||
null
|
||||
}
|
||||
state.value = state.value.copy(chatPresenceText = text)
|
||||
}
|
||||
|
||||
private fun registerPushDevice(session: QMaxSession) {
|
||||
val registrationKey = "${session.serverUrl}:${session.accessToken.take(12)}"
|
||||
if (pushRegisteredForToken == registrationKey) {
|
||||
return
|
||||
}
|
||||
|
||||
pushRegisteredForToken = registrationKey
|
||||
viewModelScope.launch {
|
||||
runCatching {
|
||||
repository.registerCurrentPushDevice(session)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun connectRealtime(session: QMaxSession) {
|
||||
realtimeConnectJob?.cancel()
|
||||
realtimeConnectJob = viewModelScope.launch {
|
||||
runCatching {
|
||||
realtime.connect(
|
||||
session = session,
|
||||
onChatListInvalidated = ::scheduleChatListRefresh,
|
||||
onMessageCreated = ::handleRealtimeMessage,
|
||||
onMessageUpdated = ::handleRealtimeMessageUpdated,
|
||||
onMessageDeleted = ::handleRealtimeMessageDeleted
|
||||
)
|
||||
realtime.joinChat(state.value.selectedChat?.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun scheduleChatListRefresh() {
|
||||
chatListRefreshJob?.cancel()
|
||||
chatListRefreshJob = viewModelScope.launch {
|
||||
delay(250)
|
||||
refreshChats(showLoading = false)
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleRealtimeMessage(message: MessageDto) {
|
||||
viewModelScope.launch {
|
||||
val current = state.value
|
||||
current.session?.let { repository.cacheRealtimeMessage(it, message) }
|
||||
if (current.selectedChat?.id == message.chatId &&
|
||||
current.messages.none { it.id == message.id }) {
|
||||
val updatedChats = normalizeChats(
|
||||
current.chats.map { chat ->
|
||||
if (chat.id == message.chatId) {
|
||||
chat.copy(
|
||||
lastMessagePreview = message.text ?: chat.lastMessagePreview,
|
||||
lastMessageAt = message.sentAt,
|
||||
unreadCount = 0
|
||||
)
|
||||
} else {
|
||||
chat
|
||||
}
|
||||
},
|
||||
selectedChatId = message.chatId
|
||||
)
|
||||
state.value = current.copy(
|
||||
chats = updatedChats,
|
||||
messages = (current.messages + message).sortedBy { it.sentAt }
|
||||
)
|
||||
syncChatRead(message.chatId)
|
||||
}
|
||||
refreshChats(showLoading = false)
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleRealtimeMessageUpdated(message: MessageDto) {
|
||||
viewModelScope.launch {
|
||||
val current = state.value
|
||||
current.session?.let { repository.cacheRealtimeMessage(it, message) }
|
||||
if (current.selectedChat?.id == message.chatId) {
|
||||
state.value = current.copy(
|
||||
messages = (current.messages.filterNot { it.id == message.id } + message).sortedBy { it.sentAt },
|
||||
replyTarget = current.replyTarget?.let { if (it.id == message.id) message else it },
|
||||
editTarget = current.editTarget?.let { if (it.id == message.id) message else it },
|
||||
forwardTarget = current.forwardTarget?.let { if (it.id == message.id) message else it }
|
||||
)
|
||||
}
|
||||
refreshChats(showLoading = false)
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleRealtimeMessageDeleted(deleted: MessageDeletedDto) {
|
||||
viewModelScope.launch {
|
||||
val current = state.value
|
||||
current.session?.let { repository.deleteCachedMessage(it, deleted.chatId, deleted.messageId) }
|
||||
if (current.selectedChat?.id == deleted.chatId) {
|
||||
state.value = current.copy(
|
||||
messages = current.messages.filterNot { it.id == deleted.messageId },
|
||||
replyTarget = current.replyTarget?.takeIf { it.id != deleted.messageId },
|
||||
editTarget = current.editTarget?.takeIf { it.id != deleted.messageId },
|
||||
forwardTarget = current.forwardTarget?.takeIf { it.id != deleted.messageId }
|
||||
)
|
||||
}
|
||||
refreshChats(showLoading = false)
|
||||
}
|
||||
}
|
||||
|
||||
private fun launchLoading(showLoading: Boolean = true, block: suspend () -> Unit) {
|
||||
viewModelScope.launch {
|
||||
if (showLoading) state.value = state.value.copy(loading = true, error = null)
|
||||
try {
|
||||
block()
|
||||
state.value = state.value.copy(error = null)
|
||||
} catch (error: Throwable) {
|
||||
Log.w(LogTag, "launchLoading failed", error)
|
||||
state.value = state.value.copy(error = error.message ?: "Ошибка")
|
||||
} finally {
|
||||
if (showLoading) state.value = state.value.copy(loading = false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun upsertMessage(messages: List<MessageDto>, message: MessageDto): List<MessageDto> {
|
||||
return (messages.filterNot { it.id == message.id } + message).sortedBy { it.sentAt }
|
||||
}
|
||||
|
||||
private fun normalizeChats(
|
||||
chats: List<ChatDto>,
|
||||
selectedChatId: String? = state.value.selectedChat?.id
|
||||
): List<ChatDto> {
|
||||
val normalized = chats.map { chat ->
|
||||
val fingerprint = chatReadFingerprint(chat)
|
||||
val isSelectedChat = selectedChatId != null && chat.id == selectedChatId
|
||||
if (isSelectedChat) {
|
||||
locallyReadChatFingerprints[chat.id] = fingerprint
|
||||
}
|
||||
|
||||
val shouldKeepRead = isSelectedChat || locallyReadChatFingerprints[chat.id] == fingerprint
|
||||
if (shouldKeepRead && chat.unreadCount != 0) {
|
||||
chat.copy(unreadCount = 0)
|
||||
} else {
|
||||
chat
|
||||
}
|
||||
}
|
||||
return orderedChats(normalized)
|
||||
}
|
||||
|
||||
private fun rememberChatRead(chat: ChatDto) {
|
||||
locallyReadChatFingerprints[chat.id] = chatReadFingerprint(chat)
|
||||
}
|
||||
|
||||
private fun chatReadFingerprint(chat: ChatDto): String {
|
||||
return "${chat.lastMessageAt.orEmpty()}\n${chat.lastMessagePreview.orEmpty()}"
|
||||
}
|
||||
|
||||
private fun persistCurrentChats() {
|
||||
val session = state.value.session ?: return
|
||||
val chats = state.value.chats
|
||||
viewModelScope.launch {
|
||||
runCatching {
|
||||
repository.cacheChats(session, chats)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun syncChatRead(chatId: String) {
|
||||
val session = state.value.session ?: return
|
||||
viewModelScope.launch {
|
||||
runCatching {
|
||||
repository.markChatRead(session, chatId)
|
||||
}
|
||||
runCatching {
|
||||
repository.cacheChats(session, state.value.chats)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun orderedChats(chats: List<ChatDto>): List<ChatDto> {
|
||||
return chats.sortedWith(
|
||||
compareByDescending<ChatDto> { it.isPinned }
|
||||
.thenByDescending { it.unreadCount > 0 }
|
||||
.thenByDescending { it.lastMessageAt.orEmpty() }
|
||||
.thenBy { it.title.lowercase() }
|
||||
)
|
||||
}
|
||||
|
||||
class Factory(private val repository: QMaxRepository) : ViewModelProvider.Factory {
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
override fun <T : ViewModel> create(modelClass: Class<T>): T {
|
||||
return QMaxViewModel(repository) as T
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCleared() {
|
||||
super.onCleared()
|
||||
viewModelScope.launch {
|
||||
realtime.disconnect()
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val LogTag = "QMAX"
|
||||
const val ChatListTimeoutMs = 45_000L
|
||||
const val MessageSyncTimeoutMs = 120_000L
|
||||
const val AutoLoginDelayMs = 1_000L
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package xyz.kusoft.qmax.ui.theme
|
||||
|
||||
import androidx.compose.material3.ColorScheme
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.lightColorScheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.graphics.Color
|
||||
|
||||
val QMaxBlue = Color(0xFF3390EC)
|
||||
val QMaxText = Color(0xFF17212B)
|
||||
val QMaxMuted = Color(0xFF6C7883)
|
||||
val QMaxBackground = Color(0xFFF3F7FA)
|
||||
val QMaxIncoming = Color.White
|
||||
val QMaxOutgoing = Color(0xFFE7F7C8)
|
||||
val QMaxUnread = QMaxBlue
|
||||
|
||||
private val Colors: ColorScheme = lightColorScheme(
|
||||
primary = QMaxBlue,
|
||||
onPrimary = Color.White,
|
||||
background = QMaxBackground,
|
||||
onBackground = QMaxText,
|
||||
surface = Color.White,
|
||||
onSurface = QMaxText,
|
||||
surfaceVariant = Color(0xFFE9EEF2),
|
||||
onSurfaceVariant = QMaxMuted
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun QMaxTheme(content: @Composable () -> Unit) {
|
||||
MaterialTheme(
|
||||
colorScheme = Colors,
|
||||
typography = MaterialTheme.typography,
|
||||
content = content
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="#FFFFFFFF"
|
||||
android:pathData="M9,16.2L4.8,12l-1.4,1.4L9,19 21,7 19.6,5.6z" />
|
||||
</vector>
|
||||
@@ -0,0 +1,8 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="48dp"
|
||||
android:height="48dp"
|
||||
android:viewportWidth="48"
|
||||
android:viewportHeight="48">
|
||||
<path android:fillColor="#3390EC" android:pathData="M24,4C12.95,4 4,12.95 4,24s8.95,20 20,20 20,-8.95 20,-20S35.05,4 24,4z" />
|
||||
<path android:fillColor="#FFFFFF" android:pathData="M13,24L35,14L30,35L24,28L19,33L20,26z" />
|
||||
</vector>
|
||||
@@ -0,0 +1,3 @@
|
||||
<resources>
|
||||
<color name="qmax_blue">#3390EC</color>
|
||||
</resources>
|
||||
@@ -0,0 +1,7 @@
|
||||
<resources>
|
||||
<style name="AppTheme" parent="android:style/Theme.Material.Light.NoActionBar">
|
||||
<item name="android:windowLightStatusBar">true</item>
|
||||
<item name="android:statusBarColor">@android:color/white</item>
|
||||
<item name="android:navigationBarColor">@android:color/white</item>
|
||||
</style>
|
||||
</resources>
|
||||
@@ -0,0 +1,4 @@
|
||||
<paths xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<cache-path name="cache" path="." />
|
||||
<files-path name="files" path="." />
|
||||
</paths>
|
||||
@@ -0,0 +1,6 @@
|
||||
plugins {
|
||||
id("com.android.application") version "9.2.1" apply false
|
||||
id("org.jetbrains.kotlin.plugin.compose") version "2.3.10" apply false
|
||||
id("org.jetbrains.kotlin.plugin.serialization") version "2.3.10" apply false
|
||||
id("com.google.gms.google-services") version "4.4.4" apply false
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
org.gradle.jvmargs=-Xmx4096m -Dfile.encoding=UTF-8
|
||||
org.gradle.workers.max=2
|
||||
android.useAndroidX=true
|
||||
android.nonTransitiveRClass=true
|
||||
kotlin.daemon.jvmargs=-Xmx2048m
|
||||
BIN
Binary file not shown.
@@ -0,0 +1,9 @@
|
||||
distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-9.6.0-bin.zip
|
||||
networkTimeout=10000
|
||||
retries=0
|
||||
retryBackOffMs=500
|
||||
validateDistributionUrl=true
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
zipStorePath=wrapper/dists
|
||||
Vendored
+248
@@ -0,0 +1,248 @@
|
||||
#!/bin/sh
|
||||
|
||||
#
|
||||
# Copyright © 2015 the original authors.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# https://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
#
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
#
|
||||
|
||||
##############################################################################
|
||||
#
|
||||
# gradlew start up script for POSIX generated by Gradle.
|
||||
#
|
||||
# Important for running:
|
||||
#
|
||||
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
|
||||
# noncompliant, but you have some other compliant shell such as ksh or
|
||||
# bash, then to run this script, type that shell name before the whole
|
||||
# command line, like:
|
||||
#
|
||||
# ksh gradlew
|
||||
#
|
||||
# Busybox and similar reduced shells will NOT work, because this script
|
||||
# requires all of these POSIX shell features:
|
||||
# * functions;
|
||||
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
|
||||
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
|
||||
# * compound commands having a testable exit status, especially «case»;
|
||||
# * various built-in commands including «command», «set», and «ulimit».
|
||||
#
|
||||
# Important for patching:
|
||||
#
|
||||
# (2) This script targets any POSIX shell, so it avoids extensions provided
|
||||
# by Bash, Ksh, etc; in particular arrays are avoided.
|
||||
#
|
||||
# The "traditional" practice of packing multiple parameters into a
|
||||
# space-separated string is a well documented source of bugs and security
|
||||
# problems, so this is (mostly) avoided, by progressively accumulating
|
||||
# options in "$@", and eventually passing that to Java.
|
||||
#
|
||||
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
|
||||
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
|
||||
# see the in-line comments for details.
|
||||
#
|
||||
# There are tweaks for specific operating systems such as AIX, CygWin,
|
||||
# Darwin, MinGW, and NonStop.
|
||||
#
|
||||
# (3) This script is generated from the Groovy template
|
||||
# https://github.com/gradle/gradle/blob/3d91ce3b8caaf77ad09f381f43615b715b53f72c/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
|
||||
# within the Gradle project.
|
||||
#
|
||||
# You can find Gradle at https://github.com/gradle/gradle/.
|
||||
#
|
||||
##############################################################################
|
||||
|
||||
# Attempt to set APP_HOME
|
||||
|
||||
# Resolve links: $0 may be a link
|
||||
app_path=$0
|
||||
|
||||
# Need this for daisy-chained symlinks.
|
||||
while
|
||||
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
|
||||
[ -h "$app_path" ]
|
||||
do
|
||||
ls=$( ls -ld "$app_path" )
|
||||
link=${ls#*' -> '}
|
||||
case $link in #(
|
||||
/*) app_path=$link ;; #(
|
||||
*) app_path=$APP_HOME$link ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# This is normally unused
|
||||
# shellcheck disable=SC2034
|
||||
APP_BASE_NAME=${0##*/}
|
||||
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
|
||||
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
|
||||
|
||||
# Use the maximum available, or set MAX_FD != -1 to use that value.
|
||||
MAX_FD=maximum
|
||||
|
||||
warn () {
|
||||
echo "$*"
|
||||
} >&2
|
||||
|
||||
die () {
|
||||
echo
|
||||
echo "$*"
|
||||
echo
|
||||
exit 1
|
||||
} >&2
|
||||
|
||||
# OS specific support (must be 'true' or 'false').
|
||||
cygwin=false
|
||||
msys=false
|
||||
darwin=false
|
||||
nonstop=false
|
||||
case "$( uname )" in #(
|
||||
CYGWIN* ) cygwin=true ;; #(
|
||||
Darwin* ) darwin=true ;; #(
|
||||
MSYS* | MINGW* ) msys=true ;; #(
|
||||
NONSTOP* ) nonstop=true ;;
|
||||
esac
|
||||
|
||||
|
||||
|
||||
# Determine the Java command to use to start the JVM.
|
||||
if [ -n "$JAVA_HOME" ] ; then
|
||||
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
|
||||
# IBM's JDK on AIX uses strange locations for the executables
|
||||
JAVACMD=$JAVA_HOME/jre/sh/java
|
||||
else
|
||||
JAVACMD=$JAVA_HOME/bin/java
|
||||
fi
|
||||
if [ ! -x "$JAVACMD" ] ; then
|
||||
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
|
||||
|
||||
Please set the JAVA_HOME variable in your environment to match the
|
||||
location of your Java installation."
|
||||
fi
|
||||
else
|
||||
JAVACMD=java
|
||||
if ! command -v java >/dev/null 2>&1
|
||||
then
|
||||
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
||||
|
||||
Please set the JAVA_HOME variable in your environment to match the
|
||||
location of your Java installation."
|
||||
fi
|
||||
fi
|
||||
|
||||
# Increase the maximum file descriptors if we can.
|
||||
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
|
||||
case $MAX_FD in #(
|
||||
max*)
|
||||
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
|
||||
# shellcheck disable=SC2039,SC3045
|
||||
MAX_FD=$( ulimit -H -n ) ||
|
||||
warn "Could not query maximum file descriptor limit"
|
||||
esac
|
||||
case $MAX_FD in #(
|
||||
'' | soft) :;; #(
|
||||
*)
|
||||
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
|
||||
# shellcheck disable=SC2039,SC3045
|
||||
ulimit -n "$MAX_FD" ||
|
||||
warn "Could not set maximum file descriptor limit to $MAX_FD"
|
||||
esac
|
||||
fi
|
||||
|
||||
# Collect all arguments for the java command, stacking in reverse order:
|
||||
# * args from the command line
|
||||
# * the main class name
|
||||
# * -classpath
|
||||
# * -D...appname settings
|
||||
# * --module-path (only if needed)
|
||||
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
|
||||
|
||||
# For Cygwin or MSYS, switch paths to Windows format before running java
|
||||
if "$cygwin" || "$msys" ; then
|
||||
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
|
||||
|
||||
JAVACMD=$( cygpath --unix "$JAVACMD" )
|
||||
|
||||
# Now convert the arguments - kludge to limit ourselves to /bin/sh
|
||||
for arg do
|
||||
if
|
||||
case $arg in #(
|
||||
-*) false ;; # don't mess with options #(
|
||||
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
|
||||
[ -e "$t" ] ;; #(
|
||||
*) false ;;
|
||||
esac
|
||||
then
|
||||
arg=$( cygpath --path --ignore --mixed "$arg" )
|
||||
fi
|
||||
# Roll the args list around exactly as many times as the number of
|
||||
# args, so each arg winds up back in the position where it started, but
|
||||
# possibly modified.
|
||||
#
|
||||
# NB: a `for` loop captures its iteration list before it begins, so
|
||||
# changing the positional parameters here affects neither the number of
|
||||
# iterations, nor the values presented in `arg`.
|
||||
shift # remove old arg
|
||||
set -- "$@" "$arg" # push replacement arg
|
||||
done
|
||||
fi
|
||||
|
||||
|
||||
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
|
||||
|
||||
# Collect all arguments for the java command:
|
||||
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
|
||||
# and any embedded shellness will be escaped.
|
||||
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
|
||||
# treated as '${Hostname}' itself on the command line.
|
||||
|
||||
set -- \
|
||||
"-Dorg.gradle.appname=$APP_BASE_NAME" \
|
||||
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
|
||||
"$@"
|
||||
|
||||
# Stop when "xargs" is not available.
|
||||
if ! command -v xargs >/dev/null 2>&1
|
||||
then
|
||||
die "xargs is not available"
|
||||
fi
|
||||
|
||||
# Use "xargs" to parse quoted args.
|
||||
#
|
||||
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
|
||||
#
|
||||
# In Bash we could simply go:
|
||||
#
|
||||
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
|
||||
# set -- "${ARGS[@]}" "$@"
|
||||
#
|
||||
# but POSIX shell has neither arrays nor command substitution, so instead we
|
||||
# post-process each arg (as a line of input to sed) to backslash-escape any
|
||||
# character that might be a shell metacharacter, then use eval to reverse
|
||||
# that process (while maintaining the separation between arguments), and wrap
|
||||
# the whole thing up as a single "set" statement.
|
||||
#
|
||||
# This will of course break if any of these variables contains a newline or
|
||||
# an unmatched quote.
|
||||
#
|
||||
|
||||
eval "set -- $(
|
||||
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
|
||||
xargs -n1 |
|
||||
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
|
||||
tr '\n' ' '
|
||||
)" '"$@"'
|
||||
|
||||
exec "$JAVACMD" "$@"
|
||||
Vendored
+82
@@ -0,0 +1,82 @@
|
||||
@rem
|
||||
@rem Copyright 2015 the original author or authors.
|
||||
@rem
|
||||
@rem Licensed under the Apache License, Version 2.0 (the "License");
|
||||
@rem you may not use this file except in compliance with the License.
|
||||
@rem You may obtain a copy of the License at
|
||||
@rem
|
||||
@rem https://www.apache.org/licenses/LICENSE-2.0
|
||||
@rem
|
||||
@rem Unless required by applicable law or agreed to in writing, software
|
||||
@rem distributed under the License is distributed on an "AS IS" BASIS,
|
||||
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
@rem See the License for the specific language governing permissions and
|
||||
@rem limitations under the License.
|
||||
@rem
|
||||
@rem SPDX-License-Identifier: Apache-2.0
|
||||
@rem
|
||||
|
||||
@if "%DEBUG%"=="" @echo off
|
||||
@rem ##########################################################################
|
||||
@rem
|
||||
@rem gradlew startup script for Windows
|
||||
@rem
|
||||
@rem ##########################################################################
|
||||
|
||||
@rem Set local scope for the variables, and ensure extensions are enabled
|
||||
setlocal EnableExtensions
|
||||
|
||||
set DIRNAME=%~dp0
|
||||
if "%DIRNAME%"=="" set DIRNAME=.
|
||||
@rem This is normally unused
|
||||
set APP_BASE_NAME=%~n0
|
||||
set APP_HOME=%DIRNAME%
|
||||
|
||||
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
|
||||
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
|
||||
|
||||
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
|
||||
|
||||
@rem Find java.exe
|
||||
if defined JAVA_HOME goto findJavaFromJavaHome
|
||||
|
||||
set JAVA_EXE=java.exe
|
||||
%JAVA_EXE% -version >NUL 2>&1
|
||||
if %ERRORLEVEL% equ 0 goto execute
|
||||
|
||||
echo. 1>&2
|
||||
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
|
||||
echo. 1>&2
|
||||
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||
echo location of your Java installation. 1>&2
|
||||
|
||||
"%COMSPEC%" /c exit 1
|
||||
|
||||
:findJavaFromJavaHome
|
||||
set JAVA_HOME=%JAVA_HOME:"=%
|
||||
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
|
||||
|
||||
if exist "%JAVA_EXE%" goto execute
|
||||
|
||||
echo. 1>&2
|
||||
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
|
||||
echo. 1>&2
|
||||
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||
echo location of your Java installation. 1>&2
|
||||
|
||||
"%COMSPEC%" /c exit 1
|
||||
|
||||
:execute
|
||||
@rem Setup the command line
|
||||
|
||||
|
||||
|
||||
@rem Execute gradlew
|
||||
@rem endlocal doesn't take effect until after the line is parsed and variables are expanded
|
||||
@rem which allows us to clear the local environment before executing the java command
|
||||
endlocal & "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* & call :exitWithErrorLevel
|
||||
|
||||
:exitWithErrorLevel
|
||||
@rem Use "%COMSPEC%" /c exit to allow operators to work properly in scripts
|
||||
"%COMSPEC%" /c exit %ERRORLEVEL%
|
||||
@@ -0,0 +1,4 @@
|
||||
storeFile=qmax-release.jks
|
||||
storePassword=cc1ed02770207c5522192ea1f9eb3e4dc52040ac
|
||||
keyAlias=qmax-release
|
||||
keyPassword=cc1ed02770207c5522192ea1f9eb3e4dc52040ac
|
||||
Binary file not shown.
@@ -0,0 +1,18 @@
|
||||
pluginManagement {
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
gradlePluginPortal()
|
||||
}
|
||||
}
|
||||
|
||||
dependencyResolutionManagement {
|
||||
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
}
|
||||
}
|
||||
|
||||
rootProject.name = "QMAX"
|
||||
include(":app")
|
||||
@@ -0,0 +1,23 @@
|
||||
QMAX_DOMAIN=qmax.kusoft.xyz
|
||||
QMAX_PUBLIC_BASE_URL=https://qmax.kusoft.xyz
|
||||
QMAX_TLS_EMAIL=admin@kusoft.xyz
|
||||
|
||||
# Generate with: openssl rand -base64 48
|
||||
QMAX_JWT_SECRET=change-me-to-a-long-random-secret-at-least-32-characters
|
||||
|
||||
# Enter this once on the Android login screen to pair the device with your private bridge.
|
||||
QMAX_PAIRING_CODE=change-me-pairing-code
|
||||
|
||||
# Use international format if MAX accepts it in your flow, otherwise the local phone format you use in MAX Web.
|
||||
QMAX_MAX_PHONE_NUMBER=79000000000
|
||||
|
||||
# Set false temporarily if you attach a visible browser/VNC workflow for CAPTCHA/debug.
|
||||
QMAX_MAX_HEADLESS=true
|
||||
QMAX_CORS_ALLOWED_ORIGINS=
|
||||
|
||||
# Optional Firebase Cloud Messaging. Mount the service account JSON into the API container
|
||||
# and set these values to enable real push notifications.
|
||||
QMAX_PUSH_ENABLED=true
|
||||
QMAX_PUSH_SHOW_PREVIEW=true
|
||||
QMAX_FIREBASE_PROJECT_ID=
|
||||
QMAX_FIREBASE_SERVICE_ACCOUNT_PATH=
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
email {$QMAX_TLS_EMAIL}
|
||||
}
|
||||
|
||||
{$QMAX_DOMAIN} {
|
||||
encode zstd gzip
|
||||
reverse_proxy qmax-api:8080
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
services:
|
||||
qmax-api:
|
||||
build:
|
||||
context: ..
|
||||
dockerfile: Dockerfile.server
|
||||
container_name: qmax-api
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
ASPNETCORE_ENVIRONMENT: Production
|
||||
QMax__PublicBaseUrl: ${QMAX_PUBLIC_BASE_URL:-https://qmax.kusoft.xyz}
|
||||
QMax__DatabasePath: /data/qmax.db
|
||||
QMax__StoragePath: /data/storage
|
||||
QMax__ReleasesPath: /data/releases
|
||||
QMax__JwtSecret: ${QMAX_JWT_SECRET}
|
||||
QMax__PairingCode: ${QMAX_PAIRING_CODE}
|
||||
QMax__MaxPhoneNumber: ${QMAX_MAX_PHONE_NUMBER}
|
||||
QMax__MaxMode: Worker
|
||||
QMax__MaxWorkerBaseUrl: http://qmax-max-worker:3001
|
||||
QMax__CorsAllowedOrigins: ${QMAX_CORS_ALLOWED_ORIGINS:-}
|
||||
QMax__PushEnabled: ${QMAX_PUSH_ENABLED:-true}
|
||||
QMax__PushShowPreview: ${QMAX_PUSH_SHOW_PREVIEW:-true}
|
||||
QMax__FirebaseProjectId: ${QMAX_FIREBASE_PROJECT_ID:-}
|
||||
QMax__FirebaseServiceAccountPath: ${QMAX_FIREBASE_SERVICE_ACCOUNT_PATH:-}
|
||||
volumes:
|
||||
- qmax-data:/data
|
||||
- ./releases:/data/releases:ro
|
||||
- ./secrets:/run/secrets/qmax:ro
|
||||
ports:
|
||||
- "127.0.0.1:18080:8080"
|
||||
depends_on:
|
||||
- qmax-max-worker
|
||||
networks:
|
||||
- qmax
|
||||
|
||||
qmax-max-worker:
|
||||
build:
|
||||
context: ../worker
|
||||
dockerfile: Dockerfile
|
||||
container_name: qmax-max-worker
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
PORT: 3001
|
||||
MAX_BASE_URL: https://web.max.ru/
|
||||
MAX_USER_DATA_DIR: /data/max-profile
|
||||
MAX_HEADLESS: ${QMAX_MAX_HEADLESS:-true}
|
||||
volumes:
|
||||
- qmax-max-profile:/data
|
||||
- qmax-data:/qmax-data
|
||||
networks:
|
||||
- qmax
|
||||
|
||||
volumes:
|
||||
qmax-data:
|
||||
qmax-max-profile:
|
||||
|
||||
networks:
|
||||
qmax:
|
||||
driver: bridge
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"slug": "qmax",
|
||||
"name": "QMAX",
|
||||
"version": "0.1.39",
|
||||
"androidVersionCode": 40,
|
||||
"channel": "stable",
|
||||
"platform": "android",
|
||||
"packageKind": "apk",
|
||||
"downloadPath": "/api/app-updates/android/download/qmax-0.1.39-stable.apk",
|
||||
"packageSizeBytes": 17730369,
|
||||
"sha256": "fb1ba02daf4a5e99972794bd69965bef8219388c96344902b9d8a67ed2635803",
|
||||
"notes": "Chat composer now follows the Android keyboard height like Telegram, and downloaded video, audio, and document attachments keep correct local cache extensions.",
|
||||
"publishedAt": "2026-07-02T18:38:00.3130417Z"
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,36 @@
|
||||
# QMAX Architecture
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
A["Android app<br/>Kotlin + Compose"] -->|HTTPS JSON + uploads| B["QMAX API<br/>ASP.NET Core"]
|
||||
A -->|SignalR planned/available| B
|
||||
B --> C["SQLite cache<br/>chats/messages/sessions"]
|
||||
B --> D["Local storage<br/>attachments/releases"]
|
||||
B -->|HTTP internal| E["MAX worker<br/>Node + Playwright"]
|
||||
E -->|persistent browser profile| F["web.max.ru"]
|
||||
B --> G["Caddy TLS<br/>qmax.kusoft.xyz"]
|
||||
```
|
||||
|
||||
The server is the only public backend surface. The MAX worker stays inside the Docker network and is controlled through the API/admin page.
|
||||
|
||||
## Security Rules
|
||||
|
||||
- Android pairs to the private server with `QMAX_PAIRING_CODE`.
|
||||
- API access uses JWT access tokens and refresh tokens.
|
||||
- MAX credentials and sessions live only on the Pi in Docker volumes.
|
||||
- `.env`, service-account files, keystores and runtime data are ignored by git.
|
||||
- Attachments are written as `.part` first and moved into place only after full upload.
|
||||
- Android image attachments are downloaded to a local `.part` cache and exposed to the UI only after size validation.
|
||||
|
||||
## Current Verified Status
|
||||
|
||||
- MAX Web login is authorized on the Raspberry Pi worker.
|
||||
- Chat list, message history, text sending and attachment sending are mapped through `worker/src/server.js`.
|
||||
- Image, video, file and voice attachments are projected through the API and rendered by the Android client.
|
||||
- Firebase initialization and Android push token registration are enabled for `xyz.kusoft.qmax`.
|
||||
|
||||
## Remaining Production Checks
|
||||
|
||||
- Keep selector drift monitoring for future MAX Web UI changes.
|
||||
- Run an unlocked-device visual pass on the connected phone for every major UI change.
|
||||
- Replace debug fallback signing with a real release key in `android/key.properties`.
|
||||
@@ -0,0 +1,54 @@
|
||||
{
|
||||
"project_info": {
|
||||
"project_number": "471457572036",
|
||||
"project_id": "qmax-29df6",
|
||||
"storage_bucket": "qmax-29df6.firebasestorage.app"
|
||||
},
|
||||
"client": [
|
||||
{
|
||||
"client_info": {
|
||||
"mobilesdk_app_id": "1:471457572036:android:249a779d7a39b9c9359591",
|
||||
"android_client_info": {
|
||||
"package_name": "com.kusoft.qmax"
|
||||
}
|
||||
},
|
||||
"oauth_client": [],
|
||||
"api_key": [
|
||||
{
|
||||
"current_key": "AIzaSyBYwLKxYG6nxBG6P11XHE-A4r6Pzz2umUk"
|
||||
},
|
||||
{
|
||||
"current_key": "AIzaSyB5QNIY1gBFdz92D_HFxX3wWMCn0HJSUc8"
|
||||
}
|
||||
],
|
||||
"services": {
|
||||
"appinvite_service": {
|
||||
"other_platform_oauth_client": []
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"client_info": {
|
||||
"mobilesdk_app_id": "1:471457572036:android:585b1da178ce5344359591",
|
||||
"android_client_info": {
|
||||
"package_name": "ru.qmax.app"
|
||||
}
|
||||
},
|
||||
"oauth_client": [],
|
||||
"api_key": [
|
||||
{
|
||||
"current_key": "AIzaSyBYwLKxYG6nxBG6P11XHE-A4r6Pzz2umUk"
|
||||
},
|
||||
{
|
||||
"current_key": "AIzaSyB5QNIY1gBFdz92D_HFxX3wWMCn0HJSUc8"
|
||||
}
|
||||
],
|
||||
"services": {
|
||||
"appinvite_service": {
|
||||
"other_platform_oauth_client": []
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"configuration_version": "1"
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 31 KiB |
File diff suppressed because one or more lines are too long
Binary file not shown.
|
After Width: | Height: | Size: 3.7 MiB |
File diff suppressed because one or more lines are too long
Binary file not shown.
|
After Width: | Height: | Size: 19 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 19 KiB |
File diff suppressed because one or more lines are too long
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"type": "service_account",
|
||||
"project_id": "qmax-29df6",
|
||||
"private_key_id": "70d14870b035f634c997cd7aa8a309628a8322fc",
|
||||
"private_key": "-----BEGIN PRIVATE KEY-----\nMIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQC2PtpkPNq+Gxfr\n7qo8Ij3CtCLAYMMjthPbIEFRtM0SBoYCoanKlCEsDDGyfbWnvbONVNcDaGB4dkNu\np6ac+VCIihaCnR7i4uABpeYWW9mziG5Xdy1VYikrNyYFaqrEJkLeF501l+iYOToP\n3H0c+72/lfJvAHmCl0vwbfJisCJOizIRrTCie7t2KMAPpJvpGfb6X9RGSh75tTBP\nLR/aB6svO/jAqy+ak3TikIXbi3wmMEQPGIwomfyquV59c75+2Lz/1DgJSv1gobBP\nhK4xHjshEtMaqyulQPib4/8L5oLVU8nAYIBzeGggi7K+jsk+iNzaYTBJ0mb3oyge\nca4RdIKzAgMBAAECggEABhCfafiQewUhzas6pReqoIEoKujNdbHIhWFUayiuLmxj\nJ1FD+kblt7aMLNl8IyHwCMMLCMT/eOLOqa8Qn2a4pGKcDyq1bW2JcOSZfKL/4ram\nnWiM7KuAnTyPmlyLZE21qfh1NeRhTDeTVBXNYBqMnQSbddA5OvxX/Z9bEqez7JMs\n28XAtYyxbnafngjsxBFozchL6oOtxtzBmROZklDatxalb4LUEKwFNFSmcKWGhNEF\nUuf4pTO9Fh3AfRkKdNj5Tx790KmMYdKA7OxQ/rsMwyJTi0oMxdKzCMEmcUrRYcrU\niJRkituWKKPtP6NsysdJ9Mh7qHxUG3Jg+epiNH5qsQKBgQDuls3q50sy12vW4dyP\nVlKu0+QmaMKXcywaXHStHYF34kyH1QZOHlTeJzxWFBKJitKHawXfy7IDQlyVrhxs\nCSkBpx26ewMjWyPS6pLMwrP/17s6oDeh8toZyq7I9vUZ170Jeekp10yixA7GiJgb\nq2hO614hZ9Js5O/1trr0G8vjmQKBgQDDi3fOcLmh9IjJLMdeRNG+U6lf1OQbH2pl\nbqXTlScfAD7YE6OFozQoHv/RuaA/6x6SRrkqwgpU+qIzZ/ykSOpI5HlYJy5CF3a8\nAlHJmSKS1BJheTU0Br2hBgGKxr03JqVij128WMf/jcbEHmi34Up9QfI4rD4eBFKp\nvkvIDyyIKwKBgQCUjTbW7H4IwNI3L9fpM0E1815Zf96w95fdLfXDl9x1rWQjKsLL\nPt1umJVbrxG/q7zbbgpxRl2m49nLpGWz6pwqmEfNRSw2BlguybjvXsc+I69CmGEr\nJ48egfED1afUGFxuGwbO82uW6GWevYufpsDCao/oUsFU5dJ2dfi/ZtMy+QKBgQCO\nV4W8lr3qMLEpkBkIfBwZ6ZiPk88AF1xBvcukOxyhKnKUo6cS7nxXAEnEBtWp8aLt\nY+ICSAFxXoiX1whHJnS03uudydeYcLATp7SVhY1vSEShA7RAzO9YuaCBvq2Z6d3V\n4vqe0hmz4yZfOKuNvseUal7B9k+5Vfg+a7GKTdFixQKBgFzaUXdkINiU3qO1qCPe\ndgLJCg/6QPKScnBxejirIsgRUMAjPzssqEh4KesksCCMB+KBnz6NMCbuNGM8d1XB\nJ4kv+e2JsLPlGyFWY1HbHiigGWXt5NTDXzSrZVi3v59dGx1DgKFsbeTC14FjKBgP\ne7TM6Oyi4+/hUgpWNhAP0fuO\n-----END PRIVATE KEY-----\n",
|
||||
"client_email": "firebase-adminsdk-fbsvc@qmax-29df6.iam.gserviceaccount.com",
|
||||
"client_id": "109102072285119842018",
|
||||
"auth_uri": "https://accounts.google.com/o/oauth2/auth",
|
||||
"token_uri": "https://oauth2.googleapis.com/token",
|
||||
"auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs",
|
||||
"client_x509_cert_url": "https://www.googleapis.com/robot/v1/metadata/x509/firebase-adminsdk-fbsvc%40qmax-29df6.iam.gserviceaccount.com",
|
||||
"universe_domain": "googleapis.com"
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 247 KiB |
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,56 @@
|
||||
param(
|
||||
[string]$HostName = "195.216.241.165",
|
||||
[int]$Port = 2222,
|
||||
[string]$User = "sevenhill",
|
||||
[string]$RemoteDir = "/home/sevenhill/qmax"
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
Write-Host "This script prepares QMAX files for Raspberry Pi deployment."
|
||||
Write-Host "It intentionally does not contain or echo SSH passwords."
|
||||
Write-Host "Prerequisites on the Pi: Docker Engine, Docker Compose plugin, DNS A record for qmax.kusoft.xyz."
|
||||
|
||||
$archive = Join-Path $env:TEMP "qmax-deploy.tar.gz"
|
||||
if (Test-Path $archive) {
|
||||
Remove-Item -LiteralPath $archive -Force
|
||||
}
|
||||
|
||||
$root = Resolve-Path "$PSScriptRoot\.."
|
||||
$staging = Join-Path $env:TEMP "qmax-deploy-staging"
|
||||
if (Test-Path $staging) {
|
||||
Remove-Item -LiteralPath $staging -Recurse -Force
|
||||
}
|
||||
New-Item -ItemType Directory -Force -Path $staging | Out-Null
|
||||
|
||||
foreach ($item in @("server", "worker", "deploy", "Dockerfile.server", "QMax.slnx")) {
|
||||
Copy-Item -LiteralPath (Join-Path $root $item) -Destination $staging -Recurse -Force
|
||||
}
|
||||
|
||||
foreach ($path in @(
|
||||
"server/QMax.Api/bin",
|
||||
"server/QMax.Api/obj",
|
||||
"worker/node_modules"
|
||||
)) {
|
||||
$target = Join-Path $staging $path
|
||||
if (Test-Path $target) {
|
||||
Remove-Item -LiteralPath $target -Recurse -Force
|
||||
}
|
||||
}
|
||||
|
||||
Push-Location $staging
|
||||
try {
|
||||
tar -czf $archive server worker deploy Dockerfile.server QMax.slnx
|
||||
}
|
||||
finally {
|
||||
Pop-Location
|
||||
}
|
||||
|
||||
Write-Host "Archive created: $archive"
|
||||
Write-Host "Upload:"
|
||||
Write-Host " scp -P $Port $archive ${User}@${HostName}:/tmp/qmax-deploy.tar.gz"
|
||||
Write-Host "Remote install:"
|
||||
Write-Host " ssh -p $Port ${User}@${HostName}"
|
||||
Write-Host " rm -rf $RemoteDir && mkdir -p $RemoteDir && tar -xzf /tmp/qmax-deploy.tar.gz -C $RemoteDir"
|
||||
Write-Host " cd $RemoteDir/deploy && cp .env.example .env && nano .env"
|
||||
Write-Host " docker compose up -d --build"
|
||||
@@ -0,0 +1,50 @@
|
||||
param(
|
||||
[string]$Version = "0.1.0",
|
||||
[int]$VersionCode = 1,
|
||||
[string]$Channel = "stable"
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
$root = Resolve-Path "$PSScriptRoot\.."
|
||||
$apk = Join-Path $root "android\app\build\outputs\apk\release\app-release.apk"
|
||||
$releaseDir = Join-Path $root "deploy\releases\android"
|
||||
|
||||
Push-Location (Join-Path $root "android")
|
||||
try {
|
||||
.\gradlew.bat :app:assembleRelease --console=plain --no-daemon
|
||||
}
|
||||
finally {
|
||||
Pop-Location
|
||||
}
|
||||
|
||||
if (!(Test-Path $apk)) {
|
||||
throw "Release APK was not produced: $apk"
|
||||
}
|
||||
|
||||
New-Item -ItemType Directory -Force -Path $releaseDir | Out-Null
|
||||
$targetName = "qmax-$Version-$Channel.apk"
|
||||
$target = Join-Path $releaseDir $targetName
|
||||
Copy-Item -LiteralPath $apk -Destination $target -Force
|
||||
|
||||
$hash = (Get-FileHash -Algorithm SHA256 -LiteralPath $target).Hash.ToLowerInvariant()
|
||||
$size = (Get-Item -LiteralPath $target).Length
|
||||
$manifest = [ordered]@{
|
||||
slug = "qmax"
|
||||
name = "QMAX"
|
||||
version = $Version
|
||||
androidVersionCode = $VersionCode
|
||||
channel = $Channel
|
||||
platform = "android"
|
||||
packageKind = "apk"
|
||||
downloadPath = "/api/app-updates/android/download/$targetName"
|
||||
packageSizeBytes = $size
|
||||
sha256 = $hash
|
||||
notes = "QMAX $Version"
|
||||
publishedAt = (Get-Date).ToUniversalTime().ToString("O")
|
||||
}
|
||||
|
||||
$manifestJson = $manifest | ConvertTo-Json -Depth 4
|
||||
$utf8NoBom = New-Object System.Text.UTF8Encoding($false)
|
||||
[System.IO.File]::WriteAllText((Join-Path $releaseDir "latest.json"), $manifestJson, $utf8NoBom)
|
||||
Write-Host "Packaged $targetName"
|
||||
Write-Host "SHA-256 $hash"
|
||||
@@ -0,0 +1,17 @@
|
||||
param(
|
||||
[string]$ServerUrl = "http://localhost:8080",
|
||||
[string]$PairingCode = "qmax-local-dev"
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
dotnet test "$PSScriptRoot\..\QMax.slnx"
|
||||
Push-Location "$PSScriptRoot\..\android"
|
||||
try {
|
||||
.\gradlew.bat :app:assembleDebug :app:assembleRelease --console=plain --no-daemon
|
||||
}
|
||||
finally {
|
||||
Pop-Location
|
||||
}
|
||||
|
||||
Write-Host "Smoke build checks completed."
|
||||
@@ -0,0 +1,26 @@
|
||||
namespace QMax.Api.Configuration;
|
||||
|
||||
public sealed class QMaxOptions
|
||||
{
|
||||
public string PublicBaseUrl { get; set; } = "https://qmax.kusoft.xyz";
|
||||
public string DatabasePath { get; set; } = "data/qmax.db";
|
||||
public string StoragePath { get; set; } = "data/storage";
|
||||
public string JwtIssuer { get; set; } = "QMAX";
|
||||
public string JwtAudience { get; set; } = "QMAX.Android";
|
||||
public string JwtSecret { get; set; } = "";
|
||||
public string PairingCode { get; set; } = "";
|
||||
public string MaxPhoneNumber { get; set; } = "";
|
||||
public string MaxMode { get; set; } = "Worker";
|
||||
public string MaxWorkerBaseUrl { get; set; } = "http://qmax-max-worker:3001";
|
||||
public string MaxUserDataPath { get; set; } = "data/max-profile";
|
||||
public bool MaxHeadless { get; set; } = true;
|
||||
public int MaxPollIntervalSeconds { get; set; } = 6;
|
||||
public long MaxUploadBytes { get; set; } = 25L * 1024 * 1024;
|
||||
public string CorsAllowedOrigins { get; set; } = "";
|
||||
public string ReleasesPath { get; set; } = "data/releases";
|
||||
public bool PushEnabled { get; set; } = true;
|
||||
public bool PushShowPreview { get; set; } = true;
|
||||
public string FirebaseProjectId { get; set; } = "";
|
||||
public string FirebaseServiceAccountPath { get; set; } = "";
|
||||
public string FirebaseServiceAccountJson { get; set; } = "";
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
namespace QMax.Api.Contracts;
|
||||
|
||||
public sealed record AppUpdateManifestDto(
|
||||
string Slug,
|
||||
string Name,
|
||||
string Version,
|
||||
int AndroidVersionCode,
|
||||
string Channel,
|
||||
string Platform,
|
||||
string PackageKind,
|
||||
string DownloadPath,
|
||||
long PackageSizeBytes,
|
||||
string Sha256,
|
||||
string Notes,
|
||||
DateTimeOffset PublishedAt);
|
||||
@@ -0,0 +1,7 @@
|
||||
namespace QMax.Api.Contracts;
|
||||
|
||||
public sealed record DeviceLoginRequest(string PairingCode, string DeviceName);
|
||||
public sealed record RefreshTokenRequest(string RefreshToken);
|
||||
public sealed record AuthResponse(string AccessToken, string RefreshToken, DateTimeOffset ExpiresAt, UserDto User);
|
||||
public sealed record UserDto(Guid Id, string DisplayName, string? PhoneNumber, string? AvatarPath);
|
||||
public sealed record SessionDto(Guid Id, string DeviceName, DateTimeOffset CreatedAt, DateTimeOffset LastSeenAt, DateTimeOffset ExpiresAt, bool IsCurrent);
|
||||
@@ -0,0 +1,53 @@
|
||||
using QMax.Api.Data.Entities;
|
||||
|
||||
namespace QMax.Api.Contracts;
|
||||
|
||||
public sealed record ChatDto(
|
||||
Guid Id,
|
||||
string? ExternalId,
|
||||
ChatKind Kind,
|
||||
string Title,
|
||||
string? AvatarUrl,
|
||||
string? LastMessagePreview,
|
||||
DateTimeOffset? LastMessageAt,
|
||||
int UnreadCount,
|
||||
bool IsPinned,
|
||||
bool IsMuted,
|
||||
bool IsArchived);
|
||||
|
||||
public sealed record MessageDto(
|
||||
Guid Id,
|
||||
Guid ChatId,
|
||||
string? ExternalId,
|
||||
string? SenderName,
|
||||
MessageDirection Direction,
|
||||
string? Text,
|
||||
DateTimeOffset SentAt,
|
||||
DateTimeOffset? EditedAt,
|
||||
DateTimeOffset? DeletedAt,
|
||||
Guid? ReplyToMessageId,
|
||||
string? ForwardedFrom,
|
||||
MessageDeliveryState DeliveryState,
|
||||
string? Error,
|
||||
IReadOnlyList<ReactionDto> Reactions,
|
||||
IReadOnlyList<AttachmentDto> Attachments);
|
||||
|
||||
public sealed record ReactionDto(string Emoji, int Count, bool ReactedByMe);
|
||||
|
||||
public sealed record AttachmentDto(
|
||||
Guid Id,
|
||||
string FileName,
|
||||
string ContentType,
|
||||
long FileSizeBytes,
|
||||
string DownloadPath,
|
||||
AttachmentKind Kind,
|
||||
int SortOrder);
|
||||
|
||||
public sealed record SendMessageRequest(string Text, Guid? ReplyToMessageId = null);
|
||||
public sealed record EditMessageRequest(string Text);
|
||||
public sealed record ForwardMessageRequest(Guid TargetChatId);
|
||||
public sealed record SetReactionRequest(string Emoji);
|
||||
public sealed record MessageDeletedDto(Guid ChatId, Guid MessageId);
|
||||
public sealed record CreateDirectChatRequest(string ExternalChatId, string Title, string? AvatarUrl = null);
|
||||
|
||||
public sealed record ChatPresenceDto(bool IsTyping, string? StatusText, DateTimeOffset UpdatedAt);
|
||||
@@ -0,0 +1,20 @@
|
||||
namespace QMax.Api.Contracts;
|
||||
|
||||
public sealed record BeginMaxLoginRequest(string? PhoneNumber);
|
||||
public sealed record SubmitMaxCodeRequest(string Code);
|
||||
public sealed record MaxBridgeStatusDto(
|
||||
string Mode,
|
||||
bool IsAuthorized,
|
||||
string? LoginStage,
|
||||
string Status,
|
||||
string? Url,
|
||||
string? Title,
|
||||
string? LastError,
|
||||
DateTimeOffset UpdatedAt);
|
||||
|
||||
public sealed record MaxBrowserSnapshotDto(
|
||||
string? Url,
|
||||
string? Title,
|
||||
string BodyText,
|
||||
string ScreenshotPngBase64,
|
||||
DateTimeOffset CapturedAt);
|
||||
@@ -0,0 +1,481 @@
|
||||
using System.Text;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Options;
|
||||
using QMax.Api.Configuration;
|
||||
using QMax.Api.Data;
|
||||
using QMax.Api.Infrastructure.Max;
|
||||
using QMax.Api.Infrastructure.Storage;
|
||||
|
||||
namespace QMax.Api.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[AllowAnonymous]
|
||||
[Route("admin")]
|
||||
public sealed class AdminController(
|
||||
QMaxDbContext db,
|
||||
IOptions<QMaxOptions> options,
|
||||
IAttachmentStorageService storage,
|
||||
IMaxBridgeClient maxBridge) : ControllerBase
|
||||
{
|
||||
private readonly QMaxOptions _options = options.Value;
|
||||
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> Index([FromQuery] string? pairingCode, CancellationToken cancellationToken)
|
||||
{
|
||||
if (!IsPairingCodeValid(pairingCode))
|
||||
{
|
||||
return Content(AdminShell("<h1>QMAX Admin</h1><p>Open this page with <code>?pairingCode=...</code>.</p>"), "text/html", Encoding.UTF8);
|
||||
}
|
||||
|
||||
var safePairingCode = pairingCode ?? "";
|
||||
var status = await BuildStatusAsync(cancellationToken);
|
||||
var encodedPairingCode = Uri.EscapeDataString(safePairingCode);
|
||||
var latestChats = (await db.Chats.AsNoTracking().ToArrayAsync(cancellationToken))
|
||||
.OrderByDescending(x => x.LastMessageAt ?? x.UpdatedAt)
|
||||
.Take(20)
|
||||
.Select(x => new
|
||||
{
|
||||
x.Id,
|
||||
x.Title,
|
||||
x.Kind,
|
||||
x.UnreadCount,
|
||||
LastMessageAt = x.LastMessageAt ?? x.UpdatedAt,
|
||||
x.LastMessagePreview
|
||||
})
|
||||
.ToArray();
|
||||
var latestAttachments = (await db.MessageAttachments
|
||||
.AsNoTracking()
|
||||
.Include(x => x.Message)
|
||||
.ThenInclude(x => x!.Chat)
|
||||
.ToArrayAsync(cancellationToken))
|
||||
.OrderByDescending(x => x.CreatedAt)
|
||||
.Take(20)
|
||||
.Select(x => new
|
||||
{
|
||||
x.Id,
|
||||
ChatTitle = x.Message!.Chat!.Title,
|
||||
x.OriginalFileName,
|
||||
x.Kind,
|
||||
x.ContentType,
|
||||
x.FileSizeBytes,
|
||||
x.CreatedAt
|
||||
})
|
||||
.ToArray();
|
||||
var latestUsers = (await db.Users
|
||||
.AsNoTracking()
|
||||
.Include(x => x.Sessions)
|
||||
.ToArrayAsync(cancellationToken))
|
||||
.OrderByDescending(x => x.UpdatedAt)
|
||||
.Take(20)
|
||||
.Select(x => new
|
||||
{
|
||||
x.Id,
|
||||
x.DisplayName,
|
||||
x.PhoneNumber,
|
||||
x.CreatedAt,
|
||||
x.UpdatedAt,
|
||||
Sessions = x.Sessions.Count,
|
||||
ActiveSessions = x.Sessions.Count(session => session.RevokedAt == null && session.ExpiresAt > DateTimeOffset.UtcNow)
|
||||
})
|
||||
.ToArray();
|
||||
|
||||
var rows = string.Join("", latestChats.Select(chat => $$"""
|
||||
<tr>
|
||||
<td>{{Html(chat.Title)}}</td>
|
||||
<td>{{Html(chat.Kind.ToString())}}</td>
|
||||
<td>{{chat.UnreadCount}}</td>
|
||||
<td>{{Html(chat.LastMessageAt.ToLocalTime().ToString("yyyy-MM-dd HH:mm"))}}</td>
|
||||
<td>{{Html(chat.LastMessagePreview)}}</td>
|
||||
</tr>
|
||||
"""));
|
||||
var attachmentRows = string.Join("", latestAttachments.Select(attachment => $$"""
|
||||
<tr>
|
||||
<td><a href="/admin/api/attachments/{{attachment.Id}}/content?pairingCode={{encodedPairingCode}}">{{Html(attachment.OriginalFileName)}}</a></td>
|
||||
<td>{{Html(attachment.ChatTitle)}}</td>
|
||||
<td>{{Html(attachment.Kind.ToString())}}</td>
|
||||
<td>{{Html(attachment.ContentType)}}</td>
|
||||
<td>{{FormatBytes(attachment.FileSizeBytes)}}</td>
|
||||
<td>{{Html(attachment.CreatedAt.ToLocalTime().ToString("yyyy-MM-dd HH:mm"))}}</td>
|
||||
</tr>
|
||||
"""));
|
||||
var userRows = string.Join("", latestUsers.Select(user => $$"""
|
||||
<tr>
|
||||
<td>{{Html(user.DisplayName)}}</td>
|
||||
<td>{{Html(user.PhoneNumber)}}</td>
|
||||
<td>{{user.ActiveSessions}} / {{user.Sessions}}</td>
|
||||
<td>{{Html(user.CreatedAt.ToLocalTime().ToString("yyyy-MM-dd HH:mm"))}}</td>
|
||||
<td>{{Html(user.UpdatedAt.ToLocalTime().ToString("yyyy-MM-dd HH:mm"))}}</td>
|
||||
<td><code>{{user.Id}}</code></td>
|
||||
</tr>
|
||||
"""));
|
||||
|
||||
var html = $$"""
|
||||
<h1>QMAX Admin</h1>
|
||||
<nav>
|
||||
<a href="/admin?pairingCode={{encodedPairingCode}}">Dashboard</a>
|
||||
<a href="/admin/max?pairingCode={{encodedPairingCode}}">MAX Browser</a>
|
||||
<a href="/admin/api/status?pairingCode={{encodedPairingCode}}">Status JSON</a>
|
||||
<a href="/admin/api/chats?pairingCode={{encodedPairingCode}}">Chats JSON</a>
|
||||
<a href="/admin/api/messages?pairingCode={{encodedPairingCode}}">Messages JSON</a>
|
||||
<a href="/admin/api/attachments?pairingCode={{encodedPairingCode}}">Attachments JSON</a>
|
||||
<a href="/admin/api/users?pairingCode={{encodedPairingCode}}">Users JSON</a>
|
||||
</nav>
|
||||
<section class="metrics">
|
||||
<div><b>{{status.Users}}</b><span>Users</span></div>
|
||||
<div><b>{{status.Chats}}</b><span>Chats</span></div>
|
||||
<div><b>{{status.Messages}}</b><span>Messages</span></div>
|
||||
<div><b>{{status.Attachments}}</b><span>Attachments</span></div>
|
||||
<div><b>{{FormatBytes(status.AttachmentBytes)}}</b><span>Media storage</span></div>
|
||||
<div><b>{{Html(status.MaxStatus)}}</b><span>MAX</span></div>
|
||||
<div><b>{{status.PushDevices}}</b><span>Push devices</span></div>
|
||||
</section>
|
||||
<section>
|
||||
<h2>Latest chats</h2>
|
||||
<table>
|
||||
<thead><tr><th>Title</th><th>Kind</th><th>Unread</th><th>Updated</th><th>Preview</th></tr></thead>
|
||||
<tbody>{{rows}}</tbody>
|
||||
</table>
|
||||
</section>
|
||||
<section>
|
||||
<h2>Users</h2>
|
||||
<table>
|
||||
<thead><tr><th>Name</th><th>Phone</th><th>Sessions</th><th>Created</th><th>Updated</th><th>Id</th></tr></thead>
|
||||
<tbody>{{userRows}}</tbody>
|
||||
</table>
|
||||
</section>
|
||||
<section>
|
||||
<h2>Latest attachments</h2>
|
||||
<table>
|
||||
<thead><tr><th>File</th><th>Chat</th><th>Kind</th><th>Content type</th><th>Size</th><th>Created</th></tr></thead>
|
||||
<tbody>{{attachmentRows}}</tbody>
|
||||
</table>
|
||||
</section>
|
||||
""";
|
||||
|
||||
return Content(AdminShell(html), "text/html", Encoding.UTF8);
|
||||
}
|
||||
|
||||
[HttpGet("api/status")]
|
||||
public async Task<IActionResult> Status([FromQuery] string? pairingCode, CancellationToken cancellationToken)
|
||||
{
|
||||
if (!IsPairingCodeValid(pairingCode)) return Unauthorized();
|
||||
return Ok(await BuildStatusAsync(cancellationToken));
|
||||
}
|
||||
|
||||
[HttpGet("api/chats")]
|
||||
public async Task<IActionResult> Chats([FromQuery] string? pairingCode, [FromQuery] string? q, [FromQuery] int take = 100, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!IsPairingCodeValid(pairingCode)) return Unauthorized();
|
||||
|
||||
var query = db.Chats.AsNoTracking();
|
||||
if (!string.IsNullOrWhiteSpace(q))
|
||||
{
|
||||
query = query.Where(x => x.Title.Contains(q) || (x.LastMessagePreview != null && x.LastMessagePreview.Contains(q)));
|
||||
}
|
||||
|
||||
var chats = (await query
|
||||
.ToArrayAsync(cancellationToken))
|
||||
.OrderByDescending(x => x.LastMessageAt ?? x.UpdatedAt)
|
||||
.Take(Math.Clamp(take, 1, 500))
|
||||
.Select(x => new
|
||||
{
|
||||
x.Id,
|
||||
x.ExternalId,
|
||||
x.Kind,
|
||||
x.Title,
|
||||
x.UnreadCount,
|
||||
x.IsPinned,
|
||||
x.IsMuted,
|
||||
x.IsArchived,
|
||||
x.LastMessageAt,
|
||||
x.LastMessagePreview,
|
||||
x.UpdatedAt
|
||||
})
|
||||
.ToArray();
|
||||
|
||||
return Ok(chats);
|
||||
}
|
||||
|
||||
[HttpGet("api/messages")]
|
||||
public async Task<IActionResult> Messages([FromQuery] string? pairingCode, [FromQuery] Guid? chatId, [FromQuery] string? q, [FromQuery] int take = 100, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!IsPairingCodeValid(pairingCode)) return Unauthorized();
|
||||
|
||||
var query = db.Messages.AsNoTracking().Include(x => x.Chat).Include(x => x.Attachments).Where(x => x.DeletedAt == null);
|
||||
if (chatId is not null)
|
||||
{
|
||||
query = query.Where(x => x.ChatId == chatId);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(q))
|
||||
{
|
||||
query = query.Where(x => x.Text != null && x.Text.Contains(q));
|
||||
}
|
||||
|
||||
var messages = (await query
|
||||
.ToArrayAsync(cancellationToken))
|
||||
.OrderByDescending(x => x.SentAt)
|
||||
.Take(Math.Clamp(take, 1, 500))
|
||||
.Select(x => new
|
||||
{
|
||||
x.Id,
|
||||
x.ChatId,
|
||||
ChatTitle = x.Chat!.Title,
|
||||
x.Direction,
|
||||
x.SenderName,
|
||||
x.Text,
|
||||
x.SentAt,
|
||||
x.DeliveryState,
|
||||
x.Error,
|
||||
Attachments = x.Attachments.Count
|
||||
})
|
||||
.ToArray();
|
||||
|
||||
return Ok(messages);
|
||||
}
|
||||
|
||||
[HttpGet("api/users")]
|
||||
public async Task<IActionResult> Users([FromQuery] string? pairingCode, [FromQuery] int take = 100, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!IsPairingCodeValid(pairingCode)) return Unauthorized();
|
||||
|
||||
var users = (await db.Users
|
||||
.AsNoTracking()
|
||||
.Include(x => x.Sessions)
|
||||
.ToArrayAsync(cancellationToken))
|
||||
.OrderByDescending(x => x.UpdatedAt)
|
||||
.Take(Math.Clamp(take, 1, 500))
|
||||
.Select(x => new
|
||||
{
|
||||
x.Id,
|
||||
x.DisplayName,
|
||||
x.PhoneNumber,
|
||||
x.AvatarPath,
|
||||
x.CreatedAt,
|
||||
x.UpdatedAt,
|
||||
Sessions = x.Sessions
|
||||
.OrderByDescending(session => session.LastSeenAt)
|
||||
.Select(session => new
|
||||
{
|
||||
session.Id,
|
||||
session.DeviceName,
|
||||
session.CreatedAt,
|
||||
session.LastSeenAt,
|
||||
session.ExpiresAt,
|
||||
IsActive = session.RevokedAt == null && session.ExpiresAt > DateTimeOffset.UtcNow,
|
||||
session.RevokedAt
|
||||
})
|
||||
.ToArray()
|
||||
})
|
||||
.ToArray();
|
||||
|
||||
return Ok(users);
|
||||
}
|
||||
|
||||
[HttpGet("api/attachments")]
|
||||
public async Task<IActionResult> Attachments([FromQuery] string? pairingCode, [FromQuery] Guid? chatId, [FromQuery] int take = 100, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!IsPairingCodeValid(pairingCode)) return Unauthorized();
|
||||
|
||||
var query = db.MessageAttachments.AsNoTracking().Include(x => x.Message).ThenInclude(x => x!.Chat).AsQueryable();
|
||||
if (chatId is not null)
|
||||
{
|
||||
query = query.Where(x => x.Message!.ChatId == chatId);
|
||||
}
|
||||
|
||||
var attachments = (await query
|
||||
.ToArrayAsync(cancellationToken))
|
||||
.OrderByDescending(x => x.CreatedAt)
|
||||
.Take(Math.Clamp(take, 1, 500))
|
||||
.Select(x => new
|
||||
{
|
||||
x.Id,
|
||||
x.MessageId,
|
||||
ChatId = x.Message!.ChatId,
|
||||
ChatTitle = x.Message.Chat!.Title,
|
||||
FileName = x.OriginalFileName,
|
||||
x.ContentType,
|
||||
x.FileSizeBytes,
|
||||
x.Kind,
|
||||
x.RemoteUrl,
|
||||
x.CreatedAt,
|
||||
ContentPath = $"/admin/api/attachments/{x.Id}/content"
|
||||
})
|
||||
.ToArray();
|
||||
|
||||
return Ok(attachments);
|
||||
}
|
||||
|
||||
[HttpGet("api/attachments/{id:guid}/content")]
|
||||
public async Task<IActionResult> AttachmentContent(Guid id, [FromQuery] string? pairingCode, CancellationToken cancellationToken)
|
||||
{
|
||||
if (!IsPairingCodeValid(pairingCode)) return Unauthorized();
|
||||
|
||||
var attachment = await db.MessageAttachments.AsNoTracking().FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
|
||||
if (attachment is null)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
var path = storage.GetPath(attachment.StorageFileName);
|
||||
return System.IO.File.Exists(path)
|
||||
? PhysicalFile(path, attachment.ContentType, attachment.OriginalFileName, enableRangeProcessing: true)
|
||||
: NotFound();
|
||||
}
|
||||
|
||||
[HttpDelete("api/attachments/{id:guid}")]
|
||||
public async Task<IActionResult> DeleteAttachment(Guid id, [FromQuery] string? pairingCode, CancellationToken cancellationToken)
|
||||
{
|
||||
if (!IsPairingCodeValid(pairingCode)) return Unauthorized();
|
||||
|
||||
var attachment = await db.MessageAttachments.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
|
||||
if (attachment is null)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
var path = storage.GetPath(attachment.StorageFileName);
|
||||
db.MessageAttachments.Remove(attachment);
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
if (System.IO.File.Exists(path))
|
||||
{
|
||||
System.IO.File.Delete(path);
|
||||
}
|
||||
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
[HttpDelete("api/users/{id:guid}")]
|
||||
public async Task<IActionResult> DeleteUser(Guid id, [FromQuery] string? pairingCode, CancellationToken cancellationToken)
|
||||
{
|
||||
if (!IsPairingCodeValid(pairingCode)) return Unauthorized();
|
||||
|
||||
var user = await db.Users.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
|
||||
if (user is null)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
await db.UserSessions.Where(x => x.UserId == id).ExecuteDeleteAsync(cancellationToken);
|
||||
await db.PushDevices.Where(x => x.UserId == id).ExecuteDeleteAsync(cancellationToken);
|
||||
db.Users.Remove(user);
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
[HttpDelete("api/chats/{id:guid}")]
|
||||
public async Task<IActionResult> DeleteChat(Guid id, [FromQuery] string? pairingCode, CancellationToken cancellationToken)
|
||||
{
|
||||
if (!IsPairingCodeValid(pairingCode)) return Unauthorized();
|
||||
|
||||
var chat = await db.Chats
|
||||
.Include(x => x.Messages)
|
||||
.ThenInclude(x => x.Attachments)
|
||||
.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
|
||||
if (chat is null)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
var files = chat.Messages
|
||||
.SelectMany(x => x.Attachments)
|
||||
.Select(x => storage.GetPath(x.StorageFileName))
|
||||
.Where(System.IO.File.Exists)
|
||||
.ToArray();
|
||||
|
||||
db.Chats.Remove(chat);
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
foreach (var file in files)
|
||||
{
|
||||
System.IO.File.Delete(file);
|
||||
}
|
||||
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
private async Task<AdminStatusDto> BuildStatusAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var maxStatus = await maxBridge.GetStatusAsync(cancellationToken);
|
||||
return new AdminStatusDto(
|
||||
"QMAX Bridge",
|
||||
DateTimeOffset.UtcNow,
|
||||
await db.Users.CountAsync(cancellationToken),
|
||||
await db.Chats.CountAsync(cancellationToken),
|
||||
await db.Messages.CountAsync(cancellationToken),
|
||||
await db.MessageAttachments.CountAsync(cancellationToken),
|
||||
await db.MessageAttachments.Select(x => (long?)x.FileSizeBytes).SumAsync(cancellationToken) ?? 0,
|
||||
await db.PushDevices.CountAsync(cancellationToken),
|
||||
maxStatus.IsAuthorized,
|
||||
maxStatus.Status,
|
||||
maxStatus.LastError);
|
||||
}
|
||||
|
||||
private bool IsPairingCodeValid(string? pairingCode)
|
||||
{
|
||||
return !string.IsNullOrWhiteSpace(_options.PairingCode) &&
|
||||
string.Equals(pairingCode, _options.PairingCode, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
private static string AdminShell(string body)
|
||||
{
|
||||
return $$"""
|
||||
<!doctype html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>QMAX Admin</title>
|
||||
<style>
|
||||
body { margin: 0; font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; background: #f3f7fa; color: #17212b; }
|
||||
main { max-width: 1320px; margin: 0 auto; padding: 18px; }
|
||||
h1 { margin: 0 0 14px; font-size: 28px; }
|
||||
h2 { margin-top: 26px; font-size: 18px; }
|
||||
nav { display: flex; flex-wrap: wrap; gap: 10px; margin-bottom: 16px; }
|
||||
nav a { color: #2578c4; text-decoration: none; font-weight: 600; }
|
||||
.metrics { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 10px; margin: 16px 0; }
|
||||
.metrics div { background: white; border: 1px solid #dde7ee; border-radius: 8px; padding: 12px; }
|
||||
.metrics b { display: block; font-size: 24px; }
|
||||
.metrics span { color: #6c7883; font-size: 13px; }
|
||||
table { width: 100%; border-collapse: collapse; background: white; border: 1px solid #dde7ee; border-radius: 8px; overflow: hidden; }
|
||||
th, td { padding: 9px 10px; border-bottom: 1px solid #edf2f5; text-align: left; vertical-align: top; }
|
||||
th { color: #6c7883; font-size: 12px; text-transform: uppercase; letter-spacing: .02em; }
|
||||
tr:last-child td { border-bottom: 0; }
|
||||
code { background: #e7eef3; padding: 2px 5px; border-radius: 5px; }
|
||||
</style>
|
||||
</head>
|
||||
<body><main>{{body}}</main></body>
|
||||
</html>
|
||||
""";
|
||||
}
|
||||
|
||||
private static string Html(string? value)
|
||||
{
|
||||
return System.Net.WebUtility.HtmlEncode(value ?? "");
|
||||
}
|
||||
|
||||
private static string FormatBytes(long bytes)
|
||||
{
|
||||
if (bytes < 1024) return $"{bytes} B";
|
||||
var kb = bytes / 1024.0;
|
||||
if (kb < 1024) return $"{kb:0.0} KB";
|
||||
var mb = kb / 1024.0;
|
||||
if (mb < 1024) return $"{mb:0.0} MB";
|
||||
return $"{mb / 1024.0:0.0} GB";
|
||||
}
|
||||
|
||||
private sealed record AdminStatusDto(
|
||||
string Name,
|
||||
DateTimeOffset ServerTime,
|
||||
int Users,
|
||||
int Chats,
|
||||
int Messages,
|
||||
int Attachments,
|
||||
long AttachmentBytes,
|
||||
int PushDevices,
|
||||
bool MaxAuthorized,
|
||||
string MaxStatus,
|
||||
string? MaxLastError);
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
using System.Net.Http.Json;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.Options;
|
||||
using QMax.Api.Configuration;
|
||||
using QMax.Api.Infrastructure.Max;
|
||||
|
||||
namespace QMax.Api.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[AllowAnonymous]
|
||||
[Route("admin/max")]
|
||||
public sealed class AdminMaxController(
|
||||
IHttpClientFactory httpClientFactory,
|
||||
IOptions<QMaxOptions> options) : ControllerBase
|
||||
{
|
||||
private readonly QMaxOptions _options = options.Value;
|
||||
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> Index([FromQuery] string pairingCode, CancellationToken cancellationToken)
|
||||
{
|
||||
if (!IsPairingCodeValid(pairingCode))
|
||||
{
|
||||
return Content(AdminShell("<h1>QMAX MAX Admin</h1><p>Open this page with <code>?pairingCode=...</code>.</p>"), "text/html", Encoding.UTF8);
|
||||
}
|
||||
|
||||
var snapshot = await WorkerGetAsync<MaxBrowserSnapshot>("snapshot", cancellationToken);
|
||||
var status = await WorkerGetAsync<MaxBridgeStatus>("status", cancellationToken);
|
||||
var screenshot = string.IsNullOrWhiteSpace(snapshot?.ScreenshotPngBase64)
|
||||
? "<p>No screenshot yet.</p>"
|
||||
: $"<img src=\"data:image/png;base64,{snapshot.ScreenshotPngBase64}\" />";
|
||||
var encodedPairingCode = Uri.EscapeDataString(pairingCode);
|
||||
var jsAuthorized = status?.IsAuthorized == true ? "true" : "false";
|
||||
var jsStage = JsonSerializer.Serialize(status?.LoginStage ?? "");
|
||||
|
||||
var html = $$"""
|
||||
<h1>QMAX MAX Admin</h1>
|
||||
<p><b>Status:</b> {{Html(status?.Status)}} | <b>Stage:</b> {{Html(status?.LoginStage)}} | <b>Authorized:</b> {{status?.IsAuthorized}} | <b>URL:</b> {{Html(snapshot?.Url)}}</p>
|
||||
<form method="post" action="/admin/max/login/start">
|
||||
<input type="hidden" name="pairingCode" value="{{Html(pairingCode)}}" />
|
||||
<button type="submit">Start phone login</button>
|
||||
</form>
|
||||
<form method="post" action="/admin/max/login/code">
|
||||
<input type="hidden" name="pairingCode" value="{{Html(pairingCode)}}" />
|
||||
<input name="code" placeholder="MAX code" autocomplete="one-time-code" inputmode="numeric" />
|
||||
<button type="submit">Submit code</button>
|
||||
</form>
|
||||
<form method="post" action="/admin/max/click">
|
||||
<input type="hidden" name="pairingCode" value="{{Html(pairingCode)}}" />
|
||||
<input name="x" placeholder="x" />
|
||||
<input name="y" placeholder="y" />
|
||||
<button type="submit">Click</button>
|
||||
</form>
|
||||
<form method="post" action="/admin/max/type">
|
||||
<input type="hidden" name="pairingCode" value="{{Html(pairingCode)}}" />
|
||||
<input name="text" placeholder="text" />
|
||||
<button type="submit">Type</button>
|
||||
</form>
|
||||
<form method="post" action="/admin/max/press">
|
||||
<input type="hidden" name="pairingCode" value="{{Html(pairingCode)}}" />
|
||||
<input name="key" placeholder="Enter" />
|
||||
<button type="submit">Press key</button>
|
||||
</form>
|
||||
<p class="links">
|
||||
<a href="/admin/max?pairingCode={{encodedPairingCode}}">Refresh</a>
|
||||
<a href="/admin/max/inspect/dom?pairingCode={{encodedPairingCode}}">DOM</a>
|
||||
<a href="/admin/max/inspect/storage?pairingCode={{encodedPairingCode}}">Storage</a>
|
||||
<a href="/admin/max/inspect/indexeddb-sample?pairingCode={{encodedPairingCode}}">IDB samples</a>
|
||||
<a href="/admin/max/inspect/network?pairingCode={{encodedPairingCode}}">Network</a>
|
||||
<a href="/admin/max/updates?pairingCode={{encodedPairingCode}}">Updates</a>
|
||||
</p>
|
||||
<p class="hint">Click directly on the screenshot to control the remote MAX browser. QMAX will forward the click and refresh the image.</p>
|
||||
<div class="screen">{{screenshot}}</div>
|
||||
<script>
|
||||
const qmaxPairingCode = "{{Html(pairingCode)}}";
|
||||
const qmaxIsAuthorized = {{jsAuthorized}};
|
||||
const qmaxStage = {{jsStage}};
|
||||
const qmaxScreen = document.querySelector(".screen img");
|
||||
if (qmaxScreen) {
|
||||
qmaxScreen.addEventListener("click", async (event) => {
|
||||
const rect = qmaxScreen.getBoundingClientRect();
|
||||
const x = Math.round((event.clientX - rect.left) * qmaxScreen.naturalWidth / rect.width);
|
||||
const y = Math.round((event.clientY - rect.top) * qmaxScreen.naturalHeight / rect.height);
|
||||
const body = new URLSearchParams({ pairingCode: qmaxPairingCode, x: String(x), y: String(y) });
|
||||
await fetch("/admin/max/click", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
body
|
||||
});
|
||||
window.setTimeout(() => window.location.reload(), 1200);
|
||||
});
|
||||
}
|
||||
const qmaxIsEditing = () => {
|
||||
const active = document.activeElement;
|
||||
return active && ["INPUT", "TEXTAREA", "SELECT"].includes(active.tagName);
|
||||
};
|
||||
if (!qmaxIsAuthorized) {
|
||||
const intervalMs = qmaxStage === "CodeEntry" ? 3000 : 5000;
|
||||
window.setInterval(() => {
|
||||
if (!document.hidden && !qmaxIsEditing()) {
|
||||
window.location.reload();
|
||||
}
|
||||
}, intervalMs);
|
||||
}
|
||||
</script>
|
||||
""";
|
||||
|
||||
return Content(AdminShell(html), "text/html", Encoding.UTF8);
|
||||
}
|
||||
|
||||
[HttpGet("inspect/{kind}")]
|
||||
public async Task<IActionResult> Inspect([FromRoute] string kind, [FromQuery] string pairingCode, CancellationToken cancellationToken)
|
||||
{
|
||||
if (!IsPairingCodeValid(pairingCode)) return Unauthorized();
|
||||
|
||||
var workerPath = kind.ToLowerInvariant() switch
|
||||
{
|
||||
"dom" => "inspect/dom",
|
||||
"storage" => "inspect/storage",
|
||||
"indexeddb-sample" => "inspect/indexeddb/sample",
|
||||
"network" => "inspect/network",
|
||||
_ => null
|
||||
};
|
||||
if (workerPath is null) return NotFound();
|
||||
|
||||
return Content(await WorkerGetRawAsync(workerPath, cancellationToken), "application/json", Encoding.UTF8);
|
||||
}
|
||||
|
||||
[HttpGet("updates")]
|
||||
public async Task<IActionResult> Updates([FromQuery] string pairingCode, CancellationToken cancellationToken)
|
||||
{
|
||||
if (!IsPairingCodeValid(pairingCode)) return Unauthorized();
|
||||
return Content(await WorkerGetRawAsync("updates", cancellationToken), "application/json", Encoding.UTF8);
|
||||
}
|
||||
|
||||
[HttpPost("login/start")]
|
||||
public async Task<IActionResult> StartLogin([FromForm] string pairingCode, CancellationToken cancellationToken)
|
||||
{
|
||||
if (!IsPairingCodeValid(pairingCode)) return Unauthorized();
|
||||
await WorkerPostAsync("login/start", new { phoneNumber = _options.MaxPhoneNumber }, cancellationToken);
|
||||
return Redirect($"/admin/max?pairingCode={Uri.EscapeDataString(pairingCode)}");
|
||||
}
|
||||
|
||||
[HttpPost("login/code")]
|
||||
public async Task<IActionResult> Code([FromForm] string pairingCode, [FromForm] string code, CancellationToken cancellationToken)
|
||||
{
|
||||
if (!IsPairingCodeValid(pairingCode)) return Unauthorized();
|
||||
await WorkerPostAsync("login/code", new { code, waitMs = 60000 }, cancellationToken);
|
||||
return Redirect($"/admin/max?pairingCode={Uri.EscapeDataString(pairingCode)}");
|
||||
}
|
||||
|
||||
[HttpPost("click")]
|
||||
public async Task<IActionResult> Click([FromForm] string pairingCode, [FromForm] int x, [FromForm] int y, CancellationToken cancellationToken)
|
||||
{
|
||||
if (!IsPairingCodeValid(pairingCode)) return Unauthorized();
|
||||
await WorkerPostAsync("browser/click", new { x, y }, cancellationToken);
|
||||
return Redirect($"/admin/max?pairingCode={Uri.EscapeDataString(pairingCode)}");
|
||||
}
|
||||
|
||||
[HttpPost("type")]
|
||||
public async Task<IActionResult> Type([FromForm] string pairingCode, [FromForm] string text, CancellationToken cancellationToken)
|
||||
{
|
||||
if (!IsPairingCodeValid(pairingCode)) return Unauthorized();
|
||||
await WorkerPostAsync("browser/type", new { text }, cancellationToken);
|
||||
return Redirect($"/admin/max?pairingCode={Uri.EscapeDataString(pairingCode)}");
|
||||
}
|
||||
|
||||
[HttpPost("press")]
|
||||
public async Task<IActionResult> Press([FromForm] string pairingCode, [FromForm] string key, CancellationToken cancellationToken)
|
||||
{
|
||||
if (!IsPairingCodeValid(pairingCode)) return Unauthorized();
|
||||
await WorkerPostAsync("browser/press", new { key = string.IsNullOrWhiteSpace(key) ? "Enter" : key }, cancellationToken);
|
||||
return Redirect($"/admin/max?pairingCode={Uri.EscapeDataString(pairingCode)}");
|
||||
}
|
||||
|
||||
private async Task<T?> WorkerGetAsync<T>(string path, CancellationToken cancellationToken)
|
||||
{
|
||||
var client = httpClientFactory.CreateClient();
|
||||
client.BaseAddress = new Uri(_options.MaxWorkerBaseUrl.TrimEnd('/') + "/");
|
||||
return await client.GetFromJsonAsync<T>(path, cancellationToken);
|
||||
}
|
||||
|
||||
private async Task<string> WorkerGetRawAsync(string path, CancellationToken cancellationToken)
|
||||
{
|
||||
var client = httpClientFactory.CreateClient();
|
||||
client.BaseAddress = new Uri(_options.MaxWorkerBaseUrl.TrimEnd('/') + "/");
|
||||
return await client.GetStringAsync(path, cancellationToken);
|
||||
}
|
||||
|
||||
private async Task WorkerPostAsync(string path, object body, CancellationToken cancellationToken)
|
||||
{
|
||||
var client = httpClientFactory.CreateClient();
|
||||
client.BaseAddress = new Uri(_options.MaxWorkerBaseUrl.TrimEnd('/') + "/");
|
||||
using var response = await client.PostAsJsonAsync(path, body, cancellationToken);
|
||||
response.EnsureSuccessStatusCode();
|
||||
}
|
||||
|
||||
private bool IsPairingCodeValid(string pairingCode)
|
||||
{
|
||||
return !string.IsNullOrWhiteSpace(_options.PairingCode) &&
|
||||
string.Equals(pairingCode, _options.PairingCode, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
private static string AdminShell(string body)
|
||||
{
|
||||
return $$"""
|
||||
<!doctype html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>QMAX MAX Admin</title>
|
||||
<style>
|
||||
body { margin: 0; font-family: system-ui, sans-serif; background: #f3f7fa; color: #17212b; }
|
||||
main { max-width: 1320px; margin: 0 auto; padding: 16px; }
|
||||
form { display: inline-flex; gap: 8px; margin: 4px; align-items: center; }
|
||||
input { padding: 8px 10px; border: 1px solid #ccd6dd; border-radius: 6px; }
|
||||
button { padding: 8px 12px; border: 0; border-radius: 6px; color: white; background: #3390ec; }
|
||||
.links { display: flex; flex-wrap: wrap; gap: 12px; }
|
||||
.hint { color: #5f7080; }
|
||||
.screen { margin-top: 16px; overflow: auto; background: #111; }
|
||||
img { max-width: 100%; display: block; cursor: crosshair; }
|
||||
</style>
|
||||
</head>
|
||||
<body><main>{{body}}</main></body>
|
||||
</html>
|
||||
""";
|
||||
}
|
||||
|
||||
private static string Html(string? value)
|
||||
{
|
||||
return System.Net.WebUtility.HtmlEncode(value ?? "");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
using System.Security.Cryptography;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.Options;
|
||||
using QMax.Api.Configuration;
|
||||
using QMax.Api.Contracts;
|
||||
|
||||
namespace QMax.Api.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/app-updates")]
|
||||
public sealed class AppUpdatesController(IOptions<QMaxOptions> options) : ControllerBase
|
||||
{
|
||||
private readonly QMaxOptions _options = options.Value;
|
||||
|
||||
[AllowAnonymous]
|
||||
[HttpGet("android/latest")]
|
||||
public async Task<ActionResult<AppUpdateManifestDto>> Latest(CancellationToken cancellationToken)
|
||||
{
|
||||
var manifestPath = Path.Combine(_options.ReleasesPath, "android", "latest.json");
|
||||
if (System.IO.File.Exists(manifestPath))
|
||||
{
|
||||
return PhysicalFile(manifestPath, "application/json");
|
||||
}
|
||||
|
||||
var androidReleases = new DirectoryInfo(Path.Combine(_options.ReleasesPath, "android"));
|
||||
if (!androidReleases.Exists)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
var apk = androidReleases
|
||||
.GetFiles("*.apk")
|
||||
.OrderByDescending(x => x.LastWriteTimeUtc)
|
||||
.FirstOrDefault();
|
||||
|
||||
if (apk is null)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
var manifest = new AppUpdateManifestDto(
|
||||
"qmax",
|
||||
"QMAX",
|
||||
"0.1.0",
|
||||
1,
|
||||
"stable",
|
||||
"android",
|
||||
"apk",
|
||||
$"/api/app-updates/android/download/{Uri.EscapeDataString(apk.Name)}",
|
||||
apk.Length,
|
||||
await Sha256Async(apk.FullName, cancellationToken),
|
||||
"QMAX local release",
|
||||
apk.LastWriteTimeUtc);
|
||||
|
||||
return manifest;
|
||||
}
|
||||
|
||||
[AllowAnonymous]
|
||||
[HttpGet("android/download/{fileName}")]
|
||||
public IActionResult Download(string fileName)
|
||||
{
|
||||
var safeName = Path.GetFileName(fileName);
|
||||
var path = Path.Combine(_options.ReleasesPath, "android", safeName);
|
||||
if (!System.IO.File.Exists(path) || !safeName.EndsWith(".apk", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
return PhysicalFile(path, "application/vnd.android.package-archive", safeName, enableRangeProcessing: true);
|
||||
}
|
||||
|
||||
private static async Task<string> Sha256Async(string path, CancellationToken cancellationToken)
|
||||
{
|
||||
await using var stream = System.IO.File.OpenRead(path);
|
||||
var hash = await SHA256.HashDataAsync(stream, cancellationToken);
|
||||
return Convert.ToHexString(hash).ToLowerInvariant();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Options;
|
||||
using QMax.Api.Configuration;
|
||||
using QMax.Api.Contracts;
|
||||
using QMax.Api.Data;
|
||||
using QMax.Api.Data.Entities;
|
||||
using QMax.Api.Infrastructure.Auth;
|
||||
|
||||
namespace QMax.Api.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/auth")]
|
||||
public sealed class AuthController(
|
||||
QMaxDbContext db,
|
||||
ITokenService tokenService,
|
||||
IOptions<QMaxOptions> options) : ControllerBase
|
||||
{
|
||||
private readonly QMaxOptions _options = options.Value;
|
||||
|
||||
[AllowAnonymous]
|
||||
[HttpPost("device/login")]
|
||||
public async Task<ActionResult<AuthResponse>> Login(DeviceLoginRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
var expected = _options.PairingCode;
|
||||
if (string.IsNullOrWhiteSpace(expected))
|
||||
{
|
||||
return Problem("QMax:PairingCode is not configured on the server.", statusCode: StatusCodes.Status503ServiceUnavailable);
|
||||
}
|
||||
|
||||
if (!FixedTimeEquals(expected, request.PairingCode))
|
||||
{
|
||||
return Unauthorized();
|
||||
}
|
||||
|
||||
var user = await db.Users.FirstOrDefaultAsync(cancellationToken);
|
||||
if (user is null)
|
||||
{
|
||||
user = new User
|
||||
{
|
||||
DisplayName = "QMAX Owner",
|
||||
PhoneNumber = string.IsNullOrWhiteSpace(_options.MaxPhoneNumber) ? null : _options.MaxPhoneNumber
|
||||
};
|
||||
db.Users.Add(user);
|
||||
}
|
||||
|
||||
var refreshToken = tokenService.CreateRefreshToken();
|
||||
var session = new UserSession
|
||||
{
|
||||
User = user,
|
||||
DeviceName = string.IsNullOrWhiteSpace(request.DeviceName) ? "Android" : request.DeviceName.Trim(),
|
||||
RefreshTokenHash = tokenService.HashRefreshToken(refreshToken)
|
||||
};
|
||||
db.UserSessions.Add(session);
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
|
||||
return CreateAuthResponse(user, session, refreshToken);
|
||||
}
|
||||
|
||||
[AllowAnonymous]
|
||||
[HttpPost("refresh")]
|
||||
public async Task<ActionResult<AuthResponse>> Refresh(RefreshTokenRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
var hash = tokenService.HashRefreshToken(request.RefreshToken);
|
||||
var session = await db.UserSessions
|
||||
.Include(x => x.User)
|
||||
.FirstOrDefaultAsync(x => x.RefreshTokenHash == hash, cancellationToken);
|
||||
|
||||
if (session?.User is null || session.RevokedAt is not null || session.ExpiresAt <= DateTimeOffset.UtcNow)
|
||||
{
|
||||
return Unauthorized();
|
||||
}
|
||||
|
||||
session.LastSeenAt = DateTimeOffset.UtcNow;
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
|
||||
return CreateAuthResponse(session.User, session, request.RefreshToken);
|
||||
}
|
||||
|
||||
[Authorize]
|
||||
[HttpPost("logout")]
|
||||
public async Task<IActionResult> Logout(CancellationToken cancellationToken)
|
||||
{
|
||||
var sessionId = User.GetSessionId();
|
||||
if (sessionId is not null)
|
||||
{
|
||||
var session = await db.UserSessions.FindAsync([sessionId.Value], cancellationToken);
|
||||
if (session is not null)
|
||||
{
|
||||
session.RevokedAt = DateTimeOffset.UtcNow;
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
[Authorize]
|
||||
[HttpGet("sessions")]
|
||||
public async Task<ActionResult<IReadOnlyList<SessionDto>>> Sessions(CancellationToken cancellationToken)
|
||||
{
|
||||
var userId = User.GetUserId();
|
||||
var currentSessionId = User.GetSessionId();
|
||||
var sessions = (await db.UserSessions
|
||||
.Where(x => x.UserId == userId && x.RevokedAt == null)
|
||||
.ToArrayAsync(cancellationToken))
|
||||
.OrderByDescending(x => x.LastSeenAt)
|
||||
.Select(x => new SessionDto(x.Id, x.DeviceName, x.CreatedAt, x.LastSeenAt, x.ExpiresAt, x.Id == currentSessionId))
|
||||
.ToArray();
|
||||
|
||||
return sessions;
|
||||
}
|
||||
|
||||
private AuthResponse CreateAuthResponse(User user, UserSession session, string refreshToken)
|
||||
{
|
||||
var expiresAt = DateTimeOffset.UtcNow.AddHours(8);
|
||||
var accessToken = tokenService.CreateAccessToken(user, session, expiresAt);
|
||||
var dto = new UserDto(user.Id, user.DisplayName, user.PhoneNumber, user.AvatarPath);
|
||||
return new AuthResponse(accessToken, refreshToken, expiresAt, dto);
|
||||
}
|
||||
|
||||
private static bool FixedTimeEquals(string expected, string actual)
|
||||
{
|
||||
var expectedBytes = System.Text.Encoding.UTF8.GetBytes(expected);
|
||||
var actualBytes = System.Text.Encoding.UTF8.GetBytes(actual);
|
||||
return expectedBytes.Length == actualBytes.Length &&
|
||||
System.Security.Cryptography.CryptographicOperations.FixedTimeEquals(expectedBytes, actualBytes);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,823 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.SignalR;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using QMax.Api.Contracts;
|
||||
using QMax.Api.Data;
|
||||
using QMax.Api.Data.Entities;
|
||||
using QMax.Api.Infrastructure.Hubs;
|
||||
using QMax.Api.Infrastructure.Max;
|
||||
using QMax.Api.Infrastructure.Storage;
|
||||
using QMax.Api.Services;
|
||||
|
||||
namespace QMax.Api.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Authorize]
|
||||
[Route("api/chats")]
|
||||
public sealed class ChatsController(
|
||||
QMaxDbContext db,
|
||||
ChatProjectionService projection,
|
||||
IMaxBridgeClient maxBridge,
|
||||
IAttachmentStorageService storage,
|
||||
IHubContext<QMaxHub> hubContext,
|
||||
MaxBridgeSyncService syncService) : ControllerBase
|
||||
{
|
||||
[HttpGet]
|
||||
public async Task<ActionResult<IReadOnlyList<ChatDto>>> GetChats(CancellationToken cancellationToken)
|
||||
{
|
||||
var chats = (await db.Chats.ToArrayAsync(cancellationToken))
|
||||
.OrderByDescending(x => x.IsPinned)
|
||||
.ThenByDescending(x => x.UnreadCount > 0)
|
||||
.ThenByDescending(x => x.LastMessageAt ?? x.UpdatedAt)
|
||||
.Take(200)
|
||||
.ToArray();
|
||||
|
||||
return chats.Select(projection.ToDto).ToArray();
|
||||
}
|
||||
|
||||
[HttpPost("direct")]
|
||||
public async Task<ActionResult<ChatDto>> CreateDirect(CreateDirectChatRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
var chat = await db.Chats.FirstOrDefaultAsync(x => x.ExternalId == request.ExternalChatId, cancellationToken);
|
||||
if (chat is null)
|
||||
{
|
||||
chat = new Chat
|
||||
{
|
||||
ExternalId = request.ExternalChatId,
|
||||
Title = request.Title,
|
||||
AvatarUrl = request.AvatarUrl,
|
||||
Kind = ChatKind.MaxDialog
|
||||
};
|
||||
db.Chats.Add(chat);
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
|
||||
await hubContext.Clients.All.SendAsync("ChatListInvalidated", cancellationToken);
|
||||
return projection.ToDto(chat);
|
||||
}
|
||||
|
||||
[HttpGet("{chatId:guid}/messages")]
|
||||
public async Task<ActionResult<IReadOnlyList<MessageDto>>> GetMessages(Guid chatId, int take = 80, DateTimeOffset? before = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var chat = await db.Chats.FirstOrDefaultAsync(x => x.Id == chatId, cancellationToken);
|
||||
if (chat is null)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
if (before is null && !string.IsNullOrWhiteSpace(chat.ExternalId))
|
||||
{
|
||||
await syncService.SyncChatHistoryAsync(chat.ExternalId, cancellationToken);
|
||||
db.ChangeTracker.Clear();
|
||||
chat = await db.Chats.FirstOrDefaultAsync(x => x.Id == chatId, cancellationToken);
|
||||
if (chat is null)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
}
|
||||
|
||||
if (before is null && chat.UnreadCount > 0)
|
||||
{
|
||||
chat.UnreadCount = 0;
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
await hubContext.Clients.All.SendAsync("ChatListInvalidated", cancellationToken);
|
||||
}
|
||||
|
||||
var query = db.Messages
|
||||
.Include(x => x.Attachments)
|
||||
.Include(x => x.Reactions)
|
||||
.Where(x => x.ChatId == chatId && x.DeletedAt == null);
|
||||
|
||||
if (before is not null)
|
||||
{
|
||||
query = query.Where(x => x.SentAt < before);
|
||||
}
|
||||
|
||||
var messages = (await query.ToArrayAsync(cancellationToken))
|
||||
.OrderByDescending(x => x.SentAt)
|
||||
.Take(Math.Clamp(take, 1, 200))
|
||||
.ToArray();
|
||||
|
||||
return messages.OrderBy(x => x.SentAt).Select(projection.ToDto).ToArray();
|
||||
}
|
||||
|
||||
[HttpPost("{chatId:guid}/read")]
|
||||
public async Task<IActionResult> MarkRead(Guid chatId, CancellationToken cancellationToken)
|
||||
{
|
||||
var chat = await db.Chats.FirstOrDefaultAsync(x => x.Id == chatId, cancellationToken);
|
||||
if (chat is null)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
if (chat.UnreadCount > 0)
|
||||
{
|
||||
chat.UnreadCount = 0;
|
||||
chat.UpdatedAt = DateTimeOffset.UtcNow;
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
await hubContext.Clients.All.SendAsync("ChatListInvalidated", cancellationToken);
|
||||
}
|
||||
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
[HttpGet("{chatId:guid}/search")]
|
||||
public async Task<ActionResult<IReadOnlyList<MessageDto>>> SearchMessages(
|
||||
Guid chatId,
|
||||
[FromQuery(Name = "q")] string? query,
|
||||
int take = 50,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var term = query?.Trim();
|
||||
if (string.IsNullOrWhiteSpace(term))
|
||||
{
|
||||
return BadRequest("Search query is required.");
|
||||
}
|
||||
|
||||
var chatExists = await db.Chats.AnyAsync(x => x.Id == chatId, cancellationToken);
|
||||
if (!chatExists)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
var messages = await db.Messages
|
||||
.Include(x => x.Attachments)
|
||||
.Include(x => x.Reactions)
|
||||
.Where(x => x.ChatId == chatId && x.DeletedAt == null)
|
||||
.ToArrayAsync(cancellationToken);
|
||||
|
||||
var results = messages
|
||||
.Where(x => MatchesSearch(x, term))
|
||||
.OrderByDescending(x => x.SentAt)
|
||||
.Take(Math.Clamp(take, 1, 200))
|
||||
.OrderBy(x => x.SentAt)
|
||||
.Select(projection.ToDto)
|
||||
.ToArray();
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
[HttpGet("{chatId:guid}/avatar")]
|
||||
public async Task<IActionResult> DownloadAvatar(Guid chatId, CancellationToken cancellationToken)
|
||||
{
|
||||
var chat = await db.Chats
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(x => x.Id == chatId, cancellationToken);
|
||||
|
||||
if (chat is null || string.IsNullOrWhiteSpace(chat.AvatarUrl))
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
var remote = await maxBridge.DownloadMediaAsync(chat.AvatarUrl, cancellationToken);
|
||||
if (remote is null)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
return File(remote.Stream, remote.ContentType);
|
||||
}
|
||||
|
||||
[HttpGet("{chatId:guid}/presence")]
|
||||
public async Task<ActionResult<ChatPresenceDto>> GetPresence(Guid chatId, CancellationToken cancellationToken)
|
||||
{
|
||||
var chat = await db.Chats
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(x => x.Id == chatId, cancellationToken);
|
||||
|
||||
if (chat is null)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(chat.ExternalId))
|
||||
{
|
||||
return new ChatPresenceDto(false, null, DateTimeOffset.UtcNow);
|
||||
}
|
||||
|
||||
var presence = await maxBridge.FetchChatPresenceAsync(chat.ExternalId, cancellationToken);
|
||||
return new ChatPresenceDto(
|
||||
presence?.IsTyping == true,
|
||||
presence?.StatusText,
|
||||
presence?.UpdatedAt ?? DateTimeOffset.UtcNow);
|
||||
}
|
||||
|
||||
[HttpPost("{chatId:guid}/messages")]
|
||||
public async Task<ActionResult<MessageDto>> SendMessage(Guid chatId, SendMessageRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
var chat = await db.Chats.FindAsync([chatId], cancellationToken);
|
||||
if (chat is null)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
var text = (request.Text ?? "").Trim();
|
||||
if (string.IsNullOrWhiteSpace(text))
|
||||
{
|
||||
return BadRequest("Message text is required.");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(chat.ExternalId))
|
||||
{
|
||||
return BadRequest("Chat is not linked to MAX.");
|
||||
}
|
||||
|
||||
var result = await maxBridge.SendTextAsync(chat.ExternalId, text, cancellationToken);
|
||||
if (!result.Success)
|
||||
{
|
||||
return MaxSendFailure(result, "text message");
|
||||
}
|
||||
|
||||
var message = new Message
|
||||
{
|
||||
ChatId = chat.Id,
|
||||
ExternalId = result.ExternalMessageId,
|
||||
Direction = MessageDirection.Outgoing,
|
||||
Text = text,
|
||||
ReplyToMessageId = request.ReplyToMessageId,
|
||||
SentAt = DateTimeOffset.UtcNow,
|
||||
DeliveryState = MessageDeliveryState.Sent,
|
||||
SenderName = "You"
|
||||
};
|
||||
db.Messages.Add(message);
|
||||
chat.LastMessagePreview = text;
|
||||
chat.LastMessageAt = message.SentAt;
|
||||
chat.UpdatedAt = DateTimeOffset.UtcNow;
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
|
||||
var dto = projection.ToDto(message);
|
||||
await hubContext.Clients.Group($"chat:{chat.Id}").SendAsync("MessageCreated", dto, cancellationToken);
|
||||
await hubContext.Clients.All.SendAsync("ChatListInvalidated", cancellationToken);
|
||||
return dto;
|
||||
}
|
||||
|
||||
[HttpPatch("{chatId:guid}/messages/{messageId:guid}")]
|
||||
public async Task<ActionResult<MessageDto>> EditMessage(
|
||||
Guid chatId,
|
||||
Guid messageId,
|
||||
EditMessageRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var text = request.Text.Trim();
|
||||
if (string.IsNullOrWhiteSpace(text))
|
||||
{
|
||||
return BadRequest("Message text is required.");
|
||||
}
|
||||
|
||||
var message = await db.Messages
|
||||
.Include(x => x.Chat)
|
||||
.Include(x => x.Attachments)
|
||||
.Include(x => x.Reactions)
|
||||
.FirstOrDefaultAsync(x => x.ChatId == chatId && x.Id == messageId && x.DeletedAt == null, cancellationToken);
|
||||
if (message is null)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
if (message.Direction != MessageDirection.Outgoing)
|
||||
{
|
||||
return Forbid();
|
||||
}
|
||||
|
||||
var maxFailure = await TryApplyMaxActionAsync(
|
||||
message,
|
||||
(externalChatId, externalMessageId) => maxBridge.EditMessageAsync(externalChatId, externalMessageId, message.Text, text, cancellationToken),
|
||||
"edit",
|
||||
cancellationToken);
|
||||
if (maxFailure is not null)
|
||||
{
|
||||
return maxFailure;
|
||||
}
|
||||
|
||||
message.Text = text;
|
||||
message.EditedAt = DateTimeOffset.UtcNow;
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
|
||||
if (message.Chat is not null)
|
||||
{
|
||||
await RecalculateChatPreviewAsync(message.Chat, cancellationToken);
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
|
||||
var dto = projection.ToDto(message);
|
||||
await hubContext.Clients.Group($"chat:{chatId}").SendAsync("MessageUpdated", dto, cancellationToken);
|
||||
await hubContext.Clients.All.SendAsync("ChatListInvalidated", cancellationToken);
|
||||
return dto;
|
||||
}
|
||||
|
||||
[HttpDelete("{chatId:guid}/messages/{messageId:guid}")]
|
||||
public async Task<IActionResult> DeleteMessage(Guid chatId, Guid messageId, CancellationToken cancellationToken)
|
||||
{
|
||||
var message = await db.Messages
|
||||
.Include(x => x.Chat)
|
||||
.FirstOrDefaultAsync(x => x.ChatId == chatId && x.Id == messageId && x.DeletedAt == null, cancellationToken);
|
||||
if (message is null)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
var maxFailure = await TryApplyMaxActionAsync(
|
||||
message,
|
||||
(externalChatId, externalMessageId) => maxBridge.DeleteMessageAsync(externalChatId, externalMessageId, message.Text, cancellationToken),
|
||||
"delete",
|
||||
cancellationToken);
|
||||
if (maxFailure is not null)
|
||||
{
|
||||
return maxFailure;
|
||||
}
|
||||
|
||||
var deletedAt = DateTimeOffset.UtcNow;
|
||||
message.DeletedAt = deletedAt;
|
||||
message.EditedAt = message.EditedAt ?? deletedAt;
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
|
||||
if (message.Chat is not null)
|
||||
{
|
||||
await RecalculateChatPreviewAsync(message.Chat, cancellationToken);
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
|
||||
await hubContext.Clients.Group($"chat:{chatId}").SendAsync("MessageDeleted", new MessageDeletedDto(chatId, messageId), cancellationToken);
|
||||
await hubContext.Clients.All.SendAsync("ChatListInvalidated", cancellationToken);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
[HttpPut("{chatId:guid}/messages/{messageId:guid}/reaction")]
|
||||
public async Task<ActionResult<MessageDto>> SetReaction(
|
||||
Guid chatId,
|
||||
Guid messageId,
|
||||
SetReactionRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var emoji = NormalizeReaction(request.Emoji);
|
||||
if (emoji is null)
|
||||
{
|
||||
return BadRequest("Reaction is required.");
|
||||
}
|
||||
|
||||
var message = await db.Messages
|
||||
.Include(x => x.Chat)
|
||||
.Include(x => x.Attachments)
|
||||
.Include(x => x.Reactions)
|
||||
.FirstOrDefaultAsync(x => x.ChatId == chatId && x.Id == messageId && x.DeletedAt == null, cancellationToken);
|
||||
if (message is null)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
var maxFailure = await TryApplyMaxActionAsync(
|
||||
message,
|
||||
(externalChatId, externalMessageId) => maxBridge.SetReactionAsync(externalChatId, externalMessageId, message.Text, emoji, cancellationToken),
|
||||
"reaction",
|
||||
cancellationToken);
|
||||
if (maxFailure is not null)
|
||||
{
|
||||
return maxFailure;
|
||||
}
|
||||
|
||||
var reaction = message.Reactions.FirstOrDefault(x => x.ActorKey == "self");
|
||||
if (reaction is null)
|
||||
{
|
||||
reaction = new MessageReaction
|
||||
{
|
||||
MessageId = message.Id,
|
||||
Emoji = emoji,
|
||||
ActorKey = "self",
|
||||
ActorName = "You"
|
||||
};
|
||||
db.MessageReactions.Add(reaction);
|
||||
}
|
||||
else
|
||||
{
|
||||
reaction.Emoji = emoji;
|
||||
reaction.ActorName = "You";
|
||||
reaction.CreatedAt = DateTimeOffset.UtcNow;
|
||||
}
|
||||
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
var updated = await LoadMessageForProjectionAsync(chatId, messageId, cancellationToken) ?? message;
|
||||
var dto = projection.ToDto(updated);
|
||||
await hubContext.Clients.Group($"chat:{chatId}").SendAsync("MessageUpdated", dto, cancellationToken);
|
||||
return dto;
|
||||
}
|
||||
|
||||
[HttpDelete("{chatId:guid}/messages/{messageId:guid}/reaction")]
|
||||
public async Task<ActionResult<MessageDto>> ClearReaction(Guid chatId, Guid messageId, CancellationToken cancellationToken)
|
||||
{
|
||||
var message = await db.Messages
|
||||
.Include(x => x.Chat)
|
||||
.Include(x => x.Attachments)
|
||||
.Include(x => x.Reactions)
|
||||
.FirstOrDefaultAsync(x => x.ChatId == chatId && x.Id == messageId && x.DeletedAt == null, cancellationToken);
|
||||
if (message is null)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
var reaction = message.Reactions.FirstOrDefault(x => x.ActorKey == "self");
|
||||
if (reaction is not null)
|
||||
{
|
||||
var maxFailure = await TryApplyMaxActionAsync(
|
||||
message,
|
||||
(externalChatId, externalMessageId) => maxBridge.ClearReactionAsync(externalChatId, externalMessageId, message.Text, reaction.Emoji, cancellationToken),
|
||||
"reaction clear",
|
||||
cancellationToken);
|
||||
if (maxFailure is not null)
|
||||
{
|
||||
return maxFailure;
|
||||
}
|
||||
|
||||
message.Reactions.Remove(reaction);
|
||||
db.MessageReactions.Remove(reaction);
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
|
||||
var updated = await LoadMessageForProjectionAsync(chatId, messageId, cancellationToken) ?? message;
|
||||
var dto = projection.ToDto(updated);
|
||||
await hubContext.Clients.Group($"chat:{chatId}").SendAsync("MessageUpdated", dto, cancellationToken);
|
||||
return dto;
|
||||
}
|
||||
|
||||
[HttpPost("{chatId:guid}/messages/{messageId:guid}/forward")]
|
||||
public async Task<ActionResult<MessageDto>> ForwardMessage(
|
||||
Guid chatId,
|
||||
Guid messageId,
|
||||
ForwardMessageRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var source = await db.Messages
|
||||
.Include(x => x.Chat)
|
||||
.Include(x => x.Attachments)
|
||||
.FirstOrDefaultAsync(x => x.ChatId == chatId && x.Id == messageId && x.DeletedAt == null, cancellationToken);
|
||||
if (source is null)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
var target = await db.Chats.FindAsync([request.TargetChatId], cancellationToken);
|
||||
if (target is null)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(target.ExternalId))
|
||||
{
|
||||
return BadRequest("Target chat is not linked to MAX.");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(source.Text) && source.Attachments.Count == 0)
|
||||
{
|
||||
return BadRequest("Cannot forward an empty message.");
|
||||
}
|
||||
|
||||
var message = new Message
|
||||
{
|
||||
ChatId = target.Id,
|
||||
Direction = MessageDirection.Outgoing,
|
||||
Text = source.Text,
|
||||
ForwardedFrom = ResolveForwardedFrom(source),
|
||||
SentAt = DateTimeOffset.UtcNow,
|
||||
DeliveryState = MessageDeliveryState.Sending,
|
||||
SenderName = "You"
|
||||
};
|
||||
|
||||
var sortOrder = 0;
|
||||
foreach (var attachment in source.Attachments.OrderBy(x => x.SortOrder))
|
||||
{
|
||||
StoredAttachment stored;
|
||||
try
|
||||
{
|
||||
stored = await CopyForwardAttachmentAsync(attachment, cancellationToken);
|
||||
}
|
||||
catch (InvalidOperationException error)
|
||||
{
|
||||
return Problem(error.Message, statusCode: StatusCodes.Status409Conflict);
|
||||
}
|
||||
|
||||
message.Attachments.Add(new MessageAttachment
|
||||
{
|
||||
OriginalFileName = stored.OriginalFileName,
|
||||
StorageFileName = stored.StorageFileName,
|
||||
ContentType = stored.ContentType,
|
||||
FileSizeBytes = stored.FileSizeBytes,
|
||||
Sha256 = stored.Sha256,
|
||||
Kind = stored.Kind,
|
||||
SortOrder = sortOrder++
|
||||
});
|
||||
}
|
||||
|
||||
var result = await SendForwardedToMaxAsync(target.ExternalId, message, cancellationToken);
|
||||
if (!result.Success)
|
||||
{
|
||||
return MaxSendFailure(result, "forward");
|
||||
}
|
||||
|
||||
message.ExternalId = result.ExternalMessageId;
|
||||
message.DeliveryState = MessageDeliveryState.Sent;
|
||||
db.Messages.Add(message);
|
||||
target.LastMessagePreview = ForwardPreview(message);
|
||||
target.LastMessageAt = message.SentAt;
|
||||
target.UpdatedAt = DateTimeOffset.UtcNow;
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
|
||||
var dto = projection.ToDto(message);
|
||||
await hubContext.Clients.Group($"chat:{target.Id}").SendAsync("MessageCreated", dto, cancellationToken);
|
||||
await hubContext.Clients.All.SendAsync("ChatListInvalidated", cancellationToken);
|
||||
return dto;
|
||||
}
|
||||
|
||||
[HttpPost("{chatId:guid}/attachments")]
|
||||
[RequestSizeLimit(30L * 1024 * 1024)]
|
||||
public async Task<ActionResult<MessageDto>> UploadAttachment(
|
||||
Guid chatId,
|
||||
[FromForm] IFormFile file,
|
||||
[FromForm] string? caption,
|
||||
[FromForm] Guid? replyToMessageId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var chat = await db.Chats.FindAsync([chatId], cancellationToken);
|
||||
if (chat is null)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(chat.ExternalId))
|
||||
{
|
||||
return BadRequest("Chat is not linked to MAX.");
|
||||
}
|
||||
|
||||
var stored = await storage.SaveAsync(file, cancellationToken);
|
||||
var result = await maxBridge.SendAttachmentAsync(chat.ExternalId, storage.GetPath(stored.StorageFileName), caption ?? "", cancellationToken);
|
||||
if (!result.Success)
|
||||
{
|
||||
return MaxSendFailure(result, "attachment");
|
||||
}
|
||||
|
||||
var message = new Message
|
||||
{
|
||||
ChatId = chat.Id,
|
||||
ExternalId = result.ExternalMessageId,
|
||||
Direction = MessageDirection.Outgoing,
|
||||
Text = caption,
|
||||
ReplyToMessageId = replyToMessageId,
|
||||
SentAt = DateTimeOffset.UtcNow,
|
||||
DeliveryState = MessageDeliveryState.Sent,
|
||||
SenderName = "You",
|
||||
Attachments =
|
||||
[
|
||||
new MessageAttachment
|
||||
{
|
||||
OriginalFileName = stored.OriginalFileName,
|
||||
StorageFileName = stored.StorageFileName,
|
||||
ContentType = stored.ContentType,
|
||||
FileSizeBytes = stored.FileSizeBytes,
|
||||
Sha256 = stored.Sha256,
|
||||
Kind = stored.Kind
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
db.Messages.Add(message);
|
||||
chat.LastMessagePreview = !string.IsNullOrWhiteSpace(caption)
|
||||
? caption
|
||||
: stored.Kind switch
|
||||
{
|
||||
AttachmentKind.Image => "\u041c\u0435\u0434\u0438\u0430",
|
||||
AttachmentKind.Gif => "\u041c\u0435\u0434\u0438\u0430",
|
||||
AttachmentKind.Video => "\u041c\u0435\u0434\u0438\u0430",
|
||||
AttachmentKind.VoiceNote => "\u0413\u043e\u043b\u043e\u0441\u043e\u0432\u043e\u0435 \u0441\u043e\u043e\u0431\u0449\u0435\u043d\u0438\u0435",
|
||||
AttachmentKind.Sticker => "\u041c\u0435\u0434\u0438\u0430",
|
||||
AttachmentKind.Contact => "\u041a\u043e\u043d\u0442\u0430\u043a\u0442",
|
||||
_ => stored.OriginalFileName
|
||||
};
|
||||
chat.LastMessageAt = message.SentAt;
|
||||
chat.UpdatedAt = DateTimeOffset.UtcNow;
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
|
||||
var dto = projection.ToDto(message);
|
||||
await hubContext.Clients.Group($"chat:{chat.Id}").SendAsync("MessageCreated", dto, cancellationToken);
|
||||
await hubContext.Clients.All.SendAsync("ChatListInvalidated", cancellationToken);
|
||||
return dto;
|
||||
}
|
||||
|
||||
[HttpGet("{chatId:guid}/attachments/{attachmentId:guid}/content")]
|
||||
public async Task<IActionResult> DownloadAttachment(Guid chatId, Guid attachmentId, CancellationToken cancellationToken)
|
||||
{
|
||||
var attachment = await db.MessageAttachments
|
||||
.Include(x => x.Message)
|
||||
.FirstOrDefaultAsync(x => x.Id == attachmentId && x.Message!.ChatId == chatId, cancellationToken);
|
||||
|
||||
if (attachment is null)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
var path = storage.GetPath(attachment.StorageFileName);
|
||||
if (!System.IO.File.Exists(path))
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(attachment.RemoteUrl))
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
var remote = await maxBridge.DownloadMediaAsync(attachment.RemoteUrl, cancellationToken);
|
||||
if (remote is null)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
await using (remote.Stream)
|
||||
{
|
||||
var stored = await storage.SaveRemoteAsync(
|
||||
attachment.OriginalFileName,
|
||||
remote.ContentType,
|
||||
remote.Stream,
|
||||
remote.ContentLength ?? attachment.FileSizeBytes,
|
||||
attachment.Kind,
|
||||
cancellationToken);
|
||||
|
||||
attachment.OriginalFileName = stored.OriginalFileName;
|
||||
attachment.StorageFileName = stored.StorageFileName;
|
||||
attachment.ContentType = stored.ContentType;
|
||||
attachment.FileSizeBytes = stored.FileSizeBytes;
|
||||
attachment.Sha256 = stored.Sha256;
|
||||
attachment.Kind = stored.Kind;
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
|
||||
path = storage.GetPath(attachment.StorageFileName);
|
||||
}
|
||||
}
|
||||
|
||||
return PhysicalFile(path, attachment.ContentType, attachment.OriginalFileName, enableRangeProcessing: true);
|
||||
}
|
||||
|
||||
private ObjectResult MaxSendFailure(MaxSendResult result, string itemName)
|
||||
{
|
||||
return Problem(
|
||||
result.Error ?? $"MAX did not accept the {itemName}.",
|
||||
statusCode: StatusCodes.Status502BadGateway);
|
||||
}
|
||||
|
||||
private async Task<ActionResult?> TryApplyMaxActionAsync(
|
||||
Message message,
|
||||
Func<string, string, Task<MaxActionResult>> action,
|
||||
string actionName,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var externalChatId = message.Chat?.ExternalId;
|
||||
var externalMessageId = message.ExternalId;
|
||||
if (string.IsNullOrWhiteSpace(externalChatId) || string.IsNullOrWhiteSpace(externalMessageId))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var result = await action(externalChatId, externalMessageId);
|
||||
if (result.Success)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return Problem(
|
||||
result.Error ?? $"MAX {actionName} action failed.",
|
||||
statusCode: StatusCodes.Status409Conflict);
|
||||
}
|
||||
|
||||
private static string? NormalizeReaction(string? value)
|
||||
{
|
||||
var emoji = value?.Trim();
|
||||
return string.IsNullOrWhiteSpace(emoji) || emoji.Length > 16 ? null : emoji;
|
||||
}
|
||||
|
||||
private async Task<Message?> LoadMessageForProjectionAsync(Guid chatId, Guid messageId, CancellationToken cancellationToken)
|
||||
{
|
||||
return await db.Messages
|
||||
.AsNoTracking()
|
||||
.Include(x => x.Attachments)
|
||||
.Include(x => x.Reactions)
|
||||
.FirstOrDefaultAsync(x => x.ChatId == chatId && x.Id == messageId && x.DeletedAt == null, cancellationToken);
|
||||
}
|
||||
|
||||
private async Task RecalculateChatPreviewAsync(Chat chat, CancellationToken cancellationToken)
|
||||
{
|
||||
var lastMessage = (await db.Messages
|
||||
.Include(x => x.Attachments)
|
||||
.Where(x => x.ChatId == chat.Id && x.DeletedAt == null)
|
||||
.ToArrayAsync(cancellationToken))
|
||||
.OrderByDescending(x => x.SentAt)
|
||||
.FirstOrDefault();
|
||||
|
||||
chat.LastMessageAt = lastMessage?.SentAt;
|
||||
chat.LastMessagePreview = lastMessage is null ? null : ForwardPreview(lastMessage);
|
||||
chat.UpdatedAt = DateTimeOffset.UtcNow;
|
||||
}
|
||||
|
||||
private async Task<StoredAttachment> CopyForwardAttachmentAsync(MessageAttachment attachment, CancellationToken cancellationToken)
|
||||
{
|
||||
var path = storage.GetPath(attachment.StorageFileName);
|
||||
if (System.IO.File.Exists(path))
|
||||
{
|
||||
await using var stream = System.IO.File.OpenRead(path);
|
||||
return await storage.SaveRemoteAsync(
|
||||
attachment.OriginalFileName,
|
||||
attachment.ContentType,
|
||||
stream,
|
||||
attachment.FileSizeBytes,
|
||||
attachment.Kind,
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(attachment.RemoteUrl))
|
||||
{
|
||||
var remote = await maxBridge.DownloadMediaAsync(attachment.RemoteUrl, cancellationToken);
|
||||
if (remote is not null)
|
||||
{
|
||||
await using (remote.Stream)
|
||||
{
|
||||
return await storage.SaveRemoteAsync(
|
||||
attachment.OriginalFileName,
|
||||
remote.ContentType,
|
||||
remote.Stream,
|
||||
remote.ContentLength ?? attachment.FileSizeBytes,
|
||||
attachment.Kind,
|
||||
cancellationToken);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw new InvalidOperationException($"Cannot forward attachment {attachment.Id}: content is unavailable.");
|
||||
}
|
||||
|
||||
private async Task<MaxSendResult> SendForwardedToMaxAsync(string externalChatId, Message message, CancellationToken cancellationToken)
|
||||
{
|
||||
var attachments = message.Attachments.OrderBy(x => x.SortOrder).ToArray();
|
||||
if (attachments.Length == 0)
|
||||
{
|
||||
return string.IsNullOrWhiteSpace(message.Text)
|
||||
? new MaxSendResult(false, null, "Forwarded message is empty.")
|
||||
: await maxBridge.SendTextAsync(externalChatId, message.Text, cancellationToken);
|
||||
}
|
||||
|
||||
MaxSendResult? lastResult = null;
|
||||
var errors = new List<string>();
|
||||
foreach (var attachment in attachments)
|
||||
{
|
||||
var caption = attachment.SortOrder == 0 ? message.Text ?? "" : "";
|
||||
var result = await maxBridge.SendAttachmentAsync(externalChatId, storage.GetPath(attachment.StorageFileName), caption, cancellationToken);
|
||||
lastResult = result;
|
||||
if (!result.Success && !string.IsNullOrWhiteSpace(result.Error))
|
||||
{
|
||||
errors.Add(result.Error);
|
||||
}
|
||||
}
|
||||
|
||||
return errors.Count == 0
|
||||
? new MaxSendResult(true, lastResult?.ExternalMessageId, null)
|
||||
: new MaxSendResult(false, lastResult?.ExternalMessageId, string.Join("; ", errors.Distinct()));
|
||||
}
|
||||
|
||||
private static string? ResolveForwardedFrom(Message source)
|
||||
{
|
||||
return source.SenderName?.Trim().Length > 0
|
||||
? source.SenderName.Trim()
|
||||
: source.Chat?.Title;
|
||||
}
|
||||
|
||||
private static string ForwardPreview(Message message)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(message.Text))
|
||||
{
|
||||
return message.Text;
|
||||
}
|
||||
|
||||
var first = message.Attachments.OrderBy(x => x.SortOrder).FirstOrDefault();
|
||||
return first?.Kind switch
|
||||
{
|
||||
AttachmentKind.Image => "\u041c\u0435\u0434\u0438\u0430",
|
||||
AttachmentKind.Gif => "\u041c\u0435\u0434\u0438\u0430",
|
||||
AttachmentKind.Video => "\u041c\u0435\u0434\u0438\u0430",
|
||||
AttachmentKind.VoiceNote => "\u0413\u043e\u043b\u043e\u0441\u043e\u0432\u043e\u0435 \u0441\u043e\u043e\u0431\u0449\u0435\u043d\u0438\u0435",
|
||||
AttachmentKind.Sticker => "\u041c\u0435\u0434\u0438\u0430",
|
||||
AttachmentKind.Contact => "\u041a\u043e\u043d\u0442\u0430\u043a\u0442",
|
||||
_ => first?.OriginalFileName ?? "\u041f\u0435\u0440\u0435\u0441\u043b\u0430\u043d\u043d\u043e\u0435 \u0441\u043e\u043e\u0431\u0449\u0435\u043d\u0438\u0435"
|
||||
};
|
||||
}
|
||||
|
||||
private static bool MatchesSearch(Message message, string term)
|
||||
{
|
||||
return Contains(message.Text, term) ||
|
||||
Contains(message.SenderName, term) ||
|
||||
Contains(message.ForwardedFrom, term) ||
|
||||
message.Attachments.Any(x =>
|
||||
Contains(x.OriginalFileName, term) ||
|
||||
Contains(x.ContentType, term)) ||
|
||||
message.Reactions.Any(x => Contains(x.Emoji, term));
|
||||
}
|
||||
|
||||
private static bool Contains(string? value, string term)
|
||||
{
|
||||
return value?.Contains(term, StringComparison.OrdinalIgnoreCase) == true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Options;
|
||||
using QMax.Api.Configuration;
|
||||
using QMax.Api.Contracts;
|
||||
using QMax.Api.Data;
|
||||
using QMax.Api.Data.Entities;
|
||||
using QMax.Api.Infrastructure.Max;
|
||||
using QMax.Api.Services;
|
||||
|
||||
namespace QMax.Api.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Authorize]
|
||||
[Route("api/max")]
|
||||
public sealed class MaxController(
|
||||
IMaxBridgeClient maxBridge,
|
||||
MaxBridgeSyncService syncService,
|
||||
QMaxDbContext db,
|
||||
IOptions<QMaxOptions> options) : ControllerBase
|
||||
{
|
||||
private readonly QMaxOptions _options = options.Value;
|
||||
|
||||
[HttpGet("status")]
|
||||
public async Task<ActionResult<MaxBridgeStatusDto>> Status(CancellationToken cancellationToken)
|
||||
{
|
||||
var status = await maxBridge.GetStatusAsync(cancellationToken);
|
||||
await SaveStateAsync(status, cancellationToken);
|
||||
return ToDto(status);
|
||||
}
|
||||
|
||||
[HttpPost("login/start")]
|
||||
public async Task<ActionResult<MaxBridgeStatusDto>> BeginLogin(BeginMaxLoginRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
var phone = string.IsNullOrWhiteSpace(request.PhoneNumber) ? _options.MaxPhoneNumber : request.PhoneNumber;
|
||||
if (string.IsNullOrWhiteSpace(phone))
|
||||
{
|
||||
return BadRequest("Phone number is required.");
|
||||
}
|
||||
|
||||
var status = await maxBridge.BeginPhoneLoginAsync(phone, cancellationToken);
|
||||
await SaveStateAsync(status, cancellationToken);
|
||||
return ToDto(status);
|
||||
}
|
||||
|
||||
[HttpPost("login/code")]
|
||||
public async Task<ActionResult<MaxBridgeStatusDto>> SubmitCode(SubmitMaxCodeRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
var status = await maxBridge.SubmitLoginCodeAsync(request.Code, cancellationToken);
|
||||
await SaveStateAsync(status, cancellationToken);
|
||||
return ToDto(status);
|
||||
}
|
||||
|
||||
[HttpGet("browser/snapshot")]
|
||||
public async Task<ActionResult<MaxBrowserSnapshotDto>> Snapshot(CancellationToken cancellationToken)
|
||||
{
|
||||
var snapshot = await maxBridge.GetSnapshotAsync(cancellationToken);
|
||||
return new MaxBrowserSnapshotDto(snapshot.Url, snapshot.Title, snapshot.BodyText, snapshot.ScreenshotPngBase64, snapshot.CapturedAt);
|
||||
}
|
||||
|
||||
[HttpPost("sync")]
|
||||
public async Task<ActionResult<object>> Sync(CancellationToken cancellationToken)
|
||||
{
|
||||
var changed = await syncService.SyncOnceAsync(cancellationToken);
|
||||
return new { changed };
|
||||
}
|
||||
|
||||
private async Task SaveStateAsync(MaxBridgeStatus status, CancellationToken cancellationToken)
|
||||
{
|
||||
var state = await db.MaxAccountStates.FirstOrDefaultAsync(x => x.Id == 1, cancellationToken);
|
||||
if (state is null)
|
||||
{
|
||||
state = new MaxAccountState { Id = 1 };
|
||||
db.MaxAccountStates.Add(state);
|
||||
}
|
||||
|
||||
state.PhoneNumber = _options.MaxPhoneNumber;
|
||||
state.Status = status.Status;
|
||||
state.IsAuthorized = status.IsAuthorized;
|
||||
state.LastUrl = status.Url;
|
||||
state.LastError = status.LastError;
|
||||
state.UpdatedAt = DateTimeOffset.UtcNow;
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
|
||||
private static MaxBridgeStatusDto ToDto(MaxBridgeStatus status)
|
||||
{
|
||||
return new MaxBridgeStatusDto(status.Mode, status.IsAuthorized, status.LoginStage, status.Status, status.Url, status.Title, status.LastError, status.UpdatedAt);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using QMax.Api.Data;
|
||||
using QMax.Api.Data.Entities;
|
||||
using QMax.Api.Infrastructure.Auth;
|
||||
|
||||
namespace QMax.Api.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Authorize]
|
||||
[Route("api/push")]
|
||||
public sealed class PushController(QMaxDbContext db) : ControllerBase
|
||||
{
|
||||
public sealed record RegisterPushDeviceRequest(string FirebaseToken, string Platform);
|
||||
|
||||
[HttpPost("devices")]
|
||||
public async Task<IActionResult> Register(RegisterPushDeviceRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(request.FirebaseToken))
|
||||
{
|
||||
return BadRequest("firebaseToken is required.");
|
||||
}
|
||||
|
||||
var userId = User.GetUserId();
|
||||
var device = await db.PushDevices.FirstOrDefaultAsync(x => x.FirebaseToken == request.FirebaseToken, cancellationToken);
|
||||
if (device is null)
|
||||
{
|
||||
device = new PushDevice { FirebaseToken = request.FirebaseToken };
|
||||
db.PushDevices.Add(device);
|
||||
}
|
||||
|
||||
device.UserId = userId;
|
||||
device.Platform = string.IsNullOrWhiteSpace(request.Platform) ? "android" : request.Platform;
|
||||
device.UpdatedAt = DateTimeOffset.UtcNow;
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
[HttpDelete("devices/{id:guid}")]
|
||||
public async Task<IActionResult> Delete(Guid id, CancellationToken cancellationToken)
|
||||
{
|
||||
var userId = User.GetUserId();
|
||||
var device = await db.PushDevices.FirstOrDefaultAsync(x => x.Id == id && x.UserId == userId, cancellationToken);
|
||||
if (device is null)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
db.PushDevices.Remove(device);
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
return NoContent();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using QMax.Api.Data;
|
||||
|
||||
namespace QMax.Api.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/status")]
|
||||
public sealed class StatusController(QMaxDbContext db) : ControllerBase
|
||||
{
|
||||
[AllowAnonymous]
|
||||
[HttpGet]
|
||||
public async Task<ActionResult<object>> Get(CancellationToken cancellationToken)
|
||||
{
|
||||
return new
|
||||
{
|
||||
name = "QMAX Bridge",
|
||||
status = "ok",
|
||||
serverTime = DateTimeOffset.UtcNow,
|
||||
chats = await db.Chats.CountAsync(cancellationToken),
|
||||
messages = await db.Messages.CountAsync(cancellationToken)
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
using System.IdentityModel.Tokens.Jwt;
|
||||
using System.Security.Claims;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
using QMax.Api.Configuration;
|
||||
using QMax.Api.Data.Entities;
|
||||
|
||||
namespace QMax.Api.Infrastructure.Auth;
|
||||
|
||||
public interface ITokenService
|
||||
{
|
||||
string CreateAccessToken(User user, UserSession session, DateTimeOffset expiresAt);
|
||||
string CreateRefreshToken();
|
||||
string HashRefreshToken(string refreshToken);
|
||||
}
|
||||
|
||||
public sealed class TokenService(IOptions<QMaxOptions> options) : ITokenService
|
||||
{
|
||||
private readonly QMaxOptions _options = options.Value;
|
||||
|
||||
public string CreateAccessToken(User user, UserSession session, DateTimeOffset expiresAt)
|
||||
{
|
||||
var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(GetJwtSecret()));
|
||||
var credentials = new SigningCredentials(key, SecurityAlgorithms.HmacSha256);
|
||||
var claims = new[]
|
||||
{
|
||||
new Claim(JwtRegisteredClaimNames.Sub, user.Id.ToString()),
|
||||
new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()),
|
||||
new Claim("sid", session.Id.ToString()),
|
||||
new Claim(ClaimTypes.NameIdentifier, user.Id.ToString()),
|
||||
new Claim(ClaimTypes.Name, user.DisplayName)
|
||||
};
|
||||
|
||||
var token = new JwtSecurityToken(
|
||||
issuer: _options.JwtIssuer,
|
||||
audience: _options.JwtAudience,
|
||||
claims: claims,
|
||||
notBefore: DateTime.UtcNow,
|
||||
expires: expiresAt.UtcDateTime,
|
||||
signingCredentials: credentials);
|
||||
|
||||
return new JwtSecurityTokenHandler().WriteToken(token);
|
||||
}
|
||||
|
||||
public string CreateRefreshToken()
|
||||
{
|
||||
Span<byte> bytes = stackalloc byte[48];
|
||||
RandomNumberGenerator.Fill(bytes);
|
||||
return Convert.ToBase64String(bytes);
|
||||
}
|
||||
|
||||
public string HashRefreshToken(string refreshToken)
|
||||
{
|
||||
var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(refreshToken));
|
||||
return Convert.ToHexString(bytes);
|
||||
}
|
||||
|
||||
private string GetJwtSecret()
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(_options.JwtSecret) && _options.JwtSecret.Length >= 32)
|
||||
{
|
||||
return _options.JwtSecret;
|
||||
}
|
||||
|
||||
if (Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT") == "Development")
|
||||
{
|
||||
return "development-only-qmax-secret-change-before-production";
|
||||
}
|
||||
|
||||
throw new InvalidOperationException("QMax:JwtSecret must be set to at least 32 characters in production.");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
using System.Security.Claims;
|
||||
|
||||
namespace QMax.Api.Infrastructure.Auth;
|
||||
|
||||
public static class UserContext
|
||||
{
|
||||
public static Guid GetUserId(this ClaimsPrincipal principal)
|
||||
{
|
||||
var value = principal.FindFirstValue(ClaimTypes.NameIdentifier)
|
||||
?? principal.FindFirstValue("sub")
|
||||
?? throw new InvalidOperationException("Authenticated user id is missing.");
|
||||
return Guid.Parse(value);
|
||||
}
|
||||
|
||||
public static Guid? GetSessionId(this ClaimsPrincipal principal)
|
||||
{
|
||||
var value = principal.FindFirstValue("sid");
|
||||
return Guid.TryParse(value, out var id) ? id : null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.SignalR;
|
||||
|
||||
namespace QMax.Api.Infrastructure.Hubs;
|
||||
|
||||
[Authorize]
|
||||
public sealed class QMaxHub : Hub
|
||||
{
|
||||
public Task JoinChat(string chatId)
|
||||
{
|
||||
return Groups.AddToGroupAsync(Context.ConnectionId, $"chat:{chatId}");
|
||||
}
|
||||
|
||||
public Task LeaveChat(string chatId)
|
||||
{
|
||||
return Groups.RemoveFromGroupAsync(Context.ConnectionId, $"chat:{chatId}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
namespace QMax.Api.Infrastructure.Max;
|
||||
|
||||
public interface IMaxBridgeClient
|
||||
{
|
||||
Task<MaxBridgeStatus> GetStatusAsync(CancellationToken cancellationToken);
|
||||
Task<MaxBridgeStatus> BeginPhoneLoginAsync(string phoneNumber, CancellationToken cancellationToken);
|
||||
Task<MaxBridgeStatus> SubmitLoginCodeAsync(string code, CancellationToken cancellationToken);
|
||||
Task<MaxBrowserSnapshot> GetSnapshotAsync(CancellationToken cancellationToken);
|
||||
Task<IReadOnlyList<MaxChatUpdate>> FetchUpdatesAsync(CancellationToken cancellationToken);
|
||||
Task<MaxChatUpdate?> FetchChatHistoryAsync(string externalChatId, CancellationToken cancellationToken);
|
||||
Task<MaxChatPresence?> FetchChatPresenceAsync(string externalChatId, CancellationToken cancellationToken);
|
||||
Task<MaxSendResult> SendTextAsync(string externalChatId, string text, CancellationToken cancellationToken);
|
||||
Task<MaxSendResult> SendAttachmentAsync(string externalChatId, string path, string caption, CancellationToken cancellationToken);
|
||||
Task<MaxActionResult> EditMessageAsync(string externalChatId, string externalMessageId, string? currentText, string text, CancellationToken cancellationToken);
|
||||
Task<MaxActionResult> DeleteMessageAsync(string externalChatId, string externalMessageId, string? currentText, CancellationToken cancellationToken);
|
||||
Task<MaxActionResult> SetReactionAsync(string externalChatId, string externalMessageId, string? currentText, string emoji, CancellationToken cancellationToken);
|
||||
Task<MaxActionResult> ClearReactionAsync(string externalChatId, string externalMessageId, string? currentText, string emoji, CancellationToken cancellationToken);
|
||||
Task<MaxMediaDownload?> DownloadMediaAsync(string remoteUrl, CancellationToken cancellationToken);
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
namespace QMax.Api.Infrastructure.Max;
|
||||
|
||||
public sealed record MaxBridgeStatus(
|
||||
string Mode,
|
||||
bool IsAuthorized,
|
||||
string? LoginStage,
|
||||
string Status,
|
||||
string? Url,
|
||||
string? Title,
|
||||
string? LastError,
|
||||
DateTimeOffset UpdatedAt);
|
||||
|
||||
public sealed record MaxBrowserSnapshot(
|
||||
string? Url,
|
||||
string? Title,
|
||||
string BodyText,
|
||||
string ScreenshotPngBase64,
|
||||
DateTimeOffset CapturedAt);
|
||||
|
||||
public sealed record MaxChatUpdate(
|
||||
string ExternalId,
|
||||
string Title,
|
||||
string? AvatarUrl,
|
||||
DateTimeOffset UpdatedAt,
|
||||
IReadOnlyList<MaxMessageUpdate> Messages,
|
||||
string? LastMessagePreview = null,
|
||||
bool? LastMessageIsOutgoing = null,
|
||||
DateTimeOffset? LastMessageAt = null,
|
||||
string? LastMessageTimeText = null);
|
||||
|
||||
public sealed record MaxMessageUpdate(
|
||||
string ExternalId,
|
||||
string? SenderExternalId,
|
||||
string? SenderName,
|
||||
bool IsOutgoing,
|
||||
string? Text,
|
||||
DateTimeOffset SentAt,
|
||||
IReadOnlyList<MaxAttachmentUpdate>? Attachments = null,
|
||||
string? DeliveryState = null);
|
||||
|
||||
public sealed record MaxAttachmentUpdate(
|
||||
string ExternalId,
|
||||
string FileName,
|
||||
string? ContentType,
|
||||
long? FileSizeBytes,
|
||||
string? RemoteUrl,
|
||||
string? Kind,
|
||||
int SortOrder,
|
||||
string? TextContent = null);
|
||||
|
||||
public sealed record MaxSendResult(bool Success, string? ExternalMessageId, string? Error);
|
||||
|
||||
public sealed record MaxActionResult(bool Success, string? Error);
|
||||
|
||||
public sealed record MaxMediaDownload(
|
||||
Stream Stream,
|
||||
string ContentType,
|
||||
long? ContentLength);
|
||||
|
||||
public sealed record MaxChatPresence(
|
||||
bool IsTyping,
|
||||
string? StatusText,
|
||||
DateTimeOffset UpdatedAt);
|
||||
@@ -0,0 +1,93 @@
|
||||
namespace QMax.Api.Infrastructure.Max;
|
||||
|
||||
public sealed class MockMaxBridgeClient : IMaxBridgeClient
|
||||
{
|
||||
private readonly List<MaxChatUpdate> _updates =
|
||||
[
|
||||
new MaxChatUpdate(
|
||||
"mock-qmax",
|
||||
"QMAX smoke chat",
|
||||
null,
|
||||
DateTimeOffset.UtcNow,
|
||||
[
|
||||
new MaxMessageUpdate(
|
||||
"mock-message-1",
|
||||
"max",
|
||||
"MAX",
|
||||
false,
|
||||
"Mock mode is active. Switch QMax:MaxMode to Playwright for real web.max.ru.",
|
||||
DateTimeOffset.UtcNow)
|
||||
])
|
||||
];
|
||||
|
||||
public Task<MaxBridgeStatus> GetStatusAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
return Task.FromResult(new MaxBridgeStatus("Mock", true, "Authorized", "MockReady", null, "Mock", null, DateTimeOffset.UtcNow));
|
||||
}
|
||||
|
||||
public Task<MaxBridgeStatus> BeginPhoneLoginAsync(string phoneNumber, CancellationToken cancellationToken)
|
||||
{
|
||||
return GetStatusAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public Task<MaxBridgeStatus> SubmitLoginCodeAsync(string code, CancellationToken cancellationToken)
|
||||
{
|
||||
return GetStatusAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public Task<MaxBrowserSnapshot> GetSnapshotAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
return Task.FromResult(new MaxBrowserSnapshot(null, "Mock", "Mock MAX bridge", "", DateTimeOffset.UtcNow));
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<MaxChatUpdate>> FetchUpdatesAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
return Task.FromResult<IReadOnlyList<MaxChatUpdate>>(_updates);
|
||||
}
|
||||
|
||||
public Task<MaxChatUpdate?> FetchChatHistoryAsync(string externalChatId, CancellationToken cancellationToken)
|
||||
{
|
||||
var update = _updates.FirstOrDefault(x => x.ExternalId == externalChatId);
|
||||
return Task.FromResult(update);
|
||||
}
|
||||
|
||||
public Task<MaxChatPresence?> FetchChatPresenceAsync(string externalChatId, CancellationToken cancellationToken)
|
||||
{
|
||||
return Task.FromResult<MaxChatPresence?>(new MaxChatPresence(false, null, DateTimeOffset.UtcNow));
|
||||
}
|
||||
|
||||
public Task<MaxSendResult> SendTextAsync(string externalChatId, string text, CancellationToken cancellationToken)
|
||||
{
|
||||
return Task.FromResult(new MaxSendResult(true, $"mock-out-{Guid.NewGuid():N}", null));
|
||||
}
|
||||
|
||||
public Task<MaxSendResult> SendAttachmentAsync(string externalChatId, string path, string caption, CancellationToken cancellationToken)
|
||||
{
|
||||
return Task.FromResult(new MaxSendResult(true, $"mock-file-{Guid.NewGuid():N}", null));
|
||||
}
|
||||
|
||||
public Task<MaxActionResult> EditMessageAsync(string externalChatId, string externalMessageId, string? currentText, string text, CancellationToken cancellationToken)
|
||||
{
|
||||
return Task.FromResult(new MaxActionResult(true, null));
|
||||
}
|
||||
|
||||
public Task<MaxActionResult> DeleteMessageAsync(string externalChatId, string externalMessageId, string? currentText, CancellationToken cancellationToken)
|
||||
{
|
||||
return Task.FromResult(new MaxActionResult(true, null));
|
||||
}
|
||||
|
||||
public Task<MaxActionResult> SetReactionAsync(string externalChatId, string externalMessageId, string? currentText, string emoji, CancellationToken cancellationToken)
|
||||
{
|
||||
return Task.FromResult(new MaxActionResult(true, null));
|
||||
}
|
||||
|
||||
public Task<MaxActionResult> ClearReactionAsync(string externalChatId, string externalMessageId, string? currentText, string emoji, CancellationToken cancellationToken)
|
||||
{
|
||||
return Task.FromResult(new MaxActionResult(true, null));
|
||||
}
|
||||
|
||||
public Task<MaxMediaDownload?> DownloadMediaAsync(string remoteUrl, CancellationToken cancellationToken)
|
||||
{
|
||||
return Task.FromResult<MaxMediaDownload?>(null);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Options;
|
||||
using QMax.Api.Configuration;
|
||||
|
||||
namespace QMax.Api.Infrastructure.Max;
|
||||
|
||||
public sealed class WorkerMaxBridgeClient(
|
||||
HttpClient httpClient,
|
||||
IOptions<QMaxOptions> options,
|
||||
ILogger<WorkerMaxBridgeClient> logger) : IMaxBridgeClient
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
|
||||
private readonly QMaxOptions _options = options.Value;
|
||||
|
||||
public async Task<MaxBridgeStatus> GetStatusAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
return await SendAsync<MaxBridgeStatus>(HttpMethod.Get, "/status", null, cancellationToken)
|
||||
?? ErrorStatus("Worker returned an empty status.");
|
||||
}
|
||||
|
||||
public async Task<MaxBridgeStatus> BeginPhoneLoginAsync(string phoneNumber, CancellationToken cancellationToken)
|
||||
{
|
||||
return await SendAsync<MaxBridgeStatus>(HttpMethod.Post, "/login/start", new { phoneNumber }, cancellationToken)
|
||||
?? ErrorStatus("Worker returned an empty login status.");
|
||||
}
|
||||
|
||||
public async Task<MaxBridgeStatus> SubmitLoginCodeAsync(string code, CancellationToken cancellationToken)
|
||||
{
|
||||
return await SendAsync<MaxBridgeStatus>(HttpMethod.Post, "/login/code", new { code, waitMs = 60000 }, cancellationToken)
|
||||
?? ErrorStatus("Worker returned an empty code status.");
|
||||
}
|
||||
|
||||
public async Task<MaxBrowserSnapshot> GetSnapshotAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
return await SendAsync<MaxBrowserSnapshot>(HttpMethod.Get, "/snapshot", null, cancellationToken)
|
||||
?? new MaxBrowserSnapshot(null, "Worker unavailable", "", "", DateTimeOffset.UtcNow);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<MaxChatUpdate>> FetchUpdatesAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
return await SendAsync<IReadOnlyList<MaxChatUpdate>>(HttpMethod.Get, "/updates", null, cancellationToken)
|
||||
?? Array.Empty<MaxChatUpdate>();
|
||||
}
|
||||
|
||||
public async Task<MaxChatUpdate?> FetchChatHistoryAsync(string externalChatId, CancellationToken cancellationToken)
|
||||
{
|
||||
return await SendAsync<MaxChatUpdate>(HttpMethod.Post, "/chat/history", new { externalChatId }, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<MaxChatPresence?> FetchChatPresenceAsync(string externalChatId, CancellationToken cancellationToken)
|
||||
{
|
||||
return await SendAsync<MaxChatPresence>(HttpMethod.Post, "/chat/presence", new { externalChatId }, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<MaxSendResult> SendTextAsync(string externalChatId, string text, CancellationToken cancellationToken)
|
||||
{
|
||||
return await SendAsync<MaxSendResult>(HttpMethod.Post, "/send/text", new { externalChatId, text }, cancellationToken)
|
||||
?? new MaxSendResult(false, null, "Worker returned an empty send result.");
|
||||
}
|
||||
|
||||
public async Task<MaxSendResult> SendAttachmentAsync(string externalChatId, string path, string caption, CancellationToken cancellationToken)
|
||||
{
|
||||
return await SendAsync<MaxSendResult>(HttpMethod.Post, "/send/attachment", new { externalChatId, path = ToWorkerPath(path), caption }, cancellationToken)
|
||||
?? new MaxSendResult(false, null, "Worker returned an empty attachment result.");
|
||||
}
|
||||
|
||||
public async Task<MaxActionResult> EditMessageAsync(string externalChatId, string externalMessageId, string? currentText, string text, CancellationToken cancellationToken)
|
||||
{
|
||||
return await SendAsync<MaxActionResult>(
|
||||
HttpMethod.Post,
|
||||
"/message/edit",
|
||||
new { externalChatId, externalMessageId, currentText, text },
|
||||
cancellationToken)
|
||||
?? new MaxActionResult(false, "Worker returned an empty edit result.");
|
||||
}
|
||||
|
||||
public async Task<MaxActionResult> DeleteMessageAsync(string externalChatId, string externalMessageId, string? currentText, CancellationToken cancellationToken)
|
||||
{
|
||||
return await SendAsync<MaxActionResult>(
|
||||
HttpMethod.Post,
|
||||
"/message/delete",
|
||||
new { externalChatId, externalMessageId, currentText },
|
||||
cancellationToken)
|
||||
?? new MaxActionResult(false, "Worker returned an empty delete result.");
|
||||
}
|
||||
|
||||
public async Task<MaxActionResult> SetReactionAsync(string externalChatId, string externalMessageId, string? currentText, string emoji, CancellationToken cancellationToken)
|
||||
{
|
||||
return await SendAsync<MaxActionResult>(
|
||||
HttpMethod.Post,
|
||||
"/message/reaction",
|
||||
new { externalChatId, externalMessageId, currentText, emoji },
|
||||
cancellationToken)
|
||||
?? new MaxActionResult(false, "Worker returned an empty reaction result.");
|
||||
}
|
||||
|
||||
public async Task<MaxActionResult> ClearReactionAsync(string externalChatId, string externalMessageId, string? currentText, string emoji, CancellationToken cancellationToken)
|
||||
{
|
||||
return await SendAsync<MaxActionResult>(
|
||||
HttpMethod.Delete,
|
||||
"/message/reaction",
|
||||
new { externalChatId, externalMessageId, currentText, emoji },
|
||||
cancellationToken)
|
||||
?? new MaxActionResult(false, "Worker returned an empty reaction clear result.");
|
||||
}
|
||||
|
||||
public async Task<MaxMediaDownload?> DownloadMediaAsync(string remoteUrl, CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(remoteUrl))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
httpClient.BaseAddress ??= new Uri(_options.MaxWorkerBaseUrl.TrimEnd('/') + "/");
|
||||
var path = $"media/fetch?url={Uri.EscapeDataString(remoteUrl)}";
|
||||
var response = await httpClient.GetAsync(path, HttpCompletionOption.ResponseHeadersRead, cancellationToken);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var content = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||
logger.LogWarning("MAX worker media fetch failed with {Status}: {Content}", response.StatusCode, content);
|
||||
response.Dispose();
|
||||
return null;
|
||||
}
|
||||
|
||||
var contentType = response.Content.Headers.ContentType?.MediaType ?? "application/octet-stream";
|
||||
var contentLength = response.Content.Headers.ContentLength;
|
||||
var stream = await response.Content.ReadAsStreamAsync(cancellationToken);
|
||||
return new MaxMediaDownload(new ResponseDisposingStream(stream, response), contentType, contentLength);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogWarning(ex, "MAX worker media fetch request failed.");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<T?> SendAsync<T>(HttpMethod method, string path, object? body, CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
httpClient.BaseAddress ??= new Uri(_options.MaxWorkerBaseUrl.TrimEnd('/') + "/");
|
||||
using var request = new HttpRequestMessage(method, path.TrimStart('/'));
|
||||
if (body is not null)
|
||||
{
|
||||
request.Content = JsonContent.Create(body, options: JsonOptions);
|
||||
}
|
||||
|
||||
using var response = await httpClient.SendAsync(request, cancellationToken);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var content = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||
logger.LogWarning("MAX worker {Path} failed with {Status}: {Content}", path, response.StatusCode, content);
|
||||
return default;
|
||||
}
|
||||
|
||||
return await response.Content.ReadFromJsonAsync<T>(JsonOptions, cancellationToken);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogWarning(ex, "MAX worker {Path} request failed.", path);
|
||||
return default;
|
||||
}
|
||||
}
|
||||
|
||||
private static MaxBridgeStatus ErrorStatus(string error)
|
||||
{
|
||||
return new MaxBridgeStatus("Worker", false, "Unavailable", "WorkerUnavailable", null, null, error, DateTimeOffset.UtcNow);
|
||||
}
|
||||
|
||||
private static string ToWorkerPath(string path)
|
||||
{
|
||||
var normalized = path.Replace('\\', '/');
|
||||
return normalized.StartsWith("/data/", StringComparison.Ordinal)
|
||||
? "/qmax-data/" + normalized["/data/".Length..]
|
||||
: normalized;
|
||||
}
|
||||
|
||||
private sealed class ResponseDisposingStream(Stream inner, HttpResponseMessage response) : Stream
|
||||
{
|
||||
public override bool CanRead => inner.CanRead;
|
||||
public override bool CanSeek => inner.CanSeek;
|
||||
public override bool CanWrite => inner.CanWrite;
|
||||
public override long Length => inner.Length;
|
||||
public override long Position
|
||||
{
|
||||
get => inner.Position;
|
||||
set => inner.Position = value;
|
||||
}
|
||||
|
||||
public override void Flush() => inner.Flush();
|
||||
public override int Read(byte[] buffer, int offset, int count) => inner.Read(buffer, offset, count);
|
||||
public override long Seek(long offset, SeekOrigin origin) => inner.Seek(offset, origin);
|
||||
public override void SetLength(long value) => inner.SetLength(value);
|
||||
public override void Write(byte[] buffer, int offset, int count) => inner.Write(buffer, offset, count);
|
||||
public override async ValueTask<int> ReadAsync(Memory<byte> buffer, CancellationToken cancellationToken = default) =>
|
||||
await inner.ReadAsync(buffer, cancellationToken);
|
||||
|
||||
protected override void Dispose(bool disposing)
|
||||
{
|
||||
if (disposing)
|
||||
{
|
||||
inner.Dispose();
|
||||
response.Dispose();
|
||||
}
|
||||
|
||||
base.Dispose(disposing);
|
||||
}
|
||||
|
||||
public override async ValueTask DisposeAsync()
|
||||
{
|
||||
await inner.DisposeAsync();
|
||||
response.Dispose();
|
||||
await base.DisposeAsync();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,238 @@
|
||||
using System.Text;
|
||||
using System.Text.Json.Serialization;
|
||||
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
||||
using Microsoft.AspNetCore.HttpOverrides;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
using QMax.Api.Configuration;
|
||||
using QMax.Api.Data;
|
||||
using QMax.Api.Infrastructure.Auth;
|
||||
using QMax.Api.Infrastructure.Hubs;
|
||||
using QMax.Api.Infrastructure.Max;
|
||||
using QMax.Api.Infrastructure.Storage;
|
||||
using QMax.Api.Services;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
builder.Services.Configure<QMaxOptions>(builder.Configuration.GetSection("QMax"));
|
||||
var configuredOptions = builder.Configuration.GetSection("QMax").Get<QMaxOptions>() ?? new QMaxOptions();
|
||||
var databaseDirectory = Path.GetDirectoryName(Path.GetFullPath(configuredOptions.DatabasePath));
|
||||
if (!string.IsNullOrWhiteSpace(databaseDirectory))
|
||||
{
|
||||
Directory.CreateDirectory(databaseDirectory);
|
||||
}
|
||||
|
||||
builder.Services.AddDbContext<QMaxDbContext>(options =>
|
||||
{
|
||||
options.UseSqlite($"Data Source={configuredOptions.DatabasePath}");
|
||||
});
|
||||
|
||||
builder.Services.AddCors(options =>
|
||||
{
|
||||
options.AddDefaultPolicy(policy =>
|
||||
{
|
||||
var origins = configuredOptions.CorsAllowedOrigins
|
||||
.Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries);
|
||||
|
||||
if (origins.Length > 0)
|
||||
{
|
||||
policy.WithOrigins(origins).AllowAnyHeader().AllowAnyMethod().AllowCredentials();
|
||||
}
|
||||
else
|
||||
{
|
||||
policy.AllowAnyHeader().AllowAnyMethod().SetIsOriginAllowed(_ => true).AllowCredentials();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
var jwtSecret = !string.IsNullOrWhiteSpace(configuredOptions.JwtSecret) && configuredOptions.JwtSecret.Length >= 32
|
||||
? configuredOptions.JwtSecret
|
||||
: builder.Environment.IsDevelopment()
|
||||
? "development-only-qmax-secret-change-before-production"
|
||||
: throw new InvalidOperationException("QMax:JwtSecret must be set to at least 32 characters in production.");
|
||||
|
||||
builder.Services
|
||||
.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
|
||||
.AddJwtBearer(options =>
|
||||
{
|
||||
options.TokenValidationParameters = new TokenValidationParameters
|
||||
{
|
||||
ValidateIssuer = true,
|
||||
ValidateAudience = true,
|
||||
ValidateLifetime = true,
|
||||
ValidateIssuerSigningKey = true,
|
||||
ValidIssuer = configuredOptions.JwtIssuer,
|
||||
ValidAudience = configuredOptions.JwtAudience,
|
||||
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtSecret)),
|
||||
ClockSkew = TimeSpan.FromMinutes(1)
|
||||
};
|
||||
options.Events = new JwtBearerEvents
|
||||
{
|
||||
OnMessageReceived = context =>
|
||||
{
|
||||
var accessToken = context.Request.Query["access_token"];
|
||||
var path = context.HttpContext.Request.Path;
|
||||
if (!string.IsNullOrEmpty(accessToken) && path.StartsWithSegments("/hubs/qmax"))
|
||||
{
|
||||
context.Token = accessToken;
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
builder.Services.AddAuthorization();
|
||||
builder.Services.AddControllers().AddJsonOptions(options =>
|
||||
{
|
||||
options.JsonSerializerOptions.Converters.Add(new JsonStringEnumConverter());
|
||||
});
|
||||
builder.Services.AddSignalR();
|
||||
builder.Services.AddHttpClient();
|
||||
builder.Services.AddSingleton<ITokenService, TokenService>();
|
||||
builder.Services.AddScoped<ChatProjectionService>();
|
||||
builder.Services.AddScoped<IPushNotificationService, FirebasePushNotificationService>();
|
||||
builder.Services.AddSingleton<IAttachmentStorageService, AttachmentStorageService>();
|
||||
builder.Services.AddSingleton<MaxBridgeSyncService>();
|
||||
builder.Services.AddHostedService<MaxSyncWorker>();
|
||||
|
||||
if (string.Equals(configuredOptions.MaxMode, "Mock", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
builder.Services.AddSingleton<IMaxBridgeClient, MockMaxBridgeClient>();
|
||||
}
|
||||
else
|
||||
{
|
||||
builder.Services.AddHttpClient<IMaxBridgeClient, WorkerMaxBridgeClient>();
|
||||
}
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
app.UseForwardedHeaders(new ForwardedHeadersOptions
|
||||
{
|
||||
ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto
|
||||
});
|
||||
|
||||
app.UseCors();
|
||||
app.UseAuthentication();
|
||||
app.UseAuthorization();
|
||||
app.MapControllers();
|
||||
app.MapHub<QMaxHub>("/hubs/qmax");
|
||||
|
||||
using (var scope = app.Services.CreateScope())
|
||||
{
|
||||
var options = scope.ServiceProvider.GetRequiredService<IOptions<QMaxOptions>>().Value;
|
||||
Directory.CreateDirectory(options.StoragePath);
|
||||
Directory.CreateDirectory(options.ReleasesPath);
|
||||
var db = scope.ServiceProvider.GetRequiredService<QMaxDbContext>();
|
||||
await db.Database.EnsureCreatedAsync();
|
||||
await EnsureCompatibilitySchemaAsync(db);
|
||||
}
|
||||
|
||||
app.Run();
|
||||
|
||||
static async Task EnsureCompatibilitySchemaAsync(QMaxDbContext db)
|
||||
{
|
||||
var connection = db.Database.GetDbConnection();
|
||||
if (connection.State != System.Data.ConnectionState.Open)
|
||||
{
|
||||
await connection.OpenAsync();
|
||||
}
|
||||
|
||||
var attachmentColumns = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
await using (var command = connection.CreateCommand())
|
||||
{
|
||||
command.CommandText = "PRAGMA table_info(MessageAttachments);";
|
||||
await using var reader = await command.ExecuteReaderAsync();
|
||||
while (await reader.ReadAsync())
|
||||
{
|
||||
attachmentColumns.Add(reader.GetString(1));
|
||||
}
|
||||
}
|
||||
|
||||
if (!attachmentColumns.Contains("RemoteUrl"))
|
||||
{
|
||||
await db.Database.ExecuteSqlRawAsync("ALTER TABLE MessageAttachments ADD COLUMN RemoteUrl TEXT;");
|
||||
}
|
||||
|
||||
if (!attachmentColumns.Contains("ExternalId"))
|
||||
{
|
||||
await db.Database.ExecuteSqlRawAsync("ALTER TABLE MessageAttachments ADD COLUMN ExternalId TEXT;");
|
||||
}
|
||||
|
||||
await db.Database.ExecuteSqlRawAsync("""
|
||||
CREATE INDEX IF NOT EXISTS IX_MessageAttachments_MessageId_ExternalId
|
||||
ON MessageAttachments (MessageId, ExternalId);
|
||||
""");
|
||||
|
||||
await db.Database.ExecuteSqlRawAsync("""
|
||||
CREATE TABLE IF NOT EXISTS MessageReactions (
|
||||
Id TEXT NOT NULL CONSTRAINT PK_MessageReactions PRIMARY KEY,
|
||||
MessageId TEXT NOT NULL,
|
||||
Emoji TEXT NOT NULL,
|
||||
ActorKey TEXT NOT NULL,
|
||||
ActorName TEXT NULL,
|
||||
CreatedAt TEXT NOT NULL,
|
||||
CONSTRAINT FK_MessageReactions_Messages_MessageId FOREIGN KEY (MessageId) REFERENCES Messages (Id) ON DELETE CASCADE
|
||||
);
|
||||
""");
|
||||
await db.Database.ExecuteSqlRawAsync("""
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS IX_MessageReactions_MessageId_ActorKey
|
||||
ON MessageReactions (MessageId, ActorKey);
|
||||
""");
|
||||
|
||||
await RemoveGenericMediaLabelsAsync(db);
|
||||
}
|
||||
|
||||
static async Task RemoveGenericMediaLabelsAsync(QMaxDbContext db)
|
||||
{
|
||||
var placeholderMessages = await db.Messages
|
||||
.Where(message => !db.MessageAttachments.Any(attachment => attachment.MessageId == message.Id))
|
||||
.ToListAsync();
|
||||
|
||||
foreach (var message in placeholderMessages.Where(message => MessageTextSanitizer.IsGenericMediaLabel(message.Text)))
|
||||
{
|
||||
db.Messages.Remove(message);
|
||||
}
|
||||
|
||||
var messages = await db.Messages.ToListAsync();
|
||||
foreach (var message in messages)
|
||||
{
|
||||
var cleanText = MessageTextSanitizer.CleanMessageText(
|
||||
message.Text,
|
||||
await db.MessageAttachments.AnyAsync(attachment => attachment.MessageId == message.Id));
|
||||
|
||||
if (!string.Equals(message.Text, cleanText, StringComparison.Ordinal))
|
||||
{
|
||||
message.Text = cleanText;
|
||||
}
|
||||
}
|
||||
|
||||
var chats = await db.Chats.ToListAsync();
|
||||
foreach (var chat in chats)
|
||||
{
|
||||
var cleanPreview = MessageTextSanitizer.CleanChatPreview(chat.LastMessagePreview);
|
||||
if (!string.Equals(chat.LastMessagePreview, cleanPreview, StringComparison.Ordinal))
|
||||
{
|
||||
chat.LastMessagePreview = cleanPreview;
|
||||
}
|
||||
}
|
||||
|
||||
var attachments = await db.MessageAttachments.ToListAsync();
|
||||
foreach (var attachment in attachments)
|
||||
{
|
||||
var normalizedFileName = AttachmentStorageService.NormalizeRemoteFileName(
|
||||
attachment.OriginalFileName,
|
||||
attachment.ContentType,
|
||||
attachment.Kind);
|
||||
|
||||
if (!string.Equals(attachment.OriginalFileName, normalizedFileName, StringComparison.Ordinal))
|
||||
{
|
||||
attachment.OriginalFileName = normalizedFileName;
|
||||
}
|
||||
}
|
||||
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
public partial class Program;
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user