diff --git a/.github/workflows/build-test.yml b/.github/workflows/build-test.yml new file mode 100644 index 0000000000..d82f5faf70 --- /dev/null +++ b/.github/workflows/build-test.yml @@ -0,0 +1,80 @@ +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 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 + + 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 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..b927c64428 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,11 @@ class _ChatIconAndTitleState extends CustomState<_ChatIconAndTitle, void, Conver title = controller.chat.getTitle(); cachedGuid = controller.chat.guid; + // --- FindMy integration --- + if (ss.settings.showLocationInChat.value) { + fetchShortAddress(); + } + // run query after render has completed if (!kIsWeb) { updateObx(() { @@ -522,6 +537,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(); @@ -536,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( @@ -569,6 +645,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..079d61161e 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,8 +279,25 @@ class AttachmentPickerState extends OptimizedState { } } }, + { + "icon": iOS ? CupertinoIcons.smiley : Icons.emoji_emotions_outlined, + "text": "Stickers", + "handle": () { + setState(() { + showStickerPicker = true; + }); + } + }, ]; + // 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; @@ -386,6 +405,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/app/layouts/settings/pages/message_view/attachment_panel.dart b/lib/app/layouts/settings/pages/message_view/attachment_panel.dart index 99ab8326cd..2254c90c48 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/attachment_picker_order_panel.dart b/lib/app/layouts/settings/pages/message_view/attachment_picker_order_panel.dart new file mode 100644 index 0000000000..0713312a82 --- /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 39e54ad940..3ae9cc520e 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) @@ -197,6 +212,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/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 0000000000..29cd75bded --- /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), + ), + ), + ), + ], + ), + ); + } +} 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 fa0912439c..bba083dedc 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))); } diff --git a/lib/database/global/settings.dart b/lib/database/global/settings.dart index 5fe58fdd4a..42ccf289a4 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(); @@ -185,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); @@ -356,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, @@ -419,6 +427,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, @@ -588,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 @@ -598,6 +608,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'] ?? ""; @@ -763,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 @@ -773,6 +785,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'] ?? ""; @@ -877,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); + } +} 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;