From 6a67774269d27d872f9521cae7f01a4b8297c16a Mon Sep 17 00:00:00 2001 From: MrMistic <70865979+MrMistic@users.noreply.github.com> Date: Fri, 13 Mar 2026 02:59:26 -0500 Subject: [PATCH 01/13] Integrate Find My Friends for location fetching in iOS chat view Added Find My Friends integration to fetch and display the short address of a friend based on their location. Implemented caching for address retrieval to optimize performance. --- .../widgets/header/cupertino_header.dart | 90 +++++++++++++++++++ 1 file changed, 90 insertions(+) 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 eb9e263da..8bfb34119 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) { From fe7400bdf0fb2939f52718435a5090fc01542bc2 Mon Sep 17 00:00:00 2001 From: MrMistic Date: Fri, 13 Mar 2026 20:08:20 +0000 Subject: [PATCH 02/13] Fix sticker rendering: add HEIC conversion, error handling, and stickerback attachment loading MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - sticker_holder.dart: Add HEIC→PNG conversion in checkImage() using FlutterImageCompress (same pattern as loadAndGetProperties), wrap in try/catch, use firstWhereOrNull, add errorBuilder to Image.memory() - reaction.dart: Same HEIC→PNG conversion and error handling for STICKERBACK sticker rendering path - chat.dart: Include 'stickerback' in attachment loading filter so STICKERBACK reaction messages get their attachments loaded Fixes #121 --- .../message/attachment/sticker_holder.dart | 64 ++++++++++++++----- .../widgets/message/reaction/reaction.dart | 60 +++++++++++++---- lib/database/io/chat.dart | 4 +- 3 files changed, 95 insertions(+), 33 deletions(-) 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 a79ffc740..35ac00aff 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/reaction/reaction.dart b/lib/app/layouts/conversation_view/widgets/message/reaction/reaction.dart index 060c32cf8..ab987bf80 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/database/io/chat.dart b/lib/database/io/chat.dart index ab5484f42..4a62307c7 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) { From 6f4239a6962c72dd71c3c0afb7733814ad2a3348 Mon Sep 17 00:00:00 2001 From: MrMistic <70865979+MrMistic@users.noreply.github.com> Date: Fri, 13 Mar 2026 17:18:40 -0500 Subject: [PATCH 03/13] Add sticker sending support (folder, save-as-sticker, picker, send path) - Add stickers directory in filesystem_service (Android external storage) - Add saveAsSticker() method in attachments_service - Add SaveAsSticker action to details_menu_action enum - Add Save as Sticker button to fullscreen image viewer (both skins) - Add Save as Sticker to message long-press popup (image attachments) - Create StickerPicker widget with grid UI, HEIC support, empty state - Integrate sticker picker into attachment picker message wheel - Wire up send path: isStickerSend flag flows through SendAnimation to set balloonBundleId, then rustpush_service detects it to build PartExtension.sticker + ExtensionApp with UserGenerated bundle ID - Fix fullscreen_image NavigationBar index mapping (dynamic action list) --- .../widgets/media_picker/sticker_picker.dart | 277 ++++++++++++++++++ .../text_field_attachment_picker.dart | 44 +++ .../message/popup/details_menu_action.dart | 4 + .../widgets/message/popup/message_popup.dart | 27 ++ .../widgets/message/send_animation.dart | 9 +- .../text_field/conversation_text_field.dart | 1 + .../fullscreen_media/fullscreen_image.dart | 64 +++- .../filesystem/filesystem_service.dart | 21 ++ lib/services/rustpush/rustpush_service.dart | 35 ++- lib/services/ui/attachments_service.dart | 18 ++ .../ui/chat/conversation_view_controller.dart | 1 + 11 files changed, 483 insertions(+), 18 deletions(-) create mode 100644 lib/app/layouts/conversation_view/widgets/media_picker/sticker_picker.dart 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 000000000..6ee19f1fb --- /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 1148bf67a..cf167039b 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/popup/details_menu_action.dart b/lib/app/layouts/conversation_view/widgets/message/popup/details_menu_action.dart index 01cd2044d..25b97bd56 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 11c6a1121..e1f720d33 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/send_animation.dart b/lib/app/layouts/conversation_view/widgets/message/send_animation.dart index 5f4d73957..ef3db6898 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 72feba45a..313f39cd4 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/services/backend/filesystem/filesystem_service.dart b/lib/services/backend/filesystem/filesystem_service.dart index 7eae2912a..027a3d958 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 6deb3c1e4..43337279c 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 b07fa18dd..e4e88ed3e 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 2847f7868..c07d99d04 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; From 8923f5cb3fa83914b718341e2a47837a756b3b06 Mon Sep 17 00:00:00 2001 From: MrMistic Date: Mon, 30 Mar 2026 05:39:09 +0000 Subject: [PATCH 04/13] Add manual test build workflow with real FairPlay certs --- .github/workflows/build-test.yml | 76 ++++++++++++++++++++++++++++++++ 1 file changed, 76 insertions(+) create mode 100644 .github/workflows/build-test.yml diff --git a/.github/workflows/build-test.yml b/.github/workflows/build-test.yml new file mode 100644 index 000000000..1a075af4a --- /dev/null +++ b/.github/workflows/build-test.yml @@ -0,0 +1,76 @@ +on: + workflow_dispatch: + +name: Build Test APK (Real Certs) +jobs: + build: + name: OpenBubbles Test APK + runs-on: ubuntu-latest + steps: + - name: Free Disk Space (Ubuntu) + uses: jlumbroso/free-disk-space@main + with: + tool-cache: true + android: true + dotnet: true + haskell: true + large-packages: true + swap-storage: true + + - uses: actions/checkout@v4 + with: + submodules: recursive + + - name: Set up Rust + uses: actions-rs/toolchain@v1 + with: + toolchain: stable + + - name: Set up Flutter + uses: subosito/flutter-action@v2 + with: + channel: stable + flutter-version: 3.24.0 + + - name: Set up Java + uses: actions/setup-java@v2 + with: + java-version: '21' + distribution: 'temurin' + + - name: Setup Android SDK + uses: amyu/setup-android@v5 + + - name: Install Protobuf compiler + run: sudo apt-get install -y protobuf-compiler + + - name: Set up FairPlay keys from secrets + run: | + mkdir -p rustpush/certs/fairplay + + cert_names=( + "4056631661436364584235346952193" + "4056631661436364584235346952194" + "4056631661436364584235346952195" + "4056631661436364584235346952196" + "4056631661436364584235346952197" + "4056631661436364584235346952198" + "4056631661436364584235346952199" + "4056631661436364584235346952200" + "4056631661436364584235346952201" + "4056631661436364584235346952208" + ) + + for name in "${cert_names[@]}"; do + echo "${{ secrets.FAIRPLAY_CERT }}" | base64 -d > rustpush/certs/fairplay/$name.crt + echo "${{ secrets.FAIRPLAY_KEY }}" | base64 -d > rustpush/certs/fairplay/$name.pem + done + + - name: Run Build Script + run: | + flutter build apk --flavor alpha --debug --target-platform android-arm64 + + - uses: actions/upload-artifact@v4 + with: + name: Alpha Test APK + path: build/app/outputs/flutter-apk/app-alpha-debug.apk From 4fe6c42593c6d05fce469ce57230475abb635a3f Mon Sep 17 00:00:00 2001 From: MrMistic Date: Tue, 31 Mar 2026 05:48:39 +0000 Subject: [PATCH 05/13] Add settings toggle for Find My location in chat header, fix overflow - Add 'Show Location in iOS Chat (BETA)' toggle in conversation settings - Location feature is now disabled by default - Fix header overflow: shrink avatar when location row is displayed --- .../widgets/header/cupertino_header.dart | 9 ++++++--- .../pages/message_view/conversation_panel.dart | 12 ++++++++++++ lib/database/global/settings.dart | 4 ++++ 3 files changed, 22 insertions(+), 3 deletions(-) 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 8bfb34119..b927c6442 100644 --- a/lib/app/layouts/conversation_view/widgets/header/cupertino_header.dart +++ b/lib/app/layouts/conversation_view/widgets/header/cupertino_header.dart @@ -488,7 +488,9 @@ class _ChatIconAndTitleState extends CustomState<_ChatIconAndTitle, void, Conver cachedGuid = controller.chat.guid; // --- FindMy integration --- - fetchShortAddress(); + if (ss.settings.showLocationInChat.value) { + fetchShortAddress(); + } // run query after render has completed if (!kIsWeb) { @@ -609,15 +611,16 @@ class _ChatIconAndTitleState extends CustomState<_ChatIconAndTitle, void, Conver if (hideInfo) { _title = controller.chat.participants.length > 1 ? "Group Chat" : controller.chat.participants[0].fakeName; } + final hasLocationRow = isLoadingFindMy || (shortAddress != null && shortAddress!.isNotEmpty); final children = [ IgnorePointer( ignoring: true, child: ContactAvatarGroupWidget( chat: controller.chat, - size: 54, + size: hasLocationRow ? 40 : 54, ), ), - const SizedBox(height: 5, width: 5), + SizedBox(height: hasLocationRow ? 2 : 5, width: 5), Row(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.center, children: [ ConstrainedBox( constraints: BoxConstraints( diff --git a/lib/app/layouts/settings/pages/message_view/conversation_panel.dart b/lib/app/layouts/settings/pages/message_view/conversation_panel.dart index 39e54ad94..0a1315deb 100644 --- a/lib/app/layouts/settings/pages/message_view/conversation_panel.dart +++ b/lib/app/layouts/settings/pages/message_view/conversation_panel.dart @@ -197,6 +197,18 @@ class _ConversationPanelState extends OptimizedState { subtitle: "Enable this to hide names under participant avatars when you view a message's reactions", backgroundColor: tileColor, )), + const SettingsDivider(padding: EdgeInsets.only(left: 16.0)), + Obx(() => SettingsSwitch( + onChanged: (bool val) { + ss.settings.showLocationInChat.value = val; + saveSettings(); + }, + initialVal: ss.settings.showLocationInChat.value, + title: "Show Location in iOS Chat (BETA)", + subtitle: "Displays the contact's city and state in the chat header using Find My Friends", + backgroundColor: tileColor, + isThreeLine: true, + )), ], ), if (!kIsWeb) diff --git a/lib/database/global/settings.dart b/lib/database/global/settings.dart index 5fe58fdd4..ab782fa53 100644 --- a/lib/database/global/settings.dart +++ b/lib/database/global/settings.dart @@ -91,6 +91,7 @@ class Settings { final RxnString userAvatarPath = RxnString(); final RxnString userPosterPath = RxnString(); final RxBool hideNamesForReactions = false.obs; + final RxBool showLocationInChat = false.obs; final RxBool replaceEmoticonsWithEmoji = true.obs; final RxnString lastLocation = RxnString(); @@ -419,6 +420,7 @@ class Settings { 'useWindowsAccent': useWindowsAccent.value, 'logLevel': logLevel.value.index, 'hideNamesForReactions': hideNamesForReactions.value, + 'showLocationInChat': showLocationInChat.value, 'replaceEmoticonsWithEmoji': replaceEmoticonsWithEmoji.value, 'lastReviewRequestTimestamp': lastReviewRequestTimestamp.value, 'defaultHandle': defaultHandle.value, @@ -598,6 +600,7 @@ class Settings { ss.settings.firstFcmRegisterDate.value = map['firstFcmRegisterDate'] ?? 0; ss.settings.logLevel.value = map['logLevel'] != null ? Level.values[map['logLevel']] : Level.info; ss.settings.hideNamesForReactions.value = map['hideNamesForReactions'] ?? false; + ss.settings.showLocationInChat.value = map['showLocationInChat'] ?? false; ss.settings.replaceEmoticonsWithEmoji.value = map['replaceEmoticonsWithEmoji'] ?? false; ss.settings.defaultHandle.value = map['defaultHandle'] ?? ""; ss.settings.cardDavServer.value = map['cardDavServer'] ?? ""; @@ -773,6 +776,7 @@ class Settings { s.firstFcmRegisterDate.value = map['firstFcmRegisterDate'] ?? 0; s.logLevel.value = map['logLevel'] != null ? Level.values[map['logLevel']] : Level.info; s.hideNamesForReactions.value = map['hideNamesForReactions'] ?? false; + s.showLocationInChat.value = map['showLocationInChat'] ?? false; s.replaceEmoticonsWithEmoji.value = map['replaceEmoticonsWithEmoji'] ?? false; s.lastReviewRequestTimestamp.value = map['lastReviewRequestTimestamp'] ?? 0; s.defaultHandle.value = map['defaultHandle'] ?? ""; From 52bb75ff6468f4f48eb299b7903994e9c459d8d3 Mon Sep 17 00:00:00 2001 From: MrMistic Date: Tue, 31 Mar 2026 06:01:23 +0000 Subject: [PATCH 06/13] Add sticker manager panel for adding and deleting stickers - New 'Manage Stickers (BETA)' option in Settings > Attachments & Media - Grid view of all saved stickers with thumbnails and HEIC support - Add stickers via file picker (multi-select) - Long-press to delete with confirmation dialog - Empty state with instructions --- .../pages/message_view/attachment_panel.dart | 18 ++ .../message_view/sticker_manager_panel.dart | 280 ++++++++++++++++++ 2 files changed, 298 insertions(+) create mode 100644 lib/app/layouts/settings/pages/message_view/sticker_manager_panel.dart diff --git a/lib/app/layouts/settings/pages/message_view/attachment_panel.dart b/lib/app/layouts/settings/pages/message_view/attachment_panel.dart index 99ab8326c..2254c90c4 100644 --- a/lib/app/layouts/settings/pages/message_view/attachment_panel.dart +++ b/lib/app/layouts/settings/pages/message_view/attachment_panel.dart @@ -1,6 +1,8 @@ import 'package:bluebubbles/helpers/helpers.dart'; +import 'package:bluebubbles/app/layouts/settings/pages/message_view/sticker_manager_panel.dart'; import 'package:bluebubbles/app/layouts/settings/widgets/settings_widgets.dart'; import 'package:bluebubbles/app/wrappers/stateful_boilerplate.dart'; +import 'package:bluebubbles/app/wrappers/theme_switcher.dart'; import 'package:bluebubbles/services/services.dart'; import 'package:file_picker/file_picker.dart'; import 'package:flutter/foundation.dart'; @@ -225,6 +227,22 @@ class _AttachmentPanelState extends OptimizedState { subtitle: "Set the swipe direction to go to previous media items", secondaryColor: headerColor, )), + if (!kIsDesktop) + const SettingsDivider(padding: EdgeInsets.only(left: 16.0)), + if (!kIsDesktop) + SettingsTile( + title: "Manage Stickers (BETA)", + subtitle: "Add, preview, and delete saved stickers", + backgroundColor: tileColor, + trailing: Icon(Icons.chevron_right, color: context.theme.colorScheme.outline), + onTap: () { + Navigator.of(context).push( + ThemeSwitcher.buildPageRoute( + builder: (context) => StickerManagerPanel(), + ), + ); + }, + ), ], ), ], diff --git a/lib/app/layouts/settings/pages/message_view/sticker_manager_panel.dart b/lib/app/layouts/settings/pages/message_view/sticker_manager_panel.dart new file mode 100644 index 000000000..29cd75bde --- /dev/null +++ b/lib/app/layouts/settings/pages/message_view/sticker_manager_panel.dart @@ -0,0 +1,280 @@ +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:file_picker/file_picker.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 StickerManagerPanel extends StatefulWidget { + @override + State createState() => _StickerManagerPanelState(); +} + +class _StickerManagerPanelState 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(); + _stickers.sort((a, b) => b.lastModifiedSync().compareTo(a.lastModifiedSync())); + } + } catch (e) { + Logger.error('Failed to load stickers', error: e); + } + _loading = false; + setState(() {}); + } + + Future addStickers() async { + final result = await FilePicker.platform.pickFiles( + type: FileType.image, + allowMultiple: true, + ); + if (result == null || result.files.isEmpty) return; + + try { + final stickerDir = await fs.stickersDirectory; + for (final file in result.files) { + if (file.path != null) { + final dest = join(stickerDir, file.name); + await File(file.path!).copy(dest); + } + } + showSnackbar('Success', 'Added ${result.files.length} sticker${result.files.length > 1 ? 's' : ''}!'); + await loadStickers(); + } catch (e) { + Logger.error('Failed to add stickers', error: e); + showSnackbar('Error', 'Failed to add stickers.'); + } + } + + Future deleteSticker(File file) async { + final confirmed = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: Text('Delete Sticker', style: context.theme.textTheme.titleLarge), + content: Text('Are you sure you want to delete this sticker?'), + backgroundColor: context.theme.colorScheme.properSurface, + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(false), + child: Text('Cancel', style: context.theme.textTheme.bodyLarge!.copyWith(color: context.theme.colorScheme.primary)), + ), + TextButton( + onPressed: () => Navigator.of(context).pop(true), + child: Text('Delete', style: context.theme.textTheme.bodyLarge!.copyWith(color: Colors.red)), + ), + ], + ), + ); + + if (confirmed == true) { + try { + await file.delete(); + showSnackbar('Deleted', 'Sticker removed.'); + await loadStickers(); + } catch (e) { + Logger.error('Failed to delete sticker', error: e); + showSnackbar('Error', 'Failed to delete sticker.'); + } + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: context.theme.colorScheme.background, + appBar: AppBar( + title: Text('Manage Stickers', style: context.theme.textTheme.titleLarge), + centerTitle: ss.settings.skin.value == Skins.iOS, + backgroundColor: context.theme.colorScheme.background, + leading: buildBackButton(context), + actions: [ + IconButton( + icon: Icon(iOS ? CupertinoIcons.add : Icons.add), + onPressed: addStickers, + tooltip: 'Add Stickers', + ), + ], + ), + body: _loading + ? Center(child: buildProgressIndicator(context)) + : _stickers.isEmpty + ? 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 yet', + style: context.theme.textTheme.bodyLarge?.copyWith( + color: context.theme.colorScheme.outline, + ), + ), + const SizedBox(height: 8), + Text( + 'Tap + to add images as stickers,\nor save them from the attachment viewer.', + textAlign: TextAlign.center, + style: context.theme.textTheme.bodySmall?.copyWith( + color: context.theme.colorScheme.outline, + ), + ), + ], + ), + ), + ) + : Padding( + padding: const EdgeInsets.all(10.0), + child: GridView.builder( + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 3, + crossAxisSpacing: 8, + mainAxisSpacing: 8, + ), + itemCount: _stickers.length, + itemBuilder: (context, index) { + return _StickerManagerTile( + file: _stickers[index], + onDelete: () => deleteSticker(_stickers[index]), + ); + }, + ), + ), + ); + } +} + +class _StickerManagerTile extends StatefulWidget { + const _StickerManagerTile({ + required this.file, + required this.onDelete, + }); + + final File file; + final VoidCallback onDelete; + + @override + State<_StickerManagerTile> createState() => _StickerManagerTileState(); +} + +class _StickerManagerTileState extends OptimizedState<_StickerManagerTile> { + 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) { + return ClipRRect( + borderRadius: BorderRadius.circular(10), + child: Stack( + fit: StackFit.expand, + children: [ + if (image != null) + Image.memory( + image!, + fit: BoxFit.cover, + cacheWidth: 300, + ) + else + Container( + color: context.theme.colorScheme.properSurface, + child: Center(child: buildProgressIndicator(context)), + ), + // Long-press delete overlay + Positioned.fill( + child: Material( + color: Colors.transparent, + child: InkWell( + borderRadius: BorderRadius.circular(10), + onLongPress: widget.onDelete, + child: const SizedBox.expand(), + ), + ), + ), + // File name label at bottom + Positioned( + bottom: 0, + left: 0, + right: 0, + child: Container( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [Colors.transparent, Colors.black54], + ), + ), + padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 4), + child: Text( + basenameWithoutExtension(widget.file.path), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle(color: Colors.white, fontSize: 10), + ), + ), + ), + ], + ), + ); + } +} From 042892a004c60bcd914d9794ae9a862c3fbe3c34 Mon Sep 17 00:00:00 2001 From: MrMistic Date: Tue, 31 Mar 2026 07:45:07 +0000 Subject: [PATCH 07/13] Add attachment picker order setting and stable debug keystore - New 'Attachment Picker Order' in Settings > Conversation Settings - Reorderable list with drag handles, matching Message Options Order pattern - iMessage apps always stay at top, only static items are reorderable - Sort persisted across sessions - Stable debug keystore for consistent APK signing across builds --- .github/workflows/build-test.yml | 4 + .../text_field_attachment_picker.dart | 8 + .../attachment_picker_order_panel.dart | 156 ++++++++++++++++++ .../message_view/conversation_panel.dart | 15 ++ lib/database/global/settings.dart | 25 +++ 5 files changed, 208 insertions(+) create mode 100644 lib/app/layouts/settings/pages/message_view/attachment_picker_order_panel.dart diff --git a/.github/workflows/build-test.yml b/.github/workflows/build-test.yml index 1a075af4a..d82f5faf7 100644 --- a/.github/workflows/build-test.yml +++ b/.github/workflows/build-test.yml @@ -44,6 +44,10 @@ jobs: - name: Install Protobuf compiler run: sudo apt-get install -y protobuf-compiler + - name: Set up stable debug keystore + run: | + echo "${{ secrets.DEBUG_KEYSTORE }}" | base64 -d > /home/runner/.android/debug.keystore + - name: Set up FairPlay keys from secrets run: | mkdir -p rustpush/certs/fairplay 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 cf167039b..079d61161 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 @@ -290,6 +290,14 @@ class AttachmentPickerState extends OptimizedState { }, ]; + // Sort static items by user's saved order + final order = ss.settings.attachmentPickerOrder; + iconsList.sort((a, b) { + final aIndex = order.indexOf(a["text"] as String); + final bIndex = order.indexOf(b["text"] as String); + return (aIndex == -1 ? 999 : aIndex).compareTo(bIndex == -1 ? 999 : bIndex); + }); + if(!controller.chat.isIMessage) return; for (var app in es.cachedStatus) { if (app.available == null) return; diff --git a/lib/app/layouts/settings/pages/message_view/attachment_picker_order_panel.dart b/lib/app/layouts/settings/pages/message_view/attachment_picker_order_panel.dart new file mode 100644 index 000000000..0713312a8 --- /dev/null +++ b/lib/app/layouts/settings/pages/message_view/attachment_picker_order_panel.dart @@ -0,0 +1,156 @@ +import 'dart:ui'; + +import 'package:bluebubbles/app/wrappers/stateful_boilerplate.dart'; +import 'package:bluebubbles/helpers/helpers.dart'; +import 'package:bluebubbles/database/global/settings.dart'; +import 'package:bluebubbles/services/backend/settings/settings_service.dart'; +import 'package:bluebubbles/services/ui/navigator/navigator_service.dart'; +import 'package:bluebubbles/services/ui/theme/themes_service.dart'; +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_acrylic/window_effect.dart'; +import 'package:get/get.dart'; + +/// Maps attachment picker item names to their icons. +const Map _iconMap = { + "Polls": Icons.how_to_vote, + "Files": Icons.folder_open_outlined, + "Location": Icons.location_on_outlined, + "Send Later": Icons.lock_clock, + "Handwritten": Icons.draw, + "Stickers": Icons.emoji_emotions_outlined, +}; + +const Map _iosIconMap = { + "Files": CupertinoIcons.folder_open, + "Location": CupertinoIcons.location, + "Send Later": CupertinoIcons.clock_solid, + "Handwritten": CupertinoIcons.pencil_outline, + "Stickers": CupertinoIcons.smiley, +}; + +class AttachmentPickerOrderPanel extends StatefulWidget { + @override + State createState() => _AttachmentPickerOrderPanelState(); +} + +class _AttachmentPickerOrderPanelState extends OptimizedState { + final RxList orderList = RxList(); + + @override + void initState() { + super.initState(); + orderList.value = List.from(ss.settings.attachmentPickerOrder); + } + + @override + Widget build(BuildContext context) { + final Rx _backgroundColor = + (kIsDesktop && ss.settings.windowEffect.value != WindowEffect.disabled ? Colors.transparent : context.theme.colorScheme.background).obs; + + final Color tileColor = (ts.inDarkMode(context) ? context.theme.colorScheme.properSurface : context.theme.colorScheme.background) + .withAlpha(ss.settings.windowEffect.value != WindowEffect.disabled ? 100 : 255); + + if (kIsDesktop) { + ss.settings.windowEffect.listen((WindowEffect effect) => + _backgroundColor.value = effect != WindowEffect.disabled ? Colors.transparent : context.theme.colorScheme.background); + } + return AnnotatedRegion( + value: SystemUiOverlayStyle( + systemNavigationBarColor: ss.settings.immersiveMode.value ? Colors.transparent : context.theme.colorScheme.background, + systemNavigationBarIconBrightness: context.theme.colorScheme.brightness.opposite, + statusBarColor: Colors.transparent, + statusBarIconBrightness: context.theme.colorScheme.brightness.opposite, + ), + child: Obx( + () => Scaffold( + backgroundColor: _backgroundColor.value, + appBar: PreferredSize( + preferredSize: Size(ns.width(context), 80), + child: ClipRRect( + child: BackdropFilter( + child: AppBar( + systemOverlayStyle: ThemeData.estimateBrightnessForColor(context.theme.colorScheme.background) == Brightness.dark + ? SystemUiOverlayStyle.light + : SystemUiOverlayStyle.dark, + toolbarHeight: kIsDesktop ? 80 : 50, + elevation: 0, + scrolledUnderElevation: 3, + surfaceTintColor: context.theme.colorScheme.primary, + leading: buildBackButton(context), + backgroundColor: _backgroundColor.value, + centerTitle: ss.settings.skin.value == Skins.iOS, + title: Text( + "Attachment Picker Order", + style: context.theme.textTheme.titleLarge, + ), + actions: [ + TextButton( + child: Text("Reset", style: context.theme.textTheme.bodyLarge!.copyWith(color: context.theme.colorScheme.primary)), + onPressed: () { + orderList.value = List.from(Settings.defaultAttachmentPickerOrder); + ss.settings.attachmentPickerOrder.value = List.from(Settings.defaultAttachmentPickerOrder); + ss.saveSettings(); + }, + ), + ], + ), + filter: ImageFilter.blur(sigmaX: 15, sigmaY: 15), + ), + ), + ), + body: Container( + color: tileColor, + child: Obx( + () => ReorderableListView.builder( + shrinkWrap: true, + onReorder: (start, end) { + if (start == end) return; + final item = orderList.removeAt(start); + orderList.insert(end > start ? end - 1 : end, item); + ss.settings.attachmentPickerOrder.value = orderList.toList(); + ss.saveSettings(); + }, + buildDefaultDragHandles: false, + itemBuilder: (context, index) { + final name = orderList[index]; + final icon = (iOS ? _iosIconMap[name] : null) ?? _iconMap[name] ?? Icons.help_outline; + return Row( + key: Key(name), + children: [ + const SizedBox(width: 16), + Icon(icon, color: context.theme.colorScheme.properOnSurface), + const SizedBox(width: 16), + Expanded( + child: Text( + name, + style: context.theme.textTheme.bodyLarge, + ), + ), + MouseRegion( + cursor: SystemMouseCursors.click, + child: ReorderableDragStartListener( + index: index, + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 12), + child: Icon( + Icons.drag_handle, + color: context.theme.colorScheme.outline, + ), + ), + ), + ), + const SizedBox(width: 16), + ], + ); + }, + itemCount: orderList.length, + ), + ), + ), + ), + ), + ); + } +} diff --git a/lib/app/layouts/settings/pages/message_view/conversation_panel.dart b/lib/app/layouts/settings/pages/message_view/conversation_panel.dart index 0a1315deb..3ae9cc520 100644 --- a/lib/app/layouts/settings/pages/message_view/conversation_panel.dart +++ b/lib/app/layouts/settings/pages/message_view/conversation_panel.dart @@ -1,6 +1,7 @@ import 'package:animated_size_and_fade/animated_size_and_fade.dart'; import 'package:audio_waveforms/audio_waveforms.dart' as aw; import 'package:bluebubbles/app/layouts/conversation_view/widgets/message/reaction/reaction.dart'; +import 'package:bluebubbles/app/layouts/settings/pages/message_view/attachment_picker_order_panel.dart'; import 'package:bluebubbles/app/layouts/settings/pages/message_view/message_options_order_panel.dart'; import 'package:bluebubbles/app/layouts/settings/widgets/content/next_button.dart'; import 'package:bluebubbles/helpers/helpers.dart'; @@ -142,6 +143,20 @@ class _ConversationPanelState extends OptimizedState { }, trailing: const NextButton(), ), + if (!kIsWeb) + const SettingsDivider(padding: EdgeInsets.only(left: 16.0)), + if (!kIsWeb) + SettingsTile( + title: "Attachment Picker Order", + subtitle: "Set the order of items in the attachment picker wheel", + onTap: () { + ns.pushSettings( + context, + AttachmentPickerOrderPanel(), + ); + }, + trailing: const NextButton(), + ), if (!kIsWeb && backend.getRemoteService() != null) const SettingsDivider(padding: EdgeInsets.only(left: 16.0)), if (!kIsWeb && backend.getRemoteService() != null) diff --git a/lib/database/global/settings.dart b/lib/database/global/settings.dart index ab782fa53..42ccf289a 100644 --- a/lib/database/global/settings.dart +++ b/lib/database/global/settings.dart @@ -186,6 +186,12 @@ class Settings { /// Use [setDetailsMenuActions] to set this value List get detailsMenuActions => _detailsMenuActions; + // Attachment picker order + static const List defaultAttachmentPickerOrder = [ + "Polls", "Files", "Location", "Send Later", "Handwritten", "Stickers" + ]; + final RxList attachmentPickerOrder = RxList.from(defaultAttachmentPickerOrder); + // Linux settings final RxBool useCustomTitleBar = RxBool(true); @@ -357,6 +363,7 @@ class Settings { 'selectedActionIndices': selectedActionIndices, 'actionList': actionList, 'detailsMenuActions': detailsMenuActions.map((action) => action.name).toList(), + 'attachmentPickerOrder': attachmentPickerOrder.toList(), 'askWhereToSave': askWhereToSave.value, 'indicatorsOnPinnedChats': statusIndicatorsOnChats.value, 'apiTimeout': apiTimeout.value, @@ -590,6 +597,7 @@ class Settings { ss.settings.selectedActionIndices.value = _processSelectedActionIndices(map['selectedActionIndices']); ss.settings.actionList.value = _processActionList(map['actionList']); ss.settings._detailsMenuActions.value = _processDetailsMenuActions(map['detailsMenuActions'], ss.settings.detailsMenuActions); + ss.settings.attachmentPickerOrder.value = _processAttachmentPickerOrder(map['attachmentPickerOrder']); ss.settings.windowEffect.value = kIsDesktop && Platform.isWindows ? WindowEffect.values.firstWhereOrNull((e) => e.name == map['windowEffect']) ?? WindowEffect.disabled @@ -766,6 +774,7 @@ class Settings { s.selectedActionIndices.value = _processSelectedActionIndices(map['selectedActionIndices']); s.actionList.value = _processActionList(map['actionList']); s._detailsMenuActions.value = _processDetailsMenuActions(map['detailsMenuActions'], DetailsMenuAction.values); + s.attachmentPickerOrder.value = _processAttachmentPickerOrder(map['attachmentPickerOrder']); s.windowEffect.value = (kIsDesktop && Platform.isWindows) ? WindowEffect.values.firstWhereOrNull((e) => e.name == map['windowEffect']) ?? WindowEffect.disabled @@ -881,3 +890,19 @@ List _filterDetailsMenuActions(List action return actions; } + +List _processAttachmentPickerOrder(dynamic rawJson) { + try { + final saved = (rawJson is List ? rawJson : jsonDecode(rawJson) as List).cast(); + final defaults = Settings.defaultAttachmentPickerOrder; + // Start with saved order, then append any new items not in the saved list + final result = [...saved.where((s) => defaults.contains(s))]; + for (final item in defaults) { + if (!result.contains(item)) result.add(item); + } + return result; + } catch (e) { + debugPrint("Using default attachmentPickerOrder"); + return List.from(Settings.defaultAttachmentPickerOrder); + } +} From 0b614b6270c43147a03ff725a7cae8b0ea42e6a1 Mon Sep 17 00:00:00 2001 From: MrMistic Date: Tue, 31 Mar 2026 21:20:25 +0000 Subject: [PATCH 08/13] Add photo upload support for Shared Albums - 'Add Photos' button appears below each synced album in Shared Albums panel - Opens media picker for multi-select photos/videos - Copies selected files to the album's synced folder (Pictures//) - Triggers syncNow() to upload new files to iCloud - Sync status display shows upload progress automatically - Button only shows for albums that have sync enabled (Android only) --- .../pages/misc/shared_streams_panel.dart | 49 +++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/lib/app/layouts/settings/pages/misc/shared_streams_panel.dart b/lib/app/layouts/settings/pages/misc/shared_streams_panel.dart index fa0912439..bba083ded 100644 --- a/lib/app/layouts/settings/pages/misc/shared_streams_panel.dart +++ b/lib/app/layouts/settings/pages/misc/shared_streams_panel.dart @@ -82,6 +82,44 @@ class _SharedStreamsPanelState extends OptimizedState { Map loading = {}; + Future _addPhotosToAlbum(api.SharedAlbum album) async { + try { + final result = await FilePicker.platform.pickFiles( + type: FileType.media, + allowMultiple: true, + ); + if (result == null || result.files.isEmpty) return; + + // Get the album's synced folder path + final dir = await ExternalPath.getExternalStoragePublicDirectory(ExternalPath.DIRECTORY_PICTURES); + final albumFolder = "$dir/${album.name}"; + await Directory(albumFolder).create(recursive: true); + + int copied = 0; + for (final file in result.files) { + if (file.path == null) continue; + final source = File(file.path!); + final dest = File("$albumFolder/${file.name}"); + if (!await dest.exists()) { + await source.copy(dest.path); + copied++; + } + } + + if (copied > 0) { + showSnackbar('Uploading', 'Added $copied photo${copied > 1 ? 's' : ''} to ${album.name}. Syncing...'); + // Trigger sync to upload the new files + await api.syncNow(lock: pushService.state!.icloudServices!.sharedstreams!); + updateSyncState(); + } else { + showSnackbar('Info', 'No new photos to add.'); + } + } catch (e, stack) { + Logger.error('Failed to add photos to shared album', error: e, trace: stack); + showSnackbar('Error', 'Failed to add photos: ${e.toString()}'); + } + } + Widget wrapDelete(Widget child, Function(BuildContext) onPressed) { return Slidable( endActionPane: ActionPane( @@ -312,6 +350,17 @@ class _SharedStreamsPanelState extends OptimizedState { rethrow; } })); + // Add Photos button for synced albums + if (syncing && !kIsDesktop) { + albums.add(SettingsTile( + title: "Add Photos to ${album.name ?? 'Album'}", + leading: Icon( + iOS ? CupertinoIcons.photo_on_rectangle : Icons.add_photo_alternate_outlined, + color: context.theme.colorScheme.primary, + ), + onTap: () => _addPhotosToAlbum(album), + )); + } if (index != myAlbums.length - 1) albums.add(const SettingsDivider(padding: EdgeInsets.only(left: 16.0))); } From dd941f1ecccec0602765ec7476433dc1ca473a2b Mon Sep 17 00:00:00 2001 From: MrMistic Date: Fri, 10 Apr 2026 12:26:28 +0000 Subject: [PATCH 09/13] Add per-chat Notification Settings and Notify Anyway Settings buttons (Android) --- ...ConversationNotificationSettingsHandler.kt | 5 +-- .../dialogs/notification_settings_dialog.dart | 33 +++++++++++++++++++ 2 files changed, 36 insertions(+), 2 deletions(-) diff --git a/android/app/src/main/kotlin/com/bluebubbles/messaging/services/system/OpenConversationNotificationSettingsHandler.kt b/android/app/src/main/kotlin/com/bluebubbles/messaging/services/system/OpenConversationNotificationSettingsHandler.kt index de58951a5..219bd40ee 100644 --- a/android/app/src/main/kotlin/com/bluebubbles/messaging/services/system/OpenConversationNotificationSettingsHandler.kt +++ b/android/app/src/main/kotlin/com/bluebubbles/messaging/services/system/OpenConversationNotificationSettingsHandler.kt @@ -35,10 +35,11 @@ class OpenConversationNotificationSettingsHandler: MethodCallHandlerImpl() { Log.d(Constants.logTag, "Creating channel...") // setup channel with parameters val channel = NotificationChannel(channelId, channelName, NotificationManager.IMPORTANCE_HIGH) - // set the channel to allow bubbling, bypassing DND, and showing badges + // set the channel to allow bubbling and showing badges channel.setAllowBubbles(true) - channel.setBypassDnd(true) channel.setShowBadge(true) + // only bypass DND for notify_anyways channels + channel.setBypassDnd(channelId.endsWith(".notify_anyways")) channel.setConversationId("com.bluebubbles.new_messages", channelId); // create the channel notificationManager.createNotificationChannel(channel) diff --git a/lib/app/layouts/settings/dialogs/notification_settings_dialog.dart b/lib/app/layouts/settings/dialogs/notification_settings_dialog.dart index a73840d88..e5f652f7d 100644 --- a/lib/app/layouts/settings/dialogs/notification_settings_dialog.dart +++ b/lib/app/layouts/settings/dialogs/notification_settings_dialog.dart @@ -1,6 +1,9 @@ +import 'dart:io'; import 'package:bluebubbles/helpers/helpers.dart'; import 'package:bluebubbles/services/backend_ui_interop/event_dispatcher.dart'; +import 'package:bluebubbles/services/services.dart'; import 'package:bluebubbles/database/models.dart'; +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:get/get.dart'; @@ -185,6 +188,36 @@ class NotificationSettingsDialog extends StatelessWidget { eventDispatcher.emit("refresh", null); }, ), + if (!kIsWeb && Platform.isAndroid) + ListTile( + mouseCursor: MouseCursor.defer, + title: Text("Notification Settings", style: context.theme.textTheme.bodyLarge), + subtitle: Text( + "Customize sound, vibration, and importance", + style: context.theme.textTheme.bodySmall!.copyWith(color: context.theme.colorScheme.properOnSurface),), + onTap: () async { + Get.back(); + await mcs.invokeMethod("open-conversation-notification-settings", { + "channel_id": "com.bluebubbles.new_messages.${chat.guid}", + "display_name": chat.getTitle(), + }); + }, + ), + if (!kIsWeb && Platform.isAndroid) + ListTile( + mouseCursor: MouseCursor.defer, + title: Text("Notify Anyway Settings", style: context.theme.textTheme.bodyLarge), + subtitle: Text( + "Customize notifications that bypass Do Not Disturb", + style: context.theme.textTheme.bodySmall!.copyWith(color: context.theme.colorScheme.properOnSurface),), + onTap: () async { + Get.back(); + await mcs.invokeMethod("open-conversation-notification-settings", { + "channel_id": "com.bluebubbles.new_messages.${chat.guid}.notify_anyways", + "display_name": "Notify Anyway: ${chat.getTitle()}", + }); + }, + ), ListTile( mouseCursor: MouseCursor.defer, title: Text("Reset chat-specific settings", From fe097418fd7c6f92ed31879d7a8457b26c5c7a7c Mon Sep 17 00:00:00 2001 From: MrMistic Date: Fri, 10 Apr 2026 12:32:32 +0000 Subject: [PATCH 10/13] Include saved stickers in settings backup and restore --- .../pages/server/backup_restore_panel.dart | 50 ++++++++++++++++++- 1 file changed, 48 insertions(+), 2 deletions(-) diff --git a/lib/app/layouts/settings/pages/server/backup_restore_panel.dart b/lib/app/layouts/settings/pages/server/backup_restore_panel.dart index 1d1a89cc7..6b4c2e4de 100644 --- a/lib/app/layouts/settings/pages/server/backup_restore_panel.dart +++ b/lib/app/layouts/settings/pages/server/backup_restore_panel.dart @@ -39,6 +39,48 @@ class _BackupRestorePanelState extends OptimizedState { List> themes = []; bool? fetching = true; + /// Reads all sticker files and returns them as a list of {filename, data} maps. + Future>> _exportStickers() async { + if (kIsWeb) return []; + try { + final stickerDir = Directory(await fs.stickersDirectory); + if (!await stickerDir.exists()) return []; + final stickers = >[]; + await for (final entity in stickerDir.list()) { + if (entity is File) { + final bytes = await entity.readAsBytes(); + stickers.add({ + 'filename': basename(entity.path), + 'data': base64Encode(bytes), + }); + } + } + return stickers; + } catch (e) { + Logger.warn('Failed to export stickers: $e'); + return []; + } + } + + /// Restores stickers from a backup map's "stickers" key. + Future _restoreStickers(Map map) async { + if (kIsWeb) return; + final stickersData = map['stickers']; + if (stickersData == null || stickersData is! List) return; + try { + final stickerDir = await fs.stickersDirectory; + for (final item in stickersData) { + if (item is Map && item['filename'] != null && item['data'] != null) { + final file = File(join(stickerDir, item['filename'])); + await file.writeAsBytes(base64Decode(item['data'])); + } + } + Logger.info('Restored ${stickersData.length} stickers from backup'); + } catch (e) { + Logger.warn('Failed to restore stickers: $e'); + } + } + @override void initState() { super.initState(); @@ -241,6 +283,7 @@ class _BackupRestorePanelState extends OptimizedState { onNo: () => Navigator.of(_context).pop(), onYes: () async { Map json = ss.settings.toMap(); + json["stickers"] = await _exportStickers(); json["description"] = item["description"]; json["timestamp"] = DateTime.now().millisecondsSinceEpoch; Response response = await http.setSettings(item["name"], json); @@ -289,10 +332,11 @@ class _BackupRestorePanelState extends OptimizedState { title: "Restore Backup?", content: const Text("Are you sure you want to restore this backup, overwriting your current Settings?"), onNo: () => Navigator.of(context).pop(), - onYes: () { + onYes: () async { Navigator.of(context).pop(); try { Settings.updateFromMap(item); + await _restoreStickers(item); showSnackbar("Success", "Settings restored successfully"); } catch (e, s) { Logger.error("Failed to restore settings backup!", error: e, trace: s); @@ -400,6 +444,7 @@ class _BackupRestorePanelState extends OptimizedState { Navigator.of(_context).pop(); } Map json = ss.settings.toMap(); + json["stickers"] = await _exportStickers(); if (desc.isNotEmpty) { json["description"] = desc; } @@ -594,12 +639,13 @@ class _BackupRestorePanelState extends OptimizedState { title: "Restore Settings?", content: const Text("Are you sure you want to restore this backup, overwriting your current Settings?"), onNo: () => Navigator.of(context).pop(), - onYes: () { + onYes: () async { Navigator.of(context).pop(); try { String jsonString = const Utf8Decoder().convert(res.files.first.bytes!); Map json = jsonDecode(jsonString); Settings.updateFromMap(json); + await _restoreStickers(json); showSnackbar("Success", "Settings restored successfully"); } catch (e, s) { Logger.error("Failed to restore settings backup!", error: e, trace: s); From 361fa7f5bf1bb6f5b6b647cd58b044ab2f04811d Mon Sep 17 00:00:00 2001 From: MrMistic Date: Thu, 16 Apr 2026 14:57:37 +0000 Subject: [PATCH 11/13] Fix sticker manager thumbnails showing wrong images after adding new stickers --- .../settings/pages/message_view/sticker_manager_panel.dart | 1 + 1 file changed, 1 insertion(+) diff --git a/lib/app/layouts/settings/pages/message_view/sticker_manager_panel.dart b/lib/app/layouts/settings/pages/message_view/sticker_manager_panel.dart index 29cd75bde..cde9cf158 100644 --- a/lib/app/layouts/settings/pages/message_view/sticker_manager_panel.dart +++ b/lib/app/layouts/settings/pages/message_view/sticker_manager_panel.dart @@ -167,6 +167,7 @@ class _StickerManagerPanelState extends OptimizedState { itemCount: _stickers.length, itemBuilder: (context, index) { return _StickerManagerTile( + key: ValueKey(_stickers[index].path), file: _stickers[index], onDelete: () => deleteSticker(_stickers[index]), ); From 0090712e979662abed0f128b41b025a0d52dedeb Mon Sep 17 00:00:00 2001 From: MrMistic Date: Thu, 16 Apr 2026 15:11:43 +0000 Subject: [PATCH 12/13] Fix: accept key parameter in _StickerManagerTile --- .../settings/pages/message_view/sticker_manager_panel.dart | 1 + 1 file changed, 1 insertion(+) diff --git a/lib/app/layouts/settings/pages/message_view/sticker_manager_panel.dart b/lib/app/layouts/settings/pages/message_view/sticker_manager_panel.dart index cde9cf158..3677064d6 100644 --- a/lib/app/layouts/settings/pages/message_view/sticker_manager_panel.dart +++ b/lib/app/layouts/settings/pages/message_view/sticker_manager_panel.dart @@ -180,6 +180,7 @@ class _StickerManagerPanelState extends OptimizedState { class _StickerManagerTile extends StatefulWidget { const _StickerManagerTile({ + super.key, required this.file, required this.onDelete, }); From 20b78c850e417da38ef5e9191df7851eaa58252f Mon Sep 17 00:00:00 2001 From: MrMistic Date: Tue, 21 Apr 2026 14:55:45 +0000 Subject: [PATCH 13/13] Add Create Sticker feature: ML Kit subject segmentation for iOS-style Visual Look Up stickers - Add ML Kit Subject Segmentation dependency (on-device, beta) - Register com.google.mlkit.vision.DEPENDENCIES meta-data for auto model download - CreateSubjectSticker.kt: native handler that runs segmentation and saves transparent PNG - Register handler in MethodCallHandler - createSubjectSticker() method in attachments_service with error handling - Create Sticker button in fullscreen viewer AppBar (top-right, styled like Done), Android-only, images-only --- android/app/build.gradle | 2 + android/app/src/main/AndroidManifest.xml | 4 + .../backend_ui_interop/MethodCallHandler.kt | 2 + .../services/system/CreateSubjectSticker.kt | 101 ++++++++++++++++++ .../fullscreen_media/fullscreen_holder.dart | 30 ++++++ lib/services/ui/attachments_service.dart | 61 +++++++++++ 6 files changed, 200 insertions(+) create mode 100644 android/app/src/main/kotlin/com/bluebubbles/messaging/services/system/CreateSubjectSticker.kt diff --git a/android/app/build.gradle b/android/app/build.gradle index bec2bfc8a..c7b681468 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -162,6 +162,8 @@ dependencies { implementation 'com.google.firebase:firebase-messaging-directboot:24.0.0' implementation 'com.google.firebase:firebase-iid:21.1.0' implementation 'com.google.firebase:firebase-firestore:25.0.0' + // ML Kit Subject Segmentation for "Create Sticker" (iOS-style Visual Look Up) + implementation 'com.google.android.gms:play-services-mlkit-subject-segmentation:16.0.0-beta1' // Kotlin Coroutines (async) implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:1.8.1" diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 3f99ba3dd..658841b6f 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -454,6 +454,10 @@ + + diff --git a/android/app/src/main/kotlin/com/bluebubbles/messaging/services/backend_ui_interop/MethodCallHandler.kt b/android/app/src/main/kotlin/com/bluebubbles/messaging/services/backend_ui_interop/MethodCallHandler.kt index e759c517f..7daf50b56 100644 --- a/android/app/src/main/kotlin/com/bluebubbles/messaging/services/backend_ui_interop/MethodCallHandler.kt +++ b/android/app/src/main/kotlin/com/bluebubbles/messaging/services/backend_ui_interop/MethodCallHandler.kt @@ -51,6 +51,7 @@ import com.bluebubbles.messaging.services.system.ConversationExemptHandler import com.bluebubbles.messaging.services.system.CreateDocumentHandler import com.bluebubbles.messaging.services.system.GetFullResolution import com.bluebubbles.messaging.services.system.GetZenMode +import com.bluebubbles.messaging.services.system.CreateSubjectSticker import com.bluebubbles.messaging.services.system.HeifDecoder import com.bluebubbles.messaging.services.system.HeifEncoder import com.bluebubbles.messaging.services.system.NativeSyncIsolateHandler @@ -130,6 +131,7 @@ class MethodCallHandler { ZenModeUUIDHandler.tag -> ZenModeUUIDHandler().handleMethodCall(call, result, context) GetZenMode.tag -> GetZenMode().handleMethodCall(call, result, context) HeifDecoder.tag -> HeifDecoder().handleMethodCall(call, result, context) + CreateSubjectSticker.tag -> CreateSubjectSticker().handleMethodCall(call, result, context) GetFullResolution.tag -> GetFullResolution().handleMethodCall(call, result, context) OpenSMSAppHandler.tag -> OpenSMSAppHandler().handleMethodCall(call, result, context) CreateDocumentHandler.tag -> CreateDocumentHandler().handleMethodCall(call, result, context) diff --git a/android/app/src/main/kotlin/com/bluebubbles/messaging/services/system/CreateSubjectSticker.kt b/android/app/src/main/kotlin/com/bluebubbles/messaging/services/system/CreateSubjectSticker.kt new file mode 100644 index 000000000..9c3b07195 --- /dev/null +++ b/android/app/src/main/kotlin/com/bluebubbles/messaging/services/system/CreateSubjectSticker.kt @@ -0,0 +1,101 @@ +package com.bluebubbles.messaging.services.system + +import android.content.Context +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.util.Log +import com.bluebubbles.messaging.Constants +import com.bluebubbles.messaging.models.MethodCallHandlerImpl +import com.google.mlkit.vision.common.InputImage +import com.google.mlkit.vision.segmentation.subject.SubjectSegmentation +import com.google.mlkit.vision.segmentation.subject.SubjectSegmenterOptions +import com.radzivon.bartoshyk.avif.coder.HeifCoder +import io.flutter.plugin.common.MethodCall +import io.flutter.plugin.common.MethodChannel +import java.io.File +import java.io.FileOutputStream + +/// Extract the foreground subject from an image and save it as a transparent-background PNG sticker. +/// Uses Google ML Kit's Subject Segmentation API (on-device, no network required after model download). +class CreateSubjectSticker : MethodCallHandlerImpl() { + companion object { + const val tag = "create-subject-sticker" + } + + override fun handleMethodCall( + call: MethodCall, + result: MethodChannel.Result, + context: Context + ) { + val inputPath: String = call.argument("file")!! + val outputPath: String = call.argument("output")!! + + try { + // Decode the input image. Prefer HeifCoder for HEIC/HEIF files since + // BitmapFactory can't handle them reliably on older Android versions. + val bitmap: Bitmap = try { + val bytes = File(inputPath).readBytes() + // HeifCoder handles HEIC/HEIF/AVIF; falls through for other formats + if (isHeifLike(inputPath)) { + HeifCoder().decode(bytes) + } else { + BitmapFactory.decodeByteArray(bytes, 0, bytes.size) + ?: throw IllegalStateException("BitmapFactory returned null") + } + } catch (e: Exception) { + Log.e(Constants.logTag, "Failed to decode image for subject segmentation: ${e.message}") + result.error("decode_failed", "Could not decode the image.", null) + return + } + + val options = SubjectSegmenterOptions.Builder() + .enableForegroundBitmap() + .build() + val segmenter = SubjectSegmentation.getClient(options) + val inputImage = InputImage.fromBitmap(bitmap, 0) + + segmenter.process(inputImage) + .addOnSuccessListener { segResult -> + val foreground = segResult.foregroundBitmap + if (foreground == null) { + Log.w(Constants.logTag, "Subject segmentation returned no foreground bitmap") + segmenter.close() + result.error("no_subject", "No subject could be detected in this image.", null) + return@addOnSuccessListener + } + try { + FileOutputStream(outputPath).use { out -> + foreground.compress(Bitmap.CompressFormat.PNG, 100, out) + } + Log.i(Constants.logTag, "Created subject sticker at $outputPath") + result.success(null) + } catch (e: Exception) { + Log.e(Constants.logTag, "Failed to write sticker PNG: ${e.message}") + result.error("write_failed", "Could not save the sticker.", e.message) + } finally { + segmenter.close() + } + } + .addOnFailureListener { e -> + Log.e(Constants.logTag, "Subject segmentation failed: ${e.message}") + segmenter.close() + // Surface model-download issues distinctly so the UI can show a helpful message. + val code = if (e.message?.contains("model", ignoreCase = true) == true + || e.message?.contains("download", ignoreCase = true) == true) { + "model_unavailable" + } else { + "segmentation_failed" + } + result.error(code, e.message ?: "Subject segmentation failed.", null) + } + } catch (e: Exception) { + Log.e(Constants.logTag, "Unexpected error in CreateSubjectSticker: ${e.message}") + result.error("unknown", e.message ?: "Unknown error", null) + } + } + + private fun isHeifLike(path: String): Boolean { + val lower = path.lowercase() + return lower.endsWith(".heic") || lower.endsWith(".heif") || lower.endsWith(".avif") + } +} diff --git a/lib/app/layouts/fullscreen_media/fullscreen_holder.dart b/lib/app/layouts/fullscreen_media/fullscreen_holder.dart index dec44c189..cd5b7fb1f 100644 --- a/lib/app/layouts/fullscreen_media/fullscreen_holder.dart +++ b/lib/app/layouts/fullscreen_media/fullscreen_holder.dart @@ -14,6 +14,7 @@ import "package:flutter/material.dart"; import 'package:flutter/services.dart'; import 'package:gesture_x_detector/gesture_x_detector.dart'; import 'package:get/get.dart'; +import 'package:universal_io/io.dart'; class FullscreenMediaHolder extends StatefulWidget { FullscreenMediaHolder({ @@ -71,6 +72,26 @@ class FullscreenMediaHolderState extends OptimizedState { super.dispose(); } + /// Whether the currently-viewed attachment is an image (not a video). + /// The Create Sticker action is only meaningful for images. + bool _currentIsImage() { + if (currentIndex < 0 || currentIndex >= attachments.length) return false; + return attachments[currentIndex].mimeStart == "image"; + } + + /// Resolve the PlatformFile for the currently-viewed attachment and run + /// subject segmentation on it to save a transparent-background sticker. + Future _createStickerFromCurrent() async { + if (!_currentIsImage()) return; + final current = attachments[currentIndex]; + final content = as.getContent(current, path: current.guid == null ? current.sourcePath : null); + if (content is! PlatformFile) { + showSnackbar('Error', 'Could not read the image yet. Try again in a moment.'); + return; + } + await as.createSubjectSticker(content); + } + @override Widget build(BuildContext context) { return TitleBarWrapper( @@ -123,6 +144,15 @@ class FullscreenMediaHolderState extends OptimizedState { systemOverlayStyle: context.theme.colorScheme.brightness == Brightness.dark ? SystemUiOverlayStyle.light : SystemUiOverlayStyle.dark, + actions: [ + if (!kIsWeb && Platform.isAndroid && widget.showInteractions && _currentIsImage()) + TextButton( + child: Text("Create Sticker", + style: context.theme.textTheme.bodyLarge! + .copyWith(color: context.theme.colorScheme.primary)), + onPressed: _createStickerFromCurrent, + ), + ], ), backgroundColor: Colors.black, body: FocusScope( diff --git a/lib/services/ui/attachments_service.dart b/lib/services/ui/attachments_service.dart index e4e88ed3e..b9051734a 100644 --- a/lib/services/ui/attachments_service.dart +++ b/lib/services/ui/attachments_service.dart @@ -10,6 +10,7 @@ import 'package:exif/exif.dart'; import 'package:file_picker/file_picker.dart' hide PlatformFile; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_image_compress/flutter_image_compress.dart'; import 'package:get/get.dart'; import 'package:image_size_getter/file_input.dart'; @@ -285,6 +286,66 @@ class AttachmentsService extends GetxService { } } + /// Extract the foreground subject from an image and save it as a + /// transparent-background PNG sticker. Android-only (uses ML Kit Subject + /// Segmentation). Mirrors iOS "Visual Look Up" sticker creation. + Future createSubjectSticker(PlatformFile file) async { + if (kIsWeb || !Platform.isAndroid) { + showSnackbar('Error', 'This feature is only available on Android.'); + return false; + } + + // We need a real file path on disk for the native side to read. + String? inputPath = file.path; + File? tempInput; + try { + if (inputPath == null) { + if (file.bytes == null) { + showSnackbar('Error', 'Could not create sticker: no file data available.'); + return false; + } + final tmpDir = await getTemporaryDirectory(); + tempInput = File(join(tmpDir.path, 'subj_in_${DateTime.now().millisecondsSinceEpoch}_${file.name}')); + await tempInput.writeAsBytes(file.bytes!); + inputPath = tempInput.path; + } + + final stickerDir = await fs.stickersDirectory; + // Output is always PNG (transparent background), even if the source was HEIC/JPEG. + final baseName = basenameWithoutExtension(file.name); + final outputPath = join(stickerDir, '${baseName}_sticker.png'); + + showSnackbar('Creating sticker', 'Extracting subject…'); + + await mcs.invokeMethod('create-subject-sticker', { + 'file': inputPath, + 'output': outputPath, + }); + + showSnackbar('Success', 'Sticker created!'); + return true; + } on PlatformException catch (e) { + final message = switch (e.code) { + 'no_subject' => 'No subject could be detected in this image.', + 'model_unavailable' => 'Sticker model is still downloading. Try again in a moment.', + 'decode_failed' => 'Could not read the image.', + 'write_failed' => 'Could not save the sticker.', + _ => e.message ?? 'Failed to create sticker.', + }; + showSnackbar('Error', message); + Logger.error('createSubjectSticker failed: ${e.code} ${e.message}', error: e); + return false; + } catch (e) { + Logger.error('createSubjectSticker failed', error: e); + showSnackbar('Error', 'Failed to create sticker.'); + return false; + } finally { + if (tempInput != null && await tempInput.exists()) { + try { await tempInput.delete(); } catch (_) {} + } + } + } + Future canAutoDownload() async { final canSave = (await Permission.storage.request()).isGranted; if (!canSave) return false;