diff --git a/lib/app/layouts/conversation_view/widgets/header/cupertino_header.dart b/lib/app/layouts/conversation_view/widgets/header/cupertino_header.dart index eb9e263dac..8bfb341194 100644 --- a/lib/app/layouts/conversation_view/widgets/header/cupertino_header.dart +++ b/lib/app/layouts/conversation_view/widgets/header/cupertino_header.dart @@ -22,6 +22,8 @@ import 'package:flutter/services.dart'; import 'package:get/get.dart'; import 'package:universal_io/io.dart'; import 'package:bluebubbles/src/rust/api/api.dart' as api; +import 'package:collection/collection.dart'; +import 'package:bluebubbles/utils/logger/logger.dart'; class CupertinoHeader extends StatelessWidget implements PreferredSizeWidget { const CupertinoHeader({Key? key, required this.controller}); @@ -456,6 +458,14 @@ class _ChatIconAndTitleState extends CustomState<_ChatIconAndTitle, void, Conver late StreamSubscription sub2; + // --- FIND MY FRIENDS CITY/STATE --- + String? shortAddress; + bool isLoadingFindMy = false; + + static final Map _findMyCache = {}; + static const _findMyCacheTtl = Duration(minutes: 5); + + @override void initState() { super.initState(); @@ -477,6 +487,9 @@ class _ChatIconAndTitleState extends CustomState<_ChatIconAndTitle, void, Conver title = controller.chat.getTitle(); cachedGuid = controller.chat.guid; + // --- FindMy integration --- + fetchShortAddress(); + // run query after render has completed if (!kIsWeb) { updateObx(() { @@ -522,6 +535,66 @@ class _ChatIconAndTitleState extends CustomState<_ChatIconAndTitle, void, Conver } } + Future fetchShortAddress() async { + // Only fetch for 1-on-1 chats + if (controller.chat.isGroup) return; + if (pushService.state == null) return; + if (pushService.state!.icloudServices == null) return; + + final handle = controller.chat.participants.firstOrNull?.address; + if (handle == null) return; + + final cached = _findMyCache[handle]; + + if (cached != null && DateTime.now().difference(cached.$2) < _findMyCacheTtl) { + setState(() { + shortAddress = cached.$1; + isLoadingFindMy = false; + }); + return; + } + + setState(() => isLoadingFindMy = true); + + try { + // Create a Find My Friends client using the current push state + final fmfClient = await api.makeFindMyFriends( + path: pushService.statePath, + config: pushService.state!.osConfig, + aps: pushService.state!.conn, + anisette: pushService.state!.anisette, + provider: pushService.state!.icloudServices!.tokenProvider, + ); + + // Fetch the current following/friends list + final following = await api.getFollowing(client: fmfClient); + + // Try to match on any known handle for the friend + final friend = following.firstWhereOrNull( + (f) => f.invitationAcceptedHandles.any((h) => h.toLowerCase() == handle.toLowerCase()), + ); + + String? cityState; + if (friend != null && friend.lastLocation?.address != null) { + final addr = friend.lastLocation!.address!; + // E.g. "San Francisco, CA" or fallback to "Country" if stateCode is missing + if (addr.locality != null && (addr.stateCode != null || addr.countryCode != null)) { + cityState = "${addr.locality}, ${addr.stateCode ?? addr.countryCode}"; + } + } + + _findMyCache[handle] = (cityState, DateTime.now()); + + setState(() { + shortAddress = cityState; + isLoadingFindMy = false; + }); + } catch (e) { + Logger.error("Failed to fetch FindMy location in convo view", error: e); + setState(() => isLoadingFindMy = false); + } + } + @override void dispose() { sub.cancel(); @@ -569,6 +642,23 @@ class _ChatIconAndTitleState extends CustomState<_ChatIconAndTitle, void, Conver color: context.theme.colorScheme.outline, ), ]), + if (isLoadingFindMy) + Padding( + padding: const EdgeInsets.only(top: 2.0, left: 6.0), + child: SizedBox( + height: 12, + width: 12, + child: CircularProgressIndicator(strokeWidth: 2), + ), + ) + else if (shortAddress != null && shortAddress!.isNotEmpty) + Padding( + padding: const EdgeInsets.only(top: 2.0, left: 6.0), + child: Text( + shortAddress!, + style: context.theme.textTheme.bodySmall?.copyWith(color: context.theme.colorScheme.outline), + ), + ), ]; if (context.orientation == Orientation.landscape && Platform.isAndroid) { diff --git a/lib/app/layouts/conversation_view/widgets/media_picker/sticker_picker.dart b/lib/app/layouts/conversation_view/widgets/media_picker/sticker_picker.dart new file mode 100644 index 0000000000..6ee19f1fb9 --- /dev/null +++ b/lib/app/layouts/conversation_view/widgets/media_picker/sticker_picker.dart @@ -0,0 +1,277 @@ +import 'dart:typed_data'; + +import 'package:bluebubbles/app/wrappers/stateful_boilerplate.dart'; +import 'package:bluebubbles/helpers/helpers.dart'; +import 'package:bluebubbles/database/models.dart'; +import 'package:bluebubbles/services/services.dart'; +import 'package:bluebubbles/utils/logger/logger.dart'; +import 'package:flutter/cupertino.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:mime_type/mime_type.dart'; +import 'package:path/path.dart' hide context; +import 'package:universal_io/io.dart'; + +class StickerPicker extends StatefulWidget { + StickerPicker({ + super.key, + required this.controller, + }); + final ConversationViewController controller; + + @override + State createState() => _StickerPickerState(); +} + +class _StickerPickerState extends OptimizedState { + List _stickers = []; + bool _loading = true; + + @override + void initState() { + super.initState(); + loadStickers(); + } + + Future loadStickers() async { + try { + final stickerDir = await fs.stickersDirectory; + final dir = Directory(stickerDir); + if (await dir.exists()) { + final entities = dir.listSync(); + _stickers = entities + .whereType() + .where((f) { + final mimeType = mime(f.path); + return mimeType != null && mimeType.startsWith('image/'); + }) + .toList(); + // Sort by most recently modified first + _stickers.sort((a, b) => b.lastModifiedSync().compareTo(a.lastModifiedSync())); + } + } catch (e) { + Logger.error('Failed to load stickers', error: e); + } + _loading = false; + setState(() {}); + } + + @override + Widget build(BuildContext context) { + if (_loading) { + return SizedBox( + height: 300, + child: Center(child: buildProgressIndicator(context)), + ); + } + + if (_stickers.isEmpty) { + return SizedBox( + height: 300, + child: Center( + child: Padding( + padding: const EdgeInsets.all(20.0), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + iOS ? CupertinoIcons.smiley : Icons.emoji_emotions_outlined, + size: 48, + color: context.theme.colorScheme.outline, + ), + const SizedBox(height: 12), + Text( + 'No stickers saved yet', + style: context.theme.textTheme.bodyLarge?.copyWith( + color: context.theme.colorScheme.outline, + ), + ), + const SizedBox(height: 4), + Text( + 'Save images as stickers from the attachment viewer,\nor add image files to the stickers folder.', + textAlign: TextAlign.center, + style: context.theme.textTheme.bodySmall?.copyWith( + color: context.theme.colorScheme.outline, + ), + ), + ], + ), + ), + ), + ); + } + + return SizedBox( + height: 300, + child: Padding( + padding: const EdgeInsets.all(10.0), + child: CustomScrollView( + scrollDirection: Axis.horizontal, + slivers: [ + SliverGrid( + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 2, + crossAxisSpacing: 10, + mainAxisSpacing: 10, + ), + delegate: SliverChildBuilderDelegate( + childCount: _stickers.length, + (context, index) { + return _StickerPickerFile( + file: _stickers[index], + controller: widget.controller, + onTap: () async { + final file = _stickers[index]; + final bytes = await file.readAsBytes(); + final name = basename(file.path); + + // Check if already selected — deselect + if (widget.controller.pickedAttachments.firstWhereOrNull( + (e) => e.path == file.path) != + null) { + widget.controller.pickedAttachments + .removeWhere((e) => e.path == file.path); + // Clear sticker flag if no attachments remain + if (widget.controller.pickedAttachments.isEmpty) { + widget.controller.isStickerSend = false; + } + } else { + widget.controller.pickedAttachments.add(PlatformFile( + path: file.path, + name: name, + size: bytes.length, + )); + widget.controller.isStickerSend = true; + } + }, + ); + }, + ), + ), + ], + ), + ), + ); + } +} + +class _StickerPickerFile extends StatefulWidget { + _StickerPickerFile({ + required this.file, + required this.controller, + required this.onTap, + }); + final File file; + final ConversationViewController controller; + final Function() onTap; + + @override + State<_StickerPickerFile> createState() => _StickerPickerFileState(); +} + +class _StickerPickerFileState extends OptimizedState<_StickerPickerFile> + with AutomaticKeepAliveClientMixin { + Uint8List? image; + + @override + void initState() { + super.initState(); + load(); + } + + Future load() async { + try { + final path = widget.file.path; + final mimeType = mime(path); + if (mimeType == 'image/heic' || + mimeType == 'image/heif' || + mimeType == 'image/tif' || + mimeType == 'image/tiff') { + final fakeAttachment = Attachment( + transferName: path, + mimeType: mimeType!, + ); + image = await as.loadAndGetProperties(fakeAttachment, + actualPath: path, onlyFetchData: true, isPreview: true); + } else { + image = await widget.file.readAsBytes(); + } + setState(() {}); + } catch (e) { + Logger.error('Failed to load sticker thumbnail', error: e); + } + } + + @override + Widget build(BuildContext context) { + super.build(context); + return Obx(() { + bool containsThis = widget.controller.pickedAttachments + .firstWhereOrNull((e) => e.path == widget.file.path) != + null; + return AnimatedContainer( + duration: const Duration(milliseconds: 250), + margin: EdgeInsets.all(containsThis ? 10 : 0), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(10), + ), + clipBehavior: Clip.antiAlias, + child: InkWell( + borderRadius: BorderRadius.circular(10), + onTap: widget.onTap, + child: Stack( + alignment: Alignment.center, + children: [ + if (image != null) + Image.memory( + image!, + fit: BoxFit.cover, + width: 150, + height: 150, + cacheWidth: 300, + frameBuilder: + (context, child, frame, wasSynchronouslyLoaded) { + if (frame == null) { + return Positioned.fill( + child: Container( + color: context.theme.colorScheme.properSurface, + ), + ); + } else { + return child; + } + }, + ), + if (image == null) + Positioned.fill( + child: Container( + color: context.theme.colorScheme.properSurface, + alignment: Alignment.center, + child: buildProgressIndicator(context), + ), + ), + if (containsThis) + Container( + decoration: BoxDecoration( + shape: BoxShape.circle, + color: context.theme.colorScheme.primary), + child: Padding( + padding: const EdgeInsets.all(5.0), + child: Icon( + iOS ? CupertinoIcons.check_mark : Icons.check, + color: context.theme.colorScheme.onPrimary, + size: 18, + ), + ), + ), + ], + ), + ), + ); + }); + } + + @override + bool get wantKeepAlive => true; +} diff --git a/lib/app/layouts/conversation_view/widgets/media_picker/text_field_attachment_picker.dart b/lib/app/layouts/conversation_view/widgets/media_picker/text_field_attachment_picker.dart index 1148bf67af..cf167039ba 100644 --- a/lib/app/layouts/conversation_view/widgets/media_picker/text_field_attachment_picker.dart +++ b/lib/app/layouts/conversation_view/widgets/media_picker/text_field_attachment_picker.dart @@ -27,6 +27,7 @@ import 'package:permission_handler/permission_handler.dart'; import 'package:photo_manager/photo_manager.dart'; import 'package:collection/collection.dart'; import 'package:bluebubbles/helpers/types/constants.dart' as constants; +import 'package:bluebubbles/app/layouts/conversation_view/widgets/media_picker/sticker_picker.dart'; class AttachmentPicker extends StatefulWidget { AttachmentPicker({ @@ -47,6 +48,7 @@ class AttachmentPickerState extends OptimizedState { List> iconsList = []; App? currentApp; + bool showStickerPicker = false; void generateIcons() { iconsList = [ @@ -277,6 +279,15 @@ class AttachmentPickerState extends OptimizedState { } } }, + { + "icon": iOS ? CupertinoIcons.smiley : Icons.emoji_emotions_outlined, + "text": "Stickers", + "handle": () { + setState(() { + showStickerPicker = true; + }); + } + }, ]; if(!controller.chat.isIMessage) return; @@ -386,6 +397,39 @@ class AttachmentPickerState extends OptimizedState { @override Widget build(BuildContext context) { + if (showStickerPicker) { + return Stack( + children: [ + StickerPicker(controller: controller), + Positioned( + top: 5, + left: 5, + child: GestureDetector( + onTap: () { + setState(() { + showStickerPicker = false; + // Clear sticker state so regular photos don't send as stickers + controller.isStickerSend = false; + controller.pickedAttachments.clear(); + }); + }, + child: Container( + padding: const EdgeInsets.all(6), + decoration: BoxDecoration( + color: context.theme.colorScheme.properSurface.withOpacity(0.8), + shape: BoxShape.circle, + ), + child: Icon( + iOS ? CupertinoIcons.back : Icons.arrow_back, + size: 20, + color: context.theme.colorScheme.properOnSurface, + ), + ), + ), + ), + ], + ); + } if (currentApp != null) { return SizedBox( height: 300, diff --git a/lib/app/layouts/conversation_view/widgets/message/attachment/sticker_holder.dart b/lib/app/layouts/conversation_view/widgets/message/attachment/sticker_holder.dart index a79ffc7402..35ac00affd 100644 --- a/lib/app/layouts/conversation_view/widgets/message/attachment/sticker_holder.dart +++ b/lib/app/layouts/conversation_view/widgets/message/attachment/sticker_holder.dart @@ -2,10 +2,13 @@ import 'dart:async'; import 'package:bluebubbles/app/wrappers/stateful_boilerplate.dart'; import 'package:bluebubbles/database/models.dart'; +import 'package:bluebubbles/helpers/helpers.dart'; import 'package:bluebubbles/services/services.dart'; import 'package:bluebubbles/utils/logger/logger.dart'; +import 'package:collection/collection.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_image_compress/flutter_image_compress.dart'; import 'package:universal_io/io.dart'; class StickerHolder extends StatefulWidget { @@ -53,23 +56,47 @@ class _StickerHolderState extends OptimizedState with AutomaticKe } Future checkImage(Message message, Attachment attachment) async { - final pathName = attachment.path; - // Check via the image package to make sure this is a valid, render-able image - // final image = await compute(decodeIsolate, PlatformFile( - // path: pathName, - // name: attachment.transferName!, - // bytes: attachment.bytes, - // size: attachment.totalBytes ?? 0, - // ), - // ); - final bytes = await File(pathName).readAsBytes(); - var stickerData = message.attributedBody.firstOrNull?.runs - .firstWhere((element) => element.attributes?.attachmentGuid == attachment.guid).attributes?.stickerData; - controller.stickerData[message.guid!] = { - attachment.guid!: (bytes, stickerData) - }; - Logger.debug("sticker count ${controller.stickerData.length}"); - setState(() {}); + try { + String pathName = attachment.path; + + // Check for HEIC and use converted PNG if available, or convert + if (attachment.mimeType?.contains('image/hei') == true) { + final pngPath = "$pathName.png"; + if (await File(pngPath).exists()) { + pathName = pngPath; + } else if (!kIsDesktop) { + final file = await FlutterImageCompress.compressAndGetFile( + pathName, + pngPath, + format: CompressFormat.png, + keepExif: true, + quality: 100, + ); + if (file != null) { + pathName = pngPath; + } + } + } + + // Check via the image package to make sure this is a valid, render-able image + // final image = await compute(decodeIsolate, PlatformFile( + // path: pathName, + // name: attachment.transferName!, + // bytes: attachment.bytes, + // size: attachment.totalBytes ?? 0, + // ), + // ); + final bytes = await File(pathName).readAsBytes(); + var stickerData = message.attributedBody.firstOrNull?.runs + .firstWhereOrNull((element) => element.attributes?.attachmentGuid == attachment.guid)?.attributes?.stickerData; + controller.stickerData[message.guid!] = { + attachment.guid!: (bytes, stickerData) + }; + Logger.debug("sticker count ${controller.stickerData.length}"); + setState(() {}); + } catch (e, stack) { + Logger.error("Failed to load sticker image", error: e, trace: stack); + } } @override @@ -110,6 +137,9 @@ class _StickerHolderState extends OptimizedState with AutomaticKe gaplessPlayback: true, cacheHeight: 200, filterQuality: FilterQuality.none, + errorBuilder: (context, error, stackTrace) { + return const SizedBox.shrink(); + }, ), scale: e.$2?.scale ?? 1, ), diff --git a/lib/app/layouts/conversation_view/widgets/message/popup/details_menu_action.dart b/lib/app/layouts/conversation_view/widgets/message/popup/details_menu_action.dart index 01cd2044d6..25b97bd56a 100644 --- a/lib/app/layouts/conversation_view/widgets/message/popup/details_menu_action.dart +++ b/lib/app/layouts/conversation_view/widgets/message/popup/details_menu_action.dart @@ -34,6 +34,7 @@ enum DetailsMenuAction { Bookmark, SelectMultiple, MessageInfo, + SaveAsSticker, } class PlatformSupport { @@ -70,6 +71,7 @@ const Map _actionPlatformSupport = { DetailsMenuAction.Bookmark: PlatformSupport(true, true, true, true), DetailsMenuAction.SelectMultiple: PlatformSupport(true, true, true, true), DetailsMenuAction.MessageInfo: PlatformSupport(true, true, true, true), + DetailsMenuAction.SaveAsSticker: PlatformSupport(true, true, true, false), }; const Map _actionToIcon = { @@ -97,6 +99,7 @@ const Map _actionToIcon = { DetailsMenuAction.Bookmark: (CupertinoIcons.bookmark, Icons.bookmark_outlined), DetailsMenuAction.SelectMultiple: (CupertinoIcons.checkmark_square, Icons.check_box_outlined), DetailsMenuAction.MessageInfo: (CupertinoIcons.info, Icons.info), + DetailsMenuAction.SaveAsSticker: (CupertinoIcons.smiley, Icons.emoji_emotions_outlined), }; const Map _actionToText = { @@ -124,6 +127,7 @@ const Map _actionToText = { DetailsMenuAction.Bookmark: "Add/Remove Bookmark", DetailsMenuAction.SelectMultiple: "Select Multiple", DetailsMenuAction.MessageInfo: "Message Info", + DetailsMenuAction.SaveAsSticker: "Save as Sticker", }; class _DetailsMenuActionUtils { diff --git a/lib/app/layouts/conversation_view/widgets/message/popup/message_popup.dart b/lib/app/layouts/conversation_view/widgets/message/popup/message_popup.dart index 11c6a11216..e1f720d33c 100644 --- a/lib/app/layouts/conversation_view/widgets/message/popup/message_popup.dart +++ b/lib/app/layouts/conversation_view/widgets/message/popup/message_popup.dart @@ -672,6 +672,28 @@ class _MessagePopupState extends OptimizedState with SingleTickerP } } + Future saveAsSticker() async { + try { + dynamic content; + if (isEmbeddedMedia) { + content = PlatformFile( + name: basename(message.interactiveMediaPath!), + path: message.interactiveMediaPath, + size: 0, + ); + } else { + content = as.getContent(part.attachments.first); + } + if (content is PlatformFile) { + popDetails(); + await as.saveAsSticker(content); + } + } catch (ex, trace) { + Logger.error("Error saving sticker: ${ex.toString()}", error: ex, trace: trace); + showSnackbar("Save Error", ex.toString()); + } + } + void openLink() { String? url = part.url; mcs.invokeMethod("open-browser", {"link": url ?? part.text}); @@ -1204,6 +1226,11 @@ class _MessagePopupState extends OptimizedState with SingleTickerP onTap: download, action: DetailsMenuAction.Save, ), + if (showDownload && !kIsWeb && part.attachments.isNotEmpty && part.attachments.first.mimeStart == "image") + DetailsMenuActionWidget( + onTap: saveAsSticker, + action: DetailsMenuAction.SaveAsSticker, + ), if ((part.text?.hasUrl ?? false) && !kIsWeb && !kIsDesktop && !ls.isBubble) DetailsMenuActionWidget( onTap: openLink, diff --git a/lib/app/layouts/conversation_view/widgets/message/reaction/reaction.dart b/lib/app/layouts/conversation_view/widgets/message/reaction/reaction.dart index 060c32cf83..ab987bf801 100644 --- a/lib/app/layouts/conversation_view/widgets/message/reaction/reaction.dart +++ b/lib/app/layouts/conversation_view/widgets/message/reaction/reaction.dart @@ -8,10 +8,12 @@ import 'package:bluebubbles/helpers/helpers.dart'; import 'package:bluebubbles/database/database.dart'; import 'package:bluebubbles/database/models.dart'; import 'package:bluebubbles/services/services.dart'; +import 'package:bluebubbles/utils/logger/logger.dart'; import 'package:defer_pointer/defer_pointer.dart'; import 'package:flutter/cupertino.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_image_compress/flutter_image_compress.dart'; import 'package:flutter_svg/svg.dart'; import 'package:get/get.dart'; import 'package:universal_io/io.dart'; @@ -89,20 +91,44 @@ class ReactionWidgetState extends OptimizedState { } Future checkImage(Attachment attachment) async { - final pathName = attachment.path; - // Check via the image package to make sure this is a valid, render-able image - // final image = await compute(decodeIsolate, PlatformFile( - // path: pathName, - // name: attachment.transferName!, - // bytes: attachment.bytes, - // size: attachment.totalBytes ?? 0, - // ), - // ); - final bytes = await File(pathName).readAsBytes(); - controller!.stickerData[reaction.guid!] = { - attachment.guid!: (bytes, null) - }; - setState(() {}); + try { + String pathName = attachment.path; + + // Check for HEIC and use converted PNG if available, or convert + if (attachment.mimeType?.contains('image/hei') == true) { + final pngPath = "$pathName.png"; + if (await File(pngPath).exists()) { + pathName = pngPath; + } else if (!kIsDesktop) { + final file = await FlutterImageCompress.compressAndGetFile( + pathName, + pngPath, + format: CompressFormat.png, + keepExif: true, + quality: 100, + ); + if (file != null) { + pathName = pngPath; + } + } + } + + // Check via the image package to make sure this is a valid, render-able image + // final image = await compute(decodeIsolate, PlatformFile( + // path: pathName, + // name: attachment.transferName!, + // bytes: attachment.bytes, + // size: attachment.totalBytes ?? 0, + // ), + // ); + final bytes = await File(pathName).readAsBytes(); + controller!.stickerData[reaction.guid!] = { + attachment.guid!: (bytes, null) + }; + setState(() {}); + } catch (e, stack) { + Logger.error("Failed to load reaction sticker image", error: e, trace: stack); + } } void updateReaction() async { @@ -208,6 +234,9 @@ class ReactionWidgetState extends OptimizedState { gaplessPlayback: true, cacheHeight: 200, filterQuality: FilterQuality.none, + errorBuilder: (context, error, stackTrace) { + return const SizedBox.shrink(); + }, ), ) : const SizedBox.shrink(); } @@ -273,6 +302,9 @@ class ReactionWidgetState extends OptimizedState { gaplessPlayback: true, cacheHeight: 200, filterQuality: FilterQuality.none, + errorBuilder: (context, error, stackTrace) { + return const SizedBox.shrink(); + }, ), ) : const SizedBox.shrink(); } diff --git a/lib/app/layouts/conversation_view/widgets/message/send_animation.dart b/lib/app/layouts/conversation_view/widgets/message/send_animation.dart index 5f4d73957d..ef3db68980 100644 --- a/lib/app/layouts/conversation_view/widgets/message/send_animation.dart +++ b/lib/app/layouts/conversation_view/widgets/message/send_animation.dart @@ -77,6 +77,11 @@ class _SendAnimationState String data = await DefaultAssetBundle.of(Get.context!).loadString("assets/rustpush/uti-map.json"); final utiMap = jsonDecode(data); + final isSticker = controller.isStickerSend; + final stickerBundleId = isSticker + ? "com.apple.Stickers.UserGenerated.MessagesExtension" + : null; + final message = Message( text: "", dateCreated: DateTime.now(), @@ -99,8 +104,8 @@ class _SendAnimationState threadOriginatorPart: i == 0 ? replyRun : null, expressiveSendStyleId: effectId, payloadData: payload, - balloonBundleId: payload?.bundleId, - stagingGuid: payload != null ? uuid.v4().toUpperCase() : null, + balloonBundleId: stickerBundleId ?? payload?.bundleId, + stagingGuid: (payload != null || isSticker) ? uuid.v4().toUpperCase() : null, ); message.generateTempGuid(); message.attachments.first!.guid = message.guid; diff --git a/lib/app/layouts/conversation_view/widgets/text_field/conversation_text_field.dart b/lib/app/layouts/conversation_view/widgets/text_field/conversation_text_field.dart index 72feba45a8..313f39cd4a 100644 --- a/lib/app/layouts/conversation_view/widgets/text_field/conversation_text_field.dart +++ b/lib/app/layouts/conversation_view/widgets/text_field/conversation_text_field.dart @@ -351,6 +351,7 @@ class ConversationTextFieldState extends CustomState with Automat await as.saveToDisk(widget.file); }, ), + if (!kIsWeb) + Padding( + padding: const EdgeInsets.only(left: 20.0), + child: FloatingActionButton( + backgroundColor: context.theme.colorScheme.secondary, + child: Icon( + Icons.emoji_emotions_outlined, + color: context.theme.colorScheme.onSecondary, + ), + onPressed: () async { + await as.saveAsSticker(widget.file); + }, + ), + ), if (!kIsWeb && !kIsDesktop) Padding( padding: const EdgeInsets.only(left: 20.0), @@ -160,6 +174,13 @@ class _FullscreenImageState extends OptimizedState with Automat color: samsung ? Colors.white : context.theme.colorScheme.primary, ), label: 'Download'), + if (!kIsWeb) + NavigationDestination( + icon: Icon( + iOS ? CupertinoIcons.smiley : Icons.emoji_emotions_outlined, + color: samsung ? Colors.white : context.theme.colorScheme.primary, + ), + label: 'Save as Sticker'), if (!kIsWeb && !kIsDesktop) NavigationDestination( icon: Icon( @@ -183,20 +204,35 @@ class _FullscreenImageState extends OptimizedState with Automat label: 'Refresh'), ], onDestinationSelected: (value) async { - if (value == 0) { - await as.saveToDisk(widget.file); - } else if (value == 1) { - if (kIsWeb || kIsDesktop) return showMetadataDialog(widget.attachment, context); - if (widget.file.path == null) return; - Share.file( - "Shared ${widget.attachment.mimeType!.split("/")[0]} from OpenBubbles: ${widget.attachment.transferName}", - widget.file.path!, - ); - } else if (value == 2) { - if (kIsWeb || kIsDesktop) return refreshAttachment(); - showMetadataDialog(widget.attachment, context); - } else if (value == 3) { - refreshAttachment(); + // Build an ordered action list matching the conditionally-included destinations + final actions = [ + 'download', + if (!kIsWeb) 'sticker', + if (!kIsWeb && !kIsDesktop) 'share', + if (iOS) 'metadata', + if (iOS) 'refresh', + ]; + final action = actions[value]; + switch (action) { + case 'download': + await as.saveToDisk(widget.file); + break; + case 'share': + if (widget.file.path == null) return; + Share.file( + "Shared ${widget.attachment.mimeType!.split("/")[0]} from OpenBubbles: ${widget.attachment.transferName}", + widget.file.path!, + ); + break; + case 'metadata': + showMetadataDialog(widget.attachment, context); + break; + case 'refresh': + refreshAttachment(); + break; + case 'sticker': + await as.saveAsSticker(widget.file); + break; } }, ), diff --git a/lib/database/io/chat.dart b/lib/database/io/chat.dart index ab5484f426..4a62307c71 100644 --- a/lib/database/io/chat.dart +++ b/lib/database/io/chat.dart @@ -160,7 +160,7 @@ class GetMessages extends AsyncTask, List> { associatedMessagesQuery.close(); associatedMessages = MessageHelper.normalizedAssociatedMessages(associatedMessages); for (Message m in associatedMessages) { - if (m.associatedMessageType != "sticker") continue; + if (m.associatedMessageType != "sticker" && m.associatedMessageType != "stickerback") continue; m.attachments = List.from(m.dbAttachments); } for (Message m in messages) { @@ -235,7 +235,7 @@ class AddMessages extends AsyncTask, List> { /// Assign the relevant attachments and associated messages to the original /// messages for (Message m in associatedMessages) { - if (m.associatedMessageType != "sticker") continue; + if (m.associatedMessageType != "sticker" && m.associatedMessageType != "stickerback") continue; m.attachments = List.from(m.dbAttachments); } for (Message m in newMessages) { diff --git a/lib/services/backend/filesystem/filesystem_service.dart b/lib/services/backend/filesystem/filesystem_service.dart index 7eae2912a5..027a3d9586 100644 --- a/lib/services/backend/filesystem/filesystem_service.dart +++ b/lib/services/backend/filesystem/filesystem_service.dart @@ -37,6 +37,27 @@ class FilesystemService extends GetxService { return filePath; } + /// Returns the path to the stickers directory. + /// On Android: /storage/emulated/0/Android/data//files/stickers/ + /// On other platforms: /stickers/ + Future get stickersDirectory async { + if (kIsWeb) throw "Cannot get stickers directory on web!"; + + String dirPath; + if (Platform.isAndroid) { + final extDir = await getExternalStorageDirectory(); + dirPath = join(extDir!.path, 'stickers'); + } else { + dirPath = join(appDocDir.path, 'stickers'); + } + + final dir = Directory(dirPath); + if (!await dir.exists()) { + await dir.create(recursive: true); + } + return dirPath; + } + Future init({bool headless = false}) async { if (!kIsWeb) { //ignore: unnecessary_cast, we need this as a workaround diff --git a/lib/services/rustpush/rustpush_service.dart b/lib/services/rustpush/rustpush_service.dart index 6deb3c1e42..43337279cd 100644 --- a/lib/services/rustpush/rustpush_service.dart +++ b/lib/services/rustpush/rustpush_service.dart @@ -531,6 +531,34 @@ class RustPushBackend implements BackendService { } } Logger.info("uploaded"); + // Detect sticker sends by balloonBundleId + final isStickerSend = m.balloonBundleId == "com.apple.Stickers.UserGenerated.MessagesExtension"; + api.PartExtension? stickerExt; + api.ExtensionApp? stickerApp; + if (isStickerSend) { + stickerExt = api.PartExtension.sticker( + msgWidth: 0.0, + rotation: 0.0, + sai: BigInt.zero, + scale: 1.0, + sli: BigInt.zero, + normalizedX: 0.5, + normalizedY: 0.5, + version: BigInt.one, + hash: "", + safi: BigInt.zero, + effectType: 0, + stickerId: uuid.v4().toUpperCase(), + ); + stickerApp = api.ExtensionApp( + name: "Stickers", + bundleId: "com.apple.Stickers.UserGenerated.MessagesExtension", + balloon: api.Balloon( + url: "", + isLive: false, + ), + ); + } var msg = await api.newMsg( conversation: await chat.getConversationData(), sender: await chat.ensureHandle(), @@ -539,14 +567,17 @@ class RustPushBackend implements BackendService { field0: [ if (m.payloadData?.appData?.first.ldText != null) api.IndexedMessagePart(part_: api.MessagePart.object(m.payloadData!.appData!.first.ldText!)), - api.IndexedMessagePart(part_: api.MessagePart.attachment(attachment!)) + api.IndexedMessagePart( + part_: api.MessagePart.attachment(attachment!), + ext: stickerExt, + ) ]), replyGuid: m.threadOriginatorGuid, replyPart: m.threadOriginatorGuid == null ? null : m.threadOriginatorPart, effect: m.expressiveSendStyleId, service: await getService(chat, forMessage: m), subject: m.subject, - app: m.payloadData == null ? null : pushService.dataToApp(m.payloadData!), + app: stickerApp ?? (m.payloadData == null ? null : pushService.dataToApp(m.payloadData!)), voice: isAudioMessage, scheduled: m.dateScheduled != null ? api.ScheduleMode(ms: m.dateScheduled!.millisecondsSinceEpoch, schedule: true) : null, embeddedProfile: await pushService.getShareProfileMessageFor(chat.participants), diff --git a/lib/services/ui/attachments_service.dart b/lib/services/ui/attachments_service.dart index b07fa18dd2..e4e88ed3e5 100644 --- a/lib/services/ui/attachments_service.dart +++ b/lib/services/ui/attachments_service.dart @@ -267,6 +267,24 @@ class AttachmentsService extends GetxService { } } + Future saveAsSticker(PlatformFile file) async { + try { + final stickerDir = await fs.stickersDirectory; + final destPath = join(stickerDir, file.name); + if (file.path != null) { + await File(file.path!).copy(destPath); + } else if (file.bytes != null) { + await File(destPath).writeAsBytes(file.bytes!); + } else { + return showSnackbar('Error', 'Could not save sticker: no file data available.'); + } + showSnackbar('Success', 'Saved as sticker!'); + } catch (e) { + Logger.error('Failed to save sticker', error: e); + showSnackbar('Error', 'Failed to save sticker.'); + } + } + Future canAutoDownload() async { final canSave = (await Permission.storage.request()).isGranted; if (!canSave) return false; diff --git a/lib/services/ui/chat/conversation_view_controller.dart b/lib/services/ui/chat/conversation_view_controller.dart index 2847f78682..c07d99d046 100644 --- a/lib/services/ui/chat/conversation_view_controller.dart +++ b/lib/services/ui/chat/conversation_view_controller.dart @@ -65,6 +65,7 @@ class ConversationViewController extends StatefulController with GetSingleTicker // text field items bool showAttachmentPicker = false; + bool isStickerSend = false; RxBool showEmojiPicker = false.obs; final GlobalKey textFieldKey = GlobalKey(); final RxList pickedAttachments = [].obs;