From 3ccd6855efbebd220356458e76f4287c2afa0dd5 Mon Sep 17 00:00:00 2001 From: Watcher Date: Fri, 14 Aug 2026 15:26:38 +0100 Subject: [PATCH] Harden native custom emoji loading Co-authored-by: Watcher Signed-off-by: Watcher --- mobile/ios/Runner/NativeEmojiPicker.swift | 81 ++++++++++++++--- .../lib/features/channels/emoji_picker.dart | 1 + .../emoji_picker/ios_native_picker.dart | 10 ++- .../features/channels/emoji_picker_test.dart | 90 ++++++++++++++++++- 4 files changed, 165 insertions(+), 17 deletions(-) diff --git a/mobile/ios/Runner/NativeEmojiPicker.swift b/mobile/ios/Runner/NativeEmojiPicker.swift index c5783030b..681ca56ee 100644 --- a/mobile/ios/Runner/NativeEmojiPicker.swift +++ b/mobile/ios/Runner/NativeEmojiPicker.swift @@ -46,6 +46,7 @@ private struct NativeEmojiItem: Identifiable, Hashable { let glyph: String? let skinVariants: [String] let imageURL: URL? + let imageHeaders: [String: String] } private struct NativeEmojiSkinTone: Identifiable { @@ -161,7 +162,8 @@ private enum NativeEmojiPickerDataLoader { keywords: keywords, glyph: defaultGlyph, skinVariants: glyphs, - imageURL: nil + imageURL: nil, + imageHeaders: [:] ) items.append(item) standardItems.append(item) @@ -189,6 +191,7 @@ private enum NativeEmojiPickerDataLoader { else { return nil } + let headers = (raw["headers"] as? [String: String]) ?? [:] return NativeEmojiItem( id: "custom-\(shortcode)", shortcode: shortcode, @@ -197,7 +200,8 @@ private enum NativeEmojiPickerDataLoader { keywords: [], glyph: nil, skinVariants: [], - imageURL: url + imageURL: url, + imageHeaders: headers ) } let customByValue = Dictionary( @@ -666,17 +670,11 @@ private struct NativeEmojiPickerView: View { } label: { Group { if let url = item.imageURL { - AsyncImage(url: url) { phase in - switch phase { - case .success(let image): - image.resizable().scaledToFit() - case .failure: - Image(systemName: "sparkles") - .foregroundStyle(Color(uiColor: appearance.secondaryText)) - default: - ProgressView().controlSize(.mini) - } - } + NativeEmojiRemoteImage( + url: url, + headers: item.imageHeaders, + fallbackColor: appearance.secondaryText + ) .frame(width: 28, height: 28) } else { Text(value).font(.system(size: 28)) @@ -699,6 +697,63 @@ private struct NativeEmojiPickerView: View { } } +private struct NativeEmojiRemoteImage: View { + let url: URL + let headers: [String: String] + let fallbackColor: UIColor + + @State private var phase: Phase = .loading + + private enum Phase { + case loading + case success(UIImage) + case failure + } + + var body: some View { + Group { + switch phase { + case .loading: + ProgressView().controlSize(.mini) + case .success(let image): + Image(uiImage: image).resizable().scaledToFit() + case .failure: + Image(systemName: "sparkles") + .foregroundStyle(Color(uiColor: fallbackColor)) + } + } + .task(id: requestIdentity) { + var request = URLRequest(url: url) + for (name, value) in headers { + request.setValue(value, forHTTPHeaderField: name) + } + do { + let (data, response) = try await URLSession.shared.data(for: request) + guard + let httpResponse = response as? HTTPURLResponse, + (200..<300).contains(httpResponse.statusCode), + let image = UIImage(data: data) + else { + phase = .failure + return + } + phase = .success(image) + } catch { + if !Task.isCancelled { phase = .failure } + } + } + } + + private var requestIdentity: String { + let headerIdentity = + headers + .sorted { $0.key < $1.key } + .map { "\($0.key):\($0.value)" } + .joined(separator: "\n") + return "\(url.absoluteString)\n\(headerIdentity)" + } +} + final class NativeEmojiPickerCoordinator: NSObject, UIAdaptivePresentationControllerDelegate { diff --git a/mobile/lib/features/channels/emoji_picker.dart b/mobile/lib/features/channels/emoji_picker.dart index 264624b00..81d889646 100644 --- a/mobile/lib/features/channels/emoji_picker.dart +++ b/mobile/lib/features/channels/emoji_picker.dart @@ -14,6 +14,7 @@ import '../../shared/emoji/emoji_data.dart'; import '../../shared/emoji/emoji_data_provider.dart'; import '../../shared/emoji/emoji_search.dart'; import '../../shared/emoji/native_emoji_glyph.dart'; +import '../../shared/relay/relay.dart'; import '../../shared/theme/theme.dart'; import '../../shared/widgets/buzz_sheet_header.dart'; import '../../shared/widgets/modal_presentation.dart'; diff --git a/mobile/lib/features/channels/emoji_picker/ios_native_picker.dart b/mobile/lib/features/channels/emoji_picker/ios_native_picker.dart index 4b2a8dac3..b5f2f93e1 100644 --- a/mobile/lib/features/channels/emoji_picker/ios_native_picker.dart +++ b/mobile/lib/features/channels/emoji_picker/ios_native_picker.dart @@ -8,8 +8,10 @@ Future _presentIosEmojiPicker({ VoidCallback? onDismiss, }) async { final container = ProviderScope.containerOf(context, listen: false); - final customEmoji = container.read(customEmojiListProvider); + final customEmoji = await container.read(customEmojiPaletteProvider.future); + if (!context.mounted) return; final recent = container.read(recentEmojiProvider); + final mediaAuth = container.read(mediaGetAuthServiceProvider); final prefs = container.read(savedPrefsProvider); final colors = context.colors; var dismissed = false; @@ -45,7 +47,11 @@ Future _presentIosEmojiPicker({ { 'customEmoji': [ for (final emoji in customEmoji) - {'shortcode': emoji.shortcode, 'url': emoji.url}, + { + 'shortcode': emoji.shortcode, + 'url': emoji.url, + 'headers': mediaAuth.headersFor(emoji.url), + }, ], 'recent': [for (final entry in recent) entry.emoji], 'skinTone': _validSkinTone(prefs.getInt(_emojiSkinTonePrefsKey)), diff --git a/mobile/test/features/channels/emoji_picker_test.dart b/mobile/test/features/channels/emoji_picker_test.dart index 4635f1f8b..4fedd99aa 100644 --- a/mobile/test/features/channels/emoji_picker_test.dart +++ b/mobile/test/features/channels/emoji_picker_test.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:buzz/features/channels/emoji_picker.dart'; import 'package:buzz/features/channels/recent_emoji_provider.dart'; import 'package:buzz/shared/custom_emoji/custom_emoji.dart'; @@ -12,6 +14,7 @@ import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:shared_preferences/shared_preferences.dart'; +import 'package:nostr/nostr.dart' as nostr; import '../../helpers/widget_helpers.dart'; @@ -126,6 +129,21 @@ final _tallDataset = () { const _customEmoji = [ CustomEmoji(shortcode: 'partyparrot', url: 'https://example.test/parrot.gif'), ]; +const _relayCustomEmoji = [ + CustomEmoji( + shortcode: 'buzzbee', + url: 'https://relay.example/media/buzzbee.png', + ), +]; + +class _FakeCustomEmojiPaletteNotifier extends CustomEmojiPaletteNotifier { + _FakeCustomEmojiPaletteNotifier(this.palette); + + final Future> palette; + + @override + Future> build() => palette; +} const _nativeEmojiPickerChannel = MethodChannel('buzz/native_emoji_picker'); @@ -604,6 +622,10 @@ void main() { final previousPlatform = debugDefaultTargetPlatformOverride; debugDefaultTargetPlatformOverride = TargetPlatform.iOS; final prefs = await _prefs(); + final mediaAuth = MediaGetAuthService( + baseUrl: 'https://relay.example', + nsec: nostr.Keys.generate().nsec, + ); MethodCall? presentation; final selected = []; var dismissals = 0; @@ -618,7 +640,12 @@ void main() { overrides: [ savedPrefsProvider.overrideWithValue(prefs), myPubkeyProvider.overrideWithValue('self'), - customEmojiListProvider.overrideWithValue(_customEmoji), + customEmojiPaletteProvider.overrideWith( + () => _FakeCustomEmojiPaletteNotifier( + Future.value([..._customEmoji, ..._relayCustomEmoji]), + ), + ), + mediaGetAuthServiceProvider.overrideWithValue(mediaAuth), ], child: MaterialApp( theme: AppTheme.light(), @@ -649,6 +676,12 @@ void main() { { 'shortcode': 'partyparrot', 'url': 'https://example.test/parrot.gif', + 'headers': {}, + }, + { + 'shortcode': 'buzzbee', + 'url': 'https://relay.example/media/buzzbee.png', + 'headers': {'Authorization': startsWith('Nostr ')}, }, ]); expect(find.byType(EmojiPickerSheet), findsNothing); @@ -665,6 +698,56 @@ void main() { } }); + testWidgets('awaits the custom emoji palette before presenting', ( + tester, + ) async { + final previousPlatform = debugDefaultTargetPlatformOverride; + debugDefaultTargetPlatformOverride = TargetPlatform.iOS; + final prefs = await _prefs(); + final palette = Completer>(); + MethodCall? presentation; + _setMockNativeEmojiPickerHandler((call) async { + presentation = call; + return true; + }); + + try { + await tester.pumpWidget( + ProviderScope( + overrides: [ + savedPrefsProvider.overrideWithValue(prefs), + customEmojiPaletteProvider.overrideWith( + () => _FakeCustomEmojiPaletteNotifier(palette.future), + ), + ], + child: MaterialApp( + theme: AppTheme.light(), + home: Scaffold( + body: Builder( + builder: (context) => FilledButton( + onPressed: () => + showEmojiPicker(context: context, onSelect: (_) {}), + child: const Text('Open picker'), + ), + ), + ), + ), + ), + ); + + await tester.tap(find.text('Open picker')); + await tester.pump(); + expect(presentation, isNull); + + palette.complete(_customEmoji); + await tester.pumpAndSettle(); + expect(presentation?.method, 'present'); + } finally { + _setMockNativeEmojiPickerHandler(null); + debugDefaultTargetPlatformOverride = previousPlatform; + } + }); + testWidgets('falls back to the Flutter picker when native cannot present', ( tester, ) async { @@ -680,7 +763,10 @@ void main() { savedPrefsProvider.overrideWithValue(prefs), myPubkeyProvider.overrideWithValue('self'), emojiDatasetOrEmptyProvider.overrideWithValue(_dataset), - customEmojiListProvider.overrideWithValue(_customEmoji), + customEmojiPaletteProvider.overrideWith( + () => + _FakeCustomEmojiPaletteNotifier(Future.value(_customEmoji)), + ), ], child: MaterialApp( theme: AppTheme.light(),