From 2fcd23a4cfc824b431254d2e98d57393582d7f83 Mon Sep 17 00:00:00 2001 From: sevenhill Date: Wed, 7 Oct 2026 22:46:55 +0300 Subject: [PATCH] Publish IKAR Android 1.0.4 and verify release artifacts --- README.md | 20 ++++++--- tool/release_smoke.dart | 28 +++++++----- tool/verify_android_apk.py | 87 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 120 insertions(+), 15 deletions(-) create mode 100644 tool/verify_android_apk.py diff --git a/README.md b/README.md index f3abeaa..fe07870 100644 --- a/README.md +++ b/README.md @@ -2,9 +2,9 @@ Иконка ИКАР -Самостоятельный Android-мессенджер с сервером `ikar_server`. Имя приложения — **ИКАР**, пакет Android — `ru.ikar.app`, версия исходников — `1.0.4+5` (не опубликована), вариант сборки — `ikar`. Опубликованный выпуск — `1.0.3+4`. +Самостоятельный Android-мессенджер с сервером `ikar_server`. Имя приложения — **ИКАР**, пакет Android — `ru.ikar.app`, версия исходников и опубликованного выпуска — `1.0.4+5`, вариант сборки — `ikar`. -Клиент находится в `C:\Repos\IKAR`, сервер — в `ikar_server/`. Иконка перенесена из прежнего Икара. Исходный каталог `C:\Repos\Qlyra` не изменён; использованный клиентский код и библиотеки сохраняют свои авторские сведения и лицензии. +Клиент находится в `C:\Repos\IKAR`, сервер — в `ikar_server/`. Основная иконка содержит рисунок Икара без надписей. Исходный каталог `C:\Repos\Qlyra` не изменён; использованный клиентский код и библиотеки сохраняют свои авторские сведения и лицензии. ## Возможности @@ -18,6 +18,16 @@ Сквозное шифрование исходного клиента сохранено в коде как отдельная функция. Обычные сообщения сервер принимает и хранит в читаемом виде; HTTPS обеспечивает шифрование транспорта. Проверки не доказывают работу всех режимов сквозного шифрования и звонков с большим количеством участников. +## Выпуск 1.0.4 + +В [Аргусе](https://argus.kusoft.xyz/#app=ikar) опубликована [ИКАР 1.0.4, сборка 5](https://argus.kusoft.xyz/api/apps/ikar/releases/24f6ba10-f133-443e-9635-e38e54ce7852/download). Выпуск включает новую главную иконку, собственную тему, изменения жестов и видеоредактора, действия с расшифрованным текстом, работающую с сервером невидимость и меню расшифровки голосовых сообщений. Серверное исправление аватарок уже развёрнуто. + +APK `artifacts/ikar-1.0.4+5.apk` подписан прежним ключом ИКАР: обновление сохраняет совместимость подписи с 1.0.3. Пакет `ru.ikar.app`, версия `1.0.4`, `versionCode=5`; размер — 88 773 085 байт, SHA-256 — `56ed77c0965cafc7bb71daee6af51e9418c93aab92b07acda9263134eb41e410`. В итоговом APK проверены свежие Flutter-библиотеки всех трёх ABI, наличие нативных зависимостей, актуальные изображения и пиксели адаптивной иконки для всех плотностей, нулевой inset основного рисунка. + +Публичные каталог, манифест и скачанный из Аргуса APK совпадают по версии, размеру и SHA-256; TLS и здоровье сервиса проверены. Обновление через сервер ИКАР также выдаёт новый манифест и тот же APK. Перед публикацией создана согласованная копия базы Аргуса; рабочая база и копия целостны, остальные карточки и работающие сервисы сохранены. Предыдущие выпуски оставлены. Проверки исходников описаны ниже; на физический телефон новый APK не устанавливался — подключённых ADB-устройств нет. + +Отчёты: `artifacts/apk-build-1.0.4.log`, `artifacts/apk-verification-1.0.4.json`, `artifacts/argus-publication-1.0.4.json`, `artifacts/argus-public-verification-1.0.4.json`, `artifacts/release-verification-1.0.4.json`. Проверка локального APK: `python tool/verify_android_apk.py --apk artifacts/ikar-1.0.4+5.apk --previous artifacts/ikar-1.0.3+4.argus.apk --version 1.0.4 --code 5`; проверки публикации: `dart run tool/verify_argus_release.dart 1.0.4 5`, `dart run tool/release_smoke.dart 1.0.4 5`. + ## Исправление установки аватарки Фото загружалось на сервер, но профиль и список фотографий возвращали относительный путь изображения и не передавали `photoId`. Клиент не мог открыть фото и корректно обновить кэш. Теперь эти ответы содержат полный HTTPS-адрес и стабильный ID фотографии; замена меняет URL и ID, удаление очищает оба значения. Push обновления профиля использует ожидаемую клиентом структуру `payload.profile`. @@ -44,7 +54,7 @@ Главная иконка содержит только рисунок Икара без надписей. Размер рисунка — около 90% диаметра круглой области; адаптивный слой учитывает расширение Android с 72 до 108 dp. Исходный рисунок — `assets/ikar_mark.png`, основные ресурсы — `assets/ikar_icon.png` и `assets/ikar.png`. Пересоздание: `dart run tool/make_ikar_icons.dart`, затем `dart run flutter_launcher_icons`. Ресурсы основной иконки обновлены для Android, iOS, Windows и macOS; альтернативная иконка сохранена. -Новая иконка и клиентские изменения требуют нового APK; текущий опубликованный выпуск их не содержит. Выпуск APK и проверка этих клиентских изменений на телефоне в этой задаче не выполнялись. +Новая иконка и клиентские изменения включены в опубликованный APK 1.0.4. Проверка этих клиентских изменений на физическом телефоне пока не выполнялась. ## Размещение на Pi @@ -86,13 +96,13 @@ dotnet test ikar_server/src/Ikar.Server.Tests/Ikar.Server.Tests.csproj `tool/deploy_ikar.ps1` упаковывает опубликованный `linux-arm64` сервер и обновляет только новый Compose-проект на Pi. Модель Vosk в `ikar_server/asr/model.zip` проверяется по SHA-256 при сборке контейнера. Скрипты в `tool/` для проверок используют временные тестовые аккаунты и удаляют их по завершении. -## Меню расшифровки голосовых сообщений 1.0.4 (не опубликовано) +## Меню расшифровки голосовых сообщений 1.0.4 Расшифровка сохранена и перенесена из маленькой кнопки «Т» в меню голосового сообщения. Нажатие на облако открывает меню с пунктом «Расшифровка голосового сообщения»: нажимается вся строка, её высота не меньше 48 логических пикселей и увеличивается при переносе текста или увеличенном шрифте. У обычных сообщений, файлов, видео, удалённых и ещё не отправленных записей этого пункта нет. Пересланные записи используют идентификаторы исходного голосового сообщения. Под записью отображается состояние распознавания, затем готовый текст; через меню его можно скрыть и показать снова без повторного запроса. Одновременные нажатия не создают повторные запросы. Ошибку можно повторить; результат, уже полученный через push, не затирается поздним ответом о постановке в очередь или сетевой ошибкой. -Целевые тесты: `artifacts/voice-menu-tests.log`. Анализ изменённых UI-файлов и новых тестов: `artifacts/voice-menu-analyze-final.log`. Живая проверка тестовой записи подтвердила загрузку, запрос получателя, завершение распознавания через push, сохранение текста для обоих участников и запрет доступа постороннему: `artifacts/voice-transcription-live-verification.json`. Тестовые аккаунты удалены. Новая версия не публиковалась и не устанавливалась на телефон по указанию пользователя; нового APK нет. +Целевые тесты: `artifacts/voice-menu-tests.log`. Анализ изменённых UI-файлов и новых тестов: `artifacts/voice-menu-analyze-final.log`. Живая проверка тестовой записи подтвердила загрузку, запрос получателя, завершение распознавания через push, сохранение текста для обоих участников и запрет доступа постороннему: `artifacts/voice-transcription-live-verification.json`. Тестовые аккаунты удалены. Меню включено в опубликованный APK 1.0.4; на физический телефон этот выпуск пока не устанавливался. ## Исправления видео 1.0.3 diff --git a/tool/release_smoke.dart b/tool/release_smoke.dart index 939abe9..ddff8fb 100644 --- a/tool/release_smoke.dart +++ b/tool/release_smoke.dart @@ -1,45 +1,53 @@ import 'dart:convert'; import 'dart:io'; import 'package:crypto/crypto.dart'; -import '../lib/core/transport/ikar_network.dart'; +import 'package:ikar/core/transport/ikar_network.dart'; -Future main() async { +Future main(List args) async { + final version = args.isEmpty ? '1.0.0' : args[0]; + final code = args.length < 2 ? 1 : int.parse(args[1]); + final reportSuffix = code == 1 ? '' : '-$version'; final http = IkarNetwork.client(); try { final base = Uri.parse('https://ikar.kusoft.xyz'); final response = await (await http.getUrl( base.resolve('/api/apps/ikar/manifest'), )).close(); - if (response.statusCode != 200) + if (response.statusCode != 200) { throw StateError('Release manifest unavailable'); + } final manifest = jsonDecode(await utf8.decodeStream(response)); if (manifest['app']['packageName'] != 'ru.ikar.app' || - manifest['release']['version'] != '1.0.0+1') + manifest['release']['version'] != '$version+$code') { throw StateError('Incorrect release identity'); + } final expected = (await File( - 'artifacts/ikar-1.0.0+1.apk.sha256', + 'artifacts/ikar-$version+$code.apk.sha256', ).readAsString()).split(' ').first; - if (manifest['release']['sha256'] != expected) + if (manifest['release']['sha256'] != expected) { throw StateError('Manifest checksum mismatch'); + } final download = await (await http.getUrl( base.resolve(manifest['release']['downloadPath']), )).close(); - if (download.statusCode != 200) + if (download.statusCode != 200) { throw StateError('APK download unavailable'); + } final digest = await sha256.bind(download).first; - if (digest.toString() != expected) + if (digest.toString() != expected) { throw StateError('Downloaded APK checksum mismatch'); + } final report = { 'passed': true, 'package': 'ru.ikar.app', - 'version': '1.0.0+1', + 'version': '$version+$code', 'sha256': expected, 'tlsValidation': true, 'bytes': manifest['release']['packageSizeBytes'], 'timestamp': DateTime.now().toUtc().toIso8601String(), }; await File( - 'artifacts/release-verification.json', + 'artifacts/release-verification$reportSuffix.json', ).writeAsString(const JsonEncoder.withIndent(' ').convert(report)); stdout.writeln(jsonEncode(report)); } finally { diff --git a/tool/verify_android_apk.py b/tool/verify_android_apk.py new file mode 100644 index 0000000..736f9ff --- /dev/null +++ b/tool/verify_android_apk.py @@ -0,0 +1,87 @@ +"""Verify APK identity, upgrade signature, fresh Flutter libraries and icon assets.""" +import argparse +import hashlib +import io +import json +from pathlib import Path +import re +import subprocess +import zipfile +from PIL import Image + + +def sha(data): + return hashlib.sha256(data).hexdigest() + + +def verify(args): + apk = Path(args.apk) + previous = Path(args.previous) + sdk = Path(args.build_tools) + metadata = subprocess.check_output([str(sdk / 'aapt.exe'), 'dump', 'badging', str(apk)], text=True, encoding='utf-8') + identity = re.search(r"package: name='([^']+)' versionCode='([^']+)' versionName='([^']+)'", metadata) + if identity is None or identity.groups() != ('ru.ikar.app', str(args.code), args.version): + raise RuntimeError('Incorrect Android package or version') + + def certificate(path): + output = subprocess.check_output([str(sdk / 'apksigner.bat'), 'verify', '--verbose', '--print-certs', str(path)], text=True, encoding='utf-8') + values = re.findall(r'Signer #\d+ certificate SHA-256 digest: ([0-9a-f]+)', output) + if len(values) != 1 or 'Verified using v2 scheme (APK Signature Scheme v2): true' not in output: + raise RuntimeError('APK signature verification failed') + return values[0] + + signer = certificate(apk) + if signer != certificate(previous): + raise RuntimeError('Signing certificate differs from the previously published APK') + latest_source = max(path.stat().st_mtime for path in Path('lib').rglob('*.dart')) + libraries = [] + assets = [] + with zipfile.ZipFile(apk) as package: + icon_path = re.search(r"application-icon-160:'([^']+)'", metadata).group(1) + icon_tree = subprocess.check_output([str(sdk / 'aapt.exe'), 'dump', 'xmltree', str(apk), icon_path], text=True, encoding='utf-8') + inset = re.search(r'android:inset\([^)]*\)=\(type 0x6\)0x([0-9a-f]+)', icon_tree) + if inset is None or int(inset[1], 16) >> 8 != 0: + raise RuntimeError('Adaptive launcher icon still has the old inset') + resources = subprocess.check_output([str(sdk / 'aapt.exe'), 'dump', '--values', 'resources', str(apk)], text=True, encoding='utf-8') + paths = re.findall(r'[^\n]*ru\.ikar\.app:drawable/ic_launcher_foreground: t=0x03[^\n]*\n\s*\(string8\) "([^"]+)"', resources) + expected_icons = {} + for path in Path('android/app/src/main/res').glob('drawable-*/ic_launcher_foreground.png'): + with Image.open(path) as image: + expected_icons[image.size] = image.convert('RGBA').tobytes() + if len(paths) != len(expected_icons): + raise RuntimeError('Missing launcher foreground densities') + for path in paths: + with Image.open(io.BytesIO(package.read(path))) as image: + if image.convert('RGBA').tobytes() != expected_icons.get(image.size): + raise RuntimeError('Packaged launcher icon differs from the current drawing') + for abi in ('armeabi-v7a', 'arm64-v8a', 'x86_64'): + generated = Path(f'build/app/intermediates/flutter/ikarRelease/jniLibs/{abi}/libapp.so') + stripped = Path(f'build/app/intermediates/stripped_native_libs/ikarRelease/stripIkarReleaseDebugSymbols/out/lib/{abi}/libapp.so') + actual = sha(package.read(f'lib/{abi}/libapp.so')) + if actual != sha(stripped.read_bytes()) or generated.stat().st_mtime < latest_source: + raise RuntimeError(f'Stale Flutter library for {abi}') + for name in ('libflutter.so', 'libfjs.so', 'libkomet_crypto.so', 'libkomet_tlottie.so'): + if not package.read(f'lib/{abi}/{name}'): + raise RuntimeError(f'Missing native dependency: {abi}/{name}') + libraries.append({'abi': abi, 'apkSha256': actual, 'matchesStripped': True, 'generatedAfterLastSourceChange': True}) + for name in ('ikar.png', 'ikar_icon.png'): + expected = Path('assets') / name + if sha(package.read(f'assets/flutter_assets/assets/{name}')) != sha(expected.read_bytes()): + raise RuntimeError(f'Stale icon asset: {name}') + assets.append(name) + report = {'passed': True, 'package': 'ru.ikar.app', 'version': args.version, + 'androidVersionCode': args.code, 'sha256': sha(apk.read_bytes()), 'bytes': apk.stat().st_size, + 'certificateSha256': signer, 'upgradeSignerMatches': True, 'libraries': libraries, 'currentIconAssets': assets, + 'adaptiveLauncherMatchesCurrentDrawing': True, 'adaptiveLauncherInsetZero': True} + Path(f'artifacts/apk-verification-{args.version}.json').write_text(json.dumps(report, indent=2), encoding='utf-8') + print(json.dumps(report)) + + +if __name__ == '__main__': + parser = argparse.ArgumentParser() + parser.add_argument('--apk', required=True) + parser.add_argument('--previous', required=True) + parser.add_argument('--version', required=True) + parser.add_argument('--code', required=True, type=int) + parser.add_argument('--build-tools', default='C:/Tools/AndroidSdk/build-tools/36.0.0') + verify(parser.parse_args())