From 5d115e0f9e03d3ced86023f3c38a067429eab38a Mon Sep 17 00:00:00 2001 From: Jganenokk Date: Sat, 22 Aug 2026 23:18:09 +0700 Subject: [PATCH] =?UTF-8?q?feat:=20=D0=BA=D0=BE=D0=BC=D0=B5=D1=82=D0=B0=20?= =?UTF-8?q?=D0=B2=20'=D0=BF=D0=BE=D0=B4=D0=B5=D0=BB=D0=B8=D1=82=D1=81?= =?UTF-8?q?=D1=8F=20'?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- android/app/src/main/AndroidManifest.xml | 17 + .../main/kotlin/ru/komet/app/MainActivity.kt | 84 +++ .../main/kotlin/ru/komet/app/ShareIntake.kt | 154 +++++ .../app/src/main/res/values-ru/strings.xml | 1 + android/app/src/main/res/values/strings.xml | 1 + lib/backend/modules/share_sender.dart | 547 ++++++++++++++++++ lib/core/media/share_thumbnail.dart | 95 +++ lib/core/share/share_intent_bridge.dart | 114 ++++ lib/core/share/share_labels.dart | 36 ++ .../screens/chats/chat_list_screen.dart | 177 +++++- .../screens/chats/share_composer_bar.dart | 355 ++++++++++++ lib/main.dart | 5 + lib/models/shared_payload.dart | 100 ++++ test/share_composer_bar_test.dart | 131 +++++ test/share_intent_test.dart | 164 ++++++ 15 files changed, 1972 insertions(+), 9 deletions(-) create mode 100644 android/app/src/main/kotlin/ru/komet/app/ShareIntake.kt create mode 100644 lib/backend/modules/share_sender.dart create mode 100644 lib/core/media/share_thumbnail.dart create mode 100644 lib/core/share/share_intent_bridge.dart create mode 100644 lib/core/share/share_labels.dart create mode 100644 lib/frontend/screens/chats/share_composer_bar.dart create mode 100644 lib/models/shared_payload.dart create mode 100644 test/share_composer_bar_test.dart create mode 100644 test/share_intent_test.dart diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index a01cf1a..0501773 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -85,6 +85,23 @@ + + + + + + + + + + + + + + + + + ? = null private var pendingChat: Long = 0L + private var pendingShare: Map? = null + private var pendingShareTask: java.util.concurrent.Future?>? = null + private val shareExecutor = java.util.concurrent.Executors.newSingleThreadExecutor() + private val shareHandler = Handler(Looper.getMainLooper()) private companion object { const val LOG_TAG = "VpnBypass" + const val SHARE_TAG = "ShareIntake" const val NFC_TAG = "NfcExchange" const val KEEP_ENGINE_ID = "komet_keep_engine" const val NFC_PHASE_MIN_MS = 350L @@ -439,6 +444,54 @@ class MainActivity : FlutterActivity() { } }) + MethodChannel( + flutterEngine.dartExecutor.binaryMessenger, + "ru.komet.app/share", + ).setMethodCallHandler { call, result -> + when (call.method) { + "consumeInitialShare" -> { + stashShare(intent, emit = false) + val ready = pendingShare + val task = pendingShareTask + if (ready != null) { + pendingShare = null + result.success(ready) + } else if (task != null) { + pendingShareTask = null + shareExecutor.execute { + val payload = try { + task.get() + } catch (e: Exception) { + Log.w(SHARE_TAG, "materialize failed: $e") + null + } + shareHandler.post { result.success(payload) } + } + } else { + result.success(null) + } + } + "clearCache" -> { + ShareIntake.clearCache(applicationContext) + result.success(null) + } + else -> result.notImplemented() + } + } + + EventChannel( + flutterEngine.dartExecutor.binaryMessenger, + "ru.komet.app/share_events", + ).setStreamHandler(object : EventChannel.StreamHandler { + override fun onListen(arguments: Any?, events: EventChannel.EventSink?) { + ShareIntake.sink = events + } + + override fun onCancel(arguments: Any?) { + ShareIntake.sink = null + } + }) + FkmChannel.attach(flutterEngine, this) } @@ -447,6 +500,7 @@ class MainActivity : FlutterActivity() { super.onCreate(savedInstanceState) intent?.let { if (it.hasExtra(CallConst.EXTRA_CALL)) stashCall(it, emit = false) } stashChatOpen(intent, emit = false) + stashShare(intent, emit = false) } override fun onNewIntent(intent: Intent) { @@ -457,6 +511,7 @@ class MainActivity : FlutterActivity() { stashCall(intent, emit = true) } stashChatOpen(intent, emit = true) + stashShare(intent, emit = true) } private fun stashChatOpen(source: Intent?, emit: Boolean) { @@ -471,6 +526,34 @@ class MainActivity : FlutterActivity() { } } + private fun stashShare(source: Intent?, emit: Boolean) { + if (!ShareIntake.isShare(source)) return + val intent = source ?: return + val snapshot = ShareIntake.snapshot(intent) ?: return + intent.action = Intent.ACTION_MAIN + intent.removeExtra(Intent.EXTRA_STREAM) + intent.removeExtra(Intent.EXTRA_TEXT) + val task = shareExecutor.submit?> { + ShareIntake.materialize(applicationContext, snapshot) + } + if (!emit) { + pendingShareTask = task + return + } + shareExecutor.execute { + val payload = try { + task.get() + } catch (e: Exception) { + Log.w(SHARE_TAG, "materialize failed: $e") + null + } ?: return@execute + shareHandler.post { + val sink = ShareIntake.sink + if (sink != null) sink.success(payload) else pendingShare = payload + } + } + } + private fun stashCall(intent: Intent, emit: Boolean) { val json = intent.getStringExtra(CallConst.EXTRA_CALL) ?: return val action = intent.getStringExtra(CallConst.EXTRA_ACTION) ?: CallConst.ACTION_RING @@ -845,6 +928,7 @@ class MainActivity : FlutterActivity() { } override fun onDestroy() { + shareExecutor.shutdown() if (keepEngineAlive() && isFinishing) { Log.d("KometFcm", "task removed, caching engine (call=${CallState.inCall} fkm=${FkmState.enabled})") flutterEngine?.let { FlutterEngineCache.getInstance().put(KEEP_ENGINE_ID, it) } diff --git a/android/app/src/main/kotlin/ru/komet/app/ShareIntake.kt b/android/app/src/main/kotlin/ru/komet/app/ShareIntake.kt new file mode 100644 index 0000000..3bf9245 --- /dev/null +++ b/android/app/src/main/kotlin/ru/komet/app/ShareIntake.kt @@ -0,0 +1,154 @@ +package ru.komet.app + +import android.content.ContentResolver +import android.content.Context +import android.content.Intent +import android.net.Uri +import android.provider.OpenableColumns +import android.util.Log +import android.webkit.MimeTypeMap +import io.flutter.plugin.common.EventChannel +import java.io.File +import java.util.concurrent.atomic.AtomicLong + +object ShareIntake { + + private const val TAG = "ShareIntake" + private const val CACHE_DIR = "shared_in" + private const val MAX_FILES = 30 + + private val seq = AtomicLong(0L) + + @Volatile + var sink: EventChannel.EventSink? = null + + fun isShare(intent: Intent?): Boolean { + val action = intent?.action ?: return false + return action == Intent.ACTION_SEND || action == Intent.ACTION_SEND_MULTIPLE + } + + class Snapshot( + val uris: List, + val text: String?, + val subject: String?, + val intentType: String?, + ) + + fun snapshot(intent: Intent): Snapshot? { + val uris = collectUris(intent).take(MAX_FILES) + val text = intent.getCharSequenceExtra(Intent.EXTRA_TEXT)?.toString() + val subject = intent.getStringExtra(Intent.EXTRA_SUBJECT) + if (uris.isEmpty() && text.isNullOrBlank()) return null + return Snapshot(uris, text, subject, intent.type) + } + + fun materialize(context: Context, snapshot: Snapshot): Map? { + val files = ArrayList>() + for (uri in snapshot.uris) { + val copied = copyToCache(context, uri, snapshot.intentType) + if (copied != null) files.add(copied) + } + + if (files.isEmpty() && snapshot.text.isNullOrBlank()) return null + + return mapOf( + "files" to files, + "text" to snapshot.text, + "subject" to snapshot.subject, + ) + } + + fun clearCache(context: Context) { + try { + val dir = File(context.cacheDir, CACHE_DIR) + if (!dir.isDirectory) return + dir.listFiles()?.forEach { it.delete() } + } catch (e: Exception) { + Log.w(TAG, "cache cleanup failed: $e") + } + } + + private fun collectUris(intent: Intent): List { + if (intent.action == Intent.ACTION_SEND_MULTIPLE) { + val list = if (android.os.Build.VERSION.SDK_INT >= 33) { + intent.getParcelableArrayListExtra(Intent.EXTRA_STREAM, Uri::class.java) + } else { + @Suppress("DEPRECATION") + intent.getParcelableArrayListExtra(Intent.EXTRA_STREAM) + } + return list?.filterNotNull() ?: emptyList() + } + val single = if (android.os.Build.VERSION.SDK_INT >= 33) { + intent.getParcelableExtra(Intent.EXTRA_STREAM, Uri::class.java) + } else { + @Suppress("DEPRECATION") + intent.getParcelableExtra(Intent.EXTRA_STREAM) + } + return if (single != null) listOf(single) else emptyList() + } + + private fun copyToCache(context: Context, uri: Uri, intentType: String?): Map? { + val resolver = context.contentResolver + val mime = resolveMime(resolver, uri, intentType) + val displayName = queryDisplayName(resolver, uri) ?: fallbackName(uri, mime) + + return try { + val dir = File(context.cacheDir, CACHE_DIR).apply { mkdirs() } + val target = File(dir, "${System.currentTimeMillis()}_${seq.incrementAndGet()}_${sanitize(displayName)}") + resolver.openInputStream(uri).use { input -> + if (input == null) return null + target.outputStream().use { output -> input.copyTo(output) } + } + if (target.length() <= 0L) { + target.delete() + return null + } + mapOf( + "path" to target.absolutePath, + "name" to displayName, + "mime" to mime, + "size" to target.length(), + ) + } catch (e: Exception) { + Log.w(TAG, "cannot read $uri: $e") + null + } + } + + private fun resolveMime(resolver: ContentResolver, uri: Uri, intentType: String?): String { + val fromResolver = resolver.getType(uri) + if (!fromResolver.isNullOrBlank() && fromResolver != "*/*") return fromResolver + val ext = MimeTypeMap.getFileExtensionFromUrl(uri.toString()) + if (!ext.isNullOrBlank()) { + val guessed = MimeTypeMap.getSingleton().getMimeTypeFromExtension(ext.lowercase()) + if (!guessed.isNullOrBlank()) return guessed + } + if (!intentType.isNullOrBlank() && intentType != "*/*") return intentType + return "application/octet-stream" + } + + private fun queryDisplayName(resolver: ContentResolver, uri: Uri): String? { + if (uri.scheme == ContentResolver.SCHEME_FILE) return uri.lastPathSegment + return try { + resolver.query(uri, arrayOf(OpenableColumns.DISPLAY_NAME), null, null, null)?.use { cursor -> + val index = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME) + if (index >= 0 && cursor.moveToFirst()) cursor.getString(index) else null + } + } catch (e: Exception) { + Log.w(TAG, "display name for $uri: $e") + null + } + } + + private fun fallbackName(uri: Uri, mime: String): String { + val last = uri.lastPathSegment?.substringAfterLast('/') + if (!last.isNullOrBlank() && last.contains('.')) return last + val ext = MimeTypeMap.getSingleton().getExtensionFromMimeType(mime) ?: "bin" + return "shared_${System.currentTimeMillis()}.$ext" + } + + private fun sanitize(name: String): String { + val cleaned = name.replace(Regex("[^A-Za-z0-9._-]"), "_") + return if (cleaned.length <= 64) cleaned else cleaned.takeLast(64) + } +} diff --git a/android/app/src/main/res/values-ru/strings.xml b/android/app/src/main/res/values-ru/strings.xml index cd6cb9b..a198418 100644 --- a/android/app/src/main/res/values-ru/strings.xml +++ b/android/app/src/main/res/values-ru/strings.xml @@ -9,4 +9,5 @@ Это уведомление держит фоновое соединение с сервером, чтобы сообщения приходили без гугловых пушей. Убрать его можно, выключив FKM — кнопкой ниже или в Настройки → Уведомления → FKM. Выключить Удалено: + Отправить в чат diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index 862884a..230c6b5 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -11,4 +11,5 @@ This notification is what keeps a background connection to the server, so messages arrive without Google push. To get rid of it, turn FKM off — with the button below, or in Settings → Notifications → FKM. Turn off Deleted: + Send to a chat diff --git a/lib/backend/modules/share_sender.dart b/lib/backend/modules/share_sender.dart new file mode 100644 index 0000000..e9a1f56 --- /dev/null +++ b/lib/backend/modules/share_sender.dart @@ -0,0 +1,547 @@ +import 'dart:async'; +import 'dart:io'; + +import '../../core/media/gallery_source.dart'; +import '../../core/media/share_thumbnail.dart'; +import '../../core/media/video_transcoder.dart'; +import '../../core/storage/app_database.dart'; +import '../../core/utils/logger.dart'; +import '../../models/attachment.dart'; +import '../../models/chat_preview_media.dart'; +import '../../models/shared_payload.dart'; +import '../../main.dart'; +import 'chats.dart'; +import 'messages.dart'; +import 'upload_service.dart'; + +class PreparedShareFile { + final SharedFile source; + final String? thumbDataUri; + final int? width; + final int? height; + final int? durationMs; + + const PreparedShareFile({ + required this.source, + this.thumbDataUri, + this.width, + this.height, + this.durationMs, + }); + + SharedFileKind get kind => source.kind; + File get file => source.file; +} + +class PreparedShare { + final List files; + final String? text; + + const PreparedShare({required this.files, this.text}); + + List get photos => + files.where((f) => f.kind == SharedFileKind.photo).toList(); + + List get videos => + files.where((f) => f.kind == SharedFileKind.video).toList(); + + List get documents => + files.where((f) => f.kind == SharedFileKind.file).toList(); + + bool get isTextOnly => files.isEmpty; + + static Future prepare(SharedPayload payload) async { + final prepared = []; + for (final source in payload.files) { + prepared.add(await _prepareOne(source)); + } + return PreparedShare(files: prepared, text: payload.text); + } + + static Future _prepareOne(SharedFile source) async { + final thumb = await sharedThumbnailDataUri(source); + switch (source.kind) { + case SharedFileKind.photo: + final dim = await imageFileDimensions(source.file); + return PreparedShareFile( + source: source, + thumbDataUri: thumb, + width: dim?.$1, + height: dim?.$2, + ); + case SharedFileKind.video: + VideoInfo? info; + try { + info = await VideoTranscoder.probe(source.path); + } catch (e) { + logger.w('Поделиться: probe ${source.path}: $e'); + } + return PreparedShareFile( + source: source, + thumbDataUri: thumb, + width: (info?.width ?? 0) > 0 ? info!.width : null, + height: (info?.height ?? 0) > 0 ? info!.height : null, + durationMs: (info?.durationMs ?? 0) > 0 ? info!.durationMs : null, + ); + case SharedFileKind.file: + return PreparedShareFile(source: source); + } + } +} + +class ShareSendResult { + final int chatCount; + final int messageCount; + + const ShareSendResult({required this.chatCount, required this.messageCount}); +} + +class ShareSender { + ShareSender._(); + + static final Map< + String, + ({int accountId, int chatId, String text, String? preview, int time}) + > + _tracked = {}; + + static StreamSubscription? _sub; + + static void _track( + String tempId, { + required int accountId, + required int chatId, + required String text, + required String? preview, + required int time, + }) { + _sub ??= UploadService.instance.events.listen(_onUploadEvent); + _tracked[tempId] = ( + accountId: accountId, + chatId: chatId, + text: text, + preview: preview, + time: time, + ); + } + + static void _onUploadEvent(UploadJobEvent event) { + final entry = _tracked.remove(event.tempId); + if (entry == null) return; + final status = event is UploadJobDone ? 'sent' : 'error'; + final messageId = event is UploadJobDone + ? (event.message?.id ?? event.tempId) + : event.tempId; + unawaited( + chats + .applyOutgoing( + entry.accountId, + entry.chatId, + messageId: messageId, + time: entry.time, + text: entry.text, + status: status, + preview: entry.preview, + ) + .catchError((Object e) { + logger.w('Поделиться: не обновить превью чата ${entry.chatId}: $e'); + }), + ); + } + + static Future send({ + required int accountId, + required List chatIds, + required PreparedShare share, + required String caption, + }) async { + var messages = 0; + for (final chatId in chatIds) { + messages += await _sendToChat( + accountId: accountId, + chatId: chatId, + share: share, + caption: caption, + ); + } + logger.i( + 'Поделиться: отправлено $messages сообщений в ${chatIds.length} чатов', + ); + return ShareSendResult(chatCount: chatIds.length, messageCount: messages); + } + + static Future _sendToChat({ + required int accountId, + required int chatId, + required PreparedShare share, + required String caption, + }) async { + if (share.isTextOnly) { + final text = caption.trim().isNotEmpty + ? caption + : (share.text ?? '').trim(); + if (text.isEmpty) return 0; + await _sendText(accountId: accountId, chatId: chatId, text: text); + return 1; + } + + final photos = share.photos; + final videos = share.videos; + final documents = share.documents; + + var used = false; + String take() { + if (used || caption.isEmpty) return ''; + used = true; + return caption; + } + + var count = 0; + if (photos.isNotEmpty) { + await _sendPhotos( + accountId: accountId, + chatId: chatId, + photos: photos, + caption: take(), + ); + count++; + } + for (final video in videos) { + await _sendVideo( + accountId: accountId, + chatId: chatId, + video: video, + caption: take(), + ); + count++; + } + for (final document in documents) { + await _sendDocument( + accountId: accountId, + chatId: chatId, + document: document, + caption: take(), + ); + count++; + } + return count; + } + + static Future _sendText({ + required int accountId, + required int chatId, + required String text, + }) async { + final now = DateTime.now().millisecondsSinceEpoch; + final tempId = UploadService.instance.newTempId(); + final placeholder = CachedMessage( + id: tempId, + accountId: accountId, + chatId: chatId, + senderId: accountId, + text: text, + time: now, + status: 'sending', + ); + await _persist(placeholder); + await _bumpChat( + accountId: accountId, + chatId: chatId, + messageId: tempId, + time: now, + text: text, + preview: null, + status: 'sending', + ); + + String realId = tempId; + var status = 'sent'; + try { + final sent = await messagesModule.sendMessage(accountId, chatId, text); + if (sent.isNotEmpty) realId = sent; + } catch (e) { + logger.w('Поделиться: текст в $chatId не ушёл: $e'); + status = 'error'; + } + final settled = CachedMessage( + id: realId, + accountId: accountId, + chatId: chatId, + senderId: accountId, + text: text, + time: now, + status: status, + ); + await _persist(settled, removeId: realId == tempId ? null : tempId); + await _bumpChat( + accountId: accountId, + chatId: chatId, + messageId: realId, + time: now, + text: text, + preview: null, + status: status, + ); + } + + static Future _sendPhotos({ + required int accountId, + required int chatId, + required List photos, + required String caption, + }) async { + final now = DateTime.now().millisecondsSinceEpoch; + final tempId = UploadService.instance.newTempId(); + + final jobs = <({File file, GalleryItem? item})>[]; + final attachments = []; + for (final photo in photos) { + jobs.add((file: photo.file, item: null)); + attachments.add( + PhotoAttachment( + localPath: photo.file.path, + previewData: photo.thumbDataUri, + width: photo.width, + height: photo.height, + ), + ); + } + if (jobs.isEmpty) return; + + final label = photos.length > 1 ? 'Изображения' : 'Изображение'; + final preview = _preview( + kind: ChatPreviewKind.photo, + files: photos, + label: caption.isEmpty ? label : null, + ); + final placeholder = CachedMessage( + id: tempId, + accountId: accountId, + chatId: chatId, + senderId: accountId, + text: caption.isEmpty ? null : caption, + time: now, + status: 'sending', + attachments: attachments, + ); + + await _persist(placeholder); + final text = caption.isEmpty ? label : caption; + await _bumpChat( + accountId: accountId, + chatId: chatId, + messageId: tempId, + time: now, + text: text, + preview: preview, + status: 'sending', + ); + _track( + tempId, + accountId: accountId, + chatId: chatId, + text: text, + preview: preview, + time: now, + ); + + unawaited( + UploadService.instance.sendPhotos( + accountId: accountId, + chatId: chatId, + tempId: tempId, + jobs: jobs, + caption: caption, + placeholder: placeholder, + ), + ); + } + + static Future _sendVideo({ + required int accountId, + required int chatId, + required PreparedShareFile video, + required String caption, + }) async { + final now = DateTime.now().millisecondsSinceEpoch; + final tempId = UploadService.instance.newTempId(); + + final preview = _preview( + kind: ChatPreviewKind.video, + files: [video], + label: caption.isEmpty ? 'Видео' : null, + ); + final placeholder = CachedMessage( + id: tempId, + accountId: accountId, + chatId: chatId, + senderId: accountId, + text: caption.isEmpty ? null : caption, + time: now, + status: 'sending', + attachments: [ + VideoAttachment( + localPath: video.file.path, + previewData: video.thumbDataUri, + width: video.width, + height: video.height, + duration: video.durationMs, + ), + ], + ); + + await _persist(placeholder); + final text = caption.isEmpty ? 'Видео' : caption; + await _bumpChat( + accountId: accountId, + chatId: chatId, + messageId: tempId, + time: now, + text: text, + preview: preview, + status: 'sending', + ); + _track( + tempId, + accountId: accountId, + chatId: chatId, + text: text, + preview: preview, + time: now, + ); + + unawaited( + UploadService.instance.sendVideo( + accountId: accountId, + chatId: chatId, + tempId: tempId, + file: video.file, + caption: caption, + placeholder: placeholder, + ), + ); + } + + static Future _sendDocument({ + required int accountId, + required int chatId, + required PreparedShareFile document, + required String caption, + }) async { + final now = DateTime.now().millisecondsSinceEpoch; + final tempId = UploadService.instance.newTempId(); + final name = document.source.name; + final size = document.source.size; + + final preview = ChatPreviewMedia( + kind: ChatPreviewKind.file, + label: caption.isEmpty ? 'Файл' : null, + detail: caption.isEmpty ? name : null, + ).encode(); + final placeholder = CachedMessage( + id: tempId, + accountId: accountId, + chatId: chatId, + senderId: accountId, + text: caption.isEmpty ? null : caption, + time: now, + status: 'sending', + attachments: [FileAttachment(name: name, size: size)], + ); + + await _persist(placeholder); + final text = caption.isEmpty ? 'Файл: $name' : caption; + await _bumpChat( + accountId: accountId, + chatId: chatId, + messageId: tempId, + time: now, + text: text, + preview: preview, + status: 'sending', + ); + _track( + tempId, + accountId: accountId, + chatId: chatId, + text: text, + preview: preview, + time: now, + ); + + unawaited( + UploadService.instance.sendFile( + accountId: accountId, + chatId: chatId, + tempId: tempId, + source: document.file, + filename: name, + size: size, + placeholder: placeholder, + ), + ); + } + + static String? _preview({ + required ChatPreviewKind kind, + required List files, + String? label, + }) { + final thumbs = []; + for (final file in files) { + final data = file.thumbDataUri; + if (data == null) continue; + thumbs.add( + ChatPreviewThumb( + source: data, + video: file.kind == SharedFileKind.video, + ), + ); + if (thumbs.length >= 3) break; + } + if (thumbs.isEmpty && label == null) return null; + return ChatPreviewMedia(kind: kind, thumbs: thumbs, label: label).encode(); + } + + static Future _persist( + CachedMessage message, { + String? removeId, + }) async { + try { + if (removeId != null && removeId != message.id) { + await AppDatabase.deleteMessage( + message.accountId, + message.chatId, + removeId, + ); + } + await AppDatabase.saveMessages([message.toDbRow()]); + } catch (e) { + logger.w('Поделиться: не сохранить плейсхолдер: $e'); + } + } + + static Future _bumpChat({ + required int accountId, + required int chatId, + required String messageId, + required int time, + required String text, + required String? preview, + required String status, + }) async { + try { + await chats.applyOutgoing( + accountId, + chatId, + messageId: messageId, + time: time, + text: text, + status: status, + preview: preview, + ); + } catch (e) { + logger.w('Поделиться: не обновить строку чата $chatId: $e'); + } + } +} diff --git a/lib/core/media/share_thumbnail.dart b/lib/core/media/share_thumbnail.dart new file mode 100644 index 0000000..ff8676b --- /dev/null +++ b/lib/core/media/share_thumbnail.dart @@ -0,0 +1,95 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/widgets.dart' show ImageProvider, MemoryImage; +import 'package:flutter/services.dart' show MissingPluginException; +import 'package:image/image.dart' as img; + +import '../../models/shared_payload.dart'; +import '../utils/logger.dart'; +import 'video_transcoder.dart'; + +const int _thumbMaxDimension = 128; +const int _thumbQuality = 70; + +Future sharedThumbnailDataUri(SharedFile source) async { + switch (source.kind) { + case SharedFileKind.photo: + return _photoThumb(source.file); + case SharedFileKind.video: + return _videoThumb(source.file); + case SharedFileKind.file: + return null; + } +} + +Future _photoThumb(File file) async { + Uint8List bytes; + try { + bytes = await file.readAsBytes(); + } catch (e) { + logger.w('Поделиться: не прочитать ${file.path}: $e'); + return null; + } + final jpeg = await compute(_encodeThumbIsolate, bytes); + return _asDataUri(jpeg); +} + +Future _videoThumb(File file) async { + try { + final frames = await VideoTranscoder.frames(file.path, const [ + 0, + ], size: _thumbMaxDimension); + if (frames.isEmpty) return null; + return _asDataUri(frames.first); + } on MissingPluginException { + return null; + } catch (e) { + logger.w('Поделиться: не взять кадр из ${file.path}: $e'); + return null; + } +} + +String? _asDataUri(Uint8List? bytes) { + if (bytes == null || bytes.isEmpty) return null; + return 'data:image/jpeg;base64,${base64Encode(bytes)}'; +} + +Uint8List? _encodeThumbIsolate(Uint8List bytes) { + final decoded = img.decodeImage(bytes); + if (decoded == null) return null; + final oriented = img.bakeOrientation(decoded); + final longest = oriented.width >= oriented.height + ? oriented.width + : oriented.height; + final scaled = longest > _thumbMaxDimension + ? img.copyResize( + oriented, + width: oriented.width >= oriented.height ? _thumbMaxDimension : null, + height: oriented.height > oriented.width ? _thumbMaxDimension : null, + interpolation: img.Interpolation.average, + ) + : oriented; + return img.encodeJpg(scaled, quality: _thumbQuality); +} + +final Map _sharedThumbCache = {}; + +ImageProvider? decodeSharedThumb(String? dataUri) { + if (dataUri == null || dataUri.isEmpty) return null; + final cached = _sharedThumbCache[dataUri]; + if (cached != null) return cached; + final comma = dataUri.indexOf(','); + if (comma < 0) return null; + try { + final provider = MemoryImage(base64Decode(dataUri.substring(comma + 1))); + if (_sharedThumbCache.length >= 32) { + _sharedThumbCache.remove(_sharedThumbCache.keys.first); + } + _sharedThumbCache[dataUri] = provider; + return provider; + } catch (_) { + return null; + } +} diff --git a/lib/core/share/share_intent_bridge.dart b/lib/core/share/share_intent_bridge.dart new file mode 100644 index 0000000..631a554 --- /dev/null +++ b/lib/core/share/share_intent_bridge.dart @@ -0,0 +1,114 @@ +import 'dart:async'; +import 'dart:io' show Platform; + +import 'package:flutter/services.dart'; + +import '../../backend/api.dart'; +import '../../frontend/screens/chats/chat_list_screen.dart'; +import '../../frontend/widgets/swipe_route.dart'; +import '../../main.dart'; +import '../../models/shared_payload.dart'; +import '../utils/logger.dart'; + +class ShareIntentBridge { + ShareIntentBridge._(); + static final ShareIntentBridge instance = ShareIntentBridge._(); + + static const _method = MethodChannel('ru.komet.app/share'); + static const _events = EventChannel('ru.komet.app/share_events'); + static const _retryDelay = Duration(milliseconds: 300); + static const _maxRetries = 100; + + bool _started = false; + bool _ready = false; + bool _presenting = false; + SharedPayload? _pending; + int _retriesLeft = 0; + Timer? _retry; + + bool get _native { + try { + return Platform.isAndroid; + } catch (_) { + return false; + } + } + + void init() { + if (_started || !_native) return; + _started = true; + _events.receiveBroadcastStream().listen( + _onEvent, + onError: (e) => logger.w('ShareIntentBridge: events stream error: $e'), + ); + api.stateStream.listen((state) { + if (state == SessionState.online) _flushPending(); + }); + } + + void markReady() { + _ready = true; + _flushPending(); + } + + Future checkInitialShare() async { + if (!_native) return; + try { + _onEvent(await _method.invokeMethod('consumeInitialShare')); + } catch (e) { + logger.w('ShareIntentBridge.checkInitialShare: $e'); + } + } + + Future clearCache() async { + if (!_native) return; + try { + await _method.invokeMethod('clearCache'); + } catch (e) { + logger.w('ShareIntentBridge.clearCache: $e'); + } + } + + void _onEvent(Object? event) { + final payload = SharedPayload.fromMap(event); + if (payload == null) return; + logger.i( + 'Поделиться: получено ${payload.files.length} файлов' + '${payload.text != null ? ' и текст' : ''}', + ); + _pending = payload; + _retriesLeft = _maxRetries; + _flushPending(); + } + + void _flushPending() { + final payload = _pending; + if (payload == null || _presenting) return; + + final context = KometApp.navigatorKey.currentContext; + if (!_ready || context == null || api.state != SessionState.online) { + if (_retriesLeft <= 0) { + _pending = null; + return; + } + _retriesLeft--; + _retry ??= Timer(_retryDelay, () { + _retry = null; + _flushPending(); + }); + return; + } + + _pending = null; + _presenting = true; + unawaited( + pushSwipeable( + context, + (_) => ChatListScreen(sharePayload: payload), + ).whenComplete(() { + _presenting = false; + unawaited(clearCache()); + }), + ); + } +} diff --git a/lib/core/share/share_labels.dart b/lib/core/share/share_labels.dart new file mode 100644 index 0000000..cb8103a --- /dev/null +++ b/lib/core/share/share_labels.dart @@ -0,0 +1,36 @@ +import '../utils/format.dart'; + +String shareTitleFor({ + required int photos, + required int videos, + required int documents, + bool textOnly = false, +}) { + if (textOnly) return 'Отправить сообщение'; + final total = photos + videos + documents; + if (total == 0) return 'Отправить сообщение'; + + if (photos == total) { + return photos == 1 + ? 'Отправить фотографию' + : 'Отправить $photos ' + '${pluralRu(photos, 'фотографию', 'фотографии', 'фотографий')}'; + } + if (videos == total) { + return videos == 1 ? 'Отправить видео' : 'Отправить $videos видео'; + } + if (documents == total) { + return documents == 1 + ? 'Отправить файл' + : 'Отправить $documents ' + '${pluralRu(documents, 'файл', 'файла', 'файлов')}'; + } + return 'Отправить $total ${pluralRu(total, 'файл', 'файла', 'файлов')}'; +} + +String shareSubtitleFor(List recipientNames) { + if (recipientNames.isEmpty) return 'Выберите чат'; + if (recipientNames.length <= 2) return 'В чат ${recipientNames.join(', ')}'; + final count = recipientNames.length; + return 'В $count ${pluralRu(count, 'чат', 'чата', 'чатов')}'; +} diff --git a/lib/frontend/screens/chats/chat_list_screen.dart b/lib/frontend/screens/chats/chat_list_screen.dart index aa47a3e..0dc2a4f 100644 --- a/lib/frontend/screens/chats/chat_list_screen.dart +++ b/lib/frontend/screens/chats/chat_list_screen.dart @@ -25,7 +25,12 @@ import '../../widgets/swipe_route.dart'; import '../../widgets/sliding_pill_nav.dart'; import '../../widgets/springy_tap.dart'; import '../../widgets/informer_banner_tile.dart'; +import '../../../backend/modules/share_sender.dart'; +import '../../../core/utils/logger.dart'; import '../../../core/utils/format.dart'; +import '../../../models/shared_payload.dart'; +import '../../widgets/rich_message_controller.dart'; +import 'share_composer_bar.dart'; import '../../../core/utils/download_history.dart'; import '../../../core/utils/link_opener.dart'; import '../../../core/utils/text_format.dart'; @@ -159,6 +164,7 @@ class ChatListScreen extends StatefulWidget { final bool forwardMode; final int forwardMessageCount; final bool archiveMode; + final SharedPayload? sharePayload; const ChatListScreen({ super.key, @@ -166,6 +172,7 @@ class ChatListScreen extends StatefulWidget { this.forwardMode = false, this.forwardMessageCount = 1, this.archiveMode = false, + this.sharePayload, }); static _ChatListScreenState? _root; @@ -252,7 +259,8 @@ class _ChatListScreenState extends State bool _reloadQueued = false; bool _reloadInFlight = false; Timer? _settleTimer; - bool get _isSelectionMode => _selectedChats.isNotEmpty; + bool get _shareMode => widget.sharePayload != null; + bool get _isSelectionMode => !_shareMode && _selectedChats.isNotEmpty; bool? _foldersListKnown; late AnimationController _navPageAnimController; @@ -265,6 +273,10 @@ class _ChatListScreenState extends State final List _folderChatScrollListenerFns = []; final Set _selectedChats = {}; final Set _inflightContactIds = {}; + final Map _selectedChatNames = {}; + RichMessageController? _shareCaption; + PreparedShare? _preparedShare; + bool _shareSending = false; DateTime _storiesRevealLayoutSettleUntil = DateTime.fromMillisecondsSinceEpoch(0); @@ -328,6 +340,107 @@ class _ChatListScreenState extends State }); } + void _initShare() { + final payload = widget.sharePayload; + if (payload == null) return; + _shareCaption = RichMessageController( + text: payload.isTextOnly ? (payload.text ?? '') : '', + ); + unawaited( + PreparedShare.prepare(payload).then((prepared) { + if (mounted) setState(() => _preparedShare = prepared); + }), + ); + } + + void _toggleShareTarget(String chatId, String name) { + Haptics.selection(); + setState(() { + if (_selectedChats.remove(chatId)) { + _selectedChatNames.remove(chatId); + } else { + _selectedChats.add(chatId); + _selectedChatNames[chatId] = name; + } + }); + } + + List get _shareRecipientNames => [ + for (final id in _selectedChats) _selectedChatNames[id] ?? 'Чат', + ]; + + Future _sendShare(String caption) async { + final prepared = _preparedShare; + final myId = _profile?.id ?? 0; + if (prepared == null || myId == 0 || _selectedChats.isEmpty) return; + if (_shareSending) return; + + final targets = []; + for (final raw in _selectedChats) { + final id = int.tryParse(raw); + if (id != null) targets.add(id); + } + if (targets.isEmpty) return; + + setState(() => _shareSending = true); + Haptics.send(); + + ShareSendResult? result; + try { + result = await ShareSender.send( + accountId: myId, + chatIds: targets, + share: prepared, + caption: caption, + ); + } catch (e) { + logger.w('Поделиться: отправка не удалась: $e'); + } + + if (!mounted) return; + setState(() => _shareSending = false); + + if (result == null) { + Haptics.error(); + showCustomNotification(context, 'Не удалось отправить'); + return; + } + + final navigator = Navigator.of(context); + if (targets.length == 1) { + final chatId = targets.first; + final chat = _chats.where((c) => c.id == chatId).firstOrNull; + navigator.pop(); + unawaited( + pushSwipeable( + navigator.context, + (_) => ChatScreen( + chatId: chatId, + name: _selectedChatNames[chatId.toString()] ?? chat?.title ?? 'Чат', + imageUrl: chat?.iconUrl ?? '', + chatType: chat?.type ?? 'DIALOG', + ), + ), + ); + return; + } + navigator.pop(); + } + + Widget _buildShareComposer(ColorScheme cs) { + final prepared = _preparedShare; + final controller = _shareCaption; + if (prepared == null || controller == null) return const SizedBox.shrink(); + if (_selectedChats.isEmpty) return const SizedBox.shrink(); + return ShareComposerBar( + share: prepared, + controller: controller, + recipientNames: _shareRecipientNames, + sending: _shareSending, + onSend: _sendShare, + ); + } + void _clearSelection() { setState(() { _selectedChats.clear(); @@ -600,7 +713,10 @@ class _ChatListScreenState extends State @override void initState() { super.initState(); - if (!widget.forwardMode && !widget.archiveMode) ChatListScreen._root = this; + if (!widget.forwardMode && !widget.archiveMode && !_shareMode) { + ChatListScreen._root = this; + } + _initShare(); _fabController = AnimationController( vsync: this, duration: const Duration(milliseconds: 350), @@ -1349,6 +1465,7 @@ class _ChatListScreenState extends State @override void dispose() { if (ChatListScreen._root == this) ChatListScreen._root = null; + _shareCaption?.dispose(); appRouteObserver.unsubscribe(this); _settleTimer?.cancel(); chats.chatsChanged.removeListener(_onChatsChanged); @@ -1574,7 +1691,27 @@ class _ChatListScreenState extends State Expanded( child: Row( children: [ + if (_shareMode) + Padding( + padding: const EdgeInsets.only( + right: 4, + ), + child: IconButton( + key: const ValueKey('share-back'), + visualDensity: + VisualDensity.compact, + icon: Icon( + Symbols.arrow_back, + color: cs.onSurface, + weight: 500, + ), + onPressed: () => Navigator.of( + context, + ).maybePop(), + ), + ), if (AppStories.current.value && + !_shareMode && _pullRatio < 0.8 && storiesModule.hasAny) Opacity( @@ -1612,10 +1749,15 @@ class _ChatListScreenState extends State ), Flexible( child: Text( - connectionStatusLabel( - _sessionState, - ) ?? - (_profile?.firstName ?? 'Чат'), + _shareMode && + _selectedChats.isNotEmpty + ? '${_selectedChats.length} ' + '${pluralRu(_selectedChats.length, 'получатель', 'получателя', 'получателей')}' + : connectionStatusLabel( + _sessionState, + ) ?? + (_profile?.firstName ?? + 'Чат'), maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle( @@ -1634,7 +1776,8 @@ class _ChatListScreenState extends State mainAxisSize: MainAxisSize.min, children: [ if (!widget.forwardMode && - !widget.archiveMode) + !widget.archiveMode && + !_shareMode) IconButton( key: const ValueKey('downloads-button'), tooltip: AppLocalizations.of( @@ -1691,7 +1834,9 @@ class _ChatListScreenState extends State padding: const EdgeInsets.fromLTRB(20, 3, 20, 8), child: GestureDetector( behavior: HitTestBehavior.opaque, - onTap: widget.forwardMode ? null : _openSearch, + onTap: (widget.forwardMode || _shareMode) + ? null + : _openSearch, child: GlossyPill( color: cs.surfaceContainerHighest, borderRadius: BorderRadius.circular(50), @@ -2187,6 +2332,14 @@ class _ChatListScreenState extends State if (widget.forwardMode) { return _getChatsBody(); } + if (_shareMode) { + return Column( + children: [ + Expanded(child: _getChatsBody()), + _buildShareComposer(cs), + ], + ); + } final bottomInset = MediaQuery.viewPaddingOf(context).bottom; final pageW = constraints.maxWidth; final pageH = constraints.maxHeight; @@ -2981,6 +3134,10 @@ class _ChatListScreenState extends State ); return; } + if (_shareMode) { + _toggleShareTarget(id, name); + return; + } if (_isSelectionMode) { _toggleSelection(id); return; @@ -3018,7 +3175,9 @@ class _ChatListScreenState extends State ); } }, - onLongPress: widget.forwardMode ? null : () => _toggleSelection(id), + onLongPress: (widget.forwardMode || _shareMode) + ? null + : () => _toggleSelection(id), child: AnimatedContainer( duration: const Duration(milliseconds: 200), color: isSelected diff --git a/lib/frontend/screens/chats/share_composer_bar.dart b/lib/frontend/screens/chats/share_composer_bar.dart new file mode 100644 index 0000000..5b52386 --- /dev/null +++ b/lib/frontend/screens/chats/share_composer_bar.dart @@ -0,0 +1,355 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:material_symbols_icons/symbols.dart'; + +import '../../../backend/modules/share_sender.dart'; +import '../../../core/media/share_thumbnail.dart'; +import '../../../core/share/share_labels.dart'; +import '../../../main.dart'; +import '../../../models/animoji.dart'; +import '../../../models/shared_payload.dart'; +import '../../widgets/emoji_panel.dart'; +import '../../widgets/rich_message_controller.dart'; +import '../../widgets/small_spinner.dart'; +import '../../widgets/springy_tap.dart'; + +class ShareComposerBar extends StatefulWidget { + const ShareComposerBar({ + super.key, + required this.share, + required this.controller, + required this.recipientNames, + required this.onSend, + this.sending = false, + }); + + final PreparedShare share; + final RichMessageController controller; + final List recipientNames; + final Future Function(String caption) onSend; + final bool sending; + + @override + State createState() => _ShareComposerBarState(); +} + +class _ShareComposerBarState extends State { + static const double _emojiPanelHeight = 280; + static const Duration _panelDuration = Duration(milliseconds: 220); + + final FocusNode _focus = FocusNode(); + bool _emojiOpen = false; + + RichMessageController get _controller => widget.controller; + + @override + void initState() { + super.initState(); + _focus.addListener(_onFocus); + } + + @override + void dispose() { + _focus.removeListener(_onFocus); + _focus.dispose(); + super.dispose(); + } + + void _onFocus() { + if (_focus.hasFocus && _emojiOpen) setState(() => _emojiOpen = false); + } + + void _toggleEmoji() { + if (_emojiOpen) { + setState(() => _emojiOpen = false); + return; + } + _focus.unfocus(); + setState(() => _emojiOpen = true); + } + + void _insertAnimoji(Animoji animoji) { + _controller.insertAnimoji(animoji); + unawaited(animojiModule.noteUsed(animoji)); + } + + Future _send() async { + if (widget.sending) return; + await widget.onSend(_controller.buildContent().text.trim()); + } + + String get _title { + final share = widget.share; + return shareTitleFor( + photos: share.photos.length, + videos: share.videos.length, + documents: share.documents.length, + textOnly: share.isTextOnly, + ); + } + + String get _subtitle => shareSubtitleFor(widget.recipientNames); + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + final bottomInset = MediaQuery.viewInsetsOf(context).bottom; + final safeBottom = MediaQuery.paddingOf(context).bottom; + + return Container( + decoration: BoxDecoration( + color: cs.surfaceContainerHigh, + borderRadius: const BorderRadius.vertical(top: Radius.circular(20)), + ), + padding: EdgeInsets.only(bottom: _emojiOpen ? 0 : bottomInset), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + if (!widget.share.isTextOnly) _buildPreviewRow(cs), + _buildInputRow(cs), + AnimatedSize( + duration: _panelDuration, + curve: Curves.easeOutCubic, + child: _emojiOpen + ? SizedBox( + height: _emojiPanelHeight + safeBottom, + child: Padding( + padding: EdgeInsets.only(bottom: safeBottom), + child: EmojiPanel(onEmojiTap: _insertAnimoji), + ), + ) + : SizedBox(height: bottomInset > 0 ? 0 : safeBottom), + ), + ], + ), + ); + } + + Widget _buildPreviewRow(ColorScheme cs) { + return Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 8), + child: Row( + children: [ + Icon(Symbols.forward, color: cs.primary, size: 22, weight: 500), + const SizedBox(width: 12), + _ShareThumbStack(files: widget.share.files), + const SizedBox(width: 12), + Expanded( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + _title, + style: TextStyle( + color: cs.primary, + fontSize: 15, + fontWeight: FontWeight.w600, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 2), + Text( + _subtitle, + style: TextStyle(color: cs.onSurfaceVariant, fontSize: 13), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + ], + ), + ); + } + + Widget _buildInputRow(ColorScheme cs) { + final count = widget.recipientNames.length; + return Padding( + padding: const EdgeInsets.fromLTRB(8, 0, 12, 8), + child: Row( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + IconButton( + onPressed: _toggleEmoji, + icon: Icon( + Symbols.mood, + color: _emojiOpen ? cs.primary : cs.onSurfaceVariant, + size: 26, + fill: _emojiOpen ? 1 : 0, + ), + ), + Expanded( + child: ConstrainedBox( + constraints: const BoxConstraints(maxHeight: 120), + child: TextField( + controller: _controller, + focusNode: _focus, + minLines: 1, + maxLines: null, + textCapitalization: TextCapitalization.sentences, + keyboardType: TextInputType.multiline, + style: TextStyle(color: cs.onSurface, fontSize: 16), + decoration: InputDecoration( + isDense: true, + border: InputBorder.none, + hintText: widget.share.isTextOnly + ? 'Сообщение' + : 'Добавить подпись...', + hintStyle: TextStyle( + color: cs.onSurfaceVariant, + fontSize: 16, + ), + ), + ), + ), + ), + const SizedBox(width: 8), + _SendButton( + count: count, + sending: widget.sending, + onTap: count == 0 ? null : _send, + ), + ], + ), + ); + } +} + +class _ShareThumbStack extends StatelessWidget { + const _ShareThumbStack({required this.files}); + + final List files; + + static const double _size = 40; + static const double _step = 9; + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + final visible = files.take(3).toList(); + final width = _size + _step * (visible.length - 1).clamp(0, 2); + + return SizedBox( + width: width, + height: _size, + child: Stack( + children: [ + for (var i = visible.length - 1; i >= 0; i--) + Positioned( + left: i * _step, + child: _ShareThumb(file: visible[i], size: _size, cs: cs), + ), + ], + ), + ); + } +} + +class _ShareThumb extends StatelessWidget { + const _ShareThumb({required this.file, required this.size, required this.cs}); + + final PreparedShareFile file; + final double size; + final ColorScheme cs; + + @override + Widget build(BuildContext context) { + final provider = decodeSharedThumb(file.thumbDataUri); + return Container( + width: size, + height: size, + decoration: BoxDecoration( + color: cs.surfaceContainerHighest, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: cs.surfaceContainerHigh, width: 2), + ), + clipBehavior: Clip.antiAlias, + child: provider != null + ? Image(image: provider, fit: BoxFit.cover) + : Icon( + file.kind == SharedFileKind.video + ? Symbols.movie + : Symbols.description, + size: 20, + color: cs.onSurfaceVariant, + ), + ); + } +} + +class _SendButton extends StatelessWidget { + const _SendButton({ + required this.count, + required this.sending, + required this.onTap, + }); + + final int count; + final bool sending; + final VoidCallback? onTap; + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + final enabled = onTap != null && !sending; + + return SpringyTap( + child: GestureDetector( + onTap: enabled ? onTap : null, + child: Stack( + clipBehavior: Clip.none, + children: [ + Container( + width: 52, + height: 52, + decoration: BoxDecoration( + color: enabled ? cs.primary : cs.surfaceContainerHighest, + shape: BoxShape.circle, + ), + child: sending + ? Padding( + padding: const EdgeInsets.all(14), + child: SmallSpinner(size: 24, color: cs.onPrimary), + ) + : Icon( + Symbols.send, + color: enabled ? cs.onPrimary : cs.onSurfaceVariant, + size: 24, + fill: 1, + ), + ), + if (count > 0 && !sending) + Positioned( + right: -2, + top: -2, + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 6, + vertical: 2, + ), + constraints: const BoxConstraints(minWidth: 20), + decoration: BoxDecoration( + color: cs.surfaceContainerHigh, + borderRadius: BorderRadius.circular(10), + border: Border.all(color: cs.primary, width: 1.5), + ), + child: Text( + '$count', + textAlign: TextAlign.center, + style: TextStyle( + color: cs.primary, + fontSize: 12, + fontWeight: FontWeight.w700, + ), + ), + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/main.dart b/lib/main.dart index 75cb592..559dd58 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -77,6 +77,7 @@ import 'core/links/deep_link_service.dart'; import 'frontend/screens/calls/call_screen.dart'; import 'core/push/fkm_controller.dart'; import 'core/push/notification_bridge.dart'; +import 'core/share/share_intent_bridge.dart'; import 'core/push/push_service.dart'; import 'core/storage/app_database.dart'; import 'core/transport/tls_config.dart'; @@ -429,6 +430,7 @@ class KometAppState extends State if (status == LoginStatus.success) { DeepLinkService.instance.markReady(); NotificationBridge.instance.markReady(); + ShareIntentBridge.instance.markReady(); unawaited(_refreshWallpaperSeed()); CallController.instance.init(api); OutboxService.instance.init(api, messagesModule); @@ -448,9 +450,11 @@ class KometAppState extends State CallController.instance.appResumed = true; CallBridge.instance.init(); NotificationBridge.instance.init(); + ShareIntentBridge.instance.init(); WidgetsBinding.instance.addPostFrameCallback((_) { CallBridge.instance.checkInitialCall(); unawaited(NotificationBridge.instance.checkInitialChat()); + unawaited(ShareIntentBridge.instance.checkInitialShare()); }); _sessionExpiredSub = api.sessionExpiredStream.listen(( @@ -622,6 +626,7 @@ class KometAppState extends State SelfCheckService.instance.resume(); CallBridge.instance.checkInitialCall(); unawaited(NotificationBridge.instance.checkInitialChat()); + unawaited(ShareIntentBridge.instance.checkInitialShare()); if (AppThemeModeConfig.current.value != AppThemeMode.schedule) return; _rescheduleSwitch(); final next = _effectiveThemeMode; diff --git a/lib/models/shared_payload.dart b/lib/models/shared_payload.dart new file mode 100644 index 0000000..1d906dc --- /dev/null +++ b/lib/models/shared_payload.dart @@ -0,0 +1,100 @@ +import 'dart:io'; + +enum SharedFileKind { photo, video, file } + +class SharedFile { + final String path; + final String name; + final String mime; + final int size; + + const SharedFile({ + required this.path, + required this.name, + required this.mime, + required this.size, + }); + + static SharedFile? fromMap(Map map) { + final path = map['path']; + if (path is! String || path.isEmpty) return null; + final name = map['name']; + final mime = map['mime']; + final size = map['size']; + return SharedFile( + path: path, + name: name is String && name.isNotEmpty ? name : _basename(path), + mime: mime is String && mime.isNotEmpty + ? mime + : 'application/octet-stream', + size: size is int ? size : 0, + ); + } + + File get file => File(path); + + SharedFileKind get kind { + if (mime.startsWith('image/') && !mime.contains('svg')) { + return SharedFileKind.photo; + } + if (mime.startsWith('video/')) return SharedFileKind.video; + return SharedFileKind.file; + } + + static String _basename(String path) { + final idx = path.lastIndexOf(Platform.pathSeparator); + return idx < 0 ? path : path.substring(idx + 1); + } +} + +class SharedPayload { + final List files; + final String? text; + final String? subject; + + const SharedPayload({this.files = const [], this.text, this.subject}); + + static SharedPayload? fromMap(Object? raw) { + if (raw is! Map) return null; + final rawFiles = raw['files']; + final files = []; + if (rawFiles is List) { + for (final entry in rawFiles) { + if (entry is! Map) continue; + final file = SharedFile.fromMap(entry); + if (file != null && file.file.existsSync()) files.add(file); + } + } + final text = raw['text']; + final subject = raw['subject']; + final payload = SharedPayload( + files: files, + text: text is String && text.trim().isNotEmpty ? text.trim() : null, + subject: subject is String && subject.trim().isNotEmpty + ? subject.trim() + : null, + ); + return payload.isEmpty ? null : payload; + } + + bool get isEmpty => files.isEmpty && text == null; + + bool get isTextOnly => files.isEmpty && text != null; + + List get photos => + files.where((f) => f.kind == SharedFileKind.photo).toList(); + + List get videos => + files.where((f) => f.kind == SharedFileKind.video).toList(); + + List get documents => + files.where((f) => f.kind == SharedFileKind.file).toList(); + + SharedFileKind? get dominantKind { + if (files.isEmpty) return null; + if (documents.isNotEmpty) return SharedFileKind.file; + if (videos.isNotEmpty && photos.isEmpty) return SharedFileKind.video; + if (photos.isNotEmpty && videos.isEmpty) return SharedFileKind.photo; + return SharedFileKind.photo; + } +} diff --git a/test/share_composer_bar_test.dart b/test/share_composer_bar_test.dart new file mode 100644 index 0000000..aa77c81 --- /dev/null +++ b/test/share_composer_bar_test.dart @@ -0,0 +1,131 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:material_symbols_icons/symbols.dart'; +import 'package:komet/backend/modules/share_sender.dart'; +import 'package:komet/frontend/screens/chats/share_composer_bar.dart'; +import 'package:komet/frontend/widgets/rich_message_controller.dart'; +import 'package:komet/models/shared_payload.dart'; + +PreparedShareFile _file(String name, String mime) => PreparedShareFile( + source: SharedFile(path: '/synthetic/$name', name: name, mime: mime, size: 8), +); + +Future _pump( + WidgetTester tester, { + required PreparedShare share, + required List recipients, + bool sending = false, + Future Function(String)? onSend, +}) async { + final controller = RichMessageController(); + addTearDown(controller.dispose); + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Align( + alignment: Alignment.bottomCenter, + child: ShareComposerBar( + share: share, + controller: controller, + recipientNames: recipients, + sending: sending, + onSend: onSend ?? (_) async {}, + ), + ), + ), + ), + ); + await tester.pump(); + return controller; +} + +void main() { + testWidgets('a single photo names itself and lists the recipient', ( + tester, + ) async { + await _pump( + tester, + share: PreparedShare(files: [_file('a.jpg', 'image/jpeg')]), + recipients: const ['ЛУКА'], + ); + + expect(find.text('Отправить фотографию'), findsOneWidget); + expect(find.text('В чат ЛУКА'), findsOneWidget); + expect(find.text('Добавить подпись...'), findsOneWidget); + }); + + testWidgets('three chats collapse into a count and a badge', (tester) async { + await _pump( + tester, + share: PreparedShare( + files: [ + _file('a.jpg', 'image/jpeg'), + _file('b.jpg', 'image/jpeg'), + _file('c.jpg', 'image/jpeg'), + ], + ), + recipients: const ['a', 'b', 'c'], + ); + + expect(find.text('Отправить 3 фотографии'), findsOneWidget); + expect(find.text('В 3 чата'), findsOneWidget); + expect(find.text('3'), findsOneWidget); + }); + + testWidgets('a text share drops the preview row', (tester) async { + final controller = await _pump( + tester, + share: const PreparedShare(files: [], text: 'https://komet.pw'), + recipients: const ['ЛУКА'], + ); + + expect(find.text('Отправить сообщение'), findsNothing); + expect(find.text('Сообщение'), findsOneWidget); + expect(controller.text, ''); + }); + + testWidgets('a file share still shows a title but no photo wording', ( + tester, + ) async { + await _pump( + tester, + share: PreparedShare(files: [_file('doc.pdf', 'application/pdf')]), + recipients: const ['ЛУКА', 'Zarub'], + ); + + expect(find.text('Отправить файл'), findsOneWidget); + expect(find.text('В чат ЛУКА, Zarub'), findsOneWidget); + }); + + testWidgets('the caption reaches onSend', (tester) async { + String? captured; + final controller = await _pump( + tester, + share: PreparedShare(files: [_file('a.jpg', 'image/jpeg')]), + recipients: const ['ЛУКА'], + onSend: (caption) async => captured = caption, + ); + + controller.text = ' привет '; + await tester.pump(); + await tester.tap(find.byIcon(Symbols.send)); + await tester.pump(); + + expect(captured, 'привет'); + }); + + testWidgets('with no recipients the send button is inert', (tester) async { + var sent = false; + await _pump( + tester, + share: PreparedShare(files: [_file('a.jpg', 'image/jpeg')]), + recipients: const [], + onSend: (_) async => sent = true, + ); + + expect(find.text('Выберите чат'), findsOneWidget); + await tester.tap(find.byIcon(Symbols.send)); + await tester.pump(); + expect(sent, isFalse); + }); +} diff --git a/test/share_intent_test.dart b/test/share_intent_test.dart new file mode 100644 index 0000000..b9d7d0a --- /dev/null +++ b/test/share_intent_test.dart @@ -0,0 +1,164 @@ +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:komet/core/share/share_labels.dart'; +import 'package:komet/models/shared_payload.dart'; + +late Directory _dir; + +String _makeFile(String name, {int bytes = 8}) { + final file = File('${_dir.path}${Platform.pathSeparator}$name') + ..writeAsBytesSync(List.filled(bytes, 0x41)); + return file.path; +} + +Map _entry(String path, String mime, {int size = 8}) => { + 'path': path, + 'name': path.split(Platform.pathSeparator).last, + 'mime': mime, + 'size': size, +}; + +void main() { + setUp(() { + _dir = Directory.systemTemp.createTempSync('synthetic_share_test'); + addTearDown(() { + if (_dir.existsSync()) _dir.deleteSync(recursive: true); + }); + }); + + group('SharedPayload.fromMap', () { + test('classifies photos, videos and documents by mime', () { + final payload = SharedPayload.fromMap({ + 'files': [ + _entry(_makeFile('a.jpg'), 'image/jpeg'), + _entry(_makeFile('b.mp4'), 'video/mp4'), + _entry(_makeFile('c.pdf'), 'application/pdf'), + ], + 'text': null, + }); + + expect(payload, isNotNull); + expect(payload!.photos.map((f) => f.name), ['a.jpg']); + expect(payload.videos.map((f) => f.name), ['b.mp4']); + expect(payload.documents.map((f) => f.name), ['c.pdf']); + expect(payload.dominantKind, SharedFileKind.file); + }); + + test('an svg is a document, not a photo', () { + final payload = SharedPayload.fromMap({ + 'files': [_entry(_makeFile('d.svg'), 'image/svg+xml')], + }); + + expect(payload!.files.single.kind, SharedFileKind.file); + }); + + test('drops entries whose file is gone', () { + final payload = SharedPayload.fromMap({ + 'files': [ + _entry(_makeFile('present.jpg'), 'image/jpeg'), + _entry('${_dir.path}/missing.jpg', 'image/jpeg'), + ], + }); + + expect(payload!.files.map((f) => f.name), ['present.jpg']); + }); + + test('a text-only share survives with no files', () { + final payload = SharedPayload.fromMap({ + 'files': const [], + 'text': ' https://komet.pw ', + }); + + expect(payload!.isTextOnly, isTrue); + expect(payload.text, 'https://komet.pw'); + }); + + test('an empty share is rejected', () { + expect(SharedPayload.fromMap({'files': const [], 'text': ' '}), isNull); + expect(SharedPayload.fromMap(null), isNull); + expect(SharedPayload.fromMap('nonsense'), isNull); + }); + + test('a missing mime falls back to a document', () { + final payload = SharedPayload.fromMap({ + 'files': [ + {'path': _makeFile('e.bin'), 'name': 'e.bin', 'size': 8}, + ], + }); + + expect(payload!.files.single.mime, 'application/octet-stream'); + expect(payload.files.single.kind, SharedFileKind.file); + }); + }); + + group('shareTitleFor', () { + test('photos use Russian plural forms', () { + expect( + shareTitleFor(photos: 1, videos: 0, documents: 0), + 'Отправить фотографию', + ); + expect( + shareTitleFor(photos: 3, videos: 0, documents: 0), + 'Отправить 3 фотографии', + ); + expect( + shareTitleFor(photos: 5, videos: 0, documents: 0), + 'Отправить 5 фотографий', + ); + expect( + shareTitleFor(photos: 11, videos: 0, documents: 0), + 'Отправить 11 фотографий', + ); + }); + + test('videos stay uninflected', () { + expect( + shareTitleFor(photos: 0, videos: 1, documents: 0), + 'Отправить видео', + ); + expect( + shareTitleFor(photos: 0, videos: 2, documents: 0), + 'Отправить 2 видео', + ); + }); + + test('documents and mixed sets fall back to file wording', () { + expect( + shareTitleFor(photos: 0, videos: 0, documents: 1), + 'Отправить файл', + ); + expect( + shareTitleFor(photos: 0, videos: 0, documents: 4), + 'Отправить 4 файла', + ); + expect( + shareTitleFor(photos: 1, videos: 1, documents: 0), + 'Отправить 2 файла', + ); + }); + + test('a text share has no media wording', () { + expect( + shareTitleFor(photos: 0, videos: 0, documents: 0, textOnly: true), + 'Отправить сообщение', + ); + }); + }); + + group('shareSubtitleFor', () { + test('names are listed up to two recipients', () { + expect(shareSubtitleFor(const ['ЛУКА']), 'В чат ЛУКА'); + expect(shareSubtitleFor(const ['ЛУКА', 'Zarub']), 'В чат ЛУКА, Zarub'); + }); + + test('three or more recipients collapse to a count', () { + expect(shareSubtitleFor(const ['a', 'b', 'c']), 'В 3 чата'); + expect(shareSubtitleFor(List.filled(5, 'x')), 'В 5 чатов'); + }); + + test('an empty selection asks for one', () { + expect(shareSubtitleFor(const []), 'Выберите чат'); + }); + }); +}