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/android/app/build.gradle b/android/app/build.gradle
index bec2bfc8ab..c7b6814685 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 3f99ba3dd9..658841b6fb 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 e759c517fa..7daf50b568 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 0000000000..9c3b071959
--- /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/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 de58951a57..219bd40eea 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/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