diff --git a/lib/backend/modules/account.dart b/lib/backend/modules/account.dart index 74d6a64..70b4b9a 100644 --- a/lib/backend/modules/account.dart +++ b/lib/backend/modules/account.dart @@ -484,7 +484,7 @@ class AccountModule { return newProfile; } - Future updateProfileAvatar(String photoToken, String avatarType) async { + Future updateProfileAvatar(String photoToken, {String avatarType = 'USER_AVATAR'}) async { _ensureOnline(); final packet = await _api.sendRequest(Opcode.profile, { 'photoToken': photoToken, diff --git a/lib/backend/modules/file_uploader.dart b/lib/backend/modules/file_uploader.dart index 2aae308..3091e60 100644 --- a/lib/backend/modules/file_uploader.dart +++ b/lib/backend/modules/file_uploader.dart @@ -190,13 +190,23 @@ class FileUploader { Socket? socket; try { socket = await _openSocket(uri); + final boundary = '----KometBoundary${DateTime.now().microsecondsSinceEpoch}'; + final preamble = utf8.encode( + '--$boundary\r\n' + 'Content-Disposition: form-data; name="file"; filename="$filename"\r\n' + 'Content-Type: ${_contentTypeForFilename(filename)}\r\n' + '\r\n', + ); + final epilogue = utf8.encode('\r\n--$boundary--\r\n'); _writeImageHeaders( socket, uri, - bytes.length, - contentType: _contentTypeForFilename(filename), + preamble.length + bytes.length + epilogue.length, + boundary: boundary, ); + socket.add(preamble); socket.add(bytes); + socket.add(epilogue); await socket.flush(); final response = await _readFullResponse( @@ -208,7 +218,6 @@ class FileUploader { } catch (_) {} if (response == null) { - logger.w('uploadImage: empty/timed-out response'); return null; } final (status, body) = response; @@ -230,12 +239,12 @@ class FileUploader { } } - void _writeImageHeaders(Socket socket, Uri uri, int total, {required String contentType}) { + void _writeImageHeaders(Socket socket, Uri uri, int total, {required String boundary}) { final path = '${uri.path}${uri.hasQuery ? "?${uri.query}" : ""}'; final headers = StringBuffer() ..write('POST $path HTTP/1.1\r\n') ..write('Host: ${uri.host}\r\n') - ..write('Content-Type: $contentType\r\n') + ..write('Content-Type: multipart/form-data; boundary=$boundary\r\n') ..write('Content-Length: $total\r\n') ..write('Connection: keep-alive\r\n') ..write('User-Agent: ${Uri.encodeComponent('OKMessages/26.14.1 (Android 11; TECNO MOBILE LIMITED TECNO LE7n; xxhdpi 480dpi 1080x2208)')}\r\n') @@ -273,36 +282,63 @@ class FileUploader { Timer? timer; StreamSubscription>? sub; - void finish() { + void finishWith((int, String)? value) { timer?.cancel(); sub?.cancel(); - if (completer.isCompleted) return; + if (!completer.isCompleted) completer.complete(value); + } + + (int, String)? tryParse({required bool atClose}) { final headerEnd = _findHeaderEnd(bytes); - if (headerEnd == -1) { - completer.complete(null); - return; - } + if (headerEnd == -1) return null; final headerStr = utf8.decode(bytes.sublist(0, headerEnd), allowMalformed: true); final lines = headerStr.split('\r\n'); final parts = lines.first.split(' '); final status = parts.length >= 2 ? (int.tryParse(parts[1]) ?? 0) : 0; - final chunked = lines.skip(1).any( + final headerLines = lines.skip(1); + final chunked = headerLines.any( (l) => l.toLowerCase().startsWith('transfer-encoding:') && l.toLowerCase().contains('chunked'), ); + int? contentLength; + for (final l in headerLines) { + if (l.toLowerCase().startsWith('content-length:')) { + contentLength = int.tryParse(l.split(':').last.trim()); + } + } final rawBody = utf8.decode(bytes.sublist(headerEnd), allowMalformed: true); - final body = chunked ? _decodeChunked(rawBody) : rawBody; - completer.complete((status, body)); + if (chunked) { + if (!atClose && !rawBody.contains('\r\n0\r\n')) return null; + return (status, _decodeChunked(rawBody)); + } + if (contentLength != null && !atClose && bytes.length - headerEnd < contentLength) { + return null; + } + return (status, rawBody); } - void fail() { - timer?.cancel(); - sub?.cancel(); - if (!completer.isCompleted) completer.complete(null); - } - - sub = socket.listen(bytes.addAll, onError: (_) => fail(), onDone: finish); - timer = Timer(timeout, fail); + sub = socket.listen( + (chunk) { + bytes.addAll(chunk); + final parsed = tryParse(atClose: false); + if (parsed != null) finishWith(parsed); + }, + onError: (e) { + logger.w('uploadImage: socket error after ${bytes.length} bytes: $e'); + finishWith(tryParse(atClose: true)); + }, + onDone: () { + final parsed = tryParse(atClose: true); + if (parsed == null) { + logger.w('uploadImage: connection closed without HTTP response (${bytes.length} bytes)'); + } + finishWith(parsed); + }, + ); + timer = Timer(timeout, () { + logger.w('uploadImage: response timeout after ${bytes.length} bytes'); + finishWith(tryParse(atClose: true)); + }); return completer.future; } diff --git a/lib/core/utils/image_utils.dart b/lib/core/utils/image_utils.dart new file mode 100644 index 0000000..a7b99a7 --- /dev/null +++ b/lib/core/utils/image_utils.dart @@ -0,0 +1,28 @@ +import 'package:flutter/foundation.dart'; +import 'package:image/image.dart' as img; + +const int _avatarMaxDimension = 1024; +const int _avatarTargetBytes = 900 * 1024; + +Future compressAvatar(Uint8List input) => compute(_encodeAvatar, input); + +Uint8List? _encodeAvatar(Uint8List input) { + final decoded = img.decodeImage(input); + if (decoded == null) return null; + final oriented = img.bakeOrientation(decoded); + final image = oriented.width > _avatarMaxDimension || oriented.height > _avatarMaxDimension + ? img.copyResize( + oriented, + width: oriented.width >= oriented.height ? _avatarMaxDimension : null, + height: oriented.height > oriented.width ? _avatarMaxDimension : null, + interpolation: img.Interpolation.average, + ) + : oriented; + var quality = 88; + var out = img.encodeJpg(image, quality: quality); + while (out.lengthInBytes > _avatarTargetBytes && quality > 35) { + quality -= 12; + out = img.encodeJpg(image, quality: quality); + } + return out; +} diff --git a/lib/frontend/screens/chats/create_group_flow.dart b/lib/frontend/screens/chats/create_group_flow.dart index 3f6b3ea..c35dca9 100644 --- a/lib/frontend/screens/chats/create_group_flow.dart +++ b/lib/frontend/screens/chats/create_group_flow.dart @@ -8,6 +8,7 @@ import 'package:material_symbols_icons/symbols.dart'; import '../../../backend/modules/chats.dart'; import '../../../backend/modules/contacts.dart'; import '../../../core/storage/token_storage.dart'; +import '../../../core/utils/image_utils.dart'; import '../../../main.dart'; import '../../widgets/custom_notification.dart'; import '../../widgets/swipe_route.dart'; @@ -131,16 +132,20 @@ class _CreateGroupFlowState extends State<_CreateGroupFlow> { if (_avatar != null) { final url = await ChatsModule.requestChatPhotoUploadUrl(api); if (url != null) { - final bytes = await _avatar!.readAsBytes(); - final token = await fileUploader.uploadImage( - Uri.parse(url), - bytes, - filename: _avatar!.uri.pathSegments.last, - ); - if (token != null) { - await ChatsModule.setChatPhoto(api, chatId: chat.id, photoToken: token); - } else if (mounted) { - showCustomNotification(context, 'Не удалось загрузить аватарку'); + final bytes = await compressAvatar(await _avatar!.readAsBytes()); + if (bytes == null) { + if (mounted) showCustomNotification(context, 'Не удалось обработать аватарку'); + } else { + final token = await fileUploader.uploadImage( + Uri.parse(url), + bytes, + filename: 'avatar.jpg', + ); + if (token != null) { + await ChatsModule.setChatPhoto(api, chatId: chat.id, photoToken: token); + } else if (mounted) { + showCustomNotification(context, 'Не удалось загрузить аватарку'); + } } } } diff --git a/lib/frontend/screens/profile/edit_profile_screen.dart b/lib/frontend/screens/profile/edit_profile_screen.dart index 44c2099..200bc49 100644 --- a/lib/frontend/screens/profile/edit_profile_screen.dart +++ b/lib/frontend/screens/profile/edit_profile_screen.dart @@ -1,10 +1,14 @@ +import 'package:file_picker/file_picker.dart'; import 'package:flutter/material.dart'; import 'package:material_symbols_icons/symbols.dart'; import '../../../core/storage/app_database.dart'; +import '../../../core/utils/image_utils.dart'; import '../../../l10n/app_localizations.dart'; -import '../../../main.dart' show accountModule, KometApp; +import '../../../main.dart' show accountModule, fileUploader, KometApp; import '../../widgets/custom_notification.dart'; +const int _maxAvatarBytes = 8 * 1024 * 1024; + class EditProfileScreen extends StatefulWidget { const EditProfileScreen({super.key}); @@ -77,12 +81,56 @@ class _EditProfileScreenState extends State { Future _changeAvatar() async { if (_isSaving) return; + final result = await FilePicker.platform.pickFiles( + type: FileType.image, + withData: true, + ); + if (result == null || result.files.isEmpty) return; + final picked = result.files.first; + final bytes = picked.bytes; + if (bytes == null) { + if (mounted) showCustomNotification(context, 'Не удалось прочитать файл'); + return; + } + if (bytes.length > _maxAvatarBytes) { + if (mounted) showCustomNotification(context, 'Картинка слишком большая (макс 8 МБ)'); + return; + } + if (!mounted) return; + setState(() => _isSaving = true); try { - final uploadUrl = await accountModule.getAvatarUploadUrl(); + final processed = await compressAvatar(bytes); + if (processed == null) { + if (!mounted) return; + showCustomNotification(context, 'Не удалось обработать изображение'); + setState(() => _isSaving = false); + return; + } + final url = await accountModule.getAvatarUploadUrl(); + final token = await fileUploader.uploadImage( + Uri.parse(url), + processed, + filename: 'avatar.jpg', + ); + if (token == null) { + if (!mounted) return; + showCustomNotification(context, 'Не удалось загрузить аватарку'); + setState(() => _isSaving = false); + return; + } + final newProfile = await accountModule.updateProfileAvatar(token); if (!mounted) return; - showCustomNotification(context, 'Загрузка аватарки: $uploadUrl (пока нет)'); + setState(() { + _avatarUrl = newProfile.baseUrl; + _photoId = newProfile.photoId; + _isSaving = false; + }); + KometApp.stateOf(context)?.notifyProfileUpdate(); + showCustomNotification(context, 'Аватарка обновлена'); } catch (e) { - if (mounted) showCustomNotification(context, 'Ошибка: $e'); + if (!mounted) return; + showCustomNotification(context, 'Ошибка: $e'); + setState(() => _isSaving = false); } } diff --git a/pubspec.lock b/pubspec.lock index d2ce143..0545e63 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -17,6 +17,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.1.2" + archive: + dependency: transitive + description: + name: archive + sha256: a96e8b390886ee8abb49b7bd3ac8df6f451c621619f52a26e815fdcf568959ff + url: "https://pub.dev" + source: hosted + version: "4.0.9" args: dependency: transitive description: @@ -413,6 +421,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.2.1" + image: + dependency: "direct main" + description: + name: image + sha256: f9881ff4998044947ec38d098bc7c8316ae1186fa786eddffdb867b9bc94dfce + url: "https://pub.dev" + source: hosted + version: "4.8.0" intl: dependency: "direct main" description: @@ -505,10 +521,10 @@ packages: dependency: transitive description: name: matcher - sha256: "12956d0ad8390bbcc63ca2e1469c0619946ccb52809807067a7020d57e647aa6" + sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 url: "https://pub.dev" source: hosted - version: "0.12.18" + version: "0.12.19" material_color_utilities: dependency: transitive description: @@ -537,10 +553,10 @@ packages: dependency: transitive description: name: meta - sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394" + sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349" url: "https://pub.dev" source: hosted - version: "1.17.0" + version: "1.18.0" mobile_scanner: dependency: "direct main" description: @@ -693,6 +709,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.8" + posix: + dependency: transitive + description: + name: posix + sha256: "185ef7606574f789b40f289c233efa52e96dead518aed988e040a10737febb07" + url: "https://pub.dev" + source: hosted + version: "6.5.0" progress_indicator_m3e: dependency: transitive description: @@ -902,10 +926,10 @@ packages: dependency: transitive description: name: test_api - sha256: "93167629bfc610f71560ab9312acdda4959de4df6fac7492c89ff0d3886f6636" + sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e" url: "https://pub.dev" source: hosted - version: "0.7.9" + version: "0.7.11" timezone: dependency: "direct main" description: diff --git a/pubspec.yaml b/pubspec.yaml index 80f23de..6fbaeec 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -44,6 +44,7 @@ dependencies: flutter_timezone: ^5.0.1 timezone: ^0.11.0 file_picker: ^8.0.0 + image: ^4.3.0 sqflite: ^2.4.2 sqflite_common_ffi: ^2.4.0+2 path: ^1.9.1